dsh-vscode-mode 0.14.5 → 0.14.7

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
@@ -0,0 +1,102 @@
1
+ /** Workspace-local Ctrl+F state bridge; ddj 2026年10月09号. */
2
+ export interface FindSnapshot {
3
+ searchString: string
4
+ isRevealed: boolean
5
+ matchCase: boolean
6
+ wholeWord: boolean
7
+ isRegex: boolean
8
+ }
9
+
10
+ interface FindState extends FindSnapshot {
11
+ change(value: Partial<FindSnapshot> & { searchScope?: null }, moveCursor: boolean): void
12
+ onFindReplaceStateChange(listener: () => void): { dispose(): void }
13
+ }
14
+ interface FindController {
15
+ getState(): FindState
16
+ start(options: Record<string, unknown>, state: FindSnapshot & { searchScope: null }): Promise<unknown>
17
+ }
18
+ export interface FindEditor {
19
+ getContribution(id: string): unknown
20
+ }
21
+ const snapshots = new Map<string, FindSnapshot>()
22
+
23
+ /** @private @author ddj 2026年10月09号 @param state Native find state. @returns Only fields safe to share across files. */
24
+ function snapshotOf(state: FindState): FindSnapshot {
25
+ return {
26
+ searchString: state.searchString, isRevealed: state.isRevealed,
27
+ matchCase: state.matchCase, wholeWord: state.wholeWord, isRegex: state.isRegex,
28
+ }
29
+ }
30
+
31
+ /** @private @author ddj 2026年10月09号 @param editor Monaco editor. @returns Supported controller, or null for native-only fallback. */
32
+ function controllerOf(editor: FindEditor): FindController | null {
33
+ try {
34
+ const controller = editor.getContribution('editor.contrib.findController') as FindController | null
35
+ const state = controller?.getState?.()
36
+ return state && typeof state.change === 'function'
37
+ && typeof state.onFindReplaceStateChange === 'function'
38
+ && typeof controller?.start === 'function' ? controller : null
39
+ } catch { return null }
40
+ }
41
+
42
+ /** @private @author ddj 2026年10月09号 @param controller Optional native controller. @param listener State observer. @returns Disposable subscription, or null when the internal API is incompatible. */
43
+ function listenFind(controller: FindController | null, listener: () => void): { dispose(): void } | null {
44
+ try { return controller?.getState().onFindReplaceStateChange(listener) ?? null } catch { return null }
45
+ }
46
+
47
+ /** @private @author ddj 2026年10月09号 @param controller Native controller. @param saved Workspace snapshot. @returns Completion when revealing; never focuses or seeds from selection. */
48
+ function applyFind(controller: FindController, saved: FindSnapshot): Promise<unknown> | void {
49
+ const value = { ...saved, searchScope: null }
50
+ if (!saved.isRevealed) { controller.getState().change(value, false); return }
51
+ return controller.start({
52
+ forceRevealReplace: false, seedSearchStringFromSelection: 'none',
53
+ seedSearchStringFromNonEmptySelection: false, seedSearchStringFromGlobalClipboard: false,
54
+ shouldFocus: 0, shouldAnimate: false, updateSearchScope: false, loop: true,
55
+ }, value)
56
+ }
57
+
58
+ /** @public @author ddj 2026年10月09号 @param editor Monaco editor. @returns Model-switch bridge; dispose releases the native listener. */
59
+ export function createFindBridge(editor: FindEditor) {
60
+ const controller = controllerOf(editor)
61
+ let scope: string | null = null
62
+ let paused = true
63
+ let disposed = false
64
+
65
+ /** @private @author ddj 2026年10月09号 Capture only user changes, not model-reset or restoration events. */
66
+ function capture(): void {
67
+ if (disposed || paused || !scope || !controller) return
68
+ try {
69
+ const saved = snapshotOf(controller.getState())
70
+ snapshots.delete(scope)
71
+ snapshots.set(scope, saved)
72
+ if (snapshots.size > 20) snapshots.delete(snapshots.keys().next().value!)
73
+ } catch { /* A disposed contribution must not interrupt view capture. */ }
74
+ }
75
+ const listener = listenFind(controller, capture)
76
+
77
+ /** @public @author ddj 2026年10月09号 Save before setModel; suppress Monaco's per-model option resets. */
78
+ function pause(): void { capture(); paused = true }
79
+
80
+ /** @public @author ddj 2026年10月09号 @param nextScope Workspace owning the bound model. Restore with moveCursor=false; no per-file range or match index. */
81
+ function resume(nextScope: string): void {
82
+ if (disposed || !controller) return
83
+ scope = nextScope
84
+ const saved = snapshots.get(scope) ?? {
85
+ searchString: '', isRevealed: false, matchCase: false, wholeWord: false, isRegex: false,
86
+ }
87
+ try {
88
+ const result = applyFind(controller, saved)
89
+ result?.catch(() => {}) // Unsupported internal API leaves native Ctrl+F usable.
90
+ } catch { /* Internal contribution compatibility must not break the editor. */ }
91
+ paused = false
92
+ }
93
+
94
+ /** @public @author ddj 2026年10月09号 Save final query and remove listener before editor destruction. */
95
+ function dispose(): void {
96
+ if (disposed) return
97
+ capture()
98
+ disposed = true
99
+ try { listener?.dispose() } catch { /* Already disposed by native editor cleanup. */ }
100
+ }
101
+ return { pause, resume, dispose }
102
+ }
@@ -0,0 +1,56 @@
1
+ /** Model-owned view/find lifecycle; ddj 2026年10月09号. */
2
+ import { createFindBridge, type FindEditor } from './editorFindState.js'
3
+ import { upsertViewState, viewStatesLoad, viewStatesSave } from './viewStateCache.js'
4
+
5
+ export interface ViewEditor extends FindEditor {
6
+ getModel(): unknown
7
+ setModel(model: unknown): void
8
+ saveViewState(): unknown
9
+ restoreViewState(state: unknown): void
10
+ dispose(): void
11
+ }
12
+
13
+ /** @public @author ddj 2026年10月09号 @param editor Monaco instance. @returns Keeper owning the model's scope/path, independent of React's active closure. */
14
+ export function createViewKeeper(editor: ViewEditor) {
15
+ const find = createFindBridge(editor)
16
+ let bound: { scope: string; path: string; model: unknown } | null = null
17
+ let disposed = false
18
+
19
+ /** @public @author ddj 2026年10月09号 @param path Optional expected path; mismatches never write a snapshot under the wrong file. Persist viewport even if cursor never moved. */
20
+ function save(path?: string): void {
21
+ if (disposed || !bound || (path && path !== bound.path)) return
22
+ try {
23
+ if (editor.getModel() !== bound.model) return
24
+ const states = viewStatesLoad(bound.scope)
25
+ const next = upsertViewState(states, bound.path, editor.saveViewState())
26
+ if (next !== states) viewStatesSave(bound.scope, next)
27
+ } catch { /* A missing/disposed model must not overwrite a valid snapshot. */ }
28
+ }
29
+
30
+ /** @public @author ddj 2026年10月09号 @param scope Model workspace. @param path Cache path. @param model Ready text model. Restore only on identity change, before explicit navigation effects. */
31
+ function bind(scope: string, path: string, model: unknown): void {
32
+ if (disposed || !path || !model) return
33
+ if (bound?.scope === scope && bound.path === path && bound.model === model
34
+ && editor.getModel() === model) return
35
+ save()
36
+ find.pause()
37
+ if (editor.getModel() !== model) editor.setModel(model)
38
+ bound = { scope, path, model }
39
+ const state = viewStatesLoad(scope)[path]
40
+ if (state) {
41
+ try { editor.restoreViewState(state) } catch { /* Stale fold ranges may be invalid. */ }
42
+ }
43
+ find.resume(scope)
44
+ }
45
+
46
+ /** @public @author ddj 2026年10月09号 Save before dispose/ref clearing; repeated cleanup is a no-op. */
47
+ function dispose(): void {
48
+ if (disposed) return
49
+ save()
50
+ find.dispose()
51
+ disposed = true
52
+ bound = null
53
+ editor.dispose()
54
+ }
55
+ return { save, bind, dispose }
56
+ }
@@ -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
+ }
@@ -53,7 +53,7 @@ import { displayDiffTotal, editorDockMode } from '../diffDock.js'
53
53
  import { editorHeight } from '../editorLayout.js'
