DeepSeek Harness 插件

senyayume/dsh-edit-diff

Star 数 ★ 0 分类 UI 增强 收录于 2026-09-21

接管 edit / write / insert / str_replace_editor 四种工具行:行级对齐后相同行只渲染一次、行内改动字符带下划线;run_code(PTC) 子调用同样覆盖;轮末给出本轮改动汇总卡片;工具行与卡片行都能右键「在资源管理器中打开」或复制文件/文件夹绝对路径,由插件自己的宿主路由完成。

安装

# GitHub 源码(首次需按提示配置 allowBuilds 构建授权后重试)

dsh plugin --profile web add github:senyayume/dsh-edit-diff

装任何插件都等于在你的机器上跑第三方代码,权限和你本人一样大——能读你的文件、用你的凭据、访问网络,工具审批管不到它。GitHub 来源的插件还会在安装时执行构建脚本——pnpm 默认拦截,所以安装可能停在 ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWEDERR_PNPM_IGNORED_BUILDS;dsh 会打印出需要添加的确切键名,把它加进该 profile 的 pnpm-workspace.yamlallowBuilds 下,重跑一次即可装上。放行构建本身就是一次信任判断:请只安装可信来源,并尽量锁定 commit(github:owner/repo#sha)。

README

中文 | English

DSH 客户端插件:文件变更工具的卡片上显示 +N -M 分色统计,展开后是一行只出现一次的差异体—— 相同行作为暗色上下文,改动行红/绿分色,行内真正改动的字符再加下划线加粗。run_code(PTC)里的 子调用与 str_replace_editor 同样接管。

轮末再给本轮改过的文件一张汇总卡:标题「N 个文件已更改」+ 合计 +A -R,头部一点整卡折叠;逐文件 一行是文件类型图标、文件名(主色)、所在目录(暗色)、+N -M,行尾「审查」「打开」,展开是同一套差异体。

右键任意一行(轮末卡的每一行,以及上面那些工具行)都有「在资源管理器中打开」/「复制文件夹路径」/ 「复制文件路径」;前者由插件自己的宿主路由完成(Windows 走 explorer.exe /n,/select,…),不依赖其它宿主接口。

特性一览

  • 行级去重 + 背景高亮:相同上下文行只渲染一次;改动行用整行红/绿底色标出(不是把文字染色),行内真正改动的字符是同一底色再深一档;文字本身走语法着色,用的是插件自带的本地分词器 + 主题的 --shiki-token-* 变量(与内置代码块同一套配色,见下面「语法着色与背景高亮」)。内置 diff 会把相同行在红绿两区各渲染一遍。
  • 覆盖四种工具行 + PTC 子调用edit / write / insert / str_replace_editor,以及 run_code(PTC)里的子调用;内置 diffCardModel 对这两类直接返回 null。
  • 轮末改动卡:这一轮改了哪些文件、各自 +N -M 与合计,整卡可折叠,行尾「审查」「打开」,展开是同一套差异体。
  • 右键路径菜单:轮末卡与工具行都能「在资源管理器中打开」/「复制文件夹路径」/「复制文件路径」,由插件自己的宿主路由完成,官方 opener 作回落。
  • 中英双语文案,路径按会话 workspace 相对化(workspace 之外原样显示绝对路径)。

它长什么样 · 快速上手 · 为什么需要它 · 语法着色与背景高亮 · 轮末改动卡 · 已知限制 · 已知契约 · 结构

它长什么样

工具行里的差异体

轮末改动卡与右键菜单

工具行上的同一套右键菜单

快速上手

系统要求

  • DSH:开发与实测环境是 DSH Desktop 自带 harness 0.1.5-rc.2@deepseek-ai/dsh-client-ui-primitives 同版本),宿主路由的注册另在 CLI harness 0.1.1 上验过。更低的版本没测:若那个版本没有 uiConversation.eventsconversation.chat.turnTail 槽位,插件会安静降级成「只接管工具行、不出轮末卡」(lib/client.js 里就是这么分支的)。
  • Node:安装由 dsh plugin add(内部 pnpm)完成;跑本仓测试需要 Node ≥ 18 与 React 18(npm install 会装,也可用 DSH 自带那份,见「验证」)。
  • 平台:「在资源管理器中打开」在 Windows 11 实测(explorer.exe /n,/select,…);macOS 走 open -R、Linux 走 xdg-open <目录>——代码在,但未在真机验证

安装

这是一个标准 DSH bundle:package.json 声明了 dsh.bundle.patchcordis.patch.yml,装上即挂载, 不需要手工改 profile。

# 内部就是 pnpm add,所以 github: 写法可用;profile 名按你要装进去的那个填
dsh plugin add github:senyayume/dsh-edit-diff --profile desktop

生效方式分两半(踩过坑,写在这里)

  • 客户端半身lib/client.js):每次页面加载都从磁盘取,刷新窗口就够(所以文案类改动看起来立刻生效);

  • host 半身lib/index.js):只在 harness 进程启动时装载;而 DSH Desktop 关窗口只是缩到托盘、进程不退, 所以动过 host 半身必须从托盘右键退出再启动(或在任务管理器里结束所有 DSH Desktop.exe)。

    不这么做的典型症状:客户端文案一轮一轮在变,host 那条路由却始终不存在——客户端 POST 打到 harness, 落到静态前端拿回 405dsh-host-frontend-static 对未知路径的非 GET/HEAD 一律 405)。

