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 OpenEditor,
45 Update,
46 Quit,
47 Confirm,
48 ForceQuit,
50 SeparatorLeft,
51 SeparatorRight,
52 None,
53}
54
55pub fn read_event() -> Result<Event> {
60 Ok(event::read()?)
61}
62
63#[derive(Debug)]
69pub enum AppAction {
70 Handled,
72 Quit,
74 ReloadCommits,
76 PrepareSplit {
78 strategy: SplitStrategy,
79 commit_oid: String,
80 },
81 ExecuteSplit {
83 strategy: SplitStrategy,
84 commit_oid: String,
85 head_oid: String,
86 },
87 PrepareDropConfirm {
89 commit_oid: String,
90 commit_summary: String,
91 },
92 ExecuteDrop {
94 commit_oid: String,
95 head_oid: String,
96 },
97 RebaseContinue(ConflictState),
99 RebaseAbort(ConflictState),
101 RunMergetool {
103 files: Vec<String>,
104 conflict_state: ConflictState,
105 },
106 RunEditor {
108 files: Vec<String>,
109 conflict_state: ConflictState,
110 },
111 PrepareReword {
113 commit_oid: String,
114 current_message: String,
115 },
116 PrepareSquash {
119 source_oid: String,
120 target_oid: String,
121 source_message: String,
122 target_message: String,
123 is_fixup: bool,
124 },
125 ExecuteMove {
127 source_oid: String,
128 insert_after_oid: String,
129 },
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum SplitStrategy {
135 PerFile,
136 PerHunk,
137 PerHunkGroup,
138}
139
140impl SplitStrategy {
141 pub const ALL: [SplitStrategy; 3] = [
142 SplitStrategy::PerFile,
143 SplitStrategy::PerHunk,
144 SplitStrategy::PerHunkGroup,
145 ];
146
147 pub fn label(self) -> &'static str {
148 match self {
149 SplitStrategy::PerFile => "Per file",
150 SplitStrategy::PerHunk => "Per hunk",
151 SplitStrategy::PerHunkGroup => "Per hunk group",
152 }
153 }
154
155 pub fn description(self) -> &'static str {
156 match self {
157 SplitStrategy::PerFile => "Create one commit per changed file",
158 SplitStrategy::PerHunk => "Create one commit per diff hunk",
159 SplitStrategy::PerHunkGroup => "Create one commit per hunk group",
160 }
161 }
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum AppMode {
167 CommitList,
169 CommitDetail,
171 SplitSelect { strategy_index: usize },
173 SplitConfirm(PendingSplit),
175 DropConfirm(PendingDrop),
177 RebaseConflict(Box<ConflictState>),
180 SquashSelect { source_index: usize, is_fixup: bool },
183 MoveSelect {
187 source_index: usize,
188 insert_before: usize,
189 },
190 Help(Box<AppMode>),
192}
193
194impl AppMode {
195 pub fn background(&self) -> Option<AppMode> {
198 match self {
199 AppMode::CommitList | AppMode::CommitDetail => None,
200 AppMode::SquashSelect { .. } | AppMode::MoveSelect { .. } => None,
201 AppMode::SplitSelect { .. }
202 | AppMode::SplitConfirm(_)
203 | AppMode::DropConfirm(_)
204 | AppMode::RebaseConflict(_) => Some(AppMode::CommitList),
205 AppMode::Help(prev) => Some(prev.as_ref().clone()),
206 }
207 }
208
209 pub fn parse_key(&self, event: Event) -> KeyCommand {
215 if let Event::Key(KeyEvent {
216 code,
217 modifiers,
218 kind,
219 ..
220 }) = event
221 && kind == event::KeyEventKind::Press
222 {
223 if code == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL) {
225 return KeyCommand::ForceQuit;
226 }
227 if modifiers.contains(KeyModifiers::CONTROL) {
228 match code {
229 KeyCode::Left => return KeyCommand::SeparatorLeft,
230 KeyCode::Right => return KeyCommand::SeparatorRight,
231 _ => {}
232 }
233 }
234 return match code {
235 KeyCode::Up | KeyCode::Char('k') => KeyCommand::MoveUp,
236 KeyCode::Down | KeyCode::Char('j') => KeyCommand::MoveDown,
237 KeyCode::PageUp => KeyCommand::PageUp,
238 KeyCode::PageDown => KeyCommand::PageDown,
239 KeyCode::Left => KeyCommand::ScrollLeft,
240 KeyCode::Right => KeyCommand::ScrollRight,
241 KeyCode::Enter => KeyCommand::Confirm,
242 KeyCode::Char('i') => KeyCommand::ToggleDetail,
243 KeyCode::Char('h') => KeyCommand::ShowHelp,
244 KeyCode::Char('p') => KeyCommand::Split,
245 KeyCode::Char('s') => KeyCommand::Squash,
246 KeyCode::Char('f') => KeyCommand::Fixup,
247 KeyCode::Char('r') => KeyCommand::Reword,
248 KeyCode::Char('d') => KeyCommand::Drop,
249 KeyCode::Char('m') => match self {
250 AppMode::RebaseConflict(_) => KeyCommand::Mergetool,
251 _ => KeyCommand::Move,
252 },
253 KeyCode::Char('e') => match self {
254 AppMode::RebaseConflict(_) => KeyCommand::OpenEditor,
255 _ => KeyCommand::None,
256 },
257 KeyCode::Char('u') => KeyCommand::Update,
258 KeyCode::Esc | KeyCode::Char('q') => KeyCommand::Quit,
259 _ => KeyCommand::None,
260 };
261 }
262 KeyCommand::None
263 }
264}
265
266#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct PendingSplit {
269 pub strategy: SplitStrategy,
270 pub commit_oid: String,
271 pub head_oid: String,
272 pub count: usize,
273}
274
275#[derive(Debug, Clone, PartialEq, Eq)]
277pub struct PendingDrop {
278 pub commit_oid: String,
279 pub commit_summary: String,
280 pub head_oid: String,
281}
282
283pub struct AppState {
288 pub should_quit: bool,
289 pub commits: Vec<CommitInfo>,
290 pub selection_index: usize,
291 pub reverse: bool,
292 pub full_fragmap: bool,
294 pub squashable_scope: SquashableScope,
296 pub reference_oid: String,
299 pub fragmap: Option<FragMap>,
302 pub fragmap_scroll_offset: usize,
304 pub mode: AppMode,
306 pub detail_scroll_offset: usize,
308 pub max_detail_scroll: usize,
310 pub detail_h_scroll_offset: usize,
312 pub max_detail_h_scroll: usize,
314 pub commit_list_visible_height: usize,
316 pub detail_visible_height: usize,
318 pub status_message: Option<String>,
320 pub status_is_error: bool,
322 pub separator_offset: i16,
324}
325
326impl AppState {
327 pub fn new() -> Self {
329 Self {
330 should_quit: false,
331 commits: Vec::new(),
332 selection_index: 0,
333 reverse: false,
334 full_fragmap: false,
335 squashable_scope: SquashableScope::Group,
336 reference_oid: String::new(),
337 fragmap: None,
338 fragmap_scroll_offset: 0,
339 mode: AppMode::CommitList,
340 detail_scroll_offset: 0,
341 max_detail_scroll: 0,
342 detail_h_scroll_offset: 0,
343 max_detail_h_scroll: 0,
344 commit_list_visible_height: 0,
345 detail_visible_height: 0,
346 status_message: None,
347 status_is_error: false,
348 separator_offset: 0,
349 }
350 }
351
352 pub fn with_commits(commits: Vec<CommitInfo>) -> Self {
354 let selection_index = commits.len().saturating_sub(1);
355 Self {
356 should_quit: false,
357 commits,
358 selection_index,
359 reverse: false,
360 full_fragmap: false,
361 squashable_scope: SquashableScope::Group,
362 reference_oid: String::new(),
363 fragmap: None,
364 fragmap_scroll_offset: 0,
365 mode: AppMode::CommitList,
366 detail_scroll_offset: 0,
367 max_detail_scroll: 0,
368 detail_h_scroll_offset: 0,
369 max_detail_h_scroll: 0,
370 commit_list_visible_height: 0,
371 detail_visible_height: 0,
372 status_message: None,
373 status_is_error: false,
374 separator_offset: 0,
375 }
376 }
377
378 pub fn move_up(&mut self) {
381 if self.selection_index > 0 {
382 self.selection_index -= 1;
383 }
384 }
385
386 pub fn move_down(&mut self) {
389 if !self.commits.is_empty() && self.selection_index < self.commits.len() - 1 {
390 self.selection_index += 1;
391 }
392 }
393
394 pub fn scroll_fragmap_left(&mut self) {
396 if self.fragmap_scroll_offset > 0 {
397 self.fragmap_scroll_offset -= 1;
398 }
399 }
400
401 pub fn scroll_fragmap_right(&mut self) {
403 self.fragmap_scroll_offset += 1;
404 }
405
406 pub fn scroll_detail_up(&mut self) {
408 if self.detail_scroll_offset > 0 {
409 self.detail_scroll_offset -= 1;
410 }
411 }
412
413 pub fn scroll_detail_down(&mut self) {
415 if self.detail_scroll_offset < self.max_detail_scroll {
416 self.detail_scroll_offset += 1;
417 }
418 }
419
420 pub fn scroll_detail_left(&mut self) {
422 if self.detail_h_scroll_offset > 0 {
423 self.detail_h_scroll_offset -= 1;
424 }
425 }
426
427 pub fn scroll_detail_right(&mut self) {
429 if self.detail_h_scroll_offset < self.max_detail_h_scroll {
430 self.detail_h_scroll_offset += 1;
431 }
432 }
433
434 pub fn page_up(&mut self, visible_height: usize) {
436 let page_size = visible_height.saturating_sub(1).max(1); self.selection_index = self.selection_index.saturating_sub(page_size);
438 }
439
440 pub fn page_down(&mut self, visible_height: usize) {
442 if self.commits.is_empty() {
443 return;
444 }
445 let page_size = visible_height.saturating_sub(1).max(1); let new_index = self.selection_index.saturating_add(page_size);
447 self.selection_index = new_index.min(self.commits.len() - 1);
448 }
449
450 pub fn scroll_detail_page_up(&mut self, visible_height: usize) {
452 let page_size = visible_height.saturating_sub(1).max(1);
453 self.detail_scroll_offset = self.detail_scroll_offset.saturating_sub(page_size);
454 }
455
456 pub fn scroll_detail_page_down(&mut self, visible_height: usize) {
458 let page_size = visible_height.saturating_sub(1).max(1);
459 let new_offset = self.detail_scroll_offset.saturating_add(page_size);
460 self.detail_scroll_offset = new_offset.min(self.max_detail_scroll);
461 }
462
463 pub fn enter_split_confirm(
465 &mut self,
466 strategy: SplitStrategy,
467 commit_oid: String,
468 head_oid: String,
469 count: usize,
470 ) {
471 self.mode = AppMode::SplitConfirm(PendingSplit {
472 strategy,
473 commit_oid,
474 head_oid,
475 count,
476 });
477 }
478
479 pub fn cancel_split_confirm(&mut self) {
481 self.mode = AppMode::CommitList;
482 }
483
484 pub fn enter_drop_confirm(
486 &mut self,
487 commit_oid: String,
488 commit_summary: String,
489 head_oid: String,
490 ) {
491 self.mode = AppMode::DropConfirm(PendingDrop {
492 commit_oid,
493 commit_summary,
494 head_oid,
495 });
496 }
497
498 pub fn cancel_drop_confirm(&mut self) {
500 self.mode = AppMode::CommitList;
501 }
502
503 pub fn enter_rebase_conflict(&mut self, state: ConflictState) {
505 self.mode = AppMode::RebaseConflict(Box::new(state));
506 }
507
508 pub fn enter_split_select(&mut self) {
511 if let Some(commit) = self.commits.get(self.selection_index)
512 && (commit.oid == "staged" || commit.oid == "unstaged")
513 {
514 self.set_error_message("Cannot split staged/unstaged changes");
515 return;
516 }
517 self.mode = AppMode::SplitSelect { strategy_index: 0 };
518 }
519
520 pub fn enter_squash_select(&mut self) {
523 self.enter_squash_or_fixup_select(false);
524 }
525
526 pub fn enter_fixup_select(&mut self) {
528 self.enter_squash_or_fixup_select(true);
529 }
530
531 fn enter_squash_or_fixup_select(&mut self, is_fixup: bool) {
532 let label = if is_fixup { "fixup" } else { "squash" };
533 if let Some(commit) = self.commits.get(self.selection_index)
534 && (commit.oid == "staged" || commit.oid == "unstaged")
535 {
536 self.set_error_message(format!("Cannot {label} staged/unstaged changes"));
537 return;
538 }
539 let real_count = self
540 .commits
541 .iter()
542 .filter(|c| c.oid != "staged" && c.oid != "unstaged")
543 .count();
544 if real_count < 2 {
545 self.set_error_message(format!(
546 "Nothing to {label} — only one commit on the branch"
547 ));
548 return;
549 }
550 self.mode = AppMode::SquashSelect {
551 source_index: self.selection_index,
552 is_fixup,
553 };
554 }
555
556 pub fn cancel_squash_select(&mut self) {
558 self.mode = AppMode::CommitList;
559 }
560
561 pub fn enter_move_select(&mut self) {
566 if let Some(commit) = self.commits.get(self.selection_index)
567 && (commit.oid == "staged" || commit.oid == "unstaged")
568 {
569 self.set_error_message("Cannot move staged/unstaged changes");
570 return;
571 }
572
573 let real_count = self
575 .commits
576 .iter()
577 .filter(|c| c.oid != "staged" && c.oid != "unstaged")
578 .count();
579 if real_count < 2 {
580 self.set_error_message("Nothing to move — only one commit on the branch");
581 return;
582 }
583
584 let source = self.selection_index;
585 let max = self.commits.len();
586 let insert_before = if source > 0 {
589 source - 1
590 } else {
591 2.min(max)
593 };
594 self.mode = AppMode::MoveSelect {
595 source_index: source,
596 insert_before,
597 };
598 }
599
600 pub fn cancel_move_select(&mut self) {
602 self.mode = AppMode::CommitList;
603 }
604
605 pub fn set_success_message(&mut self, msg: impl Into<String>) {
607 self.status_message = Some(msg.into());
608 self.status_is_error = false;
609 }
610
611 pub fn set_error_message(&mut self, msg: impl Into<String>) {
613 self.status_message = Some(msg.into());
614 self.status_is_error = true;
615 }
616
617 pub fn clear_status_message(&mut self) {
619 self.status_message = None;
620 self.status_is_error = false;
621 }
622
623 pub fn split_select_up(&mut self) {
625 if let AppMode::SplitSelect { strategy_index } = &mut self.mode
626 && *strategy_index > 0
627 {
628 *strategy_index -= 1;
629 }
630 }
631
632 pub fn split_select_down(&mut self) {
634 if let AppMode::SplitSelect { strategy_index } = &mut self.mode
635 && *strategy_index < SplitStrategy::ALL.len() - 1
636 {
637 *strategy_index += 1;
638 }
639 }
640
641 pub fn selected_split_strategy(&self) -> SplitStrategy {
643 if let AppMode::SplitSelect { strategy_index } = self.mode {
644 SplitStrategy::ALL[strategy_index]
645 } else {
646 SplitStrategy::ALL[0]
647 }
648 }
649
650 pub fn toggle_detail_view(&mut self) {
652 let new_mode = match &self.mode {
653 AppMode::CommitList => AppMode::CommitDetail,
654 AppMode::CommitDetail => AppMode::CommitList,
655 AppMode::Help(_)
656 | AppMode::SplitSelect { .. }
657 | AppMode::SplitConfirm(_)
658 | AppMode::DropConfirm(_)
659 | AppMode::RebaseConflict(_)
660 | AppMode::SquashSelect { .. }
661 | AppMode::MoveSelect { .. } => return,
662 };
663 self.mode = new_mode;
664 self.detail_scroll_offset = 0;
665 }
666
667 pub fn show_help(&mut self) {
669 if !matches!(self.mode, AppMode::Help(_)) {
670 let current = std::mem::replace(&mut self.mode, AppMode::CommitList);
671 self.mode = AppMode::Help(Box::new(current));
672 }
673 }
674
675 pub fn close_help(&mut self) {
677 if matches!(self.mode, AppMode::Help(_)) {
678 let prev = std::mem::replace(&mut self.mode, AppMode::CommitList);
679 if let AppMode::Help(prev_mode) = prev {
680 self.mode = *prev_mode;
681 }
682 }
683 }
684
685 pub fn toggle_help(&mut self) {
687 if matches!(self.mode, AppMode::Help(_)) {
688 self.close_help();
689 } else {
690 self.show_help();
691 }
692 }
693}
694
695impl Default for AppState {
696 fn default() -> Self {
697 Self::new()
698 }
699}
700
701#[cfg(test)]
702mod tests {
703 use super::*;
704
705 fn create_test_commit(oid: &str, summary: &str) -> CommitInfo {
706 CommitInfo {
707 oid: oid.to_string(),
708 summary: summary.to_string(),
709 author: Some("Test Author".to_string()),
710 date: Some("2024-01-01".to_string()),
711 parent_oids: vec![],
712 message: summary.to_string(),
713 author_email: Some("test@example.com".to_string()),
714 author_date: Some(time::OffsetDateTime::from_unix_timestamp(1704110400).unwrap()),
715 committer: Some("Test Committer".to_string()),
716 committer_email: Some("committer@example.com".to_string()),
717 commit_date: Some(time::OffsetDateTime::from_unix_timestamp(1704110400).unwrap()),
718 }
719 }
720
721 #[test]
722 fn test_move_up_with_empty_list() {
723 let mut app = AppState::new();
724 assert_eq!(app.selection_index, 0);
725 app.move_up();
726 assert_eq!(app.selection_index, 0);
727 }
728
729 #[test]
730 fn test_move_up_at_top() {
731 let mut app = AppState::new();
732 app.commits = vec![
733 create_test_commit("abc123", "First"),
734 create_test_commit("def456", "Second"),
735 ];
736 app.selection_index = 0;
737 app.move_up();
738 assert_eq!(app.selection_index, 0);
739 }
740
741 #[test]
742 fn test_move_up_from_middle() {
743 let mut app = AppState::new();
744 app.commits = vec![
745 create_test_commit("abc123", "First"),
746 create_test_commit("def456", "Second"),
747 create_test_commit("ghi789", "Third"),
748 ];
749 app.selection_index = 2;
750 app.move_up();
751 assert_eq!(app.selection_index, 1);
752 app.move_up();
753 assert_eq!(app.selection_index, 0);
754 }
755
756 #[test]
757 fn test_move_down_with_empty_list() {
758 let mut app = AppState::new();
759 assert_eq!(app.selection_index, 0);
760 app.move_down();
761 assert_eq!(app.selection_index, 0);
762 }
763
764 #[test]
765 fn test_move_down_at_bottom() {
766 let mut app = AppState::new();
767 app.commits = vec![
768 create_test_commit("abc123", "First"),
769 create_test_commit("def456", "Second"),
770 ];
771 app.selection_index = 1;
772 app.move_down();
773 assert_eq!(app.selection_index, 1);
774 }
775
776 #[test]
777 fn test_move_down_from_middle() {
778 let mut app = AppState::new();
779 app.commits = vec![
780 create_test_commit("abc123", "First"),
781 create_test_commit("def456", "Second"),
782 create_test_commit("ghi789", "Third"),
783 ];
784 app.selection_index = 0;
785 app.move_down();
786 assert_eq!(app.selection_index, 1);
787 app.move_down();
788 assert_eq!(app.selection_index, 2);
789 }
790}