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 if tab.is_loading {
936 return Task::none();
937 }
938 match tab.repo_path.clone() {
939 Some(path) => {
940 let depth = tab.commits.len();
941 crate::features::repo::commands::refresh_repo(path, depth)
942 }
943 None => Task::none(),
944 }
945 }
946
947 pub fn on_ok_refresh(
954 &mut self,
955 result: Result<(), String>,
956 ok_msg: &str,
957 err_prefix: &str,
958 ) -> Task<Message> {
959 match result {
960 Ok(()) => {
961 {
962 let tab = self.active_tab_mut();
963 tab.is_loading = false;
964 tab.status_message = Some(ok_msg.to_string());
965 }
966 self.refresh_active_tab()
967 }
968 Err(e) => {
969 let tab = self.active_tab_mut();
970 tab.is_loading = false;
971 tab.error_message = Some(format!("{err_prefix}: {e}"));
972 tab.status_message = None;
973 Task::none()
974 }
975 }
976 }
977
978 pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
980 gitkraft_core::LayoutSettings {
981 sidebar_width: Some(self.sidebar_width),
982 commit_log_width: Some(self.commit_log_width),
983 staging_height: Some(self.staging_height),
984 diff_file_list_width: Some(self.diff_file_list_width),
985 sidebar_expanded: Some(self.sidebar_expanded),
986 ui_scale: Some(self.ui_scale),
987 window_width: Some(self.window_width),
988 window_height: Some(self.window_height),
989 window_x: Some(self.window_x),
990 window_y: Some(self.window_y),
991 window_maximized: None, commit_ref_width: Some(self.commit_ref_width),
993 commit_graph_width: Some(self.commit_graph_width),
994 }
995 }
996}
997
998fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
1000 Color::from_rgb8(rgb.r, rgb.g, rgb.b)
1001}
1002
1003fn detect_system_editor() -> gitkraft_core::Editor {
1005 for var in ["VISUAL", "EDITOR"] {
1006 if let Ok(val) = std::env::var(var) {
1007 let bin = val.split('/').next_back().unwrap_or(&val).trim();
1008 return match bin {
1009 "nvim" | "neovim" => gitkraft_core::Editor::Neovim,
1010 "vim" => gitkraft_core::Editor::Vim,
1011 "hx" | "helix" => gitkraft_core::Editor::Helix,
1012 "nano" => gitkraft_core::Editor::Nano,
1013 "micro" => gitkraft_core::Editor::Micro,
1014 "emacs" => gitkraft_core::Editor::Emacs,
1015 "code" => gitkraft_core::Editor::VSCode,
1016 "zed" => gitkraft_core::Editor::Zed,
1017 "subl" => gitkraft_core::Editor::Sublime,
1018 _ => gitkraft_core::Editor::Custom(val),
1019 };
1020 }
1021 }
1022 gitkraft_core::Editor::None
1023}
1024
1025#[cfg(test)]
1028mod tests {
1029 use super::*;
1030
1031 #[test]
1032 fn new_defaults() {
1033 let state = GitKraft::new();
1034 assert!(state.active_tab().repo_path.is_none());
1035 assert!(!state.has_repo());
1036 assert_eq!(state.repo_display_name(), "New Tab");
1037 assert!(state.active_tab().commits.is_empty());
1038 assert!(state.sidebar_expanded);
1039 assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
1041 assert!(state.sidebar_width > 0.0);
1043 assert!(state.commit_log_width > 0.0);
1044 assert!(state.staging_height > 0.0);
1045 assert!(state.dragging.is_none());
1046 assert!(state.dragging_h.is_none());
1047 assert_eq!(state.tabs.len(), 1);
1049 assert_eq!(state.active_tab, 0);
1050 }
1051
1052 #[test]
1053 fn repo_display_name_extracts_basename() {
1054 let mut state = GitKraft::new();
1055 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
1056 assert_eq!(state.repo_display_name(), "my-project");
1057 }
1058
1059 #[test]
1060 fn colors_returns_theme_colors() {
1061 let state = GitKraft::new();
1062 let c = state.colors();
1063 assert!(c.bg.r < 0.5);
1065 }
1066
1067 #[test]
1068 fn iced_theme_is_custom_with_correct_palette() {
1069 let mut state = GitKraft::new();
1070
1071 state.current_theme_index = 0;
1073 let iced_t = state.iced_theme();
1074 let pal = iced_t.palette();
1075 assert!(pal.background.r < 0.5, "Default theme bg should be dark");
1076 assert_eq!(iced_t.to_string(), "Default");
1077
1078 let sol_idx = gitkraft_core::theme_index_by_name("Solarized Light");
1080 state.current_theme_index = sol_idx;
1081 let iced_t = state.iced_theme();
1082 let pal = iced_t.palette();
1083 assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
1084 assert_eq!(iced_t.to_string(), "Solarized Light");
1085
1086 let gruvbox_idx = gitkraft_core::theme_index_by_name("Gruvbox Dark");
1088 state.current_theme_index = gruvbox_idx;
1089 let iced_t = state.iced_theme();
1090 let pal = iced_t.palette();
1091 let core = gitkraft_core::theme_by_index(gruvbox_idx);
1092 let expected_accent = rgb_to_iced(core.accent);
1093 assert!(
1094 (pal.primary.r - expected_accent.r).abs() < 0.01
1095 && (pal.primary.g - expected_accent.g).abs() < 0.01
1096 && (pal.primary.b - expected_accent.b).abs() < 0.01,
1097 "Gruvbox Dark accent should match core accent"
1098 );
1099 }
1100
1101 #[test]
1102 fn iced_theme_name_round_trips_through_core() {
1103 for i in 0..gitkraft_core::THEME_COUNT {
1106 let mut state = GitKraft::new();
1107 state.current_theme_index = i;
1108 let iced_t = state.iced_theme();
1109 let name = iced_t.to_string();
1110 let resolved = gitkraft_core::theme_index_by_name(&name);
1111 assert_eq!(
1112 resolved,
1113 i,
1114 "theme index {i} ({}) did not round-trip through iced_theme name",
1115 gitkraft_core::THEME_NAMES[i]
1116 );
1117 }
1118 }
1119
1120 #[test]
1121 fn current_theme_name_round_trips() {
1122 let mut state = GitKraft::new();
1123 state.current_theme_index = gitkraft_core::theme_index_by_name("Dracula");
1124 assert_eq!(state.current_theme_name(), "Dracula");
1125 state.current_theme_index = 0;
1126 assert_eq!(state.current_theme_name(), "Default");
1127 }
1128
1129 #[test]
1130 fn repo_tab_new_empty() {
1131 let tab = RepoTab::new_empty();
1132 assert!(tab.repo_path.is_none());
1133 assert!(!tab.has_repo());
1134 assert_eq!(tab.display_name(), "New Tab");
1135 assert!(tab.commits.is_empty());
1136 assert!(tab.branches.is_empty());
1137 assert!(!tab.is_loading);
1138 }
1139
1140 #[test]
1141 fn repo_tab_display_name_with_path() {
1142 let mut tab = RepoTab::new_empty();
1143 tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
1144 assert!(tab.has_repo());
1145 assert_eq!(tab.display_name(), "cool-repo");
1146 }
1147
1148 #[test]
1149 fn search_defaults() {
1150 let state = GitKraft::new();
1151 assert!(!state.search_visible);
1152 assert!(state.search_query.is_empty());
1153 assert!(state.search_results.is_empty());
1154 assert!(state.search_selected.is_none());
1155 }
1156
1157 #[test]
1158 fn context_menu_variants_exist() {
1159 use crate::state::ContextMenu;
1161
1162 let _branch = ContextMenu::Branch {
1163 name: "main".to_string(),
1164 is_current: true,
1165 local_index: 0,
1166 };
1167 let _remote = ContextMenu::RemoteBranch {
1168 name: "origin/main".to_string(),
1169 };
1170 let _commit = ContextMenu::Commit {
1171 index: 0,
1172 oid: "abc1234".to_string(),
1173 };
1174 let _stash = ContextMenu::Stash { index: 0 };
1175 let _unstaged = ContextMenu::UnstagedFile {
1176 path: "src/main.rs".to_string(),
1177 };
1178 let _staged = ContextMenu::StagedFile {
1179 path: "src/lib.rs".to_string(),
1180 };
1181 }
1182
1183 #[test]
1184 fn repo_tab_context_menu_defaults_to_none() {
1185 let tab = crate::state::RepoTab::new_empty();
1186 assert!(tab.context_menu.is_none());
1187 }
1188
1189 #[test]
1190 fn context_menu_variants_constructable() {
1191 use crate::state::ContextMenu;
1192 let _ = ContextMenu::Stash { index: 0 };
1193 let _ = ContextMenu::UnstagedFile {
1194 path: "a.rs".into(),
1195 };
1196 let _ = ContextMenu::StagedFile {
1197 path: "b.rs".into(),
1198 };
1199 }
1200
1201 #[test]
1202 fn selected_unstaged_defaults_empty() {
1203 let tab = crate::state::RepoTab::new_empty();
1204 assert!(tab.selected_unstaged.is_empty());
1205 assert!(tab.selected_staged.is_empty());
1206 }
1207
1208 #[test]
1209 fn selected_unstaged_toggle() {
1210 let mut tab = crate::state::RepoTab::new_empty();
1211 tab.selected_unstaged.insert("a.rs".to_string());
1212 tab.selected_unstaged.insert("b.rs".to_string());
1213 assert_eq!(tab.selected_unstaged.len(), 2);
1214 assert!(tab.selected_unstaged.contains("a.rs"));
1215 tab.selected_unstaged.remove("a.rs");
1216 assert_eq!(tab.selected_unstaged.len(), 1);
1217 assert!(!tab.selected_unstaged.contains("a.rs"));
1218 }
1219
1220 #[test]
1221 fn detect_system_editor_returns_valid() {
1222 let editor = super::detect_system_editor();
1224 let _ = editor.display_name();
1225 }
1226
1227 #[test]
1230 fn selected_commit_file_indices_defaults_to_empty_vec() {
1231 let tab = RepoTab::new_empty();
1232 assert!(tab.selected_commit_file_indices.is_empty());
1233 let v: &Vec<usize> = &tab.selected_commit_file_indices;
1235 assert_eq!(v.len(), 0);
1236 }
1237
1238 #[test]
1239 fn multi_file_diffs_defaults_empty() {
1240 let tab = RepoTab::new_empty();
1241 assert!(tab.multi_file_diffs.is_empty());
1242 }
1243
1244 #[test]
1245 fn keyboard_modifiers_default_has_no_shift() {
1246 let state = GitKraft::new();
1247 assert!(!state.keyboard_modifiers.shift());
1248 }
1249
1250 #[test]
1251 fn selected_commit_file_indices_preserves_insertion_order() {
1252 let mut tab = RepoTab::new_empty();
1253 tab.selected_commit_file_indices.push(5);
1254 tab.selected_commit_file_indices.push(2);
1255 tab.selected_commit_file_indices.push(8);
1256 assert_eq!(tab.selected_commit_file_indices, vec![5, 2, 8]);
1257 }
1258
1259 #[test]
1260 fn selected_commit_file_indices_cleared_on_reset() {
1261 let mut tab = RepoTab::new_empty();
1262 tab.selected_commit_file_indices.push(0);
1263 tab.selected_commit_file_indices.push(1);
1264 tab.selected_commit_file_indices.clear();
1265 assert!(tab.selected_commit_file_indices.is_empty());
1266 }
1267
1268 #[test]
1269 fn multi_file_diffs_cleared_on_reset() {
1270 let mut tab = RepoTab::new_empty();
1271 tab.multi_file_diffs.push(gitkraft_core::DiffInfo {
1272 old_file: String::new(),
1273 new_file: "a.rs".to_string(),
1274 status: gitkraft_core::FileStatus::Modified,
1275 hunks: vec![],
1276 });
1277 tab.multi_file_diffs.clear();
1278 assert!(tab.multi_file_diffs.is_empty());
1279 }
1280
1281 #[test]
1282 fn commit_range_diffs_defaults_empty() {
1283 let tab = RepoTab::new_empty();
1284 assert!(tab.commit_range_diffs.is_empty());
1285 }
1286
1287 #[test]
1288 fn commit_range_diffs_cleared_on_apply_payload() {
1289 let mut tab = RepoTab::new_empty();
1291 tab.commit_range_diffs.push(gitkraft_core::DiffInfo {
1292 old_file: String::new(),
1293 new_file: "x.rs".to_string(),
1294 status: gitkraft_core::FileStatus::Modified,
1295 hunks: vec![],
1296 });
1297 tab.commit_range_diffs.clear();
1298 assert!(tab.commit_range_diffs.is_empty());
1299 }
1300
1301 #[test]
1304 fn modifiers_changed_sets_shift_state() {
1305 use crate::message::Message;
1306 let mut state = GitKraft::new();
1307 assert!(!state.keyboard_modifiers.shift());
1308
1309 let _ = state.update(Message::ModifiersChanged(iced::keyboard::Modifiers::SHIFT));
1310 assert!(state.keyboard_modifiers.shift());
1311
1312 let _ = state.update(Message::ModifiersChanged(
1313 iced::keyboard::Modifiers::default(),
1314 ));
1315 assert!(!state.keyboard_modifiers.shift());
1316 }
1317
1318 fn make_commit_files(names: &[&str]) -> Vec<gitkraft_core::DiffFileEntry> {
1321 names
1322 .iter()
1323 .map(|name| gitkraft_core::DiffFileEntry {
1324 old_file: String::new(),
1325 new_file: name.to_string(),
1326 status: gitkraft_core::FileStatus::Modified,
1327 })
1328 .collect()
1329 }
1330
1331 #[test]
1332 fn select_diff_by_index_regular_click_clears_multi_selection() {
1333 use crate::message::Message;
1334 let mut state = GitKraft::new();
1335 state.active_tab_mut().repo_path =
1339 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1340 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1341 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1342 state.active_tab_mut().selected_commit_file_indices = vec![0, 1];
1344
1345 let _ = state.update(Message::SelectDiffByIndex(0));
1347
1348 assert!(state.active_tab().selected_commit_file_indices.is_empty());
1349 assert_eq!(state.active_tab().selected_file_index, Some(0));
1350 }
1351
1352 #[test]
1353 fn regular_click_preserves_multi_file_diffs_until_load_completes() {
1354 use crate::message::Message;
1355 let mut state = GitKraft::new();
1356 state.active_tab_mut().repo_path =
1357 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1358 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1359 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1360
1361 state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1363 old_file: String::new(),
1364 new_file: "a.rs".to_string(),
1365 status: gitkraft_core::FileStatus::Modified,
1366 hunks: vec![],
1367 }];
1368
1369 let _ = state.update(Message::SelectDiffByIndex(1));
1372
1373 assert!(
1374 !state.active_tab().multi_file_diffs.is_empty(),
1375 "multi_file_diffs must NOT be cleared on click (deferred to diff load)"
1376 );
1377 assert_eq!(state.active_tab().selected_file_index, Some(1));
1378 assert!(state.active_tab().is_loading_file_diff);
1379 }
1380
1381 #[test]
1382 fn regular_click_preserves_diff_scroll_until_load_completes() {
1383 use crate::message::Message;
1384 let mut state = GitKraft::new();
1385 state.active_tab_mut().repo_path =
1386 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1387 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1388 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1389 state.active_tab_mut().diff_scroll_offset = 150.0;
1390
1391 let _ = state.update(Message::SelectDiffByIndex(1));
1393
1394 assert_eq!(
1395 state.active_tab().diff_scroll_offset,
1396 150.0,
1397 "diff_scroll_offset must NOT be reset on click (deferred to diff load)"
1398 );
1399 }
1400
1401 #[test]
1402 fn single_file_diff_loaded_clears_deferred_state() {
1403 use crate::message::Message;
1404 let mut state = GitKraft::new();
1405 state.active_tab_mut().repo_path =
1406 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1407 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1408 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1409
1410 state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1412 old_file: String::new(),
1413 new_file: "a.rs".to_string(),
1414 status: gitkraft_core::FileStatus::Modified,
1415 hunks: vec![],
1416 }];
1417 state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1418 old_file: String::new(),
1419 new_file: "b.rs".to_string(),
1420 status: gitkraft_core::FileStatus::Modified,
1421 hunks: vec![],
1422 }];
1423 state.active_tab_mut().is_loading_file_diff = true;
1424 state.active_tab_mut().diff_scroll_offset = 200.0;
1425
1426 let new_diff = gitkraft_core::DiffInfo {
1428 old_file: String::new(),
1429 new_file: "b.rs".to_string(),
1430 status: gitkraft_core::FileStatus::Modified,
1431 hunks: vec![],
1432 };
1433 let _ = state.update(Message::SingleFileDiffLoaded(Ok(new_diff)));
1434
1435 assert!(
1437 state.active_tab().multi_file_diffs.is_empty(),
1438 "multi_file_diffs must be cleared when new diff arrives"
1439 );
1440 assert!(
1441 state.active_tab().commit_range_diffs.is_empty(),
1442 "commit_range_diffs must be cleared when new diff arrives"
1443 );
1444 assert!(
1445 state.active_tab().selected_diff.is_some(),
1446 "selected_diff must be set to the loaded diff"
1447 );
1448 assert!(
1449 !state.active_tab().is_loading_file_diff,
1450 "is_loading_file_diff must be cleared after load"
1451 );
1452 assert_eq!(
1453 state.active_tab().diff_scroll_offset,
1454 0.0,
1455 "diff_scroll_offset must be reset when new diff arrives"
1456 );
1457 }
1458
1459 #[test]
1460 fn regular_click_preserves_commit_range_diffs_until_load_completes() {
1461 use crate::message::Message;
1462 let mut state = GitKraft::new();
1463 state.active_tab_mut().repo_path =
1464 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1465 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1466 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1467
1468 state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1470 old_file: String::new(),
1471 new_file: "x.rs".to_string(),
1472 status: gitkraft_core::FileStatus::Modified,
1473 hunks: vec![],
1474 }];
1475
1476 let _ = state.update(Message::SelectDiffByIndex(0));
1477
1478 assert!(
1479 !state.active_tab().commit_range_diffs.is_empty(),
1480 "commit_range_diffs must NOT be cleared on click (deferred to diff load)"
1481 );
1482 }
1483
1484 #[test]
1485 fn select_diff_by_index_shift_click_adds_both_files_to_selection() {
1486 use crate::message::Message;
1487 let mut state = GitKraft::new();
1488 state.active_tab_mut().repo_path =
1489 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1490 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1491 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1492 state.active_tab_mut().selected_file_index = Some(0);
1493
1494 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1496 let _ = state.update(Message::SelectDiffByIndex(1));
1497
1498 let sel = &state.active_tab().selected_commit_file_indices;
1499 assert!(sel.contains(&0), "anchor file 0 should be selected");
1500 assert!(sel.contains(&1), "newly clicked file 1 should be selected");
1501 assert_eq!(sel.len(), 2);
1502 }
1503
1504 #[test]
1505 fn anchor_file_index_defaults_to_none() {
1506 let tab = RepoTab::new_empty();
1507 assert!(tab.anchor_file_index.is_none());
1508 }
1509
1510 #[test]
1511 fn regular_click_sets_anchor() {
1512 use crate::message::Message;
1513 let mut state = GitKraft::new();
1514 state.active_tab_mut().repo_path =
1515 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1516 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1517 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1518
1519 let _ = state.update(Message::SelectDiffByIndex(2));
1520
1521 assert_eq!(
1522 state.active_tab().anchor_file_index,
1523 Some(2),
1524 "regular click must set anchor to the clicked index"
1525 );
1526 }
1527
1528 #[test]
1529 fn shift_click_selects_range_downward_from_anchor() {
1530 use crate::message::Message;
1531 let mut state = GitKraft::new();
1532 state.active_tab_mut().repo_path =
1533 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1534 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1535 state.active_tab_mut().commit_files =
1536 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1537 state.active_tab_mut().anchor_file_index = Some(1);
1539 state.active_tab_mut().selected_file_index = Some(1);
1540
1541 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1543 let _ = state.update(Message::SelectDiffByIndex(4));
1544
1545 let sel = &state.active_tab().selected_commit_file_indices;
1546 assert_eq!(
1547 sel,
1548 &vec![1, 2, 3, 4],
1549 "range must be contiguous from anchor to click"
1550 );
1551 }
1552
1553 #[test]
1554 fn shift_click_selects_range_upward_from_anchor() {
1555 use crate::message::Message;
1556 let mut state = GitKraft::new();
1557 state.active_tab_mut().repo_path =
1558 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1559 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1560 state.active_tab_mut().commit_files =
1561 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1562 state.active_tab_mut().anchor_file_index = Some(4);
1564 state.active_tab_mut().selected_file_index = Some(4);
1565
1566 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1568 let _ = state.update(Message::SelectDiffByIndex(1));
1569
1570 let sel = &state.active_tab().selected_commit_file_indices;
1571 assert_eq!(
1572 sel,
1573 &vec![1, 2, 3, 4],
1574 "range must be stored ascending regardless of click direction"
1575 );
1576 }
1577
1578 #[test]
1579 fn shift_click_anchor_fixed_on_subsequent_clicks() {
1580 use crate::message::Message;
1581 let mut state = GitKraft::new();
1582 state.active_tab_mut().repo_path =
1583 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1584 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1585 state.active_tab_mut().commit_files =
1586 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1587 state.active_tab_mut().anchor_file_index = Some(2);
1589 state.active_tab_mut().selected_file_index = Some(2);
1590 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1591
1592 let _ = state.update(Message::SelectDiffByIndex(4));
1594 assert_eq!(
1595 state.active_tab().selected_commit_file_indices,
1596 vec![2, 3, 4]
1597 );
1598
1599 let _ = state.update(Message::SelectDiffByIndex(3));
1601 assert_eq!(
1602 state.active_tab().selected_commit_file_indices,
1603 vec![2, 3],
1604 "anchor must stay fixed; second Shift+Click shrinks the range"
1605 );
1606
1607 let _ = state.update(Message::SelectDiffByIndex(0));
1609 assert_eq!(
1610 state.active_tab().selected_commit_file_indices,
1611 vec![0, 1, 2],
1612 "anchor must stay fixed; can extend range in either direction"
1613 );
1614 }
1615
1616 #[test]
1617 fn shift_click_on_anchor_itself_gives_single_item_range() {
1618 use crate::message::Message;
1619 let mut state = GitKraft::new();
1620 state.active_tab_mut().repo_path =
1621 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1622 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1623 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1624 state.active_tab_mut().anchor_file_index = Some(1);
1625 state.active_tab_mut().selected_file_index = Some(1);
1626
1627 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1629 let _ = state.update(Message::SelectDiffByIndex(1));
1630
1631 assert_eq!(state.active_tab().selected_commit_file_indices, vec![1]);
1632 assert!(
1633 state.active_tab().multi_file_diffs.is_empty(),
1634 "single-item range must not populate multi_file_diffs"
1635 );
1636 }
1637
1638 #[test]
1639 fn shift_click_range_is_always_ascending() {
1640 use crate::message::Message;
1641 let mut state = GitKraft::new();
1642 state.active_tab_mut().repo_path =
1643 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1644 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1645 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs"]);
1646 state.active_tab_mut().anchor_file_index = Some(3);
1647 state.active_tab_mut().selected_file_index = Some(3);
1648
1649 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1650 let _ = state.update(Message::SelectDiffByIndex(0));
1651
1652 let sel = &state.active_tab().selected_commit_file_indices;
1653 let is_sorted = sel.windows(2).all(|w| w[0] < w[1]);
1654 assert!(
1655 is_sorted,
1656 "selection must always be stored in ascending order"
1657 );
1658 assert_eq!(sel, &vec![0, 1, 2, 3]);
1659 }
1660
1661 #[test]
1662 fn checkout_file_at_commit_message_variants_exist() {
1663 use crate::message::Message;
1664 let _single =
1666 Message::CheckoutFileAtCommit("abc123".to_string(), "src/main.rs".to_string());
1667 let _multi = Message::CheckoutMultiFilesAtCommit(
1668 "abc123".to_string(),
1669 vec!["a.rs".to_string(), "b.rs".to_string()],
1670 );
1671 }
1672
1673 #[test]
1674 fn checkout_file_at_commit_closes_context_menu() {
1675 use crate::message::Message;
1676 let mut state = GitKraft::new();
1677 state.active_tab_mut().repo_path =
1678 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1679 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1680 oid: "abc123".to_string(),
1681 file_path: "src/main.rs".to_string(),
1682 });
1683 let _ = state.update(Message::CheckoutFileAtCommit(
1684 "abc123".to_string(),
1685 "src/main.rs".to_string(),
1686 ));
1687 assert!(state.active_tab().context_menu.is_none());
1688 }
1689
1690 #[test]
1691 fn checkout_multi_files_at_commit_closes_context_menu() {
1692 use crate::message::Message;
1693 let mut state = GitKraft::new();
1694 state.active_tab_mut().repo_path =
1695 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1696 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1697 oid: "abc123".to_string(),
1698 file_path: "src/main.rs".to_string(),
1699 });
1700 let _ = state.update(Message::CheckoutMultiFilesAtCommit(
1701 "abc123".to_string(),
1702 vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
1703 ));
1704 assert!(state.active_tab().context_menu.is_none());
1705 }
1706
1707 fn make_test_commits(count: usize) -> Vec<gitkraft_core::CommitInfo> {
1710 (0..count)
1711 .map(|i| gitkraft_core::CommitInfo {
1712 oid: i.to_string(),
1713 short_oid: i.to_string(),
1714 summary: String::new(),
1715 message: String::new(),
1716 author_name: String::new(),
1717 author_email: String::new(),
1718 time: Default::default(),
1719 parent_ids: Vec::new(),
1720 refs: Vec::new(),
1721 })
1722 .collect()
1723 }
1724
1725 #[test]
1726 fn selected_commits_defaults_empty() {
1727 let tab = RepoTab::new_empty();
1728 assert!(tab.selected_commits.is_empty());
1729 assert!(tab.anchor_commit_index.is_none());
1730 }
1731
1732 #[test]
1733 fn regular_click_commit_sets_anchor_and_clears_range() {
1734 use crate::message::Message;
1735 let mut state = GitKraft::new();
1736 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
1737 state.active_tab_mut().commits = make_test_commits(3);
1738 state.active_tab_mut().selected_commits = vec![0, 1, 2];
1739
1740 let _ = state.update(Message::SelectCommit(1));
1741
1742 assert_eq!(state.active_tab().anchor_commit_index, Some(1));
1743 assert!(state.active_tab().selected_commits.is_empty());
1744 assert_eq!(state.active_tab().selected_commit, Some(1));
1745 }
1746
1747 #[test]
1748 fn shift_click_commit_selects_range_from_anchor() {
1749 use crate::message::Message;
1750 let mut state = GitKraft::new();
1751 state.active_tab_mut().commits = make_test_commits(5);
1752 state.active_tab_mut().anchor_commit_index = Some(1);
1753 state.active_tab_mut().selected_commit = Some(1);
1754
1755 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1756 let _ = state.update(Message::SelectCommit(4));
1757
1758 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3, 4]);
1759 }
1760
1761 #[test]
1762 fn shift_click_commit_range_is_ascending_when_clicking_above_anchor() {
1763 use crate::message::Message;
1764 let mut state = GitKraft::new();
1765 state.active_tab_mut().commits = make_test_commits(5);
1766 state.active_tab_mut().anchor_commit_index = Some(3);
1767 state.active_tab_mut().selected_commit = Some(3);
1768
1769 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1770 let _ = state.update(Message::SelectCommit(1));
1771
1772 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3]);
1773 }
1774
1775 #[test]
1778 fn execute_commit_action_closes_context_menu() {
1779 use crate::message::Message;
1780 let mut state = GitKraft::new();
1781 state.active_tab_mut().repo_path =
1782 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1783 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::Commit {
1784 index: 0,
1785 oid: "abc123".to_string(),
1786 });
1787
1788 let _ = state.update(Message::ExecuteCommitAction(
1789 "abc123".to_string(),
1790 gitkraft_core::CommitAction::CherryPick,
1791 ));
1792
1793 assert!(state.active_tab().context_menu.is_none());
1794 }
1795
1796 #[test]
1797 fn execute_commit_action_sets_loading_when_repo_open() {
1798 use crate::message::Message;
1799 let mut state = GitKraft::new();
1800 state.active_tab_mut().repo_path =
1801 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1802
1803 let _ = state.update(Message::ExecuteCommitAction(
1804 "abc123".to_string(),
1805 gitkraft_core::CommitAction::ResetHard,
1806 ));
1807
1808 assert!(state.active_tab().is_loading);
1809 }
1810
1811 #[test]
1812 fn execute_commit_action_no_repo_does_not_set_loading() {
1813 use crate::message::Message;
1814 let mut state = GitKraft::new();
1815 let _ = state.update(Message::ExecuteCommitAction(
1818 "abc123".to_string(),
1819 gitkraft_core::CommitAction::CherryPick,
1820 ));
1821
1822 assert!(!state.active_tab().is_loading);
1823 }
1824
1825 #[test]
1826 fn execute_commit_action_sets_status_message_from_action_label() {
1827 use crate::message::Message;
1828 let mut state = GitKraft::new();
1829 state.active_tab_mut().repo_path =
1830 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1831
1832 let _ = state.update(Message::ExecuteCommitAction(
1833 "abc123".to_string(),
1834 gitkraft_core::CommitAction::Revert,
1835 ));
1836
1837 let status = state.active_tab().status_message.as_deref().unwrap_or("");
1838 assert!(
1840 status.contains("Revert commit"),
1841 "expected status to contain 'Revert commit', got: {status:?}"
1842 );
1843 }
1844
1845 #[test]
1848 fn file_history_defaults_empty() {
1849 let tab = RepoTab::new_empty();
1850 assert!(tab.file_history_path.is_none());
1851 assert!(tab.file_history_commits.is_empty());
1852 assert_eq!(tab.file_history_scroll, 0.0);
1853 }
1854
1855 #[test]
1856 fn blame_defaults_empty() {
1857 let tab = RepoTab::new_empty();
1858 assert!(tab.blame_path.is_none());
1859 assert!(tab.blame_lines.is_empty());
1860 assert_eq!(tab.blame_scroll, 0.0);
1861 }
1862
1863 #[test]
1864 fn pending_delete_file_defaults_none() {
1865 let tab = RepoTab::new_empty();
1866 assert!(tab.pending_delete_file.is_none());
1867 }
1868
1869 #[test]
1870 fn view_file_history_sets_path_and_clears_blame() {
1871 use crate::message::Message;
1872 let mut state = GitKraft::new();
1873 state.active_tab_mut().repo_path =
1874 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1875 state.active_tab_mut().blame_path = Some("some/file.rs".to_string());
1876
1877 let _ = state.update(Message::ViewFileHistory("src/main.rs".to_string()));
1878
1879 assert_eq!(
1880 state.active_tab().file_history_path.as_deref(),
1881 Some("src/main.rs")
1882 );
1883 assert!(state.active_tab().blame_path.is_none());
1885 }
1886
1887 #[test]
1888 fn close_file_history_clears_state() {
1889 use crate::message::Message;
1890 let mut state = GitKraft::new();
1891 state.active_tab_mut().file_history_path = Some("src/lib.rs".to_string());
1892 state.active_tab_mut().file_history_commits = vec![gitkraft_core::CommitInfo {
1893 oid: "abc".to_string(),
1894 short_oid: "abc".to_string(),
1895 summary: "s".to_string(),
1896 message: "s".to_string(),
1897 author_name: "a".to_string(),
1898 author_email: "a@b.com".to_string(),
1899 time: Default::default(),
1900 parent_ids: vec![],
1901 refs: Vec::new(),
1902 }];
1903
1904 let _ = state.update(Message::CloseFileHistory);
1905
1906 assert!(state.active_tab().file_history_path.is_none());
1907 assert!(state.active_tab().file_history_commits.is_empty());
1908 }
1909
1910 #[test]
1911 fn view_file_blame_sets_path_and_clears_history() {
1912 use crate::message::Message;
1913 let mut state = GitKraft::new();
1914 state.active_tab_mut().repo_path =
1915 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1916 state.active_tab_mut().file_history_path = Some("some/file.rs".to_string());
1917
1918 let _ = state.update(Message::ViewFileBlame("src/lib.rs".to_string()));
1919
1920 assert_eq!(state.active_tab().blame_path.as_deref(), Some("src/lib.rs"));
1921 assert!(state.active_tab().file_history_path.is_none());
1923 }
1924
1925 #[test]
1926 fn selecting_new_commit_closes_blame_overlay() {
1927 use crate::message::Message;
1928 let mut state = GitKraft::new();
1929 state.active_tab_mut().repo_path =
1930 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1931 state.active_tab_mut().commits = vec![
1933 gitkraft_core::CommitInfo {
1934 oid: "abc1".into(),
1935 short_oid: "abc1".into(),
1936 summary: "first".into(),
1937 message: "first".into(),
1938 author_name: "A".into(),
1939 author_email: "a@a.com".into(),
1940 time: Default::default(),
1941 parent_ids: Vec::new(),
1942 refs: Vec::new(),
1943 },
1944 gitkraft_core::CommitInfo {
1945 oid: "abc2".into(),
1946 short_oid: "abc2".into(),
1947 summary: "second".into(),
1948 message: "second".into(),
1949 author_name: "A".into(),
1950 author_email: "a@a.com".into(),
1951 time: Default::default(),
1952 parent_ids: Vec::new(),
1953 refs: Vec::new(),
1954 },
1955 ];
1956 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1958 state.active_tab_mut().blame_lines = vec![gitkraft_core::BlameLine {
1959 line_number: 1,
1960 content: "fn main() {}".into(),
1961 short_oid: "abc1".into(),
1962 oid: "abc1".into(),
1963 author_name: "A".into(),
1964 time: Default::default(),
1965 }];
1966
1967 let _ = state.update(Message::SelectCommit(1));
1969
1970 assert!(
1971 state.active_tab().blame_path.is_none(),
1972 "blame_path must be cleared when a new commit is selected"
1973 );
1974 assert!(
1975 state.active_tab().blame_lines.is_empty(),
1976 "blame_lines must be cleared when a new commit is selected"
1977 );
1978 }
1979
1980 #[test]
1981 fn close_file_blame_clears_state() {
1982 use crate::message::Message;
1983 let mut state = GitKraft::new();
1984 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1985
1986 let _ = state.update(Message::CloseFileBlame);
1987
1988 assert!(state.active_tab().blame_path.is_none());
1989 assert!(state.active_tab().blame_lines.is_empty());
1990 }
1991
1992 #[test]
1993 fn delete_file_sets_pending() {
1994 use crate::message::Message;
1995 let mut state = GitKraft::new();
1996
1997 let _ = state.update(Message::DeleteFile("src/old.rs".to_string()));
1998
1999 assert_eq!(
2000 state.active_tab().pending_delete_file.as_deref(),
2001 Some("src/old.rs")
2002 );
2003 assert!(state.active_tab().context_menu.is_none());
2004 }
2005
2006 #[test]
2007 fn cancel_delete_file_clears_pending() {
2008 use crate::message::Message;
2009 let mut state = GitKraft::new();
2010 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
2011
2012 let _ = state.update(Message::CancelDeleteFile);
2013
2014 assert!(state.active_tab().pending_delete_file.is_none());
2015 }
2016
2017 #[test]
2018 fn confirm_delete_file_no_repo_is_noop() {
2019 use crate::message::Message;
2020 let mut state = GitKraft::new();
2021 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
2022 let _ = state.update(Message::ConfirmDeleteFile);
2025
2026 assert!(!state.active_tab().is_loading);
2027 }
2028
2029 #[test]
2030 fn shift_arrow_down_extends_file_list_selection_when_files_loaded() {
2031 use crate::message::Message;
2032 let mut state = GitKraft::new();
2033 state.active_tab_mut().repo_path =
2034 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
2035 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
2036 state.active_tab_mut().selected_file_index = Some(0);
2037 state.active_tab_mut().anchor_file_index = Some(0);
2038 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
2040
2041 let _ = state.update(Message::ShiftArrowDown);
2042
2043 assert_eq!(state.active_tab().selected_file_index, Some(1));
2044 assert!(state.active_tab().selected_commit_file_indices.contains(&0));
2046 assert!(state.active_tab().selected_commit_file_indices.contains(&1));
2047 }
2048
2049 #[test]
2050 fn shift_arrow_down_falls_through_to_commit_log_when_no_files() {
2051 use crate::message::Message;
2052 let mut state = GitKraft::new();
2053 state.active_tab_mut().commits = make_test_commits(5);
2054 state.active_tab_mut().selected_commit = Some(1);
2055 state.active_tab_mut().anchor_commit_index = Some(1);
2056 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
2057 let _ = state.update(Message::ShiftArrowDown);
2060
2061 assert_eq!(state.active_tab().selected_commit, Some(2));
2062 assert!(state.active_tab().selected_commits.contains(&1));
2063 assert!(state.active_tab().selected_commits.contains(&2));
2064 }
2065
2066 #[test]
2067 fn file_system_changed_triggers_full_refresh() {
2068 use crate::message::Message;
2069 let mut state = GitKraft::new();
2070 state.active_tab_mut().repo_path =
2071 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
2072
2073 let _task = state.update(Message::FileSystemChanged);
2077
2078 assert!(
2082 state.active_tab().error_message.is_none(),
2083 "FileSystemChanged must not set an error message"
2084 );
2085 }
2086
2087 #[test]
2088 fn refresh_active_tab_skips_when_already_loading() {
2089 let mut state = GitKraft::new();
2093 state.active_tab_mut().repo_path =
2094 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
2095 state.active_tab_mut().is_loading = true;
2096
2097 let _task = state.refresh_active_tab();
2099 assert!(state.active_tab().is_loading);
2101 }
2102
2103 fn fake_payload(workdir: &str) -> crate::message::RepoPayload {
2107 gitkraft_core::RepoSnapshot {
2108 info: gitkraft_core::RepoInfo {
2109 path: std::path::PathBuf::from(format!("{workdir}/.git")),
2110 workdir: Some(std::path::PathBuf::from(workdir)),
2111 head_branch: Some("main".into()),
2112 is_bare: false,
2113 state: gitkraft_core::RepoState::Clean,
2114 },
2115 branches: Vec::new(),
2116 commits: Vec::new(),
2117 graph_rows: Vec::new(),
2118 unstaged: Vec::new(),
2119 staged: Vec::new(),
2120 stashes: Vec::new(),
2121 remotes: Vec::new(),
2122 }
2123 }
2124
2125 fn setup_loaded_tab(tab: &mut RepoTab, path: &str) {
2127 tab.repo_path = Some(std::path::PathBuf::from(path));
2128 tab.repo_info = Some(gitkraft_core::RepoInfo {
2129 path: std::path::PathBuf::from(format!("{path}/.git")),
2130 workdir: Some(std::path::PathBuf::from(path)),
2131 head_branch: Some("main".into()),
2132 is_bare: false,
2133 state: gitkraft_core::RepoState::Clean,
2134 });
2135 }
2136
2137 #[test]
2138 fn open_repo_creates_new_tab_when_repo_already_open() {
2139 use crate::message::Message;
2140 let mut state = GitKraft::new();
2141 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2142
2143 assert_eq!(state.tabs.len(), 1);
2144 assert_eq!(state.active_tab, 0);
2145
2146 let _task = state.update(Message::OpenRepo);
2148
2149 assert_eq!(state.tabs.len(), 2);
2150 assert_eq!(state.active_tab, 1);
2151 assert!(state.tabs[1].is_loading);
2153 assert_eq!(
2155 state.tabs[0].repo_path.as_deref(),
2156 Some(std::path::Path::new("/home/user/repo-a"))
2157 );
2158 }
2159
2160 #[test]
2161 fn open_repo_reuses_empty_tab() {
2162 use crate::message::Message;
2163 let mut state = GitKraft::new();
2164 assert!(!state.active_tab().has_repo());
2166
2167 let _task = state.update(Message::OpenRepo);
2168
2169 assert_eq!(state.tabs.len(), 1);
2171 assert_eq!(state.active_tab, 0);
2172 assert!(state.tabs[0].is_loading);
2173 }
2174
2175 #[test]
2176 fn repo_selected_deduplicates_already_open_repo() {
2177 use crate::message::Message;
2178 let mut state = GitKraft::new();
2179 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2181 state.tabs.push(RepoTab::new_empty());
2183 state.active_tab = 1;
2184
2185 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2187 "/home/user/repo-a",
2188 ))));
2189
2190 assert_eq!(state.tabs.len(), 1);
2192 assert_eq!(state.active_tab, 0);
2193 assert_eq!(
2194 state.tabs[0].repo_path.as_deref(),
2195 Some(std::path::Path::new("/home/user/repo-a"))
2196 );
2197 }
2198
2199 #[test]
2200 fn repo_selected_opens_new_repo_in_empty_tab() {
2201 use crate::message::Message;
2202 let mut state = GitKraft::new();
2203 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2205 state.tabs.push(RepoTab::new_empty());
2207 state.active_tab = 1;
2208
2209 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2211 "/home/user/repo-b",
2212 ))));
2213
2214 assert_eq!(state.tabs.len(), 2);
2216 assert_eq!(state.active_tab, 1);
2217 assert!(state.tabs[1]
2218 .status_message
2219 .as_deref()
2220 .unwrap_or("")
2221 .contains("repo-b"));
2222 }
2223
2224 #[test]
2225 fn repo_selected_cancel_removes_empty_tab() {
2226 use crate::message::Message;
2227 let mut state = GitKraft::new();
2228 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2230 state.tabs.push(RepoTab::new_empty());
2232 state.active_tab = 1;
2233
2234 let _task = state.update(Message::RepoSelected(None));
2236
2237 assert_eq!(state.tabs.len(), 1);
2239 assert_eq!(state.active_tab, 0);
2240 assert_eq!(
2241 state.tabs[0].repo_path.as_deref(),
2242 Some(std::path::Path::new("/home/user/repo-a"))
2243 );
2244 }
2245
2246 #[test]
2247 fn repo_selected_cancel_keeps_tab_if_only_one() {
2248 use crate::message::Message;
2249 let mut state = GitKraft::new();
2250 assert_eq!(state.tabs.len(), 1);
2252 assert!(!state.active_tab().has_repo());
2253
2254 let _task = state.update(Message::RepoSelected(None));
2255
2256 assert_eq!(state.tabs.len(), 1);
2257 assert!(!state.active_tab().is_loading);
2258 }
2259
2260 #[test]
2261 fn open_recent_repo_deduplicates() {
2262 use crate::message::Message;
2263 let mut state = GitKraft::new();
2264 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2266 state.tabs.push(RepoTab::new_empty());
2268 state.active_tab = 1;
2269
2270 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2273 "/home/user/repo-a",
2274 )));
2275
2276 assert_eq!(state.tabs.len(), 1, "empty tab must be removed");
2277 assert_eq!(state.active_tab, 0);
2278 assert_eq!(
2279 state.active_tab().repo_path.as_deref(),
2280 Some(std::path::Path::new("/home/user/repo-a")),
2281 );
2282 }
2283
2284 #[test]
2285 fn open_recent_repo_creates_new_tab_when_current_has_repo() {
2286 use crate::message::Message;
2287 let mut state = GitKraft::new();
2288 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2290
2291 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2293 "/home/user/repo-b",
2294 )));
2295
2296 assert_eq!(state.tabs.len(), 2);
2297 assert_eq!(state.active_tab, 1);
2298 assert!(state.tabs[1].is_loading);
2299 }
2300
2301 #[test]
2302 fn open_recent_repo_uses_empty_tab() {
2303 use crate::message::Message;
2304 let mut state = GitKraft::new();
2305 assert!(!state.active_tab().has_repo());
2307
2308 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2309 "/home/user/repo-b",
2310 )));
2311
2312 assert_eq!(state.tabs.len(), 1);
2314 assert_eq!(state.active_tab, 0);
2315 assert!(state.tabs[0].is_loading);
2316 }
2317
2318 #[test]
2319 fn open_recent_repo_dedup_removes_empty_tab_with_multiple_loaded() {
2320 use crate::message::Message;
2323 let mut state = GitKraft::new();
2324 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2325 state.tabs.push(RepoTab::new_empty());
2326 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2327 state.tabs.push(RepoTab::new_empty());
2328 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2329 state.tabs.push(RepoTab::new_empty());
2331 state.active_tab = 3;
2332
2333 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2334 "/home/user/repo-b",
2335 )));
2336
2337 assert_eq!(state.tabs.len(), 3, "empty tab must be cleaned up");
2338 assert_eq!(state.active_tab, 1);
2339 assert_eq!(
2340 state.active_tab().repo_path.as_deref(),
2341 Some(std::path::Path::new("/home/user/repo-b")),
2342 );
2343 }
2344
2345 #[test]
2348 fn repo_refreshed_targets_correct_tab_after_tab_switch() {
2349 use crate::message::Message;
2350 let mut state = GitKraft::new();
2351 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2353 state.tabs.push(RepoTab::new_empty());
2355 state.active_tab = 1; let payload = fake_payload("/home/user/repo-a");
2359 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2360
2361 assert!(
2363 state.tabs[0].repo_info.is_some(),
2364 "tab 0 should still have repo info after refresh"
2365 );
2366 assert_eq!(
2367 state.tabs[0].current_branch.as_deref(),
2368 Some("main"),
2369 "tab 0 should have updated branch from payload"
2370 );
2371 assert!(
2373 state.tabs[1].repo_info.is_none(),
2374 "tab 1 (empty) must NOT receive the refresh payload"
2375 );
2376 assert!(
2377 state.tabs[1].repo_path.is_none(),
2378 "tab 1 should still have no repo path"
2379 );
2380 }
2381
2382 #[test]
2383 fn repo_refreshed_targets_active_tab_for_new_open() {
2384 use crate::message::Message;
2385 let mut state = GitKraft::new();
2386 assert_eq!(state.tabs.len(), 1);
2390 assert!(!state.active_tab().has_repo());
2391
2392 let payload = fake_payload("/home/user/new-repo");
2393 let _task = state.update(Message::RepoOpened(Ok(payload)));
2394
2395 assert_eq!(
2397 state.tabs[0].repo_path.as_deref(),
2398 Some(std::path::Path::new("/home/user/new-repo"))
2399 );
2400 assert!(state.tabs[0].repo_info.is_some());
2401 }
2402
2403 #[test]
2404 fn repo_refreshed_does_not_duplicate_into_new_tab() {
2405 use crate::message::Message;
2406 let mut state = GitKraft::new();
2407 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2409
2410 let _task = state.update(Message::NewTab);
2412 assert_eq!(state.tabs.len(), 2);
2413 assert_eq!(state.active_tab, 1);
2414
2415 let payload = fake_payload("/home/user/repo-a");
2417 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2418
2419 assert!(
2421 state.tabs[1].repo_path.is_none(),
2422 "new empty tab must not receive repo-a refresh"
2423 );
2424 assert!(
2425 state.tabs[1].repo_info.is_none(),
2426 "new empty tab must not have repo_info"
2427 );
2428 assert_eq!(
2430 state.tabs[0].repo_path.as_deref(),
2431 Some(std::path::Path::new("/home/user/repo-a"))
2432 );
2433 }
2434
2435 #[test]
2436 fn git_operation_result_targets_correct_tab() {
2437 use crate::message::Message;
2438 let mut state = GitKraft::new();
2439 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2441 state.tabs.push(RepoTab::new_empty());
2443 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2444 state.active_tab = 1;
2445
2446 let payload = fake_payload("/home/user/repo-a");
2449 let _task = state.update(Message::GitOperationResult(Ok(payload)));
2450
2451 assert_eq!(state.tabs[0].current_branch.as_deref(), Some("main"));
2453 assert_eq!(
2455 state.tabs[1].repo_path.as_deref(),
2456 Some(std::path::Path::new("/home/user/repo-b"))
2457 );
2458 }
2459
2460 #[test]
2461 fn multiple_new_tabs_dont_get_polluted_by_refresh() {
2462 use crate::message::Message;
2463 let mut state = GitKraft::new();
2464 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2466
2467 let _task = state.update(Message::NewTab);
2469 let _task = state.update(Message::NewTab);
2470 assert_eq!(state.tabs.len(), 3);
2471 assert_eq!(state.active_tab, 2);
2472
2473 let payload = fake_payload("/home/user/repo-a");
2475 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2476
2477 assert!(state.tabs[0].repo_info.is_some());
2479 assert!(state.tabs[1].repo_info.is_none());
2480 assert!(state.tabs[2].repo_info.is_none());
2481 assert!(state.tabs[1].repo_path.is_none());
2482 assert!(state.tabs[2].repo_path.is_none());
2483 }
2484
2485 #[test]
2486 fn repo_selected_dedup_adjusts_index_when_existing_is_after_active() {
2487 use crate::message::Message;
2488 let mut state = GitKraft::new();
2489 state.tabs.push(RepoTab::new_empty());
2492 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
2493 state.active_tab = 0;
2494
2495 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2497 "/home/user/repo-a",
2498 ))));
2499
2500 assert_eq!(state.tabs.len(), 1);
2502 assert_eq!(state.active_tab, 0);
2503 assert_eq!(
2504 state.tabs[0].repo_path.as_deref(),
2505 Some(std::path::Path::new("/home/user/repo-a"))
2506 );
2507 }
2508
2509 #[test]
2512 fn apply_payload_preserves_multi_selection_by_oid() {
2513 let mut tab = RepoTab::new_empty();
2514 tab.commits = make_test_commits(5);
2515 tab.anchor_commit_index = Some(1);
2517 tab.selected_commits = vec![1, 2, 3];
2518 tab.selected_commit = Some(3);
2519 tab.selected_commit_oid = Some(tab.commits[3].oid.clone());
2520
2521 let mut payload = fake_payload("/tmp/repo");
2523 payload.commits = make_test_commits(5);
2524
2525 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2526
2527 assert_eq!(tab.anchor_commit_index, Some(1));
2529 assert_eq!(tab.selected_commits, vec![1, 2, 3]);
2530 assert_eq!(tab.selected_commit, Some(3));
2531 }
2532
2533 #[test]
2534 fn apply_payload_preserves_anchor_even_without_range() {
2535 let mut tab = RepoTab::new_empty();
2536 tab.commits = make_test_commits(5);
2537 tab.anchor_commit_index = Some(2);
2538 tab.selected_commit = Some(2);
2539 tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2540 let mut payload = fake_payload("/tmp/repo");
2543 payload.commits = make_test_commits(5);
2544
2545 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2546
2547 assert_eq!(tab.anchor_commit_index, Some(2));
2548 assert_eq!(tab.selected_commit, Some(2));
2549 }
2550
2551 #[test]
2552 fn apply_payload_clears_selection_when_commits_disappear() {
2553 let mut tab = RepoTab::new_empty();
2554 tab.commits = make_test_commits(5);
2555 tab.anchor_commit_index = Some(2);
2556 tab.selected_commits = vec![2, 3, 4];
2557 tab.selected_commit = Some(4);
2558 tab.selected_commit_oid = Some(tab.commits[4].oid.clone());
2559
2560 let mut payload = fake_payload("/tmp/repo");
2562 payload.commits = (0..3)
2563 .map(|i| gitkraft_core::CommitInfo {
2564 oid: format!("new_oid_{i}"),
2565 short_oid: format!("new_{i}"),
2566 summary: format!("new commit {i}"),
2567 message: String::new(),
2568 author_name: "Author".into(),
2569 author_email: "a@b.c".into(),
2570 time: Default::default(),
2571 parent_ids: Vec::new(),
2572 refs: Vec::new(),
2573 })
2574 .collect();
2575
2576 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2577
2578 assert!(tab.selected_commits.is_empty());
2580 assert!(tab.anchor_commit_index.is_none());
2581 assert!(tab.selected_commit.is_none());
2582 }
2583
2584 #[test]
2587 fn apply_payload_preserves_file_selection_when_commit_survives() {
2588 let mut tab = RepoTab::new_empty();
2589 tab.commits = make_test_commits(3);
2590 tab.selected_commit = Some(1);
2591 tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2592 tab.commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
2593
2594 tab.anchor_file_index = Some(0);
2596 tab.selected_commit_file_indices = vec![0, 1, 2];
2597 tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2598 old_file: String::new(),
2599 new_file: "a.rs".into(),
2600 status: gitkraft_core::FileStatus::Modified,
2601 hunks: vec![],
2602 }];
2603
2604 let mut payload = fake_payload("/tmp/repo");
2606 payload.commits = make_test_commits(3);
2607
2608 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2609
2610 assert_eq!(tab.anchor_file_index, Some(0));
2612 assert_eq!(tab.selected_commit_file_indices, vec![0, 1, 2]);
2613 assert!(
2614 !tab.multi_file_diffs.is_empty(),
2615 "multi_file_diffs must survive when commit is preserved"
2616 );
2617 }
2618
2619 #[test]
2620 fn apply_payload_clears_file_selection_when_commit_disappears() {
2621 let mut tab = RepoTab::new_empty();
2622 tab.commits = make_test_commits(3);
2623 tab.selected_commit = Some(1);
2624 tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2625 tab.commit_files = make_commit_files(&["a.rs", "b.rs"]);
2626 tab.anchor_file_index = Some(0);
2627 tab.selected_commit_file_indices = vec![0, 1];
2628 tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2629 old_file: String::new(),
2630 new_file: "a.rs".into(),
2631 status: gitkraft_core::FileStatus::Modified,
2632 hunks: vec![],
2633 }];
2634
2635 let mut payload = fake_payload("/tmp/repo");
2637 payload.commits = (0..2)
2638 .map(|i| gitkraft_core::CommitInfo {
2639 oid: format!("new_{i}"),
2640 short_oid: format!("n{i}"),
2641 summary: String::new(),
2642 message: String::new(),
2643 author_name: String::new(),
2644 author_email: String::new(),
2645 time: Default::default(),
2646 parent_ids: Vec::new(),
2647 refs: Vec::new(),
2648 })
2649 .collect();
2650
2651 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2652
2653 assert!(tab.anchor_file_index.is_none());
2655 assert!(tab.selected_commit_file_indices.is_empty());
2656 assert!(tab.multi_file_diffs.is_empty());
2657 assert!(tab.commit_files.is_empty());
2658 }
2659
2660 #[test]
2661 fn apply_payload_preserves_commit_range_diffs_when_commit_survives() {
2662 let mut tab = RepoTab::new_empty();
2663 tab.commits = make_test_commits(5);
2664 tab.selected_commit = Some(2);
2665 tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2666 tab.commit_range_diffs = vec![gitkraft_core::DiffInfo {
2667 old_file: String::new(),
2668 new_file: "x.rs".into(),
2669 status: gitkraft_core::FileStatus::Modified,
2670 hunks: vec![],
2671 }];
2672
2673 let mut payload = fake_payload("/tmp/repo");
2674 payload.commits = make_test_commits(5);
2675
2676 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2677
2678 assert!(
2679 !tab.commit_range_diffs.is_empty(),
2680 "commit_range_diffs must survive when commit is preserved"
2681 );
2682 }
2683
2684 #[test]
2685 fn apply_payload_preserves_commit_message() {
2686 let mut tab = RepoTab::new_empty();
2689 tab.repo_path = Some(std::path::PathBuf::from("/tmp/repo"));
2690 tab.commit_message = "WIP: fixing the bug".to_string();
2691
2692 let payload = fake_payload("/tmp/repo");
2693 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2694
2695 assert_eq!(
2696 tab.commit_message, "WIP: fixing the bug",
2697 "commit_message must survive background repo refreshes"
2698 );
2699 }
2700
2701 #[test]
2704 fn preview_file_loaded_sets_preview_editor() {
2705 use crate::message::Message;
2706 let mut state = GitKraft::new();
2707 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2708
2709 let content = "line 1\nline 2\nline 3".to_string();
2710 let _ = state.update(Message::FilePreviewLoaded(Ok((
2711 "test.rs".to_string(),
2712 content,
2713 ))));
2714
2715 assert!(state.preview_editor.is_some());
2716 assert_eq!(state.preview_path.as_deref(), Some("test.rs"));
2717 assert!(state.active_tab().selected_diff.is_none());
2719 }
2720
2721 #[test]
2722 fn preview_file_loaded_error_sets_error_message() {
2723 use crate::message::Message;
2724 let mut state = GitKraft::new();
2725 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2726
2727 let _ = state.update(Message::FilePreviewLoaded(Err("No such file".to_string())));
2728
2729 assert!(state.active_tab().error_message.is_some());
2730 assert!(state
2731 .active_tab()
2732 .error_message
2733 .as_ref()
2734 .unwrap()
2735 .contains("No such file"));
2736 }
2737
2738 #[test]
2739 fn close_preview_clears_editor_state() {
2740 use crate::message::Message;
2741 let mut state = GitKraft::new();
2742 state.preview_editor = Some(iced::widget::text_editor::Content::with_text("hello"));
2743 state.preview_path = Some("/test/file.txt".to_string());
2744
2745 let _ = state.update(Message::ClosePreview);
2746
2747 assert!(state.preview_editor.is_none());
2748 assert!(state.preview_path.is_none());
2749 }
2750
2751 #[test]
2754 fn close_repo_single_tab_shows_welcome_screen() {
2755 use crate::message::Message;
2759 let mut state = GitKraft::new();
2760 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2761 assert_eq!(state.tabs.len(), 1);
2762
2763 let _ = state.update(Message::CloseRepo);
2764
2765 assert_eq!(state.tabs.len(), 1, "last tab must not be removed");
2766 assert_eq!(state.active_tab, 0);
2767 assert!(!state.active_tab().has_repo(), "tab should be empty");
2768 }
2769
2770 #[test]
2771 fn close_repo_with_multiple_tabs_removes_current_tab() {
2772 use crate::message::Message;
2783 let mut state = GitKraft::new();
2784 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2786 state.tabs.push(RepoTab::new_empty());
2788 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2789 state.active_tab = 0;
2791
2792 let _ = state.update(Message::CloseRepo);
2793
2794 assert_eq!(state.tabs.len(), 1, "current tab must be removed");
2797 assert_eq!(state.active_tab, 0);
2798 assert_eq!(
2799 state.active_tab().repo_path.as_deref(),
2800 Some(std::path::Path::new("/home/user/repo-b")),
2801 "repo-b should be the surviving tab"
2802 );
2803 }
2804
2805 #[test]
2806 fn close_repo_second_tab_active_removes_it_and_keeps_first() {
2807 use crate::message::Message;
2810 let mut state = GitKraft::new();
2811 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2813 state.tabs.push(RepoTab::new_empty());
2815 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2816 state.active_tab = 1; let _ = state.update(Message::CloseRepo);
2819
2820 assert_eq!(state.tabs.len(), 1);
2821 assert_eq!(state.active_tab, 0);
2822 assert_eq!(
2823 state.active_tab().repo_path.as_deref(),
2824 Some(std::path::Path::new("/home/user/repo-a")),
2825 "repo-a should be the surviving tab"
2826 );
2827 }
2828
2829 #[test]
2832 fn close_tab_active_tab_first_position() {
2833 use crate::message::Message;
2835 let mut state = GitKraft::new();
2836 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2837 state.tabs.push(RepoTab::new_empty());
2838 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2839 state.active_tab = 0;
2840
2841 let _ = state.update(Message::CloseTab(0));
2842
2843 assert_eq!(state.tabs.len(), 1);
2844 assert_eq!(state.active_tab, 0);
2845 assert_eq!(
2846 state.active_tab().repo_path.as_deref(),
2847 Some(std::path::Path::new("/home/user/repo-b"))
2848 );
2849 }
2850
2851 #[test]
2852 fn close_tab_active_tab_second_position() {
2853 use crate::message::Message;
2855 let mut state = GitKraft::new();
2856 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2857 state.tabs.push(RepoTab::new_empty());
2858 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2859 state.active_tab = 1;
2860
2861 let _ = state.update(Message::CloseTab(1));
2862
2863 assert_eq!(state.tabs.len(), 1);
2864 assert_eq!(state.active_tab, 0);
2865 assert_eq!(
2866 state.active_tab().repo_path.as_deref(),
2867 Some(std::path::Path::new("/home/user/repo-a"))
2868 );
2869 }
2870
2871 #[test]
2872 fn close_tab_non_active_tab_before_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 = 1;
2880
2881 let _ = state.update(Message::CloseTab(0));
2882
2883 assert_eq!(state.tabs.len(), 1);
2884 assert_eq!(state.active_tab, 0, "active_tab must shift down by 1");
2885 assert_eq!(
2886 state.active_tab().repo_path.as_deref(),
2887 Some(std::path::Path::new("/home/user/repo-b")),
2888 "repo-b must remain active"
2889 );
2890 }
2891
2892 #[test]
2893 fn close_tab_non_active_tab_after_active() {
2894 use crate::message::Message;
2896 let mut state = GitKraft::new();
2897 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2898 state.tabs.push(RepoTab::new_empty());
2899 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2900 state.active_tab = 0;
2901
2902 let _ = state.update(Message::CloseTab(1));
2903
2904 assert_eq!(state.tabs.len(), 1);
2905 assert_eq!(state.active_tab, 0, "active_tab must remain unchanged");
2906 assert_eq!(
2907 state.active_tab().repo_path.as_deref(),
2908 Some(std::path::Path::new("/home/user/repo-a")),
2909 "repo-a must remain active"
2910 );
2911 }
2912
2913 #[test]
2914 fn close_tab_last_remaining_tab_is_noop() {
2915 use crate::message::Message;
2917 let mut state = GitKraft::new();
2918 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2919 assert_eq!(state.tabs.len(), 1);
2920
2921 let _ = state.update(Message::CloseTab(0));
2922
2923 assert_eq!(state.tabs.len(), 1, "last tab must not be removed");
2924 assert!(
2925 state.active_tab().has_repo(),
2926 "tab content must be unchanged"
2927 );
2928 }
2929
2930 #[test]
2931 fn close_tab_out_of_bounds_index_is_noop() {
2932 use crate::message::Message;
2934 let mut state = GitKraft::new();
2935 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2936 state.tabs.push(RepoTab::new_empty());
2937 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2938 assert_eq!(state.tabs.len(), 2);
2939
2940 let _ = state.update(Message::CloseTab(99));
2941
2942 assert_eq!(state.tabs.len(), 2, "no tab should be removed");
2943 }
2944
2945 #[test]
2948 fn close_tab_three_tabs_close_first_active() {
2949 use crate::message::Message;
2952 let mut state = GitKraft::new();
2953 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2954 state.tabs.push(RepoTab::new_empty());
2955 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2956 state.tabs.push(RepoTab::new_empty());
2957 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2958 state.active_tab = 0;
2959
2960 let _ = state.update(Message::CloseTab(0));
2961
2962 assert_eq!(state.tabs.len(), 2);
2963 assert_eq!(state.active_tab, 0);
2964 assert_eq!(
2965 state.active_tab().repo_path.as_deref(),
2966 Some(std::path::Path::new("/home/user/repo-b"))
2967 );
2968 }
2969
2970 #[test]
2971 fn close_tab_three_tabs_close_active_middle() {
2972 use crate::message::Message;
2975 let mut state = GitKraft::new();
2976 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2977 state.tabs.push(RepoTab::new_empty());
2978 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2979 state.tabs.push(RepoTab::new_empty());
2980 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2981 state.active_tab = 1;
2982
2983 let _ = state.update(Message::CloseTab(1));
2984
2985 assert_eq!(state.tabs.len(), 2);
2986 assert_eq!(
2987 state.active_tab, 1,
2988 "active_tab must stay at 1 (repo-c slides in)"
2989 );
2990 assert_eq!(
2991 state.active_tab().repo_path.as_deref(),
2992 Some(std::path::Path::new("/home/user/repo-c"))
2993 );
2994 }
2995
2996 #[test]
2997 fn close_tab_three_tabs_close_active_last() {
2998 use crate::message::Message;
3001 let mut state = GitKraft::new();
3002 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3003 state.tabs.push(RepoTab::new_empty());
3004 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3005 state.tabs.push(RepoTab::new_empty());
3006 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3007 state.active_tab = 2;
3008
3009 let _ = state.update(Message::CloseTab(2));
3010
3011 assert_eq!(state.tabs.len(), 2);
3012 assert_eq!(
3013 state.active_tab, 1,
3014 "active_tab must be clamped to last index"
3015 );
3016 assert_eq!(
3017 state.active_tab().repo_path.as_deref(),
3018 Some(std::path::Path::new("/home/user/repo-b"))
3019 );
3020 }
3021
3022 #[test]
3023 fn close_tab_three_tabs_close_first_when_middle_active() {
3024 use crate::message::Message;
3027 let mut state = GitKraft::new();
3028 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3029 state.tabs.push(RepoTab::new_empty());
3030 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3031 state.tabs.push(RepoTab::new_empty());
3032 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3033 state.active_tab = 1;
3034
3035 let _ = state.update(Message::CloseTab(0));
3036
3037 assert_eq!(state.tabs.len(), 2);
3038 assert_eq!(
3039 state.active_tab, 0,
3040 "active_tab must decrement when a tab before it is removed"
3041 );
3042 assert_eq!(
3043 state.active_tab().repo_path.as_deref(),
3044 Some(std::path::Path::new("/home/user/repo-b")),
3045 "repo-b must remain active"
3046 );
3047 }
3048
3049 #[test]
3050 fn close_tab_three_tabs_close_last_when_middle_active() {
3051 use crate::message::Message;
3054 let mut state = GitKraft::new();
3055 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3056 state.tabs.push(RepoTab::new_empty());
3057 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3058 state.tabs.push(RepoTab::new_empty());
3059 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3060 state.active_tab = 1;
3061
3062 let _ = state.update(Message::CloseTab(2));
3063
3064 assert_eq!(state.tabs.len(), 2);
3065 assert_eq!(
3066 state.active_tab, 1,
3067 "active_tab must not change when a tab after it is removed"
3068 );
3069 assert_eq!(
3070 state.active_tab().repo_path.as_deref(),
3071 Some(std::path::Path::new("/home/user/repo-b")),
3072 "repo-b must remain active"
3073 );
3074 }
3075
3076 #[test]
3079 fn close_repo_three_tabs_active_in_middle() {
3080 use crate::message::Message;
3083 let mut state = GitKraft::new();
3084 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3085 state.tabs.push(RepoTab::new_empty());
3086 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3087 state.tabs.push(RepoTab::new_empty());
3088 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3089 state.active_tab = 1;
3090
3091 let _ = state.update(Message::CloseRepo);
3092
3093 assert_eq!(state.tabs.len(), 2);
3094 assert_eq!(state.active_tab, 1);
3095 assert_eq!(
3096 state.active_tab().repo_path.as_deref(),
3097 Some(std::path::Path::new("/home/user/repo-c"))
3098 );
3099 }
3100
3101 #[test]
3102 fn close_repo_three_tabs_active_at_end() {
3103 use crate::message::Message;
3106 let mut state = GitKraft::new();
3107 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3108 state.tabs.push(RepoTab::new_empty());
3109 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3110 state.tabs.push(RepoTab::new_empty());
3111 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3112 state.active_tab = 2;
3113
3114 let _ = state.update(Message::CloseRepo);
3115
3116 assert_eq!(state.tabs.len(), 2);
3117 assert_eq!(state.active_tab, 1, "active_tab clamped to last index");
3118 assert_eq!(
3119 state.active_tab().repo_path.as_deref(),
3120 Some(std::path::Path::new("/home/user/repo-b"))
3121 );
3122 }
3123
3124 #[test]
3127 fn new_tab_appends_empty_tab_and_makes_it_active() {
3128 use crate::message::Message;
3129 let mut state = GitKraft::new();
3130 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3131 assert_eq!(state.tabs.len(), 1);
3132
3133 let _ = state.update(Message::NewTab);
3134
3135 assert_eq!(state.tabs.len(), 2, "a new empty tab must be appended");
3136 assert_eq!(state.active_tab, 1, "new tab must become active");
3137 assert!(!state.active_tab().has_repo(), "new tab must be empty");
3138 assert!(state.tabs[0].has_repo());
3140 }
3141
3142 #[test]
3143 fn new_tab_with_multiple_existing_tabs() {
3144 use crate::message::Message;
3145 let mut state = GitKraft::new();
3146 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3147 state.tabs.push(RepoTab::new_empty());
3148 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3149 state.active_tab = 0;
3150
3151 let _ = state.update(Message::NewTab);
3152
3153 assert_eq!(state.tabs.len(), 3);
3154 assert_eq!(state.active_tab, 2);
3155 assert!(!state.active_tab().has_repo());
3156 }
3157
3158 #[test]
3161 fn switch_tab_to_valid_index() {
3162 use crate::message::Message;
3163 let mut state = GitKraft::new();
3164 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3165 state.tabs.push(RepoTab::new_empty());
3166 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3167 state.active_tab = 0;
3168
3169 let _ = state.update(Message::SwitchTab(1));
3170
3171 assert_eq!(state.active_tab, 1);
3172 assert_eq!(
3173 state.active_tab().repo_path.as_deref(),
3174 Some(std::path::Path::new("/home/user/repo-b"))
3175 );
3176 }
3177
3178 #[test]
3179 fn switch_tab_to_same_index_is_noop() {
3180 use crate::message::Message;
3181 let mut state = GitKraft::new();
3182 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3183 state.tabs.push(RepoTab::new_empty());
3184 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3185 state.active_tab = 1;
3186
3187 let _ = state.update(Message::SwitchTab(1));
3188
3189 assert_eq!(state.active_tab, 1, "should remain on tab 1");
3190 }
3191
3192 #[test]
3193 fn switch_tab_out_of_bounds_is_noop() {
3194 use crate::message::Message;
3196 let mut state = GitKraft::new();
3197 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3198 state.tabs.push(RepoTab::new_empty());
3199 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3200 state.active_tab = 0;
3201
3202 let _ = state.update(Message::SwitchTab(99));
3203
3204 assert_eq!(state.active_tab, 0, "out-of-bounds switch must be ignored");
3205 }
3206
3207 #[test]
3210 fn session_state_matches_open_tab_paths_when_no_empty_tabs() {
3211 let mut state = GitKraft::new();
3212 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3213 state.tabs.push(RepoTab::new_empty());
3214 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3215 state.active_tab = 1;
3216
3217 let (paths, active) = state.session_state();
3218 assert_eq!(paths.len(), 2);
3219 assert_eq!(active, 1);
3220 }
3221
3222 #[test]
3223 fn session_state_adjusts_index_when_empty_tab_before_active() {
3224 let mut state = GitKraft::new();
3227 state.tabs.push(RepoTab::new_empty());
3229 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
3230 state.tabs.push(RepoTab::new_empty());
3231 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-b");
3232 state.active_tab = 2;
3233
3234 let (paths, active) = state.session_state();
3235 assert_eq!(paths.len(), 2);
3236 assert_eq!(active, 1, "session index must skip the empty tab at 0");
3237 }
3238
3239 #[test]
3240 fn session_state_defaults_to_zero_when_active_is_empty_tab() {
3241 let mut state = GitKraft::new();
3242 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3243 state.tabs.push(RepoTab::new_empty()); state.active_tab = 1; let (paths, active) = state.session_state();
3247 assert_eq!(paths.len(), 1);
3248 assert_eq!(active, 0, "empty active tab should default to 0");
3249 }
3250
3251 #[test]
3254 fn repo_selected_sets_repo_path_before_load() {
3255 use crate::message::Message;
3259 let mut state = GitKraft::new();
3260 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3261 state.tabs.push(RepoTab::new_empty());
3263 state.active_tab = 1;
3264
3265 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
3266 "/home/user/repo-b",
3267 ))));
3268
3269 assert_eq!(
3271 state.tabs[1].repo_path.as_deref(),
3272 Some(std::path::Path::new("/home/user/repo-b")),
3273 "repo_path must be set before async load starts"
3274 );
3275 }
3276
3277 #[test]
3278 fn open_recent_repo_sets_repo_path_before_load() {
3279 use crate::message::Message;
3280 let mut state = GitKraft::new();
3281 assert!(!state.active_tab().has_repo());
3283
3284 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
3285 "/home/user/repo-a",
3286 )));
3287
3288 assert_eq!(
3289 state.active_tab().repo_path.as_deref(),
3290 Some(std::path::Path::new("/home/user/repo-a")),
3291 "repo_path must be set before async load starts"
3292 );
3293 }
3294
3295 #[test]
3296 fn handle_repo_loaded_targets_correct_tab_after_switch() {
3297 use crate::message::Message;
3301 let mut state = GitKraft::new();
3302 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3304 state.tabs.push(RepoTab::new_empty());
3306 state.tabs[1].repo_path = Some(std::path::PathBuf::from("/home/user/repo-b"));
3307 state.tabs[1].is_loading = true;
3308
3309 state.active_tab = 0;
3311
3312 let payload = fake_payload("/home/user/repo-b");
3314 let _ = state.update(Message::RepoOpened(Ok(payload)));
3315
3316 assert!(
3318 state.tabs[1].repo_info.is_some(),
3319 "repo-b payload must be written to tab 1"
3320 );
3321 assert_eq!(
3322 state.tabs[1].repo_path.as_deref(),
3323 Some(std::path::Path::new("/home/user/repo-b")),
3324 );
3325 assert_eq!(
3327 state.tabs[0].repo_path.as_deref(),
3328 Some(std::path::Path::new("/home/user/repo-a")),
3329 );
3330 }
3331
3332 #[test]
3335 fn apply_payload_does_not_reset_is_loading_more_commits() {
3336 use crate::message::Message;
3341 let mut state = GitKraft::new();
3342 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3343
3344 state.active_tab_mut().is_loading_more_commits = true;
3346
3347 let payload = fake_payload("/home/user/repo-a");
3349 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3350
3351 assert!(
3353 state.active_tab().is_loading_more_commits,
3354 "refresh must not reset is_loading_more_commits"
3355 );
3356 }
3357
3358 #[test]
3359 fn apply_payload_resets_is_loading_more_commits_for_new_open() {
3360 let mut state = GitKraft::new();
3365 assert!(!state.active_tab().is_loading_more_commits);
3366
3367 let payload = fake_payload("/home/user/repo-a");
3368 let _ = state.update(crate::message::Message::RepoOpened(Ok(payload)));
3369
3370 assert!(!state.active_tab().is_loading_more_commits);
3371 }
3372
3373 #[test]
3374 fn refresh_does_not_shrink_commit_list() {
3375 use crate::message::Message;
3378 let mut state = GitKraft::new();
3379 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3380
3381 let tab = state.active_tab_mut();
3383 tab.commits = make_test_commits(500);
3384 for i in 0..200 {
3385 let mut c = tab.commits[0].clone();
3386 c.oid = format!("extra_{i:04}");
3387 c.short_oid = format!("x{i:03}");
3388 tab.commits.push(c);
3389 }
3390 let extended_len = tab.commits.len();
3391 tab.has_more_commits = true;
3392
3393 let mut payload = fake_payload("/home/user/repo-a");
3395 payload.commits = make_test_commits(500);
3396 payload.graph_rows = gitkraft_core::features::graph::build_graph(&payload.commits);
3397 let payload_len = payload.commits.len();
3398 assert!(
3399 payload_len < extended_len,
3400 "payload must be shorter for this test"
3401 );
3402
3403 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3404
3405 assert_eq!(
3406 state.active_tab().commits.len(),
3407 extended_len,
3408 "refresh must not shrink the commit list"
3409 );
3410 }
3411
3412 #[test]
3413 fn refresh_replaces_commits_when_count_equal_or_greater() {
3414 use crate::message::Message;
3417 let mut state = GitKraft::new();
3418 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3419 state.active_tab_mut().commits = make_test_commits(5);
3420
3421 let original_oid = state.active_tab().commits[0].oid.clone();
3422
3423 let mut payload = fake_payload("/home/user/repo-a");
3425 let mut new_commits = make_test_commits(5);
3426 new_commits[0].oid = "refreshed_oid_0000".to_string();
3427 payload.commits = new_commits;
3428 payload.graph_rows = gitkraft_core::features::graph::build_graph(&payload.commits);
3429 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3430
3431 assert_eq!(
3432 state.active_tab().commits[0].oid,
3433 "refreshed_oid_0000",
3434 "refresh with same count must replace commits"
3435 );
3436 assert_ne!(state.active_tab().commits[0].oid, original_oid);
3437 }
3438
3439 #[test]
3440 fn refresh_rebuilds_commit_display_when_content_changes_at_same_count() {
3441 use crate::message::Message;
3446 let mut state = GitKraft::new();
3447 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3448 let mut initial_commits = make_test_commits(5);
3449 initial_commits[0].author_name = "Alice".to_string();
3450 state.active_tab_mut().commits = initial_commits;
3451 state.active_tab_mut().commit_display = state
3453 .active_tab()
3454 .commits
3455 .iter()
3456 .map(|c| ("1 day ago".to_string(), c.author_name.clone()))
3457 .collect();
3458 assert_eq!(state.active_tab().commit_display[0].1, "Alice");
3459
3460 let mut payload = fake_payload("/home/user/repo-a");
3462 let mut new_commits = make_test_commits(5);
3463 new_commits[0].author_name = "Bob".to_string();
3464 payload.commits = new_commits;
3465 payload.graph_rows = gitkraft_core::features::graph::build_graph(&payload.commits);
3466 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3467
3468 assert_eq!(
3470 state.active_tab().commit_display[0].1,
3471 "Bob",
3472 "commit_display must be rebuilt when content changes at same count"
3473 );
3474 }
3475
3476 #[test]
3477 fn duplicate_more_commits_loaded_sets_has_more_false() {
3478 use crate::message::{CommitPage, Message};
3484 let mut state = GitKraft::new();
3485 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3486 let tab = state.active_tab_mut();
3487 tab.commits = make_test_commits(10);
3488 tab.has_more_commits = true;
3489
3490 let _initial_count = tab.commits.len();
3492 let mut bigger_commits = tab.commits.clone();
3493 for i in 0..5 {
3494 let mut c = bigger_commits[0].clone();
3495 c.oid = format!("new_{i:04}");
3496 bigger_commits.push(c);
3497 }
3498 let bigger_count = bigger_commits.len();
3499 let graph_rows = gitkraft_core::features::graph::build_graph(&bigger_commits);
3500 let _ = state.update(Message::MoreCommitsLoaded(Ok(CommitPage {
3501 commits: bigger_commits.clone(),
3502 graph_rows: graph_rows.clone(),
3503 })));
3504 assert!(
3505 state.active_tab().has_more_commits,
3506 "first result should keep has_more true"
3507 );
3508 assert_eq!(state.active_tab().commits.len(), bigger_count);
3509
3510 let _ = state.update(Message::MoreCommitsLoaded(Ok(CommitPage {
3512 commits: bigger_commits,
3513 graph_rows,
3514 })));
3515 assert!(
3516 !state.active_tab().has_more_commits,
3517 "identical second result must set has_more_commits false"
3518 );
3519 }
3520
3521 #[test]
3522 fn file_system_changed_allowed_during_pagination() {
3523 use crate::message::Message;
3528 let mut state = GitKraft::new();
3529 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3530 state.active_tab_mut().is_loading_more_commits = true;
3531
3532 let _task = state.update(Message::FileSystemChanged);
3533
3534 assert!(state.active_tab().is_loading_more_commits);
3536 }
3539
3540 #[test]
3541 fn has_more_commits_preserved_across_refresh() {
3542 use crate::message::Message;
3544 let mut state = GitKraft::new();
3545 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3546 state.active_tab_mut().has_more_commits = true;
3547
3548 let payload = fake_payload("/home/user/repo-a");
3549 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3550
3551 assert!(
3552 state.active_tab().has_more_commits,
3553 "refresh must preserve has_more_commits"
3554 );
3555 }
3556
3557 #[test]
3558 fn switch_to_existing_tab_removes_empty_tab() {
3559 let mut state = GitKraft::new();
3560 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3562 state.tabs.push(RepoTab::new_empty());
3564 state.active_tab = 1;
3565
3566 let found = state.switch_to_existing_tab(std::path::Path::new("/home/user/repo-a"));
3567
3568 assert!(found, "should find existing tab");
3569 assert_eq!(state.tabs.len(), 1, "empty tab should be removed");
3570 assert_eq!(state.active_tab, 0);
3571 assert_eq!(
3572 state.active_tab().repo_path.as_deref(),
3573 Some(std::path::Path::new("/home/user/repo-a")),
3574 );
3575 }
3576
3577 #[test]
3578 fn switch_to_existing_tab_returns_false_when_not_found() {
3579 let mut state = GitKraft::new();
3580 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3581
3582 let found = state.switch_to_existing_tab(std::path::Path::new("/home/user/repo-b"));
3583
3584 assert!(!found, "should not find nonexistent tab");
3585 assert_eq!(state.tabs.len(), 1);
3586 }
3587
3588 #[test]
3589 fn switch_to_existing_tab_keeps_loaded_current_tab() {
3590 let mut state = GitKraft::new();
3591 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3593 state.tabs.push(RepoTab::new_empty());
3595 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3596 state.active_tab = 1;
3597
3598 let found = state.switch_to_existing_tab(std::path::Path::new("/home/user/repo-a"));
3599
3600 assert!(found);
3601 assert_eq!(state.tabs.len(), 2, "loaded tab should not be removed");
3602 assert_eq!(state.active_tab, 0);
3603 }
3604
3605 #[test]
3606 fn default_remote_returns_first_remote() {
3607 let mut tab = RepoTab::new_empty();
3608 tab.remotes = vec![
3609 gitkraft_core::RemoteInfo {
3610 name: "upstream".to_string(),
3611 url: Some("https://example.com/upstream.git".to_string()),
3612 fetch_refspecs: vec![],
3613 },
3614 gitkraft_core::RemoteInfo {
3615 name: "origin".to_string(),
3616 url: Some("https://example.com/origin.git".to_string()),
3617 fetch_refspecs: vec![],
3618 },
3619 ];
3620 assert_eq!(tab.default_remote(), "upstream");
3621 }
3622
3623 #[test]
3624 fn default_remote_fallback_to_origin() {
3625 let tab = RepoTab::new_empty();
3626 assert_eq!(tab.default_remote(), "origin");
3627 }
3628}