DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

yudaxia1 /

yudaxia1/dsh-sidebar-file-menu

Verified

为 DeepSeek Harness Web UI 右侧栏文件树提供 VS Code 风格右键菜单:复制绝对/相对路径与名称、在资源管理器或 Finder 中显示、用默认程序打开。通过 extension 优先级带原地接管内置 files 标签页,不新增 tab,卸载即还原。

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@d1447a33

dsh-sidebar-file-menu

为 DeepSeek Harness Web UI 右侧栏文件树提供 VS Code 风格的右键菜单。

English README

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

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

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

为什么要做这个

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

  1. 只有被声明过或被修改过的文件可点。 模型在正文里写的其它任何路径都是纯文本,点了没反应。deliverables 的 README 在自己的限制一节里写明了这点:终端创建的文件必须显式调用 present 才会被收录。
  2. 目录没有去处。 同一份 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 }。

信任围栏

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

  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 并求值,而那个产物必须调用:

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

—/ 5

No ratings yet

Verified DSH bundle

Commit d1447a33d84d

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout