sh3-core 0.11.6 → 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 (56) 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 +10 -0
  7. package/dist/actions/contextMenuModel.js +44 -9
  8. package/dist/actions/contextMenuModel.test.js +28 -1
  9. package/dist/actions/listeners.d.ts +4 -0
  10. package/dist/actions/listeners.js +77 -17
  11. package/dist/actions/listeners.test.js +50 -0
  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/types.d.ts +40 -1
  24. package/dist/actions/types.test.d.ts +1 -0
  25. package/dist/actions/types.test.js +31 -0
  26. package/dist/assets/icons.svg +5 -0
  27. package/dist/documents/backends.d.ts +2 -0
  28. package/dist/documents/backends.js +55 -0
  29. package/dist/documents/backends.test.d.ts +1 -1
  30. package/dist/documents/backends.test.js +69 -1
  31. package/dist/documents/browse.d.ts +18 -0
  32. package/dist/documents/browse.js +13 -0
  33. package/dist/documents/browse.test.js +47 -0
  34. package/dist/documents/handle.js +23 -0
  35. package/dist/documents/handle.test.js +51 -0
  36. package/dist/documents/http-backend.d.ts +1 -0
  37. package/dist/documents/http-backend.js +19 -0
  38. package/dist/documents/http-backend.test.js +42 -0
  39. package/dist/documents/types.d.ts +29 -1
  40. package/dist/documents/types.js +4 -0
  41. package/dist/documents/types.test.d.ts +1 -0
  42. package/dist/documents/types.test.js +20 -0
  43. package/dist/layout/LayoutRenderer.browser.test.js +196 -0
  44. package/dist/layout/SlotContainer.svelte +13 -8
  45. package/dist/layout/SlotDropZone.svelte +44 -9
  46. 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
  47. 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
  48. package/dist/layout/ops.d.ts +10 -0
  49. package/dist/layout/ops.js +30 -2
  50. package/dist/layout/ops.test.js +111 -1
  51. package/dist/layout/slotHostPool.svelte.d.ts +7 -1
  52. package/dist/layout/slotHostPool.svelte.js +27 -8
  53. package/dist/sh3core-shard/sh3coreShard.svelte.js +18 -4
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +1 -1
  56. 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
  });
@@ -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;
@@ -47,3 +47,46 @@ describe('buildPaletteCandidates', () => {
47
47
  expect(out[0].scopeBadge).toBe('view:editor');
48
48
  });
49
49
  });
50
+ describe('buildPaletteCandidates — submenu and filter', () => {
51
+ it('filter.submenuOf: only returns children of the given parent', () => {
52
+ const entries = [
53
+ mkEntry({ id: 'p', scope: 'home', label: 'P', submenu: true }),
54
+ mkEntry({ id: 'p.a', scope: 'home', label: 'A', submenuOf: 'p' }),
55
+ mkEntry({ id: 'p.b', scope: 'home', label: 'B', submenuOf: 'p' }),
56
+ mkEntry({ id: 'q.x', scope: 'home', label: 'X', submenuOf: 'q' }),
57
+ mkEntry({ id: 'r', scope: 'home', label: 'R' }),
58
+ ];
59
+ const out = buildPaletteCandidates(entries, mkState(), { filter: { submenuOf: 'p' } });
60
+ expect(out.map((c) => c.id)).toEqual(['p.a', 'p.b']);
61
+ });
62
+ it('returns all active candidates by default (children included; scorer hides them on empty query)', () => {
63
+ const entries = [
64
+ mkEntry({ id: 'p', scope: 'home', label: 'P', submenu: true }),
65
+ mkEntry({ id: 'p.a', scope: 'home', label: 'A', submenuOf: 'p' }),
66
+ ];
67
+ const out = buildPaletteCandidates(entries, mkState());
68
+ expect(out.map((c) => c.id).sort()).toEqual(['p', 'p.a']);
69
+ });
70
+ it('hides disabled actions', () => {
71
+ const entries = [
72
+ mkEntry({ id: 'a', scope: 'home', label: 'A', disabled: true }),
73
+ mkEntry({ id: 'b', scope: 'home', label: 'B', disabled: () => true }),
74
+ mkEntry({ id: 'c', scope: 'home', label: 'C' }),
75
+ ];
76
+ const out = buildPaletteCandidates(entries, mkState(), {});
77
+ expect(out.map((c) => c.id)).toEqual(['c']);
78
+ });
79
+ it('annotates submenu and submenuOf on candidates', () => {
80
+ const entries = [
81
+ mkEntry({ id: 'p', scope: 'home', label: 'P', submenu: true }),
82
+ mkEntry({ id: 'p.a', scope: 'home', label: 'A', submenuOf: 'p' }),
83
+ ];
84
+ const out = buildPaletteCandidates(entries, mkState());
85
+ const parent = out.find((c) => c.id === 'p');
86
+ const child = out.find((c) => c.id === 'p.a');
87
+ expect(parent.submenu).toBe(true);
88
+ expect(parent.submenuOf).toBeUndefined();
89
+ expect(child.submenu).toBe(false);
90
+ expect(child.submenuOf).toBe('p');
91
+ });
92
+ });
@@ -9,6 +9,11 @@ import { register as contributionsRegister, list as contributionsList, onChange
9
9
  export const ACTIONS_POINT_ID = 'sh3.actions';
10
10
  const liveIds = new Set();
11
11
  export function registerAction(action, ownerShardId) {
12
+ if (typeof action.run !== 'function' && action.submenu !== true) {
13
+ console.warn(`[sh3] Action "${action.id}" registered by "${ownerShardId}" has no \`run\` ` +
14
+ `and is not a submenu parent (\`submenu: true\`). Registration ignored.`);
15
+ return () => { };
16
+ }
12
17
  if (liveIds.has(action.id)) {
13
18
  console.warn(`[sh3] Duplicate action id "${action.id}" registered by "${ownerShardId}". ` +
14
19
  `First registration wins; second registration still stored but shortcut conflicts may occur.`);
@@ -46,4 +46,16 @@ describe('actions registry', () => {
46
46
  expect(warn).toHaveBeenCalled();
47
47
  warn.mockRestore();
48
48
  });
49
+ it('warns and rejects an action with neither run nor submenu', () => {
50
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
51
+ const dispose = registerAction({ id: 'no-op', label: 'No-Op', scope: 'home' }, 'shard.a');
52
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('"no-op"'));
53
+ expect(listActions()).toHaveLength(0);
54
+ dispose();
55
+ warn.mockRestore();
56
+ });
57
+ it('accepts a submenu parent without run', () => {
58
+ registerAction({ id: 'p', label: 'Parent', scope: 'home', submenu: true }, 'shard.a');
59
+ expect(listActions()).toHaveLength(1);
60
+ });
49
61
  });
