diff --git a/web/src/components/layout/MultiView.test.tsx b/web/src/components/layout/MultiView.test.tsx
index 1b49c4c..239fdd6 100644
--- a/web/src/components/layout/MultiView.test.tsx
+++ b/web/src/components/layout/MultiView.test.tsx
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
-import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { leaves } from "../../layout.js";
@@ -93,4 +93,60 @@ describe("MultiView", () => {
render( );
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
});
+
+ describe("preview mode (docs/PREVIEW.md)", () => {
+ it("shows a session no pane holds in a throwaway tab, marked as a preview", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ render( );
+
+ act(() => s.getState().previewSession("s9"));
+ // s1's own tab is still in the strip — the preview never touched it.
+ expect(screen.getByText("Tab 1 · 1")).toBeTruthy();
+ const pane = document.querySelector('[data-session-id="s9"]');
+ expect(pane).toBeTruthy();
+ expect(pane!.querySelector(".bg-preview-tint")).toBeTruthy();
+ expect(screen.getByText("preview")).toBeTruthy();
+ });
+
+ it("puts the layout back byte for byte once the preview ends", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ render( );
+ const before = JSON.stringify(s.getState().layout);
+
+ act(() => s.getState().previewSession("s9"));
+ act(() => s.getState().previewSession(null));
+
+ expect(JSON.stringify(s.getState().layout)).toBe(before);
+ expect(screen.queryByText("preview")).toBeNull();
+ });
+
+ it("does not grab the keyboard while previewing", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ render( );
+
+ act(() => s.getState().previewSession("s9"));
+ const pane = document.querySelector('[data-session-id="s9"]') as HTMLElement;
+ expect(pane.querySelector('[data-testid="terminal-s9"]')).toBeTruthy();
+ // TerminalPane's `focus` prop is verified by TerminalPane's own tests;
+ // here the preview must never end up as the active-tab's accent pane.
+ expect(pane.className).not.toContain("border-accent");
+ });
+
+ it("clicking into a previewed pane commits it: no more preview, nothing moves a second time", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ render( );
+
+ act(() => s.getState().previewSession("s9"));
+ const pane = document.querySelector('[data-session-id="s9"]') as HTMLElement;
+ fireEvent.pointerDown(pane);
+
+ expect(s.getState().preview).toBeNull();
+ expect(screen.queryByText("preview")).toBeNull();
+ expect(leaves(s.getState().layout.tabs[1].root).map((l) => l.sessionId)).toEqual(["s9"]);
+ });
+ });
});
diff --git a/web/src/components/layout/MultiView.tsx b/web/src/components/layout/MultiView.tsx
index de7acf8..fe5e27b 100644
--- a/web/src/components/layout/MultiView.tsx
+++ b/web/src/components/layout/MultiView.tsx
@@ -1,5 +1,5 @@
-// React port of multiview.ts's tab + split tree (issue #249). Preview mode
-// (docs/PREVIEW.md) is issue #251; sidebar drag-to-dock and the mobile
+// React port of multiview.ts's tab + split tree (issue #249) plus its preview
+// mode (issue #251, docs/PREVIEW.md). Sidebar drag-to-dock and the mobile
// single-pane collapse stay with whichever issue wires this into the shell
// (#253) — this renders the tree itself: tabs, nested splits, pane chrome.
@@ -8,6 +8,7 @@ import { useEffect, useRef } from "react";
import { useStore } from "zustand";
import { activeTab, type Dir, type Node } from "../../layout.js";
+import { usePreview } from "../../hooks/usePreview.js";
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
import type { Session, Uuid } from "../../types.js";
import { PaneContainer } from "./PaneContainer.js";
@@ -26,6 +27,8 @@ export interface MultiViewProps {
export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiViewProps) {
const layout = useStore(store, (s: LayoutStore) => s.layout);
const tab = activeTab(layout);
+ const { previewedSessionId, commit: commitPreview } = usePreview({ store });
+ const isPreviewing = previewedSessionId !== null;
const reconciledRef = useRef(false);
useEffect(() => {
@@ -37,9 +40,17 @@ export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiVi
return (
-
+
{tab.root ? (
-
+
) : (
(emptyState ?? (
@@ -47,6 +58,14 @@ export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiVi
))
)}
+ {isPreviewing && (
+ <>
+
+
+ preview
+
+ >
+ )}
);
@@ -57,6 +76,8 @@ interface TreeProps {
store: LayoutStoreApi;
sessions?: ReadonlyMap
;
onDisconnect?: (sessionId: Uuid) => void;
+ previewedSessionId: Uuid | null;
+ commitPreview: (sessionId: Uuid) => void;
}
function SplitNode({ node, ...rest }: TreeProps & { node: Node }) {
@@ -127,21 +148,34 @@ function Pane({
store,
sessions,
onDisconnect,
+ previewedSessionId,
+ commitPreview,
}: TreeProps & { leaf: Extract; isFocused: boolean }) {
const session = leaf.sessionId ? sessions?.get(leaf.sessionId) : undefined;
+ const isPreview = leaf.sessionId !== null && leaf.sessionId === previewedSessionId;
const splitFocused = (dir: Dir) => store.getState().splitFocused(leaf.id, dir);
return (
store.getState().focusLeaf(leaf.id)}
+ onPointerDownCapture={() => {
+ // Clicking into a previewed pane is the operator choosing it
+ // (docs/PREVIEW.md).
+ if (isPreview) commitPreview(leaf.sessionId!);
+ store.getState().focusLeaf(leaf.id);
+ }}
>
-
+
{leaf.sessionId ? (
) : (
@@ -189,7 +223,7 @@ function Pane({
store.getState().closeSession(leaf.sessionId!)}
/>
) : (
diff --git a/web/src/components/layout/TabBar.tsx b/web/src/components/layout/TabBar.tsx
index 1a56185..1707ce9 100644
--- a/web/src/components/layout/TabBar.tsx
+++ b/web/src/components/layout/TabBar.tsx
@@ -13,6 +13,7 @@ export interface TabBarProps {
export function TabBar({ store }: TabBarProps) {
const layout = useStore(store, (s: LayoutStore) => s.layout);
+ const previewTempTabId = useStore(store, (s: LayoutStore) => s.preview?.tempTabId ?? null);
const [draggingId, setDraggingId] = useState(null);
const [overId, setOverId] = useState(null);
@@ -22,6 +23,9 @@ export function TabBar({ store }: TabBarProps) {
const count = leaves(tab.root).filter((l) => l.sessionId).length;
const isActive = tab.id === layout.activeTabId;
const isDropTarget = overId === tab.id && draggingId !== null && draggingId !== tab.id;
+ // A preview's throwaway tab is not a place to go — it goes on its
+ // own the moment the preview ends (docs/PREVIEW.md).
+ const isPreviewTab = tab.id === previewTempTabId;
return (
{
@@ -57,13 +65,16 @@ export function TabBar({ store }: TabBarProps) {
setDraggingId(null);
setOverId(null);
}}
- onClick={() => store.getState().selectTab(tab.id)}
+ onClick={() => {
+ if (isPreviewTab) return;
+ store.getState().selectTab(tab.id);
+ }}
>
{tab.title}
{count ? ` · ${count}` : ""}
- {layout.tabs.length > 1 && (
+ {layout.tabs.length > 1 && !isPreviewTab && (
void>();
+ let id = 0;
+ return {
+ scheduler: {
+ setTimer: (fn: () => void) => {
+ const h = ++id;
+ timers.set(h, fn);
+ return h;
+ },
+ clearTimer: (h: number) => {
+ timers.delete(h);
+ },
+ },
+ flush() {
+ for (const fn of [...timers.values()]) fn();
+ timers.clear();
+ },
+ };
+}
+
+/** Hand-driven clock + timer queue for the preview schedule's own throttle,
+ * independent of the store's save-debounce scheduler above. */
+function fakeClock() {
+ let now = 1_000;
+ const timers = new Map void }>();
+ let handle = 1;
+ return {
+ now: () => now,
+ setTimer: (fn: () => void, ms: number) => {
+ const h = handle++;
+ timers.set(h, { at: now + ms, fn });
+ return h;
+ },
+ clearTimer: (h: number) => {
+ timers.delete(h);
+ },
+ advance(ms: number) {
+ now += ms;
+ for (const [h, t] of [...timers]) {
+ if (t.at <= now) {
+ timers.delete(h);
+ act(() => t.fn());
+ }
+ }
+ },
+ };
+}
+
+function newStore(): LayoutStoreApi {
+ return createLayoutStore({ load: () => null, save: vi.fn(), scheduler: fakeScheduler().scheduler });
+}
+
+let controls: UsePreviewResult;
+
+function Probe({ store, clock }: { store: LayoutStoreApi; clock: ReturnType }) {
+ controls = usePreview({
+ store,
+ scheduleDeps: { now: clock.now, setTimer: clock.setTimer, clearTimer: clock.clearTimer },
+ });
+ return {controls.previewedSessionId ?? "none"}
;
+}
+
+afterEach(cleanup);
+
+describe("usePreview", () => {
+ it("shows the first preview immediately — no settling delay", () => {
+ const store = newStore();
+ store.getState().openSession("s1");
+ const clock = fakeClock();
+ const view = render( );
+
+ act(() => controls.preview("s9"));
+ expect(view.container.textContent).toBe("s9");
+ expect(leaves(store.getState().layout.tabs[1]?.root ?? null).map((l) => l.sessionId)).toEqual(["s9"]);
+ });
+
+ it("throttles a burst to one leading + one trailing pane swap", () => {
+ const store = newStore();
+ store.getState().openSession("s1");
+ const clock = fakeClock();
+ const view = render( );
+
+ act(() => {
+ controls.preview("s1");
+ controls.preview("s8");
+ controls.preview("s9");
+ });
+ expect(view.container.textContent).toBe("s1");
+ clock.advance(120);
+ expect(view.container.textContent).toBe("s9");
+ });
+
+ it("touch never previews", () => {
+ const store = newStore();
+ store.getState().openSession("s1");
+ const clock = fakeClock();
+ const view = render( );
+
+ act(() => controls.previewFromPointer("s9", "touch"));
+ expect(view.container.textContent).toBe("none");
+ expect(store.getState().preview).toBeNull();
+ });
+
+ it("previewFromPointer still previews for mouse/pen", () => {
+ const store = newStore();
+ store.getState().openSession("s1");
+ const clock = fakeClock();
+ const view = render( );
+
+ act(() => controls.previewFromPointer("s9", "mouse"));
+ expect(view.container.textContent).toBe("s9");
+ });
+
+ it("commit cancels a pending trailing preview and opens for real", () => {
+ const store = newStore();
+ store.getState().openSession("s1");
+ const clock = fakeClock();
+ const view = render( );
+
+ act(() => {
+ controls.preview("s1"); // leading edge: previews the already-open s1
+ controls.preview("s9"); // queued, gap not elapsed yet
+ controls.commit("s9");
+ });
+ expect(view.container.textContent).toBe("none"); // committed: no longer a preview
+ expect(leaves(store.getState().layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s9"]);
+
+ clock.advance(120);
+ // The cancelled trailing preview never fires.
+ expect(view.container.textContent).toBe("none");
+ });
+
+ it("end drops a preview immediately, bypassing the throttle", () => {
+ const store = newStore();
+ store.getState().openSession("s1");
+ const clock = fakeClock();
+ const view = render( );
+
+ act(() => controls.preview("s9"));
+ expect(view.container.textContent).toBe("s9");
+
+ act(() => controls.end());
+ expect(view.container.textContent).toBe("none");
+ expect(store.getState().layout.tabs).toHaveLength(1);
+ });
+
+ it("cancels its pending trailing preview on unmount", () => {
+ const store = newStore();
+ store.getState().openSession("s1");
+ store.getState().addTab();
+ store.getState().openSession("s2"); // leading edge below will preview this, already on screen
+ const clock = fakeClock();
+ const view = render( );
+
+ act(() => {
+ controls.preview("s2"); // leading edge: fires now
+ controls.preview("s9"); // queued trailing preview
+ });
+ expect(store.getState().preview?.sessionId).toBe("s2");
+ view.unmount();
+ clock.advance(120);
+ // The queued trailing preview must not fire after unmount.
+ expect(store.getState().preview?.sessionId).toBe("s2");
+ });
+});
diff --git a/web/src/hooks/usePreview.ts b/web/src/hooks/usePreview.ts
new file mode 100644
index 0000000..c969ae7
--- /dev/null
+++ b/web/src/hooks/usePreview.ts
@@ -0,0 +1,81 @@
+// React port of previewSchedule.ts + multiview.ts's preview controller
+// (issue #251). Owns the throttled schedule, hands the layout store the
+// throttled result, and exposes the three ways a preview ends for real —
+// see docs/PREVIEW.md, the contract this mirrors.
+
+import { useCallback, useEffect, useRef } from "react";
+import { useStore } from "zustand";
+
+import { createPreviewSchedule, type PreviewScheduleDeps } from "../previewSchedule.js";
+import type { LayoutStore, LayoutStoreApi } from "../stores/layoutStore.js";
+import type { Uuid } from "../types.js";
+
+export interface UsePreviewOptions {
+ store: LayoutStoreApi;
+ /** Test seam for the schedule's clock/timers. */
+ scheduleDeps?: PreviewScheduleDeps;
+}
+
+export interface UsePreviewResult {
+ /** The session currently shown as a preview, or null. */
+ previewedSessionId: Uuid | null;
+ /** Throttled: show `sessionId` in the content pane, or `null` to put back
+ * whatever was on screen (a highlight move, or the highlight leaving
+ * every session). Never delayed on the leading edge. */
+ preview(sessionId: Uuid | null): void;
+ /** Pointer-hover entry point: touch has no hover, so a touch pointer never
+ * previews (docs/PREVIEW.md). */
+ previewFromPointer(sessionId: Uuid | null, pointerType: string): void;
+ /** Choosing the previewed session — click/Enter on its row, click into its
+ * pane, or handing it the keyboard (Tab). Keeps exactly what is on
+ * screen: a throwaway tab becomes real, a focus move inside an existing
+ * tab stands. Nothing moves a second time. */
+ commit(sessionId: Uuid): void;
+ /** Any other real change — opening the command bar, a dialog, navigating
+ * away — ends a pending or active preview immediately, bypassing the
+ * throttle. */
+ end(): void;
+}
+
+/** Preview state management (docs/PREVIEW.md): wraps the layout store's
+ * `previewSession`/`commitPreview` with the 120ms pane-swap throttle so the
+ * UI never has to schedule it by hand. */
+export function usePreview({ store, scheduleDeps }: UsePreviewOptions): UsePreviewResult {
+ const previewedSessionId = useStore(store, (s: LayoutStore) => s.preview?.sessionId ?? null);
+
+ const scheduleRef = useRef | null>(null);
+ if (!scheduleRef.current) {
+ scheduleRef.current = createPreviewSchedule((id) => store.getState().previewSession(id), scheduleDeps);
+ }
+ useEffect(() => {
+ const schedule = scheduleRef.current;
+ return () => schedule?.cancel();
+ }, []);
+
+ const preview = useCallback((sessionId: Uuid | null) => {
+ scheduleRef.current?.(sessionId);
+ }, []);
+
+ const previewFromPointer = useCallback(
+ (sessionId: Uuid | null, pointerType: string) => {
+ if (pointerType === "touch") return;
+ preview(sessionId);
+ },
+ [preview],
+ );
+
+ const commit = useCallback(
+ (sessionId: Uuid) => {
+ scheduleRef.current?.cancel();
+ store.getState().openSession(sessionId);
+ },
+ [store],
+ );
+
+ const end = useCallback(() => {
+ scheduleRef.current?.cancel();
+ store.getState().previewSession(null);
+ }, [store]);
+
+ return { previewedSessionId, preview, previewFromPointer, commit, end };
+}
diff --git a/web/src/stores/layoutStore.test.ts b/web/src/stores/layoutStore.test.ts
index b089ada..bb9ebbb 100644
--- a/web/src/stores/layoutStore.test.ts
+++ b/web/src/stores/layoutStore.test.ts
@@ -159,4 +159,133 @@ describe("layoutStore", () => {
expect(save).toHaveBeenCalledTimes(1);
expect(save.mock.calls[0][0]).toBe(s.getState().layout);
});
+
+ describe("previewSession (docs/PREVIEW.md)", () => {
+ it("brings a session's own tab forward, then puts the layout back", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ s.getState().addTab();
+ s.getState().openSession("s2");
+ const before = JSON.stringify(s.getState().layout);
+
+ s.getState().previewSession("s1");
+ const tab1 = s.getState().layout.tabs.find((t) => leaves(t.root).some((l) => l.sessionId === "s1"))!;
+ expect(s.getState().layout.activeTabId).toBe(tab1.id);
+ expect(s.getState().preview?.sessionId).toBe("s1");
+
+ s.getState().previewSession(null);
+ expect(s.getState().preview).toBeNull();
+ expect(JSON.stringify(s.getState().layout)).toBe(before);
+ });
+
+ it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ const before = JSON.stringify(s.getState().layout);
+
+ s.getState().previewSession("s9");
+ expect(s.getState().layout.tabs).toHaveLength(2);
+ const tempTab = s.getState().layout.tabs[1];
+ expect(leaves(tempTab.root).map((l) => l.sessionId)).toEqual(["s9"]);
+
+ s.getState().previewSession(null);
+ expect(s.getState().layout.tabs).toHaveLength(1);
+ expect(JSON.stringify(s.getState().layout)).toBe(before);
+ });
+
+ it("stepping between previews leaves only the newest one on screen", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ const before = JSON.stringify(s.getState().layout);
+
+ s.getState().previewSession("s9");
+ s.getState().previewSession("s8");
+ expect(s.getState().layout.tabs).toHaveLength(2);
+ expect(s.getState().preview?.sessionId).toBe("s8");
+
+ s.getState().previewSession(null);
+ expect(JSON.stringify(s.getState().layout)).toBe(before);
+ });
+
+ it("never persists while up, and saves again once settled back", () => {
+ const save = vi.fn();
+ const { scheduler, flush } = fakeScheduler();
+ const s = store({ save, scheduler });
+ s.getState().openSession("s1");
+ flush();
+ save.mockClear();
+
+ s.getState().previewSession("s9");
+ expect(save).not.toHaveBeenCalled();
+
+ s.getState().previewSession(null);
+ flush();
+ expect(save).toHaveBeenCalledTimes(1);
+ });
+
+ it("commitPreview keeps the throwaway tab as a real one", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ s.getState().previewSession("s9");
+
+ expect(s.getState().commitPreview("s9")).toBe(true);
+ expect(s.getState().preview).toBeNull();
+ expect(s.getState().layout.tabs).toHaveLength(2);
+ expect(leaves(s.getState().layout.tabs[1].root).map((l) => l.sessionId)).toEqual(["s9"]);
+ });
+
+ it("commitPreview discards a non-matching preview and reports false", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ s.getState().previewSession("s9");
+
+ expect(s.getState().commitPreview("s1")).toBe(false);
+ expect(s.getState().preview).toBeNull();
+ expect(s.getState().layout.tabs).toHaveLength(1);
+ });
+
+ it("openSession settles a matching preview instead of moving it a second time", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ s.getState().previewSession("s9");
+
+ s.getState().openSession("s9");
+ expect(s.getState().preview).toBeNull();
+ expect(s.getState().layout.tabs).toHaveLength(2);
+ expect(leaves(s.getState().layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1"]);
+ });
+
+ it("any real layout change ends a preview first: splitFocused", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ s.getState().previewSession("s9");
+ const first = leaves(s.getState().layout.tabs[0].root)[0].id;
+
+ s.getState().splitFocused(first, "row");
+ expect(s.getState().preview).toBeNull();
+ expect(s.getState().layout.tabs).toHaveLength(1);
+ expect(leaves(s.getState().layout.tabs[0].root).map((l) => l.sessionId).sort()).toEqual([null, "s1"]);
+ });
+
+ it("any real layout change ends a preview first: addTab/selectTab/closeTab", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ s.getState().previewSession("s9");
+ s.getState().addTab();
+ expect(s.getState().preview).toBeNull();
+ // The throwaway tab was discarded before the real tab was added.
+ expect(s.getState().layout.tabs).toHaveLength(2);
+ });
+
+ it("closeSession drops the preview before closing the real pane", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ s.getState().previewSession("s9");
+
+ s.getState().closeSession("s1");
+ expect(s.getState().preview).toBeNull();
+ expect(s.getState().layout.tabs).toHaveLength(1);
+ expect(s.getState().layout.tabs[0].root).toBeNull();
+ });
+ });
});
diff --git a/web/src/stores/layoutStore.ts b/web/src/stores/layoutStore.ts
index 813e092..92a7355 100644
--- a/web/src/stores/layoutStore.ts
+++ b/web/src/stores/layoutStore.ts
@@ -11,12 +11,14 @@ import {
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";
@@ -35,13 +37,29 @@ export interface LayoutStoreOptions {
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. */
+ * 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. */
@@ -66,6 +84,15 @@ export interface LayoutActions {
* 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;
@@ -107,10 +134,51 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
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) {
@@ -130,6 +198,7 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
},
openSessionBeside(sessionId, placement) {
+ restorePreview();
mutate((layout) => {
const loc = locateSession(layout, sessionId);
if (loc) {
@@ -158,6 +227,7 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
},
closeSession(sessionId) {
+ restorePreview();
mutate((layout) => {
const loc = locateSession(layout, sessionId);
if (!loc) return;
@@ -170,6 +240,7 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
},
splitFocused(leafId, dir) {
+ restorePreview();
mutate((layout) => {
const tab = activeTab(layout);
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
@@ -178,6 +249,7 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
},
closePane(leafId) {
+ restorePreview();
mutate((layout) => {
const tab = activeTab(layout);
removeLeaf(tab, leafId);
@@ -196,24 +268,28 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
},
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);
@@ -248,6 +324,45 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
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;
+ },
};
});
}