Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6ef236f89 | ||
|
|
5747c71800 |
+38
-159
@@ -199,12 +199,6 @@ pub struct DraftPane {
|
||||
/// Highlighted entry within the visible step-one list; the implicit
|
||||
/// `Other directory…` entry sits at index `visible_groups().len()`.
|
||||
pub highlighted: usize,
|
||||
/// Set by an explicit Up/Down move, cleared by typing (#97): typing
|
||||
/// mirrors the web form's plain filter input, where Enter always
|
||||
/// resolves and submits; an explicit arrow move mirrors Tab landing on
|
||||
/// a not-yet-active picker button there, where a first Enter only
|
||||
/// chooses. See `dir_picker_choice_active` at the Enter match arm.
|
||||
dir_picker_navigated: bool,
|
||||
pub cwd_raw_visible: bool,
|
||||
pub cwd_raw: crate::text_input::TextInput,
|
||||
pub picked_cwd: Option<PathBuf>,
|
||||
@@ -276,7 +270,6 @@ impl Default for DraftPane {
|
||||
groups: Vec::new(),
|
||||
filter: crate::text_input::TextInput::default(),
|
||||
highlighted: 0,
|
||||
dir_picker_navigated: false,
|
||||
cwd_raw_visible: false,
|
||||
cwd_raw: crate::text_input::TextInput::default(),
|
||||
picked_cwd: None,
|
||||
@@ -776,7 +769,6 @@ impl DraftPane {
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
self.dir_picker_navigated = true;
|
||||
self.highlighted = ((self.selection() as i32) + delta).rem_euclid(count) as usize;
|
||||
}
|
||||
|
||||
@@ -812,17 +804,6 @@ impl DraftPane {
|
||||
rows
|
||||
}
|
||||
|
||||
/// Whether the highlighted step-one row is already the picked group
|
||||
/// (#97): once true, a further Enter submits instead of re-picking it.
|
||||
/// Always false on the trailing "Other directory…" row — picking it is
|
||||
/// never a no-op, so it always takes the Enter to reveal the raw field.
|
||||
fn dir_picker_choice_active(&self) -> bool {
|
||||
let visible = self.visible_groups();
|
||||
let sel = self.selection();
|
||||
sel < visible.len()
|
||||
&& self.selected_group_key.as_ref() == Some(&group_key(&visible[sel].main))
|
||||
}
|
||||
|
||||
/// Act on the highlighted step-one box: reveals step two, defaulting per
|
||||
/// rule 4. The trailing entry reveals the free-text path input and
|
||||
/// clears both steps (rule 3).
|
||||
@@ -952,33 +933,27 @@ impl DraftPane {
|
||||
self.prev_field();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
// Enter submits from any field (#97), mirroring web/src/spawnForm.ts.
|
||||
// Two fields keep their own Enter instead: `DirPicker`, whose
|
||||
// highlighted row isn't the picked one until a first Enter chooses
|
||||
// it (the next Enter, now that it's active, falls through to
|
||||
// submit); and the disclosure/toggle pair (`Advanced`,
|
||||
// `WorktreeToggle`), which activate on Enter like a button rather
|
||||
// than submitting, same as the web form's own `<button>` controls.
|
||||
InputKey::Enter if self.focused_field == DraftField::DirPicker => {
|
||||
let is_other_row = self.selection() >= self.visible_groups().len();
|
||||
let choose_only =
|
||||
is_other_row || (self.dir_picker_navigated && !self.dir_picker_choice_active());
|
||||
self.select_entry();
|
||||
if choose_only {
|
||||
InputKey::Enter => match self.focused_field {
|
||||
DraftField::Submit => DraftInputResult::Submit,
|
||||
DraftField::DirPicker => {
|
||||
self.select_entry();
|
||||
DraftInputResult::Consumed
|
||||
} else {
|
||||
DraftInputResult::Submit
|
||||
}
|
||||
}
|
||||
InputKey::Enter if self.focused_field == DraftField::Advanced => {
|
||||
self.toggle_advanced();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::Enter if self.focused_field == DraftField::WorktreeToggle => {
|
||||
self.toggle_worktree();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::Enter => DraftInputResult::Submit,
|
||||
DraftField::Advanced => {
|
||||
self.toggle_advanced();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
DraftField::WorktreeToggle => {
|
||||
self.toggle_worktree();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
// The existing-branch selection is live; Enter just confirms.
|
||||
DraftField::BranchExisting => DraftInputResult::Consumed,
|
||||
_ => {
|
||||
self.next_field();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
},
|
||||
InputKey::Char {
|
||||
c: ' ',
|
||||
ctrl: false,
|
||||
@@ -1050,7 +1025,6 @@ impl DraftPane {
|
||||
DraftField::DirPicker => {
|
||||
self.filter.handle_key(other);
|
||||
self.highlighted = 0;
|
||||
self.dir_picker_navigated = false;
|
||||
}
|
||||
DraftField::CwdRaw => {
|
||||
self.cwd_raw.handle_key(other);
|
||||
@@ -1418,7 +1392,7 @@ impl DraftPane {
|
||||
match self.branch_mode {
|
||||
WorktreeBranchMode::New => {
|
||||
let row = next_row();
|
||||
draw_gutter(f, row, "br:");
|
||||
draw_gutter(f, row, "new:");
|
||||
let col =
|
||||
draw_text_field(f, &self.branch, row, field_style(DraftField::BranchNew));
|
||||
if self.focused_field == DraftField::BranchNew {
|
||||
@@ -1442,6 +1416,7 @@ impl DraftPane {
|
||||
.unwrap_or(0)
|
||||
.min(20);
|
||||
let list_row = next_row();
|
||||
draw_gutter(f, list_row, "pick:");
|
||||
for i in start..(start + capacity).min(len.max(1)) {
|
||||
let y = list_row.y + (i - start) as u16;
|
||||
if y >= list_row.y + list_row.height {
|
||||
@@ -1773,11 +1748,7 @@ mod tests {
|
||||
1,
|
||||
"type-to-filter narrows the list"
|
||||
);
|
||||
assert_eq!(
|
||||
draft.on_input(&InputKey::Enter),
|
||||
DraftInputResult::Submit,
|
||||
"#97: the project filter submits with the highlighted project in one Enter"
|
||||
);
|
||||
draft.on_input(&InputKey::Enter);
|
||||
assert_eq!(
|
||||
draft.picked_cwd.as_deref(),
|
||||
Some(Path::new("/repos/maestro"))
|
||||
@@ -1789,113 +1760,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_submits_immediately_from_ordinary_fields_97() {
|
||||
let mut draft = DraftPane {
|
||||
picked_cwd: Some(PathBuf::from("/repos/maestro")),
|
||||
base_repo: Some(PathBuf::from("/repos/maestro")),
|
||||
probed_cwd: Some(PathBuf::from("/repos/maestro")),
|
||||
..DraftPane::default()
|
||||
};
|
||||
|
||||
for field in [
|
||||
DraftField::Preset,
|
||||
DraftField::Name,
|
||||
DraftField::BranchNew,
|
||||
DraftField::BranchMode,
|
||||
] {
|
||||
draft.focused_field = field;
|
||||
assert_eq!(
|
||||
draft.on_input(&InputKey::Enter),
|
||||
DraftInputResult::Submit,
|
||||
"{field:?} should submit on Enter"
|
||||
);
|
||||
}
|
||||
|
||||
draft.selected_preset = draft.presets.len() - 1; // custom
|
||||
draft.focused_field = DraftField::CustomCommand;
|
||||
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_submits_immediately_from_eager_apply_pickers_97() {
|
||||
// WorktreeChoice and BranchExisting apply their highlight the moment
|
||||
// Up/Down moves it (#96 rule 3/#54) — there is no separate "chosen
|
||||
// vs highlighted" state left to confirm, so Enter always submits.
|
||||
let mut draft = DraftPane::default();
|
||||
draft.set_projects(
|
||||
vec![repo_project(
|
||||
"maestro",
|
||||
"/repos/maestro",
|
||||
"/repos/maestro",
|
||||
1,
|
||||
None,
|
||||
)],
|
||||
Utc::now(),
|
||||
);
|
||||
draft.focused_field = DraftField::DirPicker;
|
||||
draft.on_input(&InputKey::Enter); // picks "maestro", reveals step two
|
||||
|
||||
draft.focused_field = DraftField::WorktreeChoice;
|
||||
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
|
||||
|
||||
draft.focused_field = DraftField::BranchExisting;
|
||||
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_picker_enter_chooses_then_submits_once_the_highlight_is_the_active_pick_97() {
|
||||
let mut draft = DraftPane::default();
|
||||
draft.set_projects(
|
||||
vec![
|
||||
project("maestro", "/repos/maestro", 1, Some("claude")),
|
||||
project("other", "/repos/other", 2, Some("zsh")),
|
||||
],
|
||||
Utc::now(),
|
||||
);
|
||||
draft.focused_field = DraftField::DirPicker;
|
||||
draft.on_input(&InputKey::Enter); // picks "maestro" (nothing chosen yet, submits)
|
||||
assert_eq!(
|
||||
draft.picked_cwd.as_deref(),
|
||||
Some(Path::new("/repos/maestro"))
|
||||
);
|
||||
|
||||
// Arrow onto the other row: an explicit navigation away from the
|
||||
// active pick is "mid-selection" — Enter here only chooses it.
|
||||
draft.move_selection(1);
|
||||
assert_eq!(
|
||||
draft.on_input(&InputKey::Enter),
|
||||
DraftInputResult::Consumed,
|
||||
"navigating to a different row must not submit the old pick"
|
||||
);
|
||||
assert_eq!(
|
||||
draft.picked_cwd.as_deref(),
|
||||
Some(Path::new("/repos/other")),
|
||||
"the new row is chosen"
|
||||
);
|
||||
|
||||
// The highlight is now the active pick: the next Enter submits.
|
||||
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_picker_enter_on_other_directory_never_submits_directly_97() {
|
||||
let mut draft = DraftPane::default();
|
||||
draft.set_projects(
|
||||
vec![project("maestro", "/repos/maestro", 1, None)],
|
||||
Utc::now(),
|
||||
);
|
||||
draft.focused_field = DraftField::DirPicker;
|
||||
draft.move_selection(1); // onto "Other directory…"
|
||||
|
||||
assert_eq!(
|
||||
draft.on_input(&InputKey::Enter),
|
||||
DraftInputResult::Consumed,
|
||||
"reveals the raw field instead of submitting, like the web form's own button"
|
||||
);
|
||||
assert_eq!(draft.focused_field, DraftField::CwdRaw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_down_moves_the_highlight_within_the_list() {
|
||||
let mut draft = DraftPane::default();
|
||||
@@ -2678,10 +2542,25 @@ mod tests {
|
||||
draft.base_repo = Some(PathBuf::from("/home/u/tea/maestro"));
|
||||
draft.probed_cwd = Some(PathBuf::from("/home/u/tea/maestro"));
|
||||
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||
for s in ["[new] [existing]", "br:", "crimson-otter"] {
|
||||
for s in ["[new] [existing]", "new:", "crimson-otter"] {
|
||||
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
|
||||
}
|
||||
|
||||
// The new-branch input and existing-branch picker must not share a
|
||||
// label — matching the web form's "New branch name" / "Pick a
|
||||
// branch" (#94).
|
||||
draft.set_branch_mode(WorktreeBranchMode::Existing);
|
||||
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||
assert!(
|
||||
rendered.contains("pick:"),
|
||||
"missing pick: label:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains("br:"),
|
||||
"stale ambiguous label:\n{rendered}"
|
||||
);
|
||||
draft.set_branch_mode(WorktreeBranchMode::New);
|
||||
|
||||
draft.focused_field = DraftField::WorktreeToggle;
|
||||
draft.on_input(&InputKey::Enter);
|
||||
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||
|
||||
+4
-45
@@ -176,11 +176,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
</label>
|
||||
</div>
|
||||
<label class="wt-new-wrap">
|
||||
Branch
|
||||
New branch name
|
||||
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
||||
</label>
|
||||
<label class="wt-exist-wrap" hidden>
|
||||
Branch
|
||||
Pick a branch
|
||||
<select class="branch-select"></select>
|
||||
</label>
|
||||
</div>
|
||||
@@ -549,15 +549,11 @@ 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 visible = visibleGroups(filterInput.value);
|
||||
const query = filterInput.value;
|
||||
const visible = groups.filter((g) => filterProjects([g.main, ...g.worktrees], query).length > 0);
|
||||
for (const group of visible) projectsEl.appendChild(projectBox(group));
|
||||
if (discovered.length > 0 && visible.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
@@ -866,43 +862,6 @@ 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.
|
||||
// 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 ----------------------------------------------------------
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
// @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,21 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
|
||||
expect(modes.at(-1)).toBe("existing");
|
||||
});
|
||||
|
||||
it("labels the new-branch input and existing-branch picker distinctly (#94)", async () => {
|
||||
const form = await mount();
|
||||
pickProject(form);
|
||||
await drain();
|
||||
|
||||
const newLabel = form.querySelector<HTMLElement>(".wt-new-wrap")!.textContent!.trim();
|
||||
const existLabel = form.querySelector<HTMLElement>(".wt-exist-wrap")!.textContent!.trim();
|
||||
|
||||
expect(newLabel).not.toBe("Branch");
|
||||
expect(existLabel).not.toBe("Branch");
|
||||
expect(newLabel).not.toBe(existLabel);
|
||||
expect(newLabel).toMatch(/name/i);
|
||||
expect(existLabel).toMatch(/pick|choose|select/i);
|
||||
});
|
||||
|
||||
it("collapses the advanced (worktree) section by default (#93)", async () => {
|
||||
const form = await mount();
|
||||
const toggle = form.querySelector<HTMLButtonElement>(".wt-toggle")!;
|
||||
|
||||
Reference in New Issue
Block a user