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.
110 lines
3.6 KiB
TypeScript
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;
|
|
}
|