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        // Solarized Light — custom theme with light background
885        let sol_idx = gitkraft_core::theme_index_by_name("Solarized Light");
886        state.current_theme_index = sol_idx;
887        let iced_t = state.iced_theme();
888        let pal = iced_t.palette();
889        assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
890        assert_eq!(iced_t.to_string(), "Solarized Light");
891
892        // Gruvbox Dark — accent should come from core
893        let gruvbox_idx = gitkraft_core::theme_index_by_name("Gruvbox Dark");
894        state.current_theme_index = gruvbox_idx;
895        let iced_t = state.iced_theme();
896        let pal = iced_t.palette();
897        let core = gitkraft_core::theme_by_index(gruvbox_idx);
898        let expected_accent = rgb_to_iced(core.accent);
899        assert!(
900            (pal.primary.r - expected_accent.r).abs() < 0.01
901                && (pal.primary.g - expected_accent.g).abs() < 0.01
902                && (pal.primary.b - expected_accent.b).abs() < 0.01,
903            "Gruvbox Dark accent should match core accent"
904        );
905    }
906
907    #[test]
908    fn iced_theme_name_round_trips_through_core() {
909        // Ensure the custom theme name matches a core THEME_NAMES entry so
910        // that ThemeColors::from_theme() can map it back to the right index.
911        for i in 0..gitkraft_core::THEME_COUNT {
912            let mut state = GitKraft::new();
913            state.current_theme_index = i;
914            let iced_t = state.iced_theme();
915            let name = iced_t.to_string();
916            let resolved = gitkraft_core::theme_index_by_name(&name);
917            assert_eq!(
918                resolved,
919                i,
920                "theme index {i} ({}) did not round-trip through iced_theme name",
921                gitkraft_core::THEME_NAMES[i]
922            );
923        }
924    }
925
926    #[test]
927    fn current_theme_name_round_trips() {
928        let mut state = GitKraft::new();
929        state.current_theme_index = gitkraft_core::theme_index_by_name("Dracula");
930        assert_eq!(state.current_theme_name(), "Dracula");
931        state.current_theme_index = 0;
932        assert_eq!(state.current_theme_name(), "Default");
933    }
934
935    #[test]
936    fn repo_tab_new_empty() {
937        let tab = RepoTab::new_empty();
938        assert!(tab.repo_path.is_none());
939        assert!(!tab.has_repo());
940        assert_eq!(tab.display_name(), "New Tab");
941        assert!(tab.commits.is_empty());
942        assert!(tab.branches.is_empty());
943        assert!(!tab.is_loading);
944    }
945
946    #[test]
947    fn repo_tab_display_name_with_path() {
948        let mut tab = RepoTab::new_empty();
949        tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
950        assert!(tab.has_repo());
951        assert_eq!(tab.display_name(), "cool-repo");
952    }
953
954    #[test]
955    fn search_defaults() {
956        let state = GitKraft::new();
957        assert!(!state.search_visible);
958        assert!(state.search_query.is_empty());
959        assert!(state.search_results.is_empty());
960        assert!(state.search_selected.is_none());
961    }
962
963    #[test]
964    fn context_menu_variants_exist() {
965        // Verify all context menu variants can be constructed
966        use crate::state::ContextMenu;
967
968        let _branch = ContextMenu::Branch {
969            name: "main".to_string(),
970            is_current: true,
971            local_index: 0,
972        };
973        let _remote = ContextMenu::RemoteBranch {
974            name: "origin/main".to_string(),
975        };
976        let _commit = ContextMenu::Commit {
977            index: 0,
978            oid: "abc1234".to_string(),
979        };
980        let _stash = ContextMenu::Stash { index: 0 };
981        let _unstaged = ContextMenu::UnstagedFile {
982            path: "src/main.rs".to_string(),
983        };
984        let _staged = ContextMenu::StagedFile {
985            path: "src/lib.rs".to_string(),
986        };
987    }
988
989    #[test]
990    fn repo_tab_context_menu_defaults_to_none() {
991        let tab = crate::state::RepoTab::new_empty();
992        assert!(tab.context_menu.is_none());
993    }
994
995    #[test]
996    fn context_menu_variants_constructable() {
997        use crate::state::ContextMenu;
998        let _ = ContextMenu::Stash { index: 0 };
999        let _ = ContextMenu::UnstagedFile {
1000            path: "a.rs".into(),
1001        };
1002        let _ = ContextMenu::StagedFile {
1003            path: "b.rs".into(),
1004        };
1005    }
1006
1007    #[test]
1008    fn selected_unstaged_defaults_empty() {
1009        let tab = crate::state::RepoTab::new_empty();
1010        assert!(tab.selected_unstaged.is_empty());
1011        assert!(tab.selected_staged.is_empty());
1012    }
1013
1014    #[test]
1015    fn selected_unstaged_toggle() {
1016        let mut tab = crate::state::RepoTab::new_empty();
1017        tab.selected_unstaged.insert("a.rs".to_string());
1018        tab.selected_unstaged.insert("b.rs".to_string());
1019        assert_eq!(tab.selected_unstaged.len(), 2);
1020        assert!(tab.selected_unstaged.contains("a.rs"));
1021        tab.selected_unstaged.remove("a.rs");
1022        assert_eq!(tab.selected_unstaged.len(), 1);
1023        assert!(!tab.selected_unstaged.contains("a.rs"));
1024    }
1025
1026    #[test]
1027    fn detect_system_editor_returns_valid() {
1028        // Just verify it doesn't panic
1029        let editor = super::detect_system_editor();
1030        let _ = editor.display_name();
1031    }
1032
1033    // ── Multi-file commit diff selection ──────────────────────────────────
1034
1035    #[test]
1036    fn selected_commit_file_indices_defaults_to_empty_vec() {
1037        let tab = RepoTab::new_empty();
1038        assert!(tab.selected_commit_file_indices.is_empty());
1039        // Must be a Vec (ordered), not a HashSet — check it supports indexing
1040        let v: &Vec<usize> = &tab.selected_commit_file_indices;
1041        assert_eq!(v.len(), 0);
1042    }
1043
1044    #[test]
1045    fn multi_file_diffs_defaults_empty() {
1046        let tab = RepoTab::new_empty();
1047        assert!(tab.multi_file_diffs.is_empty());
1048    }
1049
1050    #[test]
1051    fn keyboard_modifiers_default_has_no_shift() {
1052        let state = GitKraft::new();
1053        assert!(!state.keyboard_modifiers.shift());
1054    }
1055
1056    #[test]
1057    fn selected_commit_file_indices_preserves_insertion_order() {
1058        let mut tab = RepoTab::new_empty();
1059        tab.selected_commit_file_indices.push(5);
1060        tab.selected_commit_file_indices.push(2);
1061        tab.selected_commit_file_indices.push(8);
1062        assert_eq!(tab.selected_commit_file_indices, vec![5, 2, 8]);
1063    }
1064
1065    #[test]
1066    fn selected_commit_file_indices_cleared_on_reset() {
1067        let mut tab = RepoTab::new_empty();
1068        tab.selected_commit_file_indices.push(0);
1069        tab.selected_commit_file_indices.push(1);
1070        tab.selected_commit_file_indices.clear();
1071        assert!(tab.selected_commit_file_indices.is_empty());
1072    }
1073
1074    #[test]
1075    fn multi_file_diffs_cleared_on_reset() {
1076        let mut tab = RepoTab::new_empty();
1077        tab.multi_file_diffs.push(gitkraft_core::DiffInfo {
1078            old_file: String::new(),
1079            new_file: "a.rs".to_string(),
1080            status: gitkraft_core::FileStatus::Modified,
1081            hunks: vec![],
1082        });
1083        tab.multi_file_diffs.clear();
1084        assert!(tab.multi_file_diffs.is_empty());
1085    }
1086
1087    #[test]
1088    fn commit_range_diffs_defaults_empty() {
1089        let tab = RepoTab::new_empty();
1090        assert!(tab.commit_range_diffs.is_empty());
1091    }
1092
1093    #[test]
1094    fn commit_range_diffs_cleared_on_apply_payload() {
1095        // verify the field is reset — just check it's accessible and clearable
1096        let mut tab = RepoTab::new_empty();
1097        tab.commit_range_diffs.push(gitkraft_core::DiffInfo {
1098            old_file: String::new(),
1099            new_file: "x.rs".to_string(),
1100            status: gitkraft_core::FileStatus::Modified,
1101            hunks: vec![],
1102        });
1103        tab.commit_range_diffs.clear();
1104        assert!(tab.commit_range_diffs.is_empty());
1105    }
1106
1107    // ── ModifiersChanged update ───────────────────────────────────────────
1108
1109    #[test]
1110    fn modifiers_changed_sets_shift_state() {
1111        use crate::message::Message;
1112        let mut state = GitKraft::new();
1113        assert!(!state.keyboard_modifiers.shift());
1114
1115        let _ = state.update(Message::ModifiersChanged(iced::keyboard::Modifiers::SHIFT));
1116        assert!(state.keyboard_modifiers.shift());
1117
1118        let _ = state.update(Message::ModifiersChanged(
1119            iced::keyboard::Modifiers::default(),
1120        ));
1121        assert!(!state.keyboard_modifiers.shift());
1122    }
1123
1124    // ── SelectDiffByIndex update ──────────────────────────────────────────
1125
1126    fn make_commit_files(names: &[&str]) -> Vec<gitkraft_core::DiffFileEntry> {
1127        names
1128            .iter()
1129            .map(|name| gitkraft_core::DiffFileEntry {
1130                old_file: String::new(),
1131                new_file: name.to_string(),
1132                status: gitkraft_core::FileStatus::Modified,
1133            })
1134            .collect()
1135    }
1136
1137    #[test]
1138    fn select_diff_by_index_regular_click_clears_multi_selection() {
1139        use crate::message::Message;
1140        let mut state = GitKraft::new();
1141        // Provide a repo_path and oid so the update handler can reach the
1142        // `selected_file_index = Some(index)` assignment (the async task it
1143        // spawns is dropped without execution — no real repo is needed).
1144        state.active_tab_mut().repo_path =
1145            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1146        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1147        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1148        // Pre-populate a multi-selection
1149        state.active_tab_mut().selected_commit_file_indices = vec![0, 1];
1150
1151        // Regular click (no Shift) — should collapse to single-file selection
1152        let _ = state.update(Message::SelectDiffByIndex(0));
1153
1154        assert!(state.active_tab().selected_commit_file_indices.is_empty());
1155        assert_eq!(state.active_tab().selected_file_index, Some(0));
1156    }
1157
1158    #[test]
1159    fn regular_click_preserves_multi_file_diffs_until_load_completes() {
1160        use crate::message::Message;
1161        let mut state = GitKraft::new();
1162        state.active_tab_mut().repo_path =
1163            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1164        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1165        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1166
1167        // Pre-populate multi_file_diffs as if user had a multi-file selection
1168        state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1169            old_file: String::new(),
1170            new_file: "a.rs".to_string(),
1171            status: gitkraft_core::FileStatus::Modified,
1172            hunks: vec![],
1173        }];
1174
1175        // Regular click — should NOT clear multi_file_diffs immediately
1176        // (deferred to SingleFileDiffLoaded to avoid visual blink).
1177        let _ = state.update(Message::SelectDiffByIndex(1));
1178
1179        assert!(
1180            !state.active_tab().multi_file_diffs.is_empty(),
1181            "multi_file_diffs must NOT be cleared on click (deferred to diff load)"
1182        );
1183        assert_eq!(state.active_tab().selected_file_index, Some(1));
1184        assert!(state.active_tab().is_loading_file_diff);
1185    }
1186
1187    #[test]
1188    fn regular_click_preserves_diff_scroll_until_load_completes() {
1189        use crate::message::Message;
1190        let mut state = GitKraft::new();
1191        state.active_tab_mut().repo_path =
1192            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1193        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1194        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1195        state.active_tab_mut().diff_scroll_offset = 150.0;
1196
1197        // Regular click — should NOT reset diff_scroll_offset immediately.
1198        let _ = state.update(Message::SelectDiffByIndex(1));
1199
1200        assert_eq!(
1201            state.active_tab().diff_scroll_offset,
1202            150.0,
1203            "diff_scroll_offset must NOT be reset on click (deferred to diff load)"
1204        );
1205    }
1206
1207    #[test]
1208    fn single_file_diff_loaded_clears_deferred_state() {
1209        use crate::message::Message;
1210        let mut state = GitKraft::new();
1211        state.active_tab_mut().repo_path =
1212            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1213        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1214        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1215
1216        // Pre-populate multi_file_diffs and commit_range_diffs
1217        state.active_tab_mut().multi_file_diffs = vec![gitkraft_core::DiffInfo {
1218            old_file: String::new(),
1219            new_file: "a.rs".to_string(),
1220            status: gitkraft_core::FileStatus::Modified,
1221            hunks: vec![],
1222        }];
1223        state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1224            old_file: String::new(),
1225            new_file: "b.rs".to_string(),
1226            status: gitkraft_core::FileStatus::Modified,
1227            hunks: vec![],
1228        }];
1229        state.active_tab_mut().is_loading_file_diff = true;
1230        state.active_tab_mut().diff_scroll_offset = 200.0;
1231
1232        // Simulate SingleFileDiffLoaded arriving with the new diff.
1233        let new_diff = gitkraft_core::DiffInfo {
1234            old_file: String::new(),
1235            new_file: "b.rs".to_string(),
1236            status: gitkraft_core::FileStatus::Modified,
1237            hunks: vec![],
1238        };
1239        let _ = state.update(Message::SingleFileDiffLoaded(Ok(new_diff)));
1240
1241        // All deferred state should now be cleared.
1242        assert!(
1243            state.active_tab().multi_file_diffs.is_empty(),
1244            "multi_file_diffs must be cleared when new diff arrives"
1245        );
1246        assert!(
1247            state.active_tab().commit_range_diffs.is_empty(),
1248            "commit_range_diffs must be cleared when new diff arrives"
1249        );
1250        assert!(
1251            state.active_tab().selected_diff.is_some(),
1252            "selected_diff must be set to the loaded diff"
1253        );
1254        assert!(
1255            !state.active_tab().is_loading_file_diff,
1256            "is_loading_file_diff must be cleared after load"
1257        );
1258        assert_eq!(
1259            state.active_tab().diff_scroll_offset,
1260            0.0,
1261            "diff_scroll_offset must be reset when new diff arrives"
1262        );
1263    }
1264
1265    #[test]
1266    fn regular_click_preserves_commit_range_diffs_until_load_completes() {
1267        use crate::message::Message;
1268        let mut state = GitKraft::new();
1269        state.active_tab_mut().repo_path =
1270            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1271        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1272        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs"]);
1273
1274        // Pre-populate commit_range_diffs
1275        state.active_tab_mut().commit_range_diffs = vec![gitkraft_core::DiffInfo {
1276            old_file: String::new(),
1277            new_file: "x.rs".to_string(),
1278            status: gitkraft_core::FileStatus::Modified,
1279            hunks: vec![],
1280        }];
1281
1282        let _ = state.update(Message::SelectDiffByIndex(0));
1283
1284        assert!(
1285            !state.active_tab().commit_range_diffs.is_empty(),
1286            "commit_range_diffs must NOT be cleared on click (deferred to diff load)"
1287        );
1288    }
1289
1290    #[test]
1291    fn select_diff_by_index_shift_click_adds_both_files_to_selection() {
1292        use crate::message::Message;
1293        let mut state = GitKraft::new();
1294        state.active_tab_mut().repo_path =
1295            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1296        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1297        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1298        state.active_tab_mut().selected_file_index = Some(0);
1299
1300        // Shift+Click on file 1 should anchor 0 and add 1
1301        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1302        let _ = state.update(Message::SelectDiffByIndex(1));
1303
1304        let sel = &state.active_tab().selected_commit_file_indices;
1305        assert!(sel.contains(&0), "anchor file 0 should be selected");
1306        assert!(sel.contains(&1), "newly clicked file 1 should be selected");
1307        assert_eq!(sel.len(), 2);
1308    }
1309
1310    #[test]
1311    fn anchor_file_index_defaults_to_none() {
1312        let tab = RepoTab::new_empty();
1313        assert!(tab.anchor_file_index.is_none());
1314    }
1315
1316    #[test]
1317    fn regular_click_sets_anchor() {
1318        use crate::message::Message;
1319        let mut state = GitKraft::new();
1320        state.active_tab_mut().repo_path =
1321            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1322        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1323        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1324
1325        let _ = state.update(Message::SelectDiffByIndex(2));
1326
1327        assert_eq!(
1328            state.active_tab().anchor_file_index,
1329            Some(2),
1330            "regular click must set anchor to the clicked index"
1331        );
1332    }
1333
1334    #[test]
1335    fn shift_click_selects_range_downward_from_anchor() {
1336        use crate::message::Message;
1337        let mut state = GitKraft::new();
1338        state.active_tab_mut().repo_path =
1339            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1340        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1341        state.active_tab_mut().commit_files =
1342            make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1343        // Anchor at index 1
1344        state.active_tab_mut().anchor_file_index = Some(1);
1345        state.active_tab_mut().selected_file_index = Some(1);
1346
1347        // Shift+Click on index 4 — should select 1, 2, 3, 4
1348        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1349        let _ = state.update(Message::SelectDiffByIndex(4));
1350
1351        let sel = &state.active_tab().selected_commit_file_indices;
1352        assert_eq!(
1353            sel,
1354            &vec![1, 2, 3, 4],
1355            "range must be contiguous from anchor to click"
1356        );
1357    }
1358
1359    #[test]
1360    fn shift_click_selects_range_upward_from_anchor() {
1361        use crate::message::Message;
1362        let mut state = GitKraft::new();
1363        state.active_tab_mut().repo_path =
1364            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1365        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1366        state.active_tab_mut().commit_files =
1367            make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1368        // Anchor at index 4 (bottom)
1369        state.active_tab_mut().anchor_file_index = Some(4);
1370        state.active_tab_mut().selected_file_index = Some(4);
1371
1372        // Shift+Click on index 1 — should select 1, 2, 3, 4 (ascending)
1373        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1374        let _ = state.update(Message::SelectDiffByIndex(1));
1375
1376        let sel = &state.active_tab().selected_commit_file_indices;
1377        assert_eq!(
1378            sel,
1379            &vec![1, 2, 3, 4],
1380            "range must be stored ascending regardless of click direction"
1381        );
1382    }
1383
1384    #[test]
1385    fn shift_click_anchor_fixed_on_subsequent_clicks() {
1386        use crate::message::Message;
1387        let mut state = GitKraft::new();
1388        state.active_tab_mut().repo_path =
1389            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1390        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1391        state.active_tab_mut().commit_files =
1392            make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1393        // Anchor at index 2
1394        state.active_tab_mut().anchor_file_index = Some(2);
1395        state.active_tab_mut().selected_file_index = Some(2);
1396        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1397
1398        // First Shift+Click: extend to 4 → range {2, 3, 4}
1399        let _ = state.update(Message::SelectDiffByIndex(4));
1400        assert_eq!(
1401            state.active_tab().selected_commit_file_indices,
1402            vec![2, 3, 4]
1403        );
1404
1405        // Second Shift+Click: shrink back to 3 → range {2, 3} (anchor still 2)
1406        let _ = state.update(Message::SelectDiffByIndex(3));
1407        assert_eq!(
1408            state.active_tab().selected_commit_file_indices,
1409            vec![2, 3],
1410            "anchor must stay fixed; second Shift+Click shrinks the range"
1411        );
1412
1413        // Third Shift+Click: extend upward → range {0, 1, 2} (anchor still 2)
1414        let _ = state.update(Message::SelectDiffByIndex(0));
1415        assert_eq!(
1416            state.active_tab().selected_commit_file_indices,
1417            vec![0, 1, 2],
1418            "anchor must stay fixed; can extend range in either direction"
1419        );
1420    }
1421
1422    #[test]
1423    fn shift_click_on_anchor_itself_gives_single_item_range() {
1424        use crate::message::Message;
1425        let mut state = GitKraft::new();
1426        state.active_tab_mut().repo_path =
1427            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1428        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1429        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1430        state.active_tab_mut().anchor_file_index = Some(1);
1431        state.active_tab_mut().selected_file_index = Some(1);
1432
1433        // Shift+Click on the anchor itself → single-item range {1}
1434        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1435        let _ = state.update(Message::SelectDiffByIndex(1));
1436
1437        assert_eq!(state.active_tab().selected_commit_file_indices, vec![1]);
1438        assert!(
1439            state.active_tab().multi_file_diffs.is_empty(),
1440            "single-item range must not populate multi_file_diffs"
1441        );
1442    }
1443
1444    #[test]
1445    fn shift_click_range_is_always_ascending() {
1446        use crate::message::Message;
1447        let mut state = GitKraft::new();
1448        state.active_tab_mut().repo_path =
1449            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1450        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1451        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs"]);
1452        state.active_tab_mut().anchor_file_index = Some(3);
1453        state.active_tab_mut().selected_file_index = Some(3);
1454
1455        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1456        let _ = state.update(Message::SelectDiffByIndex(0));
1457
1458        let sel = &state.active_tab().selected_commit_file_indices;
1459        let is_sorted = sel.windows(2).all(|w| w[0] < w[1]);
1460        assert!(
1461            is_sorted,
1462            "selection must always be stored in ascending order"
1463        );
1464        assert_eq!(sel, &vec![0, 1, 2, 3]);
1465    }
1466
1467    #[test]
1468    fn checkout_file_at_commit_message_variants_exist() {
1469        use crate::message::Message;
1470        // Verify the new message variants can be constructed
1471        let _single =
1472            Message::CheckoutFileAtCommit("abc123".to_string(), "src/main.rs".to_string());
1473        let _multi = Message::CheckoutMultiFilesAtCommit(
1474            "abc123".to_string(),
1475            vec!["a.rs".to_string(), "b.rs".to_string()],
1476        );
1477    }
1478
1479    #[test]
1480    fn checkout_file_at_commit_closes_context_menu() {
1481        use crate::message::Message;
1482        let mut state = GitKraft::new();
1483        state.active_tab_mut().repo_path =
1484            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1485        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1486            oid: "abc123".to_string(),
1487            file_path: "src/main.rs".to_string(),
1488        });
1489        let _ = state.update(Message::CheckoutFileAtCommit(
1490            "abc123".to_string(),
1491            "src/main.rs".to_string(),
1492        ));
1493        assert!(state.active_tab().context_menu.is_none());
1494    }
1495
1496    #[test]
1497    fn checkout_multi_files_at_commit_closes_context_menu() {
1498        use crate::message::Message;
1499        let mut state = GitKraft::new();
1500        state.active_tab_mut().repo_path =
1501            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1502        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1503            oid: "abc123".to_string(),
1504            file_path: "src/main.rs".to_string(),
1505        });
1506        let _ = state.update(Message::CheckoutMultiFilesAtCommit(
1507            "abc123".to_string(),
1508            vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
1509        ));
1510        assert!(state.active_tab().context_menu.is_none());
1511    }
1512
1513    // ── Commit multi-selection ────────────────────────────────────────────
1514
1515    fn make_test_commits(count: usize) -> Vec<gitkraft_core::CommitInfo> {
1516        (0..count)
1517            .map(|i| gitkraft_core::CommitInfo {
1518                oid: i.to_string(),
1519                short_oid: i.to_string(),
1520                summary: String::new(),
1521                message: String::new(),
1522                author_name: String::new(),
1523                author_email: String::new(),
1524                time: Default::default(),
1525                parent_ids: Vec::new(),
1526            })
1527            .collect()
1528    }
1529
1530    #[test]
1531    fn selected_commits_defaults_empty() {
1532        let tab = RepoTab::new_empty();
1533        assert!(tab.selected_commits.is_empty());
1534        assert!(tab.anchor_commit_index.is_none());
1535    }
1536
1537    #[test]
1538    fn regular_click_commit_sets_anchor_and_clears_range() {
1539        use crate::message::Message;
1540        let mut state = GitKraft::new();
1541        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
1542        state.active_tab_mut().commits = make_test_commits(3);
1543        state.active_tab_mut().selected_commits = vec![0, 1, 2];
1544
1545        let _ = state.update(Message::SelectCommit(1));
1546
1547        assert_eq!(state.active_tab().anchor_commit_index, Some(1));
1548        assert!(state.active_tab().selected_commits.is_empty());
1549        assert_eq!(state.active_tab().selected_commit, Some(1));
1550    }
1551
1552    #[test]
1553    fn shift_click_commit_selects_range_from_anchor() {
1554        use crate::message::Message;
1555        let mut state = GitKraft::new();
1556        state.active_tab_mut().commits = make_test_commits(5);
1557        state.active_tab_mut().anchor_commit_index = Some(1);
1558        state.active_tab_mut().selected_commit = Some(1);
1559
1560        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1561        let _ = state.update(Message::SelectCommit(4));
1562
1563        assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3, 4]);
1564    }
1565
1566    #[test]
1567    fn shift_click_commit_range_is_ascending_when_clicking_above_anchor() {
1568        use crate::message::Message;
1569        let mut state = GitKraft::new();
1570        state.active_tab_mut().commits = make_test_commits(5);
1571        state.active_tab_mut().anchor_commit_index = Some(3);
1572        state.active_tab_mut().selected_commit = Some(3);
1573
1574        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1575        let _ = state.update(Message::SelectCommit(1));
1576
1577        assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3]);
1578    }
1579
1580    // ── ExecuteCommitAction message ───────────────────────────────────────
1581
1582    #[test]
1583    fn execute_commit_action_closes_context_menu() {
1584        use crate::message::Message;
1585        let mut state = GitKraft::new();
1586        state.active_tab_mut().repo_path =
1587            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1588        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::Commit {
1589            index: 0,
1590            oid: "abc123".to_string(),
1591        });
1592
1593        let _ = state.update(Message::ExecuteCommitAction(
1594            "abc123".to_string(),
1595            gitkraft_core::CommitAction::CherryPick,
1596        ));
1597
1598        assert!(state.active_tab().context_menu.is_none());
1599    }
1600
1601    #[test]
1602    fn execute_commit_action_sets_loading_when_repo_open() {
1603        use crate::message::Message;
1604        let mut state = GitKraft::new();
1605        state.active_tab_mut().repo_path =
1606            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1607
1608        let _ = state.update(Message::ExecuteCommitAction(
1609            "abc123".to_string(),
1610            gitkraft_core::CommitAction::ResetHard,
1611        ));
1612
1613        assert!(state.active_tab().is_loading);
1614    }
1615
1616    #[test]
1617    fn execute_commit_action_no_repo_does_not_set_loading() {
1618        use crate::message::Message;
1619        let mut state = GitKraft::new();
1620        // No repo_path set
1621
1622        let _ = state.update(Message::ExecuteCommitAction(
1623            "abc123".to_string(),
1624            gitkraft_core::CommitAction::CherryPick,
1625        ));
1626
1627        assert!(!state.active_tab().is_loading);
1628    }
1629
1630    #[test]
1631    fn execute_commit_action_sets_status_message_from_action_label() {
1632        use crate::message::Message;
1633        let mut state = GitKraft::new();
1634        state.active_tab_mut().repo_path =
1635            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1636
1637        let _ = state.update(Message::ExecuteCommitAction(
1638            "abc123".to_string(),
1639            gitkraft_core::CommitAction::Revert,
1640        ));
1641
1642        let status = state.active_tab().status_message.as_deref().unwrap_or("");
1643        // Status message should contain the action's label
1644        assert!(
1645            status.contains("Revert commit"),
1646            "expected status to contain 'Revert commit', got: {status:?}"
1647        );
1648    }
1649
1650    // ── File history / blame / delete state ──────────────────────────────
1651
1652    #[test]
1653    fn file_history_defaults_empty() {
1654        let tab = RepoTab::new_empty();
1655        assert!(tab.file_history_path.is_none());
1656        assert!(tab.file_history_commits.is_empty());
1657        assert_eq!(tab.file_history_scroll, 0.0);
1658    }
1659
1660    #[test]
1661    fn blame_defaults_empty() {
1662        let tab = RepoTab::new_empty();
1663        assert!(tab.blame_path.is_none());
1664        assert!(tab.blame_lines.is_empty());
1665        assert_eq!(tab.blame_scroll, 0.0);
1666    }
1667
1668    #[test]
1669    fn pending_delete_file_defaults_none() {
1670        let tab = RepoTab::new_empty();
1671        assert!(tab.pending_delete_file.is_none());
1672    }
1673
1674    #[test]
1675    fn view_file_history_sets_path_and_clears_blame() {
1676        use crate::message::Message;
1677        let mut state = GitKraft::new();
1678        state.active_tab_mut().repo_path =
1679            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1680        state.active_tab_mut().blame_path = Some("some/file.rs".to_string());
1681
1682        let _ = state.update(Message::ViewFileHistory("src/main.rs".to_string()));
1683
1684        assert_eq!(
1685            state.active_tab().file_history_path.as_deref(),
1686            Some("src/main.rs")
1687        );
1688        // Opening history should close blame
1689        assert!(state.active_tab().blame_path.is_none());
1690    }
1691
1692    #[test]
1693    fn close_file_history_clears_state() {
1694        use crate::message::Message;
1695        let mut state = GitKraft::new();
1696        state.active_tab_mut().file_history_path = Some("src/lib.rs".to_string());
1697        state.active_tab_mut().file_history_commits = vec![gitkraft_core::CommitInfo {
1698            oid: "abc".to_string(),
1699            short_oid: "abc".to_string(),
1700            summary: "s".to_string(),
1701            message: "s".to_string(),
1702            author_name: "a".to_string(),
1703            author_email: "a@b.com".to_string(),
1704            time: Default::default(),
1705            parent_ids: vec![],
1706        }];
1707
1708        let _ = state.update(Message::CloseFileHistory);
1709
1710        assert!(state.active_tab().file_history_path.is_none());
1711        assert!(state.active_tab().file_history_commits.is_empty());
1712    }
1713
1714    #[test]
1715    fn view_file_blame_sets_path_and_clears_history() {
1716        use crate::message::Message;
1717        let mut state = GitKraft::new();
1718        state.active_tab_mut().repo_path =
1719            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1720        state.active_tab_mut().file_history_path = Some("some/file.rs".to_string());
1721
1722        let _ = state.update(Message::ViewFileBlame("src/lib.rs".to_string()));
1723
1724        assert_eq!(state.active_tab().blame_path.as_deref(), Some("src/lib.rs"));
1725        // Opening blame should close history
1726        assert!(state.active_tab().file_history_path.is_none());
1727    }
1728
1729    #[test]
1730    fn selecting_new_commit_closes_blame_overlay() {
1731        use crate::message::Message;
1732        let mut state = GitKraft::new();
1733        state.active_tab_mut().repo_path =
1734            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1735        // Pre-populate a commit list so SelectCommit can find the commit.
1736        state.active_tab_mut().commits = vec![
1737            gitkraft_core::CommitInfo {
1738                oid: "abc1".into(),
1739                short_oid: "abc1".into(),
1740                summary: "first".into(),
1741                message: "first".into(),
1742                author_name: "A".into(),
1743                author_email: "a@a.com".into(),
1744                time: Default::default(),
1745                parent_ids: Vec::new(),
1746            },
1747            gitkraft_core::CommitInfo {
1748                oid: "abc2".into(),
1749                short_oid: "abc2".into(),
1750                summary: "second".into(),
1751                message: "second".into(),
1752                author_name: "A".into(),
1753                author_email: "a@a.com".into(),
1754                time: Default::default(),
1755                parent_ids: Vec::new(),
1756            },
1757        ];
1758        // Blame is currently open for a file from the first commit.
1759        state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1760        state.active_tab_mut().blame_lines = vec![gitkraft_core::BlameLine {
1761            line_number: 1,
1762            content: "fn main() {}".into(),
1763            short_oid: "abc1".into(),
1764            oid: "abc1".into(),
1765            author_name: "A".into(),
1766            time: Default::default(),
1767        }];
1768
1769        // Click a different commit — blame must close automatically.
1770        let _ = state.update(Message::SelectCommit(1));
1771
1772        assert!(
1773            state.active_tab().blame_path.is_none(),
1774            "blame_path must be cleared when a new commit is selected"
1775        );
1776        assert!(
1777            state.active_tab().blame_lines.is_empty(),
1778            "blame_lines must be cleared when a new commit is selected"
1779        );
1780    }
1781
1782    #[test]
1783    fn close_file_blame_clears_state() {
1784        use crate::message::Message;
1785        let mut state = GitKraft::new();
1786        state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1787
1788        let _ = state.update(Message::CloseFileBlame);
1789
1790        assert!(state.active_tab().blame_path.is_none());
1791        assert!(state.active_tab().blame_lines.is_empty());
1792    }
1793
1794    #[test]
1795    fn delete_file_sets_pending() {
1796        use crate::message::Message;
1797        let mut state = GitKraft::new();
1798
1799        let _ = state.update(Message::DeleteFile("src/old.rs".to_string()));
1800
1801        assert_eq!(
1802            state.active_tab().pending_delete_file.as_deref(),
1803            Some("src/old.rs")
1804        );
1805        assert!(state.active_tab().context_menu.is_none());
1806    }
1807
1808    #[test]
1809    fn cancel_delete_file_clears_pending() {
1810        use crate::message::Message;
1811        let mut state = GitKraft::new();
1812        state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1813
1814        let _ = state.update(Message::CancelDeleteFile);
1815
1816        assert!(state.active_tab().pending_delete_file.is_none());
1817    }
1818
1819    #[test]
1820    fn confirm_delete_file_no_repo_is_noop() {
1821        use crate::message::Message;
1822        let mut state = GitKraft::new();
1823        state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1824        // No repo_path → should not set is_loading
1825
1826        let _ = state.update(Message::ConfirmDeleteFile);
1827
1828        assert!(!state.active_tab().is_loading);
1829    }
1830
1831    #[test]
1832    fn shift_arrow_down_extends_file_list_selection_when_files_loaded() {
1833        use crate::message::Message;
1834        let mut state = GitKraft::new();
1835        state.active_tab_mut().repo_path =
1836            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1837        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1838        state.active_tab_mut().selected_file_index = Some(0);
1839        state.active_tab_mut().anchor_file_index = Some(0);
1840        // keyboard_modifiers must have SHIFT set for range selection to trigger
1841        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1842
1843        let _ = state.update(Message::ShiftArrowDown);
1844
1845        assert_eq!(state.active_tab().selected_file_index, Some(1));
1846        // Range should now include both files
1847        assert!(state.active_tab().selected_commit_file_indices.contains(&0));
1848        assert!(state.active_tab().selected_commit_file_indices.contains(&1));
1849    }
1850
1851    #[test]
1852    fn shift_arrow_down_falls_through_to_commit_log_when_no_files() {
1853        use crate::message::Message;
1854        let mut state = GitKraft::new();
1855        state.active_tab_mut().commits = make_test_commits(5);
1856        state.active_tab_mut().selected_commit = Some(1);
1857        state.active_tab_mut().anchor_commit_index = Some(1);
1858        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1859        // no commit_files
1860
1861        let _ = state.update(Message::ShiftArrowDown);
1862
1863        assert_eq!(state.active_tab().selected_commit, Some(2));
1864        assert!(state.active_tab().selected_commits.contains(&1));
1865        assert!(state.active_tab().selected_commits.contains(&2));
1866    }
1867
1868    #[test]
1869    fn file_system_changed_triggers_full_refresh() {
1870        use crate::message::Message;
1871        let mut state = GitKraft::new();
1872        state.active_tab_mut().repo_path =
1873            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1874
1875        // FileSystemChanged should call refresh_active_tab() which returns
1876        // a non-none Task.  We verify by checking that is_loading is NOT set
1877        // synchronously (the task is async), but that no error is set either.
1878        let _task = state.update(Message::FileSystemChanged);
1879
1880        // With a repo_path set, the handler must have attempted a refresh
1881        // (it returns a Task, so is_loading is set by the task executor, not here).
1882        // What we CAN check: no error was set, and status is not "error".
1883        assert!(
1884            state.active_tab().error_message.is_none(),
1885            "FileSystemChanged must not set an error message"
1886        );
1887    }
1888
1889    // ── Tab duplication prevention tests ───────────────────────────────────
1890
1891    /// Helper: build a minimal `RepoPayload` (aka `RepoSnapshot`) for a given path.
1892    fn fake_payload(workdir: &str) -> crate::message::RepoPayload {
1893        gitkraft_core::RepoSnapshot {
1894            info: gitkraft_core::RepoInfo {
1895                path: std::path::PathBuf::from(format!("{workdir}/.git")),
1896                workdir: Some(std::path::PathBuf::from(workdir)),
1897                head_branch: Some("main".into()),
1898                is_bare: false,
1899                state: gitkraft_core::RepoState::Clean,
1900            },
1901            branches: Vec::new(),
1902            commits: Vec::new(),
1903            graph_rows: Vec::new(),
1904            unstaged: Vec::new(),
1905            staged: Vec::new(),
1906            stashes: Vec::new(),
1907            remotes: Vec::new(),
1908        }
1909    }
1910
1911    /// Helper: set up a tab as if a repo was fully loaded.
1912    fn setup_loaded_tab(tab: &mut RepoTab, path: &str) {
1913        tab.repo_path = Some(std::path::PathBuf::from(path));
1914        tab.repo_info = Some(gitkraft_core::RepoInfo {
1915            path: std::path::PathBuf::from(format!("{path}/.git")),
1916            workdir: Some(std::path::PathBuf::from(path)),
1917            head_branch: Some("main".into()),
1918            is_bare: false,
1919            state: gitkraft_core::RepoState::Clean,
1920        });
1921    }
1922
1923    #[test]
1924    fn open_repo_creates_new_tab_when_repo_already_open() {
1925        use crate::message::Message;
1926        let mut state = GitKraft::new();
1927        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1928
1929        assert_eq!(state.tabs.len(), 1);
1930        assert_eq!(state.active_tab, 0);
1931
1932        // Clicking "Open" should create a new tab when the current has a repo.
1933        let _task = state.update(Message::OpenRepo);
1934
1935        assert_eq!(state.tabs.len(), 2);
1936        assert_eq!(state.active_tab, 1);
1937        // The new tab should be loading (folder picker opening).
1938        assert!(state.tabs[1].is_loading);
1939        // The original tab should be untouched.
1940        assert_eq!(
1941            state.tabs[0].repo_path.as_deref(),
1942            Some(std::path::Path::new("/home/user/repo-a"))
1943        );
1944    }
1945
1946    #[test]
1947    fn open_repo_reuses_empty_tab() {
1948        use crate::message::Message;
1949        let mut state = GitKraft::new();
1950        // Active tab is empty (no repo loaded).
1951        assert!(!state.active_tab().has_repo());
1952
1953        let _task = state.update(Message::OpenRepo);
1954
1955        // Should NOT create a new tab when the active tab is empty.
1956        assert_eq!(state.tabs.len(), 1);
1957        assert_eq!(state.active_tab, 0);
1958        assert!(state.tabs[0].is_loading);
1959    }
1960
1961    #[test]
1962    fn repo_selected_deduplicates_already_open_repo() {
1963        use crate::message::Message;
1964        let mut state = GitKraft::new();
1965        // Tab 0: fully loaded repo-a
1966        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1967        // Tab 1: empty (simulates the new tab created by OpenRepo)
1968        state.tabs.push(RepoTab::new_empty());
1969        state.active_tab = 1;
1970
1971        // User selects a folder that matches the already-open repo.
1972        let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
1973            "/home/user/repo-a",
1974        ))));
1975
1976        // Should switch to the existing tab and remove the empty one.
1977        assert_eq!(state.tabs.len(), 1);
1978        assert_eq!(state.active_tab, 0);
1979        assert_eq!(
1980            state.tabs[0].repo_path.as_deref(),
1981            Some(std::path::Path::new("/home/user/repo-a"))
1982        );
1983    }
1984
1985    #[test]
1986    fn repo_selected_opens_new_repo_in_empty_tab() {
1987        use crate::message::Message;
1988        let mut state = GitKraft::new();
1989        // Tab 0: fully loaded repo-a
1990        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
1991        // Tab 1: empty (simulates the new tab created by OpenRepo)
1992        state.tabs.push(RepoTab::new_empty());
1993        state.active_tab = 1;
1994
1995        // User selects a DIFFERENT repo.
1996        let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
1997            "/home/user/repo-b",
1998        ))));
1999
2000        // Should keep both tabs; the empty tab is now loading repo-b.
2001        assert_eq!(state.tabs.len(), 2);
2002        assert_eq!(state.active_tab, 1);
2003        assert!(state.tabs[1]
2004            .status_message
2005            .as_deref()
2006            .unwrap_or("")
2007            .contains("repo-b"));
2008    }
2009
2010    #[test]
2011    fn repo_selected_cancel_removes_empty_tab() {
2012        use crate::message::Message;
2013        let mut state = GitKraft::new();
2014        // Tab 0: fully loaded repo-a
2015        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2016        // Tab 1: empty (created by OpenRepo, waiting for folder picker)
2017        state.tabs.push(RepoTab::new_empty());
2018        state.active_tab = 1;
2019
2020        // User cancels the folder picker.
2021        let _task = state.update(Message::RepoSelected(None));
2022
2023        // The empty tab should be removed; switch back to tab 0.
2024        assert_eq!(state.tabs.len(), 1);
2025        assert_eq!(state.active_tab, 0);
2026        assert_eq!(
2027            state.tabs[0].repo_path.as_deref(),
2028            Some(std::path::Path::new("/home/user/repo-a"))
2029        );
2030    }
2031
2032    #[test]
2033    fn repo_selected_cancel_keeps_tab_if_only_one() {
2034        use crate::message::Message;
2035        let mut state = GitKraft::new();
2036        // Single empty tab — shouldn't be removed on cancel.
2037        assert_eq!(state.tabs.len(), 1);
2038        assert!(!state.active_tab().has_repo());
2039
2040        let _task = state.update(Message::RepoSelected(None));
2041
2042        assert_eq!(state.tabs.len(), 1);
2043        assert!(!state.active_tab().is_loading);
2044    }
2045
2046    #[test]
2047    fn open_recent_repo_deduplicates() {
2048        use crate::message::Message;
2049        let mut state = GitKraft::new();
2050        // Tab 0: fully loaded repo-a
2051        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2052        // Tab 1: empty tab
2053        state.tabs.push(RepoTab::new_empty());
2054        state.active_tab = 1;
2055
2056        // Opening a recent repo that's already open should switch to it.
2057        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2058            "/home/user/repo-a",
2059        )));
2060
2061        assert_eq!(state.active_tab, 0);
2062    }
2063
2064    #[test]
2065    fn open_recent_repo_creates_new_tab_when_current_has_repo() {
2066        use crate::message::Message;
2067        let mut state = GitKraft::new();
2068        // Tab 0: fully loaded repo-a
2069        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2070
2071        // Opening a different recent repo should create a new tab.
2072        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2073            "/home/user/repo-b",
2074        )));
2075
2076        assert_eq!(state.tabs.len(), 2);
2077        assert_eq!(state.active_tab, 1);
2078        assert!(state.tabs[1].is_loading);
2079    }
2080
2081    #[test]
2082    fn open_recent_repo_uses_empty_tab() {
2083        use crate::message::Message;
2084        let mut state = GitKraft::new();
2085        // Active tab is empty.
2086        assert!(!state.active_tab().has_repo());
2087
2088        let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
2089            "/home/user/repo-b",
2090        )));
2091
2092        // Should NOT create a new tab.
2093        assert_eq!(state.tabs.len(), 1);
2094        assert_eq!(state.active_tab, 0);
2095        assert!(state.tabs[0].is_loading);
2096    }
2097
2098    // ── Refresh race-condition tests ──────────────────────────────────────
2099
2100    #[test]
2101    fn repo_refreshed_targets_correct_tab_after_tab_switch() {
2102        use crate::message::Message;
2103        let mut state = GitKraft::new();
2104        // Tab 0: repo-a fully loaded
2105        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2106        // Tab 1: empty tab (user clicked "+")
2107        state.tabs.push(RepoTab::new_empty());
2108        state.active_tab = 1; // user switched to new empty tab
2109
2110        // Simulate: a RepoRefreshed result arrives for repo-a while tab 1 is active.
2111        let payload = fake_payload("/home/user/repo-a");
2112        let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2113
2114        // The payload must land in tab 0 (which owns repo-a), NOT tab 1.
2115        assert!(
2116            state.tabs[0].repo_info.is_some(),
2117            "tab 0 should still have repo info after refresh"
2118        );
2119        assert_eq!(
2120            state.tabs[0].current_branch.as_deref(),
2121            Some("main"),
2122            "tab 0 should have updated branch from payload"
2123        );
2124        // Tab 1 must remain empty.
2125        assert!(
2126            state.tabs[1].repo_info.is_none(),
2127            "tab 1 (empty) must NOT receive the refresh payload"
2128        );
2129        assert!(
2130            state.tabs[1].repo_path.is_none(),
2131            "tab 1 should still have no repo path"
2132        );
2133    }
2134
2135    #[test]
2136    fn repo_refreshed_targets_active_tab_for_new_open() {
2137        use crate::message::Message;
2138        let mut state = GitKraft::new();
2139        // Active tab is empty — user just opened a brand-new repo.
2140        // (RepoSelected set the status but the tab doesn't have repo_path yet
2141        // matching the payload, so it falls back to active tab.)
2142        assert_eq!(state.tabs.len(), 1);
2143        assert!(!state.active_tab().has_repo());
2144
2145        let payload = fake_payload("/home/user/new-repo");
2146        let _task = state.update(Message::RepoOpened(Ok(payload)));
2147
2148        // Should have applied to the active tab (the only tab).
2149        assert_eq!(
2150            state.tabs[0].repo_path.as_deref(),
2151            Some(std::path::Path::new("/home/user/new-repo"))
2152        );
2153        assert!(state.tabs[0].repo_info.is_some());
2154    }
2155
2156    #[test]
2157    fn repo_refreshed_does_not_duplicate_into_new_tab() {
2158        use crate::message::Message;
2159        let mut state = GitKraft::new();
2160        // Tab 0: repo-a loaded
2161        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2162
2163        // User clicks "+" creating an empty tab 1, then switches to it.
2164        let _task = state.update(Message::NewTab);
2165        assert_eq!(state.tabs.len(), 2);
2166        assert_eq!(state.active_tab, 1);
2167
2168        // A refresh result for repo-a arrives (was triggered before the tab switch).
2169        let payload = fake_payload("/home/user/repo-a");
2170        let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2171
2172        // Tab 1 must remain empty — the payload should go to tab 0.
2173        assert!(
2174            state.tabs[1].repo_path.is_none(),
2175            "new empty tab must not receive repo-a refresh"
2176        );
2177        assert!(
2178            state.tabs[1].repo_info.is_none(),
2179            "new empty tab must not have repo_info"
2180        );
2181        // Tab 0 must have the refreshed data.
2182        assert_eq!(
2183            state.tabs[0].repo_path.as_deref(),
2184            Some(std::path::Path::new("/home/user/repo-a"))
2185        );
2186    }
2187
2188    #[test]
2189    fn git_operation_result_targets_correct_tab() {
2190        use crate::message::Message;
2191        let mut state = GitKraft::new();
2192        // Tab 0: repo-a
2193        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2194        // Tab 1: repo-b
2195        state.tabs.push(RepoTab::new_empty());
2196        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
2197        state.active_tab = 1;
2198
2199        // A git operation result arrives for repo-a (e.g. push completed)
2200        // while user is on tab 1.
2201        let payload = fake_payload("/home/user/repo-a");
2202        let _task = state.update(Message::GitOperationResult(Ok(payload)));
2203
2204        // Payload should land in tab 0 (repo-a), not tab 1.
2205        assert_eq!(state.tabs[0].current_branch.as_deref(), Some("main"));
2206        // Tab 1's data should remain unchanged (still repo-b).
2207        assert_eq!(
2208            state.tabs[1].repo_path.as_deref(),
2209            Some(std::path::Path::new("/home/user/repo-b"))
2210        );
2211    }
2212
2213    #[test]
2214    fn multiple_new_tabs_dont_get_polluted_by_refresh() {
2215        use crate::message::Message;
2216        let mut state = GitKraft::new();
2217        // Tab 0: repo-a loaded
2218        setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
2219
2220        // User creates multiple new tabs.
2221        let _task = state.update(Message::NewTab);
2222        let _task = state.update(Message::NewTab);
2223        assert_eq!(state.tabs.len(), 3);
2224        assert_eq!(state.active_tab, 2);
2225
2226        // Refresh arrives for repo-a.
2227        let payload = fake_payload("/home/user/repo-a");
2228        let _task = state.update(Message::RepoRefreshed(Ok(payload)));
2229
2230        // Only tab 0 should be affected.
2231        assert!(state.tabs[0].repo_info.is_some());
2232        assert!(state.tabs[1].repo_info.is_none());
2233        assert!(state.tabs[2].repo_info.is_none());
2234        assert!(state.tabs[1].repo_path.is_none());
2235        assert!(state.tabs[2].repo_path.is_none());
2236    }
2237
2238    #[test]
2239    fn repo_selected_dedup_adjusts_index_when_existing_is_after_active() {
2240        use crate::message::Message;
2241        let mut state = GitKraft::new();
2242        // Tab 0: empty (newly created by OpenRepo)
2243        // Tab 1: repo-a loaded
2244        state.tabs.push(RepoTab::new_empty());
2245        setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
2246        state.active_tab = 0;
2247
2248        // User selects the same folder as repo-a.
2249        let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
2250            "/home/user/repo-a",
2251        ))));
2252
2253        // Empty tab 0 should be removed; we should now be on tab 0 (formerly tab 1).
2254        assert_eq!(state.tabs.len(), 1);
2255        assert_eq!(state.active_tab, 0);
2256        assert_eq!(
2257            state.tabs[0].repo_path.as_deref(),
2258            Some(std::path::Path::new("/home/user/repo-a"))
2259        );
2260    }
2261
2262    // ── Multi-selection preservation across refresh ───────────────────────
2263
2264    #[test]
2265    fn apply_payload_preserves_multi_selection_by_oid() {
2266        let mut tab = RepoTab::new_empty();
2267        tab.commits = make_test_commits(5);
2268        // Simulate user selecting commits 1..=3 with Shift+click.
2269        tab.anchor_commit_index = Some(1);
2270        tab.selected_commits = vec![1, 2, 3];
2271        tab.selected_commit = Some(3);
2272        tab.selected_commit_oid = Some(tab.commits[3].oid.clone());
2273
2274        // Build a payload with the same commits (simulating a background refresh).
2275        let mut payload = fake_payload("/tmp/repo");
2276        payload.commits = make_test_commits(5);
2277
2278        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2279
2280        // The multi-selection must survive the refresh.
2281        assert_eq!(tab.anchor_commit_index, Some(1));
2282        assert_eq!(tab.selected_commits, vec![1, 2, 3]);
2283        assert_eq!(tab.selected_commit, Some(3));
2284    }
2285
2286    #[test]
2287    fn apply_payload_preserves_anchor_even_without_range() {
2288        let mut tab = RepoTab::new_empty();
2289        tab.commits = make_test_commits(5);
2290        tab.anchor_commit_index = Some(2);
2291        tab.selected_commit = Some(2);
2292        tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2293        // No range selection — just a single click with anchor set.
2294
2295        let mut payload = fake_payload("/tmp/repo");
2296        payload.commits = make_test_commits(5);
2297
2298        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2299
2300        assert_eq!(tab.anchor_commit_index, Some(2));
2301        assert_eq!(tab.selected_commit, Some(2));
2302    }
2303
2304    #[test]
2305    fn apply_payload_clears_selection_when_commits_disappear() {
2306        let mut tab = RepoTab::new_empty();
2307        tab.commits = make_test_commits(5);
2308        tab.anchor_commit_index = Some(2);
2309        tab.selected_commits = vec![2, 3, 4];
2310        tab.selected_commit = Some(4);
2311        tab.selected_commit_oid = Some(tab.commits[4].oid.clone());
2312
2313        // Payload has completely different commits (e.g. force-push).
2314        let mut payload = fake_payload("/tmp/repo");
2315        payload.commits = (0..3)
2316            .map(|i| gitkraft_core::CommitInfo {
2317                oid: format!("new_oid_{i}"),
2318                short_oid: format!("new_{i}"),
2319                summary: format!("new commit {i}"),
2320                message: String::new(),
2321                author_name: "Author".into(),
2322                author_email: "a@b.c".into(),
2323                time: Default::default(),
2324                parent_ids: Vec::new(),
2325            })
2326            .collect();
2327
2328        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2329
2330        // All old OIDs are gone — selection should be cleared.
2331        assert!(tab.selected_commits.is_empty());
2332        assert!(tab.anchor_commit_index.is_none());
2333        assert!(tab.selected_commit.is_none());
2334    }
2335
2336    // ── File multi-selection preservation across refresh ─────────────────
2337
2338    #[test]
2339    fn apply_payload_preserves_file_selection_when_commit_survives() {
2340        let mut tab = RepoTab::new_empty();
2341        tab.commits = make_test_commits(3);
2342        tab.selected_commit = Some(1);
2343        tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2344        tab.commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
2345
2346        // Simulate user Shift+clicking files 0..=2.
2347        tab.anchor_file_index = Some(0);
2348        tab.selected_commit_file_indices = vec![0, 1, 2];
2349        tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2350            old_file: String::new(),
2351            new_file: "a.rs".into(),
2352            status: gitkraft_core::FileStatus::Modified,
2353            hunks: vec![],
2354        }];
2355
2356        // Background refresh with the same commits.
2357        let mut payload = fake_payload("/tmp/repo");
2358        payload.commits = make_test_commits(3);
2359
2360        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2361
2362        // File selection must survive.
2363        assert_eq!(tab.anchor_file_index, Some(0));
2364        assert_eq!(tab.selected_commit_file_indices, vec![0, 1, 2]);
2365        assert!(
2366            !tab.multi_file_diffs.is_empty(),
2367            "multi_file_diffs must survive when commit is preserved"
2368        );
2369    }
2370
2371    #[test]
2372    fn apply_payload_clears_file_selection_when_commit_disappears() {
2373        let mut tab = RepoTab::new_empty();
2374        tab.commits = make_test_commits(3);
2375        tab.selected_commit = Some(1);
2376        tab.selected_commit_oid = Some(tab.commits[1].oid.clone());
2377        tab.commit_files = make_commit_files(&["a.rs", "b.rs"]);
2378        tab.anchor_file_index = Some(0);
2379        tab.selected_commit_file_indices = vec![0, 1];
2380        tab.multi_file_diffs = vec![gitkraft_core::DiffInfo {
2381            old_file: String::new(),
2382            new_file: "a.rs".into(),
2383            status: gitkraft_core::FileStatus::Modified,
2384            hunks: vec![],
2385        }];
2386
2387        // Payload with completely different commits (force-push).
2388        let mut payload = fake_payload("/tmp/repo");
2389        payload.commits = (0..2)
2390            .map(|i| gitkraft_core::CommitInfo {
2391                oid: format!("new_{i}"),
2392                short_oid: format!("n{i}"),
2393                summary: String::new(),
2394                message: String::new(),
2395                author_name: String::new(),
2396                author_email: String::new(),
2397                time: Default::default(),
2398                parent_ids: Vec::new(),
2399            })
2400            .collect();
2401
2402        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2403
2404        // Commit is gone → file selection must be cleared.
2405        assert!(tab.anchor_file_index.is_none());
2406        assert!(tab.selected_commit_file_indices.is_empty());
2407        assert!(tab.multi_file_diffs.is_empty());
2408        assert!(tab.commit_files.is_empty());
2409    }
2410
2411    #[test]
2412    fn apply_payload_preserves_commit_range_diffs_when_commit_survives() {
2413        let mut tab = RepoTab::new_empty();
2414        tab.commits = make_test_commits(5);
2415        tab.selected_commit = Some(2);
2416        tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
2417        tab.commit_range_diffs = vec![gitkraft_core::DiffInfo {
2418            old_file: String::new(),
2419            new_file: "x.rs".into(),
2420            status: gitkraft_core::FileStatus::Modified,
2421            hunks: vec![],
2422        }];
2423
2424        let mut payload = fake_payload("/tmp/repo");
2425        payload.commits = make_test_commits(5);
2426
2427        tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
2428
2429        assert!(
2430            !tab.commit_range_diffs.is_empty(),
2431            "commit_range_diffs must survive when commit is preserved"
2432        );
2433    }
2434}