209 lines
7.3 KiB
TypeScript
209 lines
7.3 KiB
TypeScript
// @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<typeof renderSidebar>[1] = {}): {
|
|
el: HTMLElement;
|
|
handle: ReturnType<typeof renderSidebar>;
|
|
} {
|
|
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<HTMLElement>(".is-highlighted")?.dataset.navKey ?? null;
|
|
}
|
|
|
|
function tree(el: HTMLElement): HTMLElement {
|
|
return el.querySelector<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLInputElement>(".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", 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");
|
|
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, string][] = [];
|
|
const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) });
|
|
|
|
press(tree(el), "j");
|
|
press(tree(el), "j");
|
|
expect(seen).toEqual([
|
|
[A, "key"],
|
|
[B, "key"],
|
|
]);
|
|
|
|
tree(el).dispatchEvent(new FocusEvent("blur"));
|
|
expect(seen.at(-1)).toEqual([null, "key"]);
|
|
});
|
|
|
|
it("asks for a preview of the hovered card, and clears it on the way out", () => {
|
|
const seen: [string | null, string][] = [];
|
|
const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) });
|
|
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
|
|
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
|
expect(seen).toEqual([[A, "hover"]]);
|
|
// Moving within the same card is not a new preview.
|
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
|
expect(seen).toHaveLength(1);
|
|
|
|
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
|
|
expect(seen.at(-1)).toEqual([null, "hover"]);
|
|
});
|
|
|
|
it("ignores touch, where a tap already opens the session", () => {
|
|
const seen: (string | null)[] = [];
|
|
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
|
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch" }));
|
|
expect(seen).toEqual([]);
|
|
});
|
|
});
|