npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@young1lin/dsh-ui-gitworkbench

v0.1.23

Published

Out-of-tree dsh web UI plugin: a session-header git workbench chip opening a drawer with the file tree, per-file diff, history, compare, staging, commit, and sync (fetch/pull/push).

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),统计随之切换。

这份 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 互扣(两个方向都验过会红);lib/client.js 含全部 14 套调色板 | | 背景图 / 自定义 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 行钻到槽下,槽有自身底色且向左溢出;可编辑轨条仍在槽之上 |

已知边界:状态卡挂在 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 API | packages/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 检查,脏树拒绝且绝不加 --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 干净也显示状态卡——绑定标记(树形图标)是绑定指示器与面板入口;面板打开期间空视图也保持挂载(可从空源切走)。头部选择器只改显示对象,不动绑定。