DeepSeek Harness Plugin Hub

发布与管理完整 Harness Profiles,发现适合你的插件。

探索

插件目录环境预设文档中心动态

社区

发布插件联系我们报告问题

相关链接

Plugin Hub GitHubDeepSeek Harness 官方项目系统状态隐私说明
© 2026 DeepSeek Harness Plugin HubPowered byPaxTech

独立、非官方社区项目,与 DeepSeek 官方无隶属、授权或背书关系。

Ui Gitworkbench — DeepSeek Harness 插件(DSH Plugin)
← Plugins

@young1lin/dsh-ui-gitworkbench

Ui Gitworkbench

外置 dsh Web UI 插件:会话标头中的 Git 工作台芯片,打开一个抽屉,提供文件树、逐文件差异、历史记录、比较、暂存、提交和同步(fetch/pull/push)。

插件会安装到这里;不确定时保持 web。

npx -y @deepseek-ai/dsh plugin --profile web add @young1lin/dsh-ui-gitworkbench@0.1.23
README兼容性版本

兼容性与来源证明

Ui Gitworkbench 以 @young1lin/dsh-ui-gitworkbench 发布,当前版本为 0.1.23。Plugin Hub 会校验它的 manifest,并保存精确安装来源,便于复现安装结果。

DSH 兼容范围
*
运行环境
web
发布来源
npm
Registry 更新时间
2026/9/16

版本

0.1.23stable
2026/9/16
0.1.22stable
2026/9/16
0.1.21stable
2026/9/14
查看其余 8 个版本收起版本
0.1.20stable
2026/9/12
0.1.18stable
2026/9/11
0.1.17stable
2026/9/10
0.1.16stable
2026/9/4
0.1.15stable
2026/8/31
0.1.13stable
2026/8/25
0.1.12stable
2026/8/23
0.1.11stable
2026/8/20

相关插件

正在加载相关插件…

最新版
0.1.23
DSH
*
HMR
重启进程
Tree shaking
未声明可安全裁剪
解包体积
5.4 MB
文件数
133
Surface
web
许可证
MIT
发布源
npm
GitHub
★ 1
周下载
0
最近提交
2026/9/16
查看源码 ↗
README Badge

点击下方 Badge 复制 Markdown,粘贴到 README 即可。

这是你的 Plugin?认领权益 · 优先安全扫描

验证 package.json 声明的 GitHub 仓库,即可管理这个公开页面。认领后,Hub 会优先安排当前版本的安全扫描,并在通过后公开展示结果。

认领这个 Plugin →
报告问题
DeepSeek Harness Plugin Hub
ProfilesPlugins分类动态文档登录管理 Profiles
ProfilesPlugins分类动态文档登录

相关插件

继续浏览 developer-tools 分类下经过校验的插件。

Web App@deepseek-ai/dsh-web-appdsh 浏览器界面捆绑包:位于 dsh-base 之上的 Web 补丁层,加上运行时粘合插件(提供前端 dist、Web 界面提示符、bash 运行时变量和 URL 行)Sdk Minimal@deepseek-ai/dsh-sdk-minimal独立的最小 SDK 配置包:JSON-RPC、一个 DeepSeek 适配器、持久化 Shell 和 JSONL 会话Sdk App@deepseek-ai/dsh-sdk-appdsh SDK 配置包:基于 dsh-base 提供 stdio JSON-RPC 服务和进程生命周期管理Subagent Codex@deepseek-ai/dsh-subagent-codex基于官方 app-server 协议的一次性 Codex 子代理提供程序

README

@young1lin/dsh-ui-gitworkbench

🌏 中文 · English

dsh(DeepSeek Harness) 的树外 Web UI 插件:给 dsh 的 Web 界面装一个 Git 工作台,不改动 dsh 本体。

每个会话的头部都有一枚状态卡,显示当前分支、领先/落后和增删计数。点开它,右侧滑出一张工作台面板,当前 worktree 的改动一览无余:

  • 变更:可折叠的文件树,配完整上下文的左右并排 diff——双列行号、词级高亮、Shiki 语法着色,右栏可直接 Edit;二进制文件按字节嗅探,是图片就直接显示(删除的文件显示 HEAD 那份);树顶可打关键字过滤文件列表(多词与关系、智能大小写),文件行悬浮可一键撤回到上次提交(IDEA 的 Rollback,弹窗先说清后果);diff 头部常驻当前变更块与 current / total,Unstaged 可 Stage / Revert(进入 Edit 也保留),Staged 可 Unstage 当前块或整个文件;Ctrl/Cmd+F 两列查找——未武装时搜左右两列(先左后右、跨列步进),武装后查找条只压在工作树列上方;
  • 文件:仓库目录树与可编辑文件查看器,支持搜索、图片预览、CodeMirror 编辑和 blame 行信息;
  • 历史:提交列表 / 文件树 / diff 三栏并排,滚动到底自动翻页;行内带作者、悬浮卡带精确时间;diff 内 Ctrl/Cmd+F 查找(Enter / Shift+Enter 上下一个、当前 / 总数 计数、命中着色),图片显示该提交的那份(删除的显示父提交那份);IDEA 式过滤(user: / path: / after: 输入语法,或作者 / 日期 / 路径分区漏斗弹层),条件编译进 git log、全历史匹配、车道图常驻,另有「全部分支」;
  • 对比:任选两个分支互相比较,diff 内同样可查找,图片显示 head 那份(删除的显示 base 那份);
  • 提交与同步:树上勾选文件就是真实的 git add / git restore --staged,配合提交框和 fetch / pull / push 同步条,一次提交加推送全程不用离开面板;头部另有分支切换器(仅主工作树)——当前分支打点、被其他工作树占用的置灰并注明去向、远端独有分支一键签出并跟踪,本地改动带得动就随行、带不动 git 拒绝并归类提示,绝不 force;
  • 外观:七套主题族各带亮暗,默认跟随系统;支持虚化背景图和自定义 CSS,按「项目 / 全局」两个作用域保存,项目优先。

另带 worktree 仿真:模型在会话里调用 worktree_enter / worktree_exit / worktree_status 三个工具,即可在 .agents/worktrees/<name> 下建立或退出隔离 worktree,并把会话绑定过去。子代理会话不写自己的绑定,而是沿谱系借用最近绑定祖先的 worktree——standing 提示、芯片与 worktree_status 对无自有绑定的会话统一解析「有效绑定」,外层退出后子树自动失去借用。绑定后状态卡点亮绑定标记,面板头部出现 worktree 切换器(按分支列出仓库全部 worktree),统计随之切换。

演示(2 分 24 秒):状态卡 → 变更页(勾选暂存 / 逐文件 diff / 提交)→ 外观(明暗 + 七套配色 + 背景图)→ 历史页(提交图 / worktree 切换)

这份 README 同时是交接文档:插件是什么、怎么写的、踩过哪些坑、怎么继续改,全部记录在案。接手开发前请先读「§6 踩坑实录」——那里是真实调试换来的关键事实。

0. 安装

前置:DSH 已装好(dsh web 能正常运行),Node.js ≥ 20,pnpm ≥ 10。

推荐:官方插件通道,一条命令。

dsh plugin --profile web add @young1lin/dsh-ui-gitworkbench

装完重启 DSH,再硬刷新浏览器(Ctrl/Cmd + Shift + R)。包内声明了 dsh.bundle.patch,CLI 会自动把宿主半注册进 profile 的 dsh.profile.bundles,下次启动即挂载,不需要手写任何 cordis.patch.yml 挂载行。机器上没有 dsh 命令时,用 npx 直接跑:

npx -y --package @deepseek-ai/dsh dsh plugin --profile web add @young1lin/dsh-ui-gitworkbench

已安装的升级用 update,不要重复 add:

dsh plugin --profile web update @young1lin/dsh-ui-gitworkbench

dsh plugin 是 pnpm 的薄转发层:重复 add 对已装包不报错,但会把依赖重装成最新版并覆盖 link: 软链安装(从源码开发的机器会突然「回到」npm 版);update 按安装态对账,新版新增的 dsh.bundle 声明也会被自动激活进层栈。升级后同样重启 DSH。

备选:一键脚本(同样走官方通道,多处理两件小事)
# macOS / Linux(Windows 装了 Git Bash 或 WSL 也可)
curl -fsSL https://raw.githubusercontent.com/young1lin/dsh-ui-gitworkbench/main/scripts/install.sh | bash
# Windows(PowerShell 5.1+ / pwsh)
irm https://raw.githubusercontent.com/young1lin/dsh-ui-gitworkbench/main/scripts/install.ps1 | iex

脚本在安装命令之外多做两件事:预写 pnpm 11 的 minimumReleaseAgeExclude,让刚发布不足 24 小时的版本也能立即安装;幂等清理旧版手动挂载行,避免宿主半挂载两次(页面上出现两个状态卡)。支持指定版本、装完 pm2 restart dsh-web、--dry-run 试跑等参数,见脚本头部注释。

从源码开发

dsh plugin --profile web add <本仓库路径> 把源码装进 profile;改完客户端半跑 npx tsdown 再刷新浏览器即可生效(宿主半改动需重启 dsh web)。详见 §5。从 link: 源码依赖切回 npm 版时,记得移除 cordis.patch.yml 里的手动挂载行(安装脚本会自动处理)。

发布(维护者)

