// Zustand wrapper around ../layout.js's layout tree model (issue #249). // Actions mutate the tree in place via layout.js's own functions, then // re-wrap it so `useStore` selectors relying on referential equality notice. import { create } from "zustand"; import { activeTab, addTab as addTabToLayout, closeTab as closeTabInLayout, emptyLayout, equalizeRatios, findLeaf, freshId, leaves, locateSession, removeLeaf, splitLeaf, type Dir, type Layout, type Tab, } from "../layout.js"; import { loadLayout, saveLayout } from "../persistence.js"; import type { Uuid } from "../types.js"; import type { Scheduler } from "./catalogStore.js"; const SAVE_DEBOUNCE_MS = 400; const browserScheduler: Scheduler = { setTimer: (fn, ms) => window.setTimeout(fn, ms), clearTimer: (h) => window.clearTimeout(h), }; export interface LayoutStoreOptions { load?: () => Layout | null; save?: (layout: Layout) => void; scheduler?: Scheduler; } /** A provisional preview (docs/PREVIEW.md): records exactly what * `previewSession` moved, so ending it restores the layout byte-for-byte. */ export interface PreviewState { sessionId: Uuid; /** Tab that was active before the preview. */ prevActiveTabId: string; /** Focused leaf to restore, when the preview stole focus in a real tab. */ prevFocus: { tabId: string; leafId: string | null } | null; /** Throwaway tab built for a session that no pane was showing. */ tempTabId: string | null; } export interface LayoutState { layout: Layout; /** Session shown provisionally in the content pane, or null. A preview is * a view, not state: it never reaches `save` (docs/PREVIEW.md). */ preview: PreviewState | null; } export interface LayoutActions { /** Bring `sessionId` into view: focus it if already open, else fill the * focused pane (VS-Code-style replace), or seed an empty tab. Settles a * matching preview in place rather than moving it a second time. */ openSession(sessionId: Uuid): void; /** Same, but never evicts a running pane: reuses an empty leaf or splits * beside the focused one. `newTab` opens its own tab instead. */ openSessionBeside(sessionId: Uuid, opts?: { newTab?: boolean }): void; /** Drop the pane showing `sessionId` (closed/killed elsewhere). */ closeSession(sessionId: Uuid): void; splitFocused(leafId: string, dir: Dir): void; closePane(leafId: string): void; focusLeaf(leafId: string): void; selectTab(tabId: string): void; addTab(): void; closeTab(tabId: string): void; /** Tab-strip drag-and-drop reordering: moves `fromId` to sit where `toId` * currently is. */ reorderTab(fromId: string, toId: string): void; /** Persist whatever a divider drag left on a split's `ratio` — the drag * itself mutates the node directly (same trade as the vanilla version), * this just re-wraps and schedules the save. */ commitLayout(): void; /** Boot reconciliation: drop every leaf whose session isn't in `liveIds`, * and close any tab that held nothing else. A session killed while its * tab was closed is pruned instead of leaving a pane that can only ever * show a dead socket. Tabs that were already empty are left alone. */ reconcile(liveIds: ReadonlySet): void; /** Show `sessionId` in the content pane without committing to it * (docs/PREVIEW.md): its own tab comes forward, or a throwaway tab * carries it. `null` puts back whatever was on screen before. Never * schedules a save while a preview is up. */ previewSession(sessionId: Uuid | null): void; /** Keep what is being previewed: its throwaway tab becomes a real one, and * the focus it moved stands. Any other pending preview is simply * discarded. Returns whether `sessionId` was the one being previewed. */ commitPreview(sessionId: Uuid): boolean; } export type LayoutStore = LayoutState & LayoutActions; export type LayoutStoreApi = ReturnType; /** Builds an isolated layout store — load/save/scheduler are injectable * (tests), or default to the real localStorage persistence.ts + browser * timers. Same shape as {@link createCatalogStore}. */ export function createLayoutStore(opts: LayoutStoreOptions = {}) { const load = opts.load ?? loadLayout; const save = opts.save ?? saveLayout; const scheduler = opts.scheduler ?? browserScheduler; let saveTimer: number | null = null; return create((set, get) => { function touch(): void { // A shallow clone, not the same reference back: `useStore` (and any // other Object.is-based selector) needs the identity to change to // notice a mutation made in place on the tree underneath. set({ layout: { ...get().layout } }); } function schedulePersist(): void { if (saveTimer !== null) scheduler.clearTimer(saveTimer); saveTimer = scheduler.setTimer(() => { saveTimer = null; save(get().layout); }, SAVE_DEBOUNCE_MS); } function commit(): void { touch(); schedulePersist(); } function mutate(fn: (layout: Layout) => void): void { fn(get().layout); commit(); } // ---- preview (docs/PREVIEW.md): a view, not a layout change — never // schedules a save while it is up. ---- /** Undo the preview's moves. Mutates only — the caller re-renders / * persists as it sees fit. Returns whether a preview was active. */ function restorePreview(): boolean { const p = get().preview; if (!p) return false; set({ preview: null }); const layout = get().layout; if (p.tempTabId) { const i = layout.tabs.findIndex((t) => t.id === p.tempTabId); if (i >= 0) layout.tabs.splice(i, 1); } else if (p.prevFocus) { const t = layout.tabs.find((x) => x.id === p.prevFocus!.tabId); if (t) t.focusedLeafId = p.prevFocus.leafId; } layout.activeTabId = layout.tabs.some((t) => t.id === p.prevActiveTabId) ? p.prevActiveTabId : layout.tabs[0].id; return true; } function settlePreview(sessionId: Uuid): boolean { const p = get().preview; if (!p) return false; if (p.sessionId !== sessionId) { restorePreview(); return false; } set({ preview: null }); if (p.tempTabId) { const layout = get().layout; const t = layout.tabs.find((x) => x.id === p.tempTabId); if (t) t.title = `Tab ${layout.tabs.length}`; } return true; } return { layout: load() ?? emptyLayout(), preview: null, openSession(sessionId) { if (get().commitPreview(sessionId)) return; mutate((layout) => { const loc = locateSession(layout, sessionId); if (loc) { layout.activeTabId = loc.tab.id; loc.tab.focusedLeafId = loc.leaf.id; return; } const tab = activeTab(layout); if (!tab.root) { tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true); return; } const target = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || leaves(tab.root)[0]; target.sessionId = sessionId; tab.focusedLeafId = target.id; }); }, openSessionBeside(sessionId, placement) { restorePreview(); mutate((layout) => { const loc = locateSession(layout, sessionId); if (loc) { layout.activeTabId = loc.tab.id; loc.tab.focusedLeafId = loc.leaf.id; return; } if (placement?.newTab) { const fresh = addTabToLayout(layout); fresh.focusedLeafId = splitLeaf(fresh, "", sessionId, "row", true); return; } const tab = activeTab(layout); if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) { tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true); return; } const target = findLeaf(tab.root, tab.focusedLeafId)!; if (!target.sessionId) { target.sessionId = sessionId; } else { tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true); equalizeRatios(tab.root); } }); }, closeSession(sessionId) { restorePreview(); mutate((layout) => { const loc = locateSession(layout, sessionId); if (!loc) return; removeLeaf(loc.tab, loc.leaf.id); if (loc.tab.focusedLeafId === loc.leaf.id) { loc.tab.focusedLeafId = leaves(loc.tab.root)[0]?.id ?? null; } if (!loc.tab.root) closeTabInLayout(layout, loc.tab.id); }); }, splitFocused(leafId, dir) { restorePreview(); mutate((layout) => { const tab = activeTab(layout); tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true); equalizeRatios(tab.root); }); }, closePane(leafId) { restorePreview(); mutate((layout) => { const tab = activeTab(layout); removeLeaf(tab, leafId); if (tab.focusedLeafId === leafId) { tab.focusedLeafId = leaves(tab.root)[0]?.id ?? null; } }); }, focusLeaf(leafId) { mutate((layout) => { const tab = activeTab(layout); if (tab.focusedLeafId === leafId) return; tab.focusedLeafId = leafId; }); }, selectTab(tabId) { restorePreview(); mutate((layout) => { if (layout.tabs.some((t) => t.id === tabId)) layout.activeTabId = tabId; }); }, addTab() { restorePreview(); mutate((layout) => { addTabToLayout(layout); }); }, closeTab(tabId) { restorePreview(); mutate((layout) => { closeTabInLayout(layout, tabId); }); }, reorderTab(fromId, toId) { restorePreview(); mutate((layout) => { if (fromId === toId) return; const from = layout.tabs.findIndex((t) => t.id === fromId); const to = layout.tabs.findIndex((t) => t.id === toId); if (from < 0 || to < 0) return; const [moved] = layout.tabs.splice(from, 1); layout.tabs.splice(to, 0, moved); }); }, commitLayout() { commit(); }, reconcile(liveIds) { mutate((layout) => { const emptied: string[] = []; for (const tab of [...layout.tabs]) { let prunedAny = false; for (const leaf of leaves(tab.root)) { if (leaf.sessionId && !liveIds.has(leaf.sessionId)) { removeLeaf(tab, leaf.id); prunedAny = true; } } if (!prunedAny) continue; if (!tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) { tab.focusedLeafId = leaves(tab.root)[0]?.id ?? null; } if (!tab.root) emptied.push(tab.id); } for (const id of emptied) closeTabInLayout(layout, id); }); }, previewSession(sessionId) { const current = get().preview; if (current?.sessionId === sessionId) return; if (sessionId === null) { if (restorePreview()) commit(); return; } restorePreview(); const layout = get().layout; const prevActiveTabId = layout.activeTabId; const loc = locateSession(layout, sessionId); let preview: PreviewState; if (loc) { preview = { sessionId, prevActiveTabId, prevFocus: { tabId: loc.tab.id, leafId: loc.tab.focusedLeafId }, tempTabId: null, }; layout.activeTabId = loc.tab.id; loc.tab.focusedLeafId = loc.leaf.id; } else { // Not on screen anywhere: a throwaway tab carries it, so no // existing pane is evicted and nothing the operator arranged moves. const tab: Tab = { id: freshId("t"), title: "Preview", root: null, focusedLeafId: null }; tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true); layout.tabs.push(tab); layout.activeTabId = tab.id; preview = { sessionId, prevActiveTabId, prevFocus: null, tempTabId: tab.id }; } set({ layout: { ...layout }, preview }); }, commitPreview(sessionId) { const ok = settlePreview(sessionId); if (ok) commit(); return ok; }, }; }); }