Deletes the vanilla shell and mounts App.tsx on #root. main.ts and the 23 views it drove are gone; App.tsx composes the ported components — sidebar, multi-view, command bar, spawn modal, help and restart overlays, daemon link, drawer — around the catalog and layout stores. The pure parts of four deleted modules moved next to the components that still use them: the spawn-form model and spawnSimilar into components/spawn, the transcript's markdown/format helpers and the diff into components/structured, the help overlay's grouping into components/dialogs. Document-level event names now live in uiEvents.ts. Deduplicated two pairs the migration left side by side: the sidebar uses dialogs/KillDialog and common/ContextMenu. It also listens for the command bar's kill/rename events, which nothing had picked up since catalog.ts went away. styles.css stays as the token and base layer, with `#app` renamed to `#root` and the pane-body rules re-keyed off the classes the React panes actually carry. Closes #253
408 lines
13 KiB
TypeScript
408 lines
13 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
KILL_SESSION_EVENT,
|
|
RENAME_SESSION_EVENT,
|
|
SESSION_SELECT_EVENT,
|
|
SPAWN_SIMILAR_EVENT,
|
|
} from "../../uiEvents.js";
|
|
import { COMMAND_SPECS, type Command } from "../../commands.js";
|
|
import type { Session } from "../../types.js";
|
|
import { CommandBar, SPAWN_DIRECTORY_EVENT, type CommandBarProps } from "./CommandBar.js";
|
|
|
|
vi.mock("../../api.js", async (importOriginal) => ({
|
|
...(await importOriginal<Record<string, unknown>>()),
|
|
listDir: vi.fn(async () => ({ path: "", entries: [] })),
|
|
projects: vi.fn(async () => []),
|
|
recentDirs: vi.fn(async () => ({ dirs: [] })),
|
|
}));
|
|
|
|
function session(id: string, over: Partial<Session> = {}): Session {
|
|
return {
|
|
id,
|
|
cwd: "/home/me/dev/api",
|
|
command: "bash",
|
|
env: {},
|
|
metadata: { name: id },
|
|
status: "Running",
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function command(over: Partial<Command> = {}): Command {
|
|
return {
|
|
id: "cmd",
|
|
label: "A command",
|
|
group: "Test",
|
|
keys: ["a"],
|
|
scope: "global",
|
|
run: () => {},
|
|
...over,
|
|
};
|
|
}
|
|
|
|
const sessions = [
|
|
session("api", { current_cwd: "/home/me/dev/api", last_focused_at: "2026-01-01T11:00:00Z" }),
|
|
session("worker", {
|
|
current_cwd: "/home/me/dev/worker",
|
|
last_focused_at: "2026-01-01T10:00:00Z",
|
|
}),
|
|
session("notes", { current_cwd: "/home/me/notes" }),
|
|
];
|
|
|
|
function open(over: Partial<CommandBarProps> = {}) {
|
|
return render(
|
|
<CommandBar
|
|
sessions={sessions}
|
|
homeDir="/home/me"
|
|
commands={[]}
|
|
onOpenSession={() => {}}
|
|
onClose={() => {}}
|
|
{...over}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
function input(): HTMLInputElement {
|
|
return screen.getByRole("combobox") as HTMLInputElement;
|
|
}
|
|
|
|
function type(value: string): void {
|
|
fireEvent.change(input(), { target: { value } });
|
|
}
|
|
|
|
function press(key: string, init: Record<string, unknown> = {}): void {
|
|
fireEvent.keyDown(input(), { key, ...init });
|
|
}
|
|
|
|
function rowNames(): string[] {
|
|
return [...document.querySelectorAll(".cmdbar-row-name")].map((r) => r.textContent ?? "");
|
|
}
|
|
|
|
function activeName(): string | undefined {
|
|
return document.querySelector(".cmdbar-row.is-active .cmdbar-row-name")?.textContent ?? undefined;
|
|
}
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("the overlay", () => {
|
|
it("is a registry command, so the help overlay lists it", () => {
|
|
const spec = COMMAND_SPECS.find((s) => s.id === "bar.open");
|
|
expect(spec?.keys).toEqual(["ctrl-k"]);
|
|
expect(spec?.scope).toBe("global");
|
|
});
|
|
|
|
it("lists sessions by recency under a kind header on an empty query", () => {
|
|
open();
|
|
expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([
|
|
"Sessions",
|
|
]);
|
|
expect(rowNames()).toEqual(["api", "worker", "notes"]);
|
|
expect(activeName()).toBe("api");
|
|
});
|
|
|
|
it("ranks a typed query through the matcher and highlights the matched range", () => {
|
|
open();
|
|
type("wor");
|
|
expect(rowNames()).toEqual(["worker"]);
|
|
expect([...document.querySelectorAll(".cmdbar-row-name mark")].map((m) => m.textContent)).toEqual(
|
|
["wor"],
|
|
);
|
|
});
|
|
|
|
it("highlights inside the directory when that is the field that matched", () => {
|
|
open();
|
|
type("dev/wor");
|
|
expect(rowNames()).toEqual(["worker"]);
|
|
expect(
|
|
[...document.querySelectorAll(".cmdbar-row-meta mark")].map((m) => m.textContent).join(""),
|
|
).toBe("dev/wor");
|
|
});
|
|
|
|
it("renders a no-results state rather than an empty list", () => {
|
|
open();
|
|
type("zzz");
|
|
expect(rowNames()).toEqual([]);
|
|
expect(screen.getByText("No matches")).toBeDefined();
|
|
});
|
|
|
|
it("moves the highlight with the arrows and ctrl-p/ctrl-n, without wrapping", () => {
|
|
open();
|
|
press("ArrowUp");
|
|
expect(activeName()).toBe("api");
|
|
press("ArrowDown");
|
|
expect(activeName()).toBe("worker");
|
|
press("n", { ctrlKey: true });
|
|
expect(activeName()).toBe("notes");
|
|
press("ArrowDown");
|
|
expect(activeName()).toBe("notes");
|
|
press("p", { ctrlKey: true });
|
|
expect(activeName()).toBe("worker");
|
|
});
|
|
|
|
it("puts the highlight back on the best row whenever the query changes", () => {
|
|
open();
|
|
press("ArrowDown");
|
|
expect(activeName()).toBe("worker");
|
|
type("o");
|
|
expect(activeName()).toBe(rowNames()[0]);
|
|
});
|
|
|
|
it("opens the highlighted session through the caller's open path on Enter", () => {
|
|
const onOpenSession = vi.fn();
|
|
const onClose = vi.fn();
|
|
open({ onOpenSession, onClose });
|
|
press("ArrowDown");
|
|
press("Enter");
|
|
expect(onOpenSession).toHaveBeenCalledWith("worker");
|
|
expect(onClose).toHaveBeenCalled();
|
|
});
|
|
|
|
it("raises the shared select event when the caller supplies no handler", () => {
|
|
const listener = vi.fn();
|
|
document.addEventListener(SESSION_SELECT_EVENT, listener);
|
|
open({ onOpenSession: undefined });
|
|
press("Enter");
|
|
document.removeEventListener(SESSION_SELECT_EVENT, listener);
|
|
expect((listener.mock.calls[0][0] as CustomEvent).detail).toBe("api");
|
|
});
|
|
|
|
it("Enter on an empty result list does nothing", () => {
|
|
const onOpenSession = vi.fn();
|
|
const onClose = vi.fn();
|
|
open({ onOpenSession, onClose });
|
|
type("zzz");
|
|
press("Enter");
|
|
expect(onOpenSession).not.toHaveBeenCalled();
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("closes on Escape and on a press outside the panel", () => {
|
|
const onClose = vi.fn();
|
|
open({ onClose });
|
|
press("Escape");
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
fireEvent.mouseDown(document.querySelector(".cmdbar-overlay") as HTMLElement);
|
|
expect(onClose).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("stays open for a press inside the panel", () => {
|
|
const onClose = vi.fn();
|
|
open({ onClose });
|
|
fireEvent.mouseDown(document.querySelector(".cmdbar-panel") as HTMLElement);
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns the keyboard to whatever held it when it opened", () => {
|
|
const before = document.createElement("input");
|
|
document.body.appendChild(before);
|
|
before.focus();
|
|
const view = open();
|
|
expect(document.activeElement).toBe(input());
|
|
view.unmount();
|
|
expect(document.activeElement).toBe(before);
|
|
before.remove();
|
|
});
|
|
|
|
it("clicking a row opens that session", () => {
|
|
const onOpenSession = vi.fn();
|
|
open({ onOpenSession });
|
|
fireEvent.click(document.querySelectorAll(".cmdbar-row")[1]);
|
|
expect(onOpenSession).toHaveBeenCalledWith("worker");
|
|
});
|
|
});
|
|
|
|
describe("the sigils", () => {
|
|
const commands = [
|
|
command({ id: "help.toggle", label: "Show help", keys: ["?"] }),
|
|
command({ id: "spawn.open", label: "Open the spawn form", keys: ["n"], run: vi.fn() }),
|
|
];
|
|
|
|
it("lists every command in registry order under `>`, with its chord and no header", () => {
|
|
open({ commands });
|
|
type(">");
|
|
expect(rowNames()).toEqual(["Show help", "Open the spawn form"]);
|
|
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
|
expect([...document.querySelectorAll(".cmdbar-row-keys kbd")].map((k) => k.textContent)).toEqual(
|
|
["?", "n"],
|
|
);
|
|
});
|
|
|
|
it("matches a command on its label and never on its chord", () => {
|
|
open({ commands });
|
|
type(">help");
|
|
expect(rowNames()).toEqual(["Show help"]);
|
|
type(">?");
|
|
expect(rowNames()).toEqual([]);
|
|
});
|
|
|
|
it("runs the highlighted command on Enter", () => {
|
|
const run = vi.fn();
|
|
open({ commands: [command({ label: "Run me", run })] });
|
|
type(">run");
|
|
press("Enter");
|
|
expect(run).toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps the list mixed with no sigil, and returns to it when the sigil goes", () => {
|
|
open({ commands });
|
|
type(">show");
|
|
expect(rowNames()).toEqual(["Show help"]);
|
|
type("");
|
|
expect(rowNames()).toEqual(["api", "worker", "notes"]);
|
|
});
|
|
|
|
it("lists recent dirs then projects under `~`", async () => {
|
|
open({ loadDirectories: async () => ["/home/me/dev/api", "/srv/x"] });
|
|
type("~");
|
|
await waitFor(() => expect(rowNames()).toEqual(["~/dev/api", "/srv/x"]));
|
|
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
|
});
|
|
|
|
it("fills the list in from /api/dirs as a path is typed", async () => {
|
|
const listDirEntries = vi.fn(async () => ["maestro", "api"]);
|
|
open({ loadDirectories: async () => ["/home/me/notes"], listDirEntries });
|
|
// The second `~` is a literal query character and reaches the matcher.
|
|
type("~~/dev/mae");
|
|
await waitFor(() => expect(rowNames()).toEqual(["~/dev/maestro"]));
|
|
expect(listDirEntries).toHaveBeenCalledWith("~/dev/");
|
|
});
|
|
|
|
it("spawns in the highlighted directory on Enter", async () => {
|
|
const onSpawnDirectory = vi.fn();
|
|
open({ loadDirectories: async () => ["/srv/x"], onSpawnDirectory });
|
|
type("~");
|
|
await waitFor(() => expect(rowNames()).toEqual(["/srv/x"]));
|
|
press("Enter");
|
|
expect(onSpawnDirectory).toHaveBeenCalledWith("/srv/x");
|
|
});
|
|
|
|
it("raises the spawn event when the caller supplies no handler", async () => {
|
|
const listener = vi.fn();
|
|
document.addEventListener(SPAWN_DIRECTORY_EVENT, listener);
|
|
open({ loadDirectories: async () => ["/srv/x"] });
|
|
type("~");
|
|
await waitFor(() => expect(rowNames()).toEqual(["/srv/x"]));
|
|
press("Enter");
|
|
document.removeEventListener(SPAWN_DIRECTORY_EVENT, listener);
|
|
expect((listener.mock.calls[0][0] as CustomEvent).detail).toBe("/srv/x");
|
|
});
|
|
});
|
|
|
|
describe("the session action page", () => {
|
|
function drill(over: Partial<CommandBarProps> = {}) {
|
|
const view = open(over);
|
|
press("Tab");
|
|
return view;
|
|
}
|
|
|
|
it("lists kill, rename and spawn similar for the highlighted session", () => {
|
|
drill();
|
|
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
|
|
});
|
|
|
|
it("names the session being acted on", () => {
|
|
drill();
|
|
expect(screen.getByText('Actions for "api"')).toBeDefined();
|
|
});
|
|
|
|
it("drops the kind headers, since the page is one kind", () => {
|
|
drill();
|
|
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
|
});
|
|
|
|
it("stays fuzzy-searchable over the three actions", () => {
|
|
drill();
|
|
type("ren");
|
|
expect(rowNames()).toEqual(["Rename session"]);
|
|
});
|
|
|
|
it("does nothing on a command row", () => {
|
|
open({ commands: [command({ label: "Show help" })] });
|
|
type(">show");
|
|
press("Tab");
|
|
expect(rowNames()).toEqual(["Show help"]);
|
|
});
|
|
|
|
it("does nothing on a directory row", async () => {
|
|
open({ loadDirectories: async () => ["/srv/x"] });
|
|
type("~");
|
|
await waitFor(() => expect(rowNames()).toEqual(["/srv/x"]));
|
|
press("Tab");
|
|
expect(rowNames()).toEqual(["/srv/x"]);
|
|
});
|
|
|
|
it("returns to the result list on Escape, with the previous query intact", () => {
|
|
const onClose = vi.fn();
|
|
open({ onClose });
|
|
type("wor");
|
|
press("Tab");
|
|
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
|
|
press("Escape");
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
expect(input().value).toBe("wor");
|
|
expect(rowNames()).toEqual(["worker"]);
|
|
});
|
|
|
|
it("returns to the result list on backspace at an empty query", () => {
|
|
drill();
|
|
fireEvent.keyDown(input(), { key: "Backspace" });
|
|
expect(rowNames()).toEqual(["api", "worker", "notes"]);
|
|
});
|
|
|
|
it("keeps backspace ordinary while the page's query has text", () => {
|
|
drill();
|
|
type("ren");
|
|
fireEvent.keyDown(input(), { key: "Backspace" });
|
|
expect(rowNames()).toEqual(["Rename session"]);
|
|
});
|
|
|
|
it("closes the bar when the page's Escape has already been used", () => {
|
|
const onClose = vi.fn();
|
|
drill({ onClose });
|
|
press("Escape");
|
|
press("Escape");
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("raises kill, rename and spawn similar with the session, closing the bar", () => {
|
|
for (const [query, key] of [
|
|
["kill", "onKillSession"],
|
|
["rename", "onRenameSession"],
|
|
["spawn", "onSpawnSimilar"],
|
|
] as const) {
|
|
const handler = vi.fn();
|
|
const onClose = vi.fn();
|
|
drill({ [key]: handler, onClose });
|
|
type(query);
|
|
press("Enter");
|
|
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ id: "api" }));
|
|
expect(onClose).toHaveBeenCalled();
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it("hands each action to the flow it already has, by default", () => {
|
|
for (const [query, event] of [
|
|
["kill", KILL_SESSION_EVENT],
|
|
["rename", RENAME_SESSION_EVENT],
|
|
["spawn", SPAWN_SIMILAR_EVENT],
|
|
] as const) {
|
|
const listener = vi.fn();
|
|
document.addEventListener(event, listener);
|
|
drill();
|
|
type(query);
|
|
press("Enter");
|
|
document.removeEventListener(event, listener);
|
|
expect((listener.mock.calls[0][0] as CustomEvent).detail.id).toBe("api");
|
|
cleanup();
|
|
}
|
|
});
|
|
});
|