The working-directory picker was a flat list mixing repositories with their worktrees, each row repeating the project name as an abbreviated path. It is now two steps: a grid of project boxes carrying the full unabbreviated path, then — only once a project is picked — that repository's worktrees with "+ new" as the default. "+ new" creates a worktree; picking an existing one runs in it and leaves the create-worktree toggle off, since the daemon never adopts (docs/WORKTREES.md). That is #91's rule, now an explicit choice rather than an inference from which row was clicked. Also fixes `.raw-wrap[hidden]`: `display: flex` outranked the UA sheet's `[hidden]`, so the free-text cwd input showed even when hidden. The interaction rules are listed in spawnForm.ts for the TUI mirror (#96) to follow. Refs #95
182 lines
6.6 KiB
TypeScript
182 lines
6.6 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
|
import type { Project } from "./types.js";
|
|
|
|
const NOW = "2026-08-23T12:00:00Z";
|
|
|
|
function project(overrides: Partial<Project>): Project {
|
|
return {
|
|
path: `/home/user/${overrides.name ?? "proj"}`,
|
|
name: "proj",
|
|
is_worktree: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** Three fetches gate the form's initial load: /api/info, /api/projects, and
|
|
* the /api/names batch (#53). */
|
|
function stubFetch(info: unknown, projects: Project[]): void {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
const body = url.includes("/api/info")
|
|
? info
|
|
: url.includes("/api/projects")
|
|
? projects
|
|
: url.includes("/api/names")
|
|
? { names: ["allegro-cello", "presto-fugue", "adagio-lyre"] }
|
|
: undefined;
|
|
return new Response(JSON.stringify(body), { status: 200 });
|
|
}),
|
|
);
|
|
}
|
|
|
|
function boxPaths(form: HTMLFormElement): string[] {
|
|
return [...form.querySelectorAll<HTMLButtonElement>(".project-box")].map(
|
|
(b) => b.dataset.cwd ?? "",
|
|
);
|
|
}
|
|
|
|
describe("spawnForm project picker (#95: boxes, full paths, mains only)", () => {
|
|
let handle: SpawnFormHandle | null = null;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ now: Date.parse(NOW) });
|
|
});
|
|
|
|
afterEach(() => {
|
|
handle?.destroy();
|
|
handle = null;
|
|
vi.unstubAllGlobals();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
async function mount(projects: Project[], seed?: Parameters<typeof createSpawnForm>[0]["seed"]) {
|
|
stubFetch({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }, projects);
|
|
handle = createSpawnForm({
|
|
seed,
|
|
onSuccess() {},
|
|
});
|
|
document.body.appendChild(handle.el);
|
|
for (let i = 0; i < 20; i++) await Promise.resolve();
|
|
return handle;
|
|
}
|
|
|
|
it("renders one box per project, fresh before stale", async () => {
|
|
const fresh = project({ name: "fresh", path: "/home/user/fresh", last_activity: NOW });
|
|
const stale = project({
|
|
name: "stale",
|
|
path: "/home/user/stale",
|
|
last_activity: "2026-01-01T00:00:00Z",
|
|
});
|
|
const form = (await mount([stale, fresh])).el;
|
|
expect(boxPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
|
|
const boxes = [...form.querySelectorAll<HTMLButtonElement>(".project-box")];
|
|
expect(boxes[1].classList.contains("is-stale")).toBe(true);
|
|
expect(boxes[0].classList.contains("is-stale")).toBe(false);
|
|
});
|
|
|
|
it("shows the name as the primary line and the full path unabbreviated", async () => {
|
|
const p = project({ name: "nixos-config", path: "/home/user/tea/nixos-config" });
|
|
const form = (await mount([p])).el;
|
|
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
|
expect(box.querySelector(".project-name")!.textContent).toBe("nixos-config");
|
|
// Not `~/t/nixos-config`: the abbreviation hid what distinguishes two
|
|
// projects sharing a basename (#95).
|
|
expect(box.querySelector(".project-path")!.textContent).toBe("/home/user/tea/nixos-config");
|
|
});
|
|
|
|
it("lists only main checkouts as boxes — worktrees belong to step two", async () => {
|
|
const main = project({ name: "maestro", path: "/home/user/maestro" });
|
|
const wt = project({
|
|
name: "wt",
|
|
path: "/home/user/maestro/worktrees/wt",
|
|
is_worktree: true,
|
|
base_repo: main.path,
|
|
});
|
|
const form = (await mount([wt, main])).el;
|
|
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
|
|
expect(form.querySelector(".project-box.is-worktree")).toBeNull();
|
|
});
|
|
|
|
it("filters boxes as the operator types, keeping a repo its worktree matched", async () => {
|
|
const a = project({ name: "maestro", path: "/home/user/maestro" });
|
|
const b = project({ name: "tea", path: "/home/user/work/tea" });
|
|
const wt = project({
|
|
name: "bulgarians",
|
|
path: "/home/user/maestro/worktrees/bulgarians",
|
|
is_worktree: true,
|
|
base_repo: a.path,
|
|
});
|
|
const form = (await mount([a, b, wt])).el;
|
|
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
|
|
filter.value = "tea";
|
|
filter.dispatchEvent(new Event("input"));
|
|
expect(boxPaths(form)).toEqual(["/home/user/work/tea"]);
|
|
// A query that only matches a worktree still surfaces its repository box.
|
|
filter.value = "bulgarians";
|
|
filter.dispatchEvent(new Event("input"));
|
|
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
|
|
filter.value = "zzz";
|
|
filter.dispatchEvent(new Event("input"));
|
|
expect(boxPaths(form)).toEqual([]);
|
|
expect(form.querySelector(".picker-empty")).toBeTruthy();
|
|
});
|
|
|
|
it("picking a project selects its cwd and pre-selects its last command", async () => {
|
|
const p = project({
|
|
name: "maestro",
|
|
path: "/home/user/maestro",
|
|
last_command: "claude",
|
|
});
|
|
const onChange = vi.fn();
|
|
stubFetch(
|
|
{
|
|
hostname: "h",
|
|
version: "0",
|
|
home_dir: "/home/user",
|
|
presets: [
|
|
{ id: "claude", label: "Claude", command: "claude" },
|
|
{ id: "custom", label: "Custom…", command: "" },
|
|
],
|
|
},
|
|
[p],
|
|
);
|
|
handle = createSpawnForm({ onSuccess() {}, onChange });
|
|
const form = handle.el;
|
|
document.body.appendChild(form);
|
|
for (let i = 0; i < 20; i++) await Promise.resolve();
|
|
onChange.mockClear();
|
|
|
|
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
|
box.click();
|
|
|
|
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
|
|
const last = onChange.mock.lastCall![0];
|
|
expect(last.cwd).toBe("/home/user/maestro");
|
|
expect(last.command).toBe("claude");
|
|
expect(values).toBeTruthy();
|
|
// The raw input stays hidden — the picked box is the source of truth.
|
|
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true);
|
|
expect(box.classList.contains("is-active")).toBe(true);
|
|
});
|
|
|
|
it("keeps an 'Other directory…' escape hatch that reveals the raw input", async () => {
|
|
const p = project({ name: "maestro", path: "/home/user/maestro" });
|
|
const form = (await mount([p])).el;
|
|
const other = form.querySelector<HTMLButtonElement>(".project-other")!;
|
|
other.click();
|
|
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(false);
|
|
});
|
|
|
|
it("falls back to the raw input when no projects exist", async () => {
|
|
const form = (await mount([])).el;
|
|
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(false);
|
|
const raw = form.querySelector<HTMLInputElement>(".cwd-raw")!;
|
|
expect(raw.value).toBe("/home/user");
|
|
});
|
|
});
|