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