首次发布与后续发布走不同链路:

  • 首次(包还不存在于 npm,Trusted Publishing 尚无处配置):本机 npm login 后 npm publish(scope 包的 publishConfig.access 已设 public)。发布后到 npmjs.com → 包 Settings → Trusted publishing 添加 GitHub Actions 发布器:user young1lin、repository dsh-ui-gitworkbench、workflow 填 publish.yml(不带路径前缀)、Environment 留空、勾选允许 npm publish。手工发布不经 CI 里那道机器路径门禁(见 publish.yml 的 grep 步骤),发布前可自行扫一眼 lib/*.js 确认没有本机绝对路径混入。
  • 后续:npm version patch(或 minor/major)→ git push → git push --tags。tag vX.Y.Z 触发 .github/workflows/publish.yml:CI 全量检查 → tag 与 package.json 版本一致性校验 → OIDC Trusted Publishing 自动 npm publish(provenance 自动生成,全程无 npm token)。不要手动补推已由人工发布过的版本的 tag(如首次的 v0.1.0),registry 会拒绝同版本重发。

发布产物不带 sourcemap。 lib/client.js.map 解包 3.1MB、gzip 416kB,占了整包下载的 46%;排掉后 tarball 从 914.6kB 降到 498.0kB。两处配合才干净:prepack 走 bundle:publish(tsdown --no-sourcemap,连 //# sourceMappingURL 注释一并不产出——只删文件不删注释的话,dsh 的 /plugins/<id>/client.js.map 路由会给每个使用者一个 404),files 里的 !lib/*.map 再兜一道,防止上一次 dev 构建遗留的 map 被 clean: false 留在 lib/ 里蹭进包。

副作用记一笔:npm publish 和 npm pack(含 --dry-run)都会触发 prepack,所以跑完之后本机 lib/client.js 是不带 sourcemap 注释的那份,浏览器里断点看到的是打包后的代码。继续开发前跑一次 pnpm exec tsdown 就回来了。


1. 当前状态(已验证)

能力状态验证方式
宿主 gitWorkbench/stats RPC 返回真实统计✅curl -X POST /api/gitWorkbench/stats 返回 {ok:true, value:{branch, files[], diff}}
客户端 bundle 被 shell 加载(boot 清单)✅window.__DSH_BOOT__.entries 含 @young1lin/dsh-ui-gitworkbench
浏览器→宿主 RPC 通✅页面内 fetch('/api/gitWorkbench/stats', ...) 返回 200
面板 diff 完整(不丢文件)✅换用 subprocess pipe 后,diff --git 计数 = 文件数
状态卡在 git 仓库会话常驻显示(分支/↑↓/计数),仅非 git 目录或 git 失败时隐藏✅干净树也显示分支名(状态卡即会话的环境信息位);绑定徽标见 §9
agent 工具 worktree_enter/exit/status(模型可调)✅真实会话冒烟 scripts/llm_smoke.py:模型调 enter → .agents/worktrees/llm-smoke 出现;exit(remove) → 消失
宿主 worktree RPC(enter/exit/status/sessionWorktree)+ 绑定文件✅python scripts/probe_worktree.py:scratch 仓库断言 + 真仓库冒烟 + 再进入分支复用,ALL PASS
状态卡绑定标记(树形图标;徽标文字与分支重名时省略)+ 头部 worktree 选择器✅python scripts/verify_worktree_ui.py:6 步 UI 探针(绑定标记、头部路径、选择器切换、折叠/
头部分支切换(主工作树限定 / 占用置灰 / 远端签出跟踪 / 拒绝与随行)✅python scripts/verify_branch_switch.py:12 步实机探针(fixture 仓库,HEAD 与改动全程可还原)
历史过滤(作者 / 日期 / 路径下推 git log、「全部分支」、日历与三态路径树)✅python scripts/verify_history_feature.py:11 步 UI + host 探针全过(中文作者、All-branches、日历选界、目录吸收文件勾选、诚实空态)
单文件撤回(Rollback)与文件列表关键字过滤✅对 live app 实测:撤回弹窗措辞随 host 实时推导的后果变化、取消不动手、执行后 fixture 回静息态;过滤框多词 AND、忽略折叠、根勾选只动可见行
变更块导航与 Staged 恢复出口✅tests/edit-hunk-actions.test.ts + scratch fixture live probe:Staged 常驻 Unstage file,多块另有 Unstage hunk;操作后回到 Unstaged,页面无错误
客户端半被类型检查✅tsconfig.client.json 进了 bundle/typecheck;曾故意写坏一处,确认报 TS2322
主题 7 族 × 亮暗 + 跟随系统明暗✅tests/theme-palettes.test.ts 把 themes.ts 与 .module.css 互扣(两个方向都验过会红); 含全部 14 套调色板

已知边界:状态卡挂在 conversation.session.header.actions 插槽,只有**打开了会话(会话头渲染)**时才挂载。无头自动化里若没真正打开会话,状态卡不会出现——这是预期行为,手动在 UI 里开一个会话即可看到。


2. 架构(一句话 + 详情)

宿主半:一个 TypertRemoteService,跑 git 算统计 + worktree 增删与「会话→worktree」绑定,经 Typert gateway 自动发现;同一服务再以 defineTool 注册三个 agent 工具。客户端半:一个 React 面板,注册进会话头插槽,通过 connection.rpc 向宿主要数据(统计 + 会话绑定)。

2.1 宿主半(src/index.ts)

class GitWorkbenchService extends TypertRemoteService {
  static inject = ['subprocess']          // 等 subprocess 服务就绪才激活
  constructor(ctx) { super(ctx, 'gitWorkbench') }   // 注册为 ctx.gitWorkbench,命名空间 = 'gitWorkbench'
  @Remote('stats')                        // endpoint = gitWorkbench/stats
  async stats(worktreePath, signal) { ... 用 ctx.subprocess.spawn 跑 git ... }
}
export default GitWorkbenchService
  • Typert gateway 通过"源码标记反射"自动发现这个方法(读 @Remote 装饰器在原型上打的 marker)——不需要生成 descriptor、不需要改 monorepo 任何文件。这是树外插件最干净的 RPC 暴露方式。
  • 浏览器侧调用:ctx.connection.rpc.call('/api', 'gitWorkbench/stats', { args: { worktreePath } }, signal) → 返回 {ok, value} | {ok:false, error}。
  • 取数用 ctx.subprocess.spawn({argv:['git',...], cwd, stdio:{stdout:'pipe'}}),自己累加 stdout 流。见踩坑 §6.3。

2.2 客户端半(src/client/)

// src/client/index.ts
export const inject = ['sessions', 'slots', 'connection']
export function apply(ctx) {
  const connection = ctx.connection
  ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register(
    { name: 'conversation.session.header.actions', id: 'git-workbench', order: 30,
      inject: () => ({ fetchStats: async (worktreePath, signal) => {
        const r = await connection.rpc.call('/api', 'gitWorkbench/stats', worktreePath ? {args:{worktreePath}} : {args:{}}, signal)
        return r.ok ? r.value : null
      }}) },
    GitWorkbenchPanel,
  ))
}
  • 插槽系统:ctx.slots.inject(key, cb) 会在 key 插槽被声明后执行 cb;cb 里 ctx.slots.register({name,id,order,inject}, Component) 注册组件。可复用已有插槽(如本插件的 conversation.session.header.actions),也可用 declare module '@deepseek-ai/dsh-client-ui-slots' 声明合并新增插槽。
  • 组件 props:PropsRuntime<'conversation.session.header.actions'> 提供 sessionId、useSessions 等;inject 工厂返回的对象(如 fetchStats)会作为 props 注入组件。业务回调从 apply 作用域经 inject 工厂过到组件,绝不用全局 ctx。
  • 拿 worktree 路径:useSessions(state => state.byId[sessionId]?.cwd)——会话摘要自带 cwd。
  • 数据刷新:挂载时拉一次 + 面板打开时轮询(空闲 15s、agent 运行中加密到 3s——运行中的会话正在改文件,等满 15s 看到的就是旧闻)+ 手动刷新按钮。面板关着时另有一条便宜的绑定探针(见 §6.0d):只在 agent 运行中开表,走不 spawn git 的 sessionWorktree,发现绑定变了才补一次 worktreeStatus。

2.3 组件与样式(GitWorkbenchPanel.tsx + 功能组件 + styles/*.css)

  • 外壳:面板是一张四边留白的卡片(--gs-inset,14px 圆角、投影),最大化按钮切到满屏。三条边可拖:卡片左缘(MIN_DRAWER_WIDTH)、提交列表与文件树之间、文件树与 diff 之间。三处共用 useHorizontalDrag(pointer capture + pointercancel)。窗格上界由 applyPane 现场量出来算:面板宽 - 邻窗格宽 - MIN_DIFF_WIDTH,diff 是唯一不能折行的窗格,所以它的下限是硬的。宽度与主题存 localStorage。
  • 布局:变更页 = 文件树 + 逐文件 diff 两栏;历史页 = 提交列表 + 文件树 + diff 三栏并列(GitHub Desktop / JetBrains git log 的做法),各自独立滚动,因此没有可折叠的东西要解释。翻页是滚动哨兵(IntersectionObserver),不是按钮。
  • 逐块操作:DiffViews.tsx 用完整上下文 side rows 把连续增删行归成块;点击代码块或按 F7 / Shift+F7 更新显式 current block,头部固定按钮始终作用于这一个块。Unstaged 提供 Stage / Revert,Staged 提供 Unstage hunk / file;整文件 Unstage 在点击时才收集所有变化行,并沿用 diffSha 过期检查。Edit 模式改用 working-tree 真实行号计算 CodeMirror 的 dense 滚动位置,dirty buffer 只禁用 Git 区块操作,不隐藏按钮。
  • diff 渲染:renderDiff(segment) 把统一 diff 逐行分类,渲染成 [老行号][新行号][+/-槽][代码] 的 flex 行;行号从 @@ -a,b +c,d @@ 解析并随行递增。
  • 样式装载:GitWorkbenchPanel.module.css 只是一张清单,按功能 @import styles/*.css;构建在 CSS Modules 作用域化之前内联它们,运行时仍是一张类名表和一个 <style>,不是十次网络或十个 style 标签。
  • 配色:面板自带调色板,不走 dsh 主题 token——diff 需要 增/删/词级/语法 四组颜色,dsh 没有定义。所有颜色都过 --gs-* token,字面色只出现在 .overlay[data-gs-theme='<family>-<mode>'] 的调色板块里;换主题=换一组 token,别的什么都不动。只有状态卡(在 dsh 原生 chrome 里)保留 --dsw-* token。
    • 主题族与解析逻辑在 src/client/themes.ts(不 import CSS/React,因此可被测试直接加载):GitHub / IntelliJ IDEA / VS Code / One / Solarized / Nord / Cyberpunk,各带亮暗两套。
    • 明暗默认 system = 跟随操作系统(matchMedia('(prefers-color-scheme: dark)'),挂载期间持续跟随);显式选亮/暗则完全覆盖。
    • 明暗三个按钮的色块是写死的白 / 近黑 / 对角各半,不取调色板:那三个按钮命名的就是颜色本身,暗色主题下把「亮色」画成深灰等于告诉用户反话。这是全文件唯一允许出现字面色的第二处。
    • tests/theme-palettes.test.ts 把 themes.ts 的族列表和 .module.css 的调色板选择器互相扣死:少一套调色板会让面板一个 --gs-* 都没有、整块退回浏览器默认色而不报错,所以这个不变量必须由测试守。

2.3b 自定义样式:背景图 + 自定义 CSS(src/style-store.ts + 宿主 styleGet/styleSet)

  • 两个作用域:project(按仓库根 key)与 global。背景图整条取项目的——虚化度/遮罩是为某一张图调的,换一张图就不成立,所以不做逐字段合并。自定义 CSS 两边都生效,global 在前、project 在后,靠 CSS 层叠顺序让项目覆盖全局;这比"整块覆盖"有用:全局定字号、项目改强调色。解析逻辑在 themes.ts 的 effectiveBackground / effectiveCss,tests/style-resolve.test.ts 守着。
  • 存在宿主而不是 localStorage:项目设置该跟着项目走(换浏览器、清 origin 都不该丢),而且一张背景图远超 origin 配额。文件是 ~/.dsh/gitworkbench-style.json,原子写复用 src/atomic-json.ts(tmp+rename + Windows EPERM 退避),两个作用域并发写经 withStyle promise 队列串行化。
  • 图片先在浏览器里降采样(createImageBitmap → canvas → JPEG,长边 ≤2560,q0.82)再存。手机照片 4-6MB,虚化之后那些细节一点都留不下,没必要每次开面板都拖着走。
  • image 只接受 base64 data: URL(style-store.ts 的 IMAGE_PATTERN)。客户端要把它插进 url("…"),而 base64 字母表里没有引号、括号、反斜杠、分号,所以存进去的值不可能闭合函数再追加规则。https://、data:image/svg+xml、data:text/html 一律拒绝,tests/style-store.test.ts 逐条验过。
  • 背景怎么画:.drawer[data-gs-bg]::before 铺图 + filter: blur()(transform: scale(1.12) 是因为模糊会采样到盒子外,不放大边缘会透明)。同时 --gs-surface / --gs-surface-2 从实色切成 color-mix(… var(--gs-veil), transparent),各窗格因此透出底图;弹出层(主题菜单、分支选择器)故意保持实色,压在虚化照片上的菜单没法读。没设背景图时这两个 token 就等于 --gs-bg / --gs-panel,即与之前逐像素一致。
  • 用户 CSS 的抓手是 data-gs-part:overlay / card / header / tabs / commits / tree / diff。CSS Modules 的类名每次构建都换 hash,从外面根本选不中,所以必须有一组稳定属性。常见写法就是覆盖 token:[data-gs-part="card"] { --gs-accent: #ff0066; }。

2.4 worktree 仿真(src/worktree.ts 纯逻辑 + src/index.ts 里的 RPC/工具)

  • 宿主 RPC(同一 GitWorkbenchService 上多挂 4 个 @Remote,参数照 §6.8 裸标识符、signal 最后):
    • worktreeEnter(sessionId, repoPath, name, branchName, signal)——repoRootOf 解析仓库根;在 <repoRoot>/.agents/worktrees/<name> 创建(或复用)worktree、分支 = branchName ?? 名字(都不加强制前缀),写绑定;返回 {ok, worktreePath, branch, hint},hint 教模型怎么用相对路径(会话 cwd 不可变)。branchName 是给斜杠分支留的口子:feature/foo 是合法 ref、却是 Windows 目录名拼不出的拼写,名字兼任分支时这类最通行的分支永远建不出来。它的校验按分支的规矩走(isRefName 加上 git 自己也会拒的 .lock 结尾、首尾点、head 大小写碰撞),非法直接拒绝、绝不静默换名——目录标签可以随机生成,分支名有语义;且只在全新创建时生效。复用判定走 realpath:目标目录已是注册 worktree(别的工具建的、或经 Junction 映射进来的,git 登记的是另一种拼写)→ 直接绑定并保留它自己的分支(显式传了不一致的 branchName 时 hint 注明未采用),不再 worktree add。
    • worktreeExit(sessionId, remove, signal)——解绑;remove:true 且树干净才 git worktree remove,脏树拒绝。
    • worktreeStatus(sessionId, repoPath, signal)——有效绑定(自有优先,否则沿谱系借最近绑定祖先;bindingInherited 标明是否借来)+ 仓库全部 worktree 列表。
    • sessionWorktree(sessionId, signal)——{worktreePath, name, inherited},只读绑定 JSON、零 git spawn;无自有绑定时借最近绑定祖先(inherited:true),连祖先也无绑定才是双 null。客户端轮询已改用 worktreeStatus(绑定+列表一次拿全),这个 RPC 保留作轻量单查。
  • 绑定持久化 ~/.dsh/gitworkbench-worktree-bindings.json({v:1, bindings:{<sessionId>:{repoRoot,worktreePath,name,enteredAt}}})。写法是先写 .tmp 再 rename(崩溃不留半截文件);Windows 上 rename 可能 EPERM → 25/50/100/200/400ms 退避重试;所有 load→save 段落经 promise 队列互斥(withBindings),并发 enter/exit 不会互相覆盖。
  • agent 工具:同一份逻辑用 ctx.tools.register(defineTool({...})) 注册成 worktree_enter/exit/status,sessionId/cwd 取自 exec.agent?.session(不接受模型传参)——注册要点见 §6.10,schema 限制见 §6.11。
  • 客户端跟随:GitWorkbenchPanel 每轮拉 stats 的同时拉 worktreeStatus(sessionId, cwd)(绑定 + 仓库全部 worktree 一次拿到,agent 在 dsh 外面建的 worktree 也会跟进列表);有绑定 → 状态卡亮出绑定标记(树形图标;分支与徽标文字重名时省略后者)、stats 改传绑定的 worktree 绝对路径;面板头部的 worktree 选择器按分支列出所有源,只切显示对象、不动绑定。树的展开状态跨切换、跨轮询保留;选中在切换源时有意重置——旧 worktree 的路径不能漏进新树的选中(§6.0c)。

2.5 写操作:暂存 / 提交 / 同步 / 撤回(src/git-ops.ts + src/discard-ops.ts + 宿主 9 个 @Remote)

  • 勾选就是 git 调用:勾一个文件=git add -- <path>,取消=git restore --staged -- <path>,立即生效。argv 全部数组构造(无 shell,引号不是攻击面),路径一律放 -- 之后并拒绝前导 -(文件可以合法叫 -f,位置参数传进去就成了选项);全库没有 --force/reset --hard/clean 任何拼写——丢提交类操作需要的是专门的确认设计,不是碰巧排在旁边的按钮。
  • 点击即显、不丢点击:勾选走乐观更新 + 队列(stage-tree.ts 的 nextBatch 按动作聚批,一次 drain 只发一个 git 调用——宿主一次调用 ~300ms,等它返回再画勾就是用户投诉的「超级卡」),120ms 内连点两下都会入队生效;轮询回包经 settledTicks 对账后落定。
  • 提交:commit(worktreePath, message, amend, signal)——消息整段作一个 argv 元素传 -m(多行 body 是常态,拆分才是风险),绝不 -a:面板有自己的暂存区,全量扫进去等于让分区变成摆设。
  • 同步:syncStatus(branch/upstream/ahead/behind + hasRemote,读 git status 而非 rev-list --count——「没配 upstream」和「与 upstream 齐平」的计数都是 0,只有前者决定 push 要不要 --set-upstream)、fetch --prune(远端删掉的分支别再算作待拉取)、pull --ff-only/--rebase/--no-rebase(模式永远显式:按钮写什么就跑什么,不读用户的 pull.rebase 配置)、push(绝不 force;无 upstream 时 --set-upstream origin <branch>;被拒归类为 diverged,答案是先 pull 而不是覆盖别人的工作)。
  • 单文件撤回(IDEA 的 Rollback):discardPlan / discardFile,计划推导在 src/discard-ops.ts(纯函数)。语义与 IDEA 一致:不问暂存与否,索引与工作区一起回退——改过的还原、未提交过的删除、误删的找回、改名撤销;目录不提供这个手势。计划由 host 用全树 git status 现场推导(git 靠「一删一增」配对才认出改名,带单文件 pathspec 的 status 只看到一半,会把「撤销改名」错读成「还原一个 + 删掉另一个」,见 §6.16);弹窗措辞来自推导出的后果(找回已删文件是纯收益,不弹窗);执行前重推一遍并核对后果一致,文件变了就什么都不做。危险拼法禁令同上且更严:只有 git restore 加单个 pathspec(-- 之后),删除走文件系统但拒绝绝对路径 / 盘符 / UNC / .. 并按解析后路径复查在工作区内——tests/discard-ops.test.ts 扫描本模块可产出的每条计划守这条线。
  • 失败要说人话:classifyFailure 把 stderr/exit 归类为 auth / no-upstream / diverged / conflict / nothing-to-commit / dirty,原始文本随行返回——归类是提示,不替代证据。子进程环境关掉全部凭据提示(GIT_TERMINAL_PROMPT=0、GCM_INTERACTIVE=never、askpass 置空):stdin:'ignore' 不会把交互提示变成错误,只会变成没人能回答的等待,而那等待挂在宿主进程里——一个过期的 token 就能挂死整个插件 30s。

2.6 历史过滤(宿主 src/log-filter.ts + src/shortlog.ts;客户端 log-filter-query.ts / calendar.ts / dir-tree.ts / path-select.ts)

  • 条件编译成 git log 参数(log-filter.ts:统一 -i -E 方言、字面量转义、--author 逐人、approxidate --since/--until、pathspec 放 -- 之后),在全部历史上匹配后再分页——不是只筛已加载的页;过滤后的翻页仍是单次连续游走,车道图不断。裸 yyyy-mm-dd 由 host 展开为全天(§6.15);git log 失败原样透出 stderr(exit + 尾部),不静默成「无匹配」。
  • 两个入口写同一个过滤器:输入框语法(user: / path: / after: / before: + 可删除 chips,log-filter-query.ts)与漏斗弹层(作者来自 git shortlog 且跟随当前 ref——名单里的人必然搜得到;自绘日历 calendar.ts 纯函数月格;路径树 dir-tree.ts 聚合 + path-select.ts 三态勾选:勾目录覆盖并吸收子文件,目录有半选态)。防抖 300ms、在飞请求取消、条件变化回第 0 页。
  • 「全部分支」 = --all 哨兵(ref 不能以 - 开头,无歧义),ref 选择器与作者名单同步。按人搜索只匹配作者(git 没有「作者或提交者」并集下推,IDEA 同款),提交者完整显示在悬浮卡。

3. 文件布局

harness-worktree/
  package.json              dsh.client(web) + exports + 显式兼容范围的 optional peer(运行时由 profile 提供)
  .npmrc                    auto-install-peers=false(关键!见 §6.5 / §6.14)
  tsconfig.json             tsc 构建【宿主半】(stage-3 装饰器 + ambient shim)
  tsconfig.client.json      仅类型检查【客户端半】(rolldown 本身不做类型检查)
  tsdown.config.ts          客户端 closure-factory bundle + CSS Modules 构建
  vitest.config.ts          排除 .agents/**,避免 worktree 副本重复收集测试
  src/
    index.ts                GitWorkbenchService + 30 个 @Remote + worktree 三个 agent 工具
                            stats/fileDiff/fileSides/applyBlocks/writeChecked/blame/fileImage/revImage/commitStats/
                            commits/authors/repoTree/ignoredDir/compareRefs/sessionWorktree/worktreeEnter/worktreeExit/
                            worktreeStatus/styleGet/styleSet/syncStatus/stage/unstage/discardPlan/discardFile/
                            commit/fetch/pull/push/switchBranch
    atomic-json.ts          崩溃安全 JSON 写入(tmp+rename + Windows EPERM 退避)
    apply-blocks.ts         hunk patch 选择、正反向 apply 与 stale diff 防线
    blame.ts                porcelain blame 解析与路径/提交信息
    commit-cache.ts         commit hash 内容寻址 LRU
    discard-ops.ts          IDEA Rollback 的计划推导与路径防线
    fs-remove.ts            受工作区边界保护的文件删除
    git-log.ts/log-filter.ts/shortlog.ts  历史解析、过滤参数与作者名单
    git-ops.ts              写操作 argv + stderr 归类(纯函数,不 spawn;截断保首尾——关键词在头、建议在尾)
    image-sniff.ts          图片类型嗅探与读取上限
    patch-model.ts          Git patch 解析、行选择与重发射
    side-guard.ts           side diff / write 的路径与 stale-sha 校验
    style-store.ts          项目/全局外观存储
    worktree.ts             worktree 绑定、名称/分支/porcelain 纯逻辑
    write-checked.ts        编辑保存的编码、mtime/hash 与原子写校验
    types/*.d.ts            宿主/客户端 ambient shim
    client/
      index.ts              注册会话头插槽并桥接 RPC 回调
      GitWorkbenchPanel.tsx 状态卡与抽屉的状态编排;业务视图下沉到叶组件
      ChangesFileTree.tsx   变更树、过滤、勾选与提交区
      CommitHistory.tsx     历史列表、车道图、筛选与分页
      DiffViews.tsx         unified/side diff、块操作、虚拟窗口与编辑态
      WorkbenchControls.tsx 同步条、设置、来源选择器与反馈
      BranchSwitcher.tsx/branch-switch.ts  头部分支切换器:行规则(当前/占用/远端)纯函数化,仅主工作树渲染
      FileBrowser.tsx/CodeEditor.tsx/ImageView.tsx  文件页、编辑器与图片预览
      BinaryFilePane.tsx/image-source.ts  diff 窗格里的图片:按页签与状态决定读工作区、HEAD、该提交、父提交还是对比两端
      DiffFindBar.tsx/use-diff-find.ts/diff-find.ts  统一 diff 与未武装并排的 Ctrl+F:纯规则(字面量、不分大小写、5000 命中封顶;findInSides 两列先左后右)+ 停顿后扫描 + 按可视行着色;SideFindSeat/use-scroll-gutter.ts 把武装编辑器的 CodeMirror 查找面板钉在工作树列上方
      PaneDivider.tsx + *Glyph.tsx  拖拽分隔条与共享图标
      git-workbench-types.ts       面板组件/RPC 共享类型
      row-window.ts/use-row-window.ts  视口窗口纯规则与 React 桥接
      styles/*.css          按功能分片;由 GitWorkbenchPanel.module.css 构建期汇成一个 style
      *.ts                  勾选、diff、导航、缓存、过滤、主题等 React/CSS-free 纯规则
  tests/*.test.ts           单元、结构扫描、性能边界、泄漏与回归守卫(按领域与源模块对应)
  scripts/*.py              本地 live/UI/性能探针与辅助器(需真实 dsh/scratch;gitignore,不随包发布)
  cordis.patch.yml          宿主 entry 的 profile 挂载声明
  README.md / README_EN.md  中文深度交接文档 / 英文使用与维护说明
  CHANGELOG.md / CHANGELOG_EN.md  双语发布记录

4. 怎么构建

cd <仓库根目录>
pnpm install      # 装 tsdown/typescript/react/lightningcss/@types/node;.npmrc 关掉了 peer 自动安装
pnpm bundle       # = tsc -p tsconfig.json && tsc -p tsconfig.client.json && tsdown
pnpm typecheck    # 同样两个 tsc,不产出
pnpm test         # vitest

产物:

  • lib/index.js —— 宿主半(ESM,tsc 产出,装饰器已转译)
  • lib/client.js —— 客户端半(CJS closure-factory,tsdown 产出,CSS 已内联为 <style> 注入)

只改了客户端时,pnpm bundle 重建后刷新浏览器即可——web server 每次请求都从磁盘读 lib/client.js,不用重启。(别只跑 pnpm exec tsdown:rolldown 不做类型检查,会漏掉 tsconfig.client.json 才能发现的错误。)改了宿主半必须 pnpm bundle + 重启 dsh web(宿主代码在内存里,不重启不生效)。


5. 怎么加载 / 迭代

前提:deepseek-harness 仓库已 pnpm install + pnpm run build,且 DEEPSEEK_API_KEY 已设。

# 一次性:把插件装进 web profile(= 在 ~/.dsh/profiles/web 里 pnpm add 本目录)
dsh plugin --profile web add <仓库根目录>

# 启动(--patch 手工挂载宿主 entry;package.json 的 dsh.bundle.patch 声明已让
# `dsh plugin add` 自动带上补丁,--patch 仅在 profile 于该声明存在之前加入时需要)
dsh web --patch <仓库根目录>/cordis.patch.yml

# 便携交付:tarball 自足——prepack 现场构建 lib/;白名单带 lib/src、安装脚本、双语文档、AGENTS、LICENSE 与补丁
npm pack
dsh plugin --profile web add <tgz 路径>

打开 http://127.0.0.1:3080,在**有未提交改动的 git worktree**里开一个会话,会话头出现状态卡。

迭代循环:

  • 改客户端 → pnpm exec tsdown → 浏览器刷新(host 会 stat-poll 新的 lib/client.js,刷新即生效)。
  • 改宿主 → pnpm bundle → 重启 dsh web(先杀掉占用 3080 的进程)→ 刷新。

树外的客户端插件不会被 pnpm dev:web 监听(它只 glob packages/*/*/)。开发要热更就自己开个 pnpm watch(= tsdown --watch),host 仍会 stat-poll 并广播 rebuilt。


