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}
46
47// ── Per-repository tab state ──────────────────────────────────────────────────
48
49/// Per-repository state — one instance per open tab.
50pub struct RepoTab {
51    // ── Repository ────────────────────────────────────────────────────────
52    /// Path to the currently opened repository (workdir root).
53    pub repo_path: Option<PathBuf>,
54    /// High-level information about the opened repository.
55    pub repo_info: Option<RepoInfo>,
56
57    // ── Branches ──────────────────────────────────────────────────────────
58    /// All branches (local + remote) in the repository.
59    pub branches: Vec<BranchInfo>,
60    /// Name of the currently checked-out branch.
61    pub current_branch: Option<String>,
62
63    // ── Commits ───────────────────────────────────────────────────────────
64    /// Commit log (newest first).
65    pub commits: Vec<CommitInfo>,
66    /// Index into `commits` of the currently selected commit.
67    pub selected_commit: Option<usize>,
68    /// Per-commit graph layout rows for branch visualisation.
69    pub graph_rows: Vec<gitkraft_core::GraphRow>,
70
71    // ── Diff / Staging ────────────────────────────────────────────────────
72    /// Unstaged (working-directory) changes.
73    pub unstaged_changes: Vec<DiffInfo>,
74    /// Staged (index) changes.
75    pub staged_changes: Vec<DiffInfo>,
76    /// Lightweight file list for the currently selected commit (path + status only).
77    pub commit_files: Vec<gitkraft_core::DiffFileEntry>,
78    /// OID of the currently selected commit (needed for on-demand file diff loading).
79    pub selected_commit_oid: Option<String>,
80    /// Index of the selected file in `commit_files`.
81    pub selected_file_index: Option<usize>,
82    /// True while a single-file diff is being loaded.
83    pub is_loading_file_diff: bool,
84    /// The diff currently displayed in the diff viewer panel.
85    pub selected_diff: Option<DiffInfo>,
86    /// Text in the commit-message input.
87    pub commit_message: String,
88
89    // ── Stash ─────────────────────────────────────────────────────────────
90    /// All stash entries.
91    pub stashes: Vec<StashEntry>,
92
93    // ── Remotes ───────────────────────────────────────────────────────────
94    /// Configured remotes.
95    pub remotes: Vec<RemoteInfo>,
96
97    // ── Per-tab UI state ──────────────────────────────────────────────────
98    /// Whether the commit detail pane is visible.
99    pub show_commit_detail: bool,
100    /// Text in the "new branch name" input.
101    pub new_branch_name: String,
102    /// Whether the inline branch-creation UI is visible.
103    pub show_branch_create: bool,
104    /// Whether the Local branches section is expanded.
105    pub local_branches_expanded: bool,
106    /// Whether the Remote branches section is expanded.
107    pub remote_branches_expanded: bool,
108    /// Text in the "stash message" input.
109    pub stash_message: String,
110
111    /// File path pending discard confirmation (None = no pending discard).
112    pub pending_discard: Option<String>,
113
114    // ── Feedback ──────────────────────────────────────────────────────────
115    /// Transient status-bar message (e.g. "Branch created").
116    pub status_message: Option<String>,
117    /// Error message shown in a banner / toast.
118    pub error_message: Option<String>,
119    /// True while an async operation is in flight.
120    pub is_loading: bool,
121    /// Cursor position captured at the moment the context menu was opened.
122    /// Used to anchor the menu so it doesn't follow the mouse after appearing.
123    pub context_menu_pos: (f32, f32),
124
125    /// Currently open context menu, if any.
126    pub context_menu: Option<ContextMenu>,
127    /// Name of the branch currently being renamed (None = not renaming).
128    pub rename_branch_target: Option<String>,
129    /// The new name being typed in the rename input.
130    pub rename_branch_input: String,
131
132    /// When `Some(oid)`, the tag-creation inline form is visible, targeting that OID.
133    pub create_tag_target_oid: Option<String>,
134    /// True when creating an annotated tag; false for a lightweight tag.
135    pub create_tag_annotated: bool,
136    /// The tag name the user is typing.
137    pub create_tag_name: String,
138    /// The annotated tag message the user is typing (only used when `create_tag_annotated` is true).
139    pub create_tag_message: String,
140
141    /// Current scroll offset of the commit log in pixels.
142    /// Tracked via `on_scroll` so virtual scrolling can render only the
143    /// visible window of rows.
144    pub commit_scroll_offset: f32,
145
146    /// Current scroll offset of the diff viewer in pixels.
147    pub diff_scroll_offset: f32,
148    /// Pre-computed display strings for each commit:
149    /// `(truncated_summary, relative_time, truncated_author)`.
150    /// Computed once when commits load to avoid per-frame string allocations.
151    pub commit_display: Vec<(String, String, String)>,
152
153    /// Whether there are potentially more commits to load beyond those already shown.
154    pub has_more_commits: bool,
155    /// Guard: true while a background load-more task is in flight (prevents duplicates).
156    pub is_loading_more_commits: bool,
157}
158
159impl RepoTab {
160    /// Create an empty tab (no repo open — shows welcome screen).
161    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    /// Whether a repository is currently open in this tab.
207    pub fn has_repo(&self) -> bool {
208        self.repo_path.is_some()
209    }
210
211    /// Display name for the tab (last path component, or "New Tab").
212    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    /// Apply a full repo payload to this tab, resetting transient UI state.
221    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        // Reset transient UI state.
238        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
254// ── Top-level application state ───────────────────────────────────────────────
255
256/// Top-level application state for the GitKraft GUI.
257pub struct GitKraft {
258    // ── Tabs ──────────────────────────────────────────────────────────────
259    /// All open repository tabs.
260    pub tabs: Vec<RepoTab>,
261    /// Index of the currently active/visible tab.
262    pub active_tab: usize,
263
264    // ── UI state (global, not per-tab) ────────────────────────────────────
265    /// Whether the left sidebar is expanded.
266    pub sidebar_expanded: bool,
267
268    // ── Pane widths / heights (pixels) ────────────────────────────────────
269    /// Width of the left sidebar in pixels.
270    pub sidebar_width: f32,
271    /// Width of the commit-log panel in pixels.
272    pub commit_log_width: f32,
273    /// Height of the staging area in pixels.
274    pub staging_height: f32,
275    /// Width of the diff file-list sidebar in pixels.
276    pub diff_file_list_width: f32,
277
278    /// UI scale factor (1.0 = default). Adjusted with Ctrl+/Ctrl- keyboard shortcuts.
279    pub ui_scale: f32,
280
281    // ── Drag state ────────────────────────────────────────────────────────
282    /// Which vertical divider is being dragged (if any).
283    pub dragging: Option<DragTarget>,
284    /// Which horizontal divider is being dragged (if any).
285    pub dragging_h: Option<DragTargetH>,
286    /// Last known mouse X position during a drag (absolute window coords).
287    pub drag_start_x: f32,
288    /// Last known mouse Y position during a drag (absolute window coords).
289    pub drag_start_y: f32,
290    /// Whether the first move event has been received for the current vertical drag.
291    /// `false` right after `PaneDragStart` — the first `PaneDragMove` sets the
292    /// real start position instead of computing a bogus delta from 0.0.
293    pub drag_initialized: bool,
294    /// Same as `drag_initialized` but for horizontal drags.
295    pub drag_initialized_h: bool,
296
297    // ── Cursor ────────────────────────────────────────────────────────────
298    /// Last known cursor position in window coordinates.
299    /// Updated on every mouse-move event so context menus open at the
300    /// exact spot the user right-clicked.
301    pub cursor_pos: Point,
302
303    // ── Theme ─────────────────────────────────────────────────────────────
304    /// Index into `gitkraft_core::THEME_NAMES` for the currently active theme.
305    pub current_theme_index: usize,
306
307    // ── Persistence ───────────────────────────────────────────────────────
308    /// Recently opened repositories (loaded from settings on startup).
309    pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
310
311    // ── Search ────────────────────────────────────────────────────────────
312    /// Whether the search overlay is visible.
313    pub search_visible: bool,
314    /// Current search query text.
315    pub search_query: String,
316    /// Search results (commit infos matching the query).
317    pub search_results: Vec<gitkraft_core::CommitInfo>,
318    /// Index of the selected search result.
319    pub search_selected: Option<usize>,
320}
321
322impl Default for GitKraft {
323    fn default() -> Self {
324        Self::new()
325    }
326}
327
328impl GitKraft {
329    /// Build application state from persisted [`AppSettings`].
330    ///
331    /// Starts with a single empty tab regardless of what was saved — callers
332    /// that want to restore the full session should use
333    /// [`Self::new_with_session_paths`] instead.
334    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    /// Create a fresh application state with sensible defaults.
396    ///
397    /// Loads persisted settings (theme, recent repos) from disk when available.
398    /// Always starts with one empty tab — use [`Self::new_with_session_paths`] to
399    /// restore the full multi-tab session.
400    pub fn new() -> Self {
401        Self::from_settings(
402            gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default(),
403        )
404    }
405
406    /// Create state and also return the saved tab paths for startup restore.
407    ///
408    /// Call this from `main.rs` instead of [`Self::new`]; it sets up loading tabs
409    /// for every path in the persisted session and returns those paths so the
410    /// caller can spawn parallel `load_repo_at` tasks.
411    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                    // Set the path now so the tab bar shows the right name
425                    // while the repo is being loaded in the background.
426                    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    /// Paths of all tabs where a repository has been fully loaded
447    /// (`repo_info` is populated). Used to persist the multi-tab session.
448    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    /// Get a reference to the currently active tab.
457    pub fn active_tab(&self) -> &RepoTab {
458        &self.tabs[self.active_tab]
459    }
460
461    /// Get a mutable reference to the currently active tab.
462    pub fn active_tab_mut(&mut self) -> &mut RepoTab {
463        &mut self.tabs[self.active_tab]
464    }
465
466    /// Whether the active tab has a repository open.
467    pub fn has_repo(&self) -> bool {
468        self.active_tab().has_repo()
469    }
470
471    /// Helper: the display name for the active tab's repo.
472    pub fn repo_display_name(&self) -> &str {
473        self.active_tab().display_name()
474    }
475
476    /// Derive the full [`ThemeColors`] from the currently active core theme.
477    ///
478    /// Call this at the top of view functions:
479    /// ```ignore
480    /// let c = state.colors();
481    /// ```
482    pub fn colors(&self) -> ThemeColors {
483        ThemeColors::from_core(&gitkraft_core::theme_by_index(self.current_theme_index))
484    }
485
486    /// Return a **custom** `iced::Theme` whose `Palette` is derived from the
487    /// active core theme.
488    ///
489    /// This is the key to making every built-in Iced widget (text inputs,
490    /// pick-lists, scrollbars, buttons without explicit `.style()`, etc.)
491    /// inherit the correct background, text, accent, success and danger
492    /// colours.  Without this, Iced falls back to its generic Dark/Light
493    /// palette and the UI looks wrong for every non-default theme.
494    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    /// The display name of the currently active theme.
511    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    /// Refresh all data for the currently active tab's repository.
519    ///
520    /// Returns [`Task::none()`] if no repository is open in the active tab.
521    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    /// Handle a `Result<(), String>` from a git operation that should trigger
529    /// a full repository refresh on success.
530    ///
531    /// * `Ok(())` — clears `is_loading`, sets `status_message`, refreshes.
532    /// * `Err(e)` — clears `is_loading`, sets `error_message`, returns
533    ///   [`Task::none()`].
534    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    /// Build a [`LayoutSettings`] snapshot from the current pane dimensions.
560    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
572/// Convert a core [`gitkraft_core::Rgb`] to an [`iced::Color`].
573fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
574    Color::from_rgb8(rgb.r, rgb.g, rgb.b)
575}
576
577// ── Tests ─────────────────────────────────────────────────────────────────────
578
579#[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        // Default theme index should be valid
592        assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
593        // Pane defaults
594        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        // Should start with one empty tab
600        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        // The default theme (index 0) is dark, so background should be dark
616        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        // Index 0 = Default (dark) — custom theme with dark background
624        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        // Index 11 = Solarized Light — custom theme with light background
631        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        // Index 12 = Gruvbox Dark — accent should come from core
638        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        // Ensure the custom theme name matches a core THEME_NAMES entry so
654        // that ThemeColors::from_theme() can map it back to the right index.
655        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}