Ports catalog.ts's tree renderer to components under src/components/sidebar/, following docs/SIDEBAR.md: two-line cards behind a 5px activity rail, adaptive meta line (duration, detail, program, diff stat), collapsible folders, j/k walk, inline rename and a right-click menu. The nav walk reads a pure model (model.ts) rather than the DOM, so the rule that an expanded folder header is skipped and a collapsed one is not is testable without a render. Rail motion needs @keyframes, so it stays in sidebar.css rather than the Tailwind config. Closes #244
228 lines
7.8 KiB
TypeScript
228 lines
7.8 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import type { Session } from "../../types.js";
|
|
import { Sidebar } from "./Sidebar.js";
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
function session(id: string, over: Partial<Session> = {}): Session {
|
|
return {
|
|
id,
|
|
cwd: "/home/n/alpha",
|
|
command: "hx src/",
|
|
env: {},
|
|
metadata: { name: id },
|
|
status: "Running",
|
|
created_at: "2026-08-24T10:00:00Z",
|
|
...over,
|
|
} as Session;
|
|
}
|
|
|
|
/** The card element for a session, by the `data-session-id` the row carries. */
|
|
function card(id: string): HTMLElement {
|
|
const el = document.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
|
|
if (!el) throw new Error(`no card for ${id}`);
|
|
return el;
|
|
}
|
|
|
|
function tree(): HTMLElement {
|
|
return document.querySelector<HTMLElement>(".tree")!;
|
|
}
|
|
|
|
describe("SessionCard layout", () => {
|
|
it("renders two lines: the name, then a meta line naming the program", () => {
|
|
render(<Sidebar sessions={[session("alpha")]} />);
|
|
|
|
const row = card("alpha");
|
|
expect(row.textContent).toContain("alpha");
|
|
const meta = row.querySelector(".card-line2")!;
|
|
// The program only — never its arguments.
|
|
expect(meta.textContent).toContain("hx");
|
|
expect(meta.textContent).not.toContain("src/");
|
|
});
|
|
|
|
it("shows no duration under the 30s hold, and one above it", () => {
|
|
const now = Date.parse("2026-08-24T10:00:00Z");
|
|
vi.setSystemTime(now);
|
|
const fresh = session("fresh", { activity: "Active", activity_since: "2026-08-24T09:59:50Z" });
|
|
const held = session("held", { activity: "Active", activity_since: "2026-08-24T09:56:00Z" });
|
|
|
|
render(<Sidebar sessions={[fresh, held]} />);
|
|
|
|
expect(card("fresh").querySelector(".activity-duration")).toBeNull();
|
|
expect(card("held").querySelector(".activity-duration")!.textContent).toBe("4m");
|
|
});
|
|
|
|
it("paints a rail for every state, blinking on AwaitingInput and flashing on Error", () => {
|
|
render(
|
|
<Sidebar
|
|
sessions={[
|
|
session("idle"),
|
|
session("await", { activity: "AwaitingInput" }),
|
|
session("err", { activity: "Error" }),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
const rail = (id: string) => card(id).querySelector<HTMLElement>("[data-activity]")!;
|
|
expect(rail("idle").dataset.activity).toBe("idle");
|
|
expect(rail("idle").className).not.toContain("rail-");
|
|
expect(rail("await").className).toContain("rail-blink");
|
|
expect(rail("err").className).toContain("rail-flash");
|
|
// Status never rests on colour alone.
|
|
expect(rail("await").getAttribute("aria-label")).toBe("Awaiting input");
|
|
});
|
|
|
|
it("renders blocked work as diff notation with the spelled-out tooltip", () => {
|
|
render(
|
|
<Sidebar
|
|
sessions={[
|
|
session("alpha", {
|
|
blocked_work: { uncommitted_files: 2, insertions: 124, deletions: 18, untracked_files: 3 },
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
const blocked = card("alpha").querySelector<HTMLElement>(".leaf-blocked")!;
|
|
expect(blocked.textContent).toBe("+124-18?3");
|
|
expect(blocked.title).toContain("2 uncommitted files");
|
|
});
|
|
|
|
it("dims a provisional name and leaves a settled one alone", () => {
|
|
render(
|
|
<Sidebar
|
|
sessions={[
|
|
session("gen", { metadata: { name: "gen", name_source: "Generated" } }),
|
|
session("user", { metadata: { name: "user", name_source: "User" } }),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
expect(card("gen").querySelector(".text-muted")).not.toBeNull();
|
|
expect(card("user").textContent).toContain("user");
|
|
});
|
|
});
|
|
|
|
describe("keyboard walk", () => {
|
|
const two = [session("a", { cwd: "/one" }), session("b", { cwd: "/two" })];
|
|
|
|
it("j and k step the highlight through the cards", () => {
|
|
render(<Sidebar sessions={two} />);
|
|
|
|
fireEvent.keyDown(tree(), { key: "j" });
|
|
expect(card("a").dataset.highlighted).toBe("true");
|
|
fireEvent.keyDown(tree(), { key: "j" });
|
|
expect(card("b").dataset.highlighted).toBe("true");
|
|
fireEvent.keyDown(tree(), { key: "k" });
|
|
expect(card("a").dataset.highlighted).toBe("true");
|
|
});
|
|
|
|
it("stops on a collapsed folder header and Enter expands onto its first card", () => {
|
|
render(<Sidebar sessions={two} />);
|
|
|
|
const header = document.querySelector<HTMLElement>('[data-nav-key="folder:one"]')!;
|
|
fireEvent.click(header);
|
|
expect(header.getAttribute("aria-expanded")).toBe("false");
|
|
|
|
fireEvent.keyDown(tree(), { key: "j" });
|
|
expect(
|
|
document.querySelector<HTMLElement>('[data-nav-key="folder:one"]')!.dataset.highlighted,
|
|
).toBe("true");
|
|
|
|
fireEvent.keyDown(tree(), { key: "Enter" });
|
|
expect(card("a").dataset.highlighted).toBe("true");
|
|
});
|
|
|
|
it("Enter on a card opens it, and Tab hands the keyboard to the pane", () => {
|
|
const onSelect = vi.fn();
|
|
const onFocusPane = vi.fn();
|
|
render(<Sidebar sessions={two} onSelect={onSelect} onFocusPane={onFocusPane} />);
|
|
|
|
fireEvent.keyDown(tree(), { key: "j" });
|
|
fireEvent.keyDown(tree(), { key: "Enter" });
|
|
expect(onSelect).toHaveBeenCalledWith("a");
|
|
|
|
fireEvent.keyDown(tree(), { key: "Tab" });
|
|
expect(onFocusPane).toHaveBeenCalled();
|
|
});
|
|
|
|
it("previews the session the walk lands on", () => {
|
|
const onPreview = vi.fn();
|
|
render(<Sidebar sessions={two} onPreview={onPreview} />);
|
|
|
|
fireEvent.keyDown(tree(), { key: "j" });
|
|
expect(onPreview).toHaveBeenCalledWith("a");
|
|
});
|
|
|
|
it("x asks to kill the highlighted session and r renames it", () => {
|
|
const onKill = vi.fn();
|
|
render(<Sidebar sessions={two} onKill={onKill} />);
|
|
|
|
fireEvent.keyDown(tree(), { key: "j" });
|
|
fireEvent.keyDown(tree(), { key: "x" });
|
|
expect(onKill).toHaveBeenCalledWith(expect.objectContaining({ id: "a" }));
|
|
|
|
fireEvent.keyDown(tree(), { key: "r" });
|
|
expect(card("a").querySelector(".leaf-rename")).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("context menu", () => {
|
|
it("offers rename, spawn similar and kill", () => {
|
|
const onSpawnSimilar = vi.fn();
|
|
const onKill = vi.fn();
|
|
render(
|
|
<Sidebar sessions={[session("alpha")]} onSpawnSimilar={onSpawnSimilar} onKill={onKill} />,
|
|
);
|
|
|
|
fireEvent.contextMenu(card("alpha"));
|
|
const labels = [...document.querySelectorAll('[role="menuitem"]')].map((n) => n.textContent);
|
|
expect(labels).toEqual(["Rename…", "Spawn similar", "Kill session"]);
|
|
|
|
fireEvent.click(screen.getByText("Spawn similar"));
|
|
expect(onSpawnSimilar).toHaveBeenCalledWith(expect.objectContaining({ id: "alpha" }));
|
|
});
|
|
|
|
it("renames in place: Enter commits the field, Escape cancels", () => {
|
|
const onRename = vi.fn();
|
|
render(<Sidebar sessions={[session("alpha")]} onRename={onRename} />);
|
|
|
|
fireEvent.contextMenu(card("alpha"));
|
|
fireEvent.click(screen.getByText("Rename…"));
|
|
|
|
const input = card("alpha").querySelector<HTMLInputElement>(".leaf-rename")!;
|
|
expect(input.value).toBe("alpha");
|
|
fireEvent.change(input, { target: { value: " renamed " } });
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
expect(onRename).toHaveBeenCalledWith("alpha", "renamed");
|
|
expect(card("alpha").querySelector(".leaf-rename")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("search", () => {
|
|
it("filters the tree down to matching sessions", () => {
|
|
render(<Sidebar sessions={[session("alpha"), session("beta", { cwd: "/home/n/beta" })]} />);
|
|
|
|
fireEvent.change(screen.getByLabelText("Search sessions"), { target: { value: "beta" } });
|
|
|
|
expect(document.querySelector('[data-session-id="alpha"]')).toBeNull();
|
|
expect(document.querySelector('[data-session-id="beta"]')).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("empty state", () => {
|
|
it("offers a spawn when there are no sessions at all", () => {
|
|
const onSpawn = vi.fn();
|
|
render(<Sidebar sessions={[]} onSpawn={onSpawn} />);
|
|
|
|
fireEvent.click(screen.getByText("+ Spawn session"));
|
|
expect(onSpawn).toHaveBeenCalled();
|
|
});
|
|
});
|