use std::path::PathBuf;
use crate::agent::{AgentModel, Effort};
use crate::keys::Keymap;
use crate::model::{Column, SortKey, SortSpec, Worktree};
use crate::tui::event::Effect;
use crate::tui::options::OptionList;
use crate::tui::theme::Palette;
use crate::util::fuzzy;
pub const MIN_DETAIL_WIDTH: u16 = 60;
pub const MIN_HEIGHT: u16 = 5;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Mode {
List,
Filter,
Create(CreateState),
PrPicker(PrPickerState),
PrCompose(PrComposeState),
Checkout(CheckoutState),
ConfirmRemove(usize),
ConfirmCreate(usize),
ConfirmDeleteBranch {
index: usize,
force: bool,
},
ConfirmStaleBase(StaleBaseState),
ConfirmInitSubmodules(InitSubmodulesState),
ConfirmQuit {
jobs: usize,
},
Help,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pane {
List,
Detail,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum StatusKind {
#[default]
Info,
Success,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JobKey {
Path(PathBuf),
Branch(String),
New(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActiveJob {
pub key: JobKey,
pub label: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JobHome {
List,
Create,
Checkout,
PrPicker,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CreateState {
pub step: CreateStep,
pub branch: String,
pub base: String,
pub error: Option<String>,
pub options: OptionList,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StaleBaseState {
pub branch: String,
pub base: Option<String>,
pub behind: u32,
pub upstream_display: String,
pub can_fast_forward: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InitSubmodulesState {
pub dir: PathBuf,
pub branch: String,
pub count: usize,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum CreateStep {
#[default]
Branch,
Base,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CheckoutState {
pub worktree_index: usize,
pub query: String,
pub options: OptionList,
pub error: Option<String>,
pub submitting: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrItem {
pub number: u64,
pub title: String,
pub author: String,
pub state: String,
pub created_at: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ComposeField {
#[default]
Title,
Body,
Model,
Effort,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PrComposeState {
pub field: ComposeField,
pub title: String,
pub body: String,
pub draft: bool,
pub branch: String,
pub trunk: String,
pub action_label: String,
pub model: AgentModel,
pub effort: Effort,
pub submitting: bool,
pub error: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PrPickerState {
pub loading: bool,
pub prs: Vec<PrItem>,
pub selected: usize,
pub error: Option<String>,
}
pub struct App {
pub worktrees: Vec<Worktree>,
pub visible: Vec<usize>,
pub selected: usize,
pub mode: Mode,
pub filter: String,
pub focus: Pane,
pub show_sidebar: bool,
pub sidebar_width: u16,
pub sort: SortSpec,
pub detail_scroll: u16,
pub size: (u16, u16),
pub keymap: Keymap,
pub columns: Vec<Column>,
pub show_untracked: bool,
pub remove_untracked_blocks: bool,
pub nerd_fonts: bool,
pub mouse: bool,
pub color: bool,
pub palette: Palette,
pub quit: bool,
pub chosen: Option<PathBuf>,
loaded_paths: std::collections::HashSet<PathBuf>,
pub status_message: Option<String>,
pub status_kind: StatusKind,
pub too_small: bool,
pub jobs: Vec<ActiveJob>,
pub spinner_frame: usize,
pub pending_jobs: Vec<Effect>,
pub branches: Vec<String>,
pub default_base: Option<String>,
}
pub struct AppConfig {
pub keymap: Keymap,
pub sort: SortSpec,
pub columns: Vec<Column>,
pub show_untracked: bool,
pub remove_untracked_blocks: bool,
pub nerd_fonts: bool,
pub mouse: bool,
pub color: bool,
pub palette: Palette,
}
impl App {
pub fn new(worktrees: Vec<Worktree>, config: AppConfig, size: (u16, u16)) -> App {
let visible = (0..worktrees.len()).collect();
let selected = worktrees.iter().position(|w| w.is_current).unwrap_or(0);
let loaded_paths = worktrees.iter().map(|w| w.path.clone()).collect();
App {
loaded_paths,
status_message: None,
status_kind: StatusKind::Info,
too_small: false,
jobs: Vec::new(),
spinner_frame: 0,
pending_jobs: Vec::new(),
branches: Vec::new(),
default_base: None,
worktrees,
visible,
selected,
mode: Mode::List,
filter: String::new(),
focus: Pane::List,
show_sidebar: true,
sidebar_width: 40,
sort: config.sort,
detail_scroll: 0,
size,
keymap: config.keymap,
columns: config.columns,
show_untracked: config.show_untracked,
remove_untracked_blocks: config.remove_untracked_blocks,
nerd_fonts: config.nerd_fonts,
mouse: config.mouse,
color: config.color,
palette: config.palette,
quit: false,
chosen: None,
}
}
pub fn set_status(&mut self, message: impl Into<String>, kind: StatusKind) {
self.status_message = Some(message.into());
self.status_kind = kind;
}
pub fn begin_job(&mut self, key: JobKey, label: impl Into<String>) {
self.jobs.retain(|j| j.key != key);
self.jobs.push(ActiveJob {
key,
label: label.into(),
});
}
pub fn finish_job(&mut self, key: &JobKey) {
self.jobs.retain(|j| &j.key != key);
}
pub fn has_job(&self, key: &JobKey) -> bool {
self.jobs.iter().any(|j| &j.key == key)
}
pub fn job_for(&self, worktree: &Worktree) -> Option<&ActiveJob> {
self.jobs.iter().find(|j| match &j.key {
JobKey::Path(p) => worktree.has_worktree && &worktree.path == p,
JobKey::Branch(b) => {
!worktree.has_worktree && worktree.branch.as_deref() == Some(b.as_str())
}
JobKey::New(_) => false,
})
}
pub fn tick_spinner(&mut self) {
if !self.jobs.is_empty() {
self.spinner_frame = self.spinner_frame.wrapping_add(1);
}
}
pub fn any_jobs(&self) -> bool {
!self.jobs.is_empty()
}
pub fn job_summary(&self) -> Option<String> {
let first = self.jobs.first()?;
Some(if self.jobs.len() == 1 {
format!("{}…", first.label)
} else {
format!("{} (+{} more)…", first.label, self.jobs.len() - 1)
})
}
pub fn may_apply_mode(&self, home: JobHome) -> bool {
matches!(self.mode, Mode::List | Mode::Filter)
|| match home {
JobHome::List => false,
JobHome::Create => matches!(self.mode, Mode::Create(_)),
JobHome::Checkout => matches!(self.mode, Mode::Checkout(_)),
JobHome::PrPicker => matches!(self.mode, Mode::PrPicker(_)),
}
}
pub fn queue_job(&mut self, effect: Effect) {
self.pending_jobs.push(effect);
}
pub fn take_pending_jobs(&mut self) -> Vec<Effect> {
std::mem::take(&mut self.pending_jobs)
}
pub fn selected_worktree(&self) -> Option<&Worktree> {
self.visible
.get(self.selected)
.and_then(|&i| self.worktrees.get(i))
}
pub fn is_loaded(&self, worktree: &Worktree) -> bool {
self.loaded_paths.contains(&worktree.path)
}
pub fn mark_loading(&mut self) {
self.loaded_paths.clear();
}
pub fn mark_loaded(&mut self, path: PathBuf) {
self.loaded_paths.insert(path);
}
pub fn detail_visible(&self) -> bool {
!self.show_sidebar || self.size.0 >= MIN_DETAIL_WIDTH
}
pub fn set_worktrees(&mut self, worktrees: Vec<Worktree>) {
let selected_path = self.selected_worktree().map(|w| w.path.clone());
self.worktrees = worktrees;
self.apply_sort();
self.recompute_visible();
if let Some(path) = selected_path {
self.select_path(&path);
}
}
pub fn move_selection(&mut self, delta: isize) {
if self.visible.is_empty() {
return;
}
let max = self.visible.len() as isize - 1;
let next = (self.selected as isize + delta).clamp(0, max);
self.selected = next as usize;
self.detail_scroll = 0;
}
pub fn select_edge(&mut self, last: bool) {
if self.visible.is_empty() {
return;
}
self.selected = if last { self.visible.len() - 1 } else { 0 };
self.detail_scroll = 0;
}
pub fn select_row(&mut self, row: usize) {
if row < self.visible.len() {
self.selected = row;
self.detail_scroll = 0;
}
}
pub fn scroll_detail(&mut self, delta: isize) {
let max = self.selected_worktree().map_or(0, |w| {
(w.recent_commits.len() + 10) as isize
});
let next = (self.detail_scroll as isize + delta).clamp(0, max.max(0));
self.detail_scroll = next as u16;
}
pub fn cycle_sort(&mut self) {
const ORDER: [SortKey; 6] = [
SortKey::Branch,
SortKey::Dirty,
SortKey::Ahead,
SortKey::Behind,
SortKey::Activity,
SortKey::Path,
];
let current = ORDER.iter().position(|k| *k == self.sort.key).unwrap_or(0);
self.sort.key = ORDER[(current + 1) % ORDER.len()];
self.resort_preserving_selection();
}
pub fn reverse_sort(&mut self) {
self.sort.descending = !self.sort.descending;
self.resort_preserving_selection();
}
pub fn filter_push(&mut self, c: char) {
self.filter.push(c);
self.recompute_visible();
}
pub fn filter_pop(&mut self) {
self.filter.pop();
self.recompute_visible();
}
pub fn clear_filter(&mut self) {
self.filter.clear();
self.recompute_visible();
}
pub(crate) fn apply_filter(&mut self, filter: String) {
self.filter = filter;
self.selected = 0;
self.recompute_visible();
}
fn resort_preserving_selection(&mut self) {
let selected_path = self.selected_worktree().map(|w| w.path.clone());
self.apply_sort();
self.recompute_visible();
if let Some(path) = selected_path {
self.select_path(&path);
}
}
fn apply_sort(&mut self) {
crate::worktree_service::sort_worktrees_base_first(&mut self.worktrees, self.sort);
}
fn recompute_visible(&mut self) {
if self.filter.is_empty() {
self.visible = (0..self.worktrees.len()).collect();
} else {
let haystacks: Vec<String> = self.worktrees.iter().map(haystack).collect();
let matched = fuzzy::filter_indices(&haystacks, &self.filter);
let keep: std::collections::HashSet<usize> = matched.into_iter().collect();
self.visible = (0..self.worktrees.len())
.filter(|i| keep.contains(i))
.collect();
}
if self.selected >= self.visible.len() {
self.selected = self.visible.len().saturating_sub(1);
}
}
pub fn select_path(&mut self, path: &std::path::Path) {
if let Some(pos) = self
.visible
.iter()
.position(|&i| self.worktrees[i].path == path)
{
self.selected = pos;
}
}
pub fn select_branch(&mut self, branch: &str) -> bool {
let Some(pos) = self.visible.iter().position(|&i| {
let w = &self.worktrees[i];
w.has_worktree && w.branch.as_deref() == Some(branch)
}) else {
return false;
};
self.selected = pos;
self.detail_scroll = 0;
true
}
}
fn haystack(worktree: &Worktree) -> String {
let path = if worktree.has_worktree {
worktree.path.display().to_string()
} else {
String::new()
};
format!(
"{} {} {}",
worktree.branch.as_deref().unwrap_or(""),
worktree.slug.as_deref().unwrap_or(""),
path
)
}
#[cfg(test)]
pub(crate) mod testutil {
use super::*;
use std::path::PathBuf;
pub(crate) fn wt(branch: &str, current: bool) -> Worktree {
let mut w = Worktree::new(PathBuf::from(format!("/r/{branch}")));
w.branch = Some(branch.to_string());
w.slug = Some(branch.replace('/', "-"));
w.is_current = current;
w
}
pub(crate) fn branch_row(branch: &str) -> Worktree {
let mut w = Worktree::new(PathBuf::from(format!("branch://{branch}")));
w.branch = Some(branch.to_string());
w.slug = Some(branch.replace('/', "-"));
w.has_worktree = false;
w
}
pub(crate) fn app(branches: &[(&str, bool)]) -> App {
let worktrees: Vec<Worktree> = branches.iter().map(|(b, c)| wt(b, *c)).collect();
App::new(
worktrees,
AppConfig {
keymap: Keymap::defaults(),
sort: SortSpec::default(),
columns: Column::ALL.to_vec(),
show_untracked: true,
remove_untracked_blocks: false,
nerd_fonts: false,
mouse: true,
color: true,
palette: Palette::one_dark(),
},
(100, 30),
)
}
}
#[cfg(test)]
mod tests {
use super::testutil::app;
use super::*;
#[test]
fn selects_current_worktree_initially() {
let a = app(&[("main", false), ("feature", true)]);
assert_eq!(
a.selected_worktree().unwrap().branch.as_deref(),
Some("feature")
);
}
#[test]
fn navigation_clamps() {
let mut a = app(&[("a", true), ("b", false), ("c", false)]);
a.selected = 0;
a.move_selection(-1);
assert_eq!(a.selected, 0);
a.move_selection(5);
assert_eq!(a.selected, 2);
a.select_edge(false);
assert_eq!(a.selected, 0);
a.select_edge(true);
assert_eq!(a.selected, 2);
}
#[test]
fn filter_narrows_and_clamps_selection() {
let mut a = app(&[("alpha", true), ("beta", false), ("alphabet", false)]);
a.selected = 2;
a.filter_push('a');
a.filter_push('l');
a.filter_push('p');
assert_eq!(a.visible.len(), 2);
assert!(a.selected < a.visible.len());
a.clear_filter();
assert_eq!(a.visible.len(), 3);
}
#[test]
fn apply_filter_seeds_filter_and_resets_selection() {
let mut a = app(&[("alpha", true), ("beta", false), ("alphabet", false)]);
a.selected = 2;
a.apply_filter("alph".to_string());
assert_eq!(a.filter, "alph");
assert_eq!(a.visible.len(), 2);
assert_eq!(a.selected, 0);
}
#[test]
fn sort_preserves_selection_by_path() {
let mut a = app(&[("zebra", false), ("alpha", true), ("mango", false)]);
a.sort = SortSpec {
key: SortKey::Branch,
descending: false,
};
a.resort_preserving_selection();
assert_eq!(
a.selected_worktree().unwrap().branch.as_deref(),
Some("alpha")
);
}
#[test]
fn base_worktree_stays_first_after_sort() {
let mut a = app(&[("zebra", false), ("main", true), ("alpha", false)]);
let base = a
.worktrees
.iter()
.position(|w| w.branch.as_deref() == Some("main"))
.unwrap();
a.worktrees[base].is_main = true;
a.sort = SortSpec {
key: SortKey::Branch,
descending: false,
};
a.resort_preserving_selection();
let order: Vec<&str> = a
.visible
.iter()
.map(|&i| a.worktrees[i].branch.as_deref().unwrap())
.collect();
assert_eq!(order, vec!["main", "alpha", "zebra"]);
assert_eq!(
a.selected_worktree().unwrap().branch.as_deref(),
Some("main")
);
}
#[test]
fn cycle_sort_advances_field() {
let mut a = app(&[("a", true)]);
assert_eq!(a.sort.key, SortKey::Branch);
a.cycle_sort();
assert_eq!(a.sort.key, SortKey::Dirty);
a.reverse_sort();
assert!(a.sort.descending);
}
#[test]
fn detail_visible_respects_width() {
let mut a = app(&[("a", true)]);
a.size = (100, 30);
assert!(a.detail_visible());
a.size = (50, 30); assert!(!a.detail_visible());
a.show_sidebar = false; assert!(a.detail_visible());
}
#[test]
fn branch_rows_sort_below_worktrees_and_filter_by_name() {
use super::testutil::branch_row;
let mut a = app(&[("main", true), ("zebra", false)]);
a.worktrees.push(branch_row("feature/lonely"));
a.resort_preserving_selection();
let order: Vec<&str> = a
.visible
.iter()
.map(|&i| a.worktrees[i].branch.as_deref().unwrap())
.collect();
assert_eq!(order, vec!["main", "zebra", "feature/lonely"]);
a.apply_filter("lonely".into());
assert_eq!(a.visible.len(), 1);
assert_eq!(
a.selected_worktree().unwrap().branch.as_deref(),
Some("feature/lonely")
);
}
#[test]
fn select_row_within_bounds() {
let mut a = app(&[("a", true), ("b", false)]);
a.select_row(1);
assert_eq!(a.selected, 1);
a.select_row(99); assert_eq!(a.selected, 1);
}
#[test]
fn select_branch_focuses_match() {
let mut a = app(&[("main", true), ("feature/x", false), ("other", false)]);
a.selected = 0;
assert!(a.select_branch("feature/x"));
assert_eq!(
a.selected_worktree().unwrap().branch.as_deref(),
Some("feature/x")
);
}
#[test]
fn select_branch_misses_leave_selection_unchanged() {
let mut a = app(&[("alpha", true), ("beta", false)]);
a.selected = 1;
a.apply_filter("alph".into());
a.selected = 0;
assert!(!a.select_branch("beta"));
assert_eq!(a.selected, 0);
assert!(!a.select_branch("ghost"));
assert_eq!(a.selected, 0);
}
#[test]
fn select_branch_ignores_worktree_less_branch_rows() {
use super::testutil::branch_row;
let mut a = app(&[("main", true)]);
a.worktrees.push(branch_row("topic"));
a.apply_filter(String::new()); a.selected = 0;
assert!(!a.select_branch("topic"));
}
#[test]
fn job_registry_begin_finish_and_query() {
let mut a = app(&[("main", true), ("feat", false)]);
assert!(!a.any_jobs());
let key = JobKey::Path(PathBuf::from("/r/feat"));
a.begin_job(key.clone(), "Removing feat");
assert!(a.any_jobs());
assert!(a.has_job(&key));
assert_eq!(a.job_summary().as_deref(), Some("Removing feat…"));
let feat = a
.worktrees
.iter()
.find(|w| w.branch.as_deref() == Some("feat"));
assert_eq!(a.job_for(feat.unwrap()).unwrap().label, "Removing feat");
a.begin_job(key.clone(), "Removing feat again");
assert_eq!(a.jobs.len(), 1);
a.finish_job(&key);
assert!(!a.any_jobs());
assert!(a.job_summary().is_none());
}
#[test]
fn job_summary_counts_multiple() {
let mut a = app(&[("main", true)]);
a.begin_job(JobKey::New("feat/a".into()), "Creating feat/a");
a.begin_job(JobKey::Branch("feat/b".into()), "Deleting branch feat/b");
let summary = a.job_summary().unwrap();
assert!(summary.contains("+1 more"));
}
#[test]
fn branch_job_attaches_to_branch_row_only() {
use super::testutil::branch_row;
let mut a = app(&[("main", true)]);
a.worktrees.push(branch_row("topic"));
a.begin_job(JobKey::Branch("topic".into()), "Deleting branch topic");
let row = a
.worktrees
.iter()
.find(|w| !w.has_worktree && w.branch.as_deref() == Some("topic"))
.unwrap();
assert!(a.job_for(row).is_some());
a.begin_job(JobKey::New("brand-new".into()), "Creating brand-new");
assert!(a.job_for(&a.worktrees[0]).is_none());
}
#[test]
fn tick_spinner_advances_only_with_jobs() {
let mut a = app(&[("a", true)]);
a.tick_spinner();
assert_eq!(a.spinner_frame, 0); a.begin_job(JobKey::New("x".into()), "Creating x");
a.tick_spinner();
a.tick_spinner();
assert_eq!(a.spinner_frame, 2);
}
#[test]
fn may_apply_mode_guards_against_unrelated_modals() {
let mut a = app(&[("a", true)]);
assert!(a.may_apply_mode(JobHome::List));
assert!(a.may_apply_mode(JobHome::Create));
a.mode = Mode::ConfirmRemove(0);
assert!(!a.may_apply_mode(JobHome::List));
a.mode = Mode::Checkout(Default::default());
assert!(a.may_apply_mode(JobHome::Checkout));
assert!(!a.may_apply_mode(JobHome::Create));
}
#[test]
fn pending_jobs_queue_and_drain() {
let mut a = app(&[("a", true)]);
assert!(a.take_pending_jobs().is_empty());
a.queue_job(Effect::InitSubmodules {
dir: PathBuf::from("/wt/x"),
count: 2,
});
let drained = a.take_pending_jobs();
assert_eq!(drained.len(), 1);
assert!(a.take_pending_jobs().is_empty());
}
}