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 graph_rows: Vec<gitkraft_core::GraphRow>,
79
80 pub unstaged_changes: Vec<DiffInfo>,
83 pub staged_changes: Vec<DiffInfo>,
85 pub commit_files: Vec<gitkraft_core::DiffFileEntry>,
87 pub selected_commit_oid: Option<String>,
89 pub selected_file_index: Option<usize>,
91 pub is_loading_file_diff: bool,
93 pub selected_diff: Option<DiffInfo>,
95 pub commit_message: String,
97
98 pub stashes: Vec<StashEntry>,
101
102 pub remotes: Vec<RemoteInfo>,
105
106 pub show_commit_detail: bool,
109 pub new_branch_name: String,
111 pub show_branch_create: bool,
113 pub local_branches_expanded: bool,
115 pub remote_branches_expanded: bool,
117 pub stash_message: String,
119
120 pub selected_unstaged: std::collections::HashSet<String>,
122 pub selected_staged: std::collections::HashSet<String>,
124
125 pub pending_discard: Option<String>,
127
128 pub status_message: Option<String>,
131 pub error_message: Option<String>,
133 pub is_loading: bool,
135 pub context_menu_pos: (f32, f32),
138
139 pub context_menu: Option<ContextMenu>,
141 pub rename_branch_target: Option<String>,
143 pub rename_branch_input: String,
145
146 pub create_tag_target_oid: Option<String>,
148 pub create_tag_annotated: bool,
150 pub create_tag_name: String,
152 pub create_tag_message: String,
154
155 pub commit_scroll_offset: f32,
159
160 pub diff_scroll_offset: f32,
162 pub commit_display: Vec<(String, String, String)>,
166
167 pub has_more_commits: bool,
169 pub is_loading_more_commits: bool,
171}
172
173impl RepoTab {
174 pub fn new_empty() -> Self {
176 Self {
177 repo_path: None,
178 repo_info: None,
179 branches: Vec::new(),
180 current_branch: None,
181 commits: Vec::new(),
182 selected_commit: None,
183 graph_rows: Vec::new(),
184 unstaged_changes: Vec::new(),
185 staged_changes: Vec::new(),
186 commit_files: Vec::new(),
187 selected_commit_oid: None,
188 selected_file_index: None,
189 is_loading_file_diff: false,
190 selected_diff: None,
191 commit_message: String::new(),
192 stashes: Vec::new(),
193 remotes: Vec::new(),
194 show_commit_detail: false,
195 new_branch_name: String::new(),
196 show_branch_create: false,
197 local_branches_expanded: true,
198 remote_branches_expanded: true,
199 stash_message: String::new(),
200 selected_unstaged: std::collections::HashSet::new(),
201 selected_staged: std::collections::HashSet::new(),
202 pending_discard: None,
203 status_message: None,
204 error_message: None,
205 is_loading: false,
206 context_menu: None,
207 context_menu_pos: (0.0, 0.0),
208 rename_branch_target: None,
209 rename_branch_input: String::new(),
210 create_tag_target_oid: None,
211 create_tag_annotated: false,
212 create_tag_name: String::new(),
213 create_tag_message: String::new(),
214 commit_scroll_offset: 0.0,
215 diff_scroll_offset: 0.0,
216 commit_display: Vec::new(),
217 has_more_commits: true,
218 is_loading_more_commits: false,
219 }
220 }
221
222 pub fn has_repo(&self) -> bool {
224 self.repo_path.is_some()
225 }
226
227 pub fn display_name(&self) -> &str {
229 self.repo_path
230 .as_ref()
231 .and_then(|p| p.file_name())
232 .and_then(|n| n.to_str())
233 .unwrap_or("New Tab")
234 }
235
236 pub fn apply_payload(
238 &mut self,
239 payload: crate::message::RepoPayload,
240 path: std::path::PathBuf,
241 ) {
242 self.current_branch = payload.info.head_branch.clone();
243 self.repo_path = Some(path);
244 self.repo_info = Some(payload.info);
245 self.branches = payload.branches;
246 self.commits = payload.commits;
247 self.graph_rows = payload.graph_rows;
248 self.unstaged_changes = payload.unstaged;
249 self.staged_changes = payload.staged;
250 self.stashes = payload.stashes;
251 self.remotes = payload.remotes;
252
253 self.selected_commit = None;
255 self.selected_diff = None;
256 self.commit_files.clear();
257 self.selected_commit_oid = None;
258 self.selected_file_index = None;
259 self.is_loading_file_diff = false;
260 self.commit_message.clear();
261 self.error_message = None;
262 self.status_message = Some("Repository loaded.".into());
263 self.commit_scroll_offset = 0.0;
264 self.diff_scroll_offset = 0.0;
265 self.has_more_commits = true;
266 self.is_loading_more_commits = false;
267 self.selected_unstaged.clear();
268 self.selected_staged.clear();
269 }
270}
271
272pub struct GitKraft {
276 pub tabs: Vec<RepoTab>,
279 pub active_tab: usize,
281
282 pub sidebar_expanded: bool,
285
286 pub sidebar_width: f32,
289 pub commit_log_width: f32,
291 pub staging_height: f32,
293 pub diff_file_list_width: f32,
295
296 pub ui_scale: f32,
298
299 pub dragging: Option<DragTarget>,
302 pub dragging_h: Option<DragTargetH>,
304 pub drag_start_x: f32,
306 pub drag_start_y: f32,
308 pub drag_initialized: bool,
312 pub drag_initialized_h: bool,
314
315 pub cursor_pos: Point,
320
321 pub current_theme_index: usize,
324
325 pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
328
329 pub search_visible: bool,
332 pub search_query: String,
334 pub search_results: Vec<gitkraft_core::CommitInfo>,
336 pub search_selected: Option<usize>,
338
339 pub search_diff_files: Vec<gitkraft_core::DiffFileEntry>,
341 pub search_diff_selected: HashSet<usize>,
343 pub search_diff_content: Vec<gitkraft_core::DiffInfo>,
345 pub search_diff_oid: Option<String>,
347
348 pub editor: gitkraft_core::Editor,
350}
351
352impl Default for GitKraft {
353 fn default() -> Self {
354 Self::new()
355 }
356}
357
358impl GitKraft {
359 fn from_settings(settings: gitkraft_core::AppSettings) -> Self {
365 let current_theme_index = settings
366 .theme_name
367 .as_deref()
368 .map(gitkraft_core::theme_index_by_name)
369 .unwrap_or(0);
370
371 let recent_repos = settings.recent_repos;
372
373 let (
374 sidebar_width,
375 commit_log_width,
376 staging_height,
377 diff_file_list_width,
378 sidebar_expanded,
379 ui_scale,
380 ) = if let Some(ref layout) = settings.layout {
381 (
382 layout.sidebar_width.unwrap_or(220.0),
383 layout.commit_log_width.unwrap_or(500.0),
384 layout.staging_height.unwrap_or(200.0),
385 layout.diff_file_list_width.unwrap_or(180.0),
386 layout.sidebar_expanded.unwrap_or(true),
387 layout.ui_scale.unwrap_or(1.0),
388 )
389 } else {
390 (220.0, 500.0, 200.0, 180.0, true, 1.0)
391 };
392
393 Self {
394 tabs: vec![RepoTab::new_empty()],
395 active_tab: 0,
396
397 sidebar_expanded,
398
399 sidebar_width,
400 commit_log_width,
401 staging_height,
402 diff_file_list_width,
403
404 ui_scale,
405
406 dragging: None,
407 dragging_h: None,
408 drag_start_x: 0.0,
409 drag_start_y: 0.0,
410 drag_initialized: false,
411 drag_initialized_h: false,
412 cursor_pos: Point::ORIGIN,
413
414 current_theme_index,
415
416 recent_repos,
417
418 search_visible: false,
419 search_query: String::new(),
420 search_results: Vec::new(),
421 search_selected: None,
422 search_diff_files: Vec::new(),
423 search_diff_selected: HashSet::new(),
424 search_diff_content: Vec::new(),
425 search_diff_oid: None,
426
427 editor: settings
428 .editor_name
429 .as_deref()
430 .map(|name| {
431 gitkraft_core::EDITOR_NAMES
433 .iter()
434 .position(|n| n.eq_ignore_ascii_case(name))
435 .map(gitkraft_core::Editor::from_index)
436 .unwrap_or_else(|| {
437 if name.eq_ignore_ascii_case("none") {
438 gitkraft_core::Editor::None
439 } else {
440 gitkraft_core::Editor::Custom(name.to_string())
441 }
442 })
443 })
444 .unwrap_or_else(detect_system_editor),
445 }
446 }
447
448 pub fn new() -> Self {
454 Self::from_settings(
455 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default(),
456 )
457 }
458
459 pub fn new_with_session_paths() -> (Self, Vec<PathBuf>) {
465 let settings =
466 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default();
467 let open_tabs = settings.open_tabs.clone();
468 let active_tab_index = settings.active_tab_index;
469
470 let mut state = Self::from_settings(settings);
471
472 if !open_tabs.is_empty() {
473 state.tabs = open_tabs
474 .iter()
475 .map(|path| {
476 let mut tab = RepoTab::new_empty();
477 tab.repo_path = Some(path.clone());
480 if path.exists() {
481 tab.is_loading = true;
482 tab.status_message = Some(format!(
483 "Loading {}…",
484 path.file_name().unwrap_or_default().to_string_lossy()
485 ));
486 } else {
487 tab.error_message =
488 Some(format!("Repository not found: {}", path.display()));
489 }
490 tab
491 })
492 .collect();
493 state.active_tab = active_tab_index.min(state.tabs.len().saturating_sub(1));
494 }
495
496 (state, open_tabs)
497 }
498
499 pub fn open_tab_paths(&self) -> Vec<PathBuf> {
502 self.tabs
503 .iter()
504 .filter(|t| t.repo_info.is_some())
505 .filter_map(|t| t.repo_path.clone())
506 .collect()
507 }
508
509 pub fn active_tab(&self) -> &RepoTab {
511 &self.tabs[self.active_tab]
512 }
513
514 pub fn active_tab_mut(&mut self) -> &mut RepoTab {
516 &mut self.tabs[self.active_tab]
517 }
518
519 pub fn has_repo(&self) -> bool {
521 self.active_tab().has_repo()
522 }
523
524 pub fn repo_display_name(&self) -> &str {
526 self.active_tab().display_name()
527 }
528
529 pub fn colors(&self) -> ThemeColors {
536 ThemeColors::from_core(&gitkraft_core::theme_by_index(self.current_theme_index))
537 }
538
539 pub fn iced_theme(&self) -> iced::Theme {
548 let core = gitkraft_core::theme_by_index(self.current_theme_index);
549 let name = self.current_theme_name().to_string();
550
551 let palette = iced::theme::Palette {
552 background: rgb_to_iced(core.background),
553 text: rgb_to_iced(core.text_primary),
554 primary: rgb_to_iced(core.accent),
555 success: rgb_to_iced(core.success),
556 warning: rgb_to_iced(core.warning),
557 danger: rgb_to_iced(core.error),
558 };
559
560 iced::Theme::custom(name, palette)
561 }
562
563 pub fn current_theme_name(&self) -> &'static str {
565 gitkraft_core::THEME_NAMES
566 .get(self.current_theme_index)
567 .copied()
568 .unwrap_or("Default")
569 }
570
571 pub fn refresh_active_tab(&mut self) -> Task<Message> {
575 match self.active_tab().repo_path.clone() {
576 Some(path) => crate::features::repo::commands::refresh_repo(path),
577 None => Task::none(),
578 }
579 }
580
581 pub fn on_ok_refresh(
588 &mut self,
589 result: Result<(), String>,
590 ok_msg: &str,
591 err_prefix: &str,
592 ) -> Task<Message> {
593 match result {
594 Ok(()) => {
595 {
596 let tab = self.active_tab_mut();
597 tab.is_loading = false;
598 tab.status_message = Some(ok_msg.to_string());
599 }
600 self.refresh_active_tab()
601 }
602 Err(e) => {
603 let tab = self.active_tab_mut();
604 tab.is_loading = false;
605 tab.error_message = Some(format!("{err_prefix}: {e}"));
606 tab.status_message = None;
607 Task::none()
608 }
609 }
610 }
611
612 pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
614 gitkraft_core::LayoutSettings {
615 sidebar_width: Some(self.sidebar_width),
616 commit_log_width: Some(self.commit_log_width),
617 staging_height: Some(self.staging_height),
618 diff_file_list_width: Some(self.diff_file_list_width),
619 sidebar_expanded: Some(self.sidebar_expanded),
620 ui_scale: Some(self.ui_scale),
621 }
622 }
623}
624
625fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
627 Color::from_rgb8(rgb.r, rgb.g, rgb.b)
628}
629
630fn detect_system_editor() -> gitkraft_core::Editor {
632 for var in ["VISUAL", "EDITOR"] {
633 if let Ok(val) = std::env::var(var) {
634 let bin = val.split('/').next_back().unwrap_or(&val).trim();
635 return match bin {
636 "nvim" | "neovim" => gitkraft_core::Editor::Neovim,
637 "vim" => gitkraft_core::Editor::Vim,
638 "hx" | "helix" => gitkraft_core::Editor::Helix,
639 "nano" => gitkraft_core::Editor::Nano,
640 "micro" => gitkraft_core::Editor::Micro,
641 "emacs" => gitkraft_core::Editor::Emacs,
642 "code" => gitkraft_core::Editor::VSCode,
643 "zed" => gitkraft_core::Editor::Zed,
644 "subl" => gitkraft_core::Editor::Sublime,
645 _ => gitkraft_core::Editor::Custom(val),
646 };
647 }
648 }
649 gitkraft_core::Editor::None
650}
651
652#[cfg(test)]
655mod tests {
656 use super::*;
657
658 #[test]
659 fn new_defaults() {
660 let state = GitKraft::new();
661 assert!(state.active_tab().repo_path.is_none());
662 assert!(!state.has_repo());
663 assert_eq!(state.repo_display_name(), "New Tab");
664 assert!(state.active_tab().commits.is_empty());
665 assert!(state.sidebar_expanded);
666 assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
668 assert!(state.sidebar_width > 0.0);
670 assert!(state.commit_log_width > 0.0);
671 assert!(state.staging_height > 0.0);
672 assert!(state.dragging.is_none());
673 assert!(state.dragging_h.is_none());
674 assert_eq!(state.tabs.len(), 1);
676 assert_eq!(state.active_tab, 0);
677 }
678
679 #[test]
680 fn repo_display_name_extracts_basename() {
681 let mut state = GitKraft::new();
682 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
683 assert_eq!(state.repo_display_name(), "my-project");
684 }
685
686 #[test]
687 fn colors_returns_theme_colors() {
688 let state = GitKraft::new();
689 let c = state.colors();
690 assert!(c.bg.r < 0.5);
692 }
693
694 #[test]
695 fn iced_theme_is_custom_with_correct_palette() {
696 let mut state = GitKraft::new();
697
698 state.current_theme_index = 0;
700 let iced_t = state.iced_theme();
701 let pal = iced_t.palette();
702 assert!(pal.background.r < 0.5, "Default theme bg should be dark");
703 assert_eq!(iced_t.to_string(), "Default");
704
705 state.current_theme_index = 11;
707 let iced_t = state.iced_theme();
708 let pal = iced_t.palette();
709 assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
710 assert_eq!(iced_t.to_string(), "Solarized Light");
711
712 state.current_theme_index = 12;
714 let iced_t = state.iced_theme();
715 let pal = iced_t.palette();
716 let core = gitkraft_core::theme_by_index(12);
717 let expected_accent = rgb_to_iced(core.accent);
718 assert!(
719 (pal.primary.r - expected_accent.r).abs() < 0.01
720 && (pal.primary.g - expected_accent.g).abs() < 0.01
721 && (pal.primary.b - expected_accent.b).abs() < 0.01,
722 "Gruvbox Dark accent should match core accent"
723 );
724 }
725
726 #[test]
727 fn iced_theme_name_round_trips_through_core() {
728 for i in 0..gitkraft_core::THEME_COUNT {
731 let mut state = GitKraft::new();
732 state.current_theme_index = i;
733 let iced_t = state.iced_theme();
734 let name = iced_t.to_string();
735 let resolved = gitkraft_core::theme_index_by_name(&name);
736 assert_eq!(
737 resolved,
738 i,
739 "theme index {i} ({}) did not round-trip through iced_theme name",
740 gitkraft_core::THEME_NAMES[i]
741 );
742 }
743 }
744
745 #[test]
746 fn current_theme_name_round_trips() {
747 let mut state = GitKraft::new();
748 state.current_theme_index = 8;
749 assert_eq!(state.current_theme_name(), "Dracula");
750 state.current_theme_index = 0;
751 assert_eq!(state.current_theme_name(), "Default");
752 }
753
754 #[test]
755 fn repo_tab_new_empty() {
756 let tab = RepoTab::new_empty();
757 assert!(tab.repo_path.is_none());
758 assert!(!tab.has_repo());
759 assert_eq!(tab.display_name(), "New Tab");
760 assert!(tab.commits.is_empty());
761 assert!(tab.branches.is_empty());
762 assert!(!tab.is_loading);
763 }
764
765 #[test]
766 fn repo_tab_display_name_with_path() {
767 let mut tab = RepoTab::new_empty();
768 tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
769 assert!(tab.has_repo());
770 assert_eq!(tab.display_name(), "cool-repo");
771 }
772
773 #[test]
774 fn search_defaults() {
775 let state = GitKraft::new();
776 assert!(!state.search_visible);
777 assert!(state.search_query.is_empty());
778 assert!(state.search_results.is_empty());
779 assert!(state.search_selected.is_none());
780 }
781
782 #[test]
783 fn context_menu_variants_exist() {
784 use crate::state::ContextMenu;
786
787 let _branch = ContextMenu::Branch {
788 name: "main".to_string(),
789 is_current: true,
790 local_index: 0,
791 };
792 let _remote = ContextMenu::RemoteBranch {
793 name: "origin/main".to_string(),
794 };
795 let _commit = ContextMenu::Commit {
796 index: 0,
797 oid: "abc1234".to_string(),
798 };
799 let _stash = ContextMenu::Stash { index: 0 };
800 let _unstaged = ContextMenu::UnstagedFile {
801 path: "src/main.rs".to_string(),
802 };
803 let _staged = ContextMenu::StagedFile {
804 path: "src/lib.rs".to_string(),
805 };
806 }
807
808 #[test]
809 fn repo_tab_context_menu_defaults_to_none() {
810 let tab = crate::state::RepoTab::new_empty();
811 assert!(tab.context_menu.is_none());
812 }
813
814 #[test]
815 fn context_menu_variants_constructable() {
816 use crate::state::ContextMenu;
817 let _ = ContextMenu::Stash { index: 0 };
818 let _ = ContextMenu::UnstagedFile {
819 path: "a.rs".into(),
820 };
821 let _ = ContextMenu::StagedFile {
822 path: "b.rs".into(),
823 };
824 }
825
826 #[test]
827 fn selected_unstaged_defaults_empty() {
828 let tab = crate::state::RepoTab::new_empty();
829 assert!(tab.selected_unstaged.is_empty());
830 assert!(tab.selected_staged.is_empty());
831 }
832
833 #[test]
834 fn selected_unstaged_toggle() {
835 let mut tab = crate::state::RepoTab::new_empty();
836 tab.selected_unstaged.insert("a.rs".to_string());
837 tab.selected_unstaged.insert("b.rs".to_string());
838 assert_eq!(tab.selected_unstaged.len(), 2);
839 assert!(tab.selected_unstaged.contains("a.rs"));
840 tab.selected_unstaged.remove("a.rs");
841 assert_eq!(tab.selected_unstaged.len(), 1);
842 assert!(!tab.selected_unstaged.contains("a.rs"));
843 }
844
845 #[test]
846 fn detect_system_editor_returns_valid() {
847 let editor = super::detect_system_editor();
849 let _ = editor.display_name();
850 }
851}