sh3-core 0.11.6 → 0.11.7
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/actions/ActionPanel.svelte +49 -11
- package/dist/actions/ActionPanel.test.js +94 -6
- package/dist/actions/MenuButton.svelte +60 -14
- package/dist/actions/MenuButton.svelte.d.ts +3 -2
- package/dist/actions/MenuButton.test.js +38 -1
- package/dist/actions/contextMenuModel.d.ts +10 -0
- package/dist/actions/contextMenuModel.js +44 -9
- package/dist/actions/contextMenuModel.test.js +28 -1
- package/dist/actions/listeners.d.ts +4 -0
- package/dist/actions/listeners.js +77 -17
- package/dist/actions/listeners.test.js +50 -0
- package/dist/actions/menuBarModel.d.ts +14 -0
- package/dist/actions/menuBarModel.js +43 -0
- package/dist/actions/menuBarModel.test.js +75 -1
- package/dist/actions/palette-scorer.d.ts +4 -0
- package/dist/actions/palette-scorer.js +5 -0
- package/dist/actions/palette-scorer.test.js +9 -1
- package/dist/actions/paletteModel.d.ts +7 -1
- package/dist/actions/paletteModel.js +26 -1
- package/dist/actions/paletteModel.test.js +43 -0
- package/dist/actions/registry.js +5 -0
- package/dist/actions/registry.test.js +12 -0
- package/dist/actions/types.d.ts +40 -1
- package/dist/actions/types.test.d.ts +1 -0
- package/dist/actions/types.test.js +31 -0
- package/dist/assets/icons.svg +5 -0
- package/dist/documents/backends.d.ts +2 -0
- package/dist/documents/backends.js +55 -0
- package/dist/documents/backends.test.d.ts +1 -1
- package/dist/documents/backends.test.js +69 -1
- package/dist/documents/browse.d.ts +18 -0
- package/dist/documents/browse.js +13 -0
- package/dist/documents/browse.test.js +47 -0
- package/dist/documents/handle.js +23 -0
- package/dist/documents/handle.test.js +51 -0
- package/dist/documents/http-backend.d.ts +1 -0
- package/dist/documents/http-backend.js +19 -0
- package/dist/documents/http-backend.test.js +42 -0
- package/dist/documents/types.d.ts +29 -1
- package/dist/documents/types.js +4 -0
- package/dist/documents/types.test.d.ts +1 -0
- package/dist/documents/types.test.js +20 -0
- package/dist/layout/LayoutRenderer.browser.test.js +196 -0
- package/dist/layout/SlotContainer.svelte +13 -8
- package/dist/layout/SlotDropZone.svelte +44 -9
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-7-fixed-slot-drop-protection-still-accepts-a-strip-drop-into-a-fixed-tabs-node-1.png +0 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-8-same-strip-reorder-keeps-the-active-pane-populated-after-moving-the-second-tab-to-first-1.png +0 -0
- package/dist/layout/ops.d.ts +10 -0
- package/dist/layout/ops.js +30 -2
- package/dist/layout/ops.test.js +111 -1
- package/dist/layout/slotHostPool.svelte.d.ts +7 -1
- package/dist/layout/slotHostPool.svelte.js +27 -8
- package/dist/sh3core-shard/sh3coreShard.svelte.js +18 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
|
@@ -350,3 +350,199 @@ describe('LayoutRenderer browser — E.6 fixed slots', () => {
|
|
|
350
350
|
expect(document.querySelector('[data-testid="collapse-toggle-2"]')).toBeNull();
|
|
351
351
|
});
|
|
352
352
|
});
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// E.7 — fixed positions reject quadrant drops, allow strip drops
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
describe('LayoutRenderer browser — E.7 fixed-slot drop protection', () => {
|
|
357
|
+
beforeEach(() => { cleanupDOM(); resetFramework(); });
|
|
358
|
+
function mk(x, y, type, buttons = 1) {
|
|
359
|
+
return new PointerEvent(type, {
|
|
360
|
+
bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse',
|
|
361
|
+
clientX: x, clientY: y, screenX: x, screenY: y, buttons, button: 0,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
it('rejects a quadrant drop onto a fixed slot body', async () => {
|
|
365
|
+
stubView();
|
|
366
|
+
registerApp(makeApp({
|
|
367
|
+
manifest: makeAppManifest({ id: 'e7a' }),
|
|
368
|
+
initialLayout: [
|
|
369
|
+
{
|
|
370
|
+
name: 'default',
|
|
371
|
+
tree: makeTree(makeSplitNode([
|
|
372
|
+
makeTabsNode([makeTabEntry({ slotId: 'src' })]),
|
|
373
|
+
makeSlotNode('fixedSlot'),
|
|
374
|
+
], { fixed: [false, true] })),
|
|
375
|
+
},
|
|
376
|
+
],
|
|
377
|
+
}));
|
|
378
|
+
await launchApp('e7a');
|
|
379
|
+
renderWithShell(LayoutRenderer, { path: [] });
|
|
380
|
+
await settle(30);
|
|
381
|
+
const beforeRoot = JSON.stringify(layoutStore.root);
|
|
382
|
+
const srcTab = document.querySelector('[role="tab"]');
|
|
383
|
+
if (!srcTab)
|
|
384
|
+
throw new Error('no source tab');
|
|
385
|
+
const fixedHost = document.querySelector('[data-slot-id="fixedSlot"]');
|
|
386
|
+
if (!fixedHost)
|
|
387
|
+
throw new Error('no fixed slot host');
|
|
388
|
+
const fixedRect = fixedHost.getBoundingClientRect();
|
|
389
|
+
const fx = fixedRect.left + fixedRect.width / 2;
|
|
390
|
+
const fy = fixedRect.top + fixedRect.height / 2;
|
|
391
|
+
const sr = srcTab.getBoundingClientRect();
|
|
392
|
+
const sx = sr.left + sr.width / 2;
|
|
393
|
+
const sy = sr.top + sr.height / 2;
|
|
394
|
+
srcTab.dispatchEvent(mk(sx, sy, 'pointerdown'));
|
|
395
|
+
window.dispatchEvent(mk(sx + 6, sy, 'pointermove'));
|
|
396
|
+
window.dispatchEvent(mk(fx, fy, 'pointermove'));
|
|
397
|
+
window.dispatchEvent(mk(fx, fy, 'pointerup', 0));
|
|
398
|
+
await settle();
|
|
399
|
+
expect(JSON.stringify(layoutStore.root)).toBe(beforeRoot);
|
|
400
|
+
});
|
|
401
|
+
it('still accepts a strip drop into a fixed tabs node', async () => {
|
|
402
|
+
stubView();
|
|
403
|
+
registerApp(makeApp({
|
|
404
|
+
manifest: makeAppManifest({ id: 'e7b' }),
|
|
405
|
+
initialLayout: [
|
|
406
|
+
{
|
|
407
|
+
name: 'default',
|
|
408
|
+
tree: makeTree(makeSplitNode([
|
|
409
|
+
makeTabsNode([makeTabEntry({ slotId: 'src' })]),
|
|
410
|
+
makeTabsNode([makeTabEntry({ slotId: 'dst' })]),
|
|
411
|
+
], { fixed: [false, true] })),
|
|
412
|
+
},
|
|
413
|
+
],
|
|
414
|
+
}));
|
|
415
|
+
await launchApp('e7b');
|
|
416
|
+
renderWithShell(LayoutRenderer, { path: [] });
|
|
417
|
+
await settle(30);
|
|
418
|
+
const tabs = document.querySelectorAll('[role="tab"]');
|
|
419
|
+
const strips = document.querySelectorAll('[role="tablist"]');
|
|
420
|
+
if (tabs.length < 2 || strips.length < 2)
|
|
421
|
+
throw new Error('layout did not render');
|
|
422
|
+
const src = tabs[0];
|
|
423
|
+
const dstStrip = strips[1];
|
|
424
|
+
const sr = src.getBoundingClientRect();
|
|
425
|
+
const sx = sr.left + sr.width / 2;
|
|
426
|
+
const sy = sr.top + sr.height / 2;
|
|
427
|
+
const tr = dstStrip.getBoundingClientRect();
|
|
428
|
+
const tx = tr.left + tr.width / 2;
|
|
429
|
+
const ty = tr.top + tr.height / 2;
|
|
430
|
+
src.dispatchEvent(mk(sx, sy, 'pointerdown'));
|
|
431
|
+
window.dispatchEvent(mk(sx + 6, sy, 'pointermove'));
|
|
432
|
+
dstStrip.dispatchEvent(mk(tx, ty, 'pointermove'));
|
|
433
|
+
window.dispatchEvent(mk(tx, ty, 'pointerup', 0));
|
|
434
|
+
await settle();
|
|
435
|
+
// After the strip drop, the source (left) tabs node empties and is
|
|
436
|
+
// pruned (it's non-fixed). The split collapses to its sole surviving
|
|
437
|
+
// child — the previously-fixed tabs node — which is now root.
|
|
438
|
+
const root = layoutStore.root;
|
|
439
|
+
expect(root === null || root === void 0 ? void 0 : root.type).toBe('tabs');
|
|
440
|
+
if ((root === null || root === void 0 ? void 0 : root.type) === 'tabs') {
|
|
441
|
+
const slotIds = root.tabs.map((t) => t.slotId).sort();
|
|
442
|
+
expect(slotIds).toEqual(['dst', 'src']);
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
it('preserves a fixed tabs node when its last tab is dragged out', async () => {
|
|
446
|
+
stubView();
|
|
447
|
+
registerApp(makeApp({
|
|
448
|
+
manifest: makeAppManifest({ id: 'e7c' }),
|
|
449
|
+
initialLayout: [
|
|
450
|
+
{
|
|
451
|
+
name: 'default',
|
|
452
|
+
tree: makeTree(makeSplitNode([
|
|
453
|
+
makeTabsNode([makeTabEntry({ slotId: 'sink' })]),
|
|
454
|
+
makeTabsNode([makeTabEntry({ slotId: 'lonely' })]),
|
|
455
|
+
], { fixed: [false, true] })),
|
|
456
|
+
},
|
|
457
|
+
],
|
|
458
|
+
}));
|
|
459
|
+
await launchApp('e7c');
|
|
460
|
+
renderWithShell(LayoutRenderer, { path: [] });
|
|
461
|
+
await settle(30);
|
|
462
|
+
const tabs = document.querySelectorAll('[role="tab"]');
|
|
463
|
+
const strips = document.querySelectorAll('[role="tablist"]');
|
|
464
|
+
if (tabs.length < 2)
|
|
465
|
+
throw new Error('layout did not render');
|
|
466
|
+
const lonely = Array.from(tabs).find((t) => { var _a; return (_a = t.textContent) === null || _a === void 0 ? void 0 : _a.includes('lonely'); });
|
|
467
|
+
if (!lonely)
|
|
468
|
+
throw new Error('lonely tab not found');
|
|
469
|
+
const sinkStrip = strips[0];
|
|
470
|
+
const sr = lonely.getBoundingClientRect();
|
|
471
|
+
const sx = sr.left + sr.width / 2;
|
|
472
|
+
const sy = sr.top + sr.height / 2;
|
|
473
|
+
const tr = sinkStrip.getBoundingClientRect();
|
|
474
|
+
const tx = tr.left + tr.width / 2;
|
|
475
|
+
const ty = tr.top + tr.height / 2;
|
|
476
|
+
lonely.dispatchEvent(mk(sx, sy, 'pointerdown'));
|
|
477
|
+
window.dispatchEvent(mk(sx + 6, sy, 'pointermove'));
|
|
478
|
+
sinkStrip.dispatchEvent(mk(tx, ty, 'pointermove'));
|
|
479
|
+
window.dispatchEvent(mk(tx, ty, 'pointerup', 0));
|
|
480
|
+
await settle();
|
|
481
|
+
const root = layoutStore.root;
|
|
482
|
+
expect(root === null || root === void 0 ? void 0 : root.type).toBe('split');
|
|
483
|
+
if ((root === null || root === void 0 ? void 0 : root.type) === 'split') {
|
|
484
|
+
expect(root.children).toHaveLength(2);
|
|
485
|
+
const fixed = root.children[1];
|
|
486
|
+
expect(fixed.type).toBe('tabs');
|
|
487
|
+
if (fixed.type === 'tabs')
|
|
488
|
+
expect(fixed.tabs).toHaveLength(0);
|
|
489
|
+
expect(root.fixed).toEqual([false, true]);
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
// E.8 — same-strip tab reorder preserves mounted views
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
describe('LayoutRenderer browser — E.8 same-strip reorder', () => {
|
|
497
|
+
beforeEach(() => { cleanupDOM(); resetFramework(); });
|
|
498
|
+
it('keeps the active pane populated after moving the second tab to first', async () => {
|
|
499
|
+
var _a, _b, _c, _d;
|
|
500
|
+
// A view that tags its host with a unique marker so we can verify which
|
|
501
|
+
// slot's host ends up where. The marker survives re-parent because the
|
|
502
|
+
// pool moves the host element rather than recreating it.
|
|
503
|
+
registerView('marker:view', {
|
|
504
|
+
mount: (el) => {
|
|
505
|
+
var _a;
|
|
506
|
+
const tag = document.createElement('span');
|
|
507
|
+
tag.className = 'view-marker';
|
|
508
|
+
tag.textContent = 'M:' + ((_a = el.dataset.slotId) !== null && _a !== void 0 ? _a : '?');
|
|
509
|
+
el.appendChild(tag);
|
|
510
|
+
return { unmount: () => tag.remove() };
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
registerApp(makeApp({
|
|
514
|
+
manifest: makeAppManifest({ id: 'e8' }),
|
|
515
|
+
initialLayout: [
|
|
516
|
+
{
|
|
517
|
+
name: 'default',
|
|
518
|
+
tree: makeTree(makeTabsNode([
|
|
519
|
+
makeTabEntry({ slotId: 'A', label: 'A', viewId: 'marker:view' }),
|
|
520
|
+
makeTabEntry({ slotId: 'B', label: 'B', viewId: 'marker:view' }),
|
|
521
|
+
])),
|
|
522
|
+
},
|
|
523
|
+
],
|
|
524
|
+
}));
|
|
525
|
+
await launchApp('e8');
|
|
526
|
+
renderWithShell(LayoutRenderer, { path: [] });
|
|
527
|
+
await settle(50);
|
|
528
|
+
// Sanity: both panes have their respective markers.
|
|
529
|
+
const beforePanes = document.querySelectorAll('.tab-body-pane');
|
|
530
|
+
expect(beforePanes.length).toBe(2);
|
|
531
|
+
expect((_a = beforePanes[0].querySelector('.view-marker')) === null || _a === void 0 ? void 0 : _a.textContent).toBe('M:A');
|
|
532
|
+
expect((_b = beforePanes[1].querySelector('.view-marker')) === null || _b === void 0 ? void 0 : _b.textContent).toBe('M:B');
|
|
533
|
+
// Programmatic same-strip reorder: move tab at index 1 to position 0.
|
|
534
|
+
// This is the exact mutation the drag commit applies on a same-strip drop.
|
|
535
|
+
const ops = await import('./ops');
|
|
536
|
+
const root = layoutStore.root;
|
|
537
|
+
if ((root === null || root === void 0 ? void 0 : root.type) !== 'tabs')
|
|
538
|
+
throw new Error('expected tabs root');
|
|
539
|
+
ops.moveTabWithinTabs(root, 1, 0);
|
|
540
|
+
await settle(50);
|
|
541
|
+
// After the reorder: tabs are [B, A], activeTab=0. Pane 0 is the visible
|
|
542
|
+
// one. Both panes must still hold a mounted slot-host with the right view.
|
|
543
|
+
const afterPanes = document.querySelectorAll('.tab-body-pane');
|
|
544
|
+
expect(afterPanes.length).toBe(2);
|
|
545
|
+
expect((_c = afterPanes[0].querySelector('.view-marker')) === null || _c === void 0 ? void 0 : _c.textContent).toBe('M:B');
|
|
546
|
+
expect((_d = afterPanes[1].querySelector('.view-marker')) === null || _d === void 0 ? void 0 : _d.textContent).toBe('M:A');
|
|
547
|
+
});
|
|
548
|
+
});
|
|
@@ -51,14 +51,19 @@
|
|
|
51
51
|
$effect(() => {
|
|
52
52
|
if (!wrapper) return;
|
|
53
53
|
|
|
54
|
-
// Capture slotId at effect-run time so the cleanup closure
|
|
55
|
-
// correct slot
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
54
|
+
// Capture slotId AND wrapper at effect-run time so the cleanup closure
|
|
55
|
+
// releases the correct slot from the correct wrapper. Both can change
|
|
56
|
+
// before cleanup fires:
|
|
57
|
+
// - node.slotId reads the new value (Svelte updates state before
|
|
58
|
+
// running the effect's cleanup), so we'd release the wrong slot.
|
|
59
|
+
// - the wrapper binding can be re-pointed by Svelte if the component
|
|
60
|
+
// is reused. Capturing locks the release to the wrapper this
|
|
61
|
+
// effect actually appended into — important for the pool's "only
|
|
62
|
+
// detach if still in our wrapper" guard.
|
|
59
63
|
const currentSlotId = node.slotId;
|
|
64
|
+
const wrapperEl = wrapper;
|
|
60
65
|
const host = acquireSlotHost(currentSlotId, node.viewId, label || node.viewId || currentSlotId);
|
|
61
|
-
|
|
66
|
+
wrapperEl.appendChild(host);
|
|
62
67
|
|
|
63
68
|
// Local observer exists only to drive the placeholder's dims text;
|
|
64
69
|
// the view's own onResize is delivered by the pool.
|
|
@@ -69,11 +74,11 @@
|
|
|
69
74
|
height = Math.round(box.height);
|
|
70
75
|
}
|
|
71
76
|
});
|
|
72
|
-
ro.observe(
|
|
77
|
+
ro.observe(wrapperEl);
|
|
73
78
|
|
|
74
79
|
return () => {
|
|
75
80
|
ro.disconnect();
|
|
76
|
-
releaseSlotHost(currentSlotId);
|
|
81
|
+
releaseSlotHost(currentSlotId, wrapperEl);
|
|
77
82
|
};
|
|
78
83
|
});
|
|
79
84
|
</script>
|
|
@@ -17,11 +17,20 @@
|
|
|
17
17
|
* we don't support "merge into same tabs group" via body drop,
|
|
18
18
|
* only via strip drop. This keeps the UX unambiguous: body = split,
|
|
19
19
|
* strip = merge.
|
|
20
|
+
*
|
|
21
|
+
* Fixed positions:
|
|
22
|
+
* When the SplitNode parent of this zone's path has fixed=true at the
|
|
23
|
+
* relevant index, the zone reports no targets and renders no highlight or
|
|
24
|
+
* hit boxes. The strip drop is computed elsewhere (LayoutRenderer
|
|
25
|
+
* .onStripHover) and is not affected by this guard. See
|
|
26
|
+
* docs/superpowers/specs/2026-04-28-fixed-slot-drop-protection-design.md.
|
|
20
27
|
*/
|
|
21
28
|
|
|
22
29
|
import { dragState, setDropTarget, clearDropTarget, type DropTarget } from './drag.svelte';
|
|
23
30
|
import type { LayoutPath, SplitSide } from './ops';
|
|
24
|
-
import
|
|
31
|
+
import { isPathFixedByParent } from './ops';
|
|
32
|
+
import type { LayoutNode, TreeRootRef } from './types';
|
|
33
|
+
import { layoutStore } from './store.svelte';
|
|
25
34
|
|
|
26
35
|
let {
|
|
27
36
|
rootRef,
|
|
@@ -39,6 +48,22 @@
|
|
|
39
48
|
// the zone would shadow the slot's own interactions.
|
|
40
49
|
const active = $derived(dragState.phase === 'dragging');
|
|
41
50
|
|
|
51
|
+
/**
|
|
52
|
+
* True when the parent split of this zone's path has fixed=true at the
|
|
53
|
+
* relevant index. While true, the zone does not report split targets and
|
|
54
|
+
* does not render the quadrant highlight or hit boxes.
|
|
55
|
+
*/
|
|
56
|
+
const parentFixed = $derived.by(() => {
|
|
57
|
+
let root: LayoutNode | null;
|
|
58
|
+
if (rootRef.kind === 'docked') {
|
|
59
|
+
root = layoutStore.root;
|
|
60
|
+
} else {
|
|
61
|
+
const entry = layoutStore.tree.floats.find((f) => f.id === rootRef.floatId);
|
|
62
|
+
root = entry?.content ?? null;
|
|
63
|
+
}
|
|
64
|
+
return root ? isPathFixedByParent(root, path) : false;
|
|
65
|
+
});
|
|
66
|
+
|
|
42
67
|
function quadrantFor(x: number, y: number, rect: DOMRect): SplitSide {
|
|
43
68
|
const cx = rect.left + rect.width / 2;
|
|
44
69
|
const cy = rect.top + rect.height / 2;
|
|
@@ -57,6 +82,13 @@
|
|
|
57
82
|
|
|
58
83
|
function onMove(e: PointerEvent) {
|
|
59
84
|
if (!zoneEl) return;
|
|
85
|
+
if (parentFixed) {
|
|
86
|
+
if (hoveredSide !== null) {
|
|
87
|
+
hoveredSide = null;
|
|
88
|
+
clearDropTarget((t) => t.kind === 'split' && t.path.join('/') === path.join('/'));
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
60
92
|
const rect = zoneEl.getBoundingClientRect();
|
|
61
93
|
// If pointer is outside the zone (pointercapture from elsewhere),
|
|
62
94
|
// clear.
|
|
@@ -92,18 +124,21 @@
|
|
|
92
124
|
<div
|
|
93
125
|
class="slot-drop-zone"
|
|
94
126
|
class:active
|
|
127
|
+
class:locked={parentFixed}
|
|
95
128
|
bind:this={zoneEl}
|
|
96
129
|
onpointermove={onMove}
|
|
97
130
|
onpointerleave={onLeave}
|
|
98
131
|
>
|
|
99
|
-
{#if
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
132
|
+
{#if !parentFixed}
|
|
133
|
+
{#if hoveredSide}
|
|
134
|
+
<div class="quad-highlight quad-{hoveredSide}"></div>
|
|
135
|
+
{/if}
|
|
136
|
+
{#if active}
|
|
137
|
+
<div class="quad-target quad-left" data-testid="drop-zone-left"></div>
|
|
138
|
+
<div class="quad-target quad-right" data-testid="drop-zone-right"></div>
|
|
139
|
+
<div class="quad-target quad-top" data-testid="drop-zone-top"></div>
|
|
140
|
+
<div class="quad-target quad-bottom" data-testid="drop-zone-bottom"></div>
|
|
141
|
+
{/if}
|
|
107
142
|
{/if}
|
|
108
143
|
</div>
|
|
109
144
|
|
package/dist/layout/ops.d.ts
CHANGED
|
@@ -108,6 +108,16 @@ export declare function makeSplitWithNewTab(existing: LayoutNode, entry: TabEntr
|
|
|
108
108
|
export declare function splitNodeAtPath(root: LayoutNode, path: LayoutPath, entry: TabEntry, side: SplitSide): void;
|
|
109
109
|
/** Walk a LayoutPath and return the node at its tip, or null. */
|
|
110
110
|
export declare function nodeAtPath(root: LayoutNode, path: LayoutPath): LayoutNode | null;
|
|
111
|
+
/**
|
|
112
|
+
* True when the parent split of `path` exists and has `fixed[lastIndex] === true`.
|
|
113
|
+
*
|
|
114
|
+
* The drag layer and the ops layer both consult this to decide whether a
|
|
115
|
+
* quadrant-split drop may land at `path`. A `path.length === 0` always returns
|
|
116
|
+
* false: the root has no parent split. If the path leads through a non-split
|
|
117
|
+
* (tabs/slot) ancestor, no parent split exists at the relevant slice and the
|
|
118
|
+
* function returns false.
|
|
119
|
+
*/
|
|
120
|
+
export declare function isPathFixedByParent(root: LayoutNode, path: LayoutPath): boolean;
|
|
111
121
|
/**
|
|
112
122
|
* Post-mutation cleanup pass. Removes empty tabs groups from their
|
|
113
123
|
* parents and collapses single-child splits. Runs until the tree
|
package/dist/layout/ops.js
CHANGED
|
@@ -250,6 +250,8 @@ export function splitNodeAtPath(root, path, entry, side) {
|
|
|
250
250
|
const target = nodeAtPath(root, path);
|
|
251
251
|
if (!target)
|
|
252
252
|
return;
|
|
253
|
+
if (isPathFixedByParent(root, path))
|
|
254
|
+
return;
|
|
253
255
|
if (path.length === 0) {
|
|
254
256
|
// Root case: target IS root. Snapshot it so makeSplitWithNewTab
|
|
255
257
|
// embeds the clone, not root itself — avoids a circular reference
|
|
@@ -289,6 +291,24 @@ export function nodeAtPath(root, path) {
|
|
|
289
291
|
}
|
|
290
292
|
return cur;
|
|
291
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* True when the parent split of `path` exists and has `fixed[lastIndex] === true`.
|
|
296
|
+
*
|
|
297
|
+
* The drag layer and the ops layer both consult this to decide whether a
|
|
298
|
+
* quadrant-split drop may land at `path`. A `path.length === 0` always returns
|
|
299
|
+
* false: the root has no parent split. If the path leads through a non-split
|
|
300
|
+
* (tabs/slot) ancestor, no parent split exists at the relevant slice and the
|
|
301
|
+
* function returns false.
|
|
302
|
+
*/
|
|
303
|
+
export function isPathFixedByParent(root, path) {
|
|
304
|
+
var _a;
|
|
305
|
+
if (path.length === 0)
|
|
306
|
+
return false;
|
|
307
|
+
const parent = nodeAtPath(root, path.slice(0, -1));
|
|
308
|
+
if (!parent || parent.type !== 'split')
|
|
309
|
+
return false;
|
|
310
|
+
return ((_a = parent.fixed) === null || _a === void 0 ? void 0 : _a[path[path.length - 1]]) === true;
|
|
311
|
+
}
|
|
292
312
|
// ---------- Cleanup --------------------------------------------------------
|
|
293
313
|
/**
|
|
294
314
|
* Post-mutation cleanup pass. Removes empty tabs groups from their
|
|
@@ -307,6 +327,7 @@ export function cleanupTree(root) {
|
|
|
307
327
|
}
|
|
308
328
|
}
|
|
309
329
|
function cleanupPass(node, parent, indexInParent) {
|
|
330
|
+
var _a;
|
|
310
331
|
if (node.type === 'split') {
|
|
311
332
|
// Recurse first so we collapse bottom-up.
|
|
312
333
|
let recursed = false;
|
|
@@ -314,16 +335,23 @@ function cleanupPass(node, parent, indexInParent) {
|
|
|
314
335
|
if (cleanupPass(node.children[i], node, i))
|
|
315
336
|
recursed = true;
|
|
316
337
|
}
|
|
317
|
-
// Drop empty tabs children — unless the tabs node is persistent
|
|
338
|
+
// Drop empty tabs children — unless the tabs node is persistent OR is
|
|
339
|
+
// fixed in its parent. A fixed child must keep its position in the
|
|
340
|
+
// parent split's children array; pruning would mutate that array.
|
|
318
341
|
for (let i = node.children.length - 1; i >= 0; i--) {
|
|
319
342
|
const child = node.children[i];
|
|
320
|
-
if (child.type === 'tabs'
|
|
343
|
+
if (child.type === 'tabs'
|
|
344
|
+
&& child.tabs.length === 0
|
|
345
|
+
&& !child.persistent
|
|
346
|
+
&& !((_a = node.fixed) === null || _a === void 0 ? void 0 : _a[i])) {
|
|
321
347
|
node.children.splice(i, 1);
|
|
322
348
|
node.sizes.splice(i, 1);
|
|
323
349
|
if (node.pinned)
|
|
324
350
|
node.pinned.splice(i, 1);
|
|
325
351
|
if (node.collapsed)
|
|
326
352
|
node.collapsed.splice(i, 1);
|
|
353
|
+
if (node.fixed)
|
|
354
|
+
node.fixed.splice(i, 1);
|
|
327
355
|
recursed = true;
|
|
328
356
|
}
|
|
329
357
|
}
|
package/dist/layout/ops.test.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { findTabInTree, splitNodeAtPath, cleanupTree, findTabBySlotId } from './ops';
|
|
2
|
+
import { findTabInTree, splitNodeAtPath, cleanupTree, findTabBySlotId, isPathFixedByParent, } from './ops';
|
|
3
|
+
import { makeSlotNode, makeSplitNode, makeTabsNode, makeTabEntry, } from '../__test__/fixtures';
|
|
3
4
|
describe('findTabInTree', () => {
|
|
4
5
|
const tree = {
|
|
5
6
|
docked: {
|
|
@@ -84,3 +85,112 @@ describe('splitNodeAtPath — root case (path = [])', () => {
|
|
|
84
85
|
}
|
|
85
86
|
});
|
|
86
87
|
});
|
|
88
|
+
describe('isPathFixedByParent', () => {
|
|
89
|
+
it('returns false for root path', () => {
|
|
90
|
+
const root = makeSplitNode([makeSlotNode('a'), makeSlotNode('b')], { fixed: [true, true] });
|
|
91
|
+
expect(isPathFixedByParent(root, [])).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
it('returns false when parent has no fixed array', () => {
|
|
94
|
+
const root = makeSplitNode([makeSlotNode('a'), makeSlotNode('b')]);
|
|
95
|
+
expect(isPathFixedByParent(root, [0])).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
it('returns false when fixed[index] is false or undefined', () => {
|
|
98
|
+
const root = makeSplitNode([makeSlotNode('a'), makeSlotNode('b')], { fixed: [false, false] });
|
|
99
|
+
expect(isPathFixedByParent(root, [0])).toBe(false);
|
|
100
|
+
expect(isPathFixedByParent(root, [1])).toBe(false);
|
|
101
|
+
const sparse = makeSplitNode([makeSlotNode('a'), makeSlotNode('b')], { fixed: [true] });
|
|
102
|
+
expect(isPathFixedByParent(sparse, [1])).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
it('returns true when fixed[index] is true', () => {
|
|
105
|
+
const root = makeSplitNode([makeSlotNode('a'), makeSlotNode('b')], { fixed: [true, false] });
|
|
106
|
+
expect(isPathFixedByParent(root, [0])).toBe(true);
|
|
107
|
+
expect(isPathFixedByParent(root, [1])).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
it('returns true for a fixed grandchild reached through a non-fixed parent', () => {
|
|
110
|
+
const inner = makeSplitNode([makeSlotNode('x'), makeSlotNode('y')], { fixed: [true, false] });
|
|
111
|
+
const root = makeSplitNode([inner, makeSlotNode('b')]);
|
|
112
|
+
expect(isPathFixedByParent(root, [0, 0])).toBe(true);
|
|
113
|
+
expect(isPathFixedByParent(root, [0, 1])).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
it('returns false when the path lands inside a tabs node (parent is not a split)', () => {
|
|
116
|
+
const tabs = makeTabsNode([makeTabEntry({ slotId: 't1' })]);
|
|
117
|
+
const root = makeSplitNode([tabs, makeSlotNode('b')], { fixed: [true, false] });
|
|
118
|
+
expect(isPathFixedByParent(root, [0])).toBe(true);
|
|
119
|
+
// A path beyond a tabs node walks no further (nodeAtPath returns null).
|
|
120
|
+
expect(isPathFixedByParent(root, [0, 0])).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
describe('splitNodeAtPath — fixed-parent guard', () => {
|
|
124
|
+
const newEntry = () => ({ slotId: 'new', viewId: 'v', label: 'New' });
|
|
125
|
+
it('is a no-op when the parent has fixed=true at the target index', () => {
|
|
126
|
+
const tabsA = makeTabsNode([makeTabEntry({ slotId: 'a' })]);
|
|
127
|
+
const tabsB = makeTabsNode([makeTabEntry({ slotId: 'b' })]);
|
|
128
|
+
const root = makeSplitNode([tabsA, tabsB], { fixed: [true, false] });
|
|
129
|
+
const before = JSON.stringify(root);
|
|
130
|
+
splitNodeAtPath(root, [0], newEntry(), 'right');
|
|
131
|
+
splitNodeAtPath(root, [0], newEntry(), 'top');
|
|
132
|
+
expect(JSON.stringify(root)).toBe(before);
|
|
133
|
+
});
|
|
134
|
+
it('still splits at a non-fixed sibling index', () => {
|
|
135
|
+
const tabsA = makeTabsNode([makeTabEntry({ slotId: 'a' })]);
|
|
136
|
+
const tabsB = makeTabsNode([makeTabEntry({ slotId: 'b' })]);
|
|
137
|
+
const root = makeSplitNode([tabsA, tabsB], { fixed: [true, false] });
|
|
138
|
+
splitNodeAtPath(root, [1], newEntry(), 'right');
|
|
139
|
+
expect(root.children[1].type).toBe('split');
|
|
140
|
+
});
|
|
141
|
+
it('still splits a descendant of a fixed sub-split that is not itself fixed', () => {
|
|
142
|
+
const inner = makeSplitNode([makeTabsNode([makeTabEntry({ slotId: 'x' })]), makeTabsNode([makeTabEntry({ slotId: 'y' })])], { fixed: [false, false] });
|
|
143
|
+
const root = makeSplitNode([inner, makeSlotNode('b')], { fixed: [true, false] });
|
|
144
|
+
splitNodeAtPath(root, [0, 0], newEntry(), 'right');
|
|
145
|
+
const after = root.children[0].children[0];
|
|
146
|
+
expect(after.type).toBe('split');
|
|
147
|
+
});
|
|
148
|
+
it('still allows splitting the root (path = []) regardless of any fixed flags', () => {
|
|
149
|
+
const root = makeTabsNode([makeTabEntry({ slotId: 'a' })]);
|
|
150
|
+
splitNodeAtPath(root, [], newEntry(), 'right');
|
|
151
|
+
expect(root.type).toBe('split');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
describe('cleanupTree — fixed empty tabs preservation', () => {
|
|
155
|
+
it('keeps an empty tabs child whose parent has fixed=true at that index', () => {
|
|
156
|
+
const empty = makeTabsNode([]);
|
|
157
|
+
const sibling = makeTabsNode([makeTabEntry({ slotId: 'a' })]);
|
|
158
|
+
const root = makeSplitNode([empty, sibling], { fixed: [true, false] });
|
|
159
|
+
cleanupTree(root);
|
|
160
|
+
expect(root.type).toBe('split');
|
|
161
|
+
if (root.type === 'split') {
|
|
162
|
+
expect(root.children).toHaveLength(2);
|
|
163
|
+
const first = root.children[0];
|
|
164
|
+
expect(first.type).toBe('tabs');
|
|
165
|
+
if (first.type === 'tabs')
|
|
166
|
+
expect(first.tabs).toHaveLength(0);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
it('still prunes an empty tabs child whose parent fixed flag is false', () => {
|
|
170
|
+
const empty = makeTabsNode([]);
|
|
171
|
+
const sibling = makeTabsNode([makeTabEntry({ slotId: 'a' })]);
|
|
172
|
+
const root = makeSplitNode([empty, sibling], { fixed: [false, false] });
|
|
173
|
+
cleanupTree(root);
|
|
174
|
+
expect(root.type).toBe('tabs');
|
|
175
|
+
});
|
|
176
|
+
it('still prunes an empty tabs child when the parent has no fixed array at all', () => {
|
|
177
|
+
const empty = makeTabsNode([]);
|
|
178
|
+
const sibling = makeTabsNode([makeTabEntry({ slotId: 'a' })]);
|
|
179
|
+
const root = makeSplitNode([empty, sibling]);
|
|
180
|
+
cleanupTree(root);
|
|
181
|
+
expect(root.type).toBe('tabs');
|
|
182
|
+
});
|
|
183
|
+
it('preserves the parent fixed array length when a non-fixed empty tabs child IS pruned', () => {
|
|
184
|
+
const empty = makeTabsNode([]);
|
|
185
|
+
const fixedKept = makeTabsNode([makeTabEntry({ slotId: 'k' })]);
|
|
186
|
+
const other = makeTabsNode([makeTabEntry({ slotId: 'o' })]);
|
|
187
|
+
const root = makeSplitNode([empty, fixedKept, other], { fixed: [false, true, false] });
|
|
188
|
+
cleanupTree(root);
|
|
189
|
+
expect(root.type).toBe('split');
|
|
190
|
+
if (root.type === 'split') {
|
|
191
|
+
expect(root.children).toHaveLength(2);
|
|
192
|
+
expect(root.fixed).toEqual([true, false]);
|
|
193
|
+
expect(root.sizes).toHaveLength(2);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -10,8 +10,14 @@ export declare function acquireSlotHost(slotId: string, viewId: string | null, l
|
|
|
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
|
|
12
12
|
* that microtask cancels the destroy (the re-parent case).
|
|
13
|
+
*
|
|
14
|
+
* When `fromWrapper` is provided, the host is detached from the DOM only
|
|
15
|
+
* if its current parent is still that wrapper. This guards the same-strip
|
|
16
|
+
* tab reorder scenario (see below) where another caller has already moved
|
|
17
|
+
* the host to a different wrapper before this release fires; an
|
|
18
|
+
* unconditional detach would yank the host out of its new home.
|
|
13
19
|
*/
|
|
14
|
-
export declare function releaseSlotHost(slotId: string): void;
|
|
20
|
+
export declare function releaseSlotHost(slotId: string, fromWrapper?: HTMLElement): void;
|
|
15
21
|
/**
|
|
16
22
|
* Test / teardown helper — destroys every pooled host immediately. Used
|
|
17
23
|
* by HMR boundaries and tests; not part of normal runtime flow.
|
|
@@ -232,20 +232,39 @@ export function acquireSlotHost(slotId, viewId, label, meta) {
|
|
|
232
232
|
* Release the pooled host. If this was the last reference, a
|
|
233
233
|
* destruction is queued to run in a microtask; a later acquire before
|
|
234
234
|
* that microtask cancels the destroy (the re-parent case).
|
|
235
|
+
*
|
|
236
|
+
* When `fromWrapper` is provided, the host is detached from the DOM only
|
|
237
|
+
* if its current parent is still that wrapper. This guards the same-strip
|
|
238
|
+
* tab reorder scenario (see below) where another caller has already moved
|
|
239
|
+
* the host to a different wrapper before this release fires; an
|
|
240
|
+
* unconditional detach would yank the host out of its new home.
|
|
235
241
|
*/
|
|
236
|
-
export function releaseSlotHost(slotId) {
|
|
242
|
+
export function releaseSlotHost(slotId, fromWrapper) {
|
|
237
243
|
const entry = pool.get(slotId);
|
|
238
244
|
if (!entry)
|
|
239
245
|
return;
|
|
240
246
|
entry.refcount--;
|
|
241
247
|
if (entry.refcount > 0) {
|
|
242
|
-
// Refcount
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
248
|
+
// Refcount remains positive (e.g. acquireAppSlotHolds keeps a hold for
|
|
249
|
+
// every slot in the active layout). Detach the host from its current
|
|
250
|
+
// DOM parent ONLY when that parent is the wrapper the caller is
|
|
251
|
+
// releasing from — i.e. nobody else has moved it elsewhere yet.
|
|
252
|
+
//
|
|
253
|
+
// Same-strip tab reorder is the exact case this guard exists for:
|
|
254
|
+
// Svelte runs the two SlotContainer effects as
|
|
255
|
+
// [cleanup0, body0, cleanup1, body1]. By the time cleanup1 fires,
|
|
256
|
+
// body0 has already `appendChild`'d the host into the OTHER pane's
|
|
257
|
+
// wrapper (move semantics auto-detach from the original). An
|
|
258
|
+
// unconditional `.remove()` would empty the pane that just claimed
|
|
259
|
+
// the host and leave the active tab visibly blank.
|
|
260
|
+
//
|
|
261
|
+
// Same-instance prop change (preset switch on a single SlotContainer)
|
|
262
|
+
// still works correctly: the host's parent is still our wrapper at
|
|
263
|
+
// cleanup time, so the detach runs and the wrapper is empty when the
|
|
264
|
+
// new acquire appends a different host.
|
|
265
|
+
if (fromWrapper && entry.host.parentNode === fromWrapper) {
|
|
266
|
+
entry.host.remove();
|
|
267
|
+
}
|
|
249
268
|
return;
|
|
250
269
|
}
|
|
251
270
|
pendingDestroy.add(slotId);
|
|
@@ -96,9 +96,22 @@ export const sh3coreShard = {
|
|
|
96
96
|
};
|
|
97
97
|
ctx.registerView('sh3core:home', factory);
|
|
98
98
|
ctx.registerView('shell:keys-and-peers', keysFactory);
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
99
|
+
// Launcher parent — submenu drill host. No `run` needed: the
|
|
100
|
+
// dispatcher's default behavior opens a sub-palette filtered to
|
|
101
|
+
// `submenuOf === 'sh3.app.launch'`. The single parent replaces the
|
|
102
|
+
// per-app palette entries that used to flood the idle palette.
|
|
103
|
+
ctx.actions.register({
|
|
104
|
+
id: 'sh3.app.launch',
|
|
105
|
+
label: 'Launch app',
|
|
106
|
+
scope: ['home', 'app'],
|
|
107
|
+
submenu: true,
|
|
108
|
+
paletteItem: true,
|
|
109
|
+
});
|
|
110
|
+
// Dynamic launcher children: one per registered app, kept in sync
|
|
111
|
+
// as packages install/uninstall via the registry. Children inherit
|
|
112
|
+
// the parent's surface placement, so they don't set paletteItem
|
|
113
|
+
// themselves. Direct text match (e.g. typing the app name) still
|
|
114
|
+
// surfaces them at the root palette via the scorer.
|
|
102
115
|
const launcherUnregisters = new Map();
|
|
103
116
|
$effect.root(() => {
|
|
104
117
|
$effect(() => {
|
|
@@ -109,8 +122,9 @@ export const sh3coreShard = {
|
|
|
109
122
|
continue;
|
|
110
123
|
const off = ctx.actions.register({
|
|
111
124
|
id: `sh3.app.launch:${id}`,
|
|
112
|
-
label:
|
|
125
|
+
label: app.manifest.label,
|
|
113
126
|
scope: ['home', 'app'],
|
|
127
|
+
submenuOf: 'sh3.app.launch',
|
|
114
128
|
run() {
|
|
115
129
|
void launchApp(id);
|
|
116
130
|
},
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Auto-generated from package.json — do not edit manually. */
|
|
2
|
-
export declare const VERSION = "0.11.
|
|
2
|
+
export declare const VERSION = "0.11.7";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Auto-generated from package.json — do not edit manually. */
|
|
2
|
-
export const VERSION = '0.11.
|
|
2
|
+
export const VERSION = '0.11.7';
|