减重:全部右键菜单(FileTree 行菜单 / TabBar tab 菜单 / GitLens 文件与 历史菜单 / FreeWindow 标题菜单)与「+」菜单切到宿主 Menu 原语的 compact 密度(26px 行高 / 12px 字 / 164px 卡片),图标随槽位 16→14,「打开方式」 子菜单图钉 20→16,还原工具型右键菜单的趁手感。 越界:宿主子菜单纯 CSS 定位(从父行向上、向右生长)不受视口钳制——树顶 部右键时「打开方式 ▸」子菜单伸出视口上沿,窄面板(280–400px)时伸出右 沿。新增 menu-flip.ts:菜单打开期间在 <body> 发布 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。
12 KiB
文件树「打开方式」子菜单设计(2026-08-22)
状态:已实现(分支
feat/open-with-menu)。本文档记录功能设计、关键取舍与实施偏差。
目标
文件树(FileTree)右键菜单新增「在应用中打开 >」子菜单:
- 内置 资源管理器(在文件管理器中显示)、VS Code、Cursor、Zed 四个打开方式;每个子菜单行右侧有图钉,点击可把该方式固定为右键菜单的顶层直达项(再点取消固定)。
- 远端 SSH:设置中配置可选 SSH host(
user@host或~/.ssh/config别名)。非空即整个工作区视为远端:VSCode 系条目(VS Code / Cursor / 自定义且勾选"VSCode 系")改用<scheme>://vscode-remote/ssh-remote+<host>/<path>打开;本地专用条目(资源管理器 / Zed / 非 VSCode 系自定义)从菜单隐藏。 - 自定义编辑器:名称 + URL 模板(
{path}占位符,如cursor://file/{path})+ 「是否 VSCode 系」开关;配置入口在侧边栏设置 Files 卡片齿轮(与既有editorExplorer下拉同一弹窗)。 - 打开动作经宿主新路由
POST /sidebar/api/open.external执行(argv 数组 spawn,无 shell 注入),浏览器模式与 DSH Desktop 行为一致;不经过ctx.workspaces.openPath(该入口已被wrapOpenPath劫持到侧边栏编辑器,见src/client/openpath-intercept.ts)。
调研结论(reuse gate)
- DSH checkout(
~/.dsh/source/current)无"文件树 open-with"、无 SSH 远程会话、无vscode://开启器;host.openPath只能"默认应用打开",无法指定编辑器、无 reveal/select 语义 → 需要新增。 - dsh-external hub 无同类成品;
dsh-open-in-vscode是工作区行菜单、依赖外部 slot,不适用。 - primitives
Menu原生支持submenu?: readonly MenuItem[](子项点击走onSelect(child.id)),label: ReactNode可内嵌图钉;无现成图钉/品牌图标 → 引入react-icons(vsccodicons 图钉VscPin/VscPinned与文件树VscFile/VscFolder(Opened)、si品牌剪影 Cursor/Zed、菜单父行VscLinkExternal),VS Code 品牌剪影因 simple-icons 后期版本受微软商标政策下架而内嵌自 simple-icons@11.0.0(CC0,IconVscode16);菜单行图标统一 16px 与 DSH 原生菜单一致,子菜单父行的>chevron 由 label 内右对齐元素提供(基元不渲染)。⚠️ react-icons 的 exports 把require排在import前,tsdown 共享 conditionNames 会选中不可树摇的 CJS 入口(bundle +6.4MB)——tsdown.config.ts用resolve.alias钉死si/vsc的 ESM 入口,树摇后仅 +12KB。
数据模型
持久化在 pluginSettings['editor'].openWith(宿主 schema 的开放 map,无需改 PrefsSchema):
{
"openWith": {
"sshHost": "",
"customEditors": [
{ "id": "custom:<uuid>", "name": "Windsurf", "urlTemplate": "windsurf://file/{path}", "isVscodeFamily": false }
],
"pinned": ["vscode", "custom:<uuid>"]
}
}
parseOpenWithConfig(src/client/open-with.ts)容错解析:坏行丢弃、未知 pinned 在解析目标时剪枝;结构合法但未填完的行(空 name/模板)保留——设置面板正在编辑的行必须能在弹窗往返中存活,由菜单侧的isValidCustomEditor过滤。- 目标 id:内置
explorer/vscode/cursor/zed;自定义custom:<uuid>。 - URL 构建:
- 本地:模板原文替换
{path}(vscode://file//home/u/f.ts——POSIX 绝对路径自带前导/,与 VS Code 官方文档vscode://file/c:/…的约定一致;反斜杠归一化为/)。 - SSH(VSCode 系):
<scheme>://vscode-remote/ssh-remote+<host>/<path>,路径保留前导/(如…+dev/home/u/f.ts,与社区约定一致)。 - 模板无
scheme://或无{path}→ 返回 undefined(不打开)。
- 本地:模板原文替换
菜单结构(文件行与目录行一致)
在新 Tab 中打开 ← 既有
在侧边打开 ← 既有
----------------------------
VS Code (SSH) ← pinned 直达项(按内置顺序 + 自定义追加)
Cursor (SSH)
----------------------------
在应用中打开 > ← 子菜单父项(始终在 pinned 之后)
资源管理器 📌
VS Code 📌(激活)
Cursor 📌(激活)
Zed 📌
Windsurf 📌
下载 ← 既有(仅文件行)
复制相对地址 / 复制绝对地址 ← 既有
- 图钉是
label内的<span role="button" tabIndex={-1}>(Menu 行本身是<button>,嵌套原生 button 不合法);stopPropagation+preventDefault使其不关闭菜单、不触发行选择,点击后经响应式 blob 重渲染立即翻转。 - 未接线(props 缺省)或 SSH 过滤后无目标 → 整个区块隐藏,旧行为/旧测试不变。
设置面板:settings.render seam 扩展
实施偏差(与最初"render 独占"契约不同,需同步文档):SettingsBody(src/client/SideCardSection.tsx)原先"有 render 就整个替换行列表"。本功能需要行列表(editorExplorer 下拉)与自定义面板共存,因此把 seam 改为:
render给定时追加渲染在行列表(toggles/pluginToggles)之后;未声明行列表时与旧行为一致(单独渲染)。
- 仓库内无既有 render 使用者,
tests/side-card-section.spec.tsx无"render 独占"断言,风险可控;AGENTS.md§3.1/§5 与本文档同步更新。 - 面板组件
OpenWithSettings:SSH host 输入 + 自定义编辑器行(名称/模板/VSCode 系/删除)+「添加」;每次变更整体提交updatePluginSetting('openWith', next)(含 pinned 原样保留)。 - 图钉写入走
src/client/plugin-settings.ts的updatePluginSettings:Promise 链串行化 +api.settingsUpdate后store.setPrefs(parsePrefs(view.value)),避免快速连点丢写。(设置弹窗内部有自己独立的串行 commit,二者极少重叠。)
宿主路由
POST /sidebar/api/open.external(src/index.ts buildApi,与其他方法同 fence + 错误包裹):
{ action: 'reveal', path } // 在文件管理器中显示/选中
{ action: 'url', url } // 交给注册的协议处理器
src/open-external.ts:纯函数revealCommand/urlCommand(platform 可注入,单测覆盖三平台)+launchExternal(spawn(..., { detached: true, stdio: 'ignore' })+unref)。reveal:darwinopen -R;win32explorer.exe /select,;linuxxdg-open <父目录>(无统一 select 协议,KISS 打开所在目录)。url:darwinopen;win32rundll32 url.dll,FileProtocolHandler(备选cmd /c start "");linuxxdg-open。- 校验:
reveal路径必须绝对(requireAbsolute);url必须是scheme://自定义协议(拒绝 http/https)。 - Windows 平台命令为约定实现,需在 Windows 实机验证(本机为 macOS;CI 覆盖 linux)。
实施偏差(2026-09,#517 / PR #522):SSH 远程编辑器链接不再经宿主路由执行。DSH 部署在无头远端服务器时,宿主侧 xdg-open 无 DISPLAY/无编辑器,vscode:// 静默失败。api.openExternal(src/client/api.ts)现把 <scheme>://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——菜单打开期间在 <body> 发布 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(不处理,注释说明)。 - 编辑器未安装/协议未注册:由 OS 弹提示或静默失败,不做安装检测。SSH 客户端分支同理——浏览器端
location.assign无法探测协议 handler 是否存在,仍返回{started: true}。 - 自定义编辑器仅支持 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 标签后缀;未接线隐藏;翻转向量(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。- 全量
pnpm test(810 passed)、pnpm typecheck、pnpm build全绿;未跑挂载冒烟(由 CI plugin-mount 门禁覆盖)。