sh3-core 0.10.5 → 0.11.2
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/Shell.svelte +12 -31
- package/dist/__test__/reset.js +6 -0
- package/dist/actions/CommandPalette.svelte +68 -0
- package/dist/actions/CommandPalette.svelte.d.ts +11 -0
- package/dist/actions/ContextMenu.svelte +97 -0
- package/dist/actions/ContextMenu.svelte.d.ts +9 -0
- package/dist/actions/bindings-store.d.ts +8 -0
- package/dist/actions/bindings-store.js +27 -0
- package/dist/actions/bindings-store.test.d.ts +1 -0
- package/dist/actions/bindings-store.test.js +25 -0
- package/dist/actions/bindings.d.ts +4 -0
- package/dist/actions/bindings.js +17 -0
- package/dist/actions/bindings.test.d.ts +1 -0
- package/dist/actions/bindings.test.js +30 -0
- package/dist/actions/contextMenuModel.d.ts +16 -0
- package/dist/actions/contextMenuModel.js +71 -0
- package/dist/actions/contextMenuModel.test.d.ts +1 -0
- package/dist/actions/contextMenuModel.test.js +44 -0
- package/dist/actions/dispatcher.svelte.d.ts +34 -0
- package/dist/actions/dispatcher.svelte.js +117 -0
- package/dist/actions/dispatcher.test.d.ts +1 -0
- package/dist/actions/dispatcher.test.js +155 -0
- package/dist/actions/listeners.d.ts +11 -0
- package/dist/actions/listeners.js +180 -0
- package/dist/actions/listeners.test.d.ts +1 -0
- package/dist/actions/listeners.test.js +149 -0
- package/dist/actions/palette-scorer.d.ts +11 -0
- package/dist/actions/palette-scorer.js +49 -0
- package/dist/actions/palette-scorer.test.d.ts +1 -0
- package/dist/actions/palette-scorer.test.js +40 -0
- package/dist/actions/paletteModel.d.ts +4 -0
- package/dist/actions/paletteModel.js +40 -0
- package/dist/actions/paletteModel.test.d.ts +1 -0
- package/dist/actions/paletteModel.test.js +33 -0
- package/dist/actions/registry.d.ts +10 -0
- package/dist/actions/registry.js +36 -0
- package/dist/actions/registry.test.d.ts +1 -0
- package/dist/actions/registry.test.js +49 -0
- package/dist/actions/selection.svelte.d.ts +8 -0
- package/dist/actions/selection.svelte.js +44 -0
- package/dist/actions/selection.test.d.ts +1 -0
- package/dist/actions/selection.test.js +51 -0
- package/dist/actions/shardContext.test.d.ts +1 -0
- package/dist/actions/shardContext.test.js +41 -0
- package/dist/actions/shellActions.test.d.ts +1 -0
- package/dist/actions/shellActions.test.js +22 -0
- package/dist/actions/shortcuts.d.ts +5 -0
- package/dist/actions/shortcuts.js +87 -0
- package/dist/actions/shortcuts.test.d.ts +1 -0
- package/dist/actions/shortcuts.test.js +49 -0
- package/dist/actions/state.svelte.d.ts +16 -0
- package/dist/actions/state.svelte.js +76 -0
- package/dist/actions/state.test.d.ts +1 -0
- package/dist/actions/state.test.js +40 -0
- package/dist/actions/syncMountedViewIds.test.d.ts +1 -0
- package/dist/actions/syncMountedViewIds.test.js +97 -0
- package/dist/actions/types.d.ts +56 -0
- package/dist/actions/types.js +7 -0
- package/dist/api.d.ts +2 -2
- package/dist/api.js +1 -1
- package/dist/apps/lifecycle.js +13 -3
- package/dist/createShell.js +4 -1
- package/dist/host.js +6 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/layout/inspection.d.ts +11 -1
- package/dist/layout/inspection.js +13 -1
- package/dist/layout/ops-locate.test.d.ts +1 -0
- package/dist/layout/ops-locate.test.js +103 -0
- package/dist/layout/ops.d.ts +8 -0
- package/dist/layout/ops.js +27 -0
- package/dist/layout/slotHostPool.svelte.js +24 -0
- package/dist/layout/slotHostPool.test.js +14 -0
- package/dist/layout/types.d.ts +7 -0
- package/dist/overlays/FloatFrame.svelte +23 -11
- package/dist/overlays/ModalFrame.svelte +9 -1
- package/dist/overlays/ModalFrame.svelte.d.ts +1 -0
- package/dist/overlays/__test__/DummyFrame.svelte +6 -0
- package/dist/overlays/__test__/DummyFrame.svelte.d.ts +6 -0
- package/dist/overlays/float.d.ts +6 -0
- package/dist/overlays/float.js +24 -9
- package/dist/overlays/float.test.js +175 -0
- package/dist/overlays/floatDismiss.d.ts +8 -0
- package/dist/overlays/floatDismiss.js +68 -0
- package/dist/overlays/modal.js +5 -1
- package/dist/overlays/modal.test.d.ts +1 -0
- package/dist/overlays/modal.test.js +55 -0
- package/dist/overlays/popup.d.ts +2 -0
- package/dist/overlays/popup.js +24 -4
- package/dist/overlays/popup.test.d.ts +1 -0
- package/dist/overlays/popup.test.js +95 -0
- package/dist/overlays/types.d.ts +17 -1
- package/dist/primitives/Button.svelte +144 -0
- package/dist/primitives/Button.svelte.d.ts +18 -0
- package/dist/primitives/icon-context.d.ts +15 -0
- package/dist/primitives/icon-context.js +29 -0
- package/dist/sh3core-shard/sh3coreShard.svelte.js +50 -0
- package/dist/shards/activate.svelte.js +14 -0
- package/dist/shards/types.d.ts +19 -0
- package/dist/shards/types.js +5 -4
- package/dist/shell-shard/locateSlot.test.d.ts +1 -0
- package/dist/shell-shard/locateSlot.test.js +101 -0
- package/dist/shell-shard/shellShard.svelte.d.ts +7 -0
- package/dist/shell-shard/shellShard.svelte.js +34 -1
- package/dist/shellRuntime.svelte.d.ts +19 -0
- package/dist/shellRuntime.svelte.js +30 -0
- package/dist/tokens.css +11 -1
- package/dist/verbs/types.d.ts +9 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/apps/terminal/manifest.d.ts +0 -8
- package/dist/apps/terminal/manifest.js +0 -14
- package/dist/apps/terminal/terminal-app.d.ts +0 -7
- package/dist/apps/terminal/terminal-app.js +0 -14
package/dist/Shell.svelte
CHANGED
|
@@ -19,9 +19,10 @@
|
|
|
19
19
|
import FloatLayer from './overlays/FloatLayer.svelte';
|
|
20
20
|
import type { OverlayLayer } from './overlays/types';
|
|
21
21
|
import { registerLayerRoot, unregisterLayerRoot } from './overlays/roots';
|
|
22
|
-
import { bindFloatStore, unbindFloatStore
|
|
23
|
-
import { returnToHome, isAdmin
|
|
22
|
+
import { bindFloatStore, unbindFloatStore } from './overlays/float';
|
|
23
|
+
import { returnToHome, isAdmin } from './api';
|
|
24
24
|
import { getActiveRoot, layoutStore } from './layout/store.svelte';
|
|
25
|
+
import { syncMountedViewIdsFromLayout } from './actions/state.svelte';
|
|
25
26
|
import { isAuthenticated, isLocalOwner, getUser, logout } from './auth/index';
|
|
26
27
|
import iconsUrl from './assets/icons.svg';
|
|
27
28
|
import GuestBanner from './auth/GuestBanner.svelte';
|
|
@@ -62,35 +63,6 @@
|
|
|
62
63
|
};
|
|
63
64
|
});
|
|
64
65
|
|
|
65
|
-
// The AZERTY `²` key (top-left, below Escape) opens the terminal view
|
|
66
|
-
// in a floating panel (DF3 float runtime). Admin-only because the shell
|
|
67
|
-
// shard is admin-gated. Provisional ergonomics shortcut; a real hotkey
|
|
68
|
-
// contribution API is DF1 in the roadmap.
|
|
69
|
-
//
|
|
70
|
-
// Bare key (no modifier) so it must be suppressed while the user is
|
|
71
|
-
// typing into an input/textarea/contenteditable, or the shortcut would
|
|
72
|
-
// eat `²` characters mid-word.
|
|
73
|
-
$effect(() => {
|
|
74
|
-
function onKeyDown(e: KeyboardEvent) {
|
|
75
|
-
if (e.key !== '²') return;
|
|
76
|
-
if (!elevated) return;
|
|
77
|
-
const target = e.target as HTMLElement | null;
|
|
78
|
-
if (target) {
|
|
79
|
-
const tag = target.tagName;
|
|
80
|
-
if (
|
|
81
|
-
tag === 'INPUT' ||
|
|
82
|
-
tag === 'TEXTAREA' ||
|
|
83
|
-
target.isContentEditable
|
|
84
|
-
) return;
|
|
85
|
-
}
|
|
86
|
-
e.preventDefault();
|
|
87
|
-
if (!focusView('shell:terminal'))
|
|
88
|
-
floatManager.open('shell:terminal', { title: 'Shell' });
|
|
89
|
-
}
|
|
90
|
-
window.addEventListener('keydown', onKeyDown);
|
|
91
|
-
return () => window.removeEventListener('keydown', onKeyDown);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
66
|
$effect(() => {
|
|
95
67
|
const tree = layoutStore.tree;
|
|
96
68
|
bindFloatStore(tree.floats, () => ({
|
|
@@ -100,6 +72,15 @@
|
|
|
100
72
|
return () => unbindFloatStore();
|
|
101
73
|
});
|
|
102
74
|
|
|
75
|
+
// Keep the actions dispatcher's `mountedViewIds` set in sync with the
|
|
76
|
+
// live layout tree, so `view:<viewId>` scope checks (context menu,
|
|
77
|
+
// palette, keyboard) see currently-mounted views. Deep Svelte 5
|
|
78
|
+
// reactivity means this re-runs on any tree mutation.
|
|
79
|
+
$effect(() => {
|
|
80
|
+
void layoutStore.tree;
|
|
81
|
+
syncMountedViewIdsFromLayout();
|
|
82
|
+
});
|
|
83
|
+
|
|
103
84
|
// Open the server-sent events stream for key revocations.
|
|
104
85
|
// Forwards server-side revocations to the local revocation bus so that
|
|
105
86
|
// onKeyRevoked fires on the owning shard even when the user revokes from
|
package/dist/__test__/reset.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Central reset orchestrator. Every new test file calls this in
|
|
3
3
|
// beforeEach. Order matters — see the in-line comments.
|
|
4
4
|
import { __resetFloatManagerForTest } from '../overlays/float';
|
|
5
|
+
import { __resetDismissRegistryForTest } from '../overlays/floatDismiss';
|
|
5
6
|
import { __resetPresetManagerForTest } from '../overlays/presets';
|
|
6
7
|
import { __resetDragStateForTest } from '../layout/drag.svelte';
|
|
7
8
|
import { __resetLayoutStoreForTest } from '../layout/store.svelte';
|
|
@@ -9,6 +10,8 @@ import { resetSlotHostPool } from '../layout/slotHostPool.svelte';
|
|
|
9
10
|
import { __resetViewRegistryForTest } from '../shards/registry';
|
|
10
11
|
import { __resetShardRegistryForTest } from '../shards/activate.svelte';
|
|
11
12
|
import { __resetAppRegistryForTest } from '../apps/registry.svelte';
|
|
13
|
+
import { __resetDispatcherStateForTest } from '../actions/state.svelte';
|
|
14
|
+
import { __resetSelectionForTest } from '../actions/selection.svelte';
|
|
12
15
|
/**
|
|
13
16
|
* Return the framework to a deterministic boot state for tests.
|
|
14
17
|
*
|
|
@@ -24,6 +27,7 @@ import { __resetAppRegistryForTest } from '../apps/registry.svelte';
|
|
|
24
27
|
*/
|
|
25
28
|
export function resetFramework() {
|
|
26
29
|
__resetFloatManagerForTest();
|
|
30
|
+
__resetDismissRegistryForTest();
|
|
27
31
|
__resetPresetManagerForTest();
|
|
28
32
|
__resetDragStateForTest();
|
|
29
33
|
__resetLayoutStoreForTest();
|
|
@@ -31,4 +35,6 @@ export function resetFramework() {
|
|
|
31
35
|
__resetViewRegistryForTest();
|
|
32
36
|
__resetShardRegistryForTest();
|
|
33
37
|
__resetAppRegistryForTest();
|
|
38
|
+
__resetDispatcherStateForTest();
|
|
39
|
+
__resetSelectionForTest();
|
|
34
40
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { rankPaletteEntries, type PaletteCandidate } from './palette-scorer';
|
|
3
|
+
|
|
4
|
+
let { candidates, recency, prefill = '', onInvoke, onClose }: {
|
|
5
|
+
candidates: PaletteCandidate[];
|
|
6
|
+
recency: string[];
|
|
7
|
+
prefill?: string;
|
|
8
|
+
onInvoke: (id: string) => void;
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
} = $props();
|
|
11
|
+
|
|
12
|
+
let query = $state((() => prefill)());
|
|
13
|
+
let cursor = $state(0);
|
|
14
|
+
const ranked = $derived(rankPaletteEntries(candidates, query, recency));
|
|
15
|
+
|
|
16
|
+
$effect(() => { if (cursor >= ranked.length) cursor = 0; });
|
|
17
|
+
|
|
18
|
+
function onKeydown(ev: KeyboardEvent) {
|
|
19
|
+
if (ev.key === 'ArrowDown') {
|
|
20
|
+
cursor = Math.min(cursor + 1, Math.max(0, ranked.length - 1));
|
|
21
|
+
ev.preventDefault();
|
|
22
|
+
} else if (ev.key === 'ArrowUp') {
|
|
23
|
+
cursor = Math.max(cursor - 1, 0);
|
|
24
|
+
ev.preventDefault();
|
|
25
|
+
} else if (ev.key === 'Enter') {
|
|
26
|
+
if (ranked[cursor]) {
|
|
27
|
+
onInvoke(ranked[cursor].id);
|
|
28
|
+
onClose();
|
|
29
|
+
}
|
|
30
|
+
ev.preventDefault();
|
|
31
|
+
} else if (ev.key === 'Escape') {
|
|
32
|
+
onClose();
|
|
33
|
+
ev.preventDefault();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<!-- svelte-ignore a11y_autofocus -->
|
|
39
|
+
<div class="sh3-palette" role="dialog" aria-label="Command palette" aria-modal="true" tabindex="-1" onkeydown={onKeydown}>
|
|
40
|
+
<input class="sh3-palette-input hell-base-input" bind:value={query} autofocus placeholder="Command…" />
|
|
41
|
+
<div class="sh3-palette-list" role="listbox">
|
|
42
|
+
{#each ranked as item, i (item.id)}
|
|
43
|
+
<button
|
|
44
|
+
class="sh3-palette-item"
|
|
45
|
+
class:sh3-palette-active={i === cursor}
|
|
46
|
+
role="option"
|
|
47
|
+
aria-selected={i === cursor}
|
|
48
|
+
onpointerenter={() => cursor = i}
|
|
49
|
+
onclick={() => { onInvoke(item.id); onClose(); }}
|
|
50
|
+
>
|
|
51
|
+
<span class="sh3-palette-label">{item.label}</span>
|
|
52
|
+
{#if item.scopeBadge}<span class="sh3-palette-badge">[{item.scopeBadge}]</span>{/if}
|
|
53
|
+
{#if item.shortcut}<span class="sh3-palette-shortcut">{item.shortcut}</span>{/if}
|
|
54
|
+
</button>
|
|
55
|
+
{/each}
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<style>
|
|
60
|
+
.sh3-palette { min-width: 480px; max-width: 640px; background: var(--shell-bg-elevated, #22232a); color: var(--shell-fg, #e4e6eb); border-radius: var(--shell-radius-md, 6px); box-shadow: 0 8px 24px rgba(0,0,0,.4); }
|
|
61
|
+
.sh3-palette-input { width: 100%; padding: 10px; background: none; border: 0; border-bottom: 1px solid rgba(255,255,255,.1); color: inherit; font: inherit; outline: none; }
|
|
62
|
+
.sh3-palette-list { max-height: 320px; overflow-y: auto; padding: 4px 0; }
|
|
63
|
+
.sh3-palette-item { display: flex; align-items: center; gap: 12px; width: 100%; padding: 6px 12px; background: none; border: 0; color: inherit; text-align: left; font: inherit; cursor: default; }
|
|
64
|
+
.sh3-palette-active { background: var(--shell-accent, #4a5); border-radius: 0; color: #fff; }
|
|
65
|
+
.sh3-palette-label { flex: 1; }
|
|
66
|
+
.sh3-palette-badge { opacity: .5; font-size: .85em; }
|
|
67
|
+
.sh3-palette-shortcut { opacity: .6; font-size: .9em; }
|
|
68
|
+
</style>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type PaletteCandidate } from './palette-scorer';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
candidates: PaletteCandidate[];
|
|
4
|
+
recency: string[];
|
|
5
|
+
prefill?: string;
|
|
6
|
+
onInvoke: (id: string) => void;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
};
|
|
9
|
+
declare const CommandPalette: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
10
|
+
type CommandPalette = ReturnType<typeof CommandPalette>;
|
|
11
|
+
export default CommandPalette;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* ContextMenu — popup-rendered, tier-grouped action list. Receives
|
|
4
|
+
* a pre-built model and an onInvoke callback. Dismiss and anchoring
|
|
5
|
+
* are handled by PopupFrame / popupManager — this component only
|
|
6
|
+
* renders the list and handles keyboard navigation within it.
|
|
7
|
+
*/
|
|
8
|
+
import type { ContextMenuModel, MenuItem } from './contextMenuModel';
|
|
9
|
+
|
|
10
|
+
let { model, onInvoke, onClose }: {
|
|
11
|
+
model: ContextMenuModel;
|
|
12
|
+
onInvoke: (id: string) => void;
|
|
13
|
+
onClose: () => void;
|
|
14
|
+
} = $props();
|
|
15
|
+
|
|
16
|
+
const flatItems: MenuItem[] = $derived(model.tiers.flatMap((t) => t.items));
|
|
17
|
+
let cursor = $state(0);
|
|
18
|
+
|
|
19
|
+
function onKeydown(ev: KeyboardEvent) {
|
|
20
|
+
if (ev.key === 'ArrowDown') {
|
|
21
|
+
cursor = (cursor + 1) % flatItems.length;
|
|
22
|
+
ev.preventDefault();
|
|
23
|
+
} else if (ev.key === 'ArrowUp') {
|
|
24
|
+
cursor = (cursor - 1 + flatItems.length) % flatItems.length;
|
|
25
|
+
ev.preventDefault();
|
|
26
|
+
} else if (ev.key === 'Enter') {
|
|
27
|
+
if (flatItems[cursor]) {
|
|
28
|
+
onInvoke(flatItems[cursor].id);
|
|
29
|
+
onClose();
|
|
30
|
+
}
|
|
31
|
+
ev.preventDefault();
|
|
32
|
+
} else if (ev.key === 'Escape') {
|
|
33
|
+
onClose();
|
|
34
|
+
ev.preventDefault();
|
|
35
|
+
} else if (ev.key.length === 1) {
|
|
36
|
+
// type-ahead: jump to next item whose label starts with the pressed character
|
|
37
|
+
const q = ev.key.toLowerCase();
|
|
38
|
+
const start = (cursor + 1) % flatItems.length;
|
|
39
|
+
for (let i = 0; i < flatItems.length; i++) {
|
|
40
|
+
const idx = (start + i) % flatItems.length;
|
|
41
|
+
if (flatItems[idx].label.toLowerCase().startsWith(q)) {
|
|
42
|
+
cursor = idx;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<!-- svelte-ignore a11y_autofocus -->
|
|
51
|
+
<div class="sh3-context-menu" role="menu" tabindex="0" onkeydown={onKeydown} autofocus>
|
|
52
|
+
{#each model.tiers as tier, tIdx (tier.tier)}
|
|
53
|
+
{#if tIdx > 0}<div class="sh3-ctx-sep" role="separator"></div>{/if}
|
|
54
|
+
{#each tier.items as item (item.id)}
|
|
55
|
+
{@const globalIdx = flatItems.indexOf(item)}
|
|
56
|
+
<button
|
|
57
|
+
class="sh3-ctx-item"
|
|
58
|
+
class:sh3-ctx-active={globalIdx === cursor}
|
|
59
|
+
role="menuitem"
|
|
60
|
+
onpointerenter={() => { cursor = globalIdx; }}
|
|
61
|
+
onclick={() => { onInvoke(item.id); onClose(); }}
|
|
62
|
+
>
|
|
63
|
+
<span class="sh3-ctx-label">{item.label}</span>
|
|
64
|
+
{#if item.shortcut}<span class="sh3-ctx-shortcut">{item.shortcut}</span>{/if}
|
|
65
|
+
</button>
|
|
66
|
+
{/each}
|
|
67
|
+
{/each}
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<style>
|
|
71
|
+
.sh3-context-menu {
|
|
72
|
+
min-width: 200px;
|
|
73
|
+
background: var(--shell-bg-elevated, #222);
|
|
74
|
+
color: var(--shell-fg, #eee);
|
|
75
|
+
border-radius: 4px;
|
|
76
|
+
padding: 4px 0;
|
|
77
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
|
78
|
+
outline: none;
|
|
79
|
+
}
|
|
80
|
+
.sh3-ctx-item {
|
|
81
|
+
display: flex;
|
|
82
|
+
align-items: center;
|
|
83
|
+
gap: 16px;
|
|
84
|
+
width: 100%;
|
|
85
|
+
padding: 4px 10px;
|
|
86
|
+
background: none;
|
|
87
|
+
border: 0;
|
|
88
|
+
text-align: left;
|
|
89
|
+
color: inherit;
|
|
90
|
+
cursor: default;
|
|
91
|
+
font: inherit;
|
|
92
|
+
}
|
|
93
|
+
.sh3-ctx-active { background: var(--shell-accent, #6ea8fe); color: var(--shell-fg,#e4e6eb); }
|
|
94
|
+
.sh3-ctx-label { flex: 1; }
|
|
95
|
+
.sh3-ctx-shortcut { opacity: 0.6; font-size: 0.9em; }
|
|
96
|
+
.sh3-ctx-sep { height: 1px; background: rgba(255, 255, 255, 0.1); margin: 4px 0; }
|
|
97
|
+
</style>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ContextMenuModel } from './contextMenuModel';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
model: ContextMenuModel;
|
|
4
|
+
onInvoke: (id: string) => void;
|
|
5
|
+
onClose: () => void;
|
|
6
|
+
};
|
|
7
|
+
declare const ContextMenu: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
8
|
+
type ContextMenu = ReturnType<typeof ContextMenu>;
|
|
9
|
+
export default ContextMenu;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
type PerApp = Record<string, string | null>;
|
|
2
|
+
type AllApps = Record<string, PerApp>;
|
|
3
|
+
export declare function __setBindingsZone(z: {
|
|
4
|
+
bindings: AllApps;
|
|
5
|
+
}): void;
|
|
6
|
+
export declare function loadUserBindings(appId: string): Promise<PerApp>;
|
|
7
|
+
export declare function saveUserBinding(appId: string, actionId: string, shortcut: string | null | undefined): Promise<void>;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* User-binding persistence backed by the __sh3core__ user-zone slot
|
|
3
|
+
* `bindings: { [appId]: { [actionId]: shortcut | null } }`.
|
|
4
|
+
*/
|
|
5
|
+
let zone = null;
|
|
6
|
+
export function __setBindingsZone(z) {
|
|
7
|
+
zone = z;
|
|
8
|
+
}
|
|
9
|
+
export async function loadUserBindings(appId) {
|
|
10
|
+
var _a;
|
|
11
|
+
if (!zone)
|
|
12
|
+
return {};
|
|
13
|
+
return Object.assign({}, ((_a = zone.bindings[appId]) !== null && _a !== void 0 ? _a : {}));
|
|
14
|
+
}
|
|
15
|
+
export async function saveUserBinding(appId, actionId, shortcut) {
|
|
16
|
+
var _a;
|
|
17
|
+
if (!zone)
|
|
18
|
+
return;
|
|
19
|
+
const forApp = Object.assign({}, ((_a = zone.bindings[appId]) !== null && _a !== void 0 ? _a : {}));
|
|
20
|
+
if (shortcut === undefined) {
|
|
21
|
+
delete forApp[actionId];
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
forApp[actionId] = shortcut;
|
|
25
|
+
}
|
|
26
|
+
zone.bindings = Object.assign(Object.assign({}, zone.bindings), { [appId]: forApp });
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { __setBindingsZone, loadUserBindings, saveUserBinding } from './bindings-store';
|
|
3
|
+
describe('bindings-store', () => {
|
|
4
|
+
let zone;
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
zone = { bindings: {} };
|
|
7
|
+
__setBindingsZone(zone);
|
|
8
|
+
});
|
|
9
|
+
it('load returns empty for unknown app', async () => {
|
|
10
|
+
expect(await loadUserBindings('app.x')).toEqual({});
|
|
11
|
+
});
|
|
12
|
+
it('save then load round-trips', async () => {
|
|
13
|
+
await saveUserBinding('app.x', 'act.save', 'Ctrl+Alt+S');
|
|
14
|
+
expect(await loadUserBindings('app.x')).toEqual({ 'act.save': 'Ctrl+Alt+S' });
|
|
15
|
+
});
|
|
16
|
+
it('null disables', async () => {
|
|
17
|
+
await saveUserBinding('app.x', 'act.save', null);
|
|
18
|
+
expect(await loadUserBindings('app.x')).toEqual({ 'act.save': null });
|
|
19
|
+
});
|
|
20
|
+
it('removing (undefined) deletes the key', async () => {
|
|
21
|
+
await saveUserBinding('app.x', 'act.save', 'Ctrl+S');
|
|
22
|
+
await saveUserBinding('app.x', 'act.save', undefined);
|
|
23
|
+
expect(await loadUserBindings('app.x')).toEqual({});
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type Platform } from './shortcuts';
|
|
2
|
+
import type { Action } from './types';
|
|
3
|
+
export type BindingOverrides = Record<string, string | null>;
|
|
4
|
+
export declare function effectiveShortcut(action: Action, overrides: BindingOverrides, platform?: Platform): string | null;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Effective-shortcut merge. User overrides live in the user state zone
|
|
3
|
+
* under `bindings.<appId>`; this module is pure — load/persist is done
|
|
4
|
+
* by the dispatcher layer.
|
|
5
|
+
*/
|
|
6
|
+
import { canonicalizeShortcut, resolveMod } from './shortcuts';
|
|
7
|
+
export function effectiveShortcut(action, overrides, platform = 'other') {
|
|
8
|
+
if (action.id in overrides) {
|
|
9
|
+
const o = overrides[action.id];
|
|
10
|
+
if (o === null)
|
|
11
|
+
return null;
|
|
12
|
+
return canonicalizeShortcut(o);
|
|
13
|
+
}
|
|
14
|
+
if (!action.defaultShortcut)
|
|
15
|
+
return null;
|
|
16
|
+
return resolveMod(action.defaultShortcut, platform);
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { effectiveShortcut } from './bindings';
|
|
3
|
+
const mkAction = (overrides = {}) => (Object.assign({ id: 'shard.save', label: 'Save', scope: 'home', run: () => { } }, overrides));
|
|
4
|
+
describe('effectiveShortcut', () => {
|
|
5
|
+
it('returns defaultShortcut when no override', () => {
|
|
6
|
+
const a = mkAction({ defaultShortcut: 'Ctrl+S' });
|
|
7
|
+
expect(effectiveShortcut(a, {})).toBe('Ctrl+S');
|
|
8
|
+
});
|
|
9
|
+
it('returns override when present', () => {
|
|
10
|
+
const a = mkAction({ defaultShortcut: 'Ctrl+S' });
|
|
11
|
+
const overrides = { 'shard.save': 'Ctrl+Shift+S' };
|
|
12
|
+
expect(effectiveShortcut(a, overrides)).toBe('Ctrl+Shift+S');
|
|
13
|
+
});
|
|
14
|
+
it('returns null when override is null (disabled)', () => {
|
|
15
|
+
const a = mkAction({ defaultShortcut: 'Ctrl+S' });
|
|
16
|
+
const overrides = { 'shard.save': null };
|
|
17
|
+
expect(effectiveShortcut(a, overrides)).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
it('returns null when no default and no override', () => {
|
|
20
|
+
const a = mkAction();
|
|
21
|
+
expect(effectiveShortcut(a, {})).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
it('applies Mod resolution to defaults but stores overrides literally', () => {
|
|
24
|
+
const a = mkAction({ defaultShortcut: 'Mod+K' });
|
|
25
|
+
expect(effectiveShortcut(a, {}, 'mac')).toBe('Meta+K');
|
|
26
|
+
expect(effectiveShortcut(a, {}, 'other')).toBe('Ctrl+K');
|
|
27
|
+
// Override stored literally — no Mod in user-typed binds.
|
|
28
|
+
expect(effectiveShortcut(a, { 'shard.save': 'Ctrl+K' }, 'mac')).toBe('Ctrl+K');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ActionEntry } from './registry';
|
|
2
|
+
import { type DispatcherState, type TierName } from './dispatcher.svelte';
|
|
3
|
+
export interface MenuItem {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
shortcut: string | null;
|
|
7
|
+
group: string;
|
|
8
|
+
}
|
|
9
|
+
export interface MenuTier {
|
|
10
|
+
tier: TierName;
|
|
11
|
+
items: MenuItem[];
|
|
12
|
+
}
|
|
13
|
+
export interface ContextMenuModel {
|
|
14
|
+
tiers: MenuTier[];
|
|
15
|
+
}
|
|
16
|
+
export declare function buildContextMenuModel(entries: ActionEntry[], state: DispatcherState): ContextMenuModel;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Pure model layer for the context menu: takes the action registry +
|
|
3
|
+
* dispatcher state, returns a tiered, deduplicated, shortcut-annotated
|
|
4
|
+
* item list the Svelte component renders without further logic.
|
|
5
|
+
*/
|
|
6
|
+
import { isScopeActive, TIER_ORDER, } from './dispatcher.svelte';
|
|
7
|
+
import { effectiveShortcut } from './bindings';
|
|
8
|
+
function normalizeScope(scope) {
|
|
9
|
+
return Array.isArray(scope) ? scope : [scope];
|
|
10
|
+
}
|
|
11
|
+
function scopeToTier(scope) {
|
|
12
|
+
if (scope === 'home')
|
|
13
|
+
return 'home';
|
|
14
|
+
if (scope === 'app')
|
|
15
|
+
return 'app';
|
|
16
|
+
if (typeof scope === 'string' && scope.startsWith('view:'))
|
|
17
|
+
return 'view';
|
|
18
|
+
if (typeof scope === 'string' && scope.startsWith('focus:'))
|
|
19
|
+
return 'focus';
|
|
20
|
+
return 'element';
|
|
21
|
+
}
|
|
22
|
+
function innermostActiveTier(scope, state, owner) {
|
|
23
|
+
// Build a map of tier → active scopes for this action, then walk
|
|
24
|
+
// TIER_ORDER from innermost → outermost to find the first active tier.
|
|
25
|
+
const scopes = normalizeScope(scope);
|
|
26
|
+
const tierBuckets = {};
|
|
27
|
+
for (const s of scopes) {
|
|
28
|
+
const tier = scopeToTier(s);
|
|
29
|
+
if (!tierBuckets[tier])
|
|
30
|
+
tierBuckets[tier] = [];
|
|
31
|
+
tierBuckets[tier].push(s);
|
|
32
|
+
}
|
|
33
|
+
for (const tier of TIER_ORDER) {
|
|
34
|
+
const bucket = tierBuckets[tier];
|
|
35
|
+
if (!bucket)
|
|
36
|
+
continue;
|
|
37
|
+
for (const s of bucket) {
|
|
38
|
+
if (isScopeActive(s, state, owner))
|
|
39
|
+
return tier;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
export function buildContextMenuModel(entries, state) {
|
|
45
|
+
var _a;
|
|
46
|
+
const byTier = {
|
|
47
|
+
element: [], focus: [], view: [], app: [], home: [],
|
|
48
|
+
};
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (!entry.action.contextItem)
|
|
52
|
+
continue;
|
|
53
|
+
if (seen.has(entry.action.id))
|
|
54
|
+
continue;
|
|
55
|
+
const tier = innermostActiveTier(entry.action.scope, state, entry.ownerShardId);
|
|
56
|
+
if (!tier)
|
|
57
|
+
continue;
|
|
58
|
+
seen.add(entry.action.id);
|
|
59
|
+
byTier[tier].push({
|
|
60
|
+
id: entry.action.id,
|
|
61
|
+
label: entry.action.label,
|
|
62
|
+
shortcut: effectiveShortcut(entry.action, state.bindings, state.platform),
|
|
63
|
+
group: (_a = entry.action.group) !== null && _a !== void 0 ? _a : '',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
tiers: TIER_ORDER
|
|
68
|
+
.map((tier) => ({ tier, items: byTier[tier] }))
|
|
69
|
+
.filter((t) => t.items.length > 0),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildContextMenuModel } from './contextMenuModel';
|
|
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('buildContextMenuModel', () => {
|
|
9
|
+
it('returns only actions with contextItem: true', () => {
|
|
10
|
+
const entries = [
|
|
11
|
+
mkEntry({ id: 'a', scope: 'home', contextItem: true, label: 'A' }),
|
|
12
|
+
mkEntry({ id: 'b', scope: 'home', label: 'B' }), // no contextItem
|
|
13
|
+
];
|
|
14
|
+
const model = buildContextMenuModel(entries, mkState());
|
|
15
|
+
expect(model.tiers.flatMap((t) => t.items).map((i) => i.id)).toEqual(['a']);
|
|
16
|
+
});
|
|
17
|
+
it('groups by scope tier in innermost-first order with separators', () => {
|
|
18
|
+
const state = mkState({
|
|
19
|
+
activeAppId: 'app.a',
|
|
20
|
+
activeAppRequiredShards: new Set(['shard.x']),
|
|
21
|
+
selection: { type: 'orb', ref: 1, ownerShardId: 'shard.x' },
|
|
22
|
+
});
|
|
23
|
+
const entries = [
|
|
24
|
+
mkEntry({ id: 'el', scope: { element: 'orb' }, contextItem: true, label: 'Dup' }),
|
|
25
|
+
mkEntry({ id: 'ap', scope: 'app', contextItem: true, label: 'Undo' }),
|
|
26
|
+
];
|
|
27
|
+
const model = buildContextMenuModel(entries, state);
|
|
28
|
+
expect(model.tiers.map((t) => t.tier)).toEqual(['element', 'app']);
|
|
29
|
+
expect(model.tiers[0].items[0].id).toBe('el');
|
|
30
|
+
});
|
|
31
|
+
it('de-duplicates multi-scope action to innermost tier', () => {
|
|
32
|
+
const state = mkState({
|
|
33
|
+
activeAppId: 'app.a',
|
|
34
|
+
activeAppRequiredShards: new Set(['shard.x']),
|
|
35
|
+
autostartShards: new Set(['shard.x']),
|
|
36
|
+
});
|
|
37
|
+
const entries = [
|
|
38
|
+
mkEntry({ id: 'p', scope: ['home', 'app'], contextItem: true, label: 'P' }),
|
|
39
|
+
];
|
|
40
|
+
const model = buildContextMenuModel(entries, state);
|
|
41
|
+
expect(model.tiers).toHaveLength(1);
|
|
42
|
+
expect(model.tiers[0].tier).toBe('app');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AtomicScope, Selection } from './types';
|
|
2
|
+
import type { ActionEntry } from './registry';
|
|
3
|
+
import type { Platform } from './shortcuts';
|
|
4
|
+
export interface DispatcherState {
|
|
5
|
+
activeAppId: string | null;
|
|
6
|
+
activeAppRequiredShards: Set<string>;
|
|
7
|
+
autostartShards: Set<string>;
|
|
8
|
+
mountedViewIds: Set<string>;
|
|
9
|
+
focusedViewId: string | null;
|
|
10
|
+
selection: Selection | null;
|
|
11
|
+
bindings: Record<string, string | null>;
|
|
12
|
+
platform: Platform;
|
|
13
|
+
}
|
|
14
|
+
export type TierName = 'element' | 'focus' | 'view' | 'app' | 'home';
|
|
15
|
+
export declare const TIER_ORDER: readonly TierName[];
|
|
16
|
+
export interface TierIndex {
|
|
17
|
+
element: Map<string, string>;
|
|
18
|
+
focus: Map<string, string>;
|
|
19
|
+
view: Map<string, string>;
|
|
20
|
+
app: Map<string, string>;
|
|
21
|
+
home: Map<string, string>;
|
|
22
|
+
}
|
|
23
|
+
export declare function isScopeActive(scope: AtomicScope, state: DispatcherState, ownerShardId?: string): boolean;
|
|
24
|
+
export declare function buildTierIndex(entries: ActionEntry[], state: DispatcherState): TierIndex;
|
|
25
|
+
export interface KeydownEnv {
|
|
26
|
+
target: EventTarget | null;
|
|
27
|
+
shortcut: string;
|
|
28
|
+
state: DispatcherState;
|
|
29
|
+
entries: ActionEntry[];
|
|
30
|
+
runAction(actionId: string, dispatchCtx: import('./types').ActionDispatchContext): void;
|
|
31
|
+
/** Optional chained-dispatch function passed through to the action's `run` ctx. Noop by default. */
|
|
32
|
+
dispatch?: (actionId: string) => void;
|
|
33
|
+
}
|
|
34
|
+
export declare function dispatchKeydown(env: KeydownEnv): string | null;
|