dsh-vscode-mode 0.8.0 → 0.9.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.
@@ -13,21 +13,31 @@
13
13
  * 作者 ddj 2026年09月16号
14
14
  */
15
15
  import React from 'react'
16
- import { IconRefreshOutline16 } from '../../ui/icons.js'
16
+ import { IconRefreshOutline16, fileIconEl } from '../../ui/icons.js'
17
17
  import {
18
+ BATCH_PATHS_CAP,
19
+ FILE_SIZES_CAP,
18
20
  SVN_ADD_LABEL,
19
21
  SVN_DIFF_BASE_LABEL,
20
22
  SVN_REVERT_LABEL,
21
23
  SVN_STATUS_LABEL,
22
24
  SVN_STATUS_LETTER,
23
25
  SVN_STATUS_TONE,
26
+ buildAgentPrompt,
27
+ ignoreItemsOf,
24
28
  isSvnDiffable,
25
29
  pairMissingWithUnversioned,
30
+ svnChangelistNameErrorOf,
31
+ svnChunksOf,
26
32
  svnVisibleChanges,
27
33
  } from '../../../shared/svn.js'
28
34
  import { CACHE_KEY } from '../../paths.js'
29
- import { refreshSvnChanges, svnAdd, svnConflictArtifacts, svnFileSizes, svnRemoteStatus, svnRevert } from '../../svnStatus.js'
35
+ import { refreshSvnChanges, runPlanSteps, svnAdd, svnAiPlan, svnAiPlanPending, svnChangelist, svnChangesCapped, svnConflictArtifacts, svnFileSizes, svnIgnore, svnRemoteStatus, svnRevert } from '../../svnStatus.js'
30
36
  import { csvOf, downloadText, htmlTableOf } from '../../ui/svnExport.js'
37
+ import { ContextMenu } from '../../ui/ContextMenu.js'
38
+ import type { ContextMenuEntry } from '../../ui/ContextMenu.js'
39
+ import { SvnAiPlanDialog } from '../../ui/SvnAiPlanDialog.js'
40
+ import { budgetGroupsOf } from './svnListBudget.js'
31
41
  import type { SidebarCtx } from '../types.js'
32
42
 
33
43
  /** 未分组条目的分组键(changelist 为空)。 */
@@ -37,6 +47,104 @@ const NO_CHANGELIST = '(未分组)'
37
47
  const REVERT_CONFIRM_HEAD = '确认还原以下 '
38
48
  const REVERT_CONFIRM_TAIL = ' 个文件的本地改动?新增(A)文件在磁盘上会保留,仅取消登记。'
39
49
 
50
+ /** 列表渐进渲染步长(初始也按此值):数据量大时避免一次建满 DOM(面板随编辑区任意刷新重渲)。 */
51
+ const RENDER_STEP = 500
52
+ /** 还原确认弹窗最多完整列出的路径数(超出折叠为「…等 N 个文件」,防大列表拼超长文案卡 UI)。 */
53
+ const CONFIRM_PREVIEW_CAP = 50
54
+ /** 混合通道取件轮询间隔(毫秒)与次数上限(3s × 200 = 10 分钟无件自动停)。 */
55
+ const DEEP_POLL_MS = 3000
56
+ const DEEP_POLL_MAX = 200
57
+
58
+ // --region AI 智能整理(11-ai-changelist-triage)
59
+
60
+ /** 执行载荷(SvnAiPlanDialog 勾选结果;空段已剔除)。 */
61
+ interface AiSelected {
62
+ groups: Array<{ name: string; paths: string[] }>
63
+ reverts: string[]
64
+ ignores: string[]
65
+ }
66
+
67
+ /** 弹窗执行状态(null = 预览阶段;status 终态 'done')。 */
68
+ interface AiRunState {
69
+ status: 'running' | 'pausing' | 'paused' | 'cancelling' | 'done'
70
+ index: number
71
+ total: number
72
+ cur: string
73
+ log: Array<{ ok: boolean; text: string }>
74
+ cancelled: boolean
75
+ }
76
+
77
+ /**
78
+ * 勾选载荷 → 步骤队列(顺序固定 还原 → 分组 → 忽略;块/组/目录各一步,进度粒度对齐)。
79
+ * @author ddj 2026年09月23号
80
+ * @param selected 勾选载荷
81
+ * @param sessionId 会话 id
82
+ * @returns 步骤队列
83
+ */
84
+ function planStepsOf(selected: AiSelected, sessionId: string | undefined) {
85
+ const steps = []
86
+ const reverts = selected.reverts
87
+ let revDone = 0
88
+ for (const chunk of svnChunksOf(reverts, BATCH_PATHS_CAP)) {
89
+ revDone += chunk.length
90
+ steps.push({
91
+ label: '还原 ' + revDone + '/' + reverts.length + ' 项',
92
+ kind: 'revert', n: chunk.length,
93
+ run: () => svnRevert(sessionId, chunk),
94
+ })
95
+ }
96
+ const groups = selected.groups
97
+ groups.forEach((group, index) => {
98
+ let done = 0
99
+ for (const chunk of svnChunksOf(group.paths, BATCH_PATHS_CAP)) {
100
+ done += chunk.length
101
+ steps.push({
102
+ label: '分组 ' + (index + 1) + '/' + groups.length + ' 组(' + group.name + ')' + done + '/' + group.paths.length + ' 项',
103
+ kind: 'group', n: chunk.length, key: group.name,
104
+ run: () => svnChangelist(sessionId, chunk, group.name),
105
+ })
106
+ }
107
+ })
108
+ const items = ignoreItemsOf(selected.ignores)
109
+ items.forEach((item, index) => {
110
+ steps.push({
111
+ label: '忽略目录 ' + (index + 1) + '/' + items.length + '(' + (item.dir || '工作副本根') + ')',
112
+ kind: 'ignore', n: item.names.length,
113
+ run: () => svnIgnore(sessionId, [item]),
114
+ })
115
+ })
116
+ return steps
117
+ }
118
+
119
+ /**
120
+ * 逐步日志 → 汇总文案(分段计数 + 失败数与首条原因)。
121
+ * @author ddj 2026年09月23号
122
+ * @param steps 步骤队列(提供 kind/n/key)
123
+ * @param log 逐步日志
124
+ * @param cancelled 是否取消剩余
125
+ * @returns 汇总文案
126
+ */
127
+ function planSummaryOf(steps, log, cancelled) {
128
+ const sum = { revert: 0, group: 0, ignore: 0, failed: 0, firstFail: '' }
129
+ const groupNames = new Set()
130
+ log.forEach((entry, index) => {
131
+ const step = steps[index]
132
+ if (!step) return
133
+ if (entry.ok) {
134
+ sum[step.kind] += step.n
135
+ if (step.kind === 'group' && step.key) groupNames.add(step.key)
136
+ } else {
137
+ sum.failed++
138
+ if (!sum.firstFail) sum.firstFail = entry.text
139
+ }
140
+ })
141
+ let text = 'AI 整理完成:还原 ' + sum.revert + ' · 分组 ' + groupNames.size + ' 组 ' + sum.group + ' 项 · 忽略 ' + sum.ignore + ' 项'
142
+ if (sum.failed) text += ';失败 ' + sum.failed + '(首条:' + sum.firstFail + ')'
143
+ if (cancelled) text += ';已取消剩余步骤'
144
+ return text
145
+ }
146
+ // --endregion
147
+
40
148
  // --region 显示开关持久化
