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.
- package/README.md +16 -2
- package/cordis.patch.yml +7 -3
- package/lib/client.js +1646 -121
- package/lib/client.js.map +1 -1
- package/lib/index.js +1141 -106
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/ai/inline.ts +19 -2
- package/src/ai/svnTriage.ts +150 -0
- package/src/capture.ts +225 -51
- package/src/client/addToConversation.ts +203 -1
- package/src/client/pendingNav.ts +141 -0
- package/src/client/sidebar/panels/FileExplorer.ts +2 -15
- package/src/client/sidebar/panels/SvnPanel.ts +682 -72
- package/src/client/sidebar/panels/svnListBudget.ts +58 -0
- package/src/client/state/records.ts +7 -1
- package/src/client/styles/editor.css +42 -2
- package/src/client/svnStatus.ts +206 -1
- package/src/client/ui/AiSettings.ts +47 -14
- package/src/client/ui/DiffBox.ts +1 -1
- package/src/client/ui/DiffLauncher.ts +6 -4
- package/src/client/ui/EditorView.ts +32 -4
- package/src/client/ui/SvnAiPlanDialog.ts +293 -0
- package/src/client/ui/icons.ts +19 -0
- package/src/compat.ts +25 -4
- package/src/fileOpenSettings.ts +128 -26
- package/src/index.ts +7 -4
- package/src/model.ts +2 -1
- package/src/remoteWorkspace.ts +177 -0
- package/src/rpc.ts +19 -5
- package/src/shared/ai.ts +12 -0
- package/src/shared/rpc.ts +16 -1
- package/src/shared/svn.ts +296 -10
- package/src/shared/types.ts +5 -1
- package/src/svn.ts +235 -13
|
@@ -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
|
-
*
|
|
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/
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
*
|
|
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' +
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
574
|
+
live?.notify?.('未发现冲突副本(.mine/.rN):' + artifacts.map((item) => item.name).join('、'))
|
|
334
575
|
return
|
|
335
576
|
}
|
|
336
|
-
if (!
|
|
337
|
-
|
|
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
|
-
|
|
343
|
-
const
|
|
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
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
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 (
|
|
354
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
375
|
-
onRevert
|
|
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
|
|
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:
|
|
414
|
-
revert:
|
|
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 &&
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
}
|