1use anyhow::Result;
18use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
19
20use crate::{
21 CommitInfo,
22 fragmap::{FragMap, SquashableScope},
23 repo::ConflictState,
24};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum KeyCommand {
29 MoveUp,
30 MoveDown,
31 PageUp,
32 PageDown,
33 ScrollLeft,
34 ScrollRight,
35 ToggleDetail,
36 ShowHelp,
37 Split,
38 Squash,
39 Fixup,
40 Reword,
41 Drop,
42 Move,
43 Mergetool,
44 Update,
45 Quit,
46 Confirm,
47 ForceQuit,
49 SeparatorLeft,
50 SeparatorRight,
51 None,
52}
53
54pub fn read_event() -> Result<Event> {
59 Ok(event::read()?)
60}
61
62#[derive(Debug)]
68pub enum AppAction {
69 Handled,
71 Quit,
73 ReloadCommits,
75 PrepareSplit {
77 strategy: SplitStrategy,
78 commit_oid: String,
79 },
80 ExecuteSplit {
82 strategy: SplitStrategy,
83 commit_oid: String,
84 head_oid: String,
85 },
86 PrepareDropConfirm {
88 commit_oid: String,
89 commit_summary: String,
90 },
91 ExecuteDrop {
93 commit_oid: String,
94 head_oid: String,
95 },
96 RebaseContinue(ConflictState),
98 RebaseAbort(ConflictState),
100 RunMergetool {
102 files: Vec<String>,
103 conflict_state: ConflictState,
104 },
105 PrepareReword {
107 commit_oid: String,
108 current_message: String,
109 },
110 PrepareSquash {
113 source_oid: String,
114 target_oid: String,
115 source_message: String,
116 target_message: String,
117 is_fixup: bool,
118 },
119 ExecuteMove {
121 source_oid: String,
122 insert_after_oid: String,
123 },
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum SplitStrategy {
129 PerFile,
130 PerHunk,
131 PerHunkGroup,
132}
133
134impl SplitStrategy {
135 pub const ALL: [SplitStrategy; 3] = [
136 SplitStrategy::PerFile,
137 SplitStrategy::PerHunk,
138 SplitStrategy::PerHunkGroup,
139 ];
140
141 pub fn label(self) -> &'static str {
142 match self {
143 SplitStrategy::PerFile => "Per file",
144 SplitStrategy::PerHunk => "Per hunk",
145 SplitStrategy::PerHunkGroup => "Per hunk group",
146 }
147 }
148
149 pub fn description(self) -> &'static str {
150 match self {
151 SplitStrategy::PerFile => "Create one commit per changed file",
152 SplitStrategy::PerHunk => "Create one commit per diff hunk",
153 SplitStrategy::PerHunkGroup => "Create one commit per hunk group",
154 }
155 }
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
160pub enum AppMode {
161 CommitList,
163 CommitDetail,
165 SplitSelect { strategy_index: usize },
167 SplitConfirm(PendingSplit),
169 DropConfirm(PendingDrop),
171 RebaseConflict(Box<ConflictState>),
174 SquashSelect { source_index: usize, is_fixup: bool },
177 MoveSelect {
181 source_index: usize,
182 insert_before: usize,
183 },
184 Help(Box<AppMode>),
186}
187
188impl AppMode {
189 pub fn background(&self) -> Option<AppMode> {
192 match self {
193 AppMode::CommitList | AppMode::CommitDetail => None,
194 AppMode::SquashSelect { .. } | AppMode::MoveSelect { .. } => None,
195 AppMode::SplitSelect { .. }
196 | AppMode::SplitConfirm(_)
197 | AppMode::DropConfirm(_)
198 | AppMode::RebaseConflict(_) => Some(AppMode::CommitList),
199 AppMode::Help(prev) => Some(prev.as_ref().clone()),
200 }
201 }
202
203 pub fn parse_key(&self, event: Event) -> KeyCommand {
209 if let Event::Key(KeyEvent {
210 code,
211 modifiers,
212 kind,
213 ..
214 }) = event
215 && kind == event::KeyEventKind::Press
216 {
217 if code == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL) {
219 return KeyCommand::ForceQuit;
220 }
221 if modifiers.contains(KeyModifiers::CONTROL) {
222 match code {
223 KeyCode::Left => return KeyCommand::SeparatorLeft,
224 KeyCode::Right => return KeyCommand::SeparatorRight,
225 _ => {}
226 }
227 }
228 return match code {
229 KeyCode::Up | KeyCode::Char('k') => KeyCommand::MoveUp,
230 KeyCode::Down | KeyCode::Char('j') => KeyCommand::MoveDown,
231 KeyCode::PageUp => KeyCommand::PageUp,
232 KeyCode::PageDown => KeyCommand::PageDown,
233 KeyCode::Left => KeyCommand::ScrollLeft,
234 KeyCode::Right => KeyCommand::ScrollRight,
235 KeyCode::Enter => KeyCommand::Confirm,
236 KeyCode::Char('i') => KeyCommand::ToggleDetail,
237 KeyCode::Char('h') => KeyCommand::ShowHelp,
238 KeyCode::Char('p') => KeyCommand::Split,
239 KeyCode::Char('s') => KeyCommand::Squash,
240 KeyCode::Char('f') => KeyCommand::Fixup,
241 KeyCode::Char('r') => KeyCommand::Reword,
242 KeyCode::Char('d') => KeyCommand::Drop,
243 KeyCode::Char('m') => match self {
244 AppMode::RebaseConflict(_) => KeyCommand::Mergetool,
245 _ => KeyCommand::Move,
246 },
247 KeyCode::Char('u') => KeyCommand::Update,
248 KeyCode::Esc | KeyCode::Char('q') => KeyCommand::Quit,
249 _ => KeyCommand::None,
250 };
251 }
252 KeyCommand::None
253 }
254}
255
256#[derive(Debug, Clone, PartialEq, Eq)]
258pub struct PendingSplit {
259 pub strategy: SplitStrategy,
260 pub commit_oid: String,
261 pub head_oid: String,
262 pub count: usize,
263}
264
265#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct PendingDrop {
268 pub commit_oid: String,
269 pub commit_summary: String,
270 pub head_oid: String,
271}
272
273pub struct AppState {
278 pub should_quit: bool,
279 pub commits: Vec<CommitInfo>,
280 pub selection_index: usize,
281 pub reverse: bool,
282 pub full_fragmap: bool,
284 pub squashable_scope: SquashableScope,
286 pub reference_oid: String,
289 pub fragmap: Option<FragMap>,
292 pub fragmap_scroll_offset: usize,
294 pub mode: AppMode,
296 pub detail_scroll_offset: usize,
298 pub max_detail_scroll: usize,
300 pub detail_h_scroll_offset: usize,
302 pub max_detail_h_scroll: usize,
304 pub commit_list_visible_height: usize,
306 pub detail_visible_height: usize,
308 pub status_message: Option<String>,
310 pub status_is_error: bool,
312 pub separator_offset: i16,
314}
315
316impl AppState {
317 pub fn new() -> Self {
319 Self {
320 should_quit: false,
321 commits: Vec::new(),
322 selection_index: 0,
323 reverse: false,
324 full_fragmap: false,
325 squashable_scope: SquashableScope::Group,
326 reference_oid: String::new(),
327 fragmap: None,
328 fragmap_scroll_offset: 0,
329 mode: AppMode::CommitList,
330 detail_scroll_offset: 0,
331 max_detail_scroll: 0,
332 detail_h_scroll_offset: 0,
333 max_detail_h_scroll: 0,
334 commit_list_visible_height: 0,
335 detail_visible_height: 0,
336 status_message: None,
337 status_is_error: false,
338 separator_offset: 0,
339 }
340 }
341
342 pub fn with_commits(commits: Vec<CommitInfo>) -> Self {
344 let selection_index = commits.len().saturating_sub(1);
345 Self {
346 should_quit: false,
347 commits,
348 selection_index,
349 reverse: false,
350 full_fragmap: false,
351 squashable_scope: SquashableScope::Group,
352 reference_oid: String::new(),
353 fragmap: None,
354 fragmap_scroll_offset: 0,
355 mode: AppMode::CommitList,
356 detail_scroll_offset: 0,
357 max_detail_scroll: 0,
358 detail_h_scroll_offset: 0,
359 max_detail_h_scroll: 0,
360 commit_list_visible_height: 0,
361 detail_visible_height: 0,
362 status_message: None,
363 status_is_error: false,
364 separator_offset: 0,
365 }
366 }
367
368 pub fn move_up(&mut self) {
371 if self.selection_index > 0 {
372 self.selection_index -= 1;
373 }
374 }
375
376 pub fn move_down(&mut self) {
379 if !self.commits.is_empty() && self.selection_index < self.commits.len() - 1 {
380 self.selection_index += 1;
381 }
382 }
383
384 pub fn scroll_fragmap_left(&mut self) {
386 if self.fragmap_scroll_offset > 0 {
387 self.fragmap_scroll_offset -= 1;
388 }
389 }
390
391 pub fn scroll_fragmap_right(&mut self) {
393 self.fragmap_scroll_offset += 1;
394 }
395
396 pub fn scroll_detail_up(&mut self) {
398 if self.detail_scroll_offset > 0 {
399 self.detail_scroll_offset -= 1;
400 }
401 }
402
403 pub fn scroll_detail_down(&mut self) {
405 if self.detail_scroll_offset < self.max_detail_scroll {
406 self.detail_scroll_offset += 1;
407 }
408 }
409
410 pub fn scroll_detail_left(&mut self) {
412 if self.detail_h_scroll_offset > 0 {
413 self.detail_h_scroll_offset -= 1;
414 }
415 }
416
417 pub fn scroll_detail_right(&mut self) {
419 if self.detail_h_scroll_offset < self.max_detail_h_scroll {
420 self.detail_h_scroll_offset += 1;
421 }
422 }
423
424 pub fn page_up(&mut self, visible_height: usize) {
426 let page_size = visible_height.saturating_sub(1).max(1); self.selection_index = self.selection_index.saturating_sub(page_size);
428 }
429
430 pub fn page_down(&mut self, visible_height: usize) {
432 if self.commits.is_empty() {
433 return;
434 }
435 let page_size = visible_height.saturating_sub(1).max(1); let new_index = self.selection_index.saturating_add(page_size);
437 self.selection_index = new_index.min(self.commits.len() - 1);
438 }
439
440 pub fn scroll_detail_page_up(&mut self, visible_height: usize) {
442 let page_size = visible_height.saturating_sub(1).max(1);
443 self.detail_scroll_offset = self.detail_scroll_offset.saturating_sub(page_size);
444 }
445
446 pub fn scroll_detail_page_down(&mut self, visible_height: usize) {
448 let page_size = visible_height.saturating_sub(1).max(1);
449 let new_offset = self.detail_scroll_offset.saturating_add(page_size);
450 self.detail_scroll_offset = new_offset.min(self.max_detail_scroll);
451 }
452
453 pub fn enter_split_confirm(
455 &mut self,
456 strategy: SplitStrategy,
457 commit_oid: String,
458 head_oid: String,
459 count: usize,
460 ) {
461 self.mode = AppMode::SplitConfirm(PendingSplit {
462 strategy,
463 commit_oid,
464 head_oid,
465 count,
466 });
467 }
468
469 pub fn cancel_split_confirm(&mut self) {
471 self.mode = AppMode::CommitList;
472 }
473
474 pub fn enter_drop_confirm(
476 &mut self,
477 commit_oid: String,
478 commit_summary: String,
479 head_oid: String,
480 ) {
481 self.mode = AppMode::DropConfirm(PendingDrop {
482 commit_oid,
483 commit_summary,
484 head_oid,
485 });
486 }
487
488 pub fn cancel_drop_confirm(&mut self) {
490 self.mode = AppMode::CommitList;
491 }
492
493 pub fn enter_rebase_conflict(&mut self, state: ConflictState) {
495 self.mode = AppMode::RebaseConflict(Box::new(state));
496 }
497
498 pub fn enter_split_select(&mut self) {
501 if let Some(commit) = self.commits.get(self.selection_index)
502 && (commit.oid == "staged" || commit.oid == "unstaged")
503 {
504 self.set_error_message("Cannot split staged/unstaged changes");
505 return;
506 }
507 self.mode = AppMode::SplitSelect { strategy_index: 0 };
508 }
509
510 pub fn enter_squash_select(&mut self) {
513 self.enter_squash_or_fixup_select(false);
514 }
515
516 pub fn enter_fixup_select(&mut self) {
518 self.enter_squash_or_fixup_select(true);
519 }
520
521 fn enter_squash_or_fixup_select(&mut self, is_fixup: bool) {
522 let label = if is_fixup { "fixup" } else { "squash" };
523 if let Some(commit) = self.commits.get(self.selection_index)
524 && (commit.oid == "staged" || commit.oid == "unstaged")
525 {
526 self.set_error_message(format!("Cannot {label} staged/unstaged changes"));
527 return;
528 }
529 let real_count = self
530 .commits
531 .iter()
532 .filter(|c| c.oid != "staged" && c.oid != "unstaged")
533 .count();
534 if real_count < 2 {
535 self.set_error_message(format!(
536 "Nothing to {label} — only one commit on the branch"
537 ));
538 return;
539 }
540 self.mode = AppMode::SquashSelect {
541 source_index: self.selection_index,
542 is_fixup,
543 };
544 }
545
546 pub fn cancel_squash_select(&mut self) {
548 self.mode = AppMode::CommitList;
549 }
550
551 pub fn enter_move_select(&mut self) {
556 if let Some(commit) = self.commits.get(self.selection_index)
557 && (commit.oid == "staged" || commit.oid == "unstaged")
558 {
559 self.set_error_message("Cannot move staged/unstaged changes");
560 return;
561 }
562
563 let real_count = self
565 .commits
566 .iter()
567 .filter(|c| c.oid != "staged" && c.oid != "unstaged")
568 .count();
569 if real_count < 2 {
570 self.set_error_message("Nothing to move — only one commit on the branch");
571 return;
572 }
573
574 let source = self.selection_index;
575 let max = self.commits.len();
576 let insert_before = if source > 0 {
579 source - 1
580 } else {
581 2.min(max)
583 };
584 self.mode = AppMode::MoveSelect {
585 source_index: source,
586 insert_before,
587 };
588 }
589
590 pub fn cancel_move_select(&mut self) {
592 self.mode = AppMode::CommitList;
593 }
594
595 pub fn set_success_message(&mut self, msg: impl Into<String>) {
597 self.status_message = Some(msg.into());
598 self.status_is_error = false;
599 }
600
601 pub fn set_error_message(&mut self, msg: impl Into<String>) {
603 self.status_message = Some(msg.into());
604 self.status_is_error = true;
605 }
606
607 pub fn clear_status_message(&mut self) {
609 self.status_message = None;
610 self.status_is_error = false;
611 }
612
613 pub fn split_select_up(&mut self) {
615 if let AppMode::SplitSelect { strategy_index } = &mut self.mode
616 && *strategy_index > 0
617 {
618 *strategy_index -= 1;
619 }
620 }
621
622 pub fn split_select_down(&mut self) {
624 if let AppMode::SplitSelect { strategy_index } = &mut self.mode
625 && *strategy_index < SplitStrategy::ALL.len() - 1
626 {
627 *strategy_index += 1;
628 }
629 }
630
631 pub fn selected_split_strategy(&self) -> SplitStrategy {
633 if let AppMode::SplitSelect { strategy_index } = self.mode {
634 SplitStrategy::ALL[strategy_index]
635 } else {
636 SplitStrategy::ALL[0]
637 }
638 }
639
640 pub fn toggle_detail_view(&mut self) {
642 let new_mode = match &self.mode {
643 AppMode::CommitList => AppMode::CommitDetail,
644 AppMode::CommitDetail => AppMode::CommitList,
645 AppMode::Help(_)
646 | AppMode::SplitSelect { .. }
647 | AppMode::SplitConfirm(_)
648 | AppMode::DropConfirm(_)
649 | AppMode::RebaseConflict(_)
650 | AppMode::SquashSelect { .. }
651 | AppMode::MoveSelect { .. } => return,
652 };
653 self.mode = new_mode;
654 self.detail_scroll_offset = 0;
655 }
656
657 pub fn show_help(&mut self) {
659 if !matches!(self.mode, AppMode::Help(_)) {
660 let current = std::mem::replace(&mut self.mode, AppMode::CommitList);
661 self.mode = AppMode::Help(Box::new(current));
662 }
663 }
664
665 pub fn close_help(&mut self) {
667 if matches!(self.mode, AppMode::Help(_)) {
668 let prev = std::mem::replace(&mut self.mode, AppMode::CommitList);
669 if let AppMode::Help(prev_mode) = prev {
670 self.mode = *prev_mode;
671 }
672 }
673 }
674
675 pub fn toggle_help(&mut self) {
677 if matches!(self.mode, AppMode::Help(_)) {
678 self.close_help();
679 } else {
680 self.show_help();
681 }
682 }
683}
684
685impl Default for AppState {
686 fn default() -> Self {
687 Self::new()
688 }
689}
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694
695 fn create_test_commit(oid: &str, summary: &str) -> CommitInfo {
696 CommitInfo {
697 oid: oid.to_string(),
698 summary: summary.to_string(),
699 author: Some("Test Author".to_string()),
700 date: Some("2024-01-01".to_string()),
701 parent_oids: vec![],
702 message: summary.to_string(),
703 author_email: Some("test@example.com".to_string()),
704 author_date: Some(time::OffsetDateTime::from_unix_timestamp(1704110400).unwrap()),
705 committer: Some("Test Committer".to_string()),
706 committer_email: Some("committer@example.com".to_string()),
707 commit_date: Some(time::OffsetDateTime::from_unix_timestamp(1704110400).unwrap()),
708 }
709 }
710
711 #[test]
712 fn test_move_up_with_empty_list() {
713 let mut app = AppState::new();
714 assert_eq!(app.selection_index, 0);
715 app.move_up();
716 assert_eq!(app.selection_index, 0);
717 }
718
719 #[test]
720 fn test_move_up_at_top() {
721 let mut app = AppState::new();
722 app.commits = vec![
723 create_test_commit("abc123", "First"),
724 create_test_commit("def456", "Second"),
725 ];
726 app.selection_index = 0;
727 app.move_up();
728 assert_eq!(app.selection_index, 0);
729 }
730
731 #[test]
732 fn test_move_up_from_middle() {
733 let mut app = AppState::new();
734 app.commits = vec![
735 create_test_commit("abc123", "First"),
736 create_test_commit("def456", "Second"),
737 create_test_commit("ghi789", "Third"),
738 ];
739 app.selection_index = 2;
740 app.move_up();
741 assert_eq!(app.selection_index, 1);
742 app.move_up();
743 assert_eq!(app.selection_index, 0);
744 }
745
746 #[test]
747 fn test_move_down_with_empty_list() {
748 let mut app = AppState::new();
749 assert_eq!(app.selection_index, 0);
750 app.move_down();
751 assert_eq!(app.selection_index, 0);
752 }
753
754 #[test]
755 fn test_move_down_at_bottom() {
756 let mut app = AppState::new();
757 app.commits = vec![
758 create_test_commit("abc123", "First"),
759 create_test_commit("def456", "Second"),
760 ];
761 app.selection_index = 1;
762 app.move_down();
763 assert_eq!(app.selection_index, 1);
764 }
765
766 #[test]
767 fn test_move_down_from_middle() {
768 let mut app = AppState::new();
769 app.commits = vec![
770 create_test_commit("abc123", "First"),
771 create_test_commit("def456", "Second"),
772 create_test_commit("ghi789", "Third"),
773 ];
774 app.selection_index = 0;
775 app.move_down();
776 assert_eq!(app.selection_index, 1);
777 app.move_down();
778 assert_eq!(app.selection_index, 2);
779 }
780}