From e1b9b539e4233e824ddf104aedd2ccd84a0c80a1 Mon Sep 17 00:00:00 2001 From: Atlas Zeng Date: Fri, 4 Sep 2026 17:33:42 +0800 Subject: [PATCH 1/5] Revise version details and update QR codes Updated version information and fixed QR code image links in README. --- README.md | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 14b96e7..1908aa7 100644 --- a/README.md +++ b/README.md @@ -267,12 +267,6 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar > 📌 **正式版**(npm `latest`):本版仅支持 **DSH 0.1.2-rc.1+**(peer 下限 `^0.1.2-rc.1`);不再支持 0.1.0-rc.8 ~ 0.1.1-rc.2——DSH stable 用户请固定安装 `dsh-better-sidebar@0.17.1`,停留在 0.1.2-alpha.x 的宿主继续用 `dsh-better-sidebar@alpha`(v0.18.0-alpha.0)。 -自 v0.17.1 以来的全部更改(v0.18.1-alpha.0 / v0.19.0-alpha.0 两个中间版本号未发布,内容一并并入本版): - -**🔗 宿主线毕业正式版** - -- **进入 DSH 0.1.2 线并毕业**:v0.18.0-alpha.0 删除 0.1.1-rc.x 兼容层、修复 alpha.1+ 侧边对话转录空白(#472);基线经 alpha.3(#497)、alpha.5(#516,`Session.events` → `snapshotEvents()`)逐步上行;聊天文件打开漏斗迁移 `remote.session.openWorkspacePath`(#494)、「Show in folder」reveal 滚动限界(#453)。本版钉版基线 **DSH 0.1.2-rc.1**(相对 alpha.5 零源码变化、纯版本升级;`dsh-client-locale` 恢复发版,全线对齐 rc.1;CI 真机挂载冒烟 14/14) - **✨ 新功能** - 🌿 **「文件变动」统一 tab**(#475):Git 视角(真 diff / 历史 / 暂存·提交·还原 / worktree·子仓库选择)与本轮文件视角(模型读/写/编辑实时追踪)双视角合一;统一 diff 渲染(改蓝配对 + 行内字符级高亮 + 语法着色 + 上下文折叠)、底部可拖拽预览面板、一键展开独立 diff tab @@ -298,6 +292,9 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar - 新收录 10+ 插件:dsh-better-sidebar-icons(#441)、dsh-sidenote(#451,原 dsh-sidechat #470)、dsh-github-workbench(#410)、dsh-bilingual-reader(#379)、dsh-server-deck(#413)、dsh-md-export(#405)、dsh-code-nav(#404)、dsh-suhuang-scroll(#392)、dsh-better-overleaf(#370)等(均含 18+ 语言 i18n 补齐) +
+历史版本(v0.12.0 – v0.15.2) + ### v0.19.0-alpha.0 > 🧪 **alpha 通道**:本版仅支持 **DSH 0.1.2-alpha.x**(peer 下限 `^0.1.2-alpha.5`,npm dist-tag `alpha`,安装 `dsh-better-sidebar@alpha`)。该版本号未单独发布,内容已并入 **v0.18.0** 正式版。 @@ -370,9 +367,6 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar - 🔧 **git 源安装修复 `unrun` devDependency**([#336](https://github.com/omdsh-dev/DSH-better-sidebar/pull/336)):tsdown 0.22 经 `unrun` 加载配置而 pnpm 11 不自动装 peer——git-hosted 安装的 `prepare` 不再报 `Failed to import module "unrun"`(npm tarball 不受影响) - 🍃 **`ctx.effect` 严格化顺手修了 4 处**:拦截注册失败时 effect 体返回 `undefined` 改为 no-op disposer(vendored cordis 的 effect 契约要求返回 disposer,返回 `undefined` 属非法形状) -
-历史版本(v0.12.0 – v0.15.2) - ### v0.15.2 自 v0.15.1 以来的全部更改: @@ -491,7 +485,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar 推荐添加QQ群(577011007)
- 微信群二维码 + 微信群二维码 QQ群二维码
From 76b57508f41c7e8bc5b9c5babc7f35ba5e605dbe Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Fri, 4 Sep 2026 20:28:35 +0800 Subject: [PATCH 2/5] fix(ui): compact context menus + viewport-clamped submenu flip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 减重:全部右键菜单(FileTree 行菜单 / TabBar tab 菜单 / GitLens 文件与 历史菜单 / FreeWindow 标题菜单)与「+」菜单切到宿主 Menu 原语的 compact 密度(26px 行高 / 12px 字 / 164px 卡片),图标随槽位 16→14,「打开方式」 子菜单图钉 20→16,还原工具型右键菜单的趁手感。 越界:宿主子菜单纯 CSS 定位(从父行向上、向右生长)不受视口钳制——树顶 部右键时「打开方式 ▸」子菜单伸出视口上沿,窄面板(280–400px)时伸出右 沿。新增 menu-flip.ts:菜单打开期间在 发布 data-dsh-sidebar-submenu ="down|left" 方向 token(y < vh/2 → down;x + 400 > vw → left),layout.css 据此翻转子菜单生长方向与 ::before 悬停桥;属性存续期即作用域,宿主自有 菜单不受影响。刻意不做 max-height 滚动钳制——overflow 裁剪会切断悬停桥。 测试:menu-flip.spec.tsx(token 纯函数 + hook 生命周期 + CSS 契约跨文件 一致性);tab-bar / file-tree 两个 spec 追加翻转向量断言;mount.e2e 追加 compact 行高(≤30px)布局级证据。设计偏差记录同步至 docs/plans/2026-08-22-open-with-menu-design.md。 --- .../plans/2026-08-22-open-with-menu-design.md | 9 +- src/client/FileTree.tsx | 34 +++--- src/client/FreeWindow.tsx | 1 + src/client/TabBar.tsx | 6 + src/client/changes/GitLens.tsx | 2 + src/client/layout.css | 37 ++++++ src/client/menu-flip.ts | 68 +++++++++++ src/client/sidebar.module.css | 9 +- src/client/sidebar/TabContent.tsx | 3 +- tests/e2e/mount.e2e.ts | 7 ++ tests/file-tree-open-with.spec.tsx | 42 +++++++ tests/menu-flip.spec.tsx | 110 ++++++++++++++++++ tests/tab-bar-context-menu.spec.tsx | 58 +++++++++ 13 files changed, 366 insertions(+), 20 deletions(-) create mode 100644 src/client/menu-flip.ts create mode 100644 tests/menu-flip.spec.tsx diff --git a/docs/plans/2026-08-22-open-with-menu-design.md b/docs/plans/2026-08-22-open-with-menu-design.md index 7666581..4d560c4 100644 --- a/docs/plans/2026-08-22-open-with-menu-design.md +++ b/docs/plans/2026-08-22-open-with-menu-design.md @@ -89,6 +89,8 @@ Cursor (SSH) **实施偏差(2026-09,#517 / PR #522)**:SSH 远程编辑器链接**不再经宿主路由执行**。DSH 部署在无头远端服务器时,宿主侧 `xdg-open` 无 DISPLAY/无编辑器,`vscode://` 静默失败。`api.openExternal`(`src/client/api.ts`)现把 `://vscode-remote/ssh-remote+…` 形态的 URL 在浏览器客户端同步触发 `window.location.assign`(处于用户点击链内,外部协议交给本机编辑器经 Remote-SSH 打开远端文件);reveal 与本地编辑器 URL 仍走本节宿主路由。普通浏览器可处理自定义协议;禁止/未处理 `vscode://` 的 WebView 壳客户端需各自适配(见 #517 补充信息)。 +**实施偏差(2026-09,右键菜单减重与子菜单视口钳制)**:本菜单(及 TabBar / GitLens / FreeWindow 右键菜单、「+」菜单)整体切到 primitives `Menu` 的 **`compact` 密度**(行高 26px / 12px 字 / 164px 卡片 / 7px 圆角),菜单图标随槽位 16→14、子菜单图钉 20→16。同时发现宿主子菜单**不受视口钳制**(`bottom:-4px` 从父行向上生长、`left:calc(100%+10px)` 向右展开,主列表的钳制与 `max-height` 收缩均不覆盖它):树顶部右键时本子菜单伸出视口上沿,窄面板(280–400px)时伸出右沿。修复为插件侧方案 `src/client/menu-flip.ts`——菜单打开期间在 `` 发布 `data-dsh-sidebar-submenu="down|left"` 方向 token(`y < vh/2` → down;`x + 400 > vw` → left,400 镜像宿主几何 218 卡片 + 10 间隙 + 165 子菜单 + 12 边距),`layout.css` 据此翻转子菜单生长方向与 `::before` 悬停桥;**属性的存续期即作用域**(仅我们的菜单打开时存在),宿主自有菜单不受影响。**不做** `max-height` + 滚动钳制:overflow 裁剪会切掉子菜单 `::before` 悬停桥(跨 10px 间隙会触发 mouseLeave 关卡)。残留见「已知限制」末两条。 + ## 已知限制 - 路径含 `#`/`?` 的文件名经 URL 打开可能被浏览器当作 fragment/query(不处理,注释说明)。 @@ -96,13 +98,18 @@ Cursor (SSH) - 自定义编辑器仅支持 URL 模板式,不支持 CLI 命令式;无 `{dir}` 占位符。 - Linux reveal 退化为打开所在目录(精确 select 需要各文件管理器私有协议)。 - SSH 模式为**全局**(非每会话):DSH 无远程会话概念,文件树路径即宿主路径;该模型对应"DSH 跑在远端开发机上"的场景。 +- 子菜单钳制为方向翻转而非高度裁剪:约 >15 个打开方式目标 + 矮视口 + 不利光标位仍可能溢出(compact 密度已把常规 4–8 目标场景收进安全区)。 +- 翻转阈值镜像宿主 `Menu.module.css` 几何常量(218/10/165/12):宿主未来改尺寸时阈值偏保守(多翻一次方向),不会产生新越界;菜单打开期间窗口 resize 不重算 token,该次打开最坏退化为原始行为。 ## 测试 - `tests/open-with.spec.ts`:解析容错、目标解析与 SSH 过滤、URL 构建(本地/SSH/custom/坏模板)、校验器。 - `tests/open-external.spec.ts`:三平台命令表、URL 校验、spawn 前校验。 - `tests/open-external-client.spec.ts`(#522):SSH remote URL 客户端分流(不触 fetch)、本地/reveal/http(s) 留宿主、导航抛错 reject。 -- `tests/file-tree-open-with.spec.tsx`(jsdom):右键 → 子菜单/图钉;pin 不选中不关闭;选子项回调关闭菜单;SSH 标签后缀;未接线隐藏。 +- `tests/file-tree-open-with.spec.tsx`(jsdom):右键 → 子菜单/图钉;pin 不选中不关闭;选子项回调关闭菜单;SSH 标签后缀;未接线隐藏;翻转向量(down / down left / 卸载清理)。 +- `tests/menu-flip.spec.tsx`:方向 token 纯函数(四象限、边界、窄面板)+ hook 属性生命周期 + layout.css 选择器与属性名的跨文件一致性。 +- `tests/tab-bar-context-menu.spec.tsx`:追加翻转向量(顶部条 down、右缘 down left、下半空 token、卸载清理)。 +- `tests/e2e/mount.e2e.ts`:「+」菜单首行 computed 高度 ≤30px(compact 密度的布局级证据)。 - `tests/open-with-settings.spec.tsx`:SSH 输入、添加/删除(删除剪枝 pinned)、无效提示、VSCode 系开关。 - `tests/side-card-section.spec.tsx`:`SettingsBody` 行列表 + render 共存、仅 render 时不变。 - `tests/builtins.spec.ts`:editor 描述符断言补 `settings.render`。 diff --git a/src/client/FileTree.tsx b/src/client/FileTree.tsx index 9f0a7dc..ac3dbd6 100644 --- a/src/client/FileTree.tsx +++ b/src/client/FileTree.tsx @@ -32,6 +32,7 @@ import { VscFile, VscFolder, VscFolderOpened, VscLinkExternal, VscPin, VscPinned import { api, downloadUrl, isOutsideWorkspaceMessage, type FsEntry } from './api.ts' import { FenceErrorNotice } from './FenceErrorNotice.tsx' import { IconUploadOutline16, IconVscode16 } from './icons.tsx' +import { useSubmenuFlip } from './menu-flip.ts' import type { OpenWithTarget } from './open-with.ts' import { relativeTo } from './paths.ts' import { t } from './locales.ts' @@ -147,6 +148,9 @@ export function FileTree(props: { const [copiedPath, setCopiedPath] = useState(null) /** Open context menu: the row path (and whether it is a directory) plus the cursor position. */ const [rowMenu, setRowMenu] = useState<{ path: string; isDir: boolean; x: number; y: number } | null>(null) + // The row menu's "open with" submenu is the one submenu that can tower past + // the viewport; publish its flip geometry for layout.css while it is open. + useSubmenuFlip(rowMenu) /** Whether a file drag hovers the tree (drives the portaled drop zone). */ const [dropOver, setDropOver] = useState(false) /** The directory a drag is hovering right now (null = body, drop to root). */ @@ -373,13 +377,14 @@ export function FileTree(props: { if (openWithTargets === undefined || onOpenWith === undefined || openWithTargets.length === 0) return [] const pinnedIds = openWithPinned ?? [] /** Brand marks for the built-ins (monochrome silhouettes, currentColor); - * reveal gets the folder glyph, custom editors a generic code mark. */ + * reveal gets the folder glyph, custom editors a generic code mark. + * The compact menu's icon slot is 14px, so every mark renders at 14. */ const itemIcon = (target: OpenWithTarget): ReactNode => { - if (target.kind === 'reveal') return - if (target.id === 'vscode') return - if (target.id === 'cursor') return - if (target.id === 'zed') return - return + if (target.kind === 'reveal') return + if (target.id === 'vscode') return + if (target.id === 'cursor') return + if (target.id === 'zed') return + return } const pinned = openWithTargets .filter(target => pinnedIds.includes(target.id)) @@ -412,7 +417,7 @@ export function FileTree(props: { onToggleOpenWithPin?.(target.id) }} > - {pinnedNow ? : } + {pinnedNow ? : } ), @@ -433,7 +438,7 @@ export function FileTree(props: { ), - icon: , + icon: , submenu, }, ] @@ -650,22 +655,22 @@ export function FileTree(props: { items={[ // The open escapes head the FILE menu (dirs only get copy). ...(rowMenu?.isDir === false && onOpenFileNewTab !== undefined - ? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: }] + ? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: }] : []), ...(rowMenu?.isDir === false && onOpenFileSide !== undefined - ? [{ id: 'open-side', label: t('openFileSide'), icon: }] + ? [{ id: 'open-side', label: t('openFileSide'), icon: }] : []), ...openWithEntries(), // Download applies to files only (the host route refuses directories). ...(rowMenu?.isDir === false - ? [{ id: 'download', label: t('download'), icon: }] + ? [{ id: 'download', label: t('download'), icon: }] : []), // Upload into a directory (incl. the workspace root row). ...(rowMenu?.isDir === true - ? [{ id: 'upload-here', label: t('uploadHere'), icon: }] + ? [{ id: 'upload-here', label: t('uploadHere'), icon: }] : []), - { id: 'relative', label: t('copyRelative'), icon: }, - { id: 'absolute', label: t('copyAbsolute'), icon: }, + { id: 'relative', label: t('copyRelative'), icon: }, + { id: 'absolute', label: t('copyAbsolute'), icon: }, ]} onSelect={(id) => { const target = rowMenu @@ -698,6 +703,7 @@ export function FileTree(props: { ) }} portal + compact align="start" getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))} anchor={} diff --git a/src/client/FreeWindow.tsx b/src/client/FreeWindow.tsx index 6b37a6d..6ded906 100644 --- a/src/client/FreeWindow.tsx +++ b/src/client/FreeWindow.tsx @@ -279,6 +279,7 @@ export function FreeWindow(props: { else if (id === 'close') onClose() }} portal + compact align="start" getAnchorRect={() => (menu === null ? null : new DOMRect(menu.x, menu.y, 0, 0))} anchor={} diff --git a/src/client/TabBar.tsx b/src/client/TabBar.tsx index 586031b..bbffc13 100644 --- a/src/client/TabBar.tsx +++ b/src/client/TabBar.tsx @@ -16,6 +16,7 @@ import { import type { SidebarTab } from './state.ts' import { isAgentTabId } from './state.ts' import { isPinnedVirtualTab } from './pinned.ts' +import { useSubmenuFlip } from './menu-flip.ts' import { IconPinOutline16 } from './icons.tsx' import { t } from './locales.ts' import css from './sidebar.module.css' @@ -90,6 +91,9 @@ export function TabBar(props: { // The tab right-click context menu: the target tab plus the cursor // position (the portaled Menu anchors there, following the git lens/FileTree). const [tabMenu, setTabMenu] = useState<{ tabId: string; x: number; y: number } | null>(null) + // The strip sits at the panel's top, so the pin submenu must grow downward; + // the flip hook derives that from the cursor y (upper half → "down"). + useSubmenuFlip(tabMenu) const [dragOver, setDragOver] = useState(false) const listRef = useRef(null) // The context target's index in the render-time tab snapshot; -1 when the @@ -265,6 +269,7 @@ export function TabBar(props: { setMenuOpen(false) }} portal + compact align="end" anchor={( + + )}
}, { id: 'absolute', label: t('copyAbsolute'), icon: }, + // Explorer mutations close the menu; the workspace ROOT row is the + // session itself — never renamable or deletable (server double-guards). + ...(rowMenu !== null && rowMenu.path !== cwd + ? [ + { id: 'mutate-sep', type: 'separator' } as MenuEntry, + { id: 'rename', label: t('rename'), icon: }, + { id: 'delete', label: t('delete'), icon: , danger: true }, + ] + : []), ]} onSelect={(id) => { const target = rowMenu @@ -697,6 +846,14 @@ export function FileTree(props: { fileInputRef.current?.click() return } + if (id === 'rename') { + setRenaming({ path: target.path, value: baseName(target.path) }) + return + } + if (id === 'delete') { + setConfirmDelete({ path: target.path, isDir: target.isDir, name: baseName(target.path) }) + return + } copyPath( id === 'relative' ? relativeTo(cwd ?? '', target.path) : target.path, target.path, @@ -708,6 +865,36 @@ export function FileTree(props: { getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))} anchor={} /> + + {/* The delete confirmation: destructive and permanent (no host trash), + so it always lands here first — the same Cancel/Confirm shape the + git lens uses for discard/revert/cherry-pick. */} + { setConfirmDelete(null) }} + title={confirmDelete === null ? '' : t('deleteTitle', { name: confirmDelete.name })} + closeLabel={t('cancel')} + footer={( + <> + + + + )} + > +

+ {confirmDelete === null ? '' : t(confirmDelete.isDir ? 'deleteDescDir' : 'deleteDescFile')} +

+
) } diff --git a/src/client/TreePanel.tsx b/src/client/TreePanel.tsx index 942291f..e0b0309 100644 --- a/src/client/TreePanel.tsx +++ b/src/client/TreePanel.tsx @@ -63,11 +63,15 @@ export function TreePanel(props: { onOpenWith?: (targetId: string, path: string) => void onToggleOpenWithPin?: (targetId: string) => void onReferenceFile: (path: string, isDir: boolean) => void + /** A tree rename landed (passed through to FileTree for tab retargeting). */ + onPathRenamed?: (oldPath: string, newPath: string) => void + /** A tree delete landed (passed through to FileTree for tab closing). */ + onPathDeleted?: (path: string, isDir: boolean) => void /** Full-window presentation: the panel fills its host instead of docking * at a fixed width. */ full?: boolean }) { - const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, full } = props + const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, full } = props const [query, setQuery] = useState('') const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null) const [error, setError] = useState(null) @@ -252,6 +256,8 @@ export function TreePanel(props: { onOpenWith={onOpenWith} onToggleOpenWithPin={onToggleOpenWithPin} onReferenceFile={onReferenceFile} + onPathRenamed={onPathRenamed} + onPathDeleted={onPathDeleted} refreshTick={refreshTick} onUploadRequest={startUpload} busy={busy} diff --git a/src/client/api.ts b/src/client/api.ts index e780f28..3235a6f 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -278,6 +278,15 @@ export const api = { call('fs.read', scopePayload(scope, { path }), signal), fsWrite: (scope: SessionScope, path: string, content: string) => call<{ ok: true }>('fs.write', scopePayload(scope, { path, content })), + /** Rename one tree row within its directory (single-segment name; the + * server refuses existing destinations, the workspace root, and — while + * the fence is armed — anything resolving outside the workspace). */ + fsRename: (scope: SessionScope, path: string, name: string) => + call<{ path: string }>('fs.rename', scopePayload(scope, { path, name })), + /** Permanently delete one tree row (recursive for directories; a symlink + * row unlinks the link only). The UI confirms before calling this. */ + fsRemove: (scope: SessionScope, path: string) => + call<{ path: string }>('fs.remove', scopePayload(scope, { path })), /** Upload one file's raw bytes into `dir` (keeps the folder tree via * `relativePath`); the host streams it under the session workspace. */ uploadFile: (scope: SessionScope, dir: string, relativePath: string, body: Blob, signal?: AbortSignal) => diff --git a/src/client/ime-guard.ts b/src/client/ime-guard.ts index 1ee5b40..9cfdca8 100644 --- a/src/client/ime-guard.ts +++ b/src/client/ime-guard.ts @@ -25,9 +25,12 @@ * legacy signal engines emit without isComposing. */ -/** The pure decision: is this keyboard event part of an IME composition? */ -export function isImeComposition(event: { isComposing: boolean; keyCode: number }): boolean { - return event.isComposing || event.keyCode === 229 +/** The pure decision: is this keyboard event part of an IME composition? + * `isComposing` is optional on the input: React's synthetic KeyboardEvent + * type does not declare it (the DOM event always carries it), and the + * keyCode 229 fallback covers exactly those callers. */ +export function isImeComposition(event: { isComposing?: boolean; keyCode: number }): boolean { + return event.isComposing === true || event.keyCode === 229 } /** diff --git a/src/client/layout.css b/src/client/layout.css index 0e0d923..f497840 100644 --- a/src/client/layout.css +++ b/src/client/layout.css @@ -131,8 +131,31 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-center-col], * closes on the other's outside pointerdown). * * No max-height/overflow scroll cap here on purpose: overflow clipping - * would cut the submenu's ::before hover bridge (the 10px corridor that - * keeps itemWrap's mouseleave from closing the card mid-crossing). */ + * would cut the submenu's ::before hover bridge (the corridor that keeps + * itemWrap's mouseleave from closing the card mid-crossing). */ + +/* Visual tether, part 1 — the parent row stays lit while its submenu is + * open. The primitive only styles :hover, so once the pointer crossed into + * the side card the parent row's fill cleared and the floating card read as + * unrelated. The Menu marks the open parent row with aria-expanded="true" + * (host-rendered, stable), so the same hover token re-applies for the + * submenu's whole lifetime. */ +body[data-dsh-sidebar-submenu] div[role="menu"] button[aria-expanded="true"] { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* Visual tether, part 2 — the side card hugs its parent (10px → 4px gap), + * with the ::before hover corridor narrowed to match. Ordered BEFORE the + * flip overrides below so their left/right flips still win. */ +body[data-dsh-sidebar-submenu] div[role="menu"] div[role="menu"] { + left: calc(100% + 4px); +} + +body[data-dsh-sidebar-submenu] div[role="menu"] div[role="menu"]::before { + left: -4px; + width: 4px; +} + body[data-dsh-sidebar-submenu~="down"] div[role="menu"] div[role="menu"] { top: -4px; bottom: auto; @@ -140,11 +163,11 @@ body[data-dsh-sidebar-submenu~="down"] div[role="menu"] div[role="menu"] { body[data-dsh-sidebar-submenu~="left"] div[role="menu"] div[role="menu"] { left: auto; - right: calc(100% + 10px); + right: calc(100% + 4px); } /* The hover bridge follows the card to the left side of the parent row. */ body[data-dsh-sidebar-submenu~="left"] div[role="menu"] div[role="menu"]::before { left: auto; - right: -10px; + right: -4px; } diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts index 79dbd85..0862344 100644 --- a/src/client/locales-ar.ts +++ b/src/client/locales-ar.ts @@ -199,6 +199,13 @@ export const ar: Record = { addToConversation: 'إضافة إلى المحادثة', copyRelative: 'نسخ المسار النسبي', copyAbsolute: 'نسخ المسار المطلق', + rename: 'إعادة تسمية', + renameInvalid: 'لا يمكن أن يكون الاسم فارغًا أو يحتوي على فواصل مسار.', + delete: 'حذف', + deleteTitle: 'حذف «{name}»؟', + deleteDescFile: 'سيتم حذف هذا الملف نهائيًا. لا يمكن التراجع عن هذا الإجراء.', + deleteDescDir: 'سيتم حذف هذا الدليل وكل محتوياته نهائيًا. لا يمكن التراجع عن هذا الإجراء.', + dismiss: 'إغلاق', download: 'تنزيل', uploadFiles: 'رفع ملفات', uploadFolder: 'رفع مجلد', diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts index a3c022c..7a9419f 100644 --- a/src/client/locales-de.ts +++ b/src/client/locales-de.ts @@ -184,6 +184,13 @@ export const de: Record = { addToConversation: 'Zur Unterhaltung hinzufügen', copyRelative: 'Relativen Pfad kopieren', copyAbsolute: 'Absoluten Pfad kopieren', + rename: 'Umbenennen', + renameInvalid: 'Der Name darf nicht leer sein und keine Pfadtrenner enthalten.', + delete: 'Löschen', + deleteTitle: '„{name}“ löschen?', + deleteDescFile: 'Dadurch wird die Datei dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.', + deleteDescDir: 'Dadurch werden das Verzeichnis und dessen gesamter Inhalt dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.', + dismiss: 'Schließen', download: 'Herunterladen', uploadFiles: 'Dateien hochladen', uploadFolder: 'Ordner hochladen', diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts index ffc56bc..b11cb3f 100644 --- a/src/client/locales-fr.ts +++ b/src/client/locales-fr.ts @@ -191,6 +191,13 @@ export const fr: Record = { addToConversation: 'Ajouter à la discussion', copyRelative: 'Copier le chemin relatif', copyAbsolute: 'Copier le chemin absolu', + rename: 'Renommer', + renameInvalid: 'Le nom ne peut pas être vide ni contenir de séparateurs de chemin.', + delete: 'Supprimer', + deleteTitle: 'Supprimer « {name} » ?', + deleteDescFile: 'Ce fichier sera définitivement supprimé. Cette action est irréversible.', + deleteDescDir: 'Ce répertoire et tout son contenu seront définitivement supprimés. Cette action est irréversible.', + dismiss: 'Fermer', download: 'Télécharger', uploadFiles: 'Importer des fichiers', uploadFolder: 'Importer un dossier', diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts index 940aad0..2f4e9b5 100644 --- a/src/client/locales-hi.ts +++ b/src/client/locales-hi.ts @@ -198,6 +198,13 @@ export const hi: Record = { addToConversation: 'वार्तालाप में जोड़ें', copyRelative: 'सापेक्ष पथ कॉपी', copyAbsolute: 'निरपेक्ष पथ कॉपी', + rename: 'नाम बदलें', + renameInvalid: 'नाम खाली नहीं हो सकता और उसमें पथ विभाजक नहीं हो सकते।', + delete: 'हटाएँ', + deleteTitle: '“{name}” हटाएँ?', + deleteDescFile: 'यह फ़ाइल स्थायी रूप से हटा दी जाएगी। इसे पूर्ववत नहीं किया जा सकता।', + deleteDescDir: 'यह निर्देशिका और उसकी सारी सामग्री स्थायी रूप से हटा दी जाएगी। इसे पूर्ववत नहीं किया जा सकता।', + dismiss: 'बंद करें', download: 'डाउनलोड', uploadFiles: 'फ़ाइलें अपलोड करें', uploadFolder: 'फ़ोल्डर अपलोड करें', diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts index 0f30760..95015a7 100644 --- a/src/client/locales-id.ts +++ b/src/client/locales-id.ts @@ -196,6 +196,13 @@ export const id: Record = { addToConversation: 'Tambahkan ke obrolan', copyRelative: 'Salin path relatif', copyAbsolute: 'Salin path absolut', + rename: 'Ganti nama', + renameInvalid: 'Nama tidak boleh kosong atau mengandung pemisah jalur.', + delete: 'Hapus', + deleteTitle: 'Hapus "{name}"?', + deleteDescFile: 'Ini menghapus file secara permanen. Tidak dapat dibatalkan.', + deleteDescDir: 'Ini menghapus direktori dan seluruh isinya secara permanen. Tidak dapat dibatalkan.', + dismiss: 'Tutup', download: 'Unduh', uploadFiles: 'Unggah berkas', uploadFolder: 'Unggah folder', diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts index aa84920..c5bf3d9 100644 --- a/src/client/locales-it.ts +++ b/src/client/locales-it.ts @@ -189,6 +189,13 @@ export const it: Record = { addToConversation: 'Aggiungi alla conversazione', copyRelative: 'Copia percorso relativo', copyAbsolute: 'Copia percorso assoluto', + rename: 'Rinomina', + renameInvalid: 'Il nome non può essere vuoto o contenere separatori di percorso.', + delete: 'Elimina', + deleteTitle: 'Eliminare "{name}"?', + deleteDescFile: 'Il file verrà eliminato definitivamente. Questa azione non può essere annullata.', + deleteDescDir: 'La directory e tutto il suo contenuto verranno eliminati definitivamente. Questa azione non può essere annullata.', + dismiss: 'Chiudi', download: 'Scarica', uploadFiles: 'Carica file', uploadFolder: 'Carica cartella', diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts index 7d2adef..751e8eb 100644 --- a/src/client/locales-ja.ts +++ b/src/client/locales-ja.ts @@ -198,6 +198,13 @@ export const ja: Record = { addToConversation: '会話に追加', copyRelative: '相対パスをコピー', copyAbsolute: '絶対パスをコピー', + rename: '名前の変更', + renameInvalid: '名前は空にできず、パス区切り文字を含められません。', + delete: '削除', + deleteTitle: '「{name}」を削除しますか?', + deleteDescFile: 'このファイルを完全に削除します。元に戻せません。', + deleteDescDir: 'このディレクトリと内容物をすべて完全に削除します。元に戻せません。', + dismiss: '閉じる', download: 'ダウンロード', uploadFiles: 'ファイルをアップロード', uploadFolder: 'フォルダーをアップロード', diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts index 3895115..2ade5d4 100644 --- a/src/client/locales-ko.ts +++ b/src/client/locales-ko.ts @@ -190,6 +190,13 @@ export const ko: Record = { addToConversation: '대화에 추가', copyRelative: '상대 경로 복사', copyAbsolute: '절대 경로 복사', + rename: '이름 바꾸기', + renameInvalid: '이름은 비워 둘 수 없으며 경로 구분 기호를 포함할 수 없습니다.', + delete: '삭제', + deleteTitle: '"{name}"을(를) 삭제할까요?', + deleteDescFile: '이 파일이 영구적으로 삭제됩니다. 되돌릴 수 없습니다.', + deleteDescDir: '이 디렉터리와 모든 내용이 영구적으로 삭제됩니다. 되돌릴 수 없습니다.', + dismiss: '닫기', download: '다운로드', uploadFiles: '파일 업로드', uploadFolder: '폴더 업로드', diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts index 5b7dd2a..daf5e04 100644 --- a/src/client/locales-nl.ts +++ b/src/client/locales-nl.ts @@ -196,6 +196,13 @@ export const nl: Record = { addToConversation: 'Aan conversatie toevoegen', copyRelative: 'Relatief pad kopiëren', copyAbsolute: 'Absoluut pad kopiëren', + rename: 'Naam wijzigen', + renameInvalid: 'De naam mag niet leeg zijn of padscheidingstekens bevatten.', + delete: 'Verwijderen', + deleteTitle: '"{name}" verwijderen?', + deleteDescFile: 'Dit verwijdert het bestand permanent. Dit kan niet ongedaan worden gemaakt.', + deleteDescDir: 'Dit verwijdert de map en alles erin permanent. Dit kan niet ongedaan worden gemaakt.', + dismiss: 'Sluiten', download: 'Downloaden', uploadFiles: 'Bestanden uploaden', uploadFolder: 'Map uploaden', diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts index 4b672bf..5428dd4 100644 --- a/src/client/locales-pl.ts +++ b/src/client/locales-pl.ts @@ -200,6 +200,13 @@ export const pl: Record = { addToConversation: 'Dodaj do rozmowy', copyRelative: 'Kopiuj ścieżkę względną', copyAbsolute: 'Kopiuj ścieżkę bezwzględną', + rename: 'Zmień nazwę', + renameInvalid: 'Nazwa nie może być pusta ani zawierać separatorów ścieżki.', + delete: 'Usuń', + deleteTitle: 'Usunąć „{name}”?', + deleteDescFile: 'Spowoduje to trwałe usunięcie pliku. Tej operacji nie można cofnąć.', + deleteDescDir: 'Spowoduje to trwałe usunięcie katalogu i całej jego zawartości. Tej operacji nie można cofnąć.', + dismiss: 'Zamknij', download: 'Pobierz', uploadFiles: 'Wgraj pliki', uploadFolder: 'Wgraj folder', diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts index 401e63a..4ce3b6e 100644 --- a/src/client/locales-pt.ts +++ b/src/client/locales-pt.ts @@ -181,6 +181,13 @@ export const pt: Record = { addToConversation: 'Adicionar à conversa', copyRelative: 'Copiar caminho relativo', copyAbsolute: 'Copiar caminho absoluto', + rename: 'Renomear', + renameInvalid: 'O nome não pode ser vazio nem conter separadores de caminho.', + delete: 'Excluir', + deleteTitle: 'Excluir "{name}"?', + deleteDescFile: 'Isso exclui o arquivo permanentemente. Esta ação não pode ser desfeita.', + deleteDescDir: 'Isso exclui o diretório e todo o seu conteúdo permanentemente. Esta ação não pode ser desfeita.', + dismiss: 'Fechar', download: 'Baixar', uploadFiles: 'Enviar arquivos', uploadFolder: 'Enviar pasta', diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts index 1d5068b..8487cd1 100644 --- a/src/client/locales-ru.ts +++ b/src/client/locales-ru.ts @@ -194,6 +194,13 @@ export const ru: Record = { addToConversation: 'Добавить в диалог', copyRelative: 'Копировать относительный путь', copyAbsolute: 'Копировать абсолютный путь', + rename: 'Переименовать', + renameInvalid: 'Имя не может быть пустым или содержать разделители пути.', + delete: 'Удалить', + deleteTitle: 'Удалить «{name}»?', + deleteDescFile: 'Файл будет удалён безвозвратно. Это действие нельзя отменить.', + deleteDescDir: 'Каталог и всё его содержимое будут удалены безвозвратно. Это действие нельзя отменить.', + dismiss: 'Закрыть', download: 'Скачать', uploadFiles: 'Загрузить файлы', uploadFolder: 'Загрузить папку', diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts index 72280cd..6a446aa 100644 --- a/src/client/locales-sv.ts +++ b/src/client/locales-sv.ts @@ -181,6 +181,13 @@ export const sv: Record = { addToConversation: 'Lägg till i konversation', copyRelative: 'Kopiera relativ sökväg', copyAbsolute: 'Kopiera absolut sökväg', + rename: 'Byt namn', + renameInvalid: 'Namnet får inte vara tomt eller innehålla sökvägsavgränsare.', + delete: 'Ta bort', + deleteTitle: 'Ta bort "{name}"?', + deleteDescFile: 'Detta tar bort filen permanent. Det går inte att ångra.', + deleteDescDir: 'Detta tar bort katalogen och allt i den permanent. Det går inte att ångra.', + dismiss: 'Stäng', download: 'Ladda ner', uploadFiles: 'Ladda upp filer', uploadFolder: 'Ladda upp mapp', diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts index de718d2..936d476 100644 --- a/src/client/locales-th.ts +++ b/src/client/locales-th.ts @@ -198,6 +198,13 @@ export const th: Record = { addToConversation: 'เพิ่มไปยังแชท', copyRelative: 'คัดลอกพาธสัมพัทธ์', copyAbsolute: 'คัดลอกพาธ absolute', + rename: 'เปลี่ยนชื่อ', + renameInvalid: 'ชื่อต้องไม่ว่างเปล่าและต้องไม่มีตัวคั่นเส้นทาง', + delete: 'ลบ', + deleteTitle: 'ลบ "{name}" หรือไม่', + deleteDescFile: 'การดำเนินการนี้จะลบไฟล์อย่างถาวร และไม่สามารถย้อนกลับได้', + deleteDescDir: 'การดำเนินการนี้จะลบไดเรกทอรีและเนื้อหาทั้งหมดอย่างถาวร และไม่สามารถย้อนกลับได้', + dismiss: 'ปิด', download: 'ดาวน์โหลด', uploadFiles: 'อัปโหลดไฟล์', uploadFolder: 'อัปโหลดโฟลเดอร์', diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts index abe14b0..45a5de4 100644 --- a/src/client/locales-tr.ts +++ b/src/client/locales-tr.ts @@ -198,6 +198,13 @@ export const tr: Record = { addToConversation: 'Sohbete ekle', copyRelative: 'Göreli yolu kopyala', copyAbsolute: 'Mutlak yolu kopyala', + rename: 'Yeniden adlandır', + renameInvalid: 'Ad boş olamaz veya yol ayırıcıları içeremez.', + delete: 'Sil', + deleteTitle: '"{name}" silinsin mi?', + deleteDescFile: 'Bu, dosyayı kalıcı olarak siler. Bu işlem geri alınamaz.', + deleteDescDir: 'Bu, dizini ve tüm içeriğini kalıcı olarak siler. Bu işlem geri alınamaz.', + dismiss: 'Kapat', download: 'İndir', uploadFiles: 'Dosyaları yükle', uploadFolder: 'Klasör yükle', diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts index c08d162..3878a90 100644 --- a/src/client/locales-vi.ts +++ b/src/client/locales-vi.ts @@ -198,6 +198,13 @@ export const vi: Record = { addToConversation: 'Thêm vào cuộc trò chuyện', copyRelative: 'Sao chép đường dẫn tương đối', copyAbsolute: 'Sao chép đường dẫn tuyệt đối', + rename: 'Đổi tên', + renameInvalid: 'Tên không được để trống hoặc chứa dấu phân tách đường dẫn.', + delete: 'Xóa', + deleteTitle: 'Xóa "{name}"?', + deleteDescFile: 'Thao tác này sẽ xóa vĩnh viễn tệp. Không thể hoàn tác.', + deleteDescDir: 'Thao tác này sẽ xóa vĩnh viễn thư mục và toàn bộ nội dung của nó. Không thể hoàn tác.', + dismiss: 'Đóng', download: 'Tải xuống', uploadFiles: 'Tải tệp lên', uploadFolder: 'Tải thư mục lên', diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts index 6771f6b..e378960 100644 --- a/src/client/locales-zh-HK.ts +++ b/src/client/locales-zh-HK.ts @@ -213,6 +213,13 @@ export const zhHK: Record = { addToConversation: '新增到對話', copyRelative: '複製相對位址', copyAbsolute: '複製絕對位址', + rename: '重新命名', + renameInvalid: '名稱不可為空,且不可包含路徑分隔符。', + delete: '刪除', + deleteTitle: '刪除「{name}」?', + deleteDescFile: '將永久刪除該檔案,此操作無法復原。', + deleteDescDir: '將永久刪除該目錄及其全部內容,此操作無法復原。', + dismiss: '關閉', download: '下載', uploadFiles: '上傳檔案', uploadFolder: '上傳資料夾', diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts index 940c7fe..f76d1c5 100644 --- a/src/client/locales-zh-MO.ts +++ b/src/client/locales-zh-MO.ts @@ -213,6 +213,13 @@ export const zhMO: Record = { addToConversation: '新增到對話', copyRelative: '複製相對位址', copyAbsolute: '複製絕對位址', + rename: '重新命名', + renameInvalid: '名稱不可為空,且不可包含路徑分隔符。', + delete: '刪除', + deleteTitle: '刪除「{name}」?', + deleteDescFile: '將永久刪除該檔案,此操作無法復原。', + deleteDescDir: '將永久刪除該目錄及其全部內容,此操作無法復原。', + dismiss: '關閉', download: '下載', uploadFiles: '上傳檔案', uploadFolder: '上傳資料夾', diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts index 6872455..c23ed9a 100644 --- a/src/client/locales-zh-TW.ts +++ b/src/client/locales-zh-TW.ts @@ -213,6 +213,13 @@ export const zhTW: Record = { addToConversation: '新增到對話', copyRelative: '複製相對位址', copyAbsolute: '複製絕對位址', + rename: '重新命名', + renameInvalid: '名稱不可為空,且不可包含路徑分隔符。', + delete: '刪除', + deleteTitle: '刪除「{name}」?', + deleteDescFile: '將永久刪除該檔案,此操作無法復原。', + deleteDescDir: '將永久刪除該目錄及其全部內容,此操作無法復原。', + dismiss: '關閉', download: '下載', uploadFiles: '上傳檔案', uploadFolder: '上傳資料夾', diff --git a/src/client/locales.ts b/src/client/locales.ts index 45a5a27..7a58901 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -205,6 +205,13 @@ export const zh = { addToConversation: '添加到对话', copyRelative: '复制相对地址', copyAbsolute: '复制绝对地址', + rename: '重命名', + renameInvalid: '名称不能为空,且不能包含路径分隔符。', + delete: '删除', + deleteTitle: '删除「{name}」?', + deleteDescFile: '将永久删除该文件,此操作不可撤销。', + deleteDescDir: '将永久删除该目录及其全部内容,此操作不可撤销。', + dismiss: '关闭', download: '下载', uploadFiles: '上传文件', uploadFolder: '上传文件夹', @@ -634,6 +641,13 @@ export const en: Record = { addToConversation: 'Add to conversation', copyRelative: 'Copy relative path', copyAbsolute: 'Copy absolute path', + rename: 'Rename', + renameInvalid: 'The name cannot be empty or contain path separators.', + delete: 'Delete', + deleteTitle: 'Delete "{name}"?', + deleteDescFile: 'This permanently deletes the file. This cannot be undone.', + deleteDescDir: 'This permanently deletes the directory and everything inside it. This cannot be undone.', + dismiss: 'Dismiss', download: 'Download', uploadFiles: 'Upload files', uploadFolder: 'Upload folder', diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index d15d13b..3694a6b 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -1038,6 +1038,83 @@ body[data-dsh-sidebar-dragging] .bottomPanel { white-space: nowrap; } +/* ── Inline rename editor ───────────────────────────────────────────────── + The row swaps its name span for a plain input (same row metrics: 34px + height, 6px gap, xxs type); only tokens, no hardcoded colors. */ +.explorerRenaming { + cursor: text; +} + +.explorerRenameInput { + box-sizing: border-box; + flex: 1 1 auto; + min-width: 0; + height: 24px; + padding: 0 6px; + border: 1px solid var(--dsw-alias-state-business-primary); + border-radius: 6px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-xxs-12); +} + +.explorerRenameInput:focus { + outline: none; + border-color: var(--dsw-alias-state-business-primary); +} + +/* The dismissable mutation-failure strip above the root row (rename/delete + refusals — raw server text, same policy as the fence notice). */ +.explorerActionError { + display: flex; + align-items: flex-start; + gap: 6px; + box-sizing: border-box; + width: 100%; + margin-bottom: 4px; + padding: 6px 8px; + border: 1px solid var(--dsw-alias-state-error-primary); + border-radius: 8px; + background: var(--dsw-alias-bg-layer-1); +} + +.explorerActionErrorText { + flex: 1 1 auto; + min-width: 0; + overflow-wrap: anywhere; + font: var(--dsw-font-xxs-12); + color: var(--dsw-alias-state-error-primary); +} + +.explorerActionErrorClose { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border: none; + border-radius: 5px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.explorerActionErrorClose:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +/* The delete-confirmation modal's description paragraph (the git lens's + .gitConfirmDesc sibling — same quiet single-question look). */ +.explorerConfirmDesc { + margin: 0; + font: var(--dsw-font-s-14); + color: var(--dsw-alias-label-primary); + overflow-wrap: anywhere; +} + /* The hover-revealed @-reference button at the row's far right (and the transient "copied" label that replaces it after a copy). The only auto margin in the row absorbs the filename's spare width, so a short name diff --git a/src/client/tree-mutations.ts b/src/client/tree-mutations.ts new file mode 100644 index 0000000..8b1719c --- /dev/null +++ b/src/client/tree-mutations.ts @@ -0,0 +1,50 @@ +/** + * Open-tab reconciliation after a file-tree mutation (rename/delete). + * + * The tree owns the rows; the TABS live in the sidebar state (either split + * tree, the bottom panel, or free windows). A rename must retarget every + * tab whose `path` is the renamed file (the editor content survives and + * later saves land on the new path); a delete must close every tab at or + * under the removed path — files and anything inside a removed directory + * (a stale tab's next save would fail against a missing path). Both ride + * the SERVICE paths (`updateTab`/`closeTab`) rather than direct state + * reducers so the registered lifecycle callbacks fire like any other tab + * mutation. + */ +import type { Context } from '../context-types.ts' +import { baseName } from './FileTree.tsx' +import { allLeaves, type SidebarSnapshot, type SidebarStore, type SidebarTab } from './state.ts' +import { isWithinWorkspace } from './paths.ts' + +/** Every open tab that carries a file path (either split tree, the bottom + * panel, and free windows — a floating tab is as open as a docked one). */ +function pathTabsOf(snapshot: SidebarSnapshot): SidebarTab[] { + const state = snapshot.state + if (state === undefined) return [] + const tabs: SidebarTab[] = [] + for (const leaf of allLeaves(state.splits)) tabs.push(...leaf.tabs) + for (const leaf of allLeaves(state.bottomSplits)) tabs.push(...leaf.tabs) + for (const float of state.floats) tabs.push(float.tab) + return tabs.filter(tab => tab.path !== undefined) +} + +/** Retarget tabs after `oldPath` became `newPath` (title follows the new + * base name, matching how openFile titles editor tabs). */ +export function retargetPathTabs(ctx: Context, store: SidebarStore, oldPath: string, newPath: string): void { + const service = ctx.get('betterSidebar') + if (service === undefined) return + for (const tab of pathTabsOf(store.getSnapshot())) { + if (tab.path === oldPath) service.updateTab(tab.id, { path: newPath, title: baseName(newPath) }) + } +} + +/** Close tabs at or under the removed `target` (a directory takes its whole + * subtree of open files with it). */ +export function closePathTabs(ctx: Context, store: SidebarStore, target: string): void { + const service = ctx.get('betterSidebar') + if (service === undefined) return + for (const tab of pathTabsOf(store.getSnapshot())) { + const path = tab.path + if (path !== undefined && (path === target || isWithinWorkspace(target, path))) service.closeTab(tab.id) + } +} diff --git a/src/fs-operations.ts b/src/fs-operations.ts index fc6a48d..4908a30 100644 --- a/src/fs-operations.ts +++ b/src/fs-operations.ts @@ -9,14 +9,22 @@ * to a uniquely named temp sibling * and are renamed into place, so a failed, aborted, or oversized upload never * leaves a partial file at the target path. + * + * The tree's rename/delete (below) are link-aware: existence and containment + * are verified against the fully resolved target (a symlink pointing outside + * the workspace is refused while the fence is armed), but the operation + * itself addresses the lexical row path — renaming or deleting a symlink + * row renames/unlinks the LINK, never its target, matching what the tree + * row visually names (VS Code semantics). */ import { randomUUID } from 'node:crypto' import { once } from 'node:events' import { createWriteStream } from 'node:fs' -import { mkdir, rename, rm, stat } from 'node:fs/promises' +import { access, lstat, mkdir, realpath, rename, rm, stat, unlink } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' -import { requireAbsolute } from './fs-tree.ts' +import { isWithin, requireAbsolute } from './fs-tree.ts' import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts' +import { resolveSessionPath } from './session-path.ts' import { SidebarError } from './wire.ts' /** Inputs of one upload: the session scope plus the request body stream. */ @@ -94,3 +102,117 @@ export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise throw error } } + +/** Inputs of one tree-row rename. */ +export interface WorkspaceRenameInput { + /** The session workspace root; the renamed entry must stay inside it. */ + cwd: string + /** Absolute path of the row as the tree displays it (may be a symlink). */ + path: string + /** The new base name (single segment — rename never moves across directories). */ + name: string + /** Whether workspace containment is enforced (the `workspaceFence` setting; on by default). */ + fence?: boolean +} + +/** Resolve one existing entry for a link-aware mutation: the lexical row path + * plus its fully resolved real target (fence-checked). ENOENT becomes an + * fs-error, mirroring path-security's resolveRealPath semantics. */ +async function resolveEntry( + cwd: string, + target: string, + fence: boolean, +): Promise<{ absolute: string; real: string; realCwd: string }> { + const absolute = requireAbsolute(resolveSessionPath(cwd, target)) + let real: string + let realCwd: string + try { + ;[realCwd, real] = await Promise.all([realpath(cwd), realpath(absolute)]) + } catch (error) { + throw new SidebarError('fs-error', `cannot resolve "${target}": ${error instanceof Error ? error.message : String(error)}`, 400) + } + if (fence && !isWithin(realCwd, real)) { + throw new SidebarError('forbidden', `path "${target}" is outside workspace`, 403) + } + return { absolute, real, realCwd } +} + +/** Whether a path exists (ENOENT → false; other failures propagate). */ +async function pathExists(target: string): Promise { + try { + await access(target) + return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false + throw error + } +} + +/** + * Rename one tree row within its directory: `path` → `/`. + * The new name must be a single path segment (this is rename, not move); + * an existing destination is refused (POSIX rename would clobber it + * silently); the workspace root itself is never renamable; a symlink row + * renames the link, not its target. A no-op rename (same name) succeeds + * without touching the filesystem. + * + * @throws SidebarError with a wire code for shape, containment, existence + * and root failures. + */ +export async function renameWorkspaceEntry(input: WorkspaceRenameInput): Promise<{ path: string }> { + const { cwd, path, name, fence = true } = input + if (name === '' || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) { + throw new SidebarError('bad-request', 'name must be a single path segment', 400) + } + const { absolute, real, realCwd } = await resolveEntry(cwd, path, fence) + if (real === realCwd) { + throw new SidebarError('fs-error', 'cannot rename the workspace root', 400) + } + if (basename(absolute) === name) return { path: absolute } + const destination = join(dirname(absolute), name) + const safeDestination = await ensureWorkspaceWritePath(cwd, destination, fence) + if (await pathExists(safeDestination)) { + throw new SidebarError('fs-error', `"${name}" already exists`, 409) + } + try { + await rename(absolute, safeDestination) + } catch (error) { + throw new SidebarError('fs-error', `cannot rename "${path}" to "${name}": ${error instanceof Error ? error.message : String(error)}`, 400) + } + return { path: safeDestination } +} + +/** Inputs of one tree-row delete. */ +export interface WorkspaceRemoveInput { + /** The session workspace root; the removed entry must stay inside it. */ + cwd: string + /** Absolute path of the row as the tree displays it (may be a symlink). */ + path: string + /** Whether workspace containment is enforced (the `workspaceFence` setting; on by default). */ + fence?: boolean +} + +/** + * Delete one tree row permanently (there is no trash on the host): files are + * unlinked, directories removed recursively, a symlink row unlinks the LINK + * only (lstat decides, so a link to a directory does not recurse into its + * target). The workspace root itself is never removable. + * + * @throws SidebarError with a wire code for containment, existence and + * root failures. + */ +export async function removeWorkspaceEntry(input: WorkspaceRemoveInput): Promise<{ path: string }> { + const { cwd, path, fence = true } = input + const { absolute, real, realCwd } = await resolveEntry(cwd, path, fence) + if (real === realCwd) { + throw new SidebarError('fs-error', 'cannot remove the workspace root', 400) + } + try { + const info = await lstat(absolute) + if (info.isDirectory()) await rm(absolute, { recursive: true }) + else await unlink(absolute) + } catch (error) { + throw new SidebarError('fs-error', `cannot remove "${path}": ${error instanceof Error ? error.message : String(error)}`, 400) + } + return { path: absolute } +} diff --git a/src/index.ts b/src/index.ts index 85c53d6..51d76ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,7 +31,7 @@ import { } from './config.ts' import { parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts' import { resolveSessionPath } from './session-path.ts' -import { writeWorkspaceUpload } from './fs-operations.ts' +import { renameWorkspaceEntry, removeWorkspaceEntry, writeWorkspaceUpload } from './fs-operations.ts' import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts' import { searchFiles } from './fs-search.ts' import { decodeHtmlUrl } from './html-route.ts' @@ -369,6 +369,28 @@ function buildApi( } return { ok: true } }, + // The tree row's rename: single-segment name, destination-existence and + // workspace-root refusals, link-aware (renames the row, not its target). + // fs-operations.ts owns the containment and shape rules. + 'fs.rename': async (payload) => { + const { cwd } = await cwdOf(payload) + return renameWorkspaceEntry({ + cwd, + path: requireString(payload, 'path'), + name: requireString(payload, 'name'), + fence: fenceEnabledOf(getSettings), + }) + }, + // The tree row's delete (permanent — the host has no trash): recursive + // for directories, unlinks a symlink row without touching its target. + 'fs.remove': async (payload) => { + const { cwd } = await cwdOf(payload) + return removeWorkspaceEntry({ + cwd, + path: requireString(payload, 'path'), + fence: fenceEnabledOf(getSettings), + }) + }, 'git.worktrees': async (payload) => { const { cwd } = await gitCwdOf(payload) const selected = selectedRepoOf(payload) diff --git a/tests/file-tree-rename-delete.spec.tsx b/tests/file-tree-rename-delete.spec.tsx new file mode 100644 index 0000000..8d29687 --- /dev/null +++ b/tests/file-tree-rename-delete.spec.tsx @@ -0,0 +1,230 @@ +/** + * FileTree explorer mutations: the context menu's rename (inline row editor) + * and delete (confirmation modal) — menu-item visibility (never on the + * workspace root row), the commit/cancel flows, the API calls with the row + * path, the tab-reconciliation callbacks, and the failure strip. + */ +// @vitest-environment jsdom +import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest' +import { createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { act } from 'react-dom/test-utils' +import { FileTree } from '../src/client/FileTree.tsx' +import { createSidebarStore } from '../src/client/state.ts' + +// The act() environment flag (React 18.2 reads it before flushing effects). +import { setupReactAct } from './test-utils.ts' +setupReactAct() + +// vitest 4.1.11+ follows the OS locale; pin en-US so menu copy is English. +beforeAll(() => { + Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true }) +}) + +// vi.mock factories are hoisted above every declaration, so the shared +// mocks ride vi.hoisted (mockClear in afterEach keeps the implementations). +const { fsRename, fsRemove } = vi.hoisted(() => ({ + fsRename: vi.fn(async () => ({ path: '/tmp/renamed.ts' })), + fsRemove: vi.fn(async () => ({ path: '/tmp/a.ts' })), +})) + +vi.mock('../src/client/api.ts', () => ({ + api: { + fsTree: async () => ({ + entries: [ + { name: 'a.ts', path: '/tmp/a.ts', isDir: false }, + { name: 'sub', path: '/tmp/sub', isDir: true }, + ], + }), + fsRename, + fsRemove, + }, + downloadUrl: () => '/sidebar/file', + isOutsideWorkspaceMessage: () => false, +})) + +interface Harness { + container: HTMLDivElement + onPathRenamed: ReturnType + onPathDeleted: ReturnType + unmount: () => void +} + +async function mountTree(): Promise { + const container = document.createElement('div') + document.body.append(container) + const root: Root = createRoot(container) + const onPathRenamed = vi.fn() + const onPathDeleted = vi.fn() + await act(async () => { + root.render(createElement(FileTree, { + sessionId: 's1', + cwd: '/tmp', + store: createSidebarStore(), + expanded: [], + revealed: [], + onToggle: () => {}, + onOpenFile: () => {}, + onOpenFileNewTab: () => {}, + onOpenFileSide: () => {}, + onReferenceFile: () => {}, + onPathRenamed, + onPathDeleted, + refreshTick: 0, + onUploadRequest: () => {}, + busy: false, + })) + }) + return { + container, + onPathRenamed, + onPathDeleted, + unmount: () => { act(() => { root.unmount() }) }, + } +} + +/** One tree row by its displayed name (the root row included; it carries no + * role="button", so the query rides the row class). */ +function rowByName(container: HTMLDivElement, name: string): HTMLElement { + const row = [...container.querySelectorAll('[class*="explorerRow"]')] + .find(el => el.querySelector('[class*="explorerName"]')?.textContent === name) + if (row === undefined) throw new Error(`row "${name}" not found`) + return row +} + +function openMenu(container: HTMLDivElement, name: string): void { + const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 30 }) + act(() => { rowByName(container, name).dispatchEvent(event) }) +} + +function menuLabels(): string[] { + return [...document.querySelectorAll('[role="menuitem"]')].map(item => item.textContent ?? '') +} + +function clickMenuitem(label: string): void { + const item = [...document.querySelectorAll('[role="menuitem"]')].find(el => el.textContent === label) + if (item === undefined) throw new Error(`menuitem "${label}" not found`) + act(() => { item.click() }) +} + +/** Set a controlled input's value the React way (native setter + input). */ +function setNativeValue(el: HTMLInputElement, value: string): void { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set + if (setter === undefined) throw new Error('no native value setter') + setter.call(el, value) + el.dispatchEvent(new Event('input', { bubbles: true })) +} + +function pressKey(el: HTMLElement, key: string): void { + el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) +} + +let harness: Harness +afterEach(() => { + harness.unmount() + document.body.innerHTML = '' + fsRename.mockClear() + fsRemove.mockClear() +}) + +describe('FileTree rename/delete menu items', () => { + it('offers rename and delete on file and directory rows, never on the root', async () => { + harness = await mountTree() + openMenu(harness.container, 'a.ts') + expect(menuLabels()).toContain('Rename') + expect(menuLabels()).toContain('Delete') + openMenu(harness.container, 'sub') + expect(menuLabels()).toContain('Rename') + // The workspace root row (cwd itself) is the session — no mutations. + openMenu(harness.container, 'tmp') + expect(menuLabels()).not.toContain('Rename') + expect(menuLabels()).not.toContain('Delete') + }) +}) + +describe('FileTree inline rename', () => { + it('replaces the row with a prefilled editor; Enter commits via fsRename + onPathRenamed', async () => { + harness = await mountTree() + openMenu(harness.container, 'a.ts') + clickMenuitem('Rename') + const input = harness.container.querySelector('input[class*="explorerRenameInput"]') + expect(input).not.toBeNull() + expect(input!.value).toBe('a.ts') + setNativeValue(input!, 'b.ts') + await act(async () => { pressKey(input!, 'Enter') }) + expect(fsRename).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, '/tmp/a.ts', 'b.ts') + expect(harness.onPathRenamed).toHaveBeenCalledWith('/tmp/a.ts', '/tmp/renamed.ts') + // The editor row is gone (the commit closed it). + expect(harness.container.querySelector('input[class*="explorerRenameInput"]')).toBeNull() + }) + + it('Escape cancels without touching the API', async () => { + harness = await mountTree() + openMenu(harness.container, 'a.ts') + clickMenuitem('Rename') + const input = harness.container.querySelector('input[class*="explorerRenameInput"]')! + setNativeValue(input, 'b.ts') + await act(async () => { pressKey(input, 'Escape') }) + expect(fsRename).not.toHaveBeenCalled() + expect(harness.onPathRenamed).not.toHaveBeenCalled() + expect(harness.container.querySelector('input[class*="explorerRenameInput"]')).toBeNull() + }) + + it('rejects separator names client-side with the error strip, no API call', async () => { + harness = await mountTree() + openMenu(harness.container, 'a.ts') + clickMenuitem('Rename') + const input = harness.container.querySelector('input[class*="explorerRenameInput"]')! + setNativeValue(input, 'a/b.ts') + await act(async () => { pressKey(input, 'Enter') }) + expect(fsRename).not.toHaveBeenCalled() + const alert = harness.container.querySelector('[role="alert"]') + expect(alert?.textContent).toContain('path separators') + }) +}) + +describe('FileTree delete confirmation', () => { + it('confirms through the modal, then calls fsRemove + onPathDeleted', async () => { + harness = await mountTree() + openMenu(harness.container, 'a.ts') + clickMenuitem('Delete') + const dialog = document.querySelector('[role="dialog"]') + expect(dialog?.textContent).toContain('Delete "a.ts"?') + expect(dialog?.textContent).toContain('permanently deletes the file') + const confirm = [...document.querySelectorAll('button')].find(el => el.textContent === 'Delete' && el.closest('[role="dialog"]') !== null)! + await act(async () => { confirm.click() }) + expect(fsRemove).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, '/tmp/a.ts') + expect(harness.onPathDeleted).toHaveBeenCalledWith('/tmp/a.ts', false) + expect(document.querySelector('[role="dialog"]')).toBeNull() + }) + + it('the directory copy names the recursive wipe', async () => { + harness = await mountTree() + openMenu(harness.container, 'sub') + clickMenuitem('Delete') + const dialog = document.querySelector('[role="dialog"]') + expect(dialog?.textContent).toContain('everything inside it') + }) + + it('cancel closes the modal without calling anything', async () => { + harness = await mountTree() + openMenu(harness.container, 'a.ts') + clickMenuitem('Delete') + const cancel = [...document.querySelectorAll('button')].find(el => el.textContent === 'Cancel')! + act(() => { cancel.click() }) + expect(fsRemove).not.toHaveBeenCalled() + expect(document.querySelector('[role="dialog"]')).toBeNull() + }) + + it('a server failure lands in the dismissable strip', async () => { + fsRemove.mockRejectedValueOnce(new Error('boom: refused')) + harness = await mountTree() + openMenu(harness.container, 'a.ts') + clickMenuitem('Delete') + const confirm = [...document.querySelectorAll('button')].find(el => el.textContent === 'Delete' && el.closest('[role="dialog"]') !== null)! + await act(async () => { confirm.click() }) + const alert = harness.container.querySelector('[role="alert"]') + expect(alert?.textContent).toContain('boom: refused') + expect(harness.onPathDeleted).not.toHaveBeenCalled() + }) +}) diff --git a/tests/fs-operations.spec.ts b/tests/fs-operations.spec.ts index fb31399..d90ebbc 100644 --- a/tests/fs-operations.spec.ts +++ b/tests/fs-operations.spec.ts @@ -1,8 +1,8 @@ import { afterAll, describe, expect, it } from 'vitest' -import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { writeWorkspaceUpload } from '../src/fs-operations.ts' +import { renameWorkspaceEntry, removeWorkspaceEntry, writeWorkspaceUpload } from '../src/fs-operations.ts' /** The test workspace root (each suite gets its own temp tree). */ const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-upload-')) @@ -150,3 +150,92 @@ describe('writeWorkspaceUpload', () => { expect(readFileSync(target, 'utf8')).toBe('original') }) }) + +describe('renameWorkspaceEntry', () => { + it('renames a file within its directory', async () => { + writeFileSync(join(root, 'old-name.txt'), 'x') + const { path } = await renameWorkspaceEntry({ cwd: root, path: join(root, 'old-name.txt'), name: 'new-name.txt' }) + // The returned path is CANONICAL (realpath-resolved ancestors — on macOS + // the tmpdir's /var becomes /private/var), so assert the shape, not the + // lexical spelling. + expect(path.endsWith('new-name.txt')).toBe(true) + expect(existsSync(join(root, 'old-name.txt'))).toBe(false) + expect(existsSync(join(root, 'new-name.txt'))).toBe(true) + }) + + it('renames a directory (recursive content moves with it)', async () => { + mkdirSync(join(root, 'olddir/nested'), { recursive: true }) + writeFileSync(join(root, 'olddir/nested/deep.txt'), 'x') + await renameWorkspaceEntry({ cwd: root, path: join(root, 'olddir'), name: 'newdir' }) + expect(readFileSync(join(root, 'newdir/nested/deep.txt'), 'utf8')).toBe('x') + }) + + it('is a no-op for the same name (destination-exists refusal must not bite)', async () => { + writeFileSync(join(root, 'same.txt'), 'x') + await renameWorkspaceEntry({ cwd: root, path: join(root, 'same.txt'), name: 'same.txt' }) + expect(existsSync(join(root, 'same.txt'))).toBe(true) + }) + + it('refuses single-segment violations (empty, dot, traversal, separators)', async () => { + writeFileSync(join(root, 'r.txt'), 'x') + for (const name of ['', '.', '..', 'a/b', 'a\\b']) { + await expect(renameWorkspaceEntry({ cwd: root, path: join(root, 'r.txt'), name })) + .rejects.toMatchObject({ code: 'bad-request' }) + } + }) + + it('refuses an existing destination instead of clobbering it', async () => { + writeFileSync(join(root, 'src.txt'), 'src') + writeFileSync(join(root, 'dst.txt'), 'dst') + await expect(renameWorkspaceEntry({ cwd: root, path: join(root, 'src.txt'), name: 'dst.txt' })) + .rejects.toMatchObject({ code: 'fs-error', status: 409 }) + expect(readFileSync(join(root, 'dst.txt'), 'utf8')).toBe('dst') + }) + + it('refuses the workspace root and missing sources', async () => { + await expect(renameWorkspaceEntry({ cwd: root, path: root, name: 'nope' })) + .rejects.toMatchObject({ code: 'fs-error' }) + await expect(renameWorkspaceEntry({ cwd: root, path: join(root, 'missing.txt'), name: 'x' })) + .rejects.toMatchObject({ code: 'fs-error' }) + }) + + it('renames a symlink ROW, not its target', async () => { + writeFileSync(join(root, 'target.txt'), 't') + symlinkSync(join(root, 'target.txt'), join(root, 'alias.txt')) + await renameWorkspaceEntry({ cwd: root, path: join(root, 'alias.txt'), name: 'alias2.txt' }) + expect(existsSync(join(root, 'alias.txt'))).toBe(false) + expect(existsSync(join(root, 'alias2.txt'))).toBe(true) + expect(readFileSync(join(root, 'target.txt'), 'utf8')).toBe('t') + }) +}) + +describe('removeWorkspaceEntry', () => { + it('unlinks a file', async () => { + writeFileSync(join(root, 'gone.txt'), 'x') + await removeWorkspaceEntry({ cwd: root, path: join(root, 'gone.txt') }) + expect(existsSync(join(root, 'gone.txt'))).toBe(false) + }) + + it('removes a directory recursively', async () => { + mkdirSync(join(root, 'tree/sub'), { recursive: true }) + writeFileSync(join(root, 'tree/sub/leaf.txt'), 'x') + await removeWorkspaceEntry({ cwd: root, path: join(root, 'tree') }) + expect(existsSync(join(root, 'tree'))).toBe(false) + }) + + it('unlinks a symlink row without touching (or recursing into) its target', async () => { + mkdirSync(join(root, 'realdir'), { recursive: true }) + writeFileSync(join(root, 'realdir/keep.txt'), 'x') + symlinkSync(join(root, 'realdir'), join(root, 'linkdir')) + await removeWorkspaceEntry({ cwd: root, path: join(root, 'linkdir') }) + expect(existsSync(join(root, 'linkdir'))).toBe(false) + expect(readFileSync(join(root, 'realdir/keep.txt'), 'utf8')).toBe('x') + }) + + it('refuses the workspace root and missing paths', async () => { + await expect(removeWorkspaceEntry({ cwd: root, path: root })) + .rejects.toMatchObject({ code: 'fs-error' }) + await expect(removeWorkspaceEntry({ cwd: root, path: join(root, 'no-such.txt') })) + .rejects.toMatchObject({ code: 'fs-error' }) + }) +}) diff --git a/tests/menu-flip.spec.tsx b/tests/menu-flip.spec.tsx index 4ae7b72..d6281e2 100644 --- a/tests/menu-flip.spec.tsx +++ b/tests/menu-flip.spec.tsx @@ -24,6 +24,18 @@ describe('layout.css contract', () => { expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}~="left"] div[role="menu"] div[role="menu"]`) expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}~="left"] div[role="menu"] div[role="menu"]::before`) }) + + it('keeps the submenu visual tether rules (parent highlight + tight gap)', () => { + // Part 1 keeps the parent row lit while its submenu is open (the Menu + // marks it aria-expanded); part 2 tightens the card gap to 4px with the + // hover corridor narrowed to match. Losing either sends the submenu back + // to reading as an unrelated floating card. + const css = readFileSync('src/client/layout.css', 'utf8') + expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}] div[role="menu"] button[aria-expanded="true"]`) + expect(css).toContain('left: calc(100% + 4px)') + expect(css).toContain('right: calc(100% + 4px)') + expect(css).toContain('width: 4px') + }) }) describe('submenuFlipTokens', () => { From 93ad87dfe7abd5efa5f177ce64e427b42ffa618a Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sun, 6 Sep 2026 00:59:03 +0800 Subject: [PATCH 4/5] fix(sidechat): stamp the seed with fork markers so the child inherits no pending inbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sidechat.start passed `seed` without `meta.isSeeded: true` + `inheritedEventCount`, so dsh-session treated the whole seed as the child's OWN events and the child's Inbox constructor replayed the parent's `agent/inbox/spliced` history — inheriting whatever input sat UNCLAIMED in the parent at the click moment: - next-turn: a queued follow-up behind a still-running turn; - next-step (the reported long-context case, parent queue empty): a tool-result context / steering spliced between step boundaries. The first side prompt then claimed [phantom, boundary, question] — the stale parent message was sent to the model BEFORE the boundary and logged as a user/message. The fix passes the exact marker pair the host's own session.fork uses (api-session-controller); ownEvents() now stops at the end-seed boundary and the inherited inbox replays empty. Regression tests run the REAL Session.create + Inbox in both directions (old shape inherits the phantom, marked shape stays empty); routes tests pin the two create options. Verified: pnpm typecheck / test (5 pre-existing unrelated UI-spec failures on main unchanged) / build. --- AGENTS.md | 1 + docs/plans/2026-08-20-sidechat-tab-design.md | 2 + src/sidechat-routes.ts | 13 +++ tests/sidechat-routes.spec.ts | 16 +++ tests/sidechat-seed-validation.spec.ts | 115 ++++++++++++++++++- 5 files changed, 146 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 7f7a89f..0f8656b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ 8. **e2e scratch profile 的 `minimumReleaseAgeExclude` 含 `'@deepseek-ai/*'`**(`scripts/e2e-mount.sh` / `e2e-aggregate-mount.sh`,与仓库根 `pnpm-workspace.yaml` 同策):alpha 版本常在发布后 24h 内跑 lane,pnpm 11 的 `minimumReleaseAge` 默认会拒装新鲜包。 9. **插件开发树的 dsh-* 传递 peer 需提升为 devDependencies**(alpha.3 首见):`dsh-subagent` 等 npm 包把 `dsh-attachment` 等 dsh-* 姊妹包全部声明为 peerDependencies(由宿主 bundle 树统一提供,宿主侧无此问题),插件仓库若只直接依赖其中一部分,其余 peer 在 pnpm 下会解析到树上残留的旧版——如 `dsh-attachment@0.1.1-rc.1` 缺 `admitPromptContent` 导出、`dsh-subagent` 产物 import 它时测试加载即崩。因此 devDependencies 需涵盖 dev 树实际触达的全部 peer(attachment / code-runtime / scope / session-projection / system-prompt / user-approval / util-time 七个即为此提升,与直接依赖同款精确钉版);`@deepseek-ai/cordis` peer 自 alpha.3 起要求 `^4.0.2`(上游全线 peer 已升)。适配新 alpha 版本时先跑 `pnpm peers check`,把新失配的传递 peer 一并提升进 devDependencies。例外:**`dsh-client-locale` 的 peer/devDep 允许落后于基线**(alpha.5 时上游停在 0.1.2-alpha.3 未发新版)——peer 下限 `^0.1.2-alpha.3` 天然容纳同 tuple 的 alpha.5 运行时,此时保持旧钉版并在 `pnpm-workspace.yaml` 注明,待上游发版再追平;rc.1 起上游恢复发版,peer/devDep 已与基线一并钉 `0.1.2-rc.1`,该例外消除。 10. **聊天文件打开漏斗是 `remote.session.openWorkspacePath`**(`ctx.workspaces.openPath` 已随 alpha 删除,`IWorkspaces` 无此方法):「聊天区文件在侧边栏打开」拦截在 `ctx.inject(['remote.session'], …)` 内以 **defineProperty 数据属性遮蔽**该命名空间方法(gateway client 的方法是 accessor 属性、异步挂载、contribution 重挂载时服务重建——inject 回调重跑即自愈);实现见 `src/client/openpath-intercept.ts`,设计见 [docs/plans/2026-08-31-openpath-intercept-alpha-design.md](docs/plans/2026-08-31-openpath-intercept-alpha-design.md)。 +11. **自定义种子必须带 fork 标记对(`meta.isSeeded: true` + `inheritedEventCount`)**:dsh-session 契约「只给 seed 不标 isSeeded,种子算重放历史而非继承前缀」——缺标记时 `Session.ownEvents()` 含整个种子,子会话 `Inbox` 构造器重放种子里的 `agent/inbox/spliced`,**继承父会话切割时刻未领取的 inbox 输入**(排队用户消息 next-turn、长回合中工具结果上下文/steering next-step——后者即「上下文很长时侧边对话先把之前的 User msg 发出去」的根因,幽灵消息排在 boundary 之前最先发给模型)。宿主 `session.fork`(api-session-controller)的调用即规范形态;回归由 `tests/sidechat-seed-validation.spec.ts`(真实 `Session.create` + `Inbox`)守护。0.1.2-rc.1 复评:无更优雅的 sidechat 宿主 API(`ContinuableStartSpec` 仍无 seed 字段),`AgentRegistry.create` 接缝补齐标记即为规范用法。 --- diff --git a/docs/plans/2026-08-20-sidechat-tab-design.md b/docs/plans/2026-08-20-sidechat-tab-design.md index 26461ad..18bd9d3 100644 --- a/docs/plans/2026-08-20-sidechat-tab-design.md +++ b/docs/plans/2026-08-20-sidechat-tab-design.md @@ -96,3 +96,5 @@ 3. 重开线程 Tab 丢工具行:根因**不在宿主**(`session.history` cold 路径原样返回 `tool/call`,见 `api-proxy.ts:749` 与 `chunk-rows.ts` 的无损 chunk 压缩)而在首挂回源窗口——旧实现 8 事件/页 × 32 页 = 256 事件上限,而 cold 读会把 chunk-rows 展开回每个流式 delta 一条事件,一轮回答动辄数百事件,较早的 `tool/call` 掉出窗口(settled 文本靠最终 `assistant/message` 幸存)。修复 = 抽出 `collectOwnEvents`(200 事件/页 × 40 页帽、日志穷尽/无标记时落定 `seedBoundary:0` 停止每轮重走),单测覆盖四分支。 4. Subagent 页「目录损坏」行:见 §5 修正——种子追加 descriptor + SubagentView/subagent-detect 按前缀过滤(`isSideThreadSummary` 同时修好 autoOpenSubagent 误触发与计数膨胀)。旧版创建的线程仍是 corrupt 诊断行,由 SubagentView 的标题前缀过滤兜底隐藏。 - **转录传输迁至自有路由(DSH 0.1.2-alpha.1 适配)**:§6 原设计的时间线轮询走通用 `connection.api.sessions.history` RPC——该客户端面(`ctx.connection.api`)在 0.1.2-alpha.1 的 Remote-gateway 迁移中被整体移除,且其继任者(`session/follow`/`session/page`)对 `origin:'subagent'` 会话强制 subagent 地址寻址(普通 `{kind:'session'}` 地址被 `agent-busy` 拒绝),纯分页也拿不到最新尾页(`throughSeq` 不得超当前游标)。修复 = 新增 **`sidechat.events`** 路由(`src/sidechat-routes.ts`):host 侧读线程日志(live 走 `agent.session.events`,冷线程走 `ctx.get('sessionPersistence').inspect`——两版 DSH 同形,alpha.1 的 inspect 返回展开后的逻辑日志而非磁盘上的打包 chunk-row),服务端切 `session/end-seed`(`threadOwnLogEvents`,`collectOwnEvents` 的 walk 语义下沉),`afterSeq` 增量 + 8000 事件尾部帽(对齐旧 walk 上限 40×200)。客户端 `fetchThread` 单路径化,删除 `collectOwnEvents` 与 `ctx.connection` 镜像/inject。种子切割仍由 `transcriptRows` 客户端二次防御。挂载冒烟补 live+冷读两条断言。 +- **种子缺 fork 标记 → 子会话继承父会话未领取的 inbox 消息(2026-09-05,用户实测「上下文很长时侧边对话会把之前的 User msg 先发送出去」)**:`sidechat.start` 原只传 `seed` 不传 `meta.isSeeded: true` + `inheritedEventCount`。dsh-session 契约:**只给 seed 不标 isSeeded,种子算重放历史而非继承前缀** → `Session.ownEvents()` 含整个种子 → 子会话 `Inbox` 构造器(重放 `ownEvents()` 中的 `agent/inbox/spliced`)继承父会话切割时刻**任何未领取的 inbox 输入**。两个触发窗口(均以真实 0.1.2-rc.1 包 `Session.create` + `Inbox` 实验证实):① next-turn——父会话排队中的用户消息(长回合运行中补发);② **next-step(用户实测场景:父会话运行中、用户队列已清空)**——长回合里工具结果上下文/steering/运行时快照在步骤边界之间插入 next-step、尚未领取;上下文越长回合越久窗口越宽。后果:首条 `sidechat.prompt` 的 claim 顺序为 `[幽灵消息, boundary, question]`——幽灵消息排在 boundary 之前最先发给模型(工具上下文常为「工具完成,接下来做 X」,模型先继续父会话任务),并在子会话日志落成 `user/message`。修复 = create 补 `meta.isSeeded: true` + `inheritedEventCount: SessionLogOffset(seed.length)`(宿主自己的 `session.fork`——api-session-controller——的规范调用形态;`createAgent` 工厂逐字段透传 `sessions.prepare`)。`threadOwnLogEvents` 按 end-seed 标记切割,标记位置不变,转录行为不变;冷恢复按持久化 header 切割,一致。回归测试进 `tests/sidechat-seed-validation.spec.ts`(真实 Session + Inbox 双向锁定:旧形态继承幽灵 / 新形态两队列皆空)。用户拍板:不迁移既有线程、不加 inbox.clear() 兜底(修复后新线程为无操作;旧「从未发过消息」空线程首次使用可能仍先答一次旧消息,之后自愈)。 +- **「更优雅 API」复评(2026-09-05,0.1.2-rc.1 = npm latest)**:无 sidechat/sidechain 宿主 API;`ContinuableStartSpec`(`ctx.subagents.startContinuable`)仍无 seed 字段;`session/create` RPC 只建全新会话。§3 的自建结论继续成立,`AgentRegistry.create` 接缝即宿主 fork 自用的公开接缝——补齐 fork 标记即为规范用法。 diff --git a/src/sidechat-routes.ts b/src/sidechat-routes.ts index acdeadb..a0252b3 100644 --- a/src/sidechat-routes.ts +++ b/src/sidechat-routes.ts @@ -27,6 +27,7 @@ import type { Agent, AgentSetup, CreateAgentOptions, ResumeAgentOptions } from ' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import type { Context as CordisContext } from '@deepseek-ai/cordis' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { SessionLogOffset } from '@deepseek-ai/dsh-session' import type { Context, SidebarAgentPresetsService, @@ -238,16 +239,28 @@ export function buildSidechatApi(ctx: Context): SidechatRoutes { data: descriptor as unknown as Record, } const seed = [...inheritance.seed, descriptorEvent] + // Fork-marker fields (the exact shape the host's own session.fork uses, + // api-session-controller): without `isSeeded` + `inheritedEventCount` the + // session treats the whole seed as the child's OWN events, so the child's + // Inbox constructor replays the parent's `agent/inbox/spliced` events and + // inherits whatever input sat UNCLAIMED in the parent at the click moment + // (a queued follow-up, or a tool-result context spliced into next-step + // between step boundaries of a long-running turn). The first side prompt + // would then claim and send that stale message BEFORE the boundary + + // question. The marker keeps `ownEvents()` at the end-seed boundary, so + // the inherited inbox replays to empty. const options: CreateAgentOptions = { sessionId: childId, meta: { ...(parentSession.header.cwd === undefined ? {} : { cwd: parentSession.header.cwd }), parentSession: parentSession.id, + isSeeded: true, origin: 'subagent', delegationDepth: (parentSession.header.delegationDepth ?? 0) + 1, ...(agentPreset === undefined ? {} : { agentPreset }), }, seed: seed as unknown as readonly SessionEvent[], + inheritedEventCount: SessionLogOffset(seed.length), agentOptions: { ...parent.options }, setup, signal: AbortSignal.timeout(CREATE_TIMEOUT_MS), diff --git a/tests/sidechat-routes.spec.ts b/tests/sidechat-routes.spec.ts index ddce8af..4f939c6 100644 --- a/tests/sidechat-routes.spec.ts +++ b/tests/sidechat-routes.spec.ts @@ -113,6 +113,7 @@ describe('sidechat.start', () => { sessionId: string meta: Record seed: readonly { type: string; data: Record }[] + inheritedEventCount: number agentOptions: { provider: string; model: string } setup: unknown } @@ -120,10 +121,15 @@ describe('sidechat.start', () => { expect(options.meta).toMatchObject({ parentSession: 'parent', origin: 'subagent', + isSeeded: true, delegationDepth: 1, agentPreset: 'preset-a', cwd: '/p', }) + // The fork-marker pair is load-bearing: without it the child's Inbox + // replays the parent's seed inbox splices and inherits whatever input sat + // unclaimed in the parent (see sidechat-seed-validation.spec.ts). + expect(options.inheritedEventCount).toBe(options.seed.length) expect(options.agentOptions).toEqual({ provider: 'test', model: 'model-x' }) // The child carries the parent's completed turns as a verbatim seed, // closed by the durable subagent descriptor (honest catalog citizenship: @@ -221,6 +227,16 @@ describe('sidechat.start', () => { const { childId } = await api['sidechat.start']({ sessionId: 'parent', question: ' ' }) + // The empty thread carries the same fork-marker pair (isSeeded + + // inheritedEventCount = seed length) as the prompted create. + const options = services.create.mock.calls[0]![0] as { + meta: Record + seed: readonly { type: string }[] + inheritedEventCount: number + } + expect(options.meta.isSeeded).toBe(true) + expect(options.inheritedEventCount).toBe(options.seed.length) + // No prompt yet: the composer owns the first message; the placeholder // label is pinned and the in-progress snapshot is parked for it. expect(child.inject).not.toHaveBeenCalled() diff --git a/tests/sidechat-seed-validation.spec.ts b/tests/sidechat-seed-validation.spec.ts index 3f929d4..f828701 100644 --- a/tests/sidechat-seed-validation.spec.ts +++ b/tests/sidechat-seed-validation.spec.ts @@ -10,8 +10,9 @@ * not a mock, so the class of bug cannot silently return. */ import { describe, expect, it } from 'vitest' -import { Session } from '@deepseek-ai/dsh-session' +import { Session, SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' +import { Inbox } from '@deepseek-ai/dsh-agent' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import type { SidebarSessionEvent } from '../src/context-types.ts' import { buildSidechatInheritance } from '../src/sidechat-core.ts' @@ -111,3 +112,115 @@ describe('sidechat seed against the real dsh-session validator', () => { expect(types.at(-1)).toBe('session/end-seed') }) }) + +describe('sidechat seed fork markers vs the real dsh-agent Inbox replay', () => { + /** + * Regression: the thread-create call originally passed `seed` WITHOUT the + * fork-marker pair (`meta.isSeeded: true` + `inheritedEventCount`). A seed + * without them is REPLAY history, not an inherited prefix, so the child's + * ownEvents() includes the whole seed and its Inbox constructor replays the + * parent's `agent/inbox/spliced` events — inheriting whatever input sat + * UNCLAIMED in the parent at the click moment (a queued follow-up, or a + * tool-result context spliced into next-step between step boundaries of a + * long-running turn). The first side prompt then claimed and SENT that stale + * message before the boundary + question. These tests run the REAL + * dsh-session Session and dsh-agent Inbox. + */ + const pendingMessage = (id: string, kind: string, text: string) => ({ + id, + role: 'user', + content: [{ type: 'text', text }], + source: { kind }, + }) + const inboxOver = (session: Session): { nextTurn: number; nextStep: number } => { + const inbox = new Inbox(session, { inserted() {}, discarded() {}, claimed() {} }) + return { nextTurn: inbox.nextTurn.length, nextStep: inbox.nextStep.length } + } + /** The shape the routes pass since the fix: isSeeded + inheritedEventCount. */ + const forkMarkedSession = (id: string, seed: readonly SidebarSessionEvent[]): Session => + Session.create( + id as SessionId, + seed as never, + { version: 0, id: id as SessionId, createdAt: Date.now(), isSeeded: true }, + SessionLogOffset(seed.length), + ) + /** Real loop order: inbox insert → turn/start → claim deletion → step/start + * → user/message. One completed, fully claimed turn (seq 0-7). */ + const completedTurnLog = (): SidebarSessionEvent[] => [ + ev('agent/inbox/spliced', 0, { target: 'next-turn', start: 0, inserted: [pendingMessage('m-q', 'user', 'q')] }), + ev('turn/start', 1, { turn: 1 }), + ev('agent/inbox/spliced', 2, { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }), + ev('step/start', 3, { turn: 1, step: 1 }), + ev('user/message', 4, pendingMessage('m-q', 'user', 'q')), + ev('assistant/message', 5, { turn: 1, step: 1, message: assistantMessage('a') }), + ev('step/end', 6, { turn: 1, step: 1 }), + ev('turn/end', 7, { turn: 1, reason: { kind: 'completed' } }), + ] + /** Mid-turn with an UNCLAIMED tool-result context in next-step (seq 15). */ + const midTurnLog = (): SidebarSessionEvent[] => [ + ...completedTurnLog(), + ev('agent/inbox/spliced', 8, { target: 'next-turn', start: 0, inserted: [pendingMessage('m-q2', 'user', 'q2')] }), + ev('turn/start', 9, { turn: 2 }), + ev('agent/inbox/spliced', 10, { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }), + ev('step/start', 11, { turn: 2, step: 1 }), + ev('user/message', 12, pendingMessage('m-q2', 'user', 'q2')), + ev('tool/call', 13, { turn: 2, step: 1, callId: 'c1', name: 'bash', arguments: '{}' }), + ev('tool/result', 14, { + turn: 2, step: 1, + message: { + id: 'm-r', + role: 'user', + content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'ok' }] }], + source: { kind: 'tool', callId: 'c1' }, + }, + }), + ev('agent/inbox/spliced', 15, { + target: 'next-step', + start: 0, + inserted: [pendingMessage('m-ctx', 'tool', 'tool bash finished: exit 0 ... now continue the plan')], + }), + ] + + it('inherits the parent\'s UNCLAIMED next-step tool context without the fork markers (root cause)', () => { + // Parent mid-turn (user queue empty): the bash tool returned and spliced + // its follow-up context into next-step; the next step boundary has not + // claimed it yet. The reported long-context scenario. + const { seed } = buildSidechatInheritance(midTurnLog()) + // OLD call shape (seed only): the whole seed counts as own events and the + // phantom context is inherited as pending input. + const unmarked = Session.create('session-inbox-unmarked' as SessionId, seed as never) + expect(unmarked.ownEvents().length).toBe(seed.length + 1) + expect(inboxOver(unmarked)).toEqual({ nextTurn: 0, nextStep: 1 }) + }) + + it('inherits the parent\'s QUEUED follow-up (next-turn) without the fork markers', () => { + const queued = pendingMessage('m-queued', 'user', 'queued second message') + const log: SidebarSessionEvent[] = [ + ...completedTurnLog(), + ev('agent/inbox/spliced', 8, { target: 'next-turn', start: 0, inserted: [queued] }), + ] + const { seed } = buildSidechatInheritance(log) + const unmarked = Session.create('session-inbox-unmarked-turn' as SessionId, seed as never) + expect(inboxOver(unmarked)).toEqual({ nextTurn: 1, nextStep: 0 }) + }) + + it('the fork-marker pair (isSeeded + inheritedEventCount) leaves the inherited inbox empty', () => { + // Same mid-turn log as the root-cause case, plus the descriptor the + // routes append — exactly what sidechat.start passes to agents.create. + const { seed } = buildSidechatInheritance(midTurnLog()) + const withDescriptor = [ + ...seed, + { + type: 'subagent/descriptor', + seq: seed.length, + time: Date.now(), + data: snapshotSubagentDescriptor({ mode: 'continuable', provider: 'sidechat', label: 'Side: test' }), + } as unknown as SidebarSessionEvent, + ] + const marked = forkMarkedSession('session-inbox-marked', withDescriptor) + // Only the end-seed marker is own; the transcript cut is unchanged. + expect(marked.ownEvents().map(event => event.type)).toEqual(['session/end-seed']) + expect(marked.snapshotEvents().map(event => event.type).at(-1)).toBe('session/end-seed') + expect(inboxOver(marked)).toEqual({ nextTurn: 0, nextStep: 0 }) + }) +}) From 2a99968fa360d2d08bbd2545c1c13386177a4b70 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sun, 6 Sep 2026 21:53:49 +0800 Subject: [PATCH 5/5] fix(editor): rewrite local images in markdown runs of the split renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every .md preview renders through MarkdownDocument (htmlInfo is ungated), but its markdown segments passed the raw source into MarkdownText, whose chat-security stance drops non-http(s) image destinations — so local markdown-syntax images (![alt](./icon.svg), reference-style definitions) always fell back to alt text. Only HTML tags were rewritten (postProcessSanitized). Apply rewriteLocalImageUrls to each markdown run (segment + appended reference definitions) inside MarkdownDocument's prepared memo, through the same media trust fence the sanitized HTML leaves already use. Idempotent for already-absolute media URLs; remote destinations untouched. Also correct the stale "non-null only for HTML documents" comment on TextEditor's htmlInfo. --- src/client/MarkdownHtml.tsx | 16 +++++++--- src/client/TextEditor.tsx | 8 +++-- tests/markdown-html-render.spec.tsx | 48 +++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/client/MarkdownHtml.tsx b/src/client/MarkdownHtml.tsx index 70011ab..9f25286 100644 --- a/src/client/MarkdownHtml.tsx +++ b/src/client/MarkdownHtml.tsx @@ -10,9 +10,10 @@ * Security posture: every HTML string (block leaves, inline text, wrapper * open-tag attributes) goes through DOMPurify with an explicit denylist on * top of its defaults (no script/style/iframe/forms), anchors are forced to - * open in a new tab with noopener, and local media `src` attributes are - * rewritten through the session-scoped `/sidebar/file` media route — the same - * trust fence the markdown image rewriter (`markdown-images.ts`) uses. + * open in a new tab with noopener, and local media goes through the + * session-scoped `/sidebar/file` media route — markdown-syntax image + * destinations are rewritten via `markdown-images.ts` and `src` attributes + * through `resolveLocalMediaDest` (the same trust fence). */ import { useLayoutEffect, useMemo, useRef } from 'react' import { createElement, type ReactNode } from 'react' @@ -21,7 +22,7 @@ import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { ComponentType } from 'react' import { markdownTextProps } from './markdown-labels.tsx' import { lazyChunkComponent } from './lazy-chunk.tsx' -import { resolveLocalMediaDest } from './markdown-images.ts' +import { resolveLocalMediaDest, rewriteLocalImageUrls } from './markdown-images.ts' import { analyzeHtmlSegment, type AnalyzedMarkdownHtml, @@ -235,7 +236,12 @@ export function MarkdownDocument({ info, media, codeLabels }: MarkdownDocumentPr const prepared = useMemo(() => info.segments.map((segment): PreparedSegment => { if (segment.kind === 'markdown') { const defs = info.referenceDefinitions - const text = defs === '' ? segment.text : `${segment.text}\n\n${defs}` + const raw = defs === '' ? segment.text : `${segment.text}\n\n${defs}` + // MarkdownText drops non-http(s) image destinations (chat-security + // stance), so rewrite local ones into /sidebar/file media URLs first — + // the same trust fence the sanitized HTML leaves below go through. + // Idempotent: already-absolute media URLs pass through untouched. + const text = rewriteLocalImageUrls(raw, media.scope, media.path, media.origin) return { kind: 'markdown', text, diff --git a/src/client/TextEditor.tsx b/src/client/TextEditor.tsx index 459eaf0..865a034 100644 --- a/src/client/TextEditor.tsx +++ b/src/client/TextEditor.tsx @@ -346,9 +346,11 @@ export function TextEditor(props: FileViewerProps) { () => (markdown && mode === 'preview' ? splitMermaidBlocks(previewMdText) : []), [markdown, mode, previewMdText], ) - /** Raw-HTML analysis (block runs lifted out + inline gate). Non-null only - * for documents that actually contain HTML — plain markdown keeps the - * legacy single-pass render path below, byte-for-byte. */ + /** Raw-HTML analysis (block runs lifted out + inline gate). Non-null for + * every markdown preview, so the render below always takes the split + * renderer — its markdown runs rewrite local image destinations internally + * (see MarkdownHtml.tsx). The legacy single-pass branches (fed the + * pre-rewritten `previewText`) are dead in the current wiring. */ const htmlInfo = useMemo( () => (markdown && mode === 'preview' ? analyzeMarkdownHtml(previewMdText) : null), [markdown, mode, previewMdText], diff --git a/tests/markdown-html-render.spec.tsx b/tests/markdown-html-render.spec.tsx index 8e0ca9d..662152a 100644 --- a/tests/markdown-html-render.spec.tsx +++ b/tests/markdown-html-render.spec.tsx @@ -175,3 +175,51 @@ describe('MarkdownDocument (inline pass)', () => { await unmount(root) }) }) + +describe('MarkdownDocument (local markdown images)', () => { + it('renders markdown-syntax local images through the /sidebar/file route', async () => { + const { container, root } = await renderDocument([ + '# Title', + '', + '![icon](./assets/icon.svg)', + '', + '![logo][logo]', + '', + '[logo]: ./logo.png', + ].join('\n')) + const imgs = [...container.querySelectorAll('img')] + expect(imgs.length, 'both local images must render as , not alt fallback').toBe(2) + expect(imgs[0]?.getAttribute('src')).toBe('http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Fassets%2Ficon.svg&cwd=%2Fws') + expect(imgs[0]?.getAttribute('alt')).toBe('icon') + expect(imgs[1]?.getAttribute('src')).toBe('http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Flogo.png&cwd=%2Fws') + expect(imgs[1]?.getAttribute('alt')).toBe('logo') + await unmount(root) + }) + + it('renders local markdown images in HTML-mixed documents (the production path)', async () => { + const { container, root } = await renderDocument([ + '
badges
', + '', + '![shot](./img.png)', + ].join('\n')) + const img = container.querySelector('img') + expect(img, 'the markdown-syntax image after an HTML run must render').not.toBeNull() + expect(img?.getAttribute('src')).toBe('http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Fimg.png&cwd=%2Fws') + await unmount(root) + }) + + it('keeps remote image destinations untouched', async () => { + const { container, root } = await renderDocument('![r](https://example.com/x.png)') + const img = container.querySelector('img') + expect(img?.getAttribute('src')).toBe('https://example.com/x.png') + await unmount(root) + }) + + it('is idempotent for already-rewritten media URLs', async () => { + const url = 'http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Fimg.png&cwd=%2Fws' + const { container, root } = await renderDocument(`![a](${url})`) + const img = container.querySelector('img') + expect(img?.getAttribute('src')).toBe(url) + await unmount(root) + }) +})