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