sh3-core 0.21.0 → 0.22.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 (43) hide show
  1. package/dist/__test__/reset.js +2 -0
  2. package/dist/app/store/StoreView.svelte +2 -0
  3. package/dist/apps/lifecycle.js +49 -2
  4. package/dist/apps/lifecycle.test.js +234 -0
  5. package/dist/artifact.d.ts +2 -0
  6. package/dist/build.d.ts +5 -0
  7. package/dist/build.js +52 -2
  8. package/dist/build.test.js +30 -1
  9. package/dist/documents/handle.d.ts +1 -1
  10. package/dist/documents/handle.js +37 -24
  11. package/dist/documents/handle.test.js +63 -0
  12. package/dist/documents/types.d.ts +6 -0
  13. package/dist/layout/LayoutRenderer.svelte +1 -1
  14. package/dist/layout/SlotContainer.svelte +1 -0
  15. package/dist/layout/inspection.js +19 -14
  16. package/dist/layout/inspection.svelte.test.js +136 -1
  17. package/dist/layout/slotHostPool.svelte.d.ts +2 -1
  18. package/dist/layout/slotHostPool.svelte.js +6 -3
  19. package/dist/layout/slotHostPool.test.js +17 -0
  20. package/dist/layout/store.projectScope.test.d.ts +1 -0
  21. package/dist/layout/store.projectScope.test.js +76 -0
  22. package/dist/layout/store.svelte.d.ts +6 -0
  23. package/dist/layout/store.svelte.js +43 -13
  24. package/dist/layout/tree-walk.d.ts +8 -1
  25. package/dist/layout/tree-walk.js +11 -1
  26. package/dist/layout/tree-walk.test.js +53 -1
  27. package/dist/layout/types.d.ts +27 -0
  28. package/dist/layout/types.test.js +28 -0
  29. package/dist/overlays/FloatFrame.svelte +4 -1
  30. package/dist/overlays/float.d.ts +7 -1
  31. package/dist/overlays/float.js +4 -0
  32. package/dist/projects-shard/ProjectsSection.svelte +1 -5
  33. package/dist/shards/activate-runtime.test.js +45 -0
  34. package/dist/shards/activate.svelte.js +5 -1
  35. package/dist/shards/app-binding.svelte.d.ts +8 -0
  36. package/dist/shards/app-binding.svelte.js +30 -0
  37. package/dist/shards/app-binding.test.d.ts +1 -0
  38. package/dist/shards/app-binding.test.js +25 -0
  39. package/dist/shards/types.d.ts +77 -10
  40. package/dist/shell-shard/shellShard.svelte.js +10 -1
  41. package/dist/version.d.ts +1 -1
  42. package/dist/version.js +1 -1
  43. package/package.json +1 -1
@@ -251,3 +251,66 @@ describe('DocumentHandle folder ops', () => {
251
251
  ]);
252
252
  });
253
253
  });
