From 2c8758ed147bccff989dd7ed7cc303060bacc37e Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 14:58:07 +0100 Subject: [PATCH 1/3] fix(maestro): stop a late /api/info resolution from clobbering a picked preset A tile clicked, or a custom command typed, during the /api/info round trip was discarded: applyInitialCommand() ran unconditionally a second time once presets refined, re-selecting the first non-custom preset with no seed to protect the operator's pick. Guard it with a commandTouched flag, mirroring the nameTouched pattern already used for the name-batch race. Fixes #76. --- web/src/spawnForm.ts | 27 ++++++- web/src/spawnFormPreset.test.ts | 124 ++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 web/src/spawnFormPreset.test.ts diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index 1b130ce..8053f98 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -337,6 +337,15 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { emit(); } + /** Re-highlight the operator's already-picked tile after `renderCommandTiles` + * rebuilds the row from scratch (new tiles carry no `is-active` class) — + * without touching `command`, since that was already settled. */ + function reapplyActiveTile(): void { + for (const tile of cmdTiles.querySelectorAll(".tile")) { + tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === selectedPresetId); + } + } + function renderCommandTiles(): void { cmdTiles.replaceChildren(); for (const p of presets) { @@ -345,13 +354,23 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { tile.className = "tile"; tile.dataset.id = p.id; tile.innerHTML = `${escapeHtml(p.label)}`; - tile.addEventListener("click", () => selectPreset(p.id)); + tile.addEventListener("click", () => { + commandTouched = true; + selectPreset(p.id); + }); cmdTiles.appendChild(tile); } } + // Set once the operator picks a tile or types a custom command — guards + // `applyInitialCommand()` the same way `nameTouched` guards a late name + // batch: a late `/api/info` resolution must never overwrite a field the + // operator has already touched (#76). + let commandTouched = false; + cmdRaw.addEventListener("input", () => { if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") { + commandTouched = true; command = cmdRaw.value.trim(); emit(); } @@ -367,7 +386,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { if (info.presets?.length) { presets = info.presets; renderCommandTiles(); - applyInitialCommand(); + if (commandTouched) { + reapplyActiveTile(); + } else { + applyInitialCommand(); + } } }) .catch(() => { diff --git a/web/src/spawnFormPreset.test.ts b/web/src/spawnFormPreset.test.ts new file mode 100644 index 0000000..e7fe7bc --- /dev/null +++ b/web/src/spawnFormPreset.test.ts @@ -0,0 +1,124 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js"; + +/** Stubs fetch so `/api/info` only resolves once `resolveInfo()` is called — + * everything else (names, projects, gitroot) answers immediately with an + * empty/default body. Lets a test pick a preset (or type a custom command) + * mid-flight, then observe what the late `/api/info` resolution does to it. */ +function stubDeferredInfo(): { resolveInfo: () => void } { + let releaseInfo: () => void = () => {}; + const infoGate = new Promise((resolve) => { + releaseInfo = resolve; + }); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/api/info")) { + await infoGate; + return new Response( + JSON.stringify({ + hostname: "h", + version: "0", + home_dir: "/home/user", + presets: [ + { id: "claude", label: "Claude", command: "claude" }, + { id: "fish", label: "Fish", command: "fish" }, + { id: "custom", label: "Custom…", command: "" }, + ], + }), + { status: 200 }, + ); + } + if (url.includes("/api/names")) { + return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }), + ); + return { resolveInfo: releaseInfo }; +} + +describe("spawnForm preset selection vs. late /api/info (#76)", () => { + let handle: SpawnFormHandle | null = null; + + afterEach(() => { + handle?.destroy(); + handle = null; + vi.unstubAllGlobals(); + }); + + async function drain(): Promise { + for (let i = 0; i < 40; i++) await Promise.resolve(); + } + + it("keeps an operator-picked tile after /api/info resolves late", async () => { + const { resolveInfo } = stubDeferredInfo(); + let values: SpawnFormValues | undefined; + handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} }); + const form = handle.el; + document.body.appendChild(form); + await drain(); + + // Fallback presets are up; the operator picks "zsh" before /api/info lands. + const zshTile = [...form.querySelectorAll(".tile")].find( + (t) => t.dataset.id === "zsh", + )!; + zshTile.click(); + expect(zshTile.classList.contains("is-active")).toBe(true); + + // /api/info lands late with a preset list that doesn't even include "zsh". + resolveInfo(); + await drain(); + + // The operator's pick must not be discarded back to the new list's first + // entry — `onChange` only starts firing once the initial load settles + // (`ready`), which happens right here, so this is the first place the + // published command is observable. + expect(values?.command).toBe("zsh"); + }); + + it("keeps an operator-typed custom command after /api/info resolves late", async () => { + const { resolveInfo } = stubDeferredInfo(); + let values: SpawnFormValues | undefined; + handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} }); + const form = handle.el; + document.body.appendChild(form); + await drain(); + + const customTile = [...form.querySelectorAll(".tile")].find( + (t) => t.dataset.id === "custom", + )!; + customTile.click(); + const cmdRaw = form.querySelector(".cmd-raw")!; + cmdRaw.value = "htop"; + cmdRaw.dispatchEvent(new Event("input")); + + resolveInfo(); + await drain(); + + expect(values?.command).toBe("htop"); + expect(cmdRaw.value).toBe("htop"); + }); + + it("still applies the real presets' first entry when the operator never touched the command", async () => { + const { resolveInfo } = stubDeferredInfo(); + let values: SpawnFormValues | undefined; + handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} }); + const form = handle.el; + document.body.appendChild(form); + await drain(); + + resolveInfo(); + await drain(); + + // Untouched: the refined preset list is still free to settle the command. + expect(values?.command).toBe("claude"); + const fishTile = [...form.querySelectorAll(".tile")].find( + (t) => t.dataset.id === "fish", + ); + expect(fishTile).toBeDefined(); + }); +}); From 16609eff9e94c5bdaad9d14fec4c993ec0eef64e Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 14:59:56 +0100 Subject: [PATCH 2/3] feat(maestro): debounce the gitroot probe in the spawn form Typing in the raw cwd field fired one /api/gitroot request per keystroke (#74). Every keystroke now resets a 250ms window shared with the TUI draft (GITROOT_PROBE_DEBOUNCE_MS there); probeToken still owns ordering so a late answer can't overwrite a newer input. --- web/src/spawnForm.ts | 23 ++++- web/src/spawnFormProbe.test.ts | 148 ++++++++++++++++++++++++++++++ web/src/spawnFormWorktree.test.ts | 4 +- 3 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 web/src/spawnFormProbe.test.ts diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index 1b130ce..60049c0 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -94,6 +94,11 @@ export const FALLBACK_PRESETS: SpawnPreset[] = [ { id: "custom", label: "Custom…", command: "" }, ]; +/** Quiet period after raw-path typing before `/api/gitroot` fires (#74). + * Must stay equal to `GITROOT_PROBE_DEBOUNCE_MS` in + * crates/maestro-tui/src/draft.rs so both clients debounce alike. */ +const GITROOT_PROBE_DEBOUNCE_MS = 250; + export interface SpawnFormHandle { el: HTMLFormElement; /** Remove global listeners (dir autocomplete) — call when unmounting. */ @@ -413,7 +418,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { cwd = cwdRaw.value.trim(); branchCache = null; emit(); - if (wtEnabled.checked) probeGitRoot(); + if (wtEnabled.checked) scheduleGitRootProbe(); }); function markActiveProject(activeCwd: string | null): void { @@ -579,7 +584,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { applyProjectRepo(project.base_repo ?? project.path); return; } - void probeGitRoot(); + scheduleGitRootProbe(); + } + + // Every keystroke resets this timer, so a burst of typing issues one + // request once the operator pauses. `probeToken` inside probeGitRoot still + // owns ordering: a scheduled probe that fires after a newer one started + // loses the token race and is dropped. + let probeTimer: number | undefined; + function scheduleGitRootProbe(): void { + window.clearTimeout(probeTimer); + probeTimer = window.setTimeout(() => { + probeTimer = undefined; + void probeGitRoot(); + }, GITROOT_PROBE_DEBOUNCE_MS); } async function probeGitRoot(): Promise { @@ -740,6 +758,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { el: form, destroy() { destroyed = true; + window.clearTimeout(probeTimer); autocomplete.destroy(); form.remove(); }, diff --git a/web/src/spawnFormProbe.test.ts b/web/src/spawnFormProbe.test.ts new file mode 100644 index 0000000..48eb074 --- /dev/null +++ b/web/src/spawnFormProbe.test.ts @@ -0,0 +1,148 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js"; + +/** Debounce window under test — must equal GITROOT_PROBE_DEBOUNCE_MS in + * spawnForm.ts (#74). */ +const DEBOUNCE_MS = 250; + +const PROJECTS = [ + { path: "/home/user/maestro", name: "maestro", last_activity: "2026-08-20T10:00:00Z" }, +]; + +/** Stub the fetches the form makes. Gitroot responses for a given path can be + * held until `releaseGitRoot(path)` is called, so tests can land answers out + * of order. */ +function stubFetch(opts?: { holdGitRootFor?: string[] }): { + urls: string[]; + gitRootCalls: string[]; + releaseGitRoot: (path: string) => void; +} { + const urls: string[] = []; + const gitRootCalls: string[] = []; + const releases = new Map void>(); + const held = new Map>(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + urls.push(url); + if (url.includes("/api/dirs")) { + return new Response(JSON.stringify({ entries: [] }), { status: 200 }); + } + if (url.includes("/api/gitroot")) { + const path = new URL(url, "http://localhost").searchParams.get("path") ?? ""; + gitRootCalls.push(path); + if (opts?.holdGitRootFor?.includes(path)) { + let release!: () => void; + held.set( + path, + new Promise((r) => { + release = r; + }), + ); + releases.set(path, release); + } + await held.get(path); + return new Response(JSON.stringify({ root: path }), { status: 200 }); + } + if (url.includes("/api/names")) { + return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 }); + } + if (url.includes("/api/projects")) { + return new Response(JSON.stringify(PROJECTS), { status: 200 }); + } + return new Response( + JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }), + { status: 200 }, + ); + }), + ); + return { + urls, + gitRootCalls, + releaseGitRoot: (path: string) => releases.get(path)?.(), + }; +} + +describe("spawnForm gitroot probe debounce (#74)", () => { + let handle: SpawnFormHandle | null = null; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + handle?.destroy(); + handle = null; + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + async function mount(opts?: Parameters[0]) { + const stub = stubFetch(opts); + handle = createSpawnForm({ onSuccess() {} }); + document.body.appendChild(handle.el); + for (let i = 0; i < 40; i++) await Promise.resolve(); + return { form: handle.el, stub }; + } + + const drain = async () => { + for (let i = 0; i < 40; i++) await Promise.resolve(); + }; + + function typeRaw(form: HTMLFormElement, value: string): void { + const raw = form.querySelector("input[name=cwd_raw]")!; + raw.value = value; + raw.dispatchEvent(new Event("input")); + } + + it("a keystroke burst fires exactly one probe after the quiet period", async () => { + const { form, stub } = await mount(); + + form.querySelector(".project-row.project-other")!.click(); + // Burst: every keystroke reschedules; nothing may hit /api/gitroot yet. + for (let i = 1; i <= "/opt/somewhere".length; i++) { + typeRaw(form, "/opt/somewhere".slice(0, i)); + await drain(); + expect(stub.gitRootCalls).toEqual([]); + } + + await vi.advanceTimersByTimeAsync(DEBOUNCE_MS); + await drain(); + + expect(stub.gitRootCalls).toEqual(["/opt/somewhere"]); + expect(stub.urls.filter((u) => u.includes("/api/gitroot")).length).toBe(1); + }); + + it("a stale probe answer cannot overwrite a newer input", async () => { + const { form, stub } = await mount({ holdGitRootFor: ["/repo/old"] }); + + form.querySelector(".project-row.project-other")!.click(); + + // Probe for the old target, held in flight. + typeRaw(form, "/repo/old"); + await vi.advanceTimersByTimeAsync(DEBOUNCE_MS); + + // Newer target supersedes it and answers immediately. + typeRaw(form, "/repo/new"); + await vi.advanceTimersByTimeAsync(DEBOUNCE_MS); + await drain(); + + expect(stub.gitRootCalls).toEqual(["/repo/old", "/repo/new"]); + expect(form.querySelector(".wt-repo")!.textContent).toContain("/repo/new"); + expect( + form.querySelector("input[name=worktree_enabled]")!.checked, + ).toBe(true); + + // The late answer to the old target lands last but must lose. + stub.releaseGitRoot("/repo/old"); + await drain(); + + expect(form.querySelector(".wt-repo")!.textContent).toContain("/repo/new"); + expect( + form.querySelector("input[name=worktree_enabled]")!.checked, + ).toBe(true); + }); +}); diff --git a/web/src/spawnFormWorktree.test.ts b/web/src/spawnFormWorktree.test.ts index b8012e1..bb04b96 100644 --- a/web/src/spawnFormWorktree.test.ts +++ b/web/src/spawnFormWorktree.test.ts @@ -132,11 +132,13 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => { it("still probes the gitroot for a free-text directory", async () => { const form = await mount(); - // Escape hatch: switch to a free-text dir; only now does /api/gitroot fire. + // Escape hatch: switch to a free-text dir; only now does /api/gitroot + // fire — debounced, so the quiet period must elapse first (#74). form.querySelector(".project-row.project-other")!.click(); const raw = form.querySelector("input[name=cwd_raw]")!; raw.value = "/opt/somewhere"; raw.dispatchEvent(new Event("input")); + await vi.advanceTimersByTimeAsync(250); await drain(); expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(true); From 596e7e2f49acec14759ffaa356287b8b548f65ca Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 15:04:47 +0100 Subject: [PATCH 3/3] feat(maestro): debounce the gitroot probe in the TUI draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing in the raw path field fired one /api/gitroot probe per keystroke (#74). The draft now schedules the probe 250ms out — matching GITROOT_PROBE_DEBOUNCE_MS in web/src/spawnForm.ts — and every keystroke resets it; the 30Hz heartbeat polls for due probes so the burst's one request fires after typing stops. The seq guard in resolve_gitroot is untouched, so a late answer still cannot overwrite a newer input. --- crates/maestro-tui/src/app.rs | 55 ++++++++++++---- crates/maestro-tui/src/draft.rs | 113 ++++++++++++++++++++++++++++---- 2 files changed, 145 insertions(+), 23 deletions(-) diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index cecf43e..1894082 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -175,7 +175,8 @@ impl App { // Render on next loop iteration. } _ = tokio::time::sleep(Duration::from_millis(33)) => { - // 30 Hz heartbeat. + let effects = self.poll_due_probes_at(std::time::Instant::now()); + self.perform(effects).await; } } } @@ -439,7 +440,7 @@ impl App { if draft.take_name_refill() { effects.push(Effect::RefillNames(tab_idx)); } - if let Some((seq, path)) = draft.take_gitroot_probe() { + if let Some((seq, path)) = draft.take_gitroot_probe(std::time::Instant::now()) { effects.push(Effect::ProbeGitRoot { tab: tab_idx, seq, @@ -452,6 +453,26 @@ impl App { effects } + /// Fire any draft gitroot probes whose debounce window has elapsed (#74). + /// The 30 Hz heartbeat calls this so a burst of typing issues its one + /// probe even after the last keystroke. + fn poll_due_probes_at(&mut self, now: std::time::Instant) -> Vec { + let mut effects = Vec::new(); + for tab_idx in 0..self.state.tabs.len() { + let Some(PaneRef::Draft(draft)) = self.state.tabs[tab_idx].pane.as_mut() else { + continue; + }; + if let Some((seq, path)) = draft.take_gitroot_probe(now) { + effects.push(Effect::ProbeGitRoot { + tab: tab_idx, + seq, + path, + }); + } + } + effects + } + /// Populate a freshly opened draft: presets from `/api/info` (falling back /// to the built-ins already in place), projects from `/api/projects`, and /// a first name batch. Applies only if that tab still shows the draft — @@ -1347,10 +1368,11 @@ mod tests { } } - /// Editing the raw path with the worktree toggle on schedules a - /// `/api/gitroot` probe so the section learns its base repo (#65). + /// Editing the raw path schedules a `/api/gitroot` probe only once the + /// keystroke burst settles (#74): nothing fires inside the debounce + /// window; the heartbeat picks the due probe up afterwards. #[test] - fn typing_a_raw_path_schedules_a_gitroot_probe() { + fn typing_a_raw_path_schedules_a_debounced_gitroot_probe() { let mut app = make_app(); app.dispatch(Action::NewDraftPane); let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else { @@ -1359,13 +1381,24 @@ mod tests { draft.cwd_raw_visible = true; draft.focused_field = DraftField::CwdRaw; - let effects = app.handle_draft_input(&InputKey::char('/')); + // A burst of keystrokes fires nothing. + for c in "/opt/tooling".chars() { + assert!(app.handle_draft_input(&InputKey::char(c)).is_empty()); + assert!(app.poll_due_probes_at(std::time::Instant::now()).is_empty()); + } + + // Once the quiet period passes, exactly one probe is due. + let due = std::time::Instant::now() + + std::time::Duration::from_millis(crate::draft::GITROOT_PROBE_DEBOUNCE_MS) * 10; + match app.poll_due_probes_at(due).as_slice() { + [Effect::ProbeGitRoot { tab: 0, path, .. }] => { + assert_eq!(path, &std::path::PathBuf::from("/opt/tooling")); + } + other => panic!("expected one ProbeGitRoot effect, got {other:?}"), + } assert!( - matches!( - effects.as_slice(), - [Effect::ProbeGitRoot { tab: 0, seq: 2, .. }] - ), - "{effects:?}" + app.poll_due_probes_at(due).is_empty(), + "the probe is consumed once" ); } diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index 6604326..0224662 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -17,6 +17,7 @@ use std::collections::VecDeque; use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; use chrono::{DateTime, Utc}; use maestro_config::{builtin_presets, custom_preset}; @@ -35,6 +36,12 @@ use crate::projects; /// dry. pub const NAME_BATCH_SIZE: usize = 20; +/// Quiet period after raw-path typing before a `/api/gitroot` probe fires +/// (#74). Every keystroke resets it, so a burst issues one request once the +/// operator pauses. Must stay equal to `GITROOT_PROBE_DEBOUNCE_MS` in +/// web/src/spawnForm.ts so both clients debounce alike. +pub const GITROOT_PROBE_DEBOUNCE_MS: u64 = 250; + /// Width of the fixed label gutter every form line starts with (`cmd: `, /// `dir: `, …). const GUTTER: usize = 5; @@ -149,6 +156,9 @@ pub struct DraftPane { /// Bumped on every target change; answers carrying an older seq are stale. probe_seq: u32, needs_probe: bool, + /// When the pending probe may fire (debounce, #74). Typing into the raw + /// path resets it; a discrete action like the toggle makes it due at once. + probe_due_at: Option, /// Set when a probe found no repository (drives the rendered hint). repo_missing: bool, /// New-branch name. Tracks [`Self::name`] until edited, like the web form. @@ -198,6 +208,7 @@ impl Default for DraftPane { probed_cwd: None, probe_seq: 0, needs_probe: false, + probe_due_at: None, repo_missing: false, branch: crate::text_input::TextInput::default(), branch_edited: false, @@ -320,18 +331,23 @@ impl DraftPane { self.repo_missing = false; self.probe_seq += 1; self.needs_probe = false; + self.probe_due_at = None; self.probed_cwd = self.cwd(); self.branches_for = None; self.schedule_branch_load(); } /// A target with an unknown repo (raw path edit or the Other… hatch). - /// Cancels any in-flight probe and requests a fresh one. + /// Cancels any in-flight probe and schedules a fresh one after the + /// debounce window (#74). fn note_unknown_repo(&mut self) { self.base_repo = None; self.repo_missing = false; self.probe_seq += 1; self.needs_probe = self.worktree_enabled && self.cwd().is_some(); + self.probe_due_at = self + .needs_probe + .then(|| Instant::now() + Duration::from_millis(GITROOT_PROBE_DEBOUNCE_MS)); self.branches_for = None; self.schedule_branch_load(); } @@ -342,14 +358,19 @@ impl DraftPane { } } - /// Consume the gitroot-probe request: the caller fetches + /// Consume the gitroot-probe request once its debounce window has passed + /// (`now` at or after [`Self::probe_due_at`]): the caller fetches /// `GET /api/gitroot?path=…` and reports back via [`Self::resolve_gitroot`] - /// with the same seq. - pub fn take_gitroot_probe(&mut self) -> Option<(u32, PathBuf)> { + /// with the same seq. Returns `None` while typing is still in flight. + pub fn take_gitroot_probe(&mut self, now: Instant) -> Option<(u32, PathBuf)> { if !self.needs_probe { return None; } + if now < self.probe_due_at? { + return None; + } self.needs_probe = false; + self.probe_due_at = None; let cwd = self.cwd()?; self.probe_seq += 1; Some((self.probe_seq, cwd)) @@ -363,6 +384,7 @@ impl DraftPane { return; } self.needs_probe = false; + self.probe_due_at = None; self.probed_cwd = self.cwd(); match root { Some(repo) => { @@ -434,7 +456,9 @@ impl DraftPane { } if self.base_repo.is_none() { self.probe_seq += 1; + // A discrete toggle, not a keystroke burst: due immediately. self.needs_probe = cwd.is_some(); + self.probe_due_at = self.needs_probe.then(Instant::now); } self.schedule_branch_load(); } @@ -1277,6 +1301,11 @@ mod tests { } } + /// A moment comfortably past the probe debounce window (#74). + fn probe_due() -> Instant { + Instant::now() + Duration::from_millis(GITROOT_PROBE_DEBOUNCE_MS) * 10 + } + #[test] fn defaults_fall_back_to_builtin_presets_with_custom_last() { let draft = DraftPane::default(); @@ -1464,7 +1493,7 @@ mod tests { typed(&mut draft, "/opt/tooling"); assert_eq!(draft.cwd(), Some(PathBuf::from("/home/u/opt/tooling"))); // The raw target probed clean, so the worktree spec carries its repo. - let (seq, _) = draft.take_gitroot_probe().unwrap(); + let (seq, _) = draft.take_gitroot_probe(probe_due()).unwrap(); draft.resolve_gitroot(seq, Some(PathBuf::from("/home/u/opt/tooling"))); draft.seed_names(vec!["amber-falcon".into()]); let req = draft.build_request().unwrap(); @@ -1685,7 +1714,7 @@ mod tests { Some(Path::new("/repos/maestro")), "a picked project resolves its base repo without a probe" ); - assert!(draft.take_gitroot_probe().is_none()); + assert!(draft.take_gitroot_probe(probe_due()).is_none()); draft.seed_names(vec!["crimson-otter".into()]); let wt = draft.build_request().unwrap().worktree.unwrap(); @@ -1725,7 +1754,7 @@ mod tests { draft.on_input(&InputKey::Enter); assert!(draft.worktree_enabled); assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/repos/main"))); - assert!(draft.take_gitroot_probe().is_none()); + assert!(draft.take_gitroot_probe(probe_due()).is_none()); } #[test] @@ -1737,13 +1766,18 @@ mod tests { draft.on_input(&InputKey::Enter); // no projects → Other directory… typed(&mut draft, "/opt/tool"); - let (seq, path) = draft.take_gitroot_probe().expect("probe requested"); + let (seq, path) = draft + .take_gitroot_probe(probe_due()) + .expect("probe requested"); assert_eq!(path, PathBuf::from("/opt/tool")); - assert!(draft.take_gitroot_probe().is_none(), "one probe at a time"); + assert!( + draft.take_gitroot_probe(probe_due()).is_none(), + "one probe at a time" + ); // Further typing supersedes the in-flight probe… typed(&mut draft, "-x"); - let (seq2, _) = draft.take_gitroot_probe().unwrap(); + let (seq2, _) = draft.take_gitroot_probe(probe_due()).unwrap(); assert_ne!(seq2, seq); // …so a late answer to the old one must be ignored. draft.resolve_gitroot(seq, Some(PathBuf::from("/somewhere/else"))); @@ -1764,7 +1798,7 @@ mod tests { }; draft.on_input(&InputKey::Enter); typed(&mut draft, "/not/a/repo"); - let (seq, _) = draft.take_gitroot_probe().unwrap(); + let (seq, _) = draft.take_gitroot_probe(probe_due()).unwrap(); draft.resolve_gitroot(seq, None); assert!(!draft.worktree_enabled); @@ -1908,6 +1942,61 @@ mod tests { ); } + #[test] + fn a_keystroke_burst_schedules_one_debounced_gitroot_probe() { + let mut draft = DraftPane { + focused_field: DraftField::DirPicker, + ..DraftPane::default() + }; + draft.on_input(&InputKey::Enter); // no projects → Other directory… + + // Burst: every keystroke reschedules; nothing may fire inside the + // debounce window (#74). + for c in "/opt/tooling".chars() { + draft.on_input(&InputKey::char(c)); + assert!( + draft.take_gitroot_probe(Instant::now()).is_none(), + "no probe while typing is still in flight" + ); + } + + // Once the quiet period passes, exactly one probe fires. + let (seq, path) = draft + .take_gitroot_probe(probe_due()) + .expect("probe due after the quiet period"); + assert_eq!(path, PathBuf::from("/opt/tooling")); + assert!( + draft.take_gitroot_probe(probe_due()).is_none(), + "one probe total" + ); + + // The answer still lands through the unchanged seq guard. + draft.resolve_gitroot(seq, Some(PathBuf::from("/opt/tooling"))); + assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/opt/tooling"))); + } + + #[test] + fn a_stale_probe_answer_cannot_overwrite_a_newer_target_after_the_debounce() { + let mut draft = DraftPane { + focused_field: DraftField::DirPicker, + ..DraftPane::default() + }; + draft.on_input(&InputKey::Enter); // Other directory… + + typed(&mut draft, "/repo/old"); + let (old_seq, _) = draft.take_gitroot_probe(probe_due()).unwrap(); + + // Retyping supersedes the in-flight probe and gets a newer seq… + typed(&mut draft, "/repo/new"); + let (new_seq, _) = draft.take_gitroot_probe(probe_due()).unwrap(); + assert_ne!(new_seq, old_seq); + + // …so the late answer to the old target must be ignored. + draft.resolve_gitroot(new_seq, Some(PathBuf::from("/repo/new"))); + draft.resolve_gitroot(old_seq, Some(PathBuf::from("/repo/old"))); + assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/repo/new"))); + } + #[test] fn toggling_worktree_off_spawns_in_the_checkout_itself() { let mut draft = DraftPane::default(); @@ -1933,7 +2022,7 @@ mod tests { draft.on_input(&InputKey::Enter); assert!(draft.worktree_enabled); assert!( - draft.take_gitroot_probe().is_none(), + draft.take_gitroot_probe(probe_due()).is_none(), "re-enabling reuses the known repo for an unchanged target" ); }