sh3-core 0.11.4 → 0.11.7

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 (90) hide show
  1. package/dist/BrandSlot.svelte +80 -0
  2. package/dist/BrandSlot.svelte.d.ts +3 -0
  3. package/dist/BrandSlot.test.d.ts +1 -0
  4. package/dist/BrandSlot.test.js +71 -0
  5. package/dist/Shell.svelte +8 -10
  6. package/dist/actions/ActionPanel.svelte +143 -0
  7. package/dist/actions/ActionPanel.svelte.d.ts +13 -0
  8. package/dist/actions/ActionPanel.test.d.ts +1 -0
  9. package/dist/actions/ActionPanel.test.js +168 -0
  10. package/dist/actions/ContextMenu.svelte +17 -85
  11. package/dist/actions/MenuBar.svelte +57 -0
  12. package/dist/actions/MenuBar.svelte.d.ts +3 -0
  13. package/dist/actions/MenuBar.test.d.ts +1 -0
  14. package/dist/actions/MenuBar.test.js +109 -0
  15. package/dist/actions/MenuButton.svelte +150 -0
  16. package/dist/actions/MenuButton.svelte.d.ts +10 -0
  17. package/dist/actions/MenuButton.test.d.ts +1 -0
  18. package/dist/actions/MenuButton.test.js +125 -0
  19. package/dist/actions/contextMenuModel.d.ts +10 -0
  20. package/dist/actions/contextMenuModel.js +44 -9
  21. package/dist/actions/contextMenuModel.test.js +28 -1
  22. package/dist/actions/defaultMenuContainers.d.ts +2 -0
  23. package/dist/actions/defaultMenuContainers.js +7 -0
  24. package/dist/actions/defaultMenuContainers.test.d.ts +1 -0
  25. package/dist/actions/defaultMenuContainers.test.js +23 -0
  26. package/dist/actions/listeners.d.ts +4 -0
  27. package/dist/actions/listeners.js +77 -17
  28. package/dist/actions/listeners.test.js +50 -0
  29. package/dist/actions/menuBarModel.d.ts +42 -0
  30. package/dist/actions/menuBarModel.js +110 -0
  31. package/dist/actions/menuBarModel.test.d.ts +1 -0
  32. package/dist/actions/menuBarModel.test.js +158 -0
  33. package/dist/actions/palette-scorer.d.ts +4 -0
  34. package/dist/actions/palette-scorer.js +5 -0
  35. package/dist/actions/palette-scorer.test.js +9 -1
  36. package/dist/actions/paletteModel.d.ts +7 -1
  37. package/dist/actions/paletteModel.js +26 -1
  38. package/dist/actions/paletteModel.test.js +43 -0
  39. package/dist/actions/registry.js +5 -0
  40. package/dist/actions/registry.test.js +12 -0
  41. package/dist/actions/types.d.ts +48 -1
  42. package/dist/actions/types.test.d.ts +1 -0
  43. package/dist/actions/types.test.js +31 -0
  44. package/dist/apps/lifecycle.js +8 -1
  45. package/dist/apps/lifecycle.test.js +211 -1
  46. package/dist/apps/registry.svelte.d.ts +17 -1
  47. package/dist/apps/registry.svelte.js +20 -1
  48. package/dist/apps/types.d.ts +28 -0
  49. package/dist/assets/icons.svg +5 -0
  50. package/dist/documents/backends.d.ts +2 -0
  51. package/dist/documents/backends.js +55 -0
  52. package/dist/documents/backends.test.d.ts +1 -1
  53. package/dist/documents/backends.test.js +69 -1
  54. package/dist/documents/browse.d.ts +18 -0
  55. package/dist/documents/browse.js +13 -0
  56. package/dist/documents/browse.test.js +47 -0
  57. package/dist/documents/handle.js +23 -0
  58. package/dist/documents/handle.test.js +51 -0
  59. package/dist/documents/http-backend.d.ts +1 -0
  60. package/dist/documents/http-backend.js +19 -0
  61. package/dist/documents/http-backend.test.js +42 -0
  62. package/dist/documents/types.d.ts +29 -1
  63. package/dist/documents/types.js +4 -0
  64. package/dist/documents/types.test.d.ts +1 -0
  65. package/dist/documents/types.test.js +20 -0
  66. package/dist/layout/LayoutRenderer.browser.test.js +196 -0
  67. package/dist/layout/SlotContainer.svelte +13 -8
  68. package/dist/layout/SlotDropZone.svelte +44 -9
  69. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-7-fixed-slot-drop-protection-still-accepts-a-strip-drop-into-a-fixed-tabs-node-1.png +0 -0
  70. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-8-same-strip-reorder-keeps-the-active-pane-populated-after-moving-the-second-tab-to-first-1.png +0 -0
  71. package/dist/layout/ops.d.ts +10 -0
  72. package/dist/layout/ops.js +30 -2
  73. package/dist/layout/ops.test.js +111 -1
  74. package/dist/layout/slotHostPool.svelte.d.ts +7 -1
  75. package/dist/layout/slotHostPool.svelte.js +27 -8
  76. package/dist/layout/store.svelte.d.ts +27 -0
  77. package/dist/layout/store.svelte.js +63 -0
  78. package/dist/overlays/ConfirmDialog.svelte +138 -0
  79. package/dist/overlays/ConfirmDialog.svelte.d.ts +13 -0
  80. package/dist/overlays/ConfirmDialog.test.d.ts +1 -0
  81. package/dist/overlays/ConfirmDialog.test.js +123 -0
  82. package/dist/overlays/FloatFrame.svelte +2 -2
  83. package/dist/overlays/ToastItem.svelte +3 -3
  84. package/dist/primitives/base.css +5 -5
  85. package/dist/sh3core-shard/sh3coreShard.svelte.js +38 -4
  86. package/dist/shell-shard/shellShard.svelte.js +0 -4
  87. package/dist/tokens.css +1 -1
  88. package/dist/version.d.ts +1 -1
  89. package/dist/version.js +1 -1
  90. package/package.json +2 -1
@@ -3,12 +3,14 @@
3
3
  * contextmenu listener, attached at shell boot (Task 18) and removed on
4
4
  * shell teardown.
5
5
  */
6
+ import { mount } from 'svelte';
6
7
  import { listActions } from './registry';
7
8
  import { dispatchKeydown } from './dispatcher.svelte';
8
9
  import { getLiveDispatcherState, setFocusedViewId, } from './state.svelte';
9
10
  import { eventToShortcut } from './shortcuts';
10
11
  import ContextMenu from './ContextMenu.svelte';
11
- import { buildContextMenuModel } from './contextMenuModel';
12
+ import { buildContextMenuModel, buildContextMenuSubmenu } from './contextMenuModel';
13
+ import ActionPanel from './ActionPanel.svelte';
12
14
  import CommandPalette from './CommandPalette.svelte';
13
15
  import { buildPaletteCandidates } from './paletteModel';
14
16
  import { shell } from '../shellRuntime.svelte';
@@ -22,7 +24,7 @@ function viewIdOfEl(el) {
22
24
  }
23
25
  function runAction(actionId, ctx) {
24
26
  const entry = listActions().find((e) => e.action.id === actionId);
25
- if (!entry)
27
+ if (!entry || typeof entry.action.run !== 'function')
26
28
  return;
27
29
  try {
28
30
  void entry.action.run(ctx);
@@ -69,6 +71,48 @@ function isNativeOptOut(target) {
69
71
  return false;
70
72
  return target.closest('[data-sh3-context-menu="native"]') !== null;
71
73
  }
74
+ function openContextSubmenu(parentId, state, handle) {
75
+ const root = document.querySelector('.sh3-popup-host');
76
+ if (!root)
77
+ return;
78
+ const sub = document.createElement('div');
79
+ sub.className = 'sh3-popup-submenu';
80
+ sub.style.position = 'absolute';
81
+ sub.style.pointerEvents = 'auto';
82
+ const activeRow = root.querySelector('.sh3-ctx-active');
83
+ const anchorRect = (activeRow !== null && activeRow !== void 0 ? activeRow : root).getBoundingClientRect();
84
+ sub.style.left = `${anchorRect.right + 2}px`;
85
+ sub.style.top = `${anchorRect.top}px`;
86
+ root.appendChild(sub);
87
+ const subItems = buildContextMenuSubmenu(listActions(), state, parentId);
88
+ mount(ActionPanel, {
89
+ target: sub,
90
+ props: {
91
+ sections: [{ id: `submenu:${parentId}`, items: subItems }],
92
+ onInvoke: (cid) => {
93
+ var _a, _b;
94
+ const child = listActions().find((e) => e.action.id === cid);
95
+ if (!child || typeof child.action.run !== 'function')
96
+ return;
97
+ try {
98
+ void child.action.run({
99
+ action: { id: cid, label: child.action.label },
100
+ appId: state.activeAppId,
101
+ viewId: (_a = state.focusedViewId) !== null && _a !== void 0 ? _a : undefined,
102
+ selection: (_b = state.selection) !== null && _b !== void 0 ? _b : undefined,
103
+ invokedVia: 'context-menu',
104
+ dispatch: chainedDispatch,
105
+ });
106
+ }
107
+ catch (err) {
108
+ console.error(`[sh3] context-menu submenu action "${cid}" threw:`, err);
109
+ }
110
+ handle.close();
111
+ },
112
+ onDismiss: () => handle.close(),
113
+ },
114
+ });
115
+ }
72
116
  function onContextMenu(ev) {
73
117
  if (isNativeOptOut(ev.target))
74
118
  return;
@@ -83,20 +127,26 @@ function onContextMenu(ev) {
83
127
  onInvoke: (id) => {
84
128
  var _a, _b;
85
129
  const entry = listActions().find((e) => e.action.id === id);
86
- if (entry) {
87
- try {
88
- void entry.action.run({
89
- action: { id, label: entry.action.label },
90
- appId: state.activeAppId,
91
- viewId: (_a = state.focusedViewId) !== null && _a !== void 0 ? _a : undefined,
92
- selection: (_b = state.selection) !== null && _b !== void 0 ? _b : undefined,
93
- invokedVia: 'context-menu',
94
- dispatch: chainedDispatch,
95
- });
96
- }
97
- catch (err) {
98
- console.error(`[sh3] context-menu action "${id}" threw:`, err);
99
- }
130
+ if (!entry)
131
+ return;
132
+ if (entry.action.submenu === true) {
133
+ openContextSubmenu(id, state, handle);
134
+ return;
135
+ }
136
+ if (typeof entry.action.run !== 'function')
137
+ return;
138
+ try {
139
+ void entry.action.run({
140
+ action: { id, label: entry.action.label },
141
+ appId: state.activeAppId,
142
+ viewId: (_a = state.focusedViewId) !== null && _a !== void 0 ? _a : undefined,
143
+ selection: (_b = state.selection) !== null && _b !== void 0 ? _b : undefined,
144
+ invokedVia: 'context-menu',
145
+ dispatch: chainedDispatch,
146
+ });
147
+ }
148
+ catch (err) {
149
+ console.error(`[sh3] context-menu action "${id}" threw:`, err);
100
150
  }
101
151
  },
102
152
  onClose: () => handle.close(),
@@ -150,7 +200,7 @@ export function openPalette(opts) {
150
200
  var _a;
151
201
  const entries = listActions();
152
202
  const state = getLiveDispatcherState();
153
- const candidates = buildPaletteCandidates(entries, state);
203
+ const candidates = buildPaletteCandidates(entries, state, { filter: opts === null || opts === void 0 ? void 0 : opts.filter });
154
204
  const handle = shell.modal.open(CommandPalette, {
155
205
  candidates,
156
206
  recency,
@@ -161,6 +211,16 @@ export function openPalette(opts) {
161
211
  if (!entry)
162
212
  return;
163
213
  recordUse(id);
214
+ // Submenu drill: a parent without a run() opens a sub-palette
215
+ // filtered to its children. Apps that supply their own run()
216
+ // keep that behavior — drill is only the default.
217
+ if (entry.action.submenu === true && typeof entry.action.run !== 'function') {
218
+ handle.close();
219
+ openPalette({ filter: { submenuOf: id } });
220
+ return;
221
+ }
222
+ if (typeof entry.action.run !== 'function')
223
+ return;
164
224
  try {
165
225
  void entry.action.run({
166
226
  action: { id, label: entry.action.label },
@@ -115,6 +115,27 @@ describe('global contextmenu listener', () => {
115
115
  expect(def).toBe(true); // no menu → native preserved
116
116
  target.remove();
117
117
  });
118
+ it('clicking a submenu-parent row opens a nested ActionPanel listing children', async () => {
119
+ registerAction({ id: 'p', label: 'Open with…', scope: 'home', contextItem: true, submenu: true }, 'a');
120
+ registerAction({ id: 'p.a', label: 'A', scope: 'home', submenuOf: 'p', run: () => { } }, 'a');
121
+ registerAction({ id: 'p.b', label: 'B', scope: 'home', submenuOf: 'p', run: () => { } }, 'a');
122
+ const target = document.createElement('div');
123
+ document.body.appendChild(target);
124
+ const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
125
+ Object.defineProperty(ev, 'target', { value: target });
126
+ target.dispatchEvent(ev);
127
+ await Promise.resolve();
128
+ expect(document.querySelectorAll('.sh3-context-menu')).toHaveLength(1);
129
+ const parentRow = document.querySelector('.sh3-context-menu [role="menuitem"]');
130
+ parentRow.click();
131
+ await Promise.resolve();
132
+ const panels = document.querySelectorAll('.sh3-context-menu');
133
+ expect(panels.length).toBe(2);
134
+ const submenuLabels = [...panels[1].querySelectorAll('[role="menuitem"] .sh3-ctx-label')]
135
+ .map((n) => n.textContent);
136
+ expect(submenuLabels).toEqual(['A', 'B']);
137
+ target.remove();
138
+ });
118
139
  });
119
140
  describe('command palette', () => {
120
141
  let modalLayerRoot;
@@ -146,4 +167,33 @@ describe('command palette', () => {
146
167
  await Promise.resolve();
147
168
  expect(document.querySelector('.sh3-palette-item')).not.toBeNull();
148
169
  });
170
+ it('openPalette({filter}) builds candidates filtered to children of the parent', async () => {
171
+ registerAction({ id: 'p', label: 'P', scope: 'home', submenu: true }, 'shard.x');
172
+ registerAction({ id: 'p.a', label: 'A', scope: 'home', submenuOf: 'p', run: () => { } }, 'shard.x');
173
+ registerAction({ id: 'p.b', label: 'B', scope: 'home', submenuOf: 'p', run: () => { } }, 'shard.x');
174
+ registerAction({ id: 'q', label: 'Q', scope: 'home', run: () => { } }, 'shard.x');
175
+ openPalette({ filter: { submenuOf: 'p' } });
176
+ await Promise.resolve();
177
+ const labels = [...document.querySelectorAll('.sh3-palette-item .sh3-palette-label')]
178
+ .map((n) => n.textContent)
179
+ .sort();
180
+ expect(labels).toEqual(['A', 'B']);
181
+ });
182
+ it('invoking a submenu parent with no run() opens a sub-palette filtered to its children', async () => {
183
+ registerAction({ id: 'p', label: 'P', scope: 'home', submenu: true }, 'shard.x');
184
+ registerAction({ id: 'p.a', label: 'A', scope: 'home', submenuOf: 'p', run: () => { } }, 'shard.x');
185
+ openPalette();
186
+ await Promise.resolve();
187
+ // The idle palette shows only the parent (children hidden until search).
188
+ const initial = [...document.querySelectorAll('.sh3-palette-item .sh3-palette-label')]
189
+ .map((n) => n.textContent);
190
+ expect(initial).toEqual(['P']);
191
+ // Clicking the parent should drill into a sub-palette listing the child.
192
+ const item = document.querySelector('.sh3-palette-item');
193
+ item.click();
194
+ await Promise.resolve();
195
+ const drilled = [...document.querySelectorAll('.sh3-palette-item .sh3-palette-label')]
196
+ .map((n) => n.textContent);
197
+ expect(drilled).toEqual(['A']);
198
+ });
149
199
  });
@@ -0,0 +1,42 @@
1
+ import type { ActionEntry } from './registry';
2
+ import type { DispatcherState } from './dispatcher.svelte';
3
+ import type { MenuContainer } from '../apps/types';
4
+ export interface MenuBarItem {
5
+ id: string;
6
+ label: string;
7
+ shortcut: string | null;
8
+ group: string;
9
+ icon: string | undefined;
10
+ /** True iff `Action.checked` evaluates truthy at derive time. */
11
+ checked: boolean;
12
+ /** True iff `Action.disabled` evaluates truthy at derive time. */
13
+ disabled: boolean;
14
+ /** True iff `Action.submenu === true`. */
15
+ submenu: boolean;
16
+ }
17
+ /**
18
+ * Resolved container list for the currently-active app:
19
+ * - activeAppId == null → returns []
20
+ * - declared has entries → returns declared, sorted by `order`
21
+ * ascending then declaration order
22
+ * for ties / undefined
23
+ * - declared is undefined → returns a copy of DEFAULT_MENU_CONTAINERS
24
+ *
25
+ * Callers can render unconditionally; the empty-array case naturally
26
+ * suppresses the menu bar at home.
27
+ */
28
+ export declare function resolveMenuContainers(activeAppId: string | null, declared: readonly MenuContainer[] | undefined): MenuContainer[];
29
+ /**
30
+ * Items targeting `containerId`, filtered by current scope activation
31
+ * and de-duplicated to the innermost active scope per action id (mirrors
32
+ * contextMenuModel).
33
+ */
34
+ export declare function resolveMenuItems(entries: readonly ActionEntry[], state: DispatcherState, containerId: string): MenuBarItem[];
35
+ /**
36
+ * Items belonging to a submenu — entries whose `submenuOf` equals
37
+ * `parentId` and whose scope is active. Same `MenuBarItem` shape as
38
+ * top-level container items. Order is registration order, matching the
39
+ * top-level resolver's behavior. De-duplicated by id (multi-scope
40
+ * children resolve to one row, mirroring `resolveMenuItems`).
41
+ */
42
+ export declare function resolveSubmenuItems(entries: readonly ActionEntry[], state: DispatcherState, parentId: string): MenuBarItem[];
@@ -0,0 +1,110 @@
1
+ /*
2
+ * Pure model layer for the menu bar: resolves container list for the
3
+ * active app, and resolves per-container item lists by filtering the
4
+ * action registry by `menuItem` + scope-activation. Mirrors the
5
+ * de-duplication semantics of contextMenuModel.
6
+ */
7
+ import { effectiveShortcut } from './bindings';
8
+ import { innermostActiveScope } from './scope-helpers';
9
+ import { DEFAULT_MENU_CONTAINERS } from './defaultMenuContainers';
10
+ function evalFlag(v) {
11
+ if (v === undefined)
12
+ return false;
13
+ return typeof v === 'function' ? !!v() : !!v;
14
+ }
15
+ /**
16
+ * Resolved container list for the currently-active app:
17
+ * - activeAppId == null → returns []
18
+ * - declared has entries → returns declared, sorted by `order`
19
+ * ascending then declaration order
20
+ * for ties / undefined
21
+ * - declared is undefined → returns a copy of DEFAULT_MENU_CONTAINERS
22
+ *
23
+ * Callers can render unconditionally; the empty-array case naturally
24
+ * suppresses the menu bar at home.
25
+ */
26
+ export function resolveMenuContainers(activeAppId, declared) {
27
+ if (activeAppId == null)
28
+ return [];
29
+ if (declared == null)
30
+ return DEFAULT_MENU_CONTAINERS.slice();
31
+ const indexed = declared.map((c, i) => ({ c, i }));
32
+ indexed.sort((a, b) => {
33
+ const ao = a.c.order;
34
+ const bo = b.c.order;
35
+ if (ao != null && bo != null)
36
+ return ao - bo || a.i - b.i;
37
+ if (ao != null)
38
+ return -1;
39
+ if (bo != null)
40
+ return 1;
41
+ return a.i - b.i;
42
+ });
43
+ return indexed.map((x) => x.c);
44
+ }
45
+ /**
46
+ * Items targeting `containerId`, filtered by current scope activation
47
+ * and de-duplicated to the innermost active scope per action id (mirrors
48
+ * contextMenuModel).
49
+ */
50
+ export function resolveMenuItems(entries, state, containerId) {
51
+ var _a;
52
+ const out = [];
53
+ const seen = new Set();
54
+ for (const entry of entries) {
55
+ if (entry.action.menuItem !== containerId)
56
+ continue;
57
+ if (entry.action.submenuOf !== undefined)
58
+ continue;
59
+ if (seen.has(entry.action.id))
60
+ continue;
61
+ const winning = innermostActiveScope(entry.action.scope, state, entry.ownerShardId);
62
+ if (!winning)
63
+ continue;
64
+ seen.add(entry.action.id);
65
+ out.push({
66
+ id: entry.action.id,
67
+ label: entry.action.label,
68
+ shortcut: effectiveShortcut(entry.action, state.bindings, state.platform),
69
+ group: (_a = entry.action.group) !== null && _a !== void 0 ? _a : '',
70
+ icon: entry.action.icon,
71
+ checked: evalFlag(entry.action.checked),
72
+ disabled: evalFlag(entry.action.disabled),
73
+ submenu: entry.action.submenu === true,
74
+ });
75
+ }
76
+ return out;
77
+ }
78
+ /**
79
+ * Items belonging to a submenu — entries whose `submenuOf` equals
80
+ * `parentId` and whose scope is active. Same `MenuBarItem` shape as
81
+ * top-level container items. Order is registration order, matching the
82
+ * top-level resolver's behavior. De-duplicated by id (multi-scope
83
+ * children resolve to one row, mirroring `resolveMenuItems`).
84
+ */
85
+ export function resolveSubmenuItems(entries, state, parentId) {
86
+ var _a;
87
+ const out = [];
88
+ const seen = new Set();
89
+ for (const entry of entries) {
90
+ if (entry.action.submenuOf !== parentId)
91
+ continue;
92
+ if (seen.has(entry.action.id))
93
+ continue;
94
+ const winning = innermostActiveScope(entry.action.scope, state, entry.ownerShardId);
95
+ if (!winning)
96
+ continue;
97
+ seen.add(entry.action.id);
98
+ out.push({
99
+ id: entry.action.id,
100
+ label: entry.action.label,
101
+ shortcut: effectiveShortcut(entry.action, state.bindings, state.platform),
102
+ group: (_a = entry.action.group) !== null && _a !== void 0 ? _a : '',
103
+ icon: entry.action.icon,
104
+ checked: evalFlag(entry.action.checked),
105
+ disabled: evalFlag(entry.action.disabled),
106
+ submenu: entry.action.submenu === true,
107
+ });
108
+ }
109
+ return out;
110
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,158 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { resolveMenuContainers, resolveMenuItems, resolveSubmenuItems, } from './menuBarModel';
3
+ const mkEntry = (a, owner = 'shard.x') => ({
4
+ ownerShardId: owner,
5
+ action: Object.assign({ id: 'a', label: 'A', scope: 'home', run: () => { } }, a),
6
+ });
7
+ const mkState = (o = {}) => (Object.assign({ activeAppId: null, activeAppRequiredShards: new Set(), autostartShards: new Set(), mountedViewIds: new Set(), focusedViewId: null, selection: null, bindings: {}, platform: 'other' }, o));
8
+ describe('resolveMenuContainers', () => {
9
+ it('returns [] when no app is active', () => {
10
+ expect(resolveMenuContainers(null, undefined)).toEqual([]);
11
+ });
12
+ it('returns DEFAULT_MENU_CONTAINERS when app has no manifest.menus', () => {
13
+ const out = resolveMenuContainers('app.a', undefined);
14
+ expect(out.map((c) => c.id)).toEqual(['file', 'edit', 'view', 'window', 'help']);
15
+ });
16
+ it('returns manifest.menus when declared', () => {
17
+ const declared = [
18
+ { id: 'project', label: 'Project' },
19
+ { id: 'help', label: 'Help' },
20
+ ];
21
+ expect(resolveMenuContainers('app.a', declared).map((c) => c.id))
22
+ .toEqual(['project', 'help']);
23
+ });
24
+ it('sorts by `order` ascending, then by declaration order for ties/undefined', () => {
25
+ const declared = [
26
+ { id: 'a', label: 'A', order: 10 },
27
+ { id: 'b', label: 'B' },
28
+ { id: 'c', label: 'C', order: 5 },
29
+ { id: 'd', label: 'D' },
30
+ ];
31
+ expect(resolveMenuContainers('app.a', declared).map((c) => c.id))
32
+ .toEqual(['c', 'a', 'b', 'd']);
33
+ });
34
+ });
35
+ describe('resolveMenuItems', () => {
36
+ const stateWithApp = mkState({
37
+ activeAppId: 'app.a',
38
+ activeAppRequiredShards: new Set(['shard.x']),
39
+ });
40
+ it('returns only actions whose menuItem matches the container id', () => {
41
+ const entries = [
42
+ mkEntry({ id: 'open', scope: 'app', menuItem: 'file', label: 'Open' }),
43
+ mkEntry({ id: 'copy', scope: 'app', menuItem: 'edit', label: 'Copy' }),
44
+ mkEntry({ id: 'close', scope: 'app', menuItem: 'file', label: 'Close' }),
45
+ ];
46
+ const out = resolveMenuItems(entries, stateWithApp, 'file');
47
+ expect(out.map((i) => i.id)).toEqual(['open', 'close']);
48
+ });
49
+ it('skips actions whose scope is not currently active', () => {
50
+ const entries = [
51
+ mkEntry({ id: 'open', scope: 'app', menuItem: 'file', label: 'Open' }),
52
+ mkEntry({ id: 'help', scope: 'home', menuItem: 'file', label: 'Help' }),
53
+ ];
54
+ const out = resolveMenuItems(entries, stateWithApp, 'file');
55
+ expect(out.map((i) => i.id)).toEqual(['open']);
56
+ });
57
+ it('skips actions without a menuItem field', () => {
58
+ const entries = [
59
+ mkEntry({ id: 'a', scope: 'app', menuItem: 'file', label: 'A' }),
60
+ mkEntry({ id: 'b', scope: 'app', label: 'B' }),
61
+ ];
62
+ const out = resolveMenuItems(entries, stateWithApp, 'file');
63
+ expect(out.map((i) => i.id)).toEqual(['a']);
64
+ });
65
+ it('returns [] for an unknown container id', () => {
66
+ const entries = [
67
+ mkEntry({ id: 'open', scope: 'app', menuItem: 'file', label: 'Open' }),
68
+ ];
69
+ expect(resolveMenuItems(entries, stateWithApp, 'sausage')).toEqual([]);
70
+ });
71
+ it('de-duplicates multi-scope actions by innermost active scope', () => {
72
+ const state = mkState({
73
+ activeAppId: 'app.a',
74
+ activeAppRequiredShards: new Set(['shard.x']),
75
+ autostartShards: new Set(['shard.x']),
76
+ });
77
+ const entries = [
78
+ mkEntry({ id: 'p', scope: ['home', 'app'], menuItem: 'file', label: 'P' }),
79
+ ];
80
+ const out = resolveMenuItems(entries, state, 'file');
81
+ expect(out).toHaveLength(1);
82
+ expect(out[0].id).toBe('p');
83
+ });
84
+ });
85
+ describe('resolveMenuItems — checked / disabled / submenu', () => {
86
+ const stateWithApp = mkState({
87
+ activeAppId: 'app.a',
88
+ activeAppRequiredShards: new Set(['shard.x']),
89
+ });
90
+ it('evaluates checked: boolean to flag on the item', () => {
91
+ const entries = [
92
+ mkEntry({ id: 'a', scope: 'app', menuItem: 'view', label: 'A', checked: true }),
93
+ ];
94
+ const out = resolveMenuItems(entries, stateWithApp, 'view');
95
+ expect(out[0].checked).toBe(true);
96
+ });
97
+ it('evaluates checked: () => boolean on each derive', () => {
98
+ let v = false;
99
+ const entries = [
100
+ mkEntry({ id: 'a', scope: 'app', menuItem: 'view', label: 'A', checked: () => v }),
101
+ ];
102
+ expect(resolveMenuItems(entries, stateWithApp, 'view')[0].checked).toBe(false);
103
+ v = true;
104
+ expect(resolveMenuItems(entries, stateWithApp, 'view')[0].checked).toBe(true);
105
+ });
106
+ it('evaluates disabled: () => boolean on each derive', () => {
107
+ const entries = [
108
+ mkEntry({ id: 'a', scope: 'app', menuItem: 'view', label: 'A', disabled: () => true }),
109
+ ];
110
+ expect(resolveMenuItems(entries, stateWithApp, 'view')[0].disabled).toBe(true);
111
+ });
112
+ it('defaults checked / disabled to false when omitted', () => {
113
+ const entries = [
114
+ mkEntry({ id: 'a', scope: 'app', menuItem: 'view', label: 'A' }),
115
+ ];
116
+ const item = resolveMenuItems(entries, stateWithApp, 'view')[0];
117
+ expect(item.checked).toBe(false);
118
+ expect(item.disabled).toBe(false);
119
+ });
120
+ it('flags submenu: true parents and excludes their children from the container list', () => {
121
+ const entries = [
122
+ mkEntry({ id: 'p', scope: 'app', menuItem: 'view', label: 'Parent', submenu: true }),
123
+ mkEntry({ id: 'p.a', scope: 'app', label: 'A', submenuOf: 'p' }),
124
+ mkEntry({ id: 'p.b', scope: 'app', label: 'B', submenuOf: 'p' }),
125
+ ];
126
+ const out = resolveMenuItems(entries, stateWithApp, 'view');
127
+ expect(out.map((i) => i.id)).toEqual(['p']);
128
+ expect(out[0].submenu).toBe(true);
129
+ });
130
+ });
131
+ describe('resolveSubmenuItems', () => {
132
+ const stateWithApp = mkState({
133
+ activeAppId: 'app.a',
134
+ activeAppRequiredShards: new Set(['shard.x']),
135
+ });
136
+ it('returns only children whose submenuOf matches the parent id', () => {
137
+ const entries = [
138
+ mkEntry({ id: 'p', scope: 'app', menuItem: 'view', label: 'P', submenu: true }),
139
+ mkEntry({ id: 'p.a', scope: 'app', label: 'A', submenuOf: 'p' }),
140
+ mkEntry({ id: 'p.b', scope: 'app', label: 'B', submenuOf: 'p' }),
141
+ mkEntry({ id: 'q.x', scope: 'app', label: 'X', submenuOf: 'q' }),
142
+ ];
143
+ const out = resolveSubmenuItems(entries, stateWithApp, 'p');
144
+ expect(out.map((i) => i.id)).toEqual(['p.a', 'p.b']);
145
+ });
146
+ it('skips children whose scope is inactive', () => {
147
+ const entries = [
148
+ mkEntry({ id: 'p', scope: 'app', menuItem: 'view', label: 'P', submenu: true }),
149
+ mkEntry({ id: 'p.app', scope: 'app', label: 'AppOK', submenuOf: 'p' }),
150
+ mkEntry({ id: 'p.hom', scope: 'home', label: 'HomeNo', submenuOf: 'p' }),
151
+ ];
152
+ expect(resolveSubmenuItems(entries, stateWithApp, 'p').map((i) => i.id))
153
+ .toEqual(['p.app']);
154
+ });
155
+ it('returns [] for an unknown parent id', () => {
156
+ expect(resolveSubmenuItems([], stateWithApp, 'nope')).toEqual([]);
157
+ });
158
+ });
@@ -3,6 +3,10 @@ export interface PaletteCandidate {
3
3
  label: string;
4
4
  shortcut: string | null;
5
5
  scopeBadge: string | null;
6
+ /** True when `Action.submenu === true`. Hint only; ranker treats it like any candidate. */
7
+ submenu: boolean;
8
+ /** Set when this candidate is a child of a submenu parent. Hidden when the palette query is empty. */
9
+ submenuOf?: string;
6
10
  }
7
11
  export interface RankedCandidate extends PaletteCandidate {
8
12
  score: number;
@@ -30,6 +30,11 @@ export function scoreMatch(label, query) {
30
30
  export function rankPaletteEntries(candidates, query, recency) {
31
31
  const ranked = [];
32
32
  for (const c of candidates) {
33
+ // Hide submenu children from the idle (empty-query) palette so the
34
+ // default view stays uncluttered. A direct text match still surfaces
35
+ // them because the scorer runs once query is non-empty.
36
+ if (query === '' && c.submenuOf !== undefined)
37
+ continue;
33
38
  const s = scoreMatch(c.label, query);
34
39
  if (s === null)
35
40
  continue;
@@ -24,7 +24,7 @@ describe('scoreMatch', () => {
24
24
  });
25
25
  });
26
26
  describe('rankPaletteEntries', () => {
27
- const mk = (id, label) => ({ id, label, shortcut: null, scopeBadge: null });
27
+ const mk = (id, label, extra = {}) => (Object.assign({ id, label, shortcut: null, scopeBadge: null, submenu: false }, extra));
28
28
  it('filters out non-matches', () => {
29
29
  const out = rankPaletteEntries([mk('a', 'save'), mk('b', 'undo')], 'redo', []);
30
30
  expect(out).toHaveLength(0);
@@ -37,4 +37,12 @@ describe('rankPaletteEntries', () => {
37
37
  const out = rankPaletteEntries([mk('a', 'save'), mk('b', 'save')], 'sa', ['b']);
38
38
  expect(out[0].id).toBe('b');
39
39
  });
40
+ it('hides submenu children when query is empty', () => {
41
+ const out = rankPaletteEntries([mk('p', 'P', { submenu: true }), mk('p.a', 'A', { submenuOf: 'p' })], '', []);
42
+ expect(out.map((c) => c.id)).toEqual(['p']);
43
+ });
44
+ it('surfaces submenu children when query matches them', () => {
45
+ const out = rankPaletteEntries([mk('p', 'Launch app', { submenu: true }), mk('p.a', 'guml', { submenuOf: 'p' })], 'guml', []);
46
+ expect(out.map((c) => c.id)).toContain('p.a');
47
+ });
40
48
  });
@@ -1,4 +1,10 @@
1
1
  import type { ActionEntry } from './registry';
2
2
  import type { DispatcherState } from './dispatcher.svelte';
3
3
  import type { PaletteCandidate } from './palette-scorer';
4
- export declare function buildPaletteCandidates(entries: ActionEntry[], state: DispatcherState): PaletteCandidate[];
4
+ export interface BuildPaletteOpts {
5
+ /** When set, return only children of the given submenu parent. */
6
+ filter?: {
7
+ submenuOf?: string;
8
+ };
9
+ }
10
+ export declare function buildPaletteCandidates(entries: ActionEntry[], state: DispatcherState, opts?: BuildPaletteOpts): PaletteCandidate[];
@@ -3,15 +3,34 @@
3
3
  * action, deduplicated, with shortcut and scope badge resolved. Uses
4
4
  * innermost-first scope selection so the badge matches keyboard dispatch
5
5
  * and context-menu tiering (audit: RFC #24).
6
+ *
7
+ * Submenu rules:
8
+ * - When `opts.filter.submenuOf` is set, return ONLY children whose
9
+ * `submenuOf` matches it. Used by sub-palette drill.
10
+ * - Otherwise return all active candidates. The scorer
11
+ * (`rankPaletteEntries`) is responsible for hiding children when
12
+ * the user's query is empty.
13
+ * - Disabled actions are hidden in all modes (v1 — see spec).
6
14
  */
7
15
  import { effectiveShortcut } from './bindings';
8
16
  import { innermostActiveScope, scopeBadge } from './scope-helpers';
9
- export function buildPaletteCandidates(entries, state) {
17
+ function evalFlag(v) {
18
+ if (v === undefined)
19
+ return false;
20
+ return typeof v === 'function' ? !!v() : !!v;
21
+ }
22
+ export function buildPaletteCandidates(entries, state, opts = {}) {
23
+ var _a;
10
24
  const out = [];
11
25
  const seen = new Set();
26
+ const filterParent = (_a = opts.filter) === null || _a === void 0 ? void 0 : _a.submenuOf;
12
27
  for (const entry of entries) {
13
28
  if (entry.action.paletteItem === false)
14
29
  continue;
30
+ if (evalFlag(entry.action.disabled))
31
+ continue;
32
+ if (filterParent !== undefined && entry.action.submenuOf !== filterParent)
33
+ continue;
15
34
  if (seen.has(entry.action.id))
16
35
  continue;
17
36
  const winning = innermostActiveScope(entry.action.scope, state, entry.ownerShardId);
@@ -23,6 +42,12 @@ export function buildPaletteCandidates(entries, state) {
23
42
  label: entry.action.label,
24
43
  shortcut: effectiveShortcut(entry.action, state.bindings, state.platform),
25
44
  scopeBadge: scopeBadge(winning),
45
+ submenu: entry.action.submenu === true,
46
+ // When a submenuOf-filter is in effect, the resulting candidates are
47
+ // already scoped — they ARE the visible set in this palette, not
48
+ // hidden children of some other parent. Drop the `submenuOf` hint so
49
+ // the ranker doesn't apply its hide-children-on-empty rule to them.
50
+ submenuOf: filterParent !== undefined ? undefined : entry.action.submenuOf,
26
51
  });
27
52
  }
28
53
  return out;