Add M2 splits-tree layout

- src/lib/layout/tree.ts: pure helpers + types (newLeaf, splitLeaf,
  closeLeaf, replaceById, serialize/deserialize with shape-checking).
- SplitNode.svelte: flex container with pointer-captured gutter drag.
- LeafPane.svelte: per-pane toolbar (split-right ⇥, split-down ⇣,
  close ×) over the existing XtermPane.
- Pane.svelte: recursive dispatcher between SplitNode and LeafPane,
  keyed on leaf.id so swaps unmount XtermPane cleanly (kills PTY).
- App.svelte: tree-as-state with split/close handlers, auto-save to
  localStorage on every \$effect tick. Titlebar shows clickable distro
  buttons setting the default for new panes; existing panes keep theirs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-22 12:44:35 +01:00
parent 9beab64e00
commit efcdf6a9ce
7 changed files with 531 additions and 59 deletions

View file

@ -1,35 +1,87 @@
<script lang="ts">
import { onMount } from "svelte";
import XtermPane from "./components/XtermPane.svelte";
import { listDistros } from "./ipc";
import Pane from "./lib/layout/Pane.svelte";
import {
type TreeNode,
type NodeId,
type Orientation,
newLeaf,
splitLeaf,
closeLeaf,
leafCount,
serialize,
deserialize,
} from "./lib/layout/tree";
const STORAGE_KEY = "tiletopia.tree.v1";
let distros = $state<string[]>([]);
let selected = $state<string | undefined>(undefined);
let status = $state("starting…");
let statusOk = $state(true);
let loadError = $state<string | null>(null);
let defaultDistro = $state<string | undefined>(undefined);
let ready = $state(false);
let tree = $state<TreeNode>(newLeaf());
function isInteractiveDistro(name: string): boolean {
return !name.toLowerCase().startsWith("docker-desktop");
}
onMount(async () => {
// Restore saved layout, if any.
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const t = deserialize(saved);
if (t) tree = t;
}
// Resolve default distro.
try {
const d = await listDistros();
console.log("listDistros() returned:", d);
distros = d;
// Pick fresh every mount (HMR can preserve $state across reloads).
selected = d.find(isInteractiveDistro) ?? d[0] ?? undefined;
console.log("default selected:", selected);
distros = await listDistros();
defaultDistro = distros.find(isInteractiveDistro) ?? distros[0];
} catch (e) {
console.warn("list_distros failed:", e);
loadError = String(e);
}
// If any leaf in the (possibly-restored) tree has no distro, fill in the default.
// Handles first launch and trees saved before defaults were set.
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);
}
}
// Auto-save on every change. $effect re-runs whenever the proxied tree
// mutates anywhere (deep reactivity).
$effect(() => {
if (!ready) return;
try {
localStorage.setItem(STORAGE_KEY, serialize(tree));
} catch (e) {
console.warn("localStorage save failed:", e);
}
});
function pick(d: string) {
console.log("user picked distro:", d);
selected = d;
function handleSplit(leafId: NodeId, orientation: Orientation) {
tree = splitLeaf(tree, leafId, orientation, { distro: defaultDistro });
}
function handleClose(leafId: NodeId) {
const next = closeLeaf(tree, leafId);
tree = next ?? newLeaf({ distro: defaultDistro });
}
function resetLayout() {
if (!confirm("Replace current layout with a single pane? This kills all open shells.")) {
return;
}
tree = newLeaf({ distro: defaultDistro });
}
</script>
@ -41,34 +93,29 @@
{#if distros.length === 0}
<span class="muted">no distros enumerated</span>
{:else}
<span class="muted">default:</span>
{#each distros as d}
<button
class="distro-btn"
class:active={d === selected}
onclick={() => pick(d)}
>
{d}
</button>
class:active={d === defaultDistro}
onclick={() => (defaultDistro = d)}
title="Set default distro for new panes"
>{d}</button>
{/each}
{/if}
</span>
<span class="status {statusOk ? 'ok' : 'err'}">{status}</span>
<span class="layout-info">
{leafCount(tree)} pane{leafCount(tree) === 1 ? "" : "s"}
</span>
<button class="reset-btn" onclick={resetLayout} title="Reset to a single pane">
Reset
</button>
</header>
<div class="pane-wrap">
{#if loadError}
<pre class="err-pre">listDistros failed: {loadError}</pre>
{:else if selected !== undefined || distros.length === 0}
{#key selected}
<XtermPane
distro={selected}
onStatus={(msg, ok) => {
status = msg;
statusOk = ok;
}}
/>
{/key}
{#if ready}
<Pane node={tree} onSplit={handleSplit} onClose={handleClose} />
{/if}
</div>
</div>
@ -103,10 +150,24 @@
color: #666;
font-style: italic;
}
.err-pre {
color: #d66;
padding: 12px;
margin: 0;
white-space: pre-wrap;
.layout-info {
margin-left: auto;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
color: #777;
font-size: 11px;
}
.reset-btn {
font: inherit;
font-size: 11px;
background: #2a2a2a;
color: #aaa;
border: 1px solid #3a3a3a;
border-radius: 3px;
padding: 2px 10px;
cursor: pointer;
}
.reset-btn:hover {
background: #3a3a3a;
color: #ddd;
}
</style>