dsh-vscode-mode 0.25.1 → 0.26.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.25.1",
3
+ "version": "0.26.0",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + VS Code 兼容代码片段(.code-snippets 全局/项目,IntelliSense 展开)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ SVN 集成(侧栏变更面板/日志弹窗/并排差异)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车,界面状态(页签/光标/侧栏/编辑 Tab/导航历史)镜像到 DSH home 跨重启恢复",
5
5
  "icon": "assets/icon.svg",
6
6
  "keywords": [
@@ -126,6 +126,9 @@ export const SIDECAR_KEEP_TITLE = '标记这些历史改动为已看过:只改
126
126
  /** 非官方 Undo 的作用域说明:走原有回滚校验写回磁盘,但不影响官方记录。 */
127
127
  export const SIDECAR_UNDO_TITLE = '撤销这些历史改动(写回受回滚校验保护):只影响这份历史记录,不影响官方审查。'
128
128
 
129
+ /** 非官方 Keep 之后的完成态:该通道 Keep 不写盘,不能沿用「已采纳」这种会让人以为改过文件的措辞。 */
130
+ export const SIDECAR_KEEP_DONE = '已标记为看过(未改动文件)'
131
+
129
132
  /** 当前端没有该会话时,差异栏里给中性说明而不是红色报错。 */
130
133
  export const SESSION_DOWN_NOTICE = '这个会话没在当前端激活,官方审查数据暂时读不到;下面的历史改动仍可查看、Keep、Undo。'
131
134
 
@@ -30,7 +30,15 @@ export async function loadPending(port: Pick<BatchPort, 'snapshot'>): Promise<Re
30
30
  return page.items
31
31
  }
32
32
 
