Fix M4 reactivity bugs via context + class store

Symptoms in v0.1.0 install: 📡 broadcast button didn't change color
on toggle, × close button didn't remove the pane, blue active
border stuck on the first pane. All three were UI-not-rerendering-
on-state-change manifestations of the same prop-reactivity quirk
that drilling activeLeafId tried (and apparently failed) to fix.

Refactor to the Svelte 5 canonical pattern for shared reactive
state:

- New src/lib/layout/orchestration.svelte.ts with an Orchestration
  class. Reactive fields (activeLeafId, notifications, distros) are
  class-field $state declarations; methods mutate them directly.
  Provided via context (provideOrchestration / useOrchestration);
  no prop drilling.
- App.svelte: provideOrchestration(treeOps). Tree mutations remain
  closures over the App-level tree $state; the class delegates to
  them. Pane only takes `node` now.
- Pane.svelte / SplitNode.svelte: stop drilling ops + activeLeafId.
  Pure pass-through of node.
- LeafPane.svelte: useOrchestration(); `active = $derived(
  orch.activeLeafId === leaf.id)` reads the class field directly so
  Svelte 5 tracks it per-property.
- Notifications.svelte: receives notifications + onDismiss from App
  (which gets them from orch).
- Deleted src/lib/layout/ops.ts (TreeOps moved into orchestration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-22 13:59:34 +01:00
parent 6b9a3adf85
commit e871ee8e6e
6 changed files with 243 additions and 204 deletions

View file

@ -5,12 +5,14 @@
saveWorkspace,
loadWorkspace,
writeToPane,
type PaneId,
} from "./ipc";
import Pane from "./lib/layout/Pane.svelte";
import Notifications, { type Toast } from "./components/Notifications.svelte";
import Notifications from "./components/Notifications.svelte";
import Palette from "./components/Palette.svelte";
import type { PaneOps } from "./lib/layout/ops";
import {
provideOrchestration,
type TreeOps,
} from "./lib/layout/orchestration.svelte";
import {
type TreeNode,
type NodeId,
@ -36,20 +38,69 @@
const LEGACY_STORAGE_KEY = "tiletopia.tree.v1";
let distros = $state<string[]>([]);
let defaultDistro = $state<string | undefined>(undefined);
let ready = $state(false);
let tree = $state<TreeNode>(newLeaf());
// ---- orchestration state (M4) -------------------------------------------
// leafId -> backend PaneId. Plain Map (no reactivity needed — only read
// from event handlers). Repopulated on every XtermPane mount.
const paneIdByLeaf = new Map<NodeId, PaneId>();
let notifications = $state<Toast[]>([]);
let nextNotifId = 1;
let activeLeafId = $state<NodeId | null>(null);
let paletteOpen = $state(false);
// ---- tree mutation handlers (closures over tree $state) -----------------
function handleSplit(leafId: NodeId, orientation: Orientation) {
const parent = findLeaf(tree, leafId);
const inherit = parent
? { distro: parent.distro ?? defaultDistro, cwd: parent.cwd }
: { distro: defaultDistro };
tree = splitLeaf(tree, leafId, orientation, inherit);
}
function handleClose(leafId: NodeId) {
const next = closeLeaf(tree, leafId);
tree = next ?? newLeaf({ distro: defaultDistro });
orch.clearActiveIf(leafId);
}
function handleSetDistro(leafId: NodeId, distro: string) {
tree = changeDistro(tree, leafId, distro);
}
function handleSetLabel(leafId: NodeId, label: string | undefined) {
tree = changeLabel(tree, leafId, label);
}
function handleToggleBroadcast(leafId: NodeId) {
tree = toggleBroadcastInTree(tree, leafId);
}
function handleBroadcastFrom(originLeafId: NodeId, dataB64: string) {
let peers = 0;
for (const leaf of walkLeaves(tree)) {
if (leaf.id === originLeafId) continue;
if (!leaf.broadcast) continue;
const paneId = orch.paneIdByLeaf.get(leaf.id);
if (paneId == null) {
console.warn("[tiletopia] broadcast peer has no paneId yet:", leaf.id);
continue;
}
peers++;
writeToPane(paneId, dataB64).catch((e) =>
console.warn("[tiletopia] broadcast write failed:", e),
);
}
console.log("[tiletopia] broadcastFrom", originLeafId, "→", peers, "peer(s)");
}
const treeOps: TreeOps = {
split: handleSplit,
close: handleClose,
setDistro: handleSetDistro,
setLabel: handleSetLabel,
toggleBroadcast: handleToggleBroadcast,
broadcastFrom: handleBroadcastFrom,
};
// Provide the orchestration store. All Pane / SplitNode / LeafPane
// descendants consume it via `useOrchestration()` — no prop drilling.
const orch = provideOrchestration(treeOps);
function isInteractiveDistro(name: string): boolean {
return !name.toLowerCase().startsWith("docker-desktop");
}
@ -82,8 +133,9 @@
// 3. Resolve default distro.
try {
distros = await listDistros();
defaultDistro = distros.find(isInteractiveDistro) ?? distros[0];
const ds = await listDistros();
orch.distros = ds;
defaultDistro = ds.find(isInteractiveDistro) ?? ds[0];
} catch (e) {
console.warn("list_distros failed:", e);
}
@ -128,97 +180,10 @@
paletteOpen = !paletteOpen;
}
}
window.addEventListener("keydown", onKey, true); // capture phase
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
});
// ---- pane ops ------------------------------------------------------------
function handleSplit(leafId: NodeId, orientation: Orientation) {
const parent = findLeaf(tree, leafId);
const inherit = parent
? { distro: parent.distro ?? defaultDistro, cwd: parent.cwd }
: { distro: defaultDistro };
tree = splitLeaf(tree, leafId, orientation, inherit);
}
function handleClose(leafId: NodeId) {
const next = closeLeaf(tree, leafId);
tree = next ?? newLeaf({ distro: defaultDistro });
if (activeLeafId === leafId) activeLeafId = null;
}
function handleSetDistro(leafId: NodeId, distro: string) {
tree = changeDistro(tree, leafId, distro);
}
function handleSetLabel(leafId: NodeId, label: string | undefined) {
tree = changeLabel(tree, leafId, label);
}
function handleToggleBroadcast(leafId: NodeId) {
tree = toggleBroadcastInTree(tree, leafId);
const updated = findLeaf(tree, leafId);
console.log("[tiletopia] toggleBroadcast:", leafId, "now:", updated?.broadcast);
}
function handleBroadcastFrom(originLeafId: NodeId, dataB64: string) {
let peers = 0;
for (const leaf of walkLeaves(tree)) {
if (leaf.id === originLeafId) continue;
if (!leaf.broadcast) continue;
const paneId = paneIdByLeaf.get(leaf.id);
if (paneId == null) {
console.warn("[tiletopia] broadcast peer has no paneId yet:", leaf.id);
continue;
}
peers++;
writeToPane(paneId, dataB64).catch((e) =>
console.warn("[tiletopia] broadcast write failed:", e),
);
}
console.log("[tiletopia] broadcastFrom", originLeafId, "→", peers, "peer(s)");
}
function handleSetActivePane(leafId: NodeId) {
console.log("[tiletopia] setActivePane:", leafId, "(was:", activeLeafId, ")");
activeLeafId = leafId;
}
function handleRegisterPaneId(leafId: NodeId, paneId: PaneId | null) {
if (paneId == null) paneIdByLeaf.delete(leafId);
else paneIdByLeaf.set(leafId, paneId);
}
function handleNotify(message: string) {
const id = nextNotifId++;
console.log("[tiletopia] notify:", message, "(id:", id, ")");
notifications.push({ id, message });
setTimeout(() => {
notifications = notifications.filter((n) => n.id !== id);
}, 5000);
}
function dismissNotification(id: number) {
notifications = notifications.filter((n) => n.id !== id);
}
// Note: activeLeafId is NOT in ops — it's drilled as a separate prop
// through Pane / SplitNode so each LeafPane reactively picks up changes.
// Bundling it in ops via $derived caused subsequent activeLeafId changes
// to not propagate to children (Svelte 5 prop-as-derived-object quirk).
const ops: PaneOps = $derived({
split: handleSplit,
close: handleClose,
setDistro: handleSetDistro,
setLabel: handleSetLabel,
toggleBroadcast: handleToggleBroadcast,
broadcastFrom: handleBroadcastFrom,
setActivePane: handleSetActivePane,
registerPaneId: handleRegisterPaneId,
notify: handleNotify,
distros,
});
// ---- preset layouts ------------------------------------------------------
function applyPreset(make: (d: { distro?: string }) => TreeNode) {
const count = leafCount(tree);
@ -235,7 +200,7 @@
});
function onPalettePick(leafId: string) {
activeLeafId = leafId;
orch.setActive(leafId);
paletteOpen = false;
}
</script>
@ -245,11 +210,11 @@
<span class="label">tiletopia</span>
<span class="distros">
{#if distros.length === 0}
{#if orch.distros.length === 0}
<span class="muted">no distros enumerated</span>
{:else}
<span class="muted">default:</span>
{#each distros as d}
{#each orch.distros as d}
<button
class="distro-btn"
class:active={d === defaultDistro}
@ -272,7 +237,7 @@
<button class="palette-btn" onclick={() => (paletteOpen = true)} title="Jump to pane (Ctrl+K)">
⌘K
</button>
<button class="palette-btn" onclick={() => handleNotify("test toast at " + new Date().toLocaleTimeString())} title="Fire a test toast">
<button class="palette-btn" onclick={() => orch.notify("test toast at " + new Date().toLocaleTimeString())} title="Fire a test toast">
🔔
</button>
@ -283,11 +248,14 @@
<div class="pane-wrap">
{#if ready}
<Pane node={tree} {ops} {activeLeafId} />
<Pane node={tree} />
{/if}
</div>
<Notifications {notifications} onDismiss={dismissNotification} />
<Notifications
notifications={orch.notifications}
onDismiss={(id) => orch.dismiss(id)}
/>
{#if paletteOpen}
<Palette