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::{App, ComposeField, CreateState, CreateStep, MIN_HEIGHT, Mode, Pane};
14
15const MIN_SIDEBAR: u16 = 10;
17const MAX_SIDEBAR: u16 = 100;
18const LIST_TOP: u16 = 1;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum CreateDecision {
25 Update,
27 Proceed,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum Effect {
34 None,
36 Switch(PathBuf),
38 Quit,
40 TooSmall,
42 Create {
47 branch: String,
49 base: Option<String>,
51 decision: Option<CreateDecision>,
53 },
54 Remove(usize),
56 DeleteBranch {
59 branch: String,
61 force: bool,
63 },
64 MaterializeBranch {
68 branch: String,
70 },
71 FetchPrs,
73 CheckoutPr(u64),
75 CheckoutBranch {
77 worktree_index: usize,
79 branch: String,
81 },
82 Sync {
84 worktree_index: usize,
86 },
87 InitSubmodules {
89 dir: PathBuf,
91 count: usize,
93 },
94 OpenEditor(PathBuf),
96 Refresh,
98 DraftPrAi,
100 SubmitPr {
102 title: String,
104 body: String,
106 draft: bool,
108 },
109}
110
111impl CreateState {
112 fn field_mut(&mut self) -> &mut String {
114 match self.step {
115 CreateStep::Branch => &mut self.branch,
116 CreateStep::Base => &mut self.base,
117 }
118 }
119
120 fn field_value(&self) -> &str {
122 match self.step {
123 CreateStep::Branch => &self.branch,
124 CreateStep::Base => &self.base,
125 }
126 }
127
128 fn refresh_options(&mut self) {
130 let query = self.field_value().to_owned();
131 self.options.refilter(&query);
132 self.options.open();
133 }
134}
135
136fn complete_base_ref(state: &mut CreateState, branches: &[String]) {
141 let matches: Vec<&str> = branches
142 .iter()
143 .map(String::as_str)
144 .filter(|b| b.starts_with(&state.base))
145 .collect();
146 if let Some(common) = longest_common_prefix(&matches)
147 && common.len() > state.base.len()
148 {
149 state.base = common;
150 }
151}
152
153fn longest_common_prefix(items: &[&str]) -> Option<String> {
156 let (first, rest) = items.split_first()?;
157 let mut prefix: &str = first;
158 for item in rest {
159 let shared = prefix
162 .char_indices()
163 .zip(item.chars())
164 .take_while(|&((_, a), b)| a == b)
165 .map(|((i, a), _)| i + a.len_utf8())
166 .last()
167 .unwrap_or(0);
168 prefix = &prefix[..shared];
169 if prefix.is_empty() {
170 break;
171 }
172 }
173 Some(prefix.to_string())
174}
175
176fn compose_next_field(field: ComposeField) -> ComposeField {
178 match field {
179 ComposeField::Title => ComposeField::Body,
180 ComposeField::Body => ComposeField::Model,
181 ComposeField::Model => ComposeField::Effort,
182 ComposeField::Effort => ComposeField::Title,
183 }
184}
185
186fn compose_prev_field(field: ComposeField) -> ComposeField {
189 match field {
190 ComposeField::Title => ComposeField::Effort,
191 ComposeField::Effort => ComposeField::Model,
192 ComposeField::Model => ComposeField::Body,
193 ComposeField::Body => ComposeField::Title,
194 }
195}
196
197impl App {
198 pub fn handle_event(&mut self, event: Event) -> Effect {
200 match event {
201 Event::Resize(cols, rows) => self.on_resize(cols, rows),
202 Event::Key(key) if key.kind != KeyEventKind::Release => self.on_key(key),
203 Event::Mouse(mouse) if self.mouse => self.on_mouse(mouse),
204 _ => Effect::None,
205 }
206 }
207
208 fn on_resize(&mut self, cols: u16, rows: u16) -> Effect {
210 self.size = (cols, rows);
211 if rows < MIN_HEIGHT {
212 Effect::TooSmall
213 } else {
214 Effect::None
215 }
216 }
217
218 fn on_key(&mut self, key: KeyEvent) -> Effect {
220 match &self.mode {
221 Mode::List => self.key_list(key),
222 Mode::Filter => self.key_filter(key),
223 Mode::Create(_) => self.key_create(key),
224 Mode::PrPicker(_) => self.key_pr(key),
225 Mode::PrCompose(_) => self.key_compose(key),
226 Mode::Checkout(_) => self.key_checkout_picker(key),
227 Mode::ConfirmRemove(_) => self.key_confirm(key),
228 Mode::ConfirmCreate(_) => self.key_confirm_create(key),
229 Mode::ConfirmDeleteBranch { .. } => self.key_confirm_delete_branch(key),
230 Mode::ConfirmStaleBase(_) => self.key_confirm_stale_base(key),
231 Mode::ConfirmInitSubmodules(_) => self.key_confirm_init_submodules(key),
232 Mode::Help => {
233 self.mode = Mode::List;
234 Effect::None
235 }
236 }
237 }
238
239 fn key_list(&mut self, key: KeyEvent) -> Effect {
241 let Some(action) = self.keymap.action_for(KeyChord::from_event(key)) else {
242 return Effect::None;
243 };
244 let page = (self.size.1 as isize - 3).max(1);
245 match action {
246 KeyAction::NavigateUp => self.nav_or_scroll(-1),
247 KeyAction::NavigateDown => self.nav_or_scroll(1),
248 KeyAction::PageUp => self.nav_or_scroll(-page),
249 KeyAction::PageDown => self.nav_or_scroll(page),
250 KeyAction::GoToTop => self.select_edge(false),
251 KeyAction::GoToBottom => self.select_edge(true),
252 KeyAction::FocusNextPane | KeyAction::FocusPrevPane => self.toggle_focus(),
253 KeyAction::Switch => {
254 if let Some(&index) = self.visible.get(self.selected) {
255 let wt = &self.worktrees[index];
256 if wt.has_worktree {
257 let path = wt.path.clone();
258 self.chosen = Some(path.clone());
259 return Effect::Switch(path);
260 }
261 self.mode = Mode::ConfirmCreate(index);
264 }
265 }
266 KeyAction::Filter => self.mode = Mode::Filter,
267 KeyAction::ClearFilter => self.clear_filter(),
268 KeyAction::New => {
269 let options = crate::tui::OptionList::new(self.branches.clone());
272 self.mode = Mode::Create(CreateState {
276 base: self.default_base.clone().unwrap_or_default(),
277 options,
278 ..Default::default()
279 });
280 }
281 KeyAction::Remove => {
282 if let Some(&index) = self.visible.get(self.selected) {
286 self.mode = if self.worktrees[index].has_worktree {
287 Mode::ConfirmRemove(index)
288 } else {
289 Mode::ConfirmDeleteBranch {
290 index,
291 force: false,
292 }
293 };
294 }
295 }
296 KeyAction::PrCheckout => {
297 self.mode = Mode::PrPicker(crate::tui::app::PrPickerState {
298 loading: true,
299 ..Default::default()
300 });
301 return Effect::FetchPrs;
302 }
303 KeyAction::Checkout => {
304 if let Some(&index) = self.visible.get(self.selected)
309 && self.worktrees[index].has_worktree
310 {
311 let mut options = crate::tui::OptionList::new(self.branches.clone());
315 options.open();
316 self.mode = Mode::Checkout(crate::tui::app::CheckoutState {
317 worktree_index: index,
318 options,
319 ..Default::default()
320 });
321 }
322 }
323 KeyAction::Sync => {
324 if let Some(&index) = self.visible.get(self.selected) {
329 return Effect::Sync {
330 worktree_index: index,
331 };
332 }
333 }
334 KeyAction::OpenEditor => {
335 if let Some(wt) = self.selected_worktree()
337 && wt.has_worktree
338 {
339 return Effect::OpenEditor(wt.path.clone());
340 }
341 }
342 KeyAction::Refresh => return Effect::Refresh,
343 KeyAction::SortCycle => self.cycle_sort(),
344 KeyAction::SortReverse => self.reverse_sort(),
345 KeyAction::Help => self.mode = Mode::Help,
346 KeyAction::Quit => {
347 self.quit = true;
348 return Effect::Quit;
349 }
350 KeyAction::ToggleSidebar => self.show_sidebar = !self.show_sidebar,
351 KeyAction::ResizeSidebarGrow => {
352 self.sidebar_width = (self.sidebar_width + 1).min(MAX_SIDEBAR);
353 }
354 KeyAction::ResizeSidebarShrink => {
355 self.sidebar_width = self.sidebar_width.saturating_sub(1).max(MIN_SIDEBAR);
356 }
357 }
358 Effect::None
359 }
360
361 fn key_filter(&mut self, key: KeyEvent) -> Effect {
363 match key.code {
364 KeyCode::Char(c) => self.filter_push(c),
365 KeyCode::Backspace => self.filter_pop(),
366 KeyCode::Enter => self.mode = Mode::List, KeyCode::Esc => {
368 self.clear_filter();
369 self.mode = Mode::List;
370 }
371 KeyCode::Up => self.move_selection(-1),
372 KeyCode::Down => self.move_selection(1),
373 _ => {}
374 }
375 Effect::None
376 }
377
378 fn key_create(&mut self, key: KeyEvent) -> Effect {
383 let Mode::Create(state) = &mut self.mode else {
384 return Effect::None;
385 };
386 match key.code {
387 KeyCode::Char(c) => {
388 state.field_mut().push(c);
389 state.error = None;
390 state.refresh_options();
391 }
392 KeyCode::Backspace => {
393 state.field_mut().pop();
394 state.refresh_options();
395 }
396 KeyCode::Up => state.options.up(),
397 KeyCode::Down => state.options.down(),
398 KeyCode::Tab => {
399 if state.step == CreateStep::Base {
400 complete_base_ref(state, &self.branches);
401 state.refresh_options();
402 }
403 }
404 KeyCode::Esc => {
405 if state.options.is_open() {
406 state.options.close();
407 } else {
408 self.mode = Mode::List;
409 }
410 }
411 KeyCode::Enter => {
412 if let Some(selected) = state.options.selected().map(str::to_owned) {
415 *state.field_mut() = selected;
416 state.options.close();
417 } else {
418 match state.step {
419 CreateStep::Branch => {
420 let branch = state.branch.trim();
421 if branch.is_empty() {
422 state.error = Some("branch name is required".into());
423 } else if let Err(msg) = crate::git::validate_branch_name(branch) {
424 state.error = Some(msg);
425 } else {
426 state.step = CreateStep::Base;
427 state.refresh_options();
429 }
430 }
431 CreateStep::Base => {
432 let branch = state.branch.clone();
433 let base = (!state.base.trim().is_empty()).then(|| state.base.clone());
434 return Effect::Create {
437 branch,
438 base,
439 decision: None,
440 };
441 }
442 }
443 }
444 }
445 _ => {}
446 }
447 Effect::None
448 }
449
450 fn key_pr(&mut self, key: KeyEvent) -> Effect {
452 let Mode::PrPicker(state) = &mut self.mode else {
453 return Effect::None;
454 };
455 match key.code {
456 KeyCode::Up => state.selected = state.selected.saturating_sub(1),
457 KeyCode::Down => {
458 state.selected = (state.selected + 1).min(state.prs.len().saturating_sub(1));
459 }
460 KeyCode::Enter => {
461 if let Some(pr) = state.prs.get(state.selected) {
462 return Effect::CheckoutPr(pr.number);
463 }
464 }
465 KeyCode::Esc => self.mode = Mode::List,
466 _ => {}
467 }
468 Effect::None
469 }
470
471 fn key_compose(&mut self, key: KeyEvent) -> Effect {
478 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
479 let Mode::PrCompose(state) = &mut self.mode else {
480 return Effect::None;
481 };
482 if state.submitting {
484 return Effect::None;
485 }
486 match key.code {
487 KeyCode::Char('s') if ctrl => {
488 if state.title.trim().is_empty() {
489 state.error = Some("a PR title is required".into());
490 } else {
491 state.submitting = true;
492 return Effect::SubmitPr {
493 title: state.title.clone(),
494 body: state.body.clone(),
495 draft: state.draft,
496 };
497 }
498 }
499 KeyCode::Char('a') if ctrl => {
501 state.submitting = true;
502 state.error = None;
503 return Effect::DraftPrAi;
504 }
505 KeyCode::Char('m') if ctrl => state.model = state.model.next(),
507 KeyCode::Char('e') if ctrl => state.effort = state.effort.next(),
508 KeyCode::Char('d') if ctrl => state.draft = !state.draft,
509 KeyCode::Char(c) if !ctrl => {
511 match state.field {
512 ComposeField::Title => state.title.push(c),
513 ComposeField::Body => state.body.push(c),
514 ComposeField::Model | ComposeField::Effort => {}
515 }
516 state.error = None;
517 }
518 KeyCode::Backspace => {
519 match state.field {
520 ComposeField::Title => state.title.pop(),
521 ComposeField::Body => state.body.pop(),
522 ComposeField::Model | ComposeField::Effort => None,
523 };
524 state.error = None;
525 }
526 KeyCode::Up => match state.field {
528 ComposeField::Model => state.model = state.model.prev(),
529 ComposeField::Effort => state.effort = state.effort.prev(),
530 _ => {}
531 },
532 KeyCode::Down => match state.field {
533 ComposeField::Model => state.model = state.model.next(),
534 ComposeField::Effort => state.effort = state.effort.next(),
535 _ => {}
536 },
537 KeyCode::Tab => state.field = compose_next_field(state.field),
538 KeyCode::BackTab => state.field = compose_prev_field(state.field),
539 KeyCode::Enter => match state.field {
540 ComposeField::Title => state.field = ComposeField::Body,
541 ComposeField::Body => state.body.push('\n'),
542 ComposeField::Model => state.field = ComposeField::Effort,
544 ComposeField::Effort => state.field = ComposeField::Title,
545 },
546 KeyCode::Esc => self.mode = Mode::List,
547 _ => {}
548 }
549 Effect::None
550 }
551
552 fn key_checkout_picker(&mut self, key: KeyEvent) -> Effect {
557 let Mode::Checkout(state) = &mut self.mode else {
558 return Effect::None;
559 };
560 if state.submitting {
562 return Effect::None;
563 }
564 match key.code {
565 KeyCode::Char(c) => {
566 state.query.push(c);
567 state.error = None;
568 state.options.refilter(&state.query);
569 state.options.open();
570 }
571 KeyCode::Backspace => {
572 state.query.pop();
573 state.error = None;
574 state.options.refilter(&state.query);
575 state.options.open();
576 }
577 KeyCode::Up => state.options.up(),
578 KeyCode::Down => state.options.down(),
579 KeyCode::Esc => {
580 if state.options.is_open() {
581 state.options.close();
582 } else {
583 self.mode = Mode::List;
584 }
585 }
586 KeyCode::Enter => {
587 let branch = state
590 .options
591 .selected()
592 .map(str::to_owned)
593 .unwrap_or_else(|| state.query.trim().to_string());
594 if branch.is_empty() {
595 state.error = Some("branch name is required".into());
596 } else {
597 let worktree_index = state.worktree_index;
598 return Effect::CheckoutBranch {
599 worktree_index,
600 branch,
601 };
602 }
603 }
604 _ => {}
605 }
606 Effect::None
607 }
608
609 fn key_confirm(&mut self, key: KeyEvent) -> Effect {
611 let Mode::ConfirmRemove(index) = self.mode else {
612 return Effect::None;
613 };
614 if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
615 self.mode = Mode::List;
616 Effect::Remove(index)
617 } else {
618 self.mode = Mode::List;
619 Effect::None
620 }
621 }
622
623 fn key_confirm_create(&mut self, key: KeyEvent) -> Effect {
626 let Mode::ConfirmCreate(index) = self.mode else {
627 return Effect::None;
628 };
629 self.mode = Mode::List;
630 if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y'))
631 && let Some(branch) = self.worktrees.get(index).and_then(|w| w.branch.clone())
632 {
633 return Effect::MaterializeBranch { branch };
634 }
635 Effect::None
636 }
637
638 fn key_confirm_delete_branch(&mut self, key: KeyEvent) -> Effect {
643 let Mode::ConfirmDeleteBranch { index, force } = self.mode else {
644 return Effect::None;
645 };
646 self.mode = Mode::List;
647 if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y'))
648 && let Some(branch) = self.worktrees.get(index).and_then(|w| w.branch.clone())
649 {
650 return Effect::DeleteBranch { branch, force };
651 }
652 Effect::None
653 }
654
655 fn key_confirm_stale_base(&mut self, key: KeyEvent) -> Effect {
659 let Mode::ConfirmStaleBase(state) = &self.mode else {
660 return Effect::None;
661 };
662 let branch = state.branch.clone();
663 let base = state.base.clone();
664 self.mode = Mode::List;
665 let decision = match key.code {
666 KeyCode::Char('u') | KeyCode::Char('U') => CreateDecision::Update,
667 KeyCode::Char('p') | KeyCode::Char('P') => CreateDecision::Proceed,
668 _ => return Effect::None,
669 };
670 Effect::Create {
671 branch,
672 base,
673 decision: Some(decision),
674 }
675 }
676
677 fn key_confirm_init_submodules(&mut self, key: KeyEvent) -> Effect {
681 let Mode::ConfirmInitSubmodules(state) = &self.mode else {
682 return Effect::None;
683 };
684 match key.code {
685 KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => {
686 let dir = state.dir.clone();
687 let count = state.count;
688 self.mode = Mode::List;
689 Effect::InitSubmodules { dir, count }
690 }
691 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
692 self.mode = Mode::List;
693 Effect::None
694 }
695 _ => Effect::None,
696 }
697 }
698
699 fn on_mouse(&mut self, mouse: MouseEvent) -> Effect {
701 if !matches!(self.mode, Mode::List | Mode::Filter) {
706 match mouse.kind {
707 MouseEventKind::ScrollUp => self.modal_scroll(-1),
708 MouseEventKind::ScrollDown => self.modal_scroll(1),
709 _ => {}
710 }
711 return Effect::None;
712 }
713 match mouse.kind {
714 MouseEventKind::Down(MouseButton::Left) => {
715 let status_row = self.size.1.saturating_sub(1);
717 if mouse.row >= status_row {
718 return Effect::None;
719 }
720 if self.show_sidebar && mouse.column < self.sidebar_width {
721 if mouse.row >= LIST_TOP {
724 self.select_row((mouse.row - LIST_TOP) as usize);
725 }
726 self.focus = Pane::List;
727 } else {
728 self.focus = Pane::Detail;
729 }
730 }
731 MouseEventKind::ScrollUp => self.nav_or_scroll(-1),
732 MouseEventKind::ScrollDown => self.nav_or_scroll(1),
733 _ => {}
734 }
735 Effect::None
736 }
737
738 fn modal_scroll(&mut self, delta: isize) {
743 let up = delta < 0;
744 match &mut self.mode {
745 Mode::Create(state) => {
746 if up {
747 state.options.up();
748 } else {
749 state.options.down();
750 }
751 }
752 Mode::Checkout(state) => {
753 if up {
754 state.options.up();
755 } else {
756 state.options.down();
757 }
758 }
759 Mode::PrPicker(state) => {
760 if up {
761 state.selected = state.selected.saturating_sub(1);
762 } else {
763 state.selected = (state.selected + 1).min(state.prs.len().saturating_sub(1));
764 }
765 }
766 _ => {}
767 }
768 }
769
770 fn nav_or_scroll(&mut self, delta: isize) {
773 if self.focus == Pane::Detail {
774 self.scroll_detail(delta);
775 } else {
776 self.move_selection(delta);
777 }
778 }
779
780 fn toggle_focus(&mut self) {
782 self.focus = match self.focus {
783 Pane::List => Pane::Detail,
784 Pane::Detail => Pane::List,
785 };
786 }
787}
788
789#[cfg(test)]
790mod tests {
791 use super::*;
792 use crate::tui::app::testutil::app;
793 use crossterm::event::{KeyModifiers, MouseButton};
794
795 fn press(code: KeyCode) -> Event {
796 Event::Key(KeyEvent::new(code, KeyModifiers::empty()))
797 }
798
799 fn ctrl(c: char) -> Event {
800 Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL))
801 }
802
803 #[test]
804 fn navigation_keys() {
805 let mut a = app(&[("a", true), ("b", false), ("c", false)]);
806 a.selected = 0;
807 assert_eq!(a.handle_event(press(KeyCode::Char('j'))), Effect::None);
808 assert_eq!(a.selected, 1);
809 a.handle_event(press(KeyCode::Char('k')));
810 assert_eq!(a.selected, 0);
811 a.handle_event(press(KeyCode::Char('G')));
812 assert_eq!(a.selected, 2);
813 a.handle_event(press(KeyCode::Char('g')));
814 assert_eq!(a.selected, 0);
815 a.handle_event(ctrl('d')); assert!(a.selected >= 1 || a.visible.len() == 1);
817 }
818
819 #[test]
820 fn enter_switches_to_selected() {
821 let mut a = app(&[("main", true), ("feat", false)]);
822 a.selected = 1;
823 let effect = a.handle_event(press(KeyCode::Enter));
824 assert_eq!(effect, Effect::Switch(std::path::PathBuf::from("/r/feat")));
825 assert_eq!(a.chosen, Some(std::path::PathBuf::from("/r/feat")));
826 }
827
828 #[test]
829 fn enter_on_branch_row_opens_confirm_create() {
830 use crate::tui::app::testutil::branch_row;
831 let mut a = app(&[("main", true)]);
832 a.worktrees.push(branch_row("topic"));
833 a.apply_filter(String::new()); a.selected = a.visible.len() - 1; let effect = a.handle_event(press(KeyCode::Enter));
836 assert_eq!(effect, Effect::None);
837 assert!(matches!(a.mode, Mode::ConfirmCreate(_)));
838 assert!(a.chosen.is_none()); }
840
841 #[test]
842 fn confirm_create_y_materializes_other_cancels() {
843 use crate::tui::app::testutil::branch_row;
844 let mut a = app(&[("main", true)]);
845 a.worktrees.push(branch_row("topic"));
846 let idx = a
847 .worktrees
848 .iter()
849 .position(|w| w.branch.as_deref() == Some("topic"))
850 .unwrap();
851 a.mode = Mode::ConfirmCreate(idx);
852 let effect = a.handle_event(press(KeyCode::Char('y')));
853 assert_eq!(
854 effect,
855 Effect::MaterializeBranch {
856 branch: "topic".into()
857 }
858 );
859 assert_eq!(a.mode, Mode::List);
860 a.mode = Mode::ConfirmCreate(idx);
862 let effect = a.handle_event(press(KeyCode::Char('n')));
863 assert_eq!(effect, Effect::None);
864 assert_eq!(a.mode, Mode::List);
865 }
866
867 #[test]
868 fn checkout_and_open_editor_are_noops_on_branch_rows() {
869 use crate::tui::app::testutil::branch_row;
872 let mut a = app(&[("main", true)]);
873 a.worktrees.push(branch_row("topic"));
874 a.apply_filter(String::new());
875 a.selected = a.visible.len() - 1; assert_eq!(a.handle_event(press(KeyCode::Char('c'))), Effect::None);
877 assert_eq!(a.mode, Mode::List);
878 assert_eq!(a.handle_event(press(KeyCode::Char('o'))), Effect::None);
879 assert_eq!(a.mode, Mode::List);
880 }
881
882 #[test]
883 fn sync_acts_on_worktree_rows_and_branch_rows() {
884 use crate::tui::app::testutil::branch_row;
885 let mut a = app(&[("main", true), ("feat", false)]);
886 a.selected = 1;
888 assert_eq!(
889 a.handle_event(press(KeyCode::Char('y'))),
890 Effect::Sync { worktree_index: 1 }
891 );
892 a.worktrees.push(branch_row("topic"));
895 a.apply_filter(String::new());
896 let idx = a
897 .worktrees
898 .iter()
899 .position(|w| w.branch.as_deref() == Some("topic"))
900 .unwrap();
901 a.selected = a.visible.iter().position(|&i| i == idx).unwrap();
902 assert_eq!(
903 a.handle_event(press(KeyCode::Char('y'))),
904 Effect::Sync {
905 worktree_index: idx
906 }
907 );
908 assert_eq!(a.mode, Mode::List);
909 }
910
911 #[test]
912 fn remove_on_branch_row_confirms_then_deletes_branch() {
913 use crate::tui::app::testutil::branch_row;
916 let mut a = app(&[("main", true)]);
917 a.worktrees.push(branch_row("topic"));
918 a.apply_filter(String::new());
919 a.selected = a.visible.len() - 1; assert_eq!(a.handle_event(press(KeyCode::Char('d'))), Effect::None);
921 assert!(matches!(
922 a.mode,
923 Mode::ConfirmDeleteBranch { force: false, .. }
924 ));
925 let effect = a.handle_event(press(KeyCode::Char('y')));
926 assert_eq!(
927 effect,
928 Effect::DeleteBranch {
929 branch: "topic".into(),
930 force: false,
931 }
932 );
933 assert_eq!(a.mode, Mode::List);
934 a.mode = Mode::ConfirmDeleteBranch {
936 index: a.visible[a.selected],
937 force: true,
938 };
939 assert_eq!(a.handle_event(press(KeyCode::Char('n'))), Effect::None);
940 assert_eq!(a.mode, Mode::List);
941 }
942
943 #[test]
944 fn confirm_stale_base_keys_reissue_create_or_cancel() {
945 use crate::tui::app::StaleBaseState;
946 let state = StaleBaseState {
947 branch: "feature".into(),
948 base: Some("main".into()),
949 behind: 2,
950 upstream_display: "origin/main".into(),
951 can_fast_forward: true,
952 };
953 let mut a = app(&[("main", true)]);
954 a.mode = Mode::ConfirmStaleBase(state.clone());
956 assert_eq!(
957 a.handle_event(press(KeyCode::Char('u'))),
958 Effect::Create {
959 branch: "feature".into(),
960 base: Some("main".into()),
961 decision: Some(CreateDecision::Update),
962 }
963 );
964 assert_eq!(a.mode, Mode::List);
965 a.mode = Mode::ConfirmStaleBase(state.clone());
967 assert_eq!(
968 a.handle_event(press(KeyCode::Char('p'))),
969 Effect::Create {
970 branch: "feature".into(),
971 base: Some("main".into()),
972 decision: Some(CreateDecision::Proceed),
973 }
974 );
975 a.mode = Mode::ConfirmStaleBase(state);
977 assert_eq!(a.handle_event(press(KeyCode::Esc)), Effect::None);
978 assert_eq!(a.mode, Mode::List);
979 }
980
981 #[test]
982 fn confirm_init_submodules_keys_init_or_skip() {
983 use crate::tui::app::InitSubmodulesState;
984 let state = InitSubmodulesState {
985 dir: PathBuf::from("/wt/feature"),
986 branch: "feature".into(),
987 count: 2,
988 };
989 let mut a = app(&[("main", true)]);
990 a.mode = Mode::ConfirmInitSubmodules(state.clone());
992 assert_eq!(
993 a.handle_event(press(KeyCode::Enter)),
994 Effect::InitSubmodules {
995 dir: PathBuf::from("/wt/feature"),
996 count: 2,
997 }
998 );
999 assert_eq!(a.mode, Mode::List);
1000 a.mode = Mode::ConfirmInitSubmodules(state.clone());
1002 assert_eq!(
1003 a.handle_event(press(KeyCode::Char('y'))),
1004 Effect::InitSubmodules {
1005 dir: PathBuf::from("/wt/feature"),
1006 count: 2,
1007 }
1008 );
1009 a.mode = Mode::ConfirmInitSubmodules(state.clone());
1011 assert_eq!(a.handle_event(press(KeyCode::Char('n'))), Effect::None);
1012 assert_eq!(a.mode, Mode::List);
1013 a.mode = Mode::ConfirmInitSubmodules(state.clone());
1015 assert_eq!(a.handle_event(press(KeyCode::Esc)), Effect::None);
1016 assert_eq!(a.mode, Mode::List);
1017 a.mode = Mode::ConfirmInitSubmodules(state);
1019 assert_eq!(a.handle_event(press(KeyCode::Char('x'))), Effect::None);
1020 assert!(matches!(a.mode, Mode::ConfirmInitSubmodules(_)));
1021 }
1022
1023 #[test]
1024 fn quit_returns_quit() {
1025 let mut a = app(&[("a", true)]);
1026 assert_eq!(a.handle_event(press(KeyCode::Char('q'))), Effect::Quit);
1027 assert!(a.quit);
1028 }
1029
1030 #[test]
1031 fn filter_mode_typing_and_escape() {
1032 let mut a = app(&[("alpha", true), ("beta", false)]);
1033 a.handle_event(press(KeyCode::Char('/')));
1034 assert_eq!(a.mode, Mode::Filter);
1035 a.handle_event(press(KeyCode::Char('a')));
1036 a.handle_event(press(KeyCode::Char('l')));
1037 assert_eq!(a.filter, "al");
1038 assert_eq!(a.visible.len(), 1); a.handle_event(press(KeyCode::Enter)); assert_eq!(a.mode, Mode::List);
1041 assert_eq!(a.filter, "al"); a.handle_event(press(KeyCode::Char('/')));
1043 a.handle_event(press(KeyCode::Esc)); assert_eq!(a.mode, Mode::List);
1045 assert_eq!(a.filter, "");
1046 }
1047
1048 #[test]
1049 fn create_mode_flow() {
1050 let mut a = app(&[("a", true)]);
1051 a.handle_event(press(KeyCode::Char('n')));
1052 assert!(matches!(a.mode, Mode::Create(_)));
1053 a.handle_event(press(KeyCode::Enter));
1055 if let Mode::Create(s) = &a.mode {
1056 assert!(s.error.is_some());
1057 } else {
1058 panic!("expected create mode");
1059 }
1060 for c in "feature/x".chars() {
1062 a.handle_event(press(KeyCode::Char(c)));
1063 }
1064 a.handle_event(press(KeyCode::Enter));
1065 if let Mode::Create(s) = &a.mode {
1066 assert_eq!(s.step, CreateStep::Base);
1067 assert_eq!(s.branch, "feature/x");
1068 }
1069 let effect = a.handle_event(press(KeyCode::Enter));
1071 assert_eq!(
1072 effect,
1073 Effect::Create {
1074 branch: "feature/x".into(),
1075 base: None,
1076 decision: None,
1077 }
1078 );
1079 }
1080
1081 #[test]
1082 fn create_mode_prefills_default_base() {
1083 let mut a = app(&[("main", true)]);
1086 a.branches = vec!["main".into(), "origin/main".into()];
1087 a.default_base = Some("origin/main".into());
1088 a.handle_event(press(KeyCode::Char('n')));
1089 if let Mode::Create(s) = &a.mode {
1090 assert_eq!(s.base, "origin/main");
1091 assert_eq!(s.step, CreateStep::Branch); } else {
1093 panic!("expected create mode");
1094 }
1095 }
1096
1097 #[test]
1098 fn create_mode_base_empty_without_default() {
1099 let mut a = app(&[("main", true)]);
1102 assert!(a.default_base.is_none());
1103 a.handle_event(press(KeyCode::Char('n')));
1104 for c in "feature/x".chars() {
1105 a.handle_event(press(KeyCode::Char(c)));
1106 }
1107 a.handle_event(press(KeyCode::Enter)); if let Mode::Create(s) = &a.mode {
1109 assert_eq!(s.base, "");
1110 } else {
1111 panic!("expected create mode");
1112 }
1113 assert_eq!(
1114 a.handle_event(press(KeyCode::Enter)),
1115 Effect::Create {
1116 branch: "feature/x".into(),
1117 base: None,
1118 decision: None,
1119 }
1120 );
1121 }
1122
1123 #[test]
1124 fn create_mode_rejects_invalid_branch_name() {
1125 let mut a = app(&[("a", true)]);
1126 a.handle_event(press(KeyCode::Char('n')));
1127 for c in "feat..x".chars() {
1128 a.handle_event(press(KeyCode::Char(c)));
1129 }
1130 a.handle_event(press(KeyCode::Enter));
1132 if let Mode::Create(s) = &a.mode {
1133 assert_eq!(s.step, CreateStep::Branch);
1134 assert!(s.error.as_deref().unwrap().contains("invalid branch name"));
1135 } else {
1136 panic!("expected create mode");
1137 }
1138 a.handle_event(press(KeyCode::Char('y')));
1140 if let Mode::Create(s) = &a.mode {
1141 assert!(s.error.is_none());
1142 }
1143 if let Mode::Create(s) = &mut a.mode {
1145 s.branch = "feature/x".into();
1146 }
1147 a.handle_event(press(KeyCode::Enter));
1148 if let Mode::Create(s) = &a.mode {
1149 assert_eq!(s.step, CreateStep::Base);
1150 } else {
1151 panic!("expected create mode");
1152 }
1153 }
1154
1155 #[test]
1156 fn create_mode_tab_completes_base_ref() {
1157 let mut a = app(&[("a", true)]);
1158 a.branches = vec!["feature/alpha".into(), "feature/beta".into(), "main".into()];
1159 a.handle_event(press(KeyCode::Char('n')));
1160 for c in "topic".chars() {
1161 a.handle_event(press(KeyCode::Char(c)));
1162 }
1163 a.handle_event(press(KeyCode::Enter)); for c in "feat".chars() {
1166 a.handle_event(press(KeyCode::Char(c)));
1167 }
1168 a.handle_event(press(KeyCode::Tab));
1169 if let Mode::Create(s) = &a.mode {
1170 assert_eq!(s.base, "feature/");
1171 } else {
1172 panic!("expected create mode");
1173 }
1174 a.handle_event(press(KeyCode::Char('a')));
1176 a.handle_event(press(KeyCode::Tab));
1177 if let Mode::Create(s) = &a.mode {
1178 assert_eq!(s.base, "feature/alpha");
1179 }
1180 }
1181
1182 #[test]
1183 fn create_mode_tab_noop_without_candidates() {
1184 let mut a = app(&[("a", true)]);
1185 a.handle_event(press(KeyCode::Char('n')));
1186 for c in "feature/x".chars() {
1187 a.handle_event(press(KeyCode::Char(c)));
1188 }
1189 a.handle_event(press(KeyCode::Enter)); for c in "xyz".chars() {
1191 a.handle_event(press(KeyCode::Char(c)));
1192 }
1193 a.handle_event(press(KeyCode::Tab)); if let Mode::Create(s) = &a.mode {
1195 assert_eq!(s.base, "xyz");
1196 }
1197 let mut b = app(&[("a", true)]);
1199 b.branches = vec!["main".into()];
1200 b.handle_event(press(KeyCode::Char('n')));
1201 b.handle_event(press(KeyCode::Tab));
1202 if let Mode::Create(s) = &b.mode {
1203 assert!(s.branch.is_empty());
1204 }
1205 }
1206
1207 #[test]
1208 fn longest_common_prefix_cases() {
1209 assert_eq!(longest_common_prefix(&[]), None);
1210 assert_eq!(longest_common_prefix(&["solo"]).as_deref(), Some("solo"));
1211 assert_eq!(
1212 longest_common_prefix(&["feature/a", "feature/b"]).as_deref(),
1213 Some("feature/")
1214 );
1215 assert_eq!(longest_common_prefix(&["abc", "xyz"]).as_deref(), Some(""));
1216 }
1217
1218 #[test]
1219 fn create_mode_escape_cancels() {
1220 let mut a = app(&[("a", true)]);
1221 a.handle_event(press(KeyCode::Char('n')));
1222 a.handle_event(press(KeyCode::Esc));
1223 assert_eq!(a.mode, Mode::List);
1224 }
1225
1226 #[test]
1227 fn create_mode_dropdown_filters_navigates_and_accepts() {
1228 let mut a = app(&[("a", true)]);
1229 a.branches = vec!["main".into(), "origin/main".into(), "origin/dev".into()];
1230 a.handle_event(press(KeyCode::Char('n')));
1231 for c in "feature/login".chars() {
1234 a.handle_event(press(KeyCode::Char(c)));
1235 }
1236 a.handle_event(press(KeyCode::Enter));
1237 if let Mode::Create(s) = &a.mode {
1238 assert_eq!(s.step, CreateStep::Base);
1239 assert!(s.options.is_open());
1241 } else {
1242 panic!("expected create mode");
1243 }
1244 for c in "origin".chars() {
1246 a.handle_event(press(KeyCode::Char(c)));
1247 }
1248 a.handle_event(press(KeyCode::Down));
1251 a.handle_event(press(KeyCode::Enter));
1252 if let Mode::Create(s) = &a.mode {
1253 assert_eq!(s.base, "origin/dev");
1254 assert!(!s.options.is_open()); }
1256 let effect = a.handle_event(press(KeyCode::Enter));
1258 assert_eq!(
1259 effect,
1260 Effect::Create {
1261 branch: "feature/login".into(),
1262 base: Some("origin/dev".into()),
1263 decision: None,
1264 }
1265 );
1266 }
1267
1268 #[test]
1269 fn create_mode_escape_closes_dropdown_before_modal() {
1270 let mut a = app(&[("a", true)]);
1271 a.branches = vec!["main".into()];
1272 a.handle_event(press(KeyCode::Char('n')));
1273 a.handle_event(press(KeyCode::Char('m'))); if let Mode::Create(s) = &a.mode {
1275 assert!(s.options.is_open());
1276 }
1277 a.handle_event(press(KeyCode::Esc)); if let Mode::Create(s) = &a.mode {
1279 assert!(!s.options.is_open());
1280 } else {
1281 panic!("expected create mode (still open)");
1282 }
1283 a.handle_event(press(KeyCode::Esc)); assert_eq!(a.mode, Mode::List);
1285 }
1286
1287 #[test]
1288 fn confirm_remove_y_removes() {
1289 let mut a = app(&[("main", true), ("feat", false)]);
1290 a.selected = 1;
1291 a.handle_event(press(KeyCode::Char('d')));
1292 assert!(matches!(a.mode, Mode::ConfirmRemove(_)));
1293 let effect = a.handle_event(press(KeyCode::Char('y')));
1294 assert!(matches!(effect, Effect::Remove(_)));
1296 assert_eq!(a.mode, Mode::List);
1297 }
1298
1299 #[test]
1300 fn confirm_remove_other_key_cancels() {
1301 let mut a = app(&[("main", true), ("feat", false)]);
1302 a.selected = 1;
1303 a.handle_event(press(KeyCode::Char('d')));
1304 let effect = a.handle_event(press(KeyCode::Char('n')));
1305 assert_eq!(effect, Effect::None);
1306 assert_eq!(a.mode, Mode::List);
1307 }
1308
1309 #[test]
1310 fn pr_picker_opens_and_fetches() {
1311 let mut a = app(&[("a", true)]);
1312 let effect = a.handle_event(press(KeyCode::Char('p')));
1313 assert_eq!(effect, Effect::FetchPrs);
1314 assert!(matches!(a.mode, Mode::PrPicker(_)));
1315 if let Mode::PrPicker(s) = &mut a.mode {
1317 s.loading = false;
1318 s.prs = vec![
1319 crate::tui::app::PrItem {
1320 number: 7,
1321 title: "x".into(),
1322 author: "a".into(),
1323 state: "open".into(),
1324 created_at: String::new(),
1325 },
1326 crate::tui::app::PrItem {
1327 number: 9,
1328 title: "y".into(),
1329 author: "b".into(),
1330 state: "open".into(),
1331 created_at: String::new(),
1332 },
1333 ];
1334 }
1335 a.handle_event(press(KeyCode::Down));
1336 let effect = a.handle_event(press(KeyCode::Enter));
1337 assert_eq!(effect, Effect::CheckoutPr(9));
1338 }
1339
1340 #[test]
1341 fn checkout_key_opens_picker_for_selected_worktree() {
1342 let mut a = app(&[("main", true), ("feature/x", false)]);
1343 a.branches = vec!["main".into(), "feature/x".into()];
1344 a.selected = 1; a.handle_event(press(KeyCode::Char('c')));
1346 if let Mode::Checkout(s) = &a.mode {
1347 assert_eq!(s.worktree_index, a.visible[1]);
1349 assert_eq!(s.options.match_count(), 2);
1350 assert!(s.options.is_open());
1352 } else {
1353 panic!("expected checkout mode");
1354 }
1355 }
1356
1357 #[test]
1358 fn checkout_picker_arrows_select_a_branch_without_typing() {
1359 let mut a = app(&[("main", true)]);
1362 a.branches = vec!["main".into(), "origin/feature/x".into()];
1363 a.handle_event(press(KeyCode::Char('c')));
1364 a.handle_event(press(KeyCode::Down)); let effect = a.handle_event(press(KeyCode::Enter));
1366 assert_eq!(
1367 effect,
1368 Effect::CheckoutBranch {
1369 worktree_index: 0,
1370 branch: "origin/feature/x".into(),
1371 }
1372 );
1373 }
1374
1375 #[test]
1376 fn checkout_picker_submits_typed_branch() {
1377 let mut a = app(&[("main", true)]);
1378 a.branches = vec!["main".into(), "feature/x".into()];
1379 a.handle_event(press(KeyCode::Char('c')));
1380 for ch in "feature/x".chars() {
1381 a.handle_event(press(KeyCode::Char(ch)));
1382 }
1383 let effect = a.handle_event(press(KeyCode::Enter));
1385 assert_eq!(
1386 effect,
1387 Effect::CheckoutBranch {
1388 worktree_index: 0,
1389 branch: "feature/x".into(),
1390 }
1391 );
1392 }
1393
1394 #[test]
1395 fn checkout_picker_submits_highlighted_suggestion() {
1396 let mut a = app(&[("main", true)]);
1397 a.branches = vec!["main".into(), "feature/x".into(), "feature/y".into()];
1398 a.handle_event(press(KeyCode::Char('c')));
1399 for ch in "feature".chars() {
1400 a.handle_event(press(KeyCode::Char(ch)));
1401 }
1402 a.handle_event(press(KeyCode::Down));
1404 let effect = a.handle_event(press(KeyCode::Enter));
1405 assert_eq!(
1406 effect,
1407 Effect::CheckoutBranch {
1408 worktree_index: 0,
1409 branch: "feature/y".into(),
1410 }
1411 );
1412 }
1413
1414 #[test]
1415 fn checkout_picker_empty_query_errors() {
1416 let mut a = app(&[("main", true)]);
1417 a.handle_event(press(KeyCode::Char('c')));
1418 let effect = a.handle_event(press(KeyCode::Enter));
1419 assert_eq!(effect, Effect::None);
1420 if let Mode::Checkout(s) = &a.mode {
1421 assert!(s.error.is_some());
1422 } else {
1423 panic!("expected checkout mode (still open)");
1424 }
1425 }
1426
1427 #[test]
1428 fn checkout_picker_escape_closes_dropdown_then_cancels() {
1429 let mut a = app(&[("main", true)]);
1430 a.branches = vec!["main".into()];
1431 a.handle_event(press(KeyCode::Char('c')));
1432 a.handle_event(press(KeyCode::Char('m'))); if let Mode::Checkout(s) = &a.mode {
1434 assert!(s.options.is_open());
1435 }
1436 a.handle_event(press(KeyCode::Esc)); if let Mode::Checkout(s) = &a.mode {
1438 assert!(!s.options.is_open());
1439 } else {
1440 panic!("expected checkout mode (still open)");
1441 }
1442 a.handle_event(press(KeyCode::Esc)); assert_eq!(a.mode, Mode::List);
1444 }
1445
1446 #[test]
1447 fn compose_typing_field_switch_and_newline() {
1448 use crate::tui::app::PrComposeState;
1449 let mut a = app(&[("a", true)]);
1450 a.mode = Mode::PrCompose(PrComposeState::default());
1451 a.handle_event(press(KeyCode::Char('h')));
1452 a.handle_event(press(KeyCode::Char('i')));
1453 if let Mode::PrCompose(s) = &a.mode {
1454 assert_eq!(s.title, "hi");
1455 assert_eq!(s.field, ComposeField::Title);
1456 } else {
1457 panic!("expected compose mode");
1458 }
1459 a.handle_event(press(KeyCode::Enter));
1461 if let Mode::PrCompose(s) = &a.mode {
1462 assert_eq!(s.field, ComposeField::Body);
1463 }
1464 a.handle_event(press(KeyCode::Char('x')));
1466 a.handle_event(press(KeyCode::Enter));
1467 a.handle_event(press(KeyCode::Char('y')));
1468 if let Mode::PrCompose(s) = &a.mode {
1469 assert_eq!(s.body, "x\ny");
1470 }
1471 a.handle_event(press(KeyCode::BackTab));
1473 a.handle_event(press(KeyCode::Backspace));
1474 if let Mode::PrCompose(s) = &a.mode {
1475 assert_eq!(s.field, ComposeField::Title);
1476 assert_eq!(s.title, "h");
1477 }
1478 }
1479
1480 #[test]
1481 fn compose_tab_cycles_all_four_fields() {
1482 use crate::tui::app::PrComposeState;
1483 let mut a = app(&[("a", true)]);
1484 a.mode = Mode::PrCompose(PrComposeState::default());
1485 let field = |a: &App| {
1486 if let Mode::PrCompose(s) = &a.mode {
1487 s.field
1488 } else {
1489 panic!("expected compose mode")
1490 }
1491 };
1492 assert_eq!(field(&a), ComposeField::Title);
1493 a.handle_event(press(KeyCode::Tab));
1494 assert_eq!(field(&a), ComposeField::Body);
1495 a.handle_event(press(KeyCode::Tab));
1496 assert_eq!(field(&a), ComposeField::Model);
1497 a.handle_event(press(KeyCode::Tab));
1498 assert_eq!(field(&a), ComposeField::Effort);
1499 a.handle_event(press(KeyCode::Tab));
1500 assert_eq!(field(&a), ComposeField::Title); }
1502
1503 #[test]
1504 fn compose_model_effort_fields_pick_with_arrows() {
1505 use crate::agent::{AgentModel, Effort};
1506 use crate::tui::app::PrComposeState;
1507 let mut a = app(&[("a", true)]);
1508 a.mode = Mode::PrCompose(PrComposeState::default());
1509 a.handle_event(press(KeyCode::Tab));
1511 a.handle_event(press(KeyCode::Tab));
1512 a.handle_event(press(KeyCode::Down));
1514 a.handle_event(press(KeyCode::Up));
1515 a.handle_event(press(KeyCode::Char('z')));
1517 if let Mode::PrCompose(s) = &a.mode {
1518 assert_eq!(s.field, ComposeField::Model);
1519 assert_eq!(s.model, AgentModel::Sonnet);
1520 assert_eq!(s.title, "");
1521 } else {
1522 panic!("expected compose mode");
1523 }
1524 a.handle_event(press(KeyCode::Tab));
1526 a.handle_event(press(KeyCode::Down));
1527 if let Mode::PrCompose(s) = &a.mode {
1528 assert_eq!(s.field, ComposeField::Effort);
1529 assert_eq!(s.effort, Effort::Medium.next());
1530 }
1531 }
1532
1533 #[test]
1534 fn compose_ctrl_s_requires_title_and_is_not_typed() {
1535 use crate::tui::app::PrComposeState;
1536 let mut a = app(&[("a", true)]);
1537 a.mode = Mode::PrCompose(PrComposeState::default());
1538 let effect = a.handle_event(ctrl('s'));
1539 assert_eq!(effect, Effect::None);
1540 if let Mode::PrCompose(s) = &a.mode {
1541 assert!(s.error.is_some());
1542 assert_eq!(s.title, "");
1544 } else {
1545 panic!("expected compose mode");
1546 }
1547 }
1548
1549 #[test]
1550 fn compose_ctrl_s_submits_when_title_present() {
1551 use crate::tui::app::PrComposeState;
1552 let mut a = app(&[("a", true)]);
1553 a.mode = Mode::PrCompose(PrComposeState {
1554 title: "T".into(),
1555 body: "B".into(),
1556 ..Default::default()
1557 });
1558 let effect = a.handle_event(ctrl('s'));
1559 assert_eq!(
1560 effect,
1561 Effect::SubmitPr {
1562 title: "T".into(),
1563 body: "B".into(),
1564 draft: false
1565 }
1566 );
1567 if let Mode::PrCompose(s) = &a.mode {
1568 assert!(s.submitting);
1569 }
1570 }
1571
1572 #[test]
1573 fn compose_ctrl_d_toggles_draft_and_esc_cancels() {
1574 use crate::tui::app::PrComposeState;
1575 let mut a = app(&[("a", true)]);
1576 a.mode = Mode::PrCompose(PrComposeState::default());
1577 a.handle_event(ctrl('d'));
1578 if let Mode::PrCompose(s) = &a.mode {
1579 assert!(s.draft);
1580 }
1581 a.handle_event(press(KeyCode::Esc));
1582 assert_eq!(a.mode, Mode::List);
1583 }
1584
1585 #[test]
1586 fn compose_ctrl_a_triggers_ai_fill() {
1587 use crate::tui::app::PrComposeState;
1588 let mut a = app(&[("a", true)]);
1589 a.mode = Mode::PrCompose(PrComposeState::default());
1590 let effect = a.handle_event(ctrl('a'));
1591 assert_eq!(effect, Effect::DraftPrAi);
1592 if let Mode::PrCompose(s) = &a.mode {
1593 assert!(s.submitting);
1595 assert_eq!(s.title, "");
1596 } else {
1597 panic!("expected compose mode");
1598 }
1599 }
1600
1601 #[test]
1602 fn compose_ctrl_m_and_e_cycle_model_and_effort() {
1603 use crate::agent::{AgentModel, Effort};
1604 use crate::tui::app::PrComposeState;
1605 let mut a = app(&[("a", true)]);
1606 a.mode = Mode::PrCompose(PrComposeState::default());
1607 a.handle_event(ctrl('m'));
1609 a.handle_event(ctrl('e'));
1610 if let Mode::PrCompose(s) = &a.mode {
1611 assert_eq!(s.model, AgentModel::Sonnet.next());
1612 assert_eq!(s.effort, Effort::Medium.next());
1613 assert_eq!(s.title, "");
1614 } else {
1615 panic!("expected compose mode");
1616 }
1617 }
1618
1619 #[test]
1620 fn help_dismisses_on_any_key() {
1621 let mut a = app(&[("a", true)]);
1622 a.handle_event(press(KeyCode::Char('?')));
1623 assert_eq!(a.mode, Mode::Help);
1624 a.handle_event(press(KeyCode::Char('x')));
1625 assert_eq!(a.mode, Mode::List);
1626 }
1627
1628 #[test]
1629 fn sort_and_sidebar_keys() {
1630 let mut a = app(&[("a", true)]);
1631 a.handle_event(press(KeyCode::Char('s')));
1632 assert_eq!(a.sort.key, crate::model::SortKey::Dirty);
1633 a.handle_event(press(KeyCode::Char('S')));
1634 assert!(a.sort.descending);
1635 let w0 = a.sidebar_width;
1636 a.handle_event(press(KeyCode::Char('+')));
1637 assert_eq!(a.sidebar_width, w0 + 1);
1638 a.handle_event(press(KeyCode::Char('-')));
1639 assert_eq!(a.sidebar_width, w0);
1640 a.handle_event(press(KeyCode::Char('\\')));
1641 assert!(!a.show_sidebar);
1642 }
1643
1644 #[test]
1645 fn resize_too_small_exits() {
1646 let mut a = app(&[("a", true)]);
1647 assert_eq!(a.handle_event(Event::Resize(100, 4)), Effect::TooSmall);
1648 assert_eq!(a.handle_event(Event::Resize(100, 20)), Effect::None);
1649 assert_eq!(a.size, (100, 20));
1650 }
1651
1652 #[test]
1653 fn open_editor_and_refresh() {
1654 let mut a = app(&[("a", true)]);
1655 assert_eq!(
1656 a.handle_event(press(KeyCode::Char('o'))),
1657 Effect::OpenEditor(std::path::PathBuf::from("/r/a"))
1658 );
1659 assert_eq!(a.handle_event(press(KeyCode::Char('r'))), Effect::Refresh);
1660 }
1661
1662 #[test]
1663 fn mouse_click_selects_and_wheel_scrolls() {
1664 let mut a = app(&[("a", true), ("b", false), ("c", false)]);
1665 let click = Event::Mouse(MouseEvent {
1667 kind: MouseEventKind::Down(MouseButton::Left),
1668 column: 5,
1669 row: 3,
1670 modifiers: KeyModifiers::empty(),
1671 });
1672 a.handle_event(click);
1673 assert_eq!(a.selected, 2);
1674 a.handle_event(Event::Mouse(MouseEvent {
1675 kind: MouseEventKind::ScrollUp,
1676 column: 5,
1677 row: 3,
1678 modifiers: KeyModifiers::empty(),
1679 }));
1680 assert_eq!(a.selected, 1);
1681 }
1682
1683 #[test]
1684 fn mouse_ignored_when_disabled() {
1685 let mut a = app(&[("a", true), ("b", false)]);
1686 a.mouse = false;
1687 a.selected = 0;
1688 a.handle_event(Event::Mouse(MouseEvent {
1689 kind: MouseEventKind::ScrollDown,
1690 column: 5,
1691 row: 3,
1692 modifiers: KeyModifiers::empty(),
1693 }));
1694 assert_eq!(a.selected, 0);
1695 }
1696
1697 #[test]
1698 fn mouse_in_modal_does_not_touch_background() {
1699 let mut a = app(&[("a", true), ("b", false), ("c", false)]);
1702 a.selected = 1;
1703 a.mode = Mode::Create(CreateState::default());
1704 let click = Event::Mouse(MouseEvent {
1705 kind: MouseEventKind::Down(MouseButton::Left),
1706 column: 5,
1707 row: 3,
1708 modifiers: KeyModifiers::empty(),
1709 });
1710 assert_eq!(a.handle_event(click), Effect::None);
1711 assert_eq!(a.selected, 1);
1712 assert!(matches!(a.mode, Mode::Create(_)));
1713 a.handle_event(Event::Mouse(MouseEvent {
1714 kind: MouseEventKind::ScrollDown,
1715 column: 5,
1716 row: 3,
1717 modifiers: KeyModifiers::empty(),
1718 }));
1719 assert_eq!(a.selected, 1); }
1721
1722 #[test]
1723 fn mouse_scroll_moves_create_dropdown() {
1724 let mut a = app(&[("a", true)]);
1727 let mut options = crate::tui::OptionList::new(vec![
1728 "main".into(),
1729 "origin/main".into(),
1730 "origin/dev".into(),
1731 ]);
1732 options.open();
1733 a.mode = Mode::Create(CreateState {
1734 options,
1735 ..Default::default()
1736 });
1737 let wheel = |kind| {
1738 Event::Mouse(MouseEvent {
1739 kind,
1740 column: 5,
1741 row: 5,
1742 modifiers: KeyModifiers::empty(),
1743 })
1744 };
1745 a.handle_event(wheel(MouseEventKind::ScrollDown));
1746 if let Mode::Create(s) = &a.mode {
1747 assert_eq!(s.options.selected(), Some("origin/main"));
1749 } else {
1750 panic!("expected create mode");
1751 }
1752 a.handle_event(wheel(MouseEventKind::ScrollUp));
1753 if let Mode::Create(s) = &a.mode {
1754 assert_eq!(s.options.selected(), Some("main"));
1755 }
1756 }
1757
1758 #[test]
1759 fn mouse_scroll_moves_pr_picker_selection() {
1760 use crate::tui::app::{PrItem, PrPickerState};
1761 let pr = |number| PrItem {
1762 number,
1763 title: "t".into(),
1764 author: "a".into(),
1765 state: "open".into(),
1766 created_at: String::new(),
1767 };
1768 let mut a = app(&[("a", true)]);
1769 a.mode = Mode::PrPicker(PrPickerState {
1770 loading: false,
1771 prs: vec![pr(1), pr(2)],
1772 ..Default::default()
1773 });
1774 let wheel = |kind| {
1775 Event::Mouse(MouseEvent {
1776 kind,
1777 column: 5,
1778 row: 5,
1779 modifiers: KeyModifiers::empty(),
1780 })
1781 };
1782 a.handle_event(wheel(MouseEventKind::ScrollDown));
1783 if let Mode::PrPicker(s) = &a.mode {
1784 assert_eq!(s.selected, 1);
1785 } else {
1786 panic!("expected pr picker");
1787 }
1788 a.handle_event(wheel(MouseEventKind::ScrollDown));
1790 if let Mode::PrPicker(s) = &a.mode {
1791 assert_eq!(s.selected, 1);
1792 }
1793 a.handle_event(wheel(MouseEventKind::ScrollUp));
1794 if let Mode::PrPicker(s) = &a.mode {
1795 assert_eq!(s.selected, 0);
1796 }
1797 }
1798
1799 #[test]
1800 fn tab_toggles_focus() {
1801 let mut a = app(&[("a", true)]);
1802 assert_eq!(a.focus, Pane::List);
1803 a.handle_event(press(KeyCode::Tab));
1804 assert_eq!(a.focus, Pane::Detail);
1805 }
1806
1807 #[test]
1808 fn navigation_scrolls_detail_when_focused() {
1809 let mut a = app(&[("a", true), ("b", false)]);
1810 a.worktrees[0].recent_commits = vec![crate::model::Commit {
1811 hash: "h".into(),
1812 subject: "s".into(),
1813 author: "x".into(),
1814 timestamp: "2024-01-15T10:30:00Z".into(),
1815 }];
1816 a.handle_event(press(KeyCode::Tab)); a.handle_event(press(KeyCode::Char('j'))); assert_eq!(a.detail_scroll, 1);
1819 assert_eq!(a.selected, 0);
1820 a.handle_event(press(KeyCode::Char('k')));
1821 assert_eq!(a.detail_scroll, 0);
1822 a.handle_event(press(KeyCode::Tab));
1824 a.detail_scroll = 3;
1825 a.handle_event(press(KeyCode::Char('j')));
1826 assert_eq!(a.selected, 1);
1827 assert_eq!(a.detail_scroll, 0);
1828 }
1829
1830 #[test]
1831 fn mouse_click_on_status_bar_and_title_row_select_nothing() {
1832 let mut a = app(&[("a", true), ("b", false), ("c", false)]);
1833 a.size = (100, 30);
1834 a.selected = 1;
1835 let click = |row: u16| {
1837 Event::Mouse(MouseEvent {
1838 kind: MouseEventKind::Down(MouseButton::Left),
1839 column: 5,
1840 row,
1841 modifiers: KeyModifiers::empty(),
1842 })
1843 };
1844 a.handle_event(click(29));
1845 assert_eq!(a.selected, 1);
1846 a.handle_event(click(0));
1848 assert_eq!(a.selected, 1);
1849 }
1850}