41
149
 
42
150
  /**
@@ -77,6 +185,41 @@ function saveFilter(scope, filter) {
77
185
  }
78
186
  // --endregion
79
187
 
188
+ // --region 分组折叠持久化
189
+
190
+ /**
191
+ * 读取分组折叠集合(损坏/缺省安全;键独立于显示开关,旧数据无键 = 全展开,向后兼容)。
192
+ * @author ddj 2026年09月23号
193
+ * @param scope 作用域键
194
+ * @returns 折叠组名集合
195
+ */
196
+ function loadFold(scope) {
197
+ const empty = new Set()
198
+ if (!scope) return empty
199
+ try {
200
+ const raw = window.localStorage.getItem(CACHE_KEY.svn + scope + '#fold')
201
+ if (!raw) return empty
202
+ const parsed = JSON.parse(raw)
203
+ return new Set(Array.isArray(parsed) ? parsed.filter((name) => typeof name === 'string') : [])
204
+ } catch (error) {
205
+ return empty
206
+ }
207
+ }
208
+
209
+ /**
210
+ * 写分组折叠集合(配额/隐私模式失败静默)。
211
+ * @author ddj 2026年09月23号
212
+ * @param scope 作用域键
213
+ * @param names 折叠组名集合
214
+ */
215
+ function saveFold(scope, names) {
216
+ if (!scope) return
217
+ try {
218
+ window.localStorage.setItem(CACHE_KEY.svn + scope + '#fold', JSON.stringify([...names]))
219
+ } catch (error) { /* 配额满/隐私模式忽略 */ }
220
+ }
221
+ // --endregion
222
+
80
223
  // --region 展示元素
81
224
 
82
225
  /**
@@ -125,18 +268,22 @@ export function groupByChangelist(entries) {
125
268
 
126
269
  /**
127
270
  * 单条变更行(状态字母 + 路径 + 行内动作;双击 = 与基线比较)。
128
- * @author ddj 2026年09月16号 / 2026年09月20号
271
+ * React.memo:行 props(entry/pairNote/busy + 稳定回调)不变时跳过重渲染,
272
+ * 面板因名称过滤/尺寸表等面板内状态重渲时,未受影响的行不再重建。
273
+ * @author ddj 2026年09月16号 / 2026年09月20号 / 2026年09月23号
129
274
  * @param props.entry 变更条目
130
275
  * @param props.busy 批量动作进行中(按钮置灰)
131
276
  * @param props.onDiff 打开基线差异
132
277
  * @param props.onAdd 加入版本控制
133
278
  * @param props.onRevert 还原
134
279
  * @param props.onConflict 冲突副本对比(W2-4;仅冲突行出现按钮)
280
+ * @param props.onMenu 行右键菜单(分区管理入口;参数为条目与视口坐标)
281
+ * @param props.onOpen 单击跳转(编辑区打开/聚焦该文件)
135
282
  * @param props.pairNote 疑似改名标记文案(W2-5;空 = 无配对)
136
283
  * @returns 行元素
137
284
  */
