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::ConfirmQuit { .. } => self.key_confirm_quit(key),
233 Mode::Help => {
234 self.mode = Mode::List;
235 Effect::None
236 }
237 }
238 }
239
240 fn key_list(&mut self, key: KeyEvent) -> Effect {
242 let Some(action) = self.keymap.action_for(KeyChord::from_event(key)) else {
243 return Effect::None;
244 };
245 let page = (self.size.1 as isize - 3).max(1);
246 match action {
247 KeyAction::NavigateUp => self.nav_or_scroll(-1),
248 KeyAction::NavigateDown => self.nav_or_scroll(1),
249 KeyAction::PageUp => self.nav_or_scroll(-page),
250 KeyAction::PageDown => self.nav_or_scroll(page),
251 KeyAction::GoToTop => self.select_edge(false),
252 KeyAction::GoToBottom => self.select_edge(true),
253 KeyAction::FocusNextPane | KeyAction::FocusPrevPane => self.toggle_focus(),
254 KeyAction::Switch => {
255 if let Some(&index) = self.visible.get(self.selected) {
256 let wt = &self.worktrees[index];
257 if wt.has_worktree {
258 let path = wt.path.clone();
259 self.chosen = Some(path.clone());
260 return Effect::Switch(path);
261 }
262 self.mode = Mode::ConfirmCreate(index);
265 }
266 }
267 KeyAction::Filter => self.mode = Mode::Filter,
268 KeyAction::ClearFilter => self.clear_filter(),
269 KeyAction::New => {
270 let options = crate::tui::OptionList::new(self.branches.clone());
273 self.mode = Mode::Create(CreateState {
277 base: self.default_base.clone().unwrap_or_default(),
278 options,
279 ..Default::default()
280 });
281 }
282 KeyAction::Remove => {
283 if let Some(&index) = self.visible.get(self.selected) {
287 self.mode = if self.worktrees[index].has_worktree {
288 Mode::ConfirmRemove(index)
289 } else {
290 Mode::ConfirmDeleteBranch {
291 index,
292 force: false,
293 }
294 };
295 }
296 }
297 KeyAction::PrCheckout => {
298 self.mode = Mode::PrPicker(crate::tui::app::PrPickerState {
299 loading: true,
300 ..Default::default()
301 });
302 return Effect::FetchPrs;
303 }
304 KeyAction::Checkout => {
305 if let Some(&index) = self.visible.get(self.selected)
310 && self.worktrees[index].has_worktree
311 {
312 let mut options = crate::tui::OptionList::new(self.branches.clone());
316 options.open();
317 self.mode = Mode::Checkout(crate::tui::app::CheckoutState {
318 worktree_index: index,
319 options,
320 ..Default::default()
321 });
322 }
323 }
324 KeyAction::Sync => {
325 if let Some(&index) = self.visible.get(self.selected) {
330 return Effect::Sync {
331 worktree_index: index,
332 };
333 }
334 }
335 KeyAction::OpenEditor => {
336 if let Some(wt) = self.selected_worktree()
338 && wt.has_worktree
339 {
340 return Effect::OpenEditor(wt.path.clone());
341 }
342 }
343 KeyAction::Refresh => return Effect::Refresh,
344 KeyAction::SortCycle => self.cycle_sort(),
345 KeyAction::SortReverse => self.reverse_sort(),
346 KeyAction::Help => self.mode = Mode::Help,
347 KeyAction::Quit => {
348 if self.any_jobs() {
352 self.mode = Mode::ConfirmQuit {
353 jobs: self.jobs.len(),
354 };
355 } else {
356 self.quit = true;
357 return Effect::Quit;
358 }
359 }
360 KeyAction::ToggleSidebar => self.show_sidebar = !self.show_sidebar,
361 KeyAction::ResizeSidebarGrow => {
362 self.sidebar_width = (self.sidebar_width + 1).min(MAX_SIDEBAR);
363 }
364 KeyAction::ResizeSidebarShrink => {
365 self.sidebar_width = self.sidebar_width.saturating_sub(1).max(MIN_SIDEBAR);
366 }
367 }
368 Effect::None
369 }
370
371 fn key_filter(&mut self, key: KeyEvent) -> Effect {
373 match key.code {
374 KeyCode::Char(c) => self.filter_push(c),
375 KeyCode::Backspace => self.filter_pop(),
376 KeyCode::Enter => self.mode = Mode::List, KeyCode::Esc => {
378 self.clear_filter();
379 self.mode = Mode::List;
380 }
381 KeyCode::Up => self.move_selection(-1),
382 KeyCode::Down => self.move_selection(1),
383 _ => {}
384 }
385 Effect::None
386 }
387
388 fn key_create(&mut self, key: KeyEvent) -> Effect {
393 let Mode::Create(state) = &mut self.mode else {
394 return Effect::None;
395 };
396 match key.code {
397 KeyCode::Char(c) => {
398 state.field_mut().push(c);
399 state.error = None;
400 state.refresh_options();
401 }
402 KeyCode::Backspace => {
403 state.field_mut().pop();
404 state.refresh_options();
405 }
406 KeyCode::Up => state.options.up(),
407 KeyCode::Down => state.options.down(),
408 KeyCode::Tab => {
409 if state.step == CreateStep::Base {
410 complete_base_ref(state, &self.branches);
411 state.refresh_options();
412 }
413 }
414 KeyCode::Esc => {
415 if state.options.is_open() {
416 state.options.close();
417 } else {
418 self.mode = Mode::List;
419 }
420 }
421 KeyCode::Enter => {
422 if let Some(selected) = state.options.selected().map(str::to_owned) {
425 *state.field_mut() = selected;
426 state.options.close();
427 } else {
428 match state.step {
429 CreateStep::Branch => {
430 let branch = state.branch.trim();
431 if branch.is_empty() {
432 state.error = Some("branch name is required".into());
433 } else if let Err(msg) = crate::git::validate_branch_name(branch) {
434 state.error = Some(msg);
435 } else {
436 state.step = CreateStep::Base;
437 state.refresh_options();
439 }
440 }
441 CreateStep::Base => {
442 let branch = state.branch.clone();
443 let base = (!state.base.trim().is_empty()).then(|| state.base.clone());
444 return Effect::Create {
447 branch,
448 base,
449 decision: None,
450 };
451 }
452 }
453 }
454 }
455 _ => {}
456 }
457 Effect::None
458 }
459
460 fn key_pr(&mut self, key: KeyEvent) -> Effect {
462 let Mode::PrPicker(state) = &mut self.mode else {
463 return Effect::None;
464 };
465 match key.code {
466 KeyCode::Up => state.selected = state.selected.saturating_sub(1),
467 KeyCode::Down => {
468 state.selected = (state.selected + 1).min(state.prs.len().saturating_sub(1));
469 }
470 KeyCode::Enter => {
471 if let Some(pr) = state.prs.get(state.selected) {
472 return Effect::CheckoutPr(pr.number);
473 }
474 }
475 KeyCode::Esc => self.mode = Mode::List,
476 _ => {}
477 }
478 Effect::None
479 }
480
481 fn key_compose(&mut self, key: KeyEvent) -> Effect {
488 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
489 let Mode::PrCompose(state) = &mut self.mode else {
490 return Effect::None;
491 };
492 if state.submitting {
494 return Effect::None;
495 }
496 match key.code {
497 KeyCode::Char('s') if ctrl => {
498 if state.title.trim().is_empty() {
499 state.error = Some("a PR title is required".into());
500 } else {
501 state.submitting = true;
502 return Effect::SubmitPr {
503 title: state.title.clone(),
504 body: state.body.clone(),
505 draft: state.draft,
506 };
507 }
508 }
509 KeyCode::Char('a') if ctrl => {
511 state.submitting = true;
512 state.error = None;
513 return Effect::DraftPrAi;
514 }
515 KeyCode::Char('m') if ctrl => state.model = state.model.next(),
517 KeyCode::Char('e') if ctrl => state.effort = state.effort.next(),
518 KeyCode::Char('d') if ctrl => state.draft = !state.draft,
519 KeyCode::Char(c) if !ctrl => {
521 match state.field {
522 ComposeField::Title => state.title.push(c),
523 ComposeField::Body => state.body.push(c),
524 ComposeField::Model | ComposeField::Effort => {}
525 }
526 state.error = None;
527 }
528 KeyCode::Backspace => {
529 match state.field {
530 ComposeField::Title => state.title.pop(),
531 ComposeField::Body => state.body.pop(),
532 ComposeField::Model | ComposeField::Effort => None,
533 };
534 state.error = None;
535 }
536 KeyCode::Up => match state.field {
538 ComposeField::Model => state.model = state.model.prev(),
539 ComposeField::Effort => state.effort = state.effort.prev(),
540 _ => {}
541 },
542 KeyCode::Down => match state.field {
543 ComposeField::Model => state.model = state.model.next(),
544 ComposeField::Effort => state.effort = state.effort.next(),
545 _ => {}
546 },
547 KeyCode::Tab => state.field = compose_next_field(state.field),
548 KeyCode::BackTab => state.field = compose_prev_field(state.field),
549 KeyCode::Enter => match state.field {
550 ComposeField::Title => state.field = ComposeField::Body,
551 ComposeField::Body => state.body.push('\n'),
552 ComposeField::Model => state.field = ComposeField::Effort,
554 ComposeField::Effort => state.field = ComposeField::Title,
555 },
556 KeyCode::Esc => self.mode = Mode::List,
557 _ => {}
558 }
559 Effect::None
560 }
561
562 fn key_checkout_picker(&mut self, key: KeyEvent) -> Effect {
567 let Mode::Checkout(state) = &mut self.mode else {
568 return Effect::None;
569 };
570 if state.submitting {
572 return Effect::None;
573 }
574 match key.code {
575 KeyCode::Char(c) => {
576 state.query.push(c);
577 state.error = None;
578 state.options.refilter(&state.query);
579 state.options.open();
580 }
581 KeyCode::Backspace => {
582 state.query.pop();
583 state.error = None;
584 state.options.refilter(&state.query);
585 state.options.open();
586 }
587 KeyCode::Up => state.options.up(),
588 KeyCode::Down => state.options.down(),
589 KeyCode::Esc => {
590 if state.options.is_open() {
591 state.options.close();
592 } else {
593 self.mode = Mode::List;
594 }
595 }
596 KeyCode::Enter => {
597 let branch = state
600 .options
601 .selected()
602 .map(str::to_owned)
603 .unwrap_or_else(|| state.query.trim().to_string());
604 if branch.is_empty() {
605 state.error = Some("branch name is required".into());
606 } else {
607 let worktree_index = state.worktree_index;
608 return Effect::CheckoutBranch {
609 worktree_index,
610 branch,
611 };
612 }
613 }
614 _ => {}
615 }
616 Effect::None
617 }
618
619 fn key_confirm(&mut self, key: KeyEvent) -> Effect {
621 let Mode::ConfirmRemove(index) = self.mode else {
622 return Effect::None;
623 };
624 if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
625 self.mode = Mode::List;
626 Effect::Remove(index)
627 } else {
628 self.mode = Mode::List;
629 Effect::None
630 }
631 }
632
633 fn key_confirm_create(&mut self, key: KeyEvent) -> Effect {
636 let Mode::ConfirmCreate(index) = self.mode else {
637 return Effect::None;
638 };
639 self.mode = Mode::List;
640 if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y'))
641 && let Some(branch) = self.worktrees.get(index).and_then(|w| w.branch.clone())
642 {
643 return Effect::MaterializeBranch { branch };
644 }
645 Effect::None
646 }
647
648 fn key_confirm_delete_branch(&mut self, key: KeyEvent) -> Effect {
653 let Mode::ConfirmDeleteBranch { index, force } = self.mode else {
654 return Effect::None;
655 };
656 self.mode = Mode::List;
657 if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y'))
658 && let Some(branch) = self.worktrees.get(index).and_then(|w| w.branch.clone())
659 {
660 return Effect::DeleteBranch { branch, force };
661 }
662 Effect::None
663 }
664
665 fn key_confirm_stale_base(&mut self, key: KeyEvent) -> Effect {
669 let Mode::ConfirmStaleBase(state) = &self.mode else {
670 return Effect::None;
671 };
672 let branch = state.branch.clone();
673 let base = state.base.clone();
674 self.mode = Mode::List;
675 let decision = match key.code {
676 KeyCode::Char('u') | KeyCode::Char('U') => CreateDecision::Update,
677 KeyCode::Char('p') | KeyCode::Char('P') => CreateDecision::Proceed,
678 _ => return Effect::None,
679 };
680 Effect::Create {
681 branch,
682 base,
683 decision: Some(decision),
684 }
685 }
686
687 fn key_confirm_init_submodules(&mut self, key: KeyEvent) -> Effect {
691 let Mode::ConfirmInitSubmodules(state) = &self.mode else {
692 return Effect::None;
693 };
694 match key.code {
695 KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => {
696 let dir = state.dir.clone();
697 let count = state.count;
698 self.mode = Mode::List;
699 Effect::InitSubmodules { dir, count }
700 }
701 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
702 self.mode = Mode::List;
703 Effect::None
704 }
705 _ => Effect::None,
706 }
707 }
708
709 fn key_confirm_quit(&mut self, key: KeyEvent) -> Effect {
712 if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
713 self.quit = true;
714 Effect::Quit
715 } else {
716 self.mode = Mode::List;
717 Effect::None
718 }
719 }
720
721 fn on_mouse(&mut self, mouse: MouseEvent) -> Effect {
723 if !matches!(self.mode, Mode::List | Mode::Filter) {
728 match mouse.kind {
729 MouseEventKind::ScrollUp => self.modal_scroll(-1),
730 MouseEventKind::ScrollDown => self.modal_scroll(1),
731 _ => {}
732 }
733 return Effect::None;
734 }
735 match mouse.kind {
736 MouseEventKind::Down(MouseButton::Left) => {
737 let status_row = self.size.1.saturating_sub(1);
739 if mouse.row >= status_row {
740 return Effect::None;
741 }
742 if self.show_sidebar && mouse.column < self.sidebar_width {
743 if mouse.row >= LIST_TOP {
746 self.select_row((mouse.row - LIST_TOP) as usize);
747 }
748 self.focus = Pane::List;
749 } else {
750 self.focus = Pane::Detail;
751 }
752 }
753 MouseEventKind::ScrollUp => self.nav_or_scroll(-1),
754 MouseEventKind::ScrollDown => self.nav_or_scroll(1),
755 _ => {}
756 }
757 Effect::None
758 }
759
760 fn modal_scroll(&mut self, delta: isize) {
765 let up = delta < 0;
766 match &mut self.mode {
767 Mode::Create(state) => {
768 if up {
769 state.options.up();
770 } else {
771 state.options.down();
772 }
773 }
774 Mode::Checkout(state) => {
775 if up {
776 state.options.up();
777 } else {
778 state.options.down();
779 }
780 }
781 Mode::PrPicker(state) => {
782 if up {
783 state.selected = state.selected.saturating_sub(1);
784 } else {
785 state.selected = (state.selected + 1).min(state.prs.len().saturating_sub(1));
786 }
787 }
788 _ => {}
789 }
790 }
791
792 fn nav_or_scroll(&mut self, delta: isize) {
795 if self.focus == Pane::Detail {
796 self.scroll_detail(delta);
797 } else {
798 self.move_selection(delta);
799 }
800 }
801
802 fn toggle_focus(&mut self) {
804 self.focus = match self.focus {
805 Pane::List => Pane::Detail,
806 Pane::Detail => Pane::List,
807 };
808 }
809}
810
811#[cfg(test)]
812mod tests {
813 use super::*;
814 use crate::tui::app::testutil::app;
815 use crossterm::event::{KeyModifiers, MouseButton};
816
817 fn press(code: KeyCode) -> Event {
818 Event::Key(KeyEvent::new(code, KeyModifiers::empty()))
819 }
820
821 fn ctrl(c: char) -> Event {
822 Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL))
823 }
824
825 #[test]
826 fn navigation_keys() {
827 let mut a = app(&[("a", true), ("b", false), ("c", false)]);
828 a.selected = 0;
829 assert_eq!(a.handle_event(press(KeyCode::Char('j'))), Effect::None);
830 assert_eq!(a.selected, 1);
831 a.handle_event(press(KeyCode::Char('k')));
832 assert_eq!(a.selected, 0);
833 a.handle_event(press(KeyCode::Char('G')));
834 assert_eq!(a.selected, 2);
835 a.handle_event(press(KeyCode::Char('g')));
836 assert_eq!(a.selected, 0);
837 a.handle_event(ctrl('d')); assert!(a.selected >= 1 || a.visible.len() == 1);
839 }
840
841 #[test]
842 fn enter_switches_to_selected() {
843 let mut a = app(&[("main", true), ("feat", false)]);
844 a.selected = 1;
845 let effect = a.handle_event(press(KeyCode::Enter));
846 assert_eq!(effect, Effect::Switch(std::path::PathBuf::from("/r/feat")));
847 assert_eq!(a.chosen, Some(std::path::PathBuf::from("/r/feat")));
848 }
849
850 #[test]
851 fn enter_on_branch_row_opens_confirm_create() {
852 use crate::tui::app::testutil::branch_row;
853 let mut a = app(&[("main", true)]);
854 a.worktrees.push(branch_row("topic"));
855 a.apply_filter(String::new()); a.selected = a.visible.len() - 1; let effect = a.handle_event(press(KeyCode::Enter));
858 assert_eq!(effect, Effect::None);
859 assert!(matches!(a.mode, Mode::ConfirmCreate(_)));
860 assert!(a.chosen.is_none()); }
862
863 #[test]
864 fn confirm_create_y_materializes_other_cancels() {
865 use crate::tui::app::testutil::branch_row;
866 let mut a = app(&[("main", true)]);
867 a.worktrees.push(branch_row("topic"));
868 let idx = a
869 .worktrees
870 .iter()
871 .position(|w| w.branch.as_deref() == Some("topic"))
872 .unwrap();
873 a.mode = Mode::ConfirmCreate(idx);
874 let effect = a.handle_event(press(KeyCode::Char('y')));
875 assert_eq!(
876 effect,
877 Effect::MaterializeBranch {
878 branch: "topic".into()
879 }
880 );
881 assert_eq!(a.mode, Mode::List);
882 a.mode = Mode::ConfirmCreate(idx);
884 let effect = a.handle_event(press(KeyCode::Char('n')));
885 assert_eq!(effect, Effect::None);
886 assert_eq!(a.mode, Mode::List);
887 }
888
889 #[test]
890 fn checkout_and_open_editor_are_noops_on_branch_rows() {
891 use crate::tui::app::testutil::branch_row;
894 let mut a = app(&[("main", true)]);
895 a.worktrees.push(branch_row("topic"));
896 a.apply_filter(String::new());
897 a.selected = a.visible.len() - 1; assert_eq!(a.handle_event(press(KeyCode::Char('c'))), Effect::None);
899 assert_eq!(a.mode, Mode::List);
900 assert_eq!(a.handle_event(press(KeyCode::Char('o'))), Effect::None);
901 assert_eq!(a.mode, Mode::List);
902 }
903
904 #[test]
905 fn sync_acts_on_worktree_rows_and_branch_rows() {
906 use crate::tui::app::testutil::branch_row;
907 let mut a = app(&[("main", true), ("feat", false)]);
908 a.selected = 1;
910 assert_eq!(
911 a.handle_event(press(KeyCode::Char('y'))),
912 Effect::Sync { worktree_index: 1 }
913 );
914 a.worktrees.push(branch_row("topic"));
917 a.apply_filter(String::new());
918 let idx = a
919 .worktrees
920 .iter()
921 .position(|w| w.branch.as_deref() == Some("topic"))
922 .unwrap();
923 a.selected = a.visible.iter().position(|&i| i == idx).unwrap();
924 assert_eq!(
925 a.handle_event(press(KeyCode::Char('y'))),
926 Effect::Sync {
927 worktree_index: idx
928 }
929 );
930 assert_eq!(a.mode, Mode::List);
931 }
932
933 #[test]
934 fn remove_on_branch_row_confirms_then_deletes_branch() {
935 use crate::tui::app::testutil::branch_row;
938 let mut a = app(&[("main", true)]);
939 a.worktrees.push(branch_row("topic"));
940 a.apply_filter(String::new());
941 a.selected = a.visible.len() - 1; assert_eq!(a.handle_event(press(KeyCode::Char('d'))), Effect::None);
943 assert!(matches!(
944 a.mode,
945 Mode::ConfirmDeleteBranch { force: false, .. }
946 ));
947 let effect = a.handle_event(press(KeyCode::Char('y')));
948 assert_eq!(
949 effect,
950 Effect::DeleteBranch {
951 branch: "topic".into(),
952 force: false,
953 }
954 );
955 assert_eq!(a.mode, Mode::List);
956 a.mode = Mode::ConfirmDeleteBranch {
958 index: a.visible[a.selected],
959 force: true,
960 };
961 assert_eq!(a.handle_event(press(KeyCode::Char('n'))), Effect::None);
962 assert_eq!(a.mode, Mode::List);
963 }
964
965 #[test]
966 fn confirm_stale_base_keys_reissue_create_or_cancel() {
967 use crate::tui::app::StaleBaseState;
968 let state = StaleBaseState {
969 branch: "feature".into(),
970 base: Some("main".into()),
971 behind: 2,
972 upstream_display: "origin/main".into(),
973 can_fast_forward: true,
974 };
975 let mut a = app(&[("main", true)]);
976 a.mode = Mode::ConfirmStaleBase(state.clone());
978 assert_eq!(
979 a.handle_event(press(KeyCode::Char('u'))),
980 Effect::Create {
981 branch: "feature".into(),
982 base: Some("main".into()),
983 decision: Some(CreateDecision::Update),
984 }
985 );
986 assert_eq!(a.mode, Mode::List);
987 a.mode = Mode::ConfirmStaleBase(state.clone());
989 assert_eq!(
990 a.handle_event(press(KeyCode::Char('p'))),
991 Effect::Create {
992 branch: "feature".into(),
993 base: Some("main".into()),
994 decision: Some(CreateDecision::Proceed),
995 }
996 );
997 a.mode = Mode::ConfirmStaleBase(state);
999 assert_eq!(a.handle_event(press(KeyCode::Esc)), Effect::None);
1000 assert_eq!(a.mode, Mode::List);
1001 }
1002
1003 #[test]
1004 fn confirm_init_submodules_keys_init_or_skip() {
1005 use crate::tui::app::InitSubmodulesState;
1006 let state = InitSubmodulesState {
1007 dir: PathBuf::from("/wt/feature"),
1008 branch: "feature".into(),
1009 count: 2,
1010 };
1011 let mut a = app(&[("main", true)]);
1012 a.mode = Mode::ConfirmInitSubmodules(state.clone());
1014 assert_eq!(
1015 a.handle_event(press(KeyCode::Enter)),
1016 Effect::InitSubmodules {
1017 dir: PathBuf::from("/wt/feature"),
1018 count: 2,
1019 }
1020 );
1021 assert_eq!(a.mode, Mode::List);
1022 a.mode = Mode::ConfirmInitSubmodules(state.clone());
1024 assert_eq!(
1025 a.handle_event(press(KeyCode::Char('y'))),
1026 Effect::InitSubmodules {
1027 dir: PathBuf::from("/wt/feature"),
1028 count: 2,
1029 }
1030 );
1031 a.mode = Mode::ConfirmInitSubmodules(state.clone());
1033 assert_eq!(a.handle_event(press(KeyCode::Char('n'))), Effect::None);
1034 assert_eq!(a.mode, Mode::List);
1035 a.mode = Mode::ConfirmInitSubmodules(state.clone());
1037 assert_eq!(a.handle_event(press(KeyCode::Esc)), Effect::None);
1038 assert_eq!(a.mode, Mode::List);
1039 a.mode = Mode::ConfirmInitSubmodules(state);
1041 assert_eq!(a.handle_event(press(KeyCode::Char('x'))), Effect::None);
1042 assert!(matches!(a.mode, Mode::ConfirmInitSubmodules(_)));
1043 }
1044
1045 #[test]
1046 fn quit_returns_quit() {
1047 let mut a = app(&[("a", true)]);
1048 assert_eq!(a.handle_event(press(KeyCode::Char('q'))), Effect::Quit);
1049 assert!(a.quit);
1050 }
1051
1052 #[test]
1053 fn quit_with_jobs_confirms_first() {
1054 use crate::tui::app::JobKey;
1057 let mut a = app(&[("a", true)]);
1058 a.begin_job(JobKey::New("feat".into()), "Creating feat");
1059 assert_eq!(a.handle_event(press(KeyCode::Char('q'))), Effect::None);
1060 assert!(matches!(a.mode, Mode::ConfirmQuit { jobs: 1 }));
1061 assert!(!a.quit);
1062 assert_eq!(a.handle_event(press(KeyCode::Char('y'))), Effect::Quit);
1064 assert!(a.quit);
1065 let mut b = app(&[("a", true)]);
1067 b.begin_job(JobKey::New("feat".into()), "Creating feat");
1068 b.handle_event(press(KeyCode::Char('q')));
1069 assert_eq!(b.handle_event(press(KeyCode::Char('n'))), Effect::None);
1070 assert_eq!(b.mode, Mode::List);
1071 assert!(!b.quit);
1072 }
1073
1074 #[test]
1075 fn filter_mode_typing_and_escape() {
1076 let mut a = app(&[("alpha", true), ("beta", false)]);
1077 a.handle_event(press(KeyCode::Char('/')));
1078 assert_eq!(a.mode, Mode::Filter);
1079 a.handle_event(press(KeyCode::Char('a')));
1080 a.handle_event(press(KeyCode::Char('l')));
1081 assert_eq!(a.filter, "al");
1082 assert_eq!(a.visible.len(), 1); a.handle_event(press(KeyCode::Enter)); assert_eq!(a.mode, Mode::List);
1085 assert_eq!(a.filter, "al"); a.handle_event(press(KeyCode::Char('/')));
1087 a.handle_event(press(KeyCode::Esc)); assert_eq!(a.mode, Mode::List);
1089 assert_eq!(a.filter, "");
1090 }
1091
1092 #[test]
1093 fn create_mode_flow() {
1094 let mut a = app(&[("a", true)]);
1095 a.handle_event(press(KeyCode::Char('n')));
1096 assert!(matches!(a.mode, Mode::Create(_)));
1097 a.handle_event(press(KeyCode::Enter));
1099 if let Mode::Create(s) = &a.mode {
1100 assert!(s.error.is_some());
1101 } else {
1102 panic!("expected create mode");
1103 }
1104 for c in "feature/x".chars() {
1106 a.handle_event(press(KeyCode::Char(c)));
1107 }
1108 a.handle_event(press(KeyCode::Enter));
1109 if let Mode::Create(s) = &a.mode {
1110 assert_eq!(s.step, CreateStep::Base);
1111 assert_eq!(s.branch, "feature/x");
1112 }
1113 let effect = a.handle_event(press(KeyCode::Enter));
1115 assert_eq!(
1116 effect,
1117 Effect::Create {
1118 branch: "feature/x".into(),
1119 base: None,
1120 decision: None,
1121 }
1122 );
1123 }
1124
1125 #[test]
1126 fn create_mode_prefills_default_base() {
1127 let mut a = app(&[("main", true)]);
1130 a.branches = vec!["main".into(), "origin/main".into()];
1131 a.default_base = Some("origin/main".into());
1132 a.handle_event(press(KeyCode::Char('n')));
1133 if let Mode::Create(s) = &a.mode {
1134 assert_eq!(s.base, "origin/main");
1135 assert_eq!(s.step, CreateStep::Branch); } else {
1137 panic!("expected create mode");
1138 }
1139 }
1140
1141 #[test]
1142 fn create_mode_base_empty_without_default() {
1143 let mut a = app(&[("main", true)]);
1146 assert!(a.default_base.is_none());
1147 a.handle_event(press(KeyCode::Char('n')));
1148 for c in "feature/x".chars() {
1149 a.handle_event(press(KeyCode::Char(c)));
1150 }
1151 a.handle_event(press(KeyCode::Enter)); if let Mode::Create(s) = &a.mode {
1153 assert_eq!(s.base, "");
1154 } else {
1155 panic!("expected create mode");
1156 }
1157 assert_eq!(
1158 a.handle_event(press(KeyCode::Enter)),
1159 Effect::Create {
1160 branch: "feature/x".into(),
1161 base: None,
1162 decision: None,
1163 }
1164 );
1165 }
1166
1167 #[test]
1168 fn create_mode_rejects_invalid_branch_name() {
1169 let mut a = app(&[("a", true)]);
1170 a.handle_event(press(KeyCode::Char('n')));
1171 for c in "feat..x".chars() {
1172 a.handle_event(press(KeyCode::Char(c)));
1173 }
1174 a.handle_event(press(KeyCode::Enter));
1176 if let Mode::Create(s) = &a.mode {
1177 assert_eq!(s.step, CreateStep::Branch);
1178 assert!(s.error.as_deref().unwrap().contains("invalid branch name"));
1179 } else {
1180 panic!("expected create mode");
1181 }
1182 a.handle_event(press(KeyCode::Char('y')));
1184 if let Mode::Create(s) = &a.mode {
1185 assert!(s.error.is_none());
1186 }
1187 if let Mode::Create(s) = &mut a.mode {
1189 s.branch = "feature/x".into();
1190 }
1191 a.handle_event(press(KeyCode::Enter));
1192 if let Mode::Create(s) = &a.mode {
1193 assert_eq!(s.step, CreateStep::Base);
1194 } else {
1195 panic!("expected create mode");
1196 }
1197 }
1198
1199 #[test]
1200 fn create_mode_tab_completes_base_ref() {
1201 let mut a = app(&[("a", true)]);
1202 a.branches = vec!["feature/alpha".into(), "feature/beta".into(), "main".into()];
1203 a.handle_event(press(KeyCode::Char('n')));
1204 for c in "topic".chars() {
1205 a.handle_event(press(KeyCode::Char(c)));
1206 }
1207 a.handle_event(press(KeyCode::Enter)); for c in "feat".chars() {
1210 a.handle_event(press(KeyCode::Char(c)));
1211 }
1212 a.handle_event(press(KeyCode::Tab));
1213 if let Mode::Create(s) = &a.mode {
1214 assert_eq!(s.base, "feature/");
1215 } else {
1216 panic!("expected create mode");
1217 }
1218 a.handle_event(press(KeyCode::Char('a')));
1220 a.handle_event(press(KeyCode::Tab));
1221 if let Mode::Create(s) = &a.mode {
1222 assert_eq!(s.base, "feature/alpha");
1223 }
1224 }
1225
1226 #[test]
1227 fn create_mode_tab_noop_without_candidates() {
1228 let mut a = app(&[("a", true)]);
1229 a.handle_event(press(KeyCode::Char('n')));
1230 for c in "feature/x".chars() {
1231 a.handle_event(press(KeyCode::Char(c)));
1232 }
1233 a.handle_event(press(KeyCode::Enter)); for c in "xyz".chars() {
1235 a.handle_event(press(KeyCode::Char(c)));
1236 }
1237 a.handle_event(press(KeyCode::Tab)); if let Mode::Create(s) = &a.mode {
1239 assert_eq!(s.base, "xyz");
1240 }
1241 let mut b = app(&[("a", true)]);
1243 b.branches = vec!["main".into()];
1244 b.handle_event(press(KeyCode::Char('n')));
1245 b.handle_event(press(KeyCode::Tab));
1246 if let Mode::Create(s) = &b.mode {
1247 assert!(s.branch.is_empty());
1248 }
1249 }
1250
1251 #[test]
1252 fn longest_common_prefix_cases() {
1253 assert_eq!(longest_common_prefix(&[]), None);
1254 assert_eq!(longest_common_prefix(&["solo"]).as_deref(), Some("solo"));
1255 assert_eq!(
1256 longest_common_prefix(&["feature/a", "feature/b"]).as_deref(),
1257 Some("feature/")
1258 );
1259 assert_eq!(longest_common_prefix(&["abc", "xyz"]).as_deref(), Some(""));
1260 }
1261
1262 #[test]
1263 fn create_mode_escape_cancels() {
1264 let mut a = app(&[("a", true)]);
1265 a.handle_event(press(KeyCode::Char('n')));
1266 a.handle_event(press(KeyCode::Esc));
1267 assert_eq!(a.mode, Mode::List);
1268 }
1269
1270 #[test]
1271 fn create_mode_dropdown_filters_navigates_and_accepts() {
1272 let mut a = app(&[("a", true)]);
1273 a.branches = vec!["main".into(), "origin/main".into(), "origin/dev".into()];
1274 a.handle_event(press(KeyCode::Char('n')));
1275 for c in "feature/login".chars() {
1278 a.handle_event(press(KeyCode::Char(c)));
1279 }
1280 a.handle_event(press(KeyCode::Enter));
1281 if let Mode::Create(s) = &a.mode {
1282 assert_eq!(s.step, CreateStep::Base);
1283 assert!(s.options.is_open());
1285 } else {
1286 panic!("expected create mode");
1287 }
1288 for c in "origin".chars() {
1290 a.handle_event(press(KeyCode::Char(c)));
1291 }
1292 a.handle_event(press(KeyCode::Down));
1295 a.handle_event(press(KeyCode::Enter));
1296 if let Mode::Create(s) = &a.mode {
1297 assert_eq!(s.base, "origin/dev");
1298 assert!(!s.options.is_open()); }
1300 let effect = a.handle_event(press(KeyCode::Enter));
1302 assert_eq!(
1303 effect,
1304 Effect::Create {
1305 branch: "feature/login".into(),
1306 base: Some("origin/dev".into()),
1307 decision: None,
1308 }
1309 );
1310 }
1311
1312 #[test]
1313 fn create_mode_escape_closes_dropdown_before_modal() {
1314 let mut a = app(&[("a", true)]);
1315 a.branches = vec!["main".into()];
1316 a.handle_event(press(KeyCode::Char('n')));
1317 a.handle_event(press(KeyCode::Char('m'))); if let Mode::Create(s) = &a.mode {
1319 assert!(s.options.is_open());
1320 }
1321 a.handle_event(press(KeyCode::Esc)); if let Mode::Create(s) = &a.mode {
1323 assert!(!s.options.is_open());
1324 } else {
1325 panic!("expected create mode (still open)");
1326 }
1327 a.handle_event(press(KeyCode::Esc)); assert_eq!(a.mode, Mode::List);
1329 }
1330
1331 #[test]
1332 fn confirm_remove_y_removes() {
1333 let mut a = app(&[("main", true), ("feat", false)]);
1334 a.selected = 1;
1335 a.handle_event(press(KeyCode::Char('d')));
1336 assert!(matches!(a.mode, Mode::ConfirmRemove(_)));
1337 let effect = a.handle_event(press(KeyCode::Char('y')));
1338 assert!(matches!(effect, Effect::Remove(_)));
1340 assert_eq!(a.mode, Mode::List);
1341 }
1342
1343 #[test]
1344 fn confirm_remove_other_key_cancels() {
1345 let mut a = app(&[("main", true), ("feat", false)]);
1346 a.selected = 1;
1347 a.handle_event(press(KeyCode::Char('d')));
1348 let effect = a.handle_event(press(KeyCode::Char('n')));
1349 assert_eq!(effect, Effect::None);
1350 assert_eq!(a.mode, Mode::List);
1351 }
1352
1353 #[test]
1354 fn pr_picker_opens_and_fetches() {
1355 let mut a = app(&[("a", true)]);
1356 let effect = a.handle_event(press(KeyCode::Char('p')));
1357 assert_eq!(effect, Effect::FetchPrs);
1358 assert!(matches!(a.mode, Mode::PrPicker(_)));
1359 if let Mode::PrPicker(s) = &mut a.mode {
1361 s.loading = false;
1362 s.prs = vec![
1363 crate::tui::app::PrItem {
1364 number: 7,
1365 title: "x".into(),
1366 author: "a".into(),
1367 state: "open".into(),
1368 created_at: String::new(),
1369 },
1370 crate::tui::app::PrItem {
1371 number: 9,
1372 title: "y".into(),
1373 author: "b".into(),
1374 state: "open".into(),
1375 created_at: String::new(),
1376 },
1377 ];
1378 }
1379 a.handle_event(press(KeyCode::Down));
1380 let effect = a.handle_event(press(KeyCode::Enter));
1381 assert_eq!(effect, Effect::CheckoutPr(9));
1382 }
1383
1384 #[test]
1385 fn checkout_key_opens_picker_for_selected_worktree() {
1386 let mut a = app(&[("main", true), ("feature/x", false)]);
1387 a.branches = vec!["main".into(), "feature/x".into()];
1388 a.selected = 1; a.handle_event(press(KeyCode::Char('c')));
1390 if let Mode::Checkout(s) = &a.mode {
1391 assert_eq!(s.worktree_index, a.visible[1]);
1393 assert_eq!(s.options.match_count(), 2);
1394 assert!(s.options.is_open());
1396 } else {
1397 panic!("expected checkout mode");
1398 }
1399 }
1400
1401 #[test]
1402 fn checkout_picker_arrows_select_a_branch_without_typing() {
1403 let mut a = app(&[("main", true)]);
1406 a.branches = vec!["main".into(), "origin/feature/x".into()];
1407 a.handle_event(press(KeyCode::Char('c')));
1408 a.handle_event(press(KeyCode::Down)); let effect = a.handle_event(press(KeyCode::Enter));
1410 assert_eq!(
1411 effect,
1412 Effect::CheckoutBranch {
1413 worktree_index: 0,
1414 branch: "origin/feature/x".into(),
1415 }
1416 );
1417 }
1418
1419 #[test]
1420 fn checkout_picker_submits_typed_branch() {
1421 let mut a = app(&[("main", true)]);
1422 a.branches = vec!["main".into(), "feature/x".into()];
1423 a.handle_event(press(KeyCode::Char('c')));
1424 for ch in "feature/x".chars() {
1425 a.handle_event(press(KeyCode::Char(ch)));
1426 }
1427 let effect = a.handle_event(press(KeyCode::Enter));
1429 assert_eq!(
1430 effect,
1431 Effect::CheckoutBranch {
1432 worktree_index: 0,
1433 branch: "feature/x".into(),
1434 }
1435 );
1436 }
1437
1438 #[test]
1439 fn checkout_picker_submits_highlighted_suggestion() {
1440 let mut a = app(&[("main", true)]);
1441 a.branches = vec!["main".into(), "feature/x".into(), "feature/y".into()];
1442 a.handle_event(press(KeyCode::Char('c')));
1443 for ch in "feature".chars() {
1444 a.handle_event(press(KeyCode::Char(ch)));
1445 }
1446 a.handle_event(press(KeyCode::Down));
1448 let effect = a.handle_event(press(KeyCode::Enter));
1449 assert_eq!(
1450 effect,
1451 Effect::CheckoutBranch {
1452 worktree_index: 0,
1453 branch: "feature/y".into(),
1454 }
1455 );
1456 }
1457
1458 #[test]
1459 fn checkout_picker_empty_query_errors() {
1460 let mut a = app(&[("main", true)]);
1461 a.handle_event(press(KeyCode::Char('c')));
1462 let effect = a.handle_event(press(KeyCode::Enter));
1463 assert_eq!(effect, Effect::None);
1464 if let Mode::Checkout(s) = &a.mode {
1465 assert!(s.error.is_some());
1466 } else {
1467 panic!("expected checkout mode (still open)");
1468 }
1469 }
1470
1471 #[test]
1472 fn checkout_picker_escape_closes_dropdown_then_cancels() {
1473 let mut a = app(&[("main", true)]);
1474 a.branches = vec!["main".into()];
1475 a.handle_event(press(KeyCode::Char('c')));
1476 a.handle_event(press(KeyCode::Char('m'))); if let Mode::Checkout(s) = &a.mode {
1478 assert!(s.options.is_open());
1479 }
1480 a.handle_event(press(KeyCode::Esc)); if let Mode::Checkout(s) = &a.mode {
1482 assert!(!s.options.is_open());
1483 } else {
1484 panic!("expected checkout mode (still open)");
1485 }
1486 a.handle_event(press(KeyCode::Esc)); assert_eq!(a.mode, Mode::List);
1488 }
1489
1490 #[test]
1491 fn compose_typing_field_switch_and_newline() {
1492 use crate::tui::app::PrComposeState;
1493 let mut a = app(&[("a", true)]);
1494 a.mode = Mode::PrCompose(PrComposeState::default());
1495 a.handle_event(press(KeyCode::Char('h')));
1496 a.handle_event(press(KeyCode::Char('i')));
1497 if let Mode::PrCompose(s) = &a.mode {
1498 assert_eq!(s.title, "hi");
1499 assert_eq!(s.field, ComposeField::Title);
1500 } else {
1501 panic!("expected compose mode");
1502 }
1503 a.handle_event(press(KeyCode::Enter));
1505 if let Mode::PrCompose(s) = &a.mode {
1506 assert_eq!(s.field, ComposeField::Body);
1507 }
1508 a.handle_event(press(KeyCode::Char('x')));
1510 a.handle_event(press(KeyCode::Enter));
1511 a.handle_event(press(KeyCode::Char('y')));
1512 if let Mode::PrCompose(s) = &a.mode {
1513 assert_eq!(s.body, "x\ny");
1514 }
1515 a.handle_event(press(KeyCode::BackTab));
1517 a.handle_event(press(KeyCode::Backspace));
1518 if let Mode::PrCompose(s) = &a.mode {
1519 assert_eq!(s.field, ComposeField::Title);
1520 assert_eq!(s.title, "h");
1521 }
1522 }
1523
1524 #[test]
1525 fn compose_tab_cycles_all_four_fields() {
1526 use crate::tui::app::PrComposeState;
1527 let mut a = app(&[("a", true)]);
1528 a.mode = Mode::PrCompose(PrComposeState::default());
1529 let field = |a: &App| {
1530 if let Mode::PrCompose(s) = &a.mode {
1531 s.field
1532 } else {
1533 panic!("expected compose mode")
1534 }
1535 };
1536 assert_eq!(field(&a), ComposeField::Title);
1537 a.handle_event(press(KeyCode::Tab));
1538 assert_eq!(field(&a), ComposeField::Body);
1539 a.handle_event(press(KeyCode::Tab));
1540 assert_eq!(field(&a), ComposeField::Model);
1541 a.handle_event(press(KeyCode::Tab));
1542 assert_eq!(field(&a), ComposeField::Effort);
1543 a.handle_event(press(KeyCode::Tab));
1544 assert_eq!(field(&a), ComposeField::Title); }
1546
1547 #[test]
1548 fn compose_model_effort_fields_pick_with_arrows() {
1549 use crate::agent::{AgentModel, Effort};
1550 use crate::tui::app::PrComposeState;
1551 let mut a = app(&[("a", true)]);
1552 a.mode = Mode::PrCompose(PrComposeState::default());
1553 a.handle_event(press(KeyCode::Tab));
1555 a.handle_event(press(KeyCode::Tab));
1556 a.handle_event(press(KeyCode::Down));
1558 a.handle_event(press(KeyCode::Up));
1559 a.handle_event(press(KeyCode::Char('z')));
1561 if let Mode::PrCompose(s) = &a.mode {
1562 assert_eq!(s.field, ComposeField::Model);
1563 assert_eq!(s.model, AgentModel::Sonnet);
1564 assert_eq!(s.title, "");
1565 } else {
1566 panic!("expected compose mode");
1567 }
1568 a.handle_event(press(KeyCode::Tab));
1570 a.handle_event(press(KeyCode::Down));
1571 if let Mode::PrCompose(s) = &a.mode {
1572 assert_eq!(s.field, ComposeField::Effort);
1573 assert_eq!(s.effort, Effort::Medium.next());
1574 }
1575 }
1576
1577 #[test]
1578 fn compose_ctrl_s_requires_title_and_is_not_typed() {
1579 use crate::tui::app::PrComposeState;
1580 let mut a = app(&[("a", true)]);
1581 a.mode = Mode::PrCompose(PrComposeState::default());
1582 let effect = a.handle_event(ctrl('s'));
1583 assert_eq!(effect, Effect::None);
1584 if let Mode::PrCompose(s) = &a.mode {
1585 assert!(s.error.is_some());
1586 assert_eq!(s.title, "");
1588 } else {
1589 panic!("expected compose mode");
1590 }
1591 }
1592
1593 #[test]
1594 fn compose_ctrl_s_submits_when_title_present() {
1595 use crate::tui::app::PrComposeState;
1596 let mut a = app(&[("a", true)]);
1597 a.mode = Mode::PrCompose(PrComposeState {
1598 title: "T".into(),
1599 body: "B".into(),
1600 ..Default::default()
1601 });
1602 let effect = a.handle_event(ctrl('s'));
1603 assert_eq!(
1604 effect,
1605 Effect::SubmitPr {
1606 title: "T".into(),
1607 body: "B".into(),
1608 draft: false
1609 }
1610 );
1611 if let Mode::PrCompose(s) = &a.mode {
1612 assert!(s.submitting);
1613 }
1614 }
1615
1616 #[test]
1617 fn compose_ctrl_d_toggles_draft_and_esc_cancels() {
1618 use crate::tui::app::PrComposeState;
1619 let mut a = app(&[("a", true)]);
1620 a.mode = Mode::PrCompose(PrComposeState::default());
1621 a.handle_event(ctrl('d'));
1622 if let Mode::PrCompose(s) = &a.mode {
1623 assert!(s.draft);
1624 }
1625 a.handle_event(press(KeyCode::Esc));
1626 assert_eq!(a.mode, Mode::List);
1627 }
1628
1629 #[test]
1630 fn compose_ctrl_a_triggers_ai_fill() {
1631 use crate::tui::app::PrComposeState;
1632 let mut a = app(&[("a", true)]);
1633 a.mode = Mode::PrCompose(PrComposeState::default());
1634 let effect = a.handle_event(ctrl('a'));
1635 assert_eq!(effect, Effect::DraftPrAi);
1636 if let Mode::PrCompose(s) = &a.mode {
1637 assert!(s.submitting);
1639 assert_eq!(s.title, "");
1640 } else {
1641 panic!("expected compose mode");
1642 }
1643 }
1644
1645 #[test]
1646 fn compose_ctrl_m_and_e_cycle_model_and_effort() {
1647 use crate::agent::{AgentModel, Effort};
1648 use crate::tui::app::PrComposeState;
1649 let mut a = app(&[("a", true)]);
1650 a.mode = Mode::PrCompose(PrComposeState::default());
1651 a.handle_event(ctrl('m'));
1653 a.handle_event(ctrl('e'));
1654 if let Mode::PrCompose(s) = &a.mode {
1655 assert_eq!(s.model, AgentModel::Sonnet.next());
1656 assert_eq!(s.effort, Effort::Medium.next());
1657 assert_eq!(s.title, "");
1658 } else {
1659 panic!("expected compose mode");
1660 }
1661 }
1662
1663 #[test]
1664 fn help_dismisses_on_any_key() {
1665 let mut a = app(&[("a", true)]);
1666 a.handle_event(press(KeyCode::Char('?')));
1667 assert_eq!(a.mode, Mode::Help);
1668 a.handle_event(press(KeyCode::Char('x')));
1669 assert_eq!(a.mode, Mode::List);
1670 }
1671
1672 #[test]
1673 fn sort_and_sidebar_keys() {
1674 let mut a = app(&[("a", true)]);
1675 a.handle_event(press(KeyCode::Char('s')));
1676 assert_eq!(a.sort.key, crate::model::SortKey::Dirty);
1677 a.handle_event(press(KeyCode::Char('S')));
1678 assert!(a.sort.descending);
1679 let w0 = a.sidebar_width;
1680 a.handle_event(press(KeyCode::Char('+')));
1681 assert_eq!(a.sidebar_width, w0 + 1);
1682 a.handle_event(press(KeyCode::Char('-')));
1683 assert_eq!(a.sidebar_width, w0);
1684 a.handle_event(press(KeyCode::Char('\\')));
1685 assert!(!a.show_sidebar);
1686 }
1687
1688 #[test]
1689 fn resize_too_small_exits() {
1690 let mut a = app(&[("a", true)]);
1691 assert_eq!(a.handle_event(Event::Resize(100, 4)), Effect::TooSmall);
1692 assert_eq!(a.handle_event(Event::Resize(100, 20)), Effect::None);
1693 assert_eq!(a.size, (100, 20));
1694 }
1695
1696 #[test]
1697 fn open_editor_and_refresh() {
1698 let mut a = app(&[("a", true)]);
1699 assert_eq!(
1700 a.handle_event(press(KeyCode::Char('o'))),
1701 Effect::OpenEditor(std::path::PathBuf::from("/r/a"))
1702 );
1703 assert_eq!(a.handle_event(press(KeyCode::Char('r'))), Effect::Refresh);
1704 }
1705
1706 #[test]
1707 fn mouse_click_selects_and_wheel_scrolls() {
1708 let mut a = app(&[("a", true), ("b", false), ("c", false)]);
1709 let click = Event::Mouse(MouseEvent {
1711 kind: MouseEventKind::Down(MouseButton::Left),
1712 column: 5,
1713 row: 3,
1714 modifiers: KeyModifiers::empty(),
1715 });
1716 a.handle_event(click);
1717 assert_eq!(a.selected, 2);
1718 a.handle_event(Event::Mouse(MouseEvent {
1719 kind: MouseEventKind::ScrollUp,
1720 column: 5,
1721 row: 3,
1722 modifiers: KeyModifiers::empty(),
1723 }));
1724 assert_eq!(a.selected, 1);
1725 }
1726
1727 #[test]
1728 fn mouse_ignored_when_disabled() {
1729 let mut a = app(&[("a", true), ("b", false)]);
1730 a.mouse = false;
1731 a.selected = 0;
1732 a.handle_event(Event::Mouse(MouseEvent {
1733 kind: MouseEventKind::ScrollDown,
1734 column: 5,
1735 row: 3,
1736 modifiers: KeyModifiers::empty(),
1737 }));
1738 assert_eq!(a.selected, 0);
1739 }
1740
1741 #[test]
1742 fn mouse_in_modal_does_not_touch_background() {
1743 let mut a = app(&[("a", true), ("b", false), ("c", false)]);
1746 a.selected = 1;
1747 a.mode = Mode::Create(CreateState::default());
1748 let click = Event::Mouse(MouseEvent {
1749 kind: MouseEventKind::Down(MouseButton::Left),
1750 column: 5,
1751 row: 3,
1752 modifiers: KeyModifiers::empty(),
1753 });
1754 assert_eq!(a.handle_event(click), Effect::None);
1755 assert_eq!(a.selected, 1);
1756 assert!(matches!(a.mode, Mode::Create(_)));
1757 a.handle_event(Event::Mouse(MouseEvent {
1758 kind: MouseEventKind::ScrollDown,
1759 column: 5,
1760 row: 3,
1761 modifiers: KeyModifiers::empty(),
1762 }));
1763 assert_eq!(a.selected, 1); }
1765
1766 #[test]
1767 fn mouse_scroll_moves_create_dropdown() {
1768 let mut a = app(&[("a", true)]);
1771 let mut options = crate::tui::OptionList::new(vec![
1772 "main".into(),
1773 "origin/main".into(),
1774 "origin/dev".into(),
1775 ]);
1776 options.open();
1777 a.mode = Mode::Create(CreateState {
1778 options,
1779 ..Default::default()
1780 });
1781 let wheel = |kind| {
1782 Event::Mouse(MouseEvent {
1783 kind,
1784 column: 5,
1785 row: 5,
1786 modifiers: KeyModifiers::empty(),
1787 })
1788 };
1789 a.handle_event(wheel(MouseEventKind::ScrollDown));
1790 if let Mode::Create(s) = &a.mode {
1791 assert_eq!(s.options.selected(), Some("origin/main"));
1793 } else {
1794 panic!("expected create mode");
1795 }
1796 a.handle_event(wheel(MouseEventKind::ScrollUp));
1797 if let Mode::Create(s) = &a.mode {
1798 assert_eq!(s.options.selected(), Some("main"));
1799 }
1800 }
1801
1802 #[test]
1803 fn mouse_scroll_moves_pr_picker_selection() {
1804 use crate::tui::app::{PrItem, PrPickerState};
1805 let pr = |number| PrItem {
1806 number,
1807 title: "t".into(),
1808 author: "a".into(),
1809 state: "open".into(),
1810 created_at: String::new(),
1811 };
1812 let mut a = app(&[("a", true)]);
1813 a.mode = Mode::PrPicker(PrPickerState {
1814 loading: false,
1815 prs: vec![pr(1), pr(2)],
1816 ..Default::default()
1817 });
1818 let wheel = |kind| {
1819 Event::Mouse(MouseEvent {
1820 kind,
1821 column: 5,
1822 row: 5,
1823 modifiers: KeyModifiers::empty(),
1824 })
1825 };
1826 a.handle_event(wheel(MouseEventKind::ScrollDown));
1827 if let Mode::PrPicker(s) = &a.mode {
1828 assert_eq!(s.selected, 1);
1829 } else {
1830 panic!("expected pr picker");
1831 }
1832 a.handle_event(wheel(MouseEventKind::ScrollDown));
1834 if let Mode::PrPicker(s) = &a.mode {
1835 assert_eq!(s.selected, 1);
1836 }
1837 a.handle_event(wheel(MouseEventKind::ScrollUp));
1838 if let Mode::PrPicker(s) = &a.mode {
1839 assert_eq!(s.selected, 0);
1840 }
1841 }
1842
1843 #[test]
1844 fn tab_toggles_focus() {
1845 let mut a = app(&[("a", true)]);
1846 assert_eq!(a.focus, Pane::List);
1847 a.handle_event(press(KeyCode::Tab));
1848 assert_eq!(a.focus, Pane::Detail);
1849 }
1850
1851 #[test]
1852 fn navigation_scrolls_detail_when_focused() {
1853 let mut a = app(&[("a", true), ("b", false)]);
1854 a.worktrees[0].recent_commits = vec![crate::model::Commit {
1855 hash: "h".into(),
1856 subject: "s".into(),
1857 author: "x".into(),
1858 timestamp: "2024-01-15T10:30:00Z".into(),
1859 }];
1860 a.handle_event(press(KeyCode::Tab)); a.handle_event(press(KeyCode::Char('j'))); assert_eq!(a.detail_scroll, 1);
1863 assert_eq!(a.selected, 0);
1864 a.handle_event(press(KeyCode::Char('k')));
1865 assert_eq!(a.detail_scroll, 0);
1866 a.handle_event(press(KeyCode::Tab));
1868 a.detail_scroll = 3;
1869 a.handle_event(press(KeyCode::Char('j')));
1870 assert_eq!(a.selected, 1);
1871 assert_eq!(a.detail_scroll, 0);
1872 }
1873
1874 #[test]
1875 fn mouse_click_on_status_bar_and_title_row_select_nothing() {
1876 let mut a = app(&[("a", true), ("b", false), ("c", false)]);
1877 a.size = (100, 30);
1878 a.selected = 1;
1879 let click = |row: u16| {
1881 Event::Mouse(MouseEvent {
1882 kind: MouseEventKind::Down(MouseButton::Left),
1883 column: 5,
1884 row,
1885 modifiers: KeyModifiers::empty(),
1886 })
1887 };
1888 a.handle_event(click(29));
1889 assert_eq!(a.selected, 1);
1890 a.handle_event(click(0));
1892 assert_eq!(a.selected, 1);
1893 }
1894}