From 264332266c8a9a9e9f585d83217174c97ba189cf Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 22:42:46 +0100 Subject: [PATCH 1/2] feat(maestro): tui draft form gains a worktree section (#65) Worktree on by default over a git repository with Enter/Space to spawn in the checkout itself; base repo inferred from picked projects or a guarded /api/gitroot probe for raw paths (a miss unticks, web parity); new-branch name tracking the generated session name until touched; Left/Right switches to picking an existing branch from GET /api/branches, with held branches skipped and rendered dimmed beside their holder. No cleanup option: cleanup is unconditional daemon-side (#56). --- crates/maestro-tui/src/app.rs | 7 +- crates/maestro-tui/src/draft.rs | 846 +++++++++++++++++++++++++++++++- 2 files changed, 843 insertions(+), 10 deletions(-) diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 2279e0e..732124f 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -767,6 +767,8 @@ mod tests { }; draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work")); + draft.base_repo = Some(std::path::PathBuf::from("/srv/work")); + draft.probed_cwd = Some(std::path::PathBuf::from("/srv/work")); draft.selected_preset = draft.presets.len() - 1; // custom app.handle_draft_input(&InputKey::Tab); // preset → custom command for c in "claude".chars() { @@ -777,6 +779,9 @@ mod tests { for c in "crimson-otter".chars() { app.handle_draft_input(&InputKey::char(c)); } + app.handle_draft_input(&InputKey::Tab); // → worktree toggle + app.handle_draft_input(&InputKey::Tab); // → branch mode + app.handle_draft_input(&InputKey::Tab); // → new-branch name app.handle_draft_input(&InputKey::Tab); // → submit match app.handle_draft_input(&InputKey::Enter).as_slice() { @@ -800,7 +805,7 @@ mod tests { let mut app = make_app(); app.dispatch(Action::NewDraftPane); // Walk to Submit without picking anything. - for _ in 0..3 { + for _ in 0..4 { app.handle_draft_input(&InputKey::Tab); } assert!(app.handle_draft_input(&InputKey::Enter).is_empty()); diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index 7920c82..aab9bd8 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -10,15 +10,17 @@ //! entry), a project picker fed by [`crate::projects`] over `/api/projects` //! plus an `Other directory…` escape hatch, a generated session name from //! `/api/names` with a reroll key, and validation that reports which field is -//! missing instead of posting an empty request. The worktree section lands -//! with #65. +//! missing instead of posting an empty request. A worktree section (#65) +//! mirrors `web/src/spawnForm.ts`: on by default over a git repository, +//! branch tracking the generated name until touched, with a new-or-existing +//! branch switch fed by `/api/branches`. use std::collections::VecDeque; use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; use maestro_config::{builtin_presets, custom_preset}; -use maestro_protocol::{CreateSessionRequest, Project, SpawnPreset}; +use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; @@ -48,9 +50,27 @@ pub enum DraftField { /// Free-text path, revealed by picking `Other directory…`. CwdRaw, Name, + /// Worktree on/off for this session (#65). Enter / Space toggles. + WorktreeToggle, + /// New-vs-existing branch source; Left / Right switches. + BranchMode, + /// Free-text new-branch name, shown in `New` mode. + BranchNew, + /// Existing-branch picker, shown in `Existing` mode. + BranchExisting, Submit, } +/// Where a worktree's branch comes from (#65), matching the web form's +/// `WorktreeBranchMode`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorktreeBranchMode { + /// Create the branch (named after the generated session name by default). + New, + /// Check out a branch picked from `GET /api/branches` as-is. + Existing, +} + /// One flattened project-picker entry: a discovered repo/worktree plus what /// rendering needs to know about it. `indent` marks worktrees nested under /// their main checkout. @@ -67,6 +87,9 @@ pub struct DraftRow { pub enum DraftValidationError { MissingDirectory, MissingCommand, + MissingWorktreeRepo, + MissingNewBranch, + MissingExistingBranch, } impl std::fmt::Display for DraftValidationError { @@ -74,6 +97,11 @@ impl std::fmt::Display for DraftValidationError { match self { Self::MissingDirectory => write!(f, "Pick a working directory."), Self::MissingCommand => write!(f, "Pick a command (or fill the custom command)."), + Self::MissingWorktreeRepo => { + write!(f, "Worktree needs a directory inside a git repository.") + } + Self::MissingNewBranch => write!(f, "Worktree requires a branch name."), + Self::MissingExistingBranch => write!(f, "Pick an existing branch."), } } } @@ -105,6 +133,30 @@ pub struct DraftPane { /// clobbering it, exactly like the web form's `nameTouched`. pub name_touched: bool, needs_name_refill: bool, + /// Worktree on for this spawn (#65). Defaults on; a failed gitroot probe + /// unticks it, like the web form. + pub worktree_enabled: bool, + /// Enclosing repository of the chosen target — from the project scan for + /// picked rows, `/api/gitroot` for raw paths. `None` until known. + pub base_repo: Option, + /// The cwd the current `base_repo` answer belongs to. + pub(crate) probed_cwd: Option, + /// Bumped on every target change; answers carrying an older seq are stale. + probe_seq: u32, + needs_probe: bool, + /// 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. + pub branch: crate::text_input::TextInput, + branch_edited: bool, + pub branch_mode: WorktreeBranchMode, + /// Branches of the target repo (`GET /api/branches`), with occupancy. + pub branches: Vec, + /// Highlighted entry in `Existing` mode; always an available branch + /// unless moved by hand. + pub selected_branch: usize, + branches_for: Option, + needs_branches: bool, /// Last refusal (validation or a failed POST), rendered inside the form /// instead of dismissing it. pub error: Option, @@ -132,6 +184,19 @@ impl Default for DraftPane { name_queue: VecDeque::new(), name_touched: false, needs_name_refill: false, + worktree_enabled: true, + base_repo: None, + probed_cwd: None, + probe_seq: 0, + needs_probe: false, + repo_missing: false, + branch: crate::text_input::TextInput::default(), + branch_edited: false, + branch_mode: WorktreeBranchMode::New, + branches: Vec::new(), + selected_branch: 0, + branches_for: None, + needs_branches: false, error: None, focused_field: DraftField::Preset, home_dir: String::new(), @@ -200,6 +265,15 @@ impl DraftPane { pub fn set_name(&mut self, name: impl Into) { self.name = crate::text_input::TextInput::with_value(name.into()); + self.sync_branch_to_name(); + } + + /// The new-branch field mirrors the session name until the operator types + /// their own branch — and only in `New` mode, like the web form. + fn sync_branch_to_name(&mut self) { + if !self.branch_edited && self.branch_mode == WorktreeBranchMode::New { + self.branch = crate::text_input::TextInput::with_value(self.name.value().to_string()); + } } /// Reroll the generated name. Pulls from the local batch; when it has run @@ -217,6 +291,167 @@ impl DraftPane { std::mem::take(&mut self.needs_name_refill) } + // -- worktree (#65) ----------------------------------------------------- + + /// Whether the branch controls are shown: the toggle alone appears until + /// the target is known to be inside a repository. + fn worktree_body_visible(&self) -> bool { + self.worktree_enabled && self.base_repo.is_some() + } + + /// A target the draft already knows the repo for (picked project row). + fn note_picked_repo(&mut self, base_repo: PathBuf) { + self.base_repo = Some(base_repo); + self.repo_missing = false; + self.probe_seq += 1; + self.needs_probe = false; + 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. + 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.branches_for = None; + self.schedule_branch_load(); + } + + fn schedule_branch_load(&mut self) { + if self.worktree_body_visible() && self.branch_mode == WorktreeBranchMode::Existing { + self.needs_branches = true; + } + } + + /// Consume the gitroot-probe request: 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)> { + if !self.needs_probe { + return None; + } + self.needs_probe = false; + let cwd = self.cwd()?; + self.probe_seq += 1; + Some((self.probe_seq, cwd)) + } + + /// Apply one `/api/gitroot` answer. Stale answers — anything not matching + /// the latest seq — are dropped, so rapid retyping can't resurrect an old + /// repo. A miss unticks the toggle (web parity) and records the hint. + pub fn resolve_gitroot(&mut self, seq: u32, root: Option) { + if seq != self.probe_seq { + return; + } + self.needs_probe = false; + self.probed_cwd = self.cwd(); + match root { + Some(repo) => { + self.base_repo = Some(repo); + self.repo_missing = false; + } + None => { + self.base_repo = None; + self.repo_missing = true; + self.worktree_enabled = false; + } + } + self.schedule_branch_load(); + } + + /// Consume the branch-list request: the caller fetches + /// `GET /api/branches?path=…` for the returned cwd. + pub fn take_branch_load(&mut self) -> Option { + if !self.needs_branches { + return None; + } + self.needs_branches = false; + self.cwd() + } + + /// Store a fetched branch list; answers for anything but the current cwd + /// are stale and dropped. + pub fn set_branches(&mut self, cwd: &Path, branches: Vec) { + if self.cwd().as_deref() != Some(cwd) { + return; + } + self.branches_for = Some(cwd.to_path_buf()); + self.branches = branches; + self.selected_branch = (0..self.branches.len()) + .find(|&i| self.branches[i].worktree.is_none()) + .unwrap_or(0); + } + + pub fn set_branch_mode(&mut self, mode: WorktreeBranchMode) { + if self.branch_mode == mode { + return; + } + self.branch_mode = mode; + self.sync_branch_to_name(); + self.schedule_branch_load(); + } + + pub fn cycle_branch_mode(&mut self, delta: i32) { + let next = match (self.branch_mode, delta >= 0) { + (WorktreeBranchMode::New, true) | (WorktreeBranchMode::Existing, false) => { + WorktreeBranchMode::Existing + } + _ => WorktreeBranchMode::New, + }; + self.set_branch_mode(next); + } + + /// Enter / Space on the toggle. Re-enabling reuses a still-valid repo + /// answer instead of re-probing. + pub fn toggle_worktree(&mut self) { + self.worktree_enabled = !self.worktree_enabled; + if !self.worktree_enabled { + return; + } + let cwd = self.cwd(); + if cwd.as_deref() != self.probed_cwd.as_deref() { + self.base_repo = None; + self.repo_missing = false; + } + if self.base_repo.is_none() { + self.probe_seq += 1; + self.needs_probe = cwd.is_some(); + } + self.schedule_branch_load(); + } + + fn available_branch(&self, i: usize) -> bool { + self.branches.get(i).is_some_and(|b| b.worktree.is_none()) + } + + /// Move the existing-branch highlight, skipping branches another worktree + /// holds (git refuses to check them out twice). + pub fn move_branch_selection(&mut self, delta: i32) { + let available: Vec = (0..self.branches.len()) + .filter(|&i| self.available_branch(i)) + .collect(); + if available.is_empty() { + return; + } + let cur = available + .iter() + .position(|&i| i == self.selected_branch) + .unwrap_or(0); + let len = available.len() as i32; + self.selected_branch = available[((cur as i32) + delta).rem_euclid(len) as usize]; + } + + /// The highlighted branch, only when no other worktree holds it. + pub fn picked_branch(&self) -> Option<&BranchInfo> { + self.branches + .get(self.selected_branch) + .filter(|b| b.worktree.is_none()) + } + pub fn is_custom(&self) -> bool { self.presets .get(self.selected_preset) @@ -260,15 +495,57 @@ impl DraftPane { serde_json::Value::String(name.to_string()), ); } + let worktree = self.build_worktree()?; Ok(CreateSessionRequest { cwd, command: command.to_string(), env: Default::default(), metadata, - worktree: None, + worktree, }) } + /// The worktree half of the request (#65): `None` while toggled off; + /// otherwise the inferred base repo plus a branch that is either created + /// (`New`) or checked out as-is (`Existing`). Cleanup is unconditional + /// daemon-side (#56), so no option for it here. + fn build_worktree(&self) -> Result, DraftValidationError> { + if !self.worktree_enabled { + return Ok(None); + } + let Some(base_repo) = self.base_repo.clone() else { + return Err(DraftValidationError::MissingWorktreeRepo); + }; + let spec = match self.branch_mode { + WorktreeBranchMode::New => { + let branch = self.branch.value().trim(); + if branch.is_empty() { + return Err(DraftValidationError::MissingNewBranch); + } + WorktreeSpec { + base_repo, + branch: branch.to_string(), + cleanup_on_kill: true, + create_branch: true, + } + } + WorktreeBranchMode::Existing => { + let name = self + .picked_branch() + .ok_or(DraftValidationError::MissingExistingBranch)? + .name + .clone(); + WorktreeSpec { + base_repo, + branch: name, + cleanup_on_kill: true, + create_branch: false, + } + } + }; + Ok(Some(spec)) + } + fn field_order(&self) -> Vec { let mut order = vec![DraftField::Preset]; if self.is_custom() { @@ -278,7 +555,16 @@ impl DraftPane { if self.cwd_raw_visible { order.push(DraftField::CwdRaw); } - order.extend([DraftField::Name, DraftField::Submit]); + order.push(DraftField::Name); + order.push(DraftField::WorktreeToggle); + if self.worktree_body_visible() { + order.push(DraftField::BranchMode); + order.push(match self.branch_mode { + WorktreeBranchMode::New => DraftField::BranchNew, + WorktreeBranchMode::Existing => DraftField::BranchExisting, + }); + } + order.extend([DraftField::Submit]); order } @@ -346,6 +632,12 @@ impl DraftPane { let project = visible[sel].project.clone(); self.picked_cwd = Some(project.path.clone()); self.cwd_raw_visible = false; + // The scan already knows this directory's repo (#61): no probe + // round trip needed, exactly like the web form. + let repo = project + .base_repo + .clone() + .unwrap_or_else(|| project.path.clone()); if let Some(cmd) = project.last_command.as_deref() { if !cmd.is_empty() { if let Some(idx) = self.presets.iter().position(|p| p.command == cmd) { @@ -353,6 +645,7 @@ impl DraftPane { } } } + self.note_picked_repo(repo); return; } let seed = self.cwd().unwrap_or_else(|| { @@ -366,6 +659,7 @@ impl DraftPane { self.cwd_raw_visible = true; self.cwd_raw = crate::text_input::TextInput::with_value(seed.to_string_lossy()); self.focused_field = DraftField::CwdRaw; + self.note_unknown_repo(); } /// Handle one keystroke while this draft owns input (Insert mode, pane @@ -388,11 +682,25 @@ impl DraftPane { self.select_entry(); DraftInputResult::Consumed } + DraftField::WorktreeToggle => { + self.toggle_worktree(); + DraftInputResult::Consumed + } + // The existing-branch selection is live; Enter just confirms. + DraftField::BranchExisting => DraftInputResult::Consumed, _ => { self.next_field(); DraftInputResult::Consumed } }, + InputKey::Char { + c: ' ', + ctrl: false, + .. + } if self.focused_field == DraftField::WorktreeToggle => { + self.toggle_worktree(); + DraftInputResult::Consumed + } InputKey::Up if self.focused_field == DraftField::DirPicker => { self.move_selection(-1); DraftInputResult::Consumed @@ -409,6 +717,22 @@ impl DraftPane { self.cycle_preset(1); DraftInputResult::Consumed } + InputKey::Left if self.focused_field == DraftField::BranchMode => { + self.cycle_branch_mode(-1); + DraftInputResult::Consumed + } + InputKey::Right if self.focused_field == DraftField::BranchMode => { + self.cycle_branch_mode(1); + DraftInputResult::Consumed + } + InputKey::Up if self.focused_field == DraftField::BranchExisting => { + self.move_branch_selection(-1); + DraftInputResult::Consumed + } + InputKey::Down if self.focused_field == DraftField::BranchExisting => { + self.move_branch_selection(1); + DraftInputResult::Consumed + } InputKey::Char { c: 'r', ctrl: true, .. } if self.focused_field == DraftField::Name => { @@ -425,11 +749,22 @@ impl DraftPane { self.filter.handle_key(other); self.highlighted = 0; } - DraftField::CwdRaw => self.cwd_raw.handle_key(other), + DraftField::CwdRaw => { + self.cwd_raw.handle_key(other); + // The target moved: the old repo answer no longer + // applies and a fresh probe is needed. + self.note_unknown_repo(); + } DraftField::Name => { self.name.handle_key(other); self.name_touched = true; + self.sync_branch_to_name(); } + DraftField::BranchNew => { + self.branch.handle_key(other); + self.branch_edited = true; + } + _ => {} } DraftInputResult::Consumed } @@ -446,9 +781,14 @@ impl DraftPane { let show_custom = self.is_custom(); let show_raw = self.cwd_raw_visible; - let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* spawn */ + let show_wt_body = self.worktree_body_visible(); + // Up to three branch rows stay visible around the selection; held + // branches need their reason on screen, so they aren't collapsed. + let branch_rows = self.branches.len().clamp(1, 3) as u16; + let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* worktree */ + 1 /* spawn */ + u16::from(show_custom) + u16::from(show_raw) + + u16::from(show_wt_body) * (1 + if self.branch_mode == WorktreeBranchMode::New { 1 } else { branch_rows }) + u16::from(self.error.is_some()); let list_capacity = (inner.height.saturating_sub(fixed)).max(1) as usize; @@ -461,7 +801,15 @@ impl DraftPane { if show_raw { constraints.push(Constraint::Length(1)); } - constraints.push(Constraint::Length(1)); + constraints.push(Constraint::Length(1)); // name + constraints.push(Constraint::Length(1)); // worktree toggle + if show_wt_body { + constraints.push(Constraint::Length(1)); // branch mode + match self.branch_mode { + WorktreeBranchMode::New => constraints.push(Constraint::Length(1)), + WorktreeBranchMode::Existing => constraints.push(Constraint::Length(branch_rows)), + } + } constraints.push(Constraint::Length(1)); if self.error.is_some() { constraints.push(Constraint::Length(1)); @@ -631,6 +979,135 @@ impl DraftPane { }, ); + // -- worktree (#65) ----------------------------------------------------- + let wt_row = next_row(); + let hint = if self.cwd().is_none() { + Some("choose a directory first") + } else if self.repo_missing { + Some("not inside a git repository") + } else if !self.worktree_enabled { + Some("off — spawns in the checkout itself") + } else { + None + }; + let mut tiles = vec![Span::raw(format!("{: { + let row = next_row(); + draw_gutter(f, row, "br:"); + let col = + draw_text_field(f, &self.branch, row, field_style(DraftField::BranchNew)); + if self.focused_field == DraftField::BranchNew { + cursor = Some((col, row.y)); + } + } + WorktreeBranchMode::Existing => { + let len = self.branches.len(); + let capacity = branch_rows as usize; + let sel = self.selected_branch.min(len.saturating_sub(1)); + let start = if sel >= capacity { + sel + 1 - capacity + } else { + 0 + }; + let name_width = self + .branches + .iter() + .map(|b| b.name.chars().count()) + .max() + .unwrap_or(0) + .min(20); + let list_row = next_row(); + for i in start..(start + capacity).min(len.max(1)) { + let y = list_row.y + (i - start) as u16; + if y >= list_row.y + list_row.height { + break; + } + let line = match self.branches.get(i) { + Some(b) => { + let held = b.worktree.is_some(); + let mut base = Style::default(); + if held { + base = base.add_modifier(Modifier::DIM); + } + if pane_focused + && self.focused_field == DraftField::BranchExisting + && i == sel + { + base = base.add_modifier(Modifier::REVERSED); + } + let reason = b + .worktree + .as_ref() + .map(|w| { + format!(" — held by {}", abbreviate_path(w, &self.home_dir)) + }) + .unwrap_or_default(); + Line::from(vec![ + Span::styled(if i == sel { ">" } else { " " }, base), + Span::raw(" "), + Span::styled(format!("{: Line::from(Span::styled( + "no local branches found", + Style::default().add_modifier(Modifier::DIM), + )), + }; + f.render_widget( + Paragraph::new(line), + Rect { + x: list_row.x + GUTTER as u16, + y, + width: list_row.width.saturating_sub(GUTTER as u16), + height: 1, + }, + ); + } + } + } + } + // -- submit ------------------------------------------------------------------ let spawn_row = next_row(); f.render_widget( @@ -754,6 +1231,19 @@ mod tests { } } + fn repo_project( + name: &str, + path: &str, + base: &str, + days_ago: i64, + cmd: Option<&str>, + ) -> Project { + Project { + base_repo: Some(PathBuf::from(base)), + ..project(name, path, days_ago, cmd) + } + } + fn typed(draft: &mut DraftPane, s: &str) { for c in s.chars() { draft.on_input(&InputKey::char(c)); @@ -830,6 +1320,9 @@ mod tests { fn custom_command_flows_into_the_request() { let mut draft = DraftPane::default(); draft.picked_cwd = Some(PathBuf::from("/srv/work")); + draft.base_repo = Some(PathBuf::from("/srv/work")); + draft.probed_cwd = Some(PathBuf::from("/srv/work")); + draft.worktree_enabled = false; // under test: command + cwd only draft.selected_preset = draft.presets.len() - 1; draft.custom_command = crate::text_input::TextInput::with_value("bash -l"); let req = draft.build_request().unwrap(); @@ -943,8 +1436,16 @@ mod tests { assert_eq!(draft.cwd_raw.value(), "/home/u"); 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(); + draft.resolve_gitroot(seq, Some(PathBuf::from("/home/u/opt/tooling"))); + draft.seed_names(vec!["amber-falcon".into()]); let req = draft.build_request().unwrap(); assert_eq!(req.cwd, PathBuf::from("/home/u/opt/tooling")); + assert_eq!( + req.worktree.unwrap().base_repo, + PathBuf::from("/home/u/opt/tooling") + ); } #[test] @@ -970,6 +1471,8 @@ mod tests { assert_eq!(draft.name.value(), "crimson-otter"); draft.picked_cwd = Some(PathBuf::from("/w")); + draft.base_repo = Some(PathBuf::from("/w")); + draft.probed_cwd = Some(PathBuf::from("/w")); let req = draft.build_request().unwrap(); assert_eq!( req.metadata.get("name").and_then(|v| v.as_str()), @@ -1014,10 +1517,15 @@ mod tests { #[test] fn an_empty_name_sends_no_metadata_at_all() { - let draft = DraftPane { + let mut draft = DraftPane { picked_cwd: Some(PathBuf::from("/w")), ..DraftPane::default() }; + draft.base_repo = Some(PathBuf::from("/w")); + draft.probed_cwd = Some(PathBuf::from("/w")); + // No names seeded: the empty new-branch field would refuse, so spawn + // in the checkout to isolate the metadata assertion. + draft.worktree_enabled = false; let req = draft.build_request().unwrap(); assert!(req.metadata.is_empty()); } @@ -1129,6 +1637,326 @@ mod tests { ); } + #[test] + fn worktree_is_on_by_default_and_builds_a_create_branch_spec() { + let mut draft = DraftPane::default(); + assert!(draft.worktree_enabled, "on before any directory is chosen"); + draft.set_projects( + vec![repo_project( + "maestro", + "/repos/maestro", + "/repos/maestro", + 1, + None, + )], + Utc::now(), + ); + draft.focused_field = DraftField::DirPicker; + draft.on_input(&InputKey::Enter); // picks the repo row + assert_eq!( + draft.base_repo.as_deref(), + Some(Path::new("/repos/maestro")), + "a picked project resolves its base repo without a probe" + ); + assert!(draft.take_gitroot_probe().is_none()); + draft.seed_names(vec!["crimson-otter".into()]); + + let wt = draft.build_request().unwrap().worktree.unwrap(); + assert_eq!(wt.base_repo, PathBuf::from("/repos/maestro")); + assert_eq!(wt.branch, "crimson-otter", "branch defaults to the name"); + assert!(wt.create_branch); + assert!(wt.cleanup_on_kill); + } + + #[test] + fn raw_path_targets_probe_gitroot_and_resolution_feeds_the_spec() { + let mut draft = DraftPane { + focused_field: DraftField::DirPicker, + ..DraftPane::default() + }; + draft.on_input(&InputKey::Enter); // no projects → Other directory… + typed(&mut draft, "/opt/tool"); + + let (seq, path) = draft.take_gitroot_probe().expect("probe requested"); + assert_eq!(path, PathBuf::from("/opt/tool")); + assert!(draft.take_gitroot_probe().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(); + assert_ne!(seq2, seq); + // …so a late answer to the old one must be ignored. + draft.resolve_gitroot(seq, Some(PathBuf::from("/somewhere/else"))); + assert!(draft.base_repo.is_none()); + + draft.resolve_gitroot(seq2, Some(PathBuf::from("/opt/tool"))); + assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/opt/tool"))); + draft.seed_names(vec!["amber-falcon".into()]); + let req = draft.build_request().unwrap(); + assert_eq!(req.worktree.unwrap().base_repo, PathBuf::from("/opt/tool")); + } + + #[test] + fn a_failed_gitroot_probe_unticks_the_toggle_like_the_web_form() { + let mut draft = DraftPane { + focused_field: DraftField::DirPicker, + ..DraftPane::default() + }; + draft.on_input(&InputKey::Enter); + typed(&mut draft, "/not/a/repo"); + let (seq, _) = draft.take_gitroot_probe().unwrap(); + + draft.resolve_gitroot(seq, None); + assert!(!draft.worktree_enabled); + assert!( + !draft.build_request().unwrap().worktree.is_some(), + "spawns in the checkout itself" + ); + + // A stale success must not re-enable it. + draft.resolve_gitroot(seq, Some(PathBuf::from("/not/a/repo"))); + assert!(!draft.worktree_enabled); + } + + #[test] + fn the_new_branch_tracks_the_session_name_until_touched() { + let mut draft = DraftPane::default(); + draft.seed_names(vec!["crimson-otter".into(), "amber-falcon".into()]); + assert_eq!(draft.branch.value(), "crimson-otter"); + draft.reroll(); + assert_eq!(draft.branch.value(), "amber-falcon", "rerolls carry over"); + + draft.focused_field = DraftField::BranchNew; + typed(&mut draft, "-mine"); + assert_eq!(draft.branch.value(), "amber-falcon-mine"); + draft.seed_names(vec!["late-batch".into()]); + assert_eq!( + draft.branch.value(), + "amber-falcon-mine", + "an edited branch stops tracking the name" + ); + } + + #[test] + fn existing_mode_checks_out_a_picked_branch_without_creating_it() { + let mut draft = DraftPane::default(); + draft.set_projects( + vec![repo_project( + "maestro", + "/repos/maestro", + "/repos/maestro", + 1, + None, + )], + Utc::now(), + ); + draft.focused_field = DraftField::DirPicker; + draft.on_input(&InputKey::Enter); + draft.focused_field = DraftField::BranchMode; + draft.on_input(&InputKey::Right); + assert_eq!(draft.branch_mode, WorktreeBranchMode::Existing); + assert!( + draft.take_branch_load().is_some(), + "switching modes requests the branch list" + ); + + draft.set_branches( + Path::new("/repos/maestro"), + vec![ + BranchInfo { + name: "main".into(), + is_head: true, + worktree: None, + }, + BranchInfo { + name: "held".into(), + is_head: false, + worktree: Some(PathBuf::from("/repos/maestro-held")), + }, + BranchInfo { + name: "feature".into(), + is_head: false, + worktree: None, + }, + ], + ); + assert_eq!( + draft.branches[draft.selected_branch].name, "main", + "selection lands on an available branch" + ); + + draft.focused_field = DraftField::BranchExisting; + draft.on_input(&InputKey::Down); + assert_eq!( + draft.branches[draft.selected_branch].name, "feature", + "moving skips branches another worktree holds" + ); + + let wt = draft.build_request().unwrap().worktree.unwrap(); + assert_eq!(wt.branch, "feature"); + assert!(!wt.create_branch, "existing picks are checked out as-is"); + } + + #[test] + fn held_branches_render_unavailable_with_their_holder_as_the_reason() { + let mut draft = DraftPane::default(); + draft.set_projects( + vec![repo_project( + "maestro", + "/repos/maestro", + "/repos/maestro", + 1, + None, + )], + Utc::now(), + ); + draft.picked_cwd = Some(PathBuf::from("/repos/maestro")); + draft.base_repo = Some(PathBuf::from("/repos/maestro")); + draft.probed_cwd = Some(PathBuf::from("/repos/maestro")); + draft.focused_field = DraftField::BranchMode; + draft.on_input(&InputKey::Right); + let _ = draft.take_branch_load(); + draft.set_branches( + Path::new("/repos/maestro"), + vec![ + BranchInfo { + name: "held".into(), + is_head: false, + worktree: Some(PathBuf::from("/home/u/wt")), + }, + BranchInfo { + name: "free".into(), + is_head: false, + worktree: None, + }, + ], + ); + + let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true)); + assert!( + rendered.contains("held by"), + "the holder path is the reason:\n{rendered}" + ); + assert!(rendered.contains("free"), "{rendered}"); + + // A held branch is never submittable, even highlighted directly. + draft.selected_branch = 0; + assert_eq!( + draft.build_request(), + Err(DraftValidationError::MissingExistingBranch) + ); + } + + #[test] + fn toggling_worktree_off_spawns_in_the_checkout_itself() { + let mut draft = DraftPane::default(); + draft.set_projects( + vec![repo_project( + "maestro", + "/repos/maestro", + "/repos/maestro", + 1, + None, + )], + Utc::now(), + ); + draft.picked_cwd = Some(PathBuf::from("/repos/maestro")); + draft.base_repo = Some(PathBuf::from("/repos/maestro")); + draft.probed_cwd = Some(PathBuf::from("/repos/maestro")); + + draft.focused_field = DraftField::WorktreeToggle; + draft.on_input(&InputKey::Enter); + assert!(!draft.worktree_enabled); + assert!(draft.build_request().unwrap().worktree.is_none()); + + draft.on_input(&InputKey::Enter); + assert!(draft.worktree_enabled); + assert!( + draft.take_gitroot_probe().is_none(), + "re-enabling reuses the known repo for an unchanged target" + ); + } + + #[test] + fn worktree_validation_names_the_problem() { + let mut draft = DraftPane { + cwd_raw: crate::text_input::TextInput::with_value("/opt/tool"), + picked_cwd: Some(PathBuf::from("/opt/tool")), + ..DraftPane::default() + }; + assert_eq!( + draft.build_request(), + Err(DraftValidationError::MissingWorktreeRepo) + ); + assert_eq!( + DraftValidationError::MissingWorktreeRepo.to_string(), + "Worktree needs a directory inside a git repository." + ); + + draft.base_repo = Some(PathBuf::from("/opt/tool")); + assert_eq!( + draft.build_request(), + Err(DraftValidationError::MissingNewBranch), + "no names seeded yet, so the new-branch field is empty" + ); + assert_eq!( + DraftValidationError::MissingNewBranch.to_string(), + "Worktree requires a branch name." + ); + + draft.branch_mode = WorktreeBranchMode::Existing; + assert_eq!( + draft.build_request(), + Err(DraftValidationError::MissingExistingBranch) + ); + assert_eq!( + DraftValidationError::MissingExistingBranch.to_string(), + "Pick an existing branch." + ); + } + + #[test] + fn render_shows_the_worktree_section() { + let mut draft = DraftPane { + home_dir: "/home/u".into(), + ..DraftPane::default() + }; + draft.set_projects( + vec![project("maestro", "/home/u/tea/maestro", 1, Some("claude"))], + Utc::now(), + ); + draft.seed_names(vec!["crimson-otter".into()]); + let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true)); + for s in ["wt:", "[x] worktree"] { + assert!(rendered.contains(s), "missing {s:?}:\n{rendered}"); + } + assert!( + rendered.contains("choose a directory first"), + "hint while no target:\n{rendered}" + ); + assert!( + !rendered.contains("[new] [existing]"), + "body hidden until the repo is known:\n{rendered}" + ); + + draft.picked_cwd = Some(PathBuf::from("/home/u/tea/maestro")); + draft.base_repo = Some(PathBuf::from("/home/u/tea/maestro")); + draft.probed_cwd = Some(PathBuf::from("/home/u/tea/maestro")); + let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true)); + for s in ["[new] [existing]", "br:", "crimson-otter"] { + assert!(rendered.contains(s), "missing {s:?}:\n{rendered}"); + } + + draft.focused_field = DraftField::WorktreeToggle; + draft.on_input(&InputKey::Enter); + let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true)); + assert!(rendered.contains("[ ] worktree"), "{rendered}"); + assert!( + !rendered.contains("[new] [existing]"), + "body hidden while off:\n{rendered}" + ); + } + #[test] fn abbreviate_path_matches_the_web_helpers() { assert_eq!( From 67d17f48d0f792057e03ba6bc1421ce6ff64ff8f Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 22:45:49 +0100 Subject: [PATCH 2/2] feat(maestro): app loop serves the draft's worktree fetches (#65) Two new effects: ProbeGitRoot resolves the draft target's base repo via /api/gitroot (seq-guarded, a miss unticks the toggle) and LoadBranches fills the existing-branch picker from /api/branches, both applied back to the tab that requested them. Branch loads triggered by a successful probe are served inline so perform() stays recursion-free. --- crates/maestro-tui/src/app.rs | 136 +++++++++++++++++++++++++++++++++- 1 file changed, 135 insertions(+), 1 deletion(-) diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 732124f..076954a 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -12,12 +12,13 @@ use std::collections::{HashMap, HashSet}; use std::io::Stdout; +use std::path::PathBuf; use std::time::Duration; use chrono::Utc; use crossterm::event::{Event as CtEvent, EventStream}; use futures_util::StreamExt; -use maestro_protocol::{routes, CreateSessionRequest, DaemonClient, NameBatch}; +use maestro_protocol::{routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch}; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; use uuid::Uuid; @@ -88,6 +89,15 @@ pub enum Effect { LoadDraft(usize), /// A reroll drained the draft's local name batch (#64). RefillNames(usize), + /// The draft's target changed: probe `/api/gitroot` so the worktree + /// section knows its base repo (#65). `seq` guards stale answers. + ProbeGitRoot { + tab: usize, + seq: u32, + path: PathBuf, + }, + /// Fetch the draft target's branches from `/api/branches` (#65). + LoadBranches(usize), } impl App { @@ -193,6 +203,10 @@ impl App { Effect::Kill(id) => self.submit_kill(id).await, Effect::LoadDraft(tab_idx) => self.load_draft_data(tab_idx).await, Effect::RefillNames(tab_idx) => self.refill_names(tab_idx).await, + Effect::ProbeGitRoot { tab, seq, path } => { + self.probe_git_root(tab, seq, path).await + } + Effect::LoadBranches(tab) => self.load_branches(tab).await, Effect::Forward { id, bytes } => { if let Some(handle) = self.emulators.get(id) { let _ = handle.tx.send(SessionInput::Bytes(bytes)).await; @@ -349,6 +363,16 @@ impl App { if draft.take_name_refill() { effects.push(Effect::RefillNames(tab_idx)); } + if let Some((seq, path)) = draft.take_gitroot_probe() { + effects.push(Effect::ProbeGitRoot { + tab: tab_idx, + seq, + path, + }); + } + if draft.take_branch_load().is_some() { + effects.push(Effect::LoadBranches(tab_idx)); + } effects } @@ -422,6 +446,72 @@ impl App { } } + /// Resolve the draft target's enclosing repository via `/api/gitroot` + /// (#65). A miss unticks the worktree toggle inside `resolve_gitroot`; + /// a hit may schedule a branch load (Existing mode), served inline. + async fn probe_git_root(&mut self, tab_idx: usize, seq: u32, path: PathBuf) { + let root = self + .daemon + .git_root(&path.to_string_lossy()) + .await + .ok() + .and_then(|g| g.root); + let Some(PaneRef::Draft(draft)) = self + .state + .tabs + .get_mut(tab_idx) + .and_then(|t| t.pane.as_mut()) + else { + return; + }; + draft.resolve_gitroot(seq, root); + if draft.take_branch_load().is_some() { + self.load_branches(tab_idx).await; + } + } + + /// One `/api/branches` batch for the draft's current cwd (#65). Same + /// reqwest route as [`Self::fetch_names`]: no typed client call exists. + async fn load_branches(&mut self, tab_idx: usize) { + let Some(path) = self + .state + .tabs + .get(tab_idx) + .and_then(|t| t.pane.as_ref()) + .and_then(|p| match p { + PaneRef::Draft(d) => d.cwd(), + _ => None, + }) + else { + return; + }; + let url = format!( + "{}{}", + maestro_protocol::routes::normalize_base(self.daemon.base()), + routes::BRANCHES + ); + let resp = self + .http + .get(url) + .query(&[(routes::PATH_QUERY, path.to_string_lossy().as_ref())]) + .send() + .await; + // On failure the picker stays empty; "new" mode still works. + let branches = match resp { + Ok(resp) => resp.json::>().await.unwrap_or_default(), + Err(_) => return, + }; + let Some(PaneRef::Draft(draft)) = self + .state + .tabs + .get_mut(tab_idx) + .and_then(|t| t.pane.as_mut()) + else { + return; + }; + draft.set_branches(&path, branches); + } + async fn submit_spawn(&mut self, req: CreateSessionRequest) { match self.daemon.create_session(&req).await { Ok(session) => { @@ -613,6 +703,7 @@ fn render_help_lines(cfg: &TuiConfig) -> Vec { mod tests { use super::*; use crate::action::Action; + use crate::draft::DraftField; use crate::keymap::Mode; use maestro_config::TuiSection; @@ -798,6 +889,49 @@ mod tests { } } + /// Editing the raw path with the worktree toggle on schedules a + /// `/api/gitroot` probe so the section learns its base repo (#65). + #[test] + fn typing_a_raw_path_schedules_a_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 { + panic!("expected a draft pane"); + }; + draft.cwd_raw_visible = true; + draft.focused_field = DraftField::CwdRaw; + + let effects = app.handle_draft_input(&InputKey::char('/')); + assert!( + matches!( + effects.as_slice(), + [Effect::ProbeGitRoot { tab: 0, seq: 2, .. }] + ), + "{effects:?}" + ); + } + + /// Switching the worktree branch source to Existing schedules one + /// `/api/branches` load for the current target (#65). + #[test] + fn switching_to_existing_branch_mode_schedules_a_branch_load() { + let mut app = make_app(); + app.dispatch(Action::NewDraftPane); + let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else { + panic!("expected a draft pane"); + }; + draft.picked_cwd = Some(std::path::PathBuf::from("/srv/repo")); + draft.base_repo = Some(std::path::PathBuf::from("/srv/repo")); + draft.probed_cwd = Some(std::path::PathBuf::from("/srv/repo")); + draft.focused_field = DraftField::BranchMode; + + let effects = app.handle_draft_input(&InputKey::Right); + assert!( + matches!(effects.as_slice(), [Effect::LoadBranches(0)]), + "{effects:?}" + ); + } + /// Submitting with no directory names the missing field inside the form /// and posts nothing (#64). #[test]