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) -> &str {
291 self.repo_path
292 .as_ref()
293 .and_then(|p| p.file_name())
294 .and_then(|n| n.to_str())
295 .unwrap_or("New Tab")
296 }
297
298 pub fn apply_payload(
304 &mut self,
305 payload: crate::message::RepoPayload,
306 path: std::path::PathBuf,
307 ) {
308 let prev_oid = self.selected_commit_oid.clone();
310
311 let prev_anchor_oid = self
315 .anchor_commit_index
316 .and_then(|i| self.commits.get(i).map(|c| c.oid.clone()));
317 let prev_selected_oids: Vec<String> = self
318 .selected_commits
319 .iter()
320 .filter_map(|&i| self.commits.get(i).map(|c| c.oid.clone()))
321 .collect();
322
323 let prev_anchor_file = self.anchor_file_index;
327 let prev_file_indices = self.selected_commit_file_indices.clone();
328 let prev_multi_file_diffs = std::mem::take(&mut self.multi_file_diffs);
329 let prev_commit_range_diffs = std::mem::take(&mut self.commit_range_diffs);
330
331 let prev_selected_unstaged = std::mem::take(&mut self.selected_unstaged);
334 let prev_selected_staged = std::mem::take(&mut self.selected_staged);
335 let prev_anchor_unstaged = self.anchor_unstaged_index;
336 let prev_anchor_staged = self.anchor_staged_index;
337
338 let prev_selected_diff = self.selected_diff.take();
341 let prev_diff_scroll = self.diff_scroll_offset;
342
343 self.current_branch = payload.info.head_branch.clone();
344 self.repo_path = Some(path);
345 self.repo_info = Some(payload.info);
346 self.branches = payload.branches;
347 self.commits = payload.commits;
348 self.graph_rows = payload.graph_rows;
349 self.unstaged_changes = payload.unstaged;
350 self.staged_changes = payload.staged;
351 self.stashes = payload.stashes;
352 self.remotes = payload.remotes;
353
354 self.selected_commit = None;
358 self.anchor_commit_index = None;
359 self.selected_commits.clear();
360 self.selected_commit_oid = None;
361 self.commit_message.clear();
362 self.error_message = None;
363 self.status_message = Some("Repository loaded.".into());
364 self.commit_scroll_offset = 0.0;
365 self.has_more_commits = true;
366 self.is_loading_more_commits = false;
367 self.anchor_unstaged_index = None;
368 self.anchor_staged_index = None;
369 self.anchor_file_index = None;
370 self.selected_commit_file_indices.clear();
371
372 if let Some(oid) = prev_oid {
378 if let Some(new_idx) = self.commits.iter().position(|c| c.oid == oid) {
379 self.selected_commit = Some(new_idx);
380 self.selected_commit_oid = Some(oid);
381 self.selected_diff = prev_selected_diff;
388 self.diff_scroll_offset = prev_diff_scroll;
389
390 self.anchor_file_index = prev_anchor_file;
393 self.selected_commit_file_indices = prev_file_indices;
394 self.multi_file_diffs = prev_multi_file_diffs;
395 self.commit_range_diffs = prev_commit_range_diffs;
396 } else {
397 self.clear_diff_state();
399 }
400 } else {
401 if prev_selected_diff.is_some() {
404 self.selected_diff = prev_selected_diff;
405 self.diff_scroll_offset = prev_diff_scroll;
406 } else {
407 self.clear_diff_state();
408 }
409 }
410
411 if let Some(anchor_oid) = prev_anchor_oid {
415 if let Some(new_anchor) = self.commits.iter().position(|c| c.oid == anchor_oid) {
416 self.anchor_commit_index = Some(new_anchor);
417 }
418 }
419 if !prev_selected_oids.is_empty() {
420 let restored: Vec<usize> = prev_selected_oids
421 .iter()
422 .filter_map(|oid| self.commits.iter().position(|c| &c.oid == oid))
423 .collect();
424 if !restored.is_empty() {
425 self.selected_commits = restored;
426 self.selected_commits.sort_unstable();
427 }
428 }
429
430 self.selected_unstaged = prev_selected_unstaged
433 .into_iter()
434 .filter(|p| self.unstaged_changes.iter().any(|d| d.display_path() == p))
435 .collect();
436 self.selected_staged = prev_selected_staged
437 .into_iter()
438 .filter(|p| self.staged_changes.iter().any(|d| d.display_path() == p))
439 .collect();
440 self.anchor_unstaged_index =
442 prev_anchor_unstaged.filter(|&i| i < self.unstaged_changes.len());
443 self.anchor_staged_index = prev_anchor_staged.filter(|&i| i < self.staged_changes.len());
444 }
445
446 fn clear_diff_state(&mut self) {
449 self.selected_diff = None;
450 self.commit_files.clear();
451 self.selected_file_index = None;
452 self.is_loading_file_diff = false;
453 self.diff_scroll_offset = 0.0;
454 }
455}
456
457pub struct GitKraft {
461 pub tabs: Vec<RepoTab>,
464 pub active_tab: usize,
466
467 pub sidebar_expanded: bool,
470
471 pub sidebar_width: f32,
474 pub commit_log_width: f32,
476 pub staging_height: f32,
478 pub diff_file_list_width: f32,
480 pub sidebar_branches_ratio: f32,
482 pub sidebar_stash_ratio: f32,
485 pub staging_unstaged_ratio: f32,
487 pub staging_staged_ratio: f32,
490
491 pub ui_scale: f32,
493
494 pub dragging: Option<DragTarget>,
497 pub dragging_h: Option<DragTargetH>,
499 pub drag_start_x: f32,
501 pub drag_start_y: f32,
503 pub drag_initialized: bool,
507 pub drag_initialized_h: bool,
509
510 pub cursor_pos: Point,
515
516 pub current_theme_index: usize,
519
520 pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
523
524 pub search_visible: bool,
527 pub search_query: String,
529 pub search_results: Vec<gitkraft_core::CommitInfo>,
531 pub search_selected: Option<usize>,
533
534 pub search_diff_files: Vec<gitkraft_core::DiffFileEntry>,
536 pub search_diff_selected: HashSet<usize>,
538 pub search_diff_content: Vec<gitkraft_core::DiffInfo>,
540 pub search_diff_oid: Option<String>,
542
543 pub editor: gitkraft_core::Editor,
545
546 pub keyboard_modifiers: iced::keyboard::Modifiers,
548
549 pub animation_tick: u64,
552
553 pub window_width: f32,
556 pub window_height: f32,
558 pub window_x: f32,
560 pub window_y: f32,
562}
563
564impl Default for GitKraft {
565 fn default() -> Self {
566 Self::new()
567 }
568}
569
570impl GitKraft {
571 fn from_settings(settings: gitkraft_core::AppSettings) -> Self {
577 let current_theme_index = settings
578 .theme_name
579 .as_deref()
580 .map(gitkraft_core::theme_index_by_name)
581 .unwrap_or(0);
582
583 let recent_repos = settings.recent_repos;
584
585 let (
586 sidebar_width,
587 commit_log_width,
588 staging_height,
589 diff_file_list_width,
590 sidebar_expanded,
591 ui_scale,
592 ) = if let Some(ref layout) = settings.layout {
593 (
594 layout.sidebar_width.unwrap_or(220.0),
595 layout.commit_log_width.unwrap_or(500.0),
596 layout.staging_height.unwrap_or(200.0),
597 layout.diff_file_list_width.unwrap_or(180.0),
598 layout.sidebar_expanded.unwrap_or(true),
599 layout.ui_scale.unwrap_or(1.0),
600 )
601 } else {
602 (220.0, 500.0, 200.0, 180.0, true, 1.0)
603 };
604
605 Self {
606 tabs: vec![RepoTab::new_empty()],
607 active_tab: 0,
608
609 sidebar_expanded,
610
611 sidebar_width,
612 commit_log_width,
613 staging_height,
614 diff_file_list_width,
615 sidebar_branches_ratio: 0.6,
616 sidebar_stash_ratio: 0.25,
617 staging_unstaged_ratio: 0.4,
618 staging_staged_ratio: 0.3,
619
620 ui_scale,
621
622 dragging: None,
623 dragging_h: None,
624 drag_start_x: 0.0,
625 drag_start_y: 0.0,
626 drag_initialized: false,
627 drag_initialized_h: false,
628 cursor_pos: Point::ORIGIN,
629
630 current_theme_index,
631
632 recent_repos,
633
634 search_visible: false,
635 search_query: String::new(),
636 search_results: Vec::new(),
637 search_selected: None,
638 search_diff_files: Vec::new(),
639 search_diff_selected: HashSet::new(),
640 search_diff_content: Vec::new(),
641 search_diff_oid: None,
642
643 keyboard_modifiers: iced::keyboard::Modifiers::default(),
644 animation_tick: 0,
645
646 window_width: settings
647 .layout
648 .as_ref()
649 .and_then(|l| l.window_width)
650 .unwrap_or(1400.0),
651 window_height: settings
652 .layout
653 .as_ref()
654 .and_then(|l| l.window_height)
655 .unwrap_or(800.0),
656 window_x: settings
657 .layout
658 .as_ref()
659 .and_then(|l| l.window_x)
660 .unwrap_or(0.0),
661 window_y: settings
662 .layout
663 .as_ref()
664 .and_then(|l| l.window_y)
665 .unwrap_or(0.0),
666
667 editor: settings
668 .editor_name
669 .as_deref()
670 .map(|name| {
671 gitkraft_core::EDITOR_NAMES
673 .iter()
674 .position(|n| n.eq_ignore_ascii_case(name))
675 .map(gitkraft_core::Editor::from_index)
676 .unwrap_or_else(|| {
677 if name.eq_ignore_ascii_case("none") {
678 gitkraft_core::Editor::None
679 } else {
680 gitkraft_core::Editor::Custom(name.to_string())
681 }
682 })
683 })
684 .unwrap_or_else(detect_system_editor),
685 }
686 }
687
688 pub fn new() -> Self {
694 Self::from_settings(
695 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default(),
696 )
697 }
698
699 pub fn new_with_session_paths() -> (Self, Vec<PathBuf>) {
705 let settings =
706 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default();
707 let open_tabs = settings.open_tabs.clone();
708 let active_tab_index = settings.active_tab_index;
709
710 let mut state = Self::from_settings(settings);
711
712 if !open_tabs.is_empty() {
713 state.tabs = open_tabs
714 .iter()
715 .map(|path| {
716 let mut tab = RepoTab::new_empty();
717 tab.repo_path = Some(path.clone());
720 if path.exists() {
721 tab.is_loading = true;
722 tab.status_message = Some(format!(
723 "Loading {}…",
724 path.file_name().unwrap_or_default().to_string_lossy()
725 ));
726 } else {
727 tab.error_message =
728 Some(format!("Repository not found: {}", path.display()));
729 }
730 tab
731 })
732 .collect();
733 state.active_tab = active_tab_index.min(state.tabs.len().saturating_sub(1));
734 }
735
736 (state, open_tabs)
737 }
738
739 pub fn open_tab_paths(&self) -> Vec<PathBuf> {
742 self.tabs
743 .iter()
744 .filter(|t| t.repo_info.is_some())
745 .filter_map(|t| t.repo_path.clone())
746 .collect()
747 }
748
749 pub fn active_tab(&self) -> &RepoTab {
751 &self.tabs[self.active_tab]
752 }
753
754 pub fn active_tab_mut(&mut self) -> &mut RepoTab {
756 &mut self.tabs[self.active_tab]
757 }
758
759 pub fn dismiss_menu(&mut self) {
761 self.active_tab_mut().context_menu = None;
762 }
763
764 pub fn open_context_menu(&mut self, menu: ContextMenu) {
766 let pos = (self.cursor_pos.x, self.cursor_pos.y);
767 let tab = self.active_tab_mut();
768 tab.context_menu_pos = pos;
769 tab.context_menu = Some(menu);
770 }
771
772 pub fn has_repo(&self) -> bool {
774 self.active_tab().has_repo()
775 }
776
777 pub fn repo_display_name(&self) -> &str {
779 self.active_tab().display_name()
780 }
781
782 pub fn colors(&self) -> ThemeColors {
789 ThemeColors::from_core(&gitkraft_core::theme_by_index(self.current_theme_index))
790 }
791
792 pub fn iced_theme(&self) -> iced::Theme {
801 let core = gitkraft_core::theme_by_index(self.current_theme_index);
802 let name = self.current_theme_name().to_string();
803
804 let palette = iced::theme::Palette {
805 background: rgb_to_iced(core.background),
806 text: rgb_to_iced(core.text_primary),
807 primary: rgb_to_iced(core.accent),
808 success: rgb_to_iced(core.success),
809 warning: rgb_to_iced(core.warning),
810 danger: rgb_to_iced(core.error),
811 };
812
813 iced::Theme::custom(name, palette)
814 }
815
816 pub fn current_theme_name(&self) -> &'static str {
818 gitkraft_core::THEME_NAMES
819 .get(self.current_theme_index)
820 .copied()
821 .unwrap_or("Default")
822 }
823
824 pub fn refresh_active_tab(&mut self) -> Task<Message> {
828 match self.active_tab().repo_path.clone() {
829 Some(path) => crate::features::repo::commands::refresh_repo(path),
830 None => Task::none(),
831 }
832 }
833
834 pub fn on_ok_refresh(
841 &mut self,
842 result: Result<(), String>,
843 ok_msg: &str,
844 err_prefix: &str,
845 ) -> Task<Message> {
846 match result {
847 Ok(()) => {
848 {
849 let tab = self.active_tab_mut();
850 tab.is_loading = false;
851 tab.status_message = Some(ok_msg.to_string());
852 }
853 self.refresh_active_tab()
854 }
855 Err(e) => {
856 let tab = self.active_tab_mut();
857 tab.is_loading = false;
858 tab.error_message = Some(format!("{err_prefix}: {e}"));
859 tab.status_message = None;
860 Task::none()
861 }
862 }
863 }
864
865 pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
867 gitkraft_core::LayoutSettings {
868 sidebar_width: Some(self.sidebar_width),
869 commit_log_width: Some(self.commit_log_width),
870 staging_height: Some(self.staging_height),
871 diff_file_list_width: Some(self.diff_file_list_width),
872 sidebar_expanded: Some(self.sidebar_expanded),
873 ui_scale: Some(self.ui_scale),
874 window_width: Some(self.window_width),
875 window_height: Some(self.window_height),
876 window_x: Some(self.window_x),
877 window_y: Some(self.window_y),
878 window_maximized: None, }
880 }
881}
882
883fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
885 Color::from_rgb8(rgb.r, rgb.g, rgb.b)
886}
887
888fn detect_system_editor() -> gitkraft_core::Editor {
890 for var in ["VISUAL", "EDITOR"] {
891 if let Ok(val) = std::env::var(var) {
892 let bin = val.split('/').next_back().unwrap_or(&val).trim();
893 return match bin {
894 "nvim" | "neovim" => gitkraft_core::Editor::Neovim,
895 "vim" => gitkraft_core::Editor::Vim,
896 "hx" | "helix" => gitkraft_core::Editor::Helix,
897 "nano" => gitkraft_core::Editor::Nano,
898 "micro" => gitkraft_core::Editor::Micro,
899 "emacs" => gitkraft_core::Editor::Emacs,
900 "code" => gitkraft_core::Editor::VSCode,
901 "zed" => gitkraft_core::Editor::Zed,
902 "subl" => gitkraft_core::Editor::Sublime,
903 _ => gitkraft_core::Editor::Custom(val),
904 };
905 }
906 }
907 gitkraft_core::Editor::None
908}
909
910#[cfg(test)]
913mod tests {
914 use super::*;
915
916 #[test]
917 fn new_defaults() {
918 let state = GitKraft::new();
919 assert!(state.active_tab().repo_path.is_none());
920 assert!(!state.has_repo());
921 assert_eq!(state.repo_display_name(), "New Tab");
922 assert!(state.active_tab().commits.is_empty());
923 assert!(state.sidebar_expanded);
924 assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
926 assert!(state.sidebar_width > 0.0);
928 assert!(state.commit_log_width > 0.0);
929 assert!(state.staging_height > 0.0);
930 assert!(state.dragging.is_none());
931 assert!(state.dragging_h.is_none());
932 assert_eq!(state.tabs.len(), 1);
934 assert_eq!(state.active_tab, 0);
935 }
936
937 #[test]
938 fn repo_display_name_extracts_basename() {
939 let mut state = GitKraft::new();
940 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
941 assert_eq!(state.repo_display_name(), "my-project");
942 }
943
944 #[test]
945 fn colors_returns_theme_colors() {
946 let state = GitKraft::new();
947 let c = state.colors();
948 assert!(c.bg.r < 0.5);
950 }
951
952 #[test]
953 fn iced_theme_is_custom_with_correct_palette() {
954 let mut state = GitKraft::new();
955
956 state.current_theme_index = 0;
958 let iced_t = state.iced_theme();
959 let pal = iced_t.palette();
960 assert!(pal.background.r < 0.5, "Default theme bg should be dark");
961 assert_eq!(iced_t.to_string(), "Default");
962
963 let sol_idx = gitkraft_core::theme_index_by_name("Solarized Light");
965 state.current_theme_index = sol_idx;
966 let iced_t = state.iced_theme();
967 let pal = iced_t.palette();
968 assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
969 assert_eq!(iced_t.to_string(), "Solarized Light");
970
971 let gruvbox_idx = gitkraft_core::theme_index_by_name("Gruvbox Dark");
973 state.current_theme_index = gruvbox_idx;
974 let iced_t = state.iced_theme();
975 let pal = iced_t.palette();
976 let core = gitkraft_core::theme_by_index(gruvbox_idx);
977 let expected_accent = rgb_to_iced(core.accent);
978 assert!(
979 (pal.primary.r - expected_accent.r).abs() < 0.01
980 && (pal.primary.g - expected_accent.g).abs() < 0.01
981 && (pal.primary.b - expected_accent.b).abs() < 0.01,
982 "Gruvbox Dark accent should match core accent"
983 );
984 }
985
986 #[test]
987 fn iced_theme_name_round_trips_through_core() {
988 for i in 0..gitkraft_core::THEME_COUNT {
991 let mut state = GitKraft::new();
992 state.current_theme_index = i;
993 let iced_t = state.iced_theme();
994 let name = iced_t.to_string();
995 let resolved = gitkraft_core::theme_index_by_name(&name);
996 assert_eq!(
997 resolved,
998 i,
999 "theme index {i} ({}) did not round-trip through iced_theme name",
1000 gitkraft_core::THEME_NAMES[i]
1001 );
1002 }
1003 }
1004
1005 #[test]
1006 fn current_theme_name_round_trips() {
1007 let mut state = GitKraft::new();
1008 state.current_theme_index = gitkraft_core::theme_index_by_name("Dracula");
1009 assert_eq!(state.current_theme_name(), "Dracula");
1010 state.current_theme_index = 0;
1011 assert_eq!(state.current_theme_name(), "Default");
1012 }
1013
1014 #[test]
1015 fn repo_tab_new_empty() {
1016 let tab = RepoTab::new_empty();
1017 assert!(tab.repo_path.is_none());
1018 assert!(!tab.has_repo());
1019 assert_eq!(tab.display_name(), "New Tab");
1020 assert!(tab.commits.is_empty());
1021 assert!(tab.branches.is_empty());
1022 assert!(!tab.is_loading);
1023 }
1024
1025 #[test]
1026 fn repo_tab_display_name_with_path() {
1027 let mut tab = RepoTab::new_empty();
1028 tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
1029 assert!(tab.has_repo());
1030 assert_eq!(tab.display_name(), "cool-repo");
1031 }
1032
1033 #[test]
1034 fn search_defaults() {
1035 let state = GitKraft::new();
1036 assert!(!state.search_visible);
1037 assert!(state.search_query.is_empty());
1038 assert!(state.search_results.is_empty());
1039 assert!(state.search_selected.is_none());
1040 }
1041
1042 #[test]
1043 fn context_menu_variants_exist() {
1044 use crate::state::ContextMenu;
1046
1047 let _branch = ContextMenu::Branch {
1048 name: "main".to_string(),
1049 is_current: true,
1050 local_index: 0,
1051 };
1052 let _remote = ContextMenu::RemoteBranch {
1053 name: "origin/main".to_string(),
1054 };
1055 let _commit = ContextMenu::Commit {
1056 index: 0,
1057 oid: "abc1234".to_string(),
1058 };
1059 let _stash = ContextMenu::Stash { index: 0 };
1060 let _unstaged = ContextMenu::UnstagedFile {
1061 path: "src/main.rs".to_string(),
1062 };
1063 let _staged = ContextMenu::StagedFile {
1064 path: "src/lib.rs".to_string(),
1065 };
1066 }
1067
1068 #[test]
1069 fn repo_tab_context_menu_defaults_to_none() {
1070 let tab = crate::state::RepoTab::new_empty();
1071 assert!(tab.context_menu.is_none());
1072 }
1073
1074 #[test]
1075 fn context_menu_variants_constructable() {
1076 use crate::state::ContextMenu;
1077 let _ = ContextMenu::Stash { index: 0 };
1078 let _ = ContextMenu::UnstagedFile {
1079 path: "a.rs".into(),
1080 };
1081 let _ = ContextMenu::StagedFile {
1082 path: "b.rs".into(),
1083 };
1084 }
1085
1086 #[test]
1087 fn selected_unstaged_defaults_empty() {
1088 let tab = crate::state::RepoTab::new_empty();
1089 assert!(tab.selected_unstaged.is_empty());
1090 assert!(tab.selected_staged.is_empty());
1091 }
1092
1093 #[test]
1094 fn selected_unstaged_toggle() {
1095 let mut tab = crate::state::RepoTab::new_empty();
1096 tab.selected_unstaged.insert("a.rs".to_string());
1097 tab.selected_unstaged.insert("b.rs".to_string());
1098 assert_eq!(tab.selected_unstaged.len(), 2);
1099 assert!(tab.selected_unstaged.contains("a.rs"));
1100 tab.selected_unstaged.remove("a.rs");
1101 assert_eq!(tab.selected_unstaged.len(), 1);
1102 assert!(!tab.selected_unstaged.contains("a.rs"));
1103 }
1104
1105 #[test]
1106 fn detect_system_editor_returns_valid() {
1107 let editor = super::detect_system_editor();
1109 let _ = editor.display_name();
1110 }
1111
1112 #[test]
1115 fn selected_commit_file_indices_defaults_to_empty_vec() {
1116 let tab = RepoTab::new_empty();
1117 assert!(tab.selected_commit_file_indices.is_empty());
1118 let v: &Vec<usize> = &tab.selected_commit_file_indices;
1120 assert_eq!(v.len(), 0);
1121 }
1122
1123 #[test]
1124 fn multi_file_diffs_defaults_empty() {
1125 let tab = RepoTab::new_empty();
1126 assert!(tab.multi_file_diffs.is_empty());
1127 }
1128
1129 #[test]
1130 fn keyboard_modifiers_default_has_no_shift() {
1131 let state = GitKraft::new();
1132 assert!(!state.keyboard_modifiers.shift());
1133 }
1134
1135 #[test]
1136 fn selected_commit_file_indices_preserves_insertion_order() {
1137 let mut tab = RepoTab::new_empty();
1138 tab.selected_commit_file_indices.push(5);
1139 tab.selected_commit_file_indices.push(2);
1140 tab.selected_commit_file_indices.push(8);
1141 assert_eq!(tab.selected_commit_file_indices, vec![5, 2, 8]);
1142 }
1143
1144 #[test]
1145 fn selected_commit_file_indices_cleared_on_reset() {
1146 let mut tab = RepoTab::new_empty();
1147 tab.selected_commit_file_indices.push(0);
1148 tab.selected_commit_file_indices.push(1);
1149 tab.selected_commit_file_indices.clear();
1150 assert!(tab.selected_commit_file_indices.is_empty());
1151 }
1152
1153 #[test]
1154 fn multi_file_diffs_cleared_on_reset() {
1155 let mut tab = RepoTab::new_empty();
1156 tab.multi_file_diffs.push(gitkraft_core::DiffInfo {
1157 old_file: String::new(),
1158 new_file: "a.rs".to_string(),
1159 status: gitkraft_core::FileStatus::Modified,
1160 hunks: vec![],
1161 });
1162 tab.multi_file_diffs.clear();
1163 assert!(tab.multi_file_diffs.is_empty());
1164 }
1165
1166 #[test]
1167 fn commit_range_diffs_defaults_empty() {
1168 let tab = RepoTab::new_empty();
1169 assert!(tab.commit_range_diffs.is_empty());
1170 }
1171
1172 #[test]
1173 fn commit_range_diffs_cleared_on_apply_payload() {
1174 let mut tab = RepoTab::new_empty();
1176 tab.commit_range_diffs.push(gitkraft_core::DiffInfo {
1177 old_file: String::new(),
1178 new_file: "x.rs".to_string(),
1179 status: gitkraft_core::FileStatus::Modified,
1180 hunks: vec![],
1181 });
1182 tab.commit_range_diffs.clear();
1183 assert!(tab.commit_range_diffs.is_empty());
1184 }
1185
1186 #[test]
1189 fn modifiers_changed_sets_shift_state() {
1190 use crate::message::Message;
1191 let mut state = GitKraft::new();
1192 assert!(!state.keyboard_modifiers.shift());
1193
1194 let _ = state.update(Message::ModifiersChanged(iced::keyboard::Modifiers::SHIFT));
1195 assert!(state.keyboard_modifiers.shift());
1196
1197 let _ = state.update(Message::ModifiersChanged(
1198 iced::keyboard::Modifiers::default(),
1199 ));
1200 assert!(!state.keyboard_modifiers.shift());
1201 }
1202
1203 fn make_commit_files(names: &[&str]) -> Vec<gitkraft_core::DiffFileEntry> {
1206 names
1207 .iter()
1208 .map(|name| gitkraft_core::DiffFileEntry {
1209 old_file: String::new(),
1210 new_file: name.to_string(),
1211 status: gitkraft_core::FileStatus::Modified,
1212 })
1213 .collect()
1214 }
1215
1216 #[test]
1217 fn select_diff_by_index_regular_click_clears_multi_selection() {
1218 use crate::message::Message;
1219 let mut state = GitKraft::new();
1220 state.active_tab_mut().repo_path =
1224 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1225 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1226 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1227 state.active_tab_mut().selected_commit_file_indices = vec![0, 1];
1229
1230 let _ = state.update(Message::SelectDiffByIndex(0));
1232
1233 assert!(state.active_tab().selected_commit_file_indices.is_empty());
1234 assert_eq!(state.active_tab().selected_file_index, Some(0));
1235 }
1236
1237 #[test]
1238 fn regular_click_preserves_multi_file_diffs_until_load_completes() {
1239 use crate::message::Message;
1240 let mut state = GitKraft::new();
1241 state.active_tab_mut().repo_path =
1242 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1243 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1244 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1245
1246 state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1248 old_file: String::new(),
1249 new_file: "a.rs".to_string(),
1250 status: gitkraft_core::FileStatus::Modified,
1251 hunks: vec![],
1252 }];
1253
1254 let _ = state.update(Message::SelectDiffByIndex(1));
1257
1258 assert!(
1259 !state.active_tab().multi_file_diffs.is_empty(),
1260 "multi_file_diffs must NOT be cleared on click (deferred to diff load)"
1261 );
1262 assert_eq!(state.active_tab().selected_file_index, Some(1));
1263 assert!(state.active_tab().is_loading_file_diff);
1264 }
1265
1266 #[test]
1267 fn regular_click_preserves_diff_scroll_until_load_completes() {
1268 use crate::message::Message;
1269 let mut state = GitKraft::new();
1270 state.active_tab_mut().repo_path =
1271 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1272 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1273 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1274 state.active_tab_mut().diff_scroll_offset = 150.0;
1275
1276 let _ = state.update(Message::SelectDiffByIndex(1));
1278
1279 assert_eq!(
1280 state.active_tab().diff_scroll_offset,
1281 150.0,
1282 "diff_scroll_offset must NOT be reset on click (deferred to diff load)"
1283 );
1284 }
1285
1286 #[test]
1287 fn single_file_diff_loaded_clears_deferred_state() {
1288 use crate::message::Message;
1289 let mut state = GitKraft::new();
1290 state.active_tab_mut().repo_path =
1291 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1292 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1293 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1294
1295 state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1297 old_file: String::new(),
1298 new_file: "a.rs".to_string(),
1299 status: gitkraft_core::FileStatus::Modified,
1300 hunks: vec![],
1301 }];
1302 state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1303 old_file: String::new(),
1304 new_file: "b.rs".to_string(),
1305 status: gitkraft_core::FileStatus::Modified,
1306 hunks: vec![],
1307 }];
1308 state.active_tab_mut().is_loading_file_diff = true;
1309 state.active_tab_mut().diff_scroll_offset = 200.0;
1310
1311 let new_diff = gitkraft_core::DiffInfo {
1313 old_file: String::new(),
1314 new_file: "b.rs".to_string(),
1315 status: gitkraft_core::FileStatus::Modified,
1316 hunks: vec![],
1317 };
1318 let _ = state.update(Message::SingleFileDiffLoaded(Ok(new_diff)));
1319
1320 assert!(
1322 state.active_tab().multi_file_diffs.is_empty(),
1323 "multi_file_diffs must be cleared when new diff arrives"
1324 );
1325 assert!(
1326 state.active_tab().commit_range_diffs.is_empty(),
1327 "commit_range_diffs must be cleared when new diff arrives"
1328 );
1329 assert!(
1330 state.active_tab().selected_diff.is_some(),
1331 "selected_diff must be set to the loaded diff"
1332 );
1333 assert!(
1334 !state.active_tab().is_loading_file_diff,
1335 "is_loading_file_diff must be cleared after load"
1336 );
1337 assert_eq!(
1338 state.active_tab().diff_scroll_offset,
1339 0.0,
1340 "diff_scroll_offset must be reset when new diff arrives"
1341 );
1342 }
1343
1344 #[test]
1345 fn regular_click_preserves_commit_range_diffs_until_load_completes() {
1346 use crate::message::Message;
1347 let mut state = GitKraft::new();
1348 state.active_tab_mut().repo_path =
1349 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1350 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1351 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1352
1353 state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1355 old_file: String::new(),
1356 new_file: "x.rs".to_string(),
1357 status: gitkraft_core::FileStatus::Modified,
1358 hunks: vec![],
1359 }];
1360
1361 let _ = state.update(Message::SelectDiffByIndex(0));
1362
1363 assert!(
1364 !state.active_tab().commit_range_diffs.is_empty(),
1365 "commit_range_diffs must NOT be cleared on click (deferred to diff load)"
1366 );
1367 }
1368
1369 #[test]
1370 fn select_diff_by_index_shift_click_adds_both_files_to_selection() {
1371 use crate::message::Message;
1372 let mut state = GitKraft::new();
1373 state.active_tab_mut().repo_path =
1374 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1375 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1376 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1377 state.active_tab_mut().selected_file_index = Some(0);
1378
1379 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1381 let _ = state.update(Message::SelectDiffByIndex(1));
1382
1383 let sel = &state.active_tab().selected_commit_file_indices;
1384 assert!(sel.contains(&0), "anchor file 0 should be selected");
1385 assert!(sel.contains(&1), "newly clicked file 1 should be selected");
1386 assert_eq!(sel.len(), 2);
1387 }
1388
1389 #[test]
1390 fn anchor_file_index_defaults_to_none() {
1391 let tab = RepoTab::new_empty();
1392 assert!(tab.anchor_file_index.is_none());
1393 }
1394
1395 #[test]
1396 fn regular_click_sets_anchor() {
1397 use crate::message::Message;
1398 let mut state = GitKraft::new();
1399 state.active_tab_mut().repo_path =
1400 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1401 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1402 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1403
1404 let _ = state.update(Message::SelectDiffByIndex(2));
1405
1406 assert_eq!(
1407 state.active_tab().anchor_file_index,
1408 Some(2),
1409 "regular click must set anchor to the clicked index"
1410 );
1411 }
1412
1413 #[test]
1414 fn shift_click_selects_range_downward_from_anchor() {
1415 use crate::message::Message;
1416 let mut state = GitKraft::new();
1417 state.active_tab_mut().repo_path =
1418 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1419 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1420 state.active_tab_mut().commit_files =
1421 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1422 state.active_tab_mut().anchor_file_index = Some(1);
1424 state.active_tab_mut().selected_file_index = Some(1);
1425
1426 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1428 let _ = state.update(Message::SelectDiffByIndex(4));
1429
1430 let sel = &state.active_tab().selected_commit_file_indices;
1431 assert_eq!(
1432 sel,
1433 &vec![1, 2, 3, 4],
1434 "range must be contiguous from anchor to click"
1435 );
1436 }
1437
1438 #[test]
1439 fn shift_click_selects_range_upward_from_anchor() {
1440 use crate::message::Message;
1441 let mut state = GitKraft::new();
1442 state.active_tab_mut().repo_path =
1443 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1444 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1445 state.active_tab_mut().commit_files =
1446 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1447 state.active_tab_mut().anchor_file_index = Some(4);
1449 state.active_tab_mut().selected_file_index = Some(4);
1450
1451 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1453 let _ = state.update(Message::SelectDiffByIndex(1));
1454
1455 let sel = &state.active_tab().selected_commit_file_indices;
1456 assert_eq!(
1457 sel,
1458 &vec![1, 2, 3, 4],
1459 "range must be stored ascending regardless of click direction"
1460 );
1461 }
1462
1463 #[test]
1464 fn shift_click_anchor_fixed_on_subsequent_clicks() {
1465 use crate::message::Message;
1466 let mut state = GitKraft::new();
1467 state.active_tab_mut().repo_path =
1468 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1469 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1470 state.active_tab_mut().commit_files =
1471 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1472 state.active_tab_mut().anchor_file_index = Some(2);
1474 state.active_tab_mut().selected_file_index = Some(2);
1475 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1476
1477 let _ = state.update(Message::SelectDiffByIndex(4));
1479 assert_eq!(
1480 state.active_tab().selected_commit_file_indices,
1481 vec![2, 3, 4]
1482 );
1483
1484 let _ = state.update(Message::SelectDiffByIndex(3));
1486 assert_eq!(
1487 state.active_tab().selected_commit_file_indices,
1488 vec![2, 3],
1489 "anchor must stay fixed; second Shift+Click shrinks the range"
1490 );
1491
1492 let _ = state.update(Message::SelectDiffByIndex(0));
1494 assert_eq!(
1495 state.active_tab().selected_commit_file_indices,
1496 vec![0, 1, 2],
1497 "anchor must stay fixed; can extend range in either direction"
1498 );
1499 }
1500
1501 #[test]
1502 fn shift_click_on_anchor_itself_gives_single_item_range() {
1503 use crate::message::Message;
1504 let mut state = GitKraft::new();
1505 state.active_tab_mut().repo_path =
1506 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1507 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1508 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1509 state.active_tab_mut().anchor_file_index = Some(1);
1510 state.active_tab_mut().selected_file_index = Some(1);
1511
1512 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1514 let _ = state.update(Message::SelectDiffByIndex(1));
1515
1516 assert_eq!(state.active_tab().selected_commit_file_indices, vec![1]);
1517 assert!(
1518 state.active_tab().multi_file_diffs.is_empty(),
1519 "single-item range must not populate multi_file_diffs"
1520 );
1521 }
1522
1523 #[test]
1524 fn shift_click_range_is_always_ascending() {
1525 use crate::message::Message;
1526 let mut state = GitKraft::new();
1527 state.active_tab_mut().repo_path =
1528 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1529 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1530 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs"]);
1531 state.active_tab_mut().anchor_file_index = Some(3);
1532 state.active_tab_mut().selected_file_index = Some(3);
1533
1534 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1535 let _ = state.update(Message::SelectDiffByIndex(0));
1536
1537 let sel = &state.active_tab().selected_commit_file_indices;
1538 let is_sorted = sel.windows(2).all(|w| w[0] < w[1]);
1539 assert!(
1540 is_sorted,
1541 "selection must always be stored in ascending order"
1542 );
1543 assert_eq!(sel, &vec![0, 1, 2, 3]);
1544 }
1545
1546 #[test]
1547 fn checkout_file_at_commit_message_variants_exist() {
1548 use crate::message::Message;
1549 let _single =
1551 Message::CheckoutFileAtCommit("abc123".to_string(), "src/main.rs".to_string());
1552 let _multi = Message::CheckoutMultiFilesAtCommit(
1553 "abc123".to_string(),
1554 vec!["a.rs".to_string(), "b.rs".to_string()],
1555 );
1556 }
1557
1558 #[test]
1559 fn checkout_file_at_commit_closes_context_menu() {
1560 use crate::message::Message;
1561 let mut state = GitKraft::new();
1562 state.active_tab_mut().repo_path =
1563 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1564 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1565 oid: "abc123".to_string(),
1566 file_path: "src/main.rs".to_string(),
1567 });
1568 let _ = state.update(Message::CheckoutFileAtCommit(
1569 "abc123".to_string(),
1570 "src/main.rs".to_string(),
1571 ));
1572 assert!(state.active_tab().context_menu.is_none());
1573 }
1574
1575 #[test]
1576 fn checkout_multi_files_at_commit_closes_context_menu() {
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().context_menu = Some(crate::state::ContextMenu::CommitFile {
1582 oid: "abc123".to_string(),
1583 file_path: "src/main.rs".to_string(),
1584 });
1585 let _ = state.update(Message::CheckoutMultiFilesAtCommit(
1586 "abc123".to_string(),
1587 vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
1588 ));
1589 assert!(state.active_tab().context_menu.is_none());
1590 }
1591
1592 fn make_test_commits(count: usize) -> Vec<gitkraft_core::CommitInfo> {
1595 (0..count)
1596 .map(|i| gitkraft_core::CommitInfo {
1597 oid: i.to_string(),
1598 short_oid: i.to_string(),
1599 summary: String::new(),
1600 message: String::new(),
1601 author_name: String::new(),
1602 author_email: String::new(),
1603 time: Default::default(),
1604 parent_ids: Vec::new(),
1605 refs: Vec::new(),
1606 })
1607 .collect()
1608 }
1609
1610 #[test]
1611 fn selected_commits_defaults_empty() {
1612 let tab = RepoTab::new_empty();
1613 assert!(tab.selected_commits.is_empty());
1614 assert!(tab.anchor_commit_index.is_none());
1615 }
1616
1617 #[test]
1618 fn regular_click_commit_sets_anchor_and_clears_range() {
1619 use crate::message::Message;
1620 let mut state = GitKraft::new();
1621 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
1622 state.active_tab_mut().commits = make_test_commits(3);
1623 state.active_tab_mut().selected_commits = vec![0, 1, 2];
1624
1625 let _ = state.update(Message::SelectCommit(1));
1626
1627 assert_eq!(state.active_tab().anchor_commit_index, Some(1));
1628 assert!(state.active_tab().selected_commits.is_empty());
1629 assert_eq!(state.active_tab().selected_commit, Some(1));
1630 }
1631
1632 #[test]
1633 fn shift_click_commit_selects_range_from_anchor() {
1634 use crate::message::Message;
1635 let mut state = GitKraft::new();
1636 state.active_tab_mut().commits = make_test_commits(5);
1637 state.active_tab_mut().anchor_commit_index = Some(1);
1638 state.active_tab_mut().selected_commit = Some(1);
1639
1640 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1641 let _ = state.update(Message::SelectCommit(4));
1642
1643 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3, 4]);
1644 }
1645
1646 #[test]
1647 fn shift_click_commit_range_is_ascending_when_clicking_above_anchor() {
1648 use crate::message::Message;
1649 let mut state = GitKraft::new();
1650 state.active_tab_mut().commits = make_test_commits(5);
1651 state.active_tab_mut().anchor_commit_index = Some(3);
1652 state.active_tab_mut().selected_commit = Some(3);
1653
1654 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1655 let _ = state.update(Message::SelectCommit(1));
1656
1657 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3]);
1658 }
1659
1660 #[test]
1663 fn execute_commit_action_closes_context_menu() {
1664 use crate::message::Message;
1665 let mut state = GitKraft::new();
1666 state.active_tab_mut().repo_path =
1667 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1668 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::Commit {
1669 index: 0,
1670 oid: "abc123".to_string(),
1671 });
1672
1673 let _ = state.update(Message::ExecuteCommitAction(
1674 "abc123".to_string(),
1675 gitkraft_core::CommitAction::CherryPick,
1676 ));
1677
1678 assert!(state.active_tab().context_menu.is_none());
1679 }
1680
1681 #[test]
1682 fn execute_commit_action_sets_loading_when_repo_open() {
1683 use crate::message::Message;
1684 let mut state = GitKraft::new();
1685 state.active_tab_mut().repo_path =
1686 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1687
1688 let _ = state.update(Message::ExecuteCommitAction(
1689 "abc123".to_string(),
1690 gitkraft_core::CommitAction::ResetHard,
1691 ));
1692
1693 assert!(state.active_tab().is_loading);
1694 }
1695
1696 #[test]
1697 fn execute_commit_action_no_repo_does_not_set_loading() {
1698 use crate::message::Message;
1699 let mut state = GitKraft::new();
1700 let _ = state.update(Message::ExecuteCommitAction(
1703 "abc123".to_string(),
1704 gitkraft_core::CommitAction::CherryPick,
1705 ));
1706
1707 assert!(!state.active_tab().is_loading);
1708 }
1709
1710 #[test]
1711 fn execute_commit_action_sets_status_message_from_action_label() {
1712 use crate::message::Message;
1713 let mut state = GitKraft::new();
1714 state.active_tab_mut().repo_path =
1715 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1716
1717 let _ = state.update(Message::ExecuteCommitAction(
1718 "abc123".to_string(),
1719 gitkraft_core::CommitAction::Revert,
1720 ));
1721
1722 let status = state.active_tab().status_message.as_deref().unwrap_or("");
1723 assert!(
1725 status.contains("Revert commit"),
1726 "expected status to contain 'Revert commit', got: {status:?}"
1727 );
1728 }
1729
1730 #[test]
1733 fn file_history_defaults_empty() {
1734 let tab = RepoTab::new_empty();
1735 assert!(tab.file_history_path.is_none());
1736 assert!(tab.file_history_commits.is_empty());
1737 assert_eq!(tab.file_history_scroll, 0.0);
1738 }
1739
1740 #[test]
1741 fn blame_defaults_empty() {
1742 let tab = RepoTab::new_empty();
1743 assert!(tab.blame_path.is_none());
1744 assert!(tab.blame_lines.is_empty());
1745 assert_eq!(tab.blame_scroll, 0.0);
1746 }
1747
1748 #[test]
1749 fn pending_delete_file_defaults_none() {
1750 let tab = RepoTab::new_empty();
1751 assert!(tab.pending_delete_file.is_none());
1752 }
1753
1754 #[test]
1755 fn view_file_history_sets_path_and_clears_blame() {
1756 use crate::message::Message;
1757 let mut state = GitKraft::new();
1758 state.active_tab_mut().repo_path =
1759 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1760 state.active_tab_mut().blame_path = Some("some/file.rs".to_string());
1761
1762 let _ = state.update(Message::ViewFileHistory("src/main.rs".to_string()));
1763
1764 assert_eq!(
1765 state.active_tab().file_history_path.as_deref(),
1766 Some("src/main.rs")
1767 );
1768 assert!(state.active_tab().blame_path.is_none());
1770 }
1771
1772 #[test]
1773 fn close_file_history_clears_state() {
1774 use crate::message::Message;
1775 let mut state = GitKraft::new();
1776 state.active_tab_mut().file_history_path = Some("src/lib.rs".to_string());
1777 state.active_tab_mut().file_history_commits = vec![gitkraft_core::CommitInfo {
1778 oid: "abc".to_string(),
1779 short_oid: "abc".to_string(),
1780 summary: "s".to_string(),
1781 message: "s".to_string(),
1782 author_name: "a".to_string(),
1783 author_email: "a@b.com".to_string(),
1784 time: Default::default(),
1785 parent_ids: vec![],
1786 refs: Vec::new(),
1787 }];
1788
1789 let _ = state.update(Message::CloseFileHistory);
1790
1791 assert!(state.active_tab().file_history_path.is_none());
1792 assert!(state.active_tab().file_history_commits.is_empty());
1793 }
1794
1795 #[test]
1796 fn view_file_blame_sets_path_and_clears_history() {
1797 use crate::message::Message;
1798 let mut state = GitKraft::new();
1799 state.active_tab_mut().repo_path =
1800 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1801 state.active_tab_mut().file_history_path = Some("some/file.rs".to_string());
1802
1803 let _ = state.update(Message::ViewFileBlame("src/lib.rs".to_string()));
1804
1805 assert_eq!(state.active_tab().blame_path.as_deref(), Some("src/lib.rs"));
1806 assert!(state.active_tab().file_history_path.is_none());
1808 }
1809
1810 #[test]
1811 fn selecting_new_commit_closes_blame_overlay() {
1812 use crate::message::Message;
1813 let mut state = GitKraft::new();
1814 state.active_tab_mut().repo_path =
1815 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1816 state.active_tab_mut().commits = vec![
1818 gitkraft_core::CommitInfo {
1819 oid: "abc1".into(),
1820 short_oid: "abc1".into(),
1821 summary: "first".into(),
1822 message: "first".into(),
1823 author_name: "A".into(),
1824 author_email: "a@a.com".into(),
1825 time: Default::default(),
1826 parent_ids: Vec::new(),
1827 refs: Vec::new(),
1828 },
1829 gitkraft_core::CommitInfo {
1830 oid: "abc2".into(),
1831 short_oid: "abc2".into(),
1832 summary: "second".into(),
1833 message: "second".into(),
1834 author_name: "A".into(),
1835 author_email: "a@a.com".into(),
1836 time: Default::default(),
1837 parent_ids: Vec::new(),
1838 refs: Vec::new(),
1839 },
1840 ];
1841 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1843 state.active_tab_mut().blame_lines = vec![gitkraft_core::BlameLine {
1844 line_number: 1,
1845 content: "fn main() {}".into(),
1846 short_oid: "abc1".into(),
1847 oid: "abc1".into(),
1848 author_name: "A".into(),
1849 time: Default::default(),
1850 }];
1851
1852 let _ = state.update(Message::SelectCommit(1));
1854
1855 assert!(
1856 state.active_tab().blame_path.is_none(),
1857 "blame_path must be cleared when a new commit is selected"
1858 );
1859 assert!(
1860 state.active_tab().blame_lines.is_empty(),
1861 "blame_lines must be cleared when a new commit is selected"
1862 );
1863 }
1864
1865 #[test]
1866 fn close_file_blame_clears_state() {
1867 use crate::message::Message;
1868 let mut state = GitKraft::new();
1869 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1870
1871 let _ = state.update(Message::CloseFileBlame);
1872
1873 assert!(state.active_tab().blame_path.is_none());
1874 assert!(state.active_tab().blame_lines.is_empty());
1875 }
1876
1877 #[test]
1878 fn delete_file_sets_pending() {
1879 use crate::message::Message;
1880 let mut state = GitKraft::new();
1881
1882 let _ = state.update(Message::DeleteFile("src/old.rs".to_string()));
1883
1884 assert_eq!(
1885 state.active_tab().pending_delete_file.as_deref(),
1886 Some("src/old.rs")
1887 );
1888 assert!(state.active_tab().context_menu.is_none());
1889 }
1890
1891 #[test]
1892 fn cancel_delete_file_clears_pending() {
1893 use crate::message::Message;
1894 let mut state = GitKraft::new();
1895 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1896
1897 let _ = state.update(Message::CancelDeleteFile);
1898
1899 assert!(state.active_tab().pending_delete_file.is_none());
1900 }
1901
1902 #[test]
1903 fn confirm_delete_file_no_repo_is_noop() {
1904 use crate::message::Message;
1905 let mut state = GitKraft::new();
1906 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1907 let _ = state.update(Message::ConfirmDeleteFile);
1910
1911 assert!(!state.active_tab().is_loading);
1912 }
1913
1914 #[test]
1915 fn shift_arrow_down_extends_file_list_selection_when_files_loaded() {
1916 use crate::message::Message;
1917 let mut state = GitKraft::new();
1918 state.active_tab_mut().repo_path =
1919 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1920 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1921 state.active_tab_mut().selected_file_index = Some(0);
1922 state.active_tab_mut().anchor_file_index = Some(0);
1923 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1925
1926 let _ = state.update(Message::ShiftArrowDown);
1927
1928 assert_eq!(state.active_tab().selected_file_index, Some(1));
1929 assert!(state.active_tab().selected_commit_file_indices.contains(&0));
1931 assert!(state.active_tab().selected_commit_file_indices.contains(&1));
1932 }
1933
1934 #[test]
1935 fn shift_arrow_down_falls_through_to_commit_log_when_no_files() {
1936 use crate::message::Message;
1937 let mut state = GitKraft::new();
1938 state.active_tab_mut().commits = make_test_commits(5);
1939 state.active_tab_mut().selected_commit = Some(1);
1940 state.active_tab_mut().anchor_commit_index = Some(1);
1941 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1942 let _ = state.update(Message::ShiftArrowDown);
1945
1946 assert_eq!(state.active_tab().selected_commit, Some(2));
1947 assert!(state.active_tab().selected_commits.contains(&1));
1948 assert!(state.active_tab().selected_commits.contains(&2));
1949 }
1950
1951 #[test]
1952 fn file_system_changed_triggers_full_refresh() {
1953 use crate::message::Message;
1954 let mut state = GitKraft::new();
1955 state.active_tab_mut().repo_path =
1956 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1957
1958 let _task = state.update(Message::FileSystemChanged);
1962
1963 assert!(
1967 state.active_tab().error_message.is_none(),
1968 "FileSystemChanged must not set an error message"
1969 );
1970 }
1971
1972 fn fake_payload(workdir: &str) -> crate::message::RepoPayload {
1976 gitkraft_core::RepoSnapshot {
1977 info: gitkraft_core::RepoInfo {
1978 path: std::path::PathBuf::from(format!("{workdir}/.git")),
1979 workdir: Some(std::path::PathBuf::from(workdir)),
1980 head_branch: Some("main".into()),
1981 is_bare: false,
1982 state: gitkraft_core::RepoState::Clean,
1983 },
1984 branches: Vec::new(),
1985 commits: Vec::new(),
1986 graph_rows: Vec::new(),
1987 unstaged: Vec::new(),
1988 staged: Vec::new(),
1989 stashes: Vec::new(),
1990 remotes: Vec::new(),
1991 }
1992 }
1993
1994 fn setup_loaded_tab(tab: &mut RepoTab, path: &str) {
1996 tab.repo_path = Some(std::path::PathBuf::from(path));
1997 tab.repo_info = Some(gitkraft_core::RepoInfo {
1998 path: std::path::PathBuf::from(format!("{path}/.git")),
1999 workdir: Some(std::path::PathBuf::from(path)),
2000 head_branch: Some("main".into()),
2001 is_bare: false,
2002 state: gitkraft_core::RepoState::Clean,
2003 });
2004 }
2005
2006 #[test]
2007 fn open_repo_creates_new_tab_when_repo_already_open() {
2008 use crate::message::Message;
2009 let mut state = GitKraft::new();
2010 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2011
2012 assert_eq!(state.tabs.len(), 1);
2013 assert_eq!(state.active_tab, 0);
2014
2015 let _task = state.update(Message::OpenRepo);
2017
2018 assert_eq!(state.tabs.len(), 2);
2019 assert_eq!(state.active_tab, 1);
2020 assert!(state.tabs[1].is_loading);
2022 assert_eq!(
2024 state.tabs[0].repo_path.as_deref(),
2025 Some(std::path::Path::new("/home/user/repo-a"))
2026 );
2027 }
2028
2029 #[test]
2030 fn open_repo_reuses_empty_tab() {
2031 use crate::message::Message;
2032 let mut state = GitKraft::new();
2033 assert!(!state.active_tab().has_repo());
2035
2036 let _task = state.update(Message::OpenRepo);
2037
2038 assert_eq!(state.tabs.len(), 1);
2040 assert_eq!(state.active_tab, 0);
2041 assert!(state.tabs[0].is_loading);
2042 }
2043
2044 #[test]
2045 fn repo_selected_deduplicates_already_open_repo() {
2046 use crate::message::Message;
2047 let mut state = GitKraft::new();
2048 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2050 state.tabs.push(RepoTab::new_empty());
2052 state.active_tab = 1;
2053
2054 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2056 "/home/user/repo-a",
2057 ))));
2058
2059 assert_eq!(state.tabs.len(), 1);
2061 assert_eq!(state.active_tab, 0);
2062 assert_eq!(
2063 state.tabs[0].repo_path.as_deref(),
2064 Some(std::path::Path::new("/home/user/repo-a"))
2065 );
2066 }
2067
2068 #[test]
2069 fn repo_selected_opens_new_repo_in_empty_tab() {
2070 use crate::message::Message;
2071 let mut state = GitKraft::new();
2072 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2074 state.tabs.push(RepoTab::new_empty());
2076 state.active_tab = 1;
2077
2078 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2080 "/home/user/repo-b",
2081 ))));
2082
2083 assert_eq!(state.tabs.len(), 2);
2085 assert_eq!(state.active_tab, 1);
2086 assert!(state.tabs[1]
2087 .status_message
2088 .as_deref()
2089 .unwrap_or("")
2090 .contains("repo-b"));
2091 }
2092
2093 #[test]
2094 fn repo_selected_cancel_removes_empty_tab() {
2095 use crate::message::Message;
2096 let mut state = GitKraft::new();
2097 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2099 state.tabs.push(RepoTab::new_empty());
2101 state.active_tab = 1;
2102
2103 let _task = state.update(Message::RepoSelected(None));
2105
2106 assert_eq!(state.tabs.len(), 1);
2108 assert_eq!(state.active_tab, 0);
2109 assert_eq!(
2110 state.tabs[0].repo_path.as_deref(),
2111 Some(std::path::Path::new("/home/user/repo-a"))
2112 );
2113 }
2114
2115 #[test]
2116 fn repo_selected_cancel_keeps_tab_if_only_one() {
2117 use crate::message::Message;
2118 let mut state = GitKraft::new();
2119 assert_eq!(state.tabs.len(), 1);
2121 assert!(!state.active_tab().has_repo());
2122
2123 let _task = state.update(Message::RepoSelected(None));
2124
2125 assert_eq!(state.tabs.len(), 1);
2126 assert!(!state.active_tab().is_loading);
2127 }
2128
2129 #[test]
2130 fn open_recent_repo_deduplicates() {
2131 use crate::message::Message;
2132 let mut state = GitKraft::new();
2133 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2135 state.tabs.push(RepoTab::new_empty());
2137 state.active_tab = 1;
2138
2139 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2141 "/home/user/repo-a",
2142 )));
2143
2144 assert_eq!(state.active_tab, 0);
2145 }
2146
2147 #[test]
2148 fn open_recent_repo_creates_new_tab_when_current_has_repo() {
2149 use crate::message::Message;
2150 let mut state = GitKraft::new();
2151 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2153
2154 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2156 "/home/user/repo-b",
2157 )));
2158
2159 assert_eq!(state.tabs.len(), 2);
2160 assert_eq!(state.active_tab, 1);
2161 assert!(state.tabs[1].is_loading);
2162 }
2163
2164 #[test]
2165 fn open_recent_repo_uses_empty_tab() {
2166 use crate::message::Message;
2167 let mut state = GitKraft::new();
2168 assert!(!state.active_tab().has_repo());
2170
2171 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2172 "/home/user/repo-b",
2173 )));
2174
2175 assert_eq!(state.tabs.len(), 1);
2177 assert_eq!(state.active_tab, 0);
2178 assert!(state.tabs[0].is_loading);
2179 }
2180
2181 #[test]
2184 fn repo_refreshed_targets_correct_tab_after_tab_switch() {
2185 use crate::message::Message;
2186 let mut state = GitKraft::new();
2187 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2189 state.tabs.push(RepoTab::new_empty());
2191 state.active_tab = 1; let payload = fake_payload("/home/user/repo-a");
2195 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2196
2197 assert!(
2199 state.tabs[0].repo_info.is_some(),
2200 "tab 0 should still have repo info after refresh"
2201 );
2202 assert_eq!(
2203 state.tabs[0].current_branch.as_deref(),
2204 Some("main"),
2205 "tab 0 should have updated branch from payload"
2206 );
2207 assert!(
2209 state.tabs[1].repo_info.is_none(),
2210 "tab 1 (empty) must NOT receive the refresh payload"
2211 );
2212 assert!(
2213 state.tabs[1].repo_path.is_none(),
2214 "tab 1 should still have no repo path"
2215 );
2216 }
2217
2218 #[test]
2219 fn repo_refreshed_targets_active_tab_for_new_open() {
2220 use crate::message::Message;
2221 let mut state = GitKraft::new();
2222 assert_eq!(state.tabs.len(), 1);
2226 assert!(!state.active_tab().has_repo());
2227
2228 let payload = fake_payload("/home/user/new-repo");
2229 let _task = state.update(Message::RepoOpened(Ok(payload)));
2230
2231 assert_eq!(
2233 state.tabs[0].repo_path.as_deref(),
2234 Some(std::path::Path::new("/home/user/new-repo"))
2235 );
2236 assert!(state.tabs[0].repo_info.is_some());
2237 }
2238
2239 #[test]
2240 fn repo_refreshed_does_not_duplicate_into_new_tab() {
2241 use crate::message::Message;
2242 let mut state = GitKraft::new();
2243 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2245
2246 let _task = state.update(Message::NewTab);
2248 assert_eq!(state.tabs.len(), 2);
2249 assert_eq!(state.active_tab, 1);
2250
2251 let payload = fake_payload("/home/user/repo-a");
2253 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2254
2255 assert!(
2257 state.tabs[1].repo_path.is_none(),
2258 "new empty tab must not receive repo-a refresh"
2259 );
2260 assert!(
2261 state.tabs[1].repo_info.is_none(),
2262 "new empty tab must not have repo_info"
2263 );
2264 assert_eq!(
2266 state.tabs[0].repo_path.as_deref(),
2267 Some(std::path::Path::new("/home/user/repo-a"))
2268 );
2269 }
2270
2271 #[test]
2272 fn git_operation_result_targets_correct_tab() {
2273 use crate::message::Message;
2274 let mut state = GitKraft::new();
2275 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2277 state.tabs.push(RepoTab::new_empty());
2279 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2280 state.active_tab = 1;
2281
2282 let payload = fake_payload("/home/user/repo-a");
2285 let _task = state.update(Message::GitOperationResult(Ok(payload)));
2286
2287 assert_eq!(state.tabs[0].current_branch.as_deref(), Some("main"));
2289 assert_eq!(
2291 state.tabs[1].repo_path.as_deref(),
2292 Some(std::path::Path::new("/home/user/repo-b"))
2293 );
2294 }
2295
2296 #[test]
2297 fn multiple_new_tabs_dont_get_polluted_by_refresh() {
2298 use crate::message::Message;
2299 let mut state = GitKraft::new();
2300 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2302
2303 let _task = state.update(Message::NewTab);
2305 let _task = state.update(Message::NewTab);
2306 assert_eq!(state.tabs.len(), 3);
2307 assert_eq!(state.active_tab, 2);
2308
2309 let payload = fake_payload("/home/user/repo-a");
2311 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2312
2313 assert!(state.tabs[0].repo_info.is_some());
2315 assert!(state.tabs[1].repo_info.is_none());
2316 assert!(state.tabs[2].repo_info.is_none());
2317 assert!(state.tabs[1].repo_path.is_none());
2318 assert!(state.tabs[2].repo_path.is_none());
2319 }
2320
2321 #[test]
2322 fn repo_selected_dedup_adjusts_index_when_existing_is_after_active() {
2323 use crate::message::Message;
2324 let mut state = GitKraft::new();
2325 state.tabs.push(RepoTab::new_empty());
2328 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
2329 state.active_tab = 0;
2330
2331 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2333 "/home/user/repo-a",
2334 ))));
2335
2336 assert_eq!(state.tabs.len(), 1);
2338 assert_eq!(state.active_tab, 0);
2339 assert_eq!(
2340 state.tabs[0].repo_path.as_deref(),
2341 Some(std::path::Path::new("/home/user/repo-a"))
2342 );
2343 }
2344
2345 #[test]
2348 fn apply_payload_preserves_multi_selection_by_oid() {
2349 let mut tab = RepoTab::new_empty();
2350 tab.commits = make_test_commits(5);
2351 tab.anchor_commit_index = Some(1);
2353 tab.selected_commits = vec![1, 2, 3];
2354 tab.selected_commit = Some(3);
2355 tab.selected_commit_oid = Some(tab.commits[3].oid.clone());
2356
2357 let mut payload = fake_payload("/tmp/repo");
2359 payload.commits = make_test_commits(5);
2360
2361 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2362
2363 assert_eq!(tab.anchor_commit_index, Some(1));
2365 assert_eq!(tab.selected_commits, vec![1, 2, 3]);
2366 assert_eq!(tab.selected_commit, Some(3));
2367 }
2368
2369 #[test]
2370 fn apply_payload_preserves_anchor_even_without_range() {
2371 let mut tab = RepoTab::new_empty();
2372 tab.commits = make_test_commits(5);
2373 tab.anchor_commit_index = Some(2);
2374 tab.selected_commit = Some(2);
2375 tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2376 let mut payload = fake_payload("/tmp/repo");
2379 payload.commits = make_test_commits(5);
2380
2381 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2382
2383 assert_eq!(tab.anchor_commit_index, Some(2));
2384 assert_eq!(tab.selected_commit, Some(2));
2385 }
2386
2387 #[test]
2388 fn apply_payload_clears_selection_when_commits_disappear() {
2389 let mut tab = RepoTab::new_empty();
2390 tab.commits = make_test_commits(5);
2391 tab.anchor_commit_index = Some(2);
2392 tab.selected_commits = vec![2, 3, 4];
2393 tab.selected_commit = Some(4);
2394 tab.selected_commit_oid = Some(tab.commits[4].oid.clone());
2395
2396 let mut payload = fake_payload("/tmp/repo");
2398 payload.commits = (0..3)
2399 .map(|i| gitkraft_core::CommitInfo {
2400 oid: format!("new_oid_{i}"),
2401 short_oid: format!("new_{i}"),
2402 summary: format!("new commit {i}"),
2403 message: String::new(),
2404 author_name: "Author".into(),
2405 author_email: "a@b.c".into(),
2406 time: Default::default(),
2407 parent_ids: Vec::new(),
2408 refs: Vec::new(),
2409 })
2410 .collect();
2411
2412 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2413
2414 assert!(tab.selected_commits.is_empty());
2416 assert!(tab.anchor_commit_index.is_none());
2417 assert!(tab.selected_commit.is_none());
2418 }
2419
2420 #[test]
2423 fn apply_payload_preserves_file_selection_when_commit_survives() {
2424 let mut tab = RepoTab::new_empty();
2425 tab.commits = make_test_commits(3);
2426 tab.selected_commit = Some(1);
2427 tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2428 tab.commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
2429
2430 tab.anchor_file_index = Some(0);
2432 tab.selected_commit_file_indices = vec![0, 1, 2];
2433 tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2434 old_file: String::new(),
2435 new_file: "a.rs".into(),
2436 status: gitkraft_core::FileStatus::Modified,
2437 hunks: vec![],
2438 }];
2439
2440 let mut payload = fake_payload("/tmp/repo");
2442 payload.commits = make_test_commits(3);
2443
2444 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2445
2446 assert_eq!(tab.anchor_file_index, Some(0));
2448 assert_eq!(tab.selected_commit_file_indices, vec![0, 1, 2]);
2449 assert!(
2450 !tab.multi_file_diffs.is_empty(),
2451 "multi_file_diffs must survive when commit is preserved"
2452 );
2453 }
2454
2455 #[test]
2456 fn apply_payload_clears_file_selection_when_commit_disappears() {
2457 let mut tab = RepoTab::new_empty();
2458 tab.commits = make_test_commits(3);
2459 tab.selected_commit = Some(1);
2460 tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2461 tab.commit_files = make_commit_files(&["a.rs", "b.rs"]);
2462 tab.anchor_file_index = Some(0);
2463 tab.selected_commit_file_indices = vec![0, 1];
2464 tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2465 old_file: String::new(),
2466 new_file: "a.rs".into(),
2467 status: gitkraft_core::FileStatus::Modified,
2468 hunks: vec![],
2469 }];
2470
2471 let mut payload = fake_payload("/tmp/repo");
2473 payload.commits = (0..2)
2474 .map(|i| gitkraft_core::CommitInfo {
2475 oid: format!("new_{i}"),
2476 short_oid: format!("n{i}"),
2477 summary: String::new(),
2478 message: String::new(),
2479 author_name: String::new(),
2480 author_email: String::new(),
2481 time: Default::default(),
2482 parent_ids: Vec::new(),
2483 refs: Vec::new(),
2484 })
2485 .collect();
2486
2487 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2488
2489 assert!(tab.anchor_file_index.is_none());
2491 assert!(tab.selected_commit_file_indices.is_empty());
2492 assert!(tab.multi_file_diffs.is_empty());
2493 assert!(tab.commit_files.is_empty());
2494 }
2495
2496 #[test]
2497 fn apply_payload_preserves_commit_range_diffs_when_commit_survives() {
2498 let mut tab = RepoTab::new_empty();
2499 tab.commits = make_test_commits(5);
2500 tab.selected_commit = Some(2);
2501 tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2502 tab.commit_range_diffs = vec![gitkraft_core::DiffInfo {
2503 old_file: String::new(),
2504 new_file: "x.rs".into(),
2505 status: gitkraft_core::FileStatus::Modified,
2506 hunks: vec![],
2507 }];
2508
2509 let mut payload = fake_payload("/tmp/repo");
2510 payload.commits = make_test_commits(5);
2511
2512 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2513
2514 assert!(
2515 !tab.commit_range_diffs.is_empty(),
2516 "commit_range_diffs must survive when commit is preserved"
2517 );
2518 }
2519
2520 #[test]
2523 fn preview_file_loaded_sets_selected_diff() {
2524 use crate::message::Message;
2525 let mut state = GitKraft::new();
2526 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2527
2528 let content = "line 1\nline 2\nline 3".to_string();
2529 let _ = state.update(Message::FilePreviewLoaded(Ok((
2530 "test.rs".to_string(),
2531 content,
2532 ))));
2533
2534 let diff = state.active_tab().selected_diff.as_ref().unwrap();
2535 assert_eq!(diff.new_file, "test.rs");
2536 assert_eq!(diff.hunks.len(), 1);
2537 assert_eq!(diff.hunks[0].lines.len(), 3);
2538 assert_eq!(state.active_tab().diff_scroll_offset, 0.0);
2539 }
2540
2541 #[test]
2542 fn preview_file_loaded_error_sets_error_message() {
2543 use crate::message::Message;
2544 let mut state = GitKraft::new();
2545 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2546
2547 let _ = state.update(Message::FilePreviewLoaded(Err("No such file".to_string())));
2548
2549 assert!(state.active_tab().error_message.is_some());
2550 assert!(state
2551 .active_tab()
2552 .error_message
2553 .as_ref()
2554 .unwrap()
2555 .contains("No such file"));
2556 }
2557
2558 #[test]
2561 fn close_repo_single_tab_shows_welcome_screen() {
2562 use crate::message::Message;
2566 let mut state = GitKraft::new();
2567 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2568 assert_eq!(state.tabs.len(), 1);
2569
2570 let _ = state.update(Message::CloseRepo);
2571
2572 assert_eq!(state.tabs.len(), 1, "last tab must not be removed");
2573 assert_eq!(state.active_tab, 0);
2574 assert!(!state.active_tab().has_repo(), "tab should be empty");
2575 }
2576
2577 #[test]
2578 fn close_repo_with_multiple_tabs_removes_current_tab() {
2579 use crate::message::Message;
2590 let mut state = GitKraft::new();
2591 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2593 state.tabs.push(RepoTab::new_empty());
2595 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2596 state.active_tab = 0;
2598
2599 let _ = state.update(Message::CloseRepo);
2600
2601 assert_eq!(state.tabs.len(), 1, "current tab must be removed");
2604 assert_eq!(state.active_tab, 0);
2605 assert_eq!(
2606 state.active_tab().repo_path.as_deref(),
2607 Some(std::path::Path::new("/home/user/repo-b")),
2608 "repo-b should be the surviving tab"
2609 );
2610 }
2611
2612 #[test]
2613 fn close_repo_second_tab_active_removes_it_and_keeps_first() {
2614 use crate::message::Message;
2617 let mut state = GitKraft::new();
2618 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2620 state.tabs.push(RepoTab::new_empty());
2622 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2623 state.active_tab = 1; let _ = state.update(Message::CloseRepo);
2626
2627 assert_eq!(state.tabs.len(), 1);
2628 assert_eq!(state.active_tab, 0);
2629 assert_eq!(
2630 state.active_tab().repo_path.as_deref(),
2631 Some(std::path::Path::new("/home/user/repo-a")),
2632 "repo-a should be the surviving tab"
2633 );
2634 }
2635
2636 #[test]
2639 fn close_tab_active_tab_first_position() {
2640 use crate::message::Message;
2642 let mut state = GitKraft::new();
2643 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2644 state.tabs.push(RepoTab::new_empty());
2645 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2646 state.active_tab = 0;
2647
2648 let _ = state.update(Message::CloseTab(0));
2649
2650 assert_eq!(state.tabs.len(), 1);
2651 assert_eq!(state.active_tab, 0);
2652 assert_eq!(
2653 state.active_tab().repo_path.as_deref(),
2654 Some(std::path::Path::new("/home/user/repo-b"))
2655 );
2656 }
2657
2658 #[test]
2659 fn close_tab_active_tab_second_position() {
2660 use crate::message::Message;
2662 let mut state = GitKraft::new();
2663 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2664 state.tabs.push(RepoTab::new_empty());
2665 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2666 state.active_tab = 1;
2667
2668 let _ = state.update(Message::CloseTab(1));
2669
2670 assert_eq!(state.tabs.len(), 1);
2671 assert_eq!(state.active_tab, 0);
2672 assert_eq!(
2673 state.active_tab().repo_path.as_deref(),
2674 Some(std::path::Path::new("/home/user/repo-a"))
2675 );
2676 }
2677
2678 #[test]
2679 fn close_tab_non_active_tab_before_active() {
2680 use crate::message::Message;
2682 let mut state = GitKraft::new();
2683 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2684 state.tabs.push(RepoTab::new_empty());
2685 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2686 state.active_tab = 1;
2687
2688 let _ = state.update(Message::CloseTab(0));
2689
2690 assert_eq!(state.tabs.len(), 1);
2691 assert_eq!(state.active_tab, 0, "active_tab must shift down by 1");
2692 assert_eq!(
2693 state.active_tab().repo_path.as_deref(),
2694 Some(std::path::Path::new("/home/user/repo-b")),
2695 "repo-b must remain active"
2696 );
2697 }
2698
2699 #[test]
2700 fn close_tab_non_active_tab_after_active() {
2701 use crate::message::Message;
2703 let mut state = GitKraft::new();
2704 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2705 state.tabs.push(RepoTab::new_empty());
2706 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2707 state.active_tab = 0;
2708
2709 let _ = state.update(Message::CloseTab(1));
2710
2711 assert_eq!(state.tabs.len(), 1);
2712 assert_eq!(state.active_tab, 0, "active_tab must remain unchanged");
2713 assert_eq!(
2714 state.active_tab().repo_path.as_deref(),
2715 Some(std::path::Path::new("/home/user/repo-a")),
2716 "repo-a must remain active"
2717 );
2718 }
2719
2720 #[test]
2721 fn close_tab_last_remaining_tab_is_noop() {
2722 use crate::message::Message;
2724 let mut state = GitKraft::new();
2725 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2726 assert_eq!(state.tabs.len(), 1);
2727
2728 let _ = state.update(Message::CloseTab(0));
2729
2730 assert_eq!(state.tabs.len(), 1, "last tab must not be removed");
2731 assert!(
2732 state.active_tab().has_repo(),
2733 "tab content must be unchanged"
2734 );
2735 }
2736
2737 #[test]
2738 fn close_tab_out_of_bounds_index_is_noop() {
2739 use crate::message::Message;
2741 let mut state = GitKraft::new();
2742 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2743 state.tabs.push(RepoTab::new_empty());
2744 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2745 assert_eq!(state.tabs.len(), 2);
2746
2747 let _ = state.update(Message::CloseTab(99));
2748
2749 assert_eq!(state.tabs.len(), 2, "no tab should be removed");
2750 }
2751
2752 #[test]
2755 fn close_tab_three_tabs_close_first_active() {
2756 use crate::message::Message;
2759 let mut state = GitKraft::new();
2760 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2761 state.tabs.push(RepoTab::new_empty());
2762 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2763 state.tabs.push(RepoTab::new_empty());
2764 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2765 state.active_tab = 0;
2766
2767 let _ = state.update(Message::CloseTab(0));
2768
2769 assert_eq!(state.tabs.len(), 2);
2770 assert_eq!(state.active_tab, 0);
2771 assert_eq!(
2772 state.active_tab().repo_path.as_deref(),
2773 Some(std::path::Path::new("/home/user/repo-b"))
2774 );
2775 }
2776
2777 #[test]
2778 fn close_tab_three_tabs_close_active_middle() {
2779 use crate::message::Message;
2782 let mut state = GitKraft::new();
2783 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2784 state.tabs.push(RepoTab::new_empty());
2785 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2786 state.tabs.push(RepoTab::new_empty());
2787 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2788 state.active_tab = 1;
2789
2790 let _ = state.update(Message::CloseTab(1));
2791
2792 assert_eq!(state.tabs.len(), 2);
2793 assert_eq!(
2794 state.active_tab, 1,
2795 "active_tab must stay at 1 (repo-c slides in)"
2796 );
2797 assert_eq!(
2798 state.active_tab().repo_path.as_deref(),
2799 Some(std::path::Path::new("/home/user/repo-c"))
2800 );
2801 }
2802
2803 #[test]
2804 fn close_tab_three_tabs_close_active_last() {
2805 use crate::message::Message;
2808 let mut state = GitKraft::new();
2809 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2810 state.tabs.push(RepoTab::new_empty());
2811 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2812 state.tabs.push(RepoTab::new_empty());
2813 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2814 state.active_tab = 2;
2815
2816 let _ = state.update(Message::CloseTab(2));
2817
2818 assert_eq!(state.tabs.len(), 2);
2819 assert_eq!(
2820 state.active_tab, 1,
2821 "active_tab must be clamped to last index"
2822 );
2823 assert_eq!(
2824 state.active_tab().repo_path.as_deref(),
2825 Some(std::path::Path::new("/home/user/repo-b"))
2826 );
2827 }
2828
2829 #[test]
2830 fn close_tab_three_tabs_close_first_when_middle_active() {
2831 use crate::message::Message;
2834 let mut state = GitKraft::new();
2835 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2836 state.tabs.push(RepoTab::new_empty());
2837 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2838 state.tabs.push(RepoTab::new_empty());
2839 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2840 state.active_tab = 1;
2841
2842 let _ = state.update(Message::CloseTab(0));
2843
2844 assert_eq!(state.tabs.len(), 2);
2845 assert_eq!(
2846 state.active_tab, 0,
2847 "active_tab must decrement when a tab before it is removed"
2848 );
2849 assert_eq!(
2850 state.active_tab().repo_path.as_deref(),
2851 Some(std::path::Path::new("/home/user/repo-b")),
2852 "repo-b must remain active"
2853 );
2854 }
2855
2856 #[test]
2857 fn close_tab_three_tabs_close_last_when_middle_active() {
2858 use crate::message::Message;
2861 let mut state = GitKraft::new();
2862 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2863 state.tabs.push(RepoTab::new_empty());
2864 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2865 state.tabs.push(RepoTab::new_empty());
2866 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2867 state.active_tab = 1;
2868
2869 let _ = state.update(Message::CloseTab(2));
2870
2871 assert_eq!(state.tabs.len(), 2);
2872 assert_eq!(
2873 state.active_tab, 1,
2874 "active_tab must not change when a tab after it is removed"
2875 );
2876 assert_eq!(
2877 state.active_tab().repo_path.as_deref(),
2878 Some(std::path::Path::new("/home/user/repo-b")),
2879 "repo-b must remain active"
2880 );
2881 }
2882
2883 #[test]
2886 fn close_repo_three_tabs_active_in_middle() {
2887 use crate::message::Message;
2890 let mut state = GitKraft::new();
2891 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2892 state.tabs.push(RepoTab::new_empty());
2893 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2894 state.tabs.push(RepoTab::new_empty());
2895 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2896 state.active_tab = 1;
2897
2898 let _ = state.update(Message::CloseRepo);
2899
2900 assert_eq!(state.tabs.len(), 2);
2901 assert_eq!(state.active_tab, 1);
2902 assert_eq!(
2903 state.active_tab().repo_path.as_deref(),
2904 Some(std::path::Path::new("/home/user/repo-c"))
2905 );
2906 }
2907
2908 #[test]
2909 fn close_repo_three_tabs_active_at_end() {
2910 use crate::message::Message;
2913 let mut state = GitKraft::new();
2914 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2915 state.tabs.push(RepoTab::new_empty());
2916 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2917 state.tabs.push(RepoTab::new_empty());
2918 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2919 state.active_tab = 2;
2920
2921 let _ = state.update(Message::CloseRepo);
2922
2923 assert_eq!(state.tabs.len(), 2);
2924 assert_eq!(state.active_tab, 1, "active_tab clamped to last index");
2925 assert_eq!(
2926 state.active_tab().repo_path.as_deref(),
2927 Some(std::path::Path::new("/home/user/repo-b"))
2928 );
2929 }
2930
2931 #[test]
2934 fn new_tab_appends_empty_tab_and_makes_it_active() {
2935 use crate::message::Message;
2936 let mut state = GitKraft::new();
2937 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2938 assert_eq!(state.tabs.len(), 1);
2939
2940 let _ = state.update(Message::NewTab);
2941
2942 assert_eq!(state.tabs.len(), 2, "a new empty tab must be appended");
2943 assert_eq!(state.active_tab, 1, "new tab must become active");
2944 assert!(!state.active_tab().has_repo(), "new tab must be empty");
2945 assert!(state.tabs[0].has_repo());
2947 }
2948
2949 #[test]
2950 fn new_tab_with_multiple_existing_tabs() {
2951 use crate::message::Message;
2952 let mut state = GitKraft::new();
2953 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2954 state.tabs.push(RepoTab::new_empty());
2955 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2956 state.active_tab = 0;
2957
2958 let _ = state.update(Message::NewTab);
2959
2960 assert_eq!(state.tabs.len(), 3);
2961 assert_eq!(state.active_tab, 2);
2962 assert!(!state.active_tab().has_repo());
2963 }
2964
2965 #[test]
2968 fn switch_tab_to_valid_index() {
2969 use crate::message::Message;
2970 let mut state = GitKraft::new();
2971 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2972 state.tabs.push(RepoTab::new_empty());
2973 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2974 state.active_tab = 0;
2975
2976 let _ = state.update(Message::SwitchTab(1));
2977
2978 assert_eq!(state.active_tab, 1);
2979 assert_eq!(
2980 state.active_tab().repo_path.as_deref(),
2981 Some(std::path::Path::new("/home/user/repo-b"))
2982 );
2983 }
2984
2985 #[test]
2986 fn switch_tab_to_same_index_is_noop() {
2987 use crate::message::Message;
2988 let mut state = GitKraft::new();
2989 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2990 state.tabs.push(RepoTab::new_empty());
2991 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2992 state.active_tab = 1;
2993
2994 let _ = state.update(Message::SwitchTab(1));
2995
2996 assert_eq!(state.active_tab, 1, "should remain on tab 1");
2997 }
2998
2999 #[test]
3000 fn switch_tab_out_of_bounds_is_noop() {
3001 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 = 0;
3008
3009 let _ = state.update(Message::SwitchTab(99));
3010
3011 assert_eq!(state.active_tab, 0, "out-of-bounds switch must be ignored");
3012 }
3013}