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}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum DragTargetH {
27 StagingTop,
29}
30
31#[derive(Debug, Clone)]
33pub enum ContextMenu {
34 Branch {
36 name: String,
37 is_current: bool,
38 local_index: usize,
41 },
42 RemoteBranch { name: String },
44 Commit { index: usize, oid: String },
46 Stash { index: usize },
48 UnstagedFile { path: String },
50 StagedFile { path: String },
52 CommitFile { oid: String, file_path: String },
54}
55
56pub struct RepoTab {
60 pub repo_path: Option<PathBuf>,
63 pub repo_info: Option<RepoInfo>,
65
66 pub branches: Vec<BranchInfo>,
69 pub current_branch: Option<String>,
71
72 pub commits: Vec<CommitInfo>,
75 pub selected_commit: Option<usize>,
77 pub anchor_commit_index: Option<usize>,
79 pub selected_commits: Vec<usize>,
81 pub graph_rows: Vec<gitkraft_core::GraphRow>,
83
84 pub unstaged_changes: Vec<DiffInfo>,
87 pub staged_changes: Vec<DiffInfo>,
89 pub commit_files: Vec<gitkraft_core::DiffFileEntry>,
91 pub selected_commit_oid: Option<String>,
93 pub selected_file_index: Option<usize>,
95 pub is_loading_file_diff: bool,
97 pub anchor_file_index: Option<usize>,
100 pub selected_commit_file_indices: Vec<usize>,
103 pub multi_file_diffs: Vec<gitkraft_core::DiffInfo>,
105 pub commit_range_diffs: Vec<gitkraft_core::DiffInfo>,
108 pub selected_diff: Option<DiffInfo>,
110 pub commit_message: String,
112
113 pub stashes: Vec<StashEntry>,
116
117 pub remotes: Vec<RemoteInfo>,
120
121 pub show_commit_detail: bool,
124 pub new_branch_name: String,
126 pub show_branch_create: bool,
128 pub local_branches_expanded: bool,
130 pub remote_branches_expanded: bool,
132 pub stash_message: String,
134
135 pub selected_unstaged: std::collections::HashSet<String>,
137 pub selected_staged: std::collections::HashSet<String>,
139
140 pub pending_discard: Option<String>,
142
143 pub status_message: Option<String>,
146 pub error_message: Option<String>,
148 pub is_loading: bool,
150 pub context_menu_pos: (f32, f32),
153
154 pub context_menu: Option<ContextMenu>,
156 pub rename_branch_target: Option<String>,
158 pub rename_branch_input: String,
160
161 pub create_tag_target_oid: Option<String>,
163 pub create_tag_annotated: bool,
165 pub create_tag_name: String,
167 pub create_tag_message: String,
169 pub create_branch_at_oid: Option<String>,
171
172 pub commit_scroll_offset: f32,
176
177 pub diff_scroll_offset: f32,
179 pub commit_display: Vec<(String, String, String)>,
183
184 pub has_more_commits: bool,
186 pub is_loading_more_commits: bool,
188
189 pub file_history_path: Option<String>,
191 pub file_history_commits: Vec<gitkraft_core::CommitInfo>,
193 pub file_history_scroll: f32,
195
196 pub blame_path: Option<String>,
198 pub blame_lines: Vec<gitkraft_core::BlameLine>,
200 pub blame_scroll: f32,
202
203 pub pending_delete_file: Option<String>,
205}
206
207impl RepoTab {
208 pub fn new_empty() -> Self {
210 Self {
211 repo_path: None,
212 repo_info: None,
213 branches: Vec::new(),
214 current_branch: None,
215 commits: Vec::new(),
216 selected_commit: None,
217 anchor_commit_index: None,
218 selected_commits: Vec::new(),
219 graph_rows: Vec::new(),
220 unstaged_changes: Vec::new(),
221 staged_changes: Vec::new(),
222 commit_files: Vec::new(),
223 selected_commit_oid: None,
224 selected_file_index: None,
225 is_loading_file_diff: false,
226 anchor_file_index: None,
227 selected_commit_file_indices: Vec::new(),
228 multi_file_diffs: Vec::new(),
229 commit_range_diffs: Vec::new(),
230 selected_diff: None,
231 commit_message: String::new(),
232 stashes: Vec::new(),
233 remotes: Vec::new(),
234 show_commit_detail: false,
235 new_branch_name: String::new(),
236 show_branch_create: false,
237 local_branches_expanded: true,
238 remote_branches_expanded: true,
239 stash_message: String::new(),
240 selected_unstaged: std::collections::HashSet::new(),
241 selected_staged: std::collections::HashSet::new(),
242 pending_discard: None,
243 status_message: None,
244 error_message: None,
245 is_loading: false,
246 context_menu: None,
247 context_menu_pos: (0.0, 0.0),
248 rename_branch_target: None,
249 rename_branch_input: String::new(),
250 create_tag_target_oid: None,
251 create_tag_annotated: false,
252 create_tag_name: String::new(),
253 create_tag_message: String::new(),
254 create_branch_at_oid: None,
255 commit_scroll_offset: 0.0,
256 diff_scroll_offset: 0.0,
257 commit_display: Vec::new(),
258 has_more_commits: true,
259 is_loading_more_commits: false,
260 file_history_path: None,
261 file_history_commits: Vec::new(),
262 file_history_scroll: 0.0,
263 blame_path: None,
264 blame_lines: Vec::new(),
265 blame_scroll: 0.0,
266 pending_delete_file: None,
267 }
268 }
269
270 pub fn has_repo(&self) -> bool {
272 self.repo_path.is_some()
273 }
274
275 pub fn display_name(&self) -> &str {
277 self.repo_path
278 .as_ref()
279 .and_then(|p| p.file_name())
280 .and_then(|n| n.to_str())
281 .unwrap_or("New Tab")
282 }
283
284 pub fn apply_payload(
286 &mut self,
287 payload: crate::message::RepoPayload,
288 path: std::path::PathBuf,
289 ) {
290 self.current_branch = payload.info.head_branch.clone();
291 self.repo_path = Some(path);
292 self.repo_info = Some(payload.info);
293 self.branches = payload.branches;
294 self.commits = payload.commits;
295 self.graph_rows = payload.graph_rows;
296 self.unstaged_changes = payload.unstaged;
297 self.staged_changes = payload.staged;
298 self.stashes = payload.stashes;
299 self.remotes = payload.remotes;
300
301 self.selected_commit = None;
303 self.anchor_commit_index = None;
304 self.selected_commits.clear();
305 self.selected_diff = None;
306 self.commit_files.clear();
307 self.selected_commit_oid = None;
308 self.selected_file_index = None;
309 self.is_loading_file_diff = false;
310 self.commit_message.clear();
311 self.error_message = None;
312 self.status_message = Some("Repository loaded.".into());
313 self.commit_scroll_offset = 0.0;
314 self.diff_scroll_offset = 0.0;
315 self.has_more_commits = true;
316 self.is_loading_more_commits = false;
317 self.selected_unstaged.clear();
318 self.selected_staged.clear();
319 self.anchor_file_index = None;
320 self.selected_commit_file_indices.clear();
321 self.multi_file_diffs.clear();
322 self.commit_range_diffs.clear();
323 }
324}
325
326pub struct GitKraft {
330 pub tabs: Vec<RepoTab>,
333 pub active_tab: usize,
335
336 pub sidebar_expanded: bool,
339
340 pub sidebar_width: f32,
343 pub commit_log_width: f32,
345 pub staging_height: f32,
347 pub diff_file_list_width: f32,
349
350 pub ui_scale: f32,
352
353 pub dragging: Option<DragTarget>,
356 pub dragging_h: Option<DragTargetH>,
358 pub drag_start_x: f32,
360 pub drag_start_y: f32,
362 pub drag_initialized: bool,
366 pub drag_initialized_h: bool,
368
369 pub cursor_pos: Point,
374
375 pub current_theme_index: usize,
378
379 pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
382
383 pub search_visible: bool,
386 pub search_query: String,
388 pub search_results: Vec<gitkraft_core::CommitInfo>,
390 pub search_selected: Option<usize>,
392
393 pub search_diff_files: Vec<gitkraft_core::DiffFileEntry>,
395 pub search_diff_selected: HashSet<usize>,
397 pub search_diff_content: Vec<gitkraft_core::DiffInfo>,
399 pub search_diff_oid: Option<String>,
401
402 pub editor: gitkraft_core::Editor,
404
405 pub keyboard_modifiers: iced::keyboard::Modifiers,
407
408 pub window_width: f32,
411 pub window_height: f32,
413 pub window_x: f32,
415 pub window_y: f32,
417}
418
419impl Default for GitKraft {
420 fn default() -> Self {
421 Self::new()
422 }
423}
424
425impl GitKraft {
426 fn from_settings(settings: gitkraft_core::AppSettings) -> Self {
432 let current_theme_index = settings
433 .theme_name
434 .as_deref()
435 .map(gitkraft_core::theme_index_by_name)
436 .unwrap_or(0);
437
438 let recent_repos = settings.recent_repos;
439
440 let (
441 sidebar_width,
442 commit_log_width,
443 staging_height,
444 diff_file_list_width,
445 sidebar_expanded,
446 ui_scale,
447 ) = if let Some(ref layout) = settings.layout {
448 (
449 layout.sidebar_width.unwrap_or(220.0),
450 layout.commit_log_width.unwrap_or(500.0),
451 layout.staging_height.unwrap_or(200.0),
452 layout.diff_file_list_width.unwrap_or(180.0),
453 layout.sidebar_expanded.unwrap_or(true),
454 layout.ui_scale.unwrap_or(1.0),
455 )
456 } else {
457 (220.0, 500.0, 200.0, 180.0, true, 1.0)
458 };
459
460 Self {
461 tabs: vec![RepoTab::new_empty()],
462 active_tab: 0,
463
464 sidebar_expanded,
465
466 sidebar_width,
467 commit_log_width,
468 staging_height,
469 diff_file_list_width,
470
471 ui_scale,
472
473 dragging: None,
474 dragging_h: None,
475 drag_start_x: 0.0,
476 drag_start_y: 0.0,
477 drag_initialized: false,
478 drag_initialized_h: false,
479 cursor_pos: Point::ORIGIN,
480
481 current_theme_index,
482
483 recent_repos,
484
485 search_visible: false,
486 search_query: String::new(),
487 search_results: Vec::new(),
488 search_selected: None,
489 search_diff_files: Vec::new(),
490 search_diff_selected: HashSet::new(),
491 search_diff_content: Vec::new(),
492 search_diff_oid: None,
493
494 keyboard_modifiers: iced::keyboard::Modifiers::default(),
495
496 window_width: settings
497 .layout
498 .as_ref()
499 .and_then(|l| l.window_width)
500 .unwrap_or(1400.0),
501 window_height: settings
502 .layout
503 .as_ref()
504 .and_then(|l| l.window_height)
505 .unwrap_or(800.0),
506 window_x: settings
507 .layout
508 .as_ref()
509 .and_then(|l| l.window_x)
510 .unwrap_or(0.0),
511 window_y: settings
512 .layout
513 .as_ref()
514 .and_then(|l| l.window_y)
515 .unwrap_or(0.0),
516
517 editor: settings
518 .editor_name
519 .as_deref()
520 .map(|name| {
521 gitkraft_core::EDITOR_NAMES
523 .iter()
524 .position(|n| n.eq_ignore_ascii_case(name))
525 .map(gitkraft_core::Editor::from_index)
526 .unwrap_or_else(|| {
527 if name.eq_ignore_ascii_case("none") {
528 gitkraft_core::Editor::None
529 } else {
530 gitkraft_core::Editor::Custom(name.to_string())
531 }
532 })
533 })
534 .unwrap_or_else(detect_system_editor),
535 }
536 }
537
538 pub fn new() -> Self {
544 Self::from_settings(
545 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default(),
546 )
547 }
548
549 pub fn new_with_session_paths() -> (Self, Vec<PathBuf>) {
555 let settings =
556 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default();
557 let open_tabs = settings.open_tabs.clone();
558 let active_tab_index = settings.active_tab_index;
559
560 let mut state = Self::from_settings(settings);
561
562 if !open_tabs.is_empty() {
563 state.tabs = open_tabs
564 .iter()
565 .map(|path| {
566 let mut tab = RepoTab::new_empty();
567 tab.repo_path = Some(path.clone());
570 if path.exists() {
571 tab.is_loading = true;
572 tab.status_message = Some(format!(
573 "Loading {}…",
574 path.file_name().unwrap_or_default().to_string_lossy()
575 ));
576 } else {
577 tab.error_message =
578 Some(format!("Repository not found: {}", path.display()));
579 }
580 tab
581 })
582 .collect();
583 state.active_tab = active_tab_index.min(state.tabs.len().saturating_sub(1));
584 }
585
586 (state, open_tabs)
587 }
588
589 pub fn open_tab_paths(&self) -> Vec<PathBuf> {
592 self.tabs
593 .iter()
594 .filter(|t| t.repo_info.is_some())
595 .filter_map(|t| t.repo_path.clone())
596 .collect()
597 }
598
599 pub fn active_tab(&self) -> &RepoTab {
601 &self.tabs[self.active_tab]
602 }
603
604 pub fn active_tab_mut(&mut self) -> &mut RepoTab {
606 &mut self.tabs[self.active_tab]
607 }
608
609 pub fn has_repo(&self) -> bool {
611 self.active_tab().has_repo()
612 }
613
614 pub fn repo_display_name(&self) -> &str {
616 self.active_tab().display_name()
617 }
618
619 pub fn colors(&self) -> ThemeColors {
626 ThemeColors::from_core(&gitkraft_core::theme_by_index(self.current_theme_index))
627 }
628
629 pub fn iced_theme(&self) -> iced::Theme {
638 let core = gitkraft_core::theme_by_index(self.current_theme_index);
639 let name = self.current_theme_name().to_string();
640
641 let palette = iced::theme::Palette {
642 background: rgb_to_iced(core.background),
643 text: rgb_to_iced(core.text_primary),
644 primary: rgb_to_iced(core.accent),
645 success: rgb_to_iced(core.success),
646 warning: rgb_to_iced(core.warning),
647 danger: rgb_to_iced(core.error),
648 };
649
650 iced::Theme::custom(name, palette)
651 }
652
653 pub fn current_theme_name(&self) -> &'static str {
655 gitkraft_core::THEME_NAMES
656 .get(self.current_theme_index)
657 .copied()
658 .unwrap_or("Default")
659 }
660
661 pub fn refresh_active_tab(&mut self) -> Task<Message> {
665 match self.active_tab().repo_path.clone() {
666 Some(path) => crate::features::repo::commands::refresh_repo(path),
667 None => Task::none(),
668 }
669 }
670
671 pub fn on_ok_refresh(
678 &mut self,
679 result: Result<(), String>,
680 ok_msg: &str,
681 err_prefix: &str,
682 ) -> Task<Message> {
683 match result {
684 Ok(()) => {
685 {
686 let tab = self.active_tab_mut();
687 tab.is_loading = false;
688 tab.status_message = Some(ok_msg.to_string());
689 }
690 self.refresh_active_tab()
691 }
692 Err(e) => {
693 let tab = self.active_tab_mut();
694 tab.is_loading = false;
695 tab.error_message = Some(format!("{err_prefix}: {e}"));
696 tab.status_message = None;
697 Task::none()
698 }
699 }
700 }
701
702 pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
704 gitkraft_core::LayoutSettings {
705 sidebar_width: Some(self.sidebar_width),
706 commit_log_width: Some(self.commit_log_width),
707 staging_height: Some(self.staging_height),
708 diff_file_list_width: Some(self.diff_file_list_width),
709 sidebar_expanded: Some(self.sidebar_expanded),
710 ui_scale: Some(self.ui_scale),
711 window_width: Some(self.window_width),
712 window_height: Some(self.window_height),
713 window_x: Some(self.window_x),
714 window_y: Some(self.window_y),
715 window_maximized: None, }
717 }
718}
719
720fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
722 Color::from_rgb8(rgb.r, rgb.g, rgb.b)
723}
724
725fn detect_system_editor() -> gitkraft_core::Editor {
727 for var in ["VISUAL", "EDITOR"] {
728 if let Ok(val) = std::env::var(var) {
729 let bin = val.split('/').next_back().unwrap_or(&val).trim();
730 return match bin {
731 "nvim" | "neovim" => gitkraft_core::Editor::Neovim,
732 "vim" => gitkraft_core::Editor::Vim,
733 "hx" | "helix" => gitkraft_core::Editor::Helix,
734 "nano" => gitkraft_core::Editor::Nano,
735 "micro" => gitkraft_core::Editor::Micro,
736 "emacs" => gitkraft_core::Editor::Emacs,
737 "code" => gitkraft_core::Editor::VSCode,
738 "zed" => gitkraft_core::Editor::Zed,
739 "subl" => gitkraft_core::Editor::Sublime,
740 _ => gitkraft_core::Editor::Custom(val),
741 };
742 }
743 }
744 gitkraft_core::Editor::None
745}
746
747#[cfg(test)]
750mod tests {
751 use super::*;
752
753 #[test]
754 fn new_defaults() {
755 let state = GitKraft::new();
756 assert!(state.active_tab().repo_path.is_none());
757 assert!(!state.has_repo());
758 assert_eq!(state.repo_display_name(), "New Tab");
759 assert!(state.active_tab().commits.is_empty());
760 assert!(state.sidebar_expanded);
761 assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
763 assert!(state.sidebar_width > 0.0);
765 assert!(state.commit_log_width > 0.0);
766 assert!(state.staging_height > 0.0);
767 assert!(state.dragging.is_none());
768 assert!(state.dragging_h.is_none());
769 assert_eq!(state.tabs.len(), 1);
771 assert_eq!(state.active_tab, 0);
772 }
773
774 #[test]
775 fn repo_display_name_extracts_basename() {
776 let mut state = GitKraft::new();
777 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
778 assert_eq!(state.repo_display_name(), "my-project");
779 }
780
781 #[test]
782 fn colors_returns_theme_colors() {
783 let state = GitKraft::new();
784 let c = state.colors();
785 assert!(c.bg.r < 0.5);
787 }
788
789 #[test]
790 fn iced_theme_is_custom_with_correct_palette() {
791 let mut state = GitKraft::new();
792
793 state.current_theme_index = 0;
795 let iced_t = state.iced_theme();
796 let pal = iced_t.palette();
797 assert!(pal.background.r < 0.5, "Default theme bg should be dark");
798 assert_eq!(iced_t.to_string(), "Default");
799
800 state.current_theme_index = 11;
802 let iced_t = state.iced_theme();
803 let pal = iced_t.palette();
804 assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
805 assert_eq!(iced_t.to_string(), "Solarized Light");
806
807 state.current_theme_index = 12;
809 let iced_t = state.iced_theme();
810 let pal = iced_t.palette();
811 let core = gitkraft_core::theme_by_index(12);
812 let expected_accent = rgb_to_iced(core.accent);
813 assert!(
814 (pal.primary.r - expected_accent.r).abs() < 0.01
815 && (pal.primary.g - expected_accent.g).abs() < 0.01
816 && (pal.primary.b - expected_accent.b).abs() < 0.01,
817 "Gruvbox Dark accent should match core accent"
818 );
819 }
820
821 #[test]
822 fn iced_theme_name_round_trips_through_core() {
823 for i in 0..gitkraft_core::THEME_COUNT {
826 let mut state = GitKraft::new();
827 state.current_theme_index = i;
828 let iced_t = state.iced_theme();
829 let name = iced_t.to_string();
830 let resolved = gitkraft_core::theme_index_by_name(&name);
831 assert_eq!(
832 resolved,
833 i,
834 "theme index {i} ({}) did not round-trip through iced_theme name",
835 gitkraft_core::THEME_NAMES[i]
836 );
837 }
838 }
839
840 #[test]
841 fn current_theme_name_round_trips() {
842 let mut state = GitKraft::new();
843 state.current_theme_index = 8;
844 assert_eq!(state.current_theme_name(), "Dracula");
845 state.current_theme_index = 0;
846 assert_eq!(state.current_theme_name(), "Default");
847 }
848
849 #[test]
850 fn repo_tab_new_empty() {
851 let tab = RepoTab::new_empty();
852 assert!(tab.repo_path.is_none());
853 assert!(!tab.has_repo());
854 assert_eq!(tab.display_name(), "New Tab");
855 assert!(tab.commits.is_empty());
856 assert!(tab.branches.is_empty());
857 assert!(!tab.is_loading);
858 }
859
860 #[test]
861 fn repo_tab_display_name_with_path() {
862 let mut tab = RepoTab::new_empty();
863 tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
864 assert!(tab.has_repo());
865 assert_eq!(tab.display_name(), "cool-repo");
866 }
867
868 #[test]
869 fn search_defaults() {
870 let state = GitKraft::new();
871 assert!(!state.search_visible);
872 assert!(state.search_query.is_empty());
873 assert!(state.search_results.is_empty());
874 assert!(state.search_selected.is_none());
875 }
876
877 #[test]
878 fn context_menu_variants_exist() {
879 use crate::state::ContextMenu;
881
882 let _branch = ContextMenu::Branch {
883 name: "main".to_string(),
884 is_current: true,
885 local_index: 0,
886 };
887 let _remote = ContextMenu::RemoteBranch {
888 name: "origin/main".to_string(),
889 };
890 let _commit = ContextMenu::Commit {
891 index: 0,
892 oid: "abc1234".to_string(),
893 };
894 let _stash = ContextMenu::Stash { index: 0 };
895 let _unstaged = ContextMenu::UnstagedFile {
896 path: "src/main.rs".to_string(),
897 };
898 let _staged = ContextMenu::StagedFile {
899 path: "src/lib.rs".to_string(),
900 };
901 }
902
903 #[test]
904 fn repo_tab_context_menu_defaults_to_none() {
905 let tab = crate::state::RepoTab::new_empty();
906 assert!(tab.context_menu.is_none());
907 }
908
909 #[test]
910 fn context_menu_variants_constructable() {
911 use crate::state::ContextMenu;
912 let _ = ContextMenu::Stash { index: 0 };
913 let _ = ContextMenu::UnstagedFile {
914 path: "a.rs".into(),
915 };
916 let _ = ContextMenu::StagedFile {
917 path: "b.rs".into(),
918 };
919 }
920
921 #[test]
922 fn selected_unstaged_defaults_empty() {
923 let tab = crate::state::RepoTab::new_empty();
924 assert!(tab.selected_unstaged.is_empty());
925 assert!(tab.selected_staged.is_empty());
926 }
927
928 #[test]
929 fn selected_unstaged_toggle() {
930 let mut tab = crate::state::RepoTab::new_empty();
931 tab.selected_unstaged.insert("a.rs".to_string());
932 tab.selected_unstaged.insert("b.rs".to_string());
933 assert_eq!(tab.selected_unstaged.len(), 2);
934 assert!(tab.selected_unstaged.contains("a.rs"));
935 tab.selected_unstaged.remove("a.rs");
936 assert_eq!(tab.selected_unstaged.len(), 1);
937 assert!(!tab.selected_unstaged.contains("a.rs"));
938 }
939
940 #[test]
941 fn detect_system_editor_returns_valid() {
942 let editor = super::detect_system_editor();
944 let _ = editor.display_name();
945 }
946
947 #[test]
950 fn selected_commit_file_indices_defaults_to_empty_vec() {
951 let tab = RepoTab::new_empty();
952 assert!(tab.selected_commit_file_indices.is_empty());
953 let v: &Vec<usize> = &tab.selected_commit_file_indices;
955 assert_eq!(v.len(), 0);
956 }
957
958 #[test]
959 fn multi_file_diffs_defaults_empty() {
960 let tab = RepoTab::new_empty();
961 assert!(tab.multi_file_diffs.is_empty());
962 }
963
964 #[test]
965 fn keyboard_modifiers_default_has_no_shift() {
966 let state = GitKraft::new();
967 assert!(!state.keyboard_modifiers.shift());
968 }
969
970 #[test]
971 fn selected_commit_file_indices_preserves_insertion_order() {
972 let mut tab = RepoTab::new_empty();
973 tab.selected_commit_file_indices.push(5);
974 tab.selected_commit_file_indices.push(2);
975 tab.selected_commit_file_indices.push(8);
976 assert_eq!(tab.selected_commit_file_indices, vec![5, 2, 8]);
977 }
978
979 #[test]
980 fn selected_commit_file_indices_cleared_on_reset() {
981 let mut tab = RepoTab::new_empty();
982 tab.selected_commit_file_indices.push(0);
983 tab.selected_commit_file_indices.push(1);
984 tab.selected_commit_file_indices.clear();
985 assert!(tab.selected_commit_file_indices.is_empty());
986 }
987
988 #[test]
989 fn multi_file_diffs_cleared_on_reset() {
990 let mut tab = RepoTab::new_empty();
991 tab.multi_file_diffs.push(gitkraft_core::DiffInfo {
992 old_file: String::new(),
993 new_file: "a.rs".to_string(),
994 status: gitkraft_core::FileStatus::Modified,
995 hunks: vec![],
996 });
997 tab.multi_file_diffs.clear();
998 assert!(tab.multi_file_diffs.is_empty());
999 }
1000
1001 #[test]
1002 fn commit_range_diffs_defaults_empty() {
1003 let tab = RepoTab::new_empty();
1004 assert!(tab.commit_range_diffs.is_empty());
1005 }
1006
1007 #[test]
1008 fn commit_range_diffs_cleared_on_apply_payload() {
1009 let mut tab = RepoTab::new_empty();
1011 tab.commit_range_diffs.push(gitkraft_core::DiffInfo {
1012 old_file: String::new(),
1013 new_file: "x.rs".to_string(),
1014 status: gitkraft_core::FileStatus::Modified,
1015 hunks: vec![],
1016 });
1017 tab.commit_range_diffs.clear();
1018 assert!(tab.commit_range_diffs.is_empty());
1019 }
1020
1021 #[test]
1024 fn modifiers_changed_sets_shift_state() {
1025 use crate::message::Message;
1026 let mut state = GitKraft::new();
1027 assert!(!state.keyboard_modifiers.shift());
1028
1029 let _ = state.update(Message::ModifiersChanged(iced::keyboard::Modifiers::SHIFT));
1030 assert!(state.keyboard_modifiers.shift());
1031
1032 let _ = state.update(Message::ModifiersChanged(
1033 iced::keyboard::Modifiers::default(),
1034 ));
1035 assert!(!state.keyboard_modifiers.shift());
1036 }
1037
1038 fn make_commit_files(names: &[&str]) -> Vec<gitkraft_core::DiffFileEntry> {
1041 names
1042 .iter()
1043 .map(|name| gitkraft_core::DiffFileEntry {
1044 old_file: String::new(),
1045 new_file: name.to_string(),
1046 status: gitkraft_core::FileStatus::Modified,
1047 })
1048 .collect()
1049 }
1050
1051 #[test]
1052 fn select_diff_by_index_regular_click_clears_multi_selection() {
1053 use crate::message::Message;
1054 let mut state = GitKraft::new();
1055 state.active_tab_mut().repo_path =
1059 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1060 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1061 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1062 state.active_tab_mut().selected_commit_file_indices = vec![0, 1];
1064
1065 let _ = state.update(Message::SelectDiffByIndex(0));
1067
1068 assert!(state.active_tab().selected_commit_file_indices.is_empty());
1069 assert!(state.active_tab().multi_file_diffs.is_empty());
1070 assert_eq!(state.active_tab().selected_file_index, Some(0));
1071 }
1072
1073 #[test]
1074 fn select_diff_by_index_shift_click_adds_both_files_to_selection() {
1075 use crate::message::Message;
1076 let mut state = GitKraft::new();
1077 state.active_tab_mut().repo_path =
1078 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1079 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1080 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1081 state.active_tab_mut().selected_file_index = Some(0);
1082
1083 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1085 let _ = state.update(Message::SelectDiffByIndex(1));
1086
1087 let sel = &state.active_tab().selected_commit_file_indices;
1088 assert!(sel.contains(&0), "anchor file 0 should be selected");
1089 assert!(sel.contains(&1), "newly clicked file 1 should be selected");
1090 assert_eq!(sel.len(), 2);
1091 }
1092
1093 #[test]
1094 fn anchor_file_index_defaults_to_none() {
1095 let tab = RepoTab::new_empty();
1096 assert!(tab.anchor_file_index.is_none());
1097 }
1098
1099 #[test]
1100 fn regular_click_sets_anchor() {
1101 use crate::message::Message;
1102 let mut state = GitKraft::new();
1103 state.active_tab_mut().repo_path =
1104 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1105 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1106 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1107
1108 let _ = state.update(Message::SelectDiffByIndex(2));
1109
1110 assert_eq!(
1111 state.active_tab().anchor_file_index,
1112 Some(2),
1113 "regular click must set anchor to the clicked index"
1114 );
1115 }
1116
1117 #[test]
1118 fn shift_click_selects_range_downward_from_anchor() {
1119 use crate::message::Message;
1120 let mut state = GitKraft::new();
1121 state.active_tab_mut().repo_path =
1122 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1123 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1124 state.active_tab_mut().commit_files =
1125 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1126 state.active_tab_mut().anchor_file_index = Some(1);
1128 state.active_tab_mut().selected_file_index = Some(1);
1129
1130 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1132 let _ = state.update(Message::SelectDiffByIndex(4));
1133
1134 let sel = &state.active_tab().selected_commit_file_indices;
1135 assert_eq!(
1136 sel,
1137 &vec![1, 2, 3, 4],
1138 "range must be contiguous from anchor to click"
1139 );
1140 }
1141
1142 #[test]
1143 fn shift_click_selects_range_upward_from_anchor() {
1144 use crate::message::Message;
1145 let mut state = GitKraft::new();
1146 state.active_tab_mut().repo_path =
1147 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1148 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1149 state.active_tab_mut().commit_files =
1150 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1151 state.active_tab_mut().anchor_file_index = Some(4);
1153 state.active_tab_mut().selected_file_index = Some(4);
1154
1155 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1157 let _ = state.update(Message::SelectDiffByIndex(1));
1158
1159 let sel = &state.active_tab().selected_commit_file_indices;
1160 assert_eq!(
1161 sel,
1162 &vec![1, 2, 3, 4],
1163 "range must be stored ascending regardless of click direction"
1164 );
1165 }
1166
1167 #[test]
1168 fn shift_click_anchor_fixed_on_subsequent_clicks() {
1169 use crate::message::Message;
1170 let mut state = GitKraft::new();
1171 state.active_tab_mut().repo_path =
1172 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1173 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1174 state.active_tab_mut().commit_files =
1175 make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1176 state.active_tab_mut().anchor_file_index = Some(2);
1178 state.active_tab_mut().selected_file_index = Some(2);
1179 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1180
1181 let _ = state.update(Message::SelectDiffByIndex(4));
1183 assert_eq!(
1184 state.active_tab().selected_commit_file_indices,
1185 vec![2, 3, 4]
1186 );
1187
1188 let _ = state.update(Message::SelectDiffByIndex(3));
1190 assert_eq!(
1191 state.active_tab().selected_commit_file_indices,
1192 vec![2, 3],
1193 "anchor must stay fixed; second Shift+Click shrinks the range"
1194 );
1195
1196 let _ = state.update(Message::SelectDiffByIndex(0));
1198 assert_eq!(
1199 state.active_tab().selected_commit_file_indices,
1200 vec![0, 1, 2],
1201 "anchor must stay fixed; can extend range in either direction"
1202 );
1203 }
1204
1205 #[test]
1206 fn shift_click_on_anchor_itself_gives_single_item_range() {
1207 use crate::message::Message;
1208 let mut state = GitKraft::new();
1209 state.active_tab_mut().repo_path =
1210 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1211 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1212 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1213 state.active_tab_mut().anchor_file_index = Some(1);
1214 state.active_tab_mut().selected_file_index = Some(1);
1215
1216 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1218 let _ = state.update(Message::SelectDiffByIndex(1));
1219
1220 assert_eq!(state.active_tab().selected_commit_file_indices, vec![1]);
1221 assert!(
1222 state.active_tab().multi_file_diffs.is_empty(),
1223 "single-item range must not populate multi_file_diffs"
1224 );
1225 }
1226
1227 #[test]
1228 fn shift_click_range_is_always_ascending() {
1229 use crate::message::Message;
1230 let mut state = GitKraft::new();
1231 state.active_tab_mut().repo_path =
1232 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1233 state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1234 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs"]);
1235 state.active_tab_mut().anchor_file_index = Some(3);
1236 state.active_tab_mut().selected_file_index = Some(3);
1237
1238 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1239 let _ = state.update(Message::SelectDiffByIndex(0));
1240
1241 let sel = &state.active_tab().selected_commit_file_indices;
1242 let is_sorted = sel.windows(2).all(|w| w[0] < w[1]);
1243 assert!(
1244 is_sorted,
1245 "selection must always be stored in ascending order"
1246 );
1247 assert_eq!(sel, &vec![0, 1, 2, 3]);
1248 }
1249
1250 #[test]
1251 fn checkout_file_at_commit_message_variants_exist() {
1252 use crate::message::Message;
1253 let _single =
1255 Message::CheckoutFileAtCommit("abc123".to_string(), "src/main.rs".to_string());
1256 let _multi = Message::CheckoutMultiFilesAtCommit(
1257 "abc123".to_string(),
1258 vec!["a.rs".to_string(), "b.rs".to_string()],
1259 );
1260 }
1261
1262 #[test]
1263 fn checkout_file_at_commit_closes_context_menu() {
1264 use crate::message::Message;
1265 let mut state = GitKraft::new();
1266 state.active_tab_mut().repo_path =
1267 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1268 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1269 oid: "abc123".to_string(),
1270 file_path: "src/main.rs".to_string(),
1271 });
1272 let _ = state.update(Message::CheckoutFileAtCommit(
1273 "abc123".to_string(),
1274 "src/main.rs".to_string(),
1275 ));
1276 assert!(state.active_tab().context_menu.is_none());
1277 }
1278
1279 #[test]
1280 fn checkout_multi_files_at_commit_closes_context_menu() {
1281 use crate::message::Message;
1282 let mut state = GitKraft::new();
1283 state.active_tab_mut().repo_path =
1284 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1285 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1286 oid: "abc123".to_string(),
1287 file_path: "src/main.rs".to_string(),
1288 });
1289 let _ = state.update(Message::CheckoutMultiFilesAtCommit(
1290 "abc123".to_string(),
1291 vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
1292 ));
1293 assert!(state.active_tab().context_menu.is_none());
1294 }
1295
1296 fn make_test_commits(count: usize) -> Vec<gitkraft_core::CommitInfo> {
1299 (0..count)
1300 .map(|i| gitkraft_core::CommitInfo {
1301 oid: i.to_string(),
1302 short_oid: i.to_string(),
1303 summary: String::new(),
1304 message: String::new(),
1305 author_name: String::new(),
1306 author_email: String::new(),
1307 time: Default::default(),
1308 parent_ids: Vec::new(),
1309 })
1310 .collect()
1311 }
1312
1313 #[test]
1314 fn selected_commits_defaults_empty() {
1315 let tab = RepoTab::new_empty();
1316 assert!(tab.selected_commits.is_empty());
1317 assert!(tab.anchor_commit_index.is_none());
1318 }
1319
1320 #[test]
1321 fn regular_click_commit_sets_anchor_and_clears_range() {
1322 use crate::message::Message;
1323 let mut state = GitKraft::new();
1324 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
1325 state.active_tab_mut().commits = make_test_commits(3);
1326 state.active_tab_mut().selected_commits = vec![0, 1, 2];
1327
1328 let _ = state.update(Message::SelectCommit(1));
1329
1330 assert_eq!(state.active_tab().anchor_commit_index, Some(1));
1331 assert!(state.active_tab().selected_commits.is_empty());
1332 assert_eq!(state.active_tab().selected_commit, Some(1));
1333 }
1334
1335 #[test]
1336 fn shift_click_commit_selects_range_from_anchor() {
1337 use crate::message::Message;
1338 let mut state = GitKraft::new();
1339 state.active_tab_mut().commits = make_test_commits(5);
1340 state.active_tab_mut().anchor_commit_index = Some(1);
1341 state.active_tab_mut().selected_commit = Some(1);
1342
1343 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1344 let _ = state.update(Message::SelectCommit(4));
1345
1346 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3, 4]);
1347 }
1348
1349 #[test]
1350 fn shift_click_commit_range_is_ascending_when_clicking_above_anchor() {
1351 use crate::message::Message;
1352 let mut state = GitKraft::new();
1353 state.active_tab_mut().commits = make_test_commits(5);
1354 state.active_tab_mut().anchor_commit_index = Some(3);
1355 state.active_tab_mut().selected_commit = Some(3);
1356
1357 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1358 let _ = state.update(Message::SelectCommit(1));
1359
1360 assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3]);
1361 }
1362
1363 #[test]
1366 fn execute_commit_action_closes_context_menu() {
1367 use crate::message::Message;
1368 let mut state = GitKraft::new();
1369 state.active_tab_mut().repo_path =
1370 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1371 state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::Commit {
1372 index: 0,
1373 oid: "abc123".to_string(),
1374 });
1375
1376 let _ = state.update(Message::ExecuteCommitAction(
1377 "abc123".to_string(),
1378 gitkraft_core::CommitAction::CherryPick,
1379 ));
1380
1381 assert!(state.active_tab().context_menu.is_none());
1382 }
1383
1384 #[test]
1385 fn execute_commit_action_sets_loading_when_repo_open() {
1386 use crate::message::Message;
1387 let mut state = GitKraft::new();
1388 state.active_tab_mut().repo_path =
1389 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1390
1391 let _ = state.update(Message::ExecuteCommitAction(
1392 "abc123".to_string(),
1393 gitkraft_core::CommitAction::ResetHard,
1394 ));
1395
1396 assert!(state.active_tab().is_loading);
1397 }
1398
1399 #[test]
1400 fn execute_commit_action_no_repo_does_not_set_loading() {
1401 use crate::message::Message;
1402 let mut state = GitKraft::new();
1403 let _ = state.update(Message::ExecuteCommitAction(
1406 "abc123".to_string(),
1407 gitkraft_core::CommitAction::CherryPick,
1408 ));
1409
1410 assert!(!state.active_tab().is_loading);
1411 }
1412
1413 #[test]
1414 fn execute_commit_action_sets_status_message_from_action_label() {
1415 use crate::message::Message;
1416 let mut state = GitKraft::new();
1417 state.active_tab_mut().repo_path =
1418 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1419
1420 let _ = state.update(Message::ExecuteCommitAction(
1421 "abc123".to_string(),
1422 gitkraft_core::CommitAction::Revert,
1423 ));
1424
1425 let status = state.active_tab().status_message.as_deref().unwrap_or("");
1426 assert!(
1428 status.contains("Revert commit"),
1429 "expected status to contain 'Revert commit', got: {status:?}"
1430 );
1431 }
1432
1433 #[test]
1436 fn file_history_defaults_empty() {
1437 let tab = RepoTab::new_empty();
1438 assert!(tab.file_history_path.is_none());
1439 assert!(tab.file_history_commits.is_empty());
1440 assert_eq!(tab.file_history_scroll, 0.0);
1441 }
1442
1443 #[test]
1444 fn blame_defaults_empty() {
1445 let tab = RepoTab::new_empty();
1446 assert!(tab.blame_path.is_none());
1447 assert!(tab.blame_lines.is_empty());
1448 assert_eq!(tab.blame_scroll, 0.0);
1449 }
1450
1451 #[test]
1452 fn pending_delete_file_defaults_none() {
1453 let tab = RepoTab::new_empty();
1454 assert!(tab.pending_delete_file.is_none());
1455 }
1456
1457 #[test]
1458 fn view_file_history_sets_path_and_clears_blame() {
1459 use crate::message::Message;
1460 let mut state = GitKraft::new();
1461 state.active_tab_mut().repo_path =
1462 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1463 state.active_tab_mut().blame_path = Some("some/file.rs".to_string());
1464
1465 let _ = state.update(Message::ViewFileHistory("src/main.rs".to_string()));
1466
1467 assert_eq!(
1468 state.active_tab().file_history_path.as_deref(),
1469 Some("src/main.rs")
1470 );
1471 assert!(state.active_tab().blame_path.is_none());
1473 }
1474
1475 #[test]
1476 fn close_file_history_clears_state() {
1477 use crate::message::Message;
1478 let mut state = GitKraft::new();
1479 state.active_tab_mut().file_history_path = Some("src/lib.rs".to_string());
1480 state.active_tab_mut().file_history_commits = vec![gitkraft_core::CommitInfo {
1481 oid: "abc".to_string(),
1482 short_oid: "abc".to_string(),
1483 summary: "s".to_string(),
1484 message: "s".to_string(),
1485 author_name: "a".to_string(),
1486 author_email: "a@b.com".to_string(),
1487 time: Default::default(),
1488 parent_ids: vec![],
1489 }];
1490
1491 let _ = state.update(Message::CloseFileHistory);
1492
1493 assert!(state.active_tab().file_history_path.is_none());
1494 assert!(state.active_tab().file_history_commits.is_empty());
1495 }
1496
1497 #[test]
1498 fn view_file_blame_sets_path_and_clears_history() {
1499 use crate::message::Message;
1500 let mut state = GitKraft::new();
1501 state.active_tab_mut().repo_path =
1502 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1503 state.active_tab_mut().file_history_path = Some("some/file.rs".to_string());
1504
1505 let _ = state.update(Message::ViewFileBlame("src/lib.rs".to_string()));
1506
1507 assert_eq!(state.active_tab().blame_path.as_deref(), Some("src/lib.rs"));
1508 assert!(state.active_tab().file_history_path.is_none());
1510 }
1511
1512 #[test]
1513 fn selecting_new_commit_closes_blame_overlay() {
1514 use crate::message::Message;
1515 let mut state = GitKraft::new();
1516 state.active_tab_mut().repo_path =
1517 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1518 state.active_tab_mut().commits = vec![
1520 gitkraft_core::CommitInfo {
1521 oid: "abc1".into(),
1522 short_oid: "abc1".into(),
1523 summary: "first".into(),
1524 message: "first".into(),
1525 author_name: "A".into(),
1526 author_email: "a@a.com".into(),
1527 time: Default::default(),
1528 parent_ids: Vec::new(),
1529 },
1530 gitkraft_core::CommitInfo {
1531 oid: "abc2".into(),
1532 short_oid: "abc2".into(),
1533 summary: "second".into(),
1534 message: "second".into(),
1535 author_name: "A".into(),
1536 author_email: "a@a.com".into(),
1537 time: Default::default(),
1538 parent_ids: Vec::new(),
1539 },
1540 ];
1541 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1543 state.active_tab_mut().blame_lines = vec![gitkraft_core::BlameLine {
1544 line_number: 1,
1545 content: "fn main() {}".into(),
1546 short_oid: "abc1".into(),
1547 oid: "abc1".into(),
1548 author_name: "A".into(),
1549 time: Default::default(),
1550 }];
1551
1552 let _ = state.update(Message::SelectCommit(1));
1554
1555 assert!(
1556 state.active_tab().blame_path.is_none(),
1557 "blame_path must be cleared when a new commit is selected"
1558 );
1559 assert!(
1560 state.active_tab().blame_lines.is_empty(),
1561 "blame_lines must be cleared when a new commit is selected"
1562 );
1563 }
1564
1565 #[test]
1566 fn close_file_blame_clears_state() {
1567 use crate::message::Message;
1568 let mut state = GitKraft::new();
1569 state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1570
1571 let _ = state.update(Message::CloseFileBlame);
1572
1573 assert!(state.active_tab().blame_path.is_none());
1574 assert!(state.active_tab().blame_lines.is_empty());
1575 }
1576
1577 #[test]
1578 fn delete_file_sets_pending() {
1579 use crate::message::Message;
1580 let mut state = GitKraft::new();
1581
1582 let _ = state.update(Message::DeleteFile("src/old.rs".to_string()));
1583
1584 assert_eq!(
1585 state.active_tab().pending_delete_file.as_deref(),
1586 Some("src/old.rs")
1587 );
1588 assert!(state.active_tab().context_menu.is_none());
1589 }
1590
1591 #[test]
1592 fn cancel_delete_file_clears_pending() {
1593 use crate::message::Message;
1594 let mut state = GitKraft::new();
1595 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1596
1597 let _ = state.update(Message::CancelDeleteFile);
1598
1599 assert!(state.active_tab().pending_delete_file.is_none());
1600 }
1601
1602 #[test]
1603 fn confirm_delete_file_no_repo_is_noop() {
1604 use crate::message::Message;
1605 let mut state = GitKraft::new();
1606 state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1607 let _ = state.update(Message::ConfirmDeleteFile);
1610
1611 assert!(!state.active_tab().is_loading);
1612 }
1613
1614 #[test]
1615 fn shift_arrow_down_extends_file_list_selection_when_files_loaded() {
1616 use crate::message::Message;
1617 let mut state = GitKraft::new();
1618 state.active_tab_mut().repo_path =
1619 Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1620 state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1621 state.active_tab_mut().selected_file_index = Some(0);
1622 state.active_tab_mut().anchor_file_index = Some(0);
1623 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1625
1626 let _ = state.update(Message::ShiftArrowDown);
1627
1628 assert_eq!(state.active_tab().selected_file_index, Some(1));
1629 assert!(state.active_tab().selected_commit_file_indices.contains(&0));
1631 assert!(state.active_tab().selected_commit_file_indices.contains(&1));
1632 }
1633
1634 #[test]
1635 fn shift_arrow_down_falls_through_to_commit_log_when_no_files() {
1636 use crate::message::Message;
1637 let mut state = GitKraft::new();
1638 state.active_tab_mut().commits = make_test_commits(5);
1639 state.active_tab_mut().selected_commit = Some(1);
1640 state.active_tab_mut().anchor_commit_index = Some(1);
1641 state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1642 let _ = state.update(Message::ShiftArrowDown);
1645
1646 assert_eq!(state.active_tab().selected_commit, Some(2));
1647 assert!(state.active_tab().selected_commits.contains(&1));
1648 assert!(state.active_tab().selected_commits.contains(&2));
1649 }
1650}