1use std::path::PathBuf;
7
8use crate::agent::{AgentModel, Effort};
9use crate::keys::Keymap;
10use crate::model::{Column, SortKey, SortSpec, Worktree};
11use crate::tui::options::OptionList;
12use crate::tui::theme::Palette;
13use crate::util::fuzzy;
14
15pub const MIN_DETAIL_WIDTH: u16 = 60;
17pub const MIN_HEIGHT: u16 = 5;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Mode {
23 List,
25 Filter,
27 Create(CreateState),
29 PrPicker(PrPickerState),
31 PrCompose(PrComposeState),
33 Checkout(CheckoutState),
35 ConfirmRemove(usize),
37 ConfirmCreate(usize),
40 ConfirmDeleteBranch {
45 index: usize,
47 force: bool,
49 },
50 ConfirmStaleBase(StaleBaseState),
53 ConfirmInitSubmodules(InitSubmodulesState),
58 Help,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum Pane {
65 List,
67 Detail,
69}
70
71#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
73pub enum StatusKind {
74 #[default]
76 Info,
77 Success,
79 Error,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct BusyState {
88 pub label: String,
90 pub frame: usize,
92}
93
94#[derive(Debug, Clone, Default, PartialEq, Eq)]
96pub struct CreateState {
97 pub step: CreateStep,
99 pub branch: String,
101 pub base: String,
103 pub error: Option<String>,
105 pub options: OptionList,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct StaleBaseState {
115 pub branch: String,
117 pub base: Option<String>,
119 pub behind: u32,
121 pub upstream_display: String,
123 pub can_fast_forward: bool,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct InitSubmodulesState {
133 pub dir: PathBuf,
135 pub branch: String,
137 pub count: usize,
139}
140
141#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
143pub enum CreateStep {
144 #[default]
146 Branch,
147 Base,
149}
150
151#[derive(Debug, Clone, Default, PartialEq, Eq)]
154pub struct CheckoutState {
155 pub worktree_index: usize,
157 pub query: String,
159 pub options: OptionList,
161 pub error: Option<String>,
163 pub submitting: bool,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct PrItem {
170 pub number: u64,
172 pub title: String,
174 pub author: String,
176 pub state: String,
178 pub created_at: String,
180}
181
182#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
184pub enum ComposeField {
185 #[default]
187 Title,
188 Body,
190 Model,
192 Effort,
194}
195
196#[derive(Debug, Clone, Default, PartialEq, Eq)]
199pub struct PrComposeState {
200 pub field: ComposeField,
202 pub title: String,
204 pub body: String,
206 pub draft: bool,
208 pub branch: String,
210 pub trunk: String,
212 pub action_label: String,
214 pub model: AgentModel,
216 pub effort: Effort,
218 pub submitting: bool,
220 pub error: Option<String>,
222}
223
224#[derive(Debug, Clone, Default, PartialEq, Eq)]
226pub struct PrPickerState {
227 pub loading: bool,
229 pub prs: Vec<PrItem>,
231 pub selected: usize,
233 pub error: Option<String>,
235}
236
237pub struct App {
239 pub worktrees: Vec<Worktree>,
241 pub visible: Vec<usize>,
243 pub selected: usize,
245 pub mode: Mode,
247 pub filter: String,
249 pub focus: Pane,
251 pub show_sidebar: bool,
253 pub sidebar_width: u16,
255 pub sort: SortSpec,
257 pub detail_scroll: u16,
259 pub size: (u16, u16),
261 pub keymap: Keymap,
263 pub columns: Vec<Column>,
265 pub show_untracked: bool,
267 pub remove_untracked_blocks: bool,
270 pub nerd_fonts: bool,
272 pub mouse: bool,
274 pub color: bool,
276 pub palette: Palette,
278 pub quit: bool,
280 pub chosen: Option<PathBuf>,
282 pub exit_on_pr_checkout: bool,
286 loaded_paths: std::collections::HashSet<PathBuf>,
289 pub status_message: Option<String>,
291 pub status_kind: StatusKind,
293 pub too_small: bool,
295 pub busy: Option<BusyState>,
299 pub branches: Vec<String>,
303 pub default_base: Option<String>,
308}
309
310pub struct AppConfig {
313 pub keymap: Keymap,
315 pub sort: SortSpec,
317 pub columns: Vec<Column>,
319 pub show_untracked: bool,
321 pub remove_untracked_blocks: bool,
323 pub nerd_fonts: bool,
325 pub mouse: bool,
327 pub color: bool,
329 pub palette: Palette,
331}
332
333impl App {
334 pub fn new(worktrees: Vec<Worktree>, config: AppConfig, size: (u16, u16)) -> App {
338 let visible = (0..worktrees.len()).collect();
339 let selected = worktrees.iter().position(|w| w.is_current).unwrap_or(0);
340 let loaded_paths = worktrees.iter().map(|w| w.path.clone()).collect();
341 App {
342 loaded_paths,
343 status_message: None,
344 status_kind: StatusKind::Info,
345 too_small: false,
346 busy: None,
347 branches: Vec::new(),
348 default_base: None,
349 worktrees,
350 visible,
351 selected,
352 mode: Mode::List,
353 filter: String::new(),
354 focus: Pane::List,
355 show_sidebar: true,
356 sidebar_width: 40,
357 sort: config.sort,
358 detail_scroll: 0,
359 size,
360 keymap: config.keymap,
361 columns: config.columns,
362 show_untracked: config.show_untracked,
363 remove_untracked_blocks: config.remove_untracked_blocks,
364 nerd_fonts: config.nerd_fonts,
365 mouse: config.mouse,
366 color: config.color,
367 palette: config.palette,
368 quit: false,
369 chosen: None,
370 exit_on_pr_checkout: false,
371 }
372 }
373
374 pub fn set_status(&mut self, message: impl Into<String>, kind: StatusKind) {
376 self.status_message = Some(message.into());
377 self.status_kind = kind;
378 }
379
380 pub fn begin_busy(&mut self, label: impl Into<String>) {
383 self.busy = Some(BusyState {
384 label: label.into(),
385 frame: 0,
386 });
387 }
388
389 pub fn tick_busy(&mut self) {
392 if let Some(busy) = &mut self.busy {
393 busy.frame = busy.frame.wrapping_add(1);
394 }
395 }
396
397 pub fn end_busy(&mut self) {
399 self.busy = None;
400 }
401
402 pub fn is_busy(&self) -> bool {
404 self.busy.is_some()
405 }
406
407 pub fn selected_worktree(&self) -> Option<&Worktree> {
409 self.visible
410 .get(self.selected)
411 .and_then(|&i| self.worktrees.get(i))
412 }
413
414 pub fn is_loaded(&self, worktree: &Worktree) -> bool {
416 self.loaded_paths.contains(&worktree.path)
417 }
418
419 pub fn mark_loading(&mut self) {
421 self.loaded_paths.clear();
422 }
423
424 pub fn mark_loaded(&mut self, path: PathBuf) {
426 self.loaded_paths.insert(path);
427 }
428
429 pub fn detail_visible(&self) -> bool {
431 !self.show_sidebar || self.size.0 >= MIN_DETAIL_WIDTH
432 }
433
434 pub fn set_worktrees(&mut self, worktrees: Vec<Worktree>) {
437 let selected_path = self.selected_worktree().map(|w| w.path.clone());
438 self.worktrees = worktrees;
439 self.apply_sort();
440 self.recompute_visible();
441 if let Some(path) = selected_path {
442 self.select_path(&path);
443 }
444 }
445
446 pub fn move_selection(&mut self, delta: isize) {
449 if self.visible.is_empty() {
450 return;
451 }
452 let max = self.visible.len() as isize - 1;
453 let next = (self.selected as isize + delta).clamp(0, max);
454 self.selected = next as usize;
455 self.detail_scroll = 0;
456 }
457
458 pub fn select_edge(&mut self, last: bool) {
460 if self.visible.is_empty() {
461 return;
462 }
463 self.selected = if last { self.visible.len() - 1 } else { 0 };
464 self.detail_scroll = 0;
465 }
466
467 pub fn select_row(&mut self, row: usize) {
469 if row < self.visible.len() {
470 self.selected = row;
471 self.detail_scroll = 0;
472 }
473 }
474
475 pub fn scroll_detail(&mut self, delta: isize) {
478 let max = self.selected_worktree().map_or(0, |w| {
479 (w.recent_commits.len() + 10) as isize
481 });
482 let next = (self.detail_scroll as isize + delta).clamp(0, max.max(0));
483 self.detail_scroll = next as u16;
484 }
485
486 pub fn cycle_sort(&mut self) {
488 const ORDER: [SortKey; 6] = [
489 SortKey::Branch,
490 SortKey::Dirty,
491 SortKey::Ahead,
492 SortKey::Behind,
493 SortKey::Activity,
494 SortKey::Path,
495 ];
496 let current = ORDER.iter().position(|k| *k == self.sort.key).unwrap_or(0);
497 self.sort.key = ORDER[(current + 1) % ORDER.len()];
498 self.resort_preserving_selection();
499 }
500
501 pub fn reverse_sort(&mut self) {
503 self.sort.descending = !self.sort.descending;
504 self.resort_preserving_selection();
505 }
506
507 pub fn filter_push(&mut self, c: char) {
509 self.filter.push(c);
510 self.recompute_visible();
511 }
512
513 pub fn filter_pop(&mut self) {
515 self.filter.pop();
516 self.recompute_visible();
517 }
518
519 pub fn clear_filter(&mut self) {
521 self.filter.clear();
522 self.recompute_visible();
523 }
524
525 pub(crate) fn apply_filter(&mut self, filter: String) {
529 self.filter = filter;
530 self.selected = 0;
531 self.recompute_visible();
532 }
533
534 fn resort_preserving_selection(&mut self) {
536 let selected_path = self.selected_worktree().map(|w| w.path.clone());
537 self.apply_sort();
538 self.recompute_visible();
539 if let Some(path) = selected_path {
540 self.select_path(&path);
541 }
542 }
543
544 fn apply_sort(&mut self) {
547 crate::worktree_service::sort_worktrees_base_first(&mut self.worktrees, self.sort);
548 }
549
550 fn recompute_visible(&mut self) {
552 if self.filter.is_empty() {
553 self.visible = (0..self.worktrees.len()).collect();
554 } else {
555 let haystacks: Vec<String> = self.worktrees.iter().map(haystack).collect();
556 let matched = fuzzy::filter_indices(&haystacks, &self.filter);
557 let keep: std::collections::HashSet<usize> = matched.into_iter().collect();
559 self.visible = (0..self.worktrees.len())
560 .filter(|i| keep.contains(i))
561 .collect();
562 }
563 if self.selected >= self.visible.len() {
564 self.selected = self.visible.len().saturating_sub(1);
565 }
566 }
567
568 fn select_path(&mut self, path: &std::path::Path) {
570 if let Some(pos) = self
571 .visible
572 .iter()
573 .position(|&i| self.worktrees[i].path == path)
574 {
575 self.selected = pos;
576 }
577 }
578
579 pub fn select_branch(&mut self, branch: &str) -> bool {
584 let Some(pos) = self.visible.iter().position(|&i| {
585 let w = &self.worktrees[i];
586 w.has_worktree && w.branch.as_deref() == Some(branch)
587 }) else {
588 return false;
589 };
590 self.selected = pos;
591 self.detail_scroll = 0;
592 true
593 }
594}
595
596fn haystack(worktree: &Worktree) -> String {
600 let path = if worktree.has_worktree {
601 worktree.path.display().to_string()
602 } else {
603 String::new()
604 };
605 format!(
606 "{} {} {}",
607 worktree.branch.as_deref().unwrap_or(""),
608 worktree.slug.as_deref().unwrap_or(""),
609 path
610 )
611}
612
613#[cfg(test)]
614pub(crate) mod testutil {
615 use super::*;
616 use std::path::PathBuf;
617
618 pub(crate) fn wt(branch: &str, current: bool) -> Worktree {
620 let mut w = Worktree::new(PathBuf::from(format!("/r/{branch}")));
621 w.branch = Some(branch.to_string());
622 w.slug = Some(branch.replace('/', "-"));
623 w.is_current = current;
624 w
625 }
626
627 pub(crate) fn branch_row(branch: &str) -> Worktree {
629 let mut w = Worktree::new(PathBuf::from(format!("branch://{branch}")));
630 w.branch = Some(branch.to_string());
631 w.slug = Some(branch.replace('/', "-"));
632 w.has_worktree = false;
633 w
634 }
635
636 pub(crate) fn app(branches: &[(&str, bool)]) -> App {
638 let worktrees: Vec<Worktree> = branches.iter().map(|(b, c)| wt(b, *c)).collect();
639 App::new(
640 worktrees,
641 AppConfig {
642 keymap: Keymap::defaults(),
643 sort: SortSpec::default(),
644 columns: Column::ALL.to_vec(),
645 show_untracked: true,
646 remove_untracked_blocks: false,
647 nerd_fonts: false,
648 mouse: true,
649 color: true,
650 palette: Palette::one_dark(),
651 },
652 (100, 30),
653 )
654 }
655}
656
657#[cfg(test)]
658mod tests {
659 use super::testutil::app;
660 use super::*;
661
662 #[test]
663 fn selects_current_worktree_initially() {
664 let a = app(&[("main", false), ("feature", true)]);
665 assert_eq!(
666 a.selected_worktree().unwrap().branch.as_deref(),
667 Some("feature")
668 );
669 }
670
671 #[test]
672 fn navigation_clamps() {
673 let mut a = app(&[("a", true), ("b", false), ("c", false)]);
674 a.selected = 0;
675 a.move_selection(-1);
676 assert_eq!(a.selected, 0);
677 a.move_selection(5);
678 assert_eq!(a.selected, 2);
679 a.select_edge(false);
680 assert_eq!(a.selected, 0);
681 a.select_edge(true);
682 assert_eq!(a.selected, 2);
683 }
684
685 #[test]
686 fn filter_narrows_and_clamps_selection() {
687 let mut a = app(&[("alpha", true), ("beta", false), ("alphabet", false)]);
688 a.selected = 2;
689 a.filter_push('a');
690 a.filter_push('l');
691 a.filter_push('p');
692 assert_eq!(a.visible.len(), 2);
694 assert!(a.selected < a.visible.len());
695 a.clear_filter();
696 assert_eq!(a.visible.len(), 3);
697 }
698
699 #[test]
700 fn apply_filter_seeds_filter_and_resets_selection() {
701 let mut a = app(&[("alpha", true), ("beta", false), ("alphabet", false)]);
702 a.selected = 2;
703 a.apply_filter("alph".to_string());
704 assert_eq!(a.filter, "alph");
705 assert_eq!(a.visible.len(), 2);
707 assert_eq!(a.selected, 0);
708 }
709
710 #[test]
711 fn sort_preserves_selection_by_path() {
712 let mut a = app(&[("zebra", false), ("alpha", true), ("mango", false)]);
713 a.sort = SortSpec {
715 key: SortKey::Branch,
716 descending: false,
717 };
718 a.resort_preserving_selection();
719 assert_eq!(
721 a.selected_worktree().unwrap().branch.as_deref(),
722 Some("alpha")
723 );
724 }
725
726 #[test]
727 fn base_worktree_stays_first_after_sort() {
728 let mut a = app(&[("zebra", false), ("main", true), ("alpha", false)]);
729 let base = a
731 .worktrees
732 .iter()
733 .position(|w| w.branch.as_deref() == Some("main"))
734 .unwrap();
735 a.worktrees[base].is_main = true;
736 a.sort = SortSpec {
737 key: SortKey::Branch,
738 descending: false,
739 };
740 a.resort_preserving_selection();
741 let order: Vec<&str> = a
743 .visible
744 .iter()
745 .map(|&i| a.worktrees[i].branch.as_deref().unwrap())
746 .collect();
747 assert_eq!(order, vec!["main", "alpha", "zebra"]);
748 assert_eq!(
750 a.selected_worktree().unwrap().branch.as_deref(),
751 Some("main")
752 );
753 }
754
755 #[test]
756 fn cycle_sort_advances_field() {
757 let mut a = app(&[("a", true)]);
758 assert_eq!(a.sort.key, SortKey::Branch);
759 a.cycle_sort();
760 assert_eq!(a.sort.key, SortKey::Dirty);
761 a.reverse_sort();
762 assert!(a.sort.descending);
763 }
764
765 #[test]
766 fn detail_visible_respects_width() {
767 let mut a = app(&[("a", true)]);
768 a.size = (100, 30);
769 assert!(a.detail_visible());
770 a.size = (50, 30); assert!(!a.detail_visible());
772 a.show_sidebar = false; assert!(a.detail_visible());
774 }
775
776 #[test]
777 fn branch_rows_sort_below_worktrees_and_filter_by_name() {
778 use super::testutil::branch_row;
779 let mut a = app(&[("main", true), ("zebra", false)]);
780 a.worktrees.push(branch_row("feature/lonely"));
781 a.resort_preserving_selection();
783 let order: Vec<&str> = a
784 .visible
785 .iter()
786 .map(|&i| a.worktrees[i].branch.as_deref().unwrap())
787 .collect();
788 assert_eq!(order, vec!["main", "zebra", "feature/lonely"]);
789 a.apply_filter("lonely".into());
791 assert_eq!(a.visible.len(), 1);
792 assert_eq!(
793 a.selected_worktree().unwrap().branch.as_deref(),
794 Some("feature/lonely")
795 );
796 }
797
798 #[test]
799 fn select_row_within_bounds() {
800 let mut a = app(&[("a", true), ("b", false)]);
801 a.select_row(1);
802 assert_eq!(a.selected, 1);
803 a.select_row(99); assert_eq!(a.selected, 1);
805 }
806
807 #[test]
808 fn select_branch_focuses_match() {
809 let mut a = app(&[("main", true), ("feature/x", false), ("other", false)]);
810 a.selected = 0;
811 assert!(a.select_branch("feature/x"));
812 assert_eq!(
813 a.selected_worktree().unwrap().branch.as_deref(),
814 Some("feature/x")
815 );
816 }
817
818 #[test]
819 fn select_branch_misses_leave_selection_unchanged() {
820 let mut a = app(&[("alpha", true), ("beta", false)]);
821 a.selected = 1;
822 a.apply_filter("alph".into());
824 a.selected = 0;
825 assert!(!a.select_branch("beta"));
826 assert_eq!(a.selected, 0);
827 assert!(!a.select_branch("ghost"));
829 assert_eq!(a.selected, 0);
830 }
831
832 #[test]
833 fn select_branch_ignores_worktree_less_branch_rows() {
834 use super::testutil::branch_row;
835 let mut a = app(&[("main", true)]);
836 a.worktrees.push(branch_row("topic"));
837 a.apply_filter(String::new()); a.selected = 0;
839 assert!(!a.select_branch("topic"));
841 }
842
843 #[test]
844 fn busy_lifecycle_begin_tick_end() {
845 let mut a = app(&[("a", true)]);
846 assert!(!a.is_busy());
847 a.begin_busy("Removing feat/foo");
848 assert!(a.is_busy());
849 assert_eq!(a.busy.as_ref().unwrap().frame, 0);
850 assert_eq!(a.busy.as_ref().unwrap().label, "Removing feat/foo");
851 a.tick_busy();
852 a.tick_busy();
853 assert_eq!(a.busy.as_ref().unwrap().frame, 2);
854 a.end_busy();
855 assert!(!a.is_busy());
856 }
857
858 #[test]
859 fn tick_busy_is_noop_when_idle() {
860 let mut a = app(&[("a", true)]);
861 a.tick_busy();
862 assert!(!a.is_busy());
863 }
864}