6. ⚠️ 踩坑实录(接力模型必读)

这些都是花了真实调试才确认的。别绕弯,直接照做。

6.0 git diff --no-index /dev/null <f> 在 Windows 上不可用

git 会把 /dev/null 解析成仓库相对路径,报 error: Could not access '...nul'。未跟踪文件的内容 diff 不要用 git 合成,直接在宿主 fs.readFile 后自己拼 unified diff 段(diff --git a/x b/x + new file mode + @@ -0,0 +1,N @@ + 逐行加 +)。顺带行数精确、零 spawn。

6.0b git status --porcelain 默认折叠未跟踪目录

?? .agents/ 一行代表整棵子树(曾导致 205 个文件只显示 3 行)。必须加 --untracked-files=all 逐文件枚举。

6.0c 轮询不得重置 UI 状态

15s 轮询每次返回新的 files 数组引用(内容相同)。若 useEffect 依赖该引用重置树的展开状态、或 bump gen 清按需 diff 缓存,用户就会看到"莫名其妙刷新、展开的目录缩回去"。规则:树的展开状态提升到会话级组件(轮询、关开面板都不丢);gen 只在手动刷新时 bump。

6.0d 关着的面板里,状态卡没有任何刷新路径

dsh 的 session.header.cwd 终身不可变,所以 worktree_enter 之后 sessions store 一个字段都不动。绑定只有一处会读——deps 是 [sessionId, worktreePath, fetchWorktreeStatus, open]——四个全不变;而 3/15s 轮询第一行就是 if (!open) return。合起来:面板关着时状态卡是挂载那一刻的快照,agent 进了 worktree 它还写着 main,点开面板(唯一能翻 open 的动作)才追上。一个指示器最不该有的性质。

