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/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 9f0a7dc..d080d18 100644 --- a/src/client/FileTree.tsx +++ b/src/client/FileTree.tsx @@ -24,14 +24,16 @@ 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' import { t } from './locales.ts' @@ -133,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. */ @@ -140,13 +146,22 @@ 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). */ 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) + /** 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). */ @@ -268,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) @@ -373,13 +445,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 +485,7 @@ export function FileTree(props: { onToggleOpenWithPin?.(target.id) }} > - {pinnedNow ? : } + {pinnedNow ? : } ), @@ -433,7 +506,7 @@ export function FileTree(props: { ), - icon: , + icon: , submenu, }, ] @@ -447,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) { @@ -471,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 ( @@ -551,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: '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: }, + // 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 }, + ] : []), - { id: 'relative', label: t('copyRelative'), icon: }, - { id: 'absolute', label: t('copyAbsolute'), icon: }, ]} onSelect={(id) => { const target = rowMenu @@ -692,16 +846,55 @@ 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, ) }} portal + compact align="start" 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/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={(