sh3-core 0.7.3 → 0.8.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.
Files changed (174) hide show
  1. package/dist/__test__/fixtures.d.ts +12 -0
  2. package/dist/__test__/fixtures.js +62 -0
  3. package/dist/__test__/render.d.ts +3 -0
  4. package/dist/__test__/render.js +11 -0
  5. package/dist/__test__/reset.d.ts +14 -0
  6. package/dist/__test__/reset.js +34 -0
  7. package/dist/__test__/setup-dom.d.ts +1 -0
  8. package/dist/__test__/setup-dom.js +26 -0
  9. package/dist/__test__/smoke.test.d.ts +1 -0
  10. package/dist/__test__/smoke.test.js +28 -0
  11. package/dist/api.d.ts +15 -2
  12. package/dist/api.js +13 -1
  13. package/dist/app/store/StoreView.svelte +36 -7
  14. package/dist/app/store/storeShard.svelte.js +9 -3
  15. package/dist/app/store/verbs.js +8 -2
  16. package/dist/apps/lifecycle.d.ts +11 -0
  17. package/dist/apps/lifecycle.js +48 -11
  18. package/dist/apps/lifecycle.test.d.ts +1 -0
  19. package/dist/apps/lifecycle.test.js +309 -0
  20. package/dist/apps/registry.svelte.d.ts +2 -0
  21. package/dist/apps/registry.svelte.js +5 -0
  22. package/dist/apps/types.d.ts +24 -2
  23. package/dist/createShell.d.ts +2 -0
  24. package/dist/createShell.js +9 -7
  25. package/dist/documents/handle.js +5 -0
  26. package/dist/documents/index.d.ts +1 -0
  27. package/dist/documents/index.js +1 -0
  28. package/dist/documents/journal-hook.d.ts +6 -0
  29. package/dist/documents/journal-hook.js +16 -0
  30. package/dist/documents/sync/activate-integration.test.d.ts +1 -0
  31. package/dist/documents/sync/activate-integration.test.js +37 -0
  32. package/dist/documents/sync/components/DocumentSyncExplorer.svelte +99 -0
  33. package/dist/documents/sync/components/DocumentSyncExplorer.svelte.d.ts +15 -0
  34. package/dist/documents/sync/components/SyncGrantPicker.svelte +70 -0
  35. package/dist/documents/sync/components/SyncGrantPicker.svelte.d.ts +12 -0
  36. package/dist/documents/sync/conflicts.d.ts +30 -0
  37. package/dist/documents/sync/conflicts.js +77 -0
  38. package/dist/documents/sync/conflicts.test.d.ts +1 -0
  39. package/dist/documents/sync/conflicts.test.js +71 -0
  40. package/dist/documents/sync/engine.d.ts +19 -0
  41. package/dist/documents/sync/engine.js +188 -0
  42. package/dist/documents/sync/engine.test.d.ts +1 -0
  43. package/dist/documents/sync/engine.test.js +169 -0
  44. package/dist/documents/sync/handle.d.ts +11 -0
  45. package/dist/documents/sync/handle.js +79 -0
  46. package/dist/documents/sync/handle.test.d.ts +1 -0
  47. package/dist/documents/sync/handle.test.js +56 -0
  48. package/dist/documents/sync/hash.d.ts +1 -0
  49. package/dist/documents/sync/hash.js +13 -0
  50. package/dist/documents/sync/hash.test.d.ts +1 -0
  51. package/dist/documents/sync/hash.test.js +20 -0
  52. package/dist/documents/sync/index.d.ts +6 -0
  53. package/dist/documents/sync/index.js +12 -0
  54. package/dist/documents/sync/journal.d.ts +30 -0
  55. package/dist/documents/sync/journal.js +179 -0
  56. package/dist/documents/sync/journal.test.d.ts +1 -0
  57. package/dist/documents/sync/journal.test.js +87 -0
  58. package/dist/documents/sync/registry.d.ts +10 -0
  59. package/dist/documents/sync/registry.js +66 -0
  60. package/dist/documents/sync/registry.test.d.ts +1 -0
  61. package/dist/documents/sync/registry.test.js +42 -0
  62. package/dist/documents/sync/serialization.d.ts +5 -0
  63. package/dist/documents/sync/serialization.js +24 -0
  64. package/dist/documents/sync/serialization.test.d.ts +1 -0
  65. package/dist/documents/sync/serialization.test.js +26 -0
  66. package/dist/documents/sync/singleton.d.ts +11 -0
  67. package/dist/documents/sync/singleton.js +26 -0
  68. package/dist/documents/sync/tombstones.d.ts +19 -0
  69. package/dist/documents/sync/tombstones.js +58 -0
  70. package/dist/documents/sync/tombstones.test.d.ts +1 -0
  71. package/dist/documents/sync/tombstones.test.js +37 -0
  72. package/dist/documents/sync/types.d.ts +116 -0
  73. package/dist/documents/sync/types.js +27 -0
  74. package/dist/documents/sync/write-hook.test.d.ts +1 -0
  75. package/dist/documents/sync/write-hook.test.js +36 -0
  76. package/dist/env/client.d.ts +10 -5
  77. package/dist/env/client.js +12 -4
  78. package/dist/layout/LayoutRenderer.browser.test.d.ts +1 -0
  79. package/dist/layout/LayoutRenderer.browser.test.js +274 -0
  80. package/dist/layout/LayoutRenderer.svelte +2 -1
  81. package/dist/layout/LayoutRenderer.test.d.ts +1 -0
  82. package/dist/layout/LayoutRenderer.test.js +143 -0
  83. package/dist/layout/SlotContainer.svelte +8 -2
  84. package/dist/layout/SlotDropZone.svelte +19 -0
  85. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-1-drag-tab-between-groups-moves-a-tab-from-one-tabs-group-to-another-1.png +0 -0
  86. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-2-drag-tab-to-quadrant-creates-a-split-when-dropping-a-tab-on-a-quadrant-drop-zone-1.png +0 -0
  87. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-3-splitter-drag-updates-split-sizes-when-the-splitter-handle-is-dragged-1.png +0 -0
  88. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-4-close-policy-removes-closable-tabs--keeps-non-closable--and-awaits-canClose-1.png +0 -0
  89. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-5-splitter-collapse-toggle-toggles-collapsed-i--on-double-click-1.png +0 -0
  90. package/dist/layout/drag.svelte.d.ts +5 -0
  91. package/dist/layout/drag.svelte.js +15 -0
  92. package/dist/layout/slotHostPool.svelte.d.ts +16 -1
  93. package/dist/layout/slotHostPool.svelte.js +123 -5
  94. package/dist/layout/slotHostPool.test.d.ts +1 -0
  95. package/dist/layout/slotHostPool.test.js +104 -0
  96. package/dist/layout/store.svelte.d.ts +22 -0
  97. package/dist/layout/store.svelte.js +78 -16
  98. package/dist/layout/tree-walk.d.ts +2 -0
  99. package/dist/layout/tree-walk.js +1 -1
  100. package/dist/layout/types.d.ts +5 -0
  101. package/dist/overlays/float.d.ts +2 -0
  102. package/dist/overlays/float.js +4 -1
  103. package/dist/overlays/float.test.js +102 -1
  104. package/dist/primitives/ResizableSplitter.svelte +2 -0
  105. package/dist/primitives/TabbedPanel.svelte +4 -0
  106. package/dist/primitives/TabbedPanel.svelte.d.ts +2 -0
  107. package/dist/registry/installer.d.ts +10 -7
  108. package/dist/registry/installer.js +39 -35
  109. package/dist/registry/register.d.ts +17 -0
  110. package/dist/registry/register.js +22 -0
  111. package/dist/registry/register.test.d.ts +1 -0
  112. package/dist/registry/register.test.js +28 -0
  113. package/dist/shards/activate.svelte.d.ts +6 -0
  114. package/dist/shards/activate.svelte.js +33 -2
  115. package/dist/shards/registry.d.ts +4 -0
  116. package/dist/shards/registry.js +18 -0
  117. package/dist/shards/types.d.ts +16 -1
  118. package/dist/shell-shard/Terminal.svelte +140 -33
  119. package/dist/shell-shard/Terminal.svelte.d.ts +3 -0
  120. package/dist/shell-shard/auto-relocate.d.ts +12 -0
  121. package/dist/shell-shard/auto-relocate.js +20 -0
  122. package/dist/shell-shard/auto-relocate.test.d.ts +1 -0
  123. package/dist/shell-shard/auto-relocate.test.js +35 -0
  124. package/dist/shell-shard/dispatch.d.ts +15 -0
  125. package/dist/shell-shard/dispatch.js +56 -0
  126. package/dist/shell-shard/modes/builtin.d.ts +5 -0
  127. package/dist/shell-shard/modes/builtin.js +18 -0
  128. package/dist/shell-shard/modes/prefs.d.ts +5 -0
  129. package/dist/shell-shard/modes/prefs.js +31 -0
  130. package/dist/shell-shard/modes/prefs.test.d.ts +1 -0
  131. package/dist/shell-shard/modes/prefs.test.js +46 -0
  132. package/dist/shell-shard/modes/registry.d.ts +7 -0
  133. package/dist/shell-shard/modes/registry.js +27 -0
  134. package/dist/shell-shard/modes/registry.test.d.ts +1 -0
  135. package/dist/shell-shard/modes/registry.test.js +35 -0
  136. package/dist/shell-shard/modes/types.d.ts +8 -0
  137. package/dist/shell-shard/modes/types.js +1 -0
  138. package/dist/shell-shard/protocol.d.ts +6 -0
  139. package/dist/shell-shard/shellShard.svelte.js +5 -1
  140. package/dist/shell-shard/tenant-fs-client.d.ts +24 -0
  141. package/dist/shell-shard/tenant-fs-client.js +44 -0
  142. package/dist/shell-shard/tenant-fs-client.test.d.ts +1 -0
  143. package/dist/shell-shard/tenant-fs-client.test.js +49 -0
  144. package/dist/shell-shard/terminal-dispatch.test.d.ts +1 -0
  145. package/dist/shell-shard/terminal-dispatch.test.js +53 -0
  146. package/dist/shell-shard/toolbar/Toolbar.svelte +62 -0
  147. package/dist/shell-shard/toolbar/Toolbar.svelte.d.ts +11 -0
  148. package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte +28 -0
  149. package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte.d.ts +7 -0
  150. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +102 -0
  151. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte.d.ts +11 -0
  152. package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte +17 -0
  153. package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte.d.ts +6 -0
  154. package/dist/shell-shard/toolbar/slots.d.ts +17 -0
  155. package/dist/shell-shard/toolbar/slots.js +26 -0
  156. package/dist/shell-shard/toolbar/slots.test.d.ts +1 -0
  157. package/dist/shell-shard/toolbar/slots.test.js +28 -0
  158. package/dist/shell-shard/verbs/cat.d.ts +2 -0
  159. package/dist/shell-shard/verbs/cat.js +34 -0
  160. package/dist/shell-shard/verbs/cd.test.d.ts +1 -0
  161. package/dist/shell-shard/verbs/cd.test.js +56 -0
  162. package/dist/shell-shard/verbs/env.d.ts +2 -0
  163. package/dist/shell-shard/verbs/env.js +14 -0
  164. package/dist/shell-shard/verbs/index.js +6 -1
  165. package/dist/shell-shard/verbs/ls.d.ts +2 -0
  166. package/dist/shell-shard/verbs/ls.js +29 -0
  167. package/dist/shell-shard/verbs/ls.test.d.ts +1 -0
  168. package/dist/shell-shard/verbs/ls.test.js +49 -0
  169. package/dist/shell-shard/verbs/session.d.ts +0 -1
  170. package/dist/shell-shard/verbs/session.js +58 -26
  171. package/dist/verbs/types.d.ts +2 -0
  172. package/dist/version.d.ts +1 -1
  173. package/dist/version.js +1 -1
  174. package/package.json +9 -1
