tiletopia/src/App.svelte
megaproxy 96a9180f3b Fix active-pane click via document-capture pointerdown
Root cause: xterm.js attaches its own pointerdown handler inside the
terminal and calls e.stopPropagation(), which prevents the .leaf
div's onpointerdown from firing for any click landing inside the
terminal body. That's why clicking pane bodies never moved the blue
active border — the event simply never reached our handler.

Fix: register a document-level CAPTURE-phase pointerdown listener
in App.svelte. Capture fires before xterm.js's bubble-phase handler
runs (and before it can stop propagation), so we always see the
click. The handler walks up via Element.closest('[data-leaf-id]')
to find which pane was clicked, then calls orch.setActive.

- LeafPane.svelte: add data-leaf-id={leaf.id} attribute so the
  document handler can identify the clicked pane.
- App.svelte: $effect attaches document.addEventListener('pointerdown',
  ..., true) and cleans up on teardown.
- Keep the per-leaf onpointerdown as a redundant backup for clicks
  on toolbar buttons (which sit outside the xterm subtree). Cheap
  + idempotent.

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

329 lines
9.8 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);
});
// ---- Document-level active-pane detector --------------------------------
// xterm.js calls `stopPropagation` on pointerdown inside terminals, so a
// per-leaf `onpointerdown` never fires for body clicks. A document-level
// CAPTURE-phase listener fires before xterm.js can intercept, then finds
// the nearest `data-leaf-id` ancestor to know which pane was clicked.
// Toolbar buttons also pass through (they're outside the xterm container,
// their own onclick still fires in the bubble phase afterwards).
$effect(() => {
function onAnyPointerDown(e: PointerEvent) {
const t = e.target as Element | null;
if (!t) return;
const leafEl = t.closest("[data-leaf-id]");
if (!leafEl) return;
const id = leafEl.getAttribute("data-leaf-id");
if (id) orch.setActive(id);
}
document.addEventListener("pointerdown", onAnyPointerDown, true);
return () => document.removeEventListener("pointerdown", onAnyPointerDown, true);
});
// ---- 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>