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