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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.19.2",
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": [
package/src/archiveDb.ts CHANGED
@@ -33,7 +33,7 @@ type Row = Record<string, string | number | null>
33
33
  * @param cwd 工作区绝对路径
34
34
  * @returns 已缓存连接
35
35
  */
36
- function dbFor(cwd: string): DatabaseSync {
36
+ export function dbFor(cwd: string): DatabaseSync {
37
37
  const existing = connections.get(cwd)
38
38
  if (existing) return existing
39
39
  const file = archiveDbFile(cwd)
@@ -44,7 +44,7 @@ function dbFor(cwd: string): DatabaseSync {
44
44
  db.exec('CREATE TABLE IF NOT EXISTS metadata (name TEXT PRIMARY KEY, value TEXT NOT NULL)')
45
45
  const version = db.prepare("SELECT value FROM metadata WHERE name='schema'").get() as Row | undefined
46
46
  const workspace = db.prepare("SELECT value FROM metadata WHERE name='cwd'").get() as Row | undefined
47
- if ((version && version.value !== '2' && version.value !== '3') || (workspace && workspace.value !== cwd)) throw new Error('归档数据库 schema/工作区不匹配')
47
+ if ((version && version.value !== '2' && version.value !== '3' && version.value !== '4') || (workspace && workspace.value !== cwd)) throw new Error('归档数据库 schema/工作区不匹配')
48
48
  if (!!version !== !!workspace) throw new Error('归档数据库元数据不完整')
49
49
  if (!version) {
50
50
  const other = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name<>'metadata' LIMIT 1").get()
@@ -81,7 +81,7 @@ function dbFor(cwd: string): DatabaseSync {
81
81
  throw error
82
82
  }
83
83
  }
84
- if (version?.value === '3') {
84
+ if (version && (version.value === '3' || version.value === '4')) {
85
85
  const active = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='active_records'").get()
86
86
  const source = db.prepare("SELECT value FROM metadata WHERE name='active_source'").get()
87
87
  if (!active || !source) throw new Error('活跃记录数据库元数据不完整')
@@ -105,7 +105,7 @@ function dbFor(cwd: string): DatabaseSync {
105
105
  export async function migrateActive(cwd: string, source: string | null, confirm: () => Promise<string | null>): Promise<Map<string, DiffRecord>> {
106
106
  const db = dbFor(cwd)
107
107
  const version = String((db.prepare("SELECT value FROM metadata WHERE name='schema'").get() as Row).value)
108
- if (version === '3') return readActive(cwd)
108
+ if (version === '3' || version === '4') return readActive(cwd)
109
109
  const digest = source === null ? 'absent' : createHash('sha256').update(source).digest('hex')
110
110
  let recs: Record<string, DiffRecord> = {}
111
111
  if (source !== null) {
@@ -148,7 +148,7 @@ export async function migrateActive(cwd: string, source: string | null, confirm:
148
148
  * @returns 迁移完成返回 true
149
149
  */
150
150
  export function activeReady(cwd: string): boolean {
151
- return (dbFor(cwd).prepare("SELECT value FROM metadata WHERE name='schema'").get() as Row).value === '3'
151
+ return ['3', '4'].includes(String((dbFor(cwd).prepare("SELECT value FROM metadata WHERE name='schema'").get() as Row).value))
152
152
  }
153
153
 
154
154
  /**
@@ -173,7 +173,7 @@ export function checkActiveSource(cwd: string, source: string | null): void {
173
173
  */
174
174
  export function readActive(cwd: string): Map<string, DiffRecord> {
175
175
  const db = dbFor(cwd)
176
- if ((db.prepare("SELECT value FROM metadata WHERE name='schema'").get() as Row).value !== '3') throw new Error('工作区活跃记录未迁移')
176
+ if (!activeReady(cwd)) throw new Error('工作区活跃记录未迁移')
177
177
  const rows = db.prepare('SELECT payload FROM active_records ORDER BY rowid').all() as Row[]
178
178
  const recs = rows.map((row) => JSON.parse(String(row.payload)) as DiffRecord)
179
179
  return new Map(recs.map((rec) => [rec.callId, rec]))
@@ -189,7 +189,7 @@ export function readActive(cwd: string): Map<string, DiffRecord> {
189
189
  */
190
190
  export function commitActive(cwd: string, changes: ActiveChange, entries: ArchiveBatch[] = []): number {
191
191
  const db = dbFor(cwd)
192
- if ((db.prepare("SELECT value FROM metadata WHERE name='schema'").get() as Row).value !== '3') throw new Error('工作区活跃记录未迁移')
192
+ if (!activeReady(cwd)) throw new Error('工作区活跃记录未迁移')
193
193
  const upsert = db.prepare(`INSERT INTO active_records(call_id,payload,path,archived,pending,at) VALUES(?,?,?,?,?,?)
194
194
  ON CONFLICT(call_id) DO UPDATE SET payload=excluded.payload,path=excluded.path,
195
195
  archived=excluded.archived,pending=excluded.pending,at=excluded.at`)
@@ -218,7 +218,7 @@ export function commitActive(cwd: string, changes: ActiveChange, entries: Archiv
218
218
  */
219
219
  export function activeSummary(cwd: string): { active: number; pendingByFile: { path: string; pending: number }[] } {
220
220
  const db = dbFor(cwd)
221
- if ((db.prepare("SELECT value FROM metadata WHERE name='schema'").get() as Row).value !== '3') throw new Error('工作区活跃记录未迁移')
221
+ if (!activeReady(cwd)) throw new Error('工作区活跃记录未迁移')
222
222
  const active = Number((db.prepare('SELECT count(*) AS n FROM active_records WHERE archived=0').get() as Row).n)
223
223
  const rows = db.prepare('SELECT path, sum(pending) AS pending FROM active_records WHERE archived=0 AND pending>0 GROUP BY path ORDER BY pending DESC').all() as Row[]
224
224
  return { active, pendingByFile: rows.map((row) => ({ path: String(row.path), pending: Number(row.pending) })) }
package/src/capture.ts CHANGED
@@ -30,7 +30,7 @@ interface CaptureExec {
30
30
  /** 捕获所需的工具结果最小形状(tools/result 的结构子集)。 */
31
31
  interface CaptureResult {
32
32
  isError?: unknown
33
- value?: { path?: unknown; before?: unknown; after?: unknown } | null
33
+ value?: { path?: unknown; before?: unknown; after?: unknown; operation?: unknown } | null
34
34
  meta?: { diffs?: unknown }
35
35
  }
36
36
 
@@ -90,7 +90,7 @@ function buildLocalRecord(exec: CaptureExec, args: Record<string, unknown>, resu
90
90
  if (typeof path !== 'string' || !path) return null
91
91
  const before = value && typeof value.before === 'string' ? value.before : null
92
92
  const after = value && typeof value.after === 'string' ? value.after : (typeof args.content === 'string' ? args.content : null)
93
- const create = exec.name === 'write' && !!value && value.before === null
93
+ const create = exec.name === 'write' && value?.operation === 'create'
94
94
  const callHunk = exec.name === 'edit' && typeof args.old_string === 'string' && typeof args.new_string === 'string'
95
95
  ? { oldText: args.old_string, newText: args.new_string }
96
96
  : null
@@ -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
+ }
@@ -0,0 +1,30 @@
1
+ /** Source-aware metadata projection; official patches never become legacy hunks. @author ddj 2026年10月26号 */
2
+ import type { ReviewPage } from '../../shared/review.js'
3
+ import type { RecordView } from '../../shared/types.js'
4
+ import type { FileSummary, Summary } from './records.js'
5
+ import { summarize } from './records.js'
6
+
7
+ export interface DiffFile extends FileSummary { sourceKind: 'official' | 'legacy' }
8
+ export interface DiffSummary extends Summary { files: DiffFile[]; pendingFiles: DiffFile[] }
9
+
10
+ /** @public @author ddj 2026年10月26号 @param path Functional local or remote identity. @returns Comparison key without rewriting remote case. */
11
+ export function diffPathKey(path: string): string {
12
+ if (/^[a-z]+:\/\//i.test(path)) return path
13
+ const normalized = path.replace(/\\/g, '/')
14
+ return /^[a-z]:\//i.test(normalized) ? normalized.toLowerCase() : normalized
15
+ }
16
+
17
+ /** @public @author ddj 2026年10月26号 @param path Current editor file. @param page Authoritative metadata, including decided paths. @returns Whether legacy actions must be hidden for this file. */
18
+ export function officialPath(path: string, page: ReviewPage | null): boolean {
19
+ const key = diffPathKey(path)
20
+ return !!page?.overview?.coveredPaths.some((candidate) => diffPathKey(candidate) === key)
21
+ }
22
+
23
+ /** @public @author ddj 2026年10月26号 @param records Tool-call history. @param page Official file navigation, never only its first 50 rows. @returns Official-first file counts; legacy paths remain explicitly labelled. */
24
+ export function diffSummary(records: RecordView[], page: ReviewPage | null): DiffSummary {
25
+ const legacy = summarize(records.filter((record) => !officialPath(record.path, page)))
26
+ const files: DiffFile[] = (page?.overview?.files ?? []).map((file) => ({ ...file, recs: [] }))
27
+ for (const file of legacy.files) files.push({ ...file, sourceKind: 'legacy' })
28
+ const pendingFiles = files.filter((file) => file.pending > 0)
29
+ return { files, pendingFiles, totalFiles: pendingFiles.length }
30
+ }
@@ -0,0 +1,64 @@
1
+ /** One official metadata request loop per viewed session, lazy historical patches. @author ddj 2026年10月26号 */
2
+ import { rpc } from '../rpc.js'
3
+ import type { ReviewPage } from '../../shared/review.js'
4
+
5
+ export interface ReviewFeed { page: ReviewPage | null; error: string }
6
+ type Listener = (feed: ReviewFeed) => void
7
+ interface Entry { listeners: Set<Listener>; value: ReviewFeed; controller: AbortController; timer?: ReturnType<typeof setTimeout>; loading: boolean; again: boolean; onRefresh: () => void }
8
+ const feeds = new Map<string, Entry>()
9
+
10
+ /** @private @author ddj 2026年10月26号 @param entry Shared feed. Notify mounted observers only. */
11
+ function notify(entry: Entry): void { for (const listener of entry.listeners) listener(entry.value) }
12
+
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, force = false): Promise<void> {
15
+ if (entry.controller.signal.aborted) return
16
+ if (entry.loading) { entry.again = true; return }
17
+ clearTimeout(entry.timer)
18
+ entry.loading = true
19
+ entry.again = false
20
+ const previous = entry.value
21
+ try {
22
+ const result = await rpc('edrv.review.list', { sessionId }, entry.controller.signal)
23
+ if (!entry.controller.signal.aborted) entry.value = result.ok
24
+ ? { page: result.page, error: result.page.overview ? '' : 'Host 尚未提供官方文件导航,请重启加载新版插件;当前仅可浏览已返回的官方历史,角标不含官方记录。' }
25
+ : { ...entry.value, error: result.error }
26
+ } catch (error) { if (!entry.controller.signal.aborted) entry.value = { ...entry.value, error: String(error) } }
27
+ finally {
28
+ entry.loading = false
29
+ if (!entry.controller.signal.aborted) {
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)
35
+ else entry.timer = setTimeout(() => { void refresh(sessionId, entry) }, 5000)
36
+ }
37
+ }
38
+ }
39
+
40
+ /** @public @author ddj 2026年10月26号 @param sessionId Live scope. @param listener Mounted observer. @returns Cleanup cancelling requests and timers when unused. */
41
+ export function subscribeReview(sessionId: string, listener: Listener): () => void {
42
+ let entry = feeds.get(sessionId)
43
+ if (!entry) {
44
+ /** @private @author ddj 2026年10月26号 Refresh the shared official metadata after edits/decisions. */
45
+ const onRefresh = (): void => refreshReview(sessionId)
46
+ entry = { listeners: new Set(), value: { page: null, error: '' }, controller: new AbortController(), loading: false, again: false, onRefresh }
47
+ feeds.set(sessionId, entry)
48
+ if (typeof window !== 'undefined') window.addEventListener('edrv:refresh', onRefresh)
49
+ void refresh(sessionId, entry)
50
+ }
51
+ entry.listeners.add(listener); listener(entry.value)
52
+ return () => {
53
+ entry.listeners.delete(listener)
54
+ if (entry.listeners.size) return
55
+ entry.controller.abort(); clearTimeout(entry.timer); feeds.delete(sessionId)
56
+ if (typeof window !== 'undefined') window.removeEventListener('edrv:refresh', entry.onRefresh)
57
+ }
58
+ }
59
+
60
+ /** @public @author ddj 2026年10月26号 @param sessionId Refresh metadata after a decision; does not alter legacy or disk. */
61
+ export function refreshReview(sessionId: string): void {
62
+ const entry = feeds.get(sessionId)
63
+ if (entry) { clearTimeout(entry.timer); void refresh(sessionId, entry, true) }
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; }
@@ -7,7 +7,8 @@
7
7
  */
8
8
  import React from 'react'
9
9
  import { openDiffView } from '../events.js'
10
- import { summarize } from '../state/records.js'
10
+ import { diffSummary } from '../state/diffSummary.js'
11
+ import { subscribeReview } from '../state/reviewFeed.js'
11
12
  import { subscribeRecords } from '../state/recordsFeed.js'
12
13
  import { nextDiffPath } from '../diffDock.js'
13
14
  import { dockInLayout, readDiffDock, subscribeDiffDock } from '../diffDockStore.js'
@@ -33,9 +34,10 @@ export function ConversationDiffDock(props) {
33
34
  return undefined
34
35
  }
35
36
 
36
- return subscribeRecords(sessionId, (records) => {
37
- setSummary(summarize(records))
38
- })
37
+ let records = [], page = null
38
+ const offRecords = subscribeRecords(sessionId, (next) => { records = next; setSummary(diffSummary(records, page)) })
39
+ const offReview = subscribeReview(sessionId, (feed) => { page = feed.page; setSummary(diffSummary(records, page)) })
40
+ return () => { offRecords(); offReview() }
39
41
  }, [sessionId])
40
42
 
41
43
  React.useEffect(() => {
@@ -73,11 +75,8 @@ export function ConversationDiffDock(props) {
73
75
  }
74
76
 
75
77
  return React.createElement(React.Fragment, null,
78
+ palette,
76
79
  renderDock(React.createElement(DiffBox, {
77
- mode: 'chat',
78
- dock: true,
79
- fileTotal: paths.length,
80
- onOpenNextFile: click,
81
- }), sessionId || 'chat'),
82
- palette)
80
+ mode: 'chat', dock: true, fileTotal: paths.length, onOpenNextFile: click,
81
+ }), sessionId || 'chat'))
83
82
  }
@@ -7,7 +7,8 @@
7
7
  import React from 'react'
8
8
  import { appUrl } from '../../shared/appUrl.js'
9
9
  import { openEditorView, emitShowLauncher } from '../events.js'
10
- import { summarize } from '../state/records.js'
10
+ import { diffSummary } from '../state/diffSummary.js'
11
+ import { subscribeReview } from '../state/reviewFeed.js'
11
12
  import { subscribeRecords } from '../state/recordsFeed.js'
12
13
 
13
14
  /**
@@ -23,13 +24,14 @@ export function DiffBadge(props) {
23
24
  return undefined
24
25
  }
25
26
 
26
- return subscribeRecords(sessionId, (records) => {
27
- setSummary(summarize(records))
28
- })
27
+ let records = [], page = null
28
+ const offRecords = subscribeRecords(sessionId, (next) => { records = next; setSummary(diffSummary(records, page)) })
29
+ const offReview = subscribeReview(sessionId, (feed) => { page = feed.page; setSummary(diffSummary(records, page)) })
30
+ return () => { offRecords(); offReview() }
29
31
  }, [sessionId])
30
32
 
31
33
  const n = summary?.totalFiles ?? 0
32
- if (!n) return null
34
+ if (!sessionId) return null
33
35
 
34
36
  const click = () => {
35
37
  // 侧栏形态:带首个待处理文件路径(内容打开自动展开面板);旧形态行为不变
@@ -39,7 +41,7 @@ export function DiffBadge(props) {
39
41
 
40
42
  return React.createElement('button', {
41
43
  onClick: click,
42
- title: n + ' 个文件有差异,点击在编辑区查看',
44
+ title: '差异审查:' + n + ' 个待处理文件;点击查看总览与历史',
43
45
  style: { position: 'relative', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, padding: 0, background: 'transparent', border: 'none', borderRadius: 6, cursor: 'pointer' },
44
46
  },
45
47
  React.createElement('img', { src: appUrl('/edrv/assets/compare-select.png'), alt: '差异', style: { width: 22, height: 22, display: 'block' } }),
@@ -8,6 +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 { OfficialDiffBar } from './OfficialDiffBar.js'
11
12
 
12
13
  /**
13
14
  * 折叠控制使用任务栏同尺寸的 chevron 轮廓,避免依赖未声明的运行时图标包。
@@ -65,6 +66,8 @@ export function DiffBox(props) {
65
66
  }, diffDockText(fileTotal))))
66
67
  }
67
68
 
69
+ if (props.official) return React.createElement(OfficialDiffBar, props)
70
+
68
71
  let bodyEl = null
69
72
  if (expanded) {
70
73
  const rows = pendingRegions.map((r) => {
@@ -7,6 +7,8 @@
7
7
  import React from 'react'
8
8
  import { rpc } from '../rpc.js'
9
9
  import { MONO, btn } from './shared.js'
10
+ import { ReviewLedger } from './OfficialReview.js'
11
+ import { subscribeReview } from '../state/reviewFeed.js'
10
12
 
11
13
  /**
12
14
  * 文件与批次共同标识归档卡片,避免同文件多批次共用展开状态。
@@ -57,6 +59,12 @@ export function DiffLauncher(props) {
57
59
  const detailRef = React.useRef(0)
58
60
  const moreRef = React.useRef(false)
59
61
  const [error, setError] = React.useState(null)
62
+ const [source, setSource] = React.useState('official')
63
+ const [review, setReview] = React.useState({ page: null, error: '' })
64
+ React.useEffect(() => {
65
+ setReview({ page: null, error: '' })
66
+ return subscribeReview(sessionId, setReview)
67
+ }, [sessionId])
60
68
 
61
69
  /** @author ddj 2026年09月30号 @description 仅加载一个归档摘要页并丢弃失效响应。 */
62
70
  const loadArchives = (cursor = null) => {
@@ -92,16 +100,22 @@ export function DiffLauncher(props) {
92
100
  moreRef.current = false
93
101
  setMoreBusy(false)
94
102
  setArchiveDetail(null); setArchivePath(null); setArchiveErr(null)
95
- if (tab === 'archive') loadArchives()
103
+ if (tab === 'archive' && source === 'legacy') loadArchives()
96
104
  else { setArchives(null); setNextCursor(null) }
97
105
  return () => { requestRef.current++; detailRef.current++ }
98
- }, [tab, sessionId])
106
+ }, [tab, sessionId, source])
99
107
 
100
108
  let body
101
- if (tab === 'pending') {
102
- body = (sum.pendingFiles.length === 0
109
+ const legacyFiles = sum.pendingFiles.filter((file) => file.sourceKind !== 'official')
110
+ if (source === 'official') {
111
+ body = React.createElement(React.Fragment, null,
112
+ React.createElement('div', { className: 'edrv-search-empty' }, '官方轮次净差异 · 原始历史只读;编辑按钮打开当前文件,保存不代表已审。'),
113
+ review.error ? React.createElement('div', { role: 'alert' }, review.error) : null,
114
+ React.createElement(ReviewLedger, { key: sessionId + ':' + tab, sessionId, first: review.page, history: tab === 'archive' }))
115
+ } else if (tab === 'pending') {
116
+ body = (legacyFiles.length === 0
103
117
  ? React.createElement('div', { className: 'edrv-search-empty' }, '暂无待处理差异(已全部处理)')
104
- : sum.pendingFiles.map((f) => React.createElement('div', { key: f.path, className: 'edrv-launch-row', title: '打开并跳转到差异 ' + (f.label ?? f.path), onClick: () => onOpenFile(f.path) },
118
+ : legacyFiles.map((f) => React.createElement('div', { key: f.path, className: 'edrv-launch-row', title: '打开并跳转到差异 ' + (f.label ?? f.path), onClick: () => onOpenFile(f.path) },
105
119
  React.createElement('span', { className: 'edrv-launch-path' }, f.label ?? f.path),
106
120
  React.createElement('span', { className: 'edrv-launch-cnt' }, '待处理 ' + f.pending + ' 处'),
107
121
  React.createElement('button', { className: 'edrv-btn-mini edrv-btn-keep', onClick: (e) => { e.stopPropagation(); onOpenFile(f.path) } }, '打开'))))
@@ -178,6 +192,8 @@ export function DiffLauncher(props) {
178
192
  React.createElement('span', { style: { fontSize: 12, fontWeight: 600, color: 'var(--dsw-alias-label-primary,#eee)', marginRight: 4 } }, '差异'),
179
193
  React.createElement('button', { className: 'edrv-launch-tab' + (tab === 'pending' ? ' on' : ''), onClick: () => setTab('pending') }, '待处理' + (sum.totalFiles ? ' (' + sum.totalFiles + ')' : '')),
180
194
  React.createElement('button', { className: 'edrv-launch-tab' + (tab === 'archive' ? ' on' : ''), onClick: () => setTab('archive') }, '归档'),
195
+ React.createElement('button', { className: 'edrv-launch-tab' + (source === 'official' ? ' on' : ''), onClick: () => setSource('official') }, '官方净差异'),
196
+ React.createElement('button', { className: 'edrv-launch-tab' + (source === 'legacy' ? ' on' : ''), onClick: () => setSource('legacy') }, '旧工具记录'),
181
197
  React.createElement('span', { style: { flex: 1 } }),
182
198
  (error ? React.createElement('span', { style: { fontSize: 11, color: 'var(--dsw-alias-state-error-primary,#d9534f)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '40%' } }, '错误:' + String(error)) : null),
183
199
  React.createElement('button', { onClick: onClose, style: btn(false) }, '✕ 关闭')),