Skip to main content

gitkraft_gui/
state.rs

1use std::path::PathBuf;
2
3use gitkraft_core::*;
4use iced::{Color, Point, Task};
5
6use crate::message::Message;
7use crate::theme::ThemeColors;
8
9// ── Pane resize ───────────────────────────────────────────────────────────────
10
11/// Which vertical divider the user is currently dragging (if any).
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum DragTarget {
14    /// The divider between the sidebar and the commit-log panel.
15    SidebarRight,
16    /// The divider between the commit-log panel and the diff panel.
17    CommitLogRight,
18    /// The divider between the diff-viewer file list and the diff content
19    /// (only visible when a multi-file commit is selected).
20    DiffFileListRight,
21}
22
23/// Which horizontal divider the user is currently dragging (if any).
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum DragTargetH {
26    /// The divider between the middle row and the staging area.
27    StagingTop,
28}
29
30/// What item was right-clicked to open the context menu.
31#[derive(Debug, Clone)]
32pub enum ContextMenu {
33    /// A local branch.
34    Branch {
35        name: String,
36        is_current: bool,
37        /// Index in the filtered local-branch list, used to approximate
38        /// the menu's on-screen position.
39        local_index: usize,
40    },
41    /// A remote-tracking branch (e.g. origin/feature-x).
42    RemoteBranch { name: String },
43    /// A commit in the log.
44    Commit { index: usize, oid: String },
45    /// A stash entry.
46    Stash { index: usize },
47    /// An unstaged file in the staging area.
48    UnstagedFile { path: String },
49    /// A staged file in the staging area.
50    StagedFile { path: String },
51    /// A file in a commit diff.
52    CommitFile { oid: String, file_path: String },
53}
54
55// ── Per-repository tab state ──────────────────────────────────────────────────
56
57/// Per-repository state — one instance per open tab.
58pub struct RepoTab {
59    // ── Repository ────────────────────────────────────────────────────────
60    /// Path to the currently opened repository (workdir root).
61    pub repo_path: Option<PathBuf>,
62    /// High-level information about the opened repository.
63    pub repo_info: Option<RepoInfo>,
64
65    // ── Branches ──────────────────────────────────────────────────────────
66    /// All branches (local + remote) in the repository.
67    pub branches: Vec<BranchInfo>,
68    /// Name of the currently checked-out branch.
69    pub current_branch: Option<String>,
70
71    // ── Commits ───────────────────────────────────────────────────────────
72    /// Commit log (newest first).
73    pub commits: Vec<CommitInfo>,
74    /// Index into `commits` of the currently selected commit.
75    pub selected_commit: Option<usize>,
76    /// Per-commit graph layout rows for branch visualisation.
77    pub graph_rows: Vec<gitkraft_core::GraphRow>,
78
79    // ── Diff / Staging ────────────────────────────────────────────────────
80    /// Unstaged (working-directory) changes.
81    pub unstaged_changes: Vec<DiffInfo>,
82    /// Staged (index) changes.
83    pub staged_changes: Vec<DiffInfo>,
84    /// Lightweight file list for the currently selected commit (path + status only).
85    pub commit_files: Vec<gitkraft_core::DiffFileEntry>,
86    /// OID of the currently selected commit (needed for on-demand file diff loading).
87    pub selected_commit_oid: Option<String>,
88    /// Index of the selected file in `commit_files`.
89    pub selected_file_index: Option<usize>,
90    /// True while a single-file diff is being loaded.
91    pub is_loading_file_diff: bool,
92    /// The diff currently displayed in the diff viewer panel.
93    pub selected_diff: Option<DiffInfo>,
94    /// Text in the commit-message input.
95    pub commit_message: String,
96
97    // ── Stash ─────────────────────────────────────────────────────────────
98    /// All stash entries.
99    pub stashes: Vec<StashEntry>,
100
101    // ── Remotes ───────────────────────────────────────────────────────────
102    /// Configured remotes.
103    pub remotes: Vec<RemoteInfo>,
104
105    // ── Per-tab UI state ──────────────────────────────────────────────────
106    /// Whether the commit detail pane is visible.
107    pub show_commit_detail: bool,
108    /// Text in the "new branch name" input.
109    pub new_branch_name: String,
110    /// Whether the inline branch-creation UI is visible.
111    pub show_branch_create: bool,
112    /// Whether the Local branches section is expanded.
113    pub local_branches_expanded: bool,
114    /// Whether the Remote branches section is expanded.
115    pub remote_branches_expanded: bool,
116    /// Text in the "stash message" input.
117    pub stash_message: String,
118
119    /// Set of selected unstaged file paths (for multi-select with Shift+Click).
120    pub selected_unstaged: std::collections::HashSet<String>,
121    /// Set of selected staged file paths (for multi-select with Shift+Click).
122    pub selected_staged: std::collections::HashSet<String>,
123
124    /// File path pending discard confirmation (None = no pending discard).
125    pub pending_discard: Option<String>,
126
127    // ── Feedback ──────────────────────────────────────────────────────────
128    /// Transient status-bar message (e.g. "Branch created").
129    pub status_message: Option<String>,
130    /// Error message shown in a banner / toast.
131    pub error_message: Option<String>,
132    /// True while an async operation is in flight.
133    pub is_loading: bool,
134    /// Cursor position captured at the moment the context menu was opened.
135    /// Used to anchor the menu so it doesn't follow the mouse after appearing.
136    pub context_menu_pos: (f32, f32),
137
138    /// Currently open context menu, if any.
139    pub context_menu: Option<ContextMenu>,
140    /// Name of the branch currently being renamed (None = not renaming).
141    pub rename_branch_target: Option<String>,
142    /// The new name being typed in the rename input.
143    pub rename_branch_input: String,
144
145    /// When `Some(oid)`, the tag-creation inline form is visible, targeting that OID.
146    pub create_tag_target_oid: Option<String>,
147    /// True when creating an annotated tag; false for a lightweight tag.
148    pub create_tag_annotated: bool,
149    /// The tag name the user is typing.
150    pub create_tag_name: String,
151    /// The annotated tag message the user is typing (only used when `create_tag_annotated` is true).
152    pub create_tag_message: String,
153
154    /// Current scroll offset of the commit log in pixels.
155    /// Tracked via `on_scroll` so virtual scrolling can render only the
156    /// visible window of rows.
157    pub commit_scroll_offset: f32,
158
159    /// Current scroll offset of the diff viewer in pixels.
160    pub diff_scroll_offset: f32,
161    /// Pre-computed display strings for each commit:
162    /// `(truncated_summary, relative_time, truncated_author)`.
163    /// Computed once when commits load to avoid per-frame string allocations.
164    pub commit_display: Vec<(String, String, String)>,
165
166    /// Whether there are potentially more commits to load beyond those already shown.
167    pub has_more_commits: bool,
168    /// Guard: true while a background load-more task is in flight (prevents duplicates).
169    pub is_loading_more_commits: bool,
170}
171
172impl RepoTab {
173    /// Create an empty tab (no repo open — shows welcome screen).
174    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    /// Whether a repository is currently open in this tab.
222    pub fn has_repo(&self) -> bool {
223        self.repo_path.is_some()
224    }
225
226    /// Display name for the tab (last path component, or "New Tab").
227    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    /// Apply a full repo payload to this tab, resetting transient UI state.
236    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        // Reset transient UI state.
253        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
271// ── Top-level application state ───────────────────────────────────────────────
272
273/// Top-level application state for the GitKraft GUI.
274pub struct GitKraft {
275    // ── Tabs ──────────────────────────────────────────────────────────────
276    /// All open repository tabs.
277    pub tabs: Vec<RepoTab>,
278    /// Index of the currently active/visible tab.
279    pub active_tab: usize,
280
281    // ── UI state (global, not per-tab) ────────────────────────────────────
282    /// Whether the left sidebar is expanded.
283    pub sidebar_expanded: bool,
284
285    // ── Pane widths / heights (pixels) ────────────────────────────────────
286    /// Width of the left sidebar in pixels.
287    pub sidebar_width: f32,
288    /// Width of the commit-log panel in pixels.
289    pub commit_log_width: f32,
290    /// Height of the staging area in pixels.
291    pub staging_height: f32,
292    /// Width of the diff file-list sidebar in pixels.
293    pub diff_file_list_width: f32,
294
295    /// UI scale factor (1.0 = default). Adjusted with Ctrl+/Ctrl- keyboard shortcuts.
296    pub ui_scale: f32,
297
298    // ── Drag state ────────────────────────────────────────────────────────
299    /// Which vertical divider is being dragged (if any).
300    pub dragging: Option<DragTarget>,
301    /// Which horizontal divider is being dragged (if any).
302    pub dragging_h: Option<DragTargetH>,
303    /// Last known mouse X position during a drag (absolute window coords).
304    pub drag_start_x: f32,
305    /// Last known mouse Y position during a drag (absolute window coords).
306    pub drag_start_y: f32,
307    /// Whether the first move event has been received for the current vertical drag.
308    /// `false` right after `PaneDragStart` — the first `PaneDragMove` sets the
309    /// real start position instead of computing a bogus delta from 0.0.
310    pub drag_initialized: bool,
311    /// Same as `drag_initialized` but for horizontal drags.
312    pub drag_initialized_h: bool,
313
314    // ── Cursor ────────────────────────────────────────────────────────────
315    /// Last known cursor position in window coordinates.
316    /// Updated on every mouse-move event so context menus open at the
317    /// exact spot the user right-clicked.
318    pub cursor_pos: Point,
319
320    // ── Theme ─────────────────────────────────────────────────────────────
321    /// Index into `gitkraft_core::THEME_NAMES` for the currently active theme.
322    pub current_theme_index: usize,
323
324    // ── Persistence ───────────────────────────────────────────────────────
325    /// Recently opened repositories (loaded from settings on startup).
326    pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
327
328    // ── Search ────────────────────────────────────────────────────────────
329    /// Whether the search overlay is visible.
330    pub search_visible: bool,
331    /// Current search query text.
332    pub search_query: String,
333    /// Search results (commit infos matching the query).
334    pub search_results: Vec<gitkraft_core::CommitInfo>,
335    /// Index of the selected search result.
336    pub search_selected: Option<usize>,
337
338    /// Configured editor for "Open in editor" actions.
339    pub editor: gitkraft_core::Editor,
340}
341
342impl Default for GitKraft {
343    fn default() -> Self {
344        Self::new()
345    }
346}
347
348impl GitKraft {
349    /// Build application state from persisted [`AppSettings`].
350    ///
351    /// Starts with a single empty tab regardless of what was saved — callers
352    /// that want to restore the full session should use
353    /// [`Self::new_with_session_paths`] instead.
354    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                    // Try to map persisted name back to Editor variant
418                    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    /// Create a fresh application state with sensible defaults.
435    ///
436    /// Loads persisted settings (theme, recent repos) from disk when available.
437    /// Always starts with one empty tab — use [`Self::new_with_session_paths`] to
438    /// restore the full multi-tab session.
439    pub fn new() -> Self {
440        Self::from_settings(
441            gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default(),
442        )
443    }
444
445    /// Create state and also return the saved tab paths for startup restore.
446    ///
447    /// Call this from `main.rs` instead of [`Self::new`]; it sets up loading tabs
448    /// for every path in the persisted session and returns those paths so the
449    /// caller can spawn parallel `load_repo_at` tasks.
450    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                    // Set the path now so the tab bar shows the right name
464                    // while the repo is being loaded in the background.
465                    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    /// Paths of all tabs where a repository has been fully loaded
486    /// (`repo_info` is populated). Used to persist the multi-tab session.
487    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    /// Get a reference to the currently active tab.
496    pub fn active_tab(&self) -> &RepoTab {
497        &self.tabs[self.active_tab]
498    }
499
500    /// Get a mutable reference to the currently active tab.
501    pub fn active_tab_mut(&mut self) -> &mut RepoTab {
502        &mut self.tabs[self.active_tab]
503    }
504
505    /// Whether the active tab has a repository open.
506    pub fn has_repo(&self) -> bool {
507        self.active_tab().has_repo()
508    }
509
510    /// Helper: the display name for the active tab's repo.
511    pub fn repo_display_name(&self) -> &str {
512        self.active_tab().display_name()
513    }
514
515    /// Derive the full [`ThemeColors`] from the currently active core theme.
516    ///
517    /// Call this at the top of view functions:
518    /// ```ignore
519    /// let c = state.colors();
520    /// ```
521    pub fn colors(&self) -> ThemeColors {
522        ThemeColors::from_core(&gitkraft_core::theme_by_index(self.current_theme_index))
523    }
524
525    /// Return a **custom** `iced::Theme` whose `Palette` is derived from the
526    /// active core theme.
527    ///
528    /// This is the key to making every built-in Iced widget (text inputs,
529    /// pick-lists, scrollbars, buttons without explicit `.style()`, etc.)
530    /// inherit the correct background, text, accent, success and danger
531    /// colours.  Without this, Iced falls back to its generic Dark/Light
532    /// palette and the UI looks wrong for every non-default theme.
533    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    /// The display name of the currently active theme.
550    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    /// Refresh all data for the currently active tab's repository.
558    ///
559    /// Returns [`Task::none()`] if no repository is open in the active tab.
560    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    /// Handle a `Result<(), String>` from a git operation that should trigger
568    /// a full repository refresh on success.
569    ///
570    /// * `Ok(())` — clears `is_loading`, sets `status_message`, refreshes.
571    /// * `Err(e)` — clears `is_loading`, sets `error_message`, returns
572    ///   [`Task::none()`].
573    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    /// Build a [`LayoutSettings`] snapshot from the current pane dimensions.
599    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
611/// Convert a core [`gitkraft_core::Rgb`] to an [`iced::Color`].
612fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
613    Color::from_rgb8(rgb.r, rgb.g, rgb.b)
614}
615
616/// Try to detect the system's preferred editor from environment variables.
617fn 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// ── Tests ─────────────────────────────────────────────────────────────────────
639
640#[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        // Default theme index should be valid
653        assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
654        // Pane defaults
655        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        // Should start with one empty tab
661        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        // The default theme (index 0) is dark, so background should be dark
677        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        // Index 0 = Default (dark) — custom theme with dark background
685        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        // Index 11 = Solarized Light — custom theme with light background
692        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        // Index 12 = Gruvbox Dark — accent should come from core
699        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        // Ensure the custom theme name matches a core THEME_NAMES entry so
715        // that ThemeColors::from_theme() can map it back to the right index.
716        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        // Verify all context menu variants can be constructed
771        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        // Just verify it doesn't panic
834        let editor = super::detect_system_editor();
835        let _ = editor.display_name();
836    }
837}