1use std::collections::HashMap;
2use std::path::Path;
3
4use git2::Repository;
5
6use crate::compare::{BranchCompare, CompareTab};
7use crate::config::{FilterPreset, FilterPresets};
8use crate::event::GitEvent;
9use crate::export::{
10 export_heatmap_csv, export_heatmap_json, export_ownership_csv, export_ownership_json,
11 export_stats_csv, export_stats_json, export_timeline_csv, export_timeline_json,
12};
13use crate::filter::FilterQuery;
14use crate::git::{
15 BlameLine, CommitDiff, FileHistoryEntry, FilePatch, FileStatus, RepoInfo, StashEntry,
16};
17use crate::related_files::RelatedFiles;
18use crate::stats::{ActivityTimeline, CodeOwnership, FileHeatmap, RepoStats};
19use crate::suggestion::CommitSuggestion;
20use crate::topology::BranchTopology;
21
22#[derive(Debug, Clone, Copy, PartialEq, Default)]
24pub enum InputMode {
25 #[default]
26 Normal,
27 Filter,
28 BranchSelect,
29 StatusView,
30 CommitInput,
31 TopologyView,
32 StatsView,
33 HeatmapView,
34 FileHistoryView,
35 TimelineView,
36 BlameView,
37 OwnershipView,
38 StashView,
39 PatchView,
40 PresetSave,
41 BranchCompareView,
42 RelatedFilesView,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Default)]
47pub enum ViewMode {
48 #[default]
49 Normal, Compact, }
52
53#[derive(Debug, Clone, Copy, PartialEq, Default)]
55pub enum LayoutMode {
56 #[default]
57 Single, SplitPanel, }
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63pub enum CommitType {
64 Feat,
65 Fix,
66 Docs,
67 Style,
68 Refactor,
69 Test,
70 Chore,
71 Perf,
72}
73
74impl CommitType {
75 pub fn prefix(&self) -> &'static str {
77 match self {
78 Self::Feat => "feat: ",
79 Self::Fix => "fix: ",
80 Self::Docs => "docs: ",
81 Self::Style => "style: ",
82 Self::Refactor => "refactor: ",
83 Self::Test => "test: ",
84 Self::Chore => "chore: ",
85 Self::Perf => "perf: ",
86 }
87 }
88
89 pub fn key(&self) -> char {
91 match self {
92 Self::Feat => 'f',
93 Self::Fix => 'x',
94 Self::Docs => 'd',
95 Self::Style => 's',
96 Self::Refactor => 'r',
97 Self::Test => 't',
98 Self::Chore => 'c',
99 Self::Perf => 'p',
100 }
101 }
102
103 pub fn all() -> &'static [CommitType] {
105 &[
106 Self::Feat,
107 Self::Fix,
108 Self::Docs,
109 Self::Style,
110 Self::Refactor,
111 Self::Test,
112 Self::Chore,
113 Self::Perf,
114 ]
115 }
116
117 pub fn name(&self) -> &'static str {
119 match self {
120 Self::Feat => "feat",
121 Self::Fix => "fix",
122 Self::Docs => "docs",
123 Self::Style => "style",
124 Self::Refactor => "refactor",
125 Self::Test => "test",
126 Self::Chore => "chore",
127 Self::Perf => "perf",
128 }
129 }
130}
131
132pub struct App {
134 pub should_quit: bool,
136 pub repo_info: Option<RepoInfo>,
138 repo: Option<Repository>,
140 all_events: Vec<GitEvent>,
142 filtered_indices: Vec<usize>,
144 pub selected_index: usize,
146 pub show_detail: bool,
148 pub input_mode: InputMode,
150 pub filter_text: String,
152 pub filter_query: FilterQuery,
154 file_cache: HashMap<String, Vec<String>>,
156 pub branches: Vec<String>,
158 pub branch_selected_index: usize,
160 pub file_statuses: Vec<FileStatus>,
162 pub status_selected_index: usize,
164 pub commit_message: String,
166 pub commit_type: Option<CommitType>,
168 pub status_message: Option<String>,
170 pub is_loading: bool,
172 pub show_help: bool,
174 head_hash: Option<String>,
176 pub view_mode: ViewMode,
178 pub topology_cache: Option<BranchTopology>,
180 pub topology_selected_index: usize,
182 pub topology_scroll_offset: usize,
184
185 pub commit_suggestions: Vec<CommitSuggestion>,
188 pub suggestion_selected_index: usize,
190
191 pub detail_scroll_offset: usize,
194 pub detail_selected_file: usize,
196 pub detail_diff_cache: Option<CommitDiff>,
198
199 pub stats_cache: Option<RepoStats>,
202 pub stats_selected_index: usize,
204 pub stats_scroll_offset: usize,
206
207 pub heatmap_cache: Option<FileHeatmap>,
210 pub heatmap_selected_index: usize,
212 pub heatmap_scroll_offset: usize,
214
215 pub file_history_cache: Option<Vec<FileHistoryEntry>>,
218 pub file_history_path: Option<String>,
220 pub file_history_selected_index: usize,
222 pub file_history_scroll_offset: usize,
224
225 pub timeline_cache: Option<ActivityTimeline>,
228
229 pub blame_cache: Option<Vec<BlameLine>>,
232 pub blame_path: Option<String>,
234 pub blame_selected_index: usize,
236 pub blame_scroll_offset: usize,
238
239 pub ownership_cache: Option<CodeOwnership>,
242 pub ownership_selected_index: usize,
244 pub ownership_scroll_offset: usize,
246
247 pub stash_cache: Option<Vec<StashEntry>>,
250 pub stash_selected_index: usize,
252 pub stash_scroll_offset: usize,
254
255 pub patch_cache: Option<FilePatch>,
258 pub patch_scroll_offset: usize,
260
261 pub filter_presets: FilterPresets,
264 pub preset_save_slot: usize,
266 pub preset_save_name: String,
268
269 pub branch_compare_cache: Option<BranchCompare>,
272 pub branch_compare_tab: CompareTab,
274 pub branch_compare_selected_index: usize,
276 pub branch_compare_scroll_offset: usize,
278
279 pub relevance_mode: bool,
282 pub working_files: Vec<String>,
284 relevance_scores: HashMap<String, f32>,
286
287 pub related_files_cache: Option<RelatedFiles>,
290 pub related_files_selected_index: usize,
292 pub related_files_scroll_offset: usize,
294
295 pub layout_mode: LayoutMode,
298 pub left_panel_ratio: u16,
300}
301
302impl App {
303 pub fn new() -> Self {
304 Self {
305 should_quit: false,
306 repo_info: None,
307 repo: None,
308 all_events: Vec::new(),
309 filtered_indices: Vec::new(),
310 selected_index: 0,
311 show_detail: false,
312 input_mode: InputMode::Normal,
313 filter_text: String::new(),
314 filter_query: FilterQuery::default(),
315 file_cache: HashMap::new(),
316 branches: Vec::new(),
317 branch_selected_index: 0,
318 file_statuses: Vec::new(),
319 status_selected_index: 0,
320 commit_message: String::new(),
321 commit_type: None,
322 status_message: None,
323 is_loading: false,
324 show_help: false,
325 head_hash: None,
326 view_mode: ViewMode::default(),
327 topology_cache: None,
328 topology_selected_index: 0,
329 topology_scroll_offset: 0,
330 commit_suggestions: Vec::new(),
332 suggestion_selected_index: 0,
333 detail_scroll_offset: 0,
335 detail_selected_file: 0,
336 detail_diff_cache: None,
337 stats_cache: None,
339 stats_selected_index: 0,
340 stats_scroll_offset: 0,
341 heatmap_cache: None,
343 heatmap_selected_index: 0,
344 heatmap_scroll_offset: 0,
345 file_history_cache: None,
347 file_history_path: None,
348 file_history_selected_index: 0,
349 file_history_scroll_offset: 0,
350 timeline_cache: None,
352 blame_cache: None,
354 blame_path: None,
355 blame_selected_index: 0,
356 blame_scroll_offset: 0,
357 ownership_cache: None,
359 ownership_selected_index: 0,
360 ownership_scroll_offset: 0,
361 stash_cache: None,
363 stash_selected_index: 0,
364 stash_scroll_offset: 0,
365 patch_cache: None,
367 patch_scroll_offset: 0,
368 filter_presets: FilterPresets::load(),
370 preset_save_slot: 1,
371 preset_save_name: String::new(),
372 branch_compare_cache: None,
374 branch_compare_tab: CompareTab::default(),
375 branch_compare_selected_index: 0,
376 branch_compare_scroll_offset: 0,
377 relevance_mode: false,
379 working_files: Vec::new(),
380 relevance_scores: HashMap::new(),
381 related_files_cache: None,
383 related_files_selected_index: 0,
384 related_files_scroll_offset: 0,
385 layout_mode: LayoutMode::default(),
387 left_panel_ratio: 65,
388 }
389 }
390
391 pub fn load(&mut self, repo_info: RepoInfo, events: Vec<GitEvent>) {
393 self.repo_info = Some(repo_info);
394 self.filtered_indices = (0..events.len()).collect();
395 self.all_events = events;
396 self.selected_index = 0;
397 self.filter_text.clear();
398 }
399
400 pub fn set_repo(&mut self, repo: Repository) {
402 self.repo = Some(repo);
403 }
404
405 pub fn get_repo(&self) -> Option<&Repository> {
407 self.repo.as_ref()
408 }
409
410 pub fn set_head_hash(&mut self, hash: String) {
412 self.head_hash = Some(if hash.len() > 7 {
414 hash[..7].to_string()
415 } else {
416 hash
417 });
418 }
419
420 pub fn head_hash(&self) -> Option<&str> {
422 self.head_hash.as_deref()
423 }
424
425 pub fn events(&self) -> Vec<&GitEvent> {
427 self.filtered_indices
428 .iter()
429 .filter_map(|&i| self.all_events.get(i))
430 .collect()
431 }
432
433 pub fn all_events(&self) -> &[GitEvent] {
435 &self.all_events
436 }
437
438 pub fn event_count(&self) -> usize {
440 self.filtered_indices.len()
441 }
442
443 pub fn quit(&mut self) {
445 self.should_quit = true;
446 }
447
448 pub fn move_up(&mut self) {
450 if self.selected_index > 0 {
451 self.selected_index -= 1;
452 }
453 }
454
455 pub fn move_down(&mut self) {
457 if !self.filtered_indices.is_empty()
458 && self.selected_index < self.filtered_indices.len() - 1
459 {
460 self.selected_index += 1;
461 }
462 }
463
464 pub fn move_to_top(&mut self) {
466 self.selected_index = 0;
467 }
468
469 pub fn jump_to_head(&mut self) {
472 let Some(head_hash) = &self.head_hash else {
474 self.selected_index = 0;
475 return;
476 };
477
478 let head_index = self
480 .all_events
481 .iter()
482 .position(|e| e.short_hash == *head_hash);
483
484 let Some(head_idx) = head_index else {
485 self.selected_index = 0;
487 return;
488 };
489
490 if let Some(pos) = self.filtered_indices.iter().position(|&i| i == head_idx) {
492 self.selected_index = pos;
493 }
494 }
496
497 pub fn move_to_bottom(&mut self) {
499 if !self.filtered_indices.is_empty() {
500 self.selected_index = self.filtered_indices.len() - 1;
501 }
502 }
503
504 pub fn page_down(&mut self, page_size: usize) {
506 if !self.filtered_indices.is_empty() {
507 let max_index = self.filtered_indices.len() - 1;
508 self.selected_index = (self.selected_index + page_size).min(max_index);
509 }
510 }
511
512 pub fn page_up(&mut self, page_size: usize) {
514 self.selected_index = self.selected_index.saturating_sub(page_size);
515 }
516
517 pub fn toggle_help(&mut self) {
519 self.show_help = !self.show_help;
520 }
521
522 pub fn close_help(&mut self) {
524 self.show_help = false;
525 }
526
527 pub fn cycle_view_mode(&mut self) {
529 self.view_mode = match self.view_mode {
530 ViewMode::Normal => ViewMode::Compact,
531 ViewMode::Compact => ViewMode::Normal,
532 };
533 }
534
535 pub fn effective_layout_mode(&self, width: u16) -> LayoutMode {
540 const SPLIT_PANEL_THRESHOLD: u16 = 120;
541 if width >= SPLIT_PANEL_THRESHOLD && self.layout_mode == LayoutMode::SplitPanel {
542 LayoutMode::SplitPanel
543 } else {
544 LayoutMode::Single
545 }
546 }
547
548 pub fn update_detail_for_split_panel(&mut self) {
552 self.detail_diff_cache = None;
555 self.detail_scroll_offset = 0;
556 self.detail_selected_file = 0;
557 }
558
559 pub fn toggle_layout_mode(&mut self) {
561 self.layout_mode = match self.layout_mode {
562 LayoutMode::Single => LayoutMode::SplitPanel,
563 LayoutMode::SplitPanel => LayoutMode::Single,
564 };
565 }
566
567 pub fn adjust_panel_ratio(&mut self, delta: i16) {
572 let new_ratio = (self.left_panel_ratio as i16 + delta).clamp(40, 85) as u16;
573 self.left_panel_ratio = new_ratio;
574 }
575
576 pub fn jump_to_next_label(&mut self) {
578 for i in (self.selected_index + 1)..self.filtered_indices.len() {
580 if let Some(&event_idx) = self.filtered_indices.get(i) {
581 if let Some(event) = self.all_events.get(event_idx) {
582 if event.has_labels() {
583 self.selected_index = i;
584 return;
585 }
586 }
587 }
588 }
589 }
591
592 pub fn jump_to_prev_label(&mut self) {
594 if self.selected_index == 0 {
596 return;
597 }
598 for i in (0..self.selected_index).rev() {
599 if let Some(&event_idx) = self.filtered_indices.get(i) {
600 if let Some(event) = self.all_events.get(event_idx) {
601 if event.has_labels() {
602 self.selected_index = i;
603 return;
604 }
605 }
606 }
607 }
608 }
610
611 pub fn selected_event(&self) -> Option<&GitEvent> {
613 self.filtered_indices
614 .get(self.selected_index)
615 .and_then(|&i| self.all_events.get(i))
616 }
617
618 pub fn open_detail(&mut self) {
620 if self.selected_event().is_some() {
621 self.show_detail = true;
622 self.detail_scroll_offset = 0;
623 self.detail_selected_file = 0;
624 self.detail_diff_cache = None;
625 }
626 }
627
628 pub fn close_detail(&mut self) {
630 self.show_detail = false;
631 self.detail_diff_cache = None;
632 }
633
634 pub fn set_detail_diff(&mut self, diff: CommitDiff) {
636 self.detail_diff_cache = Some(diff);
637 }
638
639 pub fn detail_move_up(&mut self) {
641 if self.detail_selected_file > 0 {
642 self.detail_selected_file -= 1;
643 if self.detail_selected_file < self.detail_scroll_offset {
645 self.detail_scroll_offset = self.detail_selected_file;
646 }
647 }
648 }
649
650 pub fn detail_move_down(&mut self) {
652 if let Some(ref diff) = self.detail_diff_cache {
653 if !diff.files.is_empty() && self.detail_selected_file < diff.files.len() - 1 {
654 self.detail_selected_file += 1;
655 }
656 }
657 }
658
659 pub fn detail_adjust_scroll(&mut self, visible_lines: usize) {
661 if visible_lines == 0 {
662 return;
663 }
664 if self.detail_selected_file >= self.detail_scroll_offset + visible_lines {
666 self.detail_scroll_offset = self.detail_selected_file - visible_lines + 1;
667 }
668 }
669
670 pub fn start_filter(&mut self) {
672 self.input_mode = InputMode::Filter;
673 }
674
675 pub fn end_filter(&mut self) {
677 self.input_mode = InputMode::Normal;
678 }
679
680 pub fn filter_push(&mut self, c: char) {
682 self.filter_text.push(c);
683 self.apply_filter();
684 self.update_filter_status();
685 }
686
687 pub fn filter_pop(&mut self) {
689 self.filter_text.pop();
690 self.apply_filter();
691 self.update_filter_status();
692 }
693
694 pub fn filter_clear(&mut self) {
696 self.filter_text.clear();
697 self.apply_filter();
698 self.status_message = None;
699 }
700
701 fn update_filter_status(&mut self) {
703 let total = self.all_events.len();
704 let filtered = self.filtered_indices.len();
705 if !self.filter_text.is_empty() {
706 self.status_message = Some(format!("{}/{} commits matched", filtered, total));
707 }
708 }
709
710 fn apply_filter(&mut self) {
712 self.filter_query = FilterQuery::parse(&self.filter_text);
714
715 if self.filter_query.is_empty() {
716 self.filtered_indices = (0..self.all_events.len()).collect();
717 } else {
718 let hash_range_indices = self.calculate_hash_range_indices();
720
721 self.filtered_indices = self
722 .all_events
723 .iter()
724 .enumerate()
725 .filter(|(i, e)| {
726 if let Some((start_idx, end_idx)) = hash_range_indices {
728 if *i < start_idx || *i > end_idx {
730 return false;
731 }
732 }
733
734 let files = self.file_cache.get(&e.short_hash).map(|v| v.as_slice());
735 self.filter_query.matches(e, files)
736 })
737 .map(|(i, _)| i)
738 .collect();
739 }
740 if self.selected_index >= self.filtered_indices.len() {
742 self.selected_index = self.filtered_indices.len().saturating_sub(1);
743 }
744 }
745
746 fn calculate_hash_range_indices(&self) -> Option<(usize, usize)> {
752 let (start_hash, end_hash) = self.filter_query.hash_range.as_ref()?;
753
754 let start_idx = self
756 .all_events
757 .iter()
758 .position(|e| e.short_hash.starts_with(start_hash))?;
759
760 let end_idx = self
762 .all_events
763 .iter()
764 .position(|e| e.short_hash.starts_with(end_hash))?;
765
766 if start_idx <= end_idx {
769 Some((start_idx, end_idx))
770 } else {
771 Some((end_idx, start_idx))
773 }
774 }
775
776 pub fn preload_file_cache(&mut self, get_files: impl Fn(&str) -> Option<Vec<String>>) {
779 if !self.filter_query.has_file_filter() {
780 return;
781 }
782
783 const MAX_PRELOAD_COMMITS: usize = 200;
785
786 for event in self.all_events.iter().take(MAX_PRELOAD_COMMITS) {
787 if !self.file_cache.contains_key(&event.short_hash) {
788 if let Some(files) = get_files(&event.short_hash) {
789 self.file_cache.insert(event.short_hash.clone(), files);
790 }
791 }
792 }
793 }
794
795 pub fn clear_file_cache(&mut self) {
797 self.file_cache.clear();
798 }
799
800 pub fn file_cache_is_empty(&self) -> bool {
802 self.file_cache.is_empty()
803 }
804
805 pub fn reapply_filter(&mut self) {
807 self.apply_filter();
808 }
809
810 pub fn repo_name(&self) -> &str {
812 self.repo_info
813 .as_ref()
814 .map(|r| r.name.as_str())
815 .unwrap_or("unknown")
816 }
817
818 pub fn branch_name(&self) -> &str {
820 self.repo_info
821 .as_ref()
822 .map(|r| r.branch.as_str())
823 .unwrap_or("unknown")
824 }
825
826 pub fn start_branch_select(&mut self, branches: Vec<String>) {
828 self.branches = branches;
829 let current = self.branch_name().to_string();
831 self.branch_selected_index = self
832 .branches
833 .iter()
834 .position(|b| b == ¤t)
835 .unwrap_or(0);
836 self.input_mode = InputMode::BranchSelect;
837 }
838
839 pub fn end_branch_select(&mut self) {
841 self.input_mode = InputMode::Normal;
842 self.branches.clear();
843 }
844
845 pub fn branch_move_up(&mut self) {
847 if self.branch_selected_index > 0 {
848 self.branch_selected_index -= 1;
849 }
850 }
851
852 pub fn branch_move_down(&mut self) {
854 if !self.branches.is_empty() && self.branch_selected_index < self.branches.len() - 1 {
855 self.branch_selected_index += 1;
856 }
857 }
858
859 pub fn selected_branch(&self) -> Option<&str> {
861 self.branches
862 .get(self.branch_selected_index)
863 .map(|s| s.as_str())
864 }
865
866 pub fn update_branch(&mut self, branch: String) {
868 if let Some(ref mut info) = self.repo_info {
869 info.branch = branch;
870 }
871 }
872
873 pub fn all_events_replace(&mut self, events: Vec<GitEvent>) {
875 self.all_events = events;
876 self.selected_index = 0;
877 }
878
879 pub fn filtered_indices_reset(&mut self, count: usize) {
881 self.filtered_indices = (0..count).collect();
882 self.selected_index = 0;
883 }
884
885 pub fn start_status_view(&mut self, statuses: Vec<FileStatus>) {
887 self.file_statuses = statuses;
888 self.status_selected_index = 0;
889 self.status_message = None;
890 self.input_mode = InputMode::StatusView;
891 }
892
893 pub fn end_status_view(&mut self) {
895 self.input_mode = InputMode::Normal;
896 self.file_statuses.clear();
897 self.status_message = None;
898 }
899
900 pub fn status_move_up(&mut self) {
902 if self.status_selected_index > 0 {
903 self.status_selected_index -= 1;
904 }
905 }
906
907 pub fn status_move_down(&mut self) {
909 if !self.file_statuses.is_empty()
910 && self.status_selected_index < self.file_statuses.len() - 1
911 {
912 self.status_selected_index += 1;
913 }
914 }
915
916 pub fn selected_file_status(&self) -> Option<&FileStatus> {
918 self.file_statuses.get(self.status_selected_index)
919 }
920
921 pub fn update_file_statuses(&mut self, statuses: Vec<FileStatus>) {
923 self.file_statuses = statuses;
924 if self.status_selected_index >= self.file_statuses.len() {
925 self.status_selected_index = self.file_statuses.len().saturating_sub(1);
926 }
927 }
928
929 pub fn set_status_message(&mut self, msg: String) {
931 self.status_message = Some(msg);
932 }
933
934 pub fn start_commit_input(&mut self) {
936 self.commit_message.clear();
937 self.commit_type = None;
938 self.input_mode = InputMode::CommitInput;
939 }
940
941 pub fn end_commit_input(&mut self) {
943 self.commit_type = None;
944 self.input_mode = InputMode::StatusView;
945 }
946
947 pub fn select_commit_type(&mut self, commit_type: CommitType) {
949 self.commit_type = Some(commit_type);
950 self.commit_message = commit_type.prefix().to_string();
951 }
952
953 pub fn commit_message_push(&mut self, c: char) {
955 self.commit_message.push(c);
956 }
957
958 pub fn commit_message_pop(&mut self) {
960 self.commit_message.pop();
961 if let Some(commit_type) = self.commit_type {
963 if !self
964 .commit_message
965 .starts_with(commit_type.prefix().trim_end())
966 {
967 self.commit_type = None;
968 }
969 }
970 }
971
972 pub fn commit_message_clear(&mut self) {
974 self.commit_message.clear();
975 self.commit_type = None;
976 }
977
978 pub fn start_loading(&mut self) {
980 self.is_loading = true;
981 }
982
983 pub fn finish_loading(&mut self) {
985 self.is_loading = false;
986 }
987
988 pub fn append_events(&mut self, mut events: Vec<GitEvent>) {
990 let current_len = self.all_events.len();
991 self.all_events.append(&mut events);
992 if self.filter_text.is_empty() {
994 let new_indices: Vec<usize> = (current_len..self.all_events.len()).collect();
995 self.filtered_indices.extend(new_indices);
996 } else {
997 self.apply_filter();
999 }
1000 }
1001
1002 pub fn start_topology_view(&mut self, topology: BranchTopology) {
1006 self.topology_cache = Some(topology);
1007 self.topology_selected_index = 0;
1008 self.topology_scroll_offset = 0;
1009 self.input_mode = InputMode::TopologyView;
1010 }
1011
1012 pub fn end_topology_view(&mut self) {
1014 self.input_mode = InputMode::Normal;
1015 self.topology_cache = None;
1016 }
1017
1018 pub fn topology_move_up(&mut self) {
1020 if self.topology_selected_index > 0 {
1021 self.topology_selected_index -= 1;
1022 if self.topology_selected_index < self.topology_scroll_offset {
1024 self.topology_scroll_offset = self.topology_selected_index;
1025 }
1026 }
1027 }
1028
1029 pub fn topology_move_down(&mut self) {
1031 if let Some(ref topology) = self.topology_cache {
1032 if self.topology_selected_index < topology.branches.len().saturating_sub(1) {
1033 self.topology_selected_index += 1;
1034 }
1035 }
1036 }
1037
1038 pub fn topology_adjust_scroll(&mut self, visible_lines: usize) {
1040 if visible_lines == 0 {
1041 return;
1042 }
1043 if self.topology_selected_index >= self.topology_scroll_offset + visible_lines {
1045 self.topology_scroll_offset = self.topology_selected_index - visible_lines + 1;
1046 }
1047 }
1048
1049 pub fn selected_topology_branch(&self) -> Option<&str> {
1051 self.topology_cache.as_ref().and_then(|t| {
1052 t.branches
1053 .get(self.topology_selected_index)
1054 .map(|b| b.name.as_str())
1055 })
1056 }
1057
1058 pub fn filter_description(&self) -> String {
1060 self.filter_query.description()
1061 }
1062
1063 pub fn generate_commit_suggestions(&mut self) {
1067 use crate::suggestion::generate_suggestions;
1068
1069 let staged: Vec<FileStatus> = self
1070 .file_statuses
1071 .iter()
1072 .filter(|s| s.kind.is_staged())
1073 .cloned()
1074 .collect();
1075 self.commit_suggestions = generate_suggestions(&staged);
1076 self.suggestion_selected_index = 0;
1077 }
1078
1079 pub fn apply_suggestion(&mut self, index: usize) {
1081 if let Some(suggestion) = self.commit_suggestions.get(index) {
1082 self.commit_type = Some(suggestion.commit_type);
1083 self.commit_message = suggestion.full_message();
1084 }
1085 }
1086
1087 pub fn suggestion_move_up(&mut self) {
1089 if self.suggestion_selected_index > 0 {
1090 self.suggestion_selected_index -= 1;
1091 }
1092 }
1093
1094 pub fn suggestion_move_down(&mut self) {
1096 if !self.commit_suggestions.is_empty()
1097 && self.suggestion_selected_index < self.commit_suggestions.len() - 1
1098 {
1099 self.suggestion_selected_index += 1;
1100 }
1101 }
1102
1103 pub fn start_stats_view(&mut self, stats: RepoStats) {
1107 self.stats_cache = Some(stats);
1108 self.stats_selected_index = 0;
1109 self.stats_scroll_offset = 0;
1110 self.input_mode = InputMode::StatsView;
1111 }
1112
1113 pub fn end_stats_view(&mut self) {
1115 self.input_mode = InputMode::Normal;
1116 self.stats_cache = None;
1117 }
1118
1119 pub fn export_stats(&mut self) -> Option<String> {
1121 if let Some(ref stats) = self.stats_cache {
1122 let csv_path = Path::new("gitstack_stats.csv");
1123 let json_path = Path::new("gitstack_stats.json");
1124
1125 let mut results = Vec::new();
1126
1127 if let Err(e) = export_stats_csv(stats, csv_path) {
1128 results.push(format!("CSV error: {}", e));
1129 } else {
1130 results.push(format!("CSV: {}", csv_path.display()));
1131 }
1132
1133 if let Err(e) = export_stats_json(stats, json_path) {
1134 results.push(format!("JSON error: {}", e));
1135 } else {
1136 results.push(format!("JSON: {}", json_path.display()));
1137 }
1138
1139 let message = format!("Exported: {}", results.join(", "));
1140 self.status_message = Some(message.clone());
1141 Some(message)
1142 } else {
1143 None
1144 }
1145 }
1146
1147 pub fn stats_move_up(&mut self) {
1149 if self.stats_selected_index > 0 {
1150 self.stats_selected_index -= 1;
1151 if self.stats_selected_index < self.stats_scroll_offset {
1153 self.stats_scroll_offset = self.stats_selected_index;
1154 }
1155 }
1156 }
1157
1158 pub fn stats_move_down(&mut self) {
1160 if let Some(ref stats) = self.stats_cache {
1161 if !stats.authors.is_empty() && self.stats_selected_index < stats.authors.len() - 1 {
1162 self.stats_selected_index += 1;
1163 }
1164 }
1165 }
1166
1167 pub fn stats_adjust_scroll(&mut self, visible_lines: usize) {
1169 if visible_lines == 0 {
1170 return;
1171 }
1172 if self.stats_selected_index >= self.stats_scroll_offset + visible_lines {
1173 self.stats_scroll_offset = self.stats_selected_index - visible_lines + 1;
1174 }
1175 }
1176
1177 pub fn selected_author(&self) -> Option<&str> {
1179 self.stats_cache.as_ref().and_then(|s| {
1180 s.authors
1181 .get(self.stats_selected_index)
1182 .map(|a| a.name.as_str())
1183 })
1184 }
1185
1186 pub fn filter_by_author(&mut self, author: &str) {
1188 self.filter_text = format!("/author:{}", author);
1189 self.reapply_filter();
1190 }
1191
1192 pub fn start_heatmap_view(&mut self, heatmap: FileHeatmap) {
1196 self.heatmap_cache = Some(heatmap);
1197 self.heatmap_selected_index = 0;
1198 self.heatmap_scroll_offset = 0;
1199 self.input_mode = InputMode::HeatmapView;
1200 }
1201
1202 pub fn end_heatmap_view(&mut self) {
1204 self.input_mode = InputMode::Normal;
1205 self.heatmap_cache = None;
1206 }
1207
1208 pub fn export_heatmap(&mut self) -> Option<String> {
1210 if let Some(ref heatmap) = self.heatmap_cache {
1211 let csv_path = Path::new("gitstack_heatmap.csv");
1212 let json_path = Path::new("gitstack_heatmap.json");
1213
1214 let mut results = Vec::new();
1215
1216 if let Err(e) = export_heatmap_csv(heatmap, csv_path) {
1217 results.push(format!("CSV error: {}", e));
1218 } else {
1219 results.push(format!("CSV: {}", csv_path.display()));
1220 }
1221
1222 if let Err(e) = export_heatmap_json(heatmap, json_path) {
1223 results.push(format!("JSON error: {}", e));
1224 } else {
1225 results.push(format!("JSON: {}", json_path.display()));
1226 }
1227
1228 let message = format!("Exported: {}", results.join(", "));
1229 self.status_message = Some(message.clone());
1230 Some(message)
1231 } else {
1232 None
1233 }
1234 }
1235
1236 pub fn heatmap_increase_aggregation(&mut self) {
1238 if let Some(ref heatmap) = self.heatmap_cache.clone() {
1239 let new_level = heatmap.aggregation_level.next();
1240 self.heatmap_cache = Some(heatmap.with_aggregation(new_level));
1241 self.heatmap_selected_index = 0;
1242 self.heatmap_scroll_offset = 0;
1243 self.status_message = Some(format!("Aggregation: {}", new_level.display_name()));
1244 }
1245 }
1246
1247 pub fn heatmap_decrease_aggregation(&mut self) {
1249 if let Some(ref heatmap) = self.heatmap_cache.clone() {
1250 let new_level = heatmap.aggregation_level.prev();
1251 self.heatmap_cache = Some(heatmap.with_aggregation(new_level));
1252 self.heatmap_selected_index = 0;
1253 self.heatmap_scroll_offset = 0;
1254 self.status_message = Some(format!("Aggregation: {}", new_level.display_name()));
1255 }
1256 }
1257
1258 pub fn heatmap_move_up(&mut self) {
1260 if self.heatmap_selected_index > 0 {
1261 self.heatmap_selected_index -= 1;
1262 if self.heatmap_selected_index < self.heatmap_scroll_offset {
1264 self.heatmap_scroll_offset = self.heatmap_selected_index;
1265 }
1266 }
1267 }
1268
1269 pub fn heatmap_move_down(&mut self) {
1271 if let Some(ref heatmap) = self.heatmap_cache {
1272 if !heatmap.files.is_empty() && self.heatmap_selected_index < heatmap.files.len() - 1 {
1273 self.heatmap_selected_index += 1;
1274 }
1275 }
1276 }
1277
1278 pub fn heatmap_adjust_scroll(&mut self, visible_lines: usize) {
1280 if visible_lines == 0 {
1281 return;
1282 }
1283 if self.heatmap_selected_index >= self.heatmap_scroll_offset + visible_lines {
1284 self.heatmap_scroll_offset = self.heatmap_selected_index - visible_lines + 1;
1285 }
1286 }
1287
1288 pub fn selected_heatmap_file(&self) -> Option<&str> {
1290 self.heatmap_cache.as_ref().and_then(|h| {
1291 h.files
1292 .get(self.heatmap_selected_index)
1293 .map(|f| f.path.as_str())
1294 })
1295 }
1296
1297 pub fn filter_by_file(&mut self, file_path: &str) {
1299 self.filter_text = format!("/file:{}", file_path);
1300 self.reapply_filter();
1301 }
1302
1303 pub fn start_file_history_view(&mut self, path: String, history: Vec<FileHistoryEntry>) {
1307 self.file_history_path = Some(path);
1308 self.file_history_cache = Some(history);
1309 self.file_history_selected_index = 0;
1310 self.file_history_scroll_offset = 0;
1311 self.input_mode = InputMode::FileHistoryView;
1312 }
1313
1314 pub fn end_file_history_view(&mut self) {
1316 self.input_mode = InputMode::Normal;
1317 self.file_history_cache = None;
1318 self.file_history_path = None;
1319 }
1320
1321 pub fn file_history_move_up(&mut self) {
1323 if self.file_history_selected_index > 0 {
1324 self.file_history_selected_index -= 1;
1325 if self.file_history_selected_index < self.file_history_scroll_offset {
1327 self.file_history_scroll_offset = self.file_history_selected_index;
1328 }
1329 }
1330 }
1331
1332 pub fn file_history_move_down(&mut self) {
1334 if let Some(ref history) = self.file_history_cache {
1335 if !history.is_empty() && self.file_history_selected_index < history.len() - 1 {
1336 self.file_history_selected_index += 1;
1337 }
1338 }
1339 }
1340
1341 pub fn file_history_adjust_scroll(&mut self, visible_lines: usize) {
1343 if visible_lines == 0 {
1344 return;
1345 }
1346 if self.file_history_selected_index >= self.file_history_scroll_offset + visible_lines {
1347 self.file_history_scroll_offset = self.file_history_selected_index - visible_lines + 1;
1348 }
1349 }
1350
1351 pub fn selected_file_history(&self) -> Option<&FileHistoryEntry> {
1353 self.file_history_cache
1354 .as_ref()
1355 .and_then(|h| h.get(self.file_history_selected_index))
1356 }
1357
1358 pub fn start_timeline_view(&mut self, timeline: ActivityTimeline) {
1362 self.timeline_cache = Some(timeline);
1363 self.input_mode = InputMode::TimelineView;
1364 }
1365
1366 pub fn end_timeline_view(&mut self) {
1368 self.input_mode = InputMode::Normal;
1369 self.timeline_cache = None;
1370 }
1371
1372 pub fn export_timeline(&mut self) -> Option<String> {
1374 if let Some(ref timeline) = self.timeline_cache {
1375 let csv_path = Path::new("gitstack_timeline.csv");
1376 let json_path = Path::new("gitstack_timeline.json");
1377
1378 let mut results = Vec::new();
1379
1380 if let Err(e) = export_timeline_csv(timeline, csv_path) {
1381 results.push(format!("CSV error: {}", e));
1382 } else {
1383 results.push(format!("CSV: {}", csv_path.display()));
1384 }
1385
1386 if let Err(e) = export_timeline_json(timeline, json_path) {
1387 results.push(format!("JSON error: {}", e));
1388 } else {
1389 results.push(format!("JSON: {}", json_path.display()));
1390 }
1391
1392 let message = format!("Exported: {}", results.join(", "));
1393 self.status_message = Some(message.clone());
1394 Some(message)
1395 } else {
1396 None
1397 }
1398 }
1399
1400 pub fn start_blame_view(&mut self, path: String, blame: Vec<BlameLine>) {
1404 self.blame_path = Some(path);
1405 self.blame_cache = Some(blame);
1406 self.blame_selected_index = 0;
1407 self.blame_scroll_offset = 0;
1408 self.input_mode = InputMode::BlameView;
1409 }
1410
1411 pub fn end_blame_view(&mut self) {
1413 self.input_mode = InputMode::Normal;
1414 self.blame_cache = None;
1415 self.blame_path = None;
1416 }
1417
1418 pub fn blame_move_up(&mut self) {
1420 if self.blame_selected_index > 0 {
1421 self.blame_selected_index -= 1;
1422 if self.blame_selected_index < self.blame_scroll_offset {
1424 self.blame_scroll_offset = self.blame_selected_index;
1425 }
1426 }
1427 }
1428
1429 pub fn blame_move_down(&mut self) {
1431 if let Some(ref blame) = self.blame_cache {
1432 if !blame.is_empty() && self.blame_selected_index < blame.len() - 1 {
1433 self.blame_selected_index += 1;
1434 }
1435 }
1436 }
1437
1438 pub fn blame_adjust_scroll(&mut self, visible_lines: usize) {
1440 if visible_lines == 0 {
1441 return;
1442 }
1443 if self.blame_selected_index >= self.blame_scroll_offset + visible_lines {
1444 self.blame_scroll_offset = self.blame_selected_index - visible_lines + 1;
1445 }
1446 }
1447
1448 pub fn selected_blame_line(&self) -> Option<&BlameLine> {
1450 self.blame_cache
1451 .as_ref()
1452 .and_then(|b| b.get(self.blame_selected_index))
1453 }
1454
1455 pub fn start_ownership_view(&mut self, ownership: CodeOwnership) {
1459 self.ownership_cache = Some(ownership);
1460 self.ownership_selected_index = 0;
1461 self.ownership_scroll_offset = 0;
1462 self.input_mode = InputMode::OwnershipView;
1463 }
1464
1465 pub fn end_ownership_view(&mut self) {
1467 self.input_mode = InputMode::Normal;
1468 self.ownership_cache = None;
1469 }
1470
1471 pub fn export_ownership(&mut self) -> Option<String> {
1473 if let Some(ref ownership) = self.ownership_cache {
1474 let csv_path = Path::new("gitstack_ownership.csv");
1475 let json_path = Path::new("gitstack_ownership.json");
1476
1477 let mut results = Vec::new();
1478
1479 if let Err(e) = export_ownership_csv(ownership, csv_path) {
1480 results.push(format!("CSV error: {}", e));
1481 } else {
1482 results.push(format!("CSV: {}", csv_path.display()));
1483 }
1484
1485 if let Err(e) = export_ownership_json(ownership, json_path) {
1486 results.push(format!("JSON error: {}", e));
1487 } else {
1488 results.push(format!("JSON: {}", json_path.display()));
1489 }
1490
1491 let message = format!("Exported: {}", results.join(", "));
1492 self.status_message = Some(message.clone());
1493 Some(message)
1494 } else {
1495 None
1496 }
1497 }
1498
1499 pub fn ownership_move_up(&mut self) {
1501 if self.ownership_selected_index > 0 {
1502 self.ownership_selected_index -= 1;
1503 if self.ownership_selected_index < self.ownership_scroll_offset {
1505 self.ownership_scroll_offset = self.ownership_selected_index;
1506 }
1507 }
1508 }
1509
1510 pub fn ownership_move_down(&mut self) {
1512 if let Some(ref ownership) = self.ownership_cache {
1513 if !ownership.entries.is_empty()
1514 && self.ownership_selected_index < ownership.entries.len() - 1
1515 {
1516 self.ownership_selected_index += 1;
1517 }
1518 }
1519 }
1520
1521 pub fn ownership_adjust_scroll(&mut self, visible_lines: usize) {
1523 if visible_lines == 0 {
1524 return;
1525 }
1526 if self.ownership_selected_index >= self.ownership_scroll_offset + visible_lines {
1527 self.ownership_scroll_offset = self.ownership_selected_index - visible_lines + 1;
1528 }
1529 }
1530
1531 pub fn selected_ownership_entry(&self) -> Option<&crate::stats::CodeOwnershipEntry> {
1533 self.ownership_cache
1534 .as_ref()
1535 .and_then(|o| o.entries.get(self.ownership_selected_index))
1536 }
1537
1538 pub fn start_stash_view(&mut self, stashes: Vec<StashEntry>) {
1542 self.stash_cache = Some(stashes);
1543 self.stash_selected_index = 0;
1544 self.stash_scroll_offset = 0;
1545 self.input_mode = InputMode::StashView;
1546 }
1547
1548 pub fn end_stash_view(&mut self) {
1550 self.input_mode = InputMode::Normal;
1551 self.stash_cache = None;
1552 }
1553
1554 pub fn stash_move_up(&mut self) {
1556 if self.stash_selected_index > 0 {
1557 self.stash_selected_index -= 1;
1558 if self.stash_selected_index < self.stash_scroll_offset {
1560 self.stash_scroll_offset = self.stash_selected_index;
1561 }
1562 }
1563 }
1564
1565 pub fn stash_move_down(&mut self) {
1567 if let Some(ref stashes) = self.stash_cache {
1568 if !stashes.is_empty() && self.stash_selected_index < stashes.len() - 1 {
1569 self.stash_selected_index += 1;
1570 }
1571 }
1572 }
1573
1574 pub fn stash_adjust_scroll(&mut self, visible_lines: usize) {
1576 if visible_lines == 0 {
1577 return;
1578 }
1579 if self.stash_selected_index >= self.stash_scroll_offset + visible_lines {
1580 self.stash_scroll_offset = self.stash_selected_index - visible_lines + 1;
1581 }
1582 }
1583
1584 pub fn selected_stash_entry(&self) -> Option<&StashEntry> {
1586 self.stash_cache
1587 .as_ref()
1588 .and_then(|s| s.get(self.stash_selected_index))
1589 }
1590
1591 pub fn update_stash_cache(&mut self, stashes: Vec<StashEntry>) {
1593 let prev_len = self.stash_cache.as_ref().map(|s| s.len()).unwrap_or(0);
1594 self.stash_cache = Some(stashes);
1595 if let Some(ref stashes) = self.stash_cache {
1597 if !stashes.is_empty() && self.stash_selected_index >= stashes.len() {
1598 self.stash_selected_index = stashes.len() - 1;
1599 }
1600 if stashes.is_empty() {
1602 self.stash_selected_index = 0;
1603 }
1604 }
1605 let _ = prev_len; }
1607
1608 pub fn start_patch_view(&mut self, patch: FilePatch) {
1612 self.patch_cache = Some(patch);
1613 self.patch_scroll_offset = 0;
1614 self.input_mode = InputMode::PatchView;
1615 }
1616
1617 pub fn end_patch_view(&mut self) {
1619 self.input_mode = InputMode::Normal;
1620 self.show_detail = true; self.patch_cache = None;
1622 }
1623
1624 pub fn patch_scroll_up(&mut self) {
1626 if self.patch_scroll_offset > 0 {
1627 self.patch_scroll_offset -= 1;
1628 }
1629 }
1630
1631 pub fn patch_scroll_down(&mut self, visible_lines: usize) {
1633 if let Some(ref patch) = self.patch_cache {
1634 let max_scroll = patch.lines.len().saturating_sub(visible_lines);
1635 if self.patch_scroll_offset < max_scroll {
1636 self.patch_scroll_offset += 1;
1637 }
1638 }
1639 }
1640
1641 pub fn apply_preset(&mut self, slot: usize) {
1645 if let Some(preset) = self.filter_presets.get(slot) {
1646 let query = preset.query.clone();
1647 let name = preset.name.clone();
1648 self.filter_text = query;
1649 self.apply_filter();
1650 self.set_status_message(format!("Preset {}: {}", slot, name));
1651 }
1652 }
1653
1654 pub fn start_preset_save(&mut self) {
1656 if self.filter_text.is_empty() {
1657 self.set_status_message("No filter to save".to_string());
1658 return;
1659 }
1660 self.preset_save_slot = self.filter_presets.next_empty_slot().unwrap_or(1);
1662 self.preset_save_name.clear();
1663 self.input_mode = InputMode::PresetSave;
1664 }
1665
1666 pub fn end_preset_save(&mut self) {
1668 self.input_mode = InputMode::Filter;
1669 self.preset_save_name.clear();
1670 }
1671
1672 pub fn preset_slot_up(&mut self) {
1674 if self.preset_save_slot > 1 {
1675 self.preset_save_slot -= 1;
1676 }
1677 }
1678
1679 pub fn preset_slot_down(&mut self) {
1681 if self.preset_save_slot < 5 {
1682 self.preset_save_slot += 1;
1683 }
1684 }
1685
1686 pub fn preset_name_push(&mut self, c: char) {
1688 self.preset_save_name.push(c);
1689 }
1690
1691 pub fn preset_name_pop(&mut self) {
1693 self.preset_save_name.pop();
1694 }
1695
1696 pub fn save_preset(&mut self) -> bool {
1698 if self.filter_text.is_empty() {
1699 return false;
1700 }
1701
1702 let name = if self.preset_save_name.is_empty() {
1703 format!("Preset {}", self.preset_save_slot)
1704 } else {
1705 self.preset_save_name.clone()
1706 };
1707
1708 self.filter_presets.set(
1709 self.preset_save_slot,
1710 FilterPreset {
1711 name: name.clone(),
1712 query: self.filter_text.clone(),
1713 },
1714 );
1715
1716 if self.filter_presets.save().is_ok() {
1717 self.set_status_message(format!("Saved to slot {}: {}", self.preset_save_slot, name));
1718 true
1719 } else {
1720 self.set_status_message("Failed to save preset".to_string());
1721 false
1722 }
1723 }
1724
1725 pub fn delete_preset(&mut self, slot: usize) {
1727 match slot {
1728 1 => self.filter_presets.slot1 = None,
1729 2 => self.filter_presets.slot2 = None,
1730 3 => self.filter_presets.slot3 = None,
1731 4 => self.filter_presets.slot4 = None,
1732 5 => self.filter_presets.slot5 = None,
1733 _ => return,
1734 }
1735 if self.filter_presets.save().is_ok() {
1736 self.set_status_message(format!("Deleted preset {}", slot));
1737 }
1738 }
1739
1740 pub fn start_branch_compare_view(&mut self, compare: BranchCompare) {
1744 self.branch_compare_cache = Some(compare);
1745 self.branch_compare_tab = CompareTab::Ahead;
1746 self.branch_compare_selected_index = 0;
1747 self.branch_compare_scroll_offset = 0;
1748 self.input_mode = InputMode::BranchCompareView;
1749 }
1750
1751 pub fn end_branch_compare_view(&mut self) {
1753 self.branch_compare_cache = None;
1754 self.input_mode = InputMode::TopologyView;
1755 }
1756
1757 pub fn branch_compare_toggle_tab(&mut self) {
1759 self.branch_compare_tab = self.branch_compare_tab.toggle();
1760 self.branch_compare_selected_index = 0;
1761 self.branch_compare_scroll_offset = 0;
1762 }
1763
1764 pub fn branch_compare_move_up(&mut self) {
1766 if self.branch_compare_selected_index > 0 {
1767 self.branch_compare_selected_index -= 1;
1768 if self.branch_compare_selected_index < self.branch_compare_scroll_offset {
1769 self.branch_compare_scroll_offset = self.branch_compare_selected_index;
1770 }
1771 }
1772 }
1773
1774 pub fn branch_compare_move_down(&mut self) {
1776 if let Some(ref compare) = self.branch_compare_cache {
1777 let count = match self.branch_compare_tab {
1778 CompareTab::Ahead => compare.ahead_commits.len(),
1779 CompareTab::Behind => compare.behind_commits.len(),
1780 };
1781 if count > 0 && self.branch_compare_selected_index < count - 1 {
1782 self.branch_compare_selected_index += 1;
1783 }
1784 }
1785 }
1786
1787 pub fn branch_compare_adjust_scroll(&mut self, visible_lines: usize) {
1789 if visible_lines == 0 {
1790 return;
1791 }
1792 if self.branch_compare_selected_index >= self.branch_compare_scroll_offset + visible_lines {
1793 self.branch_compare_scroll_offset =
1794 self.branch_compare_selected_index - visible_lines + 1;
1795 }
1796 }
1797
1798 pub fn selected_branch_compare_commit(&self) -> Option<&crate::compare::CompareCommit> {
1800 let compare = self.branch_compare_cache.as_ref()?;
1801 let commits = match self.branch_compare_tab {
1802 CompareTab::Ahead => &compare.ahead_commits,
1803 CompareTab::Behind => &compare.behind_commits,
1804 };
1805 commits.get(self.branch_compare_selected_index)
1806 }
1807
1808 pub fn toggle_relevance_mode(&mut self) {
1812 self.relevance_mode = !self.relevance_mode;
1813 if self.relevance_mode {
1814 self.apply_relevance_sort();
1815 } else {
1816 self.reapply_filter();
1818 }
1819 }
1820
1821 pub fn set_working_files(&mut self, files: Vec<String>) {
1823 self.working_files = files;
1824 if self.relevance_mode {
1826 self.apply_relevance_sort();
1827 }
1828 }
1829
1830 pub fn apply_relevance_sort(&mut self) {
1832 if self.working_files.is_empty() {
1833 self.set_status_message("No working files to compare".to_string());
1834 self.relevance_mode = false;
1835 return;
1836 }
1837
1838 let events: Vec<&GitEvent> = self.all_events.iter().collect();
1840 let scores = crate::relevance::calculate_relevance(&self.working_files, &events, |hash| {
1841 self.file_cache.get(hash).cloned()
1842 });
1843
1844 self.relevance_scores.clear();
1846 for score in &scores {
1847 self.relevance_scores
1848 .insert(score.hash.clone(), score.score);
1849 }
1850
1851 self.filtered_indices.sort_by(|&a, &b| {
1853 let score_a = self
1854 .all_events
1855 .get(a)
1856 .and_then(|e| self.relevance_scores.get(&e.short_hash))
1857 .copied()
1858 .unwrap_or(-1.0);
1859 let score_b = self
1860 .all_events
1861 .get(b)
1862 .and_then(|e| self.relevance_scores.get(&e.short_hash))
1863 .copied()
1864 .unwrap_or(-1.0);
1865 score_b
1866 .partial_cmp(&score_a)
1867 .unwrap_or(std::cmp::Ordering::Equal)
1868 });
1869
1870 self.selected_index = 0;
1871 let related_count = scores.len();
1872 self.set_status_message(format!("Relevance mode: {} related commits", related_count));
1873 }
1874
1875 pub fn get_relevance_score(&self, hash: &str) -> Option<f32> {
1877 self.relevance_scores.get(hash).copied()
1878 }
1879
1880 pub fn is_relevance_mode(&self) -> bool {
1882 self.relevance_mode
1883 }
1884
1885 pub fn start_related_files_view(&mut self, related_files: RelatedFiles) {
1889 self.related_files_cache = Some(related_files);
1890 self.related_files_selected_index = 0;
1891 self.related_files_scroll_offset = 0;
1892 self.input_mode = InputMode::RelatedFilesView;
1893 }
1894
1895 pub fn close_related_files_view(&mut self) {
1897 self.related_files_cache = None;
1898 self.input_mode = InputMode::Normal;
1899 self.open_detail(); }
1901
1902 pub fn related_files_move_down(&mut self) {
1904 if let Some(ref related) = self.related_files_cache {
1905 if self.related_files_selected_index < related.related.len().saturating_sub(1) {
1906 self.related_files_selected_index += 1;
1907 }
1908 }
1909 }
1910
1911 pub fn related_files_move_up(&mut self) {
1913 if self.related_files_selected_index > 0 {
1914 self.related_files_selected_index -= 1;
1915 }
1916 }
1917
1918 pub fn related_files_adjust_scroll(&mut self, visible_lines: usize) {
1920 if self.related_files_selected_index >= self.related_files_scroll_offset + visible_lines {
1921 self.related_files_scroll_offset =
1922 self.related_files_selected_index - visible_lines + 1;
1923 } else if self.related_files_selected_index < self.related_files_scroll_offset {
1924 self.related_files_scroll_offset = self.related_files_selected_index;
1925 }
1926 }
1927}
1928
1929impl Default for App {
1930 fn default() -> Self {
1931 Self::new()
1932 }
1933}
1934
1935#[cfg(test)]
1936mod tests {
1937 use super::*;
1938 use chrono::Local;
1939
1940 fn create_test_event(message: &str) -> GitEvent {
1941 GitEvent::commit(
1942 "abc1234".to_string(),
1943 message.to_string(),
1944 "author".to_string(),
1945 Local::now(),
1946 0,
1947 0,
1948 )
1949 }
1950
1951 fn create_test_repo_info() -> RepoInfo {
1952 RepoInfo {
1953 name: "test-repo".to_string(),
1954 branch: "main".to_string(),
1955 }
1956 }
1957
1958 #[test]
1959 fn test_app_new_initializes_with_should_quit_false() {
1960 let app = App::new();
1961 assert!(!app.should_quit);
1962 }
1963
1964 #[test]
1965 fn test_app_new_initializes_with_empty_events() {
1966 let app = App::new();
1967 assert!(app.events().is_empty());
1968 }
1969
1970 #[test]
1971 fn test_app_new_initializes_with_no_repo_info() {
1972 let app = App::new();
1973 assert!(app.repo_info.is_none());
1974 }
1975
1976 #[test]
1977 fn test_app_quit_sets_should_quit_to_true() {
1978 let mut app = App::new();
1979 app.quit();
1980 assert!(app.should_quit);
1981 }
1982
1983 #[test]
1984 fn test_app_default_is_same_as_new() {
1985 let app1 = App::new();
1986 let app2 = App::default();
1987 assert_eq!(app1.should_quit, app2.should_quit);
1988 }
1989
1990 #[test]
1991 fn test_app_load_sets_repo_info() {
1992 let mut app = App::new();
1993 let repo_info = create_test_repo_info();
1994 app.load(repo_info, vec![]);
1995 assert!(app.repo_info.is_some());
1996 assert_eq!(app.repo_name(), "test-repo");
1997 }
1998
1999 #[test]
2000 fn test_app_load_sets_events() {
2001 let mut app = App::new();
2002 let events = vec![create_test_event("commit 1"), create_test_event("commit 2")];
2003 app.load(create_test_repo_info(), events);
2004 assert_eq!(app.event_count(), 2);
2005 }
2006
2007 #[test]
2008 fn test_app_load_resets_selected_index() {
2009 let mut app = App::new();
2010 app.selected_index = 5;
2011 app.load(create_test_repo_info(), vec![create_test_event("commit")]);
2012 assert_eq!(app.selected_index, 0);
2013 }
2014
2015 #[test]
2016 fn test_app_move_down_increments_selected_index() {
2017 let mut app = App::new();
2018 app.load(
2019 create_test_repo_info(),
2020 vec![create_test_event("1"), create_test_event("2")],
2021 );
2022 app.move_down();
2023 assert_eq!(app.selected_index, 1);
2024 }
2025
2026 #[test]
2027 fn test_app_move_down_does_not_exceed_events_length() {
2028 let mut app = App::new();
2029 app.load(
2030 create_test_repo_info(),
2031 vec![create_test_event("1"), create_test_event("2")],
2032 );
2033 app.move_down();
2034 app.move_down();
2035 app.move_down();
2036 assert_eq!(app.selected_index, 1);
2037 }
2038
2039 #[test]
2040 fn test_app_move_up_decrements_selected_index() {
2041 let mut app = App::new();
2042 app.load(
2043 create_test_repo_info(),
2044 vec![create_test_event("1"), create_test_event("2")],
2045 );
2046 app.selected_index = 1;
2047 app.move_up();
2048 assert_eq!(app.selected_index, 0);
2049 }
2050
2051 #[test]
2052 fn test_app_move_up_does_not_go_below_zero() {
2053 let mut app = App::new();
2054 app.load(create_test_repo_info(), vec![create_test_event("1")]);
2055 app.move_up();
2056 assert_eq!(app.selected_index, 0);
2057 }
2058
2059 #[test]
2060 fn test_app_selected_event_returns_correct_event() {
2061 let mut app = App::new();
2062 app.load(
2063 create_test_repo_info(),
2064 vec![create_test_event("first"), create_test_event("second")],
2065 );
2066 app.selected_index = 1;
2067 let event = app.selected_event().unwrap();
2068 assert_eq!(event.message, "second");
2069 }
2070
2071 #[test]
2072 fn test_app_selected_event_returns_none_when_empty() {
2073 let app = App::new();
2074 assert!(app.selected_event().is_none());
2075 }
2076
2077 #[test]
2078 fn test_app_repo_name_returns_unknown_when_no_repo() {
2079 let app = App::new();
2080 assert_eq!(app.repo_name(), "unknown");
2081 }
2082
2083 #[test]
2084 fn test_app_branch_name_returns_unknown_when_no_repo() {
2085 let app = App::new();
2086 assert_eq!(app.branch_name(), "unknown");
2087 }
2088
2089 #[test]
2090 fn test_app_new_initializes_with_show_detail_false() {
2091 let app = App::new();
2092 assert!(!app.show_detail);
2093 }
2094
2095 #[test]
2096 fn test_app_open_detail_sets_show_detail_true() {
2097 let mut app = App::new();
2098 app.load(create_test_repo_info(), vec![create_test_event("commit")]);
2099 app.open_detail();
2100 assert!(app.show_detail);
2101 }
2102
2103 #[test]
2104 fn test_app_open_detail_does_nothing_when_no_events() {
2105 let mut app = App::new();
2106 app.open_detail();
2107 assert!(!app.show_detail);
2108 }
2109
2110 #[test]
2111 fn test_app_close_detail_sets_show_detail_false() {
2112 let mut app = App::new();
2113 app.load(create_test_repo_info(), vec![create_test_event("commit")]);
2114 app.open_detail();
2115 app.close_detail();
2116 assert!(!app.show_detail);
2117 }
2118
2119 #[test]
2120 fn test_app_new_initializes_with_normal_mode() {
2121 let app = App::new();
2122 assert_eq!(app.input_mode, InputMode::Normal);
2123 }
2124
2125 #[test]
2126 fn test_app_start_filter_sets_filter_mode() {
2127 let mut app = App::new();
2128 app.start_filter();
2129 assert_eq!(app.input_mode, InputMode::Filter);
2130 }
2131
2132 #[test]
2133 fn test_app_end_filter_sets_normal_mode() {
2134 let mut app = App::new();
2135 app.start_filter();
2136 app.end_filter();
2137 assert_eq!(app.input_mode, InputMode::Normal);
2138 }
2139
2140 #[test]
2141 fn test_app_filter_push_adds_character() {
2142 let mut app = App::new();
2143 app.load(create_test_repo_info(), vec![create_test_event("test")]);
2144 app.filter_push('a');
2145 assert_eq!(app.filter_text, "a");
2146 }
2147
2148 #[test]
2149 fn test_app_filter_pop_removes_character() {
2150 let mut app = App::new();
2151 app.load(create_test_repo_info(), vec![create_test_event("test")]);
2152 app.filter_push('a');
2153 app.filter_push('b');
2154 app.filter_pop();
2155 assert_eq!(app.filter_text, "a");
2156 }
2157
2158 #[test]
2159 fn test_app_filter_clear_removes_all() {
2160 let mut app = App::new();
2161 app.load(create_test_repo_info(), vec![create_test_event("test")]);
2162 app.filter_push('a');
2163 app.filter_push('b');
2164 app.filter_clear();
2165 assert_eq!(app.filter_text, "");
2166 }
2167
2168 #[test]
2169 fn test_app_filter_filters_by_message() {
2170 let mut app = App::new();
2171 app.load(
2172 create_test_repo_info(),
2173 vec![
2174 create_test_event("feat: add feature"),
2175 create_test_event("fix: bug fix"),
2176 create_test_event("feat: another feature"),
2177 ],
2178 );
2179 app.filter_push('f');
2180 app.filter_push('i');
2181 app.filter_push('x');
2182 assert_eq!(app.event_count(), 1);
2183 }
2184
2185 #[test]
2186 fn test_app_filter_is_case_insensitive() {
2187 let mut app = App::new();
2188 app.load(
2189 create_test_repo_info(),
2190 vec![create_test_event("FEAT: Add Feature")],
2191 );
2192 app.filter_push('f');
2193 app.filter_push('e');
2194 app.filter_push('a');
2195 app.filter_push('t');
2196 assert_eq!(app.event_count(), 1);
2197 }
2198
2199 #[test]
2200 fn test_app_filter_resets_selected_index() {
2201 let mut app = App::new();
2202 app.load(
2203 create_test_repo_info(),
2204 vec![
2205 create_test_event("first"),
2206 create_test_event("second"),
2207 create_test_event("third"),
2208 ],
2209 );
2210 app.selected_index = 2;
2211 app.filter_push('f');
2212 app.filter_push('i');
2213 app.filter_push('r');
2214 assert_eq!(app.selected_index, 0);
2216 }
2217
2218 #[test]
2219 fn test_app_start_branch_select_sets_mode() {
2220 let mut app = App::new();
2221 app.load(create_test_repo_info(), vec![]);
2222 app.start_branch_select(vec!["main".to_string(), "develop".to_string()]);
2223 assert_eq!(app.input_mode, InputMode::BranchSelect);
2224 }
2225
2226 #[test]
2227 fn test_app_start_branch_select_sets_branches() {
2228 let mut app = App::new();
2229 app.load(create_test_repo_info(), vec![]);
2230 app.start_branch_select(vec!["main".to_string(), "develop".to_string()]);
2231 assert_eq!(app.branches.len(), 2);
2232 }
2233
2234 #[test]
2235 fn test_app_start_branch_select_selects_current_branch() {
2236 let mut app = App::new();
2237 app.load(create_test_repo_info(), vec![]); app.start_branch_select(vec!["develop".to_string(), "main".to_string()]);
2239 assert_eq!(app.branch_selected_index, 1);
2240 }
2241
2242 #[test]
2243 fn test_app_end_branch_select_clears_branches() {
2244 let mut app = App::new();
2245 app.load(create_test_repo_info(), vec![]);
2246 app.start_branch_select(vec!["main".to_string()]);
2247 app.end_branch_select();
2248 assert!(app.branches.is_empty());
2249 assert_eq!(app.input_mode, InputMode::Normal);
2250 }
2251
2252 #[test]
2253 fn test_app_branch_move_down_increments_index() {
2254 let mut app = App::new();
2255 app.load(create_test_repo_info(), vec![]);
2256 app.start_branch_select(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
2257 app.branch_selected_index = 0;
2258 app.branch_move_down();
2259 assert_eq!(app.branch_selected_index, 1);
2260 }
2261
2262 #[test]
2263 fn test_app_branch_move_up_decrements_index() {
2264 let mut app = App::new();
2265 app.load(create_test_repo_info(), vec![]);
2266 app.start_branch_select(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
2267 app.branch_selected_index = 2;
2268 app.branch_move_up();
2269 assert_eq!(app.branch_selected_index, 1);
2270 }
2271
2272 #[test]
2273 fn test_app_selected_branch_returns_correct_branch() {
2274 let mut app = App::new();
2275 app.load(create_test_repo_info(), vec![]);
2276 app.start_branch_select(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
2277 app.branch_selected_index = 1;
2278 assert_eq!(app.selected_branch(), Some("b"));
2279 }
2280
2281 #[test]
2282 fn test_app_update_branch_changes_repo_info() {
2283 let mut app = App::new();
2284 app.load(create_test_repo_info(), vec![]);
2285 app.update_branch("new-branch".to_string());
2286 assert_eq!(app.branch_name(), "new-branch");
2287 }
2288
2289 fn create_test_file_status() -> FileStatus {
2290 use crate::git::FileStatusKind;
2291 FileStatus {
2292 path: "test.txt".to_string(),
2293 kind: FileStatusKind::Modified,
2294 }
2295 }
2296
2297 #[test]
2298 fn test_app_start_status_view_sets_mode() {
2299 let mut app = App::new();
2300 app.start_status_view(vec![create_test_file_status()]);
2301 assert_eq!(app.input_mode, InputMode::StatusView);
2302 }
2303
2304 #[test]
2305 fn test_app_start_status_view_sets_statuses() {
2306 let mut app = App::new();
2307 app.start_status_view(vec![create_test_file_status(), create_test_file_status()]);
2308 assert_eq!(app.file_statuses.len(), 2);
2309 }
2310
2311 #[test]
2312 fn test_app_end_status_view_clears_statuses() {
2313 let mut app = App::new();
2314 app.start_status_view(vec![create_test_file_status()]);
2315 app.end_status_view();
2316 assert!(app.file_statuses.is_empty());
2317 assert_eq!(app.input_mode, InputMode::Normal);
2318 }
2319
2320 #[test]
2321 fn test_app_status_move_down_increments_index() {
2322 let mut app = App::new();
2323 app.start_status_view(vec![create_test_file_status(), create_test_file_status()]);
2324 app.status_move_down();
2325 assert_eq!(app.status_selected_index, 1);
2326 }
2327
2328 #[test]
2329 fn test_app_status_move_up_decrements_index() {
2330 let mut app = App::new();
2331 app.start_status_view(vec![create_test_file_status(), create_test_file_status()]);
2332 app.status_selected_index = 1;
2333 app.status_move_up();
2334 assert_eq!(app.status_selected_index, 0);
2335 }
2336
2337 #[test]
2338 fn test_app_selected_file_status_returns_correct_status() {
2339 let mut app = App::new();
2340 app.start_status_view(vec![create_test_file_status()]);
2341 let status = app.selected_file_status().unwrap();
2342 assert_eq!(status.path, "test.txt");
2343 }
2344
2345 #[test]
2346 fn test_app_start_commit_input_sets_mode() {
2347 let mut app = App::new();
2348 app.start_commit_input();
2349 assert_eq!(app.input_mode, InputMode::CommitInput);
2350 }
2351
2352 #[test]
2353 fn test_app_commit_message_push_adds_character() {
2354 let mut app = App::new();
2355 app.commit_message_push('t');
2356 app.commit_message_push('e');
2357 app.commit_message_push('s');
2358 app.commit_message_push('t');
2359 assert_eq!(app.commit_message, "test");
2360 }
2361
2362 #[test]
2363 fn test_app_commit_message_pop_removes_character() {
2364 let mut app = App::new();
2365 app.commit_message_push('a');
2366 app.commit_message_push('b');
2367 app.commit_message_pop();
2368 assert_eq!(app.commit_message, "a");
2369 }
2370
2371 #[test]
2372 fn test_app_set_status_message_sets_message() {
2373 let mut app = App::new();
2374 app.set_status_message("Success".to_string());
2375 assert_eq!(app.status_message, Some("Success".to_string()));
2376 }
2377
2378 #[test]
2379 fn test_app_new_initializes_with_is_loading_false() {
2380 let app = App::new();
2381 assert!(!app.is_loading);
2382 }
2383
2384 #[test]
2385 fn test_app_start_loading_sets_is_loading_true() {
2386 let mut app = App::new();
2387 app.start_loading();
2388 assert!(app.is_loading);
2389 }
2390
2391 #[test]
2392 fn test_app_finish_loading_sets_is_loading_false() {
2393 let mut app = App::new();
2394 app.start_loading();
2395 app.finish_loading();
2396 assert!(!app.is_loading);
2397 }
2398
2399 #[test]
2400 fn test_app_append_events_adds_to_existing() {
2401 let mut app = App::new();
2402 app.load(create_test_repo_info(), vec![create_test_event("first")]);
2403 assert_eq!(app.event_count(), 1);
2404
2405 app.append_events(vec![
2406 create_test_event("second"),
2407 create_test_event("third"),
2408 ]);
2409 assert_eq!(app.event_count(), 3);
2410 }
2411
2412 #[test]
2413 fn test_app_append_events_updates_filtered_indices() {
2414 let mut app = App::new();
2415 app.load(create_test_repo_info(), vec![create_test_event("first")]);
2416 app.append_events(vec![create_test_event("second")]);
2417
2418 assert_eq!(app.events().len(), 2);
2420 }
2421
2422 #[test]
2425 fn test_app_move_to_top_sets_selected_index_to_zero() {
2426 let mut app = App::new();
2427 app.load(
2428 create_test_repo_info(),
2429 vec![
2430 create_test_event("1"),
2431 create_test_event("2"),
2432 create_test_event("3"),
2433 ],
2434 );
2435 app.selected_index = 2;
2436 app.move_to_top();
2437 assert_eq!(app.selected_index, 0);
2438 }
2439
2440 #[test]
2441 fn test_app_move_to_top_works_when_already_at_top() {
2442 let mut app = App::new();
2443 app.load(create_test_repo_info(), vec![create_test_event("1")]);
2444 app.selected_index = 0;
2445 app.move_to_top();
2446 assert_eq!(app.selected_index, 0);
2447 }
2448
2449 #[test]
2450 fn test_app_move_to_bottom_sets_selected_index_to_last() {
2451 let mut app = App::new();
2452 app.load(
2453 create_test_repo_info(),
2454 vec![
2455 create_test_event("1"),
2456 create_test_event("2"),
2457 create_test_event("3"),
2458 ],
2459 );
2460 app.selected_index = 0;
2461 app.move_to_bottom();
2462 assert_eq!(app.selected_index, 2);
2463 }
2464
2465 #[test]
2466 fn test_app_move_to_bottom_does_nothing_when_empty() {
2467 let mut app = App::new();
2468 app.move_to_bottom();
2469 assert_eq!(app.selected_index, 0);
2470 }
2471
2472 #[test]
2473 fn test_app_page_down_moves_by_page_size() {
2474 let mut app = App::new();
2475 let events: Vec<_> = (0..20)
2476 .map(|i| create_test_event(&format!("{}", i)))
2477 .collect();
2478 app.load(create_test_repo_info(), events);
2479 app.selected_index = 0;
2480 app.page_down(10);
2481 assert_eq!(app.selected_index, 10);
2482 }
2483
2484 #[test]
2485 fn test_app_page_down_stops_at_last_index() {
2486 let mut app = App::new();
2487 let events: Vec<_> = (0..5)
2488 .map(|i| create_test_event(&format!("{}", i)))
2489 .collect();
2490 app.load(create_test_repo_info(), events);
2491 app.selected_index = 0;
2492 app.page_down(10);
2493 assert_eq!(app.selected_index, 4);
2494 }
2495
2496 #[test]
2497 fn test_app_page_down_does_nothing_when_empty() {
2498 let mut app = App::new();
2499 app.page_down(10);
2500 assert_eq!(app.selected_index, 0);
2501 }
2502
2503 #[test]
2504 fn test_app_page_up_moves_by_page_size() {
2505 let mut app = App::new();
2506 let events: Vec<_> = (0..20)
2507 .map(|i| create_test_event(&format!("{}", i)))
2508 .collect();
2509 app.load(create_test_repo_info(), events);
2510 app.selected_index = 15;
2511 app.page_up(10);
2512 assert_eq!(app.selected_index, 5);
2513 }
2514
2515 #[test]
2516 fn test_app_page_up_stops_at_zero() {
2517 let mut app = App::new();
2518 let events: Vec<_> = (0..5)
2519 .map(|i| create_test_event(&format!("{}", i)))
2520 .collect();
2521 app.load(create_test_repo_info(), events);
2522 app.selected_index = 3;
2523 app.page_up(10);
2524 assert_eq!(app.selected_index, 0);
2525 }
2526
2527 #[test]
2530 fn test_app_new_initializes_with_show_help_false() {
2531 let app = App::new();
2532 assert!(!app.show_help);
2533 }
2534
2535 #[test]
2536 fn test_app_toggle_help_sets_show_help_true() {
2537 let mut app = App::new();
2538 app.toggle_help();
2539 assert!(app.show_help);
2540 }
2541
2542 #[test]
2543 fn test_app_toggle_help_toggles_show_help() {
2544 let mut app = App::new();
2545 app.toggle_help();
2546 assert!(app.show_help);
2547 app.toggle_help();
2548 assert!(!app.show_help);
2549 }
2550
2551 #[test]
2552 fn test_app_close_help_sets_show_help_false() {
2553 let mut app = App::new();
2554 app.show_help = true;
2555 app.close_help();
2556 assert!(!app.show_help);
2557 }
2558
2559 #[test]
2560 fn test_app_close_help_does_nothing_when_already_closed() {
2561 let mut app = App::new();
2562 app.close_help();
2563 assert!(!app.show_help);
2564 }
2565
2566 #[test]
2569 fn test_app_jump_to_head_moves_to_head_event() {
2570 let mut app = App::new();
2571 app.load(
2572 create_test_repo_info(),
2573 vec![
2574 create_test_event("1"),
2575 create_test_event("2"),
2576 create_test_event("3"),
2577 ],
2578 );
2579 app.set_head_hash("abc1234".to_string());
2581 app.selected_index = 2;
2582 app.jump_to_head();
2583 assert_eq!(app.selected_index, 0);
2584 }
2585
2586 #[test]
2587 fn test_app_jump_to_head_works_when_already_at_head() {
2588 let mut app = App::new();
2589 app.load(create_test_repo_info(), vec![create_test_event("1")]);
2590 app.set_head_hash("abc1234".to_string());
2591 app.selected_index = 0;
2592 app.jump_to_head();
2593 assert_eq!(app.selected_index, 0);
2594 }
2595
2596 #[test]
2597 fn test_app_jump_to_head_finds_head_in_filtered_results() {
2598 let mut app = App::new();
2599 app.load(
2600 create_test_repo_info(),
2601 vec![
2602 create_test_event("HEAD commit"),
2603 create_test_event("other"),
2604 create_test_event("HEAD related"),
2605 ],
2606 );
2607 app.set_head_hash("abc1234".to_string());
2609 app.filter_push('H');
2611 app.filter_push('E');
2612 app.filter_push('A');
2613 app.filter_push('D');
2614 app.selected_index = 1;
2616 app.jump_to_head();
2618 assert_eq!(app.selected_index, 0);
2619 }
2620
2621 #[test]
2622 fn test_app_jump_to_head_does_nothing_when_head_not_in_filter() {
2623 let mut app = App::new();
2624 app.load(
2625 create_test_repo_info(),
2626 vec![
2627 create_test_event("first"),
2628 create_test_event("match me"),
2629 create_test_event("another match me"),
2630 ],
2631 );
2632 app.set_head_hash("abc1234".to_string());
2634 app.filter_push('m');
2636 app.filter_push('a');
2637 app.filter_push('t');
2638 app.filter_push('c');
2639 app.filter_push('h');
2640 app.selected_index = 1;
2641 app.jump_to_head();
2643 assert_eq!(app.selected_index, 1);
2644 }
2645
2646 #[test]
2647 fn test_app_jump_to_head_without_head_hash_goes_to_first() {
2648 let mut app = App::new();
2649 app.load(
2650 create_test_repo_info(),
2651 vec![
2652 create_test_event("1"),
2653 create_test_event("2"),
2654 create_test_event("3"),
2655 ],
2656 );
2657 app.selected_index = 2;
2659 app.jump_to_head();
2660 assert_eq!(app.selected_index, 0);
2661 }
2662
2663 #[test]
2664 fn test_app_set_head_hash_truncates_long_hash() {
2665 let mut app = App::new();
2666 app.set_head_hash("abcdef1234567890".to_string());
2667 assert_eq!(app.head_hash, Some("abcdef1".to_string()));
2668 }
2669
2670 #[test]
2671 fn test_app_set_head_hash_keeps_short_hash() {
2672 let mut app = App::new();
2673 app.set_head_hash("abc1234".to_string());
2674 assert_eq!(app.head_hash, Some("abc1234".to_string()));
2675 }
2676
2677 #[test]
2680 fn test_app_new_initializes_with_normal_view_mode() {
2681 let app = App::new();
2682 assert_eq!(app.view_mode, ViewMode::Normal);
2683 }
2684
2685 #[test]
2686 fn test_app_cycle_view_mode_normal_to_compact() {
2687 let mut app = App::new();
2688 app.cycle_view_mode();
2689 assert_eq!(app.view_mode, ViewMode::Compact);
2690 }
2691
2692 #[test]
2693 fn test_app_cycle_view_mode_toggle() {
2694 let mut app = App::new();
2695 assert_eq!(app.view_mode, ViewMode::Normal);
2696 app.cycle_view_mode();
2697 assert_eq!(app.view_mode, ViewMode::Compact);
2698 app.cycle_view_mode();
2699 assert_eq!(app.view_mode, ViewMode::Normal);
2700 }
2701
2702 fn create_labeled_event(message: &str, labels: Vec<&str>) -> GitEvent {
2705 let mut event = GitEvent::commit(
2706 "abc1234".to_string(),
2707 message.to_string(),
2708 "author".to_string(),
2709 Local::now(),
2710 0,
2711 0,
2712 );
2713 event.branch_labels = labels.into_iter().map(|s| s.to_string()).collect();
2714 event
2715 }
2716
2717 #[test]
2718 fn test_app_jump_to_next_label_finds_label() {
2719 let mut app = App::new();
2720 app.load(
2721 create_test_repo_info(),
2722 vec![
2723 create_test_event("no label 1"),
2724 create_test_event("no label 2"),
2725 create_labeled_event("with label", vec!["main"]),
2726 create_test_event("no label 3"),
2727 ],
2728 );
2729 app.selected_index = 0;
2730 app.jump_to_next_label();
2731 assert_eq!(app.selected_index, 2);
2732 }
2733
2734 #[test]
2735 fn test_app_jump_to_next_label_no_label_found() {
2736 let mut app = App::new();
2737 app.load(
2738 create_test_repo_info(),
2739 vec![
2740 create_test_event("no label 1"),
2741 create_test_event("no label 2"),
2742 create_test_event("no label 3"),
2743 ],
2744 );
2745 app.selected_index = 0;
2746 app.jump_to_next_label();
2747 assert_eq!(app.selected_index, 0); }
2749
2750 #[test]
2751 fn test_app_jump_to_prev_label_finds_label() {
2752 let mut app = App::new();
2753 app.load(
2754 create_test_repo_info(),
2755 vec![
2756 create_labeled_event("with label", vec!["main"]),
2757 create_test_event("no label 1"),
2758 create_test_event("no label 2"),
2759 create_test_event("no label 3"),
2760 ],
2761 );
2762 app.selected_index = 3;
2763 app.jump_to_prev_label();
2764 assert_eq!(app.selected_index, 0);
2765 }
2766
2767 #[test]
2768 fn test_app_jump_to_prev_label_no_label_found() {
2769 let mut app = App::new();
2770 app.load(
2771 create_test_repo_info(),
2772 vec![
2773 create_test_event("no label 1"),
2774 create_test_event("no label 2"),
2775 create_test_event("no label 3"),
2776 ],
2777 );
2778 app.selected_index = 2;
2779 app.jump_to_prev_label();
2780 assert_eq!(app.selected_index, 2); }
2782
2783 #[test]
2784 fn test_app_jump_to_prev_label_at_start() {
2785 let mut app = App::new();
2786 app.load(
2787 create_test_repo_info(),
2788 vec![
2789 create_labeled_event("with label", vec!["main"]),
2790 create_test_event("no label"),
2791 ],
2792 );
2793 app.selected_index = 0;
2794 app.jump_to_prev_label();
2795 assert_eq!(app.selected_index, 0); }
2797
2798 #[test]
2801 fn test_commit_type_prefix_feat() {
2802 assert_eq!(CommitType::Feat.prefix(), "feat: ");
2803 }
2804
2805 #[test]
2806 fn test_commit_type_prefix_fix() {
2807 assert_eq!(CommitType::Fix.prefix(), "fix: ");
2808 }
2809
2810 #[test]
2811 fn test_commit_type_prefix_all_types() {
2812 assert_eq!(CommitType::Docs.prefix(), "docs: ");
2813 assert_eq!(CommitType::Style.prefix(), "style: ");
2814 assert_eq!(CommitType::Refactor.prefix(), "refactor: ");
2815 assert_eq!(CommitType::Test.prefix(), "test: ");
2816 assert_eq!(CommitType::Chore.prefix(), "chore: ");
2817 assert_eq!(CommitType::Perf.prefix(), "perf: ");
2818 }
2819
2820 #[test]
2821 fn test_commit_type_key_feat() {
2822 assert_eq!(CommitType::Feat.key(), 'f');
2823 }
2824
2825 #[test]
2826 fn test_commit_type_key_fix() {
2827 assert_eq!(CommitType::Fix.key(), 'x');
2828 }
2829
2830 #[test]
2831 fn test_commit_type_key_all_types() {
2832 assert_eq!(CommitType::Docs.key(), 'd');
2833 assert_eq!(CommitType::Style.key(), 's');
2834 assert_eq!(CommitType::Refactor.key(), 'r');
2835 assert_eq!(CommitType::Test.key(), 't');
2836 assert_eq!(CommitType::Chore.key(), 'c');
2837 assert_eq!(CommitType::Perf.key(), 'p');
2838 }
2839
2840 #[test]
2841 fn test_commit_type_all_returns_8_types() {
2842 assert_eq!(CommitType::all().len(), 8);
2843 }
2844
2845 #[test]
2846 fn test_select_commit_type_sets_prefix() {
2847 let mut app = App::new();
2848 app.select_commit_type(CommitType::Feat);
2849 assert_eq!(app.commit_message, "feat: ");
2850 assert_eq!(app.commit_type, Some(CommitType::Feat));
2851 }
2852
2853 #[test]
2854 fn test_select_commit_type_fix() {
2855 let mut app = App::new();
2856 app.select_commit_type(CommitType::Fix);
2857 assert_eq!(app.commit_message, "fix: ");
2858 assert_eq!(app.commit_type, Some(CommitType::Fix));
2859 }
2860
2861 #[test]
2862 fn test_commit_message_pop_clears_type_when_prefix_removed() {
2863 let mut app = App::new();
2864 app.select_commit_type(CommitType::Feat);
2865 for _ in 0..6 {
2867 app.commit_message_pop();
2868 }
2869 assert_eq!(app.commit_type, None);
2870 }
2871
2872 #[test]
2873 fn test_commit_message_clear_clears_type() {
2874 let mut app = App::new();
2875 app.select_commit_type(CommitType::Feat);
2876 app.commit_message_clear();
2877 assert_eq!(app.commit_type, None);
2878 assert_eq!(app.commit_message, "");
2879 }
2880
2881 #[test]
2882 fn test_start_commit_input_clears_type() {
2883 let mut app = App::new();
2884 app.commit_type = Some(CommitType::Feat);
2885 app.start_commit_input();
2886 assert_eq!(app.commit_type, None);
2887 }
2888
2889 #[test]
2890 fn test_end_commit_input_clears_type() {
2891 let mut app = App::new();
2892 app.start_commit_input();
2893 app.select_commit_type(CommitType::Feat);
2894 app.end_commit_input();
2895 assert_eq!(app.commit_type, None);
2896 }
2897
2898 #[test]
2901 fn test_app_new_initializes_with_empty_suggestions() {
2902 let app = App::new();
2903 assert!(app.commit_suggestions.is_empty());
2904 assert_eq!(app.suggestion_selected_index, 0);
2905 }
2906
2907 #[test]
2908 fn test_suggestion_move_down_increments_index() {
2909 let mut app = App::new();
2910 app.commit_suggestions = vec![
2911 crate::suggestion::CommitSuggestion {
2912 commit_type: CommitType::Feat,
2913 scope: None,
2914 message: "test1".to_string(),
2915 confidence: 0.8,
2916 },
2917 crate::suggestion::CommitSuggestion {
2918 commit_type: CommitType::Fix,
2919 scope: None,
2920 message: "test2".to_string(),
2921 confidence: 0.6,
2922 },
2923 ];
2924 app.suggestion_move_down();
2925 assert_eq!(app.suggestion_selected_index, 1);
2926 }
2927
2928 #[test]
2929 fn test_suggestion_move_up_decrements_index() {
2930 let mut app = App::new();
2931 app.commit_suggestions = vec![
2932 crate::suggestion::CommitSuggestion {
2933 commit_type: CommitType::Feat,
2934 scope: None,
2935 message: "test1".to_string(),
2936 confidence: 0.8,
2937 },
2938 crate::suggestion::CommitSuggestion {
2939 commit_type: CommitType::Fix,
2940 scope: None,
2941 message: "test2".to_string(),
2942 confidence: 0.6,
2943 },
2944 ];
2945 app.suggestion_selected_index = 1;
2946 app.suggestion_move_up();
2947 assert_eq!(app.suggestion_selected_index, 0);
2948 }
2949
2950 #[test]
2951 fn test_apply_suggestion_sets_message_and_type() {
2952 let mut app = App::new();
2953 app.commit_suggestions = vec![crate::suggestion::CommitSuggestion {
2954 commit_type: CommitType::Feat,
2955 scope: Some("auth".to_string()),
2956 message: "add login".to_string(),
2957 confidence: 0.8,
2958 }];
2959 app.apply_suggestion(0);
2960 assert_eq!(app.commit_type, Some(CommitType::Feat));
2961 assert_eq!(app.commit_message, "feat(auth): add login");
2962 }
2963
2964 #[test]
2965 fn test_apply_suggestion_invalid_index_does_nothing() {
2966 let mut app = App::new();
2967 app.apply_suggestion(0);
2968 assert_eq!(app.commit_type, None);
2969 assert!(app.commit_message.is_empty());
2970 }
2971
2972 #[test]
2975 fn test_layout_mode_default_is_single() {
2976 let app = App::new();
2977 assert_eq!(app.layout_mode, LayoutMode::Single);
2978 }
2979
2980 #[test]
2981 fn test_toggle_layout_mode_single_to_split() {
2982 let mut app = App::new();
2983 assert_eq!(app.layout_mode, LayoutMode::Single);
2984 app.toggle_layout_mode();
2985 assert_eq!(app.layout_mode, LayoutMode::SplitPanel);
2986 }
2987
2988 #[test]
2989 fn test_toggle_layout_mode_split_to_single() {
2990 let mut app = App::new();
2991 app.layout_mode = LayoutMode::SplitPanel;
2992 app.toggle_layout_mode();
2993 assert_eq!(app.layout_mode, LayoutMode::Single);
2994 }
2995
2996 #[test]
2997 fn test_effective_layout_mode_single_when_narrow() {
2998 let mut app = App::new();
2999 app.layout_mode = LayoutMode::SplitPanel;
3000 assert_eq!(app.effective_layout_mode(119), LayoutMode::Single);
3002 assert_eq!(app.effective_layout_mode(100), LayoutMode::Single);
3003 assert_eq!(app.effective_layout_mode(80), LayoutMode::Single);
3004 }
3005
3006 #[test]
3007 fn test_effective_layout_mode_split_when_wide() {
3008 let mut app = App::new();
3009 app.layout_mode = LayoutMode::SplitPanel;
3010 assert_eq!(app.effective_layout_mode(120), LayoutMode::SplitPanel);
3012 assert_eq!(app.effective_layout_mode(150), LayoutMode::SplitPanel);
3013 assert_eq!(app.effective_layout_mode(200), LayoutMode::SplitPanel);
3014 }
3015
3016 #[test]
3017 fn test_effective_layout_mode_single_when_mode_is_single() {
3018 let app = App::new();
3019 assert_eq!(app.effective_layout_mode(200), LayoutMode::Single);
3021 }
3022
3023 #[test]
3024 fn test_left_panel_ratio_default() {
3025 let app = App::new();
3026 assert_eq!(app.left_panel_ratio, 65);
3027 }
3028
3029 #[test]
3030 fn test_adjust_panel_ratio_increase() {
3031 let mut app = App::new();
3032 app.adjust_panel_ratio(5);
3033 assert_eq!(app.left_panel_ratio, 70);
3034 }
3035
3036 #[test]
3037 fn test_adjust_panel_ratio_decrease() {
3038 let mut app = App::new();
3039 app.adjust_panel_ratio(-5);
3040 assert_eq!(app.left_panel_ratio, 60);
3041 }
3042
3043 #[test]
3044 fn test_adjust_panel_ratio_max_clamp() {
3045 let mut app = App::new();
3046 app.left_panel_ratio = 80;
3047 app.adjust_panel_ratio(10); assert_eq!(app.left_panel_ratio, 85);
3049 }
3050
3051 #[test]
3052 fn test_adjust_panel_ratio_min_clamp() {
3053 let mut app = App::new();
3054 app.left_panel_ratio = 45;
3055 app.adjust_panel_ratio(-10); assert_eq!(app.left_panel_ratio, 40);
3057 }
3058
3059 #[test]
3060 fn test_update_detail_for_split_panel_clears_cache() {
3061 let mut app = App::new();
3062 app.detail_scroll_offset = 5;
3063 app.detail_selected_file = 3;
3064 app.update_detail_for_split_panel();
3065 assert_eq!(app.detail_scroll_offset, 0);
3066 assert_eq!(app.detail_selected_file, 0);
3067 assert!(app.detail_diff_cache.is_none());
3068 }
3069}