dsh-vscode-mode 0.6.0 → 0.8.0

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.
Files changed (57) hide show
  1. package/README.md +40 -6
  2. package/assets/icon.svg +7 -0
  3. package/cordis.patch.yml +12 -0
  4. package/lib/client.js +9317 -7494
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1295 -173
  7. package/lib/index.js.map +1 -1
  8. package/locale/en.json +4 -0
  9. package/locale/zh.json +4 -0
  10. package/package.json +3 -1
  11. package/src/client/compat.ts +134 -4
  12. package/src/client/copyText.ts +28 -0
  13. package/src/client/fileClipboard.ts +46 -0
  14. package/src/client/imagePreview.ts +76 -2
  15. package/src/client/index.ts +68 -39
  16. package/src/client/monaco/lsp/lspClient.ts +46 -0
  17. package/src/client/monaco/lsp/providers.ts +245 -1
  18. package/src/client/nativeOpenStore.ts +91 -0
  19. package/src/client/officialSidebar.ts +11 -45
  20. package/src/client/openWithDialog.ts +66 -0
  21. package/src/client/outline/index.ts +1 -1
  22. package/src/client/pdf/pdfPanel.ts +18 -0
  23. package/src/client/searchSeed.ts +36 -0
  24. package/src/client/settingsContext.ts +6 -4
  25. package/src/client/sidebar/contextMenu.ts +51 -10
  26. package/src/client/sidebar/menuItems.ts +473 -50
  27. package/src/client/sidebar/panels/FileExplorer.ts +1 -1
  28. package/src/client/sidebar/panels/SearchPanel.ts +24 -3
  29. package/src/client/sidebar/panels/SvnPanel.ts +1 -1
  30. package/src/client/sidebar/panels/index.ts +1 -1
  31. package/src/client/sidebar/types.ts +6 -0
  32. package/src/client/styles/editor.css +5 -3
  33. package/src/client/svnStore.ts +6 -1
  34. package/src/client/tabActions.ts +22 -10
  35. package/src/client/ui/EditorView.ts +198 -25
  36. package/src/client/ui/McpSettings.ts +30 -0
  37. package/src/client/ui/OfficialSideTab.ts +1 -0
  38. package/src/client/ui/PerfSettings.ts +4 -1
  39. package/src/client/ui/PromptDialog.ts +63 -0
  40. package/src/client/ui/SideEditorTab.ts +1 -0
  41. package/src/client/ui/commandCatalog.ts +14 -0
  42. package/src/client/ui/icons.ts +72 -0
  43. package/src/compat.ts +2 -2
  44. package/src/dshVersion.ts +2 -1
  45. package/src/fileOpenSettings.ts +163 -40
  46. package/src/index.ts +15 -1
  47. package/src/lsp/client.ts +24 -0
  48. package/src/lsp/rpc.ts +57 -0
  49. package/src/lsp/server.ts +272 -2
  50. package/src/perf.ts +90 -4
  51. package/src/routes.ts +13 -1
  52. package/src/rpc.ts +294 -36
  53. package/src/shared/fsNames.ts +115 -0
  54. package/src/shared/keybindings.ts +2 -0
  55. package/src/shared/lsp.ts +79 -0
  56. package/src/shared/nativeOpen.ts +91 -0
  57. package/src/shared/rpc.ts +158 -1
