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        // Don't spawn a duplicate refresh when one is already in flight
934        // (is_loading is also set by the manual RefreshRepo handler).
935        if tab.is_loading {
936            return Task::none();
937        }
938        match tab.repo_path.clone() {
939            Some(path) => {
940                let depth = tab.commits.len();
941                crate::features::repo::commands::refresh_repo(path, depth)
942            }
943            None => Task::none(),
944        }
945    }
946
947    /// Handle a `Result<(), String>` from a git operation that should trigger
948    /// a full repository refresh on success.
949    ///
950    /// * `Ok(())` — clears `is_loading`, sets `status_message`, refreshes.
951    /// * `Err(e)` — clears `is_loading`, sets `error_message`, returns
952    ///   [`Task::none()`].
953    pub fn on_ok_refresh(
954        &mut self,
955        result: Result<(), String>,
956        ok_msg: &str,
957        err_prefix: &str,
958    ) -> Task<Message> {
959        match result {
960            Ok(()) => {
961                {
962                    let tab = self.active_tab_mut();
963                    tab.is_loading = false;
964                    tab.status_message = Some(ok_msg.to_string());
965                }
966                self.refresh_active_tab()
967            }
968            Err(e) => {
969                let tab = self.active_tab_mut();
970                tab.is_loading = false;
971                tab.error_message = Some(format!("{err_prefix}: {e}"));
972                tab.status_message = None;
973                Task::none()
974            }
975        }
976    }
977
978    /// Build a [`LayoutSettings`] snapshot from the current pane dimensions.
979    pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
980        gitkraft_core::LayoutSettings {
981            sidebar_width: Some(self.sidebar_width),
982            commit_log_width: Some(self.commit_log_width),
983            staging_height: Some(self.staging_height),
984            diff_file_list_width: Some(self.diff_file_list_width),
985            sidebar_expanded: Some(self.sidebar_expanded),
986            ui_scale: Some(self.ui_scale),
987            window_width: Some(self.window_width),
988            window_height: Some(self.window_height),
989            window_x: Some(self.window_x),
990            window_y: Some(self.window_y),
991            window_maximized: None, // not tracked
992            commit_ref_width: Some(self.commit_ref_width),
993            commit_graph_width: Some(self.commit_graph_width),
994        }
995    }
996}
997
998/// Convert a core [`gitkraft_core::Rgb`] to an [`iced::Color`].
999fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
1000    Color::from_rgb8(rgb.r, rgb.g, rgb.b)
1001}
1002
1003/// Try to detect the system's preferred editor from environment variables.
1004fn detect_system_editor() -> gitkraft_core::Editor {
1005    for var in ["VISUAL", "EDITOR"] {
1006        if let Ok(val) = std::env::var(var) {
1007            let bin = val.split('/').next_back().unwrap_or(&val).trim();
1008            return match bin {
1009                "nvim" | "neovim" => gitkraft_core::Editor::Neovim,
1010                "vim" => gitkraft_core::Editor::Vim,
1011                "hx" | "helix" => gitkraft_core::Editor::Helix,
1012                "nano" => gitkraft_core::Editor::Nano,
1013                "micro" => gitkraft_core::Editor::Micro,
1014                "emacs" => gitkraft_core::Editor::Emacs,
1015                "code" => gitkraft_core::Editor::VSCode,
1016                "zed" => gitkraft_core::Editor::Zed,
1017                "subl" => gitkraft_core::Editor::Sublime,
1018                _ => gitkraft_core::Editor::Custom(val),
1019            };
1020        }
1021    }
1022    gitkraft_core::Editor::None
1023}
1024
1025// ── Tests ─────────────────────────────────────────────────────────────────────
1026
1027#[cfg(test)]
1028mod tests {
1029    use super::*;
1030
1031    #[test]
1032    fn new_defaults() {
1033        let state = GitKraft::new();
1034        assert!(state.active_tab().repo_path.is_none());
1035        assert!(!state.has_repo());
1036        assert_eq!(state.repo_display_name(), "New Tab");
1037        assert!(state.active_tab().commits.is_empty());
1038        assert!(state.sidebar_expanded);
1039        // Default theme index should be valid
1040        assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
1041        // Pane defaults
1042        assert!(state.sidebar_width > 0.0);
1043        assert!(state.commit_log_width > 0.0);
1044        assert!(state.staging_height > 0.0);
1045        assert!(state.dragging.is_none());
1046        assert!(state.dragging_h.is_none());
1047        // Should start with one empty tab
1048        assert_eq!(state.tabs.len(), 1);
1049        assert_eq!(state.active_tab, 0);
1050    }
1051
1052    #[test]
1053    fn repo_display_name_extracts_basename() {
1054        let mut state = GitKraft::new();
1055        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
1056        assert_eq!(state.repo_display_name(), "my-project");
1057    }
1058
1059    #[test]
1060    fn colors_returns_theme_colors() {
1061        let state = GitKraft::new();
1062        let c = state.colors();
1063        // The default theme (index 0) is dark, so background should be dark
1064        assert!(c.bg.r < 0.5);
1065    }
1066
1067    #[test]
1068    fn iced_theme_is_custom_with_correct_palette() {
1069        let mut state = GitKraft::new();
1070
1071        // Index 0 = Default (dark) — custom theme with dark background
1072        state.current_theme_index = 0;
1073        let iced_t = state.iced_theme();
1074        let pal = iced_t.palette();
1075        assert!(pal.background.r < 0.5, "Default theme bg should be dark");
1076        assert_eq!(iced_t.to_string(), "Default");
1077
1078        // Solarized Light — custom theme with light background
1079        let sol_idx = gitkraft_core::theme_index_by_name("Solarized Light");
1080        state.current_theme_index = sol_idx;
1081        let iced_t = state.iced_theme();
1082        let pal = iced_t.palette();
1083        assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
1084        assert_eq!(iced_t.to_string(), "Solarized Light");
1085
1086        // Gruvbox Dark — accent should come from core
1087        let gruvbox_idx = gitkraft_core::theme_index_by_name("Gruvbox Dark");
1088        state.current_theme_index = gruvbox_idx;
1089        let iced_t = state.iced_theme();
1090        let pal = iced_t.palette();
1091        let core = gitkraft_core::theme_by_index(gruvbox_idx);
1092        let expected_accent = rgb_to_iced(core.accent);
1093        assert!(
1094            (pal.primary.r - expected_accent.r).abs() < 0.01
1095                && (pal.primary.g - expected_accent.g).abs() < 0.01
1096                && (pal.primary.b - expected_accent.b).abs() < 0.01,
1097            "Gruvbox Dark accent should match core accent"
1098        );
1099    }
1100
1101    #[test]
1102    fn iced_theme_name_round_trips_through_core() {
1103        // Ensure the custom theme name matches a core THEME_NAMES entry so
1104        // that ThemeColors::from_theme() can map it back to the right index.
1105        for i in 0..gitkraft_core::THEME_COUNT {
1106            let mut state = GitKraft::new();
1107            state.current_theme_index = i;
1108            let iced_t = state.iced_theme();
1109            let name = iced_t.to_string();
1110            let resolved = gitkraft_core::theme_index_by_name(&name);
1111            assert_eq!(
1112                resolved,
1113                i,
1114                "theme index {i} ({}) did not round-trip through iced_theme name",
1115                gitkraft_core::THEME_NAMES[i]
1116            );
1117        }
1118    }
1119
1120    #[test]
1121    fn current_theme_name_round_trips() {
1122        let mut state = GitKraft::new();
1123        state.current_theme_index = gitkraft_core::theme_index_by_name("Dracula");
1124        assert_eq!(state.current_theme_name(), "Dracula");
1125        state.current_theme_index = 0;
1126        assert_eq!(state.current_theme_name(), "Default");
1127    }
1128
1129    #[test]
1130    fn repo_tab_new_empty() {
1131        let tab = RepoTab::new_empty();
1132        assert!(tab.repo_path.is_none());
1133        assert!(!tab.has_repo());
1134        assert_eq!(tab.display_name(), "New Tab");
1135        assert!(tab.commits.is_empty());
1136        assert!(tab.branches.is_empty());
1137        assert!(!tab.is_loading);
1138    }
1139
1140    #[test]
1141    fn repo_tab_display_name_with_path() {
1142        let mut tab = RepoTab::new_empty();
1143        tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
1144        assert!(tab.has_repo());
1145        assert_eq!(tab.display_name(), "cool-repo");
1146    }
1147
1148    #[test]
1149    fn search_defaults() {
1150        let state = GitKraft::new();
1151        assert!(!state.search_visible);
1152        assert!(state.search_query.is_empty());
1153        assert!(state.search_results.is_empty());
1154        assert!(state.search_selected.is_none());
1155    }
1156
1157    #[test]
1158    fn context_menu_variants_exist() {
1159        // Verify all context menu variants can be constructed
1160        use crate::state::ContextMenu;
1161
1162        let _branch = ContextMenu::Branch {
1163            name: "main".to_string(),
1164            is_current: true,
1165            local_index: 0,
1166        };
1167        let _remote = ContextMenu::RemoteBranch {
1168            name: "origin/main".to_string(),
1169        };
1170        let _commit = ContextMenu::Commit {
1171            index: 0,
1172            oid: "abc1234".to_string(),
1173        };
1174        let _stash = ContextMenu::Stash { index: 0 };
1175        let _unstaged = ContextMenu::UnstagedFile {
1176            path: "src/main.rs".to_string(),
1177        };
1178        let _staged = ContextMenu::StagedFile {
1179            path: "src/lib.rs".to_string(),
1180        };
1181    }
1182
1183    #[test]
1184    fn repo_tab_context_menu_defaults_to_none() {
1185        let tab = crate::state::RepoTab::new_empty();
1186        assert!(tab.context_menu.is_none());
1187    }
1188
1189    #[test]
1190    fn context_menu_variants_constructable() {
1191        use crate::state::ContextMenu;
1192        let _ = ContextMenu::Stash { index: 0 };
1193        let _ = ContextMenu::UnstagedFile {
1194            path: "a.rs".into(),
1195        };
1196        let _ = ContextMenu::StagedFile {
1197            path: "b.rs".into(),
1198        };
1199    }
1200
1201    #[test]
1202    fn selected_unstaged_defaults_empty() {
1203        let tab = crate::state::RepoTab::new_empty();
1204        assert!(tab.selected_unstaged.is_empty());
1205        assert!(tab.selected_staged.is_empty());
1206    }
1207
1208    #[test]
1209    fn selected_unstaged_toggle() {
1210        let mut tab = crate::state::RepoTab::new_empty();
1211        tab.selected_unstaged.insert("a.rs".to_string());
1212        tab.selected_unstaged.insert("b.rs".to_string());
1213        assert_eq!(tab.selected_unstaged.len(), 2);
1214        assert!(tab.selected_unstaged.contains("a.rs"));
1215        tab.selected_unstaged.remove("a.rs");
1216        assert_eq!(tab.selected_unstaged.len(), 1);
1217        assert!(!tab.selected_unstaged.contains("a.rs"));
1218    }
1219
1220    #[test]
1221    fn detect_system_editor_returns_valid() {
1222        // Just verify it doesn't panic
1223        let editor = super::detect_system_editor();
1224        let _ = editor.display_name();
1225    }
1226
1227    // ── Multi-file commit diff selection ──────────────────────────────────
1228
1229    #[test]
1230    fn selected_commit_file_indices_defaults_to_empty_vec() {
1231        let tab = RepoTab::new_empty();
1232        assert!(tab.selected_commit_file_indices.is_empty());
1233        // Must be a Vec (ordered), not a HashSet — check it supports indexing
1234        let v: &Vec<usize> = &tab.selected_commit_file_indices;
1235        assert_eq!(v.len(), 0);
1236    }
1237
1238    #[test]
1239    fn multi_file_diffs_defaults_empty() {
1240        let tab = RepoTab::new_empty();
1241        assert!(tab.multi_file_diffs.is_empty());
1242    }
1243
1244    #[test]
1245    fn keyboard_modifiers_default_has_no_shift() {
1246        let state = GitKraft::new();
1247        assert!(!state.keyboard_modifiers.shift());
1248    }
1249
1250    #[test]
1251    fn selected_commit_file_indices_preserves_insertion_order() {
1252        let mut tab = RepoTab::new_empty();
1253        tab.selected_commit_file_indices.push(5);
1254        tab.selected_commit_file_indices.push(2);
1255        tab.selected_commit_file_indices.push(8);
1256        assert_eq!(tab.selected_commit_file_indices, vec![5, 2, 8]);
1257    }
1258
1259    #[test]
1260    fn selected_commit_file_indices_cleared_on_reset() {
1261        let mut tab = RepoTab::new_empty();
1262        tab.selected_commit_file_indices.push(0);
1263        tab.selected_commit_file_indices.push(1);
1264        tab.selected_commit_file_indices.clear();
1265        assert!(tab.selected_commit_file_indices.is_empty());
1266    }
1267
1268    #[test]
1269    fn multi_file_diffs_cleared_on_reset() {
1270        let mut tab = RepoTab::new_empty();
1271        tab.multi_file_diffs.push(gitkraft_core::DiffInfo {
1272            old_file: String::new(),
1273            new_file: "a.rs".to_string(),
1274            status: gitkraft_core::FileStatus::Modified,
1275            hunks: vec![],
1276        });
1277        tab.multi_file_diffs.clear();
1278        assert!(tab.multi_file_diffs.is_empty());
1279    }
1280
1281    #[test]
1282    fn commit_range_diffs_defaults_empty() {
1283        let tab = RepoTab::new_empty();
1284        assert!(tab.commit_range_diffs.is_empty());
1285    }
1286
1287    #[test]
1288    fn commit_range_diffs_cleared_on_apply_payload() {
1289        // verify the field is reset — just check it's accessible and clearable
1290        let mut tab = RepoTab::new_empty();
1291        tab.commit_range_diffs.push(gitkraft_core::DiffInfo {
1292            old_file: String::new(),
1293            new_file: "x.rs".to_string(),
1294            status: gitkraft_core::FileStatus::Modified,
1295            hunks: vec![],
1296        });
1297        tab.commit_range_diffs.clear();
1298        assert!(tab.commit_range_diffs.is_empty());
1299    }
1300
1301    // ── ModifiersChanged update ───────────────────────────────────────────
1302
1303    #[test]
1304    fn modifiers_changed_sets_shift_state() {
1305        use crate::message::Message;
1306        let mut state = GitKraft::new();
1307        assert!(!state.keyboard_modifiers.shift());
1308
1309        let _ = state.update(Message::ModifiersChanged(iced::keyboard::Modifiers::SHIFT));
1310        assert!(state.keyboard_modifiers.shift());
1311
1312        let _ = state.update(Message::ModifiersChanged(
1313            iced::keyboard::Modifiers::default(),
1314        ));
1315        assert!(!state.keyboard_modifiers.shift());
1316    }
1317
1318    // ── SelectDiffByIndex update ──────────────────────────────────────────
1319
1320    fn make_commit_files(names: &[&str]) -> Vec<gitkraft_core::DiffFileEntry> {
1321        names
1322            .iter()
1323            .map(|name| gitkraft_core::DiffFileEntry {
1324                old_file: String::new(),
1325                new_file: name.to_string(),
1326                status: gitkraft_core::FileStatus::Modified,
1327            })
1328            .collect()
1329    }
1330
1331    #[test]
1332    fn select_diff_by_index_regular_click_clears_multi_selection() {
1333        use crate::message::Message;
1334        let mut state = GitKraft::new();
1335        // Provide a repo_path and oid so the update handler can reach the
1336        // `selected_file_index = Some(index)` assignment (the async task it
1337        // spawns is dropped without execution — no real repo is needed).
1338        state.active_tab_mut().repo_path =
1339            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1340        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1341        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1342        // Pre-populate a multi-selection
1343        state.active_tab_mut().selected_commit_file_indices = vec![0, 1];
1344
1345        // Regular click (no Shift) — should collapse to single-file selection
1346        let _ = state.update(Message::SelectDiffByIndex(0));
1347
1348        assert!(state.active_tab().selected_commit_file_indices.is_empty());
1349        assert_eq!(state.active_tab().selected_file_index, Some(0));
1350    }
1351
1352    #[test]
1353    fn regular_click_preserves_multi_file_diffs_until_load_completes() {
1354        use crate::message::Message;
1355        let mut state = GitKraft::new();
1356        state.active_tab_mut().repo_path =
1357            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1358        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1359        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1360
1361        // Pre-populate multi_file_diffs as if user had a multi-file selection
1362        state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1363            old_file: String::new(),
1364            new_file: "a.rs".to_string(),
1365            status: gitkraft_core::FileStatus::Modified,
1366            hunks: vec![],
1367        }];
1368
1369        // Regular click — should NOT clear multi_file_diffs immediately
1370        // (deferred to SingleFileDiffLoaded to avoid visual blink).
1371        let _ = state.update(Message::SelectDiffByIndex(1));
1372
1373        assert!(
1374            !state.active_tab().multi_file_diffs.is_empty(),
1375            "multi_file_diffs must NOT be cleared on click (deferred to diff load)"
1376        );
1377        assert_eq!(state.active_tab().selected_file_index, Some(1));
1378        assert!(state.active_tab().is_loading_file_diff);
1379    }
1380
1381    #[test]
1382    fn regular_click_preserves_diff_scroll_until_load_completes() {
1383        use crate::message::Message;
1384        let mut state = GitKraft::new();
1385        state.active_tab_mut().repo_path =
1386            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1387        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1388        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1389        state.active_tab_mut().diff_scroll_offset = 150.0;
1390
1391        // Regular click — should NOT reset diff_scroll_offset immediately.
1392        let _ = state.update(Message::SelectDiffByIndex(1));
1393
1394        assert_eq!(
1395            state.active_tab().diff_scroll_offset,
1396            150.0,
1397            "diff_scroll_offset must NOT be reset on click (deferred to diff load)"
1398        );
1399    }
1400
1401    #[test]
1402    fn single_file_diff_loaded_clears_deferred_state() {
1403        use crate::message::Message;
1404        let mut state = GitKraft::new();
1405        state.active_tab_mut().repo_path =
1406            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1407        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1408        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1409
1410        // Pre-populate multi_file_diffs and commit_range_diffs
1411        state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1412            old_file: String::new(),
1413            new_file: "a.rs".to_string(),
1414            status: gitkraft_core::FileStatus::Modified,
1415            hunks: vec![],
1416        }];
1417        state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1418            old_file: String::new(),
1419            new_file: "b.rs".to_string(),
1420            status: gitkraft_core::FileStatus::Modified,
1421            hunks: vec![],
1422        }];
1423        state.active_tab_mut().is_loading_file_diff = true;
1424        state.active_tab_mut().diff_scroll_offset = 200.0;
1425
1426        // Simulate SingleFileDiffLoaded arriving with the new diff.
1427        let new_diff = gitkraft_core::DiffInfo {
1428            old_file: String::new(),
1429            new_file: "b.rs".to_string(),
1430            status: gitkraft_core::FileStatus::Modified,
1431            hunks: vec![],
1432        };
1433        let _ = state.update(Message::SingleFileDiffLoaded(Ok(new_diff)));
1434
1435        // All deferred state should now be cleared.
1436        assert!(
1437            state.active_tab().multi_file_diffs.is_empty(),
1438            "multi_file_diffs must be cleared when new diff arrives"
1439        );
1440        assert!(
1441            state.active_tab().commit_range_diffs.is_empty(),
1442            "commit_range_diffs must be cleared when new diff arrives"
1443        );
1444        assert!(
1445            state.active_tab().selected_diff.is_some(),
1446            "selected_diff must be set to the loaded diff"
1447        );
1448        assert!(
1449            !state.active_tab().is_loading_file_diff,
1450            "is_loading_file_diff must be cleared after load"
1451        );
1452        assert_eq!(
1453            state.active_tab().diff_scroll_offset,
1454            0.0,
1455            "diff_scroll_offset must be reset when new diff arrives"
1456        );
1457    }
1458
1459    #[test]
1460    fn regular_click_preserves_commit_range_diffs_until_load_completes() {
1461        use crate::message::Message;
1462        let mut state = GitKraft::new();
1463        state.active_tab_mut().repo_path =
1464            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1465        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1466        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1467
1468        // Pre-populate commit_range_diffs
1469        state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1470            old_file: String::new(),
1471            new_file: "x.rs".to_string(),
1472            status: gitkraft_core::FileStatus::Modified,
1473            hunks: vec![],
1474        }];
1475
1476        let _ = state.update(Message::SelectDiffByIndex(0));
1477
1478        assert!(
1479            !state.active_tab().commit_range_diffs.is_empty(),
1480            "commit_range_diffs must NOT be cleared on click (deferred to diff load)"
1481        );
1482    }
1483
1484    #[test]
1485    fn select_diff_by_index_shift_click_adds_both_files_to_selection() {
1486        use crate::message::Message;
1487        let mut state = GitKraft::new();
1488        state.active_tab_mut().repo_path =
1489            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1490        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1491        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1492        state.active_tab_mut().selected_file_index = Some(0);
1493
1494        // Shift+Click on file 1 should anchor 0 and add 1
1495        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1496        let _ = state.update(Message::SelectDiffByIndex(1));
1497
1498        let sel = &state.active_tab().selected_commit_file_indices;
1499        assert!(sel.contains(&0), "anchor file 0 should be selected");
1500        assert!(sel.contains(&1), "newly clicked file 1 should be selected");
1501        assert_eq!(sel.len(), 2);
1502    }
1503
1504    #[test]
1505    fn anchor_file_index_defaults_to_none() {
1506        let tab = RepoTab::new_empty();
1507        assert!(tab.anchor_file_index.is_none());
1508    }
1509
1510    #[test]
1511    fn regular_click_sets_anchor() {
1512        use crate::message::Message;
1513        let mut state = GitKraft::new();
1514        state.active_tab_mut().repo_path =
1515            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1516        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1517        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1518
1519        let _ = state.update(Message::SelectDiffByIndex(2));
1520
1521        assert_eq!(
1522            state.active_tab().anchor_file_index,
1523            Some(2),
1524            "regular click must set anchor to the clicked index"
1525        );
1526    }
1527
1528    #[test]
1529    fn shift_click_selects_range_downward_from_anchor() {
1530        use crate::message::Message;
1531        let mut state = GitKraft::new();
1532        state.active_tab_mut().repo_path =
1533            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1534        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1535        state.active_tab_mut().commit_files =
1536            make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1537        // Anchor at index 1
1538        state.active_tab_mut().anchor_file_index = Some(1);
1539        state.active_tab_mut().selected_file_index = Some(1);
1540
1541        // Shift+Click on index 4 — should select 1, 2, 3, 4
1542        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1543        let _ = state.update(Message::SelectDiffByIndex(4));
1544
1545        let sel = &state.active_tab().selected_commit_file_indices;
1546        assert_eq!(
1547            sel,
1548            &vec![1, 2, 3, 4],
1549            "range must be contiguous from anchor to click"
1550        );
1551    }
1552
1553    #[test]
1554    fn shift_click_selects_range_upward_from_anchor() {
1555        use crate::message::Message;
1556        let mut state = GitKraft::new();
1557        state.active_tab_mut().repo_path =
1558            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1559        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1560        state.active_tab_mut().commit_files =
1561            make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1562        // Anchor at index 4 (bottom)
1563        state.active_tab_mut().anchor_file_index = Some(4);
1564        state.active_tab_mut().selected_file_index = Some(4);
1565
1566        // Shift+Click on index 1 — should select 1, 2, 3, 4 (ascending)
1567        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1568        let _ = state.update(Message::SelectDiffByIndex(1));
1569
1570        let sel = &state.active_tab().selected_commit_file_indices;
1571        assert_eq!(
1572            sel,
1573            &vec![1, 2, 3, 4],
1574            "range must be stored ascending regardless of click direction"
1575        );
1576    }
1577
1578    #[test]
1579    fn shift_click_anchor_fixed_on_subsequent_clicks() {
1580        use crate::message::Message;
1581        let mut state = GitKraft::new();
1582        state.active_tab_mut().repo_path =
1583            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1584        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1585        state.active_tab_mut().commit_files =
1586            make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1587        // Anchor at index 2
1588        state.active_tab_mut().anchor_file_index = Some(2);
1589        state.active_tab_mut().selected_file_index = Some(2);
1590        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1591
1592        // First Shift+Click: extend to 4 → range {2, 3, 4}
1593        let _ = state.update(Message::SelectDiffByIndex(4));
1594        assert_eq!(
1595            state.active_tab().selected_commit_file_indices,
1596            vec![2, 3, 4]
1597        );
1598
1599        // Second Shift+Click: shrink back to 3 → range {2, 3} (anchor still 2)
1600        let _ = state.update(Message::SelectDiffByIndex(3));
1601        assert_eq!(
1602            state.active_tab().selected_commit_file_indices,
1603            vec![2, 3],
1604            "anchor must stay fixed; second Shift+Click shrinks the range"
1605        );
1606
1607        // Third Shift+Click: extend upward → range {0, 1, 2} (anchor still 2)
1608        let _ = state.update(Message::SelectDiffByIndex(0));
1609        assert_eq!(
1610            state.active_tab().selected_commit_file_indices,
1611            vec![0, 1, 2],
1612            "anchor must stay fixed; can extend range in either direction"
1613        );
1614    }
1615
1616    #[test]
1617    fn shift_click_on_anchor_itself_gives_single_item_range() {
1618        use crate::message::Message;
1619        let mut state = GitKraft::new();
1620        state.active_tab_mut().repo_path =
1621            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1622        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1623        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1624        state.active_tab_mut().anchor_file_index = Some(1);
1625        state.active_tab_mut().selected_file_index = Some(1);
1626
1627        // Shift+Click on the anchor itself → single-item range {1}
1628        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1629        let _ = state.update(Message::SelectDiffByIndex(1));
1630
1631        assert_eq!(state.active_tab().selected_commit_file_indices, vec![1]);
1632        assert!(
1633            state.active_tab().multi_file_diffs.is_empty(),
1634            "single-item range must not populate multi_file_diffs"
1635        );
1636    }
1637
1638    #[test]
1639    fn shift_click_range_is_always_ascending() {
1640        use crate::message::Message;
1641        let mut state = GitKraft::new();
1642        state.active_tab_mut().repo_path =
1643            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1644        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1645        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs"]);
1646        state.active_tab_mut().anchor_file_index = Some(3);
1647        state.active_tab_mut().selected_file_index = Some(3);
1648
1649        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1650        let _ = state.update(Message::SelectDiffByIndex(0));
1651
1652        let sel = &state.active_tab().selected_commit_file_indices;
1653        let is_sorted = sel.windows(2).all(|w| w[0] < w[1]);
1654        assert!(
1655            is_sorted,
1656            "selection must always be stored in ascending order"
1657        );
1658        assert_eq!(sel, &vec![0, 1, 2, 3]);
1659    }
1660
1661    #[test]
1662    fn checkout_file_at_commit_message_variants_exist() {
1663        use crate::message::Message;
1664        // Verify the new message variants can be constructed
1665        let _single =
1666            Message::CheckoutFileAtCommit("abc123".to_string(), "src/main.rs".to_string());
1667        let _multi = Message::CheckoutMultiFilesAtCommit(
1668            "abc123".to_string(),
1669            vec!["a.rs".to_string(), "b.rs".to_string()],
1670        );
1671    }
1672
1673    #[test]
1674    fn checkout_file_at_commit_closes_context_menu() {
1675        use crate::message::Message;
1676        let mut state = GitKraft::new();
1677        state.active_tab_mut().repo_path =
1678            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1679        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1680            oid: "abc123".to_string(),
1681            file_path: "src/main.rs".to_string(),
1682        });
1683        let _ = state.update(Message::CheckoutFileAtCommit(
1684            "abc123".to_string(),
1685            "src/main.rs".to_string(),
1686        ));
1687        assert!(state.active_tab().context_menu.is_none());
1688    }
1689
1690    #[test]
1691    fn checkout_multi_files_at_commit_closes_context_menu() {
1692        use crate::message::Message;
1693        let mut state = GitKraft::new();
1694        state.active_tab_mut().repo_path =
1695            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1696        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1697            oid: "abc123".to_string(),
1698            file_path: "src/main.rs".to_string(),
1699        });
1700        let _ = state.update(Message::CheckoutMultiFilesAtCommit(
1701            "abc123".to_string(),
1702            vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
1703        ));
1704        assert!(state.active_tab().context_menu.is_none());
1705    }
1706
1707    // ── Commit multi-selection ────────────────────────────────────────────
1708
1709    fn make_test_commits(count: usize) -> Vec<gitkraft_core::CommitInfo> {
1710        (0..count)
1711            .map(|i| gitkraft_core::CommitInfo {
1712                oid: i.to_string(),
1713                short_oid: i.to_string(),
1714                summary: String::new(),
1715                message: String::new(),
1716                author_name: String::new(),
1717                author_email: String::new(),
1718                time: Default::default(),
1719                parent_ids: Vec::new(),
1720                refs: Vec::new(),
1721            })
1722            .collect()
1723    }
1724
1725    #[test]
1726    fn selected_commits_defaults_empty() {
1727        let tab = RepoTab::new_empty();
1728        assert!(tab.selected_commits.is_empty());
1729        assert!(tab.anchor_commit_index.is_none());
1730    }
1731
1732    #[test]
1733    fn regular_click_commit_sets_anchor_and_clears_range() {
1734        use crate::message::Message;
1735        let mut state = GitKraft::new();
1736        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
1737        state.active_tab_mut().commits = make_test_commits(3);
1738        state.active_tab_mut().selected_commits = vec![0, 1, 2];
1739
1740        let _ = state.update(Message::SelectCommit(1));
1741
1742        assert_eq!(state.active_tab().anchor_commit_index, Some(1));
1743        assert!(state.active_tab().selected_commits.is_empty());
1744        assert_eq!(state.active_tab().selected_commit, Some(1));
1745    }
1746
1747    #[test]
1748    fn shift_click_commit_selects_range_from_anchor() {
1749        use crate::message::Message;
1750        let mut state = GitKraft::new();
1751        state.active_tab_mut().commits = make_test_commits(5);
1752        state.active_tab_mut().anchor_commit_index = Some(1);
1753        state.active_tab_mut().selected_commit = Some(1);
1754
1755        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1756        let _ = state.update(Message::SelectCommit(4));
1757
1758        assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3, 4]);
1759    }
1760
1761    #[test]
1762    fn shift_click_commit_range_is_ascending_when_clicking_above_anchor() {
1763        use crate::message::Message;
1764        let mut state = GitKraft::new();
1765        state.active_tab_mut().commits = make_test_commits(5);
1766        state.active_tab_mut().anchor_commit_index = Some(3);
1767        state.active_tab_mut().selected_commit = Some(3);
1768
1769        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1770        let _ = state.update(Message::SelectCommit(1));
1771
1772        assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3]);
1773    }
1774
1775    // ── ExecuteCommitAction message ───────────────────────────────────────
1776
1777    #[test]
1778    fn execute_commit_action_closes_context_menu() {
1779        use crate::message::Message;
1780        let mut state = GitKraft::new();
1781        state.active_tab_mut().repo_path =
1782            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1783        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::Commit {
1784            index: 0,
1785            oid: "abc123".to_string(),
1786        });
1787
1788        let _ = state.update(Message::ExecuteCommitAction(
1789            "abc123".to_string(),
1790            gitkraft_core::CommitAction::CherryPick,
1791        ));
1792
1793        assert!(state.active_tab().context_menu.is_none());
1794    }
1795
1796    #[test]
1797    fn execute_commit_action_sets_loading_when_repo_open() {
1798        use crate::message::Message;
1799        let mut state = GitKraft::new();
1800        state.active_tab_mut().repo_path =
1801            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1802
1803        let _ = state.update(Message::ExecuteCommitAction(
1804            "abc123".to_string(),
1805            gitkraft_core::CommitAction::ResetHard,
1806        ));
1807
1808        assert!(state.active_tab().is_loading);
1809    }
1810
1811    #[test]
1812    fn execute_commit_action_no_repo_does_not_set_loading() {
1813        use crate::message::Message;
1814        let mut state = GitKraft::new();
1815        // No repo_path set
1816
1817        let _ = state.update(Message::ExecuteCommitAction(
1818            "abc123".to_string(),
1819            gitkraft_core::CommitAction::CherryPick,
1820        ));
1821
1822        assert!(!state.active_tab().is_loading);
1823    }
1824
1825    #[test]
1826    fn execute_commit_action_sets_status_message_from_action_label() {
1827        use crate::message::Message;
1828        let mut state = GitKraft::new();
1829        state.active_tab_mut().repo_path =
1830            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1831
1832        let _ = state.update(Message::ExecuteCommitAction(
1833            "abc123".to_string(),
1834            gitkraft_core::CommitAction::Revert,
1835        ));
1836
1837        let status = state.active_tab().status_message.as_deref().unwrap_or("");
1838        // Status message should contain the action's label
1839        assert!(
1840            status.contains("Revert commit"),
1841            "expected status to contain 'Revert commit', got: {status:?}"
1842        );
1843    }
1844
1845    // ── File history / blame / delete state ──────────────────────────────
1846
1847    #[test]
1848    fn file_history_defaults_empty() {
1849        let tab = RepoTab::new_empty();
1850        assert!(tab.file_history_path.is_none());
1851        assert!(tab.file_history_commits.is_empty());
1852        assert_eq!(tab.file_history_scroll, 0.0);
1853    }
1854
1855    #[test]
1856    fn blame_defaults_empty() {
1857        let tab = RepoTab::new_empty();
1858        assert!(tab.blame_path.is_none());
1859        assert!(tab.blame_lines.is_empty());
1860        assert_eq!(tab.blame_scroll, 0.0);
1861    }
1862
1863    #[test]
1864    fn pending_delete_file_defaults_none() {
1865        let tab = RepoTab::new_empty();
1866        assert!(tab.pending_delete_file.is_none());
1867    }
1868
1869    #[test]
1870    fn view_file_history_sets_path_and_clears_blame() {
1871        use crate::message::Message;
1872        let mut state = GitKraft::new();
1873        state.active_tab_mut().repo_path =
1874            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1875        state.active_tab_mut().blame_path = Some("some/file.rs".to_string());
1876
1877        let _ = state.update(Message::ViewFileHistory("src/main.rs".to_string()));
1878
1879        assert_eq!(
1880            state.active_tab().file_history_path.as_deref(),
1881            Some("src/main.rs")
1882        );
1883        // Opening history should close blame
1884        assert!(state.active_tab().blame_path.is_none());
1885    }
1886
1887    #[test]
1888    fn close_file_history_clears_state() {
1889        use crate::message::Message;
1890        let mut state = GitKraft::new();
1891        state.active_tab_mut().file_history_path = Some("src/lib.rs".to_string());
1892        state.active_tab_mut().file_history_commits = vec![gitkraft_core::CommitInfo {
1893            oid: "abc".to_string(),
1894            short_oid: "abc".to_string(),
1895            summary: "s".to_string(),
1896            message: "s".to_string(),
1897            author_name: "a".to_string(),
1898            author_email: "a@b.com".to_string(),
1899            time: Default::default(),
1900            parent_ids: vec![],
1901            refs: Vec::new(),
1902        }];
1903
1904        let _ = state.update(Message::CloseFileHistory);
1905
1906        assert!(state.active_tab().file_history_path.is_none());
1907        assert!(state.active_tab().file_history_commits.is_empty());
1908    }
1909
1910    #[test]
1911    fn view_file_blame_sets_path_and_clears_history() {
1912        use crate::message::Message;
1913        let mut state = GitKraft::new();
1914        state.active_tab_mut().repo_path =
1915            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1916        state.active_tab_mut().file_history_path = Some("some/file.rs".to_string());
1917
1918        let _ = state.update(Message::ViewFileBlame("src/lib.rs".to_string()));
1919
1920        assert_eq!(state.active_tab().blame_path.as_deref(), Some("src/lib.rs"));
1921        // Opening blame should close history
1922        assert!(state.active_tab().file_history_path.is_none());
1923    }
1924
1925    #[test]
1926    fn selecting_new_commit_closes_blame_overlay() {
1927        use crate::message::Message;
1928        let mut state = GitKraft::new();
1929        state.active_tab_mut().repo_path =
1930            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1931        // Pre-populate a commit list so SelectCommit can find the commit.
1932        state.active_tab_mut().commits = vec![
1933            gitkraft_core::CommitInfo {
1934                oid: "abc1".into(),
1935                short_oid: "abc1".into(),
1936                summary: "first".into(),
1937                message: "first".into(),
1938                author_name: "A".into(),
1939                author_email: "a@a.com".into(),
1940                time: Default::default(),
1941                parent_ids: Vec::new(),
1942                refs: Vec::new(),
1943            },
1944            gitkraft_core::CommitInfo {
1945                oid: "abc2".into(),
1946                short_oid: "abc2".into(),
1947                summary: "second".into(),
1948                message: "second".into(),
1949                author_name: "A".into(),
1950                author_email: "a@a.com".into(),
1951                time: Default::default(),
1952                parent_ids: Vec::new(),
1953                refs: Vec::new(),
1954            },
1955        ];
1956        // Blame is currently open for a file from the first commit.
1957        state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1958        state.active_tab_mut().blame_lines = vec![gitkraft_core::BlameLine {
1959            line_number: 1,
1960            content: "fn main() {}".into(),
1961            short_oid: "abc1".into(),
1962            oid: "abc1".into(),
1963            author_name: "A".into(),
1964            time: Default::default(),
1965        }];
1966
1967        // Click a different commit — blame must close automatically.
1968        let _ = state.update(Message::SelectCommit(1));
1969
1970        assert!(
1971            state.active_tab().blame_path.is_none(),
1972            "blame_path must be cleared when a new commit is selected"
1973        );
1974        assert!(
1975            state.active_tab().blame_lines.is_empty(),
1976            "blame_lines must be cleared when a new commit is selected"
1977        );
1978    }
1979
1980    #[test]
1981    fn close_file_blame_clears_state() {
1982        use crate::message::Message;
1983        let mut state = GitKraft::new();
1984        state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1985
1986        let _ = state.update(Message::CloseFileBlame);
1987
1988        assert!(state.active_tab().blame_path.is_none());
1989        assert!(state.active_tab().blame_lines.is_empty());
1990    }
1991
1992    #[test]
1993    fn delete_file_sets_pending() {
1994        use crate::message::Message;
1995        let mut state = GitKraft::new();
1996
1997        let _ = state.update(Message::DeleteFile("src/old.rs".to_string()));
1998
1999        assert_eq!(
2000            state.active_tab().pending_delete_file.as_deref(),
2001            Some("src/old.rs")
2002        );
2003        assert!(state.active_tab().context_menu.is_none());
2004    }
2005
2006    #[test]
2007    fn cancel_delete_file_clears_pending() {
2008        use crate::message::Message;
2009        let mut state = GitKraft::new();
2010        state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
2011
2012        let _ = state.update(Message::CancelDeleteFile);
2013
2014        assert!(state.active_tab().pending_delete_file.is_none());
2015    }
2016
2017    #[test]
2018    fn confirm_delete_file_no_repo_is_noop() {
2019        use crate::message::Message;
2020        let mut state = GitKraft::new();
2021        state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
2022        // No repo_path → should not set is_loading
2023
2024        let _ = state.update(Message::ConfirmDeleteFile);
2025
2026        assert!(!state.active_tab().is_loading);
2027    }
2028
2029    #[test]
2030    fn shift_arrow_down_extends_file_list_selection_when_files_loaded() {
2031        use crate::message::Message;
2032        let mut state = GitKraft::new();
2033        state.active_tab_mut().repo_path =
2034            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
2035        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
2036        state.active_tab_mut().selected_file_index = Some(0);
2037        state.active_tab_mut().anchor_file_index = Some(0);
2038        // keyboard_modifiers must have SHIFT set for range selection to trigger
2039        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
2040
2041        let _ = state.update(Message::ShiftArrowDown);
2042
2043        assert_eq!(state.active_tab().selected_file_index, Some(1));
2044        // Range should now include both files
2045        assert!(state.active_tab().selected_commit_file_indices.contains(&0));
2046        assert!(state.active_tab().selected_commit_file_indices.contains(&1));
2047    }
2048
2049    #[test]
2050    fn shift_arrow_down_falls_through_to_commit_log_when_no_files() {
2051        use crate::message::Message;
2052        let mut state = GitKraft::new();
2053        state.active_tab_mut().commits = make_test_commits(5);
2054        state.active_tab_mut().selected_commit = Some(1);
2055        state.active_tab_mut().anchor_commit_index = Some(1);
2056        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
2057        // no commit_files
2058
2059        let _ = state.update(Message::ShiftArrowDown);
2060
2061        assert_eq!(state.active_tab().selected_commit, Some(2));
2062        assert!(state.active_tab().selected_commits.contains(&1));
2063        assert!(state.active_tab().selected_commits.contains(&2));
2064    }
2065
2066    #[test]
2067    fn file_system_changed_triggers_full_refresh() {
2068        use crate::message::Message;
2069        let mut state = GitKraft::new();
2070        state.active_tab_mut().repo_path =
2071            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
2072
2073        // FileSystemChanged should call refresh_active_tab() which returns
2074        // a non-none Task.  We verify by checking that is_loading is NOT set
2075        // synchronously (the task is async), but that no error is set either.
2076        let _task = state.update(Message::FileSystemChanged);
2077
2078        // With a repo_path set, the handler must have attempted a refresh
2079        // (it returns a Task, so is_loading is set by the task executor, not here).
2080        // What we CAN check: no error was set, and status is not "error".
2081        assert!(
2082            state.active_tab().error_message.is_none(),
2083            "FileSystemChanged must not set an error message"
2084        );
2085    }
2086
2087    #[test]
2088    fn refresh_active_tab_skips_when_already_loading() {
2089        // When is_loading is true (e.g. a manual Ctrl+R refresh is in flight),
2090        // refresh_active_tab must return Task::none() to avoid spawning a
2091        // duplicate concurrent refresh.
2092        let mut state = GitKraft::new();
2093        state.active_tab_mut().repo_path =
2094            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
2095        state.active_tab_mut().is_loading = true;
2096
2097        // With is_loading set, refresh_active_tab should be a no-op.
2098        let _task = state.refresh_active_tab();
2099        // The tab must still be loading (unchanged).
2100        assert!(state.active_tab().is_loading);
2101    }
2102
2103    // ── Tab duplication prevention tests ───────────────────────────────────
2104
2105    /// Helper: build a minimal `RepoPayload` (aka `RepoSnapshot`) for a given path.
2106    fn fake_payload(workdir: &str) -> crate::message::RepoPayload {
2107        gitkraft_core::RepoSnapshot {
2108            info: gitkraft_core::RepoInfo {
2109                path: std::path::PathBuf::from(format!("{workdir}/.git")),
2110                workdir: Some(std::path::PathBuf::from(workdir)),
2111                head_branch: Some("main".into()),
2112                is_bare: false,
2113                state: gitkraft_core::RepoState::Clean,
2114            },
2115            branches: Vec::new(),
2116            commits: Vec::new(),
2117            graph_rows: Vec::new(),
2118            unstaged: Vec::new(),
2119            staged: Vec::new(),
2120            stashes: Vec::new(),
2121            remotes: Vec::new(),
2122        }
2123    }
2124
2125    /// Helper: set up a tab as if a repo was fully loaded.
2126    fn setup_loaded_tab(tab: &mut RepoTab, path: &str) {
2127        tab.repo_path = Some(std::path::PathBuf::from(path));
2128        tab.repo_info = Some(gitkraft_core::RepoInfo {
2129            path: std::path::PathBuf::from(format!("{path}/.git")),
2130            workdir: Some(std::path::PathBuf::from(path)),
2131            head_branch: Some("main".into()),
2132            is_bare: false,
2133            state: gitkraft_core::RepoState::Clean,
2134        });
2135    }
2136
2137    #[test]
2138    fn open_repo_creates_new_tab_when_repo_already_open() {
2139        use crate::message::Message;
2140        let mut state = GitKraft::new();
2141        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2142
2143        assert_eq!(state.tabs.len(), 1);
2144        assert_eq!(state.active_tab, 0);
2145
2146        // Clicking "Open" should create a new tab when the current has a repo.
2147        let _task = state.update(Message::OpenRepo);
2148
2149        assert_eq!(state.tabs.len(), 2);
2150        assert_eq!(state.active_tab, 1);
2151        // The new tab should be loading (folder picker opening).
2152        assert!(state.tabs[1].is_loading);
2153        // The original tab should be untouched.
2154        assert_eq!(
2155            state.tabs[0].repo_path.as_deref(),
2156            Some(std::path::Path::new("/home/user/repo-a"))
2157        );
2158    }
2159
2160    #[test]
2161    fn open_repo_reuses_empty_tab() {
2162        use crate::message::Message;
2163        let mut state = GitKraft::new();
2164        // Active tab is empty (no repo loaded).
2165        assert!(!state.active_tab().has_repo());
2166
2167        let _task = state.update(Message::OpenRepo);
2168
2169        // Should NOT create a new tab when the active tab is empty.
2170        assert_eq!(state.tabs.len(), 1);
2171        assert_eq!(state.active_tab, 0);
2172        assert!(state.tabs[0].is_loading);
2173    }
2174
2175    #[test]
2176    fn repo_selected_deduplicates_already_open_repo() {
2177        use crate::message::Message;
2178        let mut state = GitKraft::new();
2179        // Tab 0: fully loaded repo-a
2180        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2181        // Tab 1: empty (simulates the new tab created by OpenRepo)
2182        state.tabs.push(RepoTab::new_empty());
2183        state.active_tab = 1;
2184
2185        // User selects a folder that matches the already-open repo.
2186        let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2187            "/home/user/repo-a",
2188        ))));
2189
2190        // Should switch to the existing tab and remove the empty one.
2191        assert_eq!(state.tabs.len(), 1);
2192        assert_eq!(state.active_tab, 0);
2193        assert_eq!(
2194            state.tabs[0].repo_path.as_deref(),
2195            Some(std::path::Path::new("/home/user/repo-a"))
2196        );
2197    }
2198
2199    #[test]
2200    fn repo_selected_opens_new_repo_in_empty_tab() {
2201        use crate::message::Message;
2202        let mut state = GitKraft::new();
2203        // Tab 0: fully loaded repo-a
2204        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2205        // Tab 1: empty (simulates the new tab created by OpenRepo)
2206        state.tabs.push(RepoTab::new_empty());
2207        state.active_tab = 1;
2208
2209        // User selects a DIFFERENT repo.
2210        let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2211            "/home/user/repo-b",
2212        ))));
2213
2214        // Should keep both tabs; the empty tab is now loading repo-b.
2215        assert_eq!(state.tabs.len(), 2);
2216        assert_eq!(state.active_tab, 1);
2217        assert!(state.tabs[1]
2218            .status_message
2219            .as_deref()
2220            .unwrap_or("")
2221            .contains("repo-b"));
2222    }
2223
2224    #[test]
2225    fn repo_selected_cancel_removes_empty_tab() {
2226        use crate::message::Message;
2227        let mut state = GitKraft::new();
2228        // Tab 0: fully loaded repo-a
2229        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2230        // Tab 1: empty (created by OpenRepo, waiting for folder picker)
2231        state.tabs.push(RepoTab::new_empty());
2232        state.active_tab = 1;
2233
2234        // User cancels the folder picker.
2235        let _task = state.update(Message::RepoSelected(None));
2236
2237        // The empty tab should be removed; switch back to tab 0.
2238        assert_eq!(state.tabs.len(), 1);
2239        assert_eq!(state.active_tab, 0);
2240        assert_eq!(
2241            state.tabs[0].repo_path.as_deref(),
2242            Some(std::path::Path::new("/home/user/repo-a"))
2243        );
2244    }
2245
2246    #[test]
2247    fn repo_selected_cancel_keeps_tab_if_only_one() {
2248        use crate::message::Message;
2249        let mut state = GitKraft::new();
2250        // Single empty tab — shouldn't be removed on cancel.
2251        assert_eq!(state.tabs.len(), 1);
2252        assert!(!state.active_tab().has_repo());
2253
2254        let _task = state.update(Message::RepoSelected(None));
2255
2256        assert_eq!(state.tabs.len(), 1);
2257        assert!(!state.active_tab().is_loading);
2258    }
2259
2260    #[test]
2261    fn open_recent_repo_deduplicates() {
2262        use crate::message::Message;
2263        let mut state = GitKraft::new();
2264        // Tab 0: fully loaded repo-a
2265        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2266        // Tab 1: empty tab (simulates user clicking "+")
2267        state.tabs.push(RepoTab::new_empty());
2268        state.active_tab = 1;
2269
2270        // Opening a recent repo that's already open should switch to it
2271        // AND remove the empty tab the user was on.
2272        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2273            "/home/user/repo-a",
2274        )));
2275
2276        assert_eq!(state.tabs.len(), 1, "empty tab must be removed");
2277        assert_eq!(state.active_tab, 0);
2278        assert_eq!(
2279            state.active_tab().repo_path.as_deref(),
2280            Some(std::path::Path::new("/home/user/repo-a")),
2281        );
2282    }
2283
2284    #[test]
2285    fn open_recent_repo_creates_new_tab_when_current_has_repo() {
2286        use crate::message::Message;
2287        let mut state = GitKraft::new();
2288        // Tab 0: fully loaded repo-a
2289        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2290
2291        // Opening a different recent repo should create a new tab.
2292        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2293            "/home/user/repo-b",
2294        )));
2295
2296        assert_eq!(state.tabs.len(), 2);
2297        assert_eq!(state.active_tab, 1);
2298        assert!(state.tabs[1].is_loading);
2299    }
2300
2301    #[test]
2302    fn open_recent_repo_uses_empty_tab() {
2303        use crate::message::Message;
2304        let mut state = GitKraft::new();
2305        // Active tab is empty.
2306        assert!(!state.active_tab().has_repo());
2307
2308        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2309            "/home/user/repo-b",
2310        )));
2311
2312        // Should NOT create a new tab.
2313        assert_eq!(state.tabs.len(), 1);
2314        assert_eq!(state.active_tab, 0);
2315        assert!(state.tabs[0].is_loading);
2316    }
2317
2318    #[test]
2319    fn open_recent_repo_dedup_removes_empty_tab_with_multiple_loaded() {
2320        // Reproduces: user has [A, B*, C], clicks "+" to get [A, B, C, Empty*],
2321        // then clicks recent repo B → should end up with [A, B*, C] (no empty tab).
2322        use crate::message::Message;
2323        let mut state = GitKraft::new();
2324        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2325        state.tabs.push(RepoTab::new_empty());
2326        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2327        state.tabs.push(RepoTab::new_empty());
2328        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2329        // Simulate clicking "+" to add an empty tab
2330        state.tabs.push(RepoTab::new_empty());
2331        state.active_tab = 3;
2332
2333        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2334            "/home/user/repo-b",
2335        )));
2336
2337        assert_eq!(state.tabs.len(), 3, "empty tab must be cleaned up");
2338        assert_eq!(state.active_tab, 1);
2339        assert_eq!(
2340            state.active_tab().repo_path.as_deref(),
2341            Some(std::path::Path::new("/home/user/repo-b")),
2342        );
2343    }
2344
2345    // ── Refresh race-condition tests ──────────────────────────────────────
2346
2347    #[test]
2348    fn repo_refreshed_targets_correct_tab_after_tab_switch() {
2349        use crate::message::Message;
2350        let mut state = GitKraft::new();
2351        // Tab 0: repo-a fully loaded
2352        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2353        // Tab 1: empty tab (user clicked "+")
2354        state.tabs.push(RepoTab::new_empty());
2355        state.active_tab = 1; // user switched to new empty tab
2356
2357        // Simulate: a RepoRefreshed result arrives for repo-a while tab 1 is active.
2358        let payload = fake_payload("/home/user/repo-a");
2359        let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2360
2361        // The payload must land in tab 0 (which owns repo-a), NOT tab 1.
2362        assert!(
2363            state.tabs[0].repo_info.is_some(),
2364            "tab 0 should still have repo info after refresh"
2365        );
2366        assert_eq!(
2367            state.tabs[0].current_branch.as_deref(),
2368            Some("main"),
2369            "tab 0 should have updated branch from payload"
2370        );
2371        // Tab 1 must remain empty.
2372        assert!(
2373            state.tabs[1].repo_info.is_none(),
2374            "tab 1 (empty) must NOT receive the refresh payload"
2375        );
2376        assert!(
2377            state.tabs[1].repo_path.is_none(),
2378            "tab 1 should still have no repo path"
2379        );
2380    }
2381
2382    #[test]
2383    fn repo_refreshed_targets_active_tab_for_new_open() {
2384        use crate::message::Message;
2385        let mut state = GitKraft::new();
2386        // Active tab is empty — user just opened a brand-new repo.
2387        // (RepoSelected set the status but the tab doesn't have repo_path yet
2388        // matching the payload, so it falls back to active tab.)
2389        assert_eq!(state.tabs.len(), 1);
2390        assert!(!state.active_tab().has_repo());
2391
2392        let payload = fake_payload("/home/user/new-repo");
2393        let _task = state.update(Message::RepoOpened(Ok(payload)));
2394
2395        // Should have applied to the active tab (the only tab).
2396        assert_eq!(
2397            state.tabs[0].repo_path.as_deref(),
2398            Some(std::path::Path::new("/home/user/new-repo"))
2399        );
2400        assert!(state.tabs[0].repo_info.is_some());
2401    }
2402
2403    #[test]
2404    fn repo_refreshed_does_not_duplicate_into_new_tab() {
2405        use crate::message::Message;
2406        let mut state = GitKraft::new();
2407        // Tab 0: repo-a loaded
2408        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2409
2410        // User clicks "+" creating an empty tab 1, then switches to it.
2411        let _task = state.update(Message::NewTab);
2412        assert_eq!(state.tabs.len(), 2);
2413        assert_eq!(state.active_tab, 1);
2414
2415        // A refresh result for repo-a arrives (was triggered before the tab switch).
2416        let payload = fake_payload("/home/user/repo-a");
2417        let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2418
2419        // Tab 1 must remain empty — the payload should go to tab 0.
2420        assert!(
2421            state.tabs[1].repo_path.is_none(),
2422            "new empty tab must not receive repo-a refresh"
2423        );
2424        assert!(
2425            state.tabs[1].repo_info.is_none(),
2426            "new empty tab must not have repo_info"
2427        );
2428        // Tab 0 must have the refreshed data.
2429        assert_eq!(
2430            state.tabs[0].repo_path.as_deref(),
2431            Some(std::path::Path::new("/home/user/repo-a"))
2432        );
2433    }
2434
2435    #[test]
2436    fn git_operation_result_targets_correct_tab() {
2437        use crate::message::Message;
2438        let mut state = GitKraft::new();
2439        // Tab 0: repo-a
2440        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2441        // Tab 1: repo-b
2442        state.tabs.push(RepoTab::new_empty());
2443        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2444        state.active_tab = 1;
2445
2446        // A git operation result arrives for repo-a (e.g. push completed)
2447        // while user is on tab 1.
2448        let payload = fake_payload("/home/user/repo-a");
2449        let _task = state.update(Message::GitOperationResult(Ok(payload)));
2450
2451        // Payload should land in tab 0 (repo-a), not tab 1.
2452        assert_eq!(state.tabs[0].current_branch.as_deref(), Some("main"));
2453        // Tab 1's data should remain unchanged (still repo-b).
2454        assert_eq!(
2455            state.tabs[1].repo_path.as_deref(),
2456            Some(std::path::Path::new("/home/user/repo-b"))
2457        );
2458    }
2459
2460    #[test]
2461    fn multiple_new_tabs_dont_get_polluted_by_refresh() {
2462        use crate::message::Message;
2463        let mut state = GitKraft::new();
2464        // Tab 0: repo-a loaded
2465        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2466
2467        // User creates multiple new tabs.
2468        let _task = state.update(Message::NewTab);
2469        let _task = state.update(Message::NewTab);
2470        assert_eq!(state.tabs.len(), 3);
2471        assert_eq!(state.active_tab, 2);
2472
2473        // Refresh arrives for repo-a.
2474        let payload = fake_payload("/home/user/repo-a");
2475        let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2476
2477        // Only tab 0 should be affected.
2478        assert!(state.tabs[0].repo_info.is_some());
2479        assert!(state.tabs[1].repo_info.is_none());
2480        assert!(state.tabs[2].repo_info.is_none());
2481        assert!(state.tabs[1].repo_path.is_none());
2482        assert!(state.tabs[2].repo_path.is_none());
2483    }
2484
2485    #[test]
2486    fn repo_selected_dedup_adjusts_index_when_existing_is_after_active() {
2487        use crate::message::Message;
2488        let mut state = GitKraft::new();
2489        // Tab 0: empty (newly created by OpenRepo)
2490        // Tab 1: repo-a loaded
2491        state.tabs.push(RepoTab::new_empty());
2492        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
2493        state.active_tab = 0;
2494
2495        // User selects the same folder as repo-a.
2496        let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2497            "/home/user/repo-a",
2498        ))));
2499
2500        // Empty tab 0 should be removed; we should now be on tab 0 (formerly tab 1).
2501        assert_eq!(state.tabs.len(), 1);
2502        assert_eq!(state.active_tab, 0);
2503        assert_eq!(
2504            state.tabs[0].repo_path.as_deref(),
2505            Some(std::path::Path::new("/home/user/repo-a"))
2506        );
2507    }
2508
2509    // ── Multi-selection preservation across refresh ───────────────────────
2510
2511    #[test]
2512    fn apply_payload_preserves_multi_selection_by_oid() {
2513        let mut tab = RepoTab::new_empty();
2514        tab.commits = make_test_commits(5);
2515        // Simulate user selecting commits 1..=3 with Shift+click.
2516        tab.anchor_commit_index = Some(1);
2517        tab.selected_commits = vec![1, 2, 3];
2518        tab.selected_commit = Some(3);
2519        tab.selected_commit_oid = Some(tab.commits[3].oid.clone());
2520
2521        // Build a payload with the same commits (simulating a background refresh).
2522        let mut payload = fake_payload("/tmp/repo");
2523        payload.commits = make_test_commits(5);
2524
2525        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2526
2527        // The multi-selection must survive the refresh.
2528        assert_eq!(tab.anchor_commit_index, Some(1));
2529        assert_eq!(tab.selected_commits, vec![1, 2, 3]);
2530        assert_eq!(tab.selected_commit, Some(3));
2531    }
2532
2533    #[test]
2534    fn apply_payload_preserves_anchor_even_without_range() {
2535        let mut tab = RepoTab::new_empty();
2536        tab.commits = make_test_commits(5);
2537        tab.anchor_commit_index = Some(2);
2538        tab.selected_commit = Some(2);
2539        tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2540        // No range selection — just a single click with anchor set.
2541
2542        let mut payload = fake_payload("/tmp/repo");
2543        payload.commits = make_test_commits(5);
2544
2545        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2546
2547        assert_eq!(tab.anchor_commit_index, Some(2));
2548        assert_eq!(tab.selected_commit, Some(2));
2549    }
2550
2551    #[test]
2552    fn apply_payload_clears_selection_when_commits_disappear() {
2553        let mut tab = RepoTab::new_empty();
2554        tab.commits = make_test_commits(5);
2555        tab.anchor_commit_index = Some(2);
2556        tab.selected_commits = vec![2, 3, 4];
2557        tab.selected_commit = Some(4);
2558        tab.selected_commit_oid = Some(tab.commits[4].oid.clone());
2559
2560        // Payload has completely different commits (e.g. force-push).
2561        let mut payload = fake_payload("/tmp/repo");
2562        payload.commits = (0..3)
2563            .map(|i| gitkraft_core::CommitInfo {
2564                oid: format!("new_oid_{i}"),
2565                short_oid: format!("new_{i}"),
2566                summary: format!("new commit {i}"),
2567                message: String::new(),
2568                author_name: "Author".into(),
2569                author_email: "a@b.c".into(),
2570                time: Default::default(),
2571                parent_ids: Vec::new(),
2572                refs: Vec::new(),
2573            })
2574            .collect();
2575
2576        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2577
2578        // All old OIDs are gone — selection should be cleared.
2579        assert!(tab.selected_commits.is_empty());
2580        assert!(tab.anchor_commit_index.is_none());
2581        assert!(tab.selected_commit.is_none());
2582    }
2583
2584    // ── File multi-selection preservation across refresh ─────────────────
2585
2586    #[test]
2587    fn apply_payload_preserves_file_selection_when_commit_survives() {
2588        let mut tab = RepoTab::new_empty();
2589        tab.commits = make_test_commits(3);
2590        tab.selected_commit = Some(1);
2591        tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2592        tab.commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
2593
2594        // Simulate user Shift+clicking files 0..=2.
2595        tab.anchor_file_index = Some(0);
2596        tab.selected_commit_file_indices = vec![0, 1, 2];
2597        tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2598            old_file: String::new(),
2599            new_file: "a.rs".into(),
2600            status: gitkraft_core::FileStatus::Modified,
2601            hunks: vec![],
2602        }];
2603
2604        // Background refresh with the same commits.
2605        let mut payload = fake_payload("/tmp/repo");
2606        payload.commits = make_test_commits(3);
2607
2608        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2609
2610        // File selection must survive.
2611        assert_eq!(tab.anchor_file_index, Some(0));
2612        assert_eq!(tab.selected_commit_file_indices, vec![0, 1, 2]);
2613        assert!(
2614            !tab.multi_file_diffs.is_empty(),
2615            "multi_file_diffs must survive when commit is preserved"
2616        );
2617    }
2618
2619    #[test]
2620    fn apply_payload_clears_file_selection_when_commit_disappears() {
2621        let mut tab = RepoTab::new_empty();
2622        tab.commits = make_test_commits(3);
2623        tab.selected_commit = Some(1);
2624        tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2625        tab.commit_files = make_commit_files(&["a.rs", "b.rs"]);
2626        tab.anchor_file_index = Some(0);
2627        tab.selected_commit_file_indices = vec![0, 1];
2628        tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2629            old_file: String::new(),
2630            new_file: "a.rs".into(),
2631            status: gitkraft_core::FileStatus::Modified,
2632            hunks: vec![],
2633        }];
2634
2635        // Payload with completely different commits (force-push).
2636        let mut payload = fake_payload("/tmp/repo");
2637        payload.commits = (0..2)
2638            .map(|i| gitkraft_core::CommitInfo {
2639                oid: format!("new_{i}"),
2640                short_oid: format!("n{i}"),
2641                summary: String::new(),
2642                message: String::new(),
2643                author_name: String::new(),
2644                author_email: String::new(),
2645                time: Default::default(),
2646                parent_ids: Vec::new(),
2647                refs: Vec::new(),
2648            })
2649            .collect();
2650
2651        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2652
2653        // Commit is gone → file selection must be cleared.
2654        assert!(tab.anchor_file_index.is_none());
2655        assert!(tab.selected_commit_file_indices.is_empty());
2656        assert!(tab.multi_file_diffs.is_empty());
2657        assert!(tab.commit_files.is_empty());
2658    }
2659
2660    #[test]
2661    fn apply_payload_preserves_commit_range_diffs_when_commit_survives() {
2662        let mut tab = RepoTab::new_empty();
2663        tab.commits = make_test_commits(5);
2664        tab.selected_commit = Some(2);
2665        tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2666        tab.commit_range_diffs = vec![gitkraft_core::DiffInfo {
2667            old_file: String::new(),
2668            new_file: "x.rs".into(),
2669            status: gitkraft_core::FileStatus::Modified,
2670            hunks: vec![],
2671        }];
2672
2673        let mut payload = fake_payload("/tmp/repo");
2674        payload.commits = make_test_commits(5);
2675
2676        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2677
2678        assert!(
2679            !tab.commit_range_diffs.is_empty(),
2680            "commit_range_diffs must survive when commit is preserved"
2681        );
2682    }
2683
2684    #[test]
2685    fn apply_payload_preserves_commit_message() {
2686        // Regression test: the git watcher triggers refreshes while the user
2687        // is typing a commit message.  apply_payload must NOT clear it.
2688        let mut tab = RepoTab::new_empty();
2689        tab.repo_path = Some(std::path::PathBuf::from("/tmp/repo"));
2690        tab.commit_message = "WIP: fixing the bug".to_string();
2691
2692        let payload = fake_payload("/tmp/repo");
2693        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2694
2695        assert_eq!(
2696            tab.commit_message, "WIP: fixing the bug",
2697            "commit_message must survive background repo refreshes"
2698        );
2699    }
2700
2701    // ── PreviewFile / OpenFiles message tests ─────────────────────────────
2702
2703    #[test]
2704    fn preview_file_loaded_sets_preview_editor() {
2705        use crate::message::Message;
2706        let mut state = GitKraft::new();
2707        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2708
2709        let content = "line 1\nline 2\nline 3".to_string();
2710        let _ = state.update(Message::FilePreviewLoaded(Ok((
2711            "test.rs".to_string(),
2712            content,
2713        ))));
2714
2715        assert!(state.preview_editor.is_some());
2716        assert_eq!(state.preview_path.as_deref(), Some("test.rs"));
2717        // The diff view should be cleared so the preview takes over.
2718        assert!(state.active_tab().selected_diff.is_none());
2719    }
2720
2721    #[test]
2722    fn preview_file_loaded_error_sets_error_message() {
2723        use crate::message::Message;
2724        let mut state = GitKraft::new();
2725        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2726
2727        let _ = state.update(Message::FilePreviewLoaded(Err("No such file".to_string())));
2728
2729        assert!(state.active_tab().error_message.is_some());
2730        assert!(state
2731            .active_tab()
2732            .error_message
2733            .as_ref()
2734            .unwrap()
2735            .contains("No such file"));
2736    }
2737
2738    #[test]
2739    fn close_preview_clears_editor_state() {
2740        use crate::message::Message;
2741        let mut state = GitKraft::new();
2742        state.preview_editor = Some(iced::widget::text_editor::Content::with_text("hello"));
2743        state.preview_path = Some("/test/file.txt".to_string());
2744
2745        let _ = state.update(Message::ClosePreview);
2746
2747        assert!(state.preview_editor.is_none());
2748        assert!(state.preview_path.is_none());
2749    }
2750
2751    // ── CloseRepo ─────────────────────────────────────────────────────────────
2752
2753    #[test]
2754    fn close_repo_single_tab_shows_welcome_screen() {
2755        // When only one tab is open, CloseRepo should empty the tab (show
2756        // welcome screen) rather than removing it — there must always be at
2757        // least one tab.
2758        use crate::message::Message;
2759        let mut state = GitKraft::new();
2760        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2761        assert_eq!(state.tabs.len(), 1);
2762
2763        let _ = state.update(Message::CloseRepo);
2764
2765        assert_eq!(state.tabs.len(), 1, "last tab must not be removed");
2766        assert_eq!(state.active_tab, 0);
2767        assert!(!state.active_tab().has_repo(), "tab should be empty");
2768    }
2769
2770    #[test]
2771    fn close_repo_with_multiple_tabs_removes_current_tab() {
2772        // The core of the reported bug: when the currently selected repo is
2773        // closed and there are other tabs open, the CURRENT tab should be
2774        // removed and the user should land on the remaining tab — NOT have the
2775        // current tab emptied in place (which left the other repo displaced).
2776        //
2777        // Before fix: CloseRepo replaced active tab with empty, keeping it
2778        //   selected, so the user saw a blank welcome screen while the other
2779        //   repo was still in the tab bar but not visible → perceived as
2780        //   "removes the other one and keeps the current one selected".
2781        // After fix: active tab is removed, user lands on the other repo.
2782        use crate::message::Message;
2783        let mut state = GitKraft::new();
2784        // Tab 0: repo-a (will be the active/selected tab)
2785        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2786        // Tab 1: repo-b
2787        state.tabs.push(RepoTab::new_empty());
2788        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2789        // repo-a (tab 0) is the currently selected tab
2790        state.active_tab = 0;
2791
2792        let _ = state.update(Message::CloseRepo);
2793
2794        // The current tab (repo-a) must be gone; repo-b must be the only
2795        // remaining tab and must be active.
2796        assert_eq!(state.tabs.len(), 1, "current tab must be removed");
2797        assert_eq!(state.active_tab, 0);
2798        assert_eq!(
2799            state.active_tab().repo_path.as_deref(),
2800            Some(std::path::Path::new("/home/user/repo-b")),
2801            "repo-b should be the surviving tab"
2802        );
2803    }
2804
2805    #[test]
2806    fn close_repo_second_tab_active_removes_it_and_keeps_first() {
2807        // Same scenario but with the SECOND tab as the active (selected) one —
2808        // the first tab must survive and become the new active tab.
2809        use crate::message::Message;
2810        let mut state = GitKraft::new();
2811        // Tab 0: repo-a
2812        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2813        // Tab 1: repo-b (active)
2814        state.tabs.push(RepoTab::new_empty());
2815        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2816        state.active_tab = 1; // repo-b is the currently selected tab
2817
2818        let _ = state.update(Message::CloseRepo);
2819
2820        assert_eq!(state.tabs.len(), 1);
2821        assert_eq!(state.active_tab, 0);
2822        assert_eq!(
2823            state.active_tab().repo_path.as_deref(),
2824            Some(std::path::Path::new("/home/user/repo-a")),
2825            "repo-a should be the surviving tab"
2826        );
2827    }
2828
2829    // ── CloseTab ──────────────────────────────────────────────────────────
2830
2831    #[test]
2832    fn close_tab_active_tab_first_position() {
2833        // active_tab=0, close tab 0 → tab 1 (repo-b) becomes active at index 0.
2834        use crate::message::Message;
2835        let mut state = GitKraft::new();
2836        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2837        state.tabs.push(RepoTab::new_empty());
2838        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2839        state.active_tab = 0;
2840
2841        let _ = state.update(Message::CloseTab(0));
2842
2843        assert_eq!(state.tabs.len(), 1);
2844        assert_eq!(state.active_tab, 0);
2845        assert_eq!(
2846            state.active_tab().repo_path.as_deref(),
2847            Some(std::path::Path::new("/home/user/repo-b"))
2848        );
2849    }
2850
2851    #[test]
2852    fn close_tab_active_tab_second_position() {
2853        // active_tab=1, close tab 1 → tab 0 (repo-a) becomes active.
2854        use crate::message::Message;
2855        let mut state = GitKraft::new();
2856        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2857        state.tabs.push(RepoTab::new_empty());
2858        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2859        state.active_tab = 1;
2860
2861        let _ = state.update(Message::CloseTab(1));
2862
2863        assert_eq!(state.tabs.len(), 1);
2864        assert_eq!(state.active_tab, 0);
2865        assert_eq!(
2866            state.active_tab().repo_path.as_deref(),
2867            Some(std::path::Path::new("/home/user/repo-a"))
2868        );
2869    }
2870
2871    #[test]
2872    fn close_tab_non_active_tab_before_active() {
2873        // active_tab=1, close tab 0 → repo-b stays active but shifts to 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 = 1;
2880
2881        let _ = state.update(Message::CloseTab(0));
2882
2883        assert_eq!(state.tabs.len(), 1);
2884        assert_eq!(state.active_tab, 0, "active_tab must shift down by 1");
2885        assert_eq!(
2886            state.active_tab().repo_path.as_deref(),
2887            Some(std::path::Path::new("/home/user/repo-b")),
2888            "repo-b must remain active"
2889        );
2890    }
2891
2892    #[test]
2893    fn close_tab_non_active_tab_after_active() {
2894        // active_tab=0, close tab 1 → repo-a stays active at index 0.
2895        use crate::message::Message;
2896        let mut state = GitKraft::new();
2897        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2898        state.tabs.push(RepoTab::new_empty());
2899        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2900        state.active_tab = 0;
2901
2902        let _ = state.update(Message::CloseTab(1));
2903
2904        assert_eq!(state.tabs.len(), 1);
2905        assert_eq!(state.active_tab, 0, "active_tab must remain unchanged");
2906        assert_eq!(
2907            state.active_tab().repo_path.as_deref(),
2908            Some(std::path::Path::new("/home/user/repo-a")),
2909            "repo-a must remain active"
2910        );
2911    }
2912
2913    #[test]
2914    fn close_tab_last_remaining_tab_is_noop() {
2915        // Can't close the last tab — CloseTab must be a no-op.
2916        use crate::message::Message;
2917        let mut state = GitKraft::new();
2918        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2919        assert_eq!(state.tabs.len(), 1);
2920
2921        let _ = state.update(Message::CloseTab(0));
2922
2923        assert_eq!(state.tabs.len(), 1, "last tab must not be removed");
2924        assert!(
2925            state.active_tab().has_repo(),
2926            "tab content must be unchanged"
2927        );
2928    }
2929
2930    #[test]
2931    fn close_tab_out_of_bounds_index_is_noop() {
2932        // Closing a tab index that doesn't exist must be silently ignored.
2933        use crate::message::Message;
2934        let mut state = GitKraft::new();
2935        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2936        state.tabs.push(RepoTab::new_empty());
2937        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2938        assert_eq!(state.tabs.len(), 2);
2939
2940        let _ = state.update(Message::CloseTab(99));
2941
2942        assert_eq!(state.tabs.len(), 2, "no tab should be removed");
2943    }
2944
2945    // ── CloseTab — three-tab scenarios ────────────────────────────────────
2946
2947    #[test]
2948    fn close_tab_three_tabs_close_first_active() {
2949        // [repo-a*(0), repo-b(1), repo-c(2)] close 0
2950        // → [repo-b(0), repo-c(1)], active stays 0 (repo-b).
2951        use crate::message::Message;
2952        let mut state = GitKraft::new();
2953        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2954        state.tabs.push(RepoTab::new_empty());
2955        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2956        state.tabs.push(RepoTab::new_empty());
2957        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2958        state.active_tab = 0;
2959
2960        let _ = state.update(Message::CloseTab(0));
2961
2962        assert_eq!(state.tabs.len(), 2);
2963        assert_eq!(state.active_tab, 0);
2964        assert_eq!(
2965            state.active_tab().repo_path.as_deref(),
2966            Some(std::path::Path::new("/home/user/repo-b"))
2967        );
2968    }
2969
2970    #[test]
2971    fn close_tab_three_tabs_close_active_middle() {
2972        // [repo-a(0), repo-b*(1), repo-c(2)] close 1
2973        // → [repo-a(0), repo-c(1)], active_tab stays 1 (repo-c slides in).
2974        use crate::message::Message;
2975        let mut state = GitKraft::new();
2976        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2977        state.tabs.push(RepoTab::new_empty());
2978        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2979        state.tabs.push(RepoTab::new_empty());
2980        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
2981        state.active_tab = 1;
2982
2983        let _ = state.update(Message::CloseTab(1));
2984
2985        assert_eq!(state.tabs.len(), 2);
2986        assert_eq!(
2987            state.active_tab, 1,
2988            "active_tab must stay at 1 (repo-c slides in)"
2989        );
2990        assert_eq!(
2991            state.active_tab().repo_path.as_deref(),
2992            Some(std::path::Path::new("/home/user/repo-c"))
2993        );
2994    }
2995
2996    #[test]
2997    fn close_tab_three_tabs_close_active_last() {
2998        // [repo-a(0), repo-b(1), repo-c*(2)] close 2
2999        // → [repo-a(0), repo-b(1)], active_tab becomes 1 (clamped).
3000        use crate::message::Message;
3001        let mut state = GitKraft::new();
3002        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3003        state.tabs.push(RepoTab::new_empty());
3004        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3005        state.tabs.push(RepoTab::new_empty());
3006        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3007        state.active_tab = 2;
3008
3009        let _ = state.update(Message::CloseTab(2));
3010
3011        assert_eq!(state.tabs.len(), 2);
3012        assert_eq!(
3013            state.active_tab, 1,
3014            "active_tab must be clamped to last index"
3015        );
3016        assert_eq!(
3017            state.active_tab().repo_path.as_deref(),
3018            Some(std::path::Path::new("/home/user/repo-b"))
3019        );
3020    }
3021
3022    #[test]
3023    fn close_tab_three_tabs_close_first_when_middle_active() {
3024        // [repo-a(0), repo-b*(1), repo-c(2)] close 0
3025        // → [repo-b(0), repo-c(1)], active_tab shifts from 1 to 0.
3026        use crate::message::Message;
3027        let mut state = GitKraft::new();
3028        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3029        state.tabs.push(RepoTab::new_empty());
3030        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3031        state.tabs.push(RepoTab::new_empty());
3032        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3033        state.active_tab = 1;
3034
3035        let _ = state.update(Message::CloseTab(0));
3036
3037        assert_eq!(state.tabs.len(), 2);
3038        assert_eq!(
3039            state.active_tab, 0,
3040            "active_tab must decrement when a tab before it is removed"
3041        );
3042        assert_eq!(
3043            state.active_tab().repo_path.as_deref(),
3044            Some(std::path::Path::new("/home/user/repo-b")),
3045            "repo-b must remain active"
3046        );
3047    }
3048
3049    #[test]
3050    fn close_tab_three_tabs_close_last_when_middle_active() {
3051        // [repo-a(0), repo-b*(1), repo-c(2)] close 2
3052        // → [repo-a(0), repo-b(1)], active_tab stays at 1.
3053        use crate::message::Message;
3054        let mut state = GitKraft::new();
3055        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3056        state.tabs.push(RepoTab::new_empty());
3057        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3058        state.tabs.push(RepoTab::new_empty());
3059        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3060        state.active_tab = 1;
3061
3062        let _ = state.update(Message::CloseTab(2));
3063
3064        assert_eq!(state.tabs.len(), 2);
3065        assert_eq!(
3066            state.active_tab, 1,
3067            "active_tab must not change when a tab after it is removed"
3068        );
3069        assert_eq!(
3070            state.active_tab().repo_path.as_deref(),
3071            Some(std::path::Path::new("/home/user/repo-b")),
3072            "repo-b must remain active"
3073        );
3074    }
3075
3076    // ── CloseRepo — three-tab scenarios ───────────────────────────────────
3077
3078    #[test]
3079    fn close_repo_three_tabs_active_in_middle() {
3080        // [repo-a(0), repo-b*(1), repo-c(2)] close active
3081        // → [repo-a(0), repo-c(1)], active_tab stays 1 (repo-c slides in).
3082        use crate::message::Message;
3083        let mut state = GitKraft::new();
3084        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3085        state.tabs.push(RepoTab::new_empty());
3086        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3087        state.tabs.push(RepoTab::new_empty());
3088        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3089        state.active_tab = 1;
3090
3091        let _ = state.update(Message::CloseRepo);
3092
3093        assert_eq!(state.tabs.len(), 2);
3094        assert_eq!(state.active_tab, 1);
3095        assert_eq!(
3096            state.active_tab().repo_path.as_deref(),
3097            Some(std::path::Path::new("/home/user/repo-c"))
3098        );
3099    }
3100
3101    #[test]
3102    fn close_repo_three_tabs_active_at_end() {
3103        // [repo-a(0), repo-b(1), repo-c*(2)] close active
3104        // → [repo-a(0), repo-b(1)], active_tab clamped to 1.
3105        use crate::message::Message;
3106        let mut state = GitKraft::new();
3107        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3108        state.tabs.push(RepoTab::new_empty());
3109        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3110        state.tabs.push(RepoTab::new_empty());
3111        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-c");
3112        state.active_tab = 2;
3113
3114        let _ = state.update(Message::CloseRepo);
3115
3116        assert_eq!(state.tabs.len(), 2);
3117        assert_eq!(state.active_tab, 1, "active_tab clamped to last index");
3118        assert_eq!(
3119            state.active_tab().repo_path.as_deref(),
3120            Some(std::path::Path::new("/home/user/repo-b"))
3121        );
3122    }
3123
3124    // ── NewTab ────────────────────────────────────────────────────────────
3125
3126    #[test]
3127    fn new_tab_appends_empty_tab_and_makes_it_active() {
3128        use crate::message::Message;
3129        let mut state = GitKraft::new();
3130        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3131        assert_eq!(state.tabs.len(), 1);
3132
3133        let _ = state.update(Message::NewTab);
3134
3135        assert_eq!(state.tabs.len(), 2, "a new empty tab must be appended");
3136        assert_eq!(state.active_tab, 1, "new tab must become active");
3137        assert!(!state.active_tab().has_repo(), "new tab must be empty");
3138        // The original repo tab must be unaffected.
3139        assert!(state.tabs[0].has_repo());
3140    }
3141
3142    #[test]
3143    fn new_tab_with_multiple_existing_tabs() {
3144        use crate::message::Message;
3145        let mut state = GitKraft::new();
3146        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3147        state.tabs.push(RepoTab::new_empty());
3148        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3149        state.active_tab = 0;
3150
3151        let _ = state.update(Message::NewTab);
3152
3153        assert_eq!(state.tabs.len(), 3);
3154        assert_eq!(state.active_tab, 2);
3155        assert!(!state.active_tab().has_repo());
3156    }
3157
3158    // ── SwitchTab ─────────────────────────────────────────────────────────
3159
3160    #[test]
3161    fn switch_tab_to_valid_index() {
3162        use crate::message::Message;
3163        let mut state = GitKraft::new();
3164        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3165        state.tabs.push(RepoTab::new_empty());
3166        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3167        state.active_tab = 0;
3168
3169        let _ = state.update(Message::SwitchTab(1));
3170
3171        assert_eq!(state.active_tab, 1);
3172        assert_eq!(
3173            state.active_tab().repo_path.as_deref(),
3174            Some(std::path::Path::new("/home/user/repo-b"))
3175        );
3176    }
3177
3178    #[test]
3179    fn switch_tab_to_same_index_is_noop() {
3180        use crate::message::Message;
3181        let mut state = GitKraft::new();
3182        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3183        state.tabs.push(RepoTab::new_empty());
3184        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3185        state.active_tab = 1;
3186
3187        let _ = state.update(Message::SwitchTab(1));
3188
3189        assert_eq!(state.active_tab, 1, "should remain on tab 1");
3190    }
3191
3192    #[test]
3193    fn switch_tab_out_of_bounds_is_noop() {
3194        // Firing SwitchTab with a too-large index must not panic or change state.
3195        use crate::message::Message;
3196        let mut state = GitKraft::new();
3197        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3198        state.tabs.push(RepoTab::new_empty());
3199        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3200        state.active_tab = 0;
3201
3202        let _ = state.update(Message::SwitchTab(99));
3203
3204        assert_eq!(state.active_tab, 0, "out-of-bounds switch must be ignored");
3205    }
3206
3207    // ── session_state ─────────────────────────────────────────────────────
3208
3209    #[test]
3210    fn session_state_matches_open_tab_paths_when_no_empty_tabs() {
3211        let mut state = GitKraft::new();
3212        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3213        state.tabs.push(RepoTab::new_empty());
3214        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3215        state.active_tab = 1;
3216
3217        let (paths, active) = state.session_state();
3218        assert_eq!(paths.len(), 2);
3219        assert_eq!(active, 1);
3220    }
3221
3222    #[test]
3223    fn session_state_adjusts_index_when_empty_tab_before_active() {
3224        // tabs: [Empty(0), repo-a(1), repo-b(2)], active = 2
3225        // session paths: [repo-a, repo-b] → session active should be 1
3226        let mut state = GitKraft::new();
3227        // Tab 0 is already an empty tab from new()
3228        state.tabs.push(RepoTab::new_empty());
3229        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
3230        state.tabs.push(RepoTab::new_empty());
3231        setup_loaded_tab(&mut state.tabs[2], "/home/user/repo-b");
3232        state.active_tab = 2;
3233
3234        let (paths, active) = state.session_state();
3235        assert_eq!(paths.len(), 2);
3236        assert_eq!(active, 1, "session index must skip the empty tab at 0");
3237    }
3238
3239    #[test]
3240    fn session_state_defaults_to_zero_when_active_is_empty_tab() {
3241        let mut state = GitKraft::new();
3242        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3243        state.tabs.push(RepoTab::new_empty()); // empty tab at index 1
3244        state.active_tab = 1; // active is the empty tab
3245
3246        let (paths, active) = state.session_state();
3247        assert_eq!(paths.len(), 1);
3248        assert_eq!(active, 0, "empty active tab should default to 0");
3249    }
3250
3251    // ── Race condition: repo_path set before load ─────────────────────────
3252
3253    #[test]
3254    fn repo_selected_sets_repo_path_before_load() {
3255        // After RepoSelected, the empty tab must have repo_path set so that
3256        // handle_repo_loaded can target it by path even if the user switches
3257        // tabs during loading.
3258        use crate::message::Message;
3259        let mut state = GitKraft::new();
3260        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3261        // Simulate OpenRepo creating a new empty tab
3262        state.tabs.push(RepoTab::new_empty());
3263        state.active_tab = 1;
3264
3265        let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
3266            "/home/user/repo-b",
3267        ))));
3268
3269        // The active tab should now have repo_path set
3270        assert_eq!(
3271            state.tabs[1].repo_path.as_deref(),
3272            Some(std::path::Path::new("/home/user/repo-b")),
3273            "repo_path must be set before async load starts"
3274        );
3275    }
3276
3277    #[test]
3278    fn open_recent_repo_sets_repo_path_before_load() {
3279        use crate::message::Message;
3280        let mut state = GitKraft::new();
3281        // Active tab is empty (no repo open)
3282        assert!(!state.active_tab().has_repo());
3283
3284        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
3285            "/home/user/repo-a",
3286        )));
3287
3288        assert_eq!(
3289            state.active_tab().repo_path.as_deref(),
3290            Some(std::path::Path::new("/home/user/repo-a")),
3291            "repo_path must be set before async load starts"
3292        );
3293    }
3294
3295    #[test]
3296    fn handle_repo_loaded_targets_correct_tab_after_switch() {
3297        // Regression test: user opens repo-b in a new tab, then switches
3298        // back to tab 0 before the load completes.  The loaded data must
3299        // still go to the correct tab (the one with repo_path = repo-b).
3300        use crate::message::Message;
3301        let mut state = GitKraft::new();
3302        // Tab 0: repo-a loaded
3303        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3304        // Tab 1: empty, has repo_path set (simulates RepoSelected having set it)
3305        state.tabs.push(RepoTab::new_empty());
3306        state.tabs[1].repo_path = Some(std::path::PathBuf::from("/home/user/repo-b"));
3307        state.tabs[1].is_loading = true;
3308
3309        // User switches back to tab 0
3310        state.active_tab = 0;
3311
3312        // Repo-b load completes
3313        let payload = fake_payload("/home/user/repo-b");
3314        let _ = state.update(Message::RepoOpened(Ok(payload)));
3315
3316        // The payload must have gone to tab 1, NOT tab 0
3317        assert!(
3318            state.tabs[1].repo_info.is_some(),
3319            "repo-b payload must be written to tab 1"
3320        );
3321        assert_eq!(
3322            state.tabs[1].repo_path.as_deref(),
3323            Some(std::path::Path::new("/home/user/repo-b")),
3324        );
3325        // Tab 0 must still be repo-a, untouched
3326        assert_eq!(
3327            state.tabs[0].repo_path.as_deref(),
3328            Some(std::path::Path::new("/home/user/repo-a")),
3329        );
3330    }
3331
3332    // ── Scroll & pagination fix tests ─────────────────────────────────────
3333
3334    #[test]
3335    fn apply_payload_does_not_reset_is_loading_more_commits() {
3336        // A background refresh must not clear is_loading_more_commits — that
3337        // flag is owned by the pagination flow.  Clearing it would let the
3338        // scroll handler fire a duplicate pagination whose identical result
3339        // falsely sets has_more_commits = false ("end of history").
3340        use crate::message::Message;
3341        let mut state = GitKraft::new();
3342        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3343
3344        // Simulate pagination in flight
3345        state.active_tab_mut().is_loading_more_commits = true;
3346
3347        // Background refresh arrives
3348        let payload = fake_payload("/home/user/repo-a");
3349        let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3350
3351        // is_loading_more_commits must still be true
3352        assert!(
3353            state.active_tab().is_loading_more_commits,
3354            "refresh must not reset is_loading_more_commits"
3355        );
3356    }
3357
3358    #[test]
3359    fn apply_payload_resets_is_loading_more_commits_for_new_open() {
3360        // For a brand-new repo open (not a refresh), the flag doesn't matter
3361        // because no pagination can be in flight.  But is_loading_more_commits
3362        // is initialized to false in new_empty(), and apply_payload should not
3363        // set it to true.
3364        let mut state = GitKraft::new();
3365        assert!(!state.active_tab().is_loading_more_commits);
3366
3367        let payload = fake_payload("/home/user/repo-a");
3368        let _ = state.update(crate::message::Message::RepoOpened(Ok(payload)));
3369
3370        assert!(!state.active_tab().is_loading_more_commits);
3371    }
3372
3373    #[test]
3374    fn refresh_does_not_shrink_commit_list() {
3375        // If pagination extended commits to 700 and then a stale refresh
3376        // arrives with only 500, the commit list must not shrink.
3377        use crate::message::Message;
3378        let mut state = GitKraft::new();
3379        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3380
3381        // Give the tab some initial commits, then extend via simulated pagination
3382        let tab = state.active_tab_mut();
3383        tab.commits = make_test_commits(500);
3384        for i in 0..200 {
3385            let mut c = tab.commits[0].clone();
3386            c.oid = format!("extra_{i:04}");
3387            c.short_oid = format!("x{i:03}");
3388            tab.commits.push(c);
3389        }
3390        let extended_len = tab.commits.len();
3391        tab.has_more_commits = true;
3392
3393        // Stale refresh arrives with only the original commits (fewer)
3394        let mut payload = fake_payload("/home/user/repo-a");
3395        payload.commits = make_test_commits(500);
3396        payload.graph_rows = gitkraft_core::features::graph::build_graph(&payload.commits);
3397        let payload_len = payload.commits.len();
3398        assert!(
3399            payload_len < extended_len,
3400            "payload must be shorter for this test"
3401        );
3402
3403        let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3404
3405        assert_eq!(
3406            state.active_tab().commits.len(),
3407            extended_len,
3408            "refresh must not shrink the commit list"
3409        );
3410    }
3411
3412    #[test]
3413    fn refresh_replaces_commits_when_count_equal_or_greater() {
3414        // When a refresh loads the same or more commits, they should replace
3415        // the existing list (to pick up new commits from pushes/rebases).
3416        use crate::message::Message;
3417        let mut state = GitKraft::new();
3418        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3419        state.active_tab_mut().commits = make_test_commits(5);
3420
3421        let original_oid = state.active_tab().commits[0].oid.clone();
3422
3423        // Refresh with same count but different content
3424        let mut payload = fake_payload("/home/user/repo-a");
3425        let mut new_commits = make_test_commits(5);
3426        new_commits[0].oid = "refreshed_oid_0000".to_string();
3427        payload.commits = new_commits;
3428        payload.graph_rows = gitkraft_core::features::graph::build_graph(&payload.commits);
3429        let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3430
3431        assert_eq!(
3432            state.active_tab().commits[0].oid,
3433            "refreshed_oid_0000",
3434            "refresh with same count must replace commits"
3435        );
3436        assert_ne!(state.active_tab().commits[0].oid, original_oid);
3437    }
3438
3439    #[test]
3440    fn refresh_rebuilds_commit_display_when_content_changes_at_same_count() {
3441        // When a refresh replaces commits with different content at the same
3442        // count (e.g. after amend/rebase), commit_display must be rebuilt.
3443        // Previously only a count change triggered the rebuild, leaving stale
3444        // author names and timestamps in the display.
3445        use crate::message::Message;
3446        let mut state = GitKraft::new();
3447        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3448        let mut initial_commits = make_test_commits(5);
3449        initial_commits[0].author_name = "Alice".to_string();
3450        state.active_tab_mut().commits = initial_commits;
3451        // Simulate a prior load that built commit_display.
3452        state.active_tab_mut().commit_display = state
3453            .active_tab()
3454            .commits
3455            .iter()
3456            .map(|c| ("1 day ago".to_string(), c.author_name.clone()))
3457            .collect();
3458        assert_eq!(state.active_tab().commit_display[0].1, "Alice");
3459
3460        // Refresh with same count but different author on the first commit
3461        let mut payload = fake_payload("/home/user/repo-a");
3462        let mut new_commits = make_test_commits(5);
3463        new_commits[0].author_name = "Bob".to_string();
3464        payload.commits = new_commits;
3465        payload.graph_rows = gitkraft_core::features::graph::build_graph(&payload.commits);
3466        let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3467
3468        // commit_display must reflect the new author, not the stale one.
3469        assert_eq!(
3470            state.active_tab().commit_display[0].1,
3471            "Bob",
3472            "commit_display must be rebuilt when content changes at same count"
3473        );
3474    }
3475
3476    #[test]
3477    fn duplicate_more_commits_loaded_sets_has_more_false() {
3478        // Documents the scenario that caused "end of history" too early:
3479        // two identical MoreCommitsLoaded results arriving back-to-back.
3480        // The second sees prev_count == new_total and sets has_more = false.
3481        // This test verifies the SYMPTOM; the fix is that apply_payload no
3482        // longer creates the duplicate by resetting is_loading_more_commits.
3483        use crate::message::{CommitPage, Message};
3484        let mut state = GitKraft::new();
3485        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3486        let tab = state.active_tab_mut();
3487        tab.commits = make_test_commits(10);
3488        tab.has_more_commits = true;
3489
3490        // First result: grows from 10 to 15
3491        let _initial_count = tab.commits.len();
3492        let mut bigger_commits = tab.commits.clone();
3493        for i in 0..5 {
3494            let mut c = bigger_commits[0].clone();
3495            c.oid = format!("new_{i:04}");
3496            bigger_commits.push(c);
3497        }
3498        let bigger_count = bigger_commits.len();
3499        let graph_rows = gitkraft_core::features::graph::build_graph(&bigger_commits);
3500        let _ = state.update(Message::MoreCommitsLoaded(Ok(CommitPage {
3501            commits: bigger_commits.clone(),
3502            graph_rows: graph_rows.clone(),
3503        })));
3504        assert!(
3505            state.active_tab().has_more_commits,
3506            "first result should keep has_more true"
3507        );
3508        assert_eq!(state.active_tab().commits.len(), bigger_count);
3509
3510        // Second identical result: prev_count == new_total → has_more = false
3511        let _ = state.update(Message::MoreCommitsLoaded(Ok(CommitPage {
3512            commits: bigger_commits,
3513            graph_rows,
3514        })));
3515        assert!(
3516            !state.active_tab().has_more_commits,
3517            "identical second result must set has_more_commits false"
3518        );
3519    }
3520
3521    #[test]
3522    fn file_system_changed_allowed_during_pagination() {
3523        // FileSystemChanged is NOT blocked by is_loading_more_commits.
3524        // The "don't shrink" guard in apply_payload handles the race.
3525        // Blocking here would permanently freeze auto-refresh if
3526        // is_loading_more_commits got stuck (e.g. tab switch mid-pagination).
3527        use crate::message::Message;
3528        let mut state = GitKraft::new();
3529        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3530        state.active_tab_mut().is_loading_more_commits = true;
3531
3532        let _task = state.update(Message::FileSystemChanged);
3533
3534        // is_loading_more_commits stays true (owned by pagination flow)
3535        assert!(state.active_tab().is_loading_more_commits);
3536        // The refresh IS allowed — we can't inspect the Task directly,
3537        // but at minimum the state should not have been corrupted.
3538    }
3539
3540    #[test]
3541    fn has_more_commits_preserved_across_refresh() {
3542        // A background refresh must not reset has_more_commits.
3543        use crate::message::Message;
3544        let mut state = GitKraft::new();
3545        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3546        state.active_tab_mut().has_more_commits = true;
3547
3548        let payload = fake_payload("/home/user/repo-a");
3549        let _ = state.update(Message::RepoRefreshed(Ok(payload)));
3550
3551        assert!(
3552            state.active_tab().has_more_commits,
3553            "refresh must preserve has_more_commits"
3554        );
3555    }
3556
3557    #[test]
3558    fn switch_to_existing_tab_removes_empty_tab() {
3559        let mut state = GitKraft::new();
3560        // Tab 0: loaded repo-a
3561        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3562        // Tab 1: empty tab (user clicked "+")
3563        state.tabs.push(RepoTab::new_empty());
3564        state.active_tab = 1;
3565
3566        let found = state.switch_to_existing_tab(std::path::Path::new("/home/user/repo-a"));
3567
3568        assert!(found, "should find existing tab");
3569        assert_eq!(state.tabs.len(), 1, "empty tab should be removed");
3570        assert_eq!(state.active_tab, 0);
3571        assert_eq!(
3572            state.active_tab().repo_path.as_deref(),
3573            Some(std::path::Path::new("/home/user/repo-a")),
3574        );
3575    }
3576
3577    #[test]
3578    fn switch_to_existing_tab_returns_false_when_not_found() {
3579        let mut state = GitKraft::new();
3580        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3581
3582        let found = state.switch_to_existing_tab(std::path::Path::new("/home/user/repo-b"));
3583
3584        assert!(!found, "should not find nonexistent tab");
3585        assert_eq!(state.tabs.len(), 1);
3586    }
3587
3588    #[test]
3589    fn switch_to_existing_tab_keeps_loaded_current_tab() {
3590        let mut state = GitKraft::new();
3591        // Tab 0: repo-a
3592        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
3593        // Tab 1: repo-b (active)
3594        state.tabs.push(RepoTab::new_empty());
3595        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
3596        state.active_tab = 1;
3597
3598        let found = state.switch_to_existing_tab(std::path::Path::new("/home/user/repo-a"));
3599
3600        assert!(found);
3601        assert_eq!(state.tabs.len(), 2, "loaded tab should not be removed");
3602        assert_eq!(state.active_tab, 0);
3603    }
3604
3605    #[test]
3606    fn default_remote_returns_first_remote() {
3607        let mut tab = RepoTab::new_empty();
3608        tab.remotes = vec![
3609            gitkraft_core::RemoteInfo {
3610                name: "upstream".to_string(),
3611                url: Some("https://example.com/upstream.git".to_string()),
3612                fetch_refspecs: vec![],
3613            },
3614            gitkraft_core::RemoteInfo {
3615                name: "origin".to_string(),
3616                url: Some("https://example.com/origin.git".to_string()),
3617                fetch_refspecs: vec![],
3618            },
3619        ];
3620        assert_eq!(tab.default_remote(), "upstream");
3621    }
3622
3623    #[test]
3624    fn default_remote_fallback_to_origin() {
3625        let tab = RepoTab::new_empty();
3626        assert_eq!(tab.default_remote(), "origin");
3627    }
3628}