Closes #249: layoutStore.ts wraps the existing layout.ts tree model in Zustand, persisting to localStorage on a 400ms debounce and adding boot reconciliation to prune leaves for sessions no longer live. MultiView/TabBar/PaneContainer/PaneTitle render tabs, nested splits with draggable dividers, drag-and-drop tab reordering, and dispatch each pane to TerminalPane or StructuredPane by session view. Preview mode (#251) and sidebar drag-to-dock stay out of scope here.
254 lines
8.1 KiB
TypeScript
254 lines
8.1 KiB
TypeScript
// 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<Uuid>): void;
|
|
}
|
|
|
|
export type LayoutStore = LayoutState & LayoutActions;
|
|
export type LayoutStoreApi = ReturnType<typeof createLayoutStore>;
|
|
|
|
/** 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<LayoutStore>((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);
|
|
});
|
|
},
|
|
};
|
|
});
|
|
}
|