33
- /** @public @author ddj 2026年10月27号 @param port Guarded existing per-file decision adapter. @param reject Undo instead of Keep. @returns Precise partial result; Undo stops at first conflict, never claims atomic multi-file rollback. */
33
+ /**
34
+ * @public
35
+ * @author ddj 2026年10月27号
36
+ * @description: 逐条决策引擎,现仅供 Undo All 使用(Keep 已改走 `reviewKeepAll.ts` 的宿主批量通道);
37
+ * 保留 reject 形态以便对照既有契约与测试。
38
+ * @param port Guarded existing per-file decision adapter.
39
+ * @param reject Undo instead of Keep.
40
+ * @returns Precise partial result; Undo stops at first conflict, never claims atomic multi-file rollback.
41
+ */
34
42
  export async function runReviewBatch(port: BatchPort, reject: boolean): Promise<BatchResult> {
35
43
  const result: BatchResult = { completed: 0, total: 0, paths: [], error: '' }
36
44
  try {
@@ -0,0 +1,72 @@
1
+ /**
2
+ * 批量 Keep:一次完整快照 + 一次批量 RPC,单条基线问题只跳过自己,不再整批中止。
3
+ * Undo 仍走 reviewBatch.ts 的逐条通道(写盘要逐项校验并在冲突处停止)。
4
+ * @author ddj 2026年11月03号
5
+ */
6
+ import type { KeepAllItem, KeepAllResult, ReviewItem, ReviewPage, ReviewResult } from '../shared/review.js'
7
+ import { loadPending } from './reviewBatch.js'
8
+
9
+ /** 批量 Keep 的 RPC 适配器;guard 在取快照前后各校验一次会话寿命。 */
10
+ export interface KeepAllPort {
11
+ snapshot: () => Promise<ReviewPage>
12
+ keepAll: (items: KeepAllItem[], batchId: string) => Promise<ReviewResult<{ result: KeepAllResult }>>
13
+ guard: () => void
14
+ }
15
+
16
+ /**
17
+ * @private
18
+ * @author ddj 2026年11月03号
19
+ * @description: 取一次完整待审快照;完整性校验失败时重取一次,避免瞬时漂移被当成不可恢复错误。
20
+ * @param port 已鉴权的 RPC 适配器
21
+ * @returns 冻结的批量目标
22
+ * @throws 两次都取不到一致快照时抛出原始原因
23
+ */
24
+ async function snapshotOnce(port: KeepAllPort): Promise<KeepAllItem[]> {
25
+ try {
26
+ return toItems(await loadPending(port))
27
+ } catch {
28
+ // 分页期间数量漂移多为瞬时:guard 仍有效就重新取一次,仍不一致才放弃
29
+ port.guard()
30
+ return toItems(await loadPending(port))
31
+ }
32
+ }
33
+
34
+ /**
35
+ * @private
36
+ * @author ddj 2026年11月03号
37
+ * @description: 把快照条目裁成批量目标,只带 host 需要的身份与版本线索。
38
+ * @param items 完整待审集
39
+ * @returns 批量目标
40
+ */
41
+ function toItems(items: ReviewItem[]): KeepAllItem[] {
42
+ return items.map((item) => ({ id: item.id, display: item.display, seq: item.seq, revision: item.revision }))
43
+ }
44
+
45
+ /**
46
+ * @public
47
+ * @author ddj 2026年11月03号
48
+ * @description: 批量保留全部待审记录(Keep 不改写磁盘)。一次往返完成,跳过项只进 skipped,
49
+ * 段级异常才写 error;guard 失效时保留已完成计数交给调用方呈现。
50
+ * @param port 已鉴权的 RPC 适配器
51
+ * @returns 与 BatchResult 同构的批量结果(含跳过明细)
52
+ */
53
+ export async function runKeepAll(port: KeepAllPort): Promise<KeepAllResult> {
54
+ const result: KeepAllResult = { completed: 0, total: 0, paths: [], skipped: [], error: '' }
55
+ try {
56
+ port.guard()
57
+ const items = await snapshotOnce(port)
58
+ result.total = items.length
59
+ if (!items.length) return result
60
+
61
+ port.guard()
62
+ const reply = await port.keepAll(items, crypto.randomUUID())
63
+ if (!reply.ok) {
64
+ result.error = reply.error
65
+ return result
66
+ }
67
+ return reply.result
68
+ } catch (cause) {
69
+ result.error = cause instanceof Error ? cause.message : String(cause)
70
+ return result
71
+ }
72
+ }
@@ -32,6 +32,39 @@ export function officialPath(path: string, page: ReviewPage | null): boolean {
32
32
  return !!page?.overview?.coveredPaths.some((candidate) => diffPathKey(candidate) === key)
33
33
  }
34
34
 
35
+ /**
36
+ * @author ddj 2026年11月3号
37
+ * @description: 本页待审条目占用的路径键:路径本身与其身份各一份。
38
+ * @param page 审查页
39
+ * @returns 待审覆盖的路径键集合
40
+ */
41
+ function pendingCoverKeys(page: ReviewPage | null): Set<string> {
42
+ const keys = new Set<string>()
43
+ for (const item of page?.items ?? []) {
44
+ if (!item.id.startsWith(PENDING_PREFIX)) continue
45
+ // 待审条目的 path 取自写入时的原始写法(agent 常用工作区相对路径),官方导航用的却是绝对身份:
46
+ // 只按 path 建覆盖集时,同一文件的官方汇总行不会被压掉,差异栏把 3 个文件算成 6 条。
47
+ // 身份(id 去掉待审前缀)与官方 identity 同源,必须一起入集。
48
+ keys.add(diffPathKey(item.currentPath || item.path))
49
+ keys.add(diffPathKey(item.id.slice(PENDING_PREFIX.length)))
50
+ }
51
+ return keys
52
+ }
53
+
54
+ /**
55
+ * @public
56
+ * @author ddj 2026年11月3号
57
+ * @description: 取本页"已有权威通道在管"的路径键:官方汇总覆盖集 + 待审条目。
58
+ * 编辑器与差异汇总必须同口径,否则同一改动在列表里被压掉、在编辑器里却仍然画得出来并可以点。
59
+ * @param page 审查页
60
+ * @returns 路径比较键集合
61
+ */
62
+ export function coveredPathKeys(page: ReviewPage | null): Set<string> {
63
+ const keys = pendingCoverKeys(page)
64
+ for (const path of page?.overview?.coveredPaths ?? []) keys.add(diffPathKey(path))
65
+ return keys
66
+ }
67
+
35
68
  /**
36
69
  * @author ddj 2026年10月30号
37
70
  * @description: 取出本页待审条目(id 带待审前缀)并折算成文件行。
@@ -40,15 +73,9 @@ export function officialPath(path: string, page: ReviewPage | null): boolean {
40
73
  */
41
74
  function pendingRowsOf(page: ReviewPage | null): { rows: DiffFile[]; covered: Set<string> } {
42
75
  const rows: DiffFile[] = []
43
- const covered = new Set<string>()
44
76
  for (const item of page?.items ?? []) {
45
77
  if (!item.id.startsWith(PENDING_PREFIX)) continue
46
78
  const path = item.currentPath || item.path
47
- // 待审条目的 path 取自写入时的原始写法(agent 常用工作区相对路径),官方导航用的却是绝对身份:
48
- // 只按 path 建覆盖集时,同一文件的官方汇总行不会被压掉,差异栏把 3 个文件算成 6 条。
49
- // 身份(id 去掉待审前缀)与官方 identity 同源,必须一起入集。
50
- covered.add(diffPathKey(path))
51
- covered.add(diffPathKey(item.id.slice(PENDING_PREFIX.length)))
52
79
  rows.push({
53
80
  path,
54
81
  label: item.display || path,
@@ -59,7 +86,7 @@ function pendingRowsOf(page: ReviewPage | null): { rows: DiffFile[]; covered: Se
59
86
  sourceKind: 'pending',
60
87
  })
61
88
  }
62
- return { rows, covered }
89
+ return { rows, covered: pendingCoverKeys(page) }
63
90
  }
64
91
 
65
92
  /** @public @author ddj 2026年10月26号 @param records Tool-call history. @param page Official file navigation, never only its first 50 rows. @returns Pending first, then official; legacy paths remain explicitly labelled. */
@@ -24,10 +24,12 @@ import { dbg, isDebug, rpc } from '../rpc.js'
24
24
  import { refreshFeed, subscribeErrors, subscribeRecords } from '../state/recordsFeed.js'
25
25
  import { subscribeReview, refreshReview } from '../state/reviewFeed.js'
26
26
  import { runReviewBatch } from '../reviewBatch.js'
27
+ import { runKeepAll } from '../reviewKeepAll.js'
28
+ import { toastDom } from '../externalOpen.js'
27
29
  import { ReviewAllConfirm } from './ReviewAllConfirm.js'
28
30
  import { reviewRegions } from '../../shared/reviewRegions.js'
29
31
  import { PENDING_PREFIX } from '../../shared/pendingReview.js'
30
- import { diffSummary, officialPath } from '../state/diffSummary.js'
32
+ import { coveredPathKeys, diffPathKey, diffSummary } from '../state/diffSummary.js'
31
33
  import { emitFileChanged, emitRefresh } from '../events.js'
32
34
  import { langOf, loadMonaco, snippetLanguageOf } from '../monaco/loader.js'
33
35
  import { bindEditActions, runEditAction } from '../monaco/editActions.js'
@@ -134,7 +136,7 @@ import { clampToolbarPosition, loadToolbarPosition, saveToolbarPosition } from '
134
136
  import { editorModelPathOf } from '../dap/modelPath.js'
135
137
  import { dapTrace } from '../dap/trace.js'
136
138
  import { isSessionMissingError, sessionErrorText, sessionMissingDetail, sessionMissingTitle } from '../sessionError.js'
137
- import { officialDownView, SESSION_DOWN_NOTICE } from '../reviewBar.js'
139
+ import { officialDownView, SESSION_DOWN_NOTICE, SIDECAR_KEEP_DONE } from '../reviewBar.js'
138
140
 
139
141
  /** 官方差异读取失败前缀:仅用于清理同源陈旧错误,不吞并保存类错误。@author ddj 2026年10月27号 */
140
142
  const REVIEW_LOAD_ERROR = '官方差异读取失败:'
@@ -565,14 +567,17 @@ export function EditorView(props) {
565
567
  layoutRef.current = layout
566
568
  if (!diffRendererRef.current) diffRendererRef.current = createDiffRenderer((sid, t) => dbg(sid, t))
567
569
 
570
+ // 已有权威通道(官方汇总 / 待审累计基线)在管的路径:旧工具记录不得再画出来,也不得被批量纳入
571
+ const coveredPaths = React.useMemo(() => coveredPathKeys(review.page), [review.page])
572
+
568
573
  const currentRecords = React.useMemo(() => {
569
574
  const list = []
570
575
  for (const rec of Object.values(records)) {
571
- if (rec.archived === true || officialPath(rec.path, review.page)) continue
576
+ if (rec.archived === true || coveredPaths.has(diffPathKey(rec.path))) continue
572
577
  if (sameFile(rec.path, active)) list.push(rec)
573
578
  }
574
579
  return list
575
- }, [records, active, cwd, review.page])
580
+ }, [records, active, cwd, coveredPaths])
576
581
 
577
582
  const reviewOpsRef = React.useRef(new Map())
578
583
  // 已自动结案的记录 id:防止「改动已不在文件中」的记录在刷新期间被反复提交判定
@@ -3315,8 +3320,10 @@ export function EditorView(props) {
3315
3320
  }
3316
3321
  // #endregion
3317
3322
  if (!res || !res.ok || !Array.isArray(res.results)) {
3318
- setError(res?.error ? String(res.error) : '批量处理失败')
3319
- return { ok: 0, fail: items.length, stale: 0 }
3323
+ const failure = res?.error ? String(res.error) : '批量处理失败'
3324
+ setError(failure)
3325
+ // 真实原因要一路带到调用处:调用方按计数兜底会把 Host 原文盖掉,用户只看得到泛化文案
3326
+ return { ok: 0, fail: items.length, stale: 0, error: failure }
3320
3327
  }
3321
3328
  let ok = 0
3322
3329
  let fail = 0
@@ -3336,8 +3343,9 @@ export function EditorView(props) {
3336
3343
  if (Object.keys(next).length) setRecords((prev) => Object.assign({}, prev, next))
3337
3344
  return { ok, fail, stale }
3338
3345
  }).catch((e) => {
3339
- setError('批量处理异常:' + String(e))
3340
- return { ok: 0, fail: items.length, stale: 0 }
3346
+ const failure = '批量处理异常:' + String(e)
3347
+ setError(failure)
3348
+ return { ok: 0, fail: items.length, stale: 0, error: failure }
3341
3349
  })
3342
3350
  }
