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 let prev_anchor_file = self.anchor_file_index;
313 let prev_file_indices = self.selected_commit_file_indices.clone();
314 let prev_multi_file_diffs = std::mem::take(&mut self.multi_file_diffs);
315 let prev_commit_range_diffs = std::mem::take(&mut self.commit_range_diffs);
316
317 self.current_branch = payload.info.head_branch.clone();
318 self.repo_path = Some(path);
319 self.repo_info = Some(payload.info);
320 self.branches = payload.branches;
321 self.commits = payload.commits;
322 self.graph_rows = payload.graph_rows;
323 self.unstaged_changes = payload.unstaged;
324 self.staged_changes = payload.staged;
325 self.stashes = payload.stashes;
326 self.remotes = payload.remotes;
327
328 self.selected_commit = None;
332 self.anchor_commit_index = None;
333 self.selected_commits.clear();
334 self.selected_commit_oid = None;
335 self.commit_message.clear();
336 self.error_message = None;
337 self.status_message = Some("Repository loaded.".into());
338 self.commit_scroll_offset = 0.0;
339 self.has_more_commits = true;
340 self.is_loading_more_commits = false;
341 self.selected_unstaged.clear();
342 self.selected_staged.clear();
343 self.anchor_file_index = None;
344 self.selected_commit_file_indices.clear();
345 self.multi_file_diffs.clear();
346 self.commit_range_diffs.clear();
347
348 if let Some(oid) = prev_oid {
354 if let Some(new_idx) = self.commits.iter().position(|c| c.oid == oid) {
355 self.selected_commit = Some(new_idx);
356 self.selected_commit_oid = Some(oid);
357 self.anchor_file_index = prev_anchor_file;
364 self.selected_commit_file_indices = prev_file_indices;
365 self.multi_file_diffs = prev_multi_file_diffs;
366 self.commit_range_diffs = prev_commit_range_diffs;
367 } else {
368 self.selected_diff = None;
370 self.commit_files.clear();
371 self.selected_file_index = None;
372 self.is_loading_file_diff = false;
373 self.diff_scroll_offset = 0.0;
374 }
375 } else {
376 self.selected_diff = None;
378 self.commit_files.clear();
379 self.selected_file_index = None;
380 self.is_loading_file_diff = false;
381 self.diff_scroll_offset = 0.0;
382 }
383
384 if let Some(anchor_oid) = prev_anchor_oid {
388 if let Some(new_anchor) = self.commits.iter().position(|c| c.oid == anchor_oid) {
389 self.anchor_commit_index = Some(new_anchor);
390 }
391 }
392 if !prev_selected_oids.is_empty() {
393 let restored: Vec<usize> = prev_selected_oids
394 .iter()
395 .filter_map(|oid| self.commits.iter().position(|c| &c.oid == oid))
396 .collect();
397 if !restored.is_empty() {
398 self.selected_commits = restored;
399 self.selected_commits.sort_unstable();
400 }
401 }
402 }
403}
404
405pub struct GitKraft {
409 pub tabs: Vec<RepoTab>,
412 pub active_tab: usize,
414
415 pub sidebar_expanded: bool,
418
419 pub sidebar_width: f32,
422 pub commit_log_width: f32,
424 pub staging_height: f32,
426 pub diff_file_list_width: f32,
428
429 pub ui_scale: f32,
431
432 pub dragging: Option<DragTarget>,
435 pub dragging_h: Option<DragTargetH>,
437 pub drag_start_x: f32,
439 pub drag_start_y: f32,
441 pub drag_initialized: bool,
445 pub drag_initialized_h: bool,
447
448 pub cursor_pos: Point,
453
454 pub current_theme_index: usize,
457
458 pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
461
462 pub search_visible: bool,
465 pub search_query: String,
467 pub search_results: Vec<gitkraft_core::CommitInfo>,
469 pub search_selected: Option<usize>,
471
472 pub search_diff_files: Vec<gitkraft_core::DiffFileEntry>,
474 pub search_diff_selected: HashSet<usize>,
476 pub search_diff_content: Vec<gitkraft_core::DiffInfo>,
478 pub search_diff_oid: Option<String>,
480
481 pub editor: gitkraft_core::Editor,
483
484 pub keyboard_modifiers: iced::keyboard::Modifiers,
486
487 pub animation_tick: u64,
490
491 pub window_width: f32,
494 pub window_height: f32,
496 pub window_x: f32,
498 pub window_y: f32,
500}
501
502impl Default for GitKraft {
503 fn default() -> Self {
504 Self::new()
505 }
506}
507
508impl GitKraft {
509 fn from_settings(settings: gitkraft_core::AppSettings) -> Self {
515 let current_theme_index = settings
516 .theme_name
517 .as_deref()
518 .map(gitkraft_core::theme_index_by_name)
519 .unwrap_or(0);
520
521 let recent_repos = settings.recent_repos;
522
523 let (
524 sidebar_width,
525 commit_log_width,
526 staging_height,
527 diff_file_list_width,
528 sidebar_expanded,
529 ui_scale,
530 ) = if let Some(ref layout) = settings.layout {
531 (
532 layout.sidebar_width.unwrap_or(220.0),
533 layout.commit_log_width.unwrap_or(500.0),
534 layout.staging_height.unwrap_or(200.0),
535 layout.diff_file_list_width.unwrap_or(180.0),
536 layout.sidebar_expanded.unwrap_or(true),
537 layout.ui_scale.unwrap_or(1.0),
538 )
539 } else {
540 (220.0, 500.0, 200.0, 180.0, true, 1.0)
541 };
542
543 Self {
544 tabs: vec![RepoTab::new_empty()],
545 active_tab: 0,
546
547 sidebar_expanded,
548
549 sidebar_width,
550 commit_log_width,
551 staging_height,
552 diff_file_list_width,
553
554 ui_scale,
555
556 dragging: None,
557 dragging_h: None,
558 drag_start_x: 0.0,
559 drag_start_y: 0.0,
560 drag_initialized: false,
561 drag_initialized_h: false,
562 cursor_pos: Point::ORIGIN,
563
564 current_theme_index,
565
566 recent_repos,
567
568 search_visible: false,
569 search_query: String::new(),
570 search_results: Vec::new(),
571 search_selected: None,
572 search_diff_files: Vec::new(),
573 search_diff_selected: HashSet::new(),
574 search_diff_content: Vec::new(),
575 search_diff_oid: None,
576
577 keyboard_modifiers: iced::keyboard::Modifiers::default(),
578 animation_tick: 0,
579
580 window_width: settings
581 .layout
582 .as_ref()
583 .and_then(|l| l.window_width)
584 .unwrap_or(1400.0),
585 window_height: settings
586 .layout
587 .as_ref()
588 .and_then(|l| l.window_height)
589 .unwrap_or(800.0),
590 window_x: settings
591 .layout
592 .as_ref()
593 .and_then(|l| l.window_x)
594 .unwrap_or(0.0),
595 window_y: settings
596 .layout
597 .as_ref()
598 .and_then(|l| l.window_y)
599 .unwrap_or(0.0),
600
601 editor: settings
602 .editor_name
603 .as_deref()
604 .map(|name| {
605 gitkraft_core::EDITOR_NAMES
607 .iter()
608 .position(|n| n.eq_ignore_ascii_case(name))
609 .map(gitkraft_core::Editor::from_index)
610 .unwrap_or_else(|| {
611 if name.eq_ignore_ascii_case("none") {
612 gitkraft_core::Editor::None
613 } else {
614 gitkraft_core::Editor::Custom(name.to_string())
615 }
616 })
617 })
618 .unwrap_or_else(detect_system_editor),
619 }
620 }
621
622 pub fn new() -> Self {
628 Self::from_settings(
629 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default(),
630 )
631 }
632
633 pub fn new_with_session_paths() -> (Self, Vec<PathBuf>) {
639 let settings =
640 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default();
641 let open_tabs = settings.open_tabs.clone();
642 let active_tab_index = settings.active_tab_index;
643
644 let mut state = Self::from_settings(settings);
645
646 if !open_tabs.is_empty() {
647 state.tabs = open_tabs
648 .iter()
649 .map(|path| {
650 let mut tab = RepoTab::new_empty();
651 tab.repo_path = Some(path.clone());
654 if path.exists() {
655 tab.is_loading = true;
656 tab.status_message = Some(format!(
657 "Loading {}…",
658 path.file_name().unwrap_or_default().to_string_lossy()
659 ));
660 } else {
661 tab.error_message =
662 Some(format!("Repository not found: {}", path.display()));
663 }
664 tab
665 })
666 .collect();
667 state.active_tab = active_tab_index.min(state.tabs.len().saturating_sub(1));
668 }
669
670 (state, open_tabs)
671 }
672
673 pub fn open_tab_paths(&self) -> Vec<PathBuf> {
676 self.tabs
677 .iter()
678 .filter(|t| t.repo_info.is_some())
679 .filter_map(|t| t.repo_path.clone())
680 .collect()
681 }
682
683 pub fn active_tab(&self) -> &RepoTab {
685 &self.tabs[self.active_tab]
686 }
687
688 pub fn active_tab_mut(&mut self) -> &mut RepoTab {
690 &mut self.tabs[self.active_tab]
691 }
692
693 pub fn has_repo(&self) -> bool {
695 self.active_tab().has_repo()
696 }
697
698 pub fn repo_display_name(&self) -> &str {
700 self.active_tab().display_name()
701 }
702
703 pub fn colors(&self) -> ThemeColors {
710 ThemeColors::from_core(&gitkraft_core::theme_by_index(self.current_theme_index))
711 }
712
713 pub fn iced_theme(&self) -> iced::Theme {
722 let core = gitkraft_core::theme_by_index(self.current_theme_index);
723 let name = self.current_theme_name().to_string();
724
725 let palette = iced::theme::Palette {
726 background: rgb_to_iced(core.background),
727 text: rgb_to_iced(core.text_primary),
728 primary: rgb_to_iced(core.accent),
729 success: rgb_to_iced(core.success),
730 warning: rgb_to_iced(core.warning),
731 danger: rgb_to_iced(core.error),
732 };
733
734 iced::Theme::custom(name, palette)
735 }
736
737 pub fn current_theme_name(&self) -> &'static str {
739 gitkraft_core::THEME_NAMES
740 .get(self.current_theme_index)
741 .copied()
742 .unwrap_or("Default")
743 }
744
745 pub fn refresh_active_tab(&mut self) -> Task<Message> {
749 match self.active_tab().repo_path.clone() {
750 Some(path) => crate::features::repo::commands::refresh_repo(path),
751 None => Task::none(),
752 }
753 }
754
755 pub fn on_ok_refresh(
762 &mut self,
763 result: Result<(), String>,
764 ok_msg: &str,
765 err_prefix: &str,
766 ) -> Task<Message> {
767 match result {
768 Ok(()) => {
769 {
770 let tab = self.active_tab_mut();
771 tab.is_loading = false;
772 tab.status_message = Some(ok_msg.to_string());
773 }
774 self.refresh_active_tab()
775 }
776 Err(e) => {
777 let tab = self.active_tab_mut();
778 tab.is_loading = false;
779 tab.error_message = Some(format!("{err_prefix}: {e}"));
780 tab.status_message = None;
781 Task::none()
782 }
783 }
784 }
785
786 pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
788 gitkraft_core::LayoutSettings {
789 sidebar_width: Some(self.sidebar_width),
790 commit_log_width: Some(self.commit_log_width),
791 staging_height: Some(self.staging_height),
792 diff_file_list_width: Some(self.diff_file_list_width),
793 sidebar_expanded: Some(self.sidebar_expanded),
794 ui_scale: Some(self.ui_scale),
795 window_width: Some(self.window_width),
796 window_height: Some(self.window_height),
797 window_x: Some(self.window_x),
798 window_y: Some(self.window_y),
799 window_maximized: None, }
801 }
802}
803
804fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
806 Color::from_rgb8(rgb.r, rgb.g, rgb.b)
807}
808
809fn detect_system_editor() -> gitkraft_core::Editor {
811 for var in ["VISUAL", "EDITOR"] {
812 if let Ok(val) = std::env::var(var) {
813 let bin = val.split('/').next_back().unwrap_or(&val).trim();
814 return match bin {
815 "nvim" | "neovim" => gitkraft_core::Editor::Neovim,
816 "vim" => gitkraft_core::Editor::Vim,
817 "hx" | "helix" => gitkraft_core::Editor::Helix,
818 "nano" => gitkraft_core::Editor::Nano,
819 "micro" => gitkraft_core::Editor::Micro,
820 "emacs" => gitkraft_core::Editor::Emacs,
821 "code" => gitkraft_core::Editor::VSCode,
822 "zed" => gitkraft_core::Editor::Zed,
823 "subl" => gitkraft_core::Editor::Sublime,
824 _ => gitkraft_core::Editor::Custom(val),
825 };
826 }
827 }
828 gitkraft_core::Editor::None
829}
830
831#[cfg(test)]
834mod tests {
835 use super::*;
836
837 #[test]
838 fn new_defaults() {
839 let state = GitKraft::new();
840 assert!(state.active_tab().repo_path.is_none());
841 assert!(!state.has_repo());
842 assert_eq!(state.repo_display_name(), "New Tab");
843 assert!(state.active_tab().commits.is_empty());
844 assert!(state.sidebar_expanded);
845 assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
847 assert!(state.sidebar_width > 0.0);
849 assert!(state.commit_log_width > 0.0);
850 assert!(state.staging_height > 0.0);
851 assert!(state.dragging.is_none());
852 assert!(state.dragging_h.is_none());
853 assert_eq!(state.tabs.len(), 1);
855 assert_eq!(state.active_tab, 0);
856 }
857
858 #[test]
859 fn repo_display_name_extracts_basename() {
860 let mut state = GitKraft::new();
861 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
862 assert_eq!(state.repo_display_name(), "my-project");
863 }
864
865 #[test]
866 fn colors_returns_theme_colors() {
867 let state = GitKraft::new();
868 let c = state.colors();
869 assert!(c.bg.r < 0.5);
871 }
872
873 #[test]
874 fn iced_theme_is_custom_with_correct_palette() {
875 let mut state = GitKraft::new();
876
877 state.current_theme_index = 0;
879 let iced_t = state.iced_theme();
880 let pal = iced_t.palette();
881 assert!(pal.background.r < 0.5, "Default theme bg should be dark");
882 assert_eq!(iced_t.to_string(), "Default");
883
884 state.current_theme_index = 11;
886 let iced_t = state.iced_theme();
887 let pal = iced_t.palette();
888 assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
889 assert_eq!(iced_t.to_string(), "Solarized Light");
890
891 state.current_theme_index = 12;
893 let iced_t = state.iced_theme();
894 let pal = iced_t.palette();
895 let core = gitkraft_core::theme_by_index(12);
896 let expected_accent = rgb_to_iced(core.accent);
897 assert!(
898 (pal.primary.r - expected_accent.r).abs() < 0.01
899 && (pal.primary.g - expected_accent.g).abs() < 0.01
900 && (pal.primary.b - expected_accent.b).abs() < 0.01,
901 "Gruvbox Dark accent should match core accent"
902 );
903 }
904
905 #[test]
906 fn iced_theme_name_round_trips_through_core() {
907 for i in 0..gitkraft_core::THEME_COUNT {
910 let mut state = GitKraft::new();
911 state.current_theme_index = i;
912 let iced_t = state.iced_theme();
913 let name = iced_t.to_string();
914 let resolved = gitkraft_core::theme_index_by_name(&name);
915 assert_eq!(
916 resolved,
917 i,
918 "theme index {i} ({}) did not round-trip through iced_theme name",
919 gitkraft_core::THEME_NAMES[i]
920 );
921 }
922 }
923
924 #[test]
925 fn current_theme_name_round_trips() {
926 let mut state = GitKraft::new();
927 state.current_theme_index = 8;
928 assert_eq!(state.current_theme_name(), "Dracula");
929 state.current_theme_index = 0;
930 assert_eq!(state.current_theme_name(), "Default");
931 }
932
933 #[test]
934 fn repo_tab_new_empty() {
935 let tab = RepoTab::new_empty();
936 assert!(tab.repo_path.is_none());
937 assert!(!tab.has_repo());
938 assert_eq!(tab.display_name(), "New Tab");
939 assert!(tab.commits.is_empty());
940 assert!(tab.branches.is_empty());
941 assert!(!tab.is_loading);
942 }
943
944 #[test]
945 fn repo_tab_display_name_with_path() {
946 let mut tab = RepoTab::new_empty();
947 tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
948 assert!(tab.has_repo());
949 assert_eq!(tab.display_name(), "cool-repo");
950 }
951
952 #[test]
953 fn search_defaults() {
954 let state = GitKraft::new();
955 assert!(!state.search_visible);
956 assert!(state.search_query.is_empty());
957 assert!(state.search_results.is_empty());
958 assert!(state.search_selected.is_none());
959 }
960
961 #[test]
962 fn context_menu_variants_exist() {
963 use crate::state::ContextMenu;
965
966 let _branch = ContextMenu::Branch {
967 name: "main".to_string(),
968 is_current: true,
969 local_index: 0,
970 };
971 let _remote = ContextMenu::RemoteBranch {
972 name: "origin/main".to_string(),
973 };
974 let _commit = ContextMenu::Commit {
975 index: 0,
976 oid: "abc1234".to_string(),
977 };
978 let _stash = ContextMenu::Stash { index: 0 };
979 let _unstaged = ContextMenu::UnstagedFile {
980 path: "src/main.rs".to_string(),
981 };
982 let _staged = ContextMenu::StagedFile {
983 path: "src/lib.rs".to_string(),
984 };
985 }
986
987 #[test]
988 fn repo_tab_context_menu_defaults_to_none() {
989 let tab = crate::state::RepoTab::new_empty();
990 assert!(tab.context_menu.is_none());
991 }
992
993 #[test]
994 fn context_menu_variants_constructable() {
995 use crate::state::ContextMenu;
996 let _ = ContextMenu::Stash { index: 0 };
997 let _ = ContextMenu::UnstagedFile {
998 path: "a.rs".into(),
999 };
1000 let _ = ContextMenu::StagedFile {
1001 path: "b.rs".into(),
1002 };
1003 }
1004
1005 #[test]
1006 fn selected_unstaged_defaults_empty() {
1007 let tab = crate::state::RepoTab::new_empty();
1008 assert!(tab.selected_unstaged.is_empty());
1009 assert!(tab.selected_staged.is_empty());
1010 }
1011
1012 #[test]
1013 fn selected_unstaged_toggle() {
1014 let mut tab = crate::state::RepoTab::new_empty();
1015 tab.selected_unstaged.insert("a.rs".to_string());
1016 tab.selected_unstaged.insert("b.rs".to_string());
1017 assert_eq!(tab.selected_unstaged.len(), 2);
1018 assert!(tab.selected_unstaged.contains("a.rs"));
1019 tab.selected_unstaged.remove("a.rs");
1020 assert_eq!(tab.selected_unstaged.len(), 1);
1021 assert!(!tab.selected_unstaged.contains("a.rs"));
1022 }
1023
1024 #[test]
1025 fn detect_system_editor_returns_valid() {
1026 let editor = super::detect_system_editor();
1028 let _ = editor.display_name();
1029 }
1030
1031 #[test]
1034 fn selected_commit_file_indices_defaults_to_empty_vec() {
1035 let tab = RepoTab::new_empty();
1036 assert!(tab.selected_commit_file_indices.is_empty());
1037 let v: &Vec<usize> = &tab.selected_commit_file_indices;
1039 assert_eq!(v.len(), 0);
1040 }
1041
1042 #[test]
1043 fn multi_file_diffs_defaults_empty() {
1044 let tab = RepoTab::new_empty();
1045 assert!(tab.multi_file_diffs.is_empty());
1046 }
1047
1048 #[test]
1049 fn keyboard_modifiers_default_has_no_shift() {
1050 let state = GitKraft::new();
1051 assert!(!state.keyboard_modifiers.shift());
1052 }
1053
1054 #[test]
1055 fn selected_commit_file_indices_preserves_insertion_order() {
1056 let mut tab = RepoTab::new_empty();
1057 tab.selected_commit_file_indices.push(5);
1058 tab.selected_commit_file_indices.push(2);
1059 tab.selected_commit_file_indices.push(8);
1060 assert_eq!(tab.selected_commit_file_indices, vec![5, 2, 8]);
1061 }
1062
1063 #[test]
1064 fn selected_commit_file_indices_cleared_on_reset() {
1065 let mut tab = RepoTab::new_empty();
1066 tab.selected_commit_file_indices.push(0);
1067 tab.selected_commit_file_indices.push(1);
1068 tab.selected_commit_file_indices.clear();
1069 assert!(tab.selected_commit_file_indices.is_empty());
1070 }
1071
1072 #[test]
1073 fn multi_file_diffs_cleared_on_reset() {
1074 let mut tab = RepoTab::new_empty();
1075 tab.multi_file_diffs.push(gitkraft_core::DiffInfo {
1076 old_file: String::new(),
1077 new_file: "a.rs".to_string(),
1078 status: gitkraft_core::FileStatus::Modified,
1079 hunks: vec![],
1080 });
1081 tab.multi_file_diffs.clear();
1082 assert!(tab.multi_file_diffs.is_empty());
1083 }
1084
1085 #[test]
1086 fn commit_range_diffs_defaults_empty() {
1087 let tab = RepoTab::new_empty();
1088 assert!(tab.commit_range_diffs.is_empty());
1089 }
1090
1091 #[test]
1092 fn commit_range_diffs_cleared_on_apply_payload() {
1093 let mut tab = RepoTab::new_empty();
1095 tab.commit_range_diffs.push(gitkraft_core::DiffInfo {
1096 old_file: String::new(),
1097 new_file: "x.rs".to_string(),
1098 status: gitkraft_core::FileStatus::Modified,
1099 hunks: vec![],
1100 });
1101 tab.commit_range_diffs.clear();
1102 assert!(tab.commit_range_diffs.is_empty());
1103 }
1104
1105 #[test]
1108 fn modifiers_changed_sets_shift_state() {
1109 use crate::message::Message;
1110 let mut state = GitKraft::new();
1111 assert!(!state.keyboard_modifiers.shift());
1112
1113 let _ = state.update(Message::ModifiersChanged(iced::keyboard::Modifiers::SHIFT));
1114 assert!(state.keyboard_modifiers.shift());
1115
1116 let _ = state.update(Message::ModifiersChanged(
1117 iced::keyboard::Modifiers::default(),
1118 ));
1119 assert!(!state.keyboard_modifiers.shift());
1120 }
1121
1122 fn make_commit_files(names: &[&str]) -> Vec<gitkraft_core::DiffFileEntry> {
1125 names
1126 .iter()
1127 .map(|name| gitkraft_core::DiffFileEntry {
1128 old_file: String::new(),
1129 new_file: name.to_string(),
1130 status: gitkraft_core::FileStatus::Modified,
1131 })
1132 .collect()
1133 }
1134
1135 #[test]
1136 fn select_diff_by_index_regular_click_clears_multi_selection() {
1137 use crate::message::Message;
1138 let mut state = GitKraft::new();
1139 state.active_tab_mut().repo_path =
1143 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1144 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1145 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1146 state.active_tab_mut().selected_commit_file_indices = vec![0, 1];
1148
1149 let _ = state.update(Message::SelectDiffByIndex(0));
1151
1152 assert!(state.active_tab().selected_commit_file_indices.is_empty());
1153 assert_eq!(state.active_tab().selected_file_index, Some(0));
1154 }
1155
1156 #[test]
1157 fn regular_click_preserves_multi_file_diffs_until_load_completes() {
1158 use crate::message::Message;
1159 let mut state = GitKraft::new();
1160 state.active_tab_mut().repo_path =
1161 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1162 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1163 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1164
1165 state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1167 old_file: String::new(),
1168 new_file: "a.rs".to_string(),
1169 status: gitkraft_core::FileStatus::Modified,
1170 hunks: vec![],
1171 }];
1172
1173 let _ = state.update(Message::SelectDiffByIndex(1));
1176
1177 assert!(
1178 !state.active_tab().multi_file_diffs.is_empty(),
1179 "multi_file_diffs must NOT be cleared on click (deferred to diff load)"
1180 );
1181 assert_eq!(state.active_tab().selected_file_index, Some(1));
1182 assert!(state.active_tab().is_loading_file_diff);
1183 }
1184
1185 #[test]
1186 fn regular_click_preserves_diff_scroll_until_load_completes() {
1187 use crate::message::Message;
1188 let mut state = GitKraft::new();
1189 state.active_tab_mut().repo_path =
1190 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1191 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1192 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1193 state.active_tab_mut().diff_scroll_offset = 150.0;
1194
1195 let _ = state.update(Message::SelectDiffByIndex(1));
1197
1198 assert_eq!(
1199 state.active_tab().diff_scroll_offset,
1200 150.0,
1201 "diff_scroll_offset must NOT be reset on click (deferred to diff load)"
1202 );
1203 }
1204
1205 #[test]
1206 fn single_file_diff_loaded_clears_deferred_state() {
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 = make_commit_files(&["a.rs", "b.rs"]);
1213
1214 state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1216 old_file: String::new(),
1217 new_file: "a.rs".to_string(),
1218 status: gitkraft_core::FileStatus::Modified,
1219 hunks: vec![],
1220 }];
1221 state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1222 old_file: String::new(),
1223 new_file: "b.rs".to_string(),
1224 status: gitkraft_core::FileStatus::Modified,
1225 hunks: vec![],
1226 }];
1227 state.active_tab_mut().is_loading_file_diff = true;
1228 state.active_tab_mut().diff_scroll_offset = 200.0;
1229
1230 let new_diff = gitkraft_core::DiffInfo {
1232 old_file: String::new(),
1233 new_file: "b.rs".to_string(),
1234 status: gitkraft_core::FileStatus::Modified,
1235 hunks: vec![],
1236 };
1237 let _ = state.update(Message::SingleFileDiffLoaded(Ok(new_diff)));
1238
1239 assert!(
1241 state.active_tab().multi_file_diffs.is_empty(),
1242 "multi_file_diffs must be cleared when new diff arrives"
1243 );
1244 assert!(
1245 state.active_tab().commit_range_diffs.is_empty(),
1246 "commit_range_diffs must be cleared when new diff arrives"
1247 );
1248 assert!(
1249 state.active_tab().selected_diff.is_some(),
1250 "selected_diff must be set to the loaded diff"
1251 );
1252 assert!(
1253 !state.active_tab().is_loading_file_diff,
1254 "is_loading_file_diff must be cleared after load"
1255 );
1256 assert_eq!(
1257 state.active_tab().diff_scroll_offset,
1258 0.0,
1259 "diff_scroll_offset must be reset when new diff arrives"
1260 );
1261 }
1262
1263 #[test]
1264 fn regular_click_preserves_commit_range_diffs_until_load_completes() {
1265 use crate::message::Message;
1266 let mut state = GitKraft::new();
1267 state.active_tab_mut().repo_path =
1268 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1269 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1270 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1271
1272 state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1274 old_file: String::new(),
1275 new_file: "x.rs".to_string(),
1276 status: gitkraft_core::FileStatus::Modified,
1277 hunks: vec![],
1278 }];
1279
1280 let _ = state.update(Message::SelectDiffByIndex(0));
1281
1282 assert!(
1283 !state.active_tab().commit_range_diffs.is_empty(),
1284 "commit_range_diffs must NOT be cleared on click (deferred to diff load)"
1285 );
1286 }
1287
1288 #[test]
1289 fn select_diff_by_index_shift_click_adds_both_files_to_selection() {
1290 use crate::message::Message;
1291 let mut state = GitKraft::new();
1292 state.active_tab_mut().repo_path =
1293 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1294 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1295 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1296 state.active_tab_mut().selected_file_index = Some(0);
1297
1298 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1300 let _ = state.update(Message::SelectDiffByIndex(1));
1301
1302 let sel = &state.active_tab().selected_commit_file_indices;
1303 assert!(sel.contains(&0), "anchor file 0 should be selected");
1304 assert!(sel.contains(&1), "newly clicked file 1 should be selected");
1305 assert_eq!(sel.len(), 2);
1306 }
1307
1308 #[test]
1309 fn anchor_file_index_defaults_to_none() {
1310 let tab = RepoTab::new_empty();
1311 assert!(tab.anchor_file_index.is_none());
1312 }
1313
1314 #[test]
1315 fn regular_click_sets_anchor() {
1316 use crate::message::Message;
1317 let mut state = GitKraft::new();
1318 state.active_tab_mut().repo_path =
1319 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1320 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1321 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1322
1323 let _ = state.update(Message::SelectDiffByIndex(2));
1324
1325 assert_eq!(
1326 state.active_tab().anchor_file_index,
1327 Some(2),
1328 "regular click must set anchor to the clicked index"
1329 );
1330 }
1331
1332 #[test]
1333 fn shift_click_selects_range_downward_from_anchor() {
1334 use crate::message::Message;
1335 let mut state = GitKraft::new();
1336 state.active_tab_mut().repo_path =
1337 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1338 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1339 state.active_tab_mut().commit_files =
1340 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1341 state.active_tab_mut().anchor_file_index = Some(1);
1343 state.active_tab_mut().selected_file_index = Some(1);
1344
1345 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1347 let _ = state.update(Message::SelectDiffByIndex(4));
1348
1349 let sel = &state.active_tab().selected_commit_file_indices;
1350 assert_eq!(
1351 sel,
1352 &vec![1, 2, 3, 4],
1353 "range must be contiguous from anchor to click"
1354 );
1355 }
1356
1357 #[test]
1358 fn shift_click_selects_range_upward_from_anchor() {
1359 use crate::message::Message;
1360 let mut state = GitKraft::new();
1361 state.active_tab_mut().repo_path =
1362 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1363 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1364 state.active_tab_mut().commit_files =
1365 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1366 state.active_tab_mut().anchor_file_index = Some(4);
1368 state.active_tab_mut().selected_file_index = Some(4);
1369
1370 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1372 let _ = state.update(Message::SelectDiffByIndex(1));
1373
1374 let sel = &state.active_tab().selected_commit_file_indices;
1375 assert_eq!(
1376 sel,
1377 &vec![1, 2, 3, 4],
1378 "range must be stored ascending regardless of click direction"
1379 );
1380 }
1381
1382 #[test]
1383 fn shift_click_anchor_fixed_on_subsequent_clicks() {
1384 use crate::message::Message;
1385 let mut state = GitKraft::new();
1386 state.active_tab_mut().repo_path =
1387 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1388 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1389 state.active_tab_mut().commit_files =
1390 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1391 state.active_tab_mut().anchor_file_index = Some(2);
1393 state.active_tab_mut().selected_file_index = Some(2);
1394 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1395
1396 let _ = state.update(Message::SelectDiffByIndex(4));
1398 assert_eq!(
1399 state.active_tab().selected_commit_file_indices,
1400 vec![2, 3, 4]
1401 );
1402
1403 let _ = state.update(Message::SelectDiffByIndex(3));
1405 assert_eq!(
1406 state.active_tab().selected_commit_file_indices,
1407 vec![2, 3],
1408 "anchor must stay fixed; second Shift+Click shrinks the range"
1409 );
1410
1411 let _ = state.update(Message::SelectDiffByIndex(0));
1413 assert_eq!(
1414 state.active_tab().selected_commit_file_indices,
1415 vec![0, 1, 2],
1416 "anchor must stay fixed; can extend range in either direction"
1417 );
1418 }
1419
1420 #[test]
1421 fn shift_click_on_anchor_itself_gives_single_item_range() {
1422 use crate::message::Message;
1423 let mut state = GitKraft::new();
1424 state.active_tab_mut().repo_path =
1425 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1426 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1427 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1428 state.active_tab_mut().anchor_file_index = Some(1);
1429 state.active_tab_mut().selected_file_index = Some(1);
1430
1431 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1433 let _ = state.update(Message::SelectDiffByIndex(1));
1434
1435 assert_eq!(state.active_tab().selected_commit_file_indices, vec![1]);
1436 assert!(
1437 state.active_tab().multi_file_diffs.is_empty(),
1438 "single-item range must not populate multi_file_diffs"
1439 );
1440 }
1441
1442 #[test]
1443 fn shift_click_range_is_always_ascending() {
1444 use crate::message::Message;
1445 let mut state = GitKraft::new();
1446 state.active_tab_mut().repo_path =
1447 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1448 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1449 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs"]);
1450 state.active_tab_mut().anchor_file_index = Some(3);
1451 state.active_tab_mut().selected_file_index = Some(3);
1452
1453 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1454 let _ = state.update(Message::SelectDiffByIndex(0));
1455
1456 let sel = &state.active_tab().selected_commit_file_indices;
1457 let is_sorted = sel.windows(2).all(|w| w[0] < w[1]);
1458 assert!(
1459 is_sorted,
1460 "selection must always be stored in ascending order"
1461 );
1462 assert_eq!(sel, &vec![0, 1, 2, 3]);
1463 }
1464
1465 #[test]
1466 fn checkout_file_at_commit_message_variants_exist() {
1467 use crate::message::Message;
1468 let _single =
1470 Message::CheckoutFileAtCommit("abc123".to_string(), "src/main.rs".to_string());
1471 let _multi = Message::CheckoutMultiFilesAtCommit(
1472 "abc123".to_string(),
1473 vec!["a.rs".to_string(), "b.rs".to_string()],
1474 );
1475 }
1476
1477 #[test]
1478 fn checkout_file_at_commit_closes_context_menu() {
1479 use crate::message::Message;
1480 let mut state = GitKraft::new();
1481 state.active_tab_mut().repo_path =
1482 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1483 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1484 oid: "abc123".to_string(),
1485 file_path: "src/main.rs".to_string(),
1486 });
1487 let _ = state.update(Message::CheckoutFileAtCommit(
1488 "abc123".to_string(),
1489 "src/main.rs".to_string(),
1490 ));
1491 assert!(state.active_tab().context_menu.is_none());
1492 }
1493
1494 #[test]
1495 fn checkout_multi_files_at_commit_closes_context_menu() {
1496 use crate::message::Message;
1497 let mut state = GitKraft::new();
1498 state.active_tab_mut().repo_path =
1499 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1500 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1501 oid: "abc123".to_string(),
1502 file_path: "src/main.rs".to_string(),
1503 });
1504 let _ = state.update(Message::CheckoutMultiFilesAtCommit(
1505 "abc123".to_string(),
1506 vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
1507 ));
1508 assert!(state.active_tab().context_menu.is_none());
1509 }
1510
1511 fn make_test_commits(count: usize) -> Vec<gitkraft_core::CommitInfo> {
1514 (0..count)
1515 .map(|i| gitkraft_core::CommitInfo {
1516 oid: i.to_string(),
1517 short_oid: i.to_string(),
1518 summary: String::new(),
1519 message: String::new(),
1520 author_name: String::new(),
1521 author_email: String::new(),
1522 time: Default::default(),
1523 parent_ids: Vec::new(),
1524 })
1525 .collect()
1526 }
1527
1528 #[test]
1529 fn selected_commits_defaults_empty() {
1530 let tab = RepoTab::new_empty();
1531 assert!(tab.selected_commits.is_empty());
1532 assert!(tab.anchor_commit_index.is_none());
1533 }
1534
1535 #[test]
1536 fn regular_click_commit_sets_anchor_and_clears_range() {
1537 use crate::message::Message;
1538 let mut state = GitKraft::new();
1539 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
1540 state.active_tab_mut().commits = make_test_commits(3);
1541 state.active_tab_mut().selected_commits = vec![0, 1, 2];
1542
1543 let _ = state.update(Message::SelectCommit(1));
1544
1545 assert_eq!(state.active_tab().anchor_commit_index, Some(1));
1546 assert!(state.active_tab().selected_commits.is_empty());
1547 assert_eq!(state.active_tab().selected_commit, Some(1));
1548 }
1549
1550 #[test]
1551 fn shift_click_commit_selects_range_from_anchor() {
1552 use crate::message::Message;
1553 let mut state = GitKraft::new();
1554 state.active_tab_mut().commits = make_test_commits(5);
1555 state.active_tab_mut().anchor_commit_index = Some(1);
1556 state.active_tab_mut().selected_commit = Some(1);
1557
1558 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1559 let _ = state.update(Message::SelectCommit(4));
1560
1561 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3, 4]);
1562 }
1563
1564 #[test]
1565 fn shift_click_commit_range_is_ascending_when_clicking_above_anchor() {
1566 use crate::message::Message;
1567 let mut state = GitKraft::new();
1568 state.active_tab_mut().commits = make_test_commits(5);
1569 state.active_tab_mut().anchor_commit_index = Some(3);
1570 state.active_tab_mut().selected_commit = Some(3);
1571
1572 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1573 let _ = state.update(Message::SelectCommit(1));
1574
1575 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3]);
1576 }
1577
1578 #[test]
1581 fn execute_commit_action_closes_context_menu() {
1582 use crate::message::Message;
1583 let mut state = GitKraft::new();
1584 state.active_tab_mut().repo_path =
1585 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1586 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::Commit {
1587 index: 0,
1588 oid: "abc123".to_string(),
1589 });
1590
1591 let _ = state.update(Message::ExecuteCommitAction(
1592 "abc123".to_string(),
1593 gitkraft_core::CommitAction::CherryPick,
1594 ));
1595
1596 assert!(state.active_tab().context_menu.is_none());
1597 }
1598
1599 #[test]
1600 fn execute_commit_action_sets_loading_when_repo_open() {
1601 use crate::message::Message;
1602 let mut state = GitKraft::new();
1603 state.active_tab_mut().repo_path =
1604 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1605
1606 let _ = state.update(Message::ExecuteCommitAction(
1607 "abc123".to_string(),
1608 gitkraft_core::CommitAction::ResetHard,
1609 ));
1610
1611 assert!(state.active_tab().is_loading);
1612 }
1613
1614 #[test]
1615 fn execute_commit_action_no_repo_does_not_set_loading() {
1616 use crate::message::Message;
1617 let mut state = GitKraft::new();
1618 let _ = state.update(Message::ExecuteCommitAction(
1621 "abc123".to_string(),
1622 gitkraft_core::CommitAction::CherryPick,
1623 ));
1624
1625 assert!(!state.active_tab().is_loading);
1626 }
1627
1628 #[test]
1629 fn execute_commit_action_sets_status_message_from_action_label() {
1630 use crate::message::Message;
1631 let mut state = GitKraft::new();
1632 state.active_tab_mut().repo_path =
1633 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1634
1635 let _ = state.update(Message::ExecuteCommitAction(
1636 "abc123".to_string(),
1637 gitkraft_core::CommitAction::Revert,
1638 ));
1639
1640 let status = state.active_tab().status_message.as_deref().unwrap_or("");
1641 assert!(
1643 status.contains("Revert commit"),
1644 "expected status to contain 'Revert commit', got: {status:?}"
1645 );
1646 }
1647
1648 #[test]
1651 fn file_history_defaults_empty() {
1652 let tab = RepoTab::new_empty();
1653 assert!(tab.file_history_path.is_none());
1654 assert!(tab.file_history_commits.is_empty());
1655 assert_eq!(tab.file_history_scroll, 0.0);
1656 }
1657
1658 #[test]
1659 fn blame_defaults_empty() {
1660 let tab = RepoTab::new_empty();
1661 assert!(tab.blame_path.is_none());
1662 assert!(tab.blame_lines.is_empty());
1663 assert_eq!(tab.blame_scroll, 0.0);
1664 }
1665
1666 #[test]
1667 fn pending_delete_file_defaults_none() {
1668 let tab = RepoTab::new_empty();
1669 assert!(tab.pending_delete_file.is_none());
1670 }
1671
1672 #[test]
1673 fn view_file_history_sets_path_and_clears_blame() {
1674 use crate::message::Message;
1675 let mut state = GitKraft::new();
1676 state.active_tab_mut().repo_path =
1677 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1678 state.active_tab_mut().blame_path = Some("some/file.rs".to_string());
1679
1680 let _ = state.update(Message::ViewFileHistory("src/main.rs".to_string()));
1681
1682 assert_eq!(
1683 state.active_tab().file_history_path.as_deref(),
1684 Some("src/main.rs")
1685 );
1686 assert!(state.active_tab().blame_path.is_none());
1688 }
1689
1690 #[test]
1691 fn close_file_history_clears_state() {
1692 use crate::message::Message;
1693 let mut state = GitKraft::new();
1694 state.active_tab_mut().file_history_path = Some("src/lib.rs".to_string());
1695 state.active_tab_mut().file_history_commits = vec![gitkraft_core::CommitInfo {
1696 oid: "abc".to_string(),
1697 short_oid: "abc".to_string(),
1698 summary: "s".to_string(),
1699 message: "s".to_string(),
1700 author_name: "a".to_string(),
1701 author_email: "a@b.com".to_string(),
1702 time: Default::default(),
1703 parent_ids: vec![],
1704 }];
1705
1706 let _ = state.update(Message::CloseFileHistory);
1707
1708 assert!(state.active_tab().file_history_path.is_none());
1709 assert!(state.active_tab().file_history_commits.is_empty());
1710 }
1711
1712 #[test]
1713 fn view_file_blame_sets_path_and_clears_history() {
1714 use crate::message::Message;
1715 let mut state = GitKraft::new();
1716 state.active_tab_mut().repo_path =
1717 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1718 state.active_tab_mut().file_history_path = Some("some/file.rs".to_string());
1719
1720 let _ = state.update(Message::ViewFileBlame("src/lib.rs".to_string()));
1721
1722 assert_eq!(state.active_tab().blame_path.as_deref(), Some("src/lib.rs"));
1723 assert!(state.active_tab().file_history_path.is_none());
1725 }
1726
1727 #[test]
1728 fn selecting_new_commit_closes_blame_overlay() {
1729 use crate::message::Message;
1730 let mut state = GitKraft::new();
1731 state.active_tab_mut().repo_path =
1732 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1733 state.active_tab_mut().commits = vec![
1735 gitkraft_core::CommitInfo {
1736 oid: "abc1".into(),
1737 short_oid: "abc1".into(),
1738 summary: "first".into(),
1739 message: "first".into(),
1740 author_name: "A".into(),
1741 author_email: "a@a.com".into(),
1742 time: Default::default(),
1743 parent_ids: Vec::new(),
1744 },
1745 gitkraft_core::CommitInfo {
1746 oid: "abc2".into(),
1747 short_oid: "abc2".into(),
1748 summary: "second".into(),
1749 message: "second".into(),
1750 author_name: "A".into(),
1751 author_email: "a@a.com".into(),
1752 time: Default::default(),
1753 parent_ids: Vec::new(),
1754 },
1755 ];
1756 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1758 state.active_tab_mut().blame_lines = vec![gitkraft_core::BlameLine {
1759 line_number: 1,
1760 content: "fn main() {}".into(),
1761 short_oid: "abc1".into(),
1762 oid: "abc1".into(),
1763 author_name: "A".into(),
1764 time: Default::default(),
1765 }];
1766
1767 let _ = state.update(Message::SelectCommit(1));
1769
1770 assert!(
1771 state.active_tab().blame_path.is_none(),
1772 "blame_path must be cleared when a new commit is selected"
1773 );
1774 assert!(
1775 state.active_tab().blame_lines.is_empty(),
1776 "blame_lines must be cleared when a new commit is selected"
1777 );
1778 }
1779
1780 #[test]
1781 fn close_file_blame_clears_state() {
1782 use crate::message::Message;
1783 let mut state = GitKraft::new();
1784 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1785
1786 let _ = state.update(Message::CloseFileBlame);
1787
1788 assert!(state.active_tab().blame_path.is_none());
1789 assert!(state.active_tab().blame_lines.is_empty());
1790 }
1791
1792 #[test]
1793 fn delete_file_sets_pending() {
1794 use crate::message::Message;
1795 let mut state = GitKraft::new();
1796
1797 let _ = state.update(Message::DeleteFile("src/old.rs".to_string()));
1798
1799 assert_eq!(
1800 state.active_tab().pending_delete_file.as_deref(),
1801 Some("src/old.rs")
1802 );
1803 assert!(state.active_tab().context_menu.is_none());
1804 }
1805
1806 #[test]
1807 fn cancel_delete_file_clears_pending() {
1808 use crate::message::Message;
1809 let mut state = GitKraft::new();
1810 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1811
1812 let _ = state.update(Message::CancelDeleteFile);
1813
1814 assert!(state.active_tab().pending_delete_file.is_none());
1815 }
1816
1817 #[test]
1818 fn confirm_delete_file_no_repo_is_noop() {
1819 use crate::message::Message;
1820 let mut state = GitKraft::new();
1821 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1822 let _ = state.update(Message::ConfirmDeleteFile);
1825
1826 assert!(!state.active_tab().is_loading);
1827 }
1828
1829 #[test]
1830 fn shift_arrow_down_extends_file_list_selection_when_files_loaded() {
1831 use crate::message::Message;
1832 let mut state = GitKraft::new();
1833 state.active_tab_mut().repo_path =
1834 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1835 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1836 state.active_tab_mut().selected_file_index = Some(0);
1837 state.active_tab_mut().anchor_file_index = Some(0);
1838 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1840
1841 let _ = state.update(Message::ShiftArrowDown);
1842
1843 assert_eq!(state.active_tab().selected_file_index, Some(1));
1844 assert!(state.active_tab().selected_commit_file_indices.contains(&0));
1846 assert!(state.active_tab().selected_commit_file_indices.contains(&1));
1847 }
1848
1849 #[test]
1850 fn shift_arrow_down_falls_through_to_commit_log_when_no_files() {
1851 use crate::message::Message;
1852 let mut state = GitKraft::new();
1853 state.active_tab_mut().commits = make_test_commits(5);
1854 state.active_tab_mut().selected_commit = Some(1);
1855 state.active_tab_mut().anchor_commit_index = Some(1);
1856 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1857 let _ = state.update(Message::ShiftArrowDown);
1860
1861 assert_eq!(state.active_tab().selected_commit, Some(2));
1862 assert!(state.active_tab().selected_commits.contains(&1));
1863 assert!(state.active_tab().selected_commits.contains(&2));
1864 }
1865
1866 #[test]
1867 fn file_system_changed_triggers_full_refresh() {
1868 use crate::message::Message;
1869 let mut state = GitKraft::new();
1870 state.active_tab_mut().repo_path =
1871 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1872
1873 let _task = state.update(Message::FileSystemChanged);
1877
1878 assert!(
1882 state.active_tab().error_message.is_none(),
1883 "FileSystemChanged must not set an error message"
1884 );
1885 }
1886
1887 fn fake_payload(workdir: &str) -> crate::message::RepoPayload {
1891 gitkraft_core::RepoSnapshot {
1892 info: gitkraft_core::RepoInfo {
1893 path: std::path::PathBuf::from(format!("{workdir}/.git")),
1894 workdir: Some(std::path::PathBuf::from(workdir)),
1895 head_branch: Some("main".into()),
1896 is_bare: false,
1897 state: gitkraft_core::RepoState::Clean,
1898 },
1899 branches: Vec::new(),
1900 commits: Vec::new(),
1901 graph_rows: Vec::new(),
1902 unstaged: Vec::new(),
1903 staged: Vec::new(),
1904 stashes: Vec::new(),
1905 remotes: Vec::new(),
1906 }
1907 }
1908
1909 fn setup_loaded_tab(tab: &mut RepoTab, path: &str) {
1911 tab.repo_path = Some(std::path::PathBuf::from(path));
1912 tab.repo_info = Some(gitkraft_core::RepoInfo {
1913 path: std::path::PathBuf::from(format!("{path}/.git")),
1914 workdir: Some(std::path::PathBuf::from(path)),
1915 head_branch: Some("main".into()),
1916 is_bare: false,
1917 state: gitkraft_core::RepoState::Clean,
1918 });
1919 }
1920
1921 #[test]
1922 fn open_repo_creates_new_tab_when_repo_already_open() {
1923 use crate::message::Message;
1924 let mut state = GitKraft::new();
1925 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1926
1927 assert_eq!(state.tabs.len(), 1);
1928 assert_eq!(state.active_tab, 0);
1929
1930 let _task = state.update(Message::OpenRepo);
1932
1933 assert_eq!(state.tabs.len(), 2);
1934 assert_eq!(state.active_tab, 1);
1935 assert!(state.tabs[1].is_loading);
1937 assert_eq!(
1939 state.tabs[0].repo_path.as_deref(),
1940 Some(std::path::Path::new("/home/user/repo-a"))
1941 );
1942 }
1943
1944 #[test]
1945 fn open_repo_reuses_empty_tab() {
1946 use crate::message::Message;
1947 let mut state = GitKraft::new();
1948 assert!(!state.active_tab().has_repo());
1950
1951 let _task = state.update(Message::OpenRepo);
1952
1953 assert_eq!(state.tabs.len(), 1);
1955 assert_eq!(state.active_tab, 0);
1956 assert!(state.tabs[0].is_loading);
1957 }
1958
1959 #[test]
1960 fn repo_selected_deduplicates_already_open_repo() {
1961 use crate::message::Message;
1962 let mut state = GitKraft::new();
1963 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1965 state.tabs.push(RepoTab::new_empty());
1967 state.active_tab = 1;
1968
1969 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
1971 "/home/user/repo-a",
1972 ))));
1973
1974 assert_eq!(state.tabs.len(), 1);
1976 assert_eq!(state.active_tab, 0);
1977 assert_eq!(
1978 state.tabs[0].repo_path.as_deref(),
1979 Some(std::path::Path::new("/home/user/repo-a"))
1980 );
1981 }
1982
1983 #[test]
1984 fn repo_selected_opens_new_repo_in_empty_tab() {
1985 use crate::message::Message;
1986 let mut state = GitKraft::new();
1987 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1989 state.tabs.push(RepoTab::new_empty());
1991 state.active_tab = 1;
1992
1993 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
1995 "/home/user/repo-b",
1996 ))));
1997
1998 assert_eq!(state.tabs.len(), 2);
2000 assert_eq!(state.active_tab, 1);
2001 assert!(state.tabs[1]
2002 .status_message
2003 .as_deref()
2004 .unwrap_or("")
2005 .contains("repo-b"));
2006 }
2007
2008 #[test]
2009 fn repo_selected_cancel_removes_empty_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 state.tabs.push(RepoTab::new_empty());
2016 state.active_tab = 1;
2017
2018 let _task = state.update(Message::RepoSelected(None));
2020
2021 assert_eq!(state.tabs.len(), 1);
2023 assert_eq!(state.active_tab, 0);
2024 assert_eq!(
2025 state.tabs[0].repo_path.as_deref(),
2026 Some(std::path::Path::new("/home/user/repo-a"))
2027 );
2028 }
2029
2030 #[test]
2031 fn repo_selected_cancel_keeps_tab_if_only_one() {
2032 use crate::message::Message;
2033 let mut state = GitKraft::new();
2034 assert_eq!(state.tabs.len(), 1);
2036 assert!(!state.active_tab().has_repo());
2037
2038 let _task = state.update(Message::RepoSelected(None));
2039
2040 assert_eq!(state.tabs.len(), 1);
2041 assert!(!state.active_tab().is_loading);
2042 }
2043
2044 #[test]
2045 fn open_recent_repo_deduplicates() {
2046 use crate::message::Message;
2047 let mut state = GitKraft::new();
2048 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2050 state.tabs.push(RepoTab::new_empty());
2052 state.active_tab = 1;
2053
2054 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2056 "/home/user/repo-a",
2057 )));
2058
2059 assert_eq!(state.active_tab, 0);
2060 }
2061
2062 #[test]
2063 fn open_recent_repo_creates_new_tab_when_current_has_repo() {
2064 use crate::message::Message;
2065 let mut state = GitKraft::new();
2066 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2068
2069 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2071 "/home/user/repo-b",
2072 )));
2073
2074 assert_eq!(state.tabs.len(), 2);
2075 assert_eq!(state.active_tab, 1);
2076 assert!(state.tabs[1].is_loading);
2077 }
2078
2079 #[test]
2080 fn open_recent_repo_uses_empty_tab() {
2081 use crate::message::Message;
2082 let mut state = GitKraft::new();
2083 assert!(!state.active_tab().has_repo());
2085
2086 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2087 "/home/user/repo-b",
2088 )));
2089
2090 assert_eq!(state.tabs.len(), 1);
2092 assert_eq!(state.active_tab, 0);
2093 assert!(state.tabs[0].is_loading);
2094 }
2095
2096 #[test]
2099 fn repo_refreshed_targets_correct_tab_after_tab_switch() {
2100 use crate::message::Message;
2101 let mut state = GitKraft::new();
2102 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2104 state.tabs.push(RepoTab::new_empty());
2106 state.active_tab = 1; let payload = fake_payload("/home/user/repo-a");
2110 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2111
2112 assert!(
2114 state.tabs[0].repo_info.is_some(),
2115 "tab 0 should still have repo info after refresh"
2116 );
2117 assert_eq!(
2118 state.tabs[0].current_branch.as_deref(),
2119 Some("main"),
2120 "tab 0 should have updated branch from payload"
2121 );
2122 assert!(
2124 state.tabs[1].repo_info.is_none(),
2125 "tab 1 (empty) must NOT receive the refresh payload"
2126 );
2127 assert!(
2128 state.tabs[1].repo_path.is_none(),
2129 "tab 1 should still have no repo path"
2130 );
2131 }
2132
2133 #[test]
2134 fn repo_refreshed_targets_active_tab_for_new_open() {
2135 use crate::message::Message;
2136 let mut state = GitKraft::new();
2137 assert_eq!(state.tabs.len(), 1);
2141 assert!(!state.active_tab().has_repo());
2142
2143 let payload = fake_payload("/home/user/new-repo");
2144 let _task = state.update(Message::RepoOpened(Ok(payload)));
2145
2146 assert_eq!(
2148 state.tabs[0].repo_path.as_deref(),
2149 Some(std::path::Path::new("/home/user/new-repo"))
2150 );
2151 assert!(state.tabs[0].repo_info.is_some());
2152 }
2153
2154 #[test]
2155 fn repo_refreshed_does_not_duplicate_into_new_tab() {
2156 use crate::message::Message;
2157 let mut state = GitKraft::new();
2158 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2160
2161 let _task = state.update(Message::NewTab);
2163 assert_eq!(state.tabs.len(), 2);
2164 assert_eq!(state.active_tab, 1);
2165
2166 let payload = fake_payload("/home/user/repo-a");
2168 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2169
2170 assert!(
2172 state.tabs[1].repo_path.is_none(),
2173 "new empty tab must not receive repo-a refresh"
2174 );
2175 assert!(
2176 state.tabs[1].repo_info.is_none(),
2177 "new empty tab must not have repo_info"
2178 );
2179 assert_eq!(
2181 state.tabs[0].repo_path.as_deref(),
2182 Some(std::path::Path::new("/home/user/repo-a"))
2183 );
2184 }
2185
2186 #[test]
2187 fn git_operation_result_targets_correct_tab() {
2188 use crate::message::Message;
2189 let mut state = GitKraft::new();
2190 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2192 state.tabs.push(RepoTab::new_empty());
2194 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2195 state.active_tab = 1;
2196
2197 let payload = fake_payload("/home/user/repo-a");
2200 let _task = state.update(Message::GitOperationResult(Ok(payload)));
2201
2202 assert_eq!(state.tabs[0].current_branch.as_deref(), Some("main"));
2204 assert_eq!(
2206 state.tabs[1].repo_path.as_deref(),
2207 Some(std::path::Path::new("/home/user/repo-b"))
2208 );
2209 }
2210
2211 #[test]
2212 fn multiple_new_tabs_dont_get_polluted_by_refresh() {
2213 use crate::message::Message;
2214 let mut state = GitKraft::new();
2215 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2217
2218 let _task = state.update(Message::NewTab);
2220 let _task = state.update(Message::NewTab);
2221 assert_eq!(state.tabs.len(), 3);
2222 assert_eq!(state.active_tab, 2);
2223
2224 let payload = fake_payload("/home/user/repo-a");
2226 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2227
2228 assert!(state.tabs[0].repo_info.is_some());
2230 assert!(state.tabs[1].repo_info.is_none());
2231 assert!(state.tabs[2].repo_info.is_none());
2232 assert!(state.tabs[1].repo_path.is_none());
2233 assert!(state.tabs[2].repo_path.is_none());
2234 }
2235
2236 #[test]
2237 fn repo_selected_dedup_adjusts_index_when_existing_is_after_active() {
2238 use crate::message::Message;
2239 let mut state = GitKraft::new();
2240 state.tabs.push(RepoTab::new_empty());
2243 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
2244 state.active_tab = 0;
2245
2246 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2248 "/home/user/repo-a",
2249 ))));
2250
2251 assert_eq!(state.tabs.len(), 1);
2253 assert_eq!(state.active_tab, 0);
2254 assert_eq!(
2255 state.tabs[0].repo_path.as_deref(),
2256 Some(std::path::Path::new("/home/user/repo-a"))
2257 );
2258 }
2259
2260 #[test]
2263 fn apply_payload_preserves_multi_selection_by_oid() {
2264 let mut tab = RepoTab::new_empty();
2265 tab.commits = make_test_commits(5);
2266 tab.anchor_commit_index = Some(1);
2268 tab.selected_commits = vec![1, 2, 3];
2269 tab.selected_commit = Some(3);
2270 tab.selected_commit_oid = Some(tab.commits[3].oid.clone());
2271
2272 let mut payload = fake_payload("/tmp/repo");
2274 payload.commits = make_test_commits(5);
2275
2276 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2277
2278 assert_eq!(tab.anchor_commit_index, Some(1));
2280 assert_eq!(tab.selected_commits, vec![1, 2, 3]);
2281 assert_eq!(tab.selected_commit, Some(3));
2282 }
2283
2284 #[test]
2285 fn apply_payload_preserves_anchor_even_without_range() {
2286 let mut tab = RepoTab::new_empty();
2287 tab.commits = make_test_commits(5);
2288 tab.anchor_commit_index = Some(2);
2289 tab.selected_commit = Some(2);
2290 tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2291 let mut payload = fake_payload("/tmp/repo");
2294 payload.commits = make_test_commits(5);
2295
2296 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2297
2298 assert_eq!(tab.anchor_commit_index, Some(2));
2299 assert_eq!(tab.selected_commit, Some(2));
2300 }
2301
2302 #[test]
2303 fn apply_payload_clears_selection_when_commits_disappear() {
2304 let mut tab = RepoTab::new_empty();
2305 tab.commits = make_test_commits(5);
2306 tab.anchor_commit_index = Some(2);
2307 tab.selected_commits = vec![2, 3, 4];
2308 tab.selected_commit = Some(4);
2309 tab.selected_commit_oid = Some(tab.commits[4].oid.clone());
2310
2311 let mut payload = fake_payload("/tmp/repo");
2313 payload.commits = (0..3)
2314 .map(|i| gitkraft_core::CommitInfo {
2315 oid: format!("new_oid_{i}"),
2316 short_oid: format!("new_{i}"),
2317 summary: format!("new commit {i}"),
2318 message: String::new(),
2319 author_name: "Author".into(),
2320 author_email: "a@b.c".into(),
2321 time: Default::default(),
2322 parent_ids: Vec::new(),
2323 })
2324 .collect();
2325
2326 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2327
2328 assert!(tab.selected_commits.is_empty());
2330 assert!(tab.anchor_commit_index.is_none());
2331 assert!(tab.selected_commit.is_none());
2332 }
2333
2334 #[test]
2337 fn apply_payload_preserves_file_selection_when_commit_survives() {
2338 let mut tab = RepoTab::new_empty();
2339 tab.commits = make_test_commits(3);
2340 tab.selected_commit = Some(1);
2341 tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2342 tab.commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
2343
2344 tab.anchor_file_index = Some(0);
2346 tab.selected_commit_file_indices = vec![0, 1, 2];
2347 tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2348 old_file: String::new(),
2349 new_file: "a.rs".into(),
2350 status: gitkraft_core::FileStatus::Modified,
2351 hunks: vec![],
2352 }];
2353
2354 let mut payload = fake_payload("/tmp/repo");
2356 payload.commits = make_test_commits(3);
2357
2358 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2359
2360 assert_eq!(tab.anchor_file_index, Some(0));
2362 assert_eq!(tab.selected_commit_file_indices, vec![0, 1, 2]);
2363 assert!(
2364 !tab.multi_file_diffs.is_empty(),
2365 "multi_file_diffs must survive when commit is preserved"
2366 );
2367 }
2368
2369 #[test]
2370 fn apply_payload_clears_file_selection_when_commit_disappears() {
2371 let mut tab = RepoTab::new_empty();
2372 tab.commits = make_test_commits(3);
2373 tab.selected_commit = Some(1);
2374 tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2375 tab.commit_files = make_commit_files(&["a.rs", "b.rs"]);
2376 tab.anchor_file_index = Some(0);
2377 tab.selected_commit_file_indices = vec![0, 1];
2378 tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2379 old_file: String::new(),
2380 new_file: "a.rs".into(),
2381 status: gitkraft_core::FileStatus::Modified,
2382 hunks: vec![],
2383 }];
2384
2385 let mut payload = fake_payload("/tmp/repo");
2387 payload.commits = (0..2)
2388 .map(|i| gitkraft_core::CommitInfo {
2389 oid: format!("new_{i}"),
2390 short_oid: format!("n{i}"),
2391 summary: String::new(),
2392 message: String::new(),
2393 author_name: String::new(),
2394 author_email: String::new(),
2395 time: Default::default(),
2396 parent_ids: Vec::new(),
2397 })
2398 .collect();
2399
2400 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2401
2402 assert!(tab.anchor_file_index.is_none());
2404 assert!(tab.selected_commit_file_indices.is_empty());
2405 assert!(tab.multi_file_diffs.is_empty());
2406 assert!(tab.commit_files.is_empty());
2407 }
2408
2409 #[test]
2410 fn apply_payload_preserves_commit_range_diffs_when_commit_survives() {
2411 let mut tab = RepoTab::new_empty();
2412 tab.commits = make_test_commits(5);
2413 tab.selected_commit = Some(2);
2414 tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2415 tab.commit_range_diffs = vec![gitkraft_core::DiffInfo {
2416 old_file: String::new(),
2417 new_file: "x.rs".into(),
2418 status: gitkraft_core::FileStatus::Modified,
2419 hunks: vec![],
2420 }];
2421
2422 let mut payload = fake_payload("/tmp/repo");
2423 payload.commits = make_test_commits(5);
2424
2425 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2426
2427 assert!(
2428 !tab.commit_range_diffs.is_empty(),
2429 "commit_range_diffs must survive when commit is preserved"
2430 );
2431 }
2432}