dsh-vscode-mode 0.1.22 → 0.1.25

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.22",
4
- "description": "DSH 上的类 VSCode 编码体验:Monaco 中央编辑器(文件页签/QuickOpen/状态栏)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚),状态持久化到工作区旁车",
3
+ "version": "0.1.25",
4
+ "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 dsh-better-sidebar 侧边栏与对话同屏)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚),状态持久化到工作区旁车",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "dsh-plugin",
@@ -51,13 +51,16 @@
51
51
  "@deepseek-ai/dsh-tools": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0",
52
52
  "cordis": ">=4.0.0-rc <5",
53
53
  "react": "^18.2.0",
54
+ "react-dom": "^18.2.0",
54
55
  "schemastery": "^3.18.0"
55
56
  },
56
57
  "devDependencies": {
57
58
  "@deepseek-ai/dsh-settings": "0.1.0-rc.8",
58
59
  "@types/node": "^24.13.3",
59
60
  "@types/react": "^18.3.1",
61
+ "@types/react-dom": "^18.3.5",
60
62
  "react": "^18.3.1",
63
+ "react-dom": "^18.3.1",
61
64
  "schemastery": "3.18.0",
62
65
  "tsdown": "^0.22.14",
63
66
  "typescript": "^5.9.0",
@@ -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', {
@@ -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,6 +26,8 @@ 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'
@@ -72,6 +75,8 @@ export function apply(ctx: any): void {
72
75
  const binder = pickSettingsBinder(ctx)
73
76
  const settings = binder.scope
74
77
  let selected = autoValue('auto')
78
+ /** 可选探测 betterSidebar 服务(不进 inject:缺失会让插件停靠等待,杀死回退路径)。 */
79
+ const sideService = detectSidebarService(ctx)
75
80
  /** 「添加到对话」动作集:编辑区/Tab 右键菜单注入文件引用与代码块(conversation 服务缺失时各动作安全降级)。 */
76
81
  const addToConversation = createAddToConversation(ctx)
77
82
 
@@ -80,6 +85,7 @@ export function apply(ctx: any): void {
80
85
  { name: '设置桥', active: settings !== undefined, note: settings !== undefined ? '使用 ' + binder.service + ' 桥' : '未绑定设置服务(fileOpenTool 持久化不可用)' },
81
86
  { name: '文件打开路由(workspaces.openPath)', active: Boolean(workspaces?.openPath), note: 'vscode 打开器优先,失败回退系统打开' },
82
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 + ')' },
83
89
  ]
84
90
 
85
91
  ctx.provide('fileOpeners', registry)
@@ -134,14 +140,35 @@ export function apply(ctx: any): void {
134
140
  }), 'vscode-mode: file link routing')
135
141
  }
136
142
 
137
- // 中央「文件编辑」页签:类 VSCode 编辑器(顶部=文件页签+搜索框,左侧=侧边栏,差异 UI=文件底部圆角悬浮框)
138
- registerSlotSafely(ctx, {
139
- name: 'conversation.view',
140
- id: 'edrv-editor',
141
- order: 5,
142
- label: '文件编辑',
143
- inject: (sessionId: string) => ({ sessionId }),
144
- }, (props: unknown) => React.createElement(EditorView, Object.assign({}, props, { schedule, addToConversation, sidebarPanels, outlineSources, fileMenuItems })))
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
+ }
145
172
 
146
173
  // 对话输入框上方差异 dock:普通对话显示单文案按钮,文件编辑页由 EditorView 隐藏
