Skip to content
dsh.fish
Bundle

dsh-sidebar-file-menu

VS Code-style right-click menu for the DSH Web UI right-sidebar file tree: copy absolute/relative path, copy name, reveal in Explorer/Finder, open with the default application

Source
yudaxia1
License
MIT
Updated
Updated 11 hours ago

Readme

# dsh-sidebar-file-menu

为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) Web UI 右侧栏文件树提供 VS Code 风格的右键菜单。

> [English README](./README.en.md)

在文件树的任意一行上右键,会弹出菜单:

| 菜单项 | 行为 |
|---|---|
| **复制绝对路径** | 把该行的绝对路径写入系统剪贴板。 |
| **复制相对路径** | 把相对当前会话工作区根目录的路径写入剪贴板。不在根目录下的行不显示此项。 |
| **复制名称** | 把路径的最后一段写入剪贴板。 |
| **在文件管理器中显示** | 在资源管理器(Windows)、Finder(macOS)或桌面文件管理器(Linux)中选中该路径。 |
| **用默认程序打开** | 把文件交给操作系统默认程序打开。仅对文件有效——目录没有默认程序。 |

后两项是唯一需要 Host 的动作:它们经由 DSH Web 服务器上一条带认证的本地 HTTP 路由,在触碰桌面之前先针对所属会话的工作区根目录校验该路径。

## 为什么要做这个

DSH 其实已经能打开**一部分**路径。`ui-deliverables` 会把某一轮创建过、或被显式交付的文件变成可点击的 chip,卡片的菜单里也能在资源管理器或 Finder 中定位它们。但仍有两个缺口:

1. **只有被声明过或被修改过的文件可点。** 模型在正文里写的其它任何路径都是纯文本,点了没反应。deliverables 的 README 在自己的限制一节里写明了这点:终端创建的文件必须显式调用 `present` 才会被收录。
2. **目录没有去处。** 同一份 README 记录了原本的「原生打开文件夹」能力是被**移除**、而不是被替换的。

而侧栏的文件树——唯一一处列出工作区全部内容的界面——每一行只有**一个**手势:左键打开或展开。它根本没有 `onContextMenu` 处理函数,于是读者最常想要的那个路径,恰恰是只能手抄的那个。

本插件补上这个手势。文件树的其它行为一概不变。

## 安装

### 从 tarball 安装

```sh
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 里也不会列出本包。

### 验证安装

```sh
dsh --profile web --dump-config | grep -A2 sidebar-file-menu
```

预期输出:

```yaml
# == 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`**,就能让本实现生效,而无需改动内置包:

```ts
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 }`。

### 信任围栏

一条能打开本地文件的路由,是值得认真对待的能力。每个请求按顺序经过:

1. **`connection.requestRejection(req)`** —— 组合自身的 Host/Origin 围栏与登录 token cookie 校验。未认证的调用者根本走不到路径处理。
2. **方法与媒体类型** —— 只接受 `POST`,只接受 `application/json`。
3. **16 KiB 请求体上限**,并把剩余部分排空,使拒绝成为可读的响应而不是一次 socket 切断。
4. **形状校验** —— `verb` 必须是三个精确字面量之一;`path` 与 `sessionId` 必须是非空字符串。
5. **能力检查** —— 在做任何路径处理之前先调 `canOpenNativePath()`,让无桌面的宿主回答「没有桌面」,而不是以晦涩的方式失败。
6. **文件系统授权** —— 解析会话的工作区根目录,在**任何东西跟随它之前**先 `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` 并求值,而那个产物必须调用:

```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 检出目录内。它作为独立包构建与打包。

## 开发

```sh
"$DSH_CHECKOUT/node_modules/.bin/tsdown"   # 改动 src/client/ 后重新构建浏览器包
pnpm pack                                   # 打包;lib/index.js 不需要构建
```

### 验证

四个校验程序无需浏览器、无需运行中的服务器、也无需桌面。每个都接受一个可选的路径参数,因此既可以指向构建产物,也可以指向已安装的产物:

```sh
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 及其会话:

```sh
# 建一个隔离 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 中,并且它的路由能应答:

```sh
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 已安装副本的链接重建即可:

```powershell
$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

Install

dsh plugin --profile web add github:yudaxia1/dsh-sidebar-file-menu

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source