从源码开发(把 junction 挂进 profile)

  • 挂载:junction $DSH_HOME/profiles/<profile>/node_modules/dsh-edit-diff → 源码目录

  • profile package.json dependencies 登记 "dsh-edit-diff": "file:<源码目录>"

  • 若 profile 里没有 dsh.bundle 这条通道,再往 cordis.patch.yml 追加:

    - insert:
        - id: edit-diff
          name: dsh-edit-diff
    

验证与卸载

npm test                     # = node test/smoke.mjs && node test/host.mjs
node --check lib/client.js
node --check lib/index.js

test/smoke.mjs 需要 React 18:优先用 DSH 应用自带那份(DSH_APP_ROOT 可指向 DSH 应用目录,默认找 D:/DSH Desktop/resources/app),找不到就退回 npm install 装的那份。

装好后逐条过的 live 清单(工具行 A1–A6、轮末卡 B1–B12、会话装配 C1–C2)与「没过怎么定位」见 docs/verification.md

卸载

dsh plugin remove dsh-edit-diff --profile <profile>(或删掉 profile cordis.patch.yml 里那条 edit-diff insert)并重启 harness,即回到 DSH 内置渲染;只回滚渲染层可以 git restore lib/client.js 后刷新窗口。

安装排障

  1. 确认装进了对的那个 profile:dsh plugin add github:senyayume/dsh-edit-diff --profile <profile>;随后 profile 的 package.jsondsh.profile.bundles 应含 dsh-edit-diff(本插件自带 dsh.bundlecordis.patch.yml,所以装上即挂载,不需要手工加 entry)。
  2. 确认真的重启了 harness:客户端半身(lib/client.js)刷新窗口就够,host 半身(lib/index.js)只在进程启动时装载;DSH Desktop 关窗口只是缩到托盘,必须托盘右键退出再启动。

那一行下方会写它走的是哪条路:已请本插件宿主打开资源管理器(插件自己的宿主路由成功)、已请求在资源管理器中显示(官方 host 已确认)(回落到官方 opener,后面还附路由为什么没用上)、或红字原因。 再看宿主日志 <DSH_HOME>/logs/host/dsh-<日期>.log:应有 [dsh-edit-diff] host half loadedreveal route mounted at /edit-diff/reveal 两行——两行都没有,就是 host 半身没装载(回到上一条)。

405 来自 dsh-host-frontend-static(未知路径的非 GET/HEAD 一律 405),含义是那条宿主路由没注册——几乎总是 host 半身没随进程重新装载,而不是插件没装。

为什么需要它

1. 有些工具根本不渲染 diff

DSH 内置的 @deepseek-ai/dsh-client-ui-tool 里,diffCardModel() 有两条硬限制:

if (block.parentCallId !== void 0) return null;            // run_code(PTC)子调用一律不渲染 diff
if (intended.tool === "str_replace_editor") return null;   // 该工具一律不渲染 diff

于是:

  • 在 PTC 模式下(run_code 内部调用 tools.edit / write / insert),编辑条目只剩「编辑 · 路径」一行加结果文本,看不到改了哪几行;
  • str_replace_editor(非 PTC 会话的编辑工具)即使成功也不显示 diff。

2. 内置 DiffBlock 不做行匹配,上下文被渲染两遍

@deepseek-ai/dsh-client-ui-primitivesbuildRows()oldText 的每一行都画成 -newText 的每一行都画成 +,两侧不做行级匹配;而主机侧 dsh-tool-fscomputeHunkDiffs()context: 3 生成 hunk,删增两侧都带同样的 3 行上下文。 结果:改 1 行会画出 14 行,其中 7 行是同一句话出现在红区和绿区,统计也变成 +7 -7

本插件不改任何 DSH 安装目录文件,而是通过官方 keyed 槽位 tool.call.toolviewpriority: -1 遮蔽内置 edit / write 行渲染(槽位注册表按 priority 取最低者渲染),额外接管 insertstr_replace_editor。差异体不再复用内置 DiffBlock:行内高亮需要每一行的 字符级差异,内置块只接受纯文本行。

diff 数据来源

  1. 结果元数据 block.meta.diffs(live 且存在时最准确,含 replace_all 的多段 hunk);
  2. 否则从调用参数推导:writecontenteditold_string/new_stringinsertnew_strstr_replace_editorcreate/str_replace/insert 命令。

参数在 root 调用、run_code 子调用以及会话回放中都存在,因此两条路径都能显示。 子调用结果块(dsh-client-ui-chatchildResult)本身不带 meta,会自动落到第 2 条。 调用失败时(block.isError)不推导:失败的 edit/write 没有应用过的 hunk,参数里的改动根本没落地 (FS_STALE_VERSION / FS_EDIT_NOT_FOUND 这类),红行只显示失败原因,不会声称 +N -M

行级对齐与行内高亮

