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    Update,
45    Quit,
46    Confirm,
47    /// Ctrl+C: quit immediately, aborting any in-progress rebase first.
48    ForceQuit,
49    SeparatorLeft,
50    SeparatorRight,
51    None,
52}
53
54/// Read the next terminal event.
55///
56/// Blocks until an event is available. Returns the event wrapped in Result
57/// to handle potential I/O errors.
58pub fn read_event() -> Result<Event> {
59    Ok(event::read()?)
60}
61
62/// Result of a view module's `handle_key` function.
63///
64/// Pure state mutations (scroll, selection, mode transitions) are applied
65/// directly to `AppState` inside the handler. Side effects that require git
66/// operations or terminal access are returned here for `main.rs` to execute.
67#[derive(Debug)]
68pub enum AppAction {
69    /// Fully handled, no side effects needed.
70    Handled,
71    /// The application should quit.
72    Quit,
73    /// Reload commits from the repository.
74    ReloadCommits,
75    /// Begin the split flow: get head_oid, count results, confirm if large.
76    PrepareSplit {
77        strategy: SplitStrategy,
78        commit_oid: String,
79    },
80    /// Execute a split that has already been confirmed.
81    ExecuteSplit {
82        strategy: SplitStrategy,
83        commit_oid: String,
84        head_oid: String,
85    },
86    /// Begin the drop flow: get head_oid from repo, then show confirmation.
87    PrepareDropConfirm {
88        commit_oid: String,
89        commit_summary: String,
90    },
91    /// Execute a confirmed drop.
92    ExecuteDrop {
93        commit_oid: String,
94        head_oid: String,
95    },
96    /// Continue a rebase after the user resolved merge conflicts.
97    RebaseContinue(ConflictState),
98    /// Abort a rebase that hit conflicts.
99    RebaseAbort(ConflictState),
100    /// Launch the merge tool for conflicting files.
101    RunMergetool {
102        files: Vec<String>,
103        conflict_state: ConflictState,
104    },
105    /// Start the reword flow: get head_oid, launch editor, rewrite commit.
106    PrepareReword {
107        commit_oid: String,
108        current_message: String,
109    },
110    /// Start the squash/fixup flow: user picked source and target.
111    /// When `is_fixup` is true the target's message is kept as-is (no editor).
112    PrepareSquash {
113        source_oid: String,
114        target_oid: String,
115        source_message: String,
116        target_message: String,
117        is_fixup: bool,
118    },
119    /// Execute a confirmed move: reorder the source commit to after insert_after_oid.
120    ExecuteMove {
121        source_oid: String,
122        insert_after_oid: String,
123    },
124}
125
126/// Split strategy options.
127#[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/// Application display mode.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub enum AppMode {
161    /// Commit list view with fragmap.
162    CommitList,
163    /// Detailed view of a single commit.
164    CommitDetail,
165    /// Split strategy selection dialog; carries the highlighted option index.
166    SplitSelect { strategy_index: usize },
167    /// Confirmation dialog for large splits (> SPLIT_CONFIRM_THRESHOLD commits).
168    SplitConfirm(PendingSplit),
169    /// Confirmation dialog before dropping a commit.
170    DropConfirm(PendingDrop),
171    /// Waiting for the user to resolve merge conflicts that arose during a
172    /// rebase operation. Enter continues, Esc aborts the entire operation.
173    RebaseConflict(Box<ConflictState>),
174    /// Squash/fixup target selection: user picks which commit to squash the source into.
175    /// When `is_fixup` is true the target's message is kept as-is (no editor).
176    SquashSelect { source_index: usize, is_fixup: bool },
177    /// Move commit selection: user picks where to insert the source commit.
178    /// `insert_before` is the index in the commit list where the separator row
179    /// appears; the commit will be moved to that position.
180    MoveSelect {
181        source_index: usize,
182        insert_before: usize,
183    },
184    /// Help dialog overlay; carries the mode to return to when closed.
185    Help(Box<AppMode>),
186}
187
188impl AppMode {
189    /// For overlay modes, return the base view that should be rendered
190    /// underneath. Returns `None` for base views (CommitList, CommitDetail).
191    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    /// Parse a terminal event into a semantic key command for this mode.
204    ///
205    /// Most keys are mode-independent (arrows, Esc, Enter). Where a key has
206    /// different meanings per mode (e.g. 'm' → Move in CommitList vs Mergetool
207    /// in RebaseConflict), this method resolves the ambiguity.
208    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            // Ctrl+C always force-quits regardless of mode.
218            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/// Data retained while the user is shown the large-split confirmation dialog.
257#[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/// Data retained while the user is shown the drop confirmation dialog.
266#[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
273/// Application state for the TUI.
274///
275/// Manages the overall state of the interactive terminal interface,
276/// including quit flag and commit list state.
277pub struct AppState {
278    pub should_quit: bool,
279    pub commits: Vec<CommitInfo>,
280    pub selection_index: usize,
281    pub reverse: bool,
282    /// Show all hunk-group columns without deduplication (--full flag).
283    pub full_fragmap: bool,
284    /// Controls what the squashable connector symbol means in the fragmap.
285    pub squashable_scope: SquashableScope,
286    /// The reference OID (merge-base) used when the session started.
287    /// Stored here so 'u' update can rescan from HEAD down to the same base.
288    pub reference_oid: String,
289    /// Optional fragmap visualization data.
290    /// None if fragmap computation failed or was not performed.
291    pub fragmap: Option<FragMap>,
292    /// Horizontal scroll offset for the fragmap grid.
293    pub fragmap_scroll_offset: usize,
294    /// Current display mode.
295    pub mode: AppMode,
296    /// Vertical scroll offset for the detail view.
297    pub detail_scroll_offset: usize,
298    /// Maximum vertical scroll offset for the detail view (updated during render).
299    pub max_detail_scroll: usize,
300    /// Horizontal scroll offset for the detail view.
301    pub detail_h_scroll_offset: usize,
302    /// Maximum horizontal scroll offset for the detail view (updated during render).
303    pub max_detail_h_scroll: usize,
304    /// Visible height of the commit list area (updated during render).
305    pub commit_list_visible_height: usize,
306    /// Visible height of the detail view area (updated during render).
307    pub detail_visible_height: usize,
308    /// Transient status message shown in the footer (cleared on next keypress).
309    pub status_message: Option<String>,
310    /// Whether the current status message represents an error (red) or success (green).
311    pub status_is_error: bool,
312    /// User-controlled offset for the vertical separator bar (positive = right, negative = left).
313    pub separator_offset: i16,
314}
315
316impl AppState {
317    /// Create a new AppState with default values.
318    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    /// Create a new AppState with the given commits, selecting the last one (HEAD).
343    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    /// Move selection up (decrement index) with lower bound check.
369    /// Does nothing if already at top or commits list is empty.
370    pub fn move_up(&mut self) {
371        if self.selection_index > 0 {
372            self.selection_index -= 1;
373        }
374    }
375
376    /// Move selection down (increment index) with upper bound check.
377    /// Does nothing if already at bottom or commits list is empty.
378    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    /// Scroll fragmap grid left.
385    pub fn scroll_fragmap_left(&mut self) {
386        if self.fragmap_scroll_offset > 0 {
387            self.fragmap_scroll_offset -= 1;
388        }
389    }
390
391    /// Scroll fragmap grid right.
392    pub fn scroll_fragmap_right(&mut self) {
393        self.fragmap_scroll_offset += 1;
394    }
395
396    /// Scroll detail view up (decrease offset).
397    pub fn scroll_detail_up(&mut self) {
398        if self.detail_scroll_offset > 0 {
399            self.detail_scroll_offset -= 1;
400        }
401    }
402
403    /// Scroll detail view down (increase offset).
404    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    /// Scroll detail view left (decrease horizontal offset).
411    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    /// Scroll detail view right (increase horizontal offset).
418    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    /// Scroll commit list up by one page (visible_height lines).
425    pub fn page_up(&mut self, visible_height: usize) {
426        let page_size = visible_height.saturating_sub(1).max(1); // Keep at least one line overlap
427        self.selection_index = self.selection_index.saturating_sub(page_size);
428    }
429
430    /// Scroll commit list down by one page (visible_height lines).
431    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); // Keep at least one line overlap
436        let new_index = self.selection_index.saturating_add(page_size);
437        self.selection_index = new_index.min(self.commits.len() - 1);
438    }
439
440    /// Scroll detail view up by one page (visible_height lines).
441    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    /// Scroll detail view down by one page (visible_height lines).
447    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    /// Enter the large-split confirmation dialog.
454    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    /// Cancel the large-split confirmation and return to CommitList.
470    pub fn cancel_split_confirm(&mut self) {
471        self.mode = AppMode::CommitList;
472    }
473
474    /// Enter the drop confirmation dialog.
475    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    /// Cancel the drop confirmation and return to CommitList.
489    pub fn cancel_drop_confirm(&mut self) {
490        self.mode = AppMode::CommitList;
491    }
492
493    /// Enter the rebase-conflict resolution dialog.
494    pub fn enter_rebase_conflict(&mut self, state: ConflictState) {
495        self.mode = AppMode::RebaseConflict(Box::new(state));
496    }
497
498    /// Enter split strategy selection mode.
499    /// Only allowed for real commits (not staged/unstaged synthetic rows).
500    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    /// Enter squash target selection mode.
511    /// Only allowed for real commits (not staged/unstaged synthetic rows).
512    pub fn enter_squash_select(&mut self) {
513        self.enter_squash_or_fixup_select(false);
514    }
515
516    /// Enter fixup target selection mode (same UI as squash, keeps target msg).
517    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    /// Cancel squash selection and return to CommitList.
547    pub fn cancel_squash_select(&mut self) {
548        self.mode = AppMode::CommitList;
549    }
550
551    /// Enter move commit selection mode.
552    /// The insertion cursor starts one position before the source (i.e. one
553    /// slot earlier in the commit list, which visually means "above" in
554    /// chronological order).
555    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        // Count real (non-synthetic) commits; moving requires at least 2.
564        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        // Pick the first valid (non-no-op) position. No-ops are source and
577        // source + 1, so try source - 1 first, then scan forward.
578        let insert_before = if source > 0 {
579            source - 1
580        } else {
581            // source == 0 → first valid position is 2 (skip 0 and 1)
582            2.min(max)
583        };
584        self.mode = AppMode::MoveSelect {
585            source_index: source,
586            insert_before,
587        };
588    }
589
590    /// Cancel move selection and return to CommitList.
591    pub fn cancel_move_select(&mut self) {
592        self.mode = AppMode::CommitList;
593    }
594
595    /// Set a success status message (shown with green background).
596    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    /// Set an error status message (shown with red background).
602    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    /// Clear the transient status message.
608    pub fn clear_status_message(&mut self) {
609        self.status_message = None;
610        self.status_is_error = false;
611    }
612
613    /// Move split strategy selection up.
614    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    /// Move split strategy selection down.
623    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    /// Get the currently selected split strategy.
632    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    /// Toggle between CommitList and CommitDetail modes.
641    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    /// Show help dialog, saving current mode to return to later.
658    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    /// Close help dialog and return to previous mode.
666    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    /// Toggle help dialog on/off.
676    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}