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