feat(maestro): Enter submits the spawn form from any field (#97)
A project-box or worktree-choice picker button that isn't yet the active pick takes the Enter to choose it instead; the next Enter, once it's active, submits. The project filter resolves to the top filtered project rather than sending whatever cwd was last set. The raw-cwd autocomplete's own Enter (accept the highlight) keeps working via e.defaultPrevented.
This commit is contained in:
+34
-2
@@ -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();
|
||||
|
||||
@@ -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<HTMLFormElement> {
|
||||
stubFetch(projects);
|
||||
handle = createSpawnForm({ onSuccess() {} });
|
||||
document.body.appendChild(handle.el);
|
||||
await drain();
|
||||
return handle.el;
|
||||
}
|
||||
|
||||
function submitSpy(): ReturnType<typeof vi.fn> {
|
||||
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<HTMLButtonElement>(`.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<HTMLInputElement>("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<HTMLInputElement>("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<HTMLButtonElement>('.cmd-tiles .tile[data-id="custom"]')!.click();
|
||||
const raw = form.querySelector<HTMLInputElement>(".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<HTMLButtonElement>(".project-other")!.click();
|
||||
const raw = form.querySelector<HTMLInputElement>("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<HTMLButtonElement>(".project-other")!.click();
|
||||
const raw = form.querySelector<HTMLInputElement>("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<HTMLButtonElement>(`.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<HTMLInputElement>(".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<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.click();
|
||||
await drain();
|
||||
|
||||
const filter = form.querySelector<HTMLInputElement>(".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<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.classList.contains(
|
||||
"is-active",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user