147
174
  registerSlotSafely(ctx, {
@@ -21,6 +21,9 @@ export function createDiffRenderer(log) {
21
21
  * @param sessionId 会话 id
22
22
  */
23
23
  const render = (monaco, editor, pendingRegions, sessionId) => {
24
+ // 空渲染且无残留(5s 轮询 setRecords 常触发):无需清理时直接跳过,
25
+ // 不打 begin 日志、不做无谓 DOM 操作;有 zone/decoration 残留时仍需走清理。
26
+ if (!pendingRegions.length && viewZoneIds.length === 0 && decorations.length === 0) return
24
27
  const renderT0 = Date.now()
25
28
  const callIdAttr = (callId, idx) => String(callId) + ':' + String(idx)
26
29
  log(sessionId, '[diff-render] begin regs=' + pendingRegions.length + ' ' + pendingRegions.map((r) => callIdAttr(r.callId, r.idx) + '@' + (r.start ?? '?') + '-' + (r.end ?? '?') + '(-' + (r.oldLines ? r.oldLines.length : 0) + '/+' + (r.newLines ? r.newLines.length : 0) + ')').join('|'))
@@ -10,6 +10,7 @@
10
10
  import React from 'react'
11
11
  import { rpc } from '../../rpc.js'
12
12
  import { ContextMenu } from '../../ui/ContextMenu.js'
13
+ import { rowMenuPosition } from '../../ui/menuPosition.js'
13
14
  import { buildTreeMenu } from '../contextMenu.js'
14
15
  import type { SidebarCtx } from '../types.js'
15
16
 
@@ -74,6 +75,7 @@ export function FileExplorer(props) {
74
75
  const refresh = () => {
75
76
  const keep = Object.keys(expandedRef.current).filter((k) => expandedRef.current[k] === true)
76
77
  tokensRef.current = {}
78
+ setMenu(null)
77
79
  setDirs({})
78
80
  setLoading({})
79
81
  setError(null)
@@ -94,6 +96,7 @@ export function FileExplorer(props) {
94
96
  setExpanded({})
95
97
  setLoading({})
96
98
  setError(null)
99
+ setMenu(null)
97
100
  setRoot(null)
98
101
  void loadDir('')
99
102
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -111,17 +114,22 @@ export function FileExplorer(props) {
111
114
  const rowEl = (e, depth, isDir, isOpen) => {
112
115
  const pending = isDir ? 0 : (pendingByPath[e.path] ?? 0)
113
116
  const active = !isDir && e.path === activePath
117
+ const contextTarget = Boolean(menu && menuEntries.length && menu.target.path === e.path)
114
118
  const dim = e.type === 'other'
115
119
  return React.createElement('div', {
116
120
  key: e.path,
117
- className: 'edrv-tree-row' + (active ? ' edrv-tree-active' : ''),
121
+ className: 'edrv-tree-row'
122
+ + (active ? ' edrv-tree-active' : '')
123
+ + (contextTarget ? ' edrv-tree-context' : ''),
118
124
  title: e.path,
119
125
  style: { paddingLeft: 6 + depth * 14 },
120
126
  onClick: () => { if (isDir) toggle(e.path); else openFile(e.path) },
121
127
  onContextMenu: (ev) => {
122
128
  ev.preventDefault()
123
129
  ev.stopPropagation()
124
- setMenu({ x: ev.clientX, y: ev.clientY, target: { path: e.path, type: e.type } })
130
+ const rect = ev.currentTarget?.getBoundingClientRect?.()
131
+ const position = rowMenuPosition(rect, ev.clientX, ev.clientY)
132
+ setMenu({ x: position.left, y: position.top, target: { path: e.path, type: e.type } })
125
133
  },
126
134
  },
127
135
  React.createElement('span', { className: 'edrv-tree-chev' },
@@ -158,6 +166,8 @@ export function FileExplorer(props) {
158
166
  // 面板区右键(空白处)→ 工作区根目录菜单;行内右键已在 rowEl 阻止冒泡。
159
167
  const onPanelContext = (ev) => {
160
168
  ev.preventDefault()
169
+ ev.stopPropagation()
170
+ if (!ev.currentTarget.contains(ev.target)) return
161
171
  setMenu({ x: ev.clientX, y: ev.clientY, target: { path: '', type: 'directory' } })
162
172
  }
163
173
  // 当前右键目标的可视菜单项(构建时过滤/排序,registry 变化下次打开生效)。
@@ -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
+ }
@@ -213,6 +213,7 @@
213
213
  [data-edrv-view] .edrv-tree-row { display: flex; align-items: center; gap: 4px; height: 22px; padding-right: 6px; border-radius: 5px; color: var(--dsw-alias-label-primary, #1f2933); cursor: pointer; white-space: nowrap; }
214
214
  [data-edrv-view] .edrv-tree-row:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.08)); }
215
215
  [data-edrv-view] .edrv-tree-row.edrv-tree-active { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.14)); }
216
+ [data-edrv-view] .edrv-tree-row.edrv-tree-context { box-shadow: inset 0 0 0 1px var(--dsw-alias-brand-primary, #0f9d58); background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.12)); }
216
217
  [data-edrv-view] .edrv-tree-chev { width: 12px; flex-shrink: 0; color: var(--dsw-alias-label-tertiary, #9aa5b1); font-size: 10px; text-align: center; }
217
218
  [data-edrv-view] .edrv-tree-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
218
219
  [data-edrv-view] .edrv-tree-name.edrv-tree-dim { color: var(--dsw-alias-label-tertiary, #9aa5b1); }
@@ -232,3 +233,13 @@
232
233
  [data-edrv-view] .edrv-side-resize:hover { background: var(--dsw-alias-brand-primary, #0f9d58); opacity: .35; }
233
234
  [data-edrv-view] .edrv-chip-btn.edrv-chip-on { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.12)); color: var(--dsw-alias-label-primary, #1f2933); }
234
235
 
236
+ /* ===== 侧栏形态(betterSidebar 面板内编辑区) ===== */
237
+ [data-edrv-view][data-edrv-layout='side'] { box-sizing: border-box; height: 100%; }
238
+
239
+ /* ===== 侧边栏引导条(旧页签形态、未装 betterSidebar 时) ===== */
240
+ [data-edrv-view] .edrv-side-hint { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; flex-wrap: wrap; padding: 6px 12px; font-size: 12px; color: var(--dsw-alias-label-secondary, #52606d); background: var(--dsw-specific-tip, var(--dsw-alias-bg-layer-2, #f0f4f4)); border-bottom: 1px solid var(--dsw-alias-border-l1, #e0e6e8); }
241
+ [data-edrv-view] .edrv-side-hint-text { white-space: nowrap; }
242
+ [data-edrv-view] .edrv-side-hint-cmd { font-size: 11px; padding: 2px 6px; border-radius: 6px; background: var(--dsw-alias-bg-layer-1, #ffffff); border: 1px solid var(--dsw-alias-border-l1, #e0e6e8); color: var(--dsw-alias-label-primary, #1f2933); white-space: nowrap; }
243
+ [data-edrv-view] .edrv-side-hint-close { margin-left: auto; border: none; background: transparent; color: var(--dsw-alias-label-tertiary, #9aa5b1); cursor: pointer; font-size: 14px; line-height: 1; padding: 2px 6px; border-radius: 6px; flex-shrink: 0; }
244
+ [data-edrv-view] .edrv-side-hint-close:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.08)); color: var(--dsw-alias-label-primary, #1f2933); }
245
+
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * dsh-vscode-mode client — 通用浮动右键菜单(createElement 风格、类型化)。
3
- * 全屏 backdrop(点击/右键/Esc 关闭)+ 固定定位(viewport clamp 防越界),
3
+ * 非阻塞式关闭:window capture 监听(点击菜单外/右键菜单外/Esc 关闭),
4
+ * 不拦截菜单外事件,允许同一事件内由发起方切换/重开菜单。
4
5
  * 条目支持 danger/disabled/separator,复用既有 edrv-ctxmenu* 样式类。
5
6
  * 作者 ddj 2026-08-27
6
7
  */
7
8
  import React from 'react'
9
+ import { createPortal } from 'react-dom'
10
+ import { clampMenuPosition } from './menuPosition.js'
8
11
 
9
12
  /** 单条菜单项(展示层形状;业务侧由 buildTreeMenu 映射而来)。 */
10
13
  export interface ContextMenuEntry {
@@ -37,17 +40,43 @@ const MENU_H = 176
37
40
  */
38
41
  export function ContextMenu(props: ContextMenuProps): React.ReactElement {
39
42
  const { x, y, entries, onClose } = props
43
+ const menuRef = React.useRef<HTMLDivElement | null>(null)
40
44
 
41
45
  React.useEffect(() => {
42
46
  const onKey = (e: KeyboardEvent): void => {
43
47
  if (e.key === 'Escape') onClose()
44
48
  }
49
+ const onPointerDown = (e: PointerEvent): void => {
50
+ if (e.button !== 0) return
51
+ if (menuRef.current && menuRef.current.contains(e.target as Node)) return
52
+ onClose()
53
+ }
54
+ const onContextMenu = (e: MouseEvent): void => {
55
+ if (menuRef.current && menuRef.current.contains(e.target as Node)) {
56
+ e.preventDefault()
57
+ }
58
+ onClose()
59
+ }
45
60
  window.addEventListener('keydown', onKey, true)
46
- return () => window.removeEventListener('keydown', onKey, true)
61
+ window.addEventListener('pointerdown', onPointerDown, true)
62
+ window.addEventListener('contextmenu', onContextMenu, true)
63
+ return () => {
64
+ window.removeEventListener('keydown', onKey, true)
65
+ window.removeEventListener('pointerdown', onPointerDown, true)
66
+ window.removeEventListener('contextmenu', onContextMenu, true)
67
+ }
47
68
  }, [onClose])
48
69
 
49
- const left = Math.max(4, Math.min(x, (window.innerWidth || 800) - MENU_W))
50
- const top = Math.max(4, Math.min(y, (window.innerHeight || 600) - MENU_H))
70
+ const safeX = Number.isFinite(x) ? x : 4
71
+ const safeY = Number.isFinite(y) ? y : 4
72
+ const position = clampMenuPosition(
73
+ safeX,
74
+ safeY,
75
+ window.innerWidth || 800,
76
+ window.innerHeight || 600,
77
+ MENU_W,
78
+ MENU_H,
79
+ )
51
80
 
52
81
  const children: React.ReactNode[] = []
53
82
  for (const entry of entries) {
@@ -71,12 +100,8 @@ export function ContextMenu(props: ContextMenuProps): React.ReactElement {
71
100
  }, entry.label))
72
101
  }
73
102
 
74
- return React.createElement(React.Fragment, null,
75
- React.createElement('div', {
76
- className: 'edrv-ctxmenu-backdrop',
77
- style: { position: 'fixed', inset: 0, zIndex: 70 },
78
- onClick: onClose,
79
- onContextMenu: (e: React.MouseEvent) => { e.preventDefault(); onClose() },
80
- }),
81
- React.createElement('div', { className: 'edrv-ctxmenu', style: { left, top } }, ...children))
103
+ const overlay = React.createElement('div', { 'data-edrv-view': '1' },
104
+ React.createElement('div', { className: 'edrv-ctxmenu', ref: menuRef, style: position }, ...children))
105
+
106
+ return typeof document === 'undefined' ? overlay : createPortal(overlay, document.body)
82
107
  }
@@ -14,7 +14,7 @@ import { summarize } from '../state/records.js'
14
14
  */
15
15
  export function DiffBadge(props) {
16
16
  const sessionId = props?.sessionId
17
- const [n, setN] = React.useState(0)
17
+ const [summary, setSummary] = React.useState(null)
18
18
  const seq = React.useRef(0)
19
19
 
20
20
  const load = React.useCallback(() => {
@@ -22,7 +22,7 @@ export function DiffBadge(props) {
22
22
  const s = ++seq.current
23
23
  rpc('edrv.list', { sessionId }).then((res) => {
24
24
  if (s !== seq.current || !res || !res.ok || !Array.isArray(res.records)) return
25
- setN(summarize(res.records).totalFiles)
25
+ setSummary(summarize(res.records))
26
26
  }).catch(() => {})
27
27
  }, [sessionId])
28
28
 
@@ -34,10 +34,12 @@ export function DiffBadge(props) {
34
34
  return () => { clearInterval(t); window.removeEventListener('edrv:refresh', onRefresh) }
35
35
  }, [load])
36
36
 
37
+ const n = summary?.totalFiles ?? 0
37
38
  if (!n) return null
38
39
 
39
40
  const click = () => {
40
- openEditorView(null)
41
+ // 侧栏形态:带首个待处理文件路径(内容打开自动展开面板);旧形态行为不变
42
+ openEditorView(summary?.pendingFiles?.[0]?.path ?? null)
41
43
  setTimeout(() => { emitShowLauncher() }, 120)
42
44
  }
43
45