Extends layoutStore with previewSession/commitPreview, mirroring multiview.ts's preview controller: a throwaway tab for a session no pane holds, byte-for-byte restore on end, and every real layout-changing action drops an active preview first. Never schedules a save while a preview is up. Adds usePreview, wrapping the store actions with previewSchedule.ts's 120ms leading-edge throttle, plus a touch guard and an immediate `end()` for real changes. MultiView/TabBar render the preview hue on the pane titlebar, canvas ring/chip, and the throwaway tab (dashed, unclickable).
153 lines
5.8 KiB
TypeScript
153 lines
5.8 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { leaves } from "../../layout.js";
|
|
import { createLayoutStore } from "../../stores/layoutStore.js";
|
|
import type { Session } from "../../types.js";
|
|
import { MultiView } from "./MultiView.js";
|
|
|
|
vi.mock("../terminal/TerminalPane.js", () => ({
|
|
TerminalPane: ({ sessionId }: { sessionId: string }) => <div data-testid={`terminal-${sessionId}`} />,
|
|
}));
|
|
|
|
vi.mock("../structured/StructuredPane.js", () => ({
|
|
StructuredPane: ({ sessionId }: { sessionId: string }) => <div data-testid={`structured-${sessionId}`} />,
|
|
}));
|
|
|
|
afterEach(cleanup);
|
|
|
|
function store() {
|
|
return createLayoutStore({
|
|
load: () => null,
|
|
save: vi.fn(),
|
|
scheduler: { setTimer: () => 1, clearTimer: () => {} },
|
|
});
|
|
}
|
|
|
|
function session(id: string, over: Partial<Session> = {}): Session {
|
|
return {
|
|
id,
|
|
cwd: "/home/n",
|
|
command: "claude",
|
|
env: {},
|
|
metadata: {},
|
|
status: "Running",
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
...over,
|
|
} as Session;
|
|
}
|
|
|
|
describe("MultiView", () => {
|
|
it("shows the empty state when the active tab has no panes", () => {
|
|
const s = store();
|
|
render(<MultiView store={s} />);
|
|
expect(screen.getByText(/No session open/)).toBeTruthy();
|
|
});
|
|
|
|
it("renders a pane per leaf, dispatching to the terminal or structured body", () => {
|
|
const s = store();
|
|
s.getState().openSession("s1");
|
|
s.getState().openSessionBeside("s2");
|
|
const sessions = new Map([
|
|
["s1", session("s1")],
|
|
["s2", session("s2", { view: "Structured" })],
|
|
]);
|
|
render(<MultiView store={s} sessions={sessions} />);
|
|
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
|
|
expect(screen.getByTestId("structured-s2")).toBeTruthy();
|
|
});
|
|
|
|
it("split right adds a second pane to the tree", () => {
|
|
const s = store();
|
|
s.getState().openSession("s1");
|
|
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
|
fireEvent.click(screen.getByTitle("Split right"));
|
|
expect(screen.getAllByText("Empty pane")).toHaveLength(1);
|
|
});
|
|
|
|
it("close pane removes it", () => {
|
|
const s = store();
|
|
s.getState().openSession("s1");
|
|
const leafId = leaves(s.getState().layout.tabs[0].root)[0].id;
|
|
s.getState().splitFocused(leafId, "row"); // adds an empty leaf beside s1
|
|
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
|
// splitFocused(after: true) puts the fresh empty leaf second — close it.
|
|
fireEvent.click(screen.getAllByTitle("Close pane")[1]);
|
|
expect(screen.queryByText("Empty pane")).toBeNull();
|
|
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
|
|
});
|
|
|
|
it("prunes a leaf whose session no longer exists, once the live set is known", () => {
|
|
const s = store();
|
|
s.getState().openSession("s1");
|
|
s.getState().openSessionBeside("dead");
|
|
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
|
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
|
|
expect(screen.queryByTestId("terminal-dead")).toBeNull();
|
|
});
|
|
|
|
it("does not touch the layout while the live set is still unknown", () => {
|
|
const s = store();
|
|
s.getState().openSession("s1");
|
|
render(<MultiView store={s} />);
|
|
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(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
|
|
|
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(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
|
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(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
|
|
|
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(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
|
|
|
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"]);
|
|
});
|
|
});
|
|
});
|