From 64b90ebddb160c1a595ab1f14537e54fdb216b3b Mon Sep 17 00:00:00 2001 From: megaproxy Date: Fri, 22 May 2026 12:55:46 +0100 Subject: [PATCH] Add M3: APPDATA persistence + presets + per-pane distro/label Backend: - save_workspace / load_workspace Tauri commands writing to %APPDATA%\com.megaproxy.tiletopia\workspace.json with atomic tmp+rename. Path from app.path().app_config_dir() (no dirs crate). Layout helpers: - tree.ts: changeDistro (with id swap to force XtermPane remount via {#key}), changeLabel, presetSingle / TwoColumns / ThreeColumns / TwoRows / TwoByTwo. - New ops.ts with PaneOps interface bundling split / close / setDistro / setLabel / distros, drilled through Pane chain instead of individual callbacks. UI: - LeafPane: in-toolbar editable label (click to rename, Enter saves, Esc cancels) and distro chip popover. Picking a different distro respawns the pane. - App.svelte: migrated from localStorage to APPDATA via the new Tauri commands, debounced 500ms. One-time localStorage migration on boot. Split inherits parent's distro+cwd. Titlebar preset buttons with confirm when replacing >1 pane. Co-Authored-By: Claude Opus 4.7 (1M context) --- memory.md | 26 ++++- src-tauri/src/commands.rs | 39 ++++++- src-tauri/src/lib.rs | 2 + src/App.svelte | 141 +++++++++++++++++------- src/ipc.ts | 8 ++ src/lib/layout/LeafPane.svelte | 190 ++++++++++++++++++++++++++++++-- src/lib/layout/Pane.svelte | 13 +-- src/lib/layout/SplitNode.svelte | 14 +-- src/lib/layout/ops.ts | 15 +++ src/lib/layout/tree.ts | 60 ++++++++++ 10 files changed, 434 insertions(+), 74 deletions(-) create mode 100644 src/lib/layout/ops.ts diff --git a/memory.md b/memory.md index 6a421cc..57a66b0 100644 --- a/memory.md +++ b/memory.md @@ -12,15 +12,20 @@ Durable memory for this project. Read at session start, update before session en - **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. +- **Layout persistence: `%APPDATA%/com.megaproxy.tiletopia/workspace.json`** via two Tauri commands (`save_workspace`, `load_workspace`). Atomic write (tmp + rename) so a crash mid-save can't leave a partial file. Path comes from Tauri's `app.path().app_config_dir()` — no separate `dirs` crate needed. M2's localStorage path is checked once at boot as a one-time migration source, then cleared. +- **Auto-save is debounced 500ms.** Every tree mutation kicks the `$effect`; it resets a timeout and only writes after 500ms of quiet. Cheap enough to never need throttling on UI mutations; matters because each gutter-drag step would otherwise hit disk dozens of times per second. +- **Pane operations bundled into a `PaneOps` interface** in `lib/layout/ops.ts`. Pane and SplitNode just pass `ops` through; LeafPane consumes it. Replaces M2's per-callback prop drilling (would have been split + close + setDistro + setLabel + distros = 5 separate props). Easier to grow as M4 adds broadcast / palette ops. +- **Per-pane distro change forces a remount via id swap.** `changeDistro` in `tree.ts` assigns a new id to the leaf; `Pane.svelte`'s `{#key leaf.id}` unmounts XtermPane (which kills the old PTY) and mounts a fresh one with the new distro. Same mechanism we already use for split/close. +- **Split inherits parent's distro AND cwd** (not label — label is a per-pane name, not a hierarchy thing). So "split right" while in a project keeps both panes in that project. ## Open questions / TODOs - [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. +- [x] ~~**M3 — workspace persistence + preset layouts + per-pane distro + pane labels.**~~ Done 2026-05-22. +- [x] ~~**Auto-save debouncing.**~~ 500ms timer in `App.svelte` `$effect`. +- [x] ~~**HMR distro picker reset.**~~ No longer an issue — per-pane distro selection via in-toolbar popover; titlebar `default:` only seeds new splits. +- [ ] **Multi-workspace tabs.** Several independent layouts the user can switch between. Saved as `workspaces.json` with `{ current: id, list: [{ id, name, tree }] }`. Not on the M0–M5 critical path; either bolt on after M5 ship or fold into a "tabs" minor milestone. +- [ ] **M4 — orchestration.** Broadcast input groups (write same bytes to N PTYs flagged into a group), idle/finish notifications (detect when a `claude` pane stops emitting output for >Ns → toast), Ctrl+K fuzzy palette over `label / distro / cwd`. Will extend `PaneOps` with a couple more methods + add a "broadcast group" concept to the leaf type. - [ ] **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. - [ ] **Persistent scrollback across app restarts.** Would need an out-of-process mux daemon. Big scope creep; explicitly deferred past v1. @@ -28,6 +33,17 @@ Durable memory for this project. Read at session start, update before session en ## Session log +### 2026-05-22 — M3 persistence + presets + per-pane distro/label + +- Backend: added `save_workspace(json)` and `load_workspace()` Tauri commands. Atomic write via tmp + rename. Path resolved from `app.path().app_config_dir()`. +- Frontend ipc: `saveWorkspace` / `loadWorkspace` wrappers. +- `tree.ts`: added `changeDistro` (with id swap to force XtermPane remount), `changeLabel`, and 5 preset trees (single, 2H, 3H, 2V, 2×2). +- New `lib/layout/ops.ts` with `PaneOps` interface; refactored `Pane.svelte` / `SplitNode.svelte` / `LeafPane.svelte` to take `ops` instead of individual callbacks. +- `LeafPane.svelte`: in-toolbar pane-label editor (click to rename, Enter saves, Esc cancels) and distro chip with click-popover. Picking a different distro in the popover respawns the pane. +- `App.svelte`: migrated to APPDATA persistence with 500ms debounce. One-time localStorage→APPDATA migration on boot. Split inherits parent's distro+cwd via `findLeaf`. Titlebar preset buttons (1 / 2H / 3H / 2V / 2×2) with a confirm prompt when replacing >1 pane. +- `pnpm check` clean (109 files, 0 errors, 0 warnings). +- Manual verification on Windows: (to fill in) + ### 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). diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index e4cb3bf..db65aa5 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,10 +1,12 @@ //! Tauri command surface. Every JS-callable function lives here. use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; -use tauri::AppHandle; +use tauri::{AppHandle, Manager}; use crate::pty::{list_wsl_distros, PaneId, PtyManager}; +const WORKSPACE_FILE: &str = "workspace.json"; + #[tauri::command] pub async fn list_distros() -> Result, String> { list_wsl_distros().map_err(|e| e.to_string()) @@ -55,3 +57,38 @@ pub async fn kill_pane( ) -> Result<(), String> { manager.kill(id).map_err(|e| e.to_string()) } + +/// Write the workspace JSON to `%APPDATA%\com.megaproxy.tiletopia\workspace.json`. +/// Writes to a `.tmp` and renames over the real file so a crash mid-write +/// can't leave a partial file readable. +#[tauri::command] +pub async fn save_workspace(app: AppHandle, json: String) -> Result<(), String> { + let dir = app + .path() + .app_config_dir() + .map_err(|e| format!("app_config_dir: {e}"))?; + std::fs::create_dir_all(&dir).map_err(|e| format!("create_dir_all: {e}"))?; + let path = dir.join(WORKSPACE_FILE); + let tmp = dir.join(format!("{WORKSPACE_FILE}.tmp")); + std::fs::write(&tmp, json.as_bytes()).map_err(|e| format!("write tmp: {e}"))?; + // `std::fs::rename` is atomic on Unix and uses MoveFileEx with + // REPLACE_EXISTING on Windows (>= Rust 1.50). + std::fs::rename(&tmp, &path).map_err(|e| format!("rename: {e}"))?; + Ok(()) +} + +/// Read the workspace JSON. Returns `None` if the file doesn't exist yet +/// (first launch). +#[tauri::command] +pub async fn load_workspace(app: AppHandle) -> Result, String> { + let dir = app + .path() + .app_config_dir() + .map_err(|e| format!("app_config_dir: {e}"))?; + let path = dir.join(WORKSPACE_FILE); + if !path.exists() { + return Ok(None); + } + let s = std::fs::read_to_string(&path).map_err(|e| format!("read: {e}"))?; + Ok(Some(s)) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4e7e3ac..ade2c64 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -22,6 +22,8 @@ pub fn run() { commands::write_to_pane, commands::resize_pane, commands::kill_pane, + commands::save_workspace, + commands::load_workspace, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.svelte b/src/App.svelte index ebe1138..f3e64c0 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,7 +1,8 @@ @@ -105,28 +166,34 @@ {/if} + + layout: + + + + + + + {leafCount(tree)} pane{leafCount(tree) === 1 ? "" : "s"} -
{#if ready} - + {/if}
diff --git a/src/ipc.ts b/src/ipc.ts index 141c343..710663a 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -30,3 +30,11 @@ export const onPaneExit = ( id: PaneId, cb: () => void, ): Promise => listen(`pane://${id}/exit`, () => cb()); + +// ---- workspace persistence ------------------------------------------------- + +export const saveWorkspace = (json: string): Promise => + invoke("save_workspace", { json }); + +export const loadWorkspace = (): Promise => + invoke("load_workspace"); diff --git a/src/lib/layout/LeafPane.svelte b/src/lib/layout/LeafPane.svelte index 4533e8a..51342d1 100644 --- a/src/lib/layout/LeafPane.svelte +++ b/src/lib/layout/LeafPane.svelte @@ -1,44 +1,134 @@
- - {leaf.label ?? leaf.distro ?? "(default)"} + {#if editingLabel} + + {:else} + + {/if} + + + + {#if distroOpen} +
e.stopPropagation()} role="menu" tabindex="-1" onkeydown={() => {}}> + {#each ops.distros as d} + + {/each} +
+ {/if}
+ {status} + @@ -75,16 +165,93 @@ font-size: 11px; color: #aaa; user-select: none; - min-height: 22px; + min-height: 24px; } .pane-label { + font: inherit; font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; font-weight: 600; color: #ccc; + background: transparent; + border: 1px solid transparent; + border-radius: 3px; + padding: 1px 6px; + cursor: text; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + max-width: 200px; } + .pane-label:hover { + background: #222; + border-color: #2a2a2a; + } + .label-input { + font: inherit; + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; + font-weight: 600; + color: #fff; + background: #0c0c0c; + border: 1px solid #3a5a8c; + border-radius: 3px; + padding: 1px 6px; + outline: none; + max-width: 240px; + } + + .distro-wrap { + position: relative; + } + .distro-chip { + font: inherit; + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; + font-size: 10px; + background: #222; + color: #88c; + border: 1px solid #2a2a3a; + border-radius: 3px; + padding: 1px 6px; + cursor: pointer; + } + .distro-chip:hover { + background: #2a2a3a; + color: #aac; + } + .distro-menu { + position: absolute; + top: 100%; + left: 0; + margin-top: 2px; + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 4px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); + z-index: 10; + min-width: 140px; + display: flex; + flex-direction: column; + padding: 2px; + } + .distro-menu-item { + font: inherit; + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; + font-size: 11px; + text-align: left; + background: transparent; + color: #ccc; + border: none; + border-radius: 2px; + padding: 3px 8px; + cursor: pointer; + } + .distro-menu-item:hover { + background: #2a2a2a; + } + .distro-menu-item.active { + background: #1a3a5c; + color: #cce6ff; + } + .pane-status { margin-left: auto; font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; @@ -94,6 +261,7 @@ } .pane-status.ok { color: #6c6; } .pane-status.err { color: #d66; } + .pane-actions { display: flex; gap: 2px; diff --git a/src/lib/layout/Pane.svelte b/src/lib/layout/Pane.svelte index fe59fc6..79bf308 100644 --- a/src/lib/layout/Pane.svelte +++ b/src/lib/layout/Pane.svelte @@ -1,23 +1,22 @@ {#if node.kind === "split"} - + {:else} {#key node.id} - + {/key} {/if} diff --git a/src/lib/layout/SplitNode.svelte b/src/lib/layout/SplitNode.svelte index bf79803..ed6f100 100644 --- a/src/lib/layout/SplitNode.svelte +++ b/src/lib/layout/SplitNode.svelte @@ -1,15 +1,14 @@