@@ -76,6 +76,7 @@
76
76
  closable,
77
77
  dirty,
78
78
  onClose,
79
+ tabIds,
79
80
  }: {
80
81
  labels: string[];
81
82
  icons?: (string | undefined)[];
@@ -100,6 +101,8 @@
100
101
  dirty?: (boolean | undefined)[];
101
102
  /** Called when the user clicks a tab's close button. */
102
103
  onClose?: (index: number) => void;
104
+ /** Optional stable ids for each tab (e.g. slotId). Used as data-testid suffixes on close buttons. */
105
+ tabIds?: (string | undefined)[];
103
106
  } = $props();
104
107
 
105
108
  function select(i: number) {
@@ -172,6 +175,7 @@
172
175
  role="button"
173
176
  tabindex="-1"
174
177
  title="Close"
178
+ data-testid={tabIds?.[i] ? `tab-close-${tabIds[i]}` : undefined}
175
179
  onclick={(e) => handleClose(i, e)}
176
180
  onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleClose(i, e); }}
177
181
  >&#x2715;</span>
@@ -44,6 +44,8 @@ type $$ComponentProps = {
44
44
  dirty?: (boolean | undefined)[];
45
45
  /** Called when the user clicks a tab's close button. */
46
46
  onClose?: (index: number) => void;
47
+ /** Optional stable ids for each tab (e.g. slotId). Used as data-testid suffixes on close buttons. */
48
+ tabIds?: (string | undefined)[];
47
49
  };
