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(usize),
58 DeleteBranch {
61 branch: String,
63 force: bool,
65 },
66 MaterializeBranch {
70 branch: String,
72 },
73 FetchPrs,
75 CheckoutPr(u64),
77 CheckoutBranch {
79 worktree_index: usize,
81 branch: String,
83 },
84 Sync {
86 worktree_index: usize,
88 },
89 InitSubmodules {
91 dir: PathBuf,
93 count: usize,
95 },
96 OpenEditor(PathBuf),
98 Refresh,
100 DraftPrAi,
102 SubmitPr {
104 title: String,
106 body: String,
108 draft: bool,
110 },
111}
112
113impl CreateState {
114 fn field_mut(&mut self) -> &mut String {
116 match self.step {
117 CreateStep::Branch => &mut self.branch,
118 CreateStep::Base => &mut self.base,
119 }
120 }
121
122 fn field_value(&self) -> &str {
124 match self.step {
125 CreateStep::Branch => &self.branch,
126 CreateStep::Base => &self.base,
127 }
128 }
129
130 fn refresh_options(&mut self) {
132 let query = self.field_value().to_owned();
133 self.options.refilter(&query);
134 self.options.open();
135 }
136}
137
138fn complete_base_ref(state: &mut CreateState, branches: &[String]) {
143 let matches: Vec<&str> = branches
144 .iter()
145 .map(String::as_str)
146 .filter(|b| b.starts_with(&state.base))
147 .collect();
148 if let Some(common) = longest_common_prefix(&matches)
149 && common.len() > state.base.len()
150 {
151 state.base = common;
152 }
153}
154
155fn longest_common_prefix(items: &[&str]) -> Option<String> {
158 let (first, rest) = items.split_first()?;
159 let mut prefix: &str = first;
160 for item in rest {
161 let shared = prefix
164 .char_indices()
165 .zip(item.chars())
166 .take_while(|&((_, a), b)| a == b)
167 .map(|((i, a), _)| i + a.len_utf8())
168 .last()
169 .unwrap_or(0);
170 prefix = &prefix[..shared];
171 if prefix.is_empty() {
172 break;
173 }
174 }
175 Some(prefix.to_string())
176}
177
178fn compose_next_field(field: ComposeField) -> ComposeField {
180 match field {
181 ComposeField::Title => ComposeField::Body,
182 ComposeField::Body => ComposeField::Model,
183 ComposeField::Model => ComposeField::Effort,
184 ComposeField::Effort => ComposeField::Title,
185 }
186}
187
188fn compose_prev_field(field: ComposeField) -> ComposeField {
191 match field {
192 ComposeField::Title => ComposeField::Effort,
193 ComposeField::Effort => ComposeField::Model,
194 ComposeField::Model => ComposeField::Body,
195 ComposeField::Body => ComposeField::Title,
196 }
197}
198
199impl App {
200 pub fn handle_event(&mut self, event: Event) -> Effect {
202 match event {
203 Event::Resize(cols, rows) => self.on_resize(cols, rows),
204 Event::Key(key) if key.kind != KeyEventKind::Release => self.on_key(key),
205 Event::Mouse(mouse) if self.mouse => self.on_mouse(mouse),
206 _ => Effect::None,
207 }
208 }
209
210 fn on_resize(&mut self, cols: u16, rows: u16) -> Effect {
212 self.size = (cols, rows);
213 if rows < MIN_HEIGHT {
214 Effect::TooSmall
215 } else {
216 Effect::None
217 }
218 }
219
220 fn on_key(&mut self, key: KeyEvent) -> Effect {
222 match &self.mode {
223 Mode::List => self.key_list(key),
224 Mode::Filter => self.key_filter(key),
225 Mode::Create(_) => self.key_create(key),
226 Mode::PrPicker(_) => self.key_pr(key),
227 Mode::PrCompose(_) => self.key_compose(key),
228 Mode::Checkout(_) => self.key_checkout_picker(key),
229 Mode::ConfirmRemove(_) => self.key_confirm(key),
230 Mode::ConfirmCreate(_) => self.key_confirm_create(key),
231 Mode::ConfirmDeleteBranch { .. } => self.key_confirm_delete_branch(key),
232 Mode::ConfirmStaleBase(_) => self.key_confirm_stale_base(key),
233 Mode::ConfirmInitSubmodules(_) => self.key_confirm_init_submodules(key),
234 Mode::ExitBlocked(_) => self.key_exit_blocked(key),
235 Mode::Help => {
236 self.mode = Mode::List;
237 Effect::None
238 }
239 }
240 }
241
242 fn key_list(&mut self, key: KeyEvent) -> Effect {
244 let Some(action) = self.keymap.action_for(KeyChord::from_event(key)) else {
245 return Effect::None;
246 };
247 let page = (self.size.1 as isize - 3).max(1);
248 match action {
249 KeyAction::NavigateUp => self.nav_or_scroll(-1),
250 KeyAction::NavigateDown => self.nav_or_scroll(1),
251 KeyAction::PageUp => self.nav_or_scroll(-page),
252 KeyAction::PageDown => self.nav_or_scroll(page),
253 KeyAction::GoToTop => self.select_edge(false),
254 KeyAction::GoToBottom => self.select_edge(true),
255 KeyAction::FocusNextPane | KeyAction::FocusPrevPane => self.toggle_focus(),
256 KeyAction::Switch => {
257 if let Some(&index) = self.visible.get(self.selected) {
258 let wt = &self.worktrees[index];
259 if wt.has_worktree {
260 let path = wt.path.clone();
265 return self.request_exit(ExitIntent::Switch(path));
266 }
267 self.mode = Mode::ConfirmCreate(index);
270 }
271 }
272 KeyAction::Filter => self.mode = Mode::Filter,
273 KeyAction::ClearFilter => self.clear_filter(),
274 KeyAction::New => {
275 let options = crate::tui::OptionList::new(self.branches.clone());
278 self.mode = Mode::Create(CreateState {
282 base: self.default_base.clone().unwrap_or_default(),
283 options,
284 ..Default::default()
285 });
286 }
287 KeyAction::Remove => {
288 if let Some(&index) = self.visible.get(self.selected) {
292 self.mode = if self.worktrees[index].has_worktree {
293 Mode::ConfirmRemove(index)
294 } else {
295 Mode::ConfirmDeleteBranch {
296 index,
297 force: false,
298 }
299 };
300 }
301 }
302 KeyAction::PrCheckout => {
303 self.mode = Mode::PrPicker(crate::tui::app::PrPickerState {
304 loading: true,
305 ..Default::default()
306 });
307 return Effect::FetchPrs;
308 }
309 KeyAction::Checkout => {
310 if let Some(&index) = self.visible.get(self.selected)
315 && self.worktrees[index].has_worktree
316 {
317 let mut options = crate::tui::OptionList::new(self.branches.clone());
321 options.open();
322 self.mode = Mode::Checkout(crate::tui::app::CheckoutState {
323 worktree_index: index,
324 options,
325 ..Default::default()
326 });
327 }
328 }
329 KeyAction::Sync => {
330 if let Some(&index) = self.visible.get(self.selected) {
335 return Effect::Sync {
336 worktree_index: index,
337 };
338 }
339 }
340 KeyAction::OpenEditor => {
341 if let Some(wt) = self.selected_worktree()
343 && wt.has_worktree
344 {
345 return Effect::OpenEditor(wt.path.clone());
346 }
347 }
348 KeyAction::Refresh => return Effect::Refresh,
349 KeyAction::SortCycle => self.cycle_sort(),
350 KeyAction::SortReverse => self.reverse_sort(),
351 KeyAction::Help => self.mode = Mode::Help,
352 KeyAction::Quit => {
353 return self.request_exit(ExitIntent::Quit);
358 }
359 KeyAction::ToggleSidebar => self.show_sidebar = !self.show_sidebar,
360 KeyAction::ResizeSidebarGrow => {
361 self.sidebar_width = (self.sidebar_width + 1).min(MAX_SIDEBAR);
362 }
363 KeyAction::ResizeSidebarShrink => {
364 self.sidebar_width = self.sidebar_width.saturating_sub(1).max(MIN_SIDEBAR);
365 }
366 }
367 Effect::None
368 }
369
370 fn key_filter(&mut self, key: KeyEvent) -> Effect {
372 match key.code {
373 KeyCode::Char(c) => self.filter_push(c),
374 KeyCode::Backspace => self.filter_pop(),
375 KeyCode::Enter => self.mode = Mode::List, KeyCode::Esc => {
377 self.clear_filter();
378 self.mode = Mode::List;
379 }
380 KeyCode::Up => self.move_selection(-1),
381 KeyCode::Down => self.move_selection(1),
382 _ => {}
383 }
384 Effect::None
385 }
386
387 fn key_create(&mut self, key: KeyEvent) -> Effect {
392 let Mode::Create(state) = &mut self.mode else {
393 return Effect::None;
394 };
395 match key.code {
396 KeyCode::Char(c) => {
397 state.field_mut().push(c);
398 state.error = None;
399 state.refresh_options();
400 }
401 KeyCode::Backspace => {
402 state.field_mut().pop();
403 state.refresh_options();
404 }
405 KeyCode::Up => state.options.up(),
406 KeyCode::Down => state.options.down(),
407 KeyCode::Tab => {
408 if state.step == CreateStep::Base {
409 complete_base_ref(state, &self.branches);
410 state.refresh_options();
411 }
412 }
413 KeyCode::Esc => {
414 if state.options.is_open() {
415 state.options.close();
416 } else {
417 self.mode = Mode::List;
418 }
419 }
420 KeyCode::Enter => {
421 if let Some(selected) = state.options.selected().map(str::to_owned) {
424 *state.field_mut() = selected;
425 state.options.close();
426 } else {
427 match state.step {
428 CreateStep::Branch => {
429 let branch = state.branch.trim();
430 if branch.is_empty() {
431 state.error = Some("branch name is required".into());
432 } else if let Err(msg) = crate::git::validate_branch_name(branch) {
433 state.error = Some(msg);
434 } else {
435 state.step = CreateStep::Base;
436 state.refresh_options();
438 }
439 }
440 CreateStep::Base => {
441 let branch = state.branch.clone();
442 let base = (!state.base.trim().is_empty()).then(|| state.base.clone());
443 return Effect::Create {
446 branch,
447 base,
448 decision: None,
449 };
450 }
451 }
452 }
453 }
454 _ => {}
455 }
456 Effect::None
457 }
458
459 fn key_pr(&mut self, key: KeyEvent) -> Effect {
461 let Mode::PrPicker(state) = &mut self.mode else {
462 return Effect::None;
463 };
464 match key.code {
465 KeyCode::Up => state.selected = state.selected.saturating_sub(1),
466 KeyCode::Down => {
467 state.selected = (state.selected + 1).min(state.prs.len().saturating_sub(1));
468 }
469 KeyCode::Enter => {
470 if let Some(pr) = state.prs.get(state.selected) {
471 return Effect::CheckoutPr(pr.number);
472 }
473 }
474 KeyCode::Esc => self.mode = Mode::List,
475 _ => {}
476 }
477 Effect::None
478 }
479
480 fn key_compose(&mut self, key: KeyEvent) -> Effect {
487 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
488 let Mode::PrCompose(state) = &mut self.mode else {
489 return Effect::None;
490 };
491 if state.submitting {
493 return Effect::None;
494 }
495 match key.code {
496 KeyCode::Char('s') if ctrl => {
497 if state.title.trim().is_empty() {
498 state.error = Some("a PR title is required".into());
499 } else {
500 state.submitting = true;
501 return Effect::SubmitPr {
502 title: state.title.clone(),
503 body: state.body.clone(),
504 draft: state.draft,
505 };
506 }
507 }
508 KeyCode::Char('a') if ctrl => {
510 state.submitting = true;
511 state.error = None;
512 return Effect::DraftPrAi;
513 }
514 KeyCode::Char('m') if ctrl => state.model = state.model.next(),
516 KeyCode::Char('e') if ctrl => state.effort = state.effort.next(),
517 KeyCode::Char('d') if ctrl => state.draft = !state.draft,
518 KeyCode::Char(c) if !ctrl => {
520 match state.field {
521 ComposeField::Title => state.title.push(c),
522 ComposeField::Body => state.body.push(c),
523 ComposeField::Model | ComposeField::Effort => {}
524 }
525 state.error = None;
526 }
527 KeyCode::Backspace => {
528 match state.field {
529 ComposeField::Title => state.title.pop(),
530 ComposeField::Body => state.body.pop(),
531 ComposeField::Model | ComposeField::Effort => None,
532 };
533 state.error = None;
534 }
535 KeyCode::Up => match state.field {
537 ComposeField::Model => state.model = state.model.prev(),
538 ComposeField::Effort => state.effort = state.effort.prev(),
539 _ => {}
540 },
541 KeyCode::Down => match state.field {
542 ComposeField::Model => state.model = state.model.next(),
543 ComposeField::Effort => state.effort = state.effort.next(),
544 _ => {}
545 },
546 KeyCode::Tab => state.field = compose_next_field(state.field),
547 KeyCode::BackTab => state.field = compose_prev_field(state.field),
548 KeyCode::Enter => match state.field {
549 ComposeField::Title => state.field = ComposeField::Body,
550 ComposeField::Body => state.body.push('\n'),
551 ComposeField::Model => state.field = ComposeField::Effort,
553 ComposeField::Effort => state.field = ComposeField::Title,
554 },
555 KeyCode::Esc => self.mode = Mode::List,
556 _ => {}
557 }
558 Effect::None
559 }
560
561 fn key_checkout_picker(&mut self, key: KeyEvent) -> Effect {
566 let Mode::Checkout(state) = &mut self.mode else {
567 return Effect::None;
568 };
569 if state.submitting {
571 return Effect::None;
572 }
573 match key.code {
574 KeyCode::Char(c) => {
575 state.query.push(c);
576 state.error = None;
577 state.options.refilter(&state.query);
578 state.options.open();
579 }
580 KeyCode::Backspace => {
581 state.query.pop();
582 state.error = None;
583 state.options.refilter(&state.query);
584 state.options.open();
585 }
586 KeyCode::Up => state.options.up(),
587 KeyCode::Down => state.options.down(),
588 KeyCode::Esc => {
589 if state.options.is_open() {
590 state.options.close();
591 } else {
592 self.mode = Mode::List;
593 }
594 }
595 KeyCode::Enter => {
596 let branch = state
599 .options
600 .selected()
601 .map(str::to_owned)
602 .unwrap_or_else(|| state.query.trim().to_string());
603 if branch.is_empty() {
604 state.error = Some("branch name is required".into());
605 } else {
606 let worktree_index = state.worktree_index;
607 return Effect::CheckoutBranch {
608 worktree_index,
609 branch,
610 };
611 }
612 }
613 _ => {}
614 }
615 Effect::None
616 }
617
618 fn key_confirm(&mut self, key: KeyEvent) -> Effect {
620 let Mode::ConfirmRemove(index) = self.mode else {
621 return Effect::None;
622 };
623 if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
624 self.mode = Mode::List;
625 Effect::Remove(index)
626 } else {
627 self.mode = Mode::List;
628 Effect::None
629 }
630 }
631
632 fn key_confirm_create(&mut self, key: KeyEvent) -> Effect {
635 let Mode::ConfirmCreate(index) = self.mode else {
636 return Effect::None;
637 };
638 self.mode = Mode::List;
639 if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y'))
640 && let Some(branch) = self.worktrees.get(index).and_then(|w| w.branch.clone())
641 {
642 return Effect::MaterializeBranch { branch };
643 }
644 Effect::None
645 }
646
647 fn key_confirm_delete_branch(&mut self, key: KeyEvent) -> Effect {
652 let Mode::ConfirmDeleteBranch { index, force } = self.mode else {
653 return Effect::None;
654 };
655 self.mode = Mode::List;
656 if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y'))
657 && let Some(branch) = self.worktrees.get(index).and_then(|w| w.branch.clone())
658 {
659 return Effect::DeleteBranch { branch, force };
660 }
661 Effect::None
662 }
663
664 fn key_confirm_stale_base(&mut self, key: KeyEvent) -> Effect {
668 let Mode::ConfirmStaleBase(state) = &self.mode else {
669 return Effect::None;
670 };
671 let branch = state.branch.clone();
672 let base = state.base.clone();
673 self.mode = Mode::List;
674 let decision = match key.code {
675 KeyCode::Char('u') | KeyCode::Char('U') => CreateDecision::Update,
676 KeyCode::Char('p') | KeyCode::Char('P') => CreateDecision::Proceed,
677 _ => return Effect::None,
678 };
679 Effect::Create {
680 branch,
681 base,
682 decision: Some(decision),
683 }
684 }
685
686 fn key_confirm_init_submodules(&mut self, key: KeyEvent) -> Effect {
690 let Mode::ConfirmInitSubmodules(state) = &self.mode else {
691 return Effect::None;
692 };
693 match key.code {
694 KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => {
695 let dir = state.dir.clone();
696 let count = state.count;
697 self.mode = Mode::List;
698 Effect::InitSubmodules { dir, count }
699 }
700 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
701 self.mode = Mode::List;
702 Effect::None
703 }
704 _ => Effect::None,
705 }
706 }
707
708 fn key_exit_blocked(&mut self, key: KeyEvent) -> Effect {
714 let Mode::ExitBlocked(state) = &self.mode else {
715 return Effect::None;
716 };
717 let intent = state.intent.clone();
718 let abandon = matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y'))
719 || match intent {
720 ExitIntent::Quit => matches!(key.code, KeyCode::Char('q')),
721 ExitIntent::Switch(_) => matches!(key.code, KeyCode::Enter),
722 };
723 if abandon {
724 self.commit_exit(intent)
725 } else if matches!(
726 key.code,
727 KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N')
728 ) {
729 self.mode = Mode::List;
730 Effect::None
731 } else {
732 Effect::None
733 }
734 }
735
736 fn on_mouse(&mut self, mouse: MouseEvent) -> Effect {
738 if !matches!(self.mode, Mode::List | Mode::Filter) {
743 match mouse.kind {
744 MouseEventKind::ScrollUp => self.modal_scroll(-1),
745 MouseEventKind::ScrollDown => self.modal_scroll(1),
746 _ => {}
747 }
748 return Effect::None;
749 }
750 match mouse.kind {
751 MouseEventKind::Down(MouseButton::Left) => {
752 let status_row = self.size.1.saturating_sub(1);
754 if mouse.row >= status_row {
755 return Effect::None;
756 }
757 if self.show_sidebar && mouse.column < self.sidebar_width {
758 if mouse.row >= LIST_TOP {
761 self.select_row((mouse.row - LIST_TOP) as usize);
762 }
763 self.focus = Pane::List;
764 } else {
765 self.focus = Pane::Detail;
766 }
767 }
768 MouseEventKind::ScrollUp => self.nav_or_scroll(-1),
769 MouseEventKind::ScrollDown => self.nav_or_scroll(1),
770 _ => {}
771 }
772 Effect::None
773 }
774
775 fn modal_scroll(&mut self, delta: isize) {
780 let up = delta < 0;
781 match &mut self.mode {
782 Mode::Create(state) => {
783 if up {
784 state.options.up();
785 } else {
786 state.options.down();
787 }
788 }
789 Mode::Checkout(state) => {
790 if up {
791 state.options.up();
792 } else {
793 state.options.down();
794 }
795 }
796 Mode::PrPicker(state) => {
797 if up {
798 state.selected = state.selected.saturating_sub(1);
799 } else {
800 state.selected = (state.selected + 1).min(state.prs.len().saturating_sub(1));
801 }
802 }
803 _ => {}
804 }
805 }
806
807 fn nav_or_scroll(&mut self, delta: isize) {
810 if self.focus == Pane::Detail {
811 self.scroll_detail(delta);
812 } else {
813 self.move_selection(delta);
814 }
815 }
816
817 fn toggle_focus(&mut self) {
819 self.focus = match self.focus {
820 Pane::List => Pane::Detail,
821 Pane::Detail => Pane::List,
822 };
823 }
824}
825
826#[cfg(test)]
827mod tests {
828 use super::*;
829 use crate::tui::app::testutil::app;
830 use crossterm::event::{KeyModifiers, MouseButton};
831
832 fn press(code: KeyCode) -> Event {
833 Event::Key(KeyEvent::new(code, KeyModifiers::empty()))
834 }
835
836 fn ctrl(c: char) -> Event {
837 Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL))
838 }
839
840 #[test]
841 fn navigation_keys() {
842 let mut a = app(&[("a", true), ("b", false), ("c", false)]);
843 a.selected = 0;
844 assert_eq!(a.handle_event(press(KeyCode::Char('j'))), Effect::None);
845 assert_eq!(a.selected, 1);
846 a.handle_event(press(KeyCode::Char('k')));
847 assert_eq!(a.selected, 0);
848 a.handle_event(press(KeyCode::Char('G')));
849 assert_eq!(a.selected, 2);
850 a.handle_event(press(KeyCode::Char('g')));
851 assert_eq!(a.selected, 0);
852 a.handle_event(ctrl('d')); assert!(a.selected >= 1 || a.visible.len() == 1);
854 }
855
856 #[test]
857 fn enter_switches_to_selected() {
858 let mut a = app(&[("main", true), ("feat", false)]);
859 a.selected = 1;
860 let effect = a.handle_event(press(KeyCode::Enter));
861 assert_eq!(effect, Effect::Switch(std::path::PathBuf::from("/r/feat")));
862 assert_eq!(a.chosen, Some(std::path::PathBuf::from("/r/feat")));
863 }
864
865 #[test]
866 fn enter_on_branch_row_opens_confirm_create() {
867 use crate::tui::app::testutil::branch_row;
868 let mut a = app(&[("main", true)]);
869 a.worktrees.push(branch_row("topic"));
870 a.apply_filter(String::new()); a.selected = a.visible.len() - 1; let effect = a.handle_event(press(KeyCode::Enter));
873 assert_eq!(effect, Effect::None);
874 assert!(matches!(a.mode, Mode::ConfirmCreate(_)));
875 assert!(a.chosen.is_none()); }
877
878 #[test]
879 fn confirm_create_y_materializes_other_cancels() {
880 use crate::tui::app::testutil::branch_row;
881 let mut a = app(&[("main", true)]);
882 a.worktrees.push(branch_row("topic"));
883 let idx = a
884 .worktrees
885 .iter()
886 .position(|w| w.branch.as_deref() == Some("topic"))
887 .unwrap();
888 a.mode = Mode::ConfirmCreate(idx);
889 let effect = a.handle_event(press(KeyCode::Char('y')));
890 assert_eq!(
891 effect,
892 Effect::MaterializeBranch {
893 branch: "topic".into()
894 }
895 );
896 assert_eq!(a.mode, Mode::List);
897 a.mode = Mode::ConfirmCreate(idx);
899 let effect = a.handle_event(press(KeyCode::Char('n')));
900 assert_eq!(effect, Effect::None);
901 assert_eq!(a.mode, Mode::List);
902 }
903
904 #[test]
905 fn checkout_and_open_editor_are_noops_on_branch_rows() {
906 use crate::tui::app::testutil::branch_row;
909 let mut a = app(&[("main", true)]);
910 a.worktrees.push(branch_row("topic"));
911 a.apply_filter(String::new());
912 a.selected = a.visible.len() - 1; assert_eq!(a.handle_event(press(KeyCode::Char('c'))), Effect::None);
914 assert_eq!(a.mode, Mode::List);
915 assert_eq!(a.handle_event(press(KeyCode::Char('o'))), Effect::None);
916 assert_eq!(a.mode, Mode::List);
917 }
918
919 #[test]
920 fn sync_acts_on_worktree_rows_and_branch_rows() {
921 use crate::tui::app::testutil::branch_row;
922 let mut a = app(&[("main", true), ("feat", false)]);
923 a.selected = 1;
925 assert_eq!(
926 a.handle_event(press(KeyCode::Char('y'))),
927 Effect::Sync { worktree_index: 1 }
928 );
929 a.worktrees.push(branch_row("topic"));
932 a.apply_filter(String::new());
933 let idx = a
934 .worktrees
935 .iter()
936 .position(|w| w.branch.as_deref() == Some("topic"))
937 .unwrap();
938 a.selected = a.visible.iter().position(|&i| i == idx).unwrap();
939 assert_eq!(
940 a.handle_event(press(KeyCode::Char('y'))),
941 Effect::Sync {
942 worktree_index: idx
943 }
944 );
945 assert_eq!(a.mode, Mode::List);
946 }
947
948 #[test]
949 fn remove_on_branch_row_confirms_then_deletes_branch() {
950 use crate::tui::app::testutil::branch_row;
953 let mut a = app(&[("main", true)]);
954 a.worktrees.push(branch_row("topic"));
955 a.apply_filter(String::new());
956 a.selected = a.visible.len() - 1; assert_eq!(a.handle_event(press(KeyCode::Char('d'))), Effect::None);
958 assert!(matches!(
959 a.mode,
960 Mode::ConfirmDeleteBranch { force: false, .. }
961 ));
962 let effect = a.handle_event(press(KeyCode::Char('y')));
963 assert_eq!(
964 effect,
965 Effect::DeleteBranch {
966 branch: "topic".into(),
967 force: false,
968 }
969 );
970 assert_eq!(a.mode, Mode::List);
971 a.mode = Mode::ConfirmDeleteBranch {
973 index: a.visible[a.selected],
974 force: true,
975 };
976 assert_eq!(a.handle_event(press(KeyCode::Char('n'))), Effect::None);
977 assert_eq!(a.mode, Mode::List);
978 }
979
980 #[test]
981 fn confirm_stale_base_keys_reissue_create_or_cancel() {
982 use crate::tui::app::StaleBaseState;
983 let state = StaleBaseState {
984 branch: "feature".into(),
985 base: Some("main".into()),
986 behind: 2,
987 upstream_display: "origin/main".into(),
988 can_fast_forward: true,
989 };
990 let mut a = app(&[("main", true)]);
991 a.mode = Mode::ConfirmStaleBase(state.clone());
993 assert_eq!(
994 a.handle_event(press(KeyCode::Char('u'))),
995 Effect::Create {
996 branch: "feature".into(),
997 base: Some("main".into()),
998 decision: Some(CreateDecision::Update),
999 }
1000 );
1001 assert_eq!(a.mode, Mode::List);
1002 a.mode = Mode::ConfirmStaleBase(state.clone());
1004 assert_eq!(
1005 a.handle_event(press(KeyCode::Char('p'))),
1006 Effect::Create {
1007 branch: "feature".into(),
1008 base: Some("main".into()),
1009 decision: Some(CreateDecision::Proceed),
1010 }
1011 );
1012 a.mode = Mode::ConfirmStaleBase(state);
1014 assert_eq!(a.handle_event(press(KeyCode::Esc)), Effect::None);
1015 assert_eq!(a.mode, Mode::List);
1016 }
1017
1018 #[test]
1019 fn confirm_init_submodules_keys_init_or_skip() {
1020 use crate::tui::app::InitSubmodulesState;
1021 let state = InitSubmodulesState {
1022 dir: PathBuf::from("/wt/feature"),
1023 branch: "feature".into(),
1024 count: 2,
1025 };
1026 let mut a = app(&[("main", true)]);
1027 a.mode = Mode::ConfirmInitSubmodules(state.clone());
1029 assert_eq!(
1030 a.handle_event(press(KeyCode::Enter)),
1031 Effect::InitSubmodules {
1032 dir: PathBuf::from("/wt/feature"),
1033 count: 2,
1034 }
1035 );
1036 assert_eq!(a.mode, Mode::List);
1037 a.mode = Mode::ConfirmInitSubmodules(state.clone());
1039 assert_eq!(
1040 a.handle_event(press(KeyCode::Char('y'))),
1041 Effect::InitSubmodules {
1042 dir: PathBuf::from("/wt/feature"),
1043 count: 2,
1044 }
1045 );
1046 a.mode = Mode::ConfirmInitSubmodules(state.clone());
1048 assert_eq!(a.handle_event(press(KeyCode::Char('n'))), Effect::None);
1049 assert_eq!(a.mode, Mode::List);
1050 a.mode = Mode::ConfirmInitSubmodules(state.clone());
1052 assert_eq!(a.handle_event(press(KeyCode::Esc)), Effect::None);
1053 assert_eq!(a.mode, Mode::List);
1054 a.mode = Mode::ConfirmInitSubmodules(state);
1056 assert_eq!(a.handle_event(press(KeyCode::Char('x'))), Effect::None);
1057 assert!(matches!(a.mode, Mode::ConfirmInitSubmodules(_)));
1058 }
1059
1060 #[test]
1061 fn quit_returns_quit() {
1062 let mut a = app(&[("a", true)]);
1063 assert_eq!(a.handle_event(press(KeyCode::Char('q'))), Effect::Quit);
1064 assert!(a.quit);
1065 }
1066
1067 #[test]
1068 fn quit_with_jobs_blocks_the_exit() {
1069 use crate::tui::app::{ExitIntent, JobKey};
1072 let mut a = app(&[("a", true)]);
1073 a.begin_job(JobKey::New("feat".into()), "Creating feat");
1074 assert_eq!(a.handle_event(press(KeyCode::Char('q'))), Effect::None);
1075 assert!(matches!(
1076 a.mode,
1077 Mode::ExitBlocked(ref s) if s.intent == ExitIntent::Quit
1078 ));
1079 assert!(!a.quit);
1080 assert_eq!(a.handle_event(press(KeyCode::Char('y'))), Effect::Quit);
1082 assert!(a.quit);
1083 let mut b = app(&[("a", true)]);
1085 b.begin_job(JobKey::New("feat".into()), "Creating feat");
1086 b.handle_event(press(KeyCode::Char('q')));
1087 assert_eq!(b.handle_event(press(KeyCode::Char('q'))), Effect::Quit);
1088 assert!(b.quit);
1089 let mut c = app(&[("a", true)]);
1091 c.begin_job(JobKey::New("feat".into()), "Creating feat");
1092 c.handle_event(press(KeyCode::Char('q')));
1093 assert_eq!(c.handle_event(press(KeyCode::Char('n'))), Effect::None);
1094 assert_eq!(c.mode, Mode::List);
1095 assert!(!c.quit);
1096 assert!(c.any_jobs());
1097 }
1098
1099 #[test]
1100 fn switch_with_jobs_blocks_the_exit() {
1101 use crate::tui::app::{ExitIntent, JobKey};
1105 let mut a = app(&[("main", true), ("feat", false)]);
1106 a.selected = 1;
1107 a.begin_job(JobKey::New("other".into()), "Creating other");
1108 assert_eq!(a.handle_event(press(KeyCode::Enter)), Effect::None);
1109 assert!(matches!(
1110 a.mode,
1111 Mode::ExitBlocked(ref s)
1112 if s.intent == ExitIntent::Switch(std::path::PathBuf::from("/r/feat"))
1113 ));
1114 assert!(a.chosen.is_none()); assert_eq!(
1117 a.handle_event(press(KeyCode::Enter)),
1118 Effect::Switch(std::path::PathBuf::from("/r/feat"))
1119 );
1120 assert_eq!(a.chosen, Some(std::path::PathBuf::from("/r/feat")));
1121 }
1122
1123 #[test]
1124 fn blocked_exit_completes_when_jobs_drain() {
1125 use crate::tui::app::JobKey;
1128 let mut a = app(&[("main", true), ("feat", false)]);
1129 a.selected = 1;
1130 let key = JobKey::New("other".into());
1131 a.begin_job(key.clone(), "Creating other");
1132 a.handle_event(press(KeyCode::Enter));
1133 assert!(!a.exit_now()); a.finish_job(&key);
1135 assert!(a.exit_now()); assert_eq!(a.chosen, Some(std::path::PathBuf::from("/r/feat")));
1137 }
1138
1139 #[test]
1140 fn filter_mode_typing_and_escape() {
1141 let mut a = app(&[("alpha", true), ("beta", false)]);
1142 a.handle_event(press(KeyCode::Char('/')));
1143 assert_eq!(a.mode, Mode::Filter);
1144 a.handle_event(press(KeyCode::Char('a')));
1145 a.handle_event(press(KeyCode::Char('l')));
1146 assert_eq!(a.filter, "al");
1147 assert_eq!(a.visible.len(), 1); a.handle_event(press(KeyCode::Enter)); assert_eq!(a.mode, Mode::List);
1150 assert_eq!(a.filter, "al"); a.handle_event(press(KeyCode::Char('/')));
1152 a.handle_event(press(KeyCode::Esc)); assert_eq!(a.mode, Mode::List);
1154 assert_eq!(a.filter, "");
1155 }
1156
1157 #[test]
1158 fn create_mode_flow() {
1159 let mut a = app(&[("a", true)]);
1160 a.handle_event(press(KeyCode::Char('n')));
1161 assert!(matches!(a.mode, Mode::Create(_)));
1162 a.handle_event(press(KeyCode::Enter));
1164 if let Mode::Create(s) = &a.mode {
1165 assert!(s.error.is_some());
1166 } else {
1167 panic!("expected create mode");
1168 }
1169 for c in "feature/x".chars() {
1171 a.handle_event(press(KeyCode::Char(c)));
1172 }
1173 a.handle_event(press(KeyCode::Enter));
1174 if let Mode::Create(s) = &a.mode {
1175 assert_eq!(s.step, CreateStep::Base);
1176 assert_eq!(s.branch, "feature/x");
1177 }
1178 let effect = a.handle_event(press(KeyCode::Enter));
1180 assert_eq!(
1181 effect,
1182 Effect::Create {
1183 branch: "feature/x".into(),
1184 base: None,
1185 decision: None,
1186 }
1187 );
1188 }
1189
1190 #[test]
1191 fn create_mode_prefills_default_base() {
1192 let mut a = app(&[("main", true)]);
1195 a.branches = vec!["main".into(), "origin/main".into()];
1196 a.default_base = Some("origin/main".into());
1197 a.handle_event(press(KeyCode::Char('n')));
1198 if let Mode::Create(s) = &a.mode {
1199 assert_eq!(s.base, "origin/main");
1200 assert_eq!(s.step, CreateStep::Branch); } else {
1202 panic!("expected create mode");
1203 }
1204 }
1205
1206 #[test]
1207 fn create_mode_base_empty_without_default() {
1208 let mut a = app(&[("main", true)]);
1211 assert!(a.default_base.is_none());
1212 a.handle_event(press(KeyCode::Char('n')));
1213 for c in "feature/x".chars() {
1214 a.handle_event(press(KeyCode::Char(c)));
1215 }
1216 a.handle_event(press(KeyCode::Enter)); if let Mode::Create(s) = &a.mode {
1218 assert_eq!(s.base, "");
1219 } else {
1220 panic!("expected create mode");
1221 }
1222 assert_eq!(
1223 a.handle_event(press(KeyCode::Enter)),
1224 Effect::Create {
1225 branch: "feature/x".into(),
1226 base: None,
1227 decision: None,
1228 }
1229 );
1230 }
1231
1232 #[test]
1233 fn create_mode_rejects_invalid_branch_name() {
1234 let mut a = app(&[("a", true)]);
1235 a.handle_event(press(KeyCode::Char('n')));
1236 for c in "feat..x".chars() {
1237 a.handle_event(press(KeyCode::Char(c)));
1238 }
1239 a.handle_event(press(KeyCode::Enter));
1241 if let Mode::Create(s) = &a.mode {
1242 assert_eq!(s.step, CreateStep::Branch);
1243 assert!(s.error.as_deref().unwrap().contains("invalid branch name"));
1244 } else {
1245 panic!("expected create mode");
1246 }
1247 a.handle_event(press(KeyCode::Char('y')));
1249 if let Mode::Create(s) = &a.mode {
1250 assert!(s.error.is_none());
1251 }
1252 if let Mode::Create(s) = &mut a.mode {
1254 s.branch = "feature/x".into();
1255 }
1256 a.handle_event(press(KeyCode::Enter));
1257 if let Mode::Create(s) = &a.mode {
1258 assert_eq!(s.step, CreateStep::Base);
1259 } else {
1260 panic!("expected create mode");
1261 }
1262 }
1263
1264 #[test]
1265 fn create_mode_tab_completes_base_ref() {
1266 let mut a = app(&[("a", true)]);
1267 a.branches = vec!["feature/alpha".into(), "feature/beta".into(), "main".into()];
1268 a.handle_event(press(KeyCode::Char('n')));
1269 for c in "topic".chars() {
1270 a.handle_event(press(KeyCode::Char(c)));
1271 }
1272 a.handle_event(press(KeyCode::Enter)); for c in "feat".chars() {
1275 a.handle_event(press(KeyCode::Char(c)));
1276 }
1277 a.handle_event(press(KeyCode::Tab));
1278 if let Mode::Create(s) = &a.mode {
1279 assert_eq!(s.base, "feature/");
1280 } else {
1281 panic!("expected create mode");
1282 }
1283 a.handle_event(press(KeyCode::Char('a')));
1285 a.handle_event(press(KeyCode::Tab));
1286 if let Mode::Create(s) = &a.mode {
1287 assert_eq!(s.base, "feature/alpha");
1288 }
1289 }
1290
1291 #[test]
1292 fn create_mode_tab_noop_without_candidates() {
1293 let mut a = app(&[("a", true)]);
1294 a.handle_event(press(KeyCode::Char('n')));
1295 for c in "feature/x".chars() {
1296 a.handle_event(press(KeyCode::Char(c)));
1297 }
1298 a.handle_event(press(KeyCode::Enter)); for c in "xyz".chars() {
1300 a.handle_event(press(KeyCode::Char(c)));
1301 }
1302 a.handle_event(press(KeyCode::Tab)); if let Mode::Create(s) = &a.mode {
1304 assert_eq!(s.base, "xyz");
1305 }
1306 let mut b = app(&[("a", true)]);
1308 b.branches = vec!["main".into()];
1309 b.handle_event(press(KeyCode::Char('n')));
1310 b.handle_event(press(KeyCode::Tab));
1311 if let Mode::Create(s) = &b.mode {
1312 assert!(s.branch.is_empty());
1313 }
1314 }
1315
1316 #[test]
1317 fn longest_common_prefix_cases() {
1318 assert_eq!(longest_common_prefix(&[]), None);
1319 assert_eq!(longest_common_prefix(&["solo"]).as_deref(), Some("solo"));
1320 assert_eq!(
1321 longest_common_prefix(&["feature/a", "feature/b"]).as_deref(),
1322 Some("feature/")
1323 );
1324 assert_eq!(longest_common_prefix(&["abc", "xyz"]).as_deref(), Some(""));
1325 }
1326
1327 #[test]
1328 fn create_mode_escape_cancels() {
1329 let mut a = app(&[("a", true)]);
1330 a.handle_event(press(KeyCode::Char('n')));
1331 a.handle_event(press(KeyCode::Esc));
1332 assert_eq!(a.mode, Mode::List);
1333 }
1334
1335 #[test]
1336 fn create_mode_dropdown_filters_navigates_and_accepts() {
1337 let mut a = app(&[("a", true)]);
1338 a.branches = vec!["main".into(), "origin/main".into(), "origin/dev".into()];
1339 a.handle_event(press(KeyCode::Char('n')));
1340 for c in "feature/login".chars() {
1343 a.handle_event(press(KeyCode::Char(c)));
1344 }
1345 a.handle_event(press(KeyCode::Enter));
1346 if let Mode::Create(s) = &a.mode {
1347 assert_eq!(s.step, CreateStep::Base);
1348 assert!(s.options.is_open());
1350 } else {
1351 panic!("expected create mode");
1352 }
1353 for c in "origin".chars() {
1355 a.handle_event(press(KeyCode::Char(c)));
1356 }
1357 a.handle_event(press(KeyCode::Down));
1360 a.handle_event(press(KeyCode::Enter));
1361 if let Mode::Create(s) = &a.mode {
1362 assert_eq!(s.base, "origin/dev");
1363 assert!(!s.options.is_open()); }
1365 let effect = a.handle_event(press(KeyCode::Enter));
1367 assert_eq!(
1368 effect,
1369 Effect::Create {
1370 branch: "feature/login".into(),
1371 base: Some("origin/dev".into()),
1372 decision: None,
1373 }
1374 );
1375 }
1376
1377 #[test]
1378 fn create_mode_escape_closes_dropdown_before_modal() {
1379 let mut a = app(&[("a", true)]);
1380 a.branches = vec!["main".into()];
1381 a.handle_event(press(KeyCode::Char('n')));
1382 a.handle_event(press(KeyCode::Char('m'))); if let Mode::Create(s) = &a.mode {
1384 assert!(s.options.is_open());
1385 }
1386 a.handle_event(press(KeyCode::Esc)); if let Mode::Create(s) = &a.mode {
1388 assert!(!s.options.is_open());
1389 } else {
1390 panic!("expected create mode (still open)");
1391 }
1392 a.handle_event(press(KeyCode::Esc)); assert_eq!(a.mode, Mode::List);
1394 }
1395
1396 #[test]
1397 fn confirm_remove_y_removes() {
1398 let mut a = app(&[("main", true), ("feat", false)]);
1399 a.selected = 1;
1400 a.handle_event(press(KeyCode::Char('d')));
1401 assert!(matches!(a.mode, Mode::ConfirmRemove(_)));
1402 let effect = a.handle_event(press(KeyCode::Char('y')));
1403 assert!(matches!(effect, Effect::Remove(_)));
1405 assert_eq!(a.mode, Mode::List);
1406 }
1407
1408 #[test]
1409 fn confirm_remove_other_key_cancels() {
1410 let mut a = app(&[("main", true), ("feat", false)]);
1411 a.selected = 1;
1412 a.handle_event(press(KeyCode::Char('d')));
1413 let effect = a.handle_event(press(KeyCode::Char('n')));
1414 assert_eq!(effect, Effect::None);
1415 assert_eq!(a.mode, Mode::List);
1416 }
1417
1418 #[test]
1419 fn pr_picker_opens_and_fetches() {
1420 let mut a = app(&[("a", true)]);
1421 let effect = a.handle_event(press(KeyCode::Char('p')));
1422 assert_eq!(effect, Effect::FetchPrs);
1423 assert!(matches!(a.mode, Mode::PrPicker(_)));
1424 if let Mode::PrPicker(s) = &mut a.mode {
1426 s.loading = false;
1427 s.prs = vec![
1428 crate::tui::app::PrItem {
1429 number: 7,
1430 title: "x".into(),
1431 author: "a".into(),
1432 state: "open".into(),
1433 created_at: String::new(),
1434 },
1435 crate::tui::app::PrItem {
1436 number: 9,
1437 title: "y".into(),
1438 author: "b".into(),
1439 state: "open".into(),
1440 created_at: String::new(),
1441 },
1442 ];
1443 }
1444 a.handle_event(press(KeyCode::Down));
1445 let effect = a.handle_event(press(KeyCode::Enter));
1446 assert_eq!(effect, Effect::CheckoutPr(9));
1447 }
1448
1449 #[test]
1450 fn checkout_key_opens_picker_for_selected_worktree() {
1451 let mut a = app(&[("main", true), ("feature/x", false)]);
1452 a.branches = vec!["main".into(), "feature/x".into()];
1453 a.selected = 1; a.handle_event(press(KeyCode::Char('c')));
1455 if let Mode::Checkout(s) = &a.mode {
1456 assert_eq!(s.worktree_index, a.visible[1]);
1458 assert_eq!(s.options.match_count(), 2);
1459 assert!(s.options.is_open());
1461 } else {
1462 panic!("expected checkout mode");
1463 }
1464 }
1465
1466 #[test]
1467 fn checkout_picker_arrows_select_a_branch_without_typing() {
1468 let mut a = app(&[("main", true)]);
1471 a.branches = vec!["main".into(), "origin/feature/x".into()];
1472 a.handle_event(press(KeyCode::Char('c')));
1473 a.handle_event(press(KeyCode::Down)); let effect = a.handle_event(press(KeyCode::Enter));
1475 assert_eq!(
1476 effect,
1477 Effect::CheckoutBranch {
1478 worktree_index: 0,
1479 branch: "origin/feature/x".into(),
1480 }
1481 );
1482 }
1483
1484 #[test]
1485 fn checkout_picker_submits_typed_branch() {
1486 let mut a = app(&[("main", true)]);
1487 a.branches = vec!["main".into(), "feature/x".into()];
1488 a.handle_event(press(KeyCode::Char('c')));
1489 for ch in "feature/x".chars() {
1490 a.handle_event(press(KeyCode::Char(ch)));
1491 }
1492 let effect = a.handle_event(press(KeyCode::Enter));
1494 assert_eq!(
1495 effect,
1496 Effect::CheckoutBranch {
1497 worktree_index: 0,
1498 branch: "feature/x".into(),
1499 }
1500 );
1501 }
1502
1503 #[test]
1504 fn checkout_picker_submits_highlighted_suggestion() {
1505 let mut a = app(&[("main", true)]);
1506 a.branches = vec!["main".into(), "feature/x".into(), "feature/y".into()];
1507 a.handle_event(press(KeyCode::Char('c')));
1508 for ch in "feature".chars() {
1509 a.handle_event(press(KeyCode::Char(ch)));
1510 }
1511 a.handle_event(press(KeyCode::Down));
1513 let effect = a.handle_event(press(KeyCode::Enter));
1514 assert_eq!(
1515 effect,
1516 Effect::CheckoutBranch {
1517 worktree_index: 0,
1518 branch: "feature/y".into(),
1519 }
1520 );
1521 }
1522
1523 #[test]
1524 fn checkout_picker_empty_query_errors() {
1525 let mut a = app(&[("main", true)]);
1526 a.handle_event(press(KeyCode::Char('c')));
1527 let effect = a.handle_event(press(KeyCode::Enter));
1528 assert_eq!(effect, Effect::None);
1529 if let Mode::Checkout(s) = &a.mode {
1530 assert!(s.error.is_some());
1531 } else {
1532 panic!("expected checkout mode (still open)");
1533 }
1534 }
1535
1536 #[test]
1537 fn checkout_picker_escape_closes_dropdown_then_cancels() {
1538 let mut a = app(&[("main", true)]);
1539 a.branches = vec!["main".into()];
1540 a.handle_event(press(KeyCode::Char('c')));
1541 a.handle_event(press(KeyCode::Char('m'))); if let Mode::Checkout(s) = &a.mode {
1543 assert!(s.options.is_open());
1544 }
1545 a.handle_event(press(KeyCode::Esc)); if let Mode::Checkout(s) = &a.mode {
1547 assert!(!s.options.is_open());
1548 } else {
1549 panic!("expected checkout mode (still open)");
1550 }
1551 a.handle_event(press(KeyCode::Esc)); assert_eq!(a.mode, Mode::List);
1553 }
1554
1555 #[test]
1556 fn compose_typing_field_switch_and_newline() {
1557 use crate::tui::app::PrComposeState;
1558 let mut a = app(&[("a", true)]);
1559 a.mode = Mode::PrCompose(PrComposeState::default());
1560 a.handle_event(press(KeyCode::Char('h')));
1561 a.handle_event(press(KeyCode::Char('i')));
1562 if let Mode::PrCompose(s) = &a.mode {
1563 assert_eq!(s.title, "hi");
1564 assert_eq!(s.field, ComposeField::Title);
1565 } else {
1566 panic!("expected compose mode");
1567 }
1568 a.handle_event(press(KeyCode::Enter));
1570 if let Mode::PrCompose(s) = &a.mode {
1571 assert_eq!(s.field, ComposeField::Body);
1572 }
1573 a.handle_event(press(KeyCode::Char('x')));
1575 a.handle_event(press(KeyCode::Enter));
1576 a.handle_event(press(KeyCode::Char('y')));
1577 if let Mode::PrCompose(s) = &a.mode {
1578 assert_eq!(s.body, "x\ny");
1579 }
1580 a.handle_event(press(KeyCode::BackTab));
1582 a.handle_event(press(KeyCode::Backspace));
1583 if let Mode::PrCompose(s) = &a.mode {
1584 assert_eq!(s.field, ComposeField::Title);
1585 assert_eq!(s.title, "h");
1586 }
1587 }
1588
1589 #[test]
1590 fn compose_tab_cycles_all_four_fields() {
1591 use crate::tui::app::PrComposeState;
1592 let mut a = app(&[("a", true)]);
1593 a.mode = Mode::PrCompose(PrComposeState::default());
1594 let field = |a: &App| {
1595 if let Mode::PrCompose(s) = &a.mode {
1596 s.field
1597 } else {
1598 panic!("expected compose mode")
1599 }
1600 };
1601 assert_eq!(field(&a), ComposeField::Title);
1602 a.handle_event(press(KeyCode::Tab));
1603 assert_eq!(field(&a), ComposeField::Body);
1604 a.handle_event(press(KeyCode::Tab));
1605 assert_eq!(field(&a), ComposeField::Model);
1606 a.handle_event(press(KeyCode::Tab));
1607 assert_eq!(field(&a), ComposeField::Effort);
1608 a.handle_event(press(KeyCode::Tab));
1609 assert_eq!(field(&a), ComposeField::Title); }
1611
1612 #[test]
1613 fn compose_model_effort_fields_pick_with_arrows() {
1614 use crate::agent::{AgentModel, Effort};
1615 use crate::tui::app::PrComposeState;
1616 let mut a = app(&[("a", true)]);
1617 a.mode = Mode::PrCompose(PrComposeState::default());
1618 a.handle_event(press(KeyCode::Tab));
1620 a.handle_event(press(KeyCode::Tab));
1621 a.handle_event(press(KeyCode::Down));
1623 a.handle_event(press(KeyCode::Up));
1624 a.handle_event(press(KeyCode::Char('z')));
1626 if let Mode::PrCompose(s) = &a.mode {
1627 assert_eq!(s.field, ComposeField::Model);
1628 assert_eq!(s.model, AgentModel::Sonnet);
1629 assert_eq!(s.title, "");
1630 } else {
1631 panic!("expected compose mode");
1632 }
1633 a.handle_event(press(KeyCode::Tab));
1635 a.handle_event(press(KeyCode::Down));
1636 if let Mode::PrCompose(s) = &a.mode {
1637 assert_eq!(s.field, ComposeField::Effort);
1638 assert_eq!(s.effort, Effort::Medium.next());
1639 }
1640 }
1641
1642 #[test]
1643 fn compose_ctrl_s_requires_title_and_is_not_typed() {
1644 use crate::tui::app::PrComposeState;
1645 let mut a = app(&[("a", true)]);
1646 a.mode = Mode::PrCompose(PrComposeState::default());
1647 let effect = a.handle_event(ctrl('s'));
1648 assert_eq!(effect, Effect::None);
1649 if let Mode::PrCompose(s) = &a.mode {
1650 assert!(s.error.is_some());
1651 assert_eq!(s.title, "");
1653 } else {
1654 panic!("expected compose mode");
1655 }
1656 }
1657
1658 #[test]
1659 fn compose_ctrl_s_submits_when_title_present() {
1660 use crate::tui::app::PrComposeState;
1661 let mut a = app(&[("a", true)]);
1662 a.mode = Mode::PrCompose(PrComposeState {
1663 title: "T".into(),
1664 body: "B".into(),
1665 ..Default::default()
1666 });
1667 let effect = a.handle_event(ctrl('s'));
1668 assert_eq!(
1669 effect,
1670 Effect::SubmitPr {
1671 title: "T".into(),
1672 body: "B".into(),
1673 draft: false
1674 }
1675 );
1676 if let Mode::PrCompose(s) = &a.mode {
1677 assert!(s.submitting);
1678 }
1679 }
1680
1681 #[test]
1682 fn compose_ctrl_d_toggles_draft_and_esc_cancels() {
1683 use crate::tui::app::PrComposeState;
1684 let mut a = app(&[("a", true)]);
1685 a.mode = Mode::PrCompose(PrComposeState::default());
1686 a.handle_event(ctrl('d'));
1687 if let Mode::PrCompose(s) = &a.mode {
1688 assert!(s.draft);
1689 }
1690 a.handle_event(press(KeyCode::Esc));
1691 assert_eq!(a.mode, Mode::List);
1692 }
1693
1694 #[test]
1695 fn compose_ctrl_a_triggers_ai_fill() {
1696 use crate::tui::app::PrComposeState;
1697 let mut a = app(&[("a", true)]);
1698 a.mode = Mode::PrCompose(PrComposeState::default());
1699 let effect = a.handle_event(ctrl('a'));
1700 assert_eq!(effect, Effect::DraftPrAi);
1701 if let Mode::PrCompose(s) = &a.mode {
1702 assert!(s.submitting);
1704 assert_eq!(s.title, "");
1705 } else {
1706 panic!("expected compose mode");
1707 }
1708 }
1709
1710 #[test]
1711 fn compose_ctrl_m_and_e_cycle_model_and_effort() {
1712 use crate::agent::{AgentModel, Effort};
1713 use crate::tui::app::PrComposeState;
1714 let mut a = app(&[("a", true)]);
1715 a.mode = Mode::PrCompose(PrComposeState::default());
1716 a.handle_event(ctrl('m'));
1718 a.handle_event(ctrl('e'));
1719 if let Mode::PrCompose(s) = &a.mode {
1720 assert_eq!(s.model, AgentModel::Sonnet.next());
1721 assert_eq!(s.effort, Effort::Medium.next());
1722 assert_eq!(s.title, "");
1723 } else {
1724 panic!("expected compose mode");
1725 }
1726 }
1727
1728 #[test]
1729 fn help_dismisses_on_any_key() {
1730 let mut a = app(&[("a", true)]);
1731 a.handle_event(press(KeyCode::Char('?')));
1732 assert_eq!(a.mode, Mode::Help);
1733 a.handle_event(press(KeyCode::Char('x')));
1734 assert_eq!(a.mode, Mode::List);
1735 }
1736
1737 #[test]
1738 fn sort_and_sidebar_keys() {
1739 let mut a = app(&[("a", true)]);
1740 a.handle_event(press(KeyCode::Char('s')));
1741 assert_eq!(a.sort.key, crate::model::SortKey::Dirty);
1742 a.handle_event(press(KeyCode::Char('S')));
1743 assert!(a.sort.descending);
1744 let w0 = a.sidebar_width;
1745 a.handle_event(press(KeyCode::Char('+')));
1746 assert_eq!(a.sidebar_width, w0 + 1);
1747 a.handle_event(press(KeyCode::Char('-')));
1748 assert_eq!(a.sidebar_width, w0);
1749 a.handle_event(press(KeyCode::Char('\\')));
1750 assert!(!a.show_sidebar);
1751 }
1752
1753 #[test]
1754 fn resize_too_small_exits() {
1755 let mut a = app(&[("a", true)]);
1756 assert_eq!(a.handle_event(Event::Resize(100, 4)), Effect::TooSmall);
1757 assert_eq!(a.handle_event(Event::Resize(100, 20)), Effect::None);
1758 assert_eq!(a.size, (100, 20));
1759 }
1760
1761 #[test]
1762 fn open_editor_and_refresh() {
1763 let mut a = app(&[("a", true)]);
1764 assert_eq!(
1765 a.handle_event(press(KeyCode::Char('o'))),
1766 Effect::OpenEditor(std::path::PathBuf::from("/r/a"))
1767 );
1768 assert_eq!(a.handle_event(press(KeyCode::Char('r'))), Effect::Refresh);
1769 }
1770
1771 #[test]
1772 fn mouse_click_selects_and_wheel_scrolls() {
1773 let mut a = app(&[("a", true), ("b", false), ("c", false)]);
1774 let click = Event::Mouse(MouseEvent {
1776 kind: MouseEventKind::Down(MouseButton::Left),
1777 column: 5,
1778 row: 3,
1779 modifiers: KeyModifiers::empty(),
1780 });
1781 a.handle_event(click);
1782 assert_eq!(a.selected, 2);
1783 a.handle_event(Event::Mouse(MouseEvent {
1784 kind: MouseEventKind::ScrollUp,
1785 column: 5,
1786 row: 3,
1787 modifiers: KeyModifiers::empty(),
1788 }));
1789 assert_eq!(a.selected, 1);
1790 }
1791
1792 #[test]
1793 fn mouse_ignored_when_disabled() {
1794 let mut a = app(&[("a", true), ("b", false)]);
1795 a.mouse = false;
1796 a.selected = 0;
1797 a.handle_event(Event::Mouse(MouseEvent {
1798 kind: MouseEventKind::ScrollDown,
1799 column: 5,
1800 row: 3,
1801 modifiers: KeyModifiers::empty(),
1802 }));
1803 assert_eq!(a.selected, 0);
1804 }
1805
1806 #[test]
1807 fn mouse_in_modal_does_not_touch_background() {
1808 let mut a = app(&[("a", true), ("b", false), ("c", false)]);
1811 a.selected = 1;
1812 a.mode = Mode::Create(CreateState::default());
1813 let click = Event::Mouse(MouseEvent {
1814 kind: MouseEventKind::Down(MouseButton::Left),
1815 column: 5,
1816 row: 3,
1817 modifiers: KeyModifiers::empty(),
1818 });
1819 assert_eq!(a.handle_event(click), Effect::None);
1820 assert_eq!(a.selected, 1);
1821 assert!(matches!(a.mode, Mode::Create(_)));
1822 a.handle_event(Event::Mouse(MouseEvent {
1823 kind: MouseEventKind::ScrollDown,
1824 column: 5,
1825 row: 3,
1826 modifiers: KeyModifiers::empty(),
1827 }));
1828 assert_eq!(a.selected, 1); }
1830
1831 #[test]
1832 fn mouse_scroll_moves_create_dropdown() {
1833 let mut a = app(&[("a", true)]);
1836 let mut options = crate::tui::OptionList::new(vec![
1837 "main".into(),
1838 "origin/main".into(),
1839 "origin/dev".into(),
1840 ]);
1841 options.open();
1842 a.mode = Mode::Create(CreateState {
1843 options,
1844 ..Default::default()
1845 });
1846 let wheel = |kind| {
1847 Event::Mouse(MouseEvent {
1848 kind,
1849 column: 5,
1850 row: 5,
1851 modifiers: KeyModifiers::empty(),
1852 })
1853 };
1854 a.handle_event(wheel(MouseEventKind::ScrollDown));
1855 if let Mode::Create(s) = &a.mode {
1856 assert_eq!(s.options.selected(), Some("origin/main"));
1858 } else {
1859 panic!("expected create mode");
1860 }
1861 a.handle_event(wheel(MouseEventKind::ScrollUp));
1862 if let Mode::Create(s) = &a.mode {
1863 assert_eq!(s.options.selected(), Some("main"));
1864 }
1865 }
1866
1867 #[test]
1868 fn mouse_scroll_moves_pr_picker_selection() {
1869 use crate::tui::app::{PrItem, PrPickerState};
1870 let pr = |number| PrItem {
1871 number,
1872 title: "t".into(),
1873 author: "a".into(),
1874 state: "open".into(),
1875 created_at: String::new(),
1876 };
1877 let mut a = app(&[("a", true)]);
1878 a.mode = Mode::PrPicker(PrPickerState {
1879 loading: false,
1880 prs: vec![pr(1), pr(2)],
1881 ..Default::default()
1882 });
1883 let wheel = |kind| {
1884 Event::Mouse(MouseEvent {
1885 kind,
1886 column: 5,
1887 row: 5,
1888 modifiers: KeyModifiers::empty(),
1889 })
1890 };
1891 a.handle_event(wheel(MouseEventKind::ScrollDown));
1892 if let Mode::PrPicker(s) = &a.mode {
1893 assert_eq!(s.selected, 1);
1894 } else {
1895 panic!("expected pr picker");
1896 }
1897 a.handle_event(wheel(MouseEventKind::ScrollDown));
1899 if let Mode::PrPicker(s) = &a.mode {
1900 assert_eq!(s.selected, 1);
1901 }
1902 a.handle_event(wheel(MouseEventKind::ScrollUp));
1903 if let Mode::PrPicker(s) = &a.mode {
1904 assert_eq!(s.selected, 0);
1905 }
1906 }
1907
1908 #[test]
1909 fn tab_toggles_focus() {
1910 let mut a = app(&[("a", true)]);
1911 assert_eq!(a.focus, Pane::List);
1912 a.handle_event(press(KeyCode::Tab));
1913 assert_eq!(a.focus, Pane::Detail);
1914 }
1915
1916 #[test]
1917 fn navigation_scrolls_detail_when_focused() {
1918 let mut a = app(&[("a", true), ("b", false)]);
1919 a.worktrees[0].recent_commits = vec![crate::model::Commit {
1920 hash: "h".into(),
1921 subject: "s".into(),
1922 author: "x".into(),
1923 timestamp: "2024-01-15T10:30:00Z".into(),
1924 }];
1925 a.handle_event(press(KeyCode::Tab)); a.handle_event(press(KeyCode::Char('j'))); assert_eq!(a.detail_scroll, 1);
1928 assert_eq!(a.selected, 0);
1929 a.handle_event(press(KeyCode::Char('k')));
1930 assert_eq!(a.detail_scroll, 0);
1931 a.handle_event(press(KeyCode::Tab));
1933 a.detail_scroll = 3;
1934 a.handle_event(press(KeyCode::Char('j')));
1935 assert_eq!(a.selected, 1);
1936 assert_eq!(a.detail_scroll, 0);
1937 }
1938
1939 #[test]
1940 fn mouse_click_on_status_bar_and_title_row_select_nothing() {
1941 let mut a = app(&[("a", true), ("b", false), ("c", false)]);
1942 a.size = (100, 30);
1943 a.selected = 1;
1944 let click = |row: u16| {
1946 Event::Mouse(MouseEvent {
1947 kind: MouseEventKind::Down(MouseButton::Left),
1948 column: 5,
1949 row,
1950 modifiers: KeyModifiers::empty(),
1951 })
1952 };
1953 a.handle_event(click(29));
1954 assert_eq!(a.selected, 1);
1955 a.handle_event(click(0));
1957 assert_eq!(a.selected, 1);
1958 }
1959}