diff --git a/docs/plans/2026-09-04-file-tree-rename-delete-design.md b/docs/plans/2026-09-04-file-tree-rename-delete-design.md new file mode 100644 index 0000000..58c1ac7 --- /dev/null +++ b/docs/plans/2026-09-04-file-tree-rename-delete-design.md @@ -0,0 +1,62 @@ +# 文件树重命名 / 删除设计(2026-09-04) + +> 状态:已实现。本文档记录功能设计、关键取舍与已知限制。 + +## 背景 + +文件树右键菜单此前只有打开/复制/上传下载等只读+导出操作,缺少资源管理器最基本的两个变更操作。本次补全: + +- **重命名**:行内编辑(VS Code/Finder 惯例,用户已确认)——菜单点「重命名」后行内名字变输入框(预选中、Enter/失焦提交、Esc 取消)。 +- **删除**:弹窗确认(用户指定)——复用 git lens 的 Cancel/Confirm Modal 范式,文件/目录文案区分,确认后永久删除。 + +同批修复二级菜单的视觉关系(见下节)。 + +## 服务端 + +- `src/fs-operations.ts` 新增 `renameWorkspaceEntry` / `removeWorkspaceEntry`(与 `writeWorkspaceUpload` 同层): + - **链路感知(link-aware)**:存在性与围栏校验对**完全解析后的目标**进行(fence 开启时指向工作区外的 symlink 一律拒绝),但操作本身作用于**词法行路径**——重命名/删除一个 symlink 行只改/删**链接本身**,绝不动目标(lstat 判定,link-to-dir 不会递归进目标)。与 VS Code 语义一致:行显示什么就操作什么。 + - `renameWorkspaceEntry`:`name` 必须单段(非空、非 `.`/`..`、无分隔符——重命名不是移动);目标已存在直接 409 拒绝(POSIX rename 会静默覆盖);工作区根本身不可重命名;同名 no-op 直接成功(不触发 409)。返回**规范路径**(realpath 解析后的祖先链)供 tab 改址。 + - `removeWorkspaceEntry`:目录递归删除、文件/symlink 解链;根拒绝。无回收站(宿主无此 API;等同 git lens discard 的永久语义)。 +- `src/index.ts` 路由 `'fs.rename'`(`{path, name}`)/ `'fs.remove'`(`{path}`),走 `cwdOf` + `fenceEnabledOf(getSettings)`,与 `fs.write` 同一围栏开关。 +- `src/client/api.ts`:`fsRename` / `fsRemove`。 + +## 客户端(FileTree) + +- 菜单尾部(复制项之后):`separator + 重命名(IconEditOutline16) + 删除(IconTrashOutline16, danger)`;**根行**(`rowMenu.path === cwd`)不渲染这两项(服务端双保险)。 +- 行内编辑:命中的行换渲染为编辑行(同缩进/图标/行高),**无 role="button"**(编辑器不是点击目标,也避免 button 嵌套 interactive 的非法结构)。裸 ``(primitives `Input` 不转发 ref,而 focus+select 正是要点);ref 回调 `useCallback([])` 稳定身份——内联箭头每渲染重跑会把正在输入的缓冲区重新全选。Enter/失焦提交、Esc 取消;`isImeComposition` 守卫(IME 组合期的 Enter/Esc 属于输入法,不提交不取消——顺手把该守卫的入参 `isComposing` 放宽为可选,React 合成事件类型未声明该字段)。 +- 删除确认:primitives `Modal`(git lens 同款 outline Cancel + primary Confirm)。 +- 失败面:树顶一条可点消失的错误条(`role="alert"`),展示服务端原文(与 FenceErrorNotice 同策略);客户端单段校验失败也走这里(`renameInvalid`)。 +- **落定收尾 `pruneTree(prefix)`**:清掉旧路径前缀的所有缓存层 → `onToggle` 收起 stale 的 expanded 项 → `retryDir(parent)` 单层重载(无需整树 refresh tick)。重命名已展开目录后新目录折叠展示(expanded 不迁移,VS Code 同样)。 + +## tab 调解(tree-mutations.ts + EditorHost) + +- `retargetPathTabs(ctx, store, old, new)`:`tab.path === old` → `service.updateTab(id, {path: new, title: baseName(new)})`(编辑内容存活,后续保存落到新路径)。 +- `closePathTabs(ctx, store, target)`:`tab.path === target || isWithinWorkspace(target, tab.path)` → `service.closeTab(id)`(目录连整个子树的打开文件一起关)。 +- tab 枚举覆盖 `state.splits` / `state.bottomSplits` / `state.floats`(浮动窗口的 tab 同样算打开)。走 service 路径(而非直改 state)使注册的生命周期回调照常触发。 +- FileTree/TreePanel 透传 `onPathRenamed?` / `onPathDeleted?`(可选,测试可不接线);EditorHost 两处 `` 接线。 + +## 二级菜单视觉关系(layout.css,同批) + +针对「二级菜单没有视觉关系」的反馈,两条纯 CSS 规则(仍以 `data-dsh-sidebar-submenu` body 属性限定作用域,宿主默认不受影响): + +1. **父行持续高亮**:宿主 Menu 在子菜单打开期间给父行按钮标 `aria-expanded="true"`(宿主已渲染、稳定),用同一 hover token 重新着色——指针移入侧卡后父行不再熄灭。 +2. **卡片贴近**:子菜单与父卡间距 10px → 4px,`::before` 悬停走廊同步收窄;翻转规则(left/down)的间距一并 4px。规则排序:tether 基础规则在前、翻转覆盖在后(同特异性下源序决胜)。 + +`tests/menu-flip.spec.tsx` 的 layout.css 契约断言扩展覆盖这三条规则。 + +## i18n + +新增 7 key(rename / renameInvalid / delete / deleteTitle / deleteDescFile / deleteDescDir / dismiss),zh/en/ja 手写;其余 18 个已发第三语言按 `locales.spec.ts` 的 key-set-parity 门禁同步补齐。 + +## 测试 + +- `tests/fs-operations.spec.ts`:rename(文件/目录/no-op 同名/单段违规/目标已存在 409/根拒绝/缺源/symlink 行改链接不改目标)、remove(文件/目录递归/symlink 只解链目标存活/根拒绝与缺路径)。 +- `tests/file-tree-rename-delete.spec.tsx`(jsdom,mock api):菜单项出现条件(文件/目录有、根无);重命名流(预填→Enter 提交→`fsRename`+`onPathRenamed`、Esc 取消、非法名走错误条);删除流(弹窗文案文件/目录区分、确认→`fsRemove`+`onPathDeleted`、取消、服务端失败进错误条)。 +- 真机挂载冒烟(`pnpm test:mount`)不触达这些交互,由 CI 门禁与手动验收覆盖。 + +## 已知限制 + +- 无回收站:删除即永久(fence 开关不改变这一点,只改变越界拒绝)。 +- 重命名不迁移 expanded/revealed 状态:改名已展开目录后新目录折叠、reveal 高亮不跟随(一次性视觉状态,成本/收益不划算)。 +- 并发窗口竞争:存在性检查与 rename/remove 之间有微小 TOCTOU 窗口(单用户侧边栏场景可忽略;服务端错误会进错误条)。 +- 「在其他编辑器里改名后本树 stale」既有行为不变(refresh tick / focus 刷新机制管)。 diff --git a/src/client/EditorHost.tsx b/src/client/EditorHost.tsx index 4411f58..d09b281 100644 --- a/src/client/EditorHost.tsx +++ b/src/client/EditorHost.tsx @@ -40,6 +40,7 @@ import { TreePanel } from './TreePanel.tsx' import { t } from './locales.ts' import { relativeTo } from './paths.ts' import { resolveSidebarPath } from './produced-files.ts' +import { closePathTabs, retargetPathTabs } from './tree-mutations.ts' import type { EditorToolbarControls, EditorToolbarState, FileViewerDescriptor } from './service.ts' import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, treeOf, type SidebarStore, type SidebarTab } from './state.ts' import css from './sidebar.module.css' @@ -225,6 +226,16 @@ export function EditorHost(props: { }) } + // Tree mutations reconcile the OPEN tabs (both split trees, the bottom + // panel, free windows): a rename retargets its tab to the new path; a + // delete closes tabs at or under the removed path. See tree-mutations.ts. + const onPathRenamed = (oldPath: string, newPath: string): void => { + retargetPathTabs(ctx, store, oldPath, newPath) + } + const onPathDeleted = (path: string): void => { + closePathTabs(ctx, store, path) + } + // The viewer's toolbar, hoisted into THIS header: the text editor reports // its state and registers its commands (both null/absent for viewers // without a toolbar — image, pdf, binary download). @@ -389,6 +400,8 @@ export function EditorHost(props: { onOpenWith={openWith} onToggleOpenWithPin={toggleOpenWithPin} onReferenceFile={onReferenceFile} + onPathRenamed={onPathRenamed} + onPathDeleted={onPathDeleted} /> ) @@ -511,6 +524,8 @@ export function EditorHost(props: { onOpenWith={openWith} onToggleOpenWithPin={toggleOpenWithPin} onReferenceFile={onReferenceFile} + onPathRenamed={onPathRenamed} + onPathDeleted={onPathDeleted} /> )} diff --git a/src/client/FileTree.tsx b/src/client/FileTree.tsx index ac3dbd6..d080d18 100644 --- a/src/client/FileTree.tsx +++ b/src/client/FileTree.tsx @@ -24,14 +24,15 @@ import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type import { createPortal } from 'react-dom' import clsx from 'clsx' import { - IconChevronRightOutline14, IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, - IconLinkOutline16, Menu, type MenuEntry, type MenuItem, writeClipboard, + Button, IconChevronRightOutline14, IconCloseFill14, IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, + IconEditOutline16, IconLinkOutline16, IconTrashOutline16, Menu, Modal, type MenuEntry, type MenuItem, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' import { SiCursor, SiZedindustries } from 'react-icons/si' import { VscFile, VscFolder, VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc' import { api, downloadUrl, isOutsideWorkspaceMessage, type FsEntry } from './api.ts' import { FenceErrorNotice } from './FenceErrorNotice.tsx' import { IconUploadOutline16, IconVscode16 } from './icons.tsx' +import { isImeComposition } from './ime-guard.ts' import { useSubmenuFlip } from './menu-flip.ts' import type { OpenWithTarget } from './open-with.ts' import { relativeTo } from './paths.ts' @@ -134,6 +135,10 @@ export function FileTree(props: { onToggleOpenWithPin?: (targetId: string) => void /** Insert `@` into the composer draft (file vs directory). */ onReferenceFile: (path: string, isDir: boolean) => void + /** A rename landed (old row path → new path): the caller retargets open tabs. */ + onPathRenamed?: (oldPath: string, newPath: string) => void + /** A delete landed: the caller closes tabs at or under the removed path. */ + onPathDeleted?: (path: string, isDir: boolean) => void /** Bump to wipe the level cache and reload the visible set. */ refreshTick: number /** Upload into `dir` (absolute, inside the workspace); runs in the caller. */ @@ -141,7 +146,7 @@ export function FileTree(props: { /** True while an upload is in flight (drops are ignored). */ busy: boolean }) { - const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, refreshTick, onUploadRequest, busy } = props + const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, refreshTick, onUploadRequest, busy } = props const [data, setData] = useState>({}) const dataRef = useRef(data) /** The row whose path was just copied ("copied" label replaces its button). */ @@ -151,6 +156,12 @@ export function FileTree(props: { // 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) + /** The row being renamed inline: its path plus the edit buffer. */ + const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null) + /** The delete awaiting the confirmation modal's yes. */ + const [confirmDelete, setConfirmDelete] = useState<{ path: string; isDir: boolean; name: string } | null>(null) + /** The last mutation failure (dismissable strip above the tree). */ + const [actionError, setActionError] = useState(null) /** 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). */ @@ -272,6 +283,63 @@ export function FileTree(props: { loadDir(dir) }, [loadDir]) + /** + * Settle the tree after one rename/delete landed at `prefix`: drop every + * cached level at or under the old path, collapse the now-stale expanded + * directories below it, and reload the immediate parent so the new shape + * shows without a full refresh tick. + */ + const pruneTree = (prefix: string): void => { + const parent = parentOf(prefix) + const under = (key: string): boolean => key === prefix || key.startsWith(`${prefix}/`) || key.startsWith(`${prefix}\\`) + for (const key of Object.keys(dataRef.current)) { + if (under(key)) delete dataRef.current[key] + } + if (parent !== prefix) delete dataRef.current[parent] + setData({ ...dataRef.current }) + for (const dir of expanded) { + if (under(dir)) onToggle(dir) + } + if (parent !== prefix) retryDir(parent) + } + + /** Client-side twin of the server's name rule (the server re-validates). */ + const validRename = (name: string): boolean => + name !== '' && name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\') + + /** Commit the inline rename: trim, no-op guard, then fs.rename + settle. */ + const commitRename = (path: string, raw: string): void => { + setRenaming(null) + const name = raw.trim() + if (cwd === undefined || name === baseName(path) || !validRename(name)) { + if (name !== baseName(path) && !validRename(name)) setActionError(t('renameInvalid')) + return + } + api.fsRename({ sessionId, cwd }, path, name) + .then((result) => { + setActionError(null) + pruneTree(path) + onPathRenamed?.(path, result.path) + }) + .catch((error: unknown) => { + setActionError(error instanceof Error ? error.message : String(error)) + }) + } + + /** Run the confirmed delete: fs.remove + settle + close affected tabs. */ + const performDelete = (target: { path: string; isDir: boolean }): void => { + if (cwd === undefined) return + api.fsRemove({ sessionId, cwd }, target.path) + .then(() => { + setActionError(null) + pruneTree(target.path) + onPathDeleted?.(target.path, target.isDir) + }) + .catch((error: unknown) => { + setActionError(error instanceof Error ? error.message : String(error)) + }) + } + // The caller's refresh tick wipes the cache (declared BEFORE the load // effect so the reload below sees the empty cache). const lastTick = useRef(refreshTick) @@ -452,6 +520,58 @@ export function FileTree(props: { const expandedSet = useMemo(() => new Set(expanded), [expanded]) const revealedSet = useMemo(() => new Set(revealed), [revealed]) + /** + * Focus + select the rename editor once, on mount. The callback identity + * must stay STABLE (useCallback []): an inline arrow re-runs on every + * render (detach null → attach el), and focus()/select() mid-keystroke + * would reselect the whole buffer while typing. A plain , not the + * primitives Input — that one forwards no ref, and focus+select is the + * entire point here. + */ + const renameInputRef = useCallback((el: HTMLInputElement | null): void => { + if (el !== null) { + el.focus() + el.select() + } + }, []) + + /** The inline rename editor replacing one row (dirs and files alike): + * same indent/icon/height for a seamless swap, Enter/blur commits, + * Escape cancels, IME composition keys never reach the handlers (the + * shared isImeComposition guard). */ + const renderRenameRow = (entry: FsEntry, depth: number): ReactNode => ( +
+ {entry.isDir + ? (expandedSet.has(entry.path) ? : ) + : } + { + setRenaming(prev => prev === null ? prev : { ...prev, value: event.target.value }) + }} + onKeyDown={(event) => { + if (isImeComposition(event)) return + if (event.key === 'Enter') { + event.preventDefault() + commitRename(entry.path, renaming?.value ?? '') + } else if (event.key === 'Escape') { + event.preventDefault() + setRenaming(null) + } + }} + onBlur={() => { commitRename(entry.path, renaming?.value ?? '') }} + /> +
+ ) + const renderLevel = (dir: string, depth: number): ReactNode => { const level = data[dir] if (level === undefined) { @@ -476,6 +596,10 @@ export function FileTree(props: { } const entries = level.entries ?? [] return entries.map(entry => { + // The row being renamed renders as its editor (no button semantics: + // an editor is not a click target — and a nested interactive inside + // role="button" would be invalid anyway). + if (renaming?.path === entry.path) return renderRenameRow(entry, depth) if (entry.isDir) { const isOpen = expandedSet.has(entry.path) return ( @@ -556,6 +680,22 @@ export function FileTree(props: {
{t('noSession')}
) : ( <> + {/* The last mutation failure (rename/delete): dismissable, raw + server text — the same show-the-truth policy the fence notice + uses. Any later action or a fresh attempt clears it. */} + {actionError !== null && ( +
+ {actionError} + +
+ )}
}, { 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', () => {