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 StagingUnstagedRight,
24 StagingStagedRight,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum DragTargetH {
31 StagingTop,
33 SidebarBranchesBottom,
35 SidebarStashBottom,
37}
38
39#[derive(Debug, Clone)]
41pub enum ContextMenu {
42 Branch {
44 name: String,
45 is_current: bool,
46 local_index: usize,
49 },
50 RemoteBranch { name: String },
52 Commit { index: usize, oid: String },
54 Stash { index: usize },
56 UnstagedFile { path: String },
58 StagedFile { path: String },
60 CommitFile { oid: String, file_path: String },
62}
63
64pub struct RepoTab {
68 pub repo_path: Option<PathBuf>,
71 pub repo_info: Option<RepoInfo>,
73
74 pub branches: Vec<BranchInfo>,
77 pub current_branch: Option<String>,
79
80 pub commits: Vec<CommitInfo>,
83 pub selected_commit: Option<usize>,
85 pub anchor_commit_index: Option<usize>,
87 pub selected_commits: Vec<usize>,
89 pub graph_rows: Vec<gitkraft_core::GraphRow>,
91
92 pub unstaged_changes: Vec<gitkraft_core::DiffFileEntry>,
95 pub staged_changes: Vec<gitkraft_core::DiffFileEntry>,
97 pub commit_files: Vec<gitkraft_core::DiffFileEntry>,
99 pub selected_commit_oid: Option<String>,
101 pub selected_file_index: Option<usize>,
103 pub is_loading_file_diff: bool,
105 pub anchor_file_index: Option<usize>,
108 pub selected_commit_file_indices: Vec<usize>,
111 pub multi_file_diffs: Vec<gitkraft_core::DiffInfo>,
113 pub commit_range_diffs: Vec<gitkraft_core::DiffInfo>,
116 pub selected_diff: Option<DiffInfo>,
118 pub commit_message: String,
120
121 pub stashes: Vec<StashEntry>,
124
125 pub remotes: Vec<RemoteInfo>,
128
129 pub show_commit_detail: bool,
132 pub new_branch_name: String,
134 pub show_branch_create: bool,
136 pub local_branches_expanded: bool,
138 pub remote_branches_expanded: bool,
140 pub stash_message: String,
142
143 pub selected_unstaged: std::collections::HashSet<String>,
145 pub selected_staged: std::collections::HashSet<String>,
147 pub anchor_unstaged_index: Option<usize>,
149 pub anchor_staged_index: Option<usize>,
151
152 pub pending_discard: Option<String>,
154
155 pub status_message: Option<String>,
158 pub error_message: Option<String>,
160 pub is_loading: bool,
162 pub context_menu_pos: (f32, f32),
165
166 pub context_menu: Option<ContextMenu>,
168 pub rename_branch_target: Option<String>,
170 pub rename_branch_input: String,
172
173 pub create_tag_target_oid: Option<String>,
175 pub create_tag_annotated: bool,
177 pub create_tag_name: String,
179 pub create_tag_message: String,
181 pub create_branch_at_oid: Option<String>,
183
184 pub commit_scroll_offset: f32,
188
189 pub diff_scroll_offset: f32,
191 pub commit_display: Vec<(String, String)>,
196
197 pub has_more_commits: bool,
199 pub is_loading_more_commits: bool,
201
202 pub file_history_path: Option<String>,
204 pub file_history_commits: Vec<gitkraft_core::CommitInfo>,
206 pub file_history_scroll: f32,
208
209 pub blame_path: Option<String>,
211 pub blame_lines: Vec<gitkraft_core::BlameLine>,
213 pub blame_scroll: f32,
215
216 pub pending_delete_file: Option<String>,
218}
219
220impl RepoTab {
221 pub fn new_empty() -> Self {
223 Self {
224 repo_path: None,
225 repo_info: None,
226 branches: Vec::new(),
227 current_branch: None,
228 commits: Vec::new(),
229 selected_commit: None,
230 anchor_commit_index: None,
231 selected_commits: Vec::new(),
232 graph_rows: Vec::new(),
233 unstaged_changes: Vec::new(),
234 staged_changes: Vec::new(),
235 commit_files: Vec::new(),
236 selected_commit_oid: None,
237 selected_file_index: None,
238 is_loading_file_diff: false,
239 anchor_file_index: None,
240 selected_commit_file_indices: Vec::new(),
241 multi_file_diffs: Vec::new(),
242 commit_range_diffs: Vec::new(),
243 selected_diff: None,
244 commit_message: String::new(),
245 stashes: Vec::new(),
246 remotes: Vec::new(),
247 show_commit_detail: false,
248 new_branch_name: String::new(),
249 show_branch_create: false,
250 local_branches_expanded: true,
251 remote_branches_expanded: true,
252 stash_message: String::new(),
253 selected_unstaged: std::collections::HashSet::new(),
254 selected_staged: std::collections::HashSet::new(),
255 anchor_unstaged_index: None,
256 anchor_staged_index: None,
257 pending_discard: None,
258 status_message: None,
259 error_message: None,
260 is_loading: false,
261 context_menu: None,
262 context_menu_pos: (0.0, 0.0),
263 rename_branch_target: None,
264 rename_branch_input: String::new(),
265 create_tag_target_oid: None,
266 create_tag_annotated: false,
267 create_tag_name: String::new(),
268 create_tag_message: String::new(),
269 create_branch_at_oid: None,
270 commit_scroll_offset: 0.0,
271 diff_scroll_offset: 0.0,
272 commit_display: Vec::new(),
273 has_more_commits: true,
274 is_loading_more_commits: false,
275 file_history_path: None,
276 file_history_commits: Vec::new(),
277 file_history_scroll: 0.0,
278 blame_path: None,
279 blame_lines: Vec::new(),
280 blame_scroll: 0.0,
281 pending_delete_file: None,
282 }
283 }
284
285 pub fn has_repo(&self) -> bool {
287 self.repo_path.is_some()
288 }
289
290 pub fn default_remote(&self) -> String {
292 self.remotes
293 .first()
294 .map(|r| r.name.clone())
295 .unwrap_or_else(|| "origin".to_string())
296 }
297
298 pub fn display_name(&self) -> String {
300 gitkraft_core::repo_display_name(self.repo_path.as_deref())
301 }
302
303 pub fn apply_payload(
309 &mut self,
310 payload: crate::message::RepoPayload,
311 path: std::path::PathBuf,
312 ) {
313 let is_refresh = self.repo_info.is_some();
315 let prev_oid = self.selected_commit_oid.clone();
316
317 let prev_anchor_oid = self
321 .anchor_commit_index
322 .and_then(|i| self.commits.get(i).map(|c| c.oid.clone()));
323 let prev_selected_oids: Vec<String> = self
324 .selected_commits
325 .iter()
326 .filter_map(|&i| self.commits.get(i).map(|c| c.oid.clone()))
327 .collect();
328
329 let prev_anchor_file = self.anchor_file_index;
333 let prev_file_indices = self.selected_commit_file_indices.clone();
334 let prev_multi_file_diffs = std::mem::take(&mut self.multi_file_diffs);
335 let prev_commit_range_diffs = std::mem::take(&mut self.commit_range_diffs);
336
337 let prev_selected_unstaged = std::mem::take(&mut self.selected_unstaged);
340 let prev_selected_staged = std::mem::take(&mut self.selected_staged);
341 let prev_anchor_unstaged = self.anchor_unstaged_index;
342 let prev_anchor_staged = self.anchor_staged_index;
343
344 let prev_selected_diff = self.selected_diff.take();
347 let prev_diff_scroll = self.diff_scroll_offset;
348
349 self.current_branch = payload.info.head_branch.clone();
350 self.repo_path = Some(path);
351 self.repo_info = Some(payload.info);
352 self.branches = payload.branches;
353
354 if !is_refresh || payload.commits.len() >= self.commits.len() {
359 self.commits = payload.commits;
360 self.graph_rows = payload.graph_rows;
361 }
362
363 self.unstaged_changes = payload.unstaged;
364 self.staged_changes = payload.staged;
365 self.stashes = payload.stashes;
366 self.remotes = payload.remotes;
367
368 self.selected_commit = None;
375 self.anchor_commit_index = None;
376 self.selected_commits.clear();
377 self.selected_commit_oid = None;
378 self.error_message = None;
379 self.status_message = Some("Repository loaded.".into());
380 if !is_refresh {
385 self.has_more_commits = true;
386 }
387 self.anchor_unstaged_index = None;
394 self.anchor_staged_index = None;
395 self.anchor_file_index = None;
396 self.selected_commit_file_indices.clear();
397
398 if let Some(oid) = prev_oid {
404 if let Some(new_idx) = self.commits.iter().position(|c| c.oid == oid) {
405 self.selected_commit = Some(new_idx);
406 self.selected_commit_oid = Some(oid);
407 self.selected_diff = prev_selected_diff;
414 self.diff_scroll_offset = prev_diff_scroll;
415
416 self.anchor_file_index = prev_anchor_file;
419 self.selected_commit_file_indices = prev_file_indices;
420 self.multi_file_diffs = prev_multi_file_diffs;
421 self.commit_range_diffs = prev_commit_range_diffs;
422 } else {
423 self.clear_diff_state();
425 }
426 } else {
427 if prev_selected_diff.is_some() {
430 self.selected_diff = prev_selected_diff;
431 self.diff_scroll_offset = prev_diff_scroll;
432 } else {
433 self.clear_diff_state();
434 }
435 }
436
437 if let Some(anchor_oid) = prev_anchor_oid {
441 if let Some(new_anchor) = self.commits.iter().position(|c| c.oid == anchor_oid) {
442 self.anchor_commit_index = Some(new_anchor);
443 }
444 }
445 if !prev_selected_oids.is_empty() {
446 let restored: Vec<usize> = prev_selected_oids
447 .iter()
448 .filter_map(|oid| self.commits.iter().position(|c| &c.oid == oid))
449 .collect();
450 if !restored.is_empty() {
451 self.selected_commits = restored;
452 self.selected_commits.sort_unstable();
453 }
454 }
455
456 self.selected_unstaged = prev_selected_unstaged
459 .into_iter()
460 .filter(|p| self.unstaged_changes.iter().any(|d| d.display_path() == p))
461 .collect();
462 self.selected_staged = prev_selected_staged
463 .into_iter()
464 .filter(|p| self.staged_changes.iter().any(|d| d.display_path() == p))
465 .collect();
466 self.anchor_unstaged_index =
468 prev_anchor_unstaged.filter(|&i| i < self.unstaged_changes.len());
469 self.anchor_staged_index = prev_anchor_staged.filter(|&i| i < self.staged_changes.len());
470 }
471
472 fn clear_diff_state(&mut self) {
475 self.selected_diff = None;
476 self.commit_files.clear();
477 self.selected_file_index = None;
478 self.is_loading_file_diff = false;
479 self.diff_scroll_offset = 0.0;
480 }
481}
482
483pub struct GitKraft {
487 pub tabs: Vec<RepoTab>,
490 pub active_tab: usize,
492
493 pub sidebar_expanded: bool,
496
497 pub sidebar_width: f32,
500 pub commit_log_width: f32,
502 pub staging_height: f32,
504 pub diff_file_list_width: f32,
506 pub sidebar_branches_ratio: f32,
508 pub sidebar_stash_ratio: f32,
511 pub staging_unstaged_ratio: f32,
513 pub staging_staged_ratio: f32,
516
517 pub ui_scale: f32,
519
520 pub dragging: Option<DragTarget>,
523 pub dragging_h: Option<DragTargetH>,
525 pub drag_start_x: f32,
527 pub drag_start_y: f32,
529 pub drag_initialized: bool,
533 pub drag_initialized_h: bool,
535
536 pub cursor_pos: Point,
541
542 pub current_theme_index: usize,
545
546 pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
549
550 pub search_visible: bool,
553 pub search_query: String,
555 pub search_results: Vec<gitkraft_core::CommitInfo>,
557 pub search_selected: Option<usize>,
559
560 pub search_diff_files: Vec<gitkraft_core::DiffFileEntry>,
562 pub search_diff_selected: HashSet<usize>,
564 pub search_diff_content: Vec<gitkraft_core::DiffInfo>,
566 pub search_diff_oid: Option<String>,
568
569 pub editor: gitkraft_core::Editor,
571
572 pub keyboard_modifiers: iced::keyboard::Modifiers,
574
575 pub animation_tick: u64,
578
579 pub window_width: f32,
582 pub window_height: f32,
584 pub window_x: f32,
586 pub window_y: f32,
588}
589
590impl Default for GitKraft {
591 fn default() -> Self {
592 Self::new()
593 }
594}
595
596impl GitKraft {
597 fn from_settings(settings: gitkraft_core::AppSettings) -> Self {
603 let current_theme_index = settings
604 .theme_name
605 .as_deref()
606 .map(gitkraft_core::theme_index_by_name)
607 .unwrap_or(0);
608
609 let recent_repos = settings.recent_repos;
610
611 let (
612 sidebar_width,
613 commit_log_width,
614 staging_height,
615 diff_file_list_width,
616 sidebar_expanded,
617 ui_scale,
618 ) = if let Some(ref layout) = settings.layout {
619 (
620 layout.sidebar_width.unwrap_or(220.0),
621 layout.commit_log_width.unwrap_or(500.0),
622 layout.staging_height.unwrap_or(200.0),
623 layout.diff_file_list_width.unwrap_or(180.0),
624 layout.sidebar_expanded.unwrap_or(true),
625 layout.ui_scale.unwrap_or(1.0),
626 )
627 } else {
628 (220.0, 500.0, 200.0, 180.0, true, 1.0)
629 };
630
631 Self {
632 tabs: vec![RepoTab::new_empty()],
633 active_tab: 0,
634
635 sidebar_expanded,
636
637 sidebar_width,
638 commit_log_width,
639 staging_height,
640 diff_file_list_width,
641 sidebar_branches_ratio: 0.6,
642 sidebar_stash_ratio: 0.25,
643 staging_unstaged_ratio: 0.4,
644 staging_staged_ratio: 0.3,
645
646 ui_scale,
647
648 dragging: None,
649 dragging_h: None,
650 drag_start_x: 0.0,
651 drag_start_y: 0.0,
652 drag_initialized: false,
653 drag_initialized_h: false,
654 cursor_pos: Point::ORIGIN,
655
656 current_theme_index,
657
658 recent_repos,
659
660 search_visible: false,
661 search_query: String::new(),
662 search_results: Vec::new(),
663 search_selected: None,
664 search_diff_files: Vec::new(),
665 search_diff_selected: HashSet::new(),
666 search_diff_content: Vec::new(),
667 search_diff_oid: None,
668
669 keyboard_modifiers: iced::keyboard::Modifiers::default(),
670 animation_tick: 0,
671
672 window_width: settings
673 .layout
674 .as_ref()
675 .and_then(|l| l.window_width)
676 .unwrap_or(1400.0),
677 window_height: settings
678 .layout
679 .as_ref()
680 .and_then(|l| l.window_height)
681 .unwrap_or(800.0),
682 window_x: settings
683 .layout
684 .as_ref()
685 .and_then(|l| l.window_x)
686 .unwrap_or(0.0),
687 window_y: settings
688 .layout
689 .as_ref()
690 .and_then(|l| l.window_y)
691 .unwrap_or(0.0),
692
693 editor: settings
694 .editor_name
695 .as_deref()
696 .map(|name| {
697 gitkraft_core::EDITOR_NAMES
699 .iter()
700 .position(|n| n.eq_ignore_ascii_case(name))
701 .map(gitkraft_core::Editor::from_index)
702 .unwrap_or_else(|| {
703 if name.eq_ignore_ascii_case("none") {
704 gitkraft_core::Editor::None
705 } else {
706 gitkraft_core::Editor::Custom(name.to_string())
707 }
708 })
709 })
710 .unwrap_or_else(detect_system_editor),
711 }
712 }
713
714 pub fn new() -> Self {
720 Self::from_settings(
721 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default(),
722 )
723 }
724
725 pub fn new_with_session_paths() -> (Self, Vec<PathBuf>) {
731 let settings =
732 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default();
733 let open_tabs = settings.open_tabs.clone();
734 let active_tab_index = settings.active_tab_index;
735
736 let mut state = Self::from_settings(settings);
737
738 if !open_tabs.is_empty() {
739 state.tabs = open_tabs
740 .iter()
741 .map(|path| {
742 let mut tab = RepoTab::new_empty();
743 tab.repo_path = Some(path.clone());
746 if path.exists() {
747 tab.is_loading = true;
748 tab.status_message = Some(format!(
749 "Loading {}…",
750 path.file_name().unwrap_or_default().to_string_lossy()
751 ));
752 } else {
753 tab.error_message =
754 Some(format!("Repository not found: {}", path.display()));
755 }
756 tab
757 })
758 .collect();
759 state.active_tab = active_tab_index.min(state.tabs.len().saturating_sub(1));
760 }
761
762 (state, open_tabs)
763 }
764
765 pub fn open_tab_paths(&self) -> Vec<PathBuf> {
768 self.tabs
769 .iter()
770 .filter(|t| t.repo_info.is_some())
771 .filter_map(|t| t.repo_path.clone())
772 .collect()
773 }
774
775 pub fn session_state(&self) -> (Vec<PathBuf>, usize) {
781 let mut paths = Vec::new();
782 let mut session_active: usize = 0;
783 let mut found_active = false;
784 for (i, tab) in self.tabs.iter().enumerate() {
785 if tab.repo_info.is_some() {
786 if let Some(ref p) = tab.repo_path {
787 if i == self.active_tab {
788 session_active = paths.len();
789 found_active = true;
790 }
791 paths.push(p.clone());
792 }
793 }
794 }
795 if !found_active {
798 session_active = 0;
799 }
800 (paths, session_active)
801 }
802
803 pub fn active_tab(&self) -> &RepoTab {
805 &self.tabs[self.active_tab]
806 }
807
808 pub fn active_tab_mut(&mut self) -> &mut RepoTab {
810 &mut self.tabs[self.active_tab]
811 }
812
813 pub fn dismiss_menu(&mut self) {
815 self.active_tab_mut().context_menu = None;
816 }
817
818 pub fn open_context_menu(&mut self, menu: ContextMenu) {
820 let pos = (self.cursor_pos.x, self.cursor_pos.y);
821 let tab = self.active_tab_mut();
822 tab.context_menu_pos = pos;
823 tab.context_menu = Some(menu);
824 }
825
826 pub fn has_repo(&self) -> bool {
828 self.active_tab().has_repo()
829 }
830
831 pub fn repo_display_name(&self) -> String {
833 self.active_tab().display_name()
834 }
835
836 pub fn colors(&self) -> ThemeColors {
843 ThemeColors::from_core(&gitkraft_core::theme_by_index(self.current_theme_index))
844 }
845
846 pub fn iced_theme(&self) -> iced::Theme {
855 let core = gitkraft_core::theme_by_index(self.current_theme_index);
856 let name = self.current_theme_name().to_string();
857
858 let palette = iced::theme::Palette {
859 background: rgb_to_iced(core.background),
860 text: rgb_to_iced(core.text_primary),
861 primary: rgb_to_iced(core.accent),
862 success: rgb_to_iced(core.success),
863 warning: rgb_to_iced(core.warning),
864 danger: rgb_to_iced(core.error),
865 };
866
867 iced::Theme::custom(name, palette)
868 }
869
870 pub fn switch_to_existing_tab(&mut self, path: &std::path::Path) -> bool {
874 let existing_idx = match self
875 .tabs
876 .iter()
877 .position(|t| t.repo_path.as_deref() == Some(path) && t.repo_info.is_some())
878 {
879 Some(idx) => idx,
880 None => return false,
881 };
882 if !self.active_tab().has_repo() && self.active_tab != existing_idx && self.tabs.len() > 1 {
885 self.tabs.remove(self.active_tab);
886 let adjusted = if existing_idx > self.active_tab {
887 existing_idx - 1
888 } else {
889 existing_idx
890 };
891 self.active_tab = adjusted;
892 } else {
893 self.active_tab = existing_idx;
894 }
895 true
896 }
897
898 pub fn current_theme_name(&self) -> &'static str {
900 gitkraft_core::THEME_NAMES
901 .get(self.current_theme_index)
902 .copied()
903 .unwrap_or("Default")
904 }
905
906 pub fn refresh_active_tab(&mut self) -> Task<Message> {
910 let tab = self.active_tab();
911 match tab.repo_path.clone() {
912 Some(path) => {
913 let depth = tab.commits.len();
914 crate::features::repo::commands::refresh_repo(path, depth)
915 }
916 None => Task::none(),
917 }
918 }
919
920 pub fn on_ok_refresh(
927 &mut self,
928 result: Result<(), String>,
929 ok_msg: &str,
930 err_prefix: &str,
931 ) -> Task<Message> {
932 match result {
933 Ok(()) => {
934 {
935 let tab = self.active_tab_mut();
936 tab.is_loading = false;
937 tab.status_message = Some(ok_msg.to_string());
938 }
939 self.refresh_active_tab()
940 }
941 Err(e) => {
942 let tab = self.active_tab_mut();
943 tab.is_loading = false;
944 tab.error_message = Some(format!("{err_prefix}: {e}"));
945 tab.status_message = None;
946 Task::none()
947 }
948 }
949 }
950
951 pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
953 gitkraft_core::LayoutSettings {
954 sidebar_width: Some(self.sidebar_width),
955 commit_log_width: Some(self.commit_log_width),
956 staging_height: Some(self.staging_height),
957 diff_file_list_width: Some(self.diff_file_list_width),
958 sidebar_expanded: Some(self.sidebar_expanded),
959 ui_scale: Some(self.ui_scale),
960 window_width: Some(self.window_width),
961 window_height: Some(self.window_height),
962 window_x: Some(self.window_x),
963 window_y: Some(self.window_y),
964 window_maximized: None, }
966 }
967}
968
969fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
971 Color::from_rgb8(rgb.r, rgb.g, rgb.b)
972}
973
974fn detect_system_editor() -> gitkraft_core::Editor {
976 for var in ["VISUAL", "EDITOR"] {
977 if let Ok(val) = std::env::var(var) {
978 let bin = val.split('/').next_back().unwrap_or(&val).trim();
979 return match bin {
980 "nvim" | "neovim" => gitkraft_core::Editor::Neovim,
981 "vim" => gitkraft_core::Editor::Vim,
982 "hx" | "helix" => gitkraft_core::Editor::Helix,
983 "nano" => gitkraft_core::Editor::Nano,
984 "micro" => gitkraft_core::Editor::Micro,
985 "emacs" => gitkraft_core::Editor::Emacs,
986 "code" => gitkraft_core::Editor::VSCode,
987 "zed" => gitkraft_core::Editor::Zed,
988 "subl" => gitkraft_core::Editor::Sublime,
989 _ => gitkraft_core::Editor::Custom(val),
990 };
991 }
992 }
993 gitkraft_core::Editor::None
994}
995
996#[cfg(test)]
999mod tests {
1000 use super::*;
1001
1002 #[test]
1003 fn new_defaults() {
1004 let state = GitKraft::new();
1005 assert!(state.active_tab().repo_path.is_none());
1006 assert!(!state.has_repo());
1007 assert_eq!(state.repo_display_name(), "New Tab");
1008 assert!(state.active_tab().commits.is_empty());
1009 assert!(state.sidebar_expanded);
1010 assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
1012 assert!(state.sidebar_width > 0.0);
1014 assert!(state.commit_log_width > 0.0);
1015 assert!(state.staging_height > 0.0);
1016 assert!(state.dragging.is_none());
1017 assert!(state.dragging_h.is_none());
1018 assert_eq!(state.tabs.len(), 1);
1020 assert_eq!(state.active_tab, 0);
1021 }
1022
1023 #[test]
1024 fn repo_display_name_extracts_basename() {
1025 let mut state = GitKraft::new();
1026 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
1027 assert_eq!(state.repo_display_name(), "my-project");
1028 }
1029
1030 #[test]
1031 fn colors_returns_theme_colors() {
1032 let state = GitKraft::new();
1033 let c = state.colors();
1034 assert!(c.bg.r < 0.5);
1036 }
1037
1038 #[test]
1039 fn iced_theme_is_custom_with_correct_palette() {
1040 let mut state = GitKraft::new();
1041
1042 state.current_theme_index = 0;
1044 let iced_t = state.iced_theme();
1045 let pal = iced_t.palette();
1046 assert!(pal.background.r < 0.5, "Default theme bg should be dark");
1047 assert_eq!(iced_t.to_string(), "Default");
1048
1049 let sol_idx = gitkraft_core::theme_index_by_name("Solarized Light");
1051 state.current_theme_index = sol_idx;
1052 let iced_t = state.iced_theme();
1053 let pal = iced_t.palette();
1054 assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
1055 assert_eq!(iced_t.to_string(), "Solarized Light");
1056
1057 let gruvbox_idx = gitkraft_core::theme_index_by_name("Gruvbox Dark");
1059 state.current_theme_index = gruvbox_idx;
1060 let iced_t = state.iced_theme();
1061 let pal = iced_t.palette();
1062 let core = gitkraft_core::theme_by_index(gruvbox_idx);
1063 let expected_accent = rgb_to_iced(core.accent);
1064 assert!(
1065 (pal.primary.r - expected_accent.r).abs() < 0.01
1066 && (pal.primary.g - expected_accent.g).abs() < 0.01
1067 && (pal.primary.b - expected_accent.b).abs() < 0.01,
1068 "Gruvbox Dark accent should match core accent"
1069 );
1070 }
1071
1072 #[test]
1073 fn iced_theme_name_round_trips_through_core() {
1074 for i in 0..gitkraft_core::THEME_COUNT {
1077 let mut state = GitKraft::new();
1078 state.current_theme_index = i;
1079 let iced_t = state.iced_theme();
1080 let name = iced_t.to_string();
1081 let resolved = gitkraft_core::theme_index_by_name(&name);
1082 assert_eq!(
1083 resolved,
1084 i,
1085 "theme index {i} ({}) did not round-trip through iced_theme name",
1086 gitkraft_core::THEME_NAMES[i]
1087 );
1088 }
1089 }
1090
1091 #[test]
1092 fn current_theme_name_round_trips() {
1093 let mut state = GitKraft::new();
1094 state.current_theme_index = gitkraft_core::theme_index_by_name("Dracula");
1095 assert_eq!(state.current_theme_name(), "Dracula");
1096 state.current_theme_index = 0;
1097 assert_eq!(state.current_theme_name(), "Default");
1098 }
1099
1100 #[test]
1101 fn repo_tab_new_empty() {
1102 let tab = RepoTab::new_empty();
1103 assert!(tab.repo_path.is_none());
1104 assert!(!tab.has_repo());
1105 assert_eq!(tab.display_name(), "New Tab");
1106 assert!(tab.commits.is_empty());
1107 assert!(tab.branches.is_empty());
1108 assert!(!tab.is_loading);
1109 }
1110
1111 #[test]
1112 fn repo_tab_display_name_with_path() {
1113 let mut tab = RepoTab::new_empty();
1114 tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
1115 assert!(tab.has_repo());
1116 assert_eq!(tab.display_name(), "cool-repo");
1117 }
1118
1119 #[test]
1120 fn search_defaults() {
1121 let state = GitKraft::new();
1122 assert!(!state.search_visible);
1123 assert!(state.search_query.is_empty());
1124 assert!(state.search_results.is_empty());
1125 assert!(state.search_selected.is_none());
1126 }
1127
1128 #[test]
1129 fn context_menu_variants_exist() {
1130 use crate::state::ContextMenu;
1132
1133 let _branch = ContextMenu::Branch {
1134 name: "main".to_string(),
1135 is_current: true,
1136 local_index: 0,
1137 };
1138 let _remote = ContextMenu::RemoteBranch {
1139 name: "origin/main".to_string(),
1140 };
1141 let _commit = ContextMenu::Commit {
1142 index: 0,
1143 oid: "abc1234".to_string(),
1144 };
1145 let _stash = ContextMenu::Stash { index: 0 };
1146 let _unstaged = ContextMenu::UnstagedFile {
1147 path: "src/main.rs".to_string(),
1148 };
1149 let _staged = ContextMenu::StagedFile {
1150 path: "src/lib.rs".to_string(),
1151 };
1152 }
1153
1154 #[test]
1155 fn repo_tab_context_menu_defaults_to_none() {
1156 let tab = crate::state::RepoTab::new_empty();
1157 assert!(tab.context_menu.is_none());
1158 }
1159
1160 #[test]
1161 fn context_menu_variants_constructable() {
1162 use crate::state::ContextMenu;
1163 let _ = ContextMenu::Stash { index: 0 };
1164 let _ = ContextMenu::UnstagedFile {
1165 path: "a.rs".into(),
1166 };
1167 let _ = ContextMenu::StagedFile {
1168 path: "b.rs".into(),
1169 };
1170 }
1171
1172 #[test]
1173 fn selected_unstaged_defaults_empty() {
1174 let tab = crate::state::RepoTab::new_empty();
1175 assert!(tab.selected_unstaged.is_empty());
1176 assert!(tab.selected_staged.is_empty());
1177 }
1178
1179 #[test]
1180 fn selected_unstaged_toggle() {
1181 let mut tab = crate::state::RepoTab::new_empty();
1182 tab.selected_unstaged.insert("a.rs".to_string());
1183 tab.selected_unstaged.insert("b.rs".to_string());
1184 assert_eq!(tab.selected_unstaged.len(), 2);
1185 assert!(tab.selected_unstaged.contains("a.rs"));
1186 tab.selected_unstaged.remove("a.rs");
1187 assert_eq!(tab.selected_unstaged.len(), 1);
1188 assert!(!tab.selected_unstaged.contains("a.rs"));
1189 }
1190
1191 #[test]
1192 fn detect_system_editor_returns_valid() {
1193 let editor = super::detect_system_editor();
1195 let _ = editor.display_name();
1196 }
1197
1198 #[test]
1201 fn selected_commit_file_indices_defaults_to_empty_vec() {
1202 let tab = RepoTab::new_empty();
1203 assert!(tab.selected_commit_file_indices.is_empty());
1204 let v: &Vec<usize> = &tab.selected_commit_file_indices;
1206 assert_eq!(v.len(), 0);
1207 }
1208
1209 #[test]
1210 fn multi_file_diffs_defaults_empty() {
1211 let tab = RepoTab::new_empty();
1212 assert!(tab.multi_file_diffs.is_empty());
1213 }
1214
1215 #[test]
1216 fn keyboard_modifiers_default_has_no_shift() {
1217 let state = GitKraft::new();
1218 assert!(!state.keyboard_modifiers.shift());
1219 }
1220
1221 #[test]
1222 fn selected_commit_file_indices_preserves_insertion_order() {
1223 let mut tab = RepoTab::new_empty();
1224 tab.selected_commit_file_indices.push(5);
1225 tab.selected_commit_file_indices.push(2);
1226 tab.selected_commit_file_indices.push(8);
1227 assert_eq!(tab.selected_commit_file_indices, vec![5, 2, 8]);
1228 }
1229
1230 #[test]
1231 fn selected_commit_file_indices_cleared_on_reset() {
1232 let mut tab = RepoTab::new_empty();
1233 tab.selected_commit_file_indices.push(0);
1234 tab.selected_commit_file_indices.push(1);
1235 tab.selected_commit_file_indices.clear();
1236 assert!(tab.selected_commit_file_indices.is_empty());
1237 }
1238
1239 #[test]
1240 fn multi_file_diffs_cleared_on_reset() {
1241 let mut tab = RepoTab::new_empty();
1242 tab.multi_file_diffs.push(gitkraft_core::DiffInfo {
1243 old_file: String::new(),
1244 new_file: "a.rs".to_string(),
1245 status: gitkraft_core::FileStatus::Modified,
1246 hunks: vec![],
1247 });
1248 tab.multi_file_diffs.clear();
1249 assert!(tab.multi_file_diffs.is_empty());
1250 }
1251
1252 #[test]
1253 fn commit_range_diffs_defaults_empty() {
1254 let tab = RepoTab::new_empty();
1255 assert!(tab.commit_range_diffs.is_empty());
1256 }
1257
1258 #[test]
1259 fn commit_range_diffs_cleared_on_apply_payload() {
1260 let mut tab = RepoTab::new_empty();
1262 tab.commit_range_diffs.push(gitkraft_core::DiffInfo {
1263 old_file: String::new(),
1264 new_file: "x.rs".to_string(),
1265 status: gitkraft_core::FileStatus::Modified,
1266 hunks: vec![],
1267 });
1268 tab.commit_range_diffs.clear();
1269 assert!(tab.commit_range_diffs.is_empty());
1270 }
1271
1272 #[test]
1275 fn modifiers_changed_sets_shift_state() {
1276 use crate::message::Message;
1277 let mut state = GitKraft::new();
1278 assert!(!state.keyboard_modifiers.shift());
1279
1280 let _ = state.update(Message::ModifiersChanged(iced::keyboard::Modifiers::SHIFT));
1281 assert!(state.keyboard_modifiers.shift());
1282
1283 let _ = state.update(Message::ModifiersChanged(
1284 iced::keyboard::Modifiers::default(),
1285 ));
1286 assert!(!state.keyboard_modifiers.shift());
1287 }
1288
1289 fn make_commit_files(names: &[&str]) -> Vec<gitkraft_core::DiffFileEntry> {
1292 names
1293 .iter()
1294 .map(|name| gitkraft_core::DiffFileEntry {
1295 old_file: String::new(),
1296 new_file: name.to_string(),
1297 status: gitkraft_core::FileStatus::Modified,
1298 })
1299 .collect()
1300 }
1301
1302 #[test]
1303 fn select_diff_by_index_regular_click_clears_multi_selection() {
1304 use crate::message::Message;
1305 let mut state = GitKraft::new();
1306 state.active_tab_mut().repo_path =
1310 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1311 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1312 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1313 state.active_tab_mut().selected_commit_file_indices = vec![0, 1];
1315
1316 let _ = state.update(Message::SelectDiffByIndex(0));
1318
1319 assert!(state.active_tab().selected_commit_file_indices.is_empty());
1320 assert_eq!(state.active_tab().selected_file_index, Some(0));
1321 }
1322
1323 #[test]
1324 fn regular_click_preserves_multi_file_diffs_until_load_completes() {
1325 use crate::message::Message;
1326 let mut state = GitKraft::new();
1327 state.active_tab_mut().repo_path =
1328 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1329 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1330 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1331
1332 state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1334 old_file: String::new(),
1335 new_file: "a.rs".to_string(),
1336 status: gitkraft_core::FileStatus::Modified,
1337 hunks: vec![],
1338 }];
1339
1340 let _ = state.update(Message::SelectDiffByIndex(1));
1343
1344 assert!(
1345 !state.active_tab().multi_file_diffs.is_empty(),
1346 "multi_file_diffs must NOT be cleared on click (deferred to diff load)"
1347 );
1348 assert_eq!(state.active_tab().selected_file_index, Some(1));
1349 assert!(state.active_tab().is_loading_file_diff);
1350 }
1351
1352 #[test]
1353 fn regular_click_preserves_diff_scroll_until_load_completes() {
1354 use crate::message::Message;
1355 let mut state = GitKraft::new();
1356 state.active_tab_mut().repo_path =
1357 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1358 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1359 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1360 state.active_tab_mut().diff_scroll_offset = 150.0;
1361
1362 let _ = state.update(Message::SelectDiffByIndex(1));
1364
1365 assert_eq!(
1366 state.active_tab().diff_scroll_offset,
1367 150.0,
1368 "diff_scroll_offset must NOT be reset on click (deferred to diff load)"
1369 );
1370 }
1371
1372 #[test]
1373 fn single_file_diff_loaded_clears_deferred_state() {
1374 use crate::message::Message;
1375 let mut state = GitKraft::new();
1376 state.active_tab_mut().repo_path =
1377 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1378 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1379 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1380
1381 state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1383 old_file: String::new(),
1384 new_file: "a.rs".to_string(),
1385 status: gitkraft_core::FileStatus::Modified,
1386 hunks: vec![],
1387 }];
1388 state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1389 old_file: String::new(),
1390 new_file: "b.rs".to_string(),
1391 status: gitkraft_core::FileStatus::Modified,
1392 hunks: vec![],
1393 }];
1394 state.active_tab_mut().is_loading_file_diff = true;
1395 state.active_tab_mut().diff_scroll_offset = 200.0;
1396
1397 let new_diff = gitkraft_core::DiffInfo {
1399 old_file: String::new(),
1400 new_file: "b.rs".to_string(),
1401 status: gitkraft_core::FileStatus::Modified,
1402 hunks: vec![],
1403 };
1404 let _ = state.update(Message::SingleFileDiffLoaded(Ok(new_diff)));
1405
1406 assert!(
1408 state.active_tab().multi_file_diffs.is_empty(),
1409 "multi_file_diffs must be cleared when new diff arrives"
1410 );
1411 assert!(
1412 state.active_tab().commit_range_diffs.is_empty(),
1413 "commit_range_diffs must be cleared when new diff arrives"
1414 );
1415 assert!(
1416 state.active_tab().selected_diff.is_some(),
1417 "selected_diff must be set to the loaded diff"
1418 );
1419 assert!(
1420 !state.active_tab().is_loading_file_diff,
1421 "is_loading_file_diff must be cleared after load"
1422 );
1423 assert_eq!(
1424 state.active_tab().diff_scroll_offset,
1425 0.0,
1426 "diff_scroll_offset must be reset when new diff arrives"
1427 );
1428 }
1429
1430 #[test]
1431 fn regular_click_preserves_commit_range_diffs_until_load_completes() {
1432 use crate::message::Message;
1433 let mut state = GitKraft::new();
1434 state.active_tab_mut().repo_path =
1435 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1436 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1437 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1438
1439 state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1441 old_file: String::new(),
1442 new_file: "x.rs".to_string(),
1443 status: gitkraft_core::FileStatus::Modified,
1444 hunks: vec![],
1445 }];
1446
1447 let _ = state.update(Message::SelectDiffByIndex(0));
1448
1449 assert!(
1450 !state.active_tab().commit_range_diffs.is_empty(),
1451 "commit_range_diffs must NOT be cleared on click (deferred to diff load)"
1452 );
1453 }
1454
1455 #[test]
1456 fn select_diff_by_index_shift_click_adds_both_files_to_selection() {
1457 use crate::message::Message;
1458 let mut state = GitKraft::new();
1459 state.active_tab_mut().repo_path =
1460 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1461 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1462 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1463 state.active_tab_mut().selected_file_index = Some(0);
1464
1465 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1467 let _ = state.update(Message::SelectDiffByIndex(1));
1468
1469 let sel = &state.active_tab().selected_commit_file_indices;
1470 assert!(sel.contains(&0), "anchor file 0 should be selected");
1471 assert!(sel.contains(&1), "newly clicked file 1 should be selected");
1472 assert_eq!(sel.len(), 2);
1473 }
1474
1475 #[test]
1476 fn anchor_file_index_defaults_to_none() {
1477 let tab = RepoTab::new_empty();
1478 assert!(tab.anchor_file_index.is_none());
1479 }
1480
1481 #[test]
1482 fn regular_click_sets_anchor() {
1483 use crate::message::Message;
1484 let mut state = GitKraft::new();
1485 state.active_tab_mut().repo_path =
1486 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1487 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1488 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1489
1490 let _ = state.update(Message::SelectDiffByIndex(2));
1491
1492 assert_eq!(
1493 state.active_tab().anchor_file_index,
1494 Some(2),
1495 "regular click must set anchor to the clicked index"
1496 );
1497 }
1498
1499 #[test]
1500 fn shift_click_selects_range_downward_from_anchor() {
1501 use crate::message::Message;
1502 let mut state = GitKraft::new();
1503 state.active_tab_mut().repo_path =
1504 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1505 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1506 state.active_tab_mut().commit_files =
1507 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1508 state.active_tab_mut().anchor_file_index = Some(1);
1510 state.active_tab_mut().selected_file_index = Some(1);
1511
1512 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1514 let _ = state.update(Message::SelectDiffByIndex(4));
1515
1516 let sel = &state.active_tab().selected_commit_file_indices;
1517 assert_eq!(
1518 sel,
1519 &vec![1, 2, 3, 4],
1520 "range must be contiguous from anchor to click"
1521 );
1522 }
1523
1524 #[test]
1525 fn shift_click_selects_range_upward_from_anchor() {
1526 use crate::message::Message;
1527 let mut state = GitKraft::new();
1528 state.active_tab_mut().repo_path =
1529 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1530 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1531 state.active_tab_mut().commit_files =
1532 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1533 state.active_tab_mut().anchor_file_index = Some(4);
1535 state.active_tab_mut().selected_file_index = Some(4);
1536
1537 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1539 let _ = state.update(Message::SelectDiffByIndex(1));
1540
1541 let sel = &state.active_tab().selected_commit_file_indices;
1542 assert_eq!(
1543 sel,
1544 &vec![1, 2, 3, 4],
1545 "range must be stored ascending regardless of click direction"
1546 );
1547 }
1548
1549 #[test]
1550 fn shift_click_anchor_fixed_on_subsequent_clicks() {
1551 use crate::message::Message;
1552 let mut state = GitKraft::new();
1553 state.active_tab_mut().repo_path =
1554 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1555 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1556 state.active_tab_mut().commit_files =
1557 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1558 state.active_tab_mut().anchor_file_index = Some(2);
1560 state.active_tab_mut().selected_file_index = Some(2);
1561 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1562
1563 let _ = state.update(Message::SelectDiffByIndex(4));
1565 assert_eq!(
1566 state.active_tab().selected_commit_file_indices,
1567 vec![2, 3, 4]
1568 );
1569
1570 let _ = state.update(Message::SelectDiffByIndex(3));
1572 assert_eq!(
1573 state.active_tab().selected_commit_file_indices,
1574 vec![2, 3],
1575 "anchor must stay fixed; second Shift+Click shrinks the range"
1576 );
1577
1578 let _ = state.update(Message::SelectDiffByIndex(0));
1580 assert_eq!(
1581 state.active_tab().selected_commit_file_indices,
1582 vec![0, 1, 2],
1583 "anchor must stay fixed; can extend range in either direction"
1584 );
1585 }
1586
1587 #[test]
1588 fn shift_click_on_anchor_itself_gives_single_item_range() {
1589 use crate::message::Message;
1590 let mut state = GitKraft::new();
1591 state.active_tab_mut().repo_path =
1592 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1593 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1594 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1595 state.active_tab_mut().anchor_file_index = Some(1);
1596 state.active_tab_mut().selected_file_index = Some(1);
1597
1598 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1600 let _ = state.update(Message::SelectDiffByIndex(1));
1601
1602 assert_eq!(state.active_tab().selected_commit_file_indices, vec![1]);
1603 assert!(
1604 state.active_tab().multi_file_diffs.is_empty(),
1605 "single-item range must not populate multi_file_diffs"
1606 );
1607 }
1608
1609 #[test]
1610 fn shift_click_range_is_always_ascending() {
1611 use crate::message::Message;
1612 let mut state = GitKraft::new();
1613 state.active_tab_mut().repo_path =
1614 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1615 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1616 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs"]);
1617 state.active_tab_mut().anchor_file_index = Some(3);
1618 state.active_tab_mut().selected_file_index = Some(3);
1619
1620 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1621 let _ = state.update(Message::SelectDiffByIndex(0));
1622
1623 let sel = &state.active_tab().selected_commit_file_indices;
1624 let is_sorted = sel.windows(2).all(|w| w[0] < w[1]);
1625 assert!(
1626 is_sorted,
1627 "selection must always be stored in ascending order"
1628 );
1629 assert_eq!(sel, &vec![0, 1, 2, 3]);
1630 }
1631
1632 #[test]
1633 fn checkout_file_at_commit_message_variants_exist() {
1634 use crate::message::Message;
1635 let _single =
1637 Message::CheckoutFileAtCommit("abc123".to_string(), "src/main.rs".to_string());
1638 let _multi = Message::CheckoutMultiFilesAtCommit(
1639 "abc123".to_string(),
1640 vec!["a.rs".to_string(), "b.rs".to_string()],
1641 );
1642 }
1643
1644 #[test]
1645 fn checkout_file_at_commit_closes_context_menu() {
1646 use crate::message::Message;
1647 let mut state = GitKraft::new();
1648 state.active_tab_mut().repo_path =
1649 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1650 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1651 oid: "abc123".to_string(),
1652 file_path: "src/main.rs".to_string(),
1653 });
1654 let _ = state.update(Message::CheckoutFileAtCommit(
1655 "abc123".to_string(),
1656 "src/main.rs".to_string(),
1657 ));
1658 assert!(state.active_tab().context_menu.is_none());
1659 }
1660
1661 #[test]
1662 fn checkout_multi_files_at_commit_closes_context_menu() {
1663 use crate::message::Message;
1664 let mut state = GitKraft::new();
1665 state.active_tab_mut().repo_path =
1666 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1667 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1668 oid: "abc123".to_string(),
1669 file_path: "src/main.rs".to_string(),
1670 });
1671 let _ = state.update(Message::CheckoutMultiFilesAtCommit(
1672 "abc123".to_string(),
1673 vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
1674 ));
1675 assert!(state.active_tab().context_menu.is_none());
1676 }
1677
1678 fn make_test_commits(count: usize) -> Vec<gitkraft_core::CommitInfo> {
1681 (0..count)
1682 .map(|i| gitkraft_core::CommitInfo {
1683 oid: i.to_string(),
1684 short_oid: i.to_string(),
1685 summary: String::new(),
1686 message: String::new(),
1687 author_name: String::new(),
1688 author_email: String::new(),
1689 time: Default::default(),
1690 parent_ids: Vec::new(),
1691 refs: Vec::new(),
1692 })
1693 .collect()
1694 }
1695
1696 #[test]
1697 fn selected_commits_defaults_empty() {
1698 let tab = RepoTab::new_empty();
1699 assert!(tab.selected_commits.is_empty());
1700 assert!(tab.anchor_commit_index.is_none());
1701 }
1702
1703 #[test]
1704 fn regular_click_commit_sets_anchor_and_clears_range() {
1705 use crate::message::Message;
1706 let mut state = GitKraft::new();
1707 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
1708 state.active_tab_mut().commits = make_test_commits(3);
1709 state.active_tab_mut().selected_commits = vec![0, 1, 2];
1710
1711 let _ = state.update(Message::SelectCommit(1));
1712
1713 assert_eq!(state.active_tab().anchor_commit_index, Some(1));
1714 assert!(state.active_tab().selected_commits.is_empty());
1715 assert_eq!(state.active_tab().selected_commit, Some(1));
1716 }
1717
1718 #[test]
1719 fn shift_click_commit_selects_range_from_anchor() {
1720 use crate::message::Message;
1721 let mut state = GitKraft::new();
1722 state.active_tab_mut().commits = make_test_commits(5);
1723 state.active_tab_mut().anchor_commit_index = Some(1);
1724 state.active_tab_mut().selected_commit = Some(1);
1725
1726 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1727 let _ = state.update(Message::SelectCommit(4));
1728
1729 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3, 4]);
1730 }
1731
1732 #[test]
1733 fn shift_click_commit_range_is_ascending_when_clicking_above_anchor() {
1734 use crate::message::Message;
1735 let mut state = GitKraft::new();
1736 state.active_tab_mut().commits = make_test_commits(5);
1737 state.active_tab_mut().anchor_commit_index = Some(3);
1738 state.active_tab_mut().selected_commit = Some(3);
1739
1740 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1741 let _ = state.update(Message::SelectCommit(1));
1742
1743 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3]);
1744 }
1745
1746 #[test]
1749 fn execute_commit_action_closes_context_menu() {
1750 use crate::message::Message;
1751 let mut state = GitKraft::new();
1752 state.active_tab_mut().repo_path =
1753 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1754 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::Commit {
1755 index: 0,
1756 oid: "abc123".to_string(),
1757 });
1758
1759 let _ = state.update(Message::ExecuteCommitAction(
1760 "abc123".to_string(),
1761 gitkraft_core::CommitAction::CherryPick,
1762 ));
1763
1764 assert!(state.active_tab().context_menu.is_none());
1765 }
1766
1767 #[test]
1768 fn execute_commit_action_sets_loading_when_repo_open() {
1769 use crate::message::Message;
1770 let mut state = GitKraft::new();
1771 state.active_tab_mut().repo_path =
1772 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1773
1774 let _ = state.update(Message::ExecuteCommitAction(
1775 "abc123".to_string(),
1776 gitkraft_core::CommitAction::ResetHard,
1777 ));
1778
1779 assert!(state.active_tab().is_loading);
1780 }
1781
1782 #[test]
1783 fn execute_commit_action_no_repo_does_not_set_loading() {
1784 use crate::message::Message;
1785 let mut state = GitKraft::new();
1786 let _ = state.update(Message::ExecuteCommitAction(
1789 "abc123".to_string(),
1790 gitkraft_core::CommitAction::CherryPick,
1791 ));
1792
1793 assert!(!state.active_tab().is_loading);
1794 }
1795
1796 #[test]
1797 fn execute_commit_action_sets_status_message_from_action_label() {
1798 use crate::message::Message;
1799 let mut state = GitKraft::new();
1800 state.active_tab_mut().repo_path =
1801 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1802
1803 let _ = state.update(Message::ExecuteCommitAction(
1804 "abc123".to_string(),
1805 gitkraft_core::CommitAction::Revert,
1806 ));
1807
1808 let status = state.active_tab().status_message.as_deref().unwrap_or("");
1809 assert!(
1811 status.contains("Revert commit"),
1812 "expected status to contain 'Revert commit', got: {status:?}"
1813 );
1814 }
1815
1816 #[test]
1819 fn file_history_defaults_empty() {
1820 let tab = RepoTab::new_empty();
1821 assert!(tab.file_history_path.is_none());
1822 assert!(tab.file_history_commits.is_empty());
1823 assert_eq!(tab.file_history_scroll, 0.0);
1824 }
1825
1826 #[test]
1827 fn blame_defaults_empty() {
1828 let tab = RepoTab::new_empty();
1829 assert!(tab.blame_path.is_none());
1830 assert!(tab.blame_lines.is_empty());
1831 assert_eq!(tab.blame_scroll, 0.0);
1832 }
1833
1834 #[test]
1835 fn pending_delete_file_defaults_none() {
1836 let tab = RepoTab::new_empty();
1837 assert!(tab.pending_delete_file.is_none());
1838 }
1839
1840 #[test]
1841 fn view_file_history_sets_path_and_clears_blame() {
1842 use crate::message::Message;
1843 let mut state = GitKraft::new();
1844 state.active_tab_mut().repo_path =
1845 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1846 state.active_tab_mut().blame_path = Some("some/file.rs".to_string());
1847
1848 let _ = state.update(Message::ViewFileHistory("src/main.rs".to_string()));
1849
1850 assert_eq!(
1851 state.active_tab().file_history_path.as_deref(),
1852 Some("src/main.rs")
1853 );
1854 assert!(state.active_tab().blame_path.is_none());
1856 }
1857
1858 #[test]
1859 fn close_file_history_clears_state() {
1860 use crate::message::Message;
1861 let mut state = GitKraft::new();
1862 state.active_tab_mut().file_history_path = Some("src/lib.rs".to_string());
1863 state.active_tab_mut().file_history_commits = vec![gitkraft_core::CommitInfo {
1864 oid: "abc".to_string(),
1865 short_oid: "abc".to_string(),
1866 summary: "s".to_string(),
1867 message: "s".to_string(),
1868 author_name: "a".to_string(),
1869 author_email: "a@b.com".to_string(),
1870 time: Default::default(),
1871 parent_ids: vec![],
1872 refs: Vec::new(),
1873 }];
1874
1875 let _ = state.update(Message::CloseFileHistory);
1876
1877 assert!(state.active_tab().file_history_path.is_none());
1878 assert!(state.active_tab().file_history_commits.is_empty());
1879 }
1880
1881 #[test]
1882 fn view_file_blame_sets_path_and_clears_history() {
1883 use crate::message::Message;
1884 let mut state = GitKraft::new();
1885 state.active_tab_mut().repo_path =
1886 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1887 state.active_tab_mut().file_history_path = Some("some/file.rs".to_string());
1888
1889 let _ = state.update(Message::ViewFileBlame("src/lib.rs".to_string()));
1890
1891 assert_eq!(state.active_tab().blame_path.as_deref(), Some("src/lib.rs"));
1892 assert!(state.active_tab().file_history_path.is_none());
1894 }
1895
1896 #[test]
1897 fn selecting_new_commit_closes_blame_overlay() {
1898 use crate::message::Message;
1899 let mut state = GitKraft::new();
1900 state.active_tab_mut().repo_path =
1901 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1902 state.active_tab_mut().commits = vec![
1904 gitkraft_core::CommitInfo {
1905 oid: "abc1".into(),
1906 short_oid: "abc1".into(),
1907 summary: "first".into(),
1908 message: "first".into(),
1909 author_name: "A".into(),
1910 author_email: "a@a.com".into(),
1911 time: Default::default(),
1912 parent_ids: Vec::new(),
1913 refs: Vec::new(),
1914 },
1915 gitkraft_core::CommitInfo {
1916 oid: "abc2".into(),
1917 short_oid: "abc2".into(),
1918 summary: "second".into(),
1919 message: "second".into(),
1920 author_name: "A".into(),
1921 author_email: "a@a.com".into(),
1922 time: Default::default(),
1923 parent_ids: Vec::new(),
1924 refs: Vec::new(),
1925 },
1926 ];
1927 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1929 state.active_tab_mut().blame_lines = vec![gitkraft_core::BlameLine {
1930 line_number: 1,
1931 content: "fn main() {}".into(),
1932 short_oid: "abc1".into(),
1933 oid: "abc1".into(),
1934 author_name: "A".into(),
1935 time: Default::default(),
1936 }];
1937
1938 let _ = state.update(Message::SelectCommit(1));
1940
1941 assert!(
1942 state.active_tab().blame_path.is_none(),
1943 "blame_path must be cleared when a new commit is selected"
1944 );
1945 assert!(
1946 state.active_tab().blame_lines.is_empty(),
1947 "blame_lines must be cleared when a new commit is selected"
1948 );
1949 }
1950
1951 #[test]
1952 fn close_file_blame_clears_state() {
1953 use crate::message::Message;
1954 let mut state = GitKraft::new();
1955 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1956
1957 let _ = state.update(Message::CloseFileBlame);
1958
1959 assert!(state.active_tab().blame_path.is_none());
1960 assert!(state.active_tab().blame_lines.is_empty());
1961 }
1962
1963 #[test]
1964 fn delete_file_sets_pending() {
1965 use crate::message::Message;
1966 let mut state = GitKraft::new();
1967
1968 let _ = state.update(Message::DeleteFile("src/old.rs".to_string()));
1969
1970 assert_eq!(
1971 state.active_tab().pending_delete_file.as_deref(),
1972 Some("src/old.rs")
1973 );
1974 assert!(state.active_tab().context_menu.is_none());
1975 }
1976
1977 #[test]
1978 fn cancel_delete_file_clears_pending() {
1979 use crate::message::Message;
1980 let mut state = GitKraft::new();
1981 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1982
1983 let _ = state.update(Message::CancelDeleteFile);
1984
1985 assert!(state.active_tab().pending_delete_file.is_none());
1986 }
1987
1988 #[test]
1989 fn confirm_delete_file_no_repo_is_noop() {
1990 use crate::message::Message;
1991 let mut state = GitKraft::new();
1992 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1993 let _ = state.update(Message::ConfirmDeleteFile);
1996
1997 assert!(!state.active_tab().is_loading);
1998 }
1999
2000 #[test]
2001 fn shift_arrow_down_extends_file_list_selection_when_files_loaded() {
2002 use crate::message::Message;
2003 let mut state = GitKraft::new();
2004 state.active_tab_mut().repo_path =
2005 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
2006 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
2007 state.active_tab_mut().selected_file_index = Some(0);
2008 state.active_tab_mut().anchor_file_index = Some(0);
2009 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
2011
2012 let _ = state.update(Message::ShiftArrowDown);
2013
2014 assert_eq!(state.active_tab().selected_file_index, Some(1));
2015 assert!(state.active_tab().selected_commit_file_indices.contains(&0));
2017 assert!(state.active_tab().selected_commit_file_indices.contains(&1));
2018 }
2019
2020 #[test]
2021 fn shift_arrow_down_falls_through_to_commit_log_when_no_files() {
2022 use crate::message::Message;
2023 let mut state = GitKraft::new();
2024 state.active_tab_mut().commits = make_test_commits(5);
2025 state.active_tab_mut().selected_commit = Some(1);
2026 state.active_tab_mut().anchor_commit_index = Some(1);
2027 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
2028 let _ = state.update(Message::ShiftArrowDown);
2031
2032 assert_eq!(state.active_tab().selected_commit, Some(2));
2033 assert!(state.active_tab().selected_commits.contains(&1));
2034 assert!(state.active_tab().selected_commits.contains(&2));
2035 }
2036
2037 #[test]
2038 fn file_system_changed_triggers_full_refresh() {
2039 use crate::message::Message;
2040 let mut state = GitKraft::new();
2041 state.active_tab_mut().repo_path =
2042 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
2043
2044 let _task = state.update(Message::FileSystemChanged);
2048
2049 assert!(
2053 state.active_tab().error_message.is_none(),
2054 "FileSystemChanged must not set an error message"
2055 );
2056 }
2057
2058 fn fake_payload(workdir: &str) -> crate::message::RepoPayload {
2062 gitkraft_core::RepoSnapshot {
2063 info: gitkraft_core::RepoInfo {
2064 path: std::path::PathBuf::from(format!("{workdir}/.git")),
2065 workdir: Some(std::path::PathBuf::from(workdir)),
2066 head_branch: Some("main".into()),
2067 is_bare: false,
2068 state: gitkraft_core::RepoState::Clean,
2069 },
2070 branches: Vec::new(),
2071 commits: Vec::new(),
2072 graph_rows: Vec::new(),
2073 unstaged: Vec::new(),
2074 staged: Vec::new(),
2075 stashes: Vec::new(),
2076 remotes: Vec::new(),
2077 }
2078 }
2079
2080 fn setup_loaded_tab(tab: &mut RepoTab, path: &str) {
2082 tab.repo_path = Some(std::path::PathBuf::from(path));
2083 tab.repo_info = Some(gitkraft_core::RepoInfo {
2084 path: std::path::PathBuf::from(format!("{path}/.git")),
2085 workdir: Some(std::path::PathBuf::from(path)),
2086 head_branch: Some("main".into()),
2087 is_bare: false,
2088 state: gitkraft_core::RepoState::Clean,
2089 });
2090 }
2091
2092 #[test]
2093 fn open_repo_creates_new_tab_when_repo_already_open() {
2094 use crate::message::Message;
2095 let mut state = GitKraft::new();
2096 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2097
2098 assert_eq!(state.tabs.len(), 1);
2099 assert_eq!(state.active_tab, 0);
2100
2101 let _task = state.update(Message::OpenRepo);
2103
2104 assert_eq!(state.tabs.len(), 2);
2105 assert_eq!(state.active_tab, 1);
2106 assert!(state.tabs[1].is_loading);
2108 assert_eq!(
2110 state.tabs[0].repo_path.as_deref(),
2111 Some(std::path::Path::new("/home/user/repo-a"))
2112 );
2113 }
2114
2115 #[test]
2116 fn open_repo_reuses_empty_tab() {
2117 use crate::message::Message;
2118 let mut state = GitKraft::new();
2119 assert!(!state.active_tab().has_repo());
2121
2122 let _task = state.update(Message::OpenRepo);
2123
2124 assert_eq!(state.tabs.len(), 1);
2126 assert_eq!(state.active_tab, 0);
2127 assert!(state.tabs[0].is_loading);
2128 }
2129
2130 #[test]
2131 fn repo_selected_deduplicates_already_open_repo() {
2132 use crate::message::Message;
2133 let mut state = GitKraft::new();
2134 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2136 state.tabs.push(RepoTab::new_empty());
2138 state.active_tab = 1;
2139
2140 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2142 "/home/user/repo-a",
2143 ))));
2144
2145 assert_eq!(state.tabs.len(), 1);
2147 assert_eq!(state.active_tab, 0);
2148 assert_eq!(
2149 state.tabs[0].repo_path.as_deref(),
2150 Some(std::path::Path::new("/home/user/repo-a"))
2151 );
2152 }
2153
2154 #[test]
2155 fn repo_selected_opens_new_repo_in_empty_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 state.tabs.push(RepoTab::new_empty());
2162 state.active_tab = 1;
2163
2164 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2166 "/home/user/repo-b",
2167 ))));
2168
2169 assert_eq!(state.tabs.len(), 2);
2171 assert_eq!(state.active_tab, 1);
2172 assert!(state.tabs[1]
2173 .status_message
2174 .as_deref()
2175 .unwrap_or("")
2176 .contains("repo-b"));
2177 }
2178
2179 #[test]
2180 fn repo_selected_cancel_removes_empty_tab() {
2181 use crate::message::Message;
2182 let mut state = GitKraft::new();
2183 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2185 state.tabs.push(RepoTab::new_empty());
2187 state.active_tab = 1;
2188
2189 let _task = state.update(Message::RepoSelected(None));
2191
2192 assert_eq!(state.tabs.len(), 1);
2194 assert_eq!(state.active_tab, 0);
2195 assert_eq!(
2196 state.tabs[0].repo_path.as_deref(),
2197 Some(std::path::Path::new("/home/user/repo-a"))
2198 );
2199 }
2200
2201 #[test]
2202 fn repo_selected_cancel_keeps_tab_if_only_one() {
2203 use crate::message::Message;
2204 let mut state = GitKraft::new();
2205 assert_eq!(state.tabs.len(), 1);
2207 assert!(!state.active_tab().has_repo());
2208
2209 let _task = state.update(Message::RepoSelected(None));
2210
2211 assert_eq!(state.tabs.len(), 1);
2212 assert!(!state.active_tab().is_loading);
2213 }
2214
2215 #[test]
2216 fn open_recent_repo_deduplicates() {
2217 use crate::message::Message;
2218 let mut state = GitKraft::new();
2219 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2221 state.tabs.push(RepoTab::new_empty());
2223 state.active_tab = 1;
2224
2225 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2228 "/home/user/repo-a",
2229 )));
2230
2231 assert_eq!(state.tabs.len(), 1, "empty tab must be removed");
2232 assert_eq!(state.active_tab, 0);
2233 assert_eq!(
2234 state.active_tab().repo_path.as_deref(),
2235 Some(std::path::Path::new("/home/user/repo-a")),
2236 );
2237 }
2238
2239 #[test]
2240 fn open_recent_repo_creates_new_tab_when_current_has_repo() {
2241 use crate::message::Message;
2242 let mut state = GitKraft::new();
2243 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2245
2246 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2248 "/home/user/repo-b",
2249 )));
2250
2251 assert_eq!(state.tabs.len(), 2);
2252 assert_eq!(state.active_tab, 1);
2253 assert!(state.tabs[1].is_loading);
2254 }
2255
2256 #[test]
2257 fn open_recent_repo_uses_empty_tab() {
2258 use crate::message::Message;
2259 let mut state = GitKraft::new();
2260 assert!(!state.active_tab().has_repo());
2262
2263 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2264 "/home/user/repo-b",
2265 )));
2266
2267 assert_eq!(state.tabs.len(), 1);
2269 assert_eq!(state.active_tab, 0);
2270 assert!(state.tabs[0].is_loading);
2271 }
2272
2273 #[test]
2274 fn open_recent_repo_dedup_removes_empty_tab_with_multiple_loaded() {
2275 use crate::message::Message;
2278 let mut state = GitKraft::new();
2279 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2280 state.tabs.push(RepoTab::new_empty());
2281 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2282 state.tabs.push(RepoTab::new_empty());
2283 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2284 state.tabs.push(RepoTab::new_empty());
2286 state.active_tab = 3;
2287
2288 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2289 "/home/user/repo-b",
2290 )));
2291
2292 assert_eq!(state.tabs.len(), 3, "empty tab must be cleaned up");
2293 assert_eq!(state.active_tab, 1);
2294 assert_eq!(
2295 state.active_tab().repo_path.as_deref(),
2296 Some(std::path::Path::new("/home/user/repo-b")),
2297 );
2298 }
2299
2300 #[test]
2303 fn repo_refreshed_targets_correct_tab_after_tab_switch() {
2304 use crate::message::Message;
2305 let mut state = GitKraft::new();
2306 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2308 state.tabs.push(RepoTab::new_empty());
2310 state.active_tab = 1; let payload = fake_payload("/home/user/repo-a");
2314 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2315
2316 assert!(
2318 state.tabs[0].repo_info.is_some(),
2319 "tab 0 should still have repo info after refresh"
2320 );
2321 assert_eq!(
2322 state.tabs[0].current_branch.as_deref(),
2323 Some("main"),
2324 "tab 0 should have updated branch from payload"
2325 );
2326 assert!(
2328 state.tabs[1].repo_info.is_none(),
2329 "tab 1 (empty) must NOT receive the refresh payload"
2330 );
2331 assert!(
2332 state.tabs[1].repo_path.is_none(),
2333 "tab 1 should still have no repo path"
2334 );
2335 }
2336
2337 #[test]
2338 fn repo_refreshed_targets_active_tab_for_new_open() {
2339 use crate::message::Message;
2340 let mut state = GitKraft::new();
2341 assert_eq!(state.tabs.len(), 1);
2345 assert!(!state.active_tab().has_repo());
2346
2347 let payload = fake_payload("/home/user/new-repo");
2348 let _task = state.update(Message::RepoOpened(Ok(payload)));
2349
2350 assert_eq!(
2352 state.tabs[0].repo_path.as_deref(),
2353 Some(std::path::Path::new("/home/user/new-repo"))
2354 );
2355 assert!(state.tabs[0].repo_info.is_some());
2356 }
2357
2358 #[test]
2359 fn repo_refreshed_does_not_duplicate_into_new_tab() {
2360 use crate::message::Message;
2361 let mut state = GitKraft::new();
2362 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2364
2365 let _task = state.update(Message::NewTab);
2367 assert_eq!(state.tabs.len(), 2);
2368 assert_eq!(state.active_tab, 1);
2369
2370 let payload = fake_payload("/home/user/repo-a");
2372 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2373
2374 assert!(
2376 state.tabs[1].repo_path.is_none(),
2377 "new empty tab must not receive repo-a refresh"
2378 );
2379 assert!(
2380 state.tabs[1].repo_info.is_none(),
2381 "new empty tab must not have repo_info"
2382 );
2383 assert_eq!(
2385 state.tabs[0].repo_path.as_deref(),
2386 Some(std::path::Path::new("/home/user/repo-a"))
2387 );
2388 }
2389
2390 #[test]
2391 fn git_operation_result_targets_correct_tab() {
2392 use crate::message::Message;
2393 let mut state = GitKraft::new();
2394 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2396 state.tabs.push(RepoTab::new_empty());
2398 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2399 state.active_tab = 1;
2400
2401 let payload = fake_payload("/home/user/repo-a");
2404 let _task = state.update(Message::GitOperationResult(Ok(payload)));
2405
2406 assert_eq!(state.tabs[0].current_branch.as_deref(), Some("main"));
2408 assert_eq!(
2410 state.tabs[1].repo_path.as_deref(),
2411 Some(std::path::Path::new("/home/user/repo-b"))
2412 );
2413 }
2414
2415 #[test]
2416 fn multiple_new_tabs_dont_get_polluted_by_refresh() {
2417 use crate::message::Message;
2418 let mut state = GitKraft::new();
2419 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2421
2422 let _task = state.update(Message::NewTab);
2424 let _task = state.update(Message::NewTab);
2425 assert_eq!(state.tabs.len(), 3);
2426 assert_eq!(state.active_tab, 2);
2427
2428 let payload = fake_payload("/home/user/repo-a");
2430 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2431
2432 assert!(state.tabs[0].repo_info.is_some());
2434 assert!(state.tabs[1].repo_info.is_none());
2435 assert!(state.tabs[2].repo_info.is_none());
2436 assert!(state.tabs[1].repo_path.is_none());
2437 assert!(state.tabs[2].repo_path.is_none());
2438 }
2439
2440 #[test]
2441 fn repo_selected_dedup_adjusts_index_when_existing_is_after_active() {
2442 use crate::message::Message;
2443 let mut state = GitKraft::new();
2444 state.tabs.push(RepoTab::new_empty());
2447 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
2448 state.active_tab = 0;
2449
2450 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2452 "/home/user/repo-a",
2453 ))));
2454
2455 assert_eq!(state.tabs.len(), 1);
2457 assert_eq!(state.active_tab, 0);
2458 assert_eq!(
2459 state.tabs[0].repo_path.as_deref(),
2460 Some(std::path::Path::new("/home/user/repo-a"))
2461 );
2462 }
2463
2464 #[test]
2467 fn apply_payload_preserves_multi_selection_by_oid() {
2468 let mut tab = RepoTab::new_empty();
2469 tab.commits = make_test_commits(5);
2470 tab.anchor_commit_index = Some(1);
2472 tab.selected_commits = vec![1, 2, 3];
2473 tab.selected_commit = Some(3);
2474 tab.selected_commit_oid = Some(tab.commits[3].oid.clone());
2475
2476 let mut payload = fake_payload("/tmp/repo");
2478 payload.commits = make_test_commits(5);
2479
2480 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2481
2482 assert_eq!(tab.anchor_commit_index, Some(1));
2484 assert_eq!(tab.selected_commits, vec![1, 2, 3]);
2485 assert_eq!(tab.selected_commit, Some(3));
2486 }
2487
2488 #[test]
2489 fn apply_payload_preserves_anchor_even_without_range() {
2490 let mut tab = RepoTab::new_empty();
2491 tab.commits = make_test_commits(5);
2492 tab.anchor_commit_index = Some(2);
2493 tab.selected_commit = Some(2);
2494 tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2495 let mut payload = fake_payload("/tmp/repo");
2498 payload.commits = make_test_commits(5);
2499
2500 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2501
2502 assert_eq!(tab.anchor_commit_index, Some(2));
2503 assert_eq!(tab.selected_commit, Some(2));
2504 }
2505
2506 #[test]
2507 fn apply_payload_clears_selection_when_commits_disappear() {
2508 let mut tab = RepoTab::new_empty();
2509 tab.commits = make_test_commits(5);
2510 tab.anchor_commit_index = Some(2);
2511 tab.selected_commits = vec![2, 3, 4];
2512 tab.selected_commit = Some(4);
2513 tab.selected_commit_oid = Some(tab.commits[4].oid.clone());
2514
2515 let mut payload = fake_payload("/tmp/repo");
2517 payload.commits = (0..3)
2518 .map(|i| gitkraft_core::CommitInfo {
2519 oid: format!("new_oid_{i}"),
2520 short_oid: format!("new_{i}"),
2521 summary: format!("new commit {i}"),
2522 message: String::new(),
2523 author_name: "Author".into(),
2524 author_email: "a@b.c".into(),
2525 time: Default::default(),
2526 parent_ids: Vec::new(),
2527 refs: Vec::new(),
2528 })
2529 .collect();
2530
2531 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2532
2533 assert!(tab.selected_commits.is_empty());
2535 assert!(tab.anchor_commit_index.is_none());
2536 assert!(tab.selected_commit.is_none());
2537 }
2538
2539 #[test]
2542 fn apply_payload_preserves_file_selection_when_commit_survives() {
2543 let mut tab = RepoTab::new_empty();
2544 tab.commits = make_test_commits(3);
2545 tab.selected_commit = Some(1);
2546 tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2547 tab.commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
2548
2549 tab.anchor_file_index = Some(0);
2551 tab.selected_commit_file_indices = vec![0, 1, 2];
2552 tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2553 old_file: String::new(),
2554 new_file: "a.rs".into(),
2555 status: gitkraft_core::FileStatus::Modified,
2556 hunks: vec![],
2557 }];
2558
2559 let mut payload = fake_payload("/tmp/repo");
2561 payload.commits = make_test_commits(3);
2562
2563 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2564
2565 assert_eq!(tab.anchor_file_index, Some(0));
2567 assert_eq!(tab.selected_commit_file_indices, vec![0, 1, 2]);
2568 assert!(
2569 !tab.multi_file_diffs.is_empty(),
2570 "multi_file_diffs must survive when commit is preserved"
2571 );
2572 }
2573
2574 #[test]
2575 fn apply_payload_clears_file_selection_when_commit_disappears() {
2576 let mut tab = RepoTab::new_empty();
2577 tab.commits = make_test_commits(3);
2578 tab.selected_commit = Some(1);
2579 tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2580 tab.commit_files = make_commit_files(&["a.rs", "b.rs"]);
2581 tab.anchor_file_index = Some(0);
2582 tab.selected_commit_file_indices = vec![0, 1];
2583 tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2584 old_file: String::new(),
2585 new_file: "a.rs".into(),
2586 status: gitkraft_core::FileStatus::Modified,
2587 hunks: vec![],
2588 }];
2589
2590 let mut payload = fake_payload("/tmp/repo");
2592 payload.commits = (0..2)
2593 .map(|i| gitkraft_core::CommitInfo {
2594 oid: format!("new_{i}"),
2595 short_oid: format!("n{i}"),
2596 summary: String::new(),
2597 message: String::new(),
2598 author_name: String::new(),
2599 author_email: String::new(),
2600 time: Default::default(),
2601 parent_ids: Vec::new(),
2602 refs: Vec::new(),
2603 })
2604 .collect();
2605
2606 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2607
2608 assert!(tab.anchor_file_index.is_none());
2610 assert!(tab.selected_commit_file_indices.is_empty());
2611 assert!(tab.multi_file_diffs.is_empty());
2612 assert!(tab.commit_files.is_empty());
2613 }
2614
2615 #[test]
2616 fn apply_payload_preserves_commit_range_diffs_when_commit_survives() {
2617 let mut tab = RepoTab::new_empty();
2618 tab.commits = make_test_commits(5);
2619 tab.selected_commit = Some(2);
2620 tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2621 tab.commit_range_diffs = vec![gitkraft_core::DiffInfo {
2622 old_file: String::new(),
2623 new_file: "x.rs".into(),
2624 status: gitkraft_core::FileStatus::Modified,
2625 hunks: vec![],
2626 }];
2627
2628 let mut payload = fake_payload("/tmp/repo");
2629 payload.commits = make_test_commits(5);
2630
2631 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2632
2633 assert!(
2634 !tab.commit_range_diffs.is_empty(),
2635 "commit_range_diffs must survive when commit is preserved"
2636 );
2637 }
2638
2639 #[test]
2640 fn apply_payload_preserves_commit_message() {
2641 let mut tab = RepoTab::new_empty();
2644 tab.repo_path = Some(std::path::PathBuf::from("/tmp/repo"));
2645 tab.commit_message = "WIP: fixing the bug".to_string();
2646
2647 let payload = fake_payload("/tmp/repo");
2648 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2649
2650 assert_eq!(
2651 tab.commit_message, "WIP: fixing the bug",
2652 "commit_message must survive background repo refreshes"
2653 );
2654 }
2655
2656 #[test]
2659 fn preview_file_loaded_sets_selected_diff() {
2660 use crate::message::Message;
2661 let mut state = GitKraft::new();
2662 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2663
2664 let content = "line 1\nline 2\nline 3".to_string();
2665 let _ = state.update(Message::FilePreviewLoaded(Ok((
2666 "test.rs".to_string(),
2667 content,
2668 ))));
2669
2670 let diff = state.active_tab().selected_diff.as_ref().unwrap();
2671 assert_eq!(diff.new_file, "test.rs");
2672 assert_eq!(diff.hunks.len(), 1);
2673 assert_eq!(diff.hunks[0].lines.len(), 3);
2674 assert_eq!(state.active_tab().diff_scroll_offset, 0.0);
2675 }
2676
2677 #[test]
2678 fn preview_file_loaded_error_sets_error_message() {
2679 use crate::message::Message;
2680 let mut state = GitKraft::new();
2681 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2682
2683 let _ = state.update(Message::FilePreviewLoaded(Err("No such file".to_string())));
2684
2685 assert!(state.active_tab().error_message.is_some());
2686 assert!(state
2687 .active_tab()
2688 .error_message
2689 .as_ref()
2690 .unwrap()
2691 .contains("No such file"));
2692 }
2693
2694 #[test]
2697 fn close_repo_single_tab_shows_welcome_screen() {
2698 use crate::message::Message;
2702 let mut state = GitKraft::new();
2703 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2704 assert_eq!(state.tabs.len(), 1);
2705
2706 let _ = state.update(Message::CloseRepo);
2707
2708 assert_eq!(state.tabs.len(), 1, "last tab must not be removed");
2709 assert_eq!(state.active_tab, 0);
2710 assert!(!state.active_tab().has_repo(), "tab should be empty");
2711 }
2712
2713 #[test]
2714 fn close_repo_with_multiple_tabs_removes_current_tab() {
2715 use crate::message::Message;
2726 let mut state = GitKraft::new();
2727 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2729 state.tabs.push(RepoTab::new_empty());
2731 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2732 state.active_tab = 0;
2734
2735 let _ = state.update(Message::CloseRepo);
2736
2737 assert_eq!(state.tabs.len(), 1, "current tab must be removed");
2740 assert_eq!(state.active_tab, 0);
2741 assert_eq!(
2742 state.active_tab().repo_path.as_deref(),
2743 Some(std::path::Path::new("/home/user/repo-b")),
2744 "repo-b should be the surviving tab"
2745 );
2746 }
2747
2748 #[test]
2749 fn close_repo_second_tab_active_removes_it_and_keeps_first() {
2750 use crate::message::Message;
2753 let mut state = GitKraft::new();
2754 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2756 state.tabs.push(RepoTab::new_empty());
2758 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2759 state.active_tab = 1; let _ = state.update(Message::CloseRepo);
2762
2763 assert_eq!(state.tabs.len(), 1);
2764 assert_eq!(state.active_tab, 0);
2765 assert_eq!(
2766 state.active_tab().repo_path.as_deref(),
2767 Some(std::path::Path::new("/home/user/repo-a")),
2768 "repo-a should be the surviving tab"
2769 );
2770 }
2771
2772 #[test]
2775 fn close_tab_active_tab_first_position() {
2776 use crate::message::Message;
2778 let mut state = GitKraft::new();
2779 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2780 state.tabs.push(RepoTab::new_empty());
2781 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2782 state.active_tab = 0;
2783
2784 let _ = state.update(Message::CloseTab(0));
2785
2786 assert_eq!(state.tabs.len(), 1);
2787 assert_eq!(state.active_tab, 0);
2788 assert_eq!(
2789 state.active_tab().repo_path.as_deref(),
2790 Some(std::path::Path::new("/home/user/repo-b"))
2791 );
2792 }
2793
2794 #[test]
2795 fn close_tab_active_tab_second_position() {
2796 use crate::message::Message;
2798 let mut state = GitKraft::new();
2799 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2800 state.tabs.push(RepoTab::new_empty());
2801 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2802 state.active_tab = 1;
2803
2804 let _ = state.update(Message::CloseTab(1));
2805
2806 assert_eq!(state.tabs.len(), 1);
2807 assert_eq!(state.active_tab, 0);
2808 assert_eq!(
2809 state.active_tab().repo_path.as_deref(),
2810 Some(std::path::Path::new("/home/user/repo-a"))
2811 );
2812 }
2813
2814 #[test]
2815 fn close_tab_non_active_tab_before_active() {
2816 use crate::message::Message;
2818 let mut state = GitKraft::new();
2819 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2820 state.tabs.push(RepoTab::new_empty());
2821 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2822 state.active_tab = 1;
2823
2824 let _ = state.update(Message::CloseTab(0));
2825
2826 assert_eq!(state.tabs.len(), 1);
2827 assert_eq!(state.active_tab, 0, "active_tab must shift down by 1");
2828 assert_eq!(
2829 state.active_tab().repo_path.as_deref(),
2830 Some(std::path::Path::new("/home/user/repo-b")),
2831 "repo-b must remain active"
2832 );
2833 }
2834
2835 #[test]
2836 fn close_tab_non_active_tab_after_active() {
2837 use crate::message::Message;
2839 let mut state = GitKraft::new();
2840 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2841 state.tabs.push(RepoTab::new_empty());
2842 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2843 state.active_tab = 0;
2844
2845 let _ = state.update(Message::CloseTab(1));
2846
2847 assert_eq!(state.tabs.len(), 1);
2848 assert_eq!(state.active_tab, 0, "active_tab must remain unchanged");
2849 assert_eq!(
2850 state.active_tab().repo_path.as_deref(),
2851 Some(std::path::Path::new("/home/user/repo-a")),
2852 "repo-a must remain active"
2853 );
2854 }
2855
2856 #[test]
2857 fn close_tab_last_remaining_tab_is_noop() {
2858 use crate::message::Message;
2860 let mut state = GitKraft::new();
2861 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2862 assert_eq!(state.tabs.len(), 1);
2863
2864 let _ = state.update(Message::CloseTab(0));
2865
2866 assert_eq!(state.tabs.len(), 1, "last tab must not be removed");
2867 assert!(
2868 state.active_tab().has_repo(),
2869 "tab content must be unchanged"
2870 );
2871 }
2872
2873 #[test]
2874 fn close_tab_out_of_bounds_index_is_noop() {
2875 use crate::message::Message;
2877 let mut state = GitKraft::new();
2878 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2879 state.tabs.push(RepoTab::new_empty());
2880 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2881 assert_eq!(state.tabs.len(), 2);
2882
2883 let _ = state.update(Message::CloseTab(99));
2884
2885 assert_eq!(state.tabs.len(), 2, "no tab should be removed");
2886 }
2887
2888 #[test]
2891 fn close_tab_three_tabs_close_first_active() {
2892 use crate::message::Message;
2895 let mut state = GitKraft::new();
2896 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2897 state.tabs.push(RepoTab::new_empty());
2898 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2899 state.tabs.push(RepoTab::new_empty());
2900 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2901 state.active_tab = 0;
2902
2903 let _ = state.update(Message::CloseTab(0));
2904
2905 assert_eq!(state.tabs.len(), 2);
2906 assert_eq!(state.active_tab, 0);
2907 assert_eq!(
2908 state.active_tab().repo_path.as_deref(),
2909 Some(std::path::Path::new("/home/user/repo-b"))
2910 );
2911 }
2912
2913 #[test]
2914 fn close_tab_three_tabs_close_active_middle() {
2915 use crate::message::Message;
2918 let mut state = GitKraft::new();
2919 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2920 state.tabs.push(RepoTab::new_empty());
2921 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2922 state.tabs.push(RepoTab::new_empty());
2923 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2924 state.active_tab = 1;
2925
2926 let _ = state.update(Message::CloseTab(1));
2927
2928 assert_eq!(state.tabs.len(), 2);
2929 assert_eq!(
2930 state.active_tab, 1,
2931 "active_tab must stay at 1 (repo-c slides in)"
2932 );
2933 assert_eq!(
2934 state.active_tab().repo_path.as_deref(),
2935 Some(std::path::Path::new("/home/user/repo-c"))
2936 );
2937 }
2938
2939 #[test]
2940 fn close_tab_three_tabs_close_active_last() {
2941 use crate::message::Message;
2944 let mut state = GitKraft::new();
2945 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2946 state.tabs.push(RepoTab::new_empty());
2947 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2948 state.tabs.push(RepoTab::new_empty());
2949 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2950 state.active_tab = 2;
2951
2952 let _ = state.update(Message::CloseTab(2));
2953
2954 assert_eq!(state.tabs.len(), 2);
2955 assert_eq!(
2956 state.active_tab, 1,
2957 "active_tab must be clamped to last index"
2958 );
2959 assert_eq!(
2960 state.active_tab().repo_path.as_deref(),
2961 Some(std::path::Path::new("/home/user/repo-b"))
2962 );
2963 }
2964
2965 #[test]
2966 fn close_tab_three_tabs_close_first_when_middle_active() {
2967 use crate::message::Message;
2970 let mut state = GitKraft::new();
2971 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2972 state.tabs.push(RepoTab::new_empty());
2973 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2974 state.tabs.push(RepoTab::new_empty());
2975 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2976 state.active_tab = 1;
2977
2978 let _ = state.update(Message::CloseTab(0));
2979
2980 assert_eq!(state.tabs.len(), 2);
2981 assert_eq!(
2982 state.active_tab, 0,
2983 "active_tab must decrement when a tab before it is removed"
2984 );
2985 assert_eq!(
2986 state.active_tab().repo_path.as_deref(),
2987 Some(std::path::Path::new("/home/user/repo-b")),
2988 "repo-b must remain active"
2989 );
2990 }
2991
2992 #[test]
2993 fn close_tab_three_tabs_close_last_when_middle_active() {
2994 use crate::message::Message;
2997 let mut state = GitKraft::new();
2998 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2999 state.tabs.push(RepoTab::new_empty());
3000 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3001 state.tabs.push(RepoTab::new_empty());
3002 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3003 state.active_tab = 1;
3004
3005 let _ = state.update(Message::CloseTab(2));
3006
3007 assert_eq!(state.tabs.len(), 2);
3008 assert_eq!(
3009 state.active_tab, 1,
3010 "active_tab must not change when a tab after it is removed"
3011 );
3012 assert_eq!(
3013 state.active_tab().repo_path.as_deref(),
3014 Some(std::path::Path::new("/home/user/repo-b")),
3015 "repo-b must remain active"
3016 );
3017 }
3018
3019 #[test]
3022 fn close_repo_three_tabs_active_in_middle() {
3023 use crate::message::Message;
3026 let mut state = GitKraft::new();
3027 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3028 state.tabs.push(RepoTab::new_empty());
3029 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3030 state.tabs.push(RepoTab::new_empty());
3031 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3032 state.active_tab = 1;
3033
3034 let _ = state.update(Message::CloseRepo);
3035
3036 assert_eq!(state.tabs.len(), 2);
3037 assert_eq!(state.active_tab, 1);
3038 assert_eq!(
3039 state.active_tab().repo_path.as_deref(),
3040 Some(std::path::Path::new("/home/user/repo-c"))
3041 );
3042 }
3043
3044 #[test]
3045 fn close_repo_three_tabs_active_at_end() {
3046 use crate::message::Message;
3049 let mut state = GitKraft::new();
3050 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3051 state.tabs.push(RepoTab::new_empty());
3052 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3053 state.tabs.push(RepoTab::new_empty());
3054 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3055 state.active_tab = 2;
3056
3057 let _ = state.update(Message::CloseRepo);
3058
3059 assert_eq!(state.tabs.len(), 2);
3060 assert_eq!(state.active_tab, 1, "active_tab clamped to last index");
3061 assert_eq!(
3062 state.active_tab().repo_path.as_deref(),
3063 Some(std::path::Path::new("/home/user/repo-b"))
3064 );
3065 }
3066
3067 #[test]
3070 fn new_tab_appends_empty_tab_and_makes_it_active() {
3071 use crate::message::Message;
3072 let mut state = GitKraft::new();
3073 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3074 assert_eq!(state.tabs.len(), 1);
3075
3076 let _ = state.update(Message::NewTab);
3077
3078 assert_eq!(state.tabs.len(), 2, "a new empty tab must be appended");
3079 assert_eq!(state.active_tab, 1, "new tab must become active");
3080 assert!(!state.active_tab().has_repo(), "new tab must be empty");
3081 assert!(state.tabs[0].has_repo());
3083 }
3084
3085 #[test]
3086 fn new_tab_with_multiple_existing_tabs() {
3087 use crate::message::Message;
3088 let mut state = GitKraft::new();
3089 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3090 state.tabs.push(RepoTab::new_empty());
3091 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3092 state.active_tab = 0;
3093
3094 let _ = state.update(Message::NewTab);
3095
3096 assert_eq!(state.tabs.len(), 3);
3097 assert_eq!(state.active_tab, 2);
3098 assert!(!state.active_tab().has_repo());
3099 }
3100
3101 #[test]
3104 fn switch_tab_to_valid_index() {
3105 use crate::message::Message;
3106 let mut state = GitKraft::new();
3107 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3108 state.tabs.push(RepoTab::new_empty());
3109 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3110 state.active_tab = 0;
3111
3112 let _ = state.update(Message::SwitchTab(1));
3113
3114 assert_eq!(state.active_tab, 1);
3115 assert_eq!(
3116 state.active_tab().repo_path.as_deref(),
3117 Some(std::path::Path::new("/home/user/repo-b"))
3118 );
3119 }
3120
3121 #[test]
3122 fn switch_tab_to_same_index_is_noop() {
3123 use crate::message::Message;
3124 let mut state = GitKraft::new();
3125 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3126 state.tabs.push(RepoTab::new_empty());
3127 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3128 state.active_tab = 1;
3129
3130 let _ = state.update(Message::SwitchTab(1));
3131
3132 assert_eq!(state.active_tab, 1, "should remain on tab 1");
3133 }
3134
3135 #[test]
3136 fn switch_tab_out_of_bounds_is_noop() {
3137 use crate::message::Message;
3139 let mut state = GitKraft::new();
3140 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3141 state.tabs.push(RepoTab::new_empty());
3142 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3143 state.active_tab = 0;
3144
3145 let _ = state.update(Message::SwitchTab(99));
3146
3147 assert_eq!(state.active_tab, 0, "out-of-bounds switch must be ignored");
3148 }
3149
3150 #[test]
3153 fn session_state_matches_open_tab_paths_when_no_empty_tabs() {
3154 let mut state = GitKraft::new();
3155 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3156 state.tabs.push(RepoTab::new_empty());
3157 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3158 state.active_tab = 1;
3159
3160 let (paths, active) = state.session_state();
3161 assert_eq!(paths.len(), 2);
3162 assert_eq!(active, 1);
3163 }
3164
3165 #[test]
3166 fn session_state_adjusts_index_when_empty_tab_before_active() {
3167 let mut state = GitKraft::new();
3170 state.tabs.push(RepoTab::new_empty());
3172 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
3173 state.tabs.push(RepoTab::new_empty());
3174 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-b");
3175 state.active_tab = 2;
3176
3177 let (paths, active) = state.session_state();
3178 assert_eq!(paths.len(), 2);
3179 assert_eq!(active, 1, "session index must skip the empty tab at 0");
3180 }
3181
3182 #[test]
3183 fn session_state_defaults_to_zero_when_active_is_empty_tab() {
3184 let mut state = GitKraft::new();
3185 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3186 state.tabs.push(RepoTab::new_empty()); state.active_tab = 1; let (paths, active) = state.session_state();
3190 assert_eq!(paths.len(), 1);
3191 assert_eq!(active, 0, "empty active tab should default to 0");
3192 }
3193
3194 #[test]
3197 fn repo_selected_sets_repo_path_before_load() {
3198 use crate::message::Message;
3202 let mut state = GitKraft::new();
3203 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3204 state.tabs.push(RepoTab::new_empty());
3206 state.active_tab = 1;
3207
3208 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
3209 "/home/user/repo-b",
3210 ))));
3211
3212 assert_eq!(
3214 state.tabs[1].repo_path.as_deref(),
3215 Some(std::path::Path::new("/home/user/repo-b")),
3216 "repo_path must be set before async load starts"
3217 );
3218 }
3219
3220 #[test]
3221 fn open_recent_repo_sets_repo_path_before_load() {
3222 use crate::message::Message;
3223 let mut state = GitKraft::new();
3224 assert!(!state.active_tab().has_repo());
3226
3227 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
3228 "/home/user/repo-a",
3229 )));
3230
3231 assert_eq!(
3232 state.active_tab().repo_path.as_deref(),
3233 Some(std::path::Path::new("/home/user/repo-a")),
3234 "repo_path must be set before async load starts"
3235 );
3236 }
3237
3238 #[test]
3239 fn handle_repo_loaded_targets_correct_tab_after_switch() {
3240 use crate::message::Message;
3244 let mut state = GitKraft::new();
3245 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3247 state.tabs.push(RepoTab::new_empty());
3249 state.tabs[1].repo_path = Some(std::path::PathBuf::from("/home/user/repo-b"));
3250 state.tabs[1].is_loading = true;
3251
3252 state.active_tab = 0;
3254
3255 let payload = fake_payload("/home/user/repo-b");
3257 let _ = state.update(Message::RepoOpened(Ok(payload)));
3258
3259 assert!(
3261 state.tabs[1].repo_info.is_some(),
3262 "repo-b payload must be written to tab 1"
3263 );
3264 assert_eq!(
3265 state.tabs[1].repo_path.as_deref(),
3266 Some(std::path::Path::new("/home/user/repo-b")),
3267 );
3268 assert_eq!(
3270 state.tabs[0].repo_path.as_deref(),
3271 Some(std::path::Path::new("/home/user/repo-a")),
3272 );
3273 }
3274
3275 #[test]
3278 fn apply_payload_does_not_reset_is_loading_more_commits() {
3279 use crate::message::Message;
3284 let mut state = GitKraft::new();
3285 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3286
3287 state.active_tab_mut().is_loading_more_commits = true;
3289
3290 let payload = fake_payload("/home/user/repo-a");
3292 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3293
3294 assert!(
3296 state.active_tab().is_loading_more_commits,
3297 "refresh must not reset is_loading_more_commits"
3298 );
3299 }
3300
3301 #[test]
3302 fn apply_payload_resets_is_loading_more_commits_for_new_open() {
3303 let mut state = GitKraft::new();
3308 assert!(!state.active_tab().is_loading_more_commits);
3309
3310 let payload = fake_payload("/home/user/repo-a");
3311 let _ = state.update(crate::message::Message::RepoOpened(Ok(payload)));
3312
3313 assert!(!state.active_tab().is_loading_more_commits);
3314 }
3315
3316 #[test]
3317 fn refresh_does_not_shrink_commit_list() {
3318 use crate::message::Message;
3321 let mut state = GitKraft::new();
3322 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3323
3324 let tab = state.active_tab_mut();
3326 tab.commits = make_test_commits(500);
3327 for i in 0..200 {
3328 let mut c = tab.commits[0].clone();
3329 c.oid = format!("extra_{i:04}");
3330 c.short_oid = format!("x{i:03}");
3331 tab.commits.push(c);
3332 }
3333 let extended_len = tab.commits.len();
3334 tab.has_more_commits = true;
3335
3336 let mut payload = fake_payload("/home/user/repo-a");
3338 payload.commits = make_test_commits(500);
3339 payload.graph_rows = gitkraft_core::features::graph::build_graph(&payload.commits);
3340 let payload_len = payload.commits.len();
3341 assert!(
3342 payload_len < extended_len,
3343 "payload must be shorter for this test"
3344 );
3345
3346 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3347
3348 assert_eq!(
3349 state.active_tab().commits.len(),
3350 extended_len,
3351 "refresh must not shrink the commit list"
3352 );
3353 }
3354
3355 #[test]
3356 fn refresh_replaces_commits_when_count_equal_or_greater() {
3357 use crate::message::Message;
3360 let mut state = GitKraft::new();
3361 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3362 state.active_tab_mut().commits = make_test_commits(5);
3363
3364 let original_oid = state.active_tab().commits[0].oid.clone();
3365
3366 let mut payload = fake_payload("/home/user/repo-a");
3368 let mut new_commits = make_test_commits(5);
3369 new_commits[0].oid = "refreshed_oid_0000".to_string();
3370 payload.commits = new_commits;
3371 payload.graph_rows = gitkraft_core::features::graph::build_graph(&payload.commits);
3372 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3373
3374 assert_eq!(
3375 state.active_tab().commits[0].oid,
3376 "refreshed_oid_0000",
3377 "refresh with same count must replace commits"
3378 );
3379 assert_ne!(state.active_tab().commits[0].oid, original_oid);
3380 }
3381
3382 #[test]
3383 fn duplicate_more_commits_loaded_sets_has_more_false() {
3384 use crate::message::{CommitPage, Message};
3390 let mut state = GitKraft::new();
3391 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3392 let tab = state.active_tab_mut();
3393 tab.commits = make_test_commits(10);
3394 tab.has_more_commits = true;
3395
3396 let _initial_count = tab.commits.len();
3398 let mut bigger_commits = tab.commits.clone();
3399 for i in 0..5 {
3400 let mut c = bigger_commits[0].clone();
3401 c.oid = format!("new_{i:04}");
3402 bigger_commits.push(c);
3403 }
3404 let bigger_count = bigger_commits.len();
3405 let graph_rows = gitkraft_core::features::graph::build_graph(&bigger_commits);
3406 let _ = state.update(Message::MoreCommitsLoaded(Ok(CommitPage {
3407 commits: bigger_commits.clone(),
3408 graph_rows: graph_rows.clone(),
3409 })));
3410 assert!(
3411 state.active_tab().has_more_commits,
3412 "first result should keep has_more true"
3413 );
3414 assert_eq!(state.active_tab().commits.len(), bigger_count);
3415
3416 let _ = state.update(Message::MoreCommitsLoaded(Ok(CommitPage {
3418 commits: bigger_commits,
3419 graph_rows,
3420 })));
3421 assert!(
3422 !state.active_tab().has_more_commits,
3423 "identical second result must set has_more_commits false"
3424 );
3425 }
3426
3427 #[test]
3428 fn file_system_changed_blocked_during_pagination() {
3429 use crate::message::Message;
3432 let mut state = GitKraft::new();
3433 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3434 state.active_tab_mut().is_loading_more_commits = true;
3435
3436 let _task = state.update(Message::FileSystemChanged);
3437
3438 assert!(
3442 !state.active_tab().is_loading,
3443 "FileSystemChanged must not start a refresh during pagination"
3444 );
3445 assert!(state.active_tab().is_loading_more_commits);
3447 }
3448
3449 #[test]
3450 fn has_more_commits_preserved_across_refresh() {
3451 use crate::message::Message;
3453 let mut state = GitKraft::new();
3454 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3455 state.active_tab_mut().has_more_commits = true;
3456
3457 let payload = fake_payload("/home/user/repo-a");
3458 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3459
3460 assert!(
3461 state.active_tab().has_more_commits,
3462 "refresh must preserve has_more_commits"
3463 );
3464 }
3465}