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

@ -108,6 +108,66 @@ export function findLeaf(root: TreeNode, leafId: NodeId): LeafNode | null {
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. */
export function leafCount(root: TreeNode): number {
if (root.kind === "leaf") return 1;