48
50
  declare const TabbedPanel: import("svelte").Component<$$ComponentProps, {}, "">;
49
51
  type TabbedPanel = ReturnType<typeof TabbedPanel>;
@@ -7,9 +7,10 @@
7
7
  *
8
8
  * Flow:
9
9
  * 1. `installPackage(bundle, meta)` -- load module from bytes, verify
10
- * declared type matches actual type, persist to IndexedDB, register
11
- * with framework.
12
- * 2. `uninstallPackage(id)` -- deactivate if active, remove from storage.
10
+ * declared type matches actual type, persist to IndexedDB, evict any
11
+ * existing registration, then register via the shared helper.
12
+ * 2. `uninstallPackage(id)` -- deactivate if active, unregister app, remove
13
+ * from storage.
13
14
  * 3. `loadInstalledPackages()` -- called at boot, re-loads all installed
14
15
  * packages from IndexedDB and registers them.
15
16
  */
@@ -18,9 +19,10 @@ import type { InstalledPackage, InstallResult, PackageMeta } from './types';
18
19
  * Install a package from raw bundle bytes and metadata.
19
20
  *
20
21
  * Loads the ESM module, verifies the declared type matches the actual module
21
- * shape, persists to IndexedDB, and registers with the framework. If
22
- * registration fails (e.g. duplicate id from a shard that was already
23
- * glob-discovered), the package is still persisted but `hotLoaded` is false.
22
+ * shape, persists to IndexedDB, evicts any existing registration for the same
23
+ * id, and registers with the framework via the shared helper. If registration
24
+ * fails (e.g. a shard that was already glob-discovered), the package is still
25
+ * persisted but `hotLoaded` is false.
24
26
  *
25
27
  * @param bundle - Raw verified ESM bundle bytes.
26
28
  * @param meta - Provenance metadata for the install record.
@@ -31,7 +33,8 @@ export declare function installPackage(bundle: ArrayBuffer, meta: PackageMeta):
31
33
  * Uninstall a package by id.
32
34
  *
33
35
  * If the package is a shard and currently active, deactivates it first.
34
- * Removes both the bundle and metadata from IndexedDB.
36
+ * Unregisters any app entry for the id, then removes both the bundle and
37
+ * metadata from IndexedDB.
35
38
  *
36
39
  * @param id - The package id to uninstall.
37
40
  */
@@ -7,24 +7,27 @@
7
7
  *
8
8
  * Flow:
9
9
  * 1. `installPackage(bundle, meta)` -- load module from bytes, verify
10
- * declared type matches actual type, persist to IndexedDB, register
11
- * with framework.
12
- * 2. `uninstallPackage(id)` -- deactivate if active, remove from storage.
10
+ * declared type matches actual type, persist to IndexedDB, evict any
11
+ * existing registration, then register via the shared helper.
12
+ * 2. `uninstallPackage(id)` -- deactivate if active, unregister app, remove
13
+ * from storage.
13
14
  * 3. `loadInstalledPackages()` -- called at boot, re-loads all installed
