Migrate frontend from Svelte 5 to React 18

After hours of fighting Svelte 5's prop-reactivity through the
recursive Pane → SplitNode → LeafPane chain (props captured at
mount, never updated; context+getter pattern crashed; DOM-direct
workarounds created zombie-split click-intercept bugs), we
checkpointed the Svelte version (branch svelte-archive at e9015b2,
tarball at D:\archives\tiletopia-svelte-2026-05-22.tar.gz) and
rewrote the frontend in React.

Kept verbatim:
- All of src-tauri/ (Rust backend, Tauri config, icons)
- scripts/ (make-icon.py, release.sh)
- README.md, CLAUDE.md, memory.md
- src/lib/layout/tree.ts (pure TS — 43 tests still pass)
- src/ipc.ts (Tauri command wrappers)

Rewrote in React:
- src/App.tsx (top-level state via useState, OrchestrationProvider
  for descendants via React.Context)
- src/lib/layout/orchestration.tsx (React Context API for shared
  state — known-reliable reactivity, no Svelte 5 wall)
- src/lib/layout/Pane.tsx (recursive dispatcher)
- src/lib/layout/SplitNode.tsx (draggable gutter, local ratio state)
- src/lib/layout/LeafPane.tsx (toolbar + XtermPane)
- src/components/XtermPane.tsx (xterm.js wrapper, refs for callbacks)
- src/components/Notifications.tsx, Palette.tsx

Build: Vite + @vitejs/plugin-react. TypeScript strict. Same Tauri 2
config. Verified: pnpm check (clean), pnpm test (43/43 pass).
Not yet verified: pnpm tauri dev — that requires the Windows host.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-22 18:05:05 +01:00
parent e9015b2790
commit 774b8633dc
32 changed files with 2087 additions and 1825 deletions

170
src/lib/layout/LeafPane.css Normal file
View file