@@ -18,9 +18,45 @@ export interface Action {
18
18
  menuItem?: string;
19
19
  defaultShortcut?: string;
20
20
  icon?: string;
21
+ /**
22
+ * Optional grouping key. Items inside the same surface (menu bar
23
+ * dropdown, context menu, submenu popup) with distinct `group`
24
+ * values are separated by a divider line in `ActionPanel`. This is
25
+ * the only separator mechanism — there is no explicit separator
26
+ * primitive. Items without a `group` form a single default group.
27
+ */
21
28
  group?: string;
22
29
  allowInInputs?: boolean;
23
- run(ctx: ActionDispatchContext): void | Promise<void>;
30
+ /**
31
+ * Toggle-state indicator. Truthy renders a leading ✓ in MenuBar /
32
+ * ContextMenu rows. Function form is re-evaluated on each derive
33
+ * (no re-registration on flip). Ignored in CommandPalette.
34
+ */
35
+ checked?: boolean | (() => boolean);
36
+ /**
37
+ * Visible-but-not-dispatchable. Greyed in MenuBar/ContextMenu,
38
+ * skipped by keyboard nav, click is a no-op, shortcut dispatch is
39
+ * blocked. Hidden from CommandPalette in v1 (a future "show
40
+ * disabled" toggle is acknowledged in the spec but out of scope).
41
+ */
42
+ disabled?: boolean | (() => boolean);
43
+ /**
44
+ * Marks this action as a submenu parent. `run` becomes optional —
45
+ * when omitted, the framework provides default drill-down behavior:
46
+ * MenuBar/ContextMenu render the row as actionless+expanding;
47
+ * CommandPalette opens a sub-palette filtered to children
48
+ * (`submenuOf === this.id`).
49
+ */
50
+ submenu?: true;
51
+ /**
52
+ * Marks this action as a child of `submenuOf`'s submenu. Children
53
+ * inherit the parent's surface placement — they do NOT repeat
54
+ * `menuItem`, `contextItem`, or `paletteItem`. Children are excluded
55
+ * from their parent's container's flat list and only appear in the
56
+ * parent's submenu popup or via direct match in the palette.
57
+ */
58
+ submenuOf?: string;
59
+ run?(ctx: ActionDispatchContext): void | Promise<void>;
24
60
  }
25
61
  export interface Selection {
26
62
  type: string;
@@ -55,6 +91,9 @@ export interface ActionsApi {
55
91
  }): void;
56
92
  openPalette(opts?: {
57
93
  prefill?: string;
94
+ filter?: {
95
+ submenuOf?: string;
96
+ };
58
97
  }): void;
59
98
  }
60
99
  export interface ResolvedAction {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,31 @@
1
+ import { describe, it, expectTypeOf } from 'vitest';
2
+ describe('Action type', () => {
3
+ it('accepts an action without run when submenu: true', () => {
4
+ const a = {
5
+ id: 'p',
6
+ label: 'Parent',
7
+ scope: 'home',
8
+ submenu: true,
9
+ };
10
+ expectTypeOf(a).toMatchTypeOf();
11
+ });
12
+ it('accepts checked / disabled as boolean or function', () => {
13
+ const flat = {
14
+ id: 'a', label: 'A', scope: 'home',
15
+ run: () => { }, checked: true, disabled: false,
16
+ };
17
+ const reactive = {
18
+ id: 'b', label: 'B', scope: 'home',
19
+ run: () => { }, checked: () => true, disabled: () => false,
20
+ };
21
+ expectTypeOf(flat).toMatchTypeOf();
22
+ expectTypeOf(reactive).toMatchTypeOf();
23
+ });
24
+ it('accepts submenuOf as a string id', () => {
25
+ const c = {
26
+ id: 'c', label: 'C', scope: 'home',
27
+ submenuOf: 'p', run: () => { },
28
+ };
29
+ expectTypeOf(c).toMatchTypeOf();
30
+ });
31
+ });