dsh-vscode-mode 0.19.2 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.19.2",
3
+ "version": "0.20.0",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + VS Code 兼容代码片段(.code-snippets 全局/项目,IntelliSense 展开)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ SVN 集成(侧栏变更面板/日志弹窗/并排差异)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车,界面状态(页签/光标/侧栏/编辑 Tab/导航历史)镜像到 DSH home 跨重启恢复",
5
5
  "icon": "assets/icon.svg",
6
6
  "keywords": [
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
@@ -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,60 @@
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): 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
+ try {
21
+ const result = await rpc('edrv.review.list', { sessionId }, entry.controller.signal)
22
+ if (!entry.controller.signal.aborted) entry.value = result.ok
23
+ ? { page: result.page, error: result.page.overview ? '' : 'Host 尚未提供官方文件导航,请重启加载新版插件;当前仅可浏览已返回的官方历史,角标不含官方记录。' }
24
+ : { ...entry.value, error: result.error }
25
+ } catch (error) { if (!entry.controller.signal.aborted) entry.value = { ...entry.value, error: String(error) } }
26
+ finally {
27
+ entry.loading = false
28
+ if (!entry.controller.signal.aborted) {
29
+ notify(entry)
30
+ if (entry.again) void refresh(sessionId, entry)
31
+ else entry.timer = setTimeout(() => { void refresh(sessionId, entry) }, 5000)
32
+ }
33
+ }
34
+ }
35
+
36
+ /** @public @author ddj 2026年10月26号 @param sessionId Live scope. @param listener Mounted observer. @returns Cleanup cancelling requests and timers when unused. */
37
+ export function subscribeReview(sessionId: string, listener: Listener): () => void {
38
+ let entry = feeds.get(sessionId)
39
+ if (!entry) {
40
+ /** @private @author ddj 2026年10月26号 Refresh the shared official metadata after edits/decisions. */
41
+ const onRefresh = (): void => refreshReview(sessionId)
42
+ entry = { listeners: new Set(), value: { page: null, error: '' }, controller: new AbortController(), loading: false, again: false, onRefresh }
43
+ feeds.set(sessionId, entry)
44
+ if (typeof window !== 'undefined') window.addEventListener('edrv:refresh', onRefresh)
45
+ void refresh(sessionId, entry)
46
+ }
47
+ entry.listeners.add(listener); listener(entry.value)
48
+ return () => {
49
+ entry.listeners.delete(listener)
50
+ if (entry.listeners.size) return
51
+ entry.controller.abort(); clearTimeout(entry.timer); feeds.delete(sessionId)
52
+ if (typeof window !== 'undefined') window.removeEventListener('edrv:refresh', entry.onRefresh)
53
+ }
54
+ }
55
+
56
+ /** @public @author ddj 2026年10月26号 @param sessionId Refresh metadata after a decision; does not alter legacy or disk. */
57
+ export function refreshReview(sessionId: string): void {
58
+ const entry = feeds.get(sessionId)
59
+ if (entry) { clearTimeout(entry.timer); void refresh(sessionId, entry) }
60
+ }
@@ -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 { ReviewLedger } from './OfficialReview.js'
11
12
 
