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>
334 lines
9.9 KiB
Svelte
334 lines
9.9 KiB
Svelte
<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>
|