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 CommitRefColumnRight,
28 CommitGraphColumnRight,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum DragTargetH {
35 StagingTop,
37 SidebarBranchesBottom,
39 SidebarStashBottom,
41}
42
43#[derive(Debug, Clone)]
45pub enum ContextMenu {
46 Branch {
48 name: String,
49 is_current: bool,
50 local_index: usize,
53 },
54 RemoteBranch { name: String },
56 Commit { index: usize, oid: String },
58 Stash { index: usize },
60 UnstagedFile { path: String },
62 StagedFile { path: String },
64 CommitFile { oid: String, file_path: String },
66}
67
68pub struct RepoTab {
72 pub repo_path: Option<PathBuf>,
75 pub repo_info: Option<RepoInfo>,
77
78 pub branches: Vec<BranchInfo>,
81 pub current_branch: Option<String>,
83
84 pub commits: Vec<CommitInfo>,
87 pub selected_commit: Option<usize>,
89 pub anchor_commit_index: Option<usize>,
91 pub selected_commits: Vec<usize>,
93 pub graph_rows: Vec<gitkraft_core::GraphRow>,
95
96 pub unstaged_changes: Vec<gitkraft_core::DiffFileEntry>,
99 pub staged_changes: Vec<gitkraft_core::DiffFileEntry>,
101 pub commit_files: Vec<gitkraft_core::DiffFileEntry>,
103 pub selected_commit_oid: Option<String>,
105 pub selected_file_index: Option<usize>,
107 pub is_loading_file_diff: bool,
109 pub anchor_file_index: Option<usize>,
112 pub selected_commit_file_indices: Vec<usize>,
115 pub multi_file_diffs: Vec<gitkraft_core::DiffInfo>,
117 pub commit_range_diffs: Vec<gitkraft_core::DiffInfo>,
120 pub selected_diff: Option<DiffInfo>,
122 pub commit_message: String,
124
125 pub stashes: Vec<StashEntry>,
128
129 pub remotes: Vec<RemoteInfo>,
132
133 pub show_commit_detail: bool,
136 pub new_branch_name: String,
138 pub show_branch_create: bool,
140 pub local_branches_expanded: bool,
142 pub remote_branches_expanded: bool,
144 pub stash_message: String,
146
147 pub selected_unstaged: std::collections::HashSet<String>,
149 pub selected_staged: std::collections::HashSet<String>,
151 pub anchor_unstaged_index: Option<usize>,
153 pub anchor_staged_index: Option<usize>,
155
156 pub pending_discard: Option<String>,
158
159 pub status_message: Option<String>,
162 pub error_message: Option<String>,
164 pub is_loading: bool,
166 pub context_menu_pos: (f32, f32),
169
170 pub context_menu: Option<ContextMenu>,
172 pub rename_branch_target: Option<String>,
174 pub rename_branch_input: String,
176
177 pub create_tag_target_oid: Option<String>,
179 pub create_tag_annotated: bool,
181 pub create_tag_name: String,
183 pub create_tag_message: String,
185 pub create_branch_at_oid: Option<String>,
187
188 pub commit_scroll_offset: f32,
192
193 pub hovered_commit: Option<usize>,
196
197 pub branch_context: Vec<Option<String>>,
202
203 pub diff_scroll_offset: f32,
205 pub commit_display: Vec<(String, String)>,
210
211 pub has_more_commits: bool,
213 pub is_loading_more_commits: bool,
215
216 pub file_history_path: Option<String>,
218 pub file_history_commits: Vec<gitkraft_core::CommitInfo>,
220 pub file_history_scroll: f32,
222
223 pub blame_path: Option<String>,
225 pub blame_lines: Vec<gitkraft_core::BlameLine>,
227 pub blame_scroll: f32,
229
230 pub pending_delete_file: Option<String>,
232}
233
234impl RepoTab {
235 pub fn new_empty() -> Self {
237 Self {
238 repo_path: None,
239 repo_info: None,
240 branches: Vec::new(),
241 current_branch: None,
242 commits: Vec::new(),
243 selected_commit: None,
244 anchor_commit_index: None,
245 selected_commits: Vec::new(),
246 graph_rows: Vec::new(),
247 unstaged_changes: Vec::new(),
248 staged_changes: Vec::new(),
249 commit_files: Vec::new(),
250 selected_commit_oid: None,
251 selected_file_index: None,
252 is_loading_file_diff: false,
253 anchor_file_index: None,
254 selected_commit_file_indices: Vec::new(),
255 multi_file_diffs: Vec::new(),
256 commit_range_diffs: Vec::new(),
257 selected_diff: None,
258 commit_message: String::new(),
259 stashes: Vec::new(),
260 remotes: Vec::new(),
261 show_commit_detail: false,
262 new_branch_name: String::new(),
263 show_branch_create: false,
264 local_branches_expanded: true,
265 remote_branches_expanded: true,
266 stash_message: String::new(),
267 selected_unstaged: std::collections::HashSet::new(),
268 selected_staged: std::collections::HashSet::new(),
269 anchor_unstaged_index: None,
270 anchor_staged_index: None,
271 pending_discard: None,
272 status_message: None,
273 error_message: None,
274 is_loading: false,
275 context_menu: None,
276 context_menu_pos: (0.0, 0.0),
277 rename_branch_target: None,
278 rename_branch_input: String::new(),
279 create_tag_target_oid: None,
280 create_tag_annotated: false,
281 create_tag_name: String::new(),
282 create_tag_message: String::new(),
283 create_branch_at_oid: None,
284 commit_scroll_offset: 0.0,
285 hovered_commit: None,
286 branch_context: Vec::new(),
287 diff_scroll_offset: 0.0,
288 commit_display: Vec::new(),
289 has_more_commits: true,
290 is_loading_more_commits: false,
291 file_history_path: None,
292 file_history_commits: Vec::new(),
293 file_history_scroll: 0.0,
294 blame_path: None,
295 blame_lines: Vec::new(),
296 blame_scroll: 0.0,
297 pending_delete_file: None,
298 }
299 }
300
301 pub fn has_repo(&self) -> bool {
303 self.repo_path.is_some()
304 }
305
306 pub fn default_remote(&self) -> String {
308 self.remotes
309 .first()
310 .map(|r| r.name.clone())
311 .unwrap_or_else(|| "origin".to_string())
312 }
313
314 pub fn display_name(&self) -> String {
316 gitkraft_core::repo_display_name(self.repo_path.as_deref())
317 }
318
319 pub fn apply_payload(
325 &mut self,
326 payload: crate::message::RepoPayload,
327 path: std::path::PathBuf,
328 ) {
329 let is_refresh = self.repo_info.is_some();
331 let prev_oid = self.selected_commit_oid.clone();
332
333 let prev_anchor_oid = self
337 .anchor_commit_index
338 .and_then(|i| self.commits.get(i).map(|c| c.oid.clone()));
339 let prev_selected_oids: Vec<String> = self
340 .selected_commits
341 .iter()
342 .filter_map(|&i| self.commits.get(i).map(|c| c.oid.clone()))
343 .collect();
344
345 let prev_anchor_file = self.anchor_file_index;
349 let prev_file_indices = self.selected_commit_file_indices.clone();
350 let prev_multi_file_diffs = std::mem::take(&mut self.multi_file_diffs);
351 let prev_commit_range_diffs = std::mem::take(&mut self.commit_range_diffs);
352
353 let prev_selected_unstaged = std::mem::take(&mut self.selected_unstaged);
356 let prev_selected_staged = std::mem::take(&mut self.selected_staged);
357 let prev_anchor_unstaged = self.anchor_unstaged_index;
358 let prev_anchor_staged = self.anchor_staged_index;
359
360 let prev_selected_diff = self.selected_diff.take();
363 let prev_diff_scroll = self.diff_scroll_offset;
364
365 self.current_branch = payload.info.head_branch.clone();
366 self.repo_path = Some(path);
367 self.repo_info = Some(payload.info);
368 self.branches = payload.branches;
369
370 if !is_refresh || payload.commits.len() >= self.commits.len() {
375 self.commits = payload.commits;
376 self.graph_rows = payload.graph_rows;
377 }
378
379 self.unstaged_changes = payload.unstaged;
380 self.staged_changes = payload.staged;
381 self.stashes = payload.stashes;
382 self.remotes = payload.remotes;
383
384 self.selected_commit = None;
391 self.anchor_commit_index = None;
392 self.selected_commits.clear();
393 self.selected_commit_oid = None;
394 self.error_message = None;
395 self.status_message = Some("Repository loaded.".into());
396 if !is_refresh {
401 self.has_more_commits = true;
402 }
403 self.anchor_unstaged_index = None;
410 self.anchor_staged_index = None;
411 self.anchor_file_index = None;
412 self.selected_commit_file_indices.clear();
413
414 if let Some(oid) = prev_oid {
420 if let Some(new_idx) = self.commits.iter().position(|c| c.oid == oid) {
421 self.selected_commit = Some(new_idx);
422 self.selected_commit_oid = Some(oid);
423 self.selected_diff = prev_selected_diff;
430 self.diff_scroll_offset = prev_diff_scroll;
431
432 self.anchor_file_index = prev_anchor_file;
435 self.selected_commit_file_indices = prev_file_indices;
436 self.multi_file_diffs = prev_multi_file_diffs;
437 self.commit_range_diffs = prev_commit_range_diffs;
438 } else {
439 self.clear_diff_state();
441 }
442 } else {
443 if prev_selected_diff.is_some() {
446 self.selected_diff = prev_selected_diff;
447 self.diff_scroll_offset = prev_diff_scroll;
448 } else {
449 self.clear_diff_state();
450 }
451 }
452
453 if let Some(anchor_oid) = prev_anchor_oid {
457 if let Some(new_anchor) = self.commits.iter().position(|c| c.oid == anchor_oid) {
458 self.anchor_commit_index = Some(new_anchor);
459 }
460 }
461 if !prev_selected_oids.is_empty() {
462 let restored: Vec<usize> = prev_selected_oids
463 .iter()
464 .filter_map(|oid| self.commits.iter().position(|c| &c.oid == oid))
465 .collect();
466 if !restored.is_empty() {
467 self.selected_commits = restored;
468 self.selected_commits.sort_unstable();
469 }
470 }
471
472 self.selected_unstaged = prev_selected_unstaged
475 .into_iter()
476 .filter(|p| self.unstaged_changes.iter().any(|d| d.display_path() == p))
477 .collect();
478 self.selected_staged = prev_selected_staged
479 .into_iter()
480 .filter(|p| self.staged_changes.iter().any(|d| d.display_path() == p))
481 .collect();
482 self.anchor_unstaged_index =
484 prev_anchor_unstaged.filter(|&i| i < self.unstaged_changes.len());
485 self.anchor_staged_index = prev_anchor_staged.filter(|&i| i < self.staged_changes.len());
486 }
487
488 fn clear_diff_state(&mut self) {
491 self.selected_diff = None;
492 self.commit_files.clear();
493 self.selected_file_index = None;
494 self.is_loading_file_diff = false;
495 self.diff_scroll_offset = 0.0;
496 }
497}
498
499pub struct GitKraft {
503 pub tabs: Vec<RepoTab>,
506 pub active_tab: usize,
508
509 pub sidebar_expanded: bool,
512
513 pub sidebar_width: f32,
516 pub commit_log_width: f32,
518 pub staging_height: f32,
520 pub diff_file_list_width: f32,
522 pub commit_ref_width: f32,
524 pub commit_graph_width: f32,
526 pub sidebar_branches_ratio: f32,
528 pub sidebar_stash_ratio: f32,
531 pub staging_unstaged_ratio: f32,
533 pub staging_staged_ratio: f32,
536
537 pub ui_scale: f32,
539
540 pub dragging: Option<DragTarget>,
543 pub dragging_h: Option<DragTargetH>,
545 pub drag_start_x: f32,
547 pub drag_start_y: f32,
549 pub drag_initialized: bool,
553 pub drag_initialized_h: bool,
555
556 pub cursor_pos: Point,
561
562 pub current_theme_index: usize,
565 pub follow_system: bool,
567
568 pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
571
572 pub show_about: bool,
575
576 pub search_visible: bool,
579 pub search_query: String,
581 pub search_results: Vec<gitkraft_core::CommitInfo>,
583 pub search_selected: Option<usize>,
585
586 pub search_diff_files: Vec<gitkraft_core::DiffFileEntry>,
588 pub search_diff_selected: HashSet<usize>,
590 pub search_diff_content: Vec<gitkraft_core::DiffInfo>,
592 pub search_diff_oid: Option<String>,
594
595 pub editor: gitkraft_core::Editor,
597
598 pub keyboard_modifiers: iced::keyboard::Modifiers,
600
601 pub animation_tick: u64,
604
605 pub window_width: f32,
608 pub window_height: f32,
610 pub window_x: f32,
612 pub window_y: f32,
614
615 pub preview_editor: Option<iced::widget::text_editor::Content>,
617 pub preview_path: Option<String>,
619}
620
621impl Default for GitKraft {
622 fn default() -> Self {
623 Self::new()
624 }
625}
626
627impl GitKraft {
628 fn from_settings(settings: gitkraft_core::AppSettings) -> Self {
634 let follow_system = settings.follow_system_theme();
635 let current_theme_index = settings.theme_index();
636
637 let recent_repos = settings.recent_repos;
638
639 let (
640 sidebar_width,
641 commit_log_width,
642 staging_height,
643 diff_file_list_width,
644 sidebar_expanded,
645 ui_scale,
646 commit_ref_width,
647 commit_graph_width,
648 ) = if let Some(ref layout) = settings.layout {
649 (
650 layout.sidebar_width.unwrap_or(220.0),
651 layout.commit_log_width.unwrap_or(500.0),
652 layout.staging_height.unwrap_or(200.0),
653 layout.diff_file_list_width.unwrap_or(180.0),
654 layout.sidebar_expanded.unwrap_or(true),
655 layout.ui_scale.unwrap_or(1.0),
656 layout.commit_ref_width.unwrap_or(120.0),
657 layout.commit_graph_width.unwrap_or(80.0),
658 )
659 } else {
660 (220.0, 500.0, 200.0, 180.0, true, 1.0, 120.0, 80.0)
661 };
662
663 Self {
664 tabs: vec![RepoTab::new_empty()],
665 active_tab: 0,
666
667 sidebar_expanded,
668
669 sidebar_width,
670 commit_log_width,
671 staging_height,
672 diff_file_list_width,
673 commit_ref_width,
674 commit_graph_width,
675 sidebar_branches_ratio: 0.6,
676 sidebar_stash_ratio: 0.25,
677 staging_unstaged_ratio: 0.4,
678 staging_staged_ratio: 0.3,
679
680 ui_scale,
681
682 dragging: None,
683 dragging_h: None,
684 drag_start_x: 0.0,
685 drag_start_y: 0.0,
686 drag_initialized: false,
687 drag_initialized_h: false,
688 cursor_pos: Point::ORIGIN,
689
690 current_theme_index,
691 follow_system,
692
693 recent_repos,
694
695 show_about: false,
696 search_visible: false,
697 search_query: String::new(),
698 search_results: Vec::new(),
699 search_selected: None,
700 search_diff_files: Vec::new(),
701 search_diff_selected: HashSet::new(),
702 search_diff_content: Vec::new(),
703 search_diff_oid: None,
704
705 keyboard_modifiers: iced::keyboard::Modifiers::default(),
706 animation_tick: 0,
707
708 window_width: settings
709 .layout
710 .as_ref()
711 .and_then(|l| l.window_width)
712 .unwrap_or(1400.0),
713 window_height: settings
714 .layout
715 .as_ref()
716 .and_then(|l| l.window_height)
717 .unwrap_or(800.0),
718 window_x: settings
719 .layout
720 .as_ref()
721 .and_then(|l| l.window_x)
722 .unwrap_or(0.0),
723 window_y: settings
724 .layout
725 .as_ref()
726 .and_then(|l| l.window_y)
727 .unwrap_or(0.0),
728
729 editor: settings
730 .editor_name
731 .as_deref()
732 .map(|name| {
733 gitkraft_core::EDITOR_NAMES
735 .iter()
736 .position(|n| n.eq_ignore_ascii_case(name))
737 .map(gitkraft_core::Editor::from_index)
738 .unwrap_or_else(|| {
739 if name.eq_ignore_ascii_case("none") {
740 gitkraft_core::Editor::None
741 } else {
742 gitkraft_core::Editor::Custom(name.to_string())
743 }
744 })
745 })
746 .unwrap_or_else(detect_system_editor),
747
748 preview_editor: None,
749 preview_path: None,
750 }
751 }
752
753 pub fn new() -> Self {
759 Self::from_settings(
760 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default(),
761 )
762 }
763
764 pub fn new_with_session_paths() -> (Self, Vec<PathBuf>) {
770 let settings =
771 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default();
772 let open_tabs = settings.open_tabs.clone();
773 let active_tab_index = settings.active_tab_index;
774
775 let mut state = Self::from_settings(settings);
776
777 if !open_tabs.is_empty() {
778 state.tabs = open_tabs
779 .iter()
780 .map(|path| {
781 let mut tab = RepoTab::new_empty();
782 tab.repo_path = Some(path.clone());
785 if path.exists() {
786 tab.is_loading = true;
787 tab.status_message = Some(format!(
788 "Loading {}…",
789 path.file_name().unwrap_or_default().to_string_lossy()
790 ));
791 } else {
792 tab.error_message =
793 Some(format!("Repository not found: {}", path.display()));
794 }
795 tab
796 })
797 .collect();
798 state.active_tab = active_tab_index.min(state.tabs.len().saturating_sub(1));
799 }
800
801 (state, open_tabs)
802 }
803
804 pub fn open_tab_paths(&self) -> Vec<PathBuf> {
807 self.tabs
808 .iter()
809 .filter(|t| t.repo_info.is_some())
810 .filter_map(|t| t.repo_path.clone())
811 .collect()
812 }
813
814 pub fn session_state(&self) -> (Vec<PathBuf>, usize) {
820 let mut paths = Vec::new();
821 let mut session_active: usize = 0;
822 let mut found_active = false;
823 for (i, tab) in self.tabs.iter().enumerate() {
824 if tab.repo_info.is_some() {
825 if let Some(ref p) = tab.repo_path {
826 if i == self.active_tab {
827 session_active = paths.len();
828 found_active = true;
829 }
830 paths.push(p.clone());
831 }
832 }
833 }
834 if !found_active {
837 session_active = 0;
838 }
839 (paths, session_active)
840 }
841
842 pub fn active_tab(&self) -> &RepoTab {
844 &self.tabs[self.active_tab]
845 }
846
847 pub fn active_tab_mut(&mut self) -> &mut RepoTab {
849 &mut self.tabs[self.active_tab]
850 }
851
852 pub fn dismiss_menu(&mut self) {
854 self.active_tab_mut().context_menu = None;
855 }
856
857 pub fn open_context_menu(&mut self, menu: ContextMenu) {
859 let pos = (self.cursor_pos.x, self.cursor_pos.y);
860 let tab = self.active_tab_mut();
861 tab.context_menu_pos = pos;
862 tab.context_menu = Some(menu);
863 }
864
865 pub fn has_repo(&self) -> bool {
867 self.active_tab().has_repo()
868 }
869
870 pub fn repo_display_name(&self) -> String {
872 self.active_tab().display_name()
873 }
874
875 pub fn colors(&self) -> ThemeColors {
882 ThemeColors::from_core(&gitkraft_core::theme_by_index(self.current_theme_index))
883 }
884
885 pub fn iced_theme(&self) -> iced::Theme {
894 let core = gitkraft_core::theme_by_index(self.current_theme_index);
895 let name = self.current_theme_name().to_string();
896
897 let palette = iced::theme::Palette {
898 background: rgb_to_iced(core.background),
899 text: rgb_to_iced(core.text_primary),
900 primary: rgb_to_iced(core.accent),
901 success: rgb_to_iced(core.success),
902 warning: rgb_to_iced(core.warning),
903 danger: rgb_to_iced(core.error),
904 };
905
906 iced::Theme::custom(name, palette)
907 }
908
909 pub fn switch_to_existing_tab(&mut self, path: &std::path::Path) -> bool {
913 let existing_idx = match self
914 .tabs
915 .iter()
916 .position(|t| t.repo_path.as_deref() == Some(path) && t.repo_info.is_some())
917 {
918 Some(idx) => idx,
919 None => return false,
920 };
921 if !self.active_tab().has_repo() && self.active_tab != existing_idx && self.tabs.len() > 1 {
924 self.tabs.remove(self.active_tab);
925 let adjusted = if existing_idx > self.active_tab {
926 existing_idx - 1
927 } else {
928 existing_idx
929 };
930 self.active_tab = adjusted;
931 } else {
932 self.active_tab = existing_idx;
933 }
934 true
935 }
936
937 pub fn persisted_theme_name(&self) -> &'static str {
939 if self.follow_system {
940 gitkraft_core::SYSTEM_THEME_NAME
941 } else {
942 self.current_theme_name()
943 }
944 }
945
946 pub fn apply_theme_choice(&mut self, choice: usize) -> bool {
949 if choice == gitkraft_core::THEME_COUNT {
950 self.follow_system = true;
951 self.current_theme_index = gitkraft_core::current_system_theme_index();
952 true
953 } else if choice < gitkraft_core::THEME_COUNT {
954 self.follow_system = false;
955 self.current_theme_index = choice;
956 true
957 } else {
958 false
959 }
960 }
961
962 pub fn refresh_system_theme(&mut self) {
964 if self.follow_system {
965 self.current_theme_index = gitkraft_core::current_system_theme_index();
966 }
967 }
968
969 pub fn current_theme_name(&self) -> &'static str {
972 gitkraft_core::THEME_NAMES
973 .get(self.current_theme_index)
974 .copied()
975 .unwrap_or("Default")
976 }
977
978 pub fn refresh_active_tab(&mut self) -> Task<Message> {
982 let tab = self.active_tab();
983 if tab.is_loading {
986 return Task::none();
987 }
988 match tab.repo_path.clone() {
989 Some(path) => {
990 let depth = tab.commits.len();
991 self.active_tab_mut().is_loading = true;
996 crate::features::repo::commands::refresh_repo(path, depth)
997 }
998 None => Task::none(),
999 }
1000 }
1001
1002 pub fn on_ok_refresh(
1009 &mut self,
1010 result: Result<(), String>,
1011 ok_msg: &str,
1012 err_prefix: &str,
1013 ) -> Task<Message> {
1014 match result {
1015 Ok(()) => {
1016 {
1017 let tab = self.active_tab_mut();
1018 tab.is_loading = false;
1019 tab.status_message = Some(ok_msg.to_string());
1020 }
1021 self.refresh_active_tab()
1022 }
1023 Err(e) => {
1024 let tab = self.active_tab_mut();
1025 tab.is_loading = false;
1026 tab.error_message = Some(format!("{err_prefix}: {e}"));
1027 tab.status_message = None;
1028 Task::none()
1029 }
1030 }
1031 }
1032
1033 pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
1035 gitkraft_core::LayoutSettings {
1036 sidebar_width: Some(self.sidebar_width),
1037 commit_log_width: Some(self.commit_log_width),
1038 staging_height: Some(self.staging_height),
1039 diff_file_list_width: Some(self.diff_file_list_width),
1040 sidebar_expanded: Some(self.sidebar_expanded),
1041 ui_scale: Some(self.ui_scale),
1042 window_width: Some(self.window_width),
1043 window_height: Some(self.window_height),
1044 window_x: Some(self.window_x),
1045 window_y: Some(self.window_y),
1046 window_maximized: None, commit_ref_width: Some(self.commit_ref_width),
1048 commit_graph_width: Some(self.commit_graph_width),
1049 }
1050 }
1051}
1052
1053fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
1055 Color::from_rgb8(rgb.r, rgb.g, rgb.b)
1056}
1057
1058fn detect_system_editor() -> gitkraft_core::Editor {
1060 for var in ["VISUAL", "EDITOR"] {
1061 if let Ok(val) = std::env::var(var) {
1062 let bin = val.split('/').next_back().unwrap_or(&val).trim();
1063 return match bin {
1064 "nvim" | "neovim" => gitkraft_core::Editor::Neovim,
1065 "vim" => gitkraft_core::Editor::Vim,
1066 "hx" | "helix" => gitkraft_core::Editor::Helix,
1067 "nano" => gitkraft_core::Editor::Nano,
1068 "micro" => gitkraft_core::Editor::Micro,
1069 "emacs" => gitkraft_core::Editor::Emacs,
1070 "code" => gitkraft_core::Editor::VSCode,
1071 "zed" => gitkraft_core::Editor::Zed,
1072 "subl" => gitkraft_core::Editor::Sublime,
1073 _ => gitkraft_core::Editor::Custom(val),
1074 };
1075 }
1076 }
1077 gitkraft_core::Editor::None
1078}
1079
1080#[cfg(test)]
1083mod tests {
1084 use super::*;
1085
1086 #[test]
1087 fn new_defaults() {
1088 let state = GitKraft::new();
1089 assert!(state.active_tab().repo_path.is_none());
1090 assert!(!state.has_repo());
1091 assert_eq!(state.repo_display_name(), "New Tab");
1092 assert!(state.active_tab().commits.is_empty());
1093 assert!(state.sidebar_expanded);
1094 assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
1096 assert!(state.sidebar_width > 0.0);
1098 assert!(state.commit_log_width > 0.0);
1099 assert!(state.staging_height > 0.0);
1100 assert!(state.dragging.is_none());
1101 assert!(state.dragging_h.is_none());
1102 assert_eq!(state.tabs.len(), 1);
1104 assert_eq!(state.active_tab, 0);
1105 }
1106
1107 #[test]
1108 fn repo_display_name_extracts_basename() {
1109 let mut state = GitKraft::new();
1110 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
1111 assert_eq!(state.repo_display_name(), "my-project");
1112 }
1113
1114 #[test]
1115 fn colors_returns_theme_colors() {
1116 let state = GitKraft::new();
1117 let c = state.colors();
1118 assert!(c.bg.r < 0.5);
1120 }
1121
1122 #[test]
1123 fn iced_theme_is_custom_with_correct_palette() {
1124 let mut state = GitKraft::new();
1125
1126 state.current_theme_index = 0;
1128 let iced_t = state.iced_theme();
1129 let pal = iced_t.palette();
1130 assert!(pal.background.r < 0.5, "Default theme bg should be dark");
1131 assert_eq!(iced_t.to_string(), "Default");
1132
1133 let sol_idx = gitkraft_core::theme_index_by_name("Solarized Light");
1135 state.current_theme_index = sol_idx;
1136 let iced_t = state.iced_theme();
1137 let pal = iced_t.palette();
1138 assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
1139 assert_eq!(iced_t.to_string(), "Solarized Light");
1140
1141 let gruvbox_idx = gitkraft_core::theme_index_by_name("Gruvbox Dark");
1143 state.current_theme_index = gruvbox_idx;
1144 let iced_t = state.iced_theme();
1145 let pal = iced_t.palette();
1146 let core = gitkraft_core::theme_by_index(gruvbox_idx);
1147 let expected_accent = rgb_to_iced(core.accent);
1148 assert!(
1149 (pal.primary.r - expected_accent.r).abs() < 0.01
1150 && (pal.primary.g - expected_accent.g).abs() < 0.01
1151 && (pal.primary.b - expected_accent.b).abs() < 0.01,
1152 "Gruvbox Dark accent should match core accent"
1153 );
1154 }
1155
1156 #[test]
1157 fn iced_theme_name_round_trips_through_core() {
1158 for i in 0..gitkraft_core::THEME_COUNT {
1161 let mut state = GitKraft::new();
1162 state.current_theme_index = i;
1163 let iced_t = state.iced_theme();
1164 let name = iced_t.to_string();
1165 let resolved = gitkraft_core::theme_index_by_name(&name);
1166 assert_eq!(
1167 resolved,
1168 i,
1169 "theme index {i} ({}) did not round-trip through iced_theme name",
1170 gitkraft_core::THEME_NAMES[i]
1171 );
1172 }
1173 }
1174
1175 #[test]
1176 fn current_theme_name_round_trips() {
1177 let mut state = GitKraft::new();
1178 state.current_theme_index = gitkraft_core::theme_index_by_name("Dracula");
1179 assert_eq!(state.current_theme_name(), "Dracula");
1180 state.current_theme_index = 0;
1181 assert_eq!(state.current_theme_name(), "Default");
1182 }
1183
1184 #[test]
1185 fn repo_tab_new_empty() {
1186 let tab = RepoTab::new_empty();
1187 assert!(tab.repo_path.is_none());
1188 assert!(!tab.has_repo());
1189 assert_eq!(tab.display_name(), "New Tab");
1190 assert!(tab.commits.is_empty());
1191 assert!(tab.branches.is_empty());
1192 assert!(!tab.is_loading);
1193 }
1194
1195 #[test]
1196 fn repo_tab_display_name_with_path() {
1197 let mut tab = RepoTab::new_empty();
1198 tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
1199 assert!(tab.has_repo());
1200 assert_eq!(tab.display_name(), "cool-repo");
1201 }
1202
1203 #[test]
1204 fn search_defaults() {
1205 let state = GitKraft::new();
1206 assert!(!state.search_visible);
1207 assert!(state.search_query.is_empty());
1208 assert!(state.search_results.is_empty());
1209 assert!(state.search_selected.is_none());
1210 }
1211
1212 #[test]
1213 fn context_menu_variants_exist() {
1214 use crate::state::ContextMenu;
1216
1217 let _branch = ContextMenu::Branch {
1218 name: "main".to_string(),
1219 is_current: true,
1220 local_index: 0,
1221 };
1222 let _remote = ContextMenu::RemoteBranch {
1223 name: "origin/main".to_string(),
1224 };
1225 let _commit = ContextMenu::Commit {
1226 index: 0,
1227 oid: "abc1234".to_string(),
1228 };
1229 let _stash = ContextMenu::Stash { index: 0 };
1230 let _unstaged = ContextMenu::UnstagedFile {
1231 path: "src/main.rs".to_string(),
1232 };
1233 let _staged = ContextMenu::StagedFile {
1234 path: "src/lib.rs".to_string(),
1235 };
1236 }
1237
1238 #[test]
1239 fn repo_tab_context_menu_defaults_to_none() {
1240 let tab = crate::state::RepoTab::new_empty();
1241 assert!(tab.context_menu.is_none());
1242 }
1243
1244 #[test]
1245 fn context_menu_variants_constructable() {
1246 use crate::state::ContextMenu;
1247 let _ = ContextMenu::Stash { index: 0 };
1248 let _ = ContextMenu::UnstagedFile {
1249 path: "a.rs".into(),
1250 };
1251 let _ = ContextMenu::StagedFile {
1252 path: "b.rs".into(),
1253 };
1254 }
1255
1256 #[test]
1257 fn selected_unstaged_defaults_empty() {
1258 let tab = crate::state::RepoTab::new_empty();
1259 assert!(tab.selected_unstaged.is_empty());
1260 assert!(tab.selected_staged.is_empty());
1261 }
1262
1263 #[test]
1264 fn selected_unstaged_toggle() {
1265 let mut tab = crate::state::RepoTab::new_empty();
1266 tab.selected_unstaged.insert("a.rs".to_string());
1267 tab.selected_unstaged.insert("b.rs".to_string());
1268 assert_eq!(tab.selected_unstaged.len(), 2);
1269 assert!(tab.selected_unstaged.contains("a.rs"));
1270 tab.selected_unstaged.remove("a.rs");
1271 assert_eq!(tab.selected_unstaged.len(), 1);
1272 assert!(!tab.selected_unstaged.contains("a.rs"));
1273 }
1274
1275 #[test]
1276 fn detect_system_editor_returns_valid() {
1277 let editor = super::detect_system_editor();
1279 let _ = editor.display_name();
1280 }
1281
1282 #[test]
1285 fn selected_commit_file_indices_defaults_to_empty_vec() {
1286 let tab = RepoTab::new_empty();
1287 assert!(tab.selected_commit_file_indices.is_empty());
1288 let v: &Vec<usize> = &tab.selected_commit_file_indices;
1290 assert_eq!(v.len(), 0);
1291 }
1292
1293 #[test]
1294 fn multi_file_diffs_defaults_empty() {
1295 let tab = RepoTab::new_empty();
1296 assert!(tab.multi_file_diffs.is_empty());
1297 }
1298
1299 #[test]
1300 fn keyboard_modifiers_default_has_no_shift() {
1301 let state = GitKraft::new();
1302 assert!(!state.keyboard_modifiers.shift());
1303 }
1304
1305 #[test]
1306 fn selected_commit_file_indices_preserves_insertion_order() {
1307 let mut tab = RepoTab::new_empty();
1308 tab.selected_commit_file_indices.push(5);
1309 tab.selected_commit_file_indices.push(2);
1310 tab.selected_commit_file_indices.push(8);
1311 assert_eq!(tab.selected_commit_file_indices, vec![5, 2, 8]);
1312 }
1313
1314 #[test]
1315 fn selected_commit_file_indices_cleared_on_reset() {
1316 let mut tab = RepoTab::new_empty();
1317 tab.selected_commit_file_indices.push(0);
1318 tab.selected_commit_file_indices.push(1);
1319 tab.selected_commit_file_indices.clear();
1320 assert!(tab.selected_commit_file_indices.is_empty());
1321 }
1322
1323 #[test]
1324 fn multi_file_diffs_cleared_on_reset() {
1325 let mut tab = RepoTab::new_empty();
1326 tab.multi_file_diffs.push(gitkraft_core::DiffInfo {
1327 old_file: String::new(),
1328 new_file: "a.rs".to_string(),
1329 status: gitkraft_core::FileStatus::Modified,
1330 hunks: vec![],
1331 });
1332 tab.multi_file_diffs.clear();
1333 assert!(tab.multi_file_diffs.is_empty());
1334 }
1335
1336 #[test]
1337 fn commit_range_diffs_defaults_empty() {
1338 let tab = RepoTab::new_empty();
1339 assert!(tab.commit_range_diffs.is_empty());
1340 }
1341
1342 #[test]
1343 fn commit_range_diffs_cleared_on_apply_payload() {
1344 let mut tab = RepoTab::new_empty();
1346 tab.commit_range_diffs.push(gitkraft_core::DiffInfo {
1347 old_file: String::new(),
1348 new_file: "x.rs".to_string(),
1349 status: gitkraft_core::FileStatus::Modified,
1350 hunks: vec![],
1351 });
1352 tab.commit_range_diffs.clear();
1353 assert!(tab.commit_range_diffs.is_empty());
1354 }
1355
1356 #[test]
1359 fn modifiers_changed_sets_shift_state() {
1360 use crate::message::Message;
1361 let mut state = GitKraft::new();
1362 assert!(!state.keyboard_modifiers.shift());
1363
1364 let _ = state.update(Message::ModifiersChanged(iced::keyboard::Modifiers::SHIFT));
1365 assert!(state.keyboard_modifiers.shift());
1366
1367 let _ = state.update(Message::ModifiersChanged(
1368 iced::keyboard::Modifiers::default(),
1369 ));
1370 assert!(!state.keyboard_modifiers.shift());
1371 }
1372
1373 fn make_commit_files(names: &[&str]) -> Vec<gitkraft_core::DiffFileEntry> {
1376 names
1377 .iter()
1378 .map(|name| gitkraft_core::DiffFileEntry {
1379 old_file: String::new(),
1380 new_file: name.to_string(),
1381 status: gitkraft_core::FileStatus::Modified,
1382 })
1383 .collect()
1384 }
1385
1386 #[test]
1387 fn select_diff_by_index_regular_click_clears_multi_selection() {
1388 use crate::message::Message;
1389 let mut state = GitKraft::new();
1390 state.active_tab_mut().repo_path =
1394 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1395 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1396 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1397 state.active_tab_mut().selected_commit_file_indices = vec![0, 1];
1399
1400 let _ = state.update(Message::SelectDiffByIndex(0));
1402
1403 assert!(state.active_tab().selected_commit_file_indices.is_empty());
1404 assert_eq!(state.active_tab().selected_file_index, Some(0));
1405 }
1406
1407 #[test]
1408 fn regular_click_preserves_multi_file_diffs_until_load_completes() {
1409 use crate::message::Message;
1410 let mut state = GitKraft::new();
1411 state.active_tab_mut().repo_path =
1412 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1413 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1414 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1415
1416 state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1418 old_file: String::new(),
1419 new_file: "a.rs".to_string(),
1420 status: gitkraft_core::FileStatus::Modified,
1421 hunks: vec![],
1422 }];
1423
1424 let _ = state.update(Message::SelectDiffByIndex(1));
1427
1428 assert!(
1429 !state.active_tab().multi_file_diffs.is_empty(),
1430 "multi_file_diffs must NOT be cleared on click (deferred to diff load)"
1431 );
1432 assert_eq!(state.active_tab().selected_file_index, Some(1));
1433 assert!(state.active_tab().is_loading_file_diff);
1434 }
1435
1436 #[test]
1437 fn regular_click_preserves_diff_scroll_until_load_completes() {
1438 use crate::message::Message;
1439 let mut state = GitKraft::new();
1440 state.active_tab_mut().repo_path =
1441 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1442 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1443 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1444 state.active_tab_mut().diff_scroll_offset = 150.0;
1445
1446 let _ = state.update(Message::SelectDiffByIndex(1));
1448
1449 assert_eq!(
1450 state.active_tab().diff_scroll_offset,
1451 150.0,
1452 "diff_scroll_offset must NOT be reset on click (deferred to diff load)"
1453 );
1454 }
1455
1456 #[test]
1457 fn single_file_diff_loaded_clears_deferred_state() {
1458 use crate::message::Message;
1459 let mut state = GitKraft::new();
1460 state.active_tab_mut().repo_path =
1461 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1462 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1463 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1464
1465 state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1467 old_file: String::new(),
1468 new_file: "a.rs".to_string(),
1469 status: gitkraft_core::FileStatus::Modified,
1470 hunks: vec![],
1471 }];
1472 state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1473 old_file: String::new(),
1474 new_file: "b.rs".to_string(),
1475 status: gitkraft_core::FileStatus::Modified,
1476 hunks: vec![],
1477 }];
1478 state.active_tab_mut().is_loading_file_diff = true;
1479 state.active_tab_mut().diff_scroll_offset = 200.0;
1480
1481 let new_diff = gitkraft_core::DiffInfo {
1483 old_file: String::new(),
1484 new_file: "b.rs".to_string(),
1485 status: gitkraft_core::FileStatus::Modified,
1486 hunks: vec![],
1487 };
1488 let _ = state.update(Message::SingleFileDiffLoaded(Ok(new_diff)));
1489
1490 assert!(
1492 state.active_tab().multi_file_diffs.is_empty(),
1493 "multi_file_diffs must be cleared when new diff arrives"
1494 );
1495 assert!(
1496 state.active_tab().commit_range_diffs.is_empty(),
1497 "commit_range_diffs must be cleared when new diff arrives"
1498 );
1499 assert!(
1500 state.active_tab().selected_diff.is_some(),
1501 "selected_diff must be set to the loaded diff"
1502 );
1503 assert!(
1504 !state.active_tab().is_loading_file_diff,
1505 "is_loading_file_diff must be cleared after load"
1506 );
1507 assert_eq!(
1508 state.active_tab().diff_scroll_offset,
1509 0.0,
1510 "diff_scroll_offset must be reset when new diff arrives"
1511 );
1512 }
1513
1514 #[test]
1515 fn regular_click_preserves_commit_range_diffs_until_load_completes() {
1516 use crate::message::Message;
1517 let mut state = GitKraft::new();
1518 state.active_tab_mut().repo_path =
1519 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1520 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1521 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1522
1523 state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1525 old_file: String::new(),
1526 new_file: "x.rs".to_string(),
1527 status: gitkraft_core::FileStatus::Modified,
1528 hunks: vec![],
1529 }];
1530
1531 let _ = state.update(Message::SelectDiffByIndex(0));
1532
1533 assert!(
1534 !state.active_tab().commit_range_diffs.is_empty(),
1535 "commit_range_diffs must NOT be cleared on click (deferred to diff load)"
1536 );
1537 }
1538
1539 #[test]
1540 fn select_diff_by_index_shift_click_adds_both_files_to_selection() {
1541 use crate::message::Message;
1542 let mut state = GitKraft::new();
1543 state.active_tab_mut().repo_path =
1544 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1545 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1546 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1547 state.active_tab_mut().selected_file_index = Some(0);
1548
1549 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1551 let _ = state.update(Message::SelectDiffByIndex(1));
1552
1553 let sel = &state.active_tab().selected_commit_file_indices;
1554 assert!(sel.contains(&0), "anchor file 0 should be selected");
1555 assert!(sel.contains(&1), "newly clicked file 1 should be selected");
1556 assert_eq!(sel.len(), 2);
1557 }
1558
1559 #[test]
1560 fn anchor_file_index_defaults_to_none() {
1561 let tab = RepoTab::new_empty();
1562 assert!(tab.anchor_file_index.is_none());
1563 }
1564
1565 #[test]
1566 fn regular_click_sets_anchor() {
1567 use crate::message::Message;
1568 let mut state = GitKraft::new();
1569 state.active_tab_mut().repo_path =
1570 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1571 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1572 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1573
1574 let _ = state.update(Message::SelectDiffByIndex(2));
1575
1576 assert_eq!(
1577 state.active_tab().anchor_file_index,
1578 Some(2),
1579 "regular click must set anchor to the clicked index"
1580 );
1581 }
1582
1583 #[test]
1584 fn shift_click_selects_range_downward_from_anchor() {
1585 use crate::message::Message;
1586 let mut state = GitKraft::new();
1587 state.active_tab_mut().repo_path =
1588 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1589 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1590 state.active_tab_mut().commit_files =
1591 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1592 state.active_tab_mut().anchor_file_index = Some(1);
1594 state.active_tab_mut().selected_file_index = Some(1);
1595
1596 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1598 let _ = state.update(Message::SelectDiffByIndex(4));
1599
1600 let sel = &state.active_tab().selected_commit_file_indices;
1601 assert_eq!(
1602 sel,
1603 &vec![1, 2, 3, 4],
1604 "range must be contiguous from anchor to click"
1605 );
1606 }
1607
1608 #[test]
1609 fn shift_click_selects_range_upward_from_anchor() {
1610 use crate::message::Message;
1611 let mut state = GitKraft::new();
1612 state.active_tab_mut().repo_path =
1613 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1614 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1615 state.active_tab_mut().commit_files =
1616 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1617 state.active_tab_mut().anchor_file_index = Some(4);
1619 state.active_tab_mut().selected_file_index = Some(4);
1620
1621 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1623 let _ = state.update(Message::SelectDiffByIndex(1));
1624
1625 let sel = &state.active_tab().selected_commit_file_indices;
1626 assert_eq!(
1627 sel,
1628 &vec![1, 2, 3, 4],
1629 "range must be stored ascending regardless of click direction"
1630 );
1631 }
1632
1633 #[test]
1634 fn shift_click_anchor_fixed_on_subsequent_clicks() {
1635 use crate::message::Message;
1636 let mut state = GitKraft::new();
1637 state.active_tab_mut().repo_path =
1638 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1639 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1640 state.active_tab_mut().commit_files =
1641 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1642 state.active_tab_mut().anchor_file_index = Some(2);
1644 state.active_tab_mut().selected_file_index = Some(2);
1645 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1646
1647 let _ = state.update(Message::SelectDiffByIndex(4));
1649 assert_eq!(
1650 state.active_tab().selected_commit_file_indices,
1651 vec![2, 3, 4]
1652 );
1653
1654 let _ = state.update(Message::SelectDiffByIndex(3));
1656 assert_eq!(
1657 state.active_tab().selected_commit_file_indices,
1658 vec![2, 3],
1659 "anchor must stay fixed; second Shift+Click shrinks the range"
1660 );
1661
1662 let _ = state.update(Message::SelectDiffByIndex(0));
1664 assert_eq!(
1665 state.active_tab().selected_commit_file_indices,
1666 vec![0, 1, 2],
1667 "anchor must stay fixed; can extend range in either direction"
1668 );
1669 }
1670
1671 #[test]
1672 fn shift_click_on_anchor_itself_gives_single_item_range() {
1673 use crate::message::Message;
1674 let mut state = GitKraft::new();
1675 state.active_tab_mut().repo_path =
1676 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1677 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1678 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1679 state.active_tab_mut().anchor_file_index = Some(1);
1680 state.active_tab_mut().selected_file_index = Some(1);
1681
1682 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1684 let _ = state.update(Message::SelectDiffByIndex(1));
1685
1686 assert_eq!(state.active_tab().selected_commit_file_indices, vec![1]);
1687 assert!(
1688 state.active_tab().multi_file_diffs.is_empty(),
1689 "single-item range must not populate multi_file_diffs"
1690 );
1691 }
1692
1693 #[test]
1694 fn shift_click_range_is_always_ascending() {
1695 use crate::message::Message;
1696 let mut state = GitKraft::new();
1697 state.active_tab_mut().repo_path =
1698 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1699 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1700 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs"]);
1701 state.active_tab_mut().anchor_file_index = Some(3);
1702 state.active_tab_mut().selected_file_index = Some(3);
1703
1704 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1705 let _ = state.update(Message::SelectDiffByIndex(0));
1706
1707 let sel = &state.active_tab().selected_commit_file_indices;
1708 let is_sorted = sel.windows(2).all(|w| w[0] < w[1]);
1709 assert!(
1710 is_sorted,
1711 "selection must always be stored in ascending order"
1712 );
1713 assert_eq!(sel, &vec![0, 1, 2, 3]);
1714 }
1715
1716 #[test]
1717 fn checkout_file_at_commit_message_variants_exist() {
1718 use crate::message::Message;
1719 let _single =
1721 Message::CheckoutFileAtCommit("abc123".to_string(), "src/main.rs".to_string());
1722 let _multi = Message::CheckoutMultiFilesAtCommit(
1723 "abc123".to_string(),
1724 vec!["a.rs".to_string(), "b.rs".to_string()],
1725 );
1726 }
1727
1728 #[test]
1729 fn checkout_file_at_commit_closes_context_menu() {
1730 use crate::message::Message;
1731 let mut state = GitKraft::new();
1732 state.active_tab_mut().repo_path =
1733 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1734 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1735 oid: "abc123".to_string(),
1736 file_path: "src/main.rs".to_string(),
1737 });
1738 let _ = state.update(Message::CheckoutFileAtCommit(
1739 "abc123".to_string(),
1740 "src/main.rs".to_string(),
1741 ));
1742 assert!(state.active_tab().context_menu.is_none());
1743 }
1744
1745 #[test]
1746 fn checkout_multi_files_at_commit_closes_context_menu() {
1747 use crate::message::Message;
1748 let mut state = GitKraft::new();
1749 state.active_tab_mut().repo_path =
1750 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1751 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1752 oid: "abc123".to_string(),
1753 file_path: "src/main.rs".to_string(),
1754 });
1755 let _ = state.update(Message::CheckoutMultiFilesAtCommit(
1756 "abc123".to_string(),
1757 vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
1758 ));
1759 assert!(state.active_tab().context_menu.is_none());
1760 }
1761
1762 fn make_test_commits(count: usize) -> Vec<gitkraft_core::CommitInfo> {
1765 (0..count)
1766 .map(|i| gitkraft_core::CommitInfo {
1767 oid: i.to_string(),
1768 summary: String::new(),
1769 message: String::new(),
1770 author_name: String::new(),
1771 author_email: String::new(),
1772 time: Default::default(),
1773 parent_ids: Vec::new(),
1774 refs: Vec::new(),
1775 })
1776 .collect()
1777 }
1778
1779 #[test]
1780 fn selected_commits_defaults_empty() {
1781 let tab = RepoTab::new_empty();
1782 assert!(tab.selected_commits.is_empty());
1783 assert!(tab.anchor_commit_index.is_none());
1784 }
1785
1786 #[test]
1787 fn regular_click_commit_sets_anchor_and_clears_range() {
1788 use crate::message::Message;
1789 let mut state = GitKraft::new();
1790 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
1791 state.active_tab_mut().commits = make_test_commits(3);
1792 state.active_tab_mut().selected_commits = vec![0, 1, 2];
1793
1794 let _ = state.update(Message::SelectCommit(1));
1795
1796 assert_eq!(state.active_tab().anchor_commit_index, Some(1));
1797 assert!(state.active_tab().selected_commits.is_empty());
1798 assert_eq!(state.active_tab().selected_commit, Some(1));
1799 }
1800
1801 #[test]
1802 fn shift_click_commit_selects_range_from_anchor() {
1803 use crate::message::Message;
1804 let mut state = GitKraft::new();
1805 state.active_tab_mut().commits = make_test_commits(5);
1806 state.active_tab_mut().anchor_commit_index = Some(1);
1807 state.active_tab_mut().selected_commit = Some(1);
1808
1809 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1810 let _ = state.update(Message::SelectCommit(4));
1811
1812 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3, 4]);
1813 }
1814
1815 #[test]
1816 fn shift_click_commit_range_is_ascending_when_clicking_above_anchor() {
1817 use crate::message::Message;
1818 let mut state = GitKraft::new();
1819 state.active_tab_mut().commits = make_test_commits(5);
1820 state.active_tab_mut().anchor_commit_index = Some(3);
1821 state.active_tab_mut().selected_commit = Some(3);
1822
1823 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1824 let _ = state.update(Message::SelectCommit(1));
1825
1826 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3]);
1827 }
1828
1829 #[test]
1832 fn execute_commit_action_closes_context_menu() {
1833 use crate::message::Message;
1834 let mut state = GitKraft::new();
1835 state.active_tab_mut().repo_path =
1836 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1837 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::Commit {
1838 index: 0,
1839 oid: "abc123".to_string(),
1840 });
1841
1842 let _ = state.update(Message::ExecuteCommitAction(
1843 "abc123".to_string(),
1844 gitkraft_core::CommitAction::CherryPick,
1845 ));
1846
1847 assert!(state.active_tab().context_menu.is_none());
1848 }
1849
1850 #[test]
1851 fn execute_commit_action_sets_loading_when_repo_open() {
1852 use crate::message::Message;
1853 let mut state = GitKraft::new();
1854 state.active_tab_mut().repo_path =
1855 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1856
1857 let _ = state.update(Message::ExecuteCommitAction(
1858 "abc123".to_string(),
1859 gitkraft_core::CommitAction::ResetHard,
1860 ));
1861
1862 assert!(state.active_tab().is_loading);
1863 }
1864
1865 #[test]
1866 fn execute_commit_action_no_repo_does_not_set_loading() {
1867 use crate::message::Message;
1868 let mut state = GitKraft::new();
1869 let _ = state.update(Message::ExecuteCommitAction(
1872 "abc123".to_string(),
1873 gitkraft_core::CommitAction::CherryPick,
1874 ));
1875
1876 assert!(!state.active_tab().is_loading);
1877 }
1878
1879 #[test]
1880 fn execute_commit_action_sets_status_message_from_action_label() {
1881 use crate::message::Message;
1882 let mut state = GitKraft::new();
1883 state.active_tab_mut().repo_path =
1884 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1885
1886 let _ = state.update(Message::ExecuteCommitAction(
1887 "abc123".to_string(),
1888 gitkraft_core::CommitAction::Revert,
1889 ));
1890
1891 let status = state.active_tab().status_message.as_deref().unwrap_or("");
1892 assert!(
1894 status.contains("Revert commit"),
1895 "expected status to contain 'Revert commit', got: {status:?}"
1896 );
1897 }
1898
1899 #[test]
1902 fn file_history_defaults_empty() {
1903 let tab = RepoTab::new_empty();
1904 assert!(tab.file_history_path.is_none());
1905 assert!(tab.file_history_commits.is_empty());
1906 assert_eq!(tab.file_history_scroll, 0.0);
1907 }
1908
1909 #[test]
1910 fn blame_defaults_empty() {
1911 let tab = RepoTab::new_empty();
1912 assert!(tab.blame_path.is_none());
1913 assert!(tab.blame_lines.is_empty());
1914 assert_eq!(tab.blame_scroll, 0.0);
1915 }
1916
1917 #[test]
1918 fn pending_delete_file_defaults_none() {
1919 let tab = RepoTab::new_empty();
1920 assert!(tab.pending_delete_file.is_none());
1921 }
1922
1923 #[test]
1924 fn view_file_history_sets_path_and_clears_blame() {
1925 use crate::message::Message;
1926 let mut state = GitKraft::new();
1927 state.active_tab_mut().repo_path =
1928 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1929 state.active_tab_mut().blame_path = Some("some/file.rs".to_string());
1930
1931 let _ = state.update(Message::ViewFileHistory("src/main.rs".to_string()));
1932
1933 assert_eq!(
1934 state.active_tab().file_history_path.as_deref(),
1935 Some("src/main.rs")
1936 );
1937 assert!(state.active_tab().blame_path.is_none());
1939 }
1940
1941 #[test]
1942 fn close_file_history_clears_state() {
1943 use crate::message::Message;
1944 let mut state = GitKraft::new();
1945 state.active_tab_mut().file_history_path = Some("src/lib.rs".to_string());
1946 state.active_tab_mut().file_history_commits = vec![gitkraft_core::CommitInfo {
1947 oid: "abc".to_string(),
1948 summary: "s".to_string(),
1949 message: "s".to_string(),
1950 author_name: "a".to_string(),
1951 author_email: "a@b.com".to_string(),
1952 time: Default::default(),
1953 parent_ids: vec![],
1954 refs: Vec::new(),
1955 }];
1956
1957 let _ = state.update(Message::CloseFileHistory);
1958
1959 assert!(state.active_tab().file_history_path.is_none());
1960 assert!(state.active_tab().file_history_commits.is_empty());
1961 }
1962
1963 #[test]
1964 fn view_file_blame_sets_path_and_clears_history() {
1965 use crate::message::Message;
1966 let mut state = GitKraft::new();
1967 state.active_tab_mut().repo_path =
1968 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1969 state.active_tab_mut().file_history_path = Some("some/file.rs".to_string());
1970
1971 let _ = state.update(Message::ViewFileBlame("src/lib.rs".to_string()));
1972
1973 assert_eq!(state.active_tab().blame_path.as_deref(), Some("src/lib.rs"));
1974 assert!(state.active_tab().file_history_path.is_none());
1976 }
1977
1978 #[test]
1979 fn selecting_new_commit_closes_blame_overlay() {
1980 use crate::message::Message;
1981 let mut state = GitKraft::new();
1982 state.active_tab_mut().repo_path =
1983 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1984 state.active_tab_mut().commits = vec![
1986 gitkraft_core::CommitInfo {
1987 oid: "abc1".into(),
1988 summary: "first".into(),
1989 message: "first".into(),
1990 author_name: "A".into(),
1991 author_email: "a@a.com".into(),
1992 time: Default::default(),
1993 parent_ids: Vec::new(),
1994 refs: Vec::new(),
1995 },
1996 gitkraft_core::CommitInfo {
1997 oid: "abc2".into(),
1998 summary: "second".into(),
1999 message: "second".into(),
2000 author_name: "A".into(),
2001 author_email: "a@a.com".into(),
2002 time: Default::default(),
2003 parent_ids: Vec::new(),
2004 refs: Vec::new(),
2005 },
2006 ];
2007 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
2009 state.active_tab_mut().blame_lines = vec![gitkraft_core::BlameLine {
2010 line_number: 1,
2011 content: "fn main() {}".into(),
2012 oid: "abc1".into(),
2013 author_name: "A".into(),
2014 time: Default::default(),
2015 }];
2016
2017 let _ = state.update(Message::SelectCommit(1));
2019
2020 assert!(
2021 state.active_tab().blame_path.is_none(),
2022 "blame_path must be cleared when a new commit is selected"
2023 );
2024 assert!(
2025 state.active_tab().blame_lines.is_empty(),
2026 "blame_lines must be cleared when a new commit is selected"
2027 );
2028 }
2029
2030 #[test]
2031 fn close_file_blame_clears_state() {
2032 use crate::message::Message;
2033 let mut state = GitKraft::new();
2034 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
2035
2036 let _ = state.update(Message::CloseFileBlame);
2037
2038 assert!(state.active_tab().blame_path.is_none());
2039 assert!(state.active_tab().blame_lines.is_empty());
2040 }
2041
2042 #[test]
2043 fn delete_file_sets_pending() {
2044 use crate::message::Message;
2045 let mut state = GitKraft::new();
2046
2047 let _ = state.update(Message::DeleteFile("src/old.rs".to_string()));
2048
2049 assert_eq!(
2050 state.active_tab().pending_delete_file.as_deref(),
2051 Some("src/old.rs")
2052 );
2053 assert!(state.active_tab().context_menu.is_none());
2054 }
2055
2056 #[test]
2057 fn cancel_delete_file_clears_pending() {
2058 use crate::message::Message;
2059 let mut state = GitKraft::new();
2060 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
2061
2062 let _ = state.update(Message::CancelDeleteFile);
2063
2064 assert!(state.active_tab().pending_delete_file.is_none());
2065 }
2066
2067 #[test]
2068 fn confirm_delete_file_no_repo_is_noop() {
2069 use crate::message::Message;
2070 let mut state = GitKraft::new();
2071 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
2072 let _ = state.update(Message::ConfirmDeleteFile);
2075
2076 assert!(!state.active_tab().is_loading);
2077 }
2078
2079 #[test]
2080 fn shift_arrow_down_extends_file_list_selection_when_files_loaded() {
2081 use crate::message::Message;
2082 let mut state = GitKraft::new();
2083 state.active_tab_mut().repo_path =
2084 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
2085 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
2086 state.active_tab_mut().selected_file_index = Some(0);
2087 state.active_tab_mut().anchor_file_index = Some(0);
2088 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
2090
2091 let _ = state.update(Message::ShiftArrowDown);
2092
2093 assert_eq!(state.active_tab().selected_file_index, Some(1));
2094 assert!(state.active_tab().selected_commit_file_indices.contains(&0));
2096 assert!(state.active_tab().selected_commit_file_indices.contains(&1));
2097 }
2098
2099 #[test]
2100 fn shift_arrow_down_falls_through_to_commit_log_when_no_files() {
2101 use crate::message::Message;
2102 let mut state = GitKraft::new();
2103 state.active_tab_mut().commits = make_test_commits(5);
2104 state.active_tab_mut().selected_commit = Some(1);
2105 state.active_tab_mut().anchor_commit_index = Some(1);
2106 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
2107 let _ = state.update(Message::ShiftArrowDown);
2110
2111 assert_eq!(state.active_tab().selected_commit, Some(2));
2112 assert!(state.active_tab().selected_commits.contains(&1));
2113 assert!(state.active_tab().selected_commits.contains(&2));
2114 }
2115
2116 #[test]
2117 fn file_system_changed_triggers_full_refresh() {
2118 use crate::message::Message;
2119 let mut state = GitKraft::new();
2120 state.active_tab_mut().repo_path =
2121 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
2122
2123 let _task = state.update(Message::FileSystemChanged);
2126
2127 assert!(
2129 state.active_tab().is_loading,
2130 "FileSystemChanged must set is_loading to guard against concurrent refreshes"
2131 );
2132 assert!(
2133 state.active_tab().error_message.is_none(),
2134 "FileSystemChanged must not set an error message"
2135 );
2136 }
2137
2138 #[test]
2139 fn refresh_active_tab_skips_when_already_loading() {
2140 let mut state = GitKraft::new();
2144 state.active_tab_mut().repo_path =
2145 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
2146 state.active_tab_mut().is_loading = true;
2147
2148 let _task = state.refresh_active_tab();
2150 assert!(state.active_tab().is_loading);
2152 }
2153
2154 fn fake_payload(workdir: &str) -> crate::message::RepoPayload {
2158 gitkraft_core::RepoSnapshot {
2159 info: gitkraft_core::RepoInfo {
2160 path: std::path::PathBuf::from(format!("{workdir}/.git")),
2161 workdir: Some(std::path::PathBuf::from(workdir)),
2162 head_branch: Some("main".into()),
2163 is_bare: false,
2164 state: gitkraft_core::RepoState::Clean,
2165 },
2166 branches: Vec::new(),
2167 commits: Vec::new(),
2168 graph_rows: Vec::new(),
2169 unstaged: Vec::new(),
2170 staged: Vec::new(),
2171 stashes: Vec::new(),
2172 remotes: Vec::new(),
2173 }
2174 }
2175
2176 fn setup_loaded_tab(tab: &mut RepoTab, path: &str) {
2178 tab.repo_path = Some(std::path::PathBuf::from(path));
2179 tab.repo_info = Some(gitkraft_core::RepoInfo {
2180 path: std::path::PathBuf::from(format!("{path}/.git")),
2181 workdir: Some(std::path::PathBuf::from(path)),
2182 head_branch: Some("main".into()),
2183 is_bare: false,
2184 state: gitkraft_core::RepoState::Clean,
2185 });
2186 }
2187
2188 #[test]
2189 fn open_repo_creates_new_tab_when_repo_already_open() {
2190 use crate::message::Message;
2191 let mut state = GitKraft::new();
2192 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2193
2194 assert_eq!(state.tabs.len(), 1);
2195 assert_eq!(state.active_tab, 0);
2196
2197 let _task = state.update(Message::OpenRepo);
2199
2200 assert_eq!(state.tabs.len(), 2);
2201 assert_eq!(state.active_tab, 1);
2202 assert!(state.tabs[1].is_loading);
2204 assert_eq!(
2206 state.tabs[0].repo_path.as_deref(),
2207 Some(std::path::Path::new("/home/user/repo-a"))
2208 );
2209 }
2210
2211 #[test]
2212 fn open_repo_reuses_empty_tab() {
2213 use crate::message::Message;
2214 let mut state = GitKraft::new();
2215 assert!(!state.active_tab().has_repo());
2217
2218 let _task = state.update(Message::OpenRepo);
2219
2220 assert_eq!(state.tabs.len(), 1);
2222 assert_eq!(state.active_tab, 0);
2223 assert!(state.tabs[0].is_loading);
2224 }
2225
2226 #[test]
2227 fn repo_selected_deduplicates_already_open_repo() {
2228 use crate::message::Message;
2229 let mut state = GitKraft::new();
2230 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2232 state.tabs.push(RepoTab::new_empty());
2234 state.active_tab = 1;
2235
2236 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2238 "/home/user/repo-a",
2239 ))));
2240
2241 assert_eq!(state.tabs.len(), 1);
2243 assert_eq!(state.active_tab, 0);
2244 assert_eq!(
2245 state.tabs[0].repo_path.as_deref(),
2246 Some(std::path::Path::new("/home/user/repo-a"))
2247 );
2248 }
2249
2250 #[test]
2251 fn repo_selected_opens_new_repo_in_empty_tab() {
2252 use crate::message::Message;
2253 let mut state = GitKraft::new();
2254 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2256 state.tabs.push(RepoTab::new_empty());
2258 state.active_tab = 1;
2259
2260 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2262 "/home/user/repo-b",
2263 ))));
2264
2265 assert_eq!(state.tabs.len(), 2);
2267 assert_eq!(state.active_tab, 1);
2268 assert!(state.tabs[1]
2269 .status_message
2270 .as_deref()
2271 .unwrap_or("")
2272 .contains("repo-b"));
2273 }
2274
2275 #[test]
2276 fn repo_selected_cancel_removes_empty_tab() {
2277 use crate::message::Message;
2278 let mut state = GitKraft::new();
2279 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2281 state.tabs.push(RepoTab::new_empty());
2283 state.active_tab = 1;
2284
2285 let _task = state.update(Message::RepoSelected(None));
2287
2288 assert_eq!(state.tabs.len(), 1);
2290 assert_eq!(state.active_tab, 0);
2291 assert_eq!(
2292 state.tabs[0].repo_path.as_deref(),
2293 Some(std::path::Path::new("/home/user/repo-a"))
2294 );
2295 }
2296
2297 #[test]
2298 fn repo_selected_cancel_keeps_tab_if_only_one() {
2299 use crate::message::Message;
2300 let mut state = GitKraft::new();
2301 assert_eq!(state.tabs.len(), 1);
2303 assert!(!state.active_tab().has_repo());
2304
2305 let _task = state.update(Message::RepoSelected(None));
2306
2307 assert_eq!(state.tabs.len(), 1);
2308 assert!(!state.active_tab().is_loading);
2309 }
2310
2311 #[test]
2312 fn open_recent_repo_deduplicates() {
2313 use crate::message::Message;
2314 let mut state = GitKraft::new();
2315 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2317 state.tabs.push(RepoTab::new_empty());
2319 state.active_tab = 1;
2320
2321 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2324 "/home/user/repo-a",
2325 )));
2326
2327 assert_eq!(state.tabs.len(), 1, "empty tab must be removed");
2328 assert_eq!(state.active_tab, 0);
2329 assert_eq!(
2330 state.active_tab().repo_path.as_deref(),
2331 Some(std::path::Path::new("/home/user/repo-a")),
2332 );
2333 }
2334
2335 #[test]
2336 fn open_recent_repo_creates_new_tab_when_current_has_repo() {
2337 use crate::message::Message;
2338 let mut state = GitKraft::new();
2339 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2341
2342 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2344 "/home/user/repo-b",
2345 )));
2346
2347 assert_eq!(state.tabs.len(), 2);
2348 assert_eq!(state.active_tab, 1);
2349 assert!(state.tabs[1].is_loading);
2350 }
2351
2352 #[test]
2353 fn open_recent_repo_uses_empty_tab() {
2354 use crate::message::Message;
2355 let mut state = GitKraft::new();
2356 assert!(!state.active_tab().has_repo());
2358
2359 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2360 "/home/user/repo-b",
2361 )));
2362
2363 assert_eq!(state.tabs.len(), 1);
2365 assert_eq!(state.active_tab, 0);
2366 assert!(state.tabs[0].is_loading);
2367 }
2368
2369 #[test]
2370 fn open_recent_repo_dedup_removes_empty_tab_with_multiple_loaded() {
2371 use crate::message::Message;
2374 let mut state = GitKraft::new();
2375 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2376 state.tabs.push(RepoTab::new_empty());
2377 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2378 state.tabs.push(RepoTab::new_empty());
2379 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2380 state.tabs.push(RepoTab::new_empty());
2382 state.active_tab = 3;
2383
2384 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2385 "/home/user/repo-b",
2386 )));
2387
2388 assert_eq!(state.tabs.len(), 3, "empty tab must be cleaned up");
2389 assert_eq!(state.active_tab, 1);
2390 assert_eq!(
2391 state.active_tab().repo_path.as_deref(),
2392 Some(std::path::Path::new("/home/user/repo-b")),
2393 );
2394 }
2395
2396 #[test]
2399 fn repo_refreshed_targets_correct_tab_after_tab_switch() {
2400 use crate::message::Message;
2401 let mut state = GitKraft::new();
2402 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2404 state.tabs.push(RepoTab::new_empty());
2406 state.active_tab = 1; let payload = fake_payload("/home/user/repo-a");
2410 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2411
2412 assert!(
2414 state.tabs[0].repo_info.is_some(),
2415 "tab 0 should still have repo info after refresh"
2416 );
2417 assert_eq!(
2418 state.tabs[0].current_branch.as_deref(),
2419 Some("main"),
2420 "tab 0 should have updated branch from payload"
2421 );
2422 assert!(
2424 state.tabs[1].repo_info.is_none(),
2425 "tab 1 (empty) must NOT receive the refresh payload"
2426 );
2427 assert!(
2428 state.tabs[1].repo_path.is_none(),
2429 "tab 1 should still have no repo path"
2430 );
2431 }
2432
2433 #[test]
2434 fn repo_refreshed_targets_active_tab_for_new_open() {
2435 use crate::message::Message;
2436 let mut state = GitKraft::new();
2437 assert_eq!(state.tabs.len(), 1);
2441 assert!(!state.active_tab().has_repo());
2442
2443 let payload = fake_payload("/home/user/new-repo");
2444 let _task = state.update(Message::RepoOpened(Ok(payload)));
2445
2446 assert_eq!(
2448 state.tabs[0].repo_path.as_deref(),
2449 Some(std::path::Path::new("/home/user/new-repo"))
2450 );
2451 assert!(state.tabs[0].repo_info.is_some());
2452 }
2453
2454 #[test]
2455 fn repo_refreshed_does_not_duplicate_into_new_tab() {
2456 use crate::message::Message;
2457 let mut state = GitKraft::new();
2458 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2460
2461 let _task = state.update(Message::NewTab);
2463 assert_eq!(state.tabs.len(), 2);
2464 assert_eq!(state.active_tab, 1);
2465
2466 let payload = fake_payload("/home/user/repo-a");
2468 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2469
2470 assert!(
2472 state.tabs[1].repo_path.is_none(),
2473 "new empty tab must not receive repo-a refresh"
2474 );
2475 assert!(
2476 state.tabs[1].repo_info.is_none(),
2477 "new empty tab must not have repo_info"
2478 );
2479 assert_eq!(
2481 state.tabs[0].repo_path.as_deref(),
2482 Some(std::path::Path::new("/home/user/repo-a"))
2483 );
2484 }
2485
2486 #[test]
2487 fn git_operation_result_targets_correct_tab() {
2488 use crate::message::Message;
2489 let mut state = GitKraft::new();
2490 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2492 state.tabs.push(RepoTab::new_empty());
2494 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2495 state.active_tab = 1;
2496
2497 let payload = fake_payload("/home/user/repo-a");
2500 let _task = state.update(Message::GitOperationResult(Ok(payload)));
2501
2502 assert_eq!(state.tabs[0].current_branch.as_deref(), Some("main"));
2504 assert_eq!(
2506 state.tabs[1].repo_path.as_deref(),
2507 Some(std::path::Path::new("/home/user/repo-b"))
2508 );
2509 }
2510
2511 #[test]
2512 fn multiple_new_tabs_dont_get_polluted_by_refresh() {
2513 use crate::message::Message;
2514 let mut state = GitKraft::new();
2515 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2517
2518 let _task = state.update(Message::NewTab);
2520 let _task = state.update(Message::NewTab);
2521 assert_eq!(state.tabs.len(), 3);
2522 assert_eq!(state.active_tab, 2);
2523
2524 let payload = fake_payload("/home/user/repo-a");
2526 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2527
2528 assert!(state.tabs[0].repo_info.is_some());
2530 assert!(state.tabs[1].repo_info.is_none());
2531 assert!(state.tabs[2].repo_info.is_none());
2532 assert!(state.tabs[1].repo_path.is_none());
2533 assert!(state.tabs[2].repo_path.is_none());
2534 }
2535
2536 #[test]
2537 fn repo_selected_dedup_adjusts_index_when_existing_is_after_active() {
2538 use crate::message::Message;
2539 let mut state = GitKraft::new();
2540 state.tabs.push(RepoTab::new_empty());
2543 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
2544 state.active_tab = 0;
2545
2546 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2548 "/home/user/repo-a",
2549 ))));
2550
2551 assert_eq!(state.tabs.len(), 1);
2553 assert_eq!(state.active_tab, 0);
2554 assert_eq!(
2555 state.tabs[0].repo_path.as_deref(),
2556 Some(std::path::Path::new("/home/user/repo-a"))
2557 );
2558 }
2559
2560 #[test]
2563 fn apply_payload_preserves_multi_selection_by_oid() {
2564 let mut tab = RepoTab::new_empty();
2565 tab.commits = make_test_commits(5);
2566 tab.anchor_commit_index = Some(1);
2568 tab.selected_commits = vec![1, 2, 3];
2569 tab.selected_commit = Some(3);
2570 tab.selected_commit_oid = Some(tab.commits[3].oid.clone());
2571
2572 let mut payload = fake_payload("/tmp/repo");
2574 payload.commits = make_test_commits(5);
2575
2576 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2577
2578 assert_eq!(tab.anchor_commit_index, Some(1));
2580 assert_eq!(tab.selected_commits, vec![1, 2, 3]);
2581 assert_eq!(tab.selected_commit, Some(3));
2582 }
2583
2584 #[test]
2585 fn apply_payload_preserves_anchor_even_without_range() {
2586 let mut tab = RepoTab::new_empty();
2587 tab.commits = make_test_commits(5);
2588 tab.anchor_commit_index = Some(2);
2589 tab.selected_commit = Some(2);
2590 tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2591 let mut payload = fake_payload("/tmp/repo");
2594 payload.commits = make_test_commits(5);
2595
2596 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2597
2598 assert_eq!(tab.anchor_commit_index, Some(2));
2599 assert_eq!(tab.selected_commit, Some(2));
2600 }
2601
2602 #[test]
2603 fn apply_payload_clears_selection_when_commits_disappear() {
2604 let mut tab = RepoTab::new_empty();
2605 tab.commits = make_test_commits(5);
2606 tab.anchor_commit_index = Some(2);
2607 tab.selected_commits = vec![2, 3, 4];
2608 tab.selected_commit = Some(4);
2609 tab.selected_commit_oid = Some(tab.commits[4].oid.clone());
2610
2611 let mut payload = fake_payload("/tmp/repo");
2613 payload.commits = (0..3)
2614 .map(|i| gitkraft_core::CommitInfo {
2615 oid: format!("new_oid_{i}"),
2616 summary: format!("new commit {i}"),
2617 message: String::new(),
2618 author_name: "Author".into(),
2619 author_email: "a@b.c".into(),
2620 time: Default::default(),
2621 parent_ids: Vec::new(),
2622 refs: Vec::new(),
2623 })
2624 .collect();
2625
2626 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2627
2628 assert!(tab.selected_commits.is_empty());
2630 assert!(tab.anchor_commit_index.is_none());
2631 assert!(tab.selected_commit.is_none());
2632 }
2633
2634 #[test]
2637 fn apply_payload_preserves_file_selection_when_commit_survives() {
2638 let mut tab = RepoTab::new_empty();
2639 tab.commits = make_test_commits(3);
2640 tab.selected_commit = Some(1);
2641 tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2642 tab.commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
2643
2644 tab.anchor_file_index = Some(0);
2646 tab.selected_commit_file_indices = vec![0, 1, 2];
2647 tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2648 old_file: String::new(),
2649 new_file: "a.rs".into(),
2650 status: gitkraft_core::FileStatus::Modified,
2651 hunks: vec![],
2652 }];
2653
2654 let mut payload = fake_payload("/tmp/repo");
2656 payload.commits = make_test_commits(3);
2657
2658 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2659
2660 assert_eq!(tab.anchor_file_index, Some(0));
2662 assert_eq!(tab.selected_commit_file_indices, vec![0, 1, 2]);
2663 assert!(
2664 !tab.multi_file_diffs.is_empty(),
2665 "multi_file_diffs must survive when commit is preserved"
2666 );
2667 }
2668
2669 #[test]
2670 fn apply_payload_clears_file_selection_when_commit_disappears() {
2671 let mut tab = RepoTab::new_empty();
2672 tab.commits = make_test_commits(3);
2673 tab.selected_commit = Some(1);
2674 tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2675 tab.commit_files = make_commit_files(&["a.rs", "b.rs"]);
2676 tab.anchor_file_index = Some(0);
2677 tab.selected_commit_file_indices = vec![0, 1];
2678 tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2679 old_file: String::new(),
2680 new_file: "a.rs".into(),
2681 status: gitkraft_core::FileStatus::Modified,
2682 hunks: vec![],
2683 }];
2684
2685 let mut payload = fake_payload("/tmp/repo");
2687 payload.commits = (0..2)
2688 .map(|i| gitkraft_core::CommitInfo {
2689 oid: format!("new_{i}"),
2690 summary: String::new(),
2691 message: String::new(),
2692 author_name: String::new(),
2693 author_email: String::new(),
2694 time: Default::default(),
2695 parent_ids: Vec::new(),
2696 refs: Vec::new(),
2697 })
2698 .collect();
2699
2700 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2701
2702 assert!(tab.anchor_file_index.is_none());
2704 assert!(tab.selected_commit_file_indices.is_empty());
2705 assert!(tab.multi_file_diffs.is_empty());
2706 assert!(tab.commit_files.is_empty());
2707 }
2708
2709 #[test]
2710 fn apply_payload_preserves_commit_range_diffs_when_commit_survives() {
2711 let mut tab = RepoTab::new_empty();
2712 tab.commits = make_test_commits(5);
2713 tab.selected_commit = Some(2);
2714 tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2715 tab.commit_range_diffs = vec![gitkraft_core::DiffInfo {
2716 old_file: String::new(),
2717 new_file: "x.rs".into(),
2718 status: gitkraft_core::FileStatus::Modified,
2719 hunks: vec![],
2720 }];
2721
2722 let mut payload = fake_payload("/tmp/repo");
2723 payload.commits = make_test_commits(5);
2724
2725 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2726
2727 assert!(
2728 !tab.commit_range_diffs.is_empty(),
2729 "commit_range_diffs must survive when commit is preserved"
2730 );
2731 }
2732
2733 #[test]
2734 fn apply_payload_preserves_commit_message() {
2735 let mut tab = RepoTab::new_empty();
2738 tab.repo_path = Some(std::path::PathBuf::from("/tmp/repo"));
2739 tab.commit_message = "WIP: fixing the bug".to_string();
2740
2741 let payload = fake_payload("/tmp/repo");
2742 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2743
2744 assert_eq!(
2745 tab.commit_message, "WIP: fixing the bug",
2746 "commit_message must survive background repo refreshes"
2747 );
2748 }
2749
2750 #[test]
2753 fn preview_file_loaded_sets_preview_editor() {
2754 use crate::message::Message;
2755 let mut state = GitKraft::new();
2756 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2757
2758 let content = "line 1\nline 2\nline 3".to_string();
2759 let _ = state.update(Message::FilePreviewLoaded(Ok((
2760 "test.rs".to_string(),
2761 content,
2762 ))));
2763
2764 assert!(state.preview_editor.is_some());
2765 assert_eq!(state.preview_path.as_deref(), Some("test.rs"));
2766 assert!(state.active_tab().selected_diff.is_none());
2768 }
2769
2770 #[test]
2771 fn preview_file_loaded_error_sets_error_message() {
2772 use crate::message::Message;
2773 let mut state = GitKraft::new();
2774 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2775
2776 let _ = state.update(Message::FilePreviewLoaded(Err("No such file".to_string())));
2777
2778 assert!(state.active_tab().error_message.is_some());
2779 assert!(state
2780 .active_tab()
2781 .error_message
2782 .as_ref()
2783 .unwrap()
2784 .contains("No such file"));
2785 }
2786
2787 #[test]
2788 fn close_preview_clears_editor_state() {
2789 use crate::message::Message;
2790 let mut state = GitKraft::new();
2791 state.preview_editor = Some(iced::widget::text_editor::Content::with_text("hello"));
2792 state.preview_path = Some("/test/file.txt".to_string());
2793
2794 let _ = state.update(Message::ClosePreview);
2795
2796 assert!(state.preview_editor.is_none());
2797 assert!(state.preview_path.is_none());
2798 }
2799
2800 #[test]
2803 fn close_repo_single_tab_shows_welcome_screen() {
2804 use crate::message::Message;
2808 let mut state = GitKraft::new();
2809 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2810 assert_eq!(state.tabs.len(), 1);
2811
2812 let _ = state.update(Message::CloseRepo);
2813
2814 assert_eq!(state.tabs.len(), 1, "last tab must not be removed");
2815 assert_eq!(state.active_tab, 0);
2816 assert!(!state.active_tab().has_repo(), "tab should be empty");
2817 }
2818
2819 #[test]
2820 fn close_repo_with_multiple_tabs_removes_current_tab() {
2821 use crate::message::Message;
2832 let mut state = GitKraft::new();
2833 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2835 state.tabs.push(RepoTab::new_empty());
2837 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2838 state.active_tab = 0;
2840
2841 let _ = state.update(Message::CloseRepo);
2842
2843 assert_eq!(state.tabs.len(), 1, "current tab must be removed");
2846 assert_eq!(state.active_tab, 0);
2847 assert_eq!(
2848 state.active_tab().repo_path.as_deref(),
2849 Some(std::path::Path::new("/home/user/repo-b")),
2850 "repo-b should be the surviving tab"
2851 );
2852 }
2853
2854 #[test]
2855 fn close_repo_second_tab_active_removes_it_and_keeps_first() {
2856 use crate::message::Message;
2859 let mut state = GitKraft::new();
2860 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2862 state.tabs.push(RepoTab::new_empty());
2864 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2865 state.active_tab = 1; let _ = state.update(Message::CloseRepo);
2868
2869 assert_eq!(state.tabs.len(), 1);
2870 assert_eq!(state.active_tab, 0);
2871 assert_eq!(
2872 state.active_tab().repo_path.as_deref(),
2873 Some(std::path::Path::new("/home/user/repo-a")),
2874 "repo-a should be the surviving tab"
2875 );
2876 }
2877
2878 #[test]
2881 fn close_tab_active_tab_first_position() {
2882 use crate::message::Message;
2884 let mut state = GitKraft::new();
2885 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2886 state.tabs.push(RepoTab::new_empty());
2887 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2888 state.active_tab = 0;
2889
2890 let _ = state.update(Message::CloseTab(0));
2891
2892 assert_eq!(state.tabs.len(), 1);
2893 assert_eq!(state.active_tab, 0);
2894 assert_eq!(
2895 state.active_tab().repo_path.as_deref(),
2896 Some(std::path::Path::new("/home/user/repo-b"))
2897 );
2898 }
2899
2900 #[test]
2901 fn close_tab_active_tab_second_position() {
2902 use crate::message::Message;
2904 let mut state = GitKraft::new();
2905 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2906 state.tabs.push(RepoTab::new_empty());
2907 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2908 state.active_tab = 1;
2909
2910 let _ = state.update(Message::CloseTab(1));
2911
2912 assert_eq!(state.tabs.len(), 1);
2913 assert_eq!(state.active_tab, 0);
2914 assert_eq!(
2915 state.active_tab().repo_path.as_deref(),
2916 Some(std::path::Path::new("/home/user/repo-a"))
2917 );
2918 }
2919
2920 #[test]
2921 fn close_tab_non_active_tab_before_active() {
2922 use crate::message::Message;
2924 let mut state = GitKraft::new();
2925 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2926 state.tabs.push(RepoTab::new_empty());
2927 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2928 state.active_tab = 1;
2929
2930 let _ = state.update(Message::CloseTab(0));
2931
2932 assert_eq!(state.tabs.len(), 1);
2933 assert_eq!(state.active_tab, 0, "active_tab must shift down by 1");
2934 assert_eq!(
2935 state.active_tab().repo_path.as_deref(),
2936 Some(std::path::Path::new("/home/user/repo-b")),
2937 "repo-b must remain active"
2938 );
2939 }
2940
2941 #[test]
2942 fn close_tab_non_active_tab_after_active() {
2943 use crate::message::Message;
2945 let mut state = GitKraft::new();
2946 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2947 state.tabs.push(RepoTab::new_empty());
2948 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2949 state.active_tab = 0;
2950
2951 let _ = state.update(Message::CloseTab(1));
2952
2953 assert_eq!(state.tabs.len(), 1);
2954 assert_eq!(state.active_tab, 0, "active_tab must remain unchanged");
2955 assert_eq!(
2956 state.active_tab().repo_path.as_deref(),
2957 Some(std::path::Path::new("/home/user/repo-a")),
2958 "repo-a must remain active"
2959 );
2960 }
2961
2962 #[test]
2963 fn close_tab_last_remaining_tab_is_noop() {
2964 use crate::message::Message;
2966 let mut state = GitKraft::new();
2967 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2968 assert_eq!(state.tabs.len(), 1);
2969
2970 let _ = state.update(Message::CloseTab(0));
2971
2972 assert_eq!(state.tabs.len(), 1, "last tab must not be removed");
2973 assert!(
2974 state.active_tab().has_repo(),
2975 "tab content must be unchanged"
2976 );
2977 }
2978
2979 #[test]
2980 fn close_tab_out_of_bounds_index_is_noop() {
2981 use crate::message::Message;
2983 let mut state = GitKraft::new();
2984 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2985 state.tabs.push(RepoTab::new_empty());
2986 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2987 assert_eq!(state.tabs.len(), 2);
2988
2989 let _ = state.update(Message::CloseTab(99));
2990
2991 assert_eq!(state.tabs.len(), 2, "no tab should be removed");
2992 }
2993
2994 #[test]
2997 fn close_tab_three_tabs_close_first_active() {
2998 use crate::message::Message;
3001 let mut state = GitKraft::new();
3002 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3003 state.tabs.push(RepoTab::new_empty());
3004 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3005 state.tabs.push(RepoTab::new_empty());
3006 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3007 state.active_tab = 0;
3008
3009 let _ = state.update(Message::CloseTab(0));
3010
3011 assert_eq!(state.tabs.len(), 2);
3012 assert_eq!(state.active_tab, 0);
3013 assert_eq!(
3014 state.active_tab().repo_path.as_deref(),
3015 Some(std::path::Path::new("/home/user/repo-b"))
3016 );
3017 }
3018
3019 #[test]
3020 fn close_tab_three_tabs_close_active_middle() {
3021 use crate::message::Message;
3024 let mut state = GitKraft::new();
3025 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3026 state.tabs.push(RepoTab::new_empty());
3027 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3028 state.tabs.push(RepoTab::new_empty());
3029 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3030 state.active_tab = 1;
3031
3032 let _ = state.update(Message::CloseTab(1));
3033
3034 assert_eq!(state.tabs.len(), 2);
3035 assert_eq!(
3036 state.active_tab, 1,
3037 "active_tab must stay at 1 (repo-c slides in)"
3038 );
3039 assert_eq!(
3040 state.active_tab().repo_path.as_deref(),
3041 Some(std::path::Path::new("/home/user/repo-c"))
3042 );
3043 }
3044
3045 #[test]
3046 fn close_tab_three_tabs_close_active_last() {
3047 use crate::message::Message;
3050 let mut state = GitKraft::new();
3051 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3052 state.tabs.push(RepoTab::new_empty());
3053 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3054 state.tabs.push(RepoTab::new_empty());
3055 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3056 state.active_tab = 2;
3057
3058 let _ = state.update(Message::CloseTab(2));
3059
3060 assert_eq!(state.tabs.len(), 2);
3061 assert_eq!(
3062 state.active_tab, 1,
3063 "active_tab must be clamped to last index"
3064 );
3065 assert_eq!(
3066 state.active_tab().repo_path.as_deref(),
3067 Some(std::path::Path::new("/home/user/repo-b"))
3068 );
3069 }
3070
3071 #[test]
3072 fn close_tab_three_tabs_close_first_when_middle_active() {
3073 use crate::message::Message;
3076 let mut state = GitKraft::new();
3077 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3078 state.tabs.push(RepoTab::new_empty());
3079 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3080 state.tabs.push(RepoTab::new_empty());
3081 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3082 state.active_tab = 1;
3083
3084 let _ = state.update(Message::CloseTab(0));
3085
3086 assert_eq!(state.tabs.len(), 2);
3087 assert_eq!(
3088 state.active_tab, 0,
3089 "active_tab must decrement when a tab before it is removed"
3090 );
3091 assert_eq!(
3092 state.active_tab().repo_path.as_deref(),
3093 Some(std::path::Path::new("/home/user/repo-b")),
3094 "repo-b must remain active"
3095 );
3096 }
3097
3098 #[test]
3099 fn close_tab_three_tabs_close_last_when_middle_active() {
3100 use crate::message::Message;
3103 let mut state = GitKraft::new();
3104 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3105 state.tabs.push(RepoTab::new_empty());
3106 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3107 state.tabs.push(RepoTab::new_empty());
3108 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3109 state.active_tab = 1;
3110
3111 let _ = state.update(Message::CloseTab(2));
3112
3113 assert_eq!(state.tabs.len(), 2);
3114 assert_eq!(
3115 state.active_tab, 1,
3116 "active_tab must not change when a tab after it is removed"
3117 );
3118 assert_eq!(
3119 state.active_tab().repo_path.as_deref(),
3120 Some(std::path::Path::new("/home/user/repo-b")),
3121 "repo-b must remain active"
3122 );
3123 }
3124
3125 #[test]
3128 fn close_repo_three_tabs_active_in_middle() {
3129 use crate::message::Message;
3132 let mut state = GitKraft::new();
3133 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3134 state.tabs.push(RepoTab::new_empty());
3135 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3136 state.tabs.push(RepoTab::new_empty());
3137 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3138 state.active_tab = 1;
3139
3140 let _ = state.update(Message::CloseRepo);
3141
3142 assert_eq!(state.tabs.len(), 2);
3143 assert_eq!(state.active_tab, 1);
3144 assert_eq!(
3145 state.active_tab().repo_path.as_deref(),
3146 Some(std::path::Path::new("/home/user/repo-c"))
3147 );
3148 }
3149
3150 #[test]
3151 fn close_repo_three_tabs_active_at_end() {
3152 use crate::message::Message;
3155 let mut state = GitKraft::new();
3156 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3157 state.tabs.push(RepoTab::new_empty());
3158 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3159 state.tabs.push(RepoTab::new_empty());
3160 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3161 state.active_tab = 2;
3162
3163 let _ = state.update(Message::CloseRepo);
3164
3165 assert_eq!(state.tabs.len(), 2);
3166 assert_eq!(state.active_tab, 1, "active_tab clamped to last index");
3167 assert_eq!(
3168 state.active_tab().repo_path.as_deref(),
3169 Some(std::path::Path::new("/home/user/repo-b"))
3170 );
3171 }
3172
3173 #[test]
3176 fn new_tab_appends_empty_tab_and_makes_it_active() {
3177 use crate::message::Message;
3178 let mut state = GitKraft::new();
3179 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3180 assert_eq!(state.tabs.len(), 1);
3181
3182 let _ = state.update(Message::NewTab);
3183
3184 assert_eq!(state.tabs.len(), 2, "a new empty tab must be appended");
3185 assert_eq!(state.active_tab, 1, "new tab must become active");
3186 assert!(!state.active_tab().has_repo(), "new tab must be empty");
3187 assert!(state.tabs[0].has_repo());
3189 }
3190
3191 #[test]
3192 fn new_tab_with_multiple_existing_tabs() {
3193 use crate::message::Message;
3194 let mut state = GitKraft::new();
3195 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3196 state.tabs.push(RepoTab::new_empty());
3197 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3198 state.active_tab = 0;
3199
3200 let _ = state.update(Message::NewTab);
3201
3202 assert_eq!(state.tabs.len(), 3);
3203 assert_eq!(state.active_tab, 2);
3204 assert!(!state.active_tab().has_repo());
3205 }
3206
3207 #[test]
3210 fn switch_tab_to_valid_index() {
3211 use crate::message::Message;
3212 let mut state = GitKraft::new();
3213 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3214 state.tabs.push(RepoTab::new_empty());
3215 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3216 state.active_tab = 0;
3217
3218 let _ = state.update(Message::SwitchTab(1));
3219
3220 assert_eq!(state.active_tab, 1);
3221 assert_eq!(
3222 state.active_tab().repo_path.as_deref(),
3223 Some(std::path::Path::new("/home/user/repo-b"))
3224 );
3225 }
3226
3227 #[test]
3228 fn switch_tab_to_same_index_is_noop() {
3229 use crate::message::Message;
3230 let mut state = GitKraft::new();
3231 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3232 state.tabs.push(RepoTab::new_empty());
3233 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3234 state.active_tab = 1;
3235
3236 let _ = state.update(Message::SwitchTab(1));
3237
3238 assert_eq!(state.active_tab, 1, "should remain on tab 1");
3239 }
3240
3241 #[test]
3242 fn switch_tab_out_of_bounds_is_noop() {
3243 use crate::message::Message;
3245 let mut state = GitKraft::new();
3246 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3247 state.tabs.push(RepoTab::new_empty());
3248 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3249 state.active_tab = 0;
3250
3251 let _ = state.update(Message::SwitchTab(99));
3252
3253 assert_eq!(state.active_tab, 0, "out-of-bounds switch must be ignored");
3254 }
3255
3256 #[test]
3259 fn session_state_matches_open_tab_paths_when_no_empty_tabs() {
3260 let mut state = GitKraft::new();
3261 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3262 state.tabs.push(RepoTab::new_empty());
3263 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3264 state.active_tab = 1;
3265
3266 let (paths, active) = state.session_state();
3267 assert_eq!(paths.len(), 2);
3268 assert_eq!(active, 1);
3269 }
3270
3271 #[test]
3272 fn session_state_adjusts_index_when_empty_tab_before_active() {
3273 let mut state = GitKraft::new();
3276 state.tabs.push(RepoTab::new_empty());
3278 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
3279 state.tabs.push(RepoTab::new_empty());
3280 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-b");
3281 state.active_tab = 2;
3282
3283 let (paths, active) = state.session_state();
3284 assert_eq!(paths.len(), 2);
3285 assert_eq!(active, 1, "session index must skip the empty tab at 0");
3286 }
3287
3288 #[test]
3289 fn session_state_defaults_to_zero_when_active_is_empty_tab() {
3290 let mut state = GitKraft::new();
3291 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3292 state.tabs.push(RepoTab::new_empty()); state.active_tab = 1; let (paths, active) = state.session_state();
3296 assert_eq!(paths.len(), 1);
3297 assert_eq!(active, 0, "empty active tab should default to 0");
3298 }
3299
3300 #[test]
3303 fn repo_selected_sets_repo_path_before_load() {
3304 use crate::message::Message;
3308 let mut state = GitKraft::new();
3309 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3310 state.tabs.push(RepoTab::new_empty());
3312 state.active_tab = 1;
3313
3314 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
3315 "/home/user/repo-b",
3316 ))));
3317
3318 assert_eq!(
3320 state.tabs[1].repo_path.as_deref(),
3321 Some(std::path::Path::new("/home/user/repo-b")),
3322 "repo_path must be set before async load starts"
3323 );
3324 }
3325
3326 #[test]
3327 fn open_recent_repo_sets_repo_path_before_load() {
3328 use crate::message::Message;
3329 let mut state = GitKraft::new();
3330 assert!(!state.active_tab().has_repo());
3332
3333 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
3334 "/home/user/repo-a",
3335 )));
3336
3337 assert_eq!(
3338 state.active_tab().repo_path.as_deref(),
3339 Some(std::path::Path::new("/home/user/repo-a")),
3340 "repo_path must be set before async load starts"
3341 );
3342 }
3343
3344 #[test]
3345 fn handle_repo_loaded_targets_correct_tab_after_switch() {
3346 use crate::message::Message;
3350 let mut state = GitKraft::new();
3351 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3353 state.tabs.push(RepoTab::new_empty());
3355 state.tabs[1].repo_path = Some(std::path::PathBuf::from("/home/user/repo-b"));
3356 state.tabs[1].is_loading = true;
3357
3358 state.active_tab = 0;
3360
3361 let payload = fake_payload("/home/user/repo-b");
3363 let _ = state.update(Message::RepoOpened(Ok(payload)));
3364
3365 assert!(
3367 state.tabs[1].repo_info.is_some(),
3368 "repo-b payload must be written to tab 1"
3369 );
3370 assert_eq!(
3371 state.tabs[1].repo_path.as_deref(),
3372 Some(std::path::Path::new("/home/user/repo-b")),
3373 );
3374 assert_eq!(
3376 state.tabs[0].repo_path.as_deref(),
3377 Some(std::path::Path::new("/home/user/repo-a")),
3378 );
3379 }
3380
3381 #[test]
3384 fn apply_payload_does_not_reset_is_loading_more_commits() {
3385 use crate::message::Message;
3390 let mut state = GitKraft::new();
3391 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3392
3393 state.active_tab_mut().is_loading_more_commits = true;
3395
3396 let payload = fake_payload("/home/user/repo-a");
3398 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3399
3400 assert!(
3402 state.active_tab().is_loading_more_commits,
3403 "refresh must not reset is_loading_more_commits"
3404 );
3405 }
3406
3407 #[test]
3408 fn apply_payload_resets_is_loading_more_commits_for_new_open() {
3409 let mut state = GitKraft::new();
3414 assert!(!state.active_tab().is_loading_more_commits);
3415
3416 let payload = fake_payload("/home/user/repo-a");
3417 let _ = state.update(crate::message::Message::RepoOpened(Ok(payload)));
3418
3419 assert!(!state.active_tab().is_loading_more_commits);
3420 }
3421
3422 #[test]
3423 fn refresh_does_not_shrink_commit_list() {
3424 use crate::message::Message;
3427 let mut state = GitKraft::new();
3428 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3429
3430 let tab = state.active_tab_mut();
3432 tab.commits = make_test_commits(500);
3433 for i in 0..200 {
3434 let mut c = tab.commits[0].clone();
3435 c.oid = format!("extra_{i:04}");
3436 tab.commits.push(c);
3437 }
3438 let extended_len = tab.commits.len();
3439 tab.has_more_commits = true;
3440
3441 let mut payload = fake_payload("/home/user/repo-a");
3443 payload.commits = make_test_commits(500);
3444 payload.graph_rows = gitkraft_core::features::graph::build_graph(&payload.commits);
3445 let payload_len = payload.commits.len();
3446 assert!(
3447 payload_len < extended_len,
3448 "payload must be shorter for this test"
3449 );
3450
3451 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3452
3453 assert_eq!(
3454 state.active_tab().commits.len(),
3455 extended_len,
3456 "refresh must not shrink the commit list"
3457 );
3458 }
3459
3460 #[test]
3461 fn refresh_replaces_commits_when_count_equal_or_greater() {
3462 use crate::message::Message;
3465 let mut state = GitKraft::new();
3466 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3467 state.active_tab_mut().commits = make_test_commits(5);
3468
3469 let original_oid = state.active_tab().commits[0].oid.clone();
3470
3471 let mut payload = fake_payload("/home/user/repo-a");
3473 let mut new_commits = make_test_commits(5);
3474 new_commits[0].oid = "refreshed_oid_0000".to_string();
3475 payload.commits = new_commits;
3476 payload.graph_rows = gitkraft_core::features::graph::build_graph(&payload.commits);
3477 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3478
3479 assert_eq!(
3480 state.active_tab().commits[0].oid,
3481 "refreshed_oid_0000",
3482 "refresh with same count must replace commits"
3483 );
3484 assert_ne!(state.active_tab().commits[0].oid, original_oid);
3485 }
3486
3487 #[test]
3488 fn refresh_rebuilds_commit_display_when_content_changes_at_same_count() {
3489 use crate::message::Message;
3494 let mut state = GitKraft::new();
3495 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3496 let mut initial_commits = make_test_commits(5);
3497 initial_commits[0].author_name = "Alice".to_string();
3498 state.active_tab_mut().commits = initial_commits;
3499 state.active_tab_mut().commit_display = state
3501 .active_tab()
3502 .commits
3503 .iter()
3504 .map(|c| ("1 day ago".to_string(), c.author_name.clone()))
3505 .collect();
3506 assert_eq!(state.active_tab().commit_display[0].1, "Alice");
3507
3508 let mut payload = fake_payload("/home/user/repo-a");
3510 let mut new_commits = make_test_commits(5);
3511 new_commits[0].author_name = "Bob".to_string();
3512 payload.commits = new_commits;
3513 payload.graph_rows = gitkraft_core::features::graph::build_graph(&payload.commits);
3514 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3515
3516 assert_eq!(
3518 state.active_tab().commit_display[0].1,
3519 "Bob",
3520 "commit_display must be rebuilt when content changes at same count"
3521 );
3522 }
3523
3524 #[test]
3525 fn duplicate_more_commits_loaded_sets_has_more_false() {
3526 use crate::message::{CommitPage, Message};
3532 let mut state = GitKraft::new();
3533 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3534 let tab = state.active_tab_mut();
3535 tab.commits = make_test_commits(10);
3536 tab.has_more_commits = true;
3537
3538 let _initial_count = tab.commits.len();
3540 let mut bigger_commits = tab.commits.clone();
3541 for i in 0..5 {
3542 let mut c = bigger_commits[0].clone();
3543 c.oid = format!("new_{i:04}");
3544 bigger_commits.push(c);
3545 }
3546 let bigger_count = bigger_commits.len();
3547 let graph_rows = gitkraft_core::features::graph::build_graph(&bigger_commits);
3548 let _ = state.update(Message::MoreCommitsLoaded(Ok(CommitPage {
3549 commits: bigger_commits.clone(),
3550 graph_rows: graph_rows.clone(),
3551 })));
3552 assert!(
3553 state.active_tab().has_more_commits,
3554 "first result should keep has_more true"
3555 );
3556 assert_eq!(state.active_tab().commits.len(), bigger_count);
3557
3558 let _ = state.update(Message::MoreCommitsLoaded(Ok(CommitPage {
3560 commits: bigger_commits,
3561 graph_rows,
3562 })));
3563 assert!(
3564 !state.active_tab().has_more_commits,
3565 "identical second result must set has_more_commits false"
3566 );
3567 }
3568
3569 #[test]
3570 fn file_system_changed_allowed_during_pagination() {
3571 use crate::message::Message;
3576 let mut state = GitKraft::new();
3577 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3578 state.active_tab_mut().is_loading_more_commits = true;
3579
3580 let _task = state.update(Message::FileSystemChanged);
3581
3582 assert!(state.active_tab().is_loading_more_commits);
3584 }
3587
3588 #[test]
3589 fn has_more_commits_preserved_across_refresh() {
3590 use crate::message::Message;
3592 let mut state = GitKraft::new();
3593 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3594 state.active_tab_mut().has_more_commits = true;
3595
3596 let payload = fake_payload("/home/user/repo-a");
3597 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3598
3599 assert!(
3600 state.active_tab().has_more_commits,
3601 "refresh must preserve has_more_commits"
3602 );
3603 }
3604
3605 #[test]
3606 fn switch_to_existing_tab_removes_empty_tab() {
3607 let mut state = GitKraft::new();
3608 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3610 state.tabs.push(RepoTab::new_empty());
3612 state.active_tab = 1;
3613
3614 let found = state.switch_to_existing_tab(std::path::Path::new("/home/user/repo-a"));
3615
3616 assert!(found, "should find existing tab");
3617 assert_eq!(state.tabs.len(), 1, "empty tab should be removed");
3618 assert_eq!(state.active_tab, 0);
3619 assert_eq!(
3620 state.active_tab().repo_path.as_deref(),
3621 Some(std::path::Path::new("/home/user/repo-a")),
3622 );
3623 }
3624
3625 #[test]
3626 fn switch_to_existing_tab_returns_false_when_not_found() {
3627 let mut state = GitKraft::new();
3628 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3629
3630 let found = state.switch_to_existing_tab(std::path::Path::new("/home/user/repo-b"));
3631
3632 assert!(!found, "should not find nonexistent tab");
3633 assert_eq!(state.tabs.len(), 1);
3634 }
3635
3636 #[test]
3637 fn switch_to_existing_tab_keeps_loaded_current_tab() {
3638 let mut state = GitKraft::new();
3639 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3641 state.tabs.push(RepoTab::new_empty());
3643 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3644 state.active_tab = 1;
3645
3646 let found = state.switch_to_existing_tab(std::path::Path::new("/home/user/repo-a"));
3647
3648 assert!(found);
3649 assert_eq!(state.tabs.len(), 2, "loaded tab should not be removed");
3650 assert_eq!(state.active_tab, 0);
3651 }
3652
3653 #[test]
3654 fn default_remote_returns_first_remote() {
3655 let mut tab = RepoTab::new_empty();
3656 tab.remotes = vec![
3657 gitkraft_core::RemoteInfo {
3658 name: "upstream".to_string(),
3659 url: Some("https://example.com/upstream.git".to_string()),
3660 fetch_refspecs: vec![],
3661 },
3662 gitkraft_core::RemoteInfo {
3663 name: "origin".to_string(),
3664 url: Some("https://example.com/origin.git".to_string()),
3665 fetch_refspecs: vec![],
3666 },
3667 ];
3668 assert_eq!(tab.default_remote(), "upstream");
3669 }
3670
3671 #[test]
3672 fn default_remote_fallback_to_origin() {
3673 let tab = RepoTab::new_empty();
3674 assert_eq!(tab.default_remote(), "origin");
3675 }
3676
3677 #[test]
3678 fn hovered_commit_defaults_to_none() {
3679 let tab = RepoTab::new_empty();
3680 assert_eq!(tab.hovered_commit, None);
3681 }
3682
3683 #[test]
3684 fn hover_commit_message_sets_state() {
3685 use crate::message::Message;
3686 let mut state = GitKraft::new();
3687 let _task = state.update(Message::HoverCommit(Some(5)));
3688 assert_eq!(state.active_tab().hovered_commit, Some(5));
3689 let _task = state.update(Message::HoverCommit(None));
3690 assert_eq!(state.active_tab().hovered_commit, None);
3691 }
3692
3693 #[test]
3694 fn branch_context_defaults_empty() {
3695 let tab = RepoTab::new_empty();
3696 assert!(tab.branch_context.is_empty());
3697 }
3698
3699 #[test]
3700 fn ghost_shows_on_selected_commit_without_refs() {
3701 let mut state = GitKraft::new();
3705 let tab = state.active_tab_mut();
3706 tab.repo_path = Some(std::path::PathBuf::from("/tmp/test"));
3707 tab.commits = vec![
3708 gitkraft_core::CommitInfo {
3709 oid: "aaa".into(),
3710 summary: "tip".into(),
3711 message: String::new(),
3712 author_name: "A".into(),
3713 author_email: String::new(),
3714 time: Default::default(),
3715 parent_ids: Vec::new(),
3716 refs: vec![gitkraft_core::RefLabel {
3717 name: "main".to_string(),
3718 kind: gitkraft_core::RefKind::Head,
3719 }],
3720 },
3721 gitkraft_core::CommitInfo {
3722 oid: "bbb".into(),
3723 summary: "child".into(),
3724 message: String::new(),
3725 author_name: "A".into(),
3726 author_email: String::new(),
3727 time: Default::default(),
3728 parent_ids: Vec::new(),
3729 refs: Vec::new(),
3730 },
3731 ];
3732 tab.graph_rows = vec![
3733 gitkraft_core::GraphRow {
3734 width: 1,
3735 node_column: 0,
3736 node_color: 0,
3737 edges: Vec::new(),
3738 },
3739 gitkraft_core::GraphRow {
3740 width: 1,
3741 node_column: 0,
3742 node_color: 0,
3743 edges: Vec::new(),
3744 },
3745 ];
3746 tab.branch_context = vec![None, Some("main".to_string())];
3747 tab.selected_commit = Some(1);
3748
3749 let tab = state.active_tab();
3751 assert_eq!(tab.selected_commit, Some(1));
3752 assert_eq!(tab.branch_context[1], Some("main".to_string()));
3753 assert_eq!(tab.branch_context[0], None);
3755 }
3756
3757 #[test]
3758 fn theme_choice_system_follows_and_preset_stops() {
3759 let mut state = GitKraft::new();
3760 assert!(state.apply_theme_choice(gitkraft_core::THEME_COUNT));
3761 assert!(state.follow_system);
3762 assert_eq!(state.persisted_theme_name(), "System");
3763 assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
3764 let nord = gitkraft_core::theme_index_by_name("Nord");
3765 assert!(state.apply_theme_choice(nord));
3766 assert!(!state.follow_system);
3767 assert_eq!(state.persisted_theme_name(), "Nord");
3768 assert!(!state.apply_theme_choice(gitkraft_core::THEME_COUNT + 5));
3769 assert_eq!(state.current_theme_index, nord);
3770 }
3771
3772 #[test]
3773 fn refresh_system_theme_only_when_following() {
3774 let mut state = GitKraft::new();
3775 state.follow_system = false;
3776 state.current_theme_index = 9;
3777 state.refresh_system_theme();
3778 assert_eq!(state.current_theme_index, 9);
3779 state.follow_system = true;
3780 state.refresh_system_theme();
3781 assert_eq!(
3782 state.current_theme_index,
3783 gitkraft_core::current_system_theme_index()
3784 );
3785 }
3786}