DSH Web GUI 的 Git 面板插件:在会话界面的标签栏里,紧挨「对话 / 轨迹」再加一个 Git 页, 用来完成日常的查看改动、暂存、提交、看 diff、翻历史、切分支和推拉。
- 仓库:https://github.com/fengbinmov/dsh-git-panel(仓库根即 npm 包根)· npm:
@fengbinmov/dsh-git-panel - 形态:一个自包含的 DSH profile bundle —— 宿主半(
exports ".")+ 浏览器半(exports "./client") - 依赖:
dsh >= 0.1.5-rc.1、Node^22.19.0 || >=24.0.0、react ^18.2.0(peer) - 边界:所有 Git 操作都由界面触发;插件不注册任何工具、不写 system prompt,模型可见面上看不到它
┌─ 会话页 ────────────────────────────────────────────────┐
│ 对话 │ 轨迹 │ Git ← conversation.view 槽位 │
├─────────────────────────────────────────────────────────┤
│ 更改 │ 历史 ← 面板内部的两个标签页 │
│ ┌───────────────┬──────────────────────────────────┐ │
│ │ 未暂存的更改 │ │ │
│ │ 已暂存的更改 │ 预览 / 提交详情 │ │
│ ├───────────────┤ │ │
│ │ 提交信息 [提交]│ │ │
│ └───────────────┴──────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
- 两个区域:上方「未暂存的更改」、下方「已暂存的更改」。一个文件同时有两侧改动时会两边都出现
(
AM这类状态两侧各一行,各自带自己那一侧的增删行数)。 - 拖拽即操作:把行从一个区域拖到另一个区域就是暂存 / 取消暂存;拖动组标题可以整组移动。
Ctrl点击多选、Shift点击范围选择;选择始终只在一个区域内,跨区域点击会放弃旧区域的选择。 - 行内没有按钮:列表保持干净,动作全部通过拖拽和键盘完成。
- Delete 键:未暂存行 = 丢弃改动(立即执行,无二次确认)——只回退工作区到索引版本, 同一文件已暂存的那部分保留不动;已暂存行 = 取消暂存。
- 底部提交框:提交信息输入框 + 右对齐的提交按钮。切走标签页、面板被重新挂载后再回来, 已经写了一半的提交信息仍在(每个仓库各存各的;提交成功后清空,失败保留)。 界面上没有 amend 勾选(宿主侧仍保留该能力)。
- 行级操作:在左侧行号栏按下并拖动,可选中若干行,浮出操作条(暂存 / 取消暂存 / 丢弃)—— 客户端会用被选中的行重建一个补丁片段交给宿主。在代码文字上拖动则完全是浏览器原生的划词选择, 两个手势互不干扰。
- 大量改动同样不卡:两个区域各自虚拟滚动(只挂载可见的几十行,滚动条仍按整组长度换算), 拖拽、多选、键盘、分隔条行为不变。
- 左右行号 + 标记列 + 补丁体,等宽字体;更改页和历史页共用同一个渲染器,不存在两套样式。
- 二进制文件、被截断的补丁、重命名、文件头等都有明确提示,不会把二进制内容伪装成文本。
- 预览头部显示的增删行数与左侧列表行取自同一份数据,两者不可能对不上。
- 三栏:提交列表 / 该提交包含的文件 / 选中文件的补丁。分隔条可拖动、双击复位, 且与更改页的分隔条位置对齐、可以互相拖回。
- 列表覆盖整个仓库的历史:所有本地分支、远程分支和 tag,不只是当前分支的祖先链。
这是画出分叉的前提——没检出的分支有当前分支到不了的提交,只按
HEAD取历史时那些提交 根本不出现,两条并行的分支会被画成一条直线。stash不算(WIP 提交不属于任何分支)。 - 左侧是真正的 git 树:泳道图把 DAG 画出来——每行按 lane 排布,合并提交的多个父提交
各占一条泳道,分叉/合并有平滑连线,同一条分支线在整段历史里保持同一个颜色。
- 未推送提交是空心节点:宿主用一次
git rev-list HEAD --branches --tags --not --remotes就知道哪些 commit 还没有任何远端分支包含它们(包括没检出的本地分支上的提交), 列表侧据此把节点画成空心环、已推送的是实心点(悬停有文字提示)。 没有配置远端时,整段历史都算未推送——这是事实,不是猜的。 - ref 徽章与分支线同色:徽章的颜色直接取该行分支线的颜色,所以分支名和它旁边那条线 读起来是同一件东西;类型靠材质和形状区分——当前检出的分支实心、本地分支实心底描边、 远程分支虚线描边、tag 直角小签,即使分不清色相也能认出是哪一类。徽章悬停会说明它是什么。
- 提交列表本身也虚拟滚动(固定 46px 行高,只挂载视口附近的几十行), 翻很长的历史不会因为一次挂载上万行而卡顿;「加载更多」分页追加时, 已经画出来的行不会重排。
- 未推送提交是空心节点:宿主用一次
- 大提交不再卡住面板:一个提交改动上万文件时,文件列表改为虚拟滚动(只挂载可见的
几十行);宿主对这类提交跳过整篇 patch,改用一次
--raw元数据补齐每个文件的状态, 单个文件的 diff 在选中它时才按需取回。打开这类提交从「等十几秒」变成百毫秒级列表就绪。 同一个提交在后台刷新或重新选中时直接命中缓存(宿主 + 浏览器各一层),不会重复生成、 重复渲染。
- 工具栏显示当前分支、上游、ahead/behind 计数;下拉菜单切换本地分支,或在菜单里对某个分支执行
merge/rebase。 - 有未解决冲突或已有进行中的操作时,切换/合并会被拦住并给出可读原因,而不是丢一个退出码。
- 一旦检测到进行中的 merge/rebase(
.git里的操作标记文件),面板顶部会出现一条警告条, 带一个「中止」按钮(abort)。 - 推送与拉取:
fetch/pull/push。当前分支没有上游时,推送自动带--set-upstream origin <branch>。
- 事件流
/gitpanel/events(SSE):分支、HEAD 或文件摘要(一个 spawn 的 digest)变化时通知浏览器重新拉取。 - 浏览器侧跨标签页只开一条 EventSource(Web Locks 选 leader + BroadcastChannel 转发)。 Chrome 对同源 HTTP/1.1 连接池有 6 条上限且跨标签页共享,每个标签页各开一条流会把普通请求全部排队。
- 后台刷新不打扰正在读的预览:行集合没变就保留行选择与已挂载的行(未跟踪文件、被上限截断的区段这类批量数据答不了的路径也不再清空面板),只有 diff 真的变了才丢弃选择;换一个文件时预览回到顶部。
面板里每个动作都是「先在本地动,再等 Git 答复」:
- 点击/拖拽的同一帧内,用预测函数改出新的视图(行移动、行数变化、区域计数变化);
- 宿主答复到达后(通常几百毫秒)用真实结果替换;
- 失败则回滚到真实状态并给出错误提示(行级操作失败会额外保留错误通知)。
预测全是 core/types.ts 里的纯函数:predictStaged / predictDiscarded / predictCommitted /
predictLineSelection,都有单测覆盖。
插件以 profile bundle 的形式挂载:宿主半随 profile 启动,浏览器半由 client-modules
按 package.json 的 dsh.client 声明提供给 GUI。
# 需要 dsh >= 0.1.5-rc.1、Node ^22.19.0 || >=24.0.0
dsh plugin --profile web add @fengbinmov/dsh-git-panel@latest
# 重启 dsh web包内已带构建产物(lib/index.js 宿主半 + lib/client.js 浏览器半),装完不需要本地编译。
# 1) 克隆并构建(仓库根即包根)
git clone https://github.com/fengbinmov/dsh-git-panel.git
cd dsh-git-panel
pnpm install
pnpm build # tsc -b && tsdown → lib/index.js(宿主)+ lib/client.js(浏览器)
# 2) 注册进 profile(link: 指向克隆目录,改完代码重建即可生效)
dsh plugin --profile web add link:<克隆目录的绝对路径>
# 3) 重启 dsh web登记后 ~/.dsh/profiles/web/package.json 会长出两处(这是实际生效的写法):
卸载:
dsh plugin --profile web remove @fengbinmov/dsh-git-panel生效范围
| 改动位置 | 需要做什么 |
|---|---|
lib/client.js(src/client/**) |
刷新页面即可(浏览器半由 client-modules 现取现发) |
lib/index.js(src/host/**、src/core/**) |
重启 dsh web —— 路由与服务在宿主进程里 |
改了源码别忘了 pnpm build:link: 方式下 profile 直接读这个包的 lib/,不会自动编译。
克隆后先 pnpm install(Node ^22.19.0 || >=24.0.0;packageManager 固定为 pnpm 11.7.0):
pnpm typecheck # tsc -b --pretty false
pnpm test # vitest run
pnpm build # tsc -b && tsdown发版:改 package.json 的版本 → 提交 → 打 vX.Y.Z tag 推上去。GitHub Actions 会跑完整门禁,
再经 OIDC Trusted Publishing 发布到 npm(不需要 token、不需要 OTP),并附带 provenance 证明。
首次使用需在 npm 的包设置里登记一次 trusted publisher(GitHub Actions / fengbinmov / dsh-git-panel / release.yml)。

{ "dependencies": { "@fengbinmov/dsh-git-panel": "link:<克隆目录的绝对路径>" }, "dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "...", "@fengbinmov/dsh-git-panel"] } } }