sh3-core 0.11.6 → 0.11.8

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 (59) hide show
  1. package/dist/actions/ActionPanel.svelte +49 -11
  2. package/dist/actions/ActionPanel.test.js +94 -6
  3. package/dist/actions/MenuButton.svelte +60 -14
  4. package/dist/actions/MenuButton.svelte.d.ts +3 -2
  5. package/dist/actions/MenuButton.test.js +38 -1
  6. package/dist/actions/contextMenuModel.d.ts +12 -1
  7. package/dist/actions/contextMenuModel.js +62 -13
  8. package/dist/actions/contextMenuModel.test.js +72 -20
  9. package/dist/actions/listeners.d.ts +6 -0
  10. package/dist/actions/listeners.js +124 -20
  11. package/dist/actions/listeners.test.js +98 -6
  12. package/dist/actions/menuBarModel.d.ts +14 -0
  13. package/dist/actions/menuBarModel.js +43 -0
  14. package/dist/actions/menuBarModel.test.js +75 -1
  15. package/dist/actions/palette-scorer.d.ts +4 -0
  16. package/dist/actions/palette-scorer.js +5 -0
  17. package/dist/actions/palette-scorer.test.js +9 -1
  18. package/dist/actions/paletteModel.d.ts +7 -1
  19. package/dist/actions/paletteModel.js +26 -1
  20. package/dist/actions/paletteModel.test.js +43 -0
  21. package/dist/actions/registry.js +5 -0
  22. package/dist/actions/registry.test.js +12 -0
  23. package/dist/actions/scope-helpers.d.ts +6 -0
  24. package/dist/actions/scope-helpers.js +10 -0
  25. package/dist/actions/scope-helpers.test.js +24 -1
  26. package/dist/actions/types.d.ts +41 -1
  27. package/dist/actions/types.test.d.ts +1 -0
  28. package/dist/actions/types.test.js +31 -0
  29. package/dist/assets/icons.svg +5 -0
  30. package/dist/documents/backends.d.ts +2 -0
  31. package/dist/documents/backends.js +55 -0
  32. package/dist/documents/backends.test.d.ts +1 -1
  33. package/dist/documents/backends.test.js +69 -1
  34. package/dist/documents/browse.d.ts +33 -0
  35. package/dist/documents/browse.js +20 -0
  36. package/dist/documents/browse.test.js +88 -0
  37. package/dist/documents/handle.js +26 -1
  38. package/dist/documents/handle.test.js +74 -0
  39. package/dist/documents/http-backend.d.ts +1 -0
  40. package/dist/documents/http-backend.js +19 -0
  41. package/dist/documents/http-backend.test.js +42 -0
  42. package/dist/documents/types.d.ts +29 -1
  43. package/dist/documents/types.js +4 -0
  44. package/dist/documents/types.test.d.ts +1 -0
  45. package/dist/documents/types.test.js +20 -0
  46. package/dist/layout/LayoutRenderer.browser.test.js +196 -0
  47. package/dist/layout/SlotContainer.svelte +13 -8
  48. package/dist/layout/SlotDropZone.svelte +44 -9
  49. 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
  50. 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
  51. package/dist/layout/ops.d.ts +10 -0
  52. package/dist/layout/ops.js +30 -2
  53. package/dist/layout/ops.test.js +111 -1
  54. package/dist/layout/slotHostPool.svelte.d.ts +7 -1
  55. package/dist/layout/slotHostPool.svelte.js +27 -8
  56. package/dist/sh3core-shard/sh3coreShard.svelte.js +18 -4
  57. package/dist/version.d.ts +1 -1
  58. package/dist/version.js +1 -1
  59. package/package.json +2 -1
@@ -1,9 +1,15 @@
1
+ import type { AtomicScope } from './types';
1
2
  export interface OpenContextMenuOpts {
2
3
  x: number;
3
4
  y: number;
5
+ scope?: AtomicScope;
4
6
  }
