Skip to main content

gitkraft_gui/
state.rs

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