|
|
|
@@ -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,
|
|
|
|
|
/// Disclosure collapsing the worktree/branch controls below (#93).
|
|
|
|
|
/// Closed by default; Enter / Space toggles.
|
|
|
|
@@ -81,14 +104,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<DraftWorktreeRow>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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<Project> {
|
|
|
|
|
let mut rows = Vec::new();
|
|
|
|
|
if group.main.is_worktree {
|
|
|
|
|
rows.push(group.main.clone());
|
|
|
|
|
}
|
|
|
|
|
rows.extend(group.worktrees.iter().map(|w| w.project.clone()));
|
|
|
|
|
rows
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Why a submit was refused. The message names the missing field so the form
|
|
|
|
@@ -133,15 +192,23 @@ pub struct DraftPane {
|
|
|
|
|
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>,
|
|
|
|
|
/// Discovered projects, sorted fresh-first and grouped by repository
|
|
|
|
|
/// (#96 step one).
|
|
|
|
|
pub groups: Vec<DraftGroup>,
|
|
|
|
|
pub filter: crate::text_input::TextInput,
|
|
|
|
|
/// Highlighted entry within the visible 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<PathBuf>,
|
|
|
|
|
/// The repository picked in step one ([`group_key`] of its main box),
|
|
|
|
|
/// `None` until a box is picked. Looked up fresh in `groups` rather than
|
|
|
|
|
/// cached, so a project-list refresh can't leave it stale.
|
|
|
|
|
pub selected_group_key: Option<PathBuf>,
|
|
|
|
|
/// Step two's current pick (#96 rule 4/5). Meaningless while
|
|
|
|
|
/// `selected_group_key` is `None`.
|
|
|
|
|
pub worktree_choice: WorktreeChoice,
|
|
|
|
|
pub name: crate::text_input::TextInput,
|
|
|
|
|
pub name_queue: VecDeque<String>,
|
|
|
|
|
/// Set once the operator types or rerolls a name; guards refills against
|
|
|
|
@@ -200,12 +267,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,
|
|
|
|
@@ -248,24 +317,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<Project>, now: DateTime<Utc>) {
|
|
|
|
|
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));
|
|
|
|
@@ -623,6 +692,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::Advanced);
|
|
|
|
@@ -667,18 +738,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.
|
|
|
|
@@ -694,35 +772,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<DraftGroup> {
|
|
|
|
|
let key = self.selected_group_key.as_ref()?;
|
|
|
|
|
self.groups
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|g| &group_key(&g.main) == key)
|
|
|
|
|
.cloned()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Step two's rows: "+ new" first, then the repository's worktrees
|
|
|
|
|
/// newest-first (#96 rule 3). Empty while no group is picked.
|
|
|
|
|
pub fn worktree_choice_rows(&self) -> Vec<(WorktreeChoice, String, String)> {
|
|
|
|
|
let Some(group) = self.selected_group() else {
|
|
|
|
|
return Vec::new();
|
|
|
|
|
};
|
|
|
|
|
let mut rows = vec![(
|
|
|
|
|
WorktreeChoice::New,
|
|
|
|
|
"+ new".to_string(),
|
|
|
|
|
format!("new worktree of {}", group.main.name),
|
|
|
|
|
)];
|
|
|
|
|
rows.extend(worktree_rows_of(&group).into_iter().map(|p| {
|
|
|
|
|
(
|
|
|
|
|
WorktreeChoice::Existing(p.path.clone()),
|
|
|
|
|
p.name.clone(),
|
|
|
|
|
p.path.to_string_lossy().into_owned(),
|
|
|
|
|
)
|
|
|
|
|
}));
|
|
|
|
|
rows
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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(|| {
|
|
|
|
@@ -732,6 +822,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());
|
|
|
|
@@ -739,6 +830,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
|
|
|
|
@@ -798,6 +978,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
|
|
|
|
@@ -872,9 +1060,20 @@ impl DraftPane {
|
|
|
|
|
let show_raw = self.cwd_raw_visible;
|
|
|
|
|
// The worktree toggle and its body stay behind the "Advanced options"
|
|
|
|
|
// disclosure (#93): collapsed hides both, regardless of what they'd
|
|
|
|
|
// otherwise show.
|
|
|
|
|
// otherwise show. The worktree *choice* step is not part of that —
|
|
|
|
|
// it belongs to the directory picker (#95 rule 3), so it stays
|
|
|
|
|
// visible once a project is picked.
|
|
|
|
|
let show_wt_toggle = !self.advanced_collapsed;
|
|
|
|
|
let show_wt_body = show_wt_toggle && self.worktree_body_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;
|
|
|
|
|
// 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;
|
|
|
|
@@ -882,6 +1081,7 @@ impl DraftPane {
|
|
|
|
|
+ u16::from(show_custom)
|
|
|
|
|
+ u16::from(show_raw)
|
|
|
|
|
+ u16::from(show_wt_toggle)
|
|
|
|
|
+ u16::from(show_wt_choice) * wt_choice_capacity
|
|
|
|
|
+ u16::from(show_wt_body) * (1 + if self.branch_mode == WorktreeBranchMode::New { 1 } else { branch_rows })
|
|
|
|
|
+ u16::from(self.error.is_some());
|
|
|
|
|
let list_capacity = (inner.height.saturating_sub(fixed)).max(1) as usize;
|
|
|
|
@@ -894,6 +1094,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)); // advanced header
|
|
|
|
@@ -973,7 +1175,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);
|
|
|
|
@@ -986,7 +1191,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);
|
|
|
|
@@ -999,31 +1204,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:<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,
|
|
|
|
|
),
|
|
|
|
|
Span::styled(format!("{:<name_width$}", group.main.name), base),
|
|
|
|
|
Span::styled(format!(" {}", group.main.path.display()), base),
|
|
|
|
|
])
|
|
|
|
|
} else {
|
|
|
|
|
let mut style = Style::default();
|
|
|
|
@@ -1051,6 +1244,53 @@ impl DraftPane {
|
|
|
|
|
if self.focused_field == DraftField::CwdRaw {
|
|
|
|
|
cursor = Some((col, row.y));
|
|
|
|
|
}
|
|
|
|
|
} else if show_wt_choice {
|
|
|
|
|
// Step two (#96 rule 3): "+ new" first, then the repository's
|
|
|
|
|
// worktrees newest-first.
|
|
|
|
|
let choice_rows = wt_choice_rows_data;
|
|
|
|
|
let csel = choice_rows
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|(c, _, _)| *c == self.worktree_choice)
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
let ccap = (wt_choice_capacity as usize).min(choice_rows.len().max(1));
|
|
|
|
|
let cstart = if csel >= ccap { csel + 1 - ccap } else { 0 };
|
|
|
|
|
let label_width = choice_rows
|
|
|
|
|
.iter()
|
|
|
|
|
.skip(cstart)
|
|
|
|
|
.take(ccap)
|
|
|
|
|
.map(|(_, label, _)| label.chars().count())
|
|
|
|
|
.max()
|
|
|
|
|
.unwrap_or(0)
|
|
|
|
|
.min(18);
|
|
|
|
|
let choice_row = next_row();
|
|
|
|
|
for i in cstart..cstart + ccap {
|
|
|
|
|
let y = choice_row.y + (i - cstart) as u16;
|
|
|
|
|
if y >= choice_row.y + choice_row.height || i >= choice_rows.len() {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
let (_, label, sub) = &choice_rows[i];
|
|
|
|
|
let active =
|
|
|
|
|
pane_focused && self.focused_field == DraftField::WorktreeChoice && i == csel;
|
|
|
|
|
let mut base = Style::default();
|
|
|
|
|
if active {
|
|
|
|
|
base = base.add_modifier(Modifier::REVERSED);
|
|
|
|
|
}
|
|
|
|
|
let line = Line::from(vec![
|
|
|
|
|
Span::styled(if active { ">" } else { " " }, base),
|
|
|
|
|
Span::raw(" "),
|
|
|
|
|
Span::styled(format!("{label:<label_width$}"), base),
|
|
|
|
|
Span::styled(format!(" {sub}"), base),
|
|
|
|
|
]);
|
|
|
|
|
f.render_widget(
|
|
|
|
|
Paragraph::new(line),
|
|
|
|
|
Rect {
|
|
|
|
|
x: choice_row.x + GUTTER as u16,
|
|
|
|
|
y,
|
|
|
|
|
width: choice_row.width.saturating_sub(GUTTER as u16),
|
|
|
|
|
height: 1,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- name + reroll ---------------------------------------------------------
|
|
|
|
@@ -1152,7 +1392,7 @@ impl DraftPane {
|
|
|
|
|
match self.branch_mode {
|
|
|
|
|
WorktreeBranchMode::New => {
|
|
|
|
|
let row = next_row();
|
|
|
|
|
draw_gutter(f, row, "br:");
|
|
|
|
|
draw_gutter(f, row, "new:");
|
|
|
|
|
let col =
|
|
|
|
|
draw_text_field(f, &self.branch, row, field_style(DraftField::BranchNew));
|
|
|
|
|
if self.focused_field == DraftField::BranchNew {
|
|
|
|
@@ -1176,6 +1416,7 @@ impl DraftPane {
|
|
|
|
|
.unwrap_or(0)
|
|
|
|
|
.min(20);
|
|
|
|
|
let list_row = next_row();
|
|
|
|
|
draw_gutter(f, list_row, "pick:");
|
|
|
|
|
for i in start..(start + capacity).min(len.max(1)) {
|
|
|
|
|
let y = list_row.y + (i - start) as u16;
|
|
|
|
|
if y >= list_row.y + list_row.height {
|
|
|
|
@@ -1473,12 +1714,8 @@ mod tests {
|
|
|
|
|
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}"
|
|
|
|
|
rendered.contains("/home/u/new/repo"),
|
|
|
|
|
"the full path, not an abbreviation (#96 rule 1):\n{rendered}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Staleness dims the row's cells.
|
|
|
|
@@ -1507,7 +1744,7 @@ mod tests {
|
|
|
|
|
assert_eq!(draft.focused_field, DraftField::DirPicker);
|
|
|
|
|
typed(&mut draft, "maes");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
draft.visible_rows().len(),
|
|
|
|
|
draft.visible_groups().len(),
|
|
|
|
|
1,
|
|
|
|
|
"type-to-filter narrows the list"
|
|
|
|
|
);
|
|
|
|
@@ -1791,38 +2028,139 @@ mod tests {
|
|
|
|
|
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);
|
|
|
|
|
/// A worktree of `group.main` (matching a solo worktree, is_worktree true).
|
|
|
|
|
fn linked_worktree(name: &str, path: &str, base: &str, days_ago: i64) -> Project {
|
|
|
|
|
let mut wt = repo_project(name, path, base, days_ago, None);
|
|
|
|
|
wt.is_worktree = true;
|
|
|
|
|
wt
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_query_matching_only_a_worktree_still_finds_its_repository_box() {
|
|
|
|
|
// #96 rule 2: the filter keeps a repository when the query matches
|
|
|
|
|
// it or any of its worktrees.
|
|
|
|
|
let mut draft = DraftPane::default();
|
|
|
|
|
draft.set_projects(
|
|
|
|
|
vec![
|
|
|
|
|
repo_project("main", "/repos/main", "/repos/main", 2, None),
|
|
|
|
|
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]
|
|
|
|
@@ -2136,6 +2474,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 {
|
|
|
|
@@ -2165,10 +2542,25 @@ mod tests {
|
|
|
|
|
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"] {
|
|
|
|
|
for s in ["[new] [existing]", "new:", "crimson-otter"] {
|
|
|
|
|
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The new-branch input and existing-branch picker must not share a
|
|
|
|
|
// label — matching the web form's "New branch name" / "Pick a
|
|
|
|
|
// branch" (#94).
|
|
|
|
|
draft.set_branch_mode(WorktreeBranchMode::Existing);
|
|
|
|
|
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
|
|
|
|
assert!(
|
|
|
|
|
rendered.contains("pick:"),
|
|
|
|
|
"missing pick: label:\n{rendered}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!rendered.contains("br:"),
|
|
|
|
|
"stale ambiguous label:\n{rendered}"
|
|
|
|
|
);
|
|
|
|
|
draft.set_branch_mode(WorktreeBranchMode::New);
|
|
|
|
|
|
|
|
|
|
draft.focused_field = DraftField::WorktreeToggle;
|
|
|
|
|
draft.on_input(&InputKey::Enter);
|
|
|
|
|
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
|
|
|
|