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}
23
24/// Which horizontal divider the user is currently dragging (if any).
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum DragTargetH {
27    /// The divider between the middle row and the staging area.
28    StagingTop,
29}
30
31/// What item was right-clicked to open the context menu.
32#[derive(Debug, Clone)]
33pub enum ContextMenu {
34    /// A local branch.
35    Branch {
36        name: String,
37        is_current: bool,
38        /// Index in the filtered local-branch list, used to approximate
39        /// the menu's on-screen position.
40        local_index: usize,
41    },
42    /// A remote-tracking branch (e.g. origin/feature-x).
43    RemoteBranch { name: String },
44    /// A commit in the log.
45    Commit { index: usize, oid: String },
46    /// A stash entry.
47    Stash { index: usize },
48    /// An unstaged file in the staging area.
49    UnstagedFile { path: String },
50    /// A staged file in the staging area.
51    StagedFile { path: String },
52    /// A file in a commit diff.
53    CommitFile { oid: String, file_path: String },
54}
55
56// ── Per-repository tab state ──────────────────────────────────────────────────
57
58/// Per-repository state — one instance per open tab.
59pub struct RepoTab {
60    // ── Repository ────────────────────────────────────────────────────────
61    /// Path to the currently opened repository (workdir root).
62    pub repo_path: Option<PathBuf>,
63    /// High-level information about the opened repository.
64    pub repo_info: Option<RepoInfo>,
65
66    // ── Branches ──────────────────────────────────────────────────────────
67    /// All branches (local + remote) in the repository.
68    pub branches: Vec<BranchInfo>,
69    /// Name of the currently checked-out branch.
70    pub current_branch: Option<String>,
71
72    // ── Commits ───────────────────────────────────────────────────────────
73    /// Commit log (newest first).
74    pub commits: Vec<CommitInfo>,
75    /// Index into `commits` of the currently selected commit.
76    pub selected_commit: Option<usize>,
77    /// Anchor commit index for range selection — set on a plain click.
78    pub anchor_commit_index: Option<usize>,
79    /// Ordered (ascending) list of commit indices in the current range selection.
80    pub selected_commits: Vec<usize>,
81    /// Per-commit graph layout rows for branch visualisation.
82    pub graph_rows: Vec<gitkraft_core::GraphRow>,
83
84    // ── Diff / Staging ────────────────────────────────────────────────────
85    /// Unstaged (working-directory) changes.
86    pub unstaged_changes: Vec<DiffInfo>,
87    /// Staged (index) changes.
88    pub staged_changes: Vec<DiffInfo>,
89    /// Lightweight file list for the currently selected commit (path + status only).
90    pub commit_files: Vec<gitkraft_core::DiffFileEntry>,
91    /// OID of the currently selected commit (needed for on-demand file diff loading).
92    pub selected_commit_oid: Option<String>,
93    /// Index of the selected file in `commit_files`.
94    pub selected_file_index: Option<usize>,
95    /// True while a single-file diff is being loaded.
96    pub is_loading_file_diff: bool,
97    /// Anchor index for range selection — set on a regular click, kept fixed while
98    /// the user extends the selection with Shift+Click.
99    pub anchor_file_index: Option<usize>,
100    /// Ordered list of file indices currently multi-selected in the commit file list (Shift+Click).
101    /// Always stored in ascending index order (lowest index first).
102    pub selected_commit_file_indices: Vec<usize>,
103    /// Diffs for all multi-selected files (populated when 2+ files are selected).
104    pub multi_file_diffs: Vec<gitkraft_core::DiffInfo>,
105    /// Combined diff for all commits in the current range selection (populated when
106    /// `selected_commits.len() > 1`). Shown in the diff panel instead of single-commit diff.
107    pub commit_range_diffs: Vec<gitkraft_core::DiffInfo>,
108    /// The diff currently displayed in the diff viewer panel.
109    pub selected_diff: Option<DiffInfo>,
110    /// Text in the commit-message input.
111    pub commit_message: String,
112
113    // ── Stash ─────────────────────────────────────────────────────────────
114    /// All stash entries.
115    pub stashes: Vec<StashEntry>,
116
117    // ── Remotes ───────────────────────────────────────────────────────────
118    /// Configured remotes.
119    pub remotes: Vec<RemoteInfo>,
120
121    // ── Per-tab UI state ──────────────────────────────────────────────────
122    /// Whether the commit detail pane is visible.
123    pub show_commit_detail: bool,
124    /// Text in the "new branch name" input.
125    pub new_branch_name: String,
126    /// Whether the inline branch-creation UI is visible.
127    pub show_branch_create: bool,
128    /// Whether the Local branches section is expanded.
129    pub local_branches_expanded: bool,
130    /// Whether the Remote branches section is expanded.
131    pub remote_branches_expanded: bool,
132    /// Text in the "stash message" input.
133    pub stash_message: String,
134
135    /// Set of selected unstaged file paths (for multi-select with Shift+Click).
136    pub selected_unstaged: std::collections::HashSet<String>,
137    /// Set of selected staged file paths (for multi-select with Shift+Click).
138    pub selected_staged: std::collections::HashSet<String>,
139
140    /// File path pending discard confirmation (None = no pending discard).
141    pub pending_discard: Option<String>,
142
143    // ── Feedback ──────────────────────────────────────────────────────────
144    /// Transient status-bar message (e.g. "Branch created").
145    pub status_message: Option<String>,
146    /// Error message shown in a banner / toast.
147    pub error_message: Option<String>,
148    /// True while an async operation is in flight.
149    pub is_loading: bool,
150    /// Cursor position captured at the moment the context menu was opened.
151    /// Used to anchor the menu so it doesn't follow the mouse after appearing.
152    pub context_menu_pos: (f32, f32),
153
154    /// Currently open context menu, if any.
155    pub context_menu: Option<ContextMenu>,
156    /// Name of the branch currently being renamed (None = not renaming).
157    pub rename_branch_target: Option<String>,
158    /// The new name being typed in the rename input.
159    pub rename_branch_input: String,
160
161    /// When `Some(oid)`, the tag-creation inline form is visible, targeting that OID.
162    pub create_tag_target_oid: Option<String>,
163    /// True when creating an annotated tag; false for a lightweight tag.
164    pub create_tag_annotated: bool,
165    /// The tag name the user is typing.
166    pub create_tag_name: String,
167    /// The annotated tag message the user is typing (only used when `create_tag_annotated` is true).
168    pub create_tag_message: String,
169    /// When `Some(oid)`, the inline "create branch at this commit" form is visible.
170    pub create_branch_at_oid: Option<String>,
171
172    /// Current scroll offset of the commit log in pixels.
173    /// Tracked via `on_scroll` so virtual scrolling can render only the
174    /// visible window of rows.
175    pub commit_scroll_offset: f32,
176
177    /// Current scroll offset of the diff viewer in pixels.
178    pub diff_scroll_offset: f32,
179    /// Pre-computed display strings for each commit:
180    /// `(truncated_summary, relative_time, truncated_author)`.
181    /// Computed once when commits load to avoid per-frame string allocations.
182    pub commit_display: Vec<(String, String, String)>,
183
184    /// Whether there are potentially more commits to load beyond those already shown.
185    pub has_more_commits: bool,
186    /// Guard: true while a background load-more task is in flight (prevents duplicates).
187    pub is_loading_more_commits: bool,
188
189    /// When `Some(path)`, the file-history overlay is shown for that repo-relative path.
190    pub file_history_path: Option<String>,
191    /// Commits loaded for the file-history overlay (newest first).
192    pub file_history_commits: Vec<gitkraft_core::CommitInfo>,
193    /// Scroll offset of the file-history list in pixels.
194    pub file_history_scroll: f32,
195
196    /// When `Some(path)`, the blame overlay is shown for that repo-relative path.
197    pub blame_path: Option<String>,
198    /// Blame lines loaded for the blame overlay.
199    pub blame_lines: Vec<gitkraft_core::BlameLine>,
200    /// Scroll offset of the blame view in pixels.
201    pub blame_scroll: f32,
202
203    /// When `Some(path)`, a delete-confirmation banner is shown for that file.
204    pub pending_delete_file: Option<String>,
205}
206
207impl RepoTab {
208    /// Create an empty tab (no repo open — shows welcome screen).
209    pub fn new_empty() -> Self {
210        Self {
211            repo_path: None,
212            repo_info: None,
213            branches: Vec::new(),
214            current_branch: None,
215            commits: Vec::new(),
216            selected_commit: None,
217            anchor_commit_index: None,
218            selected_commits: Vec::new(),
219            graph_rows: Vec::new(),
220            unstaged_changes: Vec::new(),
221            staged_changes: Vec::new(),
222            commit_files: Vec::new(),
223            selected_commit_oid: None,
224            selected_file_index: None,
225            is_loading_file_diff: false,
226            anchor_file_index: None,
227            selected_commit_file_indices: Vec::new(),
228            multi_file_diffs: Vec::new(),
229            commit_range_diffs: Vec::new(),
230            selected_diff: None,
231            commit_message: String::new(),
232            stashes: Vec::new(),
233            remotes: Vec::new(),
234            show_commit_detail: false,
235            new_branch_name: String::new(),
236            show_branch_create: false,
237            local_branches_expanded: true,
238            remote_branches_expanded: true,
239            stash_message: String::new(),
240            selected_unstaged: std::collections::HashSet::new(),
241            selected_staged: std::collections::HashSet::new(),
242            pending_discard: None,
243            status_message: None,
244            error_message: None,
245            is_loading: false,
246            context_menu: None,
247            context_menu_pos: (0.0, 0.0),
248            rename_branch_target: None,
249            rename_branch_input: String::new(),
250            create_tag_target_oid: None,
251            create_tag_annotated: false,
252            create_tag_name: String::new(),
253            create_tag_message: String::new(),
254            create_branch_at_oid: None,
255            commit_scroll_offset: 0.0,
256            diff_scroll_offset: 0.0,
257            commit_display: Vec::new(),
258            has_more_commits: true,
259            is_loading_more_commits: false,
260            file_history_path: None,
261            file_history_commits: Vec::new(),
262            file_history_scroll: 0.0,
263            blame_path: None,
264            blame_lines: Vec::new(),
265            blame_scroll: 0.0,
266            pending_delete_file: None,
267        }
268    }
269
270    /// Whether a repository is currently open in this tab.
271    pub fn has_repo(&self) -> bool {
272        self.repo_path.is_some()
273    }
274
275    /// Display name for the tab (last path component, or "New Tab").
276    pub fn display_name(&self) -> &str {
277        self.repo_path
278            .as_ref()
279            .and_then(|p| p.file_name())
280            .and_then(|n| n.to_str())
281            .unwrap_or("New Tab")
282    }
283
284    /// Apply a full repo payload to this tab, resetting transient UI state.
285    ///
286    /// The currently selected commit (if any) is **re-pinned** by OID after the
287    /// new commit list arrives, so background auto-refreshes (git-watcher or
288    /// staging changes) never clear the user's selection.
289    pub fn apply_payload(
290        &mut self,
291        payload: crate::message::RepoPayload,
292        path: std::path::PathBuf,
293    ) {
294        // ── Save selection so we can restore it after the data refresh ────
295        let prev_oid = self.selected_commit_oid.clone();
296
297        // Save multi-selection OIDs so we can re-map them after the commit
298        // list is replaced.  Without this, background auto-refreshes (git
299        // watcher, fallback poll) would silently clear a Shift+click range.
300        let prev_anchor_oid = self
301            .anchor_commit_index
302            .and_then(|i| self.commits.get(i).map(|c| c.oid.clone()));
303        let prev_selected_oids: Vec<String> = self
304            .selected_commits
305            .iter()
306            .filter_map(|&i| self.commits.get(i).map(|c| c.oid.clone()))
307            .collect();
308
309        self.current_branch = payload.info.head_branch.clone();
310        self.repo_path = Some(path);
311        self.repo_info = Some(payload.info);
312        self.branches = payload.branches;
313        self.commits = payload.commits;
314        self.graph_rows = payload.graph_rows;
315        self.unstaged_changes = payload.unstaged;
316        self.staged_changes = payload.staged;
317        self.stashes = payload.stashes;
318        self.remotes = payload.remotes;
319
320        // Reset transient UI state.
321        // NOTE: selected_commit / commit_files / selected_diff are restored
322        // below so they survive background auto-refreshes.
323        self.selected_commit = None;
324        self.anchor_commit_index = None;
325        self.selected_commits.clear();
326        self.selected_commit_oid = None;
327        self.commit_message.clear();
328        self.error_message = None;
329        self.status_message = Some("Repository loaded.".into());
330        self.commit_scroll_offset = 0.0;
331        self.has_more_commits = true;
332        self.is_loading_more_commits = false;
333        self.selected_unstaged.clear();
334        self.selected_staged.clear();
335        self.anchor_file_index = None;
336        self.selected_commit_file_indices.clear();
337        self.multi_file_diffs.clear();
338        self.commit_range_diffs.clear();
339
340        // ── Restore the previously selected commit by OID ─────────────────
341        // If the commit still exists in the refreshed list, re-select it so
342        // the diff panel, file list, and selection highlight are all
343        // preserved.  This means auto-refreshes (every 5 s fallback, git
344        // watcher) never interrupt the user’s view.
345        if let Some(oid) = prev_oid {
346            if let Some(new_idx) = self.commits.iter().position(|c| c.oid == oid) {
347                self.selected_commit = Some(new_idx);
348                self.selected_commit_oid = Some(oid);
349                // commit_files, selected_diff, selected_file_index,
350                // is_loading_file_diff, diff_scroll_offset are intentionally
351                // left unchanged — the commit content hasn’t changed.
352            } else {
353                // Commit was rebased / force-pushed away — clear everything.
354                self.selected_diff = None;
355                self.commit_files.clear();
356                self.selected_file_index = None;
357                self.is_loading_file_diff = false;
358                self.diff_scroll_offset = 0.0;
359            }
360        } else {
361            // No previous selection — safe to clear diff state.
362            self.selected_diff = None;
363            self.commit_files.clear();
364            self.selected_file_index = None;
365            self.is_loading_file_diff = false;
366            self.diff_scroll_offset = 0.0;
367        }
368
369        // ── Restore multi-selection by OID ─────────────────────────────
370        // Re-map the saved anchor and range selection from OIDs back to
371        // indices in the (possibly reordered) new commit list.
372        if let Some(anchor_oid) = prev_anchor_oid {
373            if let Some(new_anchor) = self.commits.iter().position(|c| c.oid == anchor_oid) {
374                self.anchor_commit_index = Some(new_anchor);
375            }
376        }
377        if !prev_selected_oids.is_empty() {
378            let restored: Vec<usize> = prev_selected_oids
379                .iter()
380                .filter_map(|oid| self.commits.iter().position(|c| &c.oid == oid))
381                .collect();
382            if !restored.is_empty() {
383                self.selected_commits = restored;
384                self.selected_commits.sort_unstable();
385            }
386        }
387    }
388}
389
390// ── Top-level application state ───────────────────────────────────────────────
391
392/// Top-level application state for the GitKraft GUI.
393pub struct GitKraft {
394    // ── Tabs ──────────────────────────────────────────────────────────────
395    /// All open repository tabs.
396    pub tabs: Vec<RepoTab>,
397    /// Index of the currently active/visible tab.
398    pub active_tab: usize,
399
400    // ── UI state (global, not per-tab) ────────────────────────────────────
401    /// Whether the left sidebar is expanded.
402    pub sidebar_expanded: bool,
403
404    // ── Pane widths / heights (pixels) ────────────────────────────────────
405    /// Width of the left sidebar in pixels.
406    pub sidebar_width: f32,
407    /// Width of the commit-log panel in pixels.
408    pub commit_log_width: f32,
409    /// Height of the staging area in pixels.
410    pub staging_height: f32,
411    /// Width of the diff file-list sidebar in pixels.
412    pub diff_file_list_width: f32,
413
414    /// UI scale factor (1.0 = default). Adjusted with Ctrl+/Ctrl- keyboard shortcuts.
415    pub ui_scale: f32,
416
417    // ── Drag state ────────────────────────────────────────────────────────
418    /// Which vertical divider is being dragged (if any).
419    pub dragging: Option<DragTarget>,
420    /// Which horizontal divider is being dragged (if any).
421    pub dragging_h: Option<DragTargetH>,
422    /// Last known mouse X position during a drag (absolute window coords).
423    pub drag_start_x: f32,
424    /// Last known mouse Y position during a drag (absolute window coords).
425    pub drag_start_y: f32,
426    /// Whether the first move event has been received for the current vertical drag.
427    /// `false` right after `PaneDragStart` — the first `PaneDragMove` sets the
428    /// real start position instead of computing a bogus delta from 0.0.
429    pub drag_initialized: bool,
430    /// Same as `drag_initialized` but for horizontal drags.
431    pub drag_initialized_h: bool,
432
433    // ── Cursor ────────────────────────────────────────────────────────────
434    /// Last known cursor position in window coordinates.
435    /// Updated on every mouse-move event so context menus open at the
436    /// exact spot the user right-clicked.
437    pub cursor_pos: Point,
438
439    // ── Theme ─────────────────────────────────────────────────────────────
440    /// Index into `gitkraft_core::THEME_NAMES` for the currently active theme.
441    pub current_theme_index: usize,
442
443    // ── Persistence ───────────────────────────────────────────────────────
444    /// Recently opened repositories (loaded from settings on startup).
445    pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
446
447    // ── Search ────────────────────────────────────────────────────────────
448    /// Whether the search overlay is visible.
449    pub search_visible: bool,
450    /// Current search query text.
451    pub search_query: String,
452    /// Search results (commit infos matching the query).
453    pub search_results: Vec<gitkraft_core::CommitInfo>,
454    /// Index of the selected search result.
455    pub search_selected: Option<usize>,
456
457    /// Files changed between the selected search commit and working tree.
458    pub search_diff_files: Vec<gitkraft_core::DiffFileEntry>,
459    /// Selected file indices in the search diff file list.
460    pub search_diff_selected: HashSet<usize>,
461    /// The diff content for the currently viewed search diff file(s).
462    pub search_diff_content: Vec<gitkraft_core::DiffInfo>,
463    /// OID of the commit being diffed against working tree in search.
464    pub search_diff_oid: Option<String>,
465
466    /// Configured editor for "Open in editor" actions.
467    pub editor: gitkraft_core::Editor,
468
469    /// Current keyboard modifier state (updated via subscription).
470    pub keyboard_modifiers: iced::keyboard::Modifiers,
471
472    /// Monotonically-increasing counter incremented on every `AnimationTick`.
473    /// Drives the loading-spinner frame selection in all UI widgets.
474    pub animation_tick: u64,
475
476    // ── Window geometry ───────────────────────────────────────────────────
477    /// Last known window width (updated on WindowResized).
478    pub window_width: f32,
479    /// Last known window height (updated on WindowResized).
480    pub window_height: f32,
481    /// Last known window X position (updated on WindowMoved).
482    pub window_x: f32,
483    /// Last known window Y position (updated on WindowMoved).
484    pub window_y: f32,
485}
486
487impl Default for GitKraft {
488    fn default() -> Self {
489        Self::new()
490    }
491}
492
493impl GitKraft {
494    /// Build application state from persisted [`AppSettings`].
495    ///
496    /// Starts with a single empty tab regardless of what was saved — callers
497    /// that want to restore the full session should use
498    /// [`Self::new_with_session_paths`] instead.
499    fn from_settings(settings: gitkraft_core::AppSettings) -> Self {
500        let current_theme_index = settings
501            .theme_name
502            .as_deref()
503            .map(gitkraft_core::theme_index_by_name)
504            .unwrap_or(0);
505
506        let recent_repos = settings.recent_repos;
507
508        let (
509            sidebar_width,
510            commit_log_width,
511            staging_height,
512            diff_file_list_width,
513            sidebar_expanded,
514            ui_scale,
515        ) = if let Some(ref layout) = settings.layout {
516            (
517                layout.sidebar_width.unwrap_or(220.0),
518                layout.commit_log_width.unwrap_or(500.0),
519                layout.staging_height.unwrap_or(200.0),
520                layout.diff_file_list_width.unwrap_or(180.0),
521                layout.sidebar_expanded.unwrap_or(true),
522                layout.ui_scale.unwrap_or(1.0),
523            )
524        } else {
525            (220.0, 500.0, 200.0, 180.0, true, 1.0)
526        };
527
528        Self {
529            tabs: vec![RepoTab::new_empty()],
530            active_tab: 0,
531
532            sidebar_expanded,
533
534            sidebar_width,
535            commit_log_width,
536            staging_height,
537            diff_file_list_width,
538
539            ui_scale,
540
541            dragging: None,
542            dragging_h: None,
543            drag_start_x: 0.0,
544            drag_start_y: 0.0,
545            drag_initialized: false,
546            drag_initialized_h: false,
547            cursor_pos: Point::ORIGIN,
548
549            current_theme_index,
550
551            recent_repos,
552
553            search_visible: false,
554            search_query: String::new(),
555            search_results: Vec::new(),
556            search_selected: None,
557            search_diff_files: Vec::new(),
558            search_diff_selected: HashSet::new(),
559            search_diff_content: Vec::new(),
560            search_diff_oid: None,
561
562            keyboard_modifiers: iced::keyboard::Modifiers::default(),
563            animation_tick: 0,
564
565            window_width: settings
566                .layout
567                .as_ref()
568                .and_then(|l| l.window_width)
569                .unwrap_or(1400.0),
570            window_height: settings
571                .layout
572                .as_ref()
573                .and_then(|l| l.window_height)
574                .unwrap_or(800.0),
575            window_x: settings
576                .layout
577                .as_ref()
578                .and_then(|l| l.window_x)
579                .unwrap_or(0.0),
580            window_y: settings
581                .layout
582                .as_ref()
583                .and_then(|l| l.window_y)
584                .unwrap_or(0.0),
585
586            editor: settings
587                .editor_name
588                .as_deref()
589                .map(|name| {
590                    // Try to map persisted name back to Editor variant
591                    gitkraft_core::EDITOR_NAMES
592                        .iter()
593                        .position(|n| n.eq_ignore_ascii_case(name))
594                        .map(gitkraft_core::Editor::from_index)
595                        .unwrap_or_else(|| {
596                            if name.eq_ignore_ascii_case("none") {
597                                gitkraft_core::Editor::None
598                            } else {
599                                gitkraft_core::Editor::Custom(name.to_string())
600                            }
601                        })
602                })
603                .unwrap_or_else(detect_system_editor),
604        }
605    }
606
607    /// Create a fresh application state with sensible defaults.
608    ///
609    /// Loads persisted settings (theme, recent repos) from disk when available.
610    /// Always starts with one empty tab — use [`Self::new_with_session_paths`] to
611    /// restore the full multi-tab session.
612    pub fn new() -> Self {
613        Self::from_settings(
614            gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default(),
615        )
616    }
617
618    /// Create state and also return the saved tab paths for startup restore.
619    ///
620    /// Call this from `main.rs` instead of [`Self::new`]; it sets up loading tabs
621    /// for every path in the persisted session and returns those paths so the
622    /// caller can spawn parallel `load_repo_at` tasks.
623    pub fn new_with_session_paths() -> (Self, Vec<PathBuf>) {
624        let settings =
625            gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default();
626        let open_tabs = settings.open_tabs.clone();
627        let active_tab_index = settings.active_tab_index;
628
629        let mut state = Self::from_settings(settings);
630
631        if !open_tabs.is_empty() {
632            state.tabs = open_tabs
633                .iter()
634                .map(|path| {
635                    let mut tab = RepoTab::new_empty();
636                    // Set the path now so the tab bar shows the right name
637                    // while the repo is being loaded in the background.
638                    tab.repo_path = Some(path.clone());
639                    if path.exists() {
640                        tab.is_loading = true;
641                        tab.status_message = Some(format!(
642                            "Loading {}…",
643                            path.file_name().unwrap_or_default().to_string_lossy()
644                        ));
645                    } else {
646                        tab.error_message =
647                            Some(format!("Repository not found: {}", path.display()));
648                    }
649                    tab
650                })
651                .collect();
652            state.active_tab = active_tab_index.min(state.tabs.len().saturating_sub(1));
653        }
654
655        (state, open_tabs)
656    }
657
658    /// Paths of all tabs where a repository has been fully loaded
659    /// (`repo_info` is populated). Used to persist the multi-tab session.
660    pub fn open_tab_paths(&self) -> Vec<PathBuf> {
661        self.tabs
662            .iter()
663            .filter(|t| t.repo_info.is_some())
664            .filter_map(|t| t.repo_path.clone())
665            .collect()
666    }
667
668    /// Get a reference to the currently active tab.
669    pub fn active_tab(&self) -> &RepoTab {
670        &self.tabs[self.active_tab]
671    }
672
673    /// Get a mutable reference to the currently active tab.
674    pub fn active_tab_mut(&mut self) -> &mut RepoTab {
675        &mut self.tabs[self.active_tab]
676    }
677
678    /// Whether the active tab has a repository open.
679    pub fn has_repo(&self) -> bool {
680        self.active_tab().has_repo()
681    }
682
683    /// Helper: the display name for the active tab's repo.
684    pub fn repo_display_name(&self) -> &str {
685        self.active_tab().display_name()
686    }
687
688    /// Derive the full [`ThemeColors`] from the currently active core theme.
689    ///
690    /// Call this at the top of view functions:
691    /// ```ignore
692    /// let c = state.colors();
693    /// ```
694    pub fn colors(&self) -> ThemeColors {
695        ThemeColors::from_core(&gitkraft_core::theme_by_index(self.current_theme_index))
696    }
697
698    /// Return a **custom** `iced::Theme` whose `Palette` is derived from the
699    /// active core theme.
700    ///
701    /// This is the key to making every built-in Iced widget (text inputs,
702    /// pick-lists, scrollbars, buttons without explicit `.style()`, etc.)
703    /// inherit the correct background, text, accent, success and danger
704    /// colours.  Without this, Iced falls back to its generic Dark/Light
705    /// palette and the UI looks wrong for every non-default theme.
706    pub fn iced_theme(&self) -> iced::Theme {
707        let core = gitkraft_core::theme_by_index(self.current_theme_index);
708        let name = self.current_theme_name().to_string();
709
710        let palette = iced::theme::Palette {
711            background: rgb_to_iced(core.background),
712            text: rgb_to_iced(core.text_primary),
713            primary: rgb_to_iced(core.accent),
714            success: rgb_to_iced(core.success),
715            warning: rgb_to_iced(core.warning),
716            danger: rgb_to_iced(core.error),
717        };
718
719        iced::Theme::custom(name, palette)
720    }
721
722    /// The display name of the currently active theme.
723    pub fn current_theme_name(&self) -> &'static str {
724        gitkraft_core::THEME_NAMES
725            .get(self.current_theme_index)
726            .copied()
727            .unwrap_or("Default")
728    }
729
730    /// Refresh all data for the currently active tab's repository.
731    ///
732    /// Returns [`Task::none()`] if no repository is open in the active tab.
733    pub fn refresh_active_tab(&mut self) -> Task<Message> {
734        match self.active_tab().repo_path.clone() {
735            Some(path) => crate::features::repo::commands::refresh_repo(path),
736            None => Task::none(),
737        }
738    }
739
740    /// Handle a `Result<(), String>` from a git operation that should trigger
741    /// a full repository refresh on success.
742    ///
743    /// * `Ok(())` — clears `is_loading`, sets `status_message`, refreshes.
744    /// * `Err(e)` — clears `is_loading`, sets `error_message`, returns
745    ///   [`Task::none()`].
746    pub fn on_ok_refresh(
747        &mut self,
748        result: Result<(), String>,
749        ok_msg: &str,
750        err_prefix: &str,
751    ) -> Task<Message> {
752        match result {
753            Ok(()) => {
754                {
755                    let tab = self.active_tab_mut();
756                    tab.is_loading = false;
757                    tab.status_message = Some(ok_msg.to_string());
758                }
759                self.refresh_active_tab()
760            }
761            Err(e) => {
762                let tab = self.active_tab_mut();
763                tab.is_loading = false;
764                tab.error_message = Some(format!("{err_prefix}: {e}"));
765                tab.status_message = None;
766                Task::none()
767            }
768        }
769    }
770
771    /// Build a [`LayoutSettings`] snapshot from the current pane dimensions.
772    pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
773        gitkraft_core::LayoutSettings {
774            sidebar_width: Some(self.sidebar_width),
775            commit_log_width: Some(self.commit_log_width),
776            staging_height: Some(self.staging_height),
777            diff_file_list_width: Some(self.diff_file_list_width),
778            sidebar_expanded: Some(self.sidebar_expanded),
779            ui_scale: Some(self.ui_scale),
780            window_width: Some(self.window_width),
781            window_height: Some(self.window_height),
782            window_x: Some(self.window_x),
783            window_y: Some(self.window_y),
784            window_maximized: None, // not tracked
785        }
786    }
787}
788
789/// Convert a core [`gitkraft_core::Rgb`] to an [`iced::Color`].
790fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
791    Color::from_rgb8(rgb.r, rgb.g, rgb.b)
792}
793
794/// Try to detect the system's preferred editor from environment variables.
795fn detect_system_editor() -> gitkraft_core::Editor {
796    for var in ["VISUAL", "EDITOR"] {
797        if let Ok(val) = std::env::var(var) {
798            let bin = val.split('/').next_back().unwrap_or(&val).trim();
799            return match bin {
800                "nvim" | "neovim" => gitkraft_core::Editor::Neovim,
801                "vim" => gitkraft_core::Editor::Vim,
802                "hx" | "helix" => gitkraft_core::Editor::Helix,
803                "nano" => gitkraft_core::Editor::Nano,
804                "micro" => gitkraft_core::Editor::Micro,
805                "emacs" => gitkraft_core::Editor::Emacs,
806                "code" => gitkraft_core::Editor::VSCode,
807                "zed" => gitkraft_core::Editor::Zed,
808                "subl" => gitkraft_core::Editor::Sublime,
809                _ => gitkraft_core::Editor::Custom(val),
810            };
811        }
812    }
813    gitkraft_core::Editor::None
814}
815
816// ── Tests ─────────────────────────────────────────────────────────────────────
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821
822    #[test]
823    fn new_defaults() {
824        let state = GitKraft::new();
825        assert!(state.active_tab().repo_path.is_none());
826        assert!(!state.has_repo());
827        assert_eq!(state.repo_display_name(), "New Tab");
828        assert!(state.active_tab().commits.is_empty());
829        assert!(state.sidebar_expanded);
830        // Default theme index should be valid
831        assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
832        // Pane defaults
833        assert!(state.sidebar_width > 0.0);
834        assert!(state.commit_log_width > 0.0);
835        assert!(state.staging_height > 0.0);
836        assert!(state.dragging.is_none());
837        assert!(state.dragging_h.is_none());
838        // Should start with one empty tab
839        assert_eq!(state.tabs.len(), 1);
840        assert_eq!(state.active_tab, 0);
841    }
842
843    #[test]
844    fn repo_display_name_extracts_basename() {
845        let mut state = GitKraft::new();
846        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
847        assert_eq!(state.repo_display_name(), "my-project");
848    }
849
850    #[test]
851    fn colors_returns_theme_colors() {
852        let state = GitKraft::new();
853        let c = state.colors();
854        // The default theme (index 0) is dark, so background should be dark
855        assert!(c.bg.r < 0.5);
856    }
857
858    #[test]
859    fn iced_theme_is_custom_with_correct_palette() {
860        let mut state = GitKraft::new();
861
862        // Index 0 = Default (dark) — custom theme with dark background
863        state.current_theme_index = 0;
864        let iced_t = state.iced_theme();
865        let pal = iced_t.palette();
866        assert!(pal.background.r < 0.5, "Default theme bg should be dark");
867        assert_eq!(iced_t.to_string(), "Default");
868
869        // Index 11 = Solarized Light — custom theme with light background
870        state.current_theme_index = 11;
871        let iced_t = state.iced_theme();
872        let pal = iced_t.palette();
873        assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
874        assert_eq!(iced_t.to_string(), "Solarized Light");
875
876        // Index 12 = Gruvbox Dark — accent should come from core
877        state.current_theme_index = 12;
878        let iced_t = state.iced_theme();
879        let pal = iced_t.palette();
880        let core = gitkraft_core::theme_by_index(12);
881        let expected_accent = rgb_to_iced(core.accent);
882        assert!(
883            (pal.primary.r - expected_accent.r).abs() < 0.01
884                && (pal.primary.g - expected_accent.g).abs() < 0.01
885                && (pal.primary.b - expected_accent.b).abs() < 0.01,
886            "Gruvbox Dark accent should match core accent"
887        );
888    }
889
890    #[test]
891    fn iced_theme_name_round_trips_through_core() {
892        // Ensure the custom theme name matches a core THEME_NAMES entry so
893        // that ThemeColors::from_theme() can map it back to the right index.
894        for i in 0..gitkraft_core::THEME_COUNT {
895            let mut state = GitKraft::new();
896            state.current_theme_index = i;
897            let iced_t = state.iced_theme();
898            let name = iced_t.to_string();
899            let resolved = gitkraft_core::theme_index_by_name(&name);
900            assert_eq!(
901                resolved,
902                i,
903                "theme index {i} ({}) did not round-trip through iced_theme name",
904                gitkraft_core::THEME_NAMES[i]
905            );
906        }
907    }
908
909    #[test]
910    fn current_theme_name_round_trips() {
911        let mut state = GitKraft::new();
912        state.current_theme_index = 8;
913        assert_eq!(state.current_theme_name(), "Dracula");
914        state.current_theme_index = 0;
915        assert_eq!(state.current_theme_name(), "Default");
916    }
917
918    #[test]
919    fn repo_tab_new_empty() {
920        let tab = RepoTab::new_empty();
921        assert!(tab.repo_path.is_none());
922        assert!(!tab.has_repo());
923        assert_eq!(tab.display_name(), "New Tab");
924        assert!(tab.commits.is_empty());
925        assert!(tab.branches.is_empty());
926        assert!(!tab.is_loading);
927    }
928
929    #[test]
930    fn repo_tab_display_name_with_path() {
931        let mut tab = RepoTab::new_empty();
932        tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
933        assert!(tab.has_repo());
934        assert_eq!(tab.display_name(), "cool-repo");
935    }
936
937    #[test]
938    fn search_defaults() {
939        let state = GitKraft::new();
940        assert!(!state.search_visible);
941        assert!(state.search_query.is_empty());
942        assert!(state.search_results.is_empty());
943        assert!(state.search_selected.is_none());
944    }
945
946    #[test]
947    fn context_menu_variants_exist() {
948        // Verify all context menu variants can be constructed
949        use crate::state::ContextMenu;
950
951        let _branch = ContextMenu::Branch {
952            name: "main".to_string(),
953            is_current: true,
954            local_index: 0,
955        };
956        let _remote = ContextMenu::RemoteBranch {
957            name: "origin/main".to_string(),
958        };
959        let _commit = ContextMenu::Commit {
960            index: 0,
961            oid: "abc1234".to_string(),
962        };
963        let _stash = ContextMenu::Stash { index: 0 };
964        let _unstaged = ContextMenu::UnstagedFile {
965            path: "src/main.rs".to_string(),
966        };
967        let _staged = ContextMenu::StagedFile {
968            path: "src/lib.rs".to_string(),
969        };
970    }
971
972    #[test]
973    fn repo_tab_context_menu_defaults_to_none() {
974        let tab = crate::state::RepoTab::new_empty();
975        assert!(tab.context_menu.is_none());
976    }
977
978    #[test]
979    fn context_menu_variants_constructable() {
980        use crate::state::ContextMenu;
981        let _ = ContextMenu::Stash { index: 0 };
982        let _ = ContextMenu::UnstagedFile {
983            path: "a.rs".into(),
984        };
985        let _ = ContextMenu::StagedFile {
986            path: "b.rs".into(),
987        };
988    }
989
990    #[test]
991    fn selected_unstaged_defaults_empty() {
992        let tab = crate::state::RepoTab::new_empty();
993        assert!(tab.selected_unstaged.is_empty());
994        assert!(tab.selected_staged.is_empty());
995    }
996
997    #[test]
998    fn selected_unstaged_toggle() {
999        let mut tab = crate::state::RepoTab::new_empty();
1000        tab.selected_unstaged.insert("a.rs".to_string());
1001        tab.selected_unstaged.insert("b.rs".to_string());
1002        assert_eq!(tab.selected_unstaged.len(), 2);
1003        assert!(tab.selected_unstaged.contains("a.rs"));
1004        tab.selected_unstaged.remove("a.rs");
1005        assert_eq!(tab.selected_unstaged.len(), 1);
1006        assert!(!tab.selected_unstaged.contains("a.rs"));
1007    }
1008
1009    #[test]
1010    fn detect_system_editor_returns_valid() {
1011        // Just verify it doesn't panic
1012        let editor = super::detect_system_editor();
1013        let _ = editor.display_name();
1014    }
1015
1016    // ── Multi-file commit diff selection ──────────────────────────────────
1017
1018    #[test]
1019    fn selected_commit_file_indices_defaults_to_empty_vec() {
1020        let tab = RepoTab::new_empty();
1021        assert!(tab.selected_commit_file_indices.is_empty());
1022        // Must be a Vec (ordered), not a HashSet — check it supports indexing
1023        let v: &Vec<usize> = &tab.selected_commit_file_indices;
1024        assert_eq!(v.len(), 0);
1025    }
1026
1027    #[test]
1028    fn multi_file_diffs_defaults_empty() {
1029        let tab = RepoTab::new_empty();
1030        assert!(tab.multi_file_diffs.is_empty());
1031    }
1032
1033    #[test]
1034    fn keyboard_modifiers_default_has_no_shift() {
1035        let state = GitKraft::new();
1036        assert!(!state.keyboard_modifiers.shift());
1037    }
1038
1039    #[test]
1040    fn selected_commit_file_indices_preserves_insertion_order() {
1041        let mut tab = RepoTab::new_empty();
1042        tab.selected_commit_file_indices.push(5);
1043        tab.selected_commit_file_indices.push(2);
1044        tab.selected_commit_file_indices.push(8);
1045        assert_eq!(tab.selected_commit_file_indices, vec![5, 2, 8]);
1046    }
1047
1048    #[test]
1049    fn selected_commit_file_indices_cleared_on_reset() {
1050        let mut tab = RepoTab::new_empty();
1051        tab.selected_commit_file_indices.push(0);
1052        tab.selected_commit_file_indices.push(1);
1053        tab.selected_commit_file_indices.clear();
1054        assert!(tab.selected_commit_file_indices.is_empty());
1055    }
1056
1057    #[test]
1058    fn multi_file_diffs_cleared_on_reset() {
1059        let mut tab = RepoTab::new_empty();
1060        tab.multi_file_diffs.push(gitkraft_core::DiffInfo {
1061            old_file: String::new(),
1062            new_file: "a.rs".to_string(),
1063            status: gitkraft_core::FileStatus::Modified,
1064            hunks: vec![],
1065        });
1066        tab.multi_file_diffs.clear();
1067        assert!(tab.multi_file_diffs.is_empty());
1068    }
1069
1070    #[test]
1071    fn commit_range_diffs_defaults_empty() {
1072        let tab = RepoTab::new_empty();
1073        assert!(tab.commit_range_diffs.is_empty());
1074    }
1075
1076    #[test]
1077    fn commit_range_diffs_cleared_on_apply_payload() {
1078        // verify the field is reset — just check it's accessible and clearable
1079        let mut tab = RepoTab::new_empty();
1080        tab.commit_range_diffs.push(gitkraft_core::DiffInfo {
1081            old_file: String::new(),
1082            new_file: "x.rs".to_string(),
1083            status: gitkraft_core::FileStatus::Modified,
1084            hunks: vec![],
1085        });
1086        tab.commit_range_diffs.clear();
1087        assert!(tab.commit_range_diffs.is_empty());
1088    }
1089
1090    // ── ModifiersChanged update ───────────────────────────────────────────
1091
1092    #[test]
1093    fn modifiers_changed_sets_shift_state() {
1094        use crate::message::Message;
1095        let mut state = GitKraft::new();
1096        assert!(!state.keyboard_modifiers.shift());
1097
1098        let _ = state.update(Message::ModifiersChanged(iced::keyboard::Modifiers::SHIFT));
1099        assert!(state.keyboard_modifiers.shift());
1100
1101        let _ = state.update(Message::ModifiersChanged(
1102            iced::keyboard::Modifiers::default(),
1103        ));
1104        assert!(!state.keyboard_modifiers.shift());
1105    }
1106
1107    // ── SelectDiffByIndex update ──────────────────────────────────────────
1108
1109    fn make_commit_files(names: &[&str]) -> Vec<gitkraft_core::DiffFileEntry> {
1110        names
1111            .iter()
1112            .map(|name| gitkraft_core::DiffFileEntry {
1113                old_file: String::new(),
1114                new_file: name.to_string(),
1115                status: gitkraft_core::FileStatus::Modified,
1116            })
1117            .collect()
1118    }
1119
1120    #[test]
1121    fn select_diff_by_index_regular_click_clears_multi_selection() {
1122        use crate::message::Message;
1123        let mut state = GitKraft::new();
1124        // Provide a repo_path and oid so the update handler can reach the
1125        // `selected_file_index = Some(index)` assignment (the async task it
1126        // spawns is dropped without execution — no real repo is needed).
1127        state.active_tab_mut().repo_path =
1128            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1129        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1130        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1131        // Pre-populate a multi-selection
1132        state.active_tab_mut().selected_commit_file_indices = vec![0, 1];
1133
1134        // Regular click (no Shift) — should collapse to single-file selection
1135        let _ = state.update(Message::SelectDiffByIndex(0));
1136
1137        assert!(state.active_tab().selected_commit_file_indices.is_empty());
1138        assert!(state.active_tab().multi_file_diffs.is_empty());
1139        assert_eq!(state.active_tab().selected_file_index, Some(0));
1140    }
1141
1142    #[test]
1143    fn select_diff_by_index_shift_click_adds_both_files_to_selection() {
1144        use crate::message::Message;
1145        let mut state = GitKraft::new();
1146        state.active_tab_mut().repo_path =
1147            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1148        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1149        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1150        state.active_tab_mut().selected_file_index = Some(0);
1151
1152        // Shift+Click on file 1 should anchor 0 and add 1
1153        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1154        let _ = state.update(Message::SelectDiffByIndex(1));
1155
1156        let sel = &state.active_tab().selected_commit_file_indices;
1157        assert!(sel.contains(&0), "anchor file 0 should be selected");
1158        assert!(sel.contains(&1), "newly clicked file 1 should be selected");
1159        assert_eq!(sel.len(), 2);
1160    }
1161
1162    #[test]
1163    fn anchor_file_index_defaults_to_none() {
1164        let tab = RepoTab::new_empty();
1165        assert!(tab.anchor_file_index.is_none());
1166    }
1167
1168    #[test]
1169    fn regular_click_sets_anchor() {
1170        use crate::message::Message;
1171        let mut state = GitKraft::new();
1172        state.active_tab_mut().repo_path =
1173            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1174        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1175        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1176
1177        let _ = state.update(Message::SelectDiffByIndex(2));
1178
1179        assert_eq!(
1180            state.active_tab().anchor_file_index,
1181            Some(2),
1182            "regular click must set anchor to the clicked index"
1183        );
1184    }
1185
1186    #[test]
1187    fn shift_click_selects_range_downward_from_anchor() {
1188        use crate::message::Message;
1189        let mut state = GitKraft::new();
1190        state.active_tab_mut().repo_path =
1191            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1192        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1193        state.active_tab_mut().commit_files =
1194            make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1195        // Anchor at index 1
1196        state.active_tab_mut().anchor_file_index = Some(1);
1197        state.active_tab_mut().selected_file_index = Some(1);
1198
1199        // Shift+Click on index 4 — should select 1, 2, 3, 4
1200        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1201        let _ = state.update(Message::SelectDiffByIndex(4));
1202
1203        let sel = &state.active_tab().selected_commit_file_indices;
1204        assert_eq!(
1205            sel,
1206            &vec![1, 2, 3, 4],
1207            "range must be contiguous from anchor to click"
1208        );
1209    }
1210
1211    #[test]
1212    fn shift_click_selects_range_upward_from_anchor() {
1213        use crate::message::Message;
1214        let mut state = GitKraft::new();
1215        state.active_tab_mut().repo_path =
1216            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1217        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1218        state.active_tab_mut().commit_files =
1219            make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1220        // Anchor at index 4 (bottom)
1221        state.active_tab_mut().anchor_file_index = Some(4);
1222        state.active_tab_mut().selected_file_index = Some(4);
1223
1224        // Shift+Click on index 1 — should select 1, 2, 3, 4 (ascending)
1225        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1226        let _ = state.update(Message::SelectDiffByIndex(1));
1227
1228        let sel = &state.active_tab().selected_commit_file_indices;
1229        assert_eq!(
1230            sel,
1231            &vec![1, 2, 3, 4],
1232            "range must be stored ascending regardless of click direction"
1233        );
1234    }
1235
1236    #[test]
1237    fn shift_click_anchor_fixed_on_subsequent_clicks() {
1238        use crate::message::Message;
1239        let mut state = GitKraft::new();
1240        state.active_tab_mut().repo_path =
1241            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1242        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1243        state.active_tab_mut().commit_files =
1244            make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1245        // Anchor at index 2
1246        state.active_tab_mut().anchor_file_index = Some(2);
1247        state.active_tab_mut().selected_file_index = Some(2);
1248        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1249
1250        // First Shift+Click: extend to 4 → range {2, 3, 4}
1251        let _ = state.update(Message::SelectDiffByIndex(4));
1252        assert_eq!(
1253            state.active_tab().selected_commit_file_indices,
1254            vec![2, 3, 4]
1255        );
1256
1257        // Second Shift+Click: shrink back to 3 → range {2, 3} (anchor still 2)
1258        let _ = state.update(Message::SelectDiffByIndex(3));
1259        assert_eq!(
1260            state.active_tab().selected_commit_file_indices,
1261            vec![2, 3],
1262            "anchor must stay fixed; second Shift+Click shrinks the range"
1263        );
1264
1265        // Third Shift+Click: extend upward → range {0, 1, 2} (anchor still 2)
1266        let _ = state.update(Message::SelectDiffByIndex(0));
1267        assert_eq!(
1268            state.active_tab().selected_commit_file_indices,
1269            vec![0, 1, 2],
1270            "anchor must stay fixed; can extend range in either direction"
1271        );
1272    }
1273
1274    #[test]
1275    fn shift_click_on_anchor_itself_gives_single_item_range() {
1276        use crate::message::Message;
1277        let mut state = GitKraft::new();
1278        state.active_tab_mut().repo_path =
1279            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1280        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1281        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1282        state.active_tab_mut().anchor_file_index = Some(1);
1283        state.active_tab_mut().selected_file_index = Some(1);
1284
1285        // Shift+Click on the anchor itself → single-item range {1}
1286        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1287        let _ = state.update(Message::SelectDiffByIndex(1));
1288
1289        assert_eq!(state.active_tab().selected_commit_file_indices, vec![1]);
1290        assert!(
1291            state.active_tab().multi_file_diffs.is_empty(),
1292            "single-item range must not populate multi_file_diffs"
1293        );
1294    }
1295
1296    #[test]
1297    fn shift_click_range_is_always_ascending() {
1298        use crate::message::Message;
1299        let mut state = GitKraft::new();
1300        state.active_tab_mut().repo_path =
1301            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1302        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1303        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs"]);
1304        state.active_tab_mut().anchor_file_index = Some(3);
1305        state.active_tab_mut().selected_file_index = Some(3);
1306
1307        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1308        let _ = state.update(Message::SelectDiffByIndex(0));
1309
1310        let sel = &state.active_tab().selected_commit_file_indices;
1311        let is_sorted = sel.windows(2).all(|w| w[0] < w[1]);
1312        assert!(
1313            is_sorted,
1314            "selection must always be stored in ascending order"
1315        );
1316        assert_eq!(sel, &vec![0, 1, 2, 3]);
1317    }
1318
1319    #[test]
1320    fn checkout_file_at_commit_message_variants_exist() {
1321        use crate::message::Message;
1322        // Verify the new message variants can be constructed
1323        let _single =
1324            Message::CheckoutFileAtCommit("abc123".to_string(), "src/main.rs".to_string());
1325        let _multi = Message::CheckoutMultiFilesAtCommit(
1326            "abc123".to_string(),
1327            vec!["a.rs".to_string(), "b.rs".to_string()],
1328        );
1329    }
1330
1331    #[test]
1332    fn checkout_file_at_commit_closes_context_menu() {
1333        use crate::message::Message;
1334        let mut state = GitKraft::new();
1335        state.active_tab_mut().repo_path =
1336            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1337        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1338            oid: "abc123".to_string(),
1339            file_path: "src/main.rs".to_string(),
1340        });
1341        let _ = state.update(Message::CheckoutFileAtCommit(
1342            "abc123".to_string(),
1343            "src/main.rs".to_string(),
1344        ));
1345        assert!(state.active_tab().context_menu.is_none());
1346    }
1347
1348    #[test]
1349    fn checkout_multi_files_at_commit_closes_context_menu() {
1350        use crate::message::Message;
1351        let mut state = GitKraft::new();
1352        state.active_tab_mut().repo_path =
1353            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1354        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1355            oid: "abc123".to_string(),
1356            file_path: "src/main.rs".to_string(),
1357        });
1358        let _ = state.update(Message::CheckoutMultiFilesAtCommit(
1359            "abc123".to_string(),
1360            vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
1361        ));
1362        assert!(state.active_tab().context_menu.is_none());
1363    }
1364
1365    // ── Commit multi-selection ────────────────────────────────────────────
1366
1367    fn make_test_commits(count: usize) -> Vec<gitkraft_core::CommitInfo> {
1368        (0..count)
1369            .map(|i| gitkraft_core::CommitInfo {
1370                oid: i.to_string(),
1371                short_oid: i.to_string(),
1372                summary: String::new(),
1373                message: String::new(),
1374                author_name: String::new(),
1375                author_email: String::new(),
1376                time: Default::default(),
1377                parent_ids: Vec::new(),
1378            })
1379            .collect()
1380    }
1381
1382    #[test]
1383    fn selected_commits_defaults_empty() {
1384        let tab = RepoTab::new_empty();
1385        assert!(tab.selected_commits.is_empty());
1386        assert!(tab.anchor_commit_index.is_none());
1387    }
1388
1389    #[test]
1390    fn regular_click_commit_sets_anchor_and_clears_range() {
1391        use crate::message::Message;
1392        let mut state = GitKraft::new();
1393        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
1394        state.active_tab_mut().commits = make_test_commits(3);
1395        state.active_tab_mut().selected_commits = vec![0, 1, 2];
1396
1397        let _ = state.update(Message::SelectCommit(1));
1398
1399        assert_eq!(state.active_tab().anchor_commit_index, Some(1));
1400        assert!(state.active_tab().selected_commits.is_empty());
1401        assert_eq!(state.active_tab().selected_commit, Some(1));
1402    }
1403
1404    #[test]
1405    fn shift_click_commit_selects_range_from_anchor() {
1406        use crate::message::Message;
1407        let mut state = GitKraft::new();
1408        state.active_tab_mut().commits = make_test_commits(5);
1409        state.active_tab_mut().anchor_commit_index = Some(1);
1410        state.active_tab_mut().selected_commit = Some(1);
1411
1412        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1413        let _ = state.update(Message::SelectCommit(4));
1414
1415        assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3, 4]);
1416    }
1417
1418    #[test]
1419    fn shift_click_commit_range_is_ascending_when_clicking_above_anchor() {
1420        use crate::message::Message;
1421        let mut state = GitKraft::new();
1422        state.active_tab_mut().commits = make_test_commits(5);
1423        state.active_tab_mut().anchor_commit_index = Some(3);
1424        state.active_tab_mut().selected_commit = Some(3);
1425
1426        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1427        let _ = state.update(Message::SelectCommit(1));
1428
1429        assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3]);
1430    }
1431
1432    // ── ExecuteCommitAction message ───────────────────────────────────────
1433
1434    #[test]
1435    fn execute_commit_action_closes_context_menu() {
1436        use crate::message::Message;
1437        let mut state = GitKraft::new();
1438        state.active_tab_mut().repo_path =
1439            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1440        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::Commit {
1441            index: 0,
1442            oid: "abc123".to_string(),
1443        });
1444
1445        let _ = state.update(Message::ExecuteCommitAction(
1446            "abc123".to_string(),
1447            gitkraft_core::CommitAction::CherryPick,
1448        ));
1449
1450        assert!(state.active_tab().context_menu.is_none());
1451    }
1452
1453    #[test]
1454    fn execute_commit_action_sets_loading_when_repo_open() {
1455        use crate::message::Message;
1456        let mut state = GitKraft::new();
1457        state.active_tab_mut().repo_path =
1458            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1459
1460        let _ = state.update(Message::ExecuteCommitAction(
1461            "abc123".to_string(),
1462            gitkraft_core::CommitAction::ResetHard,
1463        ));
1464
1465        assert!(state.active_tab().is_loading);
1466    }
1467
1468    #[test]
1469    fn execute_commit_action_no_repo_does_not_set_loading() {
1470        use crate::message::Message;
1471        let mut state = GitKraft::new();
1472        // No repo_path set
1473
1474        let _ = state.update(Message::ExecuteCommitAction(
1475            "abc123".to_string(),
1476            gitkraft_core::CommitAction::CherryPick,
1477        ));
1478
1479        assert!(!state.active_tab().is_loading);
1480    }
1481
1482    #[test]
1483    fn execute_commit_action_sets_status_message_from_action_label() {
1484        use crate::message::Message;
1485        let mut state = GitKraft::new();
1486        state.active_tab_mut().repo_path =
1487            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1488
1489        let _ = state.update(Message::ExecuteCommitAction(
1490            "abc123".to_string(),
1491            gitkraft_core::CommitAction::Revert,
1492        ));
1493
1494        let status = state.active_tab().status_message.as_deref().unwrap_or("");
1495        // Status message should contain the action's label
1496        assert!(
1497            status.contains("Revert commit"),
1498            "expected status to contain 'Revert commit', got: {status:?}"
1499        );
1500    }
1501
1502    // ── File history / blame / delete state ──────────────────────────────
1503
1504    #[test]
1505    fn file_history_defaults_empty() {
1506        let tab = RepoTab::new_empty();
1507        assert!(tab.file_history_path.is_none());
1508        assert!(tab.file_history_commits.is_empty());
1509        assert_eq!(tab.file_history_scroll, 0.0);
1510    }
1511
1512    #[test]
1513    fn blame_defaults_empty() {
1514        let tab = RepoTab::new_empty();
1515        assert!(tab.blame_path.is_none());
1516        assert!(tab.blame_lines.is_empty());
1517        assert_eq!(tab.blame_scroll, 0.0);
1518    }
1519
1520    #[test]
1521    fn pending_delete_file_defaults_none() {
1522        let tab = RepoTab::new_empty();
1523        assert!(tab.pending_delete_file.is_none());
1524    }
1525
1526    #[test]
1527    fn view_file_history_sets_path_and_clears_blame() {
1528        use crate::message::Message;
1529        let mut state = GitKraft::new();
1530        state.active_tab_mut().repo_path =
1531            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1532        state.active_tab_mut().blame_path = Some("some/file.rs".to_string());
1533
1534        let _ = state.update(Message::ViewFileHistory("src/main.rs".to_string()));
1535
1536        assert_eq!(
1537            state.active_tab().file_history_path.as_deref(),
1538            Some("src/main.rs")
1539        );
1540        // Opening history should close blame
1541        assert!(state.active_tab().blame_path.is_none());
1542    }
1543
1544    #[test]
1545    fn close_file_history_clears_state() {
1546        use crate::message::Message;
1547        let mut state = GitKraft::new();
1548        state.active_tab_mut().file_history_path = Some("src/lib.rs".to_string());
1549        state.active_tab_mut().file_history_commits = vec![gitkraft_core::CommitInfo {
1550            oid: "abc".to_string(),
1551            short_oid: "abc".to_string(),
1552            summary: "s".to_string(),
1553            message: "s".to_string(),
1554            author_name: "a".to_string(),
1555            author_email: "a@b.com".to_string(),
1556            time: Default::default(),
1557            parent_ids: vec![],
1558        }];
1559
1560        let _ = state.update(Message::CloseFileHistory);
1561
1562        assert!(state.active_tab().file_history_path.is_none());
1563        assert!(state.active_tab().file_history_commits.is_empty());
1564    }
1565
1566    #[test]
1567    fn view_file_blame_sets_path_and_clears_history() {
1568        use crate::message::Message;
1569        let mut state = GitKraft::new();
1570        state.active_tab_mut().repo_path =
1571            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1572        state.active_tab_mut().file_history_path = Some("some/file.rs".to_string());
1573
1574        let _ = state.update(Message::ViewFileBlame("src/lib.rs".to_string()));
1575
1576        assert_eq!(state.active_tab().blame_path.as_deref(), Some("src/lib.rs"));
1577        // Opening blame should close history
1578        assert!(state.active_tab().file_history_path.is_none());
1579    }
1580
1581    #[test]
1582    fn selecting_new_commit_closes_blame_overlay() {
1583        use crate::message::Message;
1584        let mut state = GitKraft::new();
1585        state.active_tab_mut().repo_path =
1586            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1587        // Pre-populate a commit list so SelectCommit can find the commit.
1588        state.active_tab_mut().commits = vec![
1589            gitkraft_core::CommitInfo {
1590                oid: "abc1".into(),
1591                short_oid: "abc1".into(),
1592                summary: "first".into(),
1593                message: "first".into(),
1594                author_name: "A".into(),
1595                author_email: "a@a.com".into(),
1596                time: Default::default(),
1597                parent_ids: Vec::new(),
1598            },
1599            gitkraft_core::CommitInfo {
1600                oid: "abc2".into(),
1601                short_oid: "abc2".into(),
1602                summary: "second".into(),
1603                message: "second".into(),
1604                author_name: "A".into(),
1605                author_email: "a@a.com".into(),
1606                time: Default::default(),
1607                parent_ids: Vec::new(),
1608            },
1609        ];
1610        // Blame is currently open for a file from the first commit.
1611        state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1612        state.active_tab_mut().blame_lines = vec![gitkraft_core::BlameLine {
1613            line_number: 1,
1614            content: "fn main() {}".into(),
1615            short_oid: "abc1".into(),
1616            oid: "abc1".into(),
1617            author_name: "A".into(),
1618            time: Default::default(),
1619        }];
1620
1621        // Click a different commit — blame must close automatically.
1622        let _ = state.update(Message::SelectCommit(1));
1623
1624        assert!(
1625            state.active_tab().blame_path.is_none(),
1626            "blame_path must be cleared when a new commit is selected"
1627        );
1628        assert!(
1629            state.active_tab().blame_lines.is_empty(),
1630            "blame_lines must be cleared when a new commit is selected"
1631        );
1632    }
1633
1634    #[test]
1635    fn close_file_blame_clears_state() {
1636        use crate::message::Message;
1637        let mut state = GitKraft::new();
1638        state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1639
1640        let _ = state.update(Message::CloseFileBlame);
1641
1642        assert!(state.active_tab().blame_path.is_none());
1643        assert!(state.active_tab().blame_lines.is_empty());
1644    }
1645
1646    #[test]
1647    fn delete_file_sets_pending() {
1648        use crate::message::Message;
1649        let mut state = GitKraft::new();
1650
1651        let _ = state.update(Message::DeleteFile("src/old.rs".to_string()));
1652
1653        assert_eq!(
1654            state.active_tab().pending_delete_file.as_deref(),
1655            Some("src/old.rs")
1656        );
1657        assert!(state.active_tab().context_menu.is_none());
1658    }
1659
1660    #[test]
1661    fn cancel_delete_file_clears_pending() {
1662        use crate::message::Message;
1663        let mut state = GitKraft::new();
1664        state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1665
1666        let _ = state.update(Message::CancelDeleteFile);
1667
1668        assert!(state.active_tab().pending_delete_file.is_none());
1669    }
1670
1671    #[test]
1672    fn confirm_delete_file_no_repo_is_noop() {
1673        use crate::message::Message;
1674        let mut state = GitKraft::new();
1675        state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1676        // No repo_path → should not set is_loading
1677
1678        let _ = state.update(Message::ConfirmDeleteFile);
1679
1680        assert!(!state.active_tab().is_loading);
1681    }
1682
1683    #[test]
1684    fn shift_arrow_down_extends_file_list_selection_when_files_loaded() {
1685        use crate::message::Message;
1686        let mut state = GitKraft::new();
1687        state.active_tab_mut().repo_path =
1688            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1689        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1690        state.active_tab_mut().selected_file_index = Some(0);
1691        state.active_tab_mut().anchor_file_index = Some(0);
1692        // keyboard_modifiers must have SHIFT set for range selection to trigger
1693        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1694
1695        let _ = state.update(Message::ShiftArrowDown);
1696
1697        assert_eq!(state.active_tab().selected_file_index, Some(1));
1698        // Range should now include both files
1699        assert!(state.active_tab().selected_commit_file_indices.contains(&0));
1700        assert!(state.active_tab().selected_commit_file_indices.contains(&1));
1701    }
1702
1703    #[test]
1704    fn shift_arrow_down_falls_through_to_commit_log_when_no_files() {
1705        use crate::message::Message;
1706        let mut state = GitKraft::new();
1707        state.active_tab_mut().commits = make_test_commits(5);
1708        state.active_tab_mut().selected_commit = Some(1);
1709        state.active_tab_mut().anchor_commit_index = Some(1);
1710        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1711        // no commit_files
1712
1713        let _ = state.update(Message::ShiftArrowDown);
1714
1715        assert_eq!(state.active_tab().selected_commit, Some(2));
1716        assert!(state.active_tab().selected_commits.contains(&1));
1717        assert!(state.active_tab().selected_commits.contains(&2));
1718    }
1719
1720    #[test]
1721    fn file_system_changed_triggers_full_refresh() {
1722        use crate::message::Message;
1723        let mut state = GitKraft::new();
1724        state.active_tab_mut().repo_path =
1725            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1726
1727        // FileSystemChanged should call refresh_active_tab() which returns
1728        // a non-none Task.  We verify by checking that is_loading is NOT set
1729        // synchronously (the task is async), but that no error is set either.
1730        let _task = state.update(Message::FileSystemChanged);
1731
1732        // With a repo_path set, the handler must have attempted a refresh
1733        // (it returns a Task, so is_loading is set by the task executor, not here).
1734        // What we CAN check: no error was set, and status is not "error".
1735        assert!(
1736            state.active_tab().error_message.is_none(),
1737            "FileSystemChanged must not set an error message"
1738        );
1739    }
1740
1741    // ── Tab duplication prevention tests ───────────────────────────────────
1742
1743    /// Helper: build a minimal `RepoPayload` (aka `RepoSnapshot`) for a given path.
1744    fn fake_payload(workdir: &str) -> crate::message::RepoPayload {
1745        gitkraft_core::RepoSnapshot {
1746            info: gitkraft_core::RepoInfo {
1747                path: std::path::PathBuf::from(format!("{workdir}/.git")),
1748                workdir: Some(std::path::PathBuf::from(workdir)),
1749                head_branch: Some("main".into()),
1750                is_bare: false,
1751                state: gitkraft_core::RepoState::Clean,
1752            },
1753            branches: Vec::new(),
1754            commits: Vec::new(),
1755            graph_rows: Vec::new(),
1756            unstaged: Vec::new(),
1757            staged: Vec::new(),
1758            stashes: Vec::new(),
1759            remotes: Vec::new(),
1760        }
1761    }
1762
1763    /// Helper: set up a tab as if a repo was fully loaded.
1764    fn setup_loaded_tab(tab: &mut RepoTab, path: &str) {
1765        tab.repo_path = Some(std::path::PathBuf::from(path));
1766        tab.repo_info = Some(gitkraft_core::RepoInfo {
1767            path: std::path::PathBuf::from(format!("{path}/.git")),
1768            workdir: Some(std::path::PathBuf::from(path)),
1769            head_branch: Some("main".into()),
1770            is_bare: false,
1771            state: gitkraft_core::RepoState::Clean,
1772        });
1773    }
1774
1775    #[test]
1776    fn open_repo_creates_new_tab_when_repo_already_open() {
1777        use crate::message::Message;
1778        let mut state = GitKraft::new();
1779        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1780
1781        assert_eq!(state.tabs.len(), 1);
1782        assert_eq!(state.active_tab, 0);
1783
1784        // Clicking "Open" should create a new tab when the current has a repo.
1785        let _task = state.update(Message::OpenRepo);
1786
1787        assert_eq!(state.tabs.len(), 2);
1788        assert_eq!(state.active_tab, 1);
1789        // The new tab should be loading (folder picker opening).
1790        assert!(state.tabs[1].is_loading);
1791        // The original tab should be untouched.
1792        assert_eq!(
1793            state.tabs[0].repo_path.as_deref(),
1794            Some(std::path::Path::new("/home/user/repo-a"))
1795        );
1796    }
1797
1798    #[test]
1799    fn open_repo_reuses_empty_tab() {
1800        use crate::message::Message;
1801        let mut state = GitKraft::new();
1802        // Active tab is empty (no repo loaded).
1803        assert!(!state.active_tab().has_repo());
1804
1805        let _task = state.update(Message::OpenRepo);
1806
1807        // Should NOT create a new tab when the active tab is empty.
1808        assert_eq!(state.tabs.len(), 1);
1809        assert_eq!(state.active_tab, 0);
1810        assert!(state.tabs[0].is_loading);
1811    }
1812
1813    #[test]
1814    fn repo_selected_deduplicates_already_open_repo() {
1815        use crate::message::Message;
1816        let mut state = GitKraft::new();
1817        // Tab 0: fully loaded repo-a
1818        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1819        // Tab 1: empty (simulates the new tab created by OpenRepo)
1820        state.tabs.push(RepoTab::new_empty());
1821        state.active_tab = 1;
1822
1823        // User selects a folder that matches the already-open repo.
1824        let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
1825            "/home/user/repo-a",
1826        ))));
1827
1828        // Should switch to the existing tab and remove the empty one.
1829        assert_eq!(state.tabs.len(), 1);
1830        assert_eq!(state.active_tab, 0);
1831        assert_eq!(
1832            state.tabs[0].repo_path.as_deref(),
1833            Some(std::path::Path::new("/home/user/repo-a"))
1834        );
1835    }
1836
1837    #[test]
1838    fn repo_selected_opens_new_repo_in_empty_tab() {
1839        use crate::message::Message;
1840        let mut state = GitKraft::new();
1841        // Tab 0: fully loaded repo-a
1842        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1843        // Tab 1: empty (simulates the new tab created by OpenRepo)
1844        state.tabs.push(RepoTab::new_empty());
1845        state.active_tab = 1;
1846
1847        // User selects a DIFFERENT repo.
1848        let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
1849            "/home/user/repo-b",
1850        ))));
1851
1852        // Should keep both tabs; the empty tab is now loading repo-b.
1853        assert_eq!(state.tabs.len(), 2);
1854        assert_eq!(state.active_tab, 1);
1855        assert!(state.tabs[1]
1856            .status_message
1857            .as_deref()
1858            .unwrap_or("")
1859            .contains("repo-b"));
1860    }
1861
1862    #[test]
1863    fn repo_selected_cancel_removes_empty_tab() {
1864        use crate::message::Message;
1865        let mut state = GitKraft::new();
1866        // Tab 0: fully loaded repo-a
1867        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1868        // Tab 1: empty (created by OpenRepo, waiting for folder picker)
1869        state.tabs.push(RepoTab::new_empty());
1870        state.active_tab = 1;
1871
1872        // User cancels the folder picker.
1873        let _task = state.update(Message::RepoSelected(None));
1874
1875        // The empty tab should be removed; switch back to tab 0.
1876        assert_eq!(state.tabs.len(), 1);
1877        assert_eq!(state.active_tab, 0);
1878        assert_eq!(
1879            state.tabs[0].repo_path.as_deref(),
1880            Some(std::path::Path::new("/home/user/repo-a"))
1881        );
1882    }
1883
1884    #[test]
1885    fn repo_selected_cancel_keeps_tab_if_only_one() {
1886        use crate::message::Message;
1887        let mut state = GitKraft::new();
1888        // Single empty tab — shouldn't be removed on cancel.
1889        assert_eq!(state.tabs.len(), 1);
1890        assert!(!state.active_tab().has_repo());
1891
1892        let _task = state.update(Message::RepoSelected(None));
1893
1894        assert_eq!(state.tabs.len(), 1);
1895        assert!(!state.active_tab().is_loading);
1896    }
1897
1898    #[test]
1899    fn open_recent_repo_deduplicates() {
1900        use crate::message::Message;
1901        let mut state = GitKraft::new();
1902        // Tab 0: fully loaded repo-a
1903        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1904        // Tab 1: empty tab
1905        state.tabs.push(RepoTab::new_empty());
1906        state.active_tab = 1;
1907
1908        // Opening a recent repo that's already open should switch to it.
1909        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
1910            "/home/user/repo-a",
1911        )));
1912
1913        assert_eq!(state.active_tab, 0);
1914    }
1915
1916    #[test]
1917    fn open_recent_repo_creates_new_tab_when_current_has_repo() {
1918        use crate::message::Message;
1919        let mut state = GitKraft::new();
1920        // Tab 0: fully loaded repo-a
1921        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1922
1923        // Opening a different recent repo should create a new tab.
1924        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
1925            "/home/user/repo-b",
1926        )));
1927
1928        assert_eq!(state.tabs.len(), 2);
1929        assert_eq!(state.active_tab, 1);
1930        assert!(state.tabs[1].is_loading);
1931    }
1932
1933    #[test]
1934    fn open_recent_repo_uses_empty_tab() {
1935        use crate::message::Message;
1936        let mut state = GitKraft::new();
1937        // Active tab is empty.
1938        assert!(!state.active_tab().has_repo());
1939
1940        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
1941            "/home/user/repo-b",
1942        )));
1943
1944        // Should NOT create a new tab.
1945        assert_eq!(state.tabs.len(), 1);
1946        assert_eq!(state.active_tab, 0);
1947        assert!(state.tabs[0].is_loading);
1948    }
1949
1950    // ── Refresh race-condition tests ──────────────────────────────────────
1951
1952    #[test]
1953    fn repo_refreshed_targets_correct_tab_after_tab_switch() {
1954        use crate::message::Message;
1955        let mut state = GitKraft::new();
1956        // Tab 0: repo-a fully loaded
1957        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1958        // Tab 1: empty tab (user clicked "+")
1959        state.tabs.push(RepoTab::new_empty());
1960        state.active_tab = 1; // user switched to new empty tab
1961
1962        // Simulate: a RepoRefreshed result arrives for repo-a while tab 1 is active.
1963        let payload = fake_payload("/home/user/repo-a");
1964        let _task = state.update(Message::RepoRefreshed(Ok(payload)));
1965
1966        // The payload must land in tab 0 (which owns repo-a), NOT tab 1.
1967        assert!(
1968            state.tabs[0].repo_info.is_some(),
1969            "tab 0 should still have repo info after refresh"
1970        );
1971        assert_eq!(
1972            state.tabs[0].current_branch.as_deref(),
1973            Some("main"),
1974            "tab 0 should have updated branch from payload"
1975        );
1976        // Tab 1 must remain empty.
1977        assert!(
1978            state.tabs[1].repo_info.is_none(),
1979            "tab 1 (empty) must NOT receive the refresh payload"
1980        );
1981        assert!(
1982            state.tabs[1].repo_path.is_none(),
1983            "tab 1 should still have no repo path"
1984        );
1985    }
1986
1987    #[test]
1988    fn repo_refreshed_targets_active_tab_for_new_open() {
1989        use crate::message::Message;
1990        let mut state = GitKraft::new();
1991        // Active tab is empty — user just opened a brand-new repo.
1992        // (RepoSelected set the status but the tab doesn't have repo_path yet
1993        // matching the payload, so it falls back to active tab.)
1994        assert_eq!(state.tabs.len(), 1);
1995        assert!(!state.active_tab().has_repo());
1996
1997        let payload = fake_payload("/home/user/new-repo");
1998        let _task = state.update(Message::RepoOpened(Ok(payload)));
1999
2000        // Should have applied to the active tab (the only tab).
2001        assert_eq!(
2002            state.tabs[0].repo_path.as_deref(),
2003            Some(std::path::Path::new("/home/user/new-repo"))
2004        );
2005        assert!(state.tabs[0].repo_info.is_some());
2006    }
2007
2008    #[test]
2009    fn repo_refreshed_does_not_duplicate_into_new_tab() {
2010        use crate::message::Message;
2011        let mut state = GitKraft::new();
2012        // Tab 0: repo-a loaded
2013        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2014
2015        // User clicks "+" creating an empty tab 1, then switches to it.
2016        let _task = state.update(Message::NewTab);
2017        assert_eq!(state.tabs.len(), 2);
2018        assert_eq!(state.active_tab, 1);
2019
2020        // A refresh result for repo-a arrives (was triggered before the tab switch).
2021        let payload = fake_payload("/home/user/repo-a");
2022        let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2023
2024        // Tab 1 must remain empty — the payload should go to tab 0.
2025        assert!(
2026            state.tabs[1].repo_path.is_none(),
2027            "new empty tab must not receive repo-a refresh"
2028        );
2029        assert!(
2030            state.tabs[1].repo_info.is_none(),
2031            "new empty tab must not have repo_info"
2032        );
2033        // Tab 0 must have the refreshed data.
2034        assert_eq!(
2035            state.tabs[0].repo_path.as_deref(),
2036            Some(std::path::Path::new("/home/user/repo-a"))
2037        );
2038    }
2039
2040    #[test]
2041    fn git_operation_result_targets_correct_tab() {
2042        use crate::message::Message;
2043        let mut state = GitKraft::new();
2044        // Tab 0: repo-a
2045        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2046        // Tab 1: repo-b
2047        state.tabs.push(RepoTab::new_empty());
2048        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2049        state.active_tab = 1;
2050
2051        // A git operation result arrives for repo-a (e.g. push completed)
2052        // while user is on tab 1.
2053        let payload = fake_payload("/home/user/repo-a");
2054        let _task = state.update(Message::GitOperationResult(Ok(payload)));
2055
2056        // Payload should land in tab 0 (repo-a), not tab 1.
2057        assert_eq!(state.tabs[0].current_branch.as_deref(), Some("main"));
2058        // Tab 1's data should remain unchanged (still repo-b).
2059        assert_eq!(
2060            state.tabs[1].repo_path.as_deref(),
2061            Some(std::path::Path::new("/home/user/repo-b"))
2062        );
2063    }
2064
2065    #[test]
2066    fn multiple_new_tabs_dont_get_polluted_by_refresh() {
2067        use crate::message::Message;
2068        let mut state = GitKraft::new();
2069        // Tab 0: repo-a loaded
2070        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2071
2072        // User creates multiple new tabs.
2073        let _task = state.update(Message::NewTab);
2074        let _task = state.update(Message::NewTab);
2075        assert_eq!(state.tabs.len(), 3);
2076        assert_eq!(state.active_tab, 2);
2077
2078        // Refresh arrives for repo-a.
2079        let payload = fake_payload("/home/user/repo-a");
2080        let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2081
2082        // Only tab 0 should be affected.
2083        assert!(state.tabs[0].repo_info.is_some());
2084        assert!(state.tabs[1].repo_info.is_none());
2085        assert!(state.tabs[2].repo_info.is_none());
2086        assert!(state.tabs[1].repo_path.is_none());
2087        assert!(state.tabs[2].repo_path.is_none());
2088    }
2089
2090    #[test]
2091    fn repo_selected_dedup_adjusts_index_when_existing_is_after_active() {
2092        use crate::message::Message;
2093        let mut state = GitKraft::new();
2094        // Tab 0: empty (newly created by OpenRepo)
2095        // Tab 1: repo-a loaded
2096        state.tabs.push(RepoTab::new_empty());
2097        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
2098        state.active_tab = 0;
2099
2100        // User selects the same folder as repo-a.
2101        let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2102            "/home/user/repo-a",
2103        ))));
2104
2105        // Empty tab 0 should be removed; we should now be on tab 0 (formerly tab 1).
2106        assert_eq!(state.tabs.len(), 1);
2107        assert_eq!(state.active_tab, 0);
2108        assert_eq!(
2109            state.tabs[0].repo_path.as_deref(),
2110            Some(std::path::Path::new("/home/user/repo-a"))
2111        );
2112    }
2113
2114    // ── Multi-selection preservation across refresh ───────────────────────
2115
2116    #[test]
2117    fn apply_payload_preserves_multi_selection_by_oid() {
2118        let mut tab = RepoTab::new_empty();
2119        tab.commits = make_test_commits(5);
2120        // Simulate user selecting commits 1..=3 with Shift+click.
2121        tab.anchor_commit_index = Some(1);
2122        tab.selected_commits = vec![1, 2, 3];
2123        tab.selected_commit = Some(3);
2124        tab.selected_commit_oid = Some(tab.commits[3].oid.clone());
2125
2126        // Build a payload with the same commits (simulating a background refresh).
2127        let mut payload = fake_payload("/tmp/repo");
2128        payload.commits = make_test_commits(5);
2129
2130        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2131
2132        // The multi-selection must survive the refresh.
2133        assert_eq!(tab.anchor_commit_index, Some(1));
2134        assert_eq!(tab.selected_commits, vec![1, 2, 3]);
2135        assert_eq!(tab.selected_commit, Some(3));
2136    }
2137
2138    #[test]
2139    fn apply_payload_preserves_anchor_even_without_range() {
2140        let mut tab = RepoTab::new_empty();
2141        tab.commits = make_test_commits(5);
2142        tab.anchor_commit_index = Some(2);
2143        tab.selected_commit = Some(2);
2144        tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2145        // No range selection — just a single click with anchor set.
2146
2147        let mut payload = fake_payload("/tmp/repo");
2148        payload.commits = make_test_commits(5);
2149
2150        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2151
2152        assert_eq!(tab.anchor_commit_index, Some(2));
2153        assert_eq!(tab.selected_commit, Some(2));
2154    }
2155
2156    #[test]
2157    fn apply_payload_clears_selection_when_commits_disappear() {
2158        let mut tab = RepoTab::new_empty();
2159        tab.commits = make_test_commits(5);
2160        tab.anchor_commit_index = Some(2);
2161        tab.selected_commits = vec![2, 3, 4];
2162        tab.selected_commit = Some(4);
2163        tab.selected_commit_oid = Some(tab.commits[4].oid.clone());
2164
2165        // Payload has completely different commits (e.g. force-push).
2166        let mut payload = fake_payload("/tmp/repo");
2167        payload.commits = (0..3)
2168            .map(|i| gitkraft_core::CommitInfo {
2169                oid: format!("new_oid_{i}"),
2170                short_oid: format!("new_{i}"),
2171                summary: format!("new commit {i}"),
2172                message: String::new(),
2173                author_name: "Author".into(),
2174                author_email: "a@b.c".into(),
2175                time: Default::default(),
2176                parent_ids: Vec::new(),
2177            })
2178            .collect();
2179
2180        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2181
2182        // All old OIDs are gone — selection should be cleared.
2183        assert!(tab.selected_commits.is_empty());
2184        assert!(tab.anchor_commit_index.is_none());
2185        assert!(tab.selected_commit.is_none());
2186    }
2187}