sh3-core 0.7.1 → 0.7.5
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/Shell.svelte +3 -2
- package/dist/__test__/fixtures.d.ts +12 -0
- package/dist/__test__/fixtures.js +62 -0
- package/dist/__test__/render.d.ts +3 -0
- package/dist/__test__/render.js +11 -0
- package/dist/__test__/reset.d.ts +14 -0
- package/dist/__test__/reset.js +34 -0
- package/dist/__test__/setup-dom.d.ts +1 -0
- package/dist/__test__/setup-dom.js +26 -0
- package/dist/__test__/smoke.test.d.ts +1 -0
- package/dist/__test__/smoke.test.js +28 -0
- package/dist/api.d.ts +4 -0
- package/dist/apps/lifecycle.js +27 -10
- package/dist/apps/lifecycle.test.d.ts +1 -0
- package/dist/apps/lifecycle.test.js +260 -0
- package/dist/apps/registry.svelte.d.ts +2 -0
- package/dist/apps/registry.svelte.js +5 -0
- package/dist/apps/types.d.ts +17 -0
- package/dist/contract.d.ts +10 -0
- package/dist/contract.js +10 -0
- package/dist/layout/LayoutRenderer.browser.test.d.ts +1 -0
- package/dist/layout/LayoutRenderer.browser.test.js +274 -0
- package/dist/layout/LayoutRenderer.svelte +2 -1
- package/dist/layout/LayoutRenderer.test.d.ts +1 -0
- package/dist/layout/LayoutRenderer.test.js +143 -0
- package/dist/layout/SlotContainer.svelte +8 -2
- package/dist/layout/SlotDropZone.svelte +19 -0
- 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
- 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
- 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
- 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
- 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
- package/dist/layout/drag.svelte.d.ts +5 -0
- package/dist/layout/drag.svelte.js +15 -0
- package/dist/layout/inspection.js +58 -7
- package/dist/layout/ops.js +25 -6
- package/dist/layout/ops.test.js +51 -1
- package/dist/layout/slotHostPool.svelte.d.ts +16 -1
- package/dist/layout/slotHostPool.svelte.js +124 -6
- package/dist/layout/slotHostPool.test.d.ts +1 -0
- package/dist/layout/slotHostPool.test.js +104 -0
- package/dist/layout/store.svelte.d.ts +22 -0
- package/dist/layout/store.svelte.js +80 -18
- package/dist/layout/tree-walk.d.ts +2 -0
- package/dist/layout/tree-walk.js +1 -1
- package/dist/layout/types.d.ts +5 -0
- package/dist/overlays/FloatFrame.svelte +1 -0
- package/dist/overlays/float.d.ts +2 -0
- package/dist/overlays/float.js +4 -1
- package/dist/overlays/float.test.js +102 -1
- package/dist/primitives/ResizableSplitter.svelte +2 -0
- package/dist/primitives/TabbedPanel.svelte +4 -0
- package/dist/primitives/TabbedPanel.svelte.d.ts +2 -0
- package/dist/shards/activate.svelte.d.ts +6 -0
- package/dist/shards/activate.svelte.js +10 -0
- package/dist/shards/registry.d.ts +4 -0
- package/dist/shards/registry.js +18 -0
- package/dist/shards/types.d.ts +6 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +9 -1
package/dist/layout/ops.js
CHANGED
|
@@ -195,6 +195,24 @@ export function makeSplitWithNewTab(existing, entry, side) {
|
|
|
195
195
|
children,
|
|
196
196
|
};
|
|
197
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* Shallow-clone a layout node. Used by splitNodeAtPath's root case to
|
|
200
|
+
* break the circular reference that would occur if the root object
|
|
201
|
+
* (mutated in-place) were embedded as its own child.
|
|
202
|
+
*/
|
|
203
|
+
function snapshotNode(node) {
|
|
204
|
+
if (node.type === 'tabs')
|
|
205
|
+
return Object.assign(Object.assign({}, node), { tabs: [...node.tabs] });
|
|
206
|
+
if (node.type === 'split') {
|
|
207
|
+
const clone = Object.assign(Object.assign({}, node), { children: [...node.children], sizes: [...node.sizes] });
|
|
208
|
+
if (node.pinned)
|
|
209
|
+
clone.pinned = [...node.pinned];
|
|
210
|
+
if (node.collapsed)
|
|
211
|
+
clone.collapsed = [...node.collapsed];
|
|
212
|
+
return clone;
|
|
213
|
+
}
|
|
214
|
+
return Object.assign({}, node); // slot — plain shallow copy
|
|
215
|
+
}
|
|
198
216
|
/**
|
|
199
217
|
* Apply a slot-split as a tree mutation: find the target node at the
|
|
200
218
|
* given path and replace it with a new split. Handles the root case
|
|
@@ -205,14 +223,13 @@ export function splitNodeAtPath(root, path, entry, side) {
|
|
|
205
223
|
const target = nodeAtPath(root, path);
|
|
206
224
|
if (!target)
|
|
207
225
|
return;
|
|
208
|
-
const replacement = makeSplitWithNewTab(target, entry, side);
|
|
209
226
|
if (path.length === 0) {
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
|
|
227
|
+
// Root case: target IS root. Snapshot it so makeSplitWithNewTab
|
|
228
|
+
// embeds the clone, not root itself — avoids a circular reference
|
|
229
|
+
// when we Object.assign the replacement back onto root.
|
|
230
|
+
const snapshot = snapshotNode(target);
|
|
231
|
+
const replacement = makeSplitWithNewTab(snapshot, entry, side);
|
|
214
232
|
const rootAsRecord = root;
|
|
215
|
-
// Clear stale keys first so Object.assign doesn't leave a hybrid.
|
|
216
233
|
delete rootAsRecord.tabs;
|
|
217
234
|
delete rootAsRecord.activeTab;
|
|
218
235
|
delete rootAsRecord.slotId;
|
|
@@ -220,10 +237,12 @@ export function splitNodeAtPath(root, path, entry, side) {
|
|
|
220
237
|
delete rootAsRecord.direction;
|
|
221
238
|
delete rootAsRecord.sizes;
|
|
222
239
|
delete rootAsRecord.pinned;
|
|
240
|
+
delete rootAsRecord.collapsed;
|
|
223
241
|
delete rootAsRecord.children;
|
|
224
242
|
Object.assign(rootAsRecord, replacement);
|
|
225
243
|
return;
|
|
226
244
|
}
|
|
245
|
+
const replacement = makeSplitWithNewTab(target, entry, side);
|
|
227
246
|
const parentPath = path.slice(0, -1);
|
|
228
247
|
const indexInParent = path[path.length - 1];
|
|
229
248
|
const parent = nodeAtPath(root, parentPath);
|
package/dist/layout/ops.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { findTabInTree } from './ops';
|
|
2
|
+
import { findTabInTree, splitNodeAtPath, cleanupTree, findTabBySlotId } from './ops';
|
|
3
3
|
describe('findTabInTree', () => {
|
|
4
4
|
const tree = {
|
|
5
5
|
docked: {
|
|
@@ -34,3 +34,53 @@ describe('findTabInTree', () => {
|
|
|
34
34
|
expect(findTabInTree(tree, 'nonexistent')).toBeNull();
|
|
35
35
|
});
|
|
36
36
|
});
|
|
37
|
+
describe('splitNodeAtPath — root case (path = [])', () => {
|
|
38
|
+
it('does not create a circular reference when splitting the root', () => {
|
|
39
|
+
// splitNodeAtPath mutates root in-place (tabs → split). Cast to
|
|
40
|
+
// LayoutNode so TS doesn't narrow from the initializer.
|
|
41
|
+
const root = {
|
|
42
|
+
type: 'tabs',
|
|
43
|
+
tabs: [{ slotId: 's1', viewId: 'v1', label: 'One' }],
|
|
44
|
+
activeTab: 0,
|
|
45
|
+
};
|
|
46
|
+
const entry = { slotId: 's2', viewId: 'v2', label: 'Two' };
|
|
47
|
+
splitNodeAtPath(root, [], entry, 'right');
|
|
48
|
+
// After split, root should be a split node with two children.
|
|
49
|
+
// Neither child should be root itself (no circular ref).
|
|
50
|
+
expect(root.type).toBe('split');
|
|
51
|
+
if (root.type === 'split') {
|
|
52
|
+
for (const child of root.children) {
|
|
53
|
+
expect(child).not.toBe(root);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
it('cleanupTree does not infinite-loop after splitting the root', () => {
|
|
58
|
+
const root = {
|
|
59
|
+
type: 'tabs',
|
|
60
|
+
tabs: [{ slotId: 's1', viewId: 'v1', label: 'One' }],
|
|
61
|
+
activeTab: 0,
|
|
62
|
+
};
|
|
63
|
+
const entry = { slotId: 's2', viewId: 'v2', label: 'Two' };
|
|
64
|
+
splitNodeAtPath(root, [], entry, 'right');
|
|
65
|
+
// This must terminate. Before the fix it infinite-loops.
|
|
66
|
+
cleanupTree(root);
|
|
67
|
+
// Both tabs should still be findable.
|
|
68
|
+
expect(findTabBySlotId(root, 's1')).not.toBeNull();
|
|
69
|
+
expect(findTabBySlotId(root, 's2')).not.toBeNull();
|
|
70
|
+
});
|
|
71
|
+
it('works when root is a slot leaf', () => {
|
|
72
|
+
const root = {
|
|
73
|
+
type: 'slot',
|
|
74
|
+
slotId: 'leaf',
|
|
75
|
+
viewId: 'v',
|
|
76
|
+
};
|
|
77
|
+
const entry = { slotId: 's2', viewId: 'v2', label: 'Two' };
|
|
78
|
+
splitNodeAtPath(root, [], entry, 'bottom');
|
|
79
|
+
expect(root.type).toBe('split');
|
|
80
|
+
if (root.type === 'split') {
|
|
81
|
+
for (const child of root.children) {
|
|
82
|
+
expect(child).not.toBe(root);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -5,7 +5,7 @@ import type { ViewHandle } from '../shards/types';
|
|
|
5
5
|
* the pool does not know which wrapper owns the host at any given time,
|
|
6
6
|
* and that is intentional. The same host may be passed around.
|
|
7
7
|
*/
|
|
8
|
-
export declare function acquireSlotHost(slotId: string, viewId: string | null, label: string): HTMLDivElement;
|
|
8
|
+
export declare function acquireSlotHost(slotId: string, viewId: string | null, label: string, meta?: Record<string, unknown>): HTMLDivElement;
|
|
9
9
|
/**
|
|
10
10
|
* Release the pooled host. If this was the last reference, a
|
|
11
11
|
* destruction is queued to run in a microtask; a later acquire before
|
|
@@ -34,3 +34,18 @@ export declare function isSlotDirty(slotId: string): boolean;
|
|
|
34
34
|
* re-render when the deferred mount sets the flag.
|
|
35
35
|
*/
|
|
36
36
|
export declare function isSlotClosable(slotId: string): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Test-only: set the closable policy for a slot without going through a
|
|
39
|
+
* ViewFactory. Call BEFORE launchApp so the tab strip reads the right
|
|
40
|
+
* closable state when it renders.
|
|
41
|
+
*
|
|
42
|
+
* `true` — tab shows a close button and is removed on click.
|
|
43
|
+
* `false` — tab shows no close button (non-closable).
|
|
44
|
+
*/
|
|
45
|
+
export declare function setSlotClosableForTest(slotId: string, closable: boolean): void;
|
|
46
|
+
/**
|
|
47
|
+
* Test-only: attach a canClose guard to a slot. The slot must have been
|
|
48
|
+
* marked closable via `setSlotClosableForTest` first (or the guard will
|
|
49
|
+
* be installed alongside a `true` closable flag).
|
|
50
|
+
*/
|
|
51
|
+
export declare function setSlotCanCloseForTest(slotId: string, canClose: () => Promise<boolean>): void;
|
|
@@ -32,9 +32,59 @@
|
|
|
32
32
|
* edge-case escape hatch reserved by the design. Not wired yet —
|
|
33
33
|
* phase 6 has no view that needs it.
|
|
34
34
|
*/
|
|
35
|
-
import { getView } from '../shards/registry';
|
|
35
|
+
import { getView, __addViewRegistrationListener } from '../shards/registry';
|
|
36
36
|
const pool = new Map();
|
|
37
37
|
const pendingDestroy = new Set();
|
|
38
|
+
/**
|
|
39
|
+
* Called by the registry whenever a new ViewFactory is registered.
|
|
40
|
+
* Scans the pool for entries that have the matching viewId but were not
|
|
41
|
+
* mounted (because the factory wasn't available at acquire-time). Mounts
|
|
42
|
+
* them now.
|
|
43
|
+
*
|
|
44
|
+
* This handles the "late factory registration" case: a shard activates
|
|
45
|
+
* after the layout has already acquired a slot for its view. Rather than
|
|
46
|
+
* requiring the layout to re-acquire, the pool retries the mount here.
|
|
47
|
+
*/
|
|
48
|
+
function onViewRegistered(viewId, factory) {
|
|
49
|
+
for (const [slotId, entry] of pool.entries()) {
|
|
50
|
+
if (entry.viewId !== viewId || entry.handle !== undefined)
|
|
51
|
+
continue;
|
|
52
|
+
// Entry is in the pool, matches the view, and has no handle yet —
|
|
53
|
+
// the factory wasn't available when the microtask ran. Mount now.
|
|
54
|
+
const ctx = {
|
|
55
|
+
slotId,
|
|
56
|
+
viewId,
|
|
57
|
+
label: entry.label,
|
|
58
|
+
meta: entry.meta,
|
|
59
|
+
setDirty(dirty) {
|
|
60
|
+
dirtyState[slotId] = dirty;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
queueMicrotask(() => {
|
|
64
|
+
var _a, _b;
|
|
65
|
+
// Re-check: entry may have been released before this microtask fires.
|
|
66
|
+
if (!pool.has(slotId))
|
|
67
|
+
return;
|
|
68
|
+
if (entry.handle !== undefined)
|
|
69
|
+
return; // already mounted by a race
|
|
70
|
+
entry.handle = factory.mount(entry.host, ctx);
|
|
71
|
+
if (((_a = entry.handle) === null || _a === void 0 ? void 0 : _a.closable) || slotId.startsWith('float:')) {
|
|
72
|
+
closableState[slotId] = true;
|
|
73
|
+
}
|
|
74
|
+
if ((_b = entry.handle) === null || _b === void 0 ? void 0 : _b.onResize) {
|
|
75
|
+
const onResize = entry.handle.onResize.bind(entry.handle);
|
|
76
|
+
entry.resizeObserver = new ResizeObserver((entries) => {
|
|
77
|
+
for (const e of entries) {
|
|
78
|
+
const box = e.contentRect;
|
|
79
|
+
onResize(Math.round(box.width), Math.round(box.height));
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
entry.resizeObserver.observe(entry.host);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
__addViewRegistrationListener(onViewRegistered);
|
|
38
88
|
/**
|
|
39
89
|
* Reactive dirty-state map. Keyed by slotId, values are $state so
|
|
40
90
|
* Svelte tracks reads in `isSlotDirty()` and re-renders the tab strip
|
|
@@ -75,7 +125,7 @@ const closableState = $state({});
|
|
|
75
125
|
* is destroyed before its deferred mount ever runs (e.g. rapid
|
|
76
126
|
* add-then-remove of a slot during a drag).
|
|
77
127
|
*/
|
|
78
|
-
function createHost(slotId, viewId, label) {
|
|
128
|
+
function createHost(slotId, viewId, label, meta) {
|
|
79
129
|
const host = document.createElement('div');
|
|
80
130
|
host.className = 'slot-host';
|
|
81
131
|
host.dataset.slotId = slotId;
|
|
@@ -93,6 +143,7 @@ function createHost(slotId, viewId, label) {
|
|
|
93
143
|
handle: undefined,
|
|
94
144
|
viewId,
|
|
95
145
|
label,
|
|
146
|
+
meta,
|
|
96
147
|
refcount: 0,
|
|
97
148
|
resizeObserver: undefined,
|
|
98
149
|
cancelPendingMount: () => {
|
|
@@ -108,12 +159,13 @@ function createHost(slotId, viewId, label) {
|
|
|
108
159
|
slotId,
|
|
109
160
|
viewId: viewId !== null && viewId !== void 0 ? viewId : '',
|
|
110
161
|
label,
|
|
162
|
+
meta,
|
|
111
163
|
setDirty(dirty) {
|
|
112
164
|
dirtyState[slotId] = dirty;
|
|
113
165
|
},
|
|
114
166
|
};
|
|
115
167
|
entry.handle = factory === null || factory === void 0 ? void 0 : factory.mount(host, ctx);
|
|
116
|
-
if ((_a = entry.handle) === null || _a === void 0 ? void 0 : _a.closable) {
|
|
168
|
+
if (((_a = entry.handle) === null || _a === void 0 ? void 0 : _a.closable) || slotId.startsWith('float:')) {
|
|
117
169
|
closableState[slotId] = true;
|
|
118
170
|
}
|
|
119
171
|
// The pool owns the ResizeObserver so its lifetime matches the
|
|
@@ -140,13 +192,13 @@ function createHost(slotId, viewId, label) {
|
|
|
140
192
|
* the pool does not know which wrapper owns the host at any given time,
|
|
141
193
|
* and that is intentional. The same host may be passed around.
|
|
142
194
|
*/
|
|
143
|
-
export function acquireSlotHost(slotId, viewId, label) {
|
|
195
|
+
export function acquireSlotHost(slotId, viewId, label, meta) {
|
|
144
196
|
// If the slot was about to be destroyed, cancel — this acquire is the
|
|
145
197
|
// "other half" of a re-parent (teardown was the previous container).
|
|
146
198
|
pendingDestroy.delete(slotId);
|
|
147
199
|
let entry = pool.get(slotId);
|
|
148
200
|
if (!entry) {
|
|
149
|
-
entry = createHost(slotId, viewId, label);
|
|
201
|
+
entry = createHost(slotId, viewId, label, meta);
|
|
150
202
|
pool.set(slotId, entry);
|
|
151
203
|
}
|
|
152
204
|
entry.refcount++;
|
|
@@ -162,8 +214,16 @@ export function releaseSlotHost(slotId) {
|
|
|
162
214
|
if (!entry)
|
|
163
215
|
return;
|
|
164
216
|
entry.refcount--;
|
|
165
|
-
if (entry.refcount > 0)
|
|
217
|
+
if (entry.refcount > 0) {
|
|
218
|
+
// Refcount is still > 0 (e.g. acquireAppSlotHolds holds a ref), but
|
|
219
|
+
// the renderer releasing this slot is done with it. Detach the host
|
|
220
|
+
// from its current DOM parent so it doesn't remain visible in the old
|
|
221
|
+
// SlotContainer. The pool entry (and view) stays alive for re-acquisition
|
|
222
|
+
// — for example, a preset switch back to this slot's preset will re-append
|
|
223
|
+
// the host to a new SlotContainer without destroying the view.
|
|
224
|
+
entry.host.remove();
|
|
166
225
|
return;
|
|
226
|
+
}
|
|
167
227
|
pendingDestroy.add(slotId);
|
|
168
228
|
queueMicrotask(() => {
|
|
169
229
|
var _a, _b;
|
|
@@ -200,6 +260,7 @@ export function resetSlotHostPool() {
|
|
|
200
260
|
delete dirtyState[key];
|
|
201
261
|
for (const key of Object.keys(closableState))
|
|
202
262
|
delete closableState[key];
|
|
263
|
+
handleOverrides.clear();
|
|
203
264
|
}
|
|
204
265
|
/**
|
|
205
266
|
* Read the current ViewHandle for a slot. Returns undefined if the slot
|
|
@@ -208,6 +269,9 @@ export function resetSlotHostPool() {
|
|
|
208
269
|
*/
|
|
209
270
|
export function getSlotHandle(slotId) {
|
|
210
271
|
var _a;
|
|
272
|
+
const override = handleOverrides.get(slotId);
|
|
273
|
+
if (override)
|
|
274
|
+
return override;
|
|
211
275
|
return (_a = pool.get(slotId)) === null || _a === void 0 ? void 0 : _a.handle;
|
|
212
276
|
}
|
|
213
277
|
/**
|
|
@@ -225,5 +289,59 @@ export function isSlotDirty(slotId) {
|
|
|
225
289
|
*/
|
|
226
290
|
export function isSlotClosable(slotId) {
|
|
227
291
|
var _a;
|
|
292
|
+
if (handleOverrides.has(slotId)) {
|
|
293
|
+
const h = handleOverrides.get(slotId);
|
|
294
|
+
return !!h.closable;
|
|
295
|
+
}
|
|
228
296
|
return (_a = closableState[slotId]) !== null && _a !== void 0 ? _a : false;
|
|
229
297
|
}
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Test-only handle override map — lets tests inject closable/canClose policy
|
|
300
|
+
// without needing a real ViewFactory. Not exported from src/index.ts.
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
/**
|
|
303
|
+
* Map of slotId → synthetic ViewHandle injected by tests. These take
|
|
304
|
+
* priority over the pool's real handles in `getSlotHandle` and
|
|
305
|
+
* `isSlotClosable`. Cleared by `resetSlotHostPool`.
|
|
306
|
+
*/
|
|
307
|
+
const handleOverrides = new Map();
|
|
308
|
+
/**
|
|
309
|
+
* Test-only: set the closable policy for a slot without going through a
|
|
310
|
+
* ViewFactory. Call BEFORE launchApp so the tab strip reads the right
|
|
311
|
+
* closable state when it renders.
|
|
312
|
+
*
|
|
313
|
+
* `true` — tab shows a close button and is removed on click.
|
|
314
|
+
* `false` — tab shows no close button (non-closable).
|
|
315
|
+
*/
|
|
316
|
+
export function setSlotClosableForTest(slotId, closable) {
|
|
317
|
+
const existing = handleOverrides.get(slotId);
|
|
318
|
+
if (existing) {
|
|
319
|
+
existing.closable = closable;
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
handleOverrides.set(slotId, { unmount() { }, closable });
|
|
323
|
+
}
|
|
324
|
+
// Keep reactive closableState in sync so the tab strip re-renders.
|
|
325
|
+
if (closable) {
|
|
326
|
+
closableState[slotId] = true;
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
delete closableState[slotId];
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Test-only: attach a canClose guard to a slot. The slot must have been
|
|
334
|
+
* marked closable via `setSlotClosableForTest` first (or the guard will
|
|
335
|
+
* be installed alongside a `true` closable flag).
|
|
336
|
+
*/
|
|
337
|
+
export function setSlotCanCloseForTest(slotId, canClose) {
|
|
338
|
+
const existing = handleOverrides.get(slotId);
|
|
339
|
+
if (existing) {
|
|
340
|
+
existing.closable = { canClose };
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
handleOverrides.set(slotId, { unmount() { }, closable: { canClose } });
|
|
344
|
+
}
|
|
345
|
+
// Guarded slots are still considered "closable" for tab strip rendering.
|
|
346
|
+
closableState[slotId] = true;
|
|
347
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { tick } from 'svelte';
|
|
3
|
+
import { resetFramework } from '../__test__/reset';
|
|
4
|
+
import { acquireSlotHost, releaseSlotHost } from './slotHostPool.svelte';
|
|
5
|
+
import { registerView } from '../shards/registry';
|
|
6
|
+
import SlotContainer from './SlotContainer.svelte';
|
|
7
|
+
import { renderWithShell } from '../__test__/render';
|
|
8
|
+
import { switchToHome, switchToApp } from './store.svelte';
|
|
9
|
+
import { makeApp, makeAppManifest, makeSlotNode, makeTree } from '../__test__/fixtures';
|
|
10
|
+
import { registerApp } from '../apps/registry.svelte';
|
|
11
|
+
import { launchApp } from '../apps/lifecycle';
|
|
12
|
+
// ─── D.1 ─────────────────────────────────────────────────────────────────────
|
|
13
|
+
describe('slotHostPool — D.1 refcount destroys once', () => {
|
|
14
|
+
beforeEach(resetFramework);
|
|
15
|
+
it('runs the factory teardown exactly once after the final release, in a microtask', async () => {
|
|
16
|
+
const teardown = vi.fn();
|
|
17
|
+
// ViewFactory is an object: { mount(el, ctx): ViewHandle }
|
|
18
|
+
// ViewHandle has unmount(), NOT dispose()
|
|
19
|
+
registerView('v', {
|
|
20
|
+
mount: () => ({
|
|
21
|
+
unmount: teardown,
|
|
22
|
+
}),
|
|
23
|
+
});
|
|
24
|
+
acquireSlotHost('s1', 'v', 's1');
|
|
25
|
+
acquireSlotHost('s1', 'v', 's1');
|
|
26
|
+
releaseSlotHost('s1');
|
|
27
|
+
await Promise.resolve();
|
|
28
|
+
expect(teardown).not.toHaveBeenCalled();
|
|
29
|
+
releaseSlotHost('s1');
|
|
30
|
+
await Promise.resolve();
|
|
31
|
+
expect(teardown).toHaveBeenCalledTimes(1);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
// ─── D.2 ─────────────────────────────────────────────────────────────────────
|
|
35
|
+
describe('slotHostPool — D.2 re-acquire cancels destroy', () => {
|
|
36
|
+
beforeEach(resetFramework);
|
|
37
|
+
it('does not destroy the host when released and re-acquired in the same microtask', async () => {
|
|
38
|
+
const teardown = vi.fn();
|
|
39
|
+
registerView('v', {
|
|
40
|
+
mount: () => ({
|
|
41
|
+
unmount: teardown,
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
acquireSlotHost('s2', 'v', 's2');
|
|
45
|
+
releaseSlotHost('s2');
|
|
46
|
+
acquireSlotHost('s2', 'v', 's2');
|
|
47
|
+
await Promise.resolve();
|
|
48
|
+
expect(teardown).not.toHaveBeenCalled();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
// ─── D.3 ─────────────────────────────────────────────────────────────────────
|
|
52
|
+
describe('slotHostPool — D.3 late factory registration', () => {
|
|
53
|
+
beforeEach(resetFramework);
|
|
54
|
+
it('mounts the view when a factory registers after acquireSlotHost', async () => {
|
|
55
|
+
const mount = vi.fn((el) => {
|
|
56
|
+
const span = document.createElement('span');
|
|
57
|
+
span.dataset.mountedFor = 'late';
|
|
58
|
+
el.appendChild(span);
|
|
59
|
+
return {
|
|
60
|
+
unmount: () => span.remove(),
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
acquireSlotHost('late-slot', 'late:view', 'late-slot');
|
|
64
|
+
await Promise.resolve();
|
|
65
|
+
registerView('late:view', { mount });
|
|
66
|
+
await Promise.resolve();
|
|
67
|
+
expect(mount).toHaveBeenCalledTimes(1);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
// ─── D.4 ─────────────────────────────────────────────────────────────────────
|
|
71
|
+
describe('slotHostPool — D.4 shared host across containers', () => {
|
|
72
|
+
beforeEach(resetFramework);
|
|
73
|
+
it('mounts the factory once across two SlotContainers for the same slotId', async () => {
|
|
74
|
+
const mount = vi.fn((_el) => ({ unmount: () => { } }));
|
|
75
|
+
registerView('shared:view', { mount });
|
|
76
|
+
// SlotNode for both containers — same slotId, same viewId
|
|
77
|
+
const node = { type: 'slot', slotId: 'shared', viewId: 'shared:view' };
|
|
78
|
+
renderWithShell(SlotContainer, { node, label: 'A' });
|
|
79
|
+
renderWithShell(SlotContainer, { node, label: 'B' });
|
|
80
|
+
await tick();
|
|
81
|
+
expect(mount).toHaveBeenCalledTimes(1);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
// ─── D.5 ─────────────────────────────────────────────────────────────────────
|
|
85
|
+
describe('slotHostPool — D.5 root swap preserves app slots', () => {
|
|
86
|
+
beforeEach(resetFramework);
|
|
87
|
+
it('does not destroy app-held pooled hosts when swapping app to home and back', async () => {
|
|
88
|
+
const teardown = vi.fn();
|
|
89
|
+
registerView('persist:view', { mount: () => ({ unmount: teardown }) });
|
|
90
|
+
registerApp(makeApp({
|
|
91
|
+
manifest: makeAppManifest({ id: 'd5' }),
|
|
92
|
+
initialLayout: [
|
|
93
|
+
{ name: 'default', tree: makeTree(makeSlotNode('d5-slot', 'persist:view')) },
|
|
94
|
+
],
|
|
95
|
+
}));
|
|
96
|
+
await launchApp('d5');
|
|
97
|
+
await Promise.resolve();
|
|
98
|
+
switchToHome();
|
|
99
|
+
await Promise.resolve();
|
|
100
|
+
switchToApp();
|
|
101
|
+
await Promise.resolve();
|
|
102
|
+
expect(teardown).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -7,6 +7,22 @@ import type { App } from '../apps/types';
|
|
|
7
7
|
* Does NOT switch the active root. Call switchToApp() separately.
|
|
8
8
|
*/
|
|
9
9
|
export declare function attachApp(app: App): void;
|
|
10
|
+
/**
|
|
11
|
+
* Second-phase attach: take refcount holds on every slot in the active
|
|
12
|
+
* preset's tree. Must run AFTER required shards have activated (and
|
|
13
|
+
* therefore registered their view factories), so the pool's microtask
|
|
14
|
+
* factory lookup sees them. See the refcount-hold discipline comment at
|
|
15
|
+
* the top of this module and the createHost microtask in slotHostPool.
|
|
16
|
+
*
|
|
17
|
+
* TODO(preset-switch leak): holds are taken for the INITIAL preset only
|
|
18
|
+
* and released only in detachApp. On presetManager.switch(), the old
|
|
19
|
+
* preset's slot hosts stay pool-resident with refcount >= 1 (view handle
|
|
20
|
+
* and ResizeObserver not torn down) for the lifetime of the app attach.
|
|
21
|
+
* Acceptable for small preset sets; revisit if presets become churnier.
|
|
22
|
+
* Proper fix: re-scope holds on preset switch, or make this first-frame
|
|
23
|
+
* only and let renderers own all refcounts.
|
|
24
|
+
*/
|
|
25
|
+
export declare function acquireAppSlotHolds(): void;
|
|
10
26
|
/**
|
|
11
27
|
* Detach the currently-attached app. Releases its refcount holds; the
|
|
12
28
|
* pool's microtask cleanup drops the pooled hosts if they also have no
|
|
@@ -41,3 +57,9 @@ export declare const layoutStore: {
|
|
|
41
57
|
readonly tree: LayoutTree;
|
|
42
58
|
readonly floats: FloatEntry[];
|
|
43
59
|
};
|
|
60
|
+
/**
|
|
61
|
+
* Test-only reset. Restores the layout store to its boot state: no app
|
|
62
|
+
* attached, active root = 'home'. Not exported from `src/index.ts` —
|
|
63
|
+
* tests import this submodule path directly.
|
|
64
|
+
*/
|
|
65
|
+
export declare function __resetLayoutStoreForTest(): void;
|
|
@@ -50,10 +50,10 @@ const HOME_LAYOUT = {
|
|
|
50
50
|
slotId: 'sh3core.home',
|
|
51
51
|
viewId: 'sh3core:home',
|
|
52
52
|
};
|
|
53
|
-
const HOME_TREE = {
|
|
53
|
+
const HOME_TREE = $state({
|
|
54
54
|
docked: HOME_LAYOUT,
|
|
55
55
|
floats: [],
|
|
56
|
-
};
|
|
56
|
+
});
|
|
57
57
|
let appEntry = $state(null);
|
|
58
58
|
let activeRoot = $state('home');
|
|
59
59
|
// ---------- read-side adapter helpers -------------------------------------
|
|
@@ -133,17 +133,40 @@ export function attachApp(app) {
|
|
|
133
133
|
workspace: defaultBlob,
|
|
134
134
|
});
|
|
135
135
|
const proxy = state.workspace;
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
136
|
+
// Create the entry with no slot holds yet; `acquireAppSlotHolds` does
|
|
137
|
+
// that as a second phase, after shards have had a chance to register
|
|
138
|
+
// their view factories. Binding the preset manager proxy happens here
|
|
139
|
+
// so shards can read/switch presets from their activate() hook.
|
|
140
|
+
appEntry = { appId: app.manifest.id, proxy, heldSlotIds: [] };
|
|
141
|
+
bindPresetBlob(proxy);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Second-phase attach: take refcount holds on every slot in the active
|
|
145
|
+
* preset's tree. Must run AFTER required shards have activated (and
|
|
146
|
+
* therefore registered their view factories), so the pool's microtask
|
|
147
|
+
* factory lookup sees them. See the refcount-hold discipline comment at
|
|
148
|
+
* the top of this module and the createHost microtask in slotHostPool.
|
|
149
|
+
*
|
|
150
|
+
* TODO(preset-switch leak): holds are taken for the INITIAL preset only
|
|
151
|
+
* and released only in detachApp. On presetManager.switch(), the old
|
|
152
|
+
* preset's slot hosts stay pool-resident with refcount >= 1 (view handle
|
|
153
|
+
* and ResizeObserver not torn down) for the lifetime of the app attach.
|
|
154
|
+
* Acceptable for small preset sets; revisit if presets become churnier.
|
|
155
|
+
* Proper fix: re-scope holds on preset switch, or make this first-frame
|
|
156
|
+
* only and let renderers own all refcounts.
|
|
157
|
+
*/
|
|
158
|
+
export function acquireAppSlotHolds() {
|
|
159
|
+
if (!appEntry) {
|
|
160
|
+
throw new Error('acquireAppSlotHolds: no app attached');
|
|
161
|
+
}
|
|
162
|
+
if (appEntry.heldSlotIds.length > 0)
|
|
163
|
+
return; // idempotent
|
|
164
|
+
const tree = currentTree(appEntry.proxy);
|
|
139
165
|
const refs = collectTreeSlotRefs(tree);
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
heldSlotIds.push(slotId);
|
|
166
|
+
for (const { slotId, viewId, label, meta } of refs) {
|
|
167
|
+
acquireSlotHost(slotId, viewId, label, meta);
|
|
168
|
+
appEntry.heldSlotIds.push(slotId);
|
|
144
169
|
}
|
|
145
|
-
appEntry = { appId: app.manifest.id, proxy, heldSlotIds };
|
|
146
|
-
bindPresetBlob(proxy);
|
|
147
170
|
}
|
|
148
171
|
/**
|
|
149
172
|
* Detach the currently-attached app. Releases its refcount holds; the
|
|
@@ -171,6 +194,33 @@ export function switchToApp() {
|
|
|
171
194
|
}
|
|
172
195
|
activeRoot = 'app';
|
|
173
196
|
}
|
|
197
|
+
// ---------- `layoutStore` back-compat shim -------------------------------
|
|
198
|
+
/**
|
|
199
|
+
* Reactive-derived active LayoutTree. Using `$derived.by` gives us a
|
|
200
|
+
* stable, memoized reactive node that downstream accessors
|
|
201
|
+
* (`layoutStore.root`, `.tree`, `.floats`) all subscribe to once —
|
|
202
|
+
* instead of re-running the proxy-read chain on every access.
|
|
203
|
+
*
|
|
204
|
+
* Reading `blob.activePreset` by name inside this derived is load-bearing:
|
|
205
|
+
* it registers the preset-name signal as a dependency, so
|
|
206
|
+
* `presetManager.switch()` invalidates the derived and triggers re-render.
|
|
207
|
+
* Do not collapse this into a plain function that only reads the preset
|
|
208
|
+
* object — the derived would still recompute, but consumers that cached
|
|
209
|
+
* the result via a different path could miss the invalidation.
|
|
210
|
+
*/
|
|
211
|
+
const activeTree = $derived.by(() => {
|
|
212
|
+
if (activeRoot === 'app' && appEntry) {
|
|
213
|
+
// Read activePreset by name explicitly so Svelte tracks this signal.
|
|
214
|
+
const blob = appEntry.proxy;
|
|
215
|
+
const presetName = blob.activePreset;
|
|
216
|
+
const preset = blob.presets[presetName];
|
|
217
|
+
if (!preset) {
|
|
218
|
+
throw new Error(`AppLayoutBlob active preset "${presetName}" not found in presets map`);
|
|
219
|
+
}
|
|
220
|
+
return preset.default;
|
|
221
|
+
}
|
|
222
|
+
return HOME_TREE;
|
|
223
|
+
});
|
|
174
224
|
/**
|
|
175
225
|
* The currently-rendered LayoutTree. LayoutRenderer reads this via
|
|
176
226
|
* layoutStore.tree. Home uses a framework constant; app reads the
|
|
@@ -178,9 +228,11 @@ export function switchToApp() {
|
|
|
178
228
|
* mutations from splitter/drag/ops reach the renderer unchanged).
|
|
179
229
|
*/
|
|
180
230
|
export function activeLayout() {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
231
|
+
// Delegates to the $derived.by so callers outside component contexts
|
|
232
|
+
// (e.g. inspection.ts, ops.ts) still read the correct tree. The $derived
|
|
233
|
+
// is recalculated whenever activeRoot, appEntry, or blob.activePreset
|
|
234
|
+
// changes, so these callers always see a fresh snapshot.
|
|
235
|
+
return activeTree;
|
|
184
236
|
}
|
|
185
237
|
export function getActiveRoot() {
|
|
186
238
|
return activeRoot;
|
|
@@ -189,7 +241,6 @@ export function getAttachedAppId() {
|
|
|
189
241
|
var _a;
|
|
190
242
|
return (_a = appEntry === null || appEntry === void 0 ? void 0 : appEntry.appId) !== null && _a !== void 0 ? _a : null;
|
|
191
243
|
}
|
|
192
|
-
// ---------- `layoutStore` back-compat shim -------------------------------
|
|
193
244
|
/**
|
|
194
245
|
* Preserved for callers that still read `layoutStore.root`. The `root`
|
|
195
246
|
* getter is an alias for `tree.docked` so existing callers still compile
|
|
@@ -204,12 +255,23 @@ export function getAttachedAppId() {
|
|
|
204
255
|
*/
|
|
205
256
|
export const layoutStore = {
|
|
206
257
|
get root() {
|
|
207
|
-
return
|
|
258
|
+
return activeTree.docked;
|
|
208
259
|
},
|
|
209
260
|
get tree() {
|
|
210
|
-
return
|
|
261
|
+
return activeTree;
|
|
211
262
|
},
|
|
212
263
|
get floats() {
|
|
213
|
-
return
|
|
264
|
+
return activeTree.floats;
|
|
214
265
|
},
|
|
215
266
|
};
|
|
267
|
+
/**
|
|
268
|
+
* Test-only reset. Restores the layout store to its boot state: no app
|
|
269
|
+
* attached, active root = 'home'. Not exported from `src/index.ts` —
|
|
270
|
+
* tests import this submodule path directly.
|
|
271
|
+
*/
|
|
272
|
+
export function __resetLayoutStoreForTest() {
|
|
273
|
+
appEntry = null;
|
|
274
|
+
activeRoot = 'home';
|
|
275
|
+
HOME_TREE.floats.length = 0;
|
|
276
|
+
HOME_TREE.docked = HOME_LAYOUT;
|
|
277
|
+
}
|
|
@@ -12,6 +12,7 @@ export declare function collectSlotRefs(tree: LayoutNode): {
|
|
|
12
12
|
slotId: string;
|
|
13
13
|
viewId: string | null;
|
|
14
14
|
label: string;
|
|
15
|
+
meta?: Record<string, unknown>;
|
|
15
16
|
}[];
|
|
16
17
|
/**
|
|
17
18
|
* Multi-root version of `collectSlotRefs`: walks the docked tree first
|
|
@@ -23,4 +24,5 @@ export declare function collectTreeSlotRefs(tree: LayoutTree): {
|
|
|
23
24
|
slotId: string;
|
|
24
25
|
viewId: string | null;
|
|
25
26
|
label: string;
|
|
27
|
+
meta?: Record<string, unknown>;
|
|
26
28
|
}[];
|
package/dist/layout/tree-walk.js
CHANGED
|
@@ -20,7 +20,7 @@ export function collectSlotRefs(tree) {
|
|
|
20
20
|
}
|
|
21
21
|
if (node.type === 'tabs') {
|
|
22
22
|
for (const t of node.tabs) {
|
|
23
|
-
out.push({ slotId: t.slotId, viewId: t.viewId, label: t.label });
|
|
23
|
+
out.push({ slotId: t.slotId, viewId: t.viewId, label: t.label, meta: t.meta });
|
|
24
24
|
}
|
|
25
25
|
return;
|
|
26
26
|
}
|
package/dist/layout/types.d.ts
CHANGED
|
@@ -38,6 +38,11 @@ export interface TabEntry {
|
|
|
38
38
|
label: string;
|
|
39
39
|
/** Optional icon hint (not yet rendered in phase 8). */
|
|
40
40
|
icon?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Caller-supplied instance data, threaded to `MountContext.meta`.
|
|
43
|
+
* Ephemeral — not serialized with the layout tree.
|
|
44
|
+
*/
|
|
45
|
+
meta?: Record<string, unknown>;
|
|
41
46
|
}
|
|
42
47
|
/**
|
|
43
48
|
* A layout node that groups one or more slots as tabs, showing one at a time.
|