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
2901 lines
109 KiB
Rust
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)"
|
|
);
|
|
}
|
|
}
|