dsh-vscode-mode 0.15.0 → 0.15.2
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 +5 -3
- package/lib/client.js +283 -28
- package/lib/client.js.map +1 -1
- package/lib/index.js +217 -5
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/styles/editor.css +4 -0
- package/src/client/svnNotice.ts +1 -1
- package/src/client/ui/SvnUpdateDialog.ts +143 -29
- package/src/client/ui/svnUpdateModel.ts +45 -1
- package/src/shared/rpc.ts +5 -1
- package/src/shared/svn.ts +10 -0
- package/src/svn.ts +70 -5
- package/src/svnResolve.ts +33 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-mode",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.2",
|
|
4
4
|
"description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + VS Code 兼容代码片段(.code-snippets 全局/项目,IntelliSense 展开)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ SVN 集成(侧栏变更面板/日志弹窗/并排差异)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车,界面状态(页签/光标/侧栏/编辑 Tab/导航历史)镜像到 DSH home 跨重启恢复",
|
|
5
5
|
"icon": "assets/icon.svg",
|
|
6
6
|
"keywords": [
|
|
@@ -640,6 +640,10 @@
|
|
|
640
640
|
[data-edrv-view] .edrv-update-close:not(:disabled):hover { background: var(--dsw-alias-interactive-bg-hover, #e7edf0); }
|
|
641
641
|
[data-edrv-view] .edrv-update-table { display: flex; flex-direction: column; flex: 1; min-height: 0; margin: 12px 14px 0; border: 1px solid var(--dsw-alias-border-l1, #d9e0e4); border-radius: 5px; overflow: hidden; background: var(--dsw-alias-bg-layer-1, #fff); }
|
|
642
642
|
[data-edrv-view] .edrv-update-cols, [data-edrv-view] .edrv-update-row { display: grid; grid-template-columns: 110px minmax(0, 1fr) 160px; align-items: center; }
|
|
643
|
+
[data-edrv-view] .edrv-update-sort { width: 100%; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
|
644
|
+
[data-edrv-view] .edrv-update-sort:hover { color: var(--dsw-alias-brand-primary, #2869b4); }
|
|
645
|
+
[data-edrv-view] .edrv-update-sort:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, #2869b4); outline-offset: 2px; }
|
|
646
|
+
[data-edrv-view] .edrv-update-jump { color: var(--dsw-alias-state-error-primary, #b93b43); }
|
|
643
647
|
[data-edrv-view] .edrv-update-cols { flex: none; height: 30px; background: var(--dsw-alias-bg-layer-2, #f1f4f5); border-bottom: 1px solid var(--dsw-alias-border-l1, #d9e0e4); color: var(--dsw-alias-label-secondary, #485762); font-size: 11px; font-weight: 600; }
|
|
644
648
|
[data-edrv-view] .edrv-update-cols > span, [data-edrv-view] .edrv-update-row > span { min-width: 0; padding: 0 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
645
649
|
[data-edrv-view] .edrv-update-cols > span + span, [data-edrv-view] .edrv-update-row > span + span { border-left: 1px solid var(--dsw-alias-border-l1, #d9e0e4); }
|
package/src/client/svnNotice.ts
CHANGED
|
@@ -8,7 +8,7 @@ export function bindSvnScope(sessionId: string | undefined, scope: string): void
|
|
|
8
8
|
scopes.delete(sessionId); scopes.set(sessionId, scope)
|
|
9
9
|
if (scopes.size > 512) scopes.delete(scopes.keys().next().value!)
|
|
10
10
|
}
|
|
11
|
-
const WRITES = new Set<RpcMethod>(['svn.add', 'svn.revert', 'svn.ignore', 'svn.cleanup', 'svn.update', 'svn.tortoise'])
|
|
11
|
+
const WRITES = new Set<RpcMethod>(['svn.add', 'svn.revert', 'svn.resolve', 'svn.ignore', 'svn.cleanup', 'svn.update', 'svn.tortoise'])
|
|
12
12
|
/** @public @author ddj 2026年10月10号 @param method RPC operation. @returns Whether local evidence may change (launching a writable native dialog counts, not its completion). */
|
|
13
13
|
export function isSvnWrite(method: RpcMethod): boolean { return WRITES.has(method) }
|
|
14
14
|
/** @public @author ddj 2026年10月10号 @param method Operation. @param args Typed request. @param ok Completed successfully. Notify even failed/partially applied operations; no repository access. */
|
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
* @author ddj 2026年09月24号
|
|
4
4
|
*/
|
|
5
5
|
import React from 'react'
|
|
6
|
-
import type { SvnUpdateRow, SvnUpdateState } from '../../shared/svn.js'
|
|
6
|
+
import type { SvnConflictState, SvnResolveAccept, SvnUpdateRow, SvnUpdateState } from '../../shared/svn.js'
|
|
7
7
|
import { copyText } from '../copyText.js'
|
|
8
8
|
import { rpc } from '../rpc.js'
|
|
9
9
|
import { absoluteOf } from '../tabActions.js'
|
|
10
10
|
import { ContextMenu } from './ContextMenu.js'
|
|
11
11
|
import type { ContextMenuEntry } from './ContextMenu.js'
|
|
12
12
|
import { ModalShell } from './ModalShell.js'
|
|
13
|
-
import { updateActs, updateCloseMode, updateDone, updateLocked, updateParent, updateRange, updateReady, updateType } from './svnUpdateModel.js'
|
|
13
|
+
import { nextConflict, nextUpdateSort, rowConflict, sortUpdateRows, updateActs, updateCloseMode, updateDone, updateLocked, updateParent, updateRange, updateReady, updateType } from './svnUpdateModel.js'
|
|
14
|
+
import type { UpdateColumn, UpdateSort } from './svnUpdateModel.js'
|
|
14
15
|
|
|
15
16
|
interface UpdateProps {
|
|
16
17
|
sessionId?: string
|
|
@@ -28,6 +29,68 @@ interface UpdateProps {
|
|
|
28
29
|
onOpenParent: (path: string) => void
|
|
29
30
|
}
|
|
30
31
|
interface UpdateMenu { x: number; y: number; row: SvnUpdateRow; kind: string }
|
|
32
|
+
type ConflictChoice = SvnResolveAccept | 'revert'
|
|
33
|
+
const conflictWarnings: Record<ConflictChoice, string> = {
|
|
34
|
+
'theirs-full': '使用对方完整版本,丢弃该目标本地冲突修改?',
|
|
35
|
+
'mine-full': '使用更新前自身完整版本,丢弃对方修改以及更新后编辑?这不是当前 working 内容。',
|
|
36
|
+
working: '仅标记为已解决,保留当前内容。SVN 不会自动清除文件内冲突标记;树冲突必须先手动处理。确定?',
|
|
37
|
+
revert: '还原此目标到当前 BASE,丢弃本地修改和冲突状态?这不是回到更新前版本;目录不递归。',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @private @author ddj 2026年10月14号 @param props Owner. @param paths Unique exact targets. @returns Evidence and bounded refresh action. */
|
|
41
|
+
function useConflictState(props: UpdateProps, paths: string[], ready: boolean) {
|
|
42
|
+
const [states, setStates] = React.useState<Map<string, SvnConflictState>>(new Map())
|
|
43
|
+
const [error, setError] = React.useState('')
|
|
44
|
+
const [loading, setLoading] = React.useState(false)
|
|
45
|
+
const generation = React.useRef(0)
|
|
46
|
+
/** @private @author ddj 2026年10月14号 @returns Fresh evidence; failed targets remain unknown rather than clean. */
|
|
47
|
+
const refresh = React.useCallback(async () => {
|
|
48
|
+
const token = ++generation.current
|
|
49
|
+
setLoading(true)
|
|
50
|
+
const evidence = new Map<string, SvnConflictState>()
|
|
51
|
+
const errors: string[] = []
|
|
52
|
+
for (let start = 0; start < paths.length; start += 100) {
|
|
53
|
+
try {
|
|
54
|
+
const result = await rpc('svn.conflictStatus', { sessionId: props.sessionId, paths: paths.slice(start, start + 100) })
|
|
55
|
+
if (result.ok) result.states.forEach((state) => evidence.set(state.path, state))
|
|
56
|
+
else errors.push(result.error)
|
|
57
|
+
} catch (cause) { errors.push(String(cause)) }
|
|
58
|
+
}
|
|
59
|
+
if (token !== generation.current) return
|
|
60
|
+
setStates(evidence); setError(errors.join('\n')); setLoading(false)
|
|
61
|
+
}, [props.sessionId, paths])
|
|
62
|
+
React.useEffect(() => {
|
|
63
|
+
setStates(new Map()); setError('')
|
|
64
|
+
if (ready) void refresh()
|
|
65
|
+
return () => { generation.current++ }
|
|
66
|
+
}, [ready, refresh])
|
|
67
|
+
return { states, error, refresh, setStates, loading }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @private @author ddj 2026年10月14号 @param props Owner. @param ready Complete output. @param evidence Conflict reader. @returns Single-target confirmed mutation state and command. */
|
|
71
|
+
function useConflictWrite(props: UpdateProps, ready: boolean, evidence: ReturnType<typeof useConflictState>) {
|
|
72
|
+
const [busy, setBusy] = React.useState(false)
|
|
73
|
+
const lock = React.useRef(false)
|
|
74
|
+
/** @private @author ddj 2026年10月14号 @param row Right-click target only. @param choice Explicit SVN operation. Execute once and always re-read status. */
|
|
75
|
+
const execute = async (row: SvnUpdateRow, choice: ConflictChoice) => {
|
|
76
|
+
if (lock.current || !ready || evidence.loading || !row.relPath) return
|
|
77
|
+
const state = evidence.states.get(row.relPath)
|
|
78
|
+
if (!state || !(state.text || state.props || state.tree)) return
|
|
79
|
+
if (state.tree && (choice === 'mine-full' || choice === 'theirs-full')) return
|
|
80
|
+
lock.current = true
|
|
81
|
+
try {
|
|
82
|
+
if (!window.confirm(conflictWarnings[choice] + '\n\n仅处理:' + row.relPath)) { lock.current = false; return }
|
|
83
|
+
setBusy(true)
|
|
84
|
+
evidence.setStates((previous) => { const next = new Map(previous); next.delete(row.relPath!); return next })
|
|
85
|
+
const result = choice === 'revert'
|
|
86
|
+
? await rpc('svn.revert', { sessionId: props.sessionId, paths: [row.relPath], depth: 'empty' })
|
|
87
|
+
: await rpc('svn.resolve', { sessionId: props.sessionId, path: row.relPath, accept: choice })
|
|
88
|
+
props.onNote(result.ok ? result.summary : result.error)
|
|
89
|
+
} catch (cause) { props.onNote('冲突操作失败:' + String(cause)) }
|
|
90
|
+
finally { if (lock.current) { await evidence.refresh(); lock.current = false; setBusy(false) } }
|
|
91
|
+
}
|
|
92
|
+
return { busy, execute }
|
|
93
|
+
}
|
|
31
94
|
|
|
32
95
|
/**
|
|
33
96
|
* 仅恢复明确匹配的原任务;失败时不重新触发一次 SVN 更新。
|
|
@@ -159,10 +222,22 @@ function countText(state: SvnUpdateState | null): string {
|
|
|
159
222
|
* @param selected 已选序号
|
|
160
223
|
* @returns 可点击动作列表
|
|
161
224
|
*/
|
|
162
|
-
function menuEntries(props: UpdateProps, state: SvnUpdateState, menu: UpdateMenu, rows: SvnUpdateRow[], selected: number[]
|
|
225
|
+
function menuEntries(props: UpdateProps, state: SvnUpdateState, menu: UpdateMenu, rows: SvnUpdateRow[], selected: number[],
|
|
226
|
+
ready: boolean, evidence: ReturnType<typeof useConflictState>, write: ReturnType<typeof useConflictWrite>): ContextMenuEntry[] {
|
|
163
227
|
const { row, kind } = menu
|
|
164
228
|
const actions = updateActs(row, state.phase, kind, state.revision)
|
|
165
229
|
const rel = row.relPath ?? ''
|
|
230
|
+
const conflict = rel ? evidence.states.get(rel) : undefined
|
|
231
|
+
const enabled = ready && !write.busy && !evidence.loading && Boolean(conflict && (conflict.text || conflict.props || conflict.tree))
|
|
232
|
+
const full = enabled && !conflict?.tree
|
|
233
|
+
const conflictEntries: ContextMenuEntry[] = row.action === 'C' ? [
|
|
234
|
+
{ id: 'theirs-full', label: '使用对方完整版本解决', separator: true, disabled: !full, onClick: () => { void write.execute(row, 'theirs-full') } },
|
|
235
|
+
{ id: 'mine-full', label: '使用自身完整版本解决', disabled: !full, onClick: () => { void write.execute(row, 'mine-full') } },
|
|
236
|
+
{ id: 'working', label: '标记为已解决(保留当前内容)', disabled: !enabled, onClick: () => { void write.execute(row, 'working') } },
|
|
237
|
+
{ id: 'revert', label: '还原此项…', disabled: !enabled, onClick: () => { void write.execute(row, 'revert') } },
|
|
238
|
+
...(conflict?.tree ? [{ id: 'tree-hint', label: '树冲突:请先针对性处理,再标记解决', disabled: true, onClick: () => {} }] : []),
|
|
239
|
+
...(!conflict && ready ? [{ id: 'unknown-hint', label: '状态未确认:请刷新冲突状态', disabled: true, onClick: () => {} }] : []),
|
|
240
|
+
] : []
|
|
166
241
|
const picked = new Set(selected)
|
|
167
242
|
const names = rows.filter((item) => picked.has(item.seq)).map((item) => item.relPath
|
|
168
243
|
? absoluteOf(item.relPath, props.cwd) : item.path)
|
|
@@ -172,6 +247,7 @@ function menuEntries(props: UpdateProps, state: SvnUpdateState, menu: UpdateMenu
|
|
|
172
247
|
{ id: 'open', label: '打开', disabled: !actions.open, separator: true, onClick: () => props.onOpenFile(rel) },
|
|
173
248
|
{ id: 'open-with', label: '打开方式…', disabled: !actions.openWith, onClick: () => props.onOpenWith(rel) },
|
|
174
249
|
{ id: 'parent', label: '打开父目录', disabled: !actions.parent, onClick: () => props.onOpenParent(updateParent(rel)) },
|
|
250
|
+
...conflictEntries,
|
|
175
251
|
{ id: 'copy', label: selected.length > 1 ? '复制选中路径' : '复制路径', disabled: !actions.copy, separator: true,
|
|
176
252
|
onClick: () => { void copyText(names.join('\n'), '已复制路径', props.onNote) } },
|
|
177
253
|
]
|
|
@@ -189,7 +265,7 @@ function menuEntries(props: UpdateProps, state: SvnUpdateState, menu: UpdateMenu
|
|
|
189
265
|
*/
|
|
190
266
|
function rowElement(row: SvnUpdateRow, cwd: string | null | undefined, picked: boolean,
|
|
191
267
|
onPick: (event: React.MouseEvent<HTMLDivElement>) => void,
|
|
192
|
-
onMenu: (event: React.MouseEvent<HTMLDivElement>) => void): React.ReactElement {
|
|
268
|
+
onMenu: (event: React.MouseEvent<HTMLDivElement>) => void, resolved: boolean): React.ReactElement {
|
|
193
269
|
// 显示用相对路径(窄窗省略尾部时先保住文件名),悬浮与复制仍用完整绝对路径。
|
|
194
270
|
const full = row.relPath ? absoluteOf(row.relPath, cwd) : (row.path || row.label)
|
|
195
271
|
const path = row.relPath || row.path || row.label
|
|
@@ -199,7 +275,7 @@ function rowElement(row: SvnUpdateRow, cwd: string | null | undefined, picked: b
|
|
|
199
275
|
role: 'row', 'aria-selected': picked, className: 'edrv-update-row' + (picked ? ' is-selected' : ''),
|
|
200
276
|
onClick: onPick, onContextMenu: onMenu, title: row.raw,
|
|
201
277
|
},
|
|
202
|
-
React.createElement('span', { role: 'cell', className: 'edrv-update-action tone-' + tone }, row.action ? row.label : '信息'),
|
|
278
|
+
React.createElement('span', { role: 'cell', className: 'edrv-update-action tone-' + tone }, resolved ? row.label + '(已解决)' : row.action ? row.label : '信息'),
|
|
203
279
|
React.createElement('span', { role: 'cell', className: 'edrv-update-path', title: full }, path),
|
|
204
280
|
React.createElement('span', { role: 'cell', className: 'edrv-update-type' }, row.action ? updateType(row.path) : ''))
|
|
205
281
|
}
|
|
@@ -210,7 +286,7 @@ function rowElement(row: SvnUpdateRow, cwd: string | null | undefined, picked: b
|
|
|
210
286
|
* @param count 当前记录数
|
|
211
287
|
* @returns 虚拟列表视口、行高和滚动回调
|
|
212
288
|
*/
|
|
213
|
-
function useUpdateView(count: number) {
|
|
289
|
+
function useUpdateView(count: number, sorted: boolean) {
|
|
214
290
|
const [scrollTop, setScrollTop] = React.useState(0)
|
|
215
291
|
const [height, setHeight] = React.useState(350)
|
|
216
292
|
const [compact, setCompact] = React.useState(() => window.innerWidth < 620)
|
|
@@ -229,8 +305,8 @@ function useUpdateView(count: number) {
|
|
|
229
305
|
}, [])
|
|
230
306
|
React.useLayoutEffect(() => {
|
|
231
307
|
const list = listRef.current
|
|
232
|
-
if (list && followRef.current) { list.scrollTop = list.scrollHeight; setScrollTop(list.scrollTop) }
|
|
233
|
-
}, [count, rowHeight])
|
|
308
|
+
if (list && followRef.current && !sorted) { list.scrollTop = list.scrollHeight; setScrollTop(list.scrollTop) }
|
|
309
|
+
}, [count, rowHeight, sorted])
|
|
234
310
|
/**
|
|
235
311
|
* 用户离开底部后暂停自动滚动,保持当前文件可读。
|
|
236
312
|
* @author ddj 2026年09月24号
|
|
@@ -241,7 +317,15 @@ function useUpdateView(count: number) {
|
|
|
241
317
|
followRef.current = list.scrollHeight - list.scrollTop - list.clientHeight < 40
|
|
242
318
|
setScrollTop(list.scrollTop)
|
|
243
319
|
}
|
|
244
|
-
|
|
320
|
+
/** @private @author ddj 2026年10月14号 @param index Display index. Scroll without requiring a mounted virtual row. */
|
|
321
|
+
const locate = (index: number) => {
|
|
322
|
+
const list = listRef.current
|
|
323
|
+
if (!list || index < 0) return
|
|
324
|
+
followRef.current = false
|
|
325
|
+
list.scrollTop = index * rowHeight
|
|
326
|
+
setScrollTop(list.scrollTop)
|
|
327
|
+
}
|
|
328
|
+
return { listRef, rowHeight, range: updateRange(count, scrollTop, height, rowHeight), onScroll, locate }
|
|
245
329
|
}
|
|
246
330
|
|
|
247
331
|
/**
|
|
@@ -266,14 +350,15 @@ function useUpdateMenu(props: UpdateProps, rows: SvnUpdateRow[], state: SvnUpdat
|
|
|
266
350
|
const pick = (event: React.MouseEvent<HTMLDivElement>, row: SvnUpdateRow) => {
|
|
267
351
|
const index = rows.findIndex((item) => item.seq === row.seq)
|
|
268
352
|
if (event.shiftKey) {
|
|
269
|
-
const
|
|
270
|
-
const
|
|
353
|
+
const anchor = rows.findIndex((item) => item.seq === anchorRef.current)
|
|
354
|
+
const from = Math.min(index, anchor < 0 ? index : anchor)
|
|
355
|
+
const to = Math.max(index, anchor < 0 ? index : anchor)
|
|
271
356
|
setSelected(rows.slice(from, to + 1).map((item) => item.seq))
|
|
272
357
|
} else if (event.ctrlKey || event.metaKey) {
|
|
273
358
|
setSelected((previous) => previous.includes(row.seq)
|
|
274
359
|
? previous.filter((seq) => seq !== row.seq) : previous.concat(row.seq))
|
|
275
|
-
anchorRef.current =
|
|
276
|
-
} else { setSelected([row.seq]); anchorRef.current =
|
|
360
|
+
anchorRef.current = row.seq
|
|
361
|
+
} else { setSelected([row.seq]); anchorRef.current = row.seq }
|
|
277
362
|
}
|
|
278
363
|
/**
|
|
279
364
|
* 先开放父目录/复制,再按真实磁盘类型启用文件级操作。
|
|
@@ -293,7 +378,9 @@ function useUpdateMenu(props: UpdateProps, rows: SvnUpdateRow[], state: SvnUpdat
|
|
|
293
378
|
? { ...current, kind: outcome.ok ? outcome.kind : 'missing' } : current)
|
|
294
379
|
}).catch(() => {})
|
|
295
380
|
}
|
|
296
|
-
|
|
381
|
+
/** @private @author ddj 2026年10月14号 @param row Navigation target. Select one stable row identity. */
|
|
382
|
+
const selectRow = (row: SvnUpdateRow) => { setSelected([row.seq]); anchorRef.current = row.seq }
|
|
383
|
+
return { selected, picked, menu, setMenu, pick, openMenu, selectRow }
|
|
297
384
|
}
|
|
298
385
|
|
|
299
386
|
/**
|
|
@@ -329,13 +416,18 @@ function updateHead(props: UpdateProps, state: SvnUpdateState | null, phase: str
|
|
|
329
416
|
* @returns 表格元素
|
|
330
417
|
*/
|
|
331
418
|
function updateTable(props: UpdateProps, rows: SvnUpdateRow[], view: ReturnType<typeof useUpdateView>,
|
|
332
|
-
selection: ReturnType<typeof useUpdateMenu>, finished: boolean
|
|
419
|
+
selection: ReturnType<typeof useUpdateMenu>, finished: boolean, sort: UpdateSort | null,
|
|
420
|
+
onSort: (column: UpdateColumn) => void, states: ReadonlyMap<string, SvnConflictState>): React.ReactElement {
|
|
333
421
|
const visible = rows.slice(view.range.start, view.range.end)
|
|
334
422
|
return React.createElement('div', { className: 'edrv-update-table', role: 'table', 'aria-label': 'SVN 更新文件明细', 'aria-rowcount': rows.length },
|
|
335
423
|
React.createElement('div', { role: 'row', className: 'edrv-update-cols' },
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
424
|
+
(['action', 'path', 'type'] as const).map((column) => {
|
|
425
|
+
const direction = sort?.column === column ? sort.direction : 0
|
|
426
|
+
const label = column === 'action' ? '操作' : column === 'path' ? '路径' : '类型(推断)'
|
|
427
|
+
return React.createElement('span', { key: column, role: 'columnheader', 'aria-sort': direction === 1 ? 'ascending' : direction === -1 ? 'descending' : 'none' },
|
|
428
|
+
React.createElement('button', { type: 'button', className: 'edrv-update-sort', onClick: () => onSort(column),
|
|
429
|
+
title: '点击切换升序、降序、原始顺序' + (column === 'type' ? ';仅推断类型,非 SVN 属性' : '') }, label + (direction === 1 ? ' ▲' : direction === -1 ? ' ▼' : ' ↕')))
|
|
430
|
+
})),
|
|
339
431
|
React.createElement('div', { className: 'edrv-update-list', ref: view.listRef, role: 'rowgroup', onScroll: view.onScroll },
|
|
340
432
|
React.createElement('div', { className: 'edrv-update-space', style: { height: rows.length * view.rowHeight } },
|
|
341
433
|
visible.map((row, index) => React.createElement('div', {
|
|
@@ -343,7 +435,8 @@ function updateTable(props: UpdateProps, rows: SvnUpdateRow[], view: ReturnType<
|
|
|
343
435
|
style: { top: (view.range.start + index) * view.rowHeight, height: view.rowHeight },
|
|
344
436
|
'aria-rowindex': view.range.start + index + 1,
|
|
345
437
|
}, rowElement(row, props.cwd, selection.picked.has(row.seq),
|
|
346
|
-
(event) => selection.pick(event, row), (event) => selection.openMenu(event, row)
|
|
438
|
+
(event) => selection.pick(event, row), (event) => selection.openMenu(event, row),
|
|
439
|
+
row.action === 'C' && !rowConflict(row, states))))),
|
|
347
440
|
!rows.length ? React.createElement('div', { className: 'edrv-update-empty' },
|
|
348
441
|
finished ? '没有逐文件变更' : '等待 SVN 输出…') : null))
|
|
349
442
|
}
|
|
@@ -385,10 +478,12 @@ function updateStatus(state: SvnUpdateState | null, error: string, cleaned: bool
|
|
|
385
478
|
*/
|
|
386
479
|
function updateFoot(props: UpdateProps, state: SvnUpdateState | null, closable: boolean,
|
|
387
480
|
finished: boolean, cancel: () => void, dismiss: () => void, confirmClose: boolean,
|
|
388
|
-
cleaned: boolean, cleanup: () => void): React.ReactElement {
|
|
481
|
+
cleaned: boolean, cleanup: () => void, remaining: number, jump: () => void, refresh: () => void): React.ReactElement {
|
|
389
482
|
const locked = updateLocked(state)
|
|
390
483
|
return React.createElement('footer', { className: 'edrv-update-foot' },
|
|
391
484
|
React.createElement('span', { className: 'edrv-update-counts' }, countText(state)),
|
|
485
|
+
remaining > 0 ? React.createElement('button', { type: 'button', className: 'edrv-svn-act edrv-update-jump', onClick: jump }, '跳转冲突(' + remaining + ')') : null,
|
|
486
|
+
state?.counts.C ? React.createElement('button', { type: 'button', className: 'edrv-svn-act', disabled: !finished, onClick: refresh }, '刷新冲突状态') : null,
|
|
392
487
|
locked ? React.createElement('button', {
|
|
393
488
|
type: 'button', className: 'edrv-svn-act edrv-update-cleanup', disabled: !finished || cleaned,
|
|
394
489
|
title: locked && !cleaned ? '执行 svn cleanup,清除中断残留的锁(不撤销已完成的更新)' : '已执行过清理',
|
|
@@ -410,12 +505,29 @@ function updateFoot(props: UpdateProps, state: SvnUpdateState | null, closable:
|
|
|
410
505
|
export function SvnUpdateDialog(props: UpdateProps): React.ReactElement {
|
|
411
506
|
const { state, rows, error, pendingRows, setState } = useUpdateJob(props)
|
|
412
507
|
const [cleaned, setCleaned] = React.useState(false)
|
|
413
|
-
const
|
|
414
|
-
const
|
|
508
|
+
const [sort, setSort] = React.useState<UpdateSort | null>(null)
|
|
509
|
+
const displayRows = React.useMemo(() => sortUpdateRows(rows, sort), [rows, sort])
|
|
510
|
+
const view = useUpdateView(displayRows.length, Boolean(sort))
|
|
511
|
+
const selection = useUpdateMenu(props, displayRows, state)
|
|
415
512
|
const processFinished = updateDone(state?.phase)
|
|
416
513
|
const finished = updateReady(state?.phase, pendingRows)
|
|
417
|
-
const
|
|
418
|
-
const
|
|
514
|
+
const conflictPaths = React.useMemo(() => [...new Set(rows.filter((row) => row.action === 'C' && row.relPath).map((row) => row.relPath!))], [rows])
|
|
515
|
+
const evidence = useConflictState(props, conflictPaths, finished)
|
|
516
|
+
const write = useConflictWrite(props, finished, evidence)
|
|
517
|
+
const remaining = new Set(rows.filter((row) => rowConflict(row, evidence.states)).map((row) => row.relPath || row.path)).size
|
|
518
|
+
const outside = rows.some((row) => row.action === 'C' && !row.relPath)
|
|
519
|
+
const closeMode = updateCloseMode(finished, error, Boolean(state), Boolean(selection.menu || props.childOverlay || write.busy))
|
|
520
|
+
const closable = closeMode.explicit && !write.busy
|
|
521
|
+
/** @private @author ddj 2026年10月14号 @param column Header identity. Cycle cached display ordering. */
|
|
522
|
+
const onSort = (column: UpdateColumn) => setSort((previous) => nextUpdateSort(previous, column))
|
|
523
|
+
/** @private @author ddj 2026年10月14号 Select and scroll to the next unresolved row in display order. */
|
|
524
|
+
const jump = () => {
|
|
525
|
+
const index = nextConflict(displayRows, evidence.states, selection.selected.at(-1))
|
|
526
|
+
if (index < 0) return
|
|
527
|
+
selection.selectRow(displayRows[index]); view.locate(index)
|
|
528
|
+
}
|
|
529
|
+
/** @private @author ddj 2026年10月14号 Refresh exact targets after an external resolution. */
|
|
530
|
+
const refresh = () => { if (!write.busy && finished) void evidence.refresh() }
|
|
419
531
|
/**
|
|
420
532
|
* 确认后请求终止任务;已变更文件不会自动回滚。
|
|
421
533
|
* @author ddj 2026年09月24号
|
|
@@ -445,7 +557,7 @@ export function SvnUpdateDialog(props: UpdateProps): React.ReactElement {
|
|
|
445
557
|
* @author ddj 2026年09月24号
|
|
446
558
|
*/
|
|
447
559
|
const dismiss = () => {
|
|
448
|
-
if (!
|
|
560
|
+
if (!closable) return
|
|
449
561
|
if (closeMode.confirm && !window.confirm('更新任务连接中断。关闭窗口不会停止可能仍在运行的 SVN 更新,确定暂时关闭?')) return
|
|
450
562
|
props.onClose()
|
|
451
563
|
}
|
|
@@ -458,15 +570,17 @@ export function SvnUpdateDialog(props: UpdateProps): React.ReactElement {
|
|
|
458
570
|
closeOnEsc: closeMode.passive, closeOnMask: closeMode.passive, onClose: dismiss,
|
|
459
571
|
},
|
|
460
572
|
updateHead(props, state, phase, closable, dismiss),
|
|
461
|
-
updateTable(props,
|
|
462
|
-
updateStatus(state, error, cleaned),
|
|
463
|
-
|
|
573
|
+
updateTable(props, displayRows, view, selection, finished, sort, onSort, evidence.states),
|
|
574
|
+
updateStatus(state, error || evidence.error, cleaned),
|
|
575
|
+
outside ? React.createElement('div', { className: 'edrv-update-warning' }, '存在工作区外冲突;可定位但不能在此执行解决操作') : null,
|
|
576
|
+
write.busy ? React.createElement('div', { role: 'status' }, '正在处理冲突并核对 SVN 状态…') : null,
|
|
577
|
+
updateFoot(props, state, closable, finished && !write.busy, cancel, dismiss, closeMode.confirm, cleaned, cleanup, remaining, jump, refresh),
|
|
464
578
|
state?.rawTail && (state.phase === 'failed' || state.truncated)
|
|
465
579
|
? React.createElement('details', { className: 'edrv-update-raw' },
|
|
466
580
|
React.createElement('summary', null, '查看原始输出'), React.createElement('pre', null, state.rawTail)) : null,
|
|
467
581
|
selection.menu && state ? React.createElement(ContextMenu, {
|
|
468
582
|
x: selection.menu.x, y: selection.menu.y,
|
|
469
|
-
entries: menuEntries(props, state, selection.menu,
|
|
583
|
+
entries: menuEntries(props, state, selection.menu, displayRows, selection.selected, finished, evidence, write), zIndex: 193,
|
|
470
584
|
onClose: () => selection.setMenu(null),
|
|
471
585
|
}) : null)
|
|
472
586
|
}
|
|
@@ -2,7 +2,51 @@
|
|
|
2
2
|
* dsh-vscode-mode client — SVN 更新窗口的纯视图逻辑。
|
|
3
3
|
* @author ddj 2026年09月24号
|
|
4
4
|
*/
|
|
5
|
-
import type { SvnUpdatePhase, SvnUpdateRow } from '../../shared/svn.js'
|
|
5
|
+
import type { SvnConflictState, SvnUpdatePhase, SvnUpdateRow } from '../../shared/svn.js'
|
|
6
|
+
|
|
7
|
+
export type UpdateColumn = 'action' | 'path' | 'type'
|
|
8
|
+
export interface UpdateSort { column: UpdateColumn; direction: 1 | -1 }
|
|
9
|
+
const collator = new Intl.Collator('zh-CN', { numeric: true, sensitivity: 'base' })
|
|
10
|
+
|
|
11
|
+
/** @public @author ddj 2026年10月14号 @param sort Current sort. @param column Clicked header. @returns Ascending, descending, then original output order. */
|
|
12
|
+
export function nextUpdateSort(sort: UpdateSort | null, column: UpdateColumn): UpdateSort | null {
|
|
13
|
+
if (sort?.column !== column) return { column, direction: 1 }
|
|
14
|
+
return sort.direction === 1 ? { column, direction: -1 } : null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** @public @author ddj 2026年10月14号 @param row Output row. @param column Display column. @returns Display sort key. */
|
|
18
|
+
function updateKey(row: SvnUpdateRow, column: UpdateColumn): string {
|
|
19
|
+
if (column === 'action') return row.action ? row.label : '信息'
|
|
20
|
+
if (column === 'type') return row.action ? updateType(row.path) : ''
|
|
21
|
+
return row.relPath || row.path || row.label
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @public @author ddj 2026年10月14号 @param rows Immutable original rows. @param sort Optional sort. @returns Stable derived display order, empty keys last. */
|
|
25
|
+
export function sortUpdateRows(rows: SvnUpdateRow[], sort: UpdateSort | null): SvnUpdateRow[] {
|
|
26
|
+
if (!sort) return rows
|
|
27
|
+
return rows.slice().sort((a, b) => {
|
|
28
|
+
const left = updateKey(a, sort.column), right = updateKey(b, sort.column)
|
|
29
|
+
if (!left || !right) return left ? -1 : right ? 1 : a.seq - b.seq
|
|
30
|
+
return collator.compare(left, right) * sort.direction || a.seq - b.seq
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @public @author ddj 2026年10月14号 @param row Output row. @param states Confirmed evidence by path. @returns Conservative unresolved status, including unknown/outside paths. */
|
|
35
|
+
export function rowConflict(row: SvnUpdateRow, states: ReadonlyMap<string, SvnConflictState>): boolean {
|
|
36
|
+
if (row.action !== 'C') return false
|
|
37
|
+
const state = row.relPath ? states.get(row.relPath) : undefined
|
|
38
|
+
return state ? state.text || state.props || state.tree : true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @public @author ddj 2026年10月14号 @param rows Display order. @param states Confirmed evidence. @param seq Current selection. @returns Next conflict index, wrapping around. */
|
|
42
|
+
export function nextConflict(rows: SvnUpdateRow[], states: ReadonlyMap<string, SvnConflictState>, seq?: number): number {
|
|
43
|
+
const start = rows.findIndex((row) => row.seq === seq)
|
|
44
|
+
for (let step = 1; step <= rows.length; step++) {
|
|
45
|
+
const index = (start + step) % rows.length
|
|
46
|
+
if (rowConflict(rows[index], states)) return index
|
|
47
|
+
}
|
|
48
|
+
return -1
|
|
49
|
+
}
|
|
6
50
|
|
|
7
51
|
/**
|
|
8
52
|
* 只有真实终态允许直接关闭窗口。
|
package/src/shared/rpc.ts
CHANGED
|
@@ -468,7 +468,9 @@ export interface RpcRequestMap {
|
|
|
468
468
|
'svn.explorerStatus': { sessionId?: string; force?: boolean }
|
|
469
469
|
'svn.explorerView': SvnViewRequest
|
|
470
470
|
'svn.diffBase': { sessionId?: string; path: string }
|
|
471
|
-
'svn.revert': { sessionId?: string; paths: string[] }
|
|
471
|
+
'svn.revert': { sessionId?: string; paths: string[]; depth?: 'empty' }
|
|
472
|
+
'svn.conflictStatus': { sessionId?: string; paths: string[] }
|
|
473
|
+
'svn.resolve': { sessionId?: string; path: string; accept: import('./svn.js').SvnResolveAccept }
|
|
472
474
|
'svn.add': { sessionId?: string; paths: string[] }
|
|
473
475
|
'svn.changelist': { sessionId?: string; paths: string[]; name?: string; remove?: boolean }
|
|
474
476
|
'svn.log': { sessionId?: string; path?: string; limit?: number; stopOnCopy?: boolean; startRev?: number; endRev?: number; showMerged?: boolean }
|
|
@@ -631,6 +633,8 @@ export interface RpcOkMap {
|
|
|
631
633
|
'svn.explorerView': SvnViewPayload
|
|
632
634
|
'svn.diffBase': { base: string | null; working: string; reason?: string; error?: string; binary?: boolean; encodingHint?: boolean }
|
|
633
635
|
'svn.revert': { count: number; summary: string; output: string }
|
|
636
|
+
'svn.conflictStatus': { states: import('./svn.js').SvnConflictState[] }
|
|
637
|
+
'svn.resolve': { summary: string; output: string }
|
|
634
638
|
'svn.add': { count: number; summary: string; output: string }
|
|
635
639
|
'svn.changelist': { count: number; summary: string; output: string }
|
|
636
640
|
'svn.log': { entries: SvnLogEntry[]; truncated: boolean; target: string; limit?: number }
|
package/src/shared/svn.ts
CHANGED
|
@@ -137,6 +137,16 @@ export interface SvnUpdatePoll extends SvnUpdateState {
|
|
|
137
137
|
totalSeq: number
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/** Exact target conflict evidence; null/error is never a clean status. */
|
|
141
|
+
export interface SvnConflictState {
|
|
142
|
+
path: string
|
|
143
|
+
text: boolean
|
|
144
|
+
props: boolean
|
|
145
|
+
tree: boolean
|
|
146
|
+
versioned: boolean
|
|
147
|
+
}
|
|
148
|
+
export type SvnResolveAccept = 'mine-full' | 'theirs-full' | 'working'
|
|
149
|
+
|
|
140
150
|
/** revert/add 等批量动作结果(计数 + 摘要 + 原文)。 */
|
|
141
151
|
export interface SvnActionResult {
|
|
142
152
|
/** 处理条目数(宽松计数)。 */
|
package/src/svn.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { attrOf, numAttrOf, scanXmlTags, textOf, unescapeXml } from './svnXml.js
|
|
|
19
19
|
import { TEXT_OUTPUT_CAP, batchResultOf, tailOfText, updateResultOf } from './svnText.js'
|
|
20
20
|
import { LOG_CAP, LOG_DEFAULT_LIMIT, SVN_LOG_SHOW_ALL_CAP, parseLogXml, parseSvnInfoRevision } from './svnLog.js'
|
|
21
21
|
import { SvnUpdateJobs } from './svnUpdateJob.js'
|
|
22
|
+
import { conflictOf, hasConflict, pegTarget, validAccept, validTarget } from './svnResolve.js'
|
|
22
23
|
import { EXPLORER_CAP, explorerViewOf, scopeEntriesOf, statusXmlValid, viewArgsValid } from './svnExplorer.js'
|
|
23
24
|
import { ExplorerScopes, explorerKey } from './svnExplorerCache.js'
|
|
24
25
|
import type { ExplorerScan } from './svnExplorerCache.js'
|
|
@@ -503,6 +504,14 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
|
|
|
503
504
|
const now = deps.now ?? (() => Date.now())
|
|
504
505
|
const findRoot = deps.findRoot ?? ((cwd: string) => findSvnRoot(cwd))
|
|
505
506
|
const findTortoise = deps.findTortoise ?? ((dir: string) => findTortoiseProc(dir))
|
|
507
|
+
const conflictWrites = new Set<string>()
|
|
508
|
+
/** @private @author ddj 2026年10月14号 @param root WC root. @returns Canonical lock identity across sessions. */
|
|
509
|
+
const writeKey = (root: string): string => (deps.platform ?? process.platform) === 'win32' ? root.toLowerCase() : root
|
|
510
|
+
/** @private @author ddj 2026年10月14号 @param root WC root. @returns Whether a live update or conflict write owns the WC. */
|
|
511
|
+
const writeBusy = (root: string): boolean => {
|
|
512
|
+
const job = updateJobs.active(root)
|
|
513
|
+
return conflictWrites.has(writeKey(root)) || Boolean(job && ['starting', 'running', 'cancelling'].includes(job.phase))
|
|
514
|
+
}
|
|
506
515
|
const statusCache = new Map<string, { at: number; payload: SvnStatusPayload }>()
|
|
507
516
|
/** 运行中更新按会话目录复用工作副本根;TTL 到期才重新扫描 .svn。 */
|
|
508
517
|
const updateRootCache = new Map<string, { at: number; root: string }>()
|
|
@@ -622,7 +631,7 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
|
|
|
622
631
|
* @param stdoutCap stdout 收集上限(缺省 1MB;大载荷场景如日志拉取需显式放大)
|
|
623
632
|
* @returns 退出码与输出
|
|
624
633
|
*/
|
|
625
|
-
const runSvn = async (cwd: string, argv: string[], graceMs: number, stdoutCap: number = OUTPUT_CAP): Promise<SvnRunOutcome> => {
|
|
634
|
+
const runSvn = async (cwd: string, argv: string[], graceMs: number, stdoutCap: number = OUTPUT_CAP, allowFallback = true): Promise<SvnRunOutcome> => {
|
|
626
635
|
const spawn = subprocess()
|
|
627
636
|
if (!spawn) throw new Error('缺少 subprocess 服务')
|
|
628
637
|
const fs = ctx.get('fs') as { resolve?: (p: string, o: object) => Promise<unknown>; processPath?: (t: unknown) => string } | null | undefined
|
|
@@ -643,7 +652,8 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
|
|
|
643
652
|
const stdout = handle.collected?.stdout?.readFrom(0).text ?? ''
|
|
644
653
|
const stderr = handle.collected?.stderr?.readFrom(0).text ?? ''
|
|
645
654
|
return { code: outcome?.exitCode ?? outcome?.code ?? null, stdout, stderr }
|
|
646
|
-
} catch {
|
|
655
|
+
} catch (error) {
|
|
656
|
+
if (!allowFallback) throw error
|
|
647
657
|
// 受管环境管道 EPERM:回退 inherit(只拿退出码,输出为空)
|
|
648
658
|
const handle = attempt('inherit')
|
|
649
659
|
const outcome = await handle.done
|
|
@@ -651,6 +661,15 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
|
|
|
651
661
|
}
|
|
652
662
|
}
|
|
653
663
|
|
|
664
|
+
/** @private @author ddj 2026年10月14号 @param cwd Session workspace. @param root WC root. @param path Validated target. @returns Exact conflict evidence without recursive scanning. */
|
|
665
|
+
const readConflict = async (cwd: string, root: string, path: string) => {
|
|
666
|
+
const target = svnPathOf(cwd, root, path)
|
|
667
|
+
if (!target) throw new Error('目标不在 SVN 工作副本内')
|
|
668
|
+
const result = await runSvn(root, ['status', '--xml', '--verbose', '--depth', 'empty', '--', pegTarget(target)], READ_GRACE_MS, OUTPUT_CAP, false)
|
|
669
|
+
if (result.code !== 0) throw new Error(result.stderr || result.stdout || 'SVN 状态读取失败')
|
|
670
|
+
return conflictOf(result.stdout, path)
|
|
671
|
+
}
|
|
672
|
+
|
|
654
673
|
/** 发射 TortoiseProc(GUI 长驻:不 await done,no-op catch 防未处理 rejection)。 */
|
|
655
674
|
const launchTortoise = (exe: string, action: SvnAction, absPath: string): void => {
|
|
656
675
|
const spawn = subprocess()
|
|
@@ -738,7 +757,9 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
|
|
|
738
757
|
if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
|
|
739
758
|
if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
|
|
740
759
|
try {
|
|
741
|
-
const
|
|
760
|
+
const targets = rels.map((path) => svnPathOf(sc.cwd, status.wcRoot!, path))
|
|
761
|
+
if (targets.some((path) => path === null)) return { ok: false as const, error: '目标不在 SVN 工作副本内' }
|
|
762
|
+
const outcome = await runSvn(status.wcRoot, [...verbArgs, '--', ...targets.map((path) => pegTarget(path!))], MUTATE_GRACE_MS, OUTPUT_CAP, !verbArgs.includes('--depth'))
|
|
742
763
|
const result = batchResultOf(outcome.stdout, outcome.stderr, outcome.code, verb)
|
|
743
764
|
// 变更清单已过期(revert/add 改工作副本状态):主动作废缓存,下次拉取即为最新
|
|
744
765
|
changesCache.delete(status.wcRoot)
|
|
@@ -1089,8 +1110,51 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
|
|
|
1089
1110
|
return { ok: false as const, error: '读取基线失败:' + String(error) }
|
|
1090
1111
|
}
|
|
1091
1112
|
},
|
|
1092
|
-
'svn.
|
|
1093
|
-
|
|
1113
|
+
'svn.conflictStatus': async (args: { sessionId?: string; paths?: string[] }) => {
|
|
1114
|
+
const scope = await updateScope(args.sessionId, true)
|
|
1115
|
+
if ('err' in scope) return { ok: false as const, error: scope.err }
|
|
1116
|
+
if (!scope.cli) return { ok: false as const, error: 'svn 命令不可用' }
|
|
1117
|
+
if (!Array.isArray(args.paths) || args.paths.length > BATCH_PATHS_CAP) return { ok: false as const, error: '冲突路径列表不合法或超限' }
|
|
1118
|
+
if (!args.paths.every(validTarget)) return { ok: false as const, error: '路径不合法' }
|
|
1119
|
+
const paths = [...new Set(args.paths.map(safeRel))]
|
|
1120
|
+
if (paths.some((path) => !path)) return { ok: false as const, error: '路径不合法' }
|
|
1121
|
+
try {
|
|
1122
|
+
const states = []
|
|
1123
|
+
for (const path of paths) states.push(await readConflict(scope.cwd, scope.root, path!))
|
|
1124
|
+
return { ok: true as const, states }
|
|
1125
|
+
} catch (error) { return { ok: false as const, error: String(error) } }
|
|
1126
|
+
},
|
|
1127
|
+
'svn.resolve': async (args: { sessionId?: string; path?: string; accept?: unknown }) => {
|
|
1128
|
+
if (!validAccept(args.accept)) return { ok: false as const, error: '解决策略不合法' }
|
|
1129
|
+
if (!validTarget(args.path)) return { ok: false as const, error: '路径不合法' }
|
|
1130
|
+
const path = safeRel(args.path)
|
|
1131
|
+
if (!path) return { ok: false as const, error: '路径不合法' }
|
|
1132
|
+
const scope = await updateScope(args.sessionId, true)
|
|
1133
|
+
if ('err' in scope) return { ok: false as const, error: scope.err }
|
|
1134
|
+
if (!scope.cli) return { ok: false as const, error: 'svn 命令不可用' }
|
|
1135
|
+
if (writeBusy(scope.root)) return { ok: false as const, error: '工作副本正在更新或解决冲突,请等待完成' }
|
|
1136
|
+
conflictWrites.add(writeKey(scope.root))
|
|
1137
|
+
try {
|
|
1138
|
+
const evidence = await readConflict(scope.cwd, scope.root, path)
|
|
1139
|
+
if (!hasConflict(evidence)) return { ok: false as const, error: '目标已无冲突,请刷新状态' }
|
|
1140
|
+
if (evidence.tree && args.accept !== 'working') return { ok: false as const, error: '树冲突不支持通用完整版本选择,请针对性处理' }
|
|
1141
|
+
const target = svnPathOf(scope.cwd, scope.root, path)!
|
|
1142
|
+
const outcome = await runSvn(scope.root, ['resolve', '--accept', args.accept, '--depth', 'empty', '--', pegTarget(target)], MUTATE_GRACE_MS, OUTPUT_CAP, false)
|
|
1143
|
+
if (outcome.code !== 0) return { ok: false as const, error: outcome.stderr || outcome.stdout || 'SVN 解决冲突失败' }
|
|
1144
|
+
return { ok: true as const, summary: 'SVN 解决命令已执行,请核对剩余冲突', output: outcome.stdout }
|
|
1145
|
+
} catch (error) { return { ok: false as const, error: String(error) } }
|
|
1146
|
+
finally { conflictWrites.delete(writeKey(scope.root)); changesCache.delete(scope.root); explorerCache.clear() }
|
|
1147
|
+
},
|
|
1148
|
+
'svn.revert': async (args: { sessionId?: string; paths?: string[]; depth?: unknown }) => {
|
|
1149
|
+
if (args.depth === undefined) return mutate('还原', ['revert', '-R'], args.sessionId, args.paths)
|
|
1150
|
+
if (args.depth !== 'empty') return { ok: false as const, error: '还原深度不合法' }
|
|
1151
|
+
if (!Array.isArray(args.paths) || !args.paths.length || !args.paths.every(validTarget)) return { ok: false as const, error: '路径不合法' }
|
|
1152
|
+
const scope = await updateScope(args.sessionId, true)
|
|
1153
|
+
if ('err' in scope) return { ok: false as const, error: scope.err }
|
|
1154
|
+
if (writeBusy(scope.root)) return { ok: false as const, error: '工作副本正在更新或解决冲突,请等待完成' }
|
|
1155
|
+
conflictWrites.add(writeKey(scope.root))
|
|
1156
|
+
try { return await mutate('还原', ['revert', '--depth', 'empty'], args.sessionId, args.paths) }
|
|
1157
|
+
finally { conflictWrites.delete(writeKey(scope.root)); changesCache.delete(scope.root); explorerCache.clear() }
|
|
1094
1158
|
},
|
|
1095
1159
|
'svn.add': async (args: { sessionId?: string; paths?: string[] }) => {
|
|
1096
1160
|
return mutate('加入版本控制', ['add'], args.sessionId, args.paths)
|
|
@@ -1546,6 +1610,7 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
|
|
|
1546
1610
|
const fs = ctx.get('fs') as { resolve?: (p: string, o: object) => Promise<unknown>; processPath?: (t: unknown) => string } | undefined
|
|
1547
1611
|
const cwd = fs?.resolve && fs?.processPath
|
|
1548
1612
|
? fs.processPath(await fs.resolve(scope.cwd, {})) : scope.cwd
|
|
1613
|
+
if (conflictWrites.has(writeKey(scope.root))) return { ok: false as const, error: '工作副本正在解决冲突,请等待完成' }
|
|
1549
1614
|
return { ok: true as const, ...updateJobs.start({ cwd, root: scope.root, path: rel, exe: scope.exe }) }
|
|
1550
1615
|
} catch (error) {
|
|
1551
1616
|
return { ok: false as const, error: '启动 SVN 更新失败:' + String(error) }
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { SvnConflictState, SvnResolveAccept } from './shared/svn.js'
|
|
2
|
+
import { attrOf, scanXmlTags } from './svnXml.js'
|
|
3
|
+
|
|
4
|
+
/** @public @author ddj 2026年10月14号 @param value Requested strategy. @returns Whether SVN accepts this explicit strategy. */
|
|
5
|
+
export function validAccept(value: unknown): value is SvnResolveAccept {
|
|
6
|
+
return value === 'mine-full' || value === 'theirs-full' || value === 'working'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** @public @author ddj 2026年10月14号 @param value Untrusted relative target. @returns Whether the path names exactly one non-root workspace target. */
|
|
10
|
+
export function validTarget(value: unknown): value is string {
|
|
11
|
+
if (typeof value !== 'string' || !value || /[\u0000-\u001f]/.test(value) || /^[A-Za-z]:|^[\\/]/.test(value)) return false
|
|
12
|
+
return value.replace(/\\/g, '/').split('/').every((part) => Boolean(part) && part !== '.' && part !== '..' && part.toLowerCase() !== '.svn')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** @public @author ddj 2026年10月14号 @param path Exact relative target. @returns Peg-safe SVN argument. */
|
|
16
|
+
export function pegTarget(path: string): string { return path.includes('@') ? path + '@' : path }
|
|
17
|
+
|
|
18
|
+
/** @public @author ddj 2026年10月14号 @param xml Verbose depth-empty status. @param path Client target identity. @returns Confirmed conflict evidence. @throws Missing or incomplete status evidence. */
|
|
19
|
+
export function conflictOf(xml: string, path: string): SvnConflictState {
|
|
20
|
+
if (!/^\s*(?:<\?xml[^>]*>\s*)?<status\b/.test(xml) || !/<\/status>\s*$/.test(xml)) {
|
|
21
|
+
throw new Error('SVN 冲突状态输出为空或不完整,状态未确认')
|
|
22
|
+
}
|
|
23
|
+
const statuses = [...scanXmlTags(xml, ['wc-status'])].filter((hit) => !hit.closing)
|
|
24
|
+
if (statuses.length !== 1) throw new Error('SVN 未返回唯一目标状态,状态未确认')
|
|
25
|
+
const attrs = statuses[0].attrs
|
|
26
|
+
const item = attrOf(attrs, 'item')
|
|
27
|
+
if (!item) throw new Error('SVN 目标状态缺失,状态未确认')
|
|
28
|
+
return { path, text: item === 'conflicted', props: attrOf(attrs, 'props') === 'conflicted',
|
|
29
|
+
tree: attrOf(attrs, 'tree-conflicted') === 'true', versioned: !['unversioned', 'ignored', 'external', 'none'].includes(item) }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @public @author ddj 2026年10月14号 @param state Confirmed target evidence. @returns Whether any conflict remains. */
|
|
33
|
+
export function hasConflict(state: SvnConflictState): boolean { return state.text || state.props || state.tree }
|