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    /// The divider between the unstaged and staged panels in the staging area.
23    StagingUnstagedRight,
24    /// The divider between the staged and commit panels in the staging area.
25    StagingStagedRight,
26    /// Divider between the BRANCH/TAG column and the GRAPH column in the commit log.
27    CommitRefColumnRight,
28    /// Divider between the GRAPH column and the COMMIT MESSAGE column in the commit log.
29    CommitGraphColumnRight,
30}
31
32/// Which horizontal divider the user is currently dragging (if any).
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum DragTargetH {
35    /// The divider between the middle row and the staging area.
36    StagingTop,
37    /// The divider between branches and stashes in the sidebar.
38    SidebarBranchesBottom,
39    /// The divider between stashes and remotes in the sidebar.
40    SidebarStashBottom,
41}
42
43/// What item was right-clicked to open the context menu.
44#[derive(Debug, Clone)]
45pub enum ContextMenu {
46    /// A local branch.
47    Branch {
48        name: String,
49        is_current: bool,
50        /// Index in the filtered local-branch list, used to approximate
51        /// the menu's on-screen position.
52        local_index: usize,
53    },
54    /// A remote-tracking branch (e.g. origin/feature-x).
55    RemoteBranch { name: String },
56    /// A commit in the log.
57    Commit { index: usize, oid: String },
58    /// A stash entry.
59    Stash { index: usize },
60    /// An unstaged file in the staging area.
61    UnstagedFile { path: String },
62    /// A staged file in the staging area.
63    StagedFile { path: String },
64    /// A file in a commit diff.
65    CommitFile { oid: String, file_path: String },
66}
67
68// ── Per-repository tab state ──────────────────────────────────────────────────
69
70/// Per-repository state — one instance per open tab.
71pub struct RepoTab {
72    // ── Repository ────────────────────────────────────────────────────────
73    /// Path to the currently opened repository (workdir root).
74    pub repo_path: Option<PathBuf>,
75    /// High-level information about the opened repository.
76    pub repo_info: Option<RepoInfo>,
77
78    // ── Branches ──────────────────────────────────────────────────────────
79    /// All branches (local + remote) in the repository.
80    pub branches: Vec<BranchInfo>,
81    /// Name of the currently checked-out branch.
82    pub current_branch: Option<String>,
83
84    // ── Commits ───────────────────────────────────────────────────────────
85    /// Commit log (newest first).
86    pub commits: Vec<CommitInfo>,
87    /// Index into `commits` of the currently selected commit.
88    pub selected_commit: Option<usize>,
89    /// Anchor commit index for range selection — set on a plain click.
90    pub anchor_commit_index: Option<usize>,
91    /// Ordered (ascending) list of commit indices in the current range selection.
92    pub selected_commits: Vec<usize>,
93    /// Per-commit graph layout rows for branch visualisation.
94    pub graph_rows: Vec<gitkraft_core::GraphRow>,
95
96    // ── Diff / Staging ────────────────────────────────────────────────────
97    /// Unstaged (working-directory) changes.
98    pub unstaged_changes: Vec<gitkraft_core::DiffFileEntry>,
99    /// Staged (index) changes.
100    pub staged_changes: Vec<gitkraft_core::DiffFileEntry>,
101    /// Lightweight file list for the currently selected commit (path + status only).
102    pub commit_files: Vec<gitkraft_core::DiffFileEntry>,
103    /// OID of the currently selected commit (needed for on-demand file diff loading).
104    pub selected_commit_oid: Option<String>,
105    /// Index of the selected file in `commit_files`.
106    pub selected_file_index: Option<usize>,
107    /// True while a single-file diff is being loaded.
108    pub is_loading_file_diff: bool,
109    /// Anchor index for range selection — set on a regular click, kept fixed while
110    /// the user extends the selection with Shift+Click.
111    pub anchor_file_index: Option<usize>,
112    /// Ordered list of file indices currently multi-selected in the commit file list (Shift+Click).
113    /// Always stored in ascending index order (lowest index first).
114    pub selected_commit_file_indices: Vec<usize>,
115    /// Diffs for all multi-selected files (populated when 2+ files are selected).
116    pub multi_file_diffs: Vec<gitkraft_core::DiffInfo>,
117    /// Combined diff for all commits in the current range selection (populated when
118    /// `selected_commits.len() > 1`). Shown in the diff panel instead of single-commit diff.
119    pub commit_range_diffs: Vec<gitkraft_core::DiffInfo>,
120    /// The diff currently displayed in the diff viewer panel.
121    pub selected_diff: Option<DiffInfo>,
122    /// Text in the commit-message input.
123    pub commit_message: String,
124
125    // ── Stash ─────────────────────────────────────────────────────────────
126    /// All stash entries.
127    pub stashes: Vec<StashEntry>,
128
129    // ── Remotes ───────────────────────────────────────────────────────────
130    /// Configured remotes.
131    pub remotes: Vec<RemoteInfo>,
132
133    // ── Per-tab UI state ──────────────────────────────────────────────────
134    /// Whether the commit detail pane is visible.
135    pub show_commit_detail: bool,
136    /// Text in the "new branch name" input.
137    pub new_branch_name: String,
138    /// Whether the inline branch-creation UI is visible.
139    pub show_branch_create: bool,
140    /// Whether the Local branches section is expanded.
141    pub local_branches_expanded: bool,
142    /// Whether the Remote branches section is expanded.
143    pub remote_branches_expanded: bool,
144    /// Text in the "stash message" input.
145    pub stash_message: String,
146
147    /// Set of selected unstaged file paths (for multi-select).
148    pub selected_unstaged: std::collections::HashSet<String>,
149    /// Set of selected staged file paths (for multi-select).
150    pub selected_staged: std::collections::HashSet<String>,
151    /// Anchor index for Shift+Click range selection in the unstaged list.
152    pub anchor_unstaged_index: Option<usize>,
153    /// Anchor index for Shift+Click range selection in the staged list.
154    pub anchor_staged_index: Option<usize>,
155
156    /// File path pending discard confirmation (None = no pending discard).
157    pub pending_discard: Option<String>,
158
159    // ── Feedback ──────────────────────────────────────────────────────────
160    /// Transient status-bar message (e.g. "Branch created").
161    pub status_message: Option<String>,
162    /// Error message shown in a banner / toast.
163    pub error_message: Option<String>,
164    /// True while an async operation is in flight.
165    pub is_loading: bool,
166    /// Cursor position captured at the moment the context menu was opened.
167    /// Used to anchor the menu so it doesn't follow the mouse after appearing.
168    pub context_menu_pos: (f32, f32),
169
170    /// Currently open context menu, if any.
171    pub context_menu: Option<ContextMenu>,
172    /// Name of the branch currently being renamed (None = not renaming).
173    pub rename_branch_target: Option<String>,
174    /// The new name being typed in the rename input.
175    pub rename_branch_input: String,
176
177    /// When `Some(oid)`, the tag-creation inline form is visible, targeting that OID.
178    pub create_tag_target_oid: Option<String>,
179    /// True when creating an annotated tag; false for a lightweight tag.
180    pub create_tag_annotated: bool,
181    /// The tag name the user is typing.
182    pub create_tag_name: String,
183    /// The annotated tag message the user is typing (only used when `create_tag_annotated` is true).
184    pub create_tag_message: String,
185    /// When `Some(oid)`, the inline "create branch at this commit" form is visible.
186    pub create_branch_at_oid: Option<String>,
187
188    /// Current scroll offset of the commit log in pixels.
189    /// Tracked via `on_scroll` so virtual scrolling can render only the
190    /// visible window of rows.
191    pub commit_scroll_offset: f32,
192
193    /// Index of the commit row currently being hovered by the mouse.
194    /// Used to show ghost branch labels and highlight the row.
195    pub hovered_commit: Option<usize>,
196
197    /// Pre-computed branch context labels for each commit row.
198    /// For rows with refs, this is empty (the real badges are shown instead).
199    /// For rows without refs, this contains the inherited branch name from
200    /// the nearest ancestor in the same graph lane.
201    pub branch_context: Vec<Option<String>>,
202
203    /// Current scroll offset of the diff viewer in pixels.
204    pub diff_scroll_offset: f32,
205    /// Pre-computed display strings for each commit:
206    /// `(relative_time, truncated_author)`.
207    /// Computed once when commits load to avoid per-frame string allocations.
208    /// The summary is read directly from `commits[i].summary` to avoid duplication.
209    pub commit_display: Vec<(String, String)>,
210
211    /// Whether there are potentially more commits to load beyond those already shown.
212    pub has_more_commits: bool,
213    /// Guard: true while a background load-more task is in flight (prevents duplicates).
214    pub is_loading_more_commits: bool,
215
216    /// When `Some(path)`, the file-history overlay is shown for that repo-relative path.
217    pub file_history_path: Option<String>,
218    /// Commits loaded for the file-history overlay (newest first).
219    pub file_history_commits: Vec<gitkraft_core::CommitInfo>,
220    /// Scroll offset of the file-history list in pixels.
221    pub file_history_scroll: f32,
222
223    /// When `Some(path)`, the blame overlay is shown for that repo-relative path.
224    pub blame_path: Option<String>,
225    /// Blame lines loaded for the blame overlay.
226    pub blame_lines: Vec<gitkraft_core::BlameLine>,
227    /// Scroll offset of the blame view in pixels.
228    pub blame_scroll: f32,
229
230    /// When `Some(path)`, a delete-confirmation banner is shown for that file.
231    pub pending_delete_file: Option<String>,
232}
233
234impl RepoTab {
235    /// Create an empty tab (no repo open — shows welcome screen).
236    pub fn new_empty() -> Self {
237        Self {
238            repo_path: None,
239            repo_info: None,
240            branches: Vec::new(),
241            current_branch: None,
242            commits: Vec::new(),
243            selected_commit: None,
244            anchor_commit_index: None,
245            selected_commits: Vec::new(),
246            graph_rows: Vec::new(),
247            unstaged_changes: Vec::new(),
248            staged_changes: Vec::new(),
249            commit_files: Vec::new(),
250            selected_commit_oid: None,
251            selected_file_index: None,
252            is_loading_file_diff: false,
253            anchor_file_index: None,
254            selected_commit_file_indices: Vec::new(),
255            multi_file_diffs: Vec::new(),
256            commit_range_diffs: Vec::new(),
257            selected_diff: None,
258            commit_message: String::new(),
259            stashes: Vec::new(),
260            remotes: Vec::new(),
261            show_commit_detail: false,
262            new_branch_name: String::new(),
263            show_branch_create: false,
264            local_branches_expanded: true,
265            remote_branches_expanded: true,
266            stash_message: String::new(),
267            selected_unstaged: std::collections::HashSet::new(),
268            selected_staged: std::collections::HashSet::new(),
269            anchor_unstaged_index: None,
270            anchor_staged_index: None,
271            pending_discard: None,
272            status_message: None,
273            error_message: None,
274            is_loading: false,
275            context_menu: None,
276            context_menu_pos: (0.0, 0.0),
277            rename_branch_target: None,
278            rename_branch_input: String::new(),
279            create_tag_target_oid: None,
280            create_tag_annotated: false,
281            create_tag_name: String::new(),
282            create_tag_message: String::new(),
283            create_branch_at_oid: None,
284            commit_scroll_offset: 0.0,
285            hovered_commit: None,
286            branch_context: Vec::new(),
287            diff_scroll_offset: 0.0,
288            commit_display: Vec::new(),
289            has_more_commits: true,
290            is_loading_more_commits: false,
291            file_history_path: None,
292            file_history_commits: Vec::new(),
293            file_history_scroll: 0.0,
294            blame_path: None,
295            blame_lines: Vec::new(),
296            blame_scroll: 0.0,
297            pending_delete_file: None,
298        }
299    }
300
301    /// Whether a repository is currently open in this tab.
302    pub fn has_repo(&self) -> bool {
303        self.repo_path.is_some()
304    }
305
306    /// Name of the first remote, or "origin" as a fallback.
307    pub fn default_remote(&self) -> String {
308        self.remotes
309            .first()
310            .map(|r| r.name.clone())
311            .unwrap_or_else(|| "origin".to_string())
312    }
313
314    /// Display name for the tab (last path component, or "New Tab").
315    pub fn display_name(&self) -> String {
316        gitkraft_core::repo_display_name(self.repo_path.as_deref())
317    }
318
319    /// Apply a full repo payload to this tab, resetting transient UI state.
320    ///
321    /// The currently selected commit (if any) is **re-pinned** by OID after the
322    /// new commit list arrives, so background auto-refreshes (git-watcher or
323    /// staging changes) never clear the user's selection.
324    pub fn apply_payload(
325        &mut self,
326        payload: crate::message::RepoPayload,
327        path: std::path::PathBuf,
328    ) {
329        // ── Save state so we can restore it after the data refresh ────
330        let is_refresh = self.repo_info.is_some();
331        let prev_oid = self.selected_commit_oid.clone();
332
333        // Save multi-selection OIDs so we can re-map them after the commit
334        // list is replaced.  Without this, background auto-refreshes (git
335        // watcher, fallback poll) would silently clear a Shift+click range.
336        let prev_anchor_oid = self
337            .anchor_commit_index
338            .and_then(|i| self.commits.get(i).map(|c| c.oid.clone()));
339        let prev_selected_oids: Vec<String> = self
340            .selected_commits
341            .iter()
342            .filter_map(|&i| self.commits.get(i).map(|c| c.oid.clone()))
343            .collect();
344
345        // Save file-level selection state.  File indices reference into
346        // `commit_files` which is NOT replaced during a refresh (the commit
347        // content hasn't changed), so these can be restored as-is.
348        let prev_anchor_file = self.anchor_file_index;
349        let prev_file_indices = self.selected_commit_file_indices.clone();
350        let prev_multi_file_diffs = std::mem::take(&mut self.multi_file_diffs);
351        let prev_commit_range_diffs = std::mem::take(&mut self.commit_range_diffs);
352
353        // Save staging selection — these are path-based so we can re-validate
354        // them against the refreshed unstaged/staged lists.
355        let prev_selected_unstaged = std::mem::take(&mut self.selected_unstaged);
356        let prev_selected_staged = std::mem::take(&mut self.selected_staged);
357        let prev_anchor_unstaged = self.anchor_unstaged_index;
358        let prev_anchor_staged = self.anchor_staged_index;
359
360        // Save standalone diff (set from staging click or file preview).
361        // When no commit is selected, this is the only diff the user sees.
362        let prev_selected_diff = self.selected_diff.take();
363        let prev_diff_scroll = self.diff_scroll_offset;
364
365        self.current_branch = payload.info.head_branch.clone();
366        self.repo_path = Some(path);
367        self.repo_info = Some(payload.info);
368        self.branches = payload.branches;
369
370        // Never shrink the commit list during a background refresh — a
371        // concurrent pagination (MoreCommitsLoaded) may have extended the
372        // list between the refresh start and its completion.  Shrinking
373        // would snap the user back and trigger an infinite load/shrink loop.
374        if !is_refresh || payload.commits.len() >= self.commits.len() {
375            self.commits = payload.commits;
376            self.graph_rows = payload.graph_rows;
377        }
378
379        self.unstaged_changes = payload.unstaged;
380        self.staged_changes = payload.staged;
381        self.stashes = payload.stashes;
382        self.remotes = payload.remotes;
383
384        // Reset transient UI state.
385        // NOTE: selected_commit / commit_files / selected_diff are restored
386        // below so they survive background auto-refreshes.
387        // NOTE: commit_message is intentionally NOT cleared here — the user
388        // may be mid-typing when a background refresh arrives.  It is only
389        // cleared on successful commit (CommitCreated handler).
390        self.selected_commit = None;
391        self.anchor_commit_index = None;
392        self.selected_commits.clear();
393        self.selected_commit_oid = None;
394        self.error_message = None;
395        self.status_message = Some("Repository loaded.".into());
396        // Scroll position is managed entirely by Iced's scrollable widget;
397        // we do NOT reset it here so background refreshes never snap the
398        // user back to the top.  Brand-new opens use a scroll_to(0,0) task
399        // in handle_repo_loaded instead.
400        if !is_refresh {
401            self.has_more_commits = true;
402        }
403        // NOTE: is_loading_more_commits is intentionally NOT reset here.
404        // It is owned by the pagination flow (CommitLogScrolled sets it true,
405        // handle_more_commits_loaded sets it false).  Resetting it during a
406        // background refresh would trick the scroll handler into firing a
407        // duplicate pagination, whose identical result then falsely sets
408        // has_more_commits = false ("end of history").
409        self.anchor_unstaged_index = None;
410        self.anchor_staged_index = None;
411        self.anchor_file_index = None;
412        self.selected_commit_file_indices.clear();
413
414        // ── Restore the previously selected commit by OID ─────────────────
415        // If the commit still exists in the refreshed list, re-select it so
416        // the diff panel, file list, and selection highlight are all
417        // preserved.  This means auto-refreshes (every 5 s fallback, git
418        // watcher) never interrupt the user’s view.
419        if let Some(oid) = prev_oid {
420            if let Some(new_idx) = self.commits.iter().position(|c| c.oid == oid) {
421                self.selected_commit = Some(new_idx);
422                self.selected_commit_oid = Some(oid);
423                // commit_files, selected_file_index, is_loading_file_diff,
424                // diff_scroll_offset are intentionally left unchanged —
425                // the commit content hasn’t changed.
426
427                // Restore selected_diff (may have been set from commit view,
428                // staging click, or file preview).
429                self.selected_diff = prev_selected_diff;
430                self.diff_scroll_offset = prev_diff_scroll;
431
432                // Restore file-level selection — the commit (and its file
433                // list) survived, so these indices are still valid.
434                self.anchor_file_index = prev_anchor_file;
435                self.selected_commit_file_indices = prev_file_indices;
436                self.multi_file_diffs = prev_multi_file_diffs;
437                self.commit_range_diffs = prev_commit_range_diffs;
438            } else {
439                // Commit was rebased / force-pushed away — clear everything.
440                self.clear_diff_state();
441            }
442        } else {
443            // No commit was selected. Preserve any standalone diff set from
444            // a staging click or file preview so it survives background refreshes.
445            if prev_selected_diff.is_some() {
446                self.selected_diff = prev_selected_diff;
447                self.diff_scroll_offset = prev_diff_scroll;
448            } else {
449                self.clear_diff_state();
450            }
451        }
452
453        // ── Restore multi-selection by OID ─────────────────────────────
454        // Re-map the saved anchor and range selection from OIDs back to
455        // indices in the (possibly reordered) new commit list.
456        if let Some(anchor_oid) = prev_anchor_oid {
457            if let Some(new_anchor) = self.commits.iter().position(|c| c.oid == anchor_oid) {
458                self.anchor_commit_index = Some(new_anchor);
459            }
460        }
461        if !prev_selected_oids.is_empty() {
462            let restored: Vec<usize> = prev_selected_oids
463                .iter()
464                .filter_map(|oid| self.commits.iter().position(|c| &c.oid == oid))
465                .collect();
466            if !restored.is_empty() {
467                self.selected_commits = restored;
468                self.selected_commits.sort_unstable();
469            }
470        }
471
472        // ── Restore staging selection ───────────────────────────────────
473        // Keep only paths that still appear in the refreshed file lists.
474        self.selected_unstaged = prev_selected_unstaged
475            .into_iter()
476            .filter(|p| self.unstaged_changes.iter().any(|d| d.display_path() == p))
477            .collect();
478        self.selected_staged = prev_selected_staged
479            .into_iter()
480            .filter(|p| self.staged_changes.iter().any(|d| d.display_path() == p))
481            .collect();
482        // Restore anchors only if they still point to a valid index.
483        self.anchor_unstaged_index =
484            prev_anchor_unstaged.filter(|&i| i < self.unstaged_changes.len());
485        self.anchor_staged_index = prev_anchor_staged.filter(|&i| i < self.staged_changes.len());
486    }
487
488    /// Clear all diff-related state (used when the selected commit disappears
489    /// or no commit was selected before a refresh).
490    fn clear_diff_state(&mut self) {
491        self.selected_diff = None;
492        self.commit_files.clear();
493        self.selected_file_index = None;
494        self.is_loading_file_diff = false;
495        self.diff_scroll_offset = 0.0;
496    }
497}
498
499// ── Top-level application state ───────────────────────────────────────────────
500
501/// Top-level application state for the GitKraft GUI.
502pub struct GitKraft {
503    // ── Tabs ──────────────────────────────────────────────────────────────
504    /// All open repository tabs.
505    pub tabs: Vec<RepoTab>,
506    /// Index of the currently active/visible tab.
507    pub active_tab: usize,
508
509    // ── UI state (global, not per-tab) ────────────────────────────────────
510    /// Whether the left sidebar is expanded.
511    pub sidebar_expanded: bool,
512
513    // ── Pane widths / heights (pixels) ────────────────────────────────────
514    /// Width of the left sidebar in pixels.
515    pub sidebar_width: f32,
516    /// Width of the commit-log panel in pixels.
517    pub commit_log_width: f32,
518    /// Height of the staging area in pixels.
519    pub staging_height: f32,
520    /// Width of the diff file-list sidebar in pixels.
521    pub diff_file_list_width: f32,
522    /// Width of the BRANCH/TAG column in the commit log (pixels).
523    pub commit_ref_width: f32,
524    /// Width of the GRAPH column in the commit log (pixels).
525    pub commit_graph_width: f32,
526    /// Fraction of sidebar height allocated to the branches section (0.0–1.0).
527    pub sidebar_branches_ratio: f32,
528    /// Fraction of sidebar height allocated to the stashes section (0.0–1.0).
529    /// Remotes get the remainder: 1.0 - branches_ratio - stash_ratio.
530    pub sidebar_stash_ratio: f32,
531    /// Fraction of staging area width allocated to the unstaged column (0.0–1.0).
532    pub staging_unstaged_ratio: f32,
533    /// Fraction of staging area width allocated to the staged column (0.0–1.0).
534    /// Commit column gets: 1.0 - unstaged_ratio - staged_ratio.
535    pub staging_staged_ratio: f32,
536
537    /// UI scale factor (1.0 = default). Adjusted with Ctrl+/Ctrl- keyboard shortcuts.
538    pub ui_scale: f32,
539
540    // ── Drag state ────────────────────────────────────────────────────────
541    /// Which vertical divider is being dragged (if any).
542    pub dragging: Option<DragTarget>,
543    /// Which horizontal divider is being dragged (if any).
544    pub dragging_h: Option<DragTargetH>,
545    /// Last known mouse X position during a drag (absolute window coords).
546    pub drag_start_x: f32,
547    /// Last known mouse Y position during a drag (absolute window coords).
548    pub drag_start_y: f32,
549    /// Whether the first move event has been received for the current vertical drag.
550    /// `false` right after `PaneDragStart` — the first `PaneDragMove` sets the
551    /// real start position instead of computing a bogus delta from 0.0.
552    pub drag_initialized: bool,
553    /// Same as `drag_initialized` but for horizontal drags.
554    pub drag_initialized_h: bool,
555
556    // ── Cursor ────────────────────────────────────────────────────────────
557    /// Last known cursor position in window coordinates.
558    /// Updated on every mouse-move event so context menus open at the
559    /// exact spot the user right-clicked.
560    pub cursor_pos: Point,
561
562    // ── Theme ─────────────────────────────────────────────────────────────
563    /// Index into `gitkraft_core::THEME_NAMES` for the currently active theme.
564    pub current_theme_index: usize,
565    /// Whether the theme follows the OS light/dark setting ("System").
566    pub follow_system: bool,
567
568    // ── Persistence ───────────────────────────────────────────────────────
569    /// Recently opened repositories (loaded from settings on startup).
570    pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
571
572    // ── About ─────────────────────────────────────────────────────────────
573    /// Whether the About dialog is visible.
574    pub show_about: bool,
575
576    // ── Search ────────────────────────────────────────────────────────────
577    /// Whether the search overlay is visible.
578    pub search_visible: bool,
579    /// Current search query text.
580    pub search_query: String,
581    /// Search results (commit infos matching the query).
582    pub search_results: Vec<gitkraft_core::CommitInfo>,
583    /// Index of the selected search result.
584    pub search_selected: Option<usize>,
585
586    /// Files changed between the selected search commit and working tree.
587    pub search_diff_files: Vec<gitkraft_core::DiffFileEntry>,
588    /// Selected file indices in the search diff file list.
589    pub search_diff_selected: HashSet<usize>,
590    /// The diff content for the currently viewed search diff file(s).
591    pub search_diff_content: Vec<gitkraft_core::DiffInfo>,
592    /// OID of the commit being diffed against working tree in search.
593    pub search_diff_oid: Option<String>,
594
595    /// Configured editor for "Open in editor" actions.
596    pub editor: gitkraft_core::Editor,
597
598    /// Current keyboard modifier state (updated via subscription).
599    pub keyboard_modifiers: iced::keyboard::Modifiers,
600
601    /// Monotonically-increasing counter incremented on every `AnimationTick`.
602    /// Drives the loading-spinner frame selection in all UI widgets.
603    pub animation_tick: u64,
604
605    // ── Window geometry ───────────────────────────────────────────────────
606    /// Last known window width (updated on WindowResized).
607    pub window_width: f32,
608    /// Last known window height (updated on WindowResized).
609    pub window_height: f32,
610    /// Last known window X position (updated on WindowMoved).
611    pub window_x: f32,
612    /// Last known window Y position (updated on WindowMoved).
613    pub window_y: f32,
614
615    /// File preview editor content — shown in the diff pane when a file preview is active.
616    pub preview_editor: Option<iced::widget::text_editor::Content>,
617    /// Path of the file being previewed (for the header display).
618    pub preview_path: Option<String>,
619}
620
621impl Default for GitKraft {
622    fn default() -> Self {
623        Self::new()
624    }
625}
626
627impl GitKraft {
628    /// Build application state from persisted [`AppSettings`].
629    ///
630    /// Starts with a single empty tab regardless of what was saved — callers
631    /// that want to restore the full session should use
632    /// [`Self::new_with_session_paths`] instead.
633    fn from_settings(settings: gitkraft_core::AppSettings) -> Self {
634        let follow_system = settings.follow_system_theme();
635        let current_theme_index = settings.theme_index();
636
637        let recent_repos = settings.recent_repos;
638
639        let (
640            sidebar_width,
641            commit_log_width,
642            staging_height,
643            diff_file_list_width,
644            sidebar_expanded,
645            ui_scale,
646            commit_ref_width,
647            commit_graph_width,
648        ) = if let Some(ref layout) = settings.layout {
649            (
650                layout.sidebar_width.unwrap_or(220.0),
651                layout.commit_log_width.unwrap_or(500.0),
652                layout.staging_height.unwrap_or(200.0),
653                layout.diff_file_list_width.unwrap_or(180.0),
654                layout.sidebar_expanded.unwrap_or(true),
655                layout.ui_scale.unwrap_or(1.0),
656                layout.commit_ref_width.unwrap_or(120.0),
657                layout.commit_graph_width.unwrap_or(80.0),
658            )
659        } else {
660            (220.0, 500.0, 200.0, 180.0, true, 1.0, 120.0, 80.0)
661        };
662
663        Self {
664            tabs: vec![RepoTab::new_empty()],
665            active_tab: 0,
666
667            sidebar_expanded,
668
669            sidebar_width,
670            commit_log_width,
671            staging_height,
672            diff_file_list_width,
673            commit_ref_width,
674            commit_graph_width,
675            sidebar_branches_ratio: 0.6,
676            sidebar_stash_ratio: 0.25,
677            staging_unstaged_ratio: 0.4,
678            staging_staged_ratio: 0.3,
679
680            ui_scale,
681
682            dragging: None,
683            dragging_h: None,
684            drag_start_x: 0.0,
685            drag_start_y: 0.0,
686            drag_initialized: false,
687            drag_initialized_h: false,
688            cursor_pos: Point::ORIGIN,
689
690            current_theme_index,
691            follow_system,
692
693            recent_repos,
694
695            show_about: false,
696            search_visible: false,
697            search_query: String::new(),
698            search_results: Vec::new(),
699            search_selected: None,
700            search_diff_files: Vec::new(),
701            search_diff_selected: HashSet::new(),
702            search_diff_content: Vec::new(),
703            search_diff_oid: None,
704
705            keyboard_modifiers: iced::keyboard::Modifiers::default(),
706            animation_tick: 0,
707
708            window_width: settings
709                .layout
710                .as_ref()
711                .and_then(|l| l.window_width)
712                .unwrap_or(1400.0),
713            window_height: settings
714                .layout
715                .as_ref()
716                .and_then(|l| l.window_height)
717                .unwrap_or(800.0),
718            window_x: settings
719                .layout
720                .as_ref()
721                .and_then(|l| l.window_x)
722                .unwrap_or(0.0),
723            window_y: settings
724                .layout
725                .as_ref()
726                .and_then(|l| l.window_y)
727                .unwrap_or(0.0),
728
729            editor: settings
730                .editor_name
731                .as_deref()
732                .map(|name| {
733                    // Try to map persisted name back to Editor variant
734                    gitkraft_core::EDITOR_NAMES
735                        .iter()
736                        .position(|n| n.eq_ignore_ascii_case(name))
737                        .map(gitkraft_core::Editor::from_index)
738                        .unwrap_or_else(|| {
739                            if name.eq_ignore_ascii_case("none") {
740                                gitkraft_core::Editor::None
741                            } else {
742                                gitkraft_core::Editor::Custom(name.to_string())
743                            }
744                        })
745                })
746                .unwrap_or_else(detect_system_editor),
747
748            preview_editor: None,
749            preview_path: None,
750        }
751    }
752
753    /// Create a fresh application state with sensible defaults.
754    ///
755    /// Loads persisted settings (theme, recent repos) from disk when available.
756    /// Always starts with one empty tab — use [`Self::new_with_session_paths`] to
757    /// restore the full multi-tab session.
758    pub fn new() -> Self {
759        Self::from_settings(
760            gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default(),
761        )
762    }
763
764    /// Create state and also return the saved tab paths for startup restore.
765    ///
766    /// Call this from `main.rs` instead of [`Self::new`]; it sets up loading tabs
767    /// for every path in the persisted session and returns those paths so the
768    /// caller can spawn parallel `load_repo_at` tasks.
769    pub fn new_with_session_paths() -> (Self, Vec<PathBuf>) {
770        let settings =
771            gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default();
772        let open_tabs = settings.open_tabs.clone();
773        let active_tab_index = settings.active_tab_index;
774
775        let mut state = Self::from_settings(settings);
776
777        if !open_tabs.is_empty() {
778            state.tabs = open_tabs
779                .iter()
780                .map(|path| {
781                    let mut tab = RepoTab::new_empty();
782                    // Set the path now so the tab bar shows the right name
783                    // while the repo is being loaded in the background.
784                    tab.repo_path = Some(path.clone());
785                    if path.exists() {
786                        tab.is_loading = true;
787                        tab.status_message = Some(format!(
788                            "Loading {}…",
789                            path.file_name().unwrap_or_default().to_string_lossy()
790                        ));
791                    } else {
792                        tab.error_message =
793                            Some(format!("Repository not found: {}", path.display()));
794                    }
795                    tab
796                })
797                .collect();
798            state.active_tab = active_tab_index.min(state.tabs.len().saturating_sub(1));
799        }
800
801        (state, open_tabs)
802    }
803
804    /// Paths of all tabs where a repository has been fully loaded
805    /// (`repo_info` is populated). Used to persist the multi-tab session.
806    pub fn open_tab_paths(&self) -> Vec<PathBuf> {
807        self.tabs
808            .iter()
809            .filter(|t| t.repo_info.is_some())
810            .filter_map(|t| t.repo_path.clone())
811            .collect()
812    }
813
814    /// Return the session-persistable (open_tab_paths, active_tab_index) pair.
815    ///
816    /// `open_tab_paths()` filters out empty tabs, so the raw `self.active_tab`
817    /// index does not map 1-to-1.  This helper computes the correct position
818    /// of the active tab within the filtered list.
819    pub fn session_state(&self) -> (Vec<PathBuf>, usize) {
820        let mut paths = Vec::new();
821        let mut session_active: usize = 0;
822        let mut found_active = false;
823        for (i, tab) in self.tabs.iter().enumerate() {
824            if tab.repo_info.is_some() {
825                if let Some(ref p) = tab.repo_path {
826                    if i == self.active_tab {
827                        session_active = paths.len();
828                        found_active = true;
829                    }
830                    paths.push(p.clone());
831                }
832            }
833        }
834        // If the active tab wasn't in the saved set (e.g. it's an empty tab),
835        // default to 0.
836        if !found_active {
837            session_active = 0;
838        }
839        (paths, session_active)
840    }
841
842    /// Get a reference to the currently active tab.
843    pub fn active_tab(&self) -> &RepoTab {
844        &self.tabs[self.active_tab]
845    }
846
847    /// Get a mutable reference to the currently active tab.
848    pub fn active_tab_mut(&mut self) -> &mut RepoTab {
849        &mut self.tabs[self.active_tab]
850    }
851
852    /// Dismiss any open context menu.
853    pub fn dismiss_menu(&mut self) {
854        self.active_tab_mut().context_menu = None;
855    }
856
857    /// Open a context menu at the current cursor position.
858    pub fn open_context_menu(&mut self, menu: ContextMenu) {
859        let pos = (self.cursor_pos.x, self.cursor_pos.y);
860        let tab = self.active_tab_mut();
861        tab.context_menu_pos = pos;
862        tab.context_menu = Some(menu);
863    }
864
865    /// Whether the active tab has a repository open.
866    pub fn has_repo(&self) -> bool {
867        self.active_tab().has_repo()
868    }
869
870    /// Helper: the display name for the active tab's repo.
871    pub fn repo_display_name(&self) -> String {
872        self.active_tab().display_name()
873    }
874
875    /// Derive the full [`ThemeColors`] from the currently active core theme.
876    ///
877    /// Call this at the top of view functions:
878    /// ```ignore
879    /// let c = state.colors();
880    /// ```
881    pub fn colors(&self) -> ThemeColors {
882        ThemeColors::from_core(&gitkraft_core::theme_by_index(self.current_theme_index))
883    }
884
885    /// Return a **custom** `iced::Theme` whose `Palette` is derived from the
886    /// active core theme.
887    ///
888    /// This is the key to making every built-in Iced widget (text inputs,
889    /// pick-lists, scrollbars, buttons without explicit `.style()`, etc.)
890    /// inherit the correct background, text, accent, success and danger
891    /// colours.  Without this, Iced falls back to its generic Dark/Light
892    /// palette and the UI looks wrong for every non-default theme.
893    pub fn iced_theme(&self) -> iced::Theme {
894        let core = gitkraft_core::theme_by_index(self.current_theme_index);
895        let name = self.current_theme_name().to_string();
896
897        let palette = iced::theme::Palette {
898            background: rgb_to_iced(core.background),
899            text: rgb_to_iced(core.text_primary),
900            primary: rgb_to_iced(core.accent),
901            success: rgb_to_iced(core.success),
902            warning: rgb_to_iced(core.warning),
903            danger: rgb_to_iced(core.error),
904        };
905
906        iced::Theme::custom(name, palette)
907    }
908
909    /// If a tab with `path` is already open, switch to it.
910    /// If the current tab is empty, remove it first.
911    /// Returns `true` if an existing tab was found and switched to.
912    pub fn switch_to_existing_tab(&mut self, path: &std::path::Path) -> bool {
913        let existing_idx = match self
914            .tabs
915            .iter()
916            .position(|t| t.repo_path.as_deref() == Some(path) && t.repo_info.is_some())
917        {
918            Some(idx) => idx,
919            None => return false,
920        };
921        // Remove the current tab if it's an empty "New Tab" so it
922        // doesn't linger in the tab bar after we switch away.
923        if !self.active_tab().has_repo() && self.active_tab != existing_idx && self.tabs.len() > 1 {
924            self.tabs.remove(self.active_tab);
925            let adjusted = if existing_idx > self.active_tab {
926                existing_idx - 1
927            } else {
928                existing_idx
929            };
930            self.active_tab = adjusted;
931        } else {
932            self.active_tab = existing_idx;
933        }
934        true
935    }
936
937    /// Name to persist for the current choice (`"System"` while following the OS).
938    pub fn persisted_theme_name(&self) -> &'static str {
939        if self.follow_system {
940            gitkraft_core::SYSTEM_THEME_NAME
941        } else {
942            self.current_theme_name()
943        }
944    }
945
946    /// Apply a pick-list choice (`THEME_COUNT` = System, otherwise a preset
947    /// index). Returns `false` for out-of-range values.
948    pub fn apply_theme_choice(&mut self, choice: usize) -> bool {
949        if choice == gitkraft_core::THEME_COUNT {
950            self.follow_system = true;
951            self.current_theme_index = gitkraft_core::current_system_theme_index();
952            true
953        } else if choice < gitkraft_core::THEME_COUNT {
954            self.follow_system = false;
955            self.current_theme_index = choice;
956            true
957        } else {
958            false
959        }
960    }
961
962    /// Re-read the OS appearance while following the system.
963    pub fn refresh_system_theme(&mut self) {
964        if self.follow_system {
965            self.current_theme_index = gitkraft_core::current_system_theme_index();
966        }
967    }
968
969    /// The display name of the currently active preset (the resolved preset
970    /// while following the system).
971    pub fn current_theme_name(&self) -> &'static str {
972        gitkraft_core::THEME_NAMES
973            .get(self.current_theme_index)
974            .copied()
975            .unwrap_or("Default")
976    }
977
978    /// Refresh all data for the currently active tab's repository.
979    ///
980    /// Returns [`Task::none()`] if no repository is open in the active tab.
981    pub fn refresh_active_tab(&mut self) -> Task<Message> {
982        let tab = self.active_tab();
983        // Don't spawn a duplicate refresh when one is already in flight
984        // (is_loading is also set by the manual RefreshRepo handler).
985        if tab.is_loading {
986            return Task::none();
987        }
988        match tab.repo_path.clone() {
989            Some(path) => {
990                let depth = tab.commits.len();
991                // Mark as loading BEFORE spawning the background task to prevent
992                // concurrent refresh storms from FileSystemChanged events.  Without
993                // this guard, multiple refreshes could overlap — each opening a full
994                // git2::Repository with mmapped packfiles, causing GB-scale memory use.
995                self.active_tab_mut().is_loading = true;
996                crate::features::repo::commands::refresh_repo(path, depth)
997            }
998            None => Task::none(),
999        }
1000    }
1001
1002    /// Handle a `Result<(), String>` from a git operation that should trigger
1003    /// a full repository refresh on success.
1004    ///
1005    /// * `Ok(())` — clears `is_loading`, sets `status_message`, refreshes.
1006    /// * `Err(e)` — clears `is_loading`, sets `error_message`, returns
1007    ///   [`Task::none()`].
1008    pub fn on_ok_refresh(
1009        &mut self,
1010        result: Result<(), String>,
1011        ok_msg: &str,
1012        err_prefix: &str,
1013    ) -> Task<Message> {
1014        match result {
1015            Ok(()) => {
1016                {
1017                    let tab = self.active_tab_mut();
1018                    tab.is_loading = false;
1019                    tab.status_message = Some(ok_msg.to_string());
1020                }
1021                self.refresh_active_tab()
1022            }
1023            Err(e) => {
1024                let tab = self.active_tab_mut();
1025                tab.is_loading = false;
1026                tab.error_message = Some(format!("{err_prefix}: {e}"));
1027                tab.status_message = None;
1028                Task::none()
1029            }
1030        }
1031    }
1032
1033    /// Build a [`LayoutSettings`] snapshot from the current pane dimensions.
1034    pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
1035        gitkraft_core::LayoutSettings {
1036            sidebar_width: Some(self.sidebar_width),
1037            commit_log_width: Some(self.commit_log_width),
1038            staging_height: Some(self.staging_height),
1039            diff_file_list_width: Some(self.diff_file_list_width),
1040            sidebar_expanded: Some(self.sidebar_expanded),
1041            ui_scale: Some(self.ui_scale),
1042            window_width: Some(self.window_width),
1043            window_height: Some(self.window_height),
1044            window_x: Some(self.window_x),
1045            window_y: Some(self.window_y),
1046            window_maximized: None, // not tracked
1047            commit_ref_width: Some(self.commit_ref_width),
1048            commit_graph_width: Some(self.commit_graph_width),
1049        }
1050    }
1051}
1052
1053/// Convert a core [`gitkraft_core::Rgb`] to an [`iced::Color`].
1054fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
1055    Color::from_rgb8(rgb.r, rgb.g, rgb.b)
1056}
1057
1058/// Try to detect the system's preferred editor from environment variables.
1059fn detect_system_editor() -> gitkraft_core::Editor {
1060    for var in ["VISUAL", "EDITOR"] {
1061        if let Ok(val) = std::env::var(var) {
1062            let bin = val.split('/').next_back().unwrap_or(&val).trim();
1063            return match bin {
1064                "nvim" | "neovim" => gitkraft_core::Editor::Neovim,
1065                "vim" => gitkraft_core::Editor::Vim,
1066                "hx" | "helix" => gitkraft_core::Editor::Helix,
1067                "nano" => gitkraft_core::Editor::Nano,
1068                "micro" => gitkraft_core::Editor::Micro,
1069                "emacs" => gitkraft_core::Editor::Emacs,
1070                "code" => gitkraft_core::Editor::VSCode,
1071                "zed" => gitkraft_core::Editor::Zed,
1072                "subl" => gitkraft_core::Editor::Sublime,
1073                _ => gitkraft_core::Editor::Custom(val),
1074            };
1075        }
1076    }
1077    gitkraft_core::Editor::None
1078}
1079
1080// ── Tests ─────────────────────────────────────────────────────────────────────
1081
1082#[cfg(test)]
1083mod tests {
1084    use super::*;
1085
1086    #[test]
1087    fn new_defaults() {
1088        let state = GitKraft::new();
1089        assert!(state.active_tab().repo_path.is_none());
1090        assert!(!state.has_repo());
1091        assert_eq!(state.repo_display_name(), "New Tab");
1092        assert!(state.active_tab().commits.is_empty());
1093        assert!(state.sidebar_expanded);
1094        // Default theme index should be valid
1095        assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
1096        // Pane defaults
1097        assert!(state.sidebar_width > 0.0);
1098        assert!(state.commit_log_width > 0.0);
1099        assert!(state.staging_height > 0.0);
1100        assert!(state.dragging.is_none());
1101        assert!(state.dragging_h.is_none());
1102        // Should start with one empty tab
1103        assert_eq!(state.tabs.len(), 1);
1104        assert_eq!(state.active_tab, 0);
1105    }
1106
1107    #[test]
1108    fn repo_display_name_extracts_basename() {
1109        let mut state = GitKraft::new();
1110        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
1111        assert_eq!(state.repo_display_name(), "my-project");
1112    }
1113
1114    #[test]
1115    fn colors_returns_theme_colors() {
1116        let state = GitKraft::new();
1117        let c = state.colors();
1118        // The default theme (index 0) is dark, so background should be dark
1119        assert!(c.bg.r < 0.5);
1120    }
1121
1122    #[test]
1123    fn iced_theme_is_custom_with_correct_palette() {
1124        let mut state = GitKraft::new();
1125
1126        // Index 0 = Default (dark) — custom theme with dark background
1127        state.current_theme_index = 0;
1128        let iced_t = state.iced_theme();
1129        let pal = iced_t.palette();
1130        assert!(pal.background.r < 0.5, "Default theme bg should be dark");
1131        assert_eq!(iced_t.to_string(), "Default");
1132
1133        // Solarized Light — custom theme with light background
1134        let sol_idx = gitkraft_core::theme_index_by_name("Solarized Light");
1135        state.current_theme_index = sol_idx;
1136        let iced_t = state.iced_theme();
1137        let pal = iced_t.palette();
1138        assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
1139        assert_eq!(iced_t.to_string(), "Solarized Light");
1140
1141        // Gruvbox Dark — accent should come from core
1142        let gruvbox_idx = gitkraft_core::theme_index_by_name("Gruvbox Dark");
1143        state.current_theme_index = gruvbox_idx;
1144        let iced_t = state.iced_theme();
1145        let pal = iced_t.palette();
1146        let core = gitkraft_core::theme_by_index(gruvbox_idx);
1147        let expected_accent = rgb_to_iced(core.accent);
1148        assert!(
1149            (pal.primary.r - expected_accent.r).abs() < 0.01
1150                && (pal.primary.g - expected_accent.g).abs() < 0.01
1151                && (pal.primary.b - expected_accent.b).abs() < 0.01,
1152            "Gruvbox Dark accent should match core accent"
1153        );
1154    }
1155
1156    #[test]
1157    fn iced_theme_name_round_trips_through_core() {
1158        // Ensure the custom theme name matches a core THEME_NAMES entry so
1159        // that ThemeColors::from_theme() can map it back to the right index.
1160        for i in 0..gitkraft_core::THEME_COUNT {
1161            let mut state = GitKraft::new();
1162            state.current_theme_index = i;
1163            let iced_t = state.iced_theme();
1164            let name = iced_t.to_string();
1165            let resolved = gitkraft_core::theme_index_by_name(&name);
1166            assert_eq!(
1167                resolved,
1168                i,
1169                "theme index {i} ({}) did not round-trip through iced_theme name",
1170                gitkraft_core::THEME_NAMES[i]
1171            );
1172        }
1173    }
1174
1175    #[test]
1176    fn current_theme_name_round_trips() {
1177        let mut state = GitKraft::new();
1178        state.current_theme_index = gitkraft_core::theme_index_by_name("Dracula");
1179        assert_eq!(state.current_theme_name(), "Dracula");
1180        state.current_theme_index = 0;
1181        assert_eq!(state.current_theme_name(), "Default");
1182    }
1183
1184    #[test]
1185    fn repo_tab_new_empty() {
1186        let tab = RepoTab::new_empty();
1187        assert!(tab.repo_path.is_none());
1188        assert!(!tab.has_repo());
1189        assert_eq!(tab.display_name(), "New Tab");
1190        assert!(tab.commits.is_empty());
1191        assert!(tab.branches.is_empty());
1192        assert!(!tab.is_loading);
1193    }
1194
1195    #[test]
1196    fn repo_tab_display_name_with_path() {
1197        let mut tab = RepoTab::new_empty();
1198        tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
1199        assert!(tab.has_repo());
1200        assert_eq!(tab.display_name(), "cool-repo");
1201    }
1202
1203    #[test]
1204    fn search_defaults() {
1205        let state = GitKraft::new();
1206        assert!(!state.search_visible);
1207        assert!(state.search_query.is_empty());
1208        assert!(state.search_results.is_empty());
1209        assert!(state.search_selected.is_none());
1210    }
1211
1212    #[test]
1213    fn context_menu_variants_exist() {
1214        // Verify all context menu variants can be constructed
1215        use crate::state::ContextMenu;
1216
1217        let _branch = ContextMenu::Branch {
1218            name: "main".to_string(),
1219            is_current: true,
1220            local_index: 0,
1221        };
1222        let _remote = ContextMenu::RemoteBranch {
1223            name: "origin/main".to_string(),
1224        };
1225        let _commit = ContextMenu::Commit {
1226            index: 0,
1227            oid: "abc1234".to_string(),
1228        };
1229        let _stash = ContextMenu::Stash { index: 0 };
1230        let _unstaged = ContextMenu::UnstagedFile {
1231            path: "src/main.rs".to_string(),
1232        };
1233        let _staged = ContextMenu::StagedFile {
1234            path: "src/lib.rs".to_string(),
1235        };
1236    }
1237
1238    #[test]
1239    fn repo_tab_context_menu_defaults_to_none() {
1240        let tab = crate::state::RepoTab::new_empty();
1241        assert!(tab.context_menu.is_none());
1242    }
1243
1244    #[test]
1245    fn context_menu_variants_constructable() {
1246        use crate::state::ContextMenu;
1247        let _ = ContextMenu::Stash { index: 0 };
1248        let _ = ContextMenu::UnstagedFile {
1249            path: "a.rs".into(),
1250        };
1251        let _ = ContextMenu::StagedFile {
1252            path: "b.rs".into(),
1253        };
1254    }
1255
1256    #[test]
1257    fn selected_unstaged_defaults_empty() {
1258        let tab = crate::state::RepoTab::new_empty();
1259        assert!(tab.selected_unstaged.is_empty());
1260        assert!(tab.selected_staged.is_empty());
1261    }
1262
1263    #[test]
1264    fn selected_unstaged_toggle() {
1265        let mut tab = crate::state::RepoTab::new_empty();
1266        tab.selected_unstaged.insert("a.rs".to_string());
1267        tab.selected_unstaged.insert("b.rs".to_string());
1268        assert_eq!(tab.selected_unstaged.len(), 2);
1269        assert!(tab.selected_unstaged.contains("a.rs"));
1270        tab.selected_unstaged.remove("a.rs");
1271        assert_eq!(tab.selected_unstaged.len(), 1);
1272        assert!(!tab.selected_unstaged.contains("a.rs"));
1273    }
1274
1275    #[test]
1276    fn detect_system_editor_returns_valid() {
1277        // Just verify it doesn't panic
1278        let editor = super::detect_system_editor();
1279        let _ = editor.display_name();
1280    }
1281
1282    // ── Multi-file commit diff selection ──────────────────────────────────
1283
1284    #[test]
1285    fn selected_commit_file_indices_defaults_to_empty_vec() {
1286        let tab = RepoTab::new_empty();
1287        assert!(tab.selected_commit_file_indices.is_empty());
1288        // Must be a Vec (ordered), not a HashSet — check it supports indexing
1289        let v: &Vec<usize> = &tab.selected_commit_file_indices;
1290        assert_eq!(v.len(), 0);
1291    }
1292
1293    #[test]
1294    fn multi_file_diffs_defaults_empty() {
1295        let tab = RepoTab::new_empty();
1296        assert!(tab.multi_file_diffs.is_empty());
1297    }
1298
1299    #[test]
1300    fn keyboard_modifiers_default_has_no_shift() {
1301        let state = GitKraft::new();
1302        assert!(!state.keyboard_modifiers.shift());
1303    }
1304
1305    #[test]
1306    fn selected_commit_file_indices_preserves_insertion_order() {
1307        let mut tab = RepoTab::new_empty();
1308        tab.selected_commit_file_indices.push(5);
1309        tab.selected_commit_file_indices.push(2);
1310        tab.selected_commit_file_indices.push(8);
1311        assert_eq!(tab.selected_commit_file_indices, vec![5, 2, 8]);
1312    }
1313
1314    #[test]
1315    fn selected_commit_file_indices_cleared_on_reset() {
1316        let mut tab = RepoTab::new_empty();
1317        tab.selected_commit_file_indices.push(0);
1318        tab.selected_commit_file_indices.push(1);
1319        tab.selected_commit_file_indices.clear();
1320        assert!(tab.selected_commit_file_indices.is_empty());
1321    }
1322
1323    #[test]
1324    fn multi_file_diffs_cleared_on_reset() {
1325        let mut tab = RepoTab::new_empty();
1326        tab.multi_file_diffs.push(gitkraft_core::DiffInfo {
1327            old_file: String::new(),
1328            new_file: "a.rs".to_string(),
1329            status: gitkraft_core::FileStatus::Modified,
1330            hunks: vec![],
1331        });
1332        tab.multi_file_diffs.clear();
1333        assert!(tab.multi_file_diffs.is_empty());
1334    }
1335
1336    #[test]
1337    fn commit_range_diffs_defaults_empty() {
1338        let tab = RepoTab::new_empty();
1339        assert!(tab.commit_range_diffs.is_empty());
1340    }
1341
1342    #[test]
1343    fn commit_range_diffs_cleared_on_apply_payload() {
1344        // verify the field is reset — just check it's accessible and clearable
1345        let mut tab = RepoTab::new_empty();
1346        tab.commit_range_diffs.push(gitkraft_core::DiffInfo {
1347            old_file: String::new(),
1348            new_file: "x.rs".to_string(),
1349            status: gitkraft_core::FileStatus::Modified,
1350            hunks: vec![],
1351        });
1352        tab.commit_range_diffs.clear();
1353        assert!(tab.commit_range_diffs.is_empty());
1354    }
1355
1356    // ── ModifiersChanged update ───────────────────────────────────────────
1357
1358    #[test]
1359    fn modifiers_changed_sets_shift_state() {
1360        use crate::message::Message;
1361        let mut state = GitKraft::new();
1362        assert!(!state.keyboard_modifiers.shift());
1363
1364        let _ = state.update(Message::ModifiersChanged(iced::keyboard::Modifiers::SHIFT));
1365        assert!(state.keyboard_modifiers.shift());
1366
1367        let _ = state.update(Message::ModifiersChanged(
1368            iced::keyboard::Modifiers::default(),
1369        ));
1370        assert!(!state.keyboard_modifiers.shift());
1371    }
1372
1373    // ── SelectDiffByIndex update ──────────────────────────────────────────
1374
1375    fn make_commit_files(names: &[&str]) -> Vec<gitkraft_core::DiffFileEntry> {
1376        names
1377            .iter()
1378            .map(|name| gitkraft_core::DiffFileEntry {
1379                old_file: String::new(),
1380                new_file: name.to_string(),
1381                status: gitkraft_core::FileStatus::Modified,
1382            })
1383            .collect()
1384    }
1385
1386    #[test]
1387    fn select_diff_by_index_regular_click_clears_multi_selection() {
1388        use crate::message::Message;
1389        let mut state = GitKraft::new();
1390        // Provide a repo_path and oid so the update handler can reach the
1391        // `selected_file_index = Some(index)` assignment (the async task it
1392        // spawns is dropped without execution — no real repo is needed).
1393        state.active_tab_mut().repo_path =
1394            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1395        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1396        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1397        // Pre-populate a multi-selection
1398        state.active_tab_mut().selected_commit_file_indices = vec![0, 1];
1399
1400        // Regular click (no Shift) — should collapse to single-file selection
1401        let _ = state.update(Message::SelectDiffByIndex(0));
1402
1403        assert!(state.active_tab().selected_commit_file_indices.is_empty());
1404        assert_eq!(state.active_tab().selected_file_index, Some(0));
1405    }
1406
1407    #[test]
1408    fn regular_click_preserves_multi_file_diffs_until_load_completes() {
1409        use crate::message::Message;
1410        let mut state = GitKraft::new();
1411        state.active_tab_mut().repo_path =
1412            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1413        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1414        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1415
1416        // Pre-populate multi_file_diffs as if user had a multi-file selection
1417        state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1418            old_file: String::new(),
1419            new_file: "a.rs".to_string(),
1420            status: gitkraft_core::FileStatus::Modified,
1421            hunks: vec![],
1422        }];
1423
1424        // Regular click — should NOT clear multi_file_diffs immediately
1425        // (deferred to SingleFileDiffLoaded to avoid visual blink).
1426        let _ = state.update(Message::SelectDiffByIndex(1));
1427
1428        assert!(
1429            !state.active_tab().multi_file_diffs.is_empty(),
1430            "multi_file_diffs must NOT be cleared on click (deferred to diff load)"
1431        );
1432        assert_eq!(state.active_tab().selected_file_index, Some(1));
1433        assert!(state.active_tab().is_loading_file_diff);
1434    }
1435
1436    #[test]
1437    fn regular_click_preserves_diff_scroll_until_load_completes() {
1438        use crate::message::Message;
1439        let mut state = GitKraft::new();
1440        state.active_tab_mut().repo_path =
1441            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1442        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1443        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1444        state.active_tab_mut().diff_scroll_offset = 150.0;
1445
1446        // Regular click — should NOT reset diff_scroll_offset immediately.
1447        let _ = state.update(Message::SelectDiffByIndex(1));
1448
1449        assert_eq!(
1450            state.active_tab().diff_scroll_offset,
1451            150.0,
1452            "diff_scroll_offset must NOT be reset on click (deferred to diff load)"
1453        );
1454    }
1455
1456    #[test]
1457    fn single_file_diff_loaded_clears_deferred_state() {
1458        use crate::message::Message;
1459        let mut state = GitKraft::new();
1460        state.active_tab_mut().repo_path =
1461            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1462        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1463        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1464
1465        // Pre-populate multi_file_diffs and commit_range_diffs
1466        state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1467            old_file: String::new(),
1468            new_file: "a.rs".to_string(),
1469            status: gitkraft_core::FileStatus::Modified,
1470            hunks: vec![],
1471        }];
1472        state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1473            old_file: String::new(),
1474            new_file: "b.rs".to_string(),
1475            status: gitkraft_core::FileStatus::Modified,
1476            hunks: vec![],
1477        }];
1478        state.active_tab_mut().is_loading_file_diff = true;
1479        state.active_tab_mut().diff_scroll_offset = 200.0;
1480
1481        // Simulate SingleFileDiffLoaded arriving with the new diff.
1482        let new_diff = gitkraft_core::DiffInfo {
1483            old_file: String::new(),
1484            new_file: "b.rs".to_string(),
1485            status: gitkraft_core::FileStatus::Modified,
1486            hunks: vec![],
1487        };
1488        let _ = state.update(Message::SingleFileDiffLoaded(Ok(new_diff)));
1489
1490        // All deferred state should now be cleared.
1491        assert!(
1492            state.active_tab().multi_file_diffs.is_empty(),
1493            "multi_file_diffs must be cleared when new diff arrives"
1494        );
1495        assert!(
1496            state.active_tab().commit_range_diffs.is_empty(),
1497            "commit_range_diffs must be cleared when new diff arrives"
1498        );
1499        assert!(
1500            state.active_tab().selected_diff.is_some(),
1501            "selected_diff must be set to the loaded diff"
1502        );
1503        assert!(
1504            !state.active_tab().is_loading_file_diff,
1505            "is_loading_file_diff must be cleared after load"
1506        );
1507        assert_eq!(
1508            state.active_tab().diff_scroll_offset,
1509            0.0,
1510            "diff_scroll_offset must be reset when new diff arrives"
1511        );
1512    }
1513
1514    #[test]
1515    fn regular_click_preserves_commit_range_diffs_until_load_completes() {
1516        use crate::message::Message;
1517        let mut state = GitKraft::new();
1518        state.active_tab_mut().repo_path =
1519            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1520        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1521        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1522
1523        // Pre-populate commit_range_diffs
1524        state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1525            old_file: String::new(),
1526            new_file: "x.rs".to_string(),
1527            status: gitkraft_core::FileStatus::Modified,
1528            hunks: vec![],
1529        }];
1530
1531        let _ = state.update(Message::SelectDiffByIndex(0));
1532
1533        assert!(
1534            !state.active_tab().commit_range_diffs.is_empty(),
1535            "commit_range_diffs must NOT be cleared on click (deferred to diff load)"
1536        );
1537    }
1538
1539    #[test]
1540    fn select_diff_by_index_shift_click_adds_both_files_to_selection() {
1541        use crate::message::Message;
1542        let mut state = GitKraft::new();
1543        state.active_tab_mut().repo_path =
1544            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1545        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1546        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1547        state.active_tab_mut().selected_file_index = Some(0);
1548
1549        // Shift+Click on file 1 should anchor 0 and add 1
1550        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1551        let _ = state.update(Message::SelectDiffByIndex(1));
1552
1553        let sel = &state.active_tab().selected_commit_file_indices;
1554        assert!(sel.contains(&0), "anchor file 0 should be selected");
1555        assert!(sel.contains(&1), "newly clicked file 1 should be selected");
1556        assert_eq!(sel.len(), 2);
1557    }
1558
1559    #[test]
1560    fn anchor_file_index_defaults_to_none() {
1561        let tab = RepoTab::new_empty();
1562        assert!(tab.anchor_file_index.is_none());
1563    }
1564
1565    #[test]
1566    fn regular_click_sets_anchor() {
1567        use crate::message::Message;
1568        let mut state = GitKraft::new();
1569        state.active_tab_mut().repo_path =
1570            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1571        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1572        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1573
1574        let _ = state.update(Message::SelectDiffByIndex(2));
1575
1576        assert_eq!(
1577            state.active_tab().anchor_file_index,
1578            Some(2),
1579            "regular click must set anchor to the clicked index"
1580        );
1581    }
1582
1583    #[test]
1584    fn shift_click_selects_range_downward_from_anchor() {
1585        use crate::message::Message;
1586        let mut state = GitKraft::new();
1587        state.active_tab_mut().repo_path =
1588            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1589        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1590        state.active_tab_mut().commit_files =
1591            make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1592        // Anchor at index 1
1593        state.active_tab_mut().anchor_file_index = Some(1);
1594        state.active_tab_mut().selected_file_index = Some(1);
1595
1596        // Shift+Click on index 4 — should select 1, 2, 3, 4
1597        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1598        let _ = state.update(Message::SelectDiffByIndex(4));
1599
1600        let sel = &state.active_tab().selected_commit_file_indices;
1601        assert_eq!(
1602            sel,
1603            &vec![1, 2, 3, 4],
1604            "range must be contiguous from anchor to click"
1605        );
1606    }
1607
1608    #[test]
1609    fn shift_click_selects_range_upward_from_anchor() {
1610        use crate::message::Message;
1611        let mut state = GitKraft::new();
1612        state.active_tab_mut().repo_path =
1613            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1614        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1615        state.active_tab_mut().commit_files =
1616            make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1617        // Anchor at index 4 (bottom)
1618        state.active_tab_mut().anchor_file_index = Some(4);
1619        state.active_tab_mut().selected_file_index = Some(4);
1620
1621        // Shift+Click on index 1 — should select 1, 2, 3, 4 (ascending)
1622        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1623        let _ = state.update(Message::SelectDiffByIndex(1));
1624
1625        let sel = &state.active_tab().selected_commit_file_indices;
1626        assert_eq!(
1627            sel,
1628            &vec![1, 2, 3, 4],
1629            "range must be stored ascending regardless of click direction"
1630        );
1631    }
1632
1633    #[test]
1634    fn shift_click_anchor_fixed_on_subsequent_clicks() {
1635        use crate::message::Message;
1636        let mut state = GitKraft::new();
1637        state.active_tab_mut().repo_path =
1638            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1639        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1640        state.active_tab_mut().commit_files =
1641            make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1642        // Anchor at index 2
1643        state.active_tab_mut().anchor_file_index = Some(2);
1644        state.active_tab_mut().selected_file_index = Some(2);
1645        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1646
1647        // First Shift+Click: extend to 4 → range {2, 3, 4}
1648        let _ = state.update(Message::SelectDiffByIndex(4));
1649        assert_eq!(
1650            state.active_tab().selected_commit_file_indices,
1651            vec![2, 3, 4]
1652        );
1653
1654        // Second Shift+Click: shrink back to 3 → range {2, 3} (anchor still 2)
1655        let _ = state.update(Message::SelectDiffByIndex(3));
1656        assert_eq!(
1657            state.active_tab().selected_commit_file_indices,
1658            vec![2, 3],
1659            "anchor must stay fixed; second Shift+Click shrinks the range"
1660        );
1661
1662        // Third Shift+Click: extend upward → range {0, 1, 2} (anchor still 2)
1663        let _ = state.update(Message::SelectDiffByIndex(0));
1664        assert_eq!(
1665            state.active_tab().selected_commit_file_indices,
1666            vec![0, 1, 2],
1667            "anchor must stay fixed; can extend range in either direction"
1668        );
1669    }
1670
1671    #[test]
1672    fn shift_click_on_anchor_itself_gives_single_item_range() {
1673        use crate::message::Message;
1674        let mut state = GitKraft::new();
1675        state.active_tab_mut().repo_path =
1676            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1677        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1678        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1679        state.active_tab_mut().anchor_file_index = Some(1);
1680        state.active_tab_mut().selected_file_index = Some(1);
1681
1682        // Shift+Click on the anchor itself → single-item range {1}
1683        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1684        let _ = state.update(Message::SelectDiffByIndex(1));
1685
1686        assert_eq!(state.active_tab().selected_commit_file_indices, vec![1]);
1687        assert!(
1688            state.active_tab().multi_file_diffs.is_empty(),
1689            "single-item range must not populate multi_file_diffs"
1690        );
1691    }
1692
1693    #[test]
1694    fn shift_click_range_is_always_ascending() {
1695        use crate::message::Message;
1696        let mut state = GitKraft::new();
1697        state.active_tab_mut().repo_path =
1698            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1699        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1700        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs"]);
1701        state.active_tab_mut().anchor_file_index = Some(3);
1702        state.active_tab_mut().selected_file_index = Some(3);
1703
1704        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1705        let _ = state.update(Message::SelectDiffByIndex(0));
1706
1707        let sel = &state.active_tab().selected_commit_file_indices;
1708        let is_sorted = sel.windows(2).all(|w| w[0] < w[1]);
1709        assert!(
1710            is_sorted,
1711            "selection must always be stored in ascending order"
1712        );
1713        assert_eq!(sel, &vec![0, 1, 2, 3]);
1714    }
1715
1716    #[test]
1717    fn checkout_file_at_commit_message_variants_exist() {
1718        use crate::message::Message;
1719        // Verify the new message variants can be constructed
1720        let _single =
1721            Message::CheckoutFileAtCommit("abc123".to_string(), "src/main.rs".to_string());
1722        let _multi = Message::CheckoutMultiFilesAtCommit(
1723            "abc123".to_string(),
1724            vec!["a.rs".to_string(), "b.rs".to_string()],
1725        );
1726    }
1727
1728    #[test]
1729    fn checkout_file_at_commit_closes_context_menu() {
1730        use crate::message::Message;
1731        let mut state = GitKraft::new();
1732        state.active_tab_mut().repo_path =
1733            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1734        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1735            oid: "abc123".to_string(),
1736            file_path: "src/main.rs".to_string(),
1737        });
1738        let _ = state.update(Message::CheckoutFileAtCommit(
1739            "abc123".to_string(),
1740            "src/main.rs".to_string(),
1741        ));
1742        assert!(state.active_tab().context_menu.is_none());
1743    }
1744
1745    #[test]
1746    fn checkout_multi_files_at_commit_closes_context_menu() {
1747        use crate::message::Message;
1748        let mut state = GitKraft::new();
1749        state.active_tab_mut().repo_path =
1750            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1751        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1752            oid: "abc123".to_string(),
1753            file_path: "src/main.rs".to_string(),
1754        });
1755        let _ = state.update(Message::CheckoutMultiFilesAtCommit(
1756            "abc123".to_string(),
1757            vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
1758        ));
1759        assert!(state.active_tab().context_menu.is_none());
1760    }
1761
1762    // ── Commit multi-selection ────────────────────────────────────────────
1763
1764    fn make_test_commits(count: usize) -> Vec<gitkraft_core::CommitInfo> {
1765        (0..count)
1766            .map(|i| gitkraft_core::CommitInfo {
1767                oid: i.to_string(),
1768                summary: String::new(),
1769                message: String::new(),
1770                author_name: String::new(),
1771                author_email: String::new(),
1772                time: Default::default(),
1773                parent_ids: Vec::new(),
1774                refs: Vec::new(),
1775            })
1776            .collect()
1777    }
1778
1779    #[test]
1780    fn selected_commits_defaults_empty() {
1781        let tab = RepoTab::new_empty();
1782        assert!(tab.selected_commits.is_empty());
1783        assert!(tab.anchor_commit_index.is_none());
1784    }
1785
1786    #[test]
1787    fn regular_click_commit_sets_anchor_and_clears_range() {
1788        use crate::message::Message;
1789        let mut state = GitKraft::new();
1790        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
1791        state.active_tab_mut().commits = make_test_commits(3);
1792        state.active_tab_mut().selected_commits = vec![0, 1, 2];
1793
1794        let _ = state.update(Message::SelectCommit(1));
1795
1796        assert_eq!(state.active_tab().anchor_commit_index, Some(1));
1797        assert!(state.active_tab().selected_commits.is_empty());
1798        assert_eq!(state.active_tab().selected_commit, Some(1));
1799    }
1800
1801    #[test]
1802    fn shift_click_commit_selects_range_from_anchor() {
1803        use crate::message::Message;
1804        let mut state = GitKraft::new();
1805        state.active_tab_mut().commits = make_test_commits(5);
1806        state.active_tab_mut().anchor_commit_index = Some(1);
1807        state.active_tab_mut().selected_commit = Some(1);
1808
1809        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1810        let _ = state.update(Message::SelectCommit(4));
1811
1812        assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3, 4]);
1813    }
1814
1815    #[test]
1816    fn shift_click_commit_range_is_ascending_when_clicking_above_anchor() {
1817        use crate::message::Message;
1818        let mut state = GitKraft::new();
1819        state.active_tab_mut().commits = make_test_commits(5);
1820        state.active_tab_mut().anchor_commit_index = Some(3);
1821        state.active_tab_mut().selected_commit = Some(3);
1822
1823        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1824        let _ = state.update(Message::SelectCommit(1));
1825
1826        assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3]);
1827    }
1828
1829    // ── ExecuteCommitAction message ───────────────────────────────────────
1830
1831    #[test]
1832    fn execute_commit_action_closes_context_menu() {
1833        use crate::message::Message;
1834        let mut state = GitKraft::new();
1835        state.active_tab_mut().repo_path =
1836            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1837        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::Commit {
1838            index: 0,
1839            oid: "abc123".to_string(),
1840        });
1841
1842        let _ = state.update(Message::ExecuteCommitAction(
1843            "abc123".to_string(),
1844            gitkraft_core::CommitAction::CherryPick,
1845        ));
1846
1847        assert!(state.active_tab().context_menu.is_none());
1848    }
1849
1850    #[test]
1851    fn execute_commit_action_sets_loading_when_repo_open() {
1852        use crate::message::Message;
1853        let mut state = GitKraft::new();
1854        state.active_tab_mut().repo_path =
1855            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1856
1857        let _ = state.update(Message::ExecuteCommitAction(
1858            "abc123".to_string(),
1859            gitkraft_core::CommitAction::ResetHard,
1860        ));
1861
1862        assert!(state.active_tab().is_loading);
1863    }
1864
1865    #[test]
1866    fn execute_commit_action_no_repo_does_not_set_loading() {
1867        use crate::message::Message;
1868        let mut state = GitKraft::new();
1869        // No repo_path set
1870
1871        let _ = state.update(Message::ExecuteCommitAction(
1872            "abc123".to_string(),
1873            gitkraft_core::CommitAction::CherryPick,
1874        ));
1875
1876        assert!(!state.active_tab().is_loading);
1877    }
1878
1879    #[test]
1880    fn execute_commit_action_sets_status_message_from_action_label() {
1881        use crate::message::Message;
1882        let mut state = GitKraft::new();
1883        state.active_tab_mut().repo_path =
1884            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1885
1886        let _ = state.update(Message::ExecuteCommitAction(
1887            "abc123".to_string(),
1888            gitkraft_core::CommitAction::Revert,
1889        ));
1890
1891        let status = state.active_tab().status_message.as_deref().unwrap_or("");
1892        // Status message should contain the action's label
1893        assert!(
1894            status.contains("Revert commit"),
1895            "expected status to contain 'Revert commit', got: {status:?}"
1896        );
1897    }
1898
1899    // ── File history / blame / delete state ──────────────────────────────
1900
1901    #[test]
1902    fn file_history_defaults_empty() {
1903        let tab = RepoTab::new_empty();
1904        assert!(tab.file_history_path.is_none());
1905        assert!(tab.file_history_commits.is_empty());
1906        assert_eq!(tab.file_history_scroll, 0.0);
1907    }
1908
1909    #[test]
1910    fn blame_defaults_empty() {
1911        let tab = RepoTab::new_empty();
1912        assert!(tab.blame_path.is_none());
1913        assert!(tab.blame_lines.is_empty());
1914        assert_eq!(tab.blame_scroll, 0.0);
1915    }
1916
1917    #[test]
1918    fn pending_delete_file_defaults_none() {
1919        let tab = RepoTab::new_empty();
1920        assert!(tab.pending_delete_file.is_none());
1921    }
1922
1923    #[test]
1924    fn view_file_history_sets_path_and_clears_blame() {
1925        use crate::message::Message;
1926        let mut state = GitKraft::new();
1927        state.active_tab_mut().repo_path =
1928            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1929        state.active_tab_mut().blame_path = Some("some/file.rs".to_string());
1930
1931        let _ = state.update(Message::ViewFileHistory("src/main.rs".to_string()));
1932
1933        assert_eq!(
1934            state.active_tab().file_history_path.as_deref(),
1935            Some("src/main.rs")
1936        );
1937        // Opening history should close blame
1938        assert!(state.active_tab().blame_path.is_none());
1939    }
1940
1941    #[test]
1942    fn close_file_history_clears_state() {
1943        use crate::message::Message;
1944        let mut state = GitKraft::new();
1945        state.active_tab_mut().file_history_path = Some("src/lib.rs".to_string());
1946        state.active_tab_mut().file_history_commits = vec![gitkraft_core::CommitInfo {
1947            oid: "abc".to_string(),
1948            summary: "s".to_string(),
1949            message: "s".to_string(),
1950            author_name: "a".to_string(),
1951            author_email: "a@b.com".to_string(),
1952            time: Default::default(),
1953            parent_ids: vec![],
1954            refs: Vec::new(),
1955        }];
1956
1957        let _ = state.update(Message::CloseFileHistory);
1958
1959        assert!(state.active_tab().file_history_path.is_none());
1960        assert!(state.active_tab().file_history_commits.is_empty());
1961    }
1962
1963    #[test]
1964    fn view_file_blame_sets_path_and_clears_history() {
1965        use crate::message::Message;
1966        let mut state = GitKraft::new();
1967        state.active_tab_mut().repo_path =
1968            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1969        state.active_tab_mut().file_history_path = Some("some/file.rs".to_string());
1970
1971        let _ = state.update(Message::ViewFileBlame("src/lib.rs".to_string()));
1972
1973        assert_eq!(state.active_tab().blame_path.as_deref(), Some("src/lib.rs"));
1974        // Opening blame should close history
1975        assert!(state.active_tab().file_history_path.is_none());
1976    }
1977
1978    #[test]
1979    fn selecting_new_commit_closes_blame_overlay() {
1980        use crate::message::Message;
1981        let mut state = GitKraft::new();
1982        state.active_tab_mut().repo_path =
1983            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1984        // Pre-populate a commit list so SelectCommit can find the commit.
1985        state.active_tab_mut().commits = vec![
1986            gitkraft_core::CommitInfo {
1987                oid: "abc1".into(),
1988                summary: "first".into(),
1989                message: "first".into(),
1990                author_name: "A".into(),
1991                author_email: "a@a.com".into(),
1992                time: Default::default(),
1993                parent_ids: Vec::new(),
1994                refs: Vec::new(),
1995            },
1996            gitkraft_core::CommitInfo {
1997                oid: "abc2".into(),
1998                summary: "second".into(),
1999                message: "second".into(),
2000                author_name: "A".into(),
2001                author_email: "a@a.com".into(),
2002                time: Default::default(),
2003                parent_ids: Vec::new(),
2004                refs: Vec::new(),
2005            },
2006        ];
2007        // Blame is currently open for a file from the first commit.
2008        state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
2009        state.active_tab_mut().blame_lines = vec![gitkraft_core::BlameLine {
2010            line_number: 1,
2011            content: "fn main() {}".into(),
2012            oid: "abc1".into(),
2013            author_name: "A".into(),
2014            time: Default::default(),
2015        }];
2016
2017        // Click a different commit — blame must close automatically.
2018        let _ = state.update(Message::SelectCommit(1));
2019
2020        assert!(
2021            state.active_tab().blame_path.is_none(),
2022            "blame_path must be cleared when a new commit is selected"
2023        );
2024        assert!(
2025            state.active_tab().blame_lines.is_empty(),
2026            "blame_lines must be cleared when a new commit is selected"
2027        );
2028    }
2029
2030    #[test]
2031    fn close_file_blame_clears_state() {
2032        use crate::message::Message;
2033        let mut state = GitKraft::new();
2034        state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
2035
2036        let _ = state.update(Message::CloseFileBlame);
2037
2038        assert!(state.active_tab().blame_path.is_none());
2039        assert!(state.active_tab().blame_lines.is_empty());
2040    }
2041
2042    #[test]
2043    fn delete_file_sets_pending() {
2044        use crate::message::Message;
2045        let mut state = GitKraft::new();
2046
2047        let _ = state.update(Message::DeleteFile("src/old.rs".to_string()));
2048
2049        assert_eq!(
2050            state.active_tab().pending_delete_file.as_deref(),
2051            Some("src/old.rs")
2052        );
2053        assert!(state.active_tab().context_menu.is_none());
2054    }
2055
2056    #[test]
2057    fn cancel_delete_file_clears_pending() {
2058        use crate::message::Message;
2059        let mut state = GitKraft::new();
2060        state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
2061
2062        let _ = state.update(Message::CancelDeleteFile);
2063
2064        assert!(state.active_tab().pending_delete_file.is_none());
2065    }
2066
2067    #[test]
2068    fn confirm_delete_file_no_repo_is_noop() {
2069        use crate::message::Message;
2070        let mut state = GitKraft::new();
2071        state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
2072        // No repo_path → should not set is_loading
2073
2074        let _ = state.update(Message::ConfirmDeleteFile);
2075
2076        assert!(!state.active_tab().is_loading);
2077    }
2078
2079    #[test]
2080    fn shift_arrow_down_extends_file_list_selection_when_files_loaded() {
2081        use crate::message::Message;
2082        let mut state = GitKraft::new();
2083        state.active_tab_mut().repo_path =
2084            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
2085        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
2086        state.active_tab_mut().selected_file_index = Some(0);
2087        state.active_tab_mut().anchor_file_index = Some(0);
2088        // keyboard_modifiers must have SHIFT set for range selection to trigger
2089        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
2090
2091        let _ = state.update(Message::ShiftArrowDown);
2092
2093        assert_eq!(state.active_tab().selected_file_index, Some(1));
2094        // Range should now include both files
2095        assert!(state.active_tab().selected_commit_file_indices.contains(&0));
2096        assert!(state.active_tab().selected_commit_file_indices.contains(&1));
2097    }
2098
2099    #[test]
2100    fn shift_arrow_down_falls_through_to_commit_log_when_no_files() {
2101        use crate::message::Message;
2102        let mut state = GitKraft::new();
2103        state.active_tab_mut().commits = make_test_commits(5);
2104        state.active_tab_mut().selected_commit = Some(1);
2105        state.active_tab_mut().anchor_commit_index = Some(1);
2106        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
2107        // no commit_files
2108
2109        let _ = state.update(Message::ShiftArrowDown);
2110
2111        assert_eq!(state.active_tab().selected_commit, Some(2));
2112        assert!(state.active_tab().selected_commits.contains(&1));
2113        assert!(state.active_tab().selected_commits.contains(&2));
2114    }
2115
2116    #[test]
2117    fn file_system_changed_triggers_full_refresh() {
2118        use crate::message::Message;
2119        let mut state = GitKraft::new();
2120        state.active_tab_mut().repo_path =
2121            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
2122
2123        // FileSystemChanged calls refresh_active_tab() which now sets
2124        // is_loading = true synchronously to prevent concurrent refresh storms.
2125        let _task = state.update(Message::FileSystemChanged);
2126
2127        // is_loading must be set to prevent overlapping refreshes.
2128        assert!(
2129            state.active_tab().is_loading,
2130            "FileSystemChanged must set is_loading to guard against concurrent refreshes"
2131        );
2132        assert!(
2133            state.active_tab().error_message.is_none(),
2134            "FileSystemChanged must not set an error message"
2135        );
2136    }
2137
2138    #[test]
2139    fn refresh_active_tab_skips_when_already_loading() {
2140        // When is_loading is true (e.g. a manual Ctrl+R refresh is in flight),
2141        // refresh_active_tab must return Task::none() to avoid spawning a
2142        // duplicate concurrent refresh.
2143        let mut state = GitKraft::new();
2144        state.active_tab_mut().repo_path =
2145            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
2146        state.active_tab_mut().is_loading = true;
2147
2148        // With is_loading set, refresh_active_tab should be a no-op.
2149        let _task = state.refresh_active_tab();
2150        // The tab must still be loading (unchanged).
2151        assert!(state.active_tab().is_loading);
2152    }
2153
2154    // ── Tab duplication prevention tests ───────────────────────────────────
2155
2156    /// Helper: build a minimal `RepoPayload` (aka `RepoSnapshot`) for a given path.
2157    fn fake_payload(workdir: &str) -> crate::message::RepoPayload {
2158        gitkraft_core::RepoSnapshot {
2159            info: gitkraft_core::RepoInfo {
2160                path: std::path::PathBuf::from(format!("{workdir}/.git")),
2161                workdir: Some(std::path::PathBuf::from(workdir)),
2162                head_branch: Some("main".into()),
2163                is_bare: false,
2164                state: gitkraft_core::RepoState::Clean,
2165            },
2166            branches: Vec::new(),
2167            commits: Vec::new(),
2168            graph_rows: Vec::new(),
2169            unstaged: Vec::new(),
2170            staged: Vec::new(),
2171            stashes: Vec::new(),
2172            remotes: Vec::new(),
2173        }
2174    }
2175
2176    /// Helper: set up a tab as if a repo was fully loaded.
2177    fn setup_loaded_tab(tab: &mut RepoTab, path: &str) {
2178        tab.repo_path = Some(std::path::PathBuf::from(path));
2179        tab.repo_info = Some(gitkraft_core::RepoInfo {
2180            path: std::path::PathBuf::from(format!("{path}/.git")),
2181            workdir: Some(std::path::PathBuf::from(path)),
2182            head_branch: Some("main".into()),
2183            is_bare: false,
2184            state: gitkraft_core::RepoState::Clean,
2185        });
2186    }
2187
2188    #[test]
2189    fn open_repo_creates_new_tab_when_repo_already_open() {
2190        use crate::message::Message;
2191        let mut state = GitKraft::new();
2192        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2193
2194        assert_eq!(state.tabs.len(), 1);
2195        assert_eq!(state.active_tab, 0);
2196
2197        // Clicking "Open" should create a new tab when the current has a repo.
2198        let _task = state.update(Message::OpenRepo);
2199
2200        assert_eq!(state.tabs.len(), 2);
2201        assert_eq!(state.active_tab, 1);
2202        // The new tab should be loading (folder picker opening).
2203        assert!(state.tabs[1].is_loading);
2204        // The original tab should be untouched.
2205        assert_eq!(
2206            state.tabs[0].repo_path.as_deref(),
2207            Some(std::path::Path::new("/home/user/repo-a"))
2208        );
2209    }
2210
2211    #[test]
2212    fn open_repo_reuses_empty_tab() {
2213        use crate::message::Message;
2214        let mut state = GitKraft::new();
2215        // Active tab is empty (no repo loaded).
2216        assert!(!state.active_tab().has_repo());
2217
2218        let _task = state.update(Message::OpenRepo);
2219
2220        // Should NOT create a new tab when the active tab is empty.
2221        assert_eq!(state.tabs.len(), 1);
2222        assert_eq!(state.active_tab, 0);
2223        assert!(state.tabs[0].is_loading);
2224    }
2225
2226    #[test]
2227    fn repo_selected_deduplicates_already_open_repo() {
2228        use crate::message::Message;
2229        let mut state = GitKraft::new();
2230        // Tab 0: fully loaded repo-a
2231        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2232        // Tab 1: empty (simulates the new tab created by OpenRepo)
2233        state.tabs.push(RepoTab::new_empty());
2234        state.active_tab = 1;
2235
2236        // User selects a folder that matches the already-open repo.
2237        let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2238            "/home/user/repo-a",
2239        ))));
2240
2241        // Should switch to the existing tab and remove the empty one.
2242        assert_eq!(state.tabs.len(), 1);
2243        assert_eq!(state.active_tab, 0);
2244        assert_eq!(
2245            state.tabs[0].repo_path.as_deref(),
2246            Some(std::path::Path::new("/home/user/repo-a"))
2247        );
2248    }
2249
2250    #[test]
2251    fn repo_selected_opens_new_repo_in_empty_tab() {
2252        use crate::message::Message;
2253        let mut state = GitKraft::new();
2254        // Tab 0: fully loaded repo-a
2255        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2256        // Tab 1: empty (simulates the new tab created by OpenRepo)
2257        state.tabs.push(RepoTab::new_empty());
2258        state.active_tab = 1;
2259
2260        // User selects a DIFFERENT repo.
2261        let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2262            "/home/user/repo-b",
2263        ))));
2264
2265        // Should keep both tabs; the empty tab is now loading repo-b.
2266        assert_eq!(state.tabs.len(), 2);
2267        assert_eq!(state.active_tab, 1);
2268        assert!(state.tabs[1]
2269            .status_message
2270            .as_deref()
2271            .unwrap_or("")
2272            .contains("repo-b"));
2273    }
2274
2275    #[test]
2276    fn repo_selected_cancel_removes_empty_tab() {
2277        use crate::message::Message;
2278        let mut state = GitKraft::new();
2279        // Tab 0: fully loaded repo-a
2280        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2281        // Tab 1: empty (created by OpenRepo, waiting for folder picker)
2282        state.tabs.push(RepoTab::new_empty());
2283        state.active_tab = 1;
2284
2285        // User cancels the folder picker.
2286        let _task = state.update(Message::RepoSelected(None));
2287
2288        // The empty tab should be removed; switch back to tab 0.
2289        assert_eq!(state.tabs.len(), 1);
2290        assert_eq!(state.active_tab, 0);
2291        assert_eq!(
2292            state.tabs[0].repo_path.as_deref(),
2293            Some(std::path::Path::new("/home/user/repo-a"))
2294        );
2295    }
2296
2297    #[test]
2298    fn repo_selected_cancel_keeps_tab_if_only_one() {
2299        use crate::message::Message;
2300        let mut state = GitKraft::new();
2301        // Single empty tab — shouldn't be removed on cancel.
2302        assert_eq!(state.tabs.len(), 1);
2303        assert!(!state.active_tab().has_repo());
2304
2305        let _task = state.update(Message::RepoSelected(None));
2306
2307        assert_eq!(state.tabs.len(), 1);
2308        assert!(!state.active_tab().is_loading);
2309    }
2310
2311    #[test]
2312    fn open_recent_repo_deduplicates() {
2313        use crate::message::Message;
2314        let mut state = GitKraft::new();
2315        // Tab 0: fully loaded repo-a
2316        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2317        // Tab 1: empty tab (simulates user clicking "+")
2318        state.tabs.push(RepoTab::new_empty());
2319        state.active_tab = 1;
2320
2321        // Opening a recent repo that's already open should switch to it
2322        // AND remove the empty tab the user was on.
2323        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2324            "/home/user/repo-a",
2325        )));
2326
2327        assert_eq!(state.tabs.len(), 1, "empty tab must be removed");
2328        assert_eq!(state.active_tab, 0);
2329        assert_eq!(
2330            state.active_tab().repo_path.as_deref(),
2331            Some(std::path::Path::new("/home/user/repo-a")),
2332        );
2333    }
2334
2335    #[test]
2336    fn open_recent_repo_creates_new_tab_when_current_has_repo() {
2337        use crate::message::Message;
2338        let mut state = GitKraft::new();
2339        // Tab 0: fully loaded repo-a
2340        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2341
2342        // Opening a different recent repo should create a new tab.
2343        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2344            "/home/user/repo-b",
2345        )));
2346
2347        assert_eq!(state.tabs.len(), 2);
2348        assert_eq!(state.active_tab, 1);
2349        assert!(state.tabs[1].is_loading);
2350    }
2351
2352    #[test]
2353    fn open_recent_repo_uses_empty_tab() {
2354        use crate::message::Message;
2355        let mut state = GitKraft::new();
2356        // Active tab is empty.
2357        assert!(!state.active_tab().has_repo());
2358
2359        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2360            "/home/user/repo-b",
2361        )));
2362
2363        // Should NOT create a new tab.
2364        assert_eq!(state.tabs.len(), 1);
2365        assert_eq!(state.active_tab, 0);
2366        assert!(state.tabs[0].is_loading);
2367    }
2368
2369    #[test]
2370    fn open_recent_repo_dedup_removes_empty_tab_with_multiple_loaded() {
2371        // Reproduces: user has [A, B*, C], clicks "+" to get [A, B, C, Empty*],
2372        // then clicks recent repo B → should end up with [A, B*, C] (no empty tab).
2373        use crate::message::Message;
2374        let mut state = GitKraft::new();
2375        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2376        state.tabs.push(RepoTab::new_empty());
2377        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2378        state.tabs.push(RepoTab::new_empty());
2379        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2380        // Simulate clicking "+" to add an empty tab
2381        state.tabs.push(RepoTab::new_empty());
2382        state.active_tab = 3;
2383
2384        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2385            "/home/user/repo-b",
2386        )));
2387
2388        assert_eq!(state.tabs.len(), 3, "empty tab must be cleaned up");
2389        assert_eq!(state.active_tab, 1);
2390        assert_eq!(
2391            state.active_tab().repo_path.as_deref(),
2392            Some(std::path::Path::new("/home/user/repo-b")),
2393        );
2394    }
2395
2396    // ── Refresh race-condition tests ──────────────────────────────────────
2397
2398    #[test]
2399    fn repo_refreshed_targets_correct_tab_after_tab_switch() {
2400        use crate::message::Message;
2401        let mut state = GitKraft::new();
2402        // Tab 0: repo-a fully loaded
2403        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2404        // Tab 1: empty tab (user clicked "+")
2405        state.tabs.push(RepoTab::new_empty());
2406        state.active_tab = 1; // user switched to new empty tab
2407
2408        // Simulate: a RepoRefreshed result arrives for repo-a while tab 1 is active.
2409        let payload = fake_payload("/home/user/repo-a");
2410        let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2411
2412        // The payload must land in tab 0 (which owns repo-a), NOT tab 1.
2413        assert!(
2414            state.tabs[0].repo_info.is_some(),
2415            "tab 0 should still have repo info after refresh"
2416        );
2417        assert_eq!(
2418            state.tabs[0].current_branch.as_deref(),
2419            Some("main"),
2420            "tab 0 should have updated branch from payload"
2421        );
2422        // Tab 1 must remain empty.
2423        assert!(
2424            state.tabs[1].repo_info.is_none(),
2425            "tab 1 (empty) must NOT receive the refresh payload"
2426        );
2427        assert!(
2428            state.tabs[1].repo_path.is_none(),
2429            "tab 1 should still have no repo path"
2430        );
2431    }
2432
2433    #[test]
2434    fn repo_refreshed_targets_active_tab_for_new_open() {
2435        use crate::message::Message;
2436        let mut state = GitKraft::new();
2437        // Active tab is empty — user just opened a brand-new repo.
2438        // (RepoSelected set the status but the tab doesn't have repo_path yet
2439        // matching the payload, so it falls back to active tab.)
2440        assert_eq!(state.tabs.len(), 1);
2441        assert!(!state.active_tab().has_repo());
2442
2443        let payload = fake_payload("/home/user/new-repo");
2444        let _task = state.update(Message::RepoOpened(Ok(payload)));
2445
2446        // Should have applied to the active tab (the only tab).
2447        assert_eq!(
2448            state.tabs[0].repo_path.as_deref(),
2449            Some(std::path::Path::new("/home/user/new-repo"))
2450        );
2451        assert!(state.tabs[0].repo_info.is_some());
2452    }
2453
2454    #[test]
2455    fn repo_refreshed_does_not_duplicate_into_new_tab() {
2456        use crate::message::Message;
2457        let mut state = GitKraft::new();
2458        // Tab 0: repo-a loaded
2459        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2460
2461        // User clicks "+" creating an empty tab 1, then switches to it.
2462        let _task = state.update(Message::NewTab);
2463        assert_eq!(state.tabs.len(), 2);
2464        assert_eq!(state.active_tab, 1);
2465
2466        // A refresh result for repo-a arrives (was triggered before the tab switch).
2467        let payload = fake_payload("/home/user/repo-a");
2468        let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2469
2470        // Tab 1 must remain empty — the payload should go to tab 0.
2471        assert!(
2472            state.tabs[1].repo_path.is_none(),
2473            "new empty tab must not receive repo-a refresh"
2474        );
2475        assert!(
2476            state.tabs[1].repo_info.is_none(),
2477            "new empty tab must not have repo_info"
2478        );
2479        // Tab 0 must have the refreshed data.
2480        assert_eq!(
2481            state.tabs[0].repo_path.as_deref(),
2482            Some(std::path::Path::new("/home/user/repo-a"))
2483        );
2484    }
2485
2486    #[test]
2487    fn git_operation_result_targets_correct_tab() {
2488        use crate::message::Message;
2489        let mut state = GitKraft::new();
2490        // Tab 0: repo-a
2491        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2492        // Tab 1: repo-b
2493        state.tabs.push(RepoTab::new_empty());
2494        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2495        state.active_tab = 1;
2496
2497        // A git operation result arrives for repo-a (e.g. push completed)
2498        // while user is on tab 1.
2499        let payload = fake_payload("/home/user/repo-a");
2500        let _task = state.update(Message::GitOperationResult(Ok(payload)));
2501
2502        // Payload should land in tab 0 (repo-a), not tab 1.
2503        assert_eq!(state.tabs[0].current_branch.as_deref(), Some("main"));
2504        // Tab 1's data should remain unchanged (still repo-b).
2505        assert_eq!(
2506            state.tabs[1].repo_path.as_deref(),
2507            Some(std::path::Path::new("/home/user/repo-b"))
2508        );
2509    }
2510
2511    #[test]
2512    fn multiple_new_tabs_dont_get_polluted_by_refresh() {
2513        use crate::message::Message;
2514        let mut state = GitKraft::new();
2515        // Tab 0: repo-a loaded
2516        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2517
2518        // User creates multiple new tabs.
2519        let _task = state.update(Message::NewTab);
2520        let _task = state.update(Message::NewTab);
2521        assert_eq!(state.tabs.len(), 3);
2522        assert_eq!(state.active_tab, 2);
2523
2524        // Refresh arrives for repo-a.
2525        let payload = fake_payload("/home/user/repo-a");
2526        let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2527
2528        // Only tab 0 should be affected.
2529        assert!(state.tabs[0].repo_info.is_some());
2530        assert!(state.tabs[1].repo_info.is_none());
2531        assert!(state.tabs[2].repo_info.is_none());
2532        assert!(state.tabs[1].repo_path.is_none());
2533        assert!(state.tabs[2].repo_path.is_none());
2534    }
2535
2536    #[test]
2537    fn repo_selected_dedup_adjusts_index_when_existing_is_after_active() {
2538        use crate::message::Message;
2539        let mut state = GitKraft::new();
2540        // Tab 0: empty (newly created by OpenRepo)
2541        // Tab 1: repo-a loaded
2542        state.tabs.push(RepoTab::new_empty());
2543        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
2544        state.active_tab = 0;
2545
2546        // User selects the same folder as repo-a.
2547        let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2548            "/home/user/repo-a",
2549        ))));
2550
2551        // Empty tab 0 should be removed; we should now be on tab 0 (formerly tab 1).
2552        assert_eq!(state.tabs.len(), 1);
2553        assert_eq!(state.active_tab, 0);
2554        assert_eq!(
2555            state.tabs[0].repo_path.as_deref(),
2556            Some(std::path::Path::new("/home/user/repo-a"))
2557        );
2558    }
2559
2560    // ── Multi-selection preservation across refresh ───────────────────────
2561
2562    #[test]
2563    fn apply_payload_preserves_multi_selection_by_oid() {
2564        let mut tab = RepoTab::new_empty();
2565        tab.commits = make_test_commits(5);
2566        // Simulate user selecting commits 1..=3 with Shift+click.
2567        tab.anchor_commit_index = Some(1);
2568        tab.selected_commits = vec![1, 2, 3];
2569        tab.selected_commit = Some(3);
2570        tab.selected_commit_oid = Some(tab.commits[3].oid.clone());
2571
2572        // Build a payload with the same commits (simulating a background refresh).
2573        let mut payload = fake_payload("/tmp/repo");
2574        payload.commits = make_test_commits(5);
2575
2576        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2577
2578        // The multi-selection must survive the refresh.
2579        assert_eq!(tab.anchor_commit_index, Some(1));
2580        assert_eq!(tab.selected_commits, vec![1, 2, 3]);
2581        assert_eq!(tab.selected_commit, Some(3));
2582    }
2583
2584    #[test]
2585    fn apply_payload_preserves_anchor_even_without_range() {
2586        let mut tab = RepoTab::new_empty();
2587        tab.commits = make_test_commits(5);
2588        tab.anchor_commit_index = Some(2);
2589        tab.selected_commit = Some(2);
2590        tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2591        // No range selection — just a single click with anchor set.
2592
2593        let mut payload = fake_payload("/tmp/repo");
2594        payload.commits = make_test_commits(5);
2595
2596        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2597
2598        assert_eq!(tab.anchor_commit_index, Some(2));
2599        assert_eq!(tab.selected_commit, Some(2));
2600    }
2601
2602    #[test]
2603    fn apply_payload_clears_selection_when_commits_disappear() {
2604        let mut tab = RepoTab::new_empty();
2605        tab.commits = make_test_commits(5);
2606        tab.anchor_commit_index = Some(2);
2607        tab.selected_commits = vec![2, 3, 4];
2608        tab.selected_commit = Some(4);
2609        tab.selected_commit_oid = Some(tab.commits[4].oid.clone());
2610
2611        // Payload has completely different commits (e.g. force-push).
2612        let mut payload = fake_payload("/tmp/repo");
2613        payload.commits = (0..3)
2614            .map(|i| gitkraft_core::CommitInfo {
2615                oid: format!("new_oid_{i}"),
2616                summary: format!("new commit {i}"),
2617                message: String::new(),
2618                author_name: "Author".into(),
2619                author_email: "a@b.c".into(),
2620                time: Default::default(),
2621                parent_ids: Vec::new(),
2622                refs: Vec::new(),
2623            })
2624            .collect();
2625
2626        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2627
2628        // All old OIDs are gone — selection should be cleared.
2629        assert!(tab.selected_commits.is_empty());
2630        assert!(tab.anchor_commit_index.is_none());
2631        assert!(tab.selected_commit.is_none());
2632    }
2633
2634    // ── File multi-selection preservation across refresh ─────────────────
2635
2636    #[test]
2637    fn apply_payload_preserves_file_selection_when_commit_survives() {
2638        let mut tab = RepoTab::new_empty();
2639        tab.commits = make_test_commits(3);
2640        tab.selected_commit = Some(1);
2641        tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2642        tab.commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
2643
2644        // Simulate user Shift+clicking files 0..=2.
2645        tab.anchor_file_index = Some(0);
2646        tab.selected_commit_file_indices = vec![0, 1, 2];
2647        tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2648            old_file: String::new(),
2649            new_file: "a.rs".into(),
2650            status: gitkraft_core::FileStatus::Modified,
2651            hunks: vec![],
2652        }];
2653
2654        // Background refresh with the same commits.
2655        let mut payload = fake_payload("/tmp/repo");
2656        payload.commits = make_test_commits(3);
2657
2658        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2659
2660        // File selection must survive.
2661        assert_eq!(tab.anchor_file_index, Some(0));
2662        assert_eq!(tab.selected_commit_file_indices, vec![0, 1, 2]);
2663        assert!(
2664            !tab.multi_file_diffs.is_empty(),
2665            "multi_file_diffs must survive when commit is preserved"
2666        );
2667    }
2668
2669    #[test]
2670    fn apply_payload_clears_file_selection_when_commit_disappears() {
2671        let mut tab = RepoTab::new_empty();
2672        tab.commits = make_test_commits(3);
2673        tab.selected_commit = Some(1);
2674        tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2675        tab.commit_files = make_commit_files(&["a.rs", "b.rs"]);
2676        tab.anchor_file_index = Some(0);
2677        tab.selected_commit_file_indices = vec![0, 1];
2678        tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2679            old_file: String::new(),
2680            new_file: "a.rs".into(),
2681            status: gitkraft_core::FileStatus::Modified,
2682            hunks: vec![],
2683        }];
2684
2685        // Payload with completely different commits (force-push).
2686        let mut payload = fake_payload("/tmp/repo");
2687        payload.commits = (0..2)
2688            .map(|i| gitkraft_core::CommitInfo {
2689                oid: format!("new_{i}"),
2690                summary: String::new(),
2691                message: String::new(),
2692                author_name: String::new(),
2693                author_email: String::new(),
2694                time: Default::default(),
2695                parent_ids: Vec::new(),
2696                refs: Vec::new(),
2697            })
2698            .collect();
2699
2700        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2701
2702        // Commit is gone → file selection must be cleared.
2703        assert!(tab.anchor_file_index.is_none());
2704        assert!(tab.selected_commit_file_indices.is_empty());
2705        assert!(tab.multi_file_diffs.is_empty());
2706        assert!(tab.commit_files.is_empty());
2707    }
2708
2709    #[test]
2710    fn apply_payload_preserves_commit_range_diffs_when_commit_survives() {
2711        let mut tab = RepoTab::new_empty();
2712        tab.commits = make_test_commits(5);
2713        tab.selected_commit = Some(2);
2714        tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2715        tab.commit_range_diffs = vec![gitkraft_core::DiffInfo {
2716            old_file: String::new(),
2717            new_file: "x.rs".into(),
2718            status: gitkraft_core::FileStatus::Modified,
2719            hunks: vec![],
2720        }];
2721
2722        let mut payload = fake_payload("/tmp/repo");
2723        payload.commits = make_test_commits(5);
2724
2725        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2726
2727        assert!(
2728            !tab.commit_range_diffs.is_empty(),
2729            "commit_range_diffs must survive when commit is preserved"
2730        );
2731    }
2732
2733    #[test]
2734    fn apply_payload_preserves_commit_message() {
2735        // Regression test: the git watcher triggers refreshes while the user
2736        // is typing a commit message.  apply_payload must NOT clear it.
2737        let mut tab = RepoTab::new_empty();
2738        tab.repo_path = Some(std::path::PathBuf::from("/tmp/repo"));
2739        tab.commit_message = "WIP: fixing the bug".to_string();
2740
2741        let payload = fake_payload("/tmp/repo");
2742        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2743
2744        assert_eq!(
2745            tab.commit_message, "WIP: fixing the bug",
2746            "commit_message must survive background repo refreshes"
2747        );
2748    }
2749
2750    // ── PreviewFile / OpenFiles message tests ─────────────────────────────
2751
2752    #[test]
2753    fn preview_file_loaded_sets_preview_editor() {
2754        use crate::message::Message;
2755        let mut state = GitKraft::new();
2756        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2757
2758        let content = "line 1\nline 2\nline 3".to_string();
2759        let _ = state.update(Message::FilePreviewLoaded(Ok((
2760            "test.rs".to_string(),
2761            content,
2762        ))));
2763
2764        assert!(state.preview_editor.is_some());
2765        assert_eq!(state.preview_path.as_deref(), Some("test.rs"));
2766        // The diff view should be cleared so the preview takes over.
2767        assert!(state.active_tab().selected_diff.is_none());
2768    }
2769
2770    #[test]
2771    fn preview_file_loaded_error_sets_error_message() {
2772        use crate::message::Message;
2773        let mut state = GitKraft::new();
2774        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2775
2776        let _ = state.update(Message::FilePreviewLoaded(Err("No such file".to_string())));
2777
2778        assert!(state.active_tab().error_message.is_some());
2779        assert!(state
2780            .active_tab()
2781            .error_message
2782            .as_ref()
2783            .unwrap()
2784            .contains("No such file"));
2785    }
2786
2787    #[test]
2788    fn close_preview_clears_editor_state() {
2789        use crate::message::Message;
2790        let mut state = GitKraft::new();
2791        state.preview_editor = Some(iced::widget::text_editor::Content::with_text("hello"));
2792        state.preview_path = Some("/test/file.txt".to_string());
2793
2794        let _ = state.update(Message::ClosePreview);
2795
2796        assert!(state.preview_editor.is_none());
2797        assert!(state.preview_path.is_none());
2798    }
2799
2800    // ── CloseRepo ─────────────────────────────────────────────────────────────
2801
2802    #[test]
2803    fn close_repo_single_tab_shows_welcome_screen() {
2804        // When only one tab is open, CloseRepo should empty the tab (show
2805        // welcome screen) rather than removing it — there must always be at
2806        // least one tab.
2807        use crate::message::Message;
2808        let mut state = GitKraft::new();
2809        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2810        assert_eq!(state.tabs.len(), 1);
2811
2812        let _ = state.update(Message::CloseRepo);
2813
2814        assert_eq!(state.tabs.len(), 1, "last tab must not be removed");
2815        assert_eq!(state.active_tab, 0);
2816        assert!(!state.active_tab().has_repo(), "tab should be empty");
2817    }
2818
2819    #[test]
2820    fn close_repo_with_multiple_tabs_removes_current_tab() {
2821        // The core of the reported bug: when the currently selected repo is
2822        // closed and there are other tabs open, the CURRENT tab should be
2823        // removed and the user should land on the remaining tab — NOT have the
2824        // current tab emptied in place (which left the other repo displaced).
2825        //
2826        // Before fix: CloseRepo replaced active tab with empty, keeping it
2827        //   selected, so the user saw a blank welcome screen while the other
2828        //   repo was still in the tab bar but not visible → perceived as
2829        //   "removes the other one and keeps the current one selected".
2830        // After fix: active tab is removed, user lands on the other repo.
2831        use crate::message::Message;
2832        let mut state = GitKraft::new();
2833        // Tab 0: repo-a (will be the active/selected tab)
2834        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2835        // Tab 1: repo-b
2836        state.tabs.push(RepoTab::new_empty());
2837        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2838        // repo-a (tab 0) is the currently selected tab
2839        state.active_tab = 0;
2840
2841        let _ = state.update(Message::CloseRepo);
2842
2843        // The current tab (repo-a) must be gone; repo-b must be the only
2844        // remaining tab and must be active.
2845        assert_eq!(state.tabs.len(), 1, "current tab must be removed");
2846        assert_eq!(state.active_tab, 0);
2847        assert_eq!(
2848            state.active_tab().repo_path.as_deref(),
2849            Some(std::path::Path::new("/home/user/repo-b")),
2850            "repo-b should be the surviving tab"
2851        );
2852    }
2853
2854    #[test]
2855    fn close_repo_second_tab_active_removes_it_and_keeps_first() {
2856        // Same scenario but with the SECOND tab as the active (selected) one —
2857        // the first tab must survive and become the new active tab.
2858        use crate::message::Message;
2859        let mut state = GitKraft::new();
2860        // Tab 0: repo-a
2861        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2862        // Tab 1: repo-b (active)
2863        state.tabs.push(RepoTab::new_empty());
2864        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2865        state.active_tab = 1; // repo-b is the currently selected tab
2866
2867        let _ = state.update(Message::CloseRepo);
2868
2869        assert_eq!(state.tabs.len(), 1);
2870        assert_eq!(state.active_tab, 0);
2871        assert_eq!(
2872            state.active_tab().repo_path.as_deref(),
2873            Some(std::path::Path::new("/home/user/repo-a")),
2874            "repo-a should be the surviving tab"
2875        );
2876    }
2877
2878    // ── CloseTab ──────────────────────────────────────────────────────────
2879
2880    #[test]
2881    fn close_tab_active_tab_first_position() {
2882        // active_tab=0, close tab 0 → tab 1 (repo-b) becomes active at index 0.
2883        use crate::message::Message;
2884        let mut state = GitKraft::new();
2885        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2886        state.tabs.push(RepoTab::new_empty());
2887        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2888        state.active_tab = 0;
2889
2890        let _ = state.update(Message::CloseTab(0));
2891
2892        assert_eq!(state.tabs.len(), 1);
2893        assert_eq!(state.active_tab, 0);
2894        assert_eq!(
2895            state.active_tab().repo_path.as_deref(),
2896            Some(std::path::Path::new("/home/user/repo-b"))
2897        );
2898    }
2899
2900    #[test]
2901    fn close_tab_active_tab_second_position() {
2902        // active_tab=1, close tab 1 → tab 0 (repo-a) becomes active.
2903        use crate::message::Message;
2904        let mut state = GitKraft::new();
2905        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2906        state.tabs.push(RepoTab::new_empty());
2907        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2908        state.active_tab = 1;
2909
2910        let _ = state.update(Message::CloseTab(1));
2911
2912        assert_eq!(state.tabs.len(), 1);
2913        assert_eq!(state.active_tab, 0);
2914        assert_eq!(
2915            state.active_tab().repo_path.as_deref(),
2916            Some(std::path::Path::new("/home/user/repo-a"))
2917        );
2918    }
2919
2920    #[test]
2921    fn close_tab_non_active_tab_before_active() {
2922        // active_tab=1, close tab 0 → repo-b stays active but shifts to index 0.
2923        use crate::message::Message;
2924        let mut state = GitKraft::new();
2925        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2926        state.tabs.push(RepoTab::new_empty());
2927        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2928        state.active_tab = 1;
2929
2930        let _ = state.update(Message::CloseTab(0));
2931
2932        assert_eq!(state.tabs.len(), 1);
2933        assert_eq!(state.active_tab, 0, "active_tab must shift down by 1");
2934        assert_eq!(
2935            state.active_tab().repo_path.as_deref(),
2936            Some(std::path::Path::new("/home/user/repo-b")),
2937            "repo-b must remain active"
2938        );
2939    }
2940
2941    #[test]
2942    fn close_tab_non_active_tab_after_active() {
2943        // active_tab=0, close tab 1 → repo-a stays active at index 0.
2944        use crate::message::Message;
2945        let mut state = GitKraft::new();
2946        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2947        state.tabs.push(RepoTab::new_empty());
2948        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2949        state.active_tab = 0;
2950
2951        let _ = state.update(Message::CloseTab(1));
2952
2953        assert_eq!(state.tabs.len(), 1);
2954        assert_eq!(state.active_tab, 0, "active_tab must remain unchanged");
2955        assert_eq!(
2956            state.active_tab().repo_path.as_deref(),
2957            Some(std::path::Path::new("/home/user/repo-a")),
2958            "repo-a must remain active"
2959        );
2960    }
2961
2962    #[test]
2963    fn close_tab_last_remaining_tab_is_noop() {
2964        // Can't close the last tab — CloseTab must be a no-op.
2965        use crate::message::Message;
2966        let mut state = GitKraft::new();
2967        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2968        assert_eq!(state.tabs.len(), 1);
2969
2970        let _ = state.update(Message::CloseTab(0));
2971
2972        assert_eq!(state.tabs.len(), 1, "last tab must not be removed");
2973        assert!(
2974            state.active_tab().has_repo(),
2975            "tab content must be unchanged"
2976        );
2977    }
2978
2979    #[test]
2980    fn close_tab_out_of_bounds_index_is_noop() {
2981        // Closing a tab index that doesn't exist must be silently ignored.
2982        use crate::message::Message;
2983        let mut state = GitKraft::new();
2984        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2985        state.tabs.push(RepoTab::new_empty());
2986        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2987        assert_eq!(state.tabs.len(), 2);
2988
2989        let _ = state.update(Message::CloseTab(99));
2990
2991        assert_eq!(state.tabs.len(), 2, "no tab should be removed");
2992    }
2993
2994    // ── CloseTab — three-tab scenarios ────────────────────────────────────
2995
2996    #[test]
2997    fn close_tab_three_tabs_close_first_active() {
2998        // [repo-a*(0), repo-b(1), repo-c(2)] close 0
2999        // → [repo-b(0), repo-c(1)], active stays 0 (repo-b).
3000        use crate::message::Message;
3001        let mut state = GitKraft::new();
3002        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3003        state.tabs.push(RepoTab::new_empty());
3004        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3005        state.tabs.push(RepoTab::new_empty());
3006        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3007        state.active_tab = 0;
3008
3009        let _ = state.update(Message::CloseTab(0));
3010
3011        assert_eq!(state.tabs.len(), 2);
3012        assert_eq!(state.active_tab, 0);
3013        assert_eq!(
3014            state.active_tab().repo_path.as_deref(),
3015            Some(std::path::Path::new("/home/user/repo-b"))
3016        );
3017    }
3018
3019    #[test]
3020    fn close_tab_three_tabs_close_active_middle() {
3021        // [repo-a(0), repo-b*(1), repo-c(2)] close 1
3022        // → [repo-a(0), repo-c(1)], active_tab stays 1 (repo-c slides in).
3023        use crate::message::Message;
3024        let mut state = GitKraft::new();
3025        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3026        state.tabs.push(RepoTab::new_empty());
3027        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3028        state.tabs.push(RepoTab::new_empty());
3029        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3030        state.active_tab = 1;
3031
3032        let _ = state.update(Message::CloseTab(1));
3033
3034        assert_eq!(state.tabs.len(), 2);
3035        assert_eq!(
3036            state.active_tab, 1,
3037            "active_tab must stay at 1 (repo-c slides in)"
3038        );
3039        assert_eq!(
3040            state.active_tab().repo_path.as_deref(),
3041            Some(std::path::Path::new("/home/user/repo-c"))
3042        );
3043    }
3044
3045    #[test]
3046    fn close_tab_three_tabs_close_active_last() {
3047        // [repo-a(0), repo-b(1), repo-c*(2)] close 2
3048        // → [repo-a(0), repo-b(1)], active_tab becomes 1 (clamped).
3049        use crate::message::Message;
3050        let mut state = GitKraft::new();
3051        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3052        state.tabs.push(RepoTab::new_empty());
3053        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3054        state.tabs.push(RepoTab::new_empty());
3055        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3056        state.active_tab = 2;
3057
3058        let _ = state.update(Message::CloseTab(2));
3059
3060        assert_eq!(state.tabs.len(), 2);
3061        assert_eq!(
3062            state.active_tab, 1,
3063            "active_tab must be clamped to last index"
3064        );
3065        assert_eq!(
3066            state.active_tab().repo_path.as_deref(),
3067            Some(std::path::Path::new("/home/user/repo-b"))
3068        );
3069    }
3070
3071    #[test]
3072    fn close_tab_three_tabs_close_first_when_middle_active() {
3073        // [repo-a(0), repo-b*(1), repo-c(2)] close 0
3074        // → [repo-b(0), repo-c(1)], active_tab shifts from 1 to 0.
3075        use crate::message::Message;
3076        let mut state = GitKraft::new();
3077        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3078        state.tabs.push(RepoTab::new_empty());
3079        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3080        state.tabs.push(RepoTab::new_empty());
3081        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3082        state.active_tab = 1;
3083
3084        let _ = state.update(Message::CloseTab(0));
3085
3086        assert_eq!(state.tabs.len(), 2);
3087        assert_eq!(
3088            state.active_tab, 0,
3089            "active_tab must decrement when a tab before it is removed"
3090        );
3091        assert_eq!(
3092            state.active_tab().repo_path.as_deref(),
3093            Some(std::path::Path::new("/home/user/repo-b")),
3094            "repo-b must remain active"
3095        );
3096    }
3097
3098    #[test]
3099    fn close_tab_three_tabs_close_last_when_middle_active() {
3100        // [repo-a(0), repo-b*(1), repo-c(2)] close 2
3101        // → [repo-a(0), repo-b(1)], active_tab stays at 1.
3102        use crate::message::Message;
3103        let mut state = GitKraft::new();
3104        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3105        state.tabs.push(RepoTab::new_empty());
3106        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3107        state.tabs.push(RepoTab::new_empty());
3108        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3109        state.active_tab = 1;
3110
3111        let _ = state.update(Message::CloseTab(2));
3112
3113        assert_eq!(state.tabs.len(), 2);
3114        assert_eq!(
3115            state.active_tab, 1,
3116            "active_tab must not change when a tab after it is removed"
3117        );
3118        assert_eq!(
3119            state.active_tab().repo_path.as_deref(),
3120            Some(std::path::Path::new("/home/user/repo-b")),
3121            "repo-b must remain active"
3122        );
3123    }
3124
3125    // ── CloseRepo — three-tab scenarios ───────────────────────────────────
3126
3127    #[test]
3128    fn close_repo_three_tabs_active_in_middle() {
3129        // [repo-a(0), repo-b*(1), repo-c(2)] close active
3130        // → [repo-a(0), repo-c(1)], active_tab stays 1 (repo-c slides in).
3131        use crate::message::Message;
3132        let mut state = GitKraft::new();
3133        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3134        state.tabs.push(RepoTab::new_empty());
3135        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3136        state.tabs.push(RepoTab::new_empty());
3137        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3138        state.active_tab = 1;
3139
3140        let _ = state.update(Message::CloseRepo);
3141
3142        assert_eq!(state.tabs.len(), 2);
3143        assert_eq!(state.active_tab, 1);
3144        assert_eq!(
3145            state.active_tab().repo_path.as_deref(),
3146            Some(std::path::Path::new("/home/user/repo-c"))
3147        );
3148    }
3149
3150    #[test]
3151    fn close_repo_three_tabs_active_at_end() {
3152        // [repo-a(0), repo-b(1), repo-c*(2)] close active
3153        // → [repo-a(0), repo-b(1)], active_tab clamped to 1.
3154        use crate::message::Message;
3155        let mut state = GitKraft::new();
3156        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3157        state.tabs.push(RepoTab::new_empty());
3158        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3159        state.tabs.push(RepoTab::new_empty());
3160        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3161        state.active_tab = 2;
3162
3163        let _ = state.update(Message::CloseRepo);
3164
3165        assert_eq!(state.tabs.len(), 2);
3166        assert_eq!(state.active_tab, 1, "active_tab clamped to last index");
3167        assert_eq!(
3168            state.active_tab().repo_path.as_deref(),
3169            Some(std::path::Path::new("/home/user/repo-b"))
3170        );
3171    }
3172
3173    // ── NewTab ────────────────────────────────────────────────────────────
3174
3175    #[test]
3176    fn new_tab_appends_empty_tab_and_makes_it_active() {
3177        use crate::message::Message;
3178        let mut state = GitKraft::new();
3179        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3180        assert_eq!(state.tabs.len(), 1);
3181
3182        let _ = state.update(Message::NewTab);
3183
3184        assert_eq!(state.tabs.len(), 2, "a new empty tab must be appended");
3185        assert_eq!(state.active_tab, 1, "new tab must become active");
3186        assert!(!state.active_tab().has_repo(), "new tab must be empty");
3187        // The original repo tab must be unaffected.
3188        assert!(state.tabs[0].has_repo());
3189    }
3190
3191    #[test]
3192    fn new_tab_with_multiple_existing_tabs() {
3193        use crate::message::Message;
3194        let mut state = GitKraft::new();
3195        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3196        state.tabs.push(RepoTab::new_empty());
3197        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3198        state.active_tab = 0;
3199
3200        let _ = state.update(Message::NewTab);
3201
3202        assert_eq!(state.tabs.len(), 3);
3203        assert_eq!(state.active_tab, 2);
3204        assert!(!state.active_tab().has_repo());
3205    }
3206
3207    // ── SwitchTab ─────────────────────────────────────────────────────────
3208
3209    #[test]
3210    fn switch_tab_to_valid_index() {
3211        use crate::message::Message;
3212        let mut state = GitKraft::new();
3213        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3214        state.tabs.push(RepoTab::new_empty());
3215        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3216        state.active_tab = 0;
3217
3218        let _ = state.update(Message::SwitchTab(1));
3219
3220        assert_eq!(state.active_tab, 1);
3221        assert_eq!(
3222            state.active_tab().repo_path.as_deref(),
3223            Some(std::path::Path::new("/home/user/repo-b"))
3224        );
3225    }
3226
3227    #[test]
3228    fn switch_tab_to_same_index_is_noop() {
3229        use crate::message::Message;
3230        let mut state = GitKraft::new();
3231        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3232        state.tabs.push(RepoTab::new_empty());
3233        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3234        state.active_tab = 1;
3235
3236        let _ = state.update(Message::SwitchTab(1));
3237
3238        assert_eq!(state.active_tab, 1, "should remain on tab 1");
3239    }
3240
3241    #[test]
3242    fn switch_tab_out_of_bounds_is_noop() {
3243        // Firing SwitchTab with a too-large index must not panic or change state.
3244        use crate::message::Message;
3245        let mut state = GitKraft::new();
3246        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3247        state.tabs.push(RepoTab::new_empty());
3248        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3249        state.active_tab = 0;
3250
3251        let _ = state.update(Message::SwitchTab(99));
3252
3253        assert_eq!(state.active_tab, 0, "out-of-bounds switch must be ignored");
3254    }
3255
3256    // ── session_state ─────────────────────────────────────────────────────
3257
3258    #[test]
3259    fn session_state_matches_open_tab_paths_when_no_empty_tabs() {
3260        let mut state = GitKraft::new();
3261        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3262        state.tabs.push(RepoTab::new_empty());
3263        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3264        state.active_tab = 1;
3265
3266        let (paths, active) = state.session_state();
3267        assert_eq!(paths.len(), 2);
3268        assert_eq!(active, 1);
3269    }
3270
3271    #[test]
3272    fn session_state_adjusts_index_when_empty_tab_before_active() {
3273        // tabs: [Empty(0), repo-a(1), repo-b(2)], active = 2
3274        // session paths: [repo-a, repo-b] → session active should be 1
3275        let mut state = GitKraft::new();
3276        // Tab 0 is already an empty tab from new()
3277        state.tabs.push(RepoTab::new_empty());
3278        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
3279        state.tabs.push(RepoTab::new_empty());
3280        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-b");
3281        state.active_tab = 2;
3282
3283        let (paths, active) = state.session_state();
3284        assert_eq!(paths.len(), 2);
3285        assert_eq!(active, 1, "session index must skip the empty tab at 0");
3286    }
3287
3288    #[test]
3289    fn session_state_defaults_to_zero_when_active_is_empty_tab() {
3290        let mut state = GitKraft::new();
3291        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3292        state.tabs.push(RepoTab::new_empty()); // empty tab at index 1
3293        state.active_tab = 1; // active is the empty tab
3294
3295        let (paths, active) = state.session_state();
3296        assert_eq!(paths.len(), 1);
3297        assert_eq!(active, 0, "empty active tab should default to 0");
3298    }
3299
3300    // ── Race condition: repo_path set before load ─────────────────────────
3301
3302    #[test]
3303    fn repo_selected_sets_repo_path_before_load() {
3304        // After RepoSelected, the empty tab must have repo_path set so that
3305        // handle_repo_loaded can target it by path even if the user switches
3306        // tabs during loading.
3307        use crate::message::Message;
3308        let mut state = GitKraft::new();
3309        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3310        // Simulate OpenRepo creating a new empty tab
3311        state.tabs.push(RepoTab::new_empty());
3312        state.active_tab = 1;
3313
3314        let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
3315            "/home/user/repo-b",
3316        ))));
3317
3318        // The active tab should now have repo_path set
3319        assert_eq!(
3320            state.tabs[1].repo_path.as_deref(),
3321            Some(std::path::Path::new("/home/user/repo-b")),
3322            "repo_path must be set before async load starts"
3323        );
3324    }
3325
3326    #[test]
3327    fn open_recent_repo_sets_repo_path_before_load() {
3328        use crate::message::Message;
3329        let mut state = GitKraft::new();
3330        // Active tab is empty (no repo open)
3331        assert!(!state.active_tab().has_repo());
3332
3333        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
3334            "/home/user/repo-a",
3335        )));
3336
3337        assert_eq!(
3338            state.active_tab().repo_path.as_deref(),
3339            Some(std::path::Path::new("/home/user/repo-a")),
3340            "repo_path must be set before async load starts"
3341        );
3342    }
3343
3344    #[test]
3345    fn handle_repo_loaded_targets_correct_tab_after_switch() {
3346        // Regression test: user opens repo-b in a new tab, then switches
3347        // back to tab 0 before the load completes.  The loaded data must
3348        // still go to the correct tab (the one with repo_path = repo-b).
3349        use crate::message::Message;
3350        let mut state = GitKraft::new();
3351        // Tab 0: repo-a loaded
3352        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3353        // Tab 1: empty, has repo_path set (simulates RepoSelected having set it)
3354        state.tabs.push(RepoTab::new_empty());
3355        state.tabs[1].repo_path = Some(std::path::PathBuf::from("/home/user/repo-b"));
3356        state.tabs[1].is_loading = true;
3357
3358        // User switches back to tab 0
3359        state.active_tab = 0;
3360
3361        // Repo-b load completes
3362        let payload = fake_payload("/home/user/repo-b");
3363        let _ = state.update(Message::RepoOpened(Ok(payload)));
3364
3365        // The payload must have gone to tab 1, NOT tab 0
3366        assert!(
3367            state.tabs[1].repo_info.is_some(),
3368            "repo-b payload must be written to tab 1"
3369        );
3370        assert_eq!(
3371            state.tabs[1].repo_path.as_deref(),
3372            Some(std::path::Path::new("/home/user/repo-b")),
3373        );
3374        // Tab 0 must still be repo-a, untouched
3375        assert_eq!(
3376            state.tabs[0].repo_path.as_deref(),
3377            Some(std::path::Path::new("/home/user/repo-a")),
3378        );
3379    }
3380
3381    // ── Scroll & pagination fix tests ─────────────────────────────────────
3382
3383    #[test]
3384    fn apply_payload_does_not_reset_is_loading_more_commits() {
3385        // A background refresh must not clear is_loading_more_commits — that
3386        // flag is owned by the pagination flow.  Clearing it would let the
3387        // scroll handler fire a duplicate pagination whose identical result
3388        // falsely sets has_more_commits = false ("end of history").
3389        use crate::message::Message;
3390        let mut state = GitKraft::new();
3391        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3392
3393        // Simulate pagination in flight
3394        state.active_tab_mut().is_loading_more_commits = true;
3395
3396        // Background refresh arrives
3397        let payload = fake_payload("/home/user/repo-a");
3398        let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3399
3400        // is_loading_more_commits must still be true
3401        assert!(
3402            state.active_tab().is_loading_more_commits,
3403            "refresh must not reset is_loading_more_commits"
3404        );
3405    }
3406
3407    #[test]
3408    fn apply_payload_resets_is_loading_more_commits_for_new_open() {
3409        // For a brand-new repo open (not a refresh), the flag doesn't matter
3410        // because no pagination can be in flight.  But is_loading_more_commits
3411        // is initialized to false in new_empty(), and apply_payload should not
3412        // set it to true.
3413        let mut state = GitKraft::new();
3414        assert!(!state.active_tab().is_loading_more_commits);
3415
3416        let payload = fake_payload("/home/user/repo-a");
3417        let _ = state.update(crate::message::Message::RepoOpened(Ok(payload)));
3418
3419        assert!(!state.active_tab().is_loading_more_commits);
3420    }
3421
3422    #[test]
3423    fn refresh_does_not_shrink_commit_list() {
3424        // If pagination extended commits to 700 and then a stale refresh
3425        // arrives with only 500, the commit list must not shrink.
3426        use crate::message::Message;
3427        let mut state = GitKraft::new();
3428        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3429
3430        // Give the tab some initial commits, then extend via simulated pagination
3431        let tab = state.active_tab_mut();
3432        tab.commits = make_test_commits(500);
3433        for i in 0..200 {
3434            let mut c = tab.commits[0].clone();
3435            c.oid = format!("extra_{i:04}");
3436            tab.commits.push(c);
3437        }
3438        let extended_len = tab.commits.len();
3439        tab.has_more_commits = true;
3440
3441        // Stale refresh arrives with only the original commits (fewer)
3442        let mut payload = fake_payload("/home/user/repo-a");
3443        payload.commits = make_test_commits(500);
3444        payload.graph_rows = gitkraft_core::features::graph::build_graph(&payload.commits);
3445        let payload_len = payload.commits.len();
3446        assert!(
3447            payload_len < extended_len,
3448            "payload must be shorter for this test"
3449        );
3450
3451        let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3452
3453        assert_eq!(
3454            state.active_tab().commits.len(),
3455            extended_len,
3456            "refresh must not shrink the commit list"
3457        );
3458    }
3459
3460    #[test]
3461    fn refresh_replaces_commits_when_count_equal_or_greater() {
3462        // When a refresh loads the same or more commits, they should replace
3463        // the existing list (to pick up new commits from pushes/rebases).
3464        use crate::message::Message;
3465        let mut state = GitKraft::new();
3466        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3467        state.active_tab_mut().commits = make_test_commits(5);
3468
3469        let original_oid = state.active_tab().commits[0].oid.clone();
3470
3471        // Refresh with same count but different content
3472        let mut payload = fake_payload("/home/user/repo-a");
3473        let mut new_commits = make_test_commits(5);
3474        new_commits[0].oid = "refreshed_oid_0000".to_string();
3475        payload.commits = new_commits;
3476        payload.graph_rows = gitkraft_core::features::graph::build_graph(&payload.commits);
3477        let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3478
3479        assert_eq!(
3480            state.active_tab().commits[0].oid,
3481            "refreshed_oid_0000",
3482            "refresh with same count must replace commits"
3483        );
3484        assert_ne!(state.active_tab().commits[0].oid, original_oid);
3485    }
3486
3487    #[test]
3488    fn refresh_rebuilds_commit_display_when_content_changes_at_same_count() {
3489        // When a refresh replaces commits with different content at the same
3490        // count (e.g. after amend/rebase), commit_display must be rebuilt.
3491        // Previously only a count change triggered the rebuild, leaving stale
3492        // author names and timestamps in the display.
3493        use crate::message::Message;
3494        let mut state = GitKraft::new();
3495        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3496        let mut initial_commits = make_test_commits(5);
3497        initial_commits[0].author_name = "Alice".to_string();
3498        state.active_tab_mut().commits = initial_commits;
3499        // Simulate a prior load that built commit_display.
3500        state.active_tab_mut().commit_display = state
3501            .active_tab()
3502            .commits
3503            .iter()
3504            .map(|c| ("1 day ago".to_string(), c.author_name.clone()))
3505            .collect();
3506        assert_eq!(state.active_tab().commit_display[0].1, "Alice");
3507
3508        // Refresh with same count but different author on the first commit
3509        let mut payload = fake_payload("/home/user/repo-a");
3510        let mut new_commits = make_test_commits(5);
3511        new_commits[0].author_name = "Bob".to_string();
3512        payload.commits = new_commits;
3513        payload.graph_rows = gitkraft_core::features::graph::build_graph(&payload.commits);
3514        let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3515
3516        // commit_display must reflect the new author, not the stale one.
3517        assert_eq!(
3518            state.active_tab().commit_display[0].1,
3519            "Bob",
3520            "commit_display must be rebuilt when content changes at same count"
3521        );
3522    }
3523
3524    #[test]
3525    fn duplicate_more_commits_loaded_sets_has_more_false() {
3526        // Documents the scenario that caused "end of history" too early:
3527        // two identical MoreCommitsLoaded results arriving back-to-back.
3528        // The second sees prev_count == new_total and sets has_more = false.
3529        // This test verifies the SYMPTOM; the fix is that apply_payload no
3530        // longer creates the duplicate by resetting is_loading_more_commits.
3531        use crate::message::{CommitPage, Message};
3532        let mut state = GitKraft::new();
3533        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3534        let tab = state.active_tab_mut();
3535        tab.commits = make_test_commits(10);
3536        tab.has_more_commits = true;
3537
3538        // First result: grows from 10 to 15
3539        let _initial_count = tab.commits.len();
3540        let mut bigger_commits = tab.commits.clone();
3541        for i in 0..5 {
3542            let mut c = bigger_commits[0].clone();
3543            c.oid = format!("new_{i:04}");
3544            bigger_commits.push(c);
3545        }
3546        let bigger_count = bigger_commits.len();
3547        let graph_rows = gitkraft_core::features::graph::build_graph(&bigger_commits);
3548        let _ = state.update(Message::MoreCommitsLoaded(Ok(CommitPage {
3549            commits: bigger_commits.clone(),
3550            graph_rows: graph_rows.clone(),
3551        })));
3552        assert!(
3553            state.active_tab().has_more_commits,
3554            "first result should keep has_more true"
3555        );
3556        assert_eq!(state.active_tab().commits.len(), bigger_count);
3557
3558        // Second identical result: prev_count == new_total → has_more = false
3559        let _ = state.update(Message::MoreCommitsLoaded(Ok(CommitPage {
3560            commits: bigger_commits,
3561            graph_rows,
3562        })));
3563        assert!(
3564            !state.active_tab().has_more_commits,
3565            "identical second result must set has_more_commits false"
3566        );
3567    }
3568
3569    #[test]
3570    fn file_system_changed_allowed_during_pagination() {
3571        // FileSystemChanged is NOT blocked by is_loading_more_commits.
3572        // The "don't shrink" guard in apply_payload handles the race.
3573        // Blocking here would permanently freeze auto-refresh if
3574        // is_loading_more_commits got stuck (e.g. tab switch mid-pagination).
3575        use crate::message::Message;
3576        let mut state = GitKraft::new();
3577        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3578        state.active_tab_mut().is_loading_more_commits = true;
3579
3580        let _task = state.update(Message::FileSystemChanged);
3581
3582        // is_loading_more_commits stays true (owned by pagination flow)
3583        assert!(state.active_tab().is_loading_more_commits);
3584        // The refresh IS allowed — we can't inspect the Task directly,
3585        // but at minimum the state should not have been corrupted.
3586    }
3587
3588    #[test]
3589    fn has_more_commits_preserved_across_refresh() {
3590        // A background refresh must not reset has_more_commits.
3591        use crate::message::Message;
3592        let mut state = GitKraft::new();
3593        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3594        state.active_tab_mut().has_more_commits = true;
3595
3596        let payload = fake_payload("/home/user/repo-a");
3597        let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3598
3599        assert!(
3600            state.active_tab().has_more_commits,
3601            "refresh must preserve has_more_commits"
3602        );
3603    }
3604
3605    #[test]
3606    fn switch_to_existing_tab_removes_empty_tab() {
3607        let mut state = GitKraft::new();
3608        // Tab 0: loaded repo-a
3609        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3610        // Tab 1: empty tab (user clicked "+")
3611        state.tabs.push(RepoTab::new_empty());
3612        state.active_tab = 1;
3613
3614        let found = state.switch_to_existing_tab(std::path::Path::new("/home/user/repo-a"));
3615
3616        assert!(found, "should find existing tab");
3617        assert_eq!(state.tabs.len(), 1, "empty tab should be removed");
3618        assert_eq!(state.active_tab, 0);
3619        assert_eq!(
3620            state.active_tab().repo_path.as_deref(),
3621            Some(std::path::Path::new("/home/user/repo-a")),
3622        );
3623    }
3624
3625    #[test]
3626    fn switch_to_existing_tab_returns_false_when_not_found() {
3627        let mut state = GitKraft::new();
3628        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3629
3630        let found = state.switch_to_existing_tab(std::path::Path::new("/home/user/repo-b"));
3631
3632        assert!(!found, "should not find nonexistent tab");
3633        assert_eq!(state.tabs.len(), 1);
3634    }
3635
3636    #[test]
3637    fn switch_to_existing_tab_keeps_loaded_current_tab() {
3638        let mut state = GitKraft::new();
3639        // Tab 0: repo-a
3640        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3641        // Tab 1: repo-b (active)
3642        state.tabs.push(RepoTab::new_empty());
3643        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3644        state.active_tab = 1;
3645
3646        let found = state.switch_to_existing_tab(std::path::Path::new("/home/user/repo-a"));
3647
3648        assert!(found);
3649        assert_eq!(state.tabs.len(), 2, "loaded tab should not be removed");
3650        assert_eq!(state.active_tab, 0);
3651    }
3652
3653    #[test]
3654    fn default_remote_returns_first_remote() {
3655        let mut tab = RepoTab::new_empty();
3656        tab.remotes = vec![
3657            gitkraft_core::RemoteInfo {
3658                name: "upstream".to_string(),
3659                url: Some("https://example.com/upstream.git".to_string()),
3660                fetch_refspecs: vec![],
3661            },
3662            gitkraft_core::RemoteInfo {
3663                name: "origin".to_string(),
3664                url: Some("https://example.com/origin.git".to_string()),
3665                fetch_refspecs: vec![],
3666            },
3667        ];
3668        assert_eq!(tab.default_remote(), "upstream");
3669    }
3670
3671    #[test]
3672    fn default_remote_fallback_to_origin() {
3673        let tab = RepoTab::new_empty();
3674        assert_eq!(tab.default_remote(), "origin");
3675    }
3676
3677    #[test]
3678    fn hovered_commit_defaults_to_none() {
3679        let tab = RepoTab::new_empty();
3680        assert_eq!(tab.hovered_commit, None);
3681    }
3682
3683    #[test]
3684    fn hover_commit_message_sets_state() {
3685        use crate::message::Message;
3686        let mut state = GitKraft::new();
3687        let _task = state.update(Message::HoverCommit(Some(5)));
3688        assert_eq!(state.active_tab().hovered_commit, Some(5));
3689        let _task = state.update(Message::HoverCommit(None));
3690        assert_eq!(state.active_tab().hovered_commit, None);
3691    }
3692
3693    #[test]
3694    fn branch_context_defaults_empty() {
3695        let tab = RepoTab::new_empty();
3696        assert!(tab.branch_context.is_empty());
3697    }
3698
3699    #[test]
3700    fn ghost_shows_on_selected_commit_without_refs() {
3701        // When a commit is selected, the ghost branch label should show.
3702        // This verifies the state fields are set correctly for the view
3703        // to detect show_ghost = is_hovered || is_selected.
3704        let mut state = GitKraft::new();
3705        let tab = state.active_tab_mut();
3706        tab.repo_path = Some(std::path::PathBuf::from("/tmp/test"));
3707        tab.commits = vec![
3708            gitkraft_core::CommitInfo {
3709                oid: "aaa".into(),
3710                summary: "tip".into(),
3711                message: String::new(),
3712                author_name: "A".into(),
3713                author_email: String::new(),
3714                time: Default::default(),
3715                parent_ids: Vec::new(),
3716                refs: vec![gitkraft_core::RefLabel {
3717                    name: "main".to_string(),
3718                    kind: gitkraft_core::RefKind::Head,
3719                }],
3720            },
3721            gitkraft_core::CommitInfo {
3722                oid: "bbb".into(),
3723                summary: "child".into(),
3724                message: String::new(),
3725                author_name: "A".into(),
3726                author_email: String::new(),
3727                time: Default::default(),
3728                parent_ids: Vec::new(),
3729                refs: Vec::new(),
3730            },
3731        ];
3732        tab.graph_rows = vec![
3733            gitkraft_core::GraphRow {
3734                width: 1,
3735                node_column: 0,
3736                node_color: 0,
3737                edges: Vec::new(),
3738            },
3739            gitkraft_core::GraphRow {
3740                width: 1,
3741                node_column: 0,
3742                node_color: 0,
3743                edges: Vec::new(),
3744            },
3745        ];
3746        tab.branch_context = vec![None, Some("main".to_string())];
3747        tab.selected_commit = Some(1);
3748
3749        // Verify the state is correct for the view to show ghost.
3750        let tab = state.active_tab();
3751        assert_eq!(tab.selected_commit, Some(1));
3752        assert_eq!(tab.branch_context[1], Some("main".to_string()));
3753        // Commits with refs don't get a ghost.
3754        assert_eq!(tab.branch_context[0], None);
3755    }
3756
3757    #[test]
3758    fn theme_choice_system_follows_and_preset_stops() {
3759        let mut state = GitKraft::new();
3760        assert!(state.apply_theme_choice(gitkraft_core::THEME_COUNT));
3761        assert!(state.follow_system);
3762        assert_eq!(state.persisted_theme_name(), "System");
3763        assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
3764        let nord = gitkraft_core::theme_index_by_name("Nord");
3765        assert!(state.apply_theme_choice(nord));
3766        assert!(!state.follow_system);
3767        assert_eq!(state.persisted_theme_name(), "Nord");
3768        assert!(!state.apply_theme_choice(gitkraft_core::THEME_COUNT + 5));
3769        assert_eq!(state.current_theme_index, nord);
3770    }
3771
3772    #[test]
3773    fn refresh_system_theme_only_when_following() {
3774        let mut state = GitKraft::new();
3775        state.follow_system = false;
3776        state.current_theme_index = 9;
3777        state.refresh_system_theme();
3778        assert_eq!(state.current_theme_index, 9);
3779        state.follow_system = true;
3780        state.refresh_system_theme();
3781        assert_eq!(
3782            state.current_theme_index,
3783            gitkraft_core::current_system_theme_index()
3784        );
3785    }
3786}