14
15
  * packages from IndexedDB and registers them.
15
16
  */
16
17
  import { loadBundleModule } from './loader';
17
18
  import { savePackage, loadBundle, listInstalled, removePackage } from './storage';
18
19
  import { verifyIntegrity } from './integrity';
19
- import { registerShard, deactivateShard } from '../shards/activate.svelte';
20
- import { registerApp } from '../apps/registry.svelte';
20
+ import { deactivateShard } from '../shards/activate.svelte';
21
+ import { unregisterApp } from '../apps/lifecycle';
22
+ import { registerLoadedBundle } from './register';
21
23
  /**
22
24
  * Install a package from raw bundle bytes and metadata.
23
25
  *
24
26
  * Loads the ESM module, verifies the declared type matches the actual module
25
- * shape, persists to IndexedDB, and registers with the framework. If
26
- * registration fails (e.g. duplicate id from a shard that was already
27
- * glob-discovered), the package is still persisted but `hotLoaded` is false.
27
+ * shape, persists to IndexedDB, evicts any existing registration for the same
28
+ * id, and registers with the framework via the shared helper. If registration
29
+ * fails (e.g. a shard that was already glob-discovered), the package is still
30
+ * persisted but `hotLoaded` is false.
28
31
  *
29
32
  * @param bundle - Raw verified ESM bundle bytes.
30
33
  * @param meta - Provenance metadata for the install record.
@@ -88,20 +91,27 @@ export async function installPackage(bundle, meta) {
88
91
  error: `Failed to persist package: ${err instanceof Error ? err.message : String(err)}`,
89
92
  };
90
93
  }
91
- // 5. Stamp loader-assigned version (ADR-013) then register all shards
92
- // and apps from the bundle. External package authors omit `version`
93
- // from their source manifests; the authoritative value is the
94
- // registry entry's `PackageVersion.version`, carried on `meta.version`.
94
+ // 5. Evict any existing registration for this id before re-registering.
95
+ // Without this, reinstall at the same version leaks activation state
96
+ // (shards stay active) or app entries (apps silently replace but the
97
+ // old module instance's module-scope state is never torn down).
98
+ if (meta.type === 'shard' || meta.type === 'combo') {
99
+ try {
100
+ deactivateShard(meta.id);
101
+ }
102
+ catch ( /* not active or not a shard */_a) { /* not active or not a shard */ }
103
+ }
104
+ if (meta.type === 'app' || meta.type === 'combo') {
105
+ unregisterApp(meta.id);
106
+ }
107
+ // 6. Register all shards and apps from the bundle via the shared helper.
95
108
  let hotLoaded = true;
96
109
  try {
97
- for (const shard of loaded.shards) {
98
- shard.manifest = Object.assign(Object.assign({}, shard.manifest), { version: meta.version });
99
- registerShard(shard);
100
- }
101
- for (const app of loaded.apps) {
102
- app.manifest = Object.assign(Object.assign({}, app.manifest), { version: meta.version });
103
- registerApp(app);
104
- }
110
+ registerLoadedBundle(loaded, {
111
+ version: meta.version,
112
+ sourceRegistry: meta.sourceRegistry,
113
+ contractVersion: meta.contractVersion,
114
+ });
105
115
  }
