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.
@@ -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
- /** 菜单估算宽高(clamp 防越出视口,与 EditorView menuPos 常量对齐)。 */
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 entry of entries) {
83
- if (entry.separator) {
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
- }, entry.label))
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' },