dsh-vscode-mode 0.19.2 → 0.20.1

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.
@@ -22,6 +22,10 @@ import { acceptsLoad, receiptErrorFor, receiptPath, receiptReadyFor, receiptTabP
22
22
  import { bindPanelCommand } from '../panelCommands.js'
23
23
  import { dbg, isDebug, rpc } from '../rpc.js'
24
24
  import { refreshFeed, subscribeErrors, subscribeRecords } from '../state/recordsFeed.js'
25
+ import { subscribeReview, refreshReview } from '../state/reviewFeed.js'
26
+ import { runReviewBatch } from '../reviewBatch.js'
27
+ import { reviewRegions } from '../../shared/reviewRegions.js'
28
+ import { diffSummary, officialPath } from '../state/diffSummary.js'
25
29
  import { emitFileChanged, emitRefresh } from '../events.js'
26
30
  import { langOf, loadMonaco, snippetLanguageOf } from '../monaco/loader.js'
27
31
  import { bindEditActions, runEditAction } from '../monaco/editActions.js'
@@ -256,6 +260,12 @@ export function EditorView(props) {
256
260
  const [monaco, setMonaco] = React.useState(null)
257
261
  const [monacoErr, setMonacoErr] = React.useState(null)
258
262
  const [records, setRecords] = React.useState({})
263
+ const [review, setReview] = React.useState({ page: null, error: '' })
264
+ React.useEffect(() => {
265
+ setReview({ page: null, error: '' })
266
+ if (!sessionId) return undefined
267
+ return subscribeReview(sessionId, setReview)
268
+ }, [sessionId])
259
269
  const [tabs, setTabs] = React.useState([])
260
270
  const [active, setDiskActive] = React.useState(null)
261
271
  const [planTabs, setPlanTabs] = React.useState([])
@@ -434,6 +444,13 @@ export function EditorView(props) {
434
444
  const hoverEditorRef = React.useRef(false) // 鼠标是否仍在编辑器命中区
435
445
  const hoverPanelRef = React.useRef(false) // 鼠标是否已进入 Keep/Undo 浮层
436
446
  const batchBusyRef = React.useRef(false) // 批量 Keep All/Undo All 防重入
447
+ const [reviewBatchBusy, setReviewBatchBusy] = React.useState(false)
448
+ const reviewLifeRef = React.useRef(null)
449
+ React.useEffect(() => {
450
+ const token = { live: true }
451
+ reviewLifeRef.current = token
452
+ return () => { token.live = false }
453
+ }, [sessionId])
437
454
  const menuHandlersRef = React.useRef(null) // 右键菜单动作的最新闭包(Monaco addAction 空依赖回调读取)
438
455
  const svnMenuDisposersRef = React.useRef([]) // Monaco 右键 SVN 组当前 disposables(同步时先注销旧组)
439
456
  // 调试(DAP):store 订阅 tick 驱动重渲 + gutter/F9 切换闭包(ensureEditor 空依赖读取)
@@ -531,11 +548,49 @@ export function EditorView(props) {
531
548
  const currentRecords = React.useMemo(() => {
532
549
  const list = []
533
550
  for (const rec of Object.values(records)) {
534
- if (rec.archived === true) continue
551
+ if (rec.archived === true || officialPath(rec.path, review.page)) continue
535
552
  if (sameFile(rec.path, active)) list.push(rec)
536
553
  }
537
554
  return list
538
- }, [records, active, cwd])
555
+ }, [records, active, cwd, review.page])
556
+
557
+ const reviewOpsRef = React.useRef(new Map())
558
+ const [officialDetail, setOfficialDetail] = React.useState(null)
559
+ const [officialRecords, setOfficialRecords] = React.useState([])
560
+ const [selectedReview, setSelectedReview] = React.useState(null)
561
+ // Only switching file/session/selection invalidates the visible source immediately.
562
+ // Background metadata refresh must not remove and recreate the current Monaco zones.
563
+ React.useEffect(() => {
564
+ setOfficialDetail(null)
565
+ setOfficialRecords([])
566
+ }, [sessionId, active, selectedReview])
567
+ React.useEffect(() => {
568
+ const controller = new AbortController()
569
+ if (!active || !review.page?.overview?.coveredPaths.some((path) => sameFile(path, active))) {
570
+ setOfficialDetail(null)
571
+ setOfficialRecords([])
572
+ return () => controller.abort()
573
+ }
574
+ /** @private @author ddj 2026年10月27号 Load the active file independently of the global history page. */
575
+ async function loadReview() {
576
+ try {
577
+ // Tabs use workspace-relative paths; the metadata index owns the backend identity.
578
+ const reviewPath = review.page?.overview?.coveredPaths.find((path) => sameFile(path, active)) ?? active
579
+ const page = await rpc('edrv.review.list', { sessionId, path: reviewPath }, controller.signal)
580
+ if (controller.signal.aborted) return
581
+ if (!page.ok) throw new Error(page.error)
582
+ const selected = page.page.items.find((item) => item.id === selectedReview) ?? page.page.items[0]
583
+ if (!selected) { setOfficialRecords([]); setOfficialDetail(null); return }
584
+ const result = await rpc('edrv.review.detail', { sessionId, id: selected.id }, controller.signal)
585
+ if (controller.signal.aborted) return
586
+ if (!result.ok) throw new Error(result.error)
587
+ setOfficialRecords((previous) => JSON.stringify(previous) === JSON.stringify(page.page.items) ? previous : page.page.items)
588
+ setOfficialDetail((previous) => JSON.stringify(previous) === JSON.stringify(result.detail) ? previous : result.detail)
589
+ } catch (cause) { if (!controller.signal.aborted) setError('官方差异读取失败:' + String(cause)) }
590
+ }
591
+ void loadReview()
592
+ return () => controller.abort()
593
+ }, [sessionId, active, review.page, selectedReview, sameFile])
539
594
 
540
595
  const contentReady = content !== null && contentPath === active
541
596
  // 当前 tab 是否处于图片预览模式(与 loadContent 的分派条件同源;SVG 文本模式时为 false)
@@ -550,14 +605,16 @@ export function EditorView(props) {
550
605
  const trace = isDebug() && contentReady && currentRecords.length > 0
551
606
  const start = trace ? performance.now() : 0
552
607
  // #endregion
553
- const next = diffRegions(currentRecords, contentReady ? content : null).filter((r) => !r.superseded)
608
+ const next = officialDetail
609
+ ? reviewRegions(officialDetail.diff, officialDetail.item.id, officialDetail.blocks ?? [], contentReady ? content : null)
610
+ : diffRegions(currentRecords, contentReady ? content : null).filter((r) => !r.superseded)
554
611
  // #region debug log
555
612
  if (trace) {
556
613
  try { dbg(sessionId, `[DEBUG diffRegions] records=${currentRecords.length} bytes=${content.length} regions=${next.length} ms=${(performance.now() - start).toFixed(1)}`) } catch (e) { /* 诊断不可影响渲染 */ }
557
614
  }
558
615
  // #endregion
559
616
  return next
560
- }, [currentRecords, content, contentPath, active])
617
+ }, [currentRecords, content, contentPath, active, officialDetail])
561
618
  // useMemo 稳定引用:否则 hover 等重渲染会让 view zone effect 反复重建(- 号闪烁)
