1use std::collections::HashSet;
2use std::path::PathBuf;
3
4use gitkraft_core::*;
5use iced::{Color, Point, Task};
6
7use crate::message::Message;
8use crate::theme::ThemeColors;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum DragTarget {
15 SidebarRight,
17 CommitLogRight,
19 DiffFileListRight,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum DragTargetH {
27 StagingTop,
29}
30
31#[derive(Debug, Clone)]
33pub enum ContextMenu {
34 Branch {
36 name: String,
37 is_current: bool,
38 local_index: usize,
41 },
42 RemoteBranch { name: String },
44 Commit { index: usize, oid: String },
46 Stash { index: usize },
48 UnstagedFile { path: String },
50 StagedFile { path: String },
52 CommitFile { oid: String, file_path: String },
54}
55
56pub struct RepoTab {
60 pub repo_path: Option<PathBuf>,
63 pub repo_info: Option<RepoInfo>,
65
66 pub branches: Vec<BranchInfo>,
69 pub current_branch: Option<String>,
71
72 pub commits: Vec<CommitInfo>,
75 pub selected_commit: Option<usize>,
77 pub anchor_commit_index: Option<usize>,
79 pub selected_commits: Vec<usize>,
81 pub graph_rows: Vec<gitkraft_core::GraphRow>,
83
84 pub unstaged_changes: Vec<DiffInfo>,
87 pub staged_changes: Vec<DiffInfo>,
89 pub commit_files: Vec<gitkraft_core::DiffFileEntry>,
91 pub selected_commit_oid: Option<String>,
93 pub selected_file_index: Option<usize>,
95 pub is_loading_file_diff: bool,
97 pub anchor_file_index: Option<usize>,
100 pub selected_commit_file_indices: Vec<usize>,
103 pub multi_file_diffs: Vec<gitkraft_core::DiffInfo>,
105 pub commit_range_diffs: Vec<gitkraft_core::DiffInfo>,
108 pub selected_diff: Option<DiffInfo>,
110 pub commit_message: String,
112
113 pub stashes: Vec<StashEntry>,
116
117 pub remotes: Vec<RemoteInfo>,
120
121 pub show_commit_detail: bool,
124 pub new_branch_name: String,
126 pub show_branch_create: bool,
128 pub local_branches_expanded: bool,
130 pub remote_branches_expanded: bool,
132 pub stash_message: String,
134
135 pub selected_unstaged: std::collections::HashSet<String>,
137 pub selected_staged: std::collections::HashSet<String>,
139
140 pub pending_discard: Option<String>,
142
143 pub status_message: Option<String>,
146 pub error_message: Option<String>,
148 pub is_loading: bool,
150 pub context_menu_pos: (f32, f32),
153
154 pub context_menu: Option<ContextMenu>,
156 pub rename_branch_target: Option<String>,
158 pub rename_branch_input: String,
160
161 pub create_tag_target_oid: Option<String>,
163 pub create_tag_annotated: bool,
165 pub create_tag_name: String,
167 pub create_tag_message: String,
169 pub create_branch_at_oid: Option<String>,
171
172 pub commit_scroll_offset: f32,
176
177 pub diff_scroll_offset: f32,
179 pub commit_display: Vec<(String, String, String)>,
183
184 pub has_more_commits: bool,
186 pub is_loading_more_commits: bool,
188}
189
190impl RepoTab {
191 pub fn new_empty() -> Self {
193 Self {
194 repo_path: None,
195 repo_info: None,
196 branches: Vec::new(),
197 current_branch: None,
198 commits: Vec::new(),
199 selected_commit: None,
200 anchor_commit_index: None,
201 selected_commits: Vec::new(),
202 graph_rows: Vec::new(),
203 unstaged_changes: Vec::new(),
204 staged_changes: Vec::new(),
205 commit_files: Vec::new(),
206 selected_commit_oid: None,
207 selected_file_index: None,
208 is_loading_file_diff: false,
209 anchor_file_index: None,
210 selected_commit_file_indices: Vec::new(),
211 multi_file_diffs: Vec::new(),
212 commit_range_diffs: Vec::new(),
213 selected_diff: None,
214 commit_message: String::new(),
215 stashes: Vec::new(),
216 remotes: Vec::new(),
217 show_commit_detail: false,
218 new_branch_name: String::new(),
219 show_branch_create: false,
220 local_branches_expanded: true,
221 remote_branches_expanded: true,
222 stash_message: String::new(),
223 selected_unstaged: std::collections::HashSet::new(),
224 selected_staged: std::collections::HashSet::new(),
225 pending_discard: None,
226 status_message: None,
227 error_message: None,
228 is_loading: false,
229 context_menu: None,
230 context_menu_pos: (0.0, 0.0),
231 rename_branch_target: None,
232 rename_branch_input: String::new(),
233 create_tag_target_oid: None,
234 create_tag_annotated: false,
235 create_tag_name: String::new(),
236 create_tag_message: String::new(),
237 create_branch_at_oid: None,
238 commit_scroll_offset: 0.0,
239 diff_scroll_offset: 0.0,
240 commit_display: Vec::new(),
241 has_more_commits: true,
242 is_loading_more_commits: false,
243 }
244 }
245
246 pub fn has_repo(&self) -> bool {
248 self.repo_path.is_some()
249 }
250
251 pub fn display_name(&self) -> &str {
253 self.repo_path
254 .as_ref()
255 .and_then(|p| p.file_name())
256 .and_then(|n| n.to_str())
257 .unwrap_or("New Tab")
258 }
259
260 pub fn apply_payload(
262 &mut self,
263 payload: crate::message::RepoPayload,
264 path: std::path::PathBuf,
265 ) {
266 self.current_branch = payload.info.head_branch.clone();
267 self.repo_path = Some(path);
268 self.repo_info = Some(payload.info);
269 self.branches = payload.branches;
270 self.commits = payload.commits;
271 self.graph_rows = payload.graph_rows;
272 self.unstaged_changes = payload.unstaged;
273 self.staged_changes = payload.staged;
274 self.stashes = payload.stashes;
275 self.remotes = payload.remotes;
276
277 self.selected_commit = None;
279 self.anchor_commit_index = None;
280 self.selected_commits.clear();
281 self.selected_diff = None;
282 self.commit_files.clear();
283 self.selected_commit_oid = None;
284 self.selected_file_index = None;
285 self.is_loading_file_diff = false;
286 self.commit_message.clear();
287 self.error_message = None;
288 self.status_message = Some("Repository loaded.".into());
289 self.commit_scroll_offset = 0.0;
290 self.diff_scroll_offset = 0.0;
291 self.has_more_commits = true;
292 self.is_loading_more_commits = false;
293 self.selected_unstaged.clear();
294 self.selected_staged.clear();
295 self.anchor_file_index = None;
296 self.selected_commit_file_indices.clear();
297 self.multi_file_diffs.clear();
298 self.commit_range_diffs.clear();
299 }
300}
301
302pub struct GitKraft {
306 pub tabs: Vec<RepoTab>,
309 pub active_tab: usize,
311
312 pub sidebar_expanded: bool,
315
316 pub sidebar_width: f32,
319 pub commit_log_width: f32,
321 pub staging_height: f32,
323 pub diff_file_list_width: f32,
325
326 pub ui_scale: f32,
328
329 pub dragging: Option<DragTarget>,
332 pub dragging_h: Option<DragTargetH>,
334 pub drag_start_x: f32,
336 pub drag_start_y: f32,
338 pub drag_initialized: bool,
342 pub drag_initialized_h: bool,
344
345 pub cursor_pos: Point,
350
351 pub current_theme_index: usize,
354
355 pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
358
359 pub search_visible: bool,
362 pub search_query: String,
364 pub search_results: Vec<gitkraft_core::CommitInfo>,
366 pub search_selected: Option<usize>,
368
369 pub search_diff_files: Vec<gitkraft_core::DiffFileEntry>,
371 pub search_diff_selected: HashSet<usize>,
373 pub search_diff_content: Vec<gitkraft_core::DiffInfo>,
375 pub search_diff_oid: Option<String>,
377
378 pub editor: gitkraft_core::Editor,
380
381 pub keyboard_modifiers: iced::keyboard::Modifiers,
383}
384
385impl Default for GitKraft {
386 fn default() -> Self {
387 Self::new()
388 }
389}
390
391impl GitKraft {
392 fn from_settings(settings: gitkraft_core::AppSettings) -> Self {
398 let current_theme_index = settings
399 .theme_name
400 .as_deref()
401 .map(gitkraft_core::theme_index_by_name)
402 .unwrap_or(0);
403
404 let recent_repos = settings.recent_repos;
405
406 let (
407 sidebar_width,
408 commit_log_width,
409 staging_height,
410 diff_file_list_width,
411 sidebar_expanded,
412 ui_scale,
413 ) = if let Some(ref layout) = settings.layout {
414 (
415 layout.sidebar_width.unwrap_or(220.0),
416 layout.commit_log_width.unwrap_or(500.0),
417 layout.staging_height.unwrap_or(200.0),
418 layout.diff_file_list_width.unwrap_or(180.0),
419 layout.sidebar_expanded.unwrap_or(true),
420 layout.ui_scale.unwrap_or(1.0),
421 )
422 } else {
423 (220.0, 500.0, 200.0, 180.0, true, 1.0)
424 };
425
426 Self {
427 tabs: vec![RepoTab::new_empty()],
428 active_tab: 0,
429
430 sidebar_expanded,
431
432 sidebar_width,
433 commit_log_width,
434 staging_height,
435 diff_file_list_width,
436
437 ui_scale,
438
439 dragging: None,
440 dragging_h: None,
441 drag_start_x: 0.0,
442 drag_start_y: 0.0,
443 drag_initialized: false,
444 drag_initialized_h: false,
445 cursor_pos: Point::ORIGIN,
446
447 current_theme_index,
448
449 recent_repos,
450
451 search_visible: false,
452 search_query: String::new(),
453 search_results: Vec::new(),
454 search_selected: None,
455 search_diff_files: Vec::new(),
456 search_diff_selected: HashSet::new(),
457 search_diff_content: Vec::new(),
458 search_diff_oid: None,
459
460 keyboard_modifiers: iced::keyboard::Modifiers::default(),
461
462 editor: settings
463 .editor_name
464 .as_deref()
465 .map(|name| {
466 gitkraft_core::EDITOR_NAMES
468 .iter()
469 .position(|n| n.eq_ignore_ascii_case(name))
470 .map(gitkraft_core::Editor::from_index)
471 .unwrap_or_else(|| {
472 if name.eq_ignore_ascii_case("none") {
473 gitkraft_core::Editor::None
474 } else {
475 gitkraft_core::Editor::Custom(name.to_string())
476 }
477 })
478 })
479 .unwrap_or_else(detect_system_editor),
480 }
481 }
482
483 pub fn new() -> Self {
489 Self::from_settings(
490 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default(),
491 )
492 }
493
494 pub fn new_with_session_paths() -> (Self, Vec<PathBuf>) {
500 let settings =
501 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default();
502 let open_tabs = settings.open_tabs.clone();
503 let active_tab_index = settings.active_tab_index;
504
505 let mut state = Self::from_settings(settings);
506
507 if !open_tabs.is_empty() {
508 state.tabs = open_tabs
509 .iter()
510 .map(|path| {
511 let mut tab = RepoTab::new_empty();
512 tab.repo_path = Some(path.clone());
515 if path.exists() {
516 tab.is_loading = true;
517 tab.status_message = Some(format!(
518 "Loading {}…",
519 path.file_name().unwrap_or_default().to_string_lossy()
520 ));
521 } else {
522 tab.error_message =
523 Some(format!("Repository not found: {}", path.display()));
524 }
525 tab
526 })
527 .collect();
528 state.active_tab = active_tab_index.min(state.tabs.len().saturating_sub(1));
529 }
530
531 (state, open_tabs)
532 }
533
534 pub fn open_tab_paths(&self) -> Vec<PathBuf> {
537 self.tabs
538 .iter()
539 .filter(|t| t.repo_info.is_some())
540 .filter_map(|t| t.repo_path.clone())
541 .collect()
542 }
543
544 pub fn active_tab(&self) -> &RepoTab {
546 &self.tabs[self.active_tab]
547 }
548
549 pub fn active_tab_mut(&mut self) -> &mut RepoTab {
551 &mut self.tabs[self.active_tab]
552 }
553
554 pub fn has_repo(&self) -> bool {
556 self.active_tab().has_repo()
557 }
558
559 pub fn repo_display_name(&self) -> &str {
561 self.active_tab().display_name()
562 }
563
564 pub fn colors(&self) -> ThemeColors {
571 ThemeColors::from_core(&gitkraft_core::theme_by_index(self.current_theme_index))
572 }
573
574 pub fn iced_theme(&self) -> iced::Theme {
583 let core = gitkraft_core::theme_by_index(self.current_theme_index);
584 let name = self.current_theme_name().to_string();
585
586 let palette = iced::theme::Palette {
587 background: rgb_to_iced(core.background),
588 text: rgb_to_iced(core.text_primary),
589 primary: rgb_to_iced(core.accent),
590 success: rgb_to_iced(core.success),
591 warning: rgb_to_iced(core.warning),
592 danger: rgb_to_iced(core.error),
593 };
594
595 iced::Theme::custom(name, palette)
596 }
597
598 pub fn current_theme_name(&self) -> &'static str {
600 gitkraft_core::THEME_NAMES
601 .get(self.current_theme_index)
602 .copied()
603 .unwrap_or("Default")
604 }
605
606 pub fn refresh_active_tab(&mut self) -> Task<Message> {
610 match self.active_tab().repo_path.clone() {
611 Some(path) => crate::features::repo::commands::refresh_repo(path),
612 None => Task::none(),
613 }
614 }
615
616 pub fn on_ok_refresh(
623 &mut self,
624 result: Result<(), String>,
625 ok_msg: &str,
626 err_prefix: &str,
627 ) -> Task<Message> {
628 match result {
629 Ok(()) => {
630 {
631 let tab = self.active_tab_mut();
632 tab.is_loading = false;
633 tab.status_message = Some(ok_msg.to_string());
634 }
635 self.refresh_active_tab()
636 }
637 Err(e) => {
638 let tab = self.active_tab_mut();
639 tab.is_loading = false;
640 tab.error_message = Some(format!("{err_prefix}: {e}"));
641 tab.status_message = None;
642 Task::none()
643 }
644 }
645 }
646
647 pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
649 gitkraft_core::LayoutSettings {
650 sidebar_width: Some(self.sidebar_width),
651 commit_log_width: Some(self.commit_log_width),
652 staging_height: Some(self.staging_height),
653 diff_file_list_width: Some(self.diff_file_list_width),
654 sidebar_expanded: Some(self.sidebar_expanded),
655 ui_scale: Some(self.ui_scale),
656 }
657 }
658}
659
660fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
662 Color::from_rgb8(rgb.r, rgb.g, rgb.b)
663}
664
665fn detect_system_editor() -> gitkraft_core::Editor {
667 for var in ["VISUAL", "EDITOR"] {
668 if let Ok(val) = std::env::var(var) {
669 let bin = val.split('/').next_back().unwrap_or(&val).trim();
670 return match bin {
671 "nvim" | "neovim" => gitkraft_core::Editor::Neovim,
672 "vim" => gitkraft_core::Editor::Vim,
673 "hx" | "helix" => gitkraft_core::Editor::Helix,
674 "nano" => gitkraft_core::Editor::Nano,
675 "micro" => gitkraft_core::Editor::Micro,
676 "emacs" => gitkraft_core::Editor::Emacs,
677 "code" => gitkraft_core::Editor::VSCode,
678 "zed" => gitkraft_core::Editor::Zed,
679 "subl" => gitkraft_core::Editor::Sublime,
680 _ => gitkraft_core::Editor::Custom(val),
681 };
682 }
683 }
684 gitkraft_core::Editor::None
685}
686
687#[cfg(test)]
690mod tests {
691 use super::*;
692
693 #[test]
694 fn new_defaults() {
695 let state = GitKraft::new();
696 assert!(state.active_tab().repo_path.is_none());
697 assert!(!state.has_repo());
698 assert_eq!(state.repo_display_name(), "New Tab");
699 assert!(state.active_tab().commits.is_empty());
700 assert!(state.sidebar_expanded);
701 assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
703 assert!(state.sidebar_width > 0.0);
705 assert!(state.commit_log_width > 0.0);
706 assert!(state.staging_height > 0.0);
707 assert!(state.dragging.is_none());
708 assert!(state.dragging_h.is_none());
709 assert_eq!(state.tabs.len(), 1);
711 assert_eq!(state.active_tab, 0);
712 }
713
714 #[test]
715 fn repo_display_name_extracts_basename() {
716 let mut state = GitKraft::new();
717 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
718 assert_eq!(state.repo_display_name(), "my-project");
719 }
720
721 #[test]
722 fn colors_returns_theme_colors() {
723 let state = GitKraft::new();
724 let c = state.colors();
725 assert!(c.bg.r < 0.5);
727 }
728
729 #[test]
730 fn iced_theme_is_custom_with_correct_palette() {
731 let mut state = GitKraft::new();
732
733 state.current_theme_index = 0;
735 let iced_t = state.iced_theme();
736 let pal = iced_t.palette();
737 assert!(pal.background.r < 0.5, "Default theme bg should be dark");
738 assert_eq!(iced_t.to_string(), "Default");
739
740 state.current_theme_index = 11;
742 let iced_t = state.iced_theme();
743 let pal = iced_t.palette();
744 assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
745 assert_eq!(iced_t.to_string(), "Solarized Light");
746
747 state.current_theme_index = 12;
749 let iced_t = state.iced_theme();
750 let pal = iced_t.palette();
751 let core = gitkraft_core::theme_by_index(12);
752 let expected_accent = rgb_to_iced(core.accent);
753 assert!(
754 (pal.primary.r - expected_accent.r).abs() < 0.01
755 && (pal.primary.g - expected_accent.g).abs() < 0.01
756 && (pal.primary.b - expected_accent.b).abs() < 0.01,
757 "Gruvbox Dark accent should match core accent"
758 );
759 }
760
761 #[test]
762 fn iced_theme_name_round_trips_through_core() {
763 for i in 0..gitkraft_core::THEME_COUNT {
766 let mut state = GitKraft::new();
767 state.current_theme_index = i;
768 let iced_t = state.iced_theme();
769 let name = iced_t.to_string();
770 let resolved = gitkraft_core::theme_index_by_name(&name);
771 assert_eq!(
772 resolved,
773 i,
774 "theme index {i} ({}) did not round-trip through iced_theme name",
775 gitkraft_core::THEME_NAMES[i]
776 );
777 }
778 }
779
780 #[test]
781 fn current_theme_name_round_trips() {
782 let mut state = GitKraft::new();
783 state.current_theme_index = 8;
784 assert_eq!(state.current_theme_name(), "Dracula");
785 state.current_theme_index = 0;
786 assert_eq!(state.current_theme_name(), "Default");
787 }
788
789 #[test]
790 fn repo_tab_new_empty() {
791 let tab = RepoTab::new_empty();
792 assert!(tab.repo_path.is_none());
793 assert!(!tab.has_repo());
794 assert_eq!(tab.display_name(), "New Tab");
795 assert!(tab.commits.is_empty());
796 assert!(tab.branches.is_empty());
797 assert!(!tab.is_loading);
798 }
799
800 #[test]
801 fn repo_tab_display_name_with_path() {
802 let mut tab = RepoTab::new_empty();
803 tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
804 assert!(tab.has_repo());
805 assert_eq!(tab.display_name(), "cool-repo");
806 }
807
808 #[test]
809 fn search_defaults() {
810 let state = GitKraft::new();
811 assert!(!state.search_visible);
812 assert!(state.search_query.is_empty());
813 assert!(state.search_results.is_empty());
814 assert!(state.search_selected.is_none());
815 }
816
817 #[test]
818 fn context_menu_variants_exist() {
819 use crate::state::ContextMenu;
821
822 let _branch = ContextMenu::Branch {
823 name: "main".to_string(),
824 is_current: true,
825 local_index: 0,
826 };
827 let _remote = ContextMenu::RemoteBranch {
828 name: "origin/main".to_string(),
829 };
830 let _commit = ContextMenu::Commit {
831 index: 0,
832 oid: "abc1234".to_string(),
833 };
834 let _stash = ContextMenu::Stash { index: 0 };
835 let _unstaged = ContextMenu::UnstagedFile {
836 path: "src/main.rs".to_string(),
837 };
838 let _staged = ContextMenu::StagedFile {
839 path: "src/lib.rs".to_string(),
840 };
841 }
842
843 #[test]
844 fn repo_tab_context_menu_defaults_to_none() {
845 let tab = crate::state::RepoTab::new_empty();
846 assert!(tab.context_menu.is_none());
847 }
848
849 #[test]
850 fn context_menu_variants_constructable() {
851 use crate::state::ContextMenu;
852 let _ = ContextMenu::Stash { index: 0 };
853 let _ = ContextMenu::UnstagedFile {
854 path: "a.rs".into(),
855 };
856 let _ = ContextMenu::StagedFile {
857 path: "b.rs".into(),
858 };
859 }
860
861 #[test]
862 fn selected_unstaged_defaults_empty() {
863 let tab = crate::state::RepoTab::new_empty();
864 assert!(tab.selected_unstaged.is_empty());
865 assert!(tab.selected_staged.is_empty());
866 }
867
868 #[test]
869 fn selected_unstaged_toggle() {
870 let mut tab = crate::state::RepoTab::new_empty();
871 tab.selected_unstaged.insert("a.rs".to_string());
872 tab.selected_unstaged.insert("b.rs".to_string());
873 assert_eq!(tab.selected_unstaged.len(), 2);
874 assert!(tab.selected_unstaged.contains("a.rs"));
875 tab.selected_unstaged.remove("a.rs");
876 assert_eq!(tab.selected_unstaged.len(), 1);
877 assert!(!tab.selected_unstaged.contains("a.rs"));
878 }
879
880 #[test]
881 fn detect_system_editor_returns_valid() {
882 let editor = super::detect_system_editor();
884 let _ = editor.display_name();
885 }
886
887 #[test]
890 fn selected_commit_file_indices_defaults_to_empty_vec() {
891 let tab = RepoTab::new_empty();
892 assert!(tab.selected_commit_file_indices.is_empty());
893 let v: &Vec<usize> = &tab.selected_commit_file_indices;
895 assert_eq!(v.len(), 0);
896 }
897
898 #[test]
899 fn multi_file_diffs_defaults_empty() {
900 let tab = RepoTab::new_empty();
901 assert!(tab.multi_file_diffs.is_empty());
902 }
903
904 #[test]
905 fn keyboard_modifiers_default_has_no_shift() {
906 let state = GitKraft::new();
907 assert!(!state.keyboard_modifiers.shift());
908 }
909
910 #[test]
911 fn selected_commit_file_indices_preserves_insertion_order() {
912 let mut tab = RepoTab::new_empty();
913 tab.selected_commit_file_indices.push(5);
914 tab.selected_commit_file_indices.push(2);
915 tab.selected_commit_file_indices.push(8);
916 assert_eq!(tab.selected_commit_file_indices, vec![5, 2, 8]);
917 }
918
919 #[test]
920 fn selected_commit_file_indices_cleared_on_reset() {
921 let mut tab = RepoTab::new_empty();
922 tab.selected_commit_file_indices.push(0);
923 tab.selected_commit_file_indices.push(1);
924 tab.selected_commit_file_indices.clear();
925 assert!(tab.selected_commit_file_indices.is_empty());
926 }
927
928 #[test]
929 fn multi_file_diffs_cleared_on_reset() {
930 let mut tab = RepoTab::new_empty();
931 tab.multi_file_diffs.push(gitkraft_core::DiffInfo {
932 old_file: String::new(),
933 new_file: "a.rs".to_string(),
934 status: gitkraft_core::FileStatus::Modified,
935 hunks: vec![],
936 });
937 tab.multi_file_diffs.clear();
938 assert!(tab.multi_file_diffs.is_empty());
939 }
940
941 #[test]
942 fn commit_range_diffs_defaults_empty() {
943 let tab = RepoTab::new_empty();
944 assert!(tab.commit_range_diffs.is_empty());
945 }
946
947 #[test]
948 fn commit_range_diffs_cleared_on_apply_payload() {
949 let mut tab = RepoTab::new_empty();
951 tab.commit_range_diffs.push(gitkraft_core::DiffInfo {
952 old_file: String::new(),
953 new_file: "x.rs".to_string(),
954 status: gitkraft_core::FileStatus::Modified,
955 hunks: vec![],
956 });
957 tab.commit_range_diffs.clear();
958 assert!(tab.commit_range_diffs.is_empty());
959 }
960
961 #[test]
964 fn modifiers_changed_sets_shift_state() {
965 use crate::message::Message;
966 let mut state = GitKraft::new();
967 assert!(!state.keyboard_modifiers.shift());
968
969 let _ = state.update(Message::ModifiersChanged(iced::keyboard::Modifiers::SHIFT));
970 assert!(state.keyboard_modifiers.shift());
971
972 let _ = state.update(Message::ModifiersChanged(
973 iced::keyboard::Modifiers::default(),
974 ));
975 assert!(!state.keyboard_modifiers.shift());
976 }
977
978 fn make_commit_files(names: &[&str]) -> Vec<gitkraft_core::DiffFileEntry> {
981 names
982 .iter()
983 .map(|name| gitkraft_core::DiffFileEntry {
984 old_file: String::new(),
985 new_file: name.to_string(),
986 status: gitkraft_core::FileStatus::Modified,
987 })
988 .collect()
989 }
990
991 #[test]
992 fn select_diff_by_index_regular_click_clears_multi_selection() {
993 use crate::message::Message;
994 let mut state = GitKraft::new();
995 state.active_tab_mut().repo_path =
999 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1000 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1001 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1002 state.active_tab_mut().selected_commit_file_indices = vec![0, 1];
1004
1005 let _ = state.update(Message::SelectDiffByIndex(0));
1007
1008 assert!(state.active_tab().selected_commit_file_indices.is_empty());
1009 assert!(state.active_tab().multi_file_diffs.is_empty());
1010 assert_eq!(state.active_tab().selected_file_index, Some(0));
1011 }
1012
1013 #[test]
1014 fn select_diff_by_index_shift_click_adds_both_files_to_selection() {
1015 use crate::message::Message;
1016 let mut state = GitKraft::new();
1017 state.active_tab_mut().repo_path =
1018 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1019 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1020 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1021 state.active_tab_mut().selected_file_index = Some(0);
1022
1023 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1025 let _ = state.update(Message::SelectDiffByIndex(1));
1026
1027 let sel = &state.active_tab().selected_commit_file_indices;
1028 assert!(sel.contains(&0), "anchor file 0 should be selected");
1029 assert!(sel.contains(&1), "newly clicked file 1 should be selected");
1030 assert_eq!(sel.len(), 2);
1031 }
1032
1033 #[test]
1034 fn anchor_file_index_defaults_to_none() {
1035 let tab = RepoTab::new_empty();
1036 assert!(tab.anchor_file_index.is_none());
1037 }
1038
1039 #[test]
1040 fn regular_click_sets_anchor() {
1041 use crate::message::Message;
1042 let mut state = GitKraft::new();
1043 state.active_tab_mut().repo_path =
1044 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1045 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1046 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1047
1048 let _ = state.update(Message::SelectDiffByIndex(2));
1049
1050 assert_eq!(
1051 state.active_tab().anchor_file_index,
1052 Some(2),
1053 "regular click must set anchor to the clicked index"
1054 );
1055 }
1056
1057 #[test]
1058 fn shift_click_selects_range_downward_from_anchor() {
1059 use crate::message::Message;
1060 let mut state = GitKraft::new();
1061 state.active_tab_mut().repo_path =
1062 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1063 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1064 state.active_tab_mut().commit_files =
1065 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1066 state.active_tab_mut().anchor_file_index = Some(1);
1068 state.active_tab_mut().selected_file_index = Some(1);
1069
1070 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1072 let _ = state.update(Message::SelectDiffByIndex(4));
1073
1074 let sel = &state.active_tab().selected_commit_file_indices;
1075 assert_eq!(
1076 sel,
1077 &vec![1, 2, 3, 4],
1078 "range must be contiguous from anchor to click"
1079 );
1080 }
1081
1082 #[test]
1083 fn shift_click_selects_range_upward_from_anchor() {
1084 use crate::message::Message;
1085 let mut state = GitKraft::new();
1086 state.active_tab_mut().repo_path =
1087 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1088 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1089 state.active_tab_mut().commit_files =
1090 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1091 state.active_tab_mut().anchor_file_index = Some(4);
1093 state.active_tab_mut().selected_file_index = Some(4);
1094
1095 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1097 let _ = state.update(Message::SelectDiffByIndex(1));
1098
1099 let sel = &state.active_tab().selected_commit_file_indices;
1100 assert_eq!(
1101 sel,
1102 &vec![1, 2, 3, 4],
1103 "range must be stored ascending regardless of click direction"
1104 );
1105 }
1106
1107 #[test]
1108 fn shift_click_anchor_fixed_on_subsequent_clicks() {
1109 use crate::message::Message;
1110 let mut state = GitKraft::new();
1111 state.active_tab_mut().repo_path =
1112 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1113 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1114 state.active_tab_mut().commit_files =
1115 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1116 state.active_tab_mut().anchor_file_index = Some(2);
1118 state.active_tab_mut().selected_file_index = Some(2);
1119 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1120
1121 let _ = state.update(Message::SelectDiffByIndex(4));
1123 assert_eq!(
1124 state.active_tab().selected_commit_file_indices,
1125 vec![2, 3, 4]
1126 );
1127
1128 let _ = state.update(Message::SelectDiffByIndex(3));
1130 assert_eq!(
1131 state.active_tab().selected_commit_file_indices,
1132 vec![2, 3],
1133 "anchor must stay fixed; second Shift+Click shrinks the range"
1134 );
1135
1136 let _ = state.update(Message::SelectDiffByIndex(0));
1138 assert_eq!(
1139 state.active_tab().selected_commit_file_indices,
1140 vec![0, 1, 2],
1141 "anchor must stay fixed; can extend range in either direction"
1142 );
1143 }
1144
1145 #[test]
1146 fn shift_click_on_anchor_itself_gives_single_item_range() {
1147 use crate::message::Message;
1148 let mut state = GitKraft::new();
1149 state.active_tab_mut().repo_path =
1150 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1151 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1152 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1153 state.active_tab_mut().anchor_file_index = Some(1);
1154 state.active_tab_mut().selected_file_index = Some(1);
1155
1156 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1158 let _ = state.update(Message::SelectDiffByIndex(1));
1159
1160 assert_eq!(state.active_tab().selected_commit_file_indices, vec![1]);
1161 assert!(
1162 state.active_tab().multi_file_diffs.is_empty(),
1163 "single-item range must not populate multi_file_diffs"
1164 );
1165 }
1166
1167 #[test]
1168 fn shift_click_range_is_always_ascending() {
1169 use crate::message::Message;
1170 let mut state = GitKraft::new();
1171 state.active_tab_mut().repo_path =
1172 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1173 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1174 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs"]);
1175 state.active_tab_mut().anchor_file_index = Some(3);
1176 state.active_tab_mut().selected_file_index = Some(3);
1177
1178 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1179 let _ = state.update(Message::SelectDiffByIndex(0));
1180
1181 let sel = &state.active_tab().selected_commit_file_indices;
1182 let is_sorted = sel.windows(2).all(|w| w[0] < w[1]);
1183 assert!(
1184 is_sorted,
1185 "selection must always be stored in ascending order"
1186 );
1187 assert_eq!(sel, &vec![0, 1, 2, 3]);
1188 }
1189
1190 #[test]
1191 fn checkout_file_at_commit_message_variants_exist() {
1192 use crate::message::Message;
1193 let _single =
1195 Message::CheckoutFileAtCommit("abc123".to_string(), "src/main.rs".to_string());
1196 let _multi = Message::CheckoutMultiFilesAtCommit(
1197 "abc123".to_string(),
1198 vec!["a.rs".to_string(), "b.rs".to_string()],
1199 );
1200 }
1201
1202 #[test]
1203 fn checkout_file_at_commit_closes_context_menu() {
1204 use crate::message::Message;
1205 let mut state = GitKraft::new();
1206 state.active_tab_mut().repo_path =
1207 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1208 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1209 oid: "abc123".to_string(),
1210 file_path: "src/main.rs".to_string(),
1211 });
1212 let _ = state.update(Message::CheckoutFileAtCommit(
1213 "abc123".to_string(),
1214 "src/main.rs".to_string(),
1215 ));
1216 assert!(state.active_tab().context_menu.is_none());
1217 }
1218
1219 #[test]
1220 fn checkout_multi_files_at_commit_closes_context_menu() {
1221 use crate::message::Message;
1222 let mut state = GitKraft::new();
1223 state.active_tab_mut().repo_path =
1224 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1225 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1226 oid: "abc123".to_string(),
1227 file_path: "src/main.rs".to_string(),
1228 });
1229 let _ = state.update(Message::CheckoutMultiFilesAtCommit(
1230 "abc123".to_string(),
1231 vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
1232 ));
1233 assert!(state.active_tab().context_menu.is_none());
1234 }
1235
1236 fn make_test_commits(count: usize) -> Vec<gitkraft_core::CommitInfo> {
1239 (0..count)
1240 .map(|i| gitkraft_core::CommitInfo {
1241 oid: i.to_string(),
1242 short_oid: i.to_string(),
1243 summary: String::new(),
1244 message: String::new(),
1245 author_name: String::new(),
1246 author_email: String::new(),
1247 time: Default::default(),
1248 parent_ids: Vec::new(),
1249 })
1250 .collect()
1251 }
1252
1253 #[test]
1254 fn selected_commits_defaults_empty() {
1255 let tab = RepoTab::new_empty();
1256 assert!(tab.selected_commits.is_empty());
1257 assert!(tab.anchor_commit_index.is_none());
1258 }
1259
1260 #[test]
1261 fn regular_click_commit_sets_anchor_and_clears_range() {
1262 use crate::message::Message;
1263 let mut state = GitKraft::new();
1264 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
1265 state.active_tab_mut().commits = make_test_commits(3);
1266 state.active_tab_mut().selected_commits = vec![0, 1, 2];
1267
1268 let _ = state.update(Message::SelectCommit(1));
1269
1270 assert_eq!(state.active_tab().anchor_commit_index, Some(1));
1271 assert!(state.active_tab().selected_commits.is_empty());
1272 assert_eq!(state.active_tab().selected_commit, Some(1));
1273 }
1274
1275 #[test]
1276 fn shift_click_commit_selects_range_from_anchor() {
1277 use crate::message::Message;
1278 let mut state = GitKraft::new();
1279 state.active_tab_mut().commits = make_test_commits(5);
1280 state.active_tab_mut().anchor_commit_index = Some(1);
1281 state.active_tab_mut().selected_commit = Some(1);
1282
1283 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1284 let _ = state.update(Message::SelectCommit(4));
1285
1286 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3, 4]);
1287 }
1288
1289 #[test]
1290 fn shift_click_commit_range_is_ascending_when_clicking_above_anchor() {
1291 use crate::message::Message;
1292 let mut state = GitKraft::new();
1293 state.active_tab_mut().commits = make_test_commits(5);
1294 state.active_tab_mut().anchor_commit_index = Some(3);
1295 state.active_tab_mut().selected_commit = Some(3);
1296
1297 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1298 let _ = state.update(Message::SelectCommit(1));
1299
1300 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3]);
1301 }
1302
1303 #[test]
1306 fn execute_commit_action_closes_context_menu() {
1307 use crate::message::Message;
1308 let mut state = GitKraft::new();
1309 state.active_tab_mut().repo_path =
1310 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1311 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::Commit {
1312 index: 0,
1313 oid: "abc123".to_string(),
1314 });
1315
1316 let _ = state.update(Message::ExecuteCommitAction(
1317 "abc123".to_string(),
1318 gitkraft_core::CommitAction::CherryPick,
1319 ));
1320
1321 assert!(state.active_tab().context_menu.is_none());
1322 }
1323
1324 #[test]
1325 fn execute_commit_action_sets_loading_when_repo_open() {
1326 use crate::message::Message;
1327 let mut state = GitKraft::new();
1328 state.active_tab_mut().repo_path =
1329 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1330
1331 let _ = state.update(Message::ExecuteCommitAction(
1332 "abc123".to_string(),
1333 gitkraft_core::CommitAction::ResetHard,
1334 ));
1335
1336 assert!(state.active_tab().is_loading);
1337 }
1338
1339 #[test]
1340 fn execute_commit_action_no_repo_does_not_set_loading() {
1341 use crate::message::Message;
1342 let mut state = GitKraft::new();
1343 let _ = state.update(Message::ExecuteCommitAction(
1346 "abc123".to_string(),
1347 gitkraft_core::CommitAction::CherryPick,
1348 ));
1349
1350 assert!(!state.active_tab().is_loading);
1351 }
1352
1353 #[test]
1354 fn execute_commit_action_sets_status_message_from_action_label() {
1355 use crate::message::Message;
1356 let mut state = GitKraft::new();
1357 state.active_tab_mut().repo_path =
1358 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1359
1360 let _ = state.update(Message::ExecuteCommitAction(
1361 "abc123".to_string(),
1362 gitkraft_core::CommitAction::Revert,
1363 ));
1364
1365 let status = state.active_tab().status_message.as_deref().unwrap_or("");
1366 assert!(
1368 status.contains("Revert commit"),
1369 "expected status to contain 'Revert commit', got: {status:?}"
1370 );
1371 }
1372}