补法是一条探针而不是一条轮询:sessionWorktree 只读绑定 JSON、不 spawn git,安静时每次就是一次文件读;只有它跟状态卡上的绑定对不上(bindingChanged,用 samePath 比路径——裸比会把一次分隔符差异变成每 3s 一对 worktree list + branch)才补一次完整 worktreeStatus,把徽标和选择器要的 worktree 列表一并带回来。开表窗口卡死(probesClosedBinding):面板关着 且 agent 在跑。绑定只可能在一个 turn 里动(enter/exit 是 agent 工具),而这个面板挂在每一个 session header 上,空闲会话连 timer 都不开;turn 短于一个间隔时,deps 里的 agentRunning 在 turn 结束时重跑 effect 兜底问一次。

代价明摆着:探针不看 agent 之外的改动。探针经 RPC 直接改绑定(比如 scripts/probe_worktree.py)时 agent 没在跑,关着的状态卡就不会跟。这是选定的取舍,不是漏掉的分支。

6.1 宿主半必须用 tsc 构建,不能用 tsdown

tsdown/rolldown(oxc) 不会转译 stage-3 装饰器 @Remote——产物里会留下原始 @Remote(...),Node 加载直接 SyntaxError。monorepo 里是先 tsc -b 转好再 tsdown 打包,所以没踩到。树外必须自己用 tsc 产出 lib/index.js(见 tsconfig.json + package.json 的 bundle 脚本)。

6.2 RPC 返回值必须 JSON-safe(undefined 会失败)

Typert gateway 对返回值做 assertJsonValue,任何 undefined 属性值都会被拒(报 business result failed boundary validation)。所以 error 字段在"无错误"时必须整个键都不带(声明为 error?: string,成功 return 里不写 error),不能写 error: undefined。

