// 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>) { 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; 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; }