lib/client.js 里这条链是纯函数,可被单独测试:

  • changedLines():公共前缀/后缀线性收缩,只对中间差异带跑 Myers(O(ND)),返回删/增两侧的行号
  • hunkRows():按行号把两侧对齐成 ctx / del / add 行,相同行只出现一次(内置块会在两侧各画一遍);
  • charMarks():删增行数相等的替换对再做字符级 Myers,得到行内 [start, end) 区间; 已知未闭合(2026-09-21):区间取自「未对齐的码点」,被替换的两个词之间有能对齐的公共字符时会 碎成多片registrationstool rows 实测是 regis / ati / n),观感上像高亮乱标。 语义与改法见 docs/verification.md 的「未闭合」一节;
  • withMarks():把区间挂到行上,渲染时用 .dsh-edit-diff-mark 分段输出;这个类是背景加深, 不是文字染色(见下一节)。着色这一侧另有几个纯函数:annotateRows() 给每行盖上「它是源文件第几行、 属于哪一侧」,langOfPath() 按扩展名给出语法 id,highlightDiffLines() 逐行扫描一侧文本、 把多行字符串的状态在行间传递、给出每行的 run 列表,paintFile() 把两侧的 run 列表按行号打包,rowText() / markedText() 把 「语法 run」与「改动区间」两层切开——区间落在某个 run 内部时嵌一层 .dsh-edit-diff-mark, 于是语法颜色保留、改动处换成更深的底色,appendRun() 把同色相邻 run 并起来;
  • 编辑距离超过 MAX_EDIT_DISTANCE = 400 的 hunk 不做对齐,退回「整块删除 + 整块新增」, 也就是内置的形状——绝不为了对齐拖慢卡片;oldTextnull 的全新增同理;
  • buildDiffModel() 汇总每个 hunk 的行、头部统计(只算改动行)、页脚 └ +A -R · N files、以及带 - /+ 前缀的复制文本;
  • 体超过 max-height:320px 时进 .dsh-edit-diff-scroll 滚动容器,不再折叠——所有行都在 DOM 里, 滚动查看即可;复制按钮与页脚留在滚动区外,长 hunk 里也始终可见。
  • 头部与逐文件行的 +N -M 抑制零值(纯新增显示 +87 而非 +87 -0);页脚的 └ +A -R 保留两侧, 那是内置 DiffBlock 的形状;

文本与行数组的往返用同一套 terminator 规则(contentLines() / linesText()),空行改动不会丢。

实测(本机 Node 24,internals):10000 行全新增 3ms、10000 行全不同 8ms(超限退回)、 20000 行只改 1 行 6ms、真实 7 行 hunk 0ms。

语法着色与背景高亮

