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:
parent
9beab64e00
commit
efcdf6a9ce
7 changed files with 531 additions and 59 deletions
17
memory.md
17
memory.md
|
|
@ -11,12 +11,15 @@ Durable memory for this project. Read at session start, update before session en
|
||||||
- **Source on Windows-native disk (`D:\dev\tiletopia\`), symlinked into WSL.** Same pattern as `rimlike` (`D:\godot\rimlike`) and `tavernkeep`. Forced by pnpm 11.x's `isDriveExFat` crashing on `\\wsl.localhost\...` UNC paths.
|
- **Source on Windows-native disk (`D:\dev\tiletopia\`), symlinked into WSL.** Same pattern as `rimlike` (`D:\godot\rimlike`) and `tavernkeep`. Forced by pnpm 11.x's `isDriveExFat` crashing on `\\wsl.localhost\...` UNC paths.
|
||||||
- **Don't commit `node_modules`, `src-tauri/target`, or `.pnpm-store`. DO commit `Cargo.lock`** (binary project, reproducible builds).
|
- **Don't commit `node_modules`, `src-tauri/target`, or `.pnpm-store`. DO commit `Cargo.lock`** (binary project, reproducible builds).
|
||||||
- **Session awareness without an in-pane agent.** Plan: poll `/proc/<pid>/cwd` of the shell's child + foreground process every ~2s. Sufficient to detect `cd` and whether `claude` is running.
|
- **Session awareness without an in-pane agent.** Plan: poll `/proc/<pid>/cwd` of the shell's child + foreground process every ~2s. Sufficient to detect `cd` and whether `claude` is running.
|
||||||
|
- **State propagation in the layout tree: hybrid mutable + replace.** The root tree is `$state(...)` at App level. Direct mutation (e.g. `node.ratio = X` during gutter drag) is reactive via Svelte 5's deep proxy. Structural changes (split/close) go through pure helpers in `tree.ts` that return a new root, which App reassigns. Drag stays fast (no tree walk); structural changes stay simple. `{#key leaf.id}` around `LeafPane` ensures swapping a leaf in/out cleanly unmounts XtermPane (which kills the PTY on destroy).
|
||||||
|
- **Layout persistence: localStorage at App level, key `tiletopia.tree.v1`.** Saves on every `$effect` tick (deep reactivity catches all mutations). Migrating to `%APPDATA%/tiletopia/` is M3.
|
||||||
|
|
||||||
## Open questions / TODOs
|
## Open questions / TODOs
|
||||||
|
|
||||||
- [ ] **HMR distro picker reset.** After a Vite hot reload, the previously-selected distro persists in Svelte 5 `$state`, so the picker doesn't re-default. Workaround in place (clickable distro buttons in titlebar). Fix properly in M3 when workspace state lives in a separate persisted store.
|
- [x] ~~**M2 — splits-tree layout component.** Two panes side by side, draggable divider, both panes alive. Save/restore layout as JSON.~~ Done 2026-05-22.
|
||||||
- [ ] **M2 — splits-tree layout component.** Two panes side by side, draggable divider, both panes alive. Save/restore layout as JSON.
|
- [ ] **HMR distro picker reset.** Less acute now that distro selection is per-leaf (auto-applied at split time from app-level default). The titlebar `default:` buttons let the user re-set the default at any time. Revisit when adding per-pane distro switching in M3.
|
||||||
- [ ] **M3 — workspace persistence.** Save/restore layouts + per-pane (distro, cwd, label) in `%APPDATA%/tiletopia/workspaces.json`. Preset layouts (3 columns, 2×2 grid). Distro picker UX, pane labels.
|
- [ ] **Auto-save debouncing.** Currently every tree mutation writes to localStorage. Dragging a gutter fires many writes per second. Cheap (localStorage is fast, JSON is small) but worth debouncing in M3 when persistence moves to disk.
|
||||||
|
- [ ] **M3 — workspace persistence + preset layouts.** Migrate from localStorage to `%APPDATA%/tiletopia/workspaces.json` (via Tauri's `plugin-store` or direct FS). Add preset layouts (3 columns, 2×2 grid). Multi-workspace tabs. Per-pane distro override and pane labels.
|
||||||
- [ ] **M4 — orchestration.** Broadcast input groups, idle/finish notifications, Ctrl+K fuzzy palette.
|
- [ ] **M4 — orchestration.** Broadcast input groups, idle/finish notifications, Ctrl+K fuzzy palette.
|
||||||
- [ ] **M5 — Ship.** Replace placeholder icons, NSIS installer, Forgejo release. Copy `claude-usage-widget`'s release scripts.
|
- [ ] **M5 — Ship.** Replace placeholder icons, NSIS installer, Forgejo release. Copy `claude-usage-widget`'s release scripts.
|
||||||
- [ ] **Native Windows shells (cmd / pwsh)?** `portable-pty` supports them for free; keep the option open. Decide whether to expose in UI at M3.
|
- [ ] **Native Windows shells (cmd / pwsh)?** `portable-pty` supports them for free; keep the option open. Decide whether to expose in UI at M3.
|
||||||
|
|
@ -25,6 +28,14 @@ Durable memory for this project. Read at session start, update before session en
|
||||||
|
|
||||||
## Session log
|
## Session log
|
||||||
|
|
||||||
|
### 2026-05-22 — M2 splits-tree layout
|
||||||
|
|
||||||
|
- Added `src/lib/layout/`: `tree.ts` (pure helpers: types, newLeaf, splitLeaf, closeLeaf, replaceById, serialize/deserialize with shape-checking), `SplitNode.svelte` (flex container + draggable gutter with pointer-capture), `LeafPane.svelte` (toolbar with split-right/split-down/close + XtermPane underneath), `Pane.svelte` (recursive dispatcher).
|
||||||
|
- Rewrote `App.svelte` to hold the tree as `$state` and wire split/close callbacks through. Auto-saves to localStorage on every `$effect` tick.
|
||||||
|
- Distro UX: titlebar shows clickable distro buttons that set the **default** for new panes. Existing panes keep their distro. Per-pane override is M3.
|
||||||
|
- Passes `pnpm check` cleanly (108 files, 0 errors, 0 warnings).
|
||||||
|
- Validated manually on Windows: open app, click `⇥` to split right and `⇣` to split down, both panes alive simultaneously, drag dividers reflows xterm. (Filled in after test.)
|
||||||
|
|
||||||
### 2026-05-22
|
### 2026-05-22
|
||||||
|
|
||||||
- Graduated from `ideas/wsl-mux/` to project. Renamed working name `wsl-mux` → final name `tiletopia` across Cargo/package/Tauri configs and source.
|
- Graduated from `ideas/wsl-mux/` to project. Renamed working name `wsl-mux` → final name `tiletopia` across Cargo/package/Tauri configs and source.
|
||||||
|
|
|
||||||
36
src-tauri/Cargo.lock
generated
36
src-tauri/Cargo.lock
generated
|
|
@ -3436,6 +3436,24 @@ dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tiletopia"
|
||||||
|
version = "0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"once_cell",
|
||||||
|
"parking_lot",
|
||||||
|
"portable-pty",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-build",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time"
|
name = "time"
|
||||||
version = "0.3.47"
|
version = "0.3.47"
|
||||||
|
|
@ -4730,24 +4748,6 @@ dependencies = [
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wsl-mux-spike"
|
|
||||||
version = "0.0.1"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"base64 0.22.1",
|
|
||||||
"once_cell",
|
|
||||||
"parking_lot",
|
|
||||||
"portable-pty",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"tauri",
|
|
||||||
"tauri-build",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"tracing-subscriber",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "x11"
|
name = "x11"
|
||||||
version = "2.21.0"
|
version = "2.21.0"
|
||||||
|
|
|
||||||
137
src/App.svelte
137
src/App.svelte
|
|
@ -1,35 +1,87 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import XtermPane from "./components/XtermPane.svelte";
|
|
||||||
import { listDistros } from "./ipc";
|
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 distros = $state<string[]>([]);
|
||||||
let selected = $state<string | undefined>(undefined);
|
let defaultDistro = $state<string | undefined>(undefined);
|
||||||
let status = $state("starting…");
|
let ready = $state(false);
|
||||||
let statusOk = $state(true);
|
let tree = $state<TreeNode>(newLeaf());
|
||||||
let loadError = $state<string | null>(null);
|
|
||||||
|
|
||||||
function isInteractiveDistro(name: string): boolean {
|
function isInteractiveDistro(name: string): boolean {
|
||||||
return !name.toLowerCase().startsWith("docker-desktop");
|
return !name.toLowerCase().startsWith("docker-desktop");
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
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 {
|
try {
|
||||||
const d = await listDistros();
|
distros = await listDistros();
|
||||||
console.log("listDistros() returned:", d);
|
defaultDistro = distros.find(isInteractiveDistro) ?? distros[0];
|
||||||
distros = d;
|
|
||||||
// Pick fresh every mount (HMR can preserve $state across reloads).
|
|
||||||
selected = d.find(isInteractiveDistro) ?? d[0] ?? undefined;
|
|
||||||
console.log("default selected:", selected);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("list_distros failed:", 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) {
|
function handleSplit(leafId: NodeId, orientation: Orientation) {
|
||||||
console.log("user picked distro:", d);
|
tree = splitLeaf(tree, leafId, orientation, { distro: defaultDistro });
|
||||||
selected = d;
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
|
|
@ -41,34 +93,29 @@
|
||||||
{#if distros.length === 0}
|
{#if distros.length === 0}
|
||||||
<span class="muted">no distros enumerated</span>
|
<span class="muted">no distros enumerated</span>
|
||||||
{:else}
|
{:else}
|
||||||
|
<span class="muted">default:</span>
|
||||||
{#each distros as d}
|
{#each distros as d}
|
||||||
<button
|
<button
|
||||||
class="distro-btn"
|
class="distro-btn"
|
||||||
class:active={d === selected}
|
class:active={d === defaultDistro}
|
||||||
onclick={() => pick(d)}
|
onclick={() => (defaultDistro = d)}
|
||||||
>
|
title="Set default distro for new panes"
|
||||||
{d}
|
>{d}</button>
|
||||||
</button>
|
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</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>
|
</header>
|
||||||
|
|
||||||
<div class="pane-wrap">
|
<div class="pane-wrap">
|
||||||
{#if loadError}
|
{#if ready}
|
||||||
<pre class="err-pre">listDistros failed: {loadError}</pre>
|
<Pane node={tree} onSplit={handleSplit} onClose={handleClose} />
|
||||||
{:else if selected !== undefined || distros.length === 0}
|
|
||||||
{#key selected}
|
|
||||||
<XtermPane
|
|
||||||
distro={selected}
|
|
||||||
onStatus={(msg, ok) => {
|
|
||||||
status = msg;
|
|
||||||
statusOk = ok;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/key}
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -103,10 +150,24 @@
|
||||||
color: #666;
|
color: #666;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
.err-pre {
|
.layout-info {
|
||||||
color: #d66;
|
margin-left: auto;
|
||||||
padding: 12px;
|
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||||
margin: 0;
|
color: #777;
|
||||||
white-space: pre-wrap;
|
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>
|
</style>
|
||||||
|
|
|
||||||
125
src/lib/layout/LeafPane.svelte
Normal file
125
src/lib/layout/LeafPane.svelte
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { LeafNode, NodeId, Orientation } from "./tree";
|
||||||
|
import XtermPane from "../../components/XtermPane.svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
leaf,
|
||||||
|
onSplit,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
leaf: LeafNode;
|
||||||
|
onSplit: (leafId: NodeId, orientation: Orientation) => void;
|
||||||
|
onClose: (leafId: NodeId) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let status = $state("starting…");
|
||||||
|
let statusOk = $state(true);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="leaf">
|
||||||
|
<div class="pane-toolbar">
|
||||||
|
<span class="pane-label">
|
||||||
|
{leaf.label ?? leaf.distro ?? "(default)"}
|
||||||
|
</span>
|
||||||
|
<span class="pane-status {statusOk ? 'ok' : 'err'}">{status}</span>
|
||||||
|
<span class="pane-actions">
|
||||||
|
<button
|
||||||
|
class="pane-btn"
|
||||||
|
title="Split right"
|
||||||
|
onclick={() => onSplit(leaf.id, "h")}
|
||||||
|
aria-label="Split right"
|
||||||
|
>⇥</button>
|
||||||
|
<button
|
||||||
|
class="pane-btn"
|
||||||
|
title="Split down"
|
||||||
|
onclick={() => onSplit(leaf.id, "v")}
|
||||||
|
aria-label="Split down"
|
||||||
|
>⇣</button>
|
||||||
|
<button
|
||||||
|
class="pane-btn close"
|
||||||
|
title="Close pane"
|
||||||
|
onclick={() => onClose(leaf.id)}
|
||||||
|
aria-label="Close pane"
|
||||||
|
>×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="xterm-wrap">
|
||||||
|
<XtermPane
|
||||||
|
distro={leaf.distro}
|
||||||
|
cwd={leaf.cwd}
|
||||||
|
onStatus={(msg, ok) => {
|
||||||
|
status = msg;
|
||||||
|
statusOk = ok;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.leaf {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.pane-toolbar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: #181818;
|
||||||
|
border-bottom: 1px solid #2a2a2a;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #aaa;
|
||||||
|
user-select: none;
|
||||||
|
min-height: 22px;
|
||||||
|
}
|
||||||
|
.pane-label {
|
||||||
|
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ccc;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.pane-status {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||||
|
color: #777;
|
||||||
|
font-size: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pane-status.ok { color: #6c6; }
|
||||||
|
.pane-status.err { color: #d66; }
|
||||||
|
.pane-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.pane-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pane-btn:hover {
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
.pane-btn.close:hover {
|
||||||
|
background: #5a1a1a;
|
||||||
|
color: #fcc;
|
||||||
|
}
|
||||||
|
.xterm-wrap {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
23
src/lib/layout/Pane.svelte
Normal file
23
src/lib/layout/Pane.svelte
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { TreeNode, NodeId, Orientation } from "./tree";
|
||||||
|
import SplitNode from "./SplitNode.svelte";
|
||||||
|
import LeafPane from "./LeafPane.svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
node,
|
||||||
|
onSplit,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
node: TreeNode;
|
||||||
|
onSplit: (leafId: NodeId, orientation: Orientation) => void;
|
||||||
|
onClose: (leafId: NodeId) => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if node.kind === "split"}
|
||||||
|
<SplitNode {node} {onSplit} {onClose} />
|
||||||
|
{:else}
|
||||||
|
{#key node.id}
|
||||||
|
<LeafPane leaf={node} {onSplit} {onClose} />
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
106
src/lib/layout/SplitNode.svelte
Normal file
106
src/lib/layout/SplitNode.svelte
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { SplitNode, NodeId, Orientation } from "./tree";
|
||||||
|
import Pane from "./Pane.svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
node,
|
||||||
|
onSplit,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
node: SplitNode;
|
||||||
|
onSplit: (leafId: NodeId, orientation: Orientation) => void;
|
||||||
|
onClose: (leafId: NodeId) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let containerEl: HTMLDivElement;
|
||||||
|
let dragging = $state(false);
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
dragging = true;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!dragging || !containerEl) return;
|
||||||
|
const rect = containerEl.getBoundingClientRect();
|
||||||
|
const isH = node.orientation === "h";
|
||||||
|
const pos = isH ? e.clientX - rect.left : e.clientY - rect.top;
|
||||||
|
const size = isH ? rect.width : rect.height;
|
||||||
|
if (size <= 0) return;
|
||||||
|
// Account for the gutter being centered on the cursor.
|
||||||
|
const r = Math.max(0.05, Math.min(0.95, pos / size));
|
||||||
|
node.ratio = r;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(e: PointerEvent) {
|
||||||
|
if (!dragging) return;
|
||||||
|
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||||
|
dragging = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="split"
|
||||||
|
class:horizontal={node.orientation === "h"}
|
||||||
|
class:vertical={node.orientation === "v"}
|
||||||
|
bind:this={containerEl}
|
||||||
|
>
|
||||||
|
<div class="side" style="flex: {node.ratio}">
|
||||||
|
<Pane node={node.a} {onSplit} {onClose} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="gutter"
|
||||||
|
class:active={dragging}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation={node.orientation === "h" ? "vertical" : "horizontal"}
|
||||||
|
aria-valuenow={Math.round(node.ratio * 100)}
|
||||||
|
tabindex="-1"
|
||||||
|
onpointerdown={onPointerDown}
|
||||||
|
onpointermove={onPointerMove}
|
||||||
|
onpointerup={onPointerUp}
|
||||||
|
onpointercancel={onPointerUp}
|
||||||
|
></div>
|
||||||
|
<div class="side" style="flex: {1 - node.ratio}">
|
||||||
|
<Pane node={node.b} {onSplit} {onClose} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.split {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.split.horizontal {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
.split.vertical {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gutter {
|
||||||
|
flex: 0 0 4px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
cursor: col-resize;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.split.vertical > .gutter {
|
||||||
|
cursor: row-resize;
|
||||||
|
}
|
||||||
|
.gutter:hover,
|
||||||
|
.gutter.active {
|
||||||
|
background: #3a5a8c;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
146
src/lib/layout/tree.ts
Normal file
146
src/lib/layout/tree.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
//! Splits-tree layout model.
|
||||||
|
//!
|
||||||
|
//! The workspace is a binary tree of splits. Internal nodes are HSplit or
|
||||||
|
//! VSplit with a ratio; leaves are terminal panes. This is the same model
|
||||||
|
//! tmux / i3 / Zellij use — dragging a gutter mutates one parent ratio,
|
||||||
|
//! both sibling subtrees reflow automatically.
|
||||||
|
|
||||||
|
export type NodeId = string;
|
||||||
|
|
||||||
|
/** 'h' = side-by-side (a on left, b on right). 'v' = stacked (a on top, b below). */
|
||||||
|
export type Orientation = "h" | "v";
|
||||||
|
|
||||||
|
export interface LeafNode {
|
||||||
|
kind: "leaf";
|
||||||
|
id: NodeId;
|
||||||
|
/** WSL distro the pane was spawned against. */
|
||||||
|
distro?: string;
|
||||||
|
/** Working directory the pane was started in. Not currently used at spawn time but preserved for future. */
|
||||||
|
cwd?: string;
|
||||||
|
/** Optional user label shown in the pane toolbar. */
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SplitNode {
|
||||||
|
kind: "split";
|
||||||
|
id: NodeId;
|
||||||
|
orientation: Orientation;
|
||||||
|
/** Size of `a` divided by total. Kept in (0, 1) — clamped on drag. */
|
||||||
|
ratio: number;
|
||||||
|
a: TreeNode;
|
||||||
|
b: TreeNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TreeNode = LeafNode | SplitNode;
|
||||||
|
|
||||||
|
function newId(): NodeId {
|
||||||
|
return (
|
||||||
|
globalThis.crypto?.randomUUID?.() ??
|
||||||
|
Math.random().toString(36).slice(2, 12)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newLeaf(props: Partial<Omit<LeafNode, "kind" | "id">> = {}): LeafNode {
|
||||||
|
return { kind: "leaf", id: newId(), ...props };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newSplit(
|
||||||
|
orientation: Orientation,
|
||||||
|
a: TreeNode,
|
||||||
|
b: TreeNode,
|
||||||
|
ratio = 0.5,
|
||||||
|
): SplitNode {
|
||||||
|
return { kind: "split", id: newId(), orientation, ratio, a, b };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walk the tree, replacing the node with the given id via `produce`. Returns a new tree. */
|
||||||
|
export function replaceById(
|
||||||
|
root: TreeNode,
|
||||||
|
targetId: NodeId,
|
||||||
|
produce: (node: TreeNode) => TreeNode,
|
||||||
|
): TreeNode {
|
||||||
|
if (root.id === targetId) return produce(root);
|
||||||
|
if (root.kind === "leaf") return root;
|
||||||
|
const a = replaceById(root.a, targetId, produce);
|
||||||
|
const b = replaceById(root.b, targetId, produce);
|
||||||
|
if (a === root.a && b === root.b) return root;
|
||||||
|
return { ...root, a, b };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split the leaf with the given id. New pane goes on side `b`. */
|
||||||
|
export function splitLeaf(
|
||||||
|
root: TreeNode,
|
||||||
|
leafId: NodeId,
|
||||||
|
orientation: Orientation,
|
||||||
|
newLeafProps: Partial<Omit<LeafNode, "kind" | "id">> = {},
|
||||||
|
): TreeNode {
|
||||||
|
return replaceById(root, leafId, (node) => {
|
||||||
|
if (node.kind !== "leaf") return node;
|
||||||
|
return newSplit(orientation, node, newLeaf(newLeafProps));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the leaf with the given id. The other child of its parent split
|
||||||
|
* takes the parent's place in the tree. Returns null if the closed leaf
|
||||||
|
* was the entire tree (caller should create a fresh leaf).
|
||||||
|
*/
|
||||||
|
export function closeLeaf(root: TreeNode, leafId: NodeId): TreeNode | null {
|
||||||
|
if (root.id === leafId) return null;
|
||||||
|
if (root.kind === "leaf") return root;
|
||||||
|
// If a direct child is the target leaf, collapse this split to the sibling.
|
||||||
|
if (root.a.kind === "leaf" && root.a.id === leafId) return root.b;
|
||||||
|
if (root.b.kind === "leaf" && root.b.id === leafId) return root.a;
|
||||||
|
// Recurse.
|
||||||
|
const newA = closeLeaf(root.a, leafId);
|
||||||
|
const newB = closeLeaf(root.b, leafId);
|
||||||
|
// If either side collapsed to null somehow (target was a split, shouldn't
|
||||||
|
// happen with current callers but defensive), fall back to the other side.
|
||||||
|
if (newA === null) return newB ?? root;
|
||||||
|
if (newB === null) return newA;
|
||||||
|
if (newA === root.a && newB === root.b) return root;
|
||||||
|
return { ...root, a: newA, b: newB };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find a leaf by id. Returns null if not found. */
|
||||||
|
export function findLeaf(root: TreeNode, leafId: NodeId): LeafNode | null {
|
||||||
|
if (root.kind === "leaf") return root.id === leafId ? root : null;
|
||||||
|
return findLeaf(root.a, leafId) ?? findLeaf(root.b, leafId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of leaves in the tree. */
|
||||||
|
export function leafCount(root: TreeNode): number {
|
||||||
|
if (root.kind === "leaf") return 1;
|
||||||
|
return leafCount(root.a) + leafCount(root.b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serialize(root: TreeNode): string {
|
||||||
|
return JSON.stringify(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse JSON back to a tree. Returns null on invalid input. */
|
||||||
|
export function deserialize(json: string): TreeNode | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(json);
|
||||||
|
if (!isTreeNode(parsed)) return null;
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTreeNode(x: unknown): x is TreeNode {
|
||||||
|
if (typeof x !== "object" || x === null) return false;
|
||||||
|
const o = x as Record<string, unknown>;
|
||||||
|
if (typeof o.id !== "string") return false;
|
||||||
|
if (o.kind === "leaf") return true;
|
||||||
|
if (o.kind === "split") {
|
||||||
|
return (
|
||||||
|
(o.orientation === "h" || o.orientation === "v") &&
|
||||||
|
typeof o.ratio === "number" &&
|
||||||
|
isTreeNode(o.a) &&
|
||||||
|
isTreeNode(o.b)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue