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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
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,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 编辑器(顶部=文件页签+搜索框,差异 UI=文件底部圆角悬浮框)
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
+ }