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.
@@ -0,0 +1,467 @@
1
+ /**
2
+ * 待审投影的工作区存储:把采集到的写入折进单条累计条目,并承接 Keep/Revert 与外部改动的吸收。
3
+ * @author ddj 2026年10月29号
4
+ */
5
+ import type { PendingDecide, PendingDetail, PendingHistoryMove, PendingItem, PendingPage } from '../shared/pendingReview.js'
6
+ import type { ReviewBlockState } from '../shared/reviewRegions.js'
7
+ import { reviewBlocks } from '../shared/reviewRegions.js'
8
+ import { unlink } from 'node:fs/promises'
9
+ import { resolve, sep } from 'node:path'
10
+ import type { PendingWrite } from './capture.js'
11
+ import { readText } from './capture.js'
12
+ import type { ShellLike, VcsImportOptions } from './vcs.js'
13
+ import { importVcsChanges } from './vcs.js'
14
+ import type { DecideOutcome, WriteResult } from './decide.js'
15
+ import { capabilityOf, decide, diffOf } from './decide.js'
16
+ import { entryFromNew, isSettled, mergeEntries } from './kernel.js'
17
+ import type { PendingEntry } from './kernel.js'
18
+ import type { PendingFlags, StoredEntry } from './persist.js'
19
+ import {
20
+ dropEntry, earliestUndone, latestApplied, loadBlocks, loadEntries, pushHistory,
21
+ readOp, saveBlocks, saveEntry, setHistoryState, writeOp,
22
+ } from './persist.js'
23
+
24
+ /** 统计一次差异的增删行数。 */
25
+ function countsOf(entry: PendingEntry): { added: number; deleted: number } {
26
+ const diff = diffOf(entry)
27
+ if (diff.kind !== 'text') return { added: 0, deleted: 0 }
28
+ let added = 0
29
+ let deleted = 0
30
+ for (const hunk of diff.hunks) {
31
+ for (const line of hunk.lines) {
32
+ if (line.startsWith('+')) added++
33
+ else if (line.startsWith('-')) deleted++
34
+ }
35
+ }
36
+ return { added, deleted }
37
+ }
38
+
39
+ /**
40
+ * @author ddj 2026年10月30号
41
+ * @description: 由当前累计条目现场推导块身份;块是差异的纯函数,落库的只是决定状态。
42
+ * @param entry 累计条目
43
+ * @returns 块状态列表(决定一律 pending,判定推进后由下一次推导自然收窄)
44
+ */
45
+ function blockStatesOf(entry: PendingEntry): ReviewBlockState[] {
46
+ const diff = diffOf(entry)
47
+ if (diff.kind !== 'text') return []
48
+ return reviewBlocks(diff).map((block) => ({ id: block.id, decision: 'pending' as const }))
49
+ }
50
+
51
+ /**
52
+ * @author ddj 2026年10月29号
53
+ * @description: 把一次已确认写入折进累计条目:同一身份只保留一条,基线留最早、内容取最新。
54
+ * @param stored 已有条目(可为空)
55
+ * @param write 采集到的写入
56
+ * @returns 折叠后的条目与标记
57
+ */
58
+ export function foldWrite(stored: StoredEntry | undefined, write: PendingWrite): StoredEntry {
59
+ const fresh: PendingEntry = entryFromNew({
60
+ id: write.identity,
61
+ path: write.path,
62
+ sessionId: write.sessionId,
63
+ earlierVersion: write.before === null ? 'none' : 'file',
64
+ oldText: write.before ?? '',
65
+ newText: write.after ?? '',
66
+ updatedAt: write.now,
67
+ })
68
+ if (!stored) {
69
+ return {
70
+ entry: fresh,
71
+ flags: { missing: write.after === null, diverged: false, oversized: false, revision: 0 },
72
+ }
73
+ }
74
+ const merged = mergeEntries(stored.entry, fresh)
75
+ merged.path = write.path
76
+ return {
77
+ entry: merged,
78
+ flags: {
79
+ missing: write.after === null,
80
+ diverged: false,
81
+ oversized: stored.flags.oversized,
82
+ revision: stored.flags.revision + 1,
83
+ },
84
+ }
85
+ }
86
+
87
+ /**
88
+ * @author ddj 2026年10月29号
89
+ * @description: 条目转列表投影,已解决条目仍保留但不可再决定。
90
+ * @param stored 条目与标记
91
+ * @returns 列表项
92
+ */
93
+ export function itemOf(stored: StoredEntry): PendingItem {
94
+ const { entry, flags } = stored
95
+ const counts = countsOf(entry)
96
+ const item: PendingItem = {
97
+ id: entry.id,
98
+ path: entry.path,
99
+ earlierVersion: entry.earlierVersion,
100
+ added: counts.added,
101
+ deleted: counts.deleted,
102
+ settled: isSettled(entry),
103
+ unseen: entry.unseen === true,
104
+ missing: flags.missing,
105
+ diverged: flags.diverged,
106
+ oversized: flags.oversized,
107
+ revision: flags.revision,
108
+ updatedAt: entry.updatedAt,
109
+ sessionId: entry.sessionId,
110
+ sessionIds: entry.sessionIds,
111
+ }
112
+ if (entry.lineage !== undefined) item.lineage = entry.lineage
113
+ return item
114
+ }
115
+
116
+ /**
117
+ * @author ddj 2026年10月29号
118
+ * @description: 读一页待审条目,按更新时间升序;会话过滤走血缘集合。
119
+ * @param cwd 工作区
120
+ * @param sessionId 当前会话
121
+ * @returns 列表
122
+ */
123
+ export function loadPage(cwd: string, sessionId?: string): PendingPage {
124
+ const all = loadEntries(cwd)
125
+ const visible = sessionId ? all.filter((stored) => stored.entry.sessionIds.includes(sessionId)) : all
126
+ const items = visible.map(itemOf)
127
+ const gaps: string[] = []
128
+ for (const stored of visible) {
129
+ if (stored.flags.oversized) gaps.push(`${stored.entry.path}: 文本超出上限,仅保留标记`)
130
+ }
131
+ return { items, pending: items.filter((item) => !item.settled).length, nextCursor: null, gaps }
132
+ }
133
+
134
+ /**
135
+ * @author ddj 2026年10月29号
136
+ * @description: 读单条详情,包含可交给现有面板渲染的差异与块决定。
137
+ * @param cwd 工作区
138
+ * @param id 条目身份
139
+ * @returns 详情,条目不存在时 null
140
+ */
141
+ export function loadDetail(cwd: string, id: string): PendingDetail | null {
142
+ const stored = loadEntries(cwd).find((item) => item.entry.id === id)
143
+ if (!stored) return null
144
+ const diff = diffOf(stored.entry)
145
+ return {
146
+ item: itemOf(stored),
147
+ diff,
148
+ blocks: blockStatesOf(stored.entry),
149
+ capability: capabilityOf(stored.entry, stored.flags),
150
+ }
151
+ }
152
+
153
+ /**
154
+ * @author ddj 2026年10月29号
155
+ * @description: 采集入口:折叠写入并落库。
156
+ * @param write 已确认写入
157
+ * @returns 无
158
+ */
159
+ export function recordWrite(write: PendingWrite): void {
160
+ const stored = loadEntries(write.cwd).find((item) => item.entry.id === write.identity)
161
+ if (stored && stored.entry.oldText === stored.entry.newText && stored.entry.newText === (write.after ?? '')) return
162
+ saveEntry(write.cwd, foldWrite(stored, write))
163
+ }
164
+
165
+ /**
166
+ * @author ddj 2026年10月29号
167
+ * @description: 外部改动吸收:当前内容与条目最新内容不同时采纳并标分歧,基线不动。
168
+ * @param stored 条目
169
+ * @param content 磁盘当前内容
170
+ * @param now 时间
171
+ * @returns 新条目与标记
172
+ */
173
+ export function absorbed(stored: StoredEntry, content: string, now: number): StoredEntry {
174
+ return {
175
+ entry: { ...stored.entry, newText: content, updatedAt: now, unseen: true },
176
+ flags: { ...stored.flags, diverged: true, missing: false, revision: stored.flags.revision + 1 },
177
+ }
178
+ }
179
+
180
+ /**
181
+ * @author ddj 2026年10月29号
182
+ * @description: 读回磁盘并把可读的外部改动吸收进条目;缺失只标标记不删条目。
183
+ * @param stored 条目
184
+ * @param content 磁盘当前内容,null 表示缺失或不可读
185
+ * @param now 时间
186
+ * @returns 需要落库的新状态,无需改动时为 null
187
+ */
188
+ export function refreshed(stored: StoredEntry, content: string | null, now: number): StoredEntry | null {
189
+ if (content === null) {
190
+ if (stored.flags.missing) return null
191
+ return { entry: stored.entry, flags: { ...stored.flags, missing: true, revision: stored.flags.revision + 1 } }
192
+ }
193
+ if (content === stored.entry.newText) {
194
+ if (!stored.flags.missing) return null
195
+ return { entry: stored.entry, flags: { ...stored.flags, missing: false, revision: stored.flags.revision + 1 } }
196
+ }
197
+ return absorbed(stored, content, now)
198
+ }
199
+
200
+ /**
201
+ * @author ddj 2026年10月29号
202
+ * @description: 构造带版本守卫与读回校验的写盘函数;内容已被外部改动时直接拒绝。
203
+ * @param fs 文件系统
204
+ * @param cwd 工作区
205
+ * @param identity 目标身份
206
+ * @param expected 写前应当具备的内容
207
+ * @param signal 取消信号
208
+ * @param policy 沙箱策略
209
+ * @param allowCreate 文件已被删除时是否允许按快照重建(仅撤销/重做路径使用)
210
+ * @returns 写盘函数
211
+ */
212
+ export function writerFor(
213
+ fs: any, cwd: string, identity: string, expected: string, signal?: AbortSignal, policy?: unknown,
214
+ allowCreate = false,
215
+ ): (content: string | null) => Promise<WriteResult> {
216
+ return async (content) => {
217
+ let target: any
218
+ try {
219
+ target = await fs.resolve(identity, { cwd, signal })
220
+ } catch {
221
+ return { ok: false, reason: 'resolve-failed' }
222
+ }
223
+ const current = await readText(fs, target).catch(() => undefined)
224
+ if (current === undefined) return { ok: false, reason: 'read-failed' }
225
+ // 撤销「Revert 删除新建文件」时文件已不存在,只有显式允许重建才放行
226
+ const recreate = allowCreate && current === null
227
+ if (!recreate && current !== expected) return { ok: false, reason: 'diverged' }
228
+ const info = await fs.stat(target, signal).catch(() => undefined)
229
+ try {
230
+ // 第五个实参是沙箱策略;缺它时 provider 会以 FS_SANDBOX_DENIED 拒绝写入
231
+ const written = await fs.writeText(
232
+ target, content ?? '', info ? { kind: 'replaceIfVersion', version: info.version } : undefined, signal, policy,
233
+ )
234
+ const readBack = await readText(fs, target)
235
+ if ((content ?? '') !== (readBack ?? '')) return { ok: false, reason: 'read-back-mismatch' }
236
+ if (content === null && readBack !== null) return { ok: false, reason: 'delete-failed' }
237
+ void written
238
+ return { ok: true, content }
239
+ } catch (error) {
240
+ return { ok: false, reason: reasonOf(error) }
241
+ }
242
+ }
243
+ }
244
+
245
+ /**
246
+ * @author ddj 2026年10月29号
247
+ * @description: 执行一次 Keep/Revert 并把结果落库;失败只返回原因,不写任何状态。
248
+ * @param fs 文件系统
249
+ * @param cwd 工作区
250
+ * @param request 判定请求
251
+ * @param signal 取消信号
252
+ * @returns 判定结果
253
+ */
254
+ export async function applyDecide(
255
+ fs: any, cwd: string, request: PendingDecide, signal?: AbortSignal, policy?: unknown,
256
+ ): Promise<DecideOutcome | { ok: false; reason: string }> {
257
+ const stored = loadEntries(cwd).find((item) => item.entry.id === request.id)
258
+ if (!stored) return { ok: false, reason: 'unknown-item' }
259
+ const beforeBlocks = blockStatesOf(stored.entry)
260
+ const outcome = await decide({
261
+ request,
262
+ entry: stored.entry,
263
+ flags: stored.flags,
264
+ blocks: beforeBlocks,
265
+ now: Date.now(),
266
+ write: (content) => {
267
+ if (content === null) return deleteTarget(fs, cwd, stored.entry.id, signal)
268
+ return writerFor(fs, cwd, stored.entry.id, stored.entry.newText, signal, policy)(content)
269
+ },
270
+ })
271
+ if (!outcome.ok) return { ok: false, reason: outcome.reason }
272
+ saveEntry(cwd, { entry: outcome.entry, flags: outcome.flags })
273
+ saveBlocks(cwd, request.id, outcome.blocks)
274
+ // 记录撤销快照(含是否真的写过盘),撤销/重做都从这条记录反推
275
+ pushHistory(cwd, 'decide', request.id, JSON.stringify({
276
+ before: { entry: stored.entry, flags: stored.flags, blocks: beforeBlocks },
277
+ after: { entry: outcome.entry, flags: outcome.flags, blocks: outcome.blocks as ReviewBlockState[] },
278
+ wrote: outcome.wrote,
279
+ }), Date.now())
280
+ return outcome
281
+ }
282
+
283
+ /**
284
+ * @author ddj 2026年10月30号
285
+ * @description: 版本库导入:把工作副本相对基线的改动折进待审条目,已存在条目按折叠规则并入。
286
+ * @param shell shell 能力
287
+ * @param cwd 工作区
288
+ * @param sessionId 触发导入的会话
289
+ * @param options 导入选项
290
+ * @returns 导入条数与探测到的改动数
291
+ */
292
+ export async function importFromVcs(
293
+ shell: ShellLike, cwd: string, sessionId: string, options: VcsImportOptions = {},
294
+ ): Promise<{ imported: number; changes: number }> {
295
+ const changes = await importVcsChanges(shell, cwd, options)
296
+ const now = Date.now()
297
+ for (const change of changes) {
298
+ recordWrite({
299
+ cwd,
300
+ sessionId,
301
+ path: change.path,
302
+ identity: change.path,
303
+ before: change.earlierVersion === 'none' ? null : change.oldText,
304
+ after: change.newText,
305
+ now,
306
+ })
307
+ }
308
+ return { imported: changes.length, changes: changes.length }
309
+ }
310
+
311
+ /**
312
+ * @author ddj 2026年10月30号
313
+ * @description: 撤销或重做最近一次判定。先按 operationId 判幂等,再据栈顶记录判定方向是否合法,
314
+ * 只有写盘过的判定才反向写盘,写盘失败时条目状态保持原样。
315
+ * @param fs 文件系统
316
+ * @param cwd 工作区
317
+ * @param move 撤销/重做请求
318
+ * @param signal 取消信号
319
+ * @returns 结果条目与是否写盘
320
+ */
321
+ export async function undoDecide(
322
+ fs: any, cwd: string, move: PendingHistoryMove, signal?: AbortSignal, policy?: unknown,
323
+ ): Promise<{ ok: true; item: PendingItem; wrote: boolean } | { ok: false; reason: string }> {
324
+ const replay = readOp(cwd, move.operationId)
325
+ if (replay && replay.state === 'committed' && replay.result) return JSON.parse(replay.result) as { ok: true; item: PendingItem; wrote: boolean }
326
+
327
+ const record = move.direction === 'undo' ? latestApplied(cwd) : earliestUndone(cwd)
328
+ if (!record) return { ok: false, reason: move.direction === 'undo' ? 'no-undo' : 'no-redo' }
329
+ const snapshot = parseSnapshot(record.snapshot)
330
+ if (!snapshot) return { ok: false, reason: 'bad-history' }
331
+ const stored = loadEntries(cwd).find((item) => item.entry.id === record.entryId)
332
+ if (!stored) return { ok: false, reason: 'unknown-item' }
333
+ const target = move.direction === 'undo' ? snapshot.before : snapshot.after
334
+
335
+ writeOp(cwd, move.operationId, JSON.stringify(move), 'prepared')
336
+ let wrote = false
337
+ if (snapshot.wrote) {
338
+ const result = await writeBack(fs, cwd, stored, target, signal, policy)
339
+ if (!result.ok) return { ok: false, reason: result.reason }
340
+ wrote = true
341
+ }
342
+
343
+ saveEntry(cwd, { entry: target.entry, flags: target.flags })
344
+ saveBlocks(cwd, record.entryId, target.blocks)
345
+ setHistoryState(cwd, record.id, move.direction === 'undo' ? 'undone' : 'applied')
346
+ const outcome = { ok: true as const, item: itemOf({ entry: target.entry, flags: target.flags }), wrote }
347
+ writeOp(cwd, move.operationId, JSON.stringify(move), 'committed', { result: JSON.stringify(outcome) })
348
+ return outcome
349
+ }
350
+
351
+ /**
352
+ * @author ddj 2026年10月30号
353
+ * @description: 把文件内容恢复到目标快照:目标为缺失则删除,否则按版本守卫写回并读回校验。
354
+ * @param fs 文件系统
355
+ * @param cwd 工作区
356
+ * @param stored 当前条目
357
+ * @param target 目标快照
358
+ * @param signal 取消信号
359
+ * @returns 写盘结果
360
+ */
361
+ async function writeBack(
362
+ fs: any, cwd: string, stored: StoredEntry, target: HistoryState, signal?: AbortSignal, policy?: unknown,
363
+ ): Promise<WriteResult> {
364
+ if (target.flags.missing) return deleteTarget(fs, cwd, stored.entry.id, signal)
365
+ const write = writerFor(fs, cwd, stored.entry.id, stored.entry.newText, signal, policy, true)
366
+ return write(target.entry.newText)
367
+ }
368
+
369
+ /** 历史快照中单侧状态。 */
370
+ interface HistoryState {
371
+ entry: PendingEntry
372
+ flags: PendingFlags
373
+ blocks: ReviewBlockState[]
374
+ }
375
+
376
+ /** 一次判定的双向快照。 */
377
+ interface HistorySnapshot {
378
+ before: HistoryState
379
+ after: HistoryState
380
+ wrote: boolean
381
+ }
382
+
383
+ /**
384
+ * @author ddj 2026年10月30号
385
+ * @description: 解析历史快照;损坏时返回 null,不猜测内容。
386
+ * @param text 快照文本
387
+ * @returns 快照或 null
388
+ */
389
+ function parseSnapshot(text: string): HistorySnapshot | null {
390
+ try {
391
+ const parsed = JSON.parse(text) as HistorySnapshot
392
+ if (!parsed?.before?.entry || !parsed?.after?.entry) return null
393
+ return parsed
394
+ } catch {
395
+ return null
396
+ }
397
+ }
398
+
399
+ /**
400
+ * @author ddj 2026年10月29号
401
+ * @description: Revert 的删除分支:FileSystem 抽象没有删除能力,只有确认仍在工作区内才用宿主 unlink。
402
+ * @param fs 文件系统
403
+ * @param cwd 工作区
404
+ * @param identity 目标身份
405
+ * @param signal 取消信号
406
+ * @returns 删除结果
407
+ */
408
+ async function deleteTarget(fs: any, cwd: string, identity: string, signal?: AbortSignal): Promise<WriteResult> {
409
+ let target: any
410
+ try {
411
+ target = await fs.resolve(identity, { cwd, signal })
412
+ } catch {
413
+ return { ok: false, reason: 'resolve-failed' }
414
+ }
415
+ const info = await fs.stat(target, signal).catch(() => undefined)
416
+ if (!info) return { ok: true, content: null }
417
+ const absolute = String(fs.processPath(target))
418
+ if (!insideWorkspace(cwd, absolute)) return { ok: false, reason: 'outside-workspace' }
419
+ try {
420
+ await unlink(absolute)
421
+ } catch (error) {
422
+ return { ok: false, reason: reasonOf(error) }
423
+ }
424
+ const after = await fs.stat(target, signal).catch(() => undefined)
425
+ return after ? { ok: false, reason: 'delete-failed' } : { ok: true, content: null }
426
+ }
427
+
428
+ /**
429
+ * @author ddj 2026年10月29号
430
+ * @description: 判断宿主路径是否位于工作区内,Windows 下按大小写不敏感比较。
431
+ * @param cwd 工作区
432
+ * @param absolute 目标绝对路径
433
+ * @returns 是否在工作区内
434
+ */
435
+ function insideWorkspace(cwd: string, absolute: string): boolean {
436
+ const root = resolve(cwd)
437
+ const candidate = resolve(absolute)
438
+ const normalize = (value: string) => (process.platform === 'win32' ? value.toLowerCase() : value)
439
+ const rootKey = normalize(root).endsWith(sep) ? normalize(root) : normalize(root) + sep
440
+ return normalize(candidate).startsWith(rootKey)
441
+ }
442
+
443
+ /**
444
+ * @author ddj 2026年10月29号
445
+ * @description: 从列表移除已解决条目;未解决条目拒绝移除,避免表里看不到却被删掉。
446
+ * @param cwd 工作区
447
+ * @param id 条目身份
448
+ * @param revision 并发校验版本
449
+ * @returns 是否移除
450
+ */
451
+ export function removeEntry(cwd: string, id: string, revision: number): boolean {
452
+ const stored = loadEntries(cwd).find((item) => item.entry.id === id)
453
+ if (!stored || stored.flags.revision !== revision || !isSettled(stored.entry)) return false
454
+ dropEntry(cwd, id)
455
+ return true
456
+ }
457
+
458
+ /**
459
+ * @author ddj 2026年10月29号
460
+ * @description: 取错误码用于拒绝原因,没有则回落到消息文本。
461
+ * @param error 抛出的错误
462
+ * @returns 原因字符串
463
+ */
464
+ function reasonOf(error: unknown): string {
465
+ const code = (error as { code?: unknown }).code
466
+ return typeof code === 'string' ? code : String(error)
467
+ }