dsh-vscode-mode 0.18.0 → 0.19.0

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.18.0",
3
+ "version": "0.19.0",
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": [
@@ -76,6 +76,14 @@ import { flushPageState, flushUiState, hydrateUiState, installUiStatePersist, on
76
76
  import { workspaceScopeOf } from './state/scopeStore.js'
77
77
  import { dapStore } from './dap/store.js'
78
78
  import type { CompatAdapter } from '../shared/compat.js'
79
+ import { setPanelImport } from './lazyPanel.js'
80
+ import { dbg } from './rpc.js'
81
+ import { BP_MODE, MODES, fieldOfMode } from './dap/BpWidget.js'
82
+ import { ContextMenu } from './ui/ContextMenu.js'
83
+
84
+ /** Root-owned live objects shared with independent panel chunks; no copied stores or transport state. */
85
+ export const __lazyShared = { rpc, dbg, dapStore, BP_MODE, MODES, fieldOfMode, ContextMenu }
86
+ export { setPanelImport as __setPanelImport }
79
87
 
80
88
  // ⚠️ inject 只列必需服务:webUiSettings 是 @linxin666/dsh-client-ui-web-ui-settings 提供的
81
89
  // 可选兼容桥(非官方服务),列入 inject 会让未装该桥的部署启动时 entry 永久 pending,
@@ -692,6 +700,7 @@ export async function apply(ctx: any): Promise<void> {
692
700
  // 注销挂在存活的 window.monaco 上的全部 Monaco provider 并复位模块状态。
693
701
  // 不做会导致重载后重复注册(补全/跳转/hover 各翻倍)与陈旧开关残留。
694
702
  ctx.effect(() => () => {
703
+ setPanelImport()
695
704
  try { disposeSnippets() } catch { /* 卸载异常不得阻断其余清理 */ }
696
705
  try { disposeLaunchJson() } catch { /* 同上 */ }
697
706
  try { disposeAiInline() } catch { /* 同上 */ }
@@ -0,0 +1,68 @@
1
+ /** Package-local CJS panel loading; transport, revision URLs and HMR belong to DSH. @author ddj 2026年10月22号 */
2
+ import React from 'react'
3
+ import type { SidebarCtx } from './sidebar/types.js'
4
+
5
+ type Panel = React.ComponentType<{ ctx: SidebarCtx }>
6
+ type PanelKey = 'rules' | 'debug'
7
+ type PanelImport = (file: string) => Promise<Record<string, unknown>>
8
+ interface PanelLoad { pending?: Promise<Panel>; component?: Panel }
9
+ const FILES = { rules: './client.rules.js', debug: './client.debug.js' } as const
10
+ const EXPORTS = { rules: 'RulesPanel', debug: 'DebugPanel' } as const
11
+ const loads = new Map<PanelKey, PanelLoad>()
12
+ let generation = 0
13
+ let importer: PanelImport | undefined
14
+
15
+ /** @public @author ddj 2026年10月22号 @param transport Injected ModuleLoader require.async, never a global require. @description Start a new plugin generation and discard older results. */
16
+ export function setPanelImport(transport?: PanelImport): void {
17
+ generation++
18
+ loads.clear()
19
+ importer = transport
20
+ }
21
+
22
+ /** @public @author ddj 2026年10月22号 @param key Fixed built-in panel key. @returns Shared pending load or cached component. @throws Unsupported loader or invalid chunk export. */
23
+ export function loadPanel(key: PanelKey): Promise<Panel> {
24
+ const cached = loads.get(key)
25
+ if (cached?.component) return Promise.resolve(cached.component)
26
+ if (cached?.pending) return cached.pending
27
+ if (!importer) return Promise.reject(new Error('当前 DSH 不支持包内面板加载,请升级宿主'))
28
+
29
+ const epoch = generation
30
+ const transport = importer
31
+ const state: PanelLoad = {}
32
+ const pending = Promise.resolve().then(() => transport(FILES[key])).then((exports) => {
33
+ const component = exports[EXPORTS[key]]
34
+ if (typeof component !== 'function') throw new Error('面板脚本缺少组件导出')
35
+ if (epoch !== generation) throw new Error('面板加载已失效,请重试')
36
+ state.component = component as Panel
37
+ return state.component
38
+ }).catch((error) => {
39
+ if (loads.get(key) === state) loads.delete(key)
40
+ throw error
41
+ })
42
+ state.pending = pending
43
+ loads.set(key, state)
44
+ return pending
45
+ }
46
+
47
+ /** @public @author ddj 2026年10月22号 @param props Fixed panel and current context. @returns Loading, explicit retry, or the loaded panel subtree. */
48
+ export function LazyPanel(props: { panel: PanelKey; ctx: SidebarCtx }): React.ReactElement {
49
+ const [attempt, retry] = React.useState(0)
50
+ const [view, setView] = React.useState<{ component?: Panel; error?: string }>({})
51
+ /** @private @author ddj 2026年10月22号 @returns Cleanup invalidating callbacks after switch or unmount. */
52
+ function loadEffect(): () => void {
53
+ let active = true
54
+ setView({})
55
+ /** @author ddj 2026年10月22号 @param component Loaded panel. */
56
+ function ready(component: Panel): void { if (active) setView({ component }) }
57
+ /** @author ddj 2026年10月22号 @param error Loader failure. */
58
+ function failed(error: unknown): void { if (active) setView({ error: String(error) }) }
59
+ void loadPanel(props.panel).then(ready, failed)
60
+ return () => { active = false }
61
+ }
62
+ React.useEffect(loadEffect, [props.panel, attempt])
63
+ /** @private @author ddj 2026年10月22号 @description Explicit retry; failures never loop automatically. */
64
+ function onRetry(): void { retry(attempt + 1) }
65
+ if (view.component) return React.createElement(view.component, { ctx: props.ctx })
66
+ return React.createElement('div', { className: 'edrv-empty', role: view.error ? 'alert' : 'status' },
67
+ view.error ?? '正在加载面板…', view.error && React.createElement('button', { onClick: onRetry }, '重试'))
68
+ }
@@ -0,0 +1,57 @@
1
+ /** Panel-local callback and React subtree isolation, not a security sandbox. @author ddj 2026年10月22号 */
2
+ import React from 'react'
3
+ import type { SidebarCtx, SidebarPanelDef } from './types.js'
4
+
5
+ export interface PanelCheck { panel: SidebarPanelDef; badge: number | null; error?: Error }
6
+ interface BoundaryProps { panel: SidebarPanelDef; scope?: string; sessionId?: string; compact?: boolean; children?: React.ReactNode }
7
+ interface BoundaryState { panel: SidebarPanelDef; scope?: string; sessionId?: string; error: Error | null }
8
+ const panelIds = new WeakMap<SidebarPanelDef, number>()
9
+ let nextPanelId = 0
10
+ /** @private @author ddj 2026年10月22号 @param props Subtree ownership. @returns Stable identity that changes on registration, scope or session replacement. */
11
+ function ownerKey(props: BoundaryProps): string {
12
+ if (!panelIds.has(props.panel)) panelIds.set(props.panel, ++nextPanelId)
13
+ return JSON.stringify([panelIds.get(props.panel), props.scope, props.sessionId])
14
+ }
15
+
16
+ /** @public @author ddj 2026年10月22号 @param panel Registered definition. @param ctx Current scope. @returns Hidden marker or isolated visible/badge result; failed guards stay reachable for retry. */
17
+ export function inspectPanel(panel: SidebarPanelDef, ctx: SidebarCtx): PanelCheck | null {
18
+ try {
19
+ if (panel.visible && panel.visible(ctx) !== true) return null
20
+ return { panel, badge: panel.badge?.(ctx) ?? null }
21
+ } catch (error) {
22
+ return { panel, badge: null, error: error instanceof Error ? error : new Error(String(error)) }
23
+ }
24
+ }
25
+
26
+ /** @public @author ddj 2026年10月22号 @description Reset a failed subtree when registration or scope ownership changes. */
27
+ export class PanelBoundary extends React.Component<BoundaryProps, BoundaryState> {
28
+ state: BoundaryState = { panel: this.props.panel, scope: this.props.scope, sessionId: this.props.sessionId, error: null }
29
+
30
+ /** @public @author ddj 2026年10月22号 @param props Current ownership. @param state Previous ownership/error. @returns Reset only when the owner changes. */
31
+ static getDerivedStateFromProps(props: BoundaryProps, state: BoundaryState): Partial<BoundaryState> | null {
32
+ if (state.panel === props.panel && state.scope === props.scope && state.sessionId === props.sessionId) return null
33
+ return { panel: props.panel, scope: props.scope, sessionId: props.sessionId, error: null }
34
+ }
35
+
36
+ /** @public @author ddj 2026年10月22号 @param error React render/lifecycle error. @returns Local fallback state. */
37
+ static getDerivedStateFromError(error: Error): Partial<BoundaryState> { return { error } }
38
+
39
+ /** @private @author ddj 2026年10月22号 @description Remount only this failed subtree and reevaluate its callbacks. */
40
+ private retry = (): void => { this.setState({ error: null }) }
41
+
42
+ /** @public @author ddj 2026年10月22号 @returns Original subtree or panel-local retry UI. */
43
+ render(): React.ReactNode {
44
+ if (!this.state.error) return React.createElement(React.Fragment, { key: ownerKey(this.props) }, this.props.children)
45
+ if (this.props.compact) return React.createElement('span', { role: 'img', title: '图标加载失败', 'aria-label': '图标加载失败' }, '!')
46
+ return React.createElement('div', { className: 'edrv-empty', role: 'alert' },
47
+ this.props.panel.title + ':' + String(this.state.error.message),
48
+ React.createElement('button', { onClick: this.retry }, '重试'))
49
+ }
50
+ }
51
+
52
+ /** @public @author ddj 2026年10月22号 @param props Isolated panel invocation below the boundary. @returns Panel subtree. @throws Extension callback error for the nearest boundary. */
53
+ export function PanelContent(props: { panel: SidebarPanelDef; ctx: SidebarCtx }): React.ReactNode {
54
+ const checked = inspectPanel(props.panel, props.ctx)
55
+ if (checked?.error) throw checked.error
56
+ return checked ? props.panel.render(props.ctx) as React.ReactNode : null
57
+ }
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import React from 'react'
12
12
  import type { SidebarCtx } from './types.js'
13
+ import { inspectPanel, PanelBoundary, PanelContent } from './PanelBoundary.js'
13
14
 
14
15
  const W_MAX = 560
15
16
  /** 面板区隐藏时容器的宽度(= 图标列宽,与 editor.css .edrv-rail 对齐)。 */
@@ -50,9 +51,8 @@ function railIconEl(icon) {
50
51
  export function SidebarView(props) {
51
52
  const ctx = props.ctx
52
53
  // 按 visible(ctx) 过滤活动栏图标与面板(能力相关面板:SVN 变更仅在受 SVN 管理时出现)
53
- const panels = (props.registry?.list?.() ?? []).filter((panel) => (
54
- typeof panel.visible !== 'function' || panel.visible(ctx) === true
55
- ))
54
+ const checked = (props.registry?.list?.() ?? []).map((panel) => inspectPanel(panel, ctx)).filter(Boolean)
55
+ const panels = checked.map((entry) => entry.panel)
56
56
  const activePanel = props.activePanel
57
57
  const onActive = props.onActive
58
58
  const visible = props.visible !== false
@@ -64,27 +64,66 @@ export function SidebarView(props) {
64
64
 
65
65
  const active = panels.find((p) => p.id === activePanel) || panels[0]
66
66
 
67
+ const dragCleanup = React.useRef(null)
68
+ /** @private @author ddj 2026年10月22号 @description End the active drag; safe on repeated teardown. */
69
+ function stopDrag() { dragCleanup.current?.() }
70
+ const seamOpening = React.useRef(false)
71
+ const lastVisible = React.useRef(visible)
72
+ /** @private @author ddj 2026年10月22号 @description Always release listeners on unmount, including a pending seam expansion. */
73
+ function dragEffect() { return stopDrag }
74
+ React.useEffect(dragEffect, [])
75
+ /** @private @author ddj 2026年10月22号 @description Preserve only the seam's own expansion; external visibility changes cancel ownership. */
76
+ function visibilityEffect() {
77
+ if (lastVisible.current !== visible) {
78
+ if (!(visible && seamOpening.current)) stopDrag()
79
+ seamOpening.current = false
80
+ lastVisible.current = visible
81
+ }
82
+ }
83
+ React.useEffect(visibilityEffect, [visible])
84
+
85
+ /** @private @author ddj 2026年10月22号 @param pointerId Owning pointer. @param move Pointer movement callback. @description Replace the previous drag and own every exit path. */
86
+ function bindDrag(pointerId, move) {
87
+ stopDrag()
88
+ /** @author ddj 2026年10月22号 @param event Pointer event. @description Ignore another pointer's movement. */
89
+ function onMove(event) { if (event.pointerId === pointerId) move(event) }
90
+ /** @author ddj 2026年10月22号 @param event Pointer completion. @description Only the owning pointer ends the drag. */
91
+ function onEnd(event) { if (event.pointerId === pointerId) cleanup() }
92
+ /** @author ddj 2026年10月22号 @description Remove all drag listeners and release ownership. */
93
+ function cleanup() {
94
+ window.removeEventListener('pointermove', onMove)
95
+ window.removeEventListener('pointerup', onEnd)
96
+ window.removeEventListener('pointercancel', onEnd)
97
+ window.removeEventListener('blur', cleanup)
98
+ if (dragCleanup.current === cleanup) {
99
+ dragCleanup.current = null
100
+ seamOpening.current = false
101
+ }
102
+ }
103
+ dragCleanup.current = cleanup
104
+ window.addEventListener('pointermove', onMove)
105
+ window.addEventListener('pointerup', onEnd)
106
+ window.addEventListener('pointercancel', onEnd)
107
+ window.addEventListener('blur', cleanup)
108
+ }
109
+
110
+ /** @private @author ddj 2026年10月22号 @param e Starting pointer. @description Resize the visible panel until completion or collapse. */
67
111
  const onPointerDown = (e) => {
68
112
  e.preventDefault()
69
113
  const startX = e.clientX
70
114
  const startW = width
71
- const cleanup = () => {
72
- window.removeEventListener('pointermove', onMove)
73
- window.removeEventListener('pointerup', onUp)
74
- }
75
- const onUp = () => cleanup()
115
+ /** @author ddj 2026年10月22号 @param ev Owning pointer movement. @description Apply this drag's width calculation. */
76
116
  const onMove = (ev) => {
77
117
  const raw = startW + (ev.clientX - startX)
78
118
  // 低于最小宽度:清理监听并直接收起面板区(不夹住)
79
119
  if (raw < wMin) {
80
- cleanup()
120
+ stopDrag()
81
121
  onHide()
82
122
  return
83
123
  }
84
124
  onWidth(Math.min(W_MAX, raw))
85
125
  }
86
- window.addEventListener('pointermove', onMove)
87
- window.addEventListener('pointerup', onUp)
126
+ bindDrag(e.pointerId, onMove)
88
127
 
89
128
  }
90
129
 
@@ -98,29 +137,25 @@ export function SidebarView(props) {
98
137
  const baseW = Math.max(wMin, Math.min(W_MAX, Math.round(Number(width) || wMin)))
99
138
  let expanded = false
100
139
  let expandStartX = 0
101
- const cleanup = () => {
102
- window.removeEventListener('pointermove', onMove)
103
- window.removeEventListener('pointerup', onUp)
104
- }
105
- const onUp = () => cleanup()
140
+ /** @author ddj 2026年10月22号 @param ev Owning pointer movement. @description Apply this drag's width calculation. */
106
141
  const onMove = (ev) => {
107
142
  if (!expanded) {
108
143
  if (ev.clientX - startX < PULL_OUT_THRESHOLD) return
109
144
  expanded = true
110
145
  expandStartX = ev.clientX
146
+ seamOpening.current = true
111
147
  onShow()
112
148
  return
113
149
  }
114
150
  onWidth(Math.max(wMin, Math.min(W_MAX, baseW + (ev.clientX - expandStartX))))
115
151
  }
116
- window.addEventListener('pointermove', onMove)
117
- window.addEventListener('pointerup', onUp)
152
+ bindDrag(e.pointerId, onMove)
118
153
  }
119
154
 
120
155
  const rail = React.createElement('div', { className: 'edrv-rail' },
121
156
  React.createElement('div', { style: { flex: 1, display: 'flex', flexDirection: 'column', gap: 2 } },
122
157
  panels.map((p) => {
123
- const count = typeof p.badge === 'function' ? p.badge(ctx) : null
158
+ const count = checked.find((entry) => entry.panel === p)?.badge
124
159
  return React.createElement('button', {
125
160
  key: p.id,
126
161
  className: 'edrv-rail-btn' + (visible && active && p.id === active.id ? ' edrv-rail-on' : ''),
@@ -131,7 +166,8 @@ export function SidebarView(props) {
131
166
  if (!visible && typeof onShow === 'function') onShow()
132
167
  },
133
168
  },
134
- React.createElement('span', { className: 'edrv-rail-icon' }, railIconEl(p.icon)),
169
+ React.createElement('span', { className: 'edrv-rail-icon' },
170
+ React.createElement(PanelBoundary, { panel: p, scope: ctx?.scope, sessionId: ctx?.sessionId, compact: true }, railIconEl(p.icon))),
135
171
  count > 0
136
172
  ? React.createElement('span', { className: 'edrv-rail-badge' }, String(count))
137
173
  : null)
@@ -150,7 +186,8 @@ export function SidebarView(props) {
150
186
  rail,
151
187
  visible
152
188
  ? React.createElement('div', { className: 'edrv-side-body' },
153
- active ? active.render(ctx) : null)
189
+ active ? React.createElement(PanelBoundary, { panel: active, scope: ctx?.scope, sessionId: ctx?.sessionId },
190
+ React.createElement(PanelContent, { panel: active, ctx })) : null)
154
191
  : null,
155
192
  visible
156
193
  ? React.createElement('div', { className: 'edrv-side-resize', onPointerDown: onPointerDown })
@@ -28,7 +28,7 @@ import { useExplorerSvn } from './useExplorerSvn.js'
28
28
  import { useExplorerReveal, treeRevealPath } from './useExplorerReveal.js'
29
29
  import type { SidebarCtx } from '../types.js'
30
30
 
31
- const DIR_CAP = 4000
31
+ import { flattenTree, treeAnchorTop, treeRevealTop, treeWindow, TREE_ROW_HEIGHT } from './treeWindow.js'
32
32
  const SAVE_DEBOUNCE_MS = 300
33
33
  const FOLLOW_INTERVAL_MS = 10_000
34
34
  const PREFETCH_MAX = 4
@@ -126,7 +126,8 @@ export function FileExplorer(props) {
126
126
  const saveTimerRef = React.useRef(null)
127
127
  const loadDirRef = React.useRef(null)
128
128
  const refreshRef = React.useRef(null)
129
- const treeRef = React.useRef(null) // 目录树容器(定位时按 data-edrv-path 查行)
129
+ const treeRef = React.useRef(null) // 唯一目录树滚动容器
130
+ const [viewport, setViewport] = React.useState({ scope, top: 0, height: 0 })
130
131
  const reloadDirRef = React.useRef(null) // loadDir 最新闭包(文件变化监听读取,防陈旧闭包)
131
132
  const changedTimerRef = React.useRef(null) // 文件变化合并去抖计时器
132
133
  const changedRelRef = React.useRef(new Set()) // 待重列的相对目录集合(去抖窗口内合并)
@@ -338,8 +339,6 @@ export function FileExplorer(props) {
338
339
  // eslint-disable-next-line react-hooks/exhaustive-deps
339
340
  }, [])
340
341
 
341
- // 在恢复工作区展开状态之后跟随活动文件;隐藏期间目标由编辑器保留,重新挂载时兑现。
342
- const { revealPath } = useExplorerReveal({ scope, activePath, treeRef, prepare: prepareReveal })
343
342
  const activeTreePath = treeRevealPath(activePath)
344
343
 
345
344
  // 10s 轻量跟随:已展开目录后台刷新(命中 host 索引,近零成本),树跟随 agent 写入
@@ -355,24 +354,71 @@ export function FileExplorer(props) {
355
354
  // eslint-disable-next-line react-hooks/exhaustive-deps
356
355
  }, [scope])
357
356
 
358
- /** @private @author ddj 2026年10月10号 @returns Expanded tree descriptors; icon refreshes never re-walk the tree. */
359
- function flatten() {
360
- const result = []
361
- /** @private @author ddj 2026年10月10号 @param rel Directory. @param depth Indent level. Appends only expanded rows and placeholders. */
362
- function visit(rel, depth) {
363
- const entries = dirs[rel] ?? entriesCacheGet(scope, rel)
364
- if (!entries) { result.push({ key: rel + ':loading', depth, text: loading[rel] ? '加载中…' : '无法加载' }); return }
365
- for (const e of entries) {
366
- const isDir = e.type === 'directory', isOpen = expanded[e.path] === true
367
- result.push({ key: e.path, e, depth, isDir, isOpen })
368
- if (isDir && isOpen) visit(e.path, depth + 1)
369
- }
370
- if (entries.length >= DIR_CAP) result.push({ key: rel + ':cap', depth, text: '目录条目过多,仅显示前 ' + DIR_CAP + ' 项' })
357
+ /** @private @author ddj 2026年10月22号 @returns Complete expanded model; icon-only updates do not flatten again. */
358
+ function flatten() { return flattenTree(entriesOf, expanded, loading) }
359
+ const treeRows = React.useMemo(flatten, [dirs, expanded, loading, scope])
360
+ const previousRows = React.useRef({ scope, rows: treeRows })
361
+ let anchoredTop = 0
362
+ if (viewport.scope === scope) {
363
+ anchoredTop = viewport.top
364
+ if (previousRows.current.scope === scope && previousRows.current.rows !== treeRows) {
365
+ anchoredTop = treeAnchorTop(previousRows.current.rows, treeRows, viewport.top)
371
366
  }
372
- visit('', 0)
373
- return result
374
367
  }
375
- const treeRows = React.useMemo(flatten, [dirs, expanded, loading, scope])
368
+ const currentViewport = { top: anchoredTop, height: viewport.height }
369
+ const windowRows = treeWindow(treeRows.length, currentViewport)
370
+
371
+ /** @private @author ddj 2026年10月22号 Read current geometry after scroll, resize or scope restoration. */
372
+ function measureTree() {
373
+ const host = treeRef.current
374
+ if (!host || host.clientHeight <= 0) return
375
+ setViewport({ scope, top: host.scrollTop, height: host.clientHeight })
376
+ }
377
+ const scrollFrame = React.useRef(null)
378
+ /** @private @author ddj 2026年10月22号 Coalesce high-frequency scrolling into one measured render per frame. */
379
+ function scrollTree() {
380
+ if (scrollFrame.current !== null) return
381
+ scrollFrame.current = requestAnimationFrame(readScroll)
382
+ }
383
+ /** @private @author ddj 2026年10月22号 Read only after the pending animation frame fires. */
384
+ function readScroll() { scrollFrame.current = null; measureTree() }
385
+ /** @private @author ddj 2026年10月22号 @returns Resize observer and pending frame cleanup. Keep hidden trees measurable when shown. */
386
+ function watchTree() {
387
+ const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(measureTree) : null
388
+ if (treeRef.current) observer?.observe(treeRef.current)
389
+ measureTree()
390
+ /** @private @author ddj 2026年10月22号 Stop all viewport callbacks owned by this scope. */
391
+ function cleanup() {
392
+ observer?.disconnect()
393
+ if (scrollFrame.current !== null) cancelAnimationFrame(scrollFrame.current)
394
+ scrollFrame.current = null
395
+ }
396
+ return cleanup
397
+ }
398
+ React.useLayoutEffect(watchTree, [scope])
399
+ /** @private @author ddj 2026年10月22号 Clamp actual scroll after collapse, filtering, or workspace switch. */
400
+ function clampTree() {
401
+ const host = treeRef.current
402
+ previousRows.current = { scope, rows: treeRows }
403
+ if (!host || host.clientHeight <= 0) return
404
+ if (host.scrollTop !== windowRows.top) host.scrollTop = windowRows.top
405
+ if (viewport.scope !== scope || viewport.top !== windowRows.top) {
406
+ setViewport({ scope, top: windowRows.top, height: host.clientHeight })
407
+ }
408
+ }
409
+ React.useLayoutEffect(clampTree, [scope, windowRows.top, treeRows])
410
+ /** @private @author ddj 2026年10月22号 @param path Requested file. @returns Whether its complete model row is available. */
411
+ function locateTree(path) {
412
+ const host = treeRef.current
413
+ if (!host) return false
414
+ const top = treeRevealTop(treeRows, path, { top: host.scrollTop, height: host.clientHeight })
415
+ if (top === null) return false
416
+ host.scrollTop = top
417
+ setViewport({ scope, top, height: host.clientHeight })
418
+ return true
419
+ }
420
+ // 定位由完整数据索引兑现,目标无需预先挂载 DOM;异步祖先加载后 hook 会重试。
421
+ const { revealPath } = useExplorerReveal({ scope, activePath, treeRef, prepare: prepareReveal, locate: locateTree })
376
422
  /** @private @author ddj 2026年10月10号 @returns Stable RPC row projection, independent of icon-only renders. */
377
423
  function projectRows() { return treeRows.filter((row) => row.e).map((row) => ({ path: row.e.path, isDir: row.isDir })) }
378
424
  const svnRows = React.useMemo(projectRows, [treeRows])
@@ -384,10 +430,10 @@ export function FileExplorer(props) {
384
430
  /** @private @author ddj 2026年10月10号 @param path Row path. @param type Entry type. @param event Context trigger. */
385
431
  function contextRow(path, type, event) {
386
432
  event.preventDefault(); event.stopPropagation()
387
- setMenu({ x: event.clientX, y: event.clientY, target: { path, type } })
433
+ setMenu({ scope, x: event.clientX, y: event.clientY, target: { path, type } })
388
434
  }
389
435
  const onSelect = React.useCallback((path, isDir) => selectRef.current(path, isDir), [])
390
- const onContext = React.useCallback(contextRow, [])
436
+ const onContext = React.useCallback(contextRow, [scope])
391
437
  const rootName = root ? String(root).split(/[\\/]/).pop() || root : ''
392
438
 
393
439
  /** @private @author ddj 2026年10月10号 @param row Flat descriptor. @returns Memo row; unaffected status objects retain identity. */
@@ -405,10 +451,14 @@ export function FileExplorer(props) {
405
451
  ev.preventDefault()
406
452
  ev.stopPropagation()
407
453
  if (!ev.currentTarget.contains(ev.target)) return
408
- setMenu({ x: ev.clientX, y: ev.clientY, target: { path: '', type: 'directory' } })
454
+ setMenu({ scope, x: ev.clientX, y: ev.clientY, target: { path: '', type: 'directory' } })
409
455
  }
456
+ const menuValid = menu?.scope === scope && (menu.target.path === '' || treeRows.some((row) => row.e?.path === menu.target.path))
457
+ /** @private @author ddj 2026年10月22号 Close stale path menus instead of rebinding recycled rows. */
458
+ function closeLostMenu() { if (menu && !menuValid) setMenu(null) }
459
+ React.useEffect(closeLostMenu, [menu, menuValid])
410
460
  // 当前右键目标的可视菜单项(构建时过滤/排序,registry 变化下次打开生效)。
411
- const menuEntries = menu
461
+ const menuEntries = menuValid
412
462
  ? buildTreeMenu(ctx?.fileMenuItems, menu.target, ctx).map((item) => ({
413
463
  id: item.id,
414
464
  label: item.label,
@@ -443,13 +493,16 @@ export function FileExplorer(props) {
443
493
  }, '↓') : null,
444
494
  React.createElement('button', { className: 'edrv-side-btn', title: '刷新目录树', onClick: () => emitRefresh({ scope, kind: 'unknown' }) }, refreshIconEl())),
445
495
  svn.message ? React.createElement('div', { className: 'edrv-tree-status', role: 'status' }, svn.message) : null,
446
- React.createElement('div', { className: 'edrv-tree', ref: treeRef },
447
- (errorText
496
+ (errorText
448
497
  ? React.createElement('div', { className: 'edrv-tree-error' },
449
498
  React.createElement('span', null, String(errorText)),
450
499
  React.createElement('button', { className: 'edrv-side-btn', onClick: () => emitRefresh({ scope, kind: 'unknown' }) }, '重试'))
451
500
  : null),
452
- treeRows.map(renderRow)),
501
+ React.createElement('div', { className: 'edrv-tree edrv-tree-virtual', ref: treeRef, onScroll: scrollTree },
502
+ React.createElement('div', { 'aria-hidden': true, style: { height: windowRows.start * TREE_ROW_HEIGHT } }),
503
+ treeRows.slice(windowRows.start, windowRows.end).map(renderRow),
504
+ React.createElement('div', { 'aria-hidden': true, style: { height: (treeRows.length - windowRows.end) * TREE_ROW_HEIGHT } })),
505
+
453
506
  (menu && menuEntries.length
454
507
  ? React.createElement(ContextMenu, { x: menu.x, y: menu.y, entries: menuEntries, onClose: () => setMenu(null) })
455
508
  : null))
@@ -484,7 +484,7 @@ export function SvnPanel(props) {
484
484
  const [remote, setRemote] = React.useState(null)
485
485
  const [remoteBusy, setRemoteBusy] = React.useState(false)
486
486
  // W2-5:配对候选的文件大小(path → size|null)与成对结果
487
- const [sizes, setSizes] = React.useState({})
487
+ const [sizeSnapshot, setSizes] = React.useState(null)
488
488
  // 渐进渲染预算:过滤/作用域变化时重置,避免过滤后仍停留在旧的大预算
489
489
  const [renderLimit, setRenderLimit] = React.useState(RENDER_STEP)
490
490
  // ctx 由编辑区每轮渲染重建;行动作回调改读 ref 取最新值,回调身份才能稳定(行 React.memo 生效前提)
@@ -606,14 +606,30 @@ export function SvnPanel(props) {
606
606
  () => [...missingEntries, ...unversionedEntries].slice(0, FILE_SIZES_CAP).map((entry) => entry.path),
607
607
  [missingEntries, unversionedEntries],
608
608
  )
609
- const candidateKey = React.useMemo(() => requested.join('\n'), [requested])
610
- const pairsNeeded = missingEntries.length > 0 && unversionedEntries.length > 0
609
+ const candidateKey = JSON.stringify(requested)
610
+ const sizeOwner = JSON.stringify([scope, sessionId, candidateKey, managed])
611
+ const sizeOwnerRef = React.useRef(sizeOwner)
612
+ sizeOwnerRef.current = sizeOwner
613
+ const sizes = sizeSnapshot?.owner === sizeOwner ? sizeSnapshot.table : {}
614
+ const pairsNeeded = managed && missingEntries.length > 0 && unversionedEntries.length > 0
611
615
  const pairsKnown = pairsNeeded && requested.every((path) => path in sizes)
612
- React.useEffect(() => {
613
- if (pairsKnown || !requested.length) return
614
- void svnFileSizes(sessionId, requested).then((table) => setSizes(table))
615
- // requested 内容由 candidateKey 代理:同 key 必同内容,闭包取旧数组无碍(去重语义与旧 join 依赖一致)
616
- }, [pairsKnown, candidateKey, sessionId]) // eslint-disable-line react-hooks/exhaustive-deps
616
+
617
+ /** @private @author ddj 2026年10月22号 @returns Cleanup invalidating this request. @description Keep sizes scoped to this candidate generation; failures do not retry in a render loop. */
618
+ function loadPairSizes() {
619
+ if (!pairsNeeded || !requested.length) return
620
+ let disposed = false
621
+ /** @author ddj 2026年10月22号 @param table Candidate sizes. @description Publish only to the current request owner. */
622
+ function acceptSizes(table) {
623
+ if (!disposed && sizeOwnerRef.current === sizeOwner) setSizes({ owner: sizeOwner, table })
624
+ }
625
+ /** @author ddj 2026年10月22号 @description Failed reads leave pairing unknown without breaking the changes list. */
626
+ function ignoreFailure() { /* A future candidate/session change may retry. */ }
627
+ /** @author ddj 2026年10月22号 @description Revoke this request on dependency change or unmount. */
628
+ function cancelSizes() { disposed = true }
629
+ void svnFileSizes(sessionId, requested).then(acceptSizes, ignoreFailure)
630
+ return cancelSizes
631
+ }
632
+ React.useEffect(loadPairSizes, [sizeOwner, pairsNeeded])
617
633
 
618
634
  const pairNoteOf = React.useMemo(() => {
619
635
  if (!pairsKnown) return {}
@@ -10,9 +10,8 @@ import { IconBranchOutline16, IconFolderOpenOutline16, IconListPenOutline16, Ico
10
10
  import { dapStore } from '../../dap/store.js'
11
11
  import { FileExplorer } from './FileExplorer.js'
12
12
  import { SearchPanel } from './SearchPanel.js'
13
- import { RulesPanel } from './RulesPanel.js'
13
+ import { LazyPanel } from '../../lazyPanel.js'
14
14
  import { SvnPanel } from './SvnPanel.js'
15
- import { DebugPanel } from './DebugPanel.js'
16
15
  import type { SidebarPanelDef, SidebarCtx } from '../types.js'
17
16
 
18
17
  /**
@@ -57,7 +56,7 @@ export function createRulesPanel(): SidebarPanelDef {
57
56
  title: '规则',
58
57
  icon: IconListPenOutline16,
59
58
  order: 20,
60
- render: (ctx: SidebarCtx) => React.createElement(RulesPanel, { ctx }),
59
+ render: (ctx: SidebarCtx) => React.createElement(LazyPanel, { key: 'rules', panel: 'rules', ctx }),
61
60
  }
62
61
  }
63
62
 
@@ -113,6 +112,6 @@ export function createDebugPanel(): SidebarPanelDef {
113
112
  icon: '🐞',
114
113
  order: 22,
115
114
  badge: () => (dapStore.getSnapshot().phase === 'paused' ? 1 : null),
116
- render: (ctx: SidebarCtx) => React.createElement(DebugPanel, { ctx }),
115
+ render: (ctx: SidebarCtx) => React.createElement(LazyPanel, { key: 'debug', panel: 'debug', ctx }),
117
116
  }
118
117
  }