sh3-core 0.10.5 → 0.11.4
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 +13 -0
- package/dist/actions/bindings.js +33 -0
- package/dist/actions/bindings.test.d.ts +1 -0
- package/dist/actions/bindings.test.js +52 -0
- package/dist/actions/contextMenuModel.d.ts +16 -0
- package/dist/actions/contextMenuModel.js +36 -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 +104 -0
- package/dist/actions/dispatcher.test.d.ts +1 -0
- package/dist/actions/dispatcher.test.js +155 -0
- package/dist/actions/listActive.d.ts +4 -0
- package/dist/actions/listActive.js +42 -0
- package/dist/actions/listActive.test.d.ts +1 -0
- package/dist/actions/listActive.test.js +86 -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 +29 -0
- package/dist/actions/paletteModel.test.d.ts +1 -0
- package/dist/actions/paletteModel.test.js +49 -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/scope-helpers.d.ts +11 -0
- package/dist/actions/scope-helpers.js +51 -0
- package/dist/actions/scope-helpers.test.d.ts +1 -0
- package/dist/actions/scope-helpers.test.js +62 -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 +72 -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 +28 -0
- package/dist/actions/state.svelte.js +112 -0
- package/dist/actions/state.test.d.ts +1 -0
- package/dist/actions/state.test.js +65 -0
- package/dist/actions/syncMountedViewIds.test.d.ts +1 -0
- package/dist/actions/syncMountedViewIds.test.js +97 -0
- package/dist/actions/types.d.ts +97 -0
- package/dist/actions/types.js +7 -0
- package/dist/api.d.ts +7 -2
- package/dist/api.js +7 -1
- package/dist/apps/lifecycle.js +13 -3
- package/dist/assets/favicon.png +0 -0
- package/dist/assets/favicon.svg +5 -0
- package/dist/color/api.d.ts +38 -0
- package/dist/color/api.js +10 -0
- package/dist/color/native-fallback.test.d.ts +1 -0
- package/dist/color/native-fallback.test.js +43 -0
- package/dist/color/primitive.d.ts +2 -0
- package/dist/color/primitive.js +40 -0
- package/dist/color/primitive.test.d.ts +1 -0
- package/dist/color/primitive.test.js +42 -0
- package/dist/color/shell-api.d.ts +2 -0
- package/dist/color/shell-api.js +11 -0
- package/dist/createShell.js +4 -1
- package/dist/host.js +6 -3
- 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 +39 -0
- package/dist/shellRuntime.svelte.js +45 -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
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Pure dispatcher — scope activation + tier index build. DOM wiring
|
|
3
|
+
* (keydown, focus tracking, user zone subscribe) lives in listeners.ts.
|
|
4
|
+
* This module exposes testable state transitions; listeners feed it
|
|
5
|
+
* state snapshots.
|
|
6
|
+
*/
|
|
7
|
+
import { effectiveShortcut } from './bindings';
|
|
8
|
+
import { scopeToTier, normalizeScope } from './scope-helpers';
|
|
9
|
+
export const TIER_ORDER = ['element', 'focus', 'view', 'app', 'home'];
|
|
10
|
+
export function isScopeActive(scope, state, ownerShardId) {
|
|
11
|
+
if (scope === 'home') {
|
|
12
|
+
return state.activeAppId === null;
|
|
13
|
+
}
|
|
14
|
+
if (scope === 'app') {
|
|
15
|
+
if (state.activeAppId === null)
|
|
16
|
+
return false;
|
|
17
|
+
if (!ownerShardId)
|
|
18
|
+
return false;
|
|
19
|
+
return (state.activeAppRequiredShards.has(ownerShardId) ||
|
|
20
|
+
state.autostartShards.has(ownerShardId));
|
|
21
|
+
}
|
|
22
|
+
if (typeof scope === 'string') {
|
|
23
|
+
if (scope.startsWith('view:')) {
|
|
24
|
+
return state.mountedViewIds.has(scope.slice(5));
|
|
25
|
+
}
|
|
26
|
+
if (scope.startsWith('focus:')) {
|
|
27
|
+
return state.focusedViewId === scope.slice(6);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (typeof scope === 'object' && 'element' in scope) {
|
|
31
|
+
return state.selection !== null && state.selection.type === scope.element;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
export function buildTierIndex(entries, state) {
|
|
36
|
+
const idx = {
|
|
37
|
+
element: new Map(),
|
|
38
|
+
focus: new Map(),
|
|
39
|
+
view: new Map(),
|
|
40
|
+
app: new Map(),
|
|
41
|
+
home: new Map(),
|
|
42
|
+
};
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
const shortcut = effectiveShortcut(entry.action, state.bindings, state.platform);
|
|
45
|
+
if (!shortcut)
|
|
46
|
+
continue;
|
|
47
|
+
for (const scope of normalizeScope(entry.action.scope)) {
|
|
48
|
+
if (!isScopeActive(scope, state, entry.ownerShardId))
|
|
49
|
+
continue;
|
|
50
|
+
const tier = scopeToTier(scope);
|
|
51
|
+
const existing = idx[tier].get(shortcut);
|
|
52
|
+
if (existing && existing !== entry.action.id) {
|
|
53
|
+
console.warn(`[sh3] Shortcut "${shortcut}" in tier "${tier}" is bound to both ` +
|
|
54
|
+
`"${existing}" and "${entry.action.id}"; first-registered wins. ` +
|
|
55
|
+
`Users can rebind to resolve.`);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
idx[tier].set(shortcut, entry.action.id);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return idx;
|
|
62
|
+
}
|
|
63
|
+
function isTextInput(target) {
|
|
64
|
+
if (!(target instanceof Element))
|
|
65
|
+
return false;
|
|
66
|
+
const tag = target.tagName;
|
|
67
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA')
|
|
68
|
+
return true;
|
|
69
|
+
const ce = target.contentEditable;
|
|
70
|
+
if (ce === 'true' || ce === 'plaintext-only')
|
|
71
|
+
return true;
|
|
72
|
+
// Fallback: check attribute directly (happy-dom doesn't reflect the property correctly)
|
|
73
|
+
const attr = target.getAttribute('contenteditable');
|
|
74
|
+
if (attr === 'true' || attr === '' || attr === 'plaintext-only')
|
|
75
|
+
return true;
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
export function dispatchKeydown(env) {
|
|
79
|
+
var _a, _b, _c;
|
|
80
|
+
const idx = buildTierIndex(env.entries, env.state);
|
|
81
|
+
const actionById = new Map(env.entries.map((e) => [e.action.id, e]));
|
|
82
|
+
for (const tier of TIER_ORDER) {
|
|
83
|
+
const id = idx[tier].get(env.shortcut);
|
|
84
|
+
if (!id)
|
|
85
|
+
continue;
|
|
86
|
+
const entry = actionById.get(id);
|
|
87
|
+
if (!entry)
|
|
88
|
+
continue;
|
|
89
|
+
// Input-target blocking
|
|
90
|
+
if (isTextInput(env.target) && !entry.action.allowInInputs) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
env.runAction(id, {
|
|
94
|
+
action: { id: entry.action.id, label: entry.action.label },
|
|
95
|
+
appId: env.state.activeAppId,
|
|
96
|
+
viewId: (_a = env.state.focusedViewId) !== null && _a !== void 0 ? _a : undefined,
|
|
97
|
+
selection: (_b = env.state.selection) !== null && _b !== void 0 ? _b : undefined,
|
|
98
|
+
invokedVia: 'keyboard',
|
|
99
|
+
dispatch: (_c = env.dispatch) !== null && _c !== void 0 ? _c : (() => { }),
|
|
100
|
+
});
|
|
101
|
+
return id;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { buildTierIndex, isScopeActive, dispatchKeydown, } from './dispatcher.svelte';
|
|
3
|
+
const mkEntry = (action, owner = 'shard.x') => ({
|
|
4
|
+
ownerShardId: owner,
|
|
5
|
+
action: Object.assign({ id: 'a', label: 'A', scope: 'home', run: () => { } }, action),
|
|
6
|
+
});
|
|
7
|
+
const mkState = (overrides = {}) => (Object.assign({ activeAppId: null, activeAppRequiredShards: new Set(), autostartShards: new Set(), mountedViewIds: new Set(), focusedViewId: null, selection: null, bindings: {}, platform: 'other' }, overrides));
|
|
8
|
+
describe('isScopeActive', () => {
|
|
9
|
+
it('home active iff no app', () => {
|
|
10
|
+
expect(isScopeActive('home', mkState())).toBe(true);
|
|
11
|
+
expect(isScopeActive('home', mkState({ activeAppId: 'app.a' }))).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
it('app active when app is loaded and declaring shard is required', () => {
|
|
14
|
+
const state = mkState({
|
|
15
|
+
activeAppId: 'app.a',
|
|
16
|
+
activeAppRequiredShards: new Set(['shard.x']),
|
|
17
|
+
});
|
|
18
|
+
expect(isScopeActive('app', state, 'shard.x')).toBe(true);
|
|
19
|
+
expect(isScopeActive('app', state, 'shard.y')).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
it('app active for autostart shards regardless of requiredShards', () => {
|
|
22
|
+
const state = mkState({
|
|
23
|
+
activeAppId: 'app.a',
|
|
24
|
+
activeAppRequiredShards: new Set(),
|
|
25
|
+
autostartShards: new Set(['__sh3core__']),
|
|
26
|
+
});
|
|
27
|
+
expect(isScopeActive('app', state, '__sh3core__')).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
it('view:X active iff X is mounted', () => {
|
|
30
|
+
const state = mkState({ mountedViewIds: new Set(['editor']) });
|
|
31
|
+
expect(isScopeActive('view:editor', state)).toBe(true);
|
|
32
|
+
expect(isScopeActive('view:terminal', state)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
it('focus:X active iff X is the focused view', () => {
|
|
35
|
+
const state = mkState({ focusedViewId: 'editor' });
|
|
36
|
+
expect(isScopeActive('focus:editor', state)).toBe(true);
|
|
37
|
+
expect(isScopeActive('focus:terminal', state)).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
it('element matches selection type', () => {
|
|
40
|
+
const state = mkState({ selection: { type: 'orb', ref: 1, ownerShardId: 'shard.x' } });
|
|
41
|
+
expect(isScopeActive({ element: 'orb' }, state)).toBe(true);
|
|
42
|
+
expect(isScopeActive({ element: 'node' }, state)).toBe(false);
|
|
43
|
+
expect(isScopeActive({ element: 'orb' }, mkState())).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe('buildTierIndex', () => {
|
|
47
|
+
it('places a home-scope action in the home tier with its shortcut', () => {
|
|
48
|
+
const entries = [
|
|
49
|
+
mkEntry({ id: 'x', scope: 'home', defaultShortcut: 'Ctrl+S' }),
|
|
50
|
+
];
|
|
51
|
+
const idx = buildTierIndex(entries, mkState());
|
|
52
|
+
expect(idx.home.get('Ctrl+S')).toBe('x');
|
|
53
|
+
expect(idx.app.size).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
it('ignores actions whose scope is inactive', () => {
|
|
56
|
+
const entries = [
|
|
57
|
+
mkEntry({ id: 'x', scope: 'app', defaultShortcut: 'Ctrl+S' }),
|
|
58
|
+
];
|
|
59
|
+
// No app loaded → app scope inactive.
|
|
60
|
+
const idx = buildTierIndex(entries, mkState());
|
|
61
|
+
expect(idx.app.size).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
it('places array-scope action in every matching tier', () => {
|
|
64
|
+
const entries = [
|
|
65
|
+
mkEntry({ id: 'p', scope: ['home', 'app'], defaultShortcut: 'Mod+K' }),
|
|
66
|
+
];
|
|
67
|
+
const state = mkState({
|
|
68
|
+
activeAppId: null, // home active
|
|
69
|
+
autostartShards: new Set(['shard.x']),
|
|
70
|
+
});
|
|
71
|
+
const idx = buildTierIndex(entries, state);
|
|
72
|
+
// Home is active; app is not (no app).
|
|
73
|
+
expect(idx.home.get('Ctrl+K')).toBe('p');
|
|
74
|
+
expect(idx.app.size).toBe(0);
|
|
75
|
+
});
|
|
76
|
+
it('applies user override to the indexed shortcut', () => {
|
|
77
|
+
const entries = [
|
|
78
|
+
mkEntry({ id: 'shard.x.save', scope: 'home', defaultShortcut: 'Ctrl+S' }),
|
|
79
|
+
];
|
|
80
|
+
const idx = buildTierIndex(entries, mkState({
|
|
81
|
+
bindings: { 'shard.x.save': 'Ctrl+Shift+S' },
|
|
82
|
+
}));
|
|
83
|
+
expect(idx.home.has('Ctrl+S')).toBe(false);
|
|
84
|
+
expect(idx.home.get('Ctrl+Shift+S')).toBe('shard.x.save');
|
|
85
|
+
});
|
|
86
|
+
it('null override disables the shortcut entirely', () => {
|
|
87
|
+
const entries = [
|
|
88
|
+
mkEntry({ id: 'a', scope: 'home', defaultShortcut: 'Ctrl+S' }),
|
|
89
|
+
];
|
|
90
|
+
const idx = buildTierIndex(entries, mkState({ bindings: { a: null } }));
|
|
91
|
+
expect(idx.home.size).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
it('first-registered wins and warns on same-tier shortcut conflict', () => {
|
|
94
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
95
|
+
const entries = [
|
|
96
|
+
mkEntry({ id: 'first', scope: 'home', defaultShortcut: 'Ctrl+S' }),
|
|
97
|
+
mkEntry({ id: 'second', scope: 'home', defaultShortcut: 'Ctrl+S' }),
|
|
98
|
+
];
|
|
99
|
+
const idx = buildTierIndex(entries, mkState());
|
|
100
|
+
expect(idx.home.get('Ctrl+S')).toBe('first');
|
|
101
|
+
expect(warn).toHaveBeenCalled();
|
|
102
|
+
warn.mockRestore();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
const mkEnv = (overrides = {}) => (Object.assign({ target: null, shortcut: 'Ctrl+S', state: mkState(), entries: [], runAction: vi.fn() }, overrides));
|
|
106
|
+
describe('dispatchKeydown', () => {
|
|
107
|
+
it('returns null when no match', () => {
|
|
108
|
+
const env = mkEnv();
|
|
109
|
+
const result = dispatchKeydown(env);
|
|
110
|
+
expect(result).toBeNull();
|
|
111
|
+
expect(env.runAction).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
it('matches in innermost tier and runs the action', () => {
|
|
114
|
+
const runAction = vi.fn();
|
|
115
|
+
const entries = [mkEntry({ id: 'home.x', scope: 'home', defaultShortcut: 'Ctrl+S' })];
|
|
116
|
+
const env = mkEnv({ entries, runAction });
|
|
117
|
+
const result = dispatchKeydown(env);
|
|
118
|
+
expect(result).toBe('home.x');
|
|
119
|
+
expect(runAction).toHaveBeenCalledWith('home.x', expect.objectContaining({ invokedVia: 'keyboard' }));
|
|
120
|
+
});
|
|
121
|
+
it('element tier beats app tier on same shortcut', () => {
|
|
122
|
+
const runAction = vi.fn();
|
|
123
|
+
const entries = [
|
|
124
|
+
mkEntry({ id: 'el', scope: { element: 'orb' }, defaultShortcut: 'Ctrl+S' }),
|
|
125
|
+
mkEntry({ id: 'app', scope: 'app', defaultShortcut: 'Ctrl+S' }, '__sh3core__'),
|
|
126
|
+
];
|
|
127
|
+
const state = mkState({
|
|
128
|
+
activeAppId: 'a',
|
|
129
|
+
activeAppRequiredShards: new Set(),
|
|
130
|
+
autostartShards: new Set(['__sh3core__']),
|
|
131
|
+
selection: { type: 'orb', ref: 1, ownerShardId: 's' },
|
|
132
|
+
});
|
|
133
|
+
const result = dispatchKeydown(mkEnv({ entries, runAction, state }));
|
|
134
|
+
expect(result).toBe('el');
|
|
135
|
+
});
|
|
136
|
+
it('blocks when target is <input> and allowInInputs is false', () => {
|
|
137
|
+
const inp = document.createElement('input');
|
|
138
|
+
const entries = [mkEntry({ id: 'x', scope: 'home', defaultShortcut: 'Ctrl+S' })];
|
|
139
|
+
const result = dispatchKeydown(mkEnv({ entries, target: inp }));
|
|
140
|
+
expect(result).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
it('fires when target is <input> and allowInInputs is true', () => {
|
|
143
|
+
const inp = document.createElement('input');
|
|
144
|
+
const entries = [mkEntry({ id: 'x', scope: 'home', defaultShortcut: 'Ctrl+S', allowInInputs: true })];
|
|
145
|
+
const result = dispatchKeydown(mkEnv({ entries, target: inp }));
|
|
146
|
+
expect(result).toBe('x');
|
|
147
|
+
});
|
|
148
|
+
it('treats contenteditable like <input>', () => {
|
|
149
|
+
const div = document.createElement('div');
|
|
150
|
+
div.setAttribute('contenteditable', 'true');
|
|
151
|
+
const entries = [mkEntry({ id: 'x', scope: 'home', defaultShortcut: 'Ctrl+S' })];
|
|
152
|
+
const result = dispatchKeydown(mkEnv({ entries, target: div }));
|
|
153
|
+
expect(result).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ActionEntry } from './registry';
|
|
2
|
+
import { type DispatcherState } from './dispatcher.svelte';
|
|
3
|
+
import type { ActiveActionDescriptor } from './types';
|
|
4
|
+
export declare function listActiveFromEntries(entries: ActionEntry[], state: DispatcherState): ActiveActionDescriptor[];
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Pure read-side producer for `shell.actions.listActive()`.
|
|
3
|
+
*
|
|
4
|
+
* Given a snapshot of the action registry and the dispatcher state,
|
|
5
|
+
* return one `ActiveActionDescriptor` per currently-active action, in
|
|
6
|
+
* tier-innermost-first order (element > focus > view > app > home),
|
|
7
|
+
* with registration order preserved within a tier.
|
|
8
|
+
*
|
|
9
|
+
* The shell's live wrapper calls this with `listActions()` + `getLiveDispatcherState()`.
|
|
10
|
+
*/
|
|
11
|
+
import { TIER_ORDER, } from './dispatcher.svelte';
|
|
12
|
+
import { effectiveShortcutWithSource } from './bindings';
|
|
13
|
+
import { innermostActiveScope, scopeBadge, scopeToTier } from './scope-helpers';
|
|
14
|
+
export function listActiveFromEntries(entries, state) {
|
|
15
|
+
const byTier = {
|
|
16
|
+
element: [], focus: [], view: [], app: [], home: [],
|
|
17
|
+
};
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
if (seen.has(entry.action.id))
|
|
21
|
+
continue;
|
|
22
|
+
const winning = innermostActiveScope(entry.action.scope, state, entry.ownerShardId);
|
|
23
|
+
if (!winning)
|
|
24
|
+
continue;
|
|
25
|
+
seen.add(entry.action.id);
|
|
26
|
+
const { shortcut, source } = effectiveShortcutWithSource(entry.action, state.bindings, state.platform);
|
|
27
|
+
byTier[scopeToTier(winning)].push({
|
|
28
|
+
id: entry.action.id,
|
|
29
|
+
label: entry.action.label,
|
|
30
|
+
effectiveShortcut: shortcut,
|
|
31
|
+
bindingSource: source,
|
|
32
|
+
scope: winning,
|
|
33
|
+
scopeBadge: scopeBadge(winning),
|
|
34
|
+
group: entry.action.group,
|
|
35
|
+
icon: entry.action.icon,
|
|
36
|
+
ownerShardId: entry.ownerShardId,
|
|
37
|
+
paletteItem: entry.action.paletteItem !== false,
|
|
38
|
+
contextItem: entry.action.contextItem !== false,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return TIER_ORDER.flatMap((tier) => byTier[tier]);
|
|
42
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { listActiveFromEntries } from './listActive';
|
|
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('listActiveFromEntries', () => {
|
|
9
|
+
it('omits actions whose scope is not active', () => {
|
|
10
|
+
const entries = [mkEntry({ id: 'p', scope: 'app' })];
|
|
11
|
+
expect(listActiveFromEntries(entries, mkState())).toEqual([]);
|
|
12
|
+
});
|
|
13
|
+
it('includes an active home action with default shortcut resolution', () => {
|
|
14
|
+
const entries = [mkEntry({
|
|
15
|
+
id: 'open', label: 'Open', scope: 'home', defaultShortcut: 'Mod+O',
|
|
16
|
+
})];
|
|
17
|
+
const out = listActiveFromEntries(entries, mkState({ platform: 'mac' }));
|
|
18
|
+
expect(out).toHaveLength(1);
|
|
19
|
+
expect(out[0]).toMatchObject({
|
|
20
|
+
id: 'open', label: 'Open',
|
|
21
|
+
effectiveShortcut: 'Meta+O', bindingSource: 'default',
|
|
22
|
+
scope: 'home', scopeBadge: null,
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
it('reports innermost tier for multi-scope action', () => {
|
|
26
|
+
const entries = [mkEntry({
|
|
27
|
+
id: 'm', scope: ['app', 'view:editor'],
|
|
28
|
+
}, '__sh3core__')];
|
|
29
|
+
const state = mkState({
|
|
30
|
+
activeAppId: 'a', autostartShards: new Set(['__sh3core__']),
|
|
31
|
+
mountedViewIds: new Set(['editor']),
|
|
32
|
+
});
|
|
33
|
+
const out = listActiveFromEntries(entries, state);
|
|
34
|
+
expect(out[0].scope).toBe('view:editor');
|
|
35
|
+
expect(out[0].scopeBadge).toBe('view:editor');
|
|
36
|
+
});
|
|
37
|
+
it('reports bindingSource=disabled when user null-rebound', () => {
|
|
38
|
+
const entries = [mkEntry({
|
|
39
|
+
id: 'x', scope: 'home', defaultShortcut: 'Mod+S',
|
|
40
|
+
})];
|
|
41
|
+
const state = mkState({ bindings: { x: null } });
|
|
42
|
+
const out = listActiveFromEntries(entries, state);
|
|
43
|
+
expect(out[0].effectiveShortcut).toBeNull();
|
|
44
|
+
expect(out[0].bindingSource).toBe('disabled');
|
|
45
|
+
});
|
|
46
|
+
it('reports bindingSource=none when no default and no override', () => {
|
|
47
|
+
const entries = [mkEntry({ id: 'x', scope: 'home' })];
|
|
48
|
+
const out = listActiveFromEntries(entries, mkState());
|
|
49
|
+
expect(out[0].effectiveShortcut).toBeNull();
|
|
50
|
+
expect(out[0].bindingSource).toBe('none');
|
|
51
|
+
});
|
|
52
|
+
it('carries paletteItem/contextItem defaults and overrides', () => {
|
|
53
|
+
const entries = [mkEntry({
|
|
54
|
+
id: 'a', scope: 'home', paletteItem: false,
|
|
55
|
+
})];
|
|
56
|
+
const out = listActiveFromEntries(entries, mkState());
|
|
57
|
+
expect(out[0].paletteItem).toBe(false);
|
|
58
|
+
expect(out[0].contextItem).toBe(true); // defaults to true
|
|
59
|
+
});
|
|
60
|
+
it('dedupes by action id', () => {
|
|
61
|
+
const entries = [
|
|
62
|
+
mkEntry({ id: 'dup', scope: 'home' }, 'shard.a'),
|
|
63
|
+
mkEntry({ id: 'dup', scope: 'home' }, 'shard.b'),
|
|
64
|
+
];
|
|
65
|
+
const out = listActiveFromEntries(entries, mkState());
|
|
66
|
+
expect(out).toHaveLength(1);
|
|
67
|
+
expect(out[0].ownerShardId).toBe('shard.a');
|
|
68
|
+
});
|
|
69
|
+
it('orders innermost tier first, stable by registration inside a tier', () => {
|
|
70
|
+
// `app` and `view:editor` both activate under an active app with the
|
|
71
|
+
// owning shard autostart-registered and the view mounted.
|
|
72
|
+
const entries = [
|
|
73
|
+
mkEntry({ id: 'a1', scope: 'app', label: 'A1' }, '__sh3core__'),
|
|
74
|
+
mkEntry({ id: 'v1', scope: 'view:editor', label: 'V1' }, '__sh3core__'),
|
|
75
|
+
mkEntry({ id: 'a2', scope: 'app', label: 'A2' }, '__sh3core__'),
|
|
76
|
+
];
|
|
77
|
+
const state = mkState({
|
|
78
|
+
activeAppId: 'a',
|
|
79
|
+
autostartShards: new Set(['__sh3core__']),
|
|
80
|
+
mountedViewIds: new Set(['editor']),
|
|
81
|
+
});
|
|
82
|
+
const out = listActiveFromEntries(entries, state);
|
|
83
|
+
// view tier first (innermost of the two), then app tier in reg order.
|
|
84
|
+
expect(out.map((d) => d.id)).toEqual(['v1', 'a1', 'a2']);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface OpenContextMenuOpts {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
}
|
|
5
|
+
export interface OpenPaletteOpts {
|
|
6
|
+
prefill?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function attachGlobalListeners(): void;
|
|
9
|
+
export declare function detachGlobalListeners(): void;
|
|
10
|
+
export declare function openContextMenu(opts: OpenContextMenuOpts): void;
|
|
11
|
+
export declare function openPalette(opts?: OpenPaletteOpts): void;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Document-level listener wiring for actions. Single keydown and single
|
|
3
|
+
* contextmenu listener, attached at shell boot (Task 18) and removed on
|
|
4
|
+
* shell teardown.
|
|
5
|
+
*/
|
|
6
|
+
import { listActions } from './registry';
|
|
7
|
+
import { dispatchKeydown } from './dispatcher.svelte';
|
|
8
|
+
import { getLiveDispatcherState, setFocusedViewId, } from './state.svelte';
|
|
9
|
+
import { eventToShortcut } from './shortcuts';
|
|
10
|
+
import ContextMenu from './ContextMenu.svelte';
|
|
11
|
+
import { buildContextMenuModel } from './contextMenuModel';
|
|
12
|
+
import CommandPalette from './CommandPalette.svelte';
|
|
13
|
+
import { buildPaletteCandidates } from './paletteModel';
|
|
14
|
+
import { shell } from '../shellRuntime.svelte';
|
|
15
|
+
let attached = false;
|
|
16
|
+
function viewIdOfEl(el) {
|
|
17
|
+
var _a;
|
|
18
|
+
if (!el)
|
|
19
|
+
return null;
|
|
20
|
+
const host = el.closest('[data-sh3-view]');
|
|
21
|
+
return (_a = host === null || host === void 0 ? void 0 : host.getAttribute('data-sh3-view')) !== null && _a !== void 0 ? _a : null;
|
|
22
|
+
}
|
|
23
|
+
function runAction(actionId, ctx) {
|
|
24
|
+
const entry = listActions().find((e) => e.action.id === actionId);
|
|
25
|
+
if (!entry)
|
|
26
|
+
return;
|
|
27
|
+
try {
|
|
28
|
+
void entry.action.run(ctx);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
console.error(`[sh3] action "${actionId}" threw:`, err);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Invoke another action by id from within an action's `run`. Builds a fresh
|
|
36
|
+
* dispatch context using current live state with `invokedVia: 'programmatic'`.
|
|
37
|
+
* Called via `ctx.dispatch(id)` on ActionDispatchContext.
|
|
38
|
+
*/
|
|
39
|
+
function chainedDispatch(actionId) {
|
|
40
|
+
var _a, _b;
|
|
41
|
+
const entry = listActions().find((e) => e.action.id === actionId);
|
|
42
|
+
if (!entry) {
|
|
43
|
+
console.warn(`[sh3] ctx.dispatch("${actionId}") — no action registered with that id.`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const state = getLiveDispatcherState();
|
|
47
|
+
runAction(actionId, {
|
|
48
|
+
action: { id: entry.action.id, label: entry.action.label },
|
|
49
|
+
appId: state.activeAppId,
|
|
50
|
+
viewId: (_a = state.focusedViewId) !== null && _a !== void 0 ? _a : undefined,
|
|
51
|
+
selection: (_b = state.selection) !== null && _b !== void 0 ? _b : undefined,
|
|
52
|
+
invokedVia: 'programmatic',
|
|
53
|
+
dispatch: chainedDispatch,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function onFocusIn(ev) {
|
|
57
|
+
const id = viewIdOfEl(ev.target);
|
|
58
|
+
setFocusedViewId(id);
|
|
59
|
+
}
|
|
60
|
+
function onFocusOut(_ev) {
|
|
61
|
+
// Defer — a focusout is usually followed immediately by focusin on the next element.
|
|
62
|
+
queueMicrotask(() => {
|
|
63
|
+
const id = viewIdOfEl(document.activeElement);
|
|
64
|
+
setFocusedViewId(id);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
function isNativeOptOut(target) {
|
|
68
|
+
if (!(target instanceof Element))
|
|
69
|
+
return false;
|
|
70
|
+
return target.closest('[data-sh3-context-menu="native"]') !== null;
|
|
71
|
+
}
|
|
72
|
+
function onContextMenu(ev) {
|
|
73
|
+
if (isNativeOptOut(ev.target))
|
|
74
|
+
return;
|
|
75
|
+
const entries = listActions();
|
|
76
|
+
const state = getLiveDispatcherState();
|
|
77
|
+
const model = buildContextMenuModel(entries, state);
|
|
78
|
+
if (model.tiers.length === 0)
|
|
79
|
+
return;
|
|
80
|
+
ev.preventDefault();
|
|
81
|
+
const handle = shell.popup.show(ContextMenu, { anchor: { x: ev.clientX, y: ev.clientY } }, {
|
|
82
|
+
model,
|
|
83
|
+
onInvoke: (id) => {
|
|
84
|
+
var _a, _b;
|
|
85
|
+
const entry = listActions().find((e) => e.action.id === id);
|
|
86
|
+
if (entry) {
|
|
87
|
+
try {
|
|
88
|
+
void entry.action.run({
|
|
89
|
+
action: { id, label: entry.action.label },
|
|
90
|
+
appId: state.activeAppId,
|
|
91
|
+
viewId: (_a = state.focusedViewId) !== null && _a !== void 0 ? _a : undefined,
|
|
92
|
+
selection: (_b = state.selection) !== null && _b !== void 0 ? _b : undefined,
|
|
93
|
+
invokedVia: 'context-menu',
|
|
94
|
+
dispatch: chainedDispatch,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
console.error(`[sh3] context-menu action "${id}" threw:`, err);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
onClose: () => handle.close(),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function onKeydown(ev) {
|
|
106
|
+
const shortcut = eventToShortcut(ev);
|
|
107
|
+
if (!shortcut)
|
|
108
|
+
return; // pure modifier press
|
|
109
|
+
const entries = listActions();
|
|
110
|
+
const state = getLiveDispatcherState();
|
|
111
|
+
const matched = dispatchKeydown({
|
|
112
|
+
target: ev.target,
|
|
113
|
+
shortcut,
|
|
114
|
+
state,
|
|
115
|
+
entries,
|
|
116
|
+
runAction,
|
|
117
|
+
dispatch: chainedDispatch,
|
|
118
|
+
});
|
|
119
|
+
if (matched)
|
|
120
|
+
ev.preventDefault();
|
|
121
|
+
}
|
|
122
|
+
export function attachGlobalListeners() {
|
|
123
|
+
if (attached)
|
|
124
|
+
return;
|
|
125
|
+
attached = true;
|
|
126
|
+
document.addEventListener('keydown', onKeydown);
|
|
127
|
+
document.addEventListener('focusin', onFocusIn);
|
|
128
|
+
document.addEventListener('focusout', onFocusOut);
|
|
129
|
+
document.addEventListener('contextmenu', onContextMenu);
|
|
130
|
+
}
|
|
131
|
+
export function detachGlobalListeners() {
|
|
132
|
+
if (!attached)
|
|
133
|
+
return;
|
|
134
|
+
attached = false;
|
|
135
|
+
document.removeEventListener('keydown', onKeydown);
|
|
136
|
+
document.removeEventListener('focusin', onFocusIn);
|
|
137
|
+
document.removeEventListener('focusout', onFocusOut);
|
|
138
|
+
document.removeEventListener('contextmenu', onContextMenu);
|
|
139
|
+
}
|
|
140
|
+
export function openContextMenu(opts) {
|
|
141
|
+
const fakeEvent = { target: null, clientX: opts.x, clientY: opts.y, preventDefault: () => { } };
|
|
142
|
+
onContextMenu(fakeEvent);
|
|
143
|
+
}
|
|
144
|
+
const RECENCY_CAP = 20;
|
|
145
|
+
let recency = [];
|
|
146
|
+
function recordUse(id) {
|
|
147
|
+
recency = [id, ...recency.filter((x) => x !== id)].slice(0, RECENCY_CAP);
|
|
148
|
+
}
|
|
149
|
+
export function openPalette(opts) {
|
|
150
|
+
var _a;
|
|
151
|
+
const entries = listActions();
|
|
152
|
+
const state = getLiveDispatcherState();
|
|
153
|
+
const candidates = buildPaletteCandidates(entries, state);
|
|
154
|
+
const handle = shell.modal.open(CommandPalette, {
|
|
155
|
+
candidates,
|
|
156
|
+
recency,
|
|
157
|
+
prefill: (_a = opts === null || opts === void 0 ? void 0 : opts.prefill) !== null && _a !== void 0 ? _a : '',
|
|
158
|
+
onInvoke: (id) => {
|
|
159
|
+
var _a, _b;
|
|
160
|
+
const entry = listActions().find((e) => e.action.id === id);
|
|
161
|
+
if (!entry)
|
|
162
|
+
return;
|
|
163
|
+
recordUse(id);
|
|
164
|
+
try {
|
|
165
|
+
void entry.action.run({
|
|
166
|
+
action: { id, label: entry.action.label },
|
|
167
|
+
appId: state.activeAppId,
|
|
168
|
+
viewId: (_a = state.focusedViewId) !== null && _a !== void 0 ? _a : undefined,
|
|
169
|
+
selection: (_b = state.selection) !== null && _b !== void 0 ? _b : undefined,
|
|
170
|
+
invokedVia: 'palette',
|
|
171
|
+
dispatch: chainedDispatch,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
console.error(`[sh3] palette action "${id}" threw:`, err);
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
onClose: () => handle.close(),
|
|
179
|
+
}, { dismissOnBackdrop: true });
|
|
180
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|