diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index 42a0ca1..39712cd 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -549,11 +549,15 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { if (match) selectPreset(match.id); } + /** Groups with at least one member (itself or a worktree) matching `query`. */ + function visibleGroups(query: string): ProjectGroup[] { + return groups.filter((g) => filterProjects([g.main, ...g.worktrees], query).length > 0); + } + function renderProjects(): void { projectsEl.replaceChildren(); groups = groupProjects(sortProjects(discovered, new Date())); - const query = filterInput.value; - const visible = groups.filter((g) => filterProjects([g.main, ...g.worktrees], query).length > 0); + const visible = visibleGroups(filterInput.value); for (const group of visible) projectsEl.appendChild(projectBox(group)); if (discovered.length > 0 && visible.length === 0) { const empty = document.createElement("p"); @@ -862,6 +866,34 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { // ---- live dir autocomplete on the raw cwd input (NAP-329) ------------ const autocomplete = attachDirAutocomplete(cwdRaw); + // ---- Enter submits from anywhere (#97) -------------------------------- + // A project-box/wt-choice that isn't yet the active pick takes the Enter to + // choose it instead; the next Enter, now that it's active, submits. + // dirAutocomplete's own Enter (accept the highlight) already preventDefaults + // when it handles the key, which the `e.defaultPrevented` guard defers to. + form.addEventListener("keydown", (e) => { + if (e.key !== "Enter" || e.defaultPrevented) return; + const target = e.target as HTMLElement; + + if (target instanceof HTMLButtonElement) { + if (!target.classList.contains("project-box") && !target.classList.contains("wt-choice")) return; + e.preventDefault(); + if (!target.classList.contains("is-active")) { + target.click(); + return; + } + form.requestSubmit(); + return; + } + + e.preventDefault(); + if (target === filterInput) { + const top = visibleGroups(filterInput.value)[0]; + if (top && top.main.path !== selectedGroup?.main.path) selectProject(top); + } + form.requestSubmit(); + }); + // ---- submit ---------------------------------------------------------- form.addEventListener("submit", async (e) => { e.preventDefault(); diff --git a/web/src/spawnFormEnter.test.ts b/web/src/spawnFormEnter.test.ts new file mode 100644 index 0000000..a05c4b3 --- /dev/null +++ b/web/src/spawnFormEnter.test.ts @@ -0,0 +1,240 @@ +// @vitest-environment happy-dom +// Enter submits the draft form from any field, at any point (#97). The two +// exceptions: a picker button that isn't yet the active choice takes the +// Enter to *choose* it instead (the next Enter submits), and the project +// filter must resolve to the highlighted project rather than sending +// whatever cwd was already set. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createSpawnForm, type SpawnFormHandle } 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", +}; + +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 OTHER: Project = { + path: "/home/user/tea/rev", + name: "rev", + is_worktree: false, + last_activity: "2026-08-19T10:00:00Z", +}; + +function stubFetch(projects: Project[]): void { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const body = url.includes("/api/projects") + ? projects + : url.includes("/api/names") + ? { names: ["amber-fox"] } + : url.includes("/api/branches") + ? [] + : url.includes("/api/dirs") + ? { path: "/opt", entries: ["nomatch-zzz"], truncated: false } + : { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }; + return new Response(JSON.stringify(body), { status: 200 }); + }), + ); +} + +describe("spawnForm Enter-to-submit (#97)", () => { + let handle: SpawnFormHandle | null = null; + + beforeEach(() => { + vi.useFakeTimers({ now: Date.parse("2026-08-23T12:00:00Z") }); + }); + + 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 { + stubFetch(projects); + handle = createSpawnForm({ onSuccess() {} }); + document.body.appendChild(handle.el); + await drain(); + return handle.el; + } + + function submitSpy(): ReturnType { + return vi.fn(); + } + + function pressEnter(el: HTMLElement): { defaultPrevented: boolean } { + const evt = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }); + el.dispatchEvent(evt); + return { defaultPrevented: evt.defaultPrevented }; + } + + const box = (form: HTMLFormElement, path: string) => + form.querySelector(`.project-box[data-cwd="${path}"]`)!; + + describe("submits immediately from ordinary fields", () => { + it("name field", async () => { + const form = await mount([MAESTRO]); + box(form, MAESTRO.path).click(); + await drain(); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + pressEnter(form.querySelector("input[name=name]")!); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("worktree branch field", async () => { + const form = await mount([MAESTRO]); + box(form, MAESTRO.path).click(); + await drain(); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + pressEnter(form.querySelector("input[name=worktree_branch]")!); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("custom command field", async () => { + const form = await mount([MAESTRO]); + box(form, MAESTRO.path).click(); + await drain(); + form.querySelector('.cmd-tiles .tile[data-id="custom"]')!.click(); + const raw = form.querySelector(".cmd-raw")!; + raw.value = "bash -l"; + raw.dispatchEvent(new Event("input")); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + pressEnter(raw); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("raw cwd field once the autocomplete dropdown is closed", async () => { + const form = await mount([MAESTRO]); + form.querySelector(".project-other")!.click(); + const raw = form.querySelector("input[name=cwd_raw]")!; + raw.value = "/opt/xyz-nothing-here"; + raw.dispatchEvent(new Event("input")); + await vi.advanceTimersByTimeAsync(300); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + pressEnter(raw); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("does not submit while the raw cwd autocomplete has a highlighted suggestion", async () => { + const form = await mount([MAESTRO]); + form.querySelector(".project-other")!.click(); + const raw = form.querySelector("input[name=cwd_raw]")!; + raw.focus(); + raw.value = "/opt/"; + raw.dispatchEvent(new Event("input", { bubbles: true })); + await vi.advanceTimersByTimeAsync(150); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + const { defaultPrevented } = pressEnter(raw); + + expect(defaultPrevented).toBe(true); + expect(spy).not.toHaveBeenCalled(); + expect(raw.value).toBe("/opt/nomatch-zzz/"); + }); + }); + + describe("two-step picker: first Enter chooses, second submits", () => { + it("project box", async () => { + const form = await mount([MAESTRO]); + const b = box(form, MAESTRO.path); + b.focus(); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + const first = pressEnter(b); + expect(first.defaultPrevented).toBe(true); + expect(b.classList.contains("is-active")).toBe(true); + expect(spy).not.toHaveBeenCalled(); + + pressEnter(b); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("worktree choice", async () => { + const form = await mount([MAESTRO, WT_A]); + box(form, MAESTRO.path).click(); + await drain(); + const choice = form.querySelector(`.wt-choice[data-cwd="${WT_A.path}"]`)!; + expect(choice.classList.contains("is-active")).toBe(false); // "+ new" is the default + const spy = submitSpy(); + form.addEventListener("submit", spy); + + pressEnter(choice); + expect(choice.classList.contains("is-active")).toBe(true); + expect(spy).not.toHaveBeenCalled(); + + pressEnter(choice); + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + describe("project filter", () => { + it("submits with the highlighted (first matching) project, not the filter text", async () => { + const form = await mount([MAESTRO, OTHER]); + const filter = form.querySelector(".project-filter")!; + filter.value = "rev"; + filter.dispatchEvent(new Event("input")); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + pressEnter(filter); + + expect(spy).toHaveBeenCalledTimes(1); + expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true); + }); + + it("does not clobber an already-chosen worktree when the filter still matches it", async () => { + const form = await mount([MAESTRO, WT_A]); + box(form, MAESTRO.path).click(); + await drain(); + form.querySelector(`.wt-choice[data-cwd="${WT_A.path}"]`)!.click(); + await drain(); + + const filter = form.querySelector(".project-filter")!; + filter.value = "maestro"; + filter.dispatchEvent(new Event("input")); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + pressEnter(filter); + + expect(spy).toHaveBeenCalledTimes(1); + expect( + form.querySelector(`.wt-choice[data-cwd="${WT_A.path}"]`)!.classList.contains( + "is-active", + ), + ).toBe(true); + }); + }); +});