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            })
1606            .collect()
1607    }
1608
1609    #[test]
1610    fn selected_commits_defaults_empty() {
1611        let tab = RepoTab::new_empty();
1612        assert!(tab.selected_commits.is_empty());
1613        assert!(tab.anchor_commit_index.is_none());
1614    }
1615
1616    #[test]
1617    fn regular_click_commit_sets_anchor_and_clears_range() {
1618        use crate::message::Message;
1619        let mut state = GitKraft::new();
1620        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
1621        state.active_tab_mut().commits = make_test_commits(3);
1622        state.active_tab_mut().selected_commits = vec![0, 1, 2];
1623
1624        let _ = state.update(Message::SelectCommit(1));
1625
1626        assert_eq!(state.active_tab().anchor_commit_index, Some(1));
1627        assert!(state.active_tab().selected_commits.is_empty());
1628        assert_eq!(state.active_tab().selected_commit, Some(1));
1629    }
1630
1631    #[test]
1632    fn shift_click_commit_selects_range_from_anchor() {
1633        use crate::message::Message;
1634        let mut state = GitKraft::new();
1635        state.active_tab_mut().commits = make_test_commits(5);
1636        state.active_tab_mut().anchor_commit_index = Some(1);
1637        state.active_tab_mut().selected_commit = Some(1);
1638
1639        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1640        let _ = state.update(Message::SelectCommit(4));
1641
1642        assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3, 4]);
1643    }
1644
1645    #[test]
1646    fn shift_click_commit_range_is_ascending_when_clicking_above_anchor() {
1647        use crate::message::Message;
1648        let mut state = GitKraft::new();
1649        state.active_tab_mut().commits = make_test_commits(5);
1650        state.active_tab_mut().anchor_commit_index = Some(3);
1651        state.active_tab_mut().selected_commit = Some(3);
1652
1653        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1654        let _ = state.update(Message::SelectCommit(1));
1655
1656        assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3]);
1657    }
1658
1659    // ── ExecuteCommitAction message ───────────────────────────────────────
1660
1661    #[test]
1662    fn execute_commit_action_closes_context_menu() {
1663        use crate::message::Message;
1664        let mut state = GitKraft::new();
1665        state.active_tab_mut().repo_path =
1666            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1667        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::Commit {
1668            index: 0,
1669            oid: "abc123".to_string(),
1670        });
1671
1672        let _ = state.update(Message::ExecuteCommitAction(
1673            "abc123".to_string(),
1674            gitkraft_core::CommitAction::CherryPick,
1675        ));
1676
1677        assert!(state.active_tab().context_menu.is_none());
1678    }
1679
1680    #[test]
1681    fn execute_commit_action_sets_loading_when_repo_open() {
1682        use crate::message::Message;
1683        let mut state = GitKraft::new();
1684        state.active_tab_mut().repo_path =
1685            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1686
1687        let _ = state.update(Message::ExecuteCommitAction(
1688            "abc123".to_string(),
1689            gitkraft_core::CommitAction::ResetHard,
1690        ));
1691
1692        assert!(state.active_tab().is_loading);
1693    }
1694
1695    #[test]
1696    fn execute_commit_action_no_repo_does_not_set_loading() {
1697        use crate::message::Message;
1698        let mut state = GitKraft::new();
1699        // No repo_path set
1700
1701        let _ = state.update(Message::ExecuteCommitAction(
1702            "abc123".to_string(),
1703            gitkraft_core::CommitAction::CherryPick,
1704        ));
1705
1706        assert!(!state.active_tab().is_loading);
1707    }
1708
1709    #[test]
1710    fn execute_commit_action_sets_status_message_from_action_label() {
1711        use crate::message::Message;
1712        let mut state = GitKraft::new();
1713        state.active_tab_mut().repo_path =
1714            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1715
1716        let _ = state.update(Message::ExecuteCommitAction(
1717            "abc123".to_string(),
1718            gitkraft_core::CommitAction::Revert,
1719        ));
1720
1721        let status = state.active_tab().status_message.as_deref().unwrap_or("");
1722        // Status message should contain the action's label
1723        assert!(
1724            status.contains("Revert commit"),
1725            "expected status to contain 'Revert commit', got: {status:?}"
1726        );
1727    }
1728
1729    // ── File history / blame / delete state ──────────────────────────────
1730
1731    #[test]
1732    fn file_history_defaults_empty() {
1733        let tab = RepoTab::new_empty();
1734        assert!(tab.file_history_path.is_none());
1735        assert!(tab.file_history_commits.is_empty());
1736        assert_eq!(tab.file_history_scroll, 0.0);
1737    }
1738
1739    #[test]
1740    fn blame_defaults_empty() {
1741        let tab = RepoTab::new_empty();
1742        assert!(tab.blame_path.is_none());
1743        assert!(tab.blame_lines.is_empty());
1744        assert_eq!(tab.blame_scroll, 0.0);
1745    }
1746
1747    #[test]
1748    fn pending_delete_file_defaults_none() {
1749        let tab = RepoTab::new_empty();
1750        assert!(tab.pending_delete_file.is_none());
1751    }
1752
1753    #[test]
1754    fn view_file_history_sets_path_and_clears_blame() {
1755        use crate::message::Message;
1756        let mut state = GitKraft::new();
1757        state.active_tab_mut().repo_path =
1758            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1759        state.active_tab_mut().blame_path = Some("some/file.rs".to_string());
1760
1761        let _ = state.update(Message::ViewFileHistory("src/main.rs".to_string()));
1762
1763        assert_eq!(
1764            state.active_tab().file_history_path.as_deref(),
1765            Some("src/main.rs")
1766        );
1767        // Opening history should close blame
1768        assert!(state.active_tab().blame_path.is_none());
1769    }
1770
1771    #[test]
1772    fn close_file_history_clears_state() {
1773        use crate::message::Message;
1774        let mut state = GitKraft::new();
1775        state.active_tab_mut().file_history_path = Some("src/lib.rs".to_string());
1776        state.active_tab_mut().file_history_commits = vec![gitkraft_core::CommitInfo {
1777            oid: "abc".to_string(),
1778            short_oid: "abc".to_string(),
1779            summary: "s".to_string(),
1780            message: "s".to_string(),
1781            author_name: "a".to_string(),
1782            author_email: "a@b.com".to_string(),
1783            time: Default::default(),
1784            parent_ids: vec![],
1785        }];
1786
1787        let _ = state.update(Message::CloseFileHistory);
1788
1789        assert!(state.active_tab().file_history_path.is_none());
1790        assert!(state.active_tab().file_history_commits.is_empty());
1791    }
1792
1793    #[test]
1794    fn view_file_blame_sets_path_and_clears_history() {
1795        use crate::message::Message;
1796        let mut state = GitKraft::new();
1797        state.active_tab_mut().repo_path =
1798            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1799        state.active_tab_mut().file_history_path = Some("some/file.rs".to_string());
1800
1801        let _ = state.update(Message::ViewFileBlame("src/lib.rs".to_string()));
1802
1803        assert_eq!(state.active_tab().blame_path.as_deref(), Some("src/lib.rs"));
1804        // Opening blame should close history
1805        assert!(state.active_tab().file_history_path.is_none());
1806    }
1807
1808    #[test]
1809    fn selecting_new_commit_closes_blame_overlay() {
1810        use crate::message::Message;
1811        let mut state = GitKraft::new();
1812        state.active_tab_mut().repo_path =
1813            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1814        // Pre-populate a commit list so SelectCommit can find the commit.
1815        state.active_tab_mut().commits = vec![
1816            gitkraft_core::CommitInfo {
1817                oid: "abc1".into(),
1818                short_oid: "abc1".into(),
1819                summary: "first".into(),
1820                message: "first".into(),
1821                author_name: "A".into(),
1822                author_email: "a@a.com".into(),
1823                time: Default::default(),
1824                parent_ids: Vec::new(),
1825            },
1826            gitkraft_core::CommitInfo {
1827                oid: "abc2".into(),
1828                short_oid: "abc2".into(),
1829                summary: "second".into(),
1830                message: "second".into(),
1831                author_name: "A".into(),
1832                author_email: "a@a.com".into(),
1833                time: Default::default(),
1834                parent_ids: Vec::new(),
1835            },
1836        ];
1837        // Blame is currently open for a file from the first commit.
1838        state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1839        state.active_tab_mut().blame_lines = vec![gitkraft_core::BlameLine {
1840            line_number: 1,
1841            content: "fn main() {}".into(),
1842            short_oid: "abc1".into(),
1843            oid: "abc1".into(),
1844            author_name: "A".into(),
1845            time: Default::default(),
1846        }];
1847
1848        // Click a different commit — blame must close automatically.
1849        let _ = state.update(Message::SelectCommit(1));
1850
1851        assert!(
1852            state.active_tab().blame_path.is_none(),
1853            "blame_path must be cleared when a new commit is selected"
1854        );
1855        assert!(
1856            state.active_tab().blame_lines.is_empty(),
1857            "blame_lines must be cleared when a new commit is selected"
1858        );
1859    }
1860
1861    #[test]
1862    fn close_file_blame_clears_state() {
1863        use crate::message::Message;
1864        let mut state = GitKraft::new();
1865        state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1866
1867        let _ = state.update(Message::CloseFileBlame);
1868
1869        assert!(state.active_tab().blame_path.is_none());
1870        assert!(state.active_tab().blame_lines.is_empty());
1871    }
1872
1873    #[test]
1874    fn delete_file_sets_pending() {
1875        use crate::message::Message;
1876        let mut state = GitKraft::new();
1877
1878        let _ = state.update(Message::DeleteFile("src/old.rs".to_string()));
1879
1880        assert_eq!(
1881            state.active_tab().pending_delete_file.as_deref(),
1882            Some("src/old.rs")
1883        );
1884        assert!(state.active_tab().context_menu.is_none());
1885    }
1886
1887    #[test]
1888    fn cancel_delete_file_clears_pending() {
1889        use crate::message::Message;
1890        let mut state = GitKraft::new();
1891        state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1892
1893        let _ = state.update(Message::CancelDeleteFile);
1894
1895        assert!(state.active_tab().pending_delete_file.is_none());
1896    }
1897
1898    #[test]
1899    fn confirm_delete_file_no_repo_is_noop() {
1900        use crate::message::Message;
1901        let mut state = GitKraft::new();
1902        state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1903        // No repo_path → should not set is_loading
1904
1905        let _ = state.update(Message::ConfirmDeleteFile);
1906
1907        assert!(!state.active_tab().is_loading);
1908    }
1909
1910    #[test]
1911    fn shift_arrow_down_extends_file_list_selection_when_files_loaded() {
1912        use crate::message::Message;
1913        let mut state = GitKraft::new();
1914        state.active_tab_mut().repo_path =
1915            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1916        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1917        state.active_tab_mut().selected_file_index = Some(0);
1918        state.active_tab_mut().anchor_file_index = Some(0);
1919        // keyboard_modifiers must have SHIFT set for range selection to trigger
1920        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1921
1922        let _ = state.update(Message::ShiftArrowDown);
1923
1924        assert_eq!(state.active_tab().selected_file_index, Some(1));
1925        // Range should now include both files
1926        assert!(state.active_tab().selected_commit_file_indices.contains(&0));
1927        assert!(state.active_tab().selected_commit_file_indices.contains(&1));
1928    }
1929
1930    #[test]
1931    fn shift_arrow_down_falls_through_to_commit_log_when_no_files() {
1932        use crate::message::Message;
1933        let mut state = GitKraft::new();
1934        state.active_tab_mut().commits = make_test_commits(5);
1935        state.active_tab_mut().selected_commit = Some(1);
1936        state.active_tab_mut().anchor_commit_index = Some(1);
1937        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1938        // no commit_files
1939
1940        let _ = state.update(Message::ShiftArrowDown);
1941
1942        assert_eq!(state.active_tab().selected_commit, Some(2));
1943        assert!(state.active_tab().selected_commits.contains(&1));
1944        assert!(state.active_tab().selected_commits.contains(&2));
1945    }
1946
1947    #[test]
1948    fn file_system_changed_triggers_full_refresh() {
1949        use crate::message::Message;
1950        let mut state = GitKraft::new();
1951        state.active_tab_mut().repo_path =
1952            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1953
1954        // FileSystemChanged should call refresh_active_tab() which returns
1955        // a non-none Task.  We verify by checking that is_loading is NOT set
1956        // synchronously (the task is async), but that no error is set either.
1957        let _task = state.update(Message::FileSystemChanged);
1958
1959        // With a repo_path set, the handler must have attempted a refresh
1960        // (it returns a Task, so is_loading is set by the task executor, not here).
1961        // What we CAN check: no error was set, and status is not "error".
1962        assert!(
1963            state.active_tab().error_message.is_none(),
1964            "FileSystemChanged must not set an error message"
1965        );
1966    }
1967
1968    // ── Tab duplication prevention tests ───────────────────────────────────
1969
1970    /// Helper: build a minimal `RepoPayload` (aka `RepoSnapshot`) for a given path.
1971    fn fake_payload(workdir: &str) -> crate::message::RepoPayload {
1972        gitkraft_core::RepoSnapshot {
1973            info: gitkraft_core::RepoInfo {
1974                path: std::path::PathBuf::from(format!("{workdir}/.git")),
1975                workdir: Some(std::path::PathBuf::from(workdir)),
1976                head_branch: Some("main".into()),
1977                is_bare: false,
1978                state: gitkraft_core::RepoState::Clean,
1979            },
1980            branches: Vec::new(),
1981            commits: Vec::new(),
1982            graph_rows: Vec::new(),
1983            unstaged: Vec::new(),
1984            staged: Vec::new(),
1985            stashes: Vec::new(),
1986            remotes: Vec::new(),
1987        }
1988    }
1989
1990    /// Helper: set up a tab as if a repo was fully loaded.
1991    fn setup_loaded_tab(tab: &mut RepoTab, path: &str) {
1992        tab.repo_path = Some(std::path::PathBuf::from(path));
1993        tab.repo_info = Some(gitkraft_core::RepoInfo {
1994            path: std::path::PathBuf::from(format!("{path}/.git")),
1995            workdir: Some(std::path::PathBuf::from(path)),
1996            head_branch: Some("main".into()),
1997            is_bare: false,
1998            state: gitkraft_core::RepoState::Clean,
1999        });
2000    }
2001
2002    #[test]
2003    fn open_repo_creates_new_tab_when_repo_already_open() {
2004        use crate::message::Message;
2005        let mut state = GitKraft::new();
2006        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2007
2008        assert_eq!(state.tabs.len(), 1);
2009        assert_eq!(state.active_tab, 0);
2010
2011        // Clicking "Open" should create a new tab when the current has a repo.
2012        let _task = state.update(Message::OpenRepo);
2013
2014        assert_eq!(state.tabs.len(), 2);
2015        assert_eq!(state.active_tab, 1);
2016        // The new tab should be loading (folder picker opening).
2017        assert!(state.tabs[1].is_loading);
2018        // The original tab should be untouched.
2019        assert_eq!(
2020            state.tabs[0].repo_path.as_deref(),
2021            Some(std::path::Path::new("/home/user/repo-a"))
2022        );
2023    }
2024
2025    #[test]
2026    fn open_repo_reuses_empty_tab() {
2027        use crate::message::Message;
2028        let mut state = GitKraft::new();
2029        // Active tab is empty (no repo loaded).
2030        assert!(!state.active_tab().has_repo());
2031
2032        let _task = state.update(Message::OpenRepo);
2033
2034        // Should NOT create a new tab when the active tab is empty.
2035        assert_eq!(state.tabs.len(), 1);
2036        assert_eq!(state.active_tab, 0);
2037        assert!(state.tabs[0].is_loading);
2038    }
2039
2040    #[test]
2041    fn repo_selected_deduplicates_already_open_repo() {
2042        use crate::message::Message;
2043        let mut state = GitKraft::new();
2044        // Tab 0: fully loaded repo-a
2045        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2046        // Tab 1: empty (simulates the new tab created by OpenRepo)
2047        state.tabs.push(RepoTab::new_empty());
2048        state.active_tab = 1;
2049
2050        // User selects a folder that matches the already-open repo.
2051        let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2052            "/home/user/repo-a",
2053        ))));
2054
2055        // Should switch to the existing tab and remove the empty one.
2056        assert_eq!(state.tabs.len(), 1);
2057        assert_eq!(state.active_tab, 0);
2058        assert_eq!(
2059            state.tabs[0].repo_path.as_deref(),
2060            Some(std::path::Path::new("/home/user/repo-a"))
2061        );
2062    }
2063
2064    #[test]
2065    fn repo_selected_opens_new_repo_in_empty_tab() {
2066        use crate::message::Message;
2067        let mut state = GitKraft::new();
2068        // Tab 0: fully loaded repo-a
2069        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2070        // Tab 1: empty (simulates the new tab created by OpenRepo)
2071        state.tabs.push(RepoTab::new_empty());
2072        state.active_tab = 1;
2073
2074        // User selects a DIFFERENT repo.
2075        let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2076            "/home/user/repo-b",
2077        ))));
2078
2079        // Should keep both tabs; the empty tab is now loading repo-b.
2080        assert_eq!(state.tabs.len(), 2);
2081        assert_eq!(state.active_tab, 1);
2082        assert!(state.tabs[1]
2083            .status_message
2084            .as_deref()
2085            .unwrap_or("")
2086            .contains("repo-b"));
2087    }
2088
2089    #[test]
2090    fn repo_selected_cancel_removes_empty_tab() {
2091        use crate::message::Message;
2092        let mut state = GitKraft::new();
2093        // Tab 0: fully loaded repo-a
2094        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2095        // Tab 1: empty (created by OpenRepo, waiting for folder picker)
2096        state.tabs.push(RepoTab::new_empty());
2097        state.active_tab = 1;
2098
2099        // User cancels the folder picker.
2100        let _task = state.update(Message::RepoSelected(None));
2101
2102        // The empty tab should be removed; switch back to tab 0.
2103        assert_eq!(state.tabs.len(), 1);
2104        assert_eq!(state.active_tab, 0);
2105        assert_eq!(
2106            state.tabs[0].repo_path.as_deref(),
2107            Some(std::path::Path::new("/home/user/repo-a"))
2108        );
2109    }
2110
2111    #[test]
2112    fn repo_selected_cancel_keeps_tab_if_only_one() {
2113        use crate::message::Message;
2114        let mut state = GitKraft::new();
2115        // Single empty tab — shouldn't be removed on cancel.
2116        assert_eq!(state.tabs.len(), 1);
2117        assert!(!state.active_tab().has_repo());
2118
2119        let _task = state.update(Message::RepoSelected(None));
2120
2121        assert_eq!(state.tabs.len(), 1);
2122        assert!(!state.active_tab().is_loading);
2123    }
2124
2125    #[test]
2126    fn open_recent_repo_deduplicates() {
2127        use crate::message::Message;
2128        let mut state = GitKraft::new();
2129        // Tab 0: fully loaded repo-a
2130        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2131        // Tab 1: empty tab
2132        state.tabs.push(RepoTab::new_empty());
2133        state.active_tab = 1;
2134
2135        // Opening a recent repo that's already open should switch to it.
2136        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2137            "/home/user/repo-a",
2138        )));
2139
2140        assert_eq!(state.active_tab, 0);
2141    }
2142
2143    #[test]
2144    fn open_recent_repo_creates_new_tab_when_current_has_repo() {
2145        use crate::message::Message;
2146        let mut state = GitKraft::new();
2147        // Tab 0: fully loaded repo-a
2148        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2149
2150        // Opening a different recent repo should create a new tab.
2151        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2152            "/home/user/repo-b",
2153        )));
2154
2155        assert_eq!(state.tabs.len(), 2);
2156        assert_eq!(state.active_tab, 1);
2157        assert!(state.tabs[1].is_loading);
2158    }
2159
2160    #[test]
2161    fn open_recent_repo_uses_empty_tab() {
2162        use crate::message::Message;
2163        let mut state = GitKraft::new();
2164        // Active tab is empty.
2165        assert!(!state.active_tab().has_repo());
2166
2167        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2168            "/home/user/repo-b",
2169        )));
2170
2171        // Should NOT create a new tab.
2172        assert_eq!(state.tabs.len(), 1);
2173        assert_eq!(state.active_tab, 0);
2174        assert!(state.tabs[0].is_loading);
2175    }
2176
2177    // ── Refresh race-condition tests ──────────────────────────────────────
2178
2179    #[test]
2180    fn repo_refreshed_targets_correct_tab_after_tab_switch() {
2181        use crate::message::Message;
2182        let mut state = GitKraft::new();
2183        // Tab 0: repo-a fully loaded
2184        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2185        // Tab 1: empty tab (user clicked "+")
2186        state.tabs.push(RepoTab::new_empty());
2187        state.active_tab = 1; // user switched to new empty tab
2188
2189        // Simulate: a RepoRefreshed result arrives for repo-a while tab 1 is active.
2190        let payload = fake_payload("/home/user/repo-a");
2191        let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2192
2193        // The payload must land in tab 0 (which owns repo-a), NOT tab 1.
2194        assert!(
2195            state.tabs[0].repo_info.is_some(),
2196            "tab 0 should still have repo info after refresh"
2197        );
2198        assert_eq!(
2199            state.tabs[0].current_branch.as_deref(),
2200            Some("main"),
2201            "tab 0 should have updated branch from payload"
2202        );
2203        // Tab 1 must remain empty.
2204        assert!(
2205            state.tabs[1].repo_info.is_none(),
2206            "tab 1 (empty) must NOT receive the refresh payload"
2207        );
2208        assert!(
2209            state.tabs[1].repo_path.is_none(),
2210            "tab 1 should still have no repo path"
2211        );
2212    }
2213
2214    #[test]
2215    fn repo_refreshed_targets_active_tab_for_new_open() {
2216        use crate::message::Message;
2217        let mut state = GitKraft::new();
2218        // Active tab is empty — user just opened a brand-new repo.
2219        // (RepoSelected set the status but the tab doesn't have repo_path yet
2220        // matching the payload, so it falls back to active tab.)
2221        assert_eq!(state.tabs.len(), 1);
2222        assert!(!state.active_tab().has_repo());
2223
2224        let payload = fake_payload("/home/user/new-repo");
2225        let _task = state.update(Message::RepoOpened(Ok(payload)));
2226
2227        // Should have applied to the active tab (the only tab).
2228        assert_eq!(
2229            state.tabs[0].repo_path.as_deref(),
2230            Some(std::path::Path::new("/home/user/new-repo"))
2231        );
2232        assert!(state.tabs[0].repo_info.is_some());
2233    }
2234
2235    #[test]
2236    fn repo_refreshed_does_not_duplicate_into_new_tab() {
2237        use crate::message::Message;
2238        let mut state = GitKraft::new();
2239        // Tab 0: repo-a loaded
2240        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2241
2242        // User clicks "+" creating an empty tab 1, then switches to it.
2243        let _task = state.update(Message::NewTab);
2244        assert_eq!(state.tabs.len(), 2);
2245        assert_eq!(state.active_tab, 1);
2246
2247        // A refresh result for repo-a arrives (was triggered before the tab switch).
2248        let payload = fake_payload("/home/user/repo-a");
2249        let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2250
2251        // Tab 1 must remain empty — the payload should go to tab 0.
2252        assert!(
2253            state.tabs[1].repo_path.is_none(),
2254            "new empty tab must not receive repo-a refresh"
2255        );
2256        assert!(
2257            state.tabs[1].repo_info.is_none(),
2258            "new empty tab must not have repo_info"
2259        );
2260        // Tab 0 must have the refreshed data.
2261        assert_eq!(
2262            state.tabs[0].repo_path.as_deref(),
2263            Some(std::path::Path::new("/home/user/repo-a"))
2264        );
2265    }
2266
2267    #[test]
2268    fn git_operation_result_targets_correct_tab() {
2269        use crate::message::Message;
2270        let mut state = GitKraft::new();
2271        // Tab 0: repo-a
2272        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2273        // Tab 1: repo-b
2274        state.tabs.push(RepoTab::new_empty());
2275        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2276        state.active_tab = 1;
2277
2278        // A git operation result arrives for repo-a (e.g. push completed)
2279        // while user is on tab 1.
2280        let payload = fake_payload("/home/user/repo-a");
2281        let _task = state.update(Message::GitOperationResult(Ok(payload)));
2282
2283        // Payload should land in tab 0 (repo-a), not tab 1.
2284        assert_eq!(state.tabs[0].current_branch.as_deref(), Some("main"));
2285        // Tab 1's data should remain unchanged (still repo-b).
2286        assert_eq!(
2287            state.tabs[1].repo_path.as_deref(),
2288            Some(std::path::Path::new("/home/user/repo-b"))
2289        );
2290    }
2291
2292    #[test]
2293    fn multiple_new_tabs_dont_get_polluted_by_refresh() {
2294        use crate::message::Message;
2295        let mut state = GitKraft::new();
2296        // Tab 0: repo-a loaded
2297        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2298
2299        // User creates multiple new tabs.
2300        let _task = state.update(Message::NewTab);
2301        let _task = state.update(Message::NewTab);
2302        assert_eq!(state.tabs.len(), 3);
2303        assert_eq!(state.active_tab, 2);
2304
2305        // Refresh arrives for repo-a.
2306        let payload = fake_payload("/home/user/repo-a");
2307        let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2308
2309        // Only tab 0 should be affected.
2310        assert!(state.tabs[0].repo_info.is_some());
2311        assert!(state.tabs[1].repo_info.is_none());
2312        assert!(state.tabs[2].repo_info.is_none());
2313        assert!(state.tabs[1].repo_path.is_none());
2314        assert!(state.tabs[2].repo_path.is_none());
2315    }
2316
2317    #[test]
2318    fn repo_selected_dedup_adjusts_index_when_existing_is_after_active() {
2319        use crate::message::Message;
2320        let mut state = GitKraft::new();
2321        // Tab 0: empty (newly created by OpenRepo)
2322        // Tab 1: repo-a loaded
2323        state.tabs.push(RepoTab::new_empty());
2324        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
2325        state.active_tab = 0;
2326
2327        // User selects the same folder as repo-a.
2328        let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2329            "/home/user/repo-a",
2330        ))));
2331
2332        // Empty tab 0 should be removed; we should now be on tab 0 (formerly tab 1).
2333        assert_eq!(state.tabs.len(), 1);
2334        assert_eq!(state.active_tab, 0);
2335        assert_eq!(
2336            state.tabs[0].repo_path.as_deref(),
2337            Some(std::path::Path::new("/home/user/repo-a"))
2338        );
2339    }
2340
2341    // ── Multi-selection preservation across refresh ───────────────────────
2342
2343    #[test]
2344    fn apply_payload_preserves_multi_selection_by_oid() {
2345        let mut tab = RepoTab::new_empty();
2346        tab.commits = make_test_commits(5);
2347        // Simulate user selecting commits 1..=3 with Shift+click.
2348        tab.anchor_commit_index = Some(1);
2349        tab.selected_commits = vec![1, 2, 3];
2350        tab.selected_commit = Some(3);
2351        tab.selected_commit_oid = Some(tab.commits[3].oid.clone());
2352
2353        // Build a payload with the same commits (simulating a background refresh).
2354        let mut payload = fake_payload("/tmp/repo");
2355        payload.commits = make_test_commits(5);
2356
2357        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2358
2359        // The multi-selection must survive the refresh.
2360        assert_eq!(tab.anchor_commit_index, Some(1));
2361        assert_eq!(tab.selected_commits, vec![1, 2, 3]);
2362        assert_eq!(tab.selected_commit, Some(3));
2363    }
2364
2365    #[test]
2366    fn apply_payload_preserves_anchor_even_without_range() {
2367        let mut tab = RepoTab::new_empty();
2368        tab.commits = make_test_commits(5);
2369        tab.anchor_commit_index = Some(2);
2370        tab.selected_commit = Some(2);
2371        tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2372        // No range selection — just a single click with anchor set.
2373
2374        let mut payload = fake_payload("/tmp/repo");
2375        payload.commits = make_test_commits(5);
2376
2377        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2378
2379        assert_eq!(tab.anchor_commit_index, Some(2));
2380        assert_eq!(tab.selected_commit, Some(2));
2381    }
2382
2383    #[test]
2384    fn apply_payload_clears_selection_when_commits_disappear() {
2385        let mut tab = RepoTab::new_empty();
2386        tab.commits = make_test_commits(5);
2387        tab.anchor_commit_index = Some(2);
2388        tab.selected_commits = vec![2, 3, 4];
2389        tab.selected_commit = Some(4);
2390        tab.selected_commit_oid = Some(tab.commits[4].oid.clone());
2391
2392        // Payload has completely different commits (e.g. force-push).
2393        let mut payload = fake_payload("/tmp/repo");
2394        payload.commits = (0..3)
2395            .map(|i| gitkraft_core::CommitInfo {
2396                oid: format!("new_oid_{i}"),
2397                short_oid: format!("new_{i}"),
2398                summary: format!("new commit {i}"),
2399                message: String::new(),
2400                author_name: "Author".into(),
2401                author_email: "a@b.c".into(),
2402                time: Default::default(),
2403                parent_ids: Vec::new(),
2404            })
2405            .collect();
2406
2407        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2408
2409        // All old OIDs are gone — selection should be cleared.
2410        assert!(tab.selected_commits.is_empty());
2411        assert!(tab.anchor_commit_index.is_none());
2412        assert!(tab.selected_commit.is_none());
2413    }
2414
2415    // ── File multi-selection preservation across refresh ─────────────────
2416
2417    #[test]
2418    fn apply_payload_preserves_file_selection_when_commit_survives() {
2419        let mut tab = RepoTab::new_empty();
2420        tab.commits = make_test_commits(3);
2421        tab.selected_commit = Some(1);
2422        tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2423        tab.commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
2424
2425        // Simulate user Shift+clicking files 0..=2.
2426        tab.anchor_file_index = Some(0);
2427        tab.selected_commit_file_indices = vec![0, 1, 2];
2428        tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2429            old_file: String::new(),
2430            new_file: "a.rs".into(),
2431            status: gitkraft_core::FileStatus::Modified,
2432            hunks: vec![],
2433        }];
2434
2435        // Background refresh with the same commits.
2436        let mut payload = fake_payload("/tmp/repo");
2437        payload.commits = make_test_commits(3);
2438
2439        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2440
2441        // File selection must survive.
2442        assert_eq!(tab.anchor_file_index, Some(0));
2443        assert_eq!(tab.selected_commit_file_indices, vec![0, 1, 2]);
2444        assert!(
2445            !tab.multi_file_diffs.is_empty(),
2446            "multi_file_diffs must survive when commit is preserved"
2447        );
2448    }
2449
2450    #[test]
2451    fn apply_payload_clears_file_selection_when_commit_disappears() {
2452        let mut tab = RepoTab::new_empty();
2453        tab.commits = make_test_commits(3);
2454        tab.selected_commit = Some(1);
2455        tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2456        tab.commit_files = make_commit_files(&["a.rs", "b.rs"]);
2457        tab.anchor_file_index = Some(0);
2458        tab.selected_commit_file_indices = vec![0, 1];
2459        tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2460            old_file: String::new(),
2461            new_file: "a.rs".into(),
2462            status: gitkraft_core::FileStatus::Modified,
2463            hunks: vec![],
2464        }];
2465
2466        // Payload with completely different commits (force-push).
2467        let mut payload = fake_payload("/tmp/repo");
2468        payload.commits = (0..2)
2469            .map(|i| gitkraft_core::CommitInfo {
2470                oid: format!("new_{i}"),
2471                short_oid: format!("n{i}"),
2472                summary: String::new(),
2473                message: String::new(),
2474                author_name: String::new(),
2475                author_email: String::new(),
2476                time: Default::default(),
2477                parent_ids: Vec::new(),
2478            })
2479            .collect();
2480
2481        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2482
2483        // Commit is gone → file selection must be cleared.
2484        assert!(tab.anchor_file_index.is_none());
2485        assert!(tab.selected_commit_file_indices.is_empty());
2486        assert!(tab.multi_file_diffs.is_empty());
2487        assert!(tab.commit_files.is_empty());
2488    }
2489
2490    #[test]
2491    fn apply_payload_preserves_commit_range_diffs_when_commit_survives() {
2492        let mut tab = RepoTab::new_empty();
2493        tab.commits = make_test_commits(5);
2494        tab.selected_commit = Some(2);
2495        tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2496        tab.commit_range_diffs = vec![gitkraft_core::DiffInfo {
2497            old_file: String::new(),
2498            new_file: "x.rs".into(),
2499            status: gitkraft_core::FileStatus::Modified,
2500            hunks: vec![],
2501        }];
2502
2503        let mut payload = fake_payload("/tmp/repo");
2504        payload.commits = make_test_commits(5);
2505
2506        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2507
2508        assert!(
2509            !tab.commit_range_diffs.is_empty(),
2510            "commit_range_diffs must survive when commit is preserved"
2511        );
2512    }
2513
2514    // ── PreviewFile / OpenFiles message tests ─────────────────────────
2515
2516    #[test]
2517    fn preview_file_loaded_sets_selected_diff() {
2518        use crate::message::Message;
2519        let mut state = GitKraft::new();
2520        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2521
2522        let content = "line 1\nline 2\nline 3".to_string();
2523        let _ = state.update(Message::FilePreviewLoaded(Ok((
2524            "test.rs".to_string(),
2525            content,
2526        ))));
2527
2528        let diff = state.active_tab().selected_diff.as_ref().unwrap();
2529        assert_eq!(diff.new_file, "test.rs");
2530        assert_eq!(diff.hunks.len(), 1);
2531        assert_eq!(diff.hunks[0].lines.len(), 3);
2532        assert_eq!(state.active_tab().diff_scroll_offset, 0.0);
2533    }
2534
2535    #[test]
2536    fn preview_file_loaded_error_sets_error_message() {
2537        use crate::message::Message;
2538        let mut state = GitKraft::new();
2539        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2540
2541        let _ = state.update(Message::FilePreviewLoaded(Err("No such file".to_string())));
2542
2543        assert!(state.active_tab().error_message.is_some());
2544        assert!(state
2545            .active_tab()
2546            .error_message
2547            .as_ref()
2548            .unwrap()
2549            .contains("No such file"));
2550    }
2551}