dsh-sidebar-file-menu
为 DeepSeek Harness Web UI 右侧栏文件树提供 VS Code 风格的右键菜单。
在文件树的任意一行上右键,会弹出菜单:
| 菜单项 | 行为 |
|---|---|
| 复制绝对路径 | 把该行的绝对路径写入系统剪贴板。 |
| 复制相对路径 | 把相对当前会话工作区根目录的路径写入剪贴板。不在根目录下的行不显示此项。 |
| 复制名称 | 把路径的最后一段写入剪贴板。 |
| 在文件管理器中显示 | 在资源管理器(Windows)、Finder(macOS)或桌面文件管理器(Linux)中选中该路径。 |
| 用默认程序打开 | 把文件交给操作系统默认程序打开。仅对文件有效——目录没有默认程序。 |
后两项是唯一需要 Host 的动作:它们经由 DSH Web 服务器上一条带认证的本地 HTTP 路由,在触碰桌面之前先针对所属会话的工作区根目录校验该路径。
为什么要做这个
DSH 其实已经能打开一部分路径。ui-deliverables 会把某一轮创建过、或被显式交付的文件变成可点击的 chip,卡片的菜单里也能在资源管理器或 Finder 中定位它们。但仍有两个缺口:
- 只有被声明过或被修改过的文件可点。 模型在正文里写的其它任何路径都是纯文本,点了没反应。deliverables 的 README 在自己的限制一节里写明了这点:终端创建的文件必须显式调用
present才会被收录。 - 目录没有去处。 同一份 README 记录了原本的「原生打开文件夹」能力是被移除、而不是被替换的。
而侧栏的文件树——唯一一处列出工作区全部内容的界面——每一行只有一个手势:左键打开或展开。它根本没有 onContextMenu 处理函数,于是读者最常想要的那个路径,恰恰是只能手抄的那个。
本插件补上这个手势。文件树的其它行为一概不变。
安装
从 tarball 安装
cd work/dsh-sidebar-file-menu
pnpm pack
cp dsh-sidebar-file-menu-0.1.0.tgz "$DSH_HOME/profiles/web/"
dsh plugin --profile web add './dsh-sidebar-file-menu-0.1.0.tgz'
当 tarball 放在 profile 目录里时,dsh plugin add 这一步必须在 profile 目录下执行:dsh 会把裸相对路径解析到 profile 自己的 plugins/ 目录,而不是 shell 的当前目录。绝对路径也按同一目录解析,因此含空格的路径会匹配失败;把 tarball 复制进 profile 并写成 ./<文件名>.tgz 才是可行的形式。
然后重启 dsh web 并刷新页面。插件是从启动时组装好的名册里下发的——运行中的服务器不会自动加载它,重启之前它的 boot graph 里也不会列出本包。
验证安装
dsh --profile web --dump-config | grep -A2 sidebar-file-menu
预期输出:
# == dsh-sidebar-file-menu
- id: sidebar-file-menu
name: dsh-sidebar-file-menu
使用
打开右侧栏,切到 Files 标签,在任意一行上右键。文件树本身的行为与之前完全一致:左键点目录是展开、点文件是在右侧栏预览。
架构
这一节解释插件为什么长成现在这样;它同时也是带团队过一遍「一个 DSH Web 插件是怎么拼起来的」的现成材料。
接管一个内置的标签页类型
右侧栏通过两个阶段组装标签页类型:ctx.sidebarRightTabs.register() 声明一个类型是什么,ctx.slots.register({ name: 'sidebar.right.pane.tab', key: <类型 id> }) 提供它画什么。
内置文件树在 builtin 优先级带上声明了 files 这个 kind。ui-sidebar-right 允许同一个 kind 最多各有一个 builtin 和一个 extension 注册,且 extension 带优先级最高——它自己的源码里记着:key 空间之所以保持开放,是因为「一个标签页类型可能来自本仓库之外」。因此,用同一个 files kind 再注册一个类型并省略 priority,就能让本实现生效,而无需改动内置包:
ctx.sidebarRightTabs.register({ id: MENU_ID, kind: 'files', title, guide })
由此得到两个性质,两个都重要:
- 可逆。 内置注册从未被改动,所以卸载本插件后内置文件树会原样恢复。没有任何补丁需要回滚。
- 互斥。 同一时刻只有生效的那个注册会挂载 body,因此内置的
sidebarFiles字典注册在此期间根本不会被挂载。不存在副本冲突,而本插件复用了内置命名空间来渲染文件树自己的行与失败提示。
代价是真实存在的,已记录在已知限制中:文件树是复刻而非扩展,因为内置的 FilesBody 没有暴露任何行级扩展位——它自己的 README 说目前不存在格级席位,因为「还没有东西需要它」。
浏览器到 Host:为什么用路由而不是 Remote
写剪贴板从不离开页面。而在文件管理器中定位或打开程序是原生操作,必须抵达 Host。
DSH 提供两种跨越这条界线的方式,它们不可互换:
Typert Remote (@Remote) |
裸 webServer 路由 |
|
|---|---|---|
| 由谁声明 | Host 服务上带装饰器的方法 | ctx.webServer.register() |
| 何时抵达 Client | 构建期把该贡献选入 @deepseek-ai/dsh-api-remotes |
插件的 Host 半边被挂载时 |
| 适合 | 有类型的业务操作 | 小而自包含的副作用 |
第三方插件无法把自己加进 @deepseek-ai/dsh-api-remotes——那个装配是在 monorepo 内部构建的——所以用 Remote 就意味着必须改动 harness 源码。路由则不需要改任何东西。dsh-host-open-in-app 为自己的启动端点得出了同样的结论;本插件沿用了这一先例,该决定记录在那个包的转正说明里。
路由是 POST /sidebar-file-menu/action,请求体为 { verb, path, sessionId }。
信任围栏
一条能打开本地文件的路由,是值得认真对待的能力。每个请求按顺序经过:
connection.requestRejection(req)—— 组合自身的 Host/Origin 围栏与登录 token cookie 校验。未认证的调用者根本走不到路径处理。- 方法与媒体类型 —— 只接受
POST,只接受application/json。 - 16 KiB 请求体上限,并把剩余部分排空,使拒绝成为可读的响应而不是一次 socket 切断。
- 形状校验 ——
verb必须是三个精确字面量之一;path与sessionId必须是非空字符串。 - 能力检查 —— 在做任何路径处理之前先调
canOpenNativePath(),让无桌面的宿主回答「没有桌面」,而不是以晦涩的方式失败。 - 文件系统授权 —— 解析会话的工作区根目录,在任何东西跟随它之前先
lstat该路径(因此一个指向工作区之外的链接会被归类,而不是被跟随),并且解析后的目标必须被该根目录包含。根目录之外的路径以403拒绝,而不是被打开。
几个值得直说的结论:
- 这条路由无法被改造成通用的文件启动器。它只打开所属会话工作区之内的路径,别的一概不行。
- 参数注入不是问题:打开器用 argv 数组派生可执行文件,从不经过 shell。
- 失败被归约为错误码(
no-desktop、not-found、outside-workspace、native-command-failed)并在 Client 侧重新本地化,因此不会有 Host 的文案抵达读者,也不需要翻译任何 Host 消息。
用到的扩展点
作为一份讲解材料,这一个小插件触及了 DSH 的六种不同机制:
| 机制 | 位置 |
|---|---|
| 槽位注册 | sidebar.right.pane.tab,以类型 id 为 key |
| 优先级带接管 | 以 extension 带调用 sidebarRightTabs.register |
| 带类型的 locale 字典 | ctx.locale.register 注册 sidebarFileMenu,并复用内置的 sidebarFiles |
| Cordis effect | 每一处注册都在 ctx.effect 内,因此销毁时会整体回滚 |
| 消费生成的 Remote | 用 ctx.remote.workspaceFiles.list 列目录 |
| Host 路由注册 | ctx.webServer.register,位于 connection 信任围栏之后 |
构建:shell 期望的产物
shell 不会 import 插件的浏览器半边。它取回 lib/client.js 并求值,而那个产物必须调用:
window.__ModuleLoader__.load({ id, factory: (require) => { /* … */ } })
module 与 exports 在那个作用域里并不存在,这正是 tsdown.config.ts 用 banner 提供它们、用 footer 收尾工厂函数的原因。
shell 还会预置一张固定的模块表(packages/client/web/src/platform.ts),其中恰好九个 specifier。neverBundle 就是那张表、不多不少:模块表无法应答的 require() 会在启动时抛错,所以其它一切——包括 clsx 与 @deepseek-ai/dsh-util-workspace-path——都被内联。
共享的 clientBundle tsdown preset 做的正是这些事,但它没有发布到 npm,且会从 harness 检出目录里 import 辅助模块,所以 monorepo 之外的包无法调用它。这里的 tsdown.config.ts 复刻了决定产物的那几个部分:格式、外部依赖、包装、输出名。样式表同样出于这个原因以文本形式放在 src/client/style.ts 里——那个 preset 通过 lightningcss 编译 CSS,而它无法从插件目录解析。
没有任何东西依赖插件必须待在 harness 检出目录内。它作为独立包构建与打包。
开发
"$DSH_CHECKOUT/node_modules/.bin/tsdown" # 改动 src/client/ 后重新构建浏览器包
pnpm pack # 打包;lib/index.js 不需要构建
验证
四个校验程序无需浏览器、无需运行中的服务器、也无需桌面。每个都接受一个可选的路径参数,因此既可以指向构建产物,也可以指向已安装的产物:
node verify.mjs [path/to/client.js] # 加载包体,用假 context 驱动 apply()
node verify-render.mjs [path/to/client.js] # 用真实 React 渲染组件
node verify-host.mjs [path/to/index.js] # 请求体解析与路径授权
node verify-route.mjs [path/to/index.js] # 端到端跑一遍 HTTP 处理链
verify.mjs复现 shell 的交接过程:桩掉平台模块表、对包体求值、捕获window.__ModuleLoader__.load注册、调用工厂函数,并用一个假的 Cordis context 驱动apply()。它专门断言接管语义——kind 为files、priority 被省略、body 以同一个 id 为 key、字典已注册、所有注册都在ctx.effect内。verify-render.mjs把真实的react与react-dom/server放进模块表,从槽位注册里取出组件并渲染它:一个含目录与文件的目录列表、无工作区提示、加载中层级、失败层级。它还会断言菜单的 action id 与字典键一致——这类字面量一旦漂移,运行时就会变成一行空白菜单项。verify-host.mjs用假文件系统检验安全决策:合法的请求体、未知 verb、解析到工作区之外的路径、不存在的条目、非文件条目、无法解析的根目录,以及未知会话的回退。它还钉住了lstat(path, opts, signal)的参数形状。verify-route.mjs在回环端口上把捕获到的路由处理函数跑起来,用真实的IncomingMessage/ServerResponse驱动它,因此中间件顺序与生产一致:信任围栏最先,然后是方法、媒体类型、请求体上限、形状、授权。它只断言那些在原生打开器运行之前就被拒绝的情形,所以绝不会弹出窗口。
针对隔离 home 的真实启动
这四个校验程序都伪造了 Cordis context,因此抓不到只有真实容器才拒绝的接线错误。启动一个一次性实例可以抓到,同时不碰正在运行的 profile 及其会话:
# 建一个隔离 home,只读共享真实 profile 的包缓存。
export DSH_HOME="$TEMP/dsh-verify-home" # PowerShell: $env:DSH_HOME = ...
mkdir -p "$DSH_HOME/profiles/web"
# 把 "$DSH_HOME/profiles/node_modules" 以 junction 指向真实 profile 的 node_modules,
# 再给 profiles/web 一个 package.json,其 dsh.profile.bundles 列出本插件,
# 然后用 `dsh plugin --profile web add ./plugin.tgz` 安装 tarball。
dsh web --port 3099 --host 127.0.0.1
接着确认插件出现在下发的 boot graph 中,并且它的路由能应答:
curl -s "$BASE/?token=$TOKEN" | grep -o 'dsh-sidebar-file-menu/client.js' # 必须出现
curl -s -X POST "$BASE/sidebar-file-menu/action" -H 'content-type: application/json' \
-d '{"verb":"reveal","path":"x","sessionId":"y"}' # {"ok":false,"code":"not-found"}
第二条调用才是真正的激活证明:框架返回 404 说明路由压根没注册;返回本插件自己的 JSON 则说明它已经注册,就在一个真实 Loader 里、位于信任围栏之后。
这是最重要的一项检查,而它抓到了一个校验程序抓不到的 bug。 apply 会读取 ctx.sessions,但导出的 inject 列表里漏了 sessions。一个普通的假 context 对未声明的属性返回 undefined,于是所有校验程序都通过了;而真实的 Cordis proxy 会抛错 cannot get property "sessions" without inject,整棵插件树启动失败。修法就在 inject 列表本身。apply 读取的任何新服务都必须在其中列名。
没有任何校验程序覆盖的一点是真实文件树里的浏览器 DOM 渲染。渲染套件证明了组件树能构建、React 接受它,但要确认菜单真的出现,仍然需要浏览器。
node_modules/clsx、node_modules/@deepseek-ai/dsh-util-workspace-path 与 node_modules/@deepseek-ai/dsh-native-command 需要在本地存在:前两个是为了让打包器把它们内联,而不是留下一个浏览器模块表无法应答的 require;第三个是为了让 Host 半边及其校验程序能在 profile 之外被 import。三者都在 gitignore 中;用指向 DSH profile 已安装副本的链接重建即可:
$prof = "$env:USERPROFILE\.dsh\profiles\node_modules"
New-Item -ItemType Junction -Path node_modules\clsx -Target "$prof\clsx"
New-Item -ItemType Junction -Path node_modules\@deepseek-ai\dsh-util-workspace-path -Target "$prof\@deepseek-ai\dsh-util-workspace-path"
New-Item -ItemType Junction -Path node_modules\@deepseek-ai\dsh-native-command -Target "$prof\@deepseek-ai\dsh-native-command"
lib/index.js 是手写的普通 JavaScript,不需要构建步骤。lib/ 与 *.tgz 都在 gitignore 中。
由于 pnpm pack 与 dsh plugin add 都按版本号缓存,重新安装改动过的构建时要么升 version、要么先 remove 再 add;同版本的 tarball 会报「Already up to date」并继续保留旧字节。
已知限制与待办
- 文件树是复刻的,不是扩展的。 内置的
FilesBody没有声明行级扩展位,所以想加一个手势就得自己拥有这个组件。后果是:上游ui-sidebar-files的布局修复不会自动进入这棵树。未来某个 harness 版本若加入行操作槽位,本插件就能缩小到只剩菜单。 - 原生打开作用于提供服务的 Host。 远程浏览器打开的是服务器桌面上的程序,不是读者本机的。
ui-deliverables记录了同一约束。 - 没有键盘入口。 菜单可通过右键或菜单键唤出,但没有命令面板动作,也没有快捷键绑定。
- 相对路径是在 Client 侧算的,靠归一化分隔符并剥掉根前缀,而不是去问 Host。这对显示与剪贴板文本够用,但它不是包含性检查;那项检查由 Host 路由单独执行。
openText这个 verb 存在但未被使用。 它已经接进 Host 路由(macOS 会绕过文件类型关联,这样 YAML 关联到浏览器也不会吞掉这个手势),以备将来加入针对文本的动作。目前它是只能靠直接调用路由才能触达的死代码。- 没有自动化测试。 harness 把测试挂在 monorepo 的
test:coverage与快照基础设施之后,独立包无法运行。验证是手动的;见验证安装。
许可证
MIT
No comments yet. Be the first to write one.