//! Draft pane — the spawn form living in an ordinary pane (issues #63/#64). //! //! An empty pane renders a [`DraftPane`] instead of nothing; submitting hands //! the caller a built request and the pane is replaced with the new session, //! matching the web client's inline draft panes (#57). Field editing goes //! through [`crate::text_input::TextInput`]; Tab / BackTab walk the fields. //! //! Field groups (#64), mirroring the web form (`web/src/spawnForm.ts`): //! command presets from `/api/info` (with an implicit `custom` free-text //! 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. 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 std::time::{Duration, Instant}; use chrono::{DateTime, Utc}; use maestro_config::{builtin_presets, custom_preset}; 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}; use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; use crate::input::InputKey; use crate::projects; /// How many generated names one `/api/names` refill fetches. Rerolls consume /// the local batch so they stay instant; the daemon is hit only when it runs /// 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; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DraftField { /// The horizontal preset-tile row (Left / Right move the selection). Preset, /// Free-text command, reachable only while a custom preset is selected. CustomCommand, /// Project picker: typing filters, Up / Down highlight, Enter picks. DirPicker, /// 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. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DraftRow { pub project: Project, pub stale: bool, pub indent: bool, } /// Why a submit was refused. The message names the missing field so the form /// can show it instead of posting an empty request. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DraftValidationError { MissingDirectory, MissingCommand, MissingWorktreeRepo, MissingNewBranch, MissingExistingBranch, } impl std::fmt::Display for DraftValidationError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 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."), } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DraftInputResult { Consumed, Submit, } #[derive(Debug, Clone, PartialEq)] pub struct DraftPane { /// Identity for routing async load results back to *this* draft. /// /// Tab indices shift when a tab closes, so an in-flight load addressed by /// index could be dropped or land on the wrong pane (#67 review). pub id: u64, /// Includes the implicit custom tile (empty `command`) last. pub presets: Vec, pub selected_preset: usize, pub custom_command: crate::text_input::TextInput, /// Discovered projects, sorted fresh-first with worktrees nested. pub rows: Vec, pub filter: crate::text_input::TextInput, /// Highlighted entry within the visible list; the implicit /// `Other directory…` entry sits at index `visible_rows().len()`. pub highlighted: usize, pub cwd_raw_visible: bool, pub cwd_raw: crate::text_input::TextInput, pub picked_cwd: Option, pub name: crate::text_input::TextInput, pub name_queue: VecDeque, /// Set once the operator types or rerolls a name; guards refills against /// 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, /// 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, /// 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, pub focused_field: DraftField, pub home_dir: String, } /// Source of [`DraftPane::id`]. static NEXT_DRAFT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); impl Default for DraftPane { fn default() -> Self { // Built-ins until `/api/info` answers — the form must be usable even // while its data loads, and stay usable when the daemon reports none. let mut presets = builtin_presets(); presets.push(custom_preset()); Self { id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), presets, selected_preset: 0, custom_command: crate::text_input::TextInput::default(), rows: Vec::new(), filter: crate::text_input::TextInput::default(), highlighted: 0, cwd_raw_visible: false, cwd_raw: crate::text_input::TextInput::default(), picked_cwd: None, name: crate::text_input::TextInput::default(), 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, probe_due_at: None, 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(), } } } impl DraftPane { /// Replace the preset list from `GET /api/info`. An empty-command tile is /// treated as the custom escape hatch and appended if missing, so a daemon /// list without one still leaves the operator a way out. pub fn set_presets(&mut self, mut presets: Vec) { if !presets.iter().any(|p| p.command.is_empty()) { presets.push(custom_preset()); } self.presets = presets; self.selected_preset = self .presets .iter() .position(|p| !p.command.is_empty()) .unwrap_or(0); } /// Replace the project snapshot: sort fresh-first (#54), nest worktrees /// under their main checkout, and precompute staleness for rendering. pub fn set_projects(&mut self, discovered: Vec, now: DateTime) { let sorted = projects::sort_projects(discovered, now); let groups = projects::group_projects(&sorted); self.rows = groups .iter() .flat_map(|g| { std::iter::once(DraftRow { stale: projects::is_stale(g.main, now), indent: false, project: g.main.clone(), }) .chain(g.worktrees.iter().map(|wt| DraftRow { stale: projects::is_stale(wt, now), indent: true, project: (*wt).clone(), })) }) .collect(); self.highlighted = self.highlighted.min(self.entry_count().saturating_sub(1)); } /// Fill the local name batch from `GET /api/names`, seeding the field /// itself unless the operator already typed one. pub fn seed_names(&mut self, names: Vec) { self.name_queue.extend(names); if !self.name_touched && self.name.value().trim().is_empty() { if let Some(first) = self.name_queue.pop_front() { self.set_name(first); } } } /// Append a refill batch; the first name replaces the field (this only /// happens after an explicit reroll on an empty batch). /// /// The shown name is *consumed*, not left at the head — queueing it too /// made the next reroll produce the same name and the key look dead /// (#67 review). pub fn extend_names(&mut self, mut names: Vec) { if names.is_empty() { return; } let first = names.remove(0); self.set_name(first); self.name_queue.extend(names); } 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 /// dry, flags the caller to fetch a new one via [`Self::take_name_refill`]. pub fn reroll(&mut self) { self.name_touched = true; match self.name_queue.pop_front() { Some(next) => self.set_name(next), None => self.needs_name_refill = true, } } /// Consume the reroll-needs-a-new-batch flag raised by [`Self::reroll`]. pub fn take_name_refill(&mut self) -> bool { 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.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 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(); } 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 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. 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)) } /// 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.probe_due_at = None; 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::branch_is_available(&self.branches[i])) .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; // 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(); } /// A branch is pickable only when nothing has it checked out. That means /// no linked worktree *and* not the main checkout's HEAD — git refuses a /// second checkout either way, and HEAD is commonly `main`, so treating it /// as available made it the default pick and every spawn failed with /// "already used by worktree" (#54 review). fn branch_is_available(b: &BranchInfo) -> bool { b.worktree.is_none() && !b.is_head } fn available_branch(&self, i: usize) -> bool { self.branches.get(i).is_some_and(Self::branch_is_available) } /// 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 nothing has it checked out. pub fn picked_branch(&self) -> Option<&BranchInfo> { self.branches .get(self.selected_branch) .filter(|b| Self::branch_is_available(b)) } pub fn is_custom(&self) -> bool { self.presets .get(self.selected_preset) .is_none_or(|p| p.command.is_empty()) } /// The resolved command: the selected preset's, or the custom free text. pub fn command(&self) -> &str { match self.presets.get(self.selected_preset) { Some(p) if !p.command.is_empty() => &p.command, _ => self.custom_command.value().trim(), } } /// The chosen working directory: a picked project row, else the raw path /// input when it holds something. pub fn cwd(&self) -> Option { if let Some(cwd) = &self.picked_cwd { return Some(cwd.clone()); } let raw = self.cwd_raw.value().trim(); (!raw.is_empty()).then(|| PathBuf::from(raw)) } /// Build the spawn request, naming the missing field instead of letting /// an empty request reach the daemon. A non-empty name rides along as /// `metadata.name`; an empty one sends no name at all. pub fn build_request(&self) -> Result { let Some(cwd) = self.cwd() else { return Err(DraftValidationError::MissingDirectory); }; let command = self.command(); if command.is_empty() { return Err(DraftValidationError::MissingCommand); } let mut metadata = std::collections::HashMap::new(); let name = self.name.value().trim(); if !name.is_empty() { metadata.insert( "name".to_string(), serde_json::Value::String(name.to_string()), ); } let worktree = self.build_worktree()?; Ok(CreateSessionRequest { cwd, command: command.to_string(), env: Default::default(), metadata, 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(), create_branch: true, } } WorktreeBranchMode::Existing => { let name = self .picked_branch() .ok_or(DraftValidationError::MissingExistingBranch)? .name .clone(); WorktreeSpec { base_repo, branch: name, create_branch: false, } } }; Ok(Some(spec)) } fn field_order(&self) -> Vec { let mut order = vec![DraftField::Preset]; if self.is_custom() { order.push(DraftField::CustomCommand); } order.push(DraftField::DirPicker); if self.cwd_raw_visible { order.push(DraftField::CwdRaw); } 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 } fn step_field(&mut self, delta: i32) { let order = self.field_order(); let len = order.len() as i32; let cur = order .iter() .position(|f| *f == self.focused_field) .unwrap_or(0) as i32; self.focused_field = order[(cur + delta).rem_euclid(len) as usize]; } pub fn next_field(&mut self) { self.step_field(1); } pub fn prev_field(&mut self) { self.step_field(-1); } pub fn cycle_preset(&mut self, delta: i32) { let len = self.presets.len(); if len == 0 { return; } self.selected_preset = ((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize; } /// Rows surviving the type-to-filter, in display order. pub fn visible_rows(&self) -> Vec<&DraftRow> { let query = self.filter.value(); self.rows .iter() .filter(|r| projects::matches_filter(&r.project, query)) .collect() } /// Visible entries including the trailing implicit `Other directory…`. pub fn entry_count(&self) -> usize { self.visible_rows().len() + 1 } /// Highlight clamped to the current entry count. pub fn selection(&self) -> usize { self.highlighted.min(self.entry_count().saturating_sub(1)) } pub fn move_selection(&mut self, delta: i32) { let count = self.entry_count() as i32; if count == 0 { return; } self.highlighted = ((self.selection() as i32) + delta).rem_euclid(count) as usize; } /// Act on the highlighted picker entry: a project becomes the working /// directory (and pre-selects the command it was last used with), while /// the trailing entry reveals the free-text path input. pub fn select_entry(&mut self) { let visible = self.visible_rows(); let sel = self.selection(); if sel < visible.len() { 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 .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) { self.selected_preset = idx; } } } self.note_picked_repo(repo); return; } let seed = self.cwd().unwrap_or_else(|| { if self.home_dir.is_empty() { PathBuf::from("/") } else { PathBuf::from(&self.home_dir) } }); self.picked_cwd = None; 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 /// focused). Returns [`DraftInputResult::Submit`] when the user accepts; /// the caller validates, fires the POST and replaces the draft with the /// session — or shows the reason it couldn't. pub fn on_input(&mut self, key: &InputKey) -> DraftInputResult { match key { InputKey::Tab => { self.next_field(); DraftInputResult::Consumed } InputKey::BackTab => { self.prev_field(); DraftInputResult::Consumed } InputKey::Enter => match self.focused_field { DraftField::Submit => DraftInputResult::Submit, DraftField::DirPicker => { 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 } InputKey::Down if self.focused_field == DraftField::DirPicker => { self.move_selection(1); DraftInputResult::Consumed } InputKey::Left if self.focused_field == DraftField::Preset => { self.cycle_preset(-1); DraftInputResult::Consumed } InputKey::Right if self.focused_field == DraftField::Preset => { 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 => { self.reroll(); DraftInputResult::Consumed } other => { match self.focused_field { DraftField::Preset | DraftField::Submit => {} DraftField::CustomCommand => self.custom_command.handle_key(other), // Typing filters the list; the highlight resets to the top // so a narrowed search always starts at its best match. DraftField::DirPicker => { self.filter.handle_key(other); self.highlighted = 0; } 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 } } } /// Draw into the pane area. `pane_focused` gates the field highlight and /// cursor placement the same way a session pane hides its cursor when /// unfocused. pub fn render(&self, f: &mut Frame, area: Rect, pane_focused: bool) { let block = Block::default().title(" draft ").borders(Borders::ALL); let inner = block.inner(area); f.render_widget(block, area); let show_custom = self.is_custom(); let show_raw = self.cwd_raw_visible; 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; let mut constraints = vec![Constraint::Length(1)]; if show_custom { constraints.push(Constraint::Length(1)); } constraints.push(Constraint::Length(1)); constraints.push(Constraint::Length(list_capacity as u16)); if show_raw { 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)); } let rows = Layout::vertical(constraints).split(inner); let mut r = 0usize; let mut next_row = || { r += 1; rows[r - 1] }; let field_style = |field: DraftField| { if pane_focused && self.focused_field == field { Style::default().add_modifier(Modifier::REVERSED) } else { Style::default() } }; // Tracks where the terminal cursor belongs for the focused text field. let mut cursor: Option<(u16, u16)> = None; // -- command presets --------------------------------------------------- let tiles_row = next_row(); let mut tiles = vec![Span::raw(format!("{: 0 { tiles.push(Span::raw(" ")); } let mut style = Style::default(); if i == self.selected_preset { style = style.add_modifier(Modifier::REVERSED); } tiles.push(Span::styled(format!("[{}]", preset.label), style)); } f.render_widget(Paragraph::new(Line::from(tiles)), tiles_row); // -- custom command (only while a custom tile is selected) ------------- if show_custom { let row = next_row(); draw_gutter(f, row, ""); let col = draw_text_field( f, &self.custom_command, row, field_style(DraftField::CustomCommand), ); if self.focused_field == DraftField::CustomCommand { cursor = Some((col, row.y)); } } // -- directory picker ---------------------------------------------------- let filter_row = next_row(); draw_gutter(f, filter_row, "dir:"); let col = draw_text_field( f, &self.filter, filter_row, field_style(DraftField::DirPicker), ); if self.focused_field == DraftField::DirPicker { cursor = Some((col, filter_row.y)); } let visible = self.visible_rows(); let sel = self.selection(); let count = visible.len() + 1; let capacity = list_capacity.min(count); let start = if sel >= capacity { sel + 1 - capacity } else { 0 }; let name_width = visible .iter() .take(start + capacity) .skip(start) .map(|row| row.project.name.chars().count() + usize::from(row.indent) * 2) .max() .unwrap_or(0) .min(18); let list_row = next_row(); for i in start..start + capacity { let y = list_row.y + (i - start) as u16; if y >= list_row.y + list_row.height { break; } let active = pane_focused && self.focused_field == DraftField::DirPicker && i == sel; let line = if i < visible.len() { let row = visible[i]; let mut base = Style::default(); if row.stale { base = base.add_modifier(Modifier::DIM); } if active { base = base.add_modifier(Modifier::REVERSED); } let name = if row.indent { format!(" {}", row.project.name) } else { row.project.name.clone() }; Line::from(vec![ Span::styled(if active { ">" } else { " " }, base), Span::raw(" "), Span::styled(format!("{name: { 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( Paragraph::new(Line::from(Span::styled( "[ Spawn ]", field_style(DraftField::Submit), ))), spawn_row, ); // -- error --------------------------------------------------------------------- if let Some(error) = &self.error { let row = next_row(); f.render_widget( Paragraph::new(Line::from(Span::styled( format!("error: {error}"), Style::default().fg(Color::Red), ))), row, ); } if let Some((x, y)) = cursor.filter(|_| pane_focused) { f.set_cursor_position((x, y)); } } } fn draw_gutter(f: &mut Frame, row: Rect, label: &str) { f.render_widget( Paragraph::new(Line::from(Span::raw(format!("{label: u16 { let area = Rect { x: row.x + GUTTER as u16, y: row.y, width: row.width.saturating_sub(GUTTER as u16), height: 1, }; input.render(area, f.buffer_mut(), style) } /// `~` abbreviation plus leading-segment collapsing, matching the web UI's /// `abbreviateCwd` + `abbreviateFolderName`: `/home/u/tea/Maestro` renders as /// `/h/u/t/Maestro` given home `/home/u`. pub(crate) fn abbreviate_path(path: &Path, home: &str) -> String { let raw = path.to_string_lossy(); let abbreviated = if !home.is_empty() { if raw == home { "~".to_string() } else if let Some(rest) = raw.strip_prefix(home) { if rest.starts_with('/') { format!("~{rest}") } else { raw.into_owned() } } else { raw.into_owned() } } else { raw.into_owned() }; let parts: Vec<&str> = abbreviated.split('/').collect(); if parts.len() <= 2 { return abbreviated; } let last = parts[parts.len() - 1]; let stem = parts[..parts.len() - 1] .iter() .map(|p| { if *p == "~" { "~".to_string() } else { p.chars().next().map(String::from).unwrap_or_default() } }) .collect::>() .join("/"); format!("{stem}/{last}") } #[cfg(test)] mod tests { use super::*; use chrono::TimeDelta; use ratatui::backend::TestBackend; use ratatui::Terminal; fn render_to_string(w: u16, h: u16, draw: impl FnOnce(&mut Frame)) -> String { let backend = TestBackend::new(w, h); let mut terminal = Terminal::new(backend).unwrap(); terminal.draw(draw).unwrap(); let buf = terminal.backend().buffer().clone(); let mut out = String::new(); for y in 0..buf.area.height { for x in 0..buf.area.width { out.push_str(buf[(x, y)].symbol()); } out.push('\n'); } out } fn project(name: &str, path: &str, days_ago: i64, cmd: Option<&str>) -> Project { Project { path: PathBuf::from(path), name: name.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( 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)); } } /// 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(); assert_eq!( draft.presets.last().unwrap().id, maestro_config::CUSTOM_PRESET_ID ); assert_eq!(draft.selected_preset, 0, "first non-custom tile starts"); assert_eq!(draft.command(), "claude"); assert!(!draft.is_custom()); } #[test] fn daemon_presets_replace_builtins_and_keep_the_custom_hatch() { let mut draft = DraftPane::default(); draft.set_presets(vec![SpawnPreset { id: "aider".into(), label: "Aider".into(), command: "aider".into(), }]); assert_eq!( draft.command(), "aider", "selection resets to first real preset" ); assert_eq!(draft.presets.last().unwrap().id, "custom"); } #[test] fn left_right_cycles_presets_wrapping_into_custom() { let mut draft = DraftPane::default(); draft.on_input(&InputKey::Right); assert_eq!(draft.command(), "opencode"); draft.on_input(&InputKey::Left); assert_eq!(draft.command(), "claude"); // Wrapping backwards off the first tile lands on the custom one. draft.on_input(&InputKey::Left); assert!(draft.is_custom(), "wraps backwards onto the custom tile"); draft.on_input(&InputKey::Right); assert_eq!(draft.command(), "claude"); assert_eq!( draft.focused_field, DraftField::Preset, "selection changed, focus stays" ); } #[test] fn selecting_custom_reveals_a_free_text_command_field() { let mut draft = DraftPane::default(); draft.selected_preset = draft.presets.len() - 1; assert!(draft.is_custom()); assert_eq!( draft.field_order()[..2], [DraftField::Preset, DraftField::CustomCommand], "custom command sits between presets and the dir picker" ); draft.next_field(); assert_eq!(draft.focused_field, DraftField::CustomCommand); typed(&mut draft, "bash -l"); assert_eq!(draft.command(), "bash -l"); // Tab walks past the hidden fields in order. draft.next_field(); assert_eq!(draft.focused_field, DraftField::DirPicker); } #[test] 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(); assert_eq!(req.command, "bash -l"); assert_eq!(req.cwd, PathBuf::from("/srv/work")); } #[test] fn projects_render_sorted_with_stale_dimmed_and_other_directory_last() { let mut draft = DraftPane { home_dir: "/home/u".into(), ..DraftPane::default() }; draft.set_projects( vec![ project("stale-repo", "/home/u/old/repo", 30, Some("zsh")), project("fresh-repo", "/home/u/new/repo", 1, Some("claude")), ], Utc::now(), ); let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true)); assert!(rendered.contains("Other directory…"), "{rendered}"); let fresh_pos = rendered.find("fresh-repo").unwrap(); let stale_pos = rendered.find("stale-repo").unwrap(); assert!(fresh_pos < stale_pos, "fresh before stale:\n{rendered}"); assert!( rendered.contains("~/n/repo"), "collapsed path stem:\n{rendered}" ); assert!( rendered.contains("claude"), "last command shown:\n{rendered}" ); // Staleness dims the row's cells. let backend = TestBackend::new(100, 24); let mut terminal = Terminal::new(backend).unwrap(); terminal.draw(|f| draft.render(f, f.area(), true)).unwrap(); let buf = terminal.backend().buffer(); let stale_cell = buf .content() .iter() .find(|c| c.symbol() == "s" && c.modifier.contains(Modifier::DIM)); assert!(stale_cell.is_some(), "stale row renders dimmed"); } #[test] fn typing_filters_and_enter_picks_a_project_as_the_working_directory() { let mut draft = DraftPane::default(); draft.set_projects( vec![ project("maestro", "/repos/maestro", 1, Some("claude")), project("other", "/repos/other", 2, Some("zsh")), ], Utc::now(), ); draft.next_field(); // Preset → DirPicker assert_eq!(draft.focused_field, DraftField::DirPicker); typed(&mut draft, "maes"); assert_eq!( draft.visible_rows().len(), 1, "type-to-filter narrows the list" ); draft.on_input(&InputKey::Enter); assert_eq!( draft.picked_cwd.as_deref(), Some(Path::new("/repos/maestro")) ); assert_eq!( draft.command(), "claude", "pick pre-selects the last command" ); } #[test] fn up_down_moves_the_highlight_within_the_list() { let mut draft = DraftPane::default(); draft.set_projects( vec![project("a", "/a", 1, None), project("b", "/b", 2, None)], Utc::now(), ); draft.focused_field = DraftField::DirPicker; assert_eq!(draft.selection(), 0); draft.on_input(&InputKey::Down); draft.on_input(&InputKey::Down); assert_eq!( draft.selection(), 2, "the Other directory… entry is reachable" ); draft.on_input(&InputKey::Down); assert_eq!(draft.selection(), 0, "wraps"); draft.on_input(&InputKey::Up); assert_eq!(draft.selection(), 2, "wraps backwards"); } #[test] fn other_directory_reveals_a_free_text_path_seeded_from_home() { let mut draft = DraftPane { home_dir: "/home/u".into(), ..DraftPane::default() }; draft.set_projects(vec![project("a", "/a", 1, None)], Utc::now()); draft.focused_field = DraftField::DirPicker; draft.move_selection(1); // onto Other directory… draft.on_input(&InputKey::Enter); assert!(draft.cwd_raw_visible); assert_eq!(draft.focused_field, DraftField::CwdRaw); 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(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(); assert_eq!(req.cwd, PathBuf::from("/home/u/opt/tooling")); assert_eq!( req.worktree.unwrap().base_repo, PathBuf::from("/home/u/opt/tooling") ); } #[test] fn picking_other_directory_after_a_pick_seeds_the_raw_field_with_the_pick() { let mut draft = DraftPane { home_dir: "/home/u".into(), picked_cwd: Some(PathBuf::from("/repos/picked")), ..DraftPane::default() }; draft.select_entry(); // highlight defaults past the (empty) list → Other… assert!(draft.cwd_raw_visible); assert_eq!(draft.cwd_raw.value(), "/repos/picked"); } #[test] fn generated_names_seed_the_field_and_metadata() { let mut draft = DraftPane::default(); assert!( draft.name.value().is_empty(), "no name until /api/names lands" ); draft.seed_names(vec!["crimson-otter".into(), "amber-falcon".into()]); 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()), Some("crimson-otter"), "name rides along as metadata.name" ); draft.reroll(); assert_eq!( draft.name.value(), "amber-falcon", "reroll consumes the batch" ); draft.reroll(); assert!( draft.take_name_refill(), "dry batch flags the caller to refetch" ); assert!(!draft.take_name_refill(), "flag consumes"); } #[test] fn refills_do_not_clobber_a_name_the_operator_typed() { let mut draft = DraftPane::default(); draft.seed_names(vec!["seeded".into()]); typed_name(&mut draft, "-mine"); assert_eq!(draft.name.value(), "seeded-mine"); draft.seed_names(vec!["late-batch".into()]); assert_eq!( draft.name.value(), "seeded-mine", "late initial batches never overwrite an edited name" ); } fn typed_name(draft: &mut DraftPane, s: &str) { draft.focused_field = DraftField::Name; for c in s.chars() { draft.on_input(&InputKey::char(c)); } } #[test] fn an_empty_name_sends_no_metadata_at_all() { 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()); } #[test] fn validation_names_the_missing_field_instead_of_posting() { let mut draft = DraftPane::default(); assert_eq!( draft.build_request(), Err(DraftValidationError::MissingDirectory) ); assert_eq!( DraftValidationError::MissingDirectory.to_string(), "Pick a working directory." ); draft.cwd_raw = crate::text_input::TextInput::with_value("/srv/work"); draft.picked_cwd = Some(PathBuf::from("/srv/work")); draft.selected_preset = draft.presets.len() - 1; // custom, empty text assert_eq!( draft.build_request(), Err(DraftValidationError::MissingCommand) ); assert_eq!( DraftValidationError::MissingCommand.to_string(), "Pick a command (or fill the custom command)." ); } #[test] fn render_shows_every_field_group_and_the_reroll_hint() { 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 [ "[Claude]", "[opencode]", "[Shell]", "[Custom…]", "dir:", "name:", "^R reroll", "maestro", "[ Spawn ]", ] { assert!(rendered.contains(s), "missing {s:?}:\n{rendered}"); } } #[test] fn render_shows_the_error_line_when_set() { let draft = DraftPane { error: Some(DraftValidationError::MissingDirectory.to_string()), ..DraftPane::default() }; let rendered = render_to_string(80, 24, |f| draft.render(f, f.area(), true)); assert!( rendered.contains("error: Pick a working directory."), "{rendered}" ); } /// A short pane still shows the essential fields; the list window scrolls /// with the selection instead of overflowing the border. #[test] fn render_survives_a_small_pane() { let mut draft = DraftPane::default(); draft.set_projects( (0..10) .map(|i| project(&format!("p{i}"), &format!("/p{i}"), i, None)) .collect(), Utc::now(), ); draft.focused_field = DraftField::DirPicker; for _ in 0..8 { draft.on_input(&InputKey::Down); } let rendered = render_to_string(80, 12, |f| draft.render(f, f.area(), true)); assert!( rendered.contains("[ Spawn ]"), "submit still visible:\n{rendered}" ); assert!(rendered.contains('p'), "some projects visible:\n{rendered}"); } /// The terminal cursor must sit right after the typed text in the focused /// field (gutter is 5 chars, block border takes column 0). #[test] fn render_places_the_cursor_in_the_focused_text_field() { let mut draft = DraftPane { focused_field: DraftField::DirPicker, ..DraftPane::default() }; typed(&mut draft, "ab"); let backend = TestBackend::new(80, 20); let mut terminal = Terminal::new(backend).unwrap(); terminal.draw(|f| draft.render(f, f.area(), true)).unwrap(); let pos = terminal.get_cursor_position().unwrap(); assert_eq!( pos, ratatui::layout::Position::new(1 + GUTTER as u16 + 2, 2) ); } #[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(probe_due()).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); } #[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 { 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(probe_due()) .expect("probe requested"); assert_eq!(path, PathBuf::from("/opt/tool")); 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(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"))); 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(probe_due()).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, "feature", "selection skips `main`: the main checkout has it, so git would \ refuse a second checkout" ); draft.focused_field = DraftField::BranchExisting; draft.on_input(&InputKey::Down); assert_eq!( draft.branches[draft.selected_branch].name, "feature", "the only pickable branch stays selected" ); 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 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(); 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(probe_due()).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!( abbreviate_path(Path::new("/home/u/tea/Maestro"), "/home/u"), "~/t/Maestro" ); assert_eq!(abbreviate_path(Path::new("/home/u"), "/home/u"), "~"); assert_eq!( abbreviate_path(Path::new("/opt/x"), "/home/u"), "/o/x", "three-or-more segments collapse even outside home (web parity)" ); assert_eq!( abbreviate_path(Path::new("/home/u"), ""), "/h/u", "segments collapse even when home is unknown (web parity)" ); } }