dsh-vscode-mode 0.1.19 → 0.1.20
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 +911 -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 +42 -13
- 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 +31 -0
- package/src/client/styles/editor.css +121 -5
- package/src/client/ui/ConversationDiffDock.ts +84 -0
- package/src/client/ui/DiffBox.ts +78 -29
- package/src/client/ui/EditorView.ts +207 -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,8 @@ 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'
|
|
27
31
|
import type { CompatAdapter } from '../shared/compat.js'
|
|
28
32
|
|
|
29
33
|
export const inject = ['slots', 'timer', 'locale', 'connection', 'remote', 'workspaces', 'sessions', 'conversation', 'settingsScope', 'webUiSettings']
|
|
@@ -37,18 +41,29 @@ export const inject = ['slots', 'timer', 'locale', 'connection', 'remote', 'work
|
|
|
37
41
|
export function apply(ctx: any): void {
|
|
38
42
|
const schedule = (fn: () => void, ms: number) => ctx.timeout(fn, ms)
|
|
39
43
|
|
|
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
44
|
const registry: FileOpenerRegistry = createFileOpenerRegistry()
|
|
50
45
|
const workspaces = ctx.get('workspaces')
|
|
51
46
|
const sessions = ctx.get('sessions')
|
|
47
|
+
|
|
48
|
+
// Monaco 加载时机:不再 DSH 启动即预热,改为进入会话界面(sessions.list.current 出现)后再后台加载,
|
|
49
|
+
// 用户点开「文件编辑」页签即用;空闲时仍由 EditorView 挂载兜底加载。
|
|
50
|
+
// 模块级 promise 去重,重复触发只首次真正加载;requestIdleCallback 让出会话首屏带宽,缺省回退延时调度;
|
|
51
|
+
// 预热失败静默吞掉(loader 失败会重置 promise,页签打开时仍走原有加载/重试路径)。
|
|
52
|
+
const monacoList = sessions?.list
|
|
53
|
+
if (typeof window !== 'undefined' && monacoList && typeof monacoList.subscribe === 'function') {
|
|
54
|
+
const list = monacoList as { getSnapshot: () => { current?: unknown }; subscribe: (listener: () => void) => () => void }
|
|
55
|
+
const schedulePreload = () => {
|
|
56
|
+
const preload = () => loadMonaco(() => {}).catch(() => {})
|
|
57
|
+
if (typeof window.requestIdleCallback === 'function') window.requestIdleCallback(preload, { timeout: 2000 })
|
|
58
|
+
else schedule(preload, 300)
|
|
59
|
+
}
|
|
60
|
+
const onSessionEnter = () => {
|
|
61
|
+
if (!list.getSnapshot()?.current) return
|
|
62
|
+
schedulePreload()
|
|
63
|
+
}
|
|
64
|
+
ctx.effect(() => list.subscribe(onSessionEnter), 'vscode-mode: monaco session trigger')
|
|
65
|
+
onSessionEnter()
|
|
66
|
+
}
|
|
52
67
|
const originalOpenPath = workspaces?.openPath
|
|
53
68
|
const binder = pickSettingsBinder(ctx)
|
|
54
69
|
const settings = binder.scope
|
|
@@ -65,6 +80,11 @@ export function apply(ctx: any): void {
|
|
|
65
80
|
|
|
66
81
|
ctx.provide('fileOpeners', registry)
|
|
67
82
|
ctx.effect(() => registry.register(vscodeOpener()), 'vscode-mode: file opener')
|
|
83
|
+
|
|
84
|
+
// 侧边栏面板注册表(对外 provide,供本插件/第三方注册面板;文件管理为面板 #1)
|
|
85
|
+
const sidebarPanels = createSidebarPanelRegistry()
|
|
86
|
+
ctx.provide('edrvSidebarPanels', sidebarPanels)
|
|
87
|
+
ctx.effect(() => sidebarPanels.register(createFilePanel()), 'vscode-mode: sidebar panel')
|
|
68
88
|
ctx.effect(() => registry.register({
|
|
69
89
|
id: 'system', label: '系统默认应用', priority: 0,
|
|
70
90
|
open: (path: string) => originalOpenPath.call(workspaces, path),
|
|
@@ -99,14 +119,23 @@ export function apply(ctx: any): void {
|
|
|
99
119
|
}), 'vscode-mode: file link routing')
|
|
100
120
|
}
|
|
101
121
|
|
|
102
|
-
// 中央「文件编辑」页签:类 VSCode
|
|
122
|
+
// 中央「文件编辑」页签:类 VSCode 编辑器(顶部=文件页签+搜索框,左侧=侧边栏,差异 UI=文件底部圆角悬浮框)
|
|
103
123
|
registerSlotSafely(ctx, {
|
|
104
124
|
name: 'conversation.view',
|
|
105
125
|
id: 'edrv-editor',
|
|
106
126
|
order: 5,
|
|
107
127
|
label: '文件编辑',
|
|
108
128
|
inject: (sessionId: string) => ({ sessionId }),
|
|
109
|
-
}, (props: unknown) => React.createElement(EditorView, Object.assign({}, props, { schedule, addToConversation })))
|
|
129
|
+
}, (props: unknown) => React.createElement(EditorView, Object.assign({}, props, { schedule, addToConversation, sidebarPanels })))
|
|
130
|
+
|
|
131
|
+
// 对话输入框上方差异 dock:普通对话显示单文案按钮,文件编辑页由 EditorView 隐藏
|
|
132
|
+
registerSlotSafely(ctx, {
|
|
133
|
+
name: 'conversation.input.dock',
|
|
134
|
+
id: 'edrv-diff-dock',
|
|
135
|
+
order: 30,
|
|
136
|
+
label: '差异',
|
|
137
|
+
inject: (sessionId: string) => ({ sessionId }),
|
|
138
|
+
}, (props: unknown) => React.createElement(ConversationDiffDock, Object.assign({}, props)))
|
|
110
139
|
|
|
111
140
|
// header 差异角标:仅当前工作区存在差异时渲染
|
|
112
141
|
registerSlotSafely(ctx, { name: 'conversation.session.header.utilities', id: 'edrv-diff-badge', order: 90, label: '差异' },
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* dsh-vscode-mode client — 侧边栏容器(活动栏 + 面板区 + 拖拽调宽)。
|
|
4
|
+
* 面板列表来自注册表(活动栏图标/徽标/激活态),渲染当前激活面板的 render(ctx);
|
|
5
|
+
* 右侧手柄拖拽调宽(clamp 180–560),底部按钮收起侧边栏。
|
|
6
|
+
* 作者 ddj 2026-08-26
|
|
7
|
+
*/
|
|
8
|
+
import React from 'react'
|
|
9
|
+
import type { SidebarCtx } from './types.js'
|
|
10
|
+
|
|
11
|
+
const W_MIN = 180
|
|
12
|
+
const W_MAX = 560
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 侧边栏容器。
|
|
16
|
+
* @param props.registry 面板注册表(list() 提供面板顺序)
|
|
17
|
+
* @param props.ctx 面板共享上下文
|
|
18
|
+
* @param props.activePanel 当前激活面板 id
|
|
19
|
+
* @param props.onActive 激活面板变化回调
|
|
20
|
+
* @param props.width 面板区宽度
|
|
21
|
+
* @param props.onWidth 宽度变化回调
|
|
22
|
+
* @param props.onHide 收起侧边栏回调
|
|
23
|
+
*/
|
|
24
|
+
export function SidebarView(props) {
|
|
25
|
+
const panels = props.registry?.list?.() ?? []
|
|
26
|
+
const ctx = props.ctx
|
|
27
|
+
const activePanel = props.activePanel
|
|
28
|
+
const onActive = props.onActive
|
|
29
|
+
const width = props.width
|
|
30
|
+
const onWidth = props.onWidth
|
|
31
|
+
const onHide = props.onHide
|
|
32
|
+
|
|
33
|
+
const active = panels.find((p) => p.id === activePanel) || panels[0]
|
|
34
|
+
|
|
35
|
+
const onPointerDown = (e) => {
|
|
36
|
+
e.preventDefault()
|
|
37
|
+
const startX = e.clientX
|
|
38
|
+
const startW = width
|
|
39
|
+
const onMove = (ev) => {
|
|
40
|
+
const w = Math.max(W_MIN, Math.min(W_MAX, startW + (ev.clientX - startX)))
|
|
41
|
+
onWidth(w)
|
|
42
|
+
}
|
|
43
|
+
const onUp = () => {
|
|
44
|
+
window.removeEventListener('pointermove', onMove)
|
|
45
|
+
window.removeEventListener('pointerup', onUp)
|
|
46
|
+
}
|
|
47
|
+
window.addEventListener('pointermove', onMove)
|
|
48
|
+
window.addEventListener('pointerup', onUp)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const rail = React.createElement('div', { className: 'edrv-rail' },
|
|
52
|
+
React.createElement('div', { style: { flex: 1, display: 'flex', flexDirection: 'column', gap: 2 } },
|
|
53
|
+
panels.map((p) => {
|
|
54
|
+
const count = typeof p.badge === 'function' ? p.badge(ctx) : null
|
|
55
|
+
return React.createElement('button', {
|
|
56
|
+
key: p.id,
|
|
57
|
+
className: 'edrv-rail-btn' + (active && p.id === active.id ? ' edrv-rail-on' : ''),
|
|
58
|
+
title: p.title,
|
|
59
|
+
onClick: () => onActive(p.id),
|
|
60
|
+
},
|
|
61
|
+
React.createElement('span', { className: 'edrv-rail-icon' }, p.icon),
|
|
62
|
+
count > 0
|
|
63
|
+
? React.createElement('span', { className: 'edrv-rail-badge' }, String(count))
|
|
64
|
+
: null)
|
|
65
|
+
})),
|
|
66
|
+
React.createElement('button', { className: 'edrv-rail-btn', title: '隐藏侧边栏 (Ctrl+B)', onClick: onHide }, '◀'))
|
|
67
|
+
|
|
68
|
+
return React.createElement('div', { className: 'edrv-sidebar', style: { width } },
|
|
69
|
+
rail,
|
|
70
|
+
React.createElement('div', { className: 'edrv-side-body' },
|
|
71
|
+
active ? active.render(ctx) : null),
|
|
72
|
+
React.createElement('div', { className: 'edrv-side-resize', onPointerDown: onPointerDown }))
|
|
73
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* dsh-vscode-mode client — 侧边栏「文件管理」面板(懒加载目录树)。
|
|
4
|
+
* 点目录展开/收起(首次经 edrv.listDir 拉取并缓存),点文件经 ctx.openFile 打开;
|
|
5
|
+
* 差异角标取 pendingByPath,活动文件高亮;edrv:refresh 事件与手动刷新重载树。
|
|
6
|
+
* 作者 ddj 2026-08-26
|
|
7
|
+
*/
|
|
8
|
+
import React from 'react'
|
|
9
|
+
import { rpc } from '../../rpc.js'
|
|
10
|
+
import type { SidebarCtx } from '../types.js'
|
|
11
|
+
|
|
12
|
+
const DIR_CAP = 4000
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 目录树面板主体。
|
|
16
|
+
* @param props.ctx 面板共享上下文(sessionId/openFile/activePath/pendingByPath)
|
|
17
|
+
*/
|
|
18
|
+
export function FileExplorer(props) {
|
|
19
|
+
const ctx = props?.ctx
|
|
20
|
+
const sessionId = ctx?.sessionId
|
|
21
|
+
const openFile = ctx?.openFile
|
|
22
|
+
const activePath = ctx?.activePath ?? null
|
|
23
|
+
const pendingByPath = ctx?.pendingByPath ?? {}
|
|
24
|
+
const [root, setRoot] = React.useState(null)
|
|
25
|
+
const [dirs, setDirs] = React.useState({})
|
|
26
|
+
const [expanded, setExpanded] = React.useState({})
|
|
27
|
+
const [loading, setLoading] = React.useState({})
|
|
28
|
+
const [error, setError] = React.useState(null)
|
|
29
|
+
const tokensRef = React.useRef({})
|
|
30
|
+
// expanded 的 ref 镜像:edrv:refresh 监听用首次渲染闭包,但需读到最新展开态
|
|
31
|
+
const expandedRef = React.useRef({})
|
|
32
|
+
expandedRef.current = expanded
|
|
33
|
+
|
|
34
|
+
const loadDir = (rel) => {
|
|
35
|
+
const token = (tokensRef.current[rel] || 0) + 1
|
|
36
|
+
tokensRef.current[rel] = token
|
|
37
|
+
setLoading((prev) => Object.assign({}, prev, { [rel]: true }))
|
|
38
|
+
setError(null)
|
|
39
|
+
rpc('edrv.listDir', { sessionId, path: rel }).then((res) => {
|
|
40
|
+
if (tokensRef.current[rel] !== token) return
|
|
41
|
+
if (res && res.ok && Array.isArray(res.entries)) {
|
|
42
|
+
setDirs((prev) => Object.assign({}, prev, { [rel]: res.entries }))
|
|
43
|
+
if (res.root && !root) setRoot(res.root)
|
|
44
|
+
} else {
|
|
45
|
+
setError(res?.error ? String(res.error) : '读取目录失败')
|
|
46
|
+
}
|
|
47
|
+
}).catch((e) => {
|
|
48
|
+
if (tokensRef.current[rel] !== token) return
|
|
49
|
+
setError('读取目录异常:' + String(e))
|
|
50
|
+
}).finally(() => {
|
|
51
|
+
if (tokensRef.current[rel] !== token) return
|
|
52
|
+
setLoading((prev) => {
|
|
53
|
+
const next = Object.assign({}, prev)
|
|
54
|
+
delete next[rel]
|
|
55
|
+
return next
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const toggle = (rel) => {
|
|
61
|
+
if (expanded[rel] === true) {
|
|
62
|
+
setExpanded((prev) => Object.assign({}, prev, { [rel]: false }))
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
if (!dirs[rel]) void loadDir(rel)
|
|
66
|
+
setExpanded((prev) => Object.assign({}, prev, { [rel]: true }))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const refresh = () => {
|
|
70
|
+
const keep = Object.keys(expandedRef.current).filter((k) => expandedRef.current[k] === true)
|
|
71
|
+
tokensRef.current = {}
|
|
72
|
+
setDirs({})
|
|
73
|
+
setLoading({})
|
|
74
|
+
setError(null)
|
|
75
|
+
if (keep.length) {
|
|
76
|
+
const next = {}
|
|
77
|
+
for (const rel of keep) next[rel] = true
|
|
78
|
+
setExpanded(next)
|
|
79
|
+
for (const rel of keep) void loadDir(rel)
|
|
80
|
+
} else {
|
|
81
|
+
setExpanded({})
|
|
82
|
+
}
|
|
83
|
+
void loadDir('')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
React.useEffect(() => {
|
|
87
|
+
tokensRef.current = {}
|
|
88
|
+
setDirs({})
|
|
89
|
+
setExpanded({})
|
|
90
|
+
setLoading({})
|
|
91
|
+
setError(null)
|
|
92
|
+
setRoot(null)
|
|
93
|
+
void loadDir('')
|
|
94
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
95
|
+
}, [sessionId])
|
|
96
|
+
|
|
97
|
+
React.useEffect(() => {
|
|
98
|
+
const onRefresh = () => refresh()
|
|
99
|
+
window.addEventListener('edrv:refresh', onRefresh)
|
|
100
|
+
return () => window.removeEventListener('edrv:refresh', onRefresh)
|
|
101
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
102
|
+
}, [])
|
|
103
|
+
|
|
104
|
+
const rootName = root ? String(root).split(/[\\/]/).pop() || root : ''
|
|
105
|
+
|
|
106
|
+
const rowEl = (e, depth, isDir, isOpen) => {
|
|
107
|
+
const pending = isDir ? 0 : (pendingByPath[e.path] ?? 0)
|
|
108
|
+
const active = !isDir && e.path === activePath
|
|
109
|
+
const dim = e.type === 'other'
|
|
110
|
+
return React.createElement('div', {
|
|
111
|
+
key: e.path,
|
|
112
|
+
className: 'edrv-tree-row' + (active ? ' edrv-tree-active' : ''),
|
|
113
|
+
title: e.path,
|
|
114
|
+
style: { paddingLeft: 6 + depth * 14 },
|
|
115
|
+
onClick: () => { if (isDir) toggle(e.path); else openFile(e.path) },
|
|
116
|
+
},
|
|
117
|
+
React.createElement('span', { className: 'edrv-tree-chev' },
|
|
118
|
+
isDir ? (isOpen ? '▾' : '▸') : ''),
|
|
119
|
+
React.createElement('span', { className: 'edrv-tree-name' + (dim ? ' edrv-tree-dim' : '') },
|
|
120
|
+
isDir ? (isOpen ? '📂' : '📁') : '📄', ' ', e.name),
|
|
121
|
+
(pending > 0
|
|
122
|
+
? React.createElement('span', { className: 'edrv-tree-badge' }, String(pending))
|
|
123
|
+
: null))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const rowsOf = (rel, depth) => {
|
|
127
|
+
const entries = dirs[rel]
|
|
128
|
+
if (!entries) {
|
|
129
|
+
return [React.createElement('div', { key: rel + ':loading', className: 'edrv-tree-loading', style: { paddingLeft: 6 + depth * 14 } },
|
|
130
|
+
loading[rel] ? '加载中…' : '无法加载')]
|
|
131
|
+
}
|
|
132
|
+
const rows = []
|
|
133
|
+
const cap = entries.length
|
|
134
|
+
for (let i = 0; i < cap; i++) {
|
|
135
|
+
const e = entries[i]
|
|
136
|
+
const isDir = e.type === 'directory'
|
|
137
|
+
const isOpen = expanded[e.path] === true
|
|
138
|
+
rows.push(rowEl(e, depth, isDir, isOpen))
|
|
139
|
+
if (isDir && isOpen) rows.push(...rowsOf(e.path, depth + 1))
|
|
140
|
+
}
|
|
141
|
+
if (cap >= DIR_CAP) {
|
|
142
|
+
rows.push(React.createElement('div', { key: rel + ':cap', className: 'edrv-tree-loading', style: { paddingLeft: 6 + depth * 14 } },
|
|
143
|
+
'目录条目过多,仅显示前 ' + DIR_CAP + ' 项'))
|
|
144
|
+
}
|
|
145
|
+
return rows
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return React.createElement('div', { className: 'edrv-side-panel' },
|
|
149
|
+
React.createElement('div', { className: 'edrv-side-head' },
|
|
150
|
+
React.createElement('span', { className: 'edrv-side-title' }, '资源管理器'),
|
|
151
|
+
React.createElement('span', { className: 'edrv-side-root', title: root || '' }, rootName),
|
|
152
|
+
React.createElement('span', { style: { flex: 1 } }),
|
|
153
|
+
React.createElement('button', { className: 'edrv-side-btn', title: '刷新目录树', onClick: refresh }, '⟳')),
|
|
154
|
+
React.createElement('div', { className: 'edrv-tree' },
|
|
155
|
+
(error
|
|
156
|
+
? React.createElement('div', { className: 'edrv-tree-error' },
|
|
157
|
+
React.createElement('span', null, String(error)),
|
|
158
|
+
React.createElement('button', { className: 'edrv-side-btn', onClick: () => refresh() }, '重试'))
|
|
159
|
+
: null),
|
|
160
|
+
rowsOf('', 0)))
|
|
161
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* dsh-vscode-mode client — 「文件管理」面板定义(注册表一项)。
|
|
4
|
+
* 作者 ddj 2026-08-26
|
|
5
|
+
*/
|
|
6
|
+
import React from 'react'
|
|
7
|
+
import { FileExplorer } from './FileExplorer.js'
|
|
8
|
+
import type { SidebarPanelDef, SidebarCtx } from '../types.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 构造文件管理面板定义。
|
|
12
|
+
* @author ddj 2026年08月26号
|
|
13
|
+
* @returns 面板定义(含活动栏徽标 = 差异文件总数)
|
|
14
|
+
*/
|
|
15
|
+
export function createFilePanel(): SidebarPanelDef {
|
|
16
|
+
return {
|
|
17
|
+
id: 'explorer',
|
|
18
|
+
title: '文件管理',
|
|
19
|
+
icon: '📁',
|
|
20
|
+
order: 10,
|
|
21
|
+
badge: (ctx: SidebarCtx) => (ctx.sum && ctx.sum.totalFiles > 0 ? ctx.sum.totalFiles : null),
|
|
22
|
+
render: (ctx: SidebarCtx) => React.createElement(FileExplorer, { ctx }),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode client — 侧边栏面板注册表。
|
|
3
|
+
* 镜像 fileOpeners.ts 的注册表模式:create + register(返回注销) + list + subscribe,
|
|
4
|
+
* 由 client/index.ts `ctx.provide('edrvSidebarPanels', registry)` 对外暴露(第三方可注册)。
|
|
5
|
+
* 作者 ddj 2026-08-26
|
|
6
|
+
*/
|
|
7
|
+
import type { SidebarPanelDef } from './types.js'
|
|
8
|
+
|
|
9
|
+
export interface SidebarPanelRegistry {
|
|
10
|
+
register(panel: SidebarPanelDef): () => void
|
|
11
|
+
list(): readonly SidebarPanelDef[]
|
|
12
|
+
subscribe(listener: () => void): () => void
|
|
13
|
+
get(id: string): SidebarPanelDef | undefined
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 校验面板定义并写入注册表(缺 id/render 抛 TypeError)。 */
|
|
17
|
+
function panelRegister(entries: Map<string, SidebarPanelDef>, notify: () => void, panel: SidebarPanelDef): void {
|
|
18
|
+
if (!panel.id || typeof panel.render !== 'function') throw new TypeError('侧边栏面板必须提供 id 和 render')
|
|
19
|
+
entries.set(panel.id, panel)
|
|
20
|
+
notify()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 创建生命周期独立的面板注册表。
|
|
25
|
+
* @author ddj 2026年08月26号
|
|
26
|
+
* @returns 面板注册表
|
|
27
|
+
*/
|
|
28
|
+
export function createSidebarPanelRegistry(): SidebarPanelRegistry {
|
|
29
|
+
const entries = new Map<string, SidebarPanelDef>()
|
|
30
|
+
const listeners = new Set<() => void>()
|
|
31
|
+
const notify = (): void => {
|
|
32
|
+
for (const listener of listeners) listener()
|
|
33
|
+
}
|
|
34
|
+
const list = (): readonly SidebarPanelDef[] =>
|
|
35
|
+
[...entries.values()].sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
|
36
|
+
return {
|
|
37
|
+
register(panel: SidebarPanelDef): () => void {
|
|
38
|
+
panelRegister(entries, notify, panel)
|
|
39
|
+
return () => {
|
|
40
|
+
if (entries.get(panel.id) !== panel) return
|
|
41
|
+
entries.delete(panel.id)
|
|
42
|
+
notify()
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
list,
|
|
46
|
+
subscribe(listener: () => void): () => void {
|
|
47
|
+
listeners.add(listener)
|
|
48
|
+
return () => listeners.delete(listener)
|
|
49
|
+
},
|
|
50
|
+
get: (id: string) => entries.get(id),
|
|
51
|
+
}
|
|
52
|
+
}
|