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
566 pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
569
570 pub show_about: bool,
573
574 pub search_visible: bool,
577 pub search_query: String,
579 pub search_results: Vec<gitkraft_core::CommitInfo>,
581 pub search_selected: Option<usize>,
583
584 pub search_diff_files: Vec<gitkraft_core::DiffFileEntry>,
586 pub search_diff_selected: HashSet<usize>,
588 pub search_diff_content: Vec<gitkraft_core::DiffInfo>,
590 pub search_diff_oid: Option<String>,
592
593 pub editor: gitkraft_core::Editor,
595
596 pub keyboard_modifiers: iced::keyboard::Modifiers,
598
599 pub animation_tick: u64,
602
603 pub window_width: f32,
606 pub window_height: f32,
608 pub window_x: f32,
610 pub window_y: f32,
612
613 pub preview_editor: Option<iced::widget::text_editor::Content>,
615 pub preview_path: Option<String>,
617}
618
619impl Default for GitKraft {
620 fn default() -> Self {
621 Self::new()
622 }
623}
624
625impl GitKraft {
626 fn from_settings(settings: gitkraft_core::AppSettings) -> Self {
632 let current_theme_index = settings
633 .theme_name
634 .as_deref()
635 .map(gitkraft_core::theme_index_by_name)
636 .unwrap_or(0);
637
638 let recent_repos = settings.recent_repos;
639
640 let (
641 sidebar_width,
642 commit_log_width,
643 staging_height,
644 diff_file_list_width,
645 sidebar_expanded,
646 ui_scale,
647 commit_ref_width,
648 commit_graph_width,
649 ) = if let Some(ref layout) = settings.layout {
650 (
651 layout.sidebar_width.unwrap_or(220.0),
652 layout.commit_log_width.unwrap_or(500.0),
653 layout.staging_height.unwrap_or(200.0),
654 layout.diff_file_list_width.unwrap_or(180.0),
655 layout.sidebar_expanded.unwrap_or(true),
656 layout.ui_scale.unwrap_or(1.0),
657 layout.commit_ref_width.unwrap_or(120.0),
658 layout.commit_graph_width.unwrap_or(80.0),
659 )
660 } else {
661 (220.0, 500.0, 200.0, 180.0, true, 1.0, 120.0, 80.0)
662 };
663
664 Self {
665 tabs: vec![RepoTab::new_empty()],
666 active_tab: 0,
667
668 sidebar_expanded,
669
670 sidebar_width,
671 commit_log_width,
672 staging_height,
673 diff_file_list_width,
674 commit_ref_width,
675 commit_graph_width,
676 sidebar_branches_ratio: 0.6,
677 sidebar_stash_ratio: 0.25,
678 staging_unstaged_ratio: 0.4,
679 staging_staged_ratio: 0.3,
680
681 ui_scale,
682
683 dragging: None,
684 dragging_h: None,
685 drag_start_x: 0.0,
686 drag_start_y: 0.0,
687 drag_initialized: false,
688 drag_initialized_h: false,
689 cursor_pos: Point::ORIGIN,
690
691 current_theme_index,
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 current_theme_name(&self) -> &'static str {
939 gitkraft_core::THEME_NAMES
940 .get(self.current_theme_index)
941 .copied()
942 .unwrap_or("Default")
943 }
944
945 pub fn refresh_active_tab(&mut self) -> Task<Message> {
949 let tab = self.active_tab();
950 if tab.is_loading {
953 return Task::none();
954 }
955 match tab.repo_path.clone() {
956 Some(path) => {
957 let depth = tab.commits.len();
958 self.active_tab_mut().is_loading = true;
963 crate::features::repo::commands::refresh_repo(path, depth)
964 }
965 None => Task::none(),
966 }
967 }
968
969 pub fn on_ok_refresh(
976 &mut self,
977 result: Result<(), String>,
978 ok_msg: &str,
979 err_prefix: &str,
980 ) -> Task<Message> {
981 match result {
982 Ok(()) => {
983 {
984 let tab = self.active_tab_mut();
985 tab.is_loading = false;
986 tab.status_message = Some(ok_msg.to_string());
987 }
988 self.refresh_active_tab()
989 }
990 Err(e) => {
991 let tab = self.active_tab_mut();
992 tab.is_loading = false;
993 tab.error_message = Some(format!("{err_prefix}: {e}"));
994 tab.status_message = None;
995 Task::none()
996 }
997 }
998 }
999
1000 pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
1002 gitkraft_core::LayoutSettings {
1003 sidebar_width: Some(self.sidebar_width),
1004 commit_log_width: Some(self.commit_log_width),
1005 staging_height: Some(self.staging_height),
1006 diff_file_list_width: Some(self.diff_file_list_width),
1007 sidebar_expanded: Some(self.sidebar_expanded),
1008 ui_scale: Some(self.ui_scale),
1009 window_width: Some(self.window_width),
1010 window_height: Some(self.window_height),
1011 window_x: Some(self.window_x),
1012 window_y: Some(self.window_y),
1013 window_maximized: None, commit_ref_width: Some(self.commit_ref_width),
1015 commit_graph_width: Some(self.commit_graph_width),
1016 }
1017 }
1018}
1019
1020fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
1022 Color::from_rgb8(rgb.r, rgb.g, rgb.b)
1023}
1024
1025fn detect_system_editor() -> gitkraft_core::Editor {
1027 for var in ["VISUAL", "EDITOR"] {
1028 if let Ok(val) = std::env::var(var) {
1029 let bin = val.split('/').next_back().unwrap_or(&val).trim();
1030 return match bin {
1031 "nvim" | "neovim" => gitkraft_core::Editor::Neovim,
1032 "vim" => gitkraft_core::Editor::Vim,
1033 "hx" | "helix" => gitkraft_core::Editor::Helix,
1034 "nano" => gitkraft_core::Editor::Nano,
1035 "micro" => gitkraft_core::Editor::Micro,
1036 "emacs" => gitkraft_core::Editor::Emacs,
1037 "code" => gitkraft_core::Editor::VSCode,
1038 "zed" => gitkraft_core::Editor::Zed,
1039 "subl" => gitkraft_core::Editor::Sublime,
1040 _ => gitkraft_core::Editor::Custom(val),
1041 };
1042 }
1043 }
1044 gitkraft_core::Editor::None
1045}
1046
1047#[cfg(test)]
1050mod tests {
1051 use super::*;
1052
1053 #[test]
1054 fn new_defaults() {
1055 let state = GitKraft::new();
1056 assert!(state.active_tab().repo_path.is_none());
1057 assert!(!state.has_repo());
1058 assert_eq!(state.repo_display_name(), "New Tab");
1059 assert!(state.active_tab().commits.is_empty());
1060 assert!(state.sidebar_expanded);
1061 assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
1063 assert!(state.sidebar_width > 0.0);
1065 assert!(state.commit_log_width > 0.0);
1066 assert!(state.staging_height > 0.0);
1067 assert!(state.dragging.is_none());
1068 assert!(state.dragging_h.is_none());
1069 assert_eq!(state.tabs.len(), 1);
1071 assert_eq!(state.active_tab, 0);
1072 }
1073
1074 #[test]
1075 fn repo_display_name_extracts_basename() {
1076 let mut state = GitKraft::new();
1077 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
1078 assert_eq!(state.repo_display_name(), "my-project");
1079 }
1080
1081 #[test]
1082 fn colors_returns_theme_colors() {
1083 let state = GitKraft::new();
1084 let c = state.colors();
1085 assert!(c.bg.r < 0.5);
1087 }
1088
1089 #[test]
1090 fn iced_theme_is_custom_with_correct_palette() {
1091 let mut state = GitKraft::new();
1092
1093 state.current_theme_index = 0;
1095 let iced_t = state.iced_theme();
1096 let pal = iced_t.palette();
1097 assert!(pal.background.r < 0.5, "Default theme bg should be dark");
1098 assert_eq!(iced_t.to_string(), "Default");
1099
1100 let sol_idx = gitkraft_core::theme_index_by_name("Solarized Light");
1102 state.current_theme_index = sol_idx;
1103 let iced_t = state.iced_theme();
1104 let pal = iced_t.palette();
1105 assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
1106 assert_eq!(iced_t.to_string(), "Solarized Light");
1107
1108 let gruvbox_idx = gitkraft_core::theme_index_by_name("Gruvbox Dark");
1110 state.current_theme_index = gruvbox_idx;
1111 let iced_t = state.iced_theme();
1112 let pal = iced_t.palette();
1113 let core = gitkraft_core::theme_by_index(gruvbox_idx);
1114 let expected_accent = rgb_to_iced(core.accent);
1115 assert!(
1116 (pal.primary.r - expected_accent.r).abs() < 0.01
1117 && (pal.primary.g - expected_accent.g).abs() < 0.01
1118 && (pal.primary.b - expected_accent.b).abs() < 0.01,
1119 "Gruvbox Dark accent should match core accent"
1120 );
1121 }
1122
1123 #[test]
1124 fn iced_theme_name_round_trips_through_core() {
1125 for i in 0..gitkraft_core::THEME_COUNT {
1128 let mut state = GitKraft::new();
1129 state.current_theme_index = i;
1130 let iced_t = state.iced_theme();
1131 let name = iced_t.to_string();
1132 let resolved = gitkraft_core::theme_index_by_name(&name);
1133 assert_eq!(
1134 resolved,
1135 i,
1136 "theme index {i} ({}) did not round-trip through iced_theme name",
1137 gitkraft_core::THEME_NAMES[i]
1138 );
1139 }
1140 }
1141
1142 #[test]
1143 fn current_theme_name_round_trips() {
1144 let mut state = GitKraft::new();
1145 state.current_theme_index = gitkraft_core::theme_index_by_name("Dracula");
1146 assert_eq!(state.current_theme_name(), "Dracula");
1147 state.current_theme_index = 0;
1148 assert_eq!(state.current_theme_name(), "Default");
1149 }
1150
1151 #[test]
1152 fn repo_tab_new_empty() {
1153 let tab = RepoTab::new_empty();
1154 assert!(tab.repo_path.is_none());
1155 assert!(!tab.has_repo());
1156 assert_eq!(tab.display_name(), "New Tab");
1157 assert!(tab.commits.is_empty());
1158 assert!(tab.branches.is_empty());
1159 assert!(!tab.is_loading);
1160 }
1161
1162 #[test]
1163 fn repo_tab_display_name_with_path() {
1164 let mut tab = RepoTab::new_empty();
1165 tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
1166 assert!(tab.has_repo());
1167 assert_eq!(tab.display_name(), "cool-repo");
1168 }
1169
1170 #[test]
1171 fn search_defaults() {
1172 let state = GitKraft::new();
1173 assert!(!state.search_visible);
1174 assert!(state.search_query.is_empty());
1175 assert!(state.search_results.is_empty());
1176 assert!(state.search_selected.is_none());
1177 }
1178
1179 #[test]
1180 fn context_menu_variants_exist() {
1181 use crate::state::ContextMenu;
1183
1184 let _branch = ContextMenu::Branch {
1185 name: "main".to_string(),
1186 is_current: true,
1187 local_index: 0,
1188 };
1189 let _remote = ContextMenu::RemoteBranch {
1190 name: "origin/main".to_string(),
1191 };
1192 let _commit = ContextMenu::Commit {
1193 index: 0,
1194 oid: "abc1234".to_string(),
1195 };
1196 let _stash = ContextMenu::Stash { index: 0 };
1197 let _unstaged = ContextMenu::UnstagedFile {
1198 path: "src/main.rs".to_string(),
1199 };
1200 let _staged = ContextMenu::StagedFile {
1201 path: "src/lib.rs".to_string(),
1202 };
1203 }
1204
1205 #[test]
1206 fn repo_tab_context_menu_defaults_to_none() {
1207 let tab = crate::state::RepoTab::new_empty();
1208 assert!(tab.context_menu.is_none());
1209 }
1210
1211 #[test]
1212 fn context_menu_variants_constructable() {
1213 use crate::state::ContextMenu;
1214 let _ = ContextMenu::Stash { index: 0 };
1215 let _ = ContextMenu::UnstagedFile {
1216 path: "a.rs".into(),
1217 };
1218 let _ = ContextMenu::StagedFile {
1219 path: "b.rs".into(),
1220 };
1221 }
1222
1223 #[test]
1224 fn selected_unstaged_defaults_empty() {
1225 let tab = crate::state::RepoTab::new_empty();
1226 assert!(tab.selected_unstaged.is_empty());
1227 assert!(tab.selected_staged.is_empty());
1228 }
1229
1230 #[test]
1231 fn selected_unstaged_toggle() {
1232 let mut tab = crate::state::RepoTab::new_empty();
1233 tab.selected_unstaged.insert("a.rs".to_string());
1234 tab.selected_unstaged.insert("b.rs".to_string());
1235 assert_eq!(tab.selected_unstaged.len(), 2);
1236 assert!(tab.selected_unstaged.contains("a.rs"));
1237 tab.selected_unstaged.remove("a.rs");
1238 assert_eq!(tab.selected_unstaged.len(), 1);
1239 assert!(!tab.selected_unstaged.contains("a.rs"));
1240 }
1241
1242 #[test]
1243 fn detect_system_editor_returns_valid() {
1244 let editor = super::detect_system_editor();
1246 let _ = editor.display_name();
1247 }
1248
1249 #[test]
1252 fn selected_commit_file_indices_defaults_to_empty_vec() {
1253 let tab = RepoTab::new_empty();
1254 assert!(tab.selected_commit_file_indices.is_empty());
1255 let v: &Vec<usize> = &tab.selected_commit_file_indices;
1257 assert_eq!(v.len(), 0);
1258 }
1259
1260 #[test]
1261 fn multi_file_diffs_defaults_empty() {
1262 let tab = RepoTab::new_empty();
1263 assert!(tab.multi_file_diffs.is_empty());
1264 }
1265
1266 #[test]
1267 fn keyboard_modifiers_default_has_no_shift() {
1268 let state = GitKraft::new();
1269 assert!(!state.keyboard_modifiers.shift());
1270 }
1271
1272 #[test]
1273 fn selected_commit_file_indices_preserves_insertion_order() {
1274 let mut tab = RepoTab::new_empty();
1275 tab.selected_commit_file_indices.push(5);
1276 tab.selected_commit_file_indices.push(2);
1277 tab.selected_commit_file_indices.push(8);
1278 assert_eq!(tab.selected_commit_file_indices, vec![5, 2, 8]);
1279 }
1280
1281 #[test]
1282 fn selected_commit_file_indices_cleared_on_reset() {
1283 let mut tab = RepoTab::new_empty();
1284 tab.selected_commit_file_indices.push(0);
1285 tab.selected_commit_file_indices.push(1);
1286 tab.selected_commit_file_indices.clear();
1287 assert!(tab.selected_commit_file_indices.is_empty());
1288 }
1289
1290 #[test]
1291 fn multi_file_diffs_cleared_on_reset() {
1292 let mut tab = RepoTab::new_empty();
1293 tab.multi_file_diffs.push(gitkraft_core::DiffInfo {
1294 old_file: String::new(),
1295 new_file: "a.rs".to_string(),
1296 status: gitkraft_core::FileStatus::Modified,
1297 hunks: vec![],
1298 });
1299 tab.multi_file_diffs.clear();
1300 assert!(tab.multi_file_diffs.is_empty());
1301 }
1302
1303 #[test]
1304 fn commit_range_diffs_defaults_empty() {
1305 let tab = RepoTab::new_empty();
1306 assert!(tab.commit_range_diffs.is_empty());
1307 }
1308
1309 #[test]
1310 fn commit_range_diffs_cleared_on_apply_payload() {
1311 let mut tab = RepoTab::new_empty();
1313 tab.commit_range_diffs.push(gitkraft_core::DiffInfo {
1314 old_file: String::new(),
1315 new_file: "x.rs".to_string(),
1316 status: gitkraft_core::FileStatus::Modified,
1317 hunks: vec![],
1318 });
1319 tab.commit_range_diffs.clear();
1320 assert!(tab.commit_range_diffs.is_empty());
1321 }
1322
1323 #[test]
1326 fn modifiers_changed_sets_shift_state() {
1327 use crate::message::Message;
1328 let mut state = GitKraft::new();
1329 assert!(!state.keyboard_modifiers.shift());
1330
1331 let _ = state.update(Message::ModifiersChanged(iced::keyboard::Modifiers::SHIFT));
1332 assert!(state.keyboard_modifiers.shift());
1333
1334 let _ = state.update(Message::ModifiersChanged(
1335 iced::keyboard::Modifiers::default(),
1336 ));
1337 assert!(!state.keyboard_modifiers.shift());
1338 }
1339
1340 fn make_commit_files(names: &[&str]) -> Vec<gitkraft_core::DiffFileEntry> {
1343 names
1344 .iter()
1345 .map(|name| gitkraft_core::DiffFileEntry {
1346 old_file: String::new(),
1347 new_file: name.to_string(),
1348 status: gitkraft_core::FileStatus::Modified,
1349 })
1350 .collect()
1351 }
1352
1353 #[test]
1354 fn select_diff_by_index_regular_click_clears_multi_selection() {
1355 use crate::message::Message;
1356 let mut state = GitKraft::new();
1357 state.active_tab_mut().repo_path =
1361 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1362 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1363 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1364 state.active_tab_mut().selected_commit_file_indices = vec![0, 1];
1366
1367 let _ = state.update(Message::SelectDiffByIndex(0));
1369
1370 assert!(state.active_tab().selected_commit_file_indices.is_empty());
1371 assert_eq!(state.active_tab().selected_file_index, Some(0));
1372 }
1373
1374 #[test]
1375 fn regular_click_preserves_multi_file_diffs_until_load_completes() {
1376 use crate::message::Message;
1377 let mut state = GitKraft::new();
1378 state.active_tab_mut().repo_path =
1379 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1380 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1381 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1382
1383 state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1385 old_file: String::new(),
1386 new_file: "a.rs".to_string(),
1387 status: gitkraft_core::FileStatus::Modified,
1388 hunks: vec![],
1389 }];
1390
1391 let _ = state.update(Message::SelectDiffByIndex(1));
1394
1395 assert!(
1396 !state.active_tab().multi_file_diffs.is_empty(),
1397 "multi_file_diffs must NOT be cleared on click (deferred to diff load)"
1398 );
1399 assert_eq!(state.active_tab().selected_file_index, Some(1));
1400 assert!(state.active_tab().is_loading_file_diff);
1401 }
1402
1403 #[test]
1404 fn regular_click_preserves_diff_scroll_until_load_completes() {
1405 use crate::message::Message;
1406 let mut state = GitKraft::new();
1407 state.active_tab_mut().repo_path =
1408 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1409 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1410 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1411 state.active_tab_mut().diff_scroll_offset = 150.0;
1412
1413 let _ = state.update(Message::SelectDiffByIndex(1));
1415
1416 assert_eq!(
1417 state.active_tab().diff_scroll_offset,
1418 150.0,
1419 "diff_scroll_offset must NOT be reset on click (deferred to diff load)"
1420 );
1421 }
1422
1423 #[test]
1424 fn single_file_diff_loaded_clears_deferred_state() {
1425 use crate::message::Message;
1426 let mut state = GitKraft::new();
1427 state.active_tab_mut().repo_path =
1428 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1429 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1430 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1431
1432 state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1434 old_file: String::new(),
1435 new_file: "a.rs".to_string(),
1436 status: gitkraft_core::FileStatus::Modified,
1437 hunks: vec![],
1438 }];
1439 state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1440 old_file: String::new(),
1441 new_file: "b.rs".to_string(),
1442 status: gitkraft_core::FileStatus::Modified,
1443 hunks: vec![],
1444 }];
1445 state.active_tab_mut().is_loading_file_diff = true;
1446 state.active_tab_mut().diff_scroll_offset = 200.0;
1447
1448 let new_diff = gitkraft_core::DiffInfo {
1450 old_file: String::new(),
1451 new_file: "b.rs".to_string(),
1452 status: gitkraft_core::FileStatus::Modified,
1453 hunks: vec![],
1454 };
1455 let _ = state.update(Message::SingleFileDiffLoaded(Ok(new_diff)));
1456
1457 assert!(
1459 state.active_tab().multi_file_diffs.is_empty(),
1460 "multi_file_diffs must be cleared when new diff arrives"
1461 );
1462 assert!(
1463 state.active_tab().commit_range_diffs.is_empty(),
1464 "commit_range_diffs must be cleared when new diff arrives"
1465 );
1466 assert!(
1467 state.active_tab().selected_diff.is_some(),
1468 "selected_diff must be set to the loaded diff"
1469 );
1470 assert!(
1471 !state.active_tab().is_loading_file_diff,
1472 "is_loading_file_diff must be cleared after load"
1473 );
1474 assert_eq!(
1475 state.active_tab().diff_scroll_offset,
1476 0.0,
1477 "diff_scroll_offset must be reset when new diff arrives"
1478 );
1479 }
1480
1481 #[test]
1482 fn regular_click_preserves_commit_range_diffs_until_load_completes() {
1483 use crate::message::Message;
1484 let mut state = GitKraft::new();
1485 state.active_tab_mut().repo_path =
1486 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1487 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1488 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1489
1490 state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1492 old_file: String::new(),
1493 new_file: "x.rs".to_string(),
1494 status: gitkraft_core::FileStatus::Modified,
1495 hunks: vec![],
1496 }];
1497
1498 let _ = state.update(Message::SelectDiffByIndex(0));
1499
1500 assert!(
1501 !state.active_tab().commit_range_diffs.is_empty(),
1502 "commit_range_diffs must NOT be cleared on click (deferred to diff load)"
1503 );
1504 }
1505
1506 #[test]
1507 fn select_diff_by_index_shift_click_adds_both_files_to_selection() {
1508 use crate::message::Message;
1509 let mut state = GitKraft::new();
1510 state.active_tab_mut().repo_path =
1511 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1512 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1513 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1514 state.active_tab_mut().selected_file_index = Some(0);
1515
1516 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1518 let _ = state.update(Message::SelectDiffByIndex(1));
1519
1520 let sel = &state.active_tab().selected_commit_file_indices;
1521 assert!(sel.contains(&0), "anchor file 0 should be selected");
1522 assert!(sel.contains(&1), "newly clicked file 1 should be selected");
1523 assert_eq!(sel.len(), 2);
1524 }
1525
1526 #[test]
1527 fn anchor_file_index_defaults_to_none() {
1528 let tab = RepoTab::new_empty();
1529 assert!(tab.anchor_file_index.is_none());
1530 }
1531
1532 #[test]
1533 fn regular_click_sets_anchor() {
1534 use crate::message::Message;
1535 let mut state = GitKraft::new();
1536 state.active_tab_mut().repo_path =
1537 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1538 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1539 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1540
1541 let _ = state.update(Message::SelectDiffByIndex(2));
1542
1543 assert_eq!(
1544 state.active_tab().anchor_file_index,
1545 Some(2),
1546 "regular click must set anchor to the clicked index"
1547 );
1548 }
1549
1550 #[test]
1551 fn shift_click_selects_range_downward_from_anchor() {
1552 use crate::message::Message;
1553 let mut state = GitKraft::new();
1554 state.active_tab_mut().repo_path =
1555 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1556 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1557 state.active_tab_mut().commit_files =
1558 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1559 state.active_tab_mut().anchor_file_index = Some(1);
1561 state.active_tab_mut().selected_file_index = Some(1);
1562
1563 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1565 let _ = state.update(Message::SelectDiffByIndex(4));
1566
1567 let sel = &state.active_tab().selected_commit_file_indices;
1568 assert_eq!(
1569 sel,
1570 &vec![1, 2, 3, 4],
1571 "range must be contiguous from anchor to click"
1572 );
1573 }
1574
1575 #[test]
1576 fn shift_click_selects_range_upward_from_anchor() {
1577 use crate::message::Message;
1578 let mut state = GitKraft::new();
1579 state.active_tab_mut().repo_path =
1580 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1581 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1582 state.active_tab_mut().commit_files =
1583 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1584 state.active_tab_mut().anchor_file_index = Some(4);
1586 state.active_tab_mut().selected_file_index = Some(4);
1587
1588 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1590 let _ = state.update(Message::SelectDiffByIndex(1));
1591
1592 let sel = &state.active_tab().selected_commit_file_indices;
1593 assert_eq!(
1594 sel,
1595 &vec![1, 2, 3, 4],
1596 "range must be stored ascending regardless of click direction"
1597 );
1598 }
1599
1600 #[test]
1601 fn shift_click_anchor_fixed_on_subsequent_clicks() {
1602 use crate::message::Message;
1603 let mut state = GitKraft::new();
1604 state.active_tab_mut().repo_path =
1605 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1606 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1607 state.active_tab_mut().commit_files =
1608 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1609 state.active_tab_mut().anchor_file_index = Some(2);
1611 state.active_tab_mut().selected_file_index = Some(2);
1612 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1613
1614 let _ = state.update(Message::SelectDiffByIndex(4));
1616 assert_eq!(
1617 state.active_tab().selected_commit_file_indices,
1618 vec![2, 3, 4]
1619 );
1620
1621 let _ = state.update(Message::SelectDiffByIndex(3));
1623 assert_eq!(
1624 state.active_tab().selected_commit_file_indices,
1625 vec![2, 3],
1626 "anchor must stay fixed; second Shift+Click shrinks the range"
1627 );
1628
1629 let _ = state.update(Message::SelectDiffByIndex(0));
1631 assert_eq!(
1632 state.active_tab().selected_commit_file_indices,
1633 vec![0, 1, 2],
1634 "anchor must stay fixed; can extend range in either direction"
1635 );
1636 }
1637
1638 #[test]
1639 fn shift_click_on_anchor_itself_gives_single_item_range() {
1640 use crate::message::Message;
1641 let mut state = GitKraft::new();
1642 state.active_tab_mut().repo_path =
1643 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1644 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1645 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1646 state.active_tab_mut().anchor_file_index = Some(1);
1647 state.active_tab_mut().selected_file_index = Some(1);
1648
1649 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1651 let _ = state.update(Message::SelectDiffByIndex(1));
1652
1653 assert_eq!(state.active_tab().selected_commit_file_indices, vec![1]);
1654 assert!(
1655 state.active_tab().multi_file_diffs.is_empty(),
1656 "single-item range must not populate multi_file_diffs"
1657 );
1658 }
1659
1660 #[test]
1661 fn shift_click_range_is_always_ascending() {
1662 use crate::message::Message;
1663 let mut state = GitKraft::new();
1664 state.active_tab_mut().repo_path =
1665 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1666 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1667 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs"]);
1668 state.active_tab_mut().anchor_file_index = Some(3);
1669 state.active_tab_mut().selected_file_index = Some(3);
1670
1671 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1672 let _ = state.update(Message::SelectDiffByIndex(0));
1673
1674 let sel = &state.active_tab().selected_commit_file_indices;
1675 let is_sorted = sel.windows(2).all(|w| w[0] < w[1]);
1676 assert!(
1677 is_sorted,
1678 "selection must always be stored in ascending order"
1679 );
1680 assert_eq!(sel, &vec![0, 1, 2, 3]);
1681 }
1682
1683 #[test]
1684 fn checkout_file_at_commit_message_variants_exist() {
1685 use crate::message::Message;
1686 let _single =
1688 Message::CheckoutFileAtCommit("abc123".to_string(), "src/main.rs".to_string());
1689 let _multi = Message::CheckoutMultiFilesAtCommit(
1690 "abc123".to_string(),
1691 vec!["a.rs".to_string(), "b.rs".to_string()],
1692 );
1693 }
1694
1695 #[test]
1696 fn checkout_file_at_commit_closes_context_menu() {
1697 use crate::message::Message;
1698 let mut state = GitKraft::new();
1699 state.active_tab_mut().repo_path =
1700 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1701 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1702 oid: "abc123".to_string(),
1703 file_path: "src/main.rs".to_string(),
1704 });
1705 let _ = state.update(Message::CheckoutFileAtCommit(
1706 "abc123".to_string(),
1707 "src/main.rs".to_string(),
1708 ));
1709 assert!(state.active_tab().context_menu.is_none());
1710 }
1711
1712 #[test]
1713 fn checkout_multi_files_at_commit_closes_context_menu() {
1714 use crate::message::Message;
1715 let mut state = GitKraft::new();
1716 state.active_tab_mut().repo_path =
1717 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1718 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1719 oid: "abc123".to_string(),
1720 file_path: "src/main.rs".to_string(),
1721 });
1722 let _ = state.update(Message::CheckoutMultiFilesAtCommit(
1723 "abc123".to_string(),
1724 vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
1725 ));
1726 assert!(state.active_tab().context_menu.is_none());
1727 }
1728
1729 fn make_test_commits(count: usize) -> Vec<gitkraft_core::CommitInfo> {
1732 (0..count)
1733 .map(|i| gitkraft_core::CommitInfo {
1734 oid: i.to_string(),
1735 summary: String::new(),
1736 message: String::new(),
1737 author_name: String::new(),
1738 author_email: String::new(),
1739 time: Default::default(),
1740 parent_ids: Vec::new(),
1741 refs: Vec::new(),
1742 })
1743 .collect()
1744 }
1745
1746 #[test]
1747 fn selected_commits_defaults_empty() {
1748 let tab = RepoTab::new_empty();
1749 assert!(tab.selected_commits.is_empty());
1750 assert!(tab.anchor_commit_index.is_none());
1751 }
1752
1753 #[test]
1754 fn regular_click_commit_sets_anchor_and_clears_range() {
1755 use crate::message::Message;
1756 let mut state = GitKraft::new();
1757 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
1758 state.active_tab_mut().commits = make_test_commits(3);
1759 state.active_tab_mut().selected_commits = vec![0, 1, 2];
1760
1761 let _ = state.update(Message::SelectCommit(1));
1762
1763 assert_eq!(state.active_tab().anchor_commit_index, Some(1));
1764 assert!(state.active_tab().selected_commits.is_empty());
1765 assert_eq!(state.active_tab().selected_commit, Some(1));
1766 }
1767
1768 #[test]
1769 fn shift_click_commit_selects_range_from_anchor() {
1770 use crate::message::Message;
1771 let mut state = GitKraft::new();
1772 state.active_tab_mut().commits = make_test_commits(5);
1773 state.active_tab_mut().anchor_commit_index = Some(1);
1774 state.active_tab_mut().selected_commit = Some(1);
1775
1776 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1777 let _ = state.update(Message::SelectCommit(4));
1778
1779 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3, 4]);
1780 }
1781
1782 #[test]
1783 fn shift_click_commit_range_is_ascending_when_clicking_above_anchor() {
1784 use crate::message::Message;
1785 let mut state = GitKraft::new();
1786 state.active_tab_mut().commits = make_test_commits(5);
1787 state.active_tab_mut().anchor_commit_index = Some(3);
1788 state.active_tab_mut().selected_commit = Some(3);
1789
1790 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1791 let _ = state.update(Message::SelectCommit(1));
1792
1793 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3]);
1794 }
1795
1796 #[test]
1799 fn execute_commit_action_closes_context_menu() {
1800 use crate::message::Message;
1801 let mut state = GitKraft::new();
1802 state.active_tab_mut().repo_path =
1803 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1804 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::Commit {
1805 index: 0,
1806 oid: "abc123".to_string(),
1807 });
1808
1809 let _ = state.update(Message::ExecuteCommitAction(
1810 "abc123".to_string(),
1811 gitkraft_core::CommitAction::CherryPick,
1812 ));
1813
1814 assert!(state.active_tab().context_menu.is_none());
1815 }
1816
1817 #[test]
1818 fn execute_commit_action_sets_loading_when_repo_open() {
1819 use crate::message::Message;
1820 let mut state = GitKraft::new();
1821 state.active_tab_mut().repo_path =
1822 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1823
1824 let _ = state.update(Message::ExecuteCommitAction(
1825 "abc123".to_string(),
1826 gitkraft_core::CommitAction::ResetHard,
1827 ));
1828
1829 assert!(state.active_tab().is_loading);
1830 }
1831
1832 #[test]
1833 fn execute_commit_action_no_repo_does_not_set_loading() {
1834 use crate::message::Message;
1835 let mut state = GitKraft::new();
1836 let _ = state.update(Message::ExecuteCommitAction(
1839 "abc123".to_string(),
1840 gitkraft_core::CommitAction::CherryPick,
1841 ));
1842
1843 assert!(!state.active_tab().is_loading);
1844 }
1845
1846 #[test]
1847 fn execute_commit_action_sets_status_message_from_action_label() {
1848 use crate::message::Message;
1849 let mut state = GitKraft::new();
1850 state.active_tab_mut().repo_path =
1851 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1852
1853 let _ = state.update(Message::ExecuteCommitAction(
1854 "abc123".to_string(),
1855 gitkraft_core::CommitAction::Revert,
1856 ));
1857
1858 let status = state.active_tab().status_message.as_deref().unwrap_or("");
1859 assert!(
1861 status.contains("Revert commit"),
1862 "expected status to contain 'Revert commit', got: {status:?}"
1863 );
1864 }
1865
1866 #[test]
1869 fn file_history_defaults_empty() {
1870 let tab = RepoTab::new_empty();
1871 assert!(tab.file_history_path.is_none());
1872 assert!(tab.file_history_commits.is_empty());
1873 assert_eq!(tab.file_history_scroll, 0.0);
1874 }
1875
1876 #[test]
1877 fn blame_defaults_empty() {
1878 let tab = RepoTab::new_empty();
1879 assert!(tab.blame_path.is_none());
1880 assert!(tab.blame_lines.is_empty());
1881 assert_eq!(tab.blame_scroll, 0.0);
1882 }
1883
1884 #[test]
1885 fn pending_delete_file_defaults_none() {
1886 let tab = RepoTab::new_empty();
1887 assert!(tab.pending_delete_file.is_none());
1888 }
1889
1890 #[test]
1891 fn view_file_history_sets_path_and_clears_blame() {
1892 use crate::message::Message;
1893 let mut state = GitKraft::new();
1894 state.active_tab_mut().repo_path =
1895 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1896 state.active_tab_mut().blame_path = Some("some/file.rs".to_string());
1897
1898 let _ = state.update(Message::ViewFileHistory("src/main.rs".to_string()));
1899
1900 assert_eq!(
1901 state.active_tab().file_history_path.as_deref(),
1902 Some("src/main.rs")
1903 );
1904 assert!(state.active_tab().blame_path.is_none());
1906 }
1907
1908 #[test]
1909 fn close_file_history_clears_state() {
1910 use crate::message::Message;
1911 let mut state = GitKraft::new();
1912 state.active_tab_mut().file_history_path = Some("src/lib.rs".to_string());
1913 state.active_tab_mut().file_history_commits = vec![gitkraft_core::CommitInfo {
1914 oid: "abc".to_string(),
1915 summary: "s".to_string(),
1916 message: "s".to_string(),
1917 author_name: "a".to_string(),
1918 author_email: "a@b.com".to_string(),
1919 time: Default::default(),
1920 parent_ids: vec![],
1921 refs: Vec::new(),
1922 }];
1923
1924 let _ = state.update(Message::CloseFileHistory);
1925
1926 assert!(state.active_tab().file_history_path.is_none());
1927 assert!(state.active_tab().file_history_commits.is_empty());
1928 }
1929
1930 #[test]
1931 fn view_file_blame_sets_path_and_clears_history() {
1932 use crate::message::Message;
1933 let mut state = GitKraft::new();
1934 state.active_tab_mut().repo_path =
1935 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1936 state.active_tab_mut().file_history_path = Some("some/file.rs".to_string());
1937
1938 let _ = state.update(Message::ViewFileBlame("src/lib.rs".to_string()));
1939
1940 assert_eq!(state.active_tab().blame_path.as_deref(), Some("src/lib.rs"));
1941 assert!(state.active_tab().file_history_path.is_none());
1943 }
1944
1945 #[test]
1946 fn selecting_new_commit_closes_blame_overlay() {
1947 use crate::message::Message;
1948 let mut state = GitKraft::new();
1949 state.active_tab_mut().repo_path =
1950 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1951 state.active_tab_mut().commits = vec![
1953 gitkraft_core::CommitInfo {
1954 oid: "abc1".into(),
1955 summary: "first".into(),
1956 message: "first".into(),
1957 author_name: "A".into(),
1958 author_email: "a@a.com".into(),
1959 time: Default::default(),
1960 parent_ids: Vec::new(),
1961 refs: Vec::new(),
1962 },
1963 gitkraft_core::CommitInfo {
1964 oid: "abc2".into(),
1965 summary: "second".into(),
1966 message: "second".into(),
1967 author_name: "A".into(),
1968 author_email: "a@a.com".into(),
1969 time: Default::default(),
1970 parent_ids: Vec::new(),
1971 refs: Vec::new(),
1972 },
1973 ];
1974 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1976 state.active_tab_mut().blame_lines = vec![gitkraft_core::BlameLine {
1977 line_number: 1,
1978 content: "fn main() {}".into(),
1979 oid: "abc1".into(),
1980 author_name: "A".into(),
1981 time: Default::default(),
1982 }];
1983
1984 let _ = state.update(Message::SelectCommit(1));
1986
1987 assert!(
1988 state.active_tab().blame_path.is_none(),
1989 "blame_path must be cleared when a new commit is selected"
1990 );
1991 assert!(
1992 state.active_tab().blame_lines.is_empty(),
1993 "blame_lines must be cleared when a new commit is selected"
1994 );
1995 }
1996
1997 #[test]
1998 fn close_file_blame_clears_state() {
1999 use crate::message::Message;
2000 let mut state = GitKraft::new();
2001 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
2002
2003 let _ = state.update(Message::CloseFileBlame);
2004
2005 assert!(state.active_tab().blame_path.is_none());
2006 assert!(state.active_tab().blame_lines.is_empty());
2007 }
2008
2009 #[test]
2010 fn delete_file_sets_pending() {
2011 use crate::message::Message;
2012 let mut state = GitKraft::new();
2013
2014 let _ = state.update(Message::DeleteFile("src/old.rs".to_string()));
2015
2016 assert_eq!(
2017 state.active_tab().pending_delete_file.as_deref(),
2018 Some("src/old.rs")
2019 );
2020 assert!(state.active_tab().context_menu.is_none());
2021 }
2022
2023 #[test]
2024 fn cancel_delete_file_clears_pending() {
2025 use crate::message::Message;
2026 let mut state = GitKraft::new();
2027 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
2028
2029 let _ = state.update(Message::CancelDeleteFile);
2030
2031 assert!(state.active_tab().pending_delete_file.is_none());
2032 }
2033
2034 #[test]
2035 fn confirm_delete_file_no_repo_is_noop() {
2036 use crate::message::Message;
2037 let mut state = GitKraft::new();
2038 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
2039 let _ = state.update(Message::ConfirmDeleteFile);
2042
2043 assert!(!state.active_tab().is_loading);
2044 }
2045
2046 #[test]
2047 fn shift_arrow_down_extends_file_list_selection_when_files_loaded() {
2048 use crate::message::Message;
2049 let mut state = GitKraft::new();
2050 state.active_tab_mut().repo_path =
2051 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
2052 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
2053 state.active_tab_mut().selected_file_index = Some(0);
2054 state.active_tab_mut().anchor_file_index = Some(0);
2055 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
2057
2058 let _ = state.update(Message::ShiftArrowDown);
2059
2060 assert_eq!(state.active_tab().selected_file_index, Some(1));
2061 assert!(state.active_tab().selected_commit_file_indices.contains(&0));
2063 assert!(state.active_tab().selected_commit_file_indices.contains(&1));
2064 }
2065
2066 #[test]
2067 fn shift_arrow_down_falls_through_to_commit_log_when_no_files() {
2068 use crate::message::Message;
2069 let mut state = GitKraft::new();
2070 state.active_tab_mut().commits = make_test_commits(5);
2071 state.active_tab_mut().selected_commit = Some(1);
2072 state.active_tab_mut().anchor_commit_index = Some(1);
2073 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
2074 let _ = state.update(Message::ShiftArrowDown);
2077
2078 assert_eq!(state.active_tab().selected_commit, Some(2));
2079 assert!(state.active_tab().selected_commits.contains(&1));
2080 assert!(state.active_tab().selected_commits.contains(&2));
2081 }
2082
2083 #[test]
2084 fn file_system_changed_triggers_full_refresh() {
2085 use crate::message::Message;
2086 let mut state = GitKraft::new();
2087 state.active_tab_mut().repo_path =
2088 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
2089
2090 let _task = state.update(Message::FileSystemChanged);
2093
2094 assert!(
2096 state.active_tab().is_loading,
2097 "FileSystemChanged must set is_loading to guard against concurrent refreshes"
2098 );
2099 assert!(
2100 state.active_tab().error_message.is_none(),
2101 "FileSystemChanged must not set an error message"
2102 );
2103 }
2104
2105 #[test]
2106 fn refresh_active_tab_skips_when_already_loading() {
2107 let mut state = GitKraft::new();
2111 state.active_tab_mut().repo_path =
2112 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
2113 state.active_tab_mut().is_loading = true;
2114
2115 let _task = state.refresh_active_tab();
2117 assert!(state.active_tab().is_loading);
2119 }
2120
2121 fn fake_payload(workdir: &str) -> crate::message::RepoPayload {
2125 gitkraft_core::RepoSnapshot {
2126 info: gitkraft_core::RepoInfo {
2127 path: std::path::PathBuf::from(format!("{workdir}/.git")),
2128 workdir: Some(std::path::PathBuf::from(workdir)),
2129 head_branch: Some("main".into()),
2130 is_bare: false,
2131 state: gitkraft_core::RepoState::Clean,
2132 },
2133 branches: Vec::new(),
2134 commits: Vec::new(),
2135 graph_rows: Vec::new(),
2136 unstaged: Vec::new(),
2137 staged: Vec::new(),
2138 stashes: Vec::new(),
2139 remotes: Vec::new(),
2140 }
2141 }
2142
2143 fn setup_loaded_tab(tab: &mut RepoTab, path: &str) {
2145 tab.repo_path = Some(std::path::PathBuf::from(path));
2146 tab.repo_info = Some(gitkraft_core::RepoInfo {
2147 path: std::path::PathBuf::from(format!("{path}/.git")),
2148 workdir: Some(std::path::PathBuf::from(path)),
2149 head_branch: Some("main".into()),
2150 is_bare: false,
2151 state: gitkraft_core::RepoState::Clean,
2152 });
2153 }
2154
2155 #[test]
2156 fn open_repo_creates_new_tab_when_repo_already_open() {
2157 use crate::message::Message;
2158 let mut state = GitKraft::new();
2159 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2160
2161 assert_eq!(state.tabs.len(), 1);
2162 assert_eq!(state.active_tab, 0);
2163
2164 let _task = state.update(Message::OpenRepo);
2166
2167 assert_eq!(state.tabs.len(), 2);
2168 assert_eq!(state.active_tab, 1);
2169 assert!(state.tabs[1].is_loading);
2171 assert_eq!(
2173 state.tabs[0].repo_path.as_deref(),
2174 Some(std::path::Path::new("/home/user/repo-a"))
2175 );
2176 }
2177
2178 #[test]
2179 fn open_repo_reuses_empty_tab() {
2180 use crate::message::Message;
2181 let mut state = GitKraft::new();
2182 assert!(!state.active_tab().has_repo());
2184
2185 let _task = state.update(Message::OpenRepo);
2186
2187 assert_eq!(state.tabs.len(), 1);
2189 assert_eq!(state.active_tab, 0);
2190 assert!(state.tabs[0].is_loading);
2191 }
2192
2193 #[test]
2194 fn repo_selected_deduplicates_already_open_repo() {
2195 use crate::message::Message;
2196 let mut state = GitKraft::new();
2197 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2199 state.tabs.push(RepoTab::new_empty());
2201 state.active_tab = 1;
2202
2203 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2205 "/home/user/repo-a",
2206 ))));
2207
2208 assert_eq!(state.tabs.len(), 1);
2210 assert_eq!(state.active_tab, 0);
2211 assert_eq!(
2212 state.tabs[0].repo_path.as_deref(),
2213 Some(std::path::Path::new("/home/user/repo-a"))
2214 );
2215 }
2216
2217 #[test]
2218 fn repo_selected_opens_new_repo_in_empty_tab() {
2219 use crate::message::Message;
2220 let mut state = GitKraft::new();
2221 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2223 state.tabs.push(RepoTab::new_empty());
2225 state.active_tab = 1;
2226
2227 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2229 "/home/user/repo-b",
2230 ))));
2231
2232 assert_eq!(state.tabs.len(), 2);
2234 assert_eq!(state.active_tab, 1);
2235 assert!(state.tabs[1]
2236 .status_message
2237 .as_deref()
2238 .unwrap_or("")
2239 .contains("repo-b"));
2240 }
2241
2242 #[test]
2243 fn repo_selected_cancel_removes_empty_tab() {
2244 use crate::message::Message;
2245 let mut state = GitKraft::new();
2246 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2248 state.tabs.push(RepoTab::new_empty());
2250 state.active_tab = 1;
2251
2252 let _task = state.update(Message::RepoSelected(None));
2254
2255 assert_eq!(state.tabs.len(), 1);
2257 assert_eq!(state.active_tab, 0);
2258 assert_eq!(
2259 state.tabs[0].repo_path.as_deref(),
2260 Some(std::path::Path::new("/home/user/repo-a"))
2261 );
2262 }
2263
2264 #[test]
2265 fn repo_selected_cancel_keeps_tab_if_only_one() {
2266 use crate::message::Message;
2267 let mut state = GitKraft::new();
2268 assert_eq!(state.tabs.len(), 1);
2270 assert!(!state.active_tab().has_repo());
2271
2272 let _task = state.update(Message::RepoSelected(None));
2273
2274 assert_eq!(state.tabs.len(), 1);
2275 assert!(!state.active_tab().is_loading);
2276 }
2277
2278 #[test]
2279 fn open_recent_repo_deduplicates() {
2280 use crate::message::Message;
2281 let mut state = GitKraft::new();
2282 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2284 state.tabs.push(RepoTab::new_empty());
2286 state.active_tab = 1;
2287
2288 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2291 "/home/user/repo-a",
2292 )));
2293
2294 assert_eq!(state.tabs.len(), 1, "empty tab must be removed");
2295 assert_eq!(state.active_tab, 0);
2296 assert_eq!(
2297 state.active_tab().repo_path.as_deref(),
2298 Some(std::path::Path::new("/home/user/repo-a")),
2299 );
2300 }
2301
2302 #[test]
2303 fn open_recent_repo_creates_new_tab_when_current_has_repo() {
2304 use crate::message::Message;
2305 let mut state = GitKraft::new();
2306 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2308
2309 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2311 "/home/user/repo-b",
2312 )));
2313
2314 assert_eq!(state.tabs.len(), 2);
2315 assert_eq!(state.active_tab, 1);
2316 assert!(state.tabs[1].is_loading);
2317 }
2318
2319 #[test]
2320 fn open_recent_repo_uses_empty_tab() {
2321 use crate::message::Message;
2322 let mut state = GitKraft::new();
2323 assert!(!state.active_tab().has_repo());
2325
2326 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2327 "/home/user/repo-b",
2328 )));
2329
2330 assert_eq!(state.tabs.len(), 1);
2332 assert_eq!(state.active_tab, 0);
2333 assert!(state.tabs[0].is_loading);
2334 }
2335
2336 #[test]
2337 fn open_recent_repo_dedup_removes_empty_tab_with_multiple_loaded() {
2338 use crate::message::Message;
2341 let mut state = GitKraft::new();
2342 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2343 state.tabs.push(RepoTab::new_empty());
2344 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2345 state.tabs.push(RepoTab::new_empty());
2346 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2347 state.tabs.push(RepoTab::new_empty());
2349 state.active_tab = 3;
2350
2351 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2352 "/home/user/repo-b",
2353 )));
2354
2355 assert_eq!(state.tabs.len(), 3, "empty tab must be cleaned up");
2356 assert_eq!(state.active_tab, 1);
2357 assert_eq!(
2358 state.active_tab().repo_path.as_deref(),
2359 Some(std::path::Path::new("/home/user/repo-b")),
2360 );
2361 }
2362
2363 #[test]
2366 fn repo_refreshed_targets_correct_tab_after_tab_switch() {
2367 use crate::message::Message;
2368 let mut state = GitKraft::new();
2369 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2371 state.tabs.push(RepoTab::new_empty());
2373 state.active_tab = 1; let payload = fake_payload("/home/user/repo-a");
2377 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2378
2379 assert!(
2381 state.tabs[0].repo_info.is_some(),
2382 "tab 0 should still have repo info after refresh"
2383 );
2384 assert_eq!(
2385 state.tabs[0].current_branch.as_deref(),
2386 Some("main"),
2387 "tab 0 should have updated branch from payload"
2388 );
2389 assert!(
2391 state.tabs[1].repo_info.is_none(),
2392 "tab 1 (empty) must NOT receive the refresh payload"
2393 );
2394 assert!(
2395 state.tabs[1].repo_path.is_none(),
2396 "tab 1 should still have no repo path"
2397 );
2398 }
2399
2400 #[test]
2401 fn repo_refreshed_targets_active_tab_for_new_open() {
2402 use crate::message::Message;
2403 let mut state = GitKraft::new();
2404 assert_eq!(state.tabs.len(), 1);
2408 assert!(!state.active_tab().has_repo());
2409
2410 let payload = fake_payload("/home/user/new-repo");
2411 let _task = state.update(Message::RepoOpened(Ok(payload)));
2412
2413 assert_eq!(
2415 state.tabs[0].repo_path.as_deref(),
2416 Some(std::path::Path::new("/home/user/new-repo"))
2417 );
2418 assert!(state.tabs[0].repo_info.is_some());
2419 }
2420
2421 #[test]
2422 fn repo_refreshed_does_not_duplicate_into_new_tab() {
2423 use crate::message::Message;
2424 let mut state = GitKraft::new();
2425 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2427
2428 let _task = state.update(Message::NewTab);
2430 assert_eq!(state.tabs.len(), 2);
2431 assert_eq!(state.active_tab, 1);
2432
2433 let payload = fake_payload("/home/user/repo-a");
2435 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2436
2437 assert!(
2439 state.tabs[1].repo_path.is_none(),
2440 "new empty tab must not receive repo-a refresh"
2441 );
2442 assert!(
2443 state.tabs[1].repo_info.is_none(),
2444 "new empty tab must not have repo_info"
2445 );
2446 assert_eq!(
2448 state.tabs[0].repo_path.as_deref(),
2449 Some(std::path::Path::new("/home/user/repo-a"))
2450 );
2451 }
2452
2453 #[test]
2454 fn git_operation_result_targets_correct_tab() {
2455 use crate::message::Message;
2456 let mut state = GitKraft::new();
2457 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2459 state.tabs.push(RepoTab::new_empty());
2461 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2462 state.active_tab = 1;
2463
2464 let payload = fake_payload("/home/user/repo-a");
2467 let _task = state.update(Message::GitOperationResult(Ok(payload)));
2468
2469 assert_eq!(state.tabs[0].current_branch.as_deref(), Some("main"));
2471 assert_eq!(
2473 state.tabs[1].repo_path.as_deref(),
2474 Some(std::path::Path::new("/home/user/repo-b"))
2475 );
2476 }
2477
2478 #[test]
2479 fn multiple_new_tabs_dont_get_polluted_by_refresh() {
2480 use crate::message::Message;
2481 let mut state = GitKraft::new();
2482 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2484
2485 let _task = state.update(Message::NewTab);
2487 let _task = state.update(Message::NewTab);
2488 assert_eq!(state.tabs.len(), 3);
2489 assert_eq!(state.active_tab, 2);
2490
2491 let payload = fake_payload("/home/user/repo-a");
2493 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2494
2495 assert!(state.tabs[0].repo_info.is_some());
2497 assert!(state.tabs[1].repo_info.is_none());
2498 assert!(state.tabs[2].repo_info.is_none());
2499 assert!(state.tabs[1].repo_path.is_none());
2500 assert!(state.tabs[2].repo_path.is_none());
2501 }
2502
2503 #[test]
2504 fn repo_selected_dedup_adjusts_index_when_existing_is_after_active() {
2505 use crate::message::Message;
2506 let mut state = GitKraft::new();
2507 state.tabs.push(RepoTab::new_empty());
2510 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
2511 state.active_tab = 0;
2512
2513 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2515 "/home/user/repo-a",
2516 ))));
2517
2518 assert_eq!(state.tabs.len(), 1);
2520 assert_eq!(state.active_tab, 0);
2521 assert_eq!(
2522 state.tabs[0].repo_path.as_deref(),
2523 Some(std::path::Path::new("/home/user/repo-a"))
2524 );
2525 }
2526
2527 #[test]
2530 fn apply_payload_preserves_multi_selection_by_oid() {
2531 let mut tab = RepoTab::new_empty();
2532 tab.commits = make_test_commits(5);
2533 tab.anchor_commit_index = Some(1);
2535 tab.selected_commits = vec![1, 2, 3];
2536 tab.selected_commit = Some(3);
2537 tab.selected_commit_oid = Some(tab.commits[3].oid.clone());
2538
2539 let mut payload = fake_payload("/tmp/repo");
2541 payload.commits = make_test_commits(5);
2542
2543 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2544
2545 assert_eq!(tab.anchor_commit_index, Some(1));
2547 assert_eq!(tab.selected_commits, vec![1, 2, 3]);
2548 assert_eq!(tab.selected_commit, Some(3));
2549 }
2550
2551 #[test]
2552 fn apply_payload_preserves_anchor_even_without_range() {
2553 let mut tab = RepoTab::new_empty();
2554 tab.commits = make_test_commits(5);
2555 tab.anchor_commit_index = Some(2);
2556 tab.selected_commit = Some(2);
2557 tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2558 let mut payload = fake_payload("/tmp/repo");
2561 payload.commits = make_test_commits(5);
2562
2563 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2564
2565 assert_eq!(tab.anchor_commit_index, Some(2));
2566 assert_eq!(tab.selected_commit, Some(2));
2567 }
2568
2569 #[test]
2570 fn apply_payload_clears_selection_when_commits_disappear() {
2571 let mut tab = RepoTab::new_empty();
2572 tab.commits = make_test_commits(5);
2573 tab.anchor_commit_index = Some(2);
2574 tab.selected_commits = vec![2, 3, 4];
2575 tab.selected_commit = Some(4);
2576 tab.selected_commit_oid = Some(tab.commits[4].oid.clone());
2577
2578 let mut payload = fake_payload("/tmp/repo");
2580 payload.commits = (0..3)
2581 .map(|i| gitkraft_core::CommitInfo {
2582 oid: format!("new_oid_{i}"),
2583 summary: format!("new commit {i}"),
2584 message: String::new(),
2585 author_name: "Author".into(),
2586 author_email: "a@b.c".into(),
2587 time: Default::default(),
2588 parent_ids: Vec::new(),
2589 refs: Vec::new(),
2590 })
2591 .collect();
2592
2593 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2594
2595 assert!(tab.selected_commits.is_empty());
2597 assert!(tab.anchor_commit_index.is_none());
2598 assert!(tab.selected_commit.is_none());
2599 }
2600
2601 #[test]
2604 fn apply_payload_preserves_file_selection_when_commit_survives() {
2605 let mut tab = RepoTab::new_empty();
2606 tab.commits = make_test_commits(3);
2607 tab.selected_commit = Some(1);
2608 tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2609 tab.commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
2610
2611 tab.anchor_file_index = Some(0);
2613 tab.selected_commit_file_indices = vec![0, 1, 2];
2614 tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2615 old_file: String::new(),
2616 new_file: "a.rs".into(),
2617 status: gitkraft_core::FileStatus::Modified,
2618 hunks: vec![],
2619 }];
2620
2621 let mut payload = fake_payload("/tmp/repo");
2623 payload.commits = make_test_commits(3);
2624
2625 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2626
2627 assert_eq!(tab.anchor_file_index, Some(0));
2629 assert_eq!(tab.selected_commit_file_indices, vec![0, 1, 2]);
2630 assert!(
2631 !tab.multi_file_diffs.is_empty(),
2632 "multi_file_diffs must survive when commit is preserved"
2633 );
2634 }
2635
2636 #[test]
2637 fn apply_payload_clears_file_selection_when_commit_disappears() {
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"]);
2643 tab.anchor_file_index = Some(0);
2644 tab.selected_commit_file_indices = vec![0, 1];
2645 tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2646 old_file: String::new(),
2647 new_file: "a.rs".into(),
2648 status: gitkraft_core::FileStatus::Modified,
2649 hunks: vec![],
2650 }];
2651
2652 let mut payload = fake_payload("/tmp/repo");
2654 payload.commits = (0..2)
2655 .map(|i| gitkraft_core::CommitInfo {
2656 oid: format!("new_{i}"),
2657 summary: String::new(),
2658 message: String::new(),
2659 author_name: String::new(),
2660 author_email: String::new(),
2661 time: Default::default(),
2662 parent_ids: Vec::new(),
2663 refs: Vec::new(),
2664 })
2665 .collect();
2666
2667 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2668
2669 assert!(tab.anchor_file_index.is_none());
2671 assert!(tab.selected_commit_file_indices.is_empty());
2672 assert!(tab.multi_file_diffs.is_empty());
2673 assert!(tab.commit_files.is_empty());
2674 }
2675
2676 #[test]
2677 fn apply_payload_preserves_commit_range_diffs_when_commit_survives() {
2678 let mut tab = RepoTab::new_empty();
2679 tab.commits = make_test_commits(5);
2680 tab.selected_commit = Some(2);
2681 tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2682 tab.commit_range_diffs = vec![gitkraft_core::DiffInfo {
2683 old_file: String::new(),
2684 new_file: "x.rs".into(),
2685 status: gitkraft_core::FileStatus::Modified,
2686 hunks: vec![],
2687 }];
2688
2689 let mut payload = fake_payload("/tmp/repo");
2690 payload.commits = make_test_commits(5);
2691
2692 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2693
2694 assert!(
2695 !tab.commit_range_diffs.is_empty(),
2696 "commit_range_diffs must survive when commit is preserved"
2697 );
2698 }
2699
2700 #[test]
2701 fn apply_payload_preserves_commit_message() {
2702 let mut tab = RepoTab::new_empty();
2705 tab.repo_path = Some(std::path::PathBuf::from("/tmp/repo"));
2706 tab.commit_message = "WIP: fixing the bug".to_string();
2707
2708 let payload = fake_payload("/tmp/repo");
2709 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2710
2711 assert_eq!(
2712 tab.commit_message, "WIP: fixing the bug",
2713 "commit_message must survive background repo refreshes"
2714 );
2715 }
2716
2717 #[test]
2720 fn preview_file_loaded_sets_preview_editor() {
2721 use crate::message::Message;
2722 let mut state = GitKraft::new();
2723 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2724
2725 let content = "line 1\nline 2\nline 3".to_string();
2726 let _ = state.update(Message::FilePreviewLoaded(Ok((
2727 "test.rs".to_string(),
2728 content,
2729 ))));
2730
2731 assert!(state.preview_editor.is_some());
2732 assert_eq!(state.preview_path.as_deref(), Some("test.rs"));
2733 assert!(state.active_tab().selected_diff.is_none());
2735 }
2736
2737 #[test]
2738 fn preview_file_loaded_error_sets_error_message() {
2739 use crate::message::Message;
2740 let mut state = GitKraft::new();
2741 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2742
2743 let _ = state.update(Message::FilePreviewLoaded(Err("No such file".to_string())));
2744
2745 assert!(state.active_tab().error_message.is_some());
2746 assert!(state
2747 .active_tab()
2748 .error_message
2749 .as_ref()
2750 .unwrap()
2751 .contains("No such file"));
2752 }
2753
2754 #[test]
2755 fn close_preview_clears_editor_state() {
2756 use crate::message::Message;
2757 let mut state = GitKraft::new();
2758 state.preview_editor = Some(iced::widget::text_editor::Content::with_text("hello"));
2759 state.preview_path = Some("/test/file.txt".to_string());
2760
2761 let _ = state.update(Message::ClosePreview);
2762
2763 assert!(state.preview_editor.is_none());
2764 assert!(state.preview_path.is_none());
2765 }
2766
2767 #[test]
2770 fn close_repo_single_tab_shows_welcome_screen() {
2771 use crate::message::Message;
2775 let mut state = GitKraft::new();
2776 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2777 assert_eq!(state.tabs.len(), 1);
2778
2779 let _ = state.update(Message::CloseRepo);
2780
2781 assert_eq!(state.tabs.len(), 1, "last tab must not be removed");
2782 assert_eq!(state.active_tab, 0);
2783 assert!(!state.active_tab().has_repo(), "tab should be empty");
2784 }
2785
2786 #[test]
2787 fn close_repo_with_multiple_tabs_removes_current_tab() {
2788 use crate::message::Message;
2799 let mut state = GitKraft::new();
2800 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2802 state.tabs.push(RepoTab::new_empty());
2804 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2805 state.active_tab = 0;
2807
2808 let _ = state.update(Message::CloseRepo);
2809
2810 assert_eq!(state.tabs.len(), 1, "current tab must be removed");
2813 assert_eq!(state.active_tab, 0);
2814 assert_eq!(
2815 state.active_tab().repo_path.as_deref(),
2816 Some(std::path::Path::new("/home/user/repo-b")),
2817 "repo-b should be the surviving tab"
2818 );
2819 }
2820
2821 #[test]
2822 fn close_repo_second_tab_active_removes_it_and_keeps_first() {
2823 use crate::message::Message;
2826 let mut state = GitKraft::new();
2827 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2829 state.tabs.push(RepoTab::new_empty());
2831 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2832 state.active_tab = 1; let _ = state.update(Message::CloseRepo);
2835
2836 assert_eq!(state.tabs.len(), 1);
2837 assert_eq!(state.active_tab, 0);
2838 assert_eq!(
2839 state.active_tab().repo_path.as_deref(),
2840 Some(std::path::Path::new("/home/user/repo-a")),
2841 "repo-a should be the surviving tab"
2842 );
2843 }
2844
2845 #[test]
2848 fn close_tab_active_tab_first_position() {
2849 use crate::message::Message;
2851 let mut state = GitKraft::new();
2852 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2853 state.tabs.push(RepoTab::new_empty());
2854 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2855 state.active_tab = 0;
2856
2857 let _ = state.update(Message::CloseTab(0));
2858
2859 assert_eq!(state.tabs.len(), 1);
2860 assert_eq!(state.active_tab, 0);
2861 assert_eq!(
2862 state.active_tab().repo_path.as_deref(),
2863 Some(std::path::Path::new("/home/user/repo-b"))
2864 );
2865 }
2866
2867 #[test]
2868 fn close_tab_active_tab_second_position() {
2869 use crate::message::Message;
2871 let mut state = GitKraft::new();
2872 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2873 state.tabs.push(RepoTab::new_empty());
2874 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2875 state.active_tab = 1;
2876
2877 let _ = state.update(Message::CloseTab(1));
2878
2879 assert_eq!(state.tabs.len(), 1);
2880 assert_eq!(state.active_tab, 0);
2881 assert_eq!(
2882 state.active_tab().repo_path.as_deref(),
2883 Some(std::path::Path::new("/home/user/repo-a"))
2884 );
2885 }
2886
2887 #[test]
2888 fn close_tab_non_active_tab_before_active() {
2889 use crate::message::Message;
2891 let mut state = GitKraft::new();
2892 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2893 state.tabs.push(RepoTab::new_empty());
2894 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2895 state.active_tab = 1;
2896
2897 let _ = state.update(Message::CloseTab(0));
2898
2899 assert_eq!(state.tabs.len(), 1);
2900 assert_eq!(state.active_tab, 0, "active_tab must shift down by 1");
2901 assert_eq!(
2902 state.active_tab().repo_path.as_deref(),
2903 Some(std::path::Path::new("/home/user/repo-b")),
2904 "repo-b must remain active"
2905 );
2906 }
2907
2908 #[test]
2909 fn close_tab_non_active_tab_after_active() {
2910 use crate::message::Message;
2912 let mut state = GitKraft::new();
2913 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2914 state.tabs.push(RepoTab::new_empty());
2915 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2916 state.active_tab = 0;
2917
2918 let _ = state.update(Message::CloseTab(1));
2919
2920 assert_eq!(state.tabs.len(), 1);
2921 assert_eq!(state.active_tab, 0, "active_tab must remain unchanged");
2922 assert_eq!(
2923 state.active_tab().repo_path.as_deref(),
2924 Some(std::path::Path::new("/home/user/repo-a")),
2925 "repo-a must remain active"
2926 );
2927 }
2928
2929 #[test]
2930 fn close_tab_last_remaining_tab_is_noop() {
2931 use crate::message::Message;
2933 let mut state = GitKraft::new();
2934 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2935 assert_eq!(state.tabs.len(), 1);
2936
2937 let _ = state.update(Message::CloseTab(0));
2938
2939 assert_eq!(state.tabs.len(), 1, "last tab must not be removed");
2940 assert!(
2941 state.active_tab().has_repo(),
2942 "tab content must be unchanged"
2943 );
2944 }
2945
2946 #[test]
2947 fn close_tab_out_of_bounds_index_is_noop() {
2948 use crate::message::Message;
2950 let mut state = GitKraft::new();
2951 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2952 state.tabs.push(RepoTab::new_empty());
2953 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2954 assert_eq!(state.tabs.len(), 2);
2955
2956 let _ = state.update(Message::CloseTab(99));
2957
2958 assert_eq!(state.tabs.len(), 2, "no tab should be removed");
2959 }
2960
2961 #[test]
2964 fn close_tab_three_tabs_close_first_active() {
2965 use crate::message::Message;
2968 let mut state = GitKraft::new();
2969 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2970 state.tabs.push(RepoTab::new_empty());
2971 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2972 state.tabs.push(RepoTab::new_empty());
2973 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2974 state.active_tab = 0;
2975
2976 let _ = state.update(Message::CloseTab(0));
2977
2978 assert_eq!(state.tabs.len(), 2);
2979 assert_eq!(state.active_tab, 0);
2980 assert_eq!(
2981 state.active_tab().repo_path.as_deref(),
2982 Some(std::path::Path::new("/home/user/repo-b"))
2983 );
2984 }
2985
2986 #[test]
2987 fn close_tab_three_tabs_close_active_middle() {
2988 use crate::message::Message;
2991 let mut state = GitKraft::new();
2992 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2993 state.tabs.push(RepoTab::new_empty());
2994 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2995 state.tabs.push(RepoTab::new_empty());
2996 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2997 state.active_tab = 1;
2998
2999 let _ = state.update(Message::CloseTab(1));
3000
3001 assert_eq!(state.tabs.len(), 2);
3002 assert_eq!(
3003 state.active_tab, 1,
3004 "active_tab must stay at 1 (repo-c slides in)"
3005 );
3006 assert_eq!(
3007 state.active_tab().repo_path.as_deref(),
3008 Some(std::path::Path::new("/home/user/repo-c"))
3009 );
3010 }
3011
3012 #[test]
3013 fn close_tab_three_tabs_close_active_last() {
3014 use crate::message::Message;
3017 let mut state = GitKraft::new();
3018 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3019 state.tabs.push(RepoTab::new_empty());
3020 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3021 state.tabs.push(RepoTab::new_empty());
3022 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3023 state.active_tab = 2;
3024
3025 let _ = state.update(Message::CloseTab(2));
3026
3027 assert_eq!(state.tabs.len(), 2);
3028 assert_eq!(
3029 state.active_tab, 1,
3030 "active_tab must be clamped to last index"
3031 );
3032 assert_eq!(
3033 state.active_tab().repo_path.as_deref(),
3034 Some(std::path::Path::new("/home/user/repo-b"))
3035 );
3036 }
3037
3038 #[test]
3039 fn close_tab_three_tabs_close_first_when_middle_active() {
3040 use crate::message::Message;
3043 let mut state = GitKraft::new();
3044 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3045 state.tabs.push(RepoTab::new_empty());
3046 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3047 state.tabs.push(RepoTab::new_empty());
3048 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3049 state.active_tab = 1;
3050
3051 let _ = state.update(Message::CloseTab(0));
3052
3053 assert_eq!(state.tabs.len(), 2);
3054 assert_eq!(
3055 state.active_tab, 0,
3056 "active_tab must decrement when a tab before it is removed"
3057 );
3058 assert_eq!(
3059 state.active_tab().repo_path.as_deref(),
3060 Some(std::path::Path::new("/home/user/repo-b")),
3061 "repo-b must remain active"
3062 );
3063 }
3064
3065 #[test]
3066 fn close_tab_three_tabs_close_last_when_middle_active() {
3067 use crate::message::Message;
3070 let mut state = GitKraft::new();
3071 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3072 state.tabs.push(RepoTab::new_empty());
3073 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3074 state.tabs.push(RepoTab::new_empty());
3075 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3076 state.active_tab = 1;
3077
3078 let _ = state.update(Message::CloseTab(2));
3079
3080 assert_eq!(state.tabs.len(), 2);
3081 assert_eq!(
3082 state.active_tab, 1,
3083 "active_tab must not change when a tab after it is removed"
3084 );
3085 assert_eq!(
3086 state.active_tab().repo_path.as_deref(),
3087 Some(std::path::Path::new("/home/user/repo-b")),
3088 "repo-b must remain active"
3089 );
3090 }
3091
3092 #[test]
3095 fn close_repo_three_tabs_active_in_middle() {
3096 use crate::message::Message;
3099 let mut state = GitKraft::new();
3100 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3101 state.tabs.push(RepoTab::new_empty());
3102 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3103 state.tabs.push(RepoTab::new_empty());
3104 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3105 state.active_tab = 1;
3106
3107 let _ = state.update(Message::CloseRepo);
3108
3109 assert_eq!(state.tabs.len(), 2);
3110 assert_eq!(state.active_tab, 1);
3111 assert_eq!(
3112 state.active_tab().repo_path.as_deref(),
3113 Some(std::path::Path::new("/home/user/repo-c"))
3114 );
3115 }
3116
3117 #[test]
3118 fn close_repo_three_tabs_active_at_end() {
3119 use crate::message::Message;
3122 let mut state = GitKraft::new();
3123 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3124 state.tabs.push(RepoTab::new_empty());
3125 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3126 state.tabs.push(RepoTab::new_empty());
3127 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3128 state.active_tab = 2;
3129
3130 let _ = state.update(Message::CloseRepo);
3131
3132 assert_eq!(state.tabs.len(), 2);
3133 assert_eq!(state.active_tab, 1, "active_tab clamped to last index");
3134 assert_eq!(
3135 state.active_tab().repo_path.as_deref(),
3136 Some(std::path::Path::new("/home/user/repo-b"))
3137 );
3138 }
3139
3140 #[test]
3143 fn new_tab_appends_empty_tab_and_makes_it_active() {
3144 use crate::message::Message;
3145 let mut state = GitKraft::new();
3146 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3147 assert_eq!(state.tabs.len(), 1);
3148
3149 let _ = state.update(Message::NewTab);
3150
3151 assert_eq!(state.tabs.len(), 2, "a new empty tab must be appended");
3152 assert_eq!(state.active_tab, 1, "new tab must become active");
3153 assert!(!state.active_tab().has_repo(), "new tab must be empty");
3154 assert!(state.tabs[0].has_repo());
3156 }
3157
3158 #[test]
3159 fn new_tab_with_multiple_existing_tabs() {
3160 use crate::message::Message;
3161 let mut state = GitKraft::new();
3162 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3163 state.tabs.push(RepoTab::new_empty());
3164 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3165 state.active_tab = 0;
3166
3167 let _ = state.update(Message::NewTab);
3168
3169 assert_eq!(state.tabs.len(), 3);
3170 assert_eq!(state.active_tab, 2);
3171 assert!(!state.active_tab().has_repo());
3172 }
3173
3174 #[test]
3177 fn switch_tab_to_valid_index() {
3178 use crate::message::Message;
3179 let mut state = GitKraft::new();
3180 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3181 state.tabs.push(RepoTab::new_empty());
3182 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3183 state.active_tab = 0;
3184
3185 let _ = state.update(Message::SwitchTab(1));
3186
3187 assert_eq!(state.active_tab, 1);
3188 assert_eq!(
3189 state.active_tab().repo_path.as_deref(),
3190 Some(std::path::Path::new("/home/user/repo-b"))
3191 );
3192 }
3193
3194 #[test]
3195 fn switch_tab_to_same_index_is_noop() {
3196 use crate::message::Message;
3197 let mut state = GitKraft::new();
3198 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3199 state.tabs.push(RepoTab::new_empty());
3200 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3201 state.active_tab = 1;
3202
3203 let _ = state.update(Message::SwitchTab(1));
3204
3205 assert_eq!(state.active_tab, 1, "should remain on tab 1");
3206 }
3207
3208 #[test]
3209 fn switch_tab_out_of_bounds_is_noop() {
3210 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(99));
3219
3220 assert_eq!(state.active_tab, 0, "out-of-bounds switch must be ignored");
3221 }
3222
3223 #[test]
3226 fn session_state_matches_open_tab_paths_when_no_empty_tabs() {
3227 let mut state = GitKraft::new();
3228 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3229 state.tabs.push(RepoTab::new_empty());
3230 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3231 state.active_tab = 1;
3232
3233 let (paths, active) = state.session_state();
3234 assert_eq!(paths.len(), 2);
3235 assert_eq!(active, 1);
3236 }
3237
3238 #[test]
3239 fn session_state_adjusts_index_when_empty_tab_before_active() {
3240 let mut state = GitKraft::new();
3243 state.tabs.push(RepoTab::new_empty());
3245 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
3246 state.tabs.push(RepoTab::new_empty());
3247 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-b");
3248 state.active_tab = 2;
3249
3250 let (paths, active) = state.session_state();
3251 assert_eq!(paths.len(), 2);
3252 assert_eq!(active, 1, "session index must skip the empty tab at 0");
3253 }
3254
3255 #[test]
3256 fn session_state_defaults_to_zero_when_active_is_empty_tab() {
3257 let mut state = GitKraft::new();
3258 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3259 state.tabs.push(RepoTab::new_empty()); state.active_tab = 1; let (paths, active) = state.session_state();
3263 assert_eq!(paths.len(), 1);
3264 assert_eq!(active, 0, "empty active tab should default to 0");
3265 }
3266
3267 #[test]
3270 fn repo_selected_sets_repo_path_before_load() {
3271 use crate::message::Message;
3275 let mut state = GitKraft::new();
3276 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3277 state.tabs.push(RepoTab::new_empty());
3279 state.active_tab = 1;
3280
3281 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
3282 "/home/user/repo-b",
3283 ))));
3284
3285 assert_eq!(
3287 state.tabs[1].repo_path.as_deref(),
3288 Some(std::path::Path::new("/home/user/repo-b")),
3289 "repo_path must be set before async load starts"
3290 );
3291 }
3292
3293 #[test]
3294 fn open_recent_repo_sets_repo_path_before_load() {
3295 use crate::message::Message;
3296 let mut state = GitKraft::new();
3297 assert!(!state.active_tab().has_repo());
3299
3300 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
3301 "/home/user/repo-a",
3302 )));
3303
3304 assert_eq!(
3305 state.active_tab().repo_path.as_deref(),
3306 Some(std::path::Path::new("/home/user/repo-a")),
3307 "repo_path must be set before async load starts"
3308 );
3309 }
3310
3311 #[test]
3312 fn handle_repo_loaded_targets_correct_tab_after_switch() {
3313 use crate::message::Message;
3317 let mut state = GitKraft::new();
3318 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3320 state.tabs.push(RepoTab::new_empty());
3322 state.tabs[1].repo_path = Some(std::path::PathBuf::from("/home/user/repo-b"));
3323 state.tabs[1].is_loading = true;
3324
3325 state.active_tab = 0;
3327
3328 let payload = fake_payload("/home/user/repo-b");
3330 let _ = state.update(Message::RepoOpened(Ok(payload)));
3331
3332 assert!(
3334 state.tabs[1].repo_info.is_some(),
3335 "repo-b payload must be written to tab 1"
3336 );
3337 assert_eq!(
3338 state.tabs[1].repo_path.as_deref(),
3339 Some(std::path::Path::new("/home/user/repo-b")),
3340 );
3341 assert_eq!(
3343 state.tabs[0].repo_path.as_deref(),
3344 Some(std::path::Path::new("/home/user/repo-a")),
3345 );
3346 }
3347
3348 #[test]
3351 fn apply_payload_does_not_reset_is_loading_more_commits() {
3352 use crate::message::Message;
3357 let mut state = GitKraft::new();
3358 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3359
3360 state.active_tab_mut().is_loading_more_commits = true;
3362
3363 let payload = fake_payload("/home/user/repo-a");
3365 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3366
3367 assert!(
3369 state.active_tab().is_loading_more_commits,
3370 "refresh must not reset is_loading_more_commits"
3371 );
3372 }
3373
3374 #[test]
3375 fn apply_payload_resets_is_loading_more_commits_for_new_open() {
3376 let mut state = GitKraft::new();
3381 assert!(!state.active_tab().is_loading_more_commits);
3382
3383 let payload = fake_payload("/home/user/repo-a");
3384 let _ = state.update(crate::message::Message::RepoOpened(Ok(payload)));
3385
3386 assert!(!state.active_tab().is_loading_more_commits);
3387 }
3388
3389 #[test]
3390 fn refresh_does_not_shrink_commit_list() {
3391 use crate::message::Message;
3394 let mut state = GitKraft::new();
3395 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3396
3397 let tab = state.active_tab_mut();
3399 tab.commits = make_test_commits(500);
3400 for i in 0..200 {
3401 let mut c = tab.commits[0].clone();
3402 c.oid = format!("extra_{i:04}");
3403 tab.commits.push(c);
3404 }
3405 let extended_len = tab.commits.len();
3406 tab.has_more_commits = true;
3407
3408 let mut payload = fake_payload("/home/user/repo-a");
3410 payload.commits = make_test_commits(500);
3411 payload.graph_rows = gitkraft_core::features::graph::build_graph(&payload.commits);
3412 let payload_len = payload.commits.len();
3413 assert!(
3414 payload_len < extended_len,
3415 "payload must be shorter for this test"
3416 );
3417
3418 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3419
3420 assert_eq!(
3421 state.active_tab().commits.len(),
3422 extended_len,
3423 "refresh must not shrink the commit list"
3424 );
3425 }
3426
3427 #[test]
3428 fn refresh_replaces_commits_when_count_equal_or_greater() {
3429 use crate::message::Message;
3432 let mut state = GitKraft::new();
3433 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3434 state.active_tab_mut().commits = make_test_commits(5);
3435
3436 let original_oid = state.active_tab().commits[0].oid.clone();
3437
3438 let mut payload = fake_payload("/home/user/repo-a");
3440 let mut new_commits = make_test_commits(5);
3441 new_commits[0].oid = "refreshed_oid_0000".to_string();
3442 payload.commits = new_commits;
3443 payload.graph_rows = gitkraft_core::features::graph::build_graph(&payload.commits);
3444 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3445
3446 assert_eq!(
3447 state.active_tab().commits[0].oid,
3448 "refreshed_oid_0000",
3449 "refresh with same count must replace commits"
3450 );
3451 assert_ne!(state.active_tab().commits[0].oid, original_oid);
3452 }
3453
3454 #[test]
3455 fn refresh_rebuilds_commit_display_when_content_changes_at_same_count() {
3456 use crate::message::Message;
3461 let mut state = GitKraft::new();
3462 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3463 let mut initial_commits = make_test_commits(5);
3464 initial_commits[0].author_name = "Alice".to_string();
3465 state.active_tab_mut().commits = initial_commits;
3466 state.active_tab_mut().commit_display = state
3468 .active_tab()
3469 .commits
3470 .iter()
3471 .map(|c| ("1 day ago".to_string(), c.author_name.clone()))
3472 .collect();
3473 assert_eq!(state.active_tab().commit_display[0].1, "Alice");
3474
3475 let mut payload = fake_payload("/home/user/repo-a");
3477 let mut new_commits = make_test_commits(5);
3478 new_commits[0].author_name = "Bob".to_string();
3479 payload.commits = new_commits;
3480 payload.graph_rows = gitkraft_core::features::graph::build_graph(&payload.commits);
3481 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3482
3483 assert_eq!(
3485 state.active_tab().commit_display[0].1,
3486 "Bob",
3487 "commit_display must be rebuilt when content changes at same count"
3488 );
3489 }
3490
3491 #[test]
3492 fn duplicate_more_commits_loaded_sets_has_more_false() {
3493 use crate::message::{CommitPage, Message};
3499 let mut state = GitKraft::new();
3500 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3501 let tab = state.active_tab_mut();
3502 tab.commits = make_test_commits(10);
3503 tab.has_more_commits = true;
3504
3505 let _initial_count = tab.commits.len();
3507 let mut bigger_commits = tab.commits.clone();
3508 for i in 0..5 {
3509 let mut c = bigger_commits[0].clone();
3510 c.oid = format!("new_{i:04}");
3511 bigger_commits.push(c);
3512 }
3513 let bigger_count = bigger_commits.len();
3514 let graph_rows = gitkraft_core::features::graph::build_graph(&bigger_commits);
3515 let _ = state.update(Message::MoreCommitsLoaded(Ok(CommitPage {
3516 commits: bigger_commits.clone(),
3517 graph_rows: graph_rows.clone(),
3518 })));
3519 assert!(
3520 state.active_tab().has_more_commits,
3521 "first result should keep has_more true"
3522 );
3523 assert_eq!(state.active_tab().commits.len(), bigger_count);
3524
3525 let _ = state.update(Message::MoreCommitsLoaded(Ok(CommitPage {
3527 commits: bigger_commits,
3528 graph_rows,
3529 })));
3530 assert!(
3531 !state.active_tab().has_more_commits,
3532 "identical second result must set has_more_commits false"
3533 );
3534 }
3535
3536 #[test]
3537 fn file_system_changed_allowed_during_pagination() {
3538 use crate::message::Message;
3543 let mut state = GitKraft::new();
3544 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3545 state.active_tab_mut().is_loading_more_commits = true;
3546
3547 let _task = state.update(Message::FileSystemChanged);
3548
3549 assert!(state.active_tab().is_loading_more_commits);
3551 }
3554
3555 #[test]
3556 fn has_more_commits_preserved_across_refresh() {
3557 use crate::message::Message;
3559 let mut state = GitKraft::new();
3560 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3561 state.active_tab_mut().has_more_commits = true;
3562
3563 let payload = fake_payload("/home/user/repo-a");
3564 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3565
3566 assert!(
3567 state.active_tab().has_more_commits,
3568 "refresh must preserve has_more_commits"
3569 );
3570 }
3571
3572 #[test]
3573 fn switch_to_existing_tab_removes_empty_tab() {
3574 let mut state = GitKraft::new();
3575 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3577 state.tabs.push(RepoTab::new_empty());
3579 state.active_tab = 1;
3580
3581 let found = state.switch_to_existing_tab(std::path::Path::new("/home/user/repo-a"));
3582
3583 assert!(found, "should find existing tab");
3584 assert_eq!(state.tabs.len(), 1, "empty tab should be removed");
3585 assert_eq!(state.active_tab, 0);
3586 assert_eq!(
3587 state.active_tab().repo_path.as_deref(),
3588 Some(std::path::Path::new("/home/user/repo-a")),
3589 );
3590 }
3591
3592 #[test]
3593 fn switch_to_existing_tab_returns_false_when_not_found() {
3594 let mut state = GitKraft::new();
3595 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3596
3597 let found = state.switch_to_existing_tab(std::path::Path::new("/home/user/repo-b"));
3598
3599 assert!(!found, "should not find nonexistent tab");
3600 assert_eq!(state.tabs.len(), 1);
3601 }
3602
3603 #[test]
3604 fn switch_to_existing_tab_keeps_loaded_current_tab() {
3605 let mut state = GitKraft::new();
3606 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3608 state.tabs.push(RepoTab::new_empty());
3610 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3611 state.active_tab = 1;
3612
3613 let found = state.switch_to_existing_tab(std::path::Path::new("/home/user/repo-a"));
3614
3615 assert!(found);
3616 assert_eq!(state.tabs.len(), 2, "loaded tab should not be removed");
3617 assert_eq!(state.active_tab, 0);
3618 }
3619
3620 #[test]
3621 fn default_remote_returns_first_remote() {
3622 let mut tab = RepoTab::new_empty();
3623 tab.remotes = vec![
3624 gitkraft_core::RemoteInfo {
3625 name: "upstream".to_string(),
3626 url: Some("https://example.com/upstream.git".to_string()),
3627 fetch_refspecs: vec![],
3628 },
3629 gitkraft_core::RemoteInfo {
3630 name: "origin".to_string(),
3631 url: Some("https://example.com/origin.git".to_string()),
3632 fetch_refspecs: vec![],
3633 },
3634 ];
3635 assert_eq!(tab.default_remote(), "upstream");
3636 }
3637
3638 #[test]
3639 fn default_remote_fallback_to_origin() {
3640 let tab = RepoTab::new_empty();
3641 assert_eq!(tab.default_remote(), "origin");
3642 }
3643
3644 #[test]
3645 fn hovered_commit_defaults_to_none() {
3646 let tab = RepoTab::new_empty();
3647 assert_eq!(tab.hovered_commit, None);
3648 }
3649
3650 #[test]
3651 fn hover_commit_message_sets_state() {
3652 use crate::message::Message;
3653 let mut state = GitKraft::new();
3654 let _task = state.update(Message::HoverCommit(Some(5)));
3655 assert_eq!(state.active_tab().hovered_commit, Some(5));
3656 let _task = state.update(Message::HoverCommit(None));
3657 assert_eq!(state.active_tab().hovered_commit, None);
3658 }
3659
3660 #[test]
3661 fn branch_context_defaults_empty() {
3662 let tab = RepoTab::new_empty();
3663 assert!(tab.branch_context.is_empty());
3664 }
3665
3666 #[test]
3667 fn ghost_shows_on_selected_commit_without_refs() {
3668 let mut state = GitKraft::new();
3672 let tab = state.active_tab_mut();
3673 tab.repo_path = Some(std::path::PathBuf::from("/tmp/test"));
3674 tab.commits = vec![
3675 gitkraft_core::CommitInfo {
3676 oid: "aaa".into(),
3677 summary: "tip".into(),
3678 message: String::new(),
3679 author_name: "A".into(),
3680 author_email: String::new(),
3681 time: Default::default(),
3682 parent_ids: Vec::new(),
3683 refs: vec![gitkraft_core::RefLabel {
3684 name: "main".to_string(),
3685 kind: gitkraft_core::RefKind::Head,
3686 }],
3687 },
3688 gitkraft_core::CommitInfo {
3689 oid: "bbb".into(),
3690 summary: "child".into(),
3691 message: String::new(),
3692 author_name: "A".into(),
3693 author_email: String::new(),
3694 time: Default::default(),
3695 parent_ids: Vec::new(),
3696 refs: Vec::new(),
3697 },
3698 ];
3699 tab.graph_rows = vec![
3700 gitkraft_core::GraphRow {
3701 width: 1,
3702 node_column: 0,
3703 node_color: 0,
3704 edges: Vec::new(),
3705 },
3706 gitkraft_core::GraphRow {
3707 width: 1,
3708 node_column: 0,
3709 node_color: 0,
3710 edges: Vec::new(),
3711 },
3712 ];
3713 tab.branch_context = vec![None, Some("main".to_string())];
3714 tab.selected_commit = Some(1);
3715
3716 let tab = state.active_tab();
3718 assert_eq!(tab.selected_commit, Some(1));
3719 assert_eq!(tab.branch_context[1], Some("main".to_string()));
3720 assert_eq!(tab.branch_context[0], None);
3722 }
3723}