改动行不再把文字染成红/绿:红绿改成整行的底色.dsh-edit-diff-del / .dsh-edit-diff-add), 行内真正改动的字符是同一底色再深一档.dsh-edit-diff-mark),不再是下划线加粗。文字本身交给 语法着色,所以一屏 diff 读起来像代码,而不是像一段被涂色的文本。

  • 行类只准给底色,不准给 color.dsh-edit-diff-del / .dsh-edit-diff-add / .dsh-edit-diff-ctx 里一旦写了 color:, 那个颜色会被行内每个 token span 继承,shiki 的 var(--shiki-token-*) 只在 token 自己没有颜色时才轮到,于是整行文字变成单一的红/绿, 语法色一个也看不见。2026-09-21 实际就是这样(用户截图反馈「怎么文字还全绿的」)。改动行只给 background,前景色留给 token; 认不出语言时 token 为空,此时前景由 .dsh-edit-diff-body 继承主题,和内置 CodeBlockvar(--shiki-foreground) 同一路。 +N -M 头部小药丸(.dsh-edit-diff-added / .dsh-edit-diff-removed不在此列:那里没有 token,红绿文字就是它该有的样子。

  • 高亮器是本插件自带的本地分词器@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.2 不导出它的高亮器——那一份 export {...} 列表里只有 CodeBlock 等组件,highlightLines / subscribeGrammarLoaded / grammarLoadCount 都是有定义、没导出(在模块内部供它自己的 CodeBlock 使用)。所以插件读不到它们,也就无法复用内置高亮器。 不要再去解构或读 primitives.highlightLines:那是 undefined,2026-09-21 因此先崩后哑 (守卫拿到 undefined → 静默降级纯文本,卡片不崩但一个颜色也没有)。 不要为了拿回高亮器去 patch DSH 安装目录里的 primitives——那是官方文件。

  • 配色仍然与内置代码块同源:内置 CodeBlock 走 shiki 的 createCssVariablesThemevariablePrefix: "--shiki-"),因此每个 token 的颜色都是一个 --shiki-token-* 自定义属性, 由主题包(@deepseek-ai/dsh-client-ui-themeshiki.css)在 :root 定义亮色、 在 body[data-ds-dark-theme] 定义暗色。本地分词器把自己的 token 类别映射到同一批变量TOKEN_COLOR),于是 diff 里的 token 与内置代码块里的同类 token 同色,两套主题自动跟随。 插件里没有一个写死的颜色值。⚠️ 这些变量名由主题包定义:将来主题换名需要同步 TOKEN_COLOR

  • 底色由主题 token 派生color-mix(in srgb, var(--dsw-alias-state-success-primary) 16%, transparent), 删侧用 error token。亮/暗两套主题各测过(见下),不需要维护第二份配色。

  • 逐行扫描,跨行字符串靠状态串起来highlightDiffLines() 一行一行地扫,这样每个 run 都不会 含换行,行内改动区间才能映射回 run 上;而 Python 三引号、JS 模板串这类多行字符串tokenizeLine()openQuote 在行间传递——一段 docstring 是一段字符串,不是每行各开一个。 单个引号不跨行(那是行内笔误,不该吞掉后面的文件)。

  • 散文不做分词,数据只做词法着色——这条是用户截图反馈后加的(「着色怎么这么奇怪」)。 代码分词器读英文会随机上色in / as / with / this / for / is / not / and / or / package 全都在关键字集合里,而 shiki's 里的撇号会开一个字符串把整行吞掉,MIT / WASM / TODO 又会被 SCREAMING_CASE 规则染成常量。所以按语言分三档:

    • 纯文本PLAIN_LANGSmd / mdx):markdown 是散文,整个不做分词,底色与行内改动标记照旧。 内置 CodeBlock 敢处理 markdown,是因为 shiki 的 markdown 语法分得清散文与围栏代码; 近似分词器分不清,与其乱上色不如不上色。
    • 只做词法着色lexicalOnlyyaml / toml / ini):注释、字符串、数字照上, 单词一律不分类,否则 description: install in the for as is not 会被关键字色撒满。
    • 完整着色:其余代码语言按注释 / 字符串 / 关键字 / 数字 / 调用名 / 标点分类。
  • 撇号不开字符串:单字符引号只在前一个码点不是单词字符时才开串。这一条同时救了注释、 字符串和 YAML 值里的英文散文——shiki's / don't / package's / 12" wide 都不会再吞掉整行, 而代码里的 'abc' 照旧(真代码里引号从不从词中间开始)。

  • . 之后的标识符不是关键字:成员访问名是属性/方法名,一律保持默认前景色。 KEYWORD_SETS.get(lang) 里的 get 只在 class body 内才是保留字,按 return 那样上关键字色 读起来就是错的(用户第二次截图反馈的就是这一处);get / set 也已从关键字表里移除。

  • 整段居左:剥掉 hunk 共有的前导空白。深层嵌套的 hunk 用 5 个 tab 就吃掉 40 列, 用户反馈「这个文件前面怎么空了那么多,这种能不能让他居左」。buildDiffModel() 算出这一段 每行都有的空白前缀并剥掉,代码贴左;行与行的相对缩进保留,嵌套深浅的变化照样看得见。 空行不参与计算(否则含空行的 hunk 永远居不了左),行内 mark 与着色用的两侧行同步左移 (不改的话强调会落在错的字符上)。复制出来的文本仍是文件原本的缩进——粘回文件时不该丢缩进。 另外 tab-size: 4,比浏览器默认的 8 列少一半。

  • 多行块注释跨行/* … */ 的第二行起过去被当成代码分词——注释里的英文散文被撒上关键字色, 里面的反引号还会开一个字符串(把真实卡片渲染出来才看见)。tokenizeLine() 现在把 blockCloseopenQuote 一样在行间传递,/* 没在本行闭合就把余下部分整体当注释、把闭合符交给下一行。

  • 轮末卡的接线不再被一个缺席的服务整段掐掉conversation.chat.turnTail 槽位现在无条件注册, 且 uiConversation两条路取(ctx.uiConversation 属性优先,ctx.get("uiConversation") 兜底)。 旧写法在拿不到那个服务时直接 return,于是槽位和累积器都没接上,屏幕上唯一的症状是 「轮末卡不见了」——和「这一轮没改文件」完全一样,用户无法分辨。现在这种情况会 console.warn 出声。 select 也改成绝不抛异常:链式渲染器把「抛异常」也当成拒绝,抛出去就再也看不见了。

  • 单个大写字母不算常量:SCREAMING_CASE 要长度 > 1 才算,否则 A file whose extension... 里的 A 会被染成常量色(截图里就是这么冒出来的)。

  • 全程按码点索引:分词器内部所有偏移都是 [...line]码点下标,不是 UTF-16 下标。 否则一行里每多一个 emoji,后面的 token 就整体错位一格,还会把代理对劈成两半。

  • 不需要懒加载订阅:本地分词器是同步的、没有语法要加载,所以 DiffBody没有 useSyncExternalStore,着色 memo 只依赖 model。

  • 认不出的扩展名不猜langOfPath() 的扩展名表照抄 dsh-tool-fslangFromPathMakefile.gitignore、无扩展名一律纯文本(底色与改动标记照旧)。

  • 上下文行同样着色:未改动的行按旧侧行号取 token(两侧在这一行上文本相同),所以整屏是连续的 代码观感,而不是只有红绿两行有颜色。

已知限制:

  • 这是近似的分词器,精度低于 shiki。它按语言 profile 识别注释 / 字符串 / 关键字 / 数字 / 调用名 / 标点,用的是小规模字面表,不是语法。认不出的词只是保持默认前景色,绝不会错色; 但它不认识嵌套插值、正则字面量与除号的区分、heredoc、语言特有的冷门构造。 这是「primitives 不导出高亮器」这一约束下唯一的可行路径(另一条是改 DSH 核心,已排除)。
  • 扩展名不在 allowlist 里的文件按纯文本渲染,不猜语法。
  • 纯标识符行(如 foo bar)会渲染成无色的 token span:底色与改动标记照旧, 只是没有语法色——注意不能让它返回空 run 列表,那会让整行渲染成空白。

实测(本机,headless Edge 渲染真实卡片 DOM + 真实主题样式表,暗/亮两套主题各截图核对): 代码文件(.py 等)关键字 / 字符串 / 跨行 docstring / 注释 / 数字各归其色,整行底色铺满、 改动处底色加深;markdown 渲染为纯文本 + 底色,不再出现随机关键字色与被撇号吞掉的行。 截图由一次性 harness 产出,不入库。

轮末改动卡

conversation.chat.turnTail(chain 槽)以 priority: -100 注册,并且只在本轮真的改过文件时接管—— select 返回 null 就把槽位让给链上的下一个占用者(内置的「交付文件」卡)。

为什么是 -100 而不是遮蔽内置行用的 -1(2026-09-21 踩过,卡片整晚没出现过):chain 槽不去重, 链按 priority 升序试,第一个非 null 的赢,同优先级按注册先后Array.prototype.sort 稳定, entriesOfSlot 对 chain 原样返回该数组)。第三方插件 dsh-better-sidebar 0.19.1 也在同一个 -1 上 注册同一条链,它的 select 只要「这一轮有产物文件」就认领——正好是本卡要接管的那批轮次;谁先被 loader 挂载谁赢,于是本卡时有时无。比遮蔽惯例低一个数量级,选举就不依赖加载顺序了。注册后还会订阅该槽, 一旦出现不高于本插件优先级的邻居就 console.warn 点名——输掉选举的症状和「这一轮没改文件」 在屏幕上一模一样,不能靠肉眼发现。

  • 数据来自官方 per-turn 累积器:ctx.uiConversation.events.register({ kind: "edit-diff", ... }), 监听 turn/starttool/calltool/result(只收 append surface 的结果,避免替换副本重复入账);
  • 根调用取结果 meta.diffs 的真实 hunk;PTC 子调用tool/ptc-dispatch-start / tool/ptc-dispatch) 的 wire 记录不带 turn 坐标,用 parentCallId 一路回溯到启动它的 run_code 根调用所属轮次, 再按参数推导 hunk(子调用本来就没有 meta,与工具行同一套判定);
  • 这张「调用 → 轮次」路由表在 match 阶段学习,不在 update 里:框架的 prepend(向上翻页加载 更早历史)会先把整页事件 match 完再统一 update,在 update 里记的话这一页的 PTC 子调用会 找不到祖先而丢掉(实测 4 个真实 PTC 会话丢 150 条);
  • 失败的结果不入账;同一文件多次改动合并成一行;默认预览 5 行,再多的给「再显示 N 个文件」;
  • 版式:卡片头是整卡折叠按钮(aria-expanded 的加号 + 「N 个文件已更改」+ 合计 +A -R, 等宽 tabular-nums),体是逐文件一行——FileTypeIcon 图标、文件名(--dsw-alias-label-primary)、 目录(--dsw-alias-label-caption,与文件名同一行,路径按 / 归一)、+N -M(等宽列)、 行尾「审查」「打开」两个按钮,行 hover 用 --dsw-alias-interactive-bg-hover-solid
  • 「审查」(点行同效)展开同一套对齐 + 行内高亮的 body。ZCode 的「审查」是打开 code viewer, 本机没有那条通道,所以落到行内差异体;
  • 路径按会话 workspace 相对化:inject(sessionId) 拿到会话 id,读 ctx.sessions.list 的 getSnapshot/subscribe 源(React.useSyncExternalStore,根可能晚到),workspace 内的绝对路径先拼成 相对路径再拆名/目录,出了 workspace(例如临时脚本)原样显示;拿不到根就退回原样,不抛错;
  • 「打开」调用对话视图的 openFile
  • 右键一行出上下文菜单(用 primitives 的 Menuportal + getAnchorRect 定在指针处): 「在资源管理器中打开」(走 host 的原生入口,探测不到就不出现该行)、「复制文件夹路径」、 「复制文件路径」。两项复制都复制绝对路径(所在文件夹 / 文件本身)——这里不再做 workspace 相对化,相对路径贴到终端、编辑器、资源管理器地址栏里都用不上。这一套在两处行上都可用:轮末 改动卡的每一行,以及本插件接管的工具行(edit / write / insert / str_replace_editor,也就是 对话里「编辑 · xxx.md」那一行)——同一个 useRevealMenu、同一份文案(revealNoteText),提示行落在 被点的那一行下面。能力探测是一趟 RPC,一场会话只发一次、所有行共享,不会因为一屏编辑行变成一屏 RPC (答「可以」就记住;答「不可以」不记,连接可能稍后才就绪);
  • 「在资源管理器中打开」的链路: POST 本插件自己的宿主路由 /edit-diff/reveal(可控、而且强制 新窗口),只有这条路由不在(404、或这个界面没有 fetch)才回落到官方 session/openWorkspacePathaction: "reveal")。返回值带 via 区分是谁干的:route = 本插件宿主路由(会开新窗口)、 host = 官方 opener(note 里附上路由为什么没用上)、text = 两条都没成。两种结果都留痕:成功短暂 显示一行确认,失败把原因显示在该行下方(console.warn 一条)——回落不再静默,路由坏了会写在脸上。 为什么不让官方端点当主路径:本机实测它回 { opened: true }(卡片显示「host 已确认」)但桌面没有 任何反应——原因见下面两条 Do not,官方那条链多半踩的是同一个坑。
    • Do not:打开器的 execFile 选项里不许有 windowsHide。libuv 把它翻成 STARTUPINFO.wShowWindow = SW_HIDE + STARTF_USESHOWWINDOW,新拉起的 explorer.exe 用 SW_SHOWDEFAULT 建窗口时继承成 SW_HIDE:窗口建了、文件选中了Shell.Application 里查得到, 但 IsWindowVisible = false,用户眼前什么都没有。同一台机器、同一条命令实测三种写法: windowsHide: true → 隐藏;false → 可见;不传 → 可见。这个开关是给控制台程序藏黑框用的, explorer.exe 是 GUI 程序,去掉它没有代价。(2026-09-19 的真因之一,为此白查了四轮。)
    • Do not:交给 Explorer 的路径必须是原生(反斜杠)拼法。Explorer 的 /select 对正斜杠路径是 静默无视:照样退出码 1、没有窗口,调用方分不出来。卡片里的相对路径会被 absolutePathOf/ 拼上会话根,所以 D:/a/b.md 这种形状真的会到 host;revealCommand()(argv 的 owner)用 win32.normalize 归一,absolutePathOf() 按会话根自己的拼法拼。实测 /n,/select,D:/…/a.txt 无窗口、D:\…\a.txt 有窗口。 宿主路由用 /n 强制新窗口,绕开「落进已开窗口、选中了但不置前」这条。

没做:ZCode 那张卡的「撤销」要 host 端 checkpoint(它的客户端调 previewFileRewind / applyFileRewind,配 chat.changeSummary.rewindDialog.* 一整套预览与回滚对话),DSH 本机没有这条 通道,客户端插件也造不出可信快照,所以没接;「用 VS Code 打开」也没有——那要 host 端先知道编辑器, session/openWorkspacePath 只认「系统默认应用」与「reveal」两种动作。

「在资源管理器中打开」的延迟(实测,2026-09-19)

点击到窗口可见 ≈ 0.65 s,这段全是 explorer 自己的,我们这层只有毫秒级:

阶段 实测
路由收到 POST → 拉起 explorer.exe 进程 20-30 ms
explorer 子进程交棒完退出(200 在这时返回,确认文案此时出现) 296 / 347 / 347 ms
窗口可见(shell 把窗口建出来) 650 / 764 / 1581 ms(中位 764 ms)

能给用户省的空间只剩「把 200 提前到 spawn 时刻」:省 ~300 ms 的提示文案延迟,窗口不会因此提前, 代价是丢掉失败原因(退出码 / stderr)——那正是查这个功能时唯一可用的诊断,不划算,不改。 /n 也不是开销:去掉它实测中位 1485 ms(更慢),而且目录已有窗口时它照样再开一只新窗口并置前。

僵尸 explorer 进程会显著拖慢开窗:未清理时机器上堆着 38 个 explorer 进程 / 22 只隐藏窗口,同一条 命令中位 2368 ms;清到只剩主 shell(1 个进程、0 只文件夹窗口)后中位 764 ms。每次 reveal 都会留下 一只窗口,用久了值得清一次。

已知限制(以及为什么不做)

  • 语法着色:已做(见上面「语法着色与背景高亮」一节),但走的是插件自带的近似分词器—— primitives 不导出它的高亮器,而复用它需要改 DSH 核心,已排除。配色仍与内置代码块同一套 --shiki-token-* 变量。剩下的限制都写在那一节里。
  • 真实文件行号:核心的 FileDiff 只有 { path, oldText, newText }structuredPatcholdStart/newStart 被丢弃;要显示真实行号必须在 host 半身 shadow 重注册 edit/write 定义把行号写进 meta。这属于改数据契约,未做。
  • 撤销/恢复:要 host 端快照台账加写回校验,ZCode Desktop 那张卡靠的就是同一类 host 契约 (checkpoint + previewFileRewind/applyFileRewind)。本插件的轮末卡只做汇总、折叠、展开与 「打开」文件——凭客户端插件自己实现不了可信撤销,所以没接。
  • 给内置侧边栏的工作区/会话行加菜单项dsh-client-ui-workspace 给项目行的 Menu 只喂了 rename / delete 两行(源码注释:"Menu can emit only the rename and delete rows supplied above"), 而侧边栏对外只有面板级槽位(sidebar / sidebar.workspaces + 它的 sidebar.workspaces.directoryFlow), 没有「行级菜单项」槽位。要加只能改 shipped bundle(<DSH 安装目录>/resources/app/node_modules)—— DSH 一升级就丢,且属于改核心,不做。
  • 窗口化渲染:对齐后行数通常很少,长 hunk 由 max-height:320px 的滚动容器兜住(行全部进 DOM, 实测 1200 行退化 hunk 不卡);真正的虚拟滚动没做。

已知契约(照本机 shipped 代码核对过)

写这个插件只需要记这几条,都是从本机安装包里读出来的事实:

  • 槽位遮蔽tool.call.toolview 是 keyed 槽,同一 key 同一 priority 只能有一个注册者, 重复注册会抛错(原文 "register at a different priority to shadow it (lowest renders)"); 遮蔽内置行要用更低 priority(本插件 -1,内置 0)。
  • 工具行入参{ block, toolName, cwd, home, openFile, inspect, locale }; running 的 argsRaw 在 block 顶层,settled 的在 block.call.argsRawPTC 子调用块没有 meta
  • 差异数据:settled 结果的 block.meta.diffs 是应用后的 hunk({ path, oldText, newText }, 纯新增 oldText: null,主机侧用 structuredPatchcontext: 3);取不到才从参数推导。
  • 轮末槽conversation.chat.turnTail 是 chain 槽(kind: "chain", scope: "session"), select(owner) 的返回值就是组件的 matched,返回 null 表示让位给下一个注册者; owner 是 { turn, seq, openFile }turn.data 是本轮的发布数据表。
  • 插件自己的 host 路由ctx.effect(() => ctx.webServer.register({ kind: "exact" | "prefix", path, handler }))handler 就是 Node 的 (req, res)(自己 writeHead / end);host 半身要 inject: ["webServer"], 受信判断读 ctx.get("webRuntime").trustedHosts(回环或声明过的权威 + 非跨站 + Origin 与 Host 一致—— 第三方插件 dsh-better-sidebar 用的是同一套)。客户端请求这条路时必须自己拼绝对 URLnew URL("/edit-diff/reveal", hostBase())hostBase() = location.origin,为 null/空时回退 http://dsh.internal(载体内部 origin)——shipped 的 dsh-client-ui-open-in-app 就是这么做的; 写成相对路径在桌面渲染器里会落到静态前端并拿回 405。 fetch 也要用载体那把globalThis.__DSH_TRANSPORT__.fetchdsh-client-connection 消费的就是它), 页面自带的 fetch 在桌面渲染器里到不了宿主;拿不到载体 fetch 才退回页面的 fetch别用模块级 inject 把整插件 gate 在服务上webRuntime 这类服务只在某些部署里存在,一旦缺一个, apply 永远不跑、路由静默缺失,客户端只会收到静态前端的 405(dsh-host-frontend-static 对未知路径的 非 GET/HEAD 一律 405)——2026-09-19 就这么踩过一次。
  • 原生打开/显示入口ctx.get("connection").rpc.call("/api", "session/canOpenWorkspacePath", { args: {} }) 返回 { ok, value }(能力探测),session/openWorkspacePath{ args: { request: { path, action? } } }——action: "reveal" 是「在文件管理器中显示」,缺省是按 默认应用打开;只有 loopback 页面 + 能力为真才可用(connection.isLoopback)。第三方插件 dsh-context 用的就是这一对端点。
  • 会话 workspace:session 作用域槽位(conversation.chat.turnTail 声明为 { kind: "chain", scope: "session" }) 的 inject 工厂会收到 sessionIddsh-client-ui-cordis 用同一套);ctx.sessions.list{ getSnapshot, subscribe } 源,快照形如 { byId: { [id]: { cwd } }, current }。工具行拿到的 cwd / home 是框架注进 props 的,轮末槽没有,所以轮末卡自己从 sessions 取。
  • 轮末数据ctx.uiConversation.events.register({ kind, match, start, update, buildLocationData })buildLocationData 返回 { kind: "turn", turn, key, value } 后由 turn.data.get(key) 读到; matchrole"start" / "update",用 id 把事件归并到某一轮。 框架给 definition 的 match 对象是 { event, role, location }——没有 seq:事件序号只有 match.event.seqconversationMatch() 就是这三字段;整个客户端里没有任何 match.seq 的用法)。 轮末卡按 entry.seq <= owner.seq 挑「收尾消息之前落定的改动」,所以 seq 一旦取错(undefined), select 会永远返回 null、轮末卡从不出现。
  • 派发顺序:新事件走 append/matchWindow,一个事件「先 matchstart/update」; 但 prepend(向上翻页)先把整页事件 match 完,再统一 update。任何依赖「前面事件的 update 已经跑过」 的路由逻辑,都必须在 match 阶段就把表记好。
  • PTC wire:子调用是 tool/ptc-dispatch-starttool/ptc-dispatch{ parentCallId, rootCallId, subCallId, name, arguments, isError, content }),不带 turn 坐标tool/result 只有 surfaceOp === "append" 才是转录来源。
  • 主题 token--dsw-alias-state-success-primary / --dsw-alias-state-error-primary / --dsw-alias-markdown-code-block / --dsw-font-markdown-code-block(内置 DiffBlock 用的是同一批); 轮末卡另用 --dsw-alias-label-primary / --dsw-alias-label-caption / --dsw-alias-border-l1 / --dsw-alias-interactive-bg-hover-solid,以及 primitives 的 FileTypeIcon{ path, size },按扩展名 分类着色)与 IconChevronRightOutline14(折叠加号,展开时 rotate(90deg),同 ZCode 的做法)。

「在资源管理器中打开」的参照实现(照抄的是谁)

这条能力不是自创的。本机三处现成实现里,两处(DSH 自带的 open-in-app、右侧栏的 better-sidebar)走的正是 同一条通道——在 harness 的 webServer 上注册 HTTP 路由;第三处(dsh-context)走通用 RPC。

参照 在哪 它怎么做 我们抄了什么
dsh-host-open-in-app + dsh-client-ui-open-in-app DSH 自带(右上角「在应用中打开」那个分栏按钮) host 半身 ctx.effect(() => ctx.webServer.register({ kind: "exact", path: "/open-in-app/...", handler }));客户端 fetch(new URL(route, hostBase())) 路由注册方式、hostBase() 的绝对 URL 约定
dsh-better-sidebar 本机 profile(右侧栏文件树的「在应用中打开 → 资源管理器」) host 侧 spawn 平台打开器(Windows explorer.exe /select,<路径> 平台命令形态;我们把 /select 换成立即能看见的 /n,/select,
dsh-context 本机 profile 通用 RPC 调官方 session/canOpenWorkspacePath / session/openWorkspacePath 保留为回落通道(它在本机回 {opened:true} 但桌面无反应)

怎么单独验 host 路由(不用等用户重启)

桌面渲染器里 host 半身是否装载、路由是否注册,可以直接用一份临时 harness 实测:

# 1) 在 web profile 的 node_modules 里临时挂 junction(验完 rmdir 删掉,别留在用户 profile 里)
cmd //c mklink //J "C:\Users\<user>\.dsh\profiles\web\node_modules\dsh-edit-diff" "<repo>\dsh-edit-diff"
# 2) 只插本插件的覆盖层
cat > /d/tmp/edit-diff-overlay.yml <<'EOF'
- insert:
    - id: edit-diff-probe
      name: dsh-edit-diff
EOF
# 3) 用 app 自带那份 harness 起临时实例(它才是桌面在用的版本;CLI 那份版本号不同)
node "<DSH 安装目录>/resources/app/node_modules/@deepseek-ai/dsh/lib/bin.js" \
  --profile web --patch D:/tmp/edit-diff-overlay.yml --port 5599 --no-open
# 4) 打这条路由:400 = 我们的 handler 在响应;405 = 没注册(落到静态前端)
curl -s -i -X POST -H "content-type: application/json" \
  -d '{"path":"D:/nope/nope.md"}' http://127.0.0.1:5599/edit-diff/reveal

2026-09-19 实测结果:CLI harness 0.1.1、app harness 0.1.5-rc.2、以及 app harness 再叠桌面补丁层--patch "<DSH 安装目录>/resources/app/cordis.patch.yml")三种组合下,路由都注册并返回 400

桌面侧两个事实(排查时用得上):

  • 渲染器 origin 就是 harness 的 webServer(rendererOrigin = http://127.0.0.1:${webServer.port},本机 43120); 渲染器只跟这一个服务器说话(LAN 边缘是另一件事,回环请求不走它)。
  • harness 的日志在 C:\Users\<user>\AppData\Roaming\DSH Desktop\logs\host\dsh-<日期>.log(还有同名 .error.log), host 半身现在会写 [dsh-edit-diff] host half loaded / reveal route mounted at /edit-diff/reveal / 挂载失败的 error 行。

结构

  • lib/client.js 里的 hostBase():宿主路由必须按绝对 URL 请求(location.origin,页面 origin 为 null 时用载体内部 origin http://dsh.internal)。相对路径会打到「服务这个页面的那个服务器」,在桌面 上是静态前端,只会回 405 —— 照 shipped 的 dsh-client-ui-open-in-app 抄这一条即可。
  • lib/index.js:host 半身,只干一件事——挂一条 /edit-diff/reveal 路由:收 { path },校验「绝对且 真实存在的文件」,再用 argv 数组调平台打开器(Windows 用 explorer.exe /n,/select,<路径>/n 强制 新窗口;macOS open -R;Linux xdg-open <目录>)。路由用 ctx.inject(["webServer"], cb) 挂——插件本身 照常加载,等到 web server 出现才注册;浏览器半身拿不到这条路由时回落到官方 opener。
  • lib/client.jswindow.__ModuleLoader__.load 双面包客户端半,含 diff 核心、自绘 body 与四个 keyed toolview。
  • test/smoke.mjs:用 vm 加载 client bundle(stub window / react / primitives,渲染时展开函数组件), 断言参数推导、路径相对化、元数据收窄、行级对齐、字符级标记、空行往返、超限退回、 400 组随机小样本与 O(n·m) LCS 长度对照(改动集最小),以及分色统计、body 渲染与槽位注册。 轮末卡那段按框架的真实顺序驱动(每个事件先 matchstart/update,seq 只在事件上), 并单独覆盖 prepend 的「先 match 整页再 update」路径。

License

MIT,见 LICENSE

内容来自项目 README(GitHub)↗

链接

同类插件

查看整个分类 →

社区评论

评论公开保存在 GitHub Discussions。加载评论会连接 GitHub 和 Giscus;发表内容需要 GitHub 账号。