54
54
  import { revealInExplorer as revealPathInExplorer } from '../fileReveal.js'
55
55
  import { setSidePending, SIDEBAR_INSTALL_CMD } from '../sidebarBridge.js'
56
- import { upsertViewState, viewStatesLoad, viewStatesSave } from '../state/viewStateCache.js'
56
+ import { createViewKeeper } from '../state/editorViewState.js'
57
57
  import { mdPreviewLoad, mdPreviewSave } from '../state/mdPreviewCache.js'
58
58
  import { onUiStateReady, reportUiStateEvent, uiStateSettled } from '../state/uiStatePersist.js'
59
59
  import { migrateScopedKeys, workspaceScopeOf } from '../state/scopeStore.js'
@@ -333,8 +333,8 @@ export function EditorView(props) {
333
333
  const modelsRef = React.useRef(null)
334
334
  // model 跨挂载缓存(按工作区作用域):重挂载秒显内容,消除切换对话闪烁
335
335
  if (!modelsRef.current) modelsRef.current = modelsForScope(scope)
336
- // 视图状态缓存(path → Monaco viewState;重启后恢复光标/滚动/折叠位置)
337
- const viewStatesRef = React.useRef({})
336
+ // 视图/查找生命周期跟随实际绑定的 model,不能从新一帧 active/scope 猜旧模型归属。
337
+ const viewKeeperRef = React.useRef(null)
338
338
  // 防抖保存槽(arm/flush/cancel;见 saveDebounce.ts 的缺陷说明,勿退回单一取消句柄)
339
339
  const saveTimerRef = React.useRef(createSaveTimer())
340
340
  const savingRef = React.useRef(new Set()) // 在途保存的路径(关闭前落盘去重,防同内容双发)
@@ -561,39 +561,16 @@ export function EditorView(props) {
561
561
  return normalized
562
562
  }
563
563
 
564
- /**
565
- * 保存当前活动文件的视图状态(光标/滚动/折叠)到工作区作用域缓存。
566
- * @author ddj 2026年08月28号
567
- * @param path 要保存的文件路径(缺省 = 当前 active)
568
- */
569
- const saveViewState = (path) => {
570
- const ed = editorRef.current
571
- const target = path ?? active
572
- if (!ed || !target) return
573
- try {
574
- const state = ed.saveViewState()
575
- if (!state) return
576
- viewStatesRef.current = upsertViewState(viewStatesRef.current, target, state)
577
- viewStatesSave(scope, viewStatesRef.current)
578
- } catch (e) { /* 视图状态保存失败忽略 */ }
579
- }
564
+ /** @private @author ddj 2026年10月09号 @param path 可选预期文件路径。按已绑定模型归属保存,避免新 active 覆盖旧文件状态。 */
565
+ const saveViewState = (path) => viewKeeperRef.current?.save(path)
580
566
  saveViewStateRef.current = saveViewState
581
567
 
582
- /**
583
- * 恢复指定文件的视图状态(内容就绪、setModel 后调用;恢复后即消费删除)。
584
- * @author ddj 2026年08月28号
585
- * @param path 文件路径
586
- */
587
- const restoreViewState = (path) => {
588
- const ed = editorRef.current
589
- if (!ed || !path) return
590
- const saved = viewStatesRef.current[path]
591
- if (!saved) return
592
- const next = Object.assign({}, viewStatesRef.current)
593
- delete next[path]
594
- viewStatesRef.current = next
595
- try { ed.restoreViewState(saved) } catch (e) { /* 恢复失败忽略(行号越界自动兜底) */ }
596
- }
568
+ /** @private @author ddj 2026年10月09号 ref 脱离与 effect 卸载共用:保存视图/查找后销毁,重复调用无副作用。 */
569
+ const disposeEditor = React.useCallback(() => {
570
+ viewKeeperRef.current?.dispose()
571
+ viewKeeperRef.current = null
572
+ editorRef.current = null
573
+ }, [])
597
574
 
598
575
  /**
599
576
  * 采集当前焦点条目:路径从编辑器 model 实时读取(避免空依赖闭包读到过期 active),
@@ -1166,14 +1143,14 @@ export function EditorView(props) {
1166
1143
  const onDiskChange = (change) => {
1167
1144
  if (!change || !change.path) return
1168
1145
  // 文件树同步:目录缓存按该路径失效并强制重列(外部新增/删除/改名都能看到)
1169
- emitFileChanged(change.path)
1146
+ const eventId = emitFileChanged(change.path, { scope, kind: change.kind === 'deleted' ? 'structure' : 'text' })
1170
1147
  if (change.kind === 'modified') {
1171
1148
  // 干净缓冲:强制从磁盘重读(跳过 model 复用),差异标记随之重算
1172
1149
  loadContent(change.path, sessionId, true)
1173
1150
  clearSync(scope, change.path)
1174
1151
  setDiskFlag((prev) => (prev && prev.path === change.path ? null : prev))
1175
1152
  setStatus('已同步外部修改 ' + new Date().toTimeString().slice(0, 8))
1176
- emitRefresh()
1153
+ emitRefresh({ scope, paths: [change.path], kind: 'text', eventId })
1177
1154
  return
1178
1155
  }
1179
1156
  const kind = change.kind === 'deleted' ? 'deleted' : 'conflict'
@@ -1366,7 +1343,6 @@ export function EditorView(props) {
1366
1343
  restoredScopeRef.current = scope
1367
1344
  // model 缓存跟随作用域(切工作区释放旧作用域模型;同工作区跨挂载复用)
1368
1345
  modelsRef.current = modelsForScope(scope)
1369
- viewStatesRef.current = viewStatesLoad(scope)
1370
1346
  // Markdown 预览态同样按作用域恢复(重启/换端口后由界面状态镜像回填,见 lateHydrateRef 分支)
1371
1347
  mdPreviewRef.current = mdPreviewLoad(String(scope))
1372
1348
  try {
@@ -1992,7 +1968,7 @@ export function EditorView(props) {
1992
1968
  clearSync(scope, path)
1993
1969
  acceptSaved(path, text, savedModel)
1994
1970
  refreshRecords()
1995
- emitRefresh()
1971
+ emitRefresh({ scope, paths: [path], kind: 'text' })
1996
1972
  // 片段配置文件保存后失效补全缓存(下次补全即读到新片段)
1997
1973
  if (/\.code-snippets$/i.test(path)) window.dispatchEvent(new CustomEvent('edrv:snippets-changed'))
1998
1974
  return
@@ -2077,11 +2053,10 @@ export function EditorView(props) {
2077
2053
  if (!monaco || !editorRef.current || !active || !contentReady) return
2078
2054
  const ed = editorRef.current
2079
2055
  const model = getModel(active, content)
2080
- if (ed.getModel() !== model) ed.setModel(model)
2056
+ viewKeeperRef.current?.bind(String(scope), active, model)
2081
2057
  syncTypeSuggest(ed, monaco)
2082
- restoreViewState(active)
2083
2058
  setLoadStage((prev) => ({ progress: Math.max(96, prev.progress), message: '创建编辑器视图…' }))
2084
- }, [monaco, active, content, contentReady, mdPreviewing, svnDiff])
2059
+ }, [monaco, scope, active, content, contentReady, mdPreviewing, svnDiff])
2085
2060
 
2086
2061
  // PDF 面板外壳 ref 回调(div 仅在 PDF 分支渲染,refs 先于 effect 就绪)
2087
2062
  const ensurePdfHost = (node) => { pdfHostRef.current = node }
@@ -2140,7 +2115,7 @@ export function EditorView(props) {
2140
2115
  // 跳转目标高亮的计时器随卸载清理(装饰随编辑器 dispose 一并消失)
2141
2116
  if (navFlashTimerRef.current) { clearTimeout(navFlashTimerRef.current); navFlashTimerRef.current = null }
2142
2117
  navFlashRef.current = []
2143
- if (editorRef.current) { editorRef.current.dispose(); editorRef.current = null }
2118
+ disposeEditor()
2144
2119
  // model 不在此销毁:跨挂载缓存按作用域存活(modelCache 切作用域时统一释放)
2145
2120
  for (const ctl of pdfCtlRef.current.values()) ctl.destroy()
2146
2121
  pdfCtlRef.current.clear()
@@ -2155,7 +2130,7 @@ export function EditorView(props) {
2155
2130
  // 普通 Monaco 编辑器(可编辑、单列行号)+ 自绘行内差异(decoration 绿底+ / view zone 删除块)
2156
2131
  const ensureEditor = React.useCallback((node) => {
2157
2132
  if (!node) {
2158
- if (editorRef.current) { editorRef.current.dispose(); editorRef.current = null }
2133
+ disposeEditor()
2159
2134
  return
2160
2135
  }
2161
2136
  if (editorRef.current || !monacoRef.current) return
@@ -2367,6 +2342,7 @@ export function EditorView(props) {
2367
2342
  }
2368
2343
  node.addEventListener('contextmenu', onBpCtxCapture, true)
2369
2344
  editorRef.current = ed
2345
+ viewKeeperRef.current = createViewKeeper(ed)
2370
2346
  }, [])
2371
2347
 
2372
2348
  // 调试断点装饰:断点表/活动文件变化时全量替换行首圆点(独立 collection,与差异/下划线互不干扰);
@@ -2580,7 +2556,7 @@ export function EditorView(props) {
2580
2556
  setDirtyMap((d) => Object.assign({}, d, { [path]: false }))
2581
2557
  setDiskFlag((prev) => (prev && prev.path === path ? null : prev))
2582
2558
  setStatus('已覆盖磁盘')
2583
- emitRefresh()
2559
+ emitRefresh({ scope, paths: [path], kind: 'text' })
2584
2560
  return
2585
2561
  }
2586
2562
  setStatus('覆盖失败')
@@ -2793,7 +2769,7 @@ export function EditorView(props) {
2793
2769
  // #endregion
2794
2770
  if (res && res.ok) {
2795
2771
  setRecords((prev) => Object.assign({}, prev, { [region.callId]: res.record }))
2796
- if (!silent) { reloadFile(); emitRefresh() }
2772
+ if (!silent) { reloadFile(); emitRefresh({ scope, paths: [res.record?.path || active], kind: 'structure' }) }
2797
2773
  return true
2798
2774
  }
2799
2775
  if (!silent) setError(res?.error ? String(res.error) : '操作失败')
@@ -2882,7 +2858,7 @@ export function EditorView(props) {
2882
2858
  actMany(decideRegions.map((r) => itemOf(r, false))).then((result) => {
2883
2859
  batchBusyRef.current = false
2884
2860
  reloadFile(true)
2885
- emitRefresh()
2861
+ emitRefresh({ scope, paths: active ? [active] : undefined, kind: 'structure' })
2886
2862
  setStatus(decideStatus('已采纳 ', result))
2887
2863
  if (result.fail) setError(result.fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
2888
2864
  }).catch(() => { batchBusyRef.current = false })
@@ -2893,7 +2869,7 @@ export function EditorView(props) {
2893
2869
  actMany([...decideRegions].reverse().map((r) => itemOf(r, true))).then((result) => {
2894
2870
  batchBusyRef.current = false
2895
2871
  reloadFile(true)
2896
- emitRefresh()
2872
+ emitRefresh({ scope, paths: active ? [active] : undefined, kind: 'structure' })
2897
2873
  setStatus(decideStatus('已不采纳 ', result))
2898
2874
  if (result.fail) setError(result.fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
2899
2875
  }).catch(() => { batchBusyRef.current = false })
@@ -2923,10 +2899,11 @@ export function EditorView(props) {
2923
2899
  if (batchBusyRef.current || !allPending.length) return
2924
2900
  batchBusyRef.current = true
2925
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))]
2926
2903
  actMany(list.map((r) => itemOf(r, reject))).then((result) => {
2927
2904
  batchBusyRef.current = false
2928
2905
  reloadFile(true)
2929
- emitRefresh()
2906
+ emitRefresh({ scope, paths: paths.length ? paths : undefined, kind: 'structure' })
2930
2907
  setStatus(decideStatus(reject ? '已不采纳 ' : '已采纳 ', result))
2931
2908
  if (result.fail) setError(result.fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
2932
2909
  }).catch(() => { batchBusyRef.current = false })
@@ -2939,7 +2916,7 @@ export function EditorView(props) {
2939
2916
  rpc('edrv.rollback', { sessionId, path: active }).then((res) => {
2940
2917
  if (res && res.ok) {
2941
2918
  setStatus('已回滚'); reloadFile()
2942
- emitRefresh()
2919
+ emitRefresh({ scope, paths: [active], kind: 'structure' })
2943
2920
  } else setError(res?.error ? String(res.error) : '回滚失败')
2944
2921
  }).catch((e) => setError('回滚异常:' + String(e)))
2945
2922
  }
@@ -4221,7 +4198,7 @@ export function EditorView(props) {
4221
4198
  setStatus(message)
4222
4199
  setRecentUpdate({ path: result.target, sessionId, scope, resumeJobId: result.jobId })
4223
4200
  refreshSvnChanges(sessionId, scope)
4224
- emitRefresh()
4201
+ emitRefresh({ scope, kind: 'svn', eventId: 'svn-update-' + result.jobId })
4225
4202
  }
4226
4203
  const svnUpdateEl = sameUpdateOwner(svnUpdateDialog, sessionId, scope)
4227
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
+ }