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
|
|
@ -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