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.
@@ -69,6 +69,8 @@
69
69
  [data-edrv-view] .edrv-ctxmenu-item { display: flex; align-items: center; gap: 8px; font-size: 12px; line-height: 18px; padding: 7px 10px; border: none; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-primary, #1f2933); cursor: pointer; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
70
70
  [data-edrv-view] .edrv-ctxmenu-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.08)); }
71
71
  [data-edrv-view] .edrv-ctxmenu-item.edrv-ctxmenu-danger { color: var(--dsw-alias-state-error-primary, #d9534f); }
72
+ [data-edrv-view] .edrv-ctxmenu-item:disabled,
73
+ [data-edrv-view] .edrv-ctxmenu-item.edrv-ctxmenu-disabled { color: var(--dsw-alias-label-disabled, #b0b7c0); cursor: default; background: transparent; }
72
74
  [data-edrv-view] .edrv-ctxmenu-sep { height: 1px; margin: 3px 4px; background: var(--dsw-alias-border-l1, #e0e6e8); }
73
75
  [data-edrv-view] .edrv-diffbar-body { max-height: 130px; max-width: min(560px, calc(100vw - 40px)); overflow: auto; padding: 4px 8px 8px; display: flex; flex-direction: column; gap: 4px; border-top: 1px solid var(--dsw-alias-border-l1, #e0e6e8); }
74
76
  [data-edrv-view] .edrv-diffrow { display: flex; align-items: center; gap: 8px; padding: 3px 6px; border: 1px solid var(--dsw-alias-border-l1, #e0e6e8); border-radius: 6px; background: var(--dsw-alias-bg-layer-2, #f0f4f4); cursor: pointer; flex-wrap: wrap; }
@@ -201,7 +203,7 @@
201
203
  [data-edrv-view] .edrv-rail-icon { font-size: 15px; line-height: 1; }
202
204
  [data-edrv-view] .edrv-rail-badge { position: absolute; top: 2px; right: 2px; min-width: 14px; height: 14px; padding: 0 3px; border-radius: 999px; background: var(--dsw-alias-brand-primary, #0f9d58); color: #fff; font-size: 9px; font-weight: 600; line-height: 14px; text-align: center; box-sizing: border-box; }
203
205
  [data-edrv-view] .edrv-side-body { flex: 1; min-width: 0; min-height: 0; display: flex; overflow: hidden; }
204
- [data-edrv-view] .edrv-side-panel { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
206
+ [data-edrv-view] .edrv-side-panel { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; position: relative; }
205
207
  [data-edrv-view] .edrv-side-head { display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 10px 0 12px; border-bottom: 1px solid var(--dsw-alias-border-l1, #e0e6e8); flex-shrink: 0; }
206
208
  [data-edrv-view] .edrv-side-title { font-size: 12px; font-weight: 600; color: var(--dsw-alias-label-primary, #1f2933); white-space: nowrap; }
207
209
  [data-edrv-view] .edrv-side-root { font-size: 11px; color: var(--dsw-alias-label-tertiary, #9aa5b1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -230,3 +232,13 @@
230
232
  [data-edrv-view] .edrv-side-resize:hover { background: var(--dsw-alias-brand-primary, #0f9d58); opacity: .35; }
231
233
  [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); }
232
234
 
235
+ /* ===== 侧栏形态(betterSidebar 面板内编辑区) ===== */
236
+ [data-edrv-view][data-edrv-layout='side'] { box-sizing: border-box; height: 100%; }
237
+
238
+ /* ===== 侧边栏引导条(旧页签形态、未装 betterSidebar 时) ===== */
239
+ [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); }
240
+ [data-edrv-view] .edrv-side-hint-text { white-space: nowrap; }
241
+ [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; }
242
+ [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; }
243
+ [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); }
244
+
@@ -0,0 +1,82 @@
1
+ /**
2
+ * dsh-vscode-mode client — 通用浮动右键菜单(createElement 风格、类型化)。
3
+ * 全屏 backdrop(点击/右键/Esc 关闭)+ 固定定位(viewport clamp 防越界),
4
+ * 条目支持 danger/disabled/separator,复用既有 edrv-ctxmenu* 样式类。
5
+ * 作者 ddj 2026-08-27
6
+ */
7
+ import React from 'react'
8
+
9
+ /** 单条菜单项(展示层形状;业务侧由 buildTreeMenu 映射而来)。 */
10
+ export interface ContextMenuEntry {
11
+ id: string
12
+ label: string
13
+ danger?: boolean
14
+ disabled?: boolean
15
+ /** 前置分隔线。 */
16
+ separator?: boolean
17
+ onClick?: () => void
18
+ }
19
+
20
+ export interface ContextMenuProps {
21
+ x: number
22
+ y: number
23
+ entries: ContextMenuEntry[]
24
+ onClose: () => void
25
+ }
26
+
27
+ /** 菜单估算宽高(clamp 防越出视口,与 EditorView menuPos 常量对齐)。 */
28
+ const MENU_W = 224
29
+ const MENU_H = 176
30
+
31
+ /**
32
+ * 浮动右键菜单。
33
+ * @param props.x 视口 x 坐标
34
+ * @param props.y 视口 y 坐标
35
+ * @param props.entries 菜单项列表
36
+ * @param props.onClose 关闭回调(backdrop 点击/右键/Esc/点击项后触发)
37
+ */
38
+ export function ContextMenu(props: ContextMenuProps): React.ReactElement {
39
+ const { x, y, entries, onClose } = props
40
+
41
+ React.useEffect(() => {
42
+ const onKey = (e: KeyboardEvent): void => {
43
+ if (e.key === 'Escape') onClose()
44
+ }
45
+ window.addEventListener('keydown', onKey, true)
46
+ return () => window.removeEventListener('keydown', onKey, true)
47
+ }, [onClose])
48
+
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))
51
+
52
+ const children: React.ReactNode[] = []
53
+ for (const entry of entries) {
54
+ if (entry.separator) {
55
+ children.push(React.createElement('div', { key: 'sep-' + entry.id, className: 'edrv-ctxmenu-sep' }))
56
+ continue
57
+ }
58
+ const cls = 'edrv-ctxmenu-item'
59
+ + (entry.danger ? ' edrv-ctxmenu-danger' : '')
60
+ + (entry.disabled ? ' edrv-ctxmenu-disabled' : '')
61
+ children.push(React.createElement('button', {
62
+ key: entry.id,
63
+ className: cls,
64
+ disabled: entry.disabled,
65
+ onClick: () => {
66
+ if (!entry.disabled) {
67
+ entry.onClick?.()
68
+ onClose()
69
+ }
70
+ },
71
+ }, entry.label))
72
+ }
73
+
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))
82
+ }
@@ -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
 
@@ -18,17 +18,23 @@ import { SidebarView } from '../sidebar/SidebarView.js'
18
18
  import { clearDiffDock, publishDiffDock } from '../diffDockStore.js'
19
19
  import { displayDiffTotal, editorDockMode } from '../diffDock.js'
20
20
  import { editorHeight } from '../editorLayout.js'
21
+ import { revealInExplorer as revealPathInExplorer } from '../fileReveal.js'
22
+ import { setSidePending, SIDEBAR_INSTALL_CMD } from '../sidebarBridge.js'
21
23
 
22
24
  /**
23
25
  * 中央编辑区:文件页签(脏点/关闭/打开路径)+ Ctrl+P 搜索 + Monaco 编辑器 +
24
26
  * 底部差异条(DiffBox/空态)+ 全局差异下拉(DiffLauncher)+ 状态栏。
25
27
  * @param props.sessionId 会话 id
26
28
  * @param props.schedule 延时调度(ctx.timeout)
29
+ * @param props.layout 布局形态:'tab'(中央页签,默认)| 'side'(侧边栏面板)
30
+ * @param props.sideHint 旧页签形态下显示侧边栏引导(安装命令)
27
31
  */
28
32
  export function EditorView(props) {
29
33
  const sessionId = props?.sessionId
30
34
  const schedule = props.schedule
31
35
  const addToConversation = props.addToConversation
36
+ const layout = props?.layout === 'side' ? 'side' : 'tab'
37
+ const sideHint = props?.sideHint
32
38
  const [monaco, setMonaco] = React.useState(null)
33
39
  const [monacoErr, setMonacoErr] = React.useState(null)
34
40
  const [records, setRecords] = React.useState({})
@@ -50,10 +56,13 @@ export function EditorView(props) {
50
56
  const [diffIdx, setDiffIdx] = React.useState(0) // 当前文件内差异位置(x/x 显示)
51
57
  const [fileIdx, setFileIdx] = React.useState(0) // 全局差异文件位置(x/x 文件 显示)
52
58
  const [tabMenu, setTabMenu] = React.useState(null) // Tab 右键菜单 { x,y,path }(编辑区右键走 Monaco 原生菜单,无此浮层)
53
- const [sidebarOn, setSidebarOn] = React.useState(true) // 侧边栏显隐
59
+ const [sidebarOn, setSidebarOn] = React.useState(layout !== 'side') // 侧边栏显隐(侧栏形态默认收起)
54
60
  const [sidebarW, setSidebarW] = React.useState(240) // 侧边栏宽度
55
61
  const [activePanel, setActivePanel] = React.useState('explorer') // 激活面板 id
56
62
  const [focusRequest, setFocusRequest] = React.useState(0) // 外部差异聚焦请求版本
63
+ const [hintDismissed, setHintDismissed] = React.useState(() => {
64
+ try { return localStorage.getItem('edrv.side-hint-dismissed') === '1' } catch { return false }
65
+ }) // 侧边栏引导条是否已关闭
57
66
  const editorRef = React.useRef(null)
58
67
  const viewRootRef = React.useRef(null)
59
68
  const monacoRef = React.useRef(null)
@@ -74,6 +83,8 @@ export function EditorView(props) {
74
83
  const batchBusyRef = React.useRef(false) // 批量 Keep All/Undo All 防重入
75
84
  const menuHandlersRef = React.useRef(null) // 右键菜单动作的最新闭包(Monaco addAction 空依赖回调读取)
76
85
  const diffRendererRef = React.useRef(null)
86
+ const layoutRef = React.useRef(layout) // ensureEditor 空依赖闭包读取的稳定布局
87
+ layoutRef.current = layout
77
88
  if (!diffRendererRef.current) diffRendererRef.current = createDiffRenderer((sid, t) => dbg(sid, t))
78
89
 
79
90
  const currentRecords = React.useMemo(() => {
@@ -194,10 +205,15 @@ export function EditorView(props) {
194
205
 
195
206
  // 对话输入区的唯一差异 dock 读取此会话快照;编辑器卸载时只清理自己的发布者令牌。
196
207
  const dockSourceRef = React.useRef({})
197
- React.useEffect(() => () => clearDiffDock(sessionId, dockSourceRef.current), [sessionId])
208
+ React.useEffect(() => () => {
209
+ clearDiffDock(sessionId, dockSourceRef.current)
210
+ if (layout === 'side') setSidePending(sessionId, 0)
211
+ }, [sessionId])
198
212
 
199
213
  // DSH composer 保持原生布局;编辑区只读取几何边界并同步自己的高度。
214
+ // 侧栏形态:面板容器自带高度,跳过 composer 几何同步(面板内无会话滚动区)。
200
215
  React.useLayoutEffect(() => {
216
+ if (layout === 'side') return
201
217
  const root = viewRootRef.current
202
218
  const scroll = root?.closest?.('[data-conversation-scroll]')
203
219
  if (!root || !scroll) return
@@ -248,7 +264,7 @@ export function EditorView(props) {
248
264
  window.removeEventListener('resize', scheduleUpdate)
249
265
  root.style.removeProperty('--edrv-editor-height')
250
266
  }
251
- }, [sessionId])
267
+ }, [sessionId, layout])
252
268
 
253
269
  // localStorage v2 恢复页签
254
270
  React.useEffect(() => {
@@ -272,11 +288,12 @@ export function EditorView(props) {
272
288
  catch (e) { /* 忽略 */ }
273
289
  }, [tabs, active, sessionId])
274
290
 
275
- // 侧边栏状态:恢复(显隐/宽度/激活面板)
291
+ // 侧边栏状态:恢复(显隐/宽度/激活面板);侧栏形态独立键(默认收起,不共享页签形态偏好)
292
+ const sidebarKey = 'edrv.sidebar.' + (layout === 'side' ? 'side.' : '') + String(sessionId)
276
293
  React.useEffect(() => {
277
294
  if (!sessionId) return
278
295
  try {
279
- const raw = localStorage.getItem('edrv.sidebar.' + String(sessionId))
296
+ const raw = localStorage.getItem(sidebarKey)
280
297
  if (raw) {
281
298
  const saved = JSON.parse(raw)
282
299
  if (typeof saved.on === 'boolean') setSidebarOn(saved.on)
@@ -284,14 +301,14 @@ export function EditorView(props) {
284
301
  if (typeof saved.panel === 'string') setActivePanel(saved.panel)
285
302
  }
286
303
  } catch (e) { /* 损坏忽略 */ }
287
- }, [sessionId])
304
+ }, [sessionId, sidebarKey])
288
305
 
289
306
  // 侧边栏状态持久化
290
307
  React.useEffect(() => {
291
308
  if (!sessionId) return
292
- try { localStorage.setItem('edrv.sidebar.' + String(sessionId), JSON.stringify({ on: sidebarOn, width: sidebarW, panel: activePanel })) }
309
+ try { localStorage.setItem(sidebarKey, JSON.stringify({ on: sidebarOn, width: sidebarW, panel: activePanel })) }
293
310
  catch (e) { /* 忽略 */ }
294
- }, [sidebarOn, sidebarW, activePanel, sessionId])
311
+ }, [sidebarOn, sidebarW, activePanel, sessionId, sidebarKey])
295
312
 
296
313
  // Ctrl+B 切换侧边栏(capture 抢占,避免与 DSH 全局冲突)
297
314
  React.useEffect(() => {
@@ -430,6 +447,7 @@ export function EditorView(props) {
430
447
  }
431
448
  if (editorRef.current || !monacoRef.current) return
432
449
  const m = monacoRef.current
450
+ const side = layoutRef.current === 'side'
433
451
  const ed = m.editor.create(node, {
434
452
  value: '',
435
453
  language: 'plaintext',
@@ -437,7 +455,7 @@ export function EditorView(props) {
437
455
  fontFamily: 'var(--ds-font-family-code, ui-monospace, monospace)',
438
456
  fontSize: 13,
439
457
  lineHeight: 20,
440
- minimap: { enabled: true, scale: 1 },
458
+ minimap: { enabled: !side, scale: 1 },
441
459
  glyphMargin: true,
442
460
  lineDecorationsWidth: 16,
443
461
  automaticLayout: true,
@@ -447,7 +465,7 @@ export function EditorView(props) {
447
465
  renderWhitespace: 'selection',
448
466
  smoothScrolling: true,
449
467
  cursorBlinking: 'smooth',
450
- padding: { top: 8 },
468
+ padding: { top: side ? 6 : 8 },
451
469
  })
452
470
  ed.addCommand(m.KeyMod.CtrlCmd | m.KeyCode.KeyS, () => { flushSave(); doSave(false) })
453
471
  ed.onDidChangeModelContent(() => {
@@ -491,6 +509,12 @@ export function EditorView(props) {
491
509
  if (s && p) menuHandlers()?.addRefToChat(p, { startLine: s.startLine, endLine: s.endLine })
492
510
  },
493
511
  })
512
+ // 在 OS 文件浏览器中打开/定位当前活动文件(与文件管理栏右键菜单同源能力)。
513
+ ed.addAction({
514
+ id: 'edrv.revealInExplorer', label: '在文件浏览器中打开', contextMenuGroupId: '1_edrv',
515
+ precondition: 'editorTextFocus',
516
+ run: (edx) => menuHandlers()?.openInExplorer(pathOf(edx)),
517
+ })
494
518
  // hover 差异块 → 浮出 Keep/Undo(req:鼠标移到编辑区差异块时显示)
495
519
  // 防闪烁:① 区域不变不 setState(浮窗锚定差异块起始行,不跟随鼠标);② 延迟隐藏;
496
520
  // ③ 浮窗自身 onMouseEnter 取消隐藏计时(鼠标在浮窗与编辑器间移动不闪)。
@@ -751,8 +775,23 @@ export function EditorView(props) {
751
775
  addToConversation.appendReference(sessionId, path, range).then((o) => setStatus(statusOfAdd(o, '已添加文件引用')))
752
776
  }
753
777
 
778
+ /**
779
+ * 在 OS 文件浏览器中打开/定位路径(Monaco 右键菜单用,状态栏反馈)。
780
+ * @author ddj 2026年08月27号
781
+ * @param path 工作区相对路径(可能为 null)
782
+ */
783
+ const openInExplorer = (path) => {
784
+ if (!path) { setStatus('无活动文件'); return }
785
+ if (!sessionId) { setStatus('无活动会话'); return }
786
+ setStatus('正在打开文件浏览器…')
787
+ revealPathInExplorer(sessionId, path).then((outcome) => {
788
+ setStatus(outcome.ok ? '已在文件浏览器中打开' : '打开失败')
789
+ if (!outcome.ok && outcome.error) setError(outcome.error)
790
+ })
791
+ }
792
+
754
793
  // 供 Monaco 原生右键菜单 addAction 读取的最新动作闭包(空依赖回调不随渲染重建)
755
- menuHandlersRef.current = { addRefToChat }
794
+ menuHandlersRef.current = { addRefToChat, openInExplorer }
756
795
 
757
796
  const openFile = (path, focusDiff) => {
758
797
  if (!path) return
@@ -879,36 +918,41 @@ export function EditorView(props) {
879
918
  React.createElement('button', { className: 'edrv-pill edrv-pill-undo', onClick: () => { dismissHover(); actHunk(hoverAct.region, true) } }, '↩ Undo'))
880
919
  : null
881
920
 
921
+ /** 组装差异 dock 快照(发布给 conversation.input.dock 的唯一 DiffBox 实例)。 */
922
+ const buildDockSnapshot = () => ({
923
+ mode: editorDockMode(active),
924
+ pendingRegions,
925
+ staleRegions,
926
+ onAct: actHunk,
927
+ onAcceptFile: acceptFile,
928
+ onUndoFile: undoFile,
929
+ onAcceptAllFiles: acceptAllFiles,
930
+ onUndoAllFiles: undoAllFiles,
931
+ allPendingCount: allPending.length,
932
+ onRollback: rollbackFile,
933
+ onJump: jumpTo,
934
+ otherFiles,
935
+ onOpenOther: (path) => openFile(path, true),
936
+ onOpenLauncher: (tab) => { setLauncherTab(tab || 'pending'); setLauncherOpen(true) },
937
+ onRefresh: reloadFile,
938
+ activePath: active,
939
+ diffIdx: contentReady ? diffIdx : 0,
940
+ diffTotal: displayDiffTotal(contentReady, pendingRegions.length, sum.files.find((file) => file.path === active)?.pending ?? 0),
941
+ fileIdx,
942
+ fileTotal: sum.pendingFiles.length,
943
+ onPrevDiff: () => gotoDiff(-1),
944
+ onNextDiff: () => gotoDiff(1),
945
+ onPrevFile: () => gotoFile(-1),
946
+ onNextFile: () => gotoFile(1),
947
+ onOpenNextFile: openNextFile,
948
+ })
949
+
882
950
  React.useEffect(() => {
883
951
  if (!sessionId) return
884
- const mode = editorDockMode(active)
885
- publishDiffDock(sessionId, {
886
- mode,
887
- pendingRegions,
888
- staleRegions,
889
- onAct: actHunk,
890
- onAcceptFile: acceptFile,
891
- onUndoFile: undoFile,
892
- onAcceptAllFiles: acceptAllFiles,
893
- onUndoAllFiles: undoAllFiles,
894
- allPendingCount: allPending.length,
895
- onRollback: rollbackFile,
896
- onJump: jumpTo,
897
- otherFiles,
898
- onOpenOther: (path) => openFile(path, true),
899
- onOpenLauncher: (tab) => { setLauncherTab(tab || 'pending'); setLauncherOpen(true) },
900
- onRefresh: reloadFile,
901
- activePath: active,
902
- diffIdx: contentReady ? diffIdx : 0,
903
- diffTotal: displayDiffTotal(contentReady, pendingRegions.length, sum.files.find((file) => file.path === active)?.pending ?? 0),
904
- fileIdx,
905
- fileTotal: sum.pendingFiles.length,
906
- onPrevDiff: () => gotoDiff(-1),
907
- onNextDiff: () => gotoDiff(1),
908
- onPrevFile: () => gotoFile(-1),
909
- onNextFile: () => gotoFile(1),
910
- onOpenNextFile: openNextFile,
911
- }, dockSourceRef.current)
952
+ publishDiffDock(sessionId, buildDockSnapshot(), dockSourceRef.current)
953
+ // 侧栏形态同步 Tab 角标计数(仅本形态写入;页签形态清零避免残留)
954
+ if (layout === 'side') setSidePending(sessionId, sum.totalFiles)
955
+ else setSidePending(sessionId, 0)
912
956
  })
913
957
 
914
958
  const overlay = launcherOpen
@@ -951,6 +995,8 @@ export function EditorView(props) {
951
995
  refreshRecords: () => refreshRecords(),
952
996
  editor: () => editorRef.current,
953
997
  outlineSources: props.outlineSources,
998
+ fileMenuItems: props.fileMenuItems,
999
+ notify: (message) => setStatus(message),
954
1000
  }
955
1001
 
956
1002
  // 主编辑列(侧边栏右侧):pathBar + tabRow + 编辑/差异区(底部整条留给 DSH 对话输入栏)
@@ -958,11 +1004,28 @@ export function EditorView(props) {
958
1004
  body,
959
1005
  hoverEl,
960
1006
  overlay)
1007
+ // 侧边栏引导条(仅旧页签形态且未装 betterSidebar 时显示;可复制安装命令、可关闭)
1008
+ const dismissHint = () => {
1009
+ setHintDismissed(true)
1010
+ try { localStorage.setItem('edrv.side-hint-dismissed', '1') } catch (e) { /* 忽略 */ }
1011
+ }
1012
+ const copyInstallCmd = () => {
1013
+ if (navigator.clipboard?.writeText) navigator.clipboard.writeText(SIDEBAR_INSTALL_CMD).catch(() => {})
1014
+ }
1015
+ const sideHintEl = (layout === 'tab' && sideHint && !hintDismissed)
1016
+ ? React.createElement('div', { className: 'edrv-side-hint' },
1017
+ React.createElement('span', { className: 'edrv-side-hint-text' }, '安装 dsh-better-sidebar 后可启用侧边栏编辑(对话+编辑同屏):'),
1018
+ React.createElement('code', { className: 'edrv-side-hint-cmd' }, SIDEBAR_INSTALL_CMD),
1019
+ React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', title: '复制安装命令', onClick: copyInstallCmd }, '复制命令'),
1020
+ React.createElement('button', { className: 'edrv-side-hint-close', title: '关闭提示', 'aria-label': '关闭提示', onClick: dismissHint }, '×'))
1021
+ : null
961
1022
  const mainCol = React.createElement('div', { className: 'edrv-main-col', style: { flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' } },
962
1023
  pathBar,
963
1024
  tabRow,
1025
+ sideHintEl,
964
1026
  editorArea)
965
1027
  // 编辑器根节点按 composer 顶部边界动态限高,底部对话区域继续由 DSH 原生渲染。
1028
+ // 侧栏形态由面板容器给高(100%),不做 composer 几何同步。
966
1029
  const editorRow = React.createElement('div', { className: 'edrv-editor-row' },
967
1030
  (sidebarOn && sidebarPanels
968
1031
  ? React.createElement(SidebarView, {
@@ -977,8 +1040,15 @@ export function EditorView(props) {
977
1040
  : null),
978
1041
  mainCol)
979
1042
 
980
- return React.createElement('div', { ref: viewRootRef, 'data-edrv-view': '1', style: { height: 'var(--edrv-editor-height, 100%)', maxHeight: 'var(--edrv-editor-height, 100%)', minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--dsw-alias-bg-base,transparent)', overflow: 'hidden' } },
981
- editorRow,
982
- menuBackdrop,
983
- tabMenuEl)
1043
+ const baseStyle = { minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--dsw-alias-bg-base,transparent)', overflow: 'hidden' }
1044
+ const rootEl = layout === 'side'
1045
+ ? React.createElement('div', { ref: viewRootRef, 'data-edrv-view': '1', 'data-edrv-layout': 'side', className: 'edrv-view-side', style: Object.assign({}, baseStyle, { height: '100%' }) },
1046
+ editorRow,
1047
+ menuBackdrop,
1048
+ tabMenuEl)
1049
+ : React.createElement('div', { ref: viewRootRef, 'data-edrv-view': '1', style: Object.assign({}, baseStyle, { height: 'var(--edrv-editor-height, 100%)', maxHeight: 'var(--edrv-editor-height, 100%)' }) },
1050
+ editorRow,
1051
+ menuBackdrop,
1052
+ tabMenuEl)
1053
+ return rootEl
984
1054
  }
@@ -0,0 +1,48 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * dsh-vscode-mode client — SideEditorTab:betterSidebar「文件编辑」Tab 的组件包装。
4
+ * 把 TabComponentProps(scope/tab/visible)映射为 EditorView 侧栏形态 props,
5
+ * 并按会话 key 保证切会话状态干净重建;创建种子的初始打开在挂载后投递一次。
6
+ * 作者 ddj 2026年08月25号
7
+ */
8
+ import React from 'react'
9
+ import { EditorView } from './EditorView.js'
10
+ import { resolveInitialOpen, setSideEditorMounted, takePendingSideOpen } from '../sidebarBridge.js'
11
+
12
+ /**
13
+ * 侧边栏 Tab 组件(TabDescriptor.component 装配)。
14
+ * @param props TabComponentProps(scope.sessionId、tab.path/meta 等)
15
+ * @returns EditorView 侧栏形态
16
+ */
17
+ export function SideEditorTab(props) {
18
+ const sessionId = props?.scope?.sessionId
19
+ const initial = React.useMemo(() => resolveInitialOpen(props?.tab), [props?.tab])
20
+ const dispatchedRef = React.useRef(false)
21
+
22
+ React.useEffect(() => {
23
+ setSideEditorMounted(true)
24
+ return () => setSideEditorMounted(false)
25
+ }, [])
26
+
27
+ React.useEffect(() => {
28
+ if (dispatchedRef.current) return
29
+ dispatchedRef.current = true
30
+ // 待消费打开(挂载前暂存的聚焦请求)优先于创建种子;两者都走同事件,EditorView 幂等处理
31
+ const pending = takePendingSideOpen(sessionId)
32
+ const request = pending ?? initial
33
+ window.dispatchEvent(new CustomEvent('edrv:open-editor', {
34
+ detail: { path: request?.path ?? null, focusDiff: request?.focusDiff === true },
35
+ }))
36
+ }, [initial.path, initial.focusDiff, sessionId])
37
+
38
+ return React.createElement(EditorView, {
39
+ key: sessionId,
40
+ sessionId,
41
+ layout: 'side',
42
+ schedule: props?.schedule,
43
+ addToConversation: props?.addToConversation,
44
+ sidebarPanels: props?.sidebarPanels,
45
+ outlineSources: props?.outlineSources,
46
+ fileMenuItems: props?.fileMenuItems,
47
+ })
48
+ }
package/src/compat.ts CHANGED
@@ -52,10 +52,12 @@ export function detectExternal(ctx: Ctx, depsAvailable: boolean): CompatAdapter[
52
52
  const mcpCount = entriesOf(ctx).length
53
53
  const settings = ctx.get('settings') as { describe?: unknown; update?: unknown } | undefined
54
54
  const hasSettings = Boolean(settings?.describe || settings?.update)
55
+ const sub = ctx.get('subprocess') as { spawn?: unknown } | undefined
55
56
  return [
56
57
  { name: MCP_PACKAGE, active: mcpCount > 0, note: mcpCount > 0 ? mcpCount + ' 个 MCP 服务条目' : '未检测到 MCP 条目(MCP 管理页显示为空)' },
57
58
  { name: '设置持久化(@deepseek-ai/dsh-settings)', active: depsAvailable, note: depsAvailable ? '设置 section 已安装' : '未安装:fileOpenTool 持久化降级为配置值' },
58
59
  { name: 'settings 服务', active: hasSettings, note: hasSettings ? '可读写设置' : '不可用(设置读写走配置回退)' },
60
+ { name: '文件浏览器打开(subprocess 服务)', active: typeof sub?.spawn === 'function', note: typeof sub?.spawn === 'function' ? '可定位/打开 OS 文件浏览器' : '不可用(右键「在文件浏览器中打开」将提示失败)' },
59
61
  ]
60
62
  }
61
63
 
package/src/reveal.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * dsh-vscode-mode host — 在 OS 文件浏览器中打开/定位路径(reveal 能力)。
3
+ * 纯函数 revealCommand 平台分发 + revealInExplorer 经 ctx.subprocess.spawn 发射
4
+ * (argv 数组、无 shell 插值,沿 workspace/revert 的 subprocess 契约)。
5
+ * 文件 → 资源管理器选中定位;目录 → 打开目录;Linux 无通用定位协议 → 打开所在目录。
6
+ * 作者 ddj 2026-08-27
7
+ */
8
+ import { dirname } from 'node:path'
9
+ import type { Ctx } from './store.js'
10
+
11
+ /** reveal 结果(沿 revert.ts 的 Result 风格)。 */
12
+ export type RevealResult = { ok: true } | { ok: false; error: string }
13
+
14
+ /**
15
+ * 构造平台 opener 的 argv(纯函数,platform 可注入便于单测)。
16
+ * @author ddj 2026年08月27号
17
+ * @param absPath 绝对路径
18
+ * @param isDir 是否为目录
19
+ * @param platform 目标平台(缺省当前进程平台)
20
+ * @returns opener argv(无 shell 插值)
21
+ */
22
+ export function revealCommand(absPath: string, isDir: boolean, platform: NodeJS.Platform = process.platform): { argv: string[] } {
23
+ switch (platform) {
24
+ case 'darwin':
25
+ return { argv: ['open', '-R', absPath] }
26
+ case 'win32':
27
+ // 文件 → 定位并选中;目录 → 直接打开该目录
28
+ return isDir
29
+ ? { argv: ['explorer.exe', absPath] }
30
+ : { argv: ['explorer.exe', '/select,', absPath] }
31
+ default:
32
+ // Linux 无通用 select 协议:文件打开所在目录、目录打开自身(KISS,对齐 better-sidebar)
33
+ return { argv: ['xdg-open', isDir ? absPath : dirname(absPath)] }
34
+ }
35
+ }
36
+
37
+ /**
38
+ * 经 host subprocess 服务发射 opener(fire-and-forget 语义)。
39
+ * Explorer 为 GUI 分离进程,非零退出码不视为失败;仅 spawn 级错误回失败。
40
+ * @author ddj 2026年08月27号
41
+ * @param ctx DSH host 上下文
42
+ * @param absPath 绝对路径
43
+ * @param isDir 是否为目录
44
+ * @returns 成功或失败原因
45
+ */
46
+ export async function revealInExplorer(ctx: Ctx, absPath: string, isDir: boolean): Promise<RevealResult> {
47
+ const sub = ctx.get('subprocess')
48
+ if (!sub || typeof sub.spawn !== 'function') {
49
+ return { ok: false, error: '打开文件浏览器不可用:缺少 subprocess 服务' }
50
+ }
51
+ const { argv } = revealCommand(absPath, isDir)
52
+ try {
53
+ const handle = sub.spawn({
54
+ argv,
55
+ cwd: dirname(absPath),
56
+ stdio: { stdout: { maxBytes: 1 << 16 }, stderr: { maxBytes: 1 << 16 }, stdin: 'ignore' },
57
+ graceMs: 10000,
58
+ })
59
+ await handle.done
60
+ return { ok: true }
61
+ } catch (error) {
62
+ return { ok: false, error: '打开失败:' + String(error) }
63
+ }
64
+ }
package/src/rpc.ts CHANGED
@@ -30,6 +30,7 @@ import { normalizeFileOpenTool, FILE_OPEN_DEFAULT, FILE_OPEN_SETTINGS_NS } from
30
30
  import { buildReport } from './compat.js'
31
31
  import { readDevForm, setDevForm } from './devForm.js'
32
32
  import { normalizeRel, toTreeEntries } from './tree.js'
33
+ import { revealInExplorer } from './reveal.js'
33
34
 
34
35
  /** cwd → Promise 链:串行化 debug 日志追加(fs read+write 非原子,避免并发丢行)。 */
35
36
  const debugWriteQueues = new Map<string, Promise<void>>()
@@ -336,6 +337,26 @@ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearch
336
337
  return { ok: false, error: '读取目录失败:' + String(error) }
337
338
  }
338
339
  },
340
+ 'edrv.revealInExplorer': async (args) => {
341
+ // 在 OS 文件浏览器中打开/定位路径(树行/编辑器右键菜单用),相对工作区解析。
342
+ const sc = await requireSession(ctx, args.sessionId)
343
+ if ('err' in sc) return { ok: false, error: sc.err }
344
+ const fs = ctx.get('fs')
345
+ if (!fs) return { ok: false, error: '缺少 fs' }
346
+ try {
347
+ const rel = normalizeRel(args.path)
348
+ if (rel === null) return { ok: false, error: '路径不合法' }
349
+ const target = await fs.resolve(rel || '.', { cwd: sc.cwd })
350
+ const info = await fs.stat(target)
351
+ if (!info) return { ok: false, error: '路径不存在' }
352
+ const abs = fs.processPath(target)
353
+ const outcome = await revealInExplorer(ctx, abs, info.type === 'directory')
354
+ if (!outcome.ok) return { ok: false, error: outcome.error }
355
+ return { ok: true, revealed: abs }
356
+ } catch (error) {
357
+ return { ok: false, error: '打开失败:' + String(error) }
358
+ }
359
+ },
339
360
  'mcp.list': async () => ({ ok: true, ...listMcp(ctx) }),
340
361
  'mcp.save': async (args) => {
341
362
  try { return { ok: true, server: await saveMcp(ctx, args.config) } }
package/src/shared/rpc.ts CHANGED
@@ -60,6 +60,7 @@ export interface RpcRequestMap {
60
60
  'edrv.debug': { sessionId?: string; text: string }
61
61
  'edrv.searchFiles': { sessionId?: string; query: string }
62
62
  'edrv.listDir': { sessionId?: string; path: string }
63
+ 'edrv.revealInExplorer': { sessionId?: string; path: string }
63
64
  'mcp.list': {}
64
65
  'mcp.save': { config: MpcConfig }
65
66
  'mcp.remove': { id: string }
@@ -94,6 +95,7 @@ export interface RpcOkMap {
94
95
  'edrv.debug': object
95
96
  'edrv.searchFiles': { files: string[]; truncated: boolean }
96
97
  'edrv.listDir': { root: string; path: string; entries: TreeEntry[] }
98
+ 'edrv.revealInExplorer': { revealed: string }
97
99
  'mcp.list': { servers: MpcServer[] }
98
100
  'mcp.save': { server: MpcServer }
99
101
  'mcp.remove': object