Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d06827b69 | ||
|
|
8a6cb81d3a | ||
|
|
5b0a0c2001 | ||
|
|
df9013ffdf | ||
|
|
39341b278c | ||
|
|
b646cc5adf | ||
|
|
cfc125144f | ||
|
|
cd66dbf7d8 | ||
|
|
6bb812f6bd | ||
|
|
d3098a9400 | ||
|
|
5de04af36a | ||
|
|
36cb03961c | ||
|
|
5e34ce7a61 |
@@ -97,3 +97,24 @@ jobs:
|
|||||||
|
|
||||||
- name: build
|
- name: build
|
||||||
run: pnpm -C web build
|
run: pnpm -C web build
|
||||||
|
|
||||||
|
# The flake pins pnpmDeps.hash against web/pnpm-lock.yaml (#98). A lockfile
|
||||||
|
# change invalidates the pin silently elsewhere, so the packaged build has to
|
||||||
|
# fail here too.
|
||||||
|
flake:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install nix
|
||||||
|
run: |
|
||||||
|
curl -fsSL https://nixos.org/nix/install | sh -s -- --daemon --yes
|
||||||
|
echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: build flake packages
|
||||||
|
run: |
|
||||||
|
nix build \
|
||||||
|
.#webFrontend \
|
||||||
|
.#maestro \
|
||||||
|
.#maestro-web \
|
||||||
|
--no-link
|
||||||
|
|||||||
@@ -1349,9 +1349,7 @@ mod tests {
|
|||||||
for c in "crimson-otter".chars() {
|
for c in "crimson-otter".chars() {
|
||||||
app.handle_draft_input(&InputKey::char(c));
|
app.handle_draft_input(&InputKey::char(c));
|
||||||
}
|
}
|
||||||
app.handle_draft_input(&InputKey::Tab); // → worktree toggle
|
app.handle_draft_input(&InputKey::Tab); // → advanced disclosure (collapsed, #93)
|
||||||
app.handle_draft_input(&InputKey::Tab); // → branch mode
|
|
||||||
app.handle_draft_input(&InputKey::Tab); // → new-branch name
|
|
||||||
app.handle_draft_input(&InputKey::Tab); // → submit
|
app.handle_draft_input(&InputKey::Tab); // → submit
|
||||||
|
|
||||||
match app.handle_draft_input(&InputKey::Enter).as_slice() {
|
match app.handle_draft_input(&InputKey::Enter).as_slice() {
|
||||||
@@ -1553,8 +1551,8 @@ mod tests {
|
|||||||
panic!("expected a draft pane");
|
panic!("expected a draft pane");
|
||||||
};
|
};
|
||||||
assert_eq!(draft.command(), "aider");
|
assert_eq!(draft.command(), "aider");
|
||||||
assert_eq!(draft.rows.len(), 1);
|
assert_eq!(draft.groups.len(), 1);
|
||||||
assert_eq!(draft.rows[0].project.name, "maestro");
|
assert_eq!(draft.groups[0].main.name, "maestro");
|
||||||
assert_eq!(draft.home_dir, "/home/u");
|
assert_eq!(draft.home_dir, "/home/u");
|
||||||
assert_eq!(draft.name.value(), "crimson-otter");
|
assert_eq!(draft.name.value(), "crimson-otter");
|
||||||
}
|
}
|
||||||
|
|||||||
+813
-149
File diff suppressed because it is too large
Load Diff
+88
-22
@@ -152,33 +152,38 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<fieldset class="worktree">
|
<fieldset class="worktree">
|
||||||
<legend>
|
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
|
||||||
|
<span class="chevron">▾</span>
|
||||||
|
<span class="folder-name">Advanced options</span>
|
||||||
|
<span class="wt-summary"></span>
|
||||||
|
</button>
|
||||||
|
<div class="wt-collapsible" hidden>
|
||||||
<label class="row-inline">
|
<label class="row-inline">
|
||||||
<input type="checkbox" name="worktree_enabled" />
|
<input type="checkbox" name="worktree_enabled" />
|
||||||
Create git worktree
|
Create git worktree
|
||||||
</label>
|
</label>
|
||||||
</legend>
|
<p class="wt-hint" hidden></p>
|
||||||
<p class="wt-hint" hidden></p>
|
<div class="wt-body" hidden>
|
||||||
<div class="wt-body" hidden>
|
<p class="wt-repo"></p>
|
||||||
<p class="wt-repo"></p>
|
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
|
||||||
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
|
<label class="row-inline">
|
||||||
<label class="row-inline">
|
<input type="radio" name="worktree_branch_mode" value="new" />
|
||||||
<input type="radio" name="worktree_branch_mode" value="new" />
|
New branch
|
||||||
New branch
|
</label>
|
||||||
|
<label class="row-inline">
|
||||||
|
<input type="radio" name="worktree_branch_mode" value="existing" />
|
||||||
|
Existing branch
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="wt-new-wrap">
|
||||||
|
Branch
|
||||||
|
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
||||||
</label>
|
</label>
|
||||||
<label class="row-inline">
|
<label class="wt-exist-wrap" hidden>
|
||||||
<input type="radio" name="worktree_branch_mode" value="existing" />
|
Branch
|
||||||
Existing branch
|
<select class="branch-select"></select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label class="wt-new-wrap">
|
|
||||||
Branch
|
|
||||||
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
|
||||||
</label>
|
|
||||||
<label class="wt-exist-wrap" hidden>
|
|
||||||
Branch
|
|
||||||
<select class="branch-select"></select>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<p class="form-error" role="alert" aria-live="polite"></p>
|
<p class="form-error" role="alert" aria-live="polite"></p>
|
||||||
@@ -200,6 +205,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
|
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
|
||||||
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
|
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
|
||||||
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
|
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
|
||||||
|
const wtToggle = form.querySelector(".wt-toggle") as HTMLButtonElement;
|
||||||
|
const wtCollapsible = form.querySelector(".wt-collapsible") as HTMLElement;
|
||||||
|
const wtSummary = form.querySelector(".wt-summary") as HTMLElement;
|
||||||
const wtHint = form.querySelector(".wt-hint") as HTMLElement;
|
const wtHint = form.querySelector(".wt-hint") as HTMLElement;
|
||||||
const wtBody = form.querySelector(".wt-body") as HTMLElement;
|
const wtBody = form.querySelector(".wt-body") as HTMLElement;
|
||||||
const wtRepo = form.querySelector(".wt-repo") as HTMLElement;
|
const wtRepo = form.querySelector(".wt-repo") as HTMLElement;
|
||||||
@@ -526,9 +534,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
markActiveChoice();
|
markActiveChoice();
|
||||||
if (choice === "new") {
|
if (choice === "new") {
|
||||||
wtEnabled.checked = true;
|
wtEnabled.checked = true;
|
||||||
|
updateWtSummary();
|
||||||
applyProjectRepo(group.main.base_repo ?? group.main.path);
|
applyProjectRepo(group.main.base_repo ?? group.main.path);
|
||||||
} else {
|
} else {
|
||||||
wtEnabled.checked = false;
|
wtEnabled.checked = false;
|
||||||
|
updateWtSummary();
|
||||||
inferredBaseRepo = null;
|
inferredBaseRepo = null;
|
||||||
wtBody.hidden = true;
|
wtBody.hidden = true;
|
||||||
wtHint.hidden = true;
|
wtHint.hidden = true;
|
||||||
@@ -539,11 +549,15 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
if (match) selectPreset(match.id);
|
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 {
|
function renderProjects(): void {
|
||||||
projectsEl.replaceChildren();
|
projectsEl.replaceChildren();
|
||||||
groups = groupProjects(sortProjects(discovered, new Date()));
|
groups = groupProjects(sortProjects(discovered, new Date()));
|
||||||
const query = filterInput.value;
|
const visible = visibleGroups(filterInput.value);
|
||||||
const visible = groups.filter((g) => filterProjects([g.main, ...g.worktrees], query).length > 0);
|
|
||||||
for (const group of visible) projectsEl.appendChild(projectBox(group));
|
for (const group of visible) projectsEl.appendChild(projectBox(group));
|
||||||
if (discovered.length > 0 && visible.length === 0) {
|
if (discovered.length > 0 && visible.length === 0) {
|
||||||
const empty = document.createElement("p");
|
const empty = document.createElement("p");
|
||||||
@@ -661,6 +675,18 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- worktree: disclosure (#93), reuses the sidebar's folder-header ---
|
||||||
|
let advancedCollapsed = true;
|
||||||
|
function setAdvancedCollapsed(collapsed: boolean): void {
|
||||||
|
advancedCollapsed = collapsed;
|
||||||
|
wtToggle.setAttribute("aria-expanded", String(!collapsed));
|
||||||
|
wtCollapsible.hidden = collapsed;
|
||||||
|
}
|
||||||
|
function updateWtSummary(): void {
|
||||||
|
wtSummary.textContent = wtEnabled.checked ? "creates a worktree" : "no worktree";
|
||||||
|
}
|
||||||
|
wtToggle.addEventListener("click", () => setAdvancedCollapsed(!advancedCollapsed));
|
||||||
|
|
||||||
// ---- worktree (branch-only; base_repo inferred via scan or /gitroot) --
|
// ---- worktree (branch-only; base_repo inferred via scan or /gitroot) --
|
||||||
// The project scan already reports repo membership, so a picked project
|
// The project scan already reports repo membership, so a picked project
|
||||||
// resolves its base repo locally (#61); only the free-text escape hatch
|
// resolves its base repo locally (#61); only the free-text escape hatch
|
||||||
@@ -741,6 +767,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
// Publish the untick too, or the next render seeds the box checked with
|
// Publish the untick too, or the next render seeds the box checked with
|
||||||
// no inferred repo and submitting fails.
|
// no inferred repo and submitting fails.
|
||||||
wtEnabled.checked = false;
|
wtEnabled.checked = false;
|
||||||
|
updateWtSummary();
|
||||||
emit();
|
emit();
|
||||||
wtBody.hidden = true;
|
wtBody.hidden = true;
|
||||||
wtHint.hidden = false;
|
wtHint.hidden = false;
|
||||||
@@ -814,6 +841,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
branchSelect.addEventListener("change", () => emit());
|
branchSelect.addEventListener("change", () => emit());
|
||||||
|
|
||||||
wtEnabled.addEventListener("change", () => {
|
wtEnabled.addEventListener("change", () => {
|
||||||
|
updateWtSummary();
|
||||||
emit();
|
emit();
|
||||||
if (wtEnabled.checked) {
|
if (wtEnabled.checked) {
|
||||||
resolveBaseRepo();
|
resolveBaseRepo();
|
||||||
@@ -827,6 +855,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
// The toggle defaults ON (#61): the operator almost always wants a
|
// The toggle defaults ON (#61): the operator almost always wants a
|
||||||
// worktree, and every non-repo target unticks it via the gitroot probe.
|
// worktree, and every non-repo target unticks it via the gitroot probe.
|
||||||
wtEnabled.checked = prefill.worktreeEnabled ?? true;
|
wtEnabled.checked = prefill.worktreeEnabled ?? true;
|
||||||
|
updateWtSummary();
|
||||||
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
|
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
|
||||||
setBranchMode(branchMode);
|
setBranchMode(branchMode);
|
||||||
|
|
||||||
@@ -837,6 +866,43 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
|
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
|
||||||
const autocomplete = attachDirAutocomplete(cwdRaw);
|
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.
|
||||||
|
// The worktree checkbox keeps Enter as a toggle (TUI parity: `WorktreeToggle`
|
||||||
|
// does the same) rather than folding it into the blanket-submit rule below.
|
||||||
|
form.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key !== "Enter" || e.defaultPrevented) return;
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
|
||||||
|
if (target === wtEnabled) {
|
||||||
|
e.preventDefault();
|
||||||
|
wtEnabled.checked = !wtEnabled.checked;
|
||||||
|
wtEnabled.dispatchEvent(new Event("change"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ----------------------------------------------------------
|
// ---- submit ----------------------------------------------------------
|
||||||
form.addEventListener("submit", async (e) => {
|
form.addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
// @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}"]`)!;
|
||||||
|
|
||||||
|
it("toggles the worktree checkbox on Enter instead of submitting (TUI parity)", async () => {
|
||||||
|
const form = await mount([MAESTRO]);
|
||||||
|
box(form, MAESTRO.path).click();
|
||||||
|
await drain();
|
||||||
|
const toggle = form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!;
|
||||||
|
expect(toggle.checked).toBe(true);
|
||||||
|
const spy = submitSpy();
|
||||||
|
form.addEventListener("submit", spy);
|
||||||
|
|
||||||
|
pressEnter(toggle);
|
||||||
|
expect(toggle.checked).toBe(false);
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
pressEnter(toggle);
|
||||||
|
expect(toggle.checked).toBe(true);
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -180,6 +180,59 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
|
|||||||
expect(modes.at(-1)).toBe("existing");
|
expect(modes.at(-1)).toBe("existing");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("collapses the advanced (worktree) section by default (#93)", async () => {
|
||||||
|
const form = await mount();
|
||||||
|
const toggle = form.querySelector<HTMLButtonElement>(".wt-toggle")!;
|
||||||
|
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
expect(form.querySelector<HTMLElement>(".wt-collapsible")!.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("states in the collapsed header that a worktree will be created by default (#93)", async () => {
|
||||||
|
const form = await mount();
|
||||||
|
expect(form.querySelector<HTMLElement>(".wt-summary")!.textContent).toMatch(/creates? a worktree/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("states in the collapsed header that no worktree will be created for a worktree row (#91, #93)", async () => {
|
||||||
|
urls = stubFetch({
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
path: "/home/user/wt-91",
|
||||||
|
name: "wt-91",
|
||||||
|
is_worktree: true,
|
||||||
|
base_repo: "/home/user/maestro",
|
||||||
|
last_activity: "2026-08-20T10:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
handle = createSpawnForm({ onSuccess() {} });
|
||||||
|
document.body.appendChild(handle.el);
|
||||||
|
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||||
|
|
||||||
|
handle.el
|
||||||
|
.querySelectorAll<HTMLButtonElement>(".project-box")
|
||||||
|
.forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click());
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
expect(handle.el.querySelector<HTMLElement>(".wt-summary")!.textContent).toMatch(/no worktree/i);
|
||||||
|
// Still collapsed — picking a project must not force it open.
|
||||||
|
expect(handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.getAttribute("aria-expanded")).toBe(
|
||||||
|
"false",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expanding the advanced section does not reorder the fields above it (#93)", async () => {
|
||||||
|
const form = await mount();
|
||||||
|
const before = [...form.querySelectorAll(".field")];
|
||||||
|
|
||||||
|
form.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
|
||||||
|
|
||||||
|
expect(form.querySelector<HTMLButtonElement>(".wt-toggle")!.getAttribute("aria-expanded")).toBe(
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
expect(form.querySelector<HTMLElement>(".wt-collapsible")!.hidden).toBe(false);
|
||||||
|
expect([...form.querySelectorAll(".field")]).toStrictEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not repopulate a destroyed form when the branch list lands late", async () => {
|
it("does not repopulate a destroyed form when the branch list lands late", async () => {
|
||||||
const gate: { release?: () => void } = {};
|
const gate: { release?: () => void } = {};
|
||||||
urls = stubFetch({ gateBranches: (r) => (gate.release = r) });
|
urls = stubFetch({ gateBranches: (r) => (gate.release = r) });
|
||||||
|
|||||||
+21
-6
@@ -1284,18 +1284,33 @@ textarea {
|
|||||||
.worktree {
|
.worktree {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--r-2);
|
border-radius: var(--r-2);
|
||||||
padding: var(--sp-3);
|
padding: 0 var(--sp-3);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--sp-3);
|
|
||||||
background: color-mix(in oklab, var(--surface-2) 50%, transparent);
|
background: color-mix(in oklab, var(--surface-2) 50%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.worktree legend {
|
.worktree .wt-toggle {
|
||||||
padding: 0 6px;
|
margin: 0 calc(var(--sp-3) * -1);
|
||||||
color: var(--ink);
|
padding-left: var(--sp-3);
|
||||||
font-size: var(--fs-13);
|
width: calc(100% + var(--sp-3) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-summary {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-collapsible {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding-bottom: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-collapsible[hidden] {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-error {
|
.form-error {
|
||||||
|
|||||||
Reference in New Issue
Block a user