12
13
  /**
13
14
  * 折叠控制使用任务栏同尺寸的 chevron 轮廓,避免依赖未声明的运行时图标包。
@@ -65,6 +66,23 @@ export function DiffBox(props) {
65
66
  }, diffDockText(fileTotal))))
66
67
  }
67
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
+ }
85
+
68
86
  let bodyEl = null
69
87
  if (expanded) {
70
88
  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) }, '✕ 关闭')),
@@ -22,6 +22,8 @@ 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'
26
+ import { diffSummary, officialPath } from '../state/diffSummary.js'
25
27
  import { emitFileChanged, emitRefresh } from '../events.js'
26
28
  import { langOf, loadMonaco, snippetLanguageOf } from '../monaco/loader.js'
27
29
  import { bindEditActions, runEditAction } from '../monaco/editActions.js'
@@ -256,6 +258,12 @@ export function EditorView(props) {
256
258
  const [monaco, setMonaco] = React.useState(null)
257
259
  const [monacoErr, setMonacoErr] = React.useState(null)
258
260
  const [records, setRecords] = React.useState({})
261
+ const [review, setReview] = React.useState({ page: null, error: '' })
262
+ React.useEffect(() => {
263
+ setReview({ page: null, error: '' })
264
+ if (!sessionId) return undefined
265
+ return subscribeReview(sessionId, setReview)
266
+ }, [sessionId])
259
267
  const [tabs, setTabs] = React.useState([])
260
268
  const [active, setDiskActive] = React.useState(null)
261
269
  const [planTabs, setPlanTabs] = React.useState([])
@@ -531,11 +539,11 @@ export function EditorView(props) {
531
539
  const currentRecords = React.useMemo(() => {
532
540
  const list = []
533
541
  for (const rec of Object.values(records)) {
534
- if (rec.archived === true) continue
542
+ if (rec.archived === true || officialPath(rec.path, review.page)) continue
535
543
  if (sameFile(rec.path, active)) list.push(rec)
536
544
  }
537
545
  return list
538
- }, [records, active, cwd])
546
+ }, [records, active, cwd, review.page])
539
547
 
540
548
  const contentReady = content !== null && contentPath === active
541
549
  // 当前 tab 是否处于图片预览模式(与 loadContent 的分派条件同源;SVG 文本模式时为 false)
@@ -575,7 +583,7 @@ export function EditorView(props) {
575
583
  return map
576
584
  }, [pendingRegions])
577
585
  lineRegionMapRef.current = lineRegionMap
578
- const sum = React.useMemo(() => summarize(Object.values(records)), [records])
586
+ const sum = React.useMemo(() => diffSummary(Object.values(records), review.page), [records, review.page])
579
587
 
580
588
  /** @private @author ddj 2026年10月08号 @param path Disk path. @param select Select tab. @param preview Optional explicit Markdown mode. */
581
589
  function addTab(path, select, preview) {
@@ -3132,7 +3140,7 @@ export function EditorView(props) {
3132
3140
  const allPending = React.useMemo(() => {
3133
3141
  const out = []
3134
3142
  for (const rec of Object.values(records)) {
3135
- if (rec.superseded === true) continue
3143
+ if (rec.superseded === true || officialPath(rec.path, review.page)) continue
3136
3144
  const perHunk = Array.isArray(rec.decisions?.perHunk) ? rec.decisions.perHunk : []
3137
3145
  const hunks = Array.isArray(rec.hunks) ? rec.hunks : []
3138
3146
  for (let i = 0; i < hunks.length; i++) {
@@ -3141,7 +3149,7 @@ export function EditorView(props) {
3141
3149
  }
3142
3150
  }
3143
3151
  return out
3144
- }, [records])
3152
+ }, [records, review.page])
3145
3153
 
3146
3154
  /**
3147
3155
  * 批量处理所有差异文件:采纳全部 / 不采纳全部。不采纳按 at 降序(新改先回滚)排序后一次 RPC。
@@ -4318,8 +4326,12 @@ export function EditorView(props) {
4318
4326
  * @author ddj 2026年09月29号
4319
4327
  * @returns 含布局及操作回调的差异快照
4320
4328
  */
4329
+ const officialFile = review.page?.overview?.coveredPaths.find((path) => sameFile(path, active))
4321
4330
  const buildDockSnapshot = () => ({
4322
4331
  mode: editorDockMode(active),
4332
+ 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,
4323
4335
  layout,
4324
4336
  pendingRegions,
4325
4337
  staleRegions,
@@ -0,0 +1,93 @@
1
+ /** Official immutable patch ledger; current files use the existing editor. @author ddj 2026年10月26号 */
2
+ import React from 'react'
3
+ import { rpc } from '../rpc.js'
4
+ import { openEditorView } from '../events.js'
5
+ import { refreshReview } from '../state/reviewFeed.js'
6
+ import type { ReviewDetail, ReviewItem, ReviewPage } from '../../shared/review.js'
7
+ const h = React.createElement
8
+ /** @private @author ddj 2026年10月26号 @param props Latest first-page metadata. @returns Paginated rows independent of expanded historical patch. */
9
+ export function ReviewLedger(props: { sessionId: string; first: ReviewPage | null; history?: boolean; path?: string }) {
10
+ const [page, setPage] = React.useState<ReviewPage | null>(props.first)
11
+ const [history, setHistory] = React.useState(props.history ?? false)
12
+ const [cursor, setCursor] = React.useState(0)
13
+ const [detail, setDetail] = React.useState<ReviewDetail | null>(null)
14
+ const [error, setError] = React.useState('')
15
+ const selected = React.useRef<AbortController | null>(null)
16
+ React.useEffect(() => () => selected.current?.abort(), [])
17
+ React.useEffect(() => {
18
+ const controller = new AbortController()
19
+ if (!history && !props.path && cursor === 0) { setPage(props.first); return () => controller.abort() }
20
+ void rpc('edrv.review.list', { sessionId: props.sessionId, history, cursor, path: props.path }, controller.signal).then((result) => {
21
+ if (controller.signal.aborted) return
22
+ if (result.ok) { setPage(result.page); setError('') }
23
+ else setError(result.error)
24
+ }).catch((cause) => { if (!controller.signal.aborted) setError(String(cause)) })
25
+ return () => controller.abort()
26
+ }, [props.sessionId, props.first, props.path, cursor, history])
27
+
28
+ /** @private @author ddj 2026年10月26号 @param item Frozen version identity; patch is never built from disk. */
29
+ async function select(item: ReviewItem): Promise<void> {
30
+ selected.current?.abort()
31
+ const controller = new AbortController()
32
+ selected.current = controller
33
+ try {
34
+ const result = await rpc('edrv.review.detail', { sessionId: props.sessionId, id: item.id }, controller.signal)
35
+ if (controller.signal.aborted) return
36
+ if (result.ok) { setDetail(result.detail); setError('') }
37
+ else setError(result.error)
38
+ } catch (cause) { if (!controller.signal.aborted) setError(String(cause)) }
39
+ }
40
+ return h('div', { style: { marginTop: 8 } },
41
+ h('label', null, h('input', { type: 'checkbox', checked: history, onChange: () => { setHistory(!history); setCursor(0) } }), '显示已处理及被替换的历史'),
42
+ page?.gaps.map((gap) => h('div', { key: `${gap.sessionId}:${gap.seq}`, role: 'status' }, `轮次 ${gap.turn} 采集不完整:${gap.error}`)),
43
+ error ? h('div', { role: 'alert' }, error) : null,
44
+ !page ? h('p', { role: 'status' }, '正在读取官方审查记录…') : null,
45
+ page && !page.items.length ? h('p', null, history ? '暂无已落盘历史。' : '没有待处理的官方记录;可切换查看历史。') : null,
46
+ h('div', { style: { maxHeight: 210, overflow: 'auto' } }, page?.items.map((item) => h('button', {
47
+ key: item.id, type: 'button', onClick: () => { void select(item) },
48
+ style: { display: 'block', width: '100%', textAlign: 'left', padding: '6px 4px' },
49
+ }, `轮次 ${item.turn} · ${item.display} +${item.added} −${item.deleted} · ${item.decision}${item.current ? '' : '(已被同轮新版本替换)'}`))),
50
+ cursor > 0 ? h('button', { type: 'button', onClick: () => setCursor(Math.max(0, cursor - 50)) }, '上一页') : null,
51
+ page?.nextCursor !== null && page?.nextCursor !== undefined ? h('button', { type: 'button', onClick: () => setCursor(page.nextCursor!) }, '下一页') : null,
52
+ detail ? h(PatchDetail, { key: detail.item.id, sessionId: props.sessionId, detail, onChanged: () => { setDetail(null); refreshReview(props.sessionId) } }) : null)
53
+ }
54
+
55
+ /** @private @author ddj 2026年10月26号 @param props Immutable patch and current review revision. @returns Read-only source with explicit capability gates. */
56
+ function PatchDetail(props: { sessionId: string; detail: ReviewDetail; onChanged(): void }) {
57
+ const [error, setError] = React.useState('')
58
+ const [busy, setBusy] = React.useState(false)
59
+ const [operationId] = React.useState(() => crypto.randomUUID())
60
+ const lifetime = React.useRef<AbortController | null>(null)
61
+ React.useEffect(() => {
62
+ lifetime.current = new AbortController()
63
+ return () => lifetime.current?.abort()
64
+ }, [])
65
+ const { item, diff, capability } = props.detail
66
+ /** @private @author ddj 2026年10月26号 Marks reviewed only; never writes disk. Stable operation id survives retries while mounted. */
67
+ async function keep(): Promise<void> {
68
+ setBusy(true)
69
+ try {
70
+ const result = await rpc('edrv.review.decide', { sessionId: props.sessionId, id: item.id, seq: item.seq, revision: item.revision,
71
+ operationId, decision: 'accepted' }, lifetime.current?.signal)
72
+ if (lifetime.current?.signal.aborted) return
73
+ if (result.ok) props.onChanged()
74
+ else setError(result.error)
75
+ } catch (cause) { if (!lifetime.current?.signal.aborted) setError(String(cause)) }
76
+ finally { if (!lifetime.current?.signal.aborted) setBusy(false) }
77
+ }
78
+ return h('article', { style: { marginTop: 10, borderTop: '1px solid var(--border-color, #d9dee7)', paddingTop: 8 } },
79
+ h('strong', null, `${item.display} · 轮次 ${item.turn} / 事件 ${item.seq}`),
80
+ h('div', null, `来源会话 ${item.sessionId} · 采集于 ${props.detail.capturedAt}`),
81
+ h('p', null, capability.reason),
82
+ diff.kind === 'text' ? h('div', null,
83
+ h('div', null, `${diff.before ? '原文件存在' : '原文件不存在'} / ${diff.after ? '结果文件存在' : '结果文件不存在'}${diff.coarse ? ' · 粗粒度比较' : ''}`),
84
+ h('pre', { style: { overflow: 'auto', maxHeight: 400, fontSize: 11, lineHeight: 1.6 }, 'aria-label': '官方历史差异(只读)' },
85
+ diff.hunks.map((chunk, index) => h('div', { key: index },
86
+ h('div', null, `@@ -${chunk.oldStart},${chunk.oldLines} +${chunk.newStart},${chunk.newLines} @@`),
87
+ chunk.lines.map((line, position) => h('div', { key: position, style: { background: line.startsWith('+') ? 'light-dark(#e5f5e8, #173522)' : line.startsWith('-') ? 'light-dark(#fce9e9, #452323)' : undefined } }, line)))))
88
+ ) : h('p', null, diff.kind === 'binary' ? '二进制文件:官方不提供文本差异。' : '文件超过官方大小限制:没有差异内容。'),
89
+ error ? h('div', { role: 'alert' }, error) : null,
90
+ h('button', { type: 'button', disabled: busy || !capability.keep, onClick: () => { void keep() } }, '标记已审(不改文件)'),
91
+ h('button', { type: 'button', disabled: true, title: capability.reason }, '拒绝写回不可用'),
92
+ h('button', { type: 'button', onClick: () => openEditorView(item.currentPath) }, '编辑当前文件'))
93
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /** dsh-vscode-mode Host composition. @author ddj 2026年09月28号 */
2
2
  import { registerRoutes } from './routes.js'
3
3
  import { captureToolResult } from './capture.js'
4
+ import { installCapture } from './officialCapture.js'
4
5
  import { closeArchiveDbs } from './archiveDb.js'
5
6
  import { handleRpc, integrationBaseUrlOf } from './rpc.js'
6
7
  import { newSearcher } from './search/orchestrator.js'
@@ -67,6 +68,7 @@ function createRuntime(ctx: Ctx, config: unknown) {
67
68
  * @param runtime Managers created by this instance.
68
69
  */
69
70
  function attachRuntime(ctx: Ctx, runtime: Runtime): void {
71
+ const stopCapture = installCapture(ctx)
70
72
  /** @author ddj 2026年09月28号 @param exec Tool execution. @param result Tool result. */
71
73
  function onResult(exec: unknown, result: unknown): void {
72
74
  void captureToolResult(ctx, runtime.registry, exec, result)
@@ -84,6 +86,7 @@ function attachRuntime(ctx: Ctx, runtime: Runtime): void {
84
86
  }
85
87
  /** @author ddj 2026年09月28号 Release every manager even if another cleanup rejects. */
86
88
  async function cleanup(): Promise<void> {
89
+ await stopCapture()
87
90
  runtime.fileVersions.dispose()
88
91
  closeArchiveDbs()
89
92
  disposeAllServers()
package/src/model.ts CHANGED
@@ -277,11 +277,15 @@ export function fileMaxBatch(records: Map<string, DiffRecord>, path: string): nu
277
277
  return m
278
278
  }
279
279
 
280
- /** 记录桶裁剪:超出 MAX_RECORDS 时剔除最旧的。 */
280
+ /** @public @author ddj 2026年10月26号 @param map Legacy records. @param max Cache target, never a pending retention limit. */
281
281
  export function prune(map: Map<string, DiffRecord>, max = MAX_RECORDS): void {
282
282
  if (map.size <= max) return
283
- const sorted = [...map.values()].sort((a, b) => (a.at < b.at ? -1 : 1))
284
- for (let i = 0; i < sorted.length - max; i++) map.delete(sorted[i].callId)
283
+ const sorted = [...map.values()].filter((record) => record.archived && recordResolved(record))
284
+ .sort((a, b) => (a.at < b.at ? -1 : 1))
285
+ for (const record of sorted) {
286
+ if (map.size <= max) break
287
+ map.delete(record.callId)
288
+ }
285
289
  }
286
290
 
287
291
  /** 按批次分组记录。 */