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>
383 lines
12 KiB
Svelte
383 lines
12 KiB
Svelte
<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>
|