254
+ describe('createDocumentHandle — appId override', () => {
255
+ it('uses appId as the namespace root when provided', async () => {
256
+ const backend = new MemoryDocumentBackend();
257
+ const handle = createDocumentHandle('tenant-1', 'sh3-editor', backend, { format: 'text', appId: 'guml-ide' });
258
+ await handle.write('test.guml', 'content');
259
+ const keysAppId = await backend.list('tenant-1', 'guml-ide');
260
+ expect(keysAppId.some((k) => k.path === 'test.guml')).toBe(true);
261
+ const keysShardId = await backend.list('tenant-1', 'sh3-editor');
262
+ expect(keysShardId).toHaveLength(0);
263
+ });
264
+ it('falls back to shardId when appId is not provided', async () => {
265
+ const backend = new MemoryDocumentBackend();
266
+ const handle = createDocumentHandle('tenant-1', 'sh3-editor', backend, { format: 'text' });
267
+ await handle.write('test.guml', 'content');
268
+ const keys = await backend.list('tenant-1', 'sh3-editor');
269
+ expect(keys.some((k) => k.path === 'test.guml')).toBe(true);
270
+ });
271
+ });
272
+ describe('createDocumentHandle — lazy resolvers', () => {
273
+ it('resolves namespace per operation from a function', async () => {
274
+ const { MemoryDocumentBackend } = await import('./backends');
275
+ const backend = new MemoryDocumentBackend();
276
+ let ns = 'shard-A';
277
+ const handle = createDocumentHandle('local', () => ns, backend, { format: 'text' });
278
+ await handle.write('foo.txt', 'first');
279
+ expect((await backend.list('local', 'shard-A')).map(d => d.path)).toContain('foo.txt');
280
+ ns = 'app-X';
281
+ await handle.write('bar.txt', 'second');
282
+ expect((await backend.list('local', 'app-X')).map(d => d.path)).toContain('bar.txt');
283
+ expect((await backend.list('local', 'shard-A')).map(d => d.path)).not.toContain('bar.txt');
284
+ });
285
+ it('resolves tenant per operation from a function', async () => {
286
+ const { MemoryDocumentBackend } = await import('./backends');
287
+ const backend = new MemoryDocumentBackend();
288
+ let tenant = 'alice';
289
+ const handle = createDocumentHandle(() => tenant, () => 'shard-A', backend, { format: 'text' });
290
+ await handle.write('p.txt', 'a');
291
+ expect((await backend.list('alice', 'shard-A')).map(d => d.path)).toContain('p.txt');
292
+ tenant = 'bob';
293
+ await handle.write('q.txt', 'b');
294
+ expect((await backend.list('bob', 'shard-A')).map(d => d.path)).toContain('q.txt');
295
+ });
296
+ it('watchers resolve namespace at emit time, not subscribe time', async () => {
297
+ const { MemoryDocumentBackend } = await import('./backends');
298
+ const backend = new MemoryDocumentBackend();
299
+ let ns = 'shard-A';
300
+ const handle = createDocumentHandle('local', () => ns, backend, { format: 'text' });
301
+ const seen = [];
302
+ handle.watch((c) => seen.push(`${c.type}:${c.path}`));
303
+ await handle.write('a.txt', 'x');
304
+ ns = 'app-X';
305
+ await handle.write('b.txt', 'y');
306
+ expect(seen).toContain('create:a.txt');
307
+ expect(seen).toContain('create:b.txt');
308
+ });
309
+ it('still accepts string tenant + namespace for back-compat', async () => {
310
+ const { MemoryDocumentBackend } = await import('./backends');
311
+ const backend = new MemoryDocumentBackend();
312
+ const handle = createDocumentHandle('local', 'shard-A', backend, { format: 'text' });
313
+ await handle.write('foo.txt', 'hi');
314
+ expect((await backend.list('local', 'shard-A')).map(d => d.path)).toContain('foo.txt');
315
+ });
316
+ });
@@ -50,6 +50,12 @@ export interface DocumentHandleOptions {
50
50
  format: DocumentFormat;
51
51
  /** File extensions this handle operates on, e.g. ['.guml', '.md']. */
52
52
  extensions?: string[];
53
+ /**
54
+ * When set, this handle's namespace root becomes `{scope}/docs/{appId}/`
55
+ * instead of `{scope}/docs/{shardId}/`. Use in `onAppActivate` to get
56
+ * a handle scoped to the app's shared document namespace.
57
+ */
58
+ appId?: string;
53
59
  }
54
60
  /** Metadata about a stored document. */
