sh3-core 0.11.7 → 0.11.8
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 +50 -6
- package/dist/actions/listeners.test.js +50 -8
- package/dist/actions/scope-helpers.d.ts +6 -0
- package/dist/actions/scope-helpers.js +10 -0
- package/dist/actions/scope-helpers.test.js +24 -1
- package/dist/actions/types.d.ts +1 -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/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
|
});
|
|
@@ -22,6 +22,16 @@ function viewIdOfEl(el) {
|
|
|
22
22
|
const host = el.closest('[data-sh3-view]');
|
|
23
23
|
return (_a = host === null || host === void 0 ? void 0 : host.getAttribute('data-sh3-view')) !== null && _a !== void 0 ? _a : null;
|
|
24
24
|
}
|
|
25
|
+
function resolveAnchor(args) {
|
|
26
|
+
if (args.explicit !== undefined)
|
|
27
|
+
return args.explicit;
|
|
28
|
+
if (args.event && args.event.target) {
|
|
29
|
+
const viewId = viewIdOfEl(args.event.target);
|
|
30
|
+
if (viewId)
|
|
31
|
+
return `focus:${viewId}`;
|
|
32
|
+
}
|
|
33
|
+
return args.state.activeAppId ? 'app' : 'home';
|
|
34
|
+
}
|
|
25
35
|
function runAction(actionId, ctx) {
|
|
26
36
|
const entry = listActions().find((e) => e.action.id === actionId);
|
|
27
37
|
if (!entry || typeof entry.action.run !== 'function')
|
|
@@ -71,7 +81,7 @@ function isNativeOptOut(target) {
|
|
|
71
81
|
return false;
|
|
72
82
|
return target.closest('[data-sh3-context-menu="native"]') !== null;
|
|
73
83
|
}
|
|
74
|
-
function openContextSubmenu(parentId, state, handle) {
|
|
84
|
+
function openContextSubmenu(parentId, state, handle, anchor) {
|
|
75
85
|
const root = document.querySelector('.sh3-popup-host');
|
|
76
86
|
if (!root)
|
|
77
87
|
return;
|
|
@@ -84,7 +94,7 @@ function openContextSubmenu(parentId, state, handle) {
|
|
|
84
94
|
sub.style.left = `${anchorRect.right + 2}px`;
|
|
85
95
|
sub.style.top = `${anchorRect.top}px`;
|
|
86
96
|
root.appendChild(sub);
|
|
87
|
-
const subItems = buildContextMenuSubmenu(listActions(), state, parentId);
|
|
97
|
+
const subItems = buildContextMenuSubmenu(listActions(), state, parentId, anchor);
|
|
88
98
|
mount(ActionPanel, {
|
|
89
99
|
target: sub,
|
|
90
100
|
props: {
|
|
@@ -118,7 +128,8 @@ function onContextMenu(ev) {
|
|
|
118
128
|
return;
|
|
119
129
|
const entries = listActions();
|
|
120
130
|
const state = getLiveDispatcherState();
|
|
121
|
-
const
|
|
131
|
+
const anchor = resolveAnchor({ event: ev, state });
|
|
132
|
+
const model = buildContextMenuModel(entries, state, anchor);
|
|
122
133
|
if (model.tiers.length === 0)
|
|
123
134
|
return;
|
|
124
135
|
ev.preventDefault();
|
|
@@ -130,7 +141,7 @@ function onContextMenu(ev) {
|
|
|
130
141
|
if (!entry)
|
|
131
142
|
return;
|
|
132
143
|
if (entry.action.submenu === true) {
|
|
133
|
-
openContextSubmenu(id, state, handle);
|
|
144
|
+
openContextSubmenu(id, state, handle, anchor);
|
|
134
145
|
return;
|
|
135
146
|
}
|
|
136
147
|
if (typeof entry.action.run !== 'function')
|
|
@@ -188,8 +199,41 @@ export function detachGlobalListeners() {
|
|
|
188
199
|
document.removeEventListener('contextmenu', onContextMenu);
|
|
189
200
|
}
|
|
190
201
|
export function openContextMenu(opts) {
|
|
191
|
-
const
|
|
192
|
-
|
|
202
|
+
const entries = listActions();
|
|
203
|
+
const state = getLiveDispatcherState();
|
|
204
|
+
const anchor = resolveAnchor({ explicit: opts.scope, state });
|
|
205
|
+
const model = buildContextMenuModel(entries, state, anchor);
|
|
206
|
+
if (model.tiers.length === 0)
|
|
207
|
+
return;
|
|
208
|
+
const handle = shell.popup.show(ContextMenu, { anchor: { x: opts.x, y: opts.y } }, {
|
|
209
|
+
model,
|
|
210
|
+
onInvoke: (id) => {
|
|
211
|
+
var _a, _b;
|
|
212
|
+
const entry = listActions().find((e) => e.action.id === id);
|
|
213
|
+
if (!entry)
|
|
214
|
+
return;
|
|
215
|
+
if (entry.action.submenu === true) {
|
|
216
|
+
openContextSubmenu(id, state, handle, anchor);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (typeof entry.action.run !== 'function')
|
|
220
|
+
return;
|
|
221
|
+
try {
|
|
222
|
+
void entry.action.run({
|
|
223
|
+
action: { id, label: entry.action.label },
|
|
224
|
+
appId: state.activeAppId,
|
|
225
|
+
viewId: (_a = state.focusedViewId) !== null && _a !== void 0 ? _a : undefined,
|
|
226
|
+
selection: (_b = state.selection) !== null && _b !== void 0 ? _b : undefined,
|
|
227
|
+
invokedVia: 'context-menu',
|
|
228
|
+
dispatch: chainedDispatch,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
console.error(`[sh3] context-menu action "${id}" threw:`, err);
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
onClose: () => handle.close(),
|
|
236
|
+
});
|
|
193
237
|
}
|
|
194
238
|
const RECENCY_CAP = 20;
|
|
195
239
|
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,64 @@ 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('openContextMenu({scope}) uses the explicit anchor', async () => {
|
|
153
|
+
registerAction({ id: 'cell.copy', label: 'Copy Cell', scope: { element: 'cell' }, contextItem: true, run: () => { } }, 'shard.x');
|
|
154
|
+
registerAction({ id: 'home.x', label: 'Home', scope: 'home', contextItem: true, run: () => { } }, 'shard.x');
|
|
155
|
+
openContextMenu({ x: 10, y: 20, scope: { element: 'cell' } });
|
|
156
|
+
await Promise.resolve();
|
|
157
|
+
const labels = [...document.querySelectorAll('.sh3-context-menu [role="menuitem"] .sh3-ctx-label')]
|
|
158
|
+
.map((n) => n.textContent);
|
|
159
|
+
expect(labels).toEqual(['Copy Cell']);
|
|
160
|
+
});
|
|
161
|
+
it('clicking a submenu-parent row opens children filtered by the same anchor', async () => {
|
|
119
162
|
registerAction({ id: 'p', label: 'Open with…', scope: 'home', contextItem: true, submenu: true }, 'a');
|
|
120
163
|
registerAction({ id: 'p.a', label: 'A', scope: 'home', submenuOf: 'p', run: () => { } }, 'a');
|
|
121
164
|
registerAction({ id: 'p.b', label: 'B', scope: 'home', submenuOf: 'p', run: () => { } }, 'a');
|
|
@@ -125,7 +168,6 @@ describe('global contextmenu listener', () => {
|
|
|
125
168
|
Object.defineProperty(ev, 'target', { value: target });
|
|
126
169
|
target.dispatchEvent(ev);
|
|
127
170
|
await Promise.resolve();
|
|
128
|
-
expect(document.querySelectorAll('.sh3-context-menu')).toHaveLength(1);
|
|
129
171
|
const parentRow = document.querySelector('.sh3-context-menu [role="menuitem"]');
|
|
130
172
|
parentRow.click();
|
|
131
173
|
await Promise.resolve();
|
|
@@ -9,3 +9,9 @@ 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;
|
|
@@ -49,3 +49,13 @@ 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
|
+
}
|
|
@@ -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, } 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,26 @@ 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
|
+
});
|
package/dist/actions/types.d.ts
CHANGED
|
@@ -76,6 +76,21 @@ export interface BrowseCapability {
|
|
|
76
76
|
renameFrom?(shardId: string, oldPath: string, newPath: string, opts?: {
|
|
77
77
|
newShardId?: string;
|
|
78
78
|
}): Promise<void>;
|
|
79
|
+
/**
|
|
80
|
+
* Delete a document in another shard's namespace within the active
|
|
81
|
+
* tenant. Available only when the caller declares both
|
|
82
|
+
* `documents:browse` and `documents:write`. Emits a `'delete'`
|
|
83
|
+
* `DocumentChange` so other shards and the file-explorer pick up
|
|
84
|
+
* the removal. Tenant-scoped — cannot cross tenants.
|
|
85
|
+
*
|
|
86
|
+
* Idempotent: deleting a non-existent path resolves successfully
|
|
87
|
+
* and emits no change event.
|
|
88
|
+
*
|
|
89
|
+
* Absent (undefined) on the capability object when `documents:write`
|
|
90
|
+
* is not declared; feature-detect with
|
|
91
|
+
* `typeof ctx.browse.deleteFrom === 'function'`.
|
|
92
|
+
*/
|
|
93
|
+
deleteFrom?(shardId: string, path: string): Promise<void>;
|
|
79
94
|
}
|
|
80
95
|
export interface BrowseCapabilityOptions {
|
|
81
96
|
/** When true, the returned capability exposes `readFrom`. */
|
package/dist/documents/browse.js
CHANGED
|
@@ -59,6 +59,13 @@ export function createBrowseCapability(tenantId, backend, options = { canRead: f
|
|
|
59
59
|
shardId,
|
|
60
60
|
});
|
|
61
61
|
};
|
|
62
|
+
capability.deleteFrom = async (shardId, path) => {
|
|
63
|
+
const existed = await backend.exists(tenantId, shardId, path);
|
|
64
|
+
await backend.delete(tenantId, shardId, path);
|
|
65
|
+
if (existed) {
|
|
66
|
+
documentChanges.emit({ type: 'delete', path, tenantId, shardId });
|
|
67
|
+
}
|
|
68
|
+
};
|
|
62
69
|
}
|
|
63
70
|
return capability;
|
|
64
71
|
}
|
|
@@ -263,4 +263,45 @@ describe('BrowseCapability', () => {
|
|
|
263
263
|
.rejects.toThrow(/does not support resolveConflict/);
|
|
264
264
|
});
|
|
265
265
|
});
|
|
266
|
+
describe('deleteFrom (documents:write gate)', () => {
|
|
267
|
+
it('absent when canWrite is false', () => {
|
|
268
|
+
const be = new MemoryDocumentBackend();
|
|
269
|
+
const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
|
|
270
|
+
expect(browse.deleteFrom).toBeUndefined();
|
|
271
|
+
});
|
|
272
|
+
it('present when canWrite is true', () => {
|
|
273
|
+
const be = new MemoryDocumentBackend();
|
|
274
|
+
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
275
|
+
expect(typeof browse.deleteFrom).toBe('function');
|
|
276
|
+
});
|
|
277
|
+
it('deletes from the target shard namespace and emits a delete event', async () => {
|
|
278
|
+
const be = new MemoryDocumentBackend();
|
|
279
|
+
await be.write('t1', 'target-shard', 'a.txt', 'hello');
|
|
280
|
+
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
281
|
+
const events = [];
|
|
282
|
+
const unsub = documentChanges.subscribe((c) => events.push(c));
|
|
283
|
+
await browse.deleteFrom('target-shard', 'a.txt');
|
|
284
|
+
expect(await be.read('t1', 'target-shard', 'a.txt')).toBeNull();
|
|
285
|
+
expect(events).toEqual([
|
|
286
|
+
{ type: 'delete', path: 'a.txt', tenantId: 't1', shardId: 'target-shard' },
|
|
287
|
+
]);
|
|
288
|
+
unsub();
|
|
289
|
+
});
|
|
290
|
+
it('is idempotent on missing paths and emits no event', async () => {
|
|
291
|
+
const be = new MemoryDocumentBackend();
|
|
292
|
+
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
293
|
+
const events = [];
|
|
294
|
+
const unsub = documentChanges.subscribe((c) => events.push(c));
|
|
295
|
+
await expect(browse.deleteFrom('target-shard', 'nope.txt')).resolves.toBeUndefined();
|
|
296
|
+
expect(events).toEqual([]);
|
|
297
|
+
unsub();
|
|
298
|
+
});
|
|
299
|
+
it('never crosses tenants: a t1 capability cannot delete t2 docs', async () => {
|
|
300
|
+
const be = new MemoryDocumentBackend();
|
|
301
|
+
await be.write('t2', 's', 'secret.txt', 'hidden');
|
|
302
|
+
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
303
|
+
await browse.deleteFrom('s', 'secret.txt');
|
|
304
|
+
expect(await be.read('t2', 's', 'secret.txt')).toBe('hidden');
|
|
305
|
+
});
|
|
306
|
+
});
|
|
266
307
|
});
|
package/dist/documents/handle.js
CHANGED
|
@@ -55,8 +55,10 @@ export function createDocumentHandle(tenantId, shardId, backend, options) {
|
|
|
55
55
|
emitChange(existed ? 'update' : 'create', path);
|
|
56
56
|
},
|
|
57
57
|
async delete(path) {
|
|
58
|
+
const existed = await backend.exists(tenantId, shardId, path);
|
|
58
59
|
await backend.delete(tenantId, shardId, path);
|
|
59
|
-
|
|
60
|
+
if (existed)
|
|
61
|
+
emitChange('delete', path);
|
|
60
62
|
},
|
|
61
63
|
async rename(oldPath, newPath) {
|
|
62
64
|
if (!matchesExtensions(newPath)) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { MemoryDocumentBackend } from './backends';
|
|
3
3
|
import { createDocumentHandle } from './handle';
|
|
4
|
+
import { documentChanges } from './notifications';
|
|
4
5
|
function harness() {
|
|
5
6
|
const backend = new MemoryDocumentBackend();
|
|
6
7
|
const handle = createDocumentHandle('tenant1', 'shard1', backend, { format: 'text' });
|
|
@@ -141,3 +142,25 @@ describe('DocumentHandle.rename', () => {
|
|
|
141
142
|
.rejects.toThrow(/extensions/);
|
|
142
143
|
});
|
|
143
144
|
});
|
|
145
|
+
describe('DocumentHandle.delete()', () => {
|
|
146
|
+
it('emits a delete event when the path existed', async () => {
|
|
147
|
+
const { backend, handle } = harness();
|
|
148
|
+
await backend.write('tenant1', 'shard1', 'a.txt', 'hi');
|
|
149
|
+
const events = [];
|
|
150
|
+
const unsub = documentChanges.subscribe((c) => events.push(c));
|
|
151
|
+
await handle.delete('a.txt');
|
|
152
|
+
expect(await backend.read('tenant1', 'shard1', 'a.txt')).toBeNull();
|
|
153
|
+
expect(events).toEqual([
|
|
154
|
+
{ type: 'delete', path: 'a.txt', tenantId: 'tenant1', shardId: 'shard1' },
|
|
155
|
+
]);
|
|
156
|
+
unsub();
|
|
157
|
+
});
|
|
158
|
+
it('emits no event when the path did not exist', async () => {
|
|
159
|
+
const { handle } = harness();
|
|
160
|
+
const events = [];
|
|
161
|
+
const unsub = documentChanges.subscribe((c) => events.push(c));
|
|
162
|
+
await expect(handle.delete('nope.txt')).resolves.toBeUndefined();
|
|
163
|
+
expect(events).toEqual([]);
|
|
164
|
+
unsub();
|
|
165
|
+
});
|
|
166
|
+
});
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Auto-generated from package.json — do not edit manually. */
|
|
2
|
-
export declare const VERSION = "0.11.
|
|
2
|
+
export declare const VERSION = "0.11.8";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Auto-generated from package.json — do not edit manually. */
|
|
2
|
-
export const VERSION = '0.11.
|
|
2
|
+
export const VERSION = '0.11.8';
|