sh3-core 0.10.4 → 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__/fixtures.js +1 -0
- 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/LayoutRenderer.browser.test.js +78 -0
- package/dist/layout/LayoutRenderer.svelte +1 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-6-fixed-slots-freezes-the-handle-adjacent-to-a-fixed-pane--dblclick-does-not-collapse-1.png +0 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-6-fixed-slots-hides-the-collapse-widget-on-a-fixed-pane-1.png +0 -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 +15 -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/ResizableSplitter.svelte +38 -3
- package/dist/primitives/ResizableSplitter.svelte.d.ts +7 -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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { scoreMatch, rankPaletteEntries } from './palette-scorer';
|
|
3
|
+
describe('scoreMatch', () => {
|
|
4
|
+
it('returns null when subsequence not present', () => {
|
|
5
|
+
expect(scoreMatch('abcd', 'z')).toBeNull();
|
|
6
|
+
});
|
|
7
|
+
it('returns a score when subsequence matches', () => {
|
|
8
|
+
expect(scoreMatch('save', 'sv')).not.toBeNull();
|
|
9
|
+
expect(scoreMatch('save', 'save')).not.toBeNull();
|
|
10
|
+
});
|
|
11
|
+
it('ranks tighter runs higher', () => {
|
|
12
|
+
const tight = scoreMatch('save all', 'sa');
|
|
13
|
+
const loose = scoreMatch('save all', 'sl');
|
|
14
|
+
expect(tight).toBeGreaterThan(loose);
|
|
15
|
+
});
|
|
16
|
+
it('applies prefix bonus', () => {
|
|
17
|
+
const prefix = scoreMatch('save', 'sa');
|
|
18
|
+
const mid = scoreMatch('xsave', 'sa');
|
|
19
|
+
expect(prefix).toBeGreaterThan(mid);
|
|
20
|
+
});
|
|
21
|
+
it('is case-insensitive', () => {
|
|
22
|
+
expect(scoreMatch('Save', 'sa')).not.toBeNull();
|
|
23
|
+
expect(scoreMatch('save', 'SA')).not.toBeNull();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
describe('rankPaletteEntries', () => {
|
|
27
|
+
const mk = (id, label) => ({ id, label, shortcut: null, scopeBadge: null });
|
|
28
|
+
it('filters out non-matches', () => {
|
|
29
|
+
const out = rankPaletteEntries([mk('a', 'save'), mk('b', 'undo')], 'redo', []);
|
|
30
|
+
expect(out).toHaveLength(0);
|
|
31
|
+
});
|
|
32
|
+
it('sorts by score descending', () => {
|
|
33
|
+
const out = rankPaletteEntries([mk('a', 'save'), mk('b', 'save all')], 'sa', []);
|
|
34
|
+
expect(out[0].id).toBe('a'); // tighter
|
|
35
|
+
});
|
|
36
|
+
it('recency breaks ties', () => {
|
|
37
|
+
const out = rankPaletteEntries([mk('a', 'save'), mk('b', 'save')], 'sa', ['b']);
|
|
38
|
+
expect(out[0].id).toBe('b');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ActionEntry } from './registry';
|
|
2
|
+
import type { DispatcherState } from './dispatcher.svelte';
|
|
3
|
+
import type { PaletteCandidate } from './palette-scorer';
|
|
4
|
+
export declare function buildPaletteCandidates(entries: ActionEntry[], state: DispatcherState): PaletteCandidate[];
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { isScopeActive } from './dispatcher.svelte';
|
|
2
|
+
import { effectiveShortcut } from './bindings';
|
|
3
|
+
function normalize(s) {
|
|
4
|
+
return Array.isArray(s) ? s : [s];
|
|
5
|
+
}
|
|
6
|
+
function anyScopeActive(scope, state, owner) {
|
|
7
|
+
for (const s of normalize(scope)) {
|
|
8
|
+
if (isScopeActive(s, state, owner))
|
|
9
|
+
return s;
|
|
10
|
+
}
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
function scopeBadge(scope) {
|
|
14
|
+
if (scope === 'home' || scope === 'app')
|
|
15
|
+
return null;
|
|
16
|
+
if (typeof scope === 'string')
|
|
17
|
+
return scope; // view:X / focus:X
|
|
18
|
+
return scope.element;
|
|
19
|
+
}
|
|
20
|
+
export function buildPaletteCandidates(entries, state) {
|
|
21
|
+
const out = [];
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
if (entry.action.paletteItem === false)
|
|
25
|
+
continue;
|
|
26
|
+
if (seen.has(entry.action.id))
|
|
27
|
+
continue;
|
|
28
|
+
const active = anyScopeActive(entry.action.scope, state, entry.ownerShardId);
|
|
29
|
+
if (!active)
|
|
30
|
+
continue;
|
|
31
|
+
seen.add(entry.action.id);
|
|
32
|
+
out.push({
|
|
33
|
+
id: entry.action.id,
|
|
34
|
+
label: entry.action.label,
|
|
35
|
+
shortcut: effectiveShortcut(entry.action, state.bindings, state.platform),
|
|
36
|
+
scopeBadge: scopeBadge(active),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildPaletteCandidates } from './paletteModel';
|
|
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('buildPaletteCandidates', () => {
|
|
9
|
+
it('includes actions with paletteItem default true', () => {
|
|
10
|
+
const entries = [mkEntry({ id: 'x', scope: 'home', label: 'X' })];
|
|
11
|
+
const out = buildPaletteCandidates(entries, mkState());
|
|
12
|
+
expect(out.map((c) => c.id)).toEqual(['x']);
|
|
13
|
+
});
|
|
14
|
+
it('excludes paletteItem false', () => {
|
|
15
|
+
const entries = [mkEntry({ id: 'x', scope: 'home', label: 'X', paletteItem: false })];
|
|
16
|
+
const out = buildPaletteCandidates(entries, mkState());
|
|
17
|
+
expect(out).toHaveLength(0);
|
|
18
|
+
});
|
|
19
|
+
it('excludes actions whose scope is inactive', () => {
|
|
20
|
+
const entries = [mkEntry({ id: 'x', scope: 'app', label: 'X' })];
|
|
21
|
+
const out = buildPaletteCandidates(entries, mkState());
|
|
22
|
+
expect(out).toHaveLength(0);
|
|
23
|
+
});
|
|
24
|
+
it('de-duplicates multi-scope actions', () => {
|
|
25
|
+
const entries = [mkEntry({ id: 'p', scope: ['home', 'app'], label: 'P' }, '__sh3core__')];
|
|
26
|
+
const state = mkState({
|
|
27
|
+
activeAppId: 'a',
|
|
28
|
+
autostartShards: new Set(['__sh3core__']),
|
|
29
|
+
});
|
|
30
|
+
const out = buildPaletteCandidates(entries, state);
|
|
31
|
+
expect(out).toHaveLength(1);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Action } from './types';
|
|
2
|
+
export declare const ACTIONS_POINT_ID = "sh3.actions";
|
|
3
|
+
export interface ActionEntry {
|
|
4
|
+
action: Action;
|
|
5
|
+
ownerShardId: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function registerAction(action: Action, ownerShardId: string): () => void;
|
|
8
|
+
export declare function listActions(): ActionEntry[];
|
|
9
|
+
export declare function onActionsChange(cb: () => void): () => void;
|
|
10
|
+
export declare function __resetActionsRegistryForTest(): void;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Action registry — typed facade over the shared contributions point
|
|
3
|
+
* `sh3.actions`. One entry per (ownerShardId, action) pair. Disposers
|
|
4
|
+
* returned from registerAction clean up the underlying contribution
|
|
5
|
+
* entry; activate.svelte.ts also pushes them into the shard's cleanup
|
|
6
|
+
* queue so deactivate auto-clears.
|
|
7
|
+
*/
|
|
8
|
+
import { register as contributionsRegister, list as contributionsList, onChange as contributionsOnChange, } from '../contributions/registry';
|
|
9
|
+
export const ACTIONS_POINT_ID = 'sh3.actions';
|
|
10
|
+
const liveIds = new Set();
|
|
11
|
+
export function registerAction(action, ownerShardId) {
|
|
12
|
+
if (liveIds.has(action.id)) {
|
|
13
|
+
console.warn(`[sh3] Duplicate action id "${action.id}" registered by "${ownerShardId}". ` +
|
|
14
|
+
`First registration wins; second registration still stored but shortcut conflicts may occur.`);
|
|
15
|
+
}
|
|
16
|
+
liveIds.add(action.id);
|
|
17
|
+
const entry = { action, ownerShardId };
|
|
18
|
+
const dispose = contributionsRegister(ACTIONS_POINT_ID, entry);
|
|
19
|
+
let disposed = false;
|
|
20
|
+
return () => {
|
|
21
|
+
if (disposed)
|
|
22
|
+
return;
|
|
23
|
+
disposed = true;
|
|
24
|
+
dispose();
|
|
25
|
+
liveIds.delete(action.id);
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function listActions() {
|
|
29
|
+
return contributionsList(ACTIONS_POINT_ID);
|
|
30
|
+
}
|
|
31
|
+
export function onActionsChange(cb) {
|
|
32
|
+
return contributionsOnChange(ACTIONS_POINT_ID, cb);
|
|
33
|
+
}
|
|
34
|
+
export function __resetActionsRegistryForTest() {
|
|
35
|
+
liveIds.clear();
|
|
36
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { registerAction, listActions, onActionsChange, ACTIONS_POINT_ID, __resetActionsRegistryForTest, } from './registry';
|
|
3
|
+
import { __resetContributionsForTest, list } from '../contributions/registry';
|
|
4
|
+
const mkAction = (overrides = {}) => (Object.assign({ id: 'shard.test', label: 'Test', scope: 'home', run: () => { } }, overrides));
|
|
5
|
+
describe('actions registry', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
__resetContributionsForTest();
|
|
8
|
+
__resetActionsRegistryForTest();
|
|
9
|
+
});
|
|
10
|
+
it('stores actions at the ACTIONS_POINT_ID contributions point', () => {
|
|
11
|
+
registerAction(mkAction(), 'shard.a');
|
|
12
|
+
expect(list(ACTIONS_POINT_ID)).toHaveLength(1);
|
|
13
|
+
});
|
|
14
|
+
it('listActions returns registered entries with owner', () => {
|
|
15
|
+
registerAction(mkAction({ id: 'a.one' }), 'shard.a');
|
|
16
|
+
registerAction(mkAction({ id: 'b.one' }), 'shard.b');
|
|
17
|
+
const entries = listActions();
|
|
18
|
+
expect(entries).toHaveLength(2);
|
|
19
|
+
expect(entries[0].ownerShardId).toBe('shard.a');
|
|
20
|
+
expect(entries[1].action.id).toBe('b.one');
|
|
21
|
+
});
|
|
22
|
+
it('returned disposer removes the entry', () => {
|
|
23
|
+
const dispose = registerAction(mkAction({ id: 'x' }), 'shard.a');
|
|
24
|
+
expect(listActions()).toHaveLength(1);
|
|
25
|
+
dispose();
|
|
26
|
+
expect(listActions()).toHaveLength(0);
|
|
27
|
+
});
|
|
28
|
+
it('double-dispose is safe', () => {
|
|
29
|
+
const dispose = registerAction(mkAction(), 'shard.a');
|
|
30
|
+
dispose();
|
|
31
|
+
dispose();
|
|
32
|
+
expect(listActions()).toHaveLength(0);
|
|
33
|
+
});
|
|
34
|
+
it('onActionsChange fires on register and dispose', () => {
|
|
35
|
+
let count = 0;
|
|
36
|
+
onActionsChange(() => { count++; });
|
|
37
|
+
const dispose = registerAction(mkAction(), 'shard.a');
|
|
38
|
+
expect(count).toBe(1);
|
|
39
|
+
dispose();
|
|
40
|
+
expect(count).toBe(2);
|
|
41
|
+
});
|
|
42
|
+
it('warns on duplicate action id', () => {
|
|
43
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
44
|
+
registerAction(mkAction({ id: 'dup' }), 'shard.a');
|
|
45
|
+
registerAction(mkAction({ id: 'dup' }), 'shard.b');
|
|
46
|
+
expect(warn).toHaveBeenCalled();
|
|
47
|
+
warn.mockRestore();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Selection, SelectionApi } from './types';
|
|
2
|
+
export declare function getSelection(): Selection | null;
|
|
3
|
+
export declare function makeSelectionApi(shardId: string): SelectionApi;
|
|
4
|
+
/** Called by the shell on shard deactivate and app switch. */
|
|
5
|
+
export declare function clearSelectionForShard(shardId: string): void;
|
|
6
|
+
/** Called by the shell on app switch (unconditional). */
|
|
7
|
+
export declare function clearSelectionUnconditional(): void;
|
|
8
|
+
export declare function __resetSelectionForTest(): void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Shell-level single-selection state. One selection at a time, reactive.
|
|
3
|
+
* Shards obtain a per-shard SelectionApi via makeSelectionApi(shardId)
|
|
4
|
+
* that stamps ownerShardId on set and checks owner on clear. Shell-level
|
|
5
|
+
* clearSelectionForShard is used when a shard deactivates or an app
|
|
6
|
+
* switches — it only clears if the owner matches, so two-shard
|
|
7
|
+
* interleavings don't accidentally wipe each other.
|
|
8
|
+
*/
|
|
9
|
+
let current = $state(null);
|
|
10
|
+
export function getSelection() {
|
|
11
|
+
return current;
|
|
12
|
+
}
|
|
13
|
+
export function makeSelectionApi(shardId) {
|
|
14
|
+
return {
|
|
15
|
+
get() {
|
|
16
|
+
return current;
|
|
17
|
+
},
|
|
18
|
+
set(sel) {
|
|
19
|
+
current = { type: sel.type, ref: sel.ref, ownerShardId: shardId };
|
|
20
|
+
},
|
|
21
|
+
clear() {
|
|
22
|
+
if (!current)
|
|
23
|
+
return;
|
|
24
|
+
if (current.ownerShardId !== shardId) {
|
|
25
|
+
console.warn(`[sh3] Shard "${shardId}" tried to clear selection owned by "${current.ownerShardId}". Ignoring.`);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
current = null;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Called by the shell on shard deactivate and app switch. */
|
|
33
|
+
export function clearSelectionForShard(shardId) {
|
|
34
|
+
if (current && current.ownerShardId === shardId) {
|
|
35
|
+
current = null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Called by the shell on app switch (unconditional). */
|
|
39
|
+
export function clearSelectionUnconditional() {
|
|
40
|
+
current = null;
|
|
41
|
+
}
|
|
42
|
+
export function __resetSelectionForTest() {
|
|
43
|
+
current = null;
|
|
44
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { makeSelectionApi, getSelection, clearSelectionForShard, __resetSelectionForTest, } from './selection.svelte';
|
|
3
|
+
describe('selection', () => {
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
__resetSelectionForTest();
|
|
6
|
+
});
|
|
7
|
+
it('returns null before any set', () => {
|
|
8
|
+
expect(getSelection()).toBeNull();
|
|
9
|
+
});
|
|
10
|
+
it('set stamps ownerShardId', () => {
|
|
11
|
+
const api = makeSelectionApi('shard.a');
|
|
12
|
+
api.set({ type: 'orb', ref: 42 });
|
|
13
|
+
expect(getSelection()).toEqual({ type: 'orb', ref: 42, ownerShardId: 'shard.a' });
|
|
14
|
+
});
|
|
15
|
+
it('clear by owner wipes selection', () => {
|
|
16
|
+
const api = makeSelectionApi('shard.a');
|
|
17
|
+
api.set({ type: 'orb', ref: 42 });
|
|
18
|
+
api.clear();
|
|
19
|
+
expect(getSelection()).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
it('non-owner clear is a no-op with warn', () => {
|
|
22
|
+
var _a;
|
|
23
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
24
|
+
const a = makeSelectionApi('shard.a');
|
|
25
|
+
const b = makeSelectionApi('shard.b');
|
|
26
|
+
a.set({ type: 'orb', ref: 42 });
|
|
27
|
+
b.clear();
|
|
28
|
+
expect((_a = getSelection()) === null || _a === void 0 ? void 0 : _a.type).toBe('orb');
|
|
29
|
+
expect(warn).toHaveBeenCalled();
|
|
30
|
+
warn.mockRestore();
|
|
31
|
+
});
|
|
32
|
+
it('shell-level clearSelectionForShard always works', () => {
|
|
33
|
+
const api = makeSelectionApi('shard.a');
|
|
34
|
+
api.set({ type: 'orb', ref: 42 });
|
|
35
|
+
clearSelectionForShard('shard.a');
|
|
36
|
+
expect(getSelection()).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
it('clearSelectionForShard only clears when owner matches', () => {
|
|
39
|
+
const api = makeSelectionApi('shard.a');
|
|
40
|
+
api.set({ type: 'orb', ref: 42 });
|
|
41
|
+
clearSelectionForShard('shard.b');
|
|
42
|
+
expect(getSelection()).not.toBeNull();
|
|
43
|
+
});
|
|
44
|
+
it('set from a different shard replaces the previous selection', () => {
|
|
45
|
+
const a = makeSelectionApi('shard.a');
|
|
46
|
+
const b = makeSelectionApi('shard.b');
|
|
47
|
+
a.set({ type: 'orb', ref: 1 });
|
|
48
|
+
b.set({ type: 'node', ref: 2 });
|
|
49
|
+
expect(getSelection()).toEqual({ type: 'node', ref: 2, ownerShardId: 'shard.b' });
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { registerShard, activateShard, deactivateShard } from '../shards/activate.svelte';
|
|
3
|
+
import { __resetContributionsForTest } from '../contributions/registry';
|
|
4
|
+
import { __resetActionsRegistryForTest, listActions } from './registry';
|
|
5
|
+
import { __resetSelectionForTest, getSelection } from './selection.svelte';
|
|
6
|
+
function mkShard(id, onActivate) {
|
|
7
|
+
return {
|
|
8
|
+
manifest: { id, label: id, version: '0.0.0-test', views: [] },
|
|
9
|
+
async activate(ctx) { onActivate(ctx); },
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
describe('ctx.actions on ShardContext', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
__resetContributionsForTest();
|
|
15
|
+
__resetActionsRegistryForTest();
|
|
16
|
+
__resetSelectionForTest();
|
|
17
|
+
});
|
|
18
|
+
it('registerAction is auto-unregistered on deactivate', async () => {
|
|
19
|
+
const shard = mkShard('shard.a', (ctx) => {
|
|
20
|
+
ctx.actions.register({
|
|
21
|
+
id: 'shard.a.x', label: 'X', scope: 'home',
|
|
22
|
+
run: () => { },
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
registerShard(shard);
|
|
26
|
+
await activateShard('shard.a');
|
|
27
|
+
expect(listActions()).toHaveLength(1);
|
|
28
|
+
deactivateShard('shard.a');
|
|
29
|
+
expect(listActions()).toHaveLength(0);
|
|
30
|
+
});
|
|
31
|
+
it('selection set by a shard is cleared on that shard deactivate', async () => {
|
|
32
|
+
const shard = mkShard('shard.a', (ctx) => {
|
|
33
|
+
ctx.actions.selection.set({ type: 'orb', ref: 42 });
|
|
34
|
+
});
|
|
35
|
+
registerShard(shard);
|
|
36
|
+
await activateShard('shard.a');
|
|
37
|
+
expect(getSelection()).not.toBeNull();
|
|
38
|
+
deactivateShard('shard.a');
|
|
39
|
+
expect(getSelection()).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { shell } from '../shellRuntime.svelte';
|
|
3
|
+
import { __setBindingsZone } from './bindings-store';
|
|
4
|
+
import { __resetDispatcherStateForTest, setActiveApp } from './state.svelte';
|
|
5
|
+
describe('shell.actions facade', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
__setBindingsZone({ bindings: {} });
|
|
8
|
+
__resetDispatcherStateForTest();
|
|
9
|
+
setActiveApp('app.a', new Set(['shard.x']));
|
|
10
|
+
});
|
|
11
|
+
it('rebind persists and updates live state', async () => {
|
|
12
|
+
await shell.actions.rebind('app.a', 'shard.x.save', 'Ctrl+Alt+S');
|
|
13
|
+
const now = await shell.actions.bindingsFor('app.a');
|
|
14
|
+
expect(now['shard.x.save']).toBe('Ctrl+Alt+S');
|
|
15
|
+
});
|
|
16
|
+
it('resetBinding removes the override', async () => {
|
|
17
|
+
await shell.actions.rebind('app.a', 'shard.x.save', 'Ctrl+Alt+S');
|
|
18
|
+
await shell.actions.resetBinding('app.a', 'shard.x.save');
|
|
19
|
+
const now = await shell.actions.bindingsFor('app.a');
|
|
20
|
+
expect(now['shard.x.save']).toBeUndefined();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type Platform = 'mac' | 'other';
|
|
2
|
+
export declare function canonicalizeShortcut(raw: string): string;
|
|
3
|
+
export declare function resolveMod(shortcut: string, platform: Platform): string;
|
|
4
|
+
export declare function eventToShortcut(ev: KeyboardEvent): string;
|
|
5
|
+
export declare function detectPlatform(): Platform;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Shortcut normalization — canonical form is "Mod1+Mod2+...+Key" with
|
|
3
|
+
* modifiers ordered Ctrl, Alt, Shift, Meta. Single-character keys are
|
|
4
|
+
* uppercased; named keys preserve their .key value.
|
|
5
|
+
*
|
|
6
|
+
* Mod is a pseudo-modifier: Meta on Mac, Ctrl elsewhere. Resolved once,
|
|
7
|
+
* at the moment a default shortcut is written into the tier index (not
|
|
8
|
+
* at match time) — the index stores concrete shortcuts only.
|
|
9
|
+
*/
|
|
10
|
+
const MOD_ORDER = ['Ctrl', 'Alt', 'Shift', 'Meta'];
|
|
11
|
+
const MOD_ALIASES = {
|
|
12
|
+
control: 'Ctrl',
|
|
13
|
+
ctrl: 'Ctrl',
|
|
14
|
+
alt: 'Alt',
|
|
15
|
+
option: 'Alt',
|
|
16
|
+
shift: 'Shift',
|
|
17
|
+
meta: 'Meta',
|
|
18
|
+
cmd: 'Meta',
|
|
19
|
+
command: 'Meta',
|
|
20
|
+
super: 'Meta',
|
|
21
|
+
win: 'Meta',
|
|
22
|
+
};
|
|
23
|
+
const PURE_MODIFIER_KEYS = new Set(['Control', 'Alt', 'Shift', 'Meta']);
|
|
24
|
+
function titleCaseNamedKey(key) {
|
|
25
|
+
if (key.length === 1)
|
|
26
|
+
return key.toUpperCase();
|
|
27
|
+
// Preserve browser's named-key casing (Enter, Escape, ArrowUp, etc.)
|
|
28
|
+
return key;
|
|
29
|
+
}
|
|
30
|
+
export function canonicalizeShortcut(raw) {
|
|
31
|
+
if (!raw)
|
|
32
|
+
throw new Error('Empty shortcut');
|
|
33
|
+
const rawParts = raw.split('+');
|
|
34
|
+
// Any empty segment (leading/trailing/consecutive '+') means malformed
|
|
35
|
+
if (rawParts.some((p) => p.trim() === ''))
|
|
36
|
+
throw new Error(`Malformed shortcut: "${raw}"`);
|
|
37
|
+
const parts = rawParts.map((p) => p.trim());
|
|
38
|
+
if (parts.length === 0)
|
|
39
|
+
throw new Error(`Malformed shortcut: "${raw}"`);
|
|
40
|
+
const modifiers = new Set();
|
|
41
|
+
let key;
|
|
42
|
+
for (const part of parts) {
|
|
43
|
+
const lower = part.toLowerCase();
|
|
44
|
+
if (lower in MOD_ALIASES) {
|
|
45
|
+
modifiers.add(MOD_ALIASES[lower]);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
if (key !== undefined)
|
|
49
|
+
throw new Error(`Multiple keys in shortcut: "${raw}"`);
|
|
50
|
+
key = titleCaseNamedKey(part);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (key === undefined)
|
|
54
|
+
throw new Error(`No key in shortcut: "${raw}"`);
|
|
55
|
+
const ordered = MOD_ORDER.filter((m) => modifiers.has(m));
|
|
56
|
+
return [...ordered, key].join('+');
|
|
57
|
+
}
|
|
58
|
+
export function resolveMod(shortcut, platform) {
|
|
59
|
+
if (!shortcut.toLowerCase().includes('mod'))
|
|
60
|
+
return shortcut;
|
|
61
|
+
const parts = shortcut.split('+').map((p) => {
|
|
62
|
+
if (p.toLowerCase() === 'mod')
|
|
63
|
+
return platform === 'mac' ? 'Meta' : 'Ctrl';
|
|
64
|
+
return p;
|
|
65
|
+
});
|
|
66
|
+
return canonicalizeShortcut(parts.join('+'));
|
|
67
|
+
}
|
|
68
|
+
export function eventToShortcut(ev) {
|
|
69
|
+
if (PURE_MODIFIER_KEYS.has(ev.key))
|
|
70
|
+
return '';
|
|
71
|
+
const mods = [];
|
|
72
|
+
if (ev.ctrlKey)
|
|
73
|
+
mods.push('Ctrl');
|
|
74
|
+
if (ev.altKey)
|
|
75
|
+
mods.push('Alt');
|
|
76
|
+
if (ev.shiftKey)
|
|
77
|
+
mods.push('Shift');
|
|
78
|
+
if (ev.metaKey)
|
|
79
|
+
mods.push('Meta');
|
|
80
|
+
const key = titleCaseNamedKey(ev.key);
|
|
81
|
+
return [...mods, key].join('+');
|
|
82
|
+
}
|
|
83
|
+
export function detectPlatform() {
|
|
84
|
+
if (typeof navigator === 'undefined')
|
|
85
|
+
return 'other';
|
|
86
|
+
return /Mac|iPhone|iPad|iPod/i.test(navigator.platform) ? 'mac' : 'other';
|
|
87
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { canonicalizeShortcut, eventToShortcut, resolveMod, } from './shortcuts';
|
|
3
|
+
describe('canonicalizeShortcut', () => {
|
|
4
|
+
it('orders modifiers Ctrl Alt Shift Meta', () => {
|
|
5
|
+
expect(canonicalizeShortcut('Shift+Ctrl+S')).toBe('Ctrl+Shift+S');
|
|
6
|
+
expect(canonicalizeShortcut('Meta+Alt+Ctrl+K')).toBe('Ctrl+Alt+Meta+K');
|
|
7
|
+
});
|
|
8
|
+
it('uppercases single-character keys', () => {
|
|
9
|
+
expect(canonicalizeShortcut('Ctrl+s')).toBe('Ctrl+S');
|
|
10
|
+
});
|
|
11
|
+
it('preserves named keys', () => {
|
|
12
|
+
expect(canonicalizeShortcut('Ctrl+Enter')).toBe('Ctrl+Enter');
|
|
13
|
+
expect(canonicalizeShortcut('Escape')).toBe('Escape');
|
|
14
|
+
});
|
|
15
|
+
it('is case-insensitive on modifiers', () => {
|
|
16
|
+
expect(canonicalizeShortcut('ctrl+SHIFT+k')).toBe('Ctrl+Shift+K');
|
|
17
|
+
});
|
|
18
|
+
it('throws on malformed input', () => {
|
|
19
|
+
expect(() => canonicalizeShortcut('')).toThrow();
|
|
20
|
+
expect(() => canonicalizeShortcut('Ctrl+')).toThrow();
|
|
21
|
+
expect(() => canonicalizeShortcut('+S')).toThrow();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
describe('resolveMod', () => {
|
|
25
|
+
it('maps Mod to Cmd on Mac', () => {
|
|
26
|
+
expect(resolveMod('Mod+K', 'mac')).toBe('Meta+K');
|
|
27
|
+
});
|
|
28
|
+
it('maps Mod to Ctrl on non-Mac', () => {
|
|
29
|
+
expect(resolveMod('Mod+K', 'other')).toBe('Ctrl+K');
|
|
30
|
+
});
|
|
31
|
+
it('leaves non-Mod shortcuts unchanged', () => {
|
|
32
|
+
expect(resolveMod('Ctrl+S', 'mac')).toBe('Ctrl+S');
|
|
33
|
+
expect(resolveMod('Ctrl+S', 'other')).toBe('Ctrl+S');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
describe('eventToShortcut', () => {
|
|
37
|
+
function mkEvt(partial) {
|
|
38
|
+
return Object.assign({ ctrlKey: false, altKey: false, shiftKey: false, metaKey: false, key: '' }, partial);
|
|
39
|
+
}
|
|
40
|
+
it('builds canonical form from event', () => {
|
|
41
|
+
expect(eventToShortcut(mkEvt({ ctrlKey: true, key: 's' }))).toBe('Ctrl+S');
|
|
42
|
+
expect(eventToShortcut(mkEvt({ ctrlKey: true, shiftKey: true, key: 'K' }))).toBe('Ctrl+Shift+K');
|
|
43
|
+
expect(eventToShortcut(mkEvt({ key: 'Escape' }))).toBe('Escape');
|
|
44
|
+
});
|
|
45
|
+
it('returns empty string for pure modifier press', () => {
|
|
46
|
+
expect(eventToShortcut(mkEvt({ ctrlKey: true, key: 'Control' }))).toBe('');
|
|
47
|
+
expect(eventToShortcut(mkEvt({ shiftKey: true, key: 'Shift' }))).toBe('');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { DispatcherState } from './dispatcher.svelte';
|
|
2
|
+
export declare function setActiveApp(appId: string | null, requiredShards: Set<string>): void;
|
|
3
|
+
export declare function setAutostartShards(shards: Set<string>): void;
|
|
4
|
+
export declare function setMountedViewIds(ids: Set<string>): void;
|
|
5
|
+
/**
|
|
6
|
+
* One-shot snapshot: walk the active layout tree and update
|
|
7
|
+
* `mountedViewIds` to match. Non-reactive — call from an `$effect` that
|
|
8
|
+
* reads `layoutStore.tree` to keep the set in sync as the tree mutates.
|
|
9
|
+
* Shell.svelte owns that effect during boot.
|
|
10
|
+
*/
|
|
11
|
+
export declare function syncMountedViewIdsFromLayout(): void;
|
|
12
|
+
export declare function setFocusedViewId(id: string | null): void;
|
|
13
|
+
export declare function setUserBindings(bindings: Record<string, string | null>): void;
|
|
14
|
+
export declare function getLiveDispatcherState(): DispatcherState;
|
|
15
|
+
export declare function addAutostartShard(id: string): void;
|
|
16
|
+
export declare function __resetDispatcherStateForTest(): void;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Live dispatcher state. The pure dispatcher takes a snapshot; this
|
|
3
|
+
* module maintains one, driven by setters called from listeners.ts,
|
|
4
|
+
* lifecycle hooks, and the selection module.
|
|
5
|
+
*
|
|
6
|
+
* Using primitive setters (rather than $effect chains) keeps the test
|
|
7
|
+
* surface small and lets callers update state in a predictable order
|
|
8
|
+
* during lifecycle transitions.
|
|
9
|
+
*/
|
|
10
|
+
import { getSelection } from './selection.svelte';
|
|
11
|
+
import { detectPlatform } from './shortcuts';
|
|
12
|
+
import { layoutStore } from '../layout/store.svelte';
|
|
13
|
+
import { collectTreeSlotRefs } from '../layout/tree-walk';
|
|
14
|
+
let activeAppId = $state(null);
|
|
15
|
+
let activeAppRequiredShards = $state(new Set());
|
|
16
|
+
let autostartShards = $state(new Set());
|
|
17
|
+
let mountedViewIds = $state(new Set());
|
|
18
|
+
let focusedViewId = $state(null);
|
|
19
|
+
let userBindings = $state({});
|
|
20
|
+
const platform = detectPlatform();
|
|
21
|
+
export function setActiveApp(appId, requiredShards) {
|
|
22
|
+
activeAppId = appId;
|
|
23
|
+
activeAppRequiredShards = new Set(requiredShards);
|
|
24
|
+
}
|
|
25
|
+
export function setAutostartShards(shards) {
|
|
26
|
+
autostartShards = new Set(shards);
|
|
27
|
+
}
|
|
28
|
+
export function setMountedViewIds(ids) {
|
|
29
|
+
mountedViewIds = new Set(ids);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* One-shot snapshot: walk the active layout tree and update
|
|
33
|
+
* `mountedViewIds` to match. Non-reactive — call from an `$effect` that
|
|
34
|
+
* reads `layoutStore.tree` to keep the set in sync as the tree mutates.
|
|
35
|
+
* Shell.svelte owns that effect during boot.
|
|
36
|
+
*/
|
|
37
|
+
export function syncMountedViewIdsFromLayout() {
|
|
38
|
+
const refs = collectTreeSlotRefs(layoutStore.tree);
|
|
39
|
+
const ids = new Set();
|
|
40
|
+
for (const r of refs) {
|
|
41
|
+
if (r.viewId !== null)
|
|
42
|
+
ids.add(r.viewId);
|
|
43
|
+
}
|
|
44
|
+
mountedViewIds = ids;
|
|
45
|
+
}
|
|
46
|
+
export function setFocusedViewId(id) {
|
|
47
|
+
focusedViewId = id;
|
|
48
|
+
}
|
|
49
|
+
export function setUserBindings(bindings) {
|
|
50
|
+
userBindings = Object.assign({}, bindings);
|
|
51
|
+
}
|
|
52
|
+
export function getLiveDispatcherState() {
|
|
53
|
+
return {
|
|
54
|
+
activeAppId,
|
|
55
|
+
activeAppRequiredShards,
|
|
56
|
+
autostartShards,
|
|
57
|
+
mountedViewIds,
|
|
58
|
+
focusedViewId,
|
|
59
|
+
selection: getSelection(),
|
|
60
|
+
bindings: userBindings,
|
|
61
|
+
platform,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function addAutostartShard(id) {
|
|
65
|
+
if (!autostartShards.has(id)) {
|
|
66
|
+
autostartShards = new Set([...autostartShards, id]);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function __resetDispatcherStateForTest() {
|
|
70
|
+
activeAppId = null;
|
|
71
|
+
activeAppRequiredShards = new Set();
|
|
72
|
+
autostartShards = new Set();
|
|
73
|
+
mountedViewIds = new Set();
|
|
74
|
+
focusedViewId = null;
|
|
75
|
+
userBindings = {};
|
|
76
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|