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 (