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.
Files changed (64) hide show
  1. package/dist/api.d.ts +4 -1
  2. package/dist/api.js +2 -0
  3. package/dist/apps/lifecycle.d.ts +21 -2
  4. package/dist/apps/lifecycle.js +13 -7
  5. package/dist/apps/lifecycle.test.js +18 -0
  6. package/dist/boot/satelliteMode.d.ts +7 -0
  7. package/dist/boot/satelliteMode.js +22 -0
  8. package/dist/boot/satelliteMode.test.d.ts +1 -0
  9. package/dist/boot/satelliteMode.test.js +55 -0
  10. package/dist/boot/satellitePayload.d.ts +17 -0
  11. package/dist/boot/satellitePayload.js +60 -0
  12. package/dist/boot/satellitePayload.test.d.ts +1 -0
  13. package/dist/boot/satellitePayload.test.js +53 -0
  14. package/dist/createShell.js +72 -25
  15. package/dist/host.d.ts +13 -0
  16. package/dist/host.js +36 -0
  17. package/dist/layout/store.svelte.d.ts +11 -0
  18. package/dist/layout/store.svelte.js +15 -0
  19. package/dist/overlays/FloatFrame.svelte +36 -0
  20. package/dist/runtime/runVerb-shell.test.js +0 -39
  21. package/dist/runtime/runVerb.test.js +17 -0
  22. package/dist/satellite/SatelliteShell.svelte +60 -0
  23. package/dist/satellite/SatelliteShell.svelte.d.ts +9 -0
  24. package/dist/satellite/seed.d.ts +3 -0
  25. package/dist/satellite/seed.js +20 -0
  26. package/dist/satellite/seed.test.d.ts +1 -0
  27. package/dist/satellite/seed.test.js +38 -0
  28. package/dist/satellite/walkShards.d.ts +2 -0
  29. package/dist/satellite/walkShards.js +44 -0
  30. package/dist/satellite/walkShards.test.d.ts +1 -0
  31. package/dist/satellite/walkShards.test.js +65 -0
  32. package/dist/sh3core-shard/appActions.js +51 -0
  33. package/dist/shards/activate.svelte.d.ts +2 -2
  34. package/dist/shards/activate.svelte.js +1 -1
  35. package/dist/shards/registry.d.ts +2 -1
  36. package/dist/shards/registry.js +13 -4
  37. package/dist/shards/registry.test.js +22 -1
  38. package/dist/shards/types.d.ts +1 -0
  39. package/dist/shell-shard/CommandLine.svelte +3 -0
  40. package/dist/shell-shard/CommandLine.svelte.d.ts +1 -0
  41. package/dist/shell-shard/InputLine.svelte +4 -1
  42. package/dist/shell-shard/InputLine.svelte.d.ts +2 -0
  43. package/dist/shell-shard/Terminal.svelte +24 -0
  44. package/dist/shell-shard/dispatch-to-terminal.d.ts +13 -0
  45. package/dist/shell-shard/dispatch-to-terminal.js +37 -0
  46. package/dist/shell-shard/dispatch-to-terminal.test.d.ts +1 -0
  47. package/dist/shell-shard/dispatch-to-terminal.test.js +79 -0
  48. package/dist/shell-shard/shellApi.js +2 -0
  49. package/dist/shell-shard/terminal-registry.d.ts +25 -0
  50. package/dist/shell-shard/terminal-registry.js +62 -0
  51. package/dist/shell-shard/terminal-registry.test.d.ts +1 -0
  52. package/dist/shell-shard/terminal-registry.test.js +88 -0
  53. package/dist/shellApi/window.d.ts +15 -0
  54. package/dist/shellApi/window.js +43 -0
  55. package/dist/shellApi/window.test.d.ts +1 -0
  56. package/dist/shellApi/window.test.js +19 -0
  57. package/dist/shellRuntime.svelte.d.ts +12 -0
  58. package/dist/shellRuntime.svelte.js +2 -0
  59. package/dist/shellRuntime.svelte.test.d.ts +1 -0
  60. package/dist/shellRuntime.svelte.test.js +46 -0
  61. package/dist/verbs/types.d.ts +15 -0
  62. package/dist/version.d.ts +1 -1
  63. package/dist/version.js +1 -1
  64. package/package.json +1 -1
