Save the layout tree (tabs + splits + focus + ratios) to localStorage, debounced, on every structural/focus/resize change; restore it on boot. Load rebuilds with fresh node ids and defensively drops malformed data — a corrupt or versioned-out blob falls back to a single empty tab. Sessions that no longer exist when restored aren't pruned eagerly (the session list isn't known at boot); their panes attach, the broker closes the dead socket, and the pane auto-closes. Closes #34. Part of epic #37. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+20
-3
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
Reference in New Issue
Block a user