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
312impl Default for GitKraft {
313    fn default() -> Self {
314        Self::new()
315    }
316}
317
318impl GitKraft {
319    /// Build application state from persisted [`AppSettings`].
320    ///
321    /// Starts with a single empty tab regardless of what was saved — callers
322    /// that want to restore the full session should use
323    /// [`Self::new_with_session_paths`] instead.
324    fn from_settings(settings: gitkraft_core::AppSettings) -> Self {
325        let current_theme_index = settings
326            .theme_name
327            .as_deref()
328            .map(gitkraft_core::theme_index_by_name)
329            .unwrap_or(0);
330
331        let recent_repos = settings.recent_repos;
332
333        let (
334            sidebar_width,
335            commit_log_width,
336            staging_height,
337            diff_file_list_width,
338            sidebar_expanded,
339            ui_scale,
340        ) = if let Some(ref layout) = settings.layout {
341            (
342                layout.sidebar_width.unwrap_or(220.0),
343                layout.commit_log_width.unwrap_or(500.0),
344                layout.staging_height.unwrap_or(200.0),
345                layout.diff_file_list_width.unwrap_or(180.0),
346                layout.sidebar_expanded.unwrap_or(true),
347                layout.ui_scale.unwrap_or(1.0),
348            )
349        } else {
350            (220.0, 500.0, 200.0, 180.0, true, 1.0)
351        };
352
353        Self {
354            tabs: vec![RepoTab::new_empty()],
355            active_tab: 0,
356
357            sidebar_expanded,
358
359            sidebar_width,
360            commit_log_width,
361            staging_height,
362            diff_file_list_width,
363
364            ui_scale,
365
366            dragging: None,
367            dragging_h: None,
368            drag_start_x: 0.0,
369            drag_start_y: 0.0,
370            drag_initialized: false,
371            drag_initialized_h: false,
372            cursor_pos: Point::ORIGIN,
373
374            current_theme_index,
375
376            recent_repos,
377        }
378    }
379
380    /// Create a fresh application state with sensible defaults.
381    ///
382    /// Loads persisted settings (theme, recent repos) from disk when available.
383    /// Always starts with one empty tab — use [`Self::new_with_session_paths`] to
384    /// restore the full multi-tab session.
385    pub fn new() -> Self {
386        Self::from_settings(
387            gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default(),
388        )
389    }
390
391    /// Create state and also return the saved tab paths for startup restore.
392    ///
393    /// Call this from `main.rs` instead of [`Self::new`]; it sets up loading tabs
394    /// for every path in the persisted session and returns those paths so the
395    /// caller can spawn parallel `load_repo_at` tasks.
396    pub fn new_with_session_paths() -> (Self, Vec<PathBuf>) {
397        let settings =
398            gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default();
399        let open_tabs = settings.open_tabs.clone();
400        let active_tab_index = settings.active_tab_index;
401
402        let mut state = Self::from_settings(settings);
403
404        if !open_tabs.is_empty() {
405            state.tabs = open_tabs
406                .iter()
407                .map(|path| {
408                    let mut tab = RepoTab::new_empty();
409                    // Set the path now so the tab bar shows the right name
410                    // while the repo is being loaded in the background.
411                    tab.repo_path = Some(path.clone());
412                    if path.exists() {
413                        tab.is_loading = true;
414                        tab.status_message = Some(format!(
415                            "Loading {}…",
416                            path.file_name().unwrap_or_default().to_string_lossy()
417                        ));
418                    } else {
419                        tab.error_message =
420                            Some(format!("Repository not found: {}", path.display()));
421                    }
422                    tab
423                })
424                .collect();
425            state.active_tab = active_tab_index.min(state.tabs.len().saturating_sub(1));
426        }
427
428        (state, open_tabs)
429    }
430
431    /// Paths of all tabs where a repository has been fully loaded
432    /// (`repo_info` is populated). Used to persist the multi-tab session.
433    pub fn open_tab_paths(&self) -> Vec<PathBuf> {
434        self.tabs
435            .iter()
436            .filter(|t| t.repo_info.is_some())
437            .filter_map(|t| t.repo_path.clone())
438            .collect()
439    }
440
441    /// Get a reference to the currently active tab.
442    pub fn active_tab(&self) -> &RepoTab {
443        &self.tabs[self.active_tab]
444    }
445
446    /// Get a mutable reference to the currently active tab.
447    pub fn active_tab_mut(&mut self) -> &mut RepoTab {
448        &mut self.tabs[self.active_tab]
449    }
450
451    /// Whether the active tab has a repository open.
452    pub fn has_repo(&self) -> bool {
453        self.active_tab().has_repo()
454    }
455
456    /// Helper: the display name for the active tab's repo.
457    pub fn repo_display_name(&self) -> &str {
458        self.active_tab().display_name()
459    }
460
461    /// Derive the full [`ThemeColors`] from the currently active core theme.
462    ///
463    /// Call this at the top of view functions:
464    /// ```ignore
465    /// let c = state.colors();
466    /// ```
467    pub fn colors(&self) -> ThemeColors {
468        ThemeColors::from_core(&gitkraft_core::theme_by_index(self.current_theme_index))
469    }
470
471    /// Return a **custom** `iced::Theme` whose `Palette` is derived from the
472    /// active core theme.
473    ///
474    /// This is the key to making every built-in Iced widget (text inputs,
475    /// pick-lists, scrollbars, buttons without explicit `.style()`, etc.)
476    /// inherit the correct background, text, accent, success and danger
477    /// colours.  Without this, Iced falls back to its generic Dark/Light
478    /// palette and the UI looks wrong for every non-default theme.
479    pub fn iced_theme(&self) -> iced::Theme {
480        let core = gitkraft_core::theme_by_index(self.current_theme_index);
481        let name = self.current_theme_name().to_string();
482
483        let palette = iced::theme::Palette {
484            background: rgb_to_iced(core.background),
485            text: rgb_to_iced(core.text_primary),
486            primary: rgb_to_iced(core.accent),
487            success: rgb_to_iced(core.success),
488            danger: rgb_to_iced(core.error),
489        };
490
491        iced::Theme::custom(name, palette)
492    }
493
494    /// The display name of the currently active theme.
495    pub fn current_theme_name(&self) -> &'static str {
496        gitkraft_core::THEME_NAMES
497            .get(self.current_theme_index)
498            .copied()
499            .unwrap_or("Default")
500    }
501
502    /// Refresh all data for the currently active tab's repository.
503    ///
504    /// Returns [`Task::none()`] if no repository is open in the active tab.
505    pub fn refresh_active_tab(&mut self) -> Task<Message> {
506        match self.active_tab().repo_path.clone() {
507            Some(path) => crate::features::repo::commands::refresh_repo(path),
508            None => Task::none(),
509        }
510    }
511
512    /// Handle a `Result<(), String>` from a git operation that should trigger
513    /// a full repository refresh on success.
514    ///
515    /// * `Ok(())` — clears `is_loading`, sets `status_message`, refreshes.
516    /// * `Err(e)` — clears `is_loading`, sets `error_message`, returns
517    ///   [`Task::none()`].
518    pub fn on_ok_refresh(
519        &mut self,
520        result: Result<(), String>,
521        ok_msg: &str,
522        err_prefix: &str,
523    ) -> Task<Message> {
524        match result {
525            Ok(()) => {
526                {
527                    let tab = self.active_tab_mut();
528                    tab.is_loading = false;
529                    tab.status_message = Some(ok_msg.to_string());
530                }
531                self.refresh_active_tab()
532            }
533            Err(e) => {
534                let tab = self.active_tab_mut();
535                tab.is_loading = false;
536                tab.error_message = Some(format!("{err_prefix}: {e}"));
537                tab.status_message = None;
538                Task::none()
539            }
540        }
541    }
542
543    /// Build a [`LayoutSettings`] snapshot from the current pane dimensions.
544    pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
545        gitkraft_core::LayoutSettings {
546            sidebar_width: Some(self.sidebar_width),
547            commit_log_width: Some(self.commit_log_width),
548            staging_height: Some(self.staging_height),
549            diff_file_list_width: Some(self.diff_file_list_width),
550            sidebar_expanded: Some(self.sidebar_expanded),
551            ui_scale: Some(self.ui_scale),
552        }
553    }
554}
555
556/// Convert a core [`gitkraft_core::Rgb`] to an [`iced::Color`].
557fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
558    Color::from_rgb8(rgb.r, rgb.g, rgb.b)
559}
560
561// ── Tests ─────────────────────────────────────────────────────────────────────
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    #[test]
568    fn new_defaults() {
569        let state = GitKraft::new();
570        assert!(state.active_tab().repo_path.is_none());
571        assert!(!state.has_repo());
572        assert_eq!(state.repo_display_name(), "New Tab");
573        assert!(state.active_tab().commits.is_empty());
574        assert!(state.sidebar_expanded);
575        // Default theme index should be valid
576        assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
577        // Pane defaults
578        assert!(state.sidebar_width > 0.0);
579        assert!(state.commit_log_width > 0.0);
580        assert!(state.staging_height > 0.0);
581        assert!(state.dragging.is_none());
582        assert!(state.dragging_h.is_none());
583        // Should start with one empty tab
584        assert_eq!(state.tabs.len(), 1);
585        assert_eq!(state.active_tab, 0);
586    }
587
588    #[test]
589    fn repo_display_name_extracts_basename() {
590        let mut state = GitKraft::new();
591        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
592        assert_eq!(state.repo_display_name(), "my-project");
593    }
594
595    #[test]
596    fn colors_returns_theme_colors() {
597        let state = GitKraft::new();
598        let c = state.colors();
599        // The default theme (index 0) is dark, so background should be dark
600        assert!(c.bg.r < 0.5);
601    }
602
603    #[test]
604    fn iced_theme_is_custom_with_correct_palette() {
605        let mut state = GitKraft::new();
606
607        // Index 0 = Default (dark) — custom theme with dark background
608        state.current_theme_index = 0;
609        let iced_t = state.iced_theme();
610        let pal = iced_t.palette();
611        assert!(pal.background.r < 0.5, "Default theme bg should be dark");
612        assert_eq!(iced_t.to_string(), "Default");
613
614        // Index 11 = Solarized Light — custom theme with light background
615        state.current_theme_index = 11;
616        let iced_t = state.iced_theme();
617        let pal = iced_t.palette();
618        assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
619        assert_eq!(iced_t.to_string(), "Solarized Light");
620
621        // Index 12 = Gruvbox Dark — accent should come from core
622        state.current_theme_index = 12;
623        let iced_t = state.iced_theme();
624        let pal = iced_t.palette();
625        let core = gitkraft_core::theme_by_index(12);
626        let expected_accent = rgb_to_iced(core.accent);
627        assert!(
628            (pal.primary.r - expected_accent.r).abs() < 0.01
629                && (pal.primary.g - expected_accent.g).abs() < 0.01
630                && (pal.primary.b - expected_accent.b).abs() < 0.01,
631            "Gruvbox Dark accent should match core accent"
632        );
633    }
634
635    #[test]
636    fn iced_theme_name_round_trips_through_core() {
637        // Ensure the custom theme name matches a core THEME_NAMES entry so
638        // that ThemeColors::from_theme() can map it back to the right index.
639        for i in 0..gitkraft_core::THEME_COUNT {
640            let mut state = GitKraft::new();
641            state.current_theme_index = i;
642            let iced_t = state.iced_theme();
643            let name = iced_t.to_string();
644            let resolved = gitkraft_core::theme_index_by_name(&name);
645            assert_eq!(
646                resolved,
647                i,
648                "theme index {i} ({}) did not round-trip through iced_theme name",
649                gitkraft_core::THEME_NAMES[i]
650            );
651        }
652    }
653
654    #[test]
655    fn current_theme_name_round_trips() {
656        let mut state = GitKraft::new();
657        state.current_theme_index = 8;
658        assert_eq!(state.current_theme_name(), "Dracula");
659        state.current_theme_index = 0;
660        assert_eq!(state.current_theme_name(), "Default");
661    }
662
663    #[test]
664    fn repo_tab_new_empty() {
665        let tab = RepoTab::new_empty();
666        assert!(tab.repo_path.is_none());
667        assert!(!tab.has_repo());
668        assert_eq!(tab.display_name(), "New Tab");
669        assert!(tab.commits.is_empty());
670        assert!(tab.branches.is_empty());
671        assert!(!tab.is_loading);
672    }
673
674    #[test]
675    fn repo_tab_display_name_with_path() {
676        let mut tab = RepoTab::new_empty();
677        tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
678        assert!(tab.has_repo());
679        assert_eq!(tab.display_name(), "cool-repo");
680    }
681}