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/README.md +6 -4
- package/lib/client.debug.js +934 -0
- package/lib/client.debug.js.map +1 -0
- package/lib/client.js +848 -1676
- package/lib/client.js.map +1 -1
- package/lib/client.rules.js +793 -0
- package/lib/client.rules.js.map +1 -0
- package/lib/index.js +236 -42
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +9 -0
- package/src/client/lazyPanel.ts +68 -0
- package/src/client/sidebar/PanelBoundary.ts +57 -0
- package/src/client/sidebar/SidebarView.ts +58 -21
- package/src/client/sidebar/panels/FileExplorer.ts +80 -27
- package/src/client/sidebar/panels/SvnPanel.ts +24 -8
- package/src/client/sidebar/panels/index.ts +3 -4
- package/src/client/sidebar/panels/treeWindow.ts +106 -0
- package/src/client/sidebar/panels/useExplorerReveal.ts +5 -2
- package/src/client/sidebar/registry.ts +4 -1
- package/src/client/styles/editor.css +8 -0
- package/src/client/textPages.ts +135 -0
- package/src/client/ui/EditorView.ts +71 -11
- package/src/client/ui/LargeTextView.ts +94 -0
- package/src/rpc.ts +29 -7
- package/src/shared/rpc.ts +3 -1
- package/src/shared/textPages.ts +28 -0
- package/src/textPages.ts +149 -0
- package/src/treeIndex.ts +62 -37
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-mode",
|
|
3
|
-
"version": "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": [
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
+
stopDrag()
|
|
81
121
|
onHide()
|
|
82
122
|
return
|
|
83
123
|
}
|
|
84
124
|
onWidth(Math.min(W_MAX, raw))
|
|
85
125
|
}
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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' },
|
|
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 ?
|
|
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
|
-
|
|
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) //
|
|
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月
|
|
359
|
-
function flatten() {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 [
|
|
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 =
|
|
610
|
-
const
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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 {
|
|
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(
|
|
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(
|
|
115
|
+
render: (ctx: SidebarCtx) => React.createElement(LazyPanel, { key: 'debug', panel: 'debug', ctx }),
|
|
117
116
|
}
|
|
118
117
|
}
|