1use std::path::PathBuf;
7
8use crossterm::event::{
9 Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
10};
11
12use crate::keys::{KeyAction, KeyChord};
13use crate::tui::app::{
14 App, ComposeField, CreateState, CreateStep, ExitIntent, MIN_HEIGHT, Mode, Pane,
15};
16
17const MIN_SIDEBAR: u16 = 10;
19const MAX_SIDEBAR: u16 = 100;
20const LIST_TOP: u16 = 1;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum CreateDecision {
27 Update,
29 Proceed,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum Effect {
36 None,
38 Switch(PathBuf),
40 Quit,
42 TooSmall,
44 Create {
49 branch: String,
51 base: Option<String>,
53 decision: Option<CreateDecision>,
55 },
56 Remove {
59 index: usize,
61 unlock: bool,
63 },
64 DeleteBranch {
67 branch: String,
69 force: bool,
71 },
72 MaterializeBranch {
76 branch: String,
78 },
79 FetchPrs,
81 CheckoutPr(u64),
83 CheckoutBranch {
85 worktree_index: usize,
87 branch: String,
89 },
90 Sync {
92 worktree_index: usize,
94 },
95 InitSubmodules {
97 dir: PathBuf,
99 count: usize,
101 },
102 OpenEditor(PathBuf),
104 Refresh,
106 DraftPrAi,
108 SubmitPr {
110 title: String,
112 body: String,
114 draft: bool,
116 },
117}
118
119impl CreateState {
120 fn field_mut(&mut self) -> &mut String {
122 match self.step {
123 CreateStep::Branch => &mut self.branch,
124 CreateStep::Base => &mut self.base,
125 }
126 }
127
128 fn field_value(&self) -> &str {
130 match self.step {
131 CreateStep::Branch => &self.branch,
132 CreateStep::Base => &self.base,
133 }
134 }
135
136 fn refresh_options(&mut self) {
138 let query = self.field_value().to_owned();
139 self.options.refilter(&query);
140 self.options.open();
141 }
142}
143
144fn complete_base_ref(state: &mut CreateState, branches: &[String]) {
149 let matches: Vec<&str> = branches
150 .iter()
151 .map(String::as_str)
152 .filter(|b| b.starts_with(&state.base))
153 .collect();
154 if let Some(common) = longest_common_prefix(&matches)
155 && common.len() > state.base.len()
156 {
157 state.base = common;
158 }
159}
160
161fn longest_common_prefix(items: &[&str]) -> Option<String> {
164 let (first, rest) = items.split_first()?;
165 let mut prefix: &str = first;
166 for item in rest {
167 let shared = prefix
170 .char_indices()
171 .zip(item.chars())
172 .take_while(|&((_, a), b)| a == b)
173 .map(|((i, a), _)| i + a.len_utf8())
174 .last()
175 .unwrap_or(0);
176 prefix = &prefix[..shared];
177 if prefix.is_empty() {
178 break;
179 }
180 }
181 Some(prefix.to_string())
182}
183
184fn compose_next_field(field: ComposeField) -> ComposeField {
186 match field {
187 ComposeField::Title => ComposeField::Body,
188 ComposeField::Body => ComposeField::Model,
189 ComposeField::Model => ComposeField::Effort,
190 ComposeField::Effort => ComposeField::Title,
191 }
192}
193
194fn compose_prev_field(field: ComposeField) -> ComposeField {
197 match field {
198 ComposeField::Title => ComposeField::Effort,
199 ComposeField::Effort => ComposeField::Model,
200 ComposeField::Model => ComposeField::Body,
201 ComposeField::Body => ComposeField::Title,
202 }
203}
204
205impl App {
206 pub fn handle_event(&mut self, event: Event) -> Effect {
208 match event {
209 Event::Resize(cols, rows) => self.on_resize(cols, rows),
210 Event::Key(key) if key.kind != KeyEventKind::Release => self.on_key(key),
211 Event::Mouse(mouse) if self.mouse => self.on_mouse(mouse),
212 _ => Effect::None,
213 }
214 }
215
216 fn on_resize(&mut self, cols: u16, rows: u16) -> Effect {
218 self.size = (cols, rows);
219 if rows < MIN_HEIGHT {
220 Effect::TooSmall
221 } else {
222 Effect::None
223 }
224 }
225
226 fn on_key(&mut self, key: KeyEvent) -> Effect {
228 match &self.mode {
229 Mode::List => self.key_list(key),
230 Mode::Filter => self.key_filter(key),
231 Mode::Create(_) => self.key_create(key),
232 Mode::PrPicker(_) => self.key_pr(key),
233 Mode::PrCompose(_) => self.key_compose(key),
234 Mode::Checkout(_) => self.key_checkout_picker(key),
235 Mode::ConfirmRemove(_) | Mode::ConfirmRemoveLocked { .. } => self.key_confirm(key),
236 Mode::ConfirmCreate(_) => self.key_confirm_create(key),
237 Mode::ConfirmDeleteBranch { .. } => self.key_confirm_delete_branch(key),
238 Mode::ConfirmStaleBase(_) => self.key_confirm_stale_base(key),
239 Mode::ConfirmInitSubmodules(_) => self.key_confirm_init_submodules(key),
240 Mode::ExitBlocked(_) => self.key_exit_blocked(key),
241 Mode::Help => {
242 self.mode = Mode::List;
243 Effect::None
244 }
245 }
246 }
247
248 fn key_list(&mut self, key: KeyEvent) -> Effect {
250 let Some(action) = self.keymap.action_for(KeyChord::from_event(key)) else {
251 return Effect::None;
252 };
253 let page = (self.size.1 as isize - 3).max(1);
254 match action {
255 KeyAction::NavigateUp => self.nav_or_scroll(-1),
256 KeyAction::NavigateDown => self.nav_or_scroll(1),
257 KeyAction::PageUp => self.nav_or_scroll(-page),
258 KeyAction::PageDown => self.nav_or_scroll(page),
259 KeyAction::GoToTop => self.select_edge(false),
260 KeyAction::GoToBottom => self.select_edge(true),
261 KeyAction::FocusNextPane | KeyAction::FocusPrevPane => self.toggle_focus(),
262 KeyAction::Switch => {
263 if let Some(&index) = self.visible.get(self.selected) {
264 let wt = &self.worktrees[index];
265 if wt.has_worktree {
266 let path = wt.path.clone();
271 return self.request_exit(ExitIntent::Switch(path));
272 }
273 self.mode = Mode::ConfirmCreate(index);
276 }
277 }
278 KeyAction::Filter => self.mode = Mode::Filter,
279 KeyAction::ClearFilter => self.clear_filter(),
280 KeyAction::New => {
281 let options = crate::tui::OptionList::new(self.branches.clone());
284 self.mode = Mode::Create(CreateState {
288 base: self.default_base.clone().unwrap_or_default(),
289 options,
290 ..Default::default()
291 });
292 }
293 KeyAction::Remove => {
294 if let Some(&index) = self.visible.get(self.selected) {
298 self.mode = if self.worktrees[index].has_worktree {
299 Mode::ConfirmRemove(index)
300 } else {
301 Mode::ConfirmDeleteBranch {
302 index,
303 force: false,
304 }
305 };
306 }
307 }
308 KeyAction::PrCheckout => {
309 self.mode = Mode::PrPicker(crate::tui::app::PrPickerState {
310 loading: true,
311 ..Default::default()
312 });
313 return Effect::FetchPrs;
314 }
315 KeyAction::Checkout => {
316 if let Some(&index) = self.visible.get(self.selected)
321 && self.worktrees[index].has_worktree
322 {
323 let mut options = crate::tui::OptionList::new(self.branches.clone());
327 options.open();
328 self.mode = Mode::Checkout(crate::tui::app::CheckoutState {
329 worktree_index: index,
330 options,
331 ..Default::default()
332 });
333 }
334 }
335 KeyAction::Sync => {
336 if let Some(&index) = self.visible.get(self.selected) {
341 return Effect::Sync {
342 worktree_index: index,
343 };
344 }
345 }
346 KeyAction::OpenEditor => {
347 if let Some(wt) = self.selected_worktree()
349 && wt.has_worktree
350 {
351 return Effect::OpenEditor(wt.path.clone());
352 }
353 }
354 KeyAction::Refresh => return Effect::Refresh,
355 KeyAction::SortCycle => self.cycle_sort(),
356 KeyAction::SortReverse => self.reverse_sort(),
357 KeyAction::Help => self.mode = Mode::Help,
358 KeyAction::Quit => {
359 return self.request_exit(ExitIntent::Quit);
364 }
365 KeyAction::ToggleSidebar => self.show_sidebar = !self.show_sidebar,
366 KeyAction::ResizeSidebarGrow => {
367 self.sidebar_width = (self.sidebar_width + 1).min(MAX_SIDEBAR);
368 }
369 KeyAction::ResizeSidebarShrink => {
370 self.sidebar_width = self.sidebar_width.saturating_sub(1).max(MIN_SIDEBAR);
371 }
372 }
373 Effect::None
374 }
375
376 fn key_filter(&mut self, key: KeyEvent) -> Effect {
378 match key.code {
379 KeyCode::Char(c) => self.filter_push(c),
380 KeyCode::Backspace => self.filter_pop(),
381 KeyCode::Enter => self.mode = Mode::List, KeyCode::Esc => {
383 self.clear_filter();
384 self.mode = Mode::List;
385 }
386 KeyCode::Up => self.move_selection(-1),
387 KeyCode::Down => self.move_selection(1),
388 _ => {}
389 }
390 Effect::None
391 }
392
393 fn key_create(&mut self, key: KeyEvent) -> Effect {
398 let Mode::Create(state) = &mut self.mode else {
399 return Effect::None;
400 };
401 match key.code {
402 KeyCode::Char(c) => {
403 state.field_mut().push(c);
404 state.error = None;
405 state.refresh_options();
406 }
407 KeyCode::Backspace => {
408 state.field_mut().pop();
409 state.refresh_options();
410 }
411 KeyCode::Up => state.options.up(),
412 KeyCode::Down => state.options.down(),
413 KeyCode::Tab => {
414 if state.step == CreateStep::Base {
415 complete_base_ref(state, &self.branches);
416 state.refresh_options();
417 }
418 }
419 KeyCode::Esc => {
420 if state.options.is_open() {
421 state.options.close();
422 } else {
423 self.mode = Mode::List;
424 }
425 }
426 KeyCode::Enter => {
427 if let Some(selected) = state.options.selected().map(str::to_owned) {
430 *state.field_mut() = selected;
431 state.options.close();
432 } else {
433 match state.step {
434 CreateStep::Branch => {
435 let branch = state.branch.trim();
436 if branch.is_empty() {
437 state.error = Some("branch name is required".into());
438 } else if let Err(msg) = crate::git::validate_branch_name(branch) {
439 state.error = Some(msg);
440 } else {
441 state.step = CreateStep::Base;
442 state.refresh_options();
444 }
445 }
446 CreateStep::Base => {
447 let branch = state.branch.clone();
448 let base = (!state.base.trim().is_empty()).then(|| state.base.clone());
449 return Effect::Create {
452 branch,
453 base,
454 decision: None,
455 };
456 }
457 }
458 }
459 }
460 _ => {}
461 }
462 Effect::None
463 }
464
465 fn key_pr(&mut self, key: KeyEvent) -> Effect {
467 let Mode::PrPicker(state) = &mut self.mode else {
468 return Effect::None;
469 };
470 match key.code {
471 KeyCode::Up => state.selected = state.selected.saturating_sub(1),
472 KeyCode::Down => {
473 state.selected = (state.selected + 1).min(state.prs.len().saturating_sub(1));
474 }
475 KeyCode::Enter => {
476 if let Some(pr) = state.prs.get(state.selected) {
477 return Effect::CheckoutPr(pr.number);
478 }
479 }
480 KeyCode::Esc => self.mode = Mode::List,
481 _ => {}
482 }
483 Effect::None
484 }
485
486 fn key_compose(&mut self, key: KeyEvent) -> Effect {
493 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
494 let Mode::PrCompose(state) = &mut self.mode else {
495 return Effect::None;
496 };
497 if state.submitting {
499 return Effect::None;
500 }
501 match key.code {
502 KeyCode::Char('s') if ctrl => {
503 if state.title.trim().is_empty() {
504 state.error = Some("a PR title is required".into());
505 } else {
506 state.submitting = true;
507 return Effect::SubmitPr {
508 title: state.title.clone(),
509 body: state.body.clone(),
510 draft: state.draft,
511 };
512 }
513 }
514 KeyCode::Char('a') if ctrl => {
516 state.submitting = true;
517 state.error = None;
518 return Effect::DraftPrAi;
519 }
520 KeyCode::Char('m') if ctrl => state.model = state.model.next(),
522 KeyCode::Char('e') if ctrl => state.effort = state.effort.next(),
523 KeyCode::Char('d') if ctrl => state.draft = !state.draft,
524 KeyCode::Char(c) if !ctrl => {
526 match state.field {
527 ComposeField::Title => state.title.push(c),
528 ComposeField::Body => state.body.push(c),
529 ComposeField::Model | ComposeField::Effort => {}
530 }
531 state.error = None;
532 }
533 KeyCode::Backspace => {
534 match state.field {
535 ComposeField::Title => state.title.pop(),
536 ComposeField::Body => state.body.pop(),
537 ComposeField::Model | ComposeField::Effort => None,
538 };
539 state.error = None;
540 }
541 KeyCode::Up => match state.field {
543 ComposeField::Model => state.model = state.model.prev(),
544 ComposeField::Effort => state.effort = state.effort.prev(),
545 _ => {}
546 },
547 KeyCode::Down => match state.field {
548 ComposeField::Model => state.model = state.model.next(),
549 ComposeField::Effort => state.effort = state.effort.next(),
550 _ => {}
551 },
552 KeyCode::Tab => state.field = compose_next_field(state.field),
553 KeyCode::BackTab => state.field = compose_prev_field(state.field),
554 KeyCode::Enter => match state.field {
555 ComposeField::Title => state.field = ComposeField::Body,
556 ComposeField::Body => state.body.push('\n'),
557 ComposeField::Model => state.field = ComposeField::Effort,
559 ComposeField::Effort => state.field = ComposeField::Title,
560 },
561 KeyCode::Esc => self.mode = Mode::List,
562 _ => {}
563 }
564 Effect::None
565 }
566
567 fn key_checkout_picker(&mut self, key: KeyEvent) -> Effect {
572 let Mode::Checkout(state) = &mut self.mode else {
573 return Effect::None;
574 };
575 if state.submitting {
577 return Effect::None;
578 }
579 match key.code {
580 KeyCode::Char(c) => {
581 state.query.push(c);
582 state.error = None;
583 state.options.refilter(&state.query);
584 state.options.open();
585 }
586 KeyCode::Backspace => {
587 state.query.pop();
588 state.error = None;
589 state.options.refilter(&state.query);
590 state.options.open();
591 }
592 KeyCode::Up => state.options.up(),
593 KeyCode::Down => state.options.down(),
594 KeyCode::Esc => {
595 if state.options.is_open() {
596 state.options.close();
597 } else {
598 self.mode = Mode::List;
599 }
600 }
601 KeyCode::Enter => {
602 let branch = state
605 .options
606 .selected()
607 .map(str::to_owned)
608 .unwrap_or_else(|| state.query.trim().to_string());
609 if branch.is_empty() {
610 state.error = Some("branch name is required".into());
611 } else {
612 let worktree_index = state.worktree_index;
613 return Effect::CheckoutBranch {
614 worktree_index,
615 branch,
616 };
617 }
618 }
619 _ => {}
620 }
621 Effect::None
622 }
623
624 fn key_confirm(&mut self, key: KeyEvent) -> Effect {
628 let (index, unlock) = match self.mode {
629 Mode::ConfirmRemove(index) => (index, false),
630 Mode::ConfirmRemoveLocked { index, .. } => (index, true),
631 _ => return Effect::None,
632 };
633 self.mode = Mode::List;
634 if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
635 Effect::Remove { index, unlock }
636 } else {
637 Effect::None
638 }
639 }
640
641 fn key_confirm_create(&mut self, key: KeyEvent) -> Effect {
644 let Mode::ConfirmCreate(index) = self.mode else {
645 return Effect::None;
646 };
647 self.mode = Mode::List;
648 if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y'))
649 && let Some(branch) = self.worktrees.get(index).and_then(|w| w.branch.clone())
650 {
651 return Effect::MaterializeBranch { branch };
652 }
653 Effect::None
654 }
655
656 fn key_confirm_delete_branch(&mut self, key: KeyEvent) -> Effect {
661 let Mode::ConfirmDeleteBranch { index, force } = self.mode else {
662 return Effect::None;
663 };
664 self.mode = Mode::List;
665 if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y'))
666 && let Some(branch) = self.worktrees.get(index).and_then(|w| w.branch.clone())
667 {
668 return Effect::DeleteBranch { branch, force };
669 }
670 Effect::None
671 }
672
673 fn key_confirm_stale_base(&mut self, key: KeyEvent) -> Effect {
677 let Mode::ConfirmStaleBase(state) = &self.mode else {
678 return Effect::None;
679 };
680 let branch = state.branch.clone();
681 let base = state.base.clone();
682 self.mode = Mode::List;
683 let decision = match key.code {
684 KeyCode::Char('u') | KeyCode::Char('U') => CreateDecision::Update,
685 KeyCode::Char('p') | KeyCode::Char('P') => CreateDecision::Proceed,
686 _ => return Effect::None,
687 };
688 Effect::Create {
689 branch,
690 base,
691 decision: Some(decision),
692 }
693 }
694
695 fn key_confirm_init_submodules(&mut self, key: KeyEvent) -> Effect {
699 let Mode::ConfirmInitSubmodules(state) = &self.mode else {
700 return Effect::None;
701 };
702 match key.code {
703 KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => {
704 let dir = state.dir.clone();
705 let count = state.count;
706 self.mode = Mode::List;
707 Effect::InitSubmodules { dir, count }
708 }
709 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
710 self.mode = Mode::List;
711 Effect::None
712 }
713 _ => Effect::None,
714 }
715 }
716
717 fn key_exit_blocked(&mut self, key: KeyEvent) -> Effect {
723 let Mode::ExitBlocked(state) = &self.mode else {
724 return Effect::None;
725 };
726 let intent = state.intent.clone();
727 let abandon = matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y'))
728 || match intent {
729 ExitIntent::Quit => matches!(key.code, KeyCode::Char('q')),
730 ExitIntent::Switch(_) => matches!(key.code, KeyCode::Enter),
731 };
732 if abandon {
733 self.commit_exit(intent)
734 } else if matches!(
735 key.code,
736 KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N')
737 ) {
738 self.mode = Mode::List;
739 Effect::None
740 } else {
741 Effect::None
742 }
743 }
744
745 fn on_mouse(&mut self, mouse: MouseEvent) -> Effect {
747 if !matches!(self.mode, Mode::List | Mode::Filter) {
752 match mouse.kind {
753 MouseEventKind::ScrollUp => self.modal_scroll(-1),
754 MouseEventKind::ScrollDown => self.modal_scroll(1),
755 _ => {}
756 }
757 return Effect::None;
758 }
759 match mouse.kind {
760 MouseEventKind::Down(MouseButton::Left) => {
761 let status_row = self.size.1.saturating_sub(1);
763 if mouse.row >= status_row {
764 return Effect::None;
765 }
766 if self.show_sidebar && mouse.column < self.sidebar_width {
767 if mouse.row >= LIST_TOP {
770 self.select_row((mouse.row - LIST_TOP) as usize);
771 }
772 self.focus = Pane::List;
773 } else {
774 self.focus = Pane::Detail;
775 }
776 }
777 MouseEventKind::ScrollUp => self.nav_or_scroll(-1),
778 MouseEventKind::ScrollDown => self.nav_or_scroll(1),
779 _ => {}
780 }
781 Effect::None
782 }
783
784 fn modal_scroll(&mut self, delta: isize) {
789 let up = delta < 0;
790 match &mut self.mode {
791 Mode::Create(state) => {
792 if up {
793 state.options.up();
794 } else {
795 state.options.down();
796 }
797 }
798 Mode::Checkout(state) => {
799 if up {
800 state.options.up();
801 } else {
802 state.options.down();
803 }
804 }
805 Mode::PrPicker(state) => {
806 if up {
807 state.selected = state.selected.saturating_sub(1);
808 } else {
809 state.selected = (state.selected + 1).min(state.prs.len().saturating_sub(1));
810 }
811 }
812 _ => {}
813 }
814 }
815
816 fn nav_or_scroll(&mut self, delta: isize) {
819 if self.focus == Pane::Detail {
820 self.scroll_detail(delta);
821 } else {
822 self.move_selection(delta);
823 }
824 }
825
826 fn toggle_focus(&mut self) {
828 self.focus = match self.focus {
829 Pane::List => Pane::Detail,
830 Pane::Detail => Pane::List,
831 };
832 }
833}
834
835#[cfg(test)]
836mod tests {
837 use super::*;
838 use crate::tui::app::testutil::app;
839 use crossterm::event::{KeyModifiers, MouseButton};
840
841 fn press(code: KeyCode) -> Event {
842 Event::Key(KeyEvent::new(code, KeyModifiers::empty()))
843 }
844
845 fn ctrl(c: char) -> Event {
846 Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL))
847 }
848
849 #[test]
850 fn navigation_keys() {
851 let mut a = app(&[("a", true), ("b", false), ("c", false)]);
852 a.selected = 0;
853 assert_eq!(a.handle_event(press(KeyCode::Char('j'))), Effect::None);
854 assert_eq!(a.selected, 1);
855 a.handle_event(press(KeyCode::Char('k')));
856 assert_eq!(a.selected, 0);
857 a.handle_event(press(KeyCode::Char('G')));
858 assert_eq!(a.selected, 2);
859 a.handle_event(press(KeyCode::Char('g')));
860 assert_eq!(a.selected, 0);
861 a.handle_event(ctrl('d')); assert!(a.selected >= 1 || a.visible.len() == 1);
863 }
864
865 #[test]
866 fn enter_switches_to_selected() {
867 let mut a = app(&[("main", true), ("feat", false)]);
868 a.selected = 1;
869 let effect = a.handle_event(press(KeyCode::Enter));
870 assert_eq!(effect, Effect::Switch(std::path::PathBuf::from("/r/feat")));
871 assert_eq!(a.chosen, Some(std::path::PathBuf::from("/r/feat")));
872 }
873
874 #[test]
875 fn enter_on_branch_row_opens_confirm_create() {
876 use crate::tui::app::testutil::branch_row;
877 let mut a = app(&[("main", true)]);
878 a.worktrees.push(branch_row("topic"));
879 a.apply_filter(String::new()); a.selected = a.visible.len() - 1; let effect = a.handle_event(press(KeyCode::Enter));
882 assert_eq!(effect, Effect::None);
883 assert!(matches!(a.mode, Mode::ConfirmCreate(_)));
884 assert!(a.chosen.is_none()); }
886
887 #[test]
888 fn confirm_create_y_materializes_other_cancels() {
889 use crate::tui::app::testutil::branch_row;
890 let mut a = app(&[("main", true)]);
891 a.worktrees.push(branch_row("topic"));
892 let idx = a
893 .worktrees
894 .iter()
895 .position(|w| w.branch.as_deref() == Some("topic"))
896 .unwrap();
897 a.mode = Mode::ConfirmCreate(idx);
898 let effect = a.handle_event(press(KeyCode::Char('y')));
899 assert_eq!(
900 effect,
901 Effect::MaterializeBranch {
902 branch: "topic".into()
903 }
904 );
905 assert_eq!(a.mode, Mode::List);
906 a.mode = Mode::ConfirmCreate(idx);
908 let effect = a.handle_event(press(KeyCode::Char('n')));
909 assert_eq!(effect, Effect::None);
910 assert_eq!(a.mode, Mode::List);
911 }
912
913 #[test]
914 fn checkout_and_open_editor_are_noops_on_branch_rows() {
915 use crate::tui::app::testutil::branch_row;
918 let mut a = app(&[("main", true)]);
919 a.worktrees.push(branch_row("topic"));
920 a.apply_filter(String::new());
921 a.selected = a.visible.len() - 1; assert_eq!(a.handle_event(press(KeyCode::Char('c'))), Effect::None);
923 assert_eq!(a.mode, Mode::List);
924 assert_eq!(a.handle_event(press(KeyCode::Char('o'))), Effect::None);
925 assert_eq!(a.mode, Mode::List);
926 }
927
928 #[test]
929 fn sync_acts_on_worktree_rows_and_branch_rows() {
930 use crate::tui::app::testutil::branch_row;
931 let mut a = app(&[("main", true), ("feat", false)]);
932 a.selected = 1;
934 assert_eq!(
935 a.handle_event(press(KeyCode::Char('y'))),
936 Effect::Sync { worktree_index: 1 }
937 );
938 a.worktrees.push(branch_row("topic"));
941 a.apply_filter(String::new());
942 let idx = a
943 .worktrees
944 .iter()
945 .position(|w| w.branch.as_deref() == Some("topic"))
946 .unwrap();
947 a.selected = a.visible.iter().position(|&i| i == idx).unwrap();
948 assert_eq!(
949 a.handle_event(press(KeyCode::Char('y'))),
950 Effect::Sync {
951 worktree_index: idx
952 }
953 );
954 assert_eq!(a.mode, Mode::List);
955 }
956
957 #[test]
958 fn remove_on_branch_row_confirms_then_deletes_branch() {
959 use crate::tui::app::testutil::branch_row;
962 let mut a = app(&[("main", true)]);
963 a.worktrees.push(branch_row("topic"));
964 a.apply_filter(String::new());
965 a.selected = a.visible.len() - 1; assert_eq!(a.handle_event(press(KeyCode::Char('d'))), Effect::None);
967 assert!(matches!(
968 a.mode,
969 Mode::ConfirmDeleteBranch { force: false, .. }
970 ));
971 let effect = a.handle_event(press(KeyCode::Char('y')));
972 assert_eq!(
973 effect,
974 Effect::DeleteBranch {
975 branch: "topic".into(),
976 force: false,
977 }
978 );
979 assert_eq!(a.mode, Mode::List);
980 a.mode = Mode::ConfirmDeleteBranch {
982 index: a.visible[a.selected],
983 force: true,
984 };
985 assert_eq!(a.handle_event(press(KeyCode::Char('n'))), Effect::None);
986 assert_eq!(a.mode, Mode::List);
987 }
988
989 #[test]
990 fn confirm_stale_base_keys_reissue_create_or_cancel() {
991 use crate::tui::app::StaleBaseState;
992 let state = StaleBaseState {
993 branch: "feature".into(),
994 base: Some("main".into()),
995 behind: 2,
996 upstream_display: "origin/main".into(),
997 can_fast_forward: true,
998 };
999 let mut a = app(&[("main", true)]);
1000 a.mode = Mode::ConfirmStaleBase(state.clone());
1002 assert_eq!(
1003 a.handle_event(press(KeyCode::Char('u'))),
1004 Effect::Create {
1005 branch: "feature".into(),
1006 base: Some("main".into()),
1007 decision: Some(CreateDecision::Update),
1008 }
1009 );
1010 assert_eq!(a.mode, Mode::List);
1011 a.mode = Mode::ConfirmStaleBase(state.clone());
1013 assert_eq!(
1014 a.handle_event(press(KeyCode::Char('p'))),
1015 Effect::Create {
1016 branch: "feature".into(),
1017 base: Some("main".into()),
1018 decision: Some(CreateDecision::Proceed),
1019 }
1020 );
1021 a.mode = Mode::ConfirmStaleBase(state);
1023 assert_eq!(a.handle_event(press(KeyCode::Esc)), Effect::None);
1024 assert_eq!(a.mode, Mode::List);
1025 }
1026
1027 #[test]
1028 fn confirm_init_submodules_keys_init_or_skip() {
1029 use crate::tui::app::InitSubmodulesState;
1030 let state = InitSubmodulesState {
1031 dir: PathBuf::from("/wt/feature"),
1032 branch: "feature".into(),
1033 count: 2,
1034 };
1035 let mut a = app(&[("main", true)]);
1036 a.mode = Mode::ConfirmInitSubmodules(state.clone());
1038 assert_eq!(
1039 a.handle_event(press(KeyCode::Enter)),
1040 Effect::InitSubmodules {
1041 dir: PathBuf::from("/wt/feature"),
1042 count: 2,
1043 }
1044 );
1045 assert_eq!(a.mode, Mode::List);
1046 a.mode = Mode::ConfirmInitSubmodules(state.clone());
1048 assert_eq!(
1049 a.handle_event(press(KeyCode::Char('y'))),
1050 Effect::InitSubmodules {
1051 dir: PathBuf::from("/wt/feature"),
1052 count: 2,
1053 }
1054 );
1055 a.mode = Mode::ConfirmInitSubmodules(state.clone());
1057 assert_eq!(a.handle_event(press(KeyCode::Char('n'))), Effect::None);
1058 assert_eq!(a.mode, Mode::List);
1059 a.mode = Mode::ConfirmInitSubmodules(state.clone());
1061 assert_eq!(a.handle_event(press(KeyCode::Esc)), Effect::None);
1062 assert_eq!(a.mode, Mode::List);
1063 a.mode = Mode::ConfirmInitSubmodules(state);
1065 assert_eq!(a.handle_event(press(KeyCode::Char('x'))), Effect::None);
1066 assert!(matches!(a.mode, Mode::ConfirmInitSubmodules(_)));
1067 }
1068
1069 #[test]
1070 fn quit_returns_quit() {
1071 let mut a = app(&[("a", true)]);
1072 assert_eq!(a.handle_event(press(KeyCode::Char('q'))), Effect::Quit);
1073 assert!(a.quit);
1074 }
1075
1076 #[test]
1077 fn quit_with_jobs_blocks_the_exit() {
1078 use crate::tui::app::{ExitIntent, JobKey};
1081 let mut a = app(&[("a", true)]);
1082 a.begin_job(JobKey::New("feat".into()), "Creating feat");
1083 assert_eq!(a.handle_event(press(KeyCode::Char('q'))), Effect::None);
1084 assert!(matches!(
1085 a.mode,
1086 Mode::ExitBlocked(ref s) if s.intent == ExitIntent::Quit
1087 ));
1088 assert!(!a.quit);
1089 assert_eq!(a.handle_event(press(KeyCode::Char('y'))), Effect::Quit);
1091 assert!(a.quit);
1092 let mut b = app(&[("a", true)]);
1094 b.begin_job(JobKey::New("feat".into()), "Creating feat");
1095 b.handle_event(press(KeyCode::Char('q')));
1096 assert_eq!(b.handle_event(press(KeyCode::Char('q'))), Effect::Quit);
1097 assert!(b.quit);
1098 let mut c = app(&[("a", true)]);
1100 c.begin_job(JobKey::New("feat".into()), "Creating feat");
1101 c.handle_event(press(KeyCode::Char('q')));
1102 assert_eq!(c.handle_event(press(KeyCode::Char('n'))), Effect::None);
1103 assert_eq!(c.mode, Mode::List);
1104 assert!(!c.quit);
1105 assert!(c.any_jobs());
1106 }
1107
1108 #[test]
1109 fn switch_with_jobs_blocks_the_exit() {
1110 use crate::tui::app::{ExitIntent, JobKey};
1114 let mut a = app(&[("main", true), ("feat", false)]);
1115 a.selected = 1;
1116 a.begin_job(JobKey::New("other".into()), "Creating other");
1117 assert_eq!(a.handle_event(press(KeyCode::Enter)), Effect::None);
1118 assert!(matches!(
1119 a.mode,
1120 Mode::ExitBlocked(ref s)
1121 if s.intent == ExitIntent::Switch(std::path::PathBuf::from("/r/feat"))
1122 ));
1123 assert!(a.chosen.is_none()); assert_eq!(
1126 a.handle_event(press(KeyCode::Enter)),
1127 Effect::Switch(std::path::PathBuf::from("/r/feat"))
1128 );
1129 assert_eq!(a.chosen, Some(std::path::PathBuf::from("/r/feat")));
1130 }
1131
1132 #[test]
1133 fn blocked_exit_completes_when_jobs_drain() {
1134 use crate::tui::app::JobKey;
1137 let mut a = app(&[("main", true), ("feat", false)]);
1138 a.selected = 1;
1139 let key = JobKey::New("other".into());
1140 a.begin_job(key.clone(), "Creating other");
1141 a.handle_event(press(KeyCode::Enter));
1142 assert!(!a.exit_now()); a.finish_job(&key);
1144 assert!(a.exit_now()); assert_eq!(a.chosen, Some(std::path::PathBuf::from("/r/feat")));
1146 }
1147
1148 #[test]
1149 fn filter_mode_typing_and_escape() {
1150 let mut a = app(&[("alpha", true), ("beta", false)]);
1151 a.handle_event(press(KeyCode::Char('/')));
1152 assert_eq!(a.mode, Mode::Filter);
1153 a.handle_event(press(KeyCode::Char('a')));
1154 a.handle_event(press(KeyCode::Char('l')));
1155 assert_eq!(a.filter, "al");
1156 assert_eq!(a.visible.len(), 1); a.handle_event(press(KeyCode::Enter)); assert_eq!(a.mode, Mode::List);
1159 assert_eq!(a.filter, "al"); a.handle_event(press(KeyCode::Char('/')));
1161 a.handle_event(press(KeyCode::Esc)); assert_eq!(a.mode, Mode::List);
1163 assert_eq!(a.filter, "");
1164 }
1165
1166 #[test]
1167 fn create_mode_flow() {
1168 let mut a = app(&[("a", true)]);
1169 a.handle_event(press(KeyCode::Char('n')));
1170 assert!(matches!(a.mode, Mode::Create(_)));
1171 a.handle_event(press(KeyCode::Enter));
1173 if let Mode::Create(s) = &a.mode {
1174 assert!(s.error.is_some());
1175 } else {
1176 panic!("expected create mode");
1177 }
1178 for c in "feature/x".chars() {
1180 a.handle_event(press(KeyCode::Char(c)));
1181 }
1182 a.handle_event(press(KeyCode::Enter));
1183 if let Mode::Create(s) = &a.mode {
1184 assert_eq!(s.step, CreateStep::Base);
1185 assert_eq!(s.branch, "feature/x");
1186 }
1187 let effect = a.handle_event(press(KeyCode::Enter));
1189 assert_eq!(
1190 effect,
1191 Effect::Create {
1192 branch: "feature/x".into(),
1193 base: None,
1194 decision: None,
1195 }
1196 );
1197 }
1198
1199 #[test]
1200 fn create_mode_prefills_default_base() {
1201 let mut a = app(&[("main", true)]);
1204 a.branches = vec!["main".into(), "origin/main".into()];
1205 a.default_base = Some("origin/main".into());
1206 a.handle_event(press(KeyCode::Char('n')));
1207 if let Mode::Create(s) = &a.mode {
1208 assert_eq!(s.base, "origin/main");
1209 assert_eq!(s.step, CreateStep::Branch); } else {
1211 panic!("expected create mode");
1212 }
1213 }
1214
1215 #[test]
1216 fn create_mode_base_empty_without_default() {
1217 let mut a = app(&[("main", true)]);
1220 assert!(a.default_base.is_none());
1221 a.handle_event(press(KeyCode::Char('n')));
1222 for c in "feature/x".chars() {
1223 a.handle_event(press(KeyCode::Char(c)));
1224 }
1225 a.handle_event(press(KeyCode::Enter)); if let Mode::Create(s) = &a.mode {
1227 assert_eq!(s.base, "");
1228 } else {
1229 panic!("expected create mode");
1230 }
1231 assert_eq!(
1232 a.handle_event(press(KeyCode::Enter)),
1233 Effect::Create {
1234 branch: "feature/x".into(),
1235 base: None,
1236 decision: None,
1237 }
1238 );
1239 }
1240
1241 #[test]
1242 fn create_mode_rejects_invalid_branch_name() {
1243 let mut a = app(&[("a", true)]);
1244 a.handle_event(press(KeyCode::Char('n')));
1245 for c in "feat..x".chars() {
1246 a.handle_event(press(KeyCode::Char(c)));
1247 }
1248 a.handle_event(press(KeyCode::Enter));
1250 if let Mode::Create(s) = &a.mode {
1251 assert_eq!(s.step, CreateStep::Branch);
1252 assert!(s.error.as_deref().unwrap().contains("invalid branch name"));
1253 } else {
1254 panic!("expected create mode");
1255 }
1256 a.handle_event(press(KeyCode::Char('y')));
1258 if let Mode::Create(s) = &a.mode {
1259 assert!(s.error.is_none());
1260 }
1261 if let Mode::Create(s) = &mut a.mode {
1263 s.branch = "feature/x".into();
1264 }
1265 a.handle_event(press(KeyCode::Enter));
1266 if let Mode::Create(s) = &a.mode {
1267 assert_eq!(s.step, CreateStep::Base);
1268 } else {
1269 panic!("expected create mode");
1270 }
1271 }
1272
1273 #[test]
1274 fn create_mode_tab_completes_base_ref() {
1275 let mut a = app(&[("a", true)]);
1276 a.branches = vec!["feature/alpha".into(), "feature/beta".into(), "main".into()];
1277 a.handle_event(press(KeyCode::Char('n')));
1278 for c in "topic".chars() {
1279 a.handle_event(press(KeyCode::Char(c)));
1280 }
1281 a.handle_event(press(KeyCode::Enter)); for c in "feat".chars() {
1284 a.handle_event(press(KeyCode::Char(c)));
1285 }
1286 a.handle_event(press(KeyCode::Tab));
1287 if let Mode::Create(s) = &a.mode {
1288 assert_eq!(s.base, "feature/");
1289 } else {
1290 panic!("expected create mode");
1291 }
1292 a.handle_event(press(KeyCode::Char('a')));
1294 a.handle_event(press(KeyCode::Tab));
1295 if let Mode::Create(s) = &a.mode {
1296 assert_eq!(s.base, "feature/alpha");
1297 }
1298 }
1299
1300 #[test]
1301 fn create_mode_tab_noop_without_candidates() {
1302 let mut a = app(&[("a", true)]);
1303 a.handle_event(press(KeyCode::Char('n')));
1304 for c in "feature/x".chars() {
1305 a.handle_event(press(KeyCode::Char(c)));
1306 }
1307 a.handle_event(press(KeyCode::Enter)); for c in "xyz".chars() {
1309 a.handle_event(press(KeyCode::Char(c)));
1310 }
1311 a.handle_event(press(KeyCode::Tab)); if let Mode::Create(s) = &a.mode {
1313 assert_eq!(s.base, "xyz");
1314 }
1315 let mut b = app(&[("a", true)]);
1317 b.branches = vec!["main".into()];
1318 b.handle_event(press(KeyCode::Char('n')));
1319 b.handle_event(press(KeyCode::Tab));
1320 if let Mode::Create(s) = &b.mode {
1321 assert!(s.branch.is_empty());
1322 }
1323 }
1324
1325 #[test]
1326 fn longest_common_prefix_cases() {
1327 assert_eq!(longest_common_prefix(&[]), None);
1328 assert_eq!(longest_common_prefix(&["solo"]).as_deref(), Some("solo"));
1329 assert_eq!(
1330 longest_common_prefix(&["feature/a", "feature/b"]).as_deref(),
1331 Some("feature/")
1332 );
1333 assert_eq!(longest_common_prefix(&["abc", "xyz"]).as_deref(), Some(""));
1334 }
1335
1336 #[test]
1337 fn create_mode_escape_cancels() {
1338 let mut a = app(&[("a", true)]);
1339 a.handle_event(press(KeyCode::Char('n')));
1340 a.handle_event(press(KeyCode::Esc));
1341 assert_eq!(a.mode, Mode::List);
1342 }
1343
1344 #[test]
1345 fn create_mode_dropdown_filters_navigates_and_accepts() {
1346 let mut a = app(&[("a", true)]);
1347 a.branches = vec!["main".into(), "origin/main".into(), "origin/dev".into()];
1348 a.handle_event(press(KeyCode::Char('n')));
1349 for c in "feature/login".chars() {
1352 a.handle_event(press(KeyCode::Char(c)));
1353 }
1354 a.handle_event(press(KeyCode::Enter));
1355 if let Mode::Create(s) = &a.mode {
1356 assert_eq!(s.step, CreateStep::Base);
1357 assert!(s.options.is_open());
1359 } else {
1360 panic!("expected create mode");
1361 }
1362 for c in "origin".chars() {
1364 a.handle_event(press(KeyCode::Char(c)));
1365 }
1366 a.handle_event(press(KeyCode::Down));
1369 a.handle_event(press(KeyCode::Enter));
1370 if let Mode::Create(s) = &a.mode {
1371 assert_eq!(s.base, "origin/dev");
1372 assert!(!s.options.is_open()); }
1374 let effect = a.handle_event(press(KeyCode::Enter));
1376 assert_eq!(
1377 effect,
1378 Effect::Create {
1379 branch: "feature/login".into(),
1380 base: Some("origin/dev".into()),
1381 decision: None,
1382 }
1383 );
1384 }
1385
1386 #[test]
1387 fn create_mode_escape_closes_dropdown_before_modal() {
1388 let mut a = app(&[("a", true)]);
1389 a.branches = vec!["main".into()];
1390 a.handle_event(press(KeyCode::Char('n')));
1391 a.handle_event(press(KeyCode::Char('m'))); if let Mode::Create(s) = &a.mode {
1393 assert!(s.options.is_open());
1394 }
1395 a.handle_event(press(KeyCode::Esc)); if let Mode::Create(s) = &a.mode {
1397 assert!(!s.options.is_open());
1398 } else {
1399 panic!("expected create mode (still open)");
1400 }
1401 a.handle_event(press(KeyCode::Esc)); assert_eq!(a.mode, Mode::List);
1403 }
1404
1405 #[test]
1406 fn confirm_remove_y_removes() {
1407 let mut a = app(&[("main", true), ("feat", false)]);
1408 a.selected = 1;
1409 a.handle_event(press(KeyCode::Char('d')));
1410 assert!(matches!(a.mode, Mode::ConfirmRemove(_)));
1411 let effect = a.handle_event(press(KeyCode::Char('y')));
1412 assert!(matches!(effect, Effect::Remove { unlock: false, .. }));
1415 assert_eq!(a.mode, Mode::List);
1416 }
1417
1418 #[test]
1419 fn confirm_remove_other_key_cancels() {
1420 let mut a = app(&[("main", true), ("feat", false)]);
1421 a.selected = 1;
1422 a.handle_event(press(KeyCode::Char('d')));
1423 let effect = a.handle_event(press(KeyCode::Char('n')));
1424 assert_eq!(effect, Effect::None);
1425 assert_eq!(a.mode, Mode::List);
1426 }
1427
1428 #[test]
1429 fn confirm_remove_locked_y_overrides_the_lock() {
1430 let mut a = app(&[("main", true), ("feat", false)]);
1431 a.mode = Mode::ConfirmRemoveLocked {
1432 index: 1,
1433 reason: Some("in use".into()),
1434 };
1435 let effect = a.handle_event(press(KeyCode::Char('Y')));
1436 assert_eq!(
1437 effect,
1438 Effect::Remove {
1439 index: 1,
1440 unlock: true
1441 }
1442 );
1443 assert_eq!(a.mode, Mode::List);
1444 }
1445
1446 #[test]
1447 fn confirm_remove_locked_other_key_cancels() {
1448 let mut a = app(&[("main", true), ("feat", false)]);
1449 a.mode = Mode::ConfirmRemoveLocked {
1450 index: 1,
1451 reason: None,
1452 };
1453 let effect = a.handle_event(press(KeyCode::Esc));
1454 assert_eq!(effect, Effect::None);
1455 assert_eq!(a.mode, Mode::List);
1456 }
1457
1458 #[test]
1459 fn pr_picker_opens_and_fetches() {
1460 let mut a = app(&[("a", true)]);
1461 let effect = a.handle_event(press(KeyCode::Char('p')));
1462 assert_eq!(effect, Effect::FetchPrs);
1463 assert!(matches!(a.mode, Mode::PrPicker(_)));
1464 if let Mode::PrPicker(s) = &mut a.mode {
1466 s.loading = false;
1467 s.prs = vec![
1468 crate::tui::app::PrItem {
1469 number: 7,
1470 title: "x".into(),
1471 author: "a".into(),
1472 state: "open".into(),
1473 created_at: String::new(),
1474 },
1475 crate::tui::app::PrItem {
1476 number: 9,
1477 title: "y".into(),
1478 author: "b".into(),
1479 state: "open".into(),
1480 created_at: String::new(),
1481 },
1482 ];
1483 }
1484 a.handle_event(press(KeyCode::Down));
1485 let effect = a.handle_event(press(KeyCode::Enter));
1486 assert_eq!(effect, Effect::CheckoutPr(9));
1487 }
1488
1489 #[test]
1490 fn checkout_key_opens_picker_for_selected_worktree() {
1491 let mut a = app(&[("main", true), ("feature/x", false)]);
1492 a.branches = vec!["main".into(), "feature/x".into()];
1493 a.selected = 1; a.handle_event(press(KeyCode::Char('c')));
1495 if let Mode::Checkout(s) = &a.mode {
1496 assert_eq!(s.worktree_index, a.visible[1]);
1498 assert_eq!(s.options.match_count(), 2);
1499 assert!(s.options.is_open());
1501 } else {
1502 panic!("expected checkout mode");
1503 }
1504 }
1505
1506 #[test]
1507 fn checkout_picker_arrows_select_a_branch_without_typing() {
1508 let mut a = app(&[("main", true)]);
1511 a.branches = vec!["main".into(), "origin/feature/x".into()];
1512 a.handle_event(press(KeyCode::Char('c')));
1513 a.handle_event(press(KeyCode::Down)); let effect = a.handle_event(press(KeyCode::Enter));
1515 assert_eq!(
1516 effect,
1517 Effect::CheckoutBranch {
1518 worktree_index: 0,
1519 branch: "origin/feature/x".into(),
1520 }
1521 );
1522 }
1523
1524 #[test]
1525 fn checkout_picker_submits_typed_branch() {
1526 let mut a = app(&[("main", true)]);
1527 a.branches = vec!["main".into(), "feature/x".into()];
1528 a.handle_event(press(KeyCode::Char('c')));
1529 for ch in "feature/x".chars() {
1530 a.handle_event(press(KeyCode::Char(ch)));
1531 }
1532 let effect = a.handle_event(press(KeyCode::Enter));
1534 assert_eq!(
1535 effect,
1536 Effect::CheckoutBranch {
1537 worktree_index: 0,
1538 branch: "feature/x".into(),
1539 }
1540 );
1541 }
1542
1543 #[test]
1544 fn checkout_picker_submits_highlighted_suggestion() {
1545 let mut a = app(&[("main", true)]);
1546 a.branches = vec!["main".into(), "feature/x".into(), "feature/y".into()];
1547 a.handle_event(press(KeyCode::Char('c')));
1548 for ch in "feature".chars() {
1549 a.handle_event(press(KeyCode::Char(ch)));
1550 }
1551 a.handle_event(press(KeyCode::Down));
1553 let effect = a.handle_event(press(KeyCode::Enter));
1554 assert_eq!(
1555 effect,
1556 Effect::CheckoutBranch {
1557 worktree_index: 0,
1558 branch: "feature/y".into(),
1559 }
1560 );
1561 }
1562
1563 #[test]
1564 fn checkout_picker_empty_query_errors() {
1565 let mut a = app(&[("main", true)]);
1566 a.handle_event(press(KeyCode::Char('c')));
1567 let effect = a.handle_event(press(KeyCode::Enter));
1568 assert_eq!(effect, Effect::None);
1569 if let Mode::Checkout(s) = &a.mode {
1570 assert!(s.error.is_some());
1571 } else {
1572 panic!("expected checkout mode (still open)");
1573 }
1574 }
1575
1576 #[test]
1577 fn checkout_picker_escape_closes_dropdown_then_cancels() {
1578 let mut a = app(&[("main", true)]);
1579 a.branches = vec!["main".into()];
1580 a.handle_event(press(KeyCode::Char('c')));
1581 a.handle_event(press(KeyCode::Char('m'))); if let Mode::Checkout(s) = &a.mode {
1583 assert!(s.options.is_open());
1584 }
1585 a.handle_event(press(KeyCode::Esc)); if let Mode::Checkout(s) = &a.mode {
1587 assert!(!s.options.is_open());
1588 } else {
1589 panic!("expected checkout mode (still open)");
1590 }
1591 a.handle_event(press(KeyCode::Esc)); assert_eq!(a.mode, Mode::List);
1593 }
1594
1595 #[test]
1596 fn compose_typing_field_switch_and_newline() {
1597 use crate::tui::app::PrComposeState;
1598 let mut a = app(&[("a", true)]);
1599 a.mode = Mode::PrCompose(PrComposeState::default());
1600 a.handle_event(press(KeyCode::Char('h')));
1601 a.handle_event(press(KeyCode::Char('i')));
1602 if let Mode::PrCompose(s) = &a.mode {
1603 assert_eq!(s.title, "hi");
1604 assert_eq!(s.field, ComposeField::Title);
1605 } else {
1606 panic!("expected compose mode");
1607 }
1608 a.handle_event(press(KeyCode::Enter));
1610 if let Mode::PrCompose(s) = &a.mode {
1611 assert_eq!(s.field, ComposeField::Body);
1612 }
1613 a.handle_event(press(KeyCode::Char('x')));
1615 a.handle_event(press(KeyCode::Enter));
1616 a.handle_event(press(KeyCode::Char('y')));
1617 if let Mode::PrCompose(s) = &a.mode {
1618 assert_eq!(s.body, "x\ny");
1619 }
1620 a.handle_event(press(KeyCode::BackTab));
1622 a.handle_event(press(KeyCode::Backspace));
1623 if let Mode::PrCompose(s) = &a.mode {
1624 assert_eq!(s.field, ComposeField::Title);
1625 assert_eq!(s.title, "h");
1626 }
1627 }
1628
1629 #[test]
1630 fn compose_tab_cycles_all_four_fields() {
1631 use crate::tui::app::PrComposeState;
1632 let mut a = app(&[("a", true)]);
1633 a.mode = Mode::PrCompose(PrComposeState::default());
1634 let field = |a: &App| {
1635 if let Mode::PrCompose(s) = &a.mode {
1636 s.field
1637 } else {
1638 panic!("expected compose mode")
1639 }
1640 };
1641 assert_eq!(field(&a), ComposeField::Title);
1642 a.handle_event(press(KeyCode::Tab));
1643 assert_eq!(field(&a), ComposeField::Body);
1644 a.handle_event(press(KeyCode::Tab));
1645 assert_eq!(field(&a), ComposeField::Model);
1646 a.handle_event(press(KeyCode::Tab));
1647 assert_eq!(field(&a), ComposeField::Effort);
1648 a.handle_event(press(KeyCode::Tab));
1649 assert_eq!(field(&a), ComposeField::Title); }
1651
1652 #[test]
1653 fn compose_model_effort_fields_pick_with_arrows() {
1654 use crate::agent::{AgentModel, Effort};
1655 use crate::tui::app::PrComposeState;
1656 let mut a = app(&[("a", true)]);
1657 a.mode = Mode::PrCompose(PrComposeState::default());
1658 a.handle_event(press(KeyCode::Tab));
1660 a.handle_event(press(KeyCode::Tab));
1661 a.handle_event(press(KeyCode::Down));
1663 a.handle_event(press(KeyCode::Up));
1664 a.handle_event(press(KeyCode::Char('z')));
1666 if let Mode::PrCompose(s) = &a.mode {
1667 assert_eq!(s.field, ComposeField::Model);
1668 assert_eq!(s.model, AgentModel::Sonnet);
1669 assert_eq!(s.title, "");
1670 } else {
1671 panic!("expected compose mode");
1672 }
1673 a.handle_event(press(KeyCode::Tab));
1675 a.handle_event(press(KeyCode::Down));
1676 if let Mode::PrCompose(s) = &a.mode {
1677 assert_eq!(s.field, ComposeField::Effort);
1678 assert_eq!(s.effort, Effort::Medium.next());
1679 }
1680 }
1681
1682 #[test]
1683 fn compose_ctrl_s_requires_title_and_is_not_typed() {
1684 use crate::tui::app::PrComposeState;
1685 let mut a = app(&[("a", true)]);
1686 a.mode = Mode::PrCompose(PrComposeState::default());
1687 let effect = a.handle_event(ctrl('s'));
1688 assert_eq!(effect, Effect::None);
1689 if let Mode::PrCompose(s) = &a.mode {
1690 assert!(s.error.is_some());
1691 assert_eq!(s.title, "");
1693 } else {
1694 panic!("expected compose mode");
1695 }
1696 }
1697
1698 #[test]
1699 fn compose_ctrl_s_submits_when_title_present() {
1700 use crate::tui::app::PrComposeState;
1701 let mut a = app(&[("a", true)]);
1702 a.mode = Mode::PrCompose(PrComposeState {
1703 title: "T".into(),
1704 body: "B".into(),
1705 ..Default::default()
1706 });
1707 let effect = a.handle_event(ctrl('s'));
1708 assert_eq!(
1709 effect,
1710 Effect::SubmitPr {
1711 title: "T".into(),
1712 body: "B".into(),
1713 draft: false
1714 }
1715 );
1716 if let Mode::PrCompose(s) = &a.mode {
1717 assert!(s.submitting);
1718 }
1719 }
1720
1721 #[test]
1722 fn compose_ctrl_d_toggles_draft_and_esc_cancels() {
1723 use crate::tui::app::PrComposeState;
1724 let mut a = app(&[("a", true)]);
1725 a.mode = Mode::PrCompose(PrComposeState::default());
1726 a.handle_event(ctrl('d'));
1727 if let Mode::PrCompose(s) = &a.mode {
1728 assert!(s.draft);
1729 }
1730 a.handle_event(press(KeyCode::Esc));
1731 assert_eq!(a.mode, Mode::List);
1732 }
1733
1734 #[test]
1735 fn compose_ctrl_a_triggers_ai_fill() {
1736 use crate::tui::app::PrComposeState;
1737 let mut a = app(&[("a", true)]);
1738 a.mode = Mode::PrCompose(PrComposeState::default());
1739 let effect = a.handle_event(ctrl('a'));
1740 assert_eq!(effect, Effect::DraftPrAi);
1741 if let Mode::PrCompose(s) = &a.mode {
1742 assert!(s.submitting);
1744 assert_eq!(s.title, "");
1745 } else {
1746 panic!("expected compose mode");
1747 }
1748 }
1749
1750 #[test]
1751 fn compose_ctrl_m_and_e_cycle_model_and_effort() {
1752 use crate::agent::{AgentModel, Effort};
1753 use crate::tui::app::PrComposeState;
1754 let mut a = app(&[("a", true)]);
1755 a.mode = Mode::PrCompose(PrComposeState::default());
1756 a.handle_event(ctrl('m'));
1758 a.handle_event(ctrl('e'));
1759 if let Mode::PrCompose(s) = &a.mode {
1760 assert_eq!(s.model, AgentModel::Sonnet.next());
1761 assert_eq!(s.effort, Effort::Medium.next());
1762 assert_eq!(s.title, "");
1763 } else {
1764 panic!("expected compose mode");
1765 }
1766 }
1767
1768 #[test]
1769 fn help_dismisses_on_any_key() {
1770 let mut a = app(&[("a", true)]);
1771 a.handle_event(press(KeyCode::Char('?')));
1772 assert_eq!(a.mode, Mode::Help);
1773 a.handle_event(press(KeyCode::Char('x')));
1774 assert_eq!(a.mode, Mode::List);
1775 }
1776
1777 #[test]
1778 fn sort_and_sidebar_keys() {
1779 let mut a = app(&[("a", true)]);
1780 a.handle_event(press(KeyCode::Char('s')));
1781 assert_eq!(a.sort.key, crate::model::SortKey::Dirty);
1782 a.handle_event(press(KeyCode::Char('S')));
1783 assert!(a.sort.descending);
1784 let w0 = a.sidebar_width;
1785 a.handle_event(press(KeyCode::Char('+')));
1786 assert_eq!(a.sidebar_width, w0 + 1);
1787 a.handle_event(press(KeyCode::Char('-')));
1788 assert_eq!(a.sidebar_width, w0);
1789 a.handle_event(press(KeyCode::Char('\\')));
1790 assert!(!a.show_sidebar);
1791 }
1792
1793 #[test]
1794 fn resize_too_small_exits() {
1795 let mut a = app(&[("a", true)]);
1796 assert_eq!(a.handle_event(Event::Resize(100, 4)), Effect::TooSmall);
1797 assert_eq!(a.handle_event(Event::Resize(100, 20)), Effect::None);
1798 assert_eq!(a.size, (100, 20));
1799 }
1800
1801 #[test]
1802 fn open_editor_and_refresh() {
1803 let mut a = app(&[("a", true)]);
1804 assert_eq!(
1805 a.handle_event(press(KeyCode::Char('o'))),
1806 Effect::OpenEditor(std::path::PathBuf::from("/r/a"))
1807 );
1808 assert_eq!(a.handle_event(press(KeyCode::Char('r'))), Effect::Refresh);
1809 }
1810
1811 #[test]
1812 fn mouse_click_selects_and_wheel_scrolls() {
1813 let mut a = app(&[("a", true), ("b", false), ("c", false)]);
1814 let click = Event::Mouse(MouseEvent {
1816 kind: MouseEventKind::Down(MouseButton::Left),
1817 column: 5,
1818 row: 3,
1819 modifiers: KeyModifiers::empty(),
1820 });
1821 a.handle_event(click);
1822 assert_eq!(a.selected, 2);
1823 a.handle_event(Event::Mouse(MouseEvent {
1824 kind: MouseEventKind::ScrollUp,
1825 column: 5,
1826 row: 3,
1827 modifiers: KeyModifiers::empty(),
1828 }));
1829 assert_eq!(a.selected, 1);
1830 }
1831
1832 #[test]
1833 fn mouse_ignored_when_disabled() {
1834 let mut a = app(&[("a", true), ("b", false)]);
1835 a.mouse = false;
1836 a.selected = 0;
1837 a.handle_event(Event::Mouse(MouseEvent {
1838 kind: MouseEventKind::ScrollDown,
1839 column: 5,
1840 row: 3,
1841 modifiers: KeyModifiers::empty(),
1842 }));
1843 assert_eq!(a.selected, 0);
1844 }
1845
1846 #[test]
1847 fn mouse_in_modal_does_not_touch_background() {
1848 let mut a = app(&[("a", true), ("b", false), ("c", false)]);
1851 a.selected = 1;
1852 a.mode = Mode::Create(CreateState::default());
1853 let click = Event::Mouse(MouseEvent {
1854 kind: MouseEventKind::Down(MouseButton::Left),
1855 column: 5,
1856 row: 3,
1857 modifiers: KeyModifiers::empty(),
1858 });
1859 assert_eq!(a.handle_event(click), Effect::None);
1860 assert_eq!(a.selected, 1);
1861 assert!(matches!(a.mode, Mode::Create(_)));
1862 a.handle_event(Event::Mouse(MouseEvent {
1863 kind: MouseEventKind::ScrollDown,
1864 column: 5,
1865 row: 3,
1866 modifiers: KeyModifiers::empty(),
1867 }));
1868 assert_eq!(a.selected, 1); }
1870
1871 #[test]
1872 fn mouse_scroll_moves_create_dropdown() {
1873 let mut a = app(&[("a", true)]);
1876 let mut options = crate::tui::OptionList::new(vec![
1877 "main".into(),
1878 "origin/main".into(),
1879 "origin/dev".into(),
1880 ]);
1881 options.open();
1882 a.mode = Mode::Create(CreateState {
1883 options,
1884 ..Default::default()
1885 });
1886 let wheel = |kind| {
1887 Event::Mouse(MouseEvent {
1888 kind,
1889 column: 5,
1890 row: 5,
1891 modifiers: KeyModifiers::empty(),
1892 })
1893 };
1894 a.handle_event(wheel(MouseEventKind::ScrollDown));
1895 if let Mode::Create(s) = &a.mode {
1896 assert_eq!(s.options.selected(), Some("origin/main"));
1898 } else {
1899 panic!("expected create mode");
1900 }
1901 a.handle_event(wheel(MouseEventKind::ScrollUp));
1902 if let Mode::Create(s) = &a.mode {
1903 assert_eq!(s.options.selected(), Some("main"));
1904 }
1905 }
1906
1907 #[test]
1908 fn mouse_scroll_moves_pr_picker_selection() {
1909 use crate::tui::app::{PrItem, PrPickerState};
1910 let pr = |number| PrItem {
1911 number,
1912 title: "t".into(),
1913 author: "a".into(),
1914 state: "open".into(),
1915 created_at: String::new(),
1916 };
1917 let mut a = app(&[("a", true)]);
1918 a.mode = Mode::PrPicker(PrPickerState {
1919 loading: false,
1920 prs: vec![pr(1), pr(2)],
1921 ..Default::default()
1922 });
1923 let wheel = |kind| {
1924 Event::Mouse(MouseEvent {
1925 kind,
1926 column: 5,
1927 row: 5,
1928 modifiers: KeyModifiers::empty(),
1929 })
1930 };
1931 a.handle_event(wheel(MouseEventKind::ScrollDown));
1932 if let Mode::PrPicker(s) = &a.mode {
1933 assert_eq!(s.selected, 1);
1934 } else {
1935 panic!("expected pr picker");
1936 }
1937 a.handle_event(wheel(MouseEventKind::ScrollDown));
1939 if let Mode::PrPicker(s) = &a.mode {
1940 assert_eq!(s.selected, 1);
1941 }
1942 a.handle_event(wheel(MouseEventKind::ScrollUp));
1943 if let Mode::PrPicker(s) = &a.mode {
1944 assert_eq!(s.selected, 0);
1945 }
1946 }
1947
1948 #[test]
1949 fn tab_toggles_focus() {
1950 let mut a = app(&[("a", true)]);
1951 assert_eq!(a.focus, Pane::List);
1952 a.handle_event(press(KeyCode::Tab));
1953 assert_eq!(a.focus, Pane::Detail);
1954 }
1955
1956 #[test]
1957 fn navigation_scrolls_detail_when_focused() {
1958 let mut a = app(&[("a", true), ("b", false)]);
1959 a.worktrees[0].recent_commits = vec![crate::model::Commit {
1960 hash: "h".into(),
1961 subject: "s".into(),
1962 author: "x".into(),
1963 timestamp: "2024-01-15T10:30:00Z".into(),
1964 }];
1965 a.handle_event(press(KeyCode::Tab)); a.handle_event(press(KeyCode::Char('j'))); assert_eq!(a.detail_scroll, 1);
1968 assert_eq!(a.selected, 0);
1969 a.handle_event(press(KeyCode::Char('k')));
1970 assert_eq!(a.detail_scroll, 0);
1971 a.handle_event(press(KeyCode::Tab));
1973 a.detail_scroll = 3;
1974 a.handle_event(press(KeyCode::Char('j')));
1975 assert_eq!(a.selected, 1);
1976 assert_eq!(a.detail_scroll, 0);
1977 }
1978
1979 #[test]
1980 fn mouse_click_on_status_bar_and_title_row_select_nothing() {
1981 let mut a = app(&[("a", true), ("b", false), ("c", false)]);
1982 a.size = (100, 30);
1983 a.selected = 1;
1984 let click = |row: u16| {
1986 Event::Mouse(MouseEvent {
1987 kind: MouseEventKind::Down(MouseButton::Left),
1988 column: 5,
1989 row,
1990 modifiers: KeyModifiers::empty(),
1991 })
1992 };
1993 a.handle_event(click(29));
1994 assert_eq!(a.selected, 1);
1995 a.handle_event(click(0));
1997 assert_eq!(a.selected, 1);
1998 }
1999}