Skip to main content

git_tailor/
app.rs

1// Copyright 2026 Thomas Johannesson
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15// TUI application state management
16
17use 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/// Semantic commands derived from keyboard input.
27#[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    /// Ctrl+C: quit immediately, aborting any in-progress rebase first.
49    ForceQuit,
50    SeparatorLeft,
51    SeparatorRight,
52    None,
53}
54
55/// Read the next terminal event.
56///
57/// Blocks until an event is available. Returns the event wrapped in Result
58/// to handle potential I/O errors.
59pub fn read_event() -> Result<Event> {
60    Ok(event::read()?)
61}
62
63/// Result of a view module's `handle_key` function.
64///
65/// Pure state mutations (scroll, selection, mode transitions) are applied
66/// directly to `AppState` inside the handler. Side effects that require git
67/// operations or terminal access are returned here for `main.rs` to execute.
68#[derive(Debug)]
69pub enum AppAction {
70    /// Fully handled, no side effects needed.
71    Handled,
72    /// The application should quit.
73    Quit,
74    /// Reload commits from the repository.
75    ReloadCommits,
76    /// Begin the split flow: get head_oid, count results, confirm if large.
77    PrepareSplit {
78        strategy: SplitStrategy,
79        commit_oid: String,
80    },
81    /// Execute a split that has already been confirmed.
82    ExecuteSplit {
83        strategy: SplitStrategy,
84        commit_oid: String,
85        head_oid: String,
86    },
87    /// Begin the drop flow: get head_oid from repo, then show confirmation.
88    PrepareDropConfirm {
89        commit_oid: String,
90        commit_summary: String,
91    },
92    /// Execute a confirmed drop.
93    ExecuteDrop {
94        commit_oid: String,
95        head_oid: String,
96    },
97    /// Continue a rebase after the user resolved merge conflicts.
98    RebaseContinue(ConflictState),
99    /// Abort a rebase that hit conflicts.
100    RebaseAbort(ConflictState),
101    /// Launch the merge tool for conflicting files.
102    RunMergetool {
103        files: Vec<String>,
104        conflict_state: ConflictState,
105    },
106    /// Open conflicting files in the configured editor.
107    RunEditor {
108        files: Vec<String>,
109        conflict_state: ConflictState,
110    },
111    /// Start the reword flow: get head_oid, launch editor, rewrite commit.
112    PrepareReword {
113        commit_oid: String,
114        current_message: String,
115    },
116    /// Start the squash/fixup flow: user picked source and target.
117    /// When `is_fixup` is true the target's message is kept as-is (no editor).
118    PrepareSquash {
119        source_oid: String,
120        target_oid: String,
121        source_message: String,
122        target_message: String,
123        is_fixup: bool,
124    },
125    /// Execute a confirmed move: reorder the source commit to after insert_after_oid.
126    ExecuteMove {
127        source_oid: String,
128        insert_after_oid: String,
129    },
130}
131
132/// Split strategy options.
133#[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/// Application display mode.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum AppMode {
167    /// Commit list view with fragmap.
168    CommitList,
169    /// Detailed view of a single commit.
170    CommitDetail,
171    /// Split strategy selection dialog; carries the highlighted option index.
172    SplitSelect { strategy_index: usize },
173    /// Confirmation dialog for large splits (> SPLIT_CONFIRM_THRESHOLD commits).
174    SplitConfirm(PendingSplit),
175    /// Confirmation dialog before dropping a commit.
176    DropConfirm(PendingDrop),
177    /// Waiting for the user to resolve merge conflicts that arose during a
178    /// rebase operation. Enter continues, Esc aborts the entire operation.
179    RebaseConflict(Box<ConflictState>),
180    /// Squash/fixup target selection: user picks which commit to squash the source into.
181    /// When `is_fixup` is true the target's message is kept as-is (no editor).
182    SquashSelect { source_index: usize, is_fixup: bool },
183    /// Move commit selection: user picks where to insert the source commit.
184    /// `insert_before` is the index in the commit list where the separator row
185    /// appears; the commit will be moved to that position.
186    MoveSelect {
187        source_index: usize,
188        insert_before: usize,
189    },
190    /// Help dialog overlay; carries the mode to return to when closed.
191    Help(Box<AppMode>),
192}
193
194impl AppMode {
195    /// For overlay modes, return the base view that should be rendered
196    /// underneath. Returns `None` for base views (CommitList, CommitDetail).
197    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    /// Parse a terminal event into a semantic key command for this mode.
210    ///
211    /// Most keys are mode-independent (arrows, Esc, Enter). Where a key has
212    /// different meanings per mode (e.g. 'm' → Move in CommitList vs Mergetool
213    /// in RebaseConflict), this method resolves the ambiguity.
214    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            // Ctrl+C always force-quits regardless of mode.
224            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/// Data retained while the user is shown the large-split confirmation dialog.
267#[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/// Data retained while the user is shown the drop confirmation dialog.
276#[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
283/// Application state for the TUI.
284///
285/// Manages the overall state of the interactive terminal interface,
286/// including quit flag and commit list state.
287pub struct AppState {
288    pub should_quit: bool,
289    pub commits: Vec<CommitInfo>,
290    pub selection_index: usize,
291    pub reverse: bool,
292    /// Show all hunk-group columns without deduplication (--full flag).
293    pub full_fragmap: bool,
294    /// Controls what the squashable connector symbol means in the fragmap.
295    pub squashable_scope: SquashableScope,
296    /// The reference OID (merge-base) used when the session started.
297    /// Stored here so 'u' update can rescan from HEAD down to the same base.
298    pub reference_oid: String,
299    /// Optional fragmap visualization data.
300    /// None if fragmap computation failed or was not performed.
301    pub fragmap: Option<FragMap>,
302    /// Horizontal scroll offset for the fragmap grid.
303    pub fragmap_scroll_offset: usize,
304    /// Current display mode.
305    pub mode: AppMode,
306    /// Vertical scroll offset for the detail view.
307    pub detail_scroll_offset: usize,
308    /// Maximum vertical scroll offset for the detail view (updated during render).
309    pub max_detail_scroll: usize,
310    /// Horizontal scroll offset for the detail view.
311    pub detail_h_scroll_offset: usize,
312    /// Maximum horizontal scroll offset for the detail view (updated during render).
313    pub max_detail_h_scroll: usize,
314    /// Visible height of the commit list area (updated during render).
315    pub commit_list_visible_height: usize,
316    /// Visible height of the detail view area (updated during render).
317    pub detail_visible_height: usize,
318    /// Transient status message shown in the footer (cleared on next keypress).
319    pub status_message: Option<String>,
320    /// Whether the current status message represents an error (red) or success (green).
321    pub status_is_error: bool,
322    /// User-controlled offset for the vertical separator bar (positive = right, negative = left).
323    pub separator_offset: i16,
324}
325
326impl AppState {
327    /// Create a new AppState with default values.
328    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    /// Create a new AppState with the given commits, selecting the last one (HEAD).
353    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    /// Move selection up (decrement index) with lower bound check.
379    /// Does nothing if already at top or commits list is empty.
380    pub fn move_up(&mut self) {
381        if self.selection_index > 0 {
382            self.selection_index -= 1;
383        }
384    }
385
386    /// Move selection down (increment index) with upper bound check.
387    /// Does nothing if already at bottom or commits list is empty.
388    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    /// Scroll fragmap grid left.
395    pub fn scroll_fragmap_left(&mut self) {
396        if self.fragmap_scroll_offset > 0 {
397            self.fragmap_scroll_offset -= 1;
398        }
399    }
400
401    /// Scroll fragmap grid right.
402    pub fn scroll_fragmap_right(&mut self) {
403        self.fragmap_scroll_offset += 1;
404    }
405
406    /// Scroll detail view up (decrease offset).
407    pub fn scroll_detail_up(&mut self) {
408        if self.detail_scroll_offset > 0 {
409            self.detail_scroll_offset -= 1;
410        }
411    }
412
413    /// Scroll detail view down (increase offset).
414    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    /// Scroll detail view left (decrease horizontal offset).
421    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    /// Scroll detail view right (increase horizontal offset).
428    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    /// Scroll commit list up by one page (visible_height lines).
435    pub fn page_up(&mut self, visible_height: usize) {
436        let page_size = visible_height.saturating_sub(1).max(1); // Keep at least one line overlap
437        self.selection_index = self.selection_index.saturating_sub(page_size);
438    }
439
440    /// Scroll commit list down by one page (visible_height lines).
441    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); // Keep at least one line overlap
446        let new_index = self.selection_index.saturating_add(page_size);
447        self.selection_index = new_index.min(self.commits.len() - 1);
448    }
449
450    /// Scroll detail view up by one page (visible_height lines).
451    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    /// Scroll detail view down by one page (visible_height lines).
457    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    /// Enter the large-split confirmation dialog.
464    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    /// Cancel the large-split confirmation and return to CommitList.
480    pub fn cancel_split_confirm(&mut self) {
481        self.mode = AppMode::CommitList;
482    }
483
484    /// Enter the drop confirmation dialog.
485    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    /// Cancel the drop confirmation and return to CommitList.
499    pub fn cancel_drop_confirm(&mut self) {
500        self.mode = AppMode::CommitList;
501    }
502
503    /// Enter the rebase-conflict resolution dialog.
504    pub fn enter_rebase_conflict(&mut self, state: ConflictState) {
505        self.mode = AppMode::RebaseConflict(Box::new(state));
506    }
507
508    /// Enter split strategy selection mode.
509    /// Only allowed for real commits (not staged/unstaged synthetic rows).
510    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    /// Enter squash target selection mode.
521    /// Only allowed for real commits (not staged/unstaged synthetic rows).
522    pub fn enter_squash_select(&mut self) {
523        self.enter_squash_or_fixup_select(false);
524    }
525
526    /// Enter fixup target selection mode (same UI as squash, keeps target msg).
527    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    /// Cancel squash selection and return to CommitList.
557    pub fn cancel_squash_select(&mut self) {
558        self.mode = AppMode::CommitList;
559    }
560
561    /// Enter move commit selection mode.
562    /// The insertion cursor starts one position before the source (i.e. one
563    /// slot earlier in the commit list, which visually means "above" in
564    /// chronological order).
565    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        // Count real (non-synthetic) commits; moving requires at least 2.
574        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        // Pick the first valid (non-no-op) position. No-ops are source and
587        // source + 1, so try source - 1 first, then scan forward.
588        let insert_before = if source > 0 {
589            source - 1
590        } else {
591            // source == 0 → first valid position is 2 (skip 0 and 1)
592            2.min(max)
593        };
594        self.mode = AppMode::MoveSelect {
595            source_index: source,
596            insert_before,
597        };
598    }
599
600    /// Cancel move selection and return to CommitList.
601    pub fn cancel_move_select(&mut self) {
602        self.mode = AppMode::CommitList;
603    }
604
605    /// Set a success status message (shown with green background).
606    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    /// Set an error status message (shown with red background).
612    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    /// Clear the transient status message.
618    pub fn clear_status_message(&mut self) {
619        self.status_message = None;
620        self.status_is_error = false;
621    }
622
623    /// Move split strategy selection up.
624    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    /// Move split strategy selection down.
633    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    /// Get the currently selected split strategy.
642    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    /// Toggle between CommitList and CommitDetail modes.
651    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    /// Show help dialog, saving current mode to return to later.
668    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    /// Close help dialog and return to previous mode.
676    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    /// Toggle help dialog on/off.
686    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}