@@ -33,6 +33,11 @@
33
33
  import type { FloatEntry } from '../layout/types';
34
34
  import { shell } from '../shellRuntime.svelte';
35
35
  import { makeSelectionApi } from '../actions/selection.svelte';
36
+ import { spawnSatellite } from '../shellApi/window';
37
+ import { walkShardsForContent } from '../satellite/walkShards';
38
+
39
+ const isTauri =
40
+ typeof (globalThis as Record<string, unknown>).__TAURI_INTERNALS__ !== 'undefined';
36
41
 
37
42
  const floatHeaderSelection = makeSelectionApi('__layouts__');
38
43
 
@@ -98,6 +103,7 @@
98
103
  if (e.button !== 0) return;
99
104
  if ((e.target as HTMLElement).closest('.sh3-float-close')) return;
100
105
  if ((e.target as HTMLElement).closest('.sh3-float-maximize')) return;
106
+ if ((e.target as HTMLElement).closest('.sh3-float-popout')) return;
101
107
  const target = e.currentTarget as HTMLElement;
102
108
  target.setPointerCapture?.(e.pointerId);
103
109
  dragging = true;
@@ -128,6 +134,7 @@
128
134
  function onHeaderDblClick(e: MouseEvent): void {
129
135
  if ((e.target as HTMLElement).closest('.sh3-float-close')) return;
130
136
  if ((e.target as HTMLElement).closest('.sh3-float-maximize')) return;
137
+ if ((e.target as HTMLElement).closest('.sh3-float-popout')) return;
131
138
  floatManager.toggleMaximize(entry.id);
132
139
  }
133
140
 
@@ -176,6 +183,25 @@
176
183
  e.stopPropagation();
177
184
  floatManager.close(entry.id);
178
185
  }
186
+
187
+ async function onPopOut(e: MouseEvent): Promise<void> {
188
+ e.stopPropagation();
189
+ try {
190
+ await spawnSatellite({
191
+ kind: 'float',
192
+ content: entry.content,
193
+ title: entry.title,
194
+ size: { w: entry.size.w, h: entry.size.h },
195
+ activateShards: walkShardsForContent(entry.content),
196
+ });
197
+ floatManager.close(entry.id);
198
+ } catch (err) {
199
+ shell.toast.notify(
200
+ `Pop-out failed: ${(err as Error).message}`,
201
+ { level: 'error', duration: 5000 },
202
+ );
203
+ }
204
+ }
179
205
  </script>
180
206
 
181
207
  <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
@@ -208,6 +234,14 @@
208
234
  >
209
235
  <span class="sh3-float-title">{entry.title}</span>
210
236
  <span class="sh3-float-header-actions">
237
+ {#if isTauri}
238
+ <button
239
+ class="sh3-float-popout"
240
+ onclick={onPopOut}
241
+ aria-label="Open in own window"
242
+ title="Open in own window"
243
+ >⧉</button>
244
+ {/if}
211
245
  <button
212
246
  class="sh3-float-maximize"
213
247
  onclick={onMaximize}
@@ -271,6 +305,7 @@
271
305
  gap: 2px;
272
306
  flex-shrink: 0;
273
307
  }
308
+ .sh3-float-popout,
274
309
  .sh3-float-maximize,
275
310
  .sh3-float-close {
276
311
  background: transparent;
@@ -280,6 +315,7 @@
280
315
  cursor: pointer;
281
316
  padding: 0 4px;
282
317
  }
318
+ .sh3-float-popout,
283
319
  .sh3-float-maximize {
284
320
  font-size: 12px;
285
321
  }
@@ -29,26 +29,6 @@ describe('shell-shard programmatic verbs (integration)', () => {
29
29
  registerShard(shellShard);
30
30
  await activateShard('shell');
31
31
  });
