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.
- package/dist/BrandSlot.svelte +80 -0
- package/dist/BrandSlot.svelte.d.ts +3 -0
- package/dist/BrandSlot.test.d.ts +1 -0
- package/dist/BrandSlot.test.js +71 -0
- package/dist/Shell.svelte +8 -10
- package/dist/actions/ActionPanel.svelte +143 -0
- package/dist/actions/ActionPanel.svelte.d.ts +13 -0
- package/dist/actions/ActionPanel.test.d.ts +1 -0
- package/dist/actions/ActionPanel.test.js +168 -0
- package/dist/actions/ContextMenu.svelte +17 -85
- package/dist/actions/MenuBar.svelte +57 -0
- package/dist/actions/MenuBar.svelte.d.ts +3 -0
- package/dist/actions/MenuBar.test.d.ts +1 -0
- package/dist/actions/MenuBar.test.js +109 -0
- package/dist/actions/MenuButton.svelte +150 -0
- package/dist/actions/MenuButton.svelte.d.ts +10 -0
- package/dist/actions/MenuButton.test.d.ts +1 -0
- package/dist/actions/MenuButton.test.js +125 -0
- package/dist/actions/contextMenuModel.d.ts +10 -0
- package/dist/actions/contextMenuModel.js +44 -9
- package/dist/actions/contextMenuModel.test.js +28 -1
- package/dist/actions/defaultMenuContainers.d.ts +2 -0
- package/dist/actions/defaultMenuContainers.js +7 -0
- package/dist/actions/defaultMenuContainers.test.d.ts +1 -0
- package/dist/actions/defaultMenuContainers.test.js +23 -0
- package/dist/actions/listeners.d.ts +4 -0
- package/dist/actions/listeners.js +77 -17
- package/dist/actions/listeners.test.js +50 -0
- package/dist/actions/menuBarModel.d.ts +42 -0
- package/dist/actions/menuBarModel.js +110 -0
- package/dist/actions/menuBarModel.test.d.ts +1 -0
- package/dist/actions/menuBarModel.test.js +158 -0
- package/dist/actions/palette-scorer.d.ts +4 -0
- package/dist/actions/palette-scorer.js +5 -0
- package/dist/actions/palette-scorer.test.js +9 -1
- package/dist/actions/paletteModel.d.ts +7 -1
- package/dist/actions/paletteModel.js +26 -1
- package/dist/actions/paletteModel.test.js +43 -0
- package/dist/actions/registry.js +5 -0
- package/dist/actions/registry.test.js +12 -0
- package/dist/actions/types.d.ts +48 -1
- package/dist/actions/types.test.d.ts +1 -0
- package/dist/actions/types.test.js +31 -0
- package/dist/apps/lifecycle.js +8 -1
- package/dist/apps/lifecycle.test.js +211 -1
- package/dist/apps/registry.svelte.d.ts +17 -1
- package/dist/apps/registry.svelte.js +20 -1
- package/dist/apps/types.d.ts +28 -0
- package/dist/assets/icons.svg +5 -0
- package/dist/documents/backends.d.ts +2 -0
- package/dist/documents/backends.js +55 -0
- package/dist/documents/backends.test.d.ts +1 -1
- package/dist/documents/backends.test.js +69 -1
- package/dist/documents/browse.d.ts +18 -0
- package/dist/documents/browse.js +13 -0
- package/dist/documents/browse.test.js +47 -0
- package/dist/documents/handle.js +23 -0
- package/dist/documents/handle.test.js +51 -0
- package/dist/documents/http-backend.d.ts +1 -0
- package/dist/documents/http-backend.js +19 -0
- package/dist/documents/http-backend.test.js +42 -0
- package/dist/documents/types.d.ts +29 -1
- package/dist/documents/types.js +4 -0
- package/dist/documents/types.test.d.ts +1 -0
- package/dist/documents/types.test.js +20 -0
- package/dist/layout/LayoutRenderer.browser.test.js +196 -0
- package/dist/layout/SlotContainer.svelte +13 -8
- package/dist/layout/SlotDropZone.svelte +44 -9
- 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
- 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
- package/dist/layout/ops.d.ts +10 -0
- package/dist/layout/ops.js +30 -2
- package/dist/layout/ops.test.js +111 -1
- package/dist/layout/slotHostPool.svelte.d.ts +7 -1
- package/dist/layout/slotHostPool.svelte.js +27 -8
- package/dist/layout/store.svelte.d.ts +27 -0
- package/dist/layout/store.svelte.js +63 -0
- package/dist/overlays/ConfirmDialog.svelte +138 -0
- package/dist/overlays/ConfirmDialog.svelte.d.ts +13 -0
- package/dist/overlays/ConfirmDialog.test.d.ts +1 -0
- package/dist/overlays/ConfirmDialog.test.js +123 -0
- package/dist/overlays/FloatFrame.svelte +2 -2
- package/dist/overlays/ToastItem.svelte +3 -3
- package/dist/primitives/base.css +5 -5
- package/dist/sh3core-shard/sh3coreShard.svelte.js +38 -4
- package/dist/shell-shard/shellShard.svelte.js +0 -4
- package/dist/tokens.css +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
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;
|