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.
- package/dist/actions/ActionPanel.svelte +49 -11
- package/dist/actions/ActionPanel.test.js +94 -6
- package/dist/actions/MenuButton.svelte +60 -14
- package/dist/actions/MenuButton.svelte.d.ts +3 -2
- package/dist/actions/MenuButton.test.js +38 -1
- package/dist/actions/contextMenuModel.d.ts +12 -1
- package/dist/actions/contextMenuModel.js +62 -13
- package/dist/actions/contextMenuModel.test.js +72 -20
- package/dist/actions/listeners.d.ts +6 -0
- package/dist/actions/listeners.js +124 -20
- package/dist/actions/listeners.test.js +98 -6
- package/dist/actions/menuBarModel.d.ts +14 -0
- package/dist/actions/menuBarModel.js +43 -0
- package/dist/actions/menuBarModel.test.js +75 -1
- 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/scope-helpers.d.ts +6 -0
- package/dist/actions/scope-helpers.js +10 -0
- package/dist/actions/scope-helpers.test.js +24 -1
- package/dist/actions/types.d.ts +41 -1
- package/dist/actions/types.test.d.ts +1 -0
- package/dist/actions/types.test.js +31 -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 +33 -0
- package/dist/documents/browse.js +20 -0
- package/dist/documents/browse.test.js +88 -0
- package/dist/documents/handle.js +26 -1
- package/dist/documents/handle.test.js +74 -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/sh3core-shard/sh3coreShard.svelte.js +18 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
142
|
-
|
|
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
|
|
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);
|
|
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);
|
|
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
|
|
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);
|
|
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
|
|
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;
|