dsh-vscode-mode 0.1.21 → 0.1.23

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,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.1.21",
4
- "description": "DSH 上的类 VSCode 编码体验:Monaco 中央编辑器(文件页签/QuickOpen/状态栏)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚),状态持久化到工作区旁车",
3
+ "version": "0.1.23",
4
+ "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 dsh-better-sidebar 侧边栏与对话同屏)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚),状态持久化到工作区旁车",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "dsh-plugin",
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * dsh-vscode-mode client — 跨组件/跨 slot 的窗口事件助手。
3
- * 迁移自原 src/client/index.ts 的 openEditorView 与事件派发,语义不改。
3
+ * 迁移自原 src/client/index.ts 的 openEditorView 与事件派发,语义不改;
4
+ * 侧边栏形态(sidebarBridge 已注册路由)优先,否则回退 DOM 页签点击。
4
5
  * 作者 ddj 2026-08-20
5
6
  */
7
+ import { routeSideEditor } from './sidebarBridge.js'
6
8
 
7
9
  /**
8
- * 选择中央「文件编辑」页签(DOM 级,无需 store actions)。
10
+ * 选择中央「文件编辑」页签(DOM 级,无需 store actions)。仅旧页签形态使用。
9
11
  * @author ddj 2026年08月26号
10
12
  */
11
13
  function selectEditorTab(): void {
@@ -19,11 +21,12 @@ function selectEditorTab(): void {
19
21
  }
20
22
 
21
23
  /**
22
- * 打开中央「文件编辑」页签。
24
+ * 打开「文件编辑」:侧边栏形态路由优先,旧形态回退中央页签。
23
25
  * @author ddj 2026年08月26号
24
26
  * @param path 要打开的路径(可空)
25
27
  */
26
28
  export function openEditorView(path: string | null): void {
29
+ if (routeSideEditor(path ?? null, false)) return
27
30
  selectEditorTab()
28
31
  const target = path ?? null
29
32
  setTimeout(() => {
@@ -32,11 +35,12 @@ export function openEditorView(path: string | null): void {
32
35
  }
33
36
 
34
37
  /**
35
- * 打开文件编辑页并聚焦指定文件的首个差异。
38
+ * 打开文件编辑页并聚焦指定文件的首个差异(侧边栏形态自动展开面板)。
36
39
  * @author ddj 2026年08月26号
37
40
  * @param path 待聚焦的差异文件路径
38
41
  */
39
42
  export function openDiffView(path: string): void {
43
+ if (routeSideEditor(path, true)) return
40
44
  selectEditorTab()
41
45
  setTimeout(() => {
42
46
  window.dispatchEvent(new CustomEvent('edrv:open-editor', {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * dsh-vscode-mode client — 「在文件浏览器中打开」RPC 包装(树菜单与 Monaco 右键共用)。
3
+ * 归一化为 { ok, error? },不抛异常;无会话/异常降级返回错误文案。
4
+ * 作者 ddj 2026-08-27
5
+ */
6
+ import { rpc } from './rpc.js'
7
+
8
+ export interface RevealOutcome {
9
+ ok: boolean
10
+ error?: string
11
+ }
12
+
13
+ /**
14
+ * 请求 host 在 OS 文件浏览器中打开/定位路径。
15
+ * @author ddj 2026年08月27号
16
+ * @param sessionId 会话 id(可空)
17
+ * @param path 工作区相对路径('' = 根目录)
18
+ * @returns 成功或失败原因
19
+ */
20
+ export async function revealInExplorer(sessionId: string | undefined, path: string): Promise<RevealOutcome> {
21
+ if (!sessionId) return { ok: false, error: '无活动会话' }
22
+ try {
23
+ const res = await rpc('edrv.revealInExplorer', { sessionId, path })
24
+ return res.ok ? { ok: true } : { ok: false, error: res.error }
25
+ } catch (error) {
26
+ return { ok: false, error: '打开异常:' + String(error) }
27
+ }
28
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * dsh-vscode-mode client — 浏览器半入口:slot 注册 + 装配。
3
- * 挂点:conversation.view「文件编辑」页签(中央 Monaco 编辑器)+ conversation.input.dock 差异条 + header 差异角标。
3
+ * 挂点:betterSidebar「文件编辑」Tab(侧边栏形态,对话+编辑同屏;未装 dsh-better-sidebar 时
4
+ * 回退 conversation.view 中央页签)+ conversation.input.dock 差异条 + header 差异角标。
4
5
  * 与 Host 通信:同源 fetch('/edrv/rpc')(shared/rpc 契约)。
5
6
  *
6
7
  * ⚠️ 跨版本 slot 装配(2026-08-21):新版 DSH 的 slots 系统要求 slot 必须由父 entry
@@ -25,9 +26,13 @@ import type { FileOpenerRegistry } from './fileOpeners.js'
25
26
  import { installOpenPathRouter, vscodeOpener, autoValue } from './openPathRouter.js'
26
27
  import { SettingsContext } from './settingsContext.js'
27
28
  import { SIDEBAR_PLUGIN, pickSettingsBinder, registerSlotSafely } from './compat.js'
29
+ import { detectSidebarService, installSideEditor, setEnsureSideEditor, SIDEBAR_INSTALL_CMD } from './sidebarBridge.js'
30
+ import { SideEditorTab } from './ui/SideEditorTab.js'
28
31
  import { createAddToConversation } from './addToConversation.js'
29
32
  import { createSidebarPanelRegistry } from './sidebar/registry.js'
30
33
  import { createFilePanel } from './sidebar/panels/index.js'
34
+ import { createTreeMenuRegistry } from './sidebar/contextMenu.js'
35
+ import { createDefaultFileMenuItems } from './sidebar/menuItems.js'
31
36
  import { createOutlinePanel } from './outline/index.js'
32
37
  import { createOutlineSourceRegistry, registerBuiltinOutlineSources } from './outline/sources.js'
33
38
  import type { CompatAdapter } from '../shared/compat.js'
@@ -70,6 +75,8 @@ export function apply(ctx: any): void {
70
75
  const binder = pickSettingsBinder(ctx)
71
76
  const settings = binder.scope
72
77
  let selected = autoValue('auto')
78
+ /** 可选探测 betterSidebar 服务(不进 inject:缺失会让插件停靠等待,杀死回退路径)。 */
79
+ const sideService = detectSidebarService(ctx)
73
80
  /** 「添加到对话」动作集:编辑区/Tab 右键菜单注入文件引用与代码块(conversation 服务缺失时各动作安全降级)。 */
74
81
  const addToConversation = createAddToConversation(ctx)
75
82
 
@@ -78,6 +85,7 @@ export function apply(ctx: any): void {
78
85
  { name: '设置桥', active: settings !== undefined, note: settings !== undefined ? '使用 ' + binder.service + ' 桥' : '未绑定设置服务(fileOpenTool 持久化不可用)' },
79
86
  { name: '文件打开路由(workspaces.openPath)', active: Boolean(workspaces?.openPath), note: 'vscode 打开器优先,失败回退系统打开' },
80
87
  { name: '侧边栏打开器(' + SIDEBAR_PLUGIN + ')', active: registry.get(SIDEBAR_PLUGIN) !== undefined, note: registry.get(SIDEBAR_PLUGIN) !== undefined ? '已注册(优先级 80)' : '未检测到侧边栏打开能力' },
88
+ { name: '侧边栏编辑区(' + SIDEBAR_PLUGIN + ')', active: sideService !== undefined, note: sideService !== undefined ? '编辑区=侧边栏 Tab(对话+编辑同屏)' : '未检测到;安装 dsh-better-sidebar 后刷新启用侧边栏形态(' + SIDEBAR_INSTALL_CMD + ')' },
81
89
  ]
82
90
 
83
91
  ctx.provide('fileOpeners', registry)
@@ -87,6 +95,12 @@ export function apply(ctx: any): void {
87
95
  const sidebarPanels = createSidebarPanelRegistry()
88
96
  ctx.provide('edrvSidebarPanels', sidebarPanels)
89
97
  ctx.effect(() => sidebarPanels.register(createFilePanel()), 'vscode-mode: sidebar panel')
98
+ // 文件右键菜单项注册表(对外 provide,供本插件/第三方注册;内置「在文件浏览器中打开」)
99
+ const fileMenuItems = createTreeMenuRegistry()
100
+ ctx.provide('edrvFileContextMenuItems', fileMenuItems)
101
+ for (const item of createDefaultFileMenuItems()) {
102
+ ctx.effect(() => fileMenuItems.register(item), 'vscode-mode: file context menu item ' + item.id)
103
+ }
90
104
  // 大纲源注册表(公开预留口):第三方语言插件(LSP/VSIX 等)注册更高优先级源即可覆盖兜底
91
105
  const outlineSources = createOutlineSourceRegistry()
92
106
  ctx.provide('edrvOutlineSources', outlineSources)
@@ -126,14 +140,35 @@ export function apply(ctx: any): void {
126
140
  }), 'vscode-mode: file link routing')
127
141
  }
128
142
 
129
- // 中央「文件编辑」页签:类 VSCode 编辑器(顶部=文件页签+搜索框,左侧=侧边栏,差异 UI=文件底部圆角悬浮框)
130
- registerSlotSafely(ctx, {
131
- name: 'conversation.view',
132
- id: 'edrv-editor',
133
- order: 5,
134
- label: '文件编辑',
135
- inject: (sessionId: string) => ({ sessionId }),
136
- }, (props: unknown) => React.createElement(EditorView, Object.assign({}, props, { schedule, addToConversation, sidebarPanels, outlineSources })))
143
+ // 中央「文件编辑」页签(旧形态回退):类 VSCode 编辑器;侧边栏形态可用时编辑器住 betterSidebar Tab,
144
+ // 本页签不注册(避免双实例:Monaco×2 + diff dock 每会话单源抢占)。
145
+ const registerLegacyTab = (): void => {
146
+ registerSlotSafely(ctx, {
147
+ name: 'conversation.view',
148
+ id: 'edrv-editor',
149
+ order: 5,
150
+ label: '文件编辑',
151
+ inject: (sessionId: string) => ({ sessionId }),
152
+ }, (props: unknown) => React.createElement(EditorView, Object.assign({}, props, { layout: 'tab', sideHint: SIDEBAR_INSTALL_CMD, schedule, addToConversation, sidebarPanels, outlineSources, fileMenuItems })))
153
+ }
154
+
155
+ if (sideService) {
156
+ // 侧边栏形态:注册「文件编辑」Tab(单实例、可按会话持久化、内容打开自动展开面板)
157
+ ctx.effect(() => installSideEditor({
158
+ service: sideService,
159
+ renderTab: (props: Record<string, unknown>) => React.createElement(SideEditorTab, Object.assign({}, props, { schedule, addToConversation, sidebarPanels, outlineSources, fileMenuItems })),
160
+ activeSession: () => {
161
+ const snapshot = sessions?.list?.getSnapshot?.() as { current?: string; byId?: Record<string, { cwd?: string }> } | undefined
162
+ const sessionId = snapshot?.current
163
+ if (!sessionId) return undefined
164
+ return { sessionId, cwd: snapshot?.byId?.[sessionId]?.cwd }
165
+ },
166
+ registerLegacyFallback: registerLegacyTab,
167
+ }), 'vscode-mode: sidebar editor tab')
168
+ } else {
169
+ setEnsureSideEditor(null)
170
+ registerLegacyTab()
171
+ }
137
172
 
138
173
  // 对话输入框上方差异 dock:普通对话显示单文案按钮,文件编辑页由 EditorView 隐藏
139
174
  registerSlotSafely(ctx, {
@@ -0,0 +1,99 @@
1
+ /**
2
+ * dsh-vscode-mode client — 文件管理右键菜单框架(类型 + 注册表 + 纯构建函数)。
3
+ * 镜像 sidebar/registry.ts 的注册表模式:create + register(返回注销) + list + subscribe,
4
+ * 由 client/index.ts `ctx.provide('edrvFileContextMenuItems', registry)` 对外暴露(第三方可注册)。
5
+ * 新增菜单项只需 register 一条 TreeMenuItem,面板在右键打开时经 buildTreeMenu 过滤/排序。
6
+ * 本模块不触 React/浏览器,可单测。
7
+ * 作者 ddj 2026-08-27
8
+ */
9
+ import type { TreeEntry } from '../../shared/rpc.js'
10
+ import type { SidebarCtx } from './types.js'
11
+
12
+ /** 右键目标(树行/面板空白区,path 相对工作区根,'' 表示根目录)。 */
13
+ export interface TreeMenuTarget {
14
+ path: string
15
+ type: TreeEntry['type']
16
+ }
17
+
18
+ /** 一条右键菜单项定义。 */
19
+ export interface TreeMenuItem {
20
+ id: string
21
+ label: string
22
+ /** 排序:数值越小越靠前(缺省 100)。 */
23
+ order?: number
24
+ /** 红色警示样式(如删除类操作)。 */
25
+ danger?: boolean
26
+ /** 置灰不可点(如无权限/目标不支持时)。 */
27
+ disabled?: boolean
28
+ /** 前置分隔线。 */
29
+ separator?: boolean
30
+ /** 显隐守卫:返回 false 则不显示。 */
31
+ visible?: (target: TreeMenuTarget, ctx: SidebarCtx) => boolean
32
+ run: (target: TreeMenuTarget, ctx: SidebarCtx) => void
33
+ }
34
+
35
+ /** 右键菜单项注册表(生命周期独立,可注册/注销/订阅)。 */
36
+ export interface TreeMenuRegistry {
37
+ register(item: TreeMenuItem): () => void
38
+ list(): readonly TreeMenuItem[]
39
+ subscribe(listener: () => void): () => void
40
+ get(id: string): TreeMenuItem | undefined
41
+ }
42
+
43
+ /** 校验菜单项并写入注册表(缺 id/run/label 抛 TypeError)。 */
44
+ function itemRegister(entries: Map<string, TreeMenuItem>, notify: () => void, item: TreeMenuItem): void {
45
+ if (!item.id || typeof item.run !== 'function' || !item.label) {
46
+ throw new TypeError('文件右键菜单项必须提供 id、label 和 run')
47
+ }
48
+ entries.set(item.id, item)
49
+ notify()
50
+ }
51
+
52
+ /**
53
+ * 创建生命周期独立的右键菜单项注册表。
54
+ * @author ddj 2026年08月27号
55
+ * @returns 菜单项注册表
56
+ */
57
+ export function createTreeMenuRegistry(): TreeMenuRegistry {
58
+ const entries = new Map<string, TreeMenuItem>()
59
+ const listeners = new Set<() => void>()
60
+ const notify = (): void => {
61
+ for (const listener of listeners) listener()
62
+ }
63
+ const list = (): readonly TreeMenuItem[] =>
64
+ [...entries.values()].sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
65
+ return {
66
+ register(item: TreeMenuItem): () => void {
67
+ itemRegister(entries, notify, item)
68
+ return () => {
69
+ if (entries.get(item.id) !== item) return
70
+ entries.delete(item.id)
71
+ notify()
72
+ }
73
+ },
74
+ list,
75
+ subscribe(listener: () => void): () => void {
76
+ listeners.add(listener)
77
+ return () => listeners.delete(listener)
78
+ },
79
+ get: (id: string) => entries.get(id),
80
+ }
81
+ }
82
+
83
+ /**
84
+ * 按目标构建当前可见菜单项(visible 过滤 + order 排序)。
85
+ * @author ddj 2026年08月27号
86
+ * @param registry 菜单项注册表
87
+ * @param target 右键目标
88
+ * @param ctx 面板共享上下文
89
+ * @returns 可见菜单项(已排序)
90
+ */
91
+ export function buildTreeMenu(registry: TreeMenuRegistry | undefined, target: TreeMenuTarget, ctx: SidebarCtx): TreeMenuItem[] {
92
+ if (!registry) return []
93
+ const out: TreeMenuItem[] = []
94
+ for (const item of registry.list()) {
95
+ if (typeof item.visible === 'function' && !item.visible(target, ctx)) continue
96
+ out.push(item)
97
+ }
98
+ return out
99
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * dsh-vscode-mode client — 文件管理右键菜单内置项。
3
+ * 首个内置项:「在文件浏览器中打开」(文件→OS Explorer 定位选中、目录→打开目录)。
4
+ * 反馈统一走 ctx.notify(由 EditorView 提供,落到编辑区路径栏状态)。
5
+ * 作者 ddj 2026-08-27
6
+ */
7
+ import { revealInExplorer } from '../fileReveal.js'
8
+ import type { TreeMenuItem } from './contextMenu.js'
9
+
10
+ /**
11
+ * 构造内置右键菜单项列表(后续内置项直接追加)。
12
+ * @author ddj 2026年08月27号
13
+ * @returns 内置菜单项数组
14
+ */
15
+ export function createDefaultFileMenuItems(): TreeMenuItem[] {
16
+ return [
17
+ {
18
+ id: 'reveal-in-explorer',
19
+ label: '在文件浏览器中打开',
20
+ order: 0,
21
+ run: (target, ctx) => {
22
+ void revealInExplorer(ctx.sessionId, target.path).then((outcome) => {
23
+ ctx.notify?.(outcome.ok ? '已在文件浏览器中打开' : '打开失败:' + (outcome.error ?? '未知错误'))
24
+ })
25
+ },
26
+ },
27
+ ]
28
+ }
@@ -3,17 +3,21 @@
3
3
  * dsh-vscode-mode client — 侧边栏「文件管理」面板(懒加载目录树)。
4
4
  * 点目录展开/收起(首次经 edrv.listDir 拉取并缓存),点文件经 ctx.openFile 打开;
5
5
  * 差异角标取 pendingByPath,活动文件高亮;edrv:refresh 事件与手动刷新重载树。
6
- * 作者 ddj 2026-08-26
6
+ * 右键行/空白弹出菜单:项来自 ctx.fileMenuItems 注册表(可拓展),
7
+ * 内置「在文件浏览器中打开」经 edrv.revealInExplorer 定位/打开。
8
+ * 作者 ddj 2026-08-26 / 2026-08-27
7
9
  */
8
10
  import React from 'react'
9
11
  import { rpc } from '../../rpc.js'
12
+ import { ContextMenu } from '../../ui/ContextMenu.js'
13
+ import { buildTreeMenu } from '../contextMenu.js'
10
14
  import type { SidebarCtx } from '../types.js'
11
15
 
12
16
  const DIR_CAP = 4000
13
17
 
14
18
  /**
15
19
  * 目录树面板主体。
16
- * @param props.ctx 面板共享上下文(sessionId/openFile/activePath/pendingByPath)
20
+ * @param props.ctx 面板共享上下文(sessionId/openFile/activePath/pendingByPath/fileMenuItems/notify)
17
21
  */
18
22
  export function FileExplorer(props) {
19
23
  const ctx = props?.ctx
@@ -26,6 +30,7 @@ export function FileExplorer(props) {
26
30
  const [expanded, setExpanded] = React.useState({})
27
31
  const [loading, setLoading] = React.useState({})
28
32
  const [error, setError] = React.useState(null)
33
+ const [menu, setMenu] = React.useState(null) // 右键菜单 { x, y, target }
29
34
  const tokensRef = React.useRef({})
30
35
  // expanded 的 ref 镜像:edrv:refresh 监听用首次渲染闭包,但需读到最新展开态
31
36
  const expandedRef = React.useRef({})
@@ -113,6 +118,11 @@ export function FileExplorer(props) {
113
118
  title: e.path,
114
119
  style: { paddingLeft: 6 + depth * 14 },
115
120
  onClick: () => { if (isDir) toggle(e.path); else openFile(e.path) },
121
+ onContextMenu: (ev) => {
122
+ ev.preventDefault()
123
+ ev.stopPropagation()
124
+ setMenu({ x: ev.clientX, y: ev.clientY, target: { path: e.path, type: e.type } })
125
+ },
116
126
  },
117
127
  React.createElement('span', { className: 'edrv-tree-chev' },
118
128
  isDir ? (isOpen ? '▾' : '▸') : ''),
@@ -145,7 +155,24 @@ export function FileExplorer(props) {
145
155
  return rows
146
156
  }
147
157
 
148
- return React.createElement('div', { className: 'edrv-side-panel' },
158
+ // 面板区右键(空白处)→ 工作区根目录菜单;行内右键已在 rowEl 阻止冒泡。
159
+ const onPanelContext = (ev) => {
160
+ ev.preventDefault()
161
+ setMenu({ x: ev.clientX, y: ev.clientY, target: { path: '', type: 'directory' } })
162
+ }
163
+ // 当前右键目标的可视菜单项(构建时过滤/排序,registry 变化下次打开生效)。
164
+ const menuEntries = menu
165
+ ? buildTreeMenu(ctx?.fileMenuItems, menu.target, ctx).map((item) => ({
166
+ id: item.id,
167
+ label: item.label,
168
+ danger: item.danger,
169
+ disabled: item.disabled,
170
+ separator: item.separator,
171
+ onClick: () => item.run(menu.target, ctx),
172
+ }))
173
+ : []
174
+
175
+ return React.createElement('div', { className: 'edrv-side-panel', onContextMenu: onPanelContext },
149
176
  React.createElement('div', { className: 'edrv-side-head' },
150
177
  React.createElement('span', { className: 'edrv-side-title' }, '资源管理器'),
151
178
  React.createElement('span', { className: 'edrv-side-root', title: root || '' }, rootName),
@@ -157,5 +184,8 @@ export function FileExplorer(props) {
157
184
  React.createElement('span', null, String(error)),
158
185
  React.createElement('button', { className: 'edrv-side-btn', onClick: () => refresh() }, '重试'))
159
186
  : null),
160
- rowsOf('', 0)))
187
+ rowsOf('', 0)),
188
+ (menu && menuEntries.length
189
+ ? React.createElement(ContextMenu, { x: menu.x, y: menu.y, entries: menuEntries, onClose: () => setMenu(null) })
190
+ : null))
161
191
  }
@@ -4,6 +4,7 @@
4
4
  * 作者 ddj 2026-08-26
5
5
  */
6
6
  import type { OutlineSourceRegistry } from '../outline/types.js'
7
+ import type { TreeMenuRegistry } from './contextMenu.js'
7
8
 
8
9
  /** 面板可用的共享上下文(由 SidebarView 从 EditorView 注入,面板不直碰其内部)。 */
9
10
  export interface SidebarCtx {
@@ -20,6 +21,10 @@ export interface SidebarCtx {
20
21
  editor?: () => unknown | null
21
22
  /** 大纲源注册表(公开 provide 为 edrvOutlineSources;大纲面板解析符号用)。 */
22
23
  outlineSources?: OutlineSourceRegistry
24
+ /** 文件右键菜单项注册表(公开 provide 为 edrvFileContextMenuItems;文件管理面板构建菜单用)。 */
25
+ fileMenuItems?: TreeMenuRegistry
26
+ /** 面板动作反馈(如右键菜单操作结果 → 编辑区路径栏状态)。 */
27
+ notify?: (message: string) => void
23
28
  }
24
29
 
25
30
  /** 单个侧边栏面板定义。 */
@@ -0,0 +1,247 @@
1
+ /**
2
+ * dsh-vscode-mode client — 侧边栏编辑区桥:可选探测 dsh-better-sidebar 的
3
+ * ctx.betterSidebar 服务,并注册「文件编辑」Tab(单实例)。
4
+ * 可选依赖模式:服务缺失时调用方回退到中央页签形态;本模块不注册任何东西。
5
+ * 纯逻辑(探测/初始打开解析/角标计数)可单测,DOM/事件仅存在于 install 路径。
6
+ * 作者 ddj 2026年08月25号
7
+ */
8
+
9
+ import React from 'react'
10
+
11
+ /** betterSidebar 服务名(ctx.get 用;不要加进 inject——缺失会让插件停靠等待)。 */
12
+ export const BETTER_SIDEBAR_SERVICE = 'betterSidebar'
13
+ /** 本插件在侧边栏注册的 Tab 类型 id(亦是 SidebarTab.type)。 */
14
+ export const SIDE_TAB_ID = 'edrv-editor'
15
+ /** 侧边栏 Tab 标题。 */
16
+ export const SIDE_TAB_TITLE = '文件编辑'
17
+ /** 推荐安装命令(提示条/兼容性报告共用)。 */
18
+ export const SIDEBAR_INSTALL_CMD = 'dsh plugin --profile web add dsh-better-sidebar'
19
+
20
+ /** Tab 元数据(openTab 种子随 Tab 持久化,创建路径用它恢复初始打开)。 */
21
+ export interface SideEditorMeta {
22
+ /** 待打开的工作区相对/绝对路径。 */
23
+ openPath?: string
24
+ /** 打开后聚焦首个差异。 */
25
+ focusDiff?: boolean
26
+ }
27
+
28
+ /**
29
+ * betterSidebar 服务的最小结构面(结构性探测,不 import 第三方类型)。
30
+ */
31
+ export interface SidebarServiceLike {
32
+ registerTab: (descriptor: unknown) => () => void
33
+ openTab: (seed: { type: string; title?: string; path?: string; meta?: unknown }, scope?: { sessionId: string; cwd?: string }) => void
34
+ isTabEnabled?: (id: string) => boolean
35
+ features?: readonly string[]
36
+ }
37
+
38
+ /**
39
+ * 解析 Tab 挂载时的初始打开请求(创建路径读 tab.path/meta,聚焦路径走窗口事件)。
40
+ * @author ddj 2026年08月25号
41
+ * @param tab 侧边栏 Tab(path/meta 为可选字段)
42
+ * @returns 待打开路径与是否聚焦差异
43
+ */
44
+ export function resolveInitialOpen(tab: { path?: string; meta?: unknown } | null | undefined): { path: string | null; focusDiff: boolean } {
45
+ const meta = (tab?.meta ?? {}) as SideEditorMeta
46
+ const path = typeof tab?.path === 'string' && tab.path ? tab.path : (typeof meta?.openPath === 'string' ? meta.openPath : null)
47
+ return { path: path || null, focusDiff: meta?.focusDiff === true }
48
+ }
49
+
50
+ /** 会话级待处理差异文件数缓存(Tab 角标同步读取;EditorView 侧栏形态写入)。 */
51
+ const sidePending = new Map<string, number>()
52
+
53
+ /**
54
+ * 写入会话的待处理差异文件数(0 视为清除,不显示角标)。
55
+ * @author ddj 2026年08月25号
56
+ * @param sessionId 会话 id
57
+ * @param count 待处理差异文件数
58
+ */
59
+ export function setSidePending(sessionId: string, count: number): void {
60
+ if (!sessionId) return
61
+ const value = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0
62
+ if (value > 0) sidePending.set(sessionId, value)
63
+ else sidePending.delete(sessionId)
64
+ }
65
+
66
+ /**
67
+ * 读取会话的待处理差异文件数(角标用)。
68
+ * @author ddj 2026年08月25号
69
+ * @param sessionId 会话 id
70
+ * @returns 非负计数
71
+ */
72
+ export function getSidePending(sessionId?: string): number {
73
+ if (!sessionId) return 0
74
+ return sidePending.get(sessionId) ?? 0
75
+ }
76
+
77
+ /** 模块级侧栏打开路由(events.ts 优先走它;未注册 = 旧页签形态)。 */
78
+ let ensureSideEditor: ((path: string | null, focusDiff: boolean) => boolean) | null = null
79
+
80
+ /** 待消费的初始打开(Tab 组件尚未挂载时暂存;按会话匹配,挂载时取走)。 */
81
+ let pendingOpen: { sessionId: string; path: string | null; focusDiff: boolean } | null = null
82
+ /** Tab 组件当前是否有挂载实例(有则直接投递窗口事件,无则暂存待消费)。 */
83
+ let sideEditorMounted = false
84
+
85
+ /**
86
+ * 注册/撤销侧栏打开路由。
87
+ * @author ddj 2026年08月25号
88
+ * @param fn 路由函数或 null
89
+ */
90
+ export function setEnsureSideEditor(fn: ((path: string | null, focusDiff: boolean) => boolean) | null): void {
91
+ ensureSideEditor = fn
92
+ }
93
+
94
+ /**
95
+ * 标记侧栏编辑器组件挂载状态(SideEditorTab 挂载/卸载时调用)。
96
+ * @author ddj 2026年08月25号
97
+ * @param mounted 是否已挂载
98
+ */
99
+ export function setSideEditorMounted(mounted: boolean): void {
100
+ sideEditorMounted = mounted
101
+ }
102
+
103
+ /**
104
+ * 暂存指定会话的待消费初始打开(Tab 组件未挂载时由 install 路径调用)。
105
+ * @author ddj 2026年08月25号
106
+ * @param sessionId 会话 id
107
+ * @param path 待打开路径(可空)
108
+ * @param focusDiff 是否聚焦首个差异
109
+ */
110
+ export function stagePendingSideOpen(sessionId: string, path: string | null, focusDiff: boolean): void {
111
+ if (!sessionId) return
112
+ pendingOpen = { sessionId, path: path || null, focusDiff: focusDiff === true }
113
+ }
114
+
115
+ /**
116
+ * 取走指定会话的待消费初始打开(SideEditorTab 挂载时读取;不存在返回 null)。
117
+ * @author ddj 2026年08月25号
118
+ * @param sessionId 会话 id
119
+ * @returns 待打开请求或 null
120
+ */
121
+ export function takePendingSideOpen(sessionId?: string): { path: string | null; focusDiff: boolean } | null {
122
+ if (!pendingOpen || !sessionId || pendingOpen.sessionId !== sessionId) return null
123
+ const value = { path: pendingOpen.path, focusDiff: pendingOpen.focusDiff }
124
+ pendingOpen = null
125
+ return value
126
+ }
127
+
128
+ /**
129
+ * 打开/聚焦侧栏编辑器(打开路径可选;返回是否已被侧栏形态接管)。
130
+ * @author ddj 2026年08月25号
131
+ * @param path 待打开路径(可空)
132
+ * @param focusDiff 是否聚焦首个差异
133
+ * @returns 是否路由成功
134
+ */
135
+ export function routeSideEditor(path: string | null, focusDiff: boolean): boolean {
136
+ if (typeof ensureSideEditor !== 'function') return false
137
+ try {
138
+ return ensureSideEditor(path, focusDiff) === true
139
+ } catch (error) {
140
+ console.warn('[dsh-vscode-mode] 侧栏编辑器打开失败(' + String(error) + '),已回退')
141
+ return false
142
+ }
143
+ }
144
+
145
+ /**
146
+ * 结构化探测 betterSidebar 服务(可选依赖:缺失/降级返回 undefined)。
147
+ * @author ddj 2026年08月25号
148
+ * @param ctx 客户端服务上下文
149
+ * @returns 服务结构面或 undefined
150
+ */
151
+ export function detectSidebarService(ctx: { get: (name: string) => unknown }): SidebarServiceLike | undefined {
152
+ try {
153
+ const service = ctx.get(BETTER_SIDEBAR_SERVICE) as SidebarServiceLike | undefined
154
+ if (!service || typeof service.registerTab !== 'function' || typeof service.openTab !== 'function') return undefined
155
+ return service
156
+ } catch {
157
+ return undefined
158
+ }
159
+ }
160
+
161
+ /**
162
+ * 注册侧边栏「文件编辑」Tab 并接管打开路由。
163
+ * Tab 被用户在 better-sidebar 设置里禁用时,打开路由降级为回退回调(中央页签)。
164
+ * @author ddj 2026年08月25号
165
+ * @param options 装配参数(组件依赖 + 活动会话解析 + 回退注册)
166
+ * @returns 卸载器(反注册 Tab + 撤销路由)
167
+ */
168
+ export function installSideEditor(options: {
169
+ service: SidebarServiceLike
170
+ renderTab: (props: Record<string, unknown>) => unknown
171
+ activeSession: () => { sessionId?: string; cwd?: string } | undefined
172
+ registerLegacyFallback: () => void
173
+ }): () => void {
174
+ const { service, renderTab, activeSession, registerLegacyFallback } = options
175
+ let fallbackInstalled = false
176
+ const open = (path: string | null, focusDiff: boolean): boolean => {
177
+ if (typeof service.isTabEnabled === 'function' && !service.isTabEnabled(SIDE_TAB_ID)) {
178
+ if (!fallbackInstalled) {
179
+ fallbackInstalled = true
180
+ try { registerLegacyFallback() } catch (error) { console.warn('[dsh-vscode-mode] 回退注册失败(' + String(error) + ')') }
181
+ }
182
+ return false
183
+ }
184
+ const scope = activeSession()
185
+ const sessionId = scope?.sessionId
186
+ if (!sessionId) return false
187
+ try {
188
+ service.openTab({
189
+ type: SIDE_TAB_ID,
190
+ title: SIDE_TAB_TITLE,
191
+ ...(path ? { path } : {}),
192
+ meta: { openPath: path || undefined, focusDiff },
193
+ }, { sessionId, cwd: scope?.cwd })
194
+ } catch (error) {
195
+ console.warn('[dsh-vscode-mode] openTab 失败(' + String(error) + ')')
196
+ return false
197
+ }
198
+ if (sideEditorMounted) {
199
+ // 已挂载:窗口事件直达 EditorView 监听器
200
+ window.dispatchEvent(new CustomEvent('edrv:open-editor', { detail: { path, focusDiff } }))
201
+ } else {
202
+ // 未挂载(面板折叠卸载/首次创建):暂存待消费,挂载时由 SideEditorTab 取走
203
+ stagePendingSideOpen(sessionId, path, focusDiff)
204
+ }
205
+ return true
206
+ }
207
+ let tabDisposer: (() => void) | null = null
208
+ try {
209
+ tabDisposer = service.registerTab({
210
+ id: SIDE_TAB_ID,
211
+ title: SIDE_TAB_TITLE,
212
+ icon: codeIcon,
213
+ single: true,
214
+ badge: (_ctx: unknown, scope: { sessionId?: string }) => getSidePending(scope?.sessionId) || null,
215
+ component: renderTab,
216
+ })
217
+ } catch (error) {
218
+ console.warn('[dsh-vscode-mode] 侧边栏 Tab 注册失败(' + String(error) + ')')
219
+ // 注册失败 = 侧栏形态不可用:立即回退中央页签,不接管打开路由
220
+ if (!fallbackInstalled) {
221
+ fallbackInstalled = true
222
+ try { registerLegacyFallback() } catch (inner) { console.warn('[dsh-vscode-mode] 回退注册失败(' + String(inner) + ')') }
223
+ }
224
+ return () => {}
225
+ }
226
+ setEnsureSideEditor(open)
227
+ return () => {
228
+ if (typeof tabDisposer === 'function') tabDisposer()
229
+ setEnsureSideEditor(null)
230
+ }
231
+ }
232
+
233
+ /**
234
+ * 侧边栏 Tab 图标(内联 SVG 代码括号,不依赖图标包)。
235
+ * @author ddj 2026年08月25号
236
+ * @param size 边长(px)
237
+ * @returns 图标元素
238
+ */
239
+ function codeIcon(size: number): unknown {
240
+ return React.createElement('svg', {
241
+ width: size, height: size, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true, focusable: false,
242
+ },
243
+ React.createElement('path', {
244
+ d: 'M5.5 4.5 2.5 8l3 3.5M10.5 4.5 13.5 8l-3 3.5',
245
+ stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round',
246
+ }))
247
+ }