Skip to main content

gitkraft_gui/
state.rs

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