From 854201be843b2fff55059f7f6677e6215da9d6f1 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Fri, 22 May 2026 16:15:23 +0100 Subject: [PATCH] Force active-border via direct DOM manipulation in polling loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Svelte 5's template reactivity on \`class:active={activeLeafId === leaf.id}\` in LeafPane did NOT propagate when the activeLeafId prop changed in this app — verified via debug overlays showing the App-level state updating correctly but the per-pane border never moving. Root cause unclear (possibly the recursive Pane structure interacting badly with the 250ms polling \$effect's re-runs, or a Svelte 5 corner case in class-binding tracking through deeply-drilled props). Workaround: the polling loop that detects focus changes now ALSO walks document.querySelectorAll("[data-leaf-id].leaf") on every tick and directly toggles the .active class via element.classList. If Svelte re-renders and reverts, the next 250ms tick puts it back. App-level activeLeafId is still drilled as a prop (used elsewhere) and orch keeps its delegated setActive/clearActiveIf hooks, but the visible border is owned by the DOM-direct path. Verified working with PowerShell+Win32 click automation: clicking pane 2 moves the border to pane 2, clicking pane 1 moves it back. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/App.svelte | 48 +++++++++++++++++--------- src/lib/layout/LeafPane.svelte | 21 +++++++---- src/lib/layout/Pane.svelte | 16 +++++---- src/lib/layout/SplitNode.svelte | 14 +++++--- src/lib/layout/orchestration.svelte.ts | 21 ++++++----- 5 files changed, 80 insertions(+), 40 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index 1e90889..d5e8550 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -43,6 +43,18 @@ let tree = $state(newLeaf()); let paletteOpen = $state(false); + // activeLeafId lives here (not on the orch class) because Svelte 5 didn't + // reliably track class-field $state reads from child components that + // obtained the orch instance via getContext. Local $state drilled via + // prop works. + let activeLeafId = $state(null); + function setActive(id: NodeId) { + activeLeafId = id; + } + function clearActiveIf(id: NodeId) { + if (activeLeafId === id) activeLeafId = null; + } + // ---- tree mutation handlers (closures over tree $state) ----------------- function handleSplit(leafId: NodeId, orientation: Orientation) { const parent = findLeaf(tree, leafId); @@ -55,7 +67,7 @@ function handleClose(leafId: NodeId) { const next = closeLeaf(tree, leafId); tree = next ?? newLeaf({ distro: defaultDistro }); - orch.clearActiveIf(leafId); + clearActiveIf(leafId); } function handleSetDistro(leafId: NodeId, distro: string) { @@ -100,6 +112,7 @@ // Provide the orchestration store. All Pane / SplitNode / LeafPane // descendants consume it via `useOrchestration()` — no prop drilling. const orch = provideOrchestration(treeOps); + orch.configureActiveHandlers(setActive, clearActiveIf); function isInteractiveDistro(name: string): boolean { return !name.toLowerCase().startsWith("docker-desktop"); @@ -185,25 +198,28 @@ }); // ---- Active-pane detector via active-element polling -------------------- - // Tried (and failed in WebView2): per-leaf onpointerdown (xterm blocks - // propagation), document-capture pointerdown (Webview2 only delivers the - // first one then nothing), document-capture focusin (also silently fails), - // xterm.js term.onFocus (no such API), textarea focus listener (race). - // - // Polling document.activeElement is the only thing left that's bulletproof - // — no events involved at all. 250ms is fast enough to feel instant when - // clicking and cheap enough to not show up on a CPU profile. + // We tried letting Svelte handle class:active reactively in LeafPane, but + // through this app's component chain the prop changes don't trigger a + // template re-evaluation reliably (root cause unclear — likely a Svelte 5 + // interaction with our recursive Pane / setInterval pattern). So we ALSO + // manipulate `.leaf.active` directly via DOM as a backstop. $effect(() => { let lastLeafId: string | null = null; const interval = window.setInterval(() => { const el = document.activeElement; - if (!el) return; - const leafEl = el.closest("[data-leaf-id]"); - if (!leafEl) return; - const id = leafEl.getAttribute("data-leaf-id"); + const leafEl = el?.closest("[data-leaf-id]"); + const id = leafEl?.getAttribute("data-leaf-id") ?? null; if (id && id !== lastLeafId) { lastLeafId = id; - orch.setActive(id); + setActive(id); + } + // Unconditionally re-assert the DOM state every tick — if Svelte + // re-renders and reverts our class change, the next tick puts it back. + if (id) { + document.querySelectorAll("[data-leaf-id].leaf").forEach((el) => { + if (el.getAttribute("data-leaf-id") === id) el.classList.add("active"); + else el.classList.remove("active"); + }); } }, 250); return () => clearInterval(interval); @@ -225,7 +241,7 @@ }); function onPalettePick(leafId: string) { - orch.setActive(leafId); + setActive(leafId); paletteOpen = false; } @@ -273,7 +289,7 @@
{#if ready} - + {/if}
diff --git a/src/lib/layout/LeafPane.svelte b/src/lib/layout/LeafPane.svelte index 645a095..58995dc 100644 --- a/src/lib/layout/LeafPane.svelte +++ b/src/lib/layout/LeafPane.svelte @@ -4,13 +4,20 @@ import { useOrchestration } from "./orchestration.svelte"; import XtermPane from "../../components/XtermPane.svelte"; - let { leaf }: { leaf: LeafNode } = $props(); + let { + leaf, + activeLeafId, + }: { + leaf: LeafNode; + activeLeafId: string | null; + } = $props(); 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); + // Diagnostic — log on every prop change. + $effect(() => { + console.log("[LeafPane render]", leaf.id.slice(0, 8), "activeLeafId=", activeLeafId?.slice(0, 8) ?? "null", "match=", activeLeafId === leaf.id); + }); let status = $state("starting…"); let statusOk = $state(true); @@ -100,7 +107,7 @@ let focusTrigger = $state(0); $effect(() => { - if (active) focusTrigger += 1; + if (activeLeafId === leaf.id) focusTrigger += 1; }); // Backup setActive for toolbar clicks (which can't reach the document @@ -119,7 +126,9 @@
- import type { TreeNode } from "./tree"; + import type { TreeNode, NodeId } from "./tree"; import SplitNode from "./SplitNode.svelte"; import LeafPane from "./LeafPane.svelte"; - let { node }: { node: TreeNode } = $props(); + let { + node, + activeLeafId, + }: { + node: TreeNode; + activeLeafId: NodeId | null; + } = $props(); {#if node.kind === "split"} - + {:else} - {#key node.id} - - {/key} + {/if} diff --git a/src/lib/layout/SplitNode.svelte b/src/lib/layout/SplitNode.svelte index b4fe6e7..83a9cff 100644 --- a/src/lib/layout/SplitNode.svelte +++ b/src/lib/layout/SplitNode.svelte @@ -1,8 +1,14 @@