sh3-core 0.6.0 → 0.7.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/Shell.svelte +20 -14
- package/dist/api.d.ts +5 -3
- package/dist/app/admin/adminApp.js +2 -1
- package/dist/app/admin/adminShard.svelte.js +2 -1
- package/dist/app/store/StoreView.svelte +11 -5
- package/dist/app/store/storeApp.js +2 -1
- package/dist/app/store/storeShard.svelte.js +9 -4
- package/dist/apps/terminal/manifest.js +2 -1
- package/dist/apps/types.d.ts +28 -7
- package/dist/build.d.ts +5 -2
- package/dist/build.js +21 -10
- package/dist/env/client.d.ts +10 -2
- package/dist/env/client.js +13 -2
- package/dist/layout/LayoutRenderer.svelte +21 -9
- package/dist/layout/LayoutRenderer.svelte.d.ts +2 -0
- package/dist/layout/SlotDropZone.svelte +4 -1
- package/dist/layout/SlotDropZone.svelte.d.ts +2 -0
- package/dist/layout/drag.svelte.d.ts +5 -2
- package/dist/layout/drag.svelte.js +43 -11
- package/dist/layout/floats.d.ts +35 -0
- package/dist/layout/floats.js +73 -0
- package/dist/layout/floats.test.d.ts +1 -0
- package/dist/layout/floats.test.js +114 -0
- package/dist/layout/inspection.d.ts +2 -2
- package/dist/layout/inspection.js +6 -6
- package/dist/layout/ops.d.ts +14 -1
- package/dist/layout/ops.js +17 -0
- package/dist/layout/ops.test.d.ts +1 -0
- package/dist/layout/ops.test.js +36 -0
- package/dist/layout/presets.d.ts +2 -0
- package/dist/layout/presets.js +49 -0
- package/dist/layout/presets.test.d.ts +1 -0
- package/dist/layout/presets.test.js +71 -0
- package/dist/layout/store.svelte.d.ts +17 -13
- package/dist/layout/store.svelte.js +98 -36
- package/dist/layout/tree-walk.d.ts +12 -1
- package/dist/layout/tree-walk.js +13 -0
- package/dist/layout/tree-walk.test.d.ts +1 -0
- package/dist/layout/tree-walk.test.js +41 -0
- package/dist/layout/types.d.ts +96 -6
- package/dist/layout/types.js +1 -1
- package/dist/overlays/FloatFrame.svelte +141 -0
- package/dist/overlays/FloatFrame.svelte.d.ts +7 -0
- package/dist/overlays/FloatLayer.svelte +28 -0
- package/dist/overlays/FloatLayer.svelte.d.ts +3 -0
- package/dist/overlays/float.d.ts +29 -0
- package/dist/overlays/float.js +119 -0
- package/dist/overlays/float.test.d.ts +1 -0
- package/dist/overlays/float.test.js +37 -0
- package/dist/overlays/presets.d.ts +21 -0
- package/dist/overlays/presets.js +63 -0
- package/dist/overlays/presets.test.d.ts +1 -0
- package/dist/overlays/presets.test.js +40 -0
- package/dist/registry/client.d.ts +14 -0
- package/dist/registry/client.js +37 -0
- package/dist/registry/client.test.d.ts +1 -0
- package/dist/registry/client.test.js +54 -0
- package/dist/registry/installer.js +18 -5
- package/dist/registry/schema.js +5 -0
- package/dist/registry/types.d.ts +9 -0
- package/dist/shards/types.d.ts +27 -4
- package/dist/shell-shard/Terminal.svelte +14 -8
- package/dist/shell-shard/manifest.js +2 -1
- package/dist/shell-shard/shellShard.svelte.js +2 -1
- package/dist/shellRuntime.svelte.d.ts +6 -0
- package/dist/shellRuntime.svelte.js +4 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +6 -3
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
*/
|
|
43
43
|
import { cleanupTree, insertTabIntoTabs, moveTabWithinTabs, removeTabBySlotId, splitNodeAtPath, } from './ops';
|
|
44
44
|
import { layoutStore } from './store.svelte';
|
|
45
|
+
import { isEmptyContent } from './floats';
|
|
45
46
|
export const dragState = $state({
|
|
46
47
|
phase: 'idle',
|
|
47
48
|
source: null,
|
|
@@ -69,7 +70,7 @@ let pendingStartY = 0;
|
|
|
69
70
|
* This does not yet enter the dragging phase — movement past the
|
|
70
71
|
* threshold is required.
|
|
71
72
|
*/
|
|
72
|
-
export function beginTabDrag(slotId, entry, event, tabElement) {
|
|
73
|
+
export function beginTabDrag(slotId, entry, sourceRoot, event, tabElement) {
|
|
73
74
|
if (dragState.phase !== 'idle')
|
|
74
75
|
return;
|
|
75
76
|
const rect = tabElement.getBoundingClientRect();
|
|
@@ -77,6 +78,7 @@ export function beginTabDrag(slotId, entry, event, tabElement) {
|
|
|
77
78
|
dragState.source = {
|
|
78
79
|
slotId,
|
|
79
80
|
entry,
|
|
81
|
+
sourceRoot,
|
|
80
82
|
startRect: rect,
|
|
81
83
|
offsetX: event.clientX - rect.left,
|
|
82
84
|
offsetY: event.clientY - rect.top,
|
|
@@ -130,11 +132,38 @@ function onPointerUp(_e) {
|
|
|
130
132
|
function onPointerCancel(_e) {
|
|
131
133
|
teardown();
|
|
132
134
|
}
|
|
135
|
+
function rootNode(ref) {
|
|
136
|
+
const tree = layoutStore.tree;
|
|
137
|
+
if (ref.kind === 'docked')
|
|
138
|
+
return tree.docked;
|
|
139
|
+
const f = tree.floats.find((e) => e.id === ref.floatId);
|
|
140
|
+
return f ? f.content : null;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* If `ref` points at a float whose content is now empty (no bound leaf
|
|
144
|
+
* slots), remove the float from the tree. No-op for docked refs and for
|
|
145
|
+
* floats that still contain a bound view. Called after a commit that
|
|
146
|
+
* removed a tab from a float's content.
|
|
147
|
+
*/
|
|
148
|
+
function autoCloseEmptyFloat(ref) {
|
|
149
|
+
if (ref.kind !== 'float')
|
|
150
|
+
return;
|
|
151
|
+
const tree = layoutStore.tree;
|
|
152
|
+
const idx = tree.floats.findIndex((f) => f.id === ref.floatId);
|
|
153
|
+
if (idx < 0)
|
|
154
|
+
return;
|
|
155
|
+
if (isEmptyContent(tree.floats[idx].content)) {
|
|
156
|
+
tree.floats.splice(idx, 1);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
133
159
|
function commit() {
|
|
134
160
|
const { source, target } = dragState;
|
|
135
161
|
if (!source || !target)
|
|
136
162
|
return;
|
|
137
|
-
const
|
|
163
|
+
const sourceRoot = rootNode(source.sourceRoot);
|
|
164
|
+
const targetRoot = rootNode(target.root);
|
|
165
|
+
if (!sourceRoot || !targetRoot)
|
|
166
|
+
return;
|
|
138
167
|
// Same-group strip drop: atomic move. The two-step remove/insert
|
|
139
168
|
// flow splices the reactive tabs array twice; splice's internal
|
|
140
169
|
// `[[Delete]]` trips Svelte's proxy deleteProperty trap, which can
|
|
@@ -152,25 +181,28 @@ function commit() {
|
|
|
152
181
|
const srcIdx = target.tabsNode.tabs.findIndex((t) => t.slotId === source.slotId);
|
|
153
182
|
if (srcIdx >= 0) {
|
|
154
183
|
moveTabWithinTabs(target.tabsNode, srcIdx, target.insertIndex);
|
|
155
|
-
cleanupTree(
|
|
184
|
+
cleanupTree(sourceRoot);
|
|
185
|
+
if (targetRoot !== sourceRoot)
|
|
186
|
+
cleanupTree(targetRoot);
|
|
187
|
+
autoCloseEmptyFloat(source.sourceRoot);
|
|
156
188
|
return;
|
|
157
189
|
}
|
|
158
190
|
}
|
|
159
|
-
// Cross-group strip drop or split drop: remove from
|
|
160
|
-
// insert into
|
|
161
|
-
|
|
162
|
-
// observer sees a shrunk intermediate — the splice-based flow is
|
|
163
|
-
// fine here.
|
|
164
|
-
const removed = removeTabBySlotId(root, source.slotId);
|
|
191
|
+
// Cross-group strip drop or split drop: remove from SOURCE root, then
|
|
192
|
+
// insert/split into the target.
|
|
193
|
+
const removed = removeTabBySlotId(sourceRoot, source.slotId);
|
|
165
194
|
if (!removed)
|
|
166
195
|
return;
|
|
167
196
|
if (target.kind === 'strip') {
|
|
168
197
|
insertTabIntoTabs(target.tabsNode, removed, target.insertIndex);
|
|
169
198
|
}
|
|
170
199
|
else {
|
|
171
|
-
splitNodeAtPath(
|
|
200
|
+
splitNodeAtPath(targetRoot, target.path, removed, target.side);
|
|
172
201
|
}
|
|
173
|
-
cleanupTree(
|
|
202
|
+
cleanupTree(sourceRoot);
|
|
203
|
+
if (targetRoot !== sourceRoot)
|
|
204
|
+
cleanupTree(targetRoot);
|
|
205
|
+
autoCloseEmptyFloat(source.sourceRoot);
|
|
174
206
|
}
|
|
175
207
|
function teardown() {
|
|
176
208
|
dragState.phase = 'idle';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { LayoutNode, FloatEntry } from './types';
|
|
2
|
+
export declare const DEFAULT_SLOT_MIN: {
|
|
3
|
+
readonly w: 120;
|
|
4
|
+
readonly h: 80;
|
|
5
|
+
};
|
|
6
|
+
export interface Size {
|
|
7
|
+
w: number;
|
|
8
|
+
h: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function computeMinSize(node: LayoutNode): Size;
|
|
11
|
+
/**
|
|
12
|
+
* Given the list of currently-open floats, return the position a new
|
|
13
|
+
* float should appear at. Each new float offsets (+32, +32) from the
|
|
14
|
+
* most recently opened one. If the resulting position would push the
|
|
15
|
+
* float's header outside `bounds`, wraps back to CASCADE_BASE.
|
|
16
|
+
*/
|
|
17
|
+
export declare function cascadePosition(existing: FloatEntry[], bounds: {
|
|
18
|
+
w: number;
|
|
19
|
+
h: number;
|
|
20
|
+
}): {
|
|
21
|
+
x: number;
|
|
22
|
+
y: number;
|
|
23
|
+
};
|
|
24
|
+
/** Stable, process-unique float id. Not cryptographic — just unique within a session. */
|
|
25
|
+
export declare function generateFloatId(): string;
|
|
26
|
+
/**
|
|
27
|
+
* True if a LayoutNode subtree contains no leaf slot with a bound viewId.
|
|
28
|
+
* Used by the drag-commit auto-close invariant: when the last bound leaf
|
|
29
|
+
* leaves a float, the float is removed from the tree.
|
|
30
|
+
*
|
|
31
|
+
* A tabs node with zero tabs is empty. A split node is empty iff every
|
|
32
|
+
* child is empty. Only leaf slots with a non-null viewId are considered
|
|
33
|
+
* "filled".
|
|
34
|
+
*/
|
|
35
|
+
export declare function isEmptyContent(node: LayoutNode): boolean;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Pure helpers for float content: recursive min-size computation over a
|
|
3
|
+
* LayoutNode subtree, cascade-position generation, and stable id minting.
|
|
4
|
+
*
|
|
5
|
+
* Min-size rule (see spec 2026-04-11-layout-topology-design.md):
|
|
6
|
+
* - slot: framework-constant DEFAULT_SLOT_MIN (120×80). Real per-view
|
|
7
|
+
* min-size reading is a follow-up; see rescoped DF10.
|
|
8
|
+
* - tabs: element-wise max of all tabs' slot minimums (only one visible).
|
|
9
|
+
* - split: sum along the split axis, max on the cross axis.
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_SLOT_MIN = { w: 120, h: 80 };
|
|
12
|
+
export function computeMinSize(node) {
|
|
13
|
+
if (node.type === 'slot') {
|
|
14
|
+
return Object.assign({}, DEFAULT_SLOT_MIN);
|
|
15
|
+
}
|
|
16
|
+
if (node.type === 'tabs') {
|
|
17
|
+
// All tabs fight for the same area; the min is the element-wise max.
|
|
18
|
+
return Object.assign({}, DEFAULT_SLOT_MIN);
|
|
19
|
+
}
|
|
20
|
+
// split
|
|
21
|
+
const children = node.children.map(computeMinSize);
|
|
22
|
+
if (node.direction === 'horizontal') {
|
|
23
|
+
return {
|
|
24
|
+
w: children.reduce((a, c) => a + c.w, 0),
|
|
25
|
+
h: children.reduce((a, c) => Math.max(a, c.h), 0),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
w: children.reduce((a, c) => Math.max(a, c.w), 0),
|
|
30
|
+
h: children.reduce((a, c) => a + c.h, 0),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const CASCADE_STEP = 32;
|
|
34
|
+
const CASCADE_BASE = { x: 48, y: 48 };
|
|
35
|
+
/**
|
|
36
|
+
* Given the list of currently-open floats, return the position a new
|
|
37
|
+
* float should appear at. Each new float offsets (+32, +32) from the
|
|
38
|
+
* most recently opened one. If the resulting position would push the
|
|
39
|
+
* float's header outside `bounds`, wraps back to CASCADE_BASE.
|
|
40
|
+
*/
|
|
41
|
+
export function cascadePosition(existing, bounds) {
|
|
42
|
+
if (existing.length === 0)
|
|
43
|
+
return Object.assign({}, CASCADE_BASE);
|
|
44
|
+
const last = existing[existing.length - 1];
|
|
45
|
+
const next = { x: last.position.x + CASCADE_STEP, y: last.position.y + CASCADE_STEP };
|
|
46
|
+
// Wraparound if the header would leave the tree-allocated area
|
|
47
|
+
if (next.x + 120 > bounds.w || next.y + 32 > bounds.h) {
|
|
48
|
+
return Object.assign({}, CASCADE_BASE);
|
|
49
|
+
}
|
|
50
|
+
return next;
|
|
51
|
+
}
|
|
52
|
+
let floatIdCounter = 0;
|
|
53
|
+
/** Stable, process-unique float id. Not cryptographic — just unique within a session. */
|
|
54
|
+
export function generateFloatId() {
|
|
55
|
+
floatIdCounter += 1;
|
|
56
|
+
return `float-${Date.now().toString(36)}-${floatIdCounter.toString(36)}`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* True if a LayoutNode subtree contains no leaf slot with a bound viewId.
|
|
60
|
+
* Used by the drag-commit auto-close invariant: when the last bound leaf
|
|
61
|
+
* leaves a float, the float is removed from the tree.
|
|
62
|
+
*
|
|
63
|
+
* A tabs node with zero tabs is empty. A split node is empty iff every
|
|
64
|
+
* child is empty. Only leaf slots with a non-null viewId are considered
|
|
65
|
+
* "filled".
|
|
66
|
+
*/
|
|
67
|
+
export function isEmptyContent(node) {
|
|
68
|
+
if (node.type === 'slot')
|
|
69
|
+
return node.viewId == null;
|
|
70
|
+
if (node.type === 'tabs')
|
|
71
|
+
return node.tabs.every((t) => t.viewId == null);
|
|
72
|
+
return node.children.every(isEmptyContent);
|
|
73
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { computeMinSize, cascadePosition, isEmptyContent } from './floats';
|
|
3
|
+
const slot = (slotId, viewId = 'v') => ({
|
|
4
|
+
type: 'slot',
|
|
5
|
+
slotId,
|
|
6
|
+
viewId,
|
|
7
|
+
});
|
|
8
|
+
const DEFAULT_SLOT_MIN = { w: 120, h: 80 };
|
|
9
|
+
describe('computeMinSize', () => {
|
|
10
|
+
it('returns slot default for a single slot with no declared minSize', () => {
|
|
11
|
+
expect(computeMinSize(slot('s1'))).toEqual(DEFAULT_SLOT_MIN);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
describe('computeMinSize — tabs', () => {
|
|
15
|
+
it('tabs node matches single slot minimum', () => {
|
|
16
|
+
const tabs = {
|
|
17
|
+
type: 'tabs',
|
|
18
|
+
tabs: [
|
|
19
|
+
{ slotId: 'a', viewId: 'va', label: 'A' },
|
|
20
|
+
{ slotId: 'b', viewId: 'vb', label: 'B' },
|
|
21
|
+
],
|
|
22
|
+
activeTab: 0,
|
|
23
|
+
};
|
|
24
|
+
expect(computeMinSize(tabs)).toEqual(DEFAULT_SLOT_MIN);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
describe('computeMinSize — splits', () => {
|
|
28
|
+
it('horizontal split sums widths, maxes heights', () => {
|
|
29
|
+
const split = {
|
|
30
|
+
type: 'split',
|
|
31
|
+
direction: 'horizontal',
|
|
32
|
+
sizes: [1, 1],
|
|
33
|
+
children: [slot('a'), slot('b')],
|
|
34
|
+
};
|
|
35
|
+
expect(computeMinSize(split)).toEqual({ w: 240, h: 80 });
|
|
36
|
+
});
|
|
37
|
+
it('vertical split maxes widths, sums heights', () => {
|
|
38
|
+
const split = {
|
|
39
|
+
type: 'split',
|
|
40
|
+
direction: 'vertical',
|
|
41
|
+
sizes: [1, 1],
|
|
42
|
+
children: [slot('a'), slot('b')],
|
|
43
|
+
};
|
|
44
|
+
expect(computeMinSize(split)).toEqual({ w: 120, h: 160 });
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe('cascadePosition', () => {
|
|
48
|
+
const bounds = { w: 1600, h: 900 };
|
|
49
|
+
it('returns base when no floats exist', () => {
|
|
50
|
+
expect(cascadePosition([], bounds)).toEqual({ x: 48, y: 48 });
|
|
51
|
+
});
|
|
52
|
+
it('offsets 32px from the most recent float', () => {
|
|
53
|
+
const existing = [
|
|
54
|
+
{
|
|
55
|
+
id: 'f1',
|
|
56
|
+
content: slot('s'),
|
|
57
|
+
position: { x: 100, y: 200 },
|
|
58
|
+
size: { w: 600, h: 400 },
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
expect(cascadePosition(existing, bounds)).toEqual({ x: 132, y: 232 });
|
|
62
|
+
});
|
|
63
|
+
it('wraps back to base when the next position would exit bounds', () => {
|
|
64
|
+
const existing = [
|
|
65
|
+
{
|
|
66
|
+
id: 'f1',
|
|
67
|
+
content: slot('s'),
|
|
68
|
+
position: { x: 1599, y: 100 },
|
|
69
|
+
size: { w: 600, h: 400 },
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
expect(cascadePosition(existing, bounds)).toEqual({ x: 48, y: 48 });
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
describe('isEmptyContent', () => {
|
|
76
|
+
it('true for a slot with null viewId', () => {
|
|
77
|
+
expect(isEmptyContent({ type: 'slot', slotId: 's', viewId: null })).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
it('false for a slot with a bound viewId', () => {
|
|
80
|
+
expect(isEmptyContent({ type: 'slot', slotId: 's', viewId: 'v' })).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
it('true for tabs with all null viewIds', () => {
|
|
83
|
+
expect(isEmptyContent({
|
|
84
|
+
type: 'tabs',
|
|
85
|
+
tabs: [{ slotId: 'a', viewId: null, label: 'A' }],
|
|
86
|
+
activeTab: 0,
|
|
87
|
+
})).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
it('true for tabs with no tabs at all', () => {
|
|
90
|
+
expect(isEmptyContent({ type: 'tabs', tabs: [], activeTab: 0 })).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
it('false for a split with at least one bound leaf', () => {
|
|
93
|
+
expect(isEmptyContent({
|
|
94
|
+
type: 'split',
|
|
95
|
+
direction: 'horizontal',
|
|
96
|
+
sizes: [1, 1],
|
|
97
|
+
children: [
|
|
98
|
+
{ type: 'slot', slotId: 'a', viewId: null },
|
|
99
|
+
{ type: 'slot', slotId: 'b', viewId: 'v' },
|
|
100
|
+
],
|
|
101
|
+
})).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
it('true for a split where every child is empty', () => {
|
|
104
|
+
expect(isEmptyContent({
|
|
105
|
+
type: 'split',
|
|
106
|
+
direction: 'vertical',
|
|
107
|
+
sizes: [1, 1],
|
|
108
|
+
children: [
|
|
109
|
+
{ type: 'slot', slotId: 'a', viewId: null },
|
|
110
|
+
{ type: 'slot', slotId: 'b', viewId: null },
|
|
111
|
+
],
|
|
112
|
+
})).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { TabEntry, LayoutTree } from './types';
|
|
2
2
|
/**
|
|
3
3
|
* Read-only snapshot of the currently-rendered layout tree. The return
|
|
4
4
|
* value is the live object — callers MUST NOT mutate it directly;
|
|
@@ -7,7 +7,7 @@ import type { LayoutNode, TabEntry } from './types';
|
|
|
7
7
|
* updates without manual subscription wiring.
|
|
8
8
|
*/
|
|
9
9
|
export declare function inspectActiveLayout(): {
|
|
10
|
-
root:
|
|
10
|
+
root: LayoutTree;
|
|
11
11
|
source: 'home' | 'app';
|
|
12
12
|
};
|
|
13
13
|
/**
|
|
@@ -32,7 +32,7 @@ export function inspectActiveLayout() {
|
|
|
32
32
|
* variants arrive later.
|
|
33
33
|
*/
|
|
34
34
|
export function spliceIntoActiveLayout(entry) {
|
|
35
|
-
const root = activeLayout();
|
|
35
|
+
const root = activeLayout().docked;
|
|
36
36
|
const target = findFirstTabsNode(root);
|
|
37
37
|
if (!target) {
|
|
38
38
|
throw new Error('spliceIntoActiveLayout: no tabs group found in the active layout; ' +
|
|
@@ -46,7 +46,7 @@ export function spliceIntoActiveLayout(entry) {
|
|
|
46
46
|
* layout. Returns `true` if a matching tab was found and activated.
|
|
47
47
|
*/
|
|
48
48
|
export function focusTab(slotId) {
|
|
49
|
-
const root = activeLayout();
|
|
49
|
+
const root = activeLayout().docked;
|
|
50
50
|
return focusTabWhere(root, (entry) => entry.slotId === slotId);
|
|
51
51
|
}
|
|
52
52
|
/**
|
|
@@ -54,7 +54,7 @@ export function focusTab(slotId) {
|
|
|
54
54
|
* layout. Returns `true` if a matching tab was found and activated.
|
|
55
55
|
*/
|
|
56
56
|
export function focusView(viewId) {
|
|
57
|
-
const root = activeLayout();
|
|
57
|
+
const root = activeLayout().docked;
|
|
58
58
|
return focusTabWhere(root, (entry) => entry.viewId === viewId);
|
|
59
59
|
}
|
|
60
60
|
/** Walk the tree looking for a tab entry that satisfies `pred`, activate it. */
|
|
@@ -90,7 +90,7 @@ export function expandChild(splitPath, childIndex) {
|
|
|
90
90
|
return setCollapsed(splitPath, childIndex, false);
|
|
91
91
|
}
|
|
92
92
|
function setCollapsed(splitPath, childIndex, value) {
|
|
93
|
-
const root = activeLayout();
|
|
93
|
+
const root = activeLayout().docked;
|
|
94
94
|
const node = nodeAtPath(root, splitPath);
|
|
95
95
|
if (!node || node.type !== 'split')
|
|
96
96
|
return false;
|
|
@@ -115,7 +115,7 @@ function setCollapsed(splitPath, childIndex, value) {
|
|
|
115
115
|
* the sole authority on tree mutations.
|
|
116
116
|
*/
|
|
117
117
|
export async function closeTab(slotId) {
|
|
118
|
-
const root = activeLayout();
|
|
118
|
+
const root = activeLayout().docked;
|
|
119
119
|
const located = findTabBySlotId(root, slotId);
|
|
120
120
|
if (!located)
|
|
121
121
|
return false;
|
|
@@ -189,7 +189,7 @@ function findFirstSlotPath(node, path = []) {
|
|
|
189
189
|
*/
|
|
190
190
|
export function dockIntoActiveLayout(entry) {
|
|
191
191
|
var _a;
|
|
192
|
-
const root = activeLayout();
|
|
192
|
+
const root = activeLayout().docked;
|
|
193
193
|
// 1. Already present? Focus it.
|
|
194
194
|
if (focusView((_a = entry.viewId) !== null && _a !== void 0 ? _a : ''))
|
|
195
195
|
return true;
|
package/dist/layout/ops.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { LayoutNode, SplitNode, TabsNode, SlotNode, TabEntry } from './types';
|
|
1
|
+
import type { LayoutNode, SplitNode, TabsNode, SlotNode, TabEntry, LayoutTree, TreeRootRef } from './types';
|
|
2
2
|
/** A path down the tree: the list of child indices walked from the root. */
|
|
3
3
|
export type LayoutPath = number[];
|
|
4
4
|
/** A located tab: where it lives and which entry index inside its group. */
|
|
@@ -20,6 +20,19 @@ export declare function findSlotBySlotId(root: LayoutNode, slotId: string): {
|
|
|
20
20
|
parent: SplitNode | null;
|
|
21
21
|
index: number;
|
|
22
22
|
} | null;
|
|
23
|
+
/** A located tab across all roots of a LayoutTree. */
|
|
24
|
+
export interface LocatedTabInTree {
|
|
25
|
+
root: TreeRootRef;
|
|
26
|
+
/** The docked-space located tab record from findTabBySlotId. */
|
|
27
|
+
located: LocatedTab;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Multi-root version of `findTabBySlotId`: searches the docked tree first,
|
|
31
|
+
* then each float's content in order. Returns the root reference along with
|
|
32
|
+
* the `LocatedTab` so drag commit can route its mutation to the right root.
|
|
33
|
+
* Returns null if the slot id is not present in any root.
|
|
34
|
+
*/
|
|
35
|
+
export declare function findTabInTree(tree: LayoutTree, slotId: string): LocatedTabInTree | null;
|
|
23
36
|
/**
|
|
24
37
|
* Remove a tab from its current location, returning the removed entry
|
|
25
38
|
* (or null if not found). The tabs group it was in may become empty
|
package/dist/layout/ops.js
CHANGED
|
@@ -76,6 +76,23 @@ export function findSlotBySlotId(root, slotId) {
|
|
|
76
76
|
};
|
|
77
77
|
return walk(root, null, 0);
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Multi-root version of `findTabBySlotId`: searches the docked tree first,
|
|
81
|
+
* then each float's content in order. Returns the root reference along with
|
|
82
|
+
* the `LocatedTab` so drag commit can route its mutation to the right root.
|
|
83
|
+
* Returns null if the slot id is not present in any root.
|
|
84
|
+
*/
|
|
85
|
+
export function findTabInTree(tree, slotId) {
|
|
86
|
+
const inDocked = findTabBySlotId(tree.docked, slotId);
|
|
87
|
+
if (inDocked)
|
|
88
|
+
return { root: { kind: 'docked' }, located: inDocked };
|
|
89
|
+
for (const f of tree.floats) {
|
|
90
|
+
const hit = findTabBySlotId(f.content, slotId);
|
|
91
|
+
if (hit)
|
|
92
|
+
return { root: { kind: 'float', floatId: f.id }, located: hit };
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
79
96
|
// ---------- Tab removal ----------------------------------------------------
|
|
80
97
|
/**
|
|
81
98
|
* Remove a tab from its current location, returning the removed entry
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { findTabInTree } from './ops';
|
|
3
|
+
describe('findTabInTree', () => {
|
|
4
|
+
const tree = {
|
|
5
|
+
docked: {
|
|
6
|
+
type: 'tabs',
|
|
7
|
+
tabs: [{ slotId: 'docked-a', viewId: 'v', label: 'A' }],
|
|
8
|
+
activeTab: 0,
|
|
9
|
+
},
|
|
10
|
+
floats: [
|
|
11
|
+
{
|
|
12
|
+
id: 'float-1',
|
|
13
|
+
content: {
|
|
14
|
+
type: 'tabs',
|
|
15
|
+
tabs: [{ slotId: 'float-a', viewId: 'v', label: 'A' }],
|
|
16
|
+
activeTab: 0,
|
|
17
|
+
},
|
|
18
|
+
position: { x: 0, y: 0 },
|
|
19
|
+
size: { w: 600, h: 400 },
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
};
|
|
23
|
+
it('finds a tab in the docked tree', () => {
|
|
24
|
+
const hit = findTabInTree(tree, 'docked-a');
|
|
25
|
+
expect(hit).not.toBeNull();
|
|
26
|
+
expect(hit.root).toEqual({ kind: 'docked' });
|
|
27
|
+
});
|
|
28
|
+
it('finds a tab inside a float and reports the float id', () => {
|
|
29
|
+
const hit = findTabInTree(tree, 'float-a');
|
|
30
|
+
expect(hit).not.toBeNull();
|
|
31
|
+
expect(hit.root).toEqual({ kind: 'float', floatId: 'float-1' });
|
|
32
|
+
});
|
|
33
|
+
it('returns null when the slot is not found', () => {
|
|
34
|
+
expect(findTabInTree(tree, 'nonexistent')).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Initial layout normalization — converts the three accepted input
|
|
3
|
+
* shapes (bare LayoutNode, LayoutTree, LayoutPreset[]) into a canonical
|
|
4
|
+
* CanonicalPreset[] the framework operates on. The ergonomic `tree`
|
|
5
|
+
* field on LayoutPreset is discarded after normalization in favor of
|
|
6
|
+
* `variants.default`. Non-default variant keys are preserved untouched
|
|
7
|
+
* for the rescoped DF10 selection policy.
|
|
8
|
+
*/
|
|
9
|
+
function isLayoutNode(x) {
|
|
10
|
+
if (!x || typeof x !== 'object')
|
|
11
|
+
return false;
|
|
12
|
+
const t = x.type;
|
|
13
|
+
return t === 'split' || t === 'tabs' || t === 'slot';
|
|
14
|
+
}
|
|
15
|
+
function isLayoutTree(x) {
|
|
16
|
+
if (!x || typeof x !== 'object')
|
|
17
|
+
return false;
|
|
18
|
+
const o = x;
|
|
19
|
+
return isLayoutNode(o.docked) && Array.isArray(o.floats);
|
|
20
|
+
}
|
|
21
|
+
function wrapNodeAsTree(node) {
|
|
22
|
+
return { docked: node, floats: [] };
|
|
23
|
+
}
|
|
24
|
+
function canonicalizePreset(p) {
|
|
25
|
+
const variants = {};
|
|
26
|
+
if (p.tree)
|
|
27
|
+
variants.default = p.tree;
|
|
28
|
+
if (p.variants) {
|
|
29
|
+
for (const key of Object.keys(p.variants)) {
|
|
30
|
+
variants[key] = p.variants[key];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!variants.default) {
|
|
34
|
+
throw new Error(`LayoutPreset "${p.name}" must provide either 'tree' or 'variants.default'`);
|
|
35
|
+
}
|
|
36
|
+
return { name: p.name, variants };
|
|
37
|
+
}
|
|
38
|
+
export function normalizeInitialLayout(input) {
|
|
39
|
+
if (Array.isArray(input)) {
|
|
40
|
+
return input.map(canonicalizePreset);
|
|
41
|
+
}
|
|
42
|
+
if (isLayoutTree(input)) {
|
|
43
|
+
return [{ name: 'default', variants: { default: input } }];
|
|
44
|
+
}
|
|
45
|
+
if (isLayoutNode(input)) {
|
|
46
|
+
return [{ name: 'default', variants: { default: wrapNodeAsTree(input) } }];
|
|
47
|
+
}
|
|
48
|
+
throw new Error('normalizeInitialLayout: input is not a LayoutNode, LayoutTree, or LayoutPreset[]');
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { normalizeInitialLayout } from './presets';
|
|
3
|
+
const leafNode = { type: 'slot', slotId: 's1', viewId: 'v1' };
|
|
4
|
+
describe('normalizeInitialLayout', () => {
|
|
5
|
+
it('wraps a bare LayoutNode as a single default preset with empty floats', () => {
|
|
6
|
+
const result = normalizeInitialLayout(leafNode);
|
|
7
|
+
expect(result).toEqual([
|
|
8
|
+
{
|
|
9
|
+
name: 'default',
|
|
10
|
+
variants: {
|
|
11
|
+
default: { docked: leafNode, floats: [] },
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
]);
|
|
15
|
+
});
|
|
16
|
+
it('passes a LayoutTree through as a single default preset', () => {
|
|
17
|
+
const tree = { docked: leafNode, floats: [] };
|
|
18
|
+
const result = normalizeInitialLayout(tree);
|
|
19
|
+
expect(result).toEqual([
|
|
20
|
+
{
|
|
21
|
+
name: 'default',
|
|
22
|
+
variants: { default: tree },
|
|
23
|
+
},
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
26
|
+
it('canonicalizes a preset list, using tree as default and preserving variants', () => {
|
|
27
|
+
const authorTree = { docked: leafNode, floats: [] };
|
|
28
|
+
const companionTree = {
|
|
29
|
+
docked: { type: 'slot', slotId: 's2', viewId: 'v2' },
|
|
30
|
+
floats: [],
|
|
31
|
+
};
|
|
32
|
+
const presets = [
|
|
33
|
+
{
|
|
34
|
+
name: 'author',
|
|
35
|
+
tree: authorTree,
|
|
36
|
+
variants: { companion: companionTree },
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
const result = normalizeInitialLayout(presets);
|
|
40
|
+
expect(result).toEqual([
|
|
41
|
+
{
|
|
42
|
+
name: 'author',
|
|
43
|
+
variants: {
|
|
44
|
+
default: authorTree,
|
|
45
|
+
companion: companionTree,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
it('accepts a preset with only variants (no tree shortcut)', () => {
|
|
51
|
+
const tree = { docked: leafNode, floats: [] };
|
|
52
|
+
const presets = [{ name: 'x', variants: { default: tree } }];
|
|
53
|
+
const result = normalizeInitialLayout(presets);
|
|
54
|
+
expect(result).toEqual([{ name: 'x', variants: { default: tree } }]);
|
|
55
|
+
});
|
|
56
|
+
it('throws if a preset has neither tree nor variants.default', () => {
|
|
57
|
+
const bad = [{ name: 'broken', variants: { companion: { docked: leafNode, floats: [] } } }];
|
|
58
|
+
expect(() => normalizeInitialLayout(bad)).toThrow(/must provide either 'tree' or 'variants.default'/);
|
|
59
|
+
});
|
|
60
|
+
it('when a preset has both tree and variants.default, variants.default wins', () => {
|
|
61
|
+
const fromTree = { docked: leafNode, floats: [] };
|
|
62
|
+
const fromVariant = {
|
|
63
|
+
docked: { type: 'slot', slotId: 's3', viewId: 'v3' },
|
|
64
|
+
floats: [],
|
|
65
|
+
};
|
|
66
|
+
const result = normalizeInitialLayout([
|
|
67
|
+
{ name: 'x', tree: fromTree, variants: { default: fromVariant } },
|
|
68
|
+
]);
|
|
69
|
+
expect(result[0].variants.default).toBe(fromVariant);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { LayoutNode } from './types';
|
|
1
|
+
import type { LayoutNode, LayoutTree, FloatEntry } from './types';
|
|
2
2
|
import type { App } from '../apps/types';
|
|
3
3
|
/**
|
|
4
4
|
* Attach an app: create or hydrate its workspace-zone layout proxy,
|
|
@@ -16,24 +16,28 @@ export declare function detachApp(): void;
|
|
|
16
16
|
export declare function switchToHome(): void;
|
|
17
17
|
export declare function switchToApp(): void;
|
|
18
18
|
/**
|
|
19
|
-
* The currently-rendered
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* The currently-rendered LayoutTree. LayoutRenderer reads this via
|
|
20
|
+
* layoutStore.tree. Home uses a framework constant; app reads the
|
|
21
|
+
* currently-active preset from the workspace-zone proxy (reactive, so
|
|
22
22
|
* mutations from splitter/drag/ops reach the renderer unchanged).
|
|
23
23
|
*/
|
|
24
|
-
export declare function activeLayout():
|
|
24
|
+
export declare function activeLayout(): LayoutTree;
|
|
25
25
|
export declare function getActiveRoot(): 'home' | 'app';
|
|
26
26
|
export declare function getAttachedAppId(): string | null;
|
|
27
27
|
/**
|
|
28
|
-
* Preserved for callers that still read `layoutStore.root`. The
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
28
|
+
* Preserved for callers that still read `layoutStore.root`. The `root`
|
|
29
|
+
* getter is an alias for `tree.docked` so existing callers still compile
|
|
30
|
+
* without changes. The `tree` getter exposes the full LayoutTree for
|
|
31
|
+
* new consumers that also need floats. The `floats` getter is a
|
|
32
|
+
* convenience alias for `tree.floats`.
|
|
33
|
+
*
|
|
34
|
+
* Writes to any of these properties are disallowed (mutation happens on
|
|
35
|
+
* the returned tree's nodes in place — splitter drags mutate `sizes[i]`,
|
|
36
|
+
* tab clicks mutate `activeTab`, drag-commit calls ops.ts functions that
|
|
37
|
+
* mutate children arrays).
|
|
36
38
|
*/
|
|
37
39
|
export declare const layoutStore: {
|
|
38
40
|
readonly root: LayoutNode;
|
|
41
|
+
readonly tree: LayoutTree;
|
|
42
|
+
readonly floats: FloatEntry[];
|
|
39
43
|
};
|