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
|
||||
|
|
|
|||
|
|
@ -1,20 +1,16 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
import type { LeafNode } from "./tree";
|
||||
import type { PaneOps } from "./ops";
|
||||
import { useOrchestration } from "./orchestration.svelte";
|
||||
import XtermPane from "../../components/XtermPane.svelte";
|
||||
|
||||
let {
|
||||
leaf,
|
||||
ops,
|
||||
activeLeafId,
|
||||
}: {
|
||||
leaf: LeafNode;
|
||||
ops: PaneOps;
|
||||
activeLeafId: string | null;
|
||||
} = $props();
|
||||
let { leaf }: { leaf: LeafNode } = $props();
|
||||
|
||||
const active = $derived(activeLeafId === leaf.id);
|
||||
const orch = useOrchestration();
|
||||
|
||||
// Derives directly from orch.activeLeafId — Svelte 5 tracks the class
|
||||
// field access on every re-evaluation. No prop drilling involved.
|
||||
const active = $derived(orch.activeLeafId === leaf.id);
|
||||
|
||||
let status = $state("starting…");
|
||||
let statusOk = $state(true);
|
||||
|
|
@ -33,7 +29,7 @@
|
|||
|
||||
function commitLabel() {
|
||||
if (!editingLabel) return;
|
||||
ops.setLabel(leaf.id, labelDraft);
|
||||
orch.setLabel(leaf.id, labelDraft);
|
||||
editingLabel = false;
|
||||
}
|
||||
|
||||
|
|
@ -61,7 +57,7 @@
|
|||
|
||||
function pickDistro(d: string) {
|
||||
distroOpen = false;
|
||||
if (d !== leaf.distro) ops.setDistro(leaf.id, d);
|
||||
if (d !== leaf.distro) orch.setDistro(leaf.id, d);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
|
|
@ -75,7 +71,6 @@
|
|||
const IDLE_THRESHOLD_MS = 5000;
|
||||
let lastDataTime = Date.now();
|
||||
let notifiedThisIdle = false;
|
||||
let idleTimer: number | null = null;
|
||||
|
||||
function onDataReceived() {
|
||||
lastDataTime = Date.now();
|
||||
|
|
@ -89,21 +84,16 @@
|
|||
notifiedThisIdle = true;
|
||||
const name = leaf.label ?? leaf.distro ?? "pane";
|
||||
console.log("[tiletopia] notifying idle:", leaf.id, "quietForMs:", sinceLast);
|
||||
ops.notify(`${name} is idle`);
|
||||
orch.notify(`${name} is idle`);
|
||||
}
|
||||
}
|
||||
|
||||
idleTimer = window.setInterval(checkIdle, 1000);
|
||||
onDestroy(() => {
|
||||
if (idleTimer != null) clearInterval(idleTimer);
|
||||
});
|
||||
const idleTimer = window.setInterval(checkIdle, 1000);
|
||||
onDestroy(() => clearInterval(idleTimer));
|
||||
|
||||
// ---- broadcast -----------------------------------------------------------
|
||||
function onTerminalInput(b64: string) {
|
||||
if (leaf.broadcast) {
|
||||
console.log("[tiletopia] broadcasting from:", leaf.id);
|
||||
ops.broadcastFrom(leaf.id, b64);
|
||||
}
|
||||
if (leaf.broadcast) orch.broadcastFrom(leaf.id, b64);
|
||||
}
|
||||
|
||||
// ---- focus / active ------------------------------------------------------
|
||||
|
|
@ -115,21 +105,19 @@
|
|||
|
||||
function onPaneClick() {
|
||||
console.log("[tiletopia] pane click:", leaf.id, "currentlyActive:", active);
|
||||
if (!active) ops.setActivePane(leaf.id);
|
||||
if (!active) orch.setActive(leaf.id);
|
||||
}
|
||||
|
||||
// ---- pane id registration ------------------------------------------------
|
||||
function onPaneSpawned(paneId: number) {
|
||||
ops.registerPaneId(leaf.id, paneId);
|
||||
orch.registerPaneId(leaf.id, paneId);
|
||||
}
|
||||
onDestroy(() => {
|
||||
ops.registerPaneId(leaf.id, null);
|
||||
});
|
||||
onDestroy(() => orch.registerPaneId(leaf.id, null));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="leaf"
|
||||
class:active
|
||||
class:active={active}
|
||||
class:broadcasting={leaf.broadcast}
|
||||
role="group"
|
||||
aria-label={"Terminal pane: " + (leaf.label ?? leaf.distro ?? "unnamed")}
|
||||
|
|
@ -171,7 +159,7 @@
|
|||
tabindex="-1"
|
||||
onkeydown={() => {}}
|
||||
>
|
||||
{#each ops.distros as d}
|
||||
{#each orch.distros as d}
|
||||
<button
|
||||
class="distro-menu-item"
|
||||
class:active={d === leaf.distro}
|
||||
|
|
@ -185,7 +173,7 @@
|
|||
<button
|
||||
class="bcast-chip"
|
||||
class:on={leaf.broadcast}
|
||||
onclick={(e) => { e.stopPropagation(); ops.toggleBroadcast(leaf.id); }}
|
||||
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>
|
||||
|
|
@ -196,19 +184,19 @@
|
|||
<button
|
||||
class="pane-btn"
|
||||
title="Split right"
|
||||
onclick={(e) => { e.stopPropagation(); ops.split(leaf.id, "h"); }}
|
||||
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(); ops.split(leaf.id, "v"); }}
|
||||
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(); ops.close(leaf.id); }}
|
||||
onclick={(e) => { e.stopPropagation(); orch.close(leaf.id); }}
|
||||
aria-label="Close pane"
|
||||
>×</button>
|
||||
</span>
|
||||
|
|
@ -237,12 +225,9 @@
|
|||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid transparent;
|
||||
border: 2px solid transparent;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.leaf {
|
||||
border-width: 2px;
|
||||
}
|
||||
.leaf.active {
|
||||
border-color: #5a8cd8;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,15 @@
|
|||
<script lang="ts">
|
||||
import type { TreeNode } from "./tree";
|
||||
import type { PaneOps } from "./ops";
|
||||
import SplitNode from "./SplitNode.svelte";
|
||||
import LeafPane from "./LeafPane.svelte";
|
||||
|
||||
let {
|
||||
node,
|
||||
ops,
|
||||
activeLeafId,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
ops: PaneOps;
|
||||
activeLeafId: string | null;
|
||||
} = $props();
|
||||
let { node }: { node: TreeNode } = $props();
|
||||
</script>
|
||||
|
||||
{#if node.kind === "split"}
|
||||
<SplitNode {node} {ops} {activeLeafId} />
|
||||
<SplitNode {node} />
|
||||
{:else}
|
||||
{#key node.id}
|
||||
<LeafPane leaf={node} {ops} {activeLeafId} />
|
||||
<LeafPane leaf={node} />
|
||||
{/key}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,8 @@
|
|||
<script lang="ts">
|
||||
import type { SplitNode } from "./tree";
|
||||
import type { PaneOps } from "./ops";
|
||||
import Pane from "./Pane.svelte";
|
||||
|
||||
let {
|
||||
node,
|
||||
ops,
|
||||
activeLeafId,
|
||||
}: {
|
||||
node: SplitNode;
|
||||
ops: PaneOps;
|
||||
activeLeafId: string | null;
|
||||
} = $props();
|
||||
let { node }: { node: SplitNode } = $props();
|
||||
|
||||
let containerEl: HTMLDivElement;
|
||||
let dragging = $state(false);
|
||||
|
|
@ -47,7 +38,7 @@
|
|||
bind:this={containerEl}
|
||||
>
|
||||
<div class="side" style="flex: {node.ratio}">
|
||||
<Pane node={node.a} {ops} {activeLeafId} />
|
||||
<Pane node={node.a} />
|
||||
</div>
|
||||
<div
|
||||
class="gutter"
|
||||
|
|
@ -62,7 +53,7 @@
|
|||
onpointercancel={onPointerUp}
|
||||
></div>
|
||||
<div class="side" style="flex: {1 - node.ratio}">
|
||||
<Pane node={node.b} {ops} {activeLeafId} />
|
||||
<Pane node={node.b} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
import type { NodeId, Orientation } from "./tree";
|
||||
import type { PaneId } from "../../ipc";
|
||||
|
||||
/**
|
||||
* Bundle of operations + data that any pane in the tree may need.
|
||||
* Passed down through Pane / SplitNode / LeafPane to avoid per-callback
|
||||
* prop drilling.
|
||||
*/
|
||||
export interface PaneOps {
|
||||
// ---- tree mutation
|
||||
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;
|
||||
|
||||
// ---- orchestration (M4)
|
||||
/**
|
||||
* Called from a broadcasting pane when its user types. App looks up
|
||||
* every other broadcast-enabled leaf and writes the same bytes to it.
|
||||
* Origin pane's own PTY is written by XtermPane directly.
|
||||
*/
|
||||
broadcastFrom: (originLeafId: NodeId, dataB64: string) => void;
|
||||
/** Mark a leaf as the active (focused) pane. */
|
||||
setActivePane: (leafId: NodeId) => void;
|
||||
/** LeafPane reports its backend PaneId once spawned, or null on destroy. */
|
||||
registerPaneId: (leafId: NodeId, paneId: PaneId | null) => void;
|
||||
/** Append a transient toast to the notification stack. */
|
||||
notify: (message: string) => void;
|
||||
|
||||
// ---- data
|
||||
/** All distros known to the backend; populated once at app start. */
|
||||
distros: string[];
|
||||
}
|
||||
138
src/lib/layout/orchestration.svelte.ts
Normal file
138
src/lib/layout/orchestration.svelte.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
/**
|
||||
* 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 = $state<NodeId | null>(null);
|
||||
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 --------------------------------------------------------
|
||||
setActive(leafId: NodeId): void {
|
||||
console.log("[orch] setActive", leafId, "was", this.activeLeafId);
|
||||
this.activeLeafId = leafId;
|
||||
}
|
||||
|
||||
clearActiveIf(leafId: NodeId): void {
|
||||
if (this.activeLeafId === leafId) this.activeLeafId = null;
|
||||
}
|
||||
|
||||
// ---- 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue