dsh-vscode-mode 0.21.4 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -4
- package/lib/client.js +72 -98
- package/lib/client.js.map +2 -2
- package/lib/index.js +2419 -32
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/state/diffSummary.ts +43 -4
- package/src/client/ui/DiffLauncher.ts +1 -1
- package/src/client/ui/EditorView.ts +10 -2
- package/src/client/ui/OfficialDiffBar.tsx +1 -12
- package/src/client/ui/OfficialReview.ts +13 -3
- package/src/client/ui/SidecarDiffBar.tsx +0 -22
- package/src/index.ts +9 -0
- package/src/missingReview.ts +36 -14
- package/src/pending/capture.ts +136 -0
- package/src/pending/decide.ts +213 -0
- package/src/pending/hunks.ts +241 -0
- package/src/pending/kernel.ts +233 -0
- package/src/pending/persist.ts +324 -0
- package/src/pending/store.ts +467 -0
- package/src/pending/vcs.ts +444 -0
- package/src/pendingCompat.ts +189 -0
- package/src/pendingRpc.ts +128 -0
- package/src/reviewDb.ts +3 -0
- package/src/reviewRpc.ts +52 -1
- package/src/rpc.ts +29 -2
- package/src/shared/pendingReview.ts +105 -0
- package/src/shared/rpc.ts +18 -0
- package/src/store.ts +19 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 版本库导入:把工作副本相对基线的改动读成待审条目,来源对齐 dsh-diff-approval 的 vcs 内核(MIT)。
|
|
3
|
+
* 只读命令,不修改工作副本;基线读取失败一律上报,不猜"文件不存在"。
|
|
4
|
+
* @author ddj 2026年10月30号
|
|
5
|
+
*/
|
|
6
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
7
|
+
import { dirname, join, resolve, sep } from 'node:path'
|
|
8
|
+
|
|
9
|
+
/** 支持的版本库种类。 */
|
|
10
|
+
export type VcsKind = 'git' | 'svn' | 'p4'
|
|
11
|
+
|
|
12
|
+
/** 探测结果。 */
|
|
13
|
+
export interface VcsRoot {
|
|
14
|
+
kind: VcsKind
|
|
15
|
+
root: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 一条导入改动:相对基线的旧内容与当前内容(删除时当前内容为 null)。 */
|
|
19
|
+
export interface VcsChange {
|
|
20
|
+
path: string
|
|
21
|
+
earlierVersion: 'file' | 'none'
|
|
22
|
+
oldText: string
|
|
23
|
+
newText: string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 导入选项。 */
|
|
27
|
+
export interface VcsImportOptions {
|
|
28
|
+
includeUntracked?: boolean
|
|
29
|
+
path?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** shell 能力最小面(便于单测注入)。 */
|
|
33
|
+
export interface ShellLike {
|
|
34
|
+
resolve(request: { command: string; workdir?: string; timeoutMs?: number; stdoutMaxBytes?: number; sandboxPolicy?: unknown }): unknown
|
|
35
|
+
execute(spec: unknown): Promise<{ result(): Promise<{ exitCode: number | null; timedOut: boolean; aborted: boolean; stdout: { text: string }; stderr: { text: string } }> }>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 命令超时:与上游一致,避免大仓 status 卡死。 */
|
|
39
|
+
const COMMAND_TIMEOUT = 60_000
|
|
40
|
+
/** stdout 预算:够放一个常见源文件,超出交给上游判断为超大。 */
|
|
41
|
+
const STDOUT_BUDGET = 8 * 1024 * 1024
|
|
42
|
+
/** 只有这些 git 报错才算"该版本不存在",其余一律抛出。 */
|
|
43
|
+
const ABSENT_AT_REVISION = /does not exist in|exists on disk, but not in|not a valid object name|invalid object name|unknown revision|bad revision/i
|
|
44
|
+
/** 只有这些 P4 报错才算"该版本不存在",其余一律抛出。 */
|
|
45
|
+
const P4_ABSENT_AT_REVISION = /no such file|not opened|does not exist|no file\(s\) to print|invalid file|no revisions/i
|
|
46
|
+
/** git 相对路径用正斜杠。 */
|
|
47
|
+
const GIT_SEPARATOR = '/'
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @author ddj 2026年10月30号
|
|
51
|
+
* @description: 向上查找版本库根目录,同目录 git 优先于 svn,最后才是 Perforce 配置。
|
|
52
|
+
* @param cwd 工作区
|
|
53
|
+
* @returns 探测结果,未找到时 undefined
|
|
54
|
+
*/
|
|
55
|
+
export async function detectVcsRoot(cwd: string): Promise<VcsRoot | undefined> {
|
|
56
|
+
let current = resolve(cwd)
|
|
57
|
+
for (;;) {
|
|
58
|
+
const git = await exists(join(current, '.git'))
|
|
59
|
+
if (git) return { kind: 'git', root: current }
|
|
60
|
+
if (await exists(join(current, '.svn'))) return { kind: 'svn', root: current }
|
|
61
|
+
if (await isP4Root(current)) return { kind: 'p4', root: current }
|
|
62
|
+
const parent = dirname(current)
|
|
63
|
+
if (parent === current) return undefined
|
|
64
|
+
current = parent
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @author ddj 2026年10月30号
|
|
70
|
+
* @description: 判断目录是否是 Perforce 工作区根(P4CONFIG 文件所在目录)。
|
|
71
|
+
* @param dir 目录
|
|
72
|
+
* @returns 是否 P4 根
|
|
73
|
+
*/
|
|
74
|
+
async function isP4Root(dir: string): Promise<boolean> {
|
|
75
|
+
return (await exists(join(dir, '.p4config'))) || (await exists(join(dir, '.p4config.txt')))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @author ddj 2026年10月30号
|
|
80
|
+
* @description: 读出当前工作副本相对基线的改动,只返回给定范围(含未跟踪按选项)。
|
|
81
|
+
* @param shell shell 能力
|
|
82
|
+
* @param cwd 工作区
|
|
83
|
+
* @param options 导入选项
|
|
84
|
+
* @returns 改动列表
|
|
85
|
+
*/
|
|
86
|
+
export async function importVcsChanges(shell: ShellLike, cwd: string, options: VcsImportOptions = {}): Promise<VcsChange[]> {
|
|
87
|
+
const detected = await detectVcsRoot(cwd)
|
|
88
|
+
if (!detected) return []
|
|
89
|
+
const changes = await readChanges(shell, detected, cwd, options)
|
|
90
|
+
const only = options.path ? resolve(cwd, options.path) : undefined
|
|
91
|
+
return changes.filter((change) => !only || samePath(change.path, only))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @author ddj 2026年10月30号
|
|
96
|
+
* @description: 按探测到的种类取改动,新增种类只在这里分流。
|
|
97
|
+
* @param shell shell 能力
|
|
98
|
+
* @param detected 版本库种类与根目录
|
|
99
|
+
* @param cwd 工作区
|
|
100
|
+
* @param options 导入选项
|
|
101
|
+
* @returns 改动列表
|
|
102
|
+
*/
|
|
103
|
+
async function readChanges(shell: ShellLike, detected: VcsRoot, cwd: string, options: VcsImportOptions): Promise<VcsChange[]> {
|
|
104
|
+
if (detected.kind === 'git') return gitChanges(shell, cwd, detected.root, options)
|
|
105
|
+
if (detected.kind === 'svn') return svnChanges(shell, cwd, detected.root, options)
|
|
106
|
+
return p4Changes(shell, cwd, detected.root, options)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @author ddj 2026年10月30号
|
|
111
|
+
* @description: git 改动:status -z 解析状态,基线取 HEAD 版本,未跟踪可按选项导入为新建。
|
|
112
|
+
* @param shell shell 能力
|
|
113
|
+
* @param cwd 工作区
|
|
114
|
+
* @param root 仓库根
|
|
115
|
+
* @param options 导入选项
|
|
116
|
+
* @returns 改动列表
|
|
117
|
+
*/
|
|
118
|
+
async function gitChanges(shell: ShellLike, cwd: string, root: string, options: VcsImportOptions): Promise<VcsChange[]> {
|
|
119
|
+
const command = 'git -c status.renames=false status --porcelain=v1 -z --untracked-files=all'
|
|
120
|
+
const status = await runCommand(shell, command, root)
|
|
121
|
+
if (!status.ok) throw new Error('git status 失败:' + status.stderr.slice(0, 200))
|
|
122
|
+
const changes: VcsChange[] = []
|
|
123
|
+
for (const entry of parseGitStatus(status.stdout)) {
|
|
124
|
+
if (entry.status === '??' && !options.includeUntracked) continue
|
|
125
|
+
const absolute = join(root, ...entry.path.split(GIT_SEPARATOR))
|
|
126
|
+
if (!insideRoot(root, absolute)) continue
|
|
127
|
+
const current = await readTextOf(absolute)
|
|
128
|
+
if (entry.status === '??' || entry.status === 'A ') {
|
|
129
|
+
changes.push({ path: absolute, earlierVersion: 'none', oldText: '', newText: current })
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
// 版本里确实没有该文件时按新建导入(earlierVersion='none'),不能丢掉这条改动
|
|
133
|
+
const baseline = await gitBaseline(shell, root, entry.path)
|
|
134
|
+
changes.push({
|
|
135
|
+
path: absolute,
|
|
136
|
+
earlierVersion: baseline === null ? 'none' : 'file',
|
|
137
|
+
oldText: baseline ?? '',
|
|
138
|
+
newText: current,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
return changes
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @author ddj 2026年10月30号
|
|
146
|
+
* @description: svn 改动:status --xml 解析状态,基线取 BASE 版本。
|
|
147
|
+
* @param shell shell 能力
|
|
148
|
+
* @param cwd 工作区
|
|
149
|
+
* @param root 工作副本根
|
|
150
|
+
* @param options 导入选项
|
|
151
|
+
* @returns 改动列表
|
|
152
|
+
*/
|
|
153
|
+
async function svnChanges(shell: ShellLike, cwd: string, root: string, options: VcsImportOptions): Promise<VcsChange[]> {
|
|
154
|
+
const status = await runCommand(shell, 'svn status --xml', root)
|
|
155
|
+
if (!status.ok) throw new Error('svn status 失败:' + status.stderr.slice(0, 200))
|
|
156
|
+
const changes: VcsChange[] = []
|
|
157
|
+
for (const entry of parseSvnStatus(status.stdout)) {
|
|
158
|
+
if (entry.status === 'unversioned' && !options.includeUntracked) continue
|
|
159
|
+
const absolute = resolve(root, entry.path)
|
|
160
|
+
if (!insideRoot(root, absolute)) continue
|
|
161
|
+
const current = await readTextOf(absolute)
|
|
162
|
+
if (entry.status === 'added' || entry.status === 'unversioned' || entry.status === 'replaced') {
|
|
163
|
+
changes.push({ path: absolute, earlierVersion: 'none', oldText: '', newText: current })
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
const baseline = await svnBaseline(shell, root, entry.path)
|
|
167
|
+
changes.push({ path: absolute, earlierVersion: 'file', oldText: baseline, newText: current })
|
|
168
|
+
}
|
|
169
|
+
return changes
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* @author ddj 2026年10月30号
|
|
174
|
+
* @description: 取 HEAD 版本内容;仅"该版本确实没有此文件"时返回 null,其余失败抛出。
|
|
175
|
+
* @param shell shell 能力
|
|
176
|
+
* @param root 仓库根
|
|
177
|
+
* @param relative 仓库相对路径(正斜杠)
|
|
178
|
+
* @returns 基线内容或 null
|
|
179
|
+
*/
|
|
180
|
+
async function gitBaseline(shell: ShellLike, root: string, relative: string): Promise<string | null> {
|
|
181
|
+
const size = await runCommand(shell, `git cat-file -s HEAD:${quote(relative)}`, root)
|
|
182
|
+
const budget = size.ok ? Number(size.stdout.trim()) + 1024 : STDOUT_BUDGET
|
|
183
|
+
const shown = await runCommand(shell, `git show HEAD:${quote(relative)}`, root, Math.min(budget, STDOUT_BUDGET))
|
|
184
|
+
if (shown.ok) return shown.stdout
|
|
185
|
+
if (ABSENT_AT_REVISION.test(shown.stderr)) return null
|
|
186
|
+
throw new Error('读取 git 基线失败:' + shown.stderr.slice(0, 200))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @author ddj 2026年10月30号
|
|
191
|
+
* @description: 取 svn BASE 版本内容;失败一律抛出(svn 没有可靠的"不存在"判据)。
|
|
192
|
+
* @param shell shell 能力
|
|
193
|
+
* @param root 工作副本根
|
|
194
|
+
* @param relative 相对路径
|
|
195
|
+
* @returns 基线内容
|
|
196
|
+
*/
|
|
197
|
+
async function svnBaseline(shell: ShellLike, root: string, relative: string): Promise<string> {
|
|
198
|
+
const shown = await runCommand(shell, `svn cat -r BASE ${quote(relative)}`, root)
|
|
199
|
+
if (!shown.ok) throw new Error('读取 svn 基线失败:' + shown.stderr.slice(0, 200))
|
|
200
|
+
return shown.stdout
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* @author ddj 2026年10月30号
|
|
205
|
+
* @description: Perforce 改动:opened(含 status 的未跟踪项)解析 depot 路径,再折成本地路径。
|
|
206
|
+
* @param shell shell 能力
|
|
207
|
+
* @param cwd 工作区
|
|
208
|
+
* @param root P4 工作区根
|
|
209
|
+
* @param options 导入选项
|
|
210
|
+
* @returns 改动列表
|
|
211
|
+
*/
|
|
212
|
+
async function p4Changes(shell: ShellLike, cwd: string, root: string, options: VcsImportOptions): Promise<VcsChange[]> {
|
|
213
|
+
const depotPaths = new Set<string>()
|
|
214
|
+
const opened = await runCommand(shell, 'p4 opened', root)
|
|
215
|
+
if (!opened.ok) throw new Error('p4 opened 失败:' + opened.stderr.slice(0, 200))
|
|
216
|
+
for (const path of parseP4Opened(opened.stdout)) depotPaths.add(path)
|
|
217
|
+
if (options.includeUntracked) {
|
|
218
|
+
const status = await runCommand(shell, 'p4 status', root)
|
|
219
|
+
if (!status.ok) throw new Error('p4 status 失败:' + status.stderr.slice(0, 200))
|
|
220
|
+
for (const path of parseP4Status(status.stdout)) depotPaths.add(path)
|
|
221
|
+
}
|
|
222
|
+
const changes: VcsChange[] = []
|
|
223
|
+
for (const depot of depotPaths) {
|
|
224
|
+
const absolute = await p4LocalPath(shell, root, depot)
|
|
225
|
+
if (!absolute || !insideRoot(root, absolute)) continue
|
|
226
|
+
const current = await readTextOf(absolute)
|
|
227
|
+
const baseline = await p4Baseline(shell, root, depot)
|
|
228
|
+
changes.push({
|
|
229
|
+
path: absolute,
|
|
230
|
+
earlierVersion: baseline === null ? 'none' : 'file',
|
|
231
|
+
oldText: baseline ?? '',
|
|
232
|
+
newText: current,
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
return changes
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* @author ddj 2026年10月30号
|
|
240
|
+
* @description: 解析 `p4 opened` 输出,只取 depot 路径(`//depot/x#3 - edit ...`)。
|
|
241
|
+
* @param text 输出
|
|
242
|
+
* @returns depot 路径列表
|
|
243
|
+
*/
|
|
244
|
+
function parseP4Opened(text: string): string[] {
|
|
245
|
+
const paths: string[] = []
|
|
246
|
+
for (const line of text.split(/\r?\n/)) {
|
|
247
|
+
const match = /^(\/\/[^#\s]+)#\d+/.exec(line.trim())
|
|
248
|
+
if (match) paths.push(match[1]!)
|
|
249
|
+
}
|
|
250
|
+
return paths
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* @author ddj 2026年10月30号
|
|
255
|
+
* @description: 解析 `p4 status` 输出;每行以本地路径或 depot 路径开头,取路径本身。
|
|
256
|
+
* @param text 输出
|
|
257
|
+
* @returns depot 或本地路径列表
|
|
258
|
+
*/
|
|
259
|
+
function parseP4Status(text: string): string[] {
|
|
260
|
+
const paths: string[] = []
|
|
261
|
+
for (const line of text.split(/\r?\n/)) {
|
|
262
|
+
const trimmed = line.trim()
|
|
263
|
+
if (!trimmed || trimmed.startsWith('-')) continue
|
|
264
|
+
const head = trimmed.split(/\s+-\s+/)[0]?.trim()
|
|
265
|
+
if (!head) continue
|
|
266
|
+
paths.push(head)
|
|
267
|
+
}
|
|
268
|
+
return paths
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* @author ddj 2026年10月30号
|
|
273
|
+
* @description: `p4 where` 把小写 depot 路径折成本地绝对路径。
|
|
274
|
+
* @param shell shell 能力
|
|
275
|
+
* @param root P4 工作区根
|
|
276
|
+
* @param depot depot 路径
|
|
277
|
+
* @returns 本地路径,失败时 undefined
|
|
278
|
+
*/
|
|
279
|
+
async function p4LocalPath(shell: ShellLike, root: string, depot: string): Promise<string | undefined> {
|
|
280
|
+
if (depot.startsWith('//')) {
|
|
281
|
+
const where = await runCommand(shell, `p4 where ${quote(depot)}`, root)
|
|
282
|
+
if (!where.ok) return undefined
|
|
283
|
+
// 形如 "//depot/x //client/x C:\work\x"
|
|
284
|
+
const tokens = where.stdout.trim().split(/\s+/)
|
|
285
|
+
return tokens[tokens.length - 1] ?? undefined
|
|
286
|
+
}
|
|
287
|
+
return resolve(root, depot)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* @author ddj 2026年10月30号
|
|
292
|
+
* @description: 取 `#have` 版本内容;只有明确"没有该版本"时返回 null,其余失败抛出。
|
|
293
|
+
* @param shell shell 能力
|
|
294
|
+
* @param root P4 工作区根
|
|
295
|
+
* @param depot depot 路径
|
|
296
|
+
* @returns 基线内容或 null
|
|
297
|
+
*/
|
|
298
|
+
async function p4Baseline(shell: ShellLike, root: string, depot: string): Promise<string | null> {
|
|
299
|
+
if (!depot.startsWith('//')) return null
|
|
300
|
+
const printed = await runCommand(shell, `p4 print -q ${quote(depot + '#have')}`, root)
|
|
301
|
+
if (printed.ok) return printed.stdout
|
|
302
|
+
if (P4_ABSENT_AT_REVISION.test(printed.stderr)) return null
|
|
303
|
+
throw new Error('读取 p4 基线失败:' + printed.stderr.slice(0, 200))
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* @author ddj 2026年10月30号
|
|
308
|
+
* @description: 解析 git status -z 输出;重命名/复制记录按目标路径处理。
|
|
309
|
+
* @param text -z 输出
|
|
310
|
+
* @returns 状态与仓库相对路径
|
|
311
|
+
*/
|
|
312
|
+
function parseGitStatus(text: string): Array<{ status: string; path: string }> {
|
|
313
|
+
const parts = text.split('\0').filter((part) => part.length > 2)
|
|
314
|
+
const entries: Array<{ status: string; path: string }> = []
|
|
315
|
+
for (let index = 0; index < parts.length; index++) {
|
|
316
|
+
const part = parts[index]!
|
|
317
|
+
const status = part.slice(0, 2)
|
|
318
|
+
const path = part.slice(3)
|
|
319
|
+
// 重命名/复制后紧跟原路径,跳过它
|
|
320
|
+
if (status.includes('R') || status.includes('C')) index++
|
|
321
|
+
entries.push({ status, path })
|
|
322
|
+
}
|
|
323
|
+
return entries
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* @author ddj 2026年10月30号
|
|
328
|
+
* @description: 解析 svn status --xml 输出。
|
|
329
|
+
* @param xml xml 文本
|
|
330
|
+
* @returns 条目状态与相对路径
|
|
331
|
+
*/
|
|
332
|
+
function parseSvnStatus(xml: string): Array<{ status: string; path: string }> {
|
|
333
|
+
const entries: Array<{ status: string; path: string }> = []
|
|
334
|
+
const pattern = /<entry\s+path="([^"]+)"([\s\S]*?)<\/entry>/g
|
|
335
|
+
for (const match of xml.matchAll(pattern)) {
|
|
336
|
+
const status = /<wc-status[^>]*\sitem="([^"]+)"/.exec(match[2] ?? '')?.[1]
|
|
337
|
+
if (status) entries.push({ status, path: decodeXml(match[1]!) })
|
|
338
|
+
}
|
|
339
|
+
return entries
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* @author ddj 2026年10月30号
|
|
344
|
+
* @description: 执行一条只读命令并收集输出;超时/取消/非零退出都回 ok=false,不抛出。
|
|
345
|
+
* @param shell shell 能力
|
|
346
|
+
* @param command 命令
|
|
347
|
+
* @param workdir 工作目录
|
|
348
|
+
* @param stdoutMaxBytes stdout 预算
|
|
349
|
+
* @returns 输出与状态
|
|
350
|
+
*/
|
|
351
|
+
async function runCommand(
|
|
352
|
+
shell: ShellLike, command: string, workdir: string, stdoutMaxBytes = STDOUT_BUDGET,
|
|
353
|
+
): Promise<{ ok: boolean; stdout: string; stderr: string }> {
|
|
354
|
+
try {
|
|
355
|
+
const spec = shell.resolve({ command, workdir, timeoutMs: COMMAND_TIMEOUT, stdoutMaxBytes })
|
|
356
|
+
const execution = await shell.execute(spec)
|
|
357
|
+
const result = await execution.result()
|
|
358
|
+
return {
|
|
359
|
+
ok: result.exitCode === 0 && !result.timedOut && !result.aborted,
|
|
360
|
+
stdout: result.stdout.text,
|
|
361
|
+
stderr: result.stderr.text,
|
|
362
|
+
}
|
|
363
|
+
} catch (error) {
|
|
364
|
+
return { ok: false, stdout: '', stderr: String(error) }
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* @author ddj 2026年10月30号
|
|
370
|
+
* @description: 读单元格文本,不存在时返回 null。
|
|
371
|
+
* @param path 文件路径
|
|
372
|
+
* @returns 内容或 null
|
|
373
|
+
*/
|
|
374
|
+
async function readTextOf(path: string): Promise<string | null> {
|
|
375
|
+
try {
|
|
376
|
+
const info = await stat(path)
|
|
377
|
+
if (!info.isFile()) return null
|
|
378
|
+
return await readFile(path, 'utf8')
|
|
379
|
+
} catch {
|
|
380
|
+
return null
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* @author ddj 2026年10月30号
|
|
386
|
+
* @description: 判断路径是否存在。
|
|
387
|
+
* @param path 路径
|
|
388
|
+
* @returns 是否存在
|
|
389
|
+
*/
|
|
390
|
+
async function exists(path: string): Promise<boolean> {
|
|
391
|
+
try {
|
|
392
|
+
await stat(path)
|
|
393
|
+
return true
|
|
394
|
+
} catch {
|
|
395
|
+
return false
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* @author ddj 2026年10月30号
|
|
401
|
+
* @description: 判断目标是否位于仓库根之内(Windows 下按大小写不敏感比较)。
|
|
402
|
+
* @param root 仓库根
|
|
403
|
+
* @param target 目标绝对路径
|
|
404
|
+
* @returns 是否在根内
|
|
405
|
+
*/
|
|
406
|
+
function insideRoot(root: string, target: string): boolean {
|
|
407
|
+
const normalize = (value: string) => (process.platform === 'win32' ? value.toLowerCase() : value)
|
|
408
|
+
const rootKey = normalize(resolve(root)).replace(/[\\/]+$/, '') + sep
|
|
409
|
+
return normalize(resolve(target)).startsWith(rootKey)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* @author ddj 2026年10月30号
|
|
414
|
+
* @description: 比较两个路径是否同一文件(Windows 下大小写不敏感)。
|
|
415
|
+
* @param left 路径
|
|
416
|
+
* @param right 路径
|
|
417
|
+
* @returns 是否相同
|
|
418
|
+
*/
|
|
419
|
+
function samePath(left: string, right: string): boolean {
|
|
420
|
+
const normalize = (value: string) => (process.platform === 'win32' ? value.toLowerCase() : value)
|
|
421
|
+
return normalize(resolve(left)) === normalize(resolve(right))
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* @author ddj 2026年10月30号
|
|
426
|
+
* @description: 为 shell 命令参数加引号,避免空格与特殊字符改变命令含义。
|
|
427
|
+
* @param value 参数
|
|
428
|
+
* @returns 加引号后的参数
|
|
429
|
+
*/
|
|
430
|
+
function quote(value: string): string {
|
|
431
|
+
return '"' + value.replace(/"/g, '\\"') + '"'
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* @author ddj 2026年10月30号
|
|
436
|
+
* @description: 还原 xml 实体(svn status 会转义非 ASCII 与空格)。
|
|
437
|
+
* @param value 属性值
|
|
438
|
+
* @returns 解码后的路径
|
|
439
|
+
*/
|
|
440
|
+
function decodeXml(value: string): string {
|
|
441
|
+
return value
|
|
442
|
+
.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
443
|
+
.replace(/'/g, "'").replace(/&/g, '&')
|
|
444
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 待审投影 → 现有面板的兼容投影:客户端不改渲染,数据源换成累计基线。
|
|
3
|
+
* 同一路径只由待审投影负责,官方旧行让位,避免双权威。
|
|
4
|
+
* @author ddj 2026年10月29号
|
|
5
|
+
*/
|
|
6
|
+
import type { ReviewDetail, ReviewItem, ReviewPage } from './shared/review.js'
|
|
7
|
+
import type { PendingDetail, PendingItem, PendingPage } from './shared/pendingReview.js'
|
|
8
|
+
|
|
9
|
+
/** 进程内"已被待审接管"的路径集合;旧写入口守卫只读它,绝不碰数据库。 */
|
|
10
|
+
const coveredPaths = new Set<string>()
|
|
11
|
+
|
|
12
|
+
/** 待审条目在面板列表里的 id 前缀,用于把请求路由回待审内核。 */
|
|
13
|
+
export { PENDING_PREFIX } from './shared/pendingReview.js'
|
|
14
|
+
import { PENDING_PREFIX } from './shared/pendingReview.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @author ddj 2026年10月29号
|
|
18
|
+
* @description: 由条目 id 派生稳定序号,保证轮询期间面板锚点不跳。
|
|
19
|
+
* @param id 待审条目身份
|
|
20
|
+
* @returns 正整数序号
|
|
21
|
+
*/
|
|
22
|
+
function stableSeq(id: string): number {
|
|
23
|
+
let hash = 2166136261
|
|
24
|
+
for (let index = 0; index < id.length; index++) {
|
|
25
|
+
hash ^= id.charCodeAt(index)
|
|
26
|
+
hash = Math.imul(hash, 16777619)
|
|
27
|
+
}
|
|
28
|
+
return (Math.abs(hash) % 2147483647) + 1
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @author ddj 2026年10月29号
|
|
33
|
+
* @description: 统一路径比较口径,消除分隔符与 Windows 大小写差异;远端身份保留原大小写。
|
|
34
|
+
* @param path 路径
|
|
35
|
+
* @returns 比较键
|
|
36
|
+
*/
|
|
37
|
+
function pathKey(path: string): string {
|
|
38
|
+
const unified = path.replace(/\\/g, '/')
|
|
39
|
+
// 远端仓库身份区分大小写,折叠成小写会把 Host/Repo 与 host/repo 混成同一个文件。
|
|
40
|
+
if (/^[a-z]+:\/\//i.test(unified)) return unified
|
|
41
|
+
// 盘符路径天生大小写不敏感(与宿主平台无关,客户端 diffPathKey 同口径);
|
|
42
|
+
// POSIX 路径在 POSIX 上区分大小写,不能折叠。
|
|
43
|
+
if (/^[a-z]:\//i.test(unified)) return unified.toLowerCase()
|
|
44
|
+
return process.platform === 'win32' ? unified.toLowerCase() : unified
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @author ddj 2026年10月30号
|
|
49
|
+
* @description: 对外暴露统一路径比较口径,供列表按路径筛选待审条目。
|
|
50
|
+
* @param path 路径
|
|
51
|
+
* @returns 比较键
|
|
52
|
+
*/
|
|
53
|
+
export function pendingPathKey(path: string): string {
|
|
54
|
+
return pathKey(path)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @author ddj 2026年10月30号
|
|
59
|
+
* @description: 判断两个路径是否指同一文件:比较键相同,或一方是另一方的工作区相对写法。
|
|
60
|
+
* 面板传下来的路径可能是相对路径,而条目里存的是绝对路径。
|
|
61
|
+
* @param left 路径
|
|
62
|
+
* @param right 路径
|
|
63
|
+
* @returns 是否同一文件
|
|
64
|
+
*/
|
|
65
|
+
export function pendingSamePath(left: string, right: string): boolean {
|
|
66
|
+
const a = pathKey(left)
|
|
67
|
+
const b = pathKey(right)
|
|
68
|
+
if (!a || !b) return false
|
|
69
|
+
return a === b || a.endsWith('/' + b) || b.endsWith('/' + a)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @author ddj 2026年10月30号
|
|
74
|
+
* @description: 列表里的条目 id 带待审前缀用于区分通道,内核按原始身份存取,这里统一还原。
|
|
75
|
+
* @param id 请求里的条目 id
|
|
76
|
+
* @returns 待审内核使用的原始 id
|
|
77
|
+
*/
|
|
78
|
+
export function pendingRawId(id: string): string {
|
|
79
|
+
return id.startsWith(PENDING_PREFIX) ? id.slice(PENDING_PREFIX.length) : id
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @author ddj 2026年10月30号
|
|
84
|
+
* @description: 列表显示用的短路径:工作区内显示相对路径,区外保留原文。
|
|
85
|
+
* @param cwd 工作区
|
|
86
|
+
* @param path 条目路径
|
|
87
|
+
* @returns 显示用路径
|
|
88
|
+
*/
|
|
89
|
+
export function pendingDisplay(cwd: string, path: string): string {
|
|
90
|
+
const root = cwd.replace(/[\\/]+$/, '')
|
|
91
|
+
const unified = path.replace(/\\/g, '/')
|
|
92
|
+
const base = root.replace(/\\/g, '/')
|
|
93
|
+
if (base && (unified === base || unified.toLowerCase().startsWith(base.toLowerCase() + '/'))) {
|
|
94
|
+
return unified.slice(base.length + 1)
|
|
95
|
+
}
|
|
96
|
+
return path
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @author ddj 2026年10月29号
|
|
101
|
+
* @description: 待审条目转面板列表项。
|
|
102
|
+
* @param item 待审条目
|
|
103
|
+
* @param cwd 工作区,用于显示相对路径;缺省显示原始路径
|
|
104
|
+
* @returns 面板列表项
|
|
105
|
+
*/
|
|
106
|
+
export function pendingItemOf(item: PendingItem, cwd?: string): ReviewItem {
|
|
107
|
+
return {
|
|
108
|
+
id: PENDING_PREFIX + item.id,
|
|
109
|
+
sessionId: item.sessionId,
|
|
110
|
+
turn: 0,
|
|
111
|
+
seq: stableSeq(item.id),
|
|
112
|
+
path: item.path,
|
|
113
|
+
currentPath: item.path,
|
|
114
|
+
display: cwd ? pendingDisplay(cwd, item.path) : item.path,
|
|
115
|
+
added: item.added,
|
|
116
|
+
deleted: item.deleted,
|
|
117
|
+
kind: item.oversized ? 'oversized' : 'text',
|
|
118
|
+
decision: item.settled ? 'accepted' : 'pending',
|
|
119
|
+
revision: item.revision,
|
|
120
|
+
current: true,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @author ddj 2026年10月29号
|
|
126
|
+
* @description: 待审详情转面板详情;Revert 映射到面板的 Undo 能力位。
|
|
127
|
+
* @param detail 待审详情
|
|
128
|
+
* @param capturedAt 采集时间文案
|
|
129
|
+
* @returns 面板详情
|
|
130
|
+
*/
|
|
131
|
+
export function pendingDetailOf(detail: PendingDetail, capturedAt: string, cwd?: string): ReviewDetail {
|
|
132
|
+
return {
|
|
133
|
+
item: pendingItemOf(detail.item, cwd),
|
|
134
|
+
diff: detail.diff,
|
|
135
|
+
capturedAt,
|
|
136
|
+
blocks: detail.blocks,
|
|
137
|
+
capability: {
|
|
138
|
+
keep: detail.capability.keep,
|
|
139
|
+
undo: detail.capability.revert,
|
|
140
|
+
reason: detail.capability.reason,
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @author ddj 2026年10月29号
|
|
147
|
+
* @description: 记住/忘记"该路径由待审投影接管"。进程内集合,绝不在旧写入口上查库(避免撞上写事务锁)。
|
|
148
|
+
* @param cwd 工作区
|
|
149
|
+
* @param path 路径
|
|
150
|
+
* @param covered 是否接管
|
|
151
|
+
* @returns 无
|
|
152
|
+
*/
|
|
153
|
+
export function trackPendingCover(cwd: string, path: string, covered: boolean): void {
|
|
154
|
+
const key = `${pathKey(cwd)}|${pathKey(path)}`
|
|
155
|
+
if (covered) coveredPaths.add(key)
|
|
156
|
+
else coveredPaths.delete(key)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @author ddj 2026年10月29号
|
|
161
|
+
* @description: 判断某路径是否已由待审投影接管;仅读进程内集合,不产生任何 IO。
|
|
162
|
+
* @param cwd 工作区
|
|
163
|
+
* @param path 请求路径
|
|
164
|
+
* @returns 是否已被接管
|
|
165
|
+
*/
|
|
166
|
+
export function pendingCovers(cwd: string, path: string): boolean {
|
|
167
|
+
return coveredPaths.has(`${pathKey(cwd)}|${pathKey(path)}`)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @author ddj 2026年10月29号
|
|
172
|
+
* @description: 合并官方页与待审页:待审覆盖的路径从官方结果中剔除,其余原样保留。
|
|
173
|
+
* @param official 官方页
|
|
174
|
+
* @param pending 待审页
|
|
175
|
+
* @param cwd 工作区,用于把待审行显示成相对路径
|
|
176
|
+
* @returns 合并后的页
|
|
177
|
+
*/
|
|
178
|
+
export function mergedPage(official: ReviewPage, pending: PendingPage, cwd?: string): ReviewPage {
|
|
179
|
+
const covered = new Set(pending.items.map((item) => pathKey(item.path)))
|
|
180
|
+
const kept = official.items.filter((item) => !covered.has(pathKey(item.currentPath)) && !covered.has(pathKey(item.path)))
|
|
181
|
+
const items = [...pending.items.map((item) => pendingItemOf(item, cwd)), ...kept]
|
|
182
|
+
return {
|
|
183
|
+
items,
|
|
184
|
+
pending: pending.pending + kept.filter((item) => item.decision === 'pending').length,
|
|
185
|
+
nextCursor: official.nextCursor,
|
|
186
|
+
gaps: official.gaps,
|
|
187
|
+
...(official.overview ? { overview: official.overview } : {}),
|
|
188
|
+
}
|
|
189
|
+
}
|