1use std::path::PathBuf;
2
3use gitkraft_core::*;
4use iced::{Color, Point, Task};
5
6use crate::message::Message;
7use crate::theme::ThemeColors;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum DragTarget {
14 SidebarRight,
16 CommitLogRight,
18 DiffFileListRight,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum DragTargetH {
26 StagingTop,
28}
29
30#[derive(Debug, Clone)]
32pub enum ContextMenu {
33 Branch {
35 name: String,
36 is_current: bool,
37 local_index: usize,
40 },
41 RemoteBranch { name: String },
43 Commit { index: usize, oid: String },
45 Stash { index: usize },
47 UnstagedFile { path: String },
49 StagedFile { path: String },
51 CommitFile { oid: String, file_path: String },
53}
54
55pub struct RepoTab {
59 pub repo_path: Option<PathBuf>,
62 pub repo_info: Option<RepoInfo>,
64
65 pub branches: Vec<BranchInfo>,
68 pub current_branch: Option<String>,
70
71 pub commits: Vec<CommitInfo>,
74 pub selected_commit: Option<usize>,
76 pub graph_rows: Vec<gitkraft_core::GraphRow>,
78
79 pub unstaged_changes: Vec<DiffInfo>,
82 pub staged_changes: Vec<DiffInfo>,
84 pub commit_files: Vec<gitkraft_core::DiffFileEntry>,
86 pub selected_commit_oid: Option<String>,
88 pub selected_file_index: Option<usize>,
90 pub is_loading_file_diff: bool,
92 pub selected_diff: Option<DiffInfo>,
94 pub commit_message: String,
96
97 pub stashes: Vec<StashEntry>,
100
101 pub remotes: Vec<RemoteInfo>,
104
105 pub show_commit_detail: bool,
108 pub new_branch_name: String,
110 pub show_branch_create: bool,
112 pub local_branches_expanded: bool,
114 pub remote_branches_expanded: bool,
116 pub stash_message: String,
118
119 pub selected_unstaged: std::collections::HashSet<String>,
121 pub selected_staged: std::collections::HashSet<String>,
123
124 pub pending_discard: Option<String>,
126
127 pub status_message: Option<String>,
130 pub error_message: Option<String>,
132 pub is_loading: bool,
134 pub context_menu_pos: (f32, f32),
137
138 pub context_menu: Option<ContextMenu>,
140 pub rename_branch_target: Option<String>,
142 pub rename_branch_input: String,
144
145 pub create_tag_target_oid: Option<String>,
147 pub create_tag_annotated: bool,
149 pub create_tag_name: String,
151 pub create_tag_message: String,
153
154 pub commit_scroll_offset: f32,
158
159 pub diff_scroll_offset: f32,
161 pub commit_display: Vec<(String, String, String)>,
165
166 pub has_more_commits: bool,
168 pub is_loading_more_commits: bool,
170}
171
172impl RepoTab {
173 pub fn new_empty() -> Self {
175 Self {
176 repo_path: None,
177 repo_info: None,
178 branches: Vec::new(),
179 current_branch: None,
180 commits: Vec::new(),
181 selected_commit: None,
182 graph_rows: Vec::new(),
183 unstaged_changes: Vec::new(),
184 staged_changes: Vec::new(),
185 commit_files: Vec::new(),
186 selected_commit_oid: None,
187 selected_file_index: None,
188 is_loading_file_diff: false,
189 selected_diff: None,
190 commit_message: String::new(),
191 stashes: Vec::new(),
192 remotes: Vec::new(),
193 show_commit_detail: false,
194 new_branch_name: String::new(),
195 show_branch_create: false,
196 local_branches_expanded: true,
197 remote_branches_expanded: true,
198 stash_message: String::new(),
199 selected_unstaged: std::collections::HashSet::new(),
200 selected_staged: std::collections::HashSet::new(),
201 pending_discard: None,
202 status_message: None,
203 error_message: None,
204 is_loading: false,
205 context_menu: None,
206 context_menu_pos: (0.0, 0.0),
207 rename_branch_target: None,
208 rename_branch_input: String::new(),
209 create_tag_target_oid: None,
210 create_tag_annotated: false,
211 create_tag_name: String::new(),
212 create_tag_message: String::new(),
213 commit_scroll_offset: 0.0,
214 diff_scroll_offset: 0.0,
215 commit_display: Vec::new(),
216 has_more_commits: true,
217 is_loading_more_commits: false,
218 }
219 }
220
221 pub fn has_repo(&self) -> bool {
223 self.repo_path.is_some()
224 }
225
226 pub fn display_name(&self) -> &str {
228 self.repo_path
229 .as_ref()
230 .and_then(|p| p.file_name())
231 .and_then(|n| n.to_str())
232 .unwrap_or("New Tab")
233 }
234
235 pub fn apply_payload(
237 &mut self,
238 payload: crate::message::RepoPayload,
239 path: std::path::PathBuf,
240 ) {
241 self.current_branch = payload.info.head_branch.clone();
242 self.repo_path = Some(path);
243 self.repo_info = Some(payload.info);
244 self.branches = payload.branches;
245 self.commits = payload.commits;
246 self.graph_rows = payload.graph_rows;
247 self.unstaged_changes = payload.unstaged;
248 self.staged_changes = payload.staged;
249 self.stashes = payload.stashes;
250 self.remotes = payload.remotes;
251
252 self.selected_commit = None;
254 self.selected_diff = None;
255 self.commit_files.clear();
256 self.selected_commit_oid = None;
257 self.selected_file_index = None;
258 self.is_loading_file_diff = false;
259 self.commit_message.clear();
260 self.error_message = None;
261 self.status_message = Some("Repository loaded.".into());
262 self.commit_scroll_offset = 0.0;
263 self.diff_scroll_offset = 0.0;
264 self.has_more_commits = true;
265 self.is_loading_more_commits = false;
266 self.selected_unstaged.clear();
267 self.selected_staged.clear();
268 }
269}
270
271pub struct GitKraft {
275 pub tabs: Vec<RepoTab>,
278 pub active_tab: usize,
280
281 pub sidebar_expanded: bool,
284
285 pub sidebar_width: f32,
288 pub commit_log_width: f32,
290 pub staging_height: f32,
292 pub diff_file_list_width: f32,
294
295 pub ui_scale: f32,
297
298 pub dragging: Option<DragTarget>,
301 pub dragging_h: Option<DragTargetH>,
303 pub drag_start_x: f32,
305 pub drag_start_y: f32,
307 pub drag_initialized: bool,
311 pub drag_initialized_h: bool,
313
314 pub cursor_pos: Point,
319
320 pub current_theme_index: usize,
323
324 pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
327
328 pub search_visible: bool,
331 pub search_query: String,
333 pub search_results: Vec<gitkraft_core::CommitInfo>,
335 pub search_selected: Option<usize>,
337
338 pub editor: gitkraft_core::Editor,
340}
341
342impl Default for GitKraft {
343 fn default() -> Self {
344 Self::new()
345 }
346}
347
348impl GitKraft {
349 fn from_settings(settings: gitkraft_core::AppSettings) -> Self {
355 let current_theme_index = settings
356 .theme_name
357 .as_deref()
358 .map(gitkraft_core::theme_index_by_name)
359 .unwrap_or(0);
360
361 let recent_repos = settings.recent_repos;
362
363 let (
364 sidebar_width,
365 commit_log_width,
366 staging_height,
367 diff_file_list_width,
368 sidebar_expanded,
369 ui_scale,
370 ) = if let Some(ref layout) = settings.layout {
371 (
372 layout.sidebar_width.unwrap_or(220.0),
373 layout.commit_log_width.unwrap_or(500.0),
374 layout.staging_height.unwrap_or(200.0),
375 layout.diff_file_list_width.unwrap_or(180.0),
376 layout.sidebar_expanded.unwrap_or(true),
377 layout.ui_scale.unwrap_or(1.0),
378 )
379 } else {
380 (220.0, 500.0, 200.0, 180.0, true, 1.0)
381 };
382
383 Self {
384 tabs: vec![RepoTab::new_empty()],
385 active_tab: 0,
386
387 sidebar_expanded,
388
389 sidebar_width,
390 commit_log_width,
391 staging_height,
392 diff_file_list_width,
393
394 ui_scale,
395
396 dragging: None,
397 dragging_h: None,
398 drag_start_x: 0.0,
399 drag_start_y: 0.0,
400 drag_initialized: false,
401 drag_initialized_h: false,
402 cursor_pos: Point::ORIGIN,
403
404 current_theme_index,
405
406 recent_repos,
407
408 search_visible: false,
409 search_query: String::new(),
410 search_results: Vec::new(),
411 search_selected: None,
412
413 editor: settings
414 .editor_name
415 .as_deref()
416 .map(|name| {
417 gitkraft_core::EDITOR_NAMES
419 .iter()
420 .position(|n| n.eq_ignore_ascii_case(name))
421 .map(gitkraft_core::Editor::from_index)
422 .unwrap_or_else(|| {
423 if name.eq_ignore_ascii_case("none") {
424 gitkraft_core::Editor::None
425 } else {
426 gitkraft_core::Editor::Custom(name.to_string())
427 }
428 })
429 })
430 .unwrap_or_else(detect_system_editor),
431 }
432 }
433
434 pub fn new() -> Self {
440 Self::from_settings(
441 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default(),
442 )
443 }
444
445 pub fn new_with_session_paths() -> (Self, Vec<PathBuf>) {
451 let settings =
452 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default();
453 let open_tabs = settings.open_tabs.clone();
454 let active_tab_index = settings.active_tab_index;
455
456 let mut state = Self::from_settings(settings);
457
458 if !open_tabs.is_empty() {
459 state.tabs = open_tabs
460 .iter()
461 .map(|path| {
462 let mut tab = RepoTab::new_empty();
463 tab.repo_path = Some(path.clone());
466 if path.exists() {
467 tab.is_loading = true;
468 tab.status_message = Some(format!(
469 "Loading {}…",
470 path.file_name().unwrap_or_default().to_string_lossy()
471 ));
472 } else {
473 tab.error_message =
474 Some(format!("Repository not found: {}", path.display()));
475 }
476 tab
477 })
478 .collect();
479 state.active_tab = active_tab_index.min(state.tabs.len().saturating_sub(1));
480 }
481
482 (state, open_tabs)
483 }
484
485 pub fn open_tab_paths(&self) -> Vec<PathBuf> {
488 self.tabs
489 .iter()
490 .filter(|t| t.repo_info.is_some())
491 .filter_map(|t| t.repo_path.clone())
492 .collect()
493 }
494
495 pub fn active_tab(&self) -> &RepoTab {
497 &self.tabs[self.active_tab]
498 }
499
500 pub fn active_tab_mut(&mut self) -> &mut RepoTab {
502 &mut self.tabs[self.active_tab]
503 }
504
505 pub fn has_repo(&self) -> bool {
507 self.active_tab().has_repo()
508 }
509
510 pub fn repo_display_name(&self) -> &str {
512 self.active_tab().display_name()
513 }
514
515 pub fn colors(&self) -> ThemeColors {
522 ThemeColors::from_core(&gitkraft_core::theme_by_index(self.current_theme_index))
523 }
524
525 pub fn iced_theme(&self) -> iced::Theme {
534 let core = gitkraft_core::theme_by_index(self.current_theme_index);
535 let name = self.current_theme_name().to_string();
536
537 let palette = iced::theme::Palette {
538 background: rgb_to_iced(core.background),
539 text: rgb_to_iced(core.text_primary),
540 primary: rgb_to_iced(core.accent),
541 success: rgb_to_iced(core.success),
542 warning: rgb_to_iced(core.warning),
543 danger: rgb_to_iced(core.error),
544 };
545
546 iced::Theme::custom(name, palette)
547 }
548
549 pub fn current_theme_name(&self) -> &'static str {
551 gitkraft_core::THEME_NAMES
552 .get(self.current_theme_index)
553 .copied()
554 .unwrap_or("Default")
555 }
556
557 pub fn refresh_active_tab(&mut self) -> Task<Message> {
561 match self.active_tab().repo_path.clone() {
562 Some(path) => crate::features::repo::commands::refresh_repo(path),
563 None => Task::none(),
564 }
565 }
566
567 pub fn on_ok_refresh(
574 &mut self,
575 result: Result<(), String>,
576 ok_msg: &str,
577 err_prefix: &str,
578 ) -> Task<Message> {
579 match result {
580 Ok(()) => {
581 {
582 let tab = self.active_tab_mut();
583 tab.is_loading = false;
584 tab.status_message = Some(ok_msg.to_string());
585 }
586 self.refresh_active_tab()
587 }
588 Err(e) => {
589 let tab = self.active_tab_mut();
590 tab.is_loading = false;
591 tab.error_message = Some(format!("{err_prefix}: {e}"));
592 tab.status_message = None;
593 Task::none()
594 }
595 }
596 }
597
598 pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
600 gitkraft_core::LayoutSettings {
601 sidebar_width: Some(self.sidebar_width),
602 commit_log_width: Some(self.commit_log_width),
603 staging_height: Some(self.staging_height),
604 diff_file_list_width: Some(self.diff_file_list_width),
605 sidebar_expanded: Some(self.sidebar_expanded),
606 ui_scale: Some(self.ui_scale),
607 }
608 }
609}
610
611fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
613 Color::from_rgb8(rgb.r, rgb.g, rgb.b)
614}
615
616fn detect_system_editor() -> gitkraft_core::Editor {
618 for var in ["VISUAL", "EDITOR"] {
619 if let Ok(val) = std::env::var(var) {
620 let bin = val.split('/').next_back().unwrap_or(&val).trim();
621 return match bin {
622 "nvim" | "neovim" => gitkraft_core::Editor::Neovim,
623 "vim" => gitkraft_core::Editor::Vim,
624 "hx" | "helix" => gitkraft_core::Editor::Helix,
625 "nano" => gitkraft_core::Editor::Nano,
626 "micro" => gitkraft_core::Editor::Micro,
627 "emacs" => gitkraft_core::Editor::Emacs,
628 "code" => gitkraft_core::Editor::VSCode,
629 "zed" => gitkraft_core::Editor::Zed,
630 "subl" => gitkraft_core::Editor::Sublime,
631 _ => gitkraft_core::Editor::Custom(val),
632 };
633 }
634 }
635 gitkraft_core::Editor::None
636}
637
638#[cfg(test)]
641mod tests {
642 use super::*;
643
644 #[test]
645 fn new_defaults() {
646 let state = GitKraft::new();
647 assert!(state.active_tab().repo_path.is_none());
648 assert!(!state.has_repo());
649 assert_eq!(state.repo_display_name(), "New Tab");
650 assert!(state.active_tab().commits.is_empty());
651 assert!(state.sidebar_expanded);
652 assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
654 assert!(state.sidebar_width > 0.0);
656 assert!(state.commit_log_width > 0.0);
657 assert!(state.staging_height > 0.0);
658 assert!(state.dragging.is_none());
659 assert!(state.dragging_h.is_none());
660 assert_eq!(state.tabs.len(), 1);
662 assert_eq!(state.active_tab, 0);
663 }
664
665 #[test]
666 fn repo_display_name_extracts_basename() {
667 let mut state = GitKraft::new();
668 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
669 assert_eq!(state.repo_display_name(), "my-project");
670 }
671
672 #[test]
673 fn colors_returns_theme_colors() {
674 let state = GitKraft::new();
675 let c = state.colors();
676 assert!(c.bg.r < 0.5);
678 }
679
680 #[test]
681 fn iced_theme_is_custom_with_correct_palette() {
682 let mut state = GitKraft::new();
683
684 state.current_theme_index = 0;
686 let iced_t = state.iced_theme();
687 let pal = iced_t.palette();
688 assert!(pal.background.r < 0.5, "Default theme bg should be dark");
689 assert_eq!(iced_t.to_string(), "Default");
690
691 state.current_theme_index = 11;
693 let iced_t = state.iced_theme();
694 let pal = iced_t.palette();
695 assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
696 assert_eq!(iced_t.to_string(), "Solarized Light");
697
698 state.current_theme_index = 12;
700 let iced_t = state.iced_theme();
701 let pal = iced_t.palette();
702 let core = gitkraft_core::theme_by_index(12);
703 let expected_accent = rgb_to_iced(core.accent);
704 assert!(
705 (pal.primary.r - expected_accent.r).abs() < 0.01
706 && (pal.primary.g - expected_accent.g).abs() < 0.01
707 && (pal.primary.b - expected_accent.b).abs() < 0.01,
708 "Gruvbox Dark accent should match core accent"
709 );
710 }
711
712 #[test]
713 fn iced_theme_name_round_trips_through_core() {
714 for i in 0..gitkraft_core::THEME_COUNT {
717 let mut state = GitKraft::new();
718 state.current_theme_index = i;
719 let iced_t = state.iced_theme();
720 let name = iced_t.to_string();
721 let resolved = gitkraft_core::theme_index_by_name(&name);
722 assert_eq!(
723 resolved,
724 i,
725 "theme index {i} ({}) did not round-trip through iced_theme name",
726 gitkraft_core::THEME_NAMES[i]
727 );
728 }
729 }
730
731 #[test]
732 fn current_theme_name_round_trips() {
733 let mut state = GitKraft::new();
734 state.current_theme_index = 8;
735 assert_eq!(state.current_theme_name(), "Dracula");
736 state.current_theme_index = 0;
737 assert_eq!(state.current_theme_name(), "Default");
738 }
739
740 #[test]
741 fn repo_tab_new_empty() {
742 let tab = RepoTab::new_empty();
743 assert!(tab.repo_path.is_none());
744 assert!(!tab.has_repo());
745 assert_eq!(tab.display_name(), "New Tab");
746 assert!(tab.commits.is_empty());
747 assert!(tab.branches.is_empty());
748 assert!(!tab.is_loading);
749 }
750
751 #[test]
752 fn repo_tab_display_name_with_path() {
753 let mut tab = RepoTab::new_empty();
754 tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
755 assert!(tab.has_repo());
756 assert_eq!(tab.display_name(), "cool-repo");
757 }
758
759 #[test]
760 fn search_defaults() {
761 let state = GitKraft::new();
762 assert!(!state.search_visible);
763 assert!(state.search_query.is_empty());
764 assert!(state.search_results.is_empty());
765 assert!(state.search_selected.is_none());
766 }
767
768 #[test]
769 fn context_menu_variants_exist() {
770 use crate::state::ContextMenu;
772
773 let _branch = ContextMenu::Branch {
774 name: "main".to_string(),
775 is_current: true,
776 local_index: 0,
777 };
778 let _remote = ContextMenu::RemoteBranch {
779 name: "origin/main".to_string(),
780 };
781 let _commit = ContextMenu::Commit {
782 index: 0,
783 oid: "abc1234".to_string(),
784 };
785 let _stash = ContextMenu::Stash { index: 0 };
786 let _unstaged = ContextMenu::UnstagedFile {
787 path: "src/main.rs".to_string(),
788 };
789 let _staged = ContextMenu::StagedFile {
790 path: "src/lib.rs".to_string(),
791 };
792 }
793
794 #[test]
795 fn repo_tab_context_menu_defaults_to_none() {
796 let tab = crate::state::RepoTab::new_empty();
797 assert!(tab.context_menu.is_none());
798 }
799
800 #[test]
801 fn context_menu_variants_constructable() {
802 use crate::state::ContextMenu;
803 let _ = ContextMenu::Stash { index: 0 };
804 let _ = ContextMenu::UnstagedFile {
805 path: "a.rs".into(),
806 };
807 let _ = ContextMenu::StagedFile {
808 path: "b.rs".into(),
809 };
810 }
811
812 #[test]
813 fn selected_unstaged_defaults_empty() {
814 let tab = crate::state::RepoTab::new_empty();
815 assert!(tab.selected_unstaged.is_empty());
816 assert!(tab.selected_staged.is_empty());
817 }
818
819 #[test]
820 fn selected_unstaged_toggle() {
821 let mut tab = crate::state::RepoTab::new_empty();
822 tab.selected_unstaged.insert("a.rs".to_string());
823 tab.selected_unstaged.insert("b.rs".to_string());
824 assert_eq!(tab.selected_unstaged.len(), 2);
825 assert!(tab.selected_unstaged.contains("a.rs"));
826 tab.selected_unstaged.remove("a.rs");
827 assert_eq!(tab.selected_unstaged.len(), 1);
828 assert!(!tab.selected_unstaged.contains("a.rs"));
829 }
830
831 #[test]
832 fn detect_system_editor_returns_valid() {
833 let editor = super::detect_system_editor();
835 let _ = editor.display_name();
836 }
837}