sh3-core 0.19.1 → 0.19.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/Sh3.svelte +3 -1
- package/dist/actions/menuBarModel.js +8 -0
- package/dist/actions/menuBarModel.test.js +61 -0
- package/dist/api.d.ts +4 -0
- package/dist/api.js +3 -0
- package/dist/app/admin/ApiKeysView.svelte +6 -5
- package/dist/app/store/PermissionConfirmModal.svelte +23 -0
- package/dist/app/store/PermissionConfirmModal.svelte.d.ts +1 -0
- package/dist/app/store/StoreView.svelte +6 -1
- package/dist/chrome/CompactChrome.svelte +34 -1
- package/dist/chrome/CompactChrome.svelte.test.js +11 -6
- package/dist/chrome/FloatsSheet.svelte +236 -0
- package/dist/chrome/FloatsSheet.svelte.d.ts +7 -0
- package/dist/chrome/FloatsSheet.svelte.test.d.ts +1 -0
- package/dist/chrome/FloatsSheet.svelte.test.js +155 -0
- package/dist/env/client.d.ts +5 -4
- package/dist/env/client.js +11 -17
- package/dist/env/serverUrl.d.ts +2 -0
- package/dist/env/serverUrl.js +8 -0
- package/dist/gestures/index.d.ts +17 -0
- package/dist/gestures/index.js +27 -0
- package/dist/keys/client.js +6 -7
- package/dist/keys/revocation-bus.svelte.js +11 -1
- package/dist/layout/compact/CarouselTabs.svelte +150 -14
- package/dist/layout/compact/CarouselTabs.svelte.test.js +222 -2
- package/dist/layout/compact/CompactRenderer.svelte +9 -3
- package/dist/layout/compact/CompactRenderer.svelte.test.js +5 -3
- package/dist/layout/compact/derive.js +7 -16
- package/dist/layout/compact/derive.test.js +30 -9
- package/dist/layout/compact/rootStore.svelte.d.ts +20 -0
- package/dist/layout/compact/rootStore.svelte.js +59 -0
- package/dist/layout/compact/rootStore.svelte.test.d.ts +1 -0
- package/dist/layout/compact/rootStore.svelte.test.js +54 -0
- package/dist/layout/drag.svelte.js +16 -3
- package/dist/layout/floats.d.ts +27 -0
- package/dist/layout/floats.js +20 -0
- package/dist/layout/floats.test.js +34 -1
- package/dist/layout/inspection.d.ts +20 -9
- package/dist/layout/inspection.js +91 -13
- package/dist/layout/inspection.svelte.test.d.ts +1 -0
- package/dist/layout/inspection.svelte.test.js +163 -0
- package/dist/layout/store.schemaVersion.test.js +2 -2
- package/dist/layout/types.d.ts +11 -8
- package/dist/layout/types.js +1 -1
- package/dist/layout/types.test.js +2 -2
- package/dist/overlays/FloatFrame.svelte +93 -22
- package/dist/overlays/FloatLayer.svelte +12 -1
- package/dist/overlays/float.d.ts +7 -0
- package/dist/overlays/float.js +76 -6
- package/dist/overlays/float.test.js +170 -0
- package/dist/primitives/ResizableSplitter.svelte +42 -8
- package/dist/primitives/widgets/DocumentFilePicker.d.ts +25 -0
- package/dist/primitives/widgets/DocumentFilePicker.js +74 -0
- package/dist/primitives/widgets/DocumentFilePicker.svelte +144 -0
- package/dist/primitives/widgets/DocumentFilePicker.svelte.d.ts +18 -0
- package/dist/primitives/widgets/DocumentOpener.svelte +36 -0
- package/dist/primitives/widgets/DocumentOpener.svelte.d.ts +17 -0
- package/dist/primitives/widgets/DocumentSaver.svelte +36 -0
- package/dist/primitives/widgets/DocumentSaver.svelte.d.ts +17 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte +337 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +11 -0
- package/dist/registry/checkFetch.d.ts +6 -0
- package/dist/registry/checkFetch.js +23 -0
- package/dist/sh3/views/KeysAndPeers.svelte +4 -3
- package/dist/shards/activate-runtime.test.js +99 -1
- package/dist/shards/activate.svelte.js +12 -3
- package/dist/shards/registry.d.ts +8 -1
- package/dist/shards/registry.js +13 -2
- package/dist/shards/registry.test.js +25 -4
- package/dist/shards/types.d.ts +14 -1
- package/dist/shell-shard/ScrollbackView.svelte +145 -67
- package/dist/shell-shard/ScrollbackView.svelte.test.d.ts +1 -0
- package/dist/shell-shard/ScrollbackView.svelte.test.js +182 -0
- package/dist/shell-shard/dispatch-gating.test.js +38 -2
- package/dist/shell-shard/dispatch.js +9 -1
- package/dist/shell-shard/registry-resolve.test.js +50 -0
- package/dist/shell-shard/registry.d.ts +2 -1
- package/dist/shell-shard/registry.js +12 -2
- package/dist/shell-shard/verbs/help.js +5 -4
- package/dist/shell-shard/verbs/help.svelte.test.js +5 -2
- package/dist/verbs/types.d.ts +10 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/layout/floats.d.ts
CHANGED
|
@@ -23,6 +23,33 @@ export declare function cascadePosition(existing: FloatEntry[], bounds: {
|
|
|
23
23
|
};
|
|
24
24
|
/** Stable, process-unique float id. Not cryptographic — just unique within a session. */
|
|
25
25
|
export declare function generateFloatId(): string;
|
|
26
|
+
/**
|
|
27
|
+
* Pull a float's rect into the supplied viewport bounds. Used at bind
|
|
28
|
+
* time so a float persisted from a larger viewport doesn't render past
|
|
29
|
+
* the overlay root — Firefox in particular grows the parent's painted
|
|
30
|
+
* area to fit an off-screen abspos child, which visibly bleeds the
|
|
31
|
+
* docked grid (footer ends up below the viewport).
|
|
32
|
+
*
|
|
33
|
+
* Size is shrunk to fit but never below `minSize`; if `minSize` itself
|
|
34
|
+
* exceeds bounds, position is pinned to (0,0) and size stays at min.
|
|
35
|
+
* Position is then clamped so the frame fits within bounds.
|
|
36
|
+
*/
|
|
37
|
+
export declare function clampFloatToViewport(rect: {
|
|
38
|
+
position: {
|
|
39
|
+
x: number;
|
|
40
|
+
y: number;
|
|
41
|
+
};
|
|
42
|
+
size: Size;
|
|
43
|
+
}, minSize: Size, bounds: {
|
|
44
|
+
w: number;
|
|
45
|
+
h: number;
|
|
46
|
+
}): {
|
|
47
|
+
position: {
|
|
48
|
+
x: number;
|
|
49
|
+
y: number;
|
|
50
|
+
};
|
|
51
|
+
size: Size;
|
|
52
|
+
};
|
|
26
53
|
/**
|
|
27
54
|
* True if a LayoutNode subtree contains no leaf slot with a bound viewId.
|
|
28
55
|
* Used by the drag-commit auto-close invariant: when the last bound leaf
|
package/dist/layout/floats.js
CHANGED
|
@@ -55,6 +55,26 @@ export function generateFloatId() {
|
|
|
55
55
|
floatIdCounter += 1;
|
|
56
56
|
return `float-${Date.now().toString(36)}-${floatIdCounter.toString(36)}`;
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Pull a float's rect into the supplied viewport bounds. Used at bind
|
|
60
|
+
* time so a float persisted from a larger viewport doesn't render past
|
|
61
|
+
* the overlay root — Firefox in particular grows the parent's painted
|
|
62
|
+
* area to fit an off-screen abspos child, which visibly bleeds the
|
|
63
|
+
* docked grid (footer ends up below the viewport).
|
|
64
|
+
*
|
|
65
|
+
* Size is shrunk to fit but never below `minSize`; if `minSize` itself
|
|
66
|
+
* exceeds bounds, position is pinned to (0,0) and size stays at min.
|
|
67
|
+
* Position is then clamped so the frame fits within bounds.
|
|
68
|
+
*/
|
|
69
|
+
export function clampFloatToViewport(rect, minSize, bounds) {
|
|
70
|
+
const w = Math.max(minSize.w, Math.min(rect.size.w, bounds.w));
|
|
71
|
+
const h = Math.max(minSize.h, Math.min(rect.size.h, bounds.h));
|
|
72
|
+
const maxX = Math.max(0, bounds.w - w);
|
|
73
|
+
const maxY = Math.max(0, bounds.h - h);
|
|
74
|
+
const x = Math.max(0, Math.min(rect.position.x, maxX));
|
|
75
|
+
const y = Math.max(0, Math.min(rect.position.y, maxY));
|
|
76
|
+
return { position: { x, y }, size: { w, h } };
|
|
77
|
+
}
|
|
58
78
|
/**
|
|
59
79
|
* True if a LayoutNode subtree contains no leaf slot with a bound viewId.
|
|
60
80
|
* Used by the drag-commit auto-close invariant: when the last bound leaf
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { computeMinSize, cascadePosition, isEmptyContent } from './floats';
|
|
2
|
+
import { computeMinSize, cascadePosition, isEmptyContent, clampFloatToViewport } from './floats';
|
|
3
3
|
const slot = (slotId, viewId = 'v') => ({
|
|
4
4
|
type: 'slot',
|
|
5
5
|
slotId,
|
|
@@ -72,6 +72,39 @@ describe('cascadePosition', () => {
|
|
|
72
72
|
expect(cascadePosition(existing, bounds)).toEqual({ x: 48, y: 48 });
|
|
73
73
|
});
|
|
74
74
|
});
|
|
75
|
+
describe('clampFloatToViewport', () => {
|
|
76
|
+
const min = { w: 120, h: 80 };
|
|
77
|
+
const bounds = { w: 1024, h: 768 };
|
|
78
|
+
it('returns the rect unchanged when fully inside bounds', () => {
|
|
79
|
+
const out = clampFloatToViewport({ position: { x: 100, y: 200 }, size: { w: 600, h: 400 } }, min, bounds);
|
|
80
|
+
expect(out).toEqual({ position: { x: 100, y: 200 }, size: { w: 600, h: 400 } });
|
|
81
|
+
});
|
|
82
|
+
it('pulls a float that extends past the right edge back inside', () => {
|
|
83
|
+
const out = clampFloatToViewport({ position: { x: 900, y: 50 }, size: { w: 600, h: 400 } }, min, bounds);
|
|
84
|
+
expect(out.position.x).toBe(bounds.w - 600);
|
|
85
|
+
expect(out.position.y).toBe(50);
|
|
86
|
+
expect(out.size).toEqual({ w: 600, h: 400 });
|
|
87
|
+
});
|
|
88
|
+
it('pulls a float that extends past the bottom edge back inside', () => {
|
|
89
|
+
const out = clampFloatToViewport({ position: { x: 50, y: 600 }, size: { w: 600, h: 400 } }, min, bounds);
|
|
90
|
+
expect(out.position.y).toBe(bounds.h - 400);
|
|
91
|
+
});
|
|
92
|
+
it('clamps negative position back to (0,0)', () => {
|
|
93
|
+
const out = clampFloatToViewport({ position: { x: -200, y: -50 }, size: { w: 600, h: 400 } }, min, bounds);
|
|
94
|
+
expect(out.position).toEqual({ x: 0, y: 0 });
|
|
95
|
+
});
|
|
96
|
+
it('shrinks size larger than bounds down to bounds (above min)', () => {
|
|
97
|
+
const out = clampFloatToViewport({ position: { x: 0, y: 0 }, size: { w: 4000, h: 3000 } }, min, bounds);
|
|
98
|
+
expect(out.size).toEqual({ w: bounds.w, h: bounds.h });
|
|
99
|
+
expect(out.position).toEqual({ x: 0, y: 0 });
|
|
100
|
+
});
|
|
101
|
+
it('never shrinks size below the supplied min, even when bounds < min', () => {
|
|
102
|
+
const tiny = { w: 80, h: 60 };
|
|
103
|
+
const out = clampFloatToViewport({ position: { x: 999, y: 999 }, size: { w: 600, h: 400 } }, min, tiny);
|
|
104
|
+
expect(out.size).toEqual(min);
|
|
105
|
+
expect(out.position).toEqual({ x: 0, y: 0 });
|
|
106
|
+
});
|
|
107
|
+
});
|
|
75
108
|
describe('isEmptyContent', () => {
|
|
76
109
|
it('true for a slot with null viewId', () => {
|
|
77
110
|
expect(isEmptyContent({ type: 'slot', slotId: 's', viewId: null })).toBe(true);
|
|
@@ -69,19 +69,30 @@ export declare function popoutView(slotId: string): string | null;
|
|
|
69
69
|
export declare function dockFloat(floatId: string): boolean;
|
|
70
70
|
/**
|
|
71
71
|
* Dock a view into the currently-rendered layout without caring which
|
|
72
|
-
* root it is. Used by the Ctrl+` sh3 hotkey
|
|
73
|
-
* somewhere sensible" callers. Policy:
|
|
72
|
+
* root it is. Used by the Ctrl+` sh3 hotkey, the `open <viewId>` verb,
|
|
73
|
+
* and other "just put it somewhere sensible" callers. Policy:
|
|
74
74
|
*
|
|
75
75
|
* 1. If a tab with the same `viewId` already exists, focus it and
|
|
76
76
|
* return. Callers don't want a second instance of a singleton view
|
|
77
77
|
* every time they hit the shortcut.
|
|
78
|
-
* 2.
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
78
|
+
* 2. Prefer a tabs group whose entries are explicitly flagged
|
|
79
|
+
* `role: 'body'` — that's the canonical "main content" target when
|
|
80
|
+
* authors marked it. This step matches user intent better than
|
|
81
|
+
* "first tabs group found" in layouts that pair a sidebar tabs
|
|
82
|
+
* group with a body tabs group.
|
|
83
|
+
* 3. Otherwise, prefer a standalone slot explicitly flagged
|
|
84
|
+
* `role: 'body'` — split it horizontally and place the new entry
|
|
85
|
+
* on the right (same shape as the generic slot fallback below).
|
|
86
|
+
* 4. Otherwise, append to the first tabs group found.
|
|
87
|
+
* 5. Otherwise, split the first slot leaf horizontally. This is the
|
|
88
|
+
* "floating window" fallback described in roadmap SH9 / DF3.
|
|
89
|
+
*
|
|
90
|
+
* Steps 2-3 use a *strict* role check (`=== 'body'`, not resolveRole).
|
|
91
|
+
* An unmarked slot defaults to body via resolveRole, but treating every
|
|
92
|
+
* unmarked slot as a body anchor would make step 3 swallow every
|
|
93
|
+
* standalone slot in the tree before step 4 ever got a chance — which
|
|
94
|
+
* inverts the documented fallback order. Only explicit body markers
|
|
95
|
+
* promote a node above the generic fallback.
|
|
85
96
|
*
|
|
86
97
|
* Returns true if the view was focused or inserted, false if the layout
|
|
87
98
|
* was empty or otherwise un-dockable.
|
|
@@ -16,6 +16,24 @@ import { activeLayout, getActiveRoot } from './store.svelte';
|
|
|
16
16
|
import { nodeAtPath, findTabBySlotId, removeTabBySlotId, cleanupTree, splitNodeAtPath, locateSlotIn, } from './ops';
|
|
17
17
|
import { getSlotHandle } from './slotHostPool.svelte';
|
|
18
18
|
import { floatManager } from '../overlays/float';
|
|
19
|
+
import { viewportStore } from '../viewport/store.svelte';
|
|
20
|
+
import { compactRootStore } from './compact/rootStore.svelte';
|
|
21
|
+
/**
|
|
22
|
+
* In compact mode the user sees one root at a time. When focus lands on
|
|
23
|
+
* a slot in a float, swap the compact body root to that float before
|
|
24
|
+
* returning. When focus lands in the docked tree, snap back to docked.
|
|
25
|
+
* Desktop is unaffected.
|
|
26
|
+
*/
|
|
27
|
+
function maybeSwapForCompact(located) {
|
|
28
|
+
if (viewportStore.current.class !== 'compact')
|
|
29
|
+
return;
|
|
30
|
+
if (located.kind === 'float') {
|
|
31
|
+
compactRootStore.setRoot({ kind: 'float', floatId: located.floatId });
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
compactRootStore.reset();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
19
37
|
/**
|
|
20
38
|
* Read-only snapshot of the currently-rendered layout tree. The return
|
|
21
39
|
* value is the live object — callers MUST NOT mutate it directly;
|
|
@@ -48,8 +66,10 @@ export function spliceIntoActiveLayout(entry) {
|
|
|
48
66
|
*/
|
|
49
67
|
export function focusTab(slotId) {
|
|
50
68
|
const tree = activeLayout();
|
|
51
|
-
if (focusTabWhere(tree.docked, (entry) => entry.slotId === slotId))
|
|
69
|
+
if (focusTabWhere(tree.docked, (entry) => entry.slotId === slotId)) {
|
|
70
|
+
maybeSwapForCompact({ kind: 'docked' });
|
|
52
71
|
return true;
|
|
72
|
+
}
|
|
53
73
|
return focusTabInFloats(tree, (entry) => entry.slotId === slotId);
|
|
54
74
|
}
|
|
55
75
|
/**
|
|
@@ -58,8 +78,10 @@ export function focusTab(slotId) {
|
|
|
58
78
|
*/
|
|
59
79
|
export function focusView(viewId) {
|
|
60
80
|
const tree = activeLayout();
|
|
61
|
-
if (focusTabWhere(tree.docked, (entry) => entry.viewId === viewId))
|
|
81
|
+
if (focusTabWhere(tree.docked, (entry) => entry.viewId === viewId)) {
|
|
82
|
+
maybeSwapForCompact({ kind: 'docked' });
|
|
62
83
|
return true;
|
|
84
|
+
}
|
|
63
85
|
return focusTabInFloats(tree, (entry) => entry.viewId === viewId);
|
|
64
86
|
}
|
|
65
87
|
/** Walk the tree looking for a tab entry that satisfies `pred`, activate it. */
|
|
@@ -85,6 +107,7 @@ function focusTabInFloats(tree, pred) {
|
|
|
85
107
|
for (const floatEntry of tree.floats) {
|
|
86
108
|
if (focusTabWhere(floatEntry.content, pred)) {
|
|
87
109
|
floatManager.focus(floatEntry.id);
|
|
110
|
+
maybeSwapForCompact({ kind: 'float', floatId: floatEntry.id });
|
|
88
111
|
return true;
|
|
89
112
|
}
|
|
90
113
|
}
|
|
@@ -274,19 +297,30 @@ function findFirstSlotPath(node, path = []) {
|
|
|
274
297
|
}
|
|
275
298
|
/**
|
|
276
299
|
* Dock a view into the currently-rendered layout without caring which
|
|
277
|
-
* root it is. Used by the Ctrl+` sh3 hotkey
|
|
278
|
-
* somewhere sensible" callers. Policy:
|
|
300
|
+
* root it is. Used by the Ctrl+` sh3 hotkey, the `open <viewId>` verb,
|
|
301
|
+
* and other "just put it somewhere sensible" callers. Policy:
|
|
279
302
|
*
|
|
280
303
|
* 1. If a tab with the same `viewId` already exists, focus it and
|
|
281
304
|
* return. Callers don't want a second instance of a singleton view
|
|
282
305
|
* every time they hit the shortcut.
|
|
283
|
-
* 2.
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
306
|
+
* 2. Prefer a tabs group whose entries are explicitly flagged
|
|
307
|
+
* `role: 'body'` — that's the canonical "main content" target when
|
|
308
|
+
* authors marked it. This step matches user intent better than
|
|
309
|
+
* "first tabs group found" in layouts that pair a sidebar tabs
|
|
310
|
+
* group with a body tabs group.
|
|
311
|
+
* 3. Otherwise, prefer a standalone slot explicitly flagged
|
|
312
|
+
* `role: 'body'` — split it horizontally and place the new entry
|
|
313
|
+
* on the right (same shape as the generic slot fallback below).
|
|
314
|
+
* 4. Otherwise, append to the first tabs group found.
|
|
315
|
+
* 5. Otherwise, split the first slot leaf horizontally. This is the
|
|
316
|
+
* "floating window" fallback described in roadmap SH9 / DF3.
|
|
317
|
+
*
|
|
318
|
+
* Steps 2-3 use a *strict* role check (`=== 'body'`, not resolveRole).
|
|
319
|
+
* An unmarked slot defaults to body via resolveRole, but treating every
|
|
320
|
+
* unmarked slot as a body anchor would make step 3 swallow every
|
|
321
|
+
* standalone slot in the tree before step 4 ever got a chance — which
|
|
322
|
+
* inverts the documented fallback order. Only explicit body markers
|
|
323
|
+
* promote a node above the generic fallback.
|
|
290
324
|
*
|
|
291
325
|
* Returns true if the view was focused or inserted, false if the layout
|
|
292
326
|
* was empty or otherwise un-dockable.
|
|
@@ -297,20 +331,64 @@ export function dockIntoActiveLayout(entry) {
|
|
|
297
331
|
// 1. Already present? Focus it.
|
|
298
332
|
if (focusView((_a = entry.viewId) !== null && _a !== void 0 ? _a : ''))
|
|
299
333
|
return true;
|
|
300
|
-
// 2.
|
|
334
|
+
// 2. A tabs group explicitly carrying body content wins.
|
|
335
|
+
const bodyTabs = findBodyTabsNode(root);
|
|
336
|
+
if (bodyTabs) {
|
|
337
|
+
bodyTabs.tabs.push(entry);
|
|
338
|
+
bodyTabs.activeTab = bodyTabs.tabs.length - 1;
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
// 3. A standalone slot explicitly marked as body is next.
|
|
342
|
+
const bodySlotPath = findBodySlotPath(root);
|
|
343
|
+
if (bodySlotPath) {
|
|
344
|
+
splitNodeAtPath(root, bodySlotPath, entry, 'right');
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
// 4. Existing tabs group wins (generic fallback).
|
|
301
348
|
const tabs = findFirstTabsNode(root);
|
|
302
349
|
if (tabs) {
|
|
303
350
|
tabs.tabs.push(entry);
|
|
304
351
|
tabs.activeTab = tabs.tabs.length - 1;
|
|
305
352
|
return true;
|
|
306
353
|
}
|
|
307
|
-
//
|
|
354
|
+
// 5. Fallback: split the first valid slot leaf.
|
|
308
355
|
const slotPath = findFirstSlotPath(root);
|
|
309
356
|
if (!slotPath)
|
|
310
357
|
return false;
|
|
311
358
|
splitNodeAtPath(root, slotPath, entry, 'right');
|
|
312
359
|
return true;
|
|
313
360
|
}
|
|
361
|
+
/**
|
|
362
|
+
* Find a tabs group explicitly flagged `role: 'body'`. Strict —
|
|
363
|
+
* defaulted body roles don't count, see the dockIntoActiveLayout
|
|
364
|
+
* docblock for why.
|
|
365
|
+
*/
|
|
366
|
+
function findBodyTabsNode(node) {
|
|
367
|
+
if (node.type === 'tabs') {
|
|
368
|
+
return node.role === 'body' ? node : null;
|
|
369
|
+
}
|
|
370
|
+
if (node.type === 'split') {
|
|
371
|
+
for (const c of node.children) {
|
|
372
|
+
const hit = findBodyTabsNode(c);
|
|
373
|
+
if (hit)
|
|
374
|
+
return hit;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
/** Path to the first slot leaf explicitly flagged `role: 'body'`. */
|
|
380
|
+
function findBodySlotPath(node, path = []) {
|
|
381
|
+
if (node.type === 'slot')
|
|
382
|
+
return node.role === 'body' ? path : null;
|
|
383
|
+
if (node.type === 'split') {
|
|
384
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
385
|
+
const hit = findBodySlotPath(node.children[i], [...path, i]);
|
|
386
|
+
if (hit)
|
|
387
|
+
return hit;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
314
392
|
/**
|
|
315
393
|
* Find which root a slot currently lives under in the active layout.
|
|
316
394
|
* Returns `{ kind: 'docked' }` when the slot is anywhere in the docked
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* dockIntoActiveLayout policy tests — verify the body-role preference
|
|
3
|
+
* lands before generic fallbacks. The `open <viewId>` verb routes
|
|
4
|
+
* through this function; getting the policy wrong means views land in
|
|
5
|
+
* sidebar tabs groups instead of body ones in apps that mark both.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
8
|
+
import { flushSync } from 'svelte';
|
|
9
|
+
import { attachApp, switchToApp, __resetLayoutStoreForTest, layoutStore, } from './store.svelte';
|
|
10
|
+
import { dockIntoActiveLayout } from './inspection';
|
|
11
|
+
let appCounter = 0;
|
|
12
|
+
function makeApp(initialLayout) {
|
|
13
|
+
// Unique id per call so workspace-zone storage from one test doesn't
|
|
14
|
+
// adapt-and-override another test's initialLayout via attachApp's
|
|
15
|
+
// version-gate path.
|
|
16
|
+
return {
|
|
17
|
+
manifest: { id: `test-app-${++appCounter}`, layoutVersion: 1 },
|
|
18
|
+
initialLayout,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function nextEntry(viewId) {
|
|
22
|
+
return { slotId: `s:${viewId}:${Math.random()}`, viewId, label: viewId };
|
|
23
|
+
}
|
|
24
|
+
describe('dockIntoActiveLayout — body-role preference', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
__resetLayoutStoreForTest();
|
|
27
|
+
});
|
|
28
|
+
it('appends into a tabs group flagged as body when one exists alongside a non-body tabs group', () => {
|
|
29
|
+
attachApp(makeApp({
|
|
30
|
+
type: 'split', direction: 'horizontal', sizes: [0.3, 0.7],
|
|
31
|
+
children: [
|
|
32
|
+
{
|
|
33
|
+
type: 'tabs',
|
|
34
|
+
role: 'sidebar',
|
|
35
|
+
tabs: [{ slotId: 'sb-1', viewId: 'sb:explorer', label: 'Explorer' }],
|
|
36
|
+
activeTab: 0,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
type: 'tabs',
|
|
40
|
+
role: 'body',
|
|
41
|
+
tabs: [{ slotId: 'b-1', viewId: 'body:editor', label: 'Editor' }],
|
|
42
|
+
activeTab: 0,
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
}));
|
|
46
|
+
switchToApp();
|
|
47
|
+
flushSync();
|
|
48
|
+
expect(dockIntoActiveLayout(nextEntry('body:newdoc'))).toBe(true);
|
|
49
|
+
const root = layoutStore.root;
|
|
50
|
+
const sidebar = root.children[0];
|
|
51
|
+
const body = root.children[1];
|
|
52
|
+
expect(sidebar.tabs.map((t) => t.viewId)).toEqual(['sb:explorer']);
|
|
53
|
+
expect(body.tabs.map((t) => t.viewId)).toEqual(['body:editor', 'body:newdoc']);
|
|
54
|
+
expect(body.activeTab).toBe(1);
|
|
55
|
+
});
|
|
56
|
+
it('splits a standalone slot flagged as body when no body tabs group exists', () => {
|
|
57
|
+
attachApp(makeApp({
|
|
58
|
+
type: 'split', direction: 'horizontal', sizes: [0.3, 0.7],
|
|
59
|
+
children: [
|
|
60
|
+
{ type: 'slot', slotId: 'sb', viewId: 'sb:explorer', role: 'sidebar' },
|
|
61
|
+
{ type: 'slot', slotId: 'main', viewId: 'body:editor', role: 'body' },
|
|
62
|
+
],
|
|
63
|
+
}));
|
|
64
|
+
switchToApp();
|
|
65
|
+
flushSync();
|
|
66
|
+
expect(dockIntoActiveLayout(nextEntry('body:newdoc'))).toBe(true);
|
|
67
|
+
const root = layoutStore.root;
|
|
68
|
+
// The body slot got split — its position in the parent is now a split node.
|
|
69
|
+
const right = root.children[1];
|
|
70
|
+
expect(right.type).toBe('split');
|
|
71
|
+
});
|
|
72
|
+
it('falls back to the first tabs group when no body-flagged target exists', () => {
|
|
73
|
+
attachApp(makeApp({
|
|
74
|
+
type: 'tabs',
|
|
75
|
+
tabs: [{ slotId: 'a', viewId: 'view:a', label: 'A' }],
|
|
76
|
+
activeTab: 0,
|
|
77
|
+
}));
|
|
78
|
+
switchToApp();
|
|
79
|
+
flushSync();
|
|
80
|
+
expect(dockIntoActiveLayout(nextEntry('view:b'))).toBe(true);
|
|
81
|
+
const root = layoutStore.root;
|
|
82
|
+
expect(root.tabs.map((t) => t.viewId)).toEqual(['view:a', 'view:b']);
|
|
83
|
+
expect(root.activeTab).toBe(1);
|
|
84
|
+
});
|
|
85
|
+
it('does NOT prefer a tabs group whose entries are only defaulted-to-body (strict role check)', () => {
|
|
86
|
+
// The "first tabs group" already wins the generic fallback in this
|
|
87
|
+
// shape — the assertion here is that body-preference doesn't kick in
|
|
88
|
+
// (which it would if we used resolveRole and treated unset as body)
|
|
89
|
+
// and accidentally promote a sibling tabs group above the first one.
|
|
90
|
+
attachApp(makeApp({
|
|
91
|
+
type: 'split', direction: 'horizontal', sizes: [0.5, 0.5],
|
|
92
|
+
children: [
|
|
93
|
+
{
|
|
94
|
+
type: 'tabs',
|
|
95
|
+
tabs: [{ slotId: 'l', viewId: 'view:l', label: 'L' }], // role unset
|
|
96
|
+
activeTab: 0,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
type: 'tabs',
|
|
100
|
+
tabs: [{ slotId: 'r', viewId: 'view:r', label: 'R' }], // role unset
|
|
101
|
+
activeTab: 0,
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
}));
|
|
105
|
+
switchToApp();
|
|
106
|
+
flushSync();
|
|
107
|
+
expect(dockIntoActiveLayout(nextEntry('view:new'))).toBe(true);
|
|
108
|
+
const root = layoutStore.root;
|
|
109
|
+
const left = root.children[0];
|
|
110
|
+
const right = root.children[1];
|
|
111
|
+
expect(left.tabs.map((t) => t.viewId)).toEqual(['view:l', 'view:new']);
|
|
112
|
+
expect(right.tabs.map((t) => t.viewId)).toEqual(['view:r']);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Compact body-root swap on focusView / focusTab
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
import { compactRootStore, __resetCompactRootStoreForTest, } from './compact/rootStore.svelte';
|
|
119
|
+
import { viewportStore } from '../viewport/store.svelte';
|
|
120
|
+
import { focusView } from './inspection';
|
|
121
|
+
import { floatManager, __resetFloatManagerForTest, bindFloatStore, } from '../overlays/float';
|
|
122
|
+
describe('focusView — compact body-root swap', () => {
|
|
123
|
+
beforeEach(() => {
|
|
124
|
+
__resetLayoutStoreForTest();
|
|
125
|
+
__resetCompactRootStoreForTest();
|
|
126
|
+
__resetFloatManagerForTest();
|
|
127
|
+
viewportStore.override(null);
|
|
128
|
+
bindFloatStore(layoutStore.floats, () => ({ w: 1024, h: 768 }));
|
|
129
|
+
});
|
|
130
|
+
it('compact: focusView in a float swaps body root before activating', () => {
|
|
131
|
+
viewportStore.override('compact');
|
|
132
|
+
const id = floatManager.open('view:in-float', { title: 'In Float' });
|
|
133
|
+
// open() in compact already auto-switches; reset so we can see the swap.
|
|
134
|
+
compactRootStore.reset();
|
|
135
|
+
expect(compactRootStore.current).toEqual({ kind: 'docked' });
|
|
136
|
+
const ok = focusView('view:in-float');
|
|
137
|
+
expect(ok).toBe(true);
|
|
138
|
+
expect(compactRootStore.current).toEqual({ kind: 'float', floatId: id });
|
|
139
|
+
viewportStore.override(null);
|
|
140
|
+
});
|
|
141
|
+
it('compact: focusView for a docked tab resets body root', () => {
|
|
142
|
+
viewportStore.override('compact');
|
|
143
|
+
layoutStore.tree.docked = {
|
|
144
|
+
type: 'tabs',
|
|
145
|
+
tabs: [{ slotId: 's-d', viewId: 'view:docked', label: 'Docked' }],
|
|
146
|
+
activeTab: 0,
|
|
147
|
+
};
|
|
148
|
+
floatManager.open('view:other');
|
|
149
|
+
expect(compactRootStore.current.kind).toBe('float');
|
|
150
|
+
const ok = focusView('view:docked');
|
|
151
|
+
expect(ok).toBe(true);
|
|
152
|
+
expect(compactRootStore.current).toEqual({ kind: 'docked' });
|
|
153
|
+
viewportStore.override(null);
|
|
154
|
+
});
|
|
155
|
+
it('desktop: focusView does not touch the compact body root', () => {
|
|
156
|
+
viewportStore.override('desktop');
|
|
157
|
+
floatManager.open('view:any');
|
|
158
|
+
const before = compactRootStore.current;
|
|
159
|
+
focusView('view:any');
|
|
160
|
+
expect(compactRootStore.current).toEqual(before);
|
|
161
|
+
viewportStore.override(null);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -29,7 +29,7 @@ describe('layout schema v4 → v5 backward compatibility', () => {
|
|
|
29
29
|
expect(slotA.role).toBeUndefined();
|
|
30
30
|
expect(v4Blob.drawers).toBeUndefined();
|
|
31
31
|
});
|
|
32
|
-
it('LAYOUT_SCHEMA_VERSION is
|
|
33
|
-
expect(LAYOUT_SCHEMA_VERSION).toBe(
|
|
32
|
+
it('LAYOUT_SCHEMA_VERSION is 7', () => {
|
|
33
|
+
expect(LAYOUT_SCHEMA_VERSION).toBe(7);
|
|
34
34
|
});
|
|
35
35
|
});
|
package/dist/layout/types.d.ts
CHANGED
|
@@ -5,8 +5,9 @@ export type SplitDirection = 'horizontal' | 'vertical';
|
|
|
5
5
|
* viewports to derive a compact rendering (sidebars/inspectors lift into
|
|
6
6
|
* drawer surfaces, body slots fill the page).
|
|
7
7
|
*
|
|
8
|
-
* Default `'body'`. Authored on a slot or
|
|
9
|
-
* to
|
|
8
|
+
* Default `'body'`. Authored on a slot node or a tabs node (one role
|
|
9
|
+
* per tabs group, applied to every tab); if unset, falls back to the
|
|
10
|
+
* view's `defaultRole` (registered via the shard contract). See
|
|
10
11
|
* `layout/compact/resolveRole.ts`.
|
|
11
12
|
*/
|
|
12
13
|
export type SlotRole = 'body' | 'sidebar' | 'inspector';
|
|
@@ -56,11 +57,6 @@ export interface TabEntry {
|
|
|
56
57
|
label: string;
|
|
57
58
|
/** Optional icon hint (not yet rendered in phase 8). */
|
|
58
59
|
icon?: string;
|
|
59
|
-
/**
|
|
60
|
-
* Slot-role hint for compact rendering. Default `'body'` via
|
|
61
|
-
* `resolveRole(slot, viewDefault)`. Inert on desktop.
|
|
62
|
-
*/
|
|
63
|
-
role?: SlotRole;
|
|
64
60
|
/**
|
|
65
61
|
* Caller-supplied instance data, threaded to `MountContext.meta`.
|
|
66
62
|
* Ephemeral — not serialized with the layout tree.
|
|
@@ -97,6 +93,13 @@ export interface TabsNode {
|
|
|
97
93
|
* Inert when not rendered as a carousel.
|
|
98
94
|
*/
|
|
99
95
|
wrap?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Role hint for the whole tab group in compact rendering. Default
|
|
98
|
+
* `'body'` via `resolveRole(node, viewDefault)`. Inert on desktop.
|
|
99
|
+
* Applies to every tab in the group — mixed-role tab groups are not
|
|
100
|
+
* representable by design.
|
|
101
|
+
*/
|
|
102
|
+
role?: SlotRole;
|
|
100
103
|
}
|
|
101
104
|
/**
|
|
102
105
|
* A leaf layout node that holds a single mounted view. `slotId` is the stable
|
|
@@ -215,7 +218,7 @@ export type TreeRootRef = {
|
|
|
215
218
|
* the default tree takes over — phase 7 deliberately does not ship a
|
|
216
219
|
* migration framework, only the hook for one.
|
|
217
220
|
*/
|
|
218
|
-
export declare const LAYOUT_SCHEMA_VERSION =
|
|
221
|
+
export declare const LAYOUT_SCHEMA_VERSION = 7;
|
|
219
222
|
/**
|
|
220
223
|
* The wire shape of a persisted layout in the workspace state zone.
|
|
221
224
|
* One blob per sh3 (or per program, once per-program layouts exist);
|
package/dist/layout/types.js
CHANGED
|
@@ -20,7 +20,7 @@ describe('TabsNode.wrap', () => {
|
|
|
20
20
|
});
|
|
21
21
|
});
|
|
22
22
|
describe('LAYOUT_SCHEMA_VERSION', () => {
|
|
23
|
-
it('is
|
|
24
|
-
expect(LAYOUT_SCHEMA_VERSION).toBe(
|
|
23
|
+
it('is 7 (bumped: role moved from TabEntry to TabsNode)', () => {
|
|
24
|
+
expect(LAYOUT_SCHEMA_VERSION).toBe(7);
|
|
25
25
|
});
|
|
26
26
|
});
|