55
61
  export interface DocumentMeta {
@@ -227,7 +227,7 @@
227
227
  {#if entry}
228
228
  <div class="tab-slot-wrapper">
229
229
  <SlotContainer
230
- node={{ type: 'slot', slotId: entry.slotId, viewId: entry.viewId }}
230
+ node={{ type: 'slot', slotId: entry.slotId, viewId: entry.viewId, props: entry.props }}
231
231
  label={entry.label}
232
232
  meta={entry.meta}
233
233
  />
@@ -71,6 +71,7 @@
71
71
  node.viewId,
72
72
  label || node.viewId || currentSlotId,
73
73
  meta,
74
+ node.props,
74
75
  );
75
76
  wrapperEl.appendChild(host);
76
77
 
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { activeLayout, getActiveRoot } from './store.svelte';
16
16
  import { nodeAtPath, findTabBySlotId, removeTabBySlotId, cleanupTree, splitNodeAtPath, locateSlotIn, } from './ops';
17
- import { getSlotHandle } from './slotHostPool.svelte';
17
+ import { getSlotHandle, isSlotClosable } from './slotHostPool.svelte';
18
18
  import { floatManager } from '../overlays/float';
19
19
  import { viewportStore } from '../viewport/store.svelte';
20
20
  import { compactRootStore } from './compact/rootStore.svelte';
@@ -160,14 +160,15 @@ export async function closeTab(slotId) {
160
160
  if (!located) {
161
161
  return closeFloatTab(tree, slotId);
162
162
  }
163
- const handle = getSlotHandle(slotId);
164
- const closable = handle === null || handle === void 0 ? void 0 : handle.closable;
165
- // Non-closable: no action.
166
- if (!closable)
163
+ // Use the same closability signal that drives the close button in the UI.
164
+ // This covers standalone: and float: prefixed slots that are auto-closable
165
+ // even when the view handle does not explicitly declare closable: true.
166
+ if (!isSlotClosable(slotId))
167
167
  return false;
168
- // Guarded: ask the shard.
169
- if (typeof closable === 'object') {
170
- const allowed = await closable.canClose();
168
+ // Guarded: ask the shard if it declared a canClose() guard.
169
+ const handle = getSlotHandle(slotId);
170
+ if (typeof (handle === null || handle === void 0 ? void 0 : handle.closable) === 'object') {
171
+ const allowed = await handle.closable.canClose();
171
172
  if (!allowed)
172
173
  return false;
173
174
  // Re-verify the tab still exists after the async gap — another close
@@ -237,9 +238,10 @@ export function popoutView(slotId) {
237
238
  return null;
238
239
  const title = entry.label;
239
240
  const meta = entry.meta;
241
+ const props = entry.props;
240
242
  removeTabBySlotId(tree.docked, slotId);
241
243
  cleanupTree(tree.docked);
242
- return floatManager.open(viewId, { title, meta });
244
+ return floatManager.open(viewId, { title, meta, props });
243
245
  }
244
246
  /**
245
247
  * Dock a float back into the currently-rendered layout. The float's
@@ -260,15 +262,18 @@ export function dockFloat(floatId) {
260
262
  return false;
261
263
  }
262
264
  const entry = (_b = tabs.tabs[(_a = tabs.activeTab) !== null && _a !== void 0 ? _a : 0]) !== null && _b !== void 0 ? _b : tabs.tabs[0];
263
- const ok = dockIntoActiveLayout({
265
+ const newEntry = {
264
266
  slotId: entry.slotId,
265
267
  viewId: entry.viewId,
266
268
  label: entry.label,
267
269
  meta: entry.meta,
268
- });
269
- if (ok)
270
- floatManager.close(floatId);
271
- return ok;
270
+ props: entry.props,
271
+ };
272
+ // Close the float BEFORE inserting so dockIntoActiveLayout's internal
273
+ // focusView() doesn't find the float's own tab and short-circuit the
274
+ // actual insert. The captured `newEntry` keeps the data we need.
275
+ floatManager.close(floatId);
276
+ return dockIntoActiveLayout(newEntry);
272
277
  }
273
278
  function findFirstTabsNode(node) {
274
279
  if (node.type === 'tabs')
@@ -7,7 +7,7 @@
7
7
  import { describe, it, expect, beforeEach } from 'vitest';
8
8
  import { flushSync } from 'svelte';
9
9
  import { attachApp, switchToApp, __resetLayoutStoreForTest, layoutStore, } from './store.svelte';
10
- import { dockIntoActiveLayout } from './inspection';
10
+ import { dockIntoActiveLayout, inspectActiveLayout } from './inspection';
11
11
  let appCounter = 0;
12
12
  function makeApp(initialLayout) {
13
13
  // Unique id per call so workspace-zone storage from one test doesn't
@@ -161,3 +161,138 @@ describe('focusView — compact body-root swap', () => {
161
161
  viewportStore.override(null);
162
162
  });
163
163
  });
164
+ // ---------------------------------------------------------------------------
165
+ // closeTab — standalone prefix makes tab closable regardless of view handle
166
+ // ---------------------------------------------------------------------------
167
+ import { closeTab, spliceIntoActiveLayout } from './inspection';
168
+ import { acquireSlotHost, resetSlotHostPool } from './slotHostPool.svelte';
169
+ import { registerView } from '../shards/registry';
170
+ import { __resetViewRegistryForTest } from '../shards/registry';
171
+ describe('spliceIntoActiveLayout — props stored on tab entry', () => {
172
+ beforeEach(() => {
173
+ __resetLayoutStoreForTest();
174
+ });
175
+ it('stores props on the new tab entry', () => {
176
+ attachApp(makeApp({
177
+ type: 'tabs',
178
+ tabs: [{ slotId: 'existing', viewId: 'v:existing', label: 'Existing' }],
179
+ activeTab: 0,
180
+ }));
181
+ switchToApp();
182
+ spliceIntoActiveLayout({
183
+ slotId: 'my-slot',
184
+ viewId: 'test:view',
185
+ label: 'Test',
186
+ props: { filePath: '/foo.guml' },
187
+ });
188
+ const { root } = inspectActiveLayout();
189
+ const tabs = root.docked;
190
+ if (tabs.type !== 'tabs')
191
+ throw new Error('expected tabs root');
192
+ const tab = tabs.tabs.find(t => t.slotId === 'my-slot');
193
+ expect(tab === null || tab === void 0 ? void 0 : tab.props).toEqual({ filePath: '/foo.guml' });
194
+ });
195
+ });
196
+ describe('popoutView / dockFloat — props round-trip', () => {
197
+ beforeEach(() => {
198
+ __resetLayoutStoreForTest();
199
+ __resetFloatManagerForTest();
200
+ bindFloatStore(layoutStore.floats, () => ({ w: 1024, h: 768 }));
201
+ });
202
+ it('popoutView preserves tab.props on the resulting float entry', async () => {
203
+ var _a;
204
+ const { popoutView } = await import('./inspection');
205
+ attachApp(makeApp({
206
+ type: 'tabs',
207
+ tabs: [
208
+ {
209
+ slotId: 'with-props',
210
+ viewId: 'editor:view',
211
+ label: 'My File',
212
+ props: { filePath: '/foo.guml', cursorLine: 12 },
213
+ },
214
+ // Second tab so popout doesn't leave an empty tabs root.
215
+ { slotId: 'other', viewId: 'other:view', label: 'Other' },
216
+ ],
217
+ activeTab: 0,
218
+ }));
219
+ switchToApp();
220
+ bindFloatStore(layoutStore.floats, () => ({ w: 1024, h: 768 }));
221
+ const floatId = popoutView('with-props');
222
+ expect(floatId).not.toBeNull();
223
+ const { root } = inspectActiveLayout();
224
+ const floatEntry = root.floats.find((f) => f.id === floatId);
225
+ expect(floatEntry).toBeDefined();
226
+ if (!floatEntry || floatEntry.content.type !== 'tabs')
227
+ throw new Error('expected tabs content');
228
+ expect((_a = floatEntry.content.tabs[0]) === null || _a === void 0 ? void 0 : _a.props).toEqual({ filePath: '/foo.guml', cursorLine: 12 });
229
+ });
230
+ it('dockFloat preserves tab.props on the docked-back entry', async () => {
231
+ const { popoutView, dockFloat } = await import('./inspection');
232
+ attachApp(makeApp({
233
+ type: 'tabs',
234
+ tabs: [
235
+ {
236
+ slotId: 'with-props',
237
+ viewId: 'editor:view',
238
+ label: 'My File',
239
+ props: { filePath: '/foo.guml', cursorLine: 12 },
240
+ },
241
+ // Second tab so the docked tree never becomes degenerate.
242
+ { slotId: 'other', viewId: 'other:view', label: 'Other' },
243
+ ],
244
+ activeTab: 0,
245
+ }));
246
+ switchToApp();
247
+ bindFloatStore(layoutStore.floats, () => ({ w: 1024, h: 768 }));
248
+ const floatId = popoutView('with-props');
249
+ expect(floatId).not.toBeNull();
250
+ const ok = dockFloat(floatId);
251
+ expect(ok).toBe(true);
252
+ const { root } = inspectActiveLayout();
253
+ function findTab(node) {
254
+ if (node.type === 'tabs')
255
+ return node.tabs.find((t) => t.viewId === 'editor:view');
256
+ if (node.type === 'split') {
257
+ for (const c of node.children) {
258
+ const hit = findTab(c);
259
+ if (hit)
260
+ return hit;
261
+ }
262
+ }
263
+ return undefined;
264
+ }
265
+ const restored = findTab(root.docked);
266
+ expect(restored).toBeDefined();
267
+ expect(restored === null || restored === void 0 ? void 0 : restored.props).toEqual({ filePath: '/foo.guml', cursorLine: 12 });
268
+ });
269
+ });
270
+ describe('closeTab — standalone prefix closability', () => {
271
+ beforeEach(() => {
272
+ __resetLayoutStoreForTest();
273
+ __resetFloatManagerForTest();
274
+ resetSlotHostPool();
275
+ __resetViewRegistryForTest();
276
+ });
277
+ it('removes a standalone-prefixed tab even when the view handle declares no closable flag', async () => {
278
+ const slotId = 'standalone:myview:1234';
279
+ layoutStore.tree.docked = {
280
+ type: 'tabs',
281
+ tabs: [{ slotId, viewId: 'myview', label: 'My View' }],
282
+ activeTab: 0,
283
+ };
284
+ // Factory returns a handle with no closable property — simulates a shard
285
+ // that does not explicitly mark its view as closable.
286
+ registerView('myview', { mount: () => ({ unmount: () => { } }) });
287
+ acquireSlotHost(slotId, 'myview', 'My View');
288
+ // Let the deferred mount microtask run (sets closableState via standalone: prefix).
289
+ await Promise.resolve();
290
+ await Promise.resolve();
291
+ const removed = await closeTab(slotId);
292
+ expect(removed).toBe(true);
293
+ const root = layoutStore.root;
294
+ if (!root || root.type !== 'tabs')
295
+ throw new Error('expected tabs root');
296
+ expect(root.tabs.map((t) => t.slotId)).not.toContain(slotId);
297
+ });
298
+ });
@@ -1,11 +1,12 @@
1
1
  import type { ViewHandle } from '../shards/types';
2
+ import type { JsonValue } from './types';
2
3
  /**
3
4
  * Acquire (or create) the pooled host for a slot. The caller is
4
5
  * expected to `appendChild` the returned host into its own wrapper —
5
6
  * the pool does not know which wrapper owns the host at any given time,
6
7
  * and that is intentional. The same host may be passed around.
7
8
  */
8
- export declare function acquireSlotHost(slotId: string, viewId: string | null, label: string, meta?: Record<string, unknown>): HTMLDivElement;
9
+ export declare function acquireSlotHost(slotId: string, viewId: string | null, label: string, meta?: Record<string, unknown>, props?: Record<string, JsonValue>): HTMLDivElement;
9
10
  /**
10
11
  * Release the pooled host. If this was the last reference, a
11
12
  * destruction is queued to run in a microtask; a later acquire before
@@ -60,6 +60,7 @@ function onViewRegistered(viewId, factory) {
60
60
  viewId,
61
61
  label: entry.label,
62
62
  meta: entry.meta,
63
+ props: entry.props,
63
64
  setDirty(dirty) {
64
65
  dirtyState[slotId] = dirty;
65
66
  },
@@ -132,7 +133,7 @@ const closableState = $state({});
132
133
  * is destroyed before its deferred mount ever runs (e.g. rapid
133
134
  * add-then-remove of a slot during a drag).
134
135
  */
135
- function createHost(slotId, viewId, label, meta) {
136
+ function createHost(slotId, viewId, label, meta, props) {
136
137
  const host = document.createElement('div');
137
138
  host.className = 'slot-host';
138
139
  host.dataset.slotId = slotId;
@@ -155,6 +156,7 @@ function createHost(slotId, viewId, label, meta) {
155
156
  viewId,
156
157
  label,
157
158
  meta,
159
+ props,
158
160
  refcount: 0,
159
161
  resizeObserver: undefined,
160
162
  cancelPendingMount: () => {
@@ -171,6 +173,7 @@ function createHost(slotId, viewId, label, meta) {
171
173
  viewId: viewId !== null && viewId !== void 0 ? viewId : '',
172
174
  label,
173
175
  meta,
176
+ props,
174
177
  setDirty(dirty) {
175
178
  dirtyState[slotId] = dirty;
176
179
  },
@@ -206,13 +209,13 @@ function createHost(slotId, viewId, label, meta) {
206
209
  * the pool does not know which wrapper owns the host at any given time,
207
210
  * and that is intentional. The same host may be passed around.
208
211
  */
209
- export function acquireSlotHost(slotId, viewId, label, meta) {
212
+ export function acquireSlotHost(slotId, viewId, label, meta, props) {
210
213
  // If the slot was about to be destroyed, cancel — this acquire is the
211
214
  // "other half" of a re-parent (teardown was the previous container).
212
215
  pendingDestroy.delete(slotId);
213
216
  let entry = pool.get(slotId);
214
217
  if (!entry) {
215
- entry = createHost(slotId, viewId, label, meta);
218
+ entry = createHost(slotId, viewId, label, meta, props);
216
219
  pool.set(slotId, entry);
217
220
  }
218
221
  else if (entry.viewId !== viewId) {
@@ -116,6 +116,23 @@ describe('slotHostPool — D.6 data-sh3-view attribute', () => {
116
116
  releaseSlotHost('slot-2');
117
117
  });
118
118
  });
119
+ // ─── D.8 ─────────────────────────────────────────────────────────────────────
120
+ describe('slotHostPool — D.8 props threaded to MountContext', () => {
121
+ beforeEach(resetFramework);
122
+ it('threads props from acquireSlotHost into MountContext', async () => {
123
+ var _a;
124
+ const captured = [];
125
+ registerView('props-view', {
126
+ mount(_container, ctx) {
127
+ captured.push(ctx);
128
+ return { unmount() { } };
129
+ },
130
+ });
131
+ acquireSlotHost('p-slot', 'props-view', 'Props View', undefined, { filePath: '/a.guml' });
132
+ await new Promise(resolve => queueMicrotask(resolve));
133
+ expect((_a = captured[0]) === null || _a === void 0 ? void 0 : _a.props).toEqual({ filePath: '/a.guml' });
134
+ });
135
+ });
119
136
  // ─── D.7 ─────────────────────────────────────────────────────────────────────
120
137
  describe('slotHostPool — D.7 data-sh3-scope attribute', () => {
121
138
  beforeEach(resetFramework);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { resetFramework } from '../__test__/reset';
3
+ import { makeApp, makeAppManifest, makeSlotNode, makeTree } from '../__test__/fixtures';
4
+ import { attachApp, detachApp, getActiveAppTree } from './store.svelte';
5
+ import { __setActiveScope } from '../documents/config';
6
+ import { peekZone, clearZone, backends } from '../state/zones.svelte';
7
+ describe('getActiveAppTree', () => {
8
+ beforeEach(resetFramework);
9
+ it('returns null when no app is attached', () => {
10
+ expect(getActiveAppTree()).toBeNull();
11
+ });
12
+ it('returns the current tree when an app is attached', () => {
13
+ const app = makeApp({
14
+ manifest: makeAppManifest({ id: 'scope-test-app' }),
15
+ initialLayout: [{ name: 'default', tree: makeTree(makeSlotNode('slot-1', 'v:a')) }],
16
+ });
17
+ attachApp(app);
18
+ const tree = getActiveAppTree();
19
+ expect(tree).not.toBeNull();
20
+ expect(tree === null || tree === void 0 ? void 0 : tree.docked).toBeDefined();
21
+ detachApp();
22
+ });
23
+ });
24
+ describe('layout blob — per-scope isolation', () => {
25
+ // Clean up both scope blobs so a previous run can't bleed in.
26
+ function cleanup() {
27
+ clearZone('workspace', '__sh3core__:app:scope-iso-app:scope-A');
28
+ clearZone('workspace', '__sh3core__:app:scope-iso-app:scope-B');
29
+ __setActiveScope('local');
30
+ }
31
+ beforeEach(() => {
32
+ resetFramework();
33
+ cleanup();
34
+ });
35
+ afterEach(cleanup);
36
+ it('reads the scope-A blob when attaching with scope A, ignoring the scope-B blob', async () => {
37
+ // Seed both scope-keyed blobs directly with distinguishable activePresets.
38
+ const blobA = {
39
+ layoutVersion: 1,
40
+ activePreset: 'alt',
41
+ presets: {
42
+ default: { default: makeTree(makeSlotNode('slot-1', 'v:a')) },
43
+ alt: { default: makeTree(makeSlotNode('slot-2', 'v:b')) },
44
+ },
45
+ };
46
+ const blobB = {
47
+ layoutVersion: 1,
48
+ activePreset: 'default',
49
+ presets: {
50
+ default: { default: makeTree(makeSlotNode('slot-1', 'v:a')) },
51
+ alt: { default: makeTree(makeSlotNode('slot-2', 'v:b')) },
52
+ },
53
+ };
54
+ backends.workspace.write('__sh3core__:app:scope-iso-app:scope-A', blobA);
55
+ backends.workspace.write('__sh3core__:app:scope-iso-app:scope-B', blobB);
56
+ const app = makeApp({
57
+ manifest: makeAppManifest({ id: 'scope-iso-app', layoutVersion: 1 }),
58
+ initialLayout: [
59
+ { name: 'default', tree: makeTree(makeSlotNode('slot-1', 'v:a')) },
60
+ { name: 'alt', tree: makeTree(makeSlotNode('slot-2', 'v:b')) },
61
+ ],
62
+ });
63
+ // Attach under scope A — must load scope-A's blob (activePreset='alt').
64
+ __setActiveScope('scope-A');
65
+ attachApp(app);
66
+ const loadedA = peekZone('workspace', '__sh3core__:app:scope-iso-app:scope-A');
67
+ expect(loadedA === null || loadedA === void 0 ? void 0 : loadedA.activePreset).toBe('alt');
68
+ detachApp();
69
+ // Switch to scope B and re-attach — must load scope-B's blob (activePreset='default').
70
+ __setActiveScope('scope-B');
71
+ attachApp(app);
72
+ const loadedB = peekZone('workspace', '__sh3core__:app:scope-iso-app:scope-B');
73
+ expect(loadedB === null || loadedB === void 0 ? void 0 : loadedB.activePreset).toBe('default');
74
+ detachApp();
75
+ });
76
+ });
@@ -59,6 +59,12 @@ export declare function switchToApp(): void;
59
59
  */
60
60
  export declare function activeLayout(): LayoutTree;
61
61
  export declare function getActiveRoot(): 'home' | 'app';
62
+ /**
63
+ * Returns a snapshot of the currently-attached app's active preset tree,
64
+ * or null if no app is attached. Used by the layout hook machinery to
65
+ * collect restored slots before mounting.
66
+ */
67
+ export declare function getActiveAppTree(): LayoutTree | null;
62
68
  export declare function getAttachedAppId(): string | null;
63
69
  /**
64
70
  * Preserved for callers that still read `layoutStore.root`. The `root`
@@ -35,6 +35,7 @@ import { normalizeInitialLayout, resolveActiveTree } from './presets';
35
35
  import { viewportStore } from '../viewport/store.svelte';
36
36
  import { collectTreeSlotRefs } from './tree-walk';
37
37
  import { bindPresetBlob, unbindPresetBlob } from '../overlays/presets';
38
+ import { getActiveScopeId } from '../documents/config';
38
39
  import { getRegisteredApp } from '../apps/registry.svelte';
39
40
  import { drawerStore } from './compact/drawerStore.svelte';
40
41
  // ---------- orphan cleanup of pre-phase-8 sh3 layout key ----------------
@@ -108,7 +109,7 @@ export function attachApp(app) {
108
109
  if (appEntry) {
109
110
  throw new Error(`Layout manager cannot attach app "${app.manifest.id}": app "${appEntry.appId}" is still attached`);
110
111
  }
111
- const shardId = `__sh3core__:app:${app.manifest.id}`;
112
+ const shardId = `__sh3core__:app:${app.manifest.id}:${getActiveScopeId()}`;
112
113
  // Normalize the app's initialLayout into canonical presets.
113
114
  const canonical = normalizeInitialLayout(app.initialLayout);
114
115
  if (canonical.length === 0) {
@@ -204,8 +205,8 @@ export function acquireAppSlotHolds() {
204
205
  return; // idempotent
205
206
  const tree = currentTree(appEntry.proxy);
206
207
  const refs = collectTreeSlotRefs(tree);
207
- for (const { slotId, viewId, label, meta } of refs) {
208
- acquireSlotHost(slotId, viewId, label, meta);
208
+ for (const { slotId, viewId, label, meta, props } of refs) {
209
+ acquireSlotHost(slotId, viewId, label, meta, props);
209
210
  appEntry.heldSlotIds.push(slotId);
210
211
  }
211
212
  }
@@ -240,9 +241,16 @@ export function resetActivePresetToDefault() {
240
241
  const blob = appEntry.proxy;
241
242
  const targetName = blob.activePreset;
242
243
  let target = canonical.find((p) => p.name === targetName);
243
- if (!target) {
244
+ if (!target)
244
245
  target = canonical[0];
245
- blob.activePreset = target.name;
246
+ // Snapshot all declared variants (default + compact, etc.) as plain data.
247
+ // The app object lives in a $state Map (registry.svelte.ts), so
248
+ // target.variants entries are Svelte proxies; structuredClone cannot clone
249
+ // them. $state.snapshot unwraps to a plain JS object without new reactive
250
+ // state.
251
+ const freshVariants = {};
252
+ for (const key of Object.keys(target.variants)) {
253
+ freshVariants[key] = $state.snapshot(target.variants[key]);
246
254
  }
247
255
  // Release old slot holds before swapping the tree so the pool's
248
256
  // microtask sees no live refs and tears down hosts that the new
@@ -251,14 +259,26 @@ export function resetActivePresetToDefault() {
251
259
  releaseSlotHost(slotId);
252
260
  }
253
261
  appEntry.heldSlotIds = [];
254
- // Deep-clone so the canonical object isn't aliased with future
255
- // attaches or with the app's source `LayoutPreset` objects.
256
- const freshTree = structuredClone(target.variants.default);
257
- blob.presets[blob.activePreset].default = freshTree;
258
- // Re-acquire holds against the new tree (mirrors acquireAppSlotHolds).
259
- const refs = collectTreeSlotRefs(freshTree);
260
- for (const { slotId, viewId, label, meta } of refs) {
261
- acquireSlotHost(slotId, viewId, label, meta);
262
+ // Write fresh variants into the blob. If the canonical preset name differs
263
+ // from the stored activePreset (e.g. user migrated from the unnamed
264
+ // 'default' to an explicit preset name via the variant API), or the preset
265
+ // entry is missing entirely, add it first so the activeTree derived never
266
+ // reads a missing-preset state when activePreset is updated.
267
+ if (target.name !== targetName || !blob.presets[target.name]) {
268
+ blob.presets[target.name] = freshVariants;
269
+ blob.activePreset = target.name;
270
+ }
271
+ else {
272
+ // Same preset — overwrite each declared variant in place so reactive
273
+ // subscribers see the mutation. Resets compact variant too if declared.
274
+ for (const key of Object.keys(freshVariants)) {
275
+ blob.presets[blob.activePreset][key] = freshVariants[key];
276
+ }
277
+ }
278
+ // Re-acquire holds against the new default tree (mirrors acquireAppSlotHolds).
279
+ const refs = collectTreeSlotRefs(freshVariants.default);
280
+ for (const { slotId, viewId, label, meta, props } of refs) {
281
+ acquireSlotHost(slotId, viewId, label, meta, props);
262
282
  appEntry.heldSlotIds.push(slotId);
263
283
  }
264
284
  }
@@ -341,6 +361,16 @@ export function activeLayout() {
341
361
  export function getActiveRoot() {
342
362
  return activeRoot;
343
363
  }
364
+ /**
365
+ * Returns a snapshot of the currently-attached app's active preset tree,
366
+ * or null if no app is attached. Used by the layout hook machinery to
367
+ * collect restored slots before mounting.
368
+ */
369
+ export function getActiveAppTree() {
370
+ if (!appEntry)
371
+ return null;
372
+ return currentTree(appEntry.proxy);
373
+ }
344
374
  export function getAttachedAppId() {
345
375
  var _a;
346
376
  return (_a = appEntry === null || appEntry === void 0 ? void 0 : appEntry.appId) !== null && _a !== void 0 ? _a : null;
@@ -1,4 +1,4 @@
1
- import type { LayoutNode, LayoutTree } from './types';
1
+ import type { LayoutNode, LayoutTree, RestoredSlot, JsonValue } from './types';
2
2
  /**
3
3
  * Collect the slot id / view id pairs of every slot leaf (including the
4
4
  * slots embedded inside tabs entries) in a layout tree. Used by the
@@ -13,6 +13,7 @@ export declare function collectSlotRefs(tree: LayoutNode): {
13
13
  viewId: string | null;
14
14
  label: string;
15
15
  meta?: Record<string, unknown>;
16
+ props?: Record<string, JsonValue>;
16
17
  }[];
17
18
  /**
18
19
  * Multi-root version of `collectSlotRefs`: walks the docked tree first
@@ -25,4 +26,10 @@ export declare function collectTreeSlotRefs(tree: LayoutTree): {
25
26
  viewId: string | null;
26
27
  label: string;
27
28
  meta?: Record<string, unknown>;
29
+ props?: Record<string, JsonValue>;
28
30
  }[];
31
+ /**
32
+ * Collect every slot that has a non-null viewId, for use by shard layout
33
+ * hooks (`onLayoutWillRestore`, `onLayoutRestored`). Excludes empty slots.
34
+ */
35
+ export declare function collectRestoredSlots(tree: LayoutTree): RestoredSlot[];
@@ -20,12 +20,13 @@ export function collectSlotRefs(tree) {
20
20
  viewId: node.viewId,
21
21
  label: node.viewId || node.slotId,
22
22
  meta: node.meta,
23
+ props: node.props,
23
24
  });
24
25
  return;
25
26
  }
26
27
  if (node.type === 'tabs') {
27
28
  for (const t of node.tabs) {
28
- out.push({ slotId: t.slotId, viewId: t.viewId, label: t.label, meta: t.meta });
29
+ out.push({ slotId: t.slotId, viewId: t.viewId, label: t.label, meta: t.meta, props: t.props });
29
30
  }
30
31
  return;
31
32
  }
@@ -49,3 +50,12 @@ export function collectTreeSlotRefs(tree) {
49
50
  }
50
51
  return out;
51
52
  }
53
+ /**
54
+ * Collect every slot that has a non-null viewId, for use by shard layout
55
+ * hooks (`onLayoutWillRestore`, `onLayoutRestored`). Excludes empty slots.
56
+ */
57
+ export function collectRestoredSlots(tree) {
58
+ return collectTreeSlotRefs(tree)
59
+ .filter((r) => r.viewId !== null)
60
+ .map(({ slotId, viewId, props }) => ({ slotId, viewId, props }));
61
+ }