// @vitest-environment happy-dom import { beforeAll, describe, expect, it } from "vitest"; import { FOCUS_PANE_EVENT, renderSidebar } from "./catalog.js"; import type { CatalogListener, CatalogStore } from "./events.js"; import type { Session } from "./types.js"; beforeAll(() => { Object.defineProperty(window, "localStorage", { value: { getItem: () => null, setItem: () => {} }, }); window.fetch = () => Promise.reject(new Error("offline")); }); function session(id: string, cwd: string): Session { return { id, cwd, command: "claude", env: {}, metadata: {}, status: "Running", created_at: "2026-08-24T10:00:00Z", } as Session; } function storeWith(sessions: Session[]): CatalogStore { const state = { sessions: new Map(sessions.map((s) => [s.id, s])) }; return { snapshot: () => state, subscribe: (fn: CatalogListener) => { fn(state); return () => {}; }, } as unknown as CatalogStore; } const A = "aaaaaaaa-0000-0000-0000-00000000000a"; const B = "aaaaaaaa-0000-0000-0000-00000000000b"; /** Two sessions in sibling directories: two folder headers, two cards. */ function twoFolders(opts: Parameters[1] = {}): { el: HTMLElement; handle: ReturnType; } { const handle = renderSidebar(storeWith([session(A, "/one"), session(B, "/two")]), opts); // Mounted: `focusList` moves real DOM focus, which needs a live document. document.body.replaceChildren(handle.el); return { el: handle.el, handle }; } function press(el: HTMLElement, key: string, init: KeyboardEventInit = {}): void { el.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init })); } function highlighted(el: HTMLElement): string | null { return el.querySelector(".is-highlighted")?.dataset.navKey ?? null; } function tree(el: HTMLElement): HTMLElement { return el.querySelector(".tree")!; } describe("sidebar keyboard navigation", () => { it("walks the cards and never stops on an expanded folder", () => { const { el } = twoFolders(); // The folders are there — the walk just refuses to land on them. expect(el.querySelectorAll(".tree-folder-header")).toHaveLength(2); press(tree(el), "j"); expect(highlighted(el)).toBe(`session:${A}`); press(tree(el), "j"); expect(highlighted(el)).toBe(`session:${B}`); // Wraps. press(tree(el), "j"); expect(highlighted(el)).toBe(`session:${A}`); press(tree(el), "k"); expect(highlighted(el)).toBe(`session:${B}`); }); it("accepts the arrow keys as well as j/k", () => { const { el } = twoFolders(); press(tree(el), "ArrowDown"); expect(highlighted(el)).toBe(`session:${A}`); press(tree(el), "ArrowUp"); // Backwards from the first card wraps to the last. expect(highlighted(el)).toBe(`session:${B}`); }); it("stops on a collapsed folder, since its cards are gone", () => { const { el } = twoFolders(); const header = el.querySelectorAll(".tree-folder-header")[0]; header.click(); // collapse /one press(tree(el), "j"); expect(highlighted(el)).toBe("folder:one"); press(tree(el), "j"); expect(highlighted(el)).toBe(`session:${B}`); // Enter expands it again and lands on the card it was hiding. press(tree(el), "j"); expect(highlighted(el)).toBe("folder:one"); press(tree(el), "Enter"); expect(highlighted(el)).toBe(`session:${A}`); }); it("focusList starts from the session on screen", () => { const { el, handle } = twoFolders(); handle.setHighlights({ open: new Set([B]), focused: B }); handle.focusList(); expect(highlighted(el)).toBe(`session:${B}`); expect(handle.hasListFocus()).toBe(true); }); it("Tab asks the shell for the pane back", () => { const { el } = twoFolders(); let asked = 0; document.addEventListener(FOCUS_PANE_EVENT, () => (asked += 1)); press(tree(el), "Tab"); expect(asked).toBe(1); }); it("c collapses and expands the highlighted folder", () => { const { el } = twoFolders(); const header = el.querySelectorAll(".tree-folder-header")[0]; header.click(); // collapse /one, so the walk stops on its header press(tree(el), "j"); expect(highlighted(el)).toBe("folder:one"); press(tree(el), "c"); // Expanded: the card it was hiding is back in the walk. expect(el.querySelector(`[data-nav-key="session:${A}"]`)).not.toBeNull(); }); it("r renames the highlighted session in place", () => { const { el } = twoFolders(); press(tree(el), "j"); press(tree(el), "r"); const field = el.querySelector(".leaf-rename"); expect(field).not.toBeNull(); // Escape settles the rename, so the module-level "a row is being renamed" // flag doesn't leak into the next test. field!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); }); it("x kills the highlighted session, once confirmed (#160)", async () => { const { el } = twoFolders(); const killed: string[] = []; window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { killed.push(`${init?.method ?? "GET"} ${String(input)}`); return Promise.resolve(new Response(null, { status: 204 })); }) as typeof window.fetch; press(tree(el), "j"); press(tree(el), "x"); expect(killed.some((r) => r.startsWith("DELETE"))).toBe(false); document.querySelector(".kill-dialog-kill")?.click(); await Promise.resolve(); expect(killed.some((r) => r.startsWith("DELETE") && r.includes(A))).toBe(true); }); it("leaves the keyboard alone while a row is being renamed", () => { const { el } = twoFolders(); press(tree(el), "j"); const input = document.createElement("input"); tree(el).appendChild(input); input.dispatchEvent(new KeyboardEvent("keydown", { key: "j", bubbles: true })); expect(highlighted(el)).toBe(`session:${A}`); }); }); describe("sidebar preview requests (docs/PREVIEW.md)", () => { it("asks for a preview on every keyboard step, and for none once the list blurs", () => { const seen: (string | null)[] = []; const { el } = twoFolders({ onPreview: (id) => seen.push(id) }); press(tree(el), "j"); press(tree(el), "j"); expect(seen).toEqual([A, B]); tree(el).dispatchEvent(new FocusEvent("blur")); expect(seen.at(-1)).toBe(null); }); it("ctrl+hover previews a card, plain hover does not", () => { const seen: (string | null)[] = []; const { el } = twoFolders({ onPreview: (id) => seen.push(id) }); const card = el.querySelectorAll(".tree-leaf")[0]; // Plain hover — no preview. card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" })); expect(seen).toEqual([]); // Ctrl+hover — preview fires. card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true })); expect(seen).toEqual([A]); // Deduplicated while still on the same card. card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true })); expect(seen).toHaveLength(1); tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" })); expect(seen.at(-1)).toBe(null); }); it("ignores touch even with ctrl", () => { const seen: (string | null)[] = []; const { el } = twoFolders({ onPreview: (id) => seen.push(id) }); const card = el.querySelectorAll(".tree-leaf")[0]; card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch", ctrlKey: true })); expect(seen).toEqual([]); }); });