@@ -18,14 +18,17 @@ export interface TreeMenuTarget {
18
18
  /** 一条右键菜单项定义。 */
19
19
  export interface TreeMenuItem {
20
20
  id: string
21
- label: string
21
+ /** 展示文案;目标相关时可传函数(buildTreeMenu 解析为字符串)。 */
22
+ label: string | ((target: TreeMenuTarget, ctx: SidebarCtx) => string)
22
23
  /** 排序:数值越小越靠前(缺省 100)。 */
23
24
  order?: number
25
+ /** 分组:同组条目相邻成块,组切换处由 buildTreeMenu 动态置前置分隔线(缺省不参与分组)。 */
26
+ group?: number
24
27
  /** 红色警示样式(如删除类操作)。 */
25
28
  danger?: boolean
26
- /** 置灰不可点(如无权限/目标不支持时)。 */
27
- disabled?: boolean
28
- /** 前置分隔线。 */
29
+ /** 置灰不可点(如无权限/目标不支持时):布尔或按目标动态求值。 */
30
+ disabled?: boolean | ((target: TreeMenuTarget, ctx: SidebarCtx) => boolean)
31
+ /** 前置分隔线(不带 group 的存量/第三方条目沿用静态语义)。 */
29
32
  separator?: boolean
30
33
  /** 显隐守卫:返回 false 则不显示。 */
31
34
  visible?: (target: TreeMenuTarget, ctx: SidebarCtx) => boolean
@@ -81,19 +84,57 @@ export function createTreeMenuRegistry(): TreeMenuRegistry {
81
84
  }
82
85
 
83
86
  /**
84
- * 按目标构建当前可见菜单项(visible 过滤 + order 排序)。
85
- * @author ddj 2026年08月27号
87
+ * 按目标解析动态字段并计算前置分隔线(纯函数)。
88
+ *
89
+ * 分隔线口径:
90
+ * - 带 group 的条目:与前一条可见条目组号不同即出线(首条除外)——组首条被 visible
91
+ * 隐藏时下一条自动顶上分组头,不丢线也不重复出线;
92
+ * - 不带 group 的存量/第三方条目:沿用其静态 separator;
93
+ * - 统一收尾:相邻分隔线合并为一条(动态组头与静态分隔线相邻时不叠双线)。
94
+ * @author ddj 2026年09月22号
95
+ * @param items 可见条目(已按 visible 过滤、按 order 排序)
96
+ * @param target 右键目标(动态 label/disabled 求值用)
97
+ * @param ctx 面板共享上下文
98
+ * @returns 解析后的条目(label/disabled 已定值、separator 已定值)
99
+ */
100
+ export function markMenuSeparators(
101
+ items: readonly TreeMenuItem[],
102
+ target: TreeMenuTarget,
103
+ ctx: SidebarCtx,
104
+ ): TreeMenuItem[] {
105
+ const out: TreeMenuItem[] = []
106
+ let prevGroup: number | null = null
107
+ let sepEmitted = false
108
+ for (const item of items) {
109
+ const grouped = typeof item.group === 'number'
110
+ let sep = grouped ? (out.length > 0 && item.group !== prevGroup) : (item.separator === true && out.length > 0)
111
+ if (grouped) prevGroup = item.group as number
112
+ if (sep && sepEmitted) sep = false
113
+ sepEmitted = sep
114
+ out.push({
115
+ ...item,
116
+ label: typeof item.label === 'function' ? item.label(target, ctx) : item.label,
117
+ disabled: typeof item.disabled === 'function' ? item.disabled(target, ctx) === true : item.disabled === true,
118
+ separator: sep,
119
+ })
120
+ }
121
+ return out
122
+ }
123
+
124
+ /**
125
+ * 按目标构建当前可见菜单项(visible 过滤 + order 排序 + 动态字段解析 + 分组分隔线)。
126
+ * @author ddj 2026年08月27号 / 2026年09月22号
86
127
  * @param registry 菜单项注册表
87
128
  * @param target 右键目标
88
129
  * @param ctx 面板共享上下文
89
- * @returns 可见菜单项(已排序)
130
+ * @returns 可见菜单项(已排序、已解析)
90
131
  */
91
132
  export function buildTreeMenu(registry: TreeMenuRegistry | undefined, target: TreeMenuTarget, ctx: SidebarCtx): TreeMenuItem[] {
92
133
  if (!registry) return []
93
- const out: TreeMenuItem[] = []
134
+ const visible: TreeMenuItem[] = []
94
135
  for (const item of registry.list()) {
95
136
  if (typeof item.visible === 'function' && !item.visible(target, ctx)) continue
96
- out.push(item)
137
+ visible.push(item)
97
138
  }
98
- return out
139
+ return markMenuSeparators(visible, target, ctx)
99
140
  }
@@ -1,23 +1,460 @@
1
1
  /**
2
2
  * dsh-vscode-mode client — 文件管理右键菜单内置项。
3
- * 内置项:「在文件浏览器中打开」(文件→OS Explorer 定位选中、目录→打开目录);
4
- * 「添加引用到对话」(文件/文件夹引用注入当前会话对话输入框);
5
- * 「SVN 组」——动作清单与显隐规则来自 shared/svnActions.ts(三入口共用一份,不再各写一遍)。
3
+ *
4
+ * 分组与显隐对齐 CodeBuddy 资源管理器参考图(区分文件/文件夹/根空白区):
5
+ * - 文件:打开方式… | 浏览器打开 | 对话 | 剪切·复制 | 复制路径 | 重命名·删除 | SVN 组
6
+ * - 文件夹:新建两项 | 浏览器打开 | 对话·在文件夹中查找 | 剪切·复制·粘贴 | 复制路径 | 重命名·永久删除 | SVN 组
7
+ * - 根空白区(path === ''):文件夹菜单去掉全部「仅非根」项
8
+ * 参考图中本架构无法实现的条目(在集成终端中打开 / 运行测试 / 调试测试 / Build 等)不显示。
6
9
  * 反馈统一走 ctx.notify(由 EditorView 提供,落到编辑区路径栏状态)。
7
- * 作者 ddj 2026-08-27 / 2026-09-03 / 2026-09-16
10
+ * 作者 ddj 2026-08-27 / 2026-09-03 / 2026-09-16 / 2026年09月22号
8
11
  */
9
12
  import { isSvnDiffable } from '../../shared/svn.js'
10
13
  import type { SvnChangeEntry } from '../../shared/svn.js'
11
14
  import { svnActionOn, svnActionsFor } from '../../shared/svnActions.js'
12
15
  import type { SvnActionContext, SvnTargetType } from '../../shared/svnActions.js'
16
+ import { baseNameOf, checkNewName, checkRenameName, joinRelPath } from '../../shared/fsNames.js'
17
+ import type { RpcMethod, RpcRequestMap, RpcResult } from '../../shared/rpc.js'
13
18
  import { runSvnAction } from '../svnActions.js'
14
19
  import type { SvnActionRunCtx } from '../svnActions.js'
15
20
  import { revealInExplorer } from '../fileReveal.js'
16
21
  import { statusOfAdd } from '../addToConversation.js'
22
+ import { rpc } from '../rpc.js'
23
+ import { absoluteOf, relativeOf } from '../tabActions.js'
24
+ import { copyText } from '../copyText.js'
25
+ import { clearFileClip, fileClipOf, setFileClip } from '../fileClipboard.js'
17
26
  import type { TreeMenuItem } from './contextMenu.js'
18
27
  import type { SidebarCtx } from './types.js'
19
28
  import type { TreeMenuTarget } from './contextMenu.js'
20
29
 
30
+ /** 内置条目 order 分段(每段留空隙供后续插入;SVN 组统一 200+,不与内置段交叠)。 */
31
+ const ORDER_OPEN = 5
32
+ const ORDER_NEW_FILE = 10
33
+ const ORDER_NEW_FOLDER = 20
34
+ const ORDER_REVEAL = 30
35
+ const ORDER_ADD_REF = 50
36
+ const ORDER_FIND = 60
37
+ const ORDER_CUT = 70
38
+ const ORDER_COPY = 80
39
+ const ORDER_PASTE = 90
40
+ const ORDER_COPY_PATH = 110
41
+ const ORDER_COPY_REL = 120
42
+ const ORDER_RENAME = 140
43
+ const ORDER_DELETE = 150
44
+ const SVN_ORDER_BASE = 200
45
+
46
+ /** 分组号(组切换处由 buildTreeMenu 出分隔线;SVN 组起始 7,组内分段随共享元数据递增)。 */
47
+ const GROUP_OPEN = 1
48
+ const GROUP_REVEAL = 2
49
+ const GROUP_REF = 3
50
+ const GROUP_CLIP = 4
51
+ const GROUP_COPY_PATH = 5
52
+ const GROUP_EDIT = 6
53
+ const SVN_GROUP_BASE = 7
54
+
55
+ // --region 目标判定与动作收尾
56
+
57
+ /**
58
+ * 目标是否有具体路径(非根空白区;复制路径/剪切/重命名/删除等「仅非根」守卫)。
59
+ * @author ddj 2026年09月22号
60
+ * @param target 右键目标
61
+ * @returns 是否非根
62
+ */
63
+ function hasPath(target: TreeMenuTarget): boolean {
64
+ return Boolean(target.path)
65
+ }
66
+
67
+ /**
68
+ * 目标是否目录(含根空白区)。
69
+ * @author ddj 2026年09月22号
70
+ * @param target 右键目标
71
+ * @returns 是否目录
72
+ */
73
+ function isDirTarget(target: TreeMenuTarget): boolean {
74
+ return target.type === 'directory'
75
+ }
76
+
77
+ /**
78
+ * 目标是否文件(树条目的非目录类型按文件处理)。
79
+ * @author ddj 2026年09月22号
80
+ * @param target 右键目标
81
+ * @returns 是否文件
82
+ */
83
+ function isFileTarget(target: TreeMenuTarget): boolean {
84
+ return target.type !== 'directory'
85
+ }
86
+
87
+ /**
88
+ * 是否有可用工作区目录(复制相对路径需要)。
89
+ * @author ddj 2026年09月22号
90
+ * @param ctx 面板上下文
91
+ * @returns 是否可用
92
+ */
93
+ function hasCwd(ctx: SidebarCtx): boolean {
94
+ return typeof ctx.cwd === 'string' && ctx.cwd.trim() !== ''
95
+ }
96
+
97
+ /**
98
+ * 派发窗口事件(文件树刷新 / 页签路径改写 / 关签;node 测试环境安全 no-op)。
99
+ * @author ddj 2026年09月22号
100
+ * @param name 事件名
101
+ * @param detail 事件载荷
102
+ */
103
+ function emit(name: string, detail: Record<string, unknown>): void {
104
+ if (typeof window === 'undefined') return
105
+ window.dispatchEvent(new CustomEvent(name, { detail }))
106
+ }
107
+
108
+ /**
109
+ * 文件操作 RPC 兜底执行:网络异常折算为失败结果,菜单动作统一按 ok/error 分支。
110
+ * @author ddj 2026年09月22号
111
+ * @param method RPC 方法名(edrv.fs.*)
112
+ * @param args 请求载荷
113
+ * @returns RPC 结果;异常时为失败结果
114
+ */
115
+ async function fsCall<M extends RpcMethod>(method: M, args: RpcRequestMap[M]): Promise<RpcResult<M>> {
116
+ try {
117
+ return await rpc(method, args)
118
+ } catch (error) {
119
+ return { ok: false, error: String(error) }
120
+ }
121
+ }
122
+
123
+ /**
124
+ * 文件操作成功收尾:反馈 + 目录树刷新 + SVN 变更重查。
125
+ * @author ddj 2026年09月22号
126
+ * @param ctx 面板上下文
127
+ * @param okText 成功反馈文案
128
+ */
129
+ function fsDone(ctx: SidebarCtx, okText: string): void {
130
+ ctx.notify?.(okText)
131
+ emit('edrv:refresh', {})
132
+ ctx.refreshSvnChanges?.()
133
+ }
134
+
135
+ /**
136
+ * 破坏性动作确认(ctx.confirm 缺省回退 window.confirm;无确认通道一律拒绝)。
137
+ * @author ddj 2026年09月22号
138
+ * @param ctx 面板上下文
139
+ * @param message 确认文案
140
+ * @returns 是否确认执行
141
+ */
142
+ function confirmed(ctx: SidebarCtx, message: string): boolean {
143
+ if (typeof ctx.confirm === 'function') return ctx.confirm(message)
144
+ if (typeof window !== 'undefined' && typeof window.confirm === 'function') return window.confirm(message)
145
+ return false
146
+ }
147
+
148
+ // --endregion
149
+
150
+ // --region 文件管理动作(新建 / 重命名 / 删除 / 粘贴)
151
+
152
+ /**
153
+ * 新建文件/文件夹:名称弹窗 → shared 校验 → RPC(同名拒绝)→ 文件即打开 + 刷新。
154
+ * @author ddj 2026年09月22号
155
+ * @param target 右键目标(目录)
156
+ * @param ctx 面板上下文
157
+ * @param kind file = 新建文件;directory = 新建文件夹
158
+ */
159
+ async function createEntry(target: TreeMenuTarget, ctx: SidebarCtx, kind: 'file' | 'directory'): Promise<void> {
160
+ const ask = ctx.prompt
161
+ if (!ask) {
162
+ ctx.notify?.('名称输入不可用')
163
+ return
164
+ }
165
+ const name = await ask(kind === 'file' ? '新建文件' : '新建文件夹', '')
166
+ if (name === null) return
167
+ const bad = checkNewName(name)
168
+ if (bad) {
169
+ ctx.notify?.(bad)
170
+ return
171
+ }
172
+ const path = joinRelPath(target.path, name)
173
+ const res = await fsCall(kind === 'file' ? 'edrv.fsCreateFile' : 'edrv.fsCreateDir', { sessionId: ctx.sessionId, path })
174
+ if (!res.ok) {
175
+ ctx.notify?.((kind === 'file' ? '新建文件失败:' : '新建文件夹失败:') + res.error)
176
+ return
177
+ }
178
+ if (kind === 'file') ctx.openFile(path)
179
+ fsDone(ctx, (kind === 'file' ? '已新建文件 ' : '已新建文件夹 ') + path)
180
+ }
181
+
182
+ /**
183
+ * 重命名:名称弹窗(预填原名)→ shared 校验 → RPC → 页签路径改写事件 + 刷新。
184
+ * @author ddj 2026年09月22号
185
+ * @param target 右键目标
186
+ * @param ctx 面板上下文
187
+ */
188
+ async function renameEntry(target: TreeMenuTarget, ctx: SidebarCtx): Promise<void> {
189
+ const ask = ctx.prompt
190
+ if (!ask) {
191
+ ctx.notify?.('名称输入不可用')
192
+ return
193
+ }
194
+ const from = target.path
195
+ const name = await ask('重命名', baseNameOf(from))
196
+ if (name === null || name === baseNameOf(from)) return
197
+ const bad = checkRenameName(name)
198
+ if (bad) {
199
+ ctx.notify?.(bad)
200
+ return
201
+ }
202
+ const res = await fsCall('edrv.fsRename', { sessionId: ctx.sessionId, path: from, newName: name })
203
+ if (!res.ok) {
204
+ ctx.notify?.('重命名失败:' + res.error)
205
+ return
206
+ }
207
+ if (res.to !== res.from) emit('edrv:path-renamed', { from: res.from, to: res.to })
208
+ fsDone(ctx, '已重命名')
209
+ }
210
+
211
+ /**
212
+ * 删除(文件文案「删除」/ 文件夹文案「永久删除」):确认框把关 → RPC → 关签事件 + 刷新。
213
+ * @author ddj 2026年09月22号
214
+ * @param target 右键目标
215
+ * @param ctx 面板上下文
216
+ */
217
+ async function deleteEntry(target: TreeMenuTarget, ctx: SidebarCtx): Promise<void> {
218
+ const dirTarget = isDirTarget(target)
219
+ const name = baseNameOf(target.path)
220
+ const message = dirTarget
221
+ ? '永久删除文件夹「' + name + '」及其全部内容?该操作不可恢复。'
222
+ : '删除文件「' + name + '」?该操作不可恢复。'
223
+ if (!confirmed(ctx, message)) return
224
+ const res = await fsCall('edrv.fsDelete', { sessionId: ctx.sessionId, path: target.path })
225
+ if (!res.ok) {
226
+ ctx.notify?.('删除失败:' + res.error)
227
+ return
228
+ }
229
+ emit('edrv:path-deleted', { path: target.path })
230
+ fsDone(ctx, (dirTarget ? '已永久删除「' : '已删除「') + name + '」')
231
+ }
232
+
233
+ /**
234
+ * 粘贴文件剪贴板内容到目标目录(copy → fsCopy;cut → fsMove 成功后清槽)。
235
+ * @author ddj 2026年09月22号
236
+ * @param target 右键目标(目录)
237
+ * @param ctx 面板上下文
238
+ */
239
+ async function pasteInto(target: TreeMenuTarget, ctx: SidebarCtx): Promise<void> {
240
+ const clip = fileClipOf()
241
+ if (!clip) {
242
+ ctx.notify?.('剪贴板为空')
243
+ return
244
+ }
245
+ const cut = clip.mode === 'cut'
246
+ const res = await fsCall(cut ? 'edrv.fsMove' : 'edrv.fsCopy', { sessionId: ctx.sessionId, from: clip.path, toDir: target.path })
247
+ if (!res.ok) {
248
+ ctx.notify?.('粘贴失败:' + res.error)
249
+ return
250
+ }
251
+ if (cut) {
252
+ clearFileClip()
253
+ emit('edrv:path-renamed', { from: res.from, to: res.to })
254
+ }
255
+ fsDone(ctx, (cut ? '已移动到 ' : '已复制到 ') + (target.path || '工作区根目录'))
256
+ }
257
+
258
+ // --endregion
259
+
260
+ // --region 内置菜单项分组
261
+
262
+ /**
263
+ * 分组 1:打开方式…(文件专属)+ 新建文件…/新建文件夹(文件夹专属,含根空白区)。
264
+ * @author ddj 2026年09月22号
265
+ * @returns 分组条目
266
+ */
267
+ function openGroupItems(): TreeMenuItem[] {
268
+ return [
269
+ {
270
+ id: 'open-with',
271
+ label: '打开方式…',
272
+ order: ORDER_OPEN,
273
+ group: GROUP_OPEN,
274
+ visible: (target, ctx) => isFileTarget(target) && hasPath(target) && typeof ctx.openWith === 'function',
275
+ run: (target, ctx) => ctx.openWith?.(target.path),
276
+ },
277
+ {
278
+ id: 'new-file',
279
+ label: '新建文件…',
280
+ order: ORDER_NEW_FILE,
281
+ group: GROUP_OPEN,
282
+ visible: (target) => isDirTarget(target),
283
+ run: (target, ctx) => { void createEntry(target, ctx, 'file') },
284
+ },
285
+ {
286
+ id: 'new-folder',
287
+ label: '新建文件夹',
288
+ order: ORDER_NEW_FOLDER,
289
+ group: GROUP_OPEN,
290
+ visible: (target) => isDirTarget(target),
291
+ run: (target, ctx) => { void createEntry(target, ctx, 'directory') },
292
+ },
293
+ ]
294
+ }
295
+
296
+ /**
297
+ * 分组 2:在文件资源管理器中显示(文件/文件夹/根空白区共有)。
298
+ * @author ddj 2026年09月22号
299
+ * @returns 分组条目
300
+ */
301
+ function revealGroupItems(): TreeMenuItem[] {
302
+ return [
303
+ {
304
+ id: 'reveal-in-explorer',
305
+ label: '在文件资源管理器中显示',
306
+ order: ORDER_REVEAL,
307
+ group: GROUP_REVEAL,
308
+ run: (target, ctx) => {
309
+ void revealInExplorer(ctx.sessionId, target.path).then((outcome) => {
310
+ ctx.notify?.(outcome.ok ? '已在文件浏览器中打开' : '打开失败:' + (outcome.error ?? '未知错误'))
311
+ })
312
+ },
313
+ },
314
+ ]
315
+ }
316
+
317
+ /**
318
+ * 分组 3:添加引用到对话(仅非根)+ 在文件夹中查找…(文件夹专属)。
319
+ * @author ddj 2026年09月22号
320
+ * @returns 分组条目
321
+ */
322
+ function refGroupItems(): TreeMenuItem[] {
323
+ return [
324
+ {
325
+ id: 'add-to-conversation',
326
+ label: '添加引用到对话',
327
+ order: ORDER_ADD_REF,
328
+ group: GROUP_REF,
329
+ // 排除根目录空白区(path==='' 无意义)、无会话、动作集缺失时隐藏
330
+ visible: (target, ctx) => Boolean(target.path && ctx.sessionId && ctx.addToConversation),
331
+ run: (target, ctx) => {
332
+ const isDir = target.type === 'directory'
333
+ const add = ctx.addToConversation
334
+ if (!add) {
335
+ ctx.notify?.('添加到对话不可用')
336
+ return
337
+ }
338
+ const okText = isDir ? '已添加文件夹引用' : '已添加文件引用'
339
+ void add.appendReference(ctx.sessionId, target.path, undefined, isDir ? 'folder' : 'file').then((outcome) => {
340
+ ctx.notify?.(statusOfAdd(outcome, okText))
341
+ })
342
+ },
343
+ },
344
+ {
345
+ id: 'find-in-folder',
346
+ label: '在文件夹中查找…',
347
+ order: ORDER_FIND,
348
+ group: GROUP_REF,
349
+ visible: (target, ctx) => isDirTarget(target) && typeof ctx.searchInFolder === 'function',
350
+ run: (target, ctx) => ctx.searchInFolder?.(target.path),
351
+ },
352
+ ]
353
+ }
354
+
355
+ /**
356
+ * 分组 4:剪切/复制(仅非根)+ 粘贴(文件夹专属,剪贴板空时灰显)。
357
+ * @author ddj 2026年09月22号
358
+ * @returns 分组条目
359
+ */
360
+ function clipGroupItems(): TreeMenuItem[] {
361
+ return [
362
+ {
363
+ id: 'cut',
364
+ label: '剪切',
365
+ order: ORDER_CUT,
366
+ group: GROUP_CLIP,
367
+ visible: hasPath,
368
+ run: (target, ctx) => {
369
+ setFileClip('cut', target.path)
370
+ ctx.notify?.('已剪切「' + baseNameOf(target.path) + '」')
371
+ },
372
+ },
373
+ {
374
+ id: 'copy',
375
+ label: '复制',
376
+ order: ORDER_COPY,
377
+ group: GROUP_CLIP,
378
+ visible: hasPath,
379
+ run: (target, ctx) => {
380
+ setFileClip('copy', target.path)
381
+ ctx.notify?.('已复制「' + baseNameOf(target.path) + '」')
382
+ },
383
+ },
384
+ {
385
+ id: 'paste',
386
+ label: '粘贴',
387
+ order: ORDER_PASTE,
388
+ group: GROUP_CLIP,
389
+ visible: isDirTarget,
390
+ disabled: () => fileClipOf() === null,
391
+ run: (target, ctx) => { void pasteInto(target, ctx) },
392
+ },
393
+ ]
394
+ }
395
+
396
+ /**
397
+ * 分组 5:复制路径 / 复制相对路径(仅非根;相对路径还需 cwd)。
398
+ * @author ddj 2026年09月22号
399
+ * @returns 分组条目
400
+ */
401
+ function copyPathItems(): TreeMenuItem[] {
402
+ return [
403
+ {
404
+ id: 'copy-path',
405
+ label: '复制路径',
406
+ order: ORDER_COPY_PATH,
407
+ group: GROUP_COPY_PATH,
408
+ visible: hasPath,
409
+ run: (target, ctx) => {
410
+ void copyText(absoluteOf(target.path, ctx.cwd), '已复制路径', (message) => ctx.notify?.(message))
411
+ },
412
+ },
413
+ {
414
+ id: 'copy-relative-path',
415
+ label: '复制相对路径',
416
+ order: ORDER_COPY_REL,
417
+ group: GROUP_COPY_PATH,
418
+ visible: (target, ctx) => hasPath(target) && hasCwd(ctx),
419
+ run: (target, ctx) => {
420
+ void copyText(relativeOf(target.path, ctx.cwd), '已复制相对路径', (message) => ctx.notify?.(message))
421
+ },
422
+ },
423
+ ]
424
+ }
425
+
426
+ /**
427
+ * 分组 6:重命名…(仅非根)+ 删除/永久删除(仅非根;danger,文件与文件夹文案不同)。
428
+ * @author ddj 2026年09月22号
429
+ * @returns 分组条目
430
+ */
431
+ function editGroupItems(): TreeMenuItem[] {
432
+ return [
433
+ {
434
+ id: 'rename',
435
+ label: '重命名…',
436
+ order: ORDER_RENAME,
437
+ group: GROUP_EDIT,
438
+ visible: hasPath,
439
+ run: (target, ctx) => { void renameEntry(target, ctx) },
440
+ },
441
+ {
442
+ id: 'delete',
443
+ // 文案对齐参考图:文件 = 「删除」,文件夹 = 「永久删除」(行为均为确认后的磁盘删除)
444
+ label: (target) => (isDirTarget(target) ? '永久删除' : '删除'),
445
+ order: ORDER_DELETE,
446
+ group: GROUP_EDIT,
447
+ danger: true,
448
+ visible: hasPath,
449
+ run: (target, ctx) => { void deleteEntry(target, ctx) },
450
+ },
451
+ ]
452
+ }
453
+
454
+ // --endregion
455
+
456
+ // --region SVN 组(动作清单来自 shared/svnActions.ts)
457
+
21
458
  /** 目标路径对应的变更条目(不在清单里返回 undefined)。 */
22
459
  function svnEntryOf(target: TreeMenuTarget, ctx: SidebarCtx): SvnChangeEntry | undefined {
23
460
  if (!target.path) return undefined
@@ -79,62 +516,48 @@ function treeRunCtx(target: TreeMenuTarget, ctx: SidebarCtx): SvnActionRunCtx {
79
516
  *
80
517
  * 收敛收益:此前每个动作在这里手写一项、页签菜单再手写一遍、命令栏第三遍,
81
518
  * 显隐规则三处漂移(P2 的「加入/还原」状态矩阵就重复过两份)。现在只做映射。
82
- * @author ddj 2026年09月16号
519
+ * 共享元数据的 separator 意为「新分段」:映射为分组号 +1,分隔线由 buildTreeMenu 统一出。
520
+ * @author ddj 2026年09月16号 / 2026年09月22号
83
521
  * @returns SVN 组条目
84
522
  */
85
523
  function svnMenuItems(): TreeMenuItem[] {
86
- return svnActionsFor('tree').map((action) => ({
87
- id: 'svn-' + action.id,
88
- label: action.label,
89
- order: action.order,
90
- danger: action.danger === true,
91
- separator: action.separator === true,
92
- visible: (target: TreeMenuTarget, ctx: SidebarCtx) => {
93
- const context = svnContextOf(target, ctx)
94
- return context !== null && svnActionOn(action, context)
95
- },
96
- run: (target: TreeMenuTarget, ctx: SidebarCtx) => {
97
- runSvnAction(action, treeRunCtx(target, ctx))
98
- },
99
- }))
524
+ const items: TreeMenuItem[] = []
525
+ let group = SVN_GROUP_BASE
526
+ for (const action of svnActionsFor('tree')) {
527
+ if (action.separator === true) group += 1
528
+ items.push({
529
+ id: 'svn-' + action.id,
530
+ label: action.label,
531
+ order: SVN_ORDER_BASE + action.order,
532
+ group,
533
+ danger: action.danger === true,
534
+ visible: (target: TreeMenuTarget, ctx: SidebarCtx) => {
535
+ const context = svnContextOf(target, ctx)
536
+ return context !== null && svnActionOn(action, context)
537
+ },
538
+ run: (target: TreeMenuTarget, ctx: SidebarCtx) => {
539
+ runSvnAction(action, treeRunCtx(target, ctx))
540
+ },
541
+ })
542
+ }
543
+ return items
100
544
  }
101
545
 
546
+ // --endregion
547
+
102
548
  /**
103
- * 构造内置右键菜单项列表(后续内置项直接追加)。
104
- * @author ddj 2026年08月27号
549
+ * 构造内置右键菜单项列表(后续内置项按分组追加)。
550
+ * @author ddj 2026年08月27号 / 2026年09月22号
105
551
  * @returns 内置菜单项数组
106
552
  */
107
553
  export function createDefaultFileMenuItems(): TreeMenuItem[] {
108
554
  return [
109
- {
110
- id: 'reveal-in-explorer',
111
- label: '在文件浏览器中打开',
112
- order: 0,
113
- run: (target, ctx) => {
114
- void revealInExplorer(ctx.sessionId, target.path).then((outcome) => {
115
- ctx.notify?.(outcome.ok ? '已在文件浏览器中打开' : '打开失败:' + (outcome.error ?? '未知错误'))
116
- })
117
- },
118
- },
119
- {
120
- id: 'add-to-conversation',
121
- label: '添加引用到对话',
122
- order: 1,
123
- // 排除根目录空白区(path==='' 无意义)、无会话、动作集缺失时隐藏
124
- visible: (target, ctx) => Boolean(target.path && ctx.sessionId && ctx.addToConversation),
125
- run: (target, ctx) => {
126
- const isDir = target.type === 'directory'
127
- const add = ctx.addToConversation
128
- if (!add) {
129
- ctx.notify?.('添加到对话不可用')
130
- return
131
- }
132
- const okText = isDir ? '已添加文件夹引用' : '已添加文件引用'
133
- void add.appendReference(ctx.sessionId, target.path, undefined, isDir ? 'folder' : 'file').then((outcome) => {
134
- ctx.notify?.(statusOfAdd(outcome, okText))
135
- })
136
- },
137
- },
555
+ ...openGroupItems(),
556
+ ...revealGroupItems(),
557
+ ...refGroupItems(),
558
+ ...clipGroupItems(),
559
+ ...copyPathItems(),
560
+ ...editGroupItems(),
138
561
  ...svnMenuItems(),
139
562
  ]
140
563
  }
@@ -10,7 +10,7 @@
10
10
  * 作者 ddj 2026-08-26 / 2026-08-27 / 2026-08-31 / 2026-09-10 / 2026-09-16
11
11
  */
12
12
  import React from 'react'
13
- import { FileTypeIcon, IconFolderClose16, IconFolderOpen16, IconRefreshOutline16, classifyFileType } from '@deepseek-ai/dsh-client-ui-primitives'
13
+ import { FileTypeIcon, IconFolderClose16, IconFolderOpen16, IconRefreshOutline16, classifyFileType } from '../../ui/icons.js'
14
14
  import { rpc } from '../../rpc.js'
15
15
  import { ContextMenu } from '../../ui/ContextMenu.js'
16
16
  import { buildTreeMenu } from '../contextMenu.js'