1use std::collections::HashSet;
2use std::path::PathBuf;
3
4use gitkraft_core::*;
5use iced::{Color, Point, Task};
6
7use crate::message::Message;
8use crate::theme::ThemeColors;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum DragTarget {
15 SidebarRight,
17 CommitLogRight,
19 DiffFileListRight,
22 StagingUnstagedRight,
24 StagingStagedRight,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum DragTargetH {
31 StagingTop,
33 SidebarBranchesBottom,
35 SidebarStashBottom,
37}
38
39#[derive(Debug, Clone)]
41pub enum ContextMenu {
42 Branch {
44 name: String,
45 is_current: bool,
46 local_index: usize,
49 },
50 RemoteBranch { name: String },
52 Commit { index: usize, oid: String },
54 Stash { index: usize },
56 UnstagedFile { path: String },
58 StagedFile { path: String },
60 CommitFile { oid: String, file_path: String },
62}
63
64pub struct RepoTab {
68 pub repo_path: Option<PathBuf>,
71 pub repo_info: Option<RepoInfo>,
73
74 pub branches: Vec<BranchInfo>,
77 pub current_branch: Option<String>,
79
80 pub commits: Vec<CommitInfo>,
83 pub selected_commit: Option<usize>,
85 pub anchor_commit_index: Option<usize>,
87 pub selected_commits: Vec<usize>,
89 pub graph_rows: Vec<gitkraft_core::GraphRow>,
91
92 pub unstaged_changes: Vec<DiffInfo>,
95 pub staged_changes: Vec<DiffInfo>,
97 pub commit_files: Vec<gitkraft_core::DiffFileEntry>,
99 pub selected_commit_oid: Option<String>,
101 pub selected_file_index: Option<usize>,
103 pub is_loading_file_diff: bool,
105 pub anchor_file_index: Option<usize>,
108 pub selected_commit_file_indices: Vec<usize>,
111 pub multi_file_diffs: Vec<gitkraft_core::DiffInfo>,
113 pub commit_range_diffs: Vec<gitkraft_core::DiffInfo>,
116 pub selected_diff: Option<DiffInfo>,
118 pub commit_message: String,
120
121 pub stashes: Vec<StashEntry>,
124
125 pub remotes: Vec<RemoteInfo>,
128
129 pub show_commit_detail: bool,
132 pub new_branch_name: String,
134 pub show_branch_create: bool,
136 pub local_branches_expanded: bool,
138 pub remote_branches_expanded: bool,
140 pub stash_message: String,
142
143 pub selected_unstaged: std::collections::HashSet<String>,
145 pub selected_staged: std::collections::HashSet<String>,
147 pub anchor_unstaged_index: Option<usize>,
149 pub anchor_staged_index: Option<usize>,
151
152 pub pending_discard: Option<String>,
154
155 pub status_message: Option<String>,
158 pub error_message: Option<String>,
160 pub is_loading: bool,
162 pub context_menu_pos: (f32, f32),
165
166 pub context_menu: Option<ContextMenu>,
168 pub rename_branch_target: Option<String>,
170 pub rename_branch_input: String,
172
173 pub create_tag_target_oid: Option<String>,
175 pub create_tag_annotated: bool,
177 pub create_tag_name: String,
179 pub create_tag_message: String,
181 pub create_branch_at_oid: Option<String>,
183
184 pub commit_scroll_offset: f32,
188
189 pub diff_scroll_offset: f32,
191 pub commit_display: Vec<(String, String, String)>,
195
196 pub has_more_commits: bool,
198 pub is_loading_more_commits: bool,
200
201 pub file_history_path: Option<String>,
203 pub file_history_commits: Vec<gitkraft_core::CommitInfo>,
205 pub file_history_scroll: f32,
207
208 pub blame_path: Option<String>,
210 pub blame_lines: Vec<gitkraft_core::BlameLine>,
212 pub blame_scroll: f32,
214
215 pub pending_delete_file: Option<String>,
217}
218
219impl RepoTab {
220 pub fn new_empty() -> Self {
222 Self {
223 repo_path: None,
224 repo_info: None,
225 branches: Vec::new(),
226 current_branch: None,
227 commits: Vec::new(),
228 selected_commit: None,
229 anchor_commit_index: None,
230 selected_commits: Vec::new(),
231 graph_rows: Vec::new(),
232 unstaged_changes: Vec::new(),
233 staged_changes: Vec::new(),
234 commit_files: Vec::new(),
235 selected_commit_oid: None,
236 selected_file_index: None,
237 is_loading_file_diff: false,
238 anchor_file_index: None,
239 selected_commit_file_indices: Vec::new(),
240 multi_file_diffs: Vec::new(),
241 commit_range_diffs: Vec::new(),
242 selected_diff: None,
243 commit_message: String::new(),
244 stashes: Vec::new(),
245 remotes: Vec::new(),
246 show_commit_detail: false,
247 new_branch_name: String::new(),
248 show_branch_create: false,
249 local_branches_expanded: true,
250 remote_branches_expanded: true,
251 stash_message: String::new(),
252 selected_unstaged: std::collections::HashSet::new(),
253 selected_staged: std::collections::HashSet::new(),
254 anchor_unstaged_index: None,
255 anchor_staged_index: None,
256 pending_discard: None,
257 status_message: None,
258 error_message: None,
259 is_loading: false,
260 context_menu: None,
261 context_menu_pos: (0.0, 0.0),
262 rename_branch_target: None,
263 rename_branch_input: String::new(),
264 create_tag_target_oid: None,
265 create_tag_annotated: false,
266 create_tag_name: String::new(),
267 create_tag_message: String::new(),
268 create_branch_at_oid: None,
269 commit_scroll_offset: 0.0,
270 diff_scroll_offset: 0.0,
271 commit_display: Vec::new(),
272 has_more_commits: true,
273 is_loading_more_commits: false,
274 file_history_path: None,
275 file_history_commits: Vec::new(),
276 file_history_scroll: 0.0,
277 blame_path: None,
278 blame_lines: Vec::new(),
279 blame_scroll: 0.0,
280 pending_delete_file: None,
281 }
282 }
283
284 pub fn has_repo(&self) -> bool {
286 self.repo_path.is_some()
287 }
288
289 pub fn display_name(&self) -> String {
291 gitkraft_core::repo_display_name(self.repo_path.as_deref())
292 }
293
294 pub fn apply_payload(
300 &mut self,
301 payload: crate::message::RepoPayload,
302 path: std::path::PathBuf,
303 ) {
304 let prev_oid = self.selected_commit_oid.clone();
306
307 let prev_anchor_oid = self
311 .anchor_commit_index
312 .and_then(|i| self.commits.get(i).map(|c| c.oid.clone()));
313 let prev_selected_oids: Vec<String> = self
314 .selected_commits
315 .iter()
316 .filter_map(|&i| self.commits.get(i).map(|c| c.oid.clone()))
317 .collect();
318
319 let prev_anchor_file = self.anchor_file_index;
323 let prev_file_indices = self.selected_commit_file_indices.clone();
324 let prev_multi_file_diffs = std::mem::take(&mut self.multi_file_diffs);
325 let prev_commit_range_diffs = std::mem::take(&mut self.commit_range_diffs);
326
327 let prev_selected_unstaged = std::mem::take(&mut self.selected_unstaged);
330 let prev_selected_staged = std::mem::take(&mut self.selected_staged);
331 let prev_anchor_unstaged = self.anchor_unstaged_index;
332 let prev_anchor_staged = self.anchor_staged_index;
333
334 let prev_selected_diff = self.selected_diff.take();
337 let prev_diff_scroll = self.diff_scroll_offset;
338
339 self.current_branch = payload.info.head_branch.clone();
340 self.repo_path = Some(path);
341 self.repo_info = Some(payload.info);
342 self.branches = payload.branches;
343 self.commits = payload.commits;
344 self.graph_rows = payload.graph_rows;
345 self.unstaged_changes = payload.unstaged;
346 self.staged_changes = payload.staged;
347 self.stashes = payload.stashes;
348 self.remotes = payload.remotes;
349
350 self.selected_commit = None;
357 self.anchor_commit_index = None;
358 self.selected_commits.clear();
359 self.selected_commit_oid = None;
360 self.error_message = None;
361 self.status_message = Some("Repository loaded.".into());
362 self.commit_scroll_offset = 0.0;
363 self.has_more_commits = true;
364 self.is_loading_more_commits = false;
365 self.anchor_unstaged_index = None;
366 self.anchor_staged_index = None;
367 self.anchor_file_index = None;
368 self.selected_commit_file_indices.clear();
369
370 if let Some(oid) = prev_oid {
376 if let Some(new_idx) = self.commits.iter().position(|c| c.oid == oid) {
377 self.selected_commit = Some(new_idx);
378 self.selected_commit_oid = Some(oid);
379 self.selected_diff = prev_selected_diff;
386 self.diff_scroll_offset = prev_diff_scroll;
387
388 self.anchor_file_index = prev_anchor_file;
391 self.selected_commit_file_indices = prev_file_indices;
392 self.multi_file_diffs = prev_multi_file_diffs;
393 self.commit_range_diffs = prev_commit_range_diffs;
394 } else {
395 self.clear_diff_state();
397 }
398 } else {
399 if prev_selected_diff.is_some() {
402 self.selected_diff = prev_selected_diff;
403 self.diff_scroll_offset = prev_diff_scroll;
404 } else {
405 self.clear_diff_state();
406 }
407 }
408
409 if let Some(anchor_oid) = prev_anchor_oid {
413 if let Some(new_anchor) = self.commits.iter().position(|c| c.oid == anchor_oid) {
414 self.anchor_commit_index = Some(new_anchor);
415 }
416 }
417 if !prev_selected_oids.is_empty() {
418 let restored: Vec<usize> = prev_selected_oids
419 .iter()
420 .filter_map(|oid| self.commits.iter().position(|c| &c.oid == oid))
421 .collect();
422 if !restored.is_empty() {
423 self.selected_commits = restored;
424 self.selected_commits.sort_unstable();
425 }
426 }
427
428 self.selected_unstaged = prev_selected_unstaged
431 .into_iter()
432 .filter(|p| self.unstaged_changes.iter().any(|d| d.display_path() == p))
433 .collect();
434 self.selected_staged = prev_selected_staged
435 .into_iter()
436 .filter(|p| self.staged_changes.iter().any(|d| d.display_path() == p))
437 .collect();
438 self.anchor_unstaged_index =
440 prev_anchor_unstaged.filter(|&i| i < self.unstaged_changes.len());
441 self.anchor_staged_index = prev_anchor_staged.filter(|&i| i < self.staged_changes.len());
442 }
443
444 fn clear_diff_state(&mut self) {
447 self.selected_diff = None;
448 self.commit_files.clear();
449 self.selected_file_index = None;
450 self.is_loading_file_diff = false;
451 self.diff_scroll_offset = 0.0;
452 }
453}
454
455pub struct GitKraft {
459 pub tabs: Vec<RepoTab>,
462 pub active_tab: usize,
464
465 pub sidebar_expanded: bool,
468
469 pub sidebar_width: f32,
472 pub commit_log_width: f32,
474 pub staging_height: f32,
476 pub diff_file_list_width: f32,
478 pub sidebar_branches_ratio: f32,
480 pub sidebar_stash_ratio: f32,
483 pub staging_unstaged_ratio: f32,
485 pub staging_staged_ratio: f32,
488
489 pub ui_scale: f32,
491
492 pub dragging: Option<DragTarget>,
495 pub dragging_h: Option<DragTargetH>,
497 pub drag_start_x: f32,
499 pub drag_start_y: f32,
501 pub drag_initialized: bool,
505 pub drag_initialized_h: bool,
507
508 pub cursor_pos: Point,
513
514 pub current_theme_index: usize,
517
518 pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
521
522 pub search_visible: bool,
525 pub search_query: String,
527 pub search_results: Vec<gitkraft_core::CommitInfo>,
529 pub search_selected: Option<usize>,
531
532 pub search_diff_files: Vec<gitkraft_core::DiffFileEntry>,
534 pub search_diff_selected: HashSet<usize>,
536 pub search_diff_content: Vec<gitkraft_core::DiffInfo>,
538 pub search_diff_oid: Option<String>,
540
541 pub editor: gitkraft_core::Editor,
543
544 pub keyboard_modifiers: iced::keyboard::Modifiers,
546
547 pub animation_tick: u64,
550
551 pub window_width: f32,
554 pub window_height: f32,
556 pub window_x: f32,
558 pub window_y: f32,
560}
561
562impl Default for GitKraft {
563 fn default() -> Self {
564 Self::new()
565 }
566}
567
568impl GitKraft {
569 fn from_settings(settings: gitkraft_core::AppSettings) -> Self {
575 let current_theme_index = settings
576 .theme_name
577 .as_deref()
578 .map(gitkraft_core::theme_index_by_name)
579 .unwrap_or(0);
580
581 let recent_repos = settings.recent_repos;
582
583 let (
584 sidebar_width,
585 commit_log_width,
586 staging_height,
587 diff_file_list_width,
588 sidebar_expanded,
589 ui_scale,
590 ) = if let Some(ref layout) = settings.layout {
591 (
592 layout.sidebar_width.unwrap_or(220.0),
593 layout.commit_log_width.unwrap_or(500.0),
594 layout.staging_height.unwrap_or(200.0),
595 layout.diff_file_list_width.unwrap_or(180.0),
596 layout.sidebar_expanded.unwrap_or(true),
597 layout.ui_scale.unwrap_or(1.0),
598 )
599 } else {
600 (220.0, 500.0, 200.0, 180.0, true, 1.0)
601 };
602
603 Self {
604 tabs: vec![RepoTab::new_empty()],
605 active_tab: 0,
606
607 sidebar_expanded,
608
609 sidebar_width,
610 commit_log_width,
611 staging_height,
612 diff_file_list_width,
613 sidebar_branches_ratio: 0.6,
614 sidebar_stash_ratio: 0.25,
615 staging_unstaged_ratio: 0.4,
616 staging_staged_ratio: 0.3,
617
618 ui_scale,
619
620 dragging: None,
621 dragging_h: None,
622 drag_start_x: 0.0,
623 drag_start_y: 0.0,
624 drag_initialized: false,
625 drag_initialized_h: false,
626 cursor_pos: Point::ORIGIN,
627
628 current_theme_index,
629
630 recent_repos,
631
632 search_visible: false,
633 search_query: String::new(),
634 search_results: Vec::new(),
635 search_selected: None,
636 search_diff_files: Vec::new(),
637 search_diff_selected: HashSet::new(),
638 search_diff_content: Vec::new(),
639 search_diff_oid: None,
640
641 keyboard_modifiers: iced::keyboard::Modifiers::default(),
642 animation_tick: 0,
643
644 window_width: settings
645 .layout
646 .as_ref()
647 .and_then(|l| l.window_width)
648 .unwrap_or(1400.0),
649 window_height: settings
650 .layout
651 .as_ref()
652 .and_then(|l| l.window_height)
653 .unwrap_or(800.0),
654 window_x: settings
655 .layout
656 .as_ref()
657 .and_then(|l| l.window_x)
658 .unwrap_or(0.0),
659 window_y: settings
660 .layout
661 .as_ref()
662 .and_then(|l| l.window_y)
663 .unwrap_or(0.0),
664
665 editor: settings
666 .editor_name
667 .as_deref()
668 .map(|name| {
669 gitkraft_core::EDITOR_NAMES
671 .iter()
672 .position(|n| n.eq_ignore_ascii_case(name))
673 .map(gitkraft_core::Editor::from_index)
674 .unwrap_or_else(|| {
675 if name.eq_ignore_ascii_case("none") {
676 gitkraft_core::Editor::None
677 } else {
678 gitkraft_core::Editor::Custom(name.to_string())
679 }
680 })
681 })
682 .unwrap_or_else(detect_system_editor),
683 }
684 }
685
686 pub fn new() -> Self {
692 Self::from_settings(
693 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default(),
694 )
695 }
696
697 pub fn new_with_session_paths() -> (Self, Vec<PathBuf>) {
703 let settings =
704 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default();
705 let open_tabs = settings.open_tabs.clone();
706 let active_tab_index = settings.active_tab_index;
707
708 let mut state = Self::from_settings(settings);
709
710 if !open_tabs.is_empty() {
711 state.tabs = open_tabs
712 .iter()
713 .map(|path| {
714 let mut tab = RepoTab::new_empty();
715 tab.repo_path = Some(path.clone());
718 if path.exists() {
719 tab.is_loading = true;
720 tab.status_message = Some(format!(
721 "Loading {}…",
722 path.file_name().unwrap_or_default().to_string_lossy()
723 ));
724 } else {
725 tab.error_message =
726 Some(format!("Repository not found: {}", path.display()));
727 }
728 tab
729 })
730 .collect();
731 state.active_tab = active_tab_index.min(state.tabs.len().saturating_sub(1));
732 }
733
734 (state, open_tabs)
735 }
736
737 pub fn open_tab_paths(&self) -> Vec<PathBuf> {
740 self.tabs
741 .iter()
742 .filter(|t| t.repo_info.is_some())
743 .filter_map(|t| t.repo_path.clone())
744 .collect()
745 }
746
747 pub fn active_tab(&self) -> &RepoTab {
749 &self.tabs[self.active_tab]
750 }
751
752 pub fn active_tab_mut(&mut self) -> &mut RepoTab {
754 &mut self.tabs[self.active_tab]
755 }
756
757 pub fn dismiss_menu(&mut self) {
759 self.active_tab_mut().context_menu = None;
760 }
761
762 pub fn open_context_menu(&mut self, menu: ContextMenu) {
764 let pos = (self.cursor_pos.x, self.cursor_pos.y);
765 let tab = self.active_tab_mut();
766 tab.context_menu_pos = pos;
767 tab.context_menu = Some(menu);
768 }
769
770 pub fn has_repo(&self) -> bool {
772 self.active_tab().has_repo()
773 }
774
775 pub fn repo_display_name(&self) -> String {
777 self.active_tab().display_name()
778 }
779
780 pub fn colors(&self) -> ThemeColors {
787 ThemeColors::from_core(&gitkraft_core::theme_by_index(self.current_theme_index))
788 }
789
790 pub fn iced_theme(&self) -> iced::Theme {
799 let core = gitkraft_core::theme_by_index(self.current_theme_index);
800 let name = self.current_theme_name().to_string();
801
802 let palette = iced::theme::Palette {
803 background: rgb_to_iced(core.background),
804 text: rgb_to_iced(core.text_primary),
805 primary: rgb_to_iced(core.accent),
806 success: rgb_to_iced(core.success),
807 warning: rgb_to_iced(core.warning),
808 danger: rgb_to_iced(core.error),
809 };
810
811 iced::Theme::custom(name, palette)
812 }
813
814 pub fn current_theme_name(&self) -> &'static str {
816 gitkraft_core::THEME_NAMES
817 .get(self.current_theme_index)
818 .copied()
819 .unwrap_or("Default")
820 }
821
822 pub fn refresh_active_tab(&mut self) -> Task<Message> {
826 match self.active_tab().repo_path.clone() {
827 Some(path) => crate::features::repo::commands::refresh_repo(path),
828 None => Task::none(),
829 }
830 }
831
832 pub fn on_ok_refresh(
839 &mut self,
840 result: Result<(), String>,
841 ok_msg: &str,
842 err_prefix: &str,
843 ) -> Task<Message> {
844 match result {
845 Ok(()) => {
846 {
847 let tab = self.active_tab_mut();
848 tab.is_loading = false;
849 tab.status_message = Some(ok_msg.to_string());
850 }
851 self.refresh_active_tab()
852 }
853 Err(e) => {
854 let tab = self.active_tab_mut();
855 tab.is_loading = false;
856 tab.error_message = Some(format!("{err_prefix}: {e}"));
857 tab.status_message = None;
858 Task::none()
859 }
860 }
861 }
862
863 pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
865 gitkraft_core::LayoutSettings {
866 sidebar_width: Some(self.sidebar_width),
867 commit_log_width: Some(self.commit_log_width),
868 staging_height: Some(self.staging_height),
869 diff_file_list_width: Some(self.diff_file_list_width),
870 sidebar_expanded: Some(self.sidebar_expanded),
871 ui_scale: Some(self.ui_scale),
872 window_width: Some(self.window_width),
873 window_height: Some(self.window_height),
874 window_x: Some(self.window_x),
875 window_y: Some(self.window_y),
876 window_maximized: None, }
878 }
879}
880
881fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
883 Color::from_rgb8(rgb.r, rgb.g, rgb.b)
884}
885
886fn detect_system_editor() -> gitkraft_core::Editor {
888 for var in ["VISUAL", "EDITOR"] {
889 if let Ok(val) = std::env::var(var) {
890 let bin = val.split('/').next_back().unwrap_or(&val).trim();
891 return match bin {
892 "nvim" | "neovim" => gitkraft_core::Editor::Neovim,
893 "vim" => gitkraft_core::Editor::Vim,
894 "hx" | "helix" => gitkraft_core::Editor::Helix,
895 "nano" => gitkraft_core::Editor::Nano,
896 "micro" => gitkraft_core::Editor::Micro,
897 "emacs" => gitkraft_core::Editor::Emacs,
898 "code" => gitkraft_core::Editor::VSCode,
899 "zed" => gitkraft_core::Editor::Zed,
900 "subl" => gitkraft_core::Editor::Sublime,
901 _ => gitkraft_core::Editor::Custom(val),
902 };
903 }
904 }
905 gitkraft_core::Editor::None
906}
907
908#[cfg(test)]
911mod tests {
912 use super::*;
913
914 #[test]
915 fn new_defaults() {
916 let state = GitKraft::new();
917 assert!(state.active_tab().repo_path.is_none());
918 assert!(!state.has_repo());
919 assert_eq!(state.repo_display_name(), "New Tab");
920 assert!(state.active_tab().commits.is_empty());
921 assert!(state.sidebar_expanded);
922 assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
924 assert!(state.sidebar_width > 0.0);
926 assert!(state.commit_log_width > 0.0);
927 assert!(state.staging_height > 0.0);
928 assert!(state.dragging.is_none());
929 assert!(state.dragging_h.is_none());
930 assert_eq!(state.tabs.len(), 1);
932 assert_eq!(state.active_tab, 0);
933 }
934
935 #[test]
936 fn repo_display_name_extracts_basename() {
937 let mut state = GitKraft::new();
938 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
939 assert_eq!(state.repo_display_name(), "my-project");
940 }
941
942 #[test]
943 fn colors_returns_theme_colors() {
944 let state = GitKraft::new();
945 let c = state.colors();
946 assert!(c.bg.r < 0.5);
948 }
949
950 #[test]
951 fn iced_theme_is_custom_with_correct_palette() {
952 let mut state = GitKraft::new();
953
954 state.current_theme_index = 0;
956 let iced_t = state.iced_theme();
957 let pal = iced_t.palette();
958 assert!(pal.background.r < 0.5, "Default theme bg should be dark");
959 assert_eq!(iced_t.to_string(), "Default");
960
961 let sol_idx = gitkraft_core::theme_index_by_name("Solarized Light");
963 state.current_theme_index = sol_idx;
964 let iced_t = state.iced_theme();
965 let pal = iced_t.palette();
966 assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
967 assert_eq!(iced_t.to_string(), "Solarized Light");
968
969 let gruvbox_idx = gitkraft_core::theme_index_by_name("Gruvbox Dark");
971 state.current_theme_index = gruvbox_idx;
972 let iced_t = state.iced_theme();
973 let pal = iced_t.palette();
974 let core = gitkraft_core::theme_by_index(gruvbox_idx);
975 let expected_accent = rgb_to_iced(core.accent);
976 assert!(
977 (pal.primary.r - expected_accent.r).abs() < 0.01
978 && (pal.primary.g - expected_accent.g).abs() < 0.01
979 && (pal.primary.b - expected_accent.b).abs() < 0.01,
980 "Gruvbox Dark accent should match core accent"
981 );
982 }
983
984 #[test]
985 fn iced_theme_name_round_trips_through_core() {
986 for i in 0..gitkraft_core::THEME_COUNT {
989 let mut state = GitKraft::new();
990 state.current_theme_index = i;
991 let iced_t = state.iced_theme();
992 let name = iced_t.to_string();
993 let resolved = gitkraft_core::theme_index_by_name(&name);
994 assert_eq!(
995 resolved,
996 i,
997 "theme index {i} ({}) did not round-trip through iced_theme name",
998 gitkraft_core::THEME_NAMES[i]
999 );
1000 }
1001 }
1002
1003 #[test]
1004 fn current_theme_name_round_trips() {
1005 let mut state = GitKraft::new();
1006 state.current_theme_index = gitkraft_core::theme_index_by_name("Dracula");
1007 assert_eq!(state.current_theme_name(), "Dracula");
1008 state.current_theme_index = 0;
1009 assert_eq!(state.current_theme_name(), "Default");
1010 }
1011
1012 #[test]
1013 fn repo_tab_new_empty() {
1014 let tab = RepoTab::new_empty();
1015 assert!(tab.repo_path.is_none());
1016 assert!(!tab.has_repo());
1017 assert_eq!(tab.display_name(), "New Tab");
1018 assert!(tab.commits.is_empty());
1019 assert!(tab.branches.is_empty());
1020 assert!(!tab.is_loading);
1021 }
1022
1023 #[test]
1024 fn repo_tab_display_name_with_path() {
1025 let mut tab = RepoTab::new_empty();
1026 tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
1027 assert!(tab.has_repo());
1028 assert_eq!(tab.display_name(), "cool-repo");
1029 }
1030
1031 #[test]
1032 fn search_defaults() {
1033 let state = GitKraft::new();
1034 assert!(!state.search_visible);
1035 assert!(state.search_query.is_empty());
1036 assert!(state.search_results.is_empty());
1037 assert!(state.search_selected.is_none());
1038 }
1039
1040 #[test]
1041 fn context_menu_variants_exist() {
1042 use crate::state::ContextMenu;
1044
1045 let _branch = ContextMenu::Branch {
1046 name: "main".to_string(),
1047 is_current: true,
1048 local_index: 0,
1049 };
1050 let _remote = ContextMenu::RemoteBranch {
1051 name: "origin/main".to_string(),
1052 };
1053 let _commit = ContextMenu::Commit {
1054 index: 0,
1055 oid: "abc1234".to_string(),
1056 };
1057 let _stash = ContextMenu::Stash { index: 0 };
1058 let _unstaged = ContextMenu::UnstagedFile {
1059 path: "src/main.rs".to_string(),
1060 };
1061 let _staged = ContextMenu::StagedFile {
1062 path: "src/lib.rs".to_string(),
1063 };
1064 }
1065
1066 #[test]
1067 fn repo_tab_context_menu_defaults_to_none() {
1068 let tab = crate::state::RepoTab::new_empty();
1069 assert!(tab.context_menu.is_none());
1070 }
1071
1072 #[test]
1073 fn context_menu_variants_constructable() {
1074 use crate::state::ContextMenu;
1075 let _ = ContextMenu::Stash { index: 0 };
1076 let _ = ContextMenu::UnstagedFile {
1077 path: "a.rs".into(),
1078 };
1079 let _ = ContextMenu::StagedFile {
1080 path: "b.rs".into(),
1081 };
1082 }
1083
1084 #[test]
1085 fn selected_unstaged_defaults_empty() {
1086 let tab = crate::state::RepoTab::new_empty();
1087 assert!(tab.selected_unstaged.is_empty());
1088 assert!(tab.selected_staged.is_empty());
1089 }
1090
1091 #[test]
1092 fn selected_unstaged_toggle() {
1093 let mut tab = crate::state::RepoTab::new_empty();
1094 tab.selected_unstaged.insert("a.rs".to_string());
1095 tab.selected_unstaged.insert("b.rs".to_string());
1096 assert_eq!(tab.selected_unstaged.len(), 2);
1097 assert!(tab.selected_unstaged.contains("a.rs"));
1098 tab.selected_unstaged.remove("a.rs");
1099 assert_eq!(tab.selected_unstaged.len(), 1);
1100 assert!(!tab.selected_unstaged.contains("a.rs"));
1101 }
1102
1103 #[test]
1104 fn detect_system_editor_returns_valid() {
1105 let editor = super::detect_system_editor();
1107 let _ = editor.display_name();
1108 }
1109
1110 #[test]
1113 fn selected_commit_file_indices_defaults_to_empty_vec() {
1114 let tab = RepoTab::new_empty();
1115 assert!(tab.selected_commit_file_indices.is_empty());
1116 let v: &Vec<usize> = &tab.selected_commit_file_indices;
1118 assert_eq!(v.len(), 0);
1119 }
1120
1121 #[test]
1122 fn multi_file_diffs_defaults_empty() {
1123 let tab = RepoTab::new_empty();
1124 assert!(tab.multi_file_diffs.is_empty());
1125 }
1126
1127 #[test]
1128 fn keyboard_modifiers_default_has_no_shift() {
1129 let state = GitKraft::new();
1130 assert!(!state.keyboard_modifiers.shift());
1131 }
1132
1133 #[test]
1134 fn selected_commit_file_indices_preserves_insertion_order() {
1135 let mut tab = RepoTab::new_empty();
1136 tab.selected_commit_file_indices.push(5);
1137 tab.selected_commit_file_indices.push(2);
1138 tab.selected_commit_file_indices.push(8);
1139 assert_eq!(tab.selected_commit_file_indices, vec![5, 2, 8]);
1140 }
1141
1142 #[test]
1143 fn selected_commit_file_indices_cleared_on_reset() {
1144 let mut tab = RepoTab::new_empty();
1145 tab.selected_commit_file_indices.push(0);
1146 tab.selected_commit_file_indices.push(1);
1147 tab.selected_commit_file_indices.clear();
1148 assert!(tab.selected_commit_file_indices.is_empty());
1149 }
1150
1151 #[test]
1152 fn multi_file_diffs_cleared_on_reset() {
1153 let mut tab = RepoTab::new_empty();
1154 tab.multi_file_diffs.push(gitkraft_core::DiffInfo {
1155 old_file: String::new(),
1156 new_file: "a.rs".to_string(),
1157 status: gitkraft_core::FileStatus::Modified,
1158 hunks: vec![],
1159 });
1160 tab.multi_file_diffs.clear();
1161 assert!(tab.multi_file_diffs.is_empty());
1162 }
1163
1164 #[test]
1165 fn commit_range_diffs_defaults_empty() {
1166 let tab = RepoTab::new_empty();
1167 assert!(tab.commit_range_diffs.is_empty());
1168 }
1169
1170 #[test]
1171 fn commit_range_diffs_cleared_on_apply_payload() {
1172 let mut tab = RepoTab::new_empty();
1174 tab.commit_range_diffs.push(gitkraft_core::DiffInfo {
1175 old_file: String::new(),
1176 new_file: "x.rs".to_string(),
1177 status: gitkraft_core::FileStatus::Modified,
1178 hunks: vec![],
1179 });
1180 tab.commit_range_diffs.clear();
1181 assert!(tab.commit_range_diffs.is_empty());
1182 }
1183
1184 #[test]
1187 fn modifiers_changed_sets_shift_state() {
1188 use crate::message::Message;
1189 let mut state = GitKraft::new();
1190 assert!(!state.keyboard_modifiers.shift());
1191
1192 let _ = state.update(Message::ModifiersChanged(iced::keyboard::Modifiers::SHIFT));
1193 assert!(state.keyboard_modifiers.shift());
1194
1195 let _ = state.update(Message::ModifiersChanged(
1196 iced::keyboard::Modifiers::default(),
1197 ));
1198 assert!(!state.keyboard_modifiers.shift());
1199 }
1200
1201 fn make_commit_files(names: &[&str]) -> Vec<gitkraft_core::DiffFileEntry> {
1204 names
1205 .iter()
1206 .map(|name| gitkraft_core::DiffFileEntry {
1207 old_file: String::new(),
1208 new_file: name.to_string(),
1209 status: gitkraft_core::FileStatus::Modified,
1210 })
1211 .collect()
1212 }
1213
1214 #[test]
1215 fn select_diff_by_index_regular_click_clears_multi_selection() {
1216 use crate::message::Message;
1217 let mut state = GitKraft::new();
1218 state.active_tab_mut().repo_path =
1222 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1223 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1224 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1225 state.active_tab_mut().selected_commit_file_indices = vec![0, 1];
1227
1228 let _ = state.update(Message::SelectDiffByIndex(0));
1230
1231 assert!(state.active_tab().selected_commit_file_indices.is_empty());
1232 assert_eq!(state.active_tab().selected_file_index, Some(0));
1233 }
1234
1235 #[test]
1236 fn regular_click_preserves_multi_file_diffs_until_load_completes() {
1237 use crate::message::Message;
1238 let mut state = GitKraft::new();
1239 state.active_tab_mut().repo_path =
1240 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1241 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1242 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1243
1244 state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1246 old_file: String::new(),
1247 new_file: "a.rs".to_string(),
1248 status: gitkraft_core::FileStatus::Modified,
1249 hunks: vec![],
1250 }];
1251
1252 let _ = state.update(Message::SelectDiffByIndex(1));
1255
1256 assert!(
1257 !state.active_tab().multi_file_diffs.is_empty(),
1258 "multi_file_diffs must NOT be cleared on click (deferred to diff load)"
1259 );
1260 assert_eq!(state.active_tab().selected_file_index, Some(1));
1261 assert!(state.active_tab().is_loading_file_diff);
1262 }
1263
1264 #[test]
1265 fn regular_click_preserves_diff_scroll_until_load_completes() {
1266 use crate::message::Message;
1267 let mut state = GitKraft::new();
1268 state.active_tab_mut().repo_path =
1269 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1270 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1271 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1272 state.active_tab_mut().diff_scroll_offset = 150.0;
1273
1274 let _ = state.update(Message::SelectDiffByIndex(1));
1276
1277 assert_eq!(
1278 state.active_tab().diff_scroll_offset,
1279 150.0,
1280 "diff_scroll_offset must NOT be reset on click (deferred to diff load)"
1281 );
1282 }
1283
1284 #[test]
1285 fn single_file_diff_loaded_clears_deferred_state() {
1286 use crate::message::Message;
1287 let mut state = GitKraft::new();
1288 state.active_tab_mut().repo_path =
1289 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1290 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1291 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1292
1293 state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1295 old_file: String::new(),
1296 new_file: "a.rs".to_string(),
1297 status: gitkraft_core::FileStatus::Modified,
1298 hunks: vec![],
1299 }];
1300 state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1301 old_file: String::new(),
1302 new_file: "b.rs".to_string(),
1303 status: gitkraft_core::FileStatus::Modified,
1304 hunks: vec![],
1305 }];
1306 state.active_tab_mut().is_loading_file_diff = true;
1307 state.active_tab_mut().diff_scroll_offset = 200.0;
1308
1309 let new_diff = gitkraft_core::DiffInfo {
1311 old_file: String::new(),
1312 new_file: "b.rs".to_string(),
1313 status: gitkraft_core::FileStatus::Modified,
1314 hunks: vec![],
1315 };
1316 let _ = state.update(Message::SingleFileDiffLoaded(Ok(new_diff)));
1317
1318 assert!(
1320 state.active_tab().multi_file_diffs.is_empty(),
1321 "multi_file_diffs must be cleared when new diff arrives"
1322 );
1323 assert!(
1324 state.active_tab().commit_range_diffs.is_empty(),
1325 "commit_range_diffs must be cleared when new diff arrives"
1326 );
1327 assert!(
1328 state.active_tab().selected_diff.is_some(),
1329 "selected_diff must be set to the loaded diff"
1330 );
1331 assert!(
1332 !state.active_tab().is_loading_file_diff,
1333 "is_loading_file_diff must be cleared after load"
1334 );
1335 assert_eq!(
1336 state.active_tab().diff_scroll_offset,
1337 0.0,
1338 "diff_scroll_offset must be reset when new diff arrives"
1339 );
1340 }
1341
1342 #[test]
1343 fn regular_click_preserves_commit_range_diffs_until_load_completes() {
1344 use crate::message::Message;
1345 let mut state = GitKraft::new();
1346 state.active_tab_mut().repo_path =
1347 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1348 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1349 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1350
1351 state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1353 old_file: String::new(),
1354 new_file: "x.rs".to_string(),
1355 status: gitkraft_core::FileStatus::Modified,
1356 hunks: vec![],
1357 }];
1358
1359 let _ = state.update(Message::SelectDiffByIndex(0));
1360
1361 assert!(
1362 !state.active_tab().commit_range_diffs.is_empty(),
1363 "commit_range_diffs must NOT be cleared on click (deferred to diff load)"
1364 );
1365 }
1366
1367 #[test]
1368 fn select_diff_by_index_shift_click_adds_both_files_to_selection() {
1369 use crate::message::Message;
1370 let mut state = GitKraft::new();
1371 state.active_tab_mut().repo_path =
1372 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1373 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1374 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1375 state.active_tab_mut().selected_file_index = Some(0);
1376
1377 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1379 let _ = state.update(Message::SelectDiffByIndex(1));
1380
1381 let sel = &state.active_tab().selected_commit_file_indices;
1382 assert!(sel.contains(&0), "anchor file 0 should be selected");
1383 assert!(sel.contains(&1), "newly clicked file 1 should be selected");
1384 assert_eq!(sel.len(), 2);
1385 }
1386
1387 #[test]
1388 fn anchor_file_index_defaults_to_none() {
1389 let tab = RepoTab::new_empty();
1390 assert!(tab.anchor_file_index.is_none());
1391 }
1392
1393 #[test]
1394 fn regular_click_sets_anchor() {
1395 use crate::message::Message;
1396 let mut state = GitKraft::new();
1397 state.active_tab_mut().repo_path =
1398 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1399 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1400 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1401
1402 let _ = state.update(Message::SelectDiffByIndex(2));
1403
1404 assert_eq!(
1405 state.active_tab().anchor_file_index,
1406 Some(2),
1407 "regular click must set anchor to the clicked index"
1408 );
1409 }
1410
1411 #[test]
1412 fn shift_click_selects_range_downward_from_anchor() {
1413 use crate::message::Message;
1414 let mut state = GitKraft::new();
1415 state.active_tab_mut().repo_path =
1416 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1417 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1418 state.active_tab_mut().commit_files =
1419 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1420 state.active_tab_mut().anchor_file_index = Some(1);
1422 state.active_tab_mut().selected_file_index = Some(1);
1423
1424 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1426 let _ = state.update(Message::SelectDiffByIndex(4));
1427
1428 let sel = &state.active_tab().selected_commit_file_indices;
1429 assert_eq!(
1430 sel,
1431 &vec![1, 2, 3, 4],
1432 "range must be contiguous from anchor to click"
1433 );
1434 }
1435
1436 #[test]
1437 fn shift_click_selects_range_upward_from_anchor() {
1438 use crate::message::Message;
1439 let mut state = GitKraft::new();
1440 state.active_tab_mut().repo_path =
1441 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1442 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1443 state.active_tab_mut().commit_files =
1444 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1445 state.active_tab_mut().anchor_file_index = Some(4);
1447 state.active_tab_mut().selected_file_index = Some(4);
1448
1449 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1451 let _ = state.update(Message::SelectDiffByIndex(1));
1452
1453 let sel = &state.active_tab().selected_commit_file_indices;
1454 assert_eq!(
1455 sel,
1456 &vec![1, 2, 3, 4],
1457 "range must be stored ascending regardless of click direction"
1458 );
1459 }
1460
1461 #[test]
1462 fn shift_click_anchor_fixed_on_subsequent_clicks() {
1463 use crate::message::Message;
1464 let mut state = GitKraft::new();
1465 state.active_tab_mut().repo_path =
1466 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1467 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1468 state.active_tab_mut().commit_files =
1469 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1470 state.active_tab_mut().anchor_file_index = Some(2);
1472 state.active_tab_mut().selected_file_index = Some(2);
1473 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1474
1475 let _ = state.update(Message::SelectDiffByIndex(4));
1477 assert_eq!(
1478 state.active_tab().selected_commit_file_indices,
1479 vec![2, 3, 4]
1480 );
1481
1482 let _ = state.update(Message::SelectDiffByIndex(3));
1484 assert_eq!(
1485 state.active_tab().selected_commit_file_indices,
1486 vec![2, 3],
1487 "anchor must stay fixed; second Shift+Click shrinks the range"
1488 );
1489
1490 let _ = state.update(Message::SelectDiffByIndex(0));
1492 assert_eq!(
1493 state.active_tab().selected_commit_file_indices,
1494 vec![0, 1, 2],
1495 "anchor must stay fixed; can extend range in either direction"
1496 );
1497 }
1498
1499 #[test]
1500 fn shift_click_on_anchor_itself_gives_single_item_range() {
1501 use crate::message::Message;
1502 let mut state = GitKraft::new();
1503 state.active_tab_mut().repo_path =
1504 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1505 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1506 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1507 state.active_tab_mut().anchor_file_index = Some(1);
1508 state.active_tab_mut().selected_file_index = Some(1);
1509
1510 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1512 let _ = state.update(Message::SelectDiffByIndex(1));
1513
1514 assert_eq!(state.active_tab().selected_commit_file_indices, vec![1]);
1515 assert!(
1516 state.active_tab().multi_file_diffs.is_empty(),
1517 "single-item range must not populate multi_file_diffs"
1518 );
1519 }
1520
1521 #[test]
1522 fn shift_click_range_is_always_ascending() {
1523 use crate::message::Message;
1524 let mut state = GitKraft::new();
1525 state.active_tab_mut().repo_path =
1526 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1527 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1528 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs"]);
1529 state.active_tab_mut().anchor_file_index = Some(3);
1530 state.active_tab_mut().selected_file_index = Some(3);
1531
1532 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1533 let _ = state.update(Message::SelectDiffByIndex(0));
1534
1535 let sel = &state.active_tab().selected_commit_file_indices;
1536 let is_sorted = sel.windows(2).all(|w| w[0] < w[1]);
1537 assert!(
1538 is_sorted,
1539 "selection must always be stored in ascending order"
1540 );
1541 assert_eq!(sel, &vec![0, 1, 2, 3]);
1542 }
1543
1544 #[test]
1545 fn checkout_file_at_commit_message_variants_exist() {
1546 use crate::message::Message;
1547 let _single =
1549 Message::CheckoutFileAtCommit("abc123".to_string(), "src/main.rs".to_string());
1550 let _multi = Message::CheckoutMultiFilesAtCommit(
1551 "abc123".to_string(),
1552 vec!["a.rs".to_string(), "b.rs".to_string()],
1553 );
1554 }
1555
1556 #[test]
1557 fn checkout_file_at_commit_closes_context_menu() {
1558 use crate::message::Message;
1559 let mut state = GitKraft::new();
1560 state.active_tab_mut().repo_path =
1561 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1562 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1563 oid: "abc123".to_string(),
1564 file_path: "src/main.rs".to_string(),
1565 });
1566 let _ = state.update(Message::CheckoutFileAtCommit(
1567 "abc123".to_string(),
1568 "src/main.rs".to_string(),
1569 ));
1570 assert!(state.active_tab().context_menu.is_none());
1571 }
1572
1573 #[test]
1574 fn checkout_multi_files_at_commit_closes_context_menu() {
1575 use crate::message::Message;
1576 let mut state = GitKraft::new();
1577 state.active_tab_mut().repo_path =
1578 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1579 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1580 oid: "abc123".to_string(),
1581 file_path: "src/main.rs".to_string(),
1582 });
1583 let _ = state.update(Message::CheckoutMultiFilesAtCommit(
1584 "abc123".to_string(),
1585 vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
1586 ));
1587 assert!(state.active_tab().context_menu.is_none());
1588 }
1589
1590 fn make_test_commits(count: usize) -> Vec<gitkraft_core::CommitInfo> {
1593 (0..count)
1594 .map(|i| gitkraft_core::CommitInfo {
1595 oid: i.to_string(),
1596 short_oid: i.to_string(),
1597 summary: String::new(),
1598 message: String::new(),
1599 author_name: String::new(),
1600 author_email: String::new(),
1601 time: Default::default(),
1602 parent_ids: Vec::new(),
1603 refs: Vec::new(),
1604 })
1605 .collect()
1606 }
1607
1608 #[test]
1609 fn selected_commits_defaults_empty() {
1610 let tab = RepoTab::new_empty();
1611 assert!(tab.selected_commits.is_empty());
1612 assert!(tab.anchor_commit_index.is_none());
1613 }
1614
1615 #[test]
1616 fn regular_click_commit_sets_anchor_and_clears_range() {
1617 use crate::message::Message;
1618 let mut state = GitKraft::new();
1619 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
1620 state.active_tab_mut().commits = make_test_commits(3);
1621 state.active_tab_mut().selected_commits = vec![0, 1, 2];
1622
1623 let _ = state.update(Message::SelectCommit(1));
1624
1625 assert_eq!(state.active_tab().anchor_commit_index, Some(1));
1626 assert!(state.active_tab().selected_commits.is_empty());
1627 assert_eq!(state.active_tab().selected_commit, Some(1));
1628 }
1629
1630 #[test]
1631 fn shift_click_commit_selects_range_from_anchor() {
1632 use crate::message::Message;
1633 let mut state = GitKraft::new();
1634 state.active_tab_mut().commits = make_test_commits(5);
1635 state.active_tab_mut().anchor_commit_index = Some(1);
1636 state.active_tab_mut().selected_commit = Some(1);
1637
1638 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1639 let _ = state.update(Message::SelectCommit(4));
1640
1641 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3, 4]);
1642 }
1643
1644 #[test]
1645 fn shift_click_commit_range_is_ascending_when_clicking_above_anchor() {
1646 use crate::message::Message;
1647 let mut state = GitKraft::new();
1648 state.active_tab_mut().commits = make_test_commits(5);
1649 state.active_tab_mut().anchor_commit_index = Some(3);
1650 state.active_tab_mut().selected_commit = Some(3);
1651
1652 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1653 let _ = state.update(Message::SelectCommit(1));
1654
1655 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3]);
1656 }
1657
1658 #[test]
1661 fn execute_commit_action_closes_context_menu() {
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().context_menu = Some(crate::state::ContextMenu::Commit {
1667 index: 0,
1668 oid: "abc123".to_string(),
1669 });
1670
1671 let _ = state.update(Message::ExecuteCommitAction(
1672 "abc123".to_string(),
1673 gitkraft_core::CommitAction::CherryPick,
1674 ));
1675
1676 assert!(state.active_tab().context_menu.is_none());
1677 }
1678
1679 #[test]
1680 fn execute_commit_action_sets_loading_when_repo_open() {
1681 use crate::message::Message;
1682 let mut state = GitKraft::new();
1683 state.active_tab_mut().repo_path =
1684 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1685
1686 let _ = state.update(Message::ExecuteCommitAction(
1687 "abc123".to_string(),
1688 gitkraft_core::CommitAction::ResetHard,
1689 ));
1690
1691 assert!(state.active_tab().is_loading);
1692 }
1693
1694 #[test]
1695 fn execute_commit_action_no_repo_does_not_set_loading() {
1696 use crate::message::Message;
1697 let mut state = GitKraft::new();
1698 let _ = state.update(Message::ExecuteCommitAction(
1701 "abc123".to_string(),
1702 gitkraft_core::CommitAction::CherryPick,
1703 ));
1704
1705 assert!(!state.active_tab().is_loading);
1706 }
1707
1708 #[test]
1709 fn execute_commit_action_sets_status_message_from_action_label() {
1710 use crate::message::Message;
1711 let mut state = GitKraft::new();
1712 state.active_tab_mut().repo_path =
1713 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1714
1715 let _ = state.update(Message::ExecuteCommitAction(
1716 "abc123".to_string(),
1717 gitkraft_core::CommitAction::Revert,
1718 ));
1719
1720 let status = state.active_tab().status_message.as_deref().unwrap_or("");
1721 assert!(
1723 status.contains("Revert commit"),
1724 "expected status to contain 'Revert commit', got: {status:?}"
1725 );
1726 }
1727
1728 #[test]
1731 fn file_history_defaults_empty() {
1732 let tab = RepoTab::new_empty();
1733 assert!(tab.file_history_path.is_none());
1734 assert!(tab.file_history_commits.is_empty());
1735 assert_eq!(tab.file_history_scroll, 0.0);
1736 }
1737
1738 #[test]
1739 fn blame_defaults_empty() {
1740 let tab = RepoTab::new_empty();
1741 assert!(tab.blame_path.is_none());
1742 assert!(tab.blame_lines.is_empty());
1743 assert_eq!(tab.blame_scroll, 0.0);
1744 }
1745
1746 #[test]
1747 fn pending_delete_file_defaults_none() {
1748 let tab = RepoTab::new_empty();
1749 assert!(tab.pending_delete_file.is_none());
1750 }
1751
1752 #[test]
1753 fn view_file_history_sets_path_and_clears_blame() {
1754 use crate::message::Message;
1755 let mut state = GitKraft::new();
1756 state.active_tab_mut().repo_path =
1757 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1758 state.active_tab_mut().blame_path = Some("some/file.rs".to_string());
1759
1760 let _ = state.update(Message::ViewFileHistory("src/main.rs".to_string()));
1761
1762 assert_eq!(
1763 state.active_tab().file_history_path.as_deref(),
1764 Some("src/main.rs")
1765 );
1766 assert!(state.active_tab().blame_path.is_none());
1768 }
1769
1770 #[test]
1771 fn close_file_history_clears_state() {
1772 use crate::message::Message;
1773 let mut state = GitKraft::new();
1774 state.active_tab_mut().file_history_path = Some("src/lib.rs".to_string());
1775 state.active_tab_mut().file_history_commits = vec![gitkraft_core::CommitInfo {
1776 oid: "abc".to_string(),
1777 short_oid: "abc".to_string(),
1778 summary: "s".to_string(),
1779 message: "s".to_string(),
1780 author_name: "a".to_string(),
1781 author_email: "a@b.com".to_string(),
1782 time: Default::default(),
1783 parent_ids: vec![],
1784 refs: Vec::new(),
1785 }];
1786
1787 let _ = state.update(Message::CloseFileHistory);
1788
1789 assert!(state.active_tab().file_history_path.is_none());
1790 assert!(state.active_tab().file_history_commits.is_empty());
1791 }
1792
1793 #[test]
1794 fn view_file_blame_sets_path_and_clears_history() {
1795 use crate::message::Message;
1796 let mut state = GitKraft::new();
1797 state.active_tab_mut().repo_path =
1798 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1799 state.active_tab_mut().file_history_path = Some("some/file.rs".to_string());
1800
1801 let _ = state.update(Message::ViewFileBlame("src/lib.rs".to_string()));
1802
1803 assert_eq!(state.active_tab().blame_path.as_deref(), Some("src/lib.rs"));
1804 assert!(state.active_tab().file_history_path.is_none());
1806 }
1807
1808 #[test]
1809 fn selecting_new_commit_closes_blame_overlay() {
1810 use crate::message::Message;
1811 let mut state = GitKraft::new();
1812 state.active_tab_mut().repo_path =
1813 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1814 state.active_tab_mut().commits = vec![
1816 gitkraft_core::CommitInfo {
1817 oid: "abc1".into(),
1818 short_oid: "abc1".into(),
1819 summary: "first".into(),
1820 message: "first".into(),
1821 author_name: "A".into(),
1822 author_email: "a@a.com".into(),
1823 time: Default::default(),
1824 parent_ids: Vec::new(),
1825 refs: Vec::new(),
1826 },
1827 gitkraft_core::CommitInfo {
1828 oid: "abc2".into(),
1829 short_oid: "abc2".into(),
1830 summary: "second".into(),
1831 message: "second".into(),
1832 author_name: "A".into(),
1833 author_email: "a@a.com".into(),
1834 time: Default::default(),
1835 parent_ids: Vec::new(),
1836 refs: Vec::new(),
1837 },
1838 ];
1839 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1841 state.active_tab_mut().blame_lines = vec![gitkraft_core::BlameLine {
1842 line_number: 1,
1843 content: "fn main() {}".into(),
1844 short_oid: "abc1".into(),
1845 oid: "abc1".into(),
1846 author_name: "A".into(),
1847 time: Default::default(),
1848 }];
1849
1850 let _ = state.update(Message::SelectCommit(1));
1852
1853 assert!(
1854 state.active_tab().blame_path.is_none(),
1855 "blame_path must be cleared when a new commit is selected"
1856 );
1857 assert!(
1858 state.active_tab().blame_lines.is_empty(),
1859 "blame_lines must be cleared when a new commit is selected"
1860 );
1861 }
1862
1863 #[test]
1864 fn close_file_blame_clears_state() {
1865 use crate::message::Message;
1866 let mut state = GitKraft::new();
1867 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1868
1869 let _ = state.update(Message::CloseFileBlame);
1870
1871 assert!(state.active_tab().blame_path.is_none());
1872 assert!(state.active_tab().blame_lines.is_empty());
1873 }
1874
1875 #[test]
1876 fn delete_file_sets_pending() {
1877 use crate::message::Message;
1878 let mut state = GitKraft::new();
1879
1880 let _ = state.update(Message::DeleteFile("src/old.rs".to_string()));
1881
1882 assert_eq!(
1883 state.active_tab().pending_delete_file.as_deref(),
1884 Some("src/old.rs")
1885 );
1886 assert!(state.active_tab().context_menu.is_none());
1887 }
1888
1889 #[test]
1890 fn cancel_delete_file_clears_pending() {
1891 use crate::message::Message;
1892 let mut state = GitKraft::new();
1893 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1894
1895 let _ = state.update(Message::CancelDeleteFile);
1896
1897 assert!(state.active_tab().pending_delete_file.is_none());
1898 }
1899
1900 #[test]
1901 fn confirm_delete_file_no_repo_is_noop() {
1902 use crate::message::Message;
1903 let mut state = GitKraft::new();
1904 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1905 let _ = state.update(Message::ConfirmDeleteFile);
1908
1909 assert!(!state.active_tab().is_loading);
1910 }
1911
1912 #[test]
1913 fn shift_arrow_down_extends_file_list_selection_when_files_loaded() {
1914 use crate::message::Message;
1915 let mut state = GitKraft::new();
1916 state.active_tab_mut().repo_path =
1917 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1918 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1919 state.active_tab_mut().selected_file_index = Some(0);
1920 state.active_tab_mut().anchor_file_index = Some(0);
1921 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1923
1924 let _ = state.update(Message::ShiftArrowDown);
1925
1926 assert_eq!(state.active_tab().selected_file_index, Some(1));
1927 assert!(state.active_tab().selected_commit_file_indices.contains(&0));
1929 assert!(state.active_tab().selected_commit_file_indices.contains(&1));
1930 }
1931
1932 #[test]
1933 fn shift_arrow_down_falls_through_to_commit_log_when_no_files() {
1934 use crate::message::Message;
1935 let mut state = GitKraft::new();
1936 state.active_tab_mut().commits = make_test_commits(5);
1937 state.active_tab_mut().selected_commit = Some(1);
1938 state.active_tab_mut().anchor_commit_index = Some(1);
1939 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1940 let _ = state.update(Message::ShiftArrowDown);
1943
1944 assert_eq!(state.active_tab().selected_commit, Some(2));
1945 assert!(state.active_tab().selected_commits.contains(&1));
1946 assert!(state.active_tab().selected_commits.contains(&2));
1947 }
1948
1949 #[test]
1950 fn file_system_changed_triggers_full_refresh() {
1951 use crate::message::Message;
1952 let mut state = GitKraft::new();
1953 state.active_tab_mut().repo_path =
1954 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1955
1956 let _task = state.update(Message::FileSystemChanged);
1960
1961 assert!(
1965 state.active_tab().error_message.is_none(),
1966 "FileSystemChanged must not set an error message"
1967 );
1968 }
1969
1970 fn fake_payload(workdir: &str) -> crate::message::RepoPayload {
1974 gitkraft_core::RepoSnapshot {
1975 info: gitkraft_core::RepoInfo {
1976 path: std::path::PathBuf::from(format!("{workdir}/.git")),
1977 workdir: Some(std::path::PathBuf::from(workdir)),
1978 head_branch: Some("main".into()),
1979 is_bare: false,
1980 state: gitkraft_core::RepoState::Clean,
1981 },
1982 branches: Vec::new(),
1983 commits: Vec::new(),
1984 graph_rows: Vec::new(),
1985 unstaged: Vec::new(),
1986 staged: Vec::new(),
1987 stashes: Vec::new(),
1988 remotes: Vec::new(),
1989 }
1990 }
1991
1992 fn setup_loaded_tab(tab: &mut RepoTab, path: &str) {
1994 tab.repo_path = Some(std::path::PathBuf::from(path));
1995 tab.repo_info = Some(gitkraft_core::RepoInfo {
1996 path: std::path::PathBuf::from(format!("{path}/.git")),
1997 workdir: Some(std::path::PathBuf::from(path)),
1998 head_branch: Some("main".into()),
1999 is_bare: false,
2000 state: gitkraft_core::RepoState::Clean,
2001 });
2002 }
2003
2004 #[test]
2005 fn open_repo_creates_new_tab_when_repo_already_open() {
2006 use crate::message::Message;
2007 let mut state = GitKraft::new();
2008 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2009
2010 assert_eq!(state.tabs.len(), 1);
2011 assert_eq!(state.active_tab, 0);
2012
2013 let _task = state.update(Message::OpenRepo);
2015
2016 assert_eq!(state.tabs.len(), 2);
2017 assert_eq!(state.active_tab, 1);
2018 assert!(state.tabs[1].is_loading);
2020 assert_eq!(
2022 state.tabs[0].repo_path.as_deref(),
2023 Some(std::path::Path::new("/home/user/repo-a"))
2024 );
2025 }
2026
2027 #[test]
2028 fn open_repo_reuses_empty_tab() {
2029 use crate::message::Message;
2030 let mut state = GitKraft::new();
2031 assert!(!state.active_tab().has_repo());
2033
2034 let _task = state.update(Message::OpenRepo);
2035
2036 assert_eq!(state.tabs.len(), 1);
2038 assert_eq!(state.active_tab, 0);
2039 assert!(state.tabs[0].is_loading);
2040 }
2041
2042 #[test]
2043 fn repo_selected_deduplicates_already_open_repo() {
2044 use crate::message::Message;
2045 let mut state = GitKraft::new();
2046 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2048 state.tabs.push(RepoTab::new_empty());
2050 state.active_tab = 1;
2051
2052 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2054 "/home/user/repo-a",
2055 ))));
2056
2057 assert_eq!(state.tabs.len(), 1);
2059 assert_eq!(state.active_tab, 0);
2060 assert_eq!(
2061 state.tabs[0].repo_path.as_deref(),
2062 Some(std::path::Path::new("/home/user/repo-a"))
2063 );
2064 }
2065
2066 #[test]
2067 fn repo_selected_opens_new_repo_in_empty_tab() {
2068 use crate::message::Message;
2069 let mut state = GitKraft::new();
2070 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2072 state.tabs.push(RepoTab::new_empty());
2074 state.active_tab = 1;
2075
2076 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2078 "/home/user/repo-b",
2079 ))));
2080
2081 assert_eq!(state.tabs.len(), 2);
2083 assert_eq!(state.active_tab, 1);
2084 assert!(state.tabs[1]
2085 .status_message
2086 .as_deref()
2087 .unwrap_or("")
2088 .contains("repo-b"));
2089 }
2090
2091 #[test]
2092 fn repo_selected_cancel_removes_empty_tab() {
2093 use crate::message::Message;
2094 let mut state = GitKraft::new();
2095 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2097 state.tabs.push(RepoTab::new_empty());
2099 state.active_tab = 1;
2100
2101 let _task = state.update(Message::RepoSelected(None));
2103
2104 assert_eq!(state.tabs.len(), 1);
2106 assert_eq!(state.active_tab, 0);
2107 assert_eq!(
2108 state.tabs[0].repo_path.as_deref(),
2109 Some(std::path::Path::new("/home/user/repo-a"))
2110 );
2111 }
2112
2113 #[test]
2114 fn repo_selected_cancel_keeps_tab_if_only_one() {
2115 use crate::message::Message;
2116 let mut state = GitKraft::new();
2117 assert_eq!(state.tabs.len(), 1);
2119 assert!(!state.active_tab().has_repo());
2120
2121 let _task = state.update(Message::RepoSelected(None));
2122
2123 assert_eq!(state.tabs.len(), 1);
2124 assert!(!state.active_tab().is_loading);
2125 }
2126
2127 #[test]
2128 fn open_recent_repo_deduplicates() {
2129 use crate::message::Message;
2130 let mut state = GitKraft::new();
2131 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2133 state.tabs.push(RepoTab::new_empty());
2135 state.active_tab = 1;
2136
2137 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2139 "/home/user/repo-a",
2140 )));
2141
2142 assert_eq!(state.active_tab, 0);
2143 }
2144
2145 #[test]
2146 fn open_recent_repo_creates_new_tab_when_current_has_repo() {
2147 use crate::message::Message;
2148 let mut state = GitKraft::new();
2149 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2151
2152 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2154 "/home/user/repo-b",
2155 )));
2156
2157 assert_eq!(state.tabs.len(), 2);
2158 assert_eq!(state.active_tab, 1);
2159 assert!(state.tabs[1].is_loading);
2160 }
2161
2162 #[test]
2163 fn open_recent_repo_uses_empty_tab() {
2164 use crate::message::Message;
2165 let mut state = GitKraft::new();
2166 assert!(!state.active_tab().has_repo());
2168
2169 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2170 "/home/user/repo-b",
2171 )));
2172
2173 assert_eq!(state.tabs.len(), 1);
2175 assert_eq!(state.active_tab, 0);
2176 assert!(state.tabs[0].is_loading);
2177 }
2178
2179 #[test]
2182 fn repo_refreshed_targets_correct_tab_after_tab_switch() {
2183 use crate::message::Message;
2184 let mut state = GitKraft::new();
2185 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2187 state.tabs.push(RepoTab::new_empty());
2189 state.active_tab = 1; let payload = fake_payload("/home/user/repo-a");
2193 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2194
2195 assert!(
2197 state.tabs[0].repo_info.is_some(),
2198 "tab 0 should still have repo info after refresh"
2199 );
2200 assert_eq!(
2201 state.tabs[0].current_branch.as_deref(),
2202 Some("main"),
2203 "tab 0 should have updated branch from payload"
2204 );
2205 assert!(
2207 state.tabs[1].repo_info.is_none(),
2208 "tab 1 (empty) must NOT receive the refresh payload"
2209 );
2210 assert!(
2211 state.tabs[1].repo_path.is_none(),
2212 "tab 1 should still have no repo path"
2213 );
2214 }
2215
2216 #[test]
2217 fn repo_refreshed_targets_active_tab_for_new_open() {
2218 use crate::message::Message;
2219 let mut state = GitKraft::new();
2220 assert_eq!(state.tabs.len(), 1);
2224 assert!(!state.active_tab().has_repo());
2225
2226 let payload = fake_payload("/home/user/new-repo");
2227 let _task = state.update(Message::RepoOpened(Ok(payload)));
2228
2229 assert_eq!(
2231 state.tabs[0].repo_path.as_deref(),
2232 Some(std::path::Path::new("/home/user/new-repo"))
2233 );
2234 assert!(state.tabs[0].repo_info.is_some());
2235 }
2236
2237 #[test]
2238 fn repo_refreshed_does_not_duplicate_into_new_tab() {
2239 use crate::message::Message;
2240 let mut state = GitKraft::new();
2241 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2243
2244 let _task = state.update(Message::NewTab);
2246 assert_eq!(state.tabs.len(), 2);
2247 assert_eq!(state.active_tab, 1);
2248
2249 let payload = fake_payload("/home/user/repo-a");
2251 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2252
2253 assert!(
2255 state.tabs[1].repo_path.is_none(),
2256 "new empty tab must not receive repo-a refresh"
2257 );
2258 assert!(
2259 state.tabs[1].repo_info.is_none(),
2260 "new empty tab must not have repo_info"
2261 );
2262 assert_eq!(
2264 state.tabs[0].repo_path.as_deref(),
2265 Some(std::path::Path::new("/home/user/repo-a"))
2266 );
2267 }
2268
2269 #[test]
2270 fn git_operation_result_targets_correct_tab() {
2271 use crate::message::Message;
2272 let mut state = GitKraft::new();
2273 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2275 state.tabs.push(RepoTab::new_empty());
2277 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2278 state.active_tab = 1;
2279
2280 let payload = fake_payload("/home/user/repo-a");
2283 let _task = state.update(Message::GitOperationResult(Ok(payload)));
2284
2285 assert_eq!(state.tabs[0].current_branch.as_deref(), Some("main"));
2287 assert_eq!(
2289 state.tabs[1].repo_path.as_deref(),
2290 Some(std::path::Path::new("/home/user/repo-b"))
2291 );
2292 }
2293
2294 #[test]
2295 fn multiple_new_tabs_dont_get_polluted_by_refresh() {
2296 use crate::message::Message;
2297 let mut state = GitKraft::new();
2298 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2300
2301 let _task = state.update(Message::NewTab);
2303 let _task = state.update(Message::NewTab);
2304 assert_eq!(state.tabs.len(), 3);
2305 assert_eq!(state.active_tab, 2);
2306
2307 let payload = fake_payload("/home/user/repo-a");
2309 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2310
2311 assert!(state.tabs[0].repo_info.is_some());
2313 assert!(state.tabs[1].repo_info.is_none());
2314 assert!(state.tabs[2].repo_info.is_none());
2315 assert!(state.tabs[1].repo_path.is_none());
2316 assert!(state.tabs[2].repo_path.is_none());
2317 }
2318
2319 #[test]
2320 fn repo_selected_dedup_adjusts_index_when_existing_is_after_active() {
2321 use crate::message::Message;
2322 let mut state = GitKraft::new();
2323 state.tabs.push(RepoTab::new_empty());
2326 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
2327 state.active_tab = 0;
2328
2329 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2331 "/home/user/repo-a",
2332 ))));
2333
2334 assert_eq!(state.tabs.len(), 1);
2336 assert_eq!(state.active_tab, 0);
2337 assert_eq!(
2338 state.tabs[0].repo_path.as_deref(),
2339 Some(std::path::Path::new("/home/user/repo-a"))
2340 );
2341 }
2342
2343 #[test]
2346 fn apply_payload_preserves_multi_selection_by_oid() {
2347 let mut tab = RepoTab::new_empty();
2348 tab.commits = make_test_commits(5);
2349 tab.anchor_commit_index = Some(1);
2351 tab.selected_commits = vec![1, 2, 3];
2352 tab.selected_commit = Some(3);
2353 tab.selected_commit_oid = Some(tab.commits[3].oid.clone());
2354
2355 let mut payload = fake_payload("/tmp/repo");
2357 payload.commits = make_test_commits(5);
2358
2359 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2360
2361 assert_eq!(tab.anchor_commit_index, Some(1));
2363 assert_eq!(tab.selected_commits, vec![1, 2, 3]);
2364 assert_eq!(tab.selected_commit, Some(3));
2365 }
2366
2367 #[test]
2368 fn apply_payload_preserves_anchor_even_without_range() {
2369 let mut tab = RepoTab::new_empty();
2370 tab.commits = make_test_commits(5);
2371 tab.anchor_commit_index = Some(2);
2372 tab.selected_commit = Some(2);
2373 tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2374 let mut payload = fake_payload("/tmp/repo");
2377 payload.commits = make_test_commits(5);
2378
2379 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2380
2381 assert_eq!(tab.anchor_commit_index, Some(2));
2382 assert_eq!(tab.selected_commit, Some(2));
2383 }
2384
2385 #[test]
2386 fn apply_payload_clears_selection_when_commits_disappear() {
2387 let mut tab = RepoTab::new_empty();
2388 tab.commits = make_test_commits(5);
2389 tab.anchor_commit_index = Some(2);
2390 tab.selected_commits = vec![2, 3, 4];
2391 tab.selected_commit = Some(4);
2392 tab.selected_commit_oid = Some(tab.commits[4].oid.clone());
2393
2394 let mut payload = fake_payload("/tmp/repo");
2396 payload.commits = (0..3)
2397 .map(|i| gitkraft_core::CommitInfo {
2398 oid: format!("new_oid_{i}"),
2399 short_oid: format!("new_{i}"),
2400 summary: format!("new commit {i}"),
2401 message: String::new(),
2402 author_name: "Author".into(),
2403 author_email: "a@b.c".into(),
2404 time: Default::default(),
2405 parent_ids: Vec::new(),
2406 refs: Vec::new(),
2407 })
2408 .collect();
2409
2410 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2411
2412 assert!(tab.selected_commits.is_empty());
2414 assert!(tab.anchor_commit_index.is_none());
2415 assert!(tab.selected_commit.is_none());
2416 }
2417
2418 #[test]
2421 fn apply_payload_preserves_file_selection_when_commit_survives() {
2422 let mut tab = RepoTab::new_empty();
2423 tab.commits = make_test_commits(3);
2424 tab.selected_commit = Some(1);
2425 tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2426 tab.commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
2427
2428 tab.anchor_file_index = Some(0);
2430 tab.selected_commit_file_indices = vec![0, 1, 2];
2431 tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2432 old_file: String::new(),
2433 new_file: "a.rs".into(),
2434 status: gitkraft_core::FileStatus::Modified,
2435 hunks: vec![],
2436 }];
2437
2438 let mut payload = fake_payload("/tmp/repo");
2440 payload.commits = make_test_commits(3);
2441
2442 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2443
2444 assert_eq!(tab.anchor_file_index, Some(0));
2446 assert_eq!(tab.selected_commit_file_indices, vec![0, 1, 2]);
2447 assert!(
2448 !tab.multi_file_diffs.is_empty(),
2449 "multi_file_diffs must survive when commit is preserved"
2450 );
2451 }
2452
2453 #[test]
2454 fn apply_payload_clears_file_selection_when_commit_disappears() {
2455 let mut tab = RepoTab::new_empty();
2456 tab.commits = make_test_commits(3);
2457 tab.selected_commit = Some(1);
2458 tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2459 tab.commit_files = make_commit_files(&["a.rs", "b.rs"]);
2460 tab.anchor_file_index = Some(0);
2461 tab.selected_commit_file_indices = vec![0, 1];
2462 tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2463 old_file: String::new(),
2464 new_file: "a.rs".into(),
2465 status: gitkraft_core::FileStatus::Modified,
2466 hunks: vec![],
2467 }];
2468
2469 let mut payload = fake_payload("/tmp/repo");
2471 payload.commits = (0..2)
2472 .map(|i| gitkraft_core::CommitInfo {
2473 oid: format!("new_{i}"),
2474 short_oid: format!("n{i}"),
2475 summary: String::new(),
2476 message: String::new(),
2477 author_name: String::new(),
2478 author_email: String::new(),
2479 time: Default::default(),
2480 parent_ids: Vec::new(),
2481 refs: Vec::new(),
2482 })
2483 .collect();
2484
2485 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2486
2487 assert!(tab.anchor_file_index.is_none());
2489 assert!(tab.selected_commit_file_indices.is_empty());
2490 assert!(tab.multi_file_diffs.is_empty());
2491 assert!(tab.commit_files.is_empty());
2492 }
2493
2494 #[test]
2495 fn apply_payload_preserves_commit_range_diffs_when_commit_survives() {
2496 let mut tab = RepoTab::new_empty();
2497 tab.commits = make_test_commits(5);
2498 tab.selected_commit = Some(2);
2499 tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2500 tab.commit_range_diffs = vec![gitkraft_core::DiffInfo {
2501 old_file: String::new(),
2502 new_file: "x.rs".into(),
2503 status: gitkraft_core::FileStatus::Modified,
2504 hunks: vec![],
2505 }];
2506
2507 let mut payload = fake_payload("/tmp/repo");
2508 payload.commits = make_test_commits(5);
2509
2510 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2511
2512 assert!(
2513 !tab.commit_range_diffs.is_empty(),
2514 "commit_range_diffs must survive when commit is preserved"
2515 );
2516 }
2517
2518 #[test]
2519 fn apply_payload_preserves_commit_message() {
2520 let mut tab = RepoTab::new_empty();
2523 tab.repo_path = Some(std::path::PathBuf::from("/tmp/repo"));
2524 tab.commit_message = "WIP: fixing the bug".to_string();
2525
2526 let payload = fake_payload("/tmp/repo");
2527 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2528
2529 assert_eq!(
2530 tab.commit_message, "WIP: fixing the bug",
2531 "commit_message must survive background repo refreshes"
2532 );
2533 }
2534
2535 #[test]
2538 fn preview_file_loaded_sets_selected_diff() {
2539 use crate::message::Message;
2540 let mut state = GitKraft::new();
2541 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2542
2543 let content = "line 1\nline 2\nline 3".to_string();
2544 let _ = state.update(Message::FilePreviewLoaded(Ok((
2545 "test.rs".to_string(),
2546 content,
2547 ))));
2548
2549 let diff = state.active_tab().selected_diff.as_ref().unwrap();
2550 assert_eq!(diff.new_file, "test.rs");
2551 assert_eq!(diff.hunks.len(), 1);
2552 assert_eq!(diff.hunks[0].lines.len(), 3);
2553 assert_eq!(state.active_tab().diff_scroll_offset, 0.0);
2554 }
2555
2556 #[test]
2557 fn preview_file_loaded_error_sets_error_message() {
2558 use crate::message::Message;
2559 let mut state = GitKraft::new();
2560 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2561
2562 let _ = state.update(Message::FilePreviewLoaded(Err("No such file".to_string())));
2563
2564 assert!(state.active_tab().error_message.is_some());
2565 assert!(state
2566 .active_tab()
2567 .error_message
2568 .as_ref()
2569 .unwrap()
2570 .contains("No such file"));
2571 }
2572
2573 #[test]
2576 fn close_repo_single_tab_shows_welcome_screen() {
2577 use crate::message::Message;
2581 let mut state = GitKraft::new();
2582 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2583 assert_eq!(state.tabs.len(), 1);
2584
2585 let _ = state.update(Message::CloseRepo);
2586
2587 assert_eq!(state.tabs.len(), 1, "last tab must not be removed");
2588 assert_eq!(state.active_tab, 0);
2589 assert!(!state.active_tab().has_repo(), "tab should be empty");
2590 }
2591
2592 #[test]
2593 fn close_repo_with_multiple_tabs_removes_current_tab() {
2594 use crate::message::Message;
2605 let mut state = GitKraft::new();
2606 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2608 state.tabs.push(RepoTab::new_empty());
2610 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2611 state.active_tab = 0;
2613
2614 let _ = state.update(Message::CloseRepo);
2615
2616 assert_eq!(state.tabs.len(), 1, "current tab must be removed");
2619 assert_eq!(state.active_tab, 0);
2620 assert_eq!(
2621 state.active_tab().repo_path.as_deref(),
2622 Some(std::path::Path::new("/home/user/repo-b")),
2623 "repo-b should be the surviving tab"
2624 );
2625 }
2626
2627 #[test]
2628 fn close_repo_second_tab_active_removes_it_and_keeps_first() {
2629 use crate::message::Message;
2632 let mut state = GitKraft::new();
2633 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2635 state.tabs.push(RepoTab::new_empty());
2637 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2638 state.active_tab = 1; let _ = state.update(Message::CloseRepo);
2641
2642 assert_eq!(state.tabs.len(), 1);
2643 assert_eq!(state.active_tab, 0);
2644 assert_eq!(
2645 state.active_tab().repo_path.as_deref(),
2646 Some(std::path::Path::new("/home/user/repo-a")),
2647 "repo-a should be the surviving tab"
2648 );
2649 }
2650
2651 #[test]
2654 fn close_tab_active_tab_first_position() {
2655 use crate::message::Message;
2657 let mut state = GitKraft::new();
2658 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2659 state.tabs.push(RepoTab::new_empty());
2660 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2661 state.active_tab = 0;
2662
2663 let _ = state.update(Message::CloseTab(0));
2664
2665 assert_eq!(state.tabs.len(), 1);
2666 assert_eq!(state.active_tab, 0);
2667 assert_eq!(
2668 state.active_tab().repo_path.as_deref(),
2669 Some(std::path::Path::new("/home/user/repo-b"))
2670 );
2671 }
2672
2673 #[test]
2674 fn close_tab_active_tab_second_position() {
2675 use crate::message::Message;
2677 let mut state = GitKraft::new();
2678 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2679 state.tabs.push(RepoTab::new_empty());
2680 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2681 state.active_tab = 1;
2682
2683 let _ = state.update(Message::CloseTab(1));
2684
2685 assert_eq!(state.tabs.len(), 1);
2686 assert_eq!(state.active_tab, 0);
2687 assert_eq!(
2688 state.active_tab().repo_path.as_deref(),
2689 Some(std::path::Path::new("/home/user/repo-a"))
2690 );
2691 }
2692
2693 #[test]
2694 fn close_tab_non_active_tab_before_active() {
2695 use crate::message::Message;
2697 let mut state = GitKraft::new();
2698 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2699 state.tabs.push(RepoTab::new_empty());
2700 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2701 state.active_tab = 1;
2702
2703 let _ = state.update(Message::CloseTab(0));
2704
2705 assert_eq!(state.tabs.len(), 1);
2706 assert_eq!(state.active_tab, 0, "active_tab must shift down by 1");
2707 assert_eq!(
2708 state.active_tab().repo_path.as_deref(),
2709 Some(std::path::Path::new("/home/user/repo-b")),
2710 "repo-b must remain active"
2711 );
2712 }
2713
2714 #[test]
2715 fn close_tab_non_active_tab_after_active() {
2716 use crate::message::Message;
2718 let mut state = GitKraft::new();
2719 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2720 state.tabs.push(RepoTab::new_empty());
2721 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2722 state.active_tab = 0;
2723
2724 let _ = state.update(Message::CloseTab(1));
2725
2726 assert_eq!(state.tabs.len(), 1);
2727 assert_eq!(state.active_tab, 0, "active_tab must remain unchanged");
2728 assert_eq!(
2729 state.active_tab().repo_path.as_deref(),
2730 Some(std::path::Path::new("/home/user/repo-a")),
2731 "repo-a must remain active"
2732 );
2733 }
2734
2735 #[test]
2736 fn close_tab_last_remaining_tab_is_noop() {
2737 use crate::message::Message;
2739 let mut state = GitKraft::new();
2740 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2741 assert_eq!(state.tabs.len(), 1);
2742
2743 let _ = state.update(Message::CloseTab(0));
2744
2745 assert_eq!(state.tabs.len(), 1, "last tab must not be removed");
2746 assert!(
2747 state.active_tab().has_repo(),
2748 "tab content must be unchanged"
2749 );
2750 }
2751
2752 #[test]
2753 fn close_tab_out_of_bounds_index_is_noop() {
2754 use crate::message::Message;
2756 let mut state = GitKraft::new();
2757 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2758 state.tabs.push(RepoTab::new_empty());
2759 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2760 assert_eq!(state.tabs.len(), 2);
2761
2762 let _ = state.update(Message::CloseTab(99));
2763
2764 assert_eq!(state.tabs.len(), 2, "no tab should be removed");
2765 }
2766
2767 #[test]
2770 fn close_tab_three_tabs_close_first_active() {
2771 use crate::message::Message;
2774 let mut state = GitKraft::new();
2775 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2776 state.tabs.push(RepoTab::new_empty());
2777 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2778 state.tabs.push(RepoTab::new_empty());
2779 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2780 state.active_tab = 0;
2781
2782 let _ = state.update(Message::CloseTab(0));
2783
2784 assert_eq!(state.tabs.len(), 2);
2785 assert_eq!(state.active_tab, 0);
2786 assert_eq!(
2787 state.active_tab().repo_path.as_deref(),
2788 Some(std::path::Path::new("/home/user/repo-b"))
2789 );
2790 }
2791
2792 #[test]
2793 fn close_tab_three_tabs_close_active_middle() {
2794 use crate::message::Message;
2797 let mut state = GitKraft::new();
2798 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2799 state.tabs.push(RepoTab::new_empty());
2800 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2801 state.tabs.push(RepoTab::new_empty());
2802 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2803 state.active_tab = 1;
2804
2805 let _ = state.update(Message::CloseTab(1));
2806
2807 assert_eq!(state.tabs.len(), 2);
2808 assert_eq!(
2809 state.active_tab, 1,
2810 "active_tab must stay at 1 (repo-c slides in)"
2811 );
2812 assert_eq!(
2813 state.active_tab().repo_path.as_deref(),
2814 Some(std::path::Path::new("/home/user/repo-c"))
2815 );
2816 }
2817
2818 #[test]
2819 fn close_tab_three_tabs_close_active_last() {
2820 use crate::message::Message;
2823 let mut state = GitKraft::new();
2824 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2825 state.tabs.push(RepoTab::new_empty());
2826 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2827 state.tabs.push(RepoTab::new_empty());
2828 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2829 state.active_tab = 2;
2830
2831 let _ = state.update(Message::CloseTab(2));
2832
2833 assert_eq!(state.tabs.len(), 2);
2834 assert_eq!(
2835 state.active_tab, 1,
2836 "active_tab must be clamped to last index"
2837 );
2838 assert_eq!(
2839 state.active_tab().repo_path.as_deref(),
2840 Some(std::path::Path::new("/home/user/repo-b"))
2841 );
2842 }
2843
2844 #[test]
2845 fn close_tab_three_tabs_close_first_when_middle_active() {
2846 use crate::message::Message;
2849 let mut state = GitKraft::new();
2850 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2851 state.tabs.push(RepoTab::new_empty());
2852 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2853 state.tabs.push(RepoTab::new_empty());
2854 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2855 state.active_tab = 1;
2856
2857 let _ = state.update(Message::CloseTab(0));
2858
2859 assert_eq!(state.tabs.len(), 2);
2860 assert_eq!(
2861 state.active_tab, 0,
2862 "active_tab must decrement when a tab before it is removed"
2863 );
2864 assert_eq!(
2865 state.active_tab().repo_path.as_deref(),
2866 Some(std::path::Path::new("/home/user/repo-b")),
2867 "repo-b must remain active"
2868 );
2869 }
2870
2871 #[test]
2872 fn close_tab_three_tabs_close_last_when_middle_active() {
2873 use crate::message::Message;
2876 let mut state = GitKraft::new();
2877 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2878 state.tabs.push(RepoTab::new_empty());
2879 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2880 state.tabs.push(RepoTab::new_empty());
2881 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2882 state.active_tab = 1;
2883
2884 let _ = state.update(Message::CloseTab(2));
2885
2886 assert_eq!(state.tabs.len(), 2);
2887 assert_eq!(
2888 state.active_tab, 1,
2889 "active_tab must not change when a tab after it is removed"
2890 );
2891 assert_eq!(
2892 state.active_tab().repo_path.as_deref(),
2893 Some(std::path::Path::new("/home/user/repo-b")),
2894 "repo-b must remain active"
2895 );
2896 }
2897
2898 #[test]
2901 fn close_repo_three_tabs_active_in_middle() {
2902 use crate::message::Message;
2905 let mut state = GitKraft::new();
2906 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2907 state.tabs.push(RepoTab::new_empty());
2908 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2909 state.tabs.push(RepoTab::new_empty());
2910 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2911 state.active_tab = 1;
2912
2913 let _ = state.update(Message::CloseRepo);
2914
2915 assert_eq!(state.tabs.len(), 2);
2916 assert_eq!(state.active_tab, 1);
2917 assert_eq!(
2918 state.active_tab().repo_path.as_deref(),
2919 Some(std::path::Path::new("/home/user/repo-c"))
2920 );
2921 }
2922
2923 #[test]
2924 fn close_repo_three_tabs_active_at_end() {
2925 use crate::message::Message;
2928 let mut state = GitKraft::new();
2929 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2930 state.tabs.push(RepoTab::new_empty());
2931 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2932 state.tabs.push(RepoTab::new_empty());
2933 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2934 state.active_tab = 2;
2935
2936 let _ = state.update(Message::CloseRepo);
2937
2938 assert_eq!(state.tabs.len(), 2);
2939 assert_eq!(state.active_tab, 1, "active_tab clamped to last index");
2940 assert_eq!(
2941 state.active_tab().repo_path.as_deref(),
2942 Some(std::path::Path::new("/home/user/repo-b"))
2943 );
2944 }
2945
2946 #[test]
2949 fn new_tab_appends_empty_tab_and_makes_it_active() {
2950 use crate::message::Message;
2951 let mut state = GitKraft::new();
2952 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2953 assert_eq!(state.tabs.len(), 1);
2954
2955 let _ = state.update(Message::NewTab);
2956
2957 assert_eq!(state.tabs.len(), 2, "a new empty tab must be appended");
2958 assert_eq!(state.active_tab, 1, "new tab must become active");
2959 assert!(!state.active_tab().has_repo(), "new tab must be empty");
2960 assert!(state.tabs[0].has_repo());
2962 }
2963
2964 #[test]
2965 fn new_tab_with_multiple_existing_tabs() {
2966 use crate::message::Message;
2967 let mut state = GitKraft::new();
2968 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2969 state.tabs.push(RepoTab::new_empty());
2970 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2971 state.active_tab = 0;
2972
2973 let _ = state.update(Message::NewTab);
2974
2975 assert_eq!(state.tabs.len(), 3);
2976 assert_eq!(state.active_tab, 2);
2977 assert!(!state.active_tab().has_repo());
2978 }
2979
2980 #[test]
2983 fn switch_tab_to_valid_index() {
2984 use crate::message::Message;
2985 let mut state = GitKraft::new();
2986 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2987 state.tabs.push(RepoTab::new_empty());
2988 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2989 state.active_tab = 0;
2990
2991 let _ = state.update(Message::SwitchTab(1));
2992
2993 assert_eq!(state.active_tab, 1);
2994 assert_eq!(
2995 state.active_tab().repo_path.as_deref(),
2996 Some(std::path::Path::new("/home/user/repo-b"))
2997 );
2998 }
2999
3000 #[test]
3001 fn switch_tab_to_same_index_is_noop() {
3002 use crate::message::Message;
3003 let mut state = GitKraft::new();
3004 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3005 state.tabs.push(RepoTab::new_empty());
3006 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3007 state.active_tab = 1;
3008
3009 let _ = state.update(Message::SwitchTab(1));
3010
3011 assert_eq!(state.active_tab, 1, "should remain on tab 1");
3012 }
3013
3014 #[test]
3015 fn switch_tab_out_of_bounds_is_noop() {
3016 use crate::message::Message;
3018 let mut state = GitKraft::new();
3019 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3020 state.tabs.push(RepoTab::new_empty());
3021 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3022 state.active_tab = 0;
3023
3024 let _ = state.update(Message::SwitchTab(99));
3025
3026 assert_eq!(state.active_tab, 0, "out-of-bounds switch must be ignored");
3027 }
3028}