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 let sol_idx = gitkraft_core::theme_index_by_name("Solarized Light");
886 state.current_theme_index = sol_idx;
887 let iced_t = state.iced_theme();
888 let pal = iced_t.palette();
889 assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
890 assert_eq!(iced_t.to_string(), "Solarized Light");
891
892 let gruvbox_idx = gitkraft_core::theme_index_by_name("Gruvbox Dark");
894 state.current_theme_index = gruvbox_idx;
895 let iced_t = state.iced_theme();
896 let pal = iced_t.palette();
897 let core = gitkraft_core::theme_by_index(gruvbox_idx);
898 let expected_accent = rgb_to_iced(core.accent);
899 assert!(
900 (pal.primary.r - expected_accent.r).abs() < 0.01
901 && (pal.primary.g - expected_accent.g).abs() < 0.01
902 && (pal.primary.b - expected_accent.b).abs() < 0.01,
903 "Gruvbox Dark accent should match core accent"
904 );
905 }
906
907 #[test]
908 fn iced_theme_name_round_trips_through_core() {
909 for i in 0..gitkraft_core::THEME_COUNT {
912 let mut state = GitKraft::new();
913 state.current_theme_index = i;
914 let iced_t = state.iced_theme();
915 let name = iced_t.to_string();
916 let resolved = gitkraft_core::theme_index_by_name(&name);
917 assert_eq!(
918 resolved,
919 i,
920 "theme index {i} ({}) did not round-trip through iced_theme name",
921 gitkraft_core::THEME_NAMES[i]
922 );
923 }
924 }
925
926 #[test]
927 fn current_theme_name_round_trips() {
928 let mut state = GitKraft::new();
929 state.current_theme_index = gitkraft_core::theme_index_by_name("Dracula");
930 assert_eq!(state.current_theme_name(), "Dracula");
931 state.current_theme_index = 0;
932 assert_eq!(state.current_theme_name(), "Default");
933 }
934
935 #[test]
936 fn repo_tab_new_empty() {
937 let tab = RepoTab::new_empty();
938 assert!(tab.repo_path.is_none());
939 assert!(!tab.has_repo());
940 assert_eq!(tab.display_name(), "New Tab");
941 assert!(tab.commits.is_empty());
942 assert!(tab.branches.is_empty());
943 assert!(!tab.is_loading);
944 }
945
946 #[test]
947 fn repo_tab_display_name_with_path() {
948 let mut tab = RepoTab::new_empty();
949 tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
950 assert!(tab.has_repo());
951 assert_eq!(tab.display_name(), "cool-repo");
952 }
953
954 #[test]
955 fn search_defaults() {
956 let state = GitKraft::new();
957 assert!(!state.search_visible);
958 assert!(state.search_query.is_empty());
959 assert!(state.search_results.is_empty());
960 assert!(state.search_selected.is_none());
961 }
962
963 #[test]
964 fn context_menu_variants_exist() {
965 use crate::state::ContextMenu;
967
968 let _branch = ContextMenu::Branch {
969 name: "main".to_string(),
970 is_current: true,
971 local_index: 0,
972 };
973 let _remote = ContextMenu::RemoteBranch {
974 name: "origin/main".to_string(),
975 };
976 let _commit = ContextMenu::Commit {
977 index: 0,
978 oid: "abc1234".to_string(),
979 };
980 let _stash = ContextMenu::Stash { index: 0 };
981 let _unstaged = ContextMenu::UnstagedFile {
982 path: "src/main.rs".to_string(),
983 };
984 let _staged = ContextMenu::StagedFile {
985 path: "src/lib.rs".to_string(),
986 };
987 }
988
989 #[test]
990 fn repo_tab_context_menu_defaults_to_none() {
991 let tab = crate::state::RepoTab::new_empty();
992 assert!(tab.context_menu.is_none());
993 }
994
995 #[test]
996 fn context_menu_variants_constructable() {
997 use crate::state::ContextMenu;
998 let _ = ContextMenu::Stash { index: 0 };
999 let _ = ContextMenu::UnstagedFile {
1000 path: "a.rs".into(),
1001 };
1002 let _ = ContextMenu::StagedFile {
1003 path: "b.rs".into(),
1004 };
1005 }
1006
1007 #[test]
1008 fn selected_unstaged_defaults_empty() {
1009 let tab = crate::state::RepoTab::new_empty();
1010 assert!(tab.selected_unstaged.is_empty());
1011 assert!(tab.selected_staged.is_empty());
1012 }
1013
1014 #[test]
1015 fn selected_unstaged_toggle() {
1016 let mut tab = crate::state::RepoTab::new_empty();
1017 tab.selected_unstaged.insert("a.rs".to_string());
1018 tab.selected_unstaged.insert("b.rs".to_string());
1019 assert_eq!(tab.selected_unstaged.len(), 2);
1020 assert!(tab.selected_unstaged.contains("a.rs"));
1021 tab.selected_unstaged.remove("a.rs");
1022 assert_eq!(tab.selected_unstaged.len(), 1);
1023 assert!(!tab.selected_unstaged.contains("a.rs"));
1024 }
1025
1026 #[test]
1027 fn detect_system_editor_returns_valid() {
1028 let editor = super::detect_system_editor();
1030 let _ = editor.display_name();
1031 }
1032
1033 #[test]
1036 fn selected_commit_file_indices_defaults_to_empty_vec() {
1037 let tab = RepoTab::new_empty();
1038 assert!(tab.selected_commit_file_indices.is_empty());
1039 let v: &Vec<usize> = &tab.selected_commit_file_indices;
1041 assert_eq!(v.len(), 0);
1042 }
1043
1044 #[test]
1045 fn multi_file_diffs_defaults_empty() {
1046 let tab = RepoTab::new_empty();
1047 assert!(tab.multi_file_diffs.is_empty());
1048 }
1049
1050 #[test]
1051 fn keyboard_modifiers_default_has_no_shift() {
1052 let state = GitKraft::new();
1053 assert!(!state.keyboard_modifiers.shift());
1054 }
1055
1056 #[test]
1057 fn selected_commit_file_indices_preserves_insertion_order() {
1058 let mut tab = RepoTab::new_empty();
1059 tab.selected_commit_file_indices.push(5);
1060 tab.selected_commit_file_indices.push(2);
1061 tab.selected_commit_file_indices.push(8);
1062 assert_eq!(tab.selected_commit_file_indices, vec![5, 2, 8]);
1063 }
1064
1065 #[test]
1066 fn selected_commit_file_indices_cleared_on_reset() {
1067 let mut tab = RepoTab::new_empty();
1068 tab.selected_commit_file_indices.push(0);
1069 tab.selected_commit_file_indices.push(1);
1070 tab.selected_commit_file_indices.clear();
1071 assert!(tab.selected_commit_file_indices.is_empty());
1072 }
1073
1074 #[test]
1075 fn multi_file_diffs_cleared_on_reset() {
1076 let mut tab = RepoTab::new_empty();
1077 tab.multi_file_diffs.push(gitkraft_core::DiffInfo {
1078 old_file: String::new(),
1079 new_file: "a.rs".to_string(),
1080 status: gitkraft_core::FileStatus::Modified,
1081 hunks: vec![],
1082 });
1083 tab.multi_file_diffs.clear();
1084 assert!(tab.multi_file_diffs.is_empty());
1085 }
1086
1087 #[test]
1088 fn commit_range_diffs_defaults_empty() {
1089 let tab = RepoTab::new_empty();
1090 assert!(tab.commit_range_diffs.is_empty());
1091 }
1092
1093 #[test]
1094 fn commit_range_diffs_cleared_on_apply_payload() {
1095 let mut tab = RepoTab::new_empty();
1097 tab.commit_range_diffs.push(gitkraft_core::DiffInfo {
1098 old_file: String::new(),
1099 new_file: "x.rs".to_string(),
1100 status: gitkraft_core::FileStatus::Modified,
1101 hunks: vec![],
1102 });
1103 tab.commit_range_diffs.clear();
1104 assert!(tab.commit_range_diffs.is_empty());
1105 }
1106
1107 #[test]
1110 fn modifiers_changed_sets_shift_state() {
1111 use crate::message::Message;
1112 let mut state = GitKraft::new();
1113 assert!(!state.keyboard_modifiers.shift());
1114
1115 let _ = state.update(Message::ModifiersChanged(iced::keyboard::Modifiers::SHIFT));
1116 assert!(state.keyboard_modifiers.shift());
1117
1118 let _ = state.update(Message::ModifiersChanged(
1119 iced::keyboard::Modifiers::default(),
1120 ));
1121 assert!(!state.keyboard_modifiers.shift());
1122 }
1123
1124 fn make_commit_files(names: &[&str]) -> Vec<gitkraft_core::DiffFileEntry> {
1127 names
1128 .iter()
1129 .map(|name| gitkraft_core::DiffFileEntry {
1130 old_file: String::new(),
1131 new_file: name.to_string(),
1132 status: gitkraft_core::FileStatus::Modified,
1133 })
1134 .collect()
1135 }
1136
1137 #[test]
1138 fn select_diff_by_index_regular_click_clears_multi_selection() {
1139 use crate::message::Message;
1140 let mut state = GitKraft::new();
1141 state.active_tab_mut().repo_path =
1145 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1146 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1147 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1148 state.active_tab_mut().selected_commit_file_indices = vec![0, 1];
1150
1151 let _ = state.update(Message::SelectDiffByIndex(0));
1153
1154 assert!(state.active_tab().selected_commit_file_indices.is_empty());
1155 assert_eq!(state.active_tab().selected_file_index, Some(0));
1156 }
1157
1158 #[test]
1159 fn regular_click_preserves_multi_file_diffs_until_load_completes() {
1160 use crate::message::Message;
1161 let mut state = GitKraft::new();
1162 state.active_tab_mut().repo_path =
1163 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1164 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1165 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1166
1167 state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1169 old_file: String::new(),
1170 new_file: "a.rs".to_string(),
1171 status: gitkraft_core::FileStatus::Modified,
1172 hunks: vec![],
1173 }];
1174
1175 let _ = state.update(Message::SelectDiffByIndex(1));
1178
1179 assert!(
1180 !state.active_tab().multi_file_diffs.is_empty(),
1181 "multi_file_diffs must NOT be cleared on click (deferred to diff load)"
1182 );
1183 assert_eq!(state.active_tab().selected_file_index, Some(1));
1184 assert!(state.active_tab().is_loading_file_diff);
1185 }
1186
1187 #[test]
1188 fn regular_click_preserves_diff_scroll_until_load_completes() {
1189 use crate::message::Message;
1190 let mut state = GitKraft::new();
1191 state.active_tab_mut().repo_path =
1192 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1193 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1194 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1195 state.active_tab_mut().diff_scroll_offset = 150.0;
1196
1197 let _ = state.update(Message::SelectDiffByIndex(1));
1199
1200 assert_eq!(
1201 state.active_tab().diff_scroll_offset,
1202 150.0,
1203 "diff_scroll_offset must NOT be reset on click (deferred to diff load)"
1204 );
1205 }
1206
1207 #[test]
1208 fn single_file_diff_loaded_clears_deferred_state() {
1209 use crate::message::Message;
1210 let mut state = GitKraft::new();
1211 state.active_tab_mut().repo_path =
1212 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1213 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1214 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1215
1216 state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1218 old_file: String::new(),
1219 new_file: "a.rs".to_string(),
1220 status: gitkraft_core::FileStatus::Modified,
1221 hunks: vec![],
1222 }];
1223 state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1224 old_file: String::new(),
1225 new_file: "b.rs".to_string(),
1226 status: gitkraft_core::FileStatus::Modified,
1227 hunks: vec![],
1228 }];
1229 state.active_tab_mut().is_loading_file_diff = true;
1230 state.active_tab_mut().diff_scroll_offset = 200.0;
1231
1232 let new_diff = gitkraft_core::DiffInfo {
1234 old_file: String::new(),
1235 new_file: "b.rs".to_string(),
1236 status: gitkraft_core::FileStatus::Modified,
1237 hunks: vec![],
1238 };
1239 let _ = state.update(Message::SingleFileDiffLoaded(Ok(new_diff)));
1240
1241 assert!(
1243 state.active_tab().multi_file_diffs.is_empty(),
1244 "multi_file_diffs must be cleared when new diff arrives"
1245 );
1246 assert!(
1247 state.active_tab().commit_range_diffs.is_empty(),
1248 "commit_range_diffs must be cleared when new diff arrives"
1249 );
1250 assert!(
1251 state.active_tab().selected_diff.is_some(),
1252 "selected_diff must be set to the loaded diff"
1253 );
1254 assert!(
1255 !state.active_tab().is_loading_file_diff,
1256 "is_loading_file_diff must be cleared after load"
1257 );
1258 assert_eq!(
1259 state.active_tab().diff_scroll_offset,
1260 0.0,
1261 "diff_scroll_offset must be reset when new diff arrives"
1262 );
1263 }
1264
1265 #[test]
1266 fn regular_click_preserves_commit_range_diffs_until_load_completes() {
1267 use crate::message::Message;
1268 let mut state = GitKraft::new();
1269 state.active_tab_mut().repo_path =
1270 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1271 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1272 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1273
1274 state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1276 old_file: String::new(),
1277 new_file: "x.rs".to_string(),
1278 status: gitkraft_core::FileStatus::Modified,
1279 hunks: vec![],
1280 }];
1281
1282 let _ = state.update(Message::SelectDiffByIndex(0));
1283
1284 assert!(
1285 !state.active_tab().commit_range_diffs.is_empty(),
1286 "commit_range_diffs must NOT be cleared on click (deferred to diff load)"
1287 );
1288 }
1289
1290 #[test]
1291 fn select_diff_by_index_shift_click_adds_both_files_to_selection() {
1292 use crate::message::Message;
1293 let mut state = GitKraft::new();
1294 state.active_tab_mut().repo_path =
1295 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1296 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1297 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1298 state.active_tab_mut().selected_file_index = Some(0);
1299
1300 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1302 let _ = state.update(Message::SelectDiffByIndex(1));
1303
1304 let sel = &state.active_tab().selected_commit_file_indices;
1305 assert!(sel.contains(&0), "anchor file 0 should be selected");
1306 assert!(sel.contains(&1), "newly clicked file 1 should be selected");
1307 assert_eq!(sel.len(), 2);
1308 }
1309
1310 #[test]
1311 fn anchor_file_index_defaults_to_none() {
1312 let tab = RepoTab::new_empty();
1313 assert!(tab.anchor_file_index.is_none());
1314 }
1315
1316 #[test]
1317 fn regular_click_sets_anchor() {
1318 use crate::message::Message;
1319 let mut state = GitKraft::new();
1320 state.active_tab_mut().repo_path =
1321 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1322 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1323 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1324
1325 let _ = state.update(Message::SelectDiffByIndex(2));
1326
1327 assert_eq!(
1328 state.active_tab().anchor_file_index,
1329 Some(2),
1330 "regular click must set anchor to the clicked index"
1331 );
1332 }
1333
1334 #[test]
1335 fn shift_click_selects_range_downward_from_anchor() {
1336 use crate::message::Message;
1337 let mut state = GitKraft::new();
1338 state.active_tab_mut().repo_path =
1339 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1340 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1341 state.active_tab_mut().commit_files =
1342 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1343 state.active_tab_mut().anchor_file_index = Some(1);
1345 state.active_tab_mut().selected_file_index = Some(1);
1346
1347 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1349 let _ = state.update(Message::SelectDiffByIndex(4));
1350
1351 let sel = &state.active_tab().selected_commit_file_indices;
1352 assert_eq!(
1353 sel,
1354 &vec![1, 2, 3, 4],
1355 "range must be contiguous from anchor to click"
1356 );
1357 }
1358
1359 #[test]
1360 fn shift_click_selects_range_upward_from_anchor() {
1361 use crate::message::Message;
1362 let mut state = GitKraft::new();
1363 state.active_tab_mut().repo_path =
1364 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1365 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1366 state.active_tab_mut().commit_files =
1367 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1368 state.active_tab_mut().anchor_file_index = Some(4);
1370 state.active_tab_mut().selected_file_index = Some(4);
1371
1372 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1374 let _ = state.update(Message::SelectDiffByIndex(1));
1375
1376 let sel = &state.active_tab().selected_commit_file_indices;
1377 assert_eq!(
1378 sel,
1379 &vec![1, 2, 3, 4],
1380 "range must be stored ascending regardless of click direction"
1381 );
1382 }
1383
1384 #[test]
1385 fn shift_click_anchor_fixed_on_subsequent_clicks() {
1386 use crate::message::Message;
1387 let mut state = GitKraft::new();
1388 state.active_tab_mut().repo_path =
1389 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1390 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1391 state.active_tab_mut().commit_files =
1392 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1393 state.active_tab_mut().anchor_file_index = Some(2);
1395 state.active_tab_mut().selected_file_index = Some(2);
1396 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1397
1398 let _ = state.update(Message::SelectDiffByIndex(4));
1400 assert_eq!(
1401 state.active_tab().selected_commit_file_indices,
1402 vec![2, 3, 4]
1403 );
1404
1405 let _ = state.update(Message::SelectDiffByIndex(3));
1407 assert_eq!(
1408 state.active_tab().selected_commit_file_indices,
1409 vec![2, 3],
1410 "anchor must stay fixed; second Shift+Click shrinks the range"
1411 );
1412
1413 let _ = state.update(Message::SelectDiffByIndex(0));
1415 assert_eq!(
1416 state.active_tab().selected_commit_file_indices,
1417 vec![0, 1, 2],
1418 "anchor must stay fixed; can extend range in either direction"
1419 );
1420 }
1421
1422 #[test]
1423 fn shift_click_on_anchor_itself_gives_single_item_range() {
1424 use crate::message::Message;
1425 let mut state = GitKraft::new();
1426 state.active_tab_mut().repo_path =
1427 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1428 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1429 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1430 state.active_tab_mut().anchor_file_index = Some(1);
1431 state.active_tab_mut().selected_file_index = Some(1);
1432
1433 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1435 let _ = state.update(Message::SelectDiffByIndex(1));
1436
1437 assert_eq!(state.active_tab().selected_commit_file_indices, vec![1]);
1438 assert!(
1439 state.active_tab().multi_file_diffs.is_empty(),
1440 "single-item range must not populate multi_file_diffs"
1441 );
1442 }
1443
1444 #[test]
1445 fn shift_click_range_is_always_ascending() {
1446 use crate::message::Message;
1447 let mut state = GitKraft::new();
1448 state.active_tab_mut().repo_path =
1449 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1450 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1451 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs"]);
1452 state.active_tab_mut().anchor_file_index = Some(3);
1453 state.active_tab_mut().selected_file_index = Some(3);
1454
1455 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1456 let _ = state.update(Message::SelectDiffByIndex(0));
1457
1458 let sel = &state.active_tab().selected_commit_file_indices;
1459 let is_sorted = sel.windows(2).all(|w| w[0] < w[1]);
1460 assert!(
1461 is_sorted,
1462 "selection must always be stored in ascending order"
1463 );
1464 assert_eq!(sel, &vec![0, 1, 2, 3]);
1465 }
1466
1467 #[test]
1468 fn checkout_file_at_commit_message_variants_exist() {
1469 use crate::message::Message;
1470 let _single =
1472 Message::CheckoutFileAtCommit("abc123".to_string(), "src/main.rs".to_string());
1473 let _multi = Message::CheckoutMultiFilesAtCommit(
1474 "abc123".to_string(),
1475 vec!["a.rs".to_string(), "b.rs".to_string()],
1476 );
1477 }
1478
1479 #[test]
1480 fn checkout_file_at_commit_closes_context_menu() {
1481 use crate::message::Message;
1482 let mut state = GitKraft::new();
1483 state.active_tab_mut().repo_path =
1484 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1485 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1486 oid: "abc123".to_string(),
1487 file_path: "src/main.rs".to_string(),
1488 });
1489 let _ = state.update(Message::CheckoutFileAtCommit(
1490 "abc123".to_string(),
1491 "src/main.rs".to_string(),
1492 ));
1493 assert!(state.active_tab().context_menu.is_none());
1494 }
1495
1496 #[test]
1497 fn checkout_multi_files_at_commit_closes_context_menu() {
1498 use crate::message::Message;
1499 let mut state = GitKraft::new();
1500 state.active_tab_mut().repo_path =
1501 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1502 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1503 oid: "abc123".to_string(),
1504 file_path: "src/main.rs".to_string(),
1505 });
1506 let _ = state.update(Message::CheckoutMultiFilesAtCommit(
1507 "abc123".to_string(),
1508 vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
1509 ));
1510 assert!(state.active_tab().context_menu.is_none());
1511 }
1512
1513 fn make_test_commits(count: usize) -> Vec<gitkraft_core::CommitInfo> {
1516 (0..count)
1517 .map(|i| gitkraft_core::CommitInfo {
1518 oid: i.to_string(),
1519 short_oid: i.to_string(),
1520 summary: String::new(),
1521 message: String::new(),
1522 author_name: String::new(),
1523 author_email: String::new(),
1524 time: Default::default(),
1525 parent_ids: Vec::new(),
1526 })
1527 .collect()
1528 }
1529
1530 #[test]
1531 fn selected_commits_defaults_empty() {
1532 let tab = RepoTab::new_empty();
1533 assert!(tab.selected_commits.is_empty());
1534 assert!(tab.anchor_commit_index.is_none());
1535 }
1536
1537 #[test]
1538 fn regular_click_commit_sets_anchor_and_clears_range() {
1539 use crate::message::Message;
1540 let mut state = GitKraft::new();
1541 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
1542 state.active_tab_mut().commits = make_test_commits(3);
1543 state.active_tab_mut().selected_commits = vec![0, 1, 2];
1544
1545 let _ = state.update(Message::SelectCommit(1));
1546
1547 assert_eq!(state.active_tab().anchor_commit_index, Some(1));
1548 assert!(state.active_tab().selected_commits.is_empty());
1549 assert_eq!(state.active_tab().selected_commit, Some(1));
1550 }
1551
1552 #[test]
1553 fn shift_click_commit_selects_range_from_anchor() {
1554 use crate::message::Message;
1555 let mut state = GitKraft::new();
1556 state.active_tab_mut().commits = make_test_commits(5);
1557 state.active_tab_mut().anchor_commit_index = Some(1);
1558 state.active_tab_mut().selected_commit = Some(1);
1559
1560 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1561 let _ = state.update(Message::SelectCommit(4));
1562
1563 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3, 4]);
1564 }
1565
1566 #[test]
1567 fn shift_click_commit_range_is_ascending_when_clicking_above_anchor() {
1568 use crate::message::Message;
1569 let mut state = GitKraft::new();
1570 state.active_tab_mut().commits = make_test_commits(5);
1571 state.active_tab_mut().anchor_commit_index = Some(3);
1572 state.active_tab_mut().selected_commit = Some(3);
1573
1574 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1575 let _ = state.update(Message::SelectCommit(1));
1576
1577 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3]);
1578 }
1579
1580 #[test]
1583 fn execute_commit_action_closes_context_menu() {
1584 use crate::message::Message;
1585 let mut state = GitKraft::new();
1586 state.active_tab_mut().repo_path =
1587 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1588 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::Commit {
1589 index: 0,
1590 oid: "abc123".to_string(),
1591 });
1592
1593 let _ = state.update(Message::ExecuteCommitAction(
1594 "abc123".to_string(),
1595 gitkraft_core::CommitAction::CherryPick,
1596 ));
1597
1598 assert!(state.active_tab().context_menu.is_none());
1599 }
1600
1601 #[test]
1602 fn execute_commit_action_sets_loading_when_repo_open() {
1603 use crate::message::Message;
1604 let mut state = GitKraft::new();
1605 state.active_tab_mut().repo_path =
1606 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1607
1608 let _ = state.update(Message::ExecuteCommitAction(
1609 "abc123".to_string(),
1610 gitkraft_core::CommitAction::ResetHard,
1611 ));
1612
1613 assert!(state.active_tab().is_loading);
1614 }
1615
1616 #[test]
1617 fn execute_commit_action_no_repo_does_not_set_loading() {
1618 use crate::message::Message;
1619 let mut state = GitKraft::new();
1620 let _ = state.update(Message::ExecuteCommitAction(
1623 "abc123".to_string(),
1624 gitkraft_core::CommitAction::CherryPick,
1625 ));
1626
1627 assert!(!state.active_tab().is_loading);
1628 }
1629
1630 #[test]
1631 fn execute_commit_action_sets_status_message_from_action_label() {
1632 use crate::message::Message;
1633 let mut state = GitKraft::new();
1634 state.active_tab_mut().repo_path =
1635 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1636
1637 let _ = state.update(Message::ExecuteCommitAction(
1638 "abc123".to_string(),
1639 gitkraft_core::CommitAction::Revert,
1640 ));
1641
1642 let status = state.active_tab().status_message.as_deref().unwrap_or("");
1643 assert!(
1645 status.contains("Revert commit"),
1646 "expected status to contain 'Revert commit', got: {status:?}"
1647 );
1648 }
1649
1650 #[test]
1653 fn file_history_defaults_empty() {
1654 let tab = RepoTab::new_empty();
1655 assert!(tab.file_history_path.is_none());
1656 assert!(tab.file_history_commits.is_empty());
1657 assert_eq!(tab.file_history_scroll, 0.0);
1658 }
1659
1660 #[test]
1661 fn blame_defaults_empty() {
1662 let tab = RepoTab::new_empty();
1663 assert!(tab.blame_path.is_none());
1664 assert!(tab.blame_lines.is_empty());
1665 assert_eq!(tab.blame_scroll, 0.0);
1666 }
1667
1668 #[test]
1669 fn pending_delete_file_defaults_none() {
1670 let tab = RepoTab::new_empty();
1671 assert!(tab.pending_delete_file.is_none());
1672 }
1673
1674 #[test]
1675 fn view_file_history_sets_path_and_clears_blame() {
1676 use crate::message::Message;
1677 let mut state = GitKraft::new();
1678 state.active_tab_mut().repo_path =
1679 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1680 state.active_tab_mut().blame_path = Some("some/file.rs".to_string());
1681
1682 let _ = state.update(Message::ViewFileHistory("src/main.rs".to_string()));
1683
1684 assert_eq!(
1685 state.active_tab().file_history_path.as_deref(),
1686 Some("src/main.rs")
1687 );
1688 assert!(state.active_tab().blame_path.is_none());
1690 }
1691
1692 #[test]
1693 fn close_file_history_clears_state() {
1694 use crate::message::Message;
1695 let mut state = GitKraft::new();
1696 state.active_tab_mut().file_history_path = Some("src/lib.rs".to_string());
1697 state.active_tab_mut().file_history_commits = vec![gitkraft_core::CommitInfo {
1698 oid: "abc".to_string(),
1699 short_oid: "abc".to_string(),
1700 summary: "s".to_string(),
1701 message: "s".to_string(),
1702 author_name: "a".to_string(),
1703 author_email: "a@b.com".to_string(),
1704 time: Default::default(),
1705 parent_ids: vec![],
1706 }];
1707
1708 let _ = state.update(Message::CloseFileHistory);
1709
1710 assert!(state.active_tab().file_history_path.is_none());
1711 assert!(state.active_tab().file_history_commits.is_empty());
1712 }
1713
1714 #[test]
1715 fn view_file_blame_sets_path_and_clears_history() {
1716 use crate::message::Message;
1717 let mut state = GitKraft::new();
1718 state.active_tab_mut().repo_path =
1719 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1720 state.active_tab_mut().file_history_path = Some("some/file.rs".to_string());
1721
1722 let _ = state.update(Message::ViewFileBlame("src/lib.rs".to_string()));
1723
1724 assert_eq!(state.active_tab().blame_path.as_deref(), Some("src/lib.rs"));
1725 assert!(state.active_tab().file_history_path.is_none());
1727 }
1728
1729 #[test]
1730 fn selecting_new_commit_closes_blame_overlay() {
1731 use crate::message::Message;
1732 let mut state = GitKraft::new();
1733 state.active_tab_mut().repo_path =
1734 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1735 state.active_tab_mut().commits = vec![
1737 gitkraft_core::CommitInfo {
1738 oid: "abc1".into(),
1739 short_oid: "abc1".into(),
1740 summary: "first".into(),
1741 message: "first".into(),
1742 author_name: "A".into(),
1743 author_email: "a@a.com".into(),
1744 time: Default::default(),
1745 parent_ids: Vec::new(),
1746 },
1747 gitkraft_core::CommitInfo {
1748 oid: "abc2".into(),
1749 short_oid: "abc2".into(),
1750 summary: "second".into(),
1751 message: "second".into(),
1752 author_name: "A".into(),
1753 author_email: "a@a.com".into(),
1754 time: Default::default(),
1755 parent_ids: Vec::new(),
1756 },
1757 ];
1758 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1760 state.active_tab_mut().blame_lines = vec![gitkraft_core::BlameLine {
1761 line_number: 1,
1762 content: "fn main() {}".into(),
1763 short_oid: "abc1".into(),
1764 oid: "abc1".into(),
1765 author_name: "A".into(),
1766 time: Default::default(),
1767 }];
1768
1769 let _ = state.update(Message::SelectCommit(1));
1771
1772 assert!(
1773 state.active_tab().blame_path.is_none(),
1774 "blame_path must be cleared when a new commit is selected"
1775 );
1776 assert!(
1777 state.active_tab().blame_lines.is_empty(),
1778 "blame_lines must be cleared when a new commit is selected"
1779 );
1780 }
1781
1782 #[test]
1783 fn close_file_blame_clears_state() {
1784 use crate::message::Message;
1785 let mut state = GitKraft::new();
1786 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1787
1788 let _ = state.update(Message::CloseFileBlame);
1789
1790 assert!(state.active_tab().blame_path.is_none());
1791 assert!(state.active_tab().blame_lines.is_empty());
1792 }
1793
1794 #[test]
1795 fn delete_file_sets_pending() {
1796 use crate::message::Message;
1797 let mut state = GitKraft::new();
1798
1799 let _ = state.update(Message::DeleteFile("src/old.rs".to_string()));
1800
1801 assert_eq!(
1802 state.active_tab().pending_delete_file.as_deref(),
1803 Some("src/old.rs")
1804 );
1805 assert!(state.active_tab().context_menu.is_none());
1806 }
1807
1808 #[test]
1809 fn cancel_delete_file_clears_pending() {
1810 use crate::message::Message;
1811 let mut state = GitKraft::new();
1812 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1813
1814 let _ = state.update(Message::CancelDeleteFile);
1815
1816 assert!(state.active_tab().pending_delete_file.is_none());
1817 }
1818
1819 #[test]
1820 fn confirm_delete_file_no_repo_is_noop() {
1821 use crate::message::Message;
1822 let mut state = GitKraft::new();
1823 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1824 let _ = state.update(Message::ConfirmDeleteFile);
1827
1828 assert!(!state.active_tab().is_loading);
1829 }
1830
1831 #[test]
1832 fn shift_arrow_down_extends_file_list_selection_when_files_loaded() {
1833 use crate::message::Message;
1834 let mut state = GitKraft::new();
1835 state.active_tab_mut().repo_path =
1836 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1837 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1838 state.active_tab_mut().selected_file_index = Some(0);
1839 state.active_tab_mut().anchor_file_index = Some(0);
1840 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1842
1843 let _ = state.update(Message::ShiftArrowDown);
1844
1845 assert_eq!(state.active_tab().selected_file_index, Some(1));
1846 assert!(state.active_tab().selected_commit_file_indices.contains(&0));
1848 assert!(state.active_tab().selected_commit_file_indices.contains(&1));
1849 }
1850
1851 #[test]
1852 fn shift_arrow_down_falls_through_to_commit_log_when_no_files() {
1853 use crate::message::Message;
1854 let mut state = GitKraft::new();
1855 state.active_tab_mut().commits = make_test_commits(5);
1856 state.active_tab_mut().selected_commit = Some(1);
1857 state.active_tab_mut().anchor_commit_index = Some(1);
1858 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1859 let _ = state.update(Message::ShiftArrowDown);
1862
1863 assert_eq!(state.active_tab().selected_commit, Some(2));
1864 assert!(state.active_tab().selected_commits.contains(&1));
1865 assert!(state.active_tab().selected_commits.contains(&2));
1866 }
1867
1868 #[test]
1869 fn file_system_changed_triggers_full_refresh() {
1870 use crate::message::Message;
1871 let mut state = GitKraft::new();
1872 state.active_tab_mut().repo_path =
1873 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1874
1875 let _task = state.update(Message::FileSystemChanged);
1879
1880 assert!(
1884 state.active_tab().error_message.is_none(),
1885 "FileSystemChanged must not set an error message"
1886 );
1887 }
1888
1889 fn fake_payload(workdir: &str) -> crate::message::RepoPayload {
1893 gitkraft_core::RepoSnapshot {
1894 info: gitkraft_core::RepoInfo {
1895 path: std::path::PathBuf::from(format!("{workdir}/.git")),
1896 workdir: Some(std::path::PathBuf::from(workdir)),
1897 head_branch: Some("main".into()),
1898 is_bare: false,
1899 state: gitkraft_core::RepoState::Clean,
1900 },
1901 branches: Vec::new(),
1902 commits: Vec::new(),
1903 graph_rows: Vec::new(),
1904 unstaged: Vec::new(),
1905 staged: Vec::new(),
1906 stashes: Vec::new(),
1907 remotes: Vec::new(),
1908 }
1909 }
1910
1911 fn setup_loaded_tab(tab: &mut RepoTab, path: &str) {
1913 tab.repo_path = Some(std::path::PathBuf::from(path));
1914 tab.repo_info = Some(gitkraft_core::RepoInfo {
1915 path: std::path::PathBuf::from(format!("{path}/.git")),
1916 workdir: Some(std::path::PathBuf::from(path)),
1917 head_branch: Some("main".into()),
1918 is_bare: false,
1919 state: gitkraft_core::RepoState::Clean,
1920 });
1921 }
1922
1923 #[test]
1924 fn open_repo_creates_new_tab_when_repo_already_open() {
1925 use crate::message::Message;
1926 let mut state = GitKraft::new();
1927 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1928
1929 assert_eq!(state.tabs.len(), 1);
1930 assert_eq!(state.active_tab, 0);
1931
1932 let _task = state.update(Message::OpenRepo);
1934
1935 assert_eq!(state.tabs.len(), 2);
1936 assert_eq!(state.active_tab, 1);
1937 assert!(state.tabs[1].is_loading);
1939 assert_eq!(
1941 state.tabs[0].repo_path.as_deref(),
1942 Some(std::path::Path::new("/home/user/repo-a"))
1943 );
1944 }
1945
1946 #[test]
1947 fn open_repo_reuses_empty_tab() {
1948 use crate::message::Message;
1949 let mut state = GitKraft::new();
1950 assert!(!state.active_tab().has_repo());
1952
1953 let _task = state.update(Message::OpenRepo);
1954
1955 assert_eq!(state.tabs.len(), 1);
1957 assert_eq!(state.active_tab, 0);
1958 assert!(state.tabs[0].is_loading);
1959 }
1960
1961 #[test]
1962 fn repo_selected_deduplicates_already_open_repo() {
1963 use crate::message::Message;
1964 let mut state = GitKraft::new();
1965 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1967 state.tabs.push(RepoTab::new_empty());
1969 state.active_tab = 1;
1970
1971 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
1973 "/home/user/repo-a",
1974 ))));
1975
1976 assert_eq!(state.tabs.len(), 1);
1978 assert_eq!(state.active_tab, 0);
1979 assert_eq!(
1980 state.tabs[0].repo_path.as_deref(),
1981 Some(std::path::Path::new("/home/user/repo-a"))
1982 );
1983 }
1984
1985 #[test]
1986 fn repo_selected_opens_new_repo_in_empty_tab() {
1987 use crate::message::Message;
1988 let mut state = GitKraft::new();
1989 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1991 state.tabs.push(RepoTab::new_empty());
1993 state.active_tab = 1;
1994
1995 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
1997 "/home/user/repo-b",
1998 ))));
1999
2000 assert_eq!(state.tabs.len(), 2);
2002 assert_eq!(state.active_tab, 1);
2003 assert!(state.tabs[1]
2004 .status_message
2005 .as_deref()
2006 .unwrap_or("")
2007 .contains("repo-b"));
2008 }
2009
2010 #[test]
2011 fn repo_selected_cancel_removes_empty_tab() {
2012 use crate::message::Message;
2013 let mut state = GitKraft::new();
2014 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2016 state.tabs.push(RepoTab::new_empty());
2018 state.active_tab = 1;
2019
2020 let _task = state.update(Message::RepoSelected(None));
2022
2023 assert_eq!(state.tabs.len(), 1);
2025 assert_eq!(state.active_tab, 0);
2026 assert_eq!(
2027 state.tabs[0].repo_path.as_deref(),
2028 Some(std::path::Path::new("/home/user/repo-a"))
2029 );
2030 }
2031
2032 #[test]
2033 fn repo_selected_cancel_keeps_tab_if_only_one() {
2034 use crate::message::Message;
2035 let mut state = GitKraft::new();
2036 assert_eq!(state.tabs.len(), 1);
2038 assert!(!state.active_tab().has_repo());
2039
2040 let _task = state.update(Message::RepoSelected(None));
2041
2042 assert_eq!(state.tabs.len(), 1);
2043 assert!(!state.active_tab().is_loading);
2044 }
2045
2046 #[test]
2047 fn open_recent_repo_deduplicates() {
2048 use crate::message::Message;
2049 let mut state = GitKraft::new();
2050 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2052 state.tabs.push(RepoTab::new_empty());
2054 state.active_tab = 1;
2055
2056 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2058 "/home/user/repo-a",
2059 )));
2060
2061 assert_eq!(state.active_tab, 0);
2062 }
2063
2064 #[test]
2065 fn open_recent_repo_creates_new_tab_when_current_has_repo() {
2066 use crate::message::Message;
2067 let mut state = GitKraft::new();
2068 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2070
2071 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2073 "/home/user/repo-b",
2074 )));
2075
2076 assert_eq!(state.tabs.len(), 2);
2077 assert_eq!(state.active_tab, 1);
2078 assert!(state.tabs[1].is_loading);
2079 }
2080
2081 #[test]
2082 fn open_recent_repo_uses_empty_tab() {
2083 use crate::message::Message;
2084 let mut state = GitKraft::new();
2085 assert!(!state.active_tab().has_repo());
2087
2088 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2089 "/home/user/repo-b",
2090 )));
2091
2092 assert_eq!(state.tabs.len(), 1);
2094 assert_eq!(state.active_tab, 0);
2095 assert!(state.tabs[0].is_loading);
2096 }
2097
2098 #[test]
2101 fn repo_refreshed_targets_correct_tab_after_tab_switch() {
2102 use crate::message::Message;
2103 let mut state = GitKraft::new();
2104 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2106 state.tabs.push(RepoTab::new_empty());
2108 state.active_tab = 1; let payload = fake_payload("/home/user/repo-a");
2112 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2113
2114 assert!(
2116 state.tabs[0].repo_info.is_some(),
2117 "tab 0 should still have repo info after refresh"
2118 );
2119 assert_eq!(
2120 state.tabs[0].current_branch.as_deref(),
2121 Some("main"),
2122 "tab 0 should have updated branch from payload"
2123 );
2124 assert!(
2126 state.tabs[1].repo_info.is_none(),
2127 "tab 1 (empty) must NOT receive the refresh payload"
2128 );
2129 assert!(
2130 state.tabs[1].repo_path.is_none(),
2131 "tab 1 should still have no repo path"
2132 );
2133 }
2134
2135 #[test]
2136 fn repo_refreshed_targets_active_tab_for_new_open() {
2137 use crate::message::Message;
2138 let mut state = GitKraft::new();
2139 assert_eq!(state.tabs.len(), 1);
2143 assert!(!state.active_tab().has_repo());
2144
2145 let payload = fake_payload("/home/user/new-repo");
2146 let _task = state.update(Message::RepoOpened(Ok(payload)));
2147
2148 assert_eq!(
2150 state.tabs[0].repo_path.as_deref(),
2151 Some(std::path::Path::new("/home/user/new-repo"))
2152 );
2153 assert!(state.tabs[0].repo_info.is_some());
2154 }
2155
2156 #[test]
2157 fn repo_refreshed_does_not_duplicate_into_new_tab() {
2158 use crate::message::Message;
2159 let mut state = GitKraft::new();
2160 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2162
2163 let _task = state.update(Message::NewTab);
2165 assert_eq!(state.tabs.len(), 2);
2166 assert_eq!(state.active_tab, 1);
2167
2168 let payload = fake_payload("/home/user/repo-a");
2170 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2171
2172 assert!(
2174 state.tabs[1].repo_path.is_none(),
2175 "new empty tab must not receive repo-a refresh"
2176 );
2177 assert!(
2178 state.tabs[1].repo_info.is_none(),
2179 "new empty tab must not have repo_info"
2180 );
2181 assert_eq!(
2183 state.tabs[0].repo_path.as_deref(),
2184 Some(std::path::Path::new("/home/user/repo-a"))
2185 );
2186 }
2187
2188 #[test]
2189 fn git_operation_result_targets_correct_tab() {
2190 use crate::message::Message;
2191 let mut state = GitKraft::new();
2192 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2194 state.tabs.push(RepoTab::new_empty());
2196 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2197 state.active_tab = 1;
2198
2199 let payload = fake_payload("/home/user/repo-a");
2202 let _task = state.update(Message::GitOperationResult(Ok(payload)));
2203
2204 assert_eq!(state.tabs[0].current_branch.as_deref(), Some("main"));
2206 assert_eq!(
2208 state.tabs[1].repo_path.as_deref(),
2209 Some(std::path::Path::new("/home/user/repo-b"))
2210 );
2211 }
2212
2213 #[test]
2214 fn multiple_new_tabs_dont_get_polluted_by_refresh() {
2215 use crate::message::Message;
2216 let mut state = GitKraft::new();
2217 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2219
2220 let _task = state.update(Message::NewTab);
2222 let _task = state.update(Message::NewTab);
2223 assert_eq!(state.tabs.len(), 3);
2224 assert_eq!(state.active_tab, 2);
2225
2226 let payload = fake_payload("/home/user/repo-a");
2228 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2229
2230 assert!(state.tabs[0].repo_info.is_some());
2232 assert!(state.tabs[1].repo_info.is_none());
2233 assert!(state.tabs[2].repo_info.is_none());
2234 assert!(state.tabs[1].repo_path.is_none());
2235 assert!(state.tabs[2].repo_path.is_none());
2236 }
2237
2238 #[test]
2239 fn repo_selected_dedup_adjusts_index_when_existing_is_after_active() {
2240 use crate::message::Message;
2241 let mut state = GitKraft::new();
2242 state.tabs.push(RepoTab::new_empty());
2245 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
2246 state.active_tab = 0;
2247
2248 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2250 "/home/user/repo-a",
2251 ))));
2252
2253 assert_eq!(state.tabs.len(), 1);
2255 assert_eq!(state.active_tab, 0);
2256 assert_eq!(
2257 state.tabs[0].repo_path.as_deref(),
2258 Some(std::path::Path::new("/home/user/repo-a"))
2259 );
2260 }
2261
2262 #[test]
2265 fn apply_payload_preserves_multi_selection_by_oid() {
2266 let mut tab = RepoTab::new_empty();
2267 tab.commits = make_test_commits(5);
2268 tab.anchor_commit_index = Some(1);
2270 tab.selected_commits = vec![1, 2, 3];
2271 tab.selected_commit = Some(3);
2272 tab.selected_commit_oid = Some(tab.commits[3].oid.clone());
2273
2274 let mut payload = fake_payload("/tmp/repo");
2276 payload.commits = make_test_commits(5);
2277
2278 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2279
2280 assert_eq!(tab.anchor_commit_index, Some(1));
2282 assert_eq!(tab.selected_commits, vec![1, 2, 3]);
2283 assert_eq!(tab.selected_commit, Some(3));
2284 }
2285
2286 #[test]
2287 fn apply_payload_preserves_anchor_even_without_range() {
2288 let mut tab = RepoTab::new_empty();
2289 tab.commits = make_test_commits(5);
2290 tab.anchor_commit_index = Some(2);
2291 tab.selected_commit = Some(2);
2292 tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2293 let mut payload = fake_payload("/tmp/repo");
2296 payload.commits = make_test_commits(5);
2297
2298 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2299
2300 assert_eq!(tab.anchor_commit_index, Some(2));
2301 assert_eq!(tab.selected_commit, Some(2));
2302 }
2303
2304 #[test]
2305 fn apply_payload_clears_selection_when_commits_disappear() {
2306 let mut tab = RepoTab::new_empty();
2307 tab.commits = make_test_commits(5);
2308 tab.anchor_commit_index = Some(2);
2309 tab.selected_commits = vec![2, 3, 4];
2310 tab.selected_commit = Some(4);
2311 tab.selected_commit_oid = Some(tab.commits[4].oid.clone());
2312
2313 let mut payload = fake_payload("/tmp/repo");
2315 payload.commits = (0..3)
2316 .map(|i| gitkraft_core::CommitInfo {
2317 oid: format!("new_oid_{i}"),
2318 short_oid: format!("new_{i}"),
2319 summary: format!("new commit {i}"),
2320 message: String::new(),
2321 author_name: "Author".into(),
2322 author_email: "a@b.c".into(),
2323 time: Default::default(),
2324 parent_ids: Vec::new(),
2325 })
2326 .collect();
2327
2328 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2329
2330 assert!(tab.selected_commits.is_empty());
2332 assert!(tab.anchor_commit_index.is_none());
2333 assert!(tab.selected_commit.is_none());
2334 }
2335
2336 #[test]
2339 fn apply_payload_preserves_file_selection_when_commit_survives() {
2340 let mut tab = RepoTab::new_empty();
2341 tab.commits = make_test_commits(3);
2342 tab.selected_commit = Some(1);
2343 tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2344 tab.commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
2345
2346 tab.anchor_file_index = Some(0);
2348 tab.selected_commit_file_indices = vec![0, 1, 2];
2349 tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2350 old_file: String::new(),
2351 new_file: "a.rs".into(),
2352 status: gitkraft_core::FileStatus::Modified,
2353 hunks: vec![],
2354 }];
2355
2356 let mut payload = fake_payload("/tmp/repo");
2358 payload.commits = make_test_commits(3);
2359
2360 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2361
2362 assert_eq!(tab.anchor_file_index, Some(0));
2364 assert_eq!(tab.selected_commit_file_indices, vec![0, 1, 2]);
2365 assert!(
2366 !tab.multi_file_diffs.is_empty(),
2367 "multi_file_diffs must survive when commit is preserved"
2368 );
2369 }
2370
2371 #[test]
2372 fn apply_payload_clears_file_selection_when_commit_disappears() {
2373 let mut tab = RepoTab::new_empty();
2374 tab.commits = make_test_commits(3);
2375 tab.selected_commit = Some(1);
2376 tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2377 tab.commit_files = make_commit_files(&["a.rs", "b.rs"]);
2378 tab.anchor_file_index = Some(0);
2379 tab.selected_commit_file_indices = vec![0, 1];
2380 tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2381 old_file: String::new(),
2382 new_file: "a.rs".into(),
2383 status: gitkraft_core::FileStatus::Modified,
2384 hunks: vec![],
2385 }];
2386
2387 let mut payload = fake_payload("/tmp/repo");
2389 payload.commits = (0..2)
2390 .map(|i| gitkraft_core::CommitInfo {
2391 oid: format!("new_{i}"),
2392 short_oid: format!("n{i}"),
2393 summary: String::new(),
2394 message: String::new(),
2395 author_name: String::new(),
2396 author_email: String::new(),
2397 time: Default::default(),
2398 parent_ids: Vec::new(),
2399 })
2400 .collect();
2401
2402 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2403
2404 assert!(tab.anchor_file_index.is_none());
2406 assert!(tab.selected_commit_file_indices.is_empty());
2407 assert!(tab.multi_file_diffs.is_empty());
2408 assert!(tab.commit_files.is_empty());
2409 }
2410
2411 #[test]
2412 fn apply_payload_preserves_commit_range_diffs_when_commit_survives() {
2413 let mut tab = RepoTab::new_empty();
2414 tab.commits = make_test_commits(5);
2415 tab.selected_commit = Some(2);
2416 tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2417 tab.commit_range_diffs = vec![gitkraft_core::DiffInfo {
2418 old_file: String::new(),
2419 new_file: "x.rs".into(),
2420 status: gitkraft_core::FileStatus::Modified,
2421 hunks: vec![],
2422 }];
2423
2424 let mut payload = fake_payload("/tmp/repo");
2425 payload.commits = make_test_commits(5);
2426
2427 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2428
2429 assert!(
2430 !tab.commit_range_diffs.is_empty(),
2431 "commit_range_diffs must survive when commit is preserved"
2432 );
2433 }
2434}