sh3-core 0.13.3 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.d.ts +3 -0
- package/dist/api.js +3 -0
- package/dist/app/store/StoreView.svelte +15 -4
- package/dist/app/store/permissionConfirm.js +1 -2
- package/dist/app/store/storeApp.js +0 -1
- package/dist/app/store/storeShard.svelte.js +9 -18
- package/dist/app/store/storeTypes.d.ts +21 -0
- package/dist/app/store/storeTypes.js +33 -0
- package/dist/app/store/storeTypes.test.d.ts +1 -0
- package/dist/app/store/storeTypes.test.js +41 -0
- package/dist/app/store/updatePackage.test.js +1 -1
- package/dist/app/store/verbs.test.js +20 -17
- package/dist/host.js +2 -0
- package/dist/migrations/mode-id-rename.d.ts +9 -0
- package/dist/migrations/mode-id-rename.js +39 -0
- package/dist/migrations/mode-id-rename.test.d.ts +1 -0
- package/dist/migrations/mode-id-rename.test.js +52 -0
- package/dist/overlays/FloatFrame.svelte +18 -1
- package/dist/overlays/float.d.ts +12 -0
- package/dist/overlays/float.js +16 -0
- package/dist/overlays/float.test.js +97 -2
- package/dist/overlays/modal.js +1 -0
- package/dist/overlays/modal.test.js +17 -0
- package/dist/overlays/parentHost.d.ts +1 -0
- package/dist/overlays/parentHost.js +15 -0
- package/dist/overlays/parentHost.test.d.ts +1 -0
- package/dist/overlays/parentHost.test.js +39 -0
- package/dist/overlays/popup.js +1 -0
- package/dist/overlays/popup.test.js +19 -0
- package/dist/shell-shard/Terminal.svelte +85 -8
- package/dist/shell-shard/Terminal.svelte.d.ts +2 -0
- package/dist/shell-shard/contract.d.ts +65 -0
- package/dist/shell-shard/contract.js +11 -0
- package/dist/shell-shard/dispatch-custom.test.d.ts +1 -0
- package/dist/shell-shard/dispatch-custom.test.js +104 -0
- package/dist/shell-shard/dispatch.d.ts +14 -1
- package/dist/shell-shard/dispatch.js +58 -5
- package/dist/shell-shard/modes/builtin.d.ts +2 -2
- package/dist/shell-shard/modes/builtin.js +8 -8
- package/dist/shell-shard/modes/prefs.js +1 -1
- package/dist/shell-shard/modes/prefs.test.js +13 -13
- package/dist/shell-shard/modes/registry.test.js +13 -13
- package/dist/shell-shard/output.d.ts +3 -0
- package/dist/shell-shard/output.js +75 -0
- package/dist/shell-shard/output.test.d.ts +1 -0
- package/dist/shell-shard/output.test.js +54 -0
- package/dist/shell-shard/registerShellMode.d.ts +13 -0
- package/dist/shell-shard/registerShellMode.js +14 -0
- package/dist/shell-shard/registerShellMode.test.d.ts +1 -0
- package/dist/shell-shard/registerShellMode.test.js +19 -0
- package/dist/shell-shard/shellShard.svelte.js +8 -1
- package/dist/shell-shard/terminal-dispatch.test.js +9 -9
- package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +11 -51
- package/dist/shell-shard/toolbar/slots/ModeSlot.svelte.d.ts +2 -4
- package/dist/shell-shard/toolbar/slots.test.js +6 -6
- package/dist/shell-shard/verbs/index.js +2 -0
- package/dist/shell-shard/verbs/mode.d.ts +2 -0
- package/dist/shell-shard/verbs/mode.js +28 -0
- package/dist/shell-shard/verbs/mode.test.d.ts +1 -0
- package/dist/shell-shard/verbs/mode.test.js +43 -0
- package/dist/verbs/types.d.ts +11 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/app/store/InstalledView.svelte +0 -255
- package/dist/app/store/InstalledView.svelte.d.ts +0 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
-
import { floatManager, __resetFloatManagerForTest, bindFloatStore } from './float';
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { floatManager, __resetFloatManagerForTest, bindFloatStore, getFloatParentHost, } from './float';
|
|
3
3
|
import { layoutStore } from '../layout/store.svelte';
|
|
4
4
|
describe('floatManager', () => {
|
|
5
5
|
beforeEach(() => {
|
|
@@ -80,6 +80,49 @@ describe('floatManager', () => {
|
|
|
80
80
|
expect(f.content.type).toBe('tabs');
|
|
81
81
|
});
|
|
82
82
|
});
|
|
83
|
+
describe('floatManager — anchor-aware parent host', () => {
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
__resetFloatManagerForTest();
|
|
86
|
+
});
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
document.body.innerHTML = '';
|
|
89
|
+
});
|
|
90
|
+
function makeOverlayHost(kind) {
|
|
91
|
+
const host = document.createElement('div');
|
|
92
|
+
host.dataset.shellOverlayHost = kind;
|
|
93
|
+
const anchor = document.createElement('button');
|
|
94
|
+
host.appendChild(anchor);
|
|
95
|
+
document.body.appendChild(host);
|
|
96
|
+
return { host, anchor };
|
|
97
|
+
}
|
|
98
|
+
it('getFloatParentHost is undefined when no anchor was passed', () => {
|
|
99
|
+
const id = floatManager.open('test:view', { dismissable: true });
|
|
100
|
+
expect(getFloatParentHost(id)).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
it('getFloatParentHost is undefined when the anchor lives outside any overlay host', () => {
|
|
103
|
+
const anchor = document.createElement('button');
|
|
104
|
+
document.body.appendChild(anchor);
|
|
105
|
+
const id = floatManager.open('test:view', { dismissable: true, anchor });
|
|
106
|
+
expect(getFloatParentHost(id)).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
it('getFloatParentHost returns the enclosing host for a dismissable+anchored float', () => {
|
|
109
|
+
const { host, anchor } = makeOverlayHost('modal');
|
|
110
|
+
const id = floatManager.open('test:view', { dismissable: true, anchor });
|
|
111
|
+
expect(getFloatParentHost(id)).toBe(host);
|
|
112
|
+
});
|
|
113
|
+
it('getFloatParentHost is undefined for non-dismissable floats even with an anchor', () => {
|
|
114
|
+
const { anchor } = makeOverlayHost('modal');
|
|
115
|
+
const id = floatManager.open('test:view', { anchor });
|
|
116
|
+
expect(getFloatParentHost(id)).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
it('getFloatParentHost is cleared when the float is closed', () => {
|
|
119
|
+
const { anchor } = makeOverlayHost('modal');
|
|
120
|
+
const id = floatManager.open('test:view', { dismissable: true, anchor });
|
|
121
|
+
expect(getFloatParentHost(id)).toBeDefined();
|
|
122
|
+
floatManager.close(id);
|
|
123
|
+
expect(getFloatParentHost(id)).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
83
126
|
// ---------------------------------------------------------------------------
|
|
84
127
|
// DOM tests — floatManager + FloatLayer.svelte in happy-dom
|
|
85
128
|
// ---------------------------------------------------------------------------
|
|
@@ -311,3 +354,55 @@ describe('floats — F.6 multi-picker interaction', () => {
|
|
|
311
354
|
expect(floatManager.list().some((f) => f.id === id)).toBe(false);
|
|
312
355
|
});
|
|
313
356
|
});
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
// F.7 — anchor portals dismissable float into the enclosing overlay host
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
describe('floats — F.7 anchor portals to enclosing overlay host', () => {
|
|
361
|
+
beforeEach(() => {
|
|
362
|
+
resetFramework();
|
|
363
|
+
bindManagerToStore();
|
|
364
|
+
});
|
|
365
|
+
it('reparents the FloatFrame into the anchor’s enclosing overlay host', async () => {
|
|
366
|
+
const { container } = renderWithShell(FloatLayer, {});
|
|
367
|
+
const fakeModalHost = document.createElement('div');
|
|
368
|
+
fakeModalHost.className = 'fake-modal-host';
|
|
369
|
+
fakeModalHost.dataset.shellOverlayHost = 'modal';
|
|
370
|
+
const anchor = document.createElement('button');
|
|
371
|
+
fakeModalHost.appendChild(anchor);
|
|
372
|
+
document.body.appendChild(fakeModalHost);
|
|
373
|
+
floatManager.open('test:view', {
|
|
374
|
+
dismissable: true,
|
|
375
|
+
anchor,
|
|
376
|
+
title: 'Picker',
|
|
377
|
+
});
|
|
378
|
+
await tick();
|
|
379
|
+
const frame = document.querySelector('[role="dialog"][aria-label="Picker"]');
|
|
380
|
+
expect(frame).toBeTruthy();
|
|
381
|
+
expect(fakeModalHost.contains(frame)).toBe(true);
|
|
382
|
+
expect(container.contains(frame)).toBe(false);
|
|
383
|
+
});
|
|
384
|
+
it('renders inside FloatLayer when no anchor is provided', async () => {
|
|
385
|
+
const { container } = renderWithShell(FloatLayer, {});
|
|
386
|
+
floatManager.open('test:view', { dismissable: true, title: 'NoAnchor' });
|
|
387
|
+
await tick();
|
|
388
|
+
const frame = container.querySelector('[role="dialog"][aria-label="NoAnchor"]');
|
|
389
|
+
expect(frame).toBeTruthy();
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
// F.8 — overlay host marker on FloatFrame
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
describe('floats — F.8 overlay host marker', () => {
|
|
396
|
+
beforeEach(() => {
|
|
397
|
+
resetFramework();
|
|
398
|
+
bindManagerToStore();
|
|
399
|
+
});
|
|
400
|
+
it('marks each FloatFrame with data-shell-overlay-host="float"', async () => {
|
|
401
|
+
const { container } = renderWithShell(FloatLayer, {});
|
|
402
|
+
floatManager.open('test:view', { title: 'Marked' });
|
|
403
|
+
await tick();
|
|
404
|
+
const frame = container.querySelector('[role="dialog"][aria-label="Marked"]');
|
|
405
|
+
expect(frame).toBeTruthy();
|
|
406
|
+
expect(frame.dataset.shellOverlayHost).toBe('float');
|
|
407
|
+
});
|
|
408
|
+
});
|
package/dist/overlays/modal.js
CHANGED
|
@@ -109,6 +109,7 @@ function openModal(Content, props, options) {
|
|
|
109
109
|
const root = getLayerRoot('modal');
|
|
110
110
|
const host = document.createElement('div');
|
|
111
111
|
host.className = 'sh3-modal-host';
|
|
112
|
+
host.dataset.shellOverlayHost = 'modal';
|
|
112
113
|
host.style.position = 'absolute';
|
|
113
114
|
host.style.inset = '0';
|
|
114
115
|
host.style.pointerEvents = 'auto';
|
|
@@ -88,3 +88,20 @@ describe('modal — back-cascade integration', () => {
|
|
|
88
88
|
expect(layerRoot.querySelectorAll('.sh3-modal-host').length).toBe(0);
|
|
89
89
|
});
|
|
90
90
|
});
|
|
91
|
+
describe('modal — overlay host marker', () => {
|
|
92
|
+
let layerRoot;
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
layerRoot = makeLayerRoot();
|
|
95
|
+
});
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
modalManager.closeAll();
|
|
98
|
+
teardownLayerRoot(layerRoot);
|
|
99
|
+
});
|
|
100
|
+
it('marks the modal host with data-shell-overlay-host="modal"', async () => {
|
|
101
|
+
modalManager.open(DummyFrame, {});
|
|
102
|
+
await tick();
|
|
103
|
+
const host = layerRoot.querySelector('.sh3-modal-host');
|
|
104
|
+
expect(host).not.toBeNull();
|
|
105
|
+
expect(host.dataset.shellOverlayHost).toBe('modal');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function findEnclosingOverlayHost(anchor: HTMLElement): HTMLElement | null;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Walks up from `anchor` looking for an element marked as an overlay host
|
|
3
|
+
* via `data-shell-overlay-host`. Modal hosts, popup hosts, and float frames
|
|
4
|
+
* tag themselves so anchored overlays (popups, dismissable picker floats)
|
|
5
|
+
* can mount inside their opener's stacking context instead of at a global
|
|
6
|
+
* layer root — which is what the layer-z-index invariant gives us when a
|
|
7
|
+
* popover is logically "inside" a modal.
|
|
8
|
+
*
|
|
9
|
+
* Returns null when the anchor lives in the docked tree; callers fall back
|
|
10
|
+
* to their configured layer root in that case. The marker is read via
|
|
11
|
+
* `Element.closest`, so a marker on the anchor itself counts.
|
|
12
|
+
*/
|
|
13
|
+
export function findEnclosingOverlayHost(anchor) {
|
|
14
|
+
return anchor.closest('[data-shell-overlay-host]');
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { findEnclosingOverlayHost } from './parentHost';
|
|
3
|
+
afterEach(() => {
|
|
4
|
+
document.body.innerHTML = '';
|
|
5
|
+
});
|
|
6
|
+
describe('findEnclosingOverlayHost', () => {
|
|
7
|
+
it('returns the nearest ancestor with data-shell-overlay-host', () => {
|
|
8
|
+
const host = document.createElement('div');
|
|
9
|
+
host.dataset.shellOverlayHost = 'modal';
|
|
10
|
+
const inner = document.createElement('div');
|
|
11
|
+
const anchor = document.createElement('button');
|
|
12
|
+
inner.appendChild(anchor);
|
|
13
|
+
host.appendChild(inner);
|
|
14
|
+
document.body.appendChild(host);
|
|
15
|
+
expect(findEnclosingOverlayHost(anchor)).toBe(host);
|
|
16
|
+
});
|
|
17
|
+
it('returns the anchor itself when it carries the marker', () => {
|
|
18
|
+
const anchor = document.createElement('div');
|
|
19
|
+
anchor.dataset.shellOverlayHost = 'float';
|
|
20
|
+
document.body.appendChild(anchor);
|
|
21
|
+
expect(findEnclosingOverlayHost(anchor)).toBe(anchor);
|
|
22
|
+
});
|
|
23
|
+
it('returns null when no ancestor carries the marker', () => {
|
|
24
|
+
const anchor = document.createElement('button');
|
|
25
|
+
document.body.appendChild(anchor);
|
|
26
|
+
expect(findEnclosingOverlayHost(anchor)).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
it('returns the innermost host when overlay hosts are nested', () => {
|
|
29
|
+
const outer = document.createElement('div');
|
|
30
|
+
outer.dataset.shellOverlayHost = 'modal';
|
|
31
|
+
const inner = document.createElement('div');
|
|
32
|
+
inner.dataset.shellOverlayHost = 'float';
|
|
33
|
+
const anchor = document.createElement('button');
|
|
34
|
+
inner.appendChild(anchor);
|
|
35
|
+
outer.appendChild(inner);
|
|
36
|
+
document.body.appendChild(outer);
|
|
37
|
+
expect(findEnclosingOverlayHost(anchor)).toBe(inner);
|
|
38
|
+
});
|
|
39
|
+
});
|
package/dist/overlays/popup.js
CHANGED
|
@@ -83,6 +83,7 @@ function showPopup(Content, options, props) {
|
|
|
83
83
|
const root = getLayerRoot('popup');
|
|
84
84
|
const host = document.createElement('div');
|
|
85
85
|
host.className = 'sh3-popup-host';
|
|
86
|
+
host.dataset.shellOverlayHost = 'popup';
|
|
86
87
|
host.style.position = 'absolute';
|
|
87
88
|
host.style.inset = '0';
|
|
88
89
|
host.style.pointerEvents = 'none'; // only the frame captures pointer events
|
|
@@ -126,3 +126,22 @@ describe('popup — back-cascade integration', () => {
|
|
|
126
126
|
expect(layerRoot.querySelector('.sh3-popup-host')).toBeNull();
|
|
127
127
|
});
|
|
128
128
|
});
|
|
129
|
+
describe('popup — overlay host marker', () => {
|
|
130
|
+
let layerRoot;
|
|
131
|
+
beforeEach(() => {
|
|
132
|
+
vi.stubGlobal('innerWidth', 2000);
|
|
133
|
+
vi.stubGlobal('innerHeight', 2000);
|
|
134
|
+
layerRoot = makeLayerRoot();
|
|
135
|
+
});
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
__resetPopupManagerForTest();
|
|
138
|
+
teardownLayerRoot(layerRoot);
|
|
139
|
+
vi.unstubAllGlobals();
|
|
140
|
+
});
|
|
141
|
+
it('marks the popup host with data-shell-overlay-host="popup"', () => {
|
|
142
|
+
popupManager.show(DummyFrame, { anchor: { x: 100, y: 100 } }, {});
|
|
143
|
+
const host = layerRoot.querySelector('.sh3-popup-host');
|
|
144
|
+
expect(host).not.toBeNull();
|
|
145
|
+
expect(host.dataset.shellOverlayHost).toBe('popup');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
import { registerBuiltinModes } from './modes/builtin';
|
|
12
12
|
import { resolveInitialMode, writeLastMode } from './modes/prefs';
|
|
13
13
|
import type { ShellMode, ShellRole } from './modes/types';
|
|
14
|
+
import type { ContributionsApi } from '../contributions/types';
|
|
15
|
+
import { SHELL_MODE_CONTRIBUTION_POINT, type ShellModeDescriptor } from './contract';
|
|
14
16
|
import { makeDispatch } from './dispatch';
|
|
15
17
|
import { computeRelocate } from './auto-relocate';
|
|
16
18
|
import { activeLayout } from '../layout/store.svelte';
|
|
@@ -26,26 +28,64 @@
|
|
|
26
28
|
wsUrl: string;
|
|
27
29
|
userId: string;
|
|
28
30
|
role: ShellRole;
|
|
31
|
+
contributions: ContributionsApi;
|
|
29
32
|
}
|
|
30
|
-
let { shell, wsUrl, userId, role }: Props = $props();
|
|
33
|
+
let { shell, wsUrl, userId, role, contributions }: Props = $props();
|
|
31
34
|
|
|
32
35
|
const scrollback = new Scrollback();
|
|
33
36
|
const resolver = new VerbRegistry();
|
|
34
37
|
const fs = new TenantFsClient();
|
|
35
38
|
|
|
36
|
-
// Mode registry
|
|
39
|
+
// Mode registry — holds builtins only. Contributed modes flow through
|
|
40
|
+
// the contributions API and are merged reactively below.
|
|
37
41
|
const modeRegistry = new ShellModeRegistry();
|
|
38
42
|
registerBuiltinModes(modeRegistry);
|
|
39
43
|
|
|
44
|
+
// contributions.list() returns a plain array (not reactive). Mirror it
|
|
45
|
+
// into a $state cell and refresh on every onChange notification so the
|
|
46
|
+
// picker, verb listing, and active-mode fallback all react to shard
|
|
47
|
+
// hot-mount/unmount without polling.
|
|
48
|
+
let contributedModes = $state<ShellModeDescriptor[]>(
|
|
49
|
+
untrack(() => contributions.list<ShellModeDescriptor>(SHELL_MODE_CONTRIBUTION_POINT)),
|
|
50
|
+
);
|
|
51
|
+
$effect(() => {
|
|
52
|
+
const off = contributions.onChange(SHELL_MODE_CONTRIBUTION_POINT, () => {
|
|
53
|
+
contributedModes = contributions.list<ShellModeDescriptor>(SHELL_MODE_CONTRIBUTION_POINT);
|
|
54
|
+
});
|
|
55
|
+
return () => off();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** Convert a descriptor to the internal ShellMode shape so the picker and
|
|
59
|
+
* the dispatch path treat builtin and contributed modes uniformly. */
|
|
60
|
+
function descriptorToMode(d: ShellModeDescriptor): ShellMode {
|
|
61
|
+
return {
|
|
62
|
+
id: d.id,
|
|
63
|
+
label: d.label,
|
|
64
|
+
requiresRole: d.requiresRole,
|
|
65
|
+
transport: 'custom',
|
|
66
|
+
autoRelocate: d.autoRelocate ?? false,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let visibleModes = $derived<ShellMode[]>([
|
|
71
|
+
...modeRegistry.list(role),
|
|
72
|
+
...contributedModes
|
|
73
|
+
.filter((d) => !d.requiresRole || d.requiresRole === role)
|
|
74
|
+
.map(descriptorToMode),
|
|
75
|
+
]);
|
|
76
|
+
|
|
40
77
|
// Reactive current mode
|
|
41
78
|
let mode = $state<ShellMode>(
|
|
42
79
|
untrack(() => resolveInitialMode(modeRegistry, userId, role)),
|
|
43
80
|
);
|
|
44
81
|
|
|
45
82
|
function setMode(id: string): void {
|
|
46
|
-
const next =
|
|
83
|
+
const next = visibleModes.find((m) => m.id === id);
|
|
47
84
|
if (!next) return;
|
|
48
85
|
if (next.requiresRole && next.requiresRole !== role) return;
|
|
86
|
+
// Abort any in-flight custom-mode dispatch from the outgoing mode
|
|
87
|
+
// before flipping. Safe no-op if there's nothing running.
|
|
88
|
+
cancelDispatch();
|
|
49
89
|
mode = next;
|
|
50
90
|
writeLastMode(userId, id);
|
|
51
91
|
if (next.transport !== 'ws') {
|
|
@@ -53,18 +93,55 @@
|
|
|
53
93
|
}
|
|
54
94
|
}
|
|
55
95
|
|
|
96
|
+
// If the active mode disappears (shard unloaded), fall back to sh3 — or
|
|
97
|
+
// the first available mode if even sh3 is gone.
|
|
98
|
+
$effect(() => {
|
|
99
|
+
if (!visibleModes.find((m) => m.id === mode.id)) {
|
|
100
|
+
const fallback = visibleModes.find((m) => m.id === 'sh3') ?? visibleModes[0];
|
|
101
|
+
if (fallback) {
|
|
102
|
+
const lostId = mode.id;
|
|
103
|
+
mode = fallback;
|
|
104
|
+
writeLastMode(userId, fallback.id);
|
|
105
|
+
scrollback.push({
|
|
106
|
+
kind: 'status',
|
|
107
|
+
text: `mode '${lostId}' is no longer available — switched to '${fallback.id}'`,
|
|
108
|
+
level: 'warn',
|
|
109
|
+
ts: Date.now(),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Extend the shell prop with view-local mode switching so verbs (like
|
|
116
|
+
// `mode`) can drive the picker. Only this view knows the live registry
|
|
117
|
+
// and the setMode closure, so the wrapper happens here. The shell prop
|
|
118
|
+
// is stable for the view's lifetime, so capturing its initial value via
|
|
119
|
+
// untrack is intentional.
|
|
120
|
+
const shellWithModes: ShellApi = untrack(() => ({
|
|
121
|
+
...shell,
|
|
122
|
+
setMode: (id: string) => {
|
|
123
|
+
const next = visibleModes.find((m) => m.id === id);
|
|
124
|
+
if (!next) return false;
|
|
125
|
+
if (next.requiresRole && next.requiresRole !== role) return false;
|
|
126
|
+
setMode(id);
|
|
127
|
+
return true;
|
|
128
|
+
},
|
|
129
|
+
listModes: () => visibleModes.map((m) => ({ id: m.id, label: m.label })),
|
|
130
|
+
}));
|
|
131
|
+
|
|
56
132
|
// wsUrl is a prop read at construction only. untrack prevents Svelte 5's
|
|
57
133
|
// "referenced outside a closure" warning; the URL never changes at runtime.
|
|
58
134
|
const session = untrack(() => new SessionClient(wsUrl));
|
|
59
135
|
|
|
60
|
-
const dispatch = untrack(() => makeDispatch({
|
|
136
|
+
const { dispatch, cancel: cancelDispatch } = untrack(() => makeDispatch({
|
|
61
137
|
mode: () => mode,
|
|
62
138
|
resolver,
|
|
63
139
|
scrollback,
|
|
64
140
|
session,
|
|
65
|
-
shell,
|
|
141
|
+
shell: shellWithModes,
|
|
66
142
|
fs,
|
|
67
143
|
cwd: () => session.cwd,
|
|
144
|
+
customMode: (id: string) => contributedModes.find((d) => d.id === id) ?? null,
|
|
68
145
|
}));
|
|
69
146
|
|
|
70
147
|
let locked = $state(false);
|
|
@@ -81,8 +158,8 @@
|
|
|
81
158
|
// Toolbar slot registry
|
|
82
159
|
const toolbarRegistry = new ToolbarSlotRegistry();
|
|
83
160
|
toolbarRegistry.register({ id: 'mode', order: 10, visible: () => true, component: ModeSlot });
|
|
84
|
-
toolbarRegistry.register({ id: 'focus-lock', order: 20, visible: (ctx) => ctx.mode.id === '
|
|
85
|
-
toolbarRegistry.register({ id: 'target-shard', order: 30, visible: (ctx) => ctx.mode.id === '
|
|
161
|
+
toolbarRegistry.register({ id: 'focus-lock', order: 20, visible: (ctx) => ctx.mode.id === 'sh3', component: FocusLockSlot });
|
|
162
|
+
toolbarRegistry.register({ id: 'target-shard', order: 30, visible: (ctx) => ctx.mode.id === 'sh3', component: TargetShardSlot });
|
|
86
163
|
|
|
87
164
|
/** Walk the layout tree and return the viewId of the active tab in the first
|
|
88
165
|
* TabsNode found (breadth-first). Returns null if the layout contains no
|
|
@@ -194,7 +271,7 @@
|
|
|
194
271
|
registry={toolbarRegistry}
|
|
195
272
|
ctx={{ mode, role }}
|
|
196
273
|
slotProps={{
|
|
197
|
-
mode: { mode,
|
|
274
|
+
mode: { mode, modes: visibleModes, onSelect: setMode },
|
|
198
275
|
'focus-lock': { locked: focusLocked, onToggle: () => (focusLocked = !focusLocked) },
|
|
199
276
|
'target-shard': { target: targetShard },
|
|
200
277
|
}}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { type ShellApi } from './registry';
|
|
2
2
|
import type { ShellRole } from './modes/types';
|
|
3
|
+
import type { ContributionsApi } from '../contributions/types';
|
|
3
4
|
interface Props {
|
|
4
5
|
shell: ShellApi;
|
|
5
6
|
wsUrl: string;
|
|
6
7
|
userId: string;
|
|
7
8
|
role: ShellRole;
|
|
9
|
+
contributions: ContributionsApi;
|
|
8
10
|
}
|
|
9
11
|
declare const Terminal: import("svelte").Component<Props, {}, "">;
|
|
10
12
|
type Terminal = ReturnType<typeof Terminal>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Component } from 'svelte';
|
|
2
|
+
/** Contribution-point id under which mode descriptors are registered. */
|
|
3
|
+
export declare const SHELL_MODE_CONTRIBUTION_POINT = "sh3.shell.mode";
|
|
4
|
+
/** Where the descriptor's dispatch handler executes. v1 only honors 'client'. */
|
|
5
|
+
export type ShellModeRunsOn = 'client' | 'server';
|
|
6
|
+
/** A single shell-mode contribution. */
|
|
7
|
+
export interface ShellModeDescriptor {
|
|
8
|
+
/** Unique id, namespaced in practice (e.g. 'gemini', 'claude-code'). */
|
|
9
|
+
id: string;
|
|
10
|
+
/** Short label rendered in the segmented picker. */
|
|
11
|
+
label: string;
|
|
12
|
+
/** Optional segment icon. v1 picker ignores this; reserved for forward compat. */
|
|
13
|
+
icon?: string | Component;
|
|
14
|
+
/** Role gate. Same semantics as the builtin bash mode. */
|
|
15
|
+
requiresRole?: 'admin';
|
|
16
|
+
/** Where dispatch runs. v1 only honors 'client'. */
|
|
17
|
+
runsOn: ShellModeRunsOn;
|
|
18
|
+
/** Whether the shell auto-relocates cwd when a shard takes focus. */
|
|
19
|
+
autoRelocate?: boolean;
|
|
20
|
+
/** Brain: receives input and pushes output. */
|
|
21
|
+
dispatch: ShellModeDispatchHandler;
|
|
22
|
+
/** Optional lifecycle hook fired when the mode is selected. */
|
|
23
|
+
activate?: (ctx: unknown) => void | Promise<void>;
|
|
24
|
+
/** Optional lifecycle hook fired when the mode is deselected. */
|
|
25
|
+
deactivate?: (ctx: unknown) => void;
|
|
26
|
+
}
|
|
27
|
+
export interface ShellModeDispatchInput {
|
|
28
|
+
/** The raw line as submitted by the user. */
|
|
29
|
+
line: string;
|
|
30
|
+
/** Current working directory at submit time. */
|
|
31
|
+
cwd: string;
|
|
32
|
+
/**
|
|
33
|
+
* Aborts when the user switches mode, runs `clear`, or otherwise cancels
|
|
34
|
+
* the in-flight dispatch. Mode handlers MUST propagate this signal to
|
|
35
|
+
* any long-running work (e.g. pass to fetch).
|
|
36
|
+
*/
|
|
37
|
+
signal: AbortSignal;
|
|
38
|
+
}
|
|
39
|
+
export type ShellModeDispatchHandler = (input: ShellModeDispatchInput, output: ShellModeOutput) => Promise<void>;
|
|
40
|
+
export interface ShellModeOutput {
|
|
41
|
+
/** Push a text chunk to the scrollback. Consecutive same-stream chunks coalesce. */
|
|
42
|
+
text(stream: 'stdout' | 'stderr', chunk: string): void;
|
|
43
|
+
/** Push a status entry (info / warn / error). */
|
|
44
|
+
status(level: 'info' | 'warn' | 'error', msg: string): void;
|
|
45
|
+
/** Push a rich entry whose props can be patched later via the returned handle. */
|
|
46
|
+
rich(component: Component<any>, props: Record<string, unknown>): RichEntryHandle;
|
|
47
|
+
/**
|
|
48
|
+
* Push a streaming rich entry. Returns a handle the mode appends to as
|
|
49
|
+
* tokens arrive. The framework marks the entry mid-stream until `complete()`
|
|
50
|
+
* or `error()` is called so the renderer can show a loading affordance.
|
|
51
|
+
*/
|
|
52
|
+
stream(component: Component<any>, initialProps: Record<string, unknown>): StreamHandle;
|
|
53
|
+
}
|
|
54
|
+
export interface RichEntryHandle {
|
|
55
|
+
/** Patch the entry's props. Triggers Svelte reactivity. */
|
|
56
|
+
update(patch: Record<string, unknown>): void;
|
|
57
|
+
}
|
|
58
|
+
export interface StreamHandle {
|
|
59
|
+
/** Patch props as new tokens arrive. */
|
|
60
|
+
append(patch: Record<string, unknown>): void;
|
|
61
|
+
/** Mark the stream finished cleanly. */
|
|
62
|
+
complete(): void;
|
|
63
|
+
/** Mark the stream finished with an error; renders an error status. */
|
|
64
|
+
error(err: unknown): void;
|
|
65
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Public contract for shell-mode contributions. External shards register
|
|
3
|
+
* descriptors via `registerShellMode(ctx, descriptor)` (see registerShellMode.ts);
|
|
4
|
+
* the shell-shard's dispatch path looks them up by id when transport === 'custom'.
|
|
5
|
+
*
|
|
6
|
+
* v1 only implements `runsOn: 'client'`. Selecting a `runsOn: 'server'` mode
|
|
7
|
+
* is rejected at dispatch with a clear status — server-side execution is a
|
|
8
|
+
* future addition that will not change this contract.
|
|
9
|
+
*/
|
|
10
|
+
/** Contribution-point id under which mode descriptors are registered. */
|
|
11
|
+
export const SHELL_MODE_CONTRIBUTION_POINT = 'sh3.shell.mode';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { makeDispatch } from './dispatch';
|
|
3
|
+
function makeStubDeps(mode, customMode) {
|
|
4
|
+
const pushed = [];
|
|
5
|
+
const scrollback = { push: (e) => pushed.push(e) };
|
|
6
|
+
const session = {
|
|
7
|
+
history: { push: vi.fn() },
|
|
8
|
+
send: () => { },
|
|
9
|
+
cwd: '/',
|
|
10
|
+
};
|
|
11
|
+
const shell = {};
|
|
12
|
+
const fs = {};
|
|
13
|
+
const resolver = {
|
|
14
|
+
resolve: (line) => ({ kind: 'forward', line }),
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
deps: {
|
|
18
|
+
mode: () => mode,
|
|
19
|
+
resolver,
|
|
20
|
+
scrollback,
|
|
21
|
+
session,
|
|
22
|
+
shell,
|
|
23
|
+
fs,
|
|
24
|
+
cwd: () => '/',
|
|
25
|
+
customMode,
|
|
26
|
+
},
|
|
27
|
+
pushed,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
describe('dispatch — custom transport', () => {
|
|
31
|
+
it('routes the line to the descriptor.dispatch', async () => {
|
|
32
|
+
const handler = vi.fn(async () => { });
|
|
33
|
+
const desc = {
|
|
34
|
+
id: 'gemini',
|
|
35
|
+
label: 'Gemini',
|
|
36
|
+
runsOn: 'client',
|
|
37
|
+
dispatch: handler,
|
|
38
|
+
};
|
|
39
|
+
const mode = { id: 'gemini', label: 'Gemini', transport: 'custom', autoRelocate: false };
|
|
40
|
+
const { deps } = makeStubDeps(mode, () => desc);
|
|
41
|
+
const { dispatch } = makeDispatch(deps);
|
|
42
|
+
await dispatch('hello');
|
|
43
|
+
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ line: 'hello', cwd: '/' }), expect.objectContaining({ text: expect.any(Function) }));
|
|
44
|
+
});
|
|
45
|
+
it('rejects runsOn: server with a clear status', async () => {
|
|
46
|
+
const desc = { id: 'srv', label: 'Srv', runsOn: 'server', dispatch: async () => { } };
|
|
47
|
+
const mode = { id: 'srv', label: 'Srv', transport: 'custom', autoRelocate: false };
|
|
48
|
+
const { deps, pushed } = makeStubDeps(mode, () => desc);
|
|
49
|
+
const { dispatch } = makeDispatch(deps);
|
|
50
|
+
await dispatch('hi');
|
|
51
|
+
const err = pushed.find((e) => e.kind === 'status' && e.level === 'error');
|
|
52
|
+
expect(err).toBeDefined();
|
|
53
|
+
expect(err.text).toMatch(/server-side modes are not yet supported/);
|
|
54
|
+
});
|
|
55
|
+
it('catches handler throws and renders an error status', async () => {
|
|
56
|
+
const desc = {
|
|
57
|
+
id: 'gemini',
|
|
58
|
+
label: 'Gemini',
|
|
59
|
+
runsOn: 'client',
|
|
60
|
+
dispatch: async () => {
|
|
61
|
+
throw new Error('kaboom');
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
const mode = { id: 'gemini', label: 'Gemini', transport: 'custom', autoRelocate: false };
|
|
65
|
+
const { deps, pushed } = makeStubDeps(mode, () => desc);
|
|
66
|
+
const { dispatch } = makeDispatch(deps);
|
|
67
|
+
await dispatch('hi');
|
|
68
|
+
const err = pushed.find((e) => e.kind === 'status' && e.level === 'error');
|
|
69
|
+
expect(err).toBeDefined();
|
|
70
|
+
expect(err.text).toMatch(/kaboom/);
|
|
71
|
+
});
|
|
72
|
+
it('aborts in-flight dispatch when cancel() is called', async () => {
|
|
73
|
+
let aborted = false;
|
|
74
|
+
const desc = {
|
|
75
|
+
id: 'gemini',
|
|
76
|
+
label: 'Gemini',
|
|
77
|
+
runsOn: 'client',
|
|
78
|
+
dispatch: async (input) => {
|
|
79
|
+
await new Promise((_resolve, reject) => {
|
|
80
|
+
input.signal.addEventListener('abort', () => {
|
|
81
|
+
aborted = true;
|
|
82
|
+
reject(new DOMException('aborted', 'AbortError'));
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
const mode = { id: 'gemini', label: 'Gemini', transport: 'custom', autoRelocate: false };
|
|
88
|
+
const { deps } = makeStubDeps(mode, () => desc);
|
|
89
|
+
const { dispatch, cancel } = makeDispatch(deps);
|
|
90
|
+
const promise = dispatch('hi');
|
|
91
|
+
cancel();
|
|
92
|
+
await promise;
|
|
93
|
+
expect(aborted).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
it('emits an error if the descriptor has been unloaded', async () => {
|
|
96
|
+
const mode = { id: 'ghost', label: 'Ghost', transport: 'custom', autoRelocate: false };
|
|
97
|
+
const { deps, pushed } = makeStubDeps(mode, () => null);
|
|
98
|
+
const { dispatch } = makeDispatch(deps);
|
|
99
|
+
await dispatch('hi');
|
|
100
|
+
const err = pushed.find((e) => e.kind === 'status' && e.level === 'error');
|
|
101
|
+
expect(err).toBeDefined();
|
|
102
|
+
expect(err.text).toMatch(/no longer available/);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -3,6 +3,7 @@ import type { Scrollback } from './scrollback.svelte';
|
|
|
3
3
|
import type { SessionClient } from './session-client.svelte';
|
|
4
4
|
import type { TenantFsClient } from './tenant-fs-client';
|
|
5
5
|
import type { ShellMode } from './modes/types';
|
|
6
|
+
import type { ShellModeDescriptor } from './contract';
|
|
6
7
|
export interface DispatchDeps {
|
|
7
8
|
mode: () => ShellMode;
|
|
8
9
|
resolver: VerbRegistry;
|
|
@@ -11,5 +12,17 @@ export interface DispatchDeps {
|
|
|
11
12
|
shell: ShellApi;
|
|
12
13
|
fs: TenantFsClient;
|
|
13
14
|
cwd: () => string;
|
|
15
|
+
/**
|
|
16
|
+
* Look up a contributed mode descriptor by id. Called only when the active
|
|
17
|
+
* mode has `transport: 'custom'`. Returns null if the descriptor has been
|
|
18
|
+
* unloaded (rare race; the active-mode-fallback effect in Terminal.svelte
|
|
19
|
+
* handles this on the next tick — dispatch surfaces an error in the meantime).
|
|
20
|
+
*/
|
|
21
|
+
customMode?: (id: string) => ShellModeDescriptor | null;
|
|
14
22
|
}
|
|
15
|
-
export
|
|
23
|
+
export interface DispatchHandle {
|
|
24
|
+
dispatch: (line: string) => Promise<void>;
|
|
25
|
+
/** Abort any in-flight custom-mode dispatch. Safe to call repeatedly. */
|
|
26
|
+
cancel: () => void;
|
|
27
|
+
}
|
|
28
|
+
export declare function makeDispatch(deps: DispatchDeps): DispatchHandle;
|