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 pub file_history_path: Option<String>,
191 pub file_history_commits: Vec<gitkraft_core::CommitInfo>,
193 pub file_history_scroll: f32,
195
196 pub blame_path: Option<String>,
198 pub blame_lines: Vec<gitkraft_core::BlameLine>,
200 pub blame_scroll: f32,
202
203 pub pending_delete_file: Option<String>,
205}
206
207impl RepoTab {
208 pub fn new_empty() -> Self {
210 Self {
211 repo_path: None,
212 repo_info: None,
213 branches: Vec::new(),
214 current_branch: None,
215 commits: Vec::new(),
216 selected_commit: None,
217 anchor_commit_index: None,
218 selected_commits: Vec::new(),
219 graph_rows: Vec::new(),
220 unstaged_changes: Vec::new(),
221 staged_changes: Vec::new(),
222 commit_files: Vec::new(),
223 selected_commit_oid: None,
224 selected_file_index: None,
225 is_loading_file_diff: false,
226 anchor_file_index: None,
227 selected_commit_file_indices: Vec::new(),
228 multi_file_diffs: Vec::new(),
229 commit_range_diffs: Vec::new(),
230 selected_diff: None,
231 commit_message: String::new(),
232 stashes: Vec::new(),
233 remotes: Vec::new(),
234 show_commit_detail: false,
235 new_branch_name: String::new(),
236 show_branch_create: false,
237 local_branches_expanded: true,
238 remote_branches_expanded: true,
239 stash_message: String::new(),
240 selected_unstaged: std::collections::HashSet::new(),
241 selected_staged: std::collections::HashSet::new(),
242 pending_discard: None,
243 status_message: None,
244 error_message: None,
245 is_loading: false,
246 context_menu: None,
247 context_menu_pos: (0.0, 0.0),
248 rename_branch_target: None,
249 rename_branch_input: String::new(),
250 create_tag_target_oid: None,
251 create_tag_annotated: false,
252 create_tag_name: String::new(),
253 create_tag_message: String::new(),
254 create_branch_at_oid: None,
255 commit_scroll_offset: 0.0,
256 diff_scroll_offset: 0.0,
257 commit_display: Vec::new(),
258 has_more_commits: true,
259 is_loading_more_commits: false,
260 file_history_path: None,
261 file_history_commits: Vec::new(),
262 file_history_scroll: 0.0,
263 blame_path: None,
264 blame_lines: Vec::new(),
265 blame_scroll: 0.0,
266 pending_delete_file: None,
267 }
268 }
269
270 pub fn has_repo(&self) -> bool {
272 self.repo_path.is_some()
273 }
274
275 pub fn display_name(&self) -> &str {
277 self.repo_path
278 .as_ref()
279 .and_then(|p| p.file_name())
280 .and_then(|n| n.to_str())
281 .unwrap_or("New Tab")
282 }
283
284 pub fn apply_payload(
290 &mut self,
291 payload: crate::message::RepoPayload,
292 path: std::path::PathBuf,
293 ) {
294 let prev_oid = self.selected_commit_oid.clone();
296
297 self.current_branch = payload.info.head_branch.clone();
298 self.repo_path = Some(path);
299 self.repo_info = Some(payload.info);
300 self.branches = payload.branches;
301 self.commits = payload.commits;
302 self.graph_rows = payload.graph_rows;
303 self.unstaged_changes = payload.unstaged;
304 self.staged_changes = payload.staged;
305 self.stashes = payload.stashes;
306 self.remotes = payload.remotes;
307
308 self.selected_commit = None;
312 self.anchor_commit_index = None;
313 self.selected_commits.clear();
314 self.selected_commit_oid = None;
315 self.commit_message.clear();
316 self.error_message = None;
317 self.status_message = Some("Repository loaded.".into());
318 self.commit_scroll_offset = 0.0;
319 self.has_more_commits = true;
320 self.is_loading_more_commits = false;
321 self.selected_unstaged.clear();
322 self.selected_staged.clear();
323 self.anchor_file_index = None;
324 self.selected_commit_file_indices.clear();
325 self.multi_file_diffs.clear();
326 self.commit_range_diffs.clear();
327
328 if let Some(oid) = prev_oid {
334 if let Some(new_idx) = self.commits.iter().position(|c| c.oid == oid) {
335 self.selected_commit = Some(new_idx);
336 self.selected_commit_oid = Some(oid);
337 } else {
341 self.selected_diff = None;
343 self.commit_files.clear();
344 self.selected_file_index = None;
345 self.is_loading_file_diff = false;
346 self.diff_scroll_offset = 0.0;
347 }
348 } else {
349 self.selected_diff = None;
351 self.commit_files.clear();
352 self.selected_file_index = None;
353 self.is_loading_file_diff = false;
354 self.diff_scroll_offset = 0.0;
355 }
356 }
357}
358
359pub struct GitKraft {
363 pub tabs: Vec<RepoTab>,
366 pub active_tab: usize,
368
369 pub sidebar_expanded: bool,
372
373 pub sidebar_width: f32,
376 pub commit_log_width: f32,
378 pub staging_height: f32,
380 pub diff_file_list_width: f32,
382
383 pub ui_scale: f32,
385
386 pub dragging: Option<DragTarget>,
389 pub dragging_h: Option<DragTargetH>,
391 pub drag_start_x: f32,
393 pub drag_start_y: f32,
395 pub drag_initialized: bool,
399 pub drag_initialized_h: bool,
401
402 pub cursor_pos: Point,
407
408 pub current_theme_index: usize,
411
412 pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
415
416 pub search_visible: bool,
419 pub search_query: String,
421 pub search_results: Vec<gitkraft_core::CommitInfo>,
423 pub search_selected: Option<usize>,
425
426 pub search_diff_files: Vec<gitkraft_core::DiffFileEntry>,
428 pub search_diff_selected: HashSet<usize>,
430 pub search_diff_content: Vec<gitkraft_core::DiffInfo>,
432 pub search_diff_oid: Option<String>,
434
435 pub editor: gitkraft_core::Editor,
437
438 pub keyboard_modifiers: iced::keyboard::Modifiers,
440
441 pub animation_tick: u64,
444
445 pub window_width: f32,
448 pub window_height: f32,
450 pub window_x: f32,
452 pub window_y: f32,
454}
455
456impl Default for GitKraft {
457 fn default() -> Self {
458 Self::new()
459 }
460}
461
462impl GitKraft {
463 fn from_settings(settings: gitkraft_core::AppSettings) -> Self {
469 let current_theme_index = settings
470 .theme_name
471 .as_deref()
472 .map(gitkraft_core::theme_index_by_name)
473 .unwrap_or(0);
474
475 let recent_repos = settings.recent_repos;
476
477 let (
478 sidebar_width,
479 commit_log_width,
480 staging_height,
481 diff_file_list_width,
482 sidebar_expanded,
483 ui_scale,
484 ) = if let Some(ref layout) = settings.layout {
485 (
486 layout.sidebar_width.unwrap_or(220.0),
487 layout.commit_log_width.unwrap_or(500.0),
488 layout.staging_height.unwrap_or(200.0),
489 layout.diff_file_list_width.unwrap_or(180.0),
490 layout.sidebar_expanded.unwrap_or(true),
491 layout.ui_scale.unwrap_or(1.0),
492 )
493 } else {
494 (220.0, 500.0, 200.0, 180.0, true, 1.0)
495 };
496
497 Self {
498 tabs: vec![RepoTab::new_empty()],
499 active_tab: 0,
500
501 sidebar_expanded,
502
503 sidebar_width,
504 commit_log_width,
505 staging_height,
506 diff_file_list_width,
507
508 ui_scale,
509
510 dragging: None,
511 dragging_h: None,
512 drag_start_x: 0.0,
513 drag_start_y: 0.0,
514 drag_initialized: false,
515 drag_initialized_h: false,
516 cursor_pos: Point::ORIGIN,
517
518 current_theme_index,
519
520 recent_repos,
521
522 search_visible: false,
523 search_query: String::new(),
524 search_results: Vec::new(),
525 search_selected: None,
526 search_diff_files: Vec::new(),
527 search_diff_selected: HashSet::new(),
528 search_diff_content: Vec::new(),
529 search_diff_oid: None,
530
531 keyboard_modifiers: iced::keyboard::Modifiers::default(),
532 animation_tick: 0,
533
534 window_width: settings
535 .layout
536 .as_ref()
537 .and_then(|l| l.window_width)
538 .unwrap_or(1400.0),
539 window_height: settings
540 .layout
541 .as_ref()
542 .and_then(|l| l.window_height)
543 .unwrap_or(800.0),
544 window_x: settings
545 .layout
546 .as_ref()
547 .and_then(|l| l.window_x)
548 .unwrap_or(0.0),
549 window_y: settings
550 .layout
551 .as_ref()
552 .and_then(|l| l.window_y)
553 .unwrap_or(0.0),
554
555 editor: settings
556 .editor_name
557 .as_deref()
558 .map(|name| {
559 gitkraft_core::EDITOR_NAMES
561 .iter()
562 .position(|n| n.eq_ignore_ascii_case(name))
563 .map(gitkraft_core::Editor::from_index)
564 .unwrap_or_else(|| {
565 if name.eq_ignore_ascii_case("none") {
566 gitkraft_core::Editor::None
567 } else {
568 gitkraft_core::Editor::Custom(name.to_string())
569 }
570 })
571 })
572 .unwrap_or_else(detect_system_editor),
573 }
574 }
575
576 pub fn new() -> Self {
582 Self::from_settings(
583 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default(),
584 )
585 }
586
587 pub fn new_with_session_paths() -> (Self, Vec<PathBuf>) {
593 let settings =
594 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default();
595 let open_tabs = settings.open_tabs.clone();
596 let active_tab_index = settings.active_tab_index;
597
598 let mut state = Self::from_settings(settings);
599
600 if !open_tabs.is_empty() {
601 state.tabs = open_tabs
602 .iter()
603 .map(|path| {
604 let mut tab = RepoTab::new_empty();
605 tab.repo_path = Some(path.clone());
608 if path.exists() {
609 tab.is_loading = true;
610 tab.status_message = Some(format!(
611 "Loading {}…",
612 path.file_name().unwrap_or_default().to_string_lossy()
613 ));
614 } else {
615 tab.error_message =
616 Some(format!("Repository not found: {}", path.display()));
617 }
618 tab
619 })
620 .collect();
621 state.active_tab = active_tab_index.min(state.tabs.len().saturating_sub(1));
622 }
623
624 (state, open_tabs)
625 }
626
627 pub fn open_tab_paths(&self) -> Vec<PathBuf> {
630 self.tabs
631 .iter()
632 .filter(|t| t.repo_info.is_some())
633 .filter_map(|t| t.repo_path.clone())
634 .collect()
635 }
636
637 pub fn active_tab(&self) -> &RepoTab {
639 &self.tabs[self.active_tab]
640 }
641
642 pub fn active_tab_mut(&mut self) -> &mut RepoTab {
644 &mut self.tabs[self.active_tab]
645 }
646
647 pub fn has_repo(&self) -> bool {
649 self.active_tab().has_repo()
650 }
651
652 pub fn repo_display_name(&self) -> &str {
654 self.active_tab().display_name()
655 }
656
657 pub fn colors(&self) -> ThemeColors {
664 ThemeColors::from_core(&gitkraft_core::theme_by_index(self.current_theme_index))
665 }
666
667 pub fn iced_theme(&self) -> iced::Theme {
676 let core = gitkraft_core::theme_by_index(self.current_theme_index);
677 let name = self.current_theme_name().to_string();
678
679 let palette = iced::theme::Palette {
680 background: rgb_to_iced(core.background),
681 text: rgb_to_iced(core.text_primary),
682 primary: rgb_to_iced(core.accent),
683 success: rgb_to_iced(core.success),
684 warning: rgb_to_iced(core.warning),
685 danger: rgb_to_iced(core.error),
686 };
687
688 iced::Theme::custom(name, palette)
689 }
690
691 pub fn current_theme_name(&self) -> &'static str {
693 gitkraft_core::THEME_NAMES
694 .get(self.current_theme_index)
695 .copied()
696 .unwrap_or("Default")
697 }
698
699 pub fn refresh_active_tab(&mut self) -> Task<Message> {
703 match self.active_tab().repo_path.clone() {
704 Some(path) => crate::features::repo::commands::refresh_repo(path),
705 None => Task::none(),
706 }
707 }
708
709 pub fn on_ok_refresh(
716 &mut self,
717 result: Result<(), String>,
718 ok_msg: &str,
719 err_prefix: &str,
720 ) -> Task<Message> {
721 match result {
722 Ok(()) => {
723 {
724 let tab = self.active_tab_mut();
725 tab.is_loading = false;
726 tab.status_message = Some(ok_msg.to_string());
727 }
728 self.refresh_active_tab()
729 }
730 Err(e) => {
731 let tab = self.active_tab_mut();
732 tab.is_loading = false;
733 tab.error_message = Some(format!("{err_prefix}: {e}"));
734 tab.status_message = None;
735 Task::none()
736 }
737 }
738 }
739
740 pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
742 gitkraft_core::LayoutSettings {
743 sidebar_width: Some(self.sidebar_width),
744 commit_log_width: Some(self.commit_log_width),
745 staging_height: Some(self.staging_height),
746 diff_file_list_width: Some(self.diff_file_list_width),
747 sidebar_expanded: Some(self.sidebar_expanded),
748 ui_scale: Some(self.ui_scale),
749 window_width: Some(self.window_width),
750 window_height: Some(self.window_height),
751 window_x: Some(self.window_x),
752 window_y: Some(self.window_y),
753 window_maximized: None, }
755 }
756}
757
758fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
760 Color::from_rgb8(rgb.r, rgb.g, rgb.b)
761}
762
763fn detect_system_editor() -> gitkraft_core::Editor {
765 for var in ["VISUAL", "EDITOR"] {
766 if let Ok(val) = std::env::var(var) {
767 let bin = val.split('/').next_back().unwrap_or(&val).trim();
768 return match bin {
769 "nvim" | "neovim" => gitkraft_core::Editor::Neovim,
770 "vim" => gitkraft_core::Editor::Vim,
771 "hx" | "helix" => gitkraft_core::Editor::Helix,
772 "nano" => gitkraft_core::Editor::Nano,
773 "micro" => gitkraft_core::Editor::Micro,
774 "emacs" => gitkraft_core::Editor::Emacs,
775 "code" => gitkraft_core::Editor::VSCode,
776 "zed" => gitkraft_core::Editor::Zed,
777 "subl" => gitkraft_core::Editor::Sublime,
778 _ => gitkraft_core::Editor::Custom(val),
779 };
780 }
781 }
782 gitkraft_core::Editor::None
783}
784
785#[cfg(test)]
788mod tests {
789 use super::*;
790
791 #[test]
792 fn new_defaults() {
793 let state = GitKraft::new();
794 assert!(state.active_tab().repo_path.is_none());
795 assert!(!state.has_repo());
796 assert_eq!(state.repo_display_name(), "New Tab");
797 assert!(state.active_tab().commits.is_empty());
798 assert!(state.sidebar_expanded);
799 assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
801 assert!(state.sidebar_width > 0.0);
803 assert!(state.commit_log_width > 0.0);
804 assert!(state.staging_height > 0.0);
805 assert!(state.dragging.is_none());
806 assert!(state.dragging_h.is_none());
807 assert_eq!(state.tabs.len(), 1);
809 assert_eq!(state.active_tab, 0);
810 }
811
812 #[test]
813 fn repo_display_name_extracts_basename() {
814 let mut state = GitKraft::new();
815 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
816 assert_eq!(state.repo_display_name(), "my-project");
817 }
818
819 #[test]
820 fn colors_returns_theme_colors() {
821 let state = GitKraft::new();
822 let c = state.colors();
823 assert!(c.bg.r < 0.5);
825 }
826
827 #[test]
828 fn iced_theme_is_custom_with_correct_palette() {
829 let mut state = GitKraft::new();
830
831 state.current_theme_index = 0;
833 let iced_t = state.iced_theme();
834 let pal = iced_t.palette();
835 assert!(pal.background.r < 0.5, "Default theme bg should be dark");
836 assert_eq!(iced_t.to_string(), "Default");
837
838 state.current_theme_index = 11;
840 let iced_t = state.iced_theme();
841 let pal = iced_t.palette();
842 assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
843 assert_eq!(iced_t.to_string(), "Solarized Light");
844
845 state.current_theme_index = 12;
847 let iced_t = state.iced_theme();
848 let pal = iced_t.palette();
849 let core = gitkraft_core::theme_by_index(12);
850 let expected_accent = rgb_to_iced(core.accent);
851 assert!(
852 (pal.primary.r - expected_accent.r).abs() < 0.01
853 && (pal.primary.g - expected_accent.g).abs() < 0.01
854 && (pal.primary.b - expected_accent.b).abs() < 0.01,
855 "Gruvbox Dark accent should match core accent"
856 );
857 }
858
859 #[test]
860 fn iced_theme_name_round_trips_through_core() {
861 for i in 0..gitkraft_core::THEME_COUNT {
864 let mut state = GitKraft::new();
865 state.current_theme_index = i;
866 let iced_t = state.iced_theme();
867 let name = iced_t.to_string();
868 let resolved = gitkraft_core::theme_index_by_name(&name);
869 assert_eq!(
870 resolved,
871 i,
872 "theme index {i} ({}) did not round-trip through iced_theme name",
873 gitkraft_core::THEME_NAMES[i]
874 );
875 }
876 }
877
878 #[test]
879 fn current_theme_name_round_trips() {
880 let mut state = GitKraft::new();
881 state.current_theme_index = 8;
882 assert_eq!(state.current_theme_name(), "Dracula");
883 state.current_theme_index = 0;
884 assert_eq!(state.current_theme_name(), "Default");
885 }
886
887 #[test]
888 fn repo_tab_new_empty() {
889 let tab = RepoTab::new_empty();
890 assert!(tab.repo_path.is_none());
891 assert!(!tab.has_repo());
892 assert_eq!(tab.display_name(), "New Tab");
893 assert!(tab.commits.is_empty());
894 assert!(tab.branches.is_empty());
895 assert!(!tab.is_loading);
896 }
897
898 #[test]
899 fn repo_tab_display_name_with_path() {
900 let mut tab = RepoTab::new_empty();
901 tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
902 assert!(tab.has_repo());
903 assert_eq!(tab.display_name(), "cool-repo");
904 }
905
906 #[test]
907 fn search_defaults() {
908 let state = GitKraft::new();
909 assert!(!state.search_visible);
910 assert!(state.search_query.is_empty());
911 assert!(state.search_results.is_empty());
912 assert!(state.search_selected.is_none());
913 }
914
915 #[test]
916 fn context_menu_variants_exist() {
917 use crate::state::ContextMenu;
919
920 let _branch = ContextMenu::Branch {
921 name: "main".to_string(),
922 is_current: true,
923 local_index: 0,
924 };
925 let _remote = ContextMenu::RemoteBranch {
926 name: "origin/main".to_string(),
927 };
928 let _commit = ContextMenu::Commit {
929 index: 0,
930 oid: "abc1234".to_string(),
931 };
932 let _stash = ContextMenu::Stash { index: 0 };
933 let _unstaged = ContextMenu::UnstagedFile {
934 path: "src/main.rs".to_string(),
935 };
936 let _staged = ContextMenu::StagedFile {
937 path: "src/lib.rs".to_string(),
938 };
939 }
940
941 #[test]
942 fn repo_tab_context_menu_defaults_to_none() {
943 let tab = crate::state::RepoTab::new_empty();
944 assert!(tab.context_menu.is_none());
945 }
946
947 #[test]
948 fn context_menu_variants_constructable() {
949 use crate::state::ContextMenu;
950 let _ = ContextMenu::Stash { index: 0 };
951 let _ = ContextMenu::UnstagedFile {
952 path: "a.rs".into(),
953 };
954 let _ = ContextMenu::StagedFile {
955 path: "b.rs".into(),
956 };
957 }
958
959 #[test]
960 fn selected_unstaged_defaults_empty() {
961 let tab = crate::state::RepoTab::new_empty();
962 assert!(tab.selected_unstaged.is_empty());
963 assert!(tab.selected_staged.is_empty());
964 }
965
966 #[test]
967 fn selected_unstaged_toggle() {
968 let mut tab = crate::state::RepoTab::new_empty();
969 tab.selected_unstaged.insert("a.rs".to_string());
970 tab.selected_unstaged.insert("b.rs".to_string());
971 assert_eq!(tab.selected_unstaged.len(), 2);
972 assert!(tab.selected_unstaged.contains("a.rs"));
973 tab.selected_unstaged.remove("a.rs");
974 assert_eq!(tab.selected_unstaged.len(), 1);
975 assert!(!tab.selected_unstaged.contains("a.rs"));
976 }
977
978 #[test]
979 fn detect_system_editor_returns_valid() {
980 let editor = super::detect_system_editor();
982 let _ = editor.display_name();
983 }
984
985 #[test]
988 fn selected_commit_file_indices_defaults_to_empty_vec() {
989 let tab = RepoTab::new_empty();
990 assert!(tab.selected_commit_file_indices.is_empty());
991 let v: &Vec<usize> = &tab.selected_commit_file_indices;
993 assert_eq!(v.len(), 0);
994 }
995
996 #[test]
997 fn multi_file_diffs_defaults_empty() {
998 let tab = RepoTab::new_empty();
999 assert!(tab.multi_file_diffs.is_empty());
1000 }
1001
1002 #[test]
1003 fn keyboard_modifiers_default_has_no_shift() {
1004 let state = GitKraft::new();
1005 assert!(!state.keyboard_modifiers.shift());
1006 }
1007
1008 #[test]
1009 fn selected_commit_file_indices_preserves_insertion_order() {
1010 let mut tab = RepoTab::new_empty();
1011 tab.selected_commit_file_indices.push(5);
1012 tab.selected_commit_file_indices.push(2);
1013 tab.selected_commit_file_indices.push(8);
1014 assert_eq!(tab.selected_commit_file_indices, vec![5, 2, 8]);
1015 }
1016
1017 #[test]
1018 fn selected_commit_file_indices_cleared_on_reset() {
1019 let mut tab = RepoTab::new_empty();
1020 tab.selected_commit_file_indices.push(0);
1021 tab.selected_commit_file_indices.push(1);
1022 tab.selected_commit_file_indices.clear();
1023 assert!(tab.selected_commit_file_indices.is_empty());
1024 }
1025
1026 #[test]
1027 fn multi_file_diffs_cleared_on_reset() {
1028 let mut tab = RepoTab::new_empty();
1029 tab.multi_file_diffs.push(gitkraft_core::DiffInfo {
1030 old_file: String::new(),
1031 new_file: "a.rs".to_string(),
1032 status: gitkraft_core::FileStatus::Modified,
1033 hunks: vec![],
1034 });
1035 tab.multi_file_diffs.clear();
1036 assert!(tab.multi_file_diffs.is_empty());
1037 }
1038
1039 #[test]
1040 fn commit_range_diffs_defaults_empty() {
1041 let tab = RepoTab::new_empty();
1042 assert!(tab.commit_range_diffs.is_empty());
1043 }
1044
1045 #[test]
1046 fn commit_range_diffs_cleared_on_apply_payload() {
1047 let mut tab = RepoTab::new_empty();
1049 tab.commit_range_diffs.push(gitkraft_core::DiffInfo {
1050 old_file: String::new(),
1051 new_file: "x.rs".to_string(),
1052 status: gitkraft_core::FileStatus::Modified,
1053 hunks: vec![],
1054 });
1055 tab.commit_range_diffs.clear();
1056 assert!(tab.commit_range_diffs.is_empty());
1057 }
1058
1059 #[test]
1062 fn modifiers_changed_sets_shift_state() {
1063 use crate::message::Message;
1064 let mut state = GitKraft::new();
1065 assert!(!state.keyboard_modifiers.shift());
1066
1067 let _ = state.update(Message::ModifiersChanged(iced::keyboard::Modifiers::SHIFT));
1068 assert!(state.keyboard_modifiers.shift());
1069
1070 let _ = state.update(Message::ModifiersChanged(
1071 iced::keyboard::Modifiers::default(),
1072 ));
1073 assert!(!state.keyboard_modifiers.shift());
1074 }
1075
1076 fn make_commit_files(names: &[&str]) -> Vec<gitkraft_core::DiffFileEntry> {
1079 names
1080 .iter()
1081 .map(|name| gitkraft_core::DiffFileEntry {
1082 old_file: String::new(),
1083 new_file: name.to_string(),
1084 status: gitkraft_core::FileStatus::Modified,
1085 })
1086 .collect()
1087 }
1088
1089 #[test]
1090 fn select_diff_by_index_regular_click_clears_multi_selection() {
1091 use crate::message::Message;
1092 let mut state = GitKraft::new();
1093 state.active_tab_mut().repo_path =
1097 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1098 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1099 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1100 state.active_tab_mut().selected_commit_file_indices = vec![0, 1];
1102
1103 let _ = state.update(Message::SelectDiffByIndex(0));
1105
1106 assert!(state.active_tab().selected_commit_file_indices.is_empty());
1107 assert!(state.active_tab().multi_file_diffs.is_empty());
1108 assert_eq!(state.active_tab().selected_file_index, Some(0));
1109 }
1110
1111 #[test]
1112 fn select_diff_by_index_shift_click_adds_both_files_to_selection() {
1113 use crate::message::Message;
1114 let mut state = GitKraft::new();
1115 state.active_tab_mut().repo_path =
1116 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1117 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1118 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1119 state.active_tab_mut().selected_file_index = Some(0);
1120
1121 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1123 let _ = state.update(Message::SelectDiffByIndex(1));
1124
1125 let sel = &state.active_tab().selected_commit_file_indices;
1126 assert!(sel.contains(&0), "anchor file 0 should be selected");
1127 assert!(sel.contains(&1), "newly clicked file 1 should be selected");
1128 assert_eq!(sel.len(), 2);
1129 }
1130
1131 #[test]
1132 fn anchor_file_index_defaults_to_none() {
1133 let tab = RepoTab::new_empty();
1134 assert!(tab.anchor_file_index.is_none());
1135 }
1136
1137 #[test]
1138 fn regular_click_sets_anchor() {
1139 use crate::message::Message;
1140 let mut state = GitKraft::new();
1141 state.active_tab_mut().repo_path =
1142 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1143 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1144 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1145
1146 let _ = state.update(Message::SelectDiffByIndex(2));
1147
1148 assert_eq!(
1149 state.active_tab().anchor_file_index,
1150 Some(2),
1151 "regular click must set anchor to the clicked index"
1152 );
1153 }
1154
1155 #[test]
1156 fn shift_click_selects_range_downward_from_anchor() {
1157 use crate::message::Message;
1158 let mut state = GitKraft::new();
1159 state.active_tab_mut().repo_path =
1160 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1161 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1162 state.active_tab_mut().commit_files =
1163 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1164 state.active_tab_mut().anchor_file_index = Some(1);
1166 state.active_tab_mut().selected_file_index = Some(1);
1167
1168 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1170 let _ = state.update(Message::SelectDiffByIndex(4));
1171
1172 let sel = &state.active_tab().selected_commit_file_indices;
1173 assert_eq!(
1174 sel,
1175 &vec![1, 2, 3, 4],
1176 "range must be contiguous from anchor to click"
1177 );
1178 }
1179
1180 #[test]
1181 fn shift_click_selects_range_upward_from_anchor() {
1182 use crate::message::Message;
1183 let mut state = GitKraft::new();
1184 state.active_tab_mut().repo_path =
1185 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1186 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1187 state.active_tab_mut().commit_files =
1188 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1189 state.active_tab_mut().anchor_file_index = Some(4);
1191 state.active_tab_mut().selected_file_index = Some(4);
1192
1193 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1195 let _ = state.update(Message::SelectDiffByIndex(1));
1196
1197 let sel = &state.active_tab().selected_commit_file_indices;
1198 assert_eq!(
1199 sel,
1200 &vec![1, 2, 3, 4],
1201 "range must be stored ascending regardless of click direction"
1202 );
1203 }
1204
1205 #[test]
1206 fn shift_click_anchor_fixed_on_subsequent_clicks() {
1207 use crate::message::Message;
1208 let mut state = GitKraft::new();
1209 state.active_tab_mut().repo_path =
1210 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1211 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1212 state.active_tab_mut().commit_files =
1213 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1214 state.active_tab_mut().anchor_file_index = Some(2);
1216 state.active_tab_mut().selected_file_index = Some(2);
1217 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1218
1219 let _ = state.update(Message::SelectDiffByIndex(4));
1221 assert_eq!(
1222 state.active_tab().selected_commit_file_indices,
1223 vec![2, 3, 4]
1224 );
1225
1226 let _ = state.update(Message::SelectDiffByIndex(3));
1228 assert_eq!(
1229 state.active_tab().selected_commit_file_indices,
1230 vec![2, 3],
1231 "anchor must stay fixed; second Shift+Click shrinks the range"
1232 );
1233
1234 let _ = state.update(Message::SelectDiffByIndex(0));
1236 assert_eq!(
1237 state.active_tab().selected_commit_file_indices,
1238 vec![0, 1, 2],
1239 "anchor must stay fixed; can extend range in either direction"
1240 );
1241 }
1242
1243 #[test]
1244 fn shift_click_on_anchor_itself_gives_single_item_range() {
1245 use crate::message::Message;
1246 let mut state = GitKraft::new();
1247 state.active_tab_mut().repo_path =
1248 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1249 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1250 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1251 state.active_tab_mut().anchor_file_index = Some(1);
1252 state.active_tab_mut().selected_file_index = Some(1);
1253
1254 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1256 let _ = state.update(Message::SelectDiffByIndex(1));
1257
1258 assert_eq!(state.active_tab().selected_commit_file_indices, vec![1]);
1259 assert!(
1260 state.active_tab().multi_file_diffs.is_empty(),
1261 "single-item range must not populate multi_file_diffs"
1262 );
1263 }
1264
1265 #[test]
1266 fn shift_click_range_is_always_ascending() {
1267 use crate::message::Message;
1268 let mut state = GitKraft::new();
1269 state.active_tab_mut().repo_path =
1270 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1271 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1272 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs"]);
1273 state.active_tab_mut().anchor_file_index = Some(3);
1274 state.active_tab_mut().selected_file_index = Some(3);
1275
1276 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1277 let _ = state.update(Message::SelectDiffByIndex(0));
1278
1279 let sel = &state.active_tab().selected_commit_file_indices;
1280 let is_sorted = sel.windows(2).all(|w| w[0] < w[1]);
1281 assert!(
1282 is_sorted,
1283 "selection must always be stored in ascending order"
1284 );
1285 assert_eq!(sel, &vec![0, 1, 2, 3]);
1286 }
1287
1288 #[test]
1289 fn checkout_file_at_commit_message_variants_exist() {
1290 use crate::message::Message;
1291 let _single =
1293 Message::CheckoutFileAtCommit("abc123".to_string(), "src/main.rs".to_string());
1294 let _multi = Message::CheckoutMultiFilesAtCommit(
1295 "abc123".to_string(),
1296 vec!["a.rs".to_string(), "b.rs".to_string()],
1297 );
1298 }
1299
1300 #[test]
1301 fn checkout_file_at_commit_closes_context_menu() {
1302 use crate::message::Message;
1303 let mut state = GitKraft::new();
1304 state.active_tab_mut().repo_path =
1305 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1306 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1307 oid: "abc123".to_string(),
1308 file_path: "src/main.rs".to_string(),
1309 });
1310 let _ = state.update(Message::CheckoutFileAtCommit(
1311 "abc123".to_string(),
1312 "src/main.rs".to_string(),
1313 ));
1314 assert!(state.active_tab().context_menu.is_none());
1315 }
1316
1317 #[test]
1318 fn checkout_multi_files_at_commit_closes_context_menu() {
1319 use crate::message::Message;
1320 let mut state = GitKraft::new();
1321 state.active_tab_mut().repo_path =
1322 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1323 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1324 oid: "abc123".to_string(),
1325 file_path: "src/main.rs".to_string(),
1326 });
1327 let _ = state.update(Message::CheckoutMultiFilesAtCommit(
1328 "abc123".to_string(),
1329 vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
1330 ));
1331 assert!(state.active_tab().context_menu.is_none());
1332 }
1333
1334 fn make_test_commits(count: usize) -> Vec<gitkraft_core::CommitInfo> {
1337 (0..count)
1338 .map(|i| gitkraft_core::CommitInfo {
1339 oid: i.to_string(),
1340 short_oid: i.to_string(),
1341 summary: String::new(),
1342 message: String::new(),
1343 author_name: String::new(),
1344 author_email: String::new(),
1345 time: Default::default(),
1346 parent_ids: Vec::new(),
1347 })
1348 .collect()
1349 }
1350
1351 #[test]
1352 fn selected_commits_defaults_empty() {
1353 let tab = RepoTab::new_empty();
1354 assert!(tab.selected_commits.is_empty());
1355 assert!(tab.anchor_commit_index.is_none());
1356 }
1357
1358 #[test]
1359 fn regular_click_commit_sets_anchor_and_clears_range() {
1360 use crate::message::Message;
1361 let mut state = GitKraft::new();
1362 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
1363 state.active_tab_mut().commits = make_test_commits(3);
1364 state.active_tab_mut().selected_commits = vec![0, 1, 2];
1365
1366 let _ = state.update(Message::SelectCommit(1));
1367
1368 assert_eq!(state.active_tab().anchor_commit_index, Some(1));
1369 assert!(state.active_tab().selected_commits.is_empty());
1370 assert_eq!(state.active_tab().selected_commit, Some(1));
1371 }
1372
1373 #[test]
1374 fn shift_click_commit_selects_range_from_anchor() {
1375 use crate::message::Message;
1376 let mut state = GitKraft::new();
1377 state.active_tab_mut().commits = make_test_commits(5);
1378 state.active_tab_mut().anchor_commit_index = Some(1);
1379 state.active_tab_mut().selected_commit = Some(1);
1380
1381 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1382 let _ = state.update(Message::SelectCommit(4));
1383
1384 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3, 4]);
1385 }
1386
1387 #[test]
1388 fn shift_click_commit_range_is_ascending_when_clicking_above_anchor() {
1389 use crate::message::Message;
1390 let mut state = GitKraft::new();
1391 state.active_tab_mut().commits = make_test_commits(5);
1392 state.active_tab_mut().anchor_commit_index = Some(3);
1393 state.active_tab_mut().selected_commit = Some(3);
1394
1395 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1396 let _ = state.update(Message::SelectCommit(1));
1397
1398 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3]);
1399 }
1400
1401 #[test]
1404 fn execute_commit_action_closes_context_menu() {
1405 use crate::message::Message;
1406 let mut state = GitKraft::new();
1407 state.active_tab_mut().repo_path =
1408 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1409 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::Commit {
1410 index: 0,
1411 oid: "abc123".to_string(),
1412 });
1413
1414 let _ = state.update(Message::ExecuteCommitAction(
1415 "abc123".to_string(),
1416 gitkraft_core::CommitAction::CherryPick,
1417 ));
1418
1419 assert!(state.active_tab().context_menu.is_none());
1420 }
1421
1422 #[test]
1423 fn execute_commit_action_sets_loading_when_repo_open() {
1424 use crate::message::Message;
1425 let mut state = GitKraft::new();
1426 state.active_tab_mut().repo_path =
1427 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1428
1429 let _ = state.update(Message::ExecuteCommitAction(
1430 "abc123".to_string(),
1431 gitkraft_core::CommitAction::ResetHard,
1432 ));
1433
1434 assert!(state.active_tab().is_loading);
1435 }
1436
1437 #[test]
1438 fn execute_commit_action_no_repo_does_not_set_loading() {
1439 use crate::message::Message;
1440 let mut state = GitKraft::new();
1441 let _ = state.update(Message::ExecuteCommitAction(
1444 "abc123".to_string(),
1445 gitkraft_core::CommitAction::CherryPick,
1446 ));
1447
1448 assert!(!state.active_tab().is_loading);
1449 }
1450
1451 #[test]
1452 fn execute_commit_action_sets_status_message_from_action_label() {
1453 use crate::message::Message;
1454 let mut state = GitKraft::new();
1455 state.active_tab_mut().repo_path =
1456 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1457
1458 let _ = state.update(Message::ExecuteCommitAction(
1459 "abc123".to_string(),
1460 gitkraft_core::CommitAction::Revert,
1461 ));
1462
1463 let status = state.active_tab().status_message.as_deref().unwrap_or("");
1464 assert!(
1466 status.contains("Revert commit"),
1467 "expected status to contain 'Revert commit', got: {status:?}"
1468 );
1469 }
1470
1471 #[test]
1474 fn file_history_defaults_empty() {
1475 let tab = RepoTab::new_empty();
1476 assert!(tab.file_history_path.is_none());
1477 assert!(tab.file_history_commits.is_empty());
1478 assert_eq!(tab.file_history_scroll, 0.0);
1479 }
1480
1481 #[test]
1482 fn blame_defaults_empty() {
1483 let tab = RepoTab::new_empty();
1484 assert!(tab.blame_path.is_none());
1485 assert!(tab.blame_lines.is_empty());
1486 assert_eq!(tab.blame_scroll, 0.0);
1487 }
1488
1489 #[test]
1490 fn pending_delete_file_defaults_none() {
1491 let tab = RepoTab::new_empty();
1492 assert!(tab.pending_delete_file.is_none());
1493 }
1494
1495 #[test]
1496 fn view_file_history_sets_path_and_clears_blame() {
1497 use crate::message::Message;
1498 let mut state = GitKraft::new();
1499 state.active_tab_mut().repo_path =
1500 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1501 state.active_tab_mut().blame_path = Some("some/file.rs".to_string());
1502
1503 let _ = state.update(Message::ViewFileHistory("src/main.rs".to_string()));
1504
1505 assert_eq!(
1506 state.active_tab().file_history_path.as_deref(),
1507 Some("src/main.rs")
1508 );
1509 assert!(state.active_tab().blame_path.is_none());
1511 }
1512
1513 #[test]
1514 fn close_file_history_clears_state() {
1515 use crate::message::Message;
1516 let mut state = GitKraft::new();
1517 state.active_tab_mut().file_history_path = Some("src/lib.rs".to_string());
1518 state.active_tab_mut().file_history_commits = vec![gitkraft_core::CommitInfo {
1519 oid: "abc".to_string(),
1520 short_oid: "abc".to_string(),
1521 summary: "s".to_string(),
1522 message: "s".to_string(),
1523 author_name: "a".to_string(),
1524 author_email: "a@b.com".to_string(),
1525 time: Default::default(),
1526 parent_ids: vec![],
1527 }];
1528
1529 let _ = state.update(Message::CloseFileHistory);
1530
1531 assert!(state.active_tab().file_history_path.is_none());
1532 assert!(state.active_tab().file_history_commits.is_empty());
1533 }
1534
1535 #[test]
1536 fn view_file_blame_sets_path_and_clears_history() {
1537 use crate::message::Message;
1538 let mut state = GitKraft::new();
1539 state.active_tab_mut().repo_path =
1540 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1541 state.active_tab_mut().file_history_path = Some("some/file.rs".to_string());
1542
1543 let _ = state.update(Message::ViewFileBlame("src/lib.rs".to_string()));
1544
1545 assert_eq!(state.active_tab().blame_path.as_deref(), Some("src/lib.rs"));
1546 assert!(state.active_tab().file_history_path.is_none());
1548 }
1549
1550 #[test]
1551 fn selecting_new_commit_closes_blame_overlay() {
1552 use crate::message::Message;
1553 let mut state = GitKraft::new();
1554 state.active_tab_mut().repo_path =
1555 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1556 state.active_tab_mut().commits = vec![
1558 gitkraft_core::CommitInfo {
1559 oid: "abc1".into(),
1560 short_oid: "abc1".into(),
1561 summary: "first".into(),
1562 message: "first".into(),
1563 author_name: "A".into(),
1564 author_email: "a@a.com".into(),
1565 time: Default::default(),
1566 parent_ids: Vec::new(),
1567 },
1568 gitkraft_core::CommitInfo {
1569 oid: "abc2".into(),
1570 short_oid: "abc2".into(),
1571 summary: "second".into(),
1572 message: "second".into(),
1573 author_name: "A".into(),
1574 author_email: "a@a.com".into(),
1575 time: Default::default(),
1576 parent_ids: Vec::new(),
1577 },
1578 ];
1579 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1581 state.active_tab_mut().blame_lines = vec![gitkraft_core::BlameLine {
1582 line_number: 1,
1583 content: "fn main() {}".into(),
1584 short_oid: "abc1".into(),
1585 oid: "abc1".into(),
1586 author_name: "A".into(),
1587 time: Default::default(),
1588 }];
1589
1590 let _ = state.update(Message::SelectCommit(1));
1592
1593 assert!(
1594 state.active_tab().blame_path.is_none(),
1595 "blame_path must be cleared when a new commit is selected"
1596 );
1597 assert!(
1598 state.active_tab().blame_lines.is_empty(),
1599 "blame_lines must be cleared when a new commit is selected"
1600 );
1601 }
1602
1603 #[test]
1604 fn close_file_blame_clears_state() {
1605 use crate::message::Message;
1606 let mut state = GitKraft::new();
1607 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1608
1609 let _ = state.update(Message::CloseFileBlame);
1610
1611 assert!(state.active_tab().blame_path.is_none());
1612 assert!(state.active_tab().blame_lines.is_empty());
1613 }
1614
1615 #[test]
1616 fn delete_file_sets_pending() {
1617 use crate::message::Message;
1618 let mut state = GitKraft::new();
1619
1620 let _ = state.update(Message::DeleteFile("src/old.rs".to_string()));
1621
1622 assert_eq!(
1623 state.active_tab().pending_delete_file.as_deref(),
1624 Some("src/old.rs")
1625 );
1626 assert!(state.active_tab().context_menu.is_none());
1627 }
1628
1629 #[test]
1630 fn cancel_delete_file_clears_pending() {
1631 use crate::message::Message;
1632 let mut state = GitKraft::new();
1633 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1634
1635 let _ = state.update(Message::CancelDeleteFile);
1636
1637 assert!(state.active_tab().pending_delete_file.is_none());
1638 }
1639
1640 #[test]
1641 fn confirm_delete_file_no_repo_is_noop() {
1642 use crate::message::Message;
1643 let mut state = GitKraft::new();
1644 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1645 let _ = state.update(Message::ConfirmDeleteFile);
1648
1649 assert!(!state.active_tab().is_loading);
1650 }
1651
1652 #[test]
1653 fn shift_arrow_down_extends_file_list_selection_when_files_loaded() {
1654 use crate::message::Message;
1655 let mut state = GitKraft::new();
1656 state.active_tab_mut().repo_path =
1657 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1658 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1659 state.active_tab_mut().selected_file_index = Some(0);
1660 state.active_tab_mut().anchor_file_index = Some(0);
1661 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1663
1664 let _ = state.update(Message::ShiftArrowDown);
1665
1666 assert_eq!(state.active_tab().selected_file_index, Some(1));
1667 assert!(state.active_tab().selected_commit_file_indices.contains(&0));
1669 assert!(state.active_tab().selected_commit_file_indices.contains(&1));
1670 }
1671
1672 #[test]
1673 fn shift_arrow_down_falls_through_to_commit_log_when_no_files() {
1674 use crate::message::Message;
1675 let mut state = GitKraft::new();
1676 state.active_tab_mut().commits = make_test_commits(5);
1677 state.active_tab_mut().selected_commit = Some(1);
1678 state.active_tab_mut().anchor_commit_index = Some(1);
1679 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1680 let _ = state.update(Message::ShiftArrowDown);
1683
1684 assert_eq!(state.active_tab().selected_commit, Some(2));
1685 assert!(state.active_tab().selected_commits.contains(&1));
1686 assert!(state.active_tab().selected_commits.contains(&2));
1687 }
1688
1689 #[test]
1690 fn file_system_changed_triggers_full_refresh() {
1691 use crate::message::Message;
1692 let mut state = GitKraft::new();
1693 state.active_tab_mut().repo_path =
1694 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1695
1696 let _task = state.update(Message::FileSystemChanged);
1700
1701 assert!(
1705 state.active_tab().error_message.is_none(),
1706 "FileSystemChanged must not set an error message"
1707 );
1708 }
1709
1710 fn fake_payload(workdir: &str) -> crate::message::RepoPayload {
1714 gitkraft_core::RepoSnapshot {
1715 info: gitkraft_core::RepoInfo {
1716 path: std::path::PathBuf::from(format!("{workdir}/.git")),
1717 workdir: Some(std::path::PathBuf::from(workdir)),
1718 head_branch: Some("main".into()),
1719 is_bare: false,
1720 state: gitkraft_core::RepoState::Clean,
1721 },
1722 branches: Vec::new(),
1723 commits: Vec::new(),
1724 graph_rows: Vec::new(),
1725 unstaged: Vec::new(),
1726 staged: Vec::new(),
1727 stashes: Vec::new(),
1728 remotes: Vec::new(),
1729 }
1730 }
1731
1732 fn setup_loaded_tab(tab: &mut RepoTab, path: &str) {
1734 tab.repo_path = Some(std::path::PathBuf::from(path));
1735 tab.repo_info = Some(gitkraft_core::RepoInfo {
1736 path: std::path::PathBuf::from(format!("{path}/.git")),
1737 workdir: Some(std::path::PathBuf::from(path)),
1738 head_branch: Some("main".into()),
1739 is_bare: false,
1740 state: gitkraft_core::RepoState::Clean,
1741 });
1742 }
1743
1744 #[test]
1745 fn open_repo_creates_new_tab_when_repo_already_open() {
1746 use crate::message::Message;
1747 let mut state = GitKraft::new();
1748 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1749
1750 assert_eq!(state.tabs.len(), 1);
1751 assert_eq!(state.active_tab, 0);
1752
1753 let _task = state.update(Message::OpenRepo);
1755
1756 assert_eq!(state.tabs.len(), 2);
1757 assert_eq!(state.active_tab, 1);
1758 assert!(state.tabs[1].is_loading);
1760 assert_eq!(
1762 state.tabs[0].repo_path.as_deref(),
1763 Some(std::path::Path::new("/home/user/repo-a"))
1764 );
1765 }
1766
1767 #[test]
1768 fn open_repo_reuses_empty_tab() {
1769 use crate::message::Message;
1770 let mut state = GitKraft::new();
1771 assert!(!state.active_tab().has_repo());
1773
1774 let _task = state.update(Message::OpenRepo);
1775
1776 assert_eq!(state.tabs.len(), 1);
1778 assert_eq!(state.active_tab, 0);
1779 assert!(state.tabs[0].is_loading);
1780 }
1781
1782 #[test]
1783 fn repo_selected_deduplicates_already_open_repo() {
1784 use crate::message::Message;
1785 let mut state = GitKraft::new();
1786 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1788 state.tabs.push(RepoTab::new_empty());
1790 state.active_tab = 1;
1791
1792 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
1794 "/home/user/repo-a",
1795 ))));
1796
1797 assert_eq!(state.tabs.len(), 1);
1799 assert_eq!(state.active_tab, 0);
1800 assert_eq!(
1801 state.tabs[0].repo_path.as_deref(),
1802 Some(std::path::Path::new("/home/user/repo-a"))
1803 );
1804 }
1805
1806 #[test]
1807 fn repo_selected_opens_new_repo_in_empty_tab() {
1808 use crate::message::Message;
1809 let mut state = GitKraft::new();
1810 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1812 state.tabs.push(RepoTab::new_empty());
1814 state.active_tab = 1;
1815
1816 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
1818 "/home/user/repo-b",
1819 ))));
1820
1821 assert_eq!(state.tabs.len(), 2);
1823 assert_eq!(state.active_tab, 1);
1824 assert!(state.tabs[1]
1825 .status_message
1826 .as_deref()
1827 .unwrap_or("")
1828 .contains("repo-b"));
1829 }
1830
1831 #[test]
1832 fn repo_selected_cancel_removes_empty_tab() {
1833 use crate::message::Message;
1834 let mut state = GitKraft::new();
1835 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1837 state.tabs.push(RepoTab::new_empty());
1839 state.active_tab = 1;
1840
1841 let _task = state.update(Message::RepoSelected(None));
1843
1844 assert_eq!(state.tabs.len(), 1);
1846 assert_eq!(state.active_tab, 0);
1847 assert_eq!(
1848 state.tabs[0].repo_path.as_deref(),
1849 Some(std::path::Path::new("/home/user/repo-a"))
1850 );
1851 }
1852
1853 #[test]
1854 fn repo_selected_cancel_keeps_tab_if_only_one() {
1855 use crate::message::Message;
1856 let mut state = GitKraft::new();
1857 assert_eq!(state.tabs.len(), 1);
1859 assert!(!state.active_tab().has_repo());
1860
1861 let _task = state.update(Message::RepoSelected(None));
1862
1863 assert_eq!(state.tabs.len(), 1);
1864 assert!(!state.active_tab().is_loading);
1865 }
1866
1867 #[test]
1868 fn open_recent_repo_deduplicates() {
1869 use crate::message::Message;
1870 let mut state = GitKraft::new();
1871 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1873 state.tabs.push(RepoTab::new_empty());
1875 state.active_tab = 1;
1876
1877 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
1879 "/home/user/repo-a",
1880 )));
1881
1882 assert_eq!(state.active_tab, 0);
1883 }
1884
1885 #[test]
1886 fn open_recent_repo_creates_new_tab_when_current_has_repo() {
1887 use crate::message::Message;
1888 let mut state = GitKraft::new();
1889 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1891
1892 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
1894 "/home/user/repo-b",
1895 )));
1896
1897 assert_eq!(state.tabs.len(), 2);
1898 assert_eq!(state.active_tab, 1);
1899 assert!(state.tabs[1].is_loading);
1900 }
1901
1902 #[test]
1903 fn open_recent_repo_uses_empty_tab() {
1904 use crate::message::Message;
1905 let mut state = GitKraft::new();
1906 assert!(!state.active_tab().has_repo());
1908
1909 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
1910 "/home/user/repo-b",
1911 )));
1912
1913 assert_eq!(state.tabs.len(), 1);
1915 assert_eq!(state.active_tab, 0);
1916 assert!(state.tabs[0].is_loading);
1917 }
1918
1919 #[test]
1922 fn repo_refreshed_targets_correct_tab_after_tab_switch() {
1923 use crate::message::Message;
1924 let mut state = GitKraft::new();
1925 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1927 state.tabs.push(RepoTab::new_empty());
1929 state.active_tab = 1; let payload = fake_payload("/home/user/repo-a");
1933 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
1934
1935 assert!(
1937 state.tabs[0].repo_info.is_some(),
1938 "tab 0 should still have repo info after refresh"
1939 );
1940 assert_eq!(
1941 state.tabs[0].current_branch.as_deref(),
1942 Some("main"),
1943 "tab 0 should have updated branch from payload"
1944 );
1945 assert!(
1947 state.tabs[1].repo_info.is_none(),
1948 "tab 1 (empty) must NOT receive the refresh payload"
1949 );
1950 assert!(
1951 state.tabs[1].repo_path.is_none(),
1952 "tab 1 should still have no repo path"
1953 );
1954 }
1955
1956 #[test]
1957 fn repo_refreshed_targets_active_tab_for_new_open() {
1958 use crate::message::Message;
1959 let mut state = GitKraft::new();
1960 assert_eq!(state.tabs.len(), 1);
1964 assert!(!state.active_tab().has_repo());
1965
1966 let payload = fake_payload("/home/user/new-repo");
1967 let _task = state.update(Message::RepoOpened(Ok(payload)));
1968
1969 assert_eq!(
1971 state.tabs[0].repo_path.as_deref(),
1972 Some(std::path::Path::new("/home/user/new-repo"))
1973 );
1974 assert!(state.tabs[0].repo_info.is_some());
1975 }
1976
1977 #[test]
1978 fn repo_refreshed_does_not_duplicate_into_new_tab() {
1979 use crate::message::Message;
1980 let mut state = GitKraft::new();
1981 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1983
1984 let _task = state.update(Message::NewTab);
1986 assert_eq!(state.tabs.len(), 2);
1987 assert_eq!(state.active_tab, 1);
1988
1989 let payload = fake_payload("/home/user/repo-a");
1991 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
1992
1993 assert!(
1995 state.tabs[1].repo_path.is_none(),
1996 "new empty tab must not receive repo-a refresh"
1997 );
1998 assert!(
1999 state.tabs[1].repo_info.is_none(),
2000 "new empty tab must not have repo_info"
2001 );
2002 assert_eq!(
2004 state.tabs[0].repo_path.as_deref(),
2005 Some(std::path::Path::new("/home/user/repo-a"))
2006 );
2007 }
2008
2009 #[test]
2010 fn git_operation_result_targets_correct_tab() {
2011 use crate::message::Message;
2012 let mut state = GitKraft::new();
2013 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2015 state.tabs.push(RepoTab::new_empty());
2017 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2018 state.active_tab = 1;
2019
2020 let payload = fake_payload("/home/user/repo-a");
2023 let _task = state.update(Message::GitOperationResult(Ok(payload)));
2024
2025 assert_eq!(state.tabs[0].current_branch.as_deref(), Some("main"));
2027 assert_eq!(
2029 state.tabs[1].repo_path.as_deref(),
2030 Some(std::path::Path::new("/home/user/repo-b"))
2031 );
2032 }
2033
2034 #[test]
2035 fn multiple_new_tabs_dont_get_polluted_by_refresh() {
2036 use crate::message::Message;
2037 let mut state = GitKraft::new();
2038 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2040
2041 let _task = state.update(Message::NewTab);
2043 let _task = state.update(Message::NewTab);
2044 assert_eq!(state.tabs.len(), 3);
2045 assert_eq!(state.active_tab, 2);
2046
2047 let payload = fake_payload("/home/user/repo-a");
2049 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2050
2051 assert!(state.tabs[0].repo_info.is_some());
2053 assert!(state.tabs[1].repo_info.is_none());
2054 assert!(state.tabs[2].repo_info.is_none());
2055 assert!(state.tabs[1].repo_path.is_none());
2056 assert!(state.tabs[2].repo_path.is_none());
2057 }
2058
2059 #[test]
2060 fn repo_selected_dedup_adjusts_index_when_existing_is_after_active() {
2061 use crate::message::Message;
2062 let mut state = GitKraft::new();
2063 state.tabs.push(RepoTab::new_empty());
2066 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
2067 state.active_tab = 0;
2068
2069 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2071 "/home/user/repo-a",
2072 ))));
2073
2074 assert_eq!(state.tabs.len(), 1);
2076 assert_eq!(state.active_tab, 0);
2077 assert_eq!(
2078 state.tabs[0].repo_path.as_deref(),
2079 Some(std::path::Path::new("/home/user/repo-a"))
2080 );
2081 }
2082}