diff --git a/memory.md b/memory.md index b1d3647..310534d 100644 --- a/memory.md +++ b/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. - **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//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 -- [ ] **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. -- [ ] **M2 — splits-tree layout component.** Two panes side by side, draggable divider, both panes alive. Save/restore layout as JSON. -- [ ] **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. +- [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. +- [ ] **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. +- [ ] **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. - [ ] **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. @@ -25,6 +28,14 @@ Durable memory for this project. Read at session start, update before session en ## 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 - Graduated from `ideas/wsl-mux/` to project. Renamed working name `wsl-mux` → final name `tiletopia` across Cargo/package/Tauri configs and source. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6076e27..744dad0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3436,6 +3436,24 @@ dependencies = [ "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]] name = "time" version = "0.3.47" @@ -4730,24 +4748,6 @@ dependencies = [ "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]] name = "x11" version = "2.21.0" diff --git a/src/App.svelte b/src/App.svelte index f937082..ebe1138 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,35 +1,87 @@ @@ -41,34 +93,29 @@ {#if distros.length === 0} no distros enumerated {:else} + default: {#each distros as d} + class:active={d === defaultDistro} + onclick={() => (defaultDistro = d)} + title="Set default distro for new panes" + >{d} {/each} {/if} - {status} + + {leafCount(tree)} pane{leafCount(tree) === 1 ? "" : "s"} + +
- {#if loadError} -
listDistros failed: {loadError}
- {:else if selected !== undefined || distros.length === 0} - {#key selected} - { - status = msg; - statusOk = ok; - }} - /> - {/key} + {#if ready} + {/if}
@@ -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; } diff --git a/src/lib/layout/LeafPane.svelte b/src/lib/layout/LeafPane.svelte new file mode 100644 index 0000000..4533e8a --- /dev/null +++ b/src/lib/layout/LeafPane.svelte @@ -0,0 +1,125 @@ + + +
+
+ + {leaf.label ?? leaf.distro ?? "(default)"} + + {status} + + + + + +
+
+ { + status = msg; + statusOk = ok; + }} + /> +
+
+ + diff --git a/src/lib/layout/Pane.svelte b/src/lib/layout/Pane.svelte new file mode 100644 index 0000000..fe59fc6 --- /dev/null +++ b/src/lib/layout/Pane.svelte @@ -0,0 +1,23 @@ + + +{#if node.kind === "split"} + +{:else} + {#key node.id} + + {/key} +{/if} diff --git a/src/lib/layout/SplitNode.svelte b/src/lib/layout/SplitNode.svelte new file mode 100644 index 0000000..bf79803 --- /dev/null +++ b/src/lib/layout/SplitNode.svelte @@ -0,0 +1,106 @@ + + +
+
+ +
+ +
+ +
+
+ + diff --git a/src/lib/layout/tree.ts b/src/lib/layout/tree.ts new file mode 100644 index 0000000..bf0fa98 --- /dev/null +++ b/src/lib/layout/tree.ts @@ -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> = {}): 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> = {}, +): 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; + 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; +}