dsh-vscode-mode 0.20.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
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": [
@@ -5,7 +5,7 @@ import { detectColorScheme, officialThemeOf, themeNameOf } from './theme.js'
5
5
  // #region 显示数据
6
6
  /** @private @author ddj 2026年10月09号 @param regions 待显示区域 @returns 内容敏感且无分隔符碰撞的展示键 */
7
7
  function renderKey(regions) {
8
- return JSON.stringify(regions.map((r) => [r.callId, r.idx, r.start, r.end, r.oldStart, r.create, r.oldLines, r.newLines]))
8
+ return JSON.stringify(regions.map((r) => [r.recordId ?? r.callId, r.id ?? r.idx, r.start, r.end, r.oldStart, r.create, r.oldLines, r.newLines]))
9
9
  }
10
10
 
11
11
  /** @private @author ddj 2026年10月09号 @param node 接收样式的节点 @param colors Monaco 颜色 @returns 设置插件局部变量;不覆盖官方令牌 */
@@ -48,7 +48,7 @@ function marginRow(line) {
48
48
  function zoneOf(region) {
49
49
  const domNode = document.createElement('div')
50
50
  domNode.className = 'edrv-del-zone'
51
- domNode.dataset.edrvHunk = String(region.callId) + ':' + region.idx
51
+ domNode.dataset.edrvHunk = String(region.recordId ?? region.callId) + ':' + (region.id ?? region.idx)
52
52
  const marginDomNode = document.createElement('div')
53
53
  marginDomNode.className = 'edrv-del-margin'
54
54
  const rows = []
@@ -0,0 +1,64 @@
1
+ /** Official bulk decisions use persisted metadata, never the visible history page. @author ddj 2026年10月27号 */
2
+ import type { ReviewDetail, ReviewItem, ReviewPage, ReviewResult } from '../shared/review.js'
3
+
4
+ export interface BatchPort {
5
+ list: (cursor?: number) => Promise<ReviewPage>
6
+ detail: (id: string) => Promise<ReviewDetail>
7
+ decide: (detail: ReviewDetail, reject: boolean, operationId: string) => Promise<ReviewResult<{ revision: number }>>
8
+ guard: () => void
9
+ operation: (detail: ReviewDetail, reject: boolean) => string
10
+ }
11
+ export interface BatchResult { completed: number; total: number; paths: string[]; error: string }
12
+
13
+ /** @public @author ddj 2026年10月27号 @param port Authenticated RPC adapter. @returns Frozen complete pending set; aborts before any decision if pagination shifts. @throws Incomplete or changing pages. */
14
+ export async function loadPending(port: Pick<BatchPort, 'list'>): Promise<ReviewItem[]> {
15
+ const items: ReviewItem[] = []
16
+ const seen = new Set<string>()
17
+ const cursors = new Set<number>()
18
+ let cursor: number | undefined
19
+ let expected: number | undefined
20
+ do {
21
+ const page = await port.list(cursor)
22
+ expected ??= page.pending
23
+ if (page.pending !== expected) throw new Error('待审记录在分页期间变化,请刷新后重试')
24
+ for (const item of page.items) {
25
+ if (seen.has(item.id) || !item.current || item.decision !== 'pending') throw new Error('待审分页发生重复或版本变化,请刷新后重试')
26
+ seen.add(item.id); items.push(item)
27
+ }
28
+ if (page.nextCursor === null) break
29
+ if (cursors.has(page.nextCursor) || page.items.length === 0) throw new Error('待审分页未完整返回,请刷新后重试')
30
+ cursor = page.nextCursor; cursors.add(cursor)
31
+ } while (true)
32
+ if (items.length !== expected) throw new Error('待审记录数量不完整,未执行批量操作')
33
+ return items
34
+ }
35
+
36
+ /** @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. */
37
+ export async function runReviewBatch(port: BatchPort, reject: boolean): Promise<BatchResult> {
38
+ const result: BatchResult = { completed: 0, total: 0, paths: [], error: '' }
39
+ try {
40
+ port.guard()
41
+ const items = await loadPending(port)
42
+ result.total = items.length
43
+ const details: ReviewDetail[] = []
44
+ // Preflight the entire frozen set before the first mutation. Disk CAS remains authoritative at execution.
45
+ for (const item of items) {
46
+ port.guard()
47
+ const detail = await port.detail(item.id)
48
+ if (!detail.item.current || detail.item.seq !== item.seq || detail.item.revision !== item.revision
49
+ || !detail.capability.keep) throw new Error(`${item.display}:审查版本变化,请刷新后重试`)
50
+ if (reject && !detail.capability.undo) throw new Error(`${item.display}:${detail.capability.reason}`)
51
+ details.push(detail)
52
+ }
53
+ // Reverse chronological snapshots allow a newer round's result to expose the previous round's endpoint.
54
+ details.sort((left, right) => right.capturedAt.localeCompare(left.capturedAt) || right.item.seq - left.item.seq)
55
+ for (const detail of details) {
56
+ port.guard()
57
+ const reply = await port.decide(detail, reject, port.operation(detail, reject))
58
+ if (!reply.ok) throw new Error(`${detail.item.display}:${reply.error}`)
59
+ result.completed++
60
+ result.paths.push(detail.item.currentPath)
61
+ }
62
+ } catch (cause) { result.error = cause instanceof Error ? cause.message : String(cause) }
63
+ return result
64
+ }
@@ -11,12 +11,13 @@ const feeds = new Map<string, Entry>()
11
11
  function notify(entry: Entry): void { for (const listener of entry.listeners) listener(entry.value) }
12
12
 
13
13
  /** @private @author ddj 2026年10月26号 @param sessionId Scope. @param entry Current generation. @returns First-page metadata update; no overlap or late publication. */
14
- async function refresh(sessionId: string, entry: Entry): Promise<void> {
14
+ async function refresh(sessionId: string, entry: Entry, force = false): Promise<void> {
15
15
  if (entry.controller.signal.aborted) return
16
16
  if (entry.loading) { entry.again = true; return }
17
17
  clearTimeout(entry.timer)
18
18
  entry.loading = true
19
19
  entry.again = false
20
+ const previous = entry.value
20
21
  try {
21
22
  const result = await rpc('edrv.review.list', { sessionId }, entry.controller.signal)
22
23
  if (!entry.controller.signal.aborted) entry.value = result.ok
@@ -26,8 +27,11 @@ async function refresh(sessionId: string, entry: Entry): Promise<void> {
26
27
  finally {
27
28
  entry.loading = false
28
29
  if (!entry.controller.signal.aborted) {
29
- notify(entry)
30
- if (entry.again) void refresh(sessionId, entry)
30
+ // Polling returns fresh JSON objects even when nothing changed. Preserve identity so
31
+ // editors do not tear down Monaco zones and reload file details every five seconds.
32
+ if (!force && JSON.stringify(previous) === JSON.stringify(entry.value)) entry.value = previous
33
+ else notify(entry)
34
+ if (entry.again) void refresh(sessionId, entry, true)
31
35
  else entry.timer = setTimeout(() => { void refresh(sessionId, entry) }, 5000)
32
36
  }
33
37
  }
@@ -56,5 +60,5 @@ export function subscribeReview(sessionId: string, listener: Listener): () => vo
56
60
  /** @public @author ddj 2026年10月26号 @param sessionId Refresh metadata after a decision; does not alter legacy or disk. */
57
61
  export function refreshReview(sessionId: string): void {
58
62
  const entry = feeds.get(sessionId)
59
- if (entry) { clearTimeout(entry.timer); void refresh(sessionId, entry) }
63
+ if (entry) { clearTimeout(entry.timer); void refresh(sessionId, entry, true) }
60
64
  }
@@ -271,6 +271,45 @@
271
271
  [data-edrv-view] .edrv-side-diff-dock .edrv-diffbar-body { max-height: min(180px, 25vh); max-width: 100%; }
272
272
  [data-edrv-view] .edrv-side-diff-dock .edrv-diff-details { max-height: min(260px, 35vh); overflow-y: auto; }
273
273
 
274
+ /* Official review: file context above, navigation/actions below. Theme-owned surfaces; no neon status outlines. @author ddj 2026年10月27号 */
275
+ [data-edrv-diff-dock-shell] .edrv-reviewbar {
276
+ width: 520px; max-width: 100%; min-width: 0; box-sizing: border-box;
277
+ color: var(--dsw-alias-label-primary, #26313d); font-family: var(--dsw-font-family, sans-serif);
278
+ font-size: 12px; line-height: 1.4; container: edrv-review / inline-size;
279
+ }
280
+ [data-edrv-diff-dock-shell] .edrv-review-global { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 4px; padding: 6px 8px; border-bottom: 1px solid var(--dsw-alias-border-l1, #e5e9ef); }
281
+ [data-edrv-diff-dock-shell] .edrv-review-all-actions { display: flex; align-items: center; gap: 4px; }
282
+ [data-edrv-diff-dock-shell] .edrv-review-total strong { font-variant-numeric: tabular-nums; }
283
+ [data-edrv-diff-dock-shell] .edrv-review-heading {
284
+ display: flex; align-items: center; gap: 8px; min-width: 0; padding: 10px 12px 8px;
285
+ border-bottom: 1px solid var(--dsw-alias-border-l1, #e5e9ef);
286
+ }
287
+ [data-edrv-diff-dock-shell] .edrv-review-filemark { display: inline-flex; flex: none; color: var(--dsw-alias-label-tertiary, #697586); }
288
+ [data-edrv-diff-dock-shell] .edrv-review-filename { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 600; }
289
+ [data-edrv-diff-dock-shell] .edrv-review-pending { flex: none; display: inline-flex; gap: 5px; align-items: center; color: var(--dsw-alias-label-secondary, #697586); font-size: 11px; white-space: nowrap; }
290
+ [data-edrv-diff-dock-shell] .edrv-review-pending strong { font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-primary, #26313d); }
291
+ [data-edrv-diff-dock-shell] .edrv-review-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 7px 8px; }
292
+ [data-edrv-diff-dock-shell] .edrv-review-nav { display: flex; align-items: center; flex: none; gap: 1px; }
293
+ [data-edrv-diff-dock-shell] .edrv-review-blocknav { border-left: 1px solid var(--dsw-alias-border-l1, #e5e9ef); padding-left: 6px; }
294
+ [data-edrv-diff-dock-shell] .edrv-review-position { min-width: 32px; text-align: center; font-size: 11px; font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-secondary, #697586); }
295
+ [data-edrv-diff-dock-shell] .edrv-review-actions { display: flex; align-items: center; flex: none; gap: 4px; margin-left: auto; }
296
+ [data-edrv-diff-dock-shell] .edrv-reviewbar button { height: 28px; min-height: 28px; padding: 0 8px; border-radius: 6px; font-family: inherit; font-size: 12px; font-weight: 500; white-space: nowrap; gap: 5px; }
297
+ [data-edrv-diff-dock-shell] .edrv-reviewbar button.edrv-review-icon { width: 28px; min-width: 28px; padding: 0; }
298
+ [data-edrv-diff-dock-shell] .edrv-reviewbar button.edrv-review-record { padding: 0 6px; font-size: 11px; color: var(--dsw-alias-label-secondary, #697586); }
299
+ [data-edrv-diff-dock-shell] .edrv-review-record svg { width: 12px; height: 12px; }
300
+ [data-edrv-diff-dock-shell] .edrv-reviewbar button.edrv-review-keep { background: var(--dsw-alias-brand-primary, #3b63d9); color: var(--dsw-alias-label-primary-inverted, #fff); border: 1px solid transparent; }
301
+ [data-edrv-diff-dock-shell] .edrv-reviewbar button.edrv-review-keep:hover:not(:disabled) { filter: brightness(.94); }
302
+ [data-edrv-diff-dock-shell] .edrv-reviewbar button.edrv-review-undo:hover:not(:disabled) { color: var(--dsw-alias-state-error-primary, #b64043); }
303
+ [data-edrv-diff-dock-shell] .edrv-reviewbar button:disabled { opacity: .4; cursor: not-allowed; }
304
+ [data-edrv-diff-dock-shell] .edrv-reviewbar button:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, #3b63d9); outline-offset: 2px; }
305
+ [data-edrv-diff-dock-shell] .edrv-review-notice { padding: 7px 12px; border-top: 1px solid var(--dsw-alias-border-l1, #e5e9ef); color: var(--dsw-alias-label-secondary, #697586); font-size: 11px; overflow-wrap: anywhere; }
306
+ [data-edrv-diff-dock-shell] .edrv-review-error { color: var(--dsw-alias-state-error-primary, #b64043); }
307
+ [data-edrv-diff-dock-shell] .edrv-review-body { border-top: 1px solid var(--dsw-alias-border-l1, #e5e9ef); max-height: min(240px, 30vh); overflow: auto; padding: 8px 12px; }
308
+ @container edrv-review (max-width: 420px) {
309
+ .edrv-reviewbar .edrv-review-heading { gap: 6px; padding-inline: 10px; }
310
+ .edrv-reviewbar .edrv-review-actions { justify-content: flex-end; flex: 1 0 100%; border-top: 1px solid var(--dsw-alias-border-l1, #e5e9ef); padding-top: 6px; }
311
+ }
312
+
274
313
  /* ===== 侧边栏(活动栏 + 面板区 + 文件树) ===== */
275
314
  [data-edrv-view] .edrv-editor-row { display: flex; flex: 1 1 auto; min-width: 0; min-height: 0; overflow: hidden; }
276
315
  [data-edrv-view] .edrv-main-col { box-sizing: border-box; min-width: 0; min-height: 0; overflow: hidden; }
@@ -8,7 +8,7 @@ import React from 'react'
8
8
  import { callIdAttr } from '../state/records.js'
9
9
  import { canDecideFile, diffDockText } from '../diffDock.js'
10
10
  import { badgeOf } from './shared.js'
11
- import { ReviewLedger } from './OfficialReview.js'
11
+ import { OfficialDiffBar } from './OfficialDiffBar.js'
12
12
 
13
13
  /**
14
14
  * 折叠控制使用任务栏同尺寸的 chevron 轮廓,避免依赖未声明的运行时图标包。
@@ -66,22 +66,7 @@ export function DiffBox(props) {
66
66
  }, diffDockText(fileTotal))))
67
67
  }
68
68
 
69
- if (props.official) {
70
- const official = props.official
71
- return React.createElement('div', { className: 'edrv-diffbar edrv-diffbar-dock', 'data-edrv-diffbox': '1', 'data-edrv-diff-source': 'official' },
72
- React.createElement('div', { className: 'edrv-diffbar-row' },
73
- React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', onClick: onPrevFile, disabled: !fileTotal }, '←'),
74
- React.createElement('span', null, base + ' · ' + official.pending + ' 条官方待审记录'),
75
- React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', onClick: onNextFile, disabled: !fileTotal }, '→'),
76
- React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', onClick: () => setExpanded(!expanded), 'aria-expanded': expanded }, expanded ? '收起差异' : '查看差异 / 标记已审'),
77
- React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', disabled: true, title: '官方仅提供差异块,缺少可验证全文,不能安全回滚。' }, 'Undo 不可用'),
78
- React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', onClick: () => onOpenLauncher('pending') }, '差异总览'),
79
- React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', onClick: () => onOpenLauncher('archive') }, '归档 / 历史')),
80
- props.reviewError ? React.createElement('div', { role: 'alert' }, props.reviewError) : null,
81
- expanded ? React.createElement('div', { className: 'edrv-diffbar-body' },
82
- React.createElement(ReviewLedger, { key: official.sessionId + ':' + official.path, sessionId: official.sessionId,
83
- first: official.page, path: official.path })) : null)
84
- }
69
+ if (props.official) return React.createElement(OfficialDiffBar, props)
85
70
 
86
71
  let bodyEl = null
87
72
  if (expanded) {
@@ -22,7 +22,9 @@ 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 } from '../state/reviewFeed.js'
25
+ import { subscribeReview, refreshReview } from '../state/reviewFeed.js'
26
+ import { runReviewBatch } from '../reviewBatch.js'
27
+ import { reviewRegions } from '../../shared/reviewRegions.js'
26
28
  import { diffSummary, officialPath } from '../state/diffSummary.js'
27
29
  import { emitFileChanged, emitRefresh } from '../events.js'
28
30
  import { langOf, loadMonaco, snippetLanguageOf } from '../monaco/loader.js'
@@ -442,6 +444,13 @@ export function EditorView(props) {
442
444
  const hoverEditorRef = React.useRef(false) // 鼠标是否仍在编辑器命中区
443
445
  const hoverPanelRef = React.useRef(false) // 鼠标是否已进入 Keep/Undo 浮层
444
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])
445
454
  const menuHandlersRef = React.useRef(null) // 右键菜单动作的最新闭包(Monaco addAction 空依赖回调读取)
446
455
  const svnMenuDisposersRef = React.useRef([]) // Monaco 右键 SVN 组当前 disposables(同步时先注销旧组)
447
456
  // 调试(DAP):store 订阅 tick 驱动重渲 + gutter/F9 切换闭包(ensureEditor 空依赖读取)
@@ -545,6 +554,44 @@ export function EditorView(props) {
545
554
  return list
546
555
  }, [records, active, cwd, review.page])
547
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])
594
+
548
595
  const contentReady = content !== null && contentPath === active
549
596
  // 当前 tab 是否处于图片预览模式(与 loadContent 的分派条件同源;SVG 文本模式时为 false)
550
597
  const isImageActive = !!active && isImagePath(active) && !svgTextRef.current.has(active)
@@ -558,14 +605,16 @@ export function EditorView(props) {
558
605
  const trace = isDebug() && contentReady && currentRecords.length > 0
559
606
  const start = trace ? performance.now() : 0
560
607
  // #endregion
561
- 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)
562
611
  // #region debug log
563
612
  if (trace) {
564
613
  try { dbg(sessionId, `[DEBUG diffRegions] records=${currentRecords.length} bytes=${content.length} regions=${next.length} ms=${(performance.now() - start).toFixed(1)}`) } catch (e) { /* 诊断不可影响渲染 */ }
565
614
  }
566
615
  // #endregion
567
616
  return next
568
- }, [currentRecords, content, contentPath, active])
617
+ }, [currentRecords, content, contentPath, active, officialDetail])
569
618
  // useMemo 稳定引用:否则 hover 等重渲染会让 view zone effect 反复重建(- 号闪烁)
570
619
  const pendingRegions = React.useMemo(() => regions.filter((r) => r.status === ST.PENDING && !r.stale), [regions])
571
620
  const staleRegions = React.useMemo(() => regions.filter((r) => r.status === ST.PENDING && r.stale), [regions])
@@ -2529,7 +2578,7 @@ export function EditorView(props) {
2529
2578
  if (!line || !map.size) { hoverEditorRef.current = false; hideSoon(); return }
2530
2579
  const hit = map.get(line)
2531
2580
  if (!hit) { hoverEditorRef.current = false; hideSoon(); return }
2532
- const key = callIdAttr(hit.callId, hit.idx)
2581
+ const key = hit.sourceKind === 'official' ? hit.recordId + ':' + hit.id : callIdAttr(hit.callId, hit.idx)
2533
2582
  const top = Math.max(0, ed.getTopForLineNumber(Math.max(1, hit.start)) - ed.getScrollTop())
2534
2583
  const layout = ed.getLayoutInfo()
2535
2584
  const minimapLeft = layout.minimap?.minimapLeft || 0
@@ -3013,6 +3062,7 @@ export function EditorView(props) {
3013
3062
  * @returns Promise<boolean> 是否成功
3014
3063
  */
3015
3064
  const actHunk = (region, reject, silent) => {
3065
+ if (region.sourceKind === 'official') return decideOfficial(reject, region.id)
3016
3066
  const method = reject ? 'edrv.reject' : 'edrv.accept'
3017
3067
  // #region debug log
3018
3068
  const trace = isDebug()
@@ -3113,7 +3163,38 @@ export function EditorView(props) {
3113
3163
  */
3114
3164
  const itemOf = (r, reject) => ({ callId: r.callId, scope: r.create ? 'call' : 'hunk', hunkIndex: r.idx, decision: reject ? 'rejected' : 'accepted' })
3115
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
+
3116
3196
  const acceptFile = () => {
3197
+ if (officialDetail) return decideOfficial(false)
3117
3198
  if (batchBusyRef.current || !decideRegions.length) return
3118
3199
  batchBusyRef.current = true
3119
3200
  actMany(decideRegions.map((r) => itemOf(r, false))).then((result) => {
@@ -3125,6 +3206,7 @@ export function EditorView(props) {
3125
3206
  }).catch(() => { batchBusyRef.current = false })
3126
3207
  }
3127
3208
  const undoFile = () => {
3209
+ if (officialDetail) return decideOfficial(true)
3128
3210
  if (batchBusyRef.current || !decideRegions.length) return
3129
3211
  batchBusyRef.current = true
3130
3212
  actMany([...decideRegions].reverse().map((r) => itemOf(r, true))).then((result) => {
@@ -3161,7 +3243,7 @@ export function EditorView(props) {
3161
3243
  batchBusyRef.current = true
3162
3244
  const list = reject ? [...allPending].sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : b.idx - a.idx)) : allPending
3163
3245
  const paths = [...new Set(list.map((r) => records[r.callId]?.path).filter(Boolean))]
3164
- actMany(list.map((r) => itemOf(r, reject))).then((result) => {
3246
+ return actMany(list.map((r) => itemOf(r, reject))).then((result) => {
3165
3247
  batchBusyRef.current = false
3166
3248
  reloadFile(true)
3167
3249
  emitRefresh({ scope, paths: paths.length ? paths : undefined, kind: 'structure' })
@@ -3169,8 +3251,58 @@ export function EditorView(props) {
3169
3251
  if (result.fail) setError(result.fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
3170
3252
  }).catch(() => { batchBusyRef.current = false })
3171
3253
  }
3172
- const acceptAllFiles = () => actAllPending(false)
3173
- 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)
3174
3306
  const rollbackFile = () => {
3175
3307
  if (!active) return
3176
3308
  if (!window.confirm('回滚当前文件到修改前状态?(相关差异将归档)')) return
@@ -4318,7 +4450,10 @@ export function EditorView(props) {
4318
4450
  },
4319
4451
  },
4320
4452
  React.createElement('button', { className: 'edrv-pill edrv-pill-keep', onClick: () => { dismissHover(); actHunk(hoverAct.region, false) } }, '✓ Keep'),
4321
- 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'))
4322
4457
  : null
4323
4458
 
4324
4459
  /**
@@ -4330,8 +4465,9 @@ export function EditorView(props) {
4330
4465
  const buildDockSnapshot = () => ({
4331
4466
  mode: editorDockMode(active),
4332
4467
  official: officialFile ? { sessionId, page: review.page, path: officialFile,
4333
- pending: sum.files.find((file) => sameFile(file.path, active))?.pending ?? 0 } : null,
4334
- reviewError: review.error,
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,
4335
4471
  layout,
4336
4472
  pendingRegions,
4337
4473
  staleRegions,
@@ -4340,7 +4476,9 @@ export function EditorView(props) {
4340
4476
  onUndoFile: undoFile,
4341
4477
  onAcceptAllFiles: acceptAllFiles,
4342
4478
  onUndoAllFiles: undoAllFiles,
4343
- allPendingCount: allPending.length,
4479
+ allPendingCount: allPending.length + (review.page?.pending ?? 0),
4480
+ reviewBatchBusy,
4481
+ batchUnavailable: !review.page?.overview ? '官方待审列表尚未就绪' : undefined,
4344
4482
  onRollback: rollbackFile,
4345
4483
  onJump: jumpTo,
4346
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
+ }
@@ -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);省略时用图标自带尺寸。 */