138
- function SvnRow(props) {
139
- const { entry, busy, onDiff, onAdd, onRevert, onConflict, pairNote } = props
285
+ const SvnRow = React.memo(function SvnRow(props) {
286
+ const { entry, busy, onDiff, onAdd, onRevert, onConflict, onMenu, onOpen, pairNote } = props
140
287
  const diffable = isSvnDiffable(entry.path, entry.status)
141
288
  const act = (label, title, danger, run) => React.createElement('button', {
142
289
  className: 'edrv-svn-act' + (danger ? ' edrv-svn-act-danger' : ''),
@@ -147,8 +294,15 @@ function SvnRow(props) {
147
294
  return React.createElement('div', {
148
295
  className: 'edrv-svn-row',
149
296
  title: entry.path,
297
+ onClick: () => onOpen?.(entry.path),
150
298
  onDoubleClick: () => { if (diffable) onDiff(entry.path) },
299
+ onContextMenu: (event) => {
300
+ if (!onMenu) return
301
+ event.preventDefault()
302
+ onMenu(entry, event.clientX, event.clientY)
303
+ },
151
304
  },
305
+ fileIconEl(String(entry.path ?? '').split(/[\\/]/).pop() || ''),
152
306
  statusLetterEl(entry),
153
307
  React.createElement('span', { className: 'edrv-svn-path' }, entry.path),
154
308
  (pairNote ? React.createElement('span', { className: 'edrv-svn-pair-mark', title: pairNote }, '⇄') : null),
@@ -157,13 +311,13 @@ function SvnRow(props) {
157
311
  (diffable ? act('比较', SVN_DIFF_BASE_LABEL + '(BASE 与工作区并排)', false, () => onDiff(entry.path)) : null),
158
312
  (!entry.versioned ? act('加入', SVN_ADD_LABEL, false, () => onAdd([entry.path])) : null),
159
313
  (entry.versioned ? act('还原', SVN_REVERT_LABEL + '(放弃本地改动)', true, () => onRevert([entry.path])) : null)))
160
- }
314
+ })
161
315
 
162
316
  /**
163
- * 列表主体:分组行 + 条目行;未受管理/加载中/无变更各自给出稳定文案。
164
- * @author ddj 2026年09月16号
165
- * @param state 列表状态(managed/entries/visible/groups)
166
- * @param handlers 行动作(onDiff/onAdd/onRevert/busy)
317
+ * 列表主体:分组行 + 条目行(按预算渐进渲染);未受管理/加载中/无变更各自给出稳定文案。
318
+ * @author ddj 2026年09月16号 / 2026年09月23号
319
+ * @param state 列表状态(managed/entries/visible/budget/nameFiltered/capped/collapsedSet/onFold)
320
+ * @param handlers 行动作(onDiff/onAdd/onRevert/onMore/onGroupClRemove/onMenu/busy/pairNotes)
167
321
  * @returns 主体元素或元素数组
168
322
  */
169
323
  function svnListBody(state, handlers) {
@@ -175,19 +329,79 @@ function svnListBody(state, handlers) {
175
329
  state.nameFiltered ? '无匹配条目(名称过滤生效中,清空过滤框可看全部)' : '无变更(工作副本干净)')
176
330
  }
177
331
  const rows = []
178
- for (const group of state.groups) {
179
- if (state.groups.length > 1 || group.name !== NO_CHANGELIST) {
180
- rows.push(React.createElement('div', { key: 'g:' + group.name, className: 'edrv-svn-group' },
181
- React.createElement('span', null, 'changelist: ' + group.name),
182
- React.createElement('span', { className: 'edrv-svn-group-n' }, String(group.entries.length))))
183
- }
332
+ for (const group of state.budget.groups) {
333
+ // 组头恒渲染(含「未分组」,单组也出——提供折叠入口与计数):
334
+ // chevron + 名称 + 命名组「移出分区」+ 全量计数;点击组头切换折叠
335
+ rows.push(React.createElement('div', {
336
+ key: 'g:' + group.name,
337
+ className: 'edrv-svn-group',
338
+ title: '点击折叠/展开该分组',
339
+ onClick: () => state.onFold?.(group.name),
340
+ },
341
+ React.createElement('span', { className: 'edrv-svn-fold-ch' }, group.folded ? '▸' : '▾'),
342
+ React.createElement('span', null, 'changelist: ' + group.name),
343
+ (group.name !== NO_CHANGELIST && handlers.onGroupClRemove
344
+ ? React.createElement('button', {
345
+ className: 'edrv-svn-act',
346
+ disabled: handlers.busy,
347
+ title: '把该分区全部文件移出 changelist(不改文件内容;超过 ' + BATCH_PATHS_CAP + ' 项自动分块执行)',
348
+ onClick: (event) => { event.stopPropagation(); handlers.onGroupClRemove(group.name) },
349
+ }, '移出分区')
350
+ : null),
351
+ React.createElement('span', { className: 'edrv-svn-group-n' }, String(group.full ?? group.entries.length))))
352
+ if (group.folded) continue
184
353
  for (const entry of group.entries) {
185
354
  rows.push(React.createElement(SvnRow, Object.assign({ key: entry.path, entry, pairNote: handlers.pairNotes?.[entry.path] }, handlers)))
186
355
  }
187
356
  }
357
+ if (state.budget.hasMore) {
358
+ rows.push(React.createElement('button', {
359
+ key: 'edrv-svn-more',
360
+ className: 'edrv-svn-act edrv-svn-more',
361
+ title: '继续渲染更多条目(每次 ' + RENDER_STEP + ' 条;批量动作与导出始终按全量集合计算,不受渲染预算影响)',
362
+ onClick: handlers.onMore,
363
+ }, '已显示 ' + state.budget.shown + ' / ' + state.budget.total + ' 项,显示更多'))
364
+ }
365
+ if (state.capped) {
366
+ rows.push(React.createElement('div', { key: 'edrv-svn-capped', className: 'edrv-tree-loading' },
367
+ '变更条目过多,清单已按上限截断(列表/批量/导出仅含已载入部分);可用名称过滤缩小范围'))
368
+ }
188
369
  return rows
189
370
  }
190
371
 
372
+ /**
373
+ * AI 助手 dock(设计定稿 12-panel-ai-bar):accent 左竖轨 + 「AI 整理」标签 +
374
+ * 快速分析/深度分析两按钮 + 定宽状态位。按钮文案恒定(忙态只换状态位文案,不跳版);
375
+ * AI 线程色三处同轨(竖轨/按钮描边/弹窗进度条,--dsw-alias-state-info-primary)。
376
+ * @author ddj 2026年09月23号
377
+ * @param props.managed/忙碌标志(managed/busy/aiBusy/deepWaiting/hasRun)
378
+ * @param props.onQuick 快速分析回调(host LLM 直调)
379
+ * @param props.onDeep 深度分析回调(会话 agent 投递回面板)
380
+ * @returns dock 元素
381
+ */
382
+ function aiBarEl(props) {
383
+ const { managed, busy, aiBusy, deepWaiting, hasRun, onQuick, onDeep } = props
384
+ const locked = !managed || busy || aiBusy || hasRun
385
+ const status = aiBusy ? '分析中…' : (deepWaiting ? '等待助手投递…' : '就绪')
386
+ return React.createElement('div', { className: 'edrv-svn-ai-bar' },
387
+ React.createElement('span', { className: 'edrv-svn-ai-rail', 'aria-hidden': 'true' }),
388
+ React.createElement('span', { className: 'edrv-svn-ai-label' }, 'AI 整理'),
389
+ React.createElement('button', {
390
+ className: 'edrv-svn-act edrv-svn-ai-btn',
391
+ title: '快速分析:由模型直读变更与差异,产出分组/还原/忽略方案(分析后需勾选确认才执行)',
392
+ disabled: locked,
393
+ onClick: onQuick,
394
+ }, '快速分析'),
395
+ React.createElement('button', {
396
+ className: 'edrv-svn-act edrv-svn-ai-btn',
397
+ title: '深度分析:把分析任务填入对话,由 AI 助手借 codegraph/读文件深度分析后投递方案回本面板(执行仍需勾选确认)',
398
+ disabled: locked || deepWaiting,
399
+ onClick: onDeep,
400
+ }, '深度分析'),
401
+ React.createElement('span', { style: { flex: 1 } }),
402
+ React.createElement('span', { className: 'edrv-svn-ai-status' }, status))
403
+ }
404
+
191
405
  /**
192
406
  * 工具条(显示开关 + 名称过滤 + 批量动作 + 导出)。
193
407
  * @author ddj 2026年09月16号 / 2026年09月20号
@@ -221,14 +435,18 @@ function svnToolbarEl(props) {
221
435
  ? React.createElement('div', { key: 'bulk', className: 'edrv-svn-bulk' },
222
436
  React.createElement('span', { className: 'edrv-svn-count' }, String(count) + (nameFiltered ? '/' + countAll : '') + ' 项'),
223
437
  React.createElement('span', { style: { flex: 1 } }),
438
+ // 导出簇 ┊ 动作簇(设计定稿:语义分组,分隔线只在两簇之间)
224
439
  React.createElement('button', {
225
440
  className: 'edrv-svn-act', title: '导出当前列表为 CSV(带 BOM,Excel 直开;状态/路径/changelist/修订)',
226
441
  onClick: handlers.onExportCsv,
227
- }, '导出CSV'),
442
+ }, '导出 CSV'),
228
443
  React.createElement('button', {
229
444
  className: 'edrv-svn-act', title: '导出当前列表为 HTML 报表(仅本地查看,不外发)',
230
445
  onClick: handlers.onExportHtml,
231
- }, '导出HTML'),
446
+ }, '导出 HTML'),
447
+ ((paths.unversioned.length || paths.revert.length)
448
+ ? React.createElement('span', { className: 'edrv-svn-sep' })
449
+ : null),
232
450
  (paths.unversioned.length
233
451
  ? React.createElement('button', {
234
452
  className: 'edrv-svn-act', disabled: handlers.busy,
@@ -267,38 +485,60 @@ export function SvnPanel(props) {
267
485
  const [remoteBusy, setRemoteBusy] = React.useState(false)
268
486
  // W2-5:配对候选的文件大小(path → size|null)与成对结果
269
487
  const [sizes, setSizes] = React.useState({})
488
+ // 渐进渲染预算:过滤/作用域变化时重置,避免过滤后仍停留在旧的大预算
489
+ const [renderLimit, setRenderLimit] = React.useState(RENDER_STEP)
490
+ // ctx 由编辑区每轮渲染重建;行动作回调改读 ref 取最新值,回调身份才能稳定(行 React.memo 生效前提)
491
+ const ctxRef = React.useRef(ctx)
492
+ ctxRef.current = ctx
493
+ // 行右键菜单状态(分区管理入口):{ entry, x, y } | null
494
+ const [menu, setMenu] = React.useState(null)
495
+ // 分组折叠集合(组名;按作用域持久化,独立于显示开关键)
496
+ const [collapsed, setCollapsed] = React.useState(() => loadFold(scope))
497
+ // AI 智能整理:分析 busy / 方案(null = 未开弹窗)/ 执行状态(null = 预览阶段)
498
+ const [aiBusy, setAiBusy] = React.useState(false)
499
+ const [aiPlan, setAiPlan] = React.useState(null)
500
+ const [aiRun, setAiRun] = React.useState(null)
501
+ // 执行引擎控制面(暂停/取消标志 + 步骤边界唤醒;ref 保证回调身份稳定)
502
+ const aiCtl = React.useRef({ paused: false, cancelled: false, wake: null })
270
503
 
271
504
  // 作用域切换:重读该作用域自己的开关(同一工作区共享)并清掉远端/配对的会话内状态
272
505
  React.useEffect(() => {
273
506
  setFilter(loadFilter(scope))
274
507
  setRemote(null)
275
508
  setSizes({})
509
+ setRenderLimit(RENDER_STEP)
510
+ setCollapsed(loadFold(scope))
276
511
  }, [scope])
277
512
 
278
513
  const applyFilter = (next) => {
279
514
  setFilter(next)
515
+ setRenderLimit(RENDER_STEP)
280
516
  saveFilter(scope, next)
281
517
  }
282
518
 
283
519
  /**
284
520
  * 批量动作统一流程:先 confirm(仅还原需要)→ 执行 → 反馈 → 强制重查。
285
- * @author ddj 2026年09月16号
521
+ * 确认文案只完整列前 CONFIRM_PREVIEW_CAP 条(大列表拼几千行弹窗文案会卡 UI),总数如实展示。
522
+ * @author ddj 2026年09月16号 / 2026年09月23号
286
523
  * @param kind 'add' | 'revert'
287
524
  * @param paths 目标相对路径列表
288
525
  */
289
- const runBatch = (kind, paths) => {
526
+ const runBatch = React.useCallback((kind, paths) => {
290
527
  if (!paths.length) return
291
528
  if (kind === 'revert') {
529
+ const preview = paths.length > CONFIRM_PREVIEW_CAP
530
+ ? paths.slice(0, CONFIRM_PREVIEW_CAP).join('\n') + '\n…等 ' + paths.length + ' 个文件'
531
+ : paths.join('\n')
292
532
  const head = REVERT_CONFIRM_HEAD + paths.length + REVERT_CONFIRM_TAIL
293
- if (!window.confirm(head + '\n\n' + paths.join('\n'))) return
533
+ if (!window.confirm(head + '\n\n' + preview)) return
294
534
  }
295
535
  setBusy(true)
296
536
  const task = kind === 'revert' ? svnRevert(sessionId, paths) : svnAdd(sessionId, paths)
297
537
  void task.then((outcome) => {
298
- ctx?.notify?.(outcome.message)
538
+ ctxRef.current?.notify?.(outcome.message)
299
539
  refreshSvnChanges(sessionId, scope)
300
540
  }).finally(() => setBusy(false))
301
- }
541
+ }, [sessionId, scope])
302
542
 
303
543
  /**
304
544
  * 远端更新检查(W2-3;host 侧 15s 短超时,离线/超时降级为提示条文案,不阻塞面板)。
@@ -310,7 +550,7 @@ export function SvnPanel(props) {
310
550
  void svnRemoteStatus(sessionId, '').then((outcome) => {
311
551
  if (!outcome.ok) {
312
552
  setRemote({ failed: true })
313
- ctx?.notify?.(outcome.message)
553
+ ctxRef.current?.notify?.(outcome.message)
314
554
  return
315
555
  }
316
556
  setRemote({ outdated: outcome.outdated ?? [], againstRev: outcome.againstRev ?? null })
@@ -320,59 +560,371 @@ export function SvnPanel(props) {
320
560
  /**
321
561
  * 冲突副本对比入口(W2-4,只读):扫描 .mine/.working/.rN → 默认首个 .rN ↔ .mine 并排;
322
562
  * resolve 指引在差异视图顶部展示,本入口绝不自动执行 svn resolve(归 P2 破坏性批次)。
323
- * @author ddj 2026年09月20号
563
+ * @author ddj 2026年09月20号 / 2026年09月23号
324
564
  * @param path 冲突文件的工作区相对路径
325
565
  */
326
- const openConflictDiff = (path) => {
566
+ const openConflictDiff = React.useCallback((path) => {
327
567
  void svnConflictArtifacts(sessionId, path).then((outcome) => {
328
- if (!outcome.ok) { ctx?.notify?.(outcome.message); return }
568
+ const live = ctxRef.current
569
+ if (!outcome.ok) { live?.notify?.(outcome.message); return }
329
570
  const artifacts = outcome.artifacts ?? []
330
571
  const revSide = artifacts.find((item) => item.kind === 'rev')
331
572
  const mineSide = artifacts.find((item) => item.kind === 'mine')
332
573
  if (!revSide || !mineSide) {
333
- ctx?.notify?.('未发现冲突副本(.mine/.rN):' + artifacts.map((item) => item.name).join('、'))
574
+ live?.notify?.('未发现冲突副本(.mine/.rN):' + artifacts.map((item) => item.name).join('、'))
334
575
  return
335
576
  }
336
- if (!ctx?.openSvnLocalPair) { ctx?.notify?.('当前视图不支持冲突对比'); return }
337
- ctx.openSvnLocalPair(revSide.path, mineSide.path, '.r' + (revSide.rev ?? '?'), '.mine', path)
577
+ if (!live?.openSvnLocalPair) { live?.notify?.('当前视图不支持冲突对比'); return }
578
+ live.openSvnLocalPair(revSide.path, mineSide.path, '.r' + (revSide.rev ?? '?'), '.mine', path)
338
579
  })
339
- }
580
+ }, [sessionId])
340
581
 
341
- // W1-2:展示集合(含名称过滤)与批量动作集合(不含名称过滤)分离,「全部加入/还原」不缩小范围
342
- const shown = entries === null ? [] : svnVisibleChanges(entries, filter)
343
- const actionable = entries === null ? [] : svnVisibleChanges(entries, { unversioned: filter.unversioned, ignored: filter.ignored })
582
+ // W1-2:展示集合(含名称过滤)与批量动作集合(不含名称过滤)分离,「全部加入/还原」不缩小范围。
583
+ // 派生集合全部 memo 化:面板会随编辑区任意刷新重渲,entries/filter 不变时不再重复 O(n) 过滤。
584
+ const shown = React.useMemo(
585
+ () => (entries === null ? [] : svnVisibleChanges(entries, filter)),
586
+ [entries, filter],
587
+ )
588
+ const actionable = React.useMemo(
589
+ () => (entries === null ? [] : svnVisibleChanges(entries, { unversioned: filter.unversioned, ignored: filter.ignored })),
590
+ [entries, filter],
591
+ )
344
592
  const nameFiltered = String(filter.name || '').trim() !== ''
345
593
 
346
- // W2-5:`!`(missing)与 `?`(unversioned)共存时才做大小查询(候选少,一次批量查完成对)
347
- const missingEntries = entries === null ? [] : entries.filter((entry) => entry.status === 'missing')
348
- const unversionedEntries = entries === null ? [] : entries.filter((entry) => entry.status === 'unversioned')
349
- const candidates = [...missingEntries, ...unversionedEntries].map((entry) => entry.path)
350
- const pairsKnown = missingEntries.length > 0 && unversionedEntries.length > 0
351
- && candidates.every((path) => path in sizes)
594
+ // W2-5:改名配对候选。sizes 只服务配对,无 missing 文件时零请求(此前会把全部
595
+ // unversioned 发给 svn.fileSizes 白耗 I/O);请求子集对齐 host 上限(超出部分 host 会静默截断),
596
+ // missing 优先占位,保证「有缺失文件」场景优先拿到双侧 size。
597
+ const missingEntries = React.useMemo(
598
+ () => (entries ?? []).filter((entry) => entry.status === 'missing'),
599
+ [entries],
600
+ )
601
+ const unversionedEntries = React.useMemo(
602
+ () => (entries ?? []).filter((entry) => entry.status === 'unversioned'),
603
+ [entries],
604
+ )
605
+ const requested = React.useMemo(
606
+ () => [...missingEntries, ...unversionedEntries].slice(0, FILE_SIZES_CAP).map((entry) => entry.path),
607
+ [missingEntries, unversionedEntries],
608
+ )
609
+ const candidateKey = React.useMemo(() => requested.join('\n'), [requested])
610
+ const pairsNeeded = missingEntries.length > 0 && unversionedEntries.length > 0
611
+ const pairsKnown = pairsNeeded && requested.every((path) => path in sizes)
352
612
  React.useEffect(() => {
353
- if (!pairsKnown && candidates.length) {
354
- void svnFileSizes(sessionId, candidates).then((table) => setSizes(table))
613
+ if (pairsKnown || !requested.length) return
614
+ void svnFileSizes(sessionId, requested).then((table) => setSizes(table))
615
+ // requested 内容由 candidateKey 代理:同 key 必同内容,闭包取旧数组无碍(去重语义与旧 join 依赖一致)
616
+ }, [pairsKnown, candidateKey, sessionId]) // eslint-disable-line react-hooks/exhaustive-deps
617
+
618
+ const pairNoteOf = React.useMemo(() => {
619
+ if (!pairsKnown) return {}
620
+ const notes = {}
621
+ for (const pair of pairMissingWithUnversioned(entries ?? [], sizes)) {
622
+ notes[pair.missingPath] = '疑似改名:原文件 → ' + pair.unversionedPath
623
+ notes[pair.unversionedPath] = '疑似改名:← 原文件 ' + pair.missingPath
355
624
  }
356
- }, [pairsKnown, candidates.join('\n'), sessionId]) // eslint-disable-line react-hooks/exhaustive-deps
357
- const pairNoteOf = pairsKnown
358
- ? (() => {
359
- const notes = {}
360
- for (const pair of pairMissingWithUnversioned(entries ?? [], sizes)) {
361
- notes[pair.missingPath] = '疑似改名:原文件 → ' + pair.unversionedPath
362
- notes[pair.unversionedPath] = '疑似改名:← 原文件 ' + pair.missingPath
625
+ return notes
626
+ }, [pairsKnown, entries, sizes])
627
+ // 候选超上限时如实提示(此前候选超 200 时 pairsKnown 永远不成立,配对功能静默失效)
628
+ const pairCapHint = pairsNeeded && (missingEntries.length + unversionedEntries.length) > FILE_SIZES_CAP
629
+
630
+ // 远端提示条的「本地已改 N 个」计数:Set 查表 O(n+m),替换旧的逐条 some(O(outdated×entries))
631
+ const versionedPathSet = React.useMemo(() => {
632
+ const set = new Set()
633
+ for (const entry of entries ?? []) {
634
+ if (entry.versioned) set.add(entry.path)
635
+ }
636
+ return set
637
+ }, [entries])
638
+
639
+ /**
640
+ * 分区操作统一流程:busy → 按 host 上限分块顺序执行 → 聚合反馈 → 强制重查。
641
+ * 反馈计数以面板层 paths.length 为准(svn changelist 成功输出静默,host count 为 0)。
642
+ * @author ddj 2026年09月23号
643
+ * @param paths 目标相对路径列表
644
+ * @param name 目标分区名;null = 移出分区
645
+ */
646
+ const runCl = React.useCallback(async (paths, name) => {
647
+ if (!paths.length) return
648
+ setBusy(true)
649
+ const failures = []
650
+ let done = 0
651
+ try {
652
+ for (const chunk of svnChunksOf(paths, BATCH_PATHS_CAP)) {
653
+ const outcome = await svnChangelist(sessionId, chunk, name)
654
+ if (outcome.ok) done += chunk.length
655
+ else failures.push(outcome.message)
363
656
  }
364
- return notes
365
- })()
366
- : {}
657
+ } finally {
658
+ setBusy(false)
659
+ }
660
+ const total = paths.length
661
+ if (!failures.length) {
662
+ ctxRef.current?.notify?.(name === null
663
+ ? '已移出分区(' + total + ' 项)'
664
+ : '已移入分区「' + name + '」(' + total + ' 项)')
665
+ } else {
666
+ ctxRef.current?.notify?.('分区完成 ' + done + '/' + total + ' 项;' + failures[0])
667
+ }
668
+ refreshSvnChanges(sessionId, scope)
669
+ }, [sessionId, scope])
670
+
671
+ /**
672
+ * 把单条条目移入分区:弹窗输入分区名(预填当前名,可直接改名)→ 共享校验 → 执行。
673
+ * @author ddj 2026年09月23号
674
+ * @param entry 变更条目
675
+ */
676
+ const moveToChangelist = async (entry) => {
677
+ const name = await ctxRef.current?.prompt?.('移入 SVN 分区(changelist)', entry.changelist || '')
678
+ if (!name) return
679
+ const nameError = svnChangelistNameErrorOf(name)
680
+ if (nameError) { ctxRef.current?.notify?.(nameError); return }
681
+ await runCl([entry.path], name)
682
+ }
683
+
684
+ /**
685
+ * 整组移出分区:取该分区全部条目(不受名称过滤/显示开关影响,与批量动作口径一致)。
686
+ * @author ddj 2026年09月23号
687
+ * @param groupName 分区名
688
+ */
689
+ const removeGroupCl = React.useCallback(async (groupName) => {
690
+ const paths = (entries ?? []).filter((entry) => entry.changelist === groupName).map((entry) => entry.path)
691
+ await runCl(paths, null)
692
+ }, [entries, runCl])
693
+
694
+ const openRowMenu = React.useCallback((entry, x, y) => setMenu({ entry, x, y }), [])
695
+ const closeMenu = React.useCallback(() => setMenu(null), [])
696
+
697
+ /**
698
+ * AI 智能整理分析入口:host 一次性 LLM 分析(超时 AI_PLAN_TIMEOUT_MS=300s)→ 预览弹窗;失败 notify。
699
+ * @author ddj 2026年09月23号
700
+ */
701
+ const runAiPlan = React.useCallback(() => {
702
+ if (aiBusy || aiRun) return
703
+ if (!entries || !entries.length) { ctxRef.current?.notify?.('无变更可分析'); return }
704
+ setAiBusy(true)
705
+ void svnAiPlan(sessionId).then((outcome) => {
706
+ if (!outcome.ok || !outcome.plan) { ctxRef.current?.notify?.(outcome.message); return }
707
+ setAiPlan(outcome)
708
+ }).finally(() => setAiBusy(false))
709
+ }, [aiBusy, aiRun, entries, sessionId])
710
+
711
+ /** 暂停(步骤边界生效;当前 in-flight RPC 不可中断,UI 标注「等待当前步骤完成…」)。 */
712
+ const aiPause = React.useCallback(() => {
713
+ aiCtl.current.paused = true
714
+ setAiRun((old) => (old ? { ...old, status: 'pausing' } : old))
715
+ }, [])
716
+
717
+ /** 继续(唤醒步骤边界等待,从断点续跑)。 */
718
+ const aiResume = React.useCallback(() => {
719
+ aiCtl.current.paused = false
720
+ const wake = aiCtl.current.wake
721
+ aiCtl.current.wake = null
722
+ if (wake) wake()
723
+ setAiRun((old) => (old ? { ...old, status: 'running' } : old))
724
+ }, [])
725
+
726
+ /** 取消剩余(已执行不回滚;唤醒边界等待让引擎立刻收尾)。 */
727
+ const aiCancelRest = React.useCallback(() => {
728
+ aiCtl.current.cancelled = true
729
+ aiCtl.current.paused = false
730
+ const wake = aiCtl.current.wake
731
+ aiCtl.current.wake = null
732
+ if (wake) wake()
733
+ setAiRun((old) => (old ? { ...old, status: 'cancelling' } : old))
734
+ }, [])
735
+
736
+ /**
737
+ * 预览确认 → 启动可暂停执行引擎(还原→分组→忽略固定顺序;单步失败继续)。
738
+ * @author ddj 2026年09月23号
739
+ * @param selected 勾选载荷
740
+ */
741
+ const onAiExecute = React.useCallback(async (selected) => {
742
+ const live = ctxRef.current
743
+ const steps = planStepsOf(selected, sessionId)
744
+ if (!steps.length) return
745
+ aiCtl.current = { paused: false, cancelled: false, wake: null }
746
+ setAiRun({ status: 'running', index: 0, total: steps.length, cur: steps[0].label, log: [], cancelled: false })
747
+ const ctl = {
748
+ paused: () => aiCtl.current.paused,
749
+ cancelled: () => aiCtl.current.cancelled,
750
+ /** 步骤边界等待:paused 且未 cancelled 时挂起,由 resume/cancel 唤醒。 */
751
+ wait: () => (aiCtl.current.paused && !aiCtl.current.cancelled
752
+ ? new Promise((resolve) => { aiCtl.current.wake = () => resolve(undefined) })
753
+ : Promise.resolve(undefined)),
754
+ }
755
+ const result = await runPlanSteps(steps, ctl, (tick) => {
756
+ setAiRun((old) => (old ? {
757
+ ...old,
758
+ index: tick.done,
759
+ total: tick.total,
760
+ cur: tick.cur,
761
+ log: tick.log,
762
+ status: aiCtl.current.cancelled ? 'cancelling' : old.status,
763
+ } : old))
764
+ })
765
+ setAiRun((old) => (old ? { ...old, status: 'done', cur: '', cancelled: result.cancelled } : old))
766
+ live?.notify?.(planSummaryOf(steps, result.log, result.cancelled))
767
+ refreshSvnChanges(sessionId, scope)
768
+ }, [sessionId, scope])
769
+
770
+ /** 关闭整理弹窗(「关闭并刷新」;预览阶段 = 取消零副作用)。 */
771
+ const closeAiPlan = React.useCallback(() => {
772
+ setAiPlan(null)
773
+ setAiRun(null)
774
+ refreshSvnChanges(sessionId, scope)
775
+ }, [sessionId, scope])
776
+
777
+ // --region 混合通道(01-hybrid-deep-analysis):会话 agent 深度分析 → 投递回面板
367
778
 
368
- const openSvnDiff = (p) => ctx?.openSvnDiff?.(p)
779
+ /** 取件轮询句柄(null = 未在轮询;卸载/取到/超时清理)。 */
780
+ const deepTimer = React.useRef(null)
781
+ const [deepWaiting, setDeepWaiting] = React.useState(false)
782
+
783
+ /** 停止取件轮询(幂等)。 */
784
+ const stopDeepPoll = React.useCallback(() => {
785
+ if (deepTimer.current !== null) {
786
+ clearInterval(deepTimer.current)
787
+ deepTimer.current = null
788
+ }
789
+ setDeepWaiting(false)
790
+ }, [])
791
+
792
+ // 卸载清理(防轮询泄漏)
793
+ React.useEffect(() => () => {
794
+ if (deepTimer.current !== null) clearInterval(deepTimer.current)
795
+ }, [])
796
+
797
+ /**
798
+ * 开启取件轮询:每 DEEP_POLL_MS 取一次收件箱,取到方案即开预览弹窗并停轮询;
799
+ * DEEP_POLL_MAX 次无件自动停并提示(agent 可能没投递)。
800
+ * @author ddj 2026年09月23号
801
+ * @param since 注入时刻时间戳(旧投递不算新件)
802
+ */
803
+ const startDeepPoll = React.useCallback((since) => {
804
+ stopDeepPoll()
805
+ setDeepWaiting(true)
806
+ let ticks = 0
807
+ deepTimer.current = setInterval(() => {
808
+ ticks += 1
809
+ if (ticks > DEEP_POLL_MAX) {
810
+ stopDeepPoll()
811
+ ctxRef.current?.notify?.('等待 AI 助手方案超时(10 分钟),已停止取件')
812
+ return
813
+ }
814
+ void svnAiPlanPending(sessionId, since).then((outcome) => {
815
+ if (!outcome.ok || !outcome.plan) return
816
+ stopDeepPoll()
817
+ setAiPlan({
818
+ plan: outcome.plan,
819
+ entriesCount: (ctxRef.current?.svnChanges ?? []).length,
820
+ diffIncluded: true,
821
+ dropped: outcome.dropped ?? 0,
822
+ model: undefined,
823
+ source: 'agent',
824
+ })
825
+ ctxRef.current?.notify?.('AI 助手方案已送达,请勾选确认后执行')
826
+ })
827
+ }, DEEP_POLL_MS)
828
+ }, [sessionId, stopDeepPoll])
829
+
830
+ /**
831
+ * 深度分析入口(02-deep-session-prompt 修正版):**新建独立会话**承载任务
832
+ * (不污染当前对话),草稿箱填入分析任务(变更清单由 agent 自跑只读 svn status
833
+ * 取数,prompt 定长)→ 用户在新会话发送 → agent 投递方案回面板收件箱。
834
+ * 降级:不支持自动新建会话时回落当前对话注入 + notify 注明。
835
+ * @author ddj 2026年09月23号
836
+ */
837
+ const runDeepPlan = React.useCallback(() => {
838
+ const live = ctxRef.current
839
+ if (aiBusy || aiRun || deepWaiting) return
840
+ if (!entries || !entries.length) { live?.notify?.('无变更可分析'); return }
841
+ const wcRoot = live?.svn?.wcRoot ? String(live.svn.wcRoot) : ''
842
+ const since = Date.now()
843
+ const add = live?.addToConversation
844
+ if (!add?.appendText) { live?.notify?.('无法填入任务(无会话或输入框不可用)'); return }
845
+ /** 降级:当前对话注入(不支持自动新建会话时兜底)。 */
846
+ const degrade = (prompt) => {
847
+ void add.appendText(sessionId, prompt).then((outcome) => {
848
+ if (outcome === 'ok' || outcome === 'busy') {
849
+ live?.notify?.('已填入当前对话(降级),任务已自动发送,请在本对话查看')
850
+ startDeepPoll(since)
851
+ } else {
852
+ live?.notify?.('发起失败:' + (outcome === 'unavailable' ? '无会话或输入框不可用' : '输入框忙,请重试'))
853
+ }
854
+ })
855
+ }
856
+ /** 统一收尾(点击即发送方案):create 后构建 prompt(内嵌新会话 id)→ 发送任务。 */
857
+ const sendAndWatch = (id) => {
858
+ const prompt = buildAgentPrompt(wcRoot, id || sessionId || '')
859
+ if (!id || typeof add.sendTask !== 'function') { degrade(prompt); return }
860
+ void add.sendTask(id, prompt).then((sent) => {
861
+ if (sent) {
862
+ live?.notify?.('已在新会话发起深度分析(任务已自动发送),请在会话列表查看')
863
+ startDeepPoll(since)
864
+ } else {
865
+ degrade(prompt)
866
+ }
867
+ })
868
+ }
869
+ void (add.startDraftSession
870
+ ? add.startDraftSession(wcRoot || undefined)
871
+ : Promise.resolve({ ok: false })
872
+ ).then((result) => { sendAndWatch(result?.ok ? result.id : undefined) })
873
+ }, [aiBusy, aiRun, deepWaiting, entries, sessionId, startDeepPoll])
874
+
875
+ // --endregion
876
+
877
+ /**
878
+ * 切换分组折叠(纯 UI 状态;不重置渲染预算——折叠释放预算、展开继续用已有预算)。
879
+ * @author ddj 2026年09月23号
880
+ * @param name 组名
881
+ */
882
+ const toggleFold = React.useCallback((name) => {
883
+ const next = new Set(collapsed)
884
+ if (next.has(name)) next.delete(name)
885
+ else next.add(name)
886
+ setCollapsed(next)
887
+ saveFold(scope, next)
888
+ }, [scope, collapsed])
889
+
890
+ /**
891
+ * 行右键菜单条目:分区管理(svn changelist 仅支持受版本控制文件,
892
+ * 未版本控制/忽略项禁用;目录条目的报错由 host 经 notify 透出)。
893
+ * @author ddj 2026年09月23号
894
+ * @param entry 变更条目
895
+ * @returns 菜单条目
896
+ */
897
+ const rowMenuEntriesOf = (entry): ContextMenuEntry[] => [
898
+ {
899
+ id: 'cl-set',
900
+ label: entry.changelist ? '移到其他分区…' : '移入分区…',
901
+ disabled: !entry.versioned || busy,
902
+ onClick: () => { void moveToChangelist(entry) },
903
+ },
904
+ {
905
+ id: 'cl-remove',
906
+ label: '移出分区' + (entry.changelist ? '「' + entry.changelist + '」' : ''),
907
+ disabled: !entry.versioned || !entry.changelist || busy,
908
+ onClick: () => { void runCl([entry.path], null) },
909
+ },
910
+ ]
911
+
912
+ const openSvnDiff = React.useCallback((p) => ctxRef.current?.openSvnDiff?.(p), [])
913
+ const openFileAt = React.useCallback((p) => ctxRef.current?.openFile?.(p), [])
914
+ const onAdd = React.useCallback((paths) => runBatch('add', paths), [runBatch])
915
+ const onRevert = React.useCallback((paths) => runBatch('revert', paths), [runBatch])
916
+ const showMore = React.useCallback(() => setRenderLimit((old) => old + RENDER_STEP), [])
369
917
  const handlers = {
370
918
  busy,
371
919
  onDiff: openSvnDiff,
372
920
  onConflict: openConflictDiff,
373
921
  pairNotes: pairNoteOf,
374
- onAdd: (paths) => runBatch('add', paths),
375
- onRevert: (paths) => runBatch('revert', paths),
922
+ onAdd,
923
+ onRevert,
924
+ onMore: showMore,
925
+ onMenu: openRowMenu,
926
+ onGroupClRemove: removeGroupCl,
927
+ onOpen: openFileAt,
376
928
  }
377
929
 
378
930
  /**
@@ -395,23 +947,32 @@ export function SvnPanel(props) {
395
947
  if (!done) ctx?.notify?.('导出失败:浏览器下载能力不可用')
396
948
  }
397
949
 
398
- const listState = { managed, entries, visible: shown, groups: groupByChangelist(shown), nameFiltered }
950
+ const groups = React.useMemo(() => groupByChangelist(shown), [shown])
951
+ // 折叠组映射为空 entries 组(带 full 全量计数 + folded 标记):不占渲染预算,组头仍可见
952
+ const budget = React.useMemo(() => budgetGroupsOf(
953
+ groups.map((g) => (collapsed.has(g.name)
954
+ ? { name: g.name, entries: [], full: g.entries.length, folded: true }
955
+ : g)),
956
+ renderLimit,
957
+ ), [groups, collapsed, renderLimit])
958
+ const listState = { managed, entries, visible: shown, budget, nameFiltered, capped: svnChangesCapped(scope), collapsedSet: collapsed, onFold: toggleFold }
959
+ const bulkPaths = React.useMemo(() => ({
960
+ unversioned: actionable.filter((entry) => !entry.versioned).map((entry) => entry.path),
961
+ revert: actionable.filter((entry) => entry.versioned).map((entry) => entry.path),
962
+ }), [actionable])
399
963
  const toolbar = svnToolbarEl({
400
964
  filter,
401
965
  count: shown.length,
402
966
  countAll: actionable.length,
403
- paths: {
404
- unversioned: actionable.filter((entry) => !entry.versioned).map((entry) => entry.path),
405
- revert: actionable.filter((entry) => entry.versioned).map((entry) => entry.path),
406
- },
967
+ paths: bulkPaths,
407
968
  handlers: Object.assign({}, handlers, {
408
969
  toggleUnversioned: () => applyFilter({ ...filter, unversioned: !filter.unversioned }),
409
970
  toggleIgnored: () => applyFilter({ ...filter, ignored: !filter.ignored }),
410
971
  onNameFilter: (name) => applyFilter({ ...filter, name }),
411
972
  onExportCsv: () => onExport('csv'),
412
973
  onExportHtml: () => onExport('html'),
413
- add: (paths) => runBatch('add', paths),
414
- revert: (paths) => runBatch('revert', paths),
974
+ add: onAdd,
975
+ revert: onRevert,
415
976
  }),
416
977
  })
417
978
  const rootName = ctx?.svn?.wcRoot ? String(ctx.svn.wcRoot).split(/[\\/]/).pop() || ctx.svn.wcRoot : ''
@@ -424,24 +985,73 @@ export function SvnPanel(props) {
424
985
  remote.failed
425
986
  ? '远端检查失败(网络不可达或超时;可用 ⟳ 或「检查远端」重试)'
426
987
  : (remote.outdated.length
427
- ? '远端有 ' + remote.outdated.length + ' 个更新(against r' + (remote.againstRev ?? '?') + '),其中本地已改 ' + remote.outdated.filter((item) => item.path && entries?.some((entry) => entry.path === item.path && entry.versioned)).length + ' 个;更新前请先提交或还原'
988
+ ? '远端有 ' + remote.outdated.length + ' 个更新(against r' + (remote.againstRev ?? '?') + '),其中本地已改 ' + remote.outdated.filter((item) => item.path && versionedPathSet.has(item.path)).length + ' 个;更新前请先提交或还原'
428
989
  : '远端无更新(against r' + (remote.againstRev ?? '?') + ')')),
429
990
  React.createElement('button', { className: 'edrv-svn-act', title: '关闭提示条', onClick: () => setRemote(null) }, '✕'))
430
991
 
992
+ // W2-5:改名配对候选超上限提示(评估范围 = FILE_SIZES_CAP,missing 优先)
993
+ const pairCapEl = pairCapHint
994
+ ? React.createElement('div', { className: 'edrv-svn-hintbar' },
995
+ '改名配对候选超过 ' + FILE_SIZES_CAP + ' 个,仅评估前 ' + FILE_SIZES_CAP + ' 个(缺失文件优先)')
996
+ : null
997
+
998
+ // P5:行右键菜单(分区管理;ContextMenu 经 portal 挂 body,Esc/外点自动关闭)
999
+ const menuEl = menu
1000
+ ? React.createElement(ContextMenu, {
1001
+ key: 'edrv-svn-row-menu',
1002
+ x: menu.x,
1003
+ y: menu.y,
1004
+ entries: rowMenuEntriesOf(menu.entry),
1005
+ onClose: closeMenu,
1006
+ })
1007
+ : null
1008
+
1009
+ // AI 智能整理弹窗(预览 + 进度双阶段;analysis 元信息随 aiPlan 透传;source 标注通道来源)
1010
+ const aiPlanEl = aiPlan
1011
+ ? React.createElement(SvnAiPlanDialog, {
1012
+ key: 'edrv-svn-ai-plan',
1013
+ plan: aiPlan.plan,
1014
+ meta: {
1015
+ entriesCount: aiPlan.entriesCount,
1016
+ diffIncluded: aiPlan.diffIncluded,
1017
+ dropped: aiPlan.dropped,
1018
+ model: aiPlan.model,
1019
+ source: aiPlan.source,
1020
+ },
1021
+ run: aiRun,
1022
+ onExecute: onAiExecute,
1023
+ onPause: aiPause,
1024
+ onResume: aiResume,
1025
+ onCancelRest: aiCancelRest,
1026
+ onClose: closeAiPlan,
1027
+ })
1028
+ : null
1029
+
431
1030
  return React.createElement('div', { className: 'edrv-side-panel' },
432
1031
  React.createElement('div', { className: 'edrv-side-head' },
433
1032
  React.createElement('span', { className: 'edrv-side-title' }, 'SVN 变更'),
434
1033
  React.createElement('span', { className: 'edrv-side-root', title: ctx?.svn?.wcRoot || '' }, rootName),
435
1034
  React.createElement('span', { style: { flex: 1 } }),
436
- React.createElement('button', {
437
- className: 'edrv-side-btn', title: '检查远端更新(svn status -u;约数秒,离线自动降级)',
438
- onClick: checkRemote,
439
- }, remoteBusy ? '…' : '⇅'),
440
- React.createElement('button', {
441
- className: 'edrv-side-btn', title: '刷新变更',
442
- onClick: () => { refreshSvnChanges(sessionId, scope); ctx?.refreshRecords?.() },
443
- }, refreshIconEl())),
1035
+ // 图标组(设计定稿:AI 功能迁至独立 dock,头部只留导航/刷新)
1036
+ React.createElement('span', { className: 'edrv-svn-head-icons' },
1037
+ React.createElement('button', {
1038
+ className: 'edrv-side-btn', title: '检查远端更新(svn status -u;约数秒,离线自动降级)',
1039
+ onClick: checkRemote,
1040
+ }, remoteBusy ? '…' : '⇅'),
1041
+ React.createElement('button', {
1042
+ className: 'edrv-side-btn', title: '刷新变更',
1043
+ onClick: () => { refreshSvnChanges(sessionId, scope); ctx?.refreshRecords?.() },
1044
+ }, refreshIconEl()))),
1045
+ aiBarEl({
1046
+ managed, busy, aiBusy, deepWaiting,
1047
+ hasRun: Boolean(aiRun),
1048
+ onQuick: runAiPlan,
1049
+ onDeep: runDeepPlan,
1050
+ }),
444
1051
  remoteEl,
1052
+ pairCapEl,
445
1053
  ...toolbar,
446
- React.createElement('div', { className: 'edrv-svn-list' }, svnListBody(listState, handlers)))
1054
+ React.createElement('div', { className: 'edrv-svn-list' }, svnListBody(listState, handlers)),
1055
+ menuEl,
1056
+ aiPlanEl)
447
1057
  }