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>

View file

@ -30,3 +30,11 @@ export const onPaneExit = (
id: PaneId,
cb: () => void,
): 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");

View file

@ -1,44 +1,134 @@
<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";
let {
leaf,
onSplit,
onClose,
ops,
}: {
leaf: LeafNode;
onSplit: (leafId: NodeId, orientation: Orientation) => void;
onClose: (leafId: NodeId) => void;
ops: PaneOps;
} = $props();
let status = $state("starting…");
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>
<div class="leaf">
<div class="pane-toolbar">
<span class="pane-label">
{leaf.label ?? leaf.distro ?? "(default)"}
{#if editingLabel}
<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 class="pane-status {statusOk ? 'ok' : 'err'}">{status}</span>
<span class="pane-actions">
<button
class="pane-btn"
title="Split right"
onclick={() => onSplit(leaf.id, "h")}
onclick={() => ops.split(leaf.id, "h")}
aria-label="Split right"
>⇥</button>
<button
class="pane-btn"
title="Split down"
onclick={() => onSplit(leaf.id, "v")}
onclick={() => ops.split(leaf.id, "v")}
aria-label="Split down"
>⇣</button>
<button
class="pane-btn close"
title="Close pane"
onclick={() => onClose(leaf.id)}
onclick={() => ops.close(leaf.id)}
aria-label="Close pane"
>×</button>
</span>
@ -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;

View file

@ -1,23 +1,22 @@
<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 LeafPane from "./LeafPane.svelte";
let {
node,
onSplit,
onClose,
ops,
}: {
node: TreeNode;
onSplit: (leafId: NodeId, orientation: Orientation) => void;
onClose: (leafId: NodeId) => void;
ops: PaneOps;
} = $props();
</script>
{#if node.kind === "split"}
<SplitNode {node} {onSplit} {onClose} />
<SplitNode {node} {ops} />
{:else}
{#key node.id}
<LeafPane leaf={node} {onSplit} {onClose} />
<LeafPane leaf={node} {ops} />
{/key}
{/if}

View file

@ -1,15 +1,14 @@
<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";
let {
node,
onSplit,
onClose,
ops,
}: {
node: SplitNode;
onSplit: (leafId: NodeId, orientation: Orientation) => void;
onClose: (leafId: NodeId) => void;
ops: PaneOps;
} = $props();
let containerEl: HTMLDivElement;
@ -28,7 +27,6 @@
const pos = isH ? e.clientX - rect.left : e.clientY - rect.top;
const size = isH ? rect.width : rect.height;
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));
node.ratio = r;
}
@ -47,7 +45,7 @@
bind:this={containerEl}
>
<div class="side" style="flex: {node.ratio}">
<Pane node={node.a} {onSplit} {onClose} />
<Pane node={node.a} {ops} />
</div>
<div
class="gutter"
@ -62,7 +60,7 @@
onpointercancel={onPointerUp}
></div>
<div class="side" style="flex: {1 - node.ratio}">
<Pane node={node.b} {onSplit} {onClose} />
<Pane node={node.b} {ops} />
</div>
</div>

15
src/lib/layout/ops.ts Normal file
View 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[];
}

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;