5
7
  export interface OpenPaletteOpts {
6
8
  prefill?: string;
9
+ /** Restrict candidates to children of the given submenu parent (sub-palette drill). */
10
+ filter?: {
11
+ submenuOf?: string;
12
+ };
7
13
  }
8
14
  export declare function attachGlobalListeners(): void;
9
15
  export declare function detachGlobalListeners(): void;
@@ -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';
@@ -20,9 +22,19 @@ function viewIdOfEl(el) {
20
22
  const host = el.closest('[data-sh3-view]');
21
23
  return (_a = host === null || host === void 0 ? void 0 : host.getAttribute('data-sh3-view')) !== null && _a !== void 0 ? _a : null;
22
24
  }
25
+ function resolveAnchor(args) {
26
+ if (args.explicit !== undefined)
27
+ return args.explicit;
28
+ if (args.event && args.event.target) {
29
+ const viewId = viewIdOfEl(args.event.target);
30
+ if (viewId)
31
+ return `focus:${viewId}`;
32
+ }
33
+ return args.state.activeAppId ? 'app' : 'home';
34
+ }
23
35
  function runAction(actionId, ctx) {
24
36
  const entry = listActions().find((e) => e.action.id === actionId);
25
- if (!entry)
37
+ if (!entry || typeof entry.action.run !== 'function')
26
38
  return;
27
39
  try {
28
40
  void entry.action.run(ctx);
@@ -69,12 +81,55 @@ function isNativeOptOut(target) {
69
81
  return false;
70
82
  return target.closest('[data-sh3-context-menu="native"]') !== null;
71
83
  }
84
+ function openContextSubmenu(parentId, state, handle, anchor) {
85
+ const root = document.querySelector('.sh3-popup-host');
86
+ if (!root)
87
+ return;
88
+ const sub = document.createElement('div');
89
+ sub.className = 'sh3-popup-submenu';
90
+ sub.style.position = 'absolute';
91
+ sub.style.pointerEvents = 'auto';
92
+ const activeRow = root.querySelector('.sh3-ctx-active');
93
+ const anchorRect = (activeRow !== null && activeRow !== void 0 ? activeRow : root).getBoundingClientRect();
94
+ sub.style.left = `${anchorRect.right + 2}px`;
95
+ sub.style.top = `${anchorRect.top}px`;
96
+ root.appendChild(sub);
97
+ const subItems = buildContextMenuSubmenu(listActions(), state, parentId, anchor);
98
+ mount(ActionPanel, {
99
+ target: sub,
100
+ props: {
101
+ sections: [{ id: `submenu:${parentId}`, items: subItems }],
102
+ onInvoke: (cid) => {
103
+ var _a, _b;
104
+ const child = listActions().find((e) => e.action.id === cid);
105
+ if (!child || typeof child.action.run !== 'function')
106
+ return;
107
+ try {
108
+ void child.action.run({
109
+ action: { id: cid, label: child.action.label },
110
+ appId: state.activeAppId,
111
+ viewId: (_a = state.focusedViewId) !== null && _a !== void 0 ? _a : undefined,
112
+ selection: (_b = state.selection) !== null && _b !== void 0 ? _b : undefined,
113
+ invokedVia: 'context-menu',
114
+ dispatch: chainedDispatch,
115
+ });
116
+ }
117
+ catch (err) {
118
+ console.error(`[sh3] context-menu submenu action "${cid}" threw:`, err);
119
+ }
120
+ handle.close();
121
+ },
122
+ onDismiss: () => handle.close(),
123
+ },
124
+ });
125
+ }
72
126
  function onContextMenu(ev) {
73
127
  if (isNativeOptOut(ev.target))
74
128
  return;
75
129
  const entries = listActions();
76
130
  const state = getLiveDispatcherState();
77
- const model = buildContextMenuModel(entries, state);
131
+ const anchor = resolveAnchor({ event: ev, state });
132
+ const model = buildContextMenuModel(entries, state, anchor);
78
133
  if (model.tiers.length === 0)
79
134
  return;
80
135
  ev.preventDefault();
@@ -83,20 +138,26 @@ function onContextMenu(ev) {
83
138
  onInvoke: (id) => {
84
139
  var _a, _b;
85
140
  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
- }
141
+ if (!entry)
142
+ return;
143
+ if (entry.action.submenu === true) {
144
+ openContextSubmenu(id, state, handle, anchor);
145
+ return;
146
+ }
147
+ if (typeof entry.action.run !== 'function')
148
+ return;
149
+ try {
150
+ void entry.action.run({
151
+ action: { id, label: entry.action.label },
152
+ appId: state.activeAppId,
153
+ viewId: (_a = state.focusedViewId) !== null && _a !== void 0 ? _a : undefined,
154
+ selection: (_b = state.selection) !== null && _b !== void 0 ? _b : undefined,
155
+ invokedVia: 'context-menu',
156
+ dispatch: chainedDispatch,
157
+ });
158
+ }
159
+ catch (err) {
160
+ console.error(`[sh3] context-menu action "${id}" threw:`, err);
100
161
  }
101
162
  },
102
163
  onClose: () => handle.close(),
@@ -138,8 +199,41 @@ export function detachGlobalListeners() {
138
199
  document.removeEventListener('contextmenu', onContextMenu);
139
200
  }
140
201
  export function openContextMenu(opts) {
141
- const fakeEvent = { target: null, clientX: opts.x, clientY: opts.y, preventDefault: () => { } };
142
- onContextMenu(fakeEvent);
202
+ const entries = listActions();
203
+ const state = getLiveDispatcherState();
204
+ const anchor = resolveAnchor({ explicit: opts.scope, state });
205
+ const model = buildContextMenuModel(entries, state, anchor);
206
+ if (model.tiers.length === 0)
207
+ return;
208
+ const handle = shell.popup.show(ContextMenu, { anchor: { x: opts.x, y: opts.y } }, {
209
+ model,
210
+ onInvoke: (id) => {
211
+ var _a, _b;
212
+ const entry = listActions().find((e) => e.action.id === id);
213
+ if (!entry)
214
+ return;
215
+ if (entry.action.submenu === true) {
216
+ openContextSubmenu(id, state, handle, anchor);
217
+ return;
218
+ }
219
+ if (typeof entry.action.run !== 'function')
220
+ return;
221
+ try {
222
+ void entry.action.run({
223
+ action: { id, label: entry.action.label },
224
+ appId: state.activeAppId,
225
+ viewId: (_a = state.focusedViewId) !== null && _a !== void 0 ? _a : undefined,
226
+ selection: (_b = state.selection) !== null && _b !== void 0 ? _b : undefined,
227
+ invokedVia: 'context-menu',
228
+ dispatch: chainedDispatch,
229
+ });
230
+ }
231
+ catch (err) {
232
+ console.error(`[sh3] context-menu action "${id}" threw:`, err);
233
+ }
234
+ },
235
+ onClose: () => handle.close(),
236
+ });
143
237
  }
144
238
  const RECENCY_CAP = 20;
145
239
  let recency = [];
@@ -150,7 +244,7 @@ export function openPalette(opts) {
150
244
  var _a;
151
245
  const entries = listActions();
152
246
  const state = getLiveDispatcherState();
153
- const candidates = buildPaletteCandidates(entries, state);
247
+ const candidates = buildPaletteCandidates(entries, state, { filter: opts === null || opts === void 0 ? void 0 : opts.filter });
154
248
  const handle = shell.modal.open(CommandPalette, {
155
249
  candidates,
156
250
  recency,
@@ -161,6 +255,16 @@ export function openPalette(opts) {
161
255
  if (!entry)
162
256
  return;
163
257
  recordUse(id);
258
+ // Submenu drill: a parent without a run() opens a sub-palette
259
+ // filtered to its children. Apps that supply their own run()
260
+ // keep that behavior — drill is only the default.
261
+ if (entry.action.submenu === true && typeof entry.action.run !== 'function') {
262
+ handle.close();
263
+ openPalette({ filter: { submenuOf: id } });
264
+ return;
265
+ }
266
+ if (typeof entry.action.run !== 'function')
267
+ return;
164
268
  try {
165
269
  void entry.action.run({
166
270
  action: { id, label: entry.action.label },
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import { attachGlobalListeners, detachGlobalListeners, openPalette } from './listeners';
2
+ import { attachGlobalListeners, detachGlobalListeners, openPalette, openContextMenu } from './listeners';
3
3
  import { registerAction, __resetActionsRegistryForTest } from './registry';
4
4
  import { __resetContributionsForTest } from '../contributions/registry';
5
5
  import { __resetDispatcherStateForTest, setActiveApp, setMountedViewIds, setFocusedViewId, } from './state.svelte';
@@ -82,14 +82,14 @@ describe('global contextmenu listener', () => {
82
82
  popupLayerRoot.remove();
83
83
  vi.unstubAllGlobals();
84
84
  });
85
- it('opens popup on contextmenu and preventDefault()s native', () => {
85
+ it('opens popup with home anchor when no view ancestor and no active app', () => {
86
86
  registerAction({ id: 'a.x', label: 'Dup', scope: 'home', contextItem: true, run: () => { } }, 'a');
87
87
  const target = document.createElement('div');
88
88
  document.body.appendChild(target);
89
89
  const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
90
90
  Object.defineProperty(ev, 'target', { value: target });
91
91
  const def = target.dispatchEvent(ev);
92
- expect(def).toBe(false); // preventDefault
92
+ expect(def).toBe(false);
93
93
  expect(document.querySelector('.sh3-context-menu')).not.toBeNull();
94
94
  target.remove();
95
95
  });
@@ -101,18 +101,81 @@ describe('global contextmenu listener', () => {
101
101
  const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
102
102
  Object.defineProperty(ev, 'target', { value: target });
103
103
  const def = target.dispatchEvent(ev);
104
- expect(def).toBe(true); // native preserved
104
+ expect(def).toBe(true);
105
105
  expect(document.querySelector('.sh3-context-menu')).toBeNull();
106
106
  target.remove();
107
107
  });
108
- it('does not open when no contextItem actions are active', () => {
108
+ it('does not open when no contextItem actions match the anchor', () => {
109
109
  registerAction({ id: 'a.x', label: 'Save', scope: 'home', contextItem: false, run: () => { } }, 'a');
110
110
  const target = document.createElement('div');
111
111
  document.body.appendChild(target);
112
112
  const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
113
113
  Object.defineProperty(ev, 'target', { value: target });
114
114
  const def = target.dispatchEvent(ev);
115
- expect(def).toBe(true); // no menu → native preserved
115
+ expect(def).toBe(true);
116
+ target.remove();
117
+ });
118
+ it('right-click inside data-sh3-view anchors to focus:<viewId>', async () => {
119
+ setActiveApp('app.a', new Set(['shard.x']));
120
+ setMountedViewIds(new Set(['editor']));
121
+ registerAction({ id: 'view-only', label: 'View', scope: 'focus:editor', contextItem: true, run: () => { } }, 'shard.x');
122
+ registerAction({ id: 'home-only', label: 'Home', scope: 'home', contextItem: true, run: () => { } }, 'shard.x');
123
+ const wrap = document.createElement('div');
124
+ wrap.setAttribute('data-sh3-view', 'editor');
125
+ document.body.appendChild(wrap);
126
+ const inner = document.createElement('button');
127
+ wrap.appendChild(inner);
128
+ const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
129
+ Object.defineProperty(ev, 'target', { value: inner });
130
+ inner.dispatchEvent(ev);
131
+ await Promise.resolve();
132
+ const labels = [...document.querySelectorAll('.sh3-context-menu [role="menuitem"] .sh3-ctx-label')]
133
+ .map((n) => n.textContent);
134
+ expect(labels).toEqual(['View']);
135
+ wrap.remove();
136
+ });
137
+ it('right-click outside any view falls back to app anchor when an app is active', async () => {
138
+ setActiveApp('app.a', new Set(['shard.x']));
139
+ registerAction({ id: 'app.a', label: 'App', scope: 'app', contextItem: true, run: () => { } }, 'shard.x');
140
+ registerAction({ id: 'h.a', label: 'Home', scope: 'home', contextItem: true, run: () => { } }, 'shard.x');
141
+ const target = document.createElement('div');
142
+ document.body.appendChild(target);
143
+ const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
144
+ Object.defineProperty(ev, 'target', { value: target });
145
+ target.dispatchEvent(ev);
146
+ await Promise.resolve();
147
+ const labels = [...document.querySelectorAll('.sh3-context-menu [role="menuitem"] .sh3-ctx-label')]
148
+ .map((n) => n.textContent);
149
+ expect(labels).toEqual(['App']);
150
+ target.remove();
151
+ });
152
+ it('openContextMenu({scope}) uses the explicit anchor', async () => {
153
+ registerAction({ id: 'cell.copy', label: 'Copy Cell', scope: { element: 'cell' }, contextItem: true, run: () => { } }, 'shard.x');
154
+ registerAction({ id: 'home.x', label: 'Home', scope: 'home', contextItem: true, run: () => { } }, 'shard.x');
155
+ openContextMenu({ x: 10, y: 20, scope: { element: 'cell' } });
156
+ await Promise.resolve();
157
+ const labels = [...document.querySelectorAll('.sh3-context-menu [role="menuitem"] .sh3-ctx-label')]
158
+ .map((n) => n.textContent);
159
+ expect(labels).toEqual(['Copy Cell']);
160
+ });
161
+ it('clicking a submenu-parent row opens children filtered by the same anchor', async () => {
162
+ registerAction({ id: 'p', label: 'Open with…', scope: 'home', contextItem: true, submenu: true }, 'a');
163
+ registerAction({ id: 'p.a', label: 'A', scope: 'home', submenuOf: 'p', run: () => { } }, 'a');
164
+ registerAction({ id: 'p.b', label: 'B', scope: 'home', submenuOf: 'p', run: () => { } }, 'a');
165
+ const target = document.createElement('div');
166
+ document.body.appendChild(target);
167
+ const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
168
+ Object.defineProperty(ev, 'target', { value: target });
169
+ target.dispatchEvent(ev);
170
+ await Promise.resolve();
171
+ const parentRow = document.querySelector('.sh3-context-menu [role="menuitem"]');
172
+ parentRow.click();
173
+ await Promise.resolve();
174
+ const panels = document.querySelectorAll('.sh3-context-menu');
175
+ expect(panels.length).toBe(2);
176
+ const submenuLabels = [...panels[1].querySelectorAll('[role="menuitem"] .sh3-ctx-label')]
177
+ .map((n) => n.textContent);
178
+ expect(submenuLabels).toEqual(['A', 'B']);
116
179
  target.remove();
117
180
  });
118
181
  });
@@ -146,4 +209,33 @@ describe('command palette', () => {
146
209
  await Promise.resolve();
147
210
  expect(document.querySelector('.sh3-palette-item')).not.toBeNull();
148
211
  });
212
+ it('openPalette({filter}) builds candidates filtered to children of the parent', async () => {
213
+ registerAction({ id: 'p', label: 'P', scope: 'home', submenu: true }, 'shard.x');
214
+ registerAction({ id: 'p.a', label: 'A', scope: 'home', submenuOf: 'p', run: () => { } }, 'shard.x');
215
+ registerAction({ id: 'p.b', label: 'B', scope: 'home', submenuOf: 'p', run: () => { } }, 'shard.x');
216
+ registerAction({ id: 'q', label: 'Q', scope: 'home', run: () => { } }, 'shard.x');
217
+ openPalette({ filter: { submenuOf: 'p' } });
218
+ await Promise.resolve();
219
+ const labels = [...document.querySelectorAll('.sh3-palette-item .sh3-palette-label')]
220
+ .map((n) => n.textContent)
221
+ .sort();
222
+ expect(labels).toEqual(['A', 'B']);
223
+ });
224
+ it('invoking a submenu parent with no run() opens a sub-palette filtered to its children', async () => {
225
+ registerAction({ id: 'p', label: 'P', scope: 'home', submenu: true }, 'shard.x');
226
+ registerAction({ id: 'p.a', label: 'A', scope: 'home', submenuOf: 'p', run: () => { } }, 'shard.x');
227
+ openPalette();
228
+ await Promise.resolve();
229
+ // The idle palette shows only the parent (children hidden until search).
230
+ const initial = [...document.querySelectorAll('.sh3-palette-item .sh3-palette-label')]
231
+ .map((n) => n.textContent);
232
+ expect(initial).toEqual(['P']);
233
+ // Clicking the parent should drill into a sub-palette listing the child.
234
+ const item = document.querySelector('.sh3-palette-item');
235
+ item.click();
236
+ await Promise.resolve();
237
+ const drilled = [...document.querySelectorAll('.sh3-palette-item .sh3-palette-label')]
238
+ .map((n) => n.textContent);
239
+ expect(drilled).toEqual(['A']);
240
+ });
149
241
  });
@@ -7,6 +7,12 @@ export interface MenuBarItem {
7
7
  shortcut: string | null;
8
8
  group: string;
9
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;
10
16
  }
11
17
  /**
12
18
  * Resolved container list for the currently-active app:
@@ -26,3 +32,11 @@ export declare function resolveMenuContainers(activeAppId: string | null, declar
26
32
  * contextMenuModel).
27
33
  */
28
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[];
@@ -7,6 +7,11 @@
7
7
  import { effectiveShortcut } from './bindings';
8
8
  import { innermostActiveScope } from './scope-helpers';
9
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
+ }
10
15
  /**
11
16
  * Resolved container list for the currently-active app:
12
17
  * - activeAppId == null → returns []
@@ -49,6 +54,41 @@ export function resolveMenuItems(entries, state, containerId) {
49
54
  for (const entry of entries) {
50
55
  if (entry.action.menuItem !== containerId)
51
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;
52
92
  if (seen.has(entry.action.id))
53
93
  continue;
54
94
  const winning = innermostActiveScope(entry.action.scope, state, entry.ownerShardId);
@@ -61,6 +101,9 @@ export function resolveMenuItems(entries, state, containerId) {
61
101
  shortcut: effectiveShortcut(entry.action, state.bindings, state.platform),
62
102
  group: (_a = entry.action.group) !== null && _a !== void 0 ? _a : '',
63
103
  icon: entry.action.icon,
104
+ checked: evalFlag(entry.action.checked),
105
+ disabled: evalFlag(entry.action.disabled),
106
+ submenu: entry.action.submenu === true,
64
107
  });
65
108
  }
66
109
  return out;
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { resolveMenuContainers, resolveMenuItems, } from './menuBarModel';
2
+ import { resolveMenuContainers, resolveMenuItems, resolveSubmenuItems, } from './menuBarModel';
3
3
  const mkEntry = (a, owner = 'shard.x') => ({
4
4
  ownerShardId: owner,
5
5
  action: Object.assign({ id: 'a', label: 'A', scope: 'home', run: () => { } }, a),
@@ -82,3 +82,77 @@ describe('resolveMenuItems', () => {
82
82
  expect(out[0].id).toBe('p');
83
83
  });
84
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;