sh3-core 0.15.2 → 0.15.4
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 +4 -1
- package/dist/api.js +2 -0
- package/dist/apps/lifecycle.d.ts +21 -2
- package/dist/apps/lifecycle.js +13 -7
- package/dist/apps/lifecycle.test.js +18 -0
- package/dist/boot/satelliteMode.d.ts +7 -0
- package/dist/boot/satelliteMode.js +22 -0
- package/dist/boot/satelliteMode.test.d.ts +1 -0
- package/dist/boot/satelliteMode.test.js +55 -0
- package/dist/boot/satellitePayload.d.ts +17 -0
- package/dist/boot/satellitePayload.js +60 -0
- package/dist/boot/satellitePayload.test.d.ts +1 -0
- package/dist/boot/satellitePayload.test.js +53 -0
- package/dist/createShell.js +72 -25
- package/dist/host.d.ts +13 -0
- package/dist/host.js +36 -0
- package/dist/layout/store.svelte.d.ts +11 -0
- package/dist/layout/store.svelte.js +15 -0
- package/dist/overlays/FloatFrame.svelte +36 -0
- package/dist/runtime/runVerb-shell.test.js +0 -39
- package/dist/runtime/runVerb.test.js +17 -0
- package/dist/satellite/SatelliteShell.svelte +60 -0
- package/dist/satellite/SatelliteShell.svelte.d.ts +9 -0
- package/dist/satellite/seed.d.ts +3 -0
- package/dist/satellite/seed.js +20 -0
- package/dist/satellite/seed.test.d.ts +1 -0
- package/dist/satellite/seed.test.js +38 -0
- package/dist/satellite/walkShards.d.ts +2 -0
- package/dist/satellite/walkShards.js +44 -0
- package/dist/satellite/walkShards.test.d.ts +1 -0
- package/dist/satellite/walkShards.test.js +65 -0
- package/dist/sh3core-shard/appActions.js +51 -0
- package/dist/shards/activate.svelte.d.ts +2 -2
- package/dist/shards/activate.svelte.js +1 -1
- package/dist/shards/registry.d.ts +2 -1
- package/dist/shards/registry.js +13 -4
- package/dist/shards/registry.test.js +22 -1
- package/dist/shards/types.d.ts +1 -0
- package/dist/shell-shard/CommandLine.svelte +3 -0
- package/dist/shell-shard/CommandLine.svelte.d.ts +1 -0
- package/dist/shell-shard/InputLine.svelte +4 -1
- package/dist/shell-shard/InputLine.svelte.d.ts +2 -0
- package/dist/shell-shard/Terminal.svelte +24 -0
- package/dist/shell-shard/dispatch-to-terminal.d.ts +13 -0
- package/dist/shell-shard/dispatch-to-terminal.js +37 -0
- package/dist/shell-shard/dispatch-to-terminal.test.d.ts +1 -0
- package/dist/shell-shard/dispatch-to-terminal.test.js +79 -0
- package/dist/shell-shard/shellApi.js +2 -0
- package/dist/shell-shard/terminal-registry.d.ts +25 -0
- package/dist/shell-shard/terminal-registry.js +62 -0
- package/dist/shell-shard/terminal-registry.test.d.ts +1 -0
- package/dist/shell-shard/terminal-registry.test.js +88 -0
- package/dist/shellApi/window.d.ts +15 -0
- package/dist/shellApi/window.js +43 -0
- package/dist/shellApi/window.test.d.ts +1 -0
- package/dist/shellApi/window.test.js +19 -0
- package/dist/shellRuntime.svelte.d.ts +12 -0
- package/dist/shellRuntime.svelte.js +2 -0
- package/dist/shellRuntime.svelte.test.d.ts +1 -0
- package/dist/shellRuntime.svelte.test.js +46 -0
- package/dist/verbs/types.d.ts +15 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
history: string[]; // persisted history, newest last
|
|
11
11
|
session: SessionClient;
|
|
12
12
|
onSubmit: (line: string) => void; // called with the raw entered line
|
|
13
|
+
/** Fired when the inner <input> receives focus. */
|
|
14
|
+
onFocus?: () => void;
|
|
13
15
|
}
|
|
14
|
-
let { cwd, showCwd = true, locked, history, session, onSubmit }: Props = $props();
|
|
16
|
+
let { cwd, showCwd = true, locked, history, session, onSubmit, onFocus }: Props = $props();
|
|
15
17
|
|
|
16
18
|
let draft = $state('');
|
|
17
19
|
let historyIndex = $state<number | null>(null); // null = live draft
|
|
@@ -94,6 +96,7 @@
|
|
|
94
96
|
disabled={locked}
|
|
95
97
|
name="shell-cmdline"
|
|
96
98
|
onkeydown={onKeyDown}
|
|
99
|
+
onfocus={() => onFocus?.()}
|
|
97
100
|
>
|
|
98
101
|
{#snippet prefix()}
|
|
99
102
|
{#if showCwd}<span class="shell-input-cwd">{cwd}</span>{/if}
|
|
@@ -7,6 +7,8 @@ interface Props {
|
|
|
7
7
|
history: string[];
|
|
8
8
|
session: SessionClient;
|
|
9
9
|
onSubmit: (line: string) => void;
|
|
10
|
+
/** Fired when the inner <input> receives focus. */
|
|
11
|
+
onFocus?: () => void;
|
|
10
12
|
}
|
|
11
13
|
declare const InputLine: import("svelte").Component<Props, {}, "">;
|
|
12
14
|
type InputLine = ReturnType<typeof InputLine>;
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
import FocusLockSlot from './toolbar/slots/FocusLockSlot.svelte';
|
|
27
27
|
import TargetShardSlot from './toolbar/slots/TargetShardSlot.svelte';
|
|
28
28
|
import BusySlot from './toolbar/slots/BusySlot.svelte';
|
|
29
|
+
import { registerTerminalView, mintTerminalId, type TerminalHandle } from './terminal-registry';
|
|
30
|
+
import { makeDispatchToTerminal } from './dispatch-to-terminal';
|
|
29
31
|
|
|
30
32
|
interface Props {
|
|
31
33
|
shell: ShellApi;
|
|
@@ -116,6 +118,16 @@
|
|
|
116
118
|
}),
|
|
117
119
|
);
|
|
118
120
|
|
|
121
|
+
// Focus stamp — bumped when the input receives focus so dispatchToTerminal
|
|
122
|
+
// can pick this terminal over other open terminals. Programmatic dispatches
|
|
123
|
+
// do NOT bump the stamp; otherwise they'd self-elect their target as
|
|
124
|
+
// 'focused' and mask multi-terminal ambiguity.
|
|
125
|
+
let focusStamp = $state(0);
|
|
126
|
+
function bumpFocusStamp(): void {
|
|
127
|
+
focusStamp = performance.now();
|
|
128
|
+
}
|
|
129
|
+
const terminalId = untrack(() => mintTerminalId());
|
|
130
|
+
|
|
119
131
|
// Active-mode buffer derivation. Reads `mode.id` reactively so that
|
|
120
132
|
// mode-switch flips ScrollbackView and InputLine bindings in lockstep.
|
|
121
133
|
const currentBuffer = $derived(getBuffer(mode.id));
|
|
@@ -195,6 +207,7 @@
|
|
|
195
207
|
},
|
|
196
208
|
listModes: () => visibleModes.map((m) => ({ id: m.id, label: m.label })),
|
|
197
209
|
getMode: () => ({ id: mode.id, label: mode.label }),
|
|
210
|
+
dispatchToTerminal: makeDispatchToTerminal({ headless: false }),
|
|
198
211
|
}));
|
|
199
212
|
|
|
200
213
|
// wsUrl is a prop read at construction only. untrack prevents Svelte 5's
|
|
@@ -364,6 +377,7 @@
|
|
|
364
377
|
}
|
|
365
378
|
|
|
366
379
|
let unsub: (() => void) | null = null;
|
|
380
|
+
let disposeRegistration: (() => void) | null = null;
|
|
367
381
|
|
|
368
382
|
onMount(() => {
|
|
369
383
|
unsub = session.onMessage(handleServerMessage);
|
|
@@ -379,11 +393,20 @@
|
|
|
379
393
|
// negligible, and non-bash modes still need to log mode-tagged
|
|
380
394
|
// history server-side via history-log messages.
|
|
381
395
|
session.connect();
|
|
396
|
+
|
|
397
|
+
const handle: TerminalHandle = {
|
|
398
|
+
id: terminalId,
|
|
399
|
+
dispatch,
|
|
400
|
+
getFocusStamp: () => focusStamp,
|
|
401
|
+
getMode: () => mode,
|
|
402
|
+
};
|
|
403
|
+
disposeRegistration = registerTerminalView(handle);
|
|
382
404
|
});
|
|
383
405
|
|
|
384
406
|
onDestroy(() => {
|
|
385
407
|
unsub?.();
|
|
386
408
|
session.close();
|
|
409
|
+
disposeRegistration?.();
|
|
387
410
|
});
|
|
388
411
|
</script>
|
|
389
412
|
|
|
@@ -406,6 +429,7 @@
|
|
|
406
429
|
history={currentBuffer.history}
|
|
407
430
|
{session}
|
|
408
431
|
onSubmit={dispatch}
|
|
432
|
+
onFocus={bumpFocusStamp}
|
|
409
433
|
/>
|
|
410
434
|
</div>
|
|
411
435
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type DispatchToTerminalResult = {
|
|
2
|
+
ok: true;
|
|
3
|
+
terminalId: string;
|
|
4
|
+
} | {
|
|
5
|
+
ok: false;
|
|
6
|
+
error: 'no-terminal' | 'ambiguous' | 'no-active-context';
|
|
7
|
+
message: string;
|
|
8
|
+
};
|
|
9
|
+
export interface MakeDispatchToTerminalOpts {
|
|
10
|
+
/** When true, always return 'no-active-context'. */
|
|
11
|
+
headless: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function makeDispatchToTerminal(opts: MakeDispatchToTerminalOpts): (line: string) => DispatchToTerminalResult;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* makeDispatchToTerminal — factory returning the `shell.dispatchToTerminal`
|
|
3
|
+
* function. Two flavors:
|
|
4
|
+
* - headless: always returns 'no-active-context'. Used by makeShellApiHeadless
|
|
5
|
+
* so verbs run via runVerbProgrammatic (and node-only tests) get a clean
|
|
6
|
+
* failure instead of hitting the live registry.
|
|
7
|
+
* - live: consults the module-scoped terminal-registry and routes through the
|
|
8
|
+
* resolved handle's dispatch. Used by Terminal.svelte's shellWithModes
|
|
9
|
+
* wrapper.
|
|
10
|
+
*
|
|
11
|
+
* The dispatch itself is fire-and-forget — the function resolves synchronously
|
|
12
|
+
* with the routing outcome (ok or error). Matches InputLine submit semantics:
|
|
13
|
+
* the caller doesn't await the targeted terminal's dispatch.
|
|
14
|
+
*/
|
|
15
|
+
import { resolveTargetTerminal } from './terminal-registry';
|
|
16
|
+
const ERROR_MESSAGES = {
|
|
17
|
+
'no-terminal': 'no terminal open',
|
|
18
|
+
'ambiguous': 'multiple terminals open; focus one first',
|
|
19
|
+
'no-active-context': 'dispatchToTerminal is not available in this context',
|
|
20
|
+
};
|
|
21
|
+
export function makeDispatchToTerminal(opts) {
|
|
22
|
+
if (opts.headless) {
|
|
23
|
+
return () => ({
|
|
24
|
+
ok: false,
|
|
25
|
+
error: 'no-active-context',
|
|
26
|
+
message: ERROR_MESSAGES['no-active-context'],
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return (line) => {
|
|
30
|
+
const r = resolveTargetTerminal();
|
|
31
|
+
if (!r.ok) {
|
|
32
|
+
return { ok: false, error: r.error, message: ERROR_MESSAGES[r.error] };
|
|
33
|
+
}
|
|
34
|
+
void r.handle.dispatch(line);
|
|
35
|
+
return { ok: true, terminalId: r.handle.id };
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { registerTerminalView, __resetTerminalRegistryForTest, } from './terminal-registry';
|
|
3
|
+
import { makeDispatchToTerminal } from './dispatch-to-terminal';
|
|
4
|
+
function makeHandle(id, focusStamp = 0, dispatch = vi.fn(async () => undefined)) {
|
|
5
|
+
return {
|
|
6
|
+
id,
|
|
7
|
+
dispatch,
|
|
8
|
+
getFocusStamp: () => focusStamp,
|
|
9
|
+
getMode: () => ({ id: 'sh3', label: 'sh3', transport: 'none', autoRelocate: false, showCwd: true }),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
describe('makeDispatchToTerminal', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
__resetTerminalRegistryForTest();
|
|
15
|
+
});
|
|
16
|
+
describe('headless: true', () => {
|
|
17
|
+
const dispatchToTerminal = makeDispatchToTerminal({ headless: true });
|
|
18
|
+
it('returns no-active-context even when handles are registered', () => {
|
|
19
|
+
registerTerminalView(makeHandle('term-1'));
|
|
20
|
+
const r = dispatchToTerminal('foo');
|
|
21
|
+
expect(r).toMatchObject({
|
|
22
|
+
ok: false,
|
|
23
|
+
error: 'no-active-context',
|
|
24
|
+
message: expect.stringContaining('not available'),
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
it('returns no-active-context when registry is empty', () => {
|
|
28
|
+
const r = dispatchToTerminal('foo');
|
|
29
|
+
expect(r).toMatchObject({ ok: false, error: 'no-active-context' });
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe('headless: false (live)', () => {
|
|
33
|
+
const dispatchToTerminal = makeDispatchToTerminal({ headless: false });
|
|
34
|
+
it('returns no-terminal when registry is empty', () => {
|
|
35
|
+
const r = dispatchToTerminal('foo');
|
|
36
|
+
expect(r).toMatchObject({
|
|
37
|
+
ok: false,
|
|
38
|
+
error: 'no-terminal',
|
|
39
|
+
message: 'no terminal open',
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
it('returns ambiguous when multiple terminals all have stamp 0', () => {
|
|
43
|
+
registerTerminalView(makeHandle('term-a', 0));
|
|
44
|
+
registerTerminalView(makeHandle('term-b', 0));
|
|
45
|
+
const r = dispatchToTerminal('foo');
|
|
46
|
+
expect(r).toMatchObject({
|
|
47
|
+
ok: false,
|
|
48
|
+
error: 'ambiguous',
|
|
49
|
+
message: 'multiple terminals open; focus one first',
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
it('hands the line to the resolved handle and returns ok with terminalId', () => {
|
|
53
|
+
const dispatch = vi.fn(async () => undefined);
|
|
54
|
+
registerTerminalView(makeHandle('term-1', 0, dispatch));
|
|
55
|
+
const r = dispatchToTerminal('foo bar baz');
|
|
56
|
+
expect(r).toEqual({ ok: true, terminalId: 'term-1' });
|
|
57
|
+
expect(dispatch).toHaveBeenCalledWith('foo bar baz');
|
|
58
|
+
});
|
|
59
|
+
it('targets the highest-stamped handle when multiple are registered', () => {
|
|
60
|
+
const dispatchA = vi.fn(async () => undefined);
|
|
61
|
+
const dispatchB = vi.fn(async () => undefined);
|
|
62
|
+
registerTerminalView(makeHandle('term-a', 100, dispatchA));
|
|
63
|
+
registerTerminalView(makeHandle('term-b', 200, dispatchB));
|
|
64
|
+
const r = dispatchToTerminal('hi');
|
|
65
|
+
expect(r).toEqual({ ok: true, terminalId: 'term-b' });
|
|
66
|
+
expect(dispatchA).not.toHaveBeenCalled();
|
|
67
|
+
expect(dispatchB).toHaveBeenCalledWith('hi');
|
|
68
|
+
});
|
|
69
|
+
it('does not await the dispatch (returns synchronously even when dispatch is slow)', async () => {
|
|
70
|
+
let resolveDispatch;
|
|
71
|
+
const slow = vi.fn(() => new Promise((res) => { resolveDispatch = res; }));
|
|
72
|
+
registerTerminalView(makeHandle('term-1', 0, slow));
|
|
73
|
+
const r = dispatchToTerminal('hi');
|
|
74
|
+
expect(r).toEqual({ ok: true, terminalId: 'term-1' });
|
|
75
|
+
expect(slow).toHaveBeenCalledWith('hi');
|
|
76
|
+
resolveDispatch();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -15,6 +15,7 @@ import { registeredShards, listStandaloneViews } from '../shards/activate.svelte
|
|
|
15
15
|
import { inspectActiveLayout, focusView, closeTab, popoutView, dockFloat, dockIntoActiveLayout, locateSlot as locateSlotInActiveLayout, } from '../layout/inspection';
|
|
16
16
|
import { floatManager } from '../overlays/float';
|
|
17
17
|
import { getUser, isAdmin } from '../auth/index';
|
|
18
|
+
import { makeDispatchToTerminal } from './dispatch-to-terminal';
|
|
18
19
|
const KNOWN_ZONES = ['ephemeral', 'session', 'workspace', 'user'];
|
|
19
20
|
function collectTabEntries(node) {
|
|
20
21
|
if (node.type === 'tabs') {
|
|
@@ -164,6 +165,7 @@ export function makeShellApiHeadless(zones) {
|
|
|
164
165
|
setMode(_id) { return false; },
|
|
165
166
|
listModes() { return []; },
|
|
166
167
|
getMode() { return { id: 'sh3', label: 'sh3' }; },
|
|
168
|
+
dispatchToTerminal: makeDispatchToTerminal({ headless: true }),
|
|
167
169
|
};
|
|
168
170
|
}
|
|
169
171
|
export function makeShellApiForTest() {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ShellMode } from './modes/types';
|
|
2
|
+
export interface TerminalHandle {
|
|
3
|
+
/** Stable per-mount id, e.g. `term-${counter}`. */
|
|
4
|
+
id: string;
|
|
5
|
+
/** Same dispatch fn `InputLine` calls on submit. */
|
|
6
|
+
dispatch: (line: string) => Promise<void>;
|
|
7
|
+
/** Monotonic stamp; 0 if the terminal has never been focused. */
|
|
8
|
+
getFocusStamp: () => number;
|
|
9
|
+
/** Currently active ShellMode for this terminal (informational). */
|
|
10
|
+
getMode: () => ShellMode;
|
|
11
|
+
}
|
|
12
|
+
export type ResolveTargetResult = {
|
|
13
|
+
ok: true;
|
|
14
|
+
handle: TerminalHandle;
|
|
15
|
+
} | {
|
|
16
|
+
ok: false;
|
|
17
|
+
error: 'no-terminal' | 'ambiguous';
|
|
18
|
+
};
|
|
19
|
+
/** Mint a stable id for a Terminal.svelte instance. Called once per mount. */
|
|
20
|
+
export declare function mintTerminalId(): string;
|
|
21
|
+
export declare function registerTerminalView(handle: TerminalHandle): () => void;
|
|
22
|
+
export declare function listTerminalViews(): TerminalHandle[];
|
|
23
|
+
export declare function resolveTargetTerminal(): ResolveTargetResult;
|
|
24
|
+
/** Test-only — wipes the registry and id counter between cases. */
|
|
25
|
+
export declare function __resetTerminalRegistryForTest(): void;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Module-scoped registry of live Terminal.svelte instances.
|
|
3
|
+
*
|
|
4
|
+
* Used by `shell.dispatchToTerminal(line)` to find a target terminal when a
|
|
5
|
+
* non-terminal view (floating picker, dialog) wants to drive a terminal as
|
|
6
|
+
* if the user had typed the line. Each Terminal.svelte instance registers
|
|
7
|
+
* itself on mount and disposes on destroy.
|
|
8
|
+
*
|
|
9
|
+
* Resolution rule (resolveTargetTerminal):
|
|
10
|
+
* - empty registry → { ok: false, error: 'no-terminal' }
|
|
11
|
+
* - exactly one handle → { ok: true, handle }
|
|
12
|
+
* - >1 handles, all stamps 0 → { ok: false, error: 'ambiguous' }
|
|
13
|
+
* - >1 handles, ≥1 non-zero → highest-stamped wins; equal non-zero stamps
|
|
14
|
+
* break to last-registered.
|
|
15
|
+
*
|
|
16
|
+
* No Svelte runes here — the registry is plain module state so node-project
|
|
17
|
+
* tests can drive it directly.
|
|
18
|
+
*/
|
|
19
|
+
const handles = [];
|
|
20
|
+
let nextTerminalId = 0;
|
|
21
|
+
/** Mint a stable id for a Terminal.svelte instance. Called once per mount. */
|
|
22
|
+
export function mintTerminalId() {
|
|
23
|
+
return `term-${++nextTerminalId}`;
|
|
24
|
+
}
|
|
25
|
+
export function registerTerminalView(handle) {
|
|
26
|
+
handles.push(handle);
|
|
27
|
+
let disposed = false;
|
|
28
|
+
return () => {
|
|
29
|
+
if (disposed)
|
|
30
|
+
return;
|
|
31
|
+
disposed = true;
|
|
32
|
+
const idx = handles.indexOf(handle);
|
|
33
|
+
if (idx !== -1)
|
|
34
|
+
handles.splice(idx, 1);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function listTerminalViews() {
|
|
38
|
+
return handles.slice();
|
|
39
|
+
}
|
|
40
|
+
export function resolveTargetTerminal() {
|
|
41
|
+
if (handles.length === 0)
|
|
42
|
+
return { ok: false, error: 'no-terminal' };
|
|
43
|
+
if (handles.length === 1)
|
|
44
|
+
return { ok: true, handle: handles[0] };
|
|
45
|
+
let best = null;
|
|
46
|
+
let bestStamp = 0;
|
|
47
|
+
for (const h of handles) {
|
|
48
|
+
const stamp = h.getFocusStamp();
|
|
49
|
+
if (stamp > 0 && stamp >= bestStamp) {
|
|
50
|
+
best = h;
|
|
51
|
+
bestStamp = stamp;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (best === null)
|
|
55
|
+
return { ok: false, error: 'ambiguous' };
|
|
56
|
+
return { ok: true, handle: best };
|
|
57
|
+
}
|
|
58
|
+
/** Test-only — wipes the registry and id counter between cases. */
|
|
59
|
+
export function __resetTerminalRegistryForTest() {
|
|
60
|
+
handles.length = 0;
|
|
61
|
+
nextTerminalId = 0;
|
|
62
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { registerTerminalView, listTerminalViews, resolveTargetTerminal, mintTerminalId, __resetTerminalRegistryForTest, } from './terminal-registry';
|
|
3
|
+
function makeHandle(id, focusStamp = 0) {
|
|
4
|
+
return {
|
|
5
|
+
id,
|
|
6
|
+
dispatch: async () => undefined,
|
|
7
|
+
getFocusStamp: () => focusStamp,
|
|
8
|
+
getMode: () => ({ id: 'sh3', label: 'sh3', transport: 'none', autoRelocate: false, showCwd: true }),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
describe('terminal-registry', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
__resetTerminalRegistryForTest();
|
|
14
|
+
});
|
|
15
|
+
describe('registerTerminalView', () => {
|
|
16
|
+
it('returns a disposer that removes the handle', () => {
|
|
17
|
+
const h = makeHandle('term-1');
|
|
18
|
+
const dispose = registerTerminalView(h);
|
|
19
|
+
expect(listTerminalViews()).toEqual([h]);
|
|
20
|
+
dispose();
|
|
21
|
+
expect(listTerminalViews()).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
it('disposer is idempotent', () => {
|
|
24
|
+
const dispose = registerTerminalView(makeHandle('term-1'));
|
|
25
|
+
dispose();
|
|
26
|
+
expect(() => dispose()).not.toThrow();
|
|
27
|
+
expect(listTerminalViews()).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
it('registers multiple handles in order', () => {
|
|
30
|
+
const a = makeHandle('term-a');
|
|
31
|
+
const b = makeHandle('term-b');
|
|
32
|
+
registerTerminalView(a);
|
|
33
|
+
registerTerminalView(b);
|
|
34
|
+
expect(listTerminalViews()).toEqual([a, b]);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
describe('resolveTargetTerminal', () => {
|
|
38
|
+
it('returns no-terminal when registry is empty', () => {
|
|
39
|
+
expect(resolveTargetTerminal()).toEqual({ ok: false, error: 'no-terminal' });
|
|
40
|
+
});
|
|
41
|
+
it('returns the sole handle when exactly one is registered', () => {
|
|
42
|
+
const h = makeHandle('term-1');
|
|
43
|
+
registerTerminalView(h);
|
|
44
|
+
expect(resolveTargetTerminal()).toEqual({ ok: true, handle: h });
|
|
45
|
+
});
|
|
46
|
+
it('returns ambiguous when multiple handles all have stamp 0', () => {
|
|
47
|
+
registerTerminalView(makeHandle('term-a', 0));
|
|
48
|
+
registerTerminalView(makeHandle('term-b', 0));
|
|
49
|
+
expect(resolveTargetTerminal()).toEqual({ ok: false, error: 'ambiguous' });
|
|
50
|
+
});
|
|
51
|
+
it('returns the handle with the highest non-zero stamp', () => {
|
|
52
|
+
const a = makeHandle('term-a', 100);
|
|
53
|
+
const b = makeHandle('term-b', 200);
|
|
54
|
+
registerTerminalView(a);
|
|
55
|
+
registerTerminalView(b);
|
|
56
|
+
const r = resolveTargetTerminal();
|
|
57
|
+
expect(r).toEqual({ ok: true, handle: b });
|
|
58
|
+
});
|
|
59
|
+
it('returns the highest-stamped handle when one has stamp 0 and the other does not', () => {
|
|
60
|
+
const a = makeHandle('term-a', 0);
|
|
61
|
+
const b = makeHandle('term-b', 50);
|
|
62
|
+
registerTerminalView(a);
|
|
63
|
+
registerTerminalView(b);
|
|
64
|
+
expect(resolveTargetTerminal()).toEqual({ ok: true, handle: b });
|
|
65
|
+
});
|
|
66
|
+
it('breaks equal non-zero stamps by last-registered', () => {
|
|
67
|
+
const a = makeHandle('term-a', 100);
|
|
68
|
+
const b = makeHandle('term-b', 100);
|
|
69
|
+
registerTerminalView(a);
|
|
70
|
+
registerTerminalView(b);
|
|
71
|
+
expect(resolveTargetTerminal()).toEqual({ ok: true, handle: b });
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
describe('mintTerminalId', () => {
|
|
75
|
+
it('produces unique ids on each call', () => {
|
|
76
|
+
const a = mintTerminalId();
|
|
77
|
+
const b = mintTerminalId();
|
|
78
|
+
expect(a).not.toEqual(b);
|
|
79
|
+
expect(a).toMatch(/^term-\d+$/);
|
|
80
|
+
});
|
|
81
|
+
it('is reset by __resetTerminalRegistryForTest', () => {
|
|
82
|
+
mintTerminalId();
|
|
83
|
+
mintTerminalId();
|
|
84
|
+
__resetTerminalRegistryForTest();
|
|
85
|
+
expect(mintTerminalId()).toBe('term-1');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type SatellitePayload } from '../boot/satellitePayload';
|
|
2
|
+
export interface SpawnSatelliteOptions {
|
|
3
|
+
/** Suggested window title. Defaults to a payload-derived title. */
|
|
4
|
+
title?: string;
|
|
5
|
+
/** Initial window inner width in CSS px. */
|
|
6
|
+
width?: number;
|
|
7
|
+
/** Initial window inner height in CSS px. */
|
|
8
|
+
height?: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Open a new native window rendering a satellite slice of SH3.
|
|
12
|
+
* Returns the unique label assigned to the new window, or an empty
|
|
13
|
+
* string when called outside Tauri.
|
|
14
|
+
*/
|
|
15
|
+
export declare function spawnSatellite(payload: SatellitePayload, opts?: SpawnSatelliteOptions): Promise<string>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* shell.window — host-side facade for spawning satellite windows.
|
|
3
|
+
*
|
|
4
|
+
* Under Tauri, `spawnSatellite` invokes the `sh3_spawn_satellite` Rust
|
|
5
|
+
* command which opens a native WebviewWindow pointed at the embedded
|
|
6
|
+
* sh3-server with the encoded payload in the query string. Outside
|
|
7
|
+
* Tauri (web builds) the call logs a warning and resolves with an
|
|
8
|
+
* empty string so callers can no-op gracefully.
|
|
9
|
+
*/
|
|
10
|
+
import { encodePayload } from '../boot/satellitePayload';
|
|
11
|
+
function isTauri() {
|
|
12
|
+
return typeof globalThis.__TAURI_INTERNALS__ !== 'undefined';
|
|
13
|
+
}
|
|
14
|
+
let labelCounter = 0;
|
|
15
|
+
function nextLabel() {
|
|
16
|
+
labelCounter += 1;
|
|
17
|
+
return `sh3-satellite-${Date.now()}-${labelCounter}`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Open a new native window rendering a satellite slice of SH3.
|
|
21
|
+
* Returns the unique label assigned to the new window, or an empty
|
|
22
|
+
* string when called outside Tauri.
|
|
23
|
+
*/
|
|
24
|
+
export async function spawnSatellite(payload, opts = {}) {
|
|
25
|
+
var _a, _b, _c, _d;
|
|
26
|
+
if (!isTauri()) {
|
|
27
|
+
console.warn('[sh3] shell.window.spawn called outside Tauri — no-op');
|
|
28
|
+
return '';
|
|
29
|
+
}
|
|
30
|
+
const { invoke } = await import('@tauri-apps/api/core');
|
|
31
|
+
const encoded = encodePayload(payload);
|
|
32
|
+
const label = nextLabel();
|
|
33
|
+
const title = (_a = opts.title) !== null && _a !== void 0 ? _a : (payload.kind === 'float' ? ((_b = payload.title) !== null && _b !== void 0 ? _b : 'SH3') : `SH3 — ${payload.appId}`);
|
|
34
|
+
const width = (_c = opts.width) !== null && _c !== void 0 ? _c : (payload.kind === 'float' ? payload.size.w : 1024);
|
|
35
|
+
const height = (_d = opts.height) !== null && _d !== void 0 ? _d : (payload.kind === 'float' ? payload.size.h : 768);
|
|
36
|
+
return invoke('sh3_spawn_satellite', {
|
|
37
|
+
payload: encoded,
|
|
38
|
+
label,
|
|
39
|
+
title,
|
|
40
|
+
width,
|
|
41
|
+
height,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { spawnSatellite } from './window';
|
|
3
|
+
describe('shell.window.spawn (non-Tauri)', () => {
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
delete globalThis.__TAURI_INTERNALS__;
|
|
6
|
+
});
|
|
7
|
+
it('returns empty string and warns when called without Tauri', async () => {
|
|
8
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
9
|
+
const payload = {
|
|
10
|
+
kind: 'app',
|
|
11
|
+
appId: 'x',
|
|
12
|
+
activateShards: [],
|
|
13
|
+
};
|
|
14
|
+
const result = await spawnSatellite(payload);
|
|
15
|
+
expect(result).toBe('');
|
|
16
|
+
expect(warn).toHaveBeenCalled();
|
|
17
|
+
warn.mockRestore();
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -9,6 +9,7 @@ import type { ConflictsApi } from './conflicts/api';
|
|
|
9
9
|
import type { ColorApi } from './color/api';
|
|
10
10
|
import { type OpenContextMenuOpts, type OpenPaletteOpts } from './actions/listeners';
|
|
11
11
|
import type { ActiveActionDescriptor } from './actions/types';
|
|
12
|
+
import { type DispatchToTerminalResult } from './shell-shard/dispatch-to-terminal';
|
|
12
13
|
/**
|
|
13
14
|
* The process-wide shell singleton exposed to shards and the shell's own
|
|
14
15
|
* internal code. Provides state zone creation and overlay managers.
|
|
@@ -38,6 +39,17 @@ export interface Shell {
|
|
|
38
39
|
color: ColorApi;
|
|
39
40
|
/** Actions facade — rebind keys, query bindings, open menus/palette. */
|
|
40
41
|
actions: ShellActionsApi;
|
|
42
|
+
/**
|
|
43
|
+
* Dispatch `line` through a Terminal view's normal submit path. Used by
|
|
44
|
+
* views outside a verb context (floating pickers, dialogs) to drive a
|
|
45
|
+
* terminal as if the user had typed the line. Resolves which terminal:
|
|
46
|
+
* focused → sole → fail. Returns synchronously with the routing outcome;
|
|
47
|
+
* the dispatch itself runs fire-and-forget.
|
|
48
|
+
*
|
|
49
|
+
* Mirrors `ShellApi.dispatchToTerminal` (the verb-side surface). Both
|
|
50
|
+
* read the same module-scoped registry of live Terminal.svelte instances.
|
|
51
|
+
*/
|
|
52
|
+
dispatchToTerminal(line: string): DispatchToTerminalResult;
|
|
41
53
|
}
|
|
42
54
|
/**
|
|
43
55
|
* API for managing action bindings and triggering menus/palette
|
|
@@ -27,6 +27,7 @@ import { openContextMenu as listenersOpenContextMenu, openPalette as listenersOp
|
|
|
27
27
|
import { setUserBindings, getLiveDispatcherState, onActiveChange as onActiveChangeState, __notifyActiveChange, } from './actions/state.svelte';
|
|
28
28
|
import { listActions, onActionsChange } from './actions/registry';
|
|
29
29
|
import { listActiveFromEntries } from './actions/listActive';
|
|
30
|
+
import { makeDispatchToTerminal } from './shell-shard/dispatch-to-terminal';
|
|
30
31
|
const shellActions = {
|
|
31
32
|
async rebind(appId, actionId, shortcut) {
|
|
32
33
|
await saveUserBinding(appId, actionId, shortcut);
|
|
@@ -75,4 +76,5 @@ export const shell = {
|
|
|
75
76
|
conflicts: conflictsApi,
|
|
76
77
|
color: colorApi,
|
|
77
78
|
actions: shellActions,
|
|
79
|
+
dispatchToTerminal: makeDispatchToTerminal({ headless: false }),
|
|
78
80
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Targeted coverage for the `shell` runtime singleton's dispatchToTerminal.
|
|
3
|
+
*
|
|
4
|
+
* The wiring is what matters: the singleton must consult the same module-
|
|
5
|
+
* scoped terminal-registry as ShellApi.dispatchToTerminal so a Svelte view
|
|
6
|
+
* (which imports `shell`) and a verb (which receives `vctx.shell`) hit the
|
|
7
|
+
* same set of live Terminal handles. A view import is the only path a
|
|
8
|
+
* picker has — without this, the dispatchToTerminal API is unreachable
|
|
9
|
+
* from the place it was designed to be called.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
12
|
+
import { shell } from './shellRuntime.svelte';
|
|
13
|
+
import { registerTerminalView, __resetTerminalRegistryForTest, } from './shell-shard/terminal-registry';
|
|
14
|
+
function makeHandle(id, focusStamp = 0, dispatch = vi.fn(async () => undefined)) {
|
|
15
|
+
return {
|
|
16
|
+
id,
|
|
17
|
+
dispatch,
|
|
18
|
+
getFocusStamp: () => focusStamp,
|
|
19
|
+
getMode: () => ({ id: 'sh3', label: 'sh3', transport: 'none', autoRelocate: false, showCwd: true }),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
describe('shell runtime singleton — dispatchToTerminal', () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
__resetTerminalRegistryForTest();
|
|
25
|
+
});
|
|
26
|
+
it('returns no-terminal when registry is empty', () => {
|
|
27
|
+
const r = shell.dispatchToTerminal('foo');
|
|
28
|
+
expect(r).toMatchObject({ ok: false, error: 'no-terminal', message: 'no terminal open' });
|
|
29
|
+
});
|
|
30
|
+
it('routes through the resolved handle when one is registered', () => {
|
|
31
|
+
const dispatch = vi.fn(async () => undefined);
|
|
32
|
+
registerTerminalView(makeHandle('term-1', 0, dispatch));
|
|
33
|
+
const r = shell.dispatchToTerminal('apps');
|
|
34
|
+
expect(r).toEqual({ ok: true, terminalId: 'term-1' });
|
|
35
|
+
expect(dispatch).toHaveBeenCalledWith('apps');
|
|
36
|
+
});
|
|
37
|
+
it('shares the registry with ShellApi.dispatchToTerminal (same handles, same routing)', () => {
|
|
38
|
+
const dispatch = vi.fn(async () => undefined);
|
|
39
|
+
registerTerminalView(makeHandle('term-shared', 0, dispatch));
|
|
40
|
+
// The singleton is the surface a Svelte view sees; it must hit the
|
|
41
|
+
// same registry as the verb-side ShellApi.
|
|
42
|
+
const r = shell.dispatchToTerminal('hi');
|
|
43
|
+
expect(r).toEqual({ ok: true, terminalId: 'term-shared' });
|
|
44
|
+
expect(dispatch).toHaveBeenCalledWith('hi');
|
|
45
|
+
});
|
|
46
|
+
});
|
package/dist/verbs/types.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Scrollback } from '../shell-shard/scrollback.svelte';
|
|
|
2
2
|
import type { SessionClient } from '../shell-shard/session-client.svelte';
|
|
3
3
|
import type { TenantFsClient } from '../shell-shard/tenant-fs-client';
|
|
4
4
|
import type { TreeRootRef } from '../layout/types';
|
|
5
|
+
import type { DispatchToTerminalResult } from '../shell-shard/dispatch-to-terminal';
|
|
5
6
|
export interface ShellApi {
|
|
6
7
|
listApps(): Array<{
|
|
7
8
|
id: string;
|
|
@@ -86,7 +87,21 @@ export interface ShellApi {
|
|
|
86
87
|
id: string;
|
|
87
88
|
label: string;
|
|
88
89
|
};
|
|
90
|
+
/**
|
|
91
|
+
* Dispatch `line` through a Terminal view's normal submit path. Resolves
|
|
92
|
+
* which terminal to target: the focused one if any has been focused; the
|
|
93
|
+
* sole one if exactly one terminal is open; fail otherwise. The dispatch
|
|
94
|
+
* runs fire-and-forget on the targeted terminal — this method returns
|
|
95
|
+
* synchronously with the routing outcome.
|
|
96
|
+
*
|
|
97
|
+
* Used by views outside a verb context (floating pickers, dialogs) to
|
|
98
|
+
* drive a terminal as if the user had typed the line. The targeted
|
|
99
|
+
* terminal's current mode + cwd apply — `line` is resolved against
|
|
100
|
+
* its verb registry, gating, and history exactly like a typed submit.
|
|
101
|
+
*/
|
|
102
|
+
dispatchToTerminal(line: string): DispatchToTerminalResult;
|
|
89
103
|
}
|
|
104
|
+
export type { DispatchToTerminalResult } from '../shell-shard/dispatch-to-terminal';
|
|
90
105
|
export interface VerbContext {
|
|
91
106
|
shell: ShellApi;
|
|
92
107
|
scrollback: Scrollback;
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Auto-generated from package.json — do not edit manually. */
|
|
2
|
-
export declare const VERSION = "0.15.
|
|
2
|
+
export declare const VERSION = "0.15.4";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Auto-generated from package.json — do not edit manually. */
|
|
2
|
-
export const VERSION = '0.15.
|
|
2
|
+
export const VERSION = '0.15.4';
|