// 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, leaves, locateSession, removeLeaf, splitLeaf, type Dir, type Layout, } 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; } export interface LayoutState { layout: Layout; } 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. */ 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; } 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(); } return { layout: load() ?? emptyLayout(), openSession(sessionId) { 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) { 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) { 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) { mutate((layout) => { const tab = activeTab(layout); tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true); equalizeRatios(tab.root); }); }, closePane(leafId) { 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) { mutate((layout) => { if (layout.tabs.some((t) => t.id === tabId)) layout.activeTabId = tabId; }); }, addTab() { mutate((layout) => { addTabToLayout(layout); }); }, closeTab(tabId) { mutate((layout) => { closeTabInLayout(layout, tabId); }); }, reorderTab(fromId, toId) { 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); }); }, }; }); }