This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-tui/src/draft.rs
T
Miguel Palhas bd252fc954 feat(maestro): TUI spawn form stops naming sessions
The name field starts empty behind a hint, ^R reroll is gone, and the
fetched handle seeds the worktree branch instead. Fast-forward spawns with
no name at all. A card whose name the daemon generated renders muted.

Refs #131
2026-09-02 18:05:05 +01:00

2901 lines
109 KiB
Rust

//! 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<DraftWorktreeRow>,
}
#[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<Project> {
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<SpawnPreset>,
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<DraftGroup>,
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<PathBuf>,
/// 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<PathBuf>,
/// 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<PathBuf>,
/// 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<PathBuf>,
/// The cwd the current `base_repo` answer belongs to.
pub(crate) probed_cwd: Option<PathBuf>,
/// 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<Instant>,
/// 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<BranchInfo>,
/// Highlighted entry in `Existing` mode; always an available branch
/// unless moved by hand.
pub selected_branch: usize,
branches_for: Option<PathBuf>,
needs_branches: bool,
/// Last refusal (validation or a failed POST), rendered inside the form
/// instead of dismissing it.
pub error: Option<String>,
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<SpawnPreset>) {
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<Project>, now: DateTime<Utc>) {
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<String>) {
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<String>) {
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<PathBuf>) {
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<PathBuf> {
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<BranchInfo>) {
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<usize> = (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<PathBuf> {
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<CreateSessionRequest, DraftValidationError> {
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<Option<WorktreeSpec>, 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<DraftField> {
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<DraftGroup> {
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 `<button>` controls.
InputKey::Enter if self.focused_field == DraftField::DirPicker => {
let is_other_row = self.selection() >= self.visible_groups().len();
let choose_only =
is_other_row || (self.dir_picker_navigated && !self.dir_picker_choice_active());
self.select_entry();
if choose_only {
DraftInputResult::Consumed
} else {
DraftInputResult::Submit
}
}
InputKey::Enter if self.focused_field == DraftField::Advanced => {
self.toggle_advanced();
DraftInputResult::Consumed
}
InputKey::Enter if self.focused_field == DraftField::WorktreeToggle => {
self.toggle_worktree();
DraftInputResult::Consumed
}
InputKey::Enter => DraftInputResult::Submit,
InputKey::Char {
c: ' ',
ctrl: false,
..
} if self.focused_field == DraftField::Advanced => {
self.toggle_advanced();
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::Up if self.focused_field == DraftField::WorktreeChoice => {
self.move_worktree_choice(-1);
DraftInputResult::Consumed
}
InputKey::Down if self.focused_field == DraftField::WorktreeChoice => {
self.move_worktree_choice(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
}
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;
self.dir_picker_navigated = false;
}
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 inner = area;
let show_custom = self.is_custom();
let show_raw = self.cwd_raw_visible;
// The worktree choice step, the worktree toggle and its body all stay
// behind the "Advanced options" disclosure (#93): collapsed hides
// them, regardless of what they'd otherwise show.
let show_wt_toggle = !self.advanced_collapsed;
let show_wt_body = show_wt_toggle && self.worktree_body_visible();
let show_wt_choice =
!self.advanced_collapsed && self.selected_group_key.is_some() && !show_raw;
let wt_choice_rows_data = if show_wt_choice {
self.worktree_choice_rows()
} else {
Vec::new()
};
// Up to four choices (new + worktrees) stay visible around the
// selection.
let wt_choice_capacity = wt_choice_rows_data.len().clamp(1, 4) as u16;
// 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 /* advanced header */ + 1 /* spawn */
+ u16::from(show_custom)
+ u16::from(show_raw)
+ u16::from(show_wt_toggle)
+ u16::from(show_wt_choice) * wt_choice_capacity
+ 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;
// The picker leads the form: it is where focus lands on open, and Tab
// walks down from it into the command tiles.
let mut constraints = vec![Constraint::Length(1)]; // dir filter
constraints.push(Constraint::Length(list_capacity as u16));
if show_raw {
constraints.push(Constraint::Length(1));
}
constraints.push(Constraint::Length(1)); // command tiles
if show_custom {
constraints.push(Constraint::Length(1));
}
constraints.push(Constraint::Length(1)); // name
constraints.push(Constraint::Length(1)); // advanced header
if show_wt_choice {
constraints.push(Constraint::Length(wt_choice_capacity));
}
if show_wt_toggle {
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;
// -- 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));
}
// Step one (#96 rule 1): one box per repository, name then the parent
// directory it sits in. The last segment is the name column already;
// the stem is what keeps two projects sharing a basename apart.
let visible = self.visible_groups();
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(|group| group.main.name.chars().count())
.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 group = visible[i];
let mut base = Style::default();
if group.main_stale {
base = base.add_modifier(Modifier::DIM);
}
if active {
base = base.add_modifier(Modifier::REVERSED);
}
Line::from(vec![
Span::styled(if active { ">" } else { " " }, base),
Span::raw(" "),
Span::styled(format!("{:<name_width$}", group.main.name), base),
Span::styled(
format!(" {}", parent_dir_label(&group.main.path, &self.home_dir)),
base,
),
])
} else {
let mut style = Style::default();
if active {
style = style.add_modifier(Modifier::REVERSED);
}
Line::from(Span::styled("Other directory…", style))
};
f.render_widget(
Paragraph::new(line),
Rect {
x: list_row.x,
y,
width: list_row.width,
height: 1,
},
);
}
// -- free-text path (revealed by Other directory…) -----------------------
if show_raw {
let row = next_row();
draw_gutter(f, row, "path");
let col = draw_text_field(f, &self.cwd_raw, row, field_style(DraftField::CwdRaw));
if self.focused_field == DraftField::CwdRaw {
cursor = Some((col, row.y));
}
}
// -- command presets ---------------------------------------------------
let tiles_row = next_row();
let mut tiles = vec![Span::raw(format!("{:<GUTTER$}", "cmd:"))];
for (i, preset) in self.presets.iter().enumerate() {
if i > 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));
}
}
// -- name -----------------------------------------------------------------
let name_row = next_row();
draw_gutter(f, name_row, "name:");
let col = draw_text_field(f, &self.name, name_row, field_style(DraftField::Name));
if self.focused_field == DraftField::Name {
cursor = Some((col, name_row.y));
}
// An empty field is not a missing value (#131): the daemon names the
// session, so the row says so rather than looking unfinished.
if self.name.value().is_empty() {
let hint_offset = (GUTTER as u16 + 1).min(name_row.width.saturating_sub(10));
f.render_widget(
Paragraph::new(Line::from(Span::styled(
NAME_HINT,
Style::default().add_modifier(Modifier::DIM),
))),
Rect {
x: name_row.x + hint_offset,
y: name_row.y,
width: name_row.width.saturating_sub(hint_offset),
height: 1,
},
);
}
// -- advanced disclosure (#93) ------------------------------------------
let advanced_row = next_row();
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(format!("{:<GUTTER$}", "adv:")),
Span::styled(
format!(
"[{}] Advanced options",
if self.advanced_collapsed { "+" } else { "-" }
),
field_style(DraftField::Advanced),
),
Span::styled(
format!(" — {}", self.advanced_summary()),
Style::default().add_modifier(Modifier::DIM),
),
])),
advanced_row,
);
if show_wt_choice {
// Step two (#96 rule 3): "+ new" first, then the repository's
// worktrees newest-first.
let choice_rows = wt_choice_rows_data;
let csel = choice_rows
.iter()
.position(|(c, _, _)| *c == self.worktree_choice)
.unwrap_or(0);
let ccap = (wt_choice_capacity as usize).min(choice_rows.len().max(1));
let cstart = if csel >= ccap { csel + 1 - ccap } else { 0 };
let label_width = choice_rows
.iter()
.skip(cstart)
.take(ccap)
.map(|(_, label, _)| label.chars().count())
.max()
.unwrap_or(0)
.min(18);
let choice_row = next_row();
for i in cstart..cstart + ccap {
let y = choice_row.y + (i - cstart) as u16;
if y >= choice_row.y + choice_row.height || i >= choice_rows.len() {
break;
}
let (_, label, sub) = &choice_rows[i];
let active =
pane_focused && self.focused_field == DraftField::WorktreeChoice && i == csel;
let mut base = Style::default();
if active {
base = base.add_modifier(Modifier::REVERSED);
}
let line = Line::from(vec![
Span::styled(if active { ">" } else { " " }, base),
Span::raw(" "),
Span::styled(format!("{label:<label_width$}"), base),
Span::styled(format!(" {sub}"), base),
]);
f.render_widget(
Paragraph::new(line),
Rect {
x: choice_row.x + GUTTER as u16,
y,
width: choice_row.width.saturating_sub(GUTTER as u16),
height: 1,
},
);
}
}
// -- worktree (#65) -----------------------------------------------------
if show_wt_toggle {
let wt_row = next_row();
let hint = if self.cwd().is_none() {
Some("choose a directory first")
} else if self.repo_missing {
Some("not inside a git repository")
} else if !self.worktree_enabled {
Some("off — spawns in the checkout itself")
} else {
None
};
let mut tiles = vec![Span::raw(format!("{:<GUTTER$}", "wt:"))];
tiles.push(Span::styled(
if self.worktree_enabled {
"[x] worktree"
} else {
"[ ] worktree"
},
field_style(DraftField::WorktreeToggle),
));
if let Some(hint) = hint {
tiles.push(Span::styled(
format!(" {hint}"),
Style::default().add_modifier(Modifier::DIM),
));
}
f.render_widget(Paragraph::new(Line::from(tiles)), wt_row);
}
if show_wt_body {
// branch source: new (created) vs existing (checked out as-is)
let mode_row = next_row();
let mode_tile = |label: &str, active: bool| {
Span::styled(
format!("[{label}]"),
if active {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
},
)
};
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(format!("{:<GUTTER$}", "mode:")),
mode_tile("new", self.branch_mode == WorktreeBranchMode::New),
Span::raw(" "),
mode_tile("existing", self.branch_mode == WorktreeBranchMode::Existing),
])),
mode_row,
);
match self.branch_mode {
WorktreeBranchMode::New => {
let row = next_row();
draw_gutter(f, row, "new:");
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();
draw_gutter(f, list_row, "pick:");
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!("{:<name_width$}", b.name), base),
Span::styled(reason, base),
])
}
None => 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}"),
crate::palette::active().error_style(),
))),
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:<GUTTER$}")))),
row,
);
}
/// Draw a text input after the label gutter; returns the cursor column.
fn draw_text_field(
f: &mut Frame,
input: &crate::text_input::TextInput,
row: Rect,
style: Style,
) -> 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::<Vec<_>>()
.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 {
focused_field: DraftField::Preset,
..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()[..3],
[
DraftField::DirPicker,
DraftField::Preset,
DraftField::CustomCommand
],
"the picker leads; custom command sits right after the tiles"
);
draft.next_field(); // DirPicker → Preset
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::Name);
}
#[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("~/new") && !rendered.contains("~/new/repo"),
"the parent directory only — the name column is the last segment \
(#96 rule 1):\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(),
);
assert_eq!(
draft.focused_field,
DraftField::DirPicker,
"the form opens with the project filter focused"
);
typed(&mut draft, "maes");
assert_eq!(
draft.visible_groups().len(),
1,
"type-to-filter narrows the list"
);
assert_eq!(
draft.on_input(&InputKey::Enter),
DraftInputResult::Submit,
"#97: the project filter submits with the highlighted project in one Enter"
);
assert_eq!(
draft.picked_cwd.as_deref(),
Some(Path::new("/repos/maestro"))
);
assert_eq!(
draft.command(),
"claude",
"the preset is the operator's own choice — picking a project leaves it alone"
);
}
#[test]
fn enter_submits_immediately_from_ordinary_fields_97() {
let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/repos/maestro")),
base_repo: Some(PathBuf::from("/repos/maestro")),
probed_cwd: Some(PathBuf::from("/repos/maestro")),
..DraftPane::default()
};
for field in [
DraftField::Preset,
DraftField::Name,
DraftField::BranchNew,
DraftField::BranchMode,
] {
draft.focused_field = field;
assert_eq!(
draft.on_input(&InputKey::Enter),
DraftInputResult::Submit,
"{field:?} should submit on Enter"
);
}
draft.selected_preset = draft.presets.len() - 1; // custom
draft.focused_field = DraftField::CustomCommand;
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
}
#[test]
fn enter_submits_immediately_from_eager_apply_pickers_97() {
// WorktreeChoice and BranchExisting apply their highlight the moment
// Up/Down moves it (#96 rule 3/#54) — there is no separate "chosen
// vs highlighted" state left to confirm, so Enter always submits.
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); // picks "maestro", reveals step two
draft.focused_field = DraftField::WorktreeChoice;
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
draft.focused_field = DraftField::BranchExisting;
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
}
#[test]
fn dir_picker_enter_chooses_then_submits_once_the_highlight_is_the_active_pick_97() {
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.focused_field = DraftField::DirPicker;
draft.on_input(&InputKey::Enter); // picks "maestro" (nothing chosen yet, submits)
assert_eq!(
draft.picked_cwd.as_deref(),
Some(Path::new("/repos/maestro"))
);
// Arrow onto the other row: an explicit navigation away from the
// active pick is "mid-selection" — Enter here only chooses it.
draft.move_selection(1);
assert_eq!(
draft.on_input(&InputKey::Enter),
DraftInputResult::Consumed,
"navigating to a different row must not submit the old pick"
);
assert_eq!(
draft.picked_cwd.as_deref(),
Some(Path::new("/repos/other")),
"the new row is chosen"
);
// The highlight is now the active pick: the next Enter submits.
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
}
#[test]
fn a_seeded_directory_is_preselected_so_one_enter_spawns_there() {
let mut draft = DraftPane {
seed_cwd: Some(PathBuf::from("/repos/other")),
..DraftPane::default()
};
draft.set_projects(
vec![
project("maestro", "/repos/maestro", 1, Some("claude")),
project("other", "/repos/other", 2, Some("zsh")),
],
Utc::now(),
);
assert_eq!(
draft.picked_cwd.as_deref(),
Some(Path::new("/repos/other")),
"the seeded project is picked before a key is pressed"
);
assert_eq!(draft.selection(), 1, "and its row is the highlighted one");
assert_eq!(
draft.on_input(&InputKey::Enter),
DraftInputResult::Submit,
"one Enter spawns in the seeded project"
);
assert_eq!(draft.picked_cwd.as_deref(), Some(Path::new("/repos/other")));
}
#[test]
fn a_seed_no_project_matches_leaves_the_picker_untouched() {
let mut draft = DraftPane {
seed_cwd: Some(PathBuf::from("/gone")),
..DraftPane::default()
};
draft.set_projects(
vec![project("maestro", "/repos/maestro", 1, None)],
Utc::now(),
);
assert_eq!(draft.picked_cwd, None);
assert_eq!(draft.selection(), 0);
}
#[test]
fn dir_picker_enter_on_other_directory_never_submits_directly_97() {
let mut draft = DraftPane::default();
draft.set_projects(
vec![project("maestro", "/repos/maestro", 1, None)],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
draft.move_selection(1); // onto "Other directory…"
assert_eq!(
draft.on_input(&InputKey::Enter),
DraftInputResult::Consumed,
"reveals the raw field instead of submitting, like the web form's own button"
);
assert_eq!(draft.focused_field, DraftField::CwdRaw);
}
#[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");
}
/// #131: the field stays empty and the request carries no name — the
/// daemon names the session. The fetched handle only seeds the branch.
#[test]
fn a_fetched_handle_seeds_the_branch_and_leaves_the_name_empty() {
let mut draft = DraftPane::default();
assert!(draft.name.value().is_empty());
draft.seed_names(vec!["crimson-otter".into()]);
assert!(
draft.name.value().is_empty(),
"the name field is the operator's"
);
assert_eq!(draft.branch.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!(
!req.metadata.contains_key("name"),
"an unnamed spawn sends no name"
);
}
/// A name the operator types still rides along, and still seeds the
/// branch (#131).
#[test]
fn a_typed_name_rides_along_and_seeds_the_branch() {
let mut draft = DraftPane::default();
typed_name(&mut draft, "my-session");
assert_eq!(draft.branch.value(), "my-session");
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("my-session")
);
}
/// A handle arriving after the operator typed a name never overwrites it,
/// nor the branch it seeded (#131).
#[test]
fn a_late_handle_never_clobbers_what_was_typed() {
let mut draft = DraftPane::default();
typed_name(&mut draft, "mine");
draft.seed_names(vec!["late-handle".into()]);
assert_eq!(draft.name.value(), "mine");
assert_eq!(draft.branch.value(), "mine");
}
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_naming_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:",
NAME_HINT,
"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 and the pane has no border).
#[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(GUTTER as u16 + 2, 0));
}
#[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);
}
/// A worktree of `group.main` (matching a solo worktree, is_worktree true).
fn linked_worktree(name: &str, path: &str, base: &str, days_ago: i64) -> Project {
let mut wt = repo_project(name, path, base, days_ago, None);
wt.is_worktree = true;
wt
}
#[test]
fn a_query_matching_only_a_worktree_still_finds_its_repository_box() {
// #96 rule 2: the filter keeps a repository when the query matches
// it or any of its worktrees.
let mut draft = DraftPane::default();
draft.set_projects(
vec![
repo_project("main", "/repos/main", "/repos/main", 2, None),
linked_worktree("wt-91", "/repos/wt91", "/repos/main", 1),
],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
typed(&mut draft, "wt-91");
assert_eq!(
draft.visible_groups().len(),
1,
"the worktree's name matches its repository's box"
);
assert_eq!(draft.visible_groups()[0].main.name, "main");
}
#[test]
fn picking_a_repository_box_reveals_step_two_defaulting_to_new() {
// #96 rule 3 (reveal) + rule 4 (default): a repository box that is
// itself a real main checkout defaults step two to "+ new".
let mut draft = DraftPane::default();
draft.set_projects(
vec![
repo_project("main", "/repos/main", "/repos/main", 2, None),
linked_worktree("wt-91", "/repos/wt91", "/repos/main", 1),
],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
draft.on_input(&InputKey::Enter); // picks the only box: "main"
assert_eq!(draft.worktree_choice, WorktreeChoice::New);
assert_eq!(draft.picked_cwd.as_deref(), Some(Path::new("/repos/main")));
assert!(draft.worktree_enabled, "\"+ new\" means create-worktree on");
assert!(
!draft.field_order().contains(&DraftField::WorktreeChoice),
"step two lives behind the collapsed \"Advanced options\" disclosure"
);
draft.toggle_advanced();
assert!(
draft.field_order().contains(&DraftField::WorktreeChoice),
"step two is reachable once advanced is open"
);
assert!(
draft.take_gitroot_probe(probe_due()).is_none(),
"rule 6: the scan already knows this repository"
);
// Step two: move onto the worktree and pick it (rule 5 — existing
// worktree means run in it, create-worktree off).
draft.focused_field = DraftField::WorktreeChoice;
draft.on_input(&InputKey::Down);
assert_eq!(
draft.worktree_choice,
WorktreeChoice::Existing(PathBuf::from("/repos/wt91"))
);
assert_eq!(draft.picked_cwd.as_deref(), Some(Path::new("/repos/wt91")));
assert!(!draft.worktree_enabled);
assert!(
draft.build_request().unwrap().worktree.is_none(),
"no worktree record — the session just runs there"
);
assert!(
draft.take_gitroot_probe(probe_due()).is_none(),
"rule 6: still no probe for a scan-known worktree"
);
}
#[test]
fn a_box_that_is_itself_a_linked_worktree_defaults_to_running_there() {
// #96 rule 4: when the scan found no main checkout, the box IS a
// worktree, and step two defaults to that worktree instead of
// "+ new".
let mut draft = DraftPane::default();
draft.set_projects(
vec![linked_worktree(
"solo-wt",
"/repos/solo-wt",
"/repos/solo",
1,
)],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
draft.on_input(&InputKey::Enter);
assert_eq!(
draft.worktree_choice,
WorktreeChoice::Existing(PathBuf::from("/repos/solo-wt"))
);
assert_eq!(
draft.picked_cwd.as_deref(),
Some(Path::new("/repos/solo-wt"))
);
assert!(!draft.worktree_enabled, "defaults to running in place");
}
#[test]
fn other_directory_clears_both_picker_steps() {
// #96 rule 3.
let mut draft = DraftPane::default();
draft.set_projects(
vec![
repo_project("main", "/repos/main", "/repos/main", 2, None),
linked_worktree("wt-91", "/repos/wt91", "/repos/main", 1),
],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
draft.on_input(&InputKey::Enter); // picks "main", reveals step two
assert!(draft.selected_group_key.is_some());
draft.focused_field = DraftField::DirPicker;
draft.move_selection(1); // onto Other directory…
draft.on_input(&InputKey::Enter);
assert!(draft.cwd_raw_visible);
assert!(
draft.selected_group_key.is_none(),
"step one's pick is cleared"
);
assert!(
draft.worktree_choice_rows().is_empty(),
"step two is cleared too"
);
}
#[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()]);
assert_eq!(draft.branch.value(), "crimson-otter");
draft.focused_field = DraftField::Name;
typed(&mut draft, "renamed");
assert_eq!(
draft.branch.value(),
"renamed",
"the branch tracks the name"
);
draft.focused_field = DraftField::BranchNew;
typed(&mut draft, "-mine");
assert_eq!(draft.branch.value(), "renamed-mine");
draft.seed_names(vec!["late-handle".into()]);
assert_eq!(
draft.branch.value(),
"renamed-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 {
advanced_collapsed: false,
..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_step_two_wording_full_paths_and_newest_first_order() {
let mut draft = DraftPane {
home_dir: "/home/u".into(),
..DraftPane::default()
};
draft.set_projects(
vec![
repo_project("main", "/home/u/repos/main", "/home/u/repos/main", 5, None),
linked_worktree("older-wt", "/home/u/repos/older", "/home/u/repos/main", 3),
linked_worktree("newer-wt", "/home/u/repos/newer", "/home/u/repos/main", 1),
],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
draft.on_input(&InputKey::Enter); // picks the "main" box
let collapsed = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(
!collapsed.contains("+ new"),
"step two hides with \"Advanced options\" closed:\n{collapsed}"
);
draft.toggle_advanced();
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(
rendered.contains("~/repos"),
"step one shows the directory holding the repository:\n{rendered}"
);
assert!(
rendered.contains("+ new"),
"step two's create choice:\n{rendered}"
);
assert!(
rendered.contains("/home/u/repos/newer"),
"step two shows worktrees' full paths:\n{rendered}"
);
let new_pos = rendered.find("+ new").unwrap();
let newer_pos = rendered.find("newer-wt").unwrap();
let older_pos = rendered.find("older-wt").unwrap();
assert!(
new_pos < newer_pos && newer_pos < older_pos,
"\"+ new\" first, then worktrees newest-first:\n{rendered}"
);
}
#[test]
fn render_shows_the_worktree_section() {
let mut draft = DraftPane {
home_dir: "/home/u".into(),
advanced_collapsed: false,
..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]", "new:", "crimson-otter"] {
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
}
// The new-branch input and existing-branch picker must not share a
// label — matching the web form's "New branch name" / "Pick a
// branch" (#94).
draft.set_branch_mode(WorktreeBranchMode::Existing);
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(
rendered.contains("pick:"),
"missing pick: label:\n{rendered}"
);
assert!(
!rendered.contains("br:"),
"stale ambiguous label:\n{rendered}"
);
draft.set_branch_mode(WorktreeBranchMode::New);
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 advanced_section_is_collapsed_by_default_and_states_the_worktree_default() {
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(),
);
assert!(draft.advanced_collapsed, "closed by default");
assert_eq!(
draft
.field_order()
.iter()
.filter(|f| matches!(f, DraftField::WorktreeToggle | DraftField::BranchMode))
.count(),
0,
"collapsed fields drop out of tab order"
);
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(
rendered.contains("Advanced options") && rendered.contains("creates a worktree"),
"header states the on-by-default worktree:\n{rendered}"
);
assert!(
!rendered.contains("[x] worktree"),
"checkbox stays hidden while collapsed:\n{rendered}"
);
}
#[test]
fn advanced_header_states_no_worktree_for_a_worktree_row_91() {
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![wt], Utc::now());
draft.focused_field = DraftField::DirPicker;
draft.on_input(&InputKey::Enter);
assert!(!draft.worktree_enabled);
assert!(
draft.advanced_collapsed,
"picking a row must not force it open"
);
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(
rendered.contains("no worktree"),
"header reflects #91's off default:\n{rendered}"
);
}
#[test]
fn enter_on_the_advanced_header_expands_it_without_moving_fields_above() {
let mut draft = DraftPane {
home_dir: "/home/u".into(),
focused_field: DraftField::Advanced,
..DraftPane::default()
};
draft.set_projects(
vec![project("maestro", "/home/u/tea/maestro", 1, Some("claude"))],
Utc::now(),
);
let before = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
let before_head: Vec<&str> = before.lines().take(6).collect();
draft.on_input(&InputKey::Enter);
assert!(!draft.advanced_collapsed);
let after = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
let after_head: Vec<&str> = after.lines().take(6).collect();
assert_eq!(
before_head, after_head,
"expanding must not move the fields above the disclosure"
);
assert!(after.contains("[x] worktree"), "body now visible:\n{after}");
}
#[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)"
);
}
}