sh3-core 0.11.4 → 0.11.6
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 +105 -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 +80 -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 +104 -0
- package/dist/actions/MenuButton.svelte.d.ts +9 -0
- package/dist/actions/MenuButton.test.d.ts +1 -0
- package/dist/actions/MenuButton.test.js +88 -0
- 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/menuBarModel.d.ts +28 -0
- package/dist/actions/menuBarModel.js +67 -0
- package/dist/actions/menuBarModel.test.d.ts +1 -0
- package/dist/actions/menuBarModel.test.js +84 -0
- package/dist/actions/types.d.ts +8 -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/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 +20 -0
- 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 +1 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { mount, unmount, tick } from 'svelte';
|
|
3
|
+
import MenuBar from './MenuBar.svelte';
|
|
4
|
+
import { registerLayerRoot, unregisterLayerRoot } from '../overlays/roots';
|
|
5
|
+
import { __resetPopupManagerForTest } from '../overlays/popup';
|
|
6
|
+
import { setActiveApp, __resetDispatcherStateForTest, } from './state.svelte';
|
|
7
|
+
import { registerAction, __resetActionsRegistryForTest, } from './registry';
|
|
8
|
+
import { __resetContributionsForTest } from '../contributions/registry';
|
|
9
|
+
import { registerApp, __resetAppRegistryForTest, } from '../apps/registry.svelte';
|
|
10
|
+
let layerRoot;
|
|
11
|
+
let host;
|
|
12
|
+
let cmp = null;
|
|
13
|
+
function makeApp(id, menus) {
|
|
14
|
+
return {
|
|
15
|
+
manifest: {
|
|
16
|
+
id, label: id, version: '0.0.0',
|
|
17
|
+
requiredShards: ['shard.x'], layoutVersion: 1,
|
|
18
|
+
menus,
|
|
19
|
+
},
|
|
20
|
+
initialLayout: { type: 'leaf', viewId: 'shard.x:v' },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.stubGlobal('innerWidth', 2000);
|
|
25
|
+
vi.stubGlobal('innerHeight', 2000);
|
|
26
|
+
layerRoot = document.createElement('div');
|
|
27
|
+
layerRoot.style.position = 'relative';
|
|
28
|
+
document.body.appendChild(layerRoot);
|
|
29
|
+
registerLayerRoot('popup', layerRoot);
|
|
30
|
+
host = document.createElement('div');
|
|
31
|
+
document.body.appendChild(host);
|
|
32
|
+
});
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
if (cmp) {
|
|
35
|
+
unmount(cmp);
|
|
36
|
+
cmp = null;
|
|
37
|
+
}
|
|
38
|
+
host.remove();
|
|
39
|
+
__resetPopupManagerForTest();
|
|
40
|
+
unregisterLayerRoot('popup');
|
|
41
|
+
layerRoot.remove();
|
|
42
|
+
__resetActionsRegistryForTest();
|
|
43
|
+
__resetContributionsForTest();
|
|
44
|
+
__resetAppRegistryForTest();
|
|
45
|
+
__resetDispatcherStateForTest();
|
|
46
|
+
vi.unstubAllGlobals();
|
|
47
|
+
});
|
|
48
|
+
describe('MenuBar', () => {
|
|
49
|
+
it('renders an empty menubar when no app is active', async () => {
|
|
50
|
+
cmp = mount(MenuBar, { target: host, props: {} });
|
|
51
|
+
await tick();
|
|
52
|
+
// The wrapper always renders (it occupies a grid cell in the shell
|
|
53
|
+
// tabbar so user chrome stays anchored right). When no app is active
|
|
54
|
+
// it's just empty.
|
|
55
|
+
const bar = host.querySelector('.sh3-menubar');
|
|
56
|
+
expect(bar).not.toBeNull();
|
|
57
|
+
expect(bar.querySelectorAll('.sh3-menubar-button')).toHaveLength(0);
|
|
58
|
+
});
|
|
59
|
+
it('renders one button per declared container when an app is active', async () => {
|
|
60
|
+
registerApp(makeApp('app.a', [
|
|
61
|
+
{ id: 'file', label: 'File' },
|
|
62
|
+
{ id: 'help', label: 'Help' },
|
|
63
|
+
]));
|
|
64
|
+
registerAction({
|
|
65
|
+
id: 'open', label: 'Open', scope: 'app',
|
|
66
|
+
menuItem: 'file', run: () => { },
|
|
67
|
+
}, 'shard.x');
|
|
68
|
+
registerAction({
|
|
69
|
+
id: 'docs', label: 'Docs', scope: 'app',
|
|
70
|
+
menuItem: 'help', run: () => { },
|
|
71
|
+
}, 'shard.x');
|
|
72
|
+
setActiveApp('app.a', new Set(['shard.x']));
|
|
73
|
+
cmp = mount(MenuBar, { target: host, props: {} });
|
|
74
|
+
await tick();
|
|
75
|
+
const buttons = host.querySelectorAll('.sh3-menubar-button');
|
|
76
|
+
expect(buttons).toHaveLength(2);
|
|
77
|
+
expect(buttons[0].textContent).toContain('File');
|
|
78
|
+
expect(buttons[1].textContent).toContain('Help');
|
|
79
|
+
});
|
|
80
|
+
it('falls back to DEFAULT_MENU_CONTAINERS when manifest.menus is absent', async () => {
|
|
81
|
+
registerApp(makeApp('app.b'));
|
|
82
|
+
registerAction({
|
|
83
|
+
id: 'q', label: 'Quit', scope: 'app',
|
|
84
|
+
menuItem: 'file', run: () => { },
|
|
85
|
+
}, 'shard.x');
|
|
86
|
+
setActiveApp('app.b', new Set(['shard.x']));
|
|
87
|
+
cmp = mount(MenuBar, { target: host, props: {} });
|
|
88
|
+
await tick();
|
|
89
|
+
const buttons = host.querySelectorAll('.sh3-menubar-button');
|
|
90
|
+
expect(buttons).toHaveLength(1);
|
|
91
|
+
expect(buttons[0].textContent).toContain('File');
|
|
92
|
+
});
|
|
93
|
+
it('hides containers whose item list is empty', async () => {
|
|
94
|
+
registerApp(makeApp('app.c', [
|
|
95
|
+
{ id: 'file', label: 'File' },
|
|
96
|
+
{ id: 'edit', label: 'Edit' },
|
|
97
|
+
]));
|
|
98
|
+
registerAction({
|
|
99
|
+
id: 'open', label: 'Open', scope: 'app',
|
|
100
|
+
menuItem: 'file', run: () => { },
|
|
101
|
+
}, 'shard.x');
|
|
102
|
+
setActiveApp('app.c', new Set(['shard.x']));
|
|
103
|
+
cmp = mount(MenuBar, { target: host, props: {} });
|
|
104
|
+
await tick();
|
|
105
|
+
const buttons = host.querySelectorAll('.sh3-menubar-button');
|
|
106
|
+
expect(buttons).toHaveLength(1);
|
|
107
|
+
expect(buttons[0].textContent).toContain('File');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* MenuButton — one menu bar container. A button that opens a popup
|
|
4
|
+
* containing an ActionPanel rendered with this container's items.
|
|
5
|
+
* Items are passed in (the parent MenuBar resolves them via
|
|
6
|
+
* menuBarModel.resolveMenuItems).
|
|
7
|
+
*/
|
|
8
|
+
import { popupManager } from '../overlays/popup';
|
|
9
|
+
import ActionPanel, { type ActionPanelSection } from './ActionPanel.svelte';
|
|
10
|
+
import { listActions } from './registry';
|
|
11
|
+
import { getLiveDispatcherState } from './state.svelte';
|
|
12
|
+
import type { MenuBarItem } from './menuBarModel';
|
|
13
|
+
import type { MenuContainer } from '../apps/types';
|
|
14
|
+
|
|
15
|
+
let { container, items }: {
|
|
16
|
+
container: MenuContainer;
|
|
17
|
+
items: MenuBarItem[];
|
|
18
|
+
} = $props();
|
|
19
|
+
|
|
20
|
+
let buttonEl: HTMLButtonElement | undefined = $state();
|
|
21
|
+
|
|
22
|
+
function makeSections(list: MenuBarItem[]): ActionPanelSection[] {
|
|
23
|
+
const buckets = new Map<string, MenuBarItem[]>();
|
|
24
|
+
const order: string[] = [];
|
|
25
|
+
for (const item of list) {
|
|
26
|
+
const key = item.group || '';
|
|
27
|
+
if (!buckets.has(key)) {
|
|
28
|
+
buckets.set(key, []);
|
|
29
|
+
order.push(key);
|
|
30
|
+
}
|
|
31
|
+
buckets.get(key)!.push(item);
|
|
32
|
+
}
|
|
33
|
+
return order.map((k) => ({ id: `group:${k || '_default'}`, items: buckets.get(k)! }));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function openPopup() {
|
|
37
|
+
if (!buttonEl) return;
|
|
38
|
+
const state = getLiveDispatcherState();
|
|
39
|
+
popupManager.show(
|
|
40
|
+
ActionPanel,
|
|
41
|
+
{ anchor: buttonEl, placement: 'bottom-start' },
|
|
42
|
+
{
|
|
43
|
+
sections: makeSections(items),
|
|
44
|
+
onInvoke: (id: string) => {
|
|
45
|
+
const entry = listActions().find((e) => e.action.id === id);
|
|
46
|
+
if (!entry) return;
|
|
47
|
+
try {
|
|
48
|
+
void entry.action.run({
|
|
49
|
+
action: { id, label: entry.action.label },
|
|
50
|
+
appId: state.activeAppId,
|
|
51
|
+
viewId: state.focusedViewId ?? undefined,
|
|
52
|
+
selection: state.selection ?? undefined,
|
|
53
|
+
invokedVia: 'palette',
|
|
54
|
+
dispatch: () => {},
|
|
55
|
+
});
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error(`[sh3] menu-bar action "${id}" threw:`, err);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
onDismiss: () => popupManager.close(),
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const iconPosition = $derived(container.iconPosition ?? 'before');
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
class="sh3-menubar-button"
|
|
71
|
+
bind:this={buttonEl}
|
|
72
|
+
onclick={openPopup}
|
|
73
|
+
>
|
|
74
|
+
{#if container.icon && iconPosition === 'before'}
|
|
75
|
+
<span class="sh3-menubar-icon" data-icon={container.icon}></span>
|
|
76
|
+
{/if}
|
|
77
|
+
<span class="sh3-menubar-label">{container.label}</span>
|
|
78
|
+
{#if container.icon && iconPosition === 'after'}
|
|
79
|
+
<span class="sh3-menubar-icon" data-icon={container.icon}></span>
|
|
80
|
+
{/if}
|
|
81
|
+
</button>
|
|
82
|
+
|
|
83
|
+
<style>
|
|
84
|
+
.sh3-menubar-button {
|
|
85
|
+
display: inline-flex;
|
|
86
|
+
align-items: center;
|
|
87
|
+
gap: 6px;
|
|
88
|
+
padding: 0 var(--shell-pad-md);
|
|
89
|
+
height: 100%;
|
|
90
|
+
background: transparent;
|
|
91
|
+
color: var(--shell-fg);
|
|
92
|
+
border: 0;
|
|
93
|
+
font: inherit;
|
|
94
|
+
cursor: default;
|
|
95
|
+
}
|
|
96
|
+
.sh3-menubar-button:hover {
|
|
97
|
+
background: var(--shell-bg-elevated);
|
|
98
|
+
}
|
|
99
|
+
.sh3-menubar-icon {
|
|
100
|
+
width: 14px;
|
|
101
|
+
height: 14px;
|
|
102
|
+
display: inline-block;
|
|
103
|
+
}
|
|
104
|
+
</style>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { MenuBarItem } from './menuBarModel';
|
|
2
|
+
import type { MenuContainer } from '../apps/types';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
container: MenuContainer;
|
|
5
|
+
items: MenuBarItem[];
|
|
6
|
+
};
|
|
7
|
+
declare const MenuButton: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
8
|
+
type MenuButton = ReturnType<typeof MenuButton>;
|
|
9
|
+
export default MenuButton;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { mount, unmount, tick } from 'svelte';
|
|
3
|
+
import MenuButton from './MenuButton.svelte';
|
|
4
|
+
import { registerLayerRoot, unregisterLayerRoot } from '../overlays/roots';
|
|
5
|
+
import { __resetPopupManagerForTest } from '../overlays/popup';
|
|
6
|
+
let layerRoot;
|
|
7
|
+
let host;
|
|
8
|
+
let cmp = null;
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.stubGlobal('innerWidth', 2000);
|
|
11
|
+
vi.stubGlobal('innerHeight', 2000);
|
|
12
|
+
layerRoot = document.createElement('div');
|
|
13
|
+
layerRoot.style.position = 'relative';
|
|
14
|
+
document.body.appendChild(layerRoot);
|
|
15
|
+
registerLayerRoot('popup', layerRoot);
|
|
16
|
+
host = document.createElement('div');
|
|
17
|
+
document.body.appendChild(host);
|
|
18
|
+
});
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
if (cmp) {
|
|
21
|
+
unmount(cmp);
|
|
22
|
+
cmp = null;
|
|
23
|
+
}
|
|
24
|
+
host.remove();
|
|
25
|
+
__resetPopupManagerForTest();
|
|
26
|
+
unregisterLayerRoot('popup');
|
|
27
|
+
layerRoot.remove();
|
|
28
|
+
vi.unstubAllGlobals();
|
|
29
|
+
});
|
|
30
|
+
describe('MenuButton', () => {
|
|
31
|
+
it('renders the container label', () => {
|
|
32
|
+
cmp = mount(MenuButton, {
|
|
33
|
+
target: host,
|
|
34
|
+
props: {
|
|
35
|
+
container: { id: 'file', label: 'File' },
|
|
36
|
+
items: [],
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
expect(host.textContent).toContain('File');
|
|
40
|
+
});
|
|
41
|
+
it('renders icon before label when iconPosition is "before" (default)', () => {
|
|
42
|
+
cmp = mount(MenuButton, {
|
|
43
|
+
target: host,
|
|
44
|
+
props: {
|
|
45
|
+
container: { id: 'file', label: 'File', icon: 'folder' },
|
|
46
|
+
items: [],
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
const btn = host.querySelector('button');
|
|
50
|
+
const children = Array.from(btn.children);
|
|
51
|
+
const iconIdx = children.findIndex((c) => c.classList.contains('sh3-menubar-icon'));
|
|
52
|
+
const labelIdx = children.findIndex((c) => c.classList.contains('sh3-menubar-label'));
|
|
53
|
+
expect(iconIdx).toBeGreaterThanOrEqual(0);
|
|
54
|
+
expect(iconIdx).toBeLessThan(labelIdx);
|
|
55
|
+
});
|
|
56
|
+
it('renders icon after label when iconPosition is "after"', () => {
|
|
57
|
+
cmp = mount(MenuButton, {
|
|
58
|
+
target: host,
|
|
59
|
+
props: {
|
|
60
|
+
container: { id: 'file', label: 'File', icon: 'folder', iconPosition: 'after' },
|
|
61
|
+
items: [],
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
const btn = host.querySelector('button');
|
|
65
|
+
const children = Array.from(btn.children);
|
|
66
|
+
const iconIdx = children.findIndex((c) => c.classList.contains('sh3-menubar-icon'));
|
|
67
|
+
const labelIdx = children.findIndex((c) => c.classList.contains('sh3-menubar-label'));
|
|
68
|
+
expect(labelIdx).toBeLessThan(iconIdx);
|
|
69
|
+
});
|
|
70
|
+
it('clicking the button opens a popup with ActionPanel mounted', async () => {
|
|
71
|
+
cmp = mount(MenuButton, {
|
|
72
|
+
target: host,
|
|
73
|
+
props: {
|
|
74
|
+
container: { id: 'file', label: 'File' },
|
|
75
|
+
items: [
|
|
76
|
+
{ id: 'open', label: 'Open', shortcut: 'Ctrl+O', group: '', icon: undefined },
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
await tick();
|
|
81
|
+
const btn = host.querySelector('button');
|
|
82
|
+
btn.click();
|
|
83
|
+
await tick();
|
|
84
|
+
const items = layerRoot.querySelectorAll('[role="menuitem"]');
|
|
85
|
+
expect(items.length).toBe(1);
|
|
86
|
+
expect(items[0].textContent).toContain('Open');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { DEFAULT_MENU_CONTAINERS } from './defaultMenuContainers';
|
|
3
|
+
describe('DEFAULT_MENU_CONTAINERS', () => {
|
|
4
|
+
it('declares the canonical five containers in fixed order', () => {
|
|
5
|
+
expect(DEFAULT_MENU_CONTAINERS.map((c) => c.id))
|
|
6
|
+
.toEqual(['file', 'edit', 'view', 'window', 'help']);
|
|
7
|
+
});
|
|
8
|
+
it('declares matching labels', () => {
|
|
9
|
+
expect(DEFAULT_MENU_CONTAINERS.map((c) => c.label))
|
|
10
|
+
.toEqual(['File', 'Edit', 'View', 'Window', 'Help']);
|
|
11
|
+
});
|
|
12
|
+
it('declares no icons by default (text-only fallback)', () => {
|
|
13
|
+
for (const c of DEFAULT_MENU_CONTAINERS) {
|
|
14
|
+
expect(c.icon).toBeUndefined();
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
it('is frozen — mutating throws or is silently dropped', () => {
|
|
18
|
+
expect(() => {
|
|
19
|
+
// @ts-expect-error — testing runtime immutability of a readonly constant
|
|
20
|
+
DEFAULT_MENU_CONTAINERS.push({ id: 'x', label: 'X' });
|
|
21
|
+
}).toThrow();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Resolved container list for the currently-active app:
|
|
13
|
+
* - activeAppId == null → returns []
|
|
14
|
+
* - declared has entries → returns declared, sorted by `order`
|
|
15
|
+
* ascending then declaration order
|
|
16
|
+
* for ties / undefined
|
|
17
|
+
* - declared is undefined → returns a copy of DEFAULT_MENU_CONTAINERS
|
|
18
|
+
*
|
|
19
|
+
* Callers can render unconditionally; the empty-array case naturally
|
|
20
|
+
* suppresses the menu bar at home.
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveMenuContainers(activeAppId: string | null, declared: readonly MenuContainer[] | undefined): MenuContainer[];
|
|
23
|
+
/**
|
|
24
|
+
* Items targeting `containerId`, filtered by current scope activation
|
|
25
|
+
* and de-duplicated to the innermost active scope per action id (mirrors
|
|
26
|
+
* contextMenuModel).
|
|
27
|
+
*/
|
|
28
|
+
export declare function resolveMenuItems(entries: readonly ActionEntry[], state: DispatcherState, containerId: string): MenuBarItem[];
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
/**
|
|
11
|
+
* Resolved container list for the currently-active app:
|
|
12
|
+
* - activeAppId == null → returns []
|
|
13
|
+
* - declared has entries → returns declared, sorted by `order`
|
|
14
|
+
* ascending then declaration order
|
|
15
|
+
* for ties / undefined
|
|
16
|
+
* - declared is undefined → returns a copy of DEFAULT_MENU_CONTAINERS
|
|
17
|
+
*
|
|
18
|
+
* Callers can render unconditionally; the empty-array case naturally
|
|
19
|
+
* suppresses the menu bar at home.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveMenuContainers(activeAppId, declared) {
|
|
22
|
+
if (activeAppId == null)
|
|
23
|
+
return [];
|
|
24
|
+
if (declared == null)
|
|
25
|
+
return DEFAULT_MENU_CONTAINERS.slice();
|
|
26
|
+
const indexed = declared.map((c, i) => ({ c, i }));
|
|
27
|
+
indexed.sort((a, b) => {
|
|
28
|
+
const ao = a.c.order;
|
|
29
|
+
const bo = b.c.order;
|
|
30
|
+
if (ao != null && bo != null)
|
|
31
|
+
return ao - bo || a.i - b.i;
|
|
32
|
+
if (ao != null)
|
|
33
|
+
return -1;
|
|
34
|
+
if (bo != null)
|
|
35
|
+
return 1;
|
|
36
|
+
return a.i - b.i;
|
|
37
|
+
});
|
|
38
|
+
return indexed.map((x) => x.c);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Items targeting `containerId`, filtered by current scope activation
|
|
42
|
+
* and de-duplicated to the innermost active scope per action id (mirrors
|
|
43
|
+
* contextMenuModel).
|
|
44
|
+
*/
|
|
45
|
+
export function resolveMenuItems(entries, state, containerId) {
|
|
46
|
+
var _a;
|
|
47
|
+
const out = [];
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
if (entry.action.menuItem !== containerId)
|
|
51
|
+
continue;
|
|
52
|
+
if (seen.has(entry.action.id))
|
|
53
|
+
continue;
|
|
54
|
+
const winning = innermostActiveScope(entry.action.scope, state, entry.ownerShardId);
|
|
55
|
+
if (!winning)
|
|
56
|
+
continue;
|
|
57
|
+
seen.add(entry.action.id);
|
|
58
|
+
out.push({
|
|
59
|
+
id: entry.action.id,
|
|
60
|
+
label: entry.action.label,
|
|
61
|
+
shortcut: effectiveShortcut(entry.action, state.bindings, state.platform),
|
|
62
|
+
group: (_a = entry.action.group) !== null && _a !== void 0 ? _a : '',
|
|
63
|
+
icon: entry.action.icon,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { resolveMenuContainers, resolveMenuItems, } 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
|
+
});
|
package/dist/actions/types.d.ts
CHANGED
|
@@ -8,6 +8,14 @@ export interface Action {
|
|
|
8
8
|
scope: ActionScope;
|
|
9
9
|
contextItem?: boolean;
|
|
10
10
|
paletteItem?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Optional menu container id. When set and the active app's declared
|
|
13
|
+
* (or canonical fallback) menu list contains this id, the action
|
|
14
|
+
* appears in that container's dropdown. Orphaned values render
|
|
15
|
+
* nowhere in the menu bar; the action remains reachable via
|
|
16
|
+
* palette/hotkey/context menu.
|
|
17
|
+
*/
|
|
18
|
+
menuItem?: string;
|
|
11
19
|
defaultShortcut?: string;
|
|
12
20
|
icon?: string;
|
|
13
21
|
group?: string;
|
package/dist/apps/lifecycle.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { createStateZones } from '../state/zones.svelte';
|
|
15
15
|
import { activateShard, deactivateShard, getShardContext, registeredShards, } from '../shards/activate.svelte';
|
|
16
16
|
import { attachApp, acquireAppSlotHolds, detachApp, switchToApp, switchToHome, } from '../layout/store.svelte';
|
|
17
|
-
import { activeApp, getRegisteredApp, registeredApps } from './registry.svelte';
|
|
17
|
+
import { activeApp, breadcrumbApp, getRegisteredApp, registeredApps } from './registry.svelte';
|
|
18
18
|
import { createZoneManager } from '../state/manage';
|
|
19
19
|
import { PERMISSION_STATE_MANAGE } from '../state/types';
|
|
20
20
|
import { setActiveApp, setUserBindings } from '../actions/state.svelte';
|
|
@@ -96,6 +96,7 @@ export async function launchApp(id) {
|
|
|
96
96
|
switchToApp();
|
|
97
97
|
void ((_c = app.onAppReady) === null || _c === void 0 ? void 0 : _c.call(app, getOrCreateAppContext(id)));
|
|
98
98
|
writeLastApp(id);
|
|
99
|
+
breadcrumbApp.id = id;
|
|
99
100
|
setActiveApp(id, new Set((_d = app.manifest.requiredShards) !== null && _d !== void 0 ? _d : []));
|
|
100
101
|
void loadUserBindings(id).then(setUserBindings);
|
|
101
102
|
return;
|
|
@@ -135,6 +136,7 @@ export async function launchApp(id) {
|
|
|
135
136
|
switchToApp();
|
|
136
137
|
void ((_g = app.onAppReady) === null || _g === void 0 ? void 0 : _g.call(app, getOrCreateAppContext(id)));
|
|
137
138
|
writeLastApp(id);
|
|
139
|
+
breadcrumbApp.id = id;
|
|
138
140
|
}
|
|
139
141
|
// ---------- unload --------------------------------------------------------
|
|
140
142
|
/**
|
|
@@ -226,6 +228,11 @@ export async function returnToHome() {
|
|
|
226
228
|
return false;
|
|
227
229
|
}
|
|
228
230
|
switchToHome();
|
|
231
|
+
// Mirror unregisterApp: clear the dispatcher's active-app pointer so
|
|
232
|
+
// 'app'-scope actions become inactive on home. Without this, any action
|
|
233
|
+
// registered with scope: ['app'] keeps appearing in the palette while
|
|
234
|
+
// the user is on home.
|
|
235
|
+
setActiveApp(null, new Set());
|
|
229
236
|
writeLastApp(null);
|
|
230
237
|
return true;
|
|
231
238
|
}
|