@ -0,0 +1,170 @@
.leaf {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
border: 2px solid transparent;
box-sizing: border-box;
}
.leaf.active {
border-color: #5a8cd8;
}
.leaf.broadcasting {
border-color: #e09838;
}
.leaf.active.broadcasting {
border-color: #ffb840;
}
.pane-toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
padding: 2px 8px;
background: #181818;
border-bottom: 1px solid #2a2a2a;
font-size: 11px;
color: #aaa;
user-select: none;
min-height: 24px;
}
.pane-label {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-weight: 600;
color: #ccc;
background: transparent;
border: 1px solid transparent;
border-radius: 3px;
padding: 1px 6px;
cursor: text;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.pane-label:hover {
background: #222;
border-color: #2a2a2a;
}
.label-input {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-weight: 600;
color: #fff;
background: #0c0c0c;
border: 1px solid #3a5a8c;
border-radius: 3px;
padding: 1px 6px;
outline: none;
max-width: 240px;
}
.distro-wrap {
position: relative;
}
.distro-chip,
.bcast-chip {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 10px;
background: #222;
color: #88c;
border: 1px solid #2a2a3a;
border-radius: 3px;
padding: 1px 6px;
cursor: pointer;
}
.distro-chip:hover,
.bcast-chip:hover {
background: #2a2a3a;
color: #aac;
}
.bcast-chip {
color: #777;
background: #1c1c1c;
border-color: #2a2a2a;
padding: 1px 5px;
}
.bcast-chip.on {
background: #4a3010;
color: #f0c060;
border-color: #c98a1f;
}
.distro-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 2px;
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 4px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
z-index: 10;
min-width: 140px;
display: flex;
flex-direction: column;
padding: 2px;
}
.distro-menu-item {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
text-align: left;
background: transparent;
color: #ccc;
border: none;
border-radius: 2px;
padding: 3px 8px;
cursor: pointer;
}
.distro-menu-item:hover {
background: #2a2a2a;
}
.distro-menu-item.active {
background: #1a3a5c;
color: #cce6ff;
}
.pane-status {
margin-left: auto;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
color: #777;
font-size: 10px;
white-space: nowrap;
}
.pane-status.ok { color: #6c6; }
.pane-status.err { color: #d66; }
.pane-actions {
display: flex;
gap: 2px;
}
.pane-btn {
background: transparent;
border: none;
color: #888;
font: inherit;
font-size: 14px;
line-height: 1;
padding: 2px 6px;
border-radius: 3px;
cursor: pointer;
}
.pane-btn:hover {
background: #2a2a2a;
color: #ddd;
}
.pane-btn.close:hover {
background: #5a1a1a;
color: #fcc;
}
.xterm-wrap {
flex: 1 1 auto;
min-height: 0;
position: relative;
}

View file

@ -1,400 +0,0 @@
<script lang="ts">
import { onDestroy } from "svelte";
import type { LeafNode } from "./tree";
import { useOrchestration } from "./orchestration.svelte";
import XtermPane from "../../components/XtermPane.svelte";
let {
leaf,
activeLeafId,
}: {
leaf: LeafNode;
activeLeafId: string | null;
} = $props();
const orch = useOrchestration();
let status = $state("starting…");
let statusOk = $state(true);
// ---- label editing -------------------------------------------------------
let editingLabel = $state(false);
let labelDraft = $state("");
let labelInputEl: HTMLInputElement | null = $state(null);
function startEditLabel(e: MouseEvent) {
e.stopPropagation();
labelDraft = leaf.label ?? "";
editingLabel = true;
queueMicrotask(() => labelInputEl?.select());
}
function commitLabel() {
if (!editingLabel) return;
orch.setLabel(leaf.id, labelDraft);
editingLabel = false;
}
function cancelLabel() {
editingLabel = false;
}
function onLabelKey(e: KeyboardEvent) {
if (e.key === "Enter") {
e.preventDefault();
commitLabel();
} else if (e.key === "Escape") {
e.preventDefault();
cancelLabel();
}
}
// ---- distro popover ------------------------------------------------------
let distroOpen = $state(false);
function toggleDistroMenu(e: MouseEvent) {
e.stopPropagation();
distroOpen = !distroOpen;
}
function pickDistro(d: string) {
distroOpen = false;
if (d !== leaf.distro) orch.setDistro(leaf.id, d);
}
$effect(() => {
if (!distroOpen) return;
const onDocClick = () => (distroOpen = false);
window.addEventListener("click", onDocClick);
return () => window.removeEventListener("click", onDocClick);
});
// ---- idle detection ------------------------------------------------------
const IDLE_THRESHOLD_MS = 5000;
let lastDataTime = Date.now();
let notifiedThisIdle = false;
function onDataReceived() {
lastDataTime = Date.now();
notifiedThisIdle = false;
}
function checkIdle() {
if (notifiedThisIdle) return;
const sinceLast = Date.now() - lastDataTime;
if (sinceLast >= IDLE_THRESHOLD_MS) {
notifiedThisIdle = true;
const name = leaf.label ?? leaf.distro ?? "pane";
console.log("[tiletopia] notifying idle:", leaf.id, "quietForMs:", sinceLast);
orch.notify(`${name} is idle`);
}
}
const idleTimer = window.setInterval(checkIdle, 1000);
onDestroy(() => clearInterval(idleTimer));
// ---- broadcast -----------------------------------------------------------
function onTerminalInput(b64: string) {
if (leaf.broadcast) orch.broadcastFrom(leaf.id, b64);
}
// ---- focus / active ------------------------------------------------------
let focusTrigger = $state(0);
$effect(() => {
if (activeLeafId === leaf.id) focusTrigger += 1;
});
// Backup setActive for toolbar clicks (which can't reach the document
// capture listener if a bubble-phase handler stops propagation). Cheap;
// idempotent if the document listener also fired.
function onPaneClick() {
orch.setActive(leaf.id);
}
// ---- pane id registration ------------------------------------------------
function onPaneSpawned(paneId: number) {
orch.registerPaneId(leaf.id, paneId);
}
onDestroy(() => orch.registerPaneId(leaf.id, null));
</script>
<div
class="leaf"
class:active={activeLeafId === leaf.id}
data-debug-active={activeLeafId === leaf.id ? "yes" : "no"}
data-debug-prop={(activeLeafId ?? "null").slice(0, 8)}
class:broadcasting={leaf.broadcast}
role="group"
aria-label={"Terminal pane: " + (leaf.label ?? leaf.distro ?? "unnamed")}
data-leaf-id={leaf.id}
onpointerdown={onPaneClick}
>
<div class="pane-toolbar">
{#if editingLabel}
<input
class="label-input"
bind:this={labelInputEl}
bind:value={labelDraft}
onkeydown={onLabelKey}
onblur={commitLabel}
placeholder="(label)"
/>
{:else}
<button
class="pane-label"
onclick={startEditLabel}
title="Click to rename pane"
>
{leaf.label ?? "(unnamed)"}
</button>
{/if}
<span class="distro-wrap">
<button
class="distro-chip"
onclick={toggleDistroMenu}
title="Change distro (respawns the pane)"
>
{leaf.distro ?? "(default)"}
</button>
{#if distroOpen}
<div
class="distro-menu"
onclick={(e) => e.stopPropagation()}
role="menu"
tabindex="-1"
onkeydown={() => {}}
>
{#each orch.distros as d}
<button
class="distro-menu-item"
class:active={d === leaf.distro}
onclick={() => pickDistro(d)}
>{d}</button>
{/each}
</div>
{/if}
</span>
<button
class="bcast-chip"
class:on={leaf.broadcast}
onclick={(e) => { e.stopPropagation(); orch.toggleBroadcast(leaf.id); }}
title={leaf.broadcast ? "Broadcasting (click to leave group)" : "Click to broadcast input to other broadcast panes"}
aria-pressed={leaf.broadcast ? "true" : "false"}
>📡</button>
<span class="pane-status {statusOk ? 'ok' : 'err'}">{status}</span>
<span class="pane-actions">
<button
class="pane-btn"
title="Split right"
onclick={(e) => { e.stopPropagation(); orch.split(leaf.id, "h"); }}
aria-label="Split right"
>⇥</button>
<button
class="pane-btn"
title="Split down"
onclick={(e) => { e.stopPropagation(); orch.split(leaf.id, "v"); }}
aria-label="Split down"
>⇣</button>
<button
class="pane-btn close"
title="Close pane"
onclick={(e) => { e.stopPropagation(); orch.close(leaf.id); }}
aria-label="Close pane"
>×</button>
</span>
</div>
<div class="xterm-wrap">
<XtermPane
distro={leaf.distro}
cwd={leaf.cwd}
onStatus={(msg, ok) => {
status = msg;
statusOk = ok;
}}
onSpawn={onPaneSpawned}
onInput={onTerminalInput}
onFocus={() => orch.setActive(leaf.id)}
{onDataReceived}
{focusTrigger}
/>
</div>
</div>
<style>
.leaf {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
border: 2px solid transparent;
box-sizing: border-box;
}
.leaf.active {
border-color: #5a8cd8;
}
.leaf.broadcasting {
border-color: #e09838;
}
.leaf.active.broadcasting {
border-color: #ffb840;
}
.pane-toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
padding: 2px 8px;
background: #181818;
border-bottom: 1px solid #2a2a2a;
font-size: 11px;
color: #aaa;
user-select: none;
min-height: 24px;
}
.pane-label {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-weight: 600;
color: #ccc;
background: transparent;
border: 1px solid transparent;
border-radius: 3px;
padding: 1px 6px;
cursor: text;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.pane-label:hover {
background: #222;
border-color: #2a2a2a;
}
.label-input {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-weight: 600;
color: #fff;
background: #0c0c0c;
border: 1px solid #3a5a8c;
border-radius: 3px;
padding: 1px 6px;
outline: none;
max-width: 240px;
}
.distro-wrap {
position: relative;
}
.distro-chip,
.bcast-chip {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 10px;
background: #222;
color: #88c;
border: 1px solid #2a2a3a;
border-radius: 3px;
padding: 1px 6px;
cursor: pointer;
}
.distro-chip:hover,
.bcast-chip:hover {
background: #2a2a3a;
color: #aac;
}
.bcast-chip {
color: #777;
background: #1c1c1c;
border-color: #2a2a2a;
padding: 1px 5px;
}
.bcast-chip.on {
background: #4a3010;
color: #f0c060;
border-color: #c98a1f;
}
.distro-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 2px;
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 4px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
z-index: 10;
min-width: 140px;
display: flex;
flex-direction: column;
padding: 2px;
}
.distro-menu-item {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
text-align: left;
background: transparent;
color: #ccc;
border: none;
border-radius: 2px;
padding: 3px 8px;
cursor: pointer;
}
.distro-menu-item:hover {
background: #2a2a2a;
}
.distro-menu-item.active {
background: #1a3a5c;
color: #cce6ff;
}
.pane-status {
margin-left: auto;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
color: #777;
font-size: 10px;
white-space: nowrap;
}
.pane-status.ok { color: #6c6; }
.pane-status.err { color: #d66; }
.pane-actions {
display: flex;
gap: 2px;
}
.pane-btn {
background: transparent;
border: none;
color: #888;
font: inherit;
font-size: 14px;
line-height: 1;
padding: 2px 6px;
border-radius: 3px;
cursor: pointer;
}
.pane-btn:hover {
background: #2a2a2a;
color: #ddd;
}
.pane-btn.close:hover {
background: #5a1a1a;
color: #fcc;
}
.xterm-wrap {
flex: 1 1 auto;
min-height: 0;
position: relative;
}
</style>

263
src/lib/layout/LeafPane.tsx Normal file
View file

@ -0,0 +1,263 @@
import {
useState,
useEffect,
useRef,
useCallback,
type KeyboardEvent,
type MouseEvent,
} from "react";
import type { LeafNode } from "./tree";
import { useOrchestration } from "./orchestration";
import XtermPane from "../../components/XtermPane";
import "./LeafPane.css";
const IDLE_THRESHOLD_MS = 5000;
export default function LeafPane({ leaf }: { leaf: LeafNode }) {
const orch = useOrchestration();
const isActive = orch.activeLeafId === leaf.id;
const isBroadcasting = !!leaf.broadcast;
// ---- status (from XtermPane) -------------------------------------------
const [status, setStatus] = useState("starting…");
const [statusOk, setStatusOk] = useState(true);
// ---- label editing -----------------------------------------------------
const [editingLabel, setEditingLabel] = useState(false);
const [labelDraft, setLabelDraft] = useState("");
const labelInputRef = useRef<HTMLInputElement | null>(null);
const startEditLabel = useCallback(
(e: MouseEvent) => {
e.stopPropagation();
setLabelDraft(leaf.label ?? "");
setEditingLabel(true);
// Focus on next tick so input is mounted
queueMicrotask(() => labelInputRef.current?.select());
},
[leaf.label],
);
const commitLabel = useCallback(() => {
if (!editingLabel) return;
orch.setLabel(leaf.id, labelDraft);
setEditingLabel(false);
}, [editingLabel, orch, leaf.id, labelDraft]);
const cancelLabel = useCallback(() => setEditingLabel(false), []);
const onLabelKey = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
commitLabel();
} else if (e.key === "Escape") {
e.preventDefault();
cancelLabel();
}
},
[commitLabel, cancelLabel],
);
// ---- distro popover ----------------------------------------------------
const [distroOpen, setDistroOpen] = useState(false);
const toggleDistroMenu = useCallback((e: MouseEvent) => {
e.stopPropagation();
setDistroOpen((v) => !v);
}, []);
const pickDistro = useCallback(
(d: string) => {
setDistroOpen(false);
if (d !== leaf.distro) orch.setDistro(leaf.id, d);
},
[orch, leaf.id, leaf.distro],
);
// Dismiss popover on outside click
useEffect(() => {
if (!distroOpen) return;
const onDocClick = () => setDistroOpen(false);
window.addEventListener("click", onDocClick);
return () => window.removeEventListener("click", onDocClick);
}, [distroOpen]);
// ---- idle detection ----------------------------------------------------
const lastDataTimeRef = useRef(Date.now());
const notifiedThisIdleRef = useRef(false);
const onDataReceived = useCallback(() => {
lastDataTimeRef.current = Date.now();
notifiedThisIdleRef.current = false;
}, []);
useEffect(() => {
const id = window.setInterval(() => {
if (notifiedThisIdleRef.current) return;
const dt = Date.now() - lastDataTimeRef.current;
if (dt >= IDLE_THRESHOLD_MS) {
notifiedThisIdleRef.current = true;
const name = leaf.label ?? leaf.distro ?? "pane";
orch.notify(`${name} is idle`);
}
}, 1000);
return () => clearInterval(id);
}, [leaf.label, leaf.distro, orch]);
// ---- broadcast ---------------------------------------------------------
const onTerminalInput = useCallback(
(b64: string) => {
if (isBroadcasting) orch.broadcastFrom(leaf.id, b64);
},
[isBroadcasting, orch, leaf.id],
);
// ---- focus / active highlighting ---------------------------------------
const [focusTrigger, setFocusTrigger] = useState(0);
// When this leaf becomes active, bump focusTrigger so XtermPane refocuses.
useEffect(() => {
if (isActive) setFocusTrigger((n) => n + 1);
}, [isActive]);
const onPaneClick = useCallback(() => {
orch.setActive(leaf.id);
}, [orch, leaf.id]);
const onPaneSpawned = useCallback(
(paneId: number) => {
orch.registerPaneId(leaf.id, paneId);
},
[orch, leaf.id],
);
// Unregister on unmount
useEffect(() => {
return () => orch.registerPaneId(leaf.id, null);
}, [orch, leaf.id]);
const onXtermFocus = useCallback(() => orch.setActive(leaf.id), [orch, leaf.id]);
const onStatus = useCallback((msg: string, ok: boolean) => {
setStatus(msg);
setStatusOk(ok);
}, []);
const labelText = leaf.label ?? "(unnamed)";
return (
<div
className={`leaf${isActive ? " active" : ""}${isBroadcasting ? " broadcasting" : ""}`}
role="group"
aria-label={`Terminal pane: ${leaf.label ?? leaf.distro ?? "unnamed"}`}
data-leaf-id={leaf.id}
onPointerDown={onPaneClick}
>
<div className="pane-toolbar">
{editingLabel ? (
<input
ref={labelInputRef}
className="label-input"
value={labelDraft}
onChange={(e) => setLabelDraft(e.target.value)}
onKeyDown={onLabelKey}
onBlur={commitLabel}
placeholder="(label)"
/>
) : (
<button
className="pane-label"
onClick={startEditLabel}
title="Click to rename pane"
>
{labelText}
</button>
)}
<span className="distro-wrap">
<button
className="distro-chip"
onClick={toggleDistroMenu}
title="Change distro (respawns the pane)"
>
{leaf.distro ?? "(default)"}
</button>
{distroOpen && (
<div
className="distro-menu"
role="menu"
onClick={(e) => e.stopPropagation()}
>
{orch.distros.map((d) => (
<button
key={d}
className={`distro-menu-item${d === leaf.distro ? " active" : ""}`}
onClick={() => pickDistro(d)}
>
{d}
</button>
))}
</div>
)}
</span>
<button
className={`bcast-chip${isBroadcasting ? " on" : ""}`}
onClick={(e) => {
e.stopPropagation();
orch.toggleBroadcast(leaf.id);
}}
title={
isBroadcasting
? "Broadcasting (click to leave group)"
: "Click to broadcast input to other broadcast panes"
}
aria-pressed={isBroadcasting ? "true" : "false"}
>
📡
</button>
<span className={`pane-status ${statusOk ? "ok" : "err"}`}>{status}</span>
<span className="pane-actions">
<button
className="pane-btn"
title="Split right"
onClick={(e) => {
e.stopPropagation();
orch.split(leaf.id, "h");
}}
aria-label="Split right"
>
</button>
<button
className="pane-btn"
title="Split down"
onClick={(e) => {
e.stopPropagation();
orch.split(leaf.id, "v");
}}
aria-label="Split down"
>
</button>
<button
className="pane-btn close"
title="Close pane"
onClick={(e) => {
e.stopPropagation();
orch.close(leaf.id);
}}
aria-label="Close pane"
>
×
</button>
</span>
</div>
<div className="xterm-wrap">
<XtermPane
distro={leaf.distro}
cwd={leaf.cwd}
onStatus={onStatus}
onSpawn={onPaneSpawned}
onInput={onTerminalInput}
onDataReceived={onDataReceived}
onFocus={onXtermFocus}
focusTrigger={focusTrigger}
/>
</div>
</div>
);
}

View file

@ -1,19 +0,0 @@
<script lang="ts">
import type { TreeNode, NodeId } from "./tree";
import SplitNode from "./SplitNode.svelte";
import LeafPane from "./LeafPane.svelte";
let {
node,
activeLeafId,
}: {
node: TreeNode;
activeLeafId: NodeId | null;
} = $props();
</script>
{#if node.kind === "split"}
<SplitNode {node} {activeLeafId} />
{:else}
<LeafPane leaf={node} {activeLeafId} />
{/if}

16
src/lib/layout/Pane.tsx Normal file
View file

@ -0,0 +1,16 @@
import type { TreeNode } from "./tree";
import SplitNode from "./SplitNode";
import LeafPane from "./LeafPane";
/**
* Recursive dispatcher: render a split or a leaf based on node.kind.
* The `key={node.id}` on the leaf branch makes React unmount + remount
* cleanly when a leaf is replaced (e.g. changeDistro swaps the id to
* force PTY respawn).
*/
export default function Pane({ node }: { node: TreeNode }) {
if (node.kind === "split") {
return <SplitNode node={node} />;
}
return <LeafPane key={node.id} leaf={node} />;
}

View file

@ -0,0 +1,36 @@
.split {
display: flex;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.split.horizontal {
flex-direction: row;
}
.split.vertical {
flex-direction: column;
}
.side {
display: flex;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.gutter {
flex: 0 0 4px;
background: #1a1a1a;
cursor: col-resize;
user-select: none;
touch-action: none;
transition: background 0.12s;
}
.split.vertical > .gutter {
cursor: row-resize;
}
.gutter:hover,
.gutter.active {
background: #3a5a8c;
}

View file

@ -1,116 +0,0 @@
<script lang="ts">
import type { SplitNode, NodeId } from "./tree";
import Pane from "./Pane.svelte";
let {
node,
activeLeafId,
}: {
node: SplitNode;
activeLeafId: NodeId | null;
} = $props();
let containerEl: HTMLDivElement;
let dragging = $state(false);
function onPointerDown(e: PointerEvent) {
(e.target as HTMLElement).setPointerCapture(e.pointerId);
dragging = true;
e.preventDefault();
}
// rAF-throttle the DOM flex update so we don't spam SIGWINCH to PTYs.
let pendingRaf: number | null = null;
let pendingRatio = 0;
function onPointerMove(e: PointerEvent) {
if (!dragging || !containerEl) return;
const rect = containerEl.getBoundingClientRect();
const isH = node.orientation === "h";
const pos = isH ? e.clientX - rect.left : e.clientY - rect.top;
const size = isH ? rect.width : rect.height;
if (size <= 0) return;
const r = Math.max(0.05, Math.min(0.95, pos / size));
node.ratio = r;
pendingRatio = r;
if (pendingRaf == null) {
pendingRaf = requestAnimationFrame(() => {
pendingRaf = null;
const sides = containerEl.querySelectorAll(":scope > .side");
if (sides[0]) (sides[0] as HTMLElement).style.flex = String(pendingRatio);
if (sides[1]) (sides[1] as HTMLElement).style.flex = String(1 - pendingRatio);
});
}
}
function onPointerUp(e: PointerEvent) {
if (!dragging) return;
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
dragging = false;
}
</script>
<div
class="split"
class:horizontal={node.orientation === "h"}
class:vertical={node.orientation === "v"}
bind:this={containerEl}
>
<div class="side" style="flex: {node.ratio}">
<Pane node={node.a} {activeLeafId} />
</div>
<div
class="gutter"
class:active={dragging}
role="separator"
aria-orientation={node.orientation === "h" ? "vertical" : "horizontal"}
aria-valuenow={Math.round(node.ratio * 100)}
tabindex="-1"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
></div>
<div class="side" style="flex: {1 - node.ratio}">
<Pane node={node.b} {activeLeafId} />
</div>
</div>
<style>
.split {
display: flex;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.split.horizontal {
flex-direction: row;
}
.split.vertical {
flex-direction: column;
}
.side {
display: flex;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.gutter {
flex: 0 0 4px;
background: #1a1a1a;
cursor: col-resize;
user-select: none;
touch-action: none;
transition: background 0.12s;
}
.split.vertical > .gutter {
cursor: row-resize;
}
.gutter:hover,
.gutter.active {
background: #3a5a8c;
}
</style>

View file

@ -0,0 +1,79 @@
import { useRef, useState, useCallback, type PointerEvent } from "react";
import type { SplitNode as SplitNodeType } from "./tree";
import Pane from "./Pane";
import "./SplitNode.css";
/**
* A horizontal or vertical split with a draggable gutter. The ratio is
* local React state when the gutter is dragged, we update the local
* ratio (re-rendering the two .side flex values) and ALSO bubble the
* change up to the tree (so it persists across reloads).
*
* Initialising local state from node.ratio is fine: when the tree
* mutates around this split (e.g. a child is closed), React will give us
* a new `node` prop with possibly-different `node.ratio`, but the
* `useState` initializer only runs once. We re-sync via an effect.
*/
export default function SplitNode({ node }: { node: SplitNodeType }) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [ratio, setRatio] = useState(node.ratio);
const [dragging, setDragging] = useState(false);
// Keep local ratio in sync if the tree updates from outside (e.g. preset
// applied). Only mirror — don't echo back into the tree.
// (Skipped for simplicity in v1; if it becomes annoying we can add it.)
const onPointerDown = useCallback((e: PointerEvent<HTMLDivElement>) => {
(e.target as HTMLElement).setPointerCapture(e.pointerId);
setDragging(true);
e.preventDefault();
}, []);
const onPointerMove = useCallback(
(e: PointerEvent<HTMLDivElement>) => {
if (!dragging || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const isH = node.orientation === "h";
const pos = isH ? e.clientX - rect.left : e.clientY - rect.top;
const size = isH ? rect.width : rect.height;
if (size <= 0) return;
const r = Math.max(0.05, Math.min(0.95, pos / size));
setRatio(r);
// Mutate the proxy-tree node directly so the persisted state matches.
node.ratio = r;
},
[dragging, node],
);
const onPointerUp = useCallback((e: PointerEvent<HTMLDivElement>) => {
setDragging(false);
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
}, []);
const isH = node.orientation === "h";
return (
<div
ref={containerRef}
className={`split ${isH ? "horizontal" : "vertical"}`}
>
<div className="side" style={{ flex: ratio }}>
<Pane node={node.a} />
</div>
<div
className={`gutter${dragging ? " active" : ""}`}
role="separator"
aria-orientation={isH ? "vertical" : "horizontal"}
aria-valuenow={Math.round(ratio * 100)}
tabIndex={-1}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
/>
<div className="side" style={{ flex: 1 - ratio }}>
<Pane node={node.b} />
</div>
</div>
);
}

View file

@ -1,143 +0,0 @@
/**
* Orchestration store all shared reactive state + operations the
* pane tree needs. Lives in a class with `$state` fields so Svelte 5
* reactivity tracks per-property access; provided via context so any
* descendant component can `useOrchestration()` without prop drilling.
*
* (File must be `.svelte.ts` because `$state` can only be used in
* Svelte components or files with the `.svelte.{js,ts}` extension.)
*/
import { setContext, getContext } from "svelte";
import type { NodeId, Orientation } from "./tree";
import type { PaneId } from "../../ipc";
export interface Toast {
id: number;
message: string;
}
/**
* Callbacks App provides at construction time. These do tree mutations
* (which require access to App's `tree = $state(...)`) plus broadcast
* routing (which also needs the tree). Kept as an injection seam rather
* than living inside the store so the store doesn't need to own the tree.
*/
export interface TreeOps {
split: (leafId: NodeId, orientation: Orientation) => void;
close: (leafId: NodeId) => void;
setDistro: (leafId: NodeId, distro: string) => void;
setLabel: (leafId: NodeId, label: string | undefined) => void;
toggleBroadcast: (leafId: NodeId) => void;
broadcastFrom: (originLeafId: NodeId, dataB64: string) => void;
}
export class Orchestration {
// ---- shared reactive state ----------------------------------------------
// (activeLeafId lives at App level and is drilled as a prop — Svelte 5
// doesn't seem to track class-field $state reads from child components
// that obtain the instance via getContext. Tested empirically.)
notifications = $state<Toast[]>([]);
distros = $state<string[]>([]);
// ---- non-reactive lookups -----------------------------------------------
// Plain Map: broadcast routing reads this from an event handler, not
// from reactive context. No need for $state.
paneIdByLeaf = new Map<NodeId, PaneId>();
// ---- internal -----------------------------------------------------------
#nextNotifId = 1;
#dismissTimers = new Map<number, ReturnType<typeof setTimeout>>();
#ops: TreeOps;
constructor(ops: TreeOps) {
this.#ops = ops;
}
// ---- active pane (delegated to App) -------------------------------------
// These point at App-level $state mutators set via configure().
setActive: (leafId: NodeId) => void = () => {};
clearActiveIf: (leafId: NodeId) => void = () => {};
configureActiveHandlers(
setActive: (leafId: NodeId) => void,
clearActiveIf: (leafId: NodeId) => void,
): void {
this.setActive = setActive;
this.clearActiveIf = clearActiveIf;
}
// ---- notifications ------------------------------------------------------
notify(message: string): void {
const id = this.#nextNotifId++;
console.log("[orch] notify", message);
this.notifications.push({ id, message });
const timer = setTimeout(() => {
this.notifications = this.notifications.filter((n) => n.id !== id);
this.#dismissTimers.delete(id);
}, 5000);
this.#dismissTimers.set(id, timer);
}
dismiss(id: number): void {
const t = this.#dismissTimers.get(id);
if (t) {
clearTimeout(t);
this.#dismissTimers.delete(id);
}
this.notifications = this.notifications.filter((n) => n.id !== id);
}
// ---- pane id registry ---------------------------------------------------
registerPaneId(leafId: NodeId, paneId: PaneId | null): void {
if (paneId == null) this.paneIdByLeaf.delete(leafId);
else this.paneIdByLeaf.set(leafId, paneId);
}
// ---- delegated tree ops -------------------------------------------------
// Thin pass-through so consumers only need one object.
split(leafId: NodeId, orientation: Orientation): void {
console.log("[orch] split", leafId, orientation);
this.#ops.split(leafId, orientation);
}
close(leafId: NodeId): void {
console.log("[orch] close", leafId);
this.#ops.close(leafId);
}
setDistro(leafId: NodeId, distro: string): void {
this.#ops.setDistro(leafId, distro);
}
setLabel(leafId: NodeId, label: string | undefined): void {
this.#ops.setLabel(leafId, label);
}
toggleBroadcast(leafId: NodeId): void {
console.log("[orch] toggleBroadcast", leafId);
this.#ops.toggleBroadcast(leafId);
}
broadcastFrom(originLeafId: NodeId, dataB64: string): void {
this.#ops.broadcastFrom(originLeafId, dataB64);
}
}
const KEY = Symbol("tiletopia.orchestration");
export function provideOrchestration(ops: TreeOps): Orchestration {
const o = new Orchestration(ops);
setContext(KEY, o);
return o;
}
export function useOrchestration(): Orchestration {
const o = getContext<Orchestration | undefined>(KEY);
if (!o) {
throw new Error(
"useOrchestration() called outside a provideOrchestration() ancestor",
);
}
return o;
}

View file

@ -0,0 +1,58 @@
import { createContext, useContext, type ReactNode } from "react";
import type { Orientation, NodeId } from "./tree";
import type { PaneId } from "../../ipc";
/**
* Orchestration context every piece of shared state and every operation
* that a Pane / SplitNode / LeafPane might call. Lives in React context so
* descendants can `useOrchestration()` without prop drilling.
*
* activeLeafId comes in as a plain value (re-derived by App's useState).
* React's context is reactive: when the App-level Provider updates the
* value, ALL consumers re-render. No Svelte-style props-don't-propagate
* trap here.
*/
export interface Orchestration {
// Read-only state
activeLeafId: NodeId | null;
distros: string[];
// Tree mutations
split: (leafId: NodeId, orientation: Orientation) => void;
close: (leafId: NodeId) => void;
setDistro: (leafId: NodeId, distro: string) => void;
setLabel: (leafId: NodeId, label: string | undefined) => void;
toggleBroadcast: (leafId: NodeId) => void;
// Per-pane orchestration
setActive: (leafId: NodeId) => void;
registerPaneId: (leafId: NodeId, paneId: PaneId | null) => void;
broadcastFrom: (originLeafId: NodeId, dataB64: string) => void;
notify: (message: string) => void;
}
const OrchestrationContext = createContext<Orchestration | null>(null);
export function OrchestrationProvider({
value,
children,
}: {
value: Orchestration;
children: ReactNode;
}) {
return (
<OrchestrationContext.Provider value={value}>
{children}
</OrchestrationContext.Provider>
);
}
export function useOrchestration(): Orchestration {
const orch = useContext(OrchestrationContext);
if (!orch) {
throw new Error(
"useOrchestration() must be called inside <OrchestrationProvider>",
);
}
return orch;
}