6.3 取数用 ctx.subprocess.spawn,不要用 ctx.shell

ctx.shell(bash-local/pwsh-local)在 Windows 上通过 PTY 捕获输出,大输出会从头部被 scrollback 滚掉:

  • git status --porcelain --branch 的 ## branch 头行丢失 → branch 显示空。
  • git diff HEAD(几十 KB)的前几个文件整段丢失 → 点那些文件显示"未跟踪"。

正确做法:ctx.subprocess.spawn({argv:['git',...], cwd, stdio:{stdin:'ignore',stdout:'pipe',stderr:'pipe'}, graceMs, signal}),拿到 handle.stdout 这个 Readable,自己 for await 累加所有 chunk(管道没有 scrollback 上限,一字节不丢)。同时 drain stderr 防止管道死锁,Promise.all([读stdout, 读stderr, handle.done])。

6.4 客户端 bundle 必须是 closure-factory 形状

dsh 的 ClientModuleSystem 强制要求 lib/client.js 是这个外壳(不能用普通 ESM/CJS):

window.__ModuleLoader__.load({ id: "@young1lin/dsh-ui-gitworkbench", factory: (require) => {
  var module = { exports: {} }; var exports = module.exports;
  /* ...代码... */
  return module.exports;
} });

由 tsdown.config.ts 的 outputOptions.banner/footer/intro 注入。react/react/jsx-runtime/@deepseek-ai/cordis 等是 external(运行时由 loader 的冻结模块表 require 提供,不进 node_modules 解析)。@deepseek-ai/* 的 import 必须是纯类型(import type,编译时擦除),否则会被 bundle 纯度门拒绝。

6.5 .npmrc 必须关掉 auto-install-peers

package.json 的 peerDependencies 写了 @deepseek-ai/*: "*"。pnpm 默认会自动装 peer,于是去 npm 拉 @deepseek-ai/dsh-client-runtime 及其传递依赖——而有些包没公开发布(如 dsh-compact)→ 404。.npmrc 里 auto-install-peers=false + strict-peer-dependencies=false 解决。这些包运行时由 web profile 提供(healProfilesModuleFallback 把所有内置包软链进 ~/.dsh/profiles/node_modules),本地不需要装。

6.6 CSS Modules 要自己 vendor lightningcss 插件

树外的 tsdown 没有 monorepo 那套 CSS Modules 插件。tsdown.config.ts 里 vendored 了 dsh-css-modules-inline 插件(resolveId 拦截 *.module.css → load 用 lightningcss 编译 → 注入 <style data-plugin="..."> + 导出 class map)。所以需要 pnpm add -D lightningcss。组件里 import css from './X.module.css'。

6.7 ambient shim 让 tsc 在缺包时编译

宿主半 import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' 是值 import(不是 type-only),但本地没装这个包。src/types/dsh-shim.d.ts 用 declare module 给 cordis/subprocess/typert-protocol 写宽松的类型,让 tsc 能转译。tsconfig 要 "types": ["node"](提供 process/AbortSignal)、"experimentalDecorators": false(stage-3)、"strict": false、"noEmitOnError": false。

6.8 路径参数用纯标识符

@Remote 方法在 SRC 发现模式下,gateway 靠 Function.prototype.toString 读参数名。所以参数必须是裸标识符(不能解构/默认值/rest),且 signal(若要取消)必须放最后。stats(worktreePath, signal) 是合法的;SRC 下 worktreePath 可省略(客户端传 {args:{}})。

6.9 端口 3080 被占用 → TaskStop 不够

dsh web 后台进程被 TaskStop 后,Windows 上 node 子进程可能还占着 3080,重启报 EADDRINUSE。要 netstat -ano | grep :3080 找 PID,taskkill //F //T //PID <pid>(//T 连子进程)杀干净再重启。

6.10 defineTool 注册 agent 工具的套路

  • 类上要 static inject = ['subprocess', 'tools']——不加 'tools',ctx.tools 不存在,ctx.tools.register 直接炸(工具服务要就绪才激活)。
  • description/参数 description 写英文(模型消费的语料,英文最稳),且把「进入后怎么用」写进去:file 工具加 .agents/worktrees/<name>/ 前缀、shell 命令传 per-call workdir .agents/worktrees/<name>。
  • 取会话:execute: async (args, exec) => { const session = exec.agent?.session; ... }——sessionId 用 session.id、cwd 用 session.header.cwd(注意 header.)。没有会话就拒绝(返回 {ok:false, error:'... requires a calling session'}),别 fallback 到 process.cwd()(那会绑到宿主进程目录,语义错误)。
  • 完整参照物:packages/goal/tool-goal/src/index.ts(本仓库 src/index.ts 的 registerWorktreeTools 就是照它写的)。

6.11 dsh-tools 的 JSON schema 子集:不支持 type 数组

  • 工具的 parameters/output schema 走 dsh-tools 的受限 JSON-Schema 子集,type: ['object','null'] 这种数组会在插件加载时抛错(整站起不来)。可空对象用 oneOf: [{type:'null'},{type:'object',...}]。
  • 每个 object 节点显式写 additionalProperties(false 或 true,不写不行)。
  • 输出 schema 必须容纳所有早退返回形状:worktree_status 的无会话早退 {ok:false, error} 与正常 {ok, binding, worktrees} 共用一个 schema,所以 ok/error 声明为可选、binding 用 oneOf——否则真实调用时校验失败。
  • RPC 每长一个键,output schema 就要跟一个:dsh 在模型看到结果之前按 schema 校验工具输出(createSuccessResult → validateJsonSchemaValue),additionalProperties: false 之下未声明的键不是「多一个字段」而是每次调用都 INVALID_TOOL_OUTPUT。worktreeStatus 为分支切换器长出 remoteBranches/remoteBranchesTruncated/mainWorktreePath(0.1.19)后 worktree_status 工具就一直在报错,抽屉直接读 RPC 所以没人发现,直到 code review 用 dsh 自己的校验器跑了一遍(四条 is not a declared property)。可空字符串同样写 oneOf: [{type:'null'},{type:'string'}]。守卫 tests/worktree-status-schema.test.ts:剥注释后把 schema 的属性表和 worktreeStatus 签名的返回类型键钉成相等(永远跑),本机 @deepseek-ai/dsh-tools 可解析时再用真校验器过 schema 与三种完整返回值(CI 里 peer 不存在、自动跳过)。

6.12 worktree 的 Windows 细节

  • git worktree remove 保留分支(exit 从不删 <name>——可能有未合并提交)。之后再 enter:worktree add -b <branch> <dir>(<branch> = branchName ?? 名字)会因分支已存在而失败 → 先 rev-parse --verify --quiet refs/heads/<branch> 探测,幸存则改用 worktree add <dir> <branch> 检出既有分支(hint 注明 reused,提醒模型里面有旧提交)——这也是「目录叫 X、落在既有分支 Y」的通路。
  • 绑定文件的 rename 在 Windows 可能 EPERM:页面 15s 轮询短暂持有读句柄/杀毒扫描,rename 撞上就 EPERM。做法:tmp + rename,EPERM 按 25/50/100/200/400ms 退避重试后再抛(见 src/worktree.ts 的 saveBindings)。
  • 路径一律正斜杠规范化:rev-parse --show-toplevel 的输出、porcelain 的 worktree path 都要做 .replace(/\\/g,'/') 再比对——宿主在 Windows 返回反斜杠,两边不统一就匹配不上(复用判定会失灵)。

6.13 宿主环境可能没有 git(PATH 缺失)

宿主 RPC 返回 git status failed (exit N): <stderr>(本插件的报错都带 exit code + stderr 尾部)时,先看 stderr——常见是宿主进程环境异常/git 不在 PATH,而不是目录真的不是仓库(2026-08-15 实例:目录明明是仓库却报 not a git worktree,重启 dsh web 换个健康环境即愈)。报错透出 stderr 是定位这类问题的唯一手段,新加 git 调用时照抄这个格式。

6.14 发布的 peer 范围不能写 *——* 按 latest dist-tag 解析

npm 7+ 自动安装 peer 时,* 走 latest dist-tag,不是「取版本列表最高」。@deepseek-ai/* 全系的 latest 长期停在 8 月 10 日的 0.0.1-rc.1 老线(那条线依赖从未发布的 @deepseek-ai/dsh-compact,公开安装必 404),能用的 0.1.0-rc.x 全挂 next。于是 0.1.2 之前任何不在 dsh profile 工作区里的裸 npm i 都炸 E404。规则:peer 写显式区间(cordis ^4.0.1-rc.1、dsh 系 ^0.1.0-rc.2),dsh 发新线时同步抬范围并验证 npm pack 出的 tarball 在空目录可装。注意 6.5 的 auto-install-peers=false 只管本仓库 pnpm 开发态,管不了用户侧 npm。

补充(0.1.11):范围要留,但这些 peer 同时是 optional。它们全在 tsdown.config.ts 的 CLIENT_EXTERNALS 里——dsh 外壳把它们共享进自己那张冻结模块表,运行时由加载插件的进程提供,从来不该落进 profile 自己那层 node_modules。实测目录结构可以证明:~/.dsh/profiles/web/node_modules/ 里只有插件自己,@deepseek-ai/* 全在上一层 ~/.dsh/profiles/node_modules/,Node 逐级向上解析所以能找到;而 pnpm 的 peer 检查只看本层,于是把每一个都报成 missing——官方自己的 dsh-client-ui-file-reference / dsh-file-reference 在同一次安装里打印一模一样的告警,这条才是「这是平台常态,不是本包的缺陷」的证据。「消费者不该安装的 peer」按 npm 自己的定义就是 optional,所以 0.1.11 起 peerDependenciesMeta 把六个全标为 optional:告警消失,而范围一个字没动,peer 真的在场时仍然照常校验版本。验证方式是空目录装 npm pack 出的 tarball(auto-install-peers=false,与 dsh profile 一致):改前 6 行 missing peer,改后零告警、exit 0、17 个 host 模块加 client.js 一个不少。

6.15 Windows 上裸 --since=2026-08-18 可能吃掉当天的提交

git 对裸 yyyy-mm-dd 的 --since/--until 解析带时刻语义,Windows 上一整天的提交可能全被排掉,且无任何报错。host 把裸日期展开为 T00:00:00 / T23:59:59 再交给 git(log-filter.ts)——选中一天即指一整天,不赌平台行为。

6.16 带单文件 pathspec 的 git status 会把改名拆成「一删一增」

git 靠「一删除 + 一新增」的配对才认得出改名;pathspec 只放行一半时,status 报 D 加 ??,而不是 R。任何按 status 推导计划的代码必须用全树 status 自行配对(discard-ops.ts 即因此不接受客户端传来的 status,一律重推),否则「撤销改名」会被计划成「还原一个、删掉另一个」——正好是用户没答应的那件事。

6.17 抽屉内新增模态层的 CSS 必须写 .drawer > .xxx,裸类会被压住

.drawer > *:not(.resizer) { position: relative; z-index: 1 }(布局需要)给了每个直接子元素 position 与层叠秩,裸类声明的 position: absolute 会输给它——遮罩被当作最后一个 flex 项排进抽屉底部的一条缝里,样式全对、位置全错、还不报错。模态遮罩一律写成 .drawer > .confirmScrim 这种带父作用域的选择器;tests/drawer-chrome.test.ts 有断言守着这条作用域与层叠秩。

6.18 CodeMirror 自带的界面只能在 EditorView.theme 里改,且查找面板不能用 flex 排版

.cm-* 是全局类名,写进 .module.css 会被 CSS Modules 哈希掉,选不中;改动一律走 TS 里的 EditorView.theme(paneTheme 与 cm-search-theme.ts)。这样写仍然吃得到调色板:面板挂在 .overlay 里,var(--gs-*) 照常解析。层叠也不用操心——EditorView 把 base theme 排在最前面挂载,同特异度下普通 theme 规则赢。

排版有个坑:@codemirror/search 用一个 <br> 分隔「查找行」和「替换行」,而 Blink 不给 flex 容器里的 <br> 生成盒子——flex-basis: 100%、width: 100%、min-width: 100% 三种写法都在跑起来的应用上试过,替换框一律留在查找行上,样式全对、只是少了一次换行。面板因此保持行内流:控件写成 inline-flex 原子,行距用每个控件的下外边距承担,面板下内边距按这个边距扣掉;scripts/verify_search_panel.py 在真实抽屉里量这套版式。库自带的那套值全是字面量(#f5f5f5 的条、linear-gradient 的按钮、1px solid silver 的输入框、#ffff0054 的命中、外加一个不指定字体族的 font-size: 70%),一个都不跟主题走,必须逐条盖掉;tests/cm-search-theme.test.ts 按名字守着这份清单。

6.19 探针按类名选元素要用「后缀匹配」,读状态前要先把鼠标挪开

CSS Modules 的类名带每次构建都变的哈希前缀(T3TXCq_file),所以 Playwright 里 只能按局部名匹配。但 [class*="file"] 太松:它同时选中 fileLi、filePath、 fileStatus、fileCountAdd,第一版 verify_vocabulary.py 因此量到了外层 <li>,报告「树行没有圆角」——而圆角就在里面那个 <button> 上。按后缀判断才准:

const local = (name) => [...document.querySelectorAll('[class]')]
    .filter(el => [...el.classList].some(c => c === name || c.endsWith('_' + name)));

第二个坑在特异度上:.file:hover 是 (0,2,0),裸修饰符 .fileActive 只有 (0,1,0),悬停规则必然赢。Playwright 点完一行,指针就停在那行上,getComputedStyle 读回来的是悬停态,于是选中态的强调色底会被读成中性的 --gs-raise。读状态前 先 page.mouse.move(4, 4)。抽屉里所有选中行都是这个行为:指针压上去时底色让位给 悬停,强调色文字、600 字重和左侧强调边仍在,选中依然读得出来。

还有一条:规则写在子元素上时要读子元素。.calWeek 的 11px 写在 .calWeek span 上,读容器拿到的是从面板继承来的 12px,看起来像漂移。

6.20 行号槽是内联 position: sticky,把它改成透明就等于让代码从行号底下穿过去

@codemirror/view 在 gutter 插件里用内联样式写死 this.dom.style.position = "sticky"(dist/index.js 约 11398 行),CSS 覆盖不掉。于是横向滚动时行号钉在面板 左缘不动,代码从底下滑过去——库自带 background: #f5f5f5 正是为了挡住这一幕, paneTheme 早先把它改成了 transparent,行号和代码就叠印在一起了(在跑起来的 应用上拍到过:pl0ügin、ull5neutral、cro3ssed)。所以 gutter 必须有自己的底色, 用面板的地色 --gs-surface——Files 的 .fbMain 和 Changes 的 .diffPane 都是它。 自己上色的格子(活动行、改动行)照旧盖在上面,跟盖在透明上没有区别。

只补底色还差一截:left: 0 把 gutter 钉在滚动容器的内容盒边缘,而面板是在 内边距盒上裁剪的,所以代码会继续从面板那 8px 左内边距里钻出来,露在行号左边。 底色因此要向左溢出:boxShadow: '-16px 0 0 0 var(--gs-surface)'。颜色就是地色、 又被面板裁掉,多溢一点不要钱。

两个连带项。一是 .cmHost[data-editable]::before 那条可编辑轨条压着 gutter 头两个 像素,而 CodeMirror 把 gutter 叠在 z-index: 200——轨条得写 z-index: 201,否则 gutter 一变不透明就把它埋了。二是探针查不了这件事:elementFromPoint 不做 box-shadow 的命中测试,行号左边那个点照样报 .cm-line,只能截图看像素,或者直接 读 getComputedStyle(...).boxShadow。scripts/verify_gutter.py 走的是后者。

6.21 探针「什么都没测到」的三种样子,都不长得像失败

探针最贵的失败不是断言变红,是它根本没走到要测的那一步,然后一路 PASS 或者报一个 和真实原因无关的超时。三条都是在实机上撞出来的:

一、会话列表默认是折叠的。 侧边栏按 workspace 分组,全新的无头上下文拿到的 dsh.workspace.view.v5 里 groupExpansion 是空对象——每个组都收着,会话行根本不在 DOM 里。page.get_by_text('会话标题') 于是永远找不到,再怎么等也没用。要先把 [class*="projectRow"][aria-expanded="false"] 一个个点开。这条会伪装成 Locator.click: Timeout 30000ms exceeded 卡在 cardBranch 上,看起来像抽屉没渲染。

二、Changes 侧不点「编辑」就没有编辑器。 未武装时 diff 右列渲染的是 <span> (DiffViews.tsx),CodeMirror 只在 layer === 'unstaged' && edit.armed 时挂载。 探针点开一个改动文件就去找 .cm-gutters,找不到是对的——但如果把这种情况写成 SKIP,那一整段就永远不会被验,而它看起来一直是绿的。要么点「编辑」把它武装起来, 要么把「未武装时不该有编辑器」写成一条真断言。

三、挑文件别用 .first。 fixture-01 的第一个改动文件是 PNG,二进制文件走的是 「无文本差异」分支:没有分栏、没有「编辑」按钮、没有编辑器。用 .first 拿到它, 报出来的是「分栏视图没有编辑按钮」——一个从来不存在的 bug。按扩展名挑一个文本文件。

还有一条量级的:把窗格「拖窄到一定要横向滚动」不能写死像素。320px 在 Files 侧 够窄,在 Changes 右列比最长的行还宽,于是 scrollLeft 停在 0,那一段测的是「没滚动 所以没有重叠」——不是「没有 bug」。按 gutter 自身宽度加一条缝算目标宽度,再 scrollLeft = scrollWidth 滚到底,重叠就一定发生在最坏处。

6.22 workspace 打开的是仓库子目录时,Changes 列得出文件、点开全是空白

git 的两种「路径」只在仓库根相等:git status --porcelain 和 git diff --numstat 无论在哪个目录运行,输出的都是仓库根相对路径(抽屉里的 path 全部来自这里); 而 pathspec、:path 版本语法、hash-object 的文件参数、ls-tree 的清单,全部相对 当前运行目录解析。会话打开的就是仓库根时两者天然一致;一旦 workspace 打开的是 子目录(如 git 根在 C:/mattermost/、workspace 开在 C:/mattermost/server),抽屉就 成了「树是对的,其余全空」:diff HEAD -- server/main.go 在 server/ 下运行会去找 server/server/main.go,匹配不到,exit 0、空输出——点开改动文件一片空白,任何错 都不报;勾选暂存报 pathspec did not match;blame 直接 fatal;ls-tree 从子目录吐出 剥掉前缀的清单,路径选择器给历史过滤喂的 pathspec 从此永远匹配不到;宿主侧 join(cwd, path) 读未跟踪文件同样拼出双前缀路径(实测 git for Windows:同一条 diff HEAD -- server/main.go,在根 11 行,在 server/ 0 行)。

修法:所有带路径的 RPC 先解析一次仓库根(rev-parse --show-toplevel,纯模块 src/repo-root.ts 的 rootedDir;不在仓库里则回落原目录,让调用方自己的 git 失败 照旧冒出来),git 与文件读全部在根上做。stats 是轮询的,这次解析并进它已有的 并行批次,墙钟零增加(status/numstat/rev-parse 本就 cwd 无关,仍跑在会话目录); commitStats 把解析放在缓存探测之后,命中不多花 spawn。刻意不缓存解析结果: 会话中途在子目录里 git init,下一次轮询就该认到新根。守卫两条: tests/repo-root.git.test.ts 把 git 侧行为逐条钉死(子目录下 pathspec 匹配不到、 ls-tree 剥前缀、hash-object 双前缀报错——git 哪天改了行为它会先叫); tests/host-rooted-paths.test.ts 源码扫描钉布线(先剥注释;断言带 path 的 @Remote 恰好十一个、每个方法体内必须出现 rootedDirOf;已做变异测试,改掉一个方法它会点名)。

6.23 「永远 modified」的 CRLF 幻影:status 列着 M、diff 永远为空

仓库字节 + core.autocrlf=true(或 eol=crlf 属性)的组合下,git 的 stat 检查与 clean 过滤对同一文件给出相反答案:git status 永远报 modified(smudge 方向认为 重新检出会不一样),git diff / --numstat 永远为空(clean 方向认为内容一致)。 实测两种形态稳定复现(LF 入库 + autocrlf=true + CRLF 工作区;v.txt eol=crlf 属性 + LF 入库 + CRLF 工作区),touch 失效 stat 缓存后反复 status 不会自愈。注意反例: CRLF 字节入库(autocrlf 翻转之前提交的)反而报干净——不是「有 CRLF 就有幻影」, 条件是「入库字节经 clean 后与工作区一致、经 smudge 后与工作区不一致」。

抽屉此前把它显示成一行无人解释的「无文本差异」——树里挂着 M、点开却什么都不说, 读起来就是显示坏了;unified 视图更糟:fileDiff 的 untracked fallback 不查 tracked 状态,会给幻影文件合成出 git 自己都看不见的「整文件新增」段。修复三层: 空 diff + 树行状态为 modified(isPhantomModified,diff-model.ts——fully-staged 文件 的 unstaged 层也空,但整文件 HEAD-diff 非空,所以判据必须用整文件段而不是当前层) → 面板解释这是行尾归一化幻影并建议统一 LF(locale phantomNotice;cr-visible 的 LF 建议守卫计数 2→3);fileDiff 合成前先过 isUntracked;Compare 的另一半是 三点语义方向——A...B 比较分叉点到 B,两端选反时整个文件树为空(RPC 的 files/numstat 全 0),此前一个字不说,现在给一行「想看另一方向请交换两端」 (compareEmptyHint)。守卫:tests/phantom-notice.test.ts(判定)、 tests/crlf-pipeline.test.ts(CRLF 行在解析与两遍着色中逐行自对齐——排查时管线已 被排除,钉住它继续被排除);活体验证 scripts/verify_crlf_display.py(本地,5 项断言: 幻影提示出现、fileDiff 空返回、真差异照常渲染、方向提示出现、仅行尾对比带 ␍ 渲染)。

6.24 side-by-side 两列必须用整文件 pass 着色;Shiki 的 token 里没有 CRLF 的 CR

并排视图的每一列本来就是整文件(git diff -U1000000 是覆盖全部的一个 hunk),所以知道块注释与模板字面量边界的整文件 pass 才是它的答案;highlightWindow 的逐行 re-lex 是给 unified diff 的重建规则——列没有被重建过,逐行冷启动重 lex 会把 JSX {/* … */} 无星号续行里的散文涂成关键字(switch、in 在句子里发亮)。两列与编辑器统一走 highlightRange(左右列缓存键分开,经 token-cache.ts 分块,新 chunk 一次调用、回滚不重算)。另一半:Shiki 按 \r?\n 切分输入,CRLF 行的 CR 不进任何 token,而渲染器从 runs 画 CR 字形且「一行的 runs 必须拼回该行」——runsOf 把丢掉的尾部补回最后一个 run。守卫 tests/side-pane-syntax.test.ts 用 AST 提取 DiffViews.tsx 的全部调用名断言 highlightWindow 不再被调用(文本扫描已被注释里的散文满足过两次)。


