1use std::collections::HashSet;
2use std::path::PathBuf;
3
4use gitkraft_core::*;
5use iced::{Color, Point, Task};
6
7use crate::message::Message;
8use crate::theme::ThemeColors;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum DragTarget {
15 SidebarRight,
17 CommitLogRight,
19 DiffFileListRight,
22 StagingUnstagedRight,
24 StagingStagedRight,
26 CommitRefColumnRight,
28 CommitGraphColumnRight,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum DragTargetH {
35 StagingTop,
37 SidebarBranchesBottom,
39 SidebarStashBottom,
41}
42
43#[derive(Debug, Clone)]
45pub enum ContextMenu {
46 Branch {
48 name: String,
49 is_current: bool,
50 local_index: usize,
53 },
54 RemoteBranch { name: String },
56 Commit { index: usize, oid: String },
58 Stash { index: usize },
60 UnstagedFile { path: String },
62 StagedFile { path: String },
64 CommitFile { oid: String, file_path: String },
66}
67
68pub struct RepoTab {
72 pub repo_path: Option<PathBuf>,
75 pub repo_info: Option<RepoInfo>,
77
78 pub branches: Vec<BranchInfo>,
81 pub current_branch: Option<String>,
83
84 pub commits: Vec<CommitInfo>,
87 pub selected_commit: Option<usize>,
89 pub anchor_commit_index: Option<usize>,
91 pub selected_commits: Vec<usize>,
93 pub graph_rows: Vec<gitkraft_core::GraphRow>,
95
96 pub unstaged_changes: Vec<gitkraft_core::DiffFileEntry>,
99 pub staged_changes: Vec<gitkraft_core::DiffFileEntry>,
101 pub commit_files: Vec<gitkraft_core::DiffFileEntry>,
103 pub selected_commit_oid: Option<String>,
105 pub selected_file_index: Option<usize>,
107 pub is_loading_file_diff: bool,
109 pub anchor_file_index: Option<usize>,
112 pub selected_commit_file_indices: Vec<usize>,
115 pub multi_file_diffs: Vec<gitkraft_core::DiffInfo>,
117 pub commit_range_diffs: Vec<gitkraft_core::DiffInfo>,
120 pub selected_diff: Option<DiffInfo>,
122 pub commit_message: String,
124
125 pub stashes: Vec<StashEntry>,
128
129 pub remotes: Vec<RemoteInfo>,
132
133 pub show_commit_detail: bool,
136 pub new_branch_name: String,
138 pub show_branch_create: bool,
140 pub local_branches_expanded: bool,
142 pub remote_branches_expanded: bool,
144 pub stash_message: String,
146
147 pub selected_unstaged: std::collections::HashSet<String>,
149 pub selected_staged: std::collections::HashSet<String>,
151 pub anchor_unstaged_index: Option<usize>,
153 pub anchor_staged_index: Option<usize>,
155
156 pub pending_discard: Option<String>,
158
159 pub status_message: Option<String>,
162 pub error_message: Option<String>,
164 pub is_loading: bool,
166 pub context_menu_pos: (f32, f32),
169
170 pub context_menu: Option<ContextMenu>,
172 pub rename_branch_target: Option<String>,
174 pub rename_branch_input: String,
176
177 pub create_tag_target_oid: Option<String>,
179 pub create_tag_annotated: bool,
181 pub create_tag_name: String,
183 pub create_tag_message: String,
185 pub create_branch_at_oid: Option<String>,
187
188 pub commit_scroll_offset: f32,
192
193 pub diff_scroll_offset: f32,
195 pub commit_display: Vec<(String, String)>,
200
201 pub has_more_commits: bool,
203 pub is_loading_more_commits: bool,
205
206 pub file_history_path: Option<String>,
208 pub file_history_commits: Vec<gitkraft_core::CommitInfo>,
210 pub file_history_scroll: f32,
212
213 pub blame_path: Option<String>,
215 pub blame_lines: Vec<gitkraft_core::BlameLine>,
217 pub blame_scroll: f32,
219
220 pub pending_delete_file: Option<String>,
222}
223
224impl RepoTab {
225 pub fn new_empty() -> Self {
227 Self {
228 repo_path: None,
229 repo_info: None,
230 branches: Vec::new(),
231 current_branch: None,
232 commits: Vec::new(),
233 selected_commit: None,
234 anchor_commit_index: None,
235 selected_commits: Vec::new(),
236 graph_rows: Vec::new(),
237 unstaged_changes: Vec::new(),
238 staged_changes: Vec::new(),
239 commit_files: Vec::new(),
240 selected_commit_oid: None,
241 selected_file_index: None,
242 is_loading_file_diff: false,
243 anchor_file_index: None,
244 selected_commit_file_indices: Vec::new(),
245 multi_file_diffs: Vec::new(),
246 commit_range_diffs: Vec::new(),
247 selected_diff: None,
248 commit_message: String::new(),
249 stashes: Vec::new(),
250 remotes: Vec::new(),
251 show_commit_detail: false,
252 new_branch_name: String::new(),
253 show_branch_create: false,
254 local_branches_expanded: true,
255 remote_branches_expanded: true,
256 stash_message: String::new(),
257 selected_unstaged: std::collections::HashSet::new(),
258 selected_staged: std::collections::HashSet::new(),
259 anchor_unstaged_index: None,
260 anchor_staged_index: None,
261 pending_discard: None,
262 status_message: None,
263 error_message: None,
264 is_loading: false,
265 context_menu: None,
266 context_menu_pos: (0.0, 0.0),
267 rename_branch_target: None,
268 rename_branch_input: String::new(),
269 create_tag_target_oid: None,
270 create_tag_annotated: false,
271 create_tag_name: String::new(),
272 create_tag_message: String::new(),
273 create_branch_at_oid: None,
274 commit_scroll_offset: 0.0,
275 diff_scroll_offset: 0.0,
276 commit_display: Vec::new(),
277 has_more_commits: true,
278 is_loading_more_commits: false,
279 file_history_path: None,
280 file_history_commits: Vec::new(),
281 file_history_scroll: 0.0,
282 blame_path: None,
283 blame_lines: Vec::new(),
284 blame_scroll: 0.0,
285 pending_delete_file: None,
286 }
287 }
288
289 pub fn has_repo(&self) -> bool {
291 self.repo_path.is_some()
292 }
293
294 pub fn default_remote(&self) -> String {
296 self.remotes
297 .first()
298 .map(|r| r.name.clone())
299 .unwrap_or_else(|| "origin".to_string())
300 }
301
302 pub fn display_name(&self) -> String {
304 gitkraft_core::repo_display_name(self.repo_path.as_deref())
305 }
306
307 pub fn apply_payload(
313 &mut self,
314 payload: crate::message::RepoPayload,
315 path: std::path::PathBuf,
316 ) {
317 let is_refresh = self.repo_info.is_some();
319 let prev_oid = self.selected_commit_oid.clone();
320
321 let prev_anchor_oid = self
325 .anchor_commit_index
326 .and_then(|i| self.commits.get(i).map(|c| c.oid.clone()));
327 let prev_selected_oids: Vec<String> = self
328 .selected_commits
329 .iter()
330 .filter_map(|&i| self.commits.get(i).map(|c| c.oid.clone()))
331 .collect();
332
333 let prev_anchor_file = self.anchor_file_index;
337 let prev_file_indices = self.selected_commit_file_indices.clone();
338 let prev_multi_file_diffs = std::mem::take(&mut self.multi_file_diffs);
339 let prev_commit_range_diffs = std::mem::take(&mut self.commit_range_diffs);
340
341 let prev_selected_unstaged = std::mem::take(&mut self.selected_unstaged);
344 let prev_selected_staged = std::mem::take(&mut self.selected_staged);
345 let prev_anchor_unstaged = self.anchor_unstaged_index;
346 let prev_anchor_staged = self.anchor_staged_index;
347
348 let prev_selected_diff = self.selected_diff.take();
351 let prev_diff_scroll = self.diff_scroll_offset;
352
353 self.current_branch = payload.info.head_branch.clone();
354 self.repo_path = Some(path);
355 self.repo_info = Some(payload.info);
356 self.branches = payload.branches;
357
358 if !is_refresh || payload.commits.len() >= self.commits.len() {
363 self.commits = payload.commits;
364 self.graph_rows = payload.graph_rows;
365 }
366
367 self.unstaged_changes = payload.unstaged;
368 self.staged_changes = payload.staged;
369 self.stashes = payload.stashes;
370 self.remotes = payload.remotes;
371
372 self.selected_commit = None;
379 self.anchor_commit_index = None;
380 self.selected_commits.clear();
381 self.selected_commit_oid = None;
382 self.error_message = None;
383 self.status_message = Some("Repository loaded.".into());
384 if !is_refresh {
389 self.has_more_commits = true;
390 }
391 self.anchor_unstaged_index = None;
398 self.anchor_staged_index = None;
399 self.anchor_file_index = None;
400 self.selected_commit_file_indices.clear();
401
402 if let Some(oid) = prev_oid {
408 if let Some(new_idx) = self.commits.iter().position(|c| c.oid == oid) {
409 self.selected_commit = Some(new_idx);
410 self.selected_commit_oid = Some(oid);
411 self.selected_diff = prev_selected_diff;
418 self.diff_scroll_offset = prev_diff_scroll;
419
420 self.anchor_file_index = prev_anchor_file;
423 self.selected_commit_file_indices = prev_file_indices;
424 self.multi_file_diffs = prev_multi_file_diffs;
425 self.commit_range_diffs = prev_commit_range_diffs;
426 } else {
427 self.clear_diff_state();
429 }
430 } else {
431 if prev_selected_diff.is_some() {
434 self.selected_diff = prev_selected_diff;
435 self.diff_scroll_offset = prev_diff_scroll;
436 } else {
437 self.clear_diff_state();
438 }
439 }
440
441 if let Some(anchor_oid) = prev_anchor_oid {
445 if let Some(new_anchor) = self.commits.iter().position(|c| c.oid == anchor_oid) {
446 self.anchor_commit_index = Some(new_anchor);
447 }
448 }
449 if !prev_selected_oids.is_empty() {
450 let restored: Vec<usize> = prev_selected_oids
451 .iter()
452 .filter_map(|oid| self.commits.iter().position(|c| &c.oid == oid))
453 .collect();
454 if !restored.is_empty() {
455 self.selected_commits = restored;
456 self.selected_commits.sort_unstable();
457 }
458 }
459
460 self.selected_unstaged = prev_selected_unstaged
463 .into_iter()
464 .filter(|p| self.unstaged_changes.iter().any(|d| d.display_path() == p))
465 .collect();
466 self.selected_staged = prev_selected_staged
467 .into_iter()
468 .filter(|p| self.staged_changes.iter().any(|d| d.display_path() == p))
469 .collect();
470 self.anchor_unstaged_index =
472 prev_anchor_unstaged.filter(|&i| i < self.unstaged_changes.len());
473 self.anchor_staged_index = prev_anchor_staged.filter(|&i| i < self.staged_changes.len());
474 }
475
476 fn clear_diff_state(&mut self) {
479 self.selected_diff = None;
480 self.commit_files.clear();
481 self.selected_file_index = None;
482 self.is_loading_file_diff = false;
483 self.diff_scroll_offset = 0.0;
484 }
485}
486
487pub struct GitKraft {
491 pub tabs: Vec<RepoTab>,
494 pub active_tab: usize,
496
497 pub sidebar_expanded: bool,
500
501 pub sidebar_width: f32,
504 pub commit_log_width: f32,
506 pub staging_height: f32,
508 pub diff_file_list_width: f32,
510 pub commit_ref_width: f32,
512 pub commit_graph_width: f32,
514 pub sidebar_branches_ratio: f32,
516 pub sidebar_stash_ratio: f32,
519 pub staging_unstaged_ratio: f32,
521 pub staging_staged_ratio: f32,
524
525 pub ui_scale: f32,
527
528 pub dragging: Option<DragTarget>,
531 pub dragging_h: Option<DragTargetH>,
533 pub drag_start_x: f32,
535 pub drag_start_y: f32,
537 pub drag_initialized: bool,
541 pub drag_initialized_h: bool,
543
544 pub cursor_pos: Point,
549
550 pub current_theme_index: usize,
553
554 pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
557
558 pub search_visible: bool,
561 pub search_query: String,
563 pub search_results: Vec<gitkraft_core::CommitInfo>,
565 pub search_selected: Option<usize>,
567
568 pub search_diff_files: Vec<gitkraft_core::DiffFileEntry>,
570 pub search_diff_selected: HashSet<usize>,
572 pub search_diff_content: Vec<gitkraft_core::DiffInfo>,
574 pub search_diff_oid: Option<String>,
576
577 pub editor: gitkraft_core::Editor,
579
580 pub keyboard_modifiers: iced::keyboard::Modifiers,
582
583 pub animation_tick: u64,
586
587 pub window_width: f32,
590 pub window_height: f32,
592 pub window_x: f32,
594 pub window_y: f32,
596
597 pub preview_editor: Option<iced::widget::text_editor::Content>,
599 pub preview_path: Option<String>,
601}
602
603impl Default for GitKraft {
604 fn default() -> Self {
605 Self::new()
606 }
607}
608
609impl GitKraft {
610 fn from_settings(settings: gitkraft_core::AppSettings) -> Self {
616 let current_theme_index = settings
617 .theme_name
618 .as_deref()
619 .map(gitkraft_core::theme_index_by_name)
620 .unwrap_or(0);
621
622 let recent_repos = settings.recent_repos;
623
624 let (
625 sidebar_width,
626 commit_log_width,
627 staging_height,
628 diff_file_list_width,
629 sidebar_expanded,
630 ui_scale,
631 commit_ref_width,
632 commit_graph_width,
633 ) = if let Some(ref layout) = settings.layout {
634 (
635 layout.sidebar_width.unwrap_or(220.0),
636 layout.commit_log_width.unwrap_or(500.0),
637 layout.staging_height.unwrap_or(200.0),
638 layout.diff_file_list_width.unwrap_or(180.0),
639 layout.sidebar_expanded.unwrap_or(true),
640 layout.ui_scale.unwrap_or(1.0),
641 layout.commit_ref_width.unwrap_or(120.0),
642 layout.commit_graph_width.unwrap_or(80.0),
643 )
644 } else {
645 (220.0, 500.0, 200.0, 180.0, true, 1.0, 120.0, 80.0)
646 };
647
648 Self {
649 tabs: vec![RepoTab::new_empty()],
650 active_tab: 0,
651
652 sidebar_expanded,
653
654 sidebar_width,
655 commit_log_width,
656 staging_height,
657 diff_file_list_width,
658 commit_ref_width,
659 commit_graph_width,
660 sidebar_branches_ratio: 0.6,
661 sidebar_stash_ratio: 0.25,
662 staging_unstaged_ratio: 0.4,
663 staging_staged_ratio: 0.3,
664
665 ui_scale,
666
667 dragging: None,
668 dragging_h: None,
669 drag_start_x: 0.0,
670 drag_start_y: 0.0,
671 drag_initialized: false,
672 drag_initialized_h: false,
673 cursor_pos: Point::ORIGIN,
674
675 current_theme_index,
676
677 recent_repos,
678
679 search_visible: false,
680 search_query: String::new(),
681 search_results: Vec::new(),
682 search_selected: None,
683 search_diff_files: Vec::new(),
684 search_diff_selected: HashSet::new(),
685 search_diff_content: Vec::new(),
686 search_diff_oid: None,
687
688 keyboard_modifiers: iced::keyboard::Modifiers::default(),
689 animation_tick: 0,
690
691 window_width: settings
692 .layout
693 .as_ref()
694 .and_then(|l| l.window_width)
695 .unwrap_or(1400.0),
696 window_height: settings
697 .layout
698 .as_ref()
699 .and_then(|l| l.window_height)
700 .unwrap_or(800.0),
701 window_x: settings
702 .layout
703 .as_ref()
704 .and_then(|l| l.window_x)
705 .unwrap_or(0.0),
706 window_y: settings
707 .layout
708 .as_ref()
709 .and_then(|l| l.window_y)
710 .unwrap_or(0.0),
711
712 editor: settings
713 .editor_name
714 .as_deref()
715 .map(|name| {
716 gitkraft_core::EDITOR_NAMES
718 .iter()
719 .position(|n| n.eq_ignore_ascii_case(name))
720 .map(gitkraft_core::Editor::from_index)
721 .unwrap_or_else(|| {
722 if name.eq_ignore_ascii_case("none") {
723 gitkraft_core::Editor::None
724 } else {
725 gitkraft_core::Editor::Custom(name.to_string())
726 }
727 })
728 })
729 .unwrap_or_else(detect_system_editor),
730
731 preview_editor: None,
732 preview_path: None,
733 }
734 }
735
736 pub fn new() -> Self {
742 Self::from_settings(
743 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default(),
744 )
745 }
746
747 pub fn new_with_session_paths() -> (Self, Vec<PathBuf>) {
753 let settings =
754 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default();
755 let open_tabs = settings.open_tabs.clone();
756 let active_tab_index = settings.active_tab_index;
757
758 let mut state = Self::from_settings(settings);
759
760 if !open_tabs.is_empty() {
761 state.tabs = open_tabs
762 .iter()
763 .map(|path| {
764 let mut tab = RepoTab::new_empty();
765 tab.repo_path = Some(path.clone());
768 if path.exists() {
769 tab.is_loading = true;
770 tab.status_message = Some(format!(
771 "Loading {}…",
772 path.file_name().unwrap_or_default().to_string_lossy()
773 ));
774 } else {
775 tab.error_message =
776 Some(format!("Repository not found: {}", path.display()));
777 }
778 tab
779 })
780 .collect();
781 state.active_tab = active_tab_index.min(state.tabs.len().saturating_sub(1));
782 }
783
784 (state, open_tabs)
785 }
786
787 pub fn open_tab_paths(&self) -> Vec<PathBuf> {
790 self.tabs
791 .iter()
792 .filter(|t| t.repo_info.is_some())
793 .filter_map(|t| t.repo_path.clone())
794 .collect()
795 }
796
797 pub fn session_state(&self) -> (Vec<PathBuf>, usize) {
803 let mut paths = Vec::new();
804 let mut session_active: usize = 0;
805 let mut found_active = false;
806 for (i, tab) in self.tabs.iter().enumerate() {
807 if tab.repo_info.is_some() {
808 if let Some(ref p) = tab.repo_path {
809 if i == self.active_tab {
810 session_active = paths.len();
811 found_active = true;
812 }
813 paths.push(p.clone());
814 }
815 }
816 }
817 if !found_active {
820 session_active = 0;
821 }
822 (paths, session_active)
823 }
824
825 pub fn active_tab(&self) -> &RepoTab {
827 &self.tabs[self.active_tab]
828 }
829
830 pub fn active_tab_mut(&mut self) -> &mut RepoTab {
832 &mut self.tabs[self.active_tab]
833 }
834
835 pub fn dismiss_menu(&mut self) {
837 self.active_tab_mut().context_menu = None;
838 }
839
840 pub fn open_context_menu(&mut self, menu: ContextMenu) {
842 let pos = (self.cursor_pos.x, self.cursor_pos.y);
843 let tab = self.active_tab_mut();
844 tab.context_menu_pos = pos;
845 tab.context_menu = Some(menu);
846 }
847
848 pub fn has_repo(&self) -> bool {
850 self.active_tab().has_repo()
851 }
852
853 pub fn repo_display_name(&self) -> String {
855 self.active_tab().display_name()
856 }
857
858 pub fn colors(&self) -> ThemeColors {
865 ThemeColors::from_core(&gitkraft_core::theme_by_index(self.current_theme_index))
866 }
867
868 pub fn iced_theme(&self) -> iced::Theme {
877 let core = gitkraft_core::theme_by_index(self.current_theme_index);
878 let name = self.current_theme_name().to_string();
879
880 let palette = iced::theme::Palette {
881 background: rgb_to_iced(core.background),
882 text: rgb_to_iced(core.text_primary),
883 primary: rgb_to_iced(core.accent),
884 success: rgb_to_iced(core.success),
885 warning: rgb_to_iced(core.warning),
886 danger: rgb_to_iced(core.error),
887 };
888
889 iced::Theme::custom(name, palette)
890 }
891
892 pub fn switch_to_existing_tab(&mut self, path: &std::path::Path) -> bool {
896 let existing_idx = match self
897 .tabs
898 .iter()
899 .position(|t| t.repo_path.as_deref() == Some(path) && t.repo_info.is_some())
900 {
901 Some(idx) => idx,
902 None => return false,
903 };
904 if !self.active_tab().has_repo() && self.active_tab != existing_idx && self.tabs.len() > 1 {
907 self.tabs.remove(self.active_tab);
908 let adjusted = if existing_idx > self.active_tab {
909 existing_idx - 1
910 } else {
911 existing_idx
912 };
913 self.active_tab = adjusted;
914 } else {
915 self.active_tab = existing_idx;
916 }
917 true
918 }
919
920 pub fn current_theme_name(&self) -> &'static str {
922 gitkraft_core::THEME_NAMES
923 .get(self.current_theme_index)
924 .copied()
925 .unwrap_or("Default")
926 }
927
928 pub fn refresh_active_tab(&mut self) -> Task<Message> {
932 let tab = self.active_tab();
933 match tab.repo_path.clone() {
934 Some(path) => {
935 let depth = tab.commits.len();
936 crate::features::repo::commands::refresh_repo(path, depth)
937 }
938 None => Task::none(),
939 }
940 }
941
942 pub fn on_ok_refresh(
949 &mut self,
950 result: Result<(), String>,
951 ok_msg: &str,
952 err_prefix: &str,
953 ) -> Task<Message> {
954 match result {
955 Ok(()) => {
956 {
957 let tab = self.active_tab_mut();
958 tab.is_loading = false;
959 tab.status_message = Some(ok_msg.to_string());
960 }
961 self.refresh_active_tab()
962 }
963 Err(e) => {
964 let tab = self.active_tab_mut();
965 tab.is_loading = false;
966 tab.error_message = Some(format!("{err_prefix}: {e}"));
967 tab.status_message = None;
968 Task::none()
969 }
970 }
971 }
972
973 pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
975 gitkraft_core::LayoutSettings {
976 sidebar_width: Some(self.sidebar_width),
977 commit_log_width: Some(self.commit_log_width),
978 staging_height: Some(self.staging_height),
979 diff_file_list_width: Some(self.diff_file_list_width),
980 sidebar_expanded: Some(self.sidebar_expanded),
981 ui_scale: Some(self.ui_scale),
982 window_width: Some(self.window_width),
983 window_height: Some(self.window_height),
984 window_x: Some(self.window_x),
985 window_y: Some(self.window_y),
986 window_maximized: None, commit_ref_width: Some(self.commit_ref_width),
988 commit_graph_width: Some(self.commit_graph_width),
989 }
990 }
991}
992
993fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
995 Color::from_rgb8(rgb.r, rgb.g, rgb.b)
996}
997
998fn detect_system_editor() -> gitkraft_core::Editor {
1000 for var in ["VISUAL", "EDITOR"] {
1001 if let Ok(val) = std::env::var(var) {
1002 let bin = val.split('/').next_back().unwrap_or(&val).trim();
1003 return match bin {
1004 "nvim" | "neovim" => gitkraft_core::Editor::Neovim,
1005 "vim" => gitkraft_core::Editor::Vim,
1006 "hx" | "helix" => gitkraft_core::Editor::Helix,
1007 "nano" => gitkraft_core::Editor::Nano,
1008 "micro" => gitkraft_core::Editor::Micro,
1009 "emacs" => gitkraft_core::Editor::Emacs,
1010 "code" => gitkraft_core::Editor::VSCode,
1011 "zed" => gitkraft_core::Editor::Zed,
1012 "subl" => gitkraft_core::Editor::Sublime,
1013 _ => gitkraft_core::Editor::Custom(val),
1014 };
1015 }
1016 }
1017 gitkraft_core::Editor::None
1018}
1019
1020#[cfg(test)]
1023mod tests {
1024 use super::*;
1025
1026 #[test]
1027 fn new_defaults() {
1028 let state = GitKraft::new();
1029 assert!(state.active_tab().repo_path.is_none());
1030 assert!(!state.has_repo());
1031 assert_eq!(state.repo_display_name(), "New Tab");
1032 assert!(state.active_tab().commits.is_empty());
1033 assert!(state.sidebar_expanded);
1034 assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
1036 assert!(state.sidebar_width > 0.0);
1038 assert!(state.commit_log_width > 0.0);
1039 assert!(state.staging_height > 0.0);
1040 assert!(state.dragging.is_none());
1041 assert!(state.dragging_h.is_none());
1042 assert_eq!(state.tabs.len(), 1);
1044 assert_eq!(state.active_tab, 0);
1045 }
1046
1047 #[test]
1048 fn repo_display_name_extracts_basename() {
1049 let mut state = GitKraft::new();
1050 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
1051 assert_eq!(state.repo_display_name(), "my-project");
1052 }
1053
1054 #[test]
1055 fn colors_returns_theme_colors() {
1056 let state = GitKraft::new();
1057 let c = state.colors();
1058 assert!(c.bg.r < 0.5);
1060 }
1061
1062 #[test]
1063 fn iced_theme_is_custom_with_correct_palette() {
1064 let mut state = GitKraft::new();
1065
1066 state.current_theme_index = 0;
1068 let iced_t = state.iced_theme();
1069 let pal = iced_t.palette();
1070 assert!(pal.background.r < 0.5, "Default theme bg should be dark");
1071 assert_eq!(iced_t.to_string(), "Default");
1072
1073 let sol_idx = gitkraft_core::theme_index_by_name("Solarized Light");
1075 state.current_theme_index = sol_idx;
1076 let iced_t = state.iced_theme();
1077 let pal = iced_t.palette();
1078 assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
1079 assert_eq!(iced_t.to_string(), "Solarized Light");
1080
1081 let gruvbox_idx = gitkraft_core::theme_index_by_name("Gruvbox Dark");
1083 state.current_theme_index = gruvbox_idx;
1084 let iced_t = state.iced_theme();
1085 let pal = iced_t.palette();
1086 let core = gitkraft_core::theme_by_index(gruvbox_idx);
1087 let expected_accent = rgb_to_iced(core.accent);
1088 assert!(
1089 (pal.primary.r - expected_accent.r).abs() < 0.01
1090 && (pal.primary.g - expected_accent.g).abs() < 0.01
1091 && (pal.primary.b - expected_accent.b).abs() < 0.01,
1092 "Gruvbox Dark accent should match core accent"
1093 );
1094 }
1095
1096 #[test]
1097 fn iced_theme_name_round_trips_through_core() {
1098 for i in 0..gitkraft_core::THEME_COUNT {
1101 let mut state = GitKraft::new();
1102 state.current_theme_index = i;
1103 let iced_t = state.iced_theme();
1104 let name = iced_t.to_string();
1105 let resolved = gitkraft_core::theme_index_by_name(&name);
1106 assert_eq!(
1107 resolved,
1108 i,
1109 "theme index {i} ({}) did not round-trip through iced_theme name",
1110 gitkraft_core::THEME_NAMES[i]
1111 );
1112 }
1113 }
1114
1115 #[test]
1116 fn current_theme_name_round_trips() {
1117 let mut state = GitKraft::new();
1118 state.current_theme_index = gitkraft_core::theme_index_by_name("Dracula");
1119 assert_eq!(state.current_theme_name(), "Dracula");
1120 state.current_theme_index = 0;
1121 assert_eq!(state.current_theme_name(), "Default");
1122 }
1123
1124 #[test]
1125 fn repo_tab_new_empty() {
1126 let tab = RepoTab::new_empty();
1127 assert!(tab.repo_path.is_none());
1128 assert!(!tab.has_repo());
1129 assert_eq!(tab.display_name(), "New Tab");
1130 assert!(tab.commits.is_empty());
1131 assert!(tab.branches.is_empty());
1132 assert!(!tab.is_loading);
1133 }
1134
1135 #[test]
1136 fn repo_tab_display_name_with_path() {
1137 let mut tab = RepoTab::new_empty();
1138 tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
1139 assert!(tab.has_repo());
1140 assert_eq!(tab.display_name(), "cool-repo");
1141 }
1142
1143 #[test]
1144 fn search_defaults() {
1145 let state = GitKraft::new();
1146 assert!(!state.search_visible);
1147 assert!(state.search_query.is_empty());
1148 assert!(state.search_results.is_empty());
1149 assert!(state.search_selected.is_none());
1150 }
1151
1152 #[test]
1153 fn context_menu_variants_exist() {
1154 use crate::state::ContextMenu;
1156
1157 let _branch = ContextMenu::Branch {
1158 name: "main".to_string(),
1159 is_current: true,
1160 local_index: 0,
1161 };
1162 let _remote = ContextMenu::RemoteBranch {
1163 name: "origin/main".to_string(),
1164 };
1165 let _commit = ContextMenu::Commit {
1166 index: 0,
1167 oid: "abc1234".to_string(),
1168 };
1169 let _stash = ContextMenu::Stash { index: 0 };
1170 let _unstaged = ContextMenu::UnstagedFile {
1171 path: "src/main.rs".to_string(),
1172 };
1173 let _staged = ContextMenu::StagedFile {
1174 path: "src/lib.rs".to_string(),
1175 };
1176 }
1177
1178 #[test]
1179 fn repo_tab_context_menu_defaults_to_none() {
1180 let tab = crate::state::RepoTab::new_empty();
1181 assert!(tab.context_menu.is_none());
1182 }
1183
1184 #[test]
1185 fn context_menu_variants_constructable() {
1186 use crate::state::ContextMenu;
1187 let _ = ContextMenu::Stash { index: 0 };
1188 let _ = ContextMenu::UnstagedFile {
1189 path: "a.rs".into(),
1190 };
1191 let _ = ContextMenu::StagedFile {
1192 path: "b.rs".into(),
1193 };
1194 }
1195
1196 #[test]
1197 fn selected_unstaged_defaults_empty() {
1198 let tab = crate::state::RepoTab::new_empty();
1199 assert!(tab.selected_unstaged.is_empty());
1200 assert!(tab.selected_staged.is_empty());
1201 }
1202
1203 #[test]
1204 fn selected_unstaged_toggle() {
1205 let mut tab = crate::state::RepoTab::new_empty();
1206 tab.selected_unstaged.insert("a.rs".to_string());
1207 tab.selected_unstaged.insert("b.rs".to_string());
1208 assert_eq!(tab.selected_unstaged.len(), 2);
1209 assert!(tab.selected_unstaged.contains("a.rs"));
1210 tab.selected_unstaged.remove("a.rs");
1211 assert_eq!(tab.selected_unstaged.len(), 1);
1212 assert!(!tab.selected_unstaged.contains("a.rs"));
1213 }
1214
1215 #[test]
1216 fn detect_system_editor_returns_valid() {
1217 let editor = super::detect_system_editor();
1219 let _ = editor.display_name();
1220 }
1221
1222 #[test]
1225 fn selected_commit_file_indices_defaults_to_empty_vec() {
1226 let tab = RepoTab::new_empty();
1227 assert!(tab.selected_commit_file_indices.is_empty());
1228 let v: &Vec<usize> = &tab.selected_commit_file_indices;
1230 assert_eq!(v.len(), 0);
1231 }
1232
1233 #[test]
1234 fn multi_file_diffs_defaults_empty() {
1235 let tab = RepoTab::new_empty();
1236 assert!(tab.multi_file_diffs.is_empty());
1237 }
1238
1239 #[test]
1240 fn keyboard_modifiers_default_has_no_shift() {
1241 let state = GitKraft::new();
1242 assert!(!state.keyboard_modifiers.shift());
1243 }
1244
1245 #[test]
1246 fn selected_commit_file_indices_preserves_insertion_order() {
1247 let mut tab = RepoTab::new_empty();
1248 tab.selected_commit_file_indices.push(5);
1249 tab.selected_commit_file_indices.push(2);
1250 tab.selected_commit_file_indices.push(8);
1251 assert_eq!(tab.selected_commit_file_indices, vec![5, 2, 8]);
1252 }
1253
1254 #[test]
1255 fn selected_commit_file_indices_cleared_on_reset() {
1256 let mut tab = RepoTab::new_empty();
1257 tab.selected_commit_file_indices.push(0);
1258 tab.selected_commit_file_indices.push(1);
1259 tab.selected_commit_file_indices.clear();
1260 assert!(tab.selected_commit_file_indices.is_empty());
1261 }
1262
1263 #[test]
1264 fn multi_file_diffs_cleared_on_reset() {
1265 let mut tab = RepoTab::new_empty();
1266 tab.multi_file_diffs.push(gitkraft_core::DiffInfo {
1267 old_file: String::new(),
1268 new_file: "a.rs".to_string(),
1269 status: gitkraft_core::FileStatus::Modified,
1270 hunks: vec![],
1271 });
1272 tab.multi_file_diffs.clear();
1273 assert!(tab.multi_file_diffs.is_empty());
1274 }
1275
1276 #[test]
1277 fn commit_range_diffs_defaults_empty() {
1278 let tab = RepoTab::new_empty();
1279 assert!(tab.commit_range_diffs.is_empty());
1280 }
1281
1282 #[test]
1283 fn commit_range_diffs_cleared_on_apply_payload() {
1284 let mut tab = RepoTab::new_empty();
1286 tab.commit_range_diffs.push(gitkraft_core::DiffInfo {
1287 old_file: String::new(),
1288 new_file: "x.rs".to_string(),
1289 status: gitkraft_core::FileStatus::Modified,
1290 hunks: vec![],
1291 });
1292 tab.commit_range_diffs.clear();
1293 assert!(tab.commit_range_diffs.is_empty());
1294 }
1295
1296 #[test]
1299 fn modifiers_changed_sets_shift_state() {
1300 use crate::message::Message;
1301 let mut state = GitKraft::new();
1302 assert!(!state.keyboard_modifiers.shift());
1303
1304 let _ = state.update(Message::ModifiersChanged(iced::keyboard::Modifiers::SHIFT));
1305 assert!(state.keyboard_modifiers.shift());
1306
1307 let _ = state.update(Message::ModifiersChanged(
1308 iced::keyboard::Modifiers::default(),
1309 ));
1310 assert!(!state.keyboard_modifiers.shift());
1311 }
1312
1313 fn make_commit_files(names: &[&str]) -> Vec<gitkraft_core::DiffFileEntry> {
1316 names
1317 .iter()
1318 .map(|name| gitkraft_core::DiffFileEntry {
1319 old_file: String::new(),
1320 new_file: name.to_string(),
1321 status: gitkraft_core::FileStatus::Modified,
1322 })
1323 .collect()
1324 }
1325
1326 #[test]
1327 fn select_diff_by_index_regular_click_clears_multi_selection() {
1328 use crate::message::Message;
1329 let mut state = GitKraft::new();
1330 state.active_tab_mut().repo_path =
1334 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1335 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1336 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1337 state.active_tab_mut().selected_commit_file_indices = vec![0, 1];
1339
1340 let _ = state.update(Message::SelectDiffByIndex(0));
1342
1343 assert!(state.active_tab().selected_commit_file_indices.is_empty());
1344 assert_eq!(state.active_tab().selected_file_index, Some(0));
1345 }
1346
1347 #[test]
1348 fn regular_click_preserves_multi_file_diffs_until_load_completes() {
1349 use crate::message::Message;
1350 let mut state = GitKraft::new();
1351 state.active_tab_mut().repo_path =
1352 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1353 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1354 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1355
1356 state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1358 old_file: String::new(),
1359 new_file: "a.rs".to_string(),
1360 status: gitkraft_core::FileStatus::Modified,
1361 hunks: vec![],
1362 }];
1363
1364 let _ = state.update(Message::SelectDiffByIndex(1));
1367
1368 assert!(
1369 !state.active_tab().multi_file_diffs.is_empty(),
1370 "multi_file_diffs must NOT be cleared on click (deferred to diff load)"
1371 );
1372 assert_eq!(state.active_tab().selected_file_index, Some(1));
1373 assert!(state.active_tab().is_loading_file_diff);
1374 }
1375
1376 #[test]
1377 fn regular_click_preserves_diff_scroll_until_load_completes() {
1378 use crate::message::Message;
1379 let mut state = GitKraft::new();
1380 state.active_tab_mut().repo_path =
1381 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1382 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1383 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1384 state.active_tab_mut().diff_scroll_offset = 150.0;
1385
1386 let _ = state.update(Message::SelectDiffByIndex(1));
1388
1389 assert_eq!(
1390 state.active_tab().diff_scroll_offset,
1391 150.0,
1392 "diff_scroll_offset must NOT be reset on click (deferred to diff load)"
1393 );
1394 }
1395
1396 #[test]
1397 fn single_file_diff_loaded_clears_deferred_state() {
1398 use crate::message::Message;
1399 let mut state = GitKraft::new();
1400 state.active_tab_mut().repo_path =
1401 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1402 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1403 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1404
1405 state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1407 old_file: String::new(),
1408 new_file: "a.rs".to_string(),
1409 status: gitkraft_core::FileStatus::Modified,
1410 hunks: vec![],
1411 }];
1412 state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1413 old_file: String::new(),
1414 new_file: "b.rs".to_string(),
1415 status: gitkraft_core::FileStatus::Modified,
1416 hunks: vec![],
1417 }];
1418 state.active_tab_mut().is_loading_file_diff = true;
1419 state.active_tab_mut().diff_scroll_offset = 200.0;
1420
1421 let new_diff = gitkraft_core::DiffInfo {
1423 old_file: String::new(),
1424 new_file: "b.rs".to_string(),
1425 status: gitkraft_core::FileStatus::Modified,
1426 hunks: vec![],
1427 };
1428 let _ = state.update(Message::SingleFileDiffLoaded(Ok(new_diff)));
1429
1430 assert!(
1432 state.active_tab().multi_file_diffs.is_empty(),
1433 "multi_file_diffs must be cleared when new diff arrives"
1434 );
1435 assert!(
1436 state.active_tab().commit_range_diffs.is_empty(),
1437 "commit_range_diffs must be cleared when new diff arrives"
1438 );
1439 assert!(
1440 state.active_tab().selected_diff.is_some(),
1441 "selected_diff must be set to the loaded diff"
1442 );
1443 assert!(
1444 !state.active_tab().is_loading_file_diff,
1445 "is_loading_file_diff must be cleared after load"
1446 );
1447 assert_eq!(
1448 state.active_tab().diff_scroll_offset,
1449 0.0,
1450 "diff_scroll_offset must be reset when new diff arrives"
1451 );
1452 }
1453
1454 #[test]
1455 fn regular_click_preserves_commit_range_diffs_until_load_completes() {
1456 use crate::message::Message;
1457 let mut state = GitKraft::new();
1458 state.active_tab_mut().repo_path =
1459 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1460 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1461 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1462
1463 state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1465 old_file: String::new(),
1466 new_file: "x.rs".to_string(),
1467 status: gitkraft_core::FileStatus::Modified,
1468 hunks: vec![],
1469 }];
1470
1471 let _ = state.update(Message::SelectDiffByIndex(0));
1472
1473 assert!(
1474 !state.active_tab().commit_range_diffs.is_empty(),
1475 "commit_range_diffs must NOT be cleared on click (deferred to diff load)"
1476 );
1477 }
1478
1479 #[test]
1480 fn select_diff_by_index_shift_click_adds_both_files_to_selection() {
1481 use crate::message::Message;
1482 let mut state = GitKraft::new();
1483 state.active_tab_mut().repo_path =
1484 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1485 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1486 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1487 state.active_tab_mut().selected_file_index = Some(0);
1488
1489 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1491 let _ = state.update(Message::SelectDiffByIndex(1));
1492
1493 let sel = &state.active_tab().selected_commit_file_indices;
1494 assert!(sel.contains(&0), "anchor file 0 should be selected");
1495 assert!(sel.contains(&1), "newly clicked file 1 should be selected");
1496 assert_eq!(sel.len(), 2);
1497 }
1498
1499 #[test]
1500 fn anchor_file_index_defaults_to_none() {
1501 let tab = RepoTab::new_empty();
1502 assert!(tab.anchor_file_index.is_none());
1503 }
1504
1505 #[test]
1506 fn regular_click_sets_anchor() {
1507 use crate::message::Message;
1508 let mut state = GitKraft::new();
1509 state.active_tab_mut().repo_path =
1510 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1511 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1512 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1513
1514 let _ = state.update(Message::SelectDiffByIndex(2));
1515
1516 assert_eq!(
1517 state.active_tab().anchor_file_index,
1518 Some(2),
1519 "regular click must set anchor to the clicked index"
1520 );
1521 }
1522
1523 #[test]
1524 fn shift_click_selects_range_downward_from_anchor() {
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 =
1531 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1532 state.active_tab_mut().anchor_file_index = Some(1);
1534 state.active_tab_mut().selected_file_index = Some(1);
1535
1536 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1538 let _ = state.update(Message::SelectDiffByIndex(4));
1539
1540 let sel = &state.active_tab().selected_commit_file_indices;
1541 assert_eq!(
1542 sel,
1543 &vec![1, 2, 3, 4],
1544 "range must be contiguous from anchor to click"
1545 );
1546 }
1547
1548 #[test]
1549 fn shift_click_selects_range_upward_from_anchor() {
1550 use crate::message::Message;
1551 let mut state = GitKraft::new();
1552 state.active_tab_mut().repo_path =
1553 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1554 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1555 state.active_tab_mut().commit_files =
1556 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1557 state.active_tab_mut().anchor_file_index = Some(4);
1559 state.active_tab_mut().selected_file_index = Some(4);
1560
1561 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1563 let _ = state.update(Message::SelectDiffByIndex(1));
1564
1565 let sel = &state.active_tab().selected_commit_file_indices;
1566 assert_eq!(
1567 sel,
1568 &vec![1, 2, 3, 4],
1569 "range must be stored ascending regardless of click direction"
1570 );
1571 }
1572
1573 #[test]
1574 fn shift_click_anchor_fixed_on_subsequent_clicks() {
1575 use crate::message::Message;
1576 let mut state = GitKraft::new();
1577 state.active_tab_mut().repo_path =
1578 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1579 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1580 state.active_tab_mut().commit_files =
1581 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1582 state.active_tab_mut().anchor_file_index = Some(2);
1584 state.active_tab_mut().selected_file_index = Some(2);
1585 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1586
1587 let _ = state.update(Message::SelectDiffByIndex(4));
1589 assert_eq!(
1590 state.active_tab().selected_commit_file_indices,
1591 vec![2, 3, 4]
1592 );
1593
1594 let _ = state.update(Message::SelectDiffByIndex(3));
1596 assert_eq!(
1597 state.active_tab().selected_commit_file_indices,
1598 vec![2, 3],
1599 "anchor must stay fixed; second Shift+Click shrinks the range"
1600 );
1601
1602 let _ = state.update(Message::SelectDiffByIndex(0));
1604 assert_eq!(
1605 state.active_tab().selected_commit_file_indices,
1606 vec![0, 1, 2],
1607 "anchor must stay fixed; can extend range in either direction"
1608 );
1609 }
1610
1611 #[test]
1612 fn shift_click_on_anchor_itself_gives_single_item_range() {
1613 use crate::message::Message;
1614 let mut state = GitKraft::new();
1615 state.active_tab_mut().repo_path =
1616 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1617 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1618 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1619 state.active_tab_mut().anchor_file_index = Some(1);
1620 state.active_tab_mut().selected_file_index = Some(1);
1621
1622 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1624 let _ = state.update(Message::SelectDiffByIndex(1));
1625
1626 assert_eq!(state.active_tab().selected_commit_file_indices, vec![1]);
1627 assert!(
1628 state.active_tab().multi_file_diffs.is_empty(),
1629 "single-item range must not populate multi_file_diffs"
1630 );
1631 }
1632
1633 #[test]
1634 fn shift_click_range_is_always_ascending() {
1635 use crate::message::Message;
1636 let mut state = GitKraft::new();
1637 state.active_tab_mut().repo_path =
1638 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1639 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1640 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs"]);
1641 state.active_tab_mut().anchor_file_index = Some(3);
1642 state.active_tab_mut().selected_file_index = Some(3);
1643
1644 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1645 let _ = state.update(Message::SelectDiffByIndex(0));
1646
1647 let sel = &state.active_tab().selected_commit_file_indices;
1648 let is_sorted = sel.windows(2).all(|w| w[0] < w[1]);
1649 assert!(
1650 is_sorted,
1651 "selection must always be stored in ascending order"
1652 );
1653 assert_eq!(sel, &vec![0, 1, 2, 3]);
1654 }
1655
1656 #[test]
1657 fn checkout_file_at_commit_message_variants_exist() {
1658 use crate::message::Message;
1659 let _single =
1661 Message::CheckoutFileAtCommit("abc123".to_string(), "src/main.rs".to_string());
1662 let _multi = Message::CheckoutMultiFilesAtCommit(
1663 "abc123".to_string(),
1664 vec!["a.rs".to_string(), "b.rs".to_string()],
1665 );
1666 }
1667
1668 #[test]
1669 fn checkout_file_at_commit_closes_context_menu() {
1670 use crate::message::Message;
1671 let mut state = GitKraft::new();
1672 state.active_tab_mut().repo_path =
1673 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1674 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1675 oid: "abc123".to_string(),
1676 file_path: "src/main.rs".to_string(),
1677 });
1678 let _ = state.update(Message::CheckoutFileAtCommit(
1679 "abc123".to_string(),
1680 "src/main.rs".to_string(),
1681 ));
1682 assert!(state.active_tab().context_menu.is_none());
1683 }
1684
1685 #[test]
1686 fn checkout_multi_files_at_commit_closes_context_menu() {
1687 use crate::message::Message;
1688 let mut state = GitKraft::new();
1689 state.active_tab_mut().repo_path =
1690 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1691 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1692 oid: "abc123".to_string(),
1693 file_path: "src/main.rs".to_string(),
1694 });
1695 let _ = state.update(Message::CheckoutMultiFilesAtCommit(
1696 "abc123".to_string(),
1697 vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
1698 ));
1699 assert!(state.active_tab().context_menu.is_none());
1700 }
1701
1702 fn make_test_commits(count: usize) -> Vec<gitkraft_core::CommitInfo> {
1705 (0..count)
1706 .map(|i| gitkraft_core::CommitInfo {
1707 oid: i.to_string(),
1708 short_oid: i.to_string(),
1709 summary: String::new(),
1710 message: String::new(),
1711 author_name: String::new(),
1712 author_email: String::new(),
1713 time: Default::default(),
1714 parent_ids: Vec::new(),
1715 refs: Vec::new(),
1716 })
1717 .collect()
1718 }
1719
1720 #[test]
1721 fn selected_commits_defaults_empty() {
1722 let tab = RepoTab::new_empty();
1723 assert!(tab.selected_commits.is_empty());
1724 assert!(tab.anchor_commit_index.is_none());
1725 }
1726
1727 #[test]
1728 fn regular_click_commit_sets_anchor_and_clears_range() {
1729 use crate::message::Message;
1730 let mut state = GitKraft::new();
1731 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
1732 state.active_tab_mut().commits = make_test_commits(3);
1733 state.active_tab_mut().selected_commits = vec![0, 1, 2];
1734
1735 let _ = state.update(Message::SelectCommit(1));
1736
1737 assert_eq!(state.active_tab().anchor_commit_index, Some(1));
1738 assert!(state.active_tab().selected_commits.is_empty());
1739 assert_eq!(state.active_tab().selected_commit, Some(1));
1740 }
1741
1742 #[test]
1743 fn shift_click_commit_selects_range_from_anchor() {
1744 use crate::message::Message;
1745 let mut state = GitKraft::new();
1746 state.active_tab_mut().commits = make_test_commits(5);
1747 state.active_tab_mut().anchor_commit_index = Some(1);
1748 state.active_tab_mut().selected_commit = Some(1);
1749
1750 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1751 let _ = state.update(Message::SelectCommit(4));
1752
1753 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3, 4]);
1754 }
1755
1756 #[test]
1757 fn shift_click_commit_range_is_ascending_when_clicking_above_anchor() {
1758 use crate::message::Message;
1759 let mut state = GitKraft::new();
1760 state.active_tab_mut().commits = make_test_commits(5);
1761 state.active_tab_mut().anchor_commit_index = Some(3);
1762 state.active_tab_mut().selected_commit = Some(3);
1763
1764 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1765 let _ = state.update(Message::SelectCommit(1));
1766
1767 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3]);
1768 }
1769
1770 #[test]
1773 fn execute_commit_action_closes_context_menu() {
1774 use crate::message::Message;
1775 let mut state = GitKraft::new();
1776 state.active_tab_mut().repo_path =
1777 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1778 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::Commit {
1779 index: 0,
1780 oid: "abc123".to_string(),
1781 });
1782
1783 let _ = state.update(Message::ExecuteCommitAction(
1784 "abc123".to_string(),
1785 gitkraft_core::CommitAction::CherryPick,
1786 ));
1787
1788 assert!(state.active_tab().context_menu.is_none());
1789 }
1790
1791 #[test]
1792 fn execute_commit_action_sets_loading_when_repo_open() {
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
1798 let _ = state.update(Message::ExecuteCommitAction(
1799 "abc123".to_string(),
1800 gitkraft_core::CommitAction::ResetHard,
1801 ));
1802
1803 assert!(state.active_tab().is_loading);
1804 }
1805
1806 #[test]
1807 fn execute_commit_action_no_repo_does_not_set_loading() {
1808 use crate::message::Message;
1809 let mut state = GitKraft::new();
1810 let _ = state.update(Message::ExecuteCommitAction(
1813 "abc123".to_string(),
1814 gitkraft_core::CommitAction::CherryPick,
1815 ));
1816
1817 assert!(!state.active_tab().is_loading);
1818 }
1819
1820 #[test]
1821 fn execute_commit_action_sets_status_message_from_action_label() {
1822 use crate::message::Message;
1823 let mut state = GitKraft::new();
1824 state.active_tab_mut().repo_path =
1825 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1826
1827 let _ = state.update(Message::ExecuteCommitAction(
1828 "abc123".to_string(),
1829 gitkraft_core::CommitAction::Revert,
1830 ));
1831
1832 let status = state.active_tab().status_message.as_deref().unwrap_or("");
1833 assert!(
1835 status.contains("Revert commit"),
1836 "expected status to contain 'Revert commit', got: {status:?}"
1837 );
1838 }
1839
1840 #[test]
1843 fn file_history_defaults_empty() {
1844 let tab = RepoTab::new_empty();
1845 assert!(tab.file_history_path.is_none());
1846 assert!(tab.file_history_commits.is_empty());
1847 assert_eq!(tab.file_history_scroll, 0.0);
1848 }
1849
1850 #[test]
1851 fn blame_defaults_empty() {
1852 let tab = RepoTab::new_empty();
1853 assert!(tab.blame_path.is_none());
1854 assert!(tab.blame_lines.is_empty());
1855 assert_eq!(tab.blame_scroll, 0.0);
1856 }
1857
1858 #[test]
1859 fn pending_delete_file_defaults_none() {
1860 let tab = RepoTab::new_empty();
1861 assert!(tab.pending_delete_file.is_none());
1862 }
1863
1864 #[test]
1865 fn view_file_history_sets_path_and_clears_blame() {
1866 use crate::message::Message;
1867 let mut state = GitKraft::new();
1868 state.active_tab_mut().repo_path =
1869 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1870 state.active_tab_mut().blame_path = Some("some/file.rs".to_string());
1871
1872 let _ = state.update(Message::ViewFileHistory("src/main.rs".to_string()));
1873
1874 assert_eq!(
1875 state.active_tab().file_history_path.as_deref(),
1876 Some("src/main.rs")
1877 );
1878 assert!(state.active_tab().blame_path.is_none());
1880 }
1881
1882 #[test]
1883 fn close_file_history_clears_state() {
1884 use crate::message::Message;
1885 let mut state = GitKraft::new();
1886 state.active_tab_mut().file_history_path = Some("src/lib.rs".to_string());
1887 state.active_tab_mut().file_history_commits = vec![gitkraft_core::CommitInfo {
1888 oid: "abc".to_string(),
1889 short_oid: "abc".to_string(),
1890 summary: "s".to_string(),
1891 message: "s".to_string(),
1892 author_name: "a".to_string(),
1893 author_email: "a@b.com".to_string(),
1894 time: Default::default(),
1895 parent_ids: vec![],
1896 refs: Vec::new(),
1897 }];
1898
1899 let _ = state.update(Message::CloseFileHistory);
1900
1901 assert!(state.active_tab().file_history_path.is_none());
1902 assert!(state.active_tab().file_history_commits.is_empty());
1903 }
1904
1905 #[test]
1906 fn view_file_blame_sets_path_and_clears_history() {
1907 use crate::message::Message;
1908 let mut state = GitKraft::new();
1909 state.active_tab_mut().repo_path =
1910 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1911 state.active_tab_mut().file_history_path = Some("some/file.rs".to_string());
1912
1913 let _ = state.update(Message::ViewFileBlame("src/lib.rs".to_string()));
1914
1915 assert_eq!(state.active_tab().blame_path.as_deref(), Some("src/lib.rs"));
1916 assert!(state.active_tab().file_history_path.is_none());
1918 }
1919
1920 #[test]
1921 fn selecting_new_commit_closes_blame_overlay() {
1922 use crate::message::Message;
1923 let mut state = GitKraft::new();
1924 state.active_tab_mut().repo_path =
1925 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1926 state.active_tab_mut().commits = vec![
1928 gitkraft_core::CommitInfo {
1929 oid: "abc1".into(),
1930 short_oid: "abc1".into(),
1931 summary: "first".into(),
1932 message: "first".into(),
1933 author_name: "A".into(),
1934 author_email: "a@a.com".into(),
1935 time: Default::default(),
1936 parent_ids: Vec::new(),
1937 refs: Vec::new(),
1938 },
1939 gitkraft_core::CommitInfo {
1940 oid: "abc2".into(),
1941 short_oid: "abc2".into(),
1942 summary: "second".into(),
1943 message: "second".into(),
1944 author_name: "A".into(),
1945 author_email: "a@a.com".into(),
1946 time: Default::default(),
1947 parent_ids: Vec::new(),
1948 refs: Vec::new(),
1949 },
1950 ];
1951 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1953 state.active_tab_mut().blame_lines = vec![gitkraft_core::BlameLine {
1954 line_number: 1,
1955 content: "fn main() {}".into(),
1956 short_oid: "abc1".into(),
1957 oid: "abc1".into(),
1958 author_name: "A".into(),
1959 time: Default::default(),
1960 }];
1961
1962 let _ = state.update(Message::SelectCommit(1));
1964
1965 assert!(
1966 state.active_tab().blame_path.is_none(),
1967 "blame_path must be cleared when a new commit is selected"
1968 );
1969 assert!(
1970 state.active_tab().blame_lines.is_empty(),
1971 "blame_lines must be cleared when a new commit is selected"
1972 );
1973 }
1974
1975 #[test]
1976 fn close_file_blame_clears_state() {
1977 use crate::message::Message;
1978 let mut state = GitKraft::new();
1979 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1980
1981 let _ = state.update(Message::CloseFileBlame);
1982
1983 assert!(state.active_tab().blame_path.is_none());
1984 assert!(state.active_tab().blame_lines.is_empty());
1985 }
1986
1987 #[test]
1988 fn delete_file_sets_pending() {
1989 use crate::message::Message;
1990 let mut state = GitKraft::new();
1991
1992 let _ = state.update(Message::DeleteFile("src/old.rs".to_string()));
1993
1994 assert_eq!(
1995 state.active_tab().pending_delete_file.as_deref(),
1996 Some("src/old.rs")
1997 );
1998 assert!(state.active_tab().context_menu.is_none());
1999 }
2000
2001 #[test]
2002 fn cancel_delete_file_clears_pending() {
2003 use crate::message::Message;
2004 let mut state = GitKraft::new();
2005 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
2006
2007 let _ = state.update(Message::CancelDeleteFile);
2008
2009 assert!(state.active_tab().pending_delete_file.is_none());
2010 }
2011
2012 #[test]
2013 fn confirm_delete_file_no_repo_is_noop() {
2014 use crate::message::Message;
2015 let mut state = GitKraft::new();
2016 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
2017 let _ = state.update(Message::ConfirmDeleteFile);
2020
2021 assert!(!state.active_tab().is_loading);
2022 }
2023
2024 #[test]
2025 fn shift_arrow_down_extends_file_list_selection_when_files_loaded() {
2026 use crate::message::Message;
2027 let mut state = GitKraft::new();
2028 state.active_tab_mut().repo_path =
2029 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
2030 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
2031 state.active_tab_mut().selected_file_index = Some(0);
2032 state.active_tab_mut().anchor_file_index = Some(0);
2033 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
2035
2036 let _ = state.update(Message::ShiftArrowDown);
2037
2038 assert_eq!(state.active_tab().selected_file_index, Some(1));
2039 assert!(state.active_tab().selected_commit_file_indices.contains(&0));
2041 assert!(state.active_tab().selected_commit_file_indices.contains(&1));
2042 }
2043
2044 #[test]
2045 fn shift_arrow_down_falls_through_to_commit_log_when_no_files() {
2046 use crate::message::Message;
2047 let mut state = GitKraft::new();
2048 state.active_tab_mut().commits = make_test_commits(5);
2049 state.active_tab_mut().selected_commit = Some(1);
2050 state.active_tab_mut().anchor_commit_index = Some(1);
2051 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
2052 let _ = state.update(Message::ShiftArrowDown);
2055
2056 assert_eq!(state.active_tab().selected_commit, Some(2));
2057 assert!(state.active_tab().selected_commits.contains(&1));
2058 assert!(state.active_tab().selected_commits.contains(&2));
2059 }
2060
2061 #[test]
2062 fn file_system_changed_triggers_full_refresh() {
2063 use crate::message::Message;
2064 let mut state = GitKraft::new();
2065 state.active_tab_mut().repo_path =
2066 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
2067
2068 let _task = state.update(Message::FileSystemChanged);
2072
2073 assert!(
2077 state.active_tab().error_message.is_none(),
2078 "FileSystemChanged must not set an error message"
2079 );
2080 }
2081
2082 fn fake_payload(workdir: &str) -> crate::message::RepoPayload {
2086 gitkraft_core::RepoSnapshot {
2087 info: gitkraft_core::RepoInfo {
2088 path: std::path::PathBuf::from(format!("{workdir}/.git")),
2089 workdir: Some(std::path::PathBuf::from(workdir)),
2090 head_branch: Some("main".into()),
2091 is_bare: false,
2092 state: gitkraft_core::RepoState::Clean,
2093 },
2094 branches: Vec::new(),
2095 commits: Vec::new(),
2096 graph_rows: Vec::new(),
2097 unstaged: Vec::new(),
2098 staged: Vec::new(),
2099 stashes: Vec::new(),
2100 remotes: Vec::new(),
2101 }
2102 }
2103
2104 fn setup_loaded_tab(tab: &mut RepoTab, path: &str) {
2106 tab.repo_path = Some(std::path::PathBuf::from(path));
2107 tab.repo_info = Some(gitkraft_core::RepoInfo {
2108 path: std::path::PathBuf::from(format!("{path}/.git")),
2109 workdir: Some(std::path::PathBuf::from(path)),
2110 head_branch: Some("main".into()),
2111 is_bare: false,
2112 state: gitkraft_core::RepoState::Clean,
2113 });
2114 }
2115
2116 #[test]
2117 fn open_repo_creates_new_tab_when_repo_already_open() {
2118 use crate::message::Message;
2119 let mut state = GitKraft::new();
2120 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2121
2122 assert_eq!(state.tabs.len(), 1);
2123 assert_eq!(state.active_tab, 0);
2124
2125 let _task = state.update(Message::OpenRepo);
2127
2128 assert_eq!(state.tabs.len(), 2);
2129 assert_eq!(state.active_tab, 1);
2130 assert!(state.tabs[1].is_loading);
2132 assert_eq!(
2134 state.tabs[0].repo_path.as_deref(),
2135 Some(std::path::Path::new("/home/user/repo-a"))
2136 );
2137 }
2138
2139 #[test]
2140 fn open_repo_reuses_empty_tab() {
2141 use crate::message::Message;
2142 let mut state = GitKraft::new();
2143 assert!(!state.active_tab().has_repo());
2145
2146 let _task = state.update(Message::OpenRepo);
2147
2148 assert_eq!(state.tabs.len(), 1);
2150 assert_eq!(state.active_tab, 0);
2151 assert!(state.tabs[0].is_loading);
2152 }
2153
2154 #[test]
2155 fn repo_selected_deduplicates_already_open_repo() {
2156 use crate::message::Message;
2157 let mut state = GitKraft::new();
2158 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2160 state.tabs.push(RepoTab::new_empty());
2162 state.active_tab = 1;
2163
2164 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2166 "/home/user/repo-a",
2167 ))));
2168
2169 assert_eq!(state.tabs.len(), 1);
2171 assert_eq!(state.active_tab, 0);
2172 assert_eq!(
2173 state.tabs[0].repo_path.as_deref(),
2174 Some(std::path::Path::new("/home/user/repo-a"))
2175 );
2176 }
2177
2178 #[test]
2179 fn repo_selected_opens_new_repo_in_empty_tab() {
2180 use crate::message::Message;
2181 let mut state = GitKraft::new();
2182 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2184 state.tabs.push(RepoTab::new_empty());
2186 state.active_tab = 1;
2187
2188 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2190 "/home/user/repo-b",
2191 ))));
2192
2193 assert_eq!(state.tabs.len(), 2);
2195 assert_eq!(state.active_tab, 1);
2196 assert!(state.tabs[1]
2197 .status_message
2198 .as_deref()
2199 .unwrap_or("")
2200 .contains("repo-b"));
2201 }
2202
2203 #[test]
2204 fn repo_selected_cancel_removes_empty_tab() {
2205 use crate::message::Message;
2206 let mut state = GitKraft::new();
2207 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2209 state.tabs.push(RepoTab::new_empty());
2211 state.active_tab = 1;
2212
2213 let _task = state.update(Message::RepoSelected(None));
2215
2216 assert_eq!(state.tabs.len(), 1);
2218 assert_eq!(state.active_tab, 0);
2219 assert_eq!(
2220 state.tabs[0].repo_path.as_deref(),
2221 Some(std::path::Path::new("/home/user/repo-a"))
2222 );
2223 }
2224
2225 #[test]
2226 fn repo_selected_cancel_keeps_tab_if_only_one() {
2227 use crate::message::Message;
2228 let mut state = GitKraft::new();
2229 assert_eq!(state.tabs.len(), 1);
2231 assert!(!state.active_tab().has_repo());
2232
2233 let _task = state.update(Message::RepoSelected(None));
2234
2235 assert_eq!(state.tabs.len(), 1);
2236 assert!(!state.active_tab().is_loading);
2237 }
2238
2239 #[test]
2240 fn open_recent_repo_deduplicates() {
2241 use crate::message::Message;
2242 let mut state = GitKraft::new();
2243 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2245 state.tabs.push(RepoTab::new_empty());
2247 state.active_tab = 1;
2248
2249 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2252 "/home/user/repo-a",
2253 )));
2254
2255 assert_eq!(state.tabs.len(), 1, "empty tab must be removed");
2256 assert_eq!(state.active_tab, 0);
2257 assert_eq!(
2258 state.active_tab().repo_path.as_deref(),
2259 Some(std::path::Path::new("/home/user/repo-a")),
2260 );
2261 }
2262
2263 #[test]
2264 fn open_recent_repo_creates_new_tab_when_current_has_repo() {
2265 use crate::message::Message;
2266 let mut state = GitKraft::new();
2267 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2269
2270 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2272 "/home/user/repo-b",
2273 )));
2274
2275 assert_eq!(state.tabs.len(), 2);
2276 assert_eq!(state.active_tab, 1);
2277 assert!(state.tabs[1].is_loading);
2278 }
2279
2280 #[test]
2281 fn open_recent_repo_uses_empty_tab() {
2282 use crate::message::Message;
2283 let mut state = GitKraft::new();
2284 assert!(!state.active_tab().has_repo());
2286
2287 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2288 "/home/user/repo-b",
2289 )));
2290
2291 assert_eq!(state.tabs.len(), 1);
2293 assert_eq!(state.active_tab, 0);
2294 assert!(state.tabs[0].is_loading);
2295 }
2296
2297 #[test]
2298 fn open_recent_repo_dedup_removes_empty_tab_with_multiple_loaded() {
2299 use crate::message::Message;
2302 let mut state = GitKraft::new();
2303 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2304 state.tabs.push(RepoTab::new_empty());
2305 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2306 state.tabs.push(RepoTab::new_empty());
2307 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2308 state.tabs.push(RepoTab::new_empty());
2310 state.active_tab = 3;
2311
2312 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2313 "/home/user/repo-b",
2314 )));
2315
2316 assert_eq!(state.tabs.len(), 3, "empty tab must be cleaned up");
2317 assert_eq!(state.active_tab, 1);
2318 assert_eq!(
2319 state.active_tab().repo_path.as_deref(),
2320 Some(std::path::Path::new("/home/user/repo-b")),
2321 );
2322 }
2323
2324 #[test]
2327 fn repo_refreshed_targets_correct_tab_after_tab_switch() {
2328 use crate::message::Message;
2329 let mut state = GitKraft::new();
2330 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2332 state.tabs.push(RepoTab::new_empty());
2334 state.active_tab = 1; let payload = fake_payload("/home/user/repo-a");
2338 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2339
2340 assert!(
2342 state.tabs[0].repo_info.is_some(),
2343 "tab 0 should still have repo info after refresh"
2344 );
2345 assert_eq!(
2346 state.tabs[0].current_branch.as_deref(),
2347 Some("main"),
2348 "tab 0 should have updated branch from payload"
2349 );
2350 assert!(
2352 state.tabs[1].repo_info.is_none(),
2353 "tab 1 (empty) must NOT receive the refresh payload"
2354 );
2355 assert!(
2356 state.tabs[1].repo_path.is_none(),
2357 "tab 1 should still have no repo path"
2358 );
2359 }
2360
2361 #[test]
2362 fn repo_refreshed_targets_active_tab_for_new_open() {
2363 use crate::message::Message;
2364 let mut state = GitKraft::new();
2365 assert_eq!(state.tabs.len(), 1);
2369 assert!(!state.active_tab().has_repo());
2370
2371 let payload = fake_payload("/home/user/new-repo");
2372 let _task = state.update(Message::RepoOpened(Ok(payload)));
2373
2374 assert_eq!(
2376 state.tabs[0].repo_path.as_deref(),
2377 Some(std::path::Path::new("/home/user/new-repo"))
2378 );
2379 assert!(state.tabs[0].repo_info.is_some());
2380 }
2381
2382 #[test]
2383 fn repo_refreshed_does_not_duplicate_into_new_tab() {
2384 use crate::message::Message;
2385 let mut state = GitKraft::new();
2386 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2388
2389 let _task = state.update(Message::NewTab);
2391 assert_eq!(state.tabs.len(), 2);
2392 assert_eq!(state.active_tab, 1);
2393
2394 let payload = fake_payload("/home/user/repo-a");
2396 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2397
2398 assert!(
2400 state.tabs[1].repo_path.is_none(),
2401 "new empty tab must not receive repo-a refresh"
2402 );
2403 assert!(
2404 state.tabs[1].repo_info.is_none(),
2405 "new empty tab must not have repo_info"
2406 );
2407 assert_eq!(
2409 state.tabs[0].repo_path.as_deref(),
2410 Some(std::path::Path::new("/home/user/repo-a"))
2411 );
2412 }
2413
2414 #[test]
2415 fn git_operation_result_targets_correct_tab() {
2416 use crate::message::Message;
2417 let mut state = GitKraft::new();
2418 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2420 state.tabs.push(RepoTab::new_empty());
2422 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2423 state.active_tab = 1;
2424
2425 let payload = fake_payload("/home/user/repo-a");
2428 let _task = state.update(Message::GitOperationResult(Ok(payload)));
2429
2430 assert_eq!(state.tabs[0].current_branch.as_deref(), Some("main"));
2432 assert_eq!(
2434 state.tabs[1].repo_path.as_deref(),
2435 Some(std::path::Path::new("/home/user/repo-b"))
2436 );
2437 }
2438
2439 #[test]
2440 fn multiple_new_tabs_dont_get_polluted_by_refresh() {
2441 use crate::message::Message;
2442 let mut state = GitKraft::new();
2443 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2445
2446 let _task = state.update(Message::NewTab);
2448 let _task = state.update(Message::NewTab);
2449 assert_eq!(state.tabs.len(), 3);
2450 assert_eq!(state.active_tab, 2);
2451
2452 let payload = fake_payload("/home/user/repo-a");
2454 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2455
2456 assert!(state.tabs[0].repo_info.is_some());
2458 assert!(state.tabs[1].repo_info.is_none());
2459 assert!(state.tabs[2].repo_info.is_none());
2460 assert!(state.tabs[1].repo_path.is_none());
2461 assert!(state.tabs[2].repo_path.is_none());
2462 }
2463
2464 #[test]
2465 fn repo_selected_dedup_adjusts_index_when_existing_is_after_active() {
2466 use crate::message::Message;
2467 let mut state = GitKraft::new();
2468 state.tabs.push(RepoTab::new_empty());
2471 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
2472 state.active_tab = 0;
2473
2474 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2476 "/home/user/repo-a",
2477 ))));
2478
2479 assert_eq!(state.tabs.len(), 1);
2481 assert_eq!(state.active_tab, 0);
2482 assert_eq!(
2483 state.tabs[0].repo_path.as_deref(),
2484 Some(std::path::Path::new("/home/user/repo-a"))
2485 );
2486 }
2487
2488 #[test]
2491 fn apply_payload_preserves_multi_selection_by_oid() {
2492 let mut tab = RepoTab::new_empty();
2493 tab.commits = make_test_commits(5);
2494 tab.anchor_commit_index = Some(1);
2496 tab.selected_commits = vec![1, 2, 3];
2497 tab.selected_commit = Some(3);
2498 tab.selected_commit_oid = Some(tab.commits[3].oid.clone());
2499
2500 let mut payload = fake_payload("/tmp/repo");
2502 payload.commits = make_test_commits(5);
2503
2504 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2505
2506 assert_eq!(tab.anchor_commit_index, Some(1));
2508 assert_eq!(tab.selected_commits, vec![1, 2, 3]);
2509 assert_eq!(tab.selected_commit, Some(3));
2510 }
2511
2512 #[test]
2513 fn apply_payload_preserves_anchor_even_without_range() {
2514 let mut tab = RepoTab::new_empty();
2515 tab.commits = make_test_commits(5);
2516 tab.anchor_commit_index = Some(2);
2517 tab.selected_commit = Some(2);
2518 tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2519 let mut payload = fake_payload("/tmp/repo");
2522 payload.commits = make_test_commits(5);
2523
2524 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2525
2526 assert_eq!(tab.anchor_commit_index, Some(2));
2527 assert_eq!(tab.selected_commit, Some(2));
2528 }
2529
2530 #[test]
2531 fn apply_payload_clears_selection_when_commits_disappear() {
2532 let mut tab = RepoTab::new_empty();
2533 tab.commits = make_test_commits(5);
2534 tab.anchor_commit_index = Some(2);
2535 tab.selected_commits = vec![2, 3, 4];
2536 tab.selected_commit = Some(4);
2537 tab.selected_commit_oid = Some(tab.commits[4].oid.clone());
2538
2539 let mut payload = fake_payload("/tmp/repo");
2541 payload.commits = (0..3)
2542 .map(|i| gitkraft_core::CommitInfo {
2543 oid: format!("new_oid_{i}"),
2544 short_oid: format!("new_{i}"),
2545 summary: format!("new commit {i}"),
2546 message: String::new(),
2547 author_name: "Author".into(),
2548 author_email: "a@b.c".into(),
2549 time: Default::default(),
2550 parent_ids: Vec::new(),
2551 refs: Vec::new(),
2552 })
2553 .collect();
2554
2555 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2556
2557 assert!(tab.selected_commits.is_empty());
2559 assert!(tab.anchor_commit_index.is_none());
2560 assert!(tab.selected_commit.is_none());
2561 }
2562
2563 #[test]
2566 fn apply_payload_preserves_file_selection_when_commit_survives() {
2567 let mut tab = RepoTab::new_empty();
2568 tab.commits = make_test_commits(3);
2569 tab.selected_commit = Some(1);
2570 tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2571 tab.commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
2572
2573 tab.anchor_file_index = Some(0);
2575 tab.selected_commit_file_indices = vec![0, 1, 2];
2576 tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2577 old_file: String::new(),
2578 new_file: "a.rs".into(),
2579 status: gitkraft_core::FileStatus::Modified,
2580 hunks: vec![],
2581 }];
2582
2583 let mut payload = fake_payload("/tmp/repo");
2585 payload.commits = make_test_commits(3);
2586
2587 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2588
2589 assert_eq!(tab.anchor_file_index, Some(0));
2591 assert_eq!(tab.selected_commit_file_indices, vec![0, 1, 2]);
2592 assert!(
2593 !tab.multi_file_diffs.is_empty(),
2594 "multi_file_diffs must survive when commit is preserved"
2595 );
2596 }
2597
2598 #[test]
2599 fn apply_payload_clears_file_selection_when_commit_disappears() {
2600 let mut tab = RepoTab::new_empty();
2601 tab.commits = make_test_commits(3);
2602 tab.selected_commit = Some(1);
2603 tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2604 tab.commit_files = make_commit_files(&["a.rs", "b.rs"]);
2605 tab.anchor_file_index = Some(0);
2606 tab.selected_commit_file_indices = vec![0, 1];
2607 tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2608 old_file: String::new(),
2609 new_file: "a.rs".into(),
2610 status: gitkraft_core::FileStatus::Modified,
2611 hunks: vec![],
2612 }];
2613
2614 let mut payload = fake_payload("/tmp/repo");
2616 payload.commits = (0..2)
2617 .map(|i| gitkraft_core::CommitInfo {
2618 oid: format!("new_{i}"),
2619 short_oid: format!("n{i}"),
2620 summary: String::new(),
2621 message: String::new(),
2622 author_name: String::new(),
2623 author_email: String::new(),
2624 time: Default::default(),
2625 parent_ids: Vec::new(),
2626 refs: Vec::new(),
2627 })
2628 .collect();
2629
2630 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2631
2632 assert!(tab.anchor_file_index.is_none());
2634 assert!(tab.selected_commit_file_indices.is_empty());
2635 assert!(tab.multi_file_diffs.is_empty());
2636 assert!(tab.commit_files.is_empty());
2637 }
2638
2639 #[test]
2640 fn apply_payload_preserves_commit_range_diffs_when_commit_survives() {
2641 let mut tab = RepoTab::new_empty();
2642 tab.commits = make_test_commits(5);
2643 tab.selected_commit = Some(2);
2644 tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2645 tab.commit_range_diffs = vec![gitkraft_core::DiffInfo {
2646 old_file: String::new(),
2647 new_file: "x.rs".into(),
2648 status: gitkraft_core::FileStatus::Modified,
2649 hunks: vec![],
2650 }];
2651
2652 let mut payload = fake_payload("/tmp/repo");
2653 payload.commits = make_test_commits(5);
2654
2655 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2656
2657 assert!(
2658 !tab.commit_range_diffs.is_empty(),
2659 "commit_range_diffs must survive when commit is preserved"
2660 );
2661 }
2662
2663 #[test]
2664 fn apply_payload_preserves_commit_message() {
2665 let mut tab = RepoTab::new_empty();
2668 tab.repo_path = Some(std::path::PathBuf::from("/tmp/repo"));
2669 tab.commit_message = "WIP: fixing the bug".to_string();
2670
2671 let payload = fake_payload("/tmp/repo");
2672 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2673
2674 assert_eq!(
2675 tab.commit_message, "WIP: fixing the bug",
2676 "commit_message must survive background repo refreshes"
2677 );
2678 }
2679
2680 #[test]
2683 fn preview_file_loaded_sets_preview_editor() {
2684 use crate::message::Message;
2685 let mut state = GitKraft::new();
2686 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2687
2688 let content = "line 1\nline 2\nline 3".to_string();
2689 let _ = state.update(Message::FilePreviewLoaded(Ok((
2690 "test.rs".to_string(),
2691 content,
2692 ))));
2693
2694 assert!(state.preview_editor.is_some());
2695 assert_eq!(state.preview_path.as_deref(), Some("test.rs"));
2696 assert!(state.active_tab().selected_diff.is_none());
2698 }
2699
2700 #[test]
2701 fn preview_file_loaded_error_sets_error_message() {
2702 use crate::message::Message;
2703 let mut state = GitKraft::new();
2704 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2705
2706 let _ = state.update(Message::FilePreviewLoaded(Err("No such file".to_string())));
2707
2708 assert!(state.active_tab().error_message.is_some());
2709 assert!(state
2710 .active_tab()
2711 .error_message
2712 .as_ref()
2713 .unwrap()
2714 .contains("No such file"));
2715 }
2716
2717 #[test]
2718 fn close_preview_clears_editor_state() {
2719 use crate::message::Message;
2720 let mut state = GitKraft::new();
2721 state.preview_editor = Some(iced::widget::text_editor::Content::with_text("hello"));
2722 state.preview_path = Some("/test/file.txt".to_string());
2723
2724 let _ = state.update(Message::ClosePreview);
2725
2726 assert!(state.preview_editor.is_none());
2727 assert!(state.preview_path.is_none());
2728 }
2729
2730 #[test]
2733 fn close_repo_single_tab_shows_welcome_screen() {
2734 use crate::message::Message;
2738 let mut state = GitKraft::new();
2739 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2740 assert_eq!(state.tabs.len(), 1);
2741
2742 let _ = state.update(Message::CloseRepo);
2743
2744 assert_eq!(state.tabs.len(), 1, "last tab must not be removed");
2745 assert_eq!(state.active_tab, 0);
2746 assert!(!state.active_tab().has_repo(), "tab should be empty");
2747 }
2748
2749 #[test]
2750 fn close_repo_with_multiple_tabs_removes_current_tab() {
2751 use crate::message::Message;
2762 let mut state = GitKraft::new();
2763 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2765 state.tabs.push(RepoTab::new_empty());
2767 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2768 state.active_tab = 0;
2770
2771 let _ = state.update(Message::CloseRepo);
2772
2773 assert_eq!(state.tabs.len(), 1, "current tab must be removed");
2776 assert_eq!(state.active_tab, 0);
2777 assert_eq!(
2778 state.active_tab().repo_path.as_deref(),
2779 Some(std::path::Path::new("/home/user/repo-b")),
2780 "repo-b should be the surviving tab"
2781 );
2782 }
2783
2784 #[test]
2785 fn close_repo_second_tab_active_removes_it_and_keeps_first() {
2786 use crate::message::Message;
2789 let mut state = GitKraft::new();
2790 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2792 state.tabs.push(RepoTab::new_empty());
2794 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2795 state.active_tab = 1; let _ = state.update(Message::CloseRepo);
2798
2799 assert_eq!(state.tabs.len(), 1);
2800 assert_eq!(state.active_tab, 0);
2801 assert_eq!(
2802 state.active_tab().repo_path.as_deref(),
2803 Some(std::path::Path::new("/home/user/repo-a")),
2804 "repo-a should be the surviving tab"
2805 );
2806 }
2807
2808 #[test]
2811 fn close_tab_active_tab_first_position() {
2812 use crate::message::Message;
2814 let mut state = GitKraft::new();
2815 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2816 state.tabs.push(RepoTab::new_empty());
2817 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2818 state.active_tab = 0;
2819
2820 let _ = state.update(Message::CloseTab(0));
2821
2822 assert_eq!(state.tabs.len(), 1);
2823 assert_eq!(state.active_tab, 0);
2824 assert_eq!(
2825 state.active_tab().repo_path.as_deref(),
2826 Some(std::path::Path::new("/home/user/repo-b"))
2827 );
2828 }
2829
2830 #[test]
2831 fn close_tab_active_tab_second_position() {
2832 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.active_tab = 1;
2839
2840 let _ = state.update(Message::CloseTab(1));
2841
2842 assert_eq!(state.tabs.len(), 1);
2843 assert_eq!(state.active_tab, 0);
2844 assert_eq!(
2845 state.active_tab().repo_path.as_deref(),
2846 Some(std::path::Path::new("/home/user/repo-a"))
2847 );
2848 }
2849
2850 #[test]
2851 fn close_tab_non_active_tab_before_active() {
2852 use crate::message::Message;
2854 let mut state = GitKraft::new();
2855 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2856 state.tabs.push(RepoTab::new_empty());
2857 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2858 state.active_tab = 1;
2859
2860 let _ = state.update(Message::CloseTab(0));
2861
2862 assert_eq!(state.tabs.len(), 1);
2863 assert_eq!(state.active_tab, 0, "active_tab must shift down by 1");
2864 assert_eq!(
2865 state.active_tab().repo_path.as_deref(),
2866 Some(std::path::Path::new("/home/user/repo-b")),
2867 "repo-b must remain active"
2868 );
2869 }
2870
2871 #[test]
2872 fn close_tab_non_active_tab_after_active() {
2873 use crate::message::Message;
2875 let mut state = GitKraft::new();
2876 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2877 state.tabs.push(RepoTab::new_empty());
2878 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2879 state.active_tab = 0;
2880
2881 let _ = state.update(Message::CloseTab(1));
2882
2883 assert_eq!(state.tabs.len(), 1);
2884 assert_eq!(state.active_tab, 0, "active_tab must remain unchanged");
2885 assert_eq!(
2886 state.active_tab().repo_path.as_deref(),
2887 Some(std::path::Path::new("/home/user/repo-a")),
2888 "repo-a must remain active"
2889 );
2890 }
2891
2892 #[test]
2893 fn close_tab_last_remaining_tab_is_noop() {
2894 use crate::message::Message;
2896 let mut state = GitKraft::new();
2897 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2898 assert_eq!(state.tabs.len(), 1);
2899
2900 let _ = state.update(Message::CloseTab(0));
2901
2902 assert_eq!(state.tabs.len(), 1, "last tab must not be removed");
2903 assert!(
2904 state.active_tab().has_repo(),
2905 "tab content must be unchanged"
2906 );
2907 }
2908
2909 #[test]
2910 fn close_tab_out_of_bounds_index_is_noop() {
2911 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 assert_eq!(state.tabs.len(), 2);
2918
2919 let _ = state.update(Message::CloseTab(99));
2920
2921 assert_eq!(state.tabs.len(), 2, "no tab should be removed");
2922 }
2923
2924 #[test]
2927 fn close_tab_three_tabs_close_first_active() {
2928 use crate::message::Message;
2931 let mut state = GitKraft::new();
2932 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2933 state.tabs.push(RepoTab::new_empty());
2934 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2935 state.tabs.push(RepoTab::new_empty());
2936 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2937 state.active_tab = 0;
2938
2939 let _ = state.update(Message::CloseTab(0));
2940
2941 assert_eq!(state.tabs.len(), 2);
2942 assert_eq!(state.active_tab, 0);
2943 assert_eq!(
2944 state.active_tab().repo_path.as_deref(),
2945 Some(std::path::Path::new("/home/user/repo-b"))
2946 );
2947 }
2948
2949 #[test]
2950 fn close_tab_three_tabs_close_active_middle() {
2951 use crate::message::Message;
2954 let mut state = GitKraft::new();
2955 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2956 state.tabs.push(RepoTab::new_empty());
2957 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2958 state.tabs.push(RepoTab::new_empty());
2959 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2960 state.active_tab = 1;
2961
2962 let _ = state.update(Message::CloseTab(1));
2963
2964 assert_eq!(state.tabs.len(), 2);
2965 assert_eq!(
2966 state.active_tab, 1,
2967 "active_tab must stay at 1 (repo-c slides in)"
2968 );
2969 assert_eq!(
2970 state.active_tab().repo_path.as_deref(),
2971 Some(std::path::Path::new("/home/user/repo-c"))
2972 );
2973 }
2974
2975 #[test]
2976 fn close_tab_three_tabs_close_active_last() {
2977 use crate::message::Message;
2980 let mut state = GitKraft::new();
2981 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2982 state.tabs.push(RepoTab::new_empty());
2983 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2984 state.tabs.push(RepoTab::new_empty());
2985 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2986 state.active_tab = 2;
2987
2988 let _ = state.update(Message::CloseTab(2));
2989
2990 assert_eq!(state.tabs.len(), 2);
2991 assert_eq!(
2992 state.active_tab, 1,
2993 "active_tab must be clamped to last index"
2994 );
2995 assert_eq!(
2996 state.active_tab().repo_path.as_deref(),
2997 Some(std::path::Path::new("/home/user/repo-b"))
2998 );
2999 }
3000
3001 #[test]
3002 fn close_tab_three_tabs_close_first_when_middle_active() {
3003 use crate::message::Message;
3006 let mut state = GitKraft::new();
3007 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3008 state.tabs.push(RepoTab::new_empty());
3009 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3010 state.tabs.push(RepoTab::new_empty());
3011 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3012 state.active_tab = 1;
3013
3014 let _ = state.update(Message::CloseTab(0));
3015
3016 assert_eq!(state.tabs.len(), 2);
3017 assert_eq!(
3018 state.active_tab, 0,
3019 "active_tab must decrement when a tab before it is removed"
3020 );
3021 assert_eq!(
3022 state.active_tab().repo_path.as_deref(),
3023 Some(std::path::Path::new("/home/user/repo-b")),
3024 "repo-b must remain active"
3025 );
3026 }
3027
3028 #[test]
3029 fn close_tab_three_tabs_close_last_when_middle_active() {
3030 use crate::message::Message;
3033 let mut state = GitKraft::new();
3034 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3035 state.tabs.push(RepoTab::new_empty());
3036 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3037 state.tabs.push(RepoTab::new_empty());
3038 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3039 state.active_tab = 1;
3040
3041 let _ = state.update(Message::CloseTab(2));
3042
3043 assert_eq!(state.tabs.len(), 2);
3044 assert_eq!(
3045 state.active_tab, 1,
3046 "active_tab must not change when a tab after it is removed"
3047 );
3048 assert_eq!(
3049 state.active_tab().repo_path.as_deref(),
3050 Some(std::path::Path::new("/home/user/repo-b")),
3051 "repo-b must remain active"
3052 );
3053 }
3054
3055 #[test]
3058 fn close_repo_three_tabs_active_in_middle() {
3059 use crate::message::Message;
3062 let mut state = GitKraft::new();
3063 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3064 state.tabs.push(RepoTab::new_empty());
3065 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3066 state.tabs.push(RepoTab::new_empty());
3067 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3068 state.active_tab = 1;
3069
3070 let _ = state.update(Message::CloseRepo);
3071
3072 assert_eq!(state.tabs.len(), 2);
3073 assert_eq!(state.active_tab, 1);
3074 assert_eq!(
3075 state.active_tab().repo_path.as_deref(),
3076 Some(std::path::Path::new("/home/user/repo-c"))
3077 );
3078 }
3079
3080 #[test]
3081 fn close_repo_three_tabs_active_at_end() {
3082 use crate::message::Message;
3085 let mut state = GitKraft::new();
3086 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3087 state.tabs.push(RepoTab::new_empty());
3088 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3089 state.tabs.push(RepoTab::new_empty());
3090 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3091 state.active_tab = 2;
3092
3093 let _ = state.update(Message::CloseRepo);
3094
3095 assert_eq!(state.tabs.len(), 2);
3096 assert_eq!(state.active_tab, 1, "active_tab clamped to last index");
3097 assert_eq!(
3098 state.active_tab().repo_path.as_deref(),
3099 Some(std::path::Path::new("/home/user/repo-b"))
3100 );
3101 }
3102
3103 #[test]
3106 fn new_tab_appends_empty_tab_and_makes_it_active() {
3107 use crate::message::Message;
3108 let mut state = GitKraft::new();
3109 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3110 assert_eq!(state.tabs.len(), 1);
3111
3112 let _ = state.update(Message::NewTab);
3113
3114 assert_eq!(state.tabs.len(), 2, "a new empty tab must be appended");
3115 assert_eq!(state.active_tab, 1, "new tab must become active");
3116 assert!(!state.active_tab().has_repo(), "new tab must be empty");
3117 assert!(state.tabs[0].has_repo());
3119 }
3120
3121 #[test]
3122 fn new_tab_with_multiple_existing_tabs() {
3123 use crate::message::Message;
3124 let mut state = GitKraft::new();
3125 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3126 state.tabs.push(RepoTab::new_empty());
3127 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3128 state.active_tab = 0;
3129
3130 let _ = state.update(Message::NewTab);
3131
3132 assert_eq!(state.tabs.len(), 3);
3133 assert_eq!(state.active_tab, 2);
3134 assert!(!state.active_tab().has_repo());
3135 }
3136
3137 #[test]
3140 fn switch_tab_to_valid_index() {
3141 use crate::message::Message;
3142 let mut state = GitKraft::new();
3143 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3144 state.tabs.push(RepoTab::new_empty());
3145 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3146 state.active_tab = 0;
3147
3148 let _ = state.update(Message::SwitchTab(1));
3149
3150 assert_eq!(state.active_tab, 1);
3151 assert_eq!(
3152 state.active_tab().repo_path.as_deref(),
3153 Some(std::path::Path::new("/home/user/repo-b"))
3154 );
3155 }
3156
3157 #[test]
3158 fn switch_tab_to_same_index_is_noop() {
3159 use crate::message::Message;
3160 let mut state = GitKraft::new();
3161 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3162 state.tabs.push(RepoTab::new_empty());
3163 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3164 state.active_tab = 1;
3165
3166 let _ = state.update(Message::SwitchTab(1));
3167
3168 assert_eq!(state.active_tab, 1, "should remain on tab 1");
3169 }
3170
3171 #[test]
3172 fn switch_tab_out_of_bounds_is_noop() {
3173 use crate::message::Message;
3175 let mut state = GitKraft::new();
3176 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3177 state.tabs.push(RepoTab::new_empty());
3178 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3179 state.active_tab = 0;
3180
3181 let _ = state.update(Message::SwitchTab(99));
3182
3183 assert_eq!(state.active_tab, 0, "out-of-bounds switch must be ignored");
3184 }
3185
3186 #[test]
3189 fn session_state_matches_open_tab_paths_when_no_empty_tabs() {
3190 let mut state = GitKraft::new();
3191 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3192 state.tabs.push(RepoTab::new_empty());
3193 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3194 state.active_tab = 1;
3195
3196 let (paths, active) = state.session_state();
3197 assert_eq!(paths.len(), 2);
3198 assert_eq!(active, 1);
3199 }
3200
3201 #[test]
3202 fn session_state_adjusts_index_when_empty_tab_before_active() {
3203 let mut state = GitKraft::new();
3206 state.tabs.push(RepoTab::new_empty());
3208 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
3209 state.tabs.push(RepoTab::new_empty());
3210 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-b");
3211 state.active_tab = 2;
3212
3213 let (paths, active) = state.session_state();
3214 assert_eq!(paths.len(), 2);
3215 assert_eq!(active, 1, "session index must skip the empty tab at 0");
3216 }
3217
3218 #[test]
3219 fn session_state_defaults_to_zero_when_active_is_empty_tab() {
3220 let mut state = GitKraft::new();
3221 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3222 state.tabs.push(RepoTab::new_empty()); state.active_tab = 1; let (paths, active) = state.session_state();
3226 assert_eq!(paths.len(), 1);
3227 assert_eq!(active, 0, "empty active tab should default to 0");
3228 }
3229
3230 #[test]
3233 fn repo_selected_sets_repo_path_before_load() {
3234 use crate::message::Message;
3238 let mut state = GitKraft::new();
3239 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3240 state.tabs.push(RepoTab::new_empty());
3242 state.active_tab = 1;
3243
3244 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
3245 "/home/user/repo-b",
3246 ))));
3247
3248 assert_eq!(
3250 state.tabs[1].repo_path.as_deref(),
3251 Some(std::path::Path::new("/home/user/repo-b")),
3252 "repo_path must be set before async load starts"
3253 );
3254 }
3255
3256 #[test]
3257 fn open_recent_repo_sets_repo_path_before_load() {
3258 use crate::message::Message;
3259 let mut state = GitKraft::new();
3260 assert!(!state.active_tab().has_repo());
3262
3263 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
3264 "/home/user/repo-a",
3265 )));
3266
3267 assert_eq!(
3268 state.active_tab().repo_path.as_deref(),
3269 Some(std::path::Path::new("/home/user/repo-a")),
3270 "repo_path must be set before async load starts"
3271 );
3272 }
3273
3274 #[test]
3275 fn handle_repo_loaded_targets_correct_tab_after_switch() {
3276 use crate::message::Message;
3280 let mut state = GitKraft::new();
3281 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3283 state.tabs.push(RepoTab::new_empty());
3285 state.tabs[1].repo_path = Some(std::path::PathBuf::from("/home/user/repo-b"));
3286 state.tabs[1].is_loading = true;
3287
3288 state.active_tab = 0;
3290
3291 let payload = fake_payload("/home/user/repo-b");
3293 let _ = state.update(Message::RepoOpened(Ok(payload)));
3294
3295 assert!(
3297 state.tabs[1].repo_info.is_some(),
3298 "repo-b payload must be written to tab 1"
3299 );
3300 assert_eq!(
3301 state.tabs[1].repo_path.as_deref(),
3302 Some(std::path::Path::new("/home/user/repo-b")),
3303 );
3304 assert_eq!(
3306 state.tabs[0].repo_path.as_deref(),
3307 Some(std::path::Path::new("/home/user/repo-a")),
3308 );
3309 }
3310
3311 #[test]
3314 fn apply_payload_does_not_reset_is_loading_more_commits() {
3315 use crate::message::Message;
3320 let mut state = GitKraft::new();
3321 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3322
3323 state.active_tab_mut().is_loading_more_commits = true;
3325
3326 let payload = fake_payload("/home/user/repo-a");
3328 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3329
3330 assert!(
3332 state.active_tab().is_loading_more_commits,
3333 "refresh must not reset is_loading_more_commits"
3334 );
3335 }
3336
3337 #[test]
3338 fn apply_payload_resets_is_loading_more_commits_for_new_open() {
3339 let mut state = GitKraft::new();
3344 assert!(!state.active_tab().is_loading_more_commits);
3345
3346 let payload = fake_payload("/home/user/repo-a");
3347 let _ = state.update(crate::message::Message::RepoOpened(Ok(payload)));
3348
3349 assert!(!state.active_tab().is_loading_more_commits);
3350 }
3351
3352 #[test]
3353 fn refresh_does_not_shrink_commit_list() {
3354 use crate::message::Message;
3357 let mut state = GitKraft::new();
3358 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3359
3360 let tab = state.active_tab_mut();
3362 tab.commits = make_test_commits(500);
3363 for i in 0..200 {
3364 let mut c = tab.commits[0].clone();
3365 c.oid = format!("extra_{i:04}");
3366 c.short_oid = format!("x{i:03}");
3367 tab.commits.push(c);
3368 }
3369 let extended_len = tab.commits.len();
3370 tab.has_more_commits = true;
3371
3372 let mut payload = fake_payload("/home/user/repo-a");
3374 payload.commits = make_test_commits(500);
3375 payload.graph_rows = gitkraft_core::features::graph::build_graph(&payload.commits);
3376 let payload_len = payload.commits.len();
3377 assert!(
3378 payload_len < extended_len,
3379 "payload must be shorter for this test"
3380 );
3381
3382 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3383
3384 assert_eq!(
3385 state.active_tab().commits.len(),
3386 extended_len,
3387 "refresh must not shrink the commit list"
3388 );
3389 }
3390
3391 #[test]
3392 fn refresh_replaces_commits_when_count_equal_or_greater() {
3393 use crate::message::Message;
3396 let mut state = GitKraft::new();
3397 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3398 state.active_tab_mut().commits = make_test_commits(5);
3399
3400 let original_oid = state.active_tab().commits[0].oid.clone();
3401
3402 let mut payload = fake_payload("/home/user/repo-a");
3404 let mut new_commits = make_test_commits(5);
3405 new_commits[0].oid = "refreshed_oid_0000".to_string();
3406 payload.commits = new_commits;
3407 payload.graph_rows = gitkraft_core::features::graph::build_graph(&payload.commits);
3408 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3409
3410 assert_eq!(
3411 state.active_tab().commits[0].oid,
3412 "refreshed_oid_0000",
3413 "refresh with same count must replace commits"
3414 );
3415 assert_ne!(state.active_tab().commits[0].oid, original_oid);
3416 }
3417
3418 #[test]
3419 fn duplicate_more_commits_loaded_sets_has_more_false() {
3420 use crate::message::{CommitPage, Message};
3426 let mut state = GitKraft::new();
3427 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3428 let tab = state.active_tab_mut();
3429 tab.commits = make_test_commits(10);
3430 tab.has_more_commits = true;
3431
3432 let _initial_count = tab.commits.len();
3434 let mut bigger_commits = tab.commits.clone();
3435 for i in 0..5 {
3436 let mut c = bigger_commits[0].clone();
3437 c.oid = format!("new_{i:04}");
3438 bigger_commits.push(c);
3439 }
3440 let bigger_count = bigger_commits.len();
3441 let graph_rows = gitkraft_core::features::graph::build_graph(&bigger_commits);
3442 let _ = state.update(Message::MoreCommitsLoaded(Ok(CommitPage {
3443 commits: bigger_commits.clone(),
3444 graph_rows: graph_rows.clone(),
3445 })));
3446 assert!(
3447 state.active_tab().has_more_commits,
3448 "first result should keep has_more true"
3449 );
3450 assert_eq!(state.active_tab().commits.len(), bigger_count);
3451
3452 let _ = state.update(Message::MoreCommitsLoaded(Ok(CommitPage {
3454 commits: bigger_commits,
3455 graph_rows,
3456 })));
3457 assert!(
3458 !state.active_tab().has_more_commits,
3459 "identical second result must set has_more_commits false"
3460 );
3461 }
3462
3463 #[test]
3464 fn file_system_changed_allowed_during_pagination() {
3465 use crate::message::Message;
3470 let mut state = GitKraft::new();
3471 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3472 state.active_tab_mut().is_loading_more_commits = true;
3473
3474 let _task = state.update(Message::FileSystemChanged);
3475
3476 assert!(state.active_tab().is_loading_more_commits);
3478 }
3481
3482 #[test]
3483 fn has_more_commits_preserved_across_refresh() {
3484 use crate::message::Message;
3486 let mut state = GitKraft::new();
3487 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3488 state.active_tab_mut().has_more_commits = true;
3489
3490 let payload = fake_payload("/home/user/repo-a");
3491 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3492
3493 assert!(
3494 state.active_tab().has_more_commits,
3495 "refresh must preserve has_more_commits"
3496 );
3497 }
3498
3499 #[test]
3500 fn switch_to_existing_tab_removes_empty_tab() {
3501 let mut state = GitKraft::new();
3502 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3504 state.tabs.push(RepoTab::new_empty());
3506 state.active_tab = 1;
3507
3508 let found = state.switch_to_existing_tab(std::path::Path::new("/home/user/repo-a"));
3509
3510 assert!(found, "should find existing tab");
3511 assert_eq!(state.tabs.len(), 1, "empty tab should be removed");
3512 assert_eq!(state.active_tab, 0);
3513 assert_eq!(
3514 state.active_tab().repo_path.as_deref(),
3515 Some(std::path::Path::new("/home/user/repo-a")),
3516 );
3517 }
3518
3519 #[test]
3520 fn switch_to_existing_tab_returns_false_when_not_found() {
3521 let mut state = GitKraft::new();
3522 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3523
3524 let found = state.switch_to_existing_tab(std::path::Path::new("/home/user/repo-b"));
3525
3526 assert!(!found, "should not find nonexistent tab");
3527 assert_eq!(state.tabs.len(), 1);
3528 }
3529
3530 #[test]
3531 fn switch_to_existing_tab_keeps_loaded_current_tab() {
3532 let mut state = GitKraft::new();
3533 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3535 state.tabs.push(RepoTab::new_empty());
3537 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3538 state.active_tab = 1;
3539
3540 let found = state.switch_to_existing_tab(std::path::Path::new("/home/user/repo-a"));
3541
3542 assert!(found);
3543 assert_eq!(state.tabs.len(), 2, "loaded tab should not be removed");
3544 assert_eq!(state.active_tab, 0);
3545 }
3546
3547 #[test]
3548 fn default_remote_returns_first_remote() {
3549 let mut tab = RepoTab::new_empty();
3550 tab.remotes = vec![
3551 gitkraft_core::RemoteInfo {
3552 name: "upstream".to_string(),
3553 url: Some("https://example.com/upstream.git".to_string()),
3554 fetch_refspecs: vec![],
3555 },
3556 gitkraft_core::RemoteInfo {
3557 name: "origin".to_string(),
3558 url: Some("https://example.com/origin.git".to_string()),
3559 fetch_refspecs: vec![],
3560 },
3561 ];
3562 assert_eq!(tab.default_remote(), "upstream");
3563 }
3564
3565 #[test]
3566 fn default_remote_fallback_to_origin() {
3567 let tab = RepoTab::new_empty();
3568 assert_eq!(tab.default_remote(), "origin");
3569 }
3570}