dsh-vscode-mode 0.3.1 → 0.3.2
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 +11 -3
- package/lib/client.js +1062 -197
- package/lib/client.js.map +1 -1
- package/lib/index.js +2 -1
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/addToConversation.ts +192 -20
- package/src/client/editorModelState.ts +18 -0
- package/src/client/openFlow.ts +2 -1
- package/src/client/paths.ts +4 -2
- package/src/client/saveDebounce.ts +69 -0
- package/src/client/sidebar/panels/FileExplorer.ts +83 -2
- package/src/client/state/scopeStore.ts +2 -1
- package/src/client/styles/editor.css +15 -2
- package/src/client/tabActions.ts +326 -0
- package/src/client/tabMenu.ts +107 -0
- package/src/client/ui/ContextMenu.ts +52 -8
- package/src/client/ui/EditorView.ts +221 -57
- package/src/client/ui/commandCatalog.ts +17 -1
- package/src/shared/keybindings.ts +3 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode client — 文件页签操作纯函数(关闭族 / 固定 / 路径推导)。
|
|
3
|
+
* 页签状态是 EditorView 的 `{ path, pinned }[]`;本模块不触 React/DOM,可 node 单测,
|
|
4
|
+
* 菜单动作(tabMenu)与编辑器(EditorView)共用同一份语义,避免规则散落两处。
|
|
5
|
+
*
|
|
6
|
+
* 核心约定 —— **固定 = 保护**:
|
|
7
|
+
* 「关闭其他 / 关闭右侧 / 关闭已保存 / 全部关闭」一律不关固定页签;
|
|
8
|
+
* 只有单项「关闭」可以显式关掉一个固定页签(用户明确点了它)。
|
|
9
|
+
*
|
|
10
|
+
* 关闭后的活动页签:原活动页签未被关 → 不变;被关 → 右侧优先、否则左侧末位(VS Code 行为)。
|
|
11
|
+
* 作者 ddj 2026年09月11号
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** 页签的最小形状(EditorView 持有超集)。 */
|
|
15
|
+
export interface TabLike {
|
|
16
|
+
path: string
|
|
17
|
+
pinned?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 关闭操作结果(tabs 未变化时返回原引用,React 可跳过重渲染)。 */
|
|
21
|
+
export interface CloseResult {
|
|
22
|
+
tabs: TabLike[]
|
|
23
|
+
active: string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// --region 关闭族
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 关闭指定集合:活动页签被关时按「右侧优先、否则左侧末位」补位。
|
|
30
|
+
* @author ddj 2026年09月11号
|
|
31
|
+
* @param tabs 当前页签
|
|
32
|
+
* @param closing 待关闭路径集合
|
|
33
|
+
* @param active 当前活动页签路径
|
|
34
|
+
* @returns 剩余页签与新的活动页签
|
|
35
|
+
*/
|
|
36
|
+
export function applyClose(tabs: TabLike[], closing: Set<string>, active: string | null): CloseResult {
|
|
37
|
+
const remaining = tabs.filter((tab) => !closing.has(tab.path))
|
|
38
|
+
if (remaining.length === tabs.length) return { tabs, active }
|
|
39
|
+
if (active && !closing.has(active)) return { tabs: remaining, active }
|
|
40
|
+
return { tabs: remaining, active: pickNeighbor(tabs, remaining, active) }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 关闭其他:保留目标页签与全部固定页签。
|
|
45
|
+
* @author ddj 2026年09月11号
|
|
46
|
+
* @param tabs 当前页签
|
|
47
|
+
* @param target 保留的目标页签路径
|
|
48
|
+
* @param active 当前活动页签路径
|
|
49
|
+
* @returns 关闭结果
|
|
50
|
+
*/
|
|
51
|
+
export function closeOthers(tabs: TabLike[], target: string, active: string | null): CloseResult {
|
|
52
|
+
return applyClose(tabs, closingExcept(tabs, (tab) => tab.path === target || tab.pinned === true), active)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 关闭右侧:关闭目标之后、未被固定的页签。
|
|
57
|
+
* @author ddj 2026年09月11号
|
|
58
|
+
* @param tabs 当前页签
|
|
59
|
+
* @param target 基准页签路径(其右侧才关)
|
|
60
|
+
* @param active 当前活动页签路径
|
|
61
|
+
* @returns 关闭结果
|
|
62
|
+
*/
|
|
63
|
+
export function closeRight(tabs: TabLike[], target: string, active: string | null): CloseResult {
|
|
64
|
+
const at = tabs.findIndex((tab) => tab.path === target)
|
|
65
|
+
const closing = new Set<string>()
|
|
66
|
+
for (let i = at + 1; i > 0 && i < tabs.length; i += 1) {
|
|
67
|
+
if (tabs[i].pinned !== true) closing.add(tabs[i].path)
|
|
68
|
+
}
|
|
69
|
+
return applyClose(tabs, closing, active)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 关闭已保存:关闭未固定且无未保存修改的页签。
|
|
74
|
+
* @author ddj 2026年09月11号
|
|
75
|
+
* @param tabs 当前页签
|
|
76
|
+
* @param dirty 路径 → 是否有未保存修改
|
|
77
|
+
* @param active 当前活动页签路径
|
|
78
|
+
* @returns 关闭结果
|
|
79
|
+
*/
|
|
80
|
+
export function closeSaved(tabs: TabLike[], dirty: Record<string, boolean>, active: string | null): CloseResult {
|
|
81
|
+
return applyClose(tabs, closingExcept(tabs, (tab) => tab.pinned === true || dirty[tab.path] === true), active)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 全部关闭:仅关未固定页签(全部固定时无操作)。
|
|
86
|
+
* @author ddj 2026年09月11号
|
|
87
|
+
* @param tabs 当前页签
|
|
88
|
+
* @param active 当前活动页签路径
|
|
89
|
+
* @returns 关闭结果
|
|
90
|
+
*/
|
|
91
|
+
export function closeAll(tabs: TabLike[], active: string | null): CloseResult {
|
|
92
|
+
return applyClose(tabs, closingExcept(tabs, (tab) => tab.pinned === true), active)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 反选可关闭集合(保留 keep 命中的页签,其余进关闭集)。
|
|
97
|
+
* @author ddj 2026年09月11号
|
|
98
|
+
* @param tabs 当前页签
|
|
99
|
+
* @param keep 保留判定
|
|
100
|
+
* @returns 待关闭路径集合
|
|
101
|
+
*/
|
|
102
|
+
function closingExcept(tabs: TabLike[], keep: (tab: TabLike) => boolean): Set<string> {
|
|
103
|
+
const closing = new Set<string>()
|
|
104
|
+
for (const tab of tabs) if (!keep(tab)) closing.add(tab.path)
|
|
105
|
+
return closing
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 选补位页签:先向右找最近的存活页签,再向左找,都没有则取剩余首个。
|
|
110
|
+
* @author ddj 2026年09月11号
|
|
111
|
+
* @param tabs 关闭前的页签(用于取原索引)
|
|
112
|
+
* @param remaining 关闭后剩余的页签
|
|
113
|
+
* @param active 被关掉的活动页签路径
|
|
114
|
+
* @returns 补位页签路径;无剩余返回 null
|
|
115
|
+
*/
|
|
116
|
+
function pickNeighbor(tabs: TabLike[], remaining: TabLike[], active: string | null): string | null {
|
|
117
|
+
const first = remaining[0]
|
|
118
|
+
if (!first) return null
|
|
119
|
+
const at = tabs.findIndex((tab) => tab.path === active)
|
|
120
|
+
if (at < 0) return first.path
|
|
121
|
+
const alive = new Set(remaining.map((tab) => tab.path))
|
|
122
|
+
for (let i = at + 1; i < tabs.length; i += 1) if (alive.has(tabs[i].path)) return tabs[i].path
|
|
123
|
+
for (let i = at - 1; i >= 0; i -= 1) if (alive.has(tabs[i].path)) return tabs[i].path
|
|
124
|
+
return first.path
|
|
125
|
+
}
|
|
126
|
+
// --endregion
|
|
127
|
+
|
|
128
|
+
// --region 固定与插入
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 切换页签固定态:固定页签整体前移(两侧各自保持相对顺序)。
|
|
132
|
+
* @author ddj 2026年09月11号
|
|
133
|
+
* @param tabs 当前页签
|
|
134
|
+
* @param target 目标页签路径
|
|
135
|
+
* @returns 重排后的页签(目标不存在时原样返回)
|
|
136
|
+
*/
|
|
137
|
+
export function togglePin(tabs: TabLike[], target: string): TabLike[] {
|
|
138
|
+
if (!tabs.some((tab) => tab.path === target)) return tabs
|
|
139
|
+
const next = tabs.map((tab) => (
|
|
140
|
+
tab.path === target ? { path: tab.path, pinned: tab.pinned !== true } : tab
|
|
141
|
+
))
|
|
142
|
+
return pinnedFirst(next)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 新增页签:已存在则原样返回;否则插到最后一个固定页签之后。
|
|
147
|
+
* @author ddj 2026年09月11号
|
|
148
|
+
* @param tabs 当前页签
|
|
149
|
+
* @param path 新页签路径
|
|
150
|
+
* @returns 新页签数组
|
|
151
|
+
*/
|
|
152
|
+
export function insertTab(tabs: TabLike[], path: string): TabLike[] {
|
|
153
|
+
if (tabs.some((tab) => tab.path === path)) return tabs
|
|
154
|
+
// 缺省追加到末尾;存在固定页签时插到最后一个固定页签之后
|
|
155
|
+
let at = tabs.length
|
|
156
|
+
for (let i = tabs.length - 1; i >= 0; i -= 1) {
|
|
157
|
+
if (tabs[i].pinned !== true) continue
|
|
158
|
+
at = i + 1
|
|
159
|
+
break
|
|
160
|
+
}
|
|
161
|
+
return tabs.slice(0, at).concat([{ path }], tabs.slice(at))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 固定分区:固定页签在前,两侧各自保持原相对顺序。
|
|
166
|
+
* @author ddj 2026年09月11号
|
|
167
|
+
* @param tabs 页签
|
|
168
|
+
* @returns 重排后的页签
|
|
169
|
+
*/
|
|
170
|
+
function pinnedFirst(tabs: TabLike[]): TabLike[] {
|
|
171
|
+
return tabs.filter(isPinned).concat(tabs.filter((tab) => !isPinned(tab)))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 是否固定页签。
|
|
176
|
+
* @author ddj 2026年09月11号
|
|
177
|
+
* @param tab 页签
|
|
178
|
+
* @returns 是否固定
|
|
179
|
+
*/
|
|
180
|
+
function isPinned(tab: TabLike): boolean {
|
|
181
|
+
return tab.pinned === true
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 归一化持久化的页签数据:兼容旧版 `string[]`、去重、固定分区。
|
|
186
|
+
* 损坏项(非字符串/非对象/无 path)直接丢弃。
|
|
187
|
+
* @author ddj 2026年09月11号
|
|
188
|
+
* @param raw localStorage 解析结果(任意形状)
|
|
189
|
+
* @returns 归一化后的页签数组
|
|
190
|
+
*/
|
|
191
|
+
export function normalizeTabs(raw: unknown): TabLike[] {
|
|
192
|
+
if (!Array.isArray(raw)) return []
|
|
193
|
+
const seen = new Set<string>()
|
|
194
|
+
const out: TabLike[] = []
|
|
195
|
+
for (const item of raw) {
|
|
196
|
+
const tab = tabOf(item)
|
|
197
|
+
if (!tab || seen.has(tab.path)) continue
|
|
198
|
+
seen.add(tab.path)
|
|
199
|
+
out.push(tab)
|
|
200
|
+
}
|
|
201
|
+
return pinnedFirst(out)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 解释单条持久化页签:字符串 = 路径(旧版),对象取 path/pinned。
|
|
206
|
+
* @author ddj 2026年09月11号
|
|
207
|
+
* @param item 原始条目
|
|
208
|
+
* @returns 页签或 null(无法解释)
|
|
209
|
+
*/
|
|
210
|
+
function tabOf(item: unknown): TabLike | null {
|
|
211
|
+
if (typeof item === 'string') return item ? { path: item } : null
|
|
212
|
+
if (!item || typeof item !== 'object') return null
|
|
213
|
+
const raw = item as { path?: unknown; pinned?: unknown }
|
|
214
|
+
if (typeof raw.path !== 'string' || !raw.path) return null
|
|
215
|
+
return raw.pinned === true ? { path: raw.path, pinned: true } : { path: raw.path }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 选活动页签:恢复值仍存在则用它,否则取首个(无页签返回 null)。
|
|
220
|
+
* @author ddj 2026年09月11号
|
|
221
|
+
* @param tabs 页签
|
|
222
|
+
* @param wanted 持久化的活动路径
|
|
223
|
+
* @returns 活动页签路径
|
|
224
|
+
*/
|
|
225
|
+
export function pickActive(tabs: TabLike[], wanted: unknown): string | null {
|
|
226
|
+
const first = tabs[0]
|
|
227
|
+
if (!first) return null
|
|
228
|
+
if (typeof wanted === 'string' && tabs.some((tab) => tab.path === wanted)) return wanted
|
|
229
|
+
return first.path
|
|
230
|
+
}
|
|
231
|
+
// --endregion
|
|
232
|
+
|
|
233
|
+
// --region 路径推导
|
|
234
|
+
|
|
235
|
+
/** Windows 盘符 / UNC / POSIX 根:视为工作区外的绝对路径。 */
|
|
236
|
+
const ABSOLUTE_RE = /^(?:[a-z]:[\\/]|\\\\|\/)/i
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 是否绝对路径(工作区外)。
|
|
240
|
+
* @author ddj 2026年09月11号
|
|
241
|
+
* @param path 路径
|
|
242
|
+
* @returns 是否绝对路径
|
|
243
|
+
*/
|
|
244
|
+
export function isAbsolutePath(path: string): boolean {
|
|
245
|
+
return ABSOLUTE_RE.test(String(path ?? ''))
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 是否可在资源管理器视图中定位(工作区相对且不含 `..` 上跳段)。
|
|
250
|
+
* @author ddj 2026年09月11号
|
|
251
|
+
* @param path 路径
|
|
252
|
+
* @returns 是否可定位
|
|
253
|
+
*/
|
|
254
|
+
export function isTreeRevealable(path: string): boolean {
|
|
255
|
+
const text = String(path ?? '').trim()
|
|
256
|
+
if (!text || isAbsolutePath(text)) return false
|
|
257
|
+
return !text.replace(/\\/g, '/').split('/').includes('..')
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 相对路径:cwd 内路径去掉工作区前缀;工作区外/无 cwd 回退规范化原路径。
|
|
262
|
+
* @author ddj 2026年09月11号
|
|
263
|
+
* @param path 路径
|
|
264
|
+
* @param cwd 会话工作区目录(可空)
|
|
265
|
+
* @returns 展示/复制用的相对路径
|
|
266
|
+
*/
|
|
267
|
+
export function relativeOf(path: string, cwd?: string | null): string {
|
|
268
|
+
const target = normalizeSlashes(path)
|
|
269
|
+
const base = normalizeSlashes(cwd).replace(/\/+$/, '')
|
|
270
|
+
if (base && target.toLowerCase().startsWith((base + '/').toLowerCase())) return target.slice(base.length + 1)
|
|
271
|
+
return target
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 绝对路径:相对路径按 cwd 拼接;已是绝对路径或没有 cwd 时回退规范化原路径。
|
|
276
|
+
* @author ddj 2026年09月11号
|
|
277
|
+
* @param path 路径
|
|
278
|
+
* @param cwd 会话工作区目录(可空)
|
|
279
|
+
* @returns 展示/复制用的绝对路径
|
|
280
|
+
*/
|
|
281
|
+
export function absoluteOf(path: string, cwd?: string | null): string {
|
|
282
|
+
const target = normalizeSlashes(path)
|
|
283
|
+
if (isAbsolutePath(target)) return target
|
|
284
|
+
const base = normalizeSlashes(cwd).replace(/\/+$/, '')
|
|
285
|
+
return base ? base + '/' + target : target
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* 反斜杠归一为斜杠(页签/引用/剪贴板统一用正斜杠,与既有 mentionOf 口径一致)。
|
|
290
|
+
* @author ddj 2026年09月11号
|
|
291
|
+
* @param path 路径
|
|
292
|
+
* @returns 正斜杠路径
|
|
293
|
+
*/
|
|
294
|
+
function normalizeSlashes(path: string | null | undefined): string {
|
|
295
|
+
return String(path ?? '').replace(/\\/g, '/')
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 文件路径的祖先目录(由浅到深):`a/b/c.ts` → `['a', 'a/b']`。
|
|
300
|
+
* 根级文件、绝对路径与含 `..` 的路径返回空数组。
|
|
301
|
+
* @author ddj 2026年09月11号
|
|
302
|
+
* @param path 工作区相对路径
|
|
303
|
+
* @returns 祖先目录相对路径数组
|
|
304
|
+
*/
|
|
305
|
+
export function ancestorDirsOf(path: string): string[] {
|
|
306
|
+
if (!isTreeRevealable(path)) return []
|
|
307
|
+
const segs = normalizeSlashes(path).split('/').filter(Boolean)
|
|
308
|
+
const out: string[] = []
|
|
309
|
+
for (let i = 1; i < segs.length; i += 1) {
|
|
310
|
+
const prev = segs[i - 1]
|
|
311
|
+
out.push(out.length ? out[out.length - 1] + '/' + prev : prev)
|
|
312
|
+
}
|
|
313
|
+
return out
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 文件名(页签/状态栏展示与 tooltip 用)。
|
|
318
|
+
* @author ddj 2026年09月11号
|
|
319
|
+
* @param path 路径
|
|
320
|
+
* @returns 末段文件名;空路径返回空串
|
|
321
|
+
*/
|
|
322
|
+
export function baseNameOf(path: string): string {
|
|
323
|
+
const text = normalizeSlashes(path)
|
|
324
|
+
return text.split('/').filter(Boolean).pop() ?? ''
|
|
325
|
+
}
|
|
326
|
+
// --endregion
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode client — 文件页签右键菜单模型(纯函数,可单测)。
|
|
3
|
+
* 菜单是「数据」而非 JSX:条目顺序、分组分隔线、禁用规则与键位提示全部在此收敛,
|
|
4
|
+
* EditorView 只把结果映射成 ContextMenu 的 entries 并派发动作。
|
|
5
|
+
*
|
|
6
|
+
* 与参考图(VS Code / CodeBuddy 页签右键菜单)的对齐口径:
|
|
7
|
+
* 保留其分组顺序(对话 / 关闭族 / 路径 / 定位 / 固定),**省略**本架构无法实现的
|
|
8
|
+
* 「向右拆分 / Split & Move / 移动到新窗口 / 复制到新窗口」(浏览器内单编辑器实例)。
|
|
9
|
+
* 只显示**真实已绑定**的键位;参考图里的两步弦(Ctrl+K W 等)引擎不支持,故不伪造。
|
|
10
|
+
*
|
|
11
|
+
* 「固定 = 保护」:关闭其他 / 关闭右侧 / 关闭已保存 / 全部关闭 一律不关固定页签,
|
|
12
|
+
* 故这些条目在「固定页签是唯一可关对象」时判定为禁用(与 tabActions 语义同源)。
|
|
13
|
+
* 作者 ddj 2026年09月11号
|
|
14
|
+
*/
|
|
15
|
+
import { closeAll, closeOthers, closeRight, closeSaved, isTreeRevealable, type TabLike } from './tabActions.js'
|
|
16
|
+
|
|
17
|
+
/** 菜单构建输入(EditorView 每次打开菜单时按最新状态快照传入)。 */
|
|
18
|
+
export interface TabMenuState {
|
|
19
|
+
/** 右键目标页签路径。 */
|
|
20
|
+
path: string
|
|
21
|
+
/** 当前全部页签。 */
|
|
22
|
+
tabs: TabLike[]
|
|
23
|
+
/** 当前活动页签路径。 */
|
|
24
|
+
active: string | null
|
|
25
|
+
/** 路径 → 是否有未保存修改。 */
|
|
26
|
+
dirty: Record<string, boolean>
|
|
27
|
+
/** 会话工作区目录(相对路径复制与「资源管理器视图中显示」用)。 */
|
|
28
|
+
cwd?: string | null
|
|
29
|
+
/** 是否有活动会话(复制/定位类动作依赖)。 */
|
|
30
|
+
hasSession: boolean
|
|
31
|
+
/** 「添加到对话」动作集是否可用。 */
|
|
32
|
+
canAddToConversation: boolean
|
|
33
|
+
/** 「关闭」项的键位提示(缺省无提示;由调用方读 chordOf 注入)。 */
|
|
34
|
+
closeChord?: string | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 一条页签菜单项(ContextMenu 的展示形状 + 动作 id)。 */
|
|
38
|
+
export interface TabMenuEntry {
|
|
39
|
+
id: string
|
|
40
|
+
label: string
|
|
41
|
+
/** 右侧键位提示(仅在真实绑定键位时出现)。 */
|
|
42
|
+
hint?: string
|
|
43
|
+
disabled?: boolean
|
|
44
|
+
danger?: boolean
|
|
45
|
+
/** 前置分隔线(分组的首条)。 */
|
|
46
|
+
separator?: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 关闭族 id(EditorView 的动作表按 id 分派;导出便于测试与静态校验)。 */
|
|
50
|
+
export const CLOSE_MENU_IDS = ['close', 'close-others', 'close-right', 'close-saved', 'close-all'] as const
|
|
51
|
+
|
|
52
|
+
/** 本架构不支持的条目 id(参考图有、浏览器单编辑器实例无法实现)——显式登记以防误加。 */
|
|
53
|
+
export const UNSUPPORTED_MENU_IDS = ['split-right', 'split-move', 'move-new-window', 'copy-new-window'] as const
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 构建页签右键菜单条目。
|
|
57
|
+
* @author ddj 2026年09月11号
|
|
58
|
+
* @param state 菜单状态快照
|
|
59
|
+
* @returns 菜单条目(已按参考图分组排序)
|
|
60
|
+
*/
|
|
61
|
+
export function buildTabMenu(state: TabMenuState): TabMenuEntry[] {
|
|
62
|
+
const { path, tabs, active, dirty, cwd, hasSession, canAddToConversation, closeChord } = state
|
|
63
|
+
const current = tabs.find((tab) => tab.path === path)
|
|
64
|
+
const pinned = current?.pinned === true
|
|
65
|
+
return [
|
|
66
|
+
{
|
|
67
|
+
id: 'add-to-conversation',
|
|
68
|
+
label: '添加到对话',
|
|
69
|
+
disabled: !(hasSession && canAddToConversation),
|
|
70
|
+
},
|
|
71
|
+
{ id: 'close', label: '关闭', separator: true, ...(closeChord ? { hint: closeChord } : {}) },
|
|
72
|
+
{ id: 'close-others', label: '关闭其他', disabled: nothingToClose(closeOthers(tabs, path, active), tabs) },
|
|
73
|
+
{ id: 'close-right', label: '关闭右侧标签页', disabled: nothingToClose(closeRight(tabs, path, active), tabs) },
|
|
74
|
+
{ id: 'close-saved', label: '关闭已保存', disabled: nothingToClose(closeSaved(tabs, dirty, active), tabs) },
|
|
75
|
+
{ id: 'close-all', label: '全部关闭', disabled: nothingToClose(closeAll(tabs, active), tabs) },
|
|
76
|
+
{ id: 'copy-path', label: '复制路径', separator: true },
|
|
77
|
+
{ id: 'copy-relative-path', label: '复制相对路径', disabled: !hasCwd(cwd) },
|
|
78
|
+
{ id: 'reveal-in-os', label: '在文件资源管理器中显示', separator: true, disabled: !hasSession },
|
|
79
|
+
{ id: 'reveal-in-view', label: '在资源管理器视图中显示', disabled: !isTreeRevealable(path) },
|
|
80
|
+
{
|
|
81
|
+
id: 'toggle-pinned',
|
|
82
|
+
label: pinned ? '取消固定' : '固定',
|
|
83
|
+
separator: true,
|
|
84
|
+
},
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 关闭族是否无可关对象(结果页签数与关闭前一致 = 一个也没关掉)。
|
|
90
|
+
* @author ddj 2026年09月11号
|
|
91
|
+
* @param result 关闭结果
|
|
92
|
+
* @param tabs 关闭前的页签
|
|
93
|
+
* @returns 是否无可关对象
|
|
94
|
+
*/
|
|
95
|
+
function nothingToClose(result: { tabs: TabLike[] }, tabs: TabLike[]): boolean {
|
|
96
|
+
return result.tabs.length >= tabs.length
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 是否有可用工作区目录(复制相对路径需要)。
|
|
101
|
+
* @author ddj 2026年09月11号
|
|
102
|
+
* @param cwd 会话工作区目录
|
|
103
|
+
* @returns 是否可用
|
|
104
|
+
*/
|
|
105
|
+
function hasCwd(cwd: string | null | undefined): boolean {
|
|
106
|
+
return typeof cwd === 'string' && cwd.trim() !== ''
|
|
107
|
+
}
|
|
@@ -9,7 +9,7 @@ import React from 'react'
|
|
|
9
9
|
import { createPortal } from 'react-dom'
|
|
10
10
|
import { clampMenuPosition } from './menuPosition.js'
|
|
11
11
|
|
|
12
|
-
/** 单条菜单项(展示层形状;业务侧由 buildTreeMenu 映射而来)。 */
|
|
12
|
+
/** 单条菜单项(展示层形状;业务侧由 buildTreeMenu / buildTabMenu 映射而来)。 */
|
|
13
13
|
export interface ContextMenuEntry {
|
|
14
14
|
id: string
|
|
15
15
|
label: string
|
|
@@ -17,6 +17,8 @@ export interface ContextMenuEntry {
|
|
|
17
17
|
disabled?: boolean
|
|
18
18
|
/** 前置分隔线。 */
|
|
19
19
|
separator?: boolean
|
|
20
|
+
/** 右侧提示文案(键位弦等;仅在真实绑定时传入,缺省不渲染)。 */
|
|
21
|
+
hint?: string
|
|
20
22
|
onClick?: () => void
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -27,10 +29,36 @@ export interface ContextMenuProps {
|
|
|
27
29
|
onClose: () => void
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
/**
|
|
32
|
+
/** 估算宽高(实测前的回退值;条目数变化大时以实测为准,见 useLayoutEffect)。 */
|
|
31
33
|
const MENU_W = 224
|
|
32
34
|
const MENU_H = 176
|
|
33
35
|
|
|
36
|
+
/** 渲染行:分隔线或菜单项。 */
|
|
37
|
+
export interface MenuRow {
|
|
38
|
+
kind: 'sep' | 'item'
|
|
39
|
+
entry: ContextMenuEntry
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 把条目表展开为渲染行:`separator` 是**前置分隔线**,条目本身仍然渲染。
|
|
44
|
+
*
|
|
45
|
+
* ⚠️ 旧实现把 `separator` 当成「本条是分隔线」而 `continue` 跳过条目本身 ——
|
|
46
|
+
* 因历史调用方都没用过该字段,缺陷一直潜伏;页签菜单首次使用后表现为
|
|
47
|
+
* 「关闭 / 复制路径 / 在文件资源管理器中显示 / 固定」四条主条目整条消失。
|
|
48
|
+
* 首条带 separator 时不渲染分隔线(菜单顶部不应有横线,与参考图一致)。
|
|
49
|
+
* @author ddj 2026年09月11号
|
|
50
|
+
* @param entries 菜单项列表
|
|
51
|
+
* @returns 渲染行列表
|
|
52
|
+
*/
|
|
53
|
+
export function menuRows(entries: readonly ContextMenuEntry[]): MenuRow[] {
|
|
54
|
+
const rows: MenuRow[] = []
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
if (entry.separator && rows.length) rows.push({ kind: 'sep', entry })
|
|
57
|
+
rows.push({ kind: 'item', entry })
|
|
58
|
+
}
|
|
59
|
+
return rows
|
|
60
|
+
}
|
|
61
|
+
|
|
34
62
|
/**
|
|
35
63
|
* 浮动右键菜单。
|
|
36
64
|
* @param props.x 视口 x 坐标
|
|
@@ -41,6 +69,8 @@ const MENU_H = 176
|
|
|
41
69
|
export function ContextMenu(props: ContextMenuProps): React.ReactElement {
|
|
42
70
|
const { x, y, entries, onClose } = props
|
|
43
71
|
const menuRef = React.useRef<HTMLDivElement | null>(null)
|
|
72
|
+
// 实测菜单尺寸:页签菜单有 11 条 + 键位提示,估算常量会偏低导致底部条目越界不可达
|
|
73
|
+
const [size, setSize] = React.useState<{ w: number; h: number } | null>(null)
|
|
44
74
|
|
|
45
75
|
React.useEffect(() => {
|
|
46
76
|
const onKey = (e: KeyboardEvent): void => {
|
|
@@ -67,6 +97,16 @@ export function ContextMenu(props: ContextMenuProps): React.ReactElement {
|
|
|
67
97
|
}
|
|
68
98
|
}, [onClose])
|
|
69
99
|
|
|
100
|
+
// 首帧后量一次真实尺寸并据此定位:条目数/文案长度变化都不会越出视口下边界
|
|
101
|
+
React.useLayoutEffect(() => {
|
|
102
|
+
const el = menuRef.current
|
|
103
|
+
if (!el) return
|
|
104
|
+
const w = el.offsetWidth
|
|
105
|
+
const h = el.offsetHeight
|
|
106
|
+
if (!w || !h) return
|
|
107
|
+
setSize((prev) => (prev && prev.w === w && prev.h === h ? prev : { w, h }))
|
|
108
|
+
}, [entries])
|
|
109
|
+
|
|
70
110
|
const safeX = Number.isFinite(x) ? x : 4
|
|
71
111
|
const safeY = Number.isFinite(y) ? y : 4
|
|
72
112
|
const position = clampMenuPosition(
|
|
@@ -74,16 +114,17 @@ export function ContextMenu(props: ContextMenuProps): React.ReactElement {
|
|
|
74
114
|
safeY,
|
|
75
115
|
window.innerWidth || 800,
|
|
76
116
|
window.innerHeight || 600,
|
|
77
|
-
MENU_W,
|
|
78
|
-
MENU_H,
|
|
117
|
+
size?.w ?? MENU_W,
|
|
118
|
+
size?.h ?? MENU_H,
|
|
79
119
|
)
|
|
80
120
|
|
|
81
121
|
const children: React.ReactNode[] = []
|
|
82
|
-
for (const
|
|
83
|
-
if (
|
|
84
|
-
children.push(React.createElement('div', { key: 'sep-' + entry.id, className: 'edrv-ctxmenu-sep' }))
|
|
122
|
+
for (const row of menuRows(entries)) {
|
|
123
|
+
if (row.kind === 'sep') {
|
|
124
|
+
children.push(React.createElement('div', { key: 'sep-' + row.entry.id, className: 'edrv-ctxmenu-sep' }))
|
|
85
125
|
continue
|
|
86
126
|
}
|
|
127
|
+
const entry = row.entry
|
|
87
128
|
const cls = 'edrv-ctxmenu-item'
|
|
88
129
|
+ (entry.danger ? ' edrv-ctxmenu-danger' : '')
|
|
89
130
|
+ (entry.disabled ? ' edrv-ctxmenu-disabled' : '')
|
|
@@ -91,13 +132,16 @@ export function ContextMenu(props: ContextMenuProps): React.ReactElement {
|
|
|
91
132
|
key: entry.id,
|
|
92
133
|
className: cls,
|
|
93
134
|
disabled: entry.disabled,
|
|
135
|
+
title: entry.hint ? entry.label + ' (' + entry.hint + ')' : entry.label,
|
|
94
136
|
onClick: () => {
|
|
95
137
|
if (!entry.disabled) {
|
|
96
138
|
entry.onClick?.()
|
|
97
139
|
onClose()
|
|
98
140
|
}
|
|
99
141
|
},
|
|
100
|
-
},
|
|
142
|
+
},
|
|
143
|
+
React.createElement('span', { className: 'edrv-ctxmenu-label' }, entry.label),
|
|
144
|
+
entry.hint ? React.createElement('span', { className: 'edrv-ctxmenu-hint' }, entry.hint) : null))
|
|
101
145
|
}
|
|
102
146
|
|
|
103
147
|
const overlay = React.createElement('div', { 'data-edrv-view': '1' },
|