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 hovered_commit: Option<usize>,
196
197 pub branch_context: Vec<Option<String>>,
202
203 pub diff_scroll_offset: f32,
205 pub commit_display: Vec<(String, String)>,
210
211 pub has_more_commits: bool,
213 pub is_loading_more_commits: bool,
215
216 pub file_history_path: Option<String>,
218 pub file_history_commits: Vec<gitkraft_core::CommitInfo>,
220 pub file_history_scroll: f32,
222
223 pub blame_path: Option<String>,
225 pub blame_lines: Vec<gitkraft_core::BlameLine>,
227 pub blame_scroll: f32,
229
230 pub pending_delete_file: Option<String>,
232}
233
234impl RepoTab {
235 pub fn new_empty() -> Self {
237 Self {
238 repo_path: None,
239 repo_info: None,
240 branches: Vec::new(),
241 current_branch: None,
242 commits: Vec::new(),
243 selected_commit: None,
244 anchor_commit_index: None,
245 selected_commits: Vec::new(),
246 graph_rows: Vec::new(),
247 unstaged_changes: Vec::new(),
248 staged_changes: Vec::new(),
249 commit_files: Vec::new(),
250 selected_commit_oid: None,
251 selected_file_index: None,
252 is_loading_file_diff: false,
253 anchor_file_index: None,
254 selected_commit_file_indices: Vec::new(),
255 multi_file_diffs: Vec::new(),
256 commit_range_diffs: Vec::new(),
257 selected_diff: None,
258 commit_message: String::new(),
259 stashes: Vec::new(),
260 remotes: Vec::new(),
261 show_commit_detail: false,
262 new_branch_name: String::new(),
263 show_branch_create: false,
264 local_branches_expanded: true,
265 remote_branches_expanded: true,
266 stash_message: String::new(),
267 selected_unstaged: std::collections::HashSet::new(),
268 selected_staged: std::collections::HashSet::new(),
269 anchor_unstaged_index: None,
270 anchor_staged_index: None,
271 pending_discard: None,
272 status_message: None,
273 error_message: None,
274 is_loading: false,
275 context_menu: None,
276 context_menu_pos: (0.0, 0.0),
277 rename_branch_target: None,
278 rename_branch_input: String::new(),
279 create_tag_target_oid: None,
280 create_tag_annotated: false,
281 create_tag_name: String::new(),
282 create_tag_message: String::new(),
283 create_branch_at_oid: None,
284 commit_scroll_offset: 0.0,
285 hovered_commit: None,
286 branch_context: Vec::new(),
287 diff_scroll_offset: 0.0,
288 commit_display: Vec::new(),
289 has_more_commits: true,
290 is_loading_more_commits: false,
291 file_history_path: None,
292 file_history_commits: Vec::new(),
293 file_history_scroll: 0.0,
294 blame_path: None,
295 blame_lines: Vec::new(),
296 blame_scroll: 0.0,
297 pending_delete_file: None,
298 }
299 }
300
301 pub fn has_repo(&self) -> bool {
303 self.repo_path.is_some()
304 }
305
306 pub fn default_remote(&self) -> String {
308 self.remotes
309 .first()
310 .map(|r| r.name.clone())
311 .unwrap_or_else(|| "origin".to_string())
312 }
313
314 pub fn display_name(&self) -> String {
316 gitkraft_core::repo_display_name(self.repo_path.as_deref())
317 }
318
319 pub fn apply_payload(
325 &mut self,
326 payload: crate::message::RepoPayload,
327 path: std::path::PathBuf,
328 ) {
329 let is_refresh = self.repo_info.is_some();
331 let prev_oid = self.selected_commit_oid.clone();
332
333 let prev_anchor_oid = self
337 .anchor_commit_index
338 .and_then(|i| self.commits.get(i).map(|c| c.oid.clone()));
339 let prev_selected_oids: Vec<String> = self
340 .selected_commits
341 .iter()
342 .filter_map(|&i| self.commits.get(i).map(|c| c.oid.clone()))
343 .collect();
344
345 let prev_anchor_file = self.anchor_file_index;
349 let prev_file_indices = self.selected_commit_file_indices.clone();
350 let prev_multi_file_diffs = std::mem::take(&mut self.multi_file_diffs);
351 let prev_commit_range_diffs = std::mem::take(&mut self.commit_range_diffs);
352
353 let prev_selected_unstaged = std::mem::take(&mut self.selected_unstaged);
356 let prev_selected_staged = std::mem::take(&mut self.selected_staged);
357 let prev_anchor_unstaged = self.anchor_unstaged_index;
358 let prev_anchor_staged = self.anchor_staged_index;
359
360 let prev_selected_diff = self.selected_diff.take();
363 let prev_diff_scroll = self.diff_scroll_offset;
364
365 self.current_branch = payload.info.head_branch.clone();
366 self.repo_path = Some(path);
367 self.repo_info = Some(payload.info);
368 self.branches = payload.branches;
369
370 if !is_refresh || payload.commits.len() >= self.commits.len() {
375 self.commits = payload.commits;
376 self.graph_rows = payload.graph_rows;
377 }
378
379 self.unstaged_changes = payload.unstaged;
380 self.staged_changes = payload.staged;
381 self.stashes = payload.stashes;
382 self.remotes = payload.remotes;
383
384 self.selected_commit = None;
391 self.anchor_commit_index = None;
392 self.selected_commits.clear();
393 self.selected_commit_oid = None;
394 self.error_message = None;
395 self.status_message = Some("Repository loaded.".into());
396 if !is_refresh {
401 self.has_more_commits = true;
402 }
403 self.anchor_unstaged_index = None;
410 self.anchor_staged_index = None;
411 self.anchor_file_index = None;
412 self.selected_commit_file_indices.clear();
413
414 if let Some(oid) = prev_oid {
420 if let Some(new_idx) = self.commits.iter().position(|c| c.oid == oid) {
421 self.selected_commit = Some(new_idx);
422 self.selected_commit_oid = Some(oid);
423 self.selected_diff = prev_selected_diff;
430 self.diff_scroll_offset = prev_diff_scroll;
431
432 self.anchor_file_index = prev_anchor_file;
435 self.selected_commit_file_indices = prev_file_indices;
436 self.multi_file_diffs = prev_multi_file_diffs;
437 self.commit_range_diffs = prev_commit_range_diffs;
438 } else {
439 self.clear_diff_state();
441 }
442 } else {
443 if prev_selected_diff.is_some() {
446 self.selected_diff = prev_selected_diff;
447 self.diff_scroll_offset = prev_diff_scroll;
448 } else {
449 self.clear_diff_state();
450 }
451 }
452
453 if let Some(anchor_oid) = prev_anchor_oid {
457 if let Some(new_anchor) = self.commits.iter().position(|c| c.oid == anchor_oid) {
458 self.anchor_commit_index = Some(new_anchor);
459 }
460 }
461 if !prev_selected_oids.is_empty() {
462 let restored: Vec<usize> = prev_selected_oids
463 .iter()
464 .filter_map(|oid| self.commits.iter().position(|c| &c.oid == oid))
465 .collect();
466 if !restored.is_empty() {
467 self.selected_commits = restored;
468 self.selected_commits.sort_unstable();
469 }
470 }
471
472 self.selected_unstaged = prev_selected_unstaged
475 .into_iter()
476 .filter(|p| self.unstaged_changes.iter().any(|d| d.display_path() == p))
477 .collect();
478 self.selected_staged = prev_selected_staged
479 .into_iter()
480 .filter(|p| self.staged_changes.iter().any(|d| d.display_path() == p))
481 .collect();
482 self.anchor_unstaged_index =
484 prev_anchor_unstaged.filter(|&i| i < self.unstaged_changes.len());
485 self.anchor_staged_index = prev_anchor_staged.filter(|&i| i < self.staged_changes.len());
486 }
487
488 fn clear_diff_state(&mut self) {
491 self.selected_diff = None;
492 self.commit_files.clear();
493 self.selected_file_index = None;
494 self.is_loading_file_diff = false;
495 self.diff_scroll_offset = 0.0;
496 }
497}
498
499pub struct GitKraft {
503 pub tabs: Vec<RepoTab>,
506 pub active_tab: usize,
508
509 pub sidebar_expanded: bool,
512
513 pub sidebar_width: f32,
516 pub commit_log_width: f32,
518 pub staging_height: f32,
520 pub diff_file_list_width: f32,
522 pub commit_ref_width: f32,
524 pub commit_graph_width: f32,
526 pub sidebar_branches_ratio: f32,
528 pub sidebar_stash_ratio: f32,
531 pub staging_unstaged_ratio: f32,
533 pub staging_staged_ratio: f32,
536
537 pub ui_scale: f32,
539
540 pub dragging: Option<DragTarget>,
543 pub dragging_h: Option<DragTargetH>,
545 pub drag_start_x: f32,
547 pub drag_start_y: f32,
549 pub drag_initialized: bool,
553 pub drag_initialized_h: bool,
555
556 pub cursor_pos: Point,
561
562 pub current_theme_index: usize,
565
566 pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
569
570 pub search_visible: bool,
573 pub search_query: String,
575 pub search_results: Vec<gitkraft_core::CommitInfo>,
577 pub search_selected: Option<usize>,
579
580 pub search_diff_files: Vec<gitkraft_core::DiffFileEntry>,
582 pub search_diff_selected: HashSet<usize>,
584 pub search_diff_content: Vec<gitkraft_core::DiffInfo>,
586 pub search_diff_oid: Option<String>,
588
589 pub editor: gitkraft_core::Editor,
591
592 pub keyboard_modifiers: iced::keyboard::Modifiers,
594
595 pub animation_tick: u64,
598
599 pub window_width: f32,
602 pub window_height: f32,
604 pub window_x: f32,
606 pub window_y: f32,
608
609 pub preview_editor: Option<iced::widget::text_editor::Content>,
611 pub preview_path: Option<String>,
613}
614
615impl Default for GitKraft {
616 fn default() -> Self {
617 Self::new()
618 }
619}
620
621impl GitKraft {
622 fn from_settings(settings: gitkraft_core::AppSettings) -> Self {
628 let current_theme_index = settings
629 .theme_name
630 .as_deref()
631 .map(gitkraft_core::theme_index_by_name)
632 .unwrap_or(0);
633
634 let recent_repos = settings.recent_repos;
635
636 let (
637 sidebar_width,
638 commit_log_width,
639 staging_height,
640 diff_file_list_width,
641 sidebar_expanded,
642 ui_scale,
643 commit_ref_width,
644 commit_graph_width,
645 ) = if let Some(ref layout) = settings.layout {
646 (
647 layout.sidebar_width.unwrap_or(220.0),
648 layout.commit_log_width.unwrap_or(500.0),
649 layout.staging_height.unwrap_or(200.0),
650 layout.diff_file_list_width.unwrap_or(180.0),
651 layout.sidebar_expanded.unwrap_or(true),
652 layout.ui_scale.unwrap_or(1.0),
653 layout.commit_ref_width.unwrap_or(120.0),
654 layout.commit_graph_width.unwrap_or(80.0),
655 )
656 } else {
657 (220.0, 500.0, 200.0, 180.0, true, 1.0, 120.0, 80.0)
658 };
659
660 Self {
661 tabs: vec![RepoTab::new_empty()],
662 active_tab: 0,
663
664 sidebar_expanded,
665
666 sidebar_width,
667 commit_log_width,
668 staging_height,
669 diff_file_list_width,
670 commit_ref_width,
671 commit_graph_width,
672 sidebar_branches_ratio: 0.6,
673 sidebar_stash_ratio: 0.25,
674 staging_unstaged_ratio: 0.4,
675 staging_staged_ratio: 0.3,
676
677 ui_scale,
678
679 dragging: None,
680 dragging_h: None,
681 drag_start_x: 0.0,
682 drag_start_y: 0.0,
683 drag_initialized: false,
684 drag_initialized_h: false,
685 cursor_pos: Point::ORIGIN,
686
687 current_theme_index,
688
689 recent_repos,
690
691 search_visible: false,
692 search_query: String::new(),
693 search_results: Vec::new(),
694 search_selected: None,
695 search_diff_files: Vec::new(),
696 search_diff_selected: HashSet::new(),
697 search_diff_content: Vec::new(),
698 search_diff_oid: None,
699
700 keyboard_modifiers: iced::keyboard::Modifiers::default(),
701 animation_tick: 0,
702
703 window_width: settings
704 .layout
705 .as_ref()
706 .and_then(|l| l.window_width)
707 .unwrap_or(1400.0),
708 window_height: settings
709 .layout
710 .as_ref()
711 .and_then(|l| l.window_height)
712 .unwrap_or(800.0),
713 window_x: settings
714 .layout
715 .as_ref()
716 .and_then(|l| l.window_x)
717 .unwrap_or(0.0),
718 window_y: settings
719 .layout
720 .as_ref()
721 .and_then(|l| l.window_y)
722 .unwrap_or(0.0),
723
724 editor: settings
725 .editor_name
726 .as_deref()
727 .map(|name| {
728 gitkraft_core::EDITOR_NAMES
730 .iter()
731 .position(|n| n.eq_ignore_ascii_case(name))
732 .map(gitkraft_core::Editor::from_index)
733 .unwrap_or_else(|| {
734 if name.eq_ignore_ascii_case("none") {
735 gitkraft_core::Editor::None
736 } else {
737 gitkraft_core::Editor::Custom(name.to_string())
738 }
739 })
740 })
741 .unwrap_or_else(detect_system_editor),
742
743 preview_editor: None,
744 preview_path: None,
745 }
746 }
747
748 pub fn new() -> Self {
754 Self::from_settings(
755 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default(),
756 )
757 }
758
759 pub fn new_with_session_paths() -> (Self, Vec<PathBuf>) {
765 let settings =
766 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default();
767 let open_tabs = settings.open_tabs.clone();
768 let active_tab_index = settings.active_tab_index;
769
770 let mut state = Self::from_settings(settings);
771
772 if !open_tabs.is_empty() {
773 state.tabs = open_tabs
774 .iter()
775 .map(|path| {
776 let mut tab = RepoTab::new_empty();
777 tab.repo_path = Some(path.clone());
780 if path.exists() {
781 tab.is_loading = true;
782 tab.status_message = Some(format!(
783 "Loading {}…",
784 path.file_name().unwrap_or_default().to_string_lossy()
785 ));
786 } else {
787 tab.error_message =
788 Some(format!("Repository not found: {}", path.display()));
789 }
790 tab
791 })
792 .collect();
793 state.active_tab = active_tab_index.min(state.tabs.len().saturating_sub(1));
794 }
795
796 (state, open_tabs)
797 }
798
799 pub fn open_tab_paths(&self) -> Vec<PathBuf> {
802 self.tabs
803 .iter()
804 .filter(|t| t.repo_info.is_some())
805 .filter_map(|t| t.repo_path.clone())
806 .collect()
807 }
808
809 pub fn session_state(&self) -> (Vec<PathBuf>, usize) {
815 let mut paths = Vec::new();
816 let mut session_active: usize = 0;
817 let mut found_active = false;
818 for (i, tab) in self.tabs.iter().enumerate() {
819 if tab.repo_info.is_some() {
820 if let Some(ref p) = tab.repo_path {
821 if i == self.active_tab {
822 session_active = paths.len();
823 found_active = true;
824 }
825 paths.push(p.clone());
826 }
827 }
828 }
829 if !found_active {
832 session_active = 0;
833 }
834 (paths, session_active)
835 }
836
837 pub fn active_tab(&self) -> &RepoTab {
839 &self.tabs[self.active_tab]
840 }
841
842 pub fn active_tab_mut(&mut self) -> &mut RepoTab {
844 &mut self.tabs[self.active_tab]
845 }
846
847 pub fn dismiss_menu(&mut self) {
849 self.active_tab_mut().context_menu = None;
850 }
851
852 pub fn open_context_menu(&mut self, menu: ContextMenu) {
854 let pos = (self.cursor_pos.x, self.cursor_pos.y);
855 let tab = self.active_tab_mut();
856 tab.context_menu_pos = pos;
857 tab.context_menu = Some(menu);
858 }
859
860 pub fn has_repo(&self) -> bool {
862 self.active_tab().has_repo()
863 }
864
865 pub fn repo_display_name(&self) -> String {
867 self.active_tab().display_name()
868 }
869
870 pub fn colors(&self) -> ThemeColors {
877 ThemeColors::from_core(&gitkraft_core::theme_by_index(self.current_theme_index))
878 }
879
880 pub fn iced_theme(&self) -> iced::Theme {
889 let core = gitkraft_core::theme_by_index(self.current_theme_index);
890 let name = self.current_theme_name().to_string();
891
892 let palette = iced::theme::Palette {
893 background: rgb_to_iced(core.background),
894 text: rgb_to_iced(core.text_primary),
895 primary: rgb_to_iced(core.accent),
896 success: rgb_to_iced(core.success),
897 warning: rgb_to_iced(core.warning),
898 danger: rgb_to_iced(core.error),
899 };
900
901 iced::Theme::custom(name, palette)
902 }
903
904 pub fn switch_to_existing_tab(&mut self, path: &std::path::Path) -> bool {
908 let existing_idx = match self
909 .tabs
910 .iter()
911 .position(|t| t.repo_path.as_deref() == Some(path) && t.repo_info.is_some())
912 {
913 Some(idx) => idx,
914 None => return false,
915 };
916 if !self.active_tab().has_repo() && self.active_tab != existing_idx && self.tabs.len() > 1 {
919 self.tabs.remove(self.active_tab);
920 let adjusted = if existing_idx > self.active_tab {
921 existing_idx - 1
922 } else {
923 existing_idx
924 };
925 self.active_tab = adjusted;
926 } else {
927 self.active_tab = existing_idx;
928 }
929 true
930 }
931
932 pub fn current_theme_name(&self) -> &'static str {
934 gitkraft_core::THEME_NAMES
935 .get(self.current_theme_index)
936 .copied()
937 .unwrap_or("Default")
938 }
939
940 pub fn refresh_active_tab(&mut self) -> Task<Message> {
944 let tab = self.active_tab();
945 if tab.is_loading {
948 return Task::none();
949 }
950 match tab.repo_path.clone() {
951 Some(path) => {
952 let depth = tab.commits.len();
953 self.active_tab_mut().is_loading = true;
958 crate::features::repo::commands::refresh_repo(path, depth)
959 }
960 None => Task::none(),
961 }
962 }
963
964 pub fn on_ok_refresh(
971 &mut self,
972 result: Result<(), String>,
973 ok_msg: &str,
974 err_prefix: &str,
975 ) -> Task<Message> {
976 match result {
977 Ok(()) => {
978 {
979 let tab = self.active_tab_mut();
980 tab.is_loading = false;
981 tab.status_message = Some(ok_msg.to_string());
982 }
983 self.refresh_active_tab()
984 }
985 Err(e) => {
986 let tab = self.active_tab_mut();
987 tab.is_loading = false;
988 tab.error_message = Some(format!("{err_prefix}: {e}"));
989 tab.status_message = None;
990 Task::none()
991 }
992 }
993 }
994
995 pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
997 gitkraft_core::LayoutSettings {
998 sidebar_width: Some(self.sidebar_width),
999 commit_log_width: Some(self.commit_log_width),
1000 staging_height: Some(self.staging_height),
1001 diff_file_list_width: Some(self.diff_file_list_width),
1002 sidebar_expanded: Some(self.sidebar_expanded),
1003 ui_scale: Some(self.ui_scale),
1004 window_width: Some(self.window_width),
1005 window_height: Some(self.window_height),
1006 window_x: Some(self.window_x),
1007 window_y: Some(self.window_y),
1008 window_maximized: None, commit_ref_width: Some(self.commit_ref_width),
1010 commit_graph_width: Some(self.commit_graph_width),
1011 }
1012 }
1013}
1014
1015fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
1017 Color::from_rgb8(rgb.r, rgb.g, rgb.b)
1018}
1019
1020fn detect_system_editor() -> gitkraft_core::Editor {
1022 for var in ["VISUAL", "EDITOR"] {
1023 if let Ok(val) = std::env::var(var) {
1024 let bin = val.split('/').next_back().unwrap_or(&val).trim();
1025 return match bin {
1026 "nvim" | "neovim" => gitkraft_core::Editor::Neovim,
1027 "vim" => gitkraft_core::Editor::Vim,
1028 "hx" | "helix" => gitkraft_core::Editor::Helix,
1029 "nano" => gitkraft_core::Editor::Nano,
1030 "micro" => gitkraft_core::Editor::Micro,
1031 "emacs" => gitkraft_core::Editor::Emacs,
1032 "code" => gitkraft_core::Editor::VSCode,
1033 "zed" => gitkraft_core::Editor::Zed,
1034 "subl" => gitkraft_core::Editor::Sublime,
1035 _ => gitkraft_core::Editor::Custom(val),
1036 };
1037 }
1038 }
1039 gitkraft_core::Editor::None
1040}
1041
1042#[cfg(test)]
1045mod tests {
1046 use super::*;
1047
1048 #[test]
1049 fn new_defaults() {
1050 let state = GitKraft::new();
1051 assert!(state.active_tab().repo_path.is_none());
1052 assert!(!state.has_repo());
1053 assert_eq!(state.repo_display_name(), "New Tab");
1054 assert!(state.active_tab().commits.is_empty());
1055 assert!(state.sidebar_expanded);
1056 assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
1058 assert!(state.sidebar_width > 0.0);
1060 assert!(state.commit_log_width > 0.0);
1061 assert!(state.staging_height > 0.0);
1062 assert!(state.dragging.is_none());
1063 assert!(state.dragging_h.is_none());
1064 assert_eq!(state.tabs.len(), 1);
1066 assert_eq!(state.active_tab, 0);
1067 }
1068
1069 #[test]
1070 fn repo_display_name_extracts_basename() {
1071 let mut state = GitKraft::new();
1072 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
1073 assert_eq!(state.repo_display_name(), "my-project");
1074 }
1075
1076 #[test]
1077 fn colors_returns_theme_colors() {
1078 let state = GitKraft::new();
1079 let c = state.colors();
1080 assert!(c.bg.r < 0.5);
1082 }
1083
1084 #[test]
1085 fn iced_theme_is_custom_with_correct_palette() {
1086 let mut state = GitKraft::new();
1087
1088 state.current_theme_index = 0;
1090 let iced_t = state.iced_theme();
1091 let pal = iced_t.palette();
1092 assert!(pal.background.r < 0.5, "Default theme bg should be dark");
1093 assert_eq!(iced_t.to_string(), "Default");
1094
1095 let sol_idx = gitkraft_core::theme_index_by_name("Solarized Light");
1097 state.current_theme_index = sol_idx;
1098 let iced_t = state.iced_theme();
1099 let pal = iced_t.palette();
1100 assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
1101 assert_eq!(iced_t.to_string(), "Solarized Light");
1102
1103 let gruvbox_idx = gitkraft_core::theme_index_by_name("Gruvbox Dark");
1105 state.current_theme_index = gruvbox_idx;
1106 let iced_t = state.iced_theme();
1107 let pal = iced_t.palette();
1108 let core = gitkraft_core::theme_by_index(gruvbox_idx);
1109 let expected_accent = rgb_to_iced(core.accent);
1110 assert!(
1111 (pal.primary.r - expected_accent.r).abs() < 0.01
1112 && (pal.primary.g - expected_accent.g).abs() < 0.01
1113 && (pal.primary.b - expected_accent.b).abs() < 0.01,
1114 "Gruvbox Dark accent should match core accent"
1115 );
1116 }
1117
1118 #[test]
1119 fn iced_theme_name_round_trips_through_core() {
1120 for i in 0..gitkraft_core::THEME_COUNT {
1123 let mut state = GitKraft::new();
1124 state.current_theme_index = i;
1125 let iced_t = state.iced_theme();
1126 let name = iced_t.to_string();
1127 let resolved = gitkraft_core::theme_index_by_name(&name);
1128 assert_eq!(
1129 resolved,
1130 i,
1131 "theme index {i} ({}) did not round-trip through iced_theme name",
1132 gitkraft_core::THEME_NAMES[i]
1133 );
1134 }
1135 }
1136
1137 #[test]
1138 fn current_theme_name_round_trips() {
1139 let mut state = GitKraft::new();
1140 state.current_theme_index = gitkraft_core::theme_index_by_name("Dracula");
1141 assert_eq!(state.current_theme_name(), "Dracula");
1142 state.current_theme_index = 0;
1143 assert_eq!(state.current_theme_name(), "Default");
1144 }
1145
1146 #[test]
1147 fn repo_tab_new_empty() {
1148 let tab = RepoTab::new_empty();
1149 assert!(tab.repo_path.is_none());
1150 assert!(!tab.has_repo());
1151 assert_eq!(tab.display_name(), "New Tab");
1152 assert!(tab.commits.is_empty());
1153 assert!(tab.branches.is_empty());
1154 assert!(!tab.is_loading);
1155 }
1156
1157 #[test]
1158 fn repo_tab_display_name_with_path() {
1159 let mut tab = RepoTab::new_empty();
1160 tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
1161 assert!(tab.has_repo());
1162 assert_eq!(tab.display_name(), "cool-repo");
1163 }
1164
1165 #[test]
1166 fn search_defaults() {
1167 let state = GitKraft::new();
1168 assert!(!state.search_visible);
1169 assert!(state.search_query.is_empty());
1170 assert!(state.search_results.is_empty());
1171 assert!(state.search_selected.is_none());
1172 }
1173
1174 #[test]
1175 fn context_menu_variants_exist() {
1176 use crate::state::ContextMenu;
1178
1179 let _branch = ContextMenu::Branch {
1180 name: "main".to_string(),
1181 is_current: true,
1182 local_index: 0,
1183 };
1184 let _remote = ContextMenu::RemoteBranch {
1185 name: "origin/main".to_string(),
1186 };
1187 let _commit = ContextMenu::Commit {
1188 index: 0,
1189 oid: "abc1234".to_string(),
1190 };
1191 let _stash = ContextMenu::Stash { index: 0 };
1192 let _unstaged = ContextMenu::UnstagedFile {
1193 path: "src/main.rs".to_string(),
1194 };
1195 let _staged = ContextMenu::StagedFile {
1196 path: "src/lib.rs".to_string(),
1197 };
1198 }
1199
1200 #[test]
1201 fn repo_tab_context_menu_defaults_to_none() {
1202 let tab = crate::state::RepoTab::new_empty();
1203 assert!(tab.context_menu.is_none());
1204 }
1205
1206 #[test]
1207 fn context_menu_variants_constructable() {
1208 use crate::state::ContextMenu;
1209 let _ = ContextMenu::Stash { index: 0 };
1210 let _ = ContextMenu::UnstagedFile {
1211 path: "a.rs".into(),
1212 };
1213 let _ = ContextMenu::StagedFile {
1214 path: "b.rs".into(),
1215 };
1216 }
1217
1218 #[test]
1219 fn selected_unstaged_defaults_empty() {
1220 let tab = crate::state::RepoTab::new_empty();
1221 assert!(tab.selected_unstaged.is_empty());
1222 assert!(tab.selected_staged.is_empty());
1223 }
1224
1225 #[test]
1226 fn selected_unstaged_toggle() {
1227 let mut tab = crate::state::RepoTab::new_empty();
1228 tab.selected_unstaged.insert("a.rs".to_string());
1229 tab.selected_unstaged.insert("b.rs".to_string());
1230 assert_eq!(tab.selected_unstaged.len(), 2);
1231 assert!(tab.selected_unstaged.contains("a.rs"));
1232 tab.selected_unstaged.remove("a.rs");
1233 assert_eq!(tab.selected_unstaged.len(), 1);
1234 assert!(!tab.selected_unstaged.contains("a.rs"));
1235 }
1236
1237 #[test]
1238 fn detect_system_editor_returns_valid() {
1239 let editor = super::detect_system_editor();
1241 let _ = editor.display_name();
1242 }
1243
1244 #[test]
1247 fn selected_commit_file_indices_defaults_to_empty_vec() {
1248 let tab = RepoTab::new_empty();
1249 assert!(tab.selected_commit_file_indices.is_empty());
1250 let v: &Vec<usize> = &tab.selected_commit_file_indices;
1252 assert_eq!(v.len(), 0);
1253 }
1254
1255 #[test]
1256 fn multi_file_diffs_defaults_empty() {
1257 let tab = RepoTab::new_empty();
1258 assert!(tab.multi_file_diffs.is_empty());
1259 }
1260
1261 #[test]
1262 fn keyboard_modifiers_default_has_no_shift() {
1263 let state = GitKraft::new();
1264 assert!(!state.keyboard_modifiers.shift());
1265 }
1266
1267 #[test]
1268 fn selected_commit_file_indices_preserves_insertion_order() {
1269 let mut tab = RepoTab::new_empty();
1270 tab.selected_commit_file_indices.push(5);
1271 tab.selected_commit_file_indices.push(2);
1272 tab.selected_commit_file_indices.push(8);
1273 assert_eq!(tab.selected_commit_file_indices, vec![5, 2, 8]);
1274 }
1275
1276 #[test]
1277 fn selected_commit_file_indices_cleared_on_reset() {
1278 let mut tab = RepoTab::new_empty();
1279 tab.selected_commit_file_indices.push(0);
1280 tab.selected_commit_file_indices.push(1);
1281 tab.selected_commit_file_indices.clear();
1282 assert!(tab.selected_commit_file_indices.is_empty());
1283 }
1284
1285 #[test]
1286 fn multi_file_diffs_cleared_on_reset() {
1287 let mut tab = RepoTab::new_empty();
1288 tab.multi_file_diffs.push(gitkraft_core::DiffInfo {
1289 old_file: String::new(),
1290 new_file: "a.rs".to_string(),
1291 status: gitkraft_core::FileStatus::Modified,
1292 hunks: vec![],
1293 });
1294 tab.multi_file_diffs.clear();
1295 assert!(tab.multi_file_diffs.is_empty());
1296 }
1297
1298 #[test]
1299 fn commit_range_diffs_defaults_empty() {
1300 let tab = RepoTab::new_empty();
1301 assert!(tab.commit_range_diffs.is_empty());
1302 }
1303
1304 #[test]
1305 fn commit_range_diffs_cleared_on_apply_payload() {
1306 let mut tab = RepoTab::new_empty();
1308 tab.commit_range_diffs.push(gitkraft_core::DiffInfo {
1309 old_file: String::new(),
1310 new_file: "x.rs".to_string(),
1311 status: gitkraft_core::FileStatus::Modified,
1312 hunks: vec![],
1313 });
1314 tab.commit_range_diffs.clear();
1315 assert!(tab.commit_range_diffs.is_empty());
1316 }
1317
1318 #[test]
1321 fn modifiers_changed_sets_shift_state() {
1322 use crate::message::Message;
1323 let mut state = GitKraft::new();
1324 assert!(!state.keyboard_modifiers.shift());
1325
1326 let _ = state.update(Message::ModifiersChanged(iced::keyboard::Modifiers::SHIFT));
1327 assert!(state.keyboard_modifiers.shift());
1328
1329 let _ = state.update(Message::ModifiersChanged(
1330 iced::keyboard::Modifiers::default(),
1331 ));
1332 assert!(!state.keyboard_modifiers.shift());
1333 }
1334
1335 fn make_commit_files(names: &[&str]) -> Vec<gitkraft_core::DiffFileEntry> {
1338 names
1339 .iter()
1340 .map(|name| gitkraft_core::DiffFileEntry {
1341 old_file: String::new(),
1342 new_file: name.to_string(),
1343 status: gitkraft_core::FileStatus::Modified,
1344 })
1345 .collect()
1346 }
1347
1348 #[test]
1349 fn select_diff_by_index_regular_click_clears_multi_selection() {
1350 use crate::message::Message;
1351 let mut state = GitKraft::new();
1352 state.active_tab_mut().repo_path =
1356 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1357 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1358 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1359 state.active_tab_mut().selected_commit_file_indices = vec![0, 1];
1361
1362 let _ = state.update(Message::SelectDiffByIndex(0));
1364
1365 assert!(state.active_tab().selected_commit_file_indices.is_empty());
1366 assert_eq!(state.active_tab().selected_file_index, Some(0));
1367 }
1368
1369 #[test]
1370 fn regular_click_preserves_multi_file_diffs_until_load_completes() {
1371 use crate::message::Message;
1372 let mut state = GitKraft::new();
1373 state.active_tab_mut().repo_path =
1374 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1375 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1376 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1377
1378 state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1380 old_file: String::new(),
1381 new_file: "a.rs".to_string(),
1382 status: gitkraft_core::FileStatus::Modified,
1383 hunks: vec![],
1384 }];
1385
1386 let _ = state.update(Message::SelectDiffByIndex(1));
1389
1390 assert!(
1391 !state.active_tab().multi_file_diffs.is_empty(),
1392 "multi_file_diffs must NOT be cleared on click (deferred to diff load)"
1393 );
1394 assert_eq!(state.active_tab().selected_file_index, Some(1));
1395 assert!(state.active_tab().is_loading_file_diff);
1396 }
1397
1398 #[test]
1399 fn regular_click_preserves_diff_scroll_until_load_completes() {
1400 use crate::message::Message;
1401 let mut state = GitKraft::new();
1402 state.active_tab_mut().repo_path =
1403 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1404 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1405 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1406 state.active_tab_mut().diff_scroll_offset = 150.0;
1407
1408 let _ = state.update(Message::SelectDiffByIndex(1));
1410
1411 assert_eq!(
1412 state.active_tab().diff_scroll_offset,
1413 150.0,
1414 "diff_scroll_offset must NOT be reset on click (deferred to diff load)"
1415 );
1416 }
1417
1418 #[test]
1419 fn single_file_diff_loaded_clears_deferred_state() {
1420 use crate::message::Message;
1421 let mut state = GitKraft::new();
1422 state.active_tab_mut().repo_path =
1423 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1424 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1425 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1426
1427 state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1429 old_file: String::new(),
1430 new_file: "a.rs".to_string(),
1431 status: gitkraft_core::FileStatus::Modified,
1432 hunks: vec![],
1433 }];
1434 state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1435 old_file: String::new(),
1436 new_file: "b.rs".to_string(),
1437 status: gitkraft_core::FileStatus::Modified,
1438 hunks: vec![],
1439 }];
1440 state.active_tab_mut().is_loading_file_diff = true;
1441 state.active_tab_mut().diff_scroll_offset = 200.0;
1442
1443 let new_diff = gitkraft_core::DiffInfo {
1445 old_file: String::new(),
1446 new_file: "b.rs".to_string(),
1447 status: gitkraft_core::FileStatus::Modified,
1448 hunks: vec![],
1449 };
1450 let _ = state.update(Message::SingleFileDiffLoaded(Ok(new_diff)));
1451
1452 assert!(
1454 state.active_tab().multi_file_diffs.is_empty(),
1455 "multi_file_diffs must be cleared when new diff arrives"
1456 );
1457 assert!(
1458 state.active_tab().commit_range_diffs.is_empty(),
1459 "commit_range_diffs must be cleared when new diff arrives"
1460 );
1461 assert!(
1462 state.active_tab().selected_diff.is_some(),
1463 "selected_diff must be set to the loaded diff"
1464 );
1465 assert!(
1466 !state.active_tab().is_loading_file_diff,
1467 "is_loading_file_diff must be cleared after load"
1468 );
1469 assert_eq!(
1470 state.active_tab().diff_scroll_offset,
1471 0.0,
1472 "diff_scroll_offset must be reset when new diff arrives"
1473 );
1474 }
1475
1476 #[test]
1477 fn regular_click_preserves_commit_range_diffs_until_load_completes() {
1478 use crate::message::Message;
1479 let mut state = GitKraft::new();
1480 state.active_tab_mut().repo_path =
1481 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1482 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1483 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1484
1485 state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1487 old_file: String::new(),
1488 new_file: "x.rs".to_string(),
1489 status: gitkraft_core::FileStatus::Modified,
1490 hunks: vec![],
1491 }];
1492
1493 let _ = state.update(Message::SelectDiffByIndex(0));
1494
1495 assert!(
1496 !state.active_tab().commit_range_diffs.is_empty(),
1497 "commit_range_diffs must NOT be cleared on click (deferred to diff load)"
1498 );
1499 }
1500
1501 #[test]
1502 fn select_diff_by_index_shift_click_adds_both_files_to_selection() {
1503 use crate::message::Message;
1504 let mut state = GitKraft::new();
1505 state.active_tab_mut().repo_path =
1506 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1507 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1508 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1509 state.active_tab_mut().selected_file_index = Some(0);
1510
1511 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1513 let _ = state.update(Message::SelectDiffByIndex(1));
1514
1515 let sel = &state.active_tab().selected_commit_file_indices;
1516 assert!(sel.contains(&0), "anchor file 0 should be selected");
1517 assert!(sel.contains(&1), "newly clicked file 1 should be selected");
1518 assert_eq!(sel.len(), 2);
1519 }
1520
1521 #[test]
1522 fn anchor_file_index_defaults_to_none() {
1523 let tab = RepoTab::new_empty();
1524 assert!(tab.anchor_file_index.is_none());
1525 }
1526
1527 #[test]
1528 fn regular_click_sets_anchor() {
1529 use crate::message::Message;
1530 let mut state = GitKraft::new();
1531 state.active_tab_mut().repo_path =
1532 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1533 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1534 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1535
1536 let _ = state.update(Message::SelectDiffByIndex(2));
1537
1538 assert_eq!(
1539 state.active_tab().anchor_file_index,
1540 Some(2),
1541 "regular click must set anchor to the clicked index"
1542 );
1543 }
1544
1545 #[test]
1546 fn shift_click_selects_range_downward_from_anchor() {
1547 use crate::message::Message;
1548 let mut state = GitKraft::new();
1549 state.active_tab_mut().repo_path =
1550 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1551 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1552 state.active_tab_mut().commit_files =
1553 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1554 state.active_tab_mut().anchor_file_index = Some(1);
1556 state.active_tab_mut().selected_file_index = Some(1);
1557
1558 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1560 let _ = state.update(Message::SelectDiffByIndex(4));
1561
1562 let sel = &state.active_tab().selected_commit_file_indices;
1563 assert_eq!(
1564 sel,
1565 &vec![1, 2, 3, 4],
1566 "range must be contiguous from anchor to click"
1567 );
1568 }
1569
1570 #[test]
1571 fn shift_click_selects_range_upward_from_anchor() {
1572 use crate::message::Message;
1573 let mut state = GitKraft::new();
1574 state.active_tab_mut().repo_path =
1575 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1576 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1577 state.active_tab_mut().commit_files =
1578 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1579 state.active_tab_mut().anchor_file_index = Some(4);
1581 state.active_tab_mut().selected_file_index = Some(4);
1582
1583 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1585 let _ = state.update(Message::SelectDiffByIndex(1));
1586
1587 let sel = &state.active_tab().selected_commit_file_indices;
1588 assert_eq!(
1589 sel,
1590 &vec![1, 2, 3, 4],
1591 "range must be stored ascending regardless of click direction"
1592 );
1593 }
1594
1595 #[test]
1596 fn shift_click_anchor_fixed_on_subsequent_clicks() {
1597 use crate::message::Message;
1598 let mut state = GitKraft::new();
1599 state.active_tab_mut().repo_path =
1600 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1601 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1602 state.active_tab_mut().commit_files =
1603 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1604 state.active_tab_mut().anchor_file_index = Some(2);
1606 state.active_tab_mut().selected_file_index = Some(2);
1607 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1608
1609 let _ = state.update(Message::SelectDiffByIndex(4));
1611 assert_eq!(
1612 state.active_tab().selected_commit_file_indices,
1613 vec![2, 3, 4]
1614 );
1615
1616 let _ = state.update(Message::SelectDiffByIndex(3));
1618 assert_eq!(
1619 state.active_tab().selected_commit_file_indices,
1620 vec![2, 3],
1621 "anchor must stay fixed; second Shift+Click shrinks the range"
1622 );
1623
1624 let _ = state.update(Message::SelectDiffByIndex(0));
1626 assert_eq!(
1627 state.active_tab().selected_commit_file_indices,
1628 vec![0, 1, 2],
1629 "anchor must stay fixed; can extend range in either direction"
1630 );
1631 }
1632
1633 #[test]
1634 fn shift_click_on_anchor_itself_gives_single_item_range() {
1635 use crate::message::Message;
1636 let mut state = GitKraft::new();
1637 state.active_tab_mut().repo_path =
1638 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1639 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1640 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1641 state.active_tab_mut().anchor_file_index = Some(1);
1642 state.active_tab_mut().selected_file_index = Some(1);
1643
1644 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1646 let _ = state.update(Message::SelectDiffByIndex(1));
1647
1648 assert_eq!(state.active_tab().selected_commit_file_indices, vec![1]);
1649 assert!(
1650 state.active_tab().multi_file_diffs.is_empty(),
1651 "single-item range must not populate multi_file_diffs"
1652 );
1653 }
1654
1655 #[test]
1656 fn shift_click_range_is_always_ascending() {
1657 use crate::message::Message;
1658 let mut state = GitKraft::new();
1659 state.active_tab_mut().repo_path =
1660 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1661 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1662 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs"]);
1663 state.active_tab_mut().anchor_file_index = Some(3);
1664 state.active_tab_mut().selected_file_index = Some(3);
1665
1666 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1667 let _ = state.update(Message::SelectDiffByIndex(0));
1668
1669 let sel = &state.active_tab().selected_commit_file_indices;
1670 let is_sorted = sel.windows(2).all(|w| w[0] < w[1]);
1671 assert!(
1672 is_sorted,
1673 "selection must always be stored in ascending order"
1674 );
1675 assert_eq!(sel, &vec![0, 1, 2, 3]);
1676 }
1677
1678 #[test]
1679 fn checkout_file_at_commit_message_variants_exist() {
1680 use crate::message::Message;
1681 let _single =
1683 Message::CheckoutFileAtCommit("abc123".to_string(), "src/main.rs".to_string());
1684 let _multi = Message::CheckoutMultiFilesAtCommit(
1685 "abc123".to_string(),
1686 vec!["a.rs".to_string(), "b.rs".to_string()],
1687 );
1688 }
1689
1690 #[test]
1691 fn checkout_file_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::CheckoutFileAtCommit(
1701 "abc123".to_string(),
1702 "src/main.rs".to_string(),
1703 ));
1704 assert!(state.active_tab().context_menu.is_none());
1705 }
1706
1707 #[test]
1708 fn checkout_multi_files_at_commit_closes_context_menu() {
1709 use crate::message::Message;
1710 let mut state = GitKraft::new();
1711 state.active_tab_mut().repo_path =
1712 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1713 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1714 oid: "abc123".to_string(),
1715 file_path: "src/main.rs".to_string(),
1716 });
1717 let _ = state.update(Message::CheckoutMultiFilesAtCommit(
1718 "abc123".to_string(),
1719 vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
1720 ));
1721 assert!(state.active_tab().context_menu.is_none());
1722 }
1723
1724 fn make_test_commits(count: usize) -> Vec<gitkraft_core::CommitInfo> {
1727 (0..count)
1728 .map(|i| gitkraft_core::CommitInfo {
1729 oid: i.to_string(),
1730 summary: String::new(),
1731 message: String::new(),
1732 author_name: String::new(),
1733 author_email: String::new(),
1734 time: Default::default(),
1735 parent_ids: Vec::new(),
1736 refs: Vec::new(),
1737 })
1738 .collect()
1739 }
1740
1741 #[test]
1742 fn selected_commits_defaults_empty() {
1743 let tab = RepoTab::new_empty();
1744 assert!(tab.selected_commits.is_empty());
1745 assert!(tab.anchor_commit_index.is_none());
1746 }
1747
1748 #[test]
1749 fn regular_click_commit_sets_anchor_and_clears_range() {
1750 use crate::message::Message;
1751 let mut state = GitKraft::new();
1752 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
1753 state.active_tab_mut().commits = make_test_commits(3);
1754 state.active_tab_mut().selected_commits = vec![0, 1, 2];
1755
1756 let _ = state.update(Message::SelectCommit(1));
1757
1758 assert_eq!(state.active_tab().anchor_commit_index, Some(1));
1759 assert!(state.active_tab().selected_commits.is_empty());
1760 assert_eq!(state.active_tab().selected_commit, Some(1));
1761 }
1762
1763 #[test]
1764 fn shift_click_commit_selects_range_from_anchor() {
1765 use crate::message::Message;
1766 let mut state = GitKraft::new();
1767 state.active_tab_mut().commits = make_test_commits(5);
1768 state.active_tab_mut().anchor_commit_index = Some(1);
1769 state.active_tab_mut().selected_commit = Some(1);
1770
1771 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1772 let _ = state.update(Message::SelectCommit(4));
1773
1774 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3, 4]);
1775 }
1776
1777 #[test]
1778 fn shift_click_commit_range_is_ascending_when_clicking_above_anchor() {
1779 use crate::message::Message;
1780 let mut state = GitKraft::new();
1781 state.active_tab_mut().commits = make_test_commits(5);
1782 state.active_tab_mut().anchor_commit_index = Some(3);
1783 state.active_tab_mut().selected_commit = Some(3);
1784
1785 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1786 let _ = state.update(Message::SelectCommit(1));
1787
1788 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3]);
1789 }
1790
1791 #[test]
1794 fn execute_commit_action_closes_context_menu() {
1795 use crate::message::Message;
1796 let mut state = GitKraft::new();
1797 state.active_tab_mut().repo_path =
1798 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1799 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::Commit {
1800 index: 0,
1801 oid: "abc123".to_string(),
1802 });
1803
1804 let _ = state.update(Message::ExecuteCommitAction(
1805 "abc123".to_string(),
1806 gitkraft_core::CommitAction::CherryPick,
1807 ));
1808
1809 assert!(state.active_tab().context_menu.is_none());
1810 }
1811
1812 #[test]
1813 fn execute_commit_action_sets_loading_when_repo_open() {
1814 use crate::message::Message;
1815 let mut state = GitKraft::new();
1816 state.active_tab_mut().repo_path =
1817 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1818
1819 let _ = state.update(Message::ExecuteCommitAction(
1820 "abc123".to_string(),
1821 gitkraft_core::CommitAction::ResetHard,
1822 ));
1823
1824 assert!(state.active_tab().is_loading);
1825 }
1826
1827 #[test]
1828 fn execute_commit_action_no_repo_does_not_set_loading() {
1829 use crate::message::Message;
1830 let mut state = GitKraft::new();
1831 let _ = state.update(Message::ExecuteCommitAction(
1834 "abc123".to_string(),
1835 gitkraft_core::CommitAction::CherryPick,
1836 ));
1837
1838 assert!(!state.active_tab().is_loading);
1839 }
1840
1841 #[test]
1842 fn execute_commit_action_sets_status_message_from_action_label() {
1843 use crate::message::Message;
1844 let mut state = GitKraft::new();
1845 state.active_tab_mut().repo_path =
1846 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1847
1848 let _ = state.update(Message::ExecuteCommitAction(
1849 "abc123".to_string(),
1850 gitkraft_core::CommitAction::Revert,
1851 ));
1852
1853 let status = state.active_tab().status_message.as_deref().unwrap_or("");
1854 assert!(
1856 status.contains("Revert commit"),
1857 "expected status to contain 'Revert commit', got: {status:?}"
1858 );
1859 }
1860
1861 #[test]
1864 fn file_history_defaults_empty() {
1865 let tab = RepoTab::new_empty();
1866 assert!(tab.file_history_path.is_none());
1867 assert!(tab.file_history_commits.is_empty());
1868 assert_eq!(tab.file_history_scroll, 0.0);
1869 }
1870
1871 #[test]
1872 fn blame_defaults_empty() {
1873 let tab = RepoTab::new_empty();
1874 assert!(tab.blame_path.is_none());
1875 assert!(tab.blame_lines.is_empty());
1876 assert_eq!(tab.blame_scroll, 0.0);
1877 }
1878
1879 #[test]
1880 fn pending_delete_file_defaults_none() {
1881 let tab = RepoTab::new_empty();
1882 assert!(tab.pending_delete_file.is_none());
1883 }
1884
1885 #[test]
1886 fn view_file_history_sets_path_and_clears_blame() {
1887 use crate::message::Message;
1888 let mut state = GitKraft::new();
1889 state.active_tab_mut().repo_path =
1890 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1891 state.active_tab_mut().blame_path = Some("some/file.rs".to_string());
1892
1893 let _ = state.update(Message::ViewFileHistory("src/main.rs".to_string()));
1894
1895 assert_eq!(
1896 state.active_tab().file_history_path.as_deref(),
1897 Some("src/main.rs")
1898 );
1899 assert!(state.active_tab().blame_path.is_none());
1901 }
1902
1903 #[test]
1904 fn close_file_history_clears_state() {
1905 use crate::message::Message;
1906 let mut state = GitKraft::new();
1907 state.active_tab_mut().file_history_path = Some("src/lib.rs".to_string());
1908 state.active_tab_mut().file_history_commits = vec![gitkraft_core::CommitInfo {
1909 oid: "abc".to_string(),
1910 summary: "s".to_string(),
1911 message: "s".to_string(),
1912 author_name: "a".to_string(),
1913 author_email: "a@b.com".to_string(),
1914 time: Default::default(),
1915 parent_ids: vec![],
1916 refs: Vec::new(),
1917 }];
1918
1919 let _ = state.update(Message::CloseFileHistory);
1920
1921 assert!(state.active_tab().file_history_path.is_none());
1922 assert!(state.active_tab().file_history_commits.is_empty());
1923 }
1924
1925 #[test]
1926 fn view_file_blame_sets_path_and_clears_history() {
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().file_history_path = Some("some/file.rs".to_string());
1932
1933 let _ = state.update(Message::ViewFileBlame("src/lib.rs".to_string()));
1934
1935 assert_eq!(state.active_tab().blame_path.as_deref(), Some("src/lib.rs"));
1936 assert!(state.active_tab().file_history_path.is_none());
1938 }
1939
1940 #[test]
1941 fn selecting_new_commit_closes_blame_overlay() {
1942 use crate::message::Message;
1943 let mut state = GitKraft::new();
1944 state.active_tab_mut().repo_path =
1945 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1946 state.active_tab_mut().commits = vec![
1948 gitkraft_core::CommitInfo {
1949 oid: "abc1".into(),
1950 summary: "first".into(),
1951 message: "first".into(),
1952 author_name: "A".into(),
1953 author_email: "a@a.com".into(),
1954 time: Default::default(),
1955 parent_ids: Vec::new(),
1956 refs: Vec::new(),
1957 },
1958 gitkraft_core::CommitInfo {
1959 oid: "abc2".into(),
1960 summary: "second".into(),
1961 message: "second".into(),
1962 author_name: "A".into(),
1963 author_email: "a@a.com".into(),
1964 time: Default::default(),
1965 parent_ids: Vec::new(),
1966 refs: Vec::new(),
1967 },
1968 ];
1969 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1971 state.active_tab_mut().blame_lines = vec![gitkraft_core::BlameLine {
1972 line_number: 1,
1973 content: "fn main() {}".into(),
1974 oid: "abc1".into(),
1975 author_name: "A".into(),
1976 time: Default::default(),
1977 }];
1978
1979 let _ = state.update(Message::SelectCommit(1));
1981
1982 assert!(
1983 state.active_tab().blame_path.is_none(),
1984 "blame_path must be cleared when a new commit is selected"
1985 );
1986 assert!(
1987 state.active_tab().blame_lines.is_empty(),
1988 "blame_lines must be cleared when a new commit is selected"
1989 );
1990 }
1991
1992 #[test]
1993 fn close_file_blame_clears_state() {
1994 use crate::message::Message;
1995 let mut state = GitKraft::new();
1996 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1997
1998 let _ = state.update(Message::CloseFileBlame);
1999
2000 assert!(state.active_tab().blame_path.is_none());
2001 assert!(state.active_tab().blame_lines.is_empty());
2002 }
2003
2004 #[test]
2005 fn delete_file_sets_pending() {
2006 use crate::message::Message;
2007 let mut state = GitKraft::new();
2008
2009 let _ = state.update(Message::DeleteFile("src/old.rs".to_string()));
2010
2011 assert_eq!(
2012 state.active_tab().pending_delete_file.as_deref(),
2013 Some("src/old.rs")
2014 );
2015 assert!(state.active_tab().context_menu.is_none());
2016 }
2017
2018 #[test]
2019 fn cancel_delete_file_clears_pending() {
2020 use crate::message::Message;
2021 let mut state = GitKraft::new();
2022 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
2023
2024 let _ = state.update(Message::CancelDeleteFile);
2025
2026 assert!(state.active_tab().pending_delete_file.is_none());
2027 }
2028
2029 #[test]
2030 fn confirm_delete_file_no_repo_is_noop() {
2031 use crate::message::Message;
2032 let mut state = GitKraft::new();
2033 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
2034 let _ = state.update(Message::ConfirmDeleteFile);
2037
2038 assert!(!state.active_tab().is_loading);
2039 }
2040
2041 #[test]
2042 fn shift_arrow_down_extends_file_list_selection_when_files_loaded() {
2043 use crate::message::Message;
2044 let mut state = GitKraft::new();
2045 state.active_tab_mut().repo_path =
2046 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
2047 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
2048 state.active_tab_mut().selected_file_index = Some(0);
2049 state.active_tab_mut().anchor_file_index = Some(0);
2050 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
2052
2053 let _ = state.update(Message::ShiftArrowDown);
2054
2055 assert_eq!(state.active_tab().selected_file_index, Some(1));
2056 assert!(state.active_tab().selected_commit_file_indices.contains(&0));
2058 assert!(state.active_tab().selected_commit_file_indices.contains(&1));
2059 }
2060
2061 #[test]
2062 fn shift_arrow_down_falls_through_to_commit_log_when_no_files() {
2063 use crate::message::Message;
2064 let mut state = GitKraft::new();
2065 state.active_tab_mut().commits = make_test_commits(5);
2066 state.active_tab_mut().selected_commit = Some(1);
2067 state.active_tab_mut().anchor_commit_index = Some(1);
2068 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
2069 let _ = state.update(Message::ShiftArrowDown);
2072
2073 assert_eq!(state.active_tab().selected_commit, Some(2));
2074 assert!(state.active_tab().selected_commits.contains(&1));
2075 assert!(state.active_tab().selected_commits.contains(&2));
2076 }
2077
2078 #[test]
2079 fn file_system_changed_triggers_full_refresh() {
2080 use crate::message::Message;
2081 let mut state = GitKraft::new();
2082 state.active_tab_mut().repo_path =
2083 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
2084
2085 let _task = state.update(Message::FileSystemChanged);
2088
2089 assert!(
2091 state.active_tab().is_loading,
2092 "FileSystemChanged must set is_loading to guard against concurrent refreshes"
2093 );
2094 assert!(
2095 state.active_tab().error_message.is_none(),
2096 "FileSystemChanged must not set an error message"
2097 );
2098 }
2099
2100 #[test]
2101 fn refresh_active_tab_skips_when_already_loading() {
2102 let mut state = GitKraft::new();
2106 state.active_tab_mut().repo_path =
2107 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
2108 state.active_tab_mut().is_loading = true;
2109
2110 let _task = state.refresh_active_tab();
2112 assert!(state.active_tab().is_loading);
2114 }
2115
2116 fn fake_payload(workdir: &str) -> crate::message::RepoPayload {
2120 gitkraft_core::RepoSnapshot {
2121 info: gitkraft_core::RepoInfo {
2122 path: std::path::PathBuf::from(format!("{workdir}/.git")),
2123 workdir: Some(std::path::PathBuf::from(workdir)),
2124 head_branch: Some("main".into()),
2125 is_bare: false,
2126 state: gitkraft_core::RepoState::Clean,
2127 },
2128 branches: Vec::new(),
2129 commits: Vec::new(),
2130 graph_rows: Vec::new(),
2131 unstaged: Vec::new(),
2132 staged: Vec::new(),
2133 stashes: Vec::new(),
2134 remotes: Vec::new(),
2135 }
2136 }
2137
2138 fn setup_loaded_tab(tab: &mut RepoTab, path: &str) {
2140 tab.repo_path = Some(std::path::PathBuf::from(path));
2141 tab.repo_info = Some(gitkraft_core::RepoInfo {
2142 path: std::path::PathBuf::from(format!("{path}/.git")),
2143 workdir: Some(std::path::PathBuf::from(path)),
2144 head_branch: Some("main".into()),
2145 is_bare: false,
2146 state: gitkraft_core::RepoState::Clean,
2147 });
2148 }
2149
2150 #[test]
2151 fn open_repo_creates_new_tab_when_repo_already_open() {
2152 use crate::message::Message;
2153 let mut state = GitKraft::new();
2154 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2155
2156 assert_eq!(state.tabs.len(), 1);
2157 assert_eq!(state.active_tab, 0);
2158
2159 let _task = state.update(Message::OpenRepo);
2161
2162 assert_eq!(state.tabs.len(), 2);
2163 assert_eq!(state.active_tab, 1);
2164 assert!(state.tabs[1].is_loading);
2166 assert_eq!(
2168 state.tabs[0].repo_path.as_deref(),
2169 Some(std::path::Path::new("/home/user/repo-a"))
2170 );
2171 }
2172
2173 #[test]
2174 fn open_repo_reuses_empty_tab() {
2175 use crate::message::Message;
2176 let mut state = GitKraft::new();
2177 assert!(!state.active_tab().has_repo());
2179
2180 let _task = state.update(Message::OpenRepo);
2181
2182 assert_eq!(state.tabs.len(), 1);
2184 assert_eq!(state.active_tab, 0);
2185 assert!(state.tabs[0].is_loading);
2186 }
2187
2188 #[test]
2189 fn repo_selected_deduplicates_already_open_repo() {
2190 use crate::message::Message;
2191 let mut state = GitKraft::new();
2192 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2194 state.tabs.push(RepoTab::new_empty());
2196 state.active_tab = 1;
2197
2198 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2200 "/home/user/repo-a",
2201 ))));
2202
2203 assert_eq!(state.tabs.len(), 1);
2205 assert_eq!(state.active_tab, 0);
2206 assert_eq!(
2207 state.tabs[0].repo_path.as_deref(),
2208 Some(std::path::Path::new("/home/user/repo-a"))
2209 );
2210 }
2211
2212 #[test]
2213 fn repo_selected_opens_new_repo_in_empty_tab() {
2214 use crate::message::Message;
2215 let mut state = GitKraft::new();
2216 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2218 state.tabs.push(RepoTab::new_empty());
2220 state.active_tab = 1;
2221
2222 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2224 "/home/user/repo-b",
2225 ))));
2226
2227 assert_eq!(state.tabs.len(), 2);
2229 assert_eq!(state.active_tab, 1);
2230 assert!(state.tabs[1]
2231 .status_message
2232 .as_deref()
2233 .unwrap_or("")
2234 .contains("repo-b"));
2235 }
2236
2237 #[test]
2238 fn repo_selected_cancel_removes_empty_tab() {
2239 use crate::message::Message;
2240 let mut state = GitKraft::new();
2241 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2243 state.tabs.push(RepoTab::new_empty());
2245 state.active_tab = 1;
2246
2247 let _task = state.update(Message::RepoSelected(None));
2249
2250 assert_eq!(state.tabs.len(), 1);
2252 assert_eq!(state.active_tab, 0);
2253 assert_eq!(
2254 state.tabs[0].repo_path.as_deref(),
2255 Some(std::path::Path::new("/home/user/repo-a"))
2256 );
2257 }
2258
2259 #[test]
2260 fn repo_selected_cancel_keeps_tab_if_only_one() {
2261 use crate::message::Message;
2262 let mut state = GitKraft::new();
2263 assert_eq!(state.tabs.len(), 1);
2265 assert!(!state.active_tab().has_repo());
2266
2267 let _task = state.update(Message::RepoSelected(None));
2268
2269 assert_eq!(state.tabs.len(), 1);
2270 assert!(!state.active_tab().is_loading);
2271 }
2272
2273 #[test]
2274 fn open_recent_repo_deduplicates() {
2275 use crate::message::Message;
2276 let mut state = GitKraft::new();
2277 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2279 state.tabs.push(RepoTab::new_empty());
2281 state.active_tab = 1;
2282
2283 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2286 "/home/user/repo-a",
2287 )));
2288
2289 assert_eq!(state.tabs.len(), 1, "empty tab must be removed");
2290 assert_eq!(state.active_tab, 0);
2291 assert_eq!(
2292 state.active_tab().repo_path.as_deref(),
2293 Some(std::path::Path::new("/home/user/repo-a")),
2294 );
2295 }
2296
2297 #[test]
2298 fn open_recent_repo_creates_new_tab_when_current_has_repo() {
2299 use crate::message::Message;
2300 let mut state = GitKraft::new();
2301 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2303
2304 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2306 "/home/user/repo-b",
2307 )));
2308
2309 assert_eq!(state.tabs.len(), 2);
2310 assert_eq!(state.active_tab, 1);
2311 assert!(state.tabs[1].is_loading);
2312 }
2313
2314 #[test]
2315 fn open_recent_repo_uses_empty_tab() {
2316 use crate::message::Message;
2317 let mut state = GitKraft::new();
2318 assert!(!state.active_tab().has_repo());
2320
2321 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2322 "/home/user/repo-b",
2323 )));
2324
2325 assert_eq!(state.tabs.len(), 1);
2327 assert_eq!(state.active_tab, 0);
2328 assert!(state.tabs[0].is_loading);
2329 }
2330
2331 #[test]
2332 fn open_recent_repo_dedup_removes_empty_tab_with_multiple_loaded() {
2333 use crate::message::Message;
2336 let mut state = GitKraft::new();
2337 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2338 state.tabs.push(RepoTab::new_empty());
2339 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2340 state.tabs.push(RepoTab::new_empty());
2341 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2342 state.tabs.push(RepoTab::new_empty());
2344 state.active_tab = 3;
2345
2346 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2347 "/home/user/repo-b",
2348 )));
2349
2350 assert_eq!(state.tabs.len(), 3, "empty tab must be cleaned up");
2351 assert_eq!(state.active_tab, 1);
2352 assert_eq!(
2353 state.active_tab().repo_path.as_deref(),
2354 Some(std::path::Path::new("/home/user/repo-b")),
2355 );
2356 }
2357
2358 #[test]
2361 fn repo_refreshed_targets_correct_tab_after_tab_switch() {
2362 use crate::message::Message;
2363 let mut state = GitKraft::new();
2364 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2366 state.tabs.push(RepoTab::new_empty());
2368 state.active_tab = 1; let payload = fake_payload("/home/user/repo-a");
2372 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2373
2374 assert!(
2376 state.tabs[0].repo_info.is_some(),
2377 "tab 0 should still have repo info after refresh"
2378 );
2379 assert_eq!(
2380 state.tabs[0].current_branch.as_deref(),
2381 Some("main"),
2382 "tab 0 should have updated branch from payload"
2383 );
2384 assert!(
2386 state.tabs[1].repo_info.is_none(),
2387 "tab 1 (empty) must NOT receive the refresh payload"
2388 );
2389 assert!(
2390 state.tabs[1].repo_path.is_none(),
2391 "tab 1 should still have no repo path"
2392 );
2393 }
2394
2395 #[test]
2396 fn repo_refreshed_targets_active_tab_for_new_open() {
2397 use crate::message::Message;
2398 let mut state = GitKraft::new();
2399 assert_eq!(state.tabs.len(), 1);
2403 assert!(!state.active_tab().has_repo());
2404
2405 let payload = fake_payload("/home/user/new-repo");
2406 let _task = state.update(Message::RepoOpened(Ok(payload)));
2407
2408 assert_eq!(
2410 state.tabs[0].repo_path.as_deref(),
2411 Some(std::path::Path::new("/home/user/new-repo"))
2412 );
2413 assert!(state.tabs[0].repo_info.is_some());
2414 }
2415
2416 #[test]
2417 fn repo_refreshed_does_not_duplicate_into_new_tab() {
2418 use crate::message::Message;
2419 let mut state = GitKraft::new();
2420 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2422
2423 let _task = state.update(Message::NewTab);
2425 assert_eq!(state.tabs.len(), 2);
2426 assert_eq!(state.active_tab, 1);
2427
2428 let payload = fake_payload("/home/user/repo-a");
2430 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2431
2432 assert!(
2434 state.tabs[1].repo_path.is_none(),
2435 "new empty tab must not receive repo-a refresh"
2436 );
2437 assert!(
2438 state.tabs[1].repo_info.is_none(),
2439 "new empty tab must not have repo_info"
2440 );
2441 assert_eq!(
2443 state.tabs[0].repo_path.as_deref(),
2444 Some(std::path::Path::new("/home/user/repo-a"))
2445 );
2446 }
2447
2448 #[test]
2449 fn git_operation_result_targets_correct_tab() {
2450 use crate::message::Message;
2451 let mut state = GitKraft::new();
2452 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2454 state.tabs.push(RepoTab::new_empty());
2456 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2457 state.active_tab = 1;
2458
2459 let payload = fake_payload("/home/user/repo-a");
2462 let _task = state.update(Message::GitOperationResult(Ok(payload)));
2463
2464 assert_eq!(state.tabs[0].current_branch.as_deref(), Some("main"));
2466 assert_eq!(
2468 state.tabs[1].repo_path.as_deref(),
2469 Some(std::path::Path::new("/home/user/repo-b"))
2470 );
2471 }
2472
2473 #[test]
2474 fn multiple_new_tabs_dont_get_polluted_by_refresh() {
2475 use crate::message::Message;
2476 let mut state = GitKraft::new();
2477 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2479
2480 let _task = state.update(Message::NewTab);
2482 let _task = state.update(Message::NewTab);
2483 assert_eq!(state.tabs.len(), 3);
2484 assert_eq!(state.active_tab, 2);
2485
2486 let payload = fake_payload("/home/user/repo-a");
2488 let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2489
2490 assert!(state.tabs[0].repo_info.is_some());
2492 assert!(state.tabs[1].repo_info.is_none());
2493 assert!(state.tabs[2].repo_info.is_none());
2494 assert!(state.tabs[1].repo_path.is_none());
2495 assert!(state.tabs[2].repo_path.is_none());
2496 }
2497
2498 #[test]
2499 fn repo_selected_dedup_adjusts_index_when_existing_is_after_active() {
2500 use crate::message::Message;
2501 let mut state = GitKraft::new();
2502 state.tabs.push(RepoTab::new_empty());
2505 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
2506 state.active_tab = 0;
2507
2508 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2510 "/home/user/repo-a",
2511 ))));
2512
2513 assert_eq!(state.tabs.len(), 1);
2515 assert_eq!(state.active_tab, 0);
2516 assert_eq!(
2517 state.tabs[0].repo_path.as_deref(),
2518 Some(std::path::Path::new("/home/user/repo-a"))
2519 );
2520 }
2521
2522 #[test]
2525 fn apply_payload_preserves_multi_selection_by_oid() {
2526 let mut tab = RepoTab::new_empty();
2527 tab.commits = make_test_commits(5);
2528 tab.anchor_commit_index = Some(1);
2530 tab.selected_commits = vec![1, 2, 3];
2531 tab.selected_commit = Some(3);
2532 tab.selected_commit_oid = Some(tab.commits[3].oid.clone());
2533
2534 let mut payload = fake_payload("/tmp/repo");
2536 payload.commits = make_test_commits(5);
2537
2538 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2539
2540 assert_eq!(tab.anchor_commit_index, Some(1));
2542 assert_eq!(tab.selected_commits, vec![1, 2, 3]);
2543 assert_eq!(tab.selected_commit, Some(3));
2544 }
2545
2546 #[test]
2547 fn apply_payload_preserves_anchor_even_without_range() {
2548 let mut tab = RepoTab::new_empty();
2549 tab.commits = make_test_commits(5);
2550 tab.anchor_commit_index = Some(2);
2551 tab.selected_commit = Some(2);
2552 tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2553 let mut payload = fake_payload("/tmp/repo");
2556 payload.commits = make_test_commits(5);
2557
2558 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2559
2560 assert_eq!(tab.anchor_commit_index, Some(2));
2561 assert_eq!(tab.selected_commit, Some(2));
2562 }
2563
2564 #[test]
2565 fn apply_payload_clears_selection_when_commits_disappear() {
2566 let mut tab = RepoTab::new_empty();
2567 tab.commits = make_test_commits(5);
2568 tab.anchor_commit_index = Some(2);
2569 tab.selected_commits = vec![2, 3, 4];
2570 tab.selected_commit = Some(4);
2571 tab.selected_commit_oid = Some(tab.commits[4].oid.clone());
2572
2573 let mut payload = fake_payload("/tmp/repo");
2575 payload.commits = (0..3)
2576 .map(|i| gitkraft_core::CommitInfo {
2577 oid: format!("new_oid_{i}"),
2578 summary: format!("new commit {i}"),
2579 message: String::new(),
2580 author_name: "Author".into(),
2581 author_email: "a@b.c".into(),
2582 time: Default::default(),
2583 parent_ids: Vec::new(),
2584 refs: Vec::new(),
2585 })
2586 .collect();
2587
2588 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2589
2590 assert!(tab.selected_commits.is_empty());
2592 assert!(tab.anchor_commit_index.is_none());
2593 assert!(tab.selected_commit.is_none());
2594 }
2595
2596 #[test]
2599 fn apply_payload_preserves_file_selection_when_commit_survives() {
2600 let mut tab = RepoTab::new_empty();
2601 tab.commits = make_test_commits(3);
2602 tab.selected_commit = Some(1);
2603 tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2604 tab.commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
2605
2606 tab.anchor_file_index = Some(0);
2608 tab.selected_commit_file_indices = vec![0, 1, 2];
2609 tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2610 old_file: String::new(),
2611 new_file: "a.rs".into(),
2612 status: gitkraft_core::FileStatus::Modified,
2613 hunks: vec![],
2614 }];
2615
2616 let mut payload = fake_payload("/tmp/repo");
2618 payload.commits = make_test_commits(3);
2619
2620 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2621
2622 assert_eq!(tab.anchor_file_index, Some(0));
2624 assert_eq!(tab.selected_commit_file_indices, vec![0, 1, 2]);
2625 assert!(
2626 !tab.multi_file_diffs.is_empty(),
2627 "multi_file_diffs must survive when commit is preserved"
2628 );
2629 }
2630
2631 #[test]
2632 fn apply_payload_clears_file_selection_when_commit_disappears() {
2633 let mut tab = RepoTab::new_empty();
2634 tab.commits = make_test_commits(3);
2635 tab.selected_commit = Some(1);
2636 tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2637 tab.commit_files = make_commit_files(&["a.rs", "b.rs"]);
2638 tab.anchor_file_index = Some(0);
2639 tab.selected_commit_file_indices = vec![0, 1];
2640 tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2641 old_file: String::new(),
2642 new_file: "a.rs".into(),
2643 status: gitkraft_core::FileStatus::Modified,
2644 hunks: vec![],
2645 }];
2646
2647 let mut payload = fake_payload("/tmp/repo");
2649 payload.commits = (0..2)
2650 .map(|i| gitkraft_core::CommitInfo {
2651 oid: format!("new_{i}"),
2652 summary: String::new(),
2653 message: String::new(),
2654 author_name: String::new(),
2655 author_email: String::new(),
2656 time: Default::default(),
2657 parent_ids: Vec::new(),
2658 refs: Vec::new(),
2659 })
2660 .collect();
2661
2662 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2663
2664 assert!(tab.anchor_file_index.is_none());
2666 assert!(tab.selected_commit_file_indices.is_empty());
2667 assert!(tab.multi_file_diffs.is_empty());
2668 assert!(tab.commit_files.is_empty());
2669 }
2670
2671 #[test]
2672 fn apply_payload_preserves_commit_range_diffs_when_commit_survives() {
2673 let mut tab = RepoTab::new_empty();
2674 tab.commits = make_test_commits(5);
2675 tab.selected_commit = Some(2);
2676 tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2677 tab.commit_range_diffs = vec![gitkraft_core::DiffInfo {
2678 old_file: String::new(),
2679 new_file: "x.rs".into(),
2680 status: gitkraft_core::FileStatus::Modified,
2681 hunks: vec![],
2682 }];
2683
2684 let mut payload = fake_payload("/tmp/repo");
2685 payload.commits = make_test_commits(5);
2686
2687 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2688
2689 assert!(
2690 !tab.commit_range_diffs.is_empty(),
2691 "commit_range_diffs must survive when commit is preserved"
2692 );
2693 }
2694
2695 #[test]
2696 fn apply_payload_preserves_commit_message() {
2697 let mut tab = RepoTab::new_empty();
2700 tab.repo_path = Some(std::path::PathBuf::from("/tmp/repo"));
2701 tab.commit_message = "WIP: fixing the bug".to_string();
2702
2703 let payload = fake_payload("/tmp/repo");
2704 tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2705
2706 assert_eq!(
2707 tab.commit_message, "WIP: fixing the bug",
2708 "commit_message must survive background repo refreshes"
2709 );
2710 }
2711
2712 #[test]
2715 fn preview_file_loaded_sets_preview_editor() {
2716 use crate::message::Message;
2717 let mut state = GitKraft::new();
2718 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2719
2720 let content = "line 1\nline 2\nline 3".to_string();
2721 let _ = state.update(Message::FilePreviewLoaded(Ok((
2722 "test.rs".to_string(),
2723 content,
2724 ))));
2725
2726 assert!(state.preview_editor.is_some());
2727 assert_eq!(state.preview_path.as_deref(), Some("test.rs"));
2728 assert!(state.active_tab().selected_diff.is_none());
2730 }
2731
2732 #[test]
2733 fn preview_file_loaded_error_sets_error_message() {
2734 use crate::message::Message;
2735 let mut state = GitKraft::new();
2736 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2737
2738 let _ = state.update(Message::FilePreviewLoaded(Err("No such file".to_string())));
2739
2740 assert!(state.active_tab().error_message.is_some());
2741 assert!(state
2742 .active_tab()
2743 .error_message
2744 .as_ref()
2745 .unwrap()
2746 .contains("No such file"));
2747 }
2748
2749 #[test]
2750 fn close_preview_clears_editor_state() {
2751 use crate::message::Message;
2752 let mut state = GitKraft::new();
2753 state.preview_editor = Some(iced::widget::text_editor::Content::with_text("hello"));
2754 state.preview_path = Some("/test/file.txt".to_string());
2755
2756 let _ = state.update(Message::ClosePreview);
2757
2758 assert!(state.preview_editor.is_none());
2759 assert!(state.preview_path.is_none());
2760 }
2761
2762 #[test]
2765 fn close_repo_single_tab_shows_welcome_screen() {
2766 use crate::message::Message;
2770 let mut state = GitKraft::new();
2771 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2772 assert_eq!(state.tabs.len(), 1);
2773
2774 let _ = state.update(Message::CloseRepo);
2775
2776 assert_eq!(state.tabs.len(), 1, "last tab must not be removed");
2777 assert_eq!(state.active_tab, 0);
2778 assert!(!state.active_tab().has_repo(), "tab should be empty");
2779 }
2780
2781 #[test]
2782 fn close_repo_with_multiple_tabs_removes_current_tab() {
2783 use crate::message::Message;
2794 let mut state = GitKraft::new();
2795 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2797 state.tabs.push(RepoTab::new_empty());
2799 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2800 state.active_tab = 0;
2802
2803 let _ = state.update(Message::CloseRepo);
2804
2805 assert_eq!(state.tabs.len(), 1, "current tab must be removed");
2808 assert_eq!(state.active_tab, 0);
2809 assert_eq!(
2810 state.active_tab().repo_path.as_deref(),
2811 Some(std::path::Path::new("/home/user/repo-b")),
2812 "repo-b should be the surviving tab"
2813 );
2814 }
2815
2816 #[test]
2817 fn close_repo_second_tab_active_removes_it_and_keeps_first() {
2818 use crate::message::Message;
2821 let mut state = GitKraft::new();
2822 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2824 state.tabs.push(RepoTab::new_empty());
2826 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2827 state.active_tab = 1; let _ = state.update(Message::CloseRepo);
2830
2831 assert_eq!(state.tabs.len(), 1);
2832 assert_eq!(state.active_tab, 0);
2833 assert_eq!(
2834 state.active_tab().repo_path.as_deref(),
2835 Some(std::path::Path::new("/home/user/repo-a")),
2836 "repo-a should be the surviving tab"
2837 );
2838 }
2839
2840 #[test]
2843 fn close_tab_active_tab_first_position() {
2844 use crate::message::Message;
2846 let mut state = GitKraft::new();
2847 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2848 state.tabs.push(RepoTab::new_empty());
2849 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2850 state.active_tab = 0;
2851
2852 let _ = state.update(Message::CloseTab(0));
2853
2854 assert_eq!(state.tabs.len(), 1);
2855 assert_eq!(state.active_tab, 0);
2856 assert_eq!(
2857 state.active_tab().repo_path.as_deref(),
2858 Some(std::path::Path::new("/home/user/repo-b"))
2859 );
2860 }
2861
2862 #[test]
2863 fn close_tab_active_tab_second_position() {
2864 use crate::message::Message;
2866 let mut state = GitKraft::new();
2867 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2868 state.tabs.push(RepoTab::new_empty());
2869 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2870 state.active_tab = 1;
2871
2872 let _ = state.update(Message::CloseTab(1));
2873
2874 assert_eq!(state.tabs.len(), 1);
2875 assert_eq!(state.active_tab, 0);
2876 assert_eq!(
2877 state.active_tab().repo_path.as_deref(),
2878 Some(std::path::Path::new("/home/user/repo-a"))
2879 );
2880 }
2881
2882 #[test]
2883 fn close_tab_non_active_tab_before_active() {
2884 use crate::message::Message;
2886 let mut state = GitKraft::new();
2887 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2888 state.tabs.push(RepoTab::new_empty());
2889 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2890 state.active_tab = 1;
2891
2892 let _ = state.update(Message::CloseTab(0));
2893
2894 assert_eq!(state.tabs.len(), 1);
2895 assert_eq!(state.active_tab, 0, "active_tab must shift down by 1");
2896 assert_eq!(
2897 state.active_tab().repo_path.as_deref(),
2898 Some(std::path::Path::new("/home/user/repo-b")),
2899 "repo-b must remain active"
2900 );
2901 }
2902
2903 #[test]
2904 fn close_tab_non_active_tab_after_active() {
2905 use crate::message::Message;
2907 let mut state = GitKraft::new();
2908 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2909 state.tabs.push(RepoTab::new_empty());
2910 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2911 state.active_tab = 0;
2912
2913 let _ = state.update(Message::CloseTab(1));
2914
2915 assert_eq!(state.tabs.len(), 1);
2916 assert_eq!(state.active_tab, 0, "active_tab must remain unchanged");
2917 assert_eq!(
2918 state.active_tab().repo_path.as_deref(),
2919 Some(std::path::Path::new("/home/user/repo-a")),
2920 "repo-a must remain active"
2921 );
2922 }
2923
2924 #[test]
2925 fn close_tab_last_remaining_tab_is_noop() {
2926 use crate::message::Message;
2928 let mut state = GitKraft::new();
2929 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2930 assert_eq!(state.tabs.len(), 1);
2931
2932 let _ = state.update(Message::CloseTab(0));
2933
2934 assert_eq!(state.tabs.len(), 1, "last tab must not be removed");
2935 assert!(
2936 state.active_tab().has_repo(),
2937 "tab content must be unchanged"
2938 );
2939 }
2940
2941 #[test]
2942 fn close_tab_out_of_bounds_index_is_noop() {
2943 use crate::message::Message;
2945 let mut state = GitKraft::new();
2946 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2947 state.tabs.push(RepoTab::new_empty());
2948 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2949 assert_eq!(state.tabs.len(), 2);
2950
2951 let _ = state.update(Message::CloseTab(99));
2952
2953 assert_eq!(state.tabs.len(), 2, "no tab should be removed");
2954 }
2955
2956 #[test]
2959 fn close_tab_three_tabs_close_first_active() {
2960 use crate::message::Message;
2963 let mut state = GitKraft::new();
2964 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2965 state.tabs.push(RepoTab::new_empty());
2966 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2967 state.tabs.push(RepoTab::new_empty());
2968 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2969 state.active_tab = 0;
2970
2971 let _ = state.update(Message::CloseTab(0));
2972
2973 assert_eq!(state.tabs.len(), 2);
2974 assert_eq!(state.active_tab, 0);
2975 assert_eq!(
2976 state.active_tab().repo_path.as_deref(),
2977 Some(std::path::Path::new("/home/user/repo-b"))
2978 );
2979 }
2980
2981 #[test]
2982 fn close_tab_three_tabs_close_active_middle() {
2983 use crate::message::Message;
2986 let mut state = GitKraft::new();
2987 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2988 state.tabs.push(RepoTab::new_empty());
2989 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2990 state.tabs.push(RepoTab::new_empty());
2991 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2992 state.active_tab = 1;
2993
2994 let _ = state.update(Message::CloseTab(1));
2995
2996 assert_eq!(state.tabs.len(), 2);
2997 assert_eq!(
2998 state.active_tab, 1,
2999 "active_tab must stay at 1 (repo-c slides in)"
3000 );
3001 assert_eq!(
3002 state.active_tab().repo_path.as_deref(),
3003 Some(std::path::Path::new("/home/user/repo-c"))
3004 );
3005 }
3006
3007 #[test]
3008 fn close_tab_three_tabs_close_active_last() {
3009 use crate::message::Message;
3012 let mut state = GitKraft::new();
3013 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3014 state.tabs.push(RepoTab::new_empty());
3015 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3016 state.tabs.push(RepoTab::new_empty());
3017 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3018 state.active_tab = 2;
3019
3020 let _ = state.update(Message::CloseTab(2));
3021
3022 assert_eq!(state.tabs.len(), 2);
3023 assert_eq!(
3024 state.active_tab, 1,
3025 "active_tab must be clamped to last index"
3026 );
3027 assert_eq!(
3028 state.active_tab().repo_path.as_deref(),
3029 Some(std::path::Path::new("/home/user/repo-b"))
3030 );
3031 }
3032
3033 #[test]
3034 fn close_tab_three_tabs_close_first_when_middle_active() {
3035 use crate::message::Message;
3038 let mut state = GitKraft::new();
3039 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3040 state.tabs.push(RepoTab::new_empty());
3041 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3042 state.tabs.push(RepoTab::new_empty());
3043 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3044 state.active_tab = 1;
3045
3046 let _ = state.update(Message::CloseTab(0));
3047
3048 assert_eq!(state.tabs.len(), 2);
3049 assert_eq!(
3050 state.active_tab, 0,
3051 "active_tab must decrement when a tab before it is removed"
3052 );
3053 assert_eq!(
3054 state.active_tab().repo_path.as_deref(),
3055 Some(std::path::Path::new("/home/user/repo-b")),
3056 "repo-b must remain active"
3057 );
3058 }
3059
3060 #[test]
3061 fn close_tab_three_tabs_close_last_when_middle_active() {
3062 use crate::message::Message;
3065 let mut state = GitKraft::new();
3066 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3067 state.tabs.push(RepoTab::new_empty());
3068 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3069 state.tabs.push(RepoTab::new_empty());
3070 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3071 state.active_tab = 1;
3072
3073 let _ = state.update(Message::CloseTab(2));
3074
3075 assert_eq!(state.tabs.len(), 2);
3076 assert_eq!(
3077 state.active_tab, 1,
3078 "active_tab must not change when a tab after it is removed"
3079 );
3080 assert_eq!(
3081 state.active_tab().repo_path.as_deref(),
3082 Some(std::path::Path::new("/home/user/repo-b")),
3083 "repo-b must remain active"
3084 );
3085 }
3086
3087 #[test]
3090 fn close_repo_three_tabs_active_in_middle() {
3091 use crate::message::Message;
3094 let mut state = GitKraft::new();
3095 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3096 state.tabs.push(RepoTab::new_empty());
3097 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3098 state.tabs.push(RepoTab::new_empty());
3099 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3100 state.active_tab = 1;
3101
3102 let _ = state.update(Message::CloseRepo);
3103
3104 assert_eq!(state.tabs.len(), 2);
3105 assert_eq!(state.active_tab, 1);
3106 assert_eq!(
3107 state.active_tab().repo_path.as_deref(),
3108 Some(std::path::Path::new("/home/user/repo-c"))
3109 );
3110 }
3111
3112 #[test]
3113 fn close_repo_three_tabs_active_at_end() {
3114 use crate::message::Message;
3117 let mut state = GitKraft::new();
3118 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3119 state.tabs.push(RepoTab::new_empty());
3120 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3121 state.tabs.push(RepoTab::new_empty());
3122 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3123 state.active_tab = 2;
3124
3125 let _ = state.update(Message::CloseRepo);
3126
3127 assert_eq!(state.tabs.len(), 2);
3128 assert_eq!(state.active_tab, 1, "active_tab clamped to last index");
3129 assert_eq!(
3130 state.active_tab().repo_path.as_deref(),
3131 Some(std::path::Path::new("/home/user/repo-b"))
3132 );
3133 }
3134
3135 #[test]
3138 fn new_tab_appends_empty_tab_and_makes_it_active() {
3139 use crate::message::Message;
3140 let mut state = GitKraft::new();
3141 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3142 assert_eq!(state.tabs.len(), 1);
3143
3144 let _ = state.update(Message::NewTab);
3145
3146 assert_eq!(state.tabs.len(), 2, "a new empty tab must be appended");
3147 assert_eq!(state.active_tab, 1, "new tab must become active");
3148 assert!(!state.active_tab().has_repo(), "new tab must be empty");
3149 assert!(state.tabs[0].has_repo());
3151 }
3152
3153 #[test]
3154 fn new_tab_with_multiple_existing_tabs() {
3155 use crate::message::Message;
3156 let mut state = GitKraft::new();
3157 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3158 state.tabs.push(RepoTab::new_empty());
3159 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3160 state.active_tab = 0;
3161
3162 let _ = state.update(Message::NewTab);
3163
3164 assert_eq!(state.tabs.len(), 3);
3165 assert_eq!(state.active_tab, 2);
3166 assert!(!state.active_tab().has_repo());
3167 }
3168
3169 #[test]
3172 fn switch_tab_to_valid_index() {
3173 use crate::message::Message;
3174 let mut state = GitKraft::new();
3175 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3176 state.tabs.push(RepoTab::new_empty());
3177 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3178 state.active_tab = 0;
3179
3180 let _ = state.update(Message::SwitchTab(1));
3181
3182 assert_eq!(state.active_tab, 1);
3183 assert_eq!(
3184 state.active_tab().repo_path.as_deref(),
3185 Some(std::path::Path::new("/home/user/repo-b"))
3186 );
3187 }
3188
3189 #[test]
3190 fn switch_tab_to_same_index_is_noop() {
3191 use crate::message::Message;
3192 let mut state = GitKraft::new();
3193 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3194 state.tabs.push(RepoTab::new_empty());
3195 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3196 state.active_tab = 1;
3197
3198 let _ = state.update(Message::SwitchTab(1));
3199
3200 assert_eq!(state.active_tab, 1, "should remain on tab 1");
3201 }
3202
3203 #[test]
3204 fn switch_tab_out_of_bounds_is_noop() {
3205 use crate::message::Message;
3207 let mut state = GitKraft::new();
3208 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3209 state.tabs.push(RepoTab::new_empty());
3210 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3211 state.active_tab = 0;
3212
3213 let _ = state.update(Message::SwitchTab(99));
3214
3215 assert_eq!(state.active_tab, 0, "out-of-bounds switch must be ignored");
3216 }
3217
3218 #[test]
3221 fn session_state_matches_open_tab_paths_when_no_empty_tabs() {
3222 let mut state = GitKraft::new();
3223 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3224 state.tabs.push(RepoTab::new_empty());
3225 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3226 state.active_tab = 1;
3227
3228 let (paths, active) = state.session_state();
3229 assert_eq!(paths.len(), 2);
3230 assert_eq!(active, 1);
3231 }
3232
3233 #[test]
3234 fn session_state_adjusts_index_when_empty_tab_before_active() {
3235 let mut state = GitKraft::new();
3238 state.tabs.push(RepoTab::new_empty());
3240 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
3241 state.tabs.push(RepoTab::new_empty());
3242 setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-b");
3243 state.active_tab = 2;
3244
3245 let (paths, active) = state.session_state();
3246 assert_eq!(paths.len(), 2);
3247 assert_eq!(active, 1, "session index must skip the empty tab at 0");
3248 }
3249
3250 #[test]
3251 fn session_state_defaults_to_zero_when_active_is_empty_tab() {
3252 let mut state = GitKraft::new();
3253 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3254 state.tabs.push(RepoTab::new_empty()); state.active_tab = 1; let (paths, active) = state.session_state();
3258 assert_eq!(paths.len(), 1);
3259 assert_eq!(active, 0, "empty active tab should default to 0");
3260 }
3261
3262 #[test]
3265 fn repo_selected_sets_repo_path_before_load() {
3266 use crate::message::Message;
3270 let mut state = GitKraft::new();
3271 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3272 state.tabs.push(RepoTab::new_empty());
3274 state.active_tab = 1;
3275
3276 let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
3277 "/home/user/repo-b",
3278 ))));
3279
3280 assert_eq!(
3282 state.tabs[1].repo_path.as_deref(),
3283 Some(std::path::Path::new("/home/user/repo-b")),
3284 "repo_path must be set before async load starts"
3285 );
3286 }
3287
3288 #[test]
3289 fn open_recent_repo_sets_repo_path_before_load() {
3290 use crate::message::Message;
3291 let mut state = GitKraft::new();
3292 assert!(!state.active_tab().has_repo());
3294
3295 let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
3296 "/home/user/repo-a",
3297 )));
3298
3299 assert_eq!(
3300 state.active_tab().repo_path.as_deref(),
3301 Some(std::path::Path::new("/home/user/repo-a")),
3302 "repo_path must be set before async load starts"
3303 );
3304 }
3305
3306 #[test]
3307 fn handle_repo_loaded_targets_correct_tab_after_switch() {
3308 use crate::message::Message;
3312 let mut state = GitKraft::new();
3313 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3315 state.tabs.push(RepoTab::new_empty());
3317 state.tabs[1].repo_path = Some(std::path::PathBuf::from("/home/user/repo-b"));
3318 state.tabs[1].is_loading = true;
3319
3320 state.active_tab = 0;
3322
3323 let payload = fake_payload("/home/user/repo-b");
3325 let _ = state.update(Message::RepoOpened(Ok(payload)));
3326
3327 assert!(
3329 state.tabs[1].repo_info.is_some(),
3330 "repo-b payload must be written to tab 1"
3331 );
3332 assert_eq!(
3333 state.tabs[1].repo_path.as_deref(),
3334 Some(std::path::Path::new("/home/user/repo-b")),
3335 );
3336 assert_eq!(
3338 state.tabs[0].repo_path.as_deref(),
3339 Some(std::path::Path::new("/home/user/repo-a")),
3340 );
3341 }
3342
3343 #[test]
3346 fn apply_payload_does_not_reset_is_loading_more_commits() {
3347 use crate::message::Message;
3352 let mut state = GitKraft::new();
3353 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3354
3355 state.active_tab_mut().is_loading_more_commits = true;
3357
3358 let payload = fake_payload("/home/user/repo-a");
3360 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3361
3362 assert!(
3364 state.active_tab().is_loading_more_commits,
3365 "refresh must not reset is_loading_more_commits"
3366 );
3367 }
3368
3369 #[test]
3370 fn apply_payload_resets_is_loading_more_commits_for_new_open() {
3371 let mut state = GitKraft::new();
3376 assert!(!state.active_tab().is_loading_more_commits);
3377
3378 let payload = fake_payload("/home/user/repo-a");
3379 let _ = state.update(crate::message::Message::RepoOpened(Ok(payload)));
3380
3381 assert!(!state.active_tab().is_loading_more_commits);
3382 }
3383
3384 #[test]
3385 fn refresh_does_not_shrink_commit_list() {
3386 use crate::message::Message;
3389 let mut state = GitKraft::new();
3390 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3391
3392 let tab = state.active_tab_mut();
3394 tab.commits = make_test_commits(500);
3395 for i in 0..200 {
3396 let mut c = tab.commits[0].clone();
3397 c.oid = format!("extra_{i:04}");
3398 tab.commits.push(c);
3399 }
3400 let extended_len = tab.commits.len();
3401 tab.has_more_commits = true;
3402
3403 let mut payload = fake_payload("/home/user/repo-a");
3405 payload.commits = make_test_commits(500);
3406 payload.graph_rows = gitkraft_core::features::graph::build_graph(&payload.commits);
3407 let payload_len = payload.commits.len();
3408 assert!(
3409 payload_len < extended_len,
3410 "payload must be shorter for this test"
3411 );
3412
3413 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3414
3415 assert_eq!(
3416 state.active_tab().commits.len(),
3417 extended_len,
3418 "refresh must not shrink the commit list"
3419 );
3420 }
3421
3422 #[test]
3423 fn refresh_replaces_commits_when_count_equal_or_greater() {
3424 use crate::message::Message;
3427 let mut state = GitKraft::new();
3428 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3429 state.active_tab_mut().commits = make_test_commits(5);
3430
3431 let original_oid = state.active_tab().commits[0].oid.clone();
3432
3433 let mut payload = fake_payload("/home/user/repo-a");
3435 let mut new_commits = make_test_commits(5);
3436 new_commits[0].oid = "refreshed_oid_0000".to_string();
3437 payload.commits = new_commits;
3438 payload.graph_rows = gitkraft_core::features::graph::build_graph(&payload.commits);
3439 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3440
3441 assert_eq!(
3442 state.active_tab().commits[0].oid,
3443 "refreshed_oid_0000",
3444 "refresh with same count must replace commits"
3445 );
3446 assert_ne!(state.active_tab().commits[0].oid, original_oid);
3447 }
3448
3449 #[test]
3450 fn refresh_rebuilds_commit_display_when_content_changes_at_same_count() {
3451 use crate::message::Message;
3456 let mut state = GitKraft::new();
3457 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3458 let mut initial_commits = make_test_commits(5);
3459 initial_commits[0].author_name = "Alice".to_string();
3460 state.active_tab_mut().commits = initial_commits;
3461 state.active_tab_mut().commit_display = state
3463 .active_tab()
3464 .commits
3465 .iter()
3466 .map(|c| ("1 day ago".to_string(), c.author_name.clone()))
3467 .collect();
3468 assert_eq!(state.active_tab().commit_display[0].1, "Alice");
3469
3470 let mut payload = fake_payload("/home/user/repo-a");
3472 let mut new_commits = make_test_commits(5);
3473 new_commits[0].author_name = "Bob".to_string();
3474 payload.commits = new_commits;
3475 payload.graph_rows = gitkraft_core::features::graph::build_graph(&payload.commits);
3476 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3477
3478 assert_eq!(
3480 state.active_tab().commit_display[0].1,
3481 "Bob",
3482 "commit_display must be rebuilt when content changes at same count"
3483 );
3484 }
3485
3486 #[test]
3487 fn duplicate_more_commits_loaded_sets_has_more_false() {
3488 use crate::message::{CommitPage, Message};
3494 let mut state = GitKraft::new();
3495 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3496 let tab = state.active_tab_mut();
3497 tab.commits = make_test_commits(10);
3498 tab.has_more_commits = true;
3499
3500 let _initial_count = tab.commits.len();
3502 let mut bigger_commits = tab.commits.clone();
3503 for i in 0..5 {
3504 let mut c = bigger_commits[0].clone();
3505 c.oid = format!("new_{i:04}");
3506 bigger_commits.push(c);
3507 }
3508 let bigger_count = bigger_commits.len();
3509 let graph_rows = gitkraft_core::features::graph::build_graph(&bigger_commits);
3510 let _ = state.update(Message::MoreCommitsLoaded(Ok(CommitPage {
3511 commits: bigger_commits.clone(),
3512 graph_rows: graph_rows.clone(),
3513 })));
3514 assert!(
3515 state.active_tab().has_more_commits,
3516 "first result should keep has_more true"
3517 );
3518 assert_eq!(state.active_tab().commits.len(), bigger_count);
3519
3520 let _ = state.update(Message::MoreCommitsLoaded(Ok(CommitPage {
3522 commits: bigger_commits,
3523 graph_rows,
3524 })));
3525 assert!(
3526 !state.active_tab().has_more_commits,
3527 "identical second result must set has_more_commits false"
3528 );
3529 }
3530
3531 #[test]
3532 fn file_system_changed_allowed_during_pagination() {
3533 use crate::message::Message;
3538 let mut state = GitKraft::new();
3539 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3540 state.active_tab_mut().is_loading_more_commits = true;
3541
3542 let _task = state.update(Message::FileSystemChanged);
3543
3544 assert!(state.active_tab().is_loading_more_commits);
3546 }
3549
3550 #[test]
3551 fn has_more_commits_preserved_across_refresh() {
3552 use crate::message::Message;
3554 let mut state = GitKraft::new();
3555 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3556 state.active_tab_mut().has_more_commits = true;
3557
3558 let payload = fake_payload("/home/user/repo-a");
3559 let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3560
3561 assert!(
3562 state.active_tab().has_more_commits,
3563 "refresh must preserve has_more_commits"
3564 );
3565 }
3566
3567 #[test]
3568 fn switch_to_existing_tab_removes_empty_tab() {
3569 let mut state = GitKraft::new();
3570 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3572 state.tabs.push(RepoTab::new_empty());
3574 state.active_tab = 1;
3575
3576 let found = state.switch_to_existing_tab(std::path::Path::new("/home/user/repo-a"));
3577
3578 assert!(found, "should find existing tab");
3579 assert_eq!(state.tabs.len(), 1, "empty tab should be removed");
3580 assert_eq!(state.active_tab, 0);
3581 assert_eq!(
3582 state.active_tab().repo_path.as_deref(),
3583 Some(std::path::Path::new("/home/user/repo-a")),
3584 );
3585 }
3586
3587 #[test]
3588 fn switch_to_existing_tab_returns_false_when_not_found() {
3589 let mut state = GitKraft::new();
3590 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3591
3592 let found = state.switch_to_existing_tab(std::path::Path::new("/home/user/repo-b"));
3593
3594 assert!(!found, "should not find nonexistent tab");
3595 assert_eq!(state.tabs.len(), 1);
3596 }
3597
3598 #[test]
3599 fn switch_to_existing_tab_keeps_loaded_current_tab() {
3600 let mut state = GitKraft::new();
3601 setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3603 state.tabs.push(RepoTab::new_empty());
3605 setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3606 state.active_tab = 1;
3607
3608 let found = state.switch_to_existing_tab(std::path::Path::new("/home/user/repo-a"));
3609
3610 assert!(found);
3611 assert_eq!(state.tabs.len(), 2, "loaded tab should not be removed");
3612 assert_eq!(state.active_tab, 0);
3613 }
3614
3615 #[test]
3616 fn default_remote_returns_first_remote() {
3617 let mut tab = RepoTab::new_empty();
3618 tab.remotes = vec![
3619 gitkraft_core::RemoteInfo {
3620 name: "upstream".to_string(),
3621 url: Some("https://example.com/upstream.git".to_string()),
3622 fetch_refspecs: vec![],
3623 },
3624 gitkraft_core::RemoteInfo {
3625 name: "origin".to_string(),
3626 url: Some("https://example.com/origin.git".to_string()),
3627 fetch_refspecs: vec![],
3628 },
3629 ];
3630 assert_eq!(tab.default_remote(), "upstream");
3631 }
3632
3633 #[test]
3634 fn default_remote_fallback_to_origin() {
3635 let tab = RepoTab::new_empty();
3636 assert_eq!(tab.default_remote(), "origin");
3637 }
3638
3639 #[test]
3640 fn hovered_commit_defaults_to_none() {
3641 let tab = RepoTab::new_empty();
3642 assert_eq!(tab.hovered_commit, None);
3643 }
3644
3645 #[test]
3646 fn hover_commit_message_sets_state() {
3647 use crate::message::Message;
3648 let mut state = GitKraft::new();
3649 let _task = state.update(Message::HoverCommit(Some(5)));
3650 assert_eq!(state.active_tab().hovered_commit, Some(5));
3651 let _task = state.update(Message::HoverCommit(None));
3652 assert_eq!(state.active_tab().hovered_commit, None);
3653 }
3654
3655 #[test]
3656 fn branch_context_defaults_empty() {
3657 let tab = RepoTab::new_empty();
3658 assert!(tab.branch_context.is_empty());
3659 }
3660
3661 #[test]
3662 fn ghost_shows_on_selected_commit_without_refs() {
3663 let mut state = GitKraft::new();
3667 let tab = state.active_tab_mut();
3668 tab.repo_path = Some(std::path::PathBuf::from("/tmp/test"));
3669 tab.commits = vec![
3670 gitkraft_core::CommitInfo {
3671 oid: "aaa".into(),
3672 summary: "tip".into(),
3673 message: String::new(),
3674 author_name: "A".into(),
3675 author_email: String::new(),
3676 time: Default::default(),
3677 parent_ids: Vec::new(),
3678 refs: vec![gitkraft_core::RefLabel {
3679 name: "main".to_string(),
3680 kind: gitkraft_core::RefKind::Head,
3681 }],
3682 },
3683 gitkraft_core::CommitInfo {
3684 oid: "bbb".into(),
3685 summary: "child".into(),
3686 message: String::new(),
3687 author_name: "A".into(),
3688 author_email: String::new(),
3689 time: Default::default(),
3690 parent_ids: Vec::new(),
3691 refs: Vec::new(),
3692 },
3693 ];
3694 tab.graph_rows = vec![
3695 gitkraft_core::GraphRow {
3696 width: 1,
3697 node_column: 0,
3698 node_color: 0,
3699 edges: Vec::new(),
3700 },
3701 gitkraft_core::GraphRow {
3702 width: 1,
3703 node_column: 0,
3704 node_color: 0,
3705 edges: Vec::new(),
3706 },
3707 ];
3708 tab.branch_context = vec![None, Some("main".to_string())];
3709 tab.selected_commit = Some(1);
3710
3711 let tab = state.active_tab();
3713 assert_eq!(tab.selected_commit, Some(1));
3714 assert_eq!(tab.branch_context[1], Some("main".to_string()));
3715 assert_eq!(tab.branch_context[0], None);
3717 }
3718}