dsh-vscode-mode 0.20.0 → 0.20.2

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.2",
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": [
@@ -2,11 +2,16 @@
2
2
  import { rpc } from './rpc.js'
3
3
  import type { McpSessionStatus } from '../shared/mcp.js'
4
4
 
5
- export interface SessionMcpView { status?: McpSessionStatus; stale: boolean }
5
+ export interface SessionMcpView {
6
+ status?: McpSessionStatus
7
+ stale: boolean
8
+ /** Query lifecycle only; never a connection failure classification. */
9
+ reason?: 'pending' | 'paused' | 'unavailable'
10
+ /** Browser receipt time of the last accepted snapshot, in epoch milliseconds. */
11
+ observedAt?: number
12
+ }
6
13
  type Snapshot = { sessions: McpSessionStatus[]; sampledAt: number }
7
14
  type FetchStatus = (signal: AbortSignal) => Promise<Snapshot>
8
- const unavailable: SessionMcpView = { stale: true }
9
- const inactive: SessionMcpView = { stale: false }
10
15
 
11
16
  /** One poller for all mounted row/hover consumers; never retains or activates sessions. */
12
17
  export class McpSessionState {
@@ -15,14 +20,14 @@ export class McpSessionState {
15
20
  private timer?: ReturnType<typeof setTimeout>
16
21
  private request?: AbortController
17
22
  private epoch = 0
18
- private stale = true
23
+ private fallback: SessionMcpView = { stale: true, reason: 'pending' }
19
24
  private disposed = false
20
25
 
21
26
  /** @public @author ddj 2026年10月20号 @param fetchStatus Authenticated read-only query. @param page Visibility boundary, omitted for server tests. */
22
27
  constructor(private readonly fetchStatus: FetchStatus, private readonly page?: Document) {}
23
28
 
24
29
  /** @public @author ddj 2026年10月20号 @param sessionId Row identity. @returns Stable view until the next observation. */
25
- get(sessionId: string): SessionMcpView { return this.views.get(sessionId) ?? (this.stale ? unavailable : inactive) }
30
+ get(sessionId: string): SessionMcpView { return this.views.get(sessionId) ?? this.fallback }
26
31
 
27
32
  /** @public @author ddj 2026年10月20号 @param sessionId Row identity. @param listener React invalidation. @returns Idempotent unsubscribe; the last consumer stops polling. */
28
33
  subscribe(sessionId: string, listener: () => void): () => void {
@@ -33,7 +38,8 @@ export class McpSessionState {
33
38
  this.listeners.set(sessionId, listeners)
34
39
  if (first) {
35
40
  this.page?.addEventListener('visibilitychange', this.visibility)
36
- void this.poll()
41
+ if (this.page?.hidden) this.markStale('paused')
42
+ else void this.poll()
37
43
  }
38
44
  return () => {
39
45
  listeners.delete(listener)
@@ -54,7 +60,7 @@ export class McpSessionState {
54
60
  clearTimeout(this.timer)
55
61
  this.timer = undefined
56
62
  this.request?.abort()
57
- this.publish(undefined)
63
+ this.markStale('paused')
58
64
  }
59
65
 
60
66
  /** @private @author ddj 2026年10月20号 Cleans listener lifetime as well as timers. */
@@ -66,32 +72,53 @@ export class McpSessionState {
66
72
  /** @public @author ddj 2026年10月20号 Permanently disposes HMR-owned polling. */
67
73
  dispose(): void { this.disposed = true; this.pause(); this.listeners.clear(); this.views.clear() }
68
74
 
69
- /** @private @author ddj 2026年10月20号 @param snapshot Fresh ledger, or undefined to mark all previous observations stale. */
70
- private publish(snapshot?: Snapshot): void {
71
- this.stale = snapshot === undefined
72
- if (snapshot) this.views = new Map(snapshot.sessions.map((status) => [status.sessionId, { status, stale: false }]))
73
- else for (const [id, view] of this.views) this.views.set(id, { ...view, stale: true })
75
+ /** @private @author ddj 2026年10月27号 @param snapshot Fresh ledger. Records browser receipt time without changing the host protocol. */
76
+ private publish(snapshot: Snapshot): void {
77
+ const observedAt = Date.now()
78
+ this.fallback = { stale: false, observedAt }
79
+ this.views = new Map(snapshot.sessions.map((status) => [status.sessionId, { status, stale: false, observedAt }]))
80
+ this.notify()
81
+ }
82
+
83
+ /** @private @author ddj 2026年10月27号 @param reason Query lifecycle, never a claim about connection health. Preserves stable references for unchanged observations. */
84
+ private markStale(reason: NonNullable<SessionMcpView['reason']>): void {
85
+ if (this.fallback.stale && this.fallback.reason === reason) return
86
+ this.fallback = { ...this.fallback, stale: true, reason }
87
+ for (const [id, view] of this.views) this.views.set(id, { ...view, stale: true, reason })
88
+ this.notify()
89
+ }
90
+
91
+ /** @private @author ddj 2026年10月27号 Notifies mounted row and hover consumers after an observation changes. */
92
+ private notify(): void {
74
93
  for (const listeners of this.listeners.values()) for (const listener of listeners) listener()
75
94
  }
76
95
 
77
96
  /** @private @author ddj 2026年10月20号 Queries once, with a 10-second deadline and a 2.5-second gap after settlement. */
78
97
  private async poll(): Promise<void> {
79
98
  if (this.disposed || this.request || !this.listeners.size || this.page?.hidden) return
99
+ clearTimeout(this.timer)
100
+ this.timer = undefined
101
+ if (this.fallback.reason === 'paused') this.markStale('pending')
102
+
80
103
  const request = new AbortController()
81
104
  const epoch = this.epoch
82
105
  this.request = request
83
106
  /** @author ddj 2026年10月20号 Marks overdue responses unusable even if a transport ignores cancellation. */
84
- const expire = (): void => { request.abort(); if (epoch === this.epoch) this.publish(undefined) }
107
+ const expire = (): void => { request.abort(); if (epoch === this.epoch) this.markStale('unavailable') }
85
108
  const deadline = setTimeout(expire, 10000)
86
109
  try {
87
110
  const snapshot = await this.fetchStatus(request.signal)
88
111
  if (epoch === this.epoch && !request.signal.aborted) this.publish(snapshot)
89
112
  } catch {
90
- if (epoch === this.epoch) this.publish(undefined)
113
+ if (epoch === this.epoch) this.markStale('unavailable')
91
114
  } finally {
92
115
  clearTimeout(deadline)
93
116
  this.request = undefined
94
- if (!this.disposed && this.listeners.size && !this.page?.hidden) this.timer = setTimeout(() => { void this.poll() }, 2500)
117
+ // A visibility/subscription change resumes promptly, but only after the old transport settles.
118
+ if (!this.disposed && this.listeners.size && !this.page?.hidden) {
119
+ const delay = epoch === this.epoch ? 2500 : 0
120
+ this.timer = setTimeout(() => { void this.poll() }, delay)
121
+ }
95
122
  }
96
123
  }
97
124
  }
@@ -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,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 } from '../state/reviewFeed.js'
25
+ import { subscribeReview, refreshReview } from '../state/reviewFeed.js'
26
+ import { runReviewBatch } from '../reviewBatch.js'
27
+ import { ReviewAllConfirm } from './ReviewAllConfirm.js'
28
+ import { reviewRegions } from '../../shared/reviewRegions.js'
26
29
  import { diffSummary, officialPath } from '../state/diffSummary.js'
27
30
  import { emitFileChanged, emitRefresh } from '../events.js'
28
31
  import { langOf, loadMonaco, snippetLanguageOf } from '../monaco/loader.js'
@@ -442,6 +445,14 @@ export function EditorView(props) {
442
445
  const hoverEditorRef = React.useRef(false) // 鼠标是否仍在编辑器命中区
443
446
  const hoverPanelRef = React.useRef(false) // 鼠标是否已进入 Keep/Undo 浮层
444
447
  const batchBusyRef = React.useRef(false) // 批量 Keep All/Undo All 防重入
448
+ const [reviewBatchBusy, setReviewBatchBusy] = React.useState(false)
449
+ const [reviewAllPrompt, setReviewAllPrompt] = React.useState(null)
450
+ const reviewLifeRef = React.useRef(null)
451
+ React.useEffect(() => {
452
+ const token = { live: true }
453
+ reviewLifeRef.current = token
454
+ return () => { token.live = false }
455
+ }, [sessionId])
445
456
  const menuHandlersRef = React.useRef(null) // 右键菜单动作的最新闭包(Monaco addAction 空依赖回调读取)
446
457
  const svnMenuDisposersRef = React.useRef([]) // Monaco 右键 SVN 组当前 disposables(同步时先注销旧组)
447
458
  // 调试(DAP):store 订阅 tick 驱动重渲 + gutter/F9 切换闭包(ensureEditor 空依赖读取)
@@ -545,6 +556,44 @@ export function EditorView(props) {
545
556
  return list
546
557
  }, [records, active, cwd, review.page])
547
558
 
559
+ const reviewOpsRef = React.useRef(new Map())
560
+ const [officialDetail, setOfficialDetail] = React.useState(null)
561
+ const [officialRecords, setOfficialRecords] = React.useState([])
562
+ const [selectedReview, setSelectedReview] = React.useState(null)
563
+ // Only switching file/session/selection invalidates the visible source immediately.
564
+ // Background metadata refresh must not remove and recreate the current Monaco zones.
565
+ React.useEffect(() => {
566
+ setOfficialDetail(null)
567
+ setOfficialRecords([])
568
+ }, [sessionId, active, selectedReview])
569
+ React.useEffect(() => {
570
+ const controller = new AbortController()
571
+ if (!active || !review.page?.overview?.coveredPaths.some((path) => sameFile(path, active))) {
572
+ setOfficialDetail(null)
573
+ setOfficialRecords([])
574
+ return () => controller.abort()
575
+ }
576
+ /** @private @author ddj 2026年10月27号 Load the active file independently of the global history page. */
577
+ async function loadReview() {
578
+ try {
579
+ // Tabs use workspace-relative paths; the metadata index owns the backend identity.
580
+ const reviewPath = review.page?.overview?.coveredPaths.find((path) => sameFile(path, active)) ?? active
581
+ const page = await rpc('edrv.review.list', { sessionId, path: reviewPath }, controller.signal)
582
+ if (controller.signal.aborted) return
583
+ if (!page.ok) throw new Error(page.error)
584
+ const selected = page.page.items.find((item) => item.id === selectedReview) ?? page.page.items[0]
585
+ if (!selected) { setOfficialRecords([]); setOfficialDetail(null); return }
586
+ const result = await rpc('edrv.review.detail', { sessionId, id: selected.id }, controller.signal)
587
+ if (controller.signal.aborted) return
588
+ if (!result.ok) throw new Error(result.error)
589
+ setOfficialRecords((previous) => JSON.stringify(previous) === JSON.stringify(page.page.items) ? previous : page.page.items)
590
+ setOfficialDetail((previous) => JSON.stringify(previous) === JSON.stringify(result.detail) ? previous : result.detail)
591
+ } catch (cause) { if (!controller.signal.aborted) setError('官方差异读取失败:' + String(cause)) }
592
+ }
593
+ void loadReview()
594
+ return () => controller.abort()
595
+ }, [sessionId, active, review.page, selectedReview, sameFile])
596
+
548
597
  const contentReady = content !== null && contentPath === active
549
598
  // 当前 tab 是否处于图片预览模式(与 loadContent 的分派条件同源;SVG 文本模式时为 false)
550
599
  const isImageActive = !!active && isImagePath(active) && !svgTextRef.current.has(active)
@@ -558,14 +607,16 @@ export function EditorView(props) {
558
607
  const trace = isDebug() && contentReady && currentRecords.length > 0
559
608
  const start = trace ? performance.now() : 0
560
609
  // #endregion
561
- const next = diffRegions(currentRecords, contentReady ? content : null).filter((r) => !r.superseded)
610
+ const next = officialDetail
611
+ ? reviewRegions(officialDetail.diff, officialDetail.item.id, officialDetail.blocks ?? [], contentReady ? content : null)
612
+ : diffRegions(currentRecords, contentReady ? content : null).filter((r) => !r.superseded)
562
613
  // #region debug log
563
614
  if (trace) {
564
615
  try { dbg(sessionId, `[DEBUG diffRegions] records=${currentRecords.length} bytes=${content.length} regions=${next.length} ms=${(performance.now() - start).toFixed(1)}`) } catch (e) { /* 诊断不可影响渲染 */ }
565
616
  }
566
617
  // #endregion
567
618
  return next
568
- }, [currentRecords, content, contentPath, active])
619
+ }, [currentRecords, content, contentPath, active, officialDetail])
569
620
  // useMemo 稳定引用:否则 hover 等重渲染会让 view zone effect 反复重建(- 号闪烁)
570
621
  const pendingRegions = React.useMemo(() => regions.filter((r) => r.status === ST.PENDING && !r.stale), [regions])
571
622
  const staleRegions = React.useMemo(() => regions.filter((r) => r.status === ST.PENDING && r.stale), [regions])
@@ -2529,7 +2580,7 @@ export function EditorView(props) {
2529
2580
  if (!line || !map.size) { hoverEditorRef.current = false; hideSoon(); return }
2530
2581
  const hit = map.get(line)
2531
2582
  if (!hit) { hoverEditorRef.current = false; hideSoon(); return }
2532
- const key = callIdAttr(hit.callId, hit.idx)
2583
+ const key = hit.sourceKind === 'official' ? hit.recordId + ':' + hit.id : callIdAttr(hit.callId, hit.idx)
2533
2584
  const top = Math.max(0, ed.getTopForLineNumber(Math.max(1, hit.start)) - ed.getScrollTop())
2534
2585
  const layout = ed.getLayoutInfo()
2535
2586
  const minimapLeft = layout.minimap?.minimapLeft || 0
@@ -3013,6 +3064,7 @@ export function EditorView(props) {
3013
3064
  * @returns Promise<boolean> 是否成功
3014
3065
  */
3015
3066
  const actHunk = (region, reject, silent) => {
3067
+ if (region.sourceKind === 'official') return decideOfficial(reject, region.id)
3016
3068
  const method = reject ? 'edrv.reject' : 'edrv.accept'
3017
3069
  // #region debug log
3018
3070
  const trace = isDebug()
@@ -3113,7 +3165,38 @@ export function EditorView(props) {
3113
3165
  */
3114
3166
  const itemOf = (r, reject) => ({ callId: r.callId, scope: r.create ? 'call' : 'hunk', hunkIndex: r.idx, decision: reject ? 'rejected' : 'accepted' })
3115
3167
 
3168
+ /** @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. */
3169
+ async function decideOfficial(reject, blockId) {
3170
+ if (!officialDetail || batchBusyRef.current) return false
3171
+ if (reject && (dirtyRef.current[active] || savingRef.current.has(active) || saveTimerRef.current.isPending())) {
3172
+ setError('存在未保存或正在保存的修改,请等待完成后重试'); return false
3173
+ }
3174
+ batchBusyRef.current = true
3175
+ const source = officialDetail.item
3176
+ const undoPath = active
3177
+ const undoModel = editorRef.current?.getModel?.()
3178
+ const undoVersion = undoModel?.getVersionId?.()
3179
+ const opKey = JSON.stringify([sessionId, source.id, source.seq, source.revision, blockId, reject])
3180
+ let operationId = reviewOpsRef.current.get(opKey)
3181
+ if (!operationId) { operationId = crypto.randomUUID(); reviewOpsRef.current.set(opKey, operationId) }
3182
+ try {
3183
+ const result = await rpc('edrv.review.decide', { sessionId, id: source.id, seq: source.seq,
3184
+ revision: source.revision, operationId, scope: blockId ? 'hunk' : 'file', blockId,
3185
+ decision: reject ? 'rejected' : 'accepted' })
3186
+ if (!result.ok) throw new Error(result.error)
3187
+ reviewOpsRef.current.delete(opKey)
3188
+ if (reject && activeRef.current === undoPath && !dirtyRef.current[undoPath]
3189
+ && !savingRef.current.has(undoPath) && undoModel?.getVersionId?.() === undoVersion) reloadFile(true)
3190
+ else if (reject) setError('Undo 已完成,编辑器在等待期间变化;未覆盖当前缓冲区,请核对磁盘后刷新')
3191
+ refreshReview(sessionId)
3192
+ setStatus(reject ? '已 Undo 选中差异' : '已 Keep 选中差异')
3193
+ return true
3194
+ } catch (cause) { setError(String(cause)); return false }
3195
+ finally { batchBusyRef.current = false }
3196
+ }
3197
+
3116
3198
  const acceptFile = () => {
3199
+ if (officialDetail) return decideOfficial(false)
3117
3200
  if (batchBusyRef.current || !decideRegions.length) return
3118
3201
  batchBusyRef.current = true
3119
3202
  actMany(decideRegions.map((r) => itemOf(r, false))).then((result) => {
@@ -3125,6 +3208,7 @@ export function EditorView(props) {
3125
3208
  }).catch(() => { batchBusyRef.current = false })
3126
3209
  }
3127
3210
  const undoFile = () => {
3211
+ if (officialDetail) return decideOfficial(true)
3128
3212
  if (batchBusyRef.current || !decideRegions.length) return
3129
3213
  batchBusyRef.current = true
3130
3214
  actMany([...decideRegions].reverse().map((r) => itemOf(r, true))).then((result) => {
@@ -3161,7 +3245,7 @@ export function EditorView(props) {
3161
3245
  batchBusyRef.current = true
3162
3246
  const list = reject ? [...allPending].sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : b.idx - a.idx)) : allPending
3163
3247
  const paths = [...new Set(list.map((r) => records[r.callId]?.path).filter(Boolean))]
3164
- actMany(list.map((r) => itemOf(r, reject))).then((result) => {
3248
+ return actMany(list.map((r) => itemOf(r, reject))).then((result) => {
3165
3249
  batchBusyRef.current = false
3166
3250
  reloadFile(true)
3167
3251
  emitRefresh({ scope, paths: paths.length ? paths : undefined, kind: 'structure' })
@@ -3169,8 +3253,76 @@ export function EditorView(props) {
3169
3253
  if (result.fail) setError(result.fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
3170
3254
  }).catch(() => { batchBusyRef.current = false })
3171
3255
  }
3172
- const acceptAllFiles = () => actAllPending(false)
3173
- const undoAllFiles = () => actAllPending(true)
3256
+ /** @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. */
3257
+ function guardReviewAll(reject, token) {
3258
+ if (!token?.live) throw new Error('编辑器或会话已切换,批量操作已停止')
3259
+ if (reject && (Object.values(dirtyRef.current).some(Boolean) || savingRef.current.size || saveTimerRef.current.isPending())) {
3260
+ throw new Error('存在未保存或正在保存的文件,未执行下一项 Undo,请保存后重试')
3261
+ }
3262
+ }
3263
+
3264
+ /** @private @author ddj 2026年10月27号 @param detail Frozen official revision. @param reject Undo action. @returns Stable retry ID shared with single-file decisions. */
3265
+ function batchOperation(detail, reject) {
3266
+ const source = detail.item
3267
+ const key = JSON.stringify([sessionId, source.id, source.seq, source.revision, undefined, reject])
3268
+ let operationId = reviewOpsRef.current.get(key)
3269
+ if (!operationId) { operationId = crypto.randomUUID(); reviewOpsRef.current.set(key, operationId) }
3270
+ return operationId
3271
+ }
3272
+
3273
+ /** @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. */
3274
+ async function actReviewAll(reject) {
3275
+ if (batchBusyRef.current) return
3276
+ if (!review.page?.overview) { setError('官方待审列表尚未就绪,未执行批量操作'); return }
3277
+ if (!sum.pendingFiles.length) 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
+ /** @private @author ddj 2026年10月27号 @param reject Whether to undo pending files. @description Open a DSH confirmation without starting any mutations. */
3305
+ function askReviewAll(reject) {
3306
+ if (batchBusyRef.current || !sum.pendingFiles.length) return
3307
+ if (!review.page?.overview) { setError('官方待审列表尚未就绪,未执行批量操作'); return }
3308
+ setReviewAllPrompt({ reject, count: sum.pendingFiles.length, token: reviewLifeRef.current })
3309
+ }
3310
+
3311
+ /** @private @author ddj 2026年10月27号 @description Dismiss the pending confirmation without changing review state. */
3312
+ function cancelReviewAll() {
3313
+ setReviewAllPrompt(null)
3314
+ }
3315
+
3316
+ /** @private @author ddj 2026年10月27号 @description Confirm only within the session lifetime that opened the modal. @returns The guarded batch operation, if still valid. */
3317
+ function confirmReviewAll() {
3318
+ const prompt = reviewAllPrompt
3319
+ setReviewAllPrompt(null)
3320
+ if (!prompt?.token?.live || prompt.token !== reviewLifeRef.current) return
3321
+ return actReviewAll(prompt.reject)
3322
+ }
3323
+
3324
+ const acceptAllFiles = () => askReviewAll(false)
3325
+ const undoAllFiles = () => askReviewAll(true)
3174
3326
  const rollbackFile = () => {
3175
3327
  if (!active) return
3176
3328
  if (!window.confirm('回滚当前文件到修改前状态?(相关差异将归档)')) return
@@ -4318,7 +4470,10 @@ export function EditorView(props) {
4318
4470
  },
4319
4471
  },
4320
4472
  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'))
4473
+ React.createElement('button', { className: 'edrv-pill edrv-pill-undo',
4474
+ disabled: hoverAct.region.sourceKind === 'official' && !officialDetail?.capability.undo,
4475
+ title: hoverAct.region.sourceKind === 'official' ? officialDetail?.capability.reason : undefined,
4476
+ onClick: () => { dismissHover(); actHunk(hoverAct.region, true) } }, '↩ Undo'))
4322
4477
  : null
4323
4478
 
4324
4479
  /**
@@ -4330,8 +4485,9 @@ export function EditorView(props) {
4330
4485
  const buildDockSnapshot = () => ({
4331
4486
  mode: editorDockMode(active),
4332
4487
  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,
4488
+ pending: sum.files.find((file) => sameFile(file.path, active))?.pending ?? 0,
4489
+ detail: officialDetail, records: officialRecords, onSelect: setSelectedReview } : null,
4490
+ reviewError: review.error || error,
4335
4491
  layout,
4336
4492
  pendingRegions,
4337
4493
  staleRegions,
@@ -4340,7 +4496,9 @@ export function EditorView(props) {
4340
4496
  onUndoFile: undoFile,
4341
4497
  onAcceptAllFiles: acceptAllFiles,
4342
4498
  onUndoAllFiles: undoAllFiles,
4343
- allPendingCount: allPending.length,
4499
+ allPendingCount: allPending.length + (review.page?.pending ?? 0),
4500
+ reviewBatchBusy,
4501
+ batchUnavailable: !review.page?.overview ? '官方待审列表尚未就绪' : undefined,
4344
4502
  onRollback: rollbackFile,
4345
4503
  onJump: jumpTo,
4346
4504
  otherFiles,
@@ -4877,5 +5035,11 @@ export function EditorView(props) {
4877
5035
  snippetPickerEl,
4878
5036
  dapBpMenuEl,
4879
5037
  dapBpEditEl)
4880
- return rootEl
5038
+ const reviewAllEl = reviewAllPrompt?.token?.live && reviewAllPrompt.token === reviewLifeRef.current
5039
+ ? React.createElement(ReviewAllConfirm, {
5040
+ reject: reviewAllPrompt.reject, count: reviewAllPrompt.count,
5041
+ onCancel: cancelReviewAll, onConfirm: confirmReviewAll,
5042
+ })
5043
+ : null
5044
+ return React.createElement(React.Fragment, null, rootEl, reviewAllEl)
4881
5045
  }