dsh-file-extras
DSH Web GUI 的日常操作增强插件。主体是在官方「文件」页(@deepseek-ai/dsh-client-ui-sidebar-files)上补右键菜单、行内元数据、多选与删除;另外收了两件与文件树无关、但同样天天要用的能力:文件驱动的斜杠命令与输入框 ↑/↓ 历史指令回填。
| 需求 | 现在的行为 |
|---|---|
| 覆盖默认浏览器右键菜单 | 在文件树内右键弹出自己的菜单(树外一律不接管,浏览器默认菜单照旧) |
| 文件右键 | 下载 / @ 到对话 / 复制路径 / 删除 |
| 文件夹右键 | 上传文件到此处… / @ 到对话 / 复制路径 / 删除 |
| 空白处右键 | 上传文件到此处… / 复制路径 / 刷新大小与日期 / 全选可见项(有选择时再加 清除选择 与 删除 N 项) |
| 文件大小 + 修改日期 | 行尾显示 1.2 KB · 2026-03-04 13:06:07,按字节自动选单位(B/KB/MB/GB/TB/PB,1024 进制) |
| 文件夹大小 + 修改日期 | 行尾显示 3 项 · 2026-03-04 13:06:07;只数第一级子级(readdir 一层,不递归) |
| 删除(文件夹 / 单文件 / 多文件) | 走 二次确认框(列出每一项是什么、多大、文件夹含多少内容);默认移到回收站(可还原),「永久删除」藏在默认不勾的复选框后面 |
| @ 到对话(文件 / 文件夹) | 直接在当前对话的输入框里插一枚 @ 引用芯片,和用户自己敲 @ 选出来的是同一种东西 |
| 斜杠快速命令 | ~/.dsh/commands/*.md 一个文件一条 /命令,正文可带 $ARGUMENTS 占位符;改文件立即生效 |
| ↑/↓ 快速切换历史指令 | 输入框里按 ↑/↓ 像终端一样翻出本会话自己敲过的指令,Esc 退出 |
斜杠快速命令
~/.dsh/commands/<名字>.md 一个文件一条全局斜杠命令,命令名就是去掉 .md 的文件名
(文法与注册表逐字一致:/^[a-z][a-z0-9_-]*$/)。
---
description: 审查当前工作区的代码改动
input-hint: 可选的补充说明
---
请审查当前工作区的代码改动,重点关注潜在 bug 和安全问题。
$ARGUMENTS
| 行为 | 说明 |
|---|---|
| frontmatter | 读 description(发现界面里的说明)与 input-hint(输入提示),其余键忽略但容忍 |
$ARGUMENTS |
换成用户敲在命令名后面的原始参数;没有这个占位符时正文原样发出 |
| 改文件立即生效 | 正文是每次调用时重新读盘的,不用重启 |
| 新增 / 删除文件 | 需要重启 dsh web 才反映(注册是启动时扫一遍目录) |
| 执行结果 | 正文作为一条普通用户消息交给 agent —— 与用户自己把这段话敲进输入框等价,不是系统注入 |
用的是官方 ctx.commands.register()(@deepseek-ai/dsh-commands),所以发现、补全、
command/run / command/done 生命周期事件全归官方。与 app 级命令(/goal 之类)重名时
只跳过这一条并告警,不会把其余命令一起带走。
这条能力移植自 dsh-command-md(同一作者,MIT)。
↑/↓ 快速切换历史指令
在输入框里按 ↑ 翻出本会话里自己敲过的指令,再按 ↑ 往更早走,↓ 往新的走。
第一次 ↑ 时当前草稿会存起来,一路 ↓ 翻回最新一条之后再按 ↓(或按 Esc)就原样还回来。
穿梭时输入框上方有一条 历史指令 … ↑↓ 3/12 · Esc 退出 的提示条。
| 键 | 接管条件 | 行为 |
|---|---|---|
↑ |
光标已贴在草稿最开头(或草稿为空) | 进入穿梭 / 往更早一条 |
↓ |
只在穿梭模式里 | 往新一条;已是最新时还回原草稿并退出 |
Esc |
只在穿梭模式里 | 还回原草稿并退出 |
| 任何真实输入 | 只在穿梭模式里 | 立即退出穿梭(beforeinput:打字 / 退格 / 粘贴 / 拖入 / 输入法) |
/、@ 候选菜单开着时 |
—— | 一律不抢键(那里 ↑/↓ 是选候选) |
数据不是从聊天视图里抠的(那里只有已加载的一窗),而是走 host 半区的 RPC history:
它读 ctx.sessionQuery.readSession() 的完整事件日志,只挑 source.kind === 'user' 的真实用户指令
—— 所以「更早」那些也在,而 AGENTS.md、技能目录、定时提醒这类 agent.inject() 塞进去的系统上下文不在。
(实测一个真实会话:38 条 user/message 里只有 11 条 kind 是 user,其余 27 条是
time-context / runtime-context / skill-catalog / agent-instructions / compact-checkpoint / user-approval。)
回填用官方 inputActions.setDraft() 整条替换草稿:不碰编辑器内部,不自己实现输入框。
host 通道取不到历史时 ↑/↓ 完全不接管 —— 宁可这个功能静默失效,也不能让用户觉得键盘坏了。
这条能力移植自 dsh-message-jump 的 「输入框 ↑/↓ 历史消息穿梭」(同一作者,MIT);该插件另一半的悬浮指令导航条没有收进来。
@ 到对话
右键文件或文件夹 → @ 到对话,该路径就以引用芯片的形式进入当前对话的输入框(多选时一次插一批)。 只插入、不发送 —— 后面还要接着写字,替你按发送键是越权。
这里没有重新实现「对话里 @ 文件」:
- 芯片交给官方会话自己的编辑器插(
ctx.conversation.input.for(actx).addFiles(...), 和把文件拖进输入框走的是同一条路),所以序列化(模型看到的文本)、点击芯片在右侧栏预览、 草稿持久化、发送时展开,全部沿用官方行为; - 提及文本逐字复刻官方
@补全的语法(@deepseek-ai/dsh-file-reference的formatFileMention):@src/index.ts、目录@src/、含空格@"my notes.txt"、含空格的目录@"my docs/(引号故意不闭合,官方语法如此); - 路径按「相对会话工作区根 + 正斜杠」给(官方候选就是这个形式,模型也按这个解析)。 会话工作区与树根不一致时退回绝对路径,不硬拼一个错的相对路径。
「当前对话」取 ctx.sidebarRight.mounted —— 官方对「主栏正在显示哪个会话」唯一的公开可观察量,
文件树本身也是按这个会话渲染的。拿不到会话、会话没被 retain、输入框正忙(正在提交)时都明确报错,不静默失败。
多选怎么用
官方那棵树没有多选,本插件自己实现了一套,规矩和资源管理器一致:
| 操作 | 效果 |
|---|---|
| 普通单击 | 清空选择(并放行官方行为:文件打开、目录展开) |
Ctrl / ⌘ + 单击 |
切换这一行的选中态(拦下官方行为,不会顺手打开文件或展开目录) |
Shift + 单击 |
从锚点行到这一行整段选中(按可见行的 DOM 顺序) |
| 空白处单击 | 清空选择 |
| 空白处右键 → 全选可见项 | 一次选中当前树里所有可见行 |
Esc |
分层收:确认框 → 右键菜单 → 选择 |
选中态是填色 + 一圈内描边(只填色会跟 :hover 撞成一样)。右键落在已选中的行上时,菜单作用整个选择
(标题变成 已选 N 项,删除项变成 删除 N 项,多选时不再提供只能作用于单项的下载/上传);
右键落在没选中的行上则把选择换成那一行 —— 不会出现「看着选中了 5 个,结果只删了右键的那一个」。
为什么需要 host 半区
官方文件树的数据来自 workspaceFiles Remote 命名空间,它的类型面里只有 size,没有修改时间
(WorkspaceDirectoryListing.entries[].size / WorkspaceFileStat.bytes;version 是不透明令牌,
契约上明令 never parsed)。所以 mtime 只能由插件自己从 host 侧取。
host 半区在 ctx.webServer 上挂一条前缀路由 /dsh-file-extras:
POST /dsh-file-extras/stat—— Connection RPC 信封,批量取 大小 / mtime / 一级子项数量;POST /dsh-file-extras/history—— 某个会话里真实用户指令的全文列表(↑/↓ 回填的数据源);GET /dsh-file-extras/download?root=&path=—— 流式回文件字节 +Content-Disposition;POST /dsh-file-extras/upload?root=&dir=&name=—— 请求体即原始字节,写进目标目录;POST /dsh-file-extras/remove—— 删除(RPC 信封),默认移入回收站,逐条独立结算。
浏览器的右键菜单注册进 shell.overlay(框架级浮层,本来就在每一列之上、滚动容器之外),
历史回填提示条注册进 conversation.input.dock(输入框卡片上方那条整宽座位)。
斜杠命令与这两者都无关,走官方 ctx.commands.register(),不经过本插件的通道。
文件
| 文件 | 作用 |
|---|---|
| index.js | host 半区:/dsh-file-extras 前缀路由(RPC stat / history / remove、GET download、POST upload)+ 扫描 ~/.dsh/commands/*.md 注册斜杠命令 |
| lib/commands-md.js | 命令文件的纯解析层:frontmatter、$ARGUMENTS、目录扫描(零依赖,可脱离 DSH 单测) |
| lib/trash.js | 跨平台「移入回收站」:Windows / Linux / macOS 三套后端;平台与家目录可注入,所以三套逻辑能在任意一台机器上实测 |
| lib/client.js | 浏览器半区:行内元数据 + 多选 + 右键菜单 + 删除确认框 + @ 到对话(shell.overlay)+ ↑/↓ 历史回填(conversation.input.dock) |
| cordis.patch.yml | loader 行(inject: [webServer, connection]) |
| package.json | dsh.bundle + dsh.client |
| scripts/selftest.mjs / scripts/dom-probe.mjs / scripts/trash-bin.mjs | 两层自检 + 回收站测试清理助手 |
安装
dsh plugin --profile web add dsh-file-extras
或在会话里让 Agent 用 plugin_manager 的 install_bundle 指向本目录。
装完刷新一次页面(客户端 bundle 在插件激活时读入并缓存,改代码后需要重新激活才生效)。
配置
无(无 Config 字段、无运行时开关)。可调参数都在源码顶部的常量里,每个都带注释。
安全边界
- 路由复用
connection.requestRejection():Host/Origin 检查 + 浏览器签名 cookie 认证, 非本机页面与未认证请求拿不到任何数据; root必须是绝对路径、存在、且不是盘符/文件系统根(否则「声明 root=C:\ 就能读全盘」);- 每个候选路径都做 realpath 后再比包含关系 —— 工作区内的软链接指向区外同样被拒(纯字符串前缀比对比不了这一层);
- 上传只写单段文件名(
..\..\x会被压成x),重名一律自动加序号name (1).ext,从不覆盖已有文件; - 上传先写同目录
.part-<uuid>再原子 rename,中途失败/超限不会在工作区留下半个「看起来正常」的文件; - 两端都不设文件大小上限:下载是磁盘→socket 的流式传输,上传是请求流→磁盘,host 内存占用与文件大小无关。上传只在
content-length明显失控(>4 GiB)时提前挡掉。 - 删除默认进回收站,跨平台三套后端(lib/trash.js):
Windows 走 Shell 的
SHFileOperation(PowerShell 5.1 +Microsoft.VisualBasic), Linux 走 freedesktop Trash 规范(~/.local/share/Trash/{files,info}),macOS 走~/.Trash。 回收站不可用时报错,绝不静默降级成永久删除 —— 用户自己勾「永久删除」才走fs.rm。 - 删除的安全边界:浏览器侧必须过二次确认框;host 侧拒绝工作区根自己、拒绝区外路径;
软链接按「链接本身」处理,绝不跟随到区外去递归删目标(
checkEntry按目录项判定包含关系)。 Windows 上软链接不进回收站(DeleteDirectory会顺着联接点递归),直接fs.rm; POSIX 上rename只搬链接本身,所以照常进回收站。 - 一批里有一个删不掉(被占用、权限不足、回收站拒收)只影响它自己,失败原因按条回给用户。
实现要点(改代码前先读)
- 不往 React 管的元素里插子节点。大小/日期写进行元素自己的
data-fx-meta属性, 文本靠 CSS::after { content: attr(data-fx-meta) }渲染。React 只 diff 它 props 里出现过的属性, 不认识的属性既不写也不删,所以标记不会被 reconciliation 抹掉。 - 窄栏自动换行。完整时间戳约 145px,单行放不下时(<320px)由
ResizeObserver给树根打上data-fx-narrow,时间戳换到名字下面一行 —— 宁可换行也不截断时间戳。 属性写入放在requestAnimationFrame里:换行会让行变高,直接在观察回调里改会触发ResizeObserver loop completed with undelivered notifications。 - 错误状态码分两套。RPC 端点失败也回
200+ 错误信封(rpc.call把非 2xx 当传输失败, 读不到信封里的错误码);download/upload是字节通道,失败回真正的4xx。 - 大文件不要经过 JS 内存。下载刻意不用
fetch+response.blob():那个写法要等整个文件 传完、并在页面里拼出一份完整副本之后 promise 才 resolve,大文件于是「点下去很久没反应」, 还要吃掉同等内存、最后再从内存写一遍盘。现在改成stat预检(毫秒级,失败能说清原因)+<a download>原生导航(字节由 host 直接流到磁盘,立即开始、内存零拷贝、无大小上限)。 上传同理:进度提示在传输开始前就弹出来,host 侧边收边写。 - 「@ 到对话」用的是官方输入框,不是自建通道。芯片由会话自己的 Lexical 编辑器插
(
ctx.conversation.input.for(actx).addFiles(refs, [])),本插件只做两件事: 从文件树里认出用户点了哪个路径、拼出和官方@补全逐字一致的提及文本。 提及语法(引号规则、目录尾斜杠)是formatFileMention的复刻 —— 改它之前先对照@deepseek-ai/dsh-file-reference的 grammar,一个字都不能差:差一个引号,模型就会把带空格的路径拆成两段。 history只认source.kind === 'user'。事件日志里 user 角色的消息有两大类:人工输入 / steering,以及agent.inject()塞进去的系统上下文。后者的source.kind各自不同 —— 实测一个真实会话(38 条user/message)里出现过time-context/runtime-context/skill-catalog/agent-instructions/compact-checkpoint/user-approval, 只有人工输入的 kind 恰好是'user'(11 条)。判断写的是「等于user」而不是「不等于某某」, 所以上游再加新的注入类型也漏不进来。 斜杠命令的 handler 也正因此才用createUserMessage({ source: { kind: 'user' } })而不是agent.inject()—— 那样它才会被当成「用户敲的指令」进历史。↑/↓只在能安全接管时才接管。编辑器是 Lexical 的contenteditable(不是 textarea), 所以↑只在光标贴最开头时抢,↓只在穿梭模式里抢,候选菜单开着时一律不抢; 事件监听挂在document的捕获阶段 +stopImmediatePropagation(),才能先于 Lexical 的 root 监听和 React 的委托监听。退出穿梭靠原生beforeinput(Lexical 的程序化setDraft不触发它,所以回填本身不会被误判成用户编辑)。- 斜杠命令用官方注册表,
@deepseek-ai/dsh-llm是动态 import。这个包只在 DSH 自己的 node_modules 里,从第三方插件目录不一定解析得到;拿不到就只让这一条命令回 error, 不自己拼一个假的UserMessage(那会绕过官方的 id / 冻结 / source 约定)。 同理,命令目录优先走官方的@deepseek-ai/dsh-home-paths(认$DSH_HOME),拿不到时按同一套优先级自己算。 - 回收站不是文件系统特性,是 Shell 特性。
fs.rm/unlink/rmdir落到内核的删除原语, 删掉就是删掉;真正「移入回收站」的在 Windows 上是SHFileOperation(要在<卷>:\$Recycle.Bin\<SID>\下写一对$I(元数据)+$R(数据)文件并登记进用户回收站索引, 手工挪文件进去没用 —— 没有$I边车,资源管理器列不出来也还原不了),在 Linux 上是 freedesktop Trash 规范,在 macOS 上就是~/.Trash。三套机制 Node 一个都不提供, 所以 lib/trash.js 自己实现:Windows 外包给 PowerShell 5.1 的Microsoft.VisualBasic.FileIO.FileSystem(内部就是SHFileOperation), Linux / macOS 直接按协议搬目录、写.trashinfo。
自检
node scripts/selftest.mjs # host 半区:假 ctx/req/res 驱动路由,47 个用例(真文件系统 + 三套回收站后端 + 命令文件解析)
node scripts/dom-probe.mjs # 浏览器半区:headless Chrome 里跑真实 lib/client.js,83 条断言
node scripts/dom-probe.mjs --shot out/ # 同上,另存浅色(多选+菜单)与深色(删除确认框)两张截图
selftest.mjs 里 Linux / macOS 两条回收站后端是注入平台与家目录后真跑的(不是 mock):
在 Windows 上把文件搬进一个假的 ~/.local/share/Trash,再逐字校验 files/ 落盘、
info/*.trashinfo 的 Path= 能还原原路径、DeletionDate 格式、以及同名让位成 <名字>.2。
测试往真回收站扔的东西由 scripts/trash-bin.mjs 按快照差集清干净。
dom-probe.mjs 会把完整结果写到 .npm-cache/domprobe/last-result.json,断言消息只留 300 字摘要。
复选框那几步走的是 CDP 的真实鼠标事件(合成事件驱动不了 checkbox 的激活行为,详见脚本内注释)。
selftest.mjs里「handler 把正文当用户消息交给 agent」那一条需要解析到@deepseek-ai/dsh-llm(只有装到 profile 之后才在 node_modules 链上)。 工作区里解析不到时会打印一行提示并只断言降级行为 —— 想在本地跑全,把 DSH 的@deepseek-ai挂到工作区的node_modules/下即可(该目录已 gitignore)。
dom-probe.mjs 需要 react/react-dom 的 UMD 构建:
npm install --prefix .npm-cache/domprobe --cache .npm-cache --registry=https://registry.npmjs.org react@18.3.1 react-dom@18.3.1
沙箱会禁掉命名管道导致 Chrome 渲染进程 FATAL platform_channel,dom-probe.mjs 需要在
放宽权限(danger-full-access)下运行。
注意:
dom-probe.mjs测的是照抄官方 DOM 契约的静态夹具页,用来钉住本插件的 DOM 逻辑与几何, 它不是对运行中 DSH 页面的验证。真机效果需要在 DSH Web GUI 里刷新页面后目视确认。
已知取舍
- 增强落在官方树的 DOM 契约上(
data-files-state/data-files-root/data-files-entry/data-files-path/data-files-body/data-files-reload)。这些属性是官方组件有意暴露的钩子, 但如果上游改名,本插件会静默失效(找不到树就什么都不做,不会报错也不会弄坏页面)。 - 「@ 到对话」比其余功能多依赖三个客户端服务(
conversation/sessions/sidebarRight), 而这三个没有声明在dsh.client.inject里 —— 刻意如此:它们是点击那一刻才去ctx.get()的, 缺一个只会让这一项报错,不会让整个插件(元数据、菜单、删除)起不来。 上游若改了input.for()/addFiles()的形状,这里也会明确报错而不是静默插入失败。 - 元数据每 15 秒后台重取一次(仅页面可见时)。文件在会话里被反复改写、行节点又被 React 复用, 不重取就会一直显示旧值。
- 「刷新」按的是官方的重载按钮(
data-files-reload),不自己维护目录状态。 - 选择状态由本插件维护(行元素上的
data-fx-selected属性是唯一真相),每次同步按 DOM 里实际还在的行剪枝: 折叠、刷新、删掉的行会自动掉出选择。 - 回收站的平台差异:手工把文件挪进 macOS 的
~/.Trash不会写 Finder 的「放回原处」元数据 (那在.DS_Store里,只有 Finder 自己写),所以文件能还原、但「放回原处」不可用。 Linux 跨卷时会用该卷根下的.Trash/<uid>或.Trash-<uid>(规范要求)。 回收站被系统关掉、或条目超过回收站配额时,Windows / macOS 自己会改成永久删除 —— 那是系统的行为,插件无法区分。 - 没有回收站后端的平台(FreeBSD 等)会明确回
file-extras/trash-unavailable, 确认框里勾「永久删除」仍可继续。 - 斜杠命令是启动时扫一遍目录:改已有文件的正文立即生效(每次调用重新读盘),
但新增 / 删除 / 改名
.md文件要重启dsh web才反映。 history依赖ctx.sessionQuery(dsh-base里的session-query-sqlite提供)。 缺席时该端点回file-extras/history-unavailable,浏览器侧↑/↓完全不接管, 文件树那部分功能不受任何影响。history只回最近 300 条、单条截 20000 字符:回填到输入框用,不做无上限搬运。- 本插件把
conversation/sessions/sidebarRight/commands/sessionQuery全部 懒取(ctx.get(...))而不是写进inject:任何一项缺席都只让对应功能降级或报错, 不会把整个插件挡在门外。
No comments yet. Be the first to write one.