dsh-vscode-mode 0.14.2 → 0.14.4

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.
@@ -0,0 +1,79 @@
1
+ import type { MpcConfig } from '../shared/mcp.js'
2
+
3
+ export interface McpDraft {
4
+ serverName: string
5
+ transport: 'stdio' | 'streamable-http'
6
+ command: string
7
+ args: string
8
+ cwd: string
9
+ url: string
10
+ headers: string
11
+ }
12
+
13
+ /**
14
+ * @public
15
+ * @author ddj 2026年10月08号
16
+ * @description Parse lossless JSON string[] or one literal argument per nonempty line; no shell splitting.
17
+ * @param text Argument field.
18
+ * @returns Literal argument vector.
19
+ * @throws Invalid JSON or a non-string array element.
20
+ */
21
+ export function parseArgs(text: string): string[] {
22
+ if (text.trimStart().startsWith('[')) {
23
+ let value: unknown
24
+ try { value = JSON.parse(text) } catch { throw new Error('参数 JSON 无效,请输入 string[] 或每行一个参数') }
25
+ if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) {
26
+ throw new Error('参数 JSON 必须是 string[]')
27
+ }
28
+ return value
29
+ }
30
+ return text.split(/\r?\n/).filter((line) => line.length > 0)
31
+ }
32
+
33
+ /**
34
+ * @public
35
+ * @author ddj 2026年10月08号
36
+ * @description Validate HTTP headers; duplicates are case-insensitive and values retain embedded equals.
37
+ * @param text One key=value header per line.
38
+ * @returns Validated header dictionary.
39
+ * @throws Missing separator, invalid/empty key, duplicate key, or illegal control characters.
40
+ */
41
+ export function parsePairs(text: string): Record<string, string> {
42
+ const pairs: Array<[string, string]> = []
43
+ const keys = new Set<string>()
44
+ for (const [index, raw] of text.split(/\r?\n/).entries()) {
45
+ if (!raw.trim()) continue
46
+ const separator = raw.indexOf('=')
47
+ const key = raw.slice(0, separator).trim()
48
+ const value = raw.slice(separator + 1).trim()
49
+ if (separator < 1 || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(key) || /[\x00-\x08\x0a-\x1f\x7f]/.test(value)) {
50
+ throw new Error('请求头第 ' + (index + 1) + ' 行格式错误,应为有效 key=value')
51
+ }
52
+ const normalized = key.toLowerCase()
53
+ if (keys.has(normalized)) throw new Error('请求头重复:' + key)
54
+ keys.add(normalized)
55
+ pairs.push([key, value])
56
+ }
57
+ return Object.fromEntries(pairs)
58
+ }
59
+
60
+ /**
61
+ * @public
62
+ * @author ddj 2026年10月08号
63
+ * @description Convert a form draft without losing argument boundaries or silently accepting malformed headers.
64
+ * @param draft Current editable fields.
65
+ * @returns Host MCP configuration.
66
+ * @throws Argument/header validation errors; callers must keep the form open.
67
+ */
68
+ export function configOf(draft: McpDraft): MpcConfig {
69
+ const config: MpcConfig = { serverName: draft.serverName.trim(), transport: draft.transport }
70
+ if (draft.transport === 'stdio') {
71
+ config.command = draft.command.trim()
72
+ config.args = parseArgs(draft.args)
73
+ if (draft.cwd.trim()) config.cwd = draft.cwd.trim()
74
+ } else {
75
+ config.url = draft.url.trim()
76
+ config.headers = parsePairs(draft.headers)
77
+ }
78
+ return config
79
+ }
@@ -0,0 +1,179 @@
1
+ import type { MpcProject, MpcServer } from '../shared/mcp.js'
2
+ import type { rpc } from './rpc.js'
3
+
4
+ export interface McpView {
5
+ servers: MpcServer[]
6
+ projects: MpcProject[]
7
+ loading: boolean
8
+ busy: string
9
+ error: string
10
+ }
11
+ interface McpTiming { interval?: number; timeout?: number }
12
+ interface ReadToken { controller: AbortController; version: number; timer: ReturnType<typeof setTimeout> }
13
+ type Result = { ok: boolean; error?: string }
14
+
15
+ /** Owns MCP read lifetimes and one synchronous mutation lock for the entire management page. */
16
+ export class McpState {
17
+ readonly view: McpView = { servers: [], projects: [], loading: true, busy: '', error: '' }
18
+ private active = false
19
+ private visible = true
20
+ private disposed = false
21
+ private version = 0
22
+ private reading: ReadToken | null = null
23
+ private timer: ReturnType<typeof setTimeout> | null = null
24
+ private readonly interval: number
25
+ private readonly timeout: number
26
+
27
+ /** @public @author ddj 2026年10月08号 @param request Typed RPC transport. @param changed View notification. @param timing Bounded read and polling timings. */
28
+ constructor(private readonly request: typeof rpc, private readonly changed: (view: McpView) => void, timing: McpTiming = {}) {
29
+ this.interval = Math.max(100, timing.interval ?? 2500)
30
+ this.timeout = Math.max(50, timing.timeout ?? 10000)
31
+ }
32
+
33
+ //region Read lifecycle
34
+ /** @private @author ddj 2026年10月08号 @description Publish a copied view only while mounted. */
35
+ private emit(): void {
36
+ if (!this.disposed) this.changed({ ...this.view })
37
+ }
38
+
39
+ /** @private @author ddj 2026年10月08号 @description Cancel scheduled reads and invalidate every older response. */
40
+ private invalidate(): void {
41
+ this.version++
42
+ if (this.timer !== null) clearTimeout(this.timer)
43
+ this.timer = null
44
+ this.reading?.controller.abort()
45
+ }
46
+
47
+ /** @private @author ddj 2026年10月08号 @description Schedule a single read only after the preceding transport settled. */
48
+ private schedule(): void {
49
+ if (this.disposed || !this.active || !this.visible || this.reading || this.view.loading || this.view.busy || this.timer !== null) return
50
+ this.timer = setTimeout(this.poll, this.interval)
51
+ }
52
+
53
+ /** @private @author ddj 2026年10月08号 @description Timer entry, with failures handled in read. */
54
+ private poll = (): void => {
55
+ this.timer = null
56
+ void this.read(false)
57
+ }
58
+
59
+ /** @private @author ddj 2026年10月08号 @returns Versioned read token with an abort deadline. */
60
+ private beginRead(): ReadToken {
61
+ const controller = new AbortController()
62
+ const version = this.version
63
+ /** @author ddj 2026年10月08号 @description Abort timed-out transport and invalidate its eventual response. */
64
+ const expire = (): void => {
65
+ controller.abort()
66
+ if (this.disposed || version !== this.version) return
67
+ this.version++
68
+ this.view.loading = false
69
+ this.view.error = '读取 MCP 状态超时'
70
+ this.emit()
71
+ }
72
+ const token = { controller, version, timer: setTimeout(expire, this.timeout) }
73
+ this.reading = token
74
+ return token
75
+ }
76
+
77
+ /** @private @author ddj 2026年10月08号 @param initial Initialization versus read-only polling. @param signal Abort deadline. @returns Complete settled data, never an early half-settled initial read. @throws Transport or host failures. */
78
+ private async fetchData(initial: boolean, signal: AbortSignal): Promise<Pick<McpView, 'servers' | 'projects'>> {
79
+ if (!initial) {
80
+ const snapshot = await this.request('mcp.snapshot', {}, signal)
81
+ if (!snapshot.ok) throw new Error(snapshot.error)
82
+ return snapshot
83
+ }
84
+ const [list, groups] = await Promise.allSettled([this.request('mcp.list', {}, signal), this.request('mcp.projects', {}, signal)])
85
+ if (list.status === 'rejected') throw list.reason
86
+ if (groups.status === 'rejected') throw groups.reason
87
+ if (!list.value.ok) throw new Error(list.value.error)
88
+ if (!groups.value.ok) throw new Error(groups.value.error)
89
+ return { servers: list.value.servers, projects: groups.value.projects }
90
+ }
91
+
92
+ /** @private @author ddj 2026年10月08号 @param initial Use legacy initialization RPCs once; later polls are read-only snapshots. @returns Completion after transport settles; aborted results never publish. */
93
+ private async read(initial: boolean): Promise<void> {
94
+ if (this.disposed || this.reading || this.view.busy) return
95
+ if (!initial && (!this.active || !this.visible)) return
96
+ const token = this.beginRead()
97
+ try {
98
+ const signal = token.controller.signal
99
+ const { servers, projects } = await this.fetchData(initial, signal)
100
+ if (!this.disposed && token.version === this.version && !signal.aborted) {
101
+ this.view.servers = servers
102
+ this.view.projects = projects
103
+ this.view.error = ''
104
+ }
105
+ } catch (error) {
106
+ if (!this.disposed && token.version === this.version && !token.controller.signal.aborted) this.view.error = String(error)
107
+ } finally {
108
+ clearTimeout(token.timer)
109
+ this.reading = null
110
+ if (!this.disposed) {
111
+ this.view.loading = false
112
+ this.emit()
113
+ this.schedule()
114
+ }
115
+ }
116
+ }
117
+
118
+ /** @public @author ddj 2026年10月08号 @description Initialize list/projects once per mounted page. @returns Load completion. */
119
+ load(): Promise<void> { return this.read(true) }
120
+
121
+ /** @public @author ddj 2026年10月08号 @param active Whether the top-level MCP tab is selected. */
122
+ setActive(active: boolean): void {
123
+ if (this.active === active) return
124
+ this.active = active
125
+ if (!active) this.invalidate()
126
+ this.schedule()
127
+ }
128
+
129
+ /** @public @author ddj 2026年10月08号 @param visible Whether document visibility permits background requests. */
130
+ setVisible(visible: boolean): void {
131
+ if (this.visible === visible) return
132
+ this.visible = visible
133
+ if (!visible) this.invalidate()
134
+ this.schedule()
135
+ }
136
+
137
+ /** @public @author ddj 2026年10月08号 @description Stop timers and invalidate all responses on unmount. */
138
+ dispose(): void {
139
+ this.disposed = true
140
+ this.invalidate()
141
+ if (this.reading) clearTimeout(this.reading.timer)
142
+ }
143
+ //endregion
144
+
145
+ //region Serialized mutations
146
+ /**
147
+ * @public
148
+ * @author ddj 2026年10月08号
149
+ * @description Acquire a synchronous page-wide lock before validation/RPC. Duplicate actions are rejected; failed saves never call commit.
150
+ * @param label Busy indicator key.
151
+ * @param task Validation and RPC work, including synchronous configuration errors.
152
+ * @param commit Apply successful result; may reset and close a save form.
153
+ * @returns Whether this mutation committed successfully while mounted.
154
+ */
155
+ async mutate<T extends Result>(label: string, task: () => Promise<T>, commit: (result: T) => void): Promise<boolean> {
156
+ if (this.disposed || this.view.busy) return false
157
+ this.invalidate()
158
+ this.view.busy = label
159
+ this.view.error = ''
160
+ this.emit()
161
+ try {
162
+ const result = await task()
163
+ if (!result.ok) throw new Error(result.error)
164
+ if (this.disposed) return false
165
+ commit(result)
166
+ return true
167
+ } catch (error) {
168
+ if (!this.disposed) this.view.error = String(error)
169
+ return false
170
+ } finally {
171
+ if (!this.disposed) {
172
+ this.view.busy = ''
173
+ this.emit()
174
+ this.schedule()
175
+ }
176
+ }
177
+ }
178
+ //endregion
179
+ }
@@ -175,6 +175,9 @@ export function createDiffRenderer(log) {
175
175
  const overlay = root && root.querySelector('.edrv-minus-overlay')
176
176
  if (overlay) overlay.innerHTML = ''
177
177
  }
