sh3-core 0.7.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__test__/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 +15 -2
- package/dist/api.js +13 -1
- package/dist/app/store/StoreView.svelte +36 -7
- package/dist/app/store/storeShard.svelte.js +9 -3
- package/dist/app/store/verbs.js +8 -2
- package/dist/apps/lifecycle.d.ts +11 -0
- package/dist/apps/lifecycle.js +48 -11
- package/dist/apps/lifecycle.test.d.ts +1 -0
- package/dist/apps/lifecycle.test.js +309 -0
- package/dist/apps/registry.svelte.d.ts +2 -0
- package/dist/apps/registry.svelte.js +5 -0
- package/dist/apps/types.d.ts +24 -2
- package/dist/createShell.d.ts +2 -0
- package/dist/createShell.js +9 -7
- package/dist/documents/handle.js +5 -0
- package/dist/documents/index.d.ts +1 -0
- package/dist/documents/index.js +1 -0
- package/dist/documents/journal-hook.d.ts +6 -0
- package/dist/documents/journal-hook.js +16 -0
- package/dist/documents/sync/activate-integration.test.d.ts +1 -0
- package/dist/documents/sync/activate-integration.test.js +37 -0
- package/dist/documents/sync/components/DocumentSyncExplorer.svelte +99 -0
- package/dist/documents/sync/components/DocumentSyncExplorer.svelte.d.ts +15 -0
- package/dist/documents/sync/components/SyncGrantPicker.svelte +70 -0
- package/dist/documents/sync/components/SyncGrantPicker.svelte.d.ts +12 -0
- package/dist/documents/sync/conflicts.d.ts +30 -0
- package/dist/documents/sync/conflicts.js +77 -0
- package/dist/documents/sync/conflicts.test.d.ts +1 -0
- package/dist/documents/sync/conflicts.test.js +71 -0
- package/dist/documents/sync/engine.d.ts +19 -0
- package/dist/documents/sync/engine.js +188 -0
- package/dist/documents/sync/engine.test.d.ts +1 -0
- package/dist/documents/sync/engine.test.js +169 -0
- package/dist/documents/sync/handle.d.ts +11 -0
- package/dist/documents/sync/handle.js +79 -0
- package/dist/documents/sync/handle.test.d.ts +1 -0
- package/dist/documents/sync/handle.test.js +56 -0
- package/dist/documents/sync/hash.d.ts +1 -0
- package/dist/documents/sync/hash.js +13 -0
- package/dist/documents/sync/hash.test.d.ts +1 -0
- package/dist/documents/sync/hash.test.js +20 -0
- package/dist/documents/sync/index.d.ts +6 -0
- package/dist/documents/sync/index.js +12 -0
- package/dist/documents/sync/journal.d.ts +30 -0
- package/dist/documents/sync/journal.js +179 -0
- package/dist/documents/sync/journal.test.d.ts +1 -0
- package/dist/documents/sync/journal.test.js +87 -0
- package/dist/documents/sync/registry.d.ts +10 -0
- package/dist/documents/sync/registry.js +66 -0
- package/dist/documents/sync/registry.test.d.ts +1 -0
- package/dist/documents/sync/registry.test.js +42 -0
- package/dist/documents/sync/serialization.d.ts +5 -0
- package/dist/documents/sync/serialization.js +24 -0
- package/dist/documents/sync/serialization.test.d.ts +1 -0
- package/dist/documents/sync/serialization.test.js +26 -0
- package/dist/documents/sync/singleton.d.ts +11 -0
- package/dist/documents/sync/singleton.js +26 -0
- package/dist/documents/sync/tombstones.d.ts +19 -0
- package/dist/documents/sync/tombstones.js +58 -0
- package/dist/documents/sync/tombstones.test.d.ts +1 -0
- package/dist/documents/sync/tombstones.test.js +37 -0
- package/dist/documents/sync/types.d.ts +116 -0
- package/dist/documents/sync/types.js +27 -0
- package/dist/documents/sync/write-hook.test.d.ts +1 -0
- package/dist/documents/sync/write-hook.test.js +36 -0
- package/dist/env/client.d.ts +10 -5
- package/dist/env/client.js +12 -4
- 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/slotHostPool.svelte.d.ts +16 -1
- package/dist/layout/slotHostPool.svelte.js +123 -5
- 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 +78 -16
- 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/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/registry/installer.d.ts +10 -7
- package/dist/registry/installer.js +39 -35
- package/dist/registry/register.d.ts +17 -0
- package/dist/registry/register.js +22 -0
- package/dist/registry/register.test.d.ts +1 -0
- package/dist/registry/register.test.js +28 -0
- package/dist/shards/activate.svelte.d.ts +6 -0
- package/dist/shards/activate.svelte.js +33 -2
- package/dist/shards/registry.d.ts +4 -0
- package/dist/shards/registry.js +18 -0
- package/dist/shards/types.d.ts +16 -1
- package/dist/shell-shard/Terminal.svelte +140 -33
- package/dist/shell-shard/Terminal.svelte.d.ts +3 -0
- package/dist/shell-shard/auto-relocate.d.ts +12 -0
- package/dist/shell-shard/auto-relocate.js +20 -0
- package/dist/shell-shard/auto-relocate.test.d.ts +1 -0
- package/dist/shell-shard/auto-relocate.test.js +35 -0
- package/dist/shell-shard/dispatch.d.ts +15 -0
- package/dist/shell-shard/dispatch.js +56 -0
- package/dist/shell-shard/modes/builtin.d.ts +5 -0
- package/dist/shell-shard/modes/builtin.js +18 -0
- package/dist/shell-shard/modes/prefs.d.ts +5 -0
- package/dist/shell-shard/modes/prefs.js +31 -0
- package/dist/shell-shard/modes/prefs.test.d.ts +1 -0
- package/dist/shell-shard/modes/prefs.test.js +46 -0
- package/dist/shell-shard/modes/registry.d.ts +7 -0
- package/dist/shell-shard/modes/registry.js +27 -0
- package/dist/shell-shard/modes/registry.test.d.ts +1 -0
- package/dist/shell-shard/modes/registry.test.js +35 -0
- package/dist/shell-shard/modes/types.d.ts +8 -0
- package/dist/shell-shard/modes/types.js +1 -0
- package/dist/shell-shard/protocol.d.ts +6 -0
- package/dist/shell-shard/shellShard.svelte.js +5 -1
- package/dist/shell-shard/tenant-fs-client.d.ts +24 -0
- package/dist/shell-shard/tenant-fs-client.js +44 -0
- package/dist/shell-shard/tenant-fs-client.test.d.ts +1 -0
- package/dist/shell-shard/tenant-fs-client.test.js +49 -0
- package/dist/shell-shard/terminal-dispatch.test.d.ts +1 -0
- package/dist/shell-shard/terminal-dispatch.test.js +53 -0
- package/dist/shell-shard/toolbar/Toolbar.svelte +62 -0
- package/dist/shell-shard/toolbar/Toolbar.svelte.d.ts +11 -0
- package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte +28 -0
- package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte.d.ts +7 -0
- package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +102 -0
- package/dist/shell-shard/toolbar/slots/ModeSlot.svelte.d.ts +11 -0
- package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte +17 -0
- package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte.d.ts +6 -0
- package/dist/shell-shard/toolbar/slots.d.ts +17 -0
- package/dist/shell-shard/toolbar/slots.js +26 -0
- package/dist/shell-shard/toolbar/slots.test.d.ts +1 -0
- package/dist/shell-shard/toolbar/slots.test.js +28 -0
- package/dist/shell-shard/verbs/cat.d.ts +2 -0
- package/dist/shell-shard/verbs/cat.js +34 -0
- package/dist/shell-shard/verbs/cd.test.d.ts +1 -0
- package/dist/shell-shard/verbs/cd.test.js +56 -0
- package/dist/shell-shard/verbs/env.d.ts +2 -0
- package/dist/shell-shard/verbs/env.js +14 -0
- package/dist/shell-shard/verbs/index.js +6 -1
- package/dist/shell-shard/verbs/ls.d.ts +2 -0
- package/dist/shell-shard/verbs/ls.js +29 -0
- package/dist/shell-shard/verbs/ls.test.d.ts +1 -0
- package/dist/shell-shard/verbs/ls.test.js +49 -0
- package/dist/shell-shard/verbs/session.d.ts +0 -1
- package/dist/shell-shard/verbs/session.js +58 -26
- package/dist/verbs/types.d.ts +2 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +9 -1
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { page } from '@vitest/browser/context';
|
|
3
|
+
import { resetFramework } from '../__test__/reset';
|
|
4
|
+
import { renderWithShell } from '../__test__/render';
|
|
5
|
+
import LayoutRenderer from './LayoutRenderer.svelte';
|
|
6
|
+
import { registerApp } from '../apps/registry.svelte';
|
|
7
|
+
import { launchApp } from '../apps/lifecycle';
|
|
8
|
+
import { registerView } from '../shards/registry';
|
|
9
|
+
import { makeApp, makeAppManifest, makeSplitNode, makeTabsNode, makeTabEntry, makeSlotNode, makeTree, } from '../__test__/fixtures';
|
|
10
|
+
import { layoutStore } from './store.svelte';
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Utilities
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
function stubView() {
|
|
15
|
+
registerView('test:view', {
|
|
16
|
+
mount: (el) => {
|
|
17
|
+
el.textContent = 'stub';
|
|
18
|
+
return { unmount: () => { el.textContent = ''; } };
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/** Settle microtasks + a short macrotask gap. */
|
|
23
|
+
function settle(ms = 50) {
|
|
24
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
25
|
+
}
|
|
26
|
+
/** Remove all shell host elements from the body (cleanup between tests). */
|
|
27
|
+
function cleanupDOM() {
|
|
28
|
+
const hosts = document.querySelectorAll('.sh3-shell-host');
|
|
29
|
+
hosts.forEach((h) => h.remove());
|
|
30
|
+
}
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// E.1 — drag tab between groups
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
describe('LayoutRenderer browser — E.1 drag tab between groups', () => {
|
|
35
|
+
beforeEach(() => { cleanupDOM(); resetFramework(); });
|
|
36
|
+
it('moves a tab from one tabs group to another', async () => {
|
|
37
|
+
stubView();
|
|
38
|
+
registerApp(makeApp({
|
|
39
|
+
manifest: makeAppManifest({ id: 'e1' }),
|
|
40
|
+
initialLayout: [
|
|
41
|
+
{
|
|
42
|
+
name: 'default',
|
|
43
|
+
tree: makeTree(makeSplitNode([
|
|
44
|
+
makeTabsNode([makeTabEntry({ slotId: 'L1', label: 'L1' })]),
|
|
45
|
+
makeTabsNode([makeTabEntry({ slotId: 'R1', label: 'R1' })]),
|
|
46
|
+
])),
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
}));
|
|
50
|
+
await launchApp('e1');
|
|
51
|
+
renderWithShell(LayoutRenderer, { path: [] });
|
|
52
|
+
// Wait for initial render + slot-host pool microtasks
|
|
53
|
+
await settle(30);
|
|
54
|
+
// Get tabs by role. There are two tabs: L1 (active) and R1.
|
|
55
|
+
const allTabs = document.querySelectorAll('[role="tab"]');
|
|
56
|
+
if (allTabs.length < 2)
|
|
57
|
+
throw new Error(`expected 2 tabs, got ${allTabs.length}`);
|
|
58
|
+
// Get both tab strips (role="tablist") — left and right.
|
|
59
|
+
const strips = document.querySelectorAll('[role="tablist"]');
|
|
60
|
+
if (strips.length < 2)
|
|
61
|
+
throw new Error(`expected 2 tab strips, got ${strips.length}`);
|
|
62
|
+
const srcTab = allTabs[0]; // L1 (in left strip)
|
|
63
|
+
const rightStrip = strips[1]; // right tab strip
|
|
64
|
+
const srcRect = srcTab.getBoundingClientRect();
|
|
65
|
+
const sx = srcRect.left + srcRect.width / 2;
|
|
66
|
+
const sy = srcRect.top + srcRect.height / 2;
|
|
67
|
+
const tgtRect = rightStrip.getBoundingClientRect();
|
|
68
|
+
const tgtX = tgtRect.left + tgtRect.width / 2;
|
|
69
|
+
const tgtY = tgtRect.top + tgtRect.height / 2;
|
|
70
|
+
const mk = (x, y, type, buttons = 1) => new PointerEvent(type, {
|
|
71
|
+
bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse',
|
|
72
|
+
clientX: x, clientY: y, screenX: x, screenY: y, buttons, button: 0,
|
|
73
|
+
});
|
|
74
|
+
// Start drag on source tab
|
|
75
|
+
srcTab.dispatchEvent(mk(sx, sy, 'pointerdown'));
|
|
76
|
+
// Cross the threshold (DRAG_THRESHOLD_PX = 4)
|
|
77
|
+
window.dispatchEvent(mk(sx + 2, sy, 'pointermove'));
|
|
78
|
+
window.dispatchEvent(mk(sx + 6, sy, 'pointermove'));
|
|
79
|
+
// Dispatch pointermove ON the right strip so TabbedPanel.onStripPointerMove fires
|
|
80
|
+
// and calls dragController.onStripHover → setDropTarget({ kind: 'strip' })
|
|
81
|
+
rightStrip.dispatchEvent(mk(tgtX, tgtY, 'pointermove'));
|
|
82
|
+
// Commit
|
|
83
|
+
window.dispatchEvent(mk(tgtX, tgtY, 'pointerup', 0));
|
|
84
|
+
await settle();
|
|
85
|
+
// After the drag, cleanupTree runs and removes the empty left group.
|
|
86
|
+
// The split collapses to just the right tabs node as root. Root is `tabs`
|
|
87
|
+
// with both L1 and R1 in it.
|
|
88
|
+
const root = layoutStore.root;
|
|
89
|
+
if ((root === null || root === void 0 ? void 0 : root.type) === 'split') {
|
|
90
|
+
// Cleanup didn't collapse — check left is empty, right has 2
|
|
91
|
+
const leftTabs = root.children[0];
|
|
92
|
+
const rightTabs = root.children[1];
|
|
93
|
+
expect(leftTabs.type === 'tabs' ? leftTabs.tabs.length : -1).toBe(0);
|
|
94
|
+
expect(rightTabs.type === 'tabs' ? rightTabs.tabs.length : -1).toBe(2);
|
|
95
|
+
}
|
|
96
|
+
else if ((root === null || root === void 0 ? void 0 : root.type) === 'tabs') {
|
|
97
|
+
// Cleanup collapsed the split — the lone right tabs group is now root.
|
|
98
|
+
// Both tabs should be present.
|
|
99
|
+
expect(root.tabs.length).toBe(2);
|
|
100
|
+
const slotIds = root.tabs.map((t) => t.slotId);
|
|
101
|
+
expect(slotIds).toContain('L1');
|
|
102
|
+
expect(slotIds).toContain('R1');
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
throw new Error(`expected split or tabs root, got: ${root === null || root === void 0 ? void 0 : root.type}`);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// E.2 — drag tab to quadrant drop zone
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
describe('LayoutRenderer browser — E.2 drag tab to quadrant', () => {
|
|
113
|
+
beforeEach(() => { cleanupDOM(); resetFramework(); });
|
|
114
|
+
it('creates a split when dropping a tab on a quadrant drop zone', async () => {
|
|
115
|
+
stubView();
|
|
116
|
+
registerApp(makeApp({
|
|
117
|
+
manifest: makeAppManifest({ id: 'e2' }),
|
|
118
|
+
initialLayout: [
|
|
119
|
+
{
|
|
120
|
+
name: 'default',
|
|
121
|
+
tree: makeTree(makeTabsNode([
|
|
122
|
+
makeTabEntry({ slotId: 'X', label: 'X' }),
|
|
123
|
+
makeTabEntry({ slotId: 'Y', label: 'Y' }),
|
|
124
|
+
])),
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
}));
|
|
128
|
+
await launchApp('e2');
|
|
129
|
+
renderWithShell(LayoutRenderer, { path: [] });
|
|
130
|
+
await settle(30);
|
|
131
|
+
// Get the X tab (first tab = active one)
|
|
132
|
+
const tabs = document.querySelectorAll('[role="tab"]');
|
|
133
|
+
if (tabs.length < 1)
|
|
134
|
+
throw new Error('no tabs found');
|
|
135
|
+
const srcTab = tabs[0]; // X (active)
|
|
136
|
+
const srcRect = srcTab.getBoundingClientRect();
|
|
137
|
+
const sx = srcRect.left + srcRect.width / 2;
|
|
138
|
+
const sy = srcRect.top + srcRect.height / 2;
|
|
139
|
+
const zone = document.querySelector('.slot-drop-zone');
|
|
140
|
+
if (!zone)
|
|
141
|
+
throw new Error('no .slot-drop-zone found');
|
|
142
|
+
const zRect = zone.getBoundingClientRect();
|
|
143
|
+
// Right quadrant center
|
|
144
|
+
const tgtX = zRect.left + zRect.width * 0.75;
|
|
145
|
+
const tgtY = zRect.top + zRect.height / 2;
|
|
146
|
+
const mk = (x, y, type, buttons = 1) => new PointerEvent(type, {
|
|
147
|
+
bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse',
|
|
148
|
+
clientX: x, clientY: y, screenX: x, screenY: y, buttons, button: 0,
|
|
149
|
+
});
|
|
150
|
+
srcTab.dispatchEvent(mk(sx, sy, 'pointerdown'));
|
|
151
|
+
window.dispatchEvent(mk(sx + 2, sy, 'pointermove'));
|
|
152
|
+
window.dispatchEvent(mk(sx + 6, sy, 'pointermove'));
|
|
153
|
+
// Fire pointermove on the zone element so SlotDropZone.onMove runs
|
|
154
|
+
zone.dispatchEvent(mk(tgtX, tgtY, 'pointermove'));
|
|
155
|
+
// Commit via global pointerup
|
|
156
|
+
window.dispatchEvent(mk(tgtX, tgtY, 'pointerup', 0));
|
|
157
|
+
await settle();
|
|
158
|
+
const root = layoutStore.root;
|
|
159
|
+
expect(root === null || root === void 0 ? void 0 : root.type).toBe('split');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// E.3 — splitter drag updates sizes
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
describe('LayoutRenderer browser — E.3 splitter drag', () => {
|
|
166
|
+
beforeEach(() => { cleanupDOM(); resetFramework(); });
|
|
167
|
+
it('updates split.sizes when the splitter handle is dragged', async () => {
|
|
168
|
+
stubView();
|
|
169
|
+
registerApp(makeApp({
|
|
170
|
+
manifest: makeAppManifest({ id: 'e3' }),
|
|
171
|
+
initialLayout: [
|
|
172
|
+
{
|
|
173
|
+
name: 'default',
|
|
174
|
+
tree: makeTree(makeSplitNode([makeSlotNode('a', 'test:view'), makeSlotNode('b', 'test:view')])),
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
}));
|
|
178
|
+
await launchApp('e3');
|
|
179
|
+
renderWithShell(LayoutRenderer, { path: [] });
|
|
180
|
+
await settle(30);
|
|
181
|
+
const root0 = layoutStore.root;
|
|
182
|
+
const size0 = (root0 === null || root0 === void 0 ? void 0 : root0.type) === 'split' ? root0.sizes[0] : -1;
|
|
183
|
+
// Get handle element directly from DOM (data-testid)
|
|
184
|
+
const handleEl = document.querySelector('[data-testid="splitter-handle-0"]');
|
|
185
|
+
if (!handleEl)
|
|
186
|
+
throw new Error('splitter handle not in DOM');
|
|
187
|
+
const box = handleEl.getBoundingClientRect();
|
|
188
|
+
const hx = box.left + box.width / 2;
|
|
189
|
+
const hy = box.top + box.height / 2;
|
|
190
|
+
const dx = 100;
|
|
191
|
+
const mk = (x, y, type, buttons = 1) => new PointerEvent(type, {
|
|
192
|
+
bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse',
|
|
193
|
+
clientX: x, clientY: y, screenX: x, screenY: y, buttons, button: 0,
|
|
194
|
+
});
|
|
195
|
+
// ResizableSplitter uses onpointerdown/onpointermove/onpointerup on the handle.
|
|
196
|
+
handleEl.dispatchEvent(mk(hx, hy, 'pointerdown'));
|
|
197
|
+
handleEl.dispatchEvent(mk(hx + dx / 2, hy, 'pointermove'));
|
|
198
|
+
handleEl.dispatchEvent(mk(hx + dx, hy, 'pointermove'));
|
|
199
|
+
handleEl.dispatchEvent(mk(hx + dx, hy, 'pointerup', 0));
|
|
200
|
+
await settle();
|
|
201
|
+
const root1 = layoutStore.root;
|
|
202
|
+
const size1 = (root1 === null || root1 === void 0 ? void 0 : root1.type) === 'split' ? root1.sizes[0] : -1;
|
|
203
|
+
expect(size1).not.toBe(size0);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// E.4 — tab close policy
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
import { setSlotClosableForTest, setSlotCanCloseForTest } from './slotHostPool.svelte';
|
|
210
|
+
describe('LayoutRenderer browser — E.4 close policy', () => {
|
|
211
|
+
beforeEach(() => { cleanupDOM(); resetFramework(); });
|
|
212
|
+
it('removes closable tabs, keeps non-closable, and awaits canClose', async () => {
|
|
213
|
+
stubView();
|
|
214
|
+
setSlotClosableForTest('free', true);
|
|
215
|
+
setSlotClosableForTest('pinned', false);
|
|
216
|
+
setSlotClosableForTest('guarded', true);
|
|
217
|
+
setSlotCanCloseForTest('guarded', async () => false);
|
|
218
|
+
registerApp(makeApp({
|
|
219
|
+
manifest: makeAppManifest({ id: 'e4' }),
|
|
220
|
+
initialLayout: [
|
|
221
|
+
{
|
|
222
|
+
name: 'default',
|
|
223
|
+
tree: makeTree(makeTabsNode([
|
|
224
|
+
makeTabEntry({ slotId: 'free', label: 'Free' }),
|
|
225
|
+
makeTabEntry({ slotId: 'pinned', label: 'Pinned' }),
|
|
226
|
+
makeTabEntry({ slotId: 'guarded', label: 'Guarded' }),
|
|
227
|
+
])),
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
}));
|
|
231
|
+
await launchApp('e4');
|
|
232
|
+
renderWithShell(LayoutRenderer, { path: [] });
|
|
233
|
+
await settle(30);
|
|
234
|
+
await page.getByTestId('tab-close-free').click();
|
|
235
|
+
await page.getByTestId('tab-close-guarded').click();
|
|
236
|
+
// Allow async canClose to resolve and layout to update
|
|
237
|
+
await settle(100);
|
|
238
|
+
const root = layoutStore.root;
|
|
239
|
+
if ((root === null || root === void 0 ? void 0 : root.type) !== 'tabs')
|
|
240
|
+
throw new Error('expected tabs root');
|
|
241
|
+
const labels = root.tabs.map((t) => t.label);
|
|
242
|
+
expect(labels).toContain('Pinned');
|
|
243
|
+
expect(labels).toContain('Guarded');
|
|
244
|
+
expect(labels).not.toContain('Free');
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// E.5 — double-click splitter toggles collapse
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
describe('LayoutRenderer browser — E.5 splitter collapse toggle', () => {
|
|
251
|
+
beforeEach(() => { cleanupDOM(); resetFramework(); });
|
|
252
|
+
it('toggles collapsed[i] on double-click', async () => {
|
|
253
|
+
var _a;
|
|
254
|
+
stubView();
|
|
255
|
+
registerApp(makeApp({
|
|
256
|
+
manifest: makeAppManifest({ id: 'e5' }),
|
|
257
|
+
initialLayout: [
|
|
258
|
+
{
|
|
259
|
+
name: 'default',
|
|
260
|
+
tree: makeTree(makeSplitNode([makeSlotNode('a', 'test:view'), makeSlotNode('b', 'test:view')])),
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
}));
|
|
264
|
+
await launchApp('e5');
|
|
265
|
+
renderWithShell(LayoutRenderer, { path: [] });
|
|
266
|
+
await settle(30);
|
|
267
|
+
await page.getByTestId('splitter-handle-0').dblClick();
|
|
268
|
+
await settle(50);
|
|
269
|
+
const root = layoutStore.root;
|
|
270
|
+
if ((root === null || root === void 0 ? void 0 : root.type) !== 'split')
|
|
271
|
+
throw new Error('expected split root');
|
|
272
|
+
expect((_a = root.collapsed) === null || _a === void 0 ? void 0 : _a[0]).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -195,9 +195,10 @@
|
|
|
195
195
|
closable={tabClosable(tabs.tabs)}
|
|
196
196
|
dirty={tabDirty(tabs.tabs)}
|
|
197
197
|
onClose={(i) => handleTabClose(tabs.tabs, i)}
|
|
198
|
+
tabIds={tabs.tabs.map((t) => t.slotId)}
|
|
198
199
|
/>
|
|
199
200
|
{#snippet tabBody(i: number)}
|
|
200
|
-
{@const entry = tabs
|
|
201
|
+
{@const entry = tabs?.tabs[i]}
|
|
201
202
|
{#if entry}
|
|
202
203
|
<div class="tab-slot-wrapper">
|
|
203
204
|
<SlotContainer node={{ type: 'slot', slotId: entry.slotId, viewId: entry.viewId }} label={entry.label} />
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { tick } from 'svelte';
|
|
3
|
+
import LayoutRenderer from './LayoutRenderer.svelte';
|
|
4
|
+
import { resetFramework } from '../__test__/reset';
|
|
5
|
+
import { renderWithShell } from '../__test__/render';
|
|
6
|
+
import { makeApp, makeAppManifest, makeSplitNode, makeTabsNode, makeTabEntry, makeSlotNode, makeTree, } from '../__test__/fixtures';
|
|
7
|
+
import { registerApp } from '../apps/registry.svelte';
|
|
8
|
+
import { registerView } from '../shards/registry';
|
|
9
|
+
import { launchApp } from '../apps/lifecycle';
|
|
10
|
+
function registerStubView() {
|
|
11
|
+
registerView('test:view', {
|
|
12
|
+
mount(container, ctx) {
|
|
13
|
+
const node = document.createElement('div');
|
|
14
|
+
node.dataset.viewFor = ctx.slotId;
|
|
15
|
+
container.appendChild(node);
|
|
16
|
+
return { unmount: () => node.remove() };
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
describe('LayoutRenderer — C.1 split with tabs inside', () => {
|
|
21
|
+
beforeEach(resetFramework);
|
|
22
|
+
it('renders a splitter with two panes and a tab strip', async () => {
|
|
23
|
+
registerStubView();
|
|
24
|
+
registerApp(makeApp({
|
|
25
|
+
manifest: makeAppManifest({ id: 'c1' }),
|
|
26
|
+
initialLayout: [
|
|
27
|
+
{
|
|
28
|
+
name: 'default',
|
|
29
|
+
tree: makeTree(makeSplitNode([
|
|
30
|
+
makeSlotNode('left', 'test:view'),
|
|
31
|
+
makeTabsNode([
|
|
32
|
+
makeTabEntry({ slotId: 'r1', label: 'R1', viewId: 'test:view' }),
|
|
33
|
+
makeTabEntry({ slotId: 'r2', label: 'R2', viewId: 'test:view' }),
|
|
34
|
+
]),
|
|
35
|
+
])),
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
}));
|
|
39
|
+
await launchApp('c1');
|
|
40
|
+
const { container } = renderWithShell(LayoutRenderer, { path: [] });
|
|
41
|
+
await tick();
|
|
42
|
+
expect(container.querySelector('[data-view-for="left"]')).toBeTruthy();
|
|
43
|
+
// TabbedPanel renders buttons with role="tab" inside a div[role="tablist"]
|
|
44
|
+
expect(container.querySelectorAll('[role="tab"]').length).toBeGreaterThanOrEqual(2);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
import { layoutStore } from './store.svelte';
|
|
48
|
+
describe('LayoutRenderer — C.2 live tab insertion', () => {
|
|
49
|
+
beforeEach(resetFramework);
|
|
50
|
+
it('shows a new tab button when a tab entry is pushed into an existing tabs node', async () => {
|
|
51
|
+
registerStubView();
|
|
52
|
+
registerApp(makeApp({
|
|
53
|
+
manifest: makeAppManifest({ id: 'c2' }),
|
|
54
|
+
initialLayout: [
|
|
55
|
+
{
|
|
56
|
+
name: 'default',
|
|
57
|
+
tree: makeTree(makeTabsNode([makeTabEntry({ slotId: 'a', label: 'A', viewId: 'test:view' })])),
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
}));
|
|
61
|
+
await launchApp('c2');
|
|
62
|
+
const { container } = renderWithShell(LayoutRenderer, { path: [] });
|
|
63
|
+
await tick();
|
|
64
|
+
// TabbedPanel renders buttons with role="tab"
|
|
65
|
+
const before = container.querySelectorAll('[role="tab"]').length;
|
|
66
|
+
const root = layoutStore.root;
|
|
67
|
+
if ((root === null || root === void 0 ? void 0 : root.type) !== 'tabs')
|
|
68
|
+
throw new Error('expected tabs root');
|
|
69
|
+
root.tabs.push({ slotId: 'b', viewId: 'test:view', label: 'B' });
|
|
70
|
+
await tick();
|
|
71
|
+
const after = container.querySelectorAll('[role="tab"]').length;
|
|
72
|
+
expect(after).toBe(before + 1);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
import { presetManager } from '../overlays/presets';
|
|
76
|
+
describe('LayoutRenderer — C.3 tree shape swap', () => {
|
|
77
|
+
beforeEach(resetFramework);
|
|
78
|
+
it('does not throw when swapping from tabs-at-root to split-with-tabs-inside', async () => {
|
|
79
|
+
registerStubView();
|
|
80
|
+
registerApp(makeApp({
|
|
81
|
+
manifest: makeAppManifest({ id: 'c3' }),
|
|
82
|
+
initialLayout: [
|
|
83
|
+
{
|
|
84
|
+
name: 'tabs-root',
|
|
85
|
+
tree: makeTree(makeTabsNode([makeTabEntry({ slotId: 't', viewId: 'test:view' })])),
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'split-root',
|
|
89
|
+
tree: makeTree(makeSplitNode([
|
|
90
|
+
makeSlotNode('left', 'test:view'),
|
|
91
|
+
makeTabsNode([makeTabEntry({ slotId: 'r', viewId: 'test:view' })]),
|
|
92
|
+
])),
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
}));
|
|
96
|
+
await launchApp('c3');
|
|
97
|
+
const { container } = renderWithShell(LayoutRenderer, { path: [] });
|
|
98
|
+
await tick();
|
|
99
|
+
expect(() => presetManager.switch('split-root')).not.toThrow();
|
|
100
|
+
await tick();
|
|
101
|
+
expect(container.querySelector('[data-view-for="left"]')).toBeTruthy();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
describe('LayoutRenderer — C.4 tabless preset', () => {
|
|
105
|
+
beforeEach(resetFramework);
|
|
106
|
+
it('renders a slot-only preset without accessing any tabs snippet', async () => {
|
|
107
|
+
registerStubView();
|
|
108
|
+
registerApp(makeApp({
|
|
109
|
+
manifest: makeAppManifest({ id: 'c4' }),
|
|
110
|
+
initialLayout: [
|
|
111
|
+
{ name: 'default', tree: makeTree(makeSlotNode('only', 'test:view')) },
|
|
112
|
+
],
|
|
113
|
+
}));
|
|
114
|
+
await launchApp('c4');
|
|
115
|
+
const { container } = renderWithShell(LayoutRenderer, { path: [] });
|
|
116
|
+
await tick();
|
|
117
|
+
expect(container.querySelector('[data-view-for="only"]')).toBeTruthy();
|
|
118
|
+
// TabbedPanel renders buttons with role="tab" — none should appear for a slot-only tree
|
|
119
|
+
expect(container.querySelector('[role="tab"]')).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
describe('LayoutRenderer — C.5 invalid path', () => {
|
|
123
|
+
beforeEach(resetFramework);
|
|
124
|
+
it('renders nothing when the path no longer resolves to a node', async () => {
|
|
125
|
+
registerStubView();
|
|
126
|
+
registerApp(makeApp({
|
|
127
|
+
manifest: makeAppManifest({ id: 'c5' }),
|
|
128
|
+
initialLayout: [
|
|
129
|
+
{
|
|
130
|
+
name: 'default',
|
|
131
|
+
tree: makeTree(makeSplitNode([makeSlotNode('a', 'test:view'), makeSlotNode('b', 'test:view')])),
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
}));
|
|
135
|
+
await launchApp('c5');
|
|
136
|
+
const { container } = renderWithShell(LayoutRenderer, { path: [99] });
|
|
137
|
+
await tick();
|
|
138
|
+
// LayoutRenderer renders nothing when path doesn't resolve — Svelte leaves
|
|
139
|
+
// a comment anchor node in the container but no visible content.
|
|
140
|
+
expect(container.querySelector('[data-view-for]')).toBeNull();
|
|
141
|
+
expect(container.querySelector('.leaf-slot-wrapper, .splitter, .tabbed-panel')).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -51,7 +51,13 @@
|
|
|
51
51
|
$effect(() => {
|
|
52
52
|
if (!wrapper) return;
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
// Capture slotId at effect-run time so the cleanup closure releases the
|
|
55
|
+
// correct slot even when node.slotId has already changed to a new value
|
|
56
|
+
// by the time Svelte calls the cleanup (which happens after reactive
|
|
57
|
+
// state has been updated). Without this capture, cleanup would call
|
|
58
|
+
// releaseSlotHost with the NEW slotId instead of the old one.
|
|
59
|
+
const currentSlotId = node.slotId;
|
|
60
|
+
const host = acquireSlotHost(currentSlotId, node.viewId, label || node.viewId || currentSlotId);
|
|
55
61
|
wrapper.appendChild(host);
|
|
56
62
|
|
|
57
63
|
// Local observer exists only to drive the placeholder's dims text;
|
|
@@ -67,7 +73,7 @@
|
|
|
67
73
|
|
|
68
74
|
return () => {
|
|
69
75
|
ro.disconnect();
|
|
70
|
-
releaseSlotHost(
|
|
76
|
+
releaseSlotHost(currentSlotId);
|
|
71
77
|
};
|
|
72
78
|
});
|
|
73
79
|
</script>
|
|
@@ -99,6 +99,12 @@
|
|
|
99
99
|
{#if hoveredSide}
|
|
100
100
|
<div class="quad-highlight quad-{hoveredSide}"></div>
|
|
101
101
|
{/if}
|
|
102
|
+
{#if active}
|
|
103
|
+
<div class="quad-target quad-left" data-testid="drop-zone-left"></div>
|
|
104
|
+
<div class="quad-target quad-right" data-testid="drop-zone-right"></div>
|
|
105
|
+
<div class="quad-target quad-top" data-testid="drop-zone-top"></div>
|
|
106
|
+
<div class="quad-target quad-bottom" data-testid="drop-zone-bottom"></div>
|
|
107
|
+
{/if}
|
|
102
108
|
</div>
|
|
103
109
|
|
|
104
110
|
<style>
|
|
@@ -122,4 +128,17 @@
|
|
|
122
128
|
.quad-highlight.quad-right { top: 0; bottom: 0; left: 50%; right: 0; }
|
|
123
129
|
.quad-highlight.quad-top { left: 0; right: 0; top: 0; bottom: 50%; }
|
|
124
130
|
.quad-highlight.quad-bottom { left: 0; right: 0; top: 50%; bottom: 0; }
|
|
131
|
+
|
|
132
|
+
/* Invisible hit-target overlays for each quadrant — present only during a
|
|
133
|
+
drag so they don't intercept normal pointer events. These give Playwright
|
|
134
|
+
(and pointer-based UI) a stable target area per side without relying on
|
|
135
|
+
the hover-driven highlight. */
|
|
136
|
+
.quad-target {
|
|
137
|
+
position: absolute;
|
|
138
|
+
pointer-events: none; /* the parent zone handles pointermove */
|
|
139
|
+
}
|
|
140
|
+
.quad-target.quad-left { top: 0; bottom: 0; left: 0; right: 50%; }
|
|
141
|
+
.quad-target.quad-right { top: 0; bottom: 0; left: 50%; right: 0; }
|
|
142
|
+
.quad-target.quad-top { left: 0; right: 0; top: 0; bottom: 50%; }
|
|
143
|
+
.quad-target.quad-bottom { left: 0; right: 0; top: 50%; bottom: 0; }
|
|
125
144
|
</style>
|
|
@@ -45,4 +45,9 @@ export declare function beginTabDrag(slotId: string, entry: TabEntry, sourceRoot
|
|
|
45
45
|
*/
|
|
46
46
|
export declare function setDropTarget(target: DropTarget): void;
|
|
47
47
|
export declare function clearDropTarget(match?: (target: DropTarget) => boolean): void;
|
|
48
|
+
/**
|
|
49
|
+
* Test-only reset. Returns dragState to its idle initial value and
|
|
50
|
+
* removes any pointer listeners attached during a prior drag.
|
|
51
|
+
*/
|
|
52
|
+
export declare function __resetDragStateForTest(): void;
|
|
48
53
|
export {};
|
|
@@ -230,3 +230,18 @@ export function clearDropTarget(match) {
|
|
|
230
230
|
return;
|
|
231
231
|
dragState.target = null;
|
|
232
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* Test-only reset. Returns dragState to its idle initial value and
|
|
235
|
+
* removes any pointer listeners attached during a prior drag.
|
|
236
|
+
*/
|
|
237
|
+
export function __resetDragStateForTest() {
|
|
238
|
+
removeGlobalListeners();
|
|
239
|
+
dragState.phase = 'idle';
|
|
240
|
+
dragState.source = null;
|
|
241
|
+
dragState.target = null;
|
|
242
|
+
dragState.pointerX = 0;
|
|
243
|
+
dragState.pointerY = 0;
|
|
244
|
+
clickSuppressedUntil = 0;
|
|
245
|
+
pendingStartX = 0;
|
|
246
|
+
pendingStartY = 0;
|
|
247
|
+
}
|
|
@@ -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;
|