6.25 sticky 只对最近的滚动容器负责;CodeMirror 的面板要指 topContainer,且条只认它搜的那列

position: sticky 解析到最近的滚动容器——任何 overflow 非 visible 的元素都算(并排列的 overflow-x: auto 就算),不一定是真正滚动的那个(.sideScroll)。CodeMirror 把查找面板 .cm-panels 挂成 .cm-editor 的第一个子节点并 sticky; top: 0,于是并排面板里面板粘在列上:列和文件一样高、纵向永远不滚,面板随第 1 行滚出视野(Enter 找到下一个命中、输入框没了),面板高度还把右列压低而左列不动、行对齐破掉;Files 页没有这问题(.fbBody 既是父级也是滚动容器)。修法是库自带的 panels({ topContainer }):面板挂进 SideFindSeat(DiffFindBar.tsx),位于 .sideScroll 上方、只压工作树列——条搜的是缓冲区,横跨两列等于谎报搜索范围;行镜像 .sideCols 的几何(split 占位 + 与 .paneDivider 等宽的槽 + 宿主,css-modules.test.ts 把三处 7px 绑在一起),右侧让出 .sideScroll 的滚动条槽(use-scroll-gutter.ts 量 offsetWidth - clientWidth)。两处易漏:计数插件的 querySelector 要先查宿主再回落 view.dom,否则 3/128 消失;Ctrl/Cmd+F 只能在未武装时交给 DiffFindBar(CodeMirror 不吞武装后的键,不设卫会两个查找同时开)。守卫 tests/find-panel-host.test.ts(剥注释源扫描 + 变异验证),实机探针 scripts/verify_side_find.py(本地)。

