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 PalhasandClaude Sonnet 5 a85773cef0 fix(maestro): make shift-S resolve to the fast-forward spawn binding
crossterm attaches KeyModifiers::SHIFT to every uppercase char, so a real
Shift+S delivered Char { c: 'S', shift: true }. canonical() turned that
into "shift-S", which the default "S" binding never matched, so
FastForwardSpawn was unreachable from the keyboard.

canonical() now drops the shift- prefix when the char is already
uppercase, since the case already says "shifted". Regression test drives
an actual crossterm KeyEvent through canonicalise() and the keymap,
rather than asserting on the binding string, per #70.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 14:39:53 +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
Miguel Palhas a8d693c0a3 fix(web): bump pnpmDeps hash for lockfile change
ci / web (push) Successful in 26s
ci / rust (push) Successful in 3m8s
2026-08-24 13:57:01 +01:00
Miguel Palhas 6c1bcf042c feat(maestro): spawn v2 and the worktree lifecycle redesign
ci / web (push) Successful in 25s
ci / rust (push) Failing after 1m40s
Merges two milestones. Spawn v2 (#53-#68) replaced the spawn modal with
an inline draft pane, added project scanning, branch pickers and name
generation across the web and TUI clients.

Worktree lifecycle (#77-#89) then redesigned what the daemon does with a
worktree it created, which is what Spawn v2's reviews kept getting wrong:

- The daemon never adopts a directory it finds. A path collision is
  sidestepped with a numeric suffix instead of resolved.
- A kill is refused when the worktree holds unsaved work; ignored files
  never count. --force ends the session and RETAINS the tree.
- A session that exits on its own applies the same predicate, from the
  same function, so the two paths cannot drift.
- Retained trees are persisted in state_dir, listed by GET /api/worktrees,
  and reclaimed explicitly. Reclaim removes the directory, never the branch.
- cleanup_on_kill is gone from the wire; what survives is decided by what
  is in the tree, not by what the spawn asked for.

docs/WORKTREES.md is the written contract, with the reasoning for each
non-obvious rule.
2026-08-24 13:36:53 +01:00
12 changed files with 617 additions and 47 deletions
+35 -13
View File
@@ -261,17 +261,13 @@ impl ProjectScanner {
}
/// 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>> {
self.repos_inner(false).await
}
async fn repos_inner(&self, force: bool) -> std::sync::Arc<Vec<DiscoveredRepo>> {
let mut cache = self.cache.lock().await;
if !force {
if let Some((at, repos)) = cache.as_ref() {
if at.elapsed() < self.ttl {
return repos.clone();
}
if let Some((at, repos)) = cache.as_ref() {
if at.elapsed() < self.ttl {
return repos.clone();
}
}
let roots = self.roots.clone();
@@ -287,11 +283,11 @@ impl ProjectScanner {
repos
}
/// Rescan now, ignoring the TTL. Called by the background timer — going
/// through `repos()` would hit the cache and make most ticks no-ops,
/// leaving requests to pay for the rescan instead (#54 review).
/// One background-refresh tick: rescan only if the cached snapshot has
/// passed its TTL (#75) — the timer used to bypass it and re-walk the
/// roots every [`REFRESH_INTERVAL`].
async fn refresh(&self) {
self.repos_inner(true).await;
self.repos().await;
}
/// Spawn the slow background refresh task. Drop the returned handle to
@@ -544,6 +540,32 @@ mod tests {
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]
fn disabled_scanner_always_serves_empty() {
let scanner = ProjectScanner::disabled();
+44 -11
View File
@@ -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<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
/// 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"
);
}
+20
View File
@@ -238,6 +238,26 @@ mod tests {
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
}
/// Regression for #70: the previous canonical form of a real Shift+S
/// keypress was `"shift-S"`, which the default `"S"` binding never
/// matched. Drive the actual crossterm event, not the binding string,
/// so the dead-key bug can't come back unnoticed.
#[test]
fn real_shift_s_keypress_resolves_to_fast_forward_spawn() {
use crossterm::event::{Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
use crate::input::{canonicalise, Region};
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
let key = canonicalise(&ev, |_, _| Region::Unknown).expect("key event canonicalises");
assert_eq!(
cfg.keymap.resolve(Mode::Normal, &key.canonical()),
Some(Action::FastForwardSpawn),
);
}
#[test]
fn custom_chord_timeout_is_applied() {
let mut section = TuiSection::default();
+139 -11
View File
@@ -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<Instant>,
/// 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();
}
@@ -654,6 +678,11 @@ impl DraftPane {
let project = visible[sel].project.clone();
self.picked_cwd = Some(project.path.clone());
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
// round trip needed, exactly like the web form.
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]
fn defaults_fall_back_to_builtin_presets_with_custom_last() {
let draft = DraftPane::default();
@@ -1459,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();
@@ -1680,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();
@@ -1689,6 +1723,40 @@ mod tests {
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]
fn raw_path_targets_probe_gitroot_and_resolution_feeds_the_spec() {
let mut draft = DraftPane {
@@ -1698,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")));
@@ -1725,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);
@@ -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]
fn toggling_worktree_off_spawns_in_the_checkout_itself() {
let mut draft = DraftPane::default();
@@ -1894,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"
);
}
+20 -2
View File
@@ -117,7 +117,9 @@ impl InputKey {
if *alt {
out.push_str("alt-");
}
if *shift {
// Uppercase already implies shift; crossterm sets the flag
// on every such char, so skip the redundant prefix (#70).
if *shift && !c.is_uppercase() {
out.push_str("shift-");
}
out.push(*c);
@@ -580,12 +582,28 @@ mod tests {
#[test]
fn canonical_round_trips_simple_chars() {
for s in ["a", "ctrl-a", "ctrl-w", "alt-x", "shift-A"] {
for s in ["a", "ctrl-a", "ctrl-w", "alt-x"] {
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s, "round-trip {s:?}");
}
}
/// crossterm 0.28 attaches `KeyModifiers::SHIFT` to any uppercase char,
/// so a real Shift+S delivers `Char { c: 'S', shift: true }`. Its
/// canonical form must be the bare `"S"` a keymap binds, not `"shift-S"` —
/// the uppercase letter already says "shifted"; a redundant prefix would
/// make the default `S` binding unreachable from the keyboard (#70).
#[test]
fn shift_flag_is_redundant_for_an_already_uppercase_char() {
let k = InputKey::Char {
c: 'S',
ctrl: false,
alt: false,
shift: true,
};
assert_eq!(k.canonical(), "S");
}
#[test]
fn canonical_round_trips_specials() {
for s in [
+1 -1
View File
@@ -125,7 +125,7 @@
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
inherit (finalAttrs) pname version src;
fetcherVersion = 3;
hash = "sha256-Lhi41AtCK69SM4+7zih19+Njf7o2hyXrAV0W6VO9YxU=";
hash = "sha256-D9JdgI92vzAY8en2zGEEPd4hwOLVi3sr1joT4b17Be4=";
};
buildPhase = ''
+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");
});
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 () => {
const layout = emptyLayout();
const mv = makeMv(layout);
@@ -160,3 +202,83 @@ describe("multiview openDraft (#62)", () => {
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;
}
const target = findLeaf(tab.root, tab.focusedLeafId)!;
if (!target.sessionId && !target.draft) {
// An already-empty pane becomes the draft in place.
if (!target.sessionId) {
// 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();
const stale = draftForms.get(target.id);
if (stale) {
stale.destroy();
draftForms.delete(target.id);
}
} else {
// Split a fresh draft beside the focused pane.
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)!;
[dragged.sessionId, target.sessionId] = [target.sessionId, dragged.sessionId];
[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;
} else if (sessionId) {
const loc = locateSession(layout, sessionId);
+34 -5
View File
@@ -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 {
@@ -430,9 +435,19 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
cwdRawWrap.hidden = true;
markActiveProject(project.path);
// The scan already knows this directory is inside a repo (#61): the
// toggle defaults back on and no /api/gitroot round trip is needed.
wtEnabled.checked = true;
applyProjectRepo(project.base_repo ?? project.path);
// toggle defaults back on and no /api/gitroot round trip is needed —
// unless the directory *is* a linked worktree itself (#91): spawning
// 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();
// Picking a project pre-selects the command it was last used with.
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);
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> {
@@ -730,6 +758,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
el: form,
destroy() {
destroyed = true;
window.clearTimeout(probeTimer);
autocomplete.destroy();
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. */
function stubFetch(opts?: {
gateBranches?: (release: () => void) => void;
projects?: unknown[];
}): string[] {
const urls: string[] = [];
const gated = new Promise<void>((resolve) => {
@@ -37,7 +38,7 @@ function stubFetch(opts?: {
return new Response(JSON.stringify(BRANCHES), { status: 200 });
}
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(
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 () => {
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();
const raw = form.querySelector<HTMLInputElement>("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);
+3
View File
@@ -1215,6 +1215,9 @@ textarea {
/* ---------- forms ---------- */
.spawn-form {
width: 100%;
max-width: 60ch;
margin: 0 auto;
padding: var(--sp-3);
overflow: auto;
display: flex;