dsh-vscode-mode 0.14.6 → 0.14.8

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,141 @@
1
+ /** Explorer lifecycle: local refresh is offline, remote checks are explicit. @author ddj 2026年10月09号 */
2
+ import React from 'react'
3
+ import { bindSvnScope } from '../../svnNotice.js'
4
+ import { rpc } from '../../rpc.js'
5
+ import { explorerPathOf, remoteIndexOf } from '../../../shared/svnExplorer.js'
6
+ import type { SvnViewRequest, SvnViewRow } from '../../../shared/svnExplorer.js'
7
+ import { ExplorerViewClient } from './explorerView.js'
8
+ import type { ExplorerNotice, ExplorerView } from './explorerView.js'
9
+ import type { SvnRemoteOutdatedEntry } from '../../../shared/svn.js'
10
+ import type { SidebarCtx } from '../types.js'
11
+
12
+ interface LocalSnapshot { view: ExplorerView; key: string }
13
+
14
+ // --region Explicit repository check
15
+ /** @private @author ddj 2026年10月09号 @param ctx Sidebar context. @param local Confirmed snapshot. @returns Expiring read-only remote evidence and check action. */
16
+ function useRemoteCheck(ctx: SidebarCtx | undefined, local: LocalSnapshot | null, resetToken: number, scope: string) {
17
+ const [remote, setRemote] = React.useState<Map<string, number> | null>(null)
18
+ const [message, setMessage] = React.useState('')
19
+ const [busy, setBusy] = React.useState(false)
20
+ const control = React.useRef<AbortController | null>(null)
21
+ const epoch = React.useRef(0)
22
+ const expiry = React.useRef<ReturnType<typeof setTimeout> | null>(null)
23
+
24
+ /** @private @author ddj 2026年10月09号 Cancel stale request/timer without updating an unmounted component. */
25
+ function cancel() {
26
+ epoch.current++
27
+ control.current?.abort()
28
+ control.current = null
29
+ if (expiry.current) clearTimeout(expiry.current)
30
+ expiry.current = null
31
+ }
32
+ /** @private @author ddj 2026年10月09号 @returns Cleanup; scope/local changes invalidate repository evidence. */
33
+ function reset() { cancel(); setRemote(null); setMessage(''); setBusy(false); return cancel }
34
+ React.useEffect(reset, [scope, resetToken])
35
+
36
+ /** @public @author ddj 2026年10月09号 Contact repository once; failures/expiry never imply up-to-date. */
37
+ async function checkRemote() {
38
+ if (!local || control.current) return
39
+ cancel()
40
+ setRemote(null)
41
+ const controller = new AbortController()
42
+ control.current = controller
43
+ const seq = epoch.current
44
+ setBusy(true)
45
+ setMessage('正在检查仓库更新(只读)…')
46
+ try {
47
+ const out = await rpc('svn.remoteStatus', { sessionId: ctx?.sessionId, path: '' }, controller.signal)
48
+ if (controller.signal.aborted || seq !== epoch.current) return
49
+ if (!out.ok) { setMessage(out.error); return }
50
+ /** @private @author ddj 2026年10月09号 @param entry WC-relative change. @returns Session subset. */
51
+ function scopeEntry(entry: SvnRemoteOutdatedEntry): SvnRemoteOutdatedEntry[] {
52
+ const path = explorerPathOf(entry.path, local!.view.prefix)
53
+ return path === null ? [] : [{ ...entry, path }]
54
+ }
55
+ const scoped = out.outdated.flatMap(scopeEntry)
56
+ setRemote(remoteIndexOf(scoped))
57
+ setMessage('检查于 ' + new Date().toLocaleTimeString() + ':' + scoped.length + ' 项可更新'
58
+ + (out.truncated ? '(结果不完整)' : '') + (out.againstRev === null ? '' : ',仓库 r' + out.againstRev))
59
+ /** @private @author ddj 2026年10月09号 Expire evidence; no automatic repository recheck. */
60
+ function expire() { setRemote(null); setMessage('远端检查已过期,请重新检查仓库更新') }
61
+ expiry.current = setTimeout(expire, 60_000)
62
+ } catch (error) { if (!controller.signal.aborted && seq === epoch.current) setMessage(String(error)) }
63
+ finally { if (seq === epoch.current) { control.current = null; setBusy(false) } }
64
+ }
65
+ return { remote, message, busy, checkRemote }
66
+ }
67
+ // --endregion
68
+
69
+ /** @private @author ddj 2026年10月10号 @param view Compact metadata. @returns Honest refresh/failure/last-confirmed status. */
70
+ function localMessage(view?: ExplorerView): string {
71
+ if (!view) return '正在读取本地 SVN 状态…'
72
+ const time = view.confirmedAt ? ';最后确认 ' + new Date(view.confirmedAt).toLocaleTimeString() : ';尚未确认'
73
+ if (view.error) return 'SVN 状态陈旧:' + view.error + time
74
+ return (view.refreshing ? 'SVN 状态刷新中' : view.complete ? 'SVN 本地状态已确认' : 'SVN 状态未完整确认') + time
75
+ }
76
+ // --region Offline local status
77
+ /** @public @author ddj 2026年10月09号 @param ctx Sidebar context. @returns Scope-bound local/remote icons; unknown entries never show green. */
78
+ export function useExplorerSvn(ctx: SidebarCtx | undefined, rows: readonly SvnViewRow[] = []) {
79
+ const [local, setLocal] = React.useState<LocalSnapshot | null>(null)
80
+ const [remoteToken, setRemoteToken] = React.useState(0)
81
+ const owner = React.useRef<ExplorerViewClient | null>(null)
82
+ const ready = ctx?.svn?.managed === true && ctx?.svn?.svnCli === true
83
+ const scope = ctx?.scope ?? ctx?.sessionId ?? ''
84
+ const key = JSON.stringify([scope, ready])
85
+ const snapshot = local?.key === key ? local : null
86
+ const remote = useRemoteCheck(ctx, snapshot, remoteToken, key)
87
+
88
+ /** @private @author ddj 2026年10月10号 @returns Scope owner cleanup; late results cannot populate a new workspace. */
89
+ function mount() {
90
+ setLocal(null)
91
+ bindSvnScope(ctx?.sessionId, scope)
92
+ if (!ready) return
93
+ /** @private @author ddj 2026年10月10号 @param view Compact state. Publish only this mounted scope. */
94
+ function publish(view: ExplorerView) { setLocal({ view, key }) }
95
+ /** @private @author ddj 2026年10月10号 Invalidate explicit remote evidence after non-text changes. */
96
+ function dropRemote() { setRemoteToken((value) => value + 1) }
97
+ /** @private @author ddj 2026年10月10号 @param args Compact projection. @param signal UI fetch lifetime. @returns Authenticated response. */
98
+ function fetch(args: SvnViewRequest, signal: AbortSignal) { return rpc('svn.explorerView', args, signal) }
99
+ const client = new ExplorerViewClient(scope, fetch, publish, dropRemote)
100
+ client.sessionId = ctx?.sessionId
101
+ owner.current = client
102
+ client.setRows(rows)
103
+ client.visibility(document.visibilityState !== 'hidden')
104
+ return () => { client.dispose(); if (owner.current === client) owner.current = null }
105
+ }
106
+ React.useEffect(mount, [key])
107
+ /** @private @author ddj 2026年10月10号 Row expansion/session switch only reprojects; Host owns shared workspace cache. */
108
+ function project() {
109
+ bindSvnScope(ctx?.sessionId, scope)
110
+ if (owner.current) { owner.current.sessionId = ctx?.sessionId; owner.current.setRows(rows) }
111
+ }
112
+ React.useEffect(project, [rows, ctx?.sessionId])
113
+ /** @private @author ddj 2026年10月10号 Unknown changes-list origins calibrate once; known event bursts are already scheduled. */
114
+ function changes() { owner.current?.changes() }
115
+ React.useEffect(changes, [ctx?.svnChanges])
116
+
117
+ /** @private @author ddj 2026年10月10号 @returns Scoped notices/focus/visibility listener cleanup. */
118
+ function listen() {
119
+ /** @private @author ddj 2026年10月10号 @param event Pathful or legacy notification. Legacy unknown scope safely falls back to full. */
120
+ function notice(event: Event) {
121
+ const detail = (event as CustomEvent<ExplorerNotice & { path?: string }>).detail ?? {}
122
+ owner.current?.notify({ ...detail, paths: detail.paths ?? (detail.path ? [detail.path] : undefined) })
123
+ }
124
+ /** @private @author ddj 2026年10月10号 Trigger only offline calibration; focus and visibility coalesce. */
125
+ function visibility() { owner.current?.visibility(document.visibilityState !== 'hidden') }
126
+ window.addEventListener('edrv:refresh', notice)
127
+ window.addEventListener('edrv:file-changed', notice)
128
+ window.addEventListener('focus', visibility)
129
+ document.addEventListener('visibilitychange', visibility)
130
+ return () => {
131
+ window.removeEventListener('edrv:refresh', notice); window.removeEventListener('edrv:file-changed', notice)
132
+ window.removeEventListener('focus', visibility); document.removeEventListener('visibilitychange', visibility)
133
+ }
134
+ }
135
+ React.useEffect(listen, [])
136
+ /** @public @author ddj 2026年10月10号 Request an explicit offline full scan. */
137
+ function refreshLocal() { owner.current?.refresh() }
138
+ return { ...remote, local: snapshot?.view.marks ?? null, remote: snapshot ? remote.remote : null,
139
+ message: localMessage(snapshot?.view) + (remote.message ? ';' + remote.message : ''), canCheck: ready && snapshot !== null, refreshLocal }
140
+ }
141
+ // --endregion
@@ -277,6 +277,16 @@
277
277
  /* 树行图标:官方原语(文件夹/文件类型),尺寸与行高对齐 @author ddj 2026-09-10 */
