From 76b57508f41c7e8bc5b9c5babc7f35ba5e605dbe Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Fri, 4 Sep 2026 20:28:35 +0800 Subject: [PATCH] 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={(