32
- // ── pure text verbs ────────────────────────────────────────────────────
33
- it('pwd emits a single text entry with the cwd', async () => {
34
- const out = await runVerbProgrammatic('shell', 'pwd', []);
35
- expect(out.result).toBeUndefined();
36
- expect(out.scrollback).toHaveLength(1);
37
- const entry = out.scrollback[0];
38
- expect(entry.kind).toBe('text');
39
- if (entry.kind !== 'text')
40
- throw new Error('unreachable');
41
- expect(entry.stream).toBe('stdout');
42
- expect(entry.chunks.join('')).toMatch(/^\/.*\n$/);
43
- });
44
- it('whoami emits a text entry with the user id', async () => {
45
- const out = await runVerbProgrammatic('shell', 'whoami', []);
46
- const entry = out.scrollback[0];
47
- expect(entry.kind).toBe('text');
48
- if (entry.kind !== 'text')
49
- throw new Error('unreachable');
50
- expect(entry.chunks.join('')).toMatch(/guest/);
51
- });
52
32
  // ── rich-entry introspection verbs ─────────────────────────────────────
53
33
  it('apps emits a rich entry whose props.data.apps is an array', async () => {
54
34
  const out = await runVerbProgrammatic('shell', 'apps', []);
@@ -209,23 +189,4 @@ describe('shell-shard programmatic verbs (integration)', () => {
209
189
  throw new Error('unreachable');
210
190
  expect(entry.text).toMatch(/no active floats/);
211
191
  });
212
- // ── fs verbs (no server in test → error path) ──────────────────────────
213
- it('cat with no args emits a missing-argument error', async () => {
214
- const out = await runVerbProgrammatic('shell', 'cat', []);
215
- const entry = out.scrollback[0];
216
- expect(entry.kind).toBe('status');
217
- if (entry.kind !== 'status')
218
- throw new Error('unreachable');
219
- expect(entry.level).toBe('error');
220
- expect(entry.text).toMatch(/missing file argument/);
221
- });
222
- it('ls without an fs backend emits an error status (does not throw)', async () => {
223
- const out = await runVerbProgrammatic('shell', 'ls', []);
224
- expect(out.scrollback).toHaveLength(1);
225
- const entry = out.scrollback[0];
226
- // Either a real text response (unlikely in test) or an error status —
227
- // we only commit to the contract that the verb pushes exactly one entry
228
- // and never throws.
229
- expect(['text', 'status']).toContain(entry.kind);
230
- });
231
192
  });
@@ -129,4 +129,21 @@ describe('runVerbProgrammatic', () => {
129
129
  await activateShard('tester');
130
130
  await expect(runVerbProgrammatic('tester', 'tester:boom', [])).rejects.toThrow('kaboom');
131
131
  });
132
+ it('exposes vctx.shell.dispatchToTerminal that returns no-active-context', async () => {
133
+ let observed = undefined;
134
+ registerShard({
135
+ manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
136
+ activate(ctx) {
137
+ ctx.registerVerb(makeVerb('peek-shell', true, async (vctx) => {
138
+ observed = vctx.shell.dispatchToTerminal('foo');
139
+ }));
140
+ },
141
+ });
142
+ await activateShard('tester');
143
+ await runVerbProgrammatic('tester', 'tester:peek-shell', []);
144
+ expect(observed).toMatchObject({
145
+ ok: false,
146
+ error: 'no-active-context',
147
+ });
148
+ });
132
149
  });
