dsh-vscode-mode 0.6.0 → 0.7.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.
@@ -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
  }
@@ -12,7 +12,7 @@ import { rpc } from '../../rpc.js'
12
12
  import type { SidebarCtx } from '../types.js'
13
13
  import { CACHE_KEY } from '../../paths.js'
14
14
  import { workspaceScopeOf } from '../../state/scopeStore.js'
15
- import { takeSearchSeed } from '../../searchSeed.js'
15
+ import { takeSearchSeed, takeSearchScope } from '../../searchSeed.js'
16
16
 
17
17
  const DEBOUNCE_MS = 250
18
18
  const INCLUDE_PLACEHOLDER = '例如 *.ts, src/**/include'
@@ -237,15 +237,36 @@ export function SearchPanel(props) {
237
237
  return true
238
238
  }
239
239
 
240
+ /**
241
+ * 取用一次性目录过滤种子(资源管理器右键「在文件夹中查找…」)。
242
+ * 有种子即覆盖「包含」过滤为该目录、关闭「仅当前文件」并展开过滤区(让用户看到限定范围);
243
+ * 已有可用搜索词时按新范围立即重搜。无种子为 no-op。
244
+ * @author ddj 2026年09月22号
245
+ * @returns 是否消费到种子
246
+ */
247
+ const applyScopeSeed = () => {
248
+ const dir = takeSearchScope()
249
+ if (!dir) return false
250
+ const glob = dir + '/**'
251
+ if (timerRef.current) clearTimeout(timerRef.current)
252
+ setIncludeText(glob)
253
+ setOnlyActive(false)
254
+ setSectionOpen(true)
255
+ requestRef.current = Object.assign({}, requestRef.current, { include: splitGlobs(glob) })
256
+ if (String(queryRef.current).trim().length >= 2) runSearch(queryRef.current)
257
+ return true
258
+ }
259
+
240
260
  // 挂载时消费种子:侧栏原本收起 → 派发 edrv:search-focus 时本面板尚未挂载,
241
261
  // 事件无人接收,故必须由挂载路径兜底。
242
262
  // ⚠️ 声明在「按作用域恢复」effect 之后:React 按声明序执行 effect,恢复值先落地,
243
263
  // 种子再覆盖,避免被记忆的旧查询词盖掉用户刚选中的内容。
244
- React.useEffect(() => { applySeed() }, [])
264
+ React.useEffect(() => { applyScopeSeed(); applySeed() }, [])
245
265
 
246
- // Ctrl+Shift+F 重复触发:消费种子(有则填入)并聚焦输入框(EditorView 派发 edrv:search-focus)
266
+ // Ctrl+Shift+F / 「在文件夹中查找…」重复触发:消费种子(有则填入)并聚焦输入框(EditorView 派发 edrv:search-focus)
247
267
  React.useEffect(() => {
248
268
  const onFocus = () => {
269
+ applyScopeSeed()
249
270
  applySeed()
250
271
  inputRef.current?.focus?.()
251
272
  }
@@ -51,6 +51,12 @@ export interface SidebarCtx {
51
51
  confirm?: (message: string) => boolean
52
52
  /** 面板动作反馈(如右键菜单操作结果 → 编辑区路径栏状态)。 */
53
53
  notify?: (message: string) => void
54
+ /** 名称输入弹窗(资源管理器右键的新建/重命名;取消/空输入返回 null,缺省时相关项降级提示)。 */
55
+ prompt?: (title: string, initial: string) => Promise<string | null>
56
+ /** 「打开方式…」:在已注册打开器间选择并打开(缺省时该项隐藏)。 */
57
+ openWith?: (path: string) => void
58
+ /** 「在文件夹中查找…」:限定目录并跳到搜索面板(缺省时该项隐藏)。 */
59
+ searchInFolder?: (dir: string) => void
54
60
  }
55
61
 
56
62
  /** 单个侧边栏面板定义。 */
@@ -374,6 +374,25 @@ function normalizeSlashes(path: string | null | undefined): string {
374
374
  return String(path ?? '').replace(/\\/g, '/')
375
375
  }
376
376
 
377
+ /**
378
+ * 路径改写(资源管理器右键重命名后页签/脏标同步用):
379
+ * 目标本身替换为新路径,其子树按前缀跟随改写,无关路径原样返回。
380
+ * @author ddj 2026年09月22号
381
+ * @param path 待改写路径
382
+ * @param from 原路径
383
+ * @param to 新路径
384
+ * @returns 改写后的路径
385
+ */
386
+ export function remapPathOf(path: string, from: string, to: string): string {
387
+ const text = normalizeSlashes(path)
388
+ const src = normalizeSlashes(from).replace(/\/+$/, '')
389
+ const dst = normalizeSlashes(to).replace(/\/+$/, '')
390
+ if (!src || !dst || src === dst) return text
391
+ if (text === src) return dst
392
+ if (text.startsWith(src + '/')) return dst + text.slice(src.length)
393
+ return text
394
+ }
395
+
377
396
  /**
378
397
  * 文件路径的祖先目录(由浅到深):`a/b/c.ts` → `['a', 'a/b']`。
379
398
  * 根级文件、绝对路径与含 `..` 的路径返回空数组。
@@ -392,14 +411,7 @@ export function ancestorDirsOf(path: string): string[] {
392
411
  return out
393
412
  }
394
413
 
395
- /**
396
- * 文件名(页签/状态栏展示与 tooltip 用)。
397
- * @author ddj 2026年09月11号
398
- * @param path 路径
399
- * @returns 末段文件名;空路径返回空串
400
- */
401
- export function baseNameOf(path: string): string {
402
- const text = normalizeSlashes(path)
403
- return text.split('/').filter(Boolean).pop() ?? ''
404
- }
414
+ // baseNameOf(取末段文件名)已收敛到 shared/fsNames.ts(host 与 client 共用同一份语义),
415
+ // 此处转出口保持既有引用不动。
416
+ export { baseNameOf } from '../shared/fsNames.js'
405
417
  // --endregion