tiletopia/src/App.svelte
megaproxy 058ce49d3b Force gutter-drag resize via direct DOM (same workaround)
Dragging the splitter set node.ratio in the Svelte $state tree
correctly (used by save-restore), but the template binding
style=\"flex: {node.ratio}\" on each .side div didn't re-evaluate
when ratio changed — same prop-reactivity wall we hit with the
active border and broadcast color. The gutter would drag invisibly:
internal state moved, panes stayed at their original ratio.

Workaround: SplitNode's onPointerMove now ALSO writes the flex
style directly to the two .side elements via DOM. Svelte still owns
node.ratio for persistence/serialization, but the visual is owned
by the imperative DOM write.

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

383 lines
12 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,
killPane,
} 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);
// 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<NodeId | null>(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);
const inherit = parent
? { distro: parent.distro ?? defaultDistro, cwd: parent.cwd }
: { distro: defaultDistro };
tree = splitLeaf(tree, leafId, orientation, inherit);
}
function handleClose(leafId: NodeId) {
// Kill the PTY explicitly — Svelte's onDestroy on LeafPane won't fire
// because Svelte isn't unmounting the component (same reactivity gap as
// the active/broadcast bugs).
const paneId = orch.paneIdByLeaf.get(leafId);
if (paneId != null) {
void killPane(paneId).catch((e) => console.warn("killPane failed:", e));
orch.paneIdByLeaf.delete(leafId);
}
// Brute-force hide the closed pane's flex side + the adjacent gutter so
// the sibling pane visually fills the freed space.
const leafEl = document.querySelector(`[data-leaf-id="${leafId}"]`);
const sideEl = leafEl?.closest(".side") as HTMLElement | null;
const splitEl = sideEl?.parentElement;
if (sideEl && splitEl) {
sideEl.style.display = "none";
Array.from(splitEl.children).forEach((c) => {
const child = c as HTMLElement;
if (child.classList.contains("gutter")) child.style.display = "none";
});
}
// Update tree state (used by broadcast routing, palette, persistence).
const next = closeLeaf(tree, leafId);
tree = next ?? newLeaf({ distro: defaultDistro });
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);
orch.configureActiveHandlers(setActive, clearActiveIf);
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 --------------------
// 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;
const leafEl = el?.closest("[data-leaf-id]");
const id = leafEl?.getAttribute("data-leaf-id") ?? null;
if (id && id !== lastLeafId) {
lastLeafId = id;
setActive(id);
}
// Active-border DOM sync (same workaround as below).
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");
});
}
// Broadcast-state DOM sync — Svelte's class:broadcasting={leaf.broadcast}
// and class:on={leaf.broadcast} bindings in LeafPane don't propagate
// either. Walk the tree and force the classes to match leaf.broadcast.
for (const leaf of walkLeaves(tree)) {
const leafEl = document.querySelector(`[data-leaf-id="${leaf.id}"]`);
if (!leafEl) continue;
leafEl.classList.toggle("broadcasting", !!leaf.broadcast);
const chip = leafEl.querySelector(".bcast-chip");
if (chip) chip.classList.toggle("on", !!leaf.broadcast);
}
}, 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) {
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} {activeLeafId} />
{/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>