diff --git a/crates/maestro-tui/src/fast_forward.rs b/crates/maestro-tui/src/fast_forward.rs new file mode 100644 index 0000000..4b8abb3 --- /dev/null +++ b/crates/maestro-tui/src/fast_forward.rs @@ -0,0 +1,283 @@ +//! Fast-forward spawn (#67): one keystroke, every default resolved, no form. +//! +//! The defaults mirror what a draft pane would settle on after the operator +//! picked the most recent project and kept their hands off everything else: +//! its last command (or the first non-custom preset), a freshly generated +//! name, and a worktree with a branch named after that name. Any default +//! that cannot be resolved — no projects yet, no command, no generated +//! name — downgrades the keystroke to a draft pane pre-filled with whatever +//! did resolve ([`FastForwardDefaults::apply_to_draft`]). + +use std::path::PathBuf; + +use chrono::{DateTime, Utc}; +use maestro_protocol::{CreateSessionRequest, Project, SpawnPreset, WorktreeSpec}; + +use crate::draft::DraftPane; + +/// What one fast-forward keystroke resolved. Every field optional: `None` +/// anywhere means "open a draft instead of spawning". +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FastForwardDefaults { + pub project: Option, + pub command: Option, + pub name: Option, +} + +/// Resolve the fast-forward defaults from the daemon's answers. `name` is +/// `None` when `/api/names` yielded nothing; `presets` may be empty (the +/// caller falls back to the built-ins it already has). +pub fn resolve( + projects: &[Project], + presets: &[SpawnPreset], + name: Option, + now: DateTime, +) -> FastForwardDefaults { + let project = crate::projects::sort_projects(projects.to_vec(), now) + .into_iter() + .next(); + let command = project + .as_ref() + .and_then(|p| p.last_command.clone()) + .filter(|c| !c.trim().is_empty()) + .or_else(|| { + presets + .iter() + .find(|p| !p.command.is_empty()) + .map(|p| p.command.clone()) + }); + FastForwardDefaults { + project, + command, + name, + } +} + +impl FastForwardDefaults { + /// The spawn request when *every* default resolved: worktree on, new + /// branch named after the generated session name, base repo from the + /// scan (`None` means the project is itself a main checkout). + pub fn build_request(&self) -> Option { + let project = self.project.as_ref()?; + let command = self.command.as_deref()?.trim(); + let name = self.name.as_deref()?; + if command.is_empty() || name.is_empty() { + return None; + } + let mut metadata = std::collections::HashMap::new(); + metadata.insert( + "name".to_string(), + serde_json::Value::String(name.to_string()), + ); + Some(CreateSessionRequest { + cwd: project.path.clone(), + command: command.to_string(), + env: Default::default(), + metadata, + worktree: Some(WorktreeSpec { + base_repo: project + .base_repo + .clone() + .unwrap_or_else(|| project.path.clone()), + branch: name.to_string(), + cleanup_on_kill: true, + create_branch: true, + }), + }) + } + + /// Seed a fresh draft pane with whatever did resolve — the fallback when + /// [`Self::build_request`] is `None`. The operator fills in the rest. + pub fn apply_to_draft(&self, draft: &mut DraftPane) { + if let Some(project) = &self.project { + draft.picked_cwd = Some(PathBuf::from(&project.path)); + // The scan already knows this directory's repo (#61): no probe + // round trip needed, exactly like picking the row by hand. + draft.base_repo = Some( + project + .base_repo + .clone() + .unwrap_or_else(|| project.path.clone()), + ); + draft.probed_cwd = Some(PathBuf::from(&project.path)); + } + if let Some(command) = &self.command { + if let Some(idx) = draft.presets.iter().position(|p| &p.command == command) { + draft.selected_preset = idx; + } else if !command.trim().is_empty() { + // A last_command no preset advertises lands in custom free text. + draft.selected_preset = draft.presets.len().saturating_sub(1); + draft.custom_command = crate::text_input::TextInput::with_value(command); + } + } + if let Some(name) = &self.name { + draft.set_name(name.clone()); + // Seeded, not generated: a later refill must not clobber it. + draft.name_touched = true; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeDelta; + + fn project(path: &str, days_ago: i64, cmd: Option<&str>) -> Project { + Project { + path: PathBuf::from(path), + name: path.rsplit('/').next().unwrap_or(path).into(), + is_worktree: false, + base_repo: None, + last_activity: Some(Utc::now() - TimeDelta::days(days_ago)), + last_command: cmd.map(String::from), + } + } + + fn repo_project(path: &str, base: &str, days_ago: i64, cmd: Option<&str>) -> Project { + Project { + base_repo: Some(PathBuf::from(base)), + ..project(path, days_ago, cmd) + } + } + + fn preset(id: &str, command: &str) -> SpawnPreset { + SpawnPreset { + id: id.into(), + label: id.into(), + command: command.into(), + } + } + + #[test] + fn resolves_every_default_from_the_most_recent_project() { + let projects = vec![ + project("/repos/old", 30, Some("zsh")), + repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")), + ]; + let presets = vec![preset("aider", "aider"), preset("custom", "")]; + let r = resolve(&projects, &presets, Some("amber-falcon".into()), Utc::now()); + assert_eq!( + r.project.as_ref().unwrap().path, + PathBuf::from("/repos/fresh") + ); + assert_eq!(r.command.as_deref(), Some("claude")); + assert_eq!(r.name.as_deref(), Some("amber-falcon")); + + let req = r.build_request().expect("all defaults resolved"); + assert_eq!(req.cwd, PathBuf::from("/repos/fresh")); + assert_eq!(req.command, "claude"); + assert_eq!( + req.metadata.get("name").and_then(|v| v.as_str()), + Some("amber-falcon") + ); + let wt = req.worktree.expect("worktree on"); + assert_eq!(wt.base_repo, PathBuf::from("/repos/fresh")); + assert_eq!(wt.branch, "amber-falcon"); + assert!(wt.create_branch, "fresh branch named after the session"); + } + + #[test] + fn worktree_of_the_most_recent_project_targets_its_base_repo() { + let projects = vec![repo_project("/repos/main/wt-x", "/repos/main", 0, None)]; + let r = resolve( + &projects, + &[preset("zsh", "zsh")], + Some("s".into()), + Utc::now(), + ); + let wt = r.build_request().unwrap().worktree.unwrap(); + assert_eq!(wt.base_repo, PathBuf::from("/repos/main")); + } + + #[test] + fn missing_last_command_falls_back_to_the_first_non_custom_preset() { + let projects = vec![project("/repos/p", 0, None)]; + let presets = vec![preset("custom", ""), preset("claude", "claude")]; + let r = resolve(&projects, &presets, Some("n".into()), Utc::now()); + assert_eq!(r.command.as_deref(), Some("claude")); + } + + #[test] + fn an_empty_last_command_is_no_command_at_all() { + let projects = vec![project("/repos/p", 0, Some(""))]; + let presets = vec![preset("custom", "")]; + let r = resolve(&projects, &presets, Some("n".into()), Utc::now()); + assert_eq!(r.command, None); + assert!(r.build_request().is_none()); + } + + #[test] + fn nothing_resolves_when_no_projects_are_known_yet() { + let r = resolve( + &[], + &[preset("claude", "claude")], + Some("n".into()), + Utc::now(), + ); + assert_eq!(r.project, None); + // No project → no directory → no request, even with command + name. + assert!(r.build_request().is_none()); + } + + #[test] + fn a_missing_generated_name_downgrades_to_a_draft() { + let projects = vec![project("/repos/p", 0, Some("claude"))]; + let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now()); + assert!(r.build_request().is_none(), "no name → no immediate spawn"); + } + + #[test] + fn unresolvable_defaults_still_seed_the_draft_with_what_resolved() { + let projects = vec![repo_project("/repos/p", "/repos/p", 0, Some("aider"))]; + let r = resolve(&projects, &[preset("aider", "aider")], None, Utc::now()); + + let mut draft = DraftPane::default(); + r.apply_to_draft(&mut draft); + assert_eq!(draft.cwd(), Some(PathBuf::from("/repos/p"))); + assert_eq!(draft.base_repo, Some(PathBuf::from("/repos/p"))); + assert_eq!(draft.command(), "aider"); + assert!( + draft.name.value().is_empty(), + "nothing to seed the name with" + ); + assert!(draft.worktree_enabled); + } + + #[test] + fn a_resolved_name_survives_late_refills_and_tracks_the_branch() { + let projects = vec![project("/repos/p", 0, Some("claude"))]; + let named = resolve( + &projects, + &[preset("claude", "claude")], + Some("amber-falcon".into()), + Utc::now(), + ); + let mut draft = DraftPane::default(); + named.apply_to_draft(&mut draft); + // A later batch must not overwrite the name the spawn was seeded with. + draft.seed_names(vec!["late-name".into()]); + assert_eq!(draft.name.value(), "amber-falcon"); + assert_eq!(draft.branch.value(), "amber-falcon"); + } + + #[test] + fn an_unresolved_name_still_gets_generated_by_a_later_batch() { + let projects = vec![project("/repos/p", 0, Some("claude"))]; + let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now()); + let mut draft = DraftPane::default(); + r.apply_to_draft(&mut draft); + draft.seed_names(vec!["late-name".into()]); + assert_eq!(draft.name.value(), "late-name"); + } + + #[test] + fn a_last_command_no_preset_advertises_lands_in_custom_text() { + let projects = vec![project("/repos/p", 0, Some("nvim -d"))]; + let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now()); + let mut draft = DraftPane::default(); + r.apply_to_draft(&mut draft); + assert_eq!(draft.command(), "nvim -d"); + assert!(draft.is_custom()); + } +} diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index 1e8c283..09e58af 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -9,6 +9,7 @@ pub mod cli; pub mod config; pub mod draft; pub mod emulators; +pub mod fast_forward; pub mod input; pub mod keymap; pub mod overlays;