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.
@@ -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. No tier grouping inside
24
- * a submenu popup one continuous list, separators driven by `group`.
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 tiered, deduplicated, flag-annotated item
4
- * list the Svelte component renders without further logic.
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
- export function buildContextMenuModel(entries, state) {
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
- const winning = innermostActiveScope(entry.action.scope, state, entry.ownerShardId);
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. No tier grouping inside
54
- * a submenu popup one continuous list, separators driven by `group`.
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
- const winning = innermostActiveScope(entry.action.scope, state, entry.ownerShardId);
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' }), // no contextItem
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('groups by scope tier in innermost-first order with separators', () => {
18
- const state = mkState({
19
- activeAppId: 'app.a',
20
- activeAppRequiredShards: new Set(['shard.x']),
21
- selection: { type: 'orb', ref: 1, ownerShardId: 'shard.x' },
22
- });
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: 'el', scope: { element: 'orb' }, contextItem: true, label: 'Dup' }),
25
- mkEntry({ id: 'ap', scope: 'app', contextItem: true, label: 'Undo' }),
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.map((t) => t.tier)).toEqual(['element', 'app']);
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('de-duplicates multi-scope action to innermost tier', () => {
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: 'p', scope: ['home', 'app'], contextItem: true, label: 'P' }),
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).toHaveLength(1);
42
- expect(model.tiers[0].tier).toBe('app');
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 active children of a parent', () => {
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
  });
@@ -1,6 +1,8 @@
1
+ import type { AtomicScope } from './types';
1
2
  export interface OpenContextMenuOpts {
2
3
  x: number;
3
4
  y: number;
5
+ scope?: AtomicScope;
4
6
  }
5
7
  export interface OpenPaletteOpts {
6
8
  prefill?: string;
@@ -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 model = buildContextMenuModel(entries, state);
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 fakeEvent = { target: null, clientX: opts.x, clientY: opts.y, preventDefault: () => { } };
192
- onContextMenu(fakeEvent);
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 on contextmenu and preventDefault()s native', () => {
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); // preventDefault
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); // native preserved
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 are active', () => {
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); // no menu → native preserved
115
+ expect(def).toBe(true);
116
116
  target.remove();
117
117
  });
118
- it('clicking a submenu-parent row opens a nested ActionPanel listing children', async () => {
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
+ });
@@ -88,6 +88,7 @@ export interface ActionsApi {
88
88
  openContextMenu(opts: {
89
89
  x: number;
90
90
  y: number;
91
+ scope?: AtomicScope;
91
92
  }): void;
92
93
  openPalette(opts?: {
93
94
  prefill?: string;
@@ -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`. */
@@ -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
  });
@@ -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
- emitChange('delete', path);
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.7";
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.7';
2
+ export const VERSION = '0.11.8';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh3-core",
3
- "version": "0.11.7",
3
+ "version": "0.11.8",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"