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