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}
46
47pub struct RepoTab {
51 pub repo_path: Option<PathBuf>,
54 pub repo_info: Option<RepoInfo>,
56
57 pub branches: Vec<BranchInfo>,
60 pub current_branch: Option<String>,
62
63 pub commits: Vec<CommitInfo>,
66 pub selected_commit: Option<usize>,
68 pub graph_rows: Vec<gitkraft_core::GraphRow>,
70
71 pub unstaged_changes: Vec<DiffInfo>,
74 pub staged_changes: Vec<DiffInfo>,
76 pub commit_files: Vec<gitkraft_core::DiffFileEntry>,
78 pub selected_commit_oid: Option<String>,
80 pub selected_file_index: Option<usize>,
82 pub is_loading_file_diff: bool,
84 pub selected_diff: Option<DiffInfo>,
86 pub commit_message: String,
88
89 pub stashes: Vec<StashEntry>,
92
93 pub remotes: Vec<RemoteInfo>,
96
97 pub show_commit_detail: bool,
100 pub new_branch_name: String,
102 pub show_branch_create: bool,
104 pub local_branches_expanded: bool,
106 pub remote_branches_expanded: bool,
108 pub stash_message: String,
110
111 pub pending_discard: Option<String>,
113
114 pub status_message: Option<String>,
117 pub error_message: Option<String>,
119 pub is_loading: bool,
121 pub context_menu_pos: (f32, f32),
124
125 pub context_menu: Option<ContextMenu>,
127 pub rename_branch_target: Option<String>,
129 pub rename_branch_input: String,
131
132 pub create_tag_target_oid: Option<String>,
134 pub create_tag_annotated: bool,
136 pub create_tag_name: String,
138 pub create_tag_message: String,
140
141 pub commit_scroll_offset: f32,
145
146 pub diff_scroll_offset: f32,
148 pub commit_display: Vec<(String, String, String)>,
152
153 pub has_more_commits: bool,
155 pub is_loading_more_commits: bool,
157}
158
159impl RepoTab {
160 pub fn new_empty() -> Self {
162 Self {
163 repo_path: None,
164 repo_info: None,
165 branches: Vec::new(),
166 current_branch: None,
167 commits: Vec::new(),
168 selected_commit: None,
169 graph_rows: Vec::new(),
170 unstaged_changes: Vec::new(),
171 staged_changes: Vec::new(),
172 commit_files: Vec::new(),
173 selected_commit_oid: None,
174 selected_file_index: None,
175 is_loading_file_diff: false,
176 selected_diff: None,
177 commit_message: String::new(),
178 stashes: Vec::new(),
179 remotes: Vec::new(),
180 show_commit_detail: false,
181 new_branch_name: String::new(),
182 show_branch_create: false,
183 local_branches_expanded: true,
184 remote_branches_expanded: true,
185 stash_message: String::new(),
186 pending_discard: None,
187 status_message: None,
188 error_message: None,
189 is_loading: false,
190 context_menu: None,
191 context_menu_pos: (0.0, 0.0),
192 rename_branch_target: None,
193 rename_branch_input: String::new(),
194 create_tag_target_oid: None,
195 create_tag_annotated: false,
196 create_tag_name: String::new(),
197 create_tag_message: String::new(),
198 commit_scroll_offset: 0.0,
199 diff_scroll_offset: 0.0,
200 commit_display: Vec::new(),
201 has_more_commits: true,
202 is_loading_more_commits: false,
203 }
204 }
205
206 pub fn has_repo(&self) -> bool {
208 self.repo_path.is_some()
209 }
210
211 pub fn display_name(&self) -> &str {
213 self.repo_path
214 .as_ref()
215 .and_then(|p| p.file_name())
216 .and_then(|n| n.to_str())
217 .unwrap_or("New Tab")
218 }
219
220 pub fn apply_payload(
222 &mut self,
223 payload: crate::message::RepoPayload,
224 path: std::path::PathBuf,
225 ) {
226 self.current_branch = payload.info.head_branch.clone();
227 self.repo_path = Some(path);
228 self.repo_info = Some(payload.info);
229 self.branches = payload.branches;
230 self.commits = payload.commits;
231 self.graph_rows = payload.graph_rows;
232 self.unstaged_changes = payload.unstaged;
233 self.staged_changes = payload.staged;
234 self.stashes = payload.stashes;
235 self.remotes = payload.remotes;
236
237 self.selected_commit = None;
239 self.selected_diff = None;
240 self.commit_files.clear();
241 self.selected_commit_oid = None;
242 self.selected_file_index = None;
243 self.is_loading_file_diff = false;
244 self.commit_message.clear();
245 self.error_message = None;
246 self.status_message = Some("Repository loaded.".into());
247 self.commit_scroll_offset = 0.0;
248 self.diff_scroll_offset = 0.0;
249 self.has_more_commits = true;
250 self.is_loading_more_commits = false;
251 }
252}
253
254pub struct GitKraft {
258 pub tabs: Vec<RepoTab>,
261 pub active_tab: usize,
263
264 pub sidebar_expanded: bool,
267
268 pub sidebar_width: f32,
271 pub commit_log_width: f32,
273 pub staging_height: f32,
275 pub diff_file_list_width: f32,
277
278 pub ui_scale: f32,
280
281 pub dragging: Option<DragTarget>,
284 pub dragging_h: Option<DragTargetH>,
286 pub drag_start_x: f32,
288 pub drag_start_y: f32,
290 pub drag_initialized: bool,
294 pub drag_initialized_h: bool,
296
297 pub cursor_pos: Point,
302
303 pub current_theme_index: usize,
306
307 pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
310
311 pub search_visible: bool,
314 pub search_query: String,
316 pub search_results: Vec<gitkraft_core::CommitInfo>,
318 pub search_selected: Option<usize>,
320}
321
322impl Default for GitKraft {
323 fn default() -> Self {
324 Self::new()
325 }
326}
327
328impl GitKraft {
329 fn from_settings(settings: gitkraft_core::AppSettings) -> Self {
335 let current_theme_index = settings
336 .theme_name
337 .as_deref()
338 .map(gitkraft_core::theme_index_by_name)
339 .unwrap_or(0);
340
341 let recent_repos = settings.recent_repos;
342
343 let (
344 sidebar_width,
345 commit_log_width,
346 staging_height,
347 diff_file_list_width,
348 sidebar_expanded,
349 ui_scale,
350 ) = if let Some(ref layout) = settings.layout {
351 (
352 layout.sidebar_width.unwrap_or(220.0),
353 layout.commit_log_width.unwrap_or(500.0),
354 layout.staging_height.unwrap_or(200.0),
355 layout.diff_file_list_width.unwrap_or(180.0),
356 layout.sidebar_expanded.unwrap_or(true),
357 layout.ui_scale.unwrap_or(1.0),
358 )
359 } else {
360 (220.0, 500.0, 200.0, 180.0, true, 1.0)
361 };
362
363 Self {
364 tabs: vec![RepoTab::new_empty()],
365 active_tab: 0,
366
367 sidebar_expanded,
368
369 sidebar_width,
370 commit_log_width,
371 staging_height,
372 diff_file_list_width,
373
374 ui_scale,
375
376 dragging: None,
377 dragging_h: None,
378 drag_start_x: 0.0,
379 drag_start_y: 0.0,
380 drag_initialized: false,
381 drag_initialized_h: false,
382 cursor_pos: Point::ORIGIN,
383
384 current_theme_index,
385
386 recent_repos,
387
388 search_visible: false,
389 search_query: String::new(),
390 search_results: Vec::new(),
391 search_selected: None,
392 }
393 }
394
395 pub fn new() -> Self {
401 Self::from_settings(
402 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default(),
403 )
404 }
405
406 pub fn new_with_session_paths() -> (Self, Vec<PathBuf>) {
412 let settings =
413 gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default();
414 let open_tabs = settings.open_tabs.clone();
415 let active_tab_index = settings.active_tab_index;
416
417 let mut state = Self::from_settings(settings);
418
419 if !open_tabs.is_empty() {
420 state.tabs = open_tabs
421 .iter()
422 .map(|path| {
423 let mut tab = RepoTab::new_empty();
424 tab.repo_path = Some(path.clone());
427 if path.exists() {
428 tab.is_loading = true;
429 tab.status_message = Some(format!(
430 "Loading {}…",
431 path.file_name().unwrap_or_default().to_string_lossy()
432 ));
433 } else {
434 tab.error_message =
435 Some(format!("Repository not found: {}", path.display()));
436 }
437 tab
438 })
439 .collect();
440 state.active_tab = active_tab_index.min(state.tabs.len().saturating_sub(1));
441 }
442
443 (state, open_tabs)
444 }
445
446 pub fn open_tab_paths(&self) -> Vec<PathBuf> {
449 self.tabs
450 .iter()
451 .filter(|t| t.repo_info.is_some())
452 .filter_map(|t| t.repo_path.clone())
453 .collect()
454 }
455
456 pub fn active_tab(&self) -> &RepoTab {
458 &self.tabs[self.active_tab]
459 }
460
461 pub fn active_tab_mut(&mut self) -> &mut RepoTab {
463 &mut self.tabs[self.active_tab]
464 }
465
466 pub fn has_repo(&self) -> bool {
468 self.active_tab().has_repo()
469 }
470
471 pub fn repo_display_name(&self) -> &str {
473 self.active_tab().display_name()
474 }
475
476 pub fn colors(&self) -> ThemeColors {
483 ThemeColors::from_core(&gitkraft_core::theme_by_index(self.current_theme_index))
484 }
485
486 pub fn iced_theme(&self) -> iced::Theme {
495 let core = gitkraft_core::theme_by_index(self.current_theme_index);
496 let name = self.current_theme_name().to_string();
497
498 let palette = iced::theme::Palette {
499 background: rgb_to_iced(core.background),
500 text: rgb_to_iced(core.text_primary),
501 primary: rgb_to_iced(core.accent),
502 success: rgb_to_iced(core.success),
503 warning: rgb_to_iced(core.warning),
504 danger: rgb_to_iced(core.error),
505 };
506
507 iced::Theme::custom(name, palette)
508 }
509
510 pub fn current_theme_name(&self) -> &'static str {
512 gitkraft_core::THEME_NAMES
513 .get(self.current_theme_index)
514 .copied()
515 .unwrap_or("Default")
516 }
517
518 pub fn refresh_active_tab(&mut self) -> Task<Message> {
522 match self.active_tab().repo_path.clone() {
523 Some(path) => crate::features::repo::commands::refresh_repo(path),
524 None => Task::none(),
525 }
526 }
527
528 pub fn on_ok_refresh(
535 &mut self,
536 result: Result<(), String>,
537 ok_msg: &str,
538 err_prefix: &str,
539 ) -> Task<Message> {
540 match result {
541 Ok(()) => {
542 {
543 let tab = self.active_tab_mut();
544 tab.is_loading = false;
545 tab.status_message = Some(ok_msg.to_string());
546 }
547 self.refresh_active_tab()
548 }
549 Err(e) => {
550 let tab = self.active_tab_mut();
551 tab.is_loading = false;
552 tab.error_message = Some(format!("{err_prefix}: {e}"));
553 tab.status_message = None;
554 Task::none()
555 }
556 }
557 }
558
559 pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
561 gitkraft_core::LayoutSettings {
562 sidebar_width: Some(self.sidebar_width),
563 commit_log_width: Some(self.commit_log_width),
564 staging_height: Some(self.staging_height),
565 diff_file_list_width: Some(self.diff_file_list_width),
566 sidebar_expanded: Some(self.sidebar_expanded),
567 ui_scale: Some(self.ui_scale),
568 }
569 }
570}
571
572fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
574 Color::from_rgb8(rgb.r, rgb.g, rgb.b)
575}
576
577#[cfg(test)]
580mod tests {
581 use super::*;
582
583 #[test]
584 fn new_defaults() {
585 let state = GitKraft::new();
586 assert!(state.active_tab().repo_path.is_none());
587 assert!(!state.has_repo());
588 assert_eq!(state.repo_display_name(), "New Tab");
589 assert!(state.active_tab().commits.is_empty());
590 assert!(state.sidebar_expanded);
591 assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
593 assert!(state.sidebar_width > 0.0);
595 assert!(state.commit_log_width > 0.0);
596 assert!(state.staging_height > 0.0);
597 assert!(state.dragging.is_none());
598 assert!(state.dragging_h.is_none());
599 assert_eq!(state.tabs.len(), 1);
601 assert_eq!(state.active_tab, 0);
602 }
603
604 #[test]
605 fn repo_display_name_extracts_basename() {
606 let mut state = GitKraft::new();
607 state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
608 assert_eq!(state.repo_display_name(), "my-project");
609 }
610
611 #[test]
612 fn colors_returns_theme_colors() {
613 let state = GitKraft::new();
614 let c = state.colors();
615 assert!(c.bg.r < 0.5);
617 }
618
619 #[test]
620 fn iced_theme_is_custom_with_correct_palette() {
621 let mut state = GitKraft::new();
622
623 state.current_theme_index = 0;
625 let iced_t = state.iced_theme();
626 let pal = iced_t.palette();
627 assert!(pal.background.r < 0.5, "Default theme bg should be dark");
628 assert_eq!(iced_t.to_string(), "Default");
629
630 state.current_theme_index = 11;
632 let iced_t = state.iced_theme();
633 let pal = iced_t.palette();
634 assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
635 assert_eq!(iced_t.to_string(), "Solarized Light");
636
637 state.current_theme_index = 12;
639 let iced_t = state.iced_theme();
640 let pal = iced_t.palette();
641 let core = gitkraft_core::theme_by_index(12);
642 let expected_accent = rgb_to_iced(core.accent);
643 assert!(
644 (pal.primary.r - expected_accent.r).abs() < 0.01
645 && (pal.primary.g - expected_accent.g).abs() < 0.01
646 && (pal.primary.b - expected_accent.b).abs() < 0.01,
647 "Gruvbox Dark accent should match core accent"
648 );
649 }
650
651 #[test]
652 fn iced_theme_name_round_trips_through_core() {
653 for i in 0..gitkraft_core::THEME_COUNT {
656 let mut state = GitKraft::new();
657 state.current_theme_index = i;
658 let iced_t = state.iced_theme();
659 let name = iced_t.to_string();
660 let resolved = gitkraft_core::theme_index_by_name(&name);
661 assert_eq!(
662 resolved,
663 i,
664 "theme index {i} ({}) did not round-trip through iced_theme name",
665 gitkraft_core::THEME_NAMES[i]
666 );
667 }
668 }
669
670 #[test]
671 fn current_theme_name_round_trips() {
672 let mut state = GitKraft::new();
673 state.current_theme_index = 8;
674 assert_eq!(state.current_theme_name(), "Dracula");
675 state.current_theme_index = 0;
676 assert_eq!(state.current_theme_name(), "Default");
677 }
678
679 #[test]
680 fn repo_tab_new_empty() {
681 let tab = RepoTab::new_empty();
682 assert!(tab.repo_path.is_none());
683 assert!(!tab.has_repo());
684 assert_eq!(tab.display_name(), "New Tab");
685 assert!(tab.commits.is_empty());
686 assert!(tab.branches.is_empty());
687 assert!(!tab.is_loading);
688 }
689
690 #[test]
691 fn repo_tab_display_name_with_path() {
692 let mut tab = RepoTab::new_empty();
693 tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
694 assert!(tab.has_repo());
695 assert_eq!(tab.display_name(), "cool-repo");
696 }
697
698 #[test]
699 fn search_defaults() {
700 let state = GitKraft::new();
701 assert!(!state.search_visible);
702 assert!(state.search_query.is_empty());
703 assert!(state.search_results.is_empty());
704 assert!(state.search_selected.is_none());
705 }
706}