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