106
116
  catch (err) {
107
117
  console.warn(`[sh3] Package "${meta.id}" installed but registration failed (will retry on next boot):`, err instanceof Error ? err.message : err);
@@ -113,18 +123,17 @@ export async function installPackage(bundle, meta) {
113
123
  * Uninstall a package by id.
114
124
  *
115
125
  * If the package is a shard and currently active, deactivates it first.
116
- * Removes both the bundle and metadata from IndexedDB.
126
+ * Unregisters any app entry for the id, then removes both the bundle and
127
+ * metadata from IndexedDB.
117
128
  *
118
129
  * @param id - The package id to uninstall.
119
130
  */
120
131
  export async function uninstallPackage(id) {
121
- // Attempt deactivation (no-op if not active or not a shard).
122
132
  try {
123
133
  deactivateShard(id);
124
134
  }
125
- catch (_a) {
126
- // Ignore -- may not be a shard, or may not be active.
127
- }
135
+ catch ( /* no-op */_a) { /* no-op */ }
136
+ unregisterApp(id);
128
137
  await removePackage(id);
129
138
  }
130
139
  /**
@@ -160,16 +169,11 @@ export async function loadInstalledPackages() {
160
169
  continue;
161
170
  }
162
171
  const loaded = await loadBundleModule(bytes);
163
- // Stamp loader-assigned version (ADR-013) from the persisted
164
- // InstalledPackage record before registration.
165
- for (const shard of loaded.shards) {
166
- shard.manifest = Object.assign(Object.assign({}, shard.manifest), { version: pkg.version });
167
- registerShard(shard);
168
- }
169
- for (const app of loaded.apps) {
170
- app.manifest = Object.assign(Object.assign({}, app.manifest), { version: pkg.version });
171
- registerApp(app);
172
- }
172
+ registerLoadedBundle(loaded, {
173
+ version: pkg.version,
174
+ sourceRegistry: pkg.sourceRegistry,
175
+ contractVersion: pkg.contractVersion,
176
+ });
173
177
  if (loaded.shards.length === 0 && loaded.apps.length === 0) {
174
178
  console.warn(`[sh3] Package "${pkg.id}" contains no valid shards or apps, skipping`);
175
179
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Unified bundle registration — stamps loader-assigned metadata onto every
3
+ * shard/app manifest from a loaded bundle, then registers each. Shared by
4
+ * `installer.installPackage`, `installer.loadInstalledPackages`, and
5
+ * `createShell` discoveredPackages so the three boot paths stay in sync.
6
+ *
7
+ * Per ADR-013, external package authors omit `version` from their source
8
+ * manifests; the authoritative value comes from the persisted/server
9
+ * metadata and must be stamped here before any consumer reads the manifest.
10
+ */
11
+ import type { LoadedBundle } from './loader';
12
+ export interface BundleStampMeta {
13
+ version: string;
14
+ sourceRegistry: string;
15
+ contractVersion: string;
16
+ }
17
+ export declare function registerLoadedBundle(loaded: LoadedBundle, meta: BundleStampMeta): void;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Unified bundle registration — stamps loader-assigned metadata onto every
3
+ * shard/app manifest from a loaded bundle, then registers each. Shared by
4
+ * `installer.installPackage`, `installer.loadInstalledPackages`, and
5
+ * `createShell` discoveredPackages so the three boot paths stay in sync.
6
+ *
7
+ * Per ADR-013, external package authors omit `version` from their source
8
+ * manifests; the authoritative value comes from the persisted/server
9
+ * metadata and must be stamped here before any consumer reads the manifest.
10
+ */
11
+ import { registerShard } from '../shards/activate.svelte';
12
+ import { registerApp } from '../apps/registry.svelte';
13
+ export function registerLoadedBundle(loaded, meta) {
14
+ for (const shard of loaded.shards) {
15
+ shard.manifest = Object.assign(Object.assign({}, shard.manifest), { version: meta.version });
16
+ registerShard(shard);
17
+ }
18
+ for (const app of loaded.apps) {
19
+ app.manifest = Object.assign(Object.assign({}, app.manifest), { version: meta.version });
20
+ registerApp(app);
21
+ }
22
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { resetFramework } from '../__test__/reset';
3
+ import { makeApp, makeAppManifest, makeShard, makeShardManifest } from '../__test__/fixtures';
4
+ import { registerLoadedBundle } from './register';
5
+ import { registeredApps } from '../apps/registry.svelte';
6
+ import { registeredShards } from '../shards/activate.svelte';
7
+ describe('registerLoadedBundle', () => {
8
+ beforeEach(resetFramework);
9
+ it('stamps meta.version onto every shard manifest before registering', () => {
10
+ var _a;
11
+ const shard = makeShard({ manifest: makeShardManifest({ id: 'shard-1', version: '' }) });
12
+ registerLoadedBundle({ shards: [shard], apps: [] }, { version: '2.3.4', sourceRegistry: 'https://r', contractVersion: '1' });
13
+ expect((_a = registeredShards.get('shard-1')) === null || _a === void 0 ? void 0 : _a.manifest.version).toBe('2.3.4');
14
+ });
15
+ it('stamps meta.version onto every app manifest before registering', () => {
16
+ var _a;
17
+ const app = makeApp({ manifest: makeAppManifest({ id: 'app-1', version: '' }) });
18
+ registerLoadedBundle({ shards: [], apps: [app] }, { version: '2.3.4', sourceRegistry: 'https://r', contractVersion: '1' });
19
+ expect((_a = registeredApps.get('app-1')) === null || _a === void 0 ? void 0 : _a.manifest.version).toBe('2.3.4');
20
+ });
21
+ it('registers combo bundles (both shards and apps)', () => {
22
+ const shard = makeShard({ manifest: makeShardManifest({ id: 'combo-s' }) });
23
+ const app = makeApp({ manifest: makeAppManifest({ id: 'combo-a', requiredShards: ['combo-s'] }) });
24
+ registerLoadedBundle({ shards: [shard], apps: [app] }, { version: '1.0.0', sourceRegistry: '', contractVersion: '1' });
25
+ expect(registeredShards.has('combo-s')).toBe(true);
26
+ expect(registeredApps.has('combo-a')).toBe(true);
27
+ });
28
+ });
@@ -50,3 +50,9 @@ export declare function isActive(id: string): boolean;
50
50
  * Used by lifecycle.ts to pass context to `shard.resume()`.
51
51
  */
52
52
  export declare function getShardContext(id: string): ShardContext | undefined;
53
+ /**
54
+ * Test-only reset. Tears down any active shard entries (without running
55
+ * deactivate hooks — tests should run deactivate explicitly if they care)
56
+ * and clears both registered and active maps.
57
+ */
58
+ export declare function __resetShardRegistryForTest(): void;
@@ -23,6 +23,9 @@ import { fetchEnvState, putEnvState } from '../env/client';
23
23
  import { isAdmin as checkIsAdmin } from '../auth/index';
24
24
  import { createZoneManager } from '../state/manage';
25
25
  import { PERMISSION_STATE_MANAGE } from '../state/types';
26
+ import { PERMISSION_DOCUMENTS_SYNC } from '../documents/sync/types';
27
+ import { getSyncBundle } from '../documents/sync/singleton';
28
+ import { createSyncHandle } from '../documents/sync/handle';
26
29
  /**
27
30
  * Reactive registry of every shard known to the host. Keys are shard ids.
28
31
  * Populated once at boot by the glob-discovery loop in main.ts (through
@@ -65,7 +68,7 @@ export function registerShard(shard) {
65
68
  * @throws If the shard is not registered, or if a manifest view has no factory after activation.
66
69
  */
67
70
  export async function activateShard(id) {
68
- var _a, _b;
71
+ var _a, _b, _c;
69
72
  const shard = registeredShards.get(id);
70
73
  if (!shard) {
71
74
  throw new Error(`Cannot activate shard "${id}": not registered`);
@@ -128,6 +131,24 @@ export async function activateShard(id) {
128
131
  zones: ((_a = shard.manifest.permissions) === null || _a === void 0 ? void 0 : _a.includes(PERMISSION_STATE_MANAGE))
129
132
  ? createZoneManager()
130
133
  : undefined,
134
+ sync: ((_b = shard.manifest.permissions) === null || _b === void 0 ? void 0 : _b.includes(PERMISSION_DOCUMENTS_SYNC))
135
+ ? () => {
136
+ const backend = getDocumentBackend();
137
+ const tenantId = getTenantId();
138
+ const bundlePromise = getSyncBundle(backend, tenantId);
139
+ const handlePromise = bundlePromise.then(({ engine, registry }) => createSyncHandle({ tenantId, connectorId: id, engine, registry }));
140
+ return {
141
+ connectorId: id,
142
+ grantedScopes: async () => (await handlePromise).grantedScopes(),
143
+ getManifest: async (scope) => (await handlePromise).getManifest(scope),
144
+ changesSince: async (scope, cursor) => (await handlePromise).changesSince(scope, cursor),
145
+ ack: async (scope, cursor) => (await handlePromise).ack(scope, cursor),
146
+ apply: async (scope, entry, opts) => (await handlePromise).apply(scope, entry, opts),
147
+ applyBatch: async (scope, manifest, opts) => (await handlePromise).applyBatch(scope, manifest, opts),
148
+ forget: async (scope, path) => (await handlePromise).forget(scope, path),
149
+ };
150
+ }
151
+ : undefined,
131
152
  };
132
153
  entry.ctx = ctx;
133
154
  active.set(id, entry);
@@ -149,7 +170,7 @@ export async function activateShard(id) {
149
170
  console.warn(`[sh3] Failed to hydrate env state for shard "${id}":`, err instanceof Error ? err.message : err);
150
171
  }
151
172
  }
152
- void ((_b = shard.autostart) === null || _b === void 0 ? void 0 : _b.call(shard, ctx));
173
+ void ((_c = shard.autostart) === null || _c === void 0 ? void 0 : _c.call(shard, ctx));
153
174
  }
154
175
  /**
155
176
  * Deactivate an active shard. Calls `shard.deactivate`, flushes and disposes
@@ -191,3 +212,13 @@ export function getShardContext(id) {
191
212
  var _a;
192
213
  return (_a = active.get(id)) === null || _a === void 0 ? void 0 : _a.ctx;
193
214
  }
215
+ /**
216
+ * Test-only reset. Tears down any active shard entries (without running
217
+ * deactivate hooks — tests should run deactivate explicitly if they care)
218
+ * and clears both registered and active maps.
219
+ */
220
+ export function __resetShardRegistryForTest() {
221
+ active.clear();
222
+ activeShards.clear();
223
+ registeredShards.clear();
224
+ }
@@ -1,4 +1,6 @@
1
1
  import type { ViewFactory } from './types';
2
+ export declare function __addViewRegistrationListener(fn: (viewId: string, factory: ViewFactory) => void): void;
3
+ export declare function __removeViewRegistrationListener(fn: (viewId: string, factory: ViewFactory) => void): void;
2
4
  export declare function registerView(viewId: string, factory: ViewFactory): void;
3
5
  export declare function getView(viewId: string): ViewFactory | undefined;
4
6
  export declare function unregisterView(viewId: string): void;
@@ -7,3 +9,5 @@ export declare function registerVerb(name: string, verb: Verb): void;
7
9
  export declare function getVerb(name: string): Verb | undefined;
8
10
  export declare function unregisterVerb(name: string): void;
9
11
  export declare function listVerbs(): Verb[];
12
+ /** Test-only reset: clear the view and verb registries. */
13
+ export declare function __resetViewRegistryForTest(): void;
@@ -14,11 +14,22 @@
14
14
  * hotkeys get their own sibling maps when those kinds land.
15
15
  */
16
16
  const views = new Map();
17
+ /** Listeners called after a new view factory is registered. */
18
+ const viewRegistrationListeners = new Set();
19
+ export function __addViewRegistrationListener(fn) {
20
+ viewRegistrationListeners.add(fn);
21
+ }
22
+ export function __removeViewRegistrationListener(fn) {
23
+ viewRegistrationListeners.delete(fn);
24
+ }
17
25
  export function registerView(viewId, factory) {
18
26
  if (views.has(viewId)) {
19
27
  throw new Error(`View "${viewId}" is already registered`);
20
28
  }
21
29
  views.set(viewId, factory);
30
+ for (const listener of viewRegistrationListeners) {
31
+ listener(viewId, factory);
32
+ }
22
33
  }
23
34
  export function getView(viewId) {
24
35
  return views.get(viewId);
@@ -42,3 +53,10 @@ export function unregisterVerb(name) {
42
53
  export function listVerbs() {
43
54
  return Array.from(verbs.values()).sort((a, b) => a.name.localeCompare(b.name));
44
55
  }
56
+ /** Test-only reset: clear the view and verb registries. */
57
+ export function __resetViewRegistryForTest() {
58
+ views.clear();
59
+ verbs.clear();
60
+ // Do NOT clear viewRegistrationListeners — they are module-level subscriptions
61
+ // (e.g. slotHostPool's late-factory listener) that must survive registry resets.
62
+ }
@@ -1,6 +1,7 @@
1
1
  import type { StateZones } from '../state/zones.svelte';
2
2
  import type { ZoneSchema, ZoneManager } from '../state/types';
3
3
  import type { DocumentHandle, DocumentHandleOptions } from '../documents/types';
4
+ import type { SyncHandle } from '../documents/sync/types';
4
5
  import type { EnvState } from '../env/types';
5
6
  import type { Verb } from '../verbs/types';
6
7
  /**
@@ -43,6 +44,12 @@ export interface MountContext {
43
44
  viewId: string;
44
45
  /** Initial label for the tab; may be updated by the view via future API. */
45
46
  label: string;
47
+ /**
48
+ * Caller-supplied instance data. Present when the mount was triggered with
49
+ * metadata (e.g. `shell.float.open(viewId, { meta: { ... } })`).
50
+ * Not persisted with the layout — ephemeral per mount.
51
+ */
52
+ meta?: Record<string, unknown>;
46
53
  /**
47
54
  * Push dirty-state to the tab strip. The framework renders a dirty
48
55
  * indicator (filled dot) on the tab when true, clears it when false.
@@ -110,7 +117,9 @@ export interface ShardManifest {
110
117
  /**
111
118
  * Optional permissions this shard requests beyond the default sandbox.
112
119
  * Declared in the manifest and surfaced to the user at install time.
113
- * Currently recognized: `'state:manage'` — cross-shard zone access.
120
+ * Currently recognized:
121
+ * - 'state:manage' — cross-shard zone access.
122
+ * - 'documents:sync' — cross-shard document sync API.
114
123
  */
115
124
  permissions?: string[];
116
125
  }
@@ -185,6 +194,12 @@ export interface ShardContext {
185
194
  * `if (ctx.zones)` before use.
186
195
  */
187
196
  zones?: ZoneManager;
197
+ /**
198
+ * Cross-shard document sync API. Only present when the shard's
199
+ * manifest declares the `'documents:sync'` permission. Check with
200
+ * `if (ctx.sync)` before use.
201
+ */
202
+ sync?: () => SyncHandle;
188
203
  }
189
204
  /**
190
205
  * A shard module. Shards are the fundamental unit of contribution in SH3.
@@ -6,55 +6,149 @@
6
6
  import { SessionClient } from './session-client.svelte';
7
7
  import { VerbRegistry, type ShellApi } from './registry';
8
8
  import type { ServerMessage } from './protocol';
9
+ import { TenantFsClient } from './tenant-fs-client';
10
+ import { ShellModeRegistry } from './modes/registry';
11
+ import { registerBuiltinModes } from './modes/builtin';
12
+ import { resolveInitialMode, writeLastMode } from './modes/prefs';
13
+ import type { ShellMode, ShellRole } from './modes/types';
14
+ import { makeDispatch } from './dispatch';
15
+ import { computeRelocate } from './auto-relocate';
16
+ import { activeLayout } from '../layout/store.svelte';
17
+ import type { LayoutNode } from '../layout/types';
18
+ import Toolbar from './toolbar/Toolbar.svelte';
19
+ import { ToolbarSlotRegistry } from './toolbar/slots';
20
+ import ModeSlot from './toolbar/slots/ModeSlot.svelte';
21
+ import FocusLockSlot from './toolbar/slots/FocusLockSlot.svelte';
22
+ import TargetShardSlot from './toolbar/slots/TargetShardSlot.svelte';
9
23
 
10
24
  interface Props {
11
25
  shell: ShellApi;
12
26
  wsUrl: string;
27
+ userId: string;
28
+ role: ShellRole;
13
29
  }
14
- let { shell, wsUrl }: Props = $props();
30
+ let { shell, wsUrl, userId, role }: Props = $props();
15
31
 
16
32
  const scrollback = new Scrollback();
33
+ const resolver = new VerbRegistry();
34
+ const fs = new TenantFsClient();
35
+
36
+ // Mode registry
37
+ const modeRegistry = new ShellModeRegistry();
38
+ registerBuiltinModes(modeRegistry);
39
+
40
+ // Reactive current mode
41
+ let mode = $state<ShellMode>(
42
+ untrack(() => resolveInitialMode(modeRegistry, userId, role)),
43
+ );
44
+
45
+ function setMode(id: string): void {
46
+ const next = modeRegistry.get(id);
47
+ if (!next) return;
48
+ if (next.requiresRole && next.requiresRole !== role) return;
49
+ mode = next;
50
+ writeLastMode(userId, id);
51
+ if (next.transport !== 'ws') {
52
+ scrollback.push({ kind: 'status', text: 'mode switch: reload to take effect for server-shell changes', level: 'info', ts: Date.now() });
53
+ }
54
+ }
55
+
17
56
  // wsUrl is a prop read at construction only. untrack prevents Svelte 5's
18
57
  // "referenced outside a closure" warning; the URL never changes at runtime.
19
58
  const session = untrack(() => new SessionClient(wsUrl));
20
- const resolver = new VerbRegistry();
59
+
60
+ const dispatch = untrack(() => makeDispatch({
61
+ mode: () => mode,
62
+ resolver,
63
+ scrollback,
64
+ session,
65
+ shell,
66
+ fs,
67
+ cwd: () => session.cwd,
68
+ }));
21
69
 
22
70
  let locked = $state(false);
23
71
 
24
- async function dispatch(line: string): Promise<void> {
25
- session.history.push(line);
26
- const resolution = resolver.resolve(line);
27
- if (resolution.kind === 'local') {
28
- // Log locally-dispatched verbs for shared history
29
- session.send({ t: 'history-log', line });
30
- scrollback.push({
31
- kind: 'prompt',
32
- cwd: session.cwd,
33
- line,
34
- ts: Date.now(),
35
- });
36
- try {
37
- await resolution.verb.run({
38
- shell,
39
- scrollback,
40
- session,
41
- cwd: session.cwd,
42
- dispatch,
43
- }, resolution.args);
44
- } catch (err) {
45
- scrollback.push({
46
- kind: 'status',
47
- text: `shell: verb ${resolution.verb.name} threw — ${(err as Error).message}`,
48
- level: 'error',
49
- ts: Date.now(),
50
- });
72
+ // ---------------------------------------------------------------------------
73
+ // Auto-relocate: track the focused shard and update session.cwd when focus
74
+ // changes to a shard whose documents directory exists. focusLocked and
75
+ // targetShard are read by Task 13's toolbar component via props.
76
+ // ---------------------------------------------------------------------------
77
+
78
+ let focusLocked = $state(false);
79
+ let targetShard = $state<string | null>(null);
80
+
81
+ function toggleFocusLock(): void {
82
+ focusLocked = !focusLocked;
83
+ }
84
+
85
+ // Toolbar slot registry
86
+ const toolbarRegistry = new ToolbarSlotRegistry();
87
+ toolbarRegistry.register({ id: 'mode', order: 10, visible: () => true, component: ModeSlot });
88
+ toolbarRegistry.register({ id: 'focus-lock', order: 20, visible: (ctx) => ctx.mode.id === 'user', component: FocusLockSlot });
89
+ toolbarRegistry.register({ id: 'target-shard', order: 30, visible: (ctx) => ctx.mode.id === 'user', component: TargetShardSlot });
90
+
91
+ let toolbarExpanded = $state((() => {
92
+ try { return localStorage.getItem('sh3.shell.toolbarExpanded') !== '0'; } catch { return true; }
93
+ })());
94
+
95
+ function toggleToolbar() {
96
+ toolbarExpanded = !toolbarExpanded;
97
+ try { localStorage.setItem('sh3.shell.toolbarExpanded', toolbarExpanded ? '1' : '0'); } catch {}
98
+ }
99
+
100
+ /** Walk the layout tree and return the viewId of the active tab in the first
101
+ * TabsNode found (breadth-first). Returns null if the layout contains no
102
+ * tabs node with a populated active tab. */
103
+ function getActiveViewId(node: LayoutNode): string | null {
104
+ if (node.type === 'tabs') {
105
+ const entry = node.tabs[node.activeTab];
106
+ return entry?.viewId ?? null;
107
+ }
108
+ if (node.type === 'split') {
109
+ for (const child of node.children) {
110
+ const found = getActiveViewId(child);
111
+ if (found !== null) return found;
51
112
  }
52
- } else {
53
- // Forward to server
54
- session.send({ t: 'submit', line: resolution.line });
55
113
  }
114
+ // slot node
115
+ return node.viewId ?? null;
56
116
  }
57
117
 
118
+ /** Derive the focused shard id from the currently-active layout. The shard
119
+ * id is the prefix before the first ':' in a viewId (e.g. 'shell:terminal'
120
+ * → 'shell'). Returns null when no view is active. */
121
+ function getFocusedShardId(): string | null {
122
+ try {
123
+ const tree = activeLayout();
124
+ const viewId = getActiveViewId(tree.docked);
125
+ if (!viewId) return null;
126
+ const colon = viewId.indexOf(':');
127
+ return colon >= 0 ? viewId.slice(0, colon) : viewId;
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ $effect(() => {
134
+ const focused = getFocusedShardId();
135
+ const autoRelocate = mode.autoRelocate;
136
+ const isFocusLocked = focusLocked;
137
+ (async () => {
138
+ const r = await computeRelocate({
139
+ modeAutoRelocate: autoRelocate,
140
+ focusLocked: isFocusLocked,
141
+ focusedShardId: focused,
142
+ currentShardId: 'shell',
143
+ }, fs);
144
+ if (r.kind === 'relocate' && r.path !== undefined) {
145
+ session.cwd = r.path;
146
+ }
147
+ if (focused && focused !== 'shell') targetShard = focused;
148
+ else if (!focused) targetShard = null;
149
+ })();
150
+ });
151
+
58
152
  function handleServerMessage(msg: ServerMessage) {
59
153
  if (msg.t !== 'event') return;
60
154
  const e = msg.event;
@@ -96,7 +190,9 @@
96
190
 
97
191
  onMount(() => {
98
192
  unsub = session.onMessage(handleServerMessage);
99
- session.connect();
193
+ if (mode.transport === 'ws') {
194
+ session.connect();
195
+ }
100
196
  });
101
197
 
102
198
  onDestroy(() => {
@@ -106,6 +202,17 @@
106
202
  </script>
107
203
 
108
204
  <div class="shell-terminal">
205
+ <Toolbar
206
+ registry={toolbarRegistry}
207
+ ctx={{ mode, role }}
208
+ expanded={toolbarExpanded}
209
+ onToggle={toggleToolbar}
210
+ slotProps={{
211
+ mode: { mode, role, registry: modeRegistry, onSelect: setMode },
212
+ 'focus-lock': { locked: focusLocked, onToggle: () => (focusLocked = !focusLocked) },
213
+ 'target-shard': { target: targetShard },
214
+ }}
215
+ />
109
216
  <ScrollbackView {scrollback} />
110
217
  <InputLine
111
218
  cwd={session.cwd}