@xiaoming_z/dsh-git 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +998 -2
- package/client.js +3133 -0
- package/cordis.patch.yml +29 -0
- package/docs/composer-branch-chip.png +0 -0
- package/index.js +1320 -0
- package/package.json +44 -4
- package/test/browser/README.md +39 -0
- package/test/browser/drive.mjs +122 -0
- package/test/browser/harness.js +271 -0
- package/test/browser/index.html +41 -0
- package/test/browser/setup.mjs +119 -0
- package/test/client.test.mjs +1258 -0
- package/test/host.test.mjs +817 -0
package/README.md
CHANGED
|
@@ -1,3 +1,999 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-git — 给 DSH 的 git 面板
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
给 DSH 的 git 面板插件,交互模型参照 IntelliJ IDEA 的 git 树。**默认视图是审查**:
|
|
4
|
+
自标记点以来的净效果,按「需要留意的程度」排序;另有工作区与历史两个视图。
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
dsh-git/
|
|
8
|
+
├── index.js 宿主半:git 服务 + /git-api 只读/读写路由表
|
|
9
|
+
├── client.js 浏览器半:手写客户端 bundle(Git 视图)
|
|
10
|
+
├── cordis.patch.yml 组合层:insert 一行,装下两个半边
|
|
11
|
+
├── test/ 两个测试(见「验证」)
|
|
12
|
+
│ └── browser/ 无头 Chrome 里的气泡验证(不在 npm test 里)
|
|
13
|
+
├── package.json 单包双面:dsh.bundle.patch + dsh.client
|
|
14
|
+
└── README.md
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## 为什么是一个包
|
|
18
|
+
|
|
19
|
+
**一个包,两张脸。** 宿主逻辑和浏览器 UI 在同一个包里:
|
|
20
|
+
`exports["."]` 是宿主半,`exports["./client"]` 是浏览器半,由 `package.json` 的
|
|
21
|
+
`dsh.client` 声明被发现。组合层同样在包里——`dsh.bundle.patch` 指向
|
|
22
|
+
`cordis.patch.yml`,只 insert 一行。
|
|
23
|
+
|
|
24
|
+
这不只是图省事,而是**当前生态的形状**:`hit-feedback`、`dshmarket`、
|
|
25
|
+
marketplace 上的每一个插件都是这个结构。运行时并没有「一个插件一个包」的规则
|
|
26
|
+
(`dsh-api-remotes` 是唯一刻意的例外),但双面包单包已经成了惯例,多一层
|
|
27
|
+
`bundle/` 包反而多一次安装、多一个要维护的清单。
|
|
28
|
+
|
|
29
|
+
**Loader 那一行是必需的。** 浏览器半的发现方式是「扫描已启用的 Loader 条目,
|
|
30
|
+
找声明了 `dsh.client` 的包」——所以这个包必须是一行,否则它的浏览器半永远
|
|
31
|
+
不会被加载。
|
|
32
|
+
|
|
33
|
+
> 历史上这里是三个包(`git-host` + `client-ui-git` + `bundle`)。那是因为
|
|
34
|
+
> 0.1.x 的 `dsh plugin add` 不会把声明了 `dsh.bundle` 的包自动加进
|
|
35
|
+
> `dsh.profile.bundles`,所以需要一个专门的载体包。现在这个自动登记还在,
|
|
36
|
+
> 但单包双面已经够了。
|
|
37
|
+
|
|
38
|
+
## 关键设计决定:走 webServer 路由,不走 Typert Remote
|
|
39
|
+
|
|
40
|
+
浏览器调宿主有两条路。正统做法是 Typert Remote,但它的 `/remote` 描述符是**构建期代码生成**的产物,脱离 DSH monorepo 就产不出来。
|
|
41
|
+
|
|
42
|
+
所以这里走 `ctx.webServer.register({ kind, path, handler })`——`dsh-host-webserver` 是个不认识任何 harness 概念的通用路由表,插件注册自己的前缀正是它的预期用法(`/api`、`/plugins`、`/plugins/events` 都是这么占位的)。路由前缀是 `/git-api`,目前没人占用。
|
|
43
|
+
|
|
44
|
+
代价是没有类型化的 RPC 骨架,也没有网关自带的身份策略;收益是这个包**不需要构建步骤**就能跑。
|
|
45
|
+
|
|
46
|
+
## 安装
|
|
47
|
+
|
|
48
|
+
### 从 npm 装
|
|
49
|
+
|
|
50
|
+
已经发布在 npm 上:
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
// 通过 plugin_manager 工具
|
|
54
|
+
plugin_manager({ action: 'install_bundle', target: '@xiaoming_z/dsh-git' })
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
或者命令行:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
dsh plugin --profile <profile> add @xiaoming_z/dsh-git
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 桌面端(正在跑着也能装)
|
|
64
|
+
|
|
65
|
+
用插件管理器装,**不需要退出应用**——它是进程内跑的,装完立刻生效:
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
// 通过 plugin_manager 工具
|
|
69
|
+
plugin_manager({ action: 'install_bundle', target: '/Users/xiaoming/Projects/dsh/dsh-git' })
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
UI 上对应的是「设置 → 插件 → 安装」,同一条路径。
|
|
73
|
+
|
|
74
|
+
它会做三件事:把包链进 profile、把它加进 `dsh.profile.bundles`、然后
|
|
75
|
+
**热重载**——宿主半当场挂上,浏览器半当场进 boot graph,**页面都不用刷新**。
|
|
76
|
+
|
|
77
|
+
### 命令行(本地开发)
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
dsh plugin --profile <profile> add link:/Users/xiaoming/Projects/dsh/dsh-git
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
然后重启 `dsh web`。
|
|
84
|
+
|
|
85
|
+
要点:
|
|
86
|
+
|
|
87
|
+
- **用绝对路径的 `link:`**。`dsh plugin add` 会把本地路径锚定到你**当前 shell 的 cwd**,不是 profile 目录——所以相对路径 `./dsh-git` 只有在你正好站在那个目录里才指得对。`link:` 加绝对路径没有这个歧义。
|
|
88
|
+
- 这个包声明了 `dsh.bundle.patch`,所以安装会**自动**把它加进 `dsh.profile.bundles`。不需要手动改 profile 的 `package.json`。
|
|
89
|
+
- **`dsh plugin --profile desktop …` 会被拒**(`profile "desktop" is managed exclusively by the Electron application`)。CLI 里的 `manageDesktopProfile` 开关是**桌面端自己**用的,不是给手敲命令的。所以桌面上要装,走上面那条插件管理器路径。
|
|
90
|
+
- 面板出现的位置是会话的**视图区**(与 Trajectory 并列的一个视图),因为我们注册进的是 `conversation.view`——这是 `dsh-client-ui-conversation` 声明、`dsh-client-ui-trajectory` 实际使用的那个扩展点。视图标签叫 `Git`。
|
|
91
|
+
- **包名和客户端 bundle 的 id 是同一个字符串**(`@xiaoming_z/dsh-git`)。装的时候用哪个名字,Loader 那一行就是哪个名字,bundle 也按那个名字注册——所以这三处必须一起改名。
|
|
92
|
+
|
|
93
|
+
改完 `client.js` 或 `index.js` 之后,热重载会把改动的模块换掉。DSH 的 HMR 服务是
|
|
94
|
+
**两边都有**的(宿主侧 `dsh-hmr`,浏览器侧 `dsh-client-hmr`),桌面端和
|
|
95
|
+
`dsh web` 都一样。改完没反应时,重启应用最保险。
|
|
96
|
+
|
|
97
|
+
## 配置
|
|
98
|
+
|
|
99
|
+
那一行不带 `config` 时,宿主半用默认值:只允许检查 `dsh web` 的启动目录,其他路径一律 400。要放宽就在 `cordis.patch.yml` 里给 `git` 那行加 config(文件里有注释掉的样例):
|
|
100
|
+
|
|
101
|
+
| 字段 | 默认 | 含义 |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| `allowedRoots` | `[process.cwd()]` | 额外允许的路径前缀;`["*"]` 关闭检查。**harness 自己注册过的工作区总是被信任**,所以跟随会话不依赖这一项。|
|
|
104
|
+
| `defaultRoot` | `process.cwd()` | 请求省略 `root` 时用的仓库路径 |
|
|
105
|
+
|
|
106
|
+
`allowedRoots` 不是可选装饰:webserver 只绑定回环、自身不做任何认证,一个能对任意路径执行 git 的路由等于本地信息泄露面。
|
|
107
|
+
|
|
108
|
+
## HTTP 接口
|
|
109
|
+
|
|
110
|
+
响应统一为 `{ ok: true, data }` 或 `{ ok: false, error }`。读是 `GET`,写是 `POST`。
|
|
111
|
+
|
|
112
|
+
| 路由 | 说明 |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `GET /git-api/health` | 确认宿主半已挂载(`writes: true` 表示支持写操作) |
|
|
115
|
+
| `GET /git-api/status?root=` | porcelain v2 解析后的分支头 + staged / unstaged / untracked / conflicted |
|
|
116
|
+
| `GET /git-api/log?root=&limit=&ref=` | 提交,newest-first,`--topo-order`(保证父提交不会晚于子提交出现,lane 分配要用)。`ref` 可指定分支/引用;以 `-` 开头的会被拒绝并回退到 HEAD,不会进入 git 的选项空间 |
|
|
117
|
+
| `GET /git-api/diff?root=&file=&staged=&untracked=&since=` | 工作区/索引的统一 diff 原文,不缓存。`untracked=1` 时用 `git diff --no-index -- /dev/null <file>` 合成一份「整文件新增」的 diff;`since=<hash>` 时对比标记点。路径被限制在仓库内,超大文件会截断 |
|
|
118
|
+
| `GET /git-api/branches?root=` | `{local, remote, head}`,供分支树使用。remote 按 remote 名分组;符号引用 `origin/HEAD` 会被剔除 |
|
|
119
|
+
| `GET /git-api/review?root=&since=` | 自标记点以来的一切改动(**比较工作区**,含未提交):提交列表、每文件 `+/−`、状态、总计。标记解析不到时明确报错 |
|
|
120
|
+
| `GET /git-api/commit?root=&hash=` | 单个提交的头部 + 它改动的文件(含状态与重命名原路径) |
|
|
121
|
+
| `GET /git-api/show?root=&hash=&file=` | 某个提交的补丁,可限定单个路径 |
|
|
122
|
+
| `POST /git-api/stage` | `{root, files[]}` → `git add -A --` |
|
|
123
|
+
| `POST /git-api/unstage` | `{root, files[]}` → `git reset -q HEAD --`(用 reset 而非 `restore --staged`,因为后者拒绝把新加入索引的文件移出) |
|
|
124
|
+
| `POST /git-api/hunks` | `{root, file, hunks[], staged}` → 只暂存/取消选中那几个改动块,走 `git apply --cached [--reverse]` |
|
|
125
|
+
| `POST /git-api/checkout` | `{root, ref, create?}` → 检出 `ref`;给了 `create` 就先建分支再切过去 |
|
|
126
|
+
| `POST /git-api/pull` | `{root, strategy?}` → `git pull`。`strategy` ∈ `ff-only`(默认)/ `merge` / `rebase`,返回 `{changed, before, after, output}` |
|
|
127
|
+
| `POST /git-api/push` | `{root, setUpstream?}` → `git push`;`setUpstream` 时先解析当前分支并 `--set-upstream <remote> <branch>`,返回 `{branch, remote, published, changed, before, after, output}` |
|
|
128
|
+
| `POST /git-api/commit` | `{root, message}` → `git commit -m`,返回新提交短哈希 |
|
|
129
|
+
|
|
130
|
+
### 写操作的防护
|
|
131
|
+
|
|
132
|
+
`/git-api` 刻意在 `/api` 的浏览器信任围栏**之外**(这正是插件能自己拥有一个前缀的前提),而 webserver 只绑回环、不做认证。两者相加意味着一个写端点会被用户打开的任意页面访问到。
|
|
133
|
+
|
|
134
|
+
因此每个写请求都必须带 `x-dsh-git: 1` 头。跨源的表单或 `fetch` 无法在不触发 CORS 预检的情况下设置自定义头,而本服务器不应答任何预检、也不发 CORS 头——浏览器会在请求发出前拦掉。没有这个头一律 400。
|
|
135
|
+
|
|
136
|
+
### ref 与分支名的校验
|
|
137
|
+
|
|
138
|
+
`ref` 和分支名会被拼进 git 的 argv,所以**以 `-` 开头的值一律拒绝**——否则 `--force` 会被读成选项,而不是一个叫 `-force` 的分支。读接口(`/log?ref=`)容忍坏 ref 并退回 HEAD;**写接口必须响亮地拒绝**,因为一次误读就是一次误操作。
|
|
139
|
+
|
|
140
|
+
分支名本身交给 git 判:`git check-ref-format --branch <name>`。规则长、跨版本还变过,git 才是权威;它非零退出时原始 stderr 就是给用户的报错。
|
|
141
|
+
|
|
142
|
+
## 审查视图:面板的主视图
|
|
143
|
+
|
|
144
|
+
**在一个 agent harness 里,操作类的 git 功能是低价值的那一半。** Agent 有 bash,`checkout` / `pull` / `push` / `commit` 它都能做,而且比面板的按钮更灵活——「pull 一下然后 rebase 到 main」这种组合,按钮永远追不上。
|
|
145
|
+
|
|
146
|
+
真正结构上做不到的是另一件事:**知道你上次看到哪**。Agent 没有这个状态;而且你没法问它「你改了什么我该担心的」,然后相信那个回答——独立视图的意义正在于此。
|
|
147
|
+
|
|
148
|
+
所以面板的默认视图是审查,而不是工作区。
|
|
149
|
+
|
|
150
|
+
### 它显示什么
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
自 2 小时前以来:29 个提交、23 个文件、+2985 −710(含未提交改动)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
下面按**需要留意的程度**排序,不按路径:冲突 → 删除 → 二进制 → 大改动(≥200 行)→ 其余按改动量。每行标出命中的原因和 `+/−` 行数,点进去看 diff。
|
|
157
|
+
|
|
158
|
+
排序的理由是**注意力是稀缺资源**:冲突和删除会直接弄坏东西,二进制根本无法略读,大改动是错误最容易藏身的地方。真实仓库上的实测排序:
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
tier3 大改动 +1090 −530 PathService.java
|
|
162
|
+
tier3 大改动 +805 −0 PathServiceConnectSegmentsTest.java
|
|
163
|
+
tier3 大改动 +670 −0 PathServiceConnectSegmentsRoutedTest.java
|
|
164
|
+
tier3 大改动 +210 −170 TopologyService.java
|
|
165
|
+
tier4 — +23 −8 TopologyController.java
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### 标记的语义
|
|
169
|
+
|
|
170
|
+
标记是**一个提交哈希**,比较对象是**工作区**而不是 HEAD:
|
|
171
|
+
|
|
172
|
+
```sh
|
|
173
|
+
git diff <marker> # 含已提交与未提交的全部变化
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
所以 agent 没提交的改动不会被漏掉。代价是标记只跟踪**提交**——未提交的改动会一直在列表里,直到它被提交。这是诚实的:它还没被记录到任何地方。
|
|
177
|
+
|
|
178
|
+
标记按**规范化的仓库路径**存(宿主回显的 realpath),所以同一个仓库不管从哪个路径打开都共用一个标记。存储是 `localStorage`,和列宽一样按不可信输入校验。
|
|
179
|
+
|
|
180
|
+
标记被 rebase 删掉时 `git rev-parse --verify` 会失败,面板明确报「审查点已失效」,而不是显示一个看似空白的审查结果。
|
|
181
|
+
|
|
182
|
+
## 把某个改动交给模型
|
|
183
|
+
|
|
184
|
+
面板自己不提交任何东西。它做两件事,都建立在**外壳已有的扩展点**上。
|
|
185
|
+
|
|
186
|
+
### `@` 来源
|
|
187
|
+
|
|
188
|
+
`ctx.inputTriggers.registerSource()` 是插件把内容送到模型面前的正规接口(第一方的 `ui-reference` 就是这么做的)。本插件注册一个 `@git` 来源,候选是当前会话仓库的**改动文件**与**最近的提交**——后者是文件来源给不了的。
|
|
189
|
+
|
|
190
|
+
引用在**提交时**由这个来源自己的 `codec.serialize` 决定模型看到什么:
|
|
191
|
+
|
|
192
|
+
```xml
|
|
193
|
+
<git-change path="src/foo.ts" status="M" added="12" deleted="3">Read the file, or run `git diff -- src/foo.ts`, to see the change.</git-change>
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**只发元信息,不发 diff 正文。** 理由:把整个 diff 内联会消耗本该留给模型自己抓取的 context,而一条消息里可以合法地带好几个这样的引用。
|
|
197
|
+
|
|
198
|
+
### 但这不等于"模型知道变了什么"
|
|
199
|
+
|
|
200
|
+
要说清楚它的能力边界:上面的引用告诉模型**哪里变了、变了多少、怎么去看**,**不告诉它变的是什么**。模型必须花一次工具调用才能真正看到改动。
|
|
201
|
+
|
|
202
|
+
它买到的是**分诊**,不是内容:
|
|
203
|
+
|
|
204
|
+
- 一条消息里带五个引用时,`added="900"` 和 `added="5"` 让模型能决定先看哪个——裸路径给不出任何排序依据
|
|
205
|
+
- 显式给出命令,杜绝了"该用 read 还是该用 git"的猜测:对**已删除**的文件 `read` 根本读不到
|
|
206
|
+
- `status="D"` 是模型无法从路径推断的警告——一个不存在的路径看起来更像出错
|
|
207
|
+
|
|
208
|
+
代价就是:如果消息里只有引用、没有问题,模型必须先抓取才能做任何有用的事。所以这是一个**取舍**,不是纯赢——想省掉那次调用,就得内联 diff,代价是 context 和"五个引用一起发"时的成本。
|
|
209
|
+
|
|
210
|
+
**元信息被编进引用串本身**,而不是在发送时去查。这不是省事,是两条硬约束:
|
|
211
|
+
|
|
212
|
+
1. `serialize(ref, signal)` **拿不到 session**,无法解析仓库根;
|
|
213
|
+
2. 需要发网络请求的 serialize 就是**会失败的** serialize——而文档写明失败会**阻断发送**,不会静默降级。
|
|
214
|
+
|
|
215
|
+
把数字烧进引用还带来一个好处:模型看到的就是你选它时面板上显示的那个数字。
|
|
216
|
+
|
|
217
|
+
### 为什么不用 `/` 命令
|
|
218
|
+
|
|
219
|
+
`ctx.commandUi` 的 `action` 类型看着更直接,但 `ActionSpec` 的文档写死了:
|
|
220
|
+
|
|
221
|
+
> runs one client-side callback… **It submits nothing**
|
|
222
|
+
|
|
223
|
+
`popupSelect` 同样只是客户端回调。所以在客户端命令这条路上没有办法把内容交给模型。
|
|
224
|
+
|
|
225
|
+
### 为什么面板里没有「交给模型」按钮
|
|
226
|
+
|
|
227
|
+
那需要 `inputTriggers.sessionOf(actx)` 拿到会话级输入门面。检查了整个客户端树:**它只被调用一次,在 `ui-input-trigger` 自己内部**——没有任何第一方包从外部使用它。所以这条路是公开但未经证明的,我不做一个可能静默失效的按钮,而是给一个确定能用的:
|
|
228
|
+
|
|
229
|
+
**面板里每个改动/提交行带一个「复制引用」**,把上面那段现成文本放进剪贴板,你粘进输入框、看一眼、再决定发不发。写入剪贴板完全在客户端,不需要任何未经证明的 API。
|
|
230
|
+
|
|
231
|
+
## 颜色必须是主题 token,不能是字面量
|
|
232
|
+
|
|
233
|
+
浅色模式下 diff 的增删内容几乎看不见——因为第一版用的是**给深色底挑的粉彩**(`#a6e3a1` 浅绿、`#f38ba8` 浅粉),在白底上对比度接近零。
|
|
234
|
+
|
|
235
|
+
换个更深的字面量只是把问题推到深色模式。正确做法是用外壳的**语义别名**,它们本身就是 `--dsw-static-*` 的别名,而 static 值随 `[data-ds-dark-theme]` 切换:
|
|
236
|
+
|
|
237
|
+
| 用途 | token |
|
|
238
|
+
|---|---|
|
|
239
|
+
| 新增行 | `--dsw-alias-state-success-primary` |
|
|
240
|
+
| 删除行 | `--dsw-alias-state-error-primary` |
|
|
241
|
+
| 冲突 | `--dsw-alias-state-error-primary` |
|
|
242
|
+
| 修改 / 类型变更 | `--dsw-alias-state-warn-primary` |
|
|
243
|
+
| 重命名 / 复制 / hunk 头 | `--dsw-alias-state-business-primary` |
|
|
244
|
+
| 未跟踪 / 元信息 | `--dsw-alias-label-tertiary` |
|
|
245
|
+
| 选中行 | `--dsw-alias-interactive-bg-active` |
|
|
246
|
+
| 边框 | `--dsw-alias-border-l2` |
|
|
247
|
+
|
|
248
|
+
行背景用 `color-mix(in srgb, <token> 12%, transparent)` 而不是固定的 `rgba`——同一 token 的淡色,两套主题下都成立。`color-mix` 不是我发明的,外壳自己的样式表就在用。
|
|
249
|
+
|
|
250
|
+
### 顺带发现三个 token 名是我编的
|
|
251
|
+
|
|
252
|
+
`--dsw-alias-border-secondary`、`--dsw-alias-bg-accent`、`--dsw-alias-label-inverse` **在主题包里根本不存在**——它们一直在静默走我的硬编码兜底,所以那些地方从未跟过主题。真实名字是 `--dsw-alias-border-l2`、`--dsw-alias-button-primary-fill`、`--dsw-alias-label-primary-inverted`。
|
|
253
|
+
|
|
254
|
+
### 提交图的 lane 配色
|
|
255
|
+
|
|
256
|
+
lane 需要一个多色调色板,没有现成的 token 集合。改用语义 token 循环(蓝 / 绿 / 琥珀 / 红 / 中性 / …)。前四色区分明显,其余只在很宽的图上才会用到,而 lane 算法已经把宽度压到 4 左右。圆点辉光也从拼接 `${color}33` 改成 `color-mix`——**往 `var(...)` 字符串后面接十六进制 alpha 是语法错误,不是颜色**。
|
|
257
|
+
|
|
258
|
+
### 防回归
|
|
259
|
+
|
|
260
|
+
一条结构性断言:把渲染出的整棵树序列化,**断言里面没有任何裸 hex 颜色**。工作区、历史、diff 三个视图各查一遍。以后谁再图省事写一个字面量,这条会红。
|
|
261
|
+
|
|
262
|
+
## 显示优化:被截断的名字,悬浮出气泡
|
|
263
|
+
|
|
264
|
+
面板里到处是省略号:文件名、分支名、提交标题。列是可以拖宽的,但拖宽解决不了
|
|
265
|
+
「你的路径就是比列长」。所以**被截掉的那截必须能拿回来**。
|
|
266
|
+
|
|
267
|
+
原生 `title` 不够用:
|
|
268
|
+
|
|
269
|
+
| 原生 `title` 的问题 | 气泡的做法 |
|
|
270
|
+
|---|---|
|
|
271
|
+
| 要等一秒多才出现,鼠标一动就消失 | 300ms 出现,鼠标移开才消失 |
|
|
272
|
+
| 样式是系统的,浅色深色都不跟主题 | 用外壳自己的 tooltip token |
|
|
273
|
+
| 一次只能给一段文字 | 可以给「原路径 → 新路径」两行 |
|
|
274
|
+
| **提交标题根本没有 `title`** | 标题、哈希、分支名都有 |
|
|
275
|
+
|
|
276
|
+
### 只在真的被截断时才出现
|
|
277
|
+
|
|
278
|
+
这是气泡唯一重要的规则。判断依据是布局事实,不是猜测:
|
|
279
|
+
|
|
280
|
+
```js
|
|
281
|
+
element.scrollWidth > element.clientWidth + 1
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`scrollWidth` 是文字**想要**的宽度,`clientWidth` 是它**拿到**的宽度。多出来的那
|
|
285
|
+
一个像素是给两者各自取整用的。
|
|
286
|
+
|
|
287
|
+
**放得下的名字不产生气泡。** 一个把屏幕上已有的字再念一遍的气泡是噪音,而读者
|
|
288
|
+
的手指正往下一个地方去。
|
|
289
|
+
|
|
290
|
+
判定在**延迟结束的那一刻**再做一次:手指停下的这 300ms 里,列可能被拖宽了,
|
|
291
|
+
那时候文字已经放得下了,气泡就不该出现。
|
|
292
|
+
|
|
293
|
+
### 气泡挂在 document.body 上,不在面板里
|
|
294
|
+
|
|
295
|
+
面板每一栏都是 `overflow: auto` 的滚动盒,外框还是 `overflow: hidden`。**树里的
|
|
296
|
+
气泡会被自己所在的面板裁掉**——正好是它最不该被裁的地方。
|
|
297
|
+
|
|
298
|
+
所以气泡是 `document.body` 的一个子元素,`position: fixed`。代价是它不在面板的
|
|
299
|
+
堆叠上下文里,所以层级要自己给:外壳自己的 tooltip 用的是 `z-index: 1100`,这里
|
|
300
|
+
用同一个数。
|
|
301
|
+
|
|
302
|
+
顺带一个好处:气泡不需要跟着面板的滚动动,它只按视口坐标摆放。
|
|
303
|
+
|
|
304
|
+
### 定位规则
|
|
305
|
+
|
|
306
|
+
- 默认在标签**下方**;下方放不下就翻到**上方**;
|
|
307
|
+
- 比标签**宽**的时候对齐标签的**起始边**,不是居中——居中会把一半推出窗口,气泡
|
|
308
|
+
就指着空气了;
|
|
309
|
+
- 比标签**窄**的时候居中;
|
|
310
|
+
- 任何情况下都不越过窗口边缘。
|
|
311
|
+
|
|
312
|
+
### 路径在斜杠处换行
|
|
313
|
+
|
|
314
|
+
路径里没有空格,浏览器只会 wherever 断行——经常断在文件名的中间。每个斜杠后面
|
|
315
|
+
插一个 `<wbr>`,它是一个**换行机会**、本身不带任何字符,所以气泡显示的还是那条
|
|
316
|
+
路径,复制出来的也还是那条路径,但断行会落在目录边界上:
|
|
317
|
+
|
|
318
|
+
```
|
|
319
|
+
src/main/java/com/example/account/service/impl/
|
|
320
|
+
AccountServiceImplementation.java
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### 行里没有的信息,也在气泡里
|
|
324
|
+
|
|
325
|
+
有两处信息**行里根本没有**,所以它们的气泡不按「被截断才出现」来:
|
|
326
|
+
|
|
327
|
+
| 气泡 | 内容 | 为什么总是出现 |
|
|
328
|
+
|---|---|---|
|
|
329
|
+
| 分支名 | `名字` + `→ 上游` | 上游不在行的任何地方 |
|
|
330
|
+
| 提交哈希 | 完整哈希 + 作者 | 行里只有 7 位哈希,作者根本没出现 |
|
|
331
|
+
|
|
332
|
+
这两处是**替换**掉原来行上的原生 `title` 的——信息一点没少,只是换了个更好读的
|
|
333
|
+
地方放着。
|
|
334
|
+
|
|
335
|
+
### 颜色还是主题 token
|
|
336
|
+
|
|
337
|
+
气泡用的是外壳自己的 tooltip token(`--dsw-alias-tooltip-bg`、
|
|
338
|
+
`--dsw-static-neutral-bluish-00`、`--dsw-radius-sm`),和外壳自己画的每一个
|
|
339
|
+
tooltip 一模一样,所以在两套主题里都不违和。这些 token 定义在 `body` 上,而气泡
|
|
340
|
+
正是 `body` 的子元素——**继承得到**。
|
|
341
|
+
|
|
342
|
+
### 验证
|
|
343
|
+
|
|
344
|
+
两层:
|
|
345
|
+
|
|
346
|
+
- `test/client.test.mjs` 里断言摆放算术、截断判定、气泡的生命周期,以及每个行
|
|
347
|
+
都带上了 reveal 的处理器、**不再**依赖原生 `title`;
|
|
348
|
+
- `test/browser/` 里有台子,把 client bundle 装进**无头 Chrome**(连主题 CSS 都
|
|
349
|
+
是从装好的外壳里抽出来的),派发真的 pointer 事件,量真实的 `scrollWidth`,
|
|
350
|
+
看气泡是不是真的没被裁掉、是不是真的盖得住面板。
|
|
351
|
+
|
|
352
|
+
第二层不是可有可无的:气泡的三件事——**到底截没截**、**会不会被裁**、**盖不盖得住**
|
|
353
|
+
——只有真浏览器说了算。
|
|
354
|
+
|
|
355
|
+
## 输入框里的当前分支
|
|
356
|
+
|
|
357
|
+
输入框底部那一行(权限选择器、模型选择器那一行)上,**当前工作区的分支名**以一个
|
|
358
|
+
小标签的形式出现:分支图标 + 分支名。
|
|
359
|
+
|
|
360
|
+

