// @vitest-environment happy-dom // Sidebar preview (docs/PREVIEW.md): highlighting a session shows it in the // content pane without changing the layout the operator built. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { addTab, emptyLayout, type Layout, splitLeaf } from "./layout.js"; import { serializeLayout } from "./persistence.js"; import { createMultiView, type MultiView } from "./multiview.js"; import type { ConnectionPool } from "./connections.js"; import type { SidebarHandle } from "./catalog.js"; function stubFetch(): void { vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL) => { const url = String(input); const body = url.includes("/api/info") ? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] } : undefined; return new Response(JSON.stringify(body), { status: 200 }); }), ); } interface Spy { pool: ConnectionPool; visible: () => string[]; focusCalls: (string | null)[]; mountedWithFocus: (boolean | undefined)[]; } function fakePool(): Spy { let visible: string[] = []; const focusCalls: (string | null)[] = []; const mountedWithFocus: (boolean | undefined)[] = []; const pool = { setVisible(entries: { id: string }[], opts?: { focus?: boolean }) { visible = entries.map((e) => e.id); mountedWithFocus.push(opts?.focus); }, setFocus(id: string | null) { focusCalls.push(id); }, } as unknown as ConnectionPool; return { pool, visible: () => visible, focusCalls, mountedWithFocus }; } /** Last value the controller pushed to `SidebarHandle.setPreview`. */ const previewRow: { id: string | null } = { id: null }; /** Last highlights the controller pushed to `SidebarHandle.setHighlights`. */ const rowHighlights: { open: Set; focused: string | null } = { open: new Set(), focused: null, }; function fakeSidebar(): SidebarHandle { return { el: document.createElement("aside"), setActive() {}, setHighlights(h) { rowHighlights.open = h.open; rowHighlights.focused = h.focused; }, setPreview(id: string | null) { previewRow.id = id; }, focusList() {}, hasListFocus() { return false; }, dispose() {}, }; } function makeMv(layout: Layout, pool: ConnectionPool, onLayoutChange?: () => void): MultiView { const mv = createMultiView({ pool, layout, sidebar: fakeSidebar(), infoFor: () => ({ name: "sess", command: "bash" }), onDisconnect: () => {}, renderWelcome: () => document.createElement("div"), onLayoutChange, }); document.body.appendChild(mv.el); mv.render(); return mv; } /** Tab 1 holds `s1`; tab 2 holds `s2` and is the active one. */ function twoTabs(): Layout { const layout = emptyLayout(); const first = layout.tabs[0]; first.focusedLeafId = splitLeaf(first, "", "s1", "row", true); const second = addTab(layout); second.focusedLeafId = splitLeaf(second, "", "s2", "row", true); layout.activeTabId = second.id; return layout; } describe("sidebar preview", () => { beforeEach(() => { stubFetch(); document.body.replaceChildren(); previewRow.id = null; rowHighlights.open = new Set(); rowHighlights.focused = null; }); afterEach(() => { vi.unstubAllGlobals(); }); it("brings a session's own tab forward, then puts the layout back", () => { const layout = twoTabs(); const before = serializeLayout(layout); const spy = fakePool(); const mv = makeMv(layout, spy.pool); mv.previewSession("s1"); expect(spy.visible()).toEqual(["s1"]); expect(mv.previewedSessionId()).toBe("s1"); mv.previewSession(null); expect(spy.visible()).toEqual(["s2"]); expect(mv.previewedSessionId()).toBeNull(); expect(serializeLayout(layout)).toEqual(before); }); it("marks the previewed pane and its sidebar row, and unmarks them after", () => { const layout = twoTabs(); const spy = fakePool(); const mv = makeMv(layout, spy.pool); mv.previewSession("s1"); expect(previewRow.id).toBe("s1"); expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(1); expect( (mv.el.querySelector(".mv-pane.is-preview") as HTMLElement).dataset.sessionId, ).toBe("s1"); mv.previewSession(null); expect(previewRow.id).toBeNull(); expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(0); }); it("leaves the accent on the session the pane actually holds", () => { const layout = twoTabs(); const spy = fakePool(); const mv = makeMv(layout, spy.pool); expect(rowHighlights.focused).toBe("s2"); // Walking past other rows never moves the accent off what the operator // picked: only a real open does (docs/PREVIEW.md). mv.previewSession("s1"); expect(rowHighlights.focused).toBe("s2"); expect(mv.focusedSessionId()).toBe("s2"); mv.previewSession("s9"); expect(rowHighlights.focused).toBe("s2"); mv.previewSession(null); expect(rowHighlights.focused).toBe("s2"); mv.openSession("s1"); expect(rowHighlights.focused).toBe("s1"); }); it("shows a session no pane holds in a throwaway tab that leaves no trace", () => { const layout = twoTabs(); const before = serializeLayout(layout); const spy = fakePool(); const mv = makeMv(layout, spy.pool); mv.previewSession("s9"); expect(spy.visible()).toEqual(["s9"]); expect(layout.tabs).toHaveLength(3); // The throwaway tab is not something the operator has open. expect(mv.openSessions().has("s9")).toBe(false); mv.previewSession(null); expect(layout.tabs).toHaveLength(2); expect(serializeLayout(layout)).toEqual(before); }); it("stepping between previews leaves only the newest one on screen", () => { const layout = twoTabs(); const before = serializeLayout(layout); const spy = fakePool(); const mv = makeMv(layout, spy.pool); mv.previewSession("s9"); mv.previewSession("s1"); mv.previewSession("s8"); expect(spy.visible()).toEqual(["s8"]); expect(layout.tabs).toHaveLength(3); mv.previewSession(null); expect(serializeLayout(layout)).toEqual(before); }); it("opening what is previewed keeps it: the throwaway tab becomes real", () => { const layout = twoTabs(); const spy = fakePool(); const mv = makeMv(layout, spy.pool); mv.previewSession("s9"); mv.openSession("s9"); expect(mv.previewedSessionId()).toBeNull(); expect(layout.tabs).toHaveLength(3); expect(mv.openSessions().has("s9")).toBe(true); expect(spy.visible()).toEqual(["s9"]); // Nothing the operator had open was evicted to make room. expect(mv.openSessions().has("s2")).toBe(true); }); it("never persists, and saves again once the preview is settled", () => { const layout = twoTabs(); const spy = fakePool(); const saves = vi.fn(); const mv = makeMv(layout, spy.pool, saves); saves.mockClear(); mv.previewSession("s9"); expect(saves).not.toHaveBeenCalled(); mv.previewSession(null); expect(saves).toHaveBeenCalled(); }); it("never reports a previewed pane as focused to the daemon", () => { const layout = twoTabs(); const spy = fakePool(); const mv = makeMv(layout, spy.pool); spy.focusCalls.length = 0; mv.previewSession("s1"); mv.previewSession("s9"); expect(spy.focusCalls).toEqual([]); mv.previewSession(null); expect(spy.focusCalls).toEqual(["s2"]); }); it("mounts a preview without taking the keyboard", () => { const layout = twoTabs(); const spy = fakePool(); const mv = makeMv(layout, spy.pool); spy.mountedWithFocus.length = 0; mv.previewSession("s1"); expect(spy.mountedWithFocus).toEqual([false]); mv.openSession("s1"); expect(spy.mountedWithFocus.at(-1)).toBe(true); }); it("keeps the tab bar hidden while a lone tab is previewing", () => { const layout = emptyLayout(); const tab = layout.tabs[0]; tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true); const spy = fakePool(); const mv = makeMv(layout, spy.pool); expect(mv.el.classList.contains("mv-solo-tab")).toBe(true); mv.previewSession("s9"); expect(mv.el.classList.contains("mv-solo-tab")).toBe(true); expect(mv.el.classList.contains("mv-previewing")).toBe(true); mv.previewSession(null); expect(mv.el.classList.contains("mv-previewing")).toBe(false); }); });