dsh-vscode-mode 0.15.0 → 0.15.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.15.0",
3
+ "version": "0.15.3",
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": [
@@ -15,6 +15,8 @@ import { workspaceScopeOf } from '../../state/scopeStore.js'
15
15
  import { takeSearchSeed, takeSearchScope } from '../../searchSeed.js'
16
16
 
17
17
  const DEBOUNCE_MS = 250
18
+ /** 文件变更后的重搜去抖:合并一次写盘的 refresh+file-changed 双事件与批量保存。 */
19
+ const REFRESH_DEBOUNCE_MS = 300
18
20
  const INCLUDE_PLACEHOLDER = '例如 *.ts, src/**/include'
19
21
 
20
22
  /**
@@ -56,6 +58,30 @@ export function savedSearchOf(key: string) {
56
58
  }
57
59
  }
58
60
 
61
+ /**
62
+ * 判断文件变化通知是否属于本面板作用域。
63
+ * @author ddj 2026年10月14号
64
+ * @param detail 事件 detail(scope 可缺省)
65
+ * @param scope 本面板作用域
66
+ * @returns 是否触发重搜
67
+ */
68
+ export function noticeInScope(detail: any, scope: string): boolean {
69
+ const changed = detail?.scope
70
+ return !changed || changed === scope
71
+ }
72
+
73
+ /**
74
+ * 判断文件变化通知是否可能改变内容搜索命中。
75
+ * 文件增删改名(structure)会增删命中,一并重搜;SVN 状态刷新(svn)不代表内容变化,跳过以免无谓全库扫描。
76
+ * @author ddj 2026年10月14号
77
+ * @param detail 事件 detail(kind 可缺省)
78
+ * @returns 是否触发重搜
79
+ */
80
+ export function noticeTouchesContent(detail: any): boolean {
81
+ const kind = detail?.kind
82
+ return !kind || kind === 'text' || kind === 'structure'
83
+ }
84
+
59
85
  /**
60
86
  * 命中行文本三段切分(before/命中/after,列为 1-based UTF-16)。
61
87
  * @param text 行文本
@@ -103,6 +129,8 @@ export function SearchPanel(props) {
103
129
  queryRef.current = query
104
130
  const activePathRef = React.useRef(activePath)
105
131
  activePathRef.current = activePath
132
+ // 文件变化后的重搜定时器(与输入防抖分开:两者互不取消对方)
133
+ const refreshTimerRef = React.useRef(null)
106
134
  // 请求参数 ref:防抖/立即重搜闭包统一读最新值,避免陈旧选项
107
135
  const requestRef = React.useRef({ matchCase, wholeWord, regex, include: [], exclude: [] })
108
136
  requestRef.current = {
@@ -152,9 +180,11 @@ export function SearchPanel(props) {
152
180
 
153
181
  /**
154
182
  * 执行内容搜索(请求参数从 ref 读最新值)。
183
+ * @author ddj 2026年10月14号
155
184
  * @param text 搜索词
185
+ * @param force 是否强制绕过 host 短期缓存(文件变更后的重搜必须读最新磁盘内容)
156
186
  */
157
- const runSearch = (text) => {
187
+ const runSearch = (text, force) => {
158
188
  const s = ++seqRef.current
159
189
  const clean = String(text ?? '').trim()
160
190
  if (clean.length < 2) { setMatches(null); setStatus('idle'); return }
@@ -174,6 +204,8 @@ export function SearchPanel(props) {
174
204
  sessionId, query: clean,
175
205
  matchCase: req.matchCase, wholeWord: req.wholeWord, regex: req.regex,
176
206
  include: req.include, exclude: req.exclude,
207
+ // 仅重搜路径带上 force:普通搜索的请求载荷与修复前保持完全一致。
208
+ ...(force === true ? { force: true } : {}),
177
209
  }).then((res) => {
178
210
  if (s !== seqRef.current) return
179
211
  if (res && res.ok) {
@@ -194,6 +226,10 @@ export function SearchPanel(props) {
194
226
  })
195
227
  }
196
228
 
229
+ // 事件监听闭包经 ref 取最新搜索实现与最新查询词(事件注册只做一次)
230
+ const runSearchRef = React.useRef(runSearch)
231
+ runSearchRef.current = runSearch
232
+
197
233
  /**
198
234
  * 输入变化:防抖触发搜索。
199
235
  * @param value 新输入
@@ -303,6 +339,27 @@ export function SearchPanel(props) {
303
339
  return () => window.removeEventListener('edrv:search-focus', onFocus)
304
340
  }, [])
305
341
 
342
+ // 文件内容变化(保存/外部写盘/文件增删改名)→ 去抖强制重搜,避免面板残留已失效的旧命中。
343
+ // 两种事件都监听:编辑器内保存只发 edrv:refresh,外部写盘只发 edrv:file-changed。
344
+ React.useEffect(() => {
345
+ /** @private @author ddj 2026年10月14号 @description 合并同一次写盘的多条通知后强制重搜 */
346
+ const onChanged = (event) => {
347
+ const detail = event?.detail ?? {}
348
+ if (!noticeInScope(detail, scope)) return
349
+ if (!noticeTouchesContent(detail)) return
350
+ if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current)
351
+ refreshTimerRef.current = setTimeout(() => runSearchRef.current(queryRef.current, true), REFRESH_DEBOUNCE_MS)
352
+ }
353
+ window.addEventListener('edrv:file-changed', onChanged)
354
+ window.addEventListener('edrv:refresh', onChanged)
355
+ return () => {
356
+ window.removeEventListener('edrv:file-changed', onChanged)
357
+ window.removeEventListener('edrv:refresh', onChanged)
358
+ if (refreshTimerRef.current) { clearTimeout(refreshTimerRef.current); refreshTimerRef.current = null }
359
+ }
360
+ // eslint-disable-next-line react-hooks/exhaustive-deps
361
+ }, [])
362
+
306
363
  // 一次意图对应一次提交后选区;无需定时回调,卸载后不会抢焦点。
