This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/sidebarNav.test.ts
T
Miguel PalhasandClaude Sonnet 5 d4c72577b9 feat(maestro): swap web session-list/pane focus keys to Tab/Esc
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>
2026-09-02 17:58:28 +01:00

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}`);
});
});