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    /// Anchor commit index for range selection — set on a plain click.
78    pub anchor_commit_index: Option<usize>,
79    /// Ordered (ascending) list of commit indices in the current range selection.
80    pub selected_commits: Vec<usize>,
81    /// Per-commit graph layout rows for branch visualisation.
82    pub graph_rows: Vec<gitkraft_core::GraphRow>,
83
84    // ── Diff / Staging ────────────────────────────────────────────────────
85    /// Unstaged (working-directory) changes.
86    pub unstaged_changes: Vec<DiffInfo>,
87    /// Staged (index) changes.
88    pub staged_changes: Vec<DiffInfo>,
89    /// Lightweight file list for the currently selected commit (path + status only).
90    pub commit_files: Vec<gitkraft_core::DiffFileEntry>,
91    /// OID of the currently selected commit (needed for on-demand file diff loading).
92    pub selected_commit_oid: Option<String>,
93    /// Index of the selected file in `commit_files`.
94    pub selected_file_index: Option<usize>,
95    /// True while a single-file diff is being loaded.
96    pub is_loading_file_diff: bool,
97    /// Anchor index for range selection — set on a regular click, kept fixed while
98    /// the user extends the selection with Shift+Click.
99    pub anchor_file_index: Option<usize>,
100    /// Ordered list of file indices currently multi-selected in the commit file list (Shift+Click).
101    /// Always stored in ascending index order (lowest index first).
102    pub selected_commit_file_indices: Vec<usize>,
103    /// Diffs for all multi-selected files (populated when 2+ files are selected).
104    pub multi_file_diffs: Vec<gitkraft_core::DiffInfo>,
105    /// Combined diff for all commits in the current range selection (populated when
106    /// `selected_commits.len() > 1`). Shown in the diff panel instead of single-commit diff.
107    pub commit_range_diffs: Vec<gitkraft_core::DiffInfo>,
108    /// The diff currently displayed in the diff viewer panel.
109    pub selected_diff: Option<DiffInfo>,
110    /// Text in the commit-message input.
111    pub commit_message: String,
112
113    // ── Stash ─────────────────────────────────────────────────────────────
114    /// All stash entries.
115    pub stashes: Vec<StashEntry>,
116
117    // ── Remotes ───────────────────────────────────────────────────────────
118    /// Configured remotes.
119    pub remotes: Vec<RemoteInfo>,
120
121    // ── Per-tab UI state ──────────────────────────────────────────────────
122    /// Whether the commit detail pane is visible.
123    pub show_commit_detail: bool,
124    /// Text in the "new branch name" input.
125    pub new_branch_name: String,
126    /// Whether the inline branch-creation UI is visible.
127    pub show_branch_create: bool,
128    /// Whether the Local branches section is expanded.
129    pub local_branches_expanded: bool,
130    /// Whether the Remote branches section is expanded.
131    pub remote_branches_expanded: bool,
132    /// Text in the "stash message" input.
133    pub stash_message: String,
134
135    /// Set of selected unstaged file paths (for multi-select with Shift+Click).
136    pub selected_unstaged: std::collections::HashSet<String>,
137    /// Set of selected staged file paths (for multi-select with Shift+Click).
138    pub selected_staged: std::collections::HashSet<String>,
139
140    /// File path pending discard confirmation (None = no pending discard).
141    pub pending_discard: Option<String>,
142
143    // ── Feedback ──────────────────────────────────────────────────────────
144    /// Transient status-bar message (e.g. "Branch created").
145    pub status_message: Option<String>,
146    /// Error message shown in a banner / toast.
147    pub error_message: Option<String>,
148    /// True while an async operation is in flight.
149    pub is_loading: bool,
150    /// Cursor position captured at the moment the context menu was opened.
151    /// Used to anchor the menu so it doesn't follow the mouse after appearing.
152    pub context_menu_pos: (f32, f32),
153
154    /// Currently open context menu, if any.
155    pub context_menu: Option<ContextMenu>,
156    /// Name of the branch currently being renamed (None = not renaming).
157    pub rename_branch_target: Option<String>,
158    /// The new name being typed in the rename input.
159    pub rename_branch_input: String,
160
161    /// When `Some(oid)`, the tag-creation inline form is visible, targeting that OID.
162    pub create_tag_target_oid: Option<String>,
163    /// True when creating an annotated tag; false for a lightweight tag.
164    pub create_tag_annotated: bool,
165    /// The tag name the user is typing.
166    pub create_tag_name: String,
167    /// The annotated tag message the user is typing (only used when `create_tag_annotated` is true).
168    pub create_tag_message: String,
169    /// When `Some(oid)`, the inline "create branch at this commit" form is visible.
170    pub create_branch_at_oid: Option<String>,
171
172    /// Current scroll offset of the commit log in pixels.
173    /// Tracked via `on_scroll` so virtual scrolling can render only the
174    /// visible window of rows.
175    pub commit_scroll_offset: f32,
176
177    /// Current scroll offset of the diff viewer in pixels.
178    pub diff_scroll_offset: f32,
179    /// Pre-computed display strings for each commit:
180    /// `(truncated_summary, relative_time, truncated_author)`.
181    /// Computed once when commits load to avoid per-frame string allocations.
182    pub commit_display: Vec<(String, String, String)>,
183
184    /// Whether there are potentially more commits to load beyond those already shown.
185    pub has_more_commits: bool,
186    /// Guard: true while a background load-more task is in flight (prevents duplicates).
187    pub is_loading_more_commits: bool,
188
189    /// When `Some(path)`, the file-history overlay is shown for that repo-relative path.
190    pub file_history_path: Option<String>,
191    /// Commits loaded for the file-history overlay (newest first).
192    pub file_history_commits: Vec<gitkraft_core::CommitInfo>,
193    /// Scroll offset of the file-history list in pixels.
194    pub file_history_scroll: f32,
195
196    /// When `Some(path)`, the blame overlay is shown for that repo-relative path.
197    pub blame_path: Option<String>,
198    /// Blame lines loaded for the blame overlay.
199    pub blame_lines: Vec<gitkraft_core::BlameLine>,
200    /// Scroll offset of the blame view in pixels.
201    pub blame_scroll: f32,
202
203    /// When `Some(path)`, a delete-confirmation banner is shown for that file.
204    pub pending_delete_file: Option<String>,
205}
206
207impl RepoTab {
208    /// Create an empty tab (no repo open — shows welcome screen).
209    pub fn new_empty() -> Self {
210        Self {
211            repo_path: None,
212            repo_info: None,
213            branches: Vec::new(),
214            current_branch: None,
215            commits: Vec::new(),
216            selected_commit: None,
217            anchor_commit_index: None,
218            selected_commits: Vec::new(),
219            graph_rows: Vec::new(),
220            unstaged_changes: Vec::new(),
221            staged_changes: Vec::new(),
222            commit_files: Vec::new(),
223            selected_commit_oid: None,
224            selected_file_index: None,
225            is_loading_file_diff: false,
226            anchor_file_index: None,
227            selected_commit_file_indices: Vec::new(),
228            multi_file_diffs: Vec::new(),
229            commit_range_diffs: Vec::new(),
230            selected_diff: None,
231            commit_message: String::new(),
232            stashes: Vec::new(),
233            remotes: Vec::new(),
234            show_commit_detail: false,
235            new_branch_name: String::new(),
236            show_branch_create: false,
237            local_branches_expanded: true,
238            remote_branches_expanded: true,
239            stash_message: String::new(),
240            selected_unstaged: std::collections::HashSet::new(),
241            selected_staged: std::collections::HashSet::new(),
242            pending_discard: None,
243            status_message: None,
244            error_message: None,
245            is_loading: false,
246            context_menu: None,
247            context_menu_pos: (0.0, 0.0),
248            rename_branch_target: None,
249            rename_branch_input: String::new(),
250            create_tag_target_oid: None,
251            create_tag_annotated: false,
252            create_tag_name: String::new(),
253            create_tag_message: String::new(),
254            create_branch_at_oid: None,
255            commit_scroll_offset: 0.0,
256            diff_scroll_offset: 0.0,
257            commit_display: Vec::new(),
258            has_more_commits: true,
259            is_loading_more_commits: false,
260            file_history_path: None,
261            file_history_commits: Vec::new(),
262            file_history_scroll: 0.0,
263            blame_path: None,
264            blame_lines: Vec::new(),
265            blame_scroll: 0.0,
266            pending_delete_file: None,
267        }
268    }
269
270    /// Whether a repository is currently open in this tab.
271    pub fn has_repo(&self) -> bool {
272        self.repo_path.is_some()
273    }
274
275    /// Display name for the tab (last path component, or "New Tab").
276    pub fn display_name(&self) -> &str {
277        self.repo_path
278            .as_ref()
279            .and_then(|p| p.file_name())
280            .and_then(|n| n.to_str())
281            .unwrap_or("New Tab")
282    }
283
284    /// Apply a full repo payload to this tab, resetting transient UI state.
285    ///
286    /// The currently selected commit (if any) is **re-pinned** by OID after the
287    /// new commit list arrives, so background auto-refreshes (git-watcher or
288    /// staging changes) never clear the user's selection.
289    pub fn apply_payload(
290        &mut self,
291        payload: crate::message::RepoPayload,
292        path: std::path::PathBuf,
293    ) {
294        // ── Save selection so we can restore it after the data refresh ────
295        let prev_oid = self.selected_commit_oid.clone();
296
297        self.current_branch = payload.info.head_branch.clone();
298        self.repo_path = Some(path);
299        self.repo_info = Some(payload.info);
300        self.branches = payload.branches;
301        self.commits = payload.commits;
302        self.graph_rows = payload.graph_rows;
303        self.unstaged_changes = payload.unstaged;
304        self.staged_changes = payload.staged;
305        self.stashes = payload.stashes;
306        self.remotes = payload.remotes;
307
308        // Reset transient UI state.
309        // NOTE: selected_commit / commit_files / selected_diff are restored
310        // below so they survive background auto-refreshes.
311        self.selected_commit = None;
312        self.anchor_commit_index = None;
313        self.selected_commits.clear();
314        self.selected_commit_oid = None;
315        self.commit_message.clear();
316        self.error_message = None;
317        self.status_message = Some("Repository loaded.".into());
318        self.commit_scroll_offset = 0.0;
319        self.has_more_commits = true;
320        self.is_loading_more_commits = false;
321        self.selected_unstaged.clear();
322        self.selected_staged.clear();
323        self.anchor_file_index = None;
324        self.selected_commit_file_indices.clear();
325        self.multi_file_diffs.clear();
326        self.commit_range_diffs.clear();
327
328        // ── Restore the previously selected commit by OID ─────────────────
329        // If the commit still exists in the refreshed list, re-select it so
330        // the diff panel, file list, and selection highlight are all
331        // preserved.  This means auto-refreshes (every 5 s fallback, git
332        // watcher) never interrupt the user's view.
333        if let Some(oid) = prev_oid {
334            if let Some(new_idx) = self.commits.iter().position(|c| c.oid == oid) {
335                self.selected_commit = Some(new_idx);
336                self.selected_commit_oid = Some(oid);
337                // commit_files, selected_diff, selected_file_index,
338                // is_loading_file_diff, diff_scroll_offset are intentionally
339                // left unchanged — the commit content hasn't changed.
340            } else {
341                // Commit was rebased / force-pushed away — clear everything.
342                self.selected_diff = None;
343                self.commit_files.clear();
344                self.selected_file_index = None;
345                self.is_loading_file_diff = false;
346                self.diff_scroll_offset = 0.0;
347            }
348        } else {
349            // No previous selection — safe to clear diff state.
350            self.selected_diff = None;
351            self.commit_files.clear();
352            self.selected_file_index = None;
353            self.is_loading_file_diff = false;
354            self.diff_scroll_offset = 0.0;
355        }
356    }
357}
358
359// ── Top-level application state ───────────────────────────────────────────────
360
361/// Top-level application state for the GitKraft GUI.
362pub struct GitKraft {
363    // ── Tabs ──────────────────────────────────────────────────────────────
364    /// All open repository tabs.
365    pub tabs: Vec<RepoTab>,
366    /// Index of the currently active/visible tab.
367    pub active_tab: usize,
368
369    // ── UI state (global, not per-tab) ────────────────────────────────────
370    /// Whether the left sidebar is expanded.
371    pub sidebar_expanded: bool,
372
373    // ── Pane widths / heights (pixels) ────────────────────────────────────
374    /// Width of the left sidebar in pixels.
375    pub sidebar_width: f32,
376    /// Width of the commit-log panel in pixels.
377    pub commit_log_width: f32,
378    /// Height of the staging area in pixels.
379    pub staging_height: f32,
380    /// Width of the diff file-list sidebar in pixels.
381    pub diff_file_list_width: f32,
382
383    /// UI scale factor (1.0 = default). Adjusted with Ctrl+/Ctrl- keyboard shortcuts.
384    pub ui_scale: f32,
385
386    // ── Drag state ────────────────────────────────────────────────────────
387    /// Which vertical divider is being dragged (if any).
388    pub dragging: Option<DragTarget>,
389    /// Which horizontal divider is being dragged (if any).
390    pub dragging_h: Option<DragTargetH>,
391    /// Last known mouse X position during a drag (absolute window coords).
392    pub drag_start_x: f32,
393    /// Last known mouse Y position during a drag (absolute window coords).
394    pub drag_start_y: f32,
395    /// Whether the first move event has been received for the current vertical drag.
396    /// `false` right after `PaneDragStart` — the first `PaneDragMove` sets the
397    /// real start position instead of computing a bogus delta from 0.0.
398    pub drag_initialized: bool,
399    /// Same as `drag_initialized` but for horizontal drags.
400    pub drag_initialized_h: bool,
401
402    // ── Cursor ────────────────────────────────────────────────────────────
403    /// Last known cursor position in window coordinates.
404    /// Updated on every mouse-move event so context menus open at the
405    /// exact spot the user right-clicked.
406    pub cursor_pos: Point,
407
408    // ── Theme ─────────────────────────────────────────────────────────────
409    /// Index into `gitkraft_core::THEME_NAMES` for the currently active theme.
410    pub current_theme_index: usize,
411
412    // ── Persistence ───────────────────────────────────────────────────────
413    /// Recently opened repositories (loaded from settings on startup).
414    pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
415
416    // ── Search ────────────────────────────────────────────────────────────
417    /// Whether the search overlay is visible.
418    pub search_visible: bool,
419    /// Current search query text.
420    pub search_query: String,
421    /// Search results (commit infos matching the query).
422    pub search_results: Vec<gitkraft_core::CommitInfo>,
423    /// Index of the selected search result.
424    pub search_selected: Option<usize>,
425
426    /// Files changed between the selected search commit and working tree.
427    pub search_diff_files: Vec<gitkraft_core::DiffFileEntry>,
428    /// Selected file indices in the search diff file list.
429    pub search_diff_selected: HashSet<usize>,
430    /// The diff content for the currently viewed search diff file(s).
431    pub search_diff_content: Vec<gitkraft_core::DiffInfo>,
432    /// OID of the commit being diffed against working tree in search.
433    pub search_diff_oid: Option<String>,
434
435    /// Configured editor for "Open in editor" actions.
436    pub editor: gitkraft_core::Editor,
437
438    /// Current keyboard modifier state (updated via subscription).
439    pub keyboard_modifiers: iced::keyboard::Modifiers,
440
441    /// Monotonically-increasing counter incremented on every `AnimationTick`.
442    /// Drives the loading-spinner frame selection in all UI widgets.
443    pub animation_tick: u64,
444
445    // ── Window geometry ───────────────────────────────────────────────────
446    /// Last known window width (updated on WindowResized).
447    pub window_width: f32,
448    /// Last known window height (updated on WindowResized).
449    pub window_height: f32,
450    /// Last known window X position (updated on WindowMoved).
451    pub window_x: f32,
452    /// Last known window Y position (updated on WindowMoved).
453    pub window_y: f32,
454}
455
456impl Default for GitKraft {
457    fn default() -> Self {
458        Self::new()
459    }
460}
461
462impl GitKraft {
463    /// Build application state from persisted [`AppSettings`].
464    ///
465    /// Starts with a single empty tab regardless of what was saved — callers
466    /// that want to restore the full session should use
467    /// [`Self::new_with_session_paths`] instead.
468    fn from_settings(settings: gitkraft_core::AppSettings) -> Self {
469        let current_theme_index = settings
470            .theme_name
471            .as_deref()
472            .map(gitkraft_core::theme_index_by_name)
473            .unwrap_or(0);
474
475        let recent_repos = settings.recent_repos;
476
477        let (
478            sidebar_width,
479            commit_log_width,
480            staging_height,
481            diff_file_list_width,
482            sidebar_expanded,
483            ui_scale,
484        ) = if let Some(ref layout) = settings.layout {
485            (
486                layout.sidebar_width.unwrap_or(220.0),
487                layout.commit_log_width.unwrap_or(500.0),
488                layout.staging_height.unwrap_or(200.0),
489                layout.diff_file_list_width.unwrap_or(180.0),
490                layout.sidebar_expanded.unwrap_or(true),
491                layout.ui_scale.unwrap_or(1.0),
492            )
493        } else {
494            (220.0, 500.0, 200.0, 180.0, true, 1.0)
495        };
496
497        Self {
498            tabs: vec![RepoTab::new_empty()],
499            active_tab: 0,
500
501            sidebar_expanded,
502
503            sidebar_width,
504            commit_log_width,
505            staging_height,
506            diff_file_list_width,
507
508            ui_scale,
509
510            dragging: None,
511            dragging_h: None,
512            drag_start_x: 0.0,
513            drag_start_y: 0.0,
514            drag_initialized: false,
515            drag_initialized_h: false,
516            cursor_pos: Point::ORIGIN,
517
518            current_theme_index,
519
520            recent_repos,
521
522            search_visible: false,
523            search_query: String::new(),
524            search_results: Vec::new(),
525            search_selected: None,
526            search_diff_files: Vec::new(),
527            search_diff_selected: HashSet::new(),
528            search_diff_content: Vec::new(),
529            search_diff_oid: None,
530
531            keyboard_modifiers: iced::keyboard::Modifiers::default(),
532            animation_tick: 0,
533
534            window_width: settings
535                .layout
536                .as_ref()
537                .and_then(|l| l.window_width)
538                .unwrap_or(1400.0),
539            window_height: settings
540                .layout
541                .as_ref()
542                .and_then(|l| l.window_height)
543                .unwrap_or(800.0),
544            window_x: settings
545                .layout
546                .as_ref()
547                .and_then(|l| l.window_x)
548                .unwrap_or(0.0),
549            window_y: settings
550                .layout
551                .as_ref()
552                .and_then(|l| l.window_y)
553                .unwrap_or(0.0),
554
555            editor: settings
556                .editor_name
557                .as_deref()
558                .map(|name| {
559                    // Try to map persisted name back to Editor variant
560                    gitkraft_core::EDITOR_NAMES
561                        .iter()
562                        .position(|n| n.eq_ignore_ascii_case(name))
563                        .map(gitkraft_core::Editor::from_index)
564                        .unwrap_or_else(|| {
565                            if name.eq_ignore_ascii_case("none") {
566                                gitkraft_core::Editor::None
567                            } else {
568                                gitkraft_core::Editor::Custom(name.to_string())
569                            }
570                        })
571                })
572                .unwrap_or_else(detect_system_editor),
573        }
574    }
575
576    /// Create a fresh application state with sensible defaults.
577    ///
578    /// Loads persisted settings (theme, recent repos) from disk when available.
579    /// Always starts with one empty tab — use [`Self::new_with_session_paths`] to
580    /// restore the full multi-tab session.
581    pub fn new() -> Self {
582        Self::from_settings(
583            gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default(),
584        )
585    }
586
587    /// Create state and also return the saved tab paths for startup restore.
588    ///
589    /// Call this from `main.rs` instead of [`Self::new`]; it sets up loading tabs
590    /// for every path in the persisted session and returns those paths so the
591    /// caller can spawn parallel `load_repo_at` tasks.
592    pub fn new_with_session_paths() -> (Self, Vec<PathBuf>) {
593        let settings =
594            gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default();
595        let open_tabs = settings.open_tabs.clone();
596        let active_tab_index = settings.active_tab_index;
597
598        let mut state = Self::from_settings(settings);
599
600        if !open_tabs.is_empty() {
601            state.tabs = open_tabs
602                .iter()
603                .map(|path| {
604                    let mut tab = RepoTab::new_empty();
605                    // Set the path now so the tab bar shows the right name
606                    // while the repo is being loaded in the background.
607                    tab.repo_path = Some(path.clone());
608                    if path.exists() {
609                        tab.is_loading = true;
610                        tab.status_message = Some(format!(
611                            "Loading {}…",
612                            path.file_name().unwrap_or_default().to_string_lossy()
613                        ));
614                    } else {
615                        tab.error_message =
616                            Some(format!("Repository not found: {}", path.display()));
617                    }
618                    tab
619                })
620                .collect();
621            state.active_tab = active_tab_index.min(state.tabs.len().saturating_sub(1));
622        }
623
624        (state, open_tabs)
625    }
626
627    /// Paths of all tabs where a repository has been fully loaded
628    /// (`repo_info` is populated). Used to persist the multi-tab session.
629    pub fn open_tab_paths(&self) -> Vec<PathBuf> {
630        self.tabs
631            .iter()
632            .filter(|t| t.repo_info.is_some())
633            .filter_map(|t| t.repo_path.clone())
634            .collect()
635    }
636
637    /// Get a reference to the currently active tab.
638    pub fn active_tab(&self) -> &RepoTab {
639        &self.tabs[self.active_tab]
640    }
641
642    /// Get a mutable reference to the currently active tab.
643    pub fn active_tab_mut(&mut self) -> &mut RepoTab {
644        &mut self.tabs[self.active_tab]
645    }
646
647    /// Whether the active tab has a repository open.
648    pub fn has_repo(&self) -> bool {
649        self.active_tab().has_repo()
650    }
651
652    /// Helper: the display name for the active tab's repo.
653    pub fn repo_display_name(&self) -> &str {
654        self.active_tab().display_name()
655    }
656
657    /// Derive the full [`ThemeColors`] from the currently active core theme.
658    ///
659    /// Call this at the top of view functions:
660    /// ```ignore
661    /// let c = state.colors();
662    /// ```
663    pub fn colors(&self) -> ThemeColors {
664        ThemeColors::from_core(&gitkraft_core::theme_by_index(self.current_theme_index))
665    }
666
667    /// Return a **custom** `iced::Theme` whose `Palette` is derived from the
668    /// active core theme.
669    ///
670    /// This is the key to making every built-in Iced widget (text inputs,
671    /// pick-lists, scrollbars, buttons without explicit `.style()`, etc.)
672    /// inherit the correct background, text, accent, success and danger
673    /// colours.  Without this, Iced falls back to its generic Dark/Light
674    /// palette and the UI looks wrong for every non-default theme.
675    pub fn iced_theme(&self) -> iced::Theme {
676        let core = gitkraft_core::theme_by_index(self.current_theme_index);
677        let name = self.current_theme_name().to_string();
678
679        let palette = iced::theme::Palette {
680            background: rgb_to_iced(core.background),
681            text: rgb_to_iced(core.text_primary),
682            primary: rgb_to_iced(core.accent),
683            success: rgb_to_iced(core.success),
684            warning: rgb_to_iced(core.warning),
685            danger: rgb_to_iced(core.error),
686        };
687
688        iced::Theme::custom(name, palette)
689    }
690
691    /// The display name of the currently active theme.
692    pub fn current_theme_name(&self) -> &'static str {
693        gitkraft_core::THEME_NAMES
694            .get(self.current_theme_index)
695            .copied()
696            .unwrap_or("Default")
697    }
698
699    /// Refresh all data for the currently active tab's repository.
700    ///
701    /// Returns [`Task::none()`] if no repository is open in the active tab.
702    pub fn refresh_active_tab(&mut self) -> Task<Message> {
703        match self.active_tab().repo_path.clone() {
704            Some(path) => crate::features::repo::commands::refresh_repo(path),
705            None => Task::none(),
706        }
707    }
708
709    /// Handle a `Result<(), String>` from a git operation that should trigger
710    /// a full repository refresh on success.
711    ///
712    /// * `Ok(())` — clears `is_loading`, sets `status_message`, refreshes.
713    /// * `Err(e)` — clears `is_loading`, sets `error_message`, returns
714    ///   [`Task::none()`].
715    pub fn on_ok_refresh(
716        &mut self,
717        result: Result<(), String>,
718        ok_msg: &str,
719        err_prefix: &str,
720    ) -> Task<Message> {
721        match result {
722            Ok(()) => {
723                {
724                    let tab = self.active_tab_mut();
725                    tab.is_loading = false;
726                    tab.status_message = Some(ok_msg.to_string());
727                }
728                self.refresh_active_tab()
729            }
730            Err(e) => {
731                let tab = self.active_tab_mut();
732                tab.is_loading = false;
733                tab.error_message = Some(format!("{err_prefix}: {e}"));
734                tab.status_message = None;
735                Task::none()
736            }
737        }
738    }
739
740    /// Build a [`LayoutSettings`] snapshot from the current pane dimensions.
741    pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
742        gitkraft_core::LayoutSettings {
743            sidebar_width: Some(self.sidebar_width),
744            commit_log_width: Some(self.commit_log_width),
745            staging_height: Some(self.staging_height),
746            diff_file_list_width: Some(self.diff_file_list_width),
747            sidebar_expanded: Some(self.sidebar_expanded),
748            ui_scale: Some(self.ui_scale),
749            window_width: Some(self.window_width),
750            window_height: Some(self.window_height),
751            window_x: Some(self.window_x),
752            window_y: Some(self.window_y),
753            window_maximized: None, // not tracked
754        }
755    }
756}
757
758/// Convert a core [`gitkraft_core::Rgb`] to an [`iced::Color`].
759fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
760    Color::from_rgb8(rgb.r, rgb.g, rgb.b)
761}
762
763/// Try to detect the system's preferred editor from environment variables.
764fn detect_system_editor() -> gitkraft_core::Editor {
765    for var in ["VISUAL", "EDITOR"] {
766        if let Ok(val) = std::env::var(var) {
767            let bin = val.split('/').next_back().unwrap_or(&val).trim();
768            return match bin {
769                "nvim" | "neovim" => gitkraft_core::Editor::Neovim,
770                "vim" => gitkraft_core::Editor::Vim,
771                "hx" | "helix" => gitkraft_core::Editor::Helix,
772                "nano" => gitkraft_core::Editor::Nano,
773                "micro" => gitkraft_core::Editor::Micro,
774                "emacs" => gitkraft_core::Editor::Emacs,
775                "code" => gitkraft_core::Editor::VSCode,
776                "zed" => gitkraft_core::Editor::Zed,
777                "subl" => gitkraft_core::Editor::Sublime,
778                _ => gitkraft_core::Editor::Custom(val),
779            };
780        }
781    }
782    gitkraft_core::Editor::None
783}
784
785// ── Tests ─────────────────────────────────────────────────────────────────────
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790
791    #[test]
792    fn new_defaults() {
793        let state = GitKraft::new();
794        assert!(state.active_tab().repo_path.is_none());
795        assert!(!state.has_repo());
796        assert_eq!(state.repo_display_name(), "New Tab");
797        assert!(state.active_tab().commits.is_empty());
798        assert!(state.sidebar_expanded);
799        // Default theme index should be valid
800        assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
801        // Pane defaults
802        assert!(state.sidebar_width > 0.0);
803        assert!(state.commit_log_width > 0.0);
804        assert!(state.staging_height > 0.0);
805        assert!(state.dragging.is_none());
806        assert!(state.dragging_h.is_none());
807        // Should start with one empty tab
808        assert_eq!(state.tabs.len(), 1);
809        assert_eq!(state.active_tab, 0);
810    }
811
812    #[test]
813    fn repo_display_name_extracts_basename() {
814        let mut state = GitKraft::new();
815        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
816        assert_eq!(state.repo_display_name(), "my-project");
817    }
818
819    #[test]
820    fn colors_returns_theme_colors() {
821        let state = GitKraft::new();
822        let c = state.colors();
823        // The default theme (index 0) is dark, so background should be dark
824        assert!(c.bg.r < 0.5);
825    }
826
827    #[test]
828    fn iced_theme_is_custom_with_correct_palette() {
829        let mut state = GitKraft::new();
830
831        // Index 0 = Default (dark) — custom theme with dark background
832        state.current_theme_index = 0;
833        let iced_t = state.iced_theme();
834        let pal = iced_t.palette();
835        assert!(pal.background.r < 0.5, "Default theme bg should be dark");
836        assert_eq!(iced_t.to_string(), "Default");
837
838        // Index 11 = Solarized Light — custom theme with light background
839        state.current_theme_index = 11;
840        let iced_t = state.iced_theme();
841        let pal = iced_t.palette();
842        assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
843        assert_eq!(iced_t.to_string(), "Solarized Light");
844
845        // Index 12 = Gruvbox Dark — accent should come from core
846        state.current_theme_index = 12;
847        let iced_t = state.iced_theme();
848        let pal = iced_t.palette();
849        let core = gitkraft_core::theme_by_index(12);
850        let expected_accent = rgb_to_iced(core.accent);
851        assert!(
852            (pal.primary.r - expected_accent.r).abs() < 0.01
853                && (pal.primary.g - expected_accent.g).abs() < 0.01
854                && (pal.primary.b - expected_accent.b).abs() < 0.01,
855            "Gruvbox Dark accent should match core accent"
856        );
857    }
858
859    #[test]
860    fn iced_theme_name_round_trips_through_core() {
861        // Ensure the custom theme name matches a core THEME_NAMES entry so
862        // that ThemeColors::from_theme() can map it back to the right index.
863        for i in 0..gitkraft_core::THEME_COUNT {
864            let mut state = GitKraft::new();
865            state.current_theme_index = i;
866            let iced_t = state.iced_theme();
867            let name = iced_t.to_string();
868            let resolved = gitkraft_core::theme_index_by_name(&name);
869            assert_eq!(
870                resolved,
871                i,
872                "theme index {i} ({}) did not round-trip through iced_theme name",
873                gitkraft_core::THEME_NAMES[i]
874            );
875        }
876    }
877
878    #[test]
879    fn current_theme_name_round_trips() {
880        let mut state = GitKraft::new();
881        state.current_theme_index = 8;
882        assert_eq!(state.current_theme_name(), "Dracula");
883        state.current_theme_index = 0;
884        assert_eq!(state.current_theme_name(), "Default");
885    }
886
887    #[test]
888    fn repo_tab_new_empty() {
889        let tab = RepoTab::new_empty();
890        assert!(tab.repo_path.is_none());
891        assert!(!tab.has_repo());
892        assert_eq!(tab.display_name(), "New Tab");
893        assert!(tab.commits.is_empty());
894        assert!(tab.branches.is_empty());
895        assert!(!tab.is_loading);
896    }
897
898    #[test]
899    fn repo_tab_display_name_with_path() {
900        let mut tab = RepoTab::new_empty();
901        tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
902        assert!(tab.has_repo());
903        assert_eq!(tab.display_name(), "cool-repo");
904    }
905
906    #[test]
907    fn search_defaults() {
908        let state = GitKraft::new();
909        assert!(!state.search_visible);
910        assert!(state.search_query.is_empty());
911        assert!(state.search_results.is_empty());
912        assert!(state.search_selected.is_none());
913    }
914
915    #[test]
916    fn context_menu_variants_exist() {
917        // Verify all context menu variants can be constructed
918        use crate::state::ContextMenu;
919
920        let _branch = ContextMenu::Branch {
921            name: "main".to_string(),
922            is_current: true,
923            local_index: 0,
924        };
925        let _remote = ContextMenu::RemoteBranch {
926            name: "origin/main".to_string(),
927        };
928        let _commit = ContextMenu::Commit {
929            index: 0,
930            oid: "abc1234".to_string(),
931        };
932        let _stash = ContextMenu::Stash { index: 0 };
933        let _unstaged = ContextMenu::UnstagedFile {
934            path: "src/main.rs".to_string(),
935        };
936        let _staged = ContextMenu::StagedFile {
937            path: "src/lib.rs".to_string(),
938        };
939    }
940
941    #[test]
942    fn repo_tab_context_menu_defaults_to_none() {
943        let tab = crate::state::RepoTab::new_empty();
944        assert!(tab.context_menu.is_none());
945    }
946
947    #[test]
948    fn context_menu_variants_constructable() {
949        use crate::state::ContextMenu;
950        let _ = ContextMenu::Stash { index: 0 };
951        let _ = ContextMenu::UnstagedFile {
952            path: "a.rs".into(),
953        };
954        let _ = ContextMenu::StagedFile {
955            path: "b.rs".into(),
956        };
957    }
958
959    #[test]
960    fn selected_unstaged_defaults_empty() {
961        let tab = crate::state::RepoTab::new_empty();
962        assert!(tab.selected_unstaged.is_empty());
963        assert!(tab.selected_staged.is_empty());
964    }
965
966    #[test]
967    fn selected_unstaged_toggle() {
968        let mut tab = crate::state::RepoTab::new_empty();
969        tab.selected_unstaged.insert("a.rs".to_string());
970        tab.selected_unstaged.insert("b.rs".to_string());
971        assert_eq!(tab.selected_unstaged.len(), 2);
972        assert!(tab.selected_unstaged.contains("a.rs"));
973        tab.selected_unstaged.remove("a.rs");
974        assert_eq!(tab.selected_unstaged.len(), 1);
975        assert!(!tab.selected_unstaged.contains("a.rs"));
976    }
977
978    #[test]
979    fn detect_system_editor_returns_valid() {
980        // Just verify it doesn't panic
981        let editor = super::detect_system_editor();
982        let _ = editor.display_name();
983    }
984
985    // ── Multi-file commit diff selection ──────────────────────────────────
986
987    #[test]
988    fn selected_commit_file_indices_defaults_to_empty_vec() {
989        let tab = RepoTab::new_empty();
990        assert!(tab.selected_commit_file_indices.is_empty());
991        // Must be a Vec (ordered), not a HashSet — check it supports indexing
992        let v: &Vec<usize> = &tab.selected_commit_file_indices;
993        assert_eq!(v.len(), 0);
994    }
995
996    #[test]
997    fn multi_file_diffs_defaults_empty() {
998        let tab = RepoTab::new_empty();
999        assert!(tab.multi_file_diffs.is_empty());
1000    }
1001
1002    #[test]
1003    fn keyboard_modifiers_default_has_no_shift() {
1004        let state = GitKraft::new();
1005        assert!(!state.keyboard_modifiers.shift());
1006    }
1007
1008    #[test]
1009    fn selected_commit_file_indices_preserves_insertion_order() {
1010        let mut tab = RepoTab::new_empty();
1011        tab.selected_commit_file_indices.push(5);
1012        tab.selected_commit_file_indices.push(2);
1013        tab.selected_commit_file_indices.push(8);
1014        assert_eq!(tab.selected_commit_file_indices, vec![5, 2, 8]);
1015    }
1016
1017    #[test]
1018    fn selected_commit_file_indices_cleared_on_reset() {
1019        let mut tab = RepoTab::new_empty();
1020        tab.selected_commit_file_indices.push(0);
1021        tab.selected_commit_file_indices.push(1);
1022        tab.selected_commit_file_indices.clear();
1023        assert!(tab.selected_commit_file_indices.is_empty());
1024    }
1025
1026    #[test]
1027    fn multi_file_diffs_cleared_on_reset() {
1028        let mut tab = RepoTab::new_empty();
1029        tab.multi_file_diffs.push(gitkraft_core::DiffInfo {
1030            old_file: String::new(),
1031            new_file: "a.rs".to_string(),
1032            status: gitkraft_core::FileStatus::Modified,
1033            hunks: vec![],
1034        });
1035        tab.multi_file_diffs.clear();
1036        assert!(tab.multi_file_diffs.is_empty());
1037    }
1038
1039    #[test]
1040    fn commit_range_diffs_defaults_empty() {
1041        let tab = RepoTab::new_empty();
1042        assert!(tab.commit_range_diffs.is_empty());
1043    }
1044
1045    #[test]
1046    fn commit_range_diffs_cleared_on_apply_payload() {
1047        // verify the field is reset — just check it's accessible and clearable
1048        let mut tab = RepoTab::new_empty();
1049        tab.commit_range_diffs.push(gitkraft_core::DiffInfo {
1050            old_file: String::new(),
1051            new_file: "x.rs".to_string(),
1052            status: gitkraft_core::FileStatus::Modified,
1053            hunks: vec![],
1054        });
1055        tab.commit_range_diffs.clear();
1056        assert!(tab.commit_range_diffs.is_empty());
1057    }
1058
1059    // ── ModifiersChanged update ───────────────────────────────────────────
1060
1061    #[test]
1062    fn modifiers_changed_sets_shift_state() {
1063        use crate::message::Message;
1064        let mut state = GitKraft::new();
1065        assert!(!state.keyboard_modifiers.shift());
1066
1067        let _ = state.update(Message::ModifiersChanged(iced::keyboard::Modifiers::SHIFT));
1068        assert!(state.keyboard_modifiers.shift());
1069
1070        let _ = state.update(Message::ModifiersChanged(
1071            iced::keyboard::Modifiers::default(),
1072        ));
1073        assert!(!state.keyboard_modifiers.shift());
1074    }
1075
1076    // ── SelectDiffByIndex update ──────────────────────────────────────────
1077
1078    fn make_commit_files(names: &[&str]) -> Vec<gitkraft_core::DiffFileEntry> {
1079        names
1080            .iter()
1081            .map(|name| gitkraft_core::DiffFileEntry {
1082                old_file: String::new(),
1083                new_file: name.to_string(),
1084                status: gitkraft_core::FileStatus::Modified,
1085            })
1086            .collect()
1087    }
1088
1089    #[test]
1090    fn select_diff_by_index_regular_click_clears_multi_selection() {
1091        use crate::message::Message;
1092        let mut state = GitKraft::new();
1093        // Provide a repo_path and oid so the update handler can reach the
1094        // `selected_file_index = Some(index)` assignment (the async task it
1095        // spawns is dropped without execution — no real repo is needed).
1096        state.active_tab_mut().repo_path =
1097            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1098        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1099        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1100        // Pre-populate a multi-selection
1101        state.active_tab_mut().selected_commit_file_indices = vec![0, 1];
1102
1103        // Regular click (no Shift) — should collapse to single-file selection
1104        let _ = state.update(Message::SelectDiffByIndex(0));
1105
1106        assert!(state.active_tab().selected_commit_file_indices.is_empty());
1107        assert!(state.active_tab().multi_file_diffs.is_empty());
1108        assert_eq!(state.active_tab().selected_file_index, Some(0));
1109    }
1110
1111    #[test]
1112    fn select_diff_by_index_shift_click_adds_both_files_to_selection() {
1113        use crate::message::Message;
1114        let mut state = GitKraft::new();
1115        state.active_tab_mut().repo_path =
1116            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1117        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1118        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1119        state.active_tab_mut().selected_file_index = Some(0);
1120
1121        // Shift+Click on file 1 should anchor 0 and add 1
1122        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1123        let _ = state.update(Message::SelectDiffByIndex(1));
1124
1125        let sel = &state.active_tab().selected_commit_file_indices;
1126        assert!(sel.contains(&0), "anchor file 0 should be selected");
1127        assert!(sel.contains(&1), "newly clicked file 1 should be selected");
1128        assert_eq!(sel.len(), 2);
1129    }
1130
1131    #[test]
1132    fn anchor_file_index_defaults_to_none() {
1133        let tab = RepoTab::new_empty();
1134        assert!(tab.anchor_file_index.is_none());
1135    }
1136
1137    #[test]
1138    fn regular_click_sets_anchor() {
1139        use crate::message::Message;
1140        let mut state = GitKraft::new();
1141        state.active_tab_mut().repo_path =
1142            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1143        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1144        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1145
1146        let _ = state.update(Message::SelectDiffByIndex(2));
1147
1148        assert_eq!(
1149            state.active_tab().anchor_file_index,
1150            Some(2),
1151            "regular click must set anchor to the clicked index"
1152        );
1153    }
1154
1155    #[test]
1156    fn shift_click_selects_range_downward_from_anchor() {
1157        use crate::message::Message;
1158        let mut state = GitKraft::new();
1159        state.active_tab_mut().repo_path =
1160            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1161        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1162        state.active_tab_mut().commit_files =
1163            make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1164        // Anchor at index 1
1165        state.active_tab_mut().anchor_file_index = Some(1);
1166        state.active_tab_mut().selected_file_index = Some(1);
1167
1168        // Shift+Click on index 4 — should select 1, 2, 3, 4
1169        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1170        let _ = state.update(Message::SelectDiffByIndex(4));
1171
1172        let sel = &state.active_tab().selected_commit_file_indices;
1173        assert_eq!(
1174            sel,
1175            &vec![1, 2, 3, 4],
1176            "range must be contiguous from anchor to click"
1177        );
1178    }
1179
1180    #[test]
1181    fn shift_click_selects_range_upward_from_anchor() {
1182        use crate::message::Message;
1183        let mut state = GitKraft::new();
1184        state.active_tab_mut().repo_path =
1185            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1186        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1187        state.active_tab_mut().commit_files =
1188            make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1189        // Anchor at index 4 (bottom)
1190        state.active_tab_mut().anchor_file_index = Some(4);
1191        state.active_tab_mut().selected_file_index = Some(4);
1192
1193        // Shift+Click on index 1 — should select 1, 2, 3, 4 (ascending)
1194        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1195        let _ = state.update(Message::SelectDiffByIndex(1));
1196
1197        let sel = &state.active_tab().selected_commit_file_indices;
1198        assert_eq!(
1199            sel,
1200            &vec![1, 2, 3, 4],
1201            "range must be stored ascending regardless of click direction"
1202        );
1203    }
1204
1205    #[test]
1206    fn shift_click_anchor_fixed_on_subsequent_clicks() {
1207        use crate::message::Message;
1208        let mut state = GitKraft::new();
1209        state.active_tab_mut().repo_path =
1210            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1211        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1212        state.active_tab_mut().commit_files =
1213            make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1214        // Anchor at index 2
1215        state.active_tab_mut().anchor_file_index = Some(2);
1216        state.active_tab_mut().selected_file_index = Some(2);
1217        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1218
1219        // First Shift+Click: extend to 4 → range {2, 3, 4}
1220        let _ = state.update(Message::SelectDiffByIndex(4));
1221        assert_eq!(
1222            state.active_tab().selected_commit_file_indices,
1223            vec![2, 3, 4]
1224        );
1225
1226        // Second Shift+Click: shrink back to 3 → range {2, 3} (anchor still 2)
1227        let _ = state.update(Message::SelectDiffByIndex(3));
1228        assert_eq!(
1229            state.active_tab().selected_commit_file_indices,
1230            vec![2, 3],
1231            "anchor must stay fixed; second Shift+Click shrinks the range"
1232        );
1233
1234        // Third Shift+Click: extend upward → range {0, 1, 2} (anchor still 2)
1235        let _ = state.update(Message::SelectDiffByIndex(0));
1236        assert_eq!(
1237            state.active_tab().selected_commit_file_indices,
1238            vec![0, 1, 2],
1239            "anchor must stay fixed; can extend range in either direction"
1240        );
1241    }
1242
1243    #[test]
1244    fn shift_click_on_anchor_itself_gives_single_item_range() {
1245        use crate::message::Message;
1246        let mut state = GitKraft::new();
1247        state.active_tab_mut().repo_path =
1248            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1249        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1250        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1251        state.active_tab_mut().anchor_file_index = Some(1);
1252        state.active_tab_mut().selected_file_index = Some(1);
1253
1254        // Shift+Click on the anchor itself → single-item range {1}
1255        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1256        let _ = state.update(Message::SelectDiffByIndex(1));
1257
1258        assert_eq!(state.active_tab().selected_commit_file_indices, vec![1]);
1259        assert!(
1260            state.active_tab().multi_file_diffs.is_empty(),
1261            "single-item range must not populate multi_file_diffs"
1262        );
1263    }
1264
1265    #[test]
1266    fn shift_click_range_is_always_ascending() {
1267        use crate::message::Message;
1268        let mut state = GitKraft::new();
1269        state.active_tab_mut().repo_path =
1270            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1271        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1272        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs"]);
1273        state.active_tab_mut().anchor_file_index = Some(3);
1274        state.active_tab_mut().selected_file_index = Some(3);
1275
1276        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1277        let _ = state.update(Message::SelectDiffByIndex(0));
1278
1279        let sel = &state.active_tab().selected_commit_file_indices;
1280        let is_sorted = sel.windows(2).all(|w| w[0] < w[1]);
1281        assert!(
1282            is_sorted,
1283            "selection must always be stored in ascending order"
1284        );
1285        assert_eq!(sel, &vec![0, 1, 2, 3]);
1286    }
1287
1288    #[test]
1289    fn checkout_file_at_commit_message_variants_exist() {
1290        use crate::message::Message;
1291        // Verify the new message variants can be constructed
1292        let _single =
1293            Message::CheckoutFileAtCommit("abc123".to_string(), "src/main.rs".to_string());
1294        let _multi = Message::CheckoutMultiFilesAtCommit(
1295            "abc123".to_string(),
1296            vec!["a.rs".to_string(), "b.rs".to_string()],
1297        );
1298    }
1299
1300    #[test]
1301    fn checkout_file_at_commit_closes_context_menu() {
1302        use crate::message::Message;
1303        let mut state = GitKraft::new();
1304        state.active_tab_mut().repo_path =
1305            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1306        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1307            oid: "abc123".to_string(),
1308            file_path: "src/main.rs".to_string(),
1309        });
1310        let _ = state.update(Message::CheckoutFileAtCommit(
1311            "abc123".to_string(),
1312            "src/main.rs".to_string(),
1313        ));
1314        assert!(state.active_tab().context_menu.is_none());
1315    }
1316
1317    #[test]
1318    fn checkout_multi_files_at_commit_closes_context_menu() {
1319        use crate::message::Message;
1320        let mut state = GitKraft::new();
1321        state.active_tab_mut().repo_path =
1322            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1323        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1324            oid: "abc123".to_string(),
1325            file_path: "src/main.rs".to_string(),
1326        });
1327        let _ = state.update(Message::CheckoutMultiFilesAtCommit(
1328            "abc123".to_string(),
1329            vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
1330        ));
1331        assert!(state.active_tab().context_menu.is_none());
1332    }
1333
1334    // ── Commit multi-selection ────────────────────────────────────────────
1335
1336    fn make_test_commits(count: usize) -> Vec<gitkraft_core::CommitInfo> {
1337        (0..count)
1338            .map(|i| gitkraft_core::CommitInfo {
1339                oid: i.to_string(),
1340                short_oid: i.to_string(),
1341                summary: String::new(),
1342                message: String::new(),
1343                author_name: String::new(),
1344                author_email: String::new(),
1345                time: Default::default(),
1346                parent_ids: Vec::new(),
1347            })
1348            .collect()
1349    }
1350
1351    #[test]
1352    fn selected_commits_defaults_empty() {
1353        let tab = RepoTab::new_empty();
1354        assert!(tab.selected_commits.is_empty());
1355        assert!(tab.anchor_commit_index.is_none());
1356    }
1357
1358    #[test]
1359    fn regular_click_commit_sets_anchor_and_clears_range() {
1360        use crate::message::Message;
1361        let mut state = GitKraft::new();
1362        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
1363        state.active_tab_mut().commits = make_test_commits(3);
1364        state.active_tab_mut().selected_commits = vec![0, 1, 2];
1365
1366        let _ = state.update(Message::SelectCommit(1));
1367
1368        assert_eq!(state.active_tab().anchor_commit_index, Some(1));
1369        assert!(state.active_tab().selected_commits.is_empty());
1370        assert_eq!(state.active_tab().selected_commit, Some(1));
1371    }
1372
1373    #[test]
1374    fn shift_click_commit_selects_range_from_anchor() {
1375        use crate::message::Message;
1376        let mut state = GitKraft::new();
1377        state.active_tab_mut().commits = make_test_commits(5);
1378        state.active_tab_mut().anchor_commit_index = Some(1);
1379        state.active_tab_mut().selected_commit = Some(1);
1380
1381        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1382        let _ = state.update(Message::SelectCommit(4));
1383
1384        assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3, 4]);
1385    }
1386
1387    #[test]
1388    fn shift_click_commit_range_is_ascending_when_clicking_above_anchor() {
1389        use crate::message::Message;
1390        let mut state = GitKraft::new();
1391        state.active_tab_mut().commits = make_test_commits(5);
1392        state.active_tab_mut().anchor_commit_index = Some(3);
1393        state.active_tab_mut().selected_commit = Some(3);
1394
1395        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1396        let _ = state.update(Message::SelectCommit(1));
1397
1398        assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3]);
1399    }
1400
1401    // ── ExecuteCommitAction message ───────────────────────────────────────
1402
1403    #[test]
1404    fn execute_commit_action_closes_context_menu() {
1405        use crate::message::Message;
1406        let mut state = GitKraft::new();
1407        state.active_tab_mut().repo_path =
1408            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1409        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::Commit {
1410            index: 0,
1411            oid: "abc123".to_string(),
1412        });
1413
1414        let _ = state.update(Message::ExecuteCommitAction(
1415            "abc123".to_string(),
1416            gitkraft_core::CommitAction::CherryPick,
1417        ));
1418
1419        assert!(state.active_tab().context_menu.is_none());
1420    }
1421
1422    #[test]
1423    fn execute_commit_action_sets_loading_when_repo_open() {
1424        use crate::message::Message;
1425        let mut state = GitKraft::new();
1426        state.active_tab_mut().repo_path =
1427            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1428
1429        let _ = state.update(Message::ExecuteCommitAction(
1430            "abc123".to_string(),
1431            gitkraft_core::CommitAction::ResetHard,
1432        ));
1433
1434        assert!(state.active_tab().is_loading);
1435    }
1436
1437    #[test]
1438    fn execute_commit_action_no_repo_does_not_set_loading() {
1439        use crate::message::Message;
1440        let mut state = GitKraft::new();
1441        // No repo_path set
1442
1443        let _ = state.update(Message::ExecuteCommitAction(
1444            "abc123".to_string(),
1445            gitkraft_core::CommitAction::CherryPick,
1446        ));
1447
1448        assert!(!state.active_tab().is_loading);
1449    }
1450
1451    #[test]
1452    fn execute_commit_action_sets_status_message_from_action_label() {
1453        use crate::message::Message;
1454        let mut state = GitKraft::new();
1455        state.active_tab_mut().repo_path =
1456            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1457
1458        let _ = state.update(Message::ExecuteCommitAction(
1459            "abc123".to_string(),
1460            gitkraft_core::CommitAction::Revert,
1461        ));
1462
1463        let status = state.active_tab().status_message.as_deref().unwrap_or("");
1464        // Status message should contain the action's label
1465        assert!(
1466            status.contains("Revert commit"),
1467            "expected status to contain 'Revert commit', got: {status:?}"
1468        );
1469    }
1470
1471    // ── File history / blame / delete state ──────────────────────────────
1472
1473    #[test]
1474    fn file_history_defaults_empty() {
1475        let tab = RepoTab::new_empty();
1476        assert!(tab.file_history_path.is_none());
1477        assert!(tab.file_history_commits.is_empty());
1478        assert_eq!(tab.file_history_scroll, 0.0);
1479    }
1480
1481    #[test]
1482    fn blame_defaults_empty() {
1483        let tab = RepoTab::new_empty();
1484        assert!(tab.blame_path.is_none());
1485        assert!(tab.blame_lines.is_empty());
1486        assert_eq!(tab.blame_scroll, 0.0);
1487    }
1488
1489    #[test]
1490    fn pending_delete_file_defaults_none() {
1491        let tab = RepoTab::new_empty();
1492        assert!(tab.pending_delete_file.is_none());
1493    }
1494
1495    #[test]
1496    fn view_file_history_sets_path_and_clears_blame() {
1497        use crate::message::Message;
1498        let mut state = GitKraft::new();
1499        state.active_tab_mut().repo_path =
1500            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1501        state.active_tab_mut().blame_path = Some("some/file.rs".to_string());
1502
1503        let _ = state.update(Message::ViewFileHistory("src/main.rs".to_string()));
1504
1505        assert_eq!(
1506            state.active_tab().file_history_path.as_deref(),
1507            Some("src/main.rs")
1508        );
1509        // Opening history should close blame
1510        assert!(state.active_tab().blame_path.is_none());
1511    }
1512
1513    #[test]
1514    fn close_file_history_clears_state() {
1515        use crate::message::Message;
1516        let mut state = GitKraft::new();
1517        state.active_tab_mut().file_history_path = Some("src/lib.rs".to_string());
1518        state.active_tab_mut().file_history_commits = vec![gitkraft_core::CommitInfo {
1519            oid: "abc".to_string(),
1520            short_oid: "abc".to_string(),
1521            summary: "s".to_string(),
1522            message: "s".to_string(),
1523            author_name: "a".to_string(),
1524            author_email: "a@b.com".to_string(),
1525            time: Default::default(),
1526            parent_ids: vec![],
1527        }];
1528
1529        let _ = state.update(Message::CloseFileHistory);
1530
1531        assert!(state.active_tab().file_history_path.is_none());
1532        assert!(state.active_tab().file_history_commits.is_empty());
1533    }
1534
1535    #[test]
1536    fn view_file_blame_sets_path_and_clears_history() {
1537        use crate::message::Message;
1538        let mut state = GitKraft::new();
1539        state.active_tab_mut().repo_path =
1540            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1541        state.active_tab_mut().file_history_path = Some("some/file.rs".to_string());
1542
1543        let _ = state.update(Message::ViewFileBlame("src/lib.rs".to_string()));
1544
1545        assert_eq!(state.active_tab().blame_path.as_deref(), Some("src/lib.rs"));
1546        // Opening blame should close history
1547        assert!(state.active_tab().file_history_path.is_none());
1548    }
1549
1550    #[test]
1551    fn selecting_new_commit_closes_blame_overlay() {
1552        use crate::message::Message;
1553        let mut state = GitKraft::new();
1554        state.active_tab_mut().repo_path =
1555            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1556        // Pre-populate a commit list so SelectCommit can find the commit.
1557        state.active_tab_mut().commits = vec![
1558            gitkraft_core::CommitInfo {
1559                oid: "abc1".into(),
1560                short_oid: "abc1".into(),
1561                summary: "first".into(),
1562                message: "first".into(),
1563                author_name: "A".into(),
1564                author_email: "a@a.com".into(),
1565                time: Default::default(),
1566                parent_ids: Vec::new(),
1567            },
1568            gitkraft_core::CommitInfo {
1569                oid: "abc2".into(),
1570                short_oid: "abc2".into(),
1571                summary: "second".into(),
1572                message: "second".into(),
1573                author_name: "A".into(),
1574                author_email: "a@a.com".into(),
1575                time: Default::default(),
1576                parent_ids: Vec::new(),
1577            },
1578        ];
1579        // Blame is currently open for a file from the first commit.
1580        state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1581        state.active_tab_mut().blame_lines = vec![gitkraft_core::BlameLine {
1582            line_number: 1,
1583            content: "fn main() {}".into(),
1584            short_oid: "abc1".into(),
1585            oid: "abc1".into(),
1586            author_name: "A".into(),
1587            time: Default::default(),
1588        }];
1589
1590        // Click a different commit — blame must close automatically.
1591        let _ = state.update(Message::SelectCommit(1));
1592
1593        assert!(
1594            state.active_tab().blame_path.is_none(),
1595            "blame_path must be cleared when a new commit is selected"
1596        );
1597        assert!(
1598            state.active_tab().blame_lines.is_empty(),
1599            "blame_lines must be cleared when a new commit is selected"
1600        );
1601    }
1602
1603    #[test]
1604    fn close_file_blame_clears_state() {
1605        use crate::message::Message;
1606        let mut state = GitKraft::new();
1607        state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1608
1609        let _ = state.update(Message::CloseFileBlame);
1610
1611        assert!(state.active_tab().blame_path.is_none());
1612        assert!(state.active_tab().blame_lines.is_empty());
1613    }
1614
1615    #[test]
1616    fn delete_file_sets_pending() {
1617        use crate::message::Message;
1618        let mut state = GitKraft::new();
1619
1620        let _ = state.update(Message::DeleteFile("src/old.rs".to_string()));
1621
1622        assert_eq!(
1623            state.active_tab().pending_delete_file.as_deref(),
1624            Some("src/old.rs")
1625        );
1626        assert!(state.active_tab().context_menu.is_none());
1627    }
1628
1629    #[test]
1630    fn cancel_delete_file_clears_pending() {
1631        use crate::message::Message;
1632        let mut state = GitKraft::new();
1633        state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1634
1635        let _ = state.update(Message::CancelDeleteFile);
1636
1637        assert!(state.active_tab().pending_delete_file.is_none());
1638    }
1639
1640    #[test]
1641    fn confirm_delete_file_no_repo_is_noop() {
1642        use crate::message::Message;
1643        let mut state = GitKraft::new();
1644        state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1645        // No repo_path → should not set is_loading
1646
1647        let _ = state.update(Message::ConfirmDeleteFile);
1648
1649        assert!(!state.active_tab().is_loading);
1650    }
1651
1652    #[test]
1653    fn shift_arrow_down_extends_file_list_selection_when_files_loaded() {
1654        use crate::message::Message;
1655        let mut state = GitKraft::new();
1656        state.active_tab_mut().repo_path =
1657            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1658        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1659        state.active_tab_mut().selected_file_index = Some(0);
1660        state.active_tab_mut().anchor_file_index = Some(0);
1661        // keyboard_modifiers must have SHIFT set for range selection to trigger
1662        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1663
1664        let _ = state.update(Message::ShiftArrowDown);
1665
1666        assert_eq!(state.active_tab().selected_file_index, Some(1));
1667        // Range should now include both files
1668        assert!(state.active_tab().selected_commit_file_indices.contains(&0));
1669        assert!(state.active_tab().selected_commit_file_indices.contains(&1));
1670    }
1671
1672    #[test]
1673    fn shift_arrow_down_falls_through_to_commit_log_when_no_files() {
1674        use crate::message::Message;
1675        let mut state = GitKraft::new();
1676        state.active_tab_mut().commits = make_test_commits(5);
1677        state.active_tab_mut().selected_commit = Some(1);
1678        state.active_tab_mut().anchor_commit_index = Some(1);
1679        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1680        // no commit_files
1681
1682        let _ = state.update(Message::ShiftArrowDown);
1683
1684        assert_eq!(state.active_tab().selected_commit, Some(2));
1685        assert!(state.active_tab().selected_commits.contains(&1));
1686        assert!(state.active_tab().selected_commits.contains(&2));
1687    }
1688
1689    #[test]
1690    fn file_system_changed_triggers_full_refresh() {
1691        use crate::message::Message;
1692        let mut state = GitKraft::new();
1693        state.active_tab_mut().repo_path =
1694            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1695
1696        // FileSystemChanged should call refresh_active_tab() which returns
1697        // a non-none Task.  We verify by checking that is_loading is NOT set
1698        // synchronously (the task is async), but that no error is set either.
1699        let _task = state.update(Message::FileSystemChanged);
1700
1701        // With a repo_path set, the handler must have attempted a refresh
1702        // (it returns a Task, so is_loading is set by the task executor, not here).
1703        // What we CAN check: no error was set, and status is not "error".
1704        assert!(
1705            state.active_tab().error_message.is_none(),
1706            "FileSystemChanged must not set an error message"
1707        );
1708    }
1709
1710    // ── Tab duplication prevention tests ───────────────────────────────────
1711
1712    /// Helper: build a minimal `RepoPayload` (aka `RepoSnapshot`) for a given path.
1713    fn fake_payload(workdir: &str) -> crate::message::RepoPayload {
1714        gitkraft_core::RepoSnapshot {
1715            info: gitkraft_core::RepoInfo {
1716                path: std::path::PathBuf::from(format!("{workdir}/.git")),
1717                workdir: Some(std::path::PathBuf::from(workdir)),
1718                head_branch: Some("main".into()),
1719                is_bare: false,
1720                state: gitkraft_core::RepoState::Clean,
1721            },
1722            branches: Vec::new(),
1723            commits: Vec::new(),
1724            graph_rows: Vec::new(),
1725            unstaged: Vec::new(),
1726            staged: Vec::new(),
1727            stashes: Vec::new(),
1728            remotes: Vec::new(),
1729        }
1730    }
1731
1732    /// Helper: set up a tab as if a repo was fully loaded.
1733    fn setup_loaded_tab(tab: &mut RepoTab, path: &str) {
1734        tab.repo_path = Some(std::path::PathBuf::from(path));
1735        tab.repo_info = Some(gitkraft_core::RepoInfo {
1736            path: std::path::PathBuf::from(format!("{path}/.git")),
1737            workdir: Some(std::path::PathBuf::from(path)),
1738            head_branch: Some("main".into()),
1739            is_bare: false,
1740            state: gitkraft_core::RepoState::Clean,
1741        });
1742    }
1743
1744    #[test]
1745    fn open_repo_creates_new_tab_when_repo_already_open() {
1746        use crate::message::Message;
1747        let mut state = GitKraft::new();
1748        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1749
1750        assert_eq!(state.tabs.len(), 1);
1751        assert_eq!(state.active_tab, 0);
1752
1753        // Clicking "Open" should create a new tab when the current has a repo.
1754        let _task = state.update(Message::OpenRepo);
1755
1756        assert_eq!(state.tabs.len(), 2);
1757        assert_eq!(state.active_tab, 1);
1758        // The new tab should be loading (folder picker opening).
1759        assert!(state.tabs[1].is_loading);
1760        // The original tab should be untouched.
1761        assert_eq!(
1762            state.tabs[0].repo_path.as_deref(),
1763            Some(std::path::Path::new("/home/user/repo-a"))
1764        );
1765    }
1766
1767    #[test]
1768    fn open_repo_reuses_empty_tab() {
1769        use crate::message::Message;
1770        let mut state = GitKraft::new();
1771        // Active tab is empty (no repo loaded).
1772        assert!(!state.active_tab().has_repo());
1773
1774        let _task = state.update(Message::OpenRepo);
1775
1776        // Should NOT create a new tab when the active tab is empty.
1777        assert_eq!(state.tabs.len(), 1);
1778        assert_eq!(state.active_tab, 0);
1779        assert!(state.tabs[0].is_loading);
1780    }
1781
1782    #[test]
1783    fn repo_selected_deduplicates_already_open_repo() {
1784        use crate::message::Message;
1785        let mut state = GitKraft::new();
1786        // Tab 0: fully loaded repo-a
1787        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1788        // Tab 1: empty (simulates the new tab created by OpenRepo)
1789        state.tabs.push(RepoTab::new_empty());
1790        state.active_tab = 1;
1791
1792        // User selects a folder that matches the already-open repo.
1793        let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
1794            "/home/user/repo-a",
1795        ))));
1796
1797        // Should switch to the existing tab and remove the empty one.
1798        assert_eq!(state.tabs.len(), 1);
1799        assert_eq!(state.active_tab, 0);
1800        assert_eq!(
1801            state.tabs[0].repo_path.as_deref(),
1802            Some(std::path::Path::new("/home/user/repo-a"))
1803        );
1804    }
1805
1806    #[test]
1807    fn repo_selected_opens_new_repo_in_empty_tab() {
1808        use crate::message::Message;
1809        let mut state = GitKraft::new();
1810        // Tab 0: fully loaded repo-a
1811        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1812        // Tab 1: empty (simulates the new tab created by OpenRepo)
1813        state.tabs.push(RepoTab::new_empty());
1814        state.active_tab = 1;
1815
1816        // User selects a DIFFERENT repo.
1817        let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
1818            "/home/user/repo-b",
1819        ))));
1820
1821        // Should keep both tabs; the empty tab is now loading repo-b.
1822        assert_eq!(state.tabs.len(), 2);
1823        assert_eq!(state.active_tab, 1);
1824        assert!(state.tabs[1]
1825            .status_message
1826            .as_deref()
1827            .unwrap_or("")
1828            .contains("repo-b"));
1829    }
1830
1831    #[test]
1832    fn repo_selected_cancel_removes_empty_tab() {
1833        use crate::message::Message;
1834        let mut state = GitKraft::new();
1835        // Tab 0: fully loaded repo-a
1836        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1837        // Tab 1: empty (created by OpenRepo, waiting for folder picker)
1838        state.tabs.push(RepoTab::new_empty());
1839        state.active_tab = 1;
1840
1841        // User cancels the folder picker.
1842        let _task = state.update(Message::RepoSelected(None));
1843
1844        // The empty tab should be removed; switch back to tab 0.
1845        assert_eq!(state.tabs.len(), 1);
1846        assert_eq!(state.active_tab, 0);
1847        assert_eq!(
1848            state.tabs[0].repo_path.as_deref(),
1849            Some(std::path::Path::new("/home/user/repo-a"))
1850        );
1851    }
1852
1853    #[test]
1854    fn repo_selected_cancel_keeps_tab_if_only_one() {
1855        use crate::message::Message;
1856        let mut state = GitKraft::new();
1857        // Single empty tab — shouldn't be removed on cancel.
1858        assert_eq!(state.tabs.len(), 1);
1859        assert!(!state.active_tab().has_repo());
1860
1861        let _task = state.update(Message::RepoSelected(None));
1862
1863        assert_eq!(state.tabs.len(), 1);
1864        assert!(!state.active_tab().is_loading);
1865    }
1866
1867    #[test]
1868    fn open_recent_repo_deduplicates() {
1869        use crate::message::Message;
1870        let mut state = GitKraft::new();
1871        // Tab 0: fully loaded repo-a
1872        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1873        // Tab 1: empty tab
1874        state.tabs.push(RepoTab::new_empty());
1875        state.active_tab = 1;
1876
1877        // Opening a recent repo that's already open should switch to it.
1878        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
1879            "/home/user/repo-a",
1880        )));
1881
1882        assert_eq!(state.active_tab, 0);
1883    }
1884
1885    #[test]
1886    fn open_recent_repo_creates_new_tab_when_current_has_repo() {
1887        use crate::message::Message;
1888        let mut state = GitKraft::new();
1889        // Tab 0: fully loaded repo-a
1890        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1891
1892        // Opening a different recent repo should create a new tab.
1893        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
1894            "/home/user/repo-b",
1895        )));
1896
1897        assert_eq!(state.tabs.len(), 2);
1898        assert_eq!(state.active_tab, 1);
1899        assert!(state.tabs[1].is_loading);
1900    }
1901
1902    #[test]
1903    fn open_recent_repo_uses_empty_tab() {
1904        use crate::message::Message;
1905        let mut state = GitKraft::new();
1906        // Active tab is empty.
1907        assert!(!state.active_tab().has_repo());
1908
1909        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
1910            "/home/user/repo-b",
1911        )));
1912
1913        // Should NOT create a new tab.
1914        assert_eq!(state.tabs.len(), 1);
1915        assert_eq!(state.active_tab, 0);
1916        assert!(state.tabs[0].is_loading);
1917    }
1918
1919    // ── Refresh race-condition tests ──────────────────────────────────────
1920
1921    #[test]
1922    fn repo_refreshed_targets_correct_tab_after_tab_switch() {
1923        use crate::message::Message;
1924        let mut state = GitKraft::new();
1925        // Tab 0: repo-a fully loaded
1926        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1927        // Tab 1: empty tab (user clicked "+")
1928        state.tabs.push(RepoTab::new_empty());
1929        state.active_tab = 1; // user switched to new empty tab
1930
1931        // Simulate: a RepoRefreshed result arrives for repo-a while tab 1 is active.
1932        let payload = fake_payload("/home/user/repo-a");
1933        let _task = state.update(Message::RepoRefreshed(Ok(payload)));
1934
1935        // The payload must land in tab 0 (which owns repo-a), NOT tab 1.
1936        assert!(
1937            state.tabs[0].repo_info.is_some(),
1938            "tab 0 should still have repo info after refresh"
1939        );
1940        assert_eq!(
1941            state.tabs[0].current_branch.as_deref(),
1942            Some("main"),
1943            "tab 0 should have updated branch from payload"
1944        );
1945        // Tab 1 must remain empty.
1946        assert!(
1947            state.tabs[1].repo_info.is_none(),
1948            "tab 1 (empty) must NOT receive the refresh payload"
1949        );
1950        assert!(
1951            state.tabs[1].repo_path.is_none(),
1952            "tab 1 should still have no repo path"
1953        );
1954    }
1955
1956    #[test]
1957    fn repo_refreshed_targets_active_tab_for_new_open() {
1958        use crate::message::Message;
1959        let mut state = GitKraft::new();
1960        // Active tab is empty — user just opened a brand-new repo.
1961        // (RepoSelected set the status but the tab doesn't have repo_path yet
1962        // matching the payload, so it falls back to active tab.)
1963        assert_eq!(state.tabs.len(), 1);
1964        assert!(!state.active_tab().has_repo());
1965
1966        let payload = fake_payload("/home/user/new-repo");
1967        let _task = state.update(Message::RepoOpened(Ok(payload)));
1968
1969        // Should have applied to the active tab (the only tab).
1970        assert_eq!(
1971            state.tabs[0].repo_path.as_deref(),
1972            Some(std::path::Path::new("/home/user/new-repo"))
1973        );
1974        assert!(state.tabs[0].repo_info.is_some());
1975    }
1976
1977    #[test]
1978    fn repo_refreshed_does_not_duplicate_into_new_tab() {
1979        use crate::message::Message;
1980        let mut state = GitKraft::new();
1981        // Tab 0: repo-a loaded
1982        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1983
1984        // User clicks "+" creating an empty tab 1, then switches to it.
1985        let _task = state.update(Message::NewTab);
1986        assert_eq!(state.tabs.len(), 2);
1987        assert_eq!(state.active_tab, 1);
1988
1989        // A refresh result for repo-a arrives (was triggered before the tab switch).
1990        let payload = fake_payload("/home/user/repo-a");
1991        let _task = state.update(Message::RepoRefreshed(Ok(payload)));
1992
1993        // Tab 1 must remain empty — the payload should go to tab 0.
1994        assert!(
1995            state.tabs[1].repo_path.is_none(),
1996            "new empty tab must not receive repo-a refresh"
1997        );
1998        assert!(
1999            state.tabs[1].repo_info.is_none(),
2000            "new empty tab must not have repo_info"
2001        );
2002        // Tab 0 must have the refreshed data.
2003        assert_eq!(
2004            state.tabs[0].repo_path.as_deref(),
2005            Some(std::path::Path::new("/home/user/repo-a"))
2006        );
2007    }
2008
2009    #[test]
2010    fn git_operation_result_targets_correct_tab() {
2011        use crate::message::Message;
2012        let mut state = GitKraft::new();
2013        // Tab 0: repo-a
2014        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2015        // Tab 1: repo-b
2016        state.tabs.push(RepoTab::new_empty());
2017        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2018        state.active_tab = 1;
2019
2020        // A git operation result arrives for repo-a (e.g. push completed)
2021        // while user is on tab 1.
2022        let payload = fake_payload("/home/user/repo-a");
2023        let _task = state.update(Message::GitOperationResult(Ok(payload)));
2024
2025        // Payload should land in tab 0 (repo-a), not tab 1.
2026        assert_eq!(state.tabs[0].current_branch.as_deref(), Some("main"));
2027        // Tab 1's data should remain unchanged (still repo-b).
2028        assert_eq!(
2029            state.tabs[1].repo_path.as_deref(),
2030            Some(std::path::Path::new("/home/user/repo-b"))
2031        );
2032    }
2033
2034    #[test]
2035    fn multiple_new_tabs_dont_get_polluted_by_refresh() {
2036        use crate::message::Message;
2037        let mut state = GitKraft::new();
2038        // Tab 0: repo-a loaded
2039        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2040
2041        // User creates multiple new tabs.
2042        let _task = state.update(Message::NewTab);
2043        let _task = state.update(Message::NewTab);
2044        assert_eq!(state.tabs.len(), 3);
2045        assert_eq!(state.active_tab, 2);
2046
2047        // Refresh arrives for repo-a.
2048        let payload = fake_payload("/home/user/repo-a");
2049        let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2050
2051        // Only tab 0 should be affected.
2052        assert!(state.tabs[0].repo_info.is_some());
2053        assert!(state.tabs[1].repo_info.is_none());
2054        assert!(state.tabs[2].repo_info.is_none());
2055        assert!(state.tabs[1].repo_path.is_none());
2056        assert!(state.tabs[2].repo_path.is_none());
2057    }
2058
2059    #[test]
2060    fn repo_selected_dedup_adjusts_index_when_existing_is_after_active() {
2061        use crate::message::Message;
2062        let mut state = GitKraft::new();
2063        // Tab 0: empty (newly created by OpenRepo)
2064        // Tab 1: repo-a loaded
2065        state.tabs.push(RepoTab::new_empty());
2066        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
2067        state.active_tab = 0;
2068
2069        // User selects the same folder as repo-a.
2070        let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2071            "/home/user/repo-a",
2072        ))));
2073
2074        // Empty tab 0 should be removed; we should now be on tab 0 (formerly tab 1).
2075        assert_eq!(state.tabs.len(), 1);
2076        assert_eq!(state.active_tab, 0);
2077        assert_eq!(
2078            state.tabs[0].repo_path.as_deref(),
2079            Some(std::path::Path::new("/home/user/repo-a"))
2080        );
2081    }
2082}