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.
- package/README.md +19 -2
- package/lib/client.js +8302 -7458
- package/lib/client.js.map +1 -1
- package/lib/index.js +373 -0
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/copyText.ts +28 -0
- package/src/client/fileClipboard.ts +46 -0
- package/src/client/index.ts +3 -3
- package/src/client/openWithDialog.ts +66 -0
- package/src/client/searchSeed.ts +36 -0
- package/src/client/sidebar/contextMenu.ts +51 -10
- package/src/client/sidebar/menuItems.ts +473 -50
- package/src/client/sidebar/panels/SearchPanel.ts +24 -3
- package/src/client/sidebar/types.ts +6 -0
- package/src/client/tabActions.ts +22 -10
- package/src/client/ui/EditorView.ts +81 -7
- package/src/client/ui/OfficialSideTab.ts +1 -0
- package/src/client/ui/PromptDialog.ts +63 -0
- package/src/client/ui/SideEditorTab.ts +1 -0
- package/src/client/ui/commandCatalog.ts +14 -0
- package/src/rpc.ts +198 -2
- package/src/shared/fsNames.ts +115 -0
- package/src/shared/keybindings.ts +2 -0
- package/src/shared/rpc.ts +12 -0
|
@@ -1,23 +1,460 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-vscode-mode client — 文件管理右键菜单内置项。
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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
|
/** 单个侧边栏面板定义。 */
|
package/src/client/tabActions.ts
CHANGED
|
@@ -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
|
-
|
|
397
|
-
|
|
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
|