dsh-vscode-mode 0.1.21 → 0.1.22

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.21",
3
+ "version": "0.1.22",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 中央编辑器(文件页签/QuickOpen/状态栏)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚),状态持久化到工作区旁车",
5
5
  "keywords": [
6
6
  "dsh",
@@ -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
+ }
@@ -28,6 +28,8 @@ import { SIDEBAR_PLUGIN, pickSettingsBinder, registerSlotSafely } from './compat
28
28
  import { createAddToConversation } from './addToConversation.js'
29
29
  import { createSidebarPanelRegistry } from './sidebar/registry.js'
30
30
  import { createFilePanel } from './sidebar/panels/index.js'
31
+ import { createTreeMenuRegistry } from './sidebar/contextMenu.js'
32
+ import { createDefaultFileMenuItems } from './sidebar/menuItems.js'
31
33
  import { createOutlinePanel } from './outline/index.js'
32
34
  import { createOutlineSourceRegistry, registerBuiltinOutlineSources } from './outline/sources.js'
33
35
  import type { CompatAdapter } from '../shared/compat.js'
@@ -87,6 +89,12 @@ export function apply(ctx: any): void {
87
89
  const sidebarPanels = createSidebarPanelRegistry()
88
90
  ctx.provide('edrvSidebarPanels', sidebarPanels)
89
91
  ctx.effect(() => sidebarPanels.register(createFilePanel()), 'vscode-mode: sidebar panel')
92
+ // 文件右键菜单项注册表(对外 provide,供本插件/第三方注册;内置「在文件浏览器中打开」)
93
+ const fileMenuItems = createTreeMenuRegistry()
94
+ ctx.provide('edrvFileContextMenuItems', fileMenuItems)
95
+ for (const item of createDefaultFileMenuItems()) {
96
+ ctx.effect(() => fileMenuItems.register(item), 'vscode-mode: file context menu item ' + item.id)
97
+ }
90
98
  // 大纲源注册表(公开预留口):第三方语言插件(LSP/VSIX 等)注册更高优先级源即可覆盖兜底
91
99
  const outlineSources = createOutlineSourceRegistry()
92
100
  ctx.provide('edrvOutlineSources', outlineSources)
@@ -133,7 +141,7 @@ export function apply(ctx: any): void {
133
141
  order: 5,
134
142
  label: '文件编辑',
135
143
  inject: (sessionId: string) => ({ sessionId }),
136
- }, (props: unknown) => React.createElement(EditorView, Object.assign({}, props, { schedule, addToConversation, sidebarPanels, outlineSources })))
144
+ }, (props: unknown) => React.createElement(EditorView, Object.assign({}, props, { schedule, addToConversation, sidebarPanels, outlineSources, fileMenuItems })))
137
145
 
138
146
  // 对话输入框上方差异 dock:普通对话显示单文案按钮,文件编辑页由 EditorView 隐藏
139
147
  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
  /** 单个侧边栏面板定义。 */
@@ -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; }
@@ -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
+ }
@@ -18,6 +18,7 @@ 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'
21
22
 
22
23
  /**
23
24
  * 中央编辑区:文件页签(脏点/关闭/打开路径)+ Ctrl+P 搜索 + Monaco 编辑器 +
@@ -491,6 +492,12 @@ export function EditorView(props) {
491
492
  if (s && p) menuHandlers()?.addRefToChat(p, { startLine: s.startLine, endLine: s.endLine })
492
493
  },
493
494
  })
495
+ // 在 OS 文件浏览器中打开/定位当前活动文件(与文件管理栏右键菜单同源能力)。
496
+ ed.addAction({
497
+ id: 'edrv.revealInExplorer', label: '在文件浏览器中打开', contextMenuGroupId: '1_edrv',
498
+ precondition: 'editorTextFocus',
499
+ run: (edx) => menuHandlers()?.openInExplorer(pathOf(edx)),
500
+ })
494
501
  // hover 差异块 → 浮出 Keep/Undo(req:鼠标移到编辑区差异块时显示)
495
502
  // 防闪烁:① 区域不变不 setState(浮窗锚定差异块起始行,不跟随鼠标);② 延迟隐藏;
496
503
  // ③ 浮窗自身 onMouseEnter 取消隐藏计时(鼠标在浮窗与编辑器间移动不闪)。
@@ -751,8 +758,23 @@ export function EditorView(props) {
751
758
  addToConversation.appendReference(sessionId, path, range).then((o) => setStatus(statusOfAdd(o, '已添加文件引用')))
752
759
  }
753
760
 
761
+ /**
762
+ * 在 OS 文件浏览器中打开/定位路径(Monaco 右键菜单用,状态栏反馈)。
763
+ * @author ddj 2026年08月27号
764
+ * @param path 工作区相对路径(可能为 null)
765
+ */
766
+ const openInExplorer = (path) => {
767
+ if (!path) { setStatus('无活动文件'); return }
768
+ if (!sessionId) { setStatus('无活动会话'); return }
769
+ setStatus('正在打开文件浏览器…')
770
+ revealPathInExplorer(sessionId, path).then((outcome) => {
771
+ setStatus(outcome.ok ? '已在文件浏览器中打开' : '打开失败')
772
+ if (!outcome.ok && outcome.error) setError(outcome.error)
773
+ })
774
+ }
775
+
754
776
  // 供 Monaco 原生右键菜单 addAction 读取的最新动作闭包(空依赖回调不随渲染重建)
755
- menuHandlersRef.current = { addRefToChat }
777
+ menuHandlersRef.current = { addRefToChat, openInExplorer }
756
778
 
757
779
  const openFile = (path, focusDiff) => {
758
780
  if (!path) return
@@ -951,6 +973,8 @@ export function EditorView(props) {
951
973
  refreshRecords: () => refreshRecords(),
952
974
  editor: () => editorRef.current,
953
975
  outlineSources: props.outlineSources,
976
+ fileMenuItems: props.fileMenuItems,
977
+ notify: (message) => setStatus(message),
954
978
  }
955
979
 
956
980
  // 主编辑列(侧边栏右侧):pathBar + tabRow + 编辑/差异区(底部整条留给 DSH 对话输入栏)
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