Typing in the raw path field fired one /api/gitroot probe per keystroke (#74). The draft now schedules the probe 250ms out — matching GITROOT_PROBE_DEBOUNCE_MS in web/src/spawnForm.ts — and every keystroke resets it; the 30Hz heartbeat polls for due probes so the burst's one request fires after typing stops. The seq guard in resolve_gitroot is untouched, so a late answer still cannot overwrite a newer input.
2129 lines
78 KiB
Rust
2129 lines
78 KiB
Rust
//! Draft pane — the spawn form living in an ordinary pane (issues #63/#64).
|
|
//!
|
|
//! An empty pane renders a [`DraftPane`] instead of nothing; submitting hands
|
|
//! the caller a built request and the pane is replaced with the new session,
|
|
//! matching the web client's inline draft panes (#57). Field editing goes
|
|
//! through [`crate::text_input::TextInput`]; Tab / BackTab walk the fields.
|
|
//!
|
|
//! Field groups (#64), mirroring the web form (`web/src/spawnForm.ts`):
|
|
//! command presets from `/api/info` (with an implicit `custom` free-text
|
|
//! entry), a project picker fed by [`crate::projects`] over `/api/projects`
|
|
//! plus an `Other directory…` escape hatch, a generated session name from
|
|
//! `/api/names` with a reroll key, and validation that reports which field is
|
|
//! missing instead of posting an empty request. A worktree section (#65)
|
|
//! mirrors `web/src/spawnForm.ts`: on by default over a git repository,
|
|
//! branch tracking the generated name until touched, with a new-or-existing
|
|
//! branch switch fed by `/api/branches`.
|
|
|
|
use std::collections::VecDeque;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use maestro_config::{builtin_presets, custom_preset};
|
|
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
|
|
use ratatui::layout::{Constraint, Layout, Rect};
|
|
use ratatui::style::{Color, Modifier, Style};
|
|
use ratatui::text::{Line, Span};
|
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
|
use ratatui::Frame;
|
|
|
|
use crate::input::InputKey;
|
|
use crate::projects;
|
|
|
|
/// How many generated names one `/api/names` refill fetches. Rerolls consume
|
|
/// the local batch so they stay instant; the daemon is hit only when it runs
|
|
/// dry.
|
|
pub const NAME_BATCH_SIZE: usize = 20;
|
|
|
|
/// Quiet period after raw-path typing before a `/api/gitroot` probe fires
|
|
/// (#74). Every keystroke resets it, so a burst issues one request once the
|
|
/// operator pauses. Must stay equal to `GITROOT_PROBE_DEBOUNCE_MS` in
|
|
/// web/src/spawnForm.ts so both clients debounce alike.
|
|
pub const GITROOT_PROBE_DEBOUNCE_MS: u64 = 250;
|
|
|
|
/// Width of the fixed label gutter every form line starts with (`cmd: `,
|
|
/// `dir: `, …).
|
|
const GUTTER: usize = 5;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DraftField {
|
|
/// The horizontal preset-tile row (Left / Right move the selection).
|
|
Preset,
|
|
/// Free-text command, reachable only while a custom preset is selected.
|
|
CustomCommand,
|
|
/// Project picker: typing filters, Up / Down highlight, Enter picks.
|
|
DirPicker,
|
|
/// Free-text path, revealed by picking `Other directory…`.
|
|
CwdRaw,
|
|
Name,
|
|
/// Worktree on/off for this session (#65). Enter / Space toggles.
|
|
WorktreeToggle,
|
|
/// New-vs-existing branch source; Left / Right switches.
|
|
BranchMode,
|
|
/// Free-text new-branch name, shown in `New` mode.
|
|
BranchNew,
|
|
/// Existing-branch picker, shown in `Existing` mode.
|
|
BranchExisting,
|
|
Submit,
|
|
}
|
|
|
|
/// Where a worktree's branch comes from (#65), matching the web form's
|
|
/// `WorktreeBranchMode`.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum WorktreeBranchMode {
|
|
/// Create the branch (named after the generated session name by default).
|
|
New,
|
|
/// Check out a branch picked from `GET /api/branches` as-is.
|
|
Existing,
|
|
}
|
|
|
|
/// One flattened project-picker entry: a discovered repo/worktree plus what
|
|
/// rendering needs to know about it. `indent` marks worktrees nested under
|
|
/// their main checkout.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DraftRow {
|
|
pub project: Project,
|
|
pub stale: bool,
|
|
pub indent: bool,
|
|
}
|
|
|
|
/// Why a submit was refused. The message names the missing field so the form
|
|
/// can show it instead of posting an empty request.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DraftValidationError {
|
|
MissingDirectory,
|
|
MissingCommand,
|
|
MissingWorktreeRepo,
|
|
MissingNewBranch,
|
|
MissingExistingBranch,
|
|
}
|
|
|
|
impl std::fmt::Display for DraftValidationError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::MissingDirectory => write!(f, "Pick a working directory."),
|
|
Self::MissingCommand => write!(f, "Pick a command (or fill the custom command)."),
|
|
Self::MissingWorktreeRepo => {
|
|
write!(f, "Worktree needs a directory inside a git repository.")
|
|
}
|
|
Self::MissingNewBranch => write!(f, "Worktree requires a branch name."),
|
|
Self::MissingExistingBranch => write!(f, "Pick an existing branch."),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DraftInputResult {
|
|
Consumed,
|
|
Submit,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct DraftPane {
|
|
/// Identity for routing async load results back to *this* draft.
|
|
///
|
|
/// Tab indices shift when a tab closes, so an in-flight load addressed by
|
|
/// index could be dropped or land on the wrong pane (#67 review).
|
|
pub id: u64,
|
|
/// Includes the implicit custom tile (empty `command`) last.
|
|
pub presets: Vec<SpawnPreset>,
|
|
pub selected_preset: usize,
|
|
pub custom_command: crate::text_input::TextInput,
|
|
/// Discovered projects, sorted fresh-first with worktrees nested.
|
|
pub rows: Vec<DraftRow>,
|
|
pub filter: crate::text_input::TextInput,
|
|
/// Highlighted entry within the visible list; the implicit
|
|
/// `Other directory…` entry sits at index `visible_rows().len()`.
|
|
pub highlighted: usize,
|
|
pub cwd_raw_visible: bool,
|
|
pub cwd_raw: crate::text_input::TextInput,
|
|
pub picked_cwd: Option<PathBuf>,
|
|
pub name: crate::text_input::TextInput,
|
|
pub name_queue: VecDeque<String>,
|
|
/// Set once the operator types or rerolls a name; guards refills against
|
|
/// clobbering it, exactly like the web form's `nameTouched`.
|
|
pub name_touched: bool,
|
|
needs_name_refill: bool,
|
|
/// Worktree on for this spawn (#65). Defaults on; a failed gitroot probe
|
|
/// unticks it, like the web form.
|
|
pub worktree_enabled: bool,
|
|
/// Enclosing repository of the chosen target — from the project scan for
|
|
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
|
|
pub base_repo: Option<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(),
|
|
rows: Vec::new(),
|
|
filter: crate::text_input::TextInput::default(),
|
|
highlighted: 0,
|
|
cwd_raw_visible: false,
|
|
cwd_raw: crate::text_input::TextInput::default(),
|
|
picked_cwd: None,
|
|
name: crate::text_input::TextInput::default(),
|
|
name_queue: VecDeque::new(),
|
|
name_touched: false,
|
|
needs_name_refill: false,
|
|
worktree_enabled: true,
|
|
base_repo: None,
|
|
probed_cwd: None,
|
|
probe_seq: 0,
|
|
needs_probe: false,
|
|
probe_due_at: None,
|
|
repo_missing: false,
|
|
branch: crate::text_input::TextInput::default(),
|
|
branch_edited: false,
|
|
branch_mode: WorktreeBranchMode::New,
|
|
branches: Vec::new(),
|
|
selected_branch: 0,
|
|
branches_for: None,
|
|
needs_branches: false,
|
|
error: None,
|
|
focused_field: DraftField::Preset,
|
|
home_dir: String::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl DraftPane {
|
|
/// Replace the preset list from `GET /api/info`. An empty-command tile is
|
|
/// treated as the custom escape hatch and appended if missing, so a daemon
|
|
/// list without one still leaves the operator a way out.
|
|
pub fn set_presets(&mut self, mut presets: Vec<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), nest worktrees
|
|
/// under their main checkout, 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.rows = groups
|
|
.iter()
|
|
.flat_map(|g| {
|
|
std::iter::once(DraftRow {
|
|
stale: projects::is_stale(g.main, now),
|
|
indent: false,
|
|
project: g.main.clone(),
|
|
})
|
|
.chain(g.worktrees.iter().map(|wt| DraftRow {
|
|
stale: projects::is_stale(wt, now),
|
|
indent: true,
|
|
project: (*wt).clone(),
|
|
}))
|
|
})
|
|
.collect();
|
|
self.highlighted = self.highlighted.min(self.entry_count().saturating_sub(1));
|
|
}
|
|
|
|
/// Fill the local name batch from `GET /api/names`, seeding the field
|
|
/// itself unless the operator already typed one.
|
|
pub fn seed_names(&mut self, names: Vec<String>) {
|
|
self.name_queue.extend(names);
|
|
if !self.name_touched && self.name.value().trim().is_empty() {
|
|
if let Some(first) = self.name_queue.pop_front() {
|
|
self.set_name(first);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Append a refill batch; the first name replaces the field (this only
|
|
/// happens after an explicit reroll on an empty batch).
|
|
///
|
|
/// The shown name is *consumed*, not left at the head — queueing it too
|
|
/// made the next reroll produce the same name and the key look dead
|
|
/// (#67 review).
|
|
pub fn extend_names(&mut self, mut names: Vec<String>) {
|
|
if names.is_empty() {
|
|
return;
|
|
}
|
|
let first = names.remove(0);
|
|
self.set_name(first);
|
|
self.name_queue.extend(names);
|
|
}
|
|
|
|
pub fn set_name(&mut self, name: impl Into<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.
|
|
fn sync_branch_to_name(&mut self) {
|
|
if !self.branch_edited && self.branch_mode == WorktreeBranchMode::New {
|
|
self.branch = crate::text_input::TextInput::with_value(self.name.value().to_string());
|
|
}
|
|
}
|
|
|
|
/// Reroll the generated name. Pulls from the local batch; when it has run
|
|
/// dry, flags the caller to fetch a new one via [`Self::take_name_refill`].
|
|
pub fn reroll(&mut self) {
|
|
self.name_touched = true;
|
|
match self.name_queue.pop_front() {
|
|
Some(next) => self.set_name(next),
|
|
None => self.needs_name_refill = true,
|
|
}
|
|
}
|
|
|
|
/// Consume the reroll-needs-a-new-batch flag raised by [`Self::reroll`].
|
|
pub fn take_name_refill(&mut self) -> bool {
|
|
std::mem::take(&mut self.needs_name_refill)
|
|
}
|
|
|
|
// -- worktree (#65) -----------------------------------------------------
|
|
|
|
/// Whether the branch controls are shown: the toggle alone appears until
|
|
/// the target is known to be inside a repository.
|
|
fn worktree_body_visible(&self) -> bool {
|
|
self.worktree_enabled && self.base_repo.is_some()
|
|
}
|
|
|
|
/// A target the draft already knows the repo for (picked project row).
|
|
fn note_picked_repo(&mut self, base_repo: PathBuf) {
|
|
self.base_repo = Some(base_repo);
|
|
self.repo_missing = false;
|
|
self.probe_seq += 1;
|
|
self.needs_probe = false;
|
|
self.probe_due_at = None;
|
|
self.probed_cwd = self.cwd();
|
|
self.branches_for = None;
|
|
self.schedule_branch_load();
|
|
}
|
|
|
|
/// A target with an unknown repo (raw path edit or the Other… hatch).
|
|
/// Cancels any in-flight probe and schedules a fresh one after the
|
|
/// debounce window (#74).
|
|
fn note_unknown_repo(&mut self) {
|
|
self.base_repo = None;
|
|
self.repo_missing = false;
|
|
self.probe_seq += 1;
|
|
self.needs_probe = self.worktree_enabled && self.cwd().is_some();
|
|
self.probe_due_at = self
|
|
.needs_probe
|
|
.then(|| Instant::now() + Duration::from_millis(GITROOT_PROBE_DEBOUNCE_MS));
|
|
self.branches_for = None;
|
|
self.schedule_branch_load();
|
|
}
|
|
|
|
fn schedule_branch_load(&mut self) {
|
|
if self.worktree_body_visible() && self.branch_mode == WorktreeBranchMode::Existing {
|
|
self.needs_branches = true;
|
|
}
|
|
}
|
|
|
|
/// Consume the gitroot-probe request once its debounce window has passed
|
|
/// (`now` at or after [`Self::probe_due_at`]): the caller fetches
|
|
/// `GET /api/gitroot?path=…` and reports back via [`Self::resolve_gitroot`]
|
|
/// with the same seq. Returns `None` while typing is still in flight.
|
|
pub fn take_gitroot_probe(&mut self, now: Instant) -> Option<(u32, PathBuf)> {
|
|
if !self.needs_probe {
|
|
return None;
|
|
}
|
|
if now < self.probe_due_at? {
|
|
return None;
|
|
}
|
|
self.needs_probe = false;
|
|
self.probe_due_at = None;
|
|
let cwd = self.cwd()?;
|
|
self.probe_seq += 1;
|
|
Some((self.probe_seq, cwd))
|
|
}
|
|
|
|
/// Apply one `/api/gitroot` answer. Stale answers — anything not matching
|
|
/// the latest seq — are dropped, so rapid retyping can't resurrect an old
|
|
/// repo. A miss unticks the toggle (web parity) and records the hint.
|
|
pub fn resolve_gitroot(&mut self, seq: u32, root: Option<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 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::Preset];
|
|
if self.is_custom() {
|
|
order.push(DraftField::CustomCommand);
|
|
}
|
|
order.push(DraftField::DirPicker);
|
|
if self.cwd_raw_visible {
|
|
order.push(DraftField::CwdRaw);
|
|
}
|
|
order.push(DraftField::Name);
|
|
order.push(DraftField::WorktreeToggle);
|
|
if self.worktree_body_visible() {
|
|
order.push(DraftField::BranchMode);
|
|
order.push(match self.branch_mode {
|
|
WorktreeBranchMode::New => DraftField::BranchNew,
|
|
WorktreeBranchMode::Existing => DraftField::BranchExisting,
|
|
});
|
|
}
|
|
order.extend([DraftField::Submit]);
|
|
order
|
|
}
|
|
|
|
fn step_field(&mut self, delta: i32) {
|
|
let order = self.field_order();
|
|
let len = order.len() as i32;
|
|
let cur = order
|
|
.iter()
|
|
.position(|f| *f == self.focused_field)
|
|
.unwrap_or(0) as i32;
|
|
self.focused_field = order[(cur + delta).rem_euclid(len) as usize];
|
|
}
|
|
|
|
pub fn next_field(&mut self) {
|
|
self.step_field(1);
|
|
}
|
|
|
|
pub fn prev_field(&mut self) {
|
|
self.step_field(-1);
|
|
}
|
|
|
|
pub fn cycle_preset(&mut self, delta: i32) {
|
|
let len = self.presets.len();
|
|
if len == 0 {
|
|
return;
|
|
}
|
|
self.selected_preset =
|
|
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
|
|
}
|
|
|
|
/// Rows surviving the type-to-filter, in display order.
|
|
pub fn visible_rows(&self) -> Vec<&DraftRow> {
|
|
let query = self.filter.value();
|
|
self.rows
|
|
.iter()
|
|
.filter(|r| projects::matches_filter(&r.project, query))
|
|
.collect()
|
|
}
|
|
|
|
/// Visible entries including the trailing implicit `Other directory…`.
|
|
pub fn entry_count(&self) -> usize {
|
|
self.visible_rows().len() + 1
|
|
}
|
|
|
|
/// Highlight clamped to the current entry count.
|
|
pub fn selection(&self) -> usize {
|
|
self.highlighted.min(self.entry_count().saturating_sub(1))
|
|
}
|
|
|
|
pub fn move_selection(&mut self, delta: i32) {
|
|
let count = self.entry_count() as i32;
|
|
if count == 0 {
|
|
return;
|
|
}
|
|
self.highlighted = ((self.selection() as i32) + delta).rem_euclid(count) as usize;
|
|
}
|
|
|
|
/// Act on the highlighted picker entry: a project becomes the working
|
|
/// directory (and pre-selects the command it was last used with), while
|
|
/// the trailing entry reveals the free-text path input.
|
|
pub fn select_entry(&mut self) {
|
|
let visible = self.visible_rows();
|
|
let sel = self.selection();
|
|
if sel < visible.len() {
|
|
let project = visible[sel].project.clone();
|
|
self.picked_cwd = Some(project.path.clone());
|
|
self.cwd_raw_visible = false;
|
|
// A directory that *is* a linked worktree defaults to spawning in
|
|
// place (#91): creating a second worktree of its branch fails.
|
|
// Turning it back on by hand still tries (git's own message),
|
|
// which is deliberate (#91 non-goal).
|
|
self.worktree_enabled = !project.is_worktree;
|
|
// The scan already knows this directory's repo (#61): no probe
|
|
// round trip needed, exactly like the web form.
|
|
let repo = project
|
|
.base_repo
|
|
.clone()
|
|
.unwrap_or_else(|| project.path.clone());
|
|
if let Some(cmd) = project.last_command.as_deref() {
|
|
if !cmd.is_empty() {
|
|
if let Some(idx) = self.presets.iter().position(|p| p.command == cmd) {
|
|
self.selected_preset = idx;
|
|
}
|
|
}
|
|
}
|
|
self.note_picked_repo(repo);
|
|
return;
|
|
}
|
|
let seed = self.cwd().unwrap_or_else(|| {
|
|
if self.home_dir.is_empty() {
|
|
PathBuf::from("/")
|
|
} else {
|
|
PathBuf::from(&self.home_dir)
|
|
}
|
|
});
|
|
self.picked_cwd = None;
|
|
self.cwd_raw_visible = true;
|
|
self.cwd_raw = crate::text_input::TextInput::with_value(seed.to_string_lossy());
|
|
self.focused_field = DraftField::CwdRaw;
|
|
self.note_unknown_repo();
|
|
}
|
|
|
|
/// Handle one keystroke while this draft owns input (Insert mode, pane
|
|
/// focused). Returns [`DraftInputResult::Submit`] when the user accepts;
|
|
/// the caller validates, fires the POST and replaces the draft with the
|
|
/// session — or shows the reason it couldn't.
|
|
pub fn on_input(&mut self, key: &InputKey) -> DraftInputResult {
|
|
match key {
|
|
InputKey::Tab => {
|
|
self.next_field();
|
|
DraftInputResult::Consumed
|
|
}
|
|
InputKey::BackTab => {
|
|
self.prev_field();
|
|
DraftInputResult::Consumed
|
|
}
|
|
InputKey::Enter => match self.focused_field {
|
|
DraftField::Submit => DraftInputResult::Submit,
|
|
DraftField::DirPicker => {
|
|
self.select_entry();
|
|
DraftInputResult::Consumed
|
|
}
|
|
DraftField::WorktreeToggle => {
|
|
self.toggle_worktree();
|
|
DraftInputResult::Consumed
|
|
}
|
|
// The existing-branch selection is live; Enter just confirms.
|
|
DraftField::BranchExisting => DraftInputResult::Consumed,
|
|
_ => {
|
|
self.next_field();
|
|
DraftInputResult::Consumed
|
|
}
|
|
},
|
|
InputKey::Char {
|
|
c: ' ',
|
|
ctrl: false,
|
|
..
|
|
} if self.focused_field == DraftField::WorktreeToggle => {
|
|
self.toggle_worktree();
|
|
DraftInputResult::Consumed
|
|
}
|
|
InputKey::Up if self.focused_field == DraftField::DirPicker => {
|
|
self.move_selection(-1);
|
|
DraftInputResult::Consumed
|
|
}
|
|
InputKey::Down if self.focused_field == DraftField::DirPicker => {
|
|
self.move_selection(1);
|
|
DraftInputResult::Consumed
|
|
}
|
|
InputKey::Left if self.focused_field == DraftField::Preset => {
|
|
self.cycle_preset(-1);
|
|
DraftInputResult::Consumed
|
|
}
|
|
InputKey::Right if self.focused_field == DraftField::Preset => {
|
|
self.cycle_preset(1);
|
|
DraftInputResult::Consumed
|
|
}
|
|
InputKey::Left if self.focused_field == DraftField::BranchMode => {
|
|
self.cycle_branch_mode(-1);
|
|
DraftInputResult::Consumed
|
|
}
|
|
InputKey::Right if self.focused_field == DraftField::BranchMode => {
|
|
self.cycle_branch_mode(1);
|
|
DraftInputResult::Consumed
|
|
}
|
|
InputKey::Up if self.focused_field == DraftField::BranchExisting => {
|
|
self.move_branch_selection(-1);
|
|
DraftInputResult::Consumed
|
|
}
|
|
InputKey::Down if self.focused_field == DraftField::BranchExisting => {
|
|
self.move_branch_selection(1);
|
|
DraftInputResult::Consumed
|
|
}
|
|
InputKey::Char {
|
|
c: 'r', ctrl: true, ..
|
|
} if self.focused_field == DraftField::Name => {
|
|
self.reroll();
|
|
DraftInputResult::Consumed
|
|
}
|
|
other => {
|
|
match self.focused_field {
|
|
DraftField::Preset | DraftField::Submit => {}
|
|
DraftField::CustomCommand => self.custom_command.handle_key(other),
|
|
// Typing filters the list; the highlight resets to the top
|
|
// so a narrowed search always starts at its best match.
|
|
DraftField::DirPicker => {
|
|
self.filter.handle_key(other);
|
|
self.highlighted = 0;
|
|
}
|
|
DraftField::CwdRaw => {
|
|
self.cwd_raw.handle_key(other);
|
|
// The target moved: the old repo answer no longer
|
|
// applies and a fresh probe is needed.
|
|
self.note_unknown_repo();
|
|
}
|
|
DraftField::Name => {
|
|
self.name.handle_key(other);
|
|
self.name_touched = true;
|
|
self.sync_branch_to_name();
|
|
}
|
|
DraftField::BranchNew => {
|
|
self.branch.handle_key(other);
|
|
self.branch_edited = true;
|
|
}
|
|
_ => {}
|
|
}
|
|
DraftInputResult::Consumed
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Draw into the pane area. `pane_focused` gates the field highlight and
|
|
/// cursor placement the same way a session pane hides its cursor when
|
|
/// unfocused.
|
|
pub fn render(&self, f: &mut Frame, area: Rect, pane_focused: bool) {
|
|
let block = Block::default().title(" draft ").borders(Borders::ALL);
|
|
let inner = block.inner(area);
|
|
f.render_widget(block, area);
|
|
|
|
let show_custom = self.is_custom();
|
|
let show_raw = self.cwd_raw_visible;
|
|
let show_wt_body = self.worktree_body_visible();
|
|
// Up to three branch rows stay visible around the selection; held
|
|
// branches need their reason on screen, so they aren't collapsed.
|
|
let branch_rows = self.branches.len().clamp(1, 3) as u16;
|
|
let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* worktree */ + 1 /* spawn */
|
|
+ u16::from(show_custom)
|
|
+ u16::from(show_raw)
|
|
+ u16::from(show_wt_body) * (1 + if self.branch_mode == WorktreeBranchMode::New { 1 } else { branch_rows })
|
|
+ u16::from(self.error.is_some());
|
|
let list_capacity = (inner.height.saturating_sub(fixed)).max(1) as usize;
|
|
|
|
let mut constraints = vec![Constraint::Length(1)];
|
|
if show_custom {
|
|
constraints.push(Constraint::Length(1));
|
|
}
|
|
constraints.push(Constraint::Length(1));
|
|
constraints.push(Constraint::Length(list_capacity as u16));
|
|
if show_raw {
|
|
constraints.push(Constraint::Length(1));
|
|
}
|
|
constraints.push(Constraint::Length(1)); // name
|
|
constraints.push(Constraint::Length(1)); // worktree toggle
|
|
if show_wt_body {
|
|
constraints.push(Constraint::Length(1)); // branch mode
|
|
match self.branch_mode {
|
|
WorktreeBranchMode::New => constraints.push(Constraint::Length(1)),
|
|
WorktreeBranchMode::Existing => constraints.push(Constraint::Length(branch_rows)),
|
|
}
|
|
}
|
|
constraints.push(Constraint::Length(1));
|
|
if self.error.is_some() {
|
|
constraints.push(Constraint::Length(1));
|
|
}
|
|
let rows = Layout::vertical(constraints).split(inner);
|
|
|
|
let mut r = 0usize;
|
|
let mut next_row = || {
|
|
r += 1;
|
|
rows[r - 1]
|
|
};
|
|
|
|
let field_style = |field: DraftField| {
|
|
if pane_focused && self.focused_field == field {
|
|
Style::default().add_modifier(Modifier::REVERSED)
|
|
} else {
|
|
Style::default()
|
|
}
|
|
};
|
|
|
|
// Tracks where the terminal cursor belongs for the focused text field.
|
|
let mut cursor: Option<(u16, u16)> = None;
|
|
|
|
// -- command presets ---------------------------------------------------
|
|
let tiles_row = next_row();
|
|
let mut tiles = vec![Span::raw(format!("{:<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));
|
|
}
|
|
}
|
|
|
|
// -- directory picker ----------------------------------------------------
|
|
let filter_row = next_row();
|
|
draw_gutter(f, filter_row, "dir:");
|
|
let col = draw_text_field(
|
|
f,
|
|
&self.filter,
|
|
filter_row,
|
|
field_style(DraftField::DirPicker),
|
|
);
|
|
if self.focused_field == DraftField::DirPicker {
|
|
cursor = Some((col, filter_row.y));
|
|
}
|
|
|
|
let visible = self.visible_rows();
|
|
let sel = self.selection();
|
|
let count = visible.len() + 1;
|
|
let capacity = list_capacity.min(count);
|
|
let start = if sel >= capacity {
|
|
sel + 1 - capacity
|
|
} else {
|
|
0
|
|
};
|
|
let name_width = visible
|
|
.iter()
|
|
.take(start + capacity)
|
|
.skip(start)
|
|
.map(|row| row.project.name.chars().count() + usize::from(row.indent) * 2)
|
|
.max()
|
|
.unwrap_or(0)
|
|
.min(18);
|
|
|
|
let list_row = next_row();
|
|
for i in start..start + capacity {
|
|
let y = list_row.y + (i - start) as u16;
|
|
if y >= list_row.y + list_row.height {
|
|
break;
|
|
}
|
|
let active = pane_focused && self.focused_field == DraftField::DirPicker && i == sel;
|
|
let line = if i < visible.len() {
|
|
let row = visible[i];
|
|
let mut base = Style::default();
|
|
if row.stale {
|
|
base = base.add_modifier(Modifier::DIM);
|
|
}
|
|
if active {
|
|
base = base.add_modifier(Modifier::REVERSED);
|
|
}
|
|
let name = if row.indent {
|
|
format!(" {}", row.project.name)
|
|
} else {
|
|
row.project.name.clone()
|
|
};
|
|
Line::from(vec![
|
|
Span::styled(if active { ">" } else { " " }, base),
|
|
Span::raw(" "),
|
|
Span::styled(format!("{name:<name_width$}"), base),
|
|
Span::styled(
|
|
format!(" {}", abbreviate_path(&row.project.path, &self.home_dir)),
|
|
base,
|
|
),
|
|
Span::styled(
|
|
format!(" {}", row.project.last_command.clone().unwrap_or_default()),
|
|
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));
|
|
}
|
|
}
|
|
|
|
// -- name + reroll ---------------------------------------------------------
|
|
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));
|
|
}
|
|
// Dim reroll hint, tucked after the typed name without covering it.
|
|
let hint_offset = (GUTTER as u16 + self.name.value().chars().count() as u16 + 1)
|
|
.min(name_row.width.saturating_sub(10));
|
|
f.render_widget(
|
|
Paragraph::new(Line::from(Span::styled(
|
|
"^R reroll",
|
|
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,
|
|
},
|
|
);
|
|
|
|
// -- worktree (#65) -----------------------------------------------------
|
|
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, "br:");
|
|
let col =
|
|
draw_text_field(f, &self.branch, row, field_style(DraftField::BranchNew));
|
|
if self.focused_field == DraftField::BranchNew {
|
|
cursor = Some((col, row.y));
|
|
}
|
|
}
|
|
WorktreeBranchMode::Existing => {
|
|
let len = self.branches.len();
|
|
let capacity = branch_rows as usize;
|
|
let sel = self.selected_branch.min(len.saturating_sub(1));
|
|
let start = if sel >= capacity {
|
|
sel + 1 - capacity
|
|
} else {
|
|
0
|
|
};
|
|
let name_width = self
|
|
.branches
|
|
.iter()
|
|
.map(|b| b.name.chars().count())
|
|
.max()
|
|
.unwrap_or(0)
|
|
.min(20);
|
|
let list_row = next_row();
|
|
for i in start..(start + capacity).min(len.max(1)) {
|
|
let y = list_row.y + (i - start) as u16;
|
|
if y >= list_row.y + list_row.height {
|
|
break;
|
|
}
|
|
let line = match self.branches.get(i) {
|
|
Some(b) => {
|
|
let held = b.worktree.is_some();
|
|
let mut base = Style::default();
|
|
if held {
|
|
base = base.add_modifier(Modifier::DIM);
|
|
}
|
|
if pane_focused
|
|
&& self.focused_field == DraftField::BranchExisting
|
|
&& i == sel
|
|
{
|
|
base = base.add_modifier(Modifier::REVERSED);
|
|
}
|
|
let reason = b
|
|
.worktree
|
|
.as_ref()
|
|
.map(|w| {
|
|
format!(" — held by {}", abbreviate_path(w, &self.home_dir))
|
|
})
|
|
.unwrap_or_default();
|
|
Line::from(vec![
|
|
Span::styled(if i == sel { ">" } else { " " }, base),
|
|
Span::raw(" "),
|
|
Span::styled(format!("{:<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}"),
|
|
Style::default().fg(Color::Red),
|
|
))),
|
|
row,
|
|
);
|
|
}
|
|
|
|
if let Some((x, y)) = cursor.filter(|_| pane_focused) {
|
|
f.set_cursor_position((x, y));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn draw_gutter(f: &mut Frame, row: Rect, label: &str) {
|
|
f.render_widget(
|
|
Paragraph::new(Line::from(Span::raw(format!("{label:<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::default();
|
|
draft.on_input(&InputKey::Right);
|
|
assert_eq!(draft.command(), "opencode");
|
|
draft.on_input(&InputKey::Left);
|
|
assert_eq!(draft.command(), "claude");
|
|
// Wrapping backwards off the first tile lands on the custom one.
|
|
draft.on_input(&InputKey::Left);
|
|
assert!(draft.is_custom(), "wraps backwards onto the custom tile");
|
|
draft.on_input(&InputKey::Right);
|
|
assert_eq!(draft.command(), "claude");
|
|
assert_eq!(
|
|
draft.focused_field,
|
|
DraftField::Preset,
|
|
"selection changed, focus stays"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn selecting_custom_reveals_a_free_text_command_field() {
|
|
let mut draft = DraftPane::default();
|
|
draft.selected_preset = draft.presets.len() - 1;
|
|
assert!(draft.is_custom());
|
|
assert_eq!(
|
|
draft.field_order()[..2],
|
|
[DraftField::Preset, DraftField::CustomCommand],
|
|
"custom command sits between presets and the dir picker"
|
|
);
|
|
draft.next_field();
|
|
assert_eq!(draft.focused_field, DraftField::CustomCommand);
|
|
typed(&mut draft, "bash -l");
|
|
assert_eq!(draft.command(), "bash -l");
|
|
// Tab walks past the hidden fields in order.
|
|
draft.next_field();
|
|
assert_eq!(draft.focused_field, DraftField::DirPicker);
|
|
}
|
|
|
|
#[test]
|
|
fn custom_command_flows_into_the_request() {
|
|
let mut draft = DraftPane::default();
|
|
draft.picked_cwd = Some(PathBuf::from("/srv/work"));
|
|
draft.base_repo = Some(PathBuf::from("/srv/work"));
|
|
draft.probed_cwd = Some(PathBuf::from("/srv/work"));
|
|
draft.worktree_enabled = false; // under test: command + cwd only
|
|
draft.selected_preset = draft.presets.len() - 1;
|
|
draft.custom_command = crate::text_input::TextInput::with_value("bash -l");
|
|
let req = draft.build_request().unwrap();
|
|
assert_eq!(req.command, "bash -l");
|
|
assert_eq!(req.cwd, PathBuf::from("/srv/work"));
|
|
}
|
|
|
|
#[test]
|
|
fn projects_render_sorted_with_stale_dimmed_and_other_directory_last() {
|
|
let mut draft = DraftPane {
|
|
home_dir: "/home/u".into(),
|
|
..DraftPane::default()
|
|
};
|
|
draft.set_projects(
|
|
vec![
|
|
project("stale-repo", "/home/u/old/repo", 30, Some("zsh")),
|
|
project("fresh-repo", "/home/u/new/repo", 1, Some("claude")),
|
|
],
|
|
Utc::now(),
|
|
);
|
|
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
|
assert!(rendered.contains("Other directory…"), "{rendered}");
|
|
let fresh_pos = rendered.find("fresh-repo").unwrap();
|
|
let stale_pos = rendered.find("stale-repo").unwrap();
|
|
assert!(fresh_pos < stale_pos, "fresh before stale:\n{rendered}");
|
|
assert!(
|
|
rendered.contains("~/n/repo"),
|
|
"collapsed path stem:\n{rendered}"
|
|
);
|
|
assert!(
|
|
rendered.contains("claude"),
|
|
"last command shown:\n{rendered}"
|
|
);
|
|
|
|
// Staleness dims the row's cells.
|
|
let backend = TestBackend::new(100, 24);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
terminal.draw(|f| draft.render(f, f.area(), true)).unwrap();
|
|
let buf = terminal.backend().buffer();
|
|
let stale_cell = buf
|
|
.content()
|
|
.iter()
|
|
.find(|c| c.symbol() == "s" && c.modifier.contains(Modifier::DIM));
|
|
assert!(stale_cell.is_some(), "stale row renders dimmed");
|
|
}
|
|
|
|
#[test]
|
|
fn typing_filters_and_enter_picks_a_project_as_the_working_directory() {
|
|
let mut draft = DraftPane::default();
|
|
draft.set_projects(
|
|
vec![
|
|
project("maestro", "/repos/maestro", 1, Some("claude")),
|
|
project("other", "/repos/other", 2, Some("zsh")),
|
|
],
|
|
Utc::now(),
|
|
);
|
|
draft.next_field(); // Preset → DirPicker
|
|
assert_eq!(draft.focused_field, DraftField::DirPicker);
|
|
typed(&mut draft, "maes");
|
|
assert_eq!(
|
|
draft.visible_rows().len(),
|
|
1,
|
|
"type-to-filter narrows the list"
|
|
);
|
|
draft.on_input(&InputKey::Enter);
|
|
assert_eq!(
|
|
draft.picked_cwd.as_deref(),
|
|
Some(Path::new("/repos/maestro"))
|
|
);
|
|
assert_eq!(
|
|
draft.command(),
|
|
"claude",
|
|
"pick pre-selects the last command"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn up_down_moves_the_highlight_within_the_list() {
|
|
let mut draft = DraftPane::default();
|
|
draft.set_projects(
|
|
vec![project("a", "/a", 1, None), project("b", "/b", 2, None)],
|
|
Utc::now(),
|
|
);
|
|
draft.focused_field = DraftField::DirPicker;
|
|
assert_eq!(draft.selection(), 0);
|
|
draft.on_input(&InputKey::Down);
|
|
draft.on_input(&InputKey::Down);
|
|
assert_eq!(
|
|
draft.selection(),
|
|
2,
|
|
"the Other directory… entry is reachable"
|
|
);
|
|
draft.on_input(&InputKey::Down);
|
|
assert_eq!(draft.selection(), 0, "wraps");
|
|
draft.on_input(&InputKey::Up);
|
|
assert_eq!(draft.selection(), 2, "wraps backwards");
|
|
}
|
|
|
|
#[test]
|
|
fn other_directory_reveals_a_free_text_path_seeded_from_home() {
|
|
let mut draft = DraftPane {
|
|
home_dir: "/home/u".into(),
|
|
..DraftPane::default()
|
|
};
|
|
draft.set_projects(vec![project("a", "/a", 1, None)], Utc::now());
|
|
draft.focused_field = DraftField::DirPicker;
|
|
draft.move_selection(1); // onto Other directory…
|
|
draft.on_input(&InputKey::Enter);
|
|
assert!(draft.cwd_raw_visible);
|
|
assert_eq!(draft.focused_field, DraftField::CwdRaw);
|
|
assert_eq!(draft.cwd_raw.value(), "/home/u");
|
|
typed(&mut draft, "/opt/tooling");
|
|
assert_eq!(draft.cwd(), Some(PathBuf::from("/home/u/opt/tooling")));
|
|
// The raw target probed clean, so the worktree spec carries its repo.
|
|
let (seq, _) = draft.take_gitroot_probe(probe_due()).unwrap();
|
|
draft.resolve_gitroot(seq, Some(PathBuf::from("/home/u/opt/tooling")));
|
|
draft.seed_names(vec!["amber-falcon".into()]);
|
|
let req = draft.build_request().unwrap();
|
|
assert_eq!(req.cwd, PathBuf::from("/home/u/opt/tooling"));
|
|
assert_eq!(
|
|
req.worktree.unwrap().base_repo,
|
|
PathBuf::from("/home/u/opt/tooling")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn picking_other_directory_after_a_pick_seeds_the_raw_field_with_the_pick() {
|
|
let mut draft = DraftPane {
|
|
home_dir: "/home/u".into(),
|
|
picked_cwd: Some(PathBuf::from("/repos/picked")),
|
|
..DraftPane::default()
|
|
};
|
|
draft.select_entry(); // highlight defaults past the (empty) list → Other…
|
|
assert!(draft.cwd_raw_visible);
|
|
assert_eq!(draft.cwd_raw.value(), "/repos/picked");
|
|
}
|
|
|
|
#[test]
|
|
fn generated_names_seed_the_field_and_metadata() {
|
|
let mut draft = DraftPane::default();
|
|
assert!(
|
|
draft.name.value().is_empty(),
|
|
"no name until /api/names lands"
|
|
);
|
|
draft.seed_names(vec!["crimson-otter".into(), "amber-falcon".into()]);
|
|
assert_eq!(draft.name.value(), "crimson-otter");
|
|
|
|
draft.picked_cwd = Some(PathBuf::from("/w"));
|
|
draft.base_repo = Some(PathBuf::from("/w"));
|
|
draft.probed_cwd = Some(PathBuf::from("/w"));
|
|
let req = draft.build_request().unwrap();
|
|
assert_eq!(
|
|
req.metadata.get("name").and_then(|v| v.as_str()),
|
|
Some("crimson-otter"),
|
|
"name rides along as metadata.name"
|
|
);
|
|
|
|
draft.reroll();
|
|
assert_eq!(
|
|
draft.name.value(),
|
|
"amber-falcon",
|
|
"reroll consumes the batch"
|
|
);
|
|
draft.reroll();
|
|
assert!(
|
|
draft.take_name_refill(),
|
|
"dry batch flags the caller to refetch"
|
|
);
|
|
assert!(!draft.take_name_refill(), "flag consumes");
|
|
}
|
|
|
|
#[test]
|
|
fn refills_do_not_clobber_a_name_the_operator_typed() {
|
|
let mut draft = DraftPane::default();
|
|
draft.seed_names(vec!["seeded".into()]);
|
|
typed_name(&mut draft, "-mine");
|
|
assert_eq!(draft.name.value(), "seeded-mine");
|
|
draft.seed_names(vec!["late-batch".into()]);
|
|
assert_eq!(
|
|
draft.name.value(),
|
|
"seeded-mine",
|
|
"late initial batches never overwrite an edited name"
|
|
);
|
|
}
|
|
|
|
fn typed_name(draft: &mut DraftPane, s: &str) {
|
|
draft.focused_field = DraftField::Name;
|
|
for c in s.chars() {
|
|
draft.on_input(&InputKey::char(c));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_name_sends_no_metadata_at_all() {
|
|
let mut draft = DraftPane {
|
|
picked_cwd: Some(PathBuf::from("/w")),
|
|
..DraftPane::default()
|
|
};
|
|
draft.base_repo = Some(PathBuf::from("/w"));
|
|
draft.probed_cwd = Some(PathBuf::from("/w"));
|
|
// No names seeded: the empty new-branch field would refuse, so spawn
|
|
// in the checkout to isolate the metadata assertion.
|
|
draft.worktree_enabled = false;
|
|
let req = draft.build_request().unwrap();
|
|
assert!(req.metadata.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn validation_names_the_missing_field_instead_of_posting() {
|
|
let mut draft = DraftPane::default();
|
|
assert_eq!(
|
|
draft.build_request(),
|
|
Err(DraftValidationError::MissingDirectory)
|
|
);
|
|
assert_eq!(
|
|
DraftValidationError::MissingDirectory.to_string(),
|
|
"Pick a working directory."
|
|
);
|
|
|
|
draft.cwd_raw = crate::text_input::TextInput::with_value("/srv/work");
|
|
draft.picked_cwd = Some(PathBuf::from("/srv/work"));
|
|
draft.selected_preset = draft.presets.len() - 1; // custom, empty text
|
|
assert_eq!(
|
|
draft.build_request(),
|
|
Err(DraftValidationError::MissingCommand)
|
|
);
|
|
assert_eq!(
|
|
DraftValidationError::MissingCommand.to_string(),
|
|
"Pick a command (or fill the custom command)."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_shows_every_field_group_and_the_reroll_hint() {
|
|
let mut draft = DraftPane {
|
|
home_dir: "/home/u".into(),
|
|
..DraftPane::default()
|
|
};
|
|
draft.set_projects(
|
|
vec![project("maestro", "/home/u/tea/maestro", 1, Some("claude"))],
|
|
Utc::now(),
|
|
);
|
|
draft.seed_names(vec!["crimson-otter".into()]);
|
|
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
|
for s in [
|
|
"[Claude]",
|
|
"[opencode]",
|
|
"[Shell]",
|
|
"[Custom…]",
|
|
"dir:",
|
|
"name:",
|
|
"^R reroll",
|
|
"maestro",
|
|
"[ Spawn ]",
|
|
] {
|
|
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn render_shows_the_error_line_when_set() {
|
|
let draft = DraftPane {
|
|
error: Some(DraftValidationError::MissingDirectory.to_string()),
|
|
..DraftPane::default()
|
|
};
|
|
let rendered = render_to_string(80, 24, |f| draft.render(f, f.area(), true));
|
|
assert!(
|
|
rendered.contains("error: Pick a working directory."),
|
|
"{rendered}"
|
|
);
|
|
}
|
|
|
|
/// A short pane still shows the essential fields; the list window scrolls
|
|
/// with the selection instead of overflowing the border.
|
|
#[test]
|
|
fn render_survives_a_small_pane() {
|
|
let mut draft = DraftPane::default();
|
|
draft.set_projects(
|
|
(0..10)
|
|
.map(|i| project(&format!("p{i}"), &format!("/p{i}"), i, None))
|
|
.collect(),
|
|
Utc::now(),
|
|
);
|
|
draft.focused_field = DraftField::DirPicker;
|
|
for _ in 0..8 {
|
|
draft.on_input(&InputKey::Down);
|
|
}
|
|
let rendered = render_to_string(80, 12, |f| draft.render(f, f.area(), true));
|
|
assert!(
|
|
rendered.contains("[ Spawn ]"),
|
|
"submit still visible:\n{rendered}"
|
|
);
|
|
assert!(rendered.contains('p'), "some projects visible:\n{rendered}");
|
|
}
|
|
|
|
/// The terminal cursor must sit right after the typed text in the focused
|
|
/// field (gutter is 5 chars, block border takes column 0).
|
|
#[test]
|
|
fn render_places_the_cursor_in_the_focused_text_field() {
|
|
let mut draft = DraftPane {
|
|
focused_field: DraftField::DirPicker,
|
|
..DraftPane::default()
|
|
};
|
|
typed(&mut draft, "ab");
|
|
let backend = TestBackend::new(80, 20);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
terminal.draw(|f| draft.render(f, f.area(), true)).unwrap();
|
|
let pos = terminal.get_cursor_position().unwrap();
|
|
assert_eq!(
|
|
pos,
|
|
ratatui::layout::Position::new(1 + GUTTER as u16 + 2, 2)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn worktree_is_on_by_default_and_builds_a_create_branch_spec() {
|
|
let mut draft = DraftPane::default();
|
|
assert!(draft.worktree_enabled, "on before any directory is chosen");
|
|
draft.set_projects(
|
|
vec![repo_project(
|
|
"maestro",
|
|
"/repos/maestro",
|
|
"/repos/maestro",
|
|
1,
|
|
None,
|
|
)],
|
|
Utc::now(),
|
|
);
|
|
draft.focused_field = DraftField::DirPicker;
|
|
draft.on_input(&InputKey::Enter); // picks the repo row
|
|
assert_eq!(
|
|
draft.base_repo.as_deref(),
|
|
Some(Path::new("/repos/maestro")),
|
|
"a picked project resolves its base repo without a probe"
|
|
);
|
|
assert!(draft.take_gitroot_probe(probe_due()).is_none());
|
|
draft.seed_names(vec!["crimson-otter".into()]);
|
|
|
|
let wt = draft.build_request().unwrap().worktree.unwrap();
|
|
assert_eq!(wt.base_repo, PathBuf::from("/repos/maestro"));
|
|
assert_eq!(wt.branch, "crimson-otter", "branch defaults to the name");
|
|
assert!(wt.create_branch);
|
|
}
|
|
|
|
#[test]
|
|
fn picking_a_worktree_project_defaults_the_toggle_off() {
|
|
let mut draft = DraftPane::default();
|
|
let mut wt = repo_project("wt-91", "/repos/wt91", "/repos/main", 1, None);
|
|
wt.is_worktree = true;
|
|
draft.set_projects(
|
|
vec![
|
|
repo_project("main", "/repos/main", "/repos/main", 2, None),
|
|
wt,
|
|
],
|
|
Utc::now(),
|
|
);
|
|
draft.focused_field = DraftField::DirPicker;
|
|
typed(&mut draft, "wt-91");
|
|
draft.on_input(&InputKey::Enter);
|
|
|
|
assert!(
|
|
!draft.worktree_enabled,
|
|
"the directory is already a worktree; spawning in place"
|
|
);
|
|
assert!(
|
|
draft.build_request().unwrap().worktree.is_none(),
|
|
"no worktree record — the session just runs there (#91)"
|
|
);
|
|
|
|
// Deliberately re-enabling still works, and the scan-known base repo
|
|
// survives so the toggle-on path needs no new probe.
|
|
draft.focused_field = DraftField::WorktreeToggle;
|
|
draft.on_input(&InputKey::Enter);
|
|
assert!(draft.worktree_enabled);
|
|
assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/repos/main")));
|
|
assert!(draft.take_gitroot_probe(probe_due()).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn raw_path_targets_probe_gitroot_and_resolution_feeds_the_spec() {
|
|
let mut draft = DraftPane {
|
|
focused_field: DraftField::DirPicker,
|
|
..DraftPane::default()
|
|
};
|
|
draft.on_input(&InputKey::Enter); // no projects → Other directory…
|
|
typed(&mut draft, "/opt/tool");
|
|
|
|
let (seq, path) = draft
|
|
.take_gitroot_probe(probe_due())
|
|
.expect("probe requested");
|
|
assert_eq!(path, PathBuf::from("/opt/tool"));
|
|
assert!(
|
|
draft.take_gitroot_probe(probe_due()).is_none(),
|
|
"one probe at a time"
|
|
);
|
|
|
|
// Further typing supersedes the in-flight probe…
|
|
typed(&mut draft, "-x");
|
|
let (seq2, _) = draft.take_gitroot_probe(probe_due()).unwrap();
|
|
assert_ne!(seq2, seq);
|
|
// …so a late answer to the old one must be ignored.
|
|
draft.resolve_gitroot(seq, Some(PathBuf::from("/somewhere/else")));
|
|
assert!(draft.base_repo.is_none());
|
|
|
|
draft.resolve_gitroot(seq2, Some(PathBuf::from("/opt/tool")));
|
|
assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/opt/tool")));
|
|
draft.seed_names(vec!["amber-falcon".into()]);
|
|
let req = draft.build_request().unwrap();
|
|
assert_eq!(req.worktree.unwrap().base_repo, PathBuf::from("/opt/tool"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_failed_gitroot_probe_unticks_the_toggle_like_the_web_form() {
|
|
let mut draft = DraftPane {
|
|
focused_field: DraftField::DirPicker,
|
|
..DraftPane::default()
|
|
};
|
|
draft.on_input(&InputKey::Enter);
|
|
typed(&mut draft, "/not/a/repo");
|
|
let (seq, _) = draft.take_gitroot_probe(probe_due()).unwrap();
|
|
|
|
draft.resolve_gitroot(seq, None);
|
|
assert!(!draft.worktree_enabled);
|
|
assert!(
|
|
!draft.build_request().unwrap().worktree.is_some(),
|
|
"spawns in the checkout itself"
|
|
);
|
|
|
|
// A stale success must not re-enable it.
|
|
draft.resolve_gitroot(seq, Some(PathBuf::from("/not/a/repo")));
|
|
assert!(!draft.worktree_enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn the_new_branch_tracks_the_session_name_until_touched() {
|
|
let mut draft = DraftPane::default();
|
|
draft.seed_names(vec!["crimson-otter".into(), "amber-falcon".into()]);
|
|
assert_eq!(draft.branch.value(), "crimson-otter");
|
|
draft.reroll();
|
|
assert_eq!(draft.branch.value(), "amber-falcon", "rerolls carry over");
|
|
|
|
draft.focused_field = DraftField::BranchNew;
|
|
typed(&mut draft, "-mine");
|
|
assert_eq!(draft.branch.value(), "amber-falcon-mine");
|
|
draft.seed_names(vec!["late-batch".into()]);
|
|
assert_eq!(
|
|
draft.branch.value(),
|
|
"amber-falcon-mine",
|
|
"an edited branch stops tracking the name"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn existing_mode_checks_out_a_picked_branch_without_creating_it() {
|
|
let mut draft = DraftPane::default();
|
|
draft.set_projects(
|
|
vec![repo_project(
|
|
"maestro",
|
|
"/repos/maestro",
|
|
"/repos/maestro",
|
|
1,
|
|
None,
|
|
)],
|
|
Utc::now(),
|
|
);
|
|
draft.focused_field = DraftField::DirPicker;
|
|
draft.on_input(&InputKey::Enter);
|
|
draft.focused_field = DraftField::BranchMode;
|
|
draft.on_input(&InputKey::Right);
|
|
assert_eq!(draft.branch_mode, WorktreeBranchMode::Existing);
|
|
assert!(
|
|
draft.take_branch_load().is_some(),
|
|
"switching modes requests the branch list"
|
|
);
|
|
|
|
draft.set_branches(
|
|
Path::new("/repos/maestro"),
|
|
vec![
|
|
BranchInfo {
|
|
name: "main".into(),
|
|
is_head: true,
|
|
worktree: None,
|
|
},
|
|
BranchInfo {
|
|
name: "held".into(),
|
|
is_head: false,
|
|
worktree: Some(PathBuf::from("/repos/maestro-held")),
|
|
},
|
|
BranchInfo {
|
|
name: "feature".into(),
|
|
is_head: false,
|
|
worktree: None,
|
|
},
|
|
],
|
|
);
|
|
assert_eq!(
|
|
draft.branches[draft.selected_branch].name, "feature",
|
|
"selection skips `main`: the main checkout has it, so git would \
|
|
refuse a second checkout"
|
|
);
|
|
|
|
draft.focused_field = DraftField::BranchExisting;
|
|
draft.on_input(&InputKey::Down);
|
|
assert_eq!(
|
|
draft.branches[draft.selected_branch].name, "feature",
|
|
"the only pickable branch stays selected"
|
|
);
|
|
|
|
let wt = draft.build_request().unwrap().worktree.unwrap();
|
|
assert_eq!(wt.branch, "feature");
|
|
assert!(!wt.create_branch, "existing picks are checked out as-is");
|
|
}
|
|
|
|
#[test]
|
|
fn held_branches_render_unavailable_with_their_holder_as_the_reason() {
|
|
let mut draft = DraftPane::default();
|
|
draft.set_projects(
|
|
vec![repo_project(
|
|
"maestro",
|
|
"/repos/maestro",
|
|
"/repos/maestro",
|
|
1,
|
|
None,
|
|
)],
|
|
Utc::now(),
|
|
);
|
|
draft.picked_cwd = Some(PathBuf::from("/repos/maestro"));
|
|
draft.base_repo = Some(PathBuf::from("/repos/maestro"));
|
|
draft.probed_cwd = Some(PathBuf::from("/repos/maestro"));
|
|
draft.focused_field = DraftField::BranchMode;
|
|
draft.on_input(&InputKey::Right);
|
|
let _ = draft.take_branch_load();
|
|
draft.set_branches(
|
|
Path::new("/repos/maestro"),
|
|
vec![
|
|
BranchInfo {
|
|
name: "held".into(),
|
|
is_head: false,
|
|
worktree: Some(PathBuf::from("/home/u/wt")),
|
|
},
|
|
BranchInfo {
|
|
name: "free".into(),
|
|
is_head: false,
|
|
worktree: None,
|
|
},
|
|
],
|
|
);
|
|
|
|
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
|
assert!(
|
|
rendered.contains("held by"),
|
|
"the holder path is the reason:\n{rendered}"
|
|
);
|
|
assert!(rendered.contains("free"), "{rendered}");
|
|
|
|
// A held branch is never submittable, even highlighted directly.
|
|
draft.selected_branch = 0;
|
|
assert_eq!(
|
|
draft.build_request(),
|
|
Err(DraftValidationError::MissingExistingBranch)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_keystroke_burst_schedules_one_debounced_gitroot_probe() {
|
|
let mut draft = DraftPane {
|
|
focused_field: DraftField::DirPicker,
|
|
..DraftPane::default()
|
|
};
|
|
draft.on_input(&InputKey::Enter); // no projects → Other directory…
|
|
|
|
// Burst: every keystroke reschedules; nothing may fire inside the
|
|
// debounce window (#74).
|
|
for c in "/opt/tooling".chars() {
|
|
draft.on_input(&InputKey::char(c));
|
|
assert!(
|
|
draft.take_gitroot_probe(Instant::now()).is_none(),
|
|
"no probe while typing is still in flight"
|
|
);
|
|
}
|
|
|
|
// Once the quiet period passes, exactly one probe fires.
|
|
let (seq, path) = draft
|
|
.take_gitroot_probe(probe_due())
|
|
.expect("probe due after the quiet period");
|
|
assert_eq!(path, PathBuf::from("/opt/tooling"));
|
|
assert!(
|
|
draft.take_gitroot_probe(probe_due()).is_none(),
|
|
"one probe total"
|
|
);
|
|
|
|
// The answer still lands through the unchanged seq guard.
|
|
draft.resolve_gitroot(seq, Some(PathBuf::from("/opt/tooling")));
|
|
assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/opt/tooling")));
|
|
}
|
|
|
|
#[test]
|
|
fn a_stale_probe_answer_cannot_overwrite_a_newer_target_after_the_debounce() {
|
|
let mut draft = DraftPane {
|
|
focused_field: DraftField::DirPicker,
|
|
..DraftPane::default()
|
|
};
|
|
draft.on_input(&InputKey::Enter); // Other directory…
|
|
|
|
typed(&mut draft, "/repo/old");
|
|
let (old_seq, _) = draft.take_gitroot_probe(probe_due()).unwrap();
|
|
|
|
// Retyping supersedes the in-flight probe and gets a newer seq…
|
|
typed(&mut draft, "/repo/new");
|
|
let (new_seq, _) = draft.take_gitroot_probe(probe_due()).unwrap();
|
|
assert_ne!(new_seq, old_seq);
|
|
|
|
// …so the late answer to the old target must be ignored.
|
|
draft.resolve_gitroot(new_seq, Some(PathBuf::from("/repo/new")));
|
|
draft.resolve_gitroot(old_seq, Some(PathBuf::from("/repo/old")));
|
|
assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/repo/new")));
|
|
}
|
|
|
|
#[test]
|
|
fn toggling_worktree_off_spawns_in_the_checkout_itself() {
|
|
let mut draft = DraftPane::default();
|
|
draft.set_projects(
|
|
vec![repo_project(
|
|
"maestro",
|
|
"/repos/maestro",
|
|
"/repos/maestro",
|
|
1,
|
|
None,
|
|
)],
|
|
Utc::now(),
|
|
);
|
|
draft.picked_cwd = Some(PathBuf::from("/repos/maestro"));
|
|
draft.base_repo = Some(PathBuf::from("/repos/maestro"));
|
|
draft.probed_cwd = Some(PathBuf::from("/repos/maestro"));
|
|
|
|
draft.focused_field = DraftField::WorktreeToggle;
|
|
draft.on_input(&InputKey::Enter);
|
|
assert!(!draft.worktree_enabled);
|
|
assert!(draft.build_request().unwrap().worktree.is_none());
|
|
|
|
draft.on_input(&InputKey::Enter);
|
|
assert!(draft.worktree_enabled);
|
|
assert!(
|
|
draft.take_gitroot_probe(probe_due()).is_none(),
|
|
"re-enabling reuses the known repo for an unchanged target"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn worktree_validation_names_the_problem() {
|
|
let mut draft = DraftPane {
|
|
cwd_raw: crate::text_input::TextInput::with_value("/opt/tool"),
|
|
picked_cwd: Some(PathBuf::from("/opt/tool")),
|
|
..DraftPane::default()
|
|
};
|
|
assert_eq!(
|
|
draft.build_request(),
|
|
Err(DraftValidationError::MissingWorktreeRepo)
|
|
);
|
|
assert_eq!(
|
|
DraftValidationError::MissingWorktreeRepo.to_string(),
|
|
"Worktree needs a directory inside a git repository."
|
|
);
|
|
|
|
draft.base_repo = Some(PathBuf::from("/opt/tool"));
|
|
assert_eq!(
|
|
draft.build_request(),
|
|
Err(DraftValidationError::MissingNewBranch),
|
|
"no names seeded yet, so the new-branch field is empty"
|
|
);
|
|
assert_eq!(
|
|
DraftValidationError::MissingNewBranch.to_string(),
|
|
"Worktree requires a branch name."
|
|
);
|
|
|
|
draft.branch_mode = WorktreeBranchMode::Existing;
|
|
assert_eq!(
|
|
draft.build_request(),
|
|
Err(DraftValidationError::MissingExistingBranch)
|
|
);
|
|
assert_eq!(
|
|
DraftValidationError::MissingExistingBranch.to_string(),
|
|
"Pick an existing branch."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_shows_the_worktree_section() {
|
|
let mut draft = DraftPane {
|
|
home_dir: "/home/u".into(),
|
|
..DraftPane::default()
|
|
};
|
|
draft.set_projects(
|
|
vec![project("maestro", "/home/u/tea/maestro", 1, Some("claude"))],
|
|
Utc::now(),
|
|
);
|
|
draft.seed_names(vec!["crimson-otter".into()]);
|
|
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
|
for s in ["wt:", "[x] worktree"] {
|
|
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
|
|
}
|
|
assert!(
|
|
rendered.contains("choose a directory first"),
|
|
"hint while no target:\n{rendered}"
|
|
);
|
|
assert!(
|
|
!rendered.contains("[new] [existing]"),
|
|
"body hidden until the repo is known:\n{rendered}"
|
|
);
|
|
|
|
draft.picked_cwd = Some(PathBuf::from("/home/u/tea/maestro"));
|
|
draft.base_repo = Some(PathBuf::from("/home/u/tea/maestro"));
|
|
draft.probed_cwd = Some(PathBuf::from("/home/u/tea/maestro"));
|
|
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
|
for s in ["[new] [existing]", "br:", "crimson-otter"] {
|
|
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
|
|
}
|
|
|
|
draft.focused_field = DraftField::WorktreeToggle;
|
|
draft.on_input(&InputKey::Enter);
|
|
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
|
assert!(rendered.contains("[ ] worktree"), "{rendered}");
|
|
assert!(
|
|
!rendered.contains("[new] [existing]"),
|
|
"body hidden while off:\n{rendered}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn abbreviate_path_matches_the_web_helpers() {
|
|
assert_eq!(
|
|
abbreviate_path(Path::new("/home/u/tea/Maestro"), "/home/u"),
|
|
"~/t/Maestro"
|
|
);
|
|
assert_eq!(abbreviate_path(Path::new("/home/u"), "/home/u"), "~");
|
|
assert_eq!(
|
|
abbreviate_path(Path::new("/opt/x"), "/home/u"),
|
|
"/o/x",
|
|
"three-or-more segments collapse even outside home (web parity)"
|
|
);
|
|
assert_eq!(
|
|
abbreviate_path(Path::new("/home/u"), ""),
|
|
"/h/u",
|
|
"segments collapse even when home is unknown (web parity)"
|
|
);
|
|
}
|
|
}
|