diff --git a/web/src/main.ts b/web/src/main.ts index 242d6e2..545a389 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -5,6 +5,7 @@ import { CatalogStore } from "./events.js"; import { mountHud, type Hud } from "./hud.js"; import { emptyLayout, type Layout, openSessions } from "./layout.js"; import { createMultiView, type MultiView } from "./multiview.js"; +import { loadLayout, saveLayout } from "./persistence.js"; import { openSpawnModal } from "./spawn.js"; import { initTheme } from "./theme.js"; import { sessionName } from "./types.js"; @@ -106,8 +107,16 @@ hud = mountHud(root, store, () => pool.focused()); // ---- layout state ---- // Single source of truth for what's mounted: tabs of split trees. The hash route -// is just a deep-link into it (`#/attach/:id` focuses/opens a session). -const layout: Layout = emptyLayout(); +// is just a deep-link into it (`#/attach/:id` focuses/opens a session). Restored +// from localStorage per device (issue #34); dead sessions self-prune on attach. +const layout: Layout = loadLayout() ?? emptyLayout(); + +// Debounced persist — the controller calls this after every layout change. +let saveTimer: number | undefined; +function scheduleSave(): void { + clearTimeout(saveTimer); + saveTimer = window.setTimeout(() => saveLayout(layout), 400); +} function infoFor(sessionId: string) { const s = store.snapshot().sessions.get(sessionId); @@ -155,7 +164,15 @@ function renderNotFound(raw: string): HTMLElement { // The multi-view controller renders tabs + splits inside the content pane and // mounts a live attach into each session leaf. -mv = createMultiView({ pool, layout, sidebar, infoFor, onDisconnect: closePane, renderWelcome }); +mv = createMultiView({ + pool, + layout, + sidebar, + infoFor, + onDisconnect: closePane, + renderWelcome, + onLayoutChange: scheduleSave, +}); let contentShowsMv = false; function showMultiView(): void { if (!contentShowsMv) { diff --git a/web/src/multiview.ts b/web/src/multiview.ts index 85b59cd..3b1d8c8 100644 --- a/web/src/multiview.ts +++ b/web/src/multiview.ts @@ -33,6 +33,9 @@ export interface MultiViewOptions { onDisconnect: (sessionId: string) => void; /** Welcome content for an empty tab. */ renderWelcome: () => HTMLElement; + /** Called after any layout change (structure, focus, or divider resize) so the + * shell can persist it (issue #34). */ + onLayoutChange?: () => void; } export interface MultiView { @@ -53,6 +56,7 @@ export interface MultiView { export function createMultiView(opts: MultiViewOptions): MultiView { const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts; + const persist = () => opts.onLayoutChange?.(); const el = document.createElement("div"); el.className = "mv"; @@ -96,6 +100,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { if (tab.focusedLeafId === leafId) return; tab.focusedLeafId = leafId; syncFocus(); + persist(); } // ---- pane + split DOM ---- @@ -193,6 +198,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { } divider.removeEventListener("pointermove", move); divider.removeEventListener("pointerup", up); + persist(); // save the new split ratio }; divider.addEventListener("pointermove", move); divider.addEventListener("pointerup", up); @@ -320,6 +326,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { pool.setFocus(null); canvas.appendChild(renderWelcome()); sidebar.setHighlights({ open: openSessions(layout), focused: null, colors: colorMap() }); + persist(); return; } @@ -332,6 +339,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { canvas.appendChild(buildTree(tab.root, hosts)); pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info }))); syncFocus(); + persist(); } function closeSession(sessionId: string): void { diff --git a/web/src/persistence.ts b/web/src/persistence.ts new file mode 100644 index 0000000..d64e181 --- /dev/null +++ b/web/src/persistence.ts @@ -0,0 +1,86 @@ +// 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 broker +// 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, JSON.stringify({ v: VERSION, layout })); + } catch { + // Storage full or unavailable (private mode) — persistence is best-effort. + } +} + +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; + } +} + +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; +}