|
|
361
|
+
|
|
362
|
+
**只展示,不切换。** 切换分支是面板的活;输入框是写字的地方,不是改仓库的地方。
|
|
363
|
+
两个地方都能切,就是两个地方都可能切错。
|
|
364
|
+
|
|
365
|
+
**非 git 目录不展示。** 没有分支就没有标签,而且**失败是沉默**:输入框不是抱怨
|
|
366
|
+
仓库的地方。(detached HEAD 同理——没有分支名,宿主把 `head` 留空,标签也就不出现。)
|
|
367
|
+
|
|
368
|
+
### 它挂在哪儿
|
|
369
|
+
|
|
370
|
+
`conversation.input.left` —— 外壳给输入框左侧那一排控件留的**列表**槽位,第一方
|
|
371
|
+
外壳里它是空的。列表意味着可以有多个占用者,按 `order` 排。
|
|
372
|
+
|
|
373
|
+
```
|
|
374
|
+
conversation.composer.bar
|
|
375
|
+
├── conversation.input.attachments (+ 号、附件)
|
|
376
|
+
├── conversation.input.permission (默认权限)
|
|
377
|
+
├── conversation.input.plan
|
|
378
|
+
├── conversation.input.left ← 这里:分支标签
|
|
379
|
+
├── conversation.input.model (模型选择器)
|
|
380
|
+
└── conversation.input.activity
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
芯片报的是**会话的工作区**(会话摘要里的 `cwd`),不是面板里手填的那个路径——面板
|
|
384
|
+
可以临时指向别的仓库,而输入框说的是「你在哪」。
|
|
385
|
+
|
|
386
|
+
### 读 `/branches`,不读 `/status`
|
|
387
|
+
|
|
388
|
+
芯片要的是分支名,而 `/status` 会扫整个工作区(`--untracked-files=all`)。每开一个
|
|
389
|
+
会话就为一行字扫一次工作区,太贵了。`/branches` 只列引用(`git branch --all`),
|
|
390
|
+
`head` 就是当前分支。
|
|
391
|
+
|
|
392
|
+
### 每 5 秒读一次
|
|
393
|
+
|
|
394
|
+
分支会在你脚下变:agent 在终端里 checkout 一个分支,或者别的脚本切了一个。**只读
|
|
395
|
+
一次**的标签会一直说谎。
|
|
396
|
+
|
|
397
|
+
轮询本身很便宜:宿主缓存读结果,`.git` 一变就把缓存丢掉(面板自己写完也是立刻失效
|
|
398
|
+
缓存),所以在什么都没发生的时候,轮询命中的是缓存。窗口重新获得焦点时也立刻读
|
|
399
|
+
一次——用户通常就是在这时候从终端切回来的。
|
|
400
|
+
|
|
401
|
+
### 什么时候会重新查
|
|
402
|
+
|
|
403
|
+
| 触发 | 什么时候 |
|
|
404
|
+
|---|---|
|
|
405
|
+
| 芯片挂载 | 会话打开、输入框渲染出芯片时,立刻读一次 |
|
|
406
|
+
| 轮询 | 每 5 秒 |
|
|
407
|
+
| 窗口重新获得焦点 | 立刻(从终端切回来的时候) |
|
|
408
|
+
| 会话的工作区变了 | 立刻(依赖变了,effect 重跑) |
|
|
409
|
+
|
|
410
|
+
读到的是**宿主缓存**里的东西。缓存什么时候丢:
|
|
411
|
+
|
|
412
|
+
- `.git` watcher 触发 → 防抖 120ms → 丢缓存;
|
|
413
|
+
- 面板自己写了东西(checkout、commit)→ **立刻**丢缓存。
|
|
414
|
+
|
|
415
|
+
所以从切分支到标签变,**端到端 0.1~5.1 秒**:watcher 触发(几毫秒)+ 防抖 120ms
|
|
416
|
+
+ 最多等一个轮询(5 秒)。窗口一直有焦点、你在终端切完分支再点回应用的话,focus
|
|
417
|
+
事件会立刻读一次,那就是 **0.12 秒**。
|
|
418
|
+
|
|
419
|
+
面板里的「检出」按钮**不会**直接通知芯片——芯片不是面板的一部分,它不知道面板写了
|
|
420
|
+
什么。但面板的写会让宿主立刻丢缓存,所以芯片下一次轮询(≤5 秒)读到的是新的。
|
|
421
|
+
|
|
422
|
+
### 连带修掉的一个 bug:链接工作树
|
|
423
|
+
|
|
424
|
+
`git worktree add` 出来的目录里,`.git` 是一个**文件**(一行 `gitdir: …` 指针),
|
|
425
|
+
不是目录。原来的 watcher 盯着那个文件——而它**永远不变**:工作树里的 checkout 改的是
|
|
426
|
+
`<主仓库>/.git/worktrees/<名字>/HEAD`,在别的地方。
|
|
427
|
+
|
|
428
|
+
于是缓存永远不过期,**分支标签会一直显示旧分支**(面板的分支树也一样)。而桌面端的
|
|
429
|
+
profile 里装着 `dsh-experimental-tool-worktree`——链接工作树在这个外壳里是常事。
|
|
430
|
+
|
|
431
|
+
修法是问 git 自己它的 git 目录在哪儿:
|
|
432
|
+
|
|
433
|
+
```js
|
|
434
|
+
git rev-parse --absolute-git-dir
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
普通仓库、链接工作树、submodule、`--separate-git-dir`,它都答得对。拿那个目录去
|
|
438
|
+
watch,HEAD 一改就触发。
|
|
439
|
+
|
|
440
|
+
### 图标是内联 SVG
|
|
441
|
+
|
|
442
|
+
外壳的图标包是**打进**第一方客户端里的(`@deepseek-ai/dsh-client-ui-primitives`
|
|
443
|
+
不是插件能 `require` 的模块)。要图标就得自己画路径,否则就得让整个面板依赖一个可能
|
|
444
|
+
不在的包。`currentColor` 让它跟着芯片的颜色走,也就跟着主题走。
|
|
445
|
+
|
|
446
|
+
### 排版跟外壳自己的控件对齐
|
|
447
|
+
|
|
448
|
+
13px 字重 500、行高 20px、`--dsw-alias-label-secondary` —— 和外壳输入框里的
|
|
449
|
+
权限选择器(`.select`)一模一样,所以它是那一行的一部分,而不是掉进去的一枚徽章。
|
|
450
|
+
|
|
451
|
+
名字超过 220px 就省略号截断,**气泡**补上剩下的(就是上一节那套)。
|
|
452
|
+
|
|
453
|
+
## 语言:不自己造配置,用外壳的
|
|
454
|
+
|
|
455
|
+
面板的文案全部走 `ctx.locale`,**语言选择归外壳所有**:设置 → 常规里选 English / 中文,loopback 页面把选择持久化到 `$DSH_HOME/settings.yaml`;全新浏览器用 `navigator` 请求的语言里第一个受支持的,匹配不上回退英文。
|
|
456
|
+
|
|
457
|
+
这三点合起来就是「跟随系统 / 英文 / 中文」,所以这里**不需要**再加一份插件级配置——那是同一种设置的第二个真相来源。
|
|
458
|
+
|
|
459
|
+
```js
|
|
460
|
+
ctx.effect(() => ctx.locale.register(NS, DICTS), 'ui-git: dictionaries')
|
|
461
|
+
const t = ctx.locale.bind(NS) // 调用时读当前语言
|
|
462
|
+
// 通过槽位的 inject 把 t 交给组件
|
|
463
|
+
inject: (sessionId) => ({ sessionRoot: sessionCwd(ctx, sessionId), t })
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
`t` 在调用时读当前 locale,所以切换语言只需要一次重渲染(槽位渲染器会重渲染每个 outlet),不需要重新注册。组件在拿不到注入的 `t` 时(裸渲染、槽位注入失败)退回英文词典,**不会渲染出原始 key**。
|
|
467
|
+
|
|
468
|
+
### 英文用 git 的术语,不音译
|
|
469
|
+
|
|
470
|
+
| key | 中文 | English |
|
|
471
|
+
|---|---|---|
|
|
472
|
+
| `view.worktree` / `view.history` | 工作区 / 历史 | Changes / History |
|
|
473
|
+
| `branch.checkout` | 检出 | Checkout |
|
|
474
|
+
| `pull.ff-only` / `pull.merge` / `pull.rebase` | 快进 / 合并 / 变基 | Fast-forward / Merge / Rebase |
|
|
475
|
+
| `file.stage` / `file.unstage` | 暂存 / 取消 | Stage / Unstage |
|
|
476
|
+
| `hunk.stage` | 暂存此块 | Stage hunk |
|
|
477
|
+
|
|
478
|
+
### 测试怎么保证不漏
|
|
479
|
+
|
|
480
|
+
- **两边键集必须完全相同**(`zh` 与 `en` 逐键比对),少一个就失败
|
|
481
|
+
- **英文里不许出现中文**:把英文渲染出的整棵树序列化,断言没有 CJK 字符
|
|
482
|
+
- **中文渲染必须带中文**:反向断言,防止哪天不小心全量退回英文
|
|
483
|
+
- **没有哪个英文值是它自己的 key**:防止漏填的键静默显示成 `pull.button`
|
|
484
|
+
|
|
485
|
+
### 宿主消息是英文
|
|
486
|
+
|
|
487
|
+
宿主的报错(ref 校验、hunk 越界、文件越界等)用英文,和 git 自己的诊断保持一致——git 的输出本来就是英文(或系统 locale 下本地化),中英混杂比统一英文更难读。客户端的界面文案才做双语。
|
|
488
|
+
|
|
489
|
+
## push:两个它独有的坑
|
|
490
|
+
|
|
491
|
+
`pull` 的防护(网络超时、`GIT_TERMINAL_PROMPT=0`、策略枚举)原样复用,但 push 还有两件事。
|
|
492
|
+
|
|
493
|
+
### 没有上游分支
|
|
494
|
+
|
|
495
|
+
新分支第一次推送时 `git push` 会失败,并给出一条其实很有用的指引(`git push --set-upstream origin <branch>`)。面板不该让用户去终端照抄:
|
|
496
|
+
|
|
497
|
+
```js
|
|
498
|
+
const currentBranch = branches.local.find((branch) => branch.current)
|
|
499
|
+
const needsPublish = currentBranch?.upstream === null
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
按钮因此显示「发布」而不是「推送」——**它知道自己在哪个分支上、有没有上游**,不需要先失败一次再猜。
|
|
503
|
+
|
|
504
|
+
### 发布到哪个远端
|
|
505
|
+
|
|
506
|
+
`origin` 是压倒性的惯例,只有一个远端时也无歧义。**其余情况一律拒绝而不是猜**:
|
|
507
|
+
|
|
508
|
+
```js
|
|
509
|
+
if (remotes.includes('origin')) return 'origin'
|
|
510
|
+
if (remotes.length === 1) return remotes[0]
|
|
511
|
+
if (remotes.length === 0) throw new Error('no remote to publish to')
|
|
512
|
+
throw new Error(`several remotes and none is "origin"; publish from a terminal (${remotes.join(', ')})`)
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
推错远端不是事后才发现合适的事情。
|
|
516
|
+
|
|
517
|
+
### 结果怎么报
|
|
518
|
+
|
|
519
|
+
和 pull 一样比较**上游哈希的前后值**,不解析 git 输出。首次推送时上游还不存在,"before" 是空串,于是天然读作「有变化」。
|
|
520
|
+
|
|
521
|
+
**没做 force push。** `--force-with-lease` 是安全的那一个,但它仍然能改写远端历史,值得单独设计(至少要有明确的二次确认),不该混在普通推送里顺手加上。
|
|
522
|
+
|
|
523
|
+
## 网络命令:超时与凭据
|
|
524
|
+
|
|
525
|
+
`pull` 是唯一会阻塞在网络上的操作,有两个和本地命令完全不同的失败模式。
|
|
526
|
+
|
|
527
|
+
### 凭据提示会挂死
|
|
528
|
+
|
|
529
|
+
需要认证时 git 会**等人输入**。`execFile` 没有 TTY,没人能回答它,于是进程一直挂到超时——用户看到的是一个转圈的面板,日志里什么都没有。
|
|
530
|
+
|
|
531
|
+
所以每个 git 子进程都带上:
|
|
532
|
+
|
|
533
|
+
```js
|
|
534
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
拒绝而非等待,把「挂死」变成一条能显示出来的消息。实测(起一个返回 401 的本地 HTTP 服务当远端):
|
|
538
|
+
|
|
539
|
+
| | |
|
|
540
|
+
|---|---|
|
|
541
|
+
| 未加防护 | 挂到 180s 网络超时 |
|
|
542
|
+
| 加了防护 | **76ms** 失败,`fatal: could not read Username for '…': terminal prompts disabled` |
|
|
543
|
+
|
|
544
|
+
### 超时不能共用
|
|
545
|
+
|
|
546
|
+
本地命令的 15s 上限对拉取太短——首次 fetch 一个大仓库本来就可能跑很久。所以网络命令单独给 `GIT_NETWORK_TIMEOUT_MS = 180_000`。
|
|
547
|
+
|
|
548
|
+
### 策略是枚举,不是透传
|
|
549
|
+
|
|
550
|
+
`strategy` 会进 git 的 argv,所以只接受 `ff-only` / `merge` / `rebase` 三个字面量,未知值直接拒绝。**默认 `ff-only`**:它是唯一既不重写历史、也不会在用户背后造出合并提交的选项。分叉时它会失败,并原样给出 git 的消息,用户再显式选「合并」。
|
|
551
|
+
|
|
552
|
+
返回 `changed` 靠比较拉取前后的 `HEAD` 哈希,**不解析 git 的输出**——那句话会被本地化,也在版本间改过,而两个哈希是明确的。
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
面板默认打开**当前会话工作区的仓库**,不需要手填路径。
|
|
557
|
+
|
|
558
|
+
**客户端**:槽位贡献可以声明 `inject: (sessionId) => ({...})`,外壳会把当前会话 id 传进来(`dsh-client-ui-trajectory` 就是这么拿 sessionId 的)。用它在会话摘要里取 cwd:
|
|
559
|
+
|
|
560
|
+
```js
|
|
561
|
+
const summary = ctx.sessions.list.getSnapshot().byId[sessionId]
|
|
562
|
+
summary.cwd // 该会话的工作目录
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
`ui-workspace` 也是拿 `summary.cwd` 和 `workspace.path` 做比较的,所以这是外壳自己认的权威来源,不是拼出来的。
|
|
566
|
+
|
|
567
|
+
**宿主**:客户端把 cwd 当 `root` 传给 `/git-api`,但宿主不能因此信任任意路径——`allowedRoots` 是上一轮加的防线,为了跟随会话把它放宽成 `*` 等于把防线拆掉。所以宿主改为**额外信任已注册的工作区**:
|
|
568
|
+
|
|
569
|
+
```js
|
|
570
|
+
const registry = ctx.get?.('workspaceRegistry')
|
|
571
|
+
registry.list().map((workspace) => workspace.path)
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
已注册的工作区是用户自己打开过的目录,信任它们既满足"跟随会话",又把范围限制在用户显式选择过的目录内。
|
|
575
|
+
|
|
576
|
+
### 实测
|
|
577
|
+
|
|
578
|
+
把 `allowedRoots` 收窄到一个毫不相干的路径后:
|
|
579
|
+
|
|
580
|
+
| 请求 | 结果 |
|
|
581
|
+
|---|---|
|
|
582
|
+
| 会话仓库(已注册工作区) | 放行 ✅ `branch: release-GDNX` |
|
|
583
|
+
| `/etc` | 拒绝 |
|
|
584
|
+
| `/tmp` 下任意目录 | 拒绝 |
|
|
585
|
+
|
|
586
|
+
### 两个踩到的坑
|
|
587
|
+
|
|
588
|
+
- **服务名是 `workspaceRegistry`,不是 `workspace`。** 第一版写的 `ctx.get('workspace')` 会永远返回 undefined,工作区信任**静默失效**——面板看着正常,只是在 `allowedRoots` 之外就悄悄打不开。对着 `dsh-api-workspace-controller` 的源码(`ctx.workspaceRegistry.list`)才确认。(0.2.1-rc.5 里这个服务由 `@deepseek-ai/dsh-workspace` 提供,`dsh-api-workspace-controller` 只是消费者——但插件是可选地 `ctx.get` 它,所以两种布局都成立。)
|
|
589
|
+
- **`@deepseek-ai/dsh-client-runtime` 在 0.1.5 里已经不存在。** `dsh.client.inject` 一直列着这个 0.1.0 时代的包名;整个 0.1.5 树里它只出现在 `dsh-invariants` 的文档里。已按 0.1.5 的 `ui-jobs` 改成 `locale + ui-conversation`。**0.2.1-rc.5 里它同样不存在**,而现在很多 marketplace 插件正栽在这一条上。
|
|
590
|
+
|
|
591
|
+
## 提交图为什么曾经完全不可读
|
|
592
|
+
|
|
593
|
+
第一版在真实仓库上,图栏从第 1 行的 1 条道一路涨到第 190 行的 53 条道(约 689px),把提交标题挤没了。三个错误叠加:
|
|
594
|
+
|
|
595
|
+
1. **新的道永远追加到末尾**,中间空出来的道从不回收——旧代码只裁尾部空道,内部空洞永久占位。于是每出现一个新分支就多一列。
|
|
596
|
+
2. **父提交不在已加载窗口内时仍然永久占道**。分页只取 200 条,而 merge 的第二个父提交经常落在窗口外,那些道再也等不到人来认领。
|
|
597
|
+
3. **多个道汇聚到同一提交时只认第一个**,其余的也永久悬着。
|
|
598
|
+
|
|
599
|
+
修法是三条对应规则(见 `computeLanes` 的注释):优先复用空出来的道、窗口外的父提交直接释放、汇聚时清掉重复的道。
|
|
600
|
+
|
|
601
|
+
在你的仓库上(197 提交 / 72 个 merge)实测:
|
|
602
|
+
|
|
603
|
+
| | 旧算法 | 新算法 |
|
|
604
|
+
|---|---|---|
|
|
605
|
+
| 最大宽度 | **53 条道**(689px) | **4 条道**(52px) |
|
|
606
|
+
| 前 20 行 / 后 20 行 | 1 → 53(单调增长) | 1 → 3(有界) |
|
|
607
|
+
|
|
608
|
+
## 视图高度与遮挡:两处和外壳的边界冲突
|
|
609
|
+
|
|
610
|
+
### 高度:容器在 active 阶段不约束视图
|
|
611
|
+
|
|
612
|
+
第一方会话容器对视图的高度契约是:
|
|
613
|
+
|
|
614
|
+
```css
|
|
615
|
+
.viewArea { flex: 1 0 auto; min-height: auto } /* data-phase=active */
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
会话处于 `active` 阶段时,**视图按内容撑开,由外层 `scrollBody` 滚动**。所以 `height: 100%` 解析成 `auto`,面板跟着内容走。
|
|
619
|
+
|
|
620
|
+
这里连着踩了两个坑,症状都很不对称。
|
|
621
|
+
|
|
622
|
+
**坑一:`max-height` 只能封顶,不能撑满。**
|
|
623
|
+
|
|
624
|
+
表现为**历史页正常、工作区页露出输入框**。历史页的提交列表够长,撞到了上限,顺带把底部盖住;工作区页变更少、内容短,面板缩回内容高度,输入框自然露出来。所以要的是**确定高度**,不是上限。
|
|
625
|
+
|
|
626
|
+
**坑二:按窗口高度撑满,会让滚动容器多出可滚动的余量。**
|
|
627
|
+
|
|
628
|
+
改成实测 `window.innerHeight - rect.top` 之后,症状变成「平时看不到输入框,但一直往下滚就滚出来了」。原因是输入框浮层根本不是浮层:
|
|
629
|
+
|
|
630
|
+
```css
|
|
631
|
+
.composerSeat { flex: none; display: flex; flex-direction: column }
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
`flex: none` —— 它**是滚动容器里的普通流内子元素**(我原先以为的 abspos 规则只在 `:has([data-conversation-composer-overlay])` 成立时生效,实际场景不成立)。所以滚动容器的内容 = 面板 + 输入框;面板撑满视口,内容就正好超出一个输入框的高度,可以往下滚。
|
|
635
|
+
|
|
636
|
+
**最终算法**:不看窗口,看滚动容器自己的几何。
|
|
637
|
+
|
|
638
|
+
```js
|
|
639
|
+
const scroller = nearestScroller(element) // 按 overflow-y 判定,不是按"当前是否溢出"
|
|
640
|
+
const target = scroller.clientHeight - (scroller.scrollHeight - rect.height)
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
`scrollHeight - rect.height` 就是流里**除面板之外**的一切(上面的头部、下面的输入框),从滚动口高度里减掉就是面板应得的那份。这个结果**与测量时面板恰好多高无关**——oversized、undersized 都收敛到同一个值,所以一次测量就精确,`resize` 重测也自洽。
|
|
644
|
+
|
|
645
|
+
`nearestScroller` 必须按 `overflow-y` 判定,**不能按「当前是否溢出」**:内容装得下时后者不成立,而那恰好就是需要撑大的工作区页——判不出来就会退回窗口估算,也就是坑二。没有 `getComputedStyle` 时直接返回 null,退回窗口估算。
|
|
646
|
+
|
|
647
|
+
### 遮挡:会话列有一条隐形拖拽条
|
|
648
|
+
|
|
649
|
+
会话内容列的沟槽里有一条 40px 宽、**完全隐形**的拖拽条:
|
|
650
|
+
|
|
651
|
+
```css
|
|
652
|
+
.widthHandle { z-index: 8; position: absolute; top: 0; bottom: 0; cursor: col-resize;
|
|
653
|
+
width: min(40px, calc((100% - var(--dsh-chat-content-width)) / 2 - 24px - 24px)) }
|
|
654
|
+
.widthHandle[data-side=right] { left: calc(50% + var(--dsh-chat-content-width) / 2 + 24px) }
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
它按会话内容列定位,而视图区是满宽的——所以它整个压在面板上,鼠标扫过去就变 `col-resize`,一拖就改了会话列宽。在插件里没有别的办法,只能画到它上面(`z-index: 9`)。
|
|
658
|
+
|
|
659
|
+
同一套 z-index 会连带盖住 `z-index: 7` 的输入框浮层。**这是有意的**:git 页有自己的提交框,不需要消息输入框,面板因此吃满整个高度。代价是浮层里的**权限预设**和**模型选择器**在这个页面也一并被盖住,要改得切回对话页。
|
|
660
|
+
|
|
661
|
+
分层因此拆成两个盒子:
|
|
662
|
+
|
|
663
|
+
- **外层(shell box)** —— `position: relative; z-index: 9; pointer-events: none`,不画背景。它唯一的存在理由是赢下层级之争,所以它自己不接受指针。
|
|
664
|
+
- **内层(panel)** —— 不透明背景 + `pointer-events: auto`,是真正可交互的面板。
|
|
665
|
+
|
|
666
|
+
这样即使将来外层又需要留白(比如重新露出输入框),那块留白也不会吃掉指针事件。切回对话页时面板卸载,两个浮层都恢复。
|
|
667
|
+
|
|
668
|
+
## 已实现 / 刻意未做
|
|
669
|
+
|
|
670
|
+
**已实现**
|
|
671
|
+
|
|
672
|
+
- porcelain v2 全量解析:普通变更、重命名/复制(含 `origPath`)、未跟踪、**未合并冲突**;所有条目形状统一(未跟踪项也带 `index`/`worktree`/`origPath`,避免客户端渲染出 `undefined`)
|
|
673
|
+
- 提交图 lane 分配算法 + gutter 渲染;三条防膨胀规则见上一节,真实仓库实测最大 4 条道
|
|
674
|
+
- **三个视图**:`审查`(默认,自标记点以来的净效果,按风险排序)、`工作区`(变更文件 → diff → 提交框)与 `历史`(**三栏:分支树 | 提交 | 变更内容**)
|
|
675
|
+
- 分支树含 HEAD / 本地 / 远程分组,点击任一分支即从该 ref 重新走 log,再点一次回到 HEAD
|
|
676
|
+
- **检出分支**:本地分支一键切换;远程分支检出时若已有同名本地分支就直接切过去(`checkout -b` 会拒绝重名),否则建一个跟踪分支
|
|
677
|
+
- **新建分支**:输入框内联在分支列里,基点是你当前筛选的 ref(没筛选就是 HEAD);Enter 建并切过去,Esc 取消
|
|
678
|
+
- 检出后自动解除 ref 筛选并重载——否则你会停在一个刚离开的分支的 log 上
|
|
679
|
+
- **拉取**:分支列顶部一个策略选择器(快进 / 合并 / 变基)+ 一个「拉取」按钮。结果按 HEAD 是否移动报告「已更新 a1b2c3 → d4e5f6」或「已是最新」。三个策略都是宿主接受的枚举值,选择器的 tooltip 说明各自代价
|
|
680
|
+
- **推送**:同一行一个「推送」按钮;当前分支没有上游时自动变成「发布」(推送并设置上游)。结果按上游哈希是否移动报告
|
|
681
|
+
- **点击提交看改动**:列出该提交改动的文件,点文件看该提交对该文件的补丁
|
|
682
|
+
- **文件级暂存 / 取消暂存**,以及带提交信息框的提交
|
|
683
|
+
- **改动块级(hunk)暂存 / 取消暂存**:diff 按 `@@` 拆分,每块带自己的操作按钮。做法与 `git add -p` 一致——用文件头加选中的块重新拼一个 patch,管道给 `git apply --cached`(取消时加 `--reverse`)。跳过靠前的块不会让靠后的块行号失效,因为被跳过的块根本没动索引
|
|
684
|
+
- **未跟踪文件的 diff**:`git diff` 对它们没有输出,改用 `--no-index` 对 `/dev/null` 合成一份整文件新增的 diff(该命令用退出码 1 表示「有差异」,属正常路径)。未跟踪文件不提供块级操作,因为它没有可供 patch 的索引条目
|
|
685
|
+
- `-c core.quotePath=false`:git 默认把非 ASCII 路径转义成八进制(`"a/\345\256\211..."`),中文文件名在 diff 头里完全不可读。只影响输出引用方式
|
|
686
|
+
- **审查视图(默认视图)**:自标记点以来的净效果 + 按需留意程度排序 + 复用现有下钻
|
|
687
|
+
- **自动跟随当前会话的工作区**,无需手填路径;手填的路径会覆盖它,切换会话时自动解除覆盖
|
|
688
|
+
- 面板高度钉在一个窗口内,各栏内部滚动,不再把整页撑长
|
|
689
|
+
- **各栏可拖拽调宽**:分隔条是 5px 的命中带(1px 的线几乎抓不住),hover 时点亮;拖动期监听挂在 window 上,指针离开细条也不会掉拖拽。宽度写入 `localStorage`(`dsh-git:column-widths:v1`),每次拖动结束写一次
|
|
690
|
+
- 存储不是可信输入:读回来的宽度逐列过 `clampWidth` 校验,非法值、缺字段、坏 JSON、以及**会抛异常的存储**(隐私模式、沙箱 iframe)全部退回默认值,不会让面板崩掉
|
|
691
|
+
- `.git` 目录 watcher → 防抖 → 丢弃该仓库读缓存;**写操作则立即失效缓存**,不等防抖(否则刚写完的刷新会读到它刚替换掉的状态)
|
|
692
|
+
- git 诊断透传:`merge` 冲突与 `commit` 空索引的提示在 **stdout**,坏 revision 在 stderr——两条流都检查,否则退化成无用的 "Command failed"
|
|
693
|
+
- **被截断的名字,悬浮出气泡**:文件名、分支名、提交标题被省略号截掉时,鼠标停住 300ms 就冒出一个气泡,给出完整的名字。**只在真的被截断时出现**(`scrollWidth > clientWidth`),气泡挂在 `document.body` 上(面板自己的 `overflow: hidden` 会裁掉树里的气泡),样式用外壳自己的 tooltip token。行里根本没有的信息(分支的上游、提交的作者)也在气泡里。详见「显示优化」一节
|
|
694
|
+
- **输入框里的当前分支标签**:输入框底部那一行上显示会话工作区的分支名(图标 + 分支名)。**只展示不切换**,**非 git 目录不展示**(失败是沉默),读的是 `/branches` 而不是 `/status`(不为一行字扫整个工作区),每 5 秒读一次(agent 在终端里切分支时标签要跟上),窗口重新获得焦点时立刻读一次。详见「输入框里的当前分支」一节
|
|
695
|
+
- **链接工作树的 watcher**:`.git` 在工作树里是一个**文件**,盯着它等于盯着一个永远不变的东西——工作树里的 checkout 改的是别处的 HEAD,缓存于是永远不过期,分支标签会一直显示旧分支。改成问 git 自己 git 目录在哪儿(`git rev-parse --absolute-git-dir`),普通仓库、工作树、submodule 都答得对
|
|
696
|
+
|
|
697
|
+
**刻意未做**
|
|
698
|
+
|
|
699
|
+
- Shelf、分支删除、stash、force push、merge / rebase(作为独立动作;拉取时的 merge/rebase 已支持)
|
|
700
|
+
- 行级(而非块级)选择:`git add -p` 的 `e` 模式可以在块内逐行取舍,这里只做到块
|
|
701
|
+
- 提交图的 lane 排序还没做「最小化交叉」优化——宽度已经有界(真实仓库实测 ≤4 条道),但绕线还不如 IDEA 干净
|
|
702
|
+
|
|
703
|
+
## 验证
|
|
704
|
+
|
|
705
|
+
```sh
|
|
706
|
+
cd /Users/xiaoming/Projects/dsh/dsh-git
|
|
707
|
+
npm test # 只是两个 node 脚本,没有依赖
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
`test/host.test.mjs`(95 项)自建一个含合并冲突的 git 仓库,挂载宿主半,用假的 `req`/`res` 驱动真实路由:
|
|
711
|
+
|
|
712
|
+
```
|
|
713
|
+
routes: prefix /git-api
|
|
714
|
+
PASS health
|
|
715
|
+
PASS status exposes branch + conflicted file — head=main conflicted=a.txt
|
|
716
|
+
PASS untracked entries carry the same field set as every other kind
|
|
717
|
+
PASS omitted root falls back to defaultRoot (cwd) and the allowlist rejects it
|
|
718
|
+
PASS allowlist rejects /etc
|
|
719
|
+
PASS nonexistent path rejected
|
|
720
|
+
PASS log parses every commit with no leading-newline damage — 2 commits: main change | base
|
|
721
|
+
PASS log keeps parents (graph needs them) — parents: 1,0
|
|
722
|
+
PASS branches parses HEAD marker and names — feature *main
|
|
723
|
+
PASS unstaged diff returns conflict markers for the conflicted file — 164 chars
|
|
724
|
+
PASS staged diff accepted
|
|
725
|
+
PASS commit detail returns the header — subject=main change
|
|
726
|
+
PASS commit detail lists changed files with status
|
|
727
|
+
PASS commit detail parses a multi-file commit — ["A:a.txt","A:b.txt"]
|
|
728
|
+
PASS show returns the patch for a commit
|
|
729
|
+
PASS show accepts a pathspec
|
|
730
|
+
PASS unknown GET route -> 404
|
|
731
|
+
PASS a write without the custom header is refused (cross-site protection)
|
|
732
|
+
PASS GET on a write route -> 404 (no such read route)
|
|
733
|
+
PASS unsupported method -> 405
|
|
734
|
+
PASS stage accepts a write with the header
|
|
735
|
+
PASS the write invalidated the cache immediately (no watcher wait)
|
|
736
|
+
PASS unstage accepts a write with the header
|
|
737
|
+
PASS unstage put the new file back to untracked
|
|
738
|
+
PASS an empty file list is refused
|
|
739
|
+
PASS an empty commit message is refused
|
|
740
|
+
PASS stage resolves the conflicted path
|
|
741
|
+
PASS commit through the API returns the new short hash — hash=a20ba9a
|
|
742
|
+
PASS the new commit is visible immediately — head=resolve the merge
|
|
743
|
+
PASS a merge commit lists the conflicting file
|
|
744
|
+
PASS committing with nothing staged surfaces the git diagnostic
|
|
745
|
+
PASS watcher drops the cached status after an out-of-band worktree write
|
|
746
|
+
PASS the fixture really is a linked worktree — a linked worktree keeps .git as a file
|
|
747
|
+
PASS a linked worktree reports its branch before the switch — linked-work
|
|
748
|
+
PASS a checkout inside a linked worktree drops the cache — linked-other
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
`test/client.test.mjs`(180 项)用 `new Function('window', code)` 按外壳内核的方式执行客户端 bundle,检查注册、依赖请求、slot 贡献,并对 lane 算法做单元测试:
|
|
752
|
+
|
|
753
|
+
```
|
|
754
|
+
PASS bundle registers one module factory
|
|
755
|
+
PASS bundle id matches the package name — @xiaoming_z/dsh-git
|
|
756
|
+
PASS exports apply/inject
|
|
757
|
+
PASS inject names the services the body uses — ["slots","locale","sessions"]
|
|
758
|
+
PASS no external request beyond the seeded react
|
|
759
|
+
PASS registers a locale namespace
|
|
760
|
+
PASS declares the conversation.view contribution — conversation.view
|
|
761
|
+
PASS slot descriptor is a conversation.view contribution with an id + label
|
|
762
|
+
PASS slot contribution passes a component
|
|
763
|
+
PASS linear history stays in one lane — 0,0,0
|
|
764
|
+
PASS a merge parent claims its own lane — m@0 b@1 a@0 base@0
|
|
765
|
+
PASS the merge row records both active lanes — ["a","b"]
|
|
766
|
+
PASS disjoint roots occupy separate lanes — x@0 y@0
|
|
767
|
+
PASS lane state is drained at the tail — []
|
|
768
|
+
PASS GitView builds an element tree without throwing
|
|
769
|
+
PASS empty state renders the loading placeholder
|
|
770
|
+
PASS the bubble sits below the label when there is room — {"left":100,"top":226}
|
|
771
|
+
PASS a bubble wider than the label lines up with its start edge — {"left":100,"top":226}
|
|
772
|
+
PASS the bubble flips above the label when the bottom is full — {"left":100,"top":852}
|
|
773
|
+
PASS the bubble never runs off the left edge — {"left":8,"top":226}
|
|
774
|
+
PASS the bubble never runs off the right edge — {"left":1072,"top":226}
|
|
775
|
+
PASS a label that fits is not truncated
|
|
776
|
+
PASS a label that overflows is truncated
|
|
777
|
+
PASS a label that fits produces no bubble
|
|
778
|
+
PASS a cut-off label produces a bubble
|
|
779
|
+
PASS the bubble carries the full name — src/deep/nested/file.ts
|
|
780
|
+
PASS the bubble is placed next to the label — 100px / 226px
|
|
781
|
+
PASS the bubble goes away when the pointer leaves — none
|
|
782
|
+
PASS a file name reveals its full path on hover
|
|
783
|
+
PASS the file row no longer leans on a native tooltip — undefined
|
|
784
|
+
PASS a branch name reveals itself on hover
|
|
785
|
+
PASS a commit subject reveals itself on hover
|
|
786
|
+
PASS a commit hash reveals the whole hash and its author
|
|
787
|
+
PASS the chip is registered in the composer accessory row
|
|
788
|
+
PASS the chip is registered under its own id — git-branch
|
|
789
|
+
PASS the chip follows the session workspace — {"root":"/repo/from-session"}
|
|
790
|
+
PASS the chip shows the current branch — main
|
|
791
|
+
PASS the chip carries a branch icon
|
|
792
|
+
PASS the chip is not a button — span
|
|
793
|
+
PASS the chip asks the host for the branch, not for the whole status — /branches
|
|
794
|
+
PASS a directory that is not a repository shows no chip — null
|
|
795
|
+
PASS a failed read does not crash the chip
|
|
796
|
+
PASS no workspace shows no chip
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
`test/browser/` 里有第二层:**无头 Chrome** 里跑同一个 bundle,量真实的
|
|
800
|
+
`scrollWidth`,派发真的 pointer 事件。它不在 `npm test` 里(要 Chrome、要 React 的
|
|
801
|
+
UMD、要装好的外壳),说明见那个目录的 README。台子里也把分支标签挂在一条仿输入框
|
|
802
|
+
的配件行上,量它的图标尺寸、颜色、截断和气泡。
|
|
803
|
+
|
|
804
|
+
## 兼容性:0.1.x 写的东西,0.2.1-rc.5 还能不能用
|
|
805
|
+
|
|
806
|
+
**能用,而且不用改一行代码。** 这个插件写于 DSH 0.1.x,今天(写这份 README 时)
|
|
807
|
+
装的是 **0.2.1-rc.5**。
|
|
808
|
+
|
|
809
|
+
版本号先说清楚,因为容易记错:装着的 DSH **不是 0.12.5**。三处独立确认:
|
|
810
|
+
`dsh --version`、`Info.plist` 的 `CFBundleShortVersionString`、以及运行时 boot graph
|
|
811
|
+
自己的 `release.version` 字段(`0.2.1-rc.5`,commit `d4db512`)。所以真实的落差是
|
|
812
|
+
**0.1.x → 0.2.x**——而 0.1→0 这一步基本是加法,插件绑定的每个 API 都原封不动地
|
|
813
|
+
活了下来。
|
|
814
|
+
|
|
815
|
+
逐条核对的结果(对着 0.2.1-rc.5 的源码树):
|
|
816
|
+
|
|
817
|
+
| 依赖 | 0.2.1-rc.5 里 | 结论 |
|
|
818
|
+
|---|---|---|
|
|
819
|
+
| `ctx.webServer.register({kind:'prefix',path,handler})` | 签名未变;`kind:'prefix'` 仍有效;handler 仍是 `(req,res)`;返回值仍是 disposer | ✅ |
|
|
820
|
+
| `ctx.get('workspaceRegistry').list()` → `{path}` | 服务现在由 `@deepseek-ai/dsh-workspace` 提供(不是 `dsh-api-workspace-controller`,后者只是消费者) | ✅ |
|
|
821
|
+
| `ctx.effect(fn, label)` | label 仍是第 2 个参数 | ✅ |
|
|
822
|
+
| `ctx.logger.warn/info` | 仍在 | ✅ |
|
|
823
|
+
| `name` / `inject` / `apply(ctx, config)` | 仍是当前约定;`config` 仍是 `apply` 的第 2 个参数 | ✅ |
|
|
824
|
+
| `ctx.slots.inject('conversation.view', …)` | `slots` 服务搬到了 `dsh-client-ui-renderer`,但描述符字段与第一方 trajectory/chat **逐字段一致** | ✅ |
|
|
825
|
+
| `ctx.locale.register(NS, DICTS)` / `.bind(NS)` | 仍是当前约定;内联字典仍是被支持的写法 | ✅ |
|
|
826
|
+
| `ctx.sessions.list.getSnapshot().byId[id].cwd` | 仍是权威来源,第一方代码用的是同一个表达式 | ✅ |
|
|
827
|
+
| `inputTriggers.registerSource({…})` | 仍在 | ✅ |
|
|
828
|
+
| `window.__ModuleLoader__.load({id, factory})` | 仍是这个格式;`react` 是平台种子模块,`require('react')` 不需要 `dsh.client.external` | ✅ |
|
|
829
|
+
| 14 个 `--dsw-alias-*` 主题 token | 全部存在 | ✅ |
|
|
830
|
+
|
|
831
|
+
**一个名字变了,但不影响**:`ReferenceCodec` 在 0.2.x 里已经不存在(那是 0.1.x 的
|
|
832
|
+
叫法)。插件**已经用的是替代写法**——每个来源自带 `codec: {clipboardText,
|
|
833
|
+
serialize(ref, signal)}`,也正是当前第一方 reference 插件用的那一套。交给模型
|
|
834
|
+
的那条路是通的。
|
|
835
|
+
|
|
836
|
+
**一个真·易错点,已经写进代码注释**:浏览器半注册的 id **必须等于包名**。模块系统
|
|
837
|
+
按 Loader 行的 id 找工厂,而行的 id 就是那一行的包名;对不上,bundle 会加载成功
|
|
838
|
+
但**永远绑不上它的行**——不报错,只是面板不出现。
|
|
839
|
+
|
|
840
|
+
## 端到端验证(0.2.1-rc.5 上真跑)
|
|
841
|
+
|
|
842
|
+
不是只读源码,是真跑起来看的。
|
|
843
|
+
|
|
844
|
+
**宿主半**:隔离 profile 起一个实例,未认证直接打路由。
|
|
845
|
+
|
|
846
|
+
```
|
|
847
|
+
$ curl -s http://127.0.0.1:3081/git-api/health
|
|
848
|
+
{"ok":true,"data":{"service":"dsh-git","pid":56276,"writes":true}}
|
|
849
|
+
|
|
850
|
+
$ curl -s "http://127.0.0.1:3081/git-api/status?root=/Users/xiaoming/Projects/business-troubleshooting"
|
|
851
|
+
{"ok":true,"data":{"root":"…/business-troubleshooting","branch":{"head":"release-GDNX",…},
|
|
852
|
+
"staged":[],"unstaged":[],"untracked":[{"path":"安博通不同数据示例.txt",…}],"conflicted":[]}}
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
**客户端半进了 boot 图**,而且 `inject` 正确:
|
|
856
|
+
|
|
857
|
+
```json
|
|
858
|
+
{"id":"@xiaoming_z/dsh-git",
|
|
859
|
+
"url":"plugins/??@xiaoming_z/dsh-git/client.js&rev=3b7e34fb7c97",
|
|
860
|
+
"inject":["@deepseek-ai/dsh-client-locale","@deepseek-ai/dsh-client-ui-conversation",
|
|
861
|
+
"@deepseek-ai/dsh-client-ui-input-trigger"]}
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
**服务的字节就是我手写的那份**——抓它自己的 URL,与磁盘源文件逐字节 diff,差异只有
|
|
865
|
+
服务端追加的两行:
|
|
866
|
+
|
|
867
|
+
```
|
|
868
|
+
2604a2605
|
|
869
|
+
> ;
|
|
870
|
+
> //# sourceMappingURL=??@xiaoming_z/dsh-git/client.js.map&rev=…
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
**`/git-api` 仍在信任围栏之外**。在活实例上打 `/git-api/health` 得到 **404 而不是
|
|
874
|
+
401**——证明这个前缀不在 `/api` 的浏览器信任围栏里,所以插件自己那套「写请求必须
|
|
875
|
+
带 `x-dsh-git` 头」的跨站防护依然成立、且依然是必需的。
|
|
876
|
+
|
|
877
|
+
**面板真的出现了**。无头 Chrome 走 DevTools Protocol 打开实例、点掉欢迎页、发一条
|
|
878
|
+
消息(会话进入 `active` 阶段),视图切换器渲染出:
|
|
879
|
+
|
|
880
|
+
```
|
|
881
|
+
对话 轨迹 Git
|
|
882
|
+
```
|
|
883
|
+
|
|
884
|
+
点 `Git`,面板出来:
|
|
885
|
+
|
|
886
|
+
```
|
|
887
|
+
main
|
|
888
|
+
刷新
|
|
889
|
+
审查 工作区 历史
|
|
890
|
+
14 项变更
|
|
891
|
+
还没有审查点。标记之后,这里只显示之后发生的变化——你不用再从头翻历史。
|
|
892
|
+
标记为已审查
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
同时浏览器发出:
|
|
896
|
+
|
|
897
|
+
```
|
|
898
|
+
GET /git-api/status?root=%2FUsers%2Fxiaoming%2FProjects%2Fdsh
|
|
899
|
+
GET /git-api/log?root=…&limit=200
|
|
900
|
+
GET /git-api/branches?root=…
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
**零 console error、零未捕获异常**,渲染出的树里没有裸 hex 颜色。
|
|
904
|
+
|
|
905
|
+
> **视图区只在会话进入 `active` 阶段才挂载。** 全新会话是 `hero` 阶段,这时候
|
|
906
|
+
> **连轨迹页签都没有**——不是插件的问题。必须有一条消息。
|
|
907
|
+
|
|
908
|
+
### 写路径也真的跑过了
|
|
909
|
+
|
|
910
|
+
之前只验过「防护会拒绝非法请求」,因为不该拿用户自己的仓库做实验。这次在一个
|
|
911
|
+
**一次性仓库**里(`/tmp` 下临时 `git init`,验完删掉)把写端点全过了一遍:
|
|
912
|
+
|
|
913
|
+
| 操作 | 结果 |
|
|
914
|
+
|---|---|
|
|
915
|
+
| 不带 `x-dsh-git` 头发写请求 | 被拒:`refusing a write without the x-dsh-git header (cross-site protection)` |
|
|
916
|
+
| `stage a.txt` | 成功;状态立刻变成 `staged:[a.txt]`(缓存立即失效,不等 watcher 防抖) |
|
|
917
|
+
| `stage` 一个**改动块**(文件里有两处相隔很远的改动) | 只有选中的那一块进了索引,另一块仍然未暂存 |
|
|
918
|
+
| `unstage` | 成功 |
|
|
919
|
+
| `commit` | 返回新短哈希 |
|
|
920
|
+
| `review?since=<标记>` | 提交列表、每文件 `+/−`、状态、总计全对 |
|
|
921
|
+
| `branches` / `log` / `commit` | 分支树、拓扑序提交(含父提交)、单提交头部与改动文件,全对 |
|
|
922
|
+
|
|
923
|
+
**这条「跨站防护」现在是有实测背书的**:不带头的写请求确实被拒,带头的确实生效。
|
|
924
|
+
|
|
925
|
+
### 装进桌面端之后
|
|
926
|
+
|
|
927
|
+
插件已经用插件管理器装进日常在用的 `desktop` profile(**应用不用退出**),装完
|
|
928
|
+
当场生效:
|
|
929
|
+
|
|
930
|
+
- Loader 行:`include:git` → `@xiaoming_z/dsh-git`,`fiberPhase: active`
|
|
931
|
+
- 宿主半:`GET /git-api/health` 在桌面端的端口上直接应答
|
|
932
|
+
- 浏览器半:活的客户端里 `conversation.view` 的占用者变成三个——`chat`(0)、
|
|
933
|
+
`trajectory`(10)、**`git`(30)**,全是 `active`
|
|
934
|
+
- 面板默认打开**当前会话的工作区**。桌面端的 `allowedRoots` 默认只有 profile 目录,
|
|
935
|
+
但 harness 自己注册过的工作区**总是被信任**——所以跟着会话走不需要额外配置
|
|
936
|
+
- `/etc` 之类没注册过的路径仍然被拒
|
|
937
|
+
|
|
938
|
+
### 仍然没验证的
|
|
939
|
+
|
|
940
|
+
- **`!!js` 数组语法仍未验证。** 配置走的是纯 YAML 数组,绕开了它。
|
|
941
|
+
- **CSS 变量名可能不全对。** 按钮/边框用的 `--dsw-alias-bg-accent`、
|
|
942
|
+
`--dsw-alias-border-secondary` 带兜底值:主题对不上不会崩,配色可能不贴合外壳。
|
|
943
|
+
(这两个名字在 0.2.1-rc.5 的主题包里**确实不存在**——README 早先记过这件事,
|
|
944
|
+
现在依然如此。)
|
|
945
|
+
|
|
946
|
+
## 隔离测试环境
|
|
947
|
+
|
|
948
|
+
两个 profile,都在 `~/.dsh/profiles/` 下:
|
|
949
|
+
|
|
950
|
+
**`git-test`** —— 用户的测试 profile,只有 `dsh-base` + `dsh-web-app` + 本插件。
|
|
951
|
+
|
|
952
|
+
```sh
|
|
953
|
+
dsh --profile git-test --port 3081 --no-open
|
|
954
|
+
```
|
|
955
|
+
|
|
956
|
+
它的 `cordis.patch.yml` 把 `allowedRoots` 放宽到 `/Users/xiaoming/Projects`,
|
|
957
|
+
`defaultRoot` 指向 `business-troubleshooting`。**改完热加载,不用重启。**
|
|
958
|
+
|
|
959
|
+
它没有配 LLM provider,所以**建不出会话**——而视图区只在 `active` 阶段挂载,
|
|
960
|
+
所以在这个 profile 里看不到面板。
|
|
961
|
+
|
|
962
|
+
**`git-verify`** —— 为端到端验证而建:desktop profile 的副本 + 本插件,于是它有
|
|
963
|
+
provider、有真实会话,能把面板跑出来。
|
|
964
|
+
|
|
965
|
+
```sh
|
|
966
|
+
dsh --profile git-verify --port 3082 --no-open
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
> 注意:会话存储是全局的 `~/.dsh/sessions`,不按 profile 分——几个实例会看到同一份
|
|
970
|
+
> 会话列表。
|
|
971
|
+
|
|
972
|
+
## 下一步
|
|
973
|
+
|
|
974
|
+
1. lane 排序优化(减少交叉)
|
|
975
|
+
2. Branches widget、Shelf
|
|
976
|
+
3. 行级(而非块级)选择:`git add -p` 的 `e` 模式可以在块内逐行取舍,这里只做到块
|
|
977
|
+
|
|
978
|
+
## 与既有插件的关系
|
|
979
|
+
|
|
980
|
+
用户的 profile 里已经有 `@linxin666/dsh-client-ui-git-graph@0.3.13`。二者不冲突:
|
|
981
|
+
|
|
982
|
+
| | git-graph | 本插件 |
|
|
983
|
+
|---|---|---|
|
|
984
|
+
| slot | `conversation.input.dock`、`conversation.input.selector.context` | `conversation.view` |
|
|
985
|
+
| id | `git-graph*` | `git` |
|
|
986
|
+
| 路由前缀 | `/git/*` | `/git-api` |
|
|
987
|
+
|
|
988
|
+
它还提供了两个有用的旁证:它的 `cordis.patch.yml` 自述 "node half … git service +
|
|
989
|
+
**/git/\* HTTP routes**",独立确认了走 webServer 路由而非 Typert Remote 的选择;
|
|
990
|
+
同时它是**单包双面**(`exports["."]` + `exports["./client"]`),本项目现在也改成了
|
|
991
|
+
这个形状。
|
|
992
|
+
|
|
993
|
+
## 关于「抄 IDEA」
|
|
994
|
+
|
|
995
|
+
交互逻辑是设计思想,不受版权保护;IDEA 的 git 代码是 Kotlin/Java 构建在 IntelliJ 平台上,不可移植;图标、配色、品牌资产属于 JetBrains。所以这里的做法是——把 IDEA 的交互决策当规格说明,用 DSH 的方式重新实现:
|
|
996
|
+
|
|
997
|
+
- **Log 优先**:提交图是主视图,不是附属面板
|
|
998
|
+
- **后台增量索引**:`.git` watcher + 读缓存,UI 不等 git 进程
|
|
999
|
+
- changelist、Shelf、Branches widget 留到后续
|