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/lib/client.js +271 -8
- package/lib/client.js.map +1 -1
- package/lib/index.js +113 -0
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/fileReveal.ts +28 -0
- package/src/client/index.ts +9 -1
- package/src/client/sidebar/contextMenu.ts +99 -0
- package/src/client/sidebar/menuItems.ts +28 -0
- package/src/client/sidebar/panels/FileExplorer.ts +34 -4
- package/src/client/sidebar/types.ts +5 -0
- package/src/client/styles/editor.css +3 -1
- package/src/client/ui/ContextMenu.ts +82 -0
- package/src/client/ui/EditorView.ts +25 -1
- package/src/compat.ts +2 -0
- package/src/reveal.ts +64 -0
- package/src/rpc.ts +21 -0
- package/src/shared/rpc.ts +2 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|