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/README.md +17 -7
- package/lib/client.js +1649 -272
- package/lib/client.js.map +1 -1
- package/lib/index.js +87 -0
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/diffDock.ts +52 -0
- package/src/client/diffDockStore.ts +87 -0
- package/src/client/editorLayout.ts +19 -0
- package/src/client/events.ts +40 -11
- package/src/client/index.ts +49 -13
- package/src/client/outline/OutlinePanel.tsx +213 -0
- package/src/client/outline/index.ts +24 -0
- package/src/client/outline/parse.ts +271 -0
- package/src/client/outline/sources.ts +134 -0
- package/src/client/outline/types.ts +51 -0
- package/src/client/sidebar/SidebarView.ts +73 -0
- package/src/client/sidebar/panels/FileExplorer.ts +161 -0
- package/src/client/sidebar/panels/index.ts +24 -0
- package/src/client/sidebar/registry.ts +52 -0
- package/src/client/sidebar/types.ts +36 -0
- package/src/client/styles/editor.css +130 -5
- package/src/client/ui/ConversationDiffDock.ts +84 -0
- package/src/client/ui/DiffBox.ts +78 -29
- package/src/client/ui/EditorView.ts +209 -52
- package/src/rpc.ts +21 -0
- package/src/shared/rpc.ts +10 -0
- package/src/tree.ts +62 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/client/events.ts
CHANGED
|
@@ -5,18 +5,43 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
9
|
-
* @author ddj 2026年08月
|
|
10
|
-
* @param path 要打开的路径(可空)
|
|
8
|
+
* 选择中央「文件编辑」页签(DOM 级,无需 store actions)。
|
|
9
|
+
* @author ddj 2026年08月26号
|
|
11
10
|
*/
|
|
12
|
-
|
|
11
|
+
function selectEditorTab(): void {
|
|
13
12
|
const tabs = Array.from(document.querySelectorAll('div[role="tablist"] button[role="tab"]'))
|
|
14
|
-
for (const
|
|
15
|
-
if (
|
|
13
|
+
for (const tab of tabs) {
|
|
14
|
+
if (tab.textContent && tab.textContent.includes('文件编辑')) {
|
|
15
|
+
(tab as HTMLButtonElement).click()
|
|
16
|
+
break
|
|
17
|
+
}
|
|
16
18
|
}
|
|
17
|
-
|
|
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:
|
|
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
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
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
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|