278
278
  [data-edrv-view] .edrv-tree-icon { flex-shrink: 0; display: inline-flex; align-items: center; color: var(--dsw-alias-label-tertiary, #9aa5b1); }
279
279
  [data-edrv-view] .edrv-tree-row.edrv-tree-active .edrv-tree-icon { color: var(--dsw-alias-label-secondary, #52606d); }
280
+ /* SVN overlays: official file icon remains visible, independent local/remote signs. ddj 2026-10-09 */
281
+ [data-edrv-view] .edrv-tree-iconbox { position: relative; display: inline-flex; width: 20px; height: 20px; align-items: center; flex-shrink: 0; }
282
+ [data-edrv-view] .edrv-svn-overlay { position: absolute; inset: 0; }
283
+ [data-edrv-view] .edrv-svn-local { position: absolute; right: -1px; bottom: -2px; display: flex; border-radius: 50%; box-shadow: 0 0 0 1px var(--dsw-alias-bg-layer-1, #fff); }
284
+ [data-edrv-view] .edrv-svn-icon-clean { color: #16853b; }
285
+ [data-edrv-view] .edrv-svn-icon-modified, [data-edrv-view] .edrv-svn-icon-deleted { color: #d12e35; }
286
+ [data-edrv-view] .edrv-svn-icon-conflict { color: #c27300; }
287
+ [data-edrv-view] .edrv-svn-icon-added { color: #2465c9; }
288
+ [data-edrv-view] .edrv-svn-icon-unversioned, [data-edrv-view] .edrv-svn-icon-ignored, [data-edrv-view] .edrv-svn-icon-external { color: #75808c; }
289
+ [data-edrv-view] .edrv-svn-remote { position: absolute; right: -3px; top: -4px; color: #2465c9; background: var(--dsw-alias-bg-layer-1, #fff); border-radius: 3px; font-size: 11px; font-weight: 900; line-height: 12px; }
280
290
  [data-edrv-view] .edrv-rail-badge { position: absolute; top: 2px; right: 2px; min-width: 14px; height: 14px; padding: 0 3px; border-radius: 999px; background: var(--dsw-alias-brand-primary, #0f9d58); color: var(--dsw-alias-label-primary-inverted, #fff); font-size: 9px; font-weight: 600; line-height: 14px; text-align: center; box-sizing: border-box; }
281
291
  [data-edrv-view] .edrv-side-body { flex: 1; min-width: 0; min-height: 0; display: flex; overflow: hidden; }
282
292
  [data-edrv-view] .edrv-side-panel { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; position: relative; }
@@ -298,6 +308,8 @@
298
308
  [data-edrv-view] .edrv-tree-name.edrv-tree-dim { color: var(--dsw-alias-label-tertiary, #9aa5b1); }
299
309
  [data-edrv-view] .edrv-tree-badge { min-width: 14px; height: 14px; padding: 0 4px; border-radius: 999px; background: var(--dsw-alias-state-warn-primary, #d97706); color: var(--dsw-alias-label-primary-inverted, #fff); font-size: 9px; font-weight: 600; line-height: 14px; text-align: center; box-sizing: border-box; flex-shrink: 0; }
300
310
  [data-edrv-view] .edrv-tree-loading { height: 22px; line-height: 22px; color: var(--dsw-alias-label-tertiary, #9aa5b1); font-size: 11px; }
311
+ /* SVN 状态独立占位:换行不覆盖文件树,长错误在栏内滚动。@author ddj 2026-10-12 */
312
+ [data-edrv-view] .edrv-tree-status { flex: 0 0 auto; min-width: 0; max-height: 96px; overflow: auto; padding: 4px 8px; box-sizing: border-box; line-height: 18px; white-space: normal; overflow-wrap: anywhere; color: var(--dsw-alias-label-tertiary, #9aa5b1); font-size: 11px; }
301
313
  [data-edrv-view] .edrv-tree-error { display: flex; align-items: center; gap: 8px; margin: 4px 2px 6px; padding: 6px 8px; border: 1px solid var(--dsw-alias-state-error-primary, rgba(217,83,79,.4)); border-radius: 6px; background: var(--dsw-alias-interactive-bg-hover-danger, rgba(217,83,79,.12)); color: var(--dsw-alias-state-error-primary, #d9534f); font-size: 11px; }
302
314
  /* 大纲面板:kind 字形 / detail / 行号 */
303
315
  [data-edrv-view] .edrv-outline-kind { display: inline-flex; align-items: center; justify-content: center; width: 14px; flex-shrink: 0; font-size: 12px; font-weight: 600; text-align: center; }
@@ -0,0 +1,24 @@
1
+ /** SVN mutation notifications, independent of UI stores. @author ddj 2026年10月10号 */
2
+ import type { RpcMethod, RpcRequestMap } from '../shared/rpc.js'
3
+ let sequence = 0
4
+ const scopes = new Map<string, string>()
5
+ /** @public @author ddj 2026年10月10号 @param sessionId Active session. @param scope Canonical client workspace. Record bounded routing hints; unknown sessions invalidate globally, never misapply relative paths. */
6
+ export function bindSvnScope(sessionId: string | undefined, scope: string): void {
7
+ if (!sessionId) return
8
+ scopes.delete(sessionId); scopes.set(sessionId, scope)
9
+ if (scopes.size > 512) scopes.delete(scopes.keys().next().value!)
10
+ }
11
+ const WRITES = new Set<RpcMethod>(['svn.add', 'svn.revert', 'svn.ignore', 'svn.cleanup', 'svn.update', 'svn.tortoise'])
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
+ export function isSvnWrite(method: RpcMethod): boolean { return WRITES.has(method) }
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. */
15
+ export function svnNotice<M extends RpcMethod>(method: M, args: RpcRequestMap[M], ok: boolean): void {
16
+ if (!isSvnWrite(method) || typeof window === 'undefined') return
17
+ const request = args as { sessionId?: string; path?: string; paths?: string[]; action?: string; items?: { dir: string }[] }
18
+ if (method === 'svn.tortoise' && !['commit', 'revert'].includes(request.action ?? '')) return
19
+ const scope = request.sessionId ? scopes.get(request.sessionId) : undefined
20
+ let paths: string[] | undefined
21
+ if (scope && ok && method !== 'svn.cleanup') paths = request.paths ?? request.items?.map((item) => item.dir) ?? (request.path ? [request.path] : undefined)
22
+ window.dispatchEvent(new CustomEvent('edrv:refresh', { detail: { scope, paths,
23
+ kind: 'svn', eventId: 'svn-write-' + Date.now() + '-' + (++sequence) } }))
24
+ }
@@ -1143,14 +1143,14 @@ export function EditorView(props) {
1143
1143
  const onDiskChange = (change) => {
1144
1144
  if (!change || !change.path) return
1145
1145
  // 文件树同步:目录缓存按该路径失效并强制重列(外部新增/删除/改名都能看到)
1146
- emitFileChanged(change.path)
1146
+ const eventId = emitFileChanged(change.path, { scope, kind: change.kind === 'deleted' ? 'structure' : 'text' })
1147
1147
  if (change.kind === 'modified') {
1148
1148
  // 干净缓冲:强制从磁盘重读(跳过 model 复用),差异标记随之重算
1149
1149
  loadContent(change.path, sessionId, true)
1150
1150
  clearSync(scope, change.path)
1151
1151
  setDiskFlag((prev) => (prev && prev.path === change.path ? null : prev))
1152
1152
  setStatus('已同步外部修改 ' + new Date().toTimeString().slice(0, 8))
1153
- emitRefresh()
1153
+ emitRefresh({ scope, paths: [change.path], kind: 'text', eventId })
1154
1154
  return
1155
1155
  }
1156
1156
  const kind = change.kind === 'deleted' ? 'deleted' : 'conflict'
@@ -1968,7 +1968,7 @@ export function EditorView(props) {
1968
1968
  clearSync(scope, path)
1969
1969
  acceptSaved(path, text, savedModel)
1970
1970
  refreshRecords()
1971
- emitRefresh()
1971
+ emitRefresh({ scope, paths: [path], kind: 'text' })
1972
1972
  // 片段配置文件保存后失效补全缓存(下次补全即读到新片段)
1973
1973
  if (/\.code-snippets$/i.test(path)) window.dispatchEvent(new CustomEvent('edrv:snippets-changed'))
1974
1974
  return
@@ -2556,7 +2556,7 @@ export function EditorView(props) {
2556
2556
  setDirtyMap((d) => Object.assign({}, d, { [path]: false }))
2557
2557
  setDiskFlag((prev) => (prev && prev.path === path ? null : prev))
2558
2558
  setStatus('已覆盖磁盘')
2559
- emitRefresh()
2559
+ emitRefresh({ scope, paths: [path], kind: 'text' })
2560
2560
  return
2561
2561
  }
2562
2562
  setStatus('覆盖失败')
@@ -2769,7 +2769,7 @@ export function EditorView(props) {
2769
2769
  // #endregion
2770
2770
  if (res && res.ok) {
2771
2771
  setRecords((prev) => Object.assign({}, prev, { [region.callId]: res.record }))
2772
- if (!silent) { reloadFile(); emitRefresh() }
2772
+ if (!silent) { reloadFile(); emitRefresh({ scope, paths: [res.record?.path || active], kind: 'structure' }) }
2773
2773
  return true
2774
2774
  }
2775
2775
  if (!silent) setError(res?.error ? String(res.error) : '操作失败')
@@ -2858,7 +2858,7 @@ export function EditorView(props) {
2858
2858
  actMany(decideRegions.map((r) => itemOf(r, false))).then((result) => {
2859
2859
  batchBusyRef.current = false
2860
2860
  reloadFile(true)
2861
- emitRefresh()
2861
+ emitRefresh({ scope, paths: active ? [active] : undefined, kind: 'structure' })
2862
2862
  setStatus(decideStatus('已采纳 ', result))
2863
2863
  if (result.fail) setError(result.fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
2864
2864
  }).catch(() => { batchBusyRef.current = false })
@@ -2869,7 +2869,7 @@ export function EditorView(props) {
2869
2869
  actMany([...decideRegions].reverse().map((r) => itemOf(r, true))).then((result) => {
2870
2870
  batchBusyRef.current = false
2871
2871
  reloadFile(true)
2872
- emitRefresh()
2872
+ emitRefresh({ scope, paths: active ? [active] : undefined, kind: 'structure' })
2873
2873
  setStatus(decideStatus('已不采纳 ', result))
2874
2874
  if (result.fail) setError(result.fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
2875
2875
  }).catch(() => { batchBusyRef.current = false })
@@ -2899,10 +2899,11 @@ export function EditorView(props) {
2899
2899
  if (batchBusyRef.current || !allPending.length) return
2900
2900
  batchBusyRef.current = true
2901
2901
  const list = reject ? [...allPending].sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : b.idx - a.idx)) : allPending
2902
+ const paths = [...new Set(list.map((r) => records[r.callId]?.path).filter(Boolean))]
2902
2903
  actMany(list.map((r) => itemOf(r, reject))).then((result) => {
2903
2904
  batchBusyRef.current = false
2904
2905
  reloadFile(true)
2905
- emitRefresh()
2906
+ emitRefresh({ scope, paths: paths.length ? paths : undefined, kind: 'structure' })
2906
2907
  setStatus(decideStatus(reject ? '已不采纳 ' : '已采纳 ', result))
2907
2908
  if (result.fail) setError(result.fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
2908
2909
  }).catch(() => { batchBusyRef.current = false })
@@ -2915,7 +2916,7 @@ export function EditorView(props) {
2915
2916
  rpc('edrv.rollback', { sessionId, path: active }).then((res) => {
2916
2917
  if (res && res.ok) {
2917
2918
  setStatus('已回滚'); reloadFile()
2918
- emitRefresh()
2919
+ emitRefresh({ scope, paths: [active], kind: 'structure' })
2919
2920
  } else setError(res?.error ? String(res.error) : '回滚失败')
2920
2921
  }).catch((e) => setError('回滚异常:' + String(e)))
2921
2922
  }
@@ -4197,7 +4198,7 @@ export function EditorView(props) {
4197
4198
  setStatus(message)
4198
4199
  setRecentUpdate({ path: result.target, sessionId, scope, resumeJobId: result.jobId })
4199
4200
  refreshSvnChanges(sessionId, scope)
4200
- emitRefresh()
4201
+ emitRefresh({ scope, kind: 'svn', eventId: 'svn-update-' + result.jobId })
4201
4202
  }
4202
4203
  const svnUpdateEl = sameUpdateOwner(svnUpdateDialog, sessionId, scope)
4203
4204
  ? React.createElement(SvnUpdateDialog, {
@@ -0,0 +1,24 @@
1
+ /** Small vector overlays over official file-type icons. @author ddj 2026年10月09号 */
2
+ import React from 'react'
3
+ import type { SvnExplorerMark, SvnViewMark } from '../../shared/svnExplorer.js'
4
+ /** @public @author ddj 2026年10月10号 @param row Compact mark. @returns Overlay description; stale evidence never renders a green check. */
5
+ export function viewOverlayOf(row?: SvnViewMark): SvnExplorerMark | null {
6
+ if (!row?.kind || (row.stale && row.kind === 'clean')) return null
7
+ const names = { clean: '本地正常(不表示仓库无更新)', modified: '本地修改', conflict: '冲突或阻塞',
8
+ added: '新增', deleted: '删除或缺失', unversioned: '未受控', ignored: '忽略', external: '外部引用' }
9
+ return { kind: row.kind, title: names[row.kind] + (row.count ? ';子项 ' + row.count + ' 处变更' : '')
10
+ + (row.stale ? ';状态陈旧,等待确认' : '') }
11
+ }
12
+ const SIGNS = { clean: '✓', modified: '!', conflict: '×', added: '+', deleted: '−', unversioned: '?', ignored: '·', external: '↗' }
13
+
14
+ /** @public @author ddj 2026年10月09号 @param mark Confirmed local mark. @param remote Remote changed-item count. @returns Accessible vector overlay; remote arrow coexists with local conflict. */
15
+ export function svnOverlayEl(mark: SvnExplorerMark | null, remote: number): React.ReactElement | null {
16
+ if (!mark && !remote) return null
17
+ const title = 'SVN ' + (mark?.title ?? '本地状态未确认') + (remote ? ';仓库有可更新内容(' + remote + ' 项)' : '')
18
+ const svg = mark ? React.createElement('svg', { viewBox: '0 0 12 12', width: 12, height: 12, 'aria-hidden': true },
19
+ React.createElement('circle', { cx: 6, cy: 6, r: 5.5, fill: 'currentColor' }),
20
+ React.createElement('text', { x: 6, y: 9, textAnchor: 'middle', fill: '#fff', fontSize: 9, fontFamily: 'Arial, sans-serif', fontWeight: 700 }, SIGNS[mark.kind])) : null
21
+ return React.createElement('span', { className: 'edrv-svn-overlay', title, 'aria-label': title, role: 'img' },
22
+ mark ? React.createElement('span', { className: 'edrv-svn-local edrv-svn-icon-' + mark.kind }, svg) : null,
23
+ remote ? React.createElement('span', { className: 'edrv-svn-remote', 'aria-hidden': true }, '↓') : null)
24
+ }
@@ -22,7 +22,7 @@ import { childEnv } from '../childEnv.js'
22
22
  import { stopTree } from '../processTree.js'
23
23
  import { join } from 'node:path'
24
24
  import { createFrameParser, encodeMessage } from '../lsp/jsonrpc.js'
25
- import { sourceBaseOf, sourceKeyOf, sourcePathOf, normalizeSourcePath } from './sourcePath.js'
25
+ import { sourceBaseOf, sourceKeyOf, sourcePathOf, normalizeSourcePath, rankSourceCandidates } from './sourcePath.js'
26
26
  import { bpAcksOfResponse, DAP_ACTIONS, needsConfigurationDone, requestOfConfig, threadIdOfStopped, type DapAction, type DapBreakpointInput, type DapBreakpointAck, type DapDebugConfig, type DapEventView, type DapFrameView, type DapPhase, type DapPollResult, type DapScopeView, type DapStateView, type DapVariableView } from '../shared/dap.js'
27
27
  import { dapRequest, eventBody, initializeArgs, isDapEvent, isDapResponse, responseBody, type DapEvent, type DapMessage, type DapRequest } from './protocol.js'
28
28
  import type { DapAdapterSpec } from './provider.js'
@@ -458,18 +458,50 @@ export class DapSession {
458
458
  return '会话在初始化完成前终止(attach/连接目标失败)' + dotrushLog
459
459
  }
460
460
 
461
- /** findFileReq 反向应答:按完整 chunkname 排序工作区候选并缓存首命中。 */
461
+ /**
462
+ * 按当前语言过滤并排序源码候选,供反向应答与栈帧解析共用。
463
+ * @private
464
+ * @author ddj 2026年10月09号
465
+ * @param chunk 适配器上报的源码标识
466
+ * @returns 语言合法、按路径相关度排序的候选
467
+ */
468
+ private async sourceCandidates(chunk: string): Promise<string[]> {
469
+ const files = await this.deps.findFiles(this.workspacePath, chunk)
470
+ const exts = this.frameExts()
471
+ const allowed = exts.length ? files.filter((file) => hasSourceExtension(file, exts)) : files
472
+ return rankSourceCandidates(chunk, allowed, this.workspacePath)
473
+ }
474
+
475
+ /**
476
+ * 更新源码标识及 basename 缓存;无合法候选时删除旧映射。
477
+ * @private
478
+ * @author ddj 2026年10月09号
479
+ * @param chunk 适配器上报的源码标识
480
+ * @param file 合法候选首项,空值表示未解析
481
+ * @description 副作用:更新当前会话源码缓存
482
+ */
483
+ private cacheSource(chunk: string, file: string): void {
484
+ for (const key of [sourceKeyOf(chunk), sourceKeyOf(sourceBaseOf(chunk))]) {
485
+ if (!key) continue
486
+ if (file) this.sourceFiles.set(key, file)
487
+ else this.sourceFiles.delete(key)
488
+ }
489
+ }
490
+
491
+ /**
492
+ * findFileReq 反向应答:只返回当前语言合法源码并缓存首命中。
493
+ * @private
494
+ * @author ddj 2026年10月09号
495
+ * @param body 反向请求参数
496
+ * @description 副作用:更新源码缓存并发送 findFileRsp
497
+ */
462
498
  private async respondFindFile(body: Record<string, unknown>): Promise<void> {
463
499
  const seq = typeof body.seq === 'number' ? body.seq : -1
464
500
  const chunk = String(body.file ?? '')
465
- const files = await this.deps.findFiles(this.workspacePath, chunk)
501
+ const files = await this.sourceCandidates(chunk)
466
502
  log.debug('[dap-trace] findFileReq seq=' + seq + ' chunk=' + chunk + ' candidates=' + files.length + ' first=' + (files[0] ?? ''))
467
503
  this.deps.trace?.(this.workspacePath, '[DEBUG findFileReq] seq=' + seq + ' chunk=' + chunk + ' candidates=' + files.length + ' first=' + (files[0] ?? ''))
468
- const first = files[0]
469
- if (first) {
470
- this.sourceFiles.set(sourceKeyOf(chunk), first)
471
- this.sourceFiles.set(sourceKeyOf(sourceBaseOf(chunk)), first)
472
- }
504
+ this.cacheSource(chunk, files[0] ?? '')
473
505
  this.send({ seq: this.msgSeq++, type: 'request', command: 'findFileRsp', arguments: { seq, files } })
474
506
  }
475
507
 
@@ -583,27 +615,43 @@ export class DapSession {
583
615
  if (phase === 'idle') { this.events = []; this.config = null; this.pid = undefined; this.error = undefined }
584
616
  }
585
617
 
586
- /** 适配器上报路径 → 帧视图(file 始终保留可打开的规范路径)。 */
618
+ /**
619
+ * 适配器源码标识转帧视图;完整合法路径优先,错误语言缓存须重新解析。
620
+ * @private
621
+ * @author ddj 2026年10月09号
622
+ * @param item 适配器栈帧
623
+ * @param index 栈帧下标(EmmyLua frameId)
624
+ * @returns 保留原始行号、只含合法源码路径的帧视图
625
+ * @description 副作用:按需查找源码并更新会话缓存
626
+ */
587
627
  private async frameViewOf(item: unknown, index: number): Promise<DapFrameView> {
588
628
  const raw = item as Record<string, unknown>
589
629
  const source = (raw.source ?? {}) as Record<string, unknown>
590
630
  const sourcePath = typeof source.path === 'string' ? source.path : ''
591
631
  const sourceName = typeof source.name === 'string' ? source.name : ''
632
+ const exts = this.frameExts()
592
633
  const cached = this.sourceFiles.get(sourceKeyOf(sourcePath)) ?? this.sourceFiles.get(sourceKeyOf(sourceName))
593
- let selected = cached || sourcePath || sourceName
594
- const needsLookup = !selected || !hasSourceExtension(selected, this.frameExts())
634
+ const validPath = sourcePath && !isBareChunk(sourcePath)
635
+ && (exts.length ? hasSourceExtension(sourcePath, exts) : sourceBaseOf(sourcePath).includes('.'))
636
+ const validCache = cached && (!exts.length || hasSourceExtension(cached, exts))
637
+ let selected = validPath ? sourcePath : validCache ? cached : sourcePath || sourceName
638
+ const needsLookup = !validPath && !validCache
639
+ if (cached && !validCache) {
640
+ this.cacheSource(sourcePath, '')
641
+ this.cacheSource(sourceName, '')
642
+ }
595
643
  log.debug('[dap-trace] frame rawPath=' + sourcePath + ' name=' + sourceName + ' cached=' + (cached ?? '') + ' needsLookup=' + needsLookup + ' selected=' + selected)
596
644
  this.deps.trace?.(this.workspacePath, '[DEBUG frame] rawPath=' + sourcePath + ' name=' + sourceName + ' cached=' + (cached ?? '') + ' needsLookup=' + needsLookup + ' selected=' + selected)
597
- if (!cached && needsLookup) {
598
- const candidates = await this.deps.findFiles(this.workspacePath, sourceName || sourcePath)
645
+ if (needsLookup) {
646
+ const candidates = await this.sourceCandidates(sourceName || sourcePath)
599
647
  log.debug('[dap-trace] frameLookup source=' + (sourceName || sourcePath) + ' candidates=' + candidates.length + ' first=' + (candidates[0] ?? ''))
600
648
  this.deps.trace?.(this.workspacePath, '[DEBUG frameLookup] source=' + (sourceName || sourcePath) + ' candidates=' + candidates.length + ' first=' + (candidates[0] ?? ''))
601
649
  if (candidates[0]) {
602
650
  selected = candidates[0]
603
- this.sourceFiles.set(sourceKeyOf(sourceName || sourcePath), candidates[0])
604
- } else if (isBareChunk(sourceName || sourcePath)) {
605
- // 反查失败 + 裸 chunkname:无可打开路径。置空而非回退原串,
606
- // 避免客户端按工作区根拼出必然不存在的路径、打开注定失败的页签。
651
+ this.cacheSource(sourceName || sourcePath, candidates[0])
652
+ } else if (exts.length || isBareChunk(sourceName || sourcePath)) {
653
+ // 反查失败且当前语言已知或只有裸 chunk:不回退到错误语言文件,
654
+ // 也不让客户端按工作区根拼出不存在的 chunk 路径。
607
655
  selected = ''
608
656
  }
609
657
  }
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import { registerRoutes } from './routes.js'
3
3
  import { captureToolResult } from './capture.js'
4
4
  import { closeArchiveDbs } from './archiveDb.js'
5
- import { handleRpc } from './rpc.js'
5
+ import { handleRpc, integrationBaseUrlOf } from './rpc.js'
6
6
  import { newSearcher } from './search/orchestrator.js'
7
7
  import { newContentSearcher } from './search/content.js'
8
8
  import { installIsolation } from './mcpIsolation.js'
@@ -13,6 +13,7 @@ import type { SettingsDeps } from './fileOpenSettings.js'
13
13
  import z from '@deepseek-ai/schemastery'
14
14
  import { shellMenuLifecycle } from './integrate.js'
15
15
  import { startOpenInbox } from './openInbox.js'
16
+ import { ensureActiveBridge } from './openBridge.js'
16
17
  import { installPlanTool } from './svnPlanTool.js'
17
18
  import { disposeIndex } from './treeIndex.js'
18
19
  import { sweepTreeCache } from './paths.js'
@@ -142,6 +143,13 @@ export async function apply(ctx: Ctx, config?: unknown): Promise<void> {
142
143
  await installPlanTool(ctx, submitPlan)
143
144
  ctx.effect(mountInbox, 'vscode-mode:open-inbox')
144
145
  ctx.effect(() => shellMenuLifecycle(ctx), 'vscode-mode:integration')
146
+ /** @author ddj 2026年10月12号 @returns Active-profile bridge disposer; failures never block boot. */
147
+ async function provisionBridge(): Promise<() => void> {
148
+ try { await ensureActiveBridge(ctx, await integrationBaseUrlOf(ctx)) }
149
+ catch (error) { noteWarning('外部打开桥接未就绪:' + String(error)) }
150
+ return () => {}
151
+ }
152
+ ctx.effect(provisionBridge, 'vscode-mode:bridge')
145
153
  void sweepTreeCache()
146
154
  await logCompatSummary(ctx, warnings)
147
155
  log.info('编辑差异审查已装配(认证路由、项目 MCP 作用域、SVN 方案工具与外部打开通道)')
package/src/model.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * 作者 ddj 2026-08-20
5
5
  */
6
6
  import type { Decision, DiffRecord, Hunk, RecordSummary } from './shared/types.js'
7
- import { applyLocations, fingerprint, isNoopHunk, locateHunks, preciseHunk } from './shared/diff.js'
7
+ import { applyLocations, fingerprint, isNoopHunk, locateHunks, normalizeForCompare, normalizeHunk, preciseHunk } from './shared/diff.js'
8
8
 
9
9
  /** 工作区记录桶上限(超过后按 at 最旧剔除)。 */
10
10
  export const MAX_RECORDS = 200
@@ -157,6 +157,117 @@ export function reconstructOriginal(records: DiffRecord[], content: string): { c
157
157
  }
158
158
  }
159
159
 
160
+ // #region 过程抵消
161
+
162
+ /**
163
+ * @public
164
+ * @author ddj 2026年10月10号
165
+ * 判断记录是否完全未决策;缺少逐块状态时沿用 pending 的 call 状态。
166
+ * @param record 审查记录
167
+ * @returns 未归档、未覆盖且没有用户决策时为 true
168
+ */
169
+ export function fullyPending(record: DiffRecord): boolean {
170
+ return !record.archived && !record.superseded && record.decisions.call === 'pending'
171
+ && record.decisions.perHunk.every((value) => value === 'pending')
172
+ }
173
+
174
+ /**
175
+ * @public
176
+ * @author ddj 2026年10月10号
177
+ * 比较整组 hunk 是否逐块精确逆反;不把新建或纯 no-op 当作抵消。
178
+ * @param prior 先前修改的差异块
179
+ * @param later 后续修改的差异块
180
+ * @returns 文本归一化后整组逆反且包含真实修改时为 true
181
+ */
182
+ export function hunksInverse(prior: Hunk[], later: Hunk[]): boolean {
183
+ if (!prior.length || prior.length !== later.length) return false
184
+ let changed = false
185
+ for (let idx = 0; idx < prior.length; idx++) {
186
+ const a = normalizeHunk(prior[idx])
187
+ const b = normalizeHunk(later[idx])
188
+ if (a.oldText !== b.newText || a.newText !== b.oldText) return false
189
+ if (!isNoopHunk(a)) changed = true
190
+ }
191
+ return changed
192
+ }
193
+
194
+ /**
195
+ * @private
196
+ * @author ddj 2026年10月10号
197
+ * 同路径优先按捕获批次排序;旧记录仅使用有效时间,不拿 callId 伪造先后证据。
198
+ * @param a 第一条记录
199
+ * @param b 第二条记录
200
+ * @returns 负数表示 a 更早,零表示先后无法区分
201
+ */
202
+ function recordOrder(a: DiffRecord, b: DiffRecord): number {
203
+ if (a.path !== b.path) return a.path.localeCompare(b.path)
204
+ if (Number.isInteger(a.batch) && Number.isInteger(b.batch)
205
+ && a.batch > 0 && b.batch > 0 && a.batch !== b.batch) return a.batch - b.batch
206
+ const delta = Date.parse(a.at) - Date.parse(b.at)
207
+ return Number.isFinite(delta) ? delta : 0
208
+ }
209
+
210
+ /**
211
+ * @private
212
+ * @author ddj 2026年10月10号
213
+ * 确认完整文件快照往返,防止相同文本在不同位置的反向修改被误隐藏。
214
+ * @param prior 先前记录
215
+ * @param later 后续记录
216
+ * @returns 两端快照互换且文件存在性未变化时为 true;缺少证据时保留审查
217
+ */
218
+ function snapshotsRestore(prior: DiffRecord, later: DiffRecord): boolean {
219
+ if (prior.create || later.create) return false
220
+ if (typeof prior.before !== 'string' || typeof prior.after !== 'string'
221
+ || typeof later.before !== 'string' || typeof later.after !== 'string') return false
222
+ return normalizeForCompare(prior.before) === normalizeForCompare(later.after)
223
+ && normalizeForCompare(prior.after) === normalizeForCompare(later.before)
224
+ }
225
+
226
+ /**
227
+ * @public
228
+ * @author ddj 2026年10月10号
229
+ * 查找最近一条未决的整记录逆反;必须有文件快照证明原内容确实恢复。
230
+ * @param records 工作区记录(不修改输入)
231
+ * @param record 新记录
232
+ * @returns 可安全配对的先前记录,证据不足或部分撤销时为 null
233
+ */
234
+ export function findInversePrior(records: DiffRecord[], record: DiffRecord): DiffRecord | null {
235
+ if (!fullyPending(record)) return null
236
+ let found: DiffRecord | null = null
237
+ for (const prior of records) {
238
+ if (prior.callId === record.callId || prior.path !== record.path || !fullyPending(prior)) continue
239
+ if (recordOrder(prior, record) >= 0 || !hunksInverse(prior.hunks, record.hunks)) continue
240
+ if (!snapshotsRestore(prior, record)) continue
241
+ if (!found || recordOrder(found, prior) < 0) found = prior
242
+ }
243
+ return found
244
+ }
245
+
246
+ /**
247
+ * @public
248
+ * @author ddj 2026年10月10号
249
+ * 按捕获顺序贪心配对历史抵消记录,每条最多使用一次;桶上限为 200。
250
+ * @param records 工作区记录(不修改输入或记录状态)
251
+ * @returns 可以整对归档的记录对
252
+ */
253
+ export function findCanceledPairs(records: DiffRecord[]): Array<[DiffRecord, DiffRecord]> {
254
+ const available: DiffRecord[] = []
255
+ const pairs: Array<[DiffRecord, DiffRecord]> = []
256
+ const ordered = [...records].sort(recordOrder)
257
+ for (const record of ordered) {
258
+ const prior = findInversePrior(available, record)
259
+ if (!prior) {
260
+ if (fullyPending(record)) available.push(record)
261
+ continue
262
+ }
263
+ pairs.push([prior, record])
264
+ available.splice(available.indexOf(prior), 1)
265
+ }
266
+ return pairs
267
+ }
268
+
269
+ // #endregion
270
+
160
271
  /** 文件当前最大批次号(无记录为 0)。 */
161
272
  export function fileMaxBatch(records: Map<string, DiffRecord>, path: string): number {
162
273
  let m = 0
package/src/openBridge.ts CHANGED
@@ -56,6 +56,19 @@ export async function ensureOpenBridge(ctx: Ctx, baseUrl: string): Promise<strin
56
56
  return join(dir, 'dsh-open.ini')
57
57
  }
58
58
 
59
+ /** @public @author ddj 2026年10月12号
60
+ * Provision the active profile's independent bridge at startup (desktop or web).
61
+ * Mirrors unityInstall's per-profile bridge without requiring a registered project,
62
+ * so Unity can select either target. Unknown profile returns null instead of failing.
63
+ * @param ctx Active host context.
64
+ * @param baseUrl Selected public Web URL (unused for desktop wake).
65
+ * @returns Absolute profile bridge INI path, or null when no owned profile is active.
66
+ */
67
+ export async function ensureActiveBridge(ctx: Ctx, baseUrl: string): Promise<string | null> {
68
+ if (!findProfileDir(ctx)) return null
69
+ return ensureOpenBridge(ctx, baseUrl)
70
+ }
71
+
59
72
  /** @private @author ddj 2026年09月28号
60
73
  * Replace a bridge file atomically without following a preexisting file link.
61
74
  * @param path Destination in the validated bridge directory.
package/src/rpc.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  READ_CAP,
14
14
  appendArchiveEntries,
15
15
  archiveRecords,
16
+ archiveCanceled,
16
17
  autoArchiveStale,
17
18
  policyOf,
18
19
  resolveTarget,
@@ -460,7 +461,7 @@ async function latestPatchBackup(patchPath: string): Promise<string | undefined>
460
461
  }
461
462
 
462
463
  /** 读取设置中的深链基址(缺省/非法回退默认 3080)。 */
463
- async function integrationBaseUrlOf(ctx: Ctx): Promise<string> {
464
+ export async function integrationBaseUrlOf(ctx: Ctx): Promise<string> {
464
465
  const settings = ctx.get('settings')
465
466
  const value = sectionOf(settings, FILE_OPEN_SETTINGS_NS)?.value as { integrationBaseUrl?: unknown } | undefined
466
467
  const raw = typeof value?.integrationBaseUrl === 'string' ? value.integrationBaseUrl.trim() : ''
@@ -613,6 +614,7 @@ export function buildHandlers(
613
614
  const staleStart = performance.now()
614
615
  // #endregion
615
616
  await autoArchiveStale(ctx, sc.session, sc.cwd, bucket)
617
+ await archiveCanceled(ctx, sc.session, sc.cwd, bucket)
616
618
  // #region debug log
617
619
  staleMs = performance.now() - staleStart
618
620
  // #endregion