@konnga/dsh-hud 0.9.1 → 0.9.2
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.en.md +7 -4
- package/README.md +7 -4
- package/cordis.patch.yml +1 -1
- package/lib/client.js +5059 -4940
- package/lib/client.js.map +1 -0
- package/lib/host/git.js +188 -0
- package/lib/host/image.js +265 -0
- package/lib/host/index.js +90 -0
- package/lib/host/parse.js +297 -0
- package/lib/host/readers.js +458 -0
- package/lib/host/rpc.js +317 -0
- package/lib/host/services.js +124 -0
- package/lib/host/session.js +1009 -0
- package/lib/host/types.js +12 -0
- package/package.json +20 -8
- package/lib/index.js +0 -2333
package/lib/index.js
DELETED
|
@@ -1,2333 +0,0 @@
|
|
|
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
|
-
* - `image { cwd, path, origPath?, side?, sha? }` → 图片改动**两侧**的字节与像素尺寸
|
|
21
|
-
* (`kind` 为 `image` / `too-large` / `not-image` / `missing`),给二进制预览用;
|
|
22
|
-
* 单侧上限 `MAX_IMAGE_BYTES`,超限只回报字节数。
|
|
23
|
-
* - `session-time { sessionId }` → 该会话的创建时刻与累计墙钟(`llmMs` / `toolMs` /
|
|
24
|
-
* `turns` / `steps`,来自内置 `sessionStats` 投影;服务缺失时 `available: false`)。
|
|
25
|
-
* - `prompt-history { sessionId, limit? }` → 该会话的用户 prompt(**新的在前**,来自内置
|
|
26
|
-
* `turnOutline` 投影;相邻重复只留一条,默认最多 100 条)。
|
|
27
|
-
* - `log { cwd, count? }` → 最近提交(短 sha、作者、日期、refs、标题),条数钳制 1..50。
|
|
28
|
-
* - `commit { cwd, sha }` → 某次提交的元信息与改动清单(相对**第一个父提交**,
|
|
29
|
-
* 合并提交也按第一父提交看),清单形状与 `status.changes` 一致。
|
|
30
|
-
* - `commit-diff { cwd, sha, path, origPath? }` → 该提交里某个文件的 unified diff,
|
|
31
|
-
* 已解析成带行号的 rows(形状与 `diff` 的**单侧**一致)。
|
|
32
|
-
* - `session-changes { cwd, sessionId }` → **本会话改过哪些文件**(按会话归因,见下):
|
|
33
|
-
* 路径、写入/编辑次数、首次与末次改动时间,以及"自 … 起记录"的时间戳。
|
|
34
|
-
*
|
|
35
|
-
* git 调用一律 `execFile`(无 shell)、带超时、有界输出,并对同一 cwd 做短 TTL 缓存 +
|
|
36
|
-
* 在途请求合并,避免面板轮询时反复跑 `git status`。
|
|
37
|
-
*/
|
|
38
|
-
|
|
39
|
-
import { execFile } from 'node:child_process'
|
|
40
|
-
import fs from 'node:fs'
|
|
41
|
-
import path from 'node:path'
|
|
42
|
-
|
|
43
|
-
/** 插件名(Cordis 插件面的一部分)。 */
|
|
44
|
-
export const name = 'dsh-hud'
|
|
45
|
-
|
|
46
|
-
/** 本插件独占的 RPC 通道前缀(浏览器侧用同一常量调用)。 */
|
|
47
|
-
export const CHANNEL = '/dsh-hud-rpc'
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* 模块自带的硬依赖声明:Cordis 读这里的 `inject` 做激活门控,
|
|
51
|
-
* `cordis.patch.yml` 的行里因此不再重复写一遍。
|
|
52
|
-
*/
|
|
53
|
-
export const inject = ['webServer', 'connection']
|
|
54
|
-
|
|
55
|
-
/** 本地 git 超时:大仓库的 `git status` 也可能要几秒。 */
|
|
56
|
-
const GIT_TIMEOUT_MS = 30_000
|
|
57
|
-
/** execFile 输出上限,防止异常仓库把内存吃满。 */
|
|
58
|
-
const MAX_BUFFER_BYTES = 8 * 1024 * 1024
|
|
59
|
-
/** 同一 cwd 的状态缓存时长。 */
|
|
60
|
-
const STATUS_CACHE_MS = 1_500
|
|
61
|
-
const CACHE_MAX_ENTRIES = 32
|
|
62
|
-
/** 改动清单上限;超出只计数并置 `truncated`。 */
|
|
63
|
-
const MAX_CHANGES = 200
|
|
64
|
-
/** 单文件路径上限,防御异常仓库。 */
|
|
65
|
-
const MAX_PATH_LENGTH = 4_096
|
|
66
|
-
/** 请求体上限。 */
|
|
67
|
-
const MAX_BODY_BYTES = 256 * 1024
|
|
68
|
-
|
|
69
|
-
/** 控制字符(含 NUL、换行)——出现在路径里即视为非法入参。 */
|
|
70
|
-
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/
|
|
71
|
-
|
|
72
|
-
const STATUS_CACHE = new Map()
|
|
73
|
-
const INFLIGHT = new Map()
|
|
74
|
-
|
|
75
|
-
/** 运行一条 git 命令;永不自造结果,失败就把失败原因交回调用方。 */
|
|
76
|
-
function runGit(cwd, args, timeoutMs = GIT_TIMEOUT_MS) {
|
|
77
|
-
return new Promise((resolve) => {
|
|
78
|
-
execFile(
|
|
79
|
-
'git',
|
|
80
|
-
args,
|
|
81
|
-
{ cwd, timeout: timeoutMs, maxBuffer: MAX_BUFFER_BYTES, windowsHide: true, encoding: 'utf8' },
|
|
82
|
-
(error, stdout, stderr) => {
|
|
83
|
-
if (!error) {
|
|
84
|
-
resolve({ ok: true, stdout: stdout || '' })
|
|
85
|
-
return
|
|
86
|
-
}
|
|
87
|
-
resolve({
|
|
88
|
-
ok: false,
|
|
89
|
-
stdout: stdout || '',
|
|
90
|
-
stderr: stderr || '',
|
|
91
|
-
killed: Boolean(error.killed) || Boolean(error.signal),
|
|
92
|
-
code: typeof error.code === 'number' ? error.code : (error.code ?? null),
|
|
93
|
-
message: error instanceof Error ? error.message : String(error),
|
|
94
|
-
})
|
|
95
|
-
},
|
|
96
|
-
)
|
|
97
|
-
})
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* 解析 `git status --porcelain=v2 --branch -z` 的输出。
|
|
102
|
-
*
|
|
103
|
-
* porcelain-v2 的记录以 NUL 分隔,重命名/复制的记录会多跟一条 NUL 记录作为原路径。
|
|
104
|
-
* 路径本身可能含空格,所以绝不能整行 `split(' ')` —— 按字段数用 `indexOf` 切分。
|
|
105
|
-
*
|
|
106
|
-
* @param raw - git 的原始 stdout。
|
|
107
|
-
* @returns 与 `HudGitStatus` 同形的结果。
|
|
108
|
-
*/
|
|
109
|
-
export function parseStatus(raw) {
|
|
110
|
-
const records = String(raw).split('\0')
|
|
111
|
-
|
|
112
|
-
let branch = null
|
|
113
|
-
let oid = null
|
|
114
|
-
let upstream = null
|
|
115
|
-
let ahead = 0
|
|
116
|
-
let behind = 0
|
|
117
|
-
let detached = false
|
|
118
|
-
let truncated = false
|
|
119
|
-
|
|
120
|
-
const counts = {
|
|
121
|
-
modified: 0,
|
|
122
|
-
added: 0,
|
|
123
|
-
deleted: 0,
|
|
124
|
-
renamed: 0,
|
|
125
|
-
copied: 0,
|
|
126
|
-
typeChanged: 0,
|
|
127
|
-
untracked: 0,
|
|
128
|
-
conflicted: 0,
|
|
129
|
-
}
|
|
130
|
-
const changes = []
|
|
131
|
-
|
|
132
|
-
/** 跳过 `count` 个空格分隔的字段,返回其后剩余部分(保留路径里的空格)。 */
|
|
133
|
-
const afterFields = (record, count) => {
|
|
134
|
-
let index = 0
|
|
135
|
-
for (let seen = 0; seen < count; seen += 1) {
|
|
136
|
-
const space = record.indexOf(' ', index)
|
|
137
|
-
if (space < 0) return null
|
|
138
|
-
index = space + 1
|
|
139
|
-
}
|
|
140
|
-
return record.slice(index)
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const push = (status, index, worktree, file, origFile) => {
|
|
144
|
-
if (changes.length < MAX_CHANGES) {
|
|
145
|
-
changes.push({
|
|
146
|
-
status,
|
|
147
|
-
index,
|
|
148
|
-
worktree,
|
|
149
|
-
path: origFile ? `${origFile} → ${file}` : file,
|
|
150
|
-
file,
|
|
151
|
-
origFile,
|
|
152
|
-
})
|
|
153
|
-
} else {
|
|
154
|
-
truncated = true
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
for (let cursor = 0; cursor < records.length; cursor += 1) {
|
|
159
|
-
const record = records[cursor]
|
|
160
|
-
if (!record) continue
|
|
161
|
-
|
|
162
|
-
if (record.startsWith('# branch.head ')) {
|
|
163
|
-
const value = record.slice('# branch.head '.length)
|
|
164
|
-
detached = value === '(detached)'
|
|
165
|
-
branch = detached ? null : value
|
|
166
|
-
continue
|
|
167
|
-
}
|
|
168
|
-
if (record.startsWith('# branch.oid ')) {
|
|
169
|
-
const value = record.slice('# branch.oid '.length)
|
|
170
|
-
oid = value === '(initial)' ? null : value.slice(0, 7)
|
|
171
|
-
continue
|
|
172
|
-
}
|
|
173
|
-
if (record.startsWith('# branch.upstream ')) {
|
|
174
|
-
upstream = record.slice('# branch.upstream '.length) || null
|
|
175
|
-
continue
|
|
176
|
-
}
|
|
177
|
-
if (record.startsWith('# branch.ab ')) {
|
|
178
|
-
const match = /\+(\d+) -(\d+)/.exec(record)
|
|
179
|
-
ahead = Number(match?.[1] ?? 0)
|
|
180
|
-
behind = Number(match?.[2] ?? 0)
|
|
181
|
-
continue
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
if (record.startsWith('? ')) {
|
|
185
|
-
counts.untracked += 1
|
|
186
|
-
push('?', '.', '?', record.slice(2))
|
|
187
|
-
continue
|
|
188
|
-
}
|
|
189
|
-
if (record.startsWith('! ')) {
|
|
190
|
-
// 未使用 --ignored,正常不会出现;出现时按未跟踪处理更贴近用户直觉。
|
|
191
|
-
counts.untracked += 1
|
|
192
|
-
push('!', '.', '!', record.slice(2))
|
|
193
|
-
continue
|
|
194
|
-
}
|
|
195
|
-
if (record.startsWith('u ')) {
|
|
196
|
-
counts.conflicted += 1
|
|
197
|
-
const file = afterFields(record, 10)
|
|
198
|
-
if (file !== null) push('!', record.slice(2, 4)[0], record.slice(2, 4)[1], file)
|
|
199
|
-
continue
|
|
200
|
-
}
|
|
201
|
-
if (record.startsWith('1 ') || record.startsWith('2 ')) {
|
|
202
|
-
const renamed = record.startsWith('2 ')
|
|
203
|
-
const xy = record.slice(2, 4)
|
|
204
|
-
// 展示字母:优先取 index 侧,index 为 '.' 时取 worktree 侧(与 codex-hud 同规则)。
|
|
205
|
-
const letter = xy[0] !== '.' ? xy[0] : xy[1]
|
|
206
|
-
const file = afterFields(record, renamed ? 9 : 8)
|
|
207
|
-
let origFile = null
|
|
208
|
-
if (renamed) {
|
|
209
|
-
// 重命名/复制的原路径是紧随其后的一条 NUL 记录。
|
|
210
|
-
cursor += 1
|
|
211
|
-
origFile = records[cursor] || null
|
|
212
|
-
}
|
|
213
|
-
if (letter === 'R') counts.renamed += 1
|
|
214
|
-
else if (letter === 'C') counts.copied += 1
|
|
215
|
-
else if (letter === 'A') counts.added += 1
|
|
216
|
-
else if (letter === 'D') counts.deleted += 1
|
|
217
|
-
else if (letter === 'T') counts.typeChanged += 1
|
|
218
|
-
else counts.modified += 1
|
|
219
|
-
if (file !== null) push(letter, xy[0], xy[1], file, origFile)
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const dirty = Object.values(counts).some(count => count > 0)
|
|
224
|
-
|
|
225
|
-
return {
|
|
226
|
-
repo: null, // 由调用方填入仓库根
|
|
227
|
-
branch: branch ?? oid,
|
|
228
|
-
detached,
|
|
229
|
-
oid,
|
|
230
|
-
upstream,
|
|
231
|
-
ahead,
|
|
232
|
-
behind,
|
|
233
|
-
dirty,
|
|
234
|
-
counts,
|
|
235
|
-
changes,
|
|
236
|
-
truncated,
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** 校验浏览器传来的 cwd,只接受绝对、无控制字符、长度有界的路径。 */
|
|
241
|
-
export function validateCwd(value) {
|
|
242
|
-
if (typeof value !== 'string' || value.length === 0) return { ok: false, code: 'invalid-cwd', message: 'cwd must be a non-empty string' }
|
|
243
|
-
if (value.length > MAX_PATH_LENGTH) return { ok: false, code: 'invalid-cwd', message: 'cwd is too long' }
|
|
244
|
-
if (CONTROL_CHARS.test(value)) return { ok: false, code: 'invalid-cwd', message: 'cwd contains control characters' }
|
|
245
|
-
if (!path.isAbsolute(value)) return { ok: false, code: 'invalid-cwd', message: 'cwd must be an absolute path' }
|
|
246
|
-
return { ok: true, value }
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/** 无缓存地采集一次状态(测试与缓存未命中时使用)。 */
|
|
250
|
-
export async function readStatus(cwd) {
|
|
251
|
-
const root = await runGit(cwd, ['rev-parse', '--show-toplevel'])
|
|
252
|
-
|
|
253
|
-
if (!root.ok) {
|
|
254
|
-
if (root.killed) throw Object.assign(new Error('git rev-parse timed out'), { code: 'timeout' })
|
|
255
|
-
if (root.code === 'ENOENT' && !fs.existsSync(cwd)) throw Object.assign(new Error('cwd does not exist'), { code: 'no-cwd' })
|
|
256
|
-
if (root.code === 'ENOENT') throw Object.assign(new Error('git executable not found'), { code: 'git-missing' })
|
|
257
|
-
// 其余非零退出(典型是 128)就是"不在仓库里"——这是正常结果,不是错误。
|
|
258
|
-
return { repo: null, branch: null, detached: false, oid: null, upstream: null, ahead: 0, behind: 0, dirty: false, counts: emptyCounts(), changes: [], truncated: false }
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
const repoRoot = root.stdout.split('\n')[0].trim()
|
|
262
|
-
if (!repoRoot) {
|
|
263
|
-
return { repo: null, branch: null, detached: false, oid: null, upstream: null, ahead: 0, behind: 0, dirty: false, counts: emptyCounts(), changes: [], truncated: false }
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// `--untracked-files=all`:未跟踪**按文件**计,而不是把整个未跟踪目录折成一条。
|
|
267
|
-
// 不这样写的话,`?n` 会比编辑器(以及 `git status -uall`)报的数小得多 —— 目录里 200 个新文件
|
|
268
|
-
// 只算 1 条。代价是 git 要走一遍未跟踪树,所以清单仍受 `MAX_CHANGES` 上限保护(超出置 truncated)。
|
|
269
|
-
const status = await runGit(repoRoot, ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'])
|
|
270
|
-
if (!status.ok) {
|
|
271
|
-
if (status.killed) throw Object.assign(new Error('git status timed out'), { code: 'timeout' })
|
|
272
|
-
throw Object.assign(new Error(status.stderr.trim() || status.message || 'git status failed'), { code: 'git-failed' })
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
const parsed = parseStatus(status.stdout)
|
|
276
|
-
parsed.repo = repoRoot
|
|
277
|
-
return parsed
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/** 一次最多看多少个工作区(多个会话可能共用一个目录,按目录去重之后再算)。 */
|
|
281
|
-
const ROW_STATUS_MAX_WORKTREES = 8
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* 一个会话的用户 prompt 历史,**新的在前**(给输入框的 `↑` 召回用)。
|
|
285
|
-
*
|
|
286
|
-
* 来源是内置 `turnOutline` 投影的每条 `prompt` —— 也就是会话日志里真正的用户消息,
|
|
287
|
-
* 不是我们自己记的一份。截断到 `limit` 条,相邻重复(同一句连发两次)只留一条。
|
|
288
|
-
* 投影没注册、会话不在(还没恢复进存储 / 已归档)时回 `available: false` 与空列表。
|
|
289
|
-
*
|
|
290
|
-
* @param sessionId - 会话 id。
|
|
291
|
-
* @param limit - 最多返回多少条(1..200,默认 {@link PROMPT_HISTORY_MAX})。
|
|
292
|
-
*/
|
|
293
|
-
export function readPromptHistory(sessionId, limit = PROMPT_HISTORY_MAX) {
|
|
294
|
-
const empty = { available: false, sessionId, prompts: [] }
|
|
295
|
-
if (typeof sessionId !== 'string' || sessionId.length === 0) return empty
|
|
296
|
-
const max = typeof limit === 'number' && Number.isFinite(limit) ? Math.min(200, Math.max(1, Math.trunc(limit))) : PROMPT_HISTORY_MAX
|
|
297
|
-
|
|
298
|
-
const sessionStore = sessionsService()
|
|
299
|
-
if (sessionStore === null) return empty
|
|
300
|
-
let live = []
|
|
301
|
-
try {
|
|
302
|
-
live = sessionStore.list()
|
|
303
|
-
} catch {
|
|
304
|
-
return empty
|
|
305
|
-
}
|
|
306
|
-
const session = live.find(item => item && item.header && item.header.id === sessionId)
|
|
307
|
-
if (session === undefined) return empty
|
|
308
|
-
|
|
309
|
-
const outline = sessionOutlineReader === null ? null : sessionOutlineReader(session)
|
|
310
|
-
const turns = outline !== null && Array.isArray(outline.turns) ? outline.turns : null
|
|
311
|
-
if (turns === null) return empty
|
|
312
|
-
|
|
313
|
-
const prompts = []
|
|
314
|
-
for (let index = turns.length - 1; index >= 0 && prompts.length < max; index -= 1) {
|
|
315
|
-
const turn = turns[index]
|
|
316
|
-
const raw = turn && typeof turn.prompt === 'string' ? turn.prompt.trim() : ''
|
|
317
|
-
if (raw.length === 0) continue
|
|
318
|
-
if (prompts.length > 0 && prompts[prompts.length - 1] === raw) continue
|
|
319
|
-
prompts.push(raw)
|
|
320
|
-
}
|
|
321
|
-
return { available: true, sessionId, prompts }
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/**
|
|
325
|
-
* 一个会话的"运行时长"事实:创建时刻 + 内置 `sessionStats` 的累计数字。
|
|
326
|
-
*
|
|
327
|
-
* 与 codex-hud 的会话时长口径一致:**挂钟时长 = 现在 − 会话创建时刻**(含等待与空闲),
|
|
328
|
-
* 另外把"真的在干活"的两段(模型墙钟 `llmMs`、工具墙钟 `toolMs`)一并给出,让胶囊的悬停
|
|
329
|
-
* 能说清"74 小时里其实只有 1 小时 52 分在跑"。
|
|
330
|
-
*
|
|
331
|
-
* 只读;`sessions` 服务没到齐或会话已不在(归档、子会话已回收)时回 `available: false`,
|
|
332
|
-
* 客户端据此不画这枚胶囊。
|
|
333
|
-
*
|
|
334
|
-
* @param sessionId - 会话 id。
|
|
335
|
-
* @returns `{ available, sessionId, createdAt, turns, steps, llmMs, toolMs, now }`。
|
|
336
|
-
*/
|
|
337
|
-
export function readSessionTime(sessionId) {
|
|
338
|
-
const now = Date.now()
|
|
339
|
-
const empty = { available: false, sessionId, createdAt: null, turns: null, steps: null, llmMs: null, toolMs: null, now }
|
|
340
|
-
if (typeof sessionId !== 'string' || sessionId.length === 0) return empty
|
|
341
|
-
|
|
342
|
-
const sessionStore = sessionsService()
|
|
343
|
-
if (sessionStore === null) return empty
|
|
344
|
-
let live = []
|
|
345
|
-
try {
|
|
346
|
-
live = sessionStore.list()
|
|
347
|
-
} catch {
|
|
348
|
-
return empty
|
|
349
|
-
}
|
|
350
|
-
const session = live.find(item => item && item.header && item.header.id === sessionId)
|
|
351
|
-
if (session === undefined) return empty
|
|
352
|
-
|
|
353
|
-
const createdAt = typeof session.header.createdAt === 'number' ? session.header.createdAt : null
|
|
354
|
-
const stats = sessionStatsReader === null ? null : sessionStatsReader(session)
|
|
355
|
-
return {
|
|
356
|
-
available: true,
|
|
357
|
-
sessionId,
|
|
358
|
-
createdAt,
|
|
359
|
-
turns: stats === null ? null : stats.turns,
|
|
360
|
-
steps: stats === null ? null : stats.steps,
|
|
361
|
-
llmMs: stats === null ? null : stats.llmMs,
|
|
362
|
-
toolMs: stats === null ? null : stats.toolMs,
|
|
363
|
-
now,
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/**
|
|
368
|
-
* 会话一览:每个活着的会话 → (它那个工作区的 git 摘要)+(这个会话自己的改动计数)。
|
|
369
|
-
*
|
|
370
|
-
* 两半各有一个唯一来源:git 那半走带缓存的 `readStatusCached`(按目录去重,多会话共用
|
|
371
|
-
* 一个目录只跑一次 git),"这个会话改过什么"那半直接来自归因记录(本来就按 sessionId 分,
|
|
372
|
-
* 不用再问 git)。**不返回 `changes`**:这一行只要摘要,别把清单也拖过来。
|
|
373
|
-
*/
|
|
374
|
-
export async function readSessionOverview() {
|
|
375
|
-
const sessionStore = sessionsService()
|
|
376
|
-
if (sessionStore === null) return { sessions: [], unavailable: true, truncated: false, worktrees: 0 }
|
|
377
|
-
let live = []
|
|
378
|
-
try {
|
|
379
|
-
live = sessionStore.list()
|
|
380
|
-
} catch {
|
|
381
|
-
return { sessions: [], unavailable: true, truncated: false, worktrees: 0 }
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// 会话 → 目录;同一个目录只读一次。
|
|
385
|
-
const byCwd = new Map()
|
|
386
|
-
const rows = []
|
|
387
|
-
for (const session of live) {
|
|
388
|
-
const header = session && session.header ? session.header : undefined
|
|
389
|
-
const sessionId = header && typeof header.id === 'string' ? header.id : null
|
|
390
|
-
if (sessionId === null) continue
|
|
391
|
-
const cwd = header && typeof header.cwd === 'string' && header.cwd.length > 0 ? header.cwd : null
|
|
392
|
-
rows.push({ sessionId, cwd })
|
|
393
|
-
if (cwd !== null && !byCwd.has(cwd)) byCwd.set(cwd, null)
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
const cwds = [...byCwd.keys()]
|
|
397
|
-
const used = cwds.slice(0, ROW_STATUS_MAX_WORKTREES)
|
|
398
|
-
const truncated = cwds.length > used.length
|
|
399
|
-
await Promise.all(used.map(async (cwd) => {
|
|
400
|
-
try {
|
|
401
|
-
byCwd.set(cwd, await readStatusCached(cwd))
|
|
402
|
-
} catch (error) {
|
|
403
|
-
byCwd.set(cwd, { error: error && error.code ? error.code : 'git-failed' })
|
|
404
|
-
}
|
|
405
|
-
}))
|
|
406
|
-
|
|
407
|
-
const sessions = rows.map((row) => {
|
|
408
|
-
// 会话自己的改动计数(确定 / 可能),来自归因记录 —— 与 git 无关,非仓库也能报。
|
|
409
|
-
const entry = sessionChanges.get(row.sessionId)
|
|
410
|
-
const summary = {
|
|
411
|
-
sessionId: row.sessionId,
|
|
412
|
-
cwd: row.cwd,
|
|
413
|
-
mine: entry === undefined ? 0 : entry.files.size,
|
|
414
|
-
possible: entry === undefined ? 0 : entry.possible.size,
|
|
415
|
-
}
|
|
416
|
-
/**
|
|
417
|
-
* 「可能相关」里**此刻仍然有差异**的个数(与这份 status 的清单求交)。
|
|
418
|
-
*
|
|
419
|
-
* 为什么要两个数:`possible` 是"命令里提到过的路径"(历史,含已经还原/被忽略的),
|
|
420
|
-
* 面板那一行只数"现在还有差异"的。同一个概念两个口径,界面上必须分别说清,
|
|
421
|
-
* 否则就会出现"工作区干净 + 14 个可能相关"这种让人以为算错了的组合。
|
|
422
|
-
*/
|
|
423
|
-
const possibleNow = entry === undefined
|
|
424
|
-
? 0
|
|
425
|
-
: (() => {
|
|
426
|
-
const status = row.cwd === null ? undefined : byCwd.get(row.cwd)
|
|
427
|
-
if (status === undefined || status === null || Array.isArray(status.changes) !== true) return 0
|
|
428
|
-
let count = 0
|
|
429
|
-
for (const change of status.changes) if (entry.possible.has(change.file)) count += 1
|
|
430
|
-
return count
|
|
431
|
-
})()
|
|
432
|
-
if (row.cwd === null) return { ...summary, possibleNow: 0, repo: false, error: null }
|
|
433
|
-
const status = byCwd.get(row.cwd)
|
|
434
|
-
if (status === undefined || status === null) return { ...summary, possibleNow: 0, repo: false, error: null, pending: true }
|
|
435
|
-
if (status.error !== undefined) return { ...summary, possibleNow: 0, repo: false, error: status.error }
|
|
436
|
-
return {
|
|
437
|
-
...summary,
|
|
438
|
-
possibleNow,
|
|
439
|
-
repo: status.repo !== null,
|
|
440
|
-
branch: status.branch,
|
|
441
|
-
detached: status.detached === true,
|
|
442
|
-
dirty: status.dirty === true,
|
|
443
|
-
ahead: status.ahead || 0,
|
|
444
|
-
behind: status.behind || 0,
|
|
445
|
-
counts: status.counts,
|
|
446
|
-
error: null,
|
|
447
|
-
}
|
|
448
|
-
})
|
|
449
|
-
|
|
450
|
-
return { sessions, unavailable: false, truncated, worktrees: used.length }
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
/**
|
|
454
|
-
* "这一版"的签名:给每个改动文件取 `size + mtimeMs`,用来判断"我审过的还是不是现在这一版"。
|
|
455
|
-
*
|
|
456
|
-
* 为什么不用 `git diff --numstat`:行数变化抓不到"同数改写"(把 `foo` 改成 `bar`),而
|
|
457
|
-
* `stat` 的 mtime 对任何一次写都会变 —— 也更便宜(一次 syscall,不用再跑一个 git 进程)。
|
|
458
|
-
* 反过来它也会过报:`touch` 一下(内容没变)就会让"已审"失效 —— 宁可让人多看一眼。
|
|
459
|
-
*/
|
|
460
|
-
export async function readReviewSignatures(cwd, files) {
|
|
461
|
-
const signatures = {}
|
|
462
|
-
for (const file of files) {
|
|
463
|
-
if (typeof file !== 'string' || file.length === 0) continue
|
|
464
|
-
const absolute = path.join(cwd, file)
|
|
465
|
-
try {
|
|
466
|
-
const info = await fs.promises.stat(absolute)
|
|
467
|
-
signatures[file] = `${info.size}.${Math.round(info.mtimeMs)}`
|
|
468
|
-
} catch {
|
|
469
|
-
// 文件不在了(被删掉 / 被重命名):给一个"不存在"的签名,别让它跟"没审过"混在一起。
|
|
470
|
-
signatures[file] = 'missing'
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
return signatures
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
function emptyCounts() {
|
|
477
|
-
return { modified: 0, added: 0, deleted: 0, renamed: 0, copied: 0, typeChanged: 0, untracked: 0, conflicted: 0 }
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
/** 带 TTL 缓存与在途合并的读取,面板轮询时不会反复跑 git。 */
|
|
481
|
-
async function readStatusCached(cwd, now = Date.now()) {
|
|
482
|
-
const cached = STATUS_CACHE.get(cwd)
|
|
483
|
-
if (cached && now - cached.at < STATUS_CACHE_MS) {
|
|
484
|
-
return cached.value
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
const inflight = INFLIGHT.get(cwd)
|
|
488
|
-
if (inflight) return inflight
|
|
489
|
-
|
|
490
|
-
const pending = readStatus(cwd).then(
|
|
491
|
-
(value) => {
|
|
492
|
-
INFLIGHT.delete(cwd)
|
|
493
|
-
STATUS_CACHE.set(cwd, { at: Date.now(), value })
|
|
494
|
-
if (STATUS_CACHE.size > CACHE_MAX_ENTRIES) {
|
|
495
|
-
const oldest = [...STATUS_CACHE.entries()].sort((left, right) => left[1].at - right[1].at)[0]
|
|
496
|
-
if (oldest) STATUS_CACHE.delete(oldest[0])
|
|
497
|
-
}
|
|
498
|
-
return value
|
|
499
|
-
},
|
|
500
|
-
(error) => {
|
|
501
|
-
INFLIGHT.delete(cwd)
|
|
502
|
-
throw error
|
|
503
|
-
},
|
|
504
|
-
)
|
|
505
|
-
INFLIGHT.set(cwd, pending)
|
|
506
|
-
return pending
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/** 最近提交:默认条数与上限。 */
|
|
510
|
-
const DEFAULT_COMMITS = 10
|
|
511
|
-
const MAX_COMMITS = 50
|
|
512
|
-
|
|
513
|
-
/** 单侧 diff 的文本上限(先按行截断再解析,保证解析量有界)。 */
|
|
514
|
-
const MAX_DIFF_CHARS = 200_000
|
|
515
|
-
/** 单侧 diff 的行数上限。 */
|
|
516
|
-
const MAX_DIFF_ROWS = 2_000
|
|
517
|
-
|
|
518
|
-
/** diff 行的种类:增 / 删 / 上下文 / hunk 头 / git 自己的文件头。 */
|
|
519
|
-
const DIFF_ADD = 'add'
|
|
520
|
-
const DIFF_DEL = 'del'
|
|
521
|
-
const DIFF_CTX = 'ctx'
|
|
522
|
-
const DIFF_HUNK = 'hunk'
|
|
523
|
-
const DIFF_META = 'meta'
|
|
524
|
-
|
|
525
|
-
/**
|
|
526
|
-
* 把 `git diff` 文本解析成带行号的 rows。
|
|
527
|
-
*
|
|
528
|
-
* 放在宿主半而不是浏览器半:解析是纯函数,可以直接单测,客户端只负责渲染。
|
|
529
|
-
* 行号从每个 hunk 头 `@@ -a,b +c,d @@` 起算;git 自己的文件头(`diff --git`、
|
|
530
|
-
* `index`、`---`、`+++`)只在 hunk 之外识别,因此**内容行**里以 `--`/`++`/`@@`
|
|
531
|
-
* 开头的文本不会被误判。
|
|
532
|
-
*
|
|
533
|
-
* @param raw - git 的原始 stdout。
|
|
534
|
-
* @returns `{ rows, additions, deletions, hunks, binary, truncated }`。
|
|
535
|
-
*/
|
|
536
|
-
export function parseDiff(raw) {
|
|
537
|
-
const lines = String(raw).split('\n')
|
|
538
|
-
const rows = []
|
|
539
|
-
|
|
540
|
-
let oldLine = 0
|
|
541
|
-
let newLine = 0
|
|
542
|
-
let inHunk = false
|
|
543
|
-
let additions = 0
|
|
544
|
-
let deletions = 0
|
|
545
|
-
let hunks = 0
|
|
546
|
-
let binary = false
|
|
547
|
-
let truncated = false
|
|
548
|
-
|
|
549
|
-
const push = (kind, text, old, next) => {
|
|
550
|
-
if (rows.length >= MAX_DIFF_ROWS) {
|
|
551
|
-
truncated = true
|
|
552
|
-
return false
|
|
553
|
-
}
|
|
554
|
-
rows.push({ kind, text, oldLine: old, newLine: next })
|
|
555
|
-
return true
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
559
|
-
const line = lines[index]
|
|
560
|
-
// 末尾那一行空串是 split 的产物,不是 diff 内容。
|
|
561
|
-
if (index === lines.length - 1 && line === '') break
|
|
562
|
-
|
|
563
|
-
if (line.startsWith('Binary files ') || line.startsWith('GIT binary patch')) {
|
|
564
|
-
binary = true
|
|
565
|
-
push(DIFF_META, line, null, null)
|
|
566
|
-
continue
|
|
567
|
-
}
|
|
568
|
-
if (line.startsWith('diff --git ')) {
|
|
569
|
-
// 多文件 diff(路径是目录时):新的一段从这里开始。
|
|
570
|
-
inHunk = false
|
|
571
|
-
push(DIFF_META, line, null, null)
|
|
572
|
-
continue
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
|
|
576
|
-
if (hunk !== null) {
|
|
577
|
-
inHunk = true
|
|
578
|
-
hunks += 1
|
|
579
|
-
oldLine = Number(hunk[1])
|
|
580
|
-
newLine = Number(hunk[2])
|
|
581
|
-
push(DIFF_HUNK, line, null, null)
|
|
582
|
-
continue
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
if (!inHunk) {
|
|
586
|
-
push(DIFF_META, line, null, null)
|
|
587
|
-
continue
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
if (line.startsWith('+')) {
|
|
591
|
-
additions += 1
|
|
592
|
-
push(DIFF_ADD, line.slice(1), null, newLine)
|
|
593
|
-
newLine += 1
|
|
594
|
-
continue
|
|
595
|
-
}
|
|
596
|
-
if (line.startsWith('-')) {
|
|
597
|
-
deletions += 1
|
|
598
|
-
push(DIFF_DEL, line.slice(1), oldLine, null)
|
|
599
|
-
oldLine += 1
|
|
600
|
-
continue
|
|
601
|
-
}
|
|
602
|
-
if (line.startsWith(' ')) {
|
|
603
|
-
push(DIFF_CTX, line.slice(1), oldLine, newLine)
|
|
604
|
-
oldLine += 1
|
|
605
|
-
newLine += 1
|
|
606
|
-
continue
|
|
607
|
-
}
|
|
608
|
-
// 反斜杠行("")等。
|
|
609
|
-
push(DIFF_META, line, null, null)
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
return { rows, additions, deletions, hunks, binary, truncated }
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
/** 按行边界截断文本。 */
|
|
616
|
-
function truncateDiff(text) {
|
|
617
|
-
if (text.length <= MAX_DIFF_CHARS) return { text, truncated: false }
|
|
618
|
-
const head = text.slice(0, MAX_DIFF_CHARS)
|
|
619
|
-
const lastBreak = head.lastIndexOf('\n')
|
|
620
|
-
return { text: lastBreak > 0 ? head.slice(0, lastBreak) : head, truncated: true }
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
/**
|
|
624
|
-
* 解析 `git diff --name-status -z` 的输出。
|
|
625
|
-
*
|
|
626
|
-
* `-z` 用 NUL 收尾,所以含空格/引号的路径原样给出(不会像默认输出那样带引号转义):
|
|
627
|
-
* 一段是 `STATUS\0PATH\0`,重命名/复制则是 `R075\0OLD\0NEW\0`。返回的每条与
|
|
628
|
-
* `status.changes` 同形(`status` 只留 M/A/D/R/C/T/U/X 这类单字母),这样左栏的
|
|
629
|
-
* 改动清单组件可以直接渲染这次的提交。
|
|
630
|
-
*
|
|
631
|
-
* @param raw - git 的原始 stdout。
|
|
632
|
-
* @returns `{ files, truncated }`,条数上限与工作区清单一致。
|
|
633
|
-
*/
|
|
634
|
-
export function parseNameStatus(raw) {
|
|
635
|
-
const tokens = String(raw).split('\0')
|
|
636
|
-
const files = []
|
|
637
|
-
let truncated = false
|
|
638
|
-
|
|
639
|
-
for (let cursor = 0; cursor < tokens.length;) {
|
|
640
|
-
const status = tokens[cursor]
|
|
641
|
-
cursor += 1
|
|
642
|
-
// 收尾的 NUL 会留一个空段;非状态行的段落一律跳过,而不是把垃圾当路径。
|
|
643
|
-
if (!status || !/^[A-Z]/.test(status)) continue
|
|
644
|
-
|
|
645
|
-
const letter = status.slice(0, 1)
|
|
646
|
-
const paired = letter === 'R' || letter === 'C'
|
|
647
|
-
const first = tokens[cursor]
|
|
648
|
-
cursor += 1
|
|
649
|
-
const second = paired ? tokens[cursor] : null
|
|
650
|
-
if (paired) cursor += 1
|
|
651
|
-
|
|
652
|
-
if (typeof first !== 'string' || first === '') continue
|
|
653
|
-
const file = paired ? second : first
|
|
654
|
-
const origFile = paired ? first : null
|
|
655
|
-
if (typeof file !== 'string' || file === '') continue
|
|
656
|
-
|
|
657
|
-
if (files.length >= MAX_CHANGES) {
|
|
658
|
-
truncated = true
|
|
659
|
-
break
|
|
660
|
-
}
|
|
661
|
-
files.push({
|
|
662
|
-
status: letter,
|
|
663
|
-
path: origFile === null ? file : `${origFile} → ${file}`,
|
|
664
|
-
file,
|
|
665
|
-
origFile,
|
|
666
|
-
})
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
return { files, truncated }
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
/** 提交 sha 的形状:只用十六进制,长度 4..40(面板给的是短 sha)。 */
|
|
673
|
-
const COMMIT_SHA = /^[0-9a-fA-F]{4,40}$/
|
|
674
|
-
|
|
675
|
-
/**
|
|
676
|
-
* 校验提交 sha。
|
|
677
|
-
*
|
|
678
|
-
* 只收十六进制,因此 `HEAD~1`、`--help`、`origin/main` 这类都进不来——`sha` 会作为
|
|
679
|
-
* 位置参数交给 `git`,只靠 `--` 分隔路径是不够的,必须在入口就把选项形状挡掉。
|
|
680
|
-
*/
|
|
681
|
-
export function validateCommitSha(value, label = 'sha') {
|
|
682
|
-
const reject = message => ({ ok: false, code: 'invalid-sha', message: `${label}: ${message}` })
|
|
683
|
-
if (typeof value !== 'string' || value.length === 0) return reject('must be a non-empty string')
|
|
684
|
-
if (!COMMIT_SHA.test(value)) return reject('must be 4..40 hex characters')
|
|
685
|
-
return { ok: true, value }
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
/** 校验 diff 的仓库内相对路径:必须是相对、无控制字符、无 `..`、不以 `-` 开头。 */
|
|
689
|
-
export function validateDiffPath(value, label = 'path') {
|
|
690
|
-
const reject = message => ({ ok: false, code: 'invalid-path', message: `${label}: ${message}` })
|
|
691
|
-
if (typeof value !== 'string' || value.length === 0) return reject('must be a non-empty string')
|
|
692
|
-
if (value.length > MAX_PATH_LENGTH) return reject('is too long')
|
|
693
|
-
if (CONTROL_CHARS.test(value)) return reject('contains control characters')
|
|
694
|
-
if (path.isAbsolute(value)) return reject('must be repo-relative, not absolute')
|
|
695
|
-
if (/^[\\/]/.test(value)) return reject('must not start with a separator')
|
|
696
|
-
if (value.startsWith('-')) return reject('must not start with "-"')
|
|
697
|
-
if (value.split(/[\\/]/).includes('..')) return reject('must not contain ".."')
|
|
698
|
-
return { ok: true, value }
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
/**
|
|
702
|
-
* 读一个改动路径的**两侧** diff(工作区 / 已暂存),全部只读。
|
|
703
|
-
*
|
|
704
|
-
* 未跟踪文件用 `git diff --no-index -- /dev/null <path>`(该命令以退出码 1 表示
|
|
705
|
-
* "有差异",属于正常结果);重命名把原路径一并作为 pathspec 传入。
|
|
706
|
-
*
|
|
707
|
-
* @param cwd - 会话工作区(绝对路径)。
|
|
708
|
-
* @param file - 仓库内相对路径。
|
|
709
|
-
* @param origFile - 重命名/复制的原路径,可选。
|
|
710
|
-
* @returns `{ repo, path, origPath, untracked, worktree, index }`,两侧都是 parseDiff 的结果。
|
|
711
|
-
*/
|
|
712
|
-
export async function readDiff(cwd, file, origFile = null) {
|
|
713
|
-
const root = await runGit(cwd, ['rev-parse', '--show-toplevel'])
|
|
714
|
-
if (!root.ok) {
|
|
715
|
-
if (root.killed) throw Object.assign(new Error('git rev-parse timed out'), { code: 'timeout' })
|
|
716
|
-
if (root.code === 'ENOENT' && !fs.existsSync(cwd)) throw Object.assign(new Error('cwd does not exist'), { code: 'no-cwd' })
|
|
717
|
-
if (root.code === 'ENOENT') throw Object.assign(new Error('git executable not found'), { code: 'git-missing' })
|
|
718
|
-
return { repo: null, path: file, origPath: origFile, untracked: false, worktree: parseDiff(''), index: parseDiff('') }
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
const repoRoot = root.stdout.split('\n')[0].trim()
|
|
722
|
-
const pathspec = origFile === null || origFile === undefined || origFile === '' ? [file] : [file, origFile]
|
|
723
|
-
|
|
724
|
-
const tracked = await runGit(repoRoot, ['ls-files', '--error-unmatch', '--', file])
|
|
725
|
-
const untracked = !tracked.ok
|
|
726
|
-
|
|
727
|
-
let worktreeText = ''
|
|
728
|
-
let worktreeTruncated = false
|
|
729
|
-
if (untracked) {
|
|
730
|
-
// 以退出码 1 表示"有差异",stdout 就是 unified diff。
|
|
731
|
-
const result = await runGit(repoRoot, ['diff', '--no-index', '--no-color', '--no-ext-diff', '--', '/dev/null', file])
|
|
732
|
-
if (result.code === 'ENOENT' || result.killed) {
|
|
733
|
-
throw Object.assign(new Error(result.killed ? 'git diff timed out' : 'git executable not found'), { code: result.killed ? 'timeout' : 'git-missing' })
|
|
734
|
-
}
|
|
735
|
-
worktreeText = result.stdout
|
|
736
|
-
}
|
|
737
|
-
else {
|
|
738
|
-
const result = await runGit(repoRoot, ['diff', '--no-color', '--no-ext-diff', '--', ...pathspec])
|
|
739
|
-
if (!result.ok) throw Object.assign(new Error(result.stderr.trim() || 'git diff failed'), { code: 'git-failed' })
|
|
740
|
-
worktreeText = result.stdout
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
const indexResult = untracked
|
|
744
|
-
? { ok: true, stdout: '' }
|
|
745
|
-
: await runGit(repoRoot, ['diff', '--cached', '--no-color', '--no-ext-diff', '--', ...pathspec])
|
|
746
|
-
if (!indexResult.ok) throw Object.assign(new Error(indexResult.stderr?.trim() || 'git diff --cached failed'), { code: 'git-failed' })
|
|
747
|
-
|
|
748
|
-
const worktreeCut = truncateDiff(worktreeText)
|
|
749
|
-
const indexCut = truncateDiff(indexResult.stdout || '')
|
|
750
|
-
const worktree = parseDiff(worktreeCut.text)
|
|
751
|
-
const index = parseDiff(indexCut.text)
|
|
752
|
-
|
|
753
|
-
return {
|
|
754
|
-
repo: repoRoot,
|
|
755
|
-
path: file,
|
|
756
|
-
origPath: origFile ?? null,
|
|
757
|
-
untracked,
|
|
758
|
-
worktree: { ...worktree, truncated: worktree.truncated || worktreeCut.truncated },
|
|
759
|
-
index: { ...index, truncated: index.truncated || indexCut.truncated },
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
/**
|
|
764
|
-
* 读最近的提交(只读 `git log`)。
|
|
765
|
-
*
|
|
766
|
-
* 用 NUL 分隔的 `--pretty=format:` 而不是默认格式:提交标题里可以有空格、引号、
|
|
767
|
-
* 甚至制表符,用分隔符切比按列对齐稳。空仓库(还没有任何提交)时 `git log` 以非零
|
|
768
|
-
* 退出,这里返回空列表而不是错误——"还没有提交"是正常状态。
|
|
769
|
-
*
|
|
770
|
-
* @param cwd - 会话工作区(绝对路径)。
|
|
771
|
-
* @param count - 条数,钳制在 1..50,默认 10。
|
|
772
|
-
* @returns `{ repo, commits: [{ sha, author, date, committedAt, refs, subject }] }`。
|
|
773
|
-
*/
|
|
774
|
-
export async function readLog(cwd, count = DEFAULT_COMMITS) {
|
|
775
|
-
const root = await runGit(cwd, ['rev-parse', '--show-toplevel'])
|
|
776
|
-
if (!root.ok) {
|
|
777
|
-
if (root.killed) throw Object.assign(new Error('git rev-parse timed out'), { code: 'timeout' })
|
|
778
|
-
if (root.code === 'ENOENT' && !fs.existsSync(cwd)) throw Object.assign(new Error('cwd does not exist'), { code: 'no-cwd' })
|
|
779
|
-
if (root.code === 'ENOENT') throw Object.assign(new Error('git executable not found'), { code: 'git-missing' })
|
|
780
|
-
return { repo: null, commits: [] }
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
const repoRoot = root.stdout.split('\n')[0].trim()
|
|
784
|
-
if (!repoRoot) return { repo: null, commits: [] }
|
|
785
|
-
|
|
786
|
-
const requested = Number.isFinite(count) ? Math.floor(count) : DEFAULT_COMMITS
|
|
787
|
-
const limit = Math.min(Math.max(1, requested), MAX_COMMITS)
|
|
788
|
-
|
|
789
|
-
const result = await runGit(repoRoot, [
|
|
790
|
-
'log',
|
|
791
|
-
`-n${String(limit)}`,
|
|
792
|
-
'--no-color',
|
|
793
|
-
'--date=short',
|
|
794
|
-
'--pretty=format:%h%x00%an%x00%ad%x00%ct%x00%D%x00%s',
|
|
795
|
-
])
|
|
796
|
-
if (!result.ok) {
|
|
797
|
-
// 空仓库 / 无 HEAD:不是错误,就是还没有提交。
|
|
798
|
-
return { repo: repoRoot, commits: [] }
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
const commits = result.stdout
|
|
802
|
-
.split('\n')
|
|
803
|
-
.filter(line => line.length > 0)
|
|
804
|
-
.map((line) => {
|
|
805
|
-
const [sha, author, date, seconds, refs, subject] = line.split('\0')
|
|
806
|
-
const at = Number(seconds)
|
|
807
|
-
return {
|
|
808
|
-
sha: sha ?? '',
|
|
809
|
-
author: author ?? '',
|
|
810
|
-
date: date ?? '',
|
|
811
|
-
committedAt: Number.isFinite(at) ? at * 1000 : null,
|
|
812
|
-
refs: refs ? refs.split(', ').filter(Boolean) : [],
|
|
813
|
-
subject: subject ?? '',
|
|
814
|
-
}
|
|
815
|
-
})
|
|
816
|
-
|
|
817
|
-
return { repo: repoRoot, commits }
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
/**
|
|
821
|
-
* 解析仓库根与目标提交的元信息(含父提交),`readCommit` / `readCommitDiff` 共用。
|
|
822
|
-
*
|
|
823
|
-
* 父提交直接从 `%P` 拿:`git log -1` 已经把它给了出来,不必再跑一次 `rev-parse`。
|
|
824
|
-
* 取**第一个**父提交作为比较基准——"这次提交改了什么"对合并提交也是这个意思,
|
|
825
|
-
* 而 `git show` 对合并提交默认给的是"组合差异",常常是空的,对用户没意义。
|
|
826
|
-
*
|
|
827
|
-
* @returns `{ repo, sha, shortSha, subject, author, date, committedAt, refs, parents, base }`;
|
|
828
|
-
* `base` 为空串表示这是根提交(与空树比较)。非仓库时 `repo` 为 null。
|
|
829
|
-
*/
|
|
830
|
-
async function resolveCommit(cwd, sha) {
|
|
831
|
-
const root = await runGit(cwd, ['rev-parse', '--show-toplevel'])
|
|
832
|
-
if (!root.ok) {
|
|
833
|
-
if (root.killed) throw Object.assign(new Error('git rev-parse timed out'), { code: 'timeout' })
|
|
834
|
-
if (root.code === 'ENOENT' && !fs.existsSync(cwd)) throw Object.assign(new Error('cwd does not exist'), { code: 'no-cwd' })
|
|
835
|
-
if (root.code === 'ENOENT') throw Object.assign(new Error('git executable not found'), { code: 'git-missing' })
|
|
836
|
-
return { repo: null, sha, shortSha: sha, subject: '', author: '', date: '', committedAt: null, refs: [], parents: [], base: null }
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
const repoRoot = root.stdout.split('\n')[0].trim()
|
|
840
|
-
if (!repoRoot) return { repo: null, sha, shortSha: sha, subject: '', author: '', date: '', committedAt: null, refs: [], parents: [], base: null }
|
|
841
|
-
|
|
842
|
-
const result = await runGit(repoRoot, [
|
|
843
|
-
'log',
|
|
844
|
-
'-1',
|
|
845
|
-
'--no-color',
|
|
846
|
-
'--date=short',
|
|
847
|
-
'--pretty=format:%H%x00%h%x00%an%x00%ad%x00%ct%x00%D%x00%P%x00%s',
|
|
848
|
-
sha,
|
|
849
|
-
])
|
|
850
|
-
if (!result.ok) {
|
|
851
|
-
throw Object.assign(new Error(result.stderr.trim() || 'unknown commit'), { code: result.killed ? 'timeout' : 'unknown-commit' })
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
const [full, short, author, date, seconds, refs, parents, subject] = result.stdout.split('\0')
|
|
855
|
-
const at = Number(seconds)
|
|
856
|
-
const parentList = typeof parents === 'string' && parents.length > 0 ? parents.split(' ').filter(Boolean) : []
|
|
857
|
-
|
|
858
|
-
return {
|
|
859
|
-
repo: repoRoot,
|
|
860
|
-
sha: full || sha,
|
|
861
|
-
shortSha: short || sha,
|
|
862
|
-
subject: subject ?? '',
|
|
863
|
-
author: author ?? '',
|
|
864
|
-
date: date ?? '',
|
|
865
|
-
committedAt: Number.isFinite(at) ? at * 1000 : null,
|
|
866
|
-
refs: refs ? refs.split(', ').filter(Boolean) : [],
|
|
867
|
-
parents: parentList,
|
|
868
|
-
base: parentList.length > 0 ? parentList[0] : null,
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
/** 这次提交的改动清单 + 元信息(只读 `git diff` / `git show`)。 */
|
|
873
|
-
/** 标签列表上限(超出的只报 truncated,不硬塞)。 */
|
|
874
|
-
const TAGS_MAX = 100
|
|
875
|
-
|
|
876
|
-
/**
|
|
877
|
-
* 读标签列表(`refs/tags`,按 creatordate 倒序 = 最新的在前)。
|
|
878
|
-
*
|
|
879
|
-
* 两处容易搞错、这里显式处理:
|
|
880
|
-
* - **附注 tag**(annotated)的 `%(objectname)` 是 **tag 对象**的 sha,不是它指向的提交;
|
|
881
|
-
* 要拿提交得用 `%(*objectname)`。轻量 tag 两者相同。列表里显示提交 sha(能对上“最近提交”)。
|
|
882
|
-
* - 主题用 `%(contents:subject)`:附注 tag 是 tag 说明的首行,轻量 tag 拿到的是提交标题 ——
|
|
883
|
-
* 两种情况都比只给一个名字有用;它理论上可以是多行,所以最后一个字段按“剩下全部”再取首行。
|
|
884
|
-
*/
|
|
885
|
-
export async function readTags(cwd, count = TAGS_MAX) {
|
|
886
|
-
const root = await runGit(cwd, ['rev-parse', '--show-toplevel'])
|
|
887
|
-
if (!root.ok) {
|
|
888
|
-
if (root.killed) throw Object.assign(new Error('git rev-parse timed out'), { code: 'timeout' })
|
|
889
|
-
if (root.code === 'ENOENT' && !fs.existsSync(cwd)) throw Object.assign(new Error('cwd does not exist'), { code: 'no-cwd' })
|
|
890
|
-
if (root.code === 'ENOENT') throw Object.assign(new Error('git executable not found'), { code: 'git-missing' })
|
|
891
|
-
return { repo: null, tags: [], truncated: false }
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
const repoRoot = root.stdout.split('\n')[0].trim()
|
|
895
|
-
if (!repoRoot) return { repo: null, tags: [], truncated: false }
|
|
896
|
-
|
|
897
|
-
const requested = Number.isFinite(count) ? Math.floor(count) : TAGS_MAX
|
|
898
|
-
const limit = Math.min(Math.max(1, requested), TAGS_MAX)
|
|
899
|
-
|
|
900
|
-
// 多取一条用来判断“还有更多”(比事后猜“正好等于上限”准确)。
|
|
901
|
-
const result = await runGit(repoRoot, [
|
|
902
|
-
'for-each-ref',
|
|
903
|
-
'--sort=-creatordate',
|
|
904
|
-
`--count=${String(limit + 1)}`,
|
|
905
|
-
'--format=%(refname:short)%00%(objecttype)%00%(objectname:short)%00%(*objectname:short)%00%(creatordate:short)%00%(creatordate:unix)%00%(contents:subject)',
|
|
906
|
-
'refs/tags',
|
|
907
|
-
])
|
|
908
|
-
// 没有 tag 时 git 也返回 0(输出为空),不是错误。
|
|
909
|
-
if (!result.ok) return { repo: repoRoot, tags: [], truncated: false }
|
|
910
|
-
|
|
911
|
-
const lines = result.stdout.split('\n').filter(line => line.length > 0)
|
|
912
|
-
const tags = lines.map((line) => {
|
|
913
|
-
const parts = line.split('\0')
|
|
914
|
-
const [name, objecttype, objectname, peeled, date, seconds] = parts
|
|
915
|
-
// 最后一个字段是主题:附注 tag 的说明可能是多行,这里只取首行。
|
|
916
|
-
const subject = parts.slice(6).join('\0').split('\n')[0] ?? ''
|
|
917
|
-
const at = Number(seconds)
|
|
918
|
-
const annotated = objecttype === 'tag'
|
|
919
|
-
// 附注 tag 要“剥一层”拿提交 sha;剥不出来(比如 tag 指向 blob/tree)就退回对象自身的 sha。
|
|
920
|
-
const sha = annotated && peeled ? peeled : objectname
|
|
921
|
-
return {
|
|
922
|
-
name: name ?? '',
|
|
923
|
-
sha: sha ?? '',
|
|
924
|
-
annotated,
|
|
925
|
-
date: date ?? '',
|
|
926
|
-
committedAt: Number.isFinite(at) ? at * 1000 : null,
|
|
927
|
-
subject,
|
|
928
|
-
}
|
|
929
|
-
})
|
|
930
|
-
|
|
931
|
-
// creatordate 同一个秒内的先后 git 不保证(测试里三个 tag 常在同一秒建出来),
|
|
932
|
-
// 再按名字倒序定死 —— 否则界面上同批 tag 每次刷新都可能换位置。
|
|
933
|
-
//
|
|
934
|
-
// 顺序:**先排序再截断**。反过来的话截出来的是 git 的任意顺序(t1、t2),排序再好也没用。
|
|
935
|
-
tags.sort((left, right) => {
|
|
936
|
-
const diff = (right.committedAt ?? 0) - (left.committedAt ?? 0)
|
|
937
|
-
if (diff !== 0) return diff
|
|
938
|
-
return left.name < right.name ? 1 : (left.name > right.name ? -1 : 0)
|
|
939
|
-
})
|
|
940
|
-
|
|
941
|
-
const truncated = tags.length > limit
|
|
942
|
-
return { repo: repoRoot, tags: tags.slice(0, limit), truncated }
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
export async function readCommit(cwd, sha) {
|
|
946
|
-
const commit = await resolveCommit(cwd, sha)
|
|
947
|
-
if (!commit.repo) return { repo: null, commit: null, files: [], truncated: false }
|
|
948
|
-
|
|
949
|
-
// 根提交没有父提交,改用 `show` 与空树比较;其余一律 diff 第一父提交。
|
|
950
|
-
const result = commit.base === null
|
|
951
|
-
? await runGit(commit.repo, ['show', '--format=', '--no-color', '--no-ext-diff', '--name-status', '-z', '-M', commit.sha])
|
|
952
|
-
: await runGit(commit.repo, ['diff', '--no-color', '--no-ext-diff', '--name-status', '-z', '-M', commit.base, commit.sha])
|
|
953
|
-
if (!result.ok) {
|
|
954
|
-
throw Object.assign(new Error(result.stderr.trim() || 'git diff failed'), { code: result.killed ? 'timeout' : 'git-failed' })
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
const { files, truncated } = parseNameStatus(result.stdout)
|
|
958
|
-
return {
|
|
959
|
-
repo: commit.repo,
|
|
960
|
-
commit: {
|
|
961
|
-
sha: commit.sha,
|
|
962
|
-
shortSha: commit.shortSha,
|
|
963
|
-
subject: commit.subject,
|
|
964
|
-
author: commit.author,
|
|
965
|
-
date: commit.date,
|
|
966
|
-
committedAt: commit.committedAt,
|
|
967
|
-
refs: commit.refs,
|
|
968
|
-
merge: commit.parents.length > 1,
|
|
969
|
-
},
|
|
970
|
-
files,
|
|
971
|
-
truncated,
|
|
972
|
-
}
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
/**
|
|
976
|
-
* 读这次提交里某个文件的 diff(相对第一父提交)。
|
|
977
|
-
*
|
|
978
|
-
* 重命名/复制把原路径一并作为 pathspec 传进去,否则 git 只看到"新增 + 删除"而不是
|
|
979
|
-
* 一次移动。未跟踪文件不属于提交,因此这里没有 `--no-index` 那条分支。
|
|
980
|
-
*
|
|
981
|
-
* @returns `{ repo, path, file, origPath, sha, rows, additions, deletions, hunks, binary, truncated }`。
|
|
982
|
-
*/
|
|
983
|
-
export async function readCommitDiff(cwd, sha, file, origFile = null) {
|
|
984
|
-
const commit = await resolveCommit(cwd, sha)
|
|
985
|
-
if (!commit.repo) {
|
|
986
|
-
return { repo: null, sha, file, path: file, origPath: origFile, ...parseDiff(''), truncated: false }
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
const pathspec = origFile === null || origFile === undefined || origFile === '' ? [file] : [file, origFile]
|
|
990
|
-
const result = commit.base === null
|
|
991
|
-
? await runGit(commit.repo, ['show', '--format=', '--no-color', '--no-ext-diff', '-M', commit.sha, '--', ...pathspec])
|
|
992
|
-
: await runGit(commit.repo, ['diff', '--no-color', '--no-ext-diff', '-M', commit.base, commit.sha, '--', ...pathspec])
|
|
993
|
-
if (!result.ok) {
|
|
994
|
-
throw Object.assign(new Error(result.stderr.trim() || 'git diff failed'), { code: result.killed ? 'timeout' : 'git-failed' })
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
const cut = truncateDiff(result.stdout)
|
|
998
|
-
const parsed = parseDiff(cut.text)
|
|
999
|
-
return {
|
|
1000
|
-
repo: commit.repo,
|
|
1001
|
-
sha: commit.sha,
|
|
1002
|
-
shortSha: commit.shortSha,
|
|
1003
|
-
file,
|
|
1004
|
-
path: file,
|
|
1005
|
-
origPath: origFile ?? null,
|
|
1006
|
-
...parsed,
|
|
1007
|
-
truncated: parsed.truncated || cut.truncated,
|
|
1008
|
-
}
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
/* --------------------------------------------- 二进制预览(图片,只读) */
|
|
1012
|
-
|
|
1013
|
-
/**
|
|
1014
|
-
* 单侧图片预览的字节上限。
|
|
1015
|
-
*
|
|
1016
|
-
* 超限不等于"看不了这个改动":仍然回报真实字节数,只是不把内容塞进 JSON
|
|
1017
|
-
* (base64 还要再涨 1/3,RPC 体量必须有界)。
|
|
1018
|
-
*/
|
|
1019
|
-
export const MAX_IMAGE_BYTES = 4 * 1024 * 1024
|
|
1020
|
-
|
|
1021
|
-
/** PNG 的签名(8 字节)。 */
|
|
1022
|
-
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
1023
|
-
|
|
1024
|
-
/**
|
|
1025
|
-
* 从**内容**而不是扩展名认图片类型(扩展名会骗人:`.png` 里放 gif 很常见)。
|
|
1026
|
-
*
|
|
1027
|
-
* @param buffer - 文件头若干字节。
|
|
1028
|
-
* @returns MIME 字符串,认不出返回 null。
|
|
1029
|
-
*/
|
|
1030
|
-
export function sniffImageMime(buffer) {
|
|
1031
|
-
if (!Buffer.isBuffer(buffer) || buffer.length < 12) return null
|
|
1032
|
-
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(PNG_SIGNATURE)) return 'image/png'
|
|
1033
|
-
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'image/jpeg'
|
|
1034
|
-
const gif = buffer.subarray(0, 6).toString('latin1')
|
|
1035
|
-
if (gif === 'GIF87a' || gif === 'GIF89a') return 'image/gif'
|
|
1036
|
-
if (buffer.subarray(0, 4).toString('latin1') === 'RIFF' && buffer.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp'
|
|
1037
|
-
if (buffer.subarray(0, 2).toString('latin1') === 'BM') return 'image/bmp'
|
|
1038
|
-
return null
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
/** PNG:IHDR 紧跟签名,宽高各 4 字节大端。 */
|
|
1042
|
-
function pngSize(buffer) {
|
|
1043
|
-
if (buffer.length < 24) return null
|
|
1044
|
-
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
/** GIF:逻辑屏幕描述符,宽高各 2 字节小端。 */
|
|
1048
|
-
function gifSize(buffer) {
|
|
1049
|
-
if (buffer.length < 10) return null
|
|
1050
|
-
return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) }
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
/**
|
|
1054
|
-
* JPEG:从头扫段,找 SOFn(C0–CF,但 C4/C8/CC 不是)里的高/宽。
|
|
1055
|
-
*
|
|
1056
|
-
* 段长字段是**含自身两字节**的长度,所以下一段起点是 `index + 2 + length`;
|
|
1057
|
-
* 填充 0xFF、TEM 与 RSTn 都没有长度字段,只前进一字节。
|
|
1058
|
-
*/
|
|
1059
|
-
function jpegSize(buffer) {
|
|
1060
|
-
let index = 2
|
|
1061
|
-
while (index + 9 < buffer.length) {
|
|
1062
|
-
if (buffer[index] !== 0xff) {
|
|
1063
|
-
index += 1
|
|
1064
|
-
continue
|
|
1065
|
-
}
|
|
1066
|
-
const marker = buffer[index + 1]
|
|
1067
|
-
if (marker === 0xff || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {
|
|
1068
|
-
index += 1
|
|
1069
|
-
continue
|
|
1070
|
-
}
|
|
1071
|
-
const isFrame = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc
|
|
1072
|
-
if (isFrame) return { width: buffer.readUInt16BE(index + 7), height: buffer.readUInt16BE(index + 5) }
|
|
1073
|
-
const length = buffer.readUInt16BE(index + 2)
|
|
1074
|
-
if (length < 2) return null
|
|
1075
|
-
index += 2 + length
|
|
1076
|
-
}
|
|
1077
|
-
return null
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
/** WebP:VP8X 是 24 位减一、VP8(有损)内嵌 14 位、VP8L(无损)打包在 32 位里。 */
|
|
1081
|
-
function webpSize(buffer) {
|
|
1082
|
-
if (buffer.length < 30) return null
|
|
1083
|
-
const chunk = buffer.subarray(12, 16).toString('latin1')
|
|
1084
|
-
if (chunk === 'VP8X') return { width: buffer.readUIntLE(24, 3) + 1, height: buffer.readUIntLE(27, 3) + 1 }
|
|
1085
|
-
if (chunk === 'VP8 ' && buffer[23] === 0x9d && buffer[24] === 0x01 && buffer[25] === 0x2a) {
|
|
1086
|
-
return { width: buffer.readUInt16LE(26) & 0x3fff, height: buffer.readUInt16LE(28) & 0x3fff }
|
|
1087
|
-
}
|
|
1088
|
-
if (chunk === 'VP8L' && buffer[20] === 0x2f) {
|
|
1089
|
-
const packed = buffer.readUInt32LE(21)
|
|
1090
|
-
return { width: (packed & 0x3fff) + 1, height: ((packed >> 14) & 0x3fff) + 1 }
|
|
1091
|
-
}
|
|
1092
|
-
return null
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
/** BMP:BITMAPINFOHEADER 的宽为有符号、高可为负(自上而下)。 */
|
|
1096
|
-
function bmpSize(buffer) {
|
|
1097
|
-
if (buffer.length < 26) return null
|
|
1098
|
-
const width = buffer.readInt32LE(18)
|
|
1099
|
-
const height = buffer.readInt32LE(22)
|
|
1100
|
-
if (width <= 0 || height === 0) return null
|
|
1101
|
-
return { width, height: Math.abs(height) }
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
/**
|
|
1105
|
-
* 读出图片的像素尺寸。纯函数,只读头部,认不出返回 `null`(绝不抛)。
|
|
1106
|
-
*
|
|
1107
|
-
* @param buffer - 文件内容(头部足够即可,但这里拿的是完整内容)。
|
|
1108
|
-
* @param mime - `sniffImageMime` 的结果。
|
|
1109
|
-
* @returns `{ width, height }` 或 null。
|
|
1110
|
-
*/
|
|
1111
|
-
export function readImageSize(buffer, mime) {
|
|
1112
|
-
try {
|
|
1113
|
-
if (mime === 'image/png') return pngSize(buffer)
|
|
1114
|
-
if (mime === 'image/jpeg') return jpegSize(buffer)
|
|
1115
|
-
if (mime === 'image/gif') return gifSize(buffer)
|
|
1116
|
-
if (mime === 'image/webp') return webpSize(buffer)
|
|
1117
|
-
if (mime === 'image/bmp') return bmpSize(buffer)
|
|
1118
|
-
return null
|
|
1119
|
-
}
|
|
1120
|
-
catch {
|
|
1121
|
-
return null
|
|
1122
|
-
}
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
/**
|
|
1126
|
-
* `runGit` 的字节版。
|
|
1127
|
-
*
|
|
1128
|
-
* `cat-file blob` 的输出是任意二进制,按 utf8 解码会毁掉它(非法序列变成 U+FFFD,
|
|
1129
|
-
* 再编码回去就不是原文件了),所以这条路径必须单独走 buffer 编码。
|
|
1130
|
-
*/
|
|
1131
|
-
function runGitBytes(cwd, args, maxBytes) {
|
|
1132
|
-
return new Promise((resolve) => {
|
|
1133
|
-
execFile(
|
|
1134
|
-
'git',
|
|
1135
|
-
args,
|
|
1136
|
-
{ cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: maxBytes, windowsHide: true, encoding: 'buffer' },
|
|
1137
|
-
(error, stdout, stderr) => {
|
|
1138
|
-
if (!error) {
|
|
1139
|
-
resolve({ ok: true, stdout })
|
|
1140
|
-
return
|
|
1141
|
-
}
|
|
1142
|
-
resolve({
|
|
1143
|
-
ok: false,
|
|
1144
|
-
stdout,
|
|
1145
|
-
stderr: stderr ? stderr.toString('utf8') : '',
|
|
1146
|
-
killed: Boolean(error.killed) || Boolean(error.signal),
|
|
1147
|
-
code: typeof error.code === 'number' ? error.code : (error.code ?? null),
|
|
1148
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1149
|
-
})
|
|
1150
|
-
},
|
|
1151
|
-
)
|
|
1152
|
-
})
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
/** 一个 git 对象的字节数;对象不存在(新文件还没有索引副本)返回 null。 */
|
|
1156
|
-
async function gitBlobSize(repoRoot, spec) {
|
|
1157
|
-
const result = await runGit(repoRoot, ['cat-file', '-s', spec])
|
|
1158
|
-
if (!result.ok) return null
|
|
1159
|
-
const size = Number.parseInt(result.stdout.trim(), 10)
|
|
1160
|
-
return Number.isFinite(size) && size >= 0 ? size : null
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
/** 判断一侧的内容:能预览就带上 base64 与像素尺寸,否则只说"为什么不能"。 */
|
|
1164
|
-
function describeSide(buffer) {
|
|
1165
|
-
const bytes = buffer.length
|
|
1166
|
-
const mime = sniffImageMime(buffer)
|
|
1167
|
-
if (mime === null) return { kind: 'not-image', bytes }
|
|
1168
|
-
const size = readImageSize(buffer, mime)
|
|
1169
|
-
return {
|
|
1170
|
-
kind: 'image',
|
|
1171
|
-
mime,
|
|
1172
|
-
bytes,
|
|
1173
|
-
width: size === null ? null : size.width,
|
|
1174
|
-
height: size === null ? null : size.height,
|
|
1175
|
-
base64: buffer.toString('base64'),
|
|
1176
|
-
}
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
/** 读工作区里的一个文件;不是普通文件、或超过上限,都只回报状态。 */
|
|
1180
|
-
async function readWorktreeSide(repoRoot, file) {
|
|
1181
|
-
const full = path.resolve(repoRoot, file)
|
|
1182
|
-
const rootPrefix = repoRoot.endsWith(path.sep) ? repoRoot : repoRoot + path.sep
|
|
1183
|
-
// validateDiffPath 已经挡掉绝对路径与 `..`;这里再确认落在仓库内(软链接不越界)。
|
|
1184
|
-
if (!full.startsWith(rootPrefix)) return { kind: 'missing' }
|
|
1185
|
-
let stats
|
|
1186
|
-
try {
|
|
1187
|
-
stats = await fs.promises.stat(full)
|
|
1188
|
-
}
|
|
1189
|
-
catch {
|
|
1190
|
-
return { kind: 'missing' }
|
|
1191
|
-
}
|
|
1192
|
-
if (!stats.isFile()) return { kind: 'missing' }
|
|
1193
|
-
if (stats.size > MAX_IMAGE_BYTES) return { kind: 'too-large', bytes: stats.size, limit: MAX_IMAGE_BYTES }
|
|
1194
|
-
try {
|
|
1195
|
-
return describeSide(await fs.promises.readFile(full))
|
|
1196
|
-
}
|
|
1197
|
-
catch {
|
|
1198
|
-
return { kind: 'missing' }
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
/** 读一个 git 对象(`<rev>:<path>`,rev 为空串即索引):不存在即 missing,超限只回报字节数。 */
|
|
1203
|
-
async function readGitSide(repoRoot, rev, file) {
|
|
1204
|
-
const spec = `${rev}:${file}`
|
|
1205
|
-
const size = await gitBlobSize(repoRoot, spec)
|
|
1206
|
-
if (size === null) return { kind: 'missing' }
|
|
1207
|
-
if (size > MAX_IMAGE_BYTES) return { kind: 'too-large', bytes: size, limit: MAX_IMAGE_BYTES }
|
|
1208
|
-
const result = await runGitBytes(repoRoot, ['cat-file', 'blob', spec], MAX_IMAGE_BYTES + 64 * 1024)
|
|
1209
|
-
if (!result.ok) {
|
|
1210
|
-
if (result.killed) throw Object.assign(new Error('git cat-file timed out'), { code: 'timeout' })
|
|
1211
|
-
return { kind: 'missing' }
|
|
1212
|
-
}
|
|
1213
|
-
return describeSide(result.stdout)
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
|
-
/**
|
|
1217
|
-
* 读一个二进制(图片)改动的两侧字节与像素尺寸,全部只读。
|
|
1218
|
-
*
|
|
1219
|
-
* `side` 与文本 diff 的两枚页签一一对应,这样"未暂存 / 已暂存"在两个视图里指的是同一件事:
|
|
1220
|
-
* - `worktree`(未暂存):基线 = 索引 `:<path>`,新 = 工作区文件;
|
|
1221
|
-
* - `index`(已暂存):基线 = `HEAD`,新 = 索引 `:<path>`;
|
|
1222
|
-
* - 给了 `sha`(提交明细):基线 = 第一父提交(根提交则为空),新 = 该提交。
|
|
1223
|
-
*
|
|
1224
|
-
* 两侧各自回答"能不能预览":`kind` 为 `image` / `too-large` / `not-image` / `missing`。
|
|
1225
|
-
* 任一侧不可预览都不算失败 —— 另一侧照样能看。
|
|
1226
|
-
*
|
|
1227
|
-
* @param cwd - 会话工作区(绝对路径)。
|
|
1228
|
-
* @param file - 仓库内相对路径。
|
|
1229
|
-
* @param origFile - 重命名/复制的原路径,可选。
|
|
1230
|
-
* @param side - `'worktree'` | `'index'`,给了 sha 时忽略。
|
|
1231
|
-
* @param sha - 提交 sha(提交明细用),可选。
|
|
1232
|
-
* @returns `{ repo, path, origPath, side, sha, before, after }`。
|
|
1233
|
-
*/
|
|
1234
|
-
export async function readImagePair(cwd, file, origFile = null, side = 'worktree', sha = null) {
|
|
1235
|
-
const base = { path: file, origPath: origFile ?? null, side: sha === null ? side : 'commit', sha }
|
|
1236
|
-
const empty = { repo: null, ...base, before: { kind: 'missing' }, after: { kind: 'missing' } }
|
|
1237
|
-
|
|
1238
|
-
if (sha !== null) {
|
|
1239
|
-
const commit = await resolveCommit(cwd, sha)
|
|
1240
|
-
if (!commit.repo) return empty
|
|
1241
|
-
return {
|
|
1242
|
-
repo: commit.repo,
|
|
1243
|
-
shortSha: commit.shortSha,
|
|
1244
|
-
...base,
|
|
1245
|
-
before: commit.base === null ? { kind: 'missing' } : await readGitSide(commit.repo, commit.base, origFile ?? file),
|
|
1246
|
-
after: await readGitSide(commit.repo, commit.sha, file),
|
|
1247
|
-
}
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
const root = await runGit(cwd, ['rev-parse', '--show-toplevel'])
|
|
1251
|
-
if (!root.ok) {
|
|
1252
|
-
if (root.killed) throw Object.assign(new Error('git rev-parse timed out'), { code: 'timeout' })
|
|
1253
|
-
if (root.code === 'ENOENT' && !fs.existsSync(cwd)) throw Object.assign(new Error('cwd does not exist'), { code: 'no-cwd' })
|
|
1254
|
-
if (root.code === 'ENOENT') throw Object.assign(new Error('git executable not found'), { code: 'git-missing' })
|
|
1255
|
-
return empty
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
const repoRoot = root.stdout.split('\n')[0].trim()
|
|
1259
|
-
if (side === 'index') {
|
|
1260
|
-
return {
|
|
1261
|
-
repo: repoRoot,
|
|
1262
|
-
...base,
|
|
1263
|
-
before: await readGitSide(repoRoot, 'HEAD', origFile ?? file),
|
|
1264
|
-
after: await readGitSide(repoRoot, '', file),
|
|
1265
|
-
}
|
|
1266
|
-
}
|
|
1267
|
-
return {
|
|
1268
|
-
repo: repoRoot,
|
|
1269
|
-
...base,
|
|
1270
|
-
before: await readGitSide(repoRoot, '', origFile ?? file),
|
|
1271
|
-
after: await readWorktreeSide(repoRoot, file),
|
|
1272
|
-
}
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
/* ------------------------------------------------- 会话改动(按会话归因) */
|
|
1276
|
-
|
|
1277
|
-
/**
|
|
1278
|
-
* "哪个会话写过哪个文件"——事实源是 fs seam 派发的两类事件:
|
|
1279
|
-
*
|
|
1280
|
-
* - `fs/write-intent` / `fs/edit-intent`(waterfall,参数 `(target, exec)`):这次**要**动
|
|
1281
|
-
* 哪个文件。`dsh-tool-fs` 的写入/编辑工具与 `dsh-tool-str-replace-editor` 都走 `ctx.fs`,
|
|
1282
|
-
* 因此都会发这两个意图事件。
|
|
1283
|
-
* - `fs/observed`(emit,参数 `(target, observation, exec)`):那一下**之后**文件是 `present`
|
|
1284
|
-
* 还是 `absent`。注意它对**读取**也发("编辑前必须先读"那个策略插件就是靠它),所以不能
|
|
1285
|
-
* 只看它 —— 必须与意图配对:同一个工具调用里先有意图、后有观察,才算这次改动落了地。
|
|
1286
|
-
*
|
|
1287
|
-
* `exec.agent.session.header` 同时给出 `id`(哪个会话)与 `cwd`(哪个工作区),所以"谁改的"
|
|
1288
|
-
* 不用猜。只记 cwd 之内的路径(git 只关心这些),每个会话与全局都有上限,避免长会话吃内存。
|
|
1289
|
-
*
|
|
1290
|
-
* 已知不覆盖(界面上如实标注,不假装全知):
|
|
1291
|
-
* - agent 通过 bash 间接改的文件(`sed -i`、脚本、格式化、`git` 操作);
|
|
1292
|
-
* - 本进程启动**之前**的改动(fs 观察不持久化)——所以每个会话都报一个 `since`。
|
|
1293
|
-
*/
|
|
1294
|
-
|
|
1295
|
-
/** 一次最多给多少个改动文件算"这一版"的签名(超出的那些只显示"未审",不报错)。 */
|
|
1296
|
-
const REVIEW_SIGNATURE_MAX = 200
|
|
1297
|
-
/** 单个会话最多记多少个文件;超出只计数(`dropped`),不做 LRU 淘汰。 */
|
|
1298
|
-
const SESSION_CHANGE_MAX_FILES = 500
|
|
1299
|
-
/** 最多同时记多少个会话(按最近一次改动淘汰最旧的)。 */
|
|
1300
|
-
const SESSION_CHANGE_MAX_SESSIONS = 32
|
|
1301
|
-
/** 单个会话最多记多少个"可能相关"的路径(命令旁听来的,优先级低于确定改动)。 */
|
|
1302
|
-
const SESSION_CHANGE_MAX_POSSIBLE = 300
|
|
1303
|
-
/** 每个会话留多少条命令历史(面板悬停时能看到"跑过什么")。 */
|
|
1304
|
-
const SESSION_COMMAND_MAX = 20
|
|
1305
|
-
/** 命令在界面上的显示上限(超长命令截断,别把整行撑爆)。 */
|
|
1306
|
-
const COMMAND_TEXT_MAX = 140
|
|
1307
|
-
/** 一条命令里最多抽多少个路径(防御性上限)。 */
|
|
1308
|
-
const COMMAND_PATHS_MAX = 20
|
|
1309
|
-
/** 每个会话留多少条"最近工具调用"(胶囊悬停里列最近几条)。 */
|
|
1310
|
-
const TOOL_RECENT_MAX = 12
|
|
1311
|
-
/** 最多同时记多少个会话的工具活动(与归因那份各自独立淘汰)。 */
|
|
1312
|
-
const TOOL_SESSIONS_MAX = 32
|
|
1313
|
-
/** 每个会话最多记多少个技能(超出的只计数不列名)。 */
|
|
1314
|
-
const TOOL_SKILLS_MAX = 20
|
|
1315
|
-
/** 参数摘要的显示上限(超长命令/路径截断,别把胶囊撑爆)。 */
|
|
1316
|
-
const TOOL_SUMMARY_MAX = 60
|
|
1317
|
-
|
|
1318
|
-
/**
|
|
1319
|
-
* sessionId → { active, recent, counts, total }:这个会话**跑到哪些工具**。
|
|
1320
|
-
*
|
|
1321
|
-
* 与归因那份记录分开存:归因关心"改了哪些文件",这份关心"跑了什么"。
|
|
1322
|
-
*/
|
|
1323
|
-
const sessionTools = new Map()
|
|
1324
|
-
|
|
1325
|
-
/** 取(必要时新建)某个会话的工具活动记录,并做数量上限淘汰。 */
|
|
1326
|
-
function sessionToolEntry(sessionId) {
|
|
1327
|
-
let entry = sessionTools.get(sessionId)
|
|
1328
|
-
if (entry === undefined) {
|
|
1329
|
-
entry = { active: null, recent: [], counts: new Map(), status: new Map(), skills: new Map(), total: 0 }
|
|
1330
|
-
sessionTools.set(sessionId, entry)
|
|
1331
|
-
if (sessionTools.size > TOOL_SESSIONS_MAX) {
|
|
1332
|
-
const oldest = sessionTools.keys().next().value
|
|
1333
|
-
if (oldest !== undefined && oldest !== sessionId) sessionTools.delete(oldest)
|
|
1334
|
-
}
|
|
1335
|
-
}
|
|
1336
|
-
return entry
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
/**
|
|
1340
|
-
* 参数里可以安全拿出来做"一行摘要"的键。
|
|
1341
|
-
*
|
|
1342
|
-
* 只认这些**路径/命令类**的键:工具参数里可能带着整个文件内容(write 的 `content`),
|
|
1343
|
-
* 随便取"第一个字符串"就会把内容贴到界面上 —— 所以认不出来就干脆不写摘要。
|
|
1344
|
-
*/
|
|
1345
|
-
const TOOL_SUMMARY_KEYS = ['command', 'cmd', 'script', 'filePath', 'path', 'file', 'pattern', 'query', 'url']
|
|
1346
|
-
|
|
1347
|
-
/** 「调用技能」那件工具的工具名(`dsh-tool-skill` 注册的名字)。 */
|
|
1348
|
-
const SKILL_TOOL_NAME = 'skill'
|
|
1349
|
-
|
|
1350
|
-
/**
|
|
1351
|
-
* 技能名:`skill` 工具的入参是 `{ name: '技能名' }`。
|
|
1352
|
-
*
|
|
1353
|
-
* 只对这一个工具认 `name` 键 —— 别的工具用 `name` 表达别的东西,不能被当技能名抓走。
|
|
1354
|
-
*/
|
|
1355
|
-
function skillNameOf(exec) {
|
|
1356
|
-
if (exec === null || typeof exec !== 'object' || exec.name !== SKILL_TOOL_NAME) return null
|
|
1357
|
-
const args = exec.arguments
|
|
1358
|
-
if (args === null || typeof args !== 'object') return null
|
|
1359
|
-
const value = args.name
|
|
1360
|
-
return typeof value === 'string' && value.trim().length > 0 ? value.trim().slice(0, TOOL_SUMMARY_MAX) : null
|
|
1361
|
-
}
|
|
1362
|
-
|
|
1363
|
-
/** 从工具调用参数里取一行摘要(认不出来返回 null,不用兜底去猜)。 */
|
|
1364
|
-
function toolSummaryOf(exec) {
|
|
1365
|
-
const skill = skillNameOf(exec)
|
|
1366
|
-
if (skill !== null) return skill
|
|
1367
|
-
const args = exec && exec.arguments
|
|
1368
|
-
if (args === null || typeof args !== 'object') return null
|
|
1369
|
-
for (const key of TOOL_SUMMARY_KEYS) {
|
|
1370
|
-
const value = args[key]
|
|
1371
|
-
if (typeof value === 'string' && value.trim().length > 0) {
|
|
1372
|
-
return value.replace(/\s+/g, ' ').trim().slice(0, TOOL_SUMMARY_MAX)
|
|
1373
|
-
}
|
|
1374
|
-
}
|
|
1375
|
-
return null
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
/** 工具开跑:记下"现在是哪个工具、什么时候开始的"(供胶囊显示实时秒数)。 */
|
|
1379
|
-
function noteToolStart(exec) {
|
|
1380
|
-
try {
|
|
1381
|
-
const identity = sessionIdentityOf(exec)
|
|
1382
|
-
if (identity === null) return
|
|
1383
|
-
const entry = sessionToolEntry(identity.sessionId)
|
|
1384
|
-
entry.active = {
|
|
1385
|
-
callId: identity.callId,
|
|
1386
|
-
name: typeof exec.name === 'string' && exec.name.length > 0 ? exec.name : '?',
|
|
1387
|
-
summary: toolSummaryOf(exec),
|
|
1388
|
-
startedAt: Date.now(),
|
|
1389
|
-
}
|
|
1390
|
-
entry.total += 1
|
|
1391
|
-
const name = entry.active.name
|
|
1392
|
-
entry.counts.set(name, (entry.counts.get(name) || 0) + 1)
|
|
1393
|
-
const skill = skillNameOf(exec)
|
|
1394
|
-
if (skill !== null) {
|
|
1395
|
-
const seen = entry.skills.get(skill)
|
|
1396
|
-
entry.skills.set(skill, { count: (seen ? seen.count : 0) + 1, ok: seen ? seen.ok : null })
|
|
1397
|
-
}
|
|
1398
|
-
} catch {
|
|
1399
|
-
// 旁听失败不影响工具本身。
|
|
1400
|
-
}
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
/** 工具结束:把这一次挪进"最近",并清掉 active(只有同一个 callId 才清,避免并发调用互相抹掉)。 */
|
|
1404
|
-
function noteToolFinish(exec, result) {
|
|
1405
|
-
try {
|
|
1406
|
-
const identity = sessionIdentityOf(exec)
|
|
1407
|
-
if (identity === null) return
|
|
1408
|
-
const entry = sessionToolEntry(identity.sessionId)
|
|
1409
|
-
const active = entry.active
|
|
1410
|
-
const startedAt = active !== null && active.callId === identity.callId ? active.startedAt : null
|
|
1411
|
-
entry.recent.push({
|
|
1412
|
-
name: active !== null && active.callId === identity.callId ? active.name : (typeof exec.name === 'string' ? exec.name : '?'),
|
|
1413
|
-
summary: active !== null && active.callId === identity.callId ? active.summary : toolSummaryOf(exec),
|
|
1414
|
-
ok: !resultFailed(result),
|
|
1415
|
-
at: Date.now(),
|
|
1416
|
-
ms: startedAt === null ? null : Math.max(0, Date.now() - startedAt),
|
|
1417
|
-
})
|
|
1418
|
-
if (entry.recent.length > TOOL_RECENT_MAX) entry.recent.splice(0, entry.recent.length - TOOL_RECENT_MAX)
|
|
1419
|
-
// 每个工具"最近一次调用"的结果:胶囊上每个工具前面那个 ✓/✗ 用它。
|
|
1420
|
-
const finished = entry.recent[entry.recent.length - 1]
|
|
1421
|
-
if (finished !== undefined) entry.status.set(finished.name, finished.ok === true)
|
|
1422
|
-
// 技能自己的最近一次结果(胶囊上可以据此打 ✓/✗)。
|
|
1423
|
-
const skill = skillNameOf(exec)
|
|
1424
|
-
if (skill !== null && entry.skills.has(skill)) {
|
|
1425
|
-
entry.skills.set(skill, { count: entry.skills.get(skill).count, ok: !resultFailed(result) })
|
|
1426
|
-
}
|
|
1427
|
-
if (active !== null && active.callId === identity.callId) entry.active = null
|
|
1428
|
-
} catch {
|
|
1429
|
-
// 同上。
|
|
1430
|
-
}
|
|
1431
|
-
}
|
|
1432
|
-
|
|
1433
|
-
/**
|
|
1434
|
-
* 读出某个会话的工具活动(端点载荷)。
|
|
1435
|
-
*
|
|
1436
|
-
* `active` 带 `startedAt`:实时秒数由客户端本地计时(否则要为了一个秒数把轮询压到亚秒级 ✗)。
|
|
1437
|
-
*/
|
|
1438
|
-
export function readSessionTools(sessionId) {
|
|
1439
|
-
const entry = sessionId === null || sessionId === undefined ? undefined : sessionTools.get(sessionId)
|
|
1440
|
-
if (entry === undefined) {
|
|
1441
|
-
return { sessionId: sessionId ?? null, tracking: true, active: null, recent: [], counts: [], skills: [], skillsDropped: 0, total: 0 }
|
|
1442
|
-
}
|
|
1443
|
-
const counts = [...entry.counts].map(([name, count]) => ({
|
|
1444
|
-
name,
|
|
1445
|
-
count,
|
|
1446
|
-
// `null` = 还没看到它结束过(比如正在跑第一次),别替它编一个 ✓。
|
|
1447
|
-
ok: entry.status.has(name) ? entry.status.get(name) : null,
|
|
1448
|
-
}))
|
|
1449
|
-
counts.sort((left, right) => right.count - left.count || (left.name < right.name ? -1 : 1))
|
|
1450
|
-
// 技能按次数排序(用得多在前),超过上限只报前若干个。
|
|
1451
|
-
const skills = [...entry.skills].map(([name, item]) => ({ name, count: item.count, ok: item.ok }))
|
|
1452
|
-
skills.sort((left, right) => right.count - left.count || (left.name < right.name ? -1 : 1))
|
|
1453
|
-
return {
|
|
1454
|
-
sessionId,
|
|
1455
|
-
tracking: true,
|
|
1456
|
-
active: entry.active === null ? null : { ...entry.active },
|
|
1457
|
-
recent: entry.recent.slice(-5).reverse(),
|
|
1458
|
-
counts,
|
|
1459
|
-
skills: skills.slice(0, TOOL_SKILLS_MAX),
|
|
1460
|
-
skillsDropped: Math.max(0, skills.length - TOOL_SKILLS_MAX),
|
|
1461
|
-
total: entry.total,
|
|
1462
|
-
}
|
|
1463
|
-
}
|
|
1464
|
-
|
|
1465
|
-
/** sessionId → { cwd, since, files: Map<relPath, {...}>, dropped, unconfirmed }。 */
|
|
1466
|
-
const sessionChanges = new Map()
|
|
1467
|
-
/** 本次监听开始的时刻 —— 界面说的"自 … 起记录"用的是它(不是"第一条改动的时间")。 */
|
|
1468
|
-
let sessionChangeTrackingSince = null
|
|
1469
|
-
/** callId(没有就退化成 sessionId+路径)→ 尚未配对到 `fs/observed` 的意图。 */
|
|
1470
|
-
const pendingChangeIntents = new Map()
|
|
1471
|
-
/**
|
|
1472
|
-
* 从会话取"当前回合号"的读取器。
|
|
1473
|
-
*
|
|
1474
|
-
* `sessionProjections` 是**软依赖**(`ctx.inject` 动态等它到齐):拿不到就记 `null`,
|
|
1475
|
-
* 归因照常工作 —— 绝不因为少一个服务把整块降级掉。
|
|
1476
|
-
*/
|
|
1477
|
-
let sessionTurnReader = null
|
|
1478
|
-
/**
|
|
1479
|
-
* `sessionStats` 投影的读数器(内置 `@deepseek-ai/dsh-session-stats` 提供)。
|
|
1480
|
-
*
|
|
1481
|
-
* 会话计时胶囊要的是"这个会话跑了多久、模型/工具各占多少"—— 这些正是它的字段,
|
|
1482
|
-
* 而且是**从完整持久日志折叠**出来的,分页/压实都不会让数字跳变。
|
|
1483
|
-
* 同样是软依赖:这个键没注册(插件没装/被组合掉)就回 null,胶囊只显示挂钟时长。
|
|
1484
|
-
*/
|
|
1485
|
-
let sessionStatsReader = null
|
|
1486
|
-
/**
|
|
1487
|
-
* `turnOutline` 投影的读数器:每条 turn 都带着原始 `prompt`。
|
|
1488
|
-
*
|
|
1489
|
-
* 这是 prompt 历史最好的来源 —— 比在客户端自己记一份可靠:分页、压实、重启、换设备都不影响,
|
|
1490
|
-
* 因为它就是会话日志折叠出来的。同样是软依赖(投影没注册就回 null,功能整个不启用)。
|
|
1491
|
-
*/
|
|
1492
|
-
let sessionOutlineReader = null
|
|
1493
|
-
|
|
1494
|
-
/** prompt 历史的条数上限(与客户端的 `↑` 召回共用这一个数)。 */
|
|
1495
|
-
export const PROMPT_HISTORY_MAX = 100
|
|
1496
|
-
/**
|
|
1497
|
-
* 宿主 `sessions` 服务(枚举所有活着的会话)—— 同样是软依赖:拿不到时
|
|
1498
|
-
* `session-statuses` 端点报"不可用",侧栏那枚 chip 就不画,其余功能照旧。
|
|
1499
|
-
*/
|
|
1500
|
-
let hostSessions = null
|
|
1501
|
-
/**
|
|
1502
|
-
* 宿主 ctx 的引用。用于**兜底**取 `sessions`:注入回调没到齐、或者被别的挂载实例的
|
|
1503
|
-
* 卸载清掉时(模块级状态 + 每个挂载各自 return 清理,本来就有这个缝),
|
|
1504
|
-
* 按内置契约用 `ctx.get("sessions")` 再问一次(Service 目录里写明的可选取法)。
|
|
1505
|
-
*/
|
|
1506
|
-
let hostContext = null
|
|
1507
|
-
|
|
1508
|
-
/** 会话服务:先信注入给的那份,再按契约兜底问 ctx。两个调用方共用这一处。 */
|
|
1509
|
-
function sessionsService() {
|
|
1510
|
-
if (hostSessions !== null) return hostSessions
|
|
1511
|
-
try {
|
|
1512
|
-
const fallback = hostContext !== null && typeof hostContext.get === 'function' ? hostContext.get('sessions') : undefined
|
|
1513
|
-
return fallback === undefined ? null : fallback
|
|
1514
|
-
} catch {
|
|
1515
|
-
return null
|
|
1516
|
-
}
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
/** 这次调用发生在第几回合(拿不到就是 null,界面据此不显示回合信息)。 */
|
|
1520
|
-
function currentTurnOf(exec) {
|
|
1521
|
-
if (sessionTurnReader === null) return null
|
|
1522
|
-
const session = exec && exec.agent ? exec.agent.session : undefined
|
|
1523
|
-
if (session === undefined || session === null) return null
|
|
1524
|
-
return sessionTurnReader(session)
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
|
-
/** 这个会话第一次出现在记录里的时刻(客户端据此说"自 … 起")。 */
|
|
1528
|
-
function sessionChangeEntry(sessionId, cwd) {
|
|
1529
|
-
let entry = sessionChanges.get(sessionId)
|
|
1530
|
-
if (entry === undefined) {
|
|
1531
|
-
entry = {
|
|
1532
|
-
cwd,
|
|
1533
|
-
since: Date.now(),
|
|
1534
|
-
files: new Map(),
|
|
1535
|
-
dropped: 0,
|
|
1536
|
-
// 命令旁听来的"可能相关":与 files 分开存,绝不冒充"确定改过"。
|
|
1537
|
-
possible: new Map(),
|
|
1538
|
-
possibleDropped: 0,
|
|
1539
|
-
commands: [],
|
|
1540
|
-
}
|
|
1541
|
-
sessionChanges.set(sessionId, entry)
|
|
1542
|
-
if (sessionChanges.size > SESSION_CHANGE_MAX_SESSIONS) {
|
|
1543
|
-
// 先淘汰最久没动过的那个(Map 保持插入序,删第一个就是最旧的)。
|
|
1544
|
-
const oldest = sessionChanges.keys().next().value
|
|
1545
|
-
if (oldest !== undefined && oldest !== sessionId) sessionChanges.delete(oldest)
|
|
1546
|
-
}
|
|
1547
|
-
}
|
|
1548
|
-
if (cwd !== undefined && cwd !== null) entry.cwd = cwd
|
|
1549
|
-
return entry
|
|
1550
|
-
}
|
|
1551
|
-
|
|
1552
|
-
/** 把绝对路径折成 cwd 之内的 posix 相对路径;在 cwd 之外或拿不到都返回 null。 */
|
|
1553
|
-
function sessionRelativePath(cwd, absolutePath) {
|
|
1554
|
-
if (typeof cwd !== 'string' || cwd.length === 0) return null
|
|
1555
|
-
if (typeof absolutePath !== 'string' || absolutePath.length === 0) return null
|
|
1556
|
-
if (!path.isAbsolute(absolutePath)) return null
|
|
1557
|
-
const relative = path.relative(cwd, absolutePath)
|
|
1558
|
-
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) return null
|
|
1559
|
-
return relative.split(path.sep).join('/')
|
|
1560
|
-
}
|
|
1561
|
-
|
|
1562
|
-
/**
|
|
1563
|
-
* 会自己改文件的"命令行工具":bash / pwsh / 各种 shell。
|
|
1564
|
-
*
|
|
1565
|
-
* 用名字前缀匹配而不是精确清单:产品里有 `bash`、`bash-persistent`、`pwsh`、`pwsh-persistent`
|
|
1566
|
-
* 等等,将来还会加,硬编码清单一定会过期。
|
|
1567
|
-
*/
|
|
1568
|
-
const SHELL_TOOL_RE = /^(?:bash|pwsh|sh|zsh|fish|shell|cmd|powershell|exec_command|run_command|terminal)/i
|
|
1569
|
-
|
|
1570
|
-
/** 从工具调用参数里取命令行文本(不同 shell 工具的字段名不一样,按常见几个找)。 */
|
|
1571
|
-
function shellCommandOf(exec) {
|
|
1572
|
-
if (exec === undefined || exec === null) return null
|
|
1573
|
-
if (typeof exec.name !== 'string' || !SHELL_TOOL_RE.test(exec.name)) return null
|
|
1574
|
-
const args = exec.arguments
|
|
1575
|
-
if (args === null || typeof args !== 'object') return null
|
|
1576
|
-
for (const key of ['command', 'cmd', 'script', 'input', 'line']) {
|
|
1577
|
-
const value = args[key]
|
|
1578
|
-
if (typeof value === 'string' && value.trim().length > 0) return value
|
|
1579
|
-
}
|
|
1580
|
-
return null
|
|
1581
|
-
}
|
|
1582
|
-
|
|
1583
|
-
/** 按 `; && || | 换行` 切分命令(只为抽路径,不做真正的 shell 解析)。 */
|
|
1584
|
-
function shellSegments(command) {
|
|
1585
|
-
return command
|
|
1586
|
-
.split(/\r?\n|;|&&|\|\||\|/)
|
|
1587
|
-
.map(segment => segment.trim())
|
|
1588
|
-
.filter(segment => segment.length > 0)
|
|
1589
|
-
}
|
|
1590
|
-
|
|
1591
|
-
/** 极简分词:支持单双引号(不处理转义与嵌套),够抽路径用。 */
|
|
1592
|
-
function shellTokens(segment) {
|
|
1593
|
-
const tokens = []
|
|
1594
|
-
let current = ''
|
|
1595
|
-
let quote = null
|
|
1596
|
-
for (const ch of segment) {
|
|
1597
|
-
if (quote !== null) {
|
|
1598
|
-
if (ch === quote) quote = null
|
|
1599
|
-
else current += ch
|
|
1600
|
-
continue
|
|
1601
|
-
}
|
|
1602
|
-
if (ch === '"' || ch === "'") {
|
|
1603
|
-
quote = ch
|
|
1604
|
-
continue
|
|
1605
|
-
}
|
|
1606
|
-
if (ch === ' ' || ch === '\t') {
|
|
1607
|
-
if (current.length > 0) {
|
|
1608
|
-
tokens.push(current)
|
|
1609
|
-
current = ''
|
|
1610
|
-
}
|
|
1611
|
-
continue
|
|
1612
|
-
}
|
|
1613
|
-
current += ch
|
|
1614
|
-
}
|
|
1615
|
-
if (current.length > 0) tokens.push(current)
|
|
1616
|
-
return tokens
|
|
1617
|
-
}
|
|
1618
|
-
|
|
1619
|
-
/** 环境变量赋值、sudo/env 之类的包装前缀:抽路径时要跳过。 */
|
|
1620
|
-
const SHELL_PREFIX_RE = /^(?:[A-Za-z_][A-Za-z0-9_]*=|sudo$|env$|time$|nohup$|command$|builtin$|exec$|then$|do$|if$|while$)/
|
|
1621
|
-
/** 写入类命令:它们改文件,"目标"在哪一个参数上按命令而定。 */
|
|
1622
|
-
const WRITE_COMMANDS = new Set([
|
|
1623
|
-
'mv', 'cp', 'ln', 'install', 'rsync', 'truncate', 'touch', 'rm', 'unlink',
|
|
1624
|
-
'sed', 'perl', 'tee', 'dd', 'patch', 'chmod', 'chown', 'gzip', 'gunzip', 'tar', 'unzip', 'zip',
|
|
1625
|
-
])
|
|
1626
|
-
/** 只读命令:即使带了参数也不要记成"可能改过"。 */
|
|
1627
|
-
const READ_COMMANDS = new Set([
|
|
1628
|
-
'cat', 'less', 'more', 'head', 'tail', 'grep', 'rg', 'ls', 'find', 'stat', 'wc', 'file', 'du',
|
|
1629
|
-
'diff', 'git', 'echo', 'printf', 'pwd', 'which', 'type', 'env', 'date', 'sort', 'uniq', 'awk',
|
|
1630
|
-
'jq', 'node', 'python', 'python3', 'true', 'false', 'test', 'cd', 'export', 'set', 'source',
|
|
1631
|
-
])
|
|
1632
|
-
|
|
1633
|
-
/**
|
|
1634
|
-
* 从一条命令里抽出"可能被改动的路径"。
|
|
1635
|
-
*
|
|
1636
|
-
* 这是**启发式**,只做三类有把握的事,其余一律退回"命令级提示"(不声称改过哪些路径):
|
|
1637
|
-
* 1. 重定向与 `tee` 的目标(`>`、`>>`、`tee file`);
|
|
1638
|
-
* 2. 原地编辑(`sed -i`、`perl -i`)与写入类命令(`mv`/`cp`/`rm`/`dd of=`…)的目标参数;
|
|
1639
|
-
* 3. `git checkout|restore <path>` 这类"把文件改回某个版本"的命令。
|
|
1640
|
-
* 只读命令(`cat`、`grep`、`git status`…)即使带路径也不记。
|
|
1641
|
-
* @returns {{ path: string, reason: string }[]} 去重后的候选路径(原样 token,未解析)。
|
|
1642
|
-
*/
|
|
1643
|
-
function possiblePathsOf(command) {
|
|
1644
|
-
const found = new Map()
|
|
1645
|
-
const add = (token, reason) => {
|
|
1646
|
-
const cleaned = String(token).replace(/^["']|["']$/g, '')
|
|
1647
|
-
if (cleaned.length === 0 || cleaned === '/' || cleaned.startsWith('/dev/')) return
|
|
1648
|
-
if (/^&?\d+$/.test(cleaned) || cleaned.startsWith('&')) return
|
|
1649
|
-
if (cleaned.startsWith('-')) return
|
|
1650
|
-
if (cleaned.includes('$') || cleaned.includes('*') || cleaned.includes('?')) return
|
|
1651
|
-
if (found.has(cleaned)) return
|
|
1652
|
-
found.set(cleaned, reason.slice(0, 80))
|
|
1653
|
-
}
|
|
1654
|
-
|
|
1655
|
-
for (const segment of shellSegments(command)) {
|
|
1656
|
-
// 1) 重定向与 tee:即使命令本身不认识也要抽。
|
|
1657
|
-
const redirects = segment.matchAll(/(?:^|[^0-9<>])>>?\s*([^\s;&|<>()]+)/g)
|
|
1658
|
-
for (const match of redirects) add(match[1], segment)
|
|
1659
|
-
const tees = segment.matchAll(/\btee\s+(?:-[A-Za-z-]+\s+)*([^\s;&|<>()]+)/g)
|
|
1660
|
-
for (const match of tees) add(match[1], segment)
|
|
1661
|
-
|
|
1662
|
-
const tokens = shellTokens(segment).filter(token => !SHELL_PREFIX_RE.test(token))
|
|
1663
|
-
let index = 0
|
|
1664
|
-
while (index < tokens.length && SHELL_PREFIX_RE.test(tokens[index])) index += 1
|
|
1665
|
-
const head = (tokens[index] || '').toLowerCase()
|
|
1666
|
-
const args = tokens.slice(index + 1)
|
|
1667
|
-
if (head.length === 0) continue
|
|
1668
|
-
|
|
1669
|
-
// 2) 原地编辑:`sed -i … <file>` / `perl -i … <file>` —— 取该段最后一个非 flag 参数。
|
|
1670
|
-
if ((head === 'sed' || head === 'perl') && args.some(arg => /^-[A-Za-z]*i/.test(arg))) {
|
|
1671
|
-
const last = [...args].reverse().find(arg => !arg.startsWith('-'))
|
|
1672
|
-
if (last !== undefined) add(last, segment)
|
|
1673
|
-
continue
|
|
1674
|
-
}
|
|
1675
|
-
// 3) `dd of=…`
|
|
1676
|
-
if (head === 'dd') {
|
|
1677
|
-
for (const arg of args) if (arg.startsWith('of=')) add(arg.slice(3), segment)
|
|
1678
|
-
continue
|
|
1679
|
-
}
|
|
1680
|
-
// 4) `git checkout|restore <path>`(会覆盖工作区文件)
|
|
1681
|
-
if (head === 'git') {
|
|
1682
|
-
const sub = (args[0] || '').toLowerCase()
|
|
1683
|
-
if (sub === 'checkout' || sub === 'restore') {
|
|
1684
|
-
const target = args.slice(1).find(arg => !arg.startsWith('-'))
|
|
1685
|
-
if (target !== undefined && target !== '.') add(target, segment)
|
|
1686
|
-
}
|
|
1687
|
-
continue
|
|
1688
|
-
}
|
|
1689
|
-
// 5) 写入类命令:`mv/cp/ln/install/rsync` 记目标(最后一个参数);其余记所有非 flag 参数。
|
|
1690
|
-
if (WRITE_COMMANDS.has(head)) {
|
|
1691
|
-
if (/^(?:chmod|chown|tar|gzip|gunzip|zip)$/.test(head)) continue
|
|
1692
|
-
// `mv` 的源也会"变"(消失),所以源与目标都记;其余写入类命令只记目标。
|
|
1693
|
-
if (head === 'mv') {
|
|
1694
|
-
for (const arg of args) if (!arg.startsWith('-')) add(arg, segment)
|
|
1695
|
-
continue
|
|
1696
|
-
}
|
|
1697
|
-
if (/^(?:cp|ln|install|rsync|patch)$/.test(head)) {
|
|
1698
|
-
const target = [...args].reverse().find(arg => !arg.startsWith('-'))
|
|
1699
|
-
if (target !== undefined) add(target, segment)
|
|
1700
|
-
continue
|
|
1701
|
-
}
|
|
1702
|
-
for (const arg of args) if (!arg.startsWith('-')) add(arg, segment)
|
|
1703
|
-
continue
|
|
1704
|
-
}
|
|
1705
|
-
// 其余(含只读命令、以及 npm/pnpm/prettier 这类"可能改了但说不清改哪"的)一律退回命令级提示。
|
|
1706
|
-
void READ_COMMANDS
|
|
1707
|
-
}
|
|
1708
|
-
return [...found].slice(0, COMMAND_PATHS_MAX).map(([pathToken, reason]) => ({ path: pathToken, reason }))
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
/** 命令是不是"看起来只读"(用于命令历史里的标注)。 */
|
|
1712
|
-
function commandLooksReadOnly(command) {
|
|
1713
|
-
const heads = shellSegments(command).map(segment => shellTokens(segment).filter(token => !SHELL_PREFIX_RE.test(token))[0] || '')
|
|
1714
|
-
if (heads.length === 0) return false
|
|
1715
|
-
return heads.every(head => READ_COMMANDS.has(head.toLowerCase()))
|
|
1716
|
-
}
|
|
1717
|
-
|
|
1718
|
-
/** 结果看起来是失败吗(失败的命令不改文件,就不算"可能相关")。 */
|
|
1719
|
-
function resultFailed(result) {
|
|
1720
|
-
if (result === null || typeof result !== 'object') return false
|
|
1721
|
-
if (result.ok === false) return true
|
|
1722
|
-
if (result.isError === true) return true
|
|
1723
|
-
return result.error !== undefined && result.error !== null
|
|
1724
|
-
}
|
|
1725
|
-
|
|
1726
|
-
/**
|
|
1727
|
-
* 把"第几回合"记到一条记录上(文件 / 可能相关)。
|
|
1728
|
-
*
|
|
1729
|
-
* `lastTurn` 是最近一次动它的回合,`turns` 是动过它的回合列表(去重、最多 8 个)——
|
|
1730
|
-
* 界面用前者做"只看最近一轮",后者留给悬停说明"这个文件被两轮动过"。
|
|
1731
|
-
*/
|
|
1732
|
-
function noteTurnOfRecord(record, turn) {
|
|
1733
|
-
record.lastTurn = turn
|
|
1734
|
-
if (turn === null) return
|
|
1735
|
-
if (!Array.isArray(record.turns)) record.turns = []
|
|
1736
|
-
if (record.turns[record.turns.length - 1] !== turn && record.turns.indexOf(turn) === -1) record.turns.push(turn)
|
|
1737
|
-
if (record.turns.length > 8) record.turns.splice(0, record.turns.length - 8)
|
|
1738
|
-
}
|
|
1739
|
-
|
|
1740
|
-
/** 从工具执行上下文里取会话身份与工作区;非 agent 调用返回 null。 */
|
|
1741
|
-
function sessionIdentityOf(exec) {
|
|
1742
|
-
const header = exec && exec.agent && exec.agent.session ? exec.agent.session.header : undefined
|
|
1743
|
-
if (header === undefined || header === null) return null
|
|
1744
|
-
if (typeof header.id !== 'string' || header.id.length === 0) return null
|
|
1745
|
-
const cwd = typeof header.cwd === 'string' && header.cwd.length > 0 ? header.cwd : undefined
|
|
1746
|
-
return { sessionId: header.id, cwd, callId: exec && typeof exec.callId === 'string' ? exec.callId : null }
|
|
1747
|
-
}
|
|
1748
|
-
|
|
1749
|
-
/** 意图的配对键:优先用调用 id(一次调用一个意图),没有就退化成"会话 + 路径"。 */
|
|
1750
|
-
function pendingKeyOf(identity, relative) {
|
|
1751
|
-
return identity.callId !== null ? `call:${identity.callId}` : `path:${identity.sessionId}\u0000${relative}`;
|
|
1752
|
-
}
|
|
1753
|
-
|
|
1754
|
-
/**
|
|
1755
|
-
* 记一次写入/编辑意图(事件回调;绝不抛,也绝不返回 next —— 这是 waterfall 的旁听)。
|
|
1756
|
-
* @param target - fs 目标(`displayPath` 是绝对路径)。
|
|
1757
|
-
* @param exec - 工具执行上下文。
|
|
1758
|
-
* @param kind - `"write"` 或 `"edit"`。
|
|
1759
|
-
*/
|
|
1760
|
-
function noteChangeIntent(target, exec, kind) {
|
|
1761
|
-
try {
|
|
1762
|
-
const identity = sessionIdentityOf(exec)
|
|
1763
|
-
if (identity === null) return
|
|
1764
|
-
const absolute = target && typeof target.displayPath === 'string' ? target.displayPath : null
|
|
1765
|
-
const relative = sessionRelativePath(identity.cwd, absolute)
|
|
1766
|
-
if (relative === null) return
|
|
1767
|
-
pendingChangeIntents.set(pendingKeyOf(identity, relative), {
|
|
1768
|
-
sessionId: identity.sessionId,
|
|
1769
|
-
cwd: identity.cwd,
|
|
1770
|
-
relative,
|
|
1771
|
-
kind,
|
|
1772
|
-
at: Date.now(),
|
|
1773
|
-
})
|
|
1774
|
-
// 配对表也会长:清掉明显过期的(超过 5 分钟还没等到观察的意图)。
|
|
1775
|
-
if (pendingChangeIntents.size > 512) {
|
|
1776
|
-
const cutoff = Date.now() - 5 * 60 * 1000
|
|
1777
|
-
for (const [key, entry] of pendingChangeIntents) if (entry.at < cutoff) pendingChangeIntents.delete(key)
|
|
1778
|
-
}
|
|
1779
|
-
} catch {
|
|
1780
|
-
// 旁听失败不影响写入本身。
|
|
1781
|
-
}
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
|
-
/**
|
|
1785
|
-
* 记一次观察:只有配得上"意图"的才算这次改动落了地(读取引起的观察一律忽略)。
|
|
1786
|
-
* @param target - fs 目标。
|
|
1787
|
-
* @param exec - 工具执行上下文。
|
|
1788
|
-
*/
|
|
1789
|
-
function noteChangeObservation(target, exec) {
|
|
1790
|
-
try {
|
|
1791
|
-
const identity = sessionIdentityOf(exec)
|
|
1792
|
-
if (identity === null) return
|
|
1793
|
-
const absolute = target && typeof target.displayPath === 'string' ? target.displayPath : null
|
|
1794
|
-
const relative = sessionRelativePath(identity.cwd, absolute)
|
|
1795
|
-
if (relative === null) return
|
|
1796
|
-
const key = pendingKeyOf(identity, relative)
|
|
1797
|
-
const intent = pendingChangeIntents.get(key)
|
|
1798
|
-
if (intent === undefined) return
|
|
1799
|
-
pendingChangeIntents.delete(key)
|
|
1800
|
-
|
|
1801
|
-
const entry = sessionChangeEntry(intent.sessionId, intent.cwd)
|
|
1802
|
-
let file = entry.files.get(intent.relative)
|
|
1803
|
-
if (file === undefined) {
|
|
1804
|
-
if (entry.files.size >= SESSION_CHANGE_MAX_FILES) {
|
|
1805
|
-
entry.dropped += 1
|
|
1806
|
-
return
|
|
1807
|
-
}
|
|
1808
|
-
file = { writes: 0, edits: 0, firstAt: intent.at, lastAt: intent.at, lastTurn: null, turns: [] }
|
|
1809
|
-
entry.files.set(intent.relative, file)
|
|
1810
|
-
}
|
|
1811
|
-
if (intent.kind === 'write') file.writes += 1
|
|
1812
|
-
else file.edits += 1
|
|
1813
|
-
file.lastAt = Date.now()
|
|
1814
|
-
noteTurnOfRecord(file, currentTurnOf(exec))
|
|
1815
|
-
} catch {
|
|
1816
|
-
// 同上:旁听不参与写入成败。
|
|
1817
|
-
}
|
|
1818
|
-
}
|
|
1819
|
-
|
|
1820
|
-
/**
|
|
1821
|
-
* 记一次 shell 命令(事件回调;旁听,绝不抛,也绝不吞掉 waterfall 的返回值)。
|
|
1822
|
-
*
|
|
1823
|
-
* 两类信息分开记:
|
|
1824
|
-
* - `commands`:跑过什么(含看似只读的、以及失败的),供界面说"跑过这些命令";
|
|
1825
|
-
* - `possible`:从命令里抽出来的**候选路径**,只进"可能相关"这一档,绝不写进 `files`。
|
|
1826
|
-
*
|
|
1827
|
-
* 失败的命令不改文件:只进 `commands`,不参与路径归因。
|
|
1828
|
-
*/
|
|
1829
|
-
function noteShellCommand(exec, result) {
|
|
1830
|
-
try {
|
|
1831
|
-
const command = shellCommandOf(exec)
|
|
1832
|
-
if (command === null) return
|
|
1833
|
-
const identity = sessionIdentityOf(exec)
|
|
1834
|
-
if (identity === null) return
|
|
1835
|
-
const cwd = typeof identity.cwd === 'string' && identity.cwd.length > 0 ? identity.cwd : null
|
|
1836
|
-
const entry = sessionChangeEntry(identity.sessionId, identity.cwd)
|
|
1837
|
-
const failed = resultFailed(result)
|
|
1838
|
-
const turn = currentTurnOf(exec)
|
|
1839
|
-
entry.commands.push({
|
|
1840
|
-
tool: exec.name,
|
|
1841
|
-
command: command.replace(/\s+/g, ' ').trim().slice(0, COMMAND_TEXT_MAX),
|
|
1842
|
-
at: Date.now(),
|
|
1843
|
-
turn,
|
|
1844
|
-
failed,
|
|
1845
|
-
readOnly: commandLooksReadOnly(command),
|
|
1846
|
-
})
|
|
1847
|
-
if (entry.commands.length > SESSION_COMMAND_MAX) {
|
|
1848
|
-
entry.commands.splice(0, entry.commands.length - SESSION_COMMAND_MAX)
|
|
1849
|
-
}
|
|
1850
|
-
if (failed || cwd === null) return
|
|
1851
|
-
|
|
1852
|
-
for (const candidate of possiblePathsOf(command)) {
|
|
1853
|
-
const absolute = path.isAbsolute(candidate.path) ? candidate.path : path.resolve(cwd, candidate.path)
|
|
1854
|
-
const relative = sessionRelativePath(cwd, absolute)
|
|
1855
|
-
if (relative === null) continue
|
|
1856
|
-
let item = entry.possible.get(relative)
|
|
1857
|
-
if (item === undefined) {
|
|
1858
|
-
if (entry.possible.size >= SESSION_CHANGE_MAX_POSSIBLE) {
|
|
1859
|
-
entry.possibleDropped += 1
|
|
1860
|
-
continue
|
|
1861
|
-
}
|
|
1862
|
-
item = { hits: 0, firstAt: Date.now(), lastAt: Date.now(), reason: candidate.reason, lastTurn: turn, turns: [] }
|
|
1863
|
-
entry.possible.set(relative, item)
|
|
1864
|
-
}
|
|
1865
|
-
item.hits += 1
|
|
1866
|
-
item.lastAt = Date.now()
|
|
1867
|
-
noteTurnOfRecord(item, turn)
|
|
1868
|
-
// 最近一次提到它的命令片段更有参考价值。
|
|
1869
|
-
item.reason = candidate.reason
|
|
1870
|
-
}
|
|
1871
|
-
} catch {
|
|
1872
|
-
// 旁听失败不影响命令本身。
|
|
1873
|
-
}
|
|
1874
|
-
}
|
|
1875
|
-
|
|
1876
|
-
/**
|
|
1877
|
-
* 把某个会话的改动读成端点的载荷(只含 cwd 之内的路径,按路径排序)。
|
|
1878
|
-
*
|
|
1879
|
-
* 两条诚实性检查:
|
|
1880
|
-
* - 会话记录里的 cwd 与请求的 cwd 不一致时返回**空清单**(`cwdMismatch`):记的是相对
|
|
1881
|
-
* 那个工作区的路径,跟另一个工作区对账只会标错行。
|
|
1882
|
-
* - `unconfirmed` 只数**这个会话**的待配对意图(不是全局),免得把别的会话的在途写入报成它的。
|
|
1883
|
-
*/
|
|
1884
|
-
export function readSessionChanges(cwd, sessionId) {
|
|
1885
|
-
const entry = sessionId === null || sessionId === undefined ? undefined : sessionChanges.get(sessionId)
|
|
1886
|
-
const mismatched = entry !== undefined && entry.cwd !== undefined && entry.cwd !== null && entry.cwd !== cwd
|
|
1887
|
-
const files = []
|
|
1888
|
-
if (entry !== undefined && !mismatched) {
|
|
1889
|
-
for (const [relative, file] of entry.files) {
|
|
1890
|
-
files.push({
|
|
1891
|
-
path: relative,
|
|
1892
|
-
file: relative,
|
|
1893
|
-
writes: file.writes,
|
|
1894
|
-
edits: file.edits,
|
|
1895
|
-
firstAt: file.firstAt,
|
|
1896
|
-
lastAt: file.lastAt,
|
|
1897
|
-
lastTurn: file.lastTurn === undefined ? null : file.lastTurn,
|
|
1898
|
-
turns: Array.isArray(file.turns) ? file.turns.slice() : [],
|
|
1899
|
-
})
|
|
1900
|
-
}
|
|
1901
|
-
files.sort((left, right) => (left.file < right.file ? -1 : left.file > right.file ? 1 : 0))
|
|
1902
|
-
}
|
|
1903
|
-
const possible = []
|
|
1904
|
-
if (entry !== undefined && !mismatched) {
|
|
1905
|
-
for (const [relative, item] of entry.possible) {
|
|
1906
|
-
possible.push({
|
|
1907
|
-
path: relative,
|
|
1908
|
-
file: relative,
|
|
1909
|
-
hits: item.hits,
|
|
1910
|
-
firstAt: item.firstAt,
|
|
1911
|
-
lastAt: item.lastAt,
|
|
1912
|
-
reason: item.reason,
|
|
1913
|
-
lastTurn: item.lastTurn === undefined ? null : item.lastTurn,
|
|
1914
|
-
})
|
|
1915
|
-
}
|
|
1916
|
-
possible.sort((left, right) => (left.file < right.file ? -1 : left.file > right.file ? 1 : 0))
|
|
1917
|
-
}
|
|
1918
|
-
let unconfirmed = 0
|
|
1919
|
-
if (sessionId !== null && sessionId !== undefined) {
|
|
1920
|
-
for (const intent of pendingChangeIntents.values()) if (intent.sessionId === sessionId) unconfirmed += 1
|
|
1921
|
-
}
|
|
1922
|
-
return {
|
|
1923
|
-
repo: cwd,
|
|
1924
|
-
sessionId: sessionId ?? null,
|
|
1925
|
-
tracking: true,
|
|
1926
|
-
// 记录是在宿主进程里做的、不持久化:客户端据此说"自 … 起",而不是暗示"这个会话从头到现在"。
|
|
1927
|
-
since: sessionChangeTrackingSince,
|
|
1928
|
-
// 这个会话第一次被记到改动的时刻(没有改动就是 null)。
|
|
1929
|
-
firstChangeAt: entry === undefined ? null : entry.since,
|
|
1930
|
-
files,
|
|
1931
|
-
dropped: entry === undefined ? 0 : entry.dropped,
|
|
1932
|
-
// 命令旁听的结果:`possible` 是"可能相关"的候选路径(启发式),`commands` 是跑过的命令。
|
|
1933
|
-
possible,
|
|
1934
|
-
possibleDropped: entry === undefined ? 0 : entry.possibleDropped,
|
|
1935
|
-
commands: entry === undefined || mismatched ? [] : entry.commands.slice(),
|
|
1936
|
-
unconfirmed,
|
|
1937
|
-
cwdMismatch: mismatched,
|
|
1938
|
-
}
|
|
1939
|
-
}
|
|
1940
|
-
|
|
1941
|
-
/** 挂上两个事件监听(由 `apply` 调用)。 */
|
|
1942
|
-
function trackSessionChanges(ctx) {
|
|
1943
|
-
return ctx.effect(() => {
|
|
1944
|
-
sessionChangeTrackingSince = Date.now()
|
|
1945
|
-
const stops = [
|
|
1946
|
-
ctx.on('fs/write-intent', (target, exec) => {
|
|
1947
|
-
noteChangeIntent(target, exec, 'write')
|
|
1948
|
-
}),
|
|
1949
|
-
ctx.on('fs/edit-intent', (target, exec) => {
|
|
1950
|
-
noteChangeIntent(target, exec, 'edit')
|
|
1951
|
-
}),
|
|
1952
|
-
ctx.on('fs/observed', (target, observation, exec) => {
|
|
1953
|
-
noteChangeObservation(target, exec)
|
|
1954
|
-
}),
|
|
1955
|
-
// 命令旁听:`tools/post-execute` 是 waterfall,**必须把 next() 的判定原样透传**
|
|
1956
|
-
// (兜底是 `{ kind: 'accept' }`,吞掉就等于把工具结果的裁决改掉了)。
|
|
1957
|
-
// 工具开跑:记"现在在跑哪个工具"(胶囊显示实时秒数用)。
|
|
1958
|
-
// `tools/pre-execute` 同样是 waterfall(兜底 `{ kind: 'allow' }`),必须原样透传判定。
|
|
1959
|
-
ctx.on('tools/pre-execute', (exec, next) => {
|
|
1960
|
-
noteToolStart(exec)
|
|
1961
|
-
return next()
|
|
1962
|
-
}),
|
|
1963
|
-
ctx.on('tools/post-execute', (exec, result, next) => {
|
|
1964
|
-
noteShellCommand(exec, result)
|
|
1965
|
-
noteToolFinish(exec, result)
|
|
1966
|
-
return next()
|
|
1967
|
-
}),
|
|
1968
|
-
]
|
|
1969
|
-
// 回合号来自宿主投影 `turnBoundary`。软依赖:这个服务没到齐时 turn 记 null,
|
|
1970
|
-
// 归因、面板、胶囊一切照旧(只是不显示"第几轮")。
|
|
1971
|
-
if (typeof ctx.inject === 'function') {
|
|
1972
|
-
if (typeof ctx.inject === 'function') {
|
|
1973
|
-
stops.push(ctx.inject(['sessions'], (scope) => {
|
|
1974
|
-
hostSessions = scope.sessions
|
|
1975
|
-
return () => {
|
|
1976
|
-
hostSessions = null
|
|
1977
|
-
}
|
|
1978
|
-
}))
|
|
1979
|
-
}
|
|
1980
|
-
stops.push(ctx.inject(['sessionProjections'], (scope) => {
|
|
1981
|
-
sessionOutlineReader = (session) => {
|
|
1982
|
-
try {
|
|
1983
|
-
const state = scope.sessionProjections.stateOf(session, 'turnOutline')
|
|
1984
|
-
return state === undefined || state === null ? null : state
|
|
1985
|
-
} catch {
|
|
1986
|
-
return null
|
|
1987
|
-
}
|
|
1988
|
-
}
|
|
1989
|
-
sessionStatsReader = (session) => {
|
|
1990
|
-
try {
|
|
1991
|
-
const state = scope.sessionProjections.stateOf(session, 'sessionStats')
|
|
1992
|
-
if (state === undefined || state === null) return null
|
|
1993
|
-
const num = value => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
|
|
1994
|
-
return {
|
|
1995
|
-
turns: num(state.turns),
|
|
1996
|
-
steps: num(state.steps),
|
|
1997
|
-
llmMs: num(state.llmMs),
|
|
1998
|
-
toolMs: num(state.toolMs),
|
|
1999
|
-
}
|
|
2000
|
-
} catch {
|
|
2001
|
-
return null
|
|
2002
|
-
}
|
|
2003
|
-
}
|
|
2004
|
-
sessionTurnReader = (session) => {
|
|
2005
|
-
try {
|
|
2006
|
-
const state = scope.sessionProjections.stateOf(session, 'turnBoundary')
|
|
2007
|
-
const turn = state === undefined || state === null ? null : state.lastTurn
|
|
2008
|
-
return typeof turn === 'number' && turn >= 0 ? turn : null
|
|
2009
|
-
} catch {
|
|
2010
|
-
return null
|
|
2011
|
-
}
|
|
2012
|
-
}
|
|
2013
|
-
return () => {
|
|
2014
|
-
sessionTurnReader = null
|
|
2015
|
-
sessionStatsReader = null
|
|
2016
|
-
sessionOutlineReader = null
|
|
2017
|
-
}
|
|
2018
|
-
}))
|
|
2019
|
-
}
|
|
2020
|
-
return () => {
|
|
2021
|
-
for (const stop of stops) if (typeof stop === 'function') stop()
|
|
2022
|
-
sessionChanges.clear()
|
|
2023
|
-
pendingChangeIntents.clear()
|
|
2024
|
-
sessionTools.clear()
|
|
2025
|
-
sessionChangeTrackingSince = null
|
|
2026
|
-
}
|
|
2027
|
-
}, 'dsh-hud: session change tracking')
|
|
2028
|
-
}
|
|
2029
|
-
|
|
2030
|
-
/** 端点表:全部只读;新增端点时在这里登记。 */
|
|
2031
|
-
const ENDPOINTS = {
|
|
2032
|
-
async status(args) {
|
|
2033
|
-
const input = args && typeof args === 'object' ? args : {}
|
|
2034
|
-
const checked = validateCwd(input.cwd)
|
|
2035
|
-
if (!checked.ok) throw Object.assign(new Error(checked.message), { code: checked.code })
|
|
2036
|
-
const status = await readStatusCached(checked.value)
|
|
2037
|
-
// 「审阅进度」要的"这一版"签名:只在客户端明确要的时候才算(胶囊那一路的轮询不用付这个钱)。
|
|
2038
|
-
if (input.review === true && Array.isArray(status.changes) && status.changes.length > 0) {
|
|
2039
|
-
status.review = await readReviewSignatures(
|
|
2040
|
-
checked.value,
|
|
2041
|
-
status.changes.slice(0, REVIEW_SIGNATURE_MAX).map(change => change.file),
|
|
2042
|
-
)
|
|
2043
|
-
}
|
|
2044
|
-
return status
|
|
2045
|
-
},
|
|
2046
|
-
|
|
2047
|
-
async 'session-tools'(args) {
|
|
2048
|
-
const input = args && typeof args === 'object' ? args : {}
|
|
2049
|
-
const sessionId = typeof input.sessionId === 'string' && input.sessionId.length > 0 ? input.sessionId : null
|
|
2050
|
-
return readSessionTools(sessionId)
|
|
2051
|
-
},
|
|
2052
|
-
|
|
2053
|
-
async 'session-statuses'() {
|
|
2054
|
-
return readSessionOverview()
|
|
2055
|
-
},
|
|
2056
|
-
|
|
2057
|
-
/** 一个会话的 prompt 历史(新的在前)—— 给输入框的 `↑` 召回用。 */
|
|
2058
|
-
async 'prompt-history'(args) {
|
|
2059
|
-
const input = args && typeof args === 'object' ? args : {}
|
|
2060
|
-
const sessionId = typeof input.sessionId === 'string' && input.sessionId.length > 0 ? input.sessionId : null
|
|
2061
|
-
return readPromptHistory(sessionId, input.limit)
|
|
2062
|
-
},
|
|
2063
|
-
|
|
2064
|
-
/** 一个会话的运行时长(挂钟 + 模型/工具墙钟)—— 给会话计时胶囊用。 */
|
|
2065
|
-
async 'session-time'(args) {
|
|
2066
|
-
const input = args && typeof args === 'object' ? args : {}
|
|
2067
|
-
const sessionId = typeof input.sessionId === 'string' && input.sessionId.length > 0 ? input.sessionId : null
|
|
2068
|
-
return readSessionTime(sessionId)
|
|
2069
|
-
},
|
|
2070
|
-
|
|
2071
|
-
async diff(args) {
|
|
2072
|
-
const input = args && typeof args === 'object' ? args : {}
|
|
2073
|
-
const cwd = validateCwd(input.cwd)
|
|
2074
|
-
if (!cwd.ok) throw Object.assign(new Error(cwd.message), { code: cwd.code })
|
|
2075
|
-
const file = validateDiffPath(input.path)
|
|
2076
|
-
if (!file.ok) throw Object.assign(new Error(file.message), { code: file.code })
|
|
2077
|
-
|
|
2078
|
-
let origFile = null
|
|
2079
|
-
if (input.origPath !== undefined && input.origPath !== null && input.origPath !== '') {
|
|
2080
|
-
const checked = validateDiffPath(input.origPath, 'origPath')
|
|
2081
|
-
if (!checked.ok) throw Object.assign(new Error(checked.message), { code: checked.code })
|
|
2082
|
-
origFile = checked.value
|
|
2083
|
-
}
|
|
2084
|
-
|
|
2085
|
-
return readDiff(cwd.value, file.value, origFile)
|
|
2086
|
-
},
|
|
2087
|
-
|
|
2088
|
-
/**
|
|
2089
|
-
* 图片预览:一个二进制改动两侧的字节与像素尺寸。
|
|
2090
|
-
*
|
|
2091
|
-
* `diff` 对二进制只回一句 `binary: true`(文本 diff 里没有可看的东西),
|
|
2092
|
-
* 浏览器半靠这个端点把"改了哪张图、尺寸变没变"画出来。只读,不写 index / 工作区。
|
|
2093
|
-
*/
|
|
2094
|
-
async image(args) {
|
|
2095
|
-
const input = args && typeof args === 'object' ? args : {}
|
|
2096
|
-
const cwd = validateCwd(input.cwd)
|
|
2097
|
-
if (!cwd.ok) throw Object.assign(new Error(cwd.message), { code: cwd.code })
|
|
2098
|
-
const file = validateDiffPath(input.path)
|
|
2099
|
-
if (!file.ok) throw Object.assign(new Error(file.message), { code: file.code })
|
|
2100
|
-
|
|
2101
|
-
let origFile = null
|
|
2102
|
-
if (input.origPath !== undefined && input.origPath !== null && input.origPath !== '') {
|
|
2103
|
-
const checked = validateDiffPath(input.origPath, 'origPath')
|
|
2104
|
-
if (!checked.ok) throw Object.assign(new Error(checked.message), { code: checked.code })
|
|
2105
|
-
origFile = checked.value
|
|
2106
|
-
}
|
|
2107
|
-
|
|
2108
|
-
let sha = null
|
|
2109
|
-
if (input.sha !== undefined && input.sha !== null && input.sha !== '') {
|
|
2110
|
-
const checked = validateCommitSha(input.sha)
|
|
2111
|
-
if (!checked.ok) throw Object.assign(new Error(checked.message), { code: checked.code })
|
|
2112
|
-
sha = checked.value
|
|
2113
|
-
}
|
|
2114
|
-
|
|
2115
|
-
return readImagePair(cwd.value, file.value, origFile, input.side === 'index' ? 'index' : 'worktree', sha)
|
|
2116
|
-
},
|
|
2117
|
-
|
|
2118
|
-
async log(args) {
|
|
2119
|
-
const input = args && typeof args === 'object' ? args : {}
|
|
2120
|
-
const cwd = validateCwd(input.cwd)
|
|
2121
|
-
if (!cwd.ok) throw Object.assign(new Error(cwd.message), { code: cwd.code })
|
|
2122
|
-
return readLog(cwd.value, input.count)
|
|
2123
|
-
},
|
|
2124
|
-
|
|
2125
|
-
/** 标签列表(只读)。点一个 tag 复用 `commit` 那条管道去看它指向的提交。 */
|
|
2126
|
-
async tags(args) {
|
|
2127
|
-
const input = args && typeof args === 'object' ? args : {}
|
|
2128
|
-
const cwd = validateCwd(input.cwd)
|
|
2129
|
-
if (!cwd.ok) throw Object.assign(new Error(cwd.message), { code: cwd.code })
|
|
2130
|
-
return readTags(cwd.value, input.count)
|
|
2131
|
-
},
|
|
2132
|
-
async commit(args) {
|
|
2133
|
-
const input = args && typeof args === 'object' ? args : {}
|
|
2134
|
-
const cwd = validateCwd(input.cwd)
|
|
2135
|
-
if (!cwd.ok) throw Object.assign(new Error(cwd.message), { code: cwd.code })
|
|
2136
|
-
const sha = validateCommitSha(input.sha)
|
|
2137
|
-
if (!sha.ok) throw Object.assign(new Error(sha.message), { code: sha.code })
|
|
2138
|
-
return readCommit(cwd.value, sha.value)
|
|
2139
|
-
},
|
|
2140
|
-
|
|
2141
|
-
async 'commit-diff'(args) {
|
|
2142
|
-
const input = args && typeof args === 'object' ? args : {}
|
|
2143
|
-
const cwd = validateCwd(input.cwd)
|
|
2144
|
-
if (!cwd.ok) throw Object.assign(new Error(cwd.message), { code: cwd.code })
|
|
2145
|
-
const sha = validateCommitSha(input.sha)
|
|
2146
|
-
if (!sha.ok) throw Object.assign(new Error(sha.message), { code: sha.code })
|
|
2147
|
-
const file = validateDiffPath(input.path)
|
|
2148
|
-
if (!file.ok) throw Object.assign(new Error(file.message), { code: file.code })
|
|
2149
|
-
|
|
2150
|
-
let origFile = null
|
|
2151
|
-
if (input.origPath !== undefined && input.origPath !== null && input.origPath !== '') {
|
|
2152
|
-
const checked = validateDiffPath(input.origPath, 'origPath')
|
|
2153
|
-
if (!checked.ok) throw Object.assign(new Error(checked.message), { code: checked.code })
|
|
2154
|
-
origFile = checked.value
|
|
2155
|
-
}
|
|
2156
|
-
|
|
2157
|
-
return readCommitDiff(cwd.value, sha.value, file.value, origFile)
|
|
2158
|
-
},
|
|
2159
|
-
|
|
2160
|
-
/**
|
|
2161
|
-
* 本会话改过哪些文件(按会话归因)。
|
|
2162
|
-
*
|
|
2163
|
-
* 只读:只回答"这个会话动过哪些路径",与工作区现状的关系由浏览器半自己用 `status` 求交 —
|
|
2164
|
-
* 这样端点不需要知道 git 的事,两个事实各自只有一个来源。
|
|
2165
|
-
*/
|
|
2166
|
-
async 'session-changes'(args) {
|
|
2167
|
-
const input = args && typeof args === 'object' ? args : {}
|
|
2168
|
-
const cwd = validateCwd(input.cwd)
|
|
2169
|
-
if (!cwd.ok) throw Object.assign(new Error(cwd.message), { code: cwd.code })
|
|
2170
|
-
const sessionId = typeof input.sessionId === 'string' && input.sessionId.length > 0 ? input.sessionId : null
|
|
2171
|
-
return readSessionChanges(cwd.value, sessionId)
|
|
2172
|
-
},
|
|
2173
|
-
}
|
|
2174
|
-
|
|
2175
|
-
/* ------------------------------------------------------------------ 线路层 */
|
|
2176
|
-
|
|
2177
|
-
/** 响应信封:`{ type:"server-response", rpcId, result }`。 */
|
|
2178
|
-
function envelope(rpcId, result) {
|
|
2179
|
-
return { type: 'server-response', rpcId, result }
|
|
2180
|
-
}
|
|
2181
|
-
|
|
2182
|
-
function failure(rpcId, code, message, details) {
|
|
2183
|
-
return envelope(rpcId, { ok: false, error: { code, message, details: details || {} } })
|
|
2184
|
-
}
|
|
2185
|
-
|
|
2186
|
-
function sendJson(res, status, body) {
|
|
2187
|
-
const payload = JSON.stringify(body)
|
|
2188
|
-
res.statusCode = status
|
|
2189
|
-
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
2190
|
-
res.setHeader('content-length', Buffer.byteLength(payload))
|
|
2191
|
-
res.end(payload)
|
|
2192
|
-
}
|
|
2193
|
-
|
|
2194
|
-
/** 读请求体,超过上限即拒绝(不继续缓冲)。 */
|
|
2195
|
-
function readBody(req, limit) {
|
|
2196
|
-
return new Promise((resolve, reject) => {
|
|
2197
|
-
let size = 0
|
|
2198
|
-
const chunks = []
|
|
2199
|
-
req.on('data', (chunk) => {
|
|
2200
|
-
size += chunk.length
|
|
2201
|
-
if (size > limit) {
|
|
2202
|
-
const error = new Error('payload too large')
|
|
2203
|
-
error.code = 'too-large'
|
|
2204
|
-
reject(error)
|
|
2205
|
-
return
|
|
2206
|
-
}
|
|
2207
|
-
chunks.push(chunk)
|
|
2208
|
-
})
|
|
2209
|
-
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
2210
|
-
req.on('error', reject)
|
|
2211
|
-
})
|
|
2212
|
-
}
|
|
2213
|
-
|
|
2214
|
-
/**
|
|
2215
|
-
* 路由处理器:先过 Connection 围栏,再解析信封,最后分发到端点。
|
|
2216
|
-
* 非 POST → 405,非 JSON → 415,超限 → 413,通道外路径 → 404。
|
|
2217
|
-
*/
|
|
2218
|
-
async function handleRequest(ctx, req, res) {
|
|
2219
|
-
const connection = ctx.get('connection')
|
|
2220
|
-
if (!connection || typeof connection.requestRejection !== 'function') {
|
|
2221
|
-
res.statusCode = 503
|
|
2222
|
-
res.end()
|
|
2223
|
-
return
|
|
2224
|
-
}
|
|
2225
|
-
|
|
2226
|
-
const rejection = connection.requestRejection(req)
|
|
2227
|
-
if (rejection !== undefined) {
|
|
2228
|
-
res.statusCode = rejection
|
|
2229
|
-
res.end()
|
|
2230
|
-
return
|
|
2231
|
-
}
|
|
2232
|
-
|
|
2233
|
-
if (req.method !== 'POST') {
|
|
2234
|
-
res.statusCode = 405
|
|
2235
|
-
res.end()
|
|
2236
|
-
return
|
|
2237
|
-
}
|
|
2238
|
-
|
|
2239
|
-
const contentType = String(req.headers['content-type'] || '')
|
|
2240
|
-
if (!contentType.includes('application/json')) {
|
|
2241
|
-
res.statusCode = 415
|
|
2242
|
-
res.end()
|
|
2243
|
-
return
|
|
2244
|
-
}
|
|
2245
|
-
|
|
2246
|
-
const url = new URL(req.url || '/', 'http://localhost')
|
|
2247
|
-
const pathname = url.pathname
|
|
2248
|
-
// 不能只靠「前缀路由已经过滤过路径」:路由是挂载契约,处理器仍要自己确认,
|
|
2249
|
-
// 否则 `/somewhere-else` 会被 slice 成一段垃圾端点名。
|
|
2250
|
-
if (pathname !== CHANNEL && !pathname.startsWith(`${CHANNEL}/`)) {
|
|
2251
|
-
res.statusCode = 404
|
|
2252
|
-
res.end()
|
|
2253
|
-
return
|
|
2254
|
-
}
|
|
2255
|
-
const endpoint = pathname === CHANNEL ? '' : pathname.slice(CHANNEL.length + 1)
|
|
2256
|
-
if (!endpoint || endpoint.includes('/')) {
|
|
2257
|
-
res.statusCode = 404
|
|
2258
|
-
res.end()
|
|
2259
|
-
return
|
|
2260
|
-
}
|
|
2261
|
-
|
|
2262
|
-
let raw
|
|
2263
|
-
try {
|
|
2264
|
-
raw = await readBody(req, MAX_BODY_BYTES)
|
|
2265
|
-
}
|
|
2266
|
-
catch (error) {
|
|
2267
|
-
res.statusCode = error && error.code === 'too-large' ? 413 : 400
|
|
2268
|
-
res.end()
|
|
2269
|
-
return
|
|
2270
|
-
}
|
|
2271
|
-
|
|
2272
|
-
let message
|
|
2273
|
-
try {
|
|
2274
|
-
message = JSON.parse(raw)
|
|
2275
|
-
}
|
|
2276
|
-
catch {
|
|
2277
|
-
sendJson(res, 400, failure('invalid-request', 'internal', 'invalid client-request message', { code: 'invalid-request' }))
|
|
2278
|
-
return
|
|
2279
|
-
}
|
|
2280
|
-
|
|
2281
|
-
if (!message || typeof message !== 'object' || Array.isArray(message)
|
|
2282
|
-
|| message.type !== 'client-request' || typeof message.rpcId !== 'string'
|
|
2283
|
-
|| typeof message.method !== 'string' || !('payload' in message)) {
|
|
2284
|
-
sendJson(res, 400, failure(
|
|
2285
|
-
message && typeof message.rpcId === 'string' ? message.rpcId : 'invalid-request',
|
|
2286
|
-
'internal',
|
|
2287
|
-
'invalid client-request message',
|
|
2288
|
-
{ code: 'invalid-request' },
|
|
2289
|
-
))
|
|
2290
|
-
return
|
|
2291
|
-
}
|
|
2292
|
-
|
|
2293
|
-
const rpcId = message.rpcId
|
|
2294
|
-
const handler = Object.prototype.hasOwnProperty.call(ENDPOINTS, endpoint) ? ENDPOINTS[endpoint] : null
|
|
2295
|
-
if (handler === null) {
|
|
2296
|
-
// 未知方法按 JSON-RPC 语义回 200 + 错误信封:非 2xx 会被浏览器侧的传输层
|
|
2297
|
-
// 当成连接失败,插件自己的 details.code 就再也到不了界面。
|
|
2298
|
-
sendJson(res, 200, failure(rpcId, 'internal', `unknown endpoint: ${endpoint}`, { code: 'unknown-endpoint' }))
|
|
2299
|
-
return
|
|
2300
|
-
}
|
|
2301
|
-
|
|
2302
|
-
const args = message.payload && typeof message.payload === 'object' ? message.payload.args : undefined
|
|
2303
|
-
try {
|
|
2304
|
-
const value = await handler(args)
|
|
2305
|
-
sendJson(res, 200, envelope(rpcId, { ok: true, value }))
|
|
2306
|
-
}
|
|
2307
|
-
catch (error) {
|
|
2308
|
-
const code = error && typeof error.code === 'string' ? error.code : 'git-failed'
|
|
2309
|
-
const text = error instanceof Error ? error.message : String(error)
|
|
2310
|
-
// 线路上的 error.code 固定为 "internal",插件自己的诊断码放在 details.code,
|
|
2311
|
-
// 由浏览器半按码做本地化文案。
|
|
2312
|
-
sendJson(res, 200, failure(rpcId, 'internal', text, { code }))
|
|
2313
|
-
}
|
|
2314
|
-
}
|
|
2315
|
-
|
|
2316
|
-
/* ------------------------------------------------------------------- 插件 */
|
|
2317
|
-
|
|
2318
|
-
/**
|
|
2319
|
-
* 插件入口。
|
|
2320
|
-
* @param ctx - 已注入 webServer 与 connection 的 Cordis 上下文。
|
|
2321
|
-
*/
|
|
2322
|
-
export function apply(ctx) {
|
|
2323
|
-
hostContext = ctx
|
|
2324
|
-
trackSessionChanges(ctx)
|
|
2325
|
-
ctx.effect(() => ctx.webServer.register({
|
|
2326
|
-
kind: 'prefix',
|
|
2327
|
-
path: CHANNEL,
|
|
2328
|
-
handler: (req, res) => handleRequest(ctx, req, res),
|
|
2329
|
-
}), `dsh-hud: POST ${CHANNEL}/*`)
|
|
2330
|
-
}
|
|
2331
|
-
|
|
2332
|
-
// 导出即插件面:`apply` / `inject` / `name` 供 Cordis 读取;`CHANNEL` / `parseStatus` /
|
|
2333
|
-
// `validateCwd` / `readStatus` 是可直接单测的单元(前两个是纯函数,最后一个需要真实仓库)。
|