307
364
  React.useLayoutEffect(() => {
308
365
  if (!focusTick) return
@@ -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); }
@@ -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[]): 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/rpc.ts CHANGED
@@ -1034,6 +1034,7 @@ export function buildHandlers(
1034
1034
  maxResults: args.maxResults,
1035
1035
  include: args.include,
1036
1036
  exclude: args.exclude,
1037
+ force: args.force,
1037
1038
  })
1038
1039
  return { ok: true, ...result }
1039
1040
  } catch (error) {
@@ -45,6 +45,8 @@ export interface ContentSearchRequest {
45
45
  include?: string[]
46
46
  /** 排除 glob(这些文件不参与搜索)。 */
47
47
  exclude?: string[]
48
+ /** 强制绕过短期缓存:文件变更后的重搜必须看到最新磁盘内容,每次都跑真实 rg。 */
49
+ force?: boolean
48
50
  }
49
51
 
50
52
  type ContentSearchResponse = { matches: ContentMatch[]; truncated: boolean; warning?: string }
@@ -257,7 +259,8 @@ export class ContentSearcher {
257
259
  const includeKey = (request.include ?? []).join(',')
258
260
  const excludeKey = (request.exclude ?? []).join(',')
259
261
  const key = [rootKey, query, request.matchCase ? 'mc' : '', request.wholeWord ? 'ww' : '', request.regex ? 'rx' : '', includeKey, excludeKey, PROVIDER_VERSION].join('|')
260
- const cached = this.cache.get(key)
262
+ // force(文件变更后的重搜)必须看到最新磁盘内容:跳过缓存读,见下方跳过缓存写。
263
+ const cached = request.force ? undefined : this.cache.get(key)
261
264
  if (cached) return { matches: cached.matches, truncated: cached.truncated, warning: cached.warning }
262
265
  this.inflight.get(root)?.abort()
263
266
  const controller = new AbortController()
@@ -278,7 +281,8 @@ export class ContentSearcher {
278
281
  root,
279
282
  })
280
283
  if (controller.signal.aborted) return { matches: [], truncated: false }
281
- this.cache.set(key, result)
284
+ // force 结果不回填缓存:本次是「读最新」,不代表该键的稳定内容。
285
+ if (!request.force) this.cache.set(key, result)
282
286
  return { matches: result.matches, truncated: result.truncated, warning: result.warning }
283
287
  } catch (error) {
284
288
  if (controller.signal.aborted) return { matches: [], truncated: false }
package/src/shared/rpc.ts CHANGED
@@ -377,7 +377,7 @@ export interface RpcRequestMap {
377
377
  'edrv.dlog.clear': { sessionId?: string; file?: string }
378
378
  'edrv.dlog.reveal': { sessionId?: string }
379
379
  'edrv.searchFiles': { sessionId?: string; query: string }
380
- 'edrv.searchContent': { sessionId?: string; query: string; matchCase?: boolean; wholeWord?: boolean; regex?: boolean; maxResults?: number; include?: string[]; exclude?: string[] }
380
+ 'edrv.searchContent': { sessionId?: string; query: string; matchCase?: boolean; wholeWord?: boolean; regex?: boolean; maxResults?: number; include?: string[]; exclude?: string[]; force?: boolean }
381
381
  'edrv.listDir': { sessionId?: string; path: string; force?: boolean }
382
382
  'edrv.revealInExplorer': { sessionId?: string; path: string }
383
383
  'edrv.fsCreateFile': { sessionId?: string; path: string }
@@ -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
  /** 处理条目数(宽松计数)。 */