sh3-core 0.14.0 → 0.15.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 -1
- package/dist/api.js +4 -0
- package/dist/contributions/index.d.ts +1 -1
- package/dist/contributions/index.js +1 -1
- package/dist/contributions/registry.d.ts +7 -0
- package/dist/contributions/registry.js +24 -4
- package/dist/contributions/registry.test.js +56 -1
- package/dist/contributions/types.d.ts +9 -0
- package/dist/layout/LayoutRenderer.svelte +1 -1
- package/dist/layout/tree-walk.js +6 -1
- package/dist/layout/types.d.ts +7 -0
- package/dist/overlays/FloatFrame.svelte +8 -2
- package/dist/overlays/float.js +6 -3
- package/dist/overlays/float.test.js +71 -0
- package/dist/primitives/widgets/IconToggleGroup.svelte +4 -1
- package/dist/primitives/widgets/Segmented.svelte +4 -1
- package/dist/runtime/index.d.ts +2 -0
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/runVerb.d.ts +10 -0
- package/dist/runtime/runVerb.js +97 -0
- package/dist/runtime/runVerb.test.d.ts +1 -0
- package/dist/runtime/runVerb.test.js +132 -0
- package/dist/sh3core-shard/AppInfoView.svelte +154 -0
- package/dist/sh3core-shard/AppInfoView.svelte.d.ts +11 -0
- package/dist/sh3core-shard/appActions.js +23 -5
- package/dist/shards/activate-contributions.test.js +31 -0
- package/dist/shards/activate-runtime.test.d.ts +1 -0
- package/dist/shards/activate-runtime.test.js +179 -0
- package/dist/shards/activate.svelte.js +20 -3
- package/dist/shards/registry.d.ts +11 -1
- package/dist/shards/registry.js +16 -4
- package/dist/shards/registry.test.js +24 -16
- package/dist/shards/types.d.ts +38 -1
- package/dist/shell-shard/ScrollbackView.svelte +40 -19
- package/dist/shell-shard/Terminal.svelte +55 -4
- package/dist/shell-shard/contract.d.ts +34 -0
- package/dist/shell-shard/dispatch-custom.test.js +48 -0
- package/dist/shell-shard/dispatch-gating.test.d.ts +1 -0
- package/dist/shell-shard/dispatch-gating.test.js +63 -0
- package/dist/shell-shard/dispatch-invoke.test.d.ts +1 -0
- package/dist/shell-shard/dispatch-invoke.test.js +214 -0
- package/dist/shell-shard/dispatch.d.ts +9 -1
- package/dist/shell-shard/dispatch.js +73 -2
- package/dist/shell-shard/output.d.ts +8 -1
- package/dist/shell-shard/output.js +17 -1
- package/dist/shell-shard/output.test.js +24 -5
- package/dist/shell-shard/registry-resolve.test.d.ts +1 -0
- package/dist/shell-shard/registry-resolve.test.js +26 -0
- package/dist/shell-shard/registry.d.ts +12 -1
- package/dist/shell-shard/registry.js +12 -1
- package/dist/shell-shard/shellApi.d.ts +3 -0
- package/dist/shell-shard/shellApi.js +142 -0
- package/dist/shell-shard/shellShard.svelte.d.ts +1 -7
- package/dist/shell-shard/shellShard.svelte.js +8 -163
- package/dist/shell-shard/terminal-dispatch.test.js +10 -3
- package/dist/shell-shard/toolbar/slots/BusySlot.svelte +35 -0
- package/dist/shell-shard/toolbar/slots/BusySlot.svelte.d.ts +7 -0
- package/dist/shell-shard/verbs/clear.js +1 -0
- package/dist/shell-shard/verbs/mode.js +1 -0
- package/dist/verbs/types.d.ts +68 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/api.d.ts
CHANGED
|
@@ -46,10 +46,12 @@ export declare const capabilities: {
|
|
|
46
46
|
readonly hotInstall: boolean;
|
|
47
47
|
};
|
|
48
48
|
export type { ServerShard, ServerShardContext, TenantDocumentAPI } from './server-shard/types';
|
|
49
|
-
export type { Verb, VerbContext, ShellApi } from './verbs/types';
|
|
49
|
+
export type { Verb, VerbContext, ShellApi, VerbSchema, PortableJSONSchema, } from './verbs/types';
|
|
50
50
|
export type { Scrollback } from './shell-shard/scrollback.svelte';
|
|
51
51
|
export type { SessionClient } from './shell-shard/session-client.svelte';
|
|
52
52
|
export { listVerbs } from './shards/registry';
|
|
53
|
+
export { runVerbProgrammatic } from './runtime';
|
|
54
|
+
export type { RunVerbOpts, RunVerbResult } from './runtime';
|
|
53
55
|
export { registerShellMode } from './shell-shard/registerShellMode';
|
|
54
56
|
export type { ShellModeDescriptor, ShellModeOutput, ShellModeDispatchHandler, ShellModeDispatchInput, ShellModeRunsOn, RichEntryHandle, StreamHandle, } from './shell-shard/contract';
|
|
55
57
|
export { SHELL_MODE_CONTRIBUTION_POINT } from './shell-shard/contract';
|
package/dist/api.js
CHANGED
|
@@ -56,6 +56,10 @@ export const capabilities = {
|
|
|
56
56
|
hotInstall: typeof Blob !== 'undefined' && typeof URL.createObjectURL === 'function',
|
|
57
57
|
};
|
|
58
58
|
export { listVerbs } from './shards/registry';
|
|
59
|
+
// Programmatic verb dispatch (ctx.runVerb backing function — exposed for
|
|
60
|
+
// advanced use cases like cross-tab dispatch wrappers; most consumers
|
|
61
|
+
// should call ctx.runVerb).
|
|
62
|
+
export { runVerbProgrammatic } from './runtime';
|
|
59
63
|
// Shell mode contributions (external shards extend the shell with new modes).
|
|
60
64
|
export { registerShellMode } from './shell-shard/registerShellMode';
|
|
61
65
|
export { SHELL_MODE_CONTRIBUTION_POINT } from './shell-shard/contract';
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export type { ContributionsApi } from './types';
|
|
2
|
-
export { register, list, listPoints, onChange, __resetContributionsForTest } from './registry';
|
|
2
|
+
export { register, list, listPoints, onChange, onAnyChange, __resetContributionsForTest } from './registry';
|
|
@@ -5,4 +5,4 @@
|
|
|
5
5
|
* file is internal-only, re-exporting the registry for activate.svelte.ts
|
|
6
6
|
* and for tests.
|
|
7
7
|
*/
|
|
8
|
-
export { register, list, listPoints, onChange, __resetContributionsForTest } from './registry';
|
|
8
|
+
export { register, list, listPoints, onChange, onAnyChange, __resetContributionsForTest } from './registry';
|
|
@@ -14,6 +14,13 @@ export declare function listPoints(): string[];
|
|
|
14
14
|
* safe no-op.
|
|
15
15
|
*/
|
|
16
16
|
export declare function onChange(pointId: string, cb: () => void): () => void;
|
|
17
|
+
/**
|
|
18
|
+
* Subscribe to registration changes at every contribution point.
|
|
19
|
+
* The callback receives the `pointId` so consumers can rebuild
|
|
20
|
+
* incrementally. Returns an unsubscribe; double-unsubscribe is a
|
|
21
|
+
* safe no-op. Symmetric with `onChange`, but global.
|
|
22
|
+
*/
|
|
23
|
+
export declare function onAnyChange(cb: (pointId: string) => void): () => void;
|
|
17
24
|
/**
|
|
18
25
|
* Test-only reset. Not exported from the barrel; tests import it
|
|
19
26
|
* directly from this module.
|
|
@@ -12,12 +12,15 @@
|
|
|
12
12
|
*/
|
|
13
13
|
const points = new Map();
|
|
14
14
|
const listeners = new Map();
|
|
15
|
+
const anyListeners = new Set();
|
|
15
16
|
function emit(pointId) {
|
|
16
17
|
const set = listeners.get(pointId);
|
|
17
|
-
if (
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
if (set) {
|
|
19
|
+
for (const cb of set)
|
|
20
|
+
cb();
|
|
21
|
+
}
|
|
22
|
+
for (const cb of anyListeners)
|
|
23
|
+
cb(pointId);
|
|
21
24
|
}
|
|
22
25
|
/**
|
|
23
26
|
* Register a descriptor under the given point. Returns an unregister
|
|
@@ -79,6 +82,22 @@ export function onChange(pointId, cb) {
|
|
|
79
82
|
listeners.delete(pointId);
|
|
80
83
|
};
|
|
81
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Subscribe to registration changes at every contribution point.
|
|
87
|
+
* The callback receives the `pointId` so consumers can rebuild
|
|
88
|
+
* incrementally. Returns an unsubscribe; double-unsubscribe is a
|
|
89
|
+
* safe no-op. Symmetric with `onChange`, but global.
|
|
90
|
+
*/
|
|
91
|
+
export function onAnyChange(cb) {
|
|
92
|
+
anyListeners.add(cb);
|
|
93
|
+
let disposed = false;
|
|
94
|
+
return () => {
|
|
95
|
+
if (disposed)
|
|
96
|
+
return;
|
|
97
|
+
disposed = true;
|
|
98
|
+
anyListeners.delete(cb);
|
|
99
|
+
};
|
|
100
|
+
}
|
|
82
101
|
/**
|
|
83
102
|
* Test-only reset. Not exported from the barrel; tests import it
|
|
84
103
|
* directly from this module.
|
|
@@ -86,4 +105,5 @@ export function onChange(pointId, cb) {
|
|
|
86
105
|
export function __resetContributionsForTest() {
|
|
87
106
|
points.clear();
|
|
88
107
|
listeners.clear();
|
|
108
|
+
anyListeners.clear();
|
|
89
109
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { register, list, listPoints, onChange, __resetContributionsForTest, } from './registry';
|
|
2
|
+
import { register, list, listPoints, onChange, onAnyChange, __resetContributionsForTest, } from './registry';
|
|
3
3
|
describe('contributions registry', () => {
|
|
4
4
|
beforeEach(() => {
|
|
5
5
|
__resetContributionsForTest();
|
|
@@ -106,4 +106,59 @@ describe('contributions registry', () => {
|
|
|
106
106
|
expect(cb).not.toHaveBeenCalled();
|
|
107
107
|
});
|
|
108
108
|
});
|
|
109
|
+
describe('onAnyChange', () => {
|
|
110
|
+
it('fires on register at any point', () => {
|
|
111
|
+
const cb = vi.fn();
|
|
112
|
+
onAnyChange(cb);
|
|
113
|
+
register('p1', { id: 'a' });
|
|
114
|
+
register('p2', { id: 'b' });
|
|
115
|
+
expect(cb).toHaveBeenCalledTimes(2);
|
|
116
|
+
expect(cb).toHaveBeenNthCalledWith(1, 'p1');
|
|
117
|
+
expect(cb).toHaveBeenNthCalledWith(2, 'p2');
|
|
118
|
+
});
|
|
119
|
+
it('fires on unregister at any point', () => {
|
|
120
|
+
const cb = vi.fn();
|
|
121
|
+
const u1 = register('p1', { id: 'a' });
|
|
122
|
+
const u2 = register('p2', { id: 'b' });
|
|
123
|
+
onAnyChange(cb);
|
|
124
|
+
u1();
|
|
125
|
+
u2();
|
|
126
|
+
expect(cb).toHaveBeenCalledTimes(2);
|
|
127
|
+
expect(cb).toHaveBeenNthCalledWith(1, 'p1');
|
|
128
|
+
expect(cb).toHaveBeenNthCalledWith(2, 'p2');
|
|
129
|
+
});
|
|
130
|
+
it('supports multiple subscribers', () => {
|
|
131
|
+
const a = vi.fn();
|
|
132
|
+
const b = vi.fn();
|
|
133
|
+
onAnyChange(a);
|
|
134
|
+
onAnyChange(b);
|
|
135
|
+
register('p', { id: 'x' });
|
|
136
|
+
expect(a).toHaveBeenCalledWith('p');
|
|
137
|
+
expect(b).toHaveBeenCalledWith('p');
|
|
138
|
+
});
|
|
139
|
+
it('unsubscribe stops further notifications', () => {
|
|
140
|
+
const cb = vi.fn();
|
|
141
|
+
const off = onAnyChange(cb);
|
|
142
|
+
off();
|
|
143
|
+
register('p', { id: 'x' });
|
|
144
|
+
expect(cb).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
it('double-unsubscribe is a no-op', () => {
|
|
147
|
+
const cb = vi.fn();
|
|
148
|
+
const off = onAnyChange(cb);
|
|
149
|
+
off();
|
|
150
|
+
off();
|
|
151
|
+
register('p', { id: 'x' });
|
|
152
|
+
expect(cb).not.toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
it('coexists with per-point onChange (both fire)', () => {
|
|
155
|
+
const any = vi.fn();
|
|
156
|
+
const point = vi.fn();
|
|
157
|
+
onAnyChange(any);
|
|
158
|
+
onChange('p', point);
|
|
159
|
+
register('p', { id: 'x' });
|
|
160
|
+
expect(any).toHaveBeenCalledWith('p');
|
|
161
|
+
expect(point).toHaveBeenCalledTimes(1);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
109
164
|
});
|
|
@@ -21,4 +21,13 @@ export interface ContributionsApi {
|
|
|
21
21
|
* shard deactivate.
|
|
22
22
|
*/
|
|
23
23
|
onChange(pointId: string, cb: () => void): () => void;
|
|
24
|
+
/**
|
|
25
|
+
* Subscribe to register/unregister at every contribution point. The
|
|
26
|
+
* callback receives the `pointId` so consumers can rebuild incrementally.
|
|
27
|
+
* Returns an unsubscribe; auto-unsubscribed on shard deactivate.
|
|
28
|
+
*
|
|
29
|
+
* Diagnostic-class shards (sh3-ai, sh3-diagnostic) use this to keep a
|
|
30
|
+
* live "everything wired" picture without polling `listPoints`.
|
|
31
|
+
*/
|
|
32
|
+
onAnyChange(cb: (pointId: string) => void): () => void;
|
|
24
33
|
}
|
package/dist/layout/tree-walk.js
CHANGED
|
@@ -15,7 +15,12 @@ export function collectSlotRefs(tree) {
|
|
|
15
15
|
const out = [];
|
|
16
16
|
const walk = (node) => {
|
|
17
17
|
if (node.type === 'slot') {
|
|
18
|
-
out.push({
|
|
18
|
+
out.push({
|
|
19
|
+
slotId: node.slotId,
|
|
20
|
+
viewId: node.viewId,
|
|
21
|
+
label: node.viewId || node.slotId,
|
|
22
|
+
meta: node.meta,
|
|
23
|
+
});
|
|
19
24
|
return;
|
|
20
25
|
}
|
|
21
26
|
if (node.type === 'tabs') {
|
package/dist/layout/types.d.ts
CHANGED
|
@@ -86,6 +86,13 @@ export interface SlotNode {
|
|
|
86
86
|
slotId: string;
|
|
87
87
|
/** View id to mount into this slot, or null for an empty slot. */
|
|
88
88
|
viewId: string | null;
|
|
89
|
+
/**
|
|
90
|
+
* Caller-supplied instance data, threaded to `MountContext.meta`.
|
|
91
|
+
* Ephemeral — not serialized with the layout tree. Mirrors
|
|
92
|
+
* `TabEntry.meta` for the bare-slot case (e.g. dismissable floats whose
|
|
93
|
+
* content is a single slot rather than a TabsNode).
|
|
94
|
+
*/
|
|
95
|
+
meta?: Record<string, unknown>;
|
|
89
96
|
}
|
|
90
97
|
/**
|
|
91
98
|
* Union of all layout node kinds. The recursive tree is composed entirely of
|
|
@@ -42,14 +42,20 @@
|
|
|
42
42
|
// stacking context — so a picker opened from inside a modal stacks above
|
|
43
43
|
// that modal without writing any z-index. The Svelte component lifecycle
|
|
44
44
|
// is unaffected; we're only relocating the rendered DOM node.
|
|
45
|
+
//
|
|
46
|
+
// The cleanup removes frameEl directly: Svelte's keyed-each iteration
|
|
47
|
+
// removal walks the anchor range left in FloatLayer to clear the DOM, and
|
|
48
|
+
// the portal moved frameEl out of that range — re-parenting it back to
|
|
49
|
+
// FloatLayer would orphan it (Svelte's removal pass clears the empty
|
|
50
|
+
// anchor range and misses the re-deposited frame). Effect deps are stable
|
|
51
|
+
// (frameEl, entry.id), so cleanup only fires on destroy.
|
|
45
52
|
$effect(() => {
|
|
46
53
|
if (!frameEl) return;
|
|
47
54
|
const host = getFloatParentHost(entry.id);
|
|
48
55
|
if (!host) return;
|
|
49
|
-
const original = frameEl.parentNode;
|
|
50
56
|
host.appendChild(frameEl);
|
|
51
57
|
return () => {
|
|
52
|
-
if (frameEl?.parentNode === host
|
|
58
|
+
if (frameEl?.parentNode === host) frameEl.remove();
|
|
53
59
|
};
|
|
54
60
|
});
|
|
55
61
|
|
package/dist/overlays/float.js
CHANGED
|
@@ -86,13 +86,16 @@ function openFloat(viewId, options = {}) {
|
|
|
86
86
|
let content;
|
|
87
87
|
if (options.dismissable) {
|
|
88
88
|
// Picker float: render the view directly as a leaf slot. No tab strip,
|
|
89
|
-
// no tabs wrapper, no drag-to-dock handle.
|
|
90
|
-
//
|
|
91
|
-
|
|
89
|
+
// no tabs wrapper, no drag-to-dock handle. SlotNode.meta carries
|
|
90
|
+
// options.meta through to MountContext, mirroring TabEntry.meta.
|
|
91
|
+
const slot = {
|
|
92
92
|
type: 'slot',
|
|
93
93
|
slotId,
|
|
94
94
|
viewId,
|
|
95
95
|
};
|
|
96
|
+
if (options.meta)
|
|
97
|
+
slot.meta = options.meta;
|
|
98
|
+
content = slot;
|
|
96
99
|
}
|
|
97
100
|
else {
|
|
98
101
|
const label = (_a = options.title) !== null && _a !== void 0 ? _a : viewId;
|
|
@@ -388,6 +388,32 @@ describe('floats — F.7 anchor portals to enclosing overlay host', () => {
|
|
|
388
388
|
const frame = container.querySelector('[role="dialog"][aria-label="NoAnchor"]');
|
|
389
389
|
expect(frame).toBeTruthy();
|
|
390
390
|
});
|
|
391
|
+
// Regression: Svelte's keyed-each iteration removal walks the anchor range
|
|
392
|
+
// left in FloatLayer to clear the DOM. The portal moved frameEl out of that
|
|
393
|
+
// range, so a re-parent-back-to-original cleanup orphans the empty frame
|
|
394
|
+
// in FloatLayer when the float is dismissed. Cleanup must remove frameEl
|
|
395
|
+
// directly.
|
|
396
|
+
it('removes the portaled frame on dismiss (no orphan in FloatLayer)', async () => {
|
|
397
|
+
const { container } = renderWithShell(FloatLayer, {});
|
|
398
|
+
const fakeModalHost = document.createElement('div');
|
|
399
|
+
fakeModalHost.className = 'fake-modal-host';
|
|
400
|
+
fakeModalHost.dataset.shellOverlayHost = 'modal';
|
|
401
|
+
const anchor = document.createElement('button');
|
|
402
|
+
fakeModalHost.appendChild(anchor);
|
|
403
|
+
document.body.appendChild(fakeModalHost);
|
|
404
|
+
const id = floatManager.open('test:view', {
|
|
405
|
+
dismissable: true,
|
|
406
|
+
anchor,
|
|
407
|
+
title: 'Picker',
|
|
408
|
+
});
|
|
409
|
+
await tick();
|
|
410
|
+
expect(fakeModalHost.querySelector('.sh3-float-frame')).not.toBeNull();
|
|
411
|
+
floatManager.close(id);
|
|
412
|
+
await tick();
|
|
413
|
+
const layerDiv = container.querySelector('.sh3-float-layer');
|
|
414
|
+
expect(layerDiv === null || layerDiv === void 0 ? void 0 : layerDiv.querySelector('.sh3-float-frame')).toBeNull();
|
|
415
|
+
expect(document.querySelectorAll('.sh3-float-frame').length).toBe(0);
|
|
416
|
+
});
|
|
391
417
|
});
|
|
392
418
|
// ---------------------------------------------------------------------------
|
|
393
419
|
// F.8 — overlay host marker on FloatFrame
|
|
@@ -406,3 +432,48 @@ describe('floats — F.8 overlay host marker', () => {
|
|
|
406
432
|
expect(frame.dataset.shellOverlayHost).toBe('float');
|
|
407
433
|
});
|
|
408
434
|
});
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
// F.9 — meta threading from floatManager.open into MountContext
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
import { registerView } from '../shards/registry';
|
|
439
|
+
describe('floats — F.9 meta threads to MountContext', () => {
|
|
440
|
+
beforeEach(() => {
|
|
441
|
+
resetFramework();
|
|
442
|
+
bindManagerToStore();
|
|
443
|
+
});
|
|
444
|
+
it('non-dismissable: ctx.meta receives options.meta on mount', async () => {
|
|
445
|
+
let captured;
|
|
446
|
+
registerView('test:meta-view', {
|
|
447
|
+
mount(_container, ctx) {
|
|
448
|
+
captured = ctx.meta;
|
|
449
|
+
return { unmount: () => { } };
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
renderWithShell(FloatLayer, {});
|
|
453
|
+
floatManager.open('test:meta-view', { title: 'M', meta: { kind: 'tab', n: 1 } });
|
|
454
|
+
await tick();
|
|
455
|
+
// acquireSlotHost defers factory.mount via queueMicrotask — flush it.
|
|
456
|
+
await Promise.resolve();
|
|
457
|
+
await tick();
|
|
458
|
+
expect(captured).toEqual({ kind: 'tab', n: 1 });
|
|
459
|
+
});
|
|
460
|
+
it('dismissable: ctx.meta receives options.meta on mount', async () => {
|
|
461
|
+
let captured;
|
|
462
|
+
registerView('test:meta-view', {
|
|
463
|
+
mount(_container, ctx) {
|
|
464
|
+
captured = ctx.meta;
|
|
465
|
+
return { unmount: () => { } };
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
renderWithShell(FloatLayer, {});
|
|
469
|
+
floatManager.open('test:meta-view', {
|
|
470
|
+
dismissable: true,
|
|
471
|
+
title: 'P',
|
|
472
|
+
meta: { kind: 'picker', color: '#abc' },
|
|
473
|
+
});
|
|
474
|
+
await tick();
|
|
475
|
+
await Promise.resolve();
|
|
476
|
+
await tick();
|
|
477
|
+
expect(captured).toEqual({ kind: 'picker', color: '#abc' });
|
|
478
|
+
});
|
|
479
|
+
});
|
|
@@ -78,7 +78,10 @@
|
|
|
78
78
|
color: var(--shell-fg);
|
|
79
79
|
filter: none;
|
|
80
80
|
}
|
|
81
|
-
|
|
81
|
+
/* Selector includes `.sh3-itg button` so specificity (0,2,1) beats the
|
|
82
|
+
base `.sh3-itg button` rule (0,1,1) — otherwise `background: transparent`
|
|
83
|
+
and `color: var(--shell-fg-muted)` would shadow the accent fill. */
|
|
84
|
+
.sh3-itg button.sh3-itg__btn--active {
|
|
82
85
|
background: var(--shell-accent);
|
|
83
86
|
color: var(--shell-fg-on-accent);
|
|
84
87
|
font-weight: 600;
|
|
@@ -75,7 +75,10 @@
|
|
|
75
75
|
color: var(--shell-fg);
|
|
76
76
|
filter: none;
|
|
77
77
|
}
|
|
78
|
-
|
|
78
|
+
/* Selector includes `.sh3-seg button` so specificity (0,2,1) beats the
|
|
79
|
+
base `.sh3-seg button` rule (0,1,1) — otherwise `background: transparent`
|
|
80
|
+
and `color: var(--shell-fg-muted)` would shadow the accent fill. */
|
|
81
|
+
.sh3-seg button.sh3-seg__btn--active {
|
|
79
82
|
background: var(--shell-accent);
|
|
80
83
|
color: var(--shell-fg-on-accent);
|
|
81
84
|
font-weight: 600;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { runVerbProgrammatic } from './runVerb';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ScrollbackEntry } from '../shell-shard/scrollback.svelte';
|
|
2
|
+
export interface RunVerbOpts {
|
|
3
|
+
signal?: AbortSignal;
|
|
4
|
+
structured?: unknown;
|
|
5
|
+
}
|
|
6
|
+
export interface RunVerbResult {
|
|
7
|
+
result: unknown;
|
|
8
|
+
scrollback: ScrollbackEntry[];
|
|
9
|
+
}
|
|
10
|
+
export declare function runVerbProgrammatic(shardId: string, name: string, args: string[], opts?: RunVerbOpts): Promise<RunVerbResult>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* runVerbProgrammatic — programmatic verb dispatch with synthesized VerbContext.
|
|
3
|
+
*
|
|
4
|
+
* Used by `ctx.runVerb(...)` (see shards/activate.svelte.ts). Builds a
|
|
5
|
+
* sink scrollback that captures entries into an array, a headless ShellApi
|
|
6
|
+
* (no terminal-bound state), a stub SessionClient, and a real TenantFsClient.
|
|
7
|
+
* Verbs that opt in via `programmatic: true` run against this synthesized
|
|
8
|
+
* context; non-programmatic verbs are rejected.
|
|
9
|
+
*
|
|
10
|
+
* Inner `ctx.dispatch(line)` re-enters this function. The inner call
|
|
11
|
+
* shares the same captured scrollback so the outer caller sees a flat
|
|
12
|
+
* transcript; the inner result is discarded (matches terminal `dispatch`
|
|
13
|
+
* fire-and-forget semantics).
|
|
14
|
+
*
|
|
15
|
+
* Resolution: prefixed names (`'sh3-store:install'`) look up directly;
|
|
16
|
+
* unprefixed shell names (`'apps'`) resolve against shardId 'shell'.
|
|
17
|
+
*/
|
|
18
|
+
import { activeShards } from '../shards/activate.svelte';
|
|
19
|
+
import { getVerb, listVerbsWithShard } from '../shards/registry';
|
|
20
|
+
import { makeShellApiHeadless } from '../shell-shard/shellApi';
|
|
21
|
+
import { Scrollback } from '../shell-shard/scrollback.svelte';
|
|
22
|
+
import { SessionClient } from '../shell-shard/session-client.svelte';
|
|
23
|
+
import { TenantFsClient } from '../shell-shard/tenant-fs-client';
|
|
24
|
+
function resolveVerbForDispatch(name) {
|
|
25
|
+
const verb = getVerb(name);
|
|
26
|
+
if (!verb)
|
|
27
|
+
return null;
|
|
28
|
+
const entries = listVerbsWithShard();
|
|
29
|
+
const match = entries.find((e) => e.verb === verb);
|
|
30
|
+
return match ? { verb: match.verb, shardId: match.shardId } : null;
|
|
31
|
+
}
|
|
32
|
+
export async function runVerbProgrammatic(shardId, name, args, opts) {
|
|
33
|
+
if (!activeShards.has(shardId)) {
|
|
34
|
+
throw new Error(`unknown shard: ${shardId}`);
|
|
35
|
+
}
|
|
36
|
+
const verb = getVerb(name);
|
|
37
|
+
if (!verb) {
|
|
38
|
+
throw new Error(`unknown verb: ${name}`);
|
|
39
|
+
}
|
|
40
|
+
if (!verb.programmatic) {
|
|
41
|
+
throw new Error(`verb "${name}" is not programmatic`);
|
|
42
|
+
}
|
|
43
|
+
const captured = [];
|
|
44
|
+
const sinkScrollback = makeSinkScrollback(captured);
|
|
45
|
+
const ctx = await buildProgrammaticContext({
|
|
46
|
+
sinkScrollback,
|
|
47
|
+
captured,
|
|
48
|
+
opts,
|
|
49
|
+
});
|
|
50
|
+
const result = await verb.run(ctx, args);
|
|
51
|
+
return { result, scrollback: captured };
|
|
52
|
+
}
|
|
53
|
+
async function buildProgrammaticContext(b) {
|
|
54
|
+
var _a, _b;
|
|
55
|
+
const ctx = {
|
|
56
|
+
shell: makeShellApiHeadless(),
|
|
57
|
+
scrollback: b.sinkScrollback,
|
|
58
|
+
session: makeStubSession(),
|
|
59
|
+
cwd: '/',
|
|
60
|
+
fs: new TenantFsClient(),
|
|
61
|
+
async dispatch(line) {
|
|
62
|
+
const trimmed = line.trim();
|
|
63
|
+
if (!trimmed)
|
|
64
|
+
return;
|
|
65
|
+
const space = trimmed.indexOf(' ');
|
|
66
|
+
const head = space === -1 ? trimmed : trimmed.slice(0, space);
|
|
67
|
+
const rest = space === -1 ? '' : trimmed.slice(space + 1);
|
|
68
|
+
const parts = rest.length ? rest.split(/\s+/) : [];
|
|
69
|
+
const resolved = resolveVerbForDispatch(head);
|
|
70
|
+
if (!resolved) {
|
|
71
|
+
throw new Error(`dispatch: unknown verb "${head}"`);
|
|
72
|
+
}
|
|
73
|
+
const innerCtx = Object.assign(Object.assign({}, ctx), { structuredArgs: undefined });
|
|
74
|
+
await resolved.verb.run(innerCtx, parts);
|
|
75
|
+
},
|
|
76
|
+
structuredArgs: (_a = b.opts) === null || _a === void 0 ? void 0 : _a.structured,
|
|
77
|
+
signal: (_b = b.opts) === null || _b === void 0 ? void 0 : _b.signal,
|
|
78
|
+
};
|
|
79
|
+
return ctx;
|
|
80
|
+
}
|
|
81
|
+
function makeSinkScrollback(captured) {
|
|
82
|
+
let nextId = 0;
|
|
83
|
+
const sink = {
|
|
84
|
+
entries: captured,
|
|
85
|
+
push(entry) {
|
|
86
|
+
const withId = Object.assign(Object.assign({}, entry), { id: `runverb-${++nextId}` });
|
|
87
|
+
captured.push(withId);
|
|
88
|
+
},
|
|
89
|
+
clear() {
|
|
90
|
+
captured.length = 0;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
return sink;
|
|
94
|
+
}
|
|
95
|
+
function makeStubSession() {
|
|
96
|
+
return new SessionClient('');
|
|
97
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { MemoryDocumentBackend } from '../documents/backends';
|
|
3
|
+
import { __setDocumentBackend, __setTenantId } from '../documents/config';
|
|
4
|
+
import { registerShard, activateShard, __resetShardRegistryForTest, } from '../shards/activate.svelte';
|
|
5
|
+
import { __resetViewRegistryForTest } from '../shards/registry';
|
|
6
|
+
import { runVerbProgrammatic } from './runVerb';
|
|
7
|
+
function makeVerb(name, programmatic, body = async () => undefined) {
|
|
8
|
+
return {
|
|
9
|
+
name,
|
|
10
|
+
summary: `stub ${name}`,
|
|
11
|
+
programmatic: programmatic || undefined,
|
|
12
|
+
async run(ctx, args) {
|
|
13
|
+
await body(ctx, args);
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
describe('runVerbProgrammatic', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
__resetShardRegistryForTest();
|
|
20
|
+
__resetViewRegistryForTest();
|
|
21
|
+
__setDocumentBackend(new MemoryDocumentBackend());
|
|
22
|
+
__setTenantId('tenant-test');
|
|
23
|
+
});
|
|
24
|
+
it('rejects on unknown shard', async () => {
|
|
25
|
+
await expect(runVerbProgrammatic('missing', 'echo', [])).rejects.toThrow('unknown shard: missing');
|
|
26
|
+
});
|
|
27
|
+
it('rejects on unknown verb', async () => {
|
|
28
|
+
registerShard({
|
|
29
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
30
|
+
activate(ctx) {
|
|
31
|
+
ctx.registerVerb(makeVerb('echo', true));
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
await activateShard('tester');
|
|
35
|
+
await expect(runVerbProgrammatic('tester', 'tester:missing', [])).rejects.toThrow(/unknown verb/);
|
|
36
|
+
});
|
|
37
|
+
it('rejects when verb is not programmatic', async () => {
|
|
38
|
+
registerShard({
|
|
39
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
40
|
+
activate(ctx) {
|
|
41
|
+
ctx.registerVerb(makeVerb('plain', false));
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
await activateShard('tester');
|
|
45
|
+
await expect(runVerbProgrammatic('tester', 'tester:plain', [])).rejects.toThrow('verb "tester:plain" is not programmatic');
|
|
46
|
+
});
|
|
47
|
+
it('invokes a programmatic verb and resolves with { result, scrollback }', async () => {
|
|
48
|
+
registerShard({
|
|
49
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
50
|
+
activate(ctx) {
|
|
51
|
+
ctx.registerVerb(makeVerb('echo', true, async (vctx, args) => {
|
|
52
|
+
vctx.scrollback.push({
|
|
53
|
+
kind: 'status',
|
|
54
|
+
text: `echo ${args.join(' ')}`,
|
|
55
|
+
level: 'info',
|
|
56
|
+
ts: 0,
|
|
57
|
+
});
|
|
58
|
+
}));
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
await activateShard('tester');
|
|
62
|
+
const out = await runVerbProgrammatic('tester', 'tester:echo', ['hello', 'world']);
|
|
63
|
+
expect(out.scrollback).toHaveLength(1);
|
|
64
|
+
expect(out.scrollback[0]).toMatchObject({
|
|
65
|
+
kind: 'status',
|
|
66
|
+
text: 'echo hello world',
|
|
67
|
+
level: 'info',
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
it('populates ctx.structuredArgs when opts.structured is set', async () => {
|
|
71
|
+
let observed = undefined;
|
|
72
|
+
registerShard({
|
|
73
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
74
|
+
activate(ctx) {
|
|
75
|
+
ctx.registerVerb(makeVerb('capture', true, async (vctx) => {
|
|
76
|
+
observed = vctx.structuredArgs;
|
|
77
|
+
}));
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
await activateShard('tester');
|
|
81
|
+
await runVerbProgrammatic('tester', 'tester:capture', [], { structured: { foo: 42 } });
|
|
82
|
+
expect(observed).toEqual({ foo: 42 });
|
|
83
|
+
});
|
|
84
|
+
it('plumbs opts.signal onto ctx.signal', async () => {
|
|
85
|
+
let received;
|
|
86
|
+
registerShard({
|
|
87
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
88
|
+
activate(ctx) {
|
|
89
|
+
ctx.registerVerb(makeVerb('peek', true, async (vctx) => {
|
|
90
|
+
received = vctx.signal;
|
|
91
|
+
}));
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
await activateShard('tester');
|
|
95
|
+
const ac = new AbortController();
|
|
96
|
+
await runVerbProgrammatic('tester', 'tester:peek', [], { signal: ac.signal });
|
|
97
|
+
expect(received).toBe(ac.signal);
|
|
98
|
+
});
|
|
99
|
+
it('inner dispatch re-enters runVerb and merges scrollback into the outer capture', async () => {
|
|
100
|
+
registerShard({
|
|
101
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
102
|
+
activate(ctx) {
|
|
103
|
+
ctx.registerVerb(makeVerb('inner', true, async (vctx) => {
|
|
104
|
+
vctx.scrollback.push({ kind: 'status', text: 'inner-fired', level: 'info', ts: 0 });
|
|
105
|
+
}));
|
|
106
|
+
ctx.registerVerb(makeVerb('outer', true, async (vctx) => {
|
|
107
|
+
vctx.scrollback.push({ kind: 'status', text: 'outer-before', level: 'info', ts: 0 });
|
|
108
|
+
await vctx.dispatch('tester:inner');
|
|
109
|
+
vctx.scrollback.push({ kind: 'status', text: 'outer-after', level: 'info', ts: 0 });
|
|
110
|
+
}));
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
await activateShard('tester');
|
|
114
|
+
const out = await runVerbProgrammatic('tester', 'tester:outer', []);
|
|
115
|
+
const texts = out.scrollback
|
|
116
|
+
.filter((e) => e.kind === 'status')
|
|
117
|
+
.map((e) => e.text);
|
|
118
|
+
expect(texts).toEqual(['outer-before', 'inner-fired', 'outer-after']);
|
|
119
|
+
});
|
|
120
|
+
it('propagates an error thrown by the verb', async () => {
|
|
121
|
+
registerShard({
|
|
122
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
123
|
+
activate(ctx) {
|
|
124
|
+
ctx.registerVerb(makeVerb('boom', true, async () => {
|
|
125
|
+
throw new Error('kaboom');
|
|
126
|
+
}));
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
await activateShard('tester');
|
|
130
|
+
await expect(runVerbProgrammatic('tester', 'tester:boom', [])).rejects.toThrow('kaboom');
|
|
131
|
+
});
|
|
132
|
+
});
|