dsh-vscode-mode 0.14.13 → 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.
@@ -0,0 +1,87 @@
1
+ /** In-page window chrome; closing returns tabs, never closes documents. @author ddj 2026年10月14号 */
2
+ import React from 'react'
3
+ import { createPortal } from 'react-dom'
4
+ import { clampFloat, type FloatRect } from '../state/editorLayout.js'
5
+
6
+ export interface FloatingProps {
7
+ group: string
8
+ owner: string
9
+ rect: FloatRect
10
+ focused: boolean
11
+ layer: number
12
+ onBounds(group: string, rect: FloatRect): void
13
+ onFocus(group: string): void
14
+ onReturn(group: string): void
15
+ children?: React.ReactNode
16
+ }
17
+
18
+ /** @public @author ddj 2026年10月14号 @param props Window geometry and group callbacks. @returns Theme-aware portal with move/resize handles. */
19
+ export function FloatingEditor(props: FloatingProps): React.ReactPortal {
20
+ const latest = React.useRef(props)
21
+ latest.current = props
22
+ const cleanup = React.useRef<(() => void) | null>(null)
23
+ /** @private @author ddj 2026年10月14号 Cancel a gesture on unmount without altering tabs. */
24
+ function cancel(): void { cleanup.current?.(); cleanup.current = null }
25
+ React.useEffect(() => cancel, [])
26
+ React.useEffect(() => {
27
+ /** @private @author ddj 2026年10月14号 Keep title/chrome visible after viewport changes. */
28
+ function resize(): void {
29
+ const current = latest.current
30
+ current.onBounds(current.group, clampFloat(current.rect, window.innerWidth, window.innerHeight))
31
+ }
32
+ window.addEventListener('resize', resize)
33
+ resize()
34
+ return () => window.removeEventListener('resize', resize)
35
+ }, [])
36
+
37
+ /** @private @author ddj 2026年10月14号 @param event Handle press. @param sizing Resize rather than move. */
38
+ function start(event: React.PointerEvent, sizing: boolean): void {
39
+ if (event.button !== 0 || !event.isPrimary) return
40
+ cancel()
41
+ event.preventDefault()
42
+ const initial = latest.current.rect
43
+ const x = event.clientX, y = event.clientY, pointer = event.pointerId
44
+ let frame: number | null = null
45
+ let next = initial
46
+ latest.current.onFocus(props.group)
47
+ /** @private @author ddj 2026年10月14号 Apply at most one geometry change per animation frame. */
48
+ function paint(): void { frame = null; latest.current.onBounds(props.group, next) }
49
+ /** @private @author ddj 2026年10月14号 @param moveEvent Pointer movement for the originating pointer. */
50
+ function move(moveEvent: PointerEvent): void {
51
+ if (moveEvent.pointerId !== pointer) return
52
+ moveEvent.preventDefault()
53
+ const dx = moveEvent.clientX - x, dy = moveEvent.clientY - y
54
+ next = clampFloat(sizing ? { ...initial, width: initial.width + dx, height: initial.height + dy } : { ...initial, left: initial.left + dx, top: initial.top + dy }, window.innerWidth, window.innerHeight)
55
+ if (frame === null) frame = requestAnimationFrame(paint)
56
+ }
57
+ /** @private @author ddj 2026年10月14号 Remove gesture listeners and pending frames. */
58
+ function stop(): void {
59
+ if (frame !== null) { cancelAnimationFrame(frame); paint() }
60
+ window.removeEventListener('pointermove', move, true)
61
+ window.removeEventListener('pointerup', stop, true)
62
+ window.removeEventListener('pointercancel', stop, true)
63
+ window.removeEventListener('blur', stop)
64
+ cleanup.current = null
65
+ }
66
+ cleanup.current = stop
67
+ window.addEventListener('pointermove', move, true)
68
+ window.addEventListener('pointerup', stop, true)
69
+ window.addEventListener('pointercancel', stop, true)
70
+ window.addEventListener('blur', stop)
71
+ }
72
+ /** @private @author ddj 2026年10月14号 @param event Title press. Move window without stealing button presses. */
73
+ function moveStart(event: React.PointerEvent): void { if (!(event.target as Element).closest('button')) start(event, false) }
74
+ /** @private @author ddj 2026年10月14号 @param event Resize grip press. */
75
+ function sizeStart(event: React.PointerEvent): void { start(event, true) }
76
+ /** @private @author ddj 2026年10月14号 Return all tabs to the main group. */
77
+ function returnTabs(): void { latest.current.onReturn(props.group) }
78
+ /** @private @author ddj 2026年10月14号 Bring the group to the foreground. */
79
+ function focus(): void { latest.current.onFocus(props.group) }
80
+ const rect = clampFloat(props.rect, window.innerWidth, window.innerHeight)
81
+ return createPortal(React.createElement('div', { className: 'edrv-floating' + (props.focused ? ' edrv-floating-focused' : ''), 'data-edrv-view': '1', 'data-edrv-float': props.group, 'data-edrv-owner': props.owner, style: { ...rect, zIndex: (props.focused ? 80 : 60) + props.layer }, onPointerDownCapture: focus },
82
+ React.createElement('div', { className: 'edrv-floating-title', onPointerDown: moveStart },
83
+ React.createElement('span', null, '浮动编辑组'), React.createElement('button', { type: 'button', title: '将全部文件移回主编辑区', onClick: returnTabs }, '移回主编辑区'),
84
+ React.createElement('button', { type: 'button', title: '关闭浮窗并将文件移回主编辑区', 'aria-label': '关闭浮窗并移回文件', onClick: returnTabs }, '×')),
85
+ props.children,
86
+ React.createElement('div', { className: 'edrv-floating-grip', title: '拖动调整窗口大小', onPointerDown: sizeStart })), document.body)
87
+ }
@@ -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[]): ContextMenuEntry[] {
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
- return { listRef, rowHeight, range: updateRange(count, scrollTop, height, rowHeight), onScroll }
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 from = Math.min(index, anchorRef.current)
270
- const to = Math.max(index, anchorRef.current)
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 = index
276
- } else { setSelected([row.seq]); anchorRef.current = index }
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
- return { selected, picked, menu, setMenu, pick, openMenu }
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): React.ReactElement {
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
- React.createElement('span', { role: 'columnheader' }, '操作'),
337
- React.createElement('span', { role: 'columnheader' }, '路径'),
338
- React.createElement('span', { role: 'columnheader', title: '仅对已知扩展名推断,非 SVN 属性' }, '类型(推断)')),
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 view = useUpdateView(rows.length)
414
- const selection = useUpdateMenu(props, rows, state)
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 closeMode = updateCloseMode(finished, error, Boolean(state), Boolean(selection.menu || props.childOverlay))
418
- const closable = closeMode.explicit
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 (!closeMode.explicit) return
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, rows, view, selection, finished),
462
- updateStatus(state, error, cleaned),
463
- updateFoot(props, state, closable, finished, cancel, dismiss, closeMode.confirm, cleaned, cleanup),
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, rows, selection.selected), zIndex: 193,
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 outcome = await runSvn(status.wcRoot, [...verbArgs, '--', ...rels], MUTATE_GRACE_MS)
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.revert': async (args: { sessionId?: string; paths?: string[] }) => {
1093
- return mutate('还原', ['revert', '-R'], args.sessionId, args.paths)
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) }