dsh-file-selector
给 DSH(DeepSeek Harness)自带的文件浏览器和文档预览补上「选择」这一半:把文件路径,或预览里
划选出的行范围,追加进输入框草稿。永远不会自动发送 —— 代码里没有任何一处调用 submit。
English — adds the missing selection half to the harness's built-in file browser and document preview: right-click a tree row, or a text selection inside a preview, to append that path (or line range) into the composer draft. It never sends anything.
安装
dsh plugin add chiqianyushao/dsh-file-selector
或者用 GitHub Release 上预构建的 tarball(同样不需要构建,适合不想走 git 依赖的环境):
dsh plugin add https://github.com/chiqianyushao/dsh-file-selector/releases/latest/download/dsh-file-selector.tgz
DSH Desktop:侧栏 插件 → 添加插件 → 粘贴 https://github.com/chiqianyushao/dsh-file-selector
→ 安装 → 重启 DeepSeek Harness。桌面端的 profile 由 Electron 独占,所以不要用
dsh plugin --profile desktop … 装。
仓库里已提交 lib/,因此从 GitHub 安装时不需要构建、也不需要授权构建脚本。零运行时依赖 ——
客户端 bundle 只 require('react')。
围绕 harness 0.1.5 重写过一次,又跟着 0.1.7 修过一次 API 漂移(见下方「这个接缝动过一次」)。
0.1.5 起 harness 自己带了工作区文件树(@deepseek-ai/dsh-client-ui-sidebar-files)和文档预览
(@deepseek-ai/dsh-client-ui-sidebar-documentpreview),所以更早那套「自己画一棵树 + 自己写两条
host 路由 + 自己读文件」的实现全部删掉了:不再有任何 host 面(无路由、无 fs、无鉴权代码),
客户端 bundle 只 require('react') 一个模块。
当前适配:DSH Desktop 0.12.0 / harness 0.2.0-rc.2(作者本地运行版本);最初围绕 0.1.5–0.1.7 写成,0.1.7 上验证过。插件版本 0.2.1。
一、用起来是什么样
| 你在哪 | 手势 | 结果 |
|---|---|---|
| 右侧栏文件树里的某一行 | 右键 | 菜单两行:添加到对话 / 复制路径 |
| 预览里划选一段后右键 | 右键 | 添加到对话(第 5-7 行) / 复制路径 |
| 预览标签页本身 | 右键(dockkit 的标签菜单) | 末尾多一行 添加到对话(…) |
追加进草稿的只有引用文本,一行一条,永远不搬文件内容:
src/main.ts
src/client/index.ts:5-7
README.md:12
路径会尽量缩成相对工作区的写法:文件树用它 DOM 上自己的 data-files-root 当根,预览用地址里
已经相对的路径。二进制、超大文件、markdown、图片都能引用 —— 因为根本不读内容。
草稿是追加(读当前 draft → 拼一行 → 整体写回),因为 inputActions.setDraft 是整体替换语义、
没有 append。写完之后还会回读确认(见下)。
二、为什么是这三个面、两个机制
不是选出来的,是把 harness 的扩展点翻完之后剩下的:
sidebar.right.tab.menu.item(官方 slot,主力)。这是官方 slot 契约表里右侧栏唯一的list槽, 原话是「Extra items at the end of one tab's actions menu」。owner 交过来的是{ tab, dismiss }, 加上 session 作用域的标准 propinputActions/useInput/useSessions/sessionId—— 于是写草稿这件事完全走公开接口。tab.contentId就是dsh-resource://file/session/<sid>/<path>,解出路径即可;不是文件地址的标签页(文件树自己、guide) 就渲染null。- 两个委托式
contextmenu,补官方没给菜单的地方:文件树的行(选中一个文件而不必先打开它), 以及预览里的文字选区(行范围)。两者都读官方自己的 DOM 钩子 ——data-files-entry/data-files-path/data-files-root/data-textpreview-body/data-textpreview-url/data-textpreview-line,也就是 harness 自己的scrollToLine导航时用的那批属性。
为什么不是别的:
- 行级动作没有 slot。
sidebar.right.pane.tab和sidebar.right.tab.document都是keyed(一个 key 一个 occupant),文件树的行上也没有任何注入点。要「在自带的行上右键」就只剩 DOM 委托。 slash/input-insert-text/-reference用不了。这两个事件看着像官方插入通道,但insertText(text, span)第一行就是if (span.draftRev !== this.rev) return false—— span 是触发器 picker 的「pick 时刻快照」,从外部伪造就是和一个我读不到的 revision 做 CAS。 所以仍然是inputActions.setDraft。- 不用
@path原生引用语法。那会把内容重新拉进来(host 的fileReferencesseam 会展开), 与「只要路径」相悖。引用文本里没有@,因此不会被当成引用语法解析。
委托式右键的草稿能力是从哪来的
两条委托式手势跑在 React 之外,拿不到 slot 的 prop。它们问框架自己要:拿到当前 session 的
standard-source binding({ key, ctx, hooks, keyedHooks, props },就是框架发给 slot occupant 的那一份),
其中 props.inputActions 是写草稿的动作面、hooks.input 的 getSnapshot().draft 是当前草稿。
每次手势现解,不缓存、不经过任何组件。
这两个服务刻意走 ctx.get 而不是 ctx.sessions 属性读取:属性读取要求 inject 声明,
而声明了 inject: ['sessions','uiSession'] 的插件在缺少它们的组合里根本不会激活;
get 是「无需 inject 的读取」那条官方通道,于是缺服务只会退化成一行说明,而不是手势中途抛错、
也不是整块功能消失。
早先这里还用过「在 conversation.composer.dock 里塞一个隐形组件,把 inputActions 发布到模块级变量」——
错了两次:composer 只在有 extension zone 时才渲染那个 slot,所以 zone 不在时菜单直接变死;
而且「最后一个渲染的 occupant」未必是读者正在指的那个 session。
这个接缝动过一次,第二次就靠它自己报出来了
| harness | 当前 binding 怎么拿 | session id 从哪来 |
|---|---|---|
| 0.1.5-rc.2 | uiSession.resolve(sessionId) |
sessions.list.getSnapshot().current |
| 0.1.7-rc.2 | uiSession.adapter.current.getSnapshot() |
binding 自己的 key —— 列表不再发布 current |
升到 0.1.7 后本插件直接退化成了「添加到对话不可用」:它查的 list.current 已经没了。
两个教训都写进代码了:
- session 身份取 binding 的
key,不取列表的投影。列表current和 binding 的key是同一个 事实的两种投影,我挑了错的那个;而 app 自己的右侧栏读的正是adapter.current.getSnapshot().key。 - 兼容阶梯 + 指名道姓的失败原因。
currentBinding()先试新形状再试旧形状,全都失败时那行菜单会写uiSession exposed no current-session binding—— 正是这行字把「升级后坏了」变成一句话的诊断, 而不是又一次「点了没反应」。测试台会双向演练这个阶梯(两种形状各跑一遍), 并断言第三种未知形状必须报出接缝名。
行号是怎么算出来的
预览按文件类型换 body:纯文本走 [data-textpreview-line="N"] 一行一个属性;高亮代码把活交给
primitives 的 CodeBlock,画的是 pre .line。两条路都读,规则和 harness 自己的 scrollToLine
一致(pre .line 取全局序号 +1)。另外:拖到某行行首 offset 0 收尾时,那一行其实一个字都没选中,
行尾要退一行。
为什么要有「记住选区」
行范围最自然的入口是标签菜单,而右键标签会把预览里的 DOM 选区清掉 —— 手势本身毁掉了它想引用的
东西。所以有一个 selectionchange 监听把最后一次落在预览里的选区连同它所属的文档地址记下来,
标签菜单再用。带地址校验,所以 A 文件的选区不会串到 B 文件。预览内的右键则只用实时选区:
右键落在选区内 Chromium 不会清选区,落在别处就说明用户要的是整个文件,这时用记忆值反而是错的。
为什么菜单点了没反应(两次)
菜单是手写的 body 级元素,所以「点一下」这件事必须自己保证。踩过两个都只在真实浏览器里成立的坑:
blur用了捕获阶段。blur不冒泡,只有捕获态的 window 监听器能听到后代的 blur —— 而点一行本身就是一次焦点转移:composer 的编辑器失焦 → 这个监听器把菜单拆了 → 行从文档里消失 →mouseup落空 → 那个本该执行动作的click根本没被派发。 两行同时失效,就是这个原因。现在这个监听器是普通(非捕获)的:window 自己的 blur 仍然会关菜单, 后代的不再会。- 写草稿没有回读确认。
setDraft接受了却没落到草稿上,和「点击根本没发生」在外部完全一样。 现在写完会回读同一个 store 确认,不匹配就在屏幕上报出来(不是只打 console —— 浏览器 console 不进 harness 日志,只打日志的失败等于没人看得见)。
同样的原则也用在剪贴板上:异步 writeText 被拒时回退到 selection + execCommand('copy'),
两条都失败才报出来;以前那个 .catch(() => {}) 把拒绝吞掉了,所以「复制路径没反应」看起来和
「添加到对话没反应」一模一样。
三、布局
src/index.ts host 半 —— 故意是空的(无服务/无路由),只为让 bundle row 合法
src/client/index.ts 全部功能:地址解析 / 选区与行号 / 兼容阶梯 / tab 菜单行 / 委托式右键菜单
cordis.patch.yml bundle patch:插入一行,按包名解析,无 config
scripts/build.mjs 零依赖构建:stripTypeScriptTypes + 包成 module-loader 工厂
scripts/verify.mjs 98 项校验,含假 DOM + 假 React + 假 sessions/uiSession 的行为测试台
node scripts/build.mjs # -> lib/index.js, lib/client.js
node scripts/verify.mjs # 98 checks
pnpm test # 两个都跑
typescript 只用 Node 自带的 stripTypeScriptTypes 擦除,没有打包器、没有网络安装。
verify 是真的在跑插件
每一轮 bug(读未 inject 的 Cordis 服务 → HTTP 400、z-index 压住菜单、React 身份不稳导致树被重置、
草稿写入依赖一个条件渲染的 slot、捕获态 blur 让 click 根本没发生、uiSession API 漂移)
全都是客户端运行时问题,源码级检查一个都看不见。所以 scripts/verify.mjs 会把真的
lib/client.js 物化在一个假 DOM + 假 React + 假 sessions/uiSession 上,然后驱动它:
右键一个树行、点弹出的那一行、在预览里造一个选区、渲染 slot occupant,最后断言
真正到达 setDraft 的字符串,以及到达的是哪个 session 的 setDraft。
三个让它有牙的设计:
- 假 DOM 模拟事件三阶段(捕获 / 目标 / 冒泡 +
blur这类不冒泡的类型), 而且每次点一行都按真实序列派发pointerdown→ 焦点转移的blur→click。 上一个 bug 就藏在这条序列里,而更早的套件只发一个孤立的click—— 所以它在浏览器里死了、 在测试里全绿。 - 假
uiSession可以在两种形状之间切换,所以兼容阶梯是双向演练的,而不是只在当前 harness 恰好指向的那一支上跑。 - 假 context 用 Proxy 模拟 Cordis 的两条规则:属性读取未声明的服务就抛(历史上正是这个把每个请求
变成裸 HTTP 400),
ctx.get才是允许的通道;并断言插件从头到尾只碰过effect/get/slots。
变异测试(改坏实现必须变红)最近一轮 11 个全部让套件变红,其中两个正是复现了你看到的现象:
| 变异 | 套件反应 |
|---|---|
去掉 0.1.7 的 adapter.current 那一支 |
✗ 菜单行变成 添加到对话不可用 |
session id 改回读 list.current |
✗ 菜单行变成 添加到对话不可用 |
去掉 0.1.5 的 resolve() 那一支 |
✗ 旧形状那条断言 |
| blur 改回捕获阶段 | ✗ 两处(阶段断言 + 点击序列守卫) |
| 去掉写入回读确认 | ✗ 屏幕提示断言 |
| 剪贴板拒绝被吞掉 / 去掉回退 | ✗ 两条 |
| 预览右键也允许用记忆选区 | ✗ |
标签菜单不再 dismiss() |
✗ |
| 能力缺失时不标注原因 | ✗ |
| contextmenu 不再用捕获阶段 | ✗ |
四、安装(DSH Desktop / profile web)
Desktop 的应用市场装不了本地/未发布的包(只接受 name@exact.version),必须走终端 CLI,
且 DSH_HOME 必须指向 Desktop 的 harness,否则会装进另一个 dsh 安装:
$env:DSH_HOME = "$env:APPDATA\dsh-desktop\harness"
dsh plugin --profile web add link:E:\dsh-plugiun
装完之后:profile 的 dependencies 里出现 dsh-file-selector: link:E:/dsh-plugiun,
dsh.profile.bundles 里出现 dsh-file-selector,
profiles/web/node_modules/dsh-file-selector 是指向本仓库的 Junction。
改完代码要重启 DSH Desktop:host 半是 patchReload: live 热加载的,但客户端 bundle 图是在
激活时组装的,重启才会重新拉 /plugins。重启会杀掉当前会话。
出问题的退路:Desktop 有恢复界面 —— 「进入安全模式」(profiles/desktop-safe-mode)、
「卸载插件」、「升级插件并重启」。
五、验证到什么程度
已验证
- host 半:无服务、无路由、不读文件(断言出来的,不是声称)。
- 客户端 bundle:classic script、模块 id 等于包名、只
require('react')、apply能在 「只声明了 slots」的严格 context 上跑完(任何未声明的服务读取都会抛)。 - 注册项只有一个(
sidebar.right.tab.menu.item),只带name/id/order,slot 名逐字符断言; 且不注册 composer dock 锚点。 - 草稿能力解析:0.1.7 与 0.1.5 两种 binding 形状、未知形状必须报出接缝名、
session 身份取自 binding 的
key、写入落在当前 session 而不是第一个 session。 - 能力缺失时菜单显示一行
添加到对话不可用 · 原因(inert + 原因入 title),复制路径仍可用。 - 追加语义:空草稿、非空草稿、连续两次追加;写完回读确认;写不进去时屏幕提示。
- 剪贴板:异步接口成功 / 被拒后回退 / 两条都失败则报出。
- 路径归一化:树用自己的
data-files-root作根;工作区内绝对路径 → 相对;工作区外保留绝对。 - 行范围:多行、单行、
offset 0收尾、无选区、跨文档不串。 - 菜单:位置夹取到视口内、点外部/Escape/窗口失焦关闭、动作后关闭、监听器回收、 后代失焦不关闭。
- 全程没有
submit。
没验证(如实说)
- 真实浏览器里的层叠顺序。菜单是
position: fixed; z-index: 1200(官方 Menu 卡片的 portal 用 1100, 右侧栏面板用 10),比它们高,但没有在真实浏览器里量过。 - Chromium 在右键时是否真的保留预览选区。「实时优先、记忆兜底」的分工就是为它写的, 两种行为都能工作,但「落在选区内不清选区」这条假设本身是照 Chromium 常规行为推的。
- dockkit 标签菜单的实际渲染。行样式是按 harness
Menu.module.css的 menu cell 规格写的 (min-height 40 / r10 / pad 10-8 / 14-22 / hover fill),但没在真实菜单里比对过像素。 - markdown / 图片 / PDF 预览里没有行号(本来就没有行),只有路径。
六、下一步可以做的
- 真实浏览器里量一次层叠与菜单外观(profile 的 node_modules 里就有 Puppeteer)。
- 如果
sidebar.right.tab.menu.item将来变成行级 slot,把文件树那个委托式右键换掉。 uiSession这个接缝是半公开的,已经动过一次;下次升级如果又变,菜单里的原因会直接告诉你。
授权
MIT
No comments yet. Be the first to write one.