Tab now moves the keyboard from the session list to the pane (was Escape); Escape moves it back from the pane to the list, intercepted via xterm's attachCustomKeyEventHandler so it never reaches the shell/app. Matches the TUI's `tab`/`esc` pair; Ctrl+Q was ruled out since browsers reserve it and never deliver it to page JS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
129 lines
4.2 KiB
TypeScript
129 lines
4.2 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(): { el: HTMLElement; handle: ReturnType<typeof renderSidebar> } {
|
|
const handle = renderSidebar(storeWith([session(A, "/one"), session(B, "/two")]));
|
|
// 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("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}`);
|
|
});
|
|
});
|