diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 1894082..0c353d4 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -1553,8 +1553,8 @@ mod tests { panic!("expected a draft pane"); }; assert_eq!(draft.command(), "aider"); - assert_eq!(draft.rows.len(), 1); - assert_eq!(draft.rows[0].project.name, "maestro"); + assert_eq!(draft.groups.len(), 1); + assert_eq!(draft.groups[0].main.name, "maestro"); assert_eq!(draft.home_dir, "/home/u"); assert_eq!(draft.name.value(), "crimson-otter"); } diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index 0224662..199e49f 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -7,13 +7,31 @@ //! //! 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 +//! 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` 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`. +//! +//! 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 full path). 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. use std::collections::VecDeque; use std::path::{Path, PathBuf}; @@ -52,10 +70,15 @@ pub enum DraftField { Preset, /// Free-text command, reachable only while a custom preset is selected. CustomCommand, - /// Project picker: typing filters, Up / Down highlight, Enter picks. + /// 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, /// Worktree on/off for this session (#65). Enter / Space toggles. WorktreeToggle, @@ -78,14 +101,50 @@ pub enum WorktreeBranchMode { 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. +/// "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 struct DraftRow { +pub enum WorktreeChoice { + New, + Existing(PathBuf), +} + +/// One repository box for step one of the picker (#96 rule 1): the main +/// checkout (or, when it's missing from the scan, its freshest worktree +/// standing in for it) plus every other worktree of the same repository. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DraftGroup { + pub main: Project, + pub main_stale: bool, + pub worktrees: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DraftWorktreeRow { pub project: Project, pub stale: bool, - pub indent: bool, +} + +/// The repository key a [`DraftGroup`] was formed under — same key +/// `projects::group_projects` groups by, kept separately because the main +/// box's own path only equals it when the main checkout itself was found. +fn group_key(main: &Project) -> PathBuf { + main.base_repo.clone().unwrap_or_else(|| main.path.clone()) +} + +/// Every worktree of a group, including the group's own box when the scan +/// found no main checkout to nest it under — the TUI mirror of the web +/// form's `worktreesOf`. Already newest-first: when the main box stands in +/// for a missing main checkout it is the freshest member by construction +/// ([`projects::group_projects`]), and `worktrees` itself is sorted +/// newest-first. +fn worktree_rows_of(group: &DraftGroup) -> Vec { + let mut rows = Vec::new(); + if group.main.is_worktree { + rows.push(group.main.clone()); + } + rows.extend(group.worktrees.iter().map(|w| w.project.clone())); + rows } /// Why a submit was refused. The message names the missing field so the form @@ -130,15 +189,23 @@ pub struct DraftPane { pub presets: Vec, pub selected_preset: usize, pub custom_command: crate::text_input::TextInput, - /// Discovered projects, sorted fresh-first with worktrees nested. - pub rows: Vec, + /// Discovered projects, sorted fresh-first and grouped by repository + /// (#96 step one). + pub groups: Vec, pub filter: crate::text_input::TextInput, - /// Highlighted entry within the visible list; the implicit - /// `Other directory…` entry sits at index `visible_rows().len()`. + /// Highlighted entry within the visible step-one list; the implicit + /// `Other directory…` entry sits at index `visible_groups().len()`. pub highlighted: usize, pub cwd_raw_visible: bool, pub cwd_raw: crate::text_input::TextInput, pub picked_cwd: Option, + /// The repository picked in step one ([`group_key`] of its main box), + /// `None` until a box is picked. Looked up fresh in `groups` rather than + /// cached, so a project-list refresh can't leave it stale. + pub selected_group_key: Option, + /// Step two's current pick (#96 rule 4/5). Meaningless while + /// `selected_group_key` is `None`. + pub worktree_choice: WorktreeChoice, pub name: crate::text_input::TextInput, pub name_queue: VecDeque, /// Set once the operator types or rerolls a name; guards refills against @@ -193,12 +260,14 @@ impl Default for DraftPane { presets, selected_preset: 0, custom_command: crate::text_input::TextInput::default(), - rows: Vec::new(), + groups: Vec::new(), filter: crate::text_input::TextInput::default(), highlighted: 0, cwd_raw_visible: false, cwd_raw: crate::text_input::TextInput::default(), picked_cwd: None, + selected_group_key: None, + worktree_choice: WorktreeChoice::New, name: crate::text_input::TextInput::default(), name_queue: VecDeque::new(), name_touched: false, @@ -240,24 +309,24 @@ impl DraftPane { .unwrap_or(0); } - /// Replace the project snapshot: sort fresh-first (#54), nest worktrees - /// under their main checkout, and precompute staleness for rendering. + /// Replace the project snapshot: sort fresh-first (#54), group by + /// repository (#96), and precompute staleness for rendering. pub fn set_projects(&mut self, discovered: Vec, now: DateTime) { let sorted = projects::sort_projects(discovered, now); let groups = projects::group_projects(&sorted); - self.rows = groups + self.groups = 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(), - })) + .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)); @@ -600,6 +669,8 @@ impl DraftPane { order.push(DraftField::DirPicker); if self.cwd_raw_visible { order.push(DraftField::CwdRaw); + } else if self.selected_group_key.is_some() { + order.push(DraftField::WorktreeChoice); } order.push(DraftField::Name); order.push(DraftField::WorktreeToggle); @@ -641,18 +712,25 @@ impl DraftPane { ((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> { + /// 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.rows + self.groups .iter() - .filter(|r| projects::matches_filter(&r.project, query)) + .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_rows().len() + 1 + self.visible_groups().len() + 1 } /// Highlight clamped to the current entry count. @@ -668,35 +746,47 @@ impl DraftPane { 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. + /// The step-one group currently picked, looked up fresh against the + /// latest snapshot so a project-list refresh can't leave a stale clone + /// behind. + pub fn selected_group(&self) -> Option { + let key = self.selected_group_key.as_ref()?; + self.groups + .iter() + .find(|g| &group_key(&g.main) == key) + .cloned() + } + + /// Step two's rows: "+ new" first, then the repository's worktrees + /// newest-first (#96 rule 3). Empty while no group is picked. + pub fn worktree_choice_rows(&self) -> Vec<(WorktreeChoice, String, String)> { + let Some(group) = self.selected_group() else { + return Vec::new(); + }; + let mut rows = vec![( + WorktreeChoice::New, + "+ new".to_string(), + format!("new worktree of {}", group.main.name), + )]; + rows.extend(worktree_rows_of(&group).into_iter().map(|p| { + ( + WorktreeChoice::Existing(p.path.clone()), + p.name.clone(), + p.path.to_string_lossy().into_owned(), + ) + })); + rows + } + + /// 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_rows(); + let visible = self.visible_groups(); 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); + let group = visible[sel].clone(); + self.select_group(&group); return; } let seed = self.cwd().unwrap_or_else(|| { @@ -706,6 +796,7 @@ impl DraftPane { 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()); @@ -713,6 +804,95 @@ impl DraftPane { 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; + if let Some(cmd) = target.last_command.as_deref() { + if !cmd.is_empty() { + if let Some(idx) = self.presets.iter().position(|p| p.command == cmd) { + self.selected_preset = idx; + } + } + } + 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 @@ -760,6 +940,14 @@ impl DraftPane { 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 @@ -832,6 +1020,15 @@ impl DraftPane { let show_custom = self.is_custom(); let show_raw = self.cwd_raw_visible; + let show_wt_choice = 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; 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. @@ -839,6 +1036,7 @@ impl DraftPane { let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* worktree */ + 1 /* spawn */ + u16::from(show_custom) + u16::from(show_raw) + + 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; @@ -851,6 +1049,8 @@ impl DraftPane { constraints.push(Constraint::Length(list_capacity as u16)); if show_raw { constraints.push(Constraint::Length(1)); + } else if show_wt_choice { + constraints.push(Constraint::Length(wt_choice_capacity)); } constraints.push(Constraint::Length(1)); // name constraints.push(Constraint::Length(1)); // worktree toggle @@ -927,7 +1127,10 @@ impl DraftPane { cursor = Some((col, filter_row.y)); } - let visible = self.visible_rows(); + // Step one (#96 rule 1): one box per repository, name then the FULL + // path — not the abbreviated form other lists use — so two projects + // sharing a basename stay distinguishable. + let visible = self.visible_groups(); let sel = self.selection(); let count = visible.len() + 1; let capacity = list_capacity.min(count); @@ -940,7 +1143,7 @@ impl DraftPane { .iter() .take(start + capacity) .skip(start) - .map(|row| row.project.name.chars().count() + usize::from(row.indent) * 2) + .map(|group| group.main.name.chars().count()) .max() .unwrap_or(0) .min(18); @@ -953,31 +1156,19 @@ impl DraftPane { } let active = pane_focused && self.focused_field == DraftField::DirPicker && i == sel; let line = if i < visible.len() { - let row = visible[i]; + let group = visible[i]; let mut base = Style::default(); - if row.stale { + if group.main_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:= 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: 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), - wt, + linked_worktree("wt-91", "/repos/wt91", "/repos/main", 1), ], 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_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 is now reachable" + ); + 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 (#91)" + "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" + ); + } - // 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; + #[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!(draft.worktree_enabled); - assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/repos/main"))); - assert!(draft.take_gitroot_probe(probe_due()).is_none()); + + 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] @@ -2065,6 +2400,45 @@ mod tests { ); } + #[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 rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true)); + + assert!( + rendered.contains("/home/u/repos/main"), + "step one shows the full repository path:\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 {