sh3-core 0.19.1 → 0.19.3
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/Sh3.svelte +3 -1
- package/dist/actions/menuBarModel.js +8 -0
- package/dist/actions/menuBarModel.test.js +61 -0
- package/dist/app/admin/ApiKeysView.svelte +6 -5
- package/dist/app/store/PermissionConfirmModal.svelte +23 -0
- package/dist/app/store/PermissionConfirmModal.svelte.d.ts +1 -0
- package/dist/app/store/StoreView.svelte +6 -1
- package/dist/chrome/CompactChrome.svelte.test.js +7 -4
- package/dist/env/client.d.ts +5 -4
- package/dist/env/client.js +11 -17
- package/dist/env/serverUrl.d.ts +2 -0
- package/dist/env/serverUrl.js +8 -0
- package/dist/gestures/index.d.ts +17 -0
- package/dist/gestures/index.js +27 -0
- package/dist/keys/client.js +6 -7
- package/dist/keys/revocation-bus.svelte.js +11 -1
- package/dist/layout/compact/CarouselTabs.svelte +150 -14
- package/dist/layout/compact/CarouselTabs.svelte.test.js +222 -2
- package/dist/layout/compact/CompactRenderer.svelte +1 -1
- package/dist/layout/compact/CompactRenderer.svelte.test.js +5 -3
- package/dist/layout/compact/derive.js +7 -16
- package/dist/layout/compact/derive.test.js +30 -9
- package/dist/layout/drag.svelte.js +16 -3
- package/dist/layout/inspection.d.ts +20 -9
- package/dist/layout/inspection.js +66 -11
- package/dist/layout/inspection.svelte.test.d.ts +1 -0
- package/dist/layout/inspection.svelte.test.js +114 -0
- package/dist/layout/store.schemaVersion.test.js +2 -2
- package/dist/layout/types.d.ts +11 -8
- package/dist/layout/types.js +1 -1
- package/dist/layout/types.test.js +2 -2
- package/dist/overlays/FloatFrame.svelte +93 -22
- package/dist/primitives/ResizableSplitter.svelte +42 -8
- package/dist/registry/checkFetch.d.ts +6 -0
- package/dist/registry/checkFetch.js +23 -0
- package/dist/sh3/views/KeysAndPeers.svelte +4 -3
- package/dist/shards/activate-runtime.test.js +99 -1
- package/dist/shards/activate.svelte.js +12 -3
- package/dist/shards/registry.d.ts +8 -1
- package/dist/shards/registry.js +13 -2
- package/dist/shards/registry.test.js +25 -4
- package/dist/shards/types.d.ts +14 -1
- package/dist/shell-shard/ScrollbackView.svelte +145 -67
- package/dist/shell-shard/ScrollbackView.svelte.test.d.ts +1 -0
- package/dist/shell-shard/ScrollbackView.svelte.test.js +182 -0
- package/dist/shell-shard/dispatch-gating.test.js +38 -2
- package/dist/shell-shard/dispatch.js +9 -1
- package/dist/shell-shard/registry-resolve.test.js +50 -0
- package/dist/shell-shard/registry.d.ts +2 -1
- package/dist/shell-shard/registry.js +12 -2
- package/dist/shell-shard/verbs/help.js +5 -4
- package/dist/shell-shard/verbs/help.svelte.test.js +5 -2
- package/dist/verbs/types.d.ts +10 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
import { makeSelectionApi } from '../actions/selection.svelte';
|
|
36
36
|
import { spawnSatellite } from '../sh3Api/window';
|
|
37
37
|
import { walkShardsForContent } from '../satellite/walkShards';
|
|
38
|
+
import { logGesture } from '../gestures';
|
|
38
39
|
|
|
39
40
|
const isTauri =
|
|
40
41
|
typeof (globalThis as Record<string, unknown>).__TAURI_INTERNALS__ !== 'undefined';
|
|
@@ -64,8 +65,23 @@
|
|
|
64
65
|
|
|
65
66
|
let dragging = $state(false);
|
|
66
67
|
let dragOffset = { x: 0, y: 0 };
|
|
68
|
+
/**
|
|
69
|
+
* Pointer id of the active header drag (null when idle). All header-pointer
|
|
70
|
+
* handlers filter on this so a second finger / palm / stylus ghost touch
|
|
71
|
+
* landing on the same element cannot interfere with — or worse,
|
|
72
|
+
* `pointercancel` away — the primary drag.
|
|
73
|
+
*
|
|
74
|
+
* Previously this component used `setPointerCapture` on the header. On
|
|
75
|
+
* Android, transferring implicit capture from the original pointerdown
|
|
76
|
+
* target (a child span) to the header element fires `pointercancel` on
|
|
77
|
+
* the original target, which bubbled up to the `onpointercancel` handler
|
|
78
|
+
* here and ended the drag instantly. Switching to pointer-id tracking +
|
|
79
|
+
* implicit capture is the same fix the carousel landed (commit 638d75a).
|
|
80
|
+
*/
|
|
81
|
+
let dragPointerId: number | null = null;
|
|
67
82
|
let resizing = $state(false);
|
|
68
83
|
let resizeStart = { pointer: { x: 0, y: 0 }, size: { w: 0, h: 0 }, min: { w: 0, h: 0 } };
|
|
84
|
+
let resizePointerId: number | null = null;
|
|
69
85
|
let frameEl: HTMLDivElement | undefined = $state();
|
|
70
86
|
|
|
71
87
|
const isMaximized = $derived(floatManager.isMaximized(entry.id));
|
|
@@ -99,20 +115,40 @@
|
|
|
99
115
|
};
|
|
100
116
|
});
|
|
101
117
|
|
|
118
|
+
// Remove any in-flight document-level pointer listeners if the float is
|
|
119
|
+
// closed mid-drag (e.g. the close button is hit, or the framework removes
|
|
120
|
+
// the float for another reason). Without this, dangling listeners on
|
|
121
|
+
// document would keep firing on the next pointermove and leak the
|
|
122
|
+
// closure references to the destroyed component.
|
|
123
|
+
$effect(() => {
|
|
124
|
+
return () => {
|
|
125
|
+
if (dragging) endHeaderDrag();
|
|
126
|
+
if (resizing) endGripResize();
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
|
|
102
130
|
function onHeaderPointerDown(e: PointerEvent): void {
|
|
103
131
|
if (e.button !== 0) return;
|
|
104
132
|
if ((e.target as HTMLElement).closest('.sh3-float-close')) return;
|
|
105
133
|
if ((e.target as HTMLElement).closest('.sh3-float-maximize')) return;
|
|
106
134
|
if ((e.target as HTMLElement).closest('.sh3-float-popout')) return;
|
|
107
|
-
|
|
108
|
-
target.setPointerCapture?.(e.pointerId);
|
|
135
|
+
if (dragging) return; // already mid-drag; second finger is ignored
|
|
109
136
|
dragging = true;
|
|
137
|
+
dragPointerId = e.pointerId;
|
|
110
138
|
dragOffset = { x: e.clientX - entry.position.x, y: e.clientY - entry.position.y };
|
|
111
139
|
floatManager.focus(entry.id);
|
|
140
|
+
// Listen on document so the drag survives the pointer leaving the
|
|
141
|
+
// header. Mouse pointer capture is NOT implicit (touch is) — without
|
|
142
|
+
// these listeners, a fast mouse move that crosses the header edge
|
|
143
|
+
// would never deliver another pointermove and the drag would stall.
|
|
144
|
+
// The carousel uses the same pattern.
|
|
145
|
+
document.addEventListener('pointermove', onHeaderPointerMove);
|
|
146
|
+
document.addEventListener('pointerup', onHeaderPointerUp);
|
|
147
|
+
document.addEventListener('pointercancel', onHeaderPointerCancel);
|
|
112
148
|
}
|
|
113
149
|
|
|
114
150
|
function onHeaderPointerMove(e: PointerEvent): void {
|
|
115
|
-
if (!dragging) return;
|
|
151
|
+
if (!dragging || e.pointerId !== dragPointerId) return;
|
|
116
152
|
// Implicit un-maximize on first drag movement (no-op if not maximized).
|
|
117
153
|
// We unmaximize on move rather than pointerdown so a casual click —
|
|
118
154
|
// which precedes a dblclick — does not destroy the saved prev rect
|
|
@@ -122,13 +158,30 @@
|
|
|
122
158
|
entry.position.y = e.clientY - dragOffset.y;
|
|
123
159
|
}
|
|
124
160
|
|
|
125
|
-
function
|
|
126
|
-
if (!dragging) return;
|
|
161
|
+
function endHeaderDrag(): void {
|
|
127
162
|
dragging = false;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
163
|
+
dragPointerId = null;
|
|
164
|
+
document.removeEventListener('pointermove', onHeaderPointerMove);
|
|
165
|
+
document.removeEventListener('pointerup', onHeaderPointerUp);
|
|
166
|
+
document.removeEventListener('pointercancel', onHeaderPointerCancel);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function onHeaderPointerUp(e: PointerEvent): void {
|
|
170
|
+
if (!dragging || e.pointerId !== dragPointerId) return;
|
|
171
|
+
endHeaderDrag();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function onHeaderPointerCancel(e: PointerEvent): void {
|
|
175
|
+
// Only abort if the cancel is for OUR pointer. Without this filter, any
|
|
176
|
+
// ghost cancellation on the header (palm contact, a stylus touching
|
|
177
|
+
// while a finger drags, descendant losing implicit capture) would tear
|
|
178
|
+
// down the active drag — the touch-only auto-release symptom.
|
|
179
|
+
if (e.pointerId !== dragPointerId) {
|
|
180
|
+
logGesture('floatframe:cancel-other-id', e, { dragPointerId });
|
|
181
|
+
return;
|
|
131
182
|
}
|
|
183
|
+
logGesture('floatframe:cancel-our-id', e, { dragPointerId });
|
|
184
|
+
endHeaderDrag();
|
|
132
185
|
}
|
|
133
186
|
|
|
134
187
|
function onHeaderDblClick(e: MouseEvent): void {
|
|
@@ -141,19 +194,22 @@
|
|
|
141
194
|
function onGripPointerDown(e: PointerEvent): void {
|
|
142
195
|
if (e.button !== 0) return;
|
|
143
196
|
e.stopPropagation();
|
|
144
|
-
|
|
145
|
-
target.setPointerCapture?.(e.pointerId);
|
|
197
|
+
if (resizing) return;
|
|
146
198
|
resizing = true;
|
|
199
|
+
resizePointerId = e.pointerId;
|
|
147
200
|
resizeStart = {
|
|
148
201
|
pointer: { x: e.clientX, y: e.clientY },
|
|
149
202
|
size: { w: entry.size.w, h: entry.size.h },
|
|
150
203
|
min: computeMinSize(entry.content),
|
|
151
204
|
};
|
|
152
205
|
floatManager.focus(entry.id);
|
|
206
|
+
document.addEventListener('pointermove', onGripPointerMove);
|
|
207
|
+
document.addEventListener('pointerup', onGripPointerUp);
|
|
208
|
+
document.addEventListener('pointercancel', onGripPointerCancel);
|
|
153
209
|
}
|
|
154
210
|
|
|
155
211
|
function onGripPointerMove(e: PointerEvent): void {
|
|
156
|
-
if (!resizing) return;
|
|
212
|
+
if (!resizing || e.pointerId !== resizePointerId) return;
|
|
157
213
|
floatManager.unmaximize(entry.id);
|
|
158
214
|
const dx = e.clientX - resizeStart.pointer.x;
|
|
159
215
|
const dy = e.clientY - resizeStart.pointer.y;
|
|
@@ -161,13 +217,26 @@
|
|
|
161
217
|
entry.size.h = Math.max(resizeStart.min.h, resizeStart.size.h + dy);
|
|
162
218
|
}
|
|
163
219
|
|
|
164
|
-
function
|
|
165
|
-
if (!resizing) return;
|
|
220
|
+
function endGripResize(): void {
|
|
166
221
|
resizing = false;
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
222
|
+
resizePointerId = null;
|
|
223
|
+
document.removeEventListener('pointermove', onGripPointerMove);
|
|
224
|
+
document.removeEventListener('pointerup', onGripPointerUp);
|
|
225
|
+
document.removeEventListener('pointercancel', onGripPointerCancel);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function onGripPointerUp(e: PointerEvent): void {
|
|
229
|
+
if (!resizing || e.pointerId !== resizePointerId) return;
|
|
230
|
+
endGripResize();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function onGripPointerCancel(e: PointerEvent): void {
|
|
234
|
+
if (e.pointerId !== resizePointerId) {
|
|
235
|
+
logGesture('floatframe-grip:cancel-other-id', e, { resizePointerId });
|
|
236
|
+
return;
|
|
170
237
|
}
|
|
238
|
+
logGesture('floatframe-grip:cancel-our-id', e, { resizePointerId });
|
|
239
|
+
endGripResize();
|
|
171
240
|
}
|
|
172
241
|
|
|
173
242
|
function onFrameClick(): void {
|
|
@@ -226,9 +295,6 @@
|
|
|
226
295
|
data-sh3-scope="element:float-header"
|
|
227
296
|
data-float-id={entry.id}
|
|
228
297
|
onpointerdown={onHeaderPointerDown}
|
|
229
|
-
onpointermove={onHeaderPointerMove}
|
|
230
|
-
onpointerup={onHeaderPointerUp}
|
|
231
|
-
onpointercancel={onHeaderPointerUp}
|
|
232
298
|
ondblclick={onHeaderDblClick}
|
|
233
299
|
oncontextmenu={openHeaderContextMenu}
|
|
234
300
|
>
|
|
@@ -261,9 +327,6 @@
|
|
|
261
327
|
role="presentation"
|
|
262
328
|
aria-hidden="true"
|
|
263
329
|
onpointerdown={onGripPointerDown}
|
|
264
|
-
onpointermove={onGripPointerMove}
|
|
265
|
-
onpointerup={onGripPointerUp}
|
|
266
|
-
onpointercancel={onGripPointerUp}
|
|
267
330
|
></div>
|
|
268
331
|
</div>
|
|
269
332
|
|
|
@@ -291,6 +354,11 @@
|
|
|
291
354
|
border-top-left-radius: var(--sh3-radius);
|
|
292
355
|
border-top-right-radius: var(--sh3-radius);
|
|
293
356
|
flex-shrink: 0;
|
|
357
|
+
/* Suppress browser-claimed touch gestures (pan / pinch) on the drag
|
|
358
|
+
handle. Without this, Android/iOS fire `pointercancel` as soon as the
|
|
359
|
+
finger moves past the system's scroll-claim threshold, killing the
|
|
360
|
+
header drag mid-pan. */
|
|
361
|
+
touch-action: none;
|
|
294
362
|
}
|
|
295
363
|
.sh3-float-title {
|
|
296
364
|
font-size: 12px;
|
|
@@ -335,6 +403,9 @@
|
|
|
335
403
|
width: 16px;
|
|
336
404
|
height: 16px;
|
|
337
405
|
cursor: nwse-resize;
|
|
406
|
+
/* Same rationale as the header: opt out of browser-claimed touch
|
|
407
|
+
gestures so the resize drag survives past the scroll-claim threshold. */
|
|
408
|
+
touch-action: none;
|
|
338
409
|
/* Subtle visual hint without being obtrusive — two diagonal lines made
|
|
339
410
|
from a CSS gradient stripe. */
|
|
340
411
|
background:
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import type { Snippet } from 'svelte';
|
|
21
21
|
import type { SizeMode, SplitDirection } from '../layout/types';
|
|
22
22
|
import { claim, revoke } from '../gestures/pointerClaim';
|
|
23
|
-
import { ancestorCount } from '../gestures';
|
|
23
|
+
import { ancestorCount, logGesture } from '../gestures';
|
|
24
24
|
|
|
25
25
|
const MIN_PX = 40;
|
|
26
26
|
const COLLAPSED_PX = 28;
|
|
@@ -128,7 +128,16 @@
|
|
|
128
128
|
activeDragPointerId = e.pointerId;
|
|
129
129
|
|
|
130
130
|
e.preventDefault();
|
|
131
|
-
(
|
|
131
|
+
// Document-level listeners (not element-level attributes) so the drag
|
|
132
|
+
// survives the pointer leaving the handle. Mouse pointer capture is
|
|
133
|
+
// not implicit; without document listeners, a fast mouse move past
|
|
134
|
+
// the handle edge stops delivering pointermove. setPointerCapture
|
|
135
|
+
// would work for mouse but reintroduces the Android pointercancel
|
|
136
|
+
// bug (see CarouselTabs commit 638d75a). document listeners avoid
|
|
137
|
+
// both pitfalls — same pattern the carousel uses.
|
|
138
|
+
document.addEventListener('pointermove', moveDrag);
|
|
139
|
+
document.addEventListener('pointerup', endDrag);
|
|
140
|
+
document.addEventListener('pointercancel', cancelDrag);
|
|
132
141
|
|
|
133
142
|
const rect = container.getBoundingClientRect();
|
|
134
143
|
const containerPx = direction === 'horizontal' ? rect.width : rect.height;
|
|
@@ -154,6 +163,7 @@
|
|
|
154
163
|
|
|
155
164
|
function moveDrag(e: PointerEvent) {
|
|
156
165
|
if (!drag) return;
|
|
166
|
+
if (e.pointerId !== activeDragPointerId) return;
|
|
157
167
|
const client = direction === 'horizontal' ? e.clientX : e.clientY;
|
|
158
168
|
const deltaPx = client - drag.startClient;
|
|
159
169
|
|
|
@@ -203,15 +213,42 @@
|
|
|
203
213
|
}
|
|
204
214
|
}
|
|
205
215
|
|
|
206
|
-
function
|
|
207
|
-
if (!drag) return;
|
|
208
|
-
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
|
216
|
+
function teardownDrag(): void {
|
|
209
217
|
if (activeDragPointerId !== null) {
|
|
210
218
|
revoke(activeDragPointerId, 'sh3:splitter');
|
|
211
219
|
activeDragPointerId = null;
|
|
212
220
|
}
|
|
213
221
|
drag = null;
|
|
222
|
+
document.removeEventListener('pointermove', moveDrag);
|
|
223
|
+
document.removeEventListener('pointerup', endDrag);
|
|
224
|
+
document.removeEventListener('pointercancel', cancelDrag);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function endDrag(e: PointerEvent) {
|
|
228
|
+
if (!drag) return;
|
|
229
|
+
if (e.pointerId !== activeDragPointerId) return;
|
|
230
|
+
teardownDrag();
|
|
214
231
|
}
|
|
232
|
+
|
|
233
|
+
function cancelDrag(e: PointerEvent) {
|
|
234
|
+
// Filter so a ghost pointercancel for an unrelated pointer (palm, stylus,
|
|
235
|
+
// second finger on multi-touch) doesn't abort the active resize.
|
|
236
|
+
if (!drag) return;
|
|
237
|
+
if (e.pointerId !== activeDragPointerId) {
|
|
238
|
+
logGesture('splitter:cancel-other-id', e, { activeDragPointerId });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
logGesture('splitter:cancel-our-id', e, { activeDragPointerId });
|
|
242
|
+
teardownDrag();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// If the splitter unmounts mid-drag, remove the document listeners and
|
|
246
|
+
// release the claim so the next component doesn't see a leaked pointer.
|
|
247
|
+
$effect(() => {
|
|
248
|
+
return () => {
|
|
249
|
+
if (drag) teardownDrag();
|
|
250
|
+
};
|
|
251
|
+
});
|
|
215
252
|
</script>
|
|
216
253
|
|
|
217
254
|
<div
|
|
@@ -281,9 +318,6 @@
|
|
|
281
318
|
class:frozen={isHandleFrozen(i)}
|
|
282
319
|
data-testid="splitter-handle-{i}"
|
|
283
320
|
onpointerdown={(e) => beginDrag(e, i)}
|
|
284
|
-
onpointermove={moveDrag}
|
|
285
|
-
onpointerup={endDrag}
|
|
286
|
-
onpointercancel={endDrag}
|
|
287
321
|
ondblclick={() => {
|
|
288
322
|
if (isHandleFrozen(i)) return;
|
|
289
323
|
if (!canCollapse(i) && !isCollapsed(i)) return;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side mirror of sh3-validate's checkFetch heuristic.
|
|
3
|
+
* Takes a decoded bundle string, returns advisory warning messages.
|
|
4
|
+
* Returns at most one message — enough for the install modal UX.
|
|
5
|
+
*/
|
|
6
|
+
export declare function checkBundleFetch(bundleText: string): string[];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side mirror of sh3-validate's checkFetch heuristic.
|
|
3
|
+
* Takes a decoded bundle string, returns advisory warning messages.
|
|
4
|
+
* Returns at most one message — enough for the install modal UX.
|
|
5
|
+
*/
|
|
6
|
+
export function checkBundleFetch(bundleText) {
|
|
7
|
+
const apiPositions = [];
|
|
8
|
+
const apiPattern = /\/api\//g;
|
|
9
|
+
let m;
|
|
10
|
+
while ((m = apiPattern.exec(bundleText)) !== null) {
|
|
11
|
+
apiPositions.push(m.index);
|
|
12
|
+
}
|
|
13
|
+
if (apiPositions.length === 0)
|
|
14
|
+
return [];
|
|
15
|
+
const fetchPattern = /(?<!\.)fetch\s*\(/g;
|
|
16
|
+
while ((m = fetchPattern.exec(bundleText)) !== null) {
|
|
17
|
+
const pos = m.index;
|
|
18
|
+
if (apiPositions.some((ap) => Math.abs(ap - pos) <= 200)) {
|
|
19
|
+
return ['raw fetch() call with /api/ path — use ctx.fetch() for cross-origin compatibility'];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
import type { ApiKeyPublic } from '../../keys/types';
|
|
11
11
|
import { subscribe as subscribeBus } from '../../keys/revocation-bus.svelte';
|
|
12
|
+
import { apiFetch } from '../../transport/apiFetch';
|
|
13
|
+
import { getEnvServerUrl } from '../../env/serverUrl';
|
|
12
14
|
|
|
13
15
|
let rows = $state<ApiKeyPublic[]>([]);
|
|
14
16
|
let loadError = $state<string | null>(null);
|
|
@@ -19,7 +21,7 @@
|
|
|
19
21
|
loadError = null;
|
|
20
22
|
loading = true;
|
|
21
23
|
try {
|
|
22
|
-
const res = await
|
|
24
|
+
const res = await apiFetch(`${getEnvServerUrl()}/api/keys`);
|
|
23
25
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
24
26
|
rows = await res.json();
|
|
25
27
|
} catch (err) {
|
|
@@ -31,9 +33,8 @@
|
|
|
31
33
|
|
|
32
34
|
async function revoke(id: string): Promise<void> {
|
|
33
35
|
confirmingId = null;
|
|
34
|
-
const res = await
|
|
36
|
+
const res = await apiFetch(`${getEnvServerUrl()}/api/keys/${encodeURIComponent(id)}`, {
|
|
35
37
|
method: 'DELETE',
|
|
36
|
-
credentials: 'include',
|
|
37
38
|
});
|
|
38
39
|
if (res.ok || res.status === 404) {
|
|
39
40
|
rows = rows.filter((r) => r.id !== id);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
2
|
import { MemoryDocumentBackend } from '../documents/backends';
|
|
3
3
|
import { __setDocumentBackend, __setTenantId } from '../documents/config';
|
|
4
4
|
import { registerShard, activateShard, __resetShardRegistryForTest, } from './activate.svelte';
|
|
@@ -198,4 +198,102 @@ describe('ctx.listVerbs / ctx.runVerb (integration)', () => {
|
|
|
198
198
|
},
|
|
199
199
|
});
|
|
200
200
|
});
|
|
201
|
+
it('verbNamespace overrides the shard-id prefix', async () => {
|
|
202
|
+
registerShard({
|
|
203
|
+
manifest: {
|
|
204
|
+
id: 'sh3-dirt-viewer',
|
|
205
|
+
label: 'Dirt',
|
|
206
|
+
version: '0.0.0',
|
|
207
|
+
views: [],
|
|
208
|
+
verbNamespace: 'dirt',
|
|
209
|
+
},
|
|
210
|
+
activate(ctx) {
|
|
211
|
+
ctx.registerVerb(plainVerb('publish', 'publish runtime'));
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
let consumerCtx = null;
|
|
215
|
+
registerShard({
|
|
216
|
+
manifest: { id: 'consumer', label: 'C', version: '0.0.0', views: [] },
|
|
217
|
+
activate(ctx) { consumerCtx = ctx; },
|
|
218
|
+
});
|
|
219
|
+
await activateShard('sh3-dirt-viewer');
|
|
220
|
+
await activateShard('consumer');
|
|
221
|
+
const names = consumerCtx.sh3.listVerbs().map((v) => v.name);
|
|
222
|
+
expect(names).toContain('dirt:publish');
|
|
223
|
+
expect(names.find((n) => n.startsWith('sh3-dirt-viewer:'))).toBeUndefined();
|
|
224
|
+
});
|
|
225
|
+
it('verbNamespace collision across shards: first wins, second warns', async () => {
|
|
226
|
+
var _a;
|
|
227
|
+
registerShard({
|
|
228
|
+
manifest: {
|
|
229
|
+
id: 'shard-a',
|
|
230
|
+
label: 'A',
|
|
231
|
+
version: '0.0.0',
|
|
232
|
+
views: [],
|
|
233
|
+
verbNamespace: 'shared',
|
|
234
|
+
},
|
|
235
|
+
activate(ctx) {
|
|
236
|
+
ctx.registerVerb(plainVerb('go', 'A go'));
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
registerShard({
|
|
240
|
+
manifest: {
|
|
241
|
+
id: 'shard-b',
|
|
242
|
+
label: 'B',
|
|
243
|
+
version: '0.0.0',
|
|
244
|
+
views: [],
|
|
245
|
+
verbNamespace: 'shared',
|
|
246
|
+
},
|
|
247
|
+
activate(ctx) {
|
|
248
|
+
// Collides with shard-a's 'shared:go'; should be skipped.
|
|
249
|
+
ctx.registerVerb(plainVerb('go', 'B go'));
|
|
250
|
+
// Distinct name in same namespace should still register.
|
|
251
|
+
ctx.registerVerb(plainVerb('only-b', 'B-only verb'));
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
let consumerCtx = null;
|
|
255
|
+
registerShard({
|
|
256
|
+
manifest: { id: 'consumer', label: 'C', version: '0.0.0', views: [] },
|
|
257
|
+
activate(ctx) { consumerCtx = ctx; },
|
|
258
|
+
});
|
|
259
|
+
await activateShard('shard-a');
|
|
260
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
261
|
+
await activateShard('shard-b');
|
|
262
|
+
await activateShard('consumer');
|
|
263
|
+
expect(warn).toHaveBeenCalled();
|
|
264
|
+
expect(warn.mock.calls.some((c) => /shared:go/.test(String(c[0])))).toBe(true);
|
|
265
|
+
warn.mockRestore();
|
|
266
|
+
const list = consumerCtx.sh3.listVerbs();
|
|
267
|
+
const shared = list.find((v) => v.name === 'shared:go');
|
|
268
|
+
// The losing registration must not have been tracked; deactivating
|
|
269
|
+
// shard-b later (covered elsewhere) would otherwise remove A's verb.
|
|
270
|
+
expect(shared === null || shared === void 0 ? void 0 : shared.summary).toBe('A go');
|
|
271
|
+
expect(shared === null || shared === void 0 ? void 0 : shared.shardId).toBe('shard-a');
|
|
272
|
+
expect((_a = list.find((v) => v.name === 'shared:only-b')) === null || _a === void 0 ? void 0 : _a.shardId).toBe('shard-b');
|
|
273
|
+
});
|
|
274
|
+
it('shell shard registers bare names regardless of verbNamespace setting', async () => {
|
|
275
|
+
registerShard({
|
|
276
|
+
manifest: {
|
|
277
|
+
id: 'shell',
|
|
278
|
+
label: 'Shell',
|
|
279
|
+
version: '0.0.0',
|
|
280
|
+
views: [],
|
|
281
|
+
// Intentionally set — should be ignored for the shell shard.
|
|
282
|
+
verbNamespace: 'ignored',
|
|
283
|
+
},
|
|
284
|
+
activate(ctx) {
|
|
285
|
+
ctx.registerVerb(plainVerb('clear', 'clear scrollback'));
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
let consumerCtx = null;
|
|
289
|
+
registerShard({
|
|
290
|
+
manifest: { id: 'consumer', label: 'C', version: '0.0.0', views: [] },
|
|
291
|
+
activate(ctx) { consumerCtx = ctx; },
|
|
292
|
+
});
|
|
293
|
+
await activateShard('shell');
|
|
294
|
+
await activateShard('consumer');
|
|
295
|
+
const names = consumerCtx.sh3.listVerbs().map((v) => v.name);
|
|
296
|
+
expect(names).toContain('clear');
|
|
297
|
+
expect(names.find((n) => n.startsWith('ignored:'))).toBeUndefined();
|
|
298
|
+
});
|
|
201
299
|
});
|
|
@@ -150,9 +150,18 @@ export async function activateShard(id, opts) {
|
|
|
150
150
|
entry.viewIds.add(viewId);
|
|
151
151
|
},
|
|
152
152
|
registerVerb: (verb) => {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
153
|
+
var _a;
|
|
154
|
+
let prefixed;
|
|
155
|
+
if (id === 'shell') {
|
|
156
|
+
prefixed = verb.name;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
const ns = (_a = shard.manifest.verbNamespace) !== null && _a !== void 0 ? _a : id;
|
|
160
|
+
prefixed = `${ns}:${verb.name}`;
|
|
161
|
+
}
|
|
162
|
+
if (fwRegisterVerb(prefixed, Object.assign(Object.assign({}, verb), { name: prefixed }), id)) {
|
|
163
|
+
entry.verbNames.add(prefixed);
|
|
164
|
+
}
|
|
156
165
|
},
|
|
157
166
|
documents: (options) => {
|
|
158
167
|
const handle = createDocumentHandle(getTenantId(), id, getDocumentBackend(), options);
|
|
@@ -6,7 +6,14 @@ export declare function getView(viewId: string): ViewFactory | undefined;
|
|
|
6
6
|
export declare function getShardForView(viewId: string): string | undefined;
|
|
7
7
|
export declare function unregisterView(viewId: string): void;
|
|
8
8
|
import type { Verb } from '../verbs/types';
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Register a verb under its fully-qualified name. Collisions log a
|
|
11
|
+
* `console.warn` and skip the second registration — first-wins. Returns
|
|
12
|
+
* `true` when the registration took effect, `false` when it was a
|
|
13
|
+
* duplicate (so callers like activate() can avoid tracking a name they
|
|
14
|
+
* don't own and would otherwise unregister out from under the original).
|
|
15
|
+
*/
|
|
16
|
+
export declare function registerVerb(name: string, verb: Verb, shardId: string): boolean;
|
|
10
17
|
export declare function getVerb(name: string): Verb | undefined;
|
|
11
18
|
export declare function unregisterVerb(name: string): void;
|
|
12
19
|
export declare function listVerbs(): Verb[];
|
package/dist/shards/registry.js
CHANGED
|
@@ -46,11 +46,22 @@ export function unregisterView(viewId) {
|
|
|
46
46
|
viewToShard.delete(viewId);
|
|
47
47
|
}
|
|
48
48
|
const verbs = new Map();
|
|
49
|
+
/**
|
|
50
|
+
* Register a verb under its fully-qualified name. Collisions log a
|
|
51
|
+
* `console.warn` and skip the second registration — first-wins. Returns
|
|
52
|
+
* `true` when the registration took effect, `false` when it was a
|
|
53
|
+
* duplicate (so callers like activate() can avoid tracking a name they
|
|
54
|
+
* don't own and would otherwise unregister out from under the original).
|
|
55
|
+
*/
|
|
49
56
|
export function registerVerb(name, verb, shardId) {
|
|
50
|
-
|
|
51
|
-
|
|
57
|
+
const existing = verbs.get(name);
|
|
58
|
+
if (existing) {
|
|
59
|
+
console.warn(`[sh3] verb "${name}" already registered by shard "${existing.shardId}"; ` +
|
|
60
|
+
`skipping registration from "${shardId}"`);
|
|
61
|
+
return false;
|
|
52
62
|
}
|
|
53
63
|
verbs.set(name, { verb, shardId });
|
|
64
|
+
return true;
|
|
54
65
|
}
|
|
55
66
|
export function getVerb(name) {
|
|
56
67
|
var _a;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
2
|
import { registerVerb, getVerb, unregisterVerb, listVerbs, listVerbsWithShard, registerView, unregisterView, getShardForView, __resetViewRegistryForTest, } from './registry';
|
|
3
3
|
function makeStubVerb(name) {
|
|
4
4
|
return { name, summary: `stub ${name}`, run: async () => { } };
|
|
@@ -22,9 +22,30 @@ describe('verb registry', () => {
|
|
|
22
22
|
it('returns undefined for unknown verb', () => {
|
|
23
23
|
expect(getVerb('nope')).toBeUndefined();
|
|
24
24
|
});
|
|
25
|
-
it('
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
it('first-wins on duplicate verb name; logs a warn and keeps the original', () => {
|
|
26
|
+
var _a;
|
|
27
|
+
const first = makeStubVerb('dup');
|
|
28
|
+
const second = makeStubVerb('dup');
|
|
29
|
+
second.summary = 'second registration';
|
|
30
|
+
trackVerb('dup', first, 'shardA');
|
|
31
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
32
|
+
const ok = registerVerb('dup', second, 'shardB');
|
|
33
|
+
expect(ok).toBe(false);
|
|
34
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
35
|
+
expect(warn.mock.calls[0][0]).toMatch(/already registered by shard "shardA"/);
|
|
36
|
+
expect(warn.mock.calls[0][0]).toMatch(/skipping registration from "shardB"/);
|
|
37
|
+
// Original survives — its identity, its shardId.
|
|
38
|
+
expect(getVerb('dup')).toBe(first);
|
|
39
|
+
expect((_a = listVerbsWithShard().find((e) => e.verb.name === 'dup')) === null || _a === void 0 ? void 0 : _a.shardId).toBe('shardA');
|
|
40
|
+
warn.mockRestore();
|
|
41
|
+
});
|
|
42
|
+
it('registerVerb returns true on success', () => {
|
|
43
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
44
|
+
const ok = registerVerb('fresh', makeStubVerb('fresh'), 'shell');
|
|
45
|
+
expect(ok).toBe(true);
|
|
46
|
+
expect(warn).not.toHaveBeenCalled();
|
|
47
|
+
warn.mockRestore();
|
|
48
|
+
registered.push('fresh');
|
|
28
49
|
});
|
|
29
50
|
it('unregisters a verb', () => {
|
|
30
51
|
trackVerb('gone', makeStubVerb('gone'), 'shell');
|
package/dist/shards/types.d.ts
CHANGED
|
@@ -158,6 +158,16 @@ export interface ShardManifest {
|
|
|
158
158
|
* registry visibility (observer-class shards, e.g. file-explorer).
|
|
159
159
|
*/
|
|
160
160
|
permissions?: string[];
|
|
161
|
+
/**
|
|
162
|
+
* Namespace used as the prefix for verbs this shard registers in the
|
|
163
|
+
* terminal. Defaults to `id`. Two shards may attempt to claim the same
|
|
164
|
+
* namespace; collisions on `${verbNamespace}:${verbName}` are resolved
|
|
165
|
+
* first-wins with a `console.warn`. The shell shard's namespace is
|
|
166
|
+
* implicit (empty prefix) — `verbNamespace` is ignored for `id === 'shell'`.
|
|
167
|
+
* Reserved values (`'sh3'`, `'core'`, `'shell'`, empty string) are flagged
|
|
168
|
+
* by sh3-validate at build time.
|
|
169
|
+
*/
|
|
170
|
+
verbNamespace?: string;
|
|
161
171
|
}
|
|
162
172
|
/**
|
|
163
173
|
* Source-declared shape of a shard manifest — what external package authors
|
|
@@ -197,7 +207,10 @@ export interface ShardContext {
|
|
|
197
207
|
registerView(viewId: string, factory: ViewFactory): void;
|
|
198
208
|
/**
|
|
199
209
|
* Register a verb that users can invoke from the sh3 terminal.
|
|
200
|
-
* The verb name is auto-prefixed with
|
|
210
|
+
* The verb name is auto-prefixed with `${ns}:` where `ns` is the
|
|
211
|
+
* shard's `manifest.verbNamespace ?? manifest.id` (the shell shard
|
|
212
|
+
* keeps bare names). Collisions on the resulting full name log a
|
|
213
|
+
* `console.warn` and skip the second registration — first-wins.
|
|
201
214
|
* Automatically unregistered when the shard deactivates.
|
|
202
215
|
*
|
|
203
216
|
* @param verb - The verb definition (name, summary, run function).
|