sh3-core 0.21.2 → 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.
- package/dist/__test__/reset.js +2 -0
- package/dist/apps/lifecycle.js +49 -2
- package/dist/apps/lifecycle.test.js +234 -0
- package/dist/artifact.d.ts +2 -0
- package/dist/build.js +1 -1
- package/dist/documents/handle.d.ts +1 -1
- package/dist/documents/handle.js +37 -24
- package/dist/documents/handle.test.js +63 -0
- package/dist/documents/types.d.ts +6 -0
- package/dist/layout/LayoutRenderer.svelte +1 -1
- package/dist/layout/SlotContainer.svelte +1 -0
- package/dist/layout/inspection.js +19 -14
- package/dist/layout/inspection.svelte.test.js +136 -1
- package/dist/layout/slotHostPool.svelte.d.ts +2 -1
- package/dist/layout/slotHostPool.svelte.js +6 -3
- package/dist/layout/slotHostPool.test.js +17 -0
- package/dist/layout/store.projectScope.test.d.ts +1 -0
- package/dist/layout/store.projectScope.test.js +76 -0
- package/dist/layout/store.svelte.d.ts +6 -0
- package/dist/layout/store.svelte.js +43 -13
- package/dist/layout/tree-walk.d.ts +8 -1
- package/dist/layout/tree-walk.js +11 -1
- package/dist/layout/tree-walk.test.js +53 -1
- package/dist/layout/types.d.ts +27 -0
- package/dist/layout/types.test.js +28 -0
- package/dist/overlays/FloatFrame.svelte +4 -1
- package/dist/overlays/float.d.ts +7 -1
- package/dist/overlays/float.js +4 -0
- package/dist/projects-shard/ProjectsSection.svelte +1 -5
- package/dist/shards/activate-runtime.test.js +45 -0
- package/dist/shards/activate.svelte.js +5 -1
- package/dist/shards/app-binding.svelte.d.ts +8 -0
- package/dist/shards/app-binding.svelte.js +30 -0
- package/dist/shards/app-binding.test.d.ts +1 -0
- package/dist/shards/app-binding.test.js +25 -0
- package/dist/shards/types.d.ts +77 -10
- package/dist/shell-shard/shellShard.svelte.js +10 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
164
|
-
|
|
165
|
-
//
|
|
166
|
-
if (!
|
|
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
|
-
|
|
170
|
-
|
|
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
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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[];
|
package/dist/layout/tree-walk.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { collectSlotRefs, collectTreeSlotRefs } from './tree-walk';
|
|
2
|
+
import { collectSlotRefs, collectTreeSlotRefs, collectRestoredSlots } from './tree-walk';
|
|
3
3
|
const slot = (slotId, viewId) => ({
|
|
4
4
|
type: 'slot',
|
|
5
5
|
slotId,
|
|
@@ -12,6 +12,58 @@ describe('collectSlotRefs (single LayoutNode — existing behavior)', () => {
|
|
|
12
12
|
]);
|
|
13
13
|
});
|
|
14
14
|
});
|
|
15
|
+
describe('collectSlotRefs — props', () => {
|
|
16
|
+
it('includes props from a slot node', () => {
|
|
17
|
+
const tree = {
|
|
18
|
+
type: 'slot',
|
|
19
|
+
slotId: 's1',
|
|
20
|
+
viewId: 'v:a',
|
|
21
|
+
props: { filePath: '/a.guml' },
|
|
22
|
+
};
|
|
23
|
+
const refs = collectSlotRefs(tree);
|
|
24
|
+
expect(refs[0].props).toEqual({ filePath: '/a.guml' });
|
|
25
|
+
});
|
|
26
|
+
it('includes props from a tab entry', () => {
|
|
27
|
+
const tree = {
|
|
28
|
+
type: 'tabs',
|
|
29
|
+
activeTab: 0,
|
|
30
|
+
tabs: [{ slotId: 's2', viewId: 'v:b', label: 'B', props: { x: 42 } }],
|
|
31
|
+
};
|
|
32
|
+
const refs = collectSlotRefs(tree);
|
|
33
|
+
expect(refs[0].props).toEqual({ x: 42 });
|
|
34
|
+
});
|
|
35
|
+
it('props is undefined when not set', () => {
|
|
36
|
+
const tree = { type: 'slot', slotId: 's3', viewId: 'v:c' };
|
|
37
|
+
const refs = collectSlotRefs(tree);
|
|
38
|
+
expect(refs[0].props).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
describe('collectRestoredSlots', () => {
|
|
42
|
+
it('collects slots from docked tree and floats, excluding nulls', () => {
|
|
43
|
+
const tree = {
|
|
44
|
+
docked: { type: 'slot', slotId: 'd1', viewId: 'v:d', props: { a: 1 } },
|
|
45
|
+
floats: [
|
|
46
|
+
{
|
|
47
|
+
id: 'f1',
|
|
48
|
+
content: { type: 'slot', slotId: 'f-s1', viewId: 'v:e' },
|
|
49
|
+
position: { x: 0, y: 0 },
|
|
50
|
+
size: { w: 100, h: 100 },
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
const slots = collectRestoredSlots(tree);
|
|
55
|
+
expect(slots).toHaveLength(2);
|
|
56
|
+
expect(slots[0]).toEqual({ slotId: 'd1', viewId: 'v:d', props: { a: 1 } });
|
|
57
|
+
expect(slots[1]).toEqual({ slotId: 'f-s1', viewId: 'v:e', props: undefined });
|
|
58
|
+
});
|
|
59
|
+
it('excludes slots with null viewId', () => {
|
|
60
|
+
const tree = {
|
|
61
|
+
docked: { type: 'slot', slotId: 'e1', viewId: null },
|
|
62
|
+
floats: [],
|
|
63
|
+
};
|
|
64
|
+
expect(collectRestoredSlots(tree)).toHaveLength(0);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
15
67
|
describe('collectTreeSlotRefs (LayoutTree — new)', () => {
|
|
16
68
|
it('returns docked slots followed by float content slots in order', () => {
|
|
17
69
|
const tree = {
|
package/dist/layout/types.d.ts
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
/** JSON-safe value — safe for serialization into the layout tree. */
|
|
2
|
+
export type JsonValue = string | number | boolean | null | JsonValue[] | {
|
|
3
|
+
[key: string]: JsonValue;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* A slot as seen by shard layout hooks (`onLayoutWillRestore`,
|
|
7
|
+
* `onLayoutRestored`). Carries the slot's persisted props so shards
|
|
8
|
+
* can re-run ceremony without querying the layout tree directly.
|
|
9
|
+
*/
|
|
10
|
+
export interface RestoredSlot {
|
|
11
|
+
slotId: string;
|
|
12
|
+
viewId: string;
|
|
13
|
+
props?: Record<string, JsonValue>;
|
|
14
|
+
}
|
|
1
15
|
/** Axis along which a split node divides its children. */
|
|
2
16
|
export type SplitDirection = 'horizontal' | 'vertical';
|
|
3
17
|
/**
|
|
@@ -62,6 +76,13 @@ export interface TabEntry {
|
|
|
62
76
|
* Ephemeral — not serialized with the layout tree.
|
|
63
77
|
*/
|
|
64
78
|
meta?: Record<string, unknown>;
|
|
79
|
+
/**
|
|
80
|
+
* Persistent view-level parameters. Serialized with the layout tree and
|
|
81
|
+
* passed to `ViewFactory.mount` on every mount, including layout restore.
|
|
82
|
+
* Values must be JSON-serializable. Use for data the view needs to
|
|
83
|
+
* initialize itself (e.g. `{ filePath: '/foo.guml' }`).
|
|
84
|
+
*/
|
|
85
|
+
props?: Record<string, JsonValue>;
|
|
65
86
|
}
|
|
66
87
|
/**
|
|
67
88
|
* A layout node that groups one or more slots as tabs, showing one at a time.
|
|
@@ -124,6 +145,12 @@ export interface SlotNode {
|
|
|
124
145
|
* content is a single slot rather than a TabsNode).
|
|
125
146
|
*/
|
|
126
147
|
meta?: Record<string, unknown>;
|
|
148
|
+
/**
|
|
149
|
+
* Persistent view-level parameters. Serialized with the layout tree and
|
|
150
|
+
* passed to `ViewFactory.mount` on every mount, including layout restore.
|
|
151
|
+
* Values must be JSON-serializable.
|
|
152
|
+
*/
|
|
153
|
+
props?: Record<string, JsonValue>;
|
|
127
154
|
}
|
|
128
155
|
/**
|
|
129
156
|
* Union of all layout node kinds. The recursive tree is composed entirely of
|