6.26 「是不是主工作树」宿主和客户端各判一次,答案必须来自同一个东西:树的根

主工作树的判断有两处。宿主 switchBranch 先 rootedDirOf 再 isMainWorktree——子目录会话解析到根、判为主树、接受调用。客户端决定渲染与否的门若拿 mainWorktreePath(git worktree list 首行 = 仓库根)与 statsPath(会话打开的目录)比,A ≠ A/B,控件不渲染、不报错、无从发现——宿主能做的事被 UI 藏掉,比拒绝更糟(用户实报:dsh 开在子目录 B,.git 在上层 A)。规则:门比的是所看那棵树的根,stats.repoRoot(--show-toplevel;stats 为读未跟踪文件本就解析了它,返回不多花 spawn),与 mainWorktreePath 走 samePath——一个来自 worktree list、一个来自 show-toplevel,两者拼写一致是 git 契约,tests/branch-switch.git.test.ts 钉住。切源时用 rootOfWorktree 从 worktree 列表预填 repoRoot,否则占位 stats 没有根、切换器要等 stats 抓完(大仓库以秒计)才弹入;子目录不在列表里,就等 git 的答案——猜「路径本身」会把它藏回去。不要走客户端前缀匹配(「statsPath 以 mainWorktreePath 开头」):本仓库的 worktree 就建在 <root>/.agents/worktrees/ 之内,前缀法会把 linked worktree 认成主树;嵌套的另一个仓库同理——哪棵树归谁只有 git 说了算。守卫 tests/switcher-gate.test.ts(剥注释源扫描,钉门只比 stats.repoRoot、不碰 statsPath/sessionPath/stats.worktreePath,变异验证);实机探针 scripts/verify_subdir_switch.py(本地,在 fixture 的 samples/go 注册 workspace 复现并自清理)。

6.27 「会话 cwd」不是「仓库根」:这是同一个坑的第三次,凡把 cwd 当根用的地方都要过一遍

6.22(带路径的 RPC)、6.26(切换器的门)之后,同一前提又在两处露出来——dsh 的 workspace 可以开在仓库的任一子目录,而插件里凡是默认「会话 cwd = 仓库根」的地方都在那种会话里悄悄出错。其一在宿主:worktree_enter 把 worktree 建在 <root>/.agents/worktrees/<name>,返回的 hint、每轮注入的 worktree:binding 提示、工具描述却都说「相对会话 cwd 用 .agents/worktrees/<name>」——开在 <root>/server 的会话里那个目录根本不存在,文件工具照提示加前缀,文件就落到 server/.agents/worktrees/<name>/…,在主树里、未跟踪,agent 还以为自己在 worktree 里,没有任何报错。规则:前缀由 worktreeRel(cwd, worktreePath)(worktree.ts)从会话 cwd 真正解析(node:path 的 relative,两边先统一正斜杠,结果再统一;相等答 .),子目录得 ../.agents/worktrees/<name>;hint 与提示两处都用它,提示里别再断言「工作目录仍是仓库根」。守卫 tests/worktree-rel-wiring.test.ts(剥注释扫描 index.ts 两处调用点,逐点变异)。其二在客户端:面板三处回答「这是不是会话自己的树」——源选择器的当前行与 ●、切源时「选自己的树则清除覆盖而非钉住」、绑定变化时丢掉这种钉住让视图跟着 agent——比的都是原始 cwd,A/B 在 worktree 列表里谁也不是:没有行亮、点主树行变成钉住、worktree_enter 后抽屉留在原地。规则:宿主 worktreeStatus 把它本就解析了的调用方根作为 repoRoot 返回(仓库外 null,绝不 undefined——RPC 返回值必须 JSON-safe),客户端 sessionTree(cwd, repoRoot)(worktree-view.ts)在 cwd 比根深时用根、cwd 就是根时保留 cwd 自己的拼写(挂载时的 stats 抓取以这个字符串做 effect key,只换拼写会让每个会话头都多抓一次;子目录会话则在根到手时确实多抓一次——statsPath 换值触发按源重置,芯片计数闪一次 — 再回来,仅挂载时一次,statsPathRef 挡掉过期响应);三处读 sessionRoot、比较走 samePath。守卫在 tests/switcher-gate.test.ts 追加(面板三处接线 + 宿主字段,逐点变异)。排查方法:把 workspace 开到 gitworkbench-fixture/samples/go(fixture 仓库的子目录)过一遍抽屉与 agent 工具——凡是根会话正常、子目录会话不对的,都是这个坑。

6.28 首次 push 的 remote 不能写死 origin:origin 是 git clone 的习惯,不是 git 的要求

分支无 upstream 时 argv 曾写死 push --set-upstream origin <branch>,而同步条只要 git remote 有输出就显示 push 按钮:remote 改过名的 clone、手动 remote add upstream 的仓库,按钮照常出现、点下去 git 报 'origin' does not appear to be a git repository。规则:pushRemote(remotes, pushDefault)(git-ops.ts)决定去处,顺序照 git 自己对无 upstream 分支的顺序——branch.<name>.pushRemote、其次 remote.pushDefault、再 origin——再加一条 git 不猜但抽屉可以猜的:只有一个 remote 就是它;多个且无 origin 才拒绝,并把 remote 名和该设的配置键写进错误;配置指向的名字不在 remote 列表里(remote rename 后的陈旧值)同样拒绝并点名配置键,不把 git 的 does not appear to be a git repository 直接甩给用户。pushArgv(branch, remote | null):null 表示已有 upstream、仍是裸 push(尊重用户自己的 push 配置);remote 名同分支名一样过 isSafePathArg,以 - 开头的名字不能变成参数。RPC 只在无 upstream 的路径上并行问 git remote、git config --get branch.<name>.pushRemote 与 remote.pushDefault(未设时 exit 1、stdout 空,当 '' 用;分支键优先——tests/push-remote.git.test.ts 钉住)。守卫 tests/push-remote.git.test.ts 在真 git 上钉住事实:唯一 remote 叫 upstream 的仓库,旧 argv 失败、新 argv 落地且 main@{upstream} = upstream/main。

7. dsh 仓库里的关键参考文件(去哪里抄)

