tiletopia/src/App.svelte
megaproxy f5f788652e Fix active-pane detection via activeElement polling
After exhausting event-based approaches that all failed in WebView2:
- per-leaf onpointerdown: xterm.js stopPropagation
- document-capture pointerdown: only first event ever delivered
- document-capture mousedown/click: never delivered at all
- document-capture focusin: silently fails
- term.onFocus: no such xterm.js API

The bulletproof fallback: poll document.activeElement every 250ms
and call orch.setActive on its closest [data-leaf-id] ancestor.
No DOM events involved. Verified working with automation: clicking
pane 2 turns its border blue, clicking pane 1 moves the border to
pane 1, etc.

XtermPane gained an onFocus prop (still wired through LeafPane) as a
secondary signal that might fire in some configurations, but the
polling is the actual fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:43:30 +01:00

334 lines
9.9 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { onMount } from "svelte";
import {
listDistros,
saveWorkspace,
loadWorkspace,
writeToPane,
} from "./ipc";
import Pane from "./lib/layout/Pane.svelte";
import Notifications from "./components/Notifications.svelte";
import Palette from "./components/Palette.svelte";
import {
provideOrchestration,
type TreeOps,
} from "./lib/layout/orchestration.svelte";
import {
type TreeNode,
type NodeId,
type Orientation,
type LeafNode,
newLeaf,
splitLeaf,
closeLeaf,
findLeaf,
leafCount,
walkLeaves,
changeDistro,
changeLabel,
toggleBroadcast as toggleBroadcastInTree,
serialize,
deserialize,
presetSingle,
presetTwoColumns,
presetThreeColumns,
presetTwoRows,
presetTwoByTwo,
} from "./lib/layout/tree";
const LEGACY_STORAGE_KEY = "tiletopia.tree.v1";
let defaultDistro = $state<string | undefined>(undefined);
let ready = $state(false);
let tree = $state<TreeNode>(newLeaf());
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");
}
onMount(async () => {
// 1. Try APPDATA persistence.
let loaded: TreeNode | null = null;
try {
const json = await loadWorkspace();
if (json) loaded = deserialize(json);
} catch (e) {
console.warn("loadWorkspace failed:", e);
}
// 2. Migrate from M2 localStorage if APPDATA is empty.
if (!loaded) {
try {
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacy) {
loaded = deserialize(legacy);
if (loaded) void saveWorkspace(legacy);
localStorage.removeItem(LEGACY_STORAGE_KEY);
}
} catch (e) {
console.warn("legacy localStorage migration failed:", e);
}
}
if (loaded) tree = loaded;
// 3. Resolve default distro.
try {
const ds = await listDistros();
orch.distros = ds;
defaultDistro = ds.find(isInteractiveDistro) ?? ds[0];
} catch (e) {
console.warn("list_distros failed:", e);
}
if (defaultDistro) backfillDistro(tree, defaultDistro);
ready = true;
});
function backfillDistro(node: TreeNode, fallback: string) {
if (node.kind === "leaf") {
if (!node.distro) node.distro = fallback;
} else {
backfillDistro(node.a, fallback);
backfillDistro(node.b, fallback);
}
}
// ---- debounced auto-save -------------------------------------------------
let saveTimer: number | null = null;
const SAVE_DEBOUNCE_MS = 500;
$effect(() => {
if (!ready) return;
const json = serialize(tree);
if (saveTimer != null) clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => {
saveTimer = null;
saveWorkspace(json).catch((e) =>
console.warn("saveWorkspace failed:", e),
);
}, SAVE_DEBOUNCE_MS);
});
// ---- Ctrl+K palette toggle ----------------------------------------------
// Capture phase so we win over xterm.js's keystroke capture inside terminals.
$effect(() => {
function onKey(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
console.log("[tiletopia] Ctrl+K caught, paletteOpen ->", !paletteOpen);
e.preventDefault();
e.stopPropagation();
paletteOpen = !paletteOpen;
}
}
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
});
// ---- 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.
$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");
if (id && id !== lastLeafId) {
lastLeafId = id;
orch.setActive(id);
}
}, 250);
return () => clearInterval(interval);
});
// ---- preset layouts ------------------------------------------------------
function applyPreset(make: (d: { distro?: string }) => TreeNode) {
const count = leafCount(tree);
if (count > 1 && !confirm(`Replace current layout (${count} panes)? This kills all open shells.`)) {
return;
}
tree = make({ distro: defaultDistro });
}
// ---- palette feed --------------------------------------------------------
const paletteLeaves = $derived.by<LeafNode[]>(() => {
if (!paletteOpen) return [];
return Array.from(walkLeaves(tree));
});
function onPalettePick(leafId: string) {
orch.setActive(leafId);
paletteOpen = false;
}
</script>
<div class="app">
<header class="titlebar">
<span class="label">tiletopia</span>
<span class="distros">
{#if orch.distros.length === 0}
<span class="muted">no distros enumerated</span>
{:else}
<span class="muted">default:</span>
{#each orch.distros as d}
<button
class="distro-btn"
class:active={d === defaultDistro}
onclick={() => (defaultDistro = d)}
title="Set default distro for new panes"
>{d}</button>
{/each}
{/if}
</span>
<span class="presets">
<span class="muted">layout:</span>
<button class="preset-btn" title="Single pane" onclick={() => applyPreset(presetSingle)}>1</button>
<button class="preset-btn" title="Two columns" onclick={() => applyPreset(presetTwoColumns)}>2H</button>
<button class="preset-btn" title="Three columns" onclick={() => applyPreset(presetThreeColumns)}>3H</button>
<button class="preset-btn" title="Two rows" onclick={() => applyPreset(presetTwoRows)}>2V</button>
<button class="preset-btn" title="2 × 2 grid" onclick={() => applyPreset(presetTwoByTwo)}>2×2</button>
</span>
<button class="palette-btn" onclick={() => (paletteOpen = true)} title="Jump to pane (Ctrl+K)">
⌘K
</button>
<button class="palette-btn" onclick={() => orch.notify("test toast at " + new Date().toLocaleTimeString())} title="Fire a test toast">
🔔
</button>
<span class="layout-info">
{leafCount(tree)} pane{leafCount(tree) === 1 ? "" : "s"}
</span>
</header>
<div class="pane-wrap">
{#if ready}
<Pane node={tree} />
{/if}
</div>
<Notifications
notifications={orch.notifications}
onDismiss={(id) => orch.dismiss(id)}
/>
{#if paletteOpen}
<Palette
leaves={paletteLeaves}
onPick={onPalettePick}
onClose={() => (paletteOpen = false)}
/>
{/if}
</div>
<style>
.distros, .presets {
display: flex;
gap: 4px;
align-items: center;
}
.distro-btn, .preset-btn, .palette-btn {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
background: #222;
color: #aaa;
border: 1px solid #333;
border-radius: 3px;
padding: 2px 8px;
cursor: pointer;
}
.distro-btn:hover, .preset-btn:hover, .palette-btn:hover {
background: #2a2a2a;
color: #ddd;
}
.distro-btn.active {
background: #1a3a5c;
color: #cce6ff;
border-color: #2a5a8c;
}
.preset-btn {
min-width: 28px;
text-align: center;
}
.muted {
color: #666;
font-style: italic;
}
.layout-info {
margin-left: auto;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
color: #777;
font-size: 11px;
}
</style>