178
+ // #region debug log
179
+ try { log(sessionId, '[DEBUG diffRender] regions=' + pendingRegions.length + ' zones=' + createdZones.length + ' ms=' + (Date.now() - renderT0)) } catch (e) { /* 诊断不可影响渲染 */ }
180
+ // #endregion
178
181
  }
179
182
 
180
183
  /** 卸载清理(EditorView unmount 时调用)。 */
@@ -29,6 +29,24 @@ export function countLinesBefore(text: string, index: number): number {
29
29
  return lineBefore(text, index)
30
30
  }
31
31
 
32
+ /**
33
+ * 在预计算的换行位置中找出 offset 前的行数。
34
+ * @author ddj 2026年09月29号
35
+ * @param breaks 换行符位置(递增)
36
+ * @param offset 目标位置
37
+ * @returns 目标位置前的换行数
38
+ */
39
+ function lineAt(breaks: number[], offset: number): number {
40
+ let left = 0
41
+ let right = breaks.length
42
+ while (left < right) {
43
+ const middle = (left + right) >>> 1
44
+ if (breaks[middle] < offset) left = middle + 1
45
+ else right = middle
46
+ }
47
+ return left
48
+ }
49
+
32
50
  /**
33
51
  * 行级公共前缀/后缀裁剪:old/new 首尾相同的行视为未变化(上下文),只保留真正变更的中间段。
34
52
  * @author ddj 2026年08月20号
@@ -58,6 +76,8 @@ export function diffRegions(records: RecordView[], content: string | null): Regi
58
76
  if (content === null) return regions
59
77
  const normalized = normalizeForCompare(content)
60
78
  const lines = splitLines(normalized)
79
+ const breaks: number[] = []
80
+ for (let index = normalized.indexOf('\n'); index >= 0; index = normalized.indexOf('\n', index + 1)) breaks.push(index)
61
81
  for (const rec of records) {
62
82
  if (rec.create) {
63
83
  for (let i = 0; i < rec.hunks.length; i++) {
@@ -81,7 +101,7 @@ export function diffRegions(records: RecordView[], content: string | null): Regi
81
101
  regions.push({ callId: rec.callId, idx: entry.idx, stale: true, status, create: false, oldLines: entry.hunk.oldText === null ? [] : entry.hunk.oldText.split('\n'), newLines: entry.hunk.newText ? entry.hunk.newText.split('\n') : [], rec, superseded: rec.superseded === true })
82
102
  continue
83
103
  }
84
- const start = countLinesBefore(normalized, location.start) + 1
104
+ const start = lineAt(breaks, location.start) + 1
85
105
  const oldLines = entry.hunk.oldText === null ? [] : entry.hunk.oldText.split('\n')
86
106
  const newLines = entry.hunk.newText.split('\n')
87
107
  const trimmed = trimCommonLines(oldLines, newLines)
@@ -248,6 +248,18 @@
248
248
  [data-edrv-diff-dock-shell] .edrv-diffbar-editor-content { width: max-content; }
249
249
  [data-edrv-diff-dock-shell] .edrv-diffbar-files .edrv-diffbar-file { max-width: 160px; }
250
250
  }
251
+ /* 侧栏差异栏在编辑内容底部居中悬浮,按内容宽度收缩;窄栏只在工具条内横滚。@author ddj 2026年09月29号 */
252
+ [data-edrv-view] .edrv-side-diff-dock[data-edrv-diff-dock-shell] { position: absolute; left: 50%; bottom: 28px; z-index: 25; width: max-content; max-width: calc(100% - 20px); max-height: min(300px, calc(100% - 38px)); margin: 0; transform: translateX(-50%); border-radius: 12px; background: var(--dsw-alias-bg-overlay, #fff); box-shadow: 0 6px 20px rgba(0,0,0,.18); overflow-x: hidden; overflow-y: auto; }
253
+ [data-edrv-view] .edrv-side-diff-dock .edrv-diffbar-editor-row { padding-inline: 8px; }
254
+ [data-edrv-view] .edrv-side-diff-dock .edrv-diffbar-editor-content { gap: 5px; width: max-content; }
255
+ [data-edrv-view] .edrv-side-diff-dock .edrv-diffbar-nav,
256
+ [data-edrv-view] .edrv-side-diff-dock .edrv-diffbar-files,
257
+ [data-edrv-view] .edrv-side-diff-dock .edrv-diffbar-actions { gap: 3px; }
258
+ [data-edrv-view] .edrv-side-diff-dock .edrv-pill { padding-inline: 5px; }
259
+ [data-edrv-view] .edrv-side-diff-dock .edrv-diffbar-files .edrv-diffbar-file { min-width: 0; max-width: 72px; }
260
+ [data-edrv-view] .edrv-side-diff-dock .edrv-diffbar-details-toggle { width: 26px; min-width: 26px; }
261
+ [data-edrv-view] .edrv-side-diff-dock .edrv-diffbar-body { max-height: min(180px, 25vh); max-width: 100%; }
262
+ [data-edrv-view] .edrv-side-diff-dock .edrv-diff-details { max-height: min(260px, 35vh); overflow-y: auto; }
251
263
 
252
264
  /* ===== 侧边栏(活动栏 + 面板区 + 文件树) ===== */
253
265
  [data-edrv-view] .edrv-editor-row { display: flex; flex: 1 1 auto; min-width: 0; min-height: 0; overflow: hidden; }
@@ -49,16 +49,19 @@
49
49
  .vsm-mcp-card-head{display:flex;justify-content:space-between;align-items:center;gap:12px}
50
50
  .vsm-mcp-title{display:flex;align-items:center;gap:9px;font-weight:600;font-size:14px;color:var(--dsw-alias-label-primary,#e6e6e6);min-width:0}
51
51
  .vsm-mcp-source{font-size:11px;font-weight:400;padding:2px 7px;border-radius:4px;background:var(--dsw-alias-bg-mask-2,#ffffff22);color:var(--dsw-alias-label-tertiary,#9aa5b1);flex-shrink:0}
52
- .vsm-mcp-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;background:var(--dsw-alias-state-error-primary,#e05252)}
52
+ .vsm-mcp-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;background:var(--dsw-alias-label-tertiary,#9aa5b1)}
53
53
  .vsm-mcp-dot.connected{background:var(--dsw-alias-state-success-primary,#38bd75)}
54
54
  .vsm-mcp-dot.connecting{background:var(--dsw-alias-state-warn-primary,#e5b93f)}
55
- .vsm-mcp-dot.disabled{background:var(--dsw-alias-label-tertiary,#9aa5b1)}
55
+ .vsm-mcp-dot.error{background:var(--dsw-alias-state-error-primary,#e05252)}
56
+ .vsm-mcp-dot.disabled,.vsm-mcp-dot.neutral,.vsm-mcp-dot.configured,.vsm-mcp-dot.unverified{background:var(--dsw-alias-label-tertiary,#9aa5b1)}
56
57
  .vsm-mcp-actions{display:flex;align-items:center;gap:4px;flex-shrink:0}
57
58
  .vsm-mcp-actions button{border:0;background:none;color:var(--dsw-alias-label-tertiary,#9aa5b1);cursor:pointer;font-size:15px;width:28px;height:28px;border-radius:6px;line-height:1}
58
59
  .vsm-mcp-actions button:hover{color:var(--dsw-alias-label-primary,#fff);background:var(--dsw-alias-interactive-bg-hover,#ffffff14)}
59
60
  .vsm-mcp-actions button.vsm-danger:hover{color:var(--dsw-alias-state-error-primary,#f07878);background:var(--dsw-alias-interactive-bg-hover-danger,#ff000014)}
60
61
  .vsm-switch{font-size:14px !important}
61
- .vsm-switch.on{color:var(--dsw-alias-state-success-primary,#38bd75)}
62
+ /* 配置启用使用品牌色,不冒充在线状态;在线证据仅由 connected 圆点表达。 */
63
+ .vsm-switch.on{color:var(--dsw-alias-brand-primary,#6d55ff)}
64
+ .vsm-mcp-actions button:disabled,.vsm-mcp-dialog-actions button:disabled{opacity:.55;cursor:default}
62
65
  .vsm-mcp-meta{font-size:12px;color:var(--dsw-alias-label-tertiary,#9aa5b1);margin:9px 0}
63
66
  .vsm-mcp-error{color:var(--dsw-alias-state-error-primary,#f07878);font-size:12px;margin:8px 0}
64
67
  .vsm-mcp-banner{padding:9px 12px;border:1px solid var(--dsw-alias-state-error-secondary,#d9534f55);border-radius:6px;background:var(--dsw-alias-state-error-secondary,#d9534f1a);margin-bottom:10px}
@@ -9,7 +9,7 @@ import { rpc } from '../rpc.js'
9
9
  import { openDiffView } from '../events.js'
10
10
  import { summarize } from '../state/records.js'
11
11
  import { nextDiffPath } from '../diffDock.js'
12
- import { readDiffDock, subscribeDiffDock } from '../diffDockStore.js'
12
+ import { dockInLayout, readDiffDock, subscribeDiffDock } from '../diffDockStore.js'
13
13
  import { DiffBox } from './DiffBox.js'
14
14
  import { CommandPalette } from './CommandPalette.js'
15
15
 
@@ -65,7 +65,7 @@ export function ConversationDiffDock(props) {
65
65
  // 与 EditorView 同时挂载时由 commandPaletteStore 的单实例认领保证只渲染一份。
66
66
  const palette = React.createElement(CommandPalette, { key: 'edrv-palette', sessionId })
67
67
  if (editorSnapshot) {
68
- if (editorSnapshot.mode === 'editor-empty' && !editorSnapshot.fileTotal) return palette
68
+ if (!dockInLayout(editorSnapshot, 'tab')) return palette
69
69
  return React.createElement(React.Fragment, null,
70
70
  renderDock(React.createElement(DiffBox, Object.assign({}, editorSnapshot, { dock: true })), sessionId),
71
71
  palette)
@@ -8,6 +8,35 @@ import React from 'react'
8
8
  import { rpc } from '../rpc.js'
9
9
  import { MONO, btn } from './shared.js'
10
10
 
11
+ /**
12
+ * 文件与批次共同标识归档卡片,避免同文件多批次共用展开状态。
13
+ * @author ddj 2026年09月30号
14
+ * @param entry 归档摘要或展开详情
15
+ * @returns 稳定卡片键
16
+ */
17
+ export function archiveKey(entry) {
18
+ return JSON.stringify([entry.path, entry.batch])
19
+ }
20
+
21
+ /**
22
+ * 追加一页摘要并按文件/批次去重,保留已有卡片的顺序。
23
+ * @author ddj 2026年09月30号
24
+ * @param current 已加载页面
25
+ * @param next 下一页
26
+ * @returns 合并后的摘要列表
27
+ */
28
+ export function mergeArchives(current, next) {
29
+ const merged = [...(current ?? [])]
30
+ const keys = new Set(merged.map(archiveKey))
31
+ for (const entry of next) {
32
+ const key = archiveKey(entry)
33
+ if (keys.has(key)) continue
34
+ keys.add(key)
35
+ merged.push(entry)
36
+ }
37
+ return merged
38
+ }
39
+
11
40
  /**
12
41
  * 全局差异面板:待处理文件列表(点击打开并跳转)+ 归档浏览/批次回滚。
13
42
  * 由状态栏差异 chip / header 角标触发。
@@ -22,27 +51,50 @@ export function DiffLauncher(props) {
22
51
  const [archiveDetail, setArchiveDetail] = React.useState(null)
23
52
  const [archivePath, setArchivePath] = React.useState(null)
24
53
  const [archiveErr, setArchiveErr] = React.useState(null)
54
+ const [nextCursor, setNextCursor] = React.useState(null)
55
+ const [moreBusy, setMoreBusy] = React.useState(false)
56
+ const requestRef = React.useRef(0)
57
+ const detailRef = React.useRef(0)
58
+ const moreRef = React.useRef(false)
25
59
  const [error, setError] = React.useState(null)
26
60
 
27
- const loadArchives = () => {
61
+ /** @author ddj 2026年09月30号 @description 仅加载一个归档摘要页并丢弃失效响应。 */
62
+ const loadArchives = (cursor = null) => {
28
63
  if (!sessionId) { setArchiveErr('会话不存在'); return }
29
- rpc('edrv.archiveList', { sessionId }).then((res) => {
30
- if (res && res.ok && Array.isArray(res.entries)) { setArchives(res.entries); setArchiveErr(null) }
31
- else setArchiveErr(res?.error ? String(res.error) : '归档列表读取失败')
32
- }).catch((e) => setArchiveErr('归档异常:' + String(e)))
64
+ if (cursor && moreRef.current) return
65
+ if (cursor) { moreRef.current = true; setMoreBusy(true); setArchiveErr(null) }
66
+ else { requestRef.current++; setArchives(null); setNextCursor(null); setArchiveErr(null) }
67
+ const generation = requestRef.current
68
+ rpc('edrv.archivePage', { sessionId, ...(cursor ? { cursor } : {}) }).then((res) => {
69
+ if (generation !== requestRef.current) return
70
+ if (res && res.ok && Array.isArray(res.entries)) {
71
+ setArchives((current) => cursor ? mergeArchives(current, res.entries) : res.entries)
72
+ setNextCursor(res.nextCursor ?? null)
73
+ setArchiveErr(null)
74
+ } else setArchiveErr(res?.error ? String(res.error) : '归档分页读取失败')
75
+ }).catch((e) => { if (generation === requestRef.current) setArchiveErr('归档异常:' + String(e)) })
76
+ .finally(() => { if (generation === requestRef.current) { moreRef.current = false; setMoreBusy(false) } })
33
77
  }
34
- const loadArchiveDetail = (path) => {
78
+ /** @author ddj 2026年09月30号 @description 仅加载目标文件和批次详情。 */
79
+ const loadArchiveDetail = (path, batch) => {
35
80
  if (!sessionId) return
81
+ const generation = ++detailRef.current
36
82
  setArchiveDetail(null)
37
- rpc('edrv.archiveRead', { sessionId, path }).then((res) => {
83
+ rpc('edrv.archiveRead', { sessionId, path, batch }).then((res) => {
84
+ if (generation !== detailRef.current) return
38
85
  if (res && res.ok && Array.isArray(res.batches)) setArchiveDetail(res.batches)
39
86
  else setArchiveErr(res?.error ? String(res.error) : '归档详情读取失败')
40
- }).catch((e) => setArchiveErr('归档异常:' + String(e)))
87
+ }).catch((e) => { if (generation === detailRef.current) setArchiveErr('归档异常:' + String(e)) })
41
88
  }
42
89
  React.useEffect(() => {
43
- if (tab !== 'archive') return
44
- setArchives(null); setArchiveDetail(null); setArchivePath(null); setArchiveErr(null)
45
- loadArchives()
90
+ requestRef.current++
91
+ detailRef.current++
92
+ moreRef.current = false
93
+ setMoreBusy(false)
94
+ setArchiveDetail(null); setArchivePath(null); setArchiveErr(null)
95
+ if (tab === 'archive') loadArchives()
96
+ else { setArchives(null); setNextCursor(null) }
97
+ return () => { requestRef.current++; detailRef.current++ }
46
98
  }, [tab, sessionId])
47
99
 
48
100
  let body
@@ -54,17 +106,18 @@ export function DiffLauncher(props) {
54
106
  React.createElement('span', { className: 'edrv-launch-cnt' }, '待处理 ' + f.pending + ' 处'),
55
107
  React.createElement('button', { className: 'edrv-btn-mini edrv-btn-keep', onClick: (e) => { e.stopPropagation(); onOpenFile(f.path) } }, '打开'))))
56
108
  } else {
57
- if (archiveErr) body = React.createElement('div', { className: 'edrv-search-empty', style: { color: 'var(--dsw-alias-state-error-primary,#d9534f)' } }, '错误:' + String(archiveErr))
109
+ if (archiveErr && archives === null) body = React.createElement('div', { className: 'edrv-search-empty', style: { color: 'var(--dsw-alias-state-error-primary,#d9534f)' } }, '错误:' + String(archiveErr), React.createElement('button', { onClick: () => loadArchives(), style: btn(false) }, '重试'))
58
110
  else if (archives === null) body = React.createElement('div', { className: 'edrv-search-empty' }, '加载中…')
59
111
  else if (archives.length === 0) body = React.createElement('div', { className: 'edrv-search-empty' }, '暂无归档记录')
60
112
  else {
61
113
  const inner = archives.map((e) => {
62
- const open = archivePath === e.path
114
+ const entryKey = archiveKey(e)
115
+ const open = archivePath === entryKey
63
116
  let detailEl = null
64
117
  if (open) {
65
118
  if (archiveDetail === null) detailEl = React.createElement('div', { style: { padding: 8, fontSize: 11, color: 'var(--dsw-alias-label-tertiary,#888)' } }, '加载中…')
66
119
  else {
67
- const batches = archiveDetail.filter((b) => b.path === e.path)
120
+ const batches = archiveDetail.filter((b) => b.path === e.path && b.batch === e.batch)
68
121
  detailEl = batches.map((b) => (Array.isArray(b.records) ? b.records : []).map((rec) => {
69
122
  const lines = []
70
123
  const hunks = Array.isArray(rec.hunks) ? rec.hunks : []
@@ -90,8 +143,8 @@ export function DiffLauncher(props) {
90
143
  }))
91
144
  }
92
145
  }
93
- return React.createElement('div', { key: e.at + e.path, style: { border: '1px solid var(--dsw-alias-border-l1,#333)', borderRadius: 8, overflow: 'hidden' } },
94
- React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', padding: '6px 8px', background: 'var(--dsw-alias-bg-layer-1,transparent)', cursor: 'pointer', flexWrap: 'wrap' }, onClick: () => { if (open) { setArchivePath(null); setArchiveDetail(null) } else { setArchivePath(e.path); loadArchiveDetail(e.path) } } },
146
+ return React.createElement('div', { key: entryKey, style: { border: '1px solid var(--dsw-alias-border-l1,#333)', borderRadius: 8, overflow: 'hidden' } },
147
+ React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', padding: '6px 8px', background: 'var(--dsw-alias-bg-layer-1,transparent)', cursor: 'pointer', flexWrap: 'wrap' }, onClick: () => { if (open) { detailRef.current++; setArchivePath(null); setArchiveDetail(null) } else { setArchivePath(entryKey); loadArchiveDetail(e.path, e.batch) } } },
95
148
  React.createElement('span', { style: { flex: 1, fontSize: 12, color: 'var(--dsw-alias-label-primary,#ddd)', wordBreak: 'break-all' } }, e.displayPath ?? e.path),
96
149
  (e.batch != null ? React.createElement('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary,#888)' } }, '批次 ' + e.batch) : null),
97
150
  (e.reason ? React.createElement('span', { style: { fontSize: 11, color: 'var(--dsw-alias-state-warn-primary,#b7791f)' } }, e.reason) : null),
@@ -110,7 +163,13 @@ export function DiffLauncher(props) {
110
163
  }, style: btn(false) }, '回滚')),
111
164
  detailEl)
112
165
  })
113
- body = React.createElement('div', { className: 'edrv-launch-arch' }, inner)
166
+ body = React.createElement('div', { className: 'edrv-launch-arch' }, inner,
167
+ archiveErr ? React.createElement('div', { style: { color: 'var(--dsw-alias-state-error-primary,#d9534f)' } }, '归档异常:' + archiveErr,
168
+ React.createElement('button', { onClick: () => { if (nextCursor) loadArchives(nextCursor); else loadArchives() }, style: btn(false) }, '重试')) : null,
169
+ nextCursor || moreBusy ? React.createElement('button', {
170
+ className: 'edrv-btn-mini', disabled: moreBusy || !!archiveErr,
171
+ onClick: () => { if (nextCursor) loadArchives(nextCursor) },
172
+ }, moreBusy ? '加载中…' : '加载更多') : null)
114
173
  }
115
174
  }
116
175