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