@konnga/dsh-hud 0.0.0-stage → 0.7.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/LICENSE +21 -0
- package/README.md +298 -2
- package/cordis.patch.yml +12 -0
- package/lib/client.js +3437 -0
- package/lib/index.js +1604 -0
- package/package.json +88 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,1604 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-hud — 宿主半边(Cordis 插件)。
|
|
3
|
+
*
|
|
4
|
+
* 在自己的 `ctx.webServer` 上挂 `/dsh-hud-rpc` 前缀路由,收发与内置 `/api` 完全相同的
|
|
5
|
+
* Connection RPC 信封,并把请求围栏交给 `connection.requestRejection`
|
|
6
|
+
* (Host/Origin + 浏览器会话认证),因此信任等级与内置通道一致。
|
|
7
|
+
*
|
|
8
|
+
* 为什么不直接调用内置通道:dsh >= 0.1.5-rc.1 起,外部插件无法使用
|
|
9
|
+
* `ctx.connection.rpc.handle()` —— Connection 插件是在自己内部
|
|
10
|
+
* `ctx.inject(["webServer"], …)` 作用域里注册路由的,无论调用方 inject 了什么,
|
|
11
|
+
* 那一行都会以 `cannot get property "webServer" without inject` 挂载失败。
|
|
12
|
+
* 因此本插件自持路由,只借用 Connection 的信任判定。
|
|
13
|
+
*
|
|
14
|
+
* 端点(全部只读,不写 index / 工作区 / 配置):
|
|
15
|
+
* - `status { cwd }` → 仓库事实:当前分支(或 detached)、ahead/behind、脏状态、
|
|
16
|
+
* 按类型分组的改动计数,以及改动清单(porcelain-v2 的两个字母 + 路径)。
|
|
17
|
+
* - `diff { cwd, path, origPath? }` → 一个改动文件的**两侧** unified diff
|
|
18
|
+
* (工作区 / 已暂存),已解析成带行号的 rows;未跟踪文件与 `--no-index` 对齐,
|
|
19
|
+
* 文本按行截断、行数有上限,二进制只报 `binary`。
|
|
20
|
+
* - `log { cwd, count? }` → 最近提交(短 sha、作者、日期、refs、标题),条数钳制 1..50。
|
|
21
|
+
* - `commit { cwd, sha }` → 某次提交的元信息与改动清单(相对**第一个父提交**,
|
|
22
|
+
* 合并提交也按第一父提交看),清单形状与 `status.changes` 一致。
|
|
23
|
+
* - `commit-diff { cwd, sha, path, origPath? }` → 该提交里某个文件的 unified diff,
|
|
24
|
+
* 已解析成带行号的 rows(形状与 `diff` 的**单侧**一致)。
|
|
25
|
+
* - `session-changes { cwd, sessionId }` → **本会话改过哪些文件**(按会话归因,见下):
|
|
26
|
+
* 路径、写入/编辑次数、首次与末次改动时间,以及"自 … 起记录"的时间戳。
|
|
27
|
+
*
|
|
28
|
+
* git 调用一律 `execFile`(无 shell)、带超时、有界输出,并对同一 cwd 做短 TTL 缓存 +
|
|
29
|
+
* 在途请求合并,避免面板轮询时反复跑 `git status`。
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { execFile } from 'node:child_process'
|
|
33
|
+
import fs from 'node:fs'
|
|
34
|
+
import path from 'node:path'
|
|
35
|
+
|
|
36
|
+
/** 插件名(Cordis 插件面的一部分)。 */
|
|
37
|
+
export const name = 'dsh-hud'
|
|
38
|
+
|
|
39
|
+
/** 本插件独占的 RPC 通道前缀(浏览器侧用同一常量调用)。 */
|
|
40
|
+
export const CHANNEL = '/dsh-hud-rpc'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 模块自带的硬依赖声明:Cordis 读这里的 `inject` 做激活门控,
|
|
44
|
+
* `cordis.patch.yml` 的行里因此不再重复写一遍。
|
|
45
|
+
*/
|
|
46
|
+
export const inject = ['webServer', 'connection']
|
|
47
|
+
|
|
48
|
+
/** 本地 git 超时:大仓库的 `git status` 也可能要几秒。 */
|
|
49
|
+
const GIT_TIMEOUT_MS = 30_000
|
|
50
|
+
/** execFile 输出上限,防止异常仓库把内存吃满。 */
|
|
51
|
+
const MAX_BUFFER_BYTES = 8 * 1024 * 1024
|
|
52
|
+
/** 同一 cwd 的状态缓存时长。 */
|
|
53
|
+
const STATUS_CACHE_MS = 1_500
|
|
54
|
+
const CACHE_MAX_ENTRIES = 32
|
|
55
|
+
/** 改动清单上限;超出只计数并置 `truncated`。 */
|
|
56
|
+
const MAX_CHANGES = 200
|
|
57
|
+
/** 单文件路径上限,防御异常仓库。 */
|
|
58
|
+
const MAX_PATH_LENGTH = 4_096
|
|
59
|
+
/** 请求体上限。 */
|
|
60
|
+
const MAX_BODY_BYTES = 256 * 1024
|
|
61
|
+
|
|
62
|
+
/** 控制字符(含 NUL、换行)——出现在路径里即视为非法入参。 */
|
|
63
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/
|
|
64
|
+
|
|
65
|
+
const STATUS_CACHE = new Map()
|
|
66
|
+
const INFLIGHT = new Map()
|
|
67
|
+
|
|
68
|
+
/** 运行一条 git 命令;永不自造结果,失败就把失败原因交回调用方。 */
|
|
69
|
+
function runGit(cwd, args, timeoutMs = GIT_TIMEOUT_MS) {
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
execFile(
|
|
72
|
+
'git',
|
|
73
|
+
args,
|
|
74
|
+
{ cwd, timeout: timeoutMs, maxBuffer: MAX_BUFFER_BYTES, windowsHide: true, encoding: 'utf8' },
|
|
75
|
+
(error, stdout, stderr) => {
|
|
76
|
+
if (!error) {
|
|
77
|
+
resolve({ ok: true, stdout: stdout || '' })
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
resolve({
|
|
81
|
+
ok: false,
|
|
82
|
+
stdout: stdout || '',
|
|
83
|
+
stderr: stderr || '',
|
|
84
|
+
killed: Boolean(error.killed) || Boolean(error.signal),
|
|
85
|
+
code: typeof error.code === 'number' ? error.code : (error.code ?? null),
|
|
86
|
+
message: error instanceof Error ? error.message : String(error),
|
|
87
|
+
})
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 解析 `git status --porcelain=v2 --branch -z` 的输出。
|
|
95
|
+
*
|
|
96
|
+
* porcelain-v2 的记录以 NUL 分隔,重命名/复制的记录会多跟一条 NUL 记录作为原路径。
|
|
97
|
+
* 路径本身可能含空格,所以绝不能整行 `split(' ')` —— 按字段数用 `indexOf` 切分。
|
|
98
|
+
*
|
|
99
|
+
* @param raw - git 的原始 stdout。
|
|
100
|
+
* @returns 与 `HudGitStatus` 同形的结果。
|
|
101
|
+
*/
|
|
102
|
+
export function parseStatus(raw) {
|
|
103
|
+
const records = String(raw).split('\0')
|
|
104
|
+
|
|
105
|
+
let branch = null
|
|
106
|
+
let oid = null
|
|
107
|
+
let upstream = null
|
|
108
|
+
let ahead = 0
|
|
109
|
+
let behind = 0
|
|
110
|
+
let detached = false
|
|
111
|
+
let truncated = false
|
|
112
|
+
|
|
113
|
+
const counts = {
|
|
114
|
+
modified: 0,
|
|
115
|
+
added: 0,
|
|
116
|
+
deleted: 0,
|
|
117
|
+
renamed: 0,
|
|
118
|
+
copied: 0,
|
|
119
|
+
typeChanged: 0,
|
|
120
|
+
untracked: 0,
|
|
121
|
+
conflicted: 0,
|
|
122
|
+
}
|
|
123
|
+
const changes = []
|
|
124
|
+
|
|
125
|
+
/** 跳过 `count` 个空格分隔的字段,返回其后剩余部分(保留路径里的空格)。 */
|
|
126
|
+
const afterFields = (record, count) => {
|
|
127
|
+
let index = 0
|
|
128
|
+
for (let seen = 0; seen < count; seen += 1) {
|
|
129
|
+
const space = record.indexOf(' ', index)
|
|
130
|
+
if (space < 0) return null
|
|
131
|
+
index = space + 1
|
|
132
|
+
}
|
|
133
|
+
return record.slice(index)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const push = (status, index, worktree, file, origFile) => {
|
|
137
|
+
if (changes.length < MAX_CHANGES) {
|
|
138
|
+
changes.push({
|
|
139
|
+
status,
|
|
140
|
+
index,
|
|
141
|
+
worktree,
|
|
142
|
+
path: origFile ? `${origFile} → ${file}` : file,
|
|
143
|
+
file,
|
|
144
|
+
origFile,
|
|
145
|
+
})
|
|
146
|
+
} else {
|
|
147
|
+
truncated = true
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (let cursor = 0; cursor < records.length; cursor += 1) {
|
|
152
|
+
const record = records[cursor]
|
|
153
|
+
if (!record) continue
|
|
154
|
+
|
|
155
|
+
if (record.startsWith('# branch.head ')) {
|
|
156
|
+
const value = record.slice('# branch.head '.length)
|
|
157
|
+
detached = value === '(detached)'
|
|
158
|
+
branch = detached ? null : value
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
if (record.startsWith('# branch.oid ')) {
|
|
162
|
+
const value = record.slice('# branch.oid '.length)
|
|
163
|
+
oid = value === '(initial)' ? null : value.slice(0, 7)
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
if (record.startsWith('# branch.upstream ')) {
|
|
167
|
+
upstream = record.slice('# branch.upstream '.length) || null
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
if (record.startsWith('# branch.ab ')) {
|
|
171
|
+
const match = /\+(\d+) -(\d+)/.exec(record)
|
|
172
|
+
ahead = Number(match?.[1] ?? 0)
|
|
173
|
+
behind = Number(match?.[2] ?? 0)
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (record.startsWith('? ')) {
|
|
178
|
+
counts.untracked += 1
|
|
179
|
+
push('?', '.', '?', record.slice(2))
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
if (record.startsWith('! ')) {
|
|
183
|
+
// 未使用 --ignored,正常不会出现;出现时按未跟踪处理更贴近用户直觉。
|
|
184
|
+
counts.untracked += 1
|
|
185
|
+
push('!', '.', '!', record.slice(2))
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
if (record.startsWith('u ')) {
|
|
189
|
+
counts.conflicted += 1
|
|
190
|
+
const file = afterFields(record, 10)
|
|
191
|
+
if (file !== null) push('!', record.slice(2, 4)[0], record.slice(2, 4)[1], file)
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
if (record.startsWith('1 ') || record.startsWith('2 ')) {
|
|
195
|
+
const renamed = record.startsWith('2 ')
|
|
196
|
+
const xy = record.slice(2, 4)
|
|
197
|
+
// 展示字母:优先取 index 侧,index 为 '.' 时取 worktree 侧(与 codex-hud 同规则)。
|
|
198
|
+
const letter = xy[0] !== '.' ? xy[0] : xy[1]
|
|
199
|
+
const file = afterFields(record, renamed ? 9 : 8)
|
|
200
|
+
let origFile = null
|
|
201
|
+
if (renamed) {
|
|
202
|
+
// 重命名/复制的原路径是紧随其后的一条 NUL 记录。
|
|
203
|
+
cursor += 1
|
|
204
|
+
origFile = records[cursor] || null
|
|
205
|
+
}
|
|
206
|
+
if (letter === 'R') counts.renamed += 1
|
|
207
|
+
else if (letter === 'C') counts.copied += 1
|
|
208
|
+
else if (letter === 'A') counts.added += 1
|
|
209
|
+
else if (letter === 'D') counts.deleted += 1
|
|
210
|
+
else if (letter === 'T') counts.typeChanged += 1
|
|
211
|
+
else counts.modified += 1
|
|
212
|
+
if (file !== null) push(letter, xy[0], xy[1], file, origFile)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const dirty = Object.values(counts).some(count => count > 0)
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
repo: null, // 由调用方填入仓库根
|
|
220
|
+
branch: branch ?? oid,
|
|
221
|
+
detached,
|
|
222
|
+
oid,
|
|
223
|
+
upstream,
|
|
224
|
+
ahead,
|
|
225
|
+
behind,
|
|
226
|
+
dirty,
|
|
227
|
+
counts,
|
|
228
|
+
changes,
|
|
229
|
+
truncated,
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 校验浏览器传来的 cwd,只接受绝对、无控制字符、长度有界的路径。 */
|
|
234
|
+
export function validateCwd(value) {
|
|
235
|
+
if (typeof value !== 'string' || value.length === 0) return { ok: false, code: 'invalid-cwd', message: 'cwd must be a non-empty string' }
|
|
236
|
+
if (value.length > MAX_PATH_LENGTH) return { ok: false, code: 'invalid-cwd', message: 'cwd is too long' }
|
|
237
|
+
if (CONTROL_CHARS.test(value)) return { ok: false, code: 'invalid-cwd', message: 'cwd contains control characters' }
|
|
238
|
+
if (!path.isAbsolute(value)) return { ok: false, code: 'invalid-cwd', message: 'cwd must be an absolute path' }
|
|
239
|
+
return { ok: true, value }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** 无缓存地采集一次状态(测试与缓存未命中时使用)。 */
|
|
243
|
+
export async function readStatus(cwd) {
|
|
244
|
+
const root = await runGit(cwd, ['rev-parse', '--show-toplevel'])
|
|
245
|
+
|
|
246
|
+
if (!root.ok) {
|
|
247
|
+
if (root.killed) throw Object.assign(new Error('git rev-parse timed out'), { code: 'timeout' })
|
|
248
|
+
if (root.code === 'ENOENT' && !fs.existsSync(cwd)) throw Object.assign(new Error('cwd does not exist'), { code: 'no-cwd' })
|
|
249
|
+
if (root.code === 'ENOENT') throw Object.assign(new Error('git executable not found'), { code: 'git-missing' })
|
|
250
|
+
// 其余非零退出(典型是 128)就是"不在仓库里"——这是正常结果,不是错误。
|
|
251
|
+
return { repo: null, branch: null, detached: false, oid: null, upstream: null, ahead: 0, behind: 0, dirty: false, counts: emptyCounts(), changes: [], truncated: false }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const repoRoot = root.stdout.split('\n')[0].trim()
|
|
255
|
+
if (!repoRoot) {
|
|
256
|
+
return { repo: null, branch: null, detached: false, oid: null, upstream: null, ahead: 0, behind: 0, dirty: false, counts: emptyCounts(), changes: [], truncated: false }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const status = await runGit(repoRoot, ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=normal'])
|
|
260
|
+
if (!status.ok) {
|
|
261
|
+
if (status.killed) throw Object.assign(new Error('git status timed out'), { code: 'timeout' })
|
|
262
|
+
throw Object.assign(new Error(status.stderr.trim() || status.message || 'git status failed'), { code: 'git-failed' })
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const parsed = parseStatus(status.stdout)
|
|
266
|
+
parsed.repo = repoRoot
|
|
267
|
+
return parsed
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** 一次最多看多少个工作区(多个会话可能共用一个目录,按目录去重之后再算)。 */
|
|
271
|
+
const ROW_STATUS_MAX_WORKTREES = 8
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* 会话一览:每个活着的会话 → (它那个工作区的 git 摘要)+(这个会话自己的改动计数)。
|
|
275
|
+
*
|
|
276
|
+
* 两半各有一个唯一来源:git 那半走带缓存的 `readStatusCached`(按目录去重,多会话共用
|
|
277
|
+
* 一个目录只跑一次 git),"这个会话改过什么"那半直接来自归因记录(本来就按 sessionId 分,
|
|
278
|
+
* 不用再问 git)。**不返回 `changes`**:这一行只要摘要,别把清单也拖过来。
|
|
279
|
+
*/
|
|
280
|
+
export async function readSessionOverview() {
|
|
281
|
+
if (hostSessions === null) return { sessions: [], unavailable: true, truncated: false, worktrees: 0 }
|
|
282
|
+
let live = []
|
|
283
|
+
try {
|
|
284
|
+
live = hostSessions.list()
|
|
285
|
+
} catch {
|
|
286
|
+
return { sessions: [], unavailable: true, truncated: false, worktrees: 0 }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 会话 → 目录;同一个目录只读一次。
|
|
290
|
+
const byCwd = new Map()
|
|
291
|
+
const rows = []
|
|
292
|
+
for (const session of live) {
|
|
293
|
+
const header = session && session.header ? session.header : undefined
|
|
294
|
+
const sessionId = header && typeof header.id === 'string' ? header.id : null
|
|
295
|
+
if (sessionId === null) continue
|
|
296
|
+
const cwd = header && typeof header.cwd === 'string' && header.cwd.length > 0 ? header.cwd : null
|
|
297
|
+
rows.push({ sessionId, cwd })
|
|
298
|
+
if (cwd !== null && !byCwd.has(cwd)) byCwd.set(cwd, null)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const cwds = [...byCwd.keys()]
|
|
302
|
+
const used = cwds.slice(0, ROW_STATUS_MAX_WORKTREES)
|
|
303
|
+
const truncated = cwds.length > used.length
|
|
304
|
+
await Promise.all(used.map(async (cwd) => {
|
|
305
|
+
try {
|
|
306
|
+
byCwd.set(cwd, await readStatusCached(cwd))
|
|
307
|
+
} catch (error) {
|
|
308
|
+
byCwd.set(cwd, { error: error && error.code ? error.code : 'git-failed' })
|
|
309
|
+
}
|
|
310
|
+
}))
|
|
311
|
+
|
|
312
|
+
const sessions = rows.map((row) => {
|
|
313
|
+
// 会话自己的改动计数(确定 / 可能),来自归因记录 —— 与 git 无关,非仓库也能报。
|
|
314
|
+
const entry = sessionChanges.get(row.sessionId)
|
|
315
|
+
const summary = {
|
|
316
|
+
sessionId: row.sessionId,
|
|
317
|
+
cwd: row.cwd,
|
|
318
|
+
mine: entry === undefined ? 0 : entry.files.size,
|
|
319
|
+
possible: entry === undefined ? 0 : entry.possible.size,
|
|
320
|
+
}
|
|
321
|
+
if (row.cwd === null) return { ...summary, repo: false, error: null }
|
|
322
|
+
const status = byCwd.get(row.cwd)
|
|
323
|
+
if (status === undefined || status === null) return { ...summary, repo: false, error: null, pending: true }
|
|
324
|
+
if (status.error !== undefined) return { ...summary, repo: false, error: status.error }
|
|
325
|
+
return {
|
|
326
|
+
...summary,
|
|
327
|
+
repo: status.repo !== null,
|
|
328
|
+
branch: status.branch,
|
|
329
|
+
detached: status.detached === true,
|
|
330
|
+
dirty: status.dirty === true,
|
|
331
|
+
ahead: status.ahead || 0,
|
|
332
|
+
behind: status.behind || 0,
|
|
333
|
+
counts: status.counts,
|
|
334
|
+
error: null,
|
|
335
|
+
}
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
return { sessions, unavailable: false, truncated, worktrees: used.length }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* "这一版"的签名:给每个改动文件取 `size + mtimeMs`,用来判断"我审过的还是不是现在这一版"。
|
|
343
|
+
*
|
|
344
|
+
* 为什么不用 `git diff --numstat`:行数变化抓不到"同数改写"(把 `foo` 改成 `bar`),而
|
|
345
|
+
* `stat` 的 mtime 对任何一次写都会变 —— 也更便宜(一次 syscall,不用再跑一个 git 进程)。
|
|
346
|
+
* 反过来它也会过报:`touch` 一下(内容没变)就会让"已审"失效 —— 宁可让人多看一眼。
|
|
347
|
+
*/
|
|
348
|
+
export async function readReviewSignatures(cwd, files) {
|
|
349
|
+
const signatures = {}
|
|
350
|
+
for (const file of files) {
|
|
351
|
+
if (typeof file !== 'string' || file.length === 0) continue
|
|
352
|
+
const absolute = path.join(cwd, file)
|
|
353
|
+
try {
|
|
354
|
+
const info = await fs.promises.stat(absolute)
|
|
355
|
+
signatures[file] = `${info.size}.${Math.round(info.mtimeMs)}`
|
|
356
|
+
} catch {
|
|
357
|
+
// 文件不在了(被删掉 / 被重命名):给一个"不存在"的签名,别让它跟"没审过"混在一起。
|
|
358
|
+
signatures[file] = 'missing'
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return signatures
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function emptyCounts() {
|
|
365
|
+
return { modified: 0, added: 0, deleted: 0, renamed: 0, copied: 0, typeChanged: 0, untracked: 0, conflicted: 0 }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** 带 TTL 缓存与在途合并的读取,面板轮询时不会反复跑 git。 */
|
|
369
|
+
async function readStatusCached(cwd, now = Date.now()) {
|
|
370
|
+
const cached = STATUS_CACHE.get(cwd)
|
|
371
|
+
if (cached && now - cached.at < STATUS_CACHE_MS) {
|
|
372
|
+
return cached.value
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const inflight = INFLIGHT.get(cwd)
|
|
376
|
+
if (inflight) return inflight
|
|
377
|
+
|
|
378
|
+
const pending = readStatus(cwd).then(
|
|
379
|
+
(value) => {
|
|
380
|
+
INFLIGHT.delete(cwd)
|
|
381
|
+
STATUS_CACHE.set(cwd, { at: Date.now(), value })
|
|
382
|
+
if (STATUS_CACHE.size > CACHE_MAX_ENTRIES) {
|
|
383
|
+
const oldest = [...STATUS_CACHE.entries()].sort((left, right) => left[1].at - right[1].at)[0]
|
|
384
|
+
if (oldest) STATUS_CACHE.delete(oldest[0])
|
|
385
|
+
}
|
|
386
|
+
return value
|
|
387
|
+
},
|
|
388
|
+
(error) => {
|
|
389
|
+
INFLIGHT.delete(cwd)
|
|
390
|
+
throw error
|
|
391
|
+
},
|
|
392
|
+
)
|
|
393
|
+
INFLIGHT.set(cwd, pending)
|
|
394
|
+
return pending
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** 最近提交:默认条数与上限。 */
|
|
398
|
+
const DEFAULT_COMMITS = 10
|
|
399
|
+
const MAX_COMMITS = 50
|
|
400
|
+
|
|
401
|
+
/** 单侧 diff 的文本上限(先按行截断再解析,保证解析量有界)。 */
|
|
402
|
+
const MAX_DIFF_CHARS = 200_000
|
|
403
|
+
/** 单侧 diff 的行数上限。 */
|
|
404
|
+
const MAX_DIFF_ROWS = 2_000
|
|
405
|
+
|
|
406
|
+
/** diff 行的种类:增 / 删 / 上下文 / hunk 头 / git 自己的文件头。 */
|
|
407
|
+
const DIFF_ADD = 'add'
|
|
408
|
+
const DIFF_DEL = 'del'
|
|
409
|
+
const DIFF_CTX = 'ctx'
|
|
410
|
+
const DIFF_HUNK = 'hunk'
|
|
411
|
+
const DIFF_META = 'meta'
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* 把 `git diff` 文本解析成带行号的 rows。
|
|
415
|
+
*
|
|
416
|
+
* 放在宿主半而不是浏览器半:解析是纯函数,可以直接单测,客户端只负责渲染。
|
|
417
|
+
* 行号从每个 hunk 头 `@@ -a,b +c,d @@` 起算;git 自己的文件头(`diff --git`、
|
|
418
|
+
* `index`、`---`、`+++`)只在 hunk 之外识别,因此**内容行**里以 `--`/`++`/`@@`
|
|
419
|
+
* 开头的文本不会被误判。
|
|
420
|
+
*
|
|
421
|
+
* @param raw - git 的原始 stdout。
|
|
422
|
+
* @returns `{ rows, additions, deletions, hunks, binary, truncated }`。
|
|
423
|
+
*/
|
|
424
|
+
export function parseDiff(raw) {
|
|
425
|
+
const lines = String(raw).split('\n')
|
|
426
|
+
const rows = []
|
|
427
|
+
|
|
428
|
+
let oldLine = 0
|
|
429
|
+
let newLine = 0
|
|
430
|
+
let inHunk = false
|
|
431
|
+
let additions = 0
|
|
432
|
+
let deletions = 0
|
|
433
|
+
let hunks = 0
|
|
434
|
+
let binary = false
|
|
435
|
+
let truncated = false
|
|
436
|
+
|
|
437
|
+
const push = (kind, text, old, next) => {
|
|
438
|
+
if (rows.length >= MAX_DIFF_ROWS) {
|
|
439
|
+
truncated = true
|
|
440
|
+
return false
|
|
441
|
+
}
|
|
442
|
+
rows.push({ kind, text, oldLine: old, newLine: next })
|
|
443
|
+
return true
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
447
|
+
const line = lines[index]
|
|
448
|
+
// 末尾那一行空串是 split 的产物,不是 diff 内容。
|
|
449
|
+
if (index === lines.length - 1 && line === '') break
|
|
450
|
+
|
|
451
|
+
if (line.startsWith('Binary files ') || line.startsWith('GIT binary patch')) {
|
|
452
|
+
binary = true
|
|
453
|
+
push(DIFF_META, line, null, null)
|
|
454
|
+
continue
|
|
455
|
+
}
|
|
456
|
+
if (line.startsWith('diff --git ')) {
|
|
457
|
+
// 多文件 diff(路径是目录时):新的一段从这里开始。
|
|
458
|
+
inHunk = false
|
|
459
|
+
push(DIFF_META, line, null, null)
|
|
460
|
+
continue
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
|
|
464
|
+
if (hunk !== null) {
|
|
465
|
+
inHunk = true
|
|
466
|
+
hunks += 1
|
|
467
|
+
oldLine = Number(hunk[1])
|
|
468
|
+
newLine = Number(hunk[2])
|
|
469
|
+
push(DIFF_HUNK, line, null, null)
|
|
470
|
+
continue
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (!inHunk) {
|
|
474
|
+
push(DIFF_META, line, null, null)
|
|
475
|
+
continue
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (line.startsWith('+')) {
|
|
479
|
+
additions += 1
|
|
480
|
+
push(DIFF_ADD, line.slice(1), null, newLine)
|
|
481
|
+
newLine += 1
|
|
482
|
+
continue
|
|
483
|
+
}
|
|
484
|
+
if (line.startsWith('-')) {
|
|
485
|
+
deletions += 1
|
|
486
|
+
push(DIFF_DEL, line.slice(1), oldLine, null)
|
|
487
|
+
oldLine += 1
|
|
488
|
+
continue
|
|
489
|
+
}
|
|
490
|
+
if (line.startsWith(' ')) {
|
|
491
|
+
push(DIFF_CTX, line.slice(1), oldLine, newLine)
|
|
492
|
+
oldLine += 1
|
|
493
|
+
newLine += 1
|
|
494
|
+
continue
|
|
495
|
+
}
|
|
496
|
+
// 反斜杠行("")等。
|
|
497
|
+
push(DIFF_META, line, null, null)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return { rows, additions, deletions, hunks, binary, truncated }
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** 按行边界截断文本。 */
|
|
504
|
+
function truncateDiff(text) {
|
|
505
|
+
if (text.length <= MAX_DIFF_CHARS) return { text, truncated: false }
|
|
506
|
+
const head = text.slice(0, MAX_DIFF_CHARS)
|
|
507
|
+
const lastBreak = head.lastIndexOf('\n')
|
|
508
|
+
return { text: lastBreak > 0 ? head.slice(0, lastBreak) : head, truncated: true }
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* 解析 `git diff --name-status -z` 的输出。
|
|
513
|
+
*
|
|
514
|
+
* `-z` 用 NUL 收尾,所以含空格/引号的路径原样给出(不会像默认输出那样带引号转义):
|
|
515
|
+
* 一段是 `STATUS\0PATH\0`,重命名/复制则是 `R075\0OLD\0NEW\0`。返回的每条与
|
|
516
|
+
* `status.changes` 同形(`status` 只留 M/A/D/R/C/T/U/X 这类单字母),这样左栏的
|
|
517
|
+
* 改动清单组件可以直接渲染这次的提交。
|
|
518
|
+
*
|
|
519
|
+
* @param raw - git 的原始 stdout。
|
|
520
|
+
* @returns `{ files, truncated }`,条数上限与工作区清单一致。
|
|
521
|
+
*/
|
|
522
|
+
export function parseNameStatus(raw) {
|
|
523
|
+
const tokens = String(raw).split('\0')
|
|
524
|
+
const files = []
|
|
525
|
+
let truncated = false
|
|
526
|
+
|
|
527
|
+
for (let cursor = 0; cursor < tokens.length;) {
|
|
528
|
+
const status = tokens[cursor]
|
|
529
|
+
cursor += 1
|
|
530
|
+
// 收尾的 NUL 会留一个空段;非状态行的段落一律跳过,而不是把垃圾当路径。
|
|
531
|
+
if (!status || !/^[A-Z]/.test(status)) continue
|
|
532
|
+
|
|
533
|
+
const letter = status.slice(0, 1)
|
|
534
|
+
const paired = letter === 'R' || letter === 'C'
|
|
535
|
+
const first = tokens[cursor]
|
|
536
|
+
cursor += 1
|
|
537
|
+
const second = paired ? tokens[cursor] : null
|
|
538
|
+
if (paired) cursor += 1
|
|
539
|
+
|
|
540
|
+
if (typeof first !== 'string' || first === '') continue
|
|
541
|
+
const file = paired ? second : first
|
|
542
|
+
const origFile = paired ? first : null
|
|
543
|
+
if (typeof file !== 'string' || file === '') continue
|
|
544
|
+
|
|
545
|
+
if (files.length >= MAX_CHANGES) {
|
|
546
|
+
truncated = true
|
|
547
|
+
break
|
|
548
|
+
}
|
|
549
|
+
files.push({
|
|
550
|
+
status: letter,
|
|
551
|
+
path: origFile === null ? file : `${origFile} → ${file}`,
|
|
552
|
+
file,
|
|
553
|
+
origFile,
|
|
554
|
+
})
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return { files, truncated }
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** 提交 sha 的形状:只用十六进制,长度 4..40(面板给的是短 sha)。 */
|
|
561
|
+
const COMMIT_SHA = /^[0-9a-fA-F]{4,40}$/
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* 校验提交 sha。
|
|
565
|
+
*
|
|
566
|
+
* 只收十六进制,因此 `HEAD~1`、`--help`、`origin/main` 这类都进不来——`sha` 会作为
|
|
567
|
+
* 位置参数交给 `git`,只靠 `--` 分隔路径是不够的,必须在入口就把选项形状挡掉。
|
|
568
|
+
*/
|
|
569
|
+
export function validateCommitSha(value, label = 'sha') {
|
|
570
|
+
const reject = message => ({ ok: false, code: 'invalid-sha', message: `${label}: ${message}` })
|
|
571
|
+
if (typeof value !== 'string' || value.length === 0) return reject('must be a non-empty string')
|
|
572
|
+
if (!COMMIT_SHA.test(value)) return reject('must be 4..40 hex characters')
|
|
573
|
+
return { ok: true, value }
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** 校验 diff 的仓库内相对路径:必须是相对、无控制字符、无 `..`、不以 `-` 开头。 */
|
|
577
|
+
export function validateDiffPath(value, label = 'path') {
|
|
578
|
+
const reject = message => ({ ok: false, code: 'invalid-path', message: `${label}: ${message}` })
|
|
579
|
+
if (typeof value !== 'string' || value.length === 0) return reject('must be a non-empty string')
|
|
580
|
+
if (value.length > MAX_PATH_LENGTH) return reject('is too long')
|
|
581
|
+
if (CONTROL_CHARS.test(value)) return reject('contains control characters')
|
|
582
|
+
if (path.isAbsolute(value)) return reject('must be repo-relative, not absolute')
|
|
583
|
+
if (/^[\\/]/.test(value)) return reject('must not start with a separator')
|
|
584
|
+
if (value.startsWith('-')) return reject('must not start with "-"')
|
|
585
|
+
if (value.split(/[\\/]/).includes('..')) return reject('must not contain ".."')
|
|
586
|
+
return { ok: true, value }
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* 读一个改动路径的**两侧** diff(工作区 / 已暂存),全部只读。
|
|
591
|
+
*
|
|
592
|
+
* 未跟踪文件用 `git diff --no-index -- /dev/null <path>`(该命令以退出码 1 表示
|
|
593
|
+
* "有差异",属于正常结果);重命名把原路径一并作为 pathspec 传入。
|
|
594
|
+
*
|
|
595
|
+
* @param cwd - 会话工作区(绝对路径)。
|
|
596
|
+
* @param file - 仓库内相对路径。
|
|
597
|
+
* @param origFile - 重命名/复制的原路径,可选。
|
|
598
|
+
* @returns `{ repo, path, origPath, untracked, worktree, index }`,两侧都是 parseDiff 的结果。
|
|
599
|
+
*/
|
|
600
|
+
export async function readDiff(cwd, file, origFile = null) {
|
|
601
|
+
const root = await runGit(cwd, ['rev-parse', '--show-toplevel'])
|
|
602
|
+
if (!root.ok) {
|
|
603
|
+
if (root.killed) throw Object.assign(new Error('git rev-parse timed out'), { code: 'timeout' })
|
|
604
|
+
if (root.code === 'ENOENT' && !fs.existsSync(cwd)) throw Object.assign(new Error('cwd does not exist'), { code: 'no-cwd' })
|
|
605
|
+
if (root.code === 'ENOENT') throw Object.assign(new Error('git executable not found'), { code: 'git-missing' })
|
|
606
|
+
return { repo: null, path: file, origPath: origFile, untracked: false, worktree: parseDiff(''), index: parseDiff('') }
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const repoRoot = root.stdout.split('\n')[0].trim()
|
|
610
|
+
const pathspec = origFile === null || origFile === undefined || origFile === '' ? [file] : [file, origFile]
|
|
611
|
+
|
|
612
|
+
const tracked = await runGit(repoRoot, ['ls-files', '--error-unmatch', '--', file])
|
|
613
|
+
const untracked = !tracked.ok
|
|
614
|
+
|
|
615
|
+
let worktreeText = ''
|
|
616
|
+
let worktreeTruncated = false
|
|
617
|
+
if (untracked) {
|
|
618
|
+
// 以退出码 1 表示"有差异",stdout 就是 unified diff。
|
|
619
|
+
const result = await runGit(repoRoot, ['diff', '--no-index', '--no-color', '--no-ext-diff', '--', '/dev/null', file])
|
|
620
|
+
if (result.code === 'ENOENT' || result.killed) {
|
|
621
|
+
throw Object.assign(new Error(result.killed ? 'git diff timed out' : 'git executable not found'), { code: result.killed ? 'timeout' : 'git-missing' })
|
|
622
|
+
}
|
|
623
|
+
worktreeText = result.stdout
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
const result = await runGit(repoRoot, ['diff', '--no-color', '--no-ext-diff', '--', ...pathspec])
|
|
627
|
+
if (!result.ok) throw Object.assign(new Error(result.stderr.trim() || 'git diff failed'), { code: 'git-failed' })
|
|
628
|
+
worktreeText = result.stdout
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const indexResult = untracked
|
|
632
|
+
? { ok: true, stdout: '' }
|
|
633
|
+
: await runGit(repoRoot, ['diff', '--cached', '--no-color', '--no-ext-diff', '--', ...pathspec])
|
|
634
|
+
if (!indexResult.ok) throw Object.assign(new Error(indexResult.stderr?.trim() || 'git diff --cached failed'), { code: 'git-failed' })
|
|
635
|
+
|
|
636
|
+
const worktreeCut = truncateDiff(worktreeText)
|
|
637
|
+
const indexCut = truncateDiff(indexResult.stdout || '')
|
|
638
|
+
const worktree = parseDiff(worktreeCut.text)
|
|
639
|
+
const index = parseDiff(indexCut.text)
|
|
640
|
+
|
|
641
|
+
return {
|
|
642
|
+
repo: repoRoot,
|
|
643
|
+
path: file,
|
|
644
|
+
origPath: origFile ?? null,
|
|
645
|
+
untracked,
|
|
646
|
+
worktree: { ...worktree, truncated: worktree.truncated || worktreeCut.truncated },
|
|
647
|
+
index: { ...index, truncated: index.truncated || indexCut.truncated },
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* 读最近的提交(只读 `git log`)。
|
|
653
|
+
*
|
|
654
|
+
* 用 NUL 分隔的 `--pretty=format:` 而不是默认格式:提交标题里可以有空格、引号、
|
|
655
|
+
* 甚至制表符,用分隔符切比按列对齐稳。空仓库(还没有任何提交)时 `git log` 以非零
|
|
656
|
+
* 退出,这里返回空列表而不是错误——"还没有提交"是正常状态。
|
|
657
|
+
*
|
|
658
|
+
* @param cwd - 会话工作区(绝对路径)。
|
|
659
|
+
* @param count - 条数,钳制在 1..50,默认 10。
|
|
660
|
+
* @returns `{ repo, commits: [{ sha, author, date, committedAt, refs, subject }] }`。
|
|
661
|
+
*/
|
|
662
|
+
export async function readLog(cwd, count = DEFAULT_COMMITS) {
|
|
663
|
+
const root = await runGit(cwd, ['rev-parse', '--show-toplevel'])
|
|
664
|
+
if (!root.ok) {
|
|
665
|
+
if (root.killed) throw Object.assign(new Error('git rev-parse timed out'), { code: 'timeout' })
|
|
666
|
+
if (root.code === 'ENOENT' && !fs.existsSync(cwd)) throw Object.assign(new Error('cwd does not exist'), { code: 'no-cwd' })
|
|
667
|
+
if (root.code === 'ENOENT') throw Object.assign(new Error('git executable not found'), { code: 'git-missing' })
|
|
668
|
+
return { repo: null, commits: [] }
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const repoRoot = root.stdout.split('\n')[0].trim()
|
|
672
|
+
if (!repoRoot) return { repo: null, commits: [] }
|
|
673
|
+
|
|
674
|
+
const requested = Number.isFinite(count) ? Math.floor(count) : DEFAULT_COMMITS
|
|
675
|
+
const limit = Math.min(Math.max(1, requested), MAX_COMMITS)
|
|
676
|
+
|
|
677
|
+
const result = await runGit(repoRoot, [
|
|
678
|
+
'log',
|
|
679
|
+
`-n${String(limit)}`,
|
|
680
|
+
'--no-color',
|
|
681
|
+
'--date=short',
|
|
682
|
+
'--pretty=format:%h%x00%an%x00%ad%x00%ct%x00%D%x00%s',
|
|
683
|
+
])
|
|
684
|
+
if (!result.ok) {
|
|
685
|
+
// 空仓库 / 无 HEAD:不是错误,就是还没有提交。
|
|
686
|
+
return { repo: repoRoot, commits: [] }
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const commits = result.stdout
|
|
690
|
+
.split('\n')
|
|
691
|
+
.filter(line => line.length > 0)
|
|
692
|
+
.map((line) => {
|
|
693
|
+
const [sha, author, date, seconds, refs, subject] = line.split('\0')
|
|
694
|
+
const at = Number(seconds)
|
|
695
|
+
return {
|
|
696
|
+
sha: sha ?? '',
|
|
697
|
+
author: author ?? '',
|
|
698
|
+
date: date ?? '',
|
|
699
|
+
committedAt: Number.isFinite(at) ? at * 1000 : null,
|
|
700
|
+
refs: refs ? refs.split(', ').filter(Boolean) : [],
|
|
701
|
+
subject: subject ?? '',
|
|
702
|
+
}
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
return { repo: repoRoot, commits }
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* 解析仓库根与目标提交的元信息(含父提交),`readCommit` / `readCommitDiff` 共用。
|
|
710
|
+
*
|
|
711
|
+
* 父提交直接从 `%P` 拿:`git log -1` 已经把它给了出来,不必再跑一次 `rev-parse`。
|
|
712
|
+
* 取**第一个**父提交作为比较基准——"这次提交改了什么"对合并提交也是这个意思,
|
|
713
|
+
* 而 `git show` 对合并提交默认给的是"组合差异",常常是空的,对用户没意义。
|
|
714
|
+
*
|
|
715
|
+
* @returns `{ repo, sha, shortSha, subject, author, date, committedAt, refs, parents, base }`;
|
|
716
|
+
* `base` 为空串表示这是根提交(与空树比较)。非仓库时 `repo` 为 null。
|
|
717
|
+
*/
|
|
718
|
+
async function resolveCommit(cwd, sha) {
|
|
719
|
+
const root = await runGit(cwd, ['rev-parse', '--show-toplevel'])
|
|
720
|
+
if (!root.ok) {
|
|
721
|
+
if (root.killed) throw Object.assign(new Error('git rev-parse timed out'), { code: 'timeout' })
|
|
722
|
+
if (root.code === 'ENOENT' && !fs.existsSync(cwd)) throw Object.assign(new Error('cwd does not exist'), { code: 'no-cwd' })
|
|
723
|
+
if (root.code === 'ENOENT') throw Object.assign(new Error('git executable not found'), { code: 'git-missing' })
|
|
724
|
+
return { repo: null, sha, shortSha: sha, subject: '', author: '', date: '', committedAt: null, refs: [], parents: [], base: null }
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const repoRoot = root.stdout.split('\n')[0].trim()
|
|
728
|
+
if (!repoRoot) return { repo: null, sha, shortSha: sha, subject: '', author: '', date: '', committedAt: null, refs: [], parents: [], base: null }
|
|
729
|
+
|
|
730
|
+
const result = await runGit(repoRoot, [
|
|
731
|
+
'log',
|
|
732
|
+
'-1',
|
|
733
|
+
'--no-color',
|
|
734
|
+
'--date=short',
|
|
735
|
+
'--pretty=format:%H%x00%h%x00%an%x00%ad%x00%ct%x00%D%x00%P%x00%s',
|
|
736
|
+
sha,
|
|
737
|
+
])
|
|
738
|
+
if (!result.ok) {
|
|
739
|
+
throw Object.assign(new Error(result.stderr.trim() || 'unknown commit'), { code: result.killed ? 'timeout' : 'unknown-commit' })
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const [full, short, author, date, seconds, refs, parents, subject] = result.stdout.split('\0')
|
|
743
|
+
const at = Number(seconds)
|
|
744
|
+
const parentList = typeof parents === 'string' && parents.length > 0 ? parents.split(' ').filter(Boolean) : []
|
|
745
|
+
|
|
746
|
+
return {
|
|
747
|
+
repo: repoRoot,
|
|
748
|
+
sha: full || sha,
|
|
749
|
+
shortSha: short || sha,
|
|
750
|
+
subject: subject ?? '',
|
|
751
|
+
author: author ?? '',
|
|
752
|
+
date: date ?? '',
|
|
753
|
+
committedAt: Number.isFinite(at) ? at * 1000 : null,
|
|
754
|
+
refs: refs ? refs.split(', ').filter(Boolean) : [],
|
|
755
|
+
parents: parentList,
|
|
756
|
+
base: parentList.length > 0 ? parentList[0] : null,
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** 这次提交的改动清单 + 元信息(只读 `git diff` / `git show`)。 */
|
|
761
|
+
export async function readCommit(cwd, sha) {
|
|
762
|
+
const commit = await resolveCommit(cwd, sha)
|
|
763
|
+
if (!commit.repo) return { repo: null, commit: null, files: [], truncated: false }
|
|
764
|
+
|
|
765
|
+
// 根提交没有父提交,改用 `show` 与空树比较;其余一律 diff 第一父提交。
|
|
766
|
+
const result = commit.base === null
|
|
767
|
+
? await runGit(commit.repo, ['show', '--format=', '--no-color', '--no-ext-diff', '--name-status', '-z', '-M', commit.sha])
|
|
768
|
+
: await runGit(commit.repo, ['diff', '--no-color', '--no-ext-diff', '--name-status', '-z', '-M', commit.base, commit.sha])
|
|
769
|
+
if (!result.ok) {
|
|
770
|
+
throw Object.assign(new Error(result.stderr.trim() || 'git diff failed'), { code: result.killed ? 'timeout' : 'git-failed' })
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const { files, truncated } = parseNameStatus(result.stdout)
|
|
774
|
+
return {
|
|
775
|
+
repo: commit.repo,
|
|
776
|
+
commit: {
|
|
777
|
+
sha: commit.sha,
|
|
778
|
+
shortSha: commit.shortSha,
|
|
779
|
+
subject: commit.subject,
|
|
780
|
+
author: commit.author,
|
|
781
|
+
date: commit.date,
|
|
782
|
+
committedAt: commit.committedAt,
|
|
783
|
+
refs: commit.refs,
|
|
784
|
+
merge: commit.parents.length > 1,
|
|
785
|
+
},
|
|
786
|
+
files,
|
|
787
|
+
truncated,
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* 读这次提交里某个文件的 diff(相对第一父提交)。
|
|
793
|
+
*
|
|
794
|
+
* 重命名/复制把原路径一并作为 pathspec 传进去,否则 git 只看到"新增 + 删除"而不是
|
|
795
|
+
* 一次移动。未跟踪文件不属于提交,因此这里没有 `--no-index` 那条分支。
|
|
796
|
+
*
|
|
797
|
+
* @returns `{ repo, path, file, origPath, sha, rows, additions, deletions, hunks, binary, truncated }`。
|
|
798
|
+
*/
|
|
799
|
+
export async function readCommitDiff(cwd, sha, file, origFile = null) {
|
|
800
|
+
const commit = await resolveCommit(cwd, sha)
|
|
801
|
+
if (!commit.repo) {
|
|
802
|
+
return { repo: null, sha, file, path: file, origPath: origFile, ...parseDiff(''), truncated: false }
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const pathspec = origFile === null || origFile === undefined || origFile === '' ? [file] : [file, origFile]
|
|
806
|
+
const result = commit.base === null
|
|
807
|
+
? await runGit(commit.repo, ['show', '--format=', '--no-color', '--no-ext-diff', '-M', commit.sha, '--', ...pathspec])
|
|
808
|
+
: await runGit(commit.repo, ['diff', '--no-color', '--no-ext-diff', '-M', commit.base, commit.sha, '--', ...pathspec])
|
|
809
|
+
if (!result.ok) {
|
|
810
|
+
throw Object.assign(new Error(result.stderr.trim() || 'git diff failed'), { code: result.killed ? 'timeout' : 'git-failed' })
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const cut = truncateDiff(result.stdout)
|
|
814
|
+
const parsed = parseDiff(cut.text)
|
|
815
|
+
return {
|
|
816
|
+
repo: commit.repo,
|
|
817
|
+
sha: commit.sha,
|
|
818
|
+
shortSha: commit.shortSha,
|
|
819
|
+
file,
|
|
820
|
+
path: file,
|
|
821
|
+
origPath: origFile ?? null,
|
|
822
|
+
...parsed,
|
|
823
|
+
truncated: parsed.truncated || cut.truncated,
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/* ------------------------------------------------- 会话改动(按会话归因) */
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* "哪个会话写过哪个文件"——事实源是 fs seam 派发的两类事件:
|
|
831
|
+
*
|
|
832
|
+
* - `fs/write-intent` / `fs/edit-intent`(waterfall,参数 `(target, exec)`):这次**要**动
|
|
833
|
+
* 哪个文件。`dsh-tool-fs` 的写入/编辑工具与 `dsh-tool-str-replace-editor` 都走 `ctx.fs`,
|
|
834
|
+
* 因此都会发这两个意图事件。
|
|
835
|
+
* - `fs/observed`(emit,参数 `(target, observation, exec)`):那一下**之后**文件是 `present`
|
|
836
|
+
* 还是 `absent`。注意它对**读取**也发("编辑前必须先读"那个策略插件就是靠它),所以不能
|
|
837
|
+
* 只看它 —— 必须与意图配对:同一个工具调用里先有意图、后有观察,才算这次改动落了地。
|
|
838
|
+
*
|
|
839
|
+
* `exec.agent.session.header` 同时给出 `id`(哪个会话)与 `cwd`(哪个工作区),所以"谁改的"
|
|
840
|
+
* 不用猜。只记 cwd 之内的路径(git 只关心这些),每个会话与全局都有上限,避免长会话吃内存。
|
|
841
|
+
*
|
|
842
|
+
* 已知不覆盖(界面上如实标注,不假装全知):
|
|
843
|
+
* - agent 通过 bash 间接改的文件(`sed -i`、脚本、格式化、`git` 操作);
|
|
844
|
+
* - 本进程启动**之前**的改动(fs 观察不持久化)——所以每个会话都报一个 `since`。
|
|
845
|
+
*/
|
|
846
|
+
|
|
847
|
+
/** 一次最多给多少个改动文件算"这一版"的签名(超出的那些只显示"未审",不报错)。 */
|
|
848
|
+
const REVIEW_SIGNATURE_MAX = 200
|
|
849
|
+
/** 单个会话最多记多少个文件;超出只计数(`dropped`),不做 LRU 淘汰。 */
|
|
850
|
+
const SESSION_CHANGE_MAX_FILES = 500
|
|
851
|
+
/** 最多同时记多少个会话(按最近一次改动淘汰最旧的)。 */
|
|
852
|
+
const SESSION_CHANGE_MAX_SESSIONS = 32
|
|
853
|
+
/** 单个会话最多记多少个"可能相关"的路径(命令旁听来的,优先级低于确定改动)。 */
|
|
854
|
+
const SESSION_CHANGE_MAX_POSSIBLE = 300
|
|
855
|
+
/** 每个会话留多少条命令历史(面板悬停时能看到"跑过什么")。 */
|
|
856
|
+
const SESSION_COMMAND_MAX = 20
|
|
857
|
+
/** 命令在界面上的显示上限(超长命令截断,别把整行撑爆)。 */
|
|
858
|
+
const COMMAND_TEXT_MAX = 140
|
|
859
|
+
/** 一条命令里最多抽多少个路径(防御性上限)。 */
|
|
860
|
+
const COMMAND_PATHS_MAX = 20
|
|
861
|
+
|
|
862
|
+
/** sessionId → { cwd, since, files: Map<relPath, {...}>, dropped, unconfirmed }。 */
|
|
863
|
+
const sessionChanges = new Map()
|
|
864
|
+
/** 本次监听开始的时刻 —— 界面说的"自 … 起记录"用的是它(不是"第一条改动的时间")。 */
|
|
865
|
+
let sessionChangeTrackingSince = null
|
|
866
|
+
/** callId(没有就退化成 sessionId+路径)→ 尚未配对到 `fs/observed` 的意图。 */
|
|
867
|
+
const pendingChangeIntents = new Map()
|
|
868
|
+
/**
|
|
869
|
+
* 从会话取"当前回合号"的读取器。
|
|
870
|
+
*
|
|
871
|
+
* `sessionProjections` 是**软依赖**(`ctx.inject` 动态等它到齐):拿不到就记 `null`,
|
|
872
|
+
* 归因照常工作 —— 绝不因为少一个服务把整块降级掉。
|
|
873
|
+
*/
|
|
874
|
+
let sessionTurnReader = null
|
|
875
|
+
/**
|
|
876
|
+
* 宿主 `sessions` 服务(枚举所有活着的会话)—— 同样是软依赖:拿不到时
|
|
877
|
+
* `session-statuses` 端点报"不可用",侧栏那枚 chip 就不画,其余功能照旧。
|
|
878
|
+
*/
|
|
879
|
+
let hostSessions = null
|
|
880
|
+
|
|
881
|
+
/** 这次调用发生在第几回合(拿不到就是 null,界面据此不显示回合信息)。 */
|
|
882
|
+
function currentTurnOf(exec) {
|
|
883
|
+
if (sessionTurnReader === null) return null
|
|
884
|
+
const session = exec && exec.agent ? exec.agent.session : undefined
|
|
885
|
+
if (session === undefined || session === null) return null
|
|
886
|
+
return sessionTurnReader(session)
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** 这个会话第一次出现在记录里的时刻(客户端据此说"自 … 起")。 */
|
|
890
|
+
function sessionChangeEntry(sessionId, cwd) {
|
|
891
|
+
let entry = sessionChanges.get(sessionId)
|
|
892
|
+
if (entry === undefined) {
|
|
893
|
+
entry = {
|
|
894
|
+
cwd,
|
|
895
|
+
since: Date.now(),
|
|
896
|
+
files: new Map(),
|
|
897
|
+
dropped: 0,
|
|
898
|
+
// 命令旁听来的"可能相关":与 files 分开存,绝不冒充"确定改过"。
|
|
899
|
+
possible: new Map(),
|
|
900
|
+
possibleDropped: 0,
|
|
901
|
+
commands: [],
|
|
902
|
+
}
|
|
903
|
+
sessionChanges.set(sessionId, entry)
|
|
904
|
+
if (sessionChanges.size > SESSION_CHANGE_MAX_SESSIONS) {
|
|
905
|
+
// 先淘汰最久没动过的那个(Map 保持插入序,删第一个就是最旧的)。
|
|
906
|
+
const oldest = sessionChanges.keys().next().value
|
|
907
|
+
if (oldest !== undefined && oldest !== sessionId) sessionChanges.delete(oldest)
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
if (cwd !== undefined && cwd !== null) entry.cwd = cwd
|
|
911
|
+
return entry
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** 把绝对路径折成 cwd 之内的 posix 相对路径;在 cwd 之外或拿不到都返回 null。 */
|
|
915
|
+
function sessionRelativePath(cwd, absolutePath) {
|
|
916
|
+
if (typeof cwd !== 'string' || cwd.length === 0) return null
|
|
917
|
+
if (typeof absolutePath !== 'string' || absolutePath.length === 0) return null
|
|
918
|
+
if (!path.isAbsolute(absolutePath)) return null
|
|
919
|
+
const relative = path.relative(cwd, absolutePath)
|
|
920
|
+
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) return null
|
|
921
|
+
return relative.split(path.sep).join('/')
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* 会自己改文件的"命令行工具":bash / pwsh / 各种 shell。
|
|
926
|
+
*
|
|
927
|
+
* 用名字前缀匹配而不是精确清单:产品里有 `bash`、`bash-persistent`、`pwsh`、`pwsh-persistent`
|
|
928
|
+
* 等等,将来还会加,硬编码清单一定会过期。
|
|
929
|
+
*/
|
|
930
|
+
const SHELL_TOOL_RE = /^(?:bash|pwsh|sh|zsh|fish|shell|cmd|powershell|exec_command|run_command|terminal)/i
|
|
931
|
+
|
|
932
|
+
/** 从工具调用参数里取命令行文本(不同 shell 工具的字段名不一样,按常见几个找)。 */
|
|
933
|
+
function shellCommandOf(exec) {
|
|
934
|
+
if (exec === undefined || exec === null) return null
|
|
935
|
+
if (typeof exec.name !== 'string' || !SHELL_TOOL_RE.test(exec.name)) return null
|
|
936
|
+
const args = exec.arguments
|
|
937
|
+
if (args === null || typeof args !== 'object') return null
|
|
938
|
+
for (const key of ['command', 'cmd', 'script', 'input', 'line']) {
|
|
939
|
+
const value = args[key]
|
|
940
|
+
if (typeof value === 'string' && value.trim().length > 0) return value
|
|
941
|
+
}
|
|
942
|
+
return null
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** 按 `; && || | 换行` 切分命令(只为抽路径,不做真正的 shell 解析)。 */
|
|
946
|
+
function shellSegments(command) {
|
|
947
|
+
return command
|
|
948
|
+
.split(/\r?\n|;|&&|\|\||\|/)
|
|
949
|
+
.map(segment => segment.trim())
|
|
950
|
+
.filter(segment => segment.length > 0)
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/** 极简分词:支持单双引号(不处理转义与嵌套),够抽路径用。 */
|
|
954
|
+
function shellTokens(segment) {
|
|
955
|
+
const tokens = []
|
|
956
|
+
let current = ''
|
|
957
|
+
let quote = null
|
|
958
|
+
for (const ch of segment) {
|
|
959
|
+
if (quote !== null) {
|
|
960
|
+
if (ch === quote) quote = null
|
|
961
|
+
else current += ch
|
|
962
|
+
continue
|
|
963
|
+
}
|
|
964
|
+
if (ch === '"' || ch === "'") {
|
|
965
|
+
quote = ch
|
|
966
|
+
continue
|
|
967
|
+
}
|
|
968
|
+
if (ch === ' ' || ch === '\t') {
|
|
969
|
+
if (current.length > 0) {
|
|
970
|
+
tokens.push(current)
|
|
971
|
+
current = ''
|
|
972
|
+
}
|
|
973
|
+
continue
|
|
974
|
+
}
|
|
975
|
+
current += ch
|
|
976
|
+
}
|
|
977
|
+
if (current.length > 0) tokens.push(current)
|
|
978
|
+
return tokens
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** 环境变量赋值、sudo/env 之类的包装前缀:抽路径时要跳过。 */
|
|
982
|
+
const SHELL_PREFIX_RE = /^(?:[A-Za-z_][A-Za-z0-9_]*=|sudo$|env$|time$|nohup$|command$|builtin$|exec$|then$|do$|if$|while$)/
|
|
983
|
+
/** 写入类命令:它们改文件,"目标"在哪一个参数上按命令而定。 */
|
|
984
|
+
const WRITE_COMMANDS = new Set([
|
|
985
|
+
'mv', 'cp', 'ln', 'install', 'rsync', 'truncate', 'touch', 'rm', 'unlink',
|
|
986
|
+
'sed', 'perl', 'tee', 'dd', 'patch', 'chmod', 'chown', 'gzip', 'gunzip', 'tar', 'unzip', 'zip',
|
|
987
|
+
])
|
|
988
|
+
/** 只读命令:即使带了参数也不要记成"可能改过"。 */
|
|
989
|
+
const READ_COMMANDS = new Set([
|
|
990
|
+
'cat', 'less', 'more', 'head', 'tail', 'grep', 'rg', 'ls', 'find', 'stat', 'wc', 'file', 'du',
|
|
991
|
+
'diff', 'git', 'echo', 'printf', 'pwd', 'which', 'type', 'env', 'date', 'sort', 'uniq', 'awk',
|
|
992
|
+
'jq', 'node', 'python', 'python3', 'true', 'false', 'test', 'cd', 'export', 'set', 'source',
|
|
993
|
+
])
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* 从一条命令里抽出"可能被改动的路径"。
|
|
997
|
+
*
|
|
998
|
+
* 这是**启发式**,只做三类有把握的事,其余一律退回"命令级提示"(不声称改过哪些路径):
|
|
999
|
+
* 1. 重定向与 `tee` 的目标(`>`、`>>`、`tee file`);
|
|
1000
|
+
* 2. 原地编辑(`sed -i`、`perl -i`)与写入类命令(`mv`/`cp`/`rm`/`dd of=`…)的目标参数;
|
|
1001
|
+
* 3. `git checkout|restore <path>` 这类"把文件改回某个版本"的命令。
|
|
1002
|
+
* 只读命令(`cat`、`grep`、`git status`…)即使带路径也不记。
|
|
1003
|
+
* @returns {{ path: string, reason: string }[]} 去重后的候选路径(原样 token,未解析)。
|
|
1004
|
+
*/
|
|
1005
|
+
function possiblePathsOf(command) {
|
|
1006
|
+
const found = new Map()
|
|
1007
|
+
const add = (token, reason) => {
|
|
1008
|
+
const cleaned = String(token).replace(/^["']|["']$/g, '')
|
|
1009
|
+
if (cleaned.length === 0 || cleaned === '/' || cleaned.startsWith('/dev/')) return
|
|
1010
|
+
if (/^&?\d+$/.test(cleaned) || cleaned.startsWith('&')) return
|
|
1011
|
+
if (cleaned.startsWith('-')) return
|
|
1012
|
+
if (cleaned.includes('$') || cleaned.includes('*') || cleaned.includes('?')) return
|
|
1013
|
+
if (found.has(cleaned)) return
|
|
1014
|
+
found.set(cleaned, reason.slice(0, 80))
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
for (const segment of shellSegments(command)) {
|
|
1018
|
+
// 1) 重定向与 tee:即使命令本身不认识也要抽。
|
|
1019
|
+
const redirects = segment.matchAll(/(?:^|[^0-9<>])>>?\s*([^\s;&|<>()]+)/g)
|
|
1020
|
+
for (const match of redirects) add(match[1], segment)
|
|
1021
|
+
const tees = segment.matchAll(/\btee\s+(?:-[A-Za-z-]+\s+)*([^\s;&|<>()]+)/g)
|
|
1022
|
+
for (const match of tees) add(match[1], segment)
|
|
1023
|
+
|
|
1024
|
+
const tokens = shellTokens(segment).filter(token => !SHELL_PREFIX_RE.test(token))
|
|
1025
|
+
let index = 0
|
|
1026
|
+
while (index < tokens.length && SHELL_PREFIX_RE.test(tokens[index])) index += 1
|
|
1027
|
+
const head = (tokens[index] || '').toLowerCase()
|
|
1028
|
+
const args = tokens.slice(index + 1)
|
|
1029
|
+
if (head.length === 0) continue
|
|
1030
|
+
|
|
1031
|
+
// 2) 原地编辑:`sed -i … <file>` / `perl -i … <file>` —— 取该段最后一个非 flag 参数。
|
|
1032
|
+
if ((head === 'sed' || head === 'perl') && args.some(arg => /^-[A-Za-z]*i/.test(arg))) {
|
|
1033
|
+
const last = [...args].reverse().find(arg => !arg.startsWith('-'))
|
|
1034
|
+
if (last !== undefined) add(last, segment)
|
|
1035
|
+
continue
|
|
1036
|
+
}
|
|
1037
|
+
// 3) `dd of=…`
|
|
1038
|
+
if (head === 'dd') {
|
|
1039
|
+
for (const arg of args) if (arg.startsWith('of=')) add(arg.slice(3), segment)
|
|
1040
|
+
continue
|
|
1041
|
+
}
|
|
1042
|
+
// 4) `git checkout|restore <path>`(会覆盖工作区文件)
|
|
1043
|
+
if (head === 'git') {
|
|
1044
|
+
const sub = (args[0] || '').toLowerCase()
|
|
1045
|
+
if (sub === 'checkout' || sub === 'restore') {
|
|
1046
|
+
const target = args.slice(1).find(arg => !arg.startsWith('-'))
|
|
1047
|
+
if (target !== undefined && target !== '.') add(target, segment)
|
|
1048
|
+
}
|
|
1049
|
+
continue
|
|
1050
|
+
}
|
|
1051
|
+
// 5) 写入类命令:`mv/cp/ln/install/rsync` 记目标(最后一个参数);其余记所有非 flag 参数。
|
|
1052
|
+
if (WRITE_COMMANDS.has(head)) {
|
|
1053
|
+
if (/^(?:chmod|chown|tar|gzip|gunzip|zip)$/.test(head)) continue
|
|
1054
|
+
// `mv` 的源也会"变"(消失),所以源与目标都记;其余写入类命令只记目标。
|
|
1055
|
+
if (head === 'mv') {
|
|
1056
|
+
for (const arg of args) if (!arg.startsWith('-')) add(arg, segment)
|
|
1057
|
+
continue
|
|
1058
|
+
}
|
|
1059
|
+
if (/^(?:cp|ln|install|rsync|patch)$/.test(head)) {
|
|
1060
|
+
const target = [...args].reverse().find(arg => !arg.startsWith('-'))
|
|
1061
|
+
if (target !== undefined) add(target, segment)
|
|
1062
|
+
continue
|
|
1063
|
+
}
|
|
1064
|
+
for (const arg of args) if (!arg.startsWith('-')) add(arg, segment)
|
|
1065
|
+
continue
|
|
1066
|
+
}
|
|
1067
|
+
// 其余(含只读命令、以及 npm/pnpm/prettier 这类"可能改了但说不清改哪"的)一律退回命令级提示。
|
|
1068
|
+
void READ_COMMANDS
|
|
1069
|
+
}
|
|
1070
|
+
return [...found].slice(0, COMMAND_PATHS_MAX).map(([pathToken, reason]) => ({ path: pathToken, reason }))
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/** 命令是不是"看起来只读"(用于命令历史里的标注)。 */
|
|
1074
|
+
function commandLooksReadOnly(command) {
|
|
1075
|
+
const heads = shellSegments(command).map(segment => shellTokens(segment).filter(token => !SHELL_PREFIX_RE.test(token))[0] || '')
|
|
1076
|
+
if (heads.length === 0) return false
|
|
1077
|
+
return heads.every(head => READ_COMMANDS.has(head.toLowerCase()))
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/** 结果看起来是失败吗(失败的命令不改文件,就不算"可能相关")。 */
|
|
1081
|
+
function resultFailed(result) {
|
|
1082
|
+
if (result === null || typeof result !== 'object') return false
|
|
1083
|
+
if (result.ok === false) return true
|
|
1084
|
+
if (result.isError === true) return true
|
|
1085
|
+
return result.error !== undefined && result.error !== null
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* 把"第几回合"记到一条记录上(文件 / 可能相关)。
|
|
1090
|
+
*
|
|
1091
|
+
* `lastTurn` 是最近一次动它的回合,`turns` 是动过它的回合列表(去重、最多 8 个)——
|
|
1092
|
+
* 界面用前者做"只看最近一轮",后者留给悬停说明"这个文件被两轮动过"。
|
|
1093
|
+
*/
|
|
1094
|
+
function noteTurnOfRecord(record, turn) {
|
|
1095
|
+
record.lastTurn = turn
|
|
1096
|
+
if (turn === null) return
|
|
1097
|
+
if (!Array.isArray(record.turns)) record.turns = []
|
|
1098
|
+
if (record.turns[record.turns.length - 1] !== turn && record.turns.indexOf(turn) === -1) record.turns.push(turn)
|
|
1099
|
+
if (record.turns.length > 8) record.turns.splice(0, record.turns.length - 8)
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/** 从工具执行上下文里取会话身份与工作区;非 agent 调用返回 null。 */
|
|
1103
|
+
function sessionIdentityOf(exec) {
|
|
1104
|
+
const header = exec && exec.agent && exec.agent.session ? exec.agent.session.header : undefined
|
|
1105
|
+
if (header === undefined || header === null) return null
|
|
1106
|
+
if (typeof header.id !== 'string' || header.id.length === 0) return null
|
|
1107
|
+
const cwd = typeof header.cwd === 'string' && header.cwd.length > 0 ? header.cwd : undefined
|
|
1108
|
+
return { sessionId: header.id, cwd, callId: exec && typeof exec.callId === 'string' ? exec.callId : null }
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/** 意图的配对键:优先用调用 id(一次调用一个意图),没有就退化成"会话 + 路径"。 */
|
|
1112
|
+
function pendingKeyOf(identity, relative) {
|
|
1113
|
+
return identity.callId !== null ? `call:${identity.callId}` : `path:${identity.sessionId}\u0000${relative}`;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* 记一次写入/编辑意图(事件回调;绝不抛,也绝不返回 next —— 这是 waterfall 的旁听)。
|
|
1118
|
+
* @param target - fs 目标(`displayPath` 是绝对路径)。
|
|
1119
|
+
* @param exec - 工具执行上下文。
|
|
1120
|
+
* @param kind - `"write"` 或 `"edit"`。
|
|
1121
|
+
*/
|
|
1122
|
+
function noteChangeIntent(target, exec, kind) {
|
|
1123
|
+
try {
|
|
1124
|
+
const identity = sessionIdentityOf(exec)
|
|
1125
|
+
if (identity === null) return
|
|
1126
|
+
const absolute = target && typeof target.displayPath === 'string' ? target.displayPath : null
|
|
1127
|
+
const relative = sessionRelativePath(identity.cwd, absolute)
|
|
1128
|
+
if (relative === null) return
|
|
1129
|
+
pendingChangeIntents.set(pendingKeyOf(identity, relative), {
|
|
1130
|
+
sessionId: identity.sessionId,
|
|
1131
|
+
cwd: identity.cwd,
|
|
1132
|
+
relative,
|
|
1133
|
+
kind,
|
|
1134
|
+
at: Date.now(),
|
|
1135
|
+
})
|
|
1136
|
+
// 配对表也会长:清掉明显过期的(超过 5 分钟还没等到观察的意图)。
|
|
1137
|
+
if (pendingChangeIntents.size > 512) {
|
|
1138
|
+
const cutoff = Date.now() - 5 * 60 * 1000
|
|
1139
|
+
for (const [key, entry] of pendingChangeIntents) if (entry.at < cutoff) pendingChangeIntents.delete(key)
|
|
1140
|
+
}
|
|
1141
|
+
} catch {
|
|
1142
|
+
// 旁听失败不影响写入本身。
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* 记一次观察:只有配得上"意图"的才算这次改动落了地(读取引起的观察一律忽略)。
|
|
1148
|
+
* @param target - fs 目标。
|
|
1149
|
+
* @param exec - 工具执行上下文。
|
|
1150
|
+
*/
|
|
1151
|
+
function noteChangeObservation(target, exec) {
|
|
1152
|
+
try {
|
|
1153
|
+
const identity = sessionIdentityOf(exec)
|
|
1154
|
+
if (identity === null) return
|
|
1155
|
+
const absolute = target && typeof target.displayPath === 'string' ? target.displayPath : null
|
|
1156
|
+
const relative = sessionRelativePath(identity.cwd, absolute)
|
|
1157
|
+
if (relative === null) return
|
|
1158
|
+
const key = pendingKeyOf(identity, relative)
|
|
1159
|
+
const intent = pendingChangeIntents.get(key)
|
|
1160
|
+
if (intent === undefined) return
|
|
1161
|
+
pendingChangeIntents.delete(key)
|
|
1162
|
+
|
|
1163
|
+
const entry = sessionChangeEntry(intent.sessionId, intent.cwd)
|
|
1164
|
+
let file = entry.files.get(intent.relative)
|
|
1165
|
+
if (file === undefined) {
|
|
1166
|
+
if (entry.files.size >= SESSION_CHANGE_MAX_FILES) {
|
|
1167
|
+
entry.dropped += 1
|
|
1168
|
+
return
|
|
1169
|
+
}
|
|
1170
|
+
file = { writes: 0, edits: 0, firstAt: intent.at, lastAt: intent.at, lastTurn: null, turns: [] }
|
|
1171
|
+
entry.files.set(intent.relative, file)
|
|
1172
|
+
}
|
|
1173
|
+
if (intent.kind === 'write') file.writes += 1
|
|
1174
|
+
else file.edits += 1
|
|
1175
|
+
file.lastAt = Date.now()
|
|
1176
|
+
noteTurnOfRecord(file, currentTurnOf(exec))
|
|
1177
|
+
} catch {
|
|
1178
|
+
// 同上:旁听不参与写入成败。
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* 记一次 shell 命令(事件回调;旁听,绝不抛,也绝不吞掉 waterfall 的返回值)。
|
|
1184
|
+
*
|
|
1185
|
+
* 两类信息分开记:
|
|
1186
|
+
* - `commands`:跑过什么(含看似只读的、以及失败的),供界面说"跑过这些命令";
|
|
1187
|
+
* - `possible`:从命令里抽出来的**候选路径**,只进"可能相关"这一档,绝不写进 `files`。
|
|
1188
|
+
*
|
|
1189
|
+
* 失败的命令不改文件:只进 `commands`,不参与路径归因。
|
|
1190
|
+
*/
|
|
1191
|
+
function noteShellCommand(exec, result) {
|
|
1192
|
+
try {
|
|
1193
|
+
const command = shellCommandOf(exec)
|
|
1194
|
+
if (command === null) return
|
|
1195
|
+
const identity = sessionIdentityOf(exec)
|
|
1196
|
+
if (identity === null) return
|
|
1197
|
+
const cwd = typeof identity.cwd === 'string' && identity.cwd.length > 0 ? identity.cwd : null
|
|
1198
|
+
const entry = sessionChangeEntry(identity.sessionId, identity.cwd)
|
|
1199
|
+
const failed = resultFailed(result)
|
|
1200
|
+
const turn = currentTurnOf(exec)
|
|
1201
|
+
entry.commands.push({
|
|
1202
|
+
tool: exec.name,
|
|
1203
|
+
command: command.replace(/\s+/g, ' ').trim().slice(0, COMMAND_TEXT_MAX),
|
|
1204
|
+
at: Date.now(),
|
|
1205
|
+
turn,
|
|
1206
|
+
failed,
|
|
1207
|
+
readOnly: commandLooksReadOnly(command),
|
|
1208
|
+
})
|
|
1209
|
+
if (entry.commands.length > SESSION_COMMAND_MAX) {
|
|
1210
|
+
entry.commands.splice(0, entry.commands.length - SESSION_COMMAND_MAX)
|
|
1211
|
+
}
|
|
1212
|
+
if (failed || cwd === null) return
|
|
1213
|
+
|
|
1214
|
+
for (const candidate of possiblePathsOf(command)) {
|
|
1215
|
+
const absolute = path.isAbsolute(candidate.path) ? candidate.path : path.resolve(cwd, candidate.path)
|
|
1216
|
+
const relative = sessionRelativePath(cwd, absolute)
|
|
1217
|
+
if (relative === null) continue
|
|
1218
|
+
let item = entry.possible.get(relative)
|
|
1219
|
+
if (item === undefined) {
|
|
1220
|
+
if (entry.possible.size >= SESSION_CHANGE_MAX_POSSIBLE) {
|
|
1221
|
+
entry.possibleDropped += 1
|
|
1222
|
+
continue
|
|
1223
|
+
}
|
|
1224
|
+
item = { hits: 0, firstAt: Date.now(), lastAt: Date.now(), reason: candidate.reason, lastTurn: turn, turns: [] }
|
|
1225
|
+
entry.possible.set(relative, item)
|
|
1226
|
+
}
|
|
1227
|
+
item.hits += 1
|
|
1228
|
+
item.lastAt = Date.now()
|
|
1229
|
+
noteTurnOfRecord(item, turn)
|
|
1230
|
+
// 最近一次提到它的命令片段更有参考价值。
|
|
1231
|
+
item.reason = candidate.reason
|
|
1232
|
+
}
|
|
1233
|
+
} catch {
|
|
1234
|
+
// 旁听失败不影响命令本身。
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* 把某个会话的改动读成端点的载荷(只含 cwd 之内的路径,按路径排序)。
|
|
1240
|
+
*
|
|
1241
|
+
* 两条诚实性检查:
|
|
1242
|
+
* - 会话记录里的 cwd 与请求的 cwd 不一致时返回**空清单**(`cwdMismatch`):记的是相对
|
|
1243
|
+
* 那个工作区的路径,跟另一个工作区对账只会标错行。
|
|
1244
|
+
* - `unconfirmed` 只数**这个会话**的待配对意图(不是全局),免得把别的会话的在途写入报成它的。
|
|
1245
|
+
*/
|
|
1246
|
+
export function readSessionChanges(cwd, sessionId) {
|
|
1247
|
+
const entry = sessionId === null || sessionId === undefined ? undefined : sessionChanges.get(sessionId)
|
|
1248
|
+
const mismatched = entry !== undefined && entry.cwd !== undefined && entry.cwd !== null && entry.cwd !== cwd
|
|
1249
|
+
const files = []
|
|
1250
|
+
if (entry !== undefined && !mismatched) {
|
|
1251
|
+
for (const [relative, file] of entry.files) {
|
|
1252
|
+
files.push({
|
|
1253
|
+
path: relative,
|
|
1254
|
+
file: relative,
|
|
1255
|
+
writes: file.writes,
|
|
1256
|
+
edits: file.edits,
|
|
1257
|
+
firstAt: file.firstAt,
|
|
1258
|
+
lastAt: file.lastAt,
|
|
1259
|
+
lastTurn: file.lastTurn === undefined ? null : file.lastTurn,
|
|
1260
|
+
turns: Array.isArray(file.turns) ? file.turns.slice() : [],
|
|
1261
|
+
})
|
|
1262
|
+
}
|
|
1263
|
+
files.sort((left, right) => (left.file < right.file ? -1 : left.file > right.file ? 1 : 0))
|
|
1264
|
+
}
|
|
1265
|
+
const possible = []
|
|
1266
|
+
if (entry !== undefined && !mismatched) {
|
|
1267
|
+
for (const [relative, item] of entry.possible) {
|
|
1268
|
+
possible.push({
|
|
1269
|
+
path: relative,
|
|
1270
|
+
file: relative,
|
|
1271
|
+
hits: item.hits,
|
|
1272
|
+
firstAt: item.firstAt,
|
|
1273
|
+
lastAt: item.lastAt,
|
|
1274
|
+
reason: item.reason,
|
|
1275
|
+
lastTurn: item.lastTurn === undefined ? null : item.lastTurn,
|
|
1276
|
+
})
|
|
1277
|
+
}
|
|
1278
|
+
possible.sort((left, right) => (left.file < right.file ? -1 : left.file > right.file ? 1 : 0))
|
|
1279
|
+
}
|
|
1280
|
+
let unconfirmed = 0
|
|
1281
|
+
if (sessionId !== null && sessionId !== undefined) {
|
|
1282
|
+
for (const intent of pendingChangeIntents.values()) if (intent.sessionId === sessionId) unconfirmed += 1
|
|
1283
|
+
}
|
|
1284
|
+
return {
|
|
1285
|
+
repo: cwd,
|
|
1286
|
+
sessionId: sessionId ?? null,
|
|
1287
|
+
tracking: true,
|
|
1288
|
+
// 记录是在宿主进程里做的、不持久化:客户端据此说"自 … 起",而不是暗示"这个会话从头到现在"。
|
|
1289
|
+
since: sessionChangeTrackingSince,
|
|
1290
|
+
// 这个会话第一次被记到改动的时刻(没有改动就是 null)。
|
|
1291
|
+
firstChangeAt: entry === undefined ? null : entry.since,
|
|
1292
|
+
files,
|
|
1293
|
+
dropped: entry === undefined ? 0 : entry.dropped,
|
|
1294
|
+
// 命令旁听的结果:`possible` 是"可能相关"的候选路径(启发式),`commands` 是跑过的命令。
|
|
1295
|
+
possible,
|
|
1296
|
+
possibleDropped: entry === undefined ? 0 : entry.possibleDropped,
|
|
1297
|
+
commands: entry === undefined || mismatched ? [] : entry.commands.slice(),
|
|
1298
|
+
unconfirmed,
|
|
1299
|
+
cwdMismatch: mismatched,
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/** 挂上两个事件监听(由 `apply` 调用)。 */
|
|
1304
|
+
function trackSessionChanges(ctx) {
|
|
1305
|
+
return ctx.effect(() => {
|
|
1306
|
+
sessionChangeTrackingSince = Date.now()
|
|
1307
|
+
const stops = [
|
|
1308
|
+
ctx.on('fs/write-intent', (target, exec) => {
|
|
1309
|
+
noteChangeIntent(target, exec, 'write')
|
|
1310
|
+
}),
|
|
1311
|
+
ctx.on('fs/edit-intent', (target, exec) => {
|
|
1312
|
+
noteChangeIntent(target, exec, 'edit')
|
|
1313
|
+
}),
|
|
1314
|
+
ctx.on('fs/observed', (target, observation, exec) => {
|
|
1315
|
+
noteChangeObservation(target, exec)
|
|
1316
|
+
}),
|
|
1317
|
+
// 命令旁听:`tools/post-execute` 是 waterfall,**必须把 next() 的判定原样透传**
|
|
1318
|
+
// (兜底是 `{ kind: 'accept' }`,吞掉就等于把工具结果的裁决改掉了)。
|
|
1319
|
+
ctx.on('tools/post-execute', (exec, result, next) => {
|
|
1320
|
+
noteShellCommand(exec, result)
|
|
1321
|
+
return next()
|
|
1322
|
+
}),
|
|
1323
|
+
]
|
|
1324
|
+
// 回合号来自宿主投影 `turnBoundary`。软依赖:这个服务没到齐时 turn 记 null,
|
|
1325
|
+
// 归因、面板、胶囊一切照旧(只是不显示"第几轮")。
|
|
1326
|
+
if (typeof ctx.inject === 'function') {
|
|
1327
|
+
if (typeof ctx.inject === 'function') {
|
|
1328
|
+
stops.push(ctx.inject(['sessions'], (scope) => {
|
|
1329
|
+
hostSessions = scope.sessions
|
|
1330
|
+
return () => {
|
|
1331
|
+
hostSessions = null
|
|
1332
|
+
}
|
|
1333
|
+
}))
|
|
1334
|
+
}
|
|
1335
|
+
stops.push(ctx.inject(['sessionProjections'], (scope) => {
|
|
1336
|
+
sessionTurnReader = (session) => {
|
|
1337
|
+
try {
|
|
1338
|
+
const state = scope.sessionProjections.stateOf(session, 'turnBoundary')
|
|
1339
|
+
const turn = state === undefined || state === null ? null : state.lastTurn
|
|
1340
|
+
return typeof turn === 'number' && turn >= 0 ? turn : null
|
|
1341
|
+
} catch {
|
|
1342
|
+
return null
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
return () => {
|
|
1346
|
+
sessionTurnReader = null
|
|
1347
|
+
}
|
|
1348
|
+
}))
|
|
1349
|
+
}
|
|
1350
|
+
return () => {
|
|
1351
|
+
for (const stop of stops) if (typeof stop === 'function') stop()
|
|
1352
|
+
sessionChanges.clear()
|
|
1353
|
+
pendingChangeIntents.clear()
|
|
1354
|
+
sessionChangeTrackingSince = null
|
|
1355
|
+
}
|
|
1356
|
+
}, 'dsh-hud: session change tracking')
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/** 端点表:全部只读;新增端点时在这里登记。 */
|
|
1360
|
+
const ENDPOINTS = {
|
|
1361
|
+
async status(args) {
|
|
1362
|
+
const input = args && typeof args === 'object' ? args : {}
|
|
1363
|
+
const checked = validateCwd(input.cwd)
|
|
1364
|
+
if (!checked.ok) throw Object.assign(new Error(checked.message), { code: checked.code })
|
|
1365
|
+
const status = await readStatusCached(checked.value)
|
|
1366
|
+
// 「审阅进度」要的"这一版"签名:只在客户端明确要的时候才算(胶囊那一路的轮询不用付这个钱)。
|
|
1367
|
+
if (input.review === true && Array.isArray(status.changes) && status.changes.length > 0) {
|
|
1368
|
+
status.review = await readReviewSignatures(
|
|
1369
|
+
checked.value,
|
|
1370
|
+
status.changes.slice(0, REVIEW_SIGNATURE_MAX).map(change => change.file),
|
|
1371
|
+
)
|
|
1372
|
+
}
|
|
1373
|
+
return status
|
|
1374
|
+
},
|
|
1375
|
+
|
|
1376
|
+
async 'session-statuses'() {
|
|
1377
|
+
return readSessionOverview()
|
|
1378
|
+
},
|
|
1379
|
+
|
|
1380
|
+
async diff(args) {
|
|
1381
|
+
const input = args && typeof args === 'object' ? args : {}
|
|
1382
|
+
const cwd = validateCwd(input.cwd)
|
|
1383
|
+
if (!cwd.ok) throw Object.assign(new Error(cwd.message), { code: cwd.code })
|
|
1384
|
+
const file = validateDiffPath(input.path)
|
|
1385
|
+
if (!file.ok) throw Object.assign(new Error(file.message), { code: file.code })
|
|
1386
|
+
|
|
1387
|
+
let origFile = null
|
|
1388
|
+
if (input.origPath !== undefined && input.origPath !== null && input.origPath !== '') {
|
|
1389
|
+
const checked = validateDiffPath(input.origPath, 'origPath')
|
|
1390
|
+
if (!checked.ok) throw Object.assign(new Error(checked.message), { code: checked.code })
|
|
1391
|
+
origFile = checked.value
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
return readDiff(cwd.value, file.value, origFile)
|
|
1395
|
+
},
|
|
1396
|
+
|
|
1397
|
+
async log(args) {
|
|
1398
|
+
const input = args && typeof args === 'object' ? args : {}
|
|
1399
|
+
const cwd = validateCwd(input.cwd)
|
|
1400
|
+
if (!cwd.ok) throw Object.assign(new Error(cwd.message), { code: cwd.code })
|
|
1401
|
+
return readLog(cwd.value, input.count)
|
|
1402
|
+
},
|
|
1403
|
+
|
|
1404
|
+
async commit(args) {
|
|
1405
|
+
const input = args && typeof args === 'object' ? args : {}
|
|
1406
|
+
const cwd = validateCwd(input.cwd)
|
|
1407
|
+
if (!cwd.ok) throw Object.assign(new Error(cwd.message), { code: cwd.code })
|
|
1408
|
+
const sha = validateCommitSha(input.sha)
|
|
1409
|
+
if (!sha.ok) throw Object.assign(new Error(sha.message), { code: sha.code })
|
|
1410
|
+
return readCommit(cwd.value, sha.value)
|
|
1411
|
+
},
|
|
1412
|
+
|
|
1413
|
+
async 'commit-diff'(args) {
|
|
1414
|
+
const input = args && typeof args === 'object' ? args : {}
|
|
1415
|
+
const cwd = validateCwd(input.cwd)
|
|
1416
|
+
if (!cwd.ok) throw Object.assign(new Error(cwd.message), { code: cwd.code })
|
|
1417
|
+
const sha = validateCommitSha(input.sha)
|
|
1418
|
+
if (!sha.ok) throw Object.assign(new Error(sha.message), { code: sha.code })
|
|
1419
|
+
const file = validateDiffPath(input.path)
|
|
1420
|
+
if (!file.ok) throw Object.assign(new Error(file.message), { code: file.code })
|
|
1421
|
+
|
|
1422
|
+
let origFile = null
|
|
1423
|
+
if (input.origPath !== undefined && input.origPath !== null && input.origPath !== '') {
|
|
1424
|
+
const checked = validateDiffPath(input.origPath, 'origPath')
|
|
1425
|
+
if (!checked.ok) throw Object.assign(new Error(checked.message), { code: checked.code })
|
|
1426
|
+
origFile = checked.value
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
return readCommitDiff(cwd.value, sha.value, file.value, origFile)
|
|
1430
|
+
},
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* 本会话改过哪些文件(按会话归因)。
|
|
1434
|
+
*
|
|
1435
|
+
* 只读:只回答"这个会话动过哪些路径",与工作区现状的关系由浏览器半自己用 `status` 求交 —
|
|
1436
|
+
* 这样端点不需要知道 git 的事,两个事实各自只有一个来源。
|
|
1437
|
+
*/
|
|
1438
|
+
async 'session-changes'(args) {
|
|
1439
|
+
const input = args && typeof args === 'object' ? args : {}
|
|
1440
|
+
const cwd = validateCwd(input.cwd)
|
|
1441
|
+
if (!cwd.ok) throw Object.assign(new Error(cwd.message), { code: cwd.code })
|
|
1442
|
+
const sessionId = typeof input.sessionId === 'string' && input.sessionId.length > 0 ? input.sessionId : null
|
|
1443
|
+
return readSessionChanges(cwd.value, sessionId)
|
|
1444
|
+
},
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/* ------------------------------------------------------------------ 线路层 */
|
|
1448
|
+
|
|
1449
|
+
/** 响应信封:`{ type:"server-response", rpcId, result }`。 */
|
|
1450
|
+
function envelope(rpcId, result) {
|
|
1451
|
+
return { type: 'server-response', rpcId, result }
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function failure(rpcId, code, message, details) {
|
|
1455
|
+
return envelope(rpcId, { ok: false, error: { code, message, details: details || {} } })
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
function sendJson(res, status, body) {
|
|
1459
|
+
const payload = JSON.stringify(body)
|
|
1460
|
+
res.statusCode = status
|
|
1461
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
1462
|
+
res.setHeader('content-length', Buffer.byteLength(payload))
|
|
1463
|
+
res.end(payload)
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/** 读请求体,超过上限即拒绝(不继续缓冲)。 */
|
|
1467
|
+
function readBody(req, limit) {
|
|
1468
|
+
return new Promise((resolve, reject) => {
|
|
1469
|
+
let size = 0
|
|
1470
|
+
const chunks = []
|
|
1471
|
+
req.on('data', (chunk) => {
|
|
1472
|
+
size += chunk.length
|
|
1473
|
+
if (size > limit) {
|
|
1474
|
+
const error = new Error('payload too large')
|
|
1475
|
+
error.code = 'too-large'
|
|
1476
|
+
reject(error)
|
|
1477
|
+
return
|
|
1478
|
+
}
|
|
1479
|
+
chunks.push(chunk)
|
|
1480
|
+
})
|
|
1481
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
1482
|
+
req.on('error', reject)
|
|
1483
|
+
})
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* 路由处理器:先过 Connection 围栏,再解析信封,最后分发到端点。
|
|
1488
|
+
* 非 POST → 405,非 JSON → 415,超限 → 413,通道外路径 → 404。
|
|
1489
|
+
*/
|
|
1490
|
+
async function handleRequest(ctx, req, res) {
|
|
1491
|
+
const connection = ctx.get('connection')
|
|
1492
|
+
if (!connection || typeof connection.requestRejection !== 'function') {
|
|
1493
|
+
res.statusCode = 503
|
|
1494
|
+
res.end()
|
|
1495
|
+
return
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
const rejection = connection.requestRejection(req)
|
|
1499
|
+
if (rejection !== undefined) {
|
|
1500
|
+
res.statusCode = rejection
|
|
1501
|
+
res.end()
|
|
1502
|
+
return
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
if (req.method !== 'POST') {
|
|
1506
|
+
res.statusCode = 405
|
|
1507
|
+
res.end()
|
|
1508
|
+
return
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
const contentType = String(req.headers['content-type'] || '')
|
|
1512
|
+
if (!contentType.includes('application/json')) {
|
|
1513
|
+
res.statusCode = 415
|
|
1514
|
+
res.end()
|
|
1515
|
+
return
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
1519
|
+
const pathname = url.pathname
|
|
1520
|
+
// 不能只靠「前缀路由已经过滤过路径」:路由是挂载契约,处理器仍要自己确认,
|
|
1521
|
+
// 否则 `/somewhere-else` 会被 slice 成一段垃圾端点名。
|
|
1522
|
+
if (pathname !== CHANNEL && !pathname.startsWith(`${CHANNEL}/`)) {
|
|
1523
|
+
res.statusCode = 404
|
|
1524
|
+
res.end()
|
|
1525
|
+
return
|
|
1526
|
+
}
|
|
1527
|
+
const endpoint = pathname === CHANNEL ? '' : pathname.slice(CHANNEL.length + 1)
|
|
1528
|
+
if (!endpoint || endpoint.includes('/')) {
|
|
1529
|
+
res.statusCode = 404
|
|
1530
|
+
res.end()
|
|
1531
|
+
return
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
let raw
|
|
1535
|
+
try {
|
|
1536
|
+
raw = await readBody(req, MAX_BODY_BYTES)
|
|
1537
|
+
}
|
|
1538
|
+
catch (error) {
|
|
1539
|
+
res.statusCode = error && error.code === 'too-large' ? 413 : 400
|
|
1540
|
+
res.end()
|
|
1541
|
+
return
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
let message
|
|
1545
|
+
try {
|
|
1546
|
+
message = JSON.parse(raw)
|
|
1547
|
+
}
|
|
1548
|
+
catch {
|
|
1549
|
+
sendJson(res, 400, failure('invalid-request', 'internal', 'invalid client-request message', { code: 'invalid-request' }))
|
|
1550
|
+
return
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
if (!message || typeof message !== 'object' || Array.isArray(message)
|
|
1554
|
+
|| message.type !== 'client-request' || typeof message.rpcId !== 'string'
|
|
1555
|
+
|| typeof message.method !== 'string' || !('payload' in message)) {
|
|
1556
|
+
sendJson(res, 400, failure(
|
|
1557
|
+
message && typeof message.rpcId === 'string' ? message.rpcId : 'invalid-request',
|
|
1558
|
+
'internal',
|
|
1559
|
+
'invalid client-request message',
|
|
1560
|
+
{ code: 'invalid-request' },
|
|
1561
|
+
))
|
|
1562
|
+
return
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
const rpcId = message.rpcId
|
|
1566
|
+
const handler = Object.prototype.hasOwnProperty.call(ENDPOINTS, endpoint) ? ENDPOINTS[endpoint] : null
|
|
1567
|
+
if (handler === null) {
|
|
1568
|
+
// 未知方法按 JSON-RPC 语义回 200 + 错误信封:非 2xx 会被浏览器侧的传输层
|
|
1569
|
+
// 当成连接失败,插件自己的 details.code 就再也到不了界面。
|
|
1570
|
+
sendJson(res, 200, failure(rpcId, 'internal', `unknown endpoint: ${endpoint}`, { code: 'unknown-endpoint' }))
|
|
1571
|
+
return
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
const args = message.payload && typeof message.payload === 'object' ? message.payload.args : undefined
|
|
1575
|
+
try {
|
|
1576
|
+
const value = await handler(args)
|
|
1577
|
+
sendJson(res, 200, envelope(rpcId, { ok: true, value }))
|
|
1578
|
+
}
|
|
1579
|
+
catch (error) {
|
|
1580
|
+
const code = error && typeof error.code === 'string' ? error.code : 'git-failed'
|
|
1581
|
+
const text = error instanceof Error ? error.message : String(error)
|
|
1582
|
+
// 线路上的 error.code 固定为 "internal",插件自己的诊断码放在 details.code,
|
|
1583
|
+
// 由浏览器半按码做本地化文案。
|
|
1584
|
+
sendJson(res, 200, failure(rpcId, 'internal', text, { code }))
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
/* ------------------------------------------------------------------- 插件 */
|
|
1589
|
+
|
|
1590
|
+
/**
|
|
1591
|
+
* 插件入口。
|
|
1592
|
+
* @param ctx - 已注入 webServer 与 connection 的 Cordis 上下文。
|
|
1593
|
+
*/
|
|
1594
|
+
export function apply(ctx) {
|
|
1595
|
+
trackSessionChanges(ctx)
|
|
1596
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1597
|
+
kind: 'prefix',
|
|
1598
|
+
path: CHANNEL,
|
|
1599
|
+
handler: (req, res) => handleRequest(ctx, req, res),
|
|
1600
|
+
}), `dsh-hud: POST ${CHANNEL}/*`)
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// 导出即插件面:`apply` / `inject` / `name` 供 Cordis 读取;`CHANNEL` / `parseStatus` /
|
|
1604
|
+
// `validateCwd` / `readStatus` 是可直接单测的单元(前两个是纯函数,最后一个需要真实仓库)。
|