3343
3351
 
@@ -3470,6 +3478,33 @@ export function EditorView(props) {
3470
3478
  finally { batchBusyRef.current = false; if (lifetime?.live) setReviewBatchBusy(false) }
3471
3479
  }
3472
3480
 
3481
+ /**
3482
+ * 批量失败提示:Host 原文优先,只有拿不到原因时才按计数兜底。
3483
+ * @author ddj 2026年11月3号
3484
+ * @param result actMany 的结果
3485
+ * @returns 提示文案;没有失败返回 null
3486
+ */
3487
+ const batchError = (result) => {
3488
+ if (!result.error) return batchFailText(result)
3489
+ // 会话没在当前端激活:写类 RPC 一律被 requireSession 拒绝(冷读列表却正常),
3490
+ // 只把「会话不存在」原文丢给用户会像新故障,这里改说清「没执行、记录没动、怎么恢复」。
3491
+ if (isSessionMissingError(result.error)) return '这个会话没在当前端激活,Keep/Undo 没有执行(记录未改动);在会话里发一条消息激活后重试'
3492
+ return result.error
3493
+ }
3494
+
3495
+ /**
3496
+ * 批量结果没有 Host 原文时的计数兜底文案。
3497
+ * @author ddj 2026年11月3号
3498
+ * @param result actMany 的结果
3499
+ * @returns 提示文案;没有失败返回 null
3500
+ */
3501
+ const batchFailText = (result) => {
3502
+ if (!result.fail) return null
3503
+ return result.stale
3504
+ ? `${result.fail} 处未处理(其中 ${result.stale} 处已不在文件中,只更新了记录),可刷新后重试`
3505
+ : `${result.fail} 处未处理,可刷新后重试`
3506
+ }
3507
+
3473
3508
  const acceptFile = () => {
3474
3509
  if (missingNow && !officialDetail) return decideMissing(false)
3475
3510
  if (officialDetail) return decideOfficial(false)
@@ -3479,8 +3514,11 @@ export function EditorView(props) {
3479
3514
  batchBusyRef.current = false
3480
3515
  reloadFile(true)
3481
3516
  emitRefresh({ scope, paths: active ? [active] : undefined, kind: 'structure' })
3482
- setStatus(decideStatus('已采纳 ', result))
3483
- if (result.fail) setError(result.fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
3517
+ // 能走到这里的都是「历史改动」通道:Keep 只把记录标成已看过,不写盘,
3518
+ // 用「已采纳」会让人以为文件被改过,这里必须换成准确措辞
3519
+ setStatus(result.ok ? `${SIDECAR_KEEP_DONE}(${result.ok} 处)` : decideStatus('已采纳 ', result))
3520
+ const message = batchError(result)
3521
+ if (message) setError(message)
3484
3522
  }).catch(() => { batchBusyRef.current = false })
3485
3523
  }
