sh3-core 0.11.7 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/contextMenuModel.d.ts +5 -4
- package/dist/actions/contextMenuModel.js +26 -12
- package/dist/actions/contextMenuModel.test.js +49 -24
- package/dist/actions/listeners.d.ts +2 -0
- package/dist/actions/listeners.js +65 -6
- package/dist/actions/listeners.test.js +96 -8
- package/dist/actions/scope-helpers.d.ts +23 -0
- package/dist/actions/scope-helpers.js +47 -0
- package/dist/actions/scope-helpers.test.js +56 -1
- package/dist/actions/types.d.ts +1 -0
- package/dist/api.d.ts +2 -1
- package/dist/api.js +1 -1
- package/dist/app/store/InstalledView.svelte +2 -1
- package/dist/app/store/StoreView.svelte +2 -1
- package/dist/apps/lifecycle.d.ts +7 -0
- package/dist/apps/lifecycle.js +22 -5
- package/dist/apps/lifecycle.test.js +50 -0
- package/dist/documents/browse.d.ts +15 -0
- package/dist/documents/browse.js +7 -0
- package/dist/documents/browse.test.js +41 -0
- package/dist/documents/handle.js +3 -1
- package/dist/documents/handle.test.js +23 -0
- package/dist/host.js +18 -4
- package/dist/layout/LayoutRenderer.svelte +5 -1
- package/dist/layout/LayoutRenderer.test.js +42 -0
- package/dist/layout/SlotContainer.svelte +11 -2
- package/dist/layout/SlotContainer.svelte.d.ts +1 -0
- package/dist/layout/slotHostPool.svelte.js +10 -3
- package/dist/layout/slotHostPool.test.js +15 -0
- package/dist/shards/activate-error-isolation.test.d.ts +1 -0
- package/dist/shards/activate-error-isolation.test.js +98 -0
- package/dist/shards/activate.svelte.d.ts +30 -2
- package/dist/shards/activate.svelte.js +62 -17
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ActionEntry } from './registry';
|
|
2
2
|
import { type DispatcherState, type TierName } from './dispatcher.svelte';
|
|
3
|
+
import type { AtomicScope } from './types';
|
|
3
4
|
export interface MenuItem {
|
|
4
5
|
id: string;
|
|
5
6
|
label: string;
|
|
@@ -17,10 +18,10 @@ export interface MenuTier {
|
|
|
17
18
|
export interface ContextMenuModel {
|
|
18
19
|
tiers: MenuTier[];
|
|
19
20
|
}
|
|
20
|
-
export declare function buildContextMenuModel(entries: ActionEntry[], state: DispatcherState): ContextMenuModel;
|
|
21
|
+
export declare function buildContextMenuModel(entries: ActionEntry[], state: DispatcherState, anchor: AtomicScope): ContextMenuModel;
|
|
21
22
|
/**
|
|
22
23
|
* Active children of a context-menu submenu parent. Single flat list,
|
|
23
|
-
* de-duplicated by id, in registration order.
|
|
24
|
-
*
|
|
24
|
+
* de-duplicated by id, in registration order. The same anchor filter that
|
|
25
|
+
* admitted the parent is applied to children.
|
|
25
26
|
*/
|
|
26
|
-
export declare function buildContextMenuSubmenu(entries: ActionEntry[], state: DispatcherState, parentId: string): MenuItem[];
|
|
27
|
+
export declare function buildContextMenuSubmenu(entries: ActionEntry[], state: DispatcherState, parentId: string, anchor: AtomicScope): MenuItem[];
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* Pure model layer for the context menu: takes the action registry +
|
|
3
|
-
* dispatcher state, returns a
|
|
4
|
-
* list the Svelte component renders without further
|
|
3
|
+
* dispatcher state + an anchor scope, returns a tier-grouped, deduplicated,
|
|
4
|
+
* flag-annotated item list the Svelte component renders without further
|
|
5
|
+
* logic. Items pass iff their declared scope list contains the anchor;
|
|
6
|
+
* `app`/`home` anchors additionally require the existing owner-shard
|
|
7
|
+
* activation guard.
|
|
5
8
|
*/
|
|
6
|
-
import { TIER_ORDER } from './dispatcher.svelte';
|
|
9
|
+
import { TIER_ORDER, isScopeActive, } from './dispatcher.svelte';
|
|
7
10
|
import { effectiveShortcut } from './bindings';
|
|
8
|
-
import { scopeToTier, innermostActiveScope } from './scope-helpers';
|
|
11
|
+
import { scopeToTier, innermostActiveScope, scopeEquals, normalizeScope, } from './scope-helpers';
|
|
9
12
|
function evalFlag(v) {
|
|
10
13
|
if (v === undefined)
|
|
11
14
|
return false;
|
|
@@ -24,7 +27,17 @@ function toMenuItem(entry, state) {
|
|
|
24
27
|
submenu: entry.action.submenu === true,
|
|
25
28
|
};
|
|
26
29
|
}
|
|
27
|
-
|
|
30
|
+
function matchesAnchor(action, ownerShardId, anchor, state) {
|
|
31
|
+
const inList = normalizeScope(action.scope).some((s) => scopeEquals(s, anchor));
|
|
32
|
+
if (!inList)
|
|
33
|
+
return false;
|
|
34
|
+
if (anchor === 'app' || anchor === 'home') {
|
|
35
|
+
return isScopeActive(anchor, state, ownerShardId);
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
export function buildContextMenuModel(entries, state, anchor) {
|
|
40
|
+
var _a;
|
|
28
41
|
const byTier = {
|
|
29
42
|
element: [], focus: [], view: [], app: [], home: [],
|
|
30
43
|
};
|
|
@@ -36,9 +49,11 @@ export function buildContextMenuModel(entries, state) {
|
|
|
36
49
|
continue;
|
|
37
50
|
if (seen.has(entry.action.id))
|
|
38
51
|
continue;
|
|
39
|
-
|
|
40
|
-
if (!winning)
|
|
52
|
+
if (!matchesAnchor(entry.action, entry.ownerShardId, anchor, state))
|
|
41
53
|
continue;
|
|
54
|
+
// Tier is determined by the anchor itself; the per-action winning scope
|
|
55
|
+
// is preserved purely for any future tier-aware rendering.
|
|
56
|
+
const winning = (_a = innermostActiveScope(entry.action.scope, state, entry.ownerShardId)) !== null && _a !== void 0 ? _a : anchor;
|
|
42
57
|
seen.add(entry.action.id);
|
|
43
58
|
byTier[scopeToTier(winning)].push(toMenuItem(entry, state));
|
|
44
59
|
}
|
|
@@ -50,10 +65,10 @@ export function buildContextMenuModel(entries, state) {
|
|
|
50
65
|
}
|
|
51
66
|
/**
|
|
52
67
|
* Active children of a context-menu submenu parent. Single flat list,
|
|
53
|
-
* de-duplicated by id, in registration order.
|
|
54
|
-
*
|
|
68
|
+
* de-duplicated by id, in registration order. The same anchor filter that
|
|
69
|
+
* admitted the parent is applied to children.
|
|
55
70
|
*/
|
|
56
|
-
export function buildContextMenuSubmenu(entries, state, parentId) {
|
|
71
|
+
export function buildContextMenuSubmenu(entries, state, parentId, anchor) {
|
|
57
72
|
const out = [];
|
|
58
73
|
const seen = new Set();
|
|
59
74
|
for (const entry of entries) {
|
|
@@ -61,8 +76,7 @@ export function buildContextMenuSubmenu(entries, state, parentId) {
|
|
|
61
76
|
continue;
|
|
62
77
|
if (seen.has(entry.action.id))
|
|
63
78
|
continue;
|
|
64
|
-
|
|
65
|
-
if (!winning)
|
|
79
|
+
if (!matchesAnchor(entry.action, entry.ownerShardId, anchor, state))
|
|
66
80
|
continue;
|
|
67
81
|
seen.add(entry.action.id);
|
|
68
82
|
out.push(toMenuItem(entry, state));
|
|
@@ -9,40 +9,57 @@ describe('buildContextMenuModel', () => {
|
|
|
9
9
|
it('returns only actions with contextItem: true', () => {
|
|
10
10
|
const entries = [
|
|
11
11
|
mkEntry({ id: 'a', scope: 'home', contextItem: true, label: 'A' }),
|
|
12
|
-
mkEntry({ id: 'b', scope: 'home', label: 'B' }),
|
|
12
|
+
mkEntry({ id: 'b', scope: 'home', label: 'B' }),
|
|
13
13
|
];
|
|
14
|
-
const model = buildContextMenuModel(entries, mkState());
|
|
14
|
+
const model = buildContextMenuModel(entries, mkState(), 'home');
|
|
15
15
|
expect(model.tiers.flatMap((t) => t.items).map((i) => i.id)).toEqual(['a']);
|
|
16
16
|
});
|
|
17
|
-
it('
|
|
18
|
-
const state = mkState({
|
|
19
|
-
activeAppId: 'app.a',
|
|
20
|
-
activeAppRequiredShards: new Set(['shard.x']),
|
|
21
|
-
selection: { type: 'orb', ref: 1, ownerShardId: 'shard.x' },
|
|
22
|
-
});
|
|
17
|
+
it('admits only actions whose scope list contains the anchor', () => {
|
|
18
|
+
const state = mkState({ mountedViewIds: new Set(['editor']), focusedViewId: 'editor' });
|
|
23
19
|
const entries = [
|
|
24
|
-
mkEntry({ id: '
|
|
25
|
-
mkEntry({ id: '
|
|
20
|
+
mkEntry({ id: 'view-only', scope: 'focus:editor', contextItem: true, label: 'V' }),
|
|
21
|
+
mkEntry({ id: 'home-only', scope: 'home', contextItem: true, label: 'H' }),
|
|
26
22
|
];
|
|
27
|
-
const model = buildContextMenuModel(entries, state);
|
|
28
|
-
expect(model.tiers.
|
|
29
|
-
expect(model.tiers[0].items[0].id).toBe('el');
|
|
23
|
+
const model = buildContextMenuModel(entries, state, 'focus:editor');
|
|
24
|
+
expect(model.tiers.flatMap((t) => t.items).map((i) => i.id)).toEqual(['view-only']);
|
|
30
25
|
});
|
|
31
|
-
it('
|
|
26
|
+
it('admits a multi-scope action when the anchor is one of its scopes', () => {
|
|
27
|
+
const state = mkState({ mountedViewIds: new Set(['editor']), focusedViewId: 'editor' });
|
|
28
|
+
const entries = [
|
|
29
|
+
mkEntry({ id: 'multi', scope: ['focus:editor', 'app'], contextItem: true, label: 'M' }),
|
|
30
|
+
];
|
|
31
|
+
const model = buildContextMenuModel(entries, state, 'focus:editor');
|
|
32
|
+
expect(model.tiers.flatMap((t) => t.items).map((i) => i.id)).toEqual(['multi']);
|
|
33
|
+
});
|
|
34
|
+
it('matches element scopes by element-type equality', () => {
|
|
35
|
+
const entries = [
|
|
36
|
+
mkEntry({ id: 'cell', scope: { element: 'cell' }, contextItem: true, label: 'C' }),
|
|
37
|
+
mkEntry({ id: 'row', scope: { element: 'row' }, contextItem: true, label: 'R' }),
|
|
38
|
+
];
|
|
39
|
+
const model = buildContextMenuModel(entries, mkState(), { element: 'cell' });
|
|
40
|
+
expect(model.tiers.flatMap((t) => t.items).map((i) => i.id)).toEqual(['cell']);
|
|
41
|
+
});
|
|
42
|
+
it('app anchor honors the owner-shard guard', () => {
|
|
32
43
|
const state = mkState({
|
|
33
44
|
activeAppId: 'app.a',
|
|
34
45
|
activeAppRequiredShards: new Set(['shard.x']),
|
|
35
|
-
autostartShards: new Set(['shard.x']),
|
|
36
46
|
});
|
|
37
47
|
const entries = [
|
|
38
|
-
mkEntry({ id: '
|
|
48
|
+
mkEntry({ id: 'mine', scope: 'app', contextItem: true, label: 'M' }, 'shard.x'),
|
|
49
|
+
mkEntry({ id: 'foreign', scope: 'app', contextItem: true, label: 'F' }, 'shard.y'),
|
|
39
50
|
];
|
|
40
|
-
const model = buildContextMenuModel(entries, state);
|
|
41
|
-
expect(model.tiers).
|
|
42
|
-
|
|
51
|
+
const model = buildContextMenuModel(entries, state, 'app');
|
|
52
|
+
expect(model.tiers.flatMap((t) => t.items).map((i) => i.id)).toEqual(['mine']);
|
|
53
|
+
});
|
|
54
|
+
it('home anchor only admits actions when no app is active', () => {
|
|
55
|
+
const noApp = mkState();
|
|
56
|
+
const withApp = mkState({ activeAppId: 'app.a' });
|
|
57
|
+
const entries = [
|
|
58
|
+
mkEntry({ id: 'h', scope: 'home', contextItem: true, label: 'H' }),
|
|
59
|
+
];
|
|
60
|
+
expect(buildContextMenuModel(entries, noApp, 'home').tiers).toHaveLength(1);
|
|
61
|
+
expect(buildContextMenuModel(entries, withApp, 'home').tiers).toHaveLength(0);
|
|
43
62
|
});
|
|
44
|
-
});
|
|
45
|
-
describe('buildContextMenuModel — extended fields', () => {
|
|
46
63
|
it('flags checked / disabled / submenu and excludes children', () => {
|
|
47
64
|
const entries = [
|
|
48
65
|
mkEntry({ id: 'p', label: 'P', scope: 'home', contextItem: true, submenu: true }),
|
|
@@ -50,22 +67,30 @@ describe('buildContextMenuModel — extended fields', () => {
|
|
|
50
67
|
mkEntry({ id: 't', label: 'T', scope: 'home', contextItem: true, checked: true }),
|
|
51
68
|
mkEntry({ id: 'd', label: 'D', scope: 'home', contextItem: true, disabled: () => true }),
|
|
52
69
|
];
|
|
53
|
-
const model = buildContextMenuModel(entries, mkState());
|
|
70
|
+
const model = buildContextMenuModel(entries, mkState(), 'home');
|
|
54
71
|
const homeItems = model.tiers.find((t) => t.tier === 'home').items;
|
|
55
72
|
expect(homeItems.map((i) => i.id)).toEqual(['p', 't', 'd']);
|
|
56
73
|
expect(homeItems[0].submenu).toBe(true);
|
|
57
74
|
expect(homeItems[1].checked).toBe(true);
|
|
58
75
|
expect(homeItems[2].disabled).toBe(true);
|
|
59
76
|
});
|
|
77
|
+
it('returns an empty model when no action matches the anchor', () => {
|
|
78
|
+
const entries = [
|
|
79
|
+
mkEntry({ id: 'x', scope: 'home', contextItem: true, label: 'X' }),
|
|
80
|
+
];
|
|
81
|
+
const model = buildContextMenuModel(entries, mkState(), 'focus:nope');
|
|
82
|
+
expect(model.tiers).toEqual([]);
|
|
83
|
+
});
|
|
60
84
|
});
|
|
61
85
|
describe('buildContextMenuSubmenu', () => {
|
|
62
|
-
it('returns
|
|
86
|
+
it('returns children of the parent that match the anchor', () => {
|
|
63
87
|
const entries = [
|
|
64
88
|
mkEntry({ id: 'p', label: 'P', scope: 'home', contextItem: true, submenu: true }),
|
|
65
89
|
mkEntry({ id: 'p.a', label: 'A', scope: 'home', submenuOf: 'p' }),
|
|
66
90
|
mkEntry({ id: 'p.b', label: 'B', scope: 'home', submenuOf: 'p' }),
|
|
91
|
+
mkEntry({ id: 'p.c', label: 'C', scope: 'app', submenuOf: 'p' }),
|
|
67
92
|
];
|
|
68
|
-
const items = buildContextMenuSubmenu(entries, mkState(), 'p');
|
|
93
|
+
const items = buildContextMenuSubmenu(entries, mkState(), 'p', 'home');
|
|
69
94
|
expect(items.map((i) => i.id)).toEqual(['p.a', 'p.b']);
|
|
70
95
|
});
|
|
71
96
|
});
|
|
@@ -13,6 +13,7 @@ import { buildContextMenuModel, buildContextMenuSubmenu } from './contextMenuMod
|
|
|
13
13
|
import ActionPanel from './ActionPanel.svelte';
|
|
14
14
|
import CommandPalette from './CommandPalette.svelte';
|
|
15
15
|
import { buildPaletteCandidates } from './paletteModel';
|
|
16
|
+
import { parseScopeString } from './scope-helpers';
|
|
16
17
|
import { shell } from '../shellRuntime.svelte';
|
|
17
18
|
let attached = false;
|
|
18
19
|
function viewIdOfEl(el) {
|
|
@@ -22,6 +23,30 @@ function viewIdOfEl(el) {
|
|
|
22
23
|
const host = el.closest('[data-sh3-view]');
|
|
23
24
|
return (_a = host === null || host === void 0 ? void 0 : host.getAttribute('data-sh3-view')) !== null && _a !== void 0 ? _a : null;
|
|
24
25
|
}
|
|
26
|
+
function resolveAnchor(args) {
|
|
27
|
+
var _a, _b;
|
|
28
|
+
if (args.explicit !== undefined)
|
|
29
|
+
return args.explicit;
|
|
30
|
+
const target = (_a = args.event) === null || _a === void 0 ? void 0 : _a.target;
|
|
31
|
+
if (target instanceof Element) {
|
|
32
|
+
// Preferred path: data-sh3-scope carries the literal AtomicScope encoding
|
|
33
|
+
// (see ADR-021 amendment 2026-05-01). Walked first so a sub-region
|
|
34
|
+
// overrides its enclosing slot host's auto-stamped focus:<viewId>.
|
|
35
|
+
const scopeHost = target.closest('[data-sh3-scope]');
|
|
36
|
+
if (scopeHost) {
|
|
37
|
+
const parsed = parseScopeString((_b = scopeHost.getAttribute('data-sh3-scope')) !== null && _b !== void 0 ? _b : '');
|
|
38
|
+
if (parsed)
|
|
39
|
+
return parsed;
|
|
40
|
+
}
|
|
41
|
+
// Fallback: data-sh3-view alone still maps to focus:<viewId>. Defensive
|
|
42
|
+
// for stub views and external callers that haven't adopted the new
|
|
43
|
+
// attribute; framework-stamped slot hosts now carry both.
|
|
44
|
+
const viewId = viewIdOfEl(target);
|
|
45
|
+
if (viewId)
|
|
46
|
+
return `focus:${viewId}`;
|
|
47
|
+
}
|
|
48
|
+
return args.state.activeAppId ? 'app' : 'home';
|
|
49
|
+
}
|
|
25
50
|
function runAction(actionId, ctx) {
|
|
26
51
|
const entry = listActions().find((e) => e.action.id === actionId);
|
|
27
52
|
if (!entry || typeof entry.action.run !== 'function')
|
|
@@ -71,7 +96,7 @@ function isNativeOptOut(target) {
|
|
|
71
96
|
return false;
|
|
72
97
|
return target.closest('[data-sh3-context-menu="native"]') !== null;
|
|
73
98
|
}
|
|
74
|
-
function openContextSubmenu(parentId, state, handle) {
|
|
99
|
+
function openContextSubmenu(parentId, state, handle, anchor) {
|
|
75
100
|
const root = document.querySelector('.sh3-popup-host');
|
|
76
101
|
if (!root)
|
|
77
102
|
return;
|
|
@@ -84,7 +109,7 @@ function openContextSubmenu(parentId, state, handle) {
|
|
|
84
109
|
sub.style.left = `${anchorRect.right + 2}px`;
|
|
85
110
|
sub.style.top = `${anchorRect.top}px`;
|
|
86
111
|
root.appendChild(sub);
|
|
87
|
-
const subItems = buildContextMenuSubmenu(listActions(), state, parentId);
|
|
112
|
+
const subItems = buildContextMenuSubmenu(listActions(), state, parentId, anchor);
|
|
88
113
|
mount(ActionPanel, {
|
|
89
114
|
target: sub,
|
|
90
115
|
props: {
|
|
@@ -118,7 +143,8 @@ function onContextMenu(ev) {
|
|
|
118
143
|
return;
|
|
119
144
|
const entries = listActions();
|
|
120
145
|
const state = getLiveDispatcherState();
|
|
121
|
-
const
|
|
146
|
+
const anchor = resolveAnchor({ event: ev, state });
|
|
147
|
+
const model = buildContextMenuModel(entries, state, anchor);
|
|
122
148
|
if (model.tiers.length === 0)
|
|
123
149
|
return;
|
|
124
150
|
ev.preventDefault();
|
|
@@ -130,7 +156,7 @@ function onContextMenu(ev) {
|
|
|
130
156
|
if (!entry)
|
|
131
157
|
return;
|
|
132
158
|
if (entry.action.submenu === true) {
|
|
133
|
-
openContextSubmenu(id, state, handle);
|
|
159
|
+
openContextSubmenu(id, state, handle, anchor);
|
|
134
160
|
return;
|
|
135
161
|
}
|
|
136
162
|
if (typeof entry.action.run !== 'function')
|
|
@@ -188,8 +214,41 @@ export function detachGlobalListeners() {
|
|
|
188
214
|
document.removeEventListener('contextmenu', onContextMenu);
|
|
189
215
|
}
|
|
190
216
|
export function openContextMenu(opts) {
|
|
191
|
-
const
|
|
192
|
-
|
|
217
|
+
const entries = listActions();
|
|
218
|
+
const state = getLiveDispatcherState();
|
|
219
|
+
const anchor = resolveAnchor({ explicit: opts.scope, state });
|
|
220
|
+
const model = buildContextMenuModel(entries, state, anchor);
|
|
221
|
+
if (model.tiers.length === 0)
|
|
222
|
+
return;
|
|
223
|
+
const handle = shell.popup.show(ContextMenu, { anchor: { x: opts.x, y: opts.y } }, {
|
|
224
|
+
model,
|
|
225
|
+
onInvoke: (id) => {
|
|
226
|
+
var _a, _b;
|
|
227
|
+
const entry = listActions().find((e) => e.action.id === id);
|
|
228
|
+
if (!entry)
|
|
229
|
+
return;
|
|
230
|
+
if (entry.action.submenu === true) {
|
|
231
|
+
openContextSubmenu(id, state, handle, anchor);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (typeof entry.action.run !== 'function')
|
|
235
|
+
return;
|
|
236
|
+
try {
|
|
237
|
+
void entry.action.run({
|
|
238
|
+
action: { id, label: entry.action.label },
|
|
239
|
+
appId: state.activeAppId,
|
|
240
|
+
viewId: (_a = state.focusedViewId) !== null && _a !== void 0 ? _a : undefined,
|
|
241
|
+
selection: (_b = state.selection) !== null && _b !== void 0 ? _b : undefined,
|
|
242
|
+
invokedVia: 'context-menu',
|
|
243
|
+
dispatch: chainedDispatch,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
console.error(`[sh3] context-menu action "${id}" threw:`, err);
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
onClose: () => handle.close(),
|
|
251
|
+
});
|
|
193
252
|
}
|
|
194
253
|
const RECENCY_CAP = 20;
|
|
195
254
|
let recency = [];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
-
import { attachGlobalListeners, detachGlobalListeners, openPalette } from './listeners';
|
|
2
|
+
import { attachGlobalListeners, detachGlobalListeners, openPalette, openContextMenu } from './listeners';
|
|
3
3
|
import { registerAction, __resetActionsRegistryForTest } from './registry';
|
|
4
4
|
import { __resetContributionsForTest } from '../contributions/registry';
|
|
5
5
|
import { __resetDispatcherStateForTest, setActiveApp, setMountedViewIds, setFocusedViewId, } from './state.svelte';
|
|
@@ -82,14 +82,14 @@ describe('global contextmenu listener', () => {
|
|
|
82
82
|
popupLayerRoot.remove();
|
|
83
83
|
vi.unstubAllGlobals();
|
|
84
84
|
});
|
|
85
|
-
it('opens popup
|
|
85
|
+
it('opens popup with home anchor when no view ancestor and no active app', () => {
|
|
86
86
|
registerAction({ id: 'a.x', label: 'Dup', scope: 'home', contextItem: true, run: () => { } }, 'a');
|
|
87
87
|
const target = document.createElement('div');
|
|
88
88
|
document.body.appendChild(target);
|
|
89
89
|
const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
|
|
90
90
|
Object.defineProperty(ev, 'target', { value: target });
|
|
91
91
|
const def = target.dispatchEvent(ev);
|
|
92
|
-
expect(def).toBe(false);
|
|
92
|
+
expect(def).toBe(false);
|
|
93
93
|
expect(document.querySelector('.sh3-context-menu')).not.toBeNull();
|
|
94
94
|
target.remove();
|
|
95
95
|
});
|
|
@@ -101,21 +101,110 @@ describe('global contextmenu listener', () => {
|
|
|
101
101
|
const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
|
|
102
102
|
Object.defineProperty(ev, 'target', { value: target });
|
|
103
103
|
const def = target.dispatchEvent(ev);
|
|
104
|
-
expect(def).toBe(true);
|
|
104
|
+
expect(def).toBe(true);
|
|
105
105
|
expect(document.querySelector('.sh3-context-menu')).toBeNull();
|
|
106
106
|
target.remove();
|
|
107
107
|
});
|
|
108
|
-
it('does not open when no contextItem actions
|
|
108
|
+
it('does not open when no contextItem actions match the anchor', () => {
|
|
109
109
|
registerAction({ id: 'a.x', label: 'Save', scope: 'home', contextItem: false, run: () => { } }, 'a');
|
|
110
110
|
const target = document.createElement('div');
|
|
111
111
|
document.body.appendChild(target);
|
|
112
112
|
const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
|
|
113
113
|
Object.defineProperty(ev, 'target', { value: target });
|
|
114
114
|
const def = target.dispatchEvent(ev);
|
|
115
|
-
expect(def).toBe(true);
|
|
115
|
+
expect(def).toBe(true);
|
|
116
116
|
target.remove();
|
|
117
117
|
});
|
|
118
|
-
it('
|
|
118
|
+
it('right-click inside data-sh3-view anchors to focus:<viewId>', async () => {
|
|
119
|
+
setActiveApp('app.a', new Set(['shard.x']));
|
|
120
|
+
setMountedViewIds(new Set(['editor']));
|
|
121
|
+
registerAction({ id: 'view-only', label: 'View', scope: 'focus:editor', contextItem: true, run: () => { } }, 'shard.x');
|
|
122
|
+
registerAction({ id: 'home-only', label: 'Home', scope: 'home', contextItem: true, run: () => { } }, 'shard.x');
|
|
123
|
+
const wrap = document.createElement('div');
|
|
124
|
+
wrap.setAttribute('data-sh3-view', 'editor');
|
|
125
|
+
document.body.appendChild(wrap);
|
|
126
|
+
const inner = document.createElement('button');
|
|
127
|
+
wrap.appendChild(inner);
|
|
128
|
+
const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
|
|
129
|
+
Object.defineProperty(ev, 'target', { value: inner });
|
|
130
|
+
inner.dispatchEvent(ev);
|
|
131
|
+
await Promise.resolve();
|
|
132
|
+
const labels = [...document.querySelectorAll('.sh3-context-menu [role="menuitem"] .sh3-ctx-label')]
|
|
133
|
+
.map((n) => n.textContent);
|
|
134
|
+
expect(labels).toEqual(['View']);
|
|
135
|
+
wrap.remove();
|
|
136
|
+
});
|
|
137
|
+
it('right-click outside any view falls back to app anchor when an app is active', async () => {
|
|
138
|
+
setActiveApp('app.a', new Set(['shard.x']));
|
|
139
|
+
registerAction({ id: 'app.a', label: 'App', scope: 'app', contextItem: true, run: () => { } }, 'shard.x');
|
|
140
|
+
registerAction({ id: 'h.a', label: 'Home', scope: 'home', contextItem: true, run: () => { } }, 'shard.x');
|
|
141
|
+
const target = document.createElement('div');
|
|
142
|
+
document.body.appendChild(target);
|
|
143
|
+
const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
|
|
144
|
+
Object.defineProperty(ev, 'target', { value: target });
|
|
145
|
+
target.dispatchEvent(ev);
|
|
146
|
+
await Promise.resolve();
|
|
147
|
+
const labels = [...document.querySelectorAll('.sh3-context-menu [role="menuitem"] .sh3-ctx-label')]
|
|
148
|
+
.map((n) => n.textContent);
|
|
149
|
+
expect(labels).toEqual(['App']);
|
|
150
|
+
target.remove();
|
|
151
|
+
});
|
|
152
|
+
it('right-click inside data-sh3-scope="element:..." anchors to that element atom', async () => {
|
|
153
|
+
setActiveApp('app.a', new Set(['shard.x']));
|
|
154
|
+
setMountedViewIds(new Set(['editor']));
|
|
155
|
+
registerAction({ id: 'el-only', label: 'El', scope: { element: 'svg-designer:layer' }, contextItem: true, run: () => { } }, 'shard.x');
|
|
156
|
+
registerAction({ id: 'view-only', label: 'View', scope: 'focus:editor', contextItem: true, run: () => { } }, 'shard.x');
|
|
157
|
+
// Slot-host shape: framework auto-stamps both attributes. The inner div
|
|
158
|
+
// overrides scope only — viewId identity is unchanged. closest('[data-sh3-scope]')
|
|
159
|
+
// from the click target finds the inner div first.
|
|
160
|
+
const slotHost = document.createElement('div');
|
|
161
|
+
slotHost.setAttribute('data-sh3-view', 'editor');
|
|
162
|
+
slotHost.setAttribute('data-sh3-scope', 'focus:editor');
|
|
163
|
+
document.body.appendChild(slotHost);
|
|
164
|
+
const inner = document.createElement('div');
|
|
165
|
+
inner.setAttribute('data-sh3-scope', 'element:svg-designer:layer');
|
|
166
|
+
slotHost.appendChild(inner);
|
|
167
|
+
const target = document.createElement('button');
|
|
168
|
+
inner.appendChild(target);
|
|
169
|
+
const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
|
|
170
|
+
Object.defineProperty(ev, 'target', { value: target });
|
|
171
|
+
target.dispatchEvent(ev);
|
|
172
|
+
await Promise.resolve();
|
|
173
|
+
const labels = [...document.querySelectorAll('.sh3-context-menu [role="menuitem"] .sh3-ctx-label')]
|
|
174
|
+
.map((n) => n.textContent);
|
|
175
|
+
expect(labels).toEqual(['El']);
|
|
176
|
+
slotHost.remove();
|
|
177
|
+
});
|
|
178
|
+
it('right-click outside any data-sh3-scope override falls back to focus:<viewId>', async () => {
|
|
179
|
+
setActiveApp('app.a', new Set(['shard.x']));
|
|
180
|
+
setMountedViewIds(new Set(['editor']));
|
|
181
|
+
registerAction({ id: 'view-only', label: 'View', scope: 'focus:editor', contextItem: true, run: () => { } }, 'shard.x');
|
|
182
|
+
registerAction({ id: 'el-only', label: 'El', scope: { element: 'cell' }, contextItem: true, run: () => { } }, 'shard.x');
|
|
183
|
+
const slotHost = document.createElement('div');
|
|
184
|
+
slotHost.setAttribute('data-sh3-view', 'editor');
|
|
185
|
+
slotHost.setAttribute('data-sh3-scope', 'focus:editor');
|
|
186
|
+
document.body.appendChild(slotHost);
|
|
187
|
+
const target = document.createElement('button');
|
|
188
|
+
slotHost.appendChild(target);
|
|
189
|
+
const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
|
|
190
|
+
Object.defineProperty(ev, 'target', { value: target });
|
|
191
|
+
target.dispatchEvent(ev);
|
|
192
|
+
await Promise.resolve();
|
|
193
|
+
const labels = [...document.querySelectorAll('.sh3-context-menu [role="menuitem"] .sh3-ctx-label')]
|
|
194
|
+
.map((n) => n.textContent);
|
|
195
|
+
expect(labels).toEqual(['View']);
|
|
196
|
+
slotHost.remove();
|
|
197
|
+
});
|
|
198
|
+
it('openContextMenu({scope}) uses the explicit anchor', async () => {
|
|
199
|
+
registerAction({ id: 'cell.copy', label: 'Copy Cell', scope: { element: 'cell' }, contextItem: true, run: () => { } }, 'shard.x');
|
|
200
|
+
registerAction({ id: 'home.x', label: 'Home', scope: 'home', contextItem: true, run: () => { } }, 'shard.x');
|
|
201
|
+
openContextMenu({ x: 10, y: 20, scope: { element: 'cell' } });
|
|
202
|
+
await Promise.resolve();
|
|
203
|
+
const labels = [...document.querySelectorAll('.sh3-context-menu [role="menuitem"] .sh3-ctx-label')]
|
|
204
|
+
.map((n) => n.textContent);
|
|
205
|
+
expect(labels).toEqual(['Copy Cell']);
|
|
206
|
+
});
|
|
207
|
+
it('clicking a submenu-parent row opens children filtered by the same anchor', async () => {
|
|
119
208
|
registerAction({ id: 'p', label: 'Open with…', scope: 'home', contextItem: true, submenu: true }, 'a');
|
|
120
209
|
registerAction({ id: 'p.a', label: 'A', scope: 'home', submenuOf: 'p', run: () => { } }, 'a');
|
|
121
210
|
registerAction({ id: 'p.b', label: 'B', scope: 'home', submenuOf: 'p', run: () => { } }, 'a');
|
|
@@ -125,7 +214,6 @@ describe('global contextmenu listener', () => {
|
|
|
125
214
|
Object.defineProperty(ev, 'target', { value: target });
|
|
126
215
|
target.dispatchEvent(ev);
|
|
127
216
|
await Promise.resolve();
|
|
128
|
-
expect(document.querySelectorAll('.sh3-context-menu')).toHaveLength(1);
|
|
129
217
|
const parentRow = document.querySelector('.sh3-context-menu [role="menuitem"]');
|
|
130
218
|
parentRow.click();
|
|
131
219
|
await Promise.resolve();
|
|
@@ -9,3 +9,26 @@ export declare function scopeBadge(scope: AtomicScope): string | null;
|
|
|
9
9
|
* scope is active.
|
|
10
10
|
*/
|
|
11
11
|
export declare function innermostActiveScope(scope: ActionScope, state: DispatcherState, ownerShardId: string): AtomicScope | null;
|
|
12
|
+
/**
|
|
13
|
+
* Value equality for `AtomicScope`. String atoms compare by string equality;
|
|
14
|
+
* element atoms (`{ element: T }`) compare by their `element` field. A string
|
|
15
|
+
* atom is never equal to an element atom.
|
|
16
|
+
*/
|
|
17
|
+
export declare function scopeEquals(a: AtomicScope, b: AtomicScope): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Canonical string encoding of an `AtomicScope` for transport over surfaces
|
|
20
|
+
* that can only carry strings — most notably `data-sh3-scope` DOM attribute
|
|
21
|
+
* values. String atoms (`home`, `app`, `view:<id>`, `focus:<id>`) pass
|
|
22
|
+
* through unchanged; element atoms encode as `element:<type>`. The element
|
|
23
|
+
* type itself may contain colons (e.g. `shard:type`) — only the leading
|
|
24
|
+
* `element:` is reserved by the encoding.
|
|
25
|
+
*/
|
|
26
|
+
export declare function scopeToString(scope: AtomicScope): string;
|
|
27
|
+
/**
|
|
28
|
+
* Inverse of `scopeToString`. Returns `null` for inputs that do not parse to
|
|
29
|
+
* a valid `AtomicScope` so callers (e.g. the contextmenu listener reading
|
|
30
|
+
* `data-sh3-scope` from arbitrary DOM) can fall back gracefully without
|
|
31
|
+
* throwing on a malformed attribute. Empty bodies after a known prefix
|
|
32
|
+
* (`view:`, `focus:`, `element:`) also return null.
|
|
33
|
+
*/
|
|
34
|
+
export declare function parseScopeString(s: string): AtomicScope | null;
|
|
@@ -49,3 +49,50 @@ export function innermostActiveScope(scope, state, ownerShardId) {
|
|
|
49
49
|
}
|
|
50
50
|
return null;
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Value equality for `AtomicScope`. String atoms compare by string equality;
|
|
54
|
+
* element atoms (`{ element: T }`) compare by their `element` field. A string
|
|
55
|
+
* atom is never equal to an element atom.
|
|
56
|
+
*/
|
|
57
|
+
export function scopeEquals(a, b) {
|
|
58
|
+
if (typeof a === 'string' || typeof b === 'string')
|
|
59
|
+
return a === b;
|
|
60
|
+
return a.element === b.element;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Canonical string encoding of an `AtomicScope` for transport over surfaces
|
|
64
|
+
* that can only carry strings — most notably `data-sh3-scope` DOM attribute
|
|
65
|
+
* values. String atoms (`home`, `app`, `view:<id>`, `focus:<id>`) pass
|
|
66
|
+
* through unchanged; element atoms encode as `element:<type>`. The element
|
|
67
|
+
* type itself may contain colons (e.g. `shard:type`) — only the leading
|
|
68
|
+
* `element:` is reserved by the encoding.
|
|
69
|
+
*/
|
|
70
|
+
export function scopeToString(scope) {
|
|
71
|
+
if (typeof scope === 'string')
|
|
72
|
+
return scope;
|
|
73
|
+
return `element:${scope.element}`;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Inverse of `scopeToString`. Returns `null` for inputs that do not parse to
|
|
77
|
+
* a valid `AtomicScope` so callers (e.g. the contextmenu listener reading
|
|
78
|
+
* `data-sh3-scope` from arbitrary DOM) can fall back gracefully without
|
|
79
|
+
* throwing on a malformed attribute. Empty bodies after a known prefix
|
|
80
|
+
* (`view:`, `focus:`, `element:`) also return null.
|
|
81
|
+
*/
|
|
82
|
+
export function parseScopeString(s) {
|
|
83
|
+
if (s === 'home' || s === 'app')
|
|
84
|
+
return s;
|
|
85
|
+
if (s.startsWith('view:')) {
|
|
86
|
+
const rest = s.slice('view:'.length);
|
|
87
|
+
return rest.length > 0 ? s : null;
|
|
88
|
+
}
|
|
89
|
+
if (s.startsWith('focus:')) {
|
|
90
|
+
const rest = s.slice('focus:'.length);
|
|
91
|
+
return rest.length > 0 ? s : null;
|
|
92
|
+
}
|
|
93
|
+
if (s.startsWith('element:')) {
|
|
94
|
+
const rest = s.slice('element:'.length);
|
|
95
|
+
return rest.length > 0 ? { element: rest } : null;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { scopeToTier, normalizeScope, scopeBadge, innermostActiveScope, } from './scope-helpers';
|
|
2
|
+
import { scopeToTier, normalizeScope, scopeBadge, innermostActiveScope, scopeEquals, scopeToString, parseScopeString, } from './scope-helpers';
|
|
3
3
|
const mkState = (o = {}) => (Object.assign({ activeAppId: null, activeAppRequiredShards: new Set(), autostartShards: new Set(), mountedViewIds: new Set(), focusedViewId: null, selection: null, bindings: {}, platform: 'other' }, o));
|
|
4
4
|
describe('scopeToTier', () => {
|
|
5
5
|
it('maps atoms to tier names', () => {
|
|
@@ -60,3 +60,58 @@ describe('innermostActiveScope', () => {
|
|
|
60
60
|
expect(winner).toEqual({ element: 'row' });
|
|
61
61
|
});
|
|
62
62
|
});
|
|
63
|
+
describe('scopeEquals', () => {
|
|
64
|
+
it('returns true for identical string atoms', () => {
|
|
65
|
+
expect(scopeEquals('home', 'home')).toBe(true);
|
|
66
|
+
expect(scopeEquals('app', 'app')).toBe(true);
|
|
67
|
+
expect(scopeEquals('view:editor', 'view:editor')).toBe(true);
|
|
68
|
+
expect(scopeEquals('focus:pane-1', 'focus:pane-1')).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
it('returns false for different string atoms', () => {
|
|
71
|
+
expect(scopeEquals('home', 'app')).toBe(false);
|
|
72
|
+
expect(scopeEquals('view:editor', 'view:other')).toBe(false);
|
|
73
|
+
expect(scopeEquals('focus:pane-1', 'view:pane-1')).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
it('returns true for element atoms with the same element type', () => {
|
|
76
|
+
expect(scopeEquals({ element: 'cell' }, { element: 'cell' })).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
it('returns false for element atoms with different element types', () => {
|
|
79
|
+
expect(scopeEquals({ element: 'cell' }, { element: 'row' })).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
it('returns false when comparing string atom to element atom', () => {
|
|
82
|
+
expect(scopeEquals('home', { element: 'cell' })).toBe(false);
|
|
83
|
+
expect(scopeEquals({ element: 'cell' }, 'home')).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
describe('scopeToString / parseScopeString', () => {
|
|
87
|
+
const cases = [
|
|
88
|
+
'home',
|
|
89
|
+
'app',
|
|
90
|
+
'view:editor',
|
|
91
|
+
'focus:pane-1',
|
|
92
|
+
{ element: 'cell' },
|
|
93
|
+
// Element type containing a colon — common shape (e.g. shard:type).
|
|
94
|
+
{ element: 'svg-designer:layer' },
|
|
95
|
+
];
|
|
96
|
+
it('round-trips every AtomicScope kind', () => {
|
|
97
|
+
for (const s of cases) {
|
|
98
|
+
const parsed = parseScopeString(scopeToString(s));
|
|
99
|
+
expect(parsed).toEqual(s);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
it('encodes element atoms with the element: prefix', () => {
|
|
103
|
+
expect(scopeToString({ element: 'cell' })).toBe('element:cell');
|
|
104
|
+
expect(scopeToString({ element: 'svg-designer:layer' })).toBe('element:svg-designer:layer');
|
|
105
|
+
});
|
|
106
|
+
it('passes string atoms through unchanged', () => {
|
|
107
|
+
expect(scopeToString('home')).toBe('home');
|
|
108
|
+
expect(scopeToString('focus:pane-1')).toBe('focus:pane-1');
|
|
109
|
+
});
|
|
110
|
+
it('returns null on unknown / malformed inputs', () => {
|
|
111
|
+
expect(parseScopeString('')).toBeNull();
|
|
112
|
+
expect(parseScopeString('bogus')).toBeNull();
|
|
113
|
+
expect(parseScopeString('element:')).toBeNull();
|
|
114
|
+
expect(parseScopeString('view:')).toBeNull();
|
|
115
|
+
expect(parseScopeString('focus:')).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
});
|
package/dist/actions/types.d.ts
CHANGED
package/dist/api.d.ts
CHANGED
|
@@ -27,7 +27,8 @@ export type { ConflictItem, ConflictBranch as ConflictManagerBranch, ResolveOpti
|
|
|
27
27
|
export { CONFLICT_RENDERER_POINT, ConflictPermissionError, ConflictSessionOrphanedError, } from './conflicts/api';
|
|
28
28
|
export type { ColorPickOptions, ColorContribution, ColorApi, } from './color/api';
|
|
29
29
|
export { COLOR_PICKER_POINT } from './color/api';
|
|
30
|
-
export { registeredShards, activeShards } from './shards/activate.svelte';
|
|
30
|
+
export { registeredShards, activeShards, erroredShards } from './shards/activate.svelte';
|
|
31
|
+
export type { ShardErrorEntry } from './shards/activate.svelte';
|
|
31
32
|
export type { RegistryIndex, PackageEntry, PackageVersion, RequiredDependency, InstalledPackage, InstallResult, PackageMeta, } from './registry/types';
|
|
32
33
|
export type { ResolvedPackage } from './registry/client';
|
|
33
34
|
export { fetchRegistries, fetchBundle, buildPackageMeta } from './registry/client';
|