//! The spawn form (issues #63/#64), drawn inside the spawn modal. //! //! Submitting hands the caller a built request; the modal closes and the new //! session takes the pane, matching the web client's spawn modal. 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 two-step project/worktree picker (#96, mirroring the web //! redesign in #95) fed by [`crate::projects`] over `/api/projects` plus an //! `Other directory…` escape hatch, a generated session name from //! `/api/names` for the branch default, 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`. //! //! The picker itself is two steps (#96), verbatim from the rules in //! `web/src/spawnForm.ts`'s "working-directory picker (#95)" comment: //! 1. Step one is one box per repository (name, then its parent directory). //! A worktree is a box only when its main checkout is missing from the //! scan. //! 2. The filter keeps a repository when the query matches it or any of its //! worktrees, so a worktree name still finds its box. //! 3. Picking a box reveals step two: "+ new", then that repository's //! worktrees newest-first. "Other directory…" clears both steps. //! 4. "+ new" is the default selection — except on a box that is itself a //! linked worktree, which defaults to that worktree. //! 5. "+ new" means create: cwd is the repository, create-worktree on. //! An existing worktree means run in it: cwd is that worktree, //! create-worktree OFF. The daemon never adopts (docs/WORKTREES.md), so //! these are separate acts. //! 6. Neither step probes `/api/gitroot`; the scan already reports //! repository membership. Only the free-text hatch pays for a probe. //! 7. Neither step touches the command: the directory and what runs in it //! are independent choices. use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use chrono::{DateTime, Utc}; use maestro_config::{builtin_presets, custom_preset}; use maestro_protocol::display::abbreviate_dir; use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; use crate::input::InputKey; use crate::projects; /// Shown in place of an empty name field: what happens if the operator types /// nothing (#131). pub const NAME_HINT: &str = "named after your first prompt"; /// How many handles one `/api/names` fetch asks for. One is enough: the name /// field starts empty (#131) and the handle only seeds the worktree branch. pub const NAME_BATCH_SIZE: usize = 1; /// 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, /// Step one: repository picker. Typing filters, Up / Down highlight, /// Enter picks and reveals step two (#96). DirPicker, /// Free-text path, revealed by picking `Other directory…`. CwdRaw, /// Step two, shown once a repository is picked: "+ new" then that /// repository's worktrees newest-first (#96 rule 3). Up / Down move and /// apply immediately, mirroring the web form's per-click choice. WorktreeChoice, Name, /// Disclosure collapsing the worktree/branch controls below (#93). /// Closed by default; Enter / Space toggles. Advanced, /// 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, } /// "new" (create a worktree) or an existing worktree to run in — the TUI /// mirror of the web form's `WorktreeChoice` (#96 rule 5). #[derive(Debug, Clone, PartialEq, Eq)] pub enum WorktreeChoice { New, Existing(PathBuf), } /// One repository box for step one of the picker (#96 rule 1): the main /// checkout (or, when it's missing from the scan, its freshest worktree /// standing in for it) plus every other worktree of the same repository. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DraftGroup { pub main: Project, pub main_stale: bool, pub worktrees: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct DraftWorktreeRow { pub project: Project, pub stale: bool, } /// A box's second column: the directory holding the project, `~`-abbreviated. /// Only the stem — the last segment is the name column beside it. fn parent_dir_label(path: &Path, home: &str) -> String { match path.parent() { Some(parent) => abbreviate_dir(parent, home), None => String::new(), } } /// The repository key a [`DraftGroup`] was formed under — same key /// `projects::group_projects` groups by, kept separately because the main /// box's own path only equals it when the main checkout itself was found. fn group_key(main: &Project) -> PathBuf { main.base_repo.clone().unwrap_or_else(|| main.path.clone()) } /// Every worktree of a group, including the group's own box when the scan /// found no main checkout to nest it under — the TUI mirror of the web /// form's `worktreesOf`. Already newest-first: when the main box stands in /// for a missing main checkout it is the freshest member by construction /// ([`projects::group_projects`]), and `worktrees` itself is sorted /// newest-first. fn worktree_rows_of(group: &DraftGroup) -> Vec { let mut rows = Vec::new(); if group.main.is_worktree { rows.push(group.main.clone()); } rows.extend(group.worktrees.iter().map(|w| w.project.clone())); rows } /// 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 and grouped by repository /// (#96 step one). pub groups: Vec, pub filter: crate::text_input::TextInput, /// Highlighted entry within the visible step-one list; the implicit /// `Other directory…` entry sits at index `visible_groups().len()`. pub highlighted: usize, /// Set by an explicit Up/Down move, cleared by typing (#97): typing /// mirrors the web form's plain filter input, where Enter always /// resolves and submits; an explicit arrow move mirrors Tab landing on /// a not-yet-active picker button there, where a first Enter only /// chooses. See `dir_picker_choice_active` at the Enter match arm. dir_picker_navigated: bool, pub cwd_raw_visible: bool, pub cwd_raw: crate::text_input::TextInput, pub picked_cwd: Option, /// Directory to preselect once the project scan lands — the project of /// the session the operator was last looking at, so `n` then Enter /// respawns there without touching the picker. Consumed by the first /// [`Self::set_projects`]; `None` once applied or when nothing seeded it. pub seed_cwd: Option, /// The repository picked in step one ([`group_key`] of its main box), /// `None` until a box is picked. Looked up fresh in `groups` rather than /// cached, so a project-list refresh can't leave it stale. pub selected_group_key: Option, /// Step two's current pick (#96 rule 4/5). Meaningless while /// `selected_group_key` is `None`. pub worktree_choice: WorktreeChoice, pub name: crate::text_input::TextInput, /// Set once the operator types a name; a fetched handle then seeds only /// the branch, never the field. Guards a late fetch against /// clobbering it, exactly like the web form's `nameTouched`. pub name_touched: bool, /// Worktree on for this spawn (#65). Defaults on; a failed gitroot probe /// unticks it, like the web form. pub worktree_enabled: bool, /// Whether the worktree/branch controls are collapsed behind the /// "Advanced options" disclosure (#93). Closed by default — most spawns /// take the default target. pub advanced_collapsed: 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(), groups: Vec::new(), filter: crate::text_input::TextInput::default(), highlighted: 0, dir_picker_navigated: false, cwd_raw_visible: false, cwd_raw: crate::text_input::TextInput::default(), picked_cwd: None, seed_cwd: None, selected_group_key: None, worktree_choice: WorktreeChoice::New, name: crate::text_input::TextInput::default(), name_touched: false, worktree_enabled: true, advanced_collapsed: 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, // The picker owns the keyboard on open: typing filters projects // straight away, and Tab from here reaches the command tiles. focused_field: DraftField::DirPicker, 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), group by /// repository (#96), 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.groups = groups .iter() .map(|g| DraftGroup { main: g.main.clone(), main_stale: projects::is_stale(g.main, now), worktrees: g .worktrees .iter() .map(|wt| DraftWorktreeRow { project: (*wt).clone(), stale: projects::is_stale(wt, now), }) .collect(), }) .collect(); self.highlighted = self.highlighted.min(self.entry_count().saturating_sub(1)); self.apply_seed_cwd(); } /// Preselect the seeded directory now that the scan has landed: highlight /// its repository box and pick it, so Enter from the untouched picker /// spawns there. A seed no box matches is dropped — the operator picks by /// hand, exactly as before. fn apply_seed_cwd(&mut self) { let Some(seed) = self.seed_cwd.take() else { return; }; let hit = self.visible_groups().iter().enumerate().find_map(|(i, g)| { let matches = g.main.path == seed || g.worktrees.iter().any(|w| w.project.path == seed); matches.then(|| (i, (*g).clone())) }); let Some((index, group)) = hit else { return; }; self.highlighted = index; self.select_group(&group); } /// Seed the worktree branch from a `GET /api/names` handle (#131). /// /// The name field is deliberately left alone: a session the operator does /// not name is named by the daemon — a provisional handle at spawn, /// replaced by one derived from the first prompt. The branch still needs /// a default, and the name field no longer supplies one. pub fn seed_names(&mut self, names: Vec) { let Some(handle) = names.into_iter().next() else { return; }; if self.branch_edited || self.branch_mode != WorktreeBranchMode::New || !self.branch.value().trim().is_empty() { return; } self.branch = crate::text_input::TextInput::with_value(handle); } 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. /// /// A cleared name leaves the branch on its last value (#131): the branch /// still has to be something, while the session name no longer does. fn sync_branch_to_name(&mut self) { if !self.branch_edited && self.branch_mode == WorktreeBranchMode::New && !self.name.value().is_empty() { self.branch = crate::text_input::TextInput::with_value(self.name.value().to_string()); } } // -- 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 "Advanced options" header (#93). pub fn toggle_advanced(&mut self) { self.advanced_collapsed = !self.advanced_collapsed; } /// What the collapsed header says about this spawn's default, so it stays /// visible without expanding (#93). fn advanced_summary(&self) -> &'static str { if self.worktree_enabled { "creates a worktree" } else { "no worktree" } } /// 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::DirPicker]; if self.cwd_raw_visible { order.push(DraftField::CwdRaw); } order.push(DraftField::Preset); if self.is_custom() { order.push(DraftField::CustomCommand); } order.push(DraftField::Name); order.push(DraftField::Advanced); if !self.advanced_collapsed { if !self.cwd_raw_visible && self.selected_group_key.is_some() { order.push(DraftField::WorktreeChoice); } 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; } /// Step-one boxes surviving the type-to-filter, in display order. A /// group survives when the query matches its box or any of its /// worktrees (#96 rule 2), so a worktree name still finds its box. pub fn visible_groups(&self) -> Vec<&DraftGroup> { let query = self.filter.value(); self.groups .iter() .filter(|g| { projects::matches_filter(&g.main, query) || g.worktrees .iter() .any(|w| projects::matches_filter(&w.project, query)) }) .collect() } /// Visible entries including the trailing implicit `Other directory…`. pub fn entry_count(&self) -> usize { self.visible_groups().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.dir_picker_navigated = true; self.highlighted = ((self.selection() as i32) + delta).rem_euclid(count) as usize; } /// The step-one group currently picked, looked up fresh against the /// latest snapshot so a project-list refresh can't leave a stale clone /// behind. pub fn selected_group(&self) -> Option { let key = self.selected_group_key.as_ref()?; self.groups .iter() .find(|g| &group_key(&g.main) == key) .cloned() } /// Step two's rows: "+ new" first, then the repository's worktrees /// newest-first (#96 rule 3). Empty while no group is picked. pub fn worktree_choice_rows(&self) -> Vec<(WorktreeChoice, String, String)> { let Some(group) = self.selected_group() else { return Vec::new(); }; let mut rows = vec![( WorktreeChoice::New, "+ new".to_string(), format!("new worktree of {}", group.main.name), )]; rows.extend(worktree_rows_of(&group).into_iter().map(|p| { ( WorktreeChoice::Existing(p.path.clone()), p.name.clone(), p.path.to_string_lossy().into_owned(), ) })); rows } /// Whether the highlighted step-one row is already the picked group /// (#97): once true, a further Enter submits instead of re-picking it. /// Always false on the trailing "Other directory…" row — picking it is /// never a no-op, so it always takes the Enter to reveal the raw field. fn dir_picker_choice_active(&self) -> bool { let visible = self.visible_groups(); let sel = self.selection(); sel < visible.len() && self.selected_group_key.as_ref() == Some(&group_key(&visible[sel].main)) } /// Act on the highlighted step-one box: reveals step two, defaulting per /// rule 4. The trailing entry reveals the free-text path input and /// clears both steps (rule 3). pub fn select_entry(&mut self) { let visible = self.visible_groups(); let sel = self.selection(); if sel < visible.len() { let group = visible[sel].clone(); self.select_group(&group); return; } let seed = self.cwd().unwrap_or_else(|| { if self.home_dir.is_empty() { PathBuf::from("/") } else { PathBuf::from(&self.home_dir) } }); self.clear_project_selection(); 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(); } /// Pick a step-one box: remember it and default step two (#96 rule 4) — /// "+ new", except a box that is itself a linked worktree (the scan /// found no main checkout for it), which defaults to that worktree. fn select_group(&mut self, group: &DraftGroup) { self.selected_group_key = Some(group_key(&group.main)); let default = if group.main.is_worktree { WorktreeChoice::Existing(group.main.path.clone()) } else { WorktreeChoice::New }; self.apply_worktree_choice(default); } /// Clear both picker steps (#96 rule 3), e.g. for `Other directory…`. fn clear_project_selection(&mut self) { self.selected_group_key = None; self.worktree_choice = WorktreeChoice::New; } /// Apply a step-two pick (#96 rule 5): "+ new" targets the repository /// itself with worktree creation on; an existing worktree targets it /// directly with worktree creation off — the daemon never adopts /// (docs/WORKTREES.md). Neither path probes `/api/gitroot` (rule 6): the /// scan already reported this target's repository. pub fn apply_worktree_choice(&mut self, choice: WorktreeChoice) { let Some(group) = self.selected_group() else { return; }; let target = match &choice { WorktreeChoice::New => group.main.clone(), WorktreeChoice::Existing(path) => { match worktree_rows_of(&group) .into_iter() .find(|p| &p.path == path) { Some(p) => p, None => return, } } }; self.worktree_choice = choice.clone(); self.picked_cwd = Some(target.path.clone()); self.cwd_raw_visible = false; match choice { WorktreeChoice::New => { self.worktree_enabled = true; let repo = target .base_repo .clone() .unwrap_or_else(|| target.path.clone()); self.note_picked_repo(repo); } WorktreeChoice::Existing(_) => { self.worktree_enabled = false; self.base_repo = None; 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; } } } /// Move step two's highlight and apply the newly-highlighted choice at /// once, matching the web form's per-click pick — there is no separate /// confirm step. pub fn move_worktree_choice(&mut self, delta: i32) { let rows = self.worktree_choice_rows(); let count = rows.len() as i32; if count == 0 { return; } let cur = rows .iter() .position(|(c, _, _)| *c == self.worktree_choice) .unwrap_or(0) as i32; let next = ((cur + delta).rem_euclid(count)) as usize; self.apply_worktree_choice(rows[next].0.clone()); } /// 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 } // Enter submits from any field (#97), mirroring web/src/spawnForm.ts. // Two fields keep their own Enter instead: `DirPicker`, whose // highlighted row isn't the picked one until a first Enter chooses // it (the next Enter, now that it's active, falls through to // submit); and the disclosure/toggle pair (`Advanced`, // `WorktreeToggle`), which activate on Enter like a button rather // than submitting, same as the web form's own `