3486
3524
  const undoFile = () => {
@@ -3493,7 +3531,8 @@ export function EditorView(props) {
3493
3531
  reloadFile(true)
3494
3532
  emitRefresh({ scope, paths: active ? [active] : undefined, kind: 'structure' })
3495
3533
  setStatus(decideStatus('已不采纳 ', result))
3496
- if (result.fail) setError(result.fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
3534
+ const message = batchError(result)
3535
+ if (message) setError(message)
3497
3536
  }).catch(() => { batchBusyRef.current = false })
3498
3537
  }
3499
3538
 
@@ -3501,7 +3540,7 @@ export function EditorView(props) {
3501
3540
  const allPending = React.useMemo(() => {
3502
3541
  const out = []
3503
3542
  for (const rec of Object.values(records)) {
3504
- if (rec.superseded === true || officialPath(rec.path, review.page)) continue
3543
+ if (rec.superseded === true || coveredPaths.has(diffPathKey(rec.path))) continue
3505
3544
  const perHunk = Array.isArray(rec.decisions?.perHunk) ? rec.decisions.perHunk : []
3506
3545
  const hunks = Array.isArray(rec.hunks) ? rec.hunks : []
3507
3546
  for (let i = 0; i < hunks.length; i++) {
@@ -3555,7 +3594,8 @@ export function EditorView(props) {
3555
3594
  if (reject || !missingNow) reloadFile(true)
3556
3595
  emitRefresh({ scope, paths, kind: 'structure' })
3557
3596
  setStatus(decideStatus(reject ? '已不采纳 ' : '已采纳 ', result) + `;删除文件 ${handled.size} 个`)
3558
- if (result.fail) setError(result.fail + ' 处差异处理失败,可刷新后重试')
3597
+ const message = batchError(result)
3598
+ if (message) setError(message)
3559
3599
  } catch (cause) { if (lifetime?.live) setError(String(cause)) }
3560
3600
  finally { batchBusyRef.current = false; if (lifetime?.live) refreshRecords() }
3561
3601
  }
@@ -3592,6 +3632,68 @@ export function EditorView(props) {
3592
3632
  return false
3593
3633
  }
3594
3634
 
3635
+ /** @private @author ddj 2026年11月03号 @description 批量通道的一次性完整快照(分页口径的 pending 只是页内计数)。 @returns 完整待审页 */
3636
+ async function batchSnapshot() {
3637
+ const res = await rpc('edrv.review.list', { sessionId, all: true })
3638
+ if (!res.ok) throw new Error(res.error)
3639
+ return res.page
3640
+ }
3641
+
3642
+ /** @private @author ddj 2026年11月03号 @param token 起批时的会话寿命 @returns 逐条 Undo 的精确部分完成(冲突处停止) */
3643
+ function undoAllBatch(token) {
3644
+ return runReviewBatch({
3645
+ snapshot: batchSnapshot,
3646
+ detail: async (id) => { const res = await rpc('edrv.review.detail', { sessionId, id }); if (!res.ok) throw new Error(res.error); return res.detail },
3647
+ decide: (detail, undo, operationId) => rpc('edrv.review.decide', { sessionId, id: detail.item.id, seq: detail.item.seq, revision: detail.item.revision, operationId, scope: 'file', decision: undo ? 'rejected' : 'accepted' }),
3648
+ guard: () => guardReviewAll(true, token), operation: batchOperation,
3649
+ }, true)
3650
+ }
3651
+
3652
+ /** @private @author ddj 2026年11月03号 @param token 起批时的会话寿命 @returns 一次 RPC 的批量 Keep 结果(跳过项不算失败) */
3653
+ function keepAllBatch(token) {
3654
+ return runKeepAll({
3655
+ snapshot: batchSnapshot,
3656
+ keepAll: (items, batchId) => rpc('edrv.review.keepAll', { sessionId, batchId, items }),
3657
+ guard: () => guardReviewAll(false, token),
3658
+ })
3659
+ }
3660
+
3661
+ /**
3662
+ * @private
3663
+ * @author ddj 2026年11月03号
3664
+ * @description: 批量结果落界面:刷新列表、状态栏计数;Keep 的跳过只做提示不当错误,
3665
+ * Undo 保持「遇冲突停止」口径。
3666
+ * @param result 批量结果
3667
+ * @param reject 本次是 Undo 还是 Keep
3668
+ * @param undoPath 起批时的活动文件
3669
+ * @param undoModel 起批时的编辑器模型
3670
+ * @param undoVersion 起批时的模型版本
3671
+ * @returns 官方段是否完全成功(legacy 通道是否继续)
3672
+ */
3673
+ function finishReviewAll(result, reject, undoPath, undoModel, undoVersion) {
3674
+ refreshReview(sessionId)
3675
+ if (result.paths.length) emitRefresh({ scope, paths: result.paths, kind: 'structure' })
3676
+ if (reject) {
3677
+ if (result.paths.some((path) => sameFile(path, undoPath)) && activeRef.current === undoPath
3678
+ && !dirtyRef.current[undoPath] && !savingRef.current.has(undoPath) && undoModel?.getVersionId?.() === undoVersion) reloadFile(true)
3679
+ setStatus(`Undo All:官方记录已完成 ${result.completed} / ${result.total}`)
3680
+ if (result.error) setError(`批量已停止,已完成 ${result.completed} 项,其余未处理。${result.error}`)
3681
+ return !result.error
3682
+ }
3683
+
3684
+ const skipped = result.skipped ?? []
3685
+ setStatus(`Keep All:已保留 ${result.completed} / ${result.total}` + (skipped.length ? `,跳过 ${skipped.length} 条` : ''))
3686
+ if (result.error) setError(`Keep All 未全部完成:${result.error}`)
3687
+ if (skipped.length) toastDom(skippedTip(skipped))
3688
+ return !result.error
3689
+ }
3690
+
3691
+ /** @private @author ddj 2026年11月03号 @param skipped 被跳过的条目 @returns 最多 3 条、余量汇总的提示文案 */
3692
+ function skippedTip(skipped) {
3693
+ const head = skipped.slice(0, 3).map((item) => `${item.display}:${item.reason}`).join(';')
3694
+ return skipped.length > 3 ? `${head};等 ${skipped.length} 条` : head
3695
+ }
3696
+
3595
3697
  /** @private @author ddj 2026年10月27号 @param reject Undo or Keep all workspace pending records, including previous rounds. @returns Explicit partial results; legacy proceeds only when official stage fully succeeds. */
3596
3698
  async function actReviewAll(reject) {
3597
3699
  if (batchBusyRef.current) return
@@ -3604,23 +3706,9 @@ export function EditorView(props) {
3604
3706
  batchBusyRef.current = true; setReviewBatchBusy(true)
3605
3707
  setStatus(reject ? '正在检查并 Undo 全部待审记录…' : '正在 Keep 全部待审记录…')
3606
3708
  try {
3607
- const result = await runReviewBatch({
3608
- snapshot: async () => {
3609
- const res = await rpc('edrv.review.list', { sessionId, all: true })
3610
- if (!res.ok) throw new Error(res.error)
3611
- return res.page
3612
- },
3613
- detail: async (id) => { const res = await rpc('edrv.review.detail', { sessionId, id }); if (!res.ok) throw new Error(res.error); return res.detail },
3614
- decide: (detail, undo, operationId) => rpc('edrv.review.decide', { sessionId, id: detail.item.id, seq: detail.item.seq, revision: detail.item.revision, operationId, scope: 'file', decision: undo ? 'rejected' : 'accepted' }),
3615
- guard: () => guardReviewAll(reject, token), operation: batchOperation,
3616
- }, reject)
3709
+ const result = reject ? await undoAllBatch(token) : await keepAllBatch(token)
3617
3710
  if (!token?.live) return
3618
- refreshReview(sessionId)
3619
- if (reject && result.paths.some((path) => sameFile(path, undoPath)) && activeRef.current === undoPath
3620
- && !dirtyRef.current[undoPath] && !savingRef.current.has(undoPath) && undoModel?.getVersionId?.() === undoVersion) reloadFile(true)
3621
- if (result.paths.length) emitRefresh({ scope, paths: result.paths, kind: 'structure' })
3622
- setStatus(`${reject ? 'Undo All' : 'Keep All'}:官方记录已完成 ${result.completed} / ${result.total}`)
3623
- if (result.error) { setError(`批量已停止,已完成 ${result.completed} 项,其余未处理。${result.error}`); return }
3711
+ if (!finishReviewAll(result, reject, undoPath, undoModel, undoVersion)) return
3624
3712
  guardReviewAll(reject, token)
3625
3713
  // Official-owned paths are excluded from allPending; never run legacy writes for official history.
3626
3714
  if (allPending.length) { batchBusyRef.current = false; await actAllPending(reject) }
@@ -19,7 +19,7 @@ export function ReviewAllConfirm(props: ReviewAllProps): React.ReactElement {
19
19
  const action = props.reject ? 'Undo All' : 'Keep All'
20
20
  const warning = props.reject
21
21
  ? '撤销会改写磁盘;逐项安全校验,遇到冲突停止,已完成项不会自动恢复。'
22
- : '只标记待审修改为保留,不改写文件。'
22
+ : '只标记待审修改为保留,不改写文件;个别条目若已变化或需在活跃端判定会被跳过并留在列表里,可逐个处理。'
23
23
  const footer = React.createElement(React.Fragment, null,
24
24
  React.createElement(Button, { variant: 'ghost', onClick: props.onCancel, autoFocus: true }, '取消'),
25
25
  React.createElement(Button, { variant: 'primary', onClick: props.onConfirm }, '确认 ' + action))
package/src/index.ts CHANGED
@@ -76,7 +76,7 @@ function attachRuntime(ctx: Ctx, runtime: Runtime): void {
76
76
  const stopCapture = installCapture(ctx, (cwd, snapshot) => { evidence.persist(cwd, snapshot); return true })
77
77
  // 待审累计基线:只有写后内容确实变化才落库,异常一律不打断用户工具调用
78
78
  const pending = installPending(ctx, (write) => {
79
- try { trackPendingCover(write.cwd, write.path, true) } catch { /* 内存登记失败不阻断采集 */ }
79
+ try { trackPendingCover(write.cwd, write.path, true, write.sessionId) } catch { /* 内存登记失败不阻断采集 */ }
80
80
  try { recordWrite(write) } catch (error) { log.warn('待审差异落库失败:' + String(error)) }
81
81
  })
82
82
  /** @author ddj 2026年09月28号 @param exec Tool execution. @param result Tool result. */
@@ -107,8 +107,20 @@ export function loadEntries(cwd: string): StoredEntry[] {
107
107
  * @returns 无
108
108
  */
109
109
  export function saveEntry(cwd: string, stored: StoredEntry): void {
110
+ upsertEntry(pendingDb(cwd), stored)
111
+ }
112
+
113
+ /**
114
+ * @private
115
+ * @author ddj 2026年11月03号
116
+ * @description: 在给定连接上写入或更新一条条目(含标记),供单条与批量两个入口复用。
117
+ * @param db 已迁移连接(可能已在事务中)
118
+ * @param stored 条目与标记
119
+ * @returns 无
120
+ */
121
+ function upsertEntry(db: DatabaseSync, stored: StoredEntry): void {
110
122
  const { entry, flags } = stored
111
- pendingDb(cwd).prepare(`INSERT INTO pending_entries
123
+ db.prepare(`INSERT INTO pending_entries
112
124
  (id,path,earlier,old_text,new_text,updated_at,unseen,missing,diverged,oversized,revision,session_id,session_ids,lineage)
113
125
  VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
114
126
  ON CONFLICT(id) DO UPDATE SET path=excluded.path,earlier=excluded.earlier,old_text=excluded.old_text,
@@ -165,9 +177,7 @@ export function saveBlocks(cwd: string, entryId: string, blocks: { id: string; d
165
177
  const db = pendingDb(cwd)
166
178
  db.exec('BEGIN IMMEDIATE')
167
179
  try {
168
- db.prepare('DELETE FROM pending_blocks WHERE entry_id=?').run(entryId)
169
- const insert = db.prepare('INSERT INTO pending_blocks VALUES(?,?,?)')
170
- for (const block of blocks) insert.run(entryId, block.id, block.decision)
180
+ writeBlocks(db, entryId, blocks)
171
181
  db.exec('COMMIT')
172
182
  } catch (error) {
173
183
  if (db.isTransaction) db.exec('ROLLBACK')
@@ -175,6 +185,21 @@ export function saveBlocks(cwd: string, entryId: string, blocks: { id: string; d
175
185
  }
176
186
  }
177
187
 
188
+ /**
189
+ * @private
190
+ * @author ddj 2026年11月03号
191
+ * @description: 在给定连接上覆盖某条目的块决定集合,供单条与批量两个入口复用。
192
+ * @param db 已迁移连接(可能已在事务中)
193
+ * @param entryId 条目身份
194
+ * @param blocks 块决定
195
+ * @returns 无
196
+ */
197
+ function writeBlocks(db: DatabaseSync, entryId: string, blocks: { id: string; decision: string }[]): void {
198
+ db.prepare('DELETE FROM pending_blocks WHERE entry_id=?').run(entryId)
199
+ const insert = db.prepare('INSERT INTO pending_blocks VALUES(?,?,?)')
200
+ for (const block of blocks) insert.run(entryId, block.id, block.decision)
201
+ }
202
+
178
203
 
179
204
  /**
180
205
  * @author ddj 2026年10月29号
@@ -222,7 +247,21 @@ export function writeOp(
222
247
  * @returns 无
223
248
  */
224
249
  export function pushHistory(cwd: string, kind: string, entryId: string, snapshot: string, now: number): void {
225
- const db = pendingDb(cwd)
250
+ insertHistory(pendingDb(cwd), kind, entryId, snapshot, now)
251
+ }
252
+
253
+ /**
254
+ * @private
255
+ * @author ddj 2026年11月03号
256
+ * @description: 在给定连接上追加撤销栈记录并截断重做分支,供单条与批量两个入口复用。
257
+ * @param db 已迁移连接(可能已在事务中)
258
+ * @param kind 操作种类
259
+ * @param entryId 涉及的条目
260
+ * @param snapshot 可重放快照
261
+ * @param now 时间
262
+ * @returns 无
263
+ */
264
+ function insertHistory(db: DatabaseSync, kind: string, entryId: string, snapshot: string, now: number): void {
226
265
  db.prepare("DELETE FROM pending_history WHERE state='undone'").run()
227
266
  db.prepare("INSERT INTO pending_history(kind,entry_id,snapshot,created_at,state) VALUES(?,?,?,?,'applied')")
228
267
  .run(kind, entryId, snapshot, now)
@@ -327,3 +366,53 @@ function flagsOfRow(row: Row): PendingFlags {
327
366
  revision: Number(row.revision),
328
367
  }
329
368
  }
369
+
370
+ /** 批量 Keep 的一条落库记录:stored 提供 CAS 依据与撤销快照 before,next 是推进后的条目。 */
371
+ export interface KeepRecord {
372
+ entryId: string
373
+ stored: StoredEntry
374
+ next: StoredEntry
375
+ blocks: { id: string; decision: string }[]
376
+ snapshot: string
377
+ }
378
+
379
+ /**
380
+ * @public
381
+ * @author ddj 2026年11月03号
382
+ * @description: 在一次事务内批量推进多条待审条目(批量 Keep 用)。每条按 revision 做 CAS,
383
+ * 只跳过冲突的那一条,不牵连整批;块决定与撤销快照与单条路径同口径。
384
+ * @param cwd 工作区
385
+ * @param records 待落库记录
386
+ * @returns 因版本已变被跳过的条目
387
+ * @throws 事务级异常(SQL/程序错误)整体回滚后抛出
388
+ */
389
+ export function saveDecisions(cwd: string, records: KeepRecord[]): { id: string; reason: string }[] {
390
+ const db = pendingDb(cwd)
391
+ const skipped: { id: string; reason: string }[] = []
392
+ const advance = db.prepare(`UPDATE pending_entries SET path=?,earlier=?,old_text=?,new_text=?,updated_at=?,unseen=?,
393
+ missing=?,diverged=?,oversized=?,revision=?,session_id=?,session_ids=?,lineage=? WHERE id=? AND revision=?`)
394
+
395
+ db.exec('BEGIN IMMEDIATE')
396
+ try {
397
+ for (const record of records) {
398
+ const { entry, flags } = record.next
399
+ const expected = record.stored.flags.revision
400
+ const changed = advance.run(entry.path, entry.earlierVersion, entry.oldText, entry.newText, entry.updatedAt,
401
+ entry.unseen === true ? 1 : 0, flags.missing ? 1 : 0, flags.diverged ? 1 : 0, flags.oversized ? 1 : 0,
402
+ flags.revision, entry.sessionId, JSON.stringify(entry.sessionIds),
403
+ entry.lineage ? JSON.stringify(entry.lineage) : null, record.entryId, expected)
404
+ // 读条目到落库之间被别的决定推进过:只放弃这一条
405
+ if (changed.changes !== 1) {
406
+ skipped.push({ id: record.entryId, reason: '待审条目已变化,请重试' })
407
+ continue
408
+ }
409
+ writeBlocks(db, record.entryId, record.blocks)
410
+ insertHistory(db, 'decide', record.entryId, record.snapshot, Date.now())
411
+ }
412
+ db.exec('COMMIT')
413
+ return skipped
414
+ } catch (error) {
415
+ if (db.isTransaction) db.exec('ROLLBACK')
416
+ throw error
417
+ }
418
+ }
@@ -16,9 +16,10 @@ import { capabilityOf, decide, diffOf } from './decide.js'
16
16
  import { entryFromNew, isSettled, mergeEntries } from './kernel.js'
17
17
  import type { PendingEntry } from './kernel.js'
18
18
  import type { PendingFlags, StoredEntry } from './persist.js'
19
+ import type { KeepRecord } from './persist.js'
19
20
  import {
20
21
  dropEntry, earliestUndone, latestApplied, loadBlocks, loadEntries, pushHistory,
21
- readOp, saveBlocks, saveEntry, setHistoryState, writeOp,
22
+ readOp, saveBlocks, saveDecisions, saveEntry, setHistoryState, writeOp,
22
23
  } from './persist.js'
23
24
 
24
25
  /**
@@ -289,6 +290,80 @@ export async function applyDecide(
289
290
  return outcome
290
291
  }
291
292
 
293
+ /** 批量 Keep 的目标条目:display 只用于回报,operationId 由 host 按批次派生。 */
294
+ export interface PendingKeepTarget {
295
+ id: string
296
+ display: string
297
+ operationId: string
298
+ }
299
+ /** 批量 Keep 的结果:kept 是真正推进到保留态的条目,skipped 说明该条为什么没保留。 */
300
+ export interface PendingKeepOutcome {
301
+ kept: { id: string; path: string }[]
302
+ skipped: { id: string; display: string; reason: string }[]
303
+ }
304
+
305
+ /**
306
+ * @public
307
+ * @author ddj 2026年11月03号
308
+ * @description: 一次读取全表后批量保留待审条目(Keep 不写盘)。写入用刚读到的最新 revision,
309
+ * 客户端快照漂移不会整批中止;单条 oversized、不存在或落库时被抢先推进只跳过自己。
310
+ * @param cwd 工作区
311
+ * @param items 待保留条目
312
+ * @returns 保留成功与跳过的明细
313
+ * @throws 待审库迁移未就绪等存储级异常
314
+ */
315
+ export async function keepAll(cwd: string, items: PendingKeepTarget[]): Promise<PendingKeepOutcome> {
316
+ const stored = new Map(loadEntries(cwd).map((item) => [item.entry.id, item]))
317
+ const now = Date.now()
318
+ const outcome: PendingKeepOutcome = { kept: [], skipped: [] }
319
+ const records: KeepRecord[] = []
320
+
321
+ for (const item of items) {
322
+ const before = stored.get(item.id)
323
+ if (!before) {
324
+ outcome.skipped.push({ id: item.id, display: item.display, reason: '待审条目不存在' })
325
+ continue
326
+ }
327
+ if (before.flags.oversized) {
328
+ outcome.skipped.push({ id: item.id, display: item.display, reason: '文本超出上限,不能整文件保留' })
329
+ continue
330
+ }
331
+ // 已经是保留态:幂等无操作,仍算保留成功
332
+ if (isSettled(before.entry)) {
333
+ outcome.kept.push({ id: item.id, path: before.entry.path })
334
+ continue
335
+ }
336
+
337
+ const beforeBlocks = blockStatesOf(before.entry)
338
+ const outcomeOf = await decide({
339
+ request: { sessionId: before.entry.sessionId, id: item.id, revision: before.flags.revision,
340
+ operationId: item.operationId, decision: 'accepted', scope: 'file' },
341
+ entry: before.entry, flags: before.flags, blocks: beforeBlocks, now,
342
+ // Keep 永不写盘:真走到写回说明逻辑被改坏,宁可炸掉也不静默落盘
343
+ write: () => { throw new Error('批量 Keep 不写盘') },
344
+ })
345
+ if (!outcomeOf.ok) {
346
+ outcome.skipped.push({ id: item.id, display: item.display, reason: outcomeOf.reason })
347
+ continue
348
+ }
349
+ outcome.kept.push({ id: item.id, path: before.entry.path })
350
+ records.push({ entryId: item.id, stored: before, next: { entry: outcomeOf.entry, flags: outcomeOf.flags },
351
+ blocks: outcomeOf.blocks, snapshot: JSON.stringify({
352
+ before: { entry: before.entry, flags: before.flags, blocks: beforeBlocks },
353
+ after: { entry: outcomeOf.entry, flags: outcomeOf.flags, blocks: outcomeOf.blocks },
354
+ wrote: outcomeOf.wrote,
355
+ }) })
356
+ }
357
+
358
+ // 落库阶段被别的决定抢先推进的条目从 kept 挪到 skipped,如实反映磁盘状态
359
+ const displayOf = new Map(items.map((item) => [item.id, item.display]))
360
+ for (const conflict of saveDecisions(cwd, records)) {
361
+ outcome.kept = outcome.kept.filter((entry) => entry.id !== conflict.id)
362
+ outcome.skipped.push({ id: conflict.id, display: displayOf.get(conflict.id) ?? conflict.id, reason: conflict.reason })
363
+ }
364
+ return outcome
365
+ }
366
+
292
367
  /**
293
368
  * @author ddj 2026年10月30号
294
369
  * @description: 版本库导入:把工作副本相对基线的改动折进待审条目,已存在条目按折叠规则并入。