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:
parent
6b9a3adf85
commit
e871ee8e6e
6 changed files with 243 additions and 204 deletions
184
src/App.svelte
184
src/App.svelte
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue