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
234 lines
7.8 KiB
TypeScript
234 lines
7.8 KiB
TypeScript
// @vitest-environment happy-dom
|
|
// Second step of the working-directory picker (#95). See the interaction rules
|
|
// block in spawnForm.ts for what each choice means.
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
|
|
import type { Project } from "./types.js";
|
|
|
|
const MAESTRO: Project = {
|
|
path: "/home/user/tea/maestro",
|
|
name: "maestro",
|
|
is_worktree: false,
|
|
last_activity: "2026-08-20T10:00:00Z",
|
|
last_command: "claude",
|
|
};
|
|
|
|
const WT_A: Project = {
|
|
path: "/home/user/tea/maestro/worktrees/bulgarians",
|
|
name: "bulgarians",
|
|
is_worktree: true,
|
|
base_repo: MAESTRO.path,
|
|
last_activity: "2026-08-22T10:00:00Z",
|
|
};
|
|
|
|
const WT_B: Project = {
|
|
path: "/home/user/tea/maestro/worktrees/spawn-v2",
|
|
name: "spawn-v2",
|
|
is_worktree: true,
|
|
base_repo: MAESTRO.path,
|
|
last_activity: "2026-08-21T10:00:00Z",
|
|
};
|
|
|
|
const OTHER: Project = {
|
|
path: "/home/user/tea/rev",
|
|
name: "rev",
|
|
is_worktree: false,
|
|
last_activity: "2026-08-19T10:00:00Z",
|
|
};
|
|
|
|
function stubFetch(projects: Project[]): string[] {
|
|
const urls: string[] = [];
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
urls.push(url);
|
|
const body = url.includes("/api/projects")
|
|
? projects
|
|
: url.includes("/api/names")
|
|
? { names: ["amber-fox"] }
|
|
: url.includes("/api/branches")
|
|
? []
|
|
: { hostname: "h", version: "0", home_dir: "/home/user", presets: [] };
|
|
return new Response(JSON.stringify(body), { status: 200 });
|
|
}),
|
|
);
|
|
return urls;
|
|
}
|
|
|
|
describe("spawnForm worktree step (#95)", () => {
|
|
let handle: SpawnFormHandle | null = null;
|
|
let urls: string[] = [];
|
|
let changes: SpawnFormValues[] = [];
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ now: Date.parse("2026-08-23T12:00:00Z") });
|
|
changes = [];
|
|
});
|
|
|
|
afterEach(() => {
|
|
handle?.destroy();
|
|
handle = null;
|
|
vi.unstubAllGlobals();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
const drain = async () => {
|
|
for (let i = 0; i < 40; i++) await Promise.resolve();
|
|
};
|
|
|
|
async function mount(projects: Project[]): Promise<HTMLFormElement> {
|
|
urls = stubFetch(projects);
|
|
handle = createSpawnForm({ onSuccess() {}, onChange: (v) => changes.push(v) });
|
|
document.body.appendChild(handle.el);
|
|
await drain();
|
|
return handle.el;
|
|
}
|
|
|
|
const step = (form: HTMLFormElement) => form.querySelector<HTMLElement>(".wt-step")!;
|
|
const choices = (form: HTMLFormElement) => [
|
|
...form.querySelectorAll<HTMLButtonElement>(".wt-choice"),
|
|
];
|
|
const activeChoice = (form: HTMLFormElement) =>
|
|
form.querySelector<HTMLButtonElement>(".wt-choice.is-active");
|
|
const box = (form: HTMLFormElement, path: string) =>
|
|
form.querySelector<HTMLButtonElement>(`.project-box[data-cwd="${path}"]`)!;
|
|
const toggle = (form: HTMLFormElement) =>
|
|
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!;
|
|
|
|
it("hides the worktree step until a project is picked", async () => {
|
|
const form = await mount([MAESTRO, WT_A]);
|
|
expect(step(form).hidden).toBe(true);
|
|
expect(choices(form)).toEqual([]);
|
|
|
|
box(form, MAESTRO.path).click();
|
|
await drain();
|
|
|
|
expect(step(form).hidden).toBe(false);
|
|
});
|
|
|
|
it("lists '+ new' first and selected by default, then the project's worktrees", async () => {
|
|
const form = await mount([MAESTRO, WT_A, WT_B]);
|
|
box(form, MAESTRO.path).click();
|
|
await drain();
|
|
|
|
const labels = choices(form).map((c) => c.querySelector(".wt-choice-name")!.textContent);
|
|
expect(labels).toEqual(["+ new", "bulgarians", "spawn-v2"]);
|
|
expect(activeChoice(form)!.dataset.kind).toBe("new");
|
|
});
|
|
|
|
it("taking the '+ new' default runs in the repo with the create toggle on", async () => {
|
|
const form = await mount([MAESTRO, WT_A]);
|
|
box(form, MAESTRO.path).click();
|
|
await drain();
|
|
|
|
expect(changes.at(-1)!.cwd).toBe(MAESTRO.path);
|
|
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
|
|
expect(toggle(form).checked).toBe(true);
|
|
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain(MAESTRO.path);
|
|
// The scan already knows the repo — no probe.
|
|
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
|
|
});
|
|
|
|
it("picking an existing worktree runs in it with the create toggle off (#91)", async () => {
|
|
const form = await mount([MAESTRO, WT_A, WT_B]);
|
|
box(form, MAESTRO.path).click();
|
|
await drain();
|
|
|
|
const existing = choices(form).find((c) => c.dataset.cwd === WT_A.path)!;
|
|
existing.click();
|
|
await drain();
|
|
|
|
expect(changes.at(-1)!.cwd).toBe(WT_A.path);
|
|
expect(changes.at(-1)!.worktreeEnabled).toBe(false);
|
|
expect(toggle(form).checked).toBe(false);
|
|
expect(form.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
|
|
expect(existing.classList.contains("is-active")).toBe(true);
|
|
// Still no probe: the scan reported this worktree's repo membership.
|
|
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
|
|
// The project box stays marked — it is what the worktree list belongs to.
|
|
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
|
|
});
|
|
|
|
it("switching back to '+ new' re-enables the create toggle", async () => {
|
|
const form = await mount([MAESTRO, WT_A]);
|
|
box(form, MAESTRO.path).click();
|
|
await drain();
|
|
choices(form)
|
|
.find((c) => c.dataset.cwd === WT_A.path)!
|
|
.click();
|
|
await drain();
|
|
|
|
choices(form)
|
|
.find((c) => c.dataset.kind === "new")!
|
|
.click();
|
|
await drain();
|
|
|
|
expect(changes.at(-1)!.cwd).toBe(MAESTRO.path);
|
|
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
|
|
expect(toggle(form).checked).toBe(true);
|
|
});
|
|
|
|
it("repopulates and resets to '+ new' when another project is picked", async () => {
|
|
const form = await mount([MAESTRO, WT_A, OTHER]);
|
|
box(form, MAESTRO.path).click();
|
|
await drain();
|
|
choices(form)
|
|
.find((c) => c.dataset.cwd === WT_A.path)!
|
|
.click();
|
|
await drain();
|
|
|
|
box(form, OTHER.path).click();
|
|
await drain();
|
|
|
|
expect(choices(form).map((c) => c.dataset.kind)).toEqual(["new"]);
|
|
expect(activeChoice(form)!.dataset.kind).toBe("new");
|
|
expect(changes.at(-1)!.cwd).toBe(OTHER.path);
|
|
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
|
|
});
|
|
|
|
it("defaults to the worktree itself when the box is a linked worktree (#91)", async () => {
|
|
// No main checkout in the scan, so the orphan worktree is its own box.
|
|
const form = await mount([WT_A]);
|
|
box(form, WT_A.path).click();
|
|
await drain();
|
|
|
|
expect(activeChoice(form)!.dataset.cwd).toBe(WT_A.path);
|
|
expect(changes.at(-1)!.cwd).toBe(WT_A.path);
|
|
expect(changes.at(-1)!.worktreeEnabled).toBe(false);
|
|
expect(toggle(form).checked).toBe(false);
|
|
expect(form.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
|
|
});
|
|
|
|
it("hides the worktree step again when the operator takes the raw-input hatch", async () => {
|
|
const form = await mount([MAESTRO, WT_A]);
|
|
box(form, MAESTRO.path).click();
|
|
await drain();
|
|
expect(step(form).hidden).toBe(false);
|
|
|
|
form.querySelector<HTMLButtonElement>(".project-other")!.click();
|
|
await drain();
|
|
|
|
expect(step(form).hidden).toBe(true);
|
|
expect(choices(form)).toEqual([]);
|
|
expect(form.querySelector(".project-box.is-active")).toBeNull();
|
|
});
|
|
|
|
it("restores the picked worktree from a draft seed", async () => {
|
|
urls = stubFetch([MAESTRO, WT_A, WT_B]);
|
|
handle = createSpawnForm({
|
|
onSuccess() {},
|
|
seed: { cwd: WT_A.path, cwdRawVisible: false, worktreeEnabled: false },
|
|
});
|
|
document.body.appendChild(handle.el);
|
|
await drain();
|
|
const form = handle.el;
|
|
|
|
expect(step(form).hidden).toBe(false);
|
|
expect(activeChoice(form)!.dataset.cwd).toBe(WT_A.path);
|
|
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
|
|
expect(toggle(form).checked).toBe(false);
|
|
});
|
|
});
|