@@ -0,0 +1,60 @@
1
+ <script lang="ts">
2
+ /*
3
+ * SatelliteShell — top-level root component mounted by createShell() when
4
+ * detectSatelliteMode() returns a payload.
5
+ *
6
+ * Renders only LayoutRenderer against the seeded in-memory layout. No brand
7
+ * bar, no home button, no overlay chrome. The workspace zone backend has
8
+ * already been forced to MemoryBackend by createShell() before this mounts.
9
+ *
10
+ * Float payloads: seeds HOME_TREE's docked node directly via
11
+ * seedSatelliteLayout(), so LayoutRenderer immediately shows the detached
12
+ * float content.
13
+ *
14
+ * App payloads: kicks off launchApp(appId, { skipLastApp, skipSwitchToHome })
15
+ * which handles the full attach/switchToApp lifecycle. The seed call is
16
+ * skipped because launchApp replaces the active root to 'app' on its own.
17
+ */
18
+
19
+ import '../tokens.css';
20
+ import '../primitives/base.css';
21
+ import LayoutRenderer from '../layout/LayoutRenderer.svelte';
22
+ import { seedSatelliteLayout } from '../layout/store.svelte';
23
+ import { seedLayoutFromPayload } from './seed';
24
+ import { launchApp } from '../apps/lifecycle';
25
+ import type { SatellitePayload } from '../boot/satellitePayload';
26
+
27
+ interface Props {
28
+ payload: SatellitePayload;
29
+ }
30
+ let { payload }: Props = $props();
31
+
32
+ // The payload is a one-shot prop set at spawn time and never reassigned.
33
+ // We read it inside $effect to make the one-time-read intent explicit
34
+ // (silences state_referenced_locally) and to defer side-effects past the
35
+ // script's synchronous evaluation phase.
36
+ $effect(() => {
37
+ if (payload.kind === 'float') {
38
+ seedSatelliteLayout(seedLayoutFromPayload(payload).docked);
39
+ } else if (payload.kind === 'app') {
40
+ const appId = payload.appId;
41
+ queueMicrotask(() => {
42
+ void launchApp(appId, { skipLastApp: true, skipSwitchToHome: true });
43
+ });
44
+ }
45
+ });
46
+ </script>
47
+
48
+ <div class="sh3-satellite-root">
49
+ <LayoutRenderer />
50
+ </div>
51
+
52
+ <style>
53
+ .sh3-satellite-root {
54
+ position: absolute;
55
+ inset: 0;
56
+ overflow: hidden;
57
+ background: var(--shell-grad-bg, var(--shell-bg));
58
+ color: var(--shell-fg);
59
+ }
60
+ </style>
@@ -0,0 +1,9 @@
1
+ import '../tokens.css';
2
+ import '../primitives/base.css';
3
+ import type { SatellitePayload } from '../boot/satellitePayload';
4
+ interface Props {
5
+ payload: SatellitePayload;
6
+ }
7
+ declare const SatelliteShell: import("svelte").Component<Props, {}, "">;
8
+ type SatelliteShell = ReturnType<typeof SatelliteShell>;
9
+ export default SatelliteShell;
@@ -0,0 +1,3 @@
1
+ import type { LayoutTree } from '../layout/types';
2
+ import type { SatellitePayload } from '../boot/satellitePayload';
3
+ export declare function seedLayoutFromPayload(payload: SatellitePayload): LayoutTree;
@@ -0,0 +1,20 @@
1
+ /*
2
+ * seedLayoutFromPayload — produce the initial LayoutTree the satellite
3
+ * mounts. Float payloads use the supplied content tree directly as the
4
+ * docked root. App payloads start with a single empty slot (viewId: null)
5
+ * that the launched app fills via the normal attachApp lifecycle.
6
+ */
7
+ export function seedLayoutFromPayload(payload) {
8
+ if (payload.kind === 'float') {
9
+ return {
10
+ docked: payload.content,
11
+ floats: [],
12
+ };
13
+ }
14
+ // App payload: empty slot — attachApp will replace it.
15
+ const root = { type: 'slot', slotId: 'satellite:root', viewId: null };
16
+ return {
17
+ docked: root,
18
+ floats: [],
19
+ };
20
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { seedLayoutFromPayload } from './seed';
3
+ describe('seedLayoutFromPayload', () => {
4
+ it('seeds a float payload with content as the docked root', () => {
5
+ const content = { type: 'slot', slotId: 's:1', viewId: 'a:v' };
6
+ const payload = {
7
+ kind: 'float',
8
+ content,
9
+ title: 'X',
10
+ size: { w: 800, h: 600 },
11
+ activateShards: ['a'],
12
+ };
13
+ const tree = seedLayoutFromPayload(payload);
14
+ expect(tree.docked).toEqual(content);
15
+ expect(tree.floats).toEqual([]);
16
+ });
17
+ it('seeds an app payload with an empty slot the app will fill', () => {
18
+ const payload = {
19
+ kind: 'app',
20
+ appId: 'sh3-store',
21
+ activateShards: ['sh3-store'],
22
+ };
23
+ const tree = seedLayoutFromPayload(payload);
24
+ expect(tree.docked.type).toBe('slot');
25
+ expect(tree.floats).toEqual([]);
26
+ });
27
+ it('seeds an app payload with a null viewId on the empty slot', () => {
28
+ const payload = {
29
+ kind: 'app',
30
+ appId: 'sh3-store',
31
+ activateShards: ['sh3-store'],
32
+ };
33
+ const tree = seedLayoutFromPayload(payload);
34
+ if (tree.docked.type !== 'slot')
35
+ throw new Error('expected slot');
36
+ expect(tree.docked.viewId).toBeNull();
37
+ });
38
+ });
@@ -0,0 +1,2 @@
1
+ import type { LayoutNode } from '../layout/types';
2
+ export declare function walkShardsForContent(node: LayoutNode): string[];
@@ -0,0 +1,44 @@
1
+ /*
2
+ * walkShardsForContent — given a LayoutNode tree (typically from a float's
3
+ * content), return the deduplicated list of shardIds providing the slot
4
+ * views inside it. Used at spawn time to compute the satellite's
5
+ * activateShards list.
6
+ *
7
+ * Slots whose viewId is null or has no registered provider are silently
8
+ * skipped — the satellite will surface a "Required shard X missing" error
9
+ * if it encounters an unrenderable slot during mount.
10
+ */
11
+ import { getShardForView } from '../shards/registry';
12
+ export function walkShardsForContent(node) {
13
+ const acc = new Set();
14
+ walk(node, acc);
15
+ return [...acc];
16
+ }
17
+ function walk(node, acc) {
18
+ switch (node.type) {
19
+ case 'slot': {
20
+ if (node.viewId !== null) {
21
+ const shardId = getShardForView(node.viewId);
22
+ if (shardId)
23
+ acc.add(shardId);
24
+ }
25
+ return;
26
+ }
27
+ case 'tabs': {
28
+ for (const tab of node.tabs) {
29
+ if (tab.viewId !== null) {
30
+ const shardId = getShardForView(tab.viewId);
31
+ if (shardId)
32
+ acc.add(shardId);
33
+ }
34
+ }
35
+ return;
36
+ }
37
+ case 'split': {
38
+ for (const child of node.children) {
39
+ walk(child, acc);
40
+ }
41
+ return;
42
+ }
43
+ }
44
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { registerView, __resetViewRegistryForTest, } from '../shards/registry';
3
+ import { walkShardsForContent } from './walkShards';
4
+ const mountStub = () => ({ unmount: () => { } });
5
+ beforeEach(() => {
6
+ __resetViewRegistryForTest();
7
+ });
8
+ describe('walkShardsForContent', () => {
9
+ it('returns empty array for a slot with an unregistered view', () => {
10
+ const node = { type: 'slot', slotId: 's', viewId: 'unknown:v' };
11
+ expect(walkShardsForContent(node)).toEqual([]);
12
+ });
13
+ it('returns the providing shard for a single registered slot', () => {
14
+ registerView('alpha:v', { mount: mountStub }, 'alpha');
15
+ const node = { type: 'slot', slotId: 's', viewId: 'alpha:v' };
16
+ expect(walkShardsForContent(node)).toEqual(['alpha']);
17
+ });
18
+ it("walks tabs nodes and collects each tab's shard", () => {
19
+ registerView('a:v', { mount: mountStub }, 'a');
20
+ registerView('b:v', { mount: mountStub }, 'b');
21
+ const node = {
22
+ type: 'tabs',
23
+ tabs: [
24
+ { slotId: 't1', viewId: 'a:v', label: 'A' },
25
+ { slotId: 't2', viewId: 'b:v', label: 'B' },
26
+ ],
27
+ activeTab: 0,
28
+ };
29
+ expect(walkShardsForContent(node).sort()).toEqual(['a', 'b']);
30
+ });
31
+ it('walks split nodes recursively', () => {
32
+ registerView('x:v', { mount: mountStub }, 'x');
33
+ registerView('y:v', { mount: mountStub }, 'y');
34
+ const node = {
35
+ type: 'split',
36
+ direction: 'horizontal',
37
+ children: [
38
+ { type: 'slot', slotId: 's1', viewId: 'x:v' },
39
+ {
40
+ type: 'split',
41
+ direction: 'vertical',
42
+ children: [
43
+ { type: 'slot', slotId: 's2', viewId: 'y:v' },
44
+ { type: 'slot', slotId: 's3', viewId: 'x:v' },
45
+ ],
46
+ sizes: [0.5, 0.5],
47
+ },
48
+ ],
49
+ sizes: [0.5, 0.5],
50
+ };
51
+ expect(walkShardsForContent(node).sort()).toEqual(['x', 'y']);
52
+ });
53
+ it('dedupes when multiple slots share the same shard', () => {
54
+ registerView('z:v', { mount: mountStub }, 'z');
55
+ const node = {
56
+ type: 'tabs',
57
+ tabs: [
58
+ { slotId: 't1', viewId: 'z:v', label: 'A' },
59
+ { slotId: 't2', viewId: 'z:v', label: 'B' },
60
+ ],
61
+ activeTab: 0,
62
+ };
63
+ expect(walkShardsForContent(node)).toEqual(['z']);
64
+ });
65
+ });
@@ -27,6 +27,10 @@ import { toastManager } from '../overlays/toast';
27
27
  import AppUpdateAvailableModal from '../app/store/AppUpdateAvailableModal.svelte';
28
28
  import UninstallAppDialog from '../app/store/UninstallAppDialog.svelte';
29
29
  import AppInfoView from './AppInfoView.svelte';
30
+ import { spawnSatellite } from '../shellApi/window';
31
+ import { activeApp, getActiveApp } from '../apps/registry.svelte';
32
+ import { returnToHome } from '../apps/lifecycle';
33
+ const isTauri = typeof globalThis.__TAURI_INTERNALS__ !== 'undefined';
30
34
  export function computeAppActionDisabled(g) {
31
35
  return !g.admin || g.builtin;
32
36
  }
@@ -106,6 +110,34 @@ async function runCheckUpdate(_ctx) {
106
110
  };
107
111
  modalManager.open(AppUpdateAvailableModal, props);
108
112
  }
113
+ function runPopOut(_ctx) {
114
+ var _a;
115
+ const ref = readSelection();
116
+ if (!ref)
117
+ return;
118
+ const manifest = findApp(ref.appId);
119
+ if (!manifest)
120
+ return;
121
+ void spawnSatellite({
122
+ kind: 'app',
123
+ appId: ref.appId,
124
+ activateShards: (_a = manifest.requiredShards) !== null && _a !== void 0 ? _a : [],
125
+ });
126
+ }
127
+ async function runPopOutCurrent(_ctx) {
128
+ var _a;
129
+ const current = getActiveApp();
130
+ if (!current)
131
+ return;
132
+ const appId = current.id;
133
+ const requiredShards = (_a = current.requiredShards) !== null && _a !== void 0 ? _a : [];
134
+ await returnToHome();
135
+ void spawnSatellite({
136
+ kind: 'app',
137
+ appId,
138
+ activateShards: requiredShards,
139
+ });
140
+ }
109
141
  function runUninstall(_ctx) {
110
142
  var _a, _b;
111
143
  const ref = readSelection();
@@ -193,6 +225,25 @@ export function registerAppActions(ctx) {
193
225
  disabled: isDisabledForCurrent,
194
226
  run: runUninstall,
195
227
  },
228
+ {
229
+ id: 'app.popout',
230
+ label: 'Open in standalone view',
231
+ scope: { element: 'app' },
232
+ contextItem: true,
233
+ paletteItem: false,
234
+ group: 'window',
235
+ disabled: () => !isTauri,
236
+ run: runPopOut,
237
+ },
238
+ {
239
+ id: 'app.popoutCurrent',
240
+ label: 'Pop out current app',
241
+ scope: 'app',
242
+ contextItem: false,
243
+ paletteItem: true,
244
+ disabled: () => !isTauri || activeApp.id == null,
245
+ run: runPopOutCurrent,
246
+ },
196
247
  ];
197
248
  const disposers = actions.map((a) => ctx.actions.register(a));
198
249
  return () => disposers.forEach((d) => d());
@@ -18,7 +18,7 @@ export declare const activeShards: Map<string, Shard>;
18
18
  export interface ShardErrorEntry {
19
19
  id: string;
20
20
  error: unknown;
21
- phase: 'autostart' | 'launch';
21
+ phase: 'autostart' | 'launch' | 'satellite';
22
22
  timestamp: number;
23
23
  }
24
24
  export declare const erroredShards: Map<string, ShardErrorEntry>;
@@ -39,7 +39,7 @@ export interface ActivateShardOpts {
39
39
  * recorded in `erroredShards` if activation fails. Defaults to 'launch'
40
40
  * (the common case — required by an app being launched).
41
41
  */
42
- phase?: 'autostart' | 'launch';
42
+ phase?: 'autostart' | 'launch' | 'satellite';
43
43
  }
44
44
  /**
45
45
  * Activate a registered shard. Builds a `ShardContext`, calls `shard.activate`,
@@ -134,7 +134,7 @@ export async function activateShard(id, opts) {
134
134
  const ctx = {
135
135
  state: (schema) => shell.state(id, schema),
136
136
  registerView: (viewId, factory) => {
137
- registerView(viewId, factory);
137
+ registerView(viewId, factory, shard.manifest.id);
138
138
  entry.viewIds.add(viewId);
139
139
  },
140
140
  registerVerb: (verb) => {
@@ -1,8 +1,9 @@
1
1
  import type { ViewFactory } from './types';
2
2
  export declare function __addViewRegistrationListener(fn: (viewId: string, factory: ViewFactory) => void): void;
3
3
  export declare function __removeViewRegistrationListener(fn: (viewId: string, factory: ViewFactory) => void): void;
4
- export declare function registerView(viewId: string, factory: ViewFactory): void;
4
+ export declare function registerView(viewId: string, factory: ViewFactory, shardId?: string): void;
5
5
  export declare function getView(viewId: string): ViewFactory | undefined;
6
+ export declare function getShardForView(viewId: string): string | undefined;
6
7
  export declare function unregisterView(viewId: string): void;
7
8
  import type { Verb } from '../verbs/types';
8
9
  export declare function registerVerb(name: string, verb: Verb, shardId: string): void;
@@ -1,9 +1,9 @@
1
1
  /*
2
2
  * Contribution registry — views and verbs.
3
3
  *
4
- * Tracks which ViewFactory answers a given viewId. In this phase the
5
- * registry is a flat module-level Map with no awareness of shard identity;
6
- * writes come from `activateShard` which additionally remembers which
4
+ * Tracks which ViewFactory answers a given viewId and, via the `viewToShard`
5
+ * reverse map, which shard owns each view (queryable through `getShardForView`).
6
+ * Writes come from `activateShard` which additionally remembers which
7
7
  * viewIds a given shard registered so they can be torn down in
8
8
  * `deactivateShard`.
9
9
  *
@@ -14,6 +14,7 @@
14
14
  * hotkeys get their own sibling maps when those kinds land.
15
15
  */
16
16
  const views = new Map();
17
+ const viewToShard = new Map();
17
18
  /** Listeners called after a new view factory is registered. */
18
19
  const viewRegistrationListeners = new Set();
19
20
  export function __addViewRegistrationListener(fn) {
@@ -22,11 +23,14 @@ export function __addViewRegistrationListener(fn) {
22
23
  export function __removeViewRegistrationListener(fn) {
23
24
  viewRegistrationListeners.delete(fn);
24
25
  }
25
- export function registerView(viewId, factory) {
26
+ export function registerView(viewId, factory, shardId) {
26
27
  if (views.has(viewId)) {
27
28
  throw new Error(`View "${viewId}" is already registered`);
28
29
  }
29
30
  views.set(viewId, factory);
31
+ if (shardId !== undefined) {
32
+ viewToShard.set(viewId, shardId);
33
+ }
30
34
  for (const listener of viewRegistrationListeners) {
31
35
  listener(viewId, factory);
32
36
  }
@@ -34,8 +38,12 @@ export function registerView(viewId, factory) {
34
38
  export function getView(viewId) {
35
39
  return views.get(viewId);
36
40
  }
41
+ export function getShardForView(viewId) {
42
+ return viewToShard.get(viewId);
43
+ }
37
44
  export function unregisterView(viewId) {
38
45
  views.delete(viewId);
46
+ viewToShard.delete(viewId);
39
47
  }
40
48
  const verbs = new Map();
41
49
  export function registerVerb(name, verb, shardId) {
@@ -68,6 +76,7 @@ export function listVerbsWithShard() {
68
76
  /** Test-only reset: clear the view and verb registries. */
69
77
  export function __resetViewRegistryForTest() {
70
78
  views.clear();
79
+ viewToShard.clear();
71
80
  verbs.clear();
72
81
  // Do NOT clear viewRegistrationListeners — they are module-level subscriptions
73
82
  // (e.g. slotHostPool's late-factory listener) that must survive registry resets.
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
- import { registerVerb, getVerb, unregisterVerb, listVerbs, listVerbsWithShard, } from './registry';
2
+ import { registerVerb, getVerb, unregisterVerb, listVerbs, listVerbsWithShard, registerView, unregisterView, getShardForView, __resetViewRegistryForTest, } from './registry';
3
3
  function makeStubVerb(name) {
4
4
  return { name, summary: `stub ${name}`, run: async () => { } };
5
5
  }
@@ -68,3 +68,24 @@ describe('verb registry', () => {
68
68
  expect(listVerbsWithShard()).toHaveLength(0);
69
69
  });
70
70
  });
71
+ describe('getShardForView (reverse index)', () => {
72
+ beforeEach(() => __resetViewRegistryForTest());
73
+ it('returns undefined for an unregistered view', () => {
74
+ expect(getShardForView('nope:never')).toBeUndefined();
75
+ });
76
+ it('returns the shard id passed at registration time', () => {
77
+ registerView('test:rv1', { mount: () => ({ unmount: () => { } }) }, 'shardA');
78
+ expect(getShardForView('test:rv1')).toBe('shardA');
79
+ unregisterView('test:rv1');
80
+ });
81
+ it('returns undefined if registered without a shard id (legacy callers)', () => {
82
+ registerView('test:rv2', { mount: () => ({ unmount: () => { } }) });
83
+ expect(getShardForView('test:rv2')).toBeUndefined();
84
+ unregisterView('test:rv2');
85
+ });
86
+ it('clears the reverse entry on unregisterView', () => {
87
+ registerView('test:rv3', { mount: () => ({ unmount: () => { } }) }, 'shardB');
88
+ unregisterView('test:rv3');
89
+ expect(getShardForView('test:rv3')).toBeUndefined();
90
+ });
91
+ });
@@ -181,6 +181,7 @@ export interface ShardContext {
181
181
  /**
182
182
  * Register a view factory for a view id declared in the shard manifest.
183
183
  * Must be called for every id listed in `manifest.views` during `activate`.
184
+ * The shard id is injected automatically by the shell — shards do not need to pass it.
184
185
  *
185
186
  * @param viewId - Must match an entry in `manifest.views`.
186
187
  * @param factory - The adapter that mounts the view into a container element.
@@ -22,6 +22,7 @@
22
22
  disabled?: boolean;
23
23
  name?: string;
24
24
  onkeydown?: (e: KeyboardEvent) => void;
25
+ onfocus?: (e: FocusEvent) => void;
25
26
  }
26
27
  let {
27
28
  value = $bindable(''),
@@ -29,6 +30,7 @@
29
30
  disabled = false,
30
31
  name,
31
32
  onkeydown,
33
+ onfocus,
32
34
  }: Props = $props();
33
35
 
34
36
  let input: HTMLInputElement | null = $state(null);
@@ -72,6 +74,7 @@
72
74
  {name}
73
75
  {disabled}
74
76
  onkeydown={handleKeyDown}
77
+ onfocus={onfocus}
75
78
  spellcheck="false"
76
79
  autocomplete="off"
77
80
  autocapitalize="off"
@@ -20,6 +20,7 @@ interface Props {
20
20
  disabled?: boolean;
21
21
  name?: string;
22
22
  onkeydown?: (e: KeyboardEvent) => void;
23
+ onfocus?: (e: FocusEvent) => void;
23
24
  }
24
25
  declare const CommandLine: import("svelte").Component<Props, {}, "value">;
25
26
  type CommandLine = ReturnType<typeof CommandLine>;