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) <noreply@anthropic.com>
This commit is contained in:
parent
1869d08181
commit
64b90ebddb
10 changed files with 434 additions and 74 deletions
26
memory.md
26
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).
|
- **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).
|
- **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
|
## 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.
|
- [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.
|
- [x] ~~**M3 — workspace persistence + preset layouts + per-pane distro + pane labels.**~~ Done 2026-05-22.
|
||||||
- [ ] **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.
|
- [x] ~~**Auto-save debouncing.**~~ 500ms timer in `App.svelte` `$effect`.
|
||||||
- [ ] **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.
|
- [x] ~~**HMR distro picker reset.**~~ No longer an issue — per-pane distro selection via in-toolbar popover; titlebar `default:` only seeds new splits.
|
||||||
- [ ] **M4 — orchestration.** Broadcast input groups, idle/finish notifications, Ctrl+K fuzzy palette.
|
- [ ] **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.
|
- [ ] **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.
|
||||||
- [ ] **Persistent scrollback across app restarts.** Would need an out-of-process mux daemon. Big scope creep; explicitly deferred past v1.
|
- [ ] **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
|
## 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
|
### 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).
|
- 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).
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
//! Tauri command surface. Every JS-callable function lives here.
|
//! Tauri command surface. Every JS-callable function lives here.
|
||||||
|
|
||||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
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};
|
use crate::pty::{list_wsl_distros, PaneId, PtyManager};
|
||||||
|
|
||||||
|
const WORKSPACE_FILE: &str = "workspace.json";
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_distros() -> Result<Vec<String>, String> {
|
pub async fn list_distros() -> Result<Vec<String>, String> {
|
||||||
list_wsl_distros().map_err(|e| e.to_string())
|
list_wsl_distros().map_err(|e| e.to_string())
|
||||||
|
|
@ -55,3 +57,38 @@ pub async fn kill_pane(
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
manager.kill(id).map_err(|e| e.to_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<Option<String>, 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))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ pub fn run() {
|
||||||
commands::write_to_pane,
|
commands::write_to_pane,
|
||||||
commands::resize_pane,
|
commands::resize_pane,
|
||||||
commands::kill_pane,
|
commands::kill_pane,
|
||||||
|
commands::save_workspace,
|
||||||
|
commands::load_workspace,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|
|
||||||
141
src/App.svelte
141
src/App.svelte
|
|
@ -1,7 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { listDistros } from "./ipc";
|
import { listDistros, saveWorkspace, loadWorkspace } from "./ipc";
|
||||||
import Pane from "./lib/layout/Pane.svelte";
|
import Pane from "./lib/layout/Pane.svelte";
|
||||||
|
import type { PaneOps } from "./lib/layout/ops";
|
||||||
import {
|
import {
|
||||||
type TreeNode,
|
type TreeNode,
|
||||||
type NodeId,
|
type NodeId,
|
||||||
|
|
@ -9,12 +10,20 @@
|
||||||
newLeaf,
|
newLeaf,
|
||||||
splitLeaf,
|
splitLeaf,
|
||||||
closeLeaf,
|
closeLeaf,
|
||||||
|
findLeaf,
|
||||||
leafCount,
|
leafCount,
|
||||||
|
changeDistro,
|
||||||
|
changeLabel,
|
||||||
serialize,
|
serialize,
|
||||||
deserialize,
|
deserialize,
|
||||||
|
presetSingle,
|
||||||
|
presetTwoColumns,
|
||||||
|
presetThreeColumns,
|
||||||
|
presetTwoRows,
|
||||||
|
presetTwoByTwo,
|
||||||
} from "./lib/layout/tree";
|
} from "./lib/layout/tree";
|
||||||
|
|
||||||
const STORAGE_KEY = "tiletopia.tree.v1";
|
const LEGACY_STORAGE_KEY = "tiletopia.tree.v1";
|
||||||
|
|
||||||
let distros = $state<string[]>([]);
|
let distros = $state<string[]>([]);
|
||||||
let defaultDistro = $state<string | undefined>(undefined);
|
let defaultDistro = $state<string | undefined>(undefined);
|
||||||
|
|
@ -26,14 +35,36 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Restore saved layout, if any.
|
// 1. Try the new APPDATA persistence.
|
||||||
const saved = localStorage.getItem(STORAGE_KEY);
|
let loaded: TreeNode | null = null;
|
||||||
if (saved) {
|
try {
|
||||||
const t = deserialize(saved);
|
const json = await loadWorkspace();
|
||||||
if (t) tree = t;
|
if (json) loaded = deserialize(json);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("loadWorkspace failed:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve default distro.
|
// 2. Fall back to the M2 localStorage layout (one-time migration).
|
||||||
|
if (!loaded) {
|
||||||
|
try {
|
||||||
|
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||||
|
if (legacy) {
|
||||||
|
loaded = deserialize(legacy);
|
||||||
|
if (loaded) {
|
||||||
|
// Promote to APPDATA so it survives future loads even without
|
||||||
|
// localStorage. Fire-and-forget; debounced save will catch it too.
|
||||||
|
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 {
|
try {
|
||||||
distros = await listDistros();
|
distros = await listDistros();
|
||||||
defaultDistro = distros.find(isInteractiveDistro) ?? distros[0];
|
defaultDistro = distros.find(isInteractiveDistro) ?? distros[0];
|
||||||
|
|
@ -41,8 +72,8 @@
|
||||||
console.warn("list_distros failed:", e);
|
console.warn("list_distros failed:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If any leaf in the (possibly-restored) tree has no distro, fill in the default.
|
// 4. Backfill distro on any leaves that lack one (handles first launch
|
||||||
// Handles first launch and trees saved before defaults were set.
|
// and trees saved before defaults were resolved).
|
||||||
if (defaultDistro) backfillDistro(tree, defaultDistro);
|
if (defaultDistro) backfillDistro(tree, defaultDistro);
|
||||||
|
|
||||||
ready = true;
|
ready = true;
|
||||||
|
|
@ -57,19 +88,31 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-save on every change. $effect re-runs whenever the proxied tree
|
// ---- debounced auto-save -------------------------------------------------
|
||||||
// mutates anywhere (deep reactivity).
|
let saveTimer: number | null = null;
|
||||||
|
const SAVE_DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!ready) return;
|
if (!ready) return;
|
||||||
try {
|
const json = serialize(tree);
|
||||||
localStorage.setItem(STORAGE_KEY, serialize(tree));
|
if (saveTimer != null) clearTimeout(saveTimer);
|
||||||
} catch (e) {
|
saveTimer = window.setTimeout(() => {
|
||||||
console.warn("localStorage save failed:", e);
|
saveTimer = null;
|
||||||
}
|
saveWorkspace(json).catch((e) =>
|
||||||
|
console.warn("saveWorkspace failed:", e),
|
||||||
|
);
|
||||||
|
}, SAVE_DEBOUNCE_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- pane ops ------------------------------------------------------------
|
||||||
function handleSplit(leafId: NodeId, orientation: Orientation) {
|
function handleSplit(leafId: NodeId, orientation: Orientation) {
|
||||||
tree = splitLeaf(tree, leafId, orientation, { distro: defaultDistro });
|
// Inherit distro + cwd from the parent leaf so split-from-project
|
||||||
|
// keeps both panes in the same context.
|
||||||
|
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) {
|
function handleClose(leafId: NodeId) {
|
||||||
|
|
@ -77,11 +120,29 @@
|
||||||
tree = next ?? newLeaf({ distro: defaultDistro });
|
tree = next ?? newLeaf({ distro: defaultDistro });
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetLayout() {
|
function handleSetDistro(leafId: NodeId, distro: string) {
|
||||||
if (!confirm("Replace current layout with a single pane? This kills all open shells.")) {
|
tree = changeDistro(tree, leafId, distro);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSetLabel(leafId: NodeId, label: string | undefined) {
|
||||||
|
tree = changeLabel(tree, leafId, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ops: PaneOps = $derived({
|
||||||
|
split: handleSplit,
|
||||||
|
close: handleClose,
|
||||||
|
setDistro: handleSetDistro,
|
||||||
|
setLabel: handleSetLabel,
|
||||||
|
distros,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 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;
|
return;
|
||||||
}
|
}
|
||||||
tree = newLeaf({ distro: defaultDistro });
|
tree = make({ distro: defaultDistro });
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -105,28 +166,34 @@
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</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>
|
||||||
|
|
||||||
<span class="layout-info">
|
<span class="layout-info">
|
||||||
{leafCount(tree)} pane{leafCount(tree) === 1 ? "" : "s"}
|
{leafCount(tree)} pane{leafCount(tree) === 1 ? "" : "s"}
|
||||||
</span>
|
</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 ready}
|
{#if ready}
|
||||||
<Pane node={tree} onSplit={handleSplit} onClose={handleClose} />
|
<Pane node={tree} {ops} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.distros {
|
.distros, .presets {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.distro-btn {
|
.distro-btn, .preset-btn {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|
@ -137,7 +204,7 @@
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.distro-btn:hover {
|
.distro-btn:hover, .preset-btn:hover {
|
||||||
background: #2a2a2a;
|
background: #2a2a2a;
|
||||||
color: #ddd;
|
color: #ddd;
|
||||||
}
|
}
|
||||||
|
|
@ -146,6 +213,10 @@
|
||||||
color: #cce6ff;
|
color: #cce6ff;
|
||||||
border-color: #2a5a8c;
|
border-color: #2a5a8c;
|
||||||
}
|
}
|
||||||
|
.preset-btn {
|
||||||
|
min-width: 28px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
.muted {
|
.muted {
|
||||||
color: #666;
|
color: #666;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
|
@ -156,18 +227,4 @@
|
||||||
color: #777;
|
color: #777;
|
||||||
font-size: 11px;
|
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>
|
||||||
|
|
|
||||||
|
|
@ -30,3 +30,11 @@ export const onPaneExit = (
|
||||||
id: PaneId,
|
id: PaneId,
|
||||||
cb: () => void,
|
cb: () => void,
|
||||||
): Promise<UnlistenFn> => listen(`pane://${id}/exit`, () => cb());
|
): Promise<UnlistenFn> => listen(`pane://${id}/exit`, () => cb());
|
||||||
|
|
||||||
|
// ---- workspace persistence -------------------------------------------------
|
||||||
|
|
||||||
|
export const saveWorkspace = (json: string): Promise<void> =>
|
||||||
|
invoke("save_workspace", { json });
|
||||||
|
|
||||||
|
export const loadWorkspace = (): Promise<string | null> =>
|
||||||
|
invoke("load_workspace");
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,134 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { LeafNode, NodeId, Orientation } from "./tree";
|
import type { LeafNode } from "./tree";
|
||||||
|
import type { PaneOps } from "./ops";
|
||||||
import XtermPane from "../../components/XtermPane.svelte";
|
import XtermPane from "../../components/XtermPane.svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
leaf,
|
leaf,
|
||||||
onSplit,
|
ops,
|
||||||
onClose,
|
|
||||||
}: {
|
}: {
|
||||||
leaf: LeafNode;
|
leaf: LeafNode;
|
||||||
onSplit: (leafId: NodeId, orientation: Orientation) => void;
|
ops: PaneOps;
|
||||||
onClose: (leafId: NodeId) => void;
|
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let status = $state("starting…");
|
let status = $state("starting…");
|
||||||
let statusOk = $state(true);
|
let statusOk = $state(true);
|
||||||
|
|
||||||
|
// ---- label editing -------------------------------------------------------
|
||||||
|
let editingLabel = $state(false);
|
||||||
|
let labelDraft = $state("");
|
||||||
|
let labelInputEl: HTMLInputElement | null = $state(null);
|
||||||
|
|
||||||
|
function startEditLabel() {
|
||||||
|
labelDraft = leaf.label ?? "";
|
||||||
|
editingLabel = true;
|
||||||
|
// Focus the input after Svelte renders it.
|
||||||
|
queueMicrotask(() => labelInputEl?.select());
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitLabel() {
|
||||||
|
if (!editingLabel) return;
|
||||||
|
ops.setLabel(leaf.id, labelDraft);
|
||||||
|
editingLabel = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelLabel() {
|
||||||
|
editingLabel = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLabelKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
commitLabel();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelLabel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- distro popover ------------------------------------------------------
|
||||||
|
let distroOpen = $state(false);
|
||||||
|
|
||||||
|
function toggleDistroMenu(e: MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
distroOpen = !distroOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickDistro(d: string) {
|
||||||
|
distroOpen = false;
|
||||||
|
if (d !== leaf.distro) ops.setDistro(leaf.id, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss popover on outside click.
|
||||||
|
$effect(() => {
|
||||||
|
if (!distroOpen) return;
|
||||||
|
const onDocClick = () => (distroOpen = false);
|
||||||
|
window.addEventListener("click", onDocClick);
|
||||||
|
return () => window.removeEventListener("click", onDocClick);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="leaf">
|
<div class="leaf">
|
||||||
<div class="pane-toolbar">
|
<div class="pane-toolbar">
|
||||||
<span class="pane-label">
|
{#if editingLabel}
|
||||||
{leaf.label ?? leaf.distro ?? "(default)"}
|
<input
|
||||||
|
class="label-input"
|
||||||
|
bind:this={labelInputEl}
|
||||||
|
bind:value={labelDraft}
|
||||||
|
onkeydown={onLabelKey}
|
||||||
|
onblur={commitLabel}
|
||||||
|
placeholder="(label)"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="pane-label"
|
||||||
|
onclick={startEditLabel}
|
||||||
|
title="Click to rename pane"
|
||||||
|
>
|
||||||
|
{leaf.label ?? "(unnamed)"}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<span class="distro-wrap">
|
||||||
|
<button
|
||||||
|
class="distro-chip"
|
||||||
|
onclick={toggleDistroMenu}
|
||||||
|
title="Change distro (respawns the pane)"
|
||||||
|
>
|
||||||
|
{leaf.distro ?? "(default)"} ▾
|
||||||
|
</button>
|
||||||
|
{#if distroOpen}
|
||||||
|
<div class="distro-menu" onclick={(e) => e.stopPropagation()} role="menu" tabindex="-1" onkeydown={() => {}}>
|
||||||
|
{#each ops.distros as d}
|
||||||
|
<button
|
||||||
|
class="distro-menu-item"
|
||||||
|
class:active={d === leaf.distro}
|
||||||
|
onclick={() => pickDistro(d)}
|
||||||
|
>{d}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="pane-status {statusOk ? 'ok' : 'err'}">{status}</span>
|
<span class="pane-status {statusOk ? 'ok' : 'err'}">{status}</span>
|
||||||
|
|
||||||
<span class="pane-actions">
|
<span class="pane-actions">
|
||||||
<button
|
<button
|
||||||
class="pane-btn"
|
class="pane-btn"
|
||||||
title="Split right"
|
title="Split right"
|
||||||
onclick={() => onSplit(leaf.id, "h")}
|
onclick={() => ops.split(leaf.id, "h")}
|
||||||
aria-label="Split right"
|
aria-label="Split right"
|
||||||
>⇥</button>
|
>⇥</button>
|
||||||
<button
|
<button
|
||||||
class="pane-btn"
|
class="pane-btn"
|
||||||
title="Split down"
|
title="Split down"
|
||||||
onclick={() => onSplit(leaf.id, "v")}
|
onclick={() => ops.split(leaf.id, "v")}
|
||||||
aria-label="Split down"
|
aria-label="Split down"
|
||||||
>⇣</button>
|
>⇣</button>
|
||||||
<button
|
<button
|
||||||
class="pane-btn close"
|
class="pane-btn close"
|
||||||
title="Close pane"
|
title="Close pane"
|
||||||
onclick={() => onClose(leaf.id)}
|
onclick={() => ops.close(leaf.id)}
|
||||||
aria-label="Close pane"
|
aria-label="Close pane"
|
||||||
>×</button>
|
>×</button>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -75,16 +165,93 @@
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
min-height: 22px;
|
min-height: 24px;
|
||||||
}
|
}
|
||||||
.pane-label {
|
.pane-label {
|
||||||
|
font: inherit;
|
||||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
cursor: text;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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 {
|
.pane-status {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||||
|
|
@ -94,6 +261,7 @@
|
||||||
}
|
}
|
||||||
.pane-status.ok { color: #6c6; }
|
.pane-status.ok { color: #6c6; }
|
||||||
.pane-status.err { color: #d66; }
|
.pane-status.err { color: #d66; }
|
||||||
|
|
||||||
.pane-actions {
|
.pane-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,22 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { TreeNode, NodeId, Orientation } from "./tree";
|
import type { TreeNode } from "./tree";
|
||||||
|
import type { PaneOps } from "./ops";
|
||||||
import SplitNode from "./SplitNode.svelte";
|
import SplitNode from "./SplitNode.svelte";
|
||||||
import LeafPane from "./LeafPane.svelte";
|
import LeafPane from "./LeafPane.svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
node,
|
node,
|
||||||
onSplit,
|
ops,
|
||||||
onClose,
|
|
||||||
}: {
|
}: {
|
||||||
node: TreeNode;
|
node: TreeNode;
|
||||||
onSplit: (leafId: NodeId, orientation: Orientation) => void;
|
ops: PaneOps;
|
||||||
onClose: (leafId: NodeId) => void;
|
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if node.kind === "split"}
|
{#if node.kind === "split"}
|
||||||
<SplitNode {node} {onSplit} {onClose} />
|
<SplitNode {node} {ops} />
|
||||||
{:else}
|
{:else}
|
||||||
{#key node.id}
|
{#key node.id}
|
||||||
<LeafPane leaf={node} {onSplit} {onClose} />
|
<LeafPane leaf={node} {ops} />
|
||||||
{/key}
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { SplitNode, NodeId, Orientation } from "./tree";
|
import type { SplitNode } from "./tree";
|
||||||
|
import type { PaneOps } from "./ops";
|
||||||
import Pane from "./Pane.svelte";
|
import Pane from "./Pane.svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
node,
|
node,
|
||||||
onSplit,
|
ops,
|
||||||
onClose,
|
|
||||||
}: {
|
}: {
|
||||||
node: SplitNode;
|
node: SplitNode;
|
||||||
onSplit: (leafId: NodeId, orientation: Orientation) => void;
|
ops: PaneOps;
|
||||||
onClose: (leafId: NodeId) => void;
|
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let containerEl: HTMLDivElement;
|
let containerEl: HTMLDivElement;
|
||||||
|
|
@ -28,7 +27,6 @@
|
||||||
const pos = isH ? e.clientX - rect.left : e.clientY - rect.top;
|
const pos = isH ? e.clientX - rect.left : e.clientY - rect.top;
|
||||||
const size = isH ? rect.width : rect.height;
|
const size = isH ? rect.width : rect.height;
|
||||||
if (size <= 0) return;
|
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));
|
const r = Math.max(0.05, Math.min(0.95, pos / size));
|
||||||
node.ratio = r;
|
node.ratio = r;
|
||||||
}
|
}
|
||||||
|
|
@ -47,7 +45,7 @@
|
||||||
bind:this={containerEl}
|
bind:this={containerEl}
|
||||||
>
|
>
|
||||||
<div class="side" style="flex: {node.ratio}">
|
<div class="side" style="flex: {node.ratio}">
|
||||||
<Pane node={node.a} {onSplit} {onClose} />
|
<Pane node={node.a} {ops} />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="gutter"
|
class="gutter"
|
||||||
|
|
@ -62,7 +60,7 @@
|
||||||
onpointercancel={onPointerUp}
|
onpointercancel={onPointerUp}
|
||||||
></div>
|
></div>
|
||||||
<div class="side" style="flex: {1 - node.ratio}">
|
<div class="side" style="flex: {1 - node.ratio}">
|
||||||
<Pane node={node.b} {onSplit} {onClose} />
|
<Pane node={node.b} {ops} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
15
src/lib/layout/ops.ts
Normal file
15
src/lib/layout/ops.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { NodeId, Orientation } from "./tree";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bundle of operations + data that any pane in the tree may need.
|
||||||
|
* Passed down through Pane / SplitNode / LeafPane to avoid per-callback
|
||||||
|
* prop drilling.
|
||||||
|
*/
|
||||||
|
export interface PaneOps {
|
||||||
|
split: (leafId: NodeId, orientation: Orientation) => void;
|
||||||
|
close: (leafId: NodeId) => void;
|
||||||
|
setDistro: (leafId: NodeId, distro: string) => void;
|
||||||
|
setLabel: (leafId: NodeId, label: string | undefined) => void;
|
||||||
|
/** All distros known to the backend; populated once at app start. */
|
||||||
|
distros: string[];
|
||||||
|
}
|
||||||
|
|
@ -108,6 +108,66 @@ export function findLeaf(root: TreeNode, leafId: NodeId): LeafNode | null {
|
||||||
return findLeaf(root.a, leafId) ?? findLeaf(root.b, leafId);
|
return findLeaf(root.a, leafId) ?? findLeaf(root.b, leafId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swap the distro on a leaf. The leaf gets a **new id** so the rendering
|
||||||
|
* layer's `{#key node.id}` block remounts XtermPane — the old PTY is killed
|
||||||
|
* and a fresh one spawns with the new distro.
|
||||||
|
*/
|
||||||
|
export function changeDistro(
|
||||||
|
root: TreeNode,
|
||||||
|
leafId: NodeId,
|
||||||
|
distro: string,
|
||||||
|
): TreeNode {
|
||||||
|
return replaceById(root, leafId, (node) => {
|
||||||
|
if (node.kind !== "leaf") return node;
|
||||||
|
return { ...node, id: newId(), distro };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set or clear a leaf's label. Does NOT remount (label is metadata only). */
|
||||||
|
export function changeLabel(
|
||||||
|
root: TreeNode,
|
||||||
|
leafId: NodeId,
|
||||||
|
label: string | undefined,
|
||||||
|
): TreeNode {
|
||||||
|
return replaceById(root, leafId, (node) => {
|
||||||
|
if (node.kind !== "leaf") return node;
|
||||||
|
const trimmed = label?.trim();
|
||||||
|
return { ...node, label: trimmed ? trimmed : undefined };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- preset layouts --------------------------------------------------------
|
||||||
|
|
||||||
|
type LeafDefaults = Partial<Omit<LeafNode, "kind" | "id">>;
|
||||||
|
|
||||||
|
export function presetSingle(d: LeafDefaults = {}): TreeNode {
|
||||||
|
return newLeaf(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function presetTwoColumns(d: LeafDefaults = {}): TreeNode {
|
||||||
|
return newSplit("h", newLeaf(d), newLeaf(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function presetThreeColumns(d: LeafDefaults = {}): TreeNode {
|
||||||
|
// Even thirds: outer split at 1/3, inner split at 1/2.
|
||||||
|
return newSplit(
|
||||||
|
"h",
|
||||||
|
newLeaf(d),
|
||||||
|
newSplit("h", newLeaf(d), newLeaf(d), 0.5),
|
||||||
|
1 / 3,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function presetTwoRows(d: LeafDefaults = {}): TreeNode {
|
||||||
|
return newSplit("v", newLeaf(d), newLeaf(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function presetTwoByTwo(d: LeafDefaults = {}): TreeNode {
|
||||||
|
const row = () => newSplit("h", newLeaf(d), newLeaf(d));
|
||||||
|
return newSplit("v", row(), row());
|
||||||
|
}
|
||||||
|
|
||||||
/** Number of leaves in the tree. */
|
/** Number of leaves in the tree. */
|
||||||
export function leafCount(root: TreeNode): number {
|
export function leafCount(root: TreeNode): number {
|
||||||
if (root.kind === "leaf") return 1;
|
if (root.kind === "leaf") return 1;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue