Compare commits

...
Author SHA1 Message Date
Miguel Palhas 596e7e2f49 feat(maestro): debounce the gitroot probe in the TUI draft
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.
2026-08-24 15:04:47 +01:00
Miguel Palhas 16609eff9e 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.
2026-08-24 14:59:56 +01:00
Miguel Palhas 2a2da7f779 merge: move draft forms along with swapped pane data
Closes #73
2026-08-24 14:55:55 +01:00
Miguel Palhas 5eab434647 merge: default the worktree toggle off in an existing worktree
Closes #91
2026-08-24 14:54:46 +01:00
Miguel Palhas 8e9ed580ea Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/73-draft-pane-swap 2026-08-24 14:54:26 +01:00
Miguel Palhas 2d5e2f1718 fix(maestro): move draft forms along with swapped pane data
Center-zone pane drag swapped dragged.draft/target.draft but left
draftForms cached under the old leaf id, so both panes kept showing
their pre-swap form values while writing into the swapped leaf.

Tear down both leaves' cached forms at swap time (same pattern as
openDraft's stale-form teardown, #72) so render() remounts fresh
forms seeded from the swapped data.

Fixes #73
2026-08-24 14:54:18 +01:00
Miguel Palhas b80d604408 fix(maestro): default the worktree toggle off when the directory is itself a worktree (#91)
Picking a project row that is itself a linked git worktree used to
default 'Create git worktree' on, so spawning asked for a second
worktree of an already-checked-out branch and git refused. Both clients
now default the toggle off there: the session just runs in that existing
worktree with no worktree record (not adoption). Turning it back on by
hand still tries, per the issue's non-goal.
2026-08-24 14:52:17 +01:00
Miguel Palhas 56d1afe9bd merge: cap and centre the spawn form's width
Closes #92
2026-08-24 14:48:45 +01:00
Miguel Palhas 73e9ecfc85 fix(maestro): cap and centre the spawn form's width
The form filled the full pane width, so on a 4K display every input
stretched thousands of pixels wide. Bound it to 60ch (in line with the
48ch/42ch precedents already in the type scale) and centre it with
margin auto; panes narrower than the cap still fill edge to edge.
2026-08-24 14:48:19 +01:00
Miguel Palhas 095f810176 merge: make shift-S resolve to the fast-forward spawn binding
Closes #70
2026-08-24 14:45:53 +01:00
Miguel Palhas 3e809db1bc merge: honour the project cache TTL in the background rescan
Closes #75
2026-08-24 14:42:54 +01:00
Miguel Palhas e8bd8a4952 merge: reuse an empty draft pane and keep its seed
Closes #72
2026-08-24 14:41:42 +01:00
Miguel Palhas 9c6e06ec4f fix(maestro): reuse empty draft pane and keep its seed (#72)
openDraft's reuse check was `!target.draft`, dead after first render:
createSpawnForm auto-syncs its picked defaults into the leaf's draft
once its project load settles, so every empty pane has a non-null
draft a second later. `n` and Spawn always split instead of reusing
the pane on screen.

Reuse now checks only `!target.sessionId`, matching the codebase's own
definition of "empty" pane elsewhere. Reusing also drops the leaf's
stale form instance so a new seed is read by the rebuilt form instead
of being ignored by the old one.
2026-08-24 14:39:51 +01:00
Miguel Palhas 3029de667e fix(maestro): honour the project cache TTL in the background rescan (#75) 2026-08-24 14:39:10 +01:00
9 changed files with 576 additions and 44 deletions
+35 -13
View File
@@ -261,17 +261,13 @@ impl ProjectScanner {
} }
/// Cached snapshot, rescanning (off-thread) when the TTL has lapsed. /// Cached snapshot, rescanning (off-thread) when the TTL has lapsed.
/// Also the background timer's entry point: a tick inside the TTL is a
/// no-op (#75).
pub async fn repos(&self) -> std::sync::Arc<Vec<DiscoveredRepo>> { pub async fn repos(&self) -> std::sync::Arc<Vec<DiscoveredRepo>> {
self.repos_inner(false).await
}
async fn repos_inner(&self, force: bool) -> std::sync::Arc<Vec<DiscoveredRepo>> {
let mut cache = self.cache.lock().await; let mut cache = self.cache.lock().await;
if !force { if let Some((at, repos)) = cache.as_ref() {
if let Some((at, repos)) = cache.as_ref() { if at.elapsed() < self.ttl {
if at.elapsed() < self.ttl { return repos.clone();
return repos.clone();
}
} }
} }
let roots = self.roots.clone(); let roots = self.roots.clone();
@@ -287,11 +283,11 @@ impl ProjectScanner {
repos repos
} }
/// Rescan now, ignoring the TTL. Called by the background timer — going /// One background-refresh tick: rescan only if the cached snapshot has
/// through `repos()` would hit the cache and make most ticks no-ops, /// passed its TTL (#75) — the timer used to bypass it and re-walk the
/// leaving requests to pay for the rescan instead (#54 review). /// roots every [`REFRESH_INTERVAL`].
async fn refresh(&self) { async fn refresh(&self) {
self.repos_inner(true).await; self.repos().await;
} }
/// Spawn the slow background refresh task. Drop the returned handle to /// Spawn the slow background refresh task. Drop the returned handle to
@@ -544,6 +540,32 @@ mod tests {
assert_eq!(fresh[0].name, "late"); assert_eq!(fresh[0].name, "late");
} }
#[tokio::test]
async fn background_refresh_honours_the_ttl() {
let tmp = tempfile::tempdir().unwrap();
let mut scanner = ProjectScanner::new(
vec![tmp.path().to_path_buf()],
3,
PathBuf::from("/nonexistent"),
);
let empty = scanner.repos().await;
assert!(empty.is_empty());
make_repo(tmp.path(), "late");
// A background tick inside the TTL must not re-walk the filesystem:
// the new repo stays invisible until the cache expires.
scanner.refresh().await;
assert!(scanner.repos().await.is_empty());
// Once the TTL lapses, the next tick picks it up.
scanner.ttl = Duration::ZERO;
scanner.refresh().await;
let fresh = scanner.repos().await;
assert_eq!(fresh.len(), 1);
assert_eq!(fresh[0].name, "late");
}
#[test] #[test]
fn disabled_scanner_always_serves_empty() { fn disabled_scanner_always_serves_empty() {
let scanner = ProjectScanner::disabled(); let scanner = ProjectScanner::disabled();
+44 -11
View File
@@ -175,7 +175,8 @@ impl App {
// Render on next loop iteration. // Render on next loop iteration.
} }
_ = tokio::time::sleep(Duration::from_millis(33)) => { _ = 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() { if draft.take_name_refill() {
effects.push(Effect::RefillNames(tab_idx)); 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 { effects.push(Effect::ProbeGitRoot {
tab: tab_idx, tab: tab_idx,
seq, seq,
@@ -452,6 +453,26 @@ impl App {
effects 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<Effect> {
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 /// Populate a freshly opened draft: presets from `/api/info` (falling back
/// to the built-ins already in place), projects from `/api/projects`, and /// 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 — /// 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 /// Editing the raw path schedules a `/api/gitroot` probe only once the
/// `/api/gitroot` probe so the section learns its base repo (#65). /// keystroke burst settles (#74): nothing fires inside the debounce
/// window; the heartbeat picks the due probe up afterwards.
#[test] #[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(); let mut app = make_app();
app.dispatch(Action::NewDraftPane); app.dispatch(Action::NewDraftPane);
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else { 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.cwd_raw_visible = true;
draft.focused_field = DraftField::CwdRaw; 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!( assert!(
matches!( app.poll_due_probes_at(due).is_empty(),
effects.as_slice(), "the probe is consumed once"
[Effect::ProbeGitRoot { tab: 0, seq: 2, .. }]
),
"{effects:?}"
); );
} }
+139 -11
View File
@@ -17,6 +17,7 @@
use std::collections::VecDeque; use std::collections::VecDeque;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use maestro_config::{builtin_presets, custom_preset}; use maestro_config::{builtin_presets, custom_preset};
@@ -35,6 +36,12 @@ use crate::projects;
/// dry. /// dry.
pub const NAME_BATCH_SIZE: usize = 20; 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: `, /// Width of the fixed label gutter every form line starts with (`cmd: `,
/// `dir: `, …). /// `dir: `, …).
const GUTTER: usize = 5; const GUTTER: usize = 5;
@@ -149,6 +156,9 @@ pub struct DraftPane {
/// Bumped on every target change; answers carrying an older seq are stale. /// Bumped on every target change; answers carrying an older seq are stale.
probe_seq: u32, probe_seq: u32,
needs_probe: bool, 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<Instant>,
/// Set when a probe found no repository (drives the rendered hint). /// Set when a probe found no repository (drives the rendered hint).
repo_missing: bool, repo_missing: bool,
/// New-branch name. Tracks [`Self::name`] until edited, like the web form. /// New-branch name. Tracks [`Self::name`] until edited, like the web form.
@@ -198,6 +208,7 @@ impl Default for DraftPane {
probed_cwd: None, probed_cwd: None,
probe_seq: 0, probe_seq: 0,
needs_probe: false, needs_probe: false,
probe_due_at: None,
repo_missing: false, repo_missing: false,
branch: crate::text_input::TextInput::default(), branch: crate::text_input::TextInput::default(),
branch_edited: false, branch_edited: false,
@@ -320,18 +331,23 @@ impl DraftPane {
self.repo_missing = false; self.repo_missing = false;
self.probe_seq += 1; self.probe_seq += 1;
self.needs_probe = false; self.needs_probe = false;
self.probe_due_at = None;
self.probed_cwd = self.cwd(); self.probed_cwd = self.cwd();
self.branches_for = None; self.branches_for = None;
self.schedule_branch_load(); self.schedule_branch_load();
} }
/// A target with an unknown repo (raw path edit or the Other… hatch). /// 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) { fn note_unknown_repo(&mut self) {
self.base_repo = None; self.base_repo = None;
self.repo_missing = false; self.repo_missing = false;
self.probe_seq += 1; self.probe_seq += 1;
self.needs_probe = self.worktree_enabled && self.cwd().is_some(); 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.branches_for = None;
self.schedule_branch_load(); 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`] /// `GET /api/gitroot?path=…` and reports back via [`Self::resolve_gitroot`]
/// with the same seq. /// with the same seq. Returns `None` while typing is still in flight.
pub fn take_gitroot_probe(&mut self) -> Option<(u32, PathBuf)> { pub fn take_gitroot_probe(&mut self, now: Instant) -> Option<(u32, PathBuf)> {
if !self.needs_probe { if !self.needs_probe {
return None; return None;
} }
if now < self.probe_due_at? {
return None;
}
self.needs_probe = false; self.needs_probe = false;
self.probe_due_at = None;
let cwd = self.cwd()?; let cwd = self.cwd()?;
self.probe_seq += 1; self.probe_seq += 1;
Some((self.probe_seq, cwd)) Some((self.probe_seq, cwd))
@@ -363,6 +384,7 @@ impl DraftPane {
return; return;
} }
self.needs_probe = false; self.needs_probe = false;
self.probe_due_at = None;
self.probed_cwd = self.cwd(); self.probed_cwd = self.cwd();
match root { match root {
Some(repo) => { Some(repo) => {
@@ -434,7 +456,9 @@ impl DraftPane {
} }
if self.base_repo.is_none() { if self.base_repo.is_none() {
self.probe_seq += 1; self.probe_seq += 1;
// A discrete toggle, not a keystroke burst: due immediately.
self.needs_probe = cwd.is_some(); self.needs_probe = cwd.is_some();
self.probe_due_at = self.needs_probe.then(Instant::now);
} }
self.schedule_branch_load(); self.schedule_branch_load();
} }
@@ -654,6 +678,11 @@ impl DraftPane {
let project = visible[sel].project.clone(); let project = visible[sel].project.clone();
self.picked_cwd = Some(project.path.clone()); self.picked_cwd = Some(project.path.clone());
self.cwd_raw_visible = false; self.cwd_raw_visible = false;
// A directory that *is* a linked worktree defaults to spawning in
// place (#91): creating a second worktree of its branch fails.
// Turning it back on by hand still tries (git's own message),
// which is deliberate (#91 non-goal).
self.worktree_enabled = !project.is_worktree;
// The scan already knows this directory's repo (#61): no probe // The scan already knows this directory's repo (#61): no probe
// round trip needed, exactly like the web form. // round trip needed, exactly like the web form.
let repo = project let repo = project
@@ -1272,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] #[test]
fn defaults_fall_back_to_builtin_presets_with_custom_last() { fn defaults_fall_back_to_builtin_presets_with_custom_last() {
let draft = DraftPane::default(); let draft = DraftPane::default();
@@ -1459,7 +1493,7 @@ mod tests {
typed(&mut draft, "/opt/tooling"); typed(&mut draft, "/opt/tooling");
assert_eq!(draft.cwd(), Some(PathBuf::from("/home/u/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. // 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.resolve_gitroot(seq, Some(PathBuf::from("/home/u/opt/tooling")));
draft.seed_names(vec!["amber-falcon".into()]); draft.seed_names(vec!["amber-falcon".into()]);
let req = draft.build_request().unwrap(); let req = draft.build_request().unwrap();
@@ -1680,7 +1714,7 @@ mod tests {
Some(Path::new("/repos/maestro")), Some(Path::new("/repos/maestro")),
"a picked project resolves its base repo without a probe" "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()]); draft.seed_names(vec!["crimson-otter".into()]);
let wt = draft.build_request().unwrap().worktree.unwrap(); let wt = draft.build_request().unwrap().worktree.unwrap();
@@ -1689,6 +1723,40 @@ mod tests {
assert!(wt.create_branch); assert!(wt.create_branch);
} }
#[test]
fn picking_a_worktree_project_defaults_the_toggle_off() {
let mut draft = DraftPane::default();
let mut wt = repo_project("wt-91", "/repos/wt91", "/repos/main", 1, None);
wt.is_worktree = true;
draft.set_projects(
vec![
repo_project("main", "/repos/main", "/repos/main", 2, None),
wt,
],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
typed(&mut draft, "wt-91");
draft.on_input(&InputKey::Enter);
assert!(
!draft.worktree_enabled,
"the directory is already a worktree; spawning in place"
);
assert!(
draft.build_request().unwrap().worktree.is_none(),
"no worktree record — the session just runs there (#91)"
);
// Deliberately re-enabling still works, and the scan-known base repo
// survives so the toggle-on path needs no new probe.
draft.focused_field = DraftField::WorktreeToggle;
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(probe_due()).is_none());
}
#[test] #[test]
fn raw_path_targets_probe_gitroot_and_resolution_feeds_the_spec() { fn raw_path_targets_probe_gitroot_and_resolution_feeds_the_spec() {
let mut draft = DraftPane { let mut draft = DraftPane {
@@ -1698,13 +1766,18 @@ mod tests {
draft.on_input(&InputKey::Enter); // no projects → Other directory… draft.on_input(&InputKey::Enter); // no projects → Other directory…
typed(&mut draft, "/opt/tool"); 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_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… // Further typing supersedes the in-flight probe…
typed(&mut draft, "-x"); typed(&mut draft, "-x");
let (seq2, _) = draft.take_gitroot_probe().unwrap(); let (seq2, _) = draft.take_gitroot_probe(probe_due()).unwrap();
assert_ne!(seq2, seq); assert_ne!(seq2, seq);
// …so a late answer to the old one must be ignored. // …so a late answer to the old one must be ignored.
draft.resolve_gitroot(seq, Some(PathBuf::from("/somewhere/else"))); draft.resolve_gitroot(seq, Some(PathBuf::from("/somewhere/else")));
@@ -1725,7 +1798,7 @@ mod tests {
}; };
draft.on_input(&InputKey::Enter); draft.on_input(&InputKey::Enter);
typed(&mut draft, "/not/a/repo"); 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); draft.resolve_gitroot(seq, None);
assert!(!draft.worktree_enabled); assert!(!draft.worktree_enabled);
@@ -1869,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] #[test]
fn toggling_worktree_off_spawns_in_the_checkout_itself() { fn toggling_worktree_off_spawns_in_the_checkout_itself() {
let mut draft = DraftPane::default(); let mut draft = DraftPane::default();
@@ -1894,7 +2022,7 @@ mod tests {
draft.on_input(&InputKey::Enter); draft.on_input(&InputKey::Enter);
assert!(draft.worktree_enabled); assert!(draft.worktree_enabled);
assert!( 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" "re-enabling reuses the known repo for an unchanged target"
); );
} }
+122
View File
@@ -147,6 +147,48 @@ describe("multiview openDraft (#62)", () => {
expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip"); expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip");
}); });
it("reuses an already-synced empty pane instead of splitting (#72)", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
const empty = splitLeaf(tab, first, null, "row", true);
tab.focusedLeafId = empty;
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
// Let the mounted form's loadProjects() settle and auto-sync its picked
// defaults into leaf.draft before openDraft runs (#72's dead-branch window).
await flush();
expect(leaves(tab.root).find((l) => l.id === empty)?.draft).not.toBeNull();
mv.openDraft();
await flush();
expect(leaves(tab.root)).toHaveLength(2);
expect(tab.focusedLeafId).toBe(empty);
});
it("keeps a seed after reusing an already-synced empty pane (#72)", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
const empty = splitLeaf(tab, first, null, "row", true);
tab.focusedLeafId = empty;
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
await flush();
mv.openDraft({ cwd: "/home/user/proj", command: "claude" });
await flush();
const leaf = leaves(tab.root).find((l) => l.id === empty)!;
expect(leaf.draft?.cwd).toBe("/home/user/proj");
expect(leaf.draft?.command).toBe("claude");
const cwdRaw = mv.el.querySelector(".mv-draft .cwd-raw") as HTMLInputElement;
expect(cwdRaw.value).toBe("/home/user/proj");
});
it("seeds a clone's cwd and command into the draft", async () => { it("seeds a clone's cwd and command into the draft", async () => {
const layout = emptyLayout(); const layout = emptyLayout();
const mv = makeMv(layout); const mv = makeMv(layout);
@@ -160,3 +202,83 @@ describe("multiview openDraft (#62)", () => {
expect(leaf.draft?.command).toBe("claude"); expect(leaf.draft?.command).toBe("claude");
}); });
}); });
describe("multiview pane-drag swap (#73)", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
/** Drag `sourcePane`'s title onto `targetPane`'s center, as the real pane
* drag handler does — modelled after the pointer sequence in runDrag()/
* updateDock() in multiview.ts, since neither is exposed on MultiView. */
async function dragPaneOntoCenter(sourcePane: HTMLElement, targetPane: HTMLElement): Promise<void> {
// The real drag-start gate requires the pointerdown target to sit inside
// an `.attach-header` (attach.ts's session title bar). Draft panes mount
// no such element, so stand one in for the gate check itself.
const marker = document.createElement("div");
marker.className = "attach-header";
sourcePane.appendChild(marker);
vi.spyOn(targetPane, "getBoundingClientRect").mockReturnValue({
left: 100,
top: 0,
width: 100,
height: 100,
right: 200,
bottom: 100,
x: 100,
y: 0,
toJSON() {},
} as DOMRect);
(document as unknown as { elementsFromPoint: (x: number, y: number) => Element[] }).elementsFromPoint = () => [
targetPane,
];
marker.dispatchEvent(
new PointerEvent("pointerdown", { clientX: 0, clientY: 0, bubbles: true, cancelable: true }),
);
window.dispatchEvent(
new PointerEvent("pointermove", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }),
);
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }));
await flush();
}
it("moves each draft's form along with its data when two draft panes are swapped", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const leaf1 = splitLeaf(tab, "", null, "row", true);
const leaf2 = splitLeaf(tab, leaf1, null, "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
await flush();
const paneOf = (leafId: string) => mv.el.querySelector(`[data-leaf-id="${leafId}"]`) as HTMLElement;
const nameInputOf = (leafId: string) => paneOf(leafId).querySelector("input[name=name]") as HTMLInputElement;
nameInputOf(leaf1).value = "west";
nameInputOf(leaf1).dispatchEvent(new Event("input"));
nameInputOf(leaf2).value = "east";
nameInputOf(leaf2).dispatchEvent(new Event("input"));
await flush();
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("west");
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("east");
await dragPaneOntoCenter(paneOf(leaf1), paneOf(leaf2));
// The leaf data swaps (already correct pre-fix)...
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("east");
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("west");
// ...and the mounted forms must follow, not stay behind showing the old
// pane's values (#73).
expect(nameInputOf(leaf1).value).toBe("east");
expect(nameInputOf(leaf2).value).toBe("west");
});
});
+18 -2
View File
@@ -423,9 +423,15 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
return; return;
} }
const target = findLeaf(tab.root, tab.focusedLeafId)!; const target = findLeaf(tab.root, tab.focusedLeafId)!;
if (!target.sessionId && !target.draft) { if (!target.sessionId) {
// An already-empty pane becomes the draft in place. // Empty = no session, not `!draft` (#72): the mounted form auto-syncs
// picked defaults into `draft` once ready, so that's never reliably null.
target.draft = draft(); target.draft = draft();
const stale = draftForms.get(target.id);
if (stale) {
stale.destroy();
draftForms.delete(target.id);
}
} else { } else {
// Split a fresh draft beside the focused pane. // Split a fresh draft beside the focused pane.
tab.focusedLeafId = splitLeaf(tab, target.id, null, "row", true); tab.focusedLeafId = splitLeaf(tab, target.id, null, "row", true);
@@ -695,6 +701,16 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const dragged = findLeaf(tab.root, self)!; const dragged = findLeaf(tab.root, self)!;
[dragged.sessionId, target.sessionId] = [target.sessionId, dragged.sessionId]; [dragged.sessionId, target.sessionId] = [target.sessionId, dragged.sessionId];
[dragged.draft, target.draft] = [target.draft, dragged.draft]; [dragged.draft, target.draft] = [target.draft, dragged.draft];
// The leaf data moved but each pane's mounted form is still keyed to
// its old leaf (#73) — tear both down like openDraft's reuse case
// (#72) so render() remounts fresh forms seeded from the swapped data.
for (const id of [dragged.id, target.id]) {
const stale = draftForms.get(id);
if (stale) {
stale.destroy();
draftForms.delete(id);
}
}
tab.focusedLeafId = target.id; tab.focusedLeafId = target.id;
} else if (sessionId) { } else if (sessionId) {
const loc = locateSession(layout, sessionId); const loc = locateSession(layout, sessionId);
+34 -5
View File
@@ -94,6 +94,11 @@ export const FALLBACK_PRESETS: SpawnPreset[] = [
{ id: "custom", label: "Custom…", command: "" }, { 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 { export interface SpawnFormHandle {
el: HTMLFormElement; el: HTMLFormElement;
/** Remove global listeners (dir autocomplete) — call when unmounting. */ /** Remove global listeners (dir autocomplete) — call when unmounting. */
@@ -413,7 +418,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
cwd = cwdRaw.value.trim(); cwd = cwdRaw.value.trim();
branchCache = null; branchCache = null;
emit(); emit();
if (wtEnabled.checked) probeGitRoot(); if (wtEnabled.checked) scheduleGitRootProbe();
}); });
function markActiveProject(activeCwd: string | null): void { function markActiveProject(activeCwd: string | null): void {
@@ -430,9 +435,19 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
cwdRawWrap.hidden = true; cwdRawWrap.hidden = true;
markActiveProject(project.path); markActiveProject(project.path);
// The scan already knows this directory is inside a repo (#61): the // The scan already knows this directory is inside a repo (#61): the
// toggle defaults back on and no /api/gitroot round trip is needed. // toggle defaults back on and no /api/gitroot round trip is needed —
wtEnabled.checked = true; // unless the directory *is* a linked worktree itself (#91): spawning
applyProjectRepo(project.base_repo ?? project.path); // in place is the only thing that works there, so default the toggle
// off. Turning it back on by hand still tries (and fails with git's
// own message), which is deliberate (#91 non-goal).
wtEnabled.checked = !project.is_worktree;
if (project.is_worktree) {
inferredBaseRepo = null;
wtBody.hidden = true;
wtHint.hidden = true;
} else {
applyProjectRepo(project.base_repo ?? project.path);
}
emit(); emit();
// Picking a project pre-selects the command it was last used with. // Picking a project pre-selects the command it was last used with.
const match = presets.find((p) => p.command === project.last_command && p.command !== ""); const match = presets.find((p) => p.command === project.last_command && p.command !== "");
@@ -569,7 +584,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
applyProjectRepo(project.base_repo ?? project.path); applyProjectRepo(project.base_repo ?? project.path);
return; 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<void> { async function probeGitRoot(): Promise<void> {
@@ -730,6 +758,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
el: form, el: form,
destroy() { destroy() {
destroyed = true; destroyed = true;
window.clearTimeout(probeTimer);
autocomplete.destroy(); autocomplete.destroy();
form.remove(); form.remove();
}, },
+148
View File
@@ -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<string, () => void>();
const held = new Map<string, Promise<void>>();
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<void>((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<typeof stubFetch>[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<HTMLInputElement>("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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".wt-repo")!.textContent).toContain("/repo/new");
expect(
form.querySelector<HTMLInputElement>("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<HTMLElement>(".wt-repo")!.textContent).toContain("/repo/new");
expect(
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked,
).toBe(true);
});
});
+33 -2
View File
@@ -18,6 +18,7 @@ const BRANCHES = [
* its branch list is still in flight. */ * its branch list is still in flight. */
function stubFetch(opts?: { function stubFetch(opts?: {
gateBranches?: (release: () => void) => void; gateBranches?: (release: () => void) => void;
projects?: unknown[];
}): string[] { }): string[] {
const urls: string[] = []; const urls: string[] = [];
const gated = new Promise<void>((resolve) => { const gated = new Promise<void>((resolve) => {
@@ -37,7 +38,7 @@ function stubFetch(opts?: {
return new Response(JSON.stringify(BRANCHES), { status: 200 }); return new Response(JSON.stringify(BRANCHES), { status: 200 });
} }
if (url.includes("/api/projects")) { if (url.includes("/api/projects")) {
return new Response(JSON.stringify(PROJECTS), { status: 200 }); return new Response(JSON.stringify(opts?.projects ?? PROJECTS), { status: 200 });
} }
return new Response( return new Response(
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }), JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }),
@@ -100,14 +101,44 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
); );
}); });
it("defaults the worktree toggle off for a row that is itself a worktree (#91)", 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-row")
.forEach((row) => row.dataset.cwd === "/home/user/wt-91" && row.click());
await drain();
expect(handle.el.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked).toBe(
false,
);
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
expect(handle.el.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
});
it("still probes the gitroot for a free-text directory", async () => { it("still probes the gitroot for a free-text directory", async () => {
const form = await mount(); 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<HTMLElement>(".project-row.project-other")!.click(); form.querySelector<HTMLElement>(".project-row.project-other")!.click();
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!; const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
raw.value = "/opt/somewhere"; raw.value = "/opt/somewhere";
raw.dispatchEvent(new Event("input")); raw.dispatchEvent(new Event("input"));
await vi.advanceTimersByTimeAsync(250);
await drain(); await drain();
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(true); expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(true);
+3
View File
@@ -1215,6 +1215,9 @@ textarea {
/* ---------- forms ---------- */ /* ---------- forms ---------- */
.spawn-form { .spawn-form {
width: 100%;
max-width: 60ch;
margin: 0 auto;
padding: var(--sp-3); padding: var(--sp-3);
overflow: auto; overflow: auto;
display: flex; display: flex;