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 let prev_anchor_oid = self
301 .anchor_commit_index
302 .and_then(|i| self.commits.get(i).map(|c| c.oid.clone()));
303 let prev_selected_oids: Vec<String> = self
304 .selected_commits
305 .iter()
306 .filter_map(|&i| self.commits.get(i).map(|c| c.oid.clone()))
307 .collect();
308
309 self.current_branch = payload.info.head_branch.clone();
310 self.repo_path = Some(path);
311 self.repo_info = Some(payload.info);
312 self.branches = payload.branches;
313 self.commits = payload.commits;
314 self.graph_rows = payload.graph_rows;
315 self.unstaged_changes = payload.unstaged;
316 self.staged_changes = payload.staged;
317 self.stashes = payload.stashes;
318 self.remotes = payload.remotes;
319
320 self.selected_commit = None;
324 self.anchor_commit_index = None;
325 self.selected_commits.clear();
326 self.selected_commit_oid = None;
327 self.commit_message.clear();
328 self.error_message = None;
329 self.status_message = Some("Repository loaded.".into());
330 self.commit_scroll_offset = 0.0;
331 self.has_more_commits = true;
332 self.is_loading_more_commits = false;
333 self.selected_unstaged.clear();
334 self.selected_staged.clear();
335 self.anchor_file_index = None;
336 self.selected_commit_file_indices.clear();
337 self.multi_file_diffs.clear();
338 self.commit_range_diffs.clear();
339
340 if let Some(oid) = prev_oid {
346 if let Some(new_idx) = self.commits.iter().position(|c| c.oid == oid) {
347 self.selected_commit = Some(new_idx);
348 self.selected_commit_oid = Some(oid);
349 } else {
353 self.selected_diff = None;
355 self.commit_files.clear();
356 self.selected_file_index = None;
357 self.is_loading_file_diff = false;
358 self.diff_scroll_offset = 0.0;
359 }
360 } else {
361 self.selected_diff = None;
363 self.commit_files.clear();
364 self.selected_file_index = None;
365 self.is_loading_file_diff = false;
366 self.diff_scroll_offset = 0.0;
367 }
368
369 if let Some(anchor_oid) = prev_anchor_oid {
373 if let Some(new_anchor) = self.commits.iter().position(|c| c.oid == anchor_oid) {
374 self.anchor_commit_index = Some(new_anchor);
375 }
376 }
377 if !prev_selected_oids.is_empty() {
378 let restored: Vec<usize> = prev_selected_oids
379 .iter()
380 .filter_map(|oid| self.commits.iter().position(|c| &c.oid == oid))
381 .collect();
382 if !restored.is_empty() {
383 self.selected_commits = restored;
384 self.selected_commits.sort_unstable();
385 }
386 }
387 }
388}
389
390pub struct GitKraft {
394 pub tabs: Vec<RepoTab>,
397 pub active_tab: usize,
399
400 pub sidebar_expanded: bool,
403
404 pub sidebar_width: f32,
407 pub commit_log_width: f32,
409 pub staging_height: f32,
411 pub diff_file_list_width: f32,
413
414 pub ui_scale: f32,
416
417 pub dragging: Option<DragTarget>,
420 pub dragging_h: Option<DragTargetH>,
422 pub drag_start_x: f32,
424 pub drag_start_y: f32,
426 pub drag_initialized: bool,
430 pub drag_initialized_h: bool,
432
433 pub cursor_pos: Point,
438
439 pub current_theme_index: usize,
442
443 pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
446
447 pub search_visible: bool,
450 pub search_query: String,
452 pub search_results: Vec<gitkraft_core::CommitInfo>,
454 pub search_selected: Option<usize>,
456
457 pub search_diff_files: Vec<gitkraft_core::DiffFileEntry>,
459 pub search_diff_selected: HashSet<usize>,
461 pub search_diff_content: Vec<gitkraft_core::DiffInfo>,
463 pub search_diff_oid: Option<String>,
465
466 pub editor: gitkraft_core::Editor,
468
469 pub keyboard_modifiers: iced::keyboard::Modifiers,
471
472 pub animation_tick: u64,
475
476 pub window_width: f32,
479 pub window_height: f32,
481 pub window_x: f32,
483 pub window_y: f32,
485}
486
487impl Default for GitKraft {
488 fn default() -> Self {
489 Self::new()
490 }
491}
492
493impl GitKraft {
494 fn from_settings(settings: gitkraft_core::AppSettings) -> Self {
500 let current_theme_index = settings
501 .theme_name
502 .as_deref()
503 .map(gitkraft_core::theme_index_by_name)
504 .unwrap_or(0);
505
506 let recent_repos = settings.recent_repos;
507
508 let (
509 sidebar_width,
510 commit_log_width,
511 staging_height,
512 diff_file_list_width,
513 sidebar_expanded,
514 ui_scale,
515 ) = if let Some(ref layout) = settings.layout {
516 (
517 layout.sidebar_width.unwrap_or(220.0),
518 layout.commit_log_width.unwrap_or(500.0),
519 layout.staging_height.unwrap_or(200.0),
520 layout.diff_file_list_width.unwrap_or(180.0),
521 layout.sidebar_expanded.unwrap_or(true),
522 layout.ui_scale.unwrap_or(1.0),
523 )
524 } else {
525 (220.0, 500.0, 200.0, 180.0, true, 1.0)
526 };
527
528 Self {
529 tabs: vec![RepoTab::new_empty()],
530 active_tab: 0,
531
532 sidebar_expanded,
533
534 sidebar_width,
535 commit_log_width,
536 staging_height,
537 diff_file_list_width,
538
539 ui_scale,
540
541 dragging: None,
542 dragging_h: None,
543 drag_start_x: 0.0,
544 drag_start_y: 0.0,
545 drag_initialized: false,
546 drag_initialized_h: false,
547 cursor_pos: Point::ORIGIN,
548
549 current_theme_index,
550
551 recent_repos,
552
553 search_visible: false,
554 search_query: String::new(),
555 search_results: Vec::new(),
556 search_selected: None,
557 search_diff_files: Vec::new(),
558 search_diff_selected: HashSet::new(),
559 search_diff_content: Vec::new(),
560 search_diff_oid: None,
561
562 keyboard_modifiers: iced::keyboard::Modifiers::default(),
563 animation_tick: 0,
564
565 window_width: settings
566 .layout
567 .as_ref()
568 .and_then(|l| l.window_width)
569 .unwrap_or(1400.0),
570 window_height: settings
571 .layout
572 .as_ref()
573 .and_then(|l| l.window_height)
574 .unwrap_or(800.0),
575 window_x: settings
576 .layout
577 .as_ref()
578 .and_then(|l| l.window_x)
579 .unwrap_or(0.0),
580 window_y: settings
581 .layout
582 .as_ref()
583 .and_then(|l| l.window_y)
584 .unwrap_or(0.0),
585
586 editor: settings
587 .editor_name
588 .as_deref()
589 .map(|name| {
590 gitkraft_core::EDITOR_NAMES
592 .iter()
593 .position(|n| n.eq_ignore_ascii_case(name))
594 .map(gitkraft_core::Editor::from_index)
595 .unwrap_or_else(|| {
596 if name.eq_ignore_ascii_case("none") {
597 gitkraft_core::Editor::None
598 } else {
599 gitkraft_core::Editor::Custom(name.to_string())
600 }
601 })
602 })
603 .unwrap_or_else(detect_system_editor),
604 }
605 }
606
607 pub fn new() -> Self {
613 Self::from_settings(
614 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default(),
615 )
616 }
617
618 pub fn new_with_session_paths() -> (Self, Vec<PathBuf>) {
624 let settings =
625 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default();
626 let open_tabs = settings.open_tabs.clone();
627 let active_tab_index = settings.active_tab_index;
628
629 let mut state = Self::from_settings(settings);
630
631 if !open_tabs.is_empty() {
632 state.tabs = open_tabs
633 .iter()
634 .map(|path| {
635 let mut tab = RepoTab::new_empty();
636 tab.repo_path = Some(path.clone());
639 if path.exists() {
640 tab.is_loading = true;
641 tab.status_message = Some(format!(
642 "Loading {}…",
643 path.file_name().unwrap_or_default().to_string_lossy()
644 ));
645 } else {
646 tab.error_message =
647 Some(format!("Repository not found: {}", path.display()));
648 }
649 tab
650 })
651 .collect();
652 state.active_tab = active_tab_index.min(state.tabs.len().saturating_sub(1));
653 }
654
655 (state, open_tabs)
656 }
657
658 pub fn open_tab_paths(&self) -> Vec<PathBuf> {
661 self.tabs
662 .iter()
663 .filter(|t| t.repo_info.is_some())
664 .filter_map(|t| t.repo_path.clone())
665 .collect()
666 }
667
668 pub fn active_tab(&self) -> &RepoTab {
670 &self.tabs[self.active_tab]
671 }
672
673 pub fn active_tab_mut(&mut self) -> &mut RepoTab {
675 &mut self.tabs[self.active_tab]
676 }
677
678 pub fn has_repo(&self) -> bool {
680 self.active_tab().has_repo()
681 }
682
683 pub fn repo_display_name(&self) -> &str {
685 self.active_tab().display_name()
686 }
687
688 pub fn colors(&self) -> ThemeColors {
695 ThemeColors::from_core(&gitkraft_core::theme_by_index(self.current_theme_index))
696 }
697
698 pub fn iced_theme(&self) -> iced::Theme {
707 let core = gitkraft_core::theme_by_index(self.current_theme_index);
708 let name = self.current_theme_name().to_string();
709
710 let palette = iced::theme::Palette {
711 background: rgb_to_iced(core.background),
712 text: rgb_to_iced(core.text_primary),
713 primary: rgb_to_iced(core.accent),
714 success: rgb_to_iced(core.success),
715 warning: rgb_to_iced(core.warning),
716 danger: rgb_to_iced(core.error),
717 };
718
719 iced::Theme::custom(name, palette)
720 }
721
722 pub fn current_theme_name(&self) -> &'static str {
724 gitkraft_core::THEME_NAMES
725 .get(self.current_theme_index)
726 .copied()
727 .unwrap_or("Default")
728 }
729
730 pub fn refresh_active_tab(&mut self) -> Task<Message> {
734 match self.active_tab().repo_path.clone() {
735 Some(path) => crate::features::repo::commands::refresh_repo(path),
736 None => Task::none(),
737 }
738 }
739
740 pub fn on_ok_refresh(
747 &mut self,
748 result: Result<(), String>,
749 ok_msg: &str,
750 err_prefix: &str,
751 ) -> Task<Message> {
752 match result {
753 Ok(()) => {
754 {
755 let tab = self.active_tab_mut();
756 tab.is_loading = false;
757 tab.status_message = Some(ok_msg.to_string());
758 }
759 self.refresh_active_tab()
760 }
761 Err(e) => {
762 let tab = self.active_tab_mut();
763 tab.is_loading = false;
764 tab.error_message = Some(format!("{err_prefix}: {e}"));
765 tab.status_message = None;
766 Task::none()
767 }
768 }
769 }
770
771 pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
773 gitkraft_core::LayoutSettings {
774 sidebar_width: Some(self.sidebar_width),
775 commit_log_width: Some(self.commit_log_width),
776 staging_height: Some(self.staging_height),
777 diff_file_list_width: Some(self.diff_file_list_width),
778 sidebar_expanded: Some(self.sidebar_expanded),
779 ui_scale: Some(self.ui_scale),
780 window_width: Some(self.window_width),
781 window_height: Some(self.window_height),
782 window_x: Some(self.window_x),
783 window_y: Some(self.window_y),
784 window_maximized: None, }
786 }
787}
788
789fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
791 Color::from_rgb8(rgb.r, rgb.g, rgb.b)
792}
793
794fn detect_system_editor() -> gitkraft_core::Editor {
796 for var in ["VISUAL", "EDITOR"] {
797 if let Ok(val) = std::env::var(var) {
798 let bin = val.split('/').next_back().unwrap_or(&val).trim();
799 return match bin {
800 "nvim" | "neovim" => gitkraft_core::Editor::Neovim,
801 "vim" => gitkraft_core::Editor::Vim,
802 "hx" | "helix" => gitkraft_core::Editor::Helix,
803 "nano" => gitkraft_core::Editor::Nano,
804 "micro" => gitkraft_core::Editor::Micro,
805 "emacs" => gitkraft_core::Editor::Emacs,
806 "code" => gitkraft_core::Editor::VSCode,
807 "zed" => gitkraft_core::Editor::Zed,
808 "subl" => gitkraft_core::Editor::Sublime,
809 _ => gitkraft_core::Editor::Custom(val),
810 };
811 }
812 }
813 gitkraft_core::Editor::None
814}
815
816#[cfg(test)]
819mod tests {
820 use super::*;
821
822 #[test]
823 fn new_defaults() {
824 let state = GitKraft::new();
825 assert!(state.active_tab().repo_path.is_none());
826 assert!(!state.has_repo());
827 assert_eq!(state.repo_display_name(), "New Tab");
828 assert!(state.active_tab().commits.is_empty());
829 assert!(state.sidebar_expanded);
830 assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
832 assert!(state.sidebar_width > 0.0);
834 assert!(state.commit_log_width > 0.0);
835 assert!(state.staging_height > 0.0);
836 assert!(state.dragging.is_none());
837 assert!(state.dragging_h.is_none());
838 assert_eq!(state.tabs.len(), 1);
840 assert_eq!(state.active_tab, 0);
841 }
842
843 #[test]
844 fn repo_display_name_extracts_basename() {
845 let mut state = GitKraft::new();
846 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
847 assert_eq!(state.repo_display_name(), "my-project");
848 }
849
850 #[test]
851 fn colors_returns_theme_colors() {
852 let state = GitKraft::new();
853 let c = state.colors();
854 assert!(c.bg.r < 0.5);
856 }
857
858 #[test]
859 fn iced_theme_is_custom_with_correct_palette() {
860 let mut state = GitKraft::new();
861
862 state.current_theme_index = 0;
864 let iced_t = state.iced_theme();
865 let pal = iced_t.palette();
866 assert!(pal.background.r < 0.5, "Default theme bg should be dark");
867 assert_eq!(iced_t.to_string(), "Default");
868
869 state.current_theme_index = 11;
871 let iced_t = state.iced_theme();
872 let pal = iced_t.palette();
873 assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
874 assert_eq!(iced_t.to_string(), "Solarized Light");
875
876 state.current_theme_index = 12;
878 let iced_t = state.iced_theme();
879 let pal = iced_t.palette();
880 let core = gitkraft_core::theme_by_index(12);
881 let expected_accent = rgb_to_iced(core.accent);
882 assert!(
883 (pal.primary.r - expected_accent.r).abs() < 0.01
884 && (pal.primary.g - expected_accent.g).abs() < 0.01
885 && (pal.primary.b - expected_accent.b).abs() < 0.01,
886 "Gruvbox Dark accent should match core accent"
887 );
888 }
889
890 #[test]
891 fn iced_theme_name_round_trips_through_core() {
892 for i in 0..gitkraft_core::THEME_COUNT {
895 let mut state = GitKraft::new();
896 state.current_theme_index = i;
897 let iced_t = state.iced_theme();
898 let name = iced_t.to_string();
899 let resolved = gitkraft_core::theme_index_by_name(&name);
900 assert_eq!(
901 resolved,
902 i,
903 "theme index {i} ({}) did not round-trip through iced_theme name",
904 gitkraft_core::THEME_NAMES[i]
905 );
906 }
907 }
908
909 #[test]
910 fn current_theme_name_round_trips() {
911 let mut state = GitKraft::new();
912 state.current_theme_index = 8;
913 assert_eq!(state.current_theme_name(), "Dracula");
914 state.current_theme_index = 0;
915 assert_eq!(state.current_theme_name(), "Default");
916 }
917
918 #[test]
919 fn repo_tab_new_empty() {
920 let tab = RepoTab::new_empty();
921 assert!(tab.repo_path.is_none());
922 assert!(!tab.has_repo());
923 assert_eq!(tab.display_name(), "New Tab");
924 assert!(tab.commits.is_empty());
925 assert!(tab.branches.is_empty());
926 assert!(!tab.is_loading);
927 }
928
929 #[test]
930 fn repo_tab_display_name_with_path() {
931 let mut tab = RepoTab::new_empty();
932 tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
933 assert!(tab.has_repo());
934 assert_eq!(tab.display_name(), "cool-repo");
935 }
936
937 #[test]
938 fn search_defaults() {
939 let state = GitKraft::new();
940 assert!(!state.search_visible);
941 assert!(state.search_query.is_empty());
942 assert!(state.search_results.is_empty());
943 assert!(state.search_selected.is_none());
944 }
945
946 #[test]
947 fn context_menu_variants_exist() {
948 use crate::state::ContextMenu;
950
951 let _branch = ContextMenu::Branch {
952 name: "main".to_string(),
953 is_current: true,
954 local_index: 0,
955 };
956 let _remote = ContextMenu::RemoteBranch {
957 name: "origin/main".to_string(),
958 };
959 let _commit = ContextMenu::Commit {
960 index: 0,
961 oid: "abc1234".to_string(),
962 };
963 let _stash = ContextMenu::Stash { index: 0 };
964 let _unstaged = ContextMenu::UnstagedFile {
965 path: "src/main.rs".to_string(),
966 };
967 let _staged = ContextMenu::StagedFile {
968 path: "src/lib.rs".to_string(),
969 };
970 }
971
972 #[test]
973 fn repo_tab_context_menu_defaults_to_none() {
974 let tab = crate::state::RepoTab::new_empty();
975 assert!(tab.context_menu.is_none());
976 }
977
978 #[test]
979 fn context_menu_variants_constructable() {
980 use crate::state::ContextMenu;
981 let _ = ContextMenu::Stash { index: 0 };
982 let _ = ContextMenu::UnstagedFile {
983 path: "a.rs".into(),
984 };
985 let _ = ContextMenu::StagedFile {
986 path: "b.rs".into(),
987 };
988 }
989
990 #[test]
991 fn selected_unstaged_defaults_empty() {
992 let tab = crate::state::RepoTab::new_empty();
993 assert!(tab.selected_unstaged.is_empty());
994 assert!(tab.selected_staged.is_empty());
995 }
996
997 #[test]
998 fn selected_unstaged_toggle() {
999 let mut tab = crate::state::RepoTab::new_empty();
1000 tab.selected_unstaged.insert("a.rs".to_string());
1001 tab.selected_unstaged.insert("b.rs".to_string());
1002 assert_eq!(tab.selected_unstaged.len(), 2);
1003 assert!(tab.selected_unstaged.contains("a.rs"));
1004 tab.selected_unstaged.remove("a.rs");
1005 assert_eq!(tab.selected_unstaged.len(), 1);
1006 assert!(!tab.selected_unstaged.contains("a.rs"));
1007 }
1008
1009 #[test]
1010 fn detect_system_editor_returns_valid() {
1011 let editor = super::detect_system_editor();
1013 let _ = editor.display_name();
1014 }
1015
1016 #[test]
1019 fn selected_commit_file_indices_defaults_to_empty_vec() {
1020 let tab = RepoTab::new_empty();
1021 assert!(tab.selected_commit_file_indices.is_empty());
1022 let v: &Vec<usize> = &tab.selected_commit_file_indices;
1024 assert_eq!(v.len(), 0);
1025 }
1026
1027 #[test]
1028 fn multi_file_diffs_defaults_empty() {
1029 let tab = RepoTab::new_empty();
1030 assert!(tab.multi_file_diffs.is_empty());
1031 }
1032
1033 #[test]
1034 fn keyboard_modifiers_default_has_no_shift() {
1035 let state = GitKraft::new();
1036 assert!(!state.keyboard_modifiers.shift());
1037 }
1038
1039 #[test]
1040 fn selected_commit_file_indices_preserves_insertion_order() {
1041 let mut tab = RepoTab::new_empty();
1042 tab.selected_commit_file_indices.push(5);
1043 tab.selected_commit_file_indices.push(2);
1044 tab.selected_commit_file_indices.push(8);
1045 assert_eq!(tab.selected_commit_file_indices, vec![5, 2, 8]);
1046 }
1047
1048 #[test]
1049 fn selected_commit_file_indices_cleared_on_reset() {
1050 let mut tab = RepoTab::new_empty();
1051 tab.selected_commit_file_indices.push(0);
1052 tab.selected_commit_file_indices.push(1);
1053 tab.selected_commit_file_indices.clear();
1054 assert!(tab.selected_commit_file_indices.is_empty());
1055 }
1056
1057 #[test]
1058 fn multi_file_diffs_cleared_on_reset() {
1059 let mut tab = RepoTab::new_empty();
1060 tab.multi_file_diffs.push(gitkraft_core::DiffInfo {
1061 old_file: String::new(),
1062 new_file: "a.rs".to_string(),
1063 status: gitkraft_core::FileStatus::Modified,
1064 hunks: vec![],
1065 });
1066 tab.multi_file_diffs.clear();
1067 assert!(tab.multi_file_diffs.is_empty());
1068 }
1069
1070 #[test]
1071 fn commit_range_diffs_defaults_empty() {
1072 let tab = RepoTab::new_empty();
1073 assert!(tab.commit_range_diffs.is_empty());
1074 }
1075
1076 #[test]
1077 fn commit_range_diffs_cleared_on_apply_payload() {
1078 let mut tab = RepoTab::new_empty();
1080 tab.commit_range_diffs.push(gitkraft_core::DiffInfo {
1081 old_file: String::new(),
1082 new_file: "x.rs".to_string(),
1083 status: gitkraft_core::FileStatus::Modified,
1084 hunks: vec![],
1085 });
1086 tab.commit_range_diffs.clear();
1087 assert!(tab.commit_range_diffs.is_empty());
1088 }
1089
1090 #[test]
1093 fn modifiers_changed_sets_shift_state() {
1094 use crate::message::Message;
1095 let mut state = GitKraft::new();
1096 assert!(!state.keyboard_modifiers.shift());
1097
1098 let _ = state.update(Message::ModifiersChanged(iced::keyboard::Modifiers::SHIFT));
1099 assert!(state.keyboard_modifiers.shift());
1100
1101 let _ = state.update(Message::ModifiersChanged(
1102 iced::keyboard::Modifiers::default(),
1103 ));
1104 assert!(!state.keyboard_modifiers.shift());
1105 }
1106
1107 fn make_commit_files(names: &[&str]) -> Vec<gitkraft_core::DiffFileEntry> {
1110 names
1111 .iter()
1112 .map(|name| gitkraft_core::DiffFileEntry {
1113 old_file: String::new(),
1114 new_file: name.to_string(),
1115 status: gitkraft_core::FileStatus::Modified,
1116 })
1117 .collect()
1118 }
1119
1120 #[test]
1121 fn select_diff_by_index_regular_click_clears_multi_selection() {
1122 use crate::message::Message;
1123 let mut state = GitKraft::new();
1124 state.active_tab_mut().repo_path =
1128 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1129 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1130 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1131 state.active_tab_mut().selected_commit_file_indices = vec![0, 1];
1133
1134 let _ = state.update(Message::SelectDiffByIndex(0));
1136
1137 assert!(state.active_tab().selected_commit_file_indices.is_empty());
1138 assert!(state.active_tab().multi_file_diffs.is_empty());
1139 assert_eq!(state.active_tab().selected_file_index, Some(0));
1140 }
1141
1142 #[test]
1143 fn select_diff_by_index_shift_click_adds_both_files_to_selection() {
1144 use crate::message::Message;
1145 let mut state = GitKraft::new();
1146 state.active_tab_mut().repo_path =
1147 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1148 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1149 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1150 state.active_tab_mut().selected_file_index = Some(0);
1151
1152 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1154 let _ = state.update(Message::SelectDiffByIndex(1));
1155
1156 let sel = &state.active_tab().selected_commit_file_indices;
1157 assert!(sel.contains(&0), "anchor file 0 should be selected");
1158 assert!(sel.contains(&1), "newly clicked file 1 should be selected");
1159 assert_eq!(sel.len(), 2);
1160 }
1161
1162 #[test]
1163 fn anchor_file_index_defaults_to_none() {
1164 let tab = RepoTab::new_empty();
1165 assert!(tab.anchor_file_index.is_none());
1166 }
1167
1168 #[test]
1169 fn regular_click_sets_anchor() {
1170 use crate::message::Message;
1171 let mut state = GitKraft::new();
1172 state.active_tab_mut().repo_path =
1173 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1174 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1175 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1176
1177 let _ = state.update(Message::SelectDiffByIndex(2));
1178
1179 assert_eq!(
1180 state.active_tab().anchor_file_index,
1181 Some(2),
1182 "regular click must set anchor to the clicked index"
1183 );
1184 }
1185
1186 #[test]
1187 fn shift_click_selects_range_downward_from_anchor() {
1188 use crate::message::Message;
1189 let mut state = GitKraft::new();
1190 state.active_tab_mut().repo_path =
1191 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1192 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1193 state.active_tab_mut().commit_files =
1194 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1195 state.active_tab_mut().anchor_file_index = Some(1);
1197 state.active_tab_mut().selected_file_index = Some(1);
1198
1199 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1201 let _ = state.update(Message::SelectDiffByIndex(4));
1202
1203 let sel = &state.active_tab().selected_commit_file_indices;
1204 assert_eq!(
1205 sel,
1206 &vec![1, 2, 3, 4],
1207 "range must be contiguous from anchor to click"
1208 );
1209 }
1210
1211 #[test]
1212 fn shift_click_selects_range_upward_from_anchor() {
1213 use crate::message::Message;
1214 let mut state = GitKraft::new();
1215 state.active_tab_mut().repo_path =
1216 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1217 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1218 state.active_tab_mut().commit_files =
1219 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1220 state.active_tab_mut().anchor_file_index = Some(4);
1222 state.active_tab_mut().selected_file_index = Some(4);
1223
1224 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1226 let _ = state.update(Message::SelectDiffByIndex(1));
1227
1228 let sel = &state.active_tab().selected_commit_file_indices;
1229 assert_eq!(
1230 sel,
1231 &vec![1, 2, 3, 4],
1232 "range must be stored ascending regardless of click direction"
1233 );
1234 }
1235
1236 #[test]
1237 fn shift_click_anchor_fixed_on_subsequent_clicks() {
1238 use crate::message::Message;
1239 let mut state = GitKraft::new();
1240 state.active_tab_mut().repo_path =
1241 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1242 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1243 state.active_tab_mut().commit_files =
1244 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1245 state.active_tab_mut().anchor_file_index = Some(2);
1247 state.active_tab_mut().selected_file_index = Some(2);
1248 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1249
1250 let _ = state.update(Message::SelectDiffByIndex(4));
1252 assert_eq!(
1253 state.active_tab().selected_commit_file_indices,
1254 vec![2, 3, 4]
1255 );
1256
1257 let _ = state.update(Message::SelectDiffByIndex(3));
1259 assert_eq!(
1260 state.active_tab().selected_commit_file_indices,
1261 vec![2, 3],
1262 "anchor must stay fixed; second Shift+Click shrinks the range"
1263 );
1264
1265 let _ = state.update(Message::SelectDiffByIndex(0));
1267 assert_eq!(
1268 state.active_tab().selected_commit_file_indices,
1269 vec![0, 1, 2],
1270 "anchor must stay fixed; can extend range in either direction"
1271 );
1272 }
1273
1274 #[test]
1275 fn shift_click_on_anchor_itself_gives_single_item_range() {
1276 use crate::message::Message;
1277 let mut state = GitKraft::new();
1278 state.active_tab_mut().repo_path =
1279 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1280 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1281 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1282 state.active_tab_mut().anchor_file_index = Some(1);
1283 state.active_tab_mut().selected_file_index = Some(1);
1284
1285 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1287 let _ = state.update(Message::SelectDiffByIndex(1));
1288
1289 assert_eq!(state.active_tab().selected_commit_file_indices, vec![1]);
1290 assert!(
1291 state.active_tab().multi_file_diffs.is_empty(),
1292 "single-item range must not populate multi_file_diffs"
1293 );
1294 }
1295
1296 #[test]
1297 fn shift_click_range_is_always_ascending() {
1298 use crate::message::Message;
1299 let mut state = GitKraft::new();
1300 state.active_tab_mut().repo_path =
1301 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1302 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1303 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs"]);
1304 state.active_tab_mut().anchor_file_index = Some(3);
1305 state.active_tab_mut().selected_file_index = Some(3);
1306
1307 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1308 let _ = state.update(Message::SelectDiffByIndex(0));
1309
1310 let sel = &state.active_tab().selected_commit_file_indices;
1311 let is_sorted = sel.windows(2).all(|w| w[0] < w[1]);
1312 assert!(
1313 is_sorted,
1314 "selection must always be stored in ascending order"
1315 );
1316 assert_eq!(sel, &vec![0, 1, 2, 3]);
1317 }
1318
1319 #[test]
1320 fn checkout_file_at_commit_message_variants_exist() {
1321 use crate::message::Message;
1322 let _single =
1324 Message::CheckoutFileAtCommit("abc123".to_string(), "src/main.rs".to_string());
1325 let _multi = Message::CheckoutMultiFilesAtCommit(
1326 "abc123".to_string(),
1327 vec!["a.rs".to_string(), "b.rs".to_string()],
1328 );
1329 }
1330
1331 #[test]
1332 fn checkout_file_at_commit_closes_context_menu() {
1333 use crate::message::Message;
1334 let mut state = GitKraft::new();
1335 state.active_tab_mut().repo_path =
1336 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1337 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1338 oid: "abc123".to_string(),
1339 file_path: "src/main.rs".to_string(),
1340 });
1341 let _ = state.update(Message::CheckoutFileAtCommit(
1342 "abc123".to_string(),
1343 "src/main.rs".to_string(),
1344 ));
1345 assert!(state.active_tab().context_menu.is_none());
1346 }
1347
1348 #[test]
1349 fn checkout_multi_files_at_commit_closes_context_menu() {
1350 use crate::message::Message;
1351 let mut state = GitKraft::new();
1352 state.active_tab_mut().repo_path =
1353 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1354 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1355 oid: "abc123".to_string(),
1356 file_path: "src/main.rs".to_string(),
1357 });
1358 let _ = state.update(Message::CheckoutMultiFilesAtCommit(
1359 "abc123".to_string(),
1360 vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
1361 ));
1362 assert!(state.active_tab().context_menu.is_none());
1363 }
1364
1365 fn make_test_commits(count: usize) -> Vec<gitkraft_core::CommitInfo> {
1368 (0..count)
1369 .map(|i| gitkraft_core::CommitInfo {
1370 oid: i.to_string(),
1371 short_oid: i.to_string(),
1372 summary: String::new(),
1373 message: String::new(),
1374 author_name: String::new(),
1375 author_email: String::new(),
1376 time: Default::default(),
1377 parent_ids: Vec::new(),
1378 })
1379 .collect()
1380 }
1381
1382 #[test]
1383 fn selected_commits_defaults_empty() {
1384 let tab = RepoTab::new_empty();
1385 assert!(tab.selected_commits.is_empty());
1386 assert!(tab.anchor_commit_index.is_none());
1387 }
1388
1389 #[test]
1390 fn regular_click_commit_sets_anchor_and_clears_range() {
1391 use crate::message::Message;
1392 let mut state = GitKraft::new();
1393 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
1394 state.active_tab_mut().commits = make_test_commits(3);
1395 state.active_tab_mut().selected_commits = vec![0, 1, 2];
1396
1397 let _ = state.update(Message::SelectCommit(1));
1398
1399 assert_eq!(state.active_tab().anchor_commit_index, Some(1));
1400 assert!(state.active_tab().selected_commits.is_empty());
1401 assert_eq!(state.active_tab().selected_commit, Some(1));
1402 }
1403
1404 #[test]
1405 fn shift_click_commit_selects_range_from_anchor() {
1406 use crate::message::Message;
1407 let mut state = GitKraft::new();
1408 state.active_tab_mut().commits = make_test_commits(5);
1409 state.active_tab_mut().anchor_commit_index = Some(1);
1410 state.active_tab_mut().selected_commit = Some(1);
1411
1412 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1413 let _ = state.update(Message::SelectCommit(4));
1414
1415 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3, 4]);
1416 }
1417
1418 #[test]
1419 fn shift_click_commit_range_is_ascending_when_clicking_above_anchor() {
1420 use crate::message::Message;
1421 let mut state = GitKraft::new();
1422 state.active_tab_mut().commits = make_test_commits(5);
1423 state.active_tab_mut().anchor_commit_index = Some(3);
1424 state.active_tab_mut().selected_commit = Some(3);
1425
1426 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1427 let _ = state.update(Message::SelectCommit(1));
1428
1429 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3]);
1430 }
1431
1432 #[test]
1435 fn execute_commit_action_closes_context_menu() {
1436 use crate::message::Message;
1437 let mut state = GitKraft::new();
1438 state.active_tab_mut().repo_path =
1439 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1440 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::Commit {
1441 index: 0,
1442 oid: "abc123".to_string(),
1443 });
1444
1445 let _ = state.update(Message::ExecuteCommitAction(
1446 "abc123".to_string(),
1447 gitkraft_core::CommitAction::CherryPick,
1448 ));
1449
1450 assert!(state.active_tab().context_menu.is_none());
1451 }
1452
1453 #[test]
1454 fn execute_commit_action_sets_loading_when_repo_open() {
1455 use crate::message::Message;
1456 let mut state = GitKraft::new();
1457 state.active_tab_mut().repo_path =
1458 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1459
1460 let _ = state.update(Message::ExecuteCommitAction(
1461 "abc123".to_string(),
1462 gitkraft_core::CommitAction::ResetHard,
1463 ));
1464
1465 assert!(state.active_tab().is_loading);
1466 }
1467
1468 #[test]
1469 fn execute_commit_action_no_repo_does_not_set_loading() {
1470 use crate::message::Message;
1471 let mut state = GitKraft::new();
1472 let _ = state.update(Message::ExecuteCommitAction(
1475 "abc123".to_string(),
1476 gitkraft_core::CommitAction::CherryPick,
1477 ));
1478
1479 assert!(!state.active_tab().is_loading);
1480 }
1481
1482 #[test]
1483 fn execute_commit_action_sets_status_message_from_action_label() {
1484 use crate::message::Message;
1485 let mut state = GitKraft::new();
1486 state.active_tab_mut().repo_path =
1487 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1488
1489 let _ = state.update(Message::ExecuteCommitAction(
1490 "abc123".to_string(),
1491 gitkraft_core::CommitAction::Revert,
1492 ));
1493
1494 let status = state.active_tab().status_message.as_deref().unwrap_or("");
1495 assert!(
1497 status.contains("Revert commit"),
1498 "expected status to contain 'Revert commit', got: {status:?}"
1499 );
1500 }
1501
1502 #[test]
1505 fn file_history_defaults_empty() {
1506 let tab = RepoTab::new_empty();
1507 assert!(tab.file_history_path.is_none());
1508 assert!(tab.file_history_commits.is_empty());
1509 assert_eq!(tab.file_history_scroll, 0.0);
1510 }
1511
1512 #[test]
1513 fn blame_defaults_empty() {
1514 let tab = RepoTab::new_empty();
1515 assert!(tab.blame_path.is_none());
1516 assert!(tab.blame_lines.is_empty());
1517 assert_eq!(tab.blame_scroll, 0.0);
1518 }
1519
1520 #[test]
1521 fn pending_delete_file_defaults_none() {
1522 let tab = RepoTab::new_empty();
1523 assert!(tab.pending_delete_file.is_none());
1524 }
1525
1526 #[test]
1527 fn view_file_history_sets_path_and_clears_blame() {
1528 use crate::message::Message;
1529 let mut state = GitKraft::new();
1530 state.active_tab_mut().repo_path =
1531 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1532 state.active_tab_mut().blame_path = Some("some/file.rs".to_string());
1533
1534 let _ = state.update(Message::ViewFileHistory("src/main.rs".to_string()));
1535
1536 assert_eq!(
1537 state.active_tab().file_history_path.as_deref(),
1538 Some("src/main.rs")
1539 );
1540 assert!(state.active_tab().blame_path.is_none());
1542 }
1543
1544 #[test]
1545 fn close_file_history_clears_state() {
1546 use crate::message::Message;
1547 let mut state = GitKraft::new();
1548 state.active_tab_mut().file_history_path = Some("src/lib.rs".to_string());
1549 state.active_tab_mut().file_history_commits = vec![gitkraft_core::CommitInfo {
1550 oid: "abc".to_string(),
1551 short_oid: "abc".to_string(),
1552 summary: "s".to_string(),
1553 message: "s".to_string(),
1554 author_name: "a".to_string(),
1555 author_email: "a@b.com".to_string(),
1556 time: Default::default(),
1557 parent_ids: vec![],
1558 }];
1559
1560 let _ = state.update(Message::CloseFileHistory);
1561
1562 assert!(state.active_tab().file_history_path.is_none());
1563 assert!(state.active_tab().file_history_commits.is_empty());
1564 }
1565
1566 #[test]
1567 fn view_file_blame_sets_path_and_clears_history() {
1568 use crate::message::Message;
1569 let mut state = GitKraft::new();
1570 state.active_tab_mut().repo_path =
1571 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1572 state.active_tab_mut().file_history_path = Some("some/file.rs".to_string());
1573
1574 let _ = state.update(Message::ViewFileBlame("src/lib.rs".to_string()));
1575
1576 assert_eq!(state.active_tab().blame_path.as_deref(), Some("src/lib.rs"));
1577 assert!(state.active_tab().file_history_path.is_none());
1579 }
1580
1581 #[test]
1582 fn selecting_new_commit_closes_blame_overlay() {
1583 use crate::message::Message;
1584 let mut state = GitKraft::new();
1585 state.active_tab_mut().repo_path =
1586 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1587 state.active_tab_mut().commits = vec![
1589 gitkraft_core::CommitInfo {
1590 oid: "abc1".into(),
1591 short_oid: "abc1".into(),
1592 summary: "first".into(),
1593 message: "first".into(),
1594 author_name: "A".into(),
1595 author_email: "a@a.com".into(),
1596 time: Default::default(),
1597 parent_ids: Vec::new(),
1598 },
1599 gitkraft_core::CommitInfo {
1600 oid: "abc2".into(),
1601 short_oid: "abc2".into(),
1602 summary: "second".into(),
1603 message: "second".into(),
1604 author_name: "A".into(),
1605 author_email: "a@a.com".into(),
1606 time: Default::default(),
1607 parent_ids: Vec::new(),
1608 },
1609 ];
1610 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1612 state.active_tab_mut().blame_lines = vec![gitkraft_core::BlameLine {
1613 line_number: 1,
1614 content: "fn main() {}".into(),
1615 short_oid: "abc1".into(),
1616 oid: "abc1".into(),
1617 author_name: "A".into(),
1618 time: Default::default(),
1619 }];
1620
1621 let _ = state.update(Message::SelectCommit(1));
1623
1624 assert!(
1625 state.active_tab().blame_path.is_none(),
1626 "blame_path must be cleared when a new commit is selected"
1627 );
1628 assert!(
1629 state.active_tab().blame_lines.is_empty(),
1630 "blame_lines must be cleared when a new commit is selected"
1631 );
1632 }
1633
1634 #[test]
1635 fn close_file_blame_clears_state() {
1636 use crate::message::Message;
1637 let mut state = GitKraft::new();
1638 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1639
1640 let _ = state.update(Message::CloseFileBlame);
1641
1642 assert!(state.active_tab().blame_path.is_none());
1643 assert!(state.active_tab().blame_lines.is_empty());
1644 }
1645
1646 #[test]
1647 fn delete_file_sets_pending() {
1648 use crate::message::Message;
1649 let mut state = GitKraft::new();
1650
1651 let _ = state.update(Message::DeleteFile("src/old.rs".to_string()));
1652
1653 assert_eq!(
1654 state.active_tab().pending_delete_file.as_deref(),
1655 Some("src/old.rs")
1656 );
1657 assert!(state.active_tab().context_menu.is_none());
1658 }
1659
1660 #[test]
1661 fn cancel_delete_file_clears_pending() {
1662 use crate::message::Message;
1663 let mut state = GitKraft::new();
1664 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1665
1666 let _ = state.update(Message::CancelDeleteFile);
1667
1668 assert!(state.active_tab().pending_delete_file.is_none());
1669 }
1670
1671 #[test]
1672 fn confirm_delete_file_no_repo_is_noop() {
1673 use crate::message::Message;
1674 let mut state = GitKraft::new();
1675 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1676 let _ = state.update(Message::ConfirmDeleteFile);
1679
1680 assert!(!state.active_tab().is_loading);
1681 }
1682
1683 #[test]
1684 fn shift_arrow_down_extends_file_list_selection_when_files_loaded() {
1685 use crate::message::Message;
1686 let mut state = GitKraft::new();
1687 state.active_tab_mut().repo_path =
1688 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1689 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1690 state.active_tab_mut().selected_file_index = Some(0);
1691 state.active_tab_mut().anchor_file_index = Some(0);
1692 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1694
1695 let _ = state.update(Message::ShiftArrowDown);
1696
1697 assert_eq!(state.active_tab().selected_file_index, Some(1));
1698 assert!(state.active_tab().selected_commit_file_indices.contains(&0));
1700 assert!(state.active_tab().selected_commit_file_indices.contains(&1));
1701 }
1702
1703 #[test]
1704 fn shift_arrow_down_falls_through_to_commit_log_when_no_files() {
1705 use crate::message::Message;
1706 let mut state = GitKraft::new();
1707 state.active_tab_mut().commits = make_test_commits(5);
1708 state.active_tab_mut().selected_commit = Some(1);
1709 state.active_tab_mut().anchor_commit_index = Some(1);
1710 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1711 let _ = state.update(Message::ShiftArrowDown);
1714
1715 assert_eq!(state.active_tab().selected_commit, Some(2));
1716 assert!(state.active_tab().selected_commits.contains(&1));
1717 assert!(state.active_tab().selected_commits.contains(&2));
1718 }
1719
1720 #[test]
1721 fn file_system_changed_triggers_full_refresh() {
1722 use crate::message::Message;
1723 let mut state = GitKraft::new();
1724 state.active_tab_mut().repo_path =
1725 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1726
1727 let _task = state.update(Message::FileSystemChanged);
1731
1732 assert!(
1736 state.active_tab().error_message.is_none(),
1737 "FileSystemChanged must not set an error message"
1738 );
1739 }
1740
1741 fn fake_payload(workdir: &str) -> crate::message::RepoPayload {
1745 gitkraft_core::RepoSnapshot {
1746 info: gitkraft_core::RepoInfo {
1747 path: std::path::PathBuf::from(format!("{workdir}/.git")),
1748 workdir: Some(std::path::PathBuf::from(workdir)),
1749 head_branch: Some("main".into()),
1750 is_bare: false,
1751 state: gitkraft_core::RepoState::Clean,
1752 },
1753 branches: Vec::new(),
1754 commits: Vec::new(),
1755 graph_rows: Vec::new(),
1756 unstaged: Vec::new(),
1757 staged: Vec::new(),
1758 stashes: Vec::new(),
1759 remotes: Vec::new(),
1760 }
1761 }
1762
1763 fn setup_loaded_tab(tab: &mut RepoTab, path: &str) {
1765 tab.repo_path = Some(std::path::PathBuf::from(path));
1766 tab.repo_info = Some(gitkraft_core::RepoInfo {
1767 path: std::path::PathBuf::from(format!("{path}/.git")),
1768 workdir: Some(std::path::PathBuf::from(path)),
1769 head_branch: Some("main".into()),
1770 is_bare: false,
1771 state: gitkraft_core::RepoState::Clean,
1772 });
1773 }
1774
1775 #[test]
1776 fn open_repo_creates_new_tab_when_repo_already_open() {
1777 use crate::message::Message;
1778 let mut state = GitKraft::new();
1779 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1780
1781 assert_eq!(state.tabs.len(), 1);
1782 assert_eq!(state.active_tab, 0);
1783
1784 let _task = state.update(Message::OpenRepo);
1786
1787 assert_eq!(state.tabs.len(), 2);
1788 assert_eq!(state.active_tab, 1);
1789 assert!(state.tabs[1].is_loading);
1791 assert_eq!(
1793 state.tabs[0].repo_path.as_deref(),
1794 Some(std::path::Path::new("/home/user/repo-a"))
1795 );
1796 }
1797
1798 #[test]
1799 fn open_repo_reuses_empty_tab() {
1800 use crate::message::Message;
1801 let mut state = GitKraft::new();
1802 assert!(!state.active_tab().has_repo());
1804
1805 let _task = state.update(Message::OpenRepo);
1806
1807 assert_eq!(state.tabs.len(), 1);
1809 assert_eq!(state.active_tab, 0);
1810 assert!(state.tabs[0].is_loading);
1811 }
1812
1813 #[test]
1814 fn repo_selected_deduplicates_already_open_repo() {
1815 use crate::message::Message;
1816 let mut state = GitKraft::new();
1817 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1819 state.tabs.push(RepoTab::new_empty());
1821 state.active_tab = 1;
1822
1823 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
1825 "/home/user/repo-a",
1826 ))));
1827
1828 assert_eq!(state.tabs.len(), 1);
1830 assert_eq!(state.active_tab, 0);
1831 assert_eq!(
1832 state.tabs[0].repo_path.as_deref(),
1833 Some(std::path::Path::new("/home/user/repo-a"))
1834 );
1835 }
1836
1837 #[test]
1838 fn repo_selected_opens_new_repo_in_empty_tab() {
1839 use crate::message::Message;
1840 let mut state = GitKraft::new();
1841 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1843 state.tabs.push(RepoTab::new_empty());
1845 state.active_tab = 1;
1846
1847 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
1849 "/home/user/repo-b",
1850 ))));
1851
1852 assert_eq!(state.tabs.len(), 2);
1854 assert_eq!(state.active_tab, 1);
1855 assert!(state.tabs[1]
1856 .status_message
1857 .as_deref()
1858 .unwrap_or("")
1859 .contains("repo-b"));
1860 }
1861
1862 #[test]
1863 fn repo_selected_cancel_removes_empty_tab() {
1864 use crate::message::Message;
1865 let mut state = GitKraft::new();
1866 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1868 state.tabs.push(RepoTab::new_empty());
1870 state.active_tab = 1;
1871
1872 let _task = state.update(Message::RepoSelected(None));
1874
1875 assert_eq!(state.tabs.len(), 1);
1877 assert_eq!(state.active_tab, 0);
1878 assert_eq!(
1879 state.tabs[0].repo_path.as_deref(),
1880 Some(std::path::Path::new("/home/user/repo-a"))
1881 );
1882 }
1883
1884 #[test]
1885 fn repo_selected_cancel_keeps_tab_if_only_one() {
1886 use crate::message::Message;
1887 let mut state = GitKraft::new();
1888 assert_eq!(state.tabs.len(), 1);
1890 assert!(!state.active_tab().has_repo());
1891
1892 let _task = state.update(Message::RepoSelected(None));
1893
1894 assert_eq!(state.tabs.len(), 1);
1895 assert!(!state.active_tab().is_loading);
1896 }
1897
1898 #[test]
1899 fn open_recent_repo_deduplicates() {
1900 use crate::message::Message;
1901 let mut state = GitKraft::new();
1902 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1904 state.tabs.push(RepoTab::new_empty());
1906 state.active_tab = 1;
1907
1908 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
1910 "/home/user/repo-a",
1911 )));
1912
1913 assert_eq!(state.active_tab, 0);
1914 }
1915
1916 #[test]
1917 fn open_recent_repo_creates_new_tab_when_current_has_repo() {
1918 use crate::message::Message;
1919 let mut state = GitKraft::new();
1920 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1922
1923 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
1925 "/home/user/repo-b",
1926 )));
1927
1928 assert_eq!(state.tabs.len(), 2);
1929 assert_eq!(state.active_tab, 1);
1930 assert!(state.tabs[1].is_loading);
1931 }
1932
1933 #[test]
1934 fn open_recent_repo_uses_empty_tab() {
1935 use crate::message::Message;
1936 let mut state = GitKraft::new();
1937 assert!(!state.active_tab().has_repo());
1939
1940 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
1941 "/home/user/repo-b",
1942 )));
1943
1944 assert_eq!(state.tabs.len(), 1);
1946 assert_eq!(state.active_tab, 0);
1947 assert!(state.tabs[0].is_loading);
1948 }
1949
1950 #[test]
1953 fn repo_refreshed_targets_correct_tab_after_tab_switch() {
1954 use crate::message::Message;
1955 let mut state = GitKraft::new();
1956 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1958 state.tabs.push(RepoTab::new_empty());
1960 state.active_tab = 1; let payload = fake_payload("/home/user/repo-a");
1964 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
1965
1966 assert!(
1968 state.tabs[0].repo_info.is_some(),
1969 "tab 0 should still have repo info after refresh"
1970 );
1971 assert_eq!(
1972 state.tabs[0].current_branch.as_deref(),
1973 Some("main"),
1974 "tab 0 should have updated branch from payload"
1975 );
1976 assert!(
1978 state.tabs[1].repo_info.is_none(),
1979 "tab 1 (empty) must NOT receive the refresh payload"
1980 );
1981 assert!(
1982 state.tabs[1].repo_path.is_none(),
1983 "tab 1 should still have no repo path"
1984 );
1985 }
1986
1987 #[test]
1988 fn repo_refreshed_targets_active_tab_for_new_open() {
1989 use crate::message::Message;
1990 let mut state = GitKraft::new();
1991 assert_eq!(state.tabs.len(), 1);
1995 assert!(!state.active_tab().has_repo());
1996
1997 let payload = fake_payload("/home/user/new-repo");
1998 let _task = state.update(Message::RepoOpened(Ok(payload)));
1999
2000 assert_eq!(
2002 state.tabs[0].repo_path.as_deref(),
2003 Some(std::path::Path::new("/home/user/new-repo"))
2004 );
2005 assert!(state.tabs[0].repo_info.is_some());
2006 }
2007
2008 #[test]
2009 fn repo_refreshed_does_not_duplicate_into_new_tab() {
2010 use crate::message::Message;
2011 let mut state = GitKraft::new();
2012 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2014
2015 let _task = state.update(Message::NewTab);
2017 assert_eq!(state.tabs.len(), 2);
2018 assert_eq!(state.active_tab, 1);
2019
2020 let payload = fake_payload("/home/user/repo-a");
2022 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2023
2024 assert!(
2026 state.tabs[1].repo_path.is_none(),
2027 "new empty tab must not receive repo-a refresh"
2028 );
2029 assert!(
2030 state.tabs[1].repo_info.is_none(),
2031 "new empty tab must not have repo_info"
2032 );
2033 assert_eq!(
2035 state.tabs[0].repo_path.as_deref(),
2036 Some(std::path::Path::new("/home/user/repo-a"))
2037 );
2038 }
2039
2040 #[test]
2041 fn git_operation_result_targets_correct_tab() {
2042 use crate::message::Message;
2043 let mut state = GitKraft::new();
2044 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2046 state.tabs.push(RepoTab::new_empty());
2048 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2049 state.active_tab = 1;
2050
2051 let payload = fake_payload("/home/user/repo-a");
2054 let _task = state.update(Message::GitOperationResult(Ok(payload)));
2055
2056 assert_eq!(state.tabs[0].current_branch.as_deref(), Some("main"));
2058 assert_eq!(
2060 state.tabs[1].repo_path.as_deref(),
2061 Some(std::path::Path::new("/home/user/repo-b"))
2062 );
2063 }
2064
2065 #[test]
2066 fn multiple_new_tabs_dont_get_polluted_by_refresh() {
2067 use crate::message::Message;
2068 let mut state = GitKraft::new();
2069 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2071
2072 let _task = state.update(Message::NewTab);
2074 let _task = state.update(Message::NewTab);
2075 assert_eq!(state.tabs.len(), 3);
2076 assert_eq!(state.active_tab, 2);
2077
2078 let payload = fake_payload("/home/user/repo-a");
2080 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2081
2082 assert!(state.tabs[0].repo_info.is_some());
2084 assert!(state.tabs[1].repo_info.is_none());
2085 assert!(state.tabs[2].repo_info.is_none());
2086 assert!(state.tabs[1].repo_path.is_none());
2087 assert!(state.tabs[2].repo_path.is_none());
2088 }
2089
2090 #[test]
2091 fn repo_selected_dedup_adjusts_index_when_existing_is_after_active() {
2092 use crate::message::Message;
2093 let mut state = GitKraft::new();
2094 state.tabs.push(RepoTab::new_empty());
2097 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
2098 state.active_tab = 0;
2099
2100 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2102 "/home/user/repo-a",
2103 ))));
2104
2105 assert_eq!(state.tabs.len(), 1);
2107 assert_eq!(state.active_tab, 0);
2108 assert_eq!(
2109 state.tabs[0].repo_path.as_deref(),
2110 Some(std::path::Path::new("/home/user/repo-a"))
2111 );
2112 }
2113
2114 #[test]
2117 fn apply_payload_preserves_multi_selection_by_oid() {
2118 let mut tab = RepoTab::new_empty();
2119 tab.commits = make_test_commits(5);
2120 tab.anchor_commit_index = Some(1);
2122 tab.selected_commits = vec![1, 2, 3];
2123 tab.selected_commit = Some(3);
2124 tab.selected_commit_oid = Some(tab.commits[3].oid.clone());
2125
2126 let mut payload = fake_payload("/tmp/repo");
2128 payload.commits = make_test_commits(5);
2129
2130 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2131
2132 assert_eq!(tab.anchor_commit_index, Some(1));
2134 assert_eq!(tab.selected_commits, vec![1, 2, 3]);
2135 assert_eq!(tab.selected_commit, Some(3));
2136 }
2137
2138 #[test]
2139 fn apply_payload_preserves_anchor_even_without_range() {
2140 let mut tab = RepoTab::new_empty();
2141 tab.commits = make_test_commits(5);
2142 tab.anchor_commit_index = Some(2);
2143 tab.selected_commit = Some(2);
2144 tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2145 let mut payload = fake_payload("/tmp/repo");
2148 payload.commits = make_test_commits(5);
2149
2150 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2151
2152 assert_eq!(tab.anchor_commit_index, Some(2));
2153 assert_eq!(tab.selected_commit, Some(2));
2154 }
2155
2156 #[test]
2157 fn apply_payload_clears_selection_when_commits_disappear() {
2158 let mut tab = RepoTab::new_empty();
2159 tab.commits = make_test_commits(5);
2160 tab.anchor_commit_index = Some(2);
2161 tab.selected_commits = vec![2, 3, 4];
2162 tab.selected_commit = Some(4);
2163 tab.selected_commit_oid = Some(tab.commits[4].oid.clone());
2164
2165 let mut payload = fake_payload("/tmp/repo");
2167 payload.commits = (0..3)
2168 .map(|i| gitkraft_core::CommitInfo {
2169 oid: format!("new_oid_{i}"),
2170 short_oid: format!("new_{i}"),
2171 summary: format!("new commit {i}"),
2172 message: String::new(),
2173 author_name: "Author".into(),
2174 author_email: "a@b.c".into(),
2175 time: Default::default(),
2176 parent_ids: Vec::new(),
2177 })
2178 .collect();
2179
2180 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2181
2182 assert!(tab.selected_commits.is_empty());
2184 assert!(tab.anchor_commit_index.is_none());
2185 assert!(tab.selected_commit.is_none());
2186 }
2187}