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]