This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/persistence.ts
T
Miguel Palhas 6f8de8de7c fix(maestro): draft-pane regressions from the integration review
Six findings from reviewing the accumulated blitz/spawn-v2 diff:

- loadRecents restored the seeded cwd but never emitted it, so the leaf's
  draft kept cwd:"" and the next render reseeded the form blank.
- stripDrafts did not recurse, so every half-filled draft in a split tab
  was written to localStorage; the old test passed only because hydrate
  discards draft on the way back in.
- showRawCwd and selectPreset grabbed focus from async init paths, so a
  draft pane stole the caret seconds after mounting.
- .mv-draft inherited no layout once the form left .modal-body.
- onSuccess mounted the new session onto a leaf that may have been closed
  while the POST was in flight, stranding it in no pane.
2026-08-23 20:12:28 +01:00

110 lines
3.6 KiB
TypeScript

// Per-device persistence of the multi-view layout (issue #34). The layout tree
// is plain data, so it serialises straight to localStorage. On load we rebuild
// with fresh node ids (the id counter resets each boot) and defensively drop
// anything malformed — a corrupt or stale blob falls back to a single empty tab.
//
// Sessions that no longer exist when a layout is restored are not pruned here
// (the session list isn't known yet at boot); their panes attach, the daemon
// closes the dead socket, and the pane auto-closes (see ConnectionPool.onClosed).
import { freshId, type Layout, type Node, type Tab } from "./layout.js";
const KEY = "maestro-layout-v1";
const VERSION = 1;
export function saveLayout(layout: Layout): void {
try {
localStorage.setItem(KEY, serializeLayout(layout));
} catch {
// Storage full or unavailable (private mode) — persistence is best-effort.
}
}
/** Serialise the layout for storage with draft state stripped (issue #57): a
* reload gives back an empty form, never a stale half-filled one. */
export function serializeLayout(layout: Layout): string {
const clean: Layout = {
...layout,
tabs: layout.tabs.map((t) => ({
...t,
root: stripDrafts(t.root),
})),
};
return JSON.stringify({ v: VERSION, layout: clean });
}
function stripDrafts(node: Node | null): Node | null {
if (!node) return node;
// Recurse: a draft pane only exists once a tab has split, so a shallow strip
// would persist every half-filled form it was meant to drop.
if (node.kind === "split") {
return { ...node, a: stripDrafts(node.a)!, b: stripDrafts(node.b)! };
}
return { ...node, draft: null };
}
export function loadLayout(): Layout | null {
let raw: string | null;
try {
raw = localStorage.getItem(KEY);
} catch {
return null;
}
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as { v?: number; layout?: unknown };
if (parsed.v !== VERSION) return null;
return hydrate(parsed.layout);
} catch {
return null;
}
}
export function hydrate(raw: unknown): Layout | null {
if (!raw || typeof raw !== "object") return null;
const r = raw as { tabs?: unknown; activeTabId?: unknown };
if (!Array.isArray(r.tabs) || r.tabs.length === 0) return null;
const tabs: Tab[] = [];
let activeTabId = "";
for (const rt of r.tabs as Array<Record<string, unknown>>) {
const id = freshId("t");
let focusedLeafId: string | null = null;
const root = hydrateNode(rt.root, rt.focusedLeafId, (fid) => {
focusedLeafId = fid;
});
tabs.push({
id,
title: typeof rt.title === "string" ? rt.title : `Tab ${tabs.length + 1}`,
root,
focusedLeafId,
});
if (rt.id === r.activeTabId) activeTabId = id;
}
if (!activeTabId) activeTabId = tabs[0].id;
return { tabs, activeTabId };
}
function hydrateNode(
raw: unknown,
oldFocused: unknown,
onFocus: (newId: string) => void,
): Node | null {
if (!raw || typeof raw !== "object") return null;
const n = raw as Record<string, unknown>;
if (n.kind === "leaf") {
const id = freshId("l");
if (n.id === oldFocused) onFocus(id);
return { kind: "leaf", id, sessionId: typeof n.sessionId === "string" ? n.sessionId : null };
}
if (n.kind === "split" && (n.dir === "row" || n.dir === "col")) {
const a = hydrateNode(n.a, oldFocused, onFocus);
const b = hydrateNode(n.b, oldFocused, onFocus);
if (!a) return b;
if (!b) return a;
const ratio = typeof n.ratio === "number" && n.ratio > 0.05 && n.ratio < 0.95 ? n.ratio : 0.5;
return { kind: "split", dir: n.dir, a, b, ratio };
}
return null;
}