dsh-vscode-mode 0.1.19 → 0.1.21

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.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 中央编辑器(文件页签/QuickOpen/状态栏)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚),状态持久化到工作区旁车",
5
5
  "keywords": [
6
6
  "dsh",
@@ -0,0 +1,52 @@
1
+ /**
2
+ * dsh-vscode-mode client — 对话差异 dock 纯函数。
3
+ * 只负责差异文件轮转和文案,状态查询仍由组件复用现有 edrv.list/summarize。
4
+ * 作者 ddj 2026-08-26
5
+ */
6
+
7
+ /**
8
+ * 从稳定差异文件列表中取下一个路径,并返回下一次索引。
9
+ * @author ddj 2026年08月26号
10
+ * @param paths 待处理差异文件路径
11
+ * @param index 当前轮转索引
12
+ * @returns 下一个路径与归一化后的下一索引
13
+ */
14
+ export function nextDiffPath(paths: string[], index: number): { path: string | null; index: number } {
15
+ if (!paths.length) return { path: null, index: 0 }
16
+ const current = Number.isFinite(index) ? Math.max(0, Math.floor(index)) : 0
17
+ const at = current % paths.length
18
+ return { path: paths[at] ?? null, index: (at + 1) % paths.length }
19
+ }
20
+
21
+ /**
22
+ * 生成对话差异 dock 的单按钮文案。
23
+ * @author ddj 2026年08月26号
24
+ * @param count 待处理差异文件数量
25
+ * @returns 展示文案
26
+ */
27
+ export function diffDockText(count: number): string {
28
+ return '差异 ' + Math.max(0, Math.floor(count)) + ' 个文件 · 查看下一个'
29
+ }
30
+
31
+ /**
32
+ * 根据活动文件决定编辑页 dock 形态,加载期间保持 editor 结构稳定。
33
+ * @author ddj 2026年08月26号
34
+ * @param activePath 当前活动文件路径
35
+ * @returns 编辑态或无文件空态
36
+ */
37
+ export function editorDockMode(activePath: string | null | undefined): 'editor' | 'editor-empty' {
38
+ return activePath ? 'editor' : 'editor-empty'
39
+ }
40
+
41
+ /**
42
+ * 获取切换文件期间稳定展示的文件内差异数量。
43
+ * @author ddj 2026年08月26号
44
+ * @param ready 当前文件内容是否已加载完成
45
+ * @param actualTotal 内容就绪后的精确差异数
46
+ * @param fallbackTotal 内容加载期间的文件摘要差异数
47
+ * @returns 非负整数差异数
48
+ */
49
+ export function displayDiffTotal(ready: boolean, actualTotal: number, fallbackTotal: number): number {
50
+ const value = ready ? actualTotal : fallbackTotal
51
+ return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0
52
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * dsh-vscode-mode client — 会话级差异 dock store。
3
+ * EditorView 发布当前编辑上下文,conversation.input.dock 唯一读取并渲染。
4
+ * 作者 ddj 2026年08月26号
5
+ */
6
+
7
+ /** 差异 dock 的显示形态。 */
8
+ export type DiffDockMode = 'chat' | 'editor' | 'editor-empty'
9
+
10
+ /**
11
+ * 跨 slot 传递的差异 dock 快照。
12
+ * 操作回调随快照一起更新,避免 dock 持有已卸载编辑器的旧闭包。
13
+ */
14
+ export interface DiffDockSnapshot {
15
+ mode: DiffDockMode
16
+ [key: string]: unknown
17
+ }
18
+
19
+ type DiffDockListener = () => void
20
+ type DiffDockSource = object
21
+
22
+ const values = new Map<string, DiffDockSnapshot>()
23
+ const sources = new Map<string, DiffDockSource>()
24
+ const listeners = new Map<string, Set<DiffDockListener>>()
25
+
26
+ /** 通知指定会话的 dock 订阅者。 */
27
+ function notifyDiff(sessionId: string): void {
28
+ for (const listener of listeners.get(sessionId) ?? []) listener()
29
+ }
30
+
31
+ /**
32
+ * 发布指定会话的最新编辑器差异上下文。
33
+ * @author ddj 2026年08月26号
34
+ * @param sessionId 会话 id
35
+ * @param snapshot 完整 DiffBox 操作上下文
36
+ * @param source 发布者令牌,用于卸载时防止误清理新实例
37
+ */
38
+ export function publishDiffDock(sessionId: string, snapshot: DiffDockSnapshot, source?: DiffDockSource): void {
39
+ values.set(sessionId, snapshot)
40
+ if (source) sources.set(sessionId, source)
41
+ else sources.delete(sessionId)
42
+ notifyDiff(sessionId)
43
+ }
44
+
45
+ /**
46
+ * 清除指定会话的差异上下文。
47
+ * @author ddj 2026年08月26号
48
+ * @param sessionId 会话 id
49
+ * @param source 可选发布者令牌;不匹配时忽略清理
50
+ */
51
+ export function clearDiffDock(sessionId: string, source?: DiffDockSource): void {
52
+ if (source && sources.get(sessionId) !== source) return
53
+ values.delete(sessionId)
54
+ sources.delete(sessionId)
55
+ notifyDiff(sessionId)
56
+ }
57
+
58
+ /**
59
+ * 读取指定会话的最新上下文。
60
+ * @author ddj 2026年08月26号
61
+ * @param sessionId 会话 id
62
+ * @returns 最新上下文或 null
63
+ */
64
+ export function readDiffDock(sessionId?: string): DiffDockSnapshot | null {
65
+ if (!sessionId) return null
66
+ return values.get(sessionId) ?? null
67
+ }
68
+
69
+ /**
70
+ * 订阅指定会话的上下文变化。
71
+ * @author ddj 2026年08月26号
72
+ * @param sessionId 会话 id
73
+ * @param listener 变化回调
74
+ * @returns 取消订阅函数
75
+ */
76
+ export function subscribeDiffDock(sessionId: string, listener: DiffDockListener): () => void {
77
+ let bucket = listeners.get(sessionId)
78
+ if (!bucket) {
79
+ bucket = new Set()
80
+ listeners.set(sessionId, bucket)
81
+ }
82
+ bucket.add(listener)
83
+ return () => {
84
+ bucket!.delete(listener)
85
+ if (!bucket!.size) listeners.delete(sessionId)
86
+ }
87
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * dsh-vscode-mode client — 编辑区与原生 composer 的几何边界计算。
3
+ * @author ddj 2026年08月26号
4
+ */
5
+
6
+ /**
7
+ * 计算编辑根节点在当前会话滚动区内、composer 上方可使用的高度。
8
+ * @author ddj 2026年08月26号
9
+ * @param rootTop 编辑根节点顶边
10
+ * @param scrollBottom 会话滚动区可视底边
11
+ * @param composerTop composer 顶边;缺失时回退滚动区底边
12
+ * @returns 非负可用高度
13
+ */
14
+ export function editorHeight(rootTop: number, scrollBottom: number, composerTop?: number): number {
15
+ const root = Number.isFinite(rootTop) ? rootTop : 0
16
+ const scroll = Number.isFinite(scrollBottom) ? scrollBottom : root
17
+ const composer = Number.isFinite(composerTop) ? composerTop as number : scroll
18
+ return Math.max(0, Math.min(scroll, composer) - root)
19
+ }
@@ -5,18 +5,43 @@
5
5
  */
6
6
 
7
7
  /**
8
- * 打开中央「文件编辑」页签:点击 shell 会话头部的视图页签(DOM 级,无需 store actions)。
9
- * @author ddj 2026年08月20号
10
- * @param path 要打开的路径(可空)
8
+ * 选择中央「文件编辑」页签(DOM 级,无需 store actions)。
9
+ * @author ddj 2026年08月26号
11
10
  */
12
- export function openEditorView(path: string | null): void {
11
+ function selectEditorTab(): void {
13
12
  const tabs = Array.from(document.querySelectorAll('div[role="tablist"] button[role="tab"]'))
14
- for (const b of tabs) {
15
- if (b.textContent && b.textContent.includes('文件编辑')) { (b as HTMLButtonElement).click(); break }
13
+ for (const tab of tabs) {
14
+ if (tab.textContent && tab.textContent.includes('文件编辑')) {
15
+ (tab as HTMLButtonElement).click()
16
+ break
17
+ }
16
18
  }
17
- const p = path ?? null
19
+ }
20
+
21
+ /**
22
+ * 打开中央「文件编辑」页签。
23
+ * @author ddj 2026年08月26号
24
+ * @param path 要打开的路径(可空)
25
+ */
26
+ export function openEditorView(path: string | null): void {
27
+ selectEditorTab()
28
+ const target = path ?? null
18
29
  setTimeout(() => {
19
- window.dispatchEvent(new CustomEvent('edrv:open-editor', { detail: { path: p } }))
30
+ window.dispatchEvent(new CustomEvent('edrv:open-editor', { detail: { path: target } }))
31
+ }, 80)
32
+ }
33
+
34
+ /**
35
+ * 打开文件编辑页并聚焦指定文件的首个差异。
36
+ * @author ddj 2026年08月26号
37
+ * @param path 待聚焦的差异文件路径
38
+ */
39
+ export function openDiffView(path: string): void {
40
+ selectEditorTab()
41
+ setTimeout(() => {
42
+ window.dispatchEvent(new CustomEvent('edrv:open-editor', {
43
+ detail: { path, focusDiff: true },
44
+ }))
20
45
  }, 80)
21
46
  }
22
47
 
@@ -30,7 +55,11 @@ export function emitOpenEditor(path: string): void {
30
55
  window.dispatchEvent(new CustomEvent('edrv:open-editor', { detail: { path } }))
31
56
  }
32
57
 
33
- /** 拉起 DiffLauncher。 */
34
- export function emitShowLauncher(): void {
35
- window.dispatchEvent(new CustomEvent('edrv:show-launcher'))
58
+ /**
59
+ * 拉起 DiffLauncher。
60
+ * @author ddj 2026年08月26号
61
+ * @param tab 可选的目标页签
62
+ */
63
+ export function emitShowLauncher(tab?: 'pending' | 'archive'): void {
64
+ window.dispatchEvent(new CustomEvent('edrv:show-launcher', { detail: { tab } }))
36
65
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * dsh-vscode-mode client — 浏览器半入口:slot 注册 + 装配。
3
- * 挂点:conversation.view「文件编辑」页签(中央 Monaco 编辑器)+ header 差异角标。
3
+ * 挂点:conversation.view「文件编辑」页签(中央 Monaco 编辑器)+ conversation.input.dock 差异条 + header 差异角标。
4
4
  * 与 Host 通信:同源 fetch('/edrv/rpc')(shared/rpc 契约)。
5
5
  *
6
6
  * ⚠️ 跨版本 slot 装配(2026-08-21):新版 DSH 的 slots 系统要求 slot 必须由父 entry
@@ -8,13 +8,15 @@
8
8
  * 声明未就绪时抛 `slot ... is not declared (a parent entry's children table must declare it)`。
9
9
  * 正确写法 = `ctx.slots.inject(name, () => ctx.slots.register(...))`:声明存在时同步
10
10
  * 执行,否则等待声明(官方 ui-conversation 自身即此模式);旧版(rc.8 及更早)同样支持,
11
- * 故跨版本兼容。slot 名未变:conversation.view / conversation.session.header.utilities。
11
+ * 故跨版本兼容。slot 名:conversation.view / conversation.session.header.utilities /
12
+ * conversation.input.dock。
12
13
  * 作者 ddj 2026-08-20
13
14
  */
14
15
  import React from 'react'
15
16
  import './styles/editor.css'
16
17
  import { EditorView } from './ui/EditorView.js'
17
18
  import { DiffBadge } from './ui/DiffBadge.js'
19
+ import { ConversationDiffDock } from './ui/ConversationDiffDock.js'
18
20
  import { McpSettings } from './ui/McpSettings.js'
19
21
  import { rpc } from './rpc.js'
20
22
  import { loadMonaco } from './monaco/loader.js'
@@ -24,6 +26,10 @@ import { installOpenPathRouter, vscodeOpener, autoValue } from './openPathRouter
24
26
  import { SettingsContext } from './settingsContext.js'
25
27
  import { SIDEBAR_PLUGIN, pickSettingsBinder, registerSlotSafely } from './compat.js'
26
28
  import { createAddToConversation } from './addToConversation.js'
29
+ import { createSidebarPanelRegistry } from './sidebar/registry.js'
30
+ import { createFilePanel } from './sidebar/panels/index.js'
31
+ import { createOutlinePanel } from './outline/index.js'
32
+ import { createOutlineSourceRegistry, registerBuiltinOutlineSources } from './outline/sources.js'
27
33
  import type { CompatAdapter } from '../shared/compat.js'
28
34
 
29
35
  export const inject = ['slots', 'timer', 'locale', 'connection', 'remote', 'workspaces', 'sessions', 'conversation', 'settingsScope', 'webUiSettings']
@@ -37,18 +43,29 @@ export const inject = ['slots', 'timer', 'locale', 'connection', 'remote', 'work
37
43
  export function apply(ctx: any): void {
38
44
  const schedule = (fn: () => void, ms: number) => ctx.timeout(fn, ms)
39
45
 
40
- // Monaco 预热:DSH 启动后后台加载编辑器核心(模块级 promise 去重),用户点开「文件编辑」页签即用,
41
- // 不再等页签挂载后才首次拉取 /edrv/vendor/*。requestIdleCallback 让出首屏带宽,缺省回退延时调度;
42
- // 预热失败静默吞掉(loader 失败会重置 promise,页签打开时仍走原有加载/重试路径)。
43
- if (typeof window !== 'undefined') {
44
- const preloadMonaco = () => loadMonaco(() => {}).catch(() => {})
45
- if (typeof window.requestIdleCallback === 'function') window.requestIdleCallback(preloadMonaco, { timeout: 2000 })
46
- else schedule(preloadMonaco, 300)
47
- }
48
-
49
46
  const registry: FileOpenerRegistry = createFileOpenerRegistry()
50
47
  const workspaces = ctx.get('workspaces')
51
48
  const sessions = ctx.get('sessions')
49
+
50
+ // Monaco 加载时机:不再 DSH 启动即预热,改为进入会话界面(sessions.list.current 出现)后再后台加载,
51
+ // 用户点开「文件编辑」页签即用;空闲时仍由 EditorView 挂载兜底加载。
52
+ // 模块级 promise 去重,重复触发只首次真正加载;requestIdleCallback 让出会话首屏带宽,缺省回退延时调度;
53
+ // 预热失败静默吞掉(loader 失败会重置 promise,页签打开时仍走原有加载/重试路径)。
54
+ const monacoList = sessions?.list
55
+ if (typeof window !== 'undefined' && monacoList && typeof monacoList.subscribe === 'function') {
56
+ const list = monacoList as { getSnapshot: () => { current?: unknown }; subscribe: (listener: () => void) => () => void }
57
+ const schedulePreload = () => {
58
+ const preload = () => loadMonaco(() => {}).catch(() => {})
59
+ if (typeof window.requestIdleCallback === 'function') window.requestIdleCallback(preload, { timeout: 2000 })
60
+ else schedule(preload, 300)
61
+ }
62
+ const onSessionEnter = () => {
63
+ if (!list.getSnapshot()?.current) return
64
+ schedulePreload()
65
+ }
66
+ ctx.effect(() => list.subscribe(onSessionEnter), 'vscode-mode: monaco session trigger')
67
+ onSessionEnter()
68
+ }
52
69
  const originalOpenPath = workspaces?.openPath
53
70
  const binder = pickSettingsBinder(ctx)
54
71
  const settings = binder.scope
@@ -65,6 +82,16 @@ export function apply(ctx: any): void {
65
82
 
66
83
  ctx.provide('fileOpeners', registry)
67
84
  ctx.effect(() => registry.register(vscodeOpener()), 'vscode-mode: file opener')
85
+
86
+ // 侧边栏面板注册表(对外 provide,供本插件/第三方注册面板;文件管理为面板 #1)
87
+ const sidebarPanels = createSidebarPanelRegistry()
88
+ ctx.provide('edrvSidebarPanels', sidebarPanels)
89
+ ctx.effect(() => sidebarPanels.register(createFilePanel()), 'vscode-mode: sidebar panel')
90
+ // 大纲源注册表(公开预留口):第三方语言插件(LSP/VSIX 等)注册更高优先级源即可覆盖兜底
91
+ const outlineSources = createOutlineSourceRegistry()
92
+ ctx.provide('edrvOutlineSources', outlineSources)
93
+ ctx.effect(() => registerBuiltinOutlineSources(outlineSources), 'vscode-mode: outline sources')
94
+ ctx.effect(() => sidebarPanels.register(createOutlinePanel()), 'vscode-mode: sidebar panel outline')
68
95
  ctx.effect(() => registry.register({
69
96
  id: 'system', label: '系统默认应用', priority: 0,
70
97
  open: (path: string) => originalOpenPath.call(workspaces, path),
@@ -99,14 +126,23 @@ export function apply(ctx: any): void {
99
126
  }), 'vscode-mode: file link routing')
100
127
  }
101
128
 
102
- // 中央「文件编辑」页签:类 VSCode 编辑器(顶部=文件页签+搜索框,差异 UI=文件底部圆角悬浮框)
129
+ // 中央「文件编辑」页签:类 VSCode 编辑器(顶部=文件页签+搜索框,左侧=侧边栏,差异 UI=文件底部圆角悬浮框)
103
130
  registerSlotSafely(ctx, {
104
131
  name: 'conversation.view',
105
132
  id: 'edrv-editor',
106
133
  order: 5,
107
134
  label: '文件编辑',
108
135
  inject: (sessionId: string) => ({ sessionId }),
109
- }, (props: unknown) => React.createElement(EditorView, Object.assign({}, props, { schedule, addToConversation })))
136
+ }, (props: unknown) => React.createElement(EditorView, Object.assign({}, props, { schedule, addToConversation, sidebarPanels, outlineSources })))
137
+
138
+ // 对话输入框上方差异 dock:普通对话显示单文案按钮,文件编辑页由 EditorView 隐藏
139
+ registerSlotSafely(ctx, {
140
+ name: 'conversation.input.dock',
141
+ id: 'edrv-diff-dock',
142
+ order: 30,
143
+ label: '差异',
144
+ inject: (sessionId: string) => ({ sessionId }),
145
+ }, (props: unknown) => React.createElement(ConversationDiffDock, Object.assign({}, props)))
110
146
 
111
147
  // header 差异角标:仅当前工作区存在差异时渲染
112
148
  registerSlotSafely(ctx, { name: 'conversation.session.header.utilities', id: 'edrv-diff-badge', order: 90, label: '差异' },
@@ -0,0 +1,213 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * dsh-vscode-mode client — 侧边栏「大纲」面板。
4
+ * 数据源经 resolveOutline(源注册表,优先级降序、首个非空生效)解析当前活动文件符号:
5
+ * monaco 源吃原生 document symbol provider,fallback 源兜底无内置提供方的语言。
6
+ * 交互:点击符号跳转编辑器、▸/▾ 折叠、光标所在符号高亮、空/加载/错误态。
7
+ * 作者 ddj 2026-08-27
8
+ */
9
+ import React from 'react'
10
+ import { resolveOutline } from './sources.js'
11
+ import type { SidebarCtx } from '../sidebar/types.js'
12
+
13
+ /** 渲染符号数上限(防超大文件卡顿,超出显示截断提示)。 */
14
+ const RENDER_CAP = 800
15
+
16
+ /** kind 分组元信息(字形 + 样式类)。 */
17
+ const KIND_GROUPS = {
18
+ func: { glyph: 'ƒ', cls: 'edrv-ol-func' },
19
+ type: { glyph: 'C', cls: 'edrv-ol-type' },
20
+ data: { glyph: '•', cls: 'edrv-ol-data' },
21
+ ns: { glyph: '▤', cls: 'edrv-ol-ns' },
22
+ key: { glyph: '·', cls: 'edrv-ol-key' },
23
+ }
24
+
25
+ /** SymbolKind 数值 → 分组(File0..Package3=ns;Class4/Enum9/Interface10/Struct22/TypeParameter25=type;Method5/Constructor8/Function11=func;Key19/EnumMember21=key)。 */
26
+ function kindMeta(kind) {
27
+ const k = kind | 0
28
+ if (k <= 3) return KIND_GROUPS.ns
29
+ if (k === 4 || k === 9 || k === 10 || k === 22 || k === 25) return KIND_GROUPS.type
30
+ if (k === 5 || k === 8 || k === 11) return KIND_GROUPS.func
31
+ if (k === 19 || k === 21) return KIND_GROUPS.key
32
+ return KIND_GROUPS.data
33
+ }
34
+
35
+ /**
36
+ * 大纲面板主体。
37
+ * @param props.ctx 面板共享上下文(editor/outlineSources/activePath)
38
+ */
39
+ export function OutlinePanel(props) {
40
+ const ctx = props?.ctx
41
+ const activePath = ctx?.activePath ?? null
42
+ const [symbols, setSymbols] = React.useState(null)
43
+ const [error, setError] = React.useState(null)
44
+ const [collapsed, setCollapsed] = React.useState({})
45
+ const [cursorLine, setCursorLine] = React.useState(null)
46
+ const seqRef = React.useRef(0)
47
+ // ref 镜像:refresh 稳定([]),监听器只挂一次,避免 EditorView 每渲染重建 ctx 造成抖动
48
+ const ctxRef = React.useRef(ctx)
49
+ ctxRef.current = ctx
50
+ const activeRef = React.useRef(activePath)
51
+ activeRef.current = activePath
52
+ const sourcesRef = React.useRef(ctx?.outlineSources)
53
+ sourcesRef.current = ctx?.outlineSources
54
+
55
+ const refresh = React.useCallback(() => {
56
+ const c = ctxRef.current
57
+ const ed = c?.editor?.()
58
+ const model = ed?.getModel?.()
59
+ const seq = ++seqRef.current
60
+ if (!ed || !model) { setSymbols(null); setError(null); return }
61
+ const uriPath = model?.uri?.path
62
+ const modelPath = uriPath ? decodeURIComponent(String(uriPath).replace(/^\//, '')) : null
63
+ const active = activeRef.current
64
+ if (active && modelPath !== active) { setSymbols(null); setError(null); return }
65
+ const sources = sourcesRef.current
66
+ if (!sources) { setSymbols([]); setError(null); return }
67
+ setError(null)
68
+ resolveOutline(sources, { languageId: model.getLanguageId(), model, editor: ed, monaco: window.monaco })
69
+ .then((list) => { if (seq === seqRef.current) setSymbols(list || []) })
70
+ .catch((e) => { if (seq === seqRef.current) { setError(String(e?.message ?? e)); setSymbols(null) } })
71
+ }, [])
72
+
73
+ // 编辑器监听:模型切换/内容编辑(防抖)/光标移动 + edrv:refresh;
74
+ // 编辑器未就绪时轮询等待(避免面板先于 Monaco 挂载后永久停在加载态)
75
+ React.useEffect(() => {
76
+ const disposers = []
77
+ let debounceTimer = null
78
+ let waitTimer = null
79
+ const attach = () => {
80
+ const ed = ctxRef.current?.editor?.()
81
+ if (!ed) return false
82
+ let contentDisposable = null
83
+ const subContent = (model) => {
84
+ if (contentDisposable) { contentDisposable.dispose(); contentDisposable = null }
85
+ contentDisposable = model?.onDidChangeContent?.(() => {
86
+ if (debounceTimer) clearTimeout(debounceTimer)
87
+ debounceTimer = setTimeout(() => { debounceTimer = null; refresh() }, 300)
88
+ }) ?? null
89
+ }
90
+ const onModel = () => { subContent(ed.getModel?.()); refresh() }
91
+ const onCursor = (e) => setCursorLine(e?.position?.lineNumber ?? null)
92
+ subContent(ed.getModel?.())
93
+ const subs = [
94
+ ed.onDidChangeModel?.(onModel),
95
+ ed.onDidChangeCursorPosition?.(onCursor),
96
+ { dispose: () => { if (contentDisposable) contentDisposable.dispose() } },
97
+ ]
98
+ for (const s of subs) if (s) disposers.push(s)
99
+ refresh()
100
+ return true
101
+ }
102
+ const onRefresh = () => refresh()
103
+ window.addEventListener('edrv:refresh', onRefresh)
104
+ if (!attach()) {
105
+ waitTimer = setInterval(() => { if (attach()) { clearInterval(waitTimer); waitTimer = null } }, 400)
106
+ }
107
+ return () => {
108
+ if (debounceTimer) clearTimeout(debounceTimer)
109
+ if (waitTimer) clearInterval(waitTimer)
110
+ for (const d of disposers) if (d?.dispose) d.dispose()
111
+ window.removeEventListener('edrv:refresh', onRefresh)
112
+ }
113
+ }, [refresh])
114
+
115
+ // 活动文件变化 → 重拉
116
+ React.useEffect(() => { refresh() }, [refresh, activePath])
117
+
118
+ const jump = (sym) => {
119
+ const ed = ctxRef.current?.editor?.()
120
+ if (!ed) return
121
+ const line = Math.max(1, sym?.selectLine ?? sym?.startLine ?? 1)
122
+ ed.revealLineInCenter(line)
123
+ ed.setPosition({ lineNumber: line, column: 1 })
124
+ ed.focus()
125
+ }
126
+
127
+ const toggleCollapse = (key) => {
128
+ setCollapsed((prev) => Object.assign({}, prev, { [key]: prev[key] === true ? false : true }))
129
+ }
130
+
131
+ const allKeys = React.useMemo(() => {
132
+ const keys = []
133
+ const walk = (list, prefix) => {
134
+ for (let i = 0; i < list.length; i++) {
135
+ const key = prefix ? prefix + '/' + i : String(i)
136
+ const sym = list[i]
137
+ if (sym.children && sym.children.length) { keys.push(key); walk(sym.children, key) }
138
+ }
139
+ }
140
+ walk(symbols || [], '')
141
+ return keys
142
+ }, [symbols])
143
+
144
+ const setAll = (value) => {
145
+ const next = {}
146
+ for (const key of allKeys) next[key] = value
147
+ setCollapsed(next)
148
+ }
149
+
150
+ const renderTree = (list, depth, prefix) => {
151
+ const rows = []
152
+ const cap = Math.min(list.length, RENDER_CAP)
153
+ for (let i = 0; i < cap; i++) {
154
+ const sym = list[i]
155
+ const key = prefix ? prefix + '/' + i : String(i)
156
+ const kids = sym.children && sym.children.length ? sym.children : null
157
+ const isCollapsed = collapsed[key] === true
158
+ const meta = kindMeta(sym.kind)
159
+ const active = cursorLine != null && sym.startLine <= cursorLine && cursorLine <= sym.endLine
160
+ rows.push(React.createElement('div', {
161
+ key,
162
+ className: 'edrv-tree-row' + (active ? ' edrv-tree-active' : ''),
163
+ title: sym.detail || sym.name,
164
+ style: { paddingLeft: 6 + depth * 14 },
165
+ onClick: () => jump(sym),
166
+ },
167
+ React.createElement('span', {
168
+ className: 'edrv-tree-chev',
169
+ onClick: (e) => { e.stopPropagation(); toggleCollapse(key) },
170
+ }, kids ? (isCollapsed ? '▸' : '▾') : ''),
171
+ React.createElement('span', { className: 'edrv-outline-kind ' + meta.cls }, meta.glyph),
172
+ React.createElement('span', { className: 'edrv-tree-name' }, sym.name),
173
+ (sym.detail ? React.createElement('span', { className: 'edrv-outline-detail' }, sym.detail) : null),
174
+ React.createElement('span', { className: 'edrv-outline-ln' }, String(sym.selectLine ?? sym.startLine ?? ''))))
175
+ if (kids && !isCollapsed) rows.push(...renderTree(kids, depth + 1, key))
176
+ }
177
+ if (list.length > RENDER_CAP) {
178
+ rows.push(React.createElement('div', { key: 'cap', className: 'edrv-tree-loading' }, '符号过多,仅显示前 ' + RENDER_CAP + ' 个'))
179
+ }
180
+ return rows
181
+ }
182
+
183
+ const basename = activePath ? String(activePath).split(/[\\/]/).pop() || activePath : ''
184
+
185
+ let body
186
+ if (error) {
187
+ body = React.createElement('div', { className: 'edrv-tree' },
188
+ React.createElement('div', { className: 'edrv-tree-error' },
189
+ React.createElement('span', null, String(error)),
190
+ React.createElement('button', { className: 'edrv-side-btn', onClick: refresh }, '重试')))
191
+ } else if (!activePath) {
192
+ body = React.createElement('div', { className: 'edrv-tree' },
193
+ React.createElement('div', { className: 'edrv-tree-loading' }, '未打开文件'))
194
+ } else if (symbols === null) {
195
+ body = React.createElement('div', { className: 'edrv-tree' },
196
+ React.createElement('div', { className: 'edrv-tree-loading' }, '加载中…'))
197
+ } else if (symbols.length === 0) {
198
+ body = React.createElement('div', { className: 'edrv-tree' },
199
+ React.createElement('div', { className: 'edrv-tree-loading' }, '该语言暂不支持大纲或文件无符号'))
200
+ } else {
201
+ body = React.createElement('div', { className: 'edrv-tree' }, ...renderTree(symbols, 0, ''))
202
+ }
203
+
204
+ return React.createElement('div', { className: 'edrv-side-panel' },
205
+ React.createElement('div', { className: 'edrv-side-head' },
206
+ React.createElement('span', { className: 'edrv-side-title' }, '大纲'),
207
+ React.createElement('span', { className: 'edrv-side-root', title: activePath || '' }, basename),
208
+ React.createElement('span', { style: { flex: 1 } }),
209
+ React.createElement('button', { className: 'edrv-side-btn', title: '折叠全部', disabled: !(symbols && symbols.length), onClick: () => setAll(true) }, '−'),
210
+ React.createElement('button', { className: 'edrv-side-btn', title: '展开全部', disabled: !(symbols && symbols.length), onClick: () => setAll(false) }, '+'),
211
+ React.createElement('button', { className: 'edrv-side-btn', title: '刷新大纲', onClick: refresh }, '⟳')),
212
+ body)
213
+ }
@@ -0,0 +1,24 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * dsh-vscode-mode client — 「大纲」面板定义(注册表一项)。
4
+ * 数据源解析复用 outline/sources 的源注册表(ctx.outlineSources 注入)。
5
+ * 作者 ddj 2026-08-27
6
+ */
7
+ import React from 'react'
8
+ import { OutlinePanel } from './OutlinePanel.js'
9
+ import type { SidebarPanelDef, SidebarCtx } from '../sidebar/types.js'
10
+
11
+ /**
12
+ * 构造大纲面板定义。
13
+ * @author ddj 2026年08月27号
14
+ * @returns 面板定义(无徽标;活动栏图标 📜)
15
+ */
16
+ export function createOutlinePanel(): SidebarPanelDef {
17
+ return {
18
+ id: 'outline',
19
+ title: '大纲',
20
+ icon: '📜',
21
+ order: 20,
22
+ render: (ctx: SidebarCtx) => React.createElement(OutlinePanel, { ctx }),
23
+ }
24
+ }