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:
megaproxy 2026-05-22 12:55:46 +01:00
parent 1869d08181
commit 64b90ebddb
10 changed files with 434 additions and 74 deletions

View file

@ -1,7 +1,8 @@
<script lang="ts">
import { onMount } from "svelte";
import { listDistros } from "./ipc";
import { listDistros, saveWorkspace, loadWorkspace } from "./ipc";
import Pane from "./lib/layout/Pane.svelte";
import type { PaneOps } from "./lib/layout/ops";
import {
type TreeNode,
type NodeId,
@ -9,12 +10,20 @@
newLeaf,
splitLeaf,
closeLeaf,
findLeaf,
leafCount,
changeDistro,
changeLabel,
serialize,
deserialize,
presetSingle,
presetTwoColumns,
presetThreeColumns,
presetTwoRows,
presetTwoByTwo,
} from "./lib/layout/tree";
const STORAGE_KEY = "tiletopia.tree.v1";
const LEGACY_STORAGE_KEY = "tiletopia.tree.v1";
let distros = $state<string[]>([]);
let defaultDistro = $state<string | undefined>(undefined);
@ -26,14 +35,36 @@
}
onMount(async () => {
// Restore saved layout, if any.
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const t = deserialize(saved);
if (t) tree = t;
// 1. Try the new APPDATA persistence.
let loaded: TreeNode | null = null;
try {
const json = await loadWorkspace();
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 {
distros = await listDistros();
defaultDistro = distros.find(isInteractiveDistro) ?? distros[0];
@ -41,8 +72,8 @@
console.warn("list_distros failed:", 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.
// 4. Backfill distro on any leaves that lack one (handles first launch
// and trees saved before defaults were resolved).
if (defaultDistro) backfillDistro(tree, defaultDistro);
ready = true;
@ -57,19 +88,31 @@
}
}
// Auto-save on every change. $effect re-runs whenever the proxied tree
// mutates anywhere (deep reactivity).
// ---- debounced auto-save -------------------------------------------------
let saveTimer: number | null = null;
const SAVE_DEBOUNCE_MS = 500;
$effect(() => {
if (!ready) return;
try {
localStorage.setItem(STORAGE_KEY, serialize(tree));
} catch (e) {
console.warn("localStorage save failed:", e);
}
const json = serialize(tree);
if (saveTimer != null) clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => {
saveTimer = null;
saveWorkspace(json).catch((e) =>
console.warn("saveWorkspace failed:", e),
);
}, SAVE_DEBOUNCE_MS);
});
// ---- pane ops ------------------------------------------------------------
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) {
@ -77,11 +120,29 @@
tree = next ?? newLeaf({ distro: defaultDistro });
}
function resetLayout() {
if (!confirm("Replace current layout with a single pane? This kills all open shells.")) {
function handleSetDistro(leafId: NodeId, distro: string) {
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;
}
tree = newLeaf({ distro: defaultDistro });
tree = make({ distro: defaultDistro });
}
</script>
@ -105,28 +166,34 @@
{/if}
</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">
{leafCount(tree)} pane{leafCount(tree) === 1 ? "" : "s"}
</span>
<button class="reset-btn" onclick={resetLayout} title="Reset to a single pane">
Reset
</button>
</header>
<div class="pane-wrap">
{#if ready}
<Pane node={tree} onSplit={handleSplit} onClose={handleClose} />
<Pane node={tree} {ops} />
{/if}
</div>
</div>
<style>
.distros {
.distros, .presets {
display: flex;
gap: 4px;
align-items: center;
}
.distro-btn {
.distro-btn, .preset-btn {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
@ -137,7 +204,7 @@
padding: 2px 8px;
cursor: pointer;
}
.distro-btn:hover {
.distro-btn:hover, .preset-btn:hover {
background: #2a2a2a;
color: #ddd;
}
@ -146,6 +213,10 @@
color: #cce6ff;
border-color: #2a5a8c;
}
.preset-btn {
min-width: 28px;
text-align: center;
}
.muted {
color: #666;
font-style: italic;
@ -156,18 +227,4 @@
color: #777;
font-size: 11px;
}
.reset-btn {
font: inherit;
font-size: 11px;
background: #2a2a2a;
color: #aaa;
border: 1px solid #3a3a3a;
border-radius: 3px;
padding: 2px 10px;
cursor: pointer;
}
.reset-btn:hover {
background: #3a3a3a;
color: #ddd;
}
</style>