接手改这个插件时,对照这些原文件(路径相对 dsh 仓库根;开发机上它是本仓库的兄弟目录 ../deepseek-harness):

要做什么看哪里
抄一个完整客户端插件的套路packages/client/ui-jobs/(package.json 的 dsh.client、src/client/index.ts 的插槽注册、.module.css)
插槽 API / 组件 props 类型packages/client/ui-slots/src/index.ts(SlotMap、PropsRuntime、register)
原生 diff 组件(如果想复用)packages/client/ui-primitives/src/DiffBlock.tsx(吃 {path,oldText,newText}[],红删绿增)
主题 token 名packages/client/ui-jobs/src/client/*.module.css、ui-primitives/src/DiffBlock.module.css(--dsw-alias-*、--dsw-alias-state-success/error-primary)
客户端 bundle 格式 / 纯度门 / CSS 插件packages/client/tsdown.client.ts(本插件的 tsdown 配置就是从这里 vendored 的)
Typert 宿主发布(@Remote)packages/typert/protocol/src/index.ts(TypertRemoteService、Remote、remoteMethods);真实例子 packages/goal/goal/src/index.ts
注册 agent 工具(defineTool)packages/goal/tool-goal/src/index.ts(inject 加 'tools'、exec.agent.session 取会话、presentCall 卡片);schema 子集与 cloneJson 见 @deepseek-ai/dsh-tools / packages/core/tools
客户端 RPC 调用形态packages/client/connection/src/client/rpc.ts(connection.rpc.call(channel, endpoint, payload, signal))
/api 派发(gateway 拦截器只有一个)packages/client/connection/src/rpc-host.ts;packages/api/gateway/src/index.ts
subprocess spawn APIpackages/subprocess/subprocess/src/types.ts(SubprocessSpawnSpec、SubprocessHandle、CollectedOutput)
出树加载(dsh plugin add = pnpm 转发)apps/cli/src/plugin.ts;profile 组合 packages/boot/app-boot/src/profile.ts

8. 可继续做的事(给接力模型的点子)

  • 行号单列 / 双列可选:现在是双列(老/新)。可加开关。
  • 会话级基准:当前基准是"工作区 vs HEAD"。若要"本次会话以来的变更",需在会话开始时快照 git tree OID 并持久化,再相对它 diff(复杂度高)。
  • 更多主题族:加一族= themes.ts 加一行 + .module.css 加两块调色板,tests/theme-palettes.test.ts 会盯着两边对齐。
  • 样式作用域再细一层:现在是「项目 / 全局」两级。若要「按 worktree」再加一层,style-store.ts 的 projects 换成两级 key 即可,解析顺序在 effectiveBackground/effectiveCss 一处改。
  • 复用 DiffBlock:若不需要文件列表/行号,可直接用 @deepseek-ai/dsh-client-ui-primitives 的 DiffBlock(把统一 diff 解析成 {path,oldText,newText}[] 喂给它),更省事但定制性低。

9. 设计基准

  • 「此次变更」= 工作区相对 HEAD 的未提交改动(git diff HEAD + git status --untracked-files=all)。行数:tracked 来自 --numstat,untracked 来自宿主合成时的精确行数统计。
  • 未跟踪文件:宿主 fs.readFile 合成 diff 段(见 §6.0),单文件 >1MB 只计数不合 diff;随包总量上限 160KB,超出部分点击时走 gitWorkbench/fileDiff RPC 按需加载(tracked 用 git diff HEAD -- <path>)。
  • 二进制判定:numstat 的 - 计数,或未跟踪文件前 8KB 含 NUL 字节。二进制文件不计行数;字节嗅探为图片(8 种浏览器能画的格式)的直接显示——变更页读工作区、删除的读 HEAD;历史页读该提交、删除的读第一父提交;对比页读 head、删除的读 base(image-source.ts)——其余显示占位。
  • diff 文本总量上限 400 KB(DIFF_CHAR_CAP),超出截断。
  • 环境卡(非状态卡)常驻会话头:branch/detached + ↑↓ ahead-behind + +N −M 文件数。
  • 左侧为可折叠文件树:目录节点带文件数徽章与聚合 +N/−N;>12 文件的目录默认折叠;「展开全部/收起全部」;选中文件自动展开祖先链;展开状态会话级持久(见 §6.0c)。
  • 词级高亮 = 相邻 −/+ 行按 token LCS 对齐(diff-model.ts),行底色之上叠加强调色;语法着色 = Shiki(highlight.ts:本地包、Oniguruma WASM 引擎(内联进包、异步实例化,抽屉一打开就预热)、语法按需分包加载)——lib/client.js 2.3MB 的主因即它。bundle 纯度门禁的是 @deepseek-ai/* 的值导入(运行时由 profile 提供),不是第三方库;早期「正则单遍扫描」的实现已被替换。
  • 状态卡是会话的环境信息位:git 仓库内常驻显示分支(或 detached sha)+↑↓+计数,干净树也显示;仅 stats.error(非 git 目录 / git 不可用)时隐藏。绑定标记 = 树形图标:插件所建 worktree 的分支就是名字本身(旧绑定为 wt/<name>),徽标印名只会把分支名说两遍,所以只留图标;外部建的 worktree 徽标 = 图标+name——那是唯一点名目录的地方。
  • 面板是浮起的卡片(四边留白 + 圆角 + 投影),左缘可拖拽改宽、有最大化满屏;宽度与外观都存 localStorage,且读回时校验(旧版本写的族名不会漏到 data-gs-theme 上)。
  • 明暗默认跟随操作系统(prefers-color-scheme),可显式覆盖;主题族 7 套(GitHub / IntelliJ IDEA / VS Code / One / Solarized / Nord / Cyberpunk)各带亮暗。面板内滚动条也按当前调色板重绘——按类名逐个列举是不行的:文件树那栏改过名之后就一直漏在外面、保持系统原生的浅色滚动条,所以规则写成 .drawer *。
  • 背景图与自定义 CSS 按「项目 / 全局」两个作用域存在宿主(~/.dsh/gitworkbench-style.json),项目优先;背景图整条取项目的,自定义 CSS 两边叠加、项目在后。详见 §2.3b。
  • worktree 语义:
    • 目录 = 仓库根下 .agents/worktrees/<name>(仓库内,无沙箱越界);分支 = 名字本身,不加强制前缀。name 规则 = git ref 字符集 ∩ Windows 目录名:字母/数字开头,可用 . _ - +,最长 64;拒绝 ..、尾部点、.lock 结尾、Windows 保留名(CON/NUL 等)与 head;非法或缺省自动生成 worktree-<hex6>。
    • 退出默认保留目录,remove:true 才删;删除前 git status --porcelain 检查,脏树拒绝且绝不加 (保守,防丢改动)。
lib/client.js
背景图 / 自定义 CSS 的项目+全局存储✅对构建产物 lib/index.js 跑 styleGet/styleSet 全流程(临时 HOME,18/18 PASS):读写、项目优先、越界钳制、恶意 image 拒绝、清空删记录、非仓库拒绝、两作用域并发写不互相覆盖
Ctrl/Cmd+F 查找面板穿抽屉的控件,并报 当前 / 总数✅python scripts/verify_search_panel.py:24 项实机检查(条随调色板重绘、控件同高同圆角、命中底色非库自带、窄窗格回流、计数随 Enter 前进)
diff 窗格里的图片(变更 / 历史 / 对比)与统一 diff 的 Ctrl+F✅python scripts/verify_pane_image_find.py:18 项实机检查,scratch worktree imghist(工作区改动 / 未跟踪 / 该提交 / 父提交 / 对比 head 各一张图;Ctrl+F 开条、计数、Enter 逐个走完 22 个命中、下折时滚动、绕回、Esc 关闭并还焦点)
历史过滤框按键不再随已加载行数变贵✅python scripts/verify_history_filter_perf.py(CDP CPU profile + 帧卡顿计数):同一会话 116 行已加载、12 个按键,脚本时间 449ms → 150ms,formatCommitDate 从 profile 首位消失
抽屉视觉词汇表单一(圆角 / 字号 / 控件高度 / 悬停 / 选中)✅tests/drawer-chrome.test.ts 逐条声明扫描全表,六个变异全红;python scripts/verify_vocabulary.py 实机复核(18 个筛选控件同高、17 处小字同号、树行圆角与选中 chip)
行号槽不再把代码压在底下✅python scripts/verify_gutter.py:窗格拖窄后横向滚动 400px,63 行钻到槽下,槽有自身底色且向左溢出;可编辑轨条仍在槽之上
--force
  • 会话 cwd 不可变(dsh 本体约束):enter 不切 cwd,而是返回 hint 指引模型——file 工具用 .agents/worktrees/<name>/ 前缀的相对路径,shell 命令传 per-call workdir .agents/worktrees/<name>(相对会话 cwd 解析)。
  • 再进入:目录仍是注册 worktree(含外部工具建的、经 Junction 映射的——realpath 判定)→ 直接复用、只补绑定并保留其分支;目录已删但分支 <name> 幸存 → worktree add <dir> <name> 检出旧分支(hint 注明 reused)。
  • 绑定(per-session)持久化于 ~/.dsh/gitworkbench-worktree-bindings.json;损坏/缺失视为无绑定并重建。写入原子(tmp+rename)+ 互斥(promise 队列)+ EPERM 退避重试(§6.12)。
  • 子代理借绑定、不写键:agent/session-start 事件把子会话 header 的 parentSession 喂进宿主 parentOf 表(提示回调还会从活 header 自愈补第一跳,兜插件重载);standing 提示、芯片/抽屉、worktree_status、sessionWorktree 统一经 resolveEffectiveBinding(worktree.ts:自有绑定优先,miss 沿父链借最近绑定祖先,环检测 + 8 跳上限)解析有效绑定。只读不写——外层 worktree_exit 后子树下次读取自动失去借用(更高祖先仍绑定时向上翻转);子会话自己 worktree_enter 以自有绑定遮蔽继承。worktree_exit 对无自有绑定的会话报错并指明绑定属父会话。宿主重启后空闲会话的谱系边要等其 loop 恢复才有——查询退化为无继承(即旧版行为,fail-soft)。
  • 状态卡纪律例外:有绑定时即使 bound worktree 干净也显示状态卡——绑定标记(树形图标)是绑定指示器与面板入口;面板打开期间空视图也保持挂载(可从空源切走)。头部选择器只改显示对象,不动绑定。