562
619
  const pendingRegions = React.useMemo(() => regions.filter((r) => r.status === ST.PENDING && !r.stale), [regions])
563
620
  const staleRegions = React.useMemo(() => regions.filter((r) => r.status === ST.PENDING && r.stale), [regions])
@@ -575,7 +632,7 @@ export function EditorView(props) {
575
632
  return map
576
633
  }, [pendingRegions])
577
634
  lineRegionMapRef.current = lineRegionMap
578
- const sum = React.useMemo(() => summarize(Object.values(records)), [records])
635
+ const sum = React.useMemo(() => diffSummary(Object.values(records), review.page), [records, review.page])
579
636
 
580
637
  /** @private @author ddj 2026年10月08号 @param path Disk path. @param select Select tab. @param preview Optional explicit Markdown mode. */
581
638
  function addTab(path, select, preview) {
@@ -2521,7 +2578,7 @@ export function EditorView(props) {
2521
2578
  if (!line || !map.size) { hoverEditorRef.current = false; hideSoon(); return }
2522
2579
  const hit = map.get(line)
2523
2580
  if (!hit) { hoverEditorRef.current = false; hideSoon(); return }
2524
- const key = callIdAttr(hit.callId, hit.idx)
2581
+ const key = hit.sourceKind === 'official' ? hit.recordId + ':' + hit.id : callIdAttr(hit.callId, hit.idx)
2525
2582
  const top = Math.max(0, ed.getTopForLineNumber(Math.max(1, hit.start)) - ed.getScrollTop())
2526
2583
  const layout = ed.getLayoutInfo()
2527
2584
  const minimapLeft = layout.minimap?.minimapLeft || 0
@@ -3005,6 +3062,7 @@ export function EditorView(props) {
3005
3062
  * @returns Promise<boolean> 是否成功
3006
3063
  */
3007
3064
  const actHunk = (region, reject, silent) => {
3065
+ if (region.sourceKind === 'official') return decideOfficial(reject, region.id)
3008
3066
  const method = reject ? 'edrv.reject' : 'edrv.accept'
3009
3067
  // #region debug log
3010
3068
  const trace = isDebug()
@@ -3105,7 +3163,38 @@ export function EditorView(props) {
3105
3163
  */
3106
3164
  const itemOf = (r, reject) => ({ callId: r.callId, scope: r.create ? 'call' : 'hunk', hunkIndex: r.idx, decision: reject ? 'rejected' : 'accepted' })
3107
3165
 
3166
+ /** @private @author ddj 2026年10月27号 @param reject Undo instead of Keep. @param blockId Optional contiguous block. @returns Persisted decision result; never reloads over a dirty buffer. */
3167
+ async function decideOfficial(reject, blockId) {
3168
+ if (!officialDetail || batchBusyRef.current) return false
3169
+ if (reject && (dirtyRef.current[active] || savingRef.current.has(active) || saveTimerRef.current.isPending())) {
3170
+ setError('存在未保存或正在保存的修改,请等待完成后重试'); return false
3171
+ }
3172
+ batchBusyRef.current = true
3173
+ const source = officialDetail.item
3174
+ const undoPath = active
3175
+ const undoModel = editorRef.current?.getModel?.()
3176
+ const undoVersion = undoModel?.getVersionId?.()
3177
+ const opKey = JSON.stringify([sessionId, source.id, source.seq, source.revision, blockId, reject])
3178
+ let operationId = reviewOpsRef.current.get(opKey)
3179
+ if (!operationId) { operationId = crypto.randomUUID(); reviewOpsRef.current.set(opKey, operationId) }
3180
+ try {
3181
+ const result = await rpc('edrv.review.decide', { sessionId, id: source.id, seq: source.seq,
3182
+ revision: source.revision, operationId, scope: blockId ? 'hunk' : 'file', blockId,
3183
+ decision: reject ? 'rejected' : 'accepted' })
3184
+ if (!result.ok) throw new Error(result.error)
3185
+ reviewOpsRef.current.delete(opKey)
3186
+ if (reject && activeRef.current === undoPath && !dirtyRef.current[undoPath]
3187
+ && !savingRef.current.has(undoPath) && undoModel?.getVersionId?.() === undoVersion) reloadFile(true)
3188
+ else if (reject) setError('Undo 已完成,编辑器在等待期间变化;未覆盖当前缓冲区,请核对磁盘后刷新')
3189
+ refreshReview(sessionId)
3190
+ setStatus(reject ? '已 Undo 选中差异' : '已 Keep 选中差异')
3191
+ return true
3192
+ } catch (cause) { setError(String(cause)); return false }
3193
+ finally { batchBusyRef.current = false }
3194
+ }
3195
+
3108
3196
  const acceptFile = () => {
3197
+ if (officialDetail) return decideOfficial(false)
3109
3198
  if (batchBusyRef.current || !decideRegions.length) return
3110
3199
  batchBusyRef.current = true
3111
3200
  actMany(decideRegions.map((r) => itemOf(r, false))).then((result) => {
@@ -3117,6 +3206,7 @@ export function EditorView(props) {
3117
3206
  }).catch(() => { batchBusyRef.current = false })
3118
3207
  }
3119
3208
  const undoFile = () => {
3209
+ if (officialDetail) return decideOfficial(true)
3120
3210
  if (batchBusyRef.current || !decideRegions.length) return
3121
3211
  batchBusyRef.current = true
3122
3212
  actMany([...decideRegions].reverse().map((r) => itemOf(r, true))).then((result) => {
@@ -3132,7 +3222,7 @@ export function EditorView(props) {
3132
3222
  const allPending = React.useMemo(() => {
3133
3223
  const out = []
3134
3224
  for (const rec of Object.values(records)) {
3135
- if (rec.superseded === true) continue
3225
+ if (rec.superseded === true || officialPath(rec.path, review.page)) continue
3136
3226
  const perHunk = Array.isArray(rec.decisions?.perHunk) ? rec.decisions.perHunk : []
3137
3227
  const hunks = Array.isArray(rec.hunks) ? rec.hunks : []
3138
3228
  for (let i = 0; i < hunks.length; i++) {
@@ -3141,7 +3231,7 @@ export function EditorView(props) {
3141
3231
  }
3142
3232
  }
3143
3233
  return out
3144
- }, [records])
3234
+ }, [records, review.page])
3145
3235
 
3146
3236
  /**
3147
3237
  * 批量处理所有差异文件:采纳全部 / 不采纳全部。不采纳按 at 降序(新改先回滚)排序后一次 RPC。
@@ -3153,7 +3243,7 @@ export function EditorView(props) {
3153
3243
  batchBusyRef.current = true
3154
3244
  const list = reject ? [...allPending].sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : b.idx - a.idx)) : allPending
3155
3245
  const paths = [...new Set(list.map((r) => records[r.callId]?.path).filter(Boolean))]
3156
- actMany(list.map((r) => itemOf(r, reject))).then((result) => {
3246
+ return actMany(list.map((r) => itemOf(r, reject))).then((result) => {
3157
3247
  batchBusyRef.current = false
3158
3248
  reloadFile(true)
3159
3249
  emitRefresh({ scope, paths: paths.length ? paths : undefined, kind: 'structure' })
@@ -3161,8 +3251,58 @@ export function EditorView(props) {
3161
3251
  if (result.fail) setError(result.fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
3162
3252
  }).catch(() => { batchBusyRef.current = false })
3163
3253
  }
3164
- const acceptAllFiles = () => actAllPending(false)
3165
- const undoAllFiles = () => actAllPending(true)
3254
+ /** @private @author ddj 2026年10月27号 @param reject Undo requires every local buffer to be saved. @param token Starting session lifetime. @returns Throws before the next mutation when unsafe. */
3255
+ function guardReviewAll(reject, token) {
3256
+ if (!token?.live) throw new Error('编辑器或会话已切换,批量操作已停止')
3257
+ if (reject && (Object.values(dirtyRef.current).some(Boolean) || savingRef.current.size || saveTimerRef.current.isPending())) {
3258
+ throw new Error('存在未保存或正在保存的文件,未执行下一项 Undo,请保存后重试')
3259
+ }
3260
+ }
3261
+
3262
+ /** @private @author ddj 2026年10月27号 @param detail Frozen official revision. @param reject Undo action. @returns Stable retry ID shared with single-file decisions. */
3263
+ function batchOperation(detail, reject) {
3264
+ const source = detail.item
3265
+ const key = JSON.stringify([sessionId, source.id, source.seq, source.revision, undefined, reject])
3266
+ let operationId = reviewOpsRef.current.get(key)
3267
+ if (!operationId) { operationId = crypto.randomUUID(); reviewOpsRef.current.set(key, operationId) }
3268
+ return operationId
3269
+ }
3270
+
3271
+ /** @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. */
3272
+ async function actReviewAll(reject) {
3273
+ if (batchBusyRef.current) return
3274
+ if (!review.page?.overview) { setError('官方待审列表尚未就绪,未执行批量操作'); return }
3275
+ const count = sum.pendingFiles.length
3276
+ if (!count) return
3277
+ if (!window.confirm(`${reject ? 'Undo All' : 'Keep All'}:处理当前工作区全部 ${count} 个待审差异文件(含其他轮次)?${reject ? '\n撤销会改写磁盘;逐项安全校验,遇到冲突停止,已完成项不会自动恢复。' : '\n只标记待审修改为保留,不改写文件。'}`)) return
3278
+ const token = reviewLifeRef.current
3279
+ const undoPath = activeRef.current
3280
+ const undoModel = editorRef.current?.getModel?.()
3281
+ const undoVersion = undoModel?.getVersionId?.()
3282
+ batchBusyRef.current = true; setReviewBatchBusy(true)
3283
+ setStatus(reject ? '正在检查并 Undo 全部待审记录…' : '正在 Keep 全部待审记录…')
3284
+ try {
3285
+ const result = await runReviewBatch({
3286
+ list: async (cursor) => { const res = await rpc('edrv.review.list', { sessionId, cursor }); if (!res.ok) throw new Error(res.error); return res.page },
3287
+ detail: async (id) => { const res = await rpc('edrv.review.detail', { sessionId, id }); if (!res.ok) throw new Error(res.error); return res.detail },
3288
+ 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' }),
3289
+ guard: () => guardReviewAll(reject, token), operation: batchOperation,
3290
+ }, reject)
3291
+ if (!token?.live) return
3292
+ refreshReview(sessionId)
3293
+ if (reject && result.paths.some((path) => sameFile(path, undoPath)) && activeRef.current === undoPath
3294
+ && !dirtyRef.current[undoPath] && !savingRef.current.has(undoPath) && undoModel?.getVersionId?.() === undoVersion) reloadFile(true)
3295
+ if (result.paths.length) emitRefresh({ scope, paths: result.paths, kind: 'structure' })
3296
+ setStatus(`${reject ? 'Undo All' : 'Keep All'}:官方记录已完成 ${result.completed} / ${result.total}`)
3297
+ if (result.error) { setError(`批量已停止,已完成 ${result.completed} 项,其余未处理。${result.error}`); return }
3298
+ guardReviewAll(reject, token)
3299
+ // Official-owned paths are excluded from allPending; never run legacy writes for official history.
3300
+ if (allPending.length) { batchBusyRef.current = false; await actAllPending(reject) }
3301
+ } catch (cause) { if (token?.live) setError(String(cause)) }
3302
+ finally { batchBusyRef.current = false; if (token?.live) setReviewBatchBusy(false) }
3303
+ }
3304
+ const acceptAllFiles = () => actReviewAll(false)
3305
+ const undoAllFiles = () => actReviewAll(true)
3166
3306
  const rollbackFile = () => {
3167
3307
  if (!active) return
3168
3308
  if (!window.confirm('回滚当前文件到修改前状态?(相关差异将归档)')) return
@@ -4310,7 +4450,10 @@ export function EditorView(props) {
4310
4450
  },
4311
4451
  },
4312
4452
  React.createElement('button', { className: 'edrv-pill edrv-pill-keep', onClick: () => { dismissHover(); actHunk(hoverAct.region, false) } }, '✓ Keep'),
4313
- React.createElement('button', { className: 'edrv-pill edrv-pill-undo', onClick: () => { dismissHover(); actHunk(hoverAct.region, true) } }, '↩ Undo'))
4453
+ React.createElement('button', { className: 'edrv-pill edrv-pill-undo',
4454
+ disabled: hoverAct.region.sourceKind === 'official' && !officialDetail?.capability.undo,
4455
+ title: hoverAct.region.sourceKind === 'official' ? officialDetail?.capability.reason : undefined,
4456
+ onClick: () => { dismissHover(); actHunk(hoverAct.region, true) } }, '↩ Undo'))
4314
4457
  : null
4315
4458
 
4316
4459
  /**
@@ -4318,8 +4461,13 @@ export function EditorView(props) {
4318
4461
  * @author ddj 2026年09月29号
4319
4462
  * @returns 含布局及操作回调的差异快照
4320
4463
  */
4464
+ const officialFile = review.page?.overview?.coveredPaths.find((path) => sameFile(path, active))
4321
4465
  const buildDockSnapshot = () => ({
4322
4466
  mode: editorDockMode(active),
4467
+ official: officialFile ? { sessionId, page: review.page, path: officialFile,
4468
+ pending: sum.files.find((file) => sameFile(file.path, active))?.pending ?? 0,
4469
+ detail: officialDetail, records: officialRecords, onSelect: setSelectedReview } : null,
4470
+ reviewError: review.error || error,
4323
4471
  layout,
4324
4472
  pendingRegions,
4325
4473
  staleRegions,
@@ -4328,7 +4476,9 @@ export function EditorView(props) {
4328
4476
  onUndoFile: undoFile,
4329
4477
  onAcceptAllFiles: acceptAllFiles,
4330
4478
  onUndoAllFiles: undoAllFiles,
4331
- allPendingCount: allPending.length,
4479
+ allPendingCount: allPending.length + (review.page?.pending ?? 0),
4480
+ reviewBatchBusy,
4481
+ batchUnavailable: !review.page?.overview ? '官方待审列表尚未就绪' : undefined,
4332
4482
  onRollback: rollbackFile,
4333
4483
  onJump: jumpTo,
4334
4484
  otherFiles,
@@ -0,0 +1,112 @@
1
+ import React from 'react'
2
+ import { Button, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
3
+ import type { ReviewDetail, ReviewItem, ReviewPage } from '../../shared/review.js'
4
+ import { ReviewLedger } from './OfficialReview.js'
5
+
6
+ export interface OfficialBarProps {
7
+ official: { sessionId: string; path: string; pending: number; detail?: ReviewDetail; records?: ReviewItem[]; page: ReviewPage; onSelect: (id: string) => void }
8
+ activePath: string
9
+ pendingRegions?: unknown[]
10
+ staleRegions?: unknown[]
11
+ diffIdx?: number
12
+ fileTotal?: number
13
+ fileIdx?: number
14
+ allPendingCount?: number
15
+ reviewBatchBusy?: boolean
16
+ batchUnavailable?: string
17
+ onAcceptAllFiles?: () => void
18
+ onUndoAllFiles?: () => void
19
+ onPrevFile: () => void
20
+ onNextFile: () => void
21
+ onPrevDiff: () => void
22
+ onNextDiff: () => void
23
+ onAcceptFile: () => void
24
+ onUndoFile: () => void
25
+ onOpenLauncher: (tab: string) => void
26
+ reviewError?: string
27
+ }
28
+
29
+ type Mark = 'left' | 'right' | 'up' | 'down' | 'check' | 'undo' | 'more' | 'file'
30
+ const marks: Record<Mark, string> = {
31
+ left: 'm10 4-5 4 5 4', right: 'm6 4 5 4-5 4', up: 'm4 10 4-5 4 5', down: 'm4 6 4 5 4-5',
32
+ check: 'm3 8 3 3 7-7', undo: 'M5 3 2 6l3 3M2 6h7a4 4 0 0 1 0 8',
33
+ more: 'M3 8h.01M8 8h.01M13 8h.01', file: 'M9 2H4v12h8V5L9 2Zm0 0v4h3M6 9h4M6 11h3',
34
+ }
35
+
36
+ /** @private @author ddj 2026年10月27号 @param mark Toolbar action. @returns Decorative consistent-stroke icon; labels live on buttons. */
37
+ function barIcon(mark: Mark): React.ReactElement {
38
+ return <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={marks[mark]} /></svg>
39
+ }
40
+
41
+ /** @private @author ddj 2026年10月27号 @param props Global workspace counts and bulk handlers. @returns Persistent global review controls, distinct from current-file actions. */
42
+ function ReviewAllRow(props: OfficialBarProps): React.ReactElement {
43
+ const total = props.fileTotal ?? 0
44
+ const current = total && (props.fileIdx ?? -1) >= 0 ? (props.fileIdx ?? 0) + 1 : 0
45
+ const disabled = props.reviewBatchBusy || !!props.batchUnavailable || !props.allPendingCount
46
+ return <div className="edrv-review-global" role="group" aria-label="全部待审文件">
47
+ <Button size="sm" variant="ghost" className="edrv-review-total" onClick={() => props.onOpenLauncher('pending')} title="打开当前工作区全部待审差异文件">差异文件 <strong>{current} / {total}</strong></Button>
48
+ <span className="edrv-review-all-actions">
49
+ <Button size="sm" variant="ghost" onClick={props.onAcceptAllFiles} disabled={disabled || !props.onAcceptAllFiles} title={props.batchUnavailable || 'Keep 当前工作区全部待审文件(含其他轮次),不改写磁盘'}>Keep All</Button>
50
+ <Button size="sm" variant="ghost" className="edrv-review-undo" onClick={props.onUndoAllFiles} disabled={disabled || !props.onUndoAllFiles} title={props.batchUnavailable || '检查全部待审记录后逐项 Undo;缺少证据或遇到冲突停止,不强制覆盖'}>Undo All</Button>
51
+ </span>
52
+ </div>
53
+ }
54
+
55
+ /** @public @author ddj 2026年10月27号 @param props Existing review callbacks and immutable record metadata. @returns Responsive review toolbar; no disk or decision side effects until an explicit action. */
56
+ export function OfficialDiffBar(props: OfficialBarProps): React.ReactElement {
57
+ const { official, pendingRegions = [], staleRegions = [], diffIdx = 0 } = props
58
+ const [expanded, setExpanded] = React.useState(false)
59
+ const [menu, setMenu] = React.useState<'records' | 'more' | null>(null)
60
+ const name = String(props.activePath || '').split(/[\\/]/).pop() || '当前文件'
61
+ const detail = official.detail
62
+ const records = official.records ?? []
63
+ const count = pendingRegions.length
64
+ const position = count ? `${Math.min(diffIdx + 1, count)} / ${count}` : '—'
65
+
66
+ /** @private @author ddj 2026年10月27号 @returns Close current popup without changing selection. */
67
+ function closeMenu(): void { setMenu(null) }
68
+ /** @private @author ddj 2026年10月27号 @param id Immutable record ID. @returns Select record and dismiss popup. */
69
+ function selectRecord(id: string): void { official.onSelect(id); closeMenu() }
70
+ /** @private @author ddj 2026年10月27号 @param tab Existing launcher tab. @returns Open existing overview, never create another review entry. */
71
+ function openOverview(tab: string): void { props.onOpenLauncher(tab); closeMenu() }
72
+ /** @private @author ddj 2026年10月27号 @param mark Navigation icon. @param label Accessible action. @param action Existing callback. @param disabled Whether navigation is unavailable. @returns Compact keyboard-focusable navigation control. */
73
+ function navButton(mark: Mark, label: string, action: () => void, disabled: boolean): React.ReactElement {
74
+ return <Button size="sm" variant="ghost" className="edrv-review-icon" aria-label={label} title={label} disabled={disabled} onClick={action}>{barIcon(mark)}</Button>
75
+ }
76
+
77
+ const recordTrigger = <Button size="sm" variant="ghost" className="edrv-review-record" aria-label="选择官方差异记录" aria-haspopup="menu" aria-expanded={menu === 'records'} disabled={!records.length} onClick={() => setMenu(menu === 'records' ? null : 'records')}>
78
+ {detail ? `轮次 ${detail.item.turn}` : '选择记录'}{barIcon('down')}
79
+ </Button>
80
+ const moreTrigger = <Button size="sm" variant="ghost" className="edrv-review-icon" aria-label="更多审查操作" title="更多审查操作" aria-haspopup="menu" aria-expanded={menu === 'more'} onClick={() => setMenu(menu === 'more' ? null : 'more')}>{barIcon('more')}</Button>
81
+
82
+ return <div className="edrv-diffbar edrv-diffbar-dock edrv-reviewbar" data-edrv-diffbox="1" data-edrv-diff-source="official" role="region" aria-label="文件差异审查">
83
+ <ReviewAllRow {...props} />
84
+ {props.reviewBatchBusy ? <div className="edrv-review-notice" role="status">正在处理全部待审记录…</div> : null}
85
+ <div className="edrv-review-heading">
86
+ <span className="edrv-review-filemark">{barIcon('file')}</span>
87
+ <span className="edrv-review-filename" title={props.activePath}>{name}</span>
88
+ <span className="edrv-review-pending" title={`${official.pending} 条官方待审记录`}>待审 <strong>{official.pending}</strong></span>
89
+ <Menu open={menu === 'records'} anchor={recordTrigger} items={records.map((item) => ({ id: item.id, label: `轮次 ${item.turn} / 事件 ${item.seq}` }))} selectedId={detail?.item.id} onSelect={selectRecord} onClose={closeMenu} side="top" align="end" portal compact autoFocus />
90
+ </div>
91
+ <div className="edrv-review-tools">
92
+ <div className="edrv-review-nav" role="group" aria-label="文件导航">
93
+ {navButton('left', '上一个差异文件', props.onPrevFile, !props.fileTotal)}
94
+ {navButton('right', '下一个差异文件', props.onNextFile, !props.fileTotal)}
95
+ </div>
96
+ <div className="edrv-review-nav edrv-review-blocknav" role="group" aria-label="差异导航">
97
+ {navButton('up', '上一个差异', props.onPrevDiff, !count)}
98
+ <span className="edrv-review-position" aria-live="polite">{position}</span>
99
+ {navButton('down', '下一个差异', props.onNextDiff, !count)}
100
+ </div>
101
+ <div className="edrv-review-actions">
102
+ <Button size="sm" variant="ghost" className="edrv-review-undo" icon={barIcon('undo')} onClick={props.onUndoFile} disabled={props.reviewBatchBusy || !detail?.capability.undo} title={detail?.capability.reason}>Undo</Button>
103
+ <Button size="sm" variant="primary" className="edrv-review-keep" icon={barIcon('check')} onClick={props.onAcceptFile} disabled={props.reviewBatchBusy || !detail?.capability.keep} title="保留当前记录的未处理修改">Keep</Button>
104
+ <Button size="sm" variant="ghost" className="edrv-review-details" onClick={() => setExpanded(!expanded)} aria-expanded={expanded} aria-label={expanded ? '收起差异' : '展开差异'} title={expanded ? '收起差异' : '展开差异'}>详情{barIcon(expanded ? 'up' : 'down')}</Button>
105
+ <Menu open={menu === 'more'} anchor={moreTrigger} items={[{ id: 'pending', label: '差异总览' }, { id: 'archive', label: '归档 / 历史' }]} onSelect={openOverview} onClose={closeMenu} side="top" align="end" portal compact autoFocus />
106
+ </div>
107
+ </div>
108
+ {detail && staleRegions.length > 0 ? <div className="edrv-review-notice" role="status">{staleRegions.length} 处历史差异无法定位,展开详情查看原始增删。</div> : null}
109
+ {props.reviewError ? <div className="edrv-review-notice edrv-review-error" role="alert">{props.reviewError}</div> : null}
110
+ {expanded ? <div className="edrv-review-body"><ReviewLedger key={`${official.sessionId}:${official.path}`} sessionId={official.sessionId} first={official.page} path={official.path} /></div> : null}
111
+ </div>
112
+ }
@@ -0,0 +1,93 @@
1
+ /** Official immutable patch ledger; current files use the existing editor. @author ddj 2026年10月26号 */
2
+ import React from 'react'
3
+ import { rpc } from '../rpc.js'
4
+ import { openEditorView } from '../events.js'
5
+ import { refreshReview } from '../state/reviewFeed.js'
6
+ import type { ReviewDetail, ReviewItem, ReviewPage } from '../../shared/review.js'
7
+ const h = React.createElement
8
+ /** @private @author ddj 2026年10月26号 @param props Latest first-page metadata. @returns Paginated rows independent of expanded historical patch. */
9
+ export function ReviewLedger(props: { sessionId: string; first: ReviewPage | null; history?: boolean; path?: string }) {
10
+ const [page, setPage] = React.useState<ReviewPage | null>(props.first)
11
+ const [history, setHistory] = React.useState(props.history ?? false)
12
+ const [cursor, setCursor] = React.useState(0)
13
+ const [detail, setDetail] = React.useState<ReviewDetail | null>(null)
14
+ const [error, setError] = React.useState('')
15
+ const selected = React.useRef<AbortController | null>(null)
16
+ React.useEffect(() => () => selected.current?.abort(), [])
17
+ React.useEffect(() => {
18
+ const controller = new AbortController()
19
+ if (!history && !props.path && cursor === 0) { setPage(props.first); return () => controller.abort() }
20
+ void rpc('edrv.review.list', { sessionId: props.sessionId, history, cursor, path: props.path }, controller.signal).then((result) => {
21
+ if (controller.signal.aborted) return
22
+ if (result.ok) { setPage(result.page); setError('') }
23
+ else setError(result.error)
24
+ }).catch((cause) => { if (!controller.signal.aborted) setError(String(cause)) })
25
+ return () => controller.abort()
26
+ }, [props.sessionId, props.first, props.path, cursor, history])
27
+
28
+ /** @private @author ddj 2026年10月26号 @param item Frozen version identity; patch is never built from disk. */
29
+ async function select(item: ReviewItem): Promise<void> {
30
+ selected.current?.abort()
31
+ const controller = new AbortController()
32
+ selected.current = controller
33
+ try {
34
+ const result = await rpc('edrv.review.detail', { sessionId: props.sessionId, id: item.id }, controller.signal)
35
+ if (controller.signal.aborted) return
36
+ if (result.ok) { setDetail(result.detail); setError('') }
37
+ else setError(result.error)
38
+ } catch (cause) { if (!controller.signal.aborted) setError(String(cause)) }
39
+ }
40
+ return h('div', { style: { marginTop: 8 } },
41
+ h('label', null, h('input', { type: 'checkbox', checked: history, onChange: () => { setHistory(!history); setCursor(0) } }), '显示已处理及被替换的历史'),
42
+ page?.gaps.map((gap) => h('div', { key: `${gap.sessionId}:${gap.seq}`, role: 'status' }, `轮次 ${gap.turn} 采集不完整:${gap.error}`)),
43
+ error ? h('div', { role: 'alert' }, error) : null,
44
+ !page ? h('p', { role: 'status' }, '正在读取官方审查记录…') : null,
45
+ page && !page.items.length ? h('p', null, history ? '暂无已落盘历史。' : '没有待处理的官方记录;可切换查看历史。') : null,
46
+ h('div', { style: { maxHeight: 210, overflow: 'auto' } }, page?.items.map((item) => h('button', {
47
+ key: item.id, type: 'button', onClick: () => { void select(item) },
48
+ style: { display: 'block', width: '100%', textAlign: 'left', padding: '6px 4px' },
49
+ }, `轮次 ${item.turn} · ${item.display} +${item.added} −${item.deleted} · ${item.decision}${item.current ? '' : '(已被同轮新版本替换)'}`))),
50
+ cursor > 0 ? h('button', { type: 'button', onClick: () => setCursor(Math.max(0, cursor - 50)) }, '上一页') : null,
51
+ page?.nextCursor !== null && page?.nextCursor !== undefined ? h('button', { type: 'button', onClick: () => setCursor(page.nextCursor!) }, '下一页') : null,
52
+ detail ? h(PatchDetail, { key: detail.item.id, sessionId: props.sessionId, detail, onChanged: () => { setDetail(null); refreshReview(props.sessionId) } }) : null)
53
+ }
54
+
55
+ /** @private @author ddj 2026年10月26号 @param props Immutable patch and current review revision. @returns Read-only source with explicit capability gates. */
56
+ function PatchDetail(props: { sessionId: string; detail: ReviewDetail; onChanged(): void }) {
57
+ const [error, setError] = React.useState('')
58
+ const [busy, setBusy] = React.useState(false)
59
+ const [operationId] = React.useState(() => crypto.randomUUID())
60
+ const lifetime = React.useRef<AbortController | null>(null)
61
+ React.useEffect(() => {
62
+ lifetime.current = new AbortController()
63
+ return () => lifetime.current?.abort()
64
+ }, [])
65
+ const { item, diff, capability } = props.detail
66
+ /** @private @author ddj 2026年10月26号 Marks reviewed only; never writes disk. Stable operation id survives retries while mounted. */
67
+ async function keep(): Promise<void> {
68
+ setBusy(true)
69
+ try {
70
+ const result = await rpc('edrv.review.decide', { sessionId: props.sessionId, id: item.id, seq: item.seq, revision: item.revision,
71
+ operationId, decision: 'accepted' }, lifetime.current?.signal)
72
+ if (lifetime.current?.signal.aborted) return
73
+ if (result.ok) props.onChanged()
74
+ else setError(result.error)
75
+ } catch (cause) { if (!lifetime.current?.signal.aborted) setError(String(cause)) }
76
+ finally { if (!lifetime.current?.signal.aborted) setBusy(false) }
77
+ }
78
+ return h('article', { style: { marginTop: 10, borderTop: '1px solid var(--border-color, #d9dee7)', paddingTop: 8 } },
79
+ h('strong', null, `${item.display} · 轮次 ${item.turn} / 事件 ${item.seq}`),
80
+ h('div', null, `来源会话 ${item.sessionId} · 采集于 ${props.detail.capturedAt}`),
81
+ h('p', null, capability.reason),
82
+ diff.kind === 'text' ? h('div', null,
83
+ h('div', null, `${diff.before ? '原文件存在' : '原文件不存在'} / ${diff.after ? '结果文件存在' : '结果文件不存在'}${diff.coarse ? ' · 粗粒度比较' : ''}`),
84
+ h('pre', { style: { overflow: 'auto', maxHeight: 400, fontSize: 11, lineHeight: 1.6 }, 'aria-label': '官方历史差异(只读)' },
85
+ diff.hunks.map((chunk, index) => h('div', { key: index },
86
+ h('div', null, `@@ -${chunk.oldStart},${chunk.oldLines} +${chunk.newStart},${chunk.newLines} @@`),
87
+ chunk.lines.map((line, position) => h('div', { key: position, style: { background: line.startsWith('+') ? 'light-dark(#e5f5e8, #173522)' : line.startsWith('-') ? 'light-dark(#fce9e9, #452323)' : undefined } }, line)))))
88
+ ) : h('p', null, diff.kind === 'binary' ? '二进制文件:官方不提供文本差异。' : '文件超过官方大小限制:没有差异内容。'),
89
+ error ? h('div', { role: 'alert' }, error) : null,
90
+ h('button', { type: 'button', disabled: busy || !capability.keep, onClick: () => { void keep() } }, '标记已审(不改文件)'),
91
+ h('button', { type: 'button', disabled: true, title: capability.reason }, '拒绝写回不可用'),
92
+ h('button', { type: 'button', onClick: () => openEditorView(item.currentPath) }, '编辑当前文件'))
93
+ }
@@ -7,6 +7,28 @@
7
7
  * 作者 ddj 2026-09-10
8
8
  */
9
9
  declare module '@deepseek-ai/dsh-client-ui-primitives' {
10
+ /** Compact theme-backed button; mirrors official Button.d.ts. @author ddj 2026年10月27号 */
11
+ export const Button: import('react').ComponentType<import('react').ButtonHTMLAttributes<HTMLButtonElement> & {
12
+ variant?: 'primary' | 'ghost' | 'outline' | 'toolbar'
13
+ size?: 'md' | 'sm'
14
+ icon?: import('react').ReactNode
15
+ }>
16
+
17
+ /** Anchored selectable menu; mirrors the subset used from official Menu.d.ts. @author ddj 2026年10月27号 */
18
+ export const Menu: import('react').ComponentType<{
19
+ open: boolean
20
+ anchor: import('react').ReactNode
21
+ items: readonly { id: string; label: import('react').ReactNode; disabled?: boolean }[]
22
+ selectedId?: string
23
+ onSelect: (id: string) => void
24
+ onClose: () => void
25
+ side?: 'top' | 'bottom' | 'right'
26
+ align?: 'start' | 'end'
27
+ portal?: boolean
28
+ compact?: boolean
29
+ autoFocus?: boolean
30
+ }>
31
+
10
32
  /** 图标组件通用 props(官方 Icon* 组件与 FileTypeIcon 共用形状)。 */
11
33
  export interface EdrvIconProps {
12
34
  /** 图标边长(px);省略时用图标自带尺寸。 */
package/src/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  /** dsh-vscode-mode Host composition. @author ddj 2026年09月28号 */
2
2
  import { registerRoutes } from './routes.js'
3
3
  import { captureToolResult } from './capture.js'
4
+ import { installCapture } from './officialCapture.js'
5
+ import { installEvidence } from './reviewCapture.js'
4
6
  import { closeArchiveDbs } from './archiveDb.js'
5
7
  import { handleRpc, integrationBaseUrlOf } from './rpc.js'
6
8
  import { newSearcher } from './search/orchestrator.js'
@@ -67,6 +69,8 @@ function createRuntime(ctx: Ctx, config: unknown) {
67
69
  * @param runtime Managers created by this instance.
68
70
  */
69
71
  function attachRuntime(ctx: Ctx, runtime: Runtime): void {
72
+ const evidence = installEvidence(ctx)
73
+ const stopCapture = installCapture(ctx, (cwd, snapshot) => { evidence.persist(cwd, snapshot); return true })
70
74
  /** @author ddj 2026年09月28号 @param exec Tool execution. @param result Tool result. */
71
75
  function onResult(exec: unknown, result: unknown): void {
72
76
  void captureToolResult(ctx, runtime.registry, exec, result)
@@ -84,6 +88,8 @@ function attachRuntime(ctx: Ctx, runtime: Runtime): void {
84
88
  }
85
89
  /** @author ddj 2026年09月28号 Release every manager even if another cleanup rejects. */
86
90
  async function cleanup(): Promise<void> {
91
+ await stopCapture()
92
+ evidence.dispose()
87
93
  runtime.fileVersions.dispose()
88
94
  closeArchiveDbs()
89
95
  disposeAllServers()
package/src/model.ts CHANGED
@@ -277,11 +277,15 @@ export function fileMaxBatch(records: Map<string, DiffRecord>, path: string): nu
277
277
  return m
278
278
  }
279
279
 
280
- /** 记录桶裁剪:超出 MAX_RECORDS 时剔除最旧的。 */
280
+ /** @public @author ddj 2026年10月26号 @param map Legacy records. @param max Cache target, never a pending retention limit. */
281
281
  export function prune(map: Map<string, DiffRecord>, max = MAX_RECORDS): void {
282
282
  if (map.size <= max) return
283
- const sorted = [...map.values()].sort((a, b) => (a.at < b.at ? -1 : 1))
284
- for (let i = 0; i < sorted.length - max; i++) map.delete(sorted[i].callId)
283
+ const sorted = [...map.values()].filter((record) => record.archived && recordResolved(record))
284
+ .sort((a, b) => (a.at < b.at ? -1 : 1))
285
+ for (const record of sorted) {
286
+ if (map.size <= max) break
287
+ map.delete(record.callId)
288
+ }
285
289
  }
286
290
 
287
291
  /** 按批次分组记录。 */