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    pub fn apply_payload(
286        &mut self,
287        payload: crate::message::RepoPayload,
288        path: std::path::PathBuf,
289    ) {
290        self.current_branch = payload.info.head_branch.clone();
291        self.repo_path = Some(path);
292        self.repo_info = Some(payload.info);
293        self.branches = payload.branches;
294        self.commits = payload.commits;
295        self.graph_rows = payload.graph_rows;
296        self.unstaged_changes = payload.unstaged;
297        self.staged_changes = payload.staged;
298        self.stashes = payload.stashes;
299        self.remotes = payload.remotes;
300
301        // Reset transient UI state.
302        self.selected_commit = None;
303        self.anchor_commit_index = None;
304        self.selected_commits.clear();
305        self.selected_diff = None;
306        self.commit_files.clear();
307        self.selected_commit_oid = None;
308        self.selected_file_index = None;
309        self.is_loading_file_diff = false;
310        self.commit_message.clear();
311        self.error_message = None;
312        self.status_message = Some("Repository loaded.".into());
313        self.commit_scroll_offset = 0.0;
314        self.diff_scroll_offset = 0.0;
315        self.has_more_commits = true;
316        self.is_loading_more_commits = false;
317        self.selected_unstaged.clear();
318        self.selected_staged.clear();
319        self.anchor_file_index = None;
320        self.selected_commit_file_indices.clear();
321        self.multi_file_diffs.clear();
322        self.commit_range_diffs.clear();
323    }
324}
325
326// ── Top-level application state ───────────────────────────────────────────────
327
328/// Top-level application state for the GitKraft GUI.
329pub struct GitKraft {
330    // ── Tabs ──────────────────────────────────────────────────────────────
331    /// All open repository tabs.
332    pub tabs: Vec<RepoTab>,
333    /// Index of the currently active/visible tab.
334    pub active_tab: usize,
335
336    // ── UI state (global, not per-tab) ────────────────────────────────────
337    /// Whether the left sidebar is expanded.
338    pub sidebar_expanded: bool,
339
340    // ── Pane widths / heights (pixels) ────────────────────────────────────
341    /// Width of the left sidebar in pixels.
342    pub sidebar_width: f32,
343    /// Width of the commit-log panel in pixels.
344    pub commit_log_width: f32,
345    /// Height of the staging area in pixels.
346    pub staging_height: f32,
347    /// Width of the diff file-list sidebar in pixels.
348    pub diff_file_list_width: f32,
349
350    /// UI scale factor (1.0 = default). Adjusted with Ctrl+/Ctrl- keyboard shortcuts.
351    pub ui_scale: f32,
352
353    // ── Drag state ────────────────────────────────────────────────────────
354    /// Which vertical divider is being dragged (if any).
355    pub dragging: Option<DragTarget>,
356    /// Which horizontal divider is being dragged (if any).
357    pub dragging_h: Option<DragTargetH>,
358    /// Last known mouse X position during a drag (absolute window coords).
359    pub drag_start_x: f32,
360    /// Last known mouse Y position during a drag (absolute window coords).
361    pub drag_start_y: f32,
362    /// Whether the first move event has been received for the current vertical drag.
363    /// `false` right after `PaneDragStart` — the first `PaneDragMove` sets the
364    /// real start position instead of computing a bogus delta from 0.0.
365    pub drag_initialized: bool,
366    /// Same as `drag_initialized` but for horizontal drags.
367    pub drag_initialized_h: bool,
368
369    // ── Cursor ────────────────────────────────────────────────────────────
370    /// Last known cursor position in window coordinates.
371    /// Updated on every mouse-move event so context menus open at the
372    /// exact spot the user right-clicked.
373    pub cursor_pos: Point,
374
375    // ── Theme ─────────────────────────────────────────────────────────────
376    /// Index into `gitkraft_core::THEME_NAMES` for the currently active theme.
377    pub current_theme_index: usize,
378
379    // ── Persistence ───────────────────────────────────────────────────────
380    /// Recently opened repositories (loaded from settings on startup).
381    pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
382
383    // ── Search ────────────────────────────────────────────────────────────
384    /// Whether the search overlay is visible.
385    pub search_visible: bool,
386    /// Current search query text.
387    pub search_query: String,
388    /// Search results (commit infos matching the query).
389    pub search_results: Vec<gitkraft_core::CommitInfo>,
390    /// Index of the selected search result.
391    pub search_selected: Option<usize>,
392
393    /// Files changed between the selected search commit and working tree.
394    pub search_diff_files: Vec<gitkraft_core::DiffFileEntry>,
395    /// Selected file indices in the search diff file list.
396    pub search_diff_selected: HashSet<usize>,
397    /// The diff content for the currently viewed search diff file(s).
398    pub search_diff_content: Vec<gitkraft_core::DiffInfo>,
399    /// OID of the commit being diffed against working tree in search.
400    pub search_diff_oid: Option<String>,
401
402    /// Configured editor for "Open in editor" actions.
403    pub editor: gitkraft_core::Editor,
404
405    /// Current keyboard modifier state (updated via subscription).
406    pub keyboard_modifiers: iced::keyboard::Modifiers,
407
408    // ── Window geometry ───────────────────────────────────────────────────
409    /// Last known window width (updated on WindowResized).
410    pub window_width: f32,
411    /// Last known window height (updated on WindowResized).
412    pub window_height: f32,
413    /// Last known window X position (updated on WindowMoved).
414    pub window_x: f32,
415    /// Last known window Y position (updated on WindowMoved).
416    pub window_y: f32,
417}
418
419impl Default for GitKraft {
420    fn default() -> Self {
421        Self::new()
422    }
423}
424
425impl GitKraft {
426    /// Build application state from persisted [`AppSettings`].
427    ///
428    /// Starts with a single empty tab regardless of what was saved — callers
429    /// that want to restore the full session should use
430    /// [`Self::new_with_session_paths`] instead.
431    fn from_settings(settings: gitkraft_core::AppSettings) -> Self {
432        let current_theme_index = settings
433            .theme_name
434            .as_deref()
435            .map(gitkraft_core::theme_index_by_name)
436            .unwrap_or(0);
437
438        let recent_repos = settings.recent_repos;
439
440        let (
441            sidebar_width,
442            commit_log_width,
443            staging_height,
444            diff_file_list_width,
445            sidebar_expanded,
446            ui_scale,
447        ) = if let Some(ref layout) = settings.layout {
448            (
449                layout.sidebar_width.unwrap_or(220.0),
450                layout.commit_log_width.unwrap_or(500.0),
451                layout.staging_height.unwrap_or(200.0),
452                layout.diff_file_list_width.unwrap_or(180.0),
453                layout.sidebar_expanded.unwrap_or(true),
454                layout.ui_scale.unwrap_or(1.0),
455            )
456        } else {
457            (220.0, 500.0, 200.0, 180.0, true, 1.0)
458        };
459
460        Self {
461            tabs: vec![RepoTab::new_empty()],
462            active_tab: 0,
463
464            sidebar_expanded,
465
466            sidebar_width,
467            commit_log_width,
468            staging_height,
469            diff_file_list_width,
470
471            ui_scale,
472
473            dragging: None,
474            dragging_h: None,
475            drag_start_x: 0.0,
476            drag_start_y: 0.0,
477            drag_initialized: false,
478            drag_initialized_h: false,
479            cursor_pos: Point::ORIGIN,
480
481            current_theme_index,
482
483            recent_repos,
484
485            search_visible: false,
486            search_query: String::new(),
487            search_results: Vec::new(),
488            search_selected: None,
489            search_diff_files: Vec::new(),
490            search_diff_selected: HashSet::new(),
491            search_diff_content: Vec::new(),
492            search_diff_oid: None,
493
494            keyboard_modifiers: iced::keyboard::Modifiers::default(),
495
496            window_width: settings
497                .layout
498                .as_ref()
499                .and_then(|l| l.window_width)
500                .unwrap_or(1400.0),
501            window_height: settings
502                .layout
503                .as_ref()
504                .and_then(|l| l.window_height)
505                .unwrap_or(800.0),
506            window_x: settings
507                .layout
508                .as_ref()
509                .and_then(|l| l.window_x)
510                .unwrap_or(0.0),
511            window_y: settings
512                .layout
513                .as_ref()
514                .and_then(|l| l.window_y)
515                .unwrap_or(0.0),
516
517            editor: settings
518                .editor_name
519                .as_deref()
520                .map(|name| {
521                    // Try to map persisted name back to Editor variant
522                    gitkraft_core::EDITOR_NAMES
523                        .iter()
524                        .position(|n| n.eq_ignore_ascii_case(name))
525                        .map(gitkraft_core::Editor::from_index)
526                        .unwrap_or_else(|| {
527                            if name.eq_ignore_ascii_case("none") {
528                                gitkraft_core::Editor::None
529                            } else {
530                                gitkraft_core::Editor::Custom(name.to_string())
531                            }
532                        })
533                })
534                .unwrap_or_else(detect_system_editor),
535        }
536    }
537
538    /// Create a fresh application state with sensible defaults.
539    ///
540    /// Loads persisted settings (theme, recent repos) from disk when available.
541    /// Always starts with one empty tab — use [`Self::new_with_session_paths`] to
542    /// restore the full multi-tab session.
543    pub fn new() -> Self {
544        Self::from_settings(
545            gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default(),
546        )
547    }
548
549    /// Create state and also return the saved tab paths for startup restore.
550    ///
551    /// Call this from `main.rs` instead of [`Self::new`]; it sets up loading tabs
552    /// for every path in the persisted session and returns those paths so the
553    /// caller can spawn parallel `load_repo_at` tasks.
554    pub fn new_with_session_paths() -> (Self, Vec<PathBuf>) {
555        let settings =
556            gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default();
557        let open_tabs = settings.open_tabs.clone();
558        let active_tab_index = settings.active_tab_index;
559
560        let mut state = Self::from_settings(settings);
561
562        if !open_tabs.is_empty() {
563            state.tabs = open_tabs
564                .iter()
565                .map(|path| {
566                    let mut tab = RepoTab::new_empty();
567                    // Set the path now so the tab bar shows the right name
568                    // while the repo is being loaded in the background.
569                    tab.repo_path = Some(path.clone());
570                    if path.exists() {
571                        tab.is_loading = true;
572                        tab.status_message = Some(format!(
573                            "Loading {}…",
574                            path.file_name().unwrap_or_default().to_string_lossy()
575                        ));
576                    } else {
577                        tab.error_message =
578                            Some(format!("Repository not found: {}", path.display()));
579                    }
580                    tab
581                })
582                .collect();
583            state.active_tab = active_tab_index.min(state.tabs.len().saturating_sub(1));
584        }
585
586        (state, open_tabs)
587    }
588
589    /// Paths of all tabs where a repository has been fully loaded
590    /// (`repo_info` is populated). Used to persist the multi-tab session.
591    pub fn open_tab_paths(&self) -> Vec<PathBuf> {
592        self.tabs
593            .iter()
594            .filter(|t| t.repo_info.is_some())
595            .filter_map(|t| t.repo_path.clone())
596            .collect()
597    }
598
599    /// Get a reference to the currently active tab.
600    pub fn active_tab(&self) -> &RepoTab {
601        &self.tabs[self.active_tab]
602    }
603
604    /// Get a mutable reference to the currently active tab.
605    pub fn active_tab_mut(&mut self) -> &mut RepoTab {
606        &mut self.tabs[self.active_tab]
607    }
608
609    /// Whether the active tab has a repository open.
610    pub fn has_repo(&self) -> bool {
611        self.active_tab().has_repo()
612    }
613
614    /// Helper: the display name for the active tab's repo.
615    pub fn repo_display_name(&self) -> &str {
616        self.active_tab().display_name()
617    }
618
619    /// Derive the full [`ThemeColors`] from the currently active core theme.
620    ///
621    /// Call this at the top of view functions:
622    /// ```ignore
623    /// let c = state.colors();
624    /// ```
625    pub fn colors(&self) -> ThemeColors {
626        ThemeColors::from_core(&gitkraft_core::theme_by_index(self.current_theme_index))
627    }
628
629    /// Return a **custom** `iced::Theme` whose `Palette` is derived from the
630    /// active core theme.
631    ///
632    /// This is the key to making every built-in Iced widget (text inputs,
633    /// pick-lists, scrollbars, buttons without explicit `.style()`, etc.)
634    /// inherit the correct background, text, accent, success and danger
635    /// colours.  Without this, Iced falls back to its generic Dark/Light
636    /// palette and the UI looks wrong for every non-default theme.
637    pub fn iced_theme(&self) -> iced::Theme {
638        let core = gitkraft_core::theme_by_index(self.current_theme_index);
639        let name = self.current_theme_name().to_string();
640
641        let palette = iced::theme::Palette {
642            background: rgb_to_iced(core.background),
643            text: rgb_to_iced(core.text_primary),
644            primary: rgb_to_iced(core.accent),
645            success: rgb_to_iced(core.success),
646            warning: rgb_to_iced(core.warning),
647            danger: rgb_to_iced(core.error),
648        };
649
650        iced::Theme::custom(name, palette)
651    }
652
653    /// The display name of the currently active theme.
654    pub fn current_theme_name(&self) -> &'static str {
655        gitkraft_core::THEME_NAMES
656            .get(self.current_theme_index)
657            .copied()
658            .unwrap_or("Default")
659    }
660
661    /// Refresh all data for the currently active tab's repository.
662    ///
663    /// Returns [`Task::none()`] if no repository is open in the active tab.
664    pub fn refresh_active_tab(&mut self) -> Task<Message> {
665        match self.active_tab().repo_path.clone() {
666            Some(path) => crate::features::repo::commands::refresh_repo(path),
667            None => Task::none(),
668        }
669    }
670
671    /// Handle a `Result<(), String>` from a git operation that should trigger
672    /// a full repository refresh on success.
673    ///
674    /// * `Ok(())` — clears `is_loading`, sets `status_message`, refreshes.
675    /// * `Err(e)` — clears `is_loading`, sets `error_message`, returns
676    ///   [`Task::none()`].
677    pub fn on_ok_refresh(
678        &mut self,
679        result: Result<(), String>,
680        ok_msg: &str,
681        err_prefix: &str,
682    ) -> Task<Message> {
683        match result {
684            Ok(()) => {
685                {
686                    let tab = self.active_tab_mut();
687                    tab.is_loading = false;
688                    tab.status_message = Some(ok_msg.to_string());
689                }
690                self.refresh_active_tab()
691            }
692            Err(e) => {
693                let tab = self.active_tab_mut();
694                tab.is_loading = false;
695                tab.error_message = Some(format!("{err_prefix}: {e}"));
696                tab.status_message = None;
697                Task::none()
698            }
699        }
700    }
701
702    /// Build a [`LayoutSettings`] snapshot from the current pane dimensions.
703    pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
704        gitkraft_core::LayoutSettings {
705            sidebar_width: Some(self.sidebar_width),
706            commit_log_width: Some(self.commit_log_width),
707            staging_height: Some(self.staging_height),
708            diff_file_list_width: Some(self.diff_file_list_width),
709            sidebar_expanded: Some(self.sidebar_expanded),
710            ui_scale: Some(self.ui_scale),
711            window_width: Some(self.window_width),
712            window_height: Some(self.window_height),
713            window_x: Some(self.window_x),
714            window_y: Some(self.window_y),
715            window_maximized: None, // not tracked
716        }
717    }
718}
719
720/// Convert a core [`gitkraft_core::Rgb`] to an [`iced::Color`].
721fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
722    Color::from_rgb8(rgb.r, rgb.g, rgb.b)
723}
724
725/// Try to detect the system's preferred editor from environment variables.
726fn detect_system_editor() -> gitkraft_core::Editor {
727    for var in ["VISUAL", "EDITOR"] {
728        if let Ok(val) = std::env::var(var) {
729            let bin = val.split('/').next_back().unwrap_or(&val).trim();
730            return match bin {
731                "nvim" | "neovim" => gitkraft_core::Editor::Neovim,
732                "vim" => gitkraft_core::Editor::Vim,
733                "hx" | "helix" => gitkraft_core::Editor::Helix,
734                "nano" => gitkraft_core::Editor::Nano,
735                "micro" => gitkraft_core::Editor::Micro,
736                "emacs" => gitkraft_core::Editor::Emacs,
737                "code" => gitkraft_core::Editor::VSCode,
738                "zed" => gitkraft_core::Editor::Zed,
739                "subl" => gitkraft_core::Editor::Sublime,
740                _ => gitkraft_core::Editor::Custom(val),
741            };
742        }
743    }
744    gitkraft_core::Editor::None
745}
746
747// ── Tests ─────────────────────────────────────────────────────────────────────
748
749#[cfg(test)]
750mod tests {
751    use super::*;
752
753    #[test]
754    fn new_defaults() {
755        let state = GitKraft::new();
756        assert!(state.active_tab().repo_path.is_none());
757        assert!(!state.has_repo());
758        assert_eq!(state.repo_display_name(), "New Tab");
759        assert!(state.active_tab().commits.is_empty());
760        assert!(state.sidebar_expanded);
761        // Default theme index should be valid
762        assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
763        // Pane defaults
764        assert!(state.sidebar_width > 0.0);
765        assert!(state.commit_log_width > 0.0);
766        assert!(state.staging_height > 0.0);
767        assert!(state.dragging.is_none());
768        assert!(state.dragging_h.is_none());
769        // Should start with one empty tab
770        assert_eq!(state.tabs.len(), 1);
771        assert_eq!(state.active_tab, 0);
772    }
773
774    #[test]
775    fn repo_display_name_extracts_basename() {
776        let mut state = GitKraft::new();
777        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
778        assert_eq!(state.repo_display_name(), "my-project");
779    }
780
781    #[test]
782    fn colors_returns_theme_colors() {
783        let state = GitKraft::new();
784        let c = state.colors();
785        // The default theme (index 0) is dark, so background should be dark
786        assert!(c.bg.r < 0.5);
787    }
788
789    #[test]
790    fn iced_theme_is_custom_with_correct_palette() {
791        let mut state = GitKraft::new();
792
793        // Index 0 = Default (dark) — custom theme with dark background
794        state.current_theme_index = 0;
795        let iced_t = state.iced_theme();
796        let pal = iced_t.palette();
797        assert!(pal.background.r < 0.5, "Default theme bg should be dark");
798        assert_eq!(iced_t.to_string(), "Default");
799
800        // Index 11 = Solarized Light — custom theme with light background
801        state.current_theme_index = 11;
802        let iced_t = state.iced_theme();
803        let pal = iced_t.palette();
804        assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
805        assert_eq!(iced_t.to_string(), "Solarized Light");
806
807        // Index 12 = Gruvbox Dark — accent should come from core
808        state.current_theme_index = 12;
809        let iced_t = state.iced_theme();
810        let pal = iced_t.palette();
811        let core = gitkraft_core::theme_by_index(12);
812        let expected_accent = rgb_to_iced(core.accent);
813        assert!(
814            (pal.primary.r - expected_accent.r).abs() < 0.01
815                && (pal.primary.g - expected_accent.g).abs() < 0.01
816                && (pal.primary.b - expected_accent.b).abs() < 0.01,
817            "Gruvbox Dark accent should match core accent"
818        );
819    }
820
821    #[test]
822    fn iced_theme_name_round_trips_through_core() {
823        // Ensure the custom theme name matches a core THEME_NAMES entry so
824        // that ThemeColors::from_theme() can map it back to the right index.
825        for i in 0..gitkraft_core::THEME_COUNT {
826            let mut state = GitKraft::new();
827            state.current_theme_index = i;
828            let iced_t = state.iced_theme();
829            let name = iced_t.to_string();
830            let resolved = gitkraft_core::theme_index_by_name(&name);
831            assert_eq!(
832                resolved,
833                i,
834                "theme index {i} ({}) did not round-trip through iced_theme name",
835                gitkraft_core::THEME_NAMES[i]
836            );
837        }
838    }
839
840    #[test]
841    fn current_theme_name_round_trips() {
842        let mut state = GitKraft::new();
843        state.current_theme_index = 8;
844        assert_eq!(state.current_theme_name(), "Dracula");
845        state.current_theme_index = 0;
846        assert_eq!(state.current_theme_name(), "Default");
847    }
848
849    #[test]
850    fn repo_tab_new_empty() {
851        let tab = RepoTab::new_empty();
852        assert!(tab.repo_path.is_none());
853        assert!(!tab.has_repo());
854        assert_eq!(tab.display_name(), "New Tab");
855        assert!(tab.commits.is_empty());
856        assert!(tab.branches.is_empty());
857        assert!(!tab.is_loading);
858    }
859
860    #[test]
861    fn repo_tab_display_name_with_path() {
862        let mut tab = RepoTab::new_empty();
863        tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
864        assert!(tab.has_repo());
865        assert_eq!(tab.display_name(), "cool-repo");
866    }
867
868    #[test]
869    fn search_defaults() {
870        let state = GitKraft::new();
871        assert!(!state.search_visible);
872        assert!(state.search_query.is_empty());
873        assert!(state.search_results.is_empty());
874        assert!(state.search_selected.is_none());
875    }
876
877    #[test]
878    fn context_menu_variants_exist() {
879        // Verify all context menu variants can be constructed
880        use crate::state::ContextMenu;
881
882        let _branch = ContextMenu::Branch {
883            name: "main".to_string(),
884            is_current: true,
885            local_index: 0,
886        };
887        let _remote = ContextMenu::RemoteBranch {
888            name: "origin/main".to_string(),
889        };
890        let _commit = ContextMenu::Commit {
891            index: 0,
892            oid: "abc1234".to_string(),
893        };
894        let _stash = ContextMenu::Stash { index: 0 };
895        let _unstaged = ContextMenu::UnstagedFile {
896            path: "src/main.rs".to_string(),
897        };
898        let _staged = ContextMenu::StagedFile {
899            path: "src/lib.rs".to_string(),
900        };
901    }
902
903    #[test]
904    fn repo_tab_context_menu_defaults_to_none() {
905        let tab = crate::state::RepoTab::new_empty();
906        assert!(tab.context_menu.is_none());
907    }
908
909    #[test]
910    fn context_menu_variants_constructable() {
911        use crate::state::ContextMenu;
912        let _ = ContextMenu::Stash { index: 0 };
913        let _ = ContextMenu::UnstagedFile {
914            path: "a.rs".into(),
915        };
916        let _ = ContextMenu::StagedFile {
917            path: "b.rs".into(),
918        };
919    }
920
921    #[test]
922    fn selected_unstaged_defaults_empty() {
923        let tab = crate::state::RepoTab::new_empty();
924        assert!(tab.selected_unstaged.is_empty());
925        assert!(tab.selected_staged.is_empty());
926    }
927
928    #[test]
929    fn selected_unstaged_toggle() {
930        let mut tab = crate::state::RepoTab::new_empty();
931        tab.selected_unstaged.insert("a.rs".to_string());
932        tab.selected_unstaged.insert("b.rs".to_string());
933        assert_eq!(tab.selected_unstaged.len(), 2);
934        assert!(tab.selected_unstaged.contains("a.rs"));
935        tab.selected_unstaged.remove("a.rs");
936        assert_eq!(tab.selected_unstaged.len(), 1);
937        assert!(!tab.selected_unstaged.contains("a.rs"));
938    }
939
940    #[test]
941    fn detect_system_editor_returns_valid() {
942        // Just verify it doesn't panic
943        let editor = super::detect_system_editor();
944        let _ = editor.display_name();
945    }
946
947    // ── Multi-file commit diff selection ──────────────────────────────────
948
949    #[test]
950    fn selected_commit_file_indices_defaults_to_empty_vec() {
951        let tab = RepoTab::new_empty();
952        assert!(tab.selected_commit_file_indices.is_empty());
953        // Must be a Vec (ordered), not a HashSet — check it supports indexing
954        let v: &Vec<usize> = &tab.selected_commit_file_indices;
955        assert_eq!(v.len(), 0);
956    }
957
958    #[test]
959    fn multi_file_diffs_defaults_empty() {
960        let tab = RepoTab::new_empty();
961        assert!(tab.multi_file_diffs.is_empty());
962    }
963
964    #[test]
965    fn keyboard_modifiers_default_has_no_shift() {
966        let state = GitKraft::new();
967        assert!(!state.keyboard_modifiers.shift());
968    }
969
970    #[test]
971    fn selected_commit_file_indices_preserves_insertion_order() {
972        let mut tab = RepoTab::new_empty();
973        tab.selected_commit_file_indices.push(5);
974        tab.selected_commit_file_indices.push(2);
975        tab.selected_commit_file_indices.push(8);
976        assert_eq!(tab.selected_commit_file_indices, vec![5, 2, 8]);
977    }
978
979    #[test]
980    fn selected_commit_file_indices_cleared_on_reset() {
981        let mut tab = RepoTab::new_empty();
982        tab.selected_commit_file_indices.push(0);
983        tab.selected_commit_file_indices.push(1);
984        tab.selected_commit_file_indices.clear();
985        assert!(tab.selected_commit_file_indices.is_empty());
986    }
987
988    #[test]
989    fn multi_file_diffs_cleared_on_reset() {
990        let mut tab = RepoTab::new_empty();
991        tab.multi_file_diffs.push(gitkraft_core::DiffInfo {
992            old_file: String::new(),
993            new_file: "a.rs".to_string(),
994            status: gitkraft_core::FileStatus::Modified,
995            hunks: vec![],
996        });
997        tab.multi_file_diffs.clear();
998        assert!(tab.multi_file_diffs.is_empty());
999    }
1000
1001    #[test]
1002    fn commit_range_diffs_defaults_empty() {
1003        let tab = RepoTab::new_empty();
1004        assert!(tab.commit_range_diffs.is_empty());
1005    }
1006
1007    #[test]
1008    fn commit_range_diffs_cleared_on_apply_payload() {
1009        // verify the field is reset — just check it's accessible and clearable
1010        let mut tab = RepoTab::new_empty();
1011        tab.commit_range_diffs.push(gitkraft_core::DiffInfo {
1012            old_file: String::new(),
1013            new_file: "x.rs".to_string(),
1014            status: gitkraft_core::FileStatus::Modified,
1015            hunks: vec![],
1016        });
1017        tab.commit_range_diffs.clear();
1018        assert!(tab.commit_range_diffs.is_empty());
1019    }
1020
1021    // ── ModifiersChanged update ───────────────────────────────────────────
1022
1023    #[test]
1024    fn modifiers_changed_sets_shift_state() {
1025        use crate::message::Message;
1026        let mut state = GitKraft::new();
1027        assert!(!state.keyboard_modifiers.shift());
1028
1029        let _ = state.update(Message::ModifiersChanged(iced::keyboard::Modifiers::SHIFT));
1030        assert!(state.keyboard_modifiers.shift());
1031
1032        let _ = state.update(Message::ModifiersChanged(
1033            iced::keyboard::Modifiers::default(),
1034        ));
1035        assert!(!state.keyboard_modifiers.shift());
1036    }
1037
1038    // ── SelectDiffByIndex update ──────────────────────────────────────────
1039
1040    fn make_commit_files(names: &[&str]) -> Vec<gitkraft_core::DiffFileEntry> {
1041        names
1042            .iter()
1043            .map(|name| gitkraft_core::DiffFileEntry {
1044                old_file: String::new(),
1045                new_file: name.to_string(),
1046                status: gitkraft_core::FileStatus::Modified,
1047            })
1048            .collect()
1049    }
1050
1051    #[test]
1052    fn select_diff_by_index_regular_click_clears_multi_selection() {
1053        use crate::message::Message;
1054        let mut state = GitKraft::new();
1055        // Provide a repo_path and oid so the update handler can reach the
1056        // `selected_file_index = Some(index)` assignment (the async task it
1057        // spawns is dropped without execution — no real repo is needed).
1058        state.active_tab_mut().repo_path =
1059            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1060        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1061        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1062        // Pre-populate a multi-selection
1063        state.active_tab_mut().selected_commit_file_indices = vec![0, 1];
1064
1065        // Regular click (no Shift) — should collapse to single-file selection
1066        let _ = state.update(Message::SelectDiffByIndex(0));
1067
1068        assert!(state.active_tab().selected_commit_file_indices.is_empty());
1069        assert!(state.active_tab().multi_file_diffs.is_empty());
1070        assert_eq!(state.active_tab().selected_file_index, Some(0));
1071    }
1072
1073    #[test]
1074    fn select_diff_by_index_shift_click_adds_both_files_to_selection() {
1075        use crate::message::Message;
1076        let mut state = GitKraft::new();
1077        state.active_tab_mut().repo_path =
1078            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1079        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1080        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1081        state.active_tab_mut().selected_file_index = Some(0);
1082
1083        // Shift+Click on file 1 should anchor 0 and add 1
1084        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1085        let _ = state.update(Message::SelectDiffByIndex(1));
1086
1087        let sel = &state.active_tab().selected_commit_file_indices;
1088        assert!(sel.contains(&0), "anchor file 0 should be selected");
1089        assert!(sel.contains(&1), "newly clicked file 1 should be selected");
1090        assert_eq!(sel.len(), 2);
1091    }
1092
1093    #[test]
1094    fn anchor_file_index_defaults_to_none() {
1095        let tab = RepoTab::new_empty();
1096        assert!(tab.anchor_file_index.is_none());
1097    }
1098
1099    #[test]
1100    fn regular_click_sets_anchor() {
1101        use crate::message::Message;
1102        let mut state = GitKraft::new();
1103        state.active_tab_mut().repo_path =
1104            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1105        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1106        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1107
1108        let _ = state.update(Message::SelectDiffByIndex(2));
1109
1110        assert_eq!(
1111            state.active_tab().anchor_file_index,
1112            Some(2),
1113            "regular click must set anchor to the clicked index"
1114        );
1115    }
1116
1117    #[test]
1118    fn shift_click_selects_range_downward_from_anchor() {
1119        use crate::message::Message;
1120        let mut state = GitKraft::new();
1121        state.active_tab_mut().repo_path =
1122            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1123        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1124        state.active_tab_mut().commit_files =
1125            make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1126        // Anchor at index 1
1127        state.active_tab_mut().anchor_file_index = Some(1);
1128        state.active_tab_mut().selected_file_index = Some(1);
1129
1130        // Shift+Click on index 4 — should select 1, 2, 3, 4
1131        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1132        let _ = state.update(Message::SelectDiffByIndex(4));
1133
1134        let sel = &state.active_tab().selected_commit_file_indices;
1135        assert_eq!(
1136            sel,
1137            &vec![1, 2, 3, 4],
1138            "range must be contiguous from anchor to click"
1139        );
1140    }
1141
1142    #[test]
1143    fn shift_click_selects_range_upward_from_anchor() {
1144        use crate::message::Message;
1145        let mut state = GitKraft::new();
1146        state.active_tab_mut().repo_path =
1147            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1148        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1149        state.active_tab_mut().commit_files =
1150            make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1151        // Anchor at index 4 (bottom)
1152        state.active_tab_mut().anchor_file_index = Some(4);
1153        state.active_tab_mut().selected_file_index = Some(4);
1154
1155        // Shift+Click on index 1 — should select 1, 2, 3, 4 (ascending)
1156        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1157        let _ = state.update(Message::SelectDiffByIndex(1));
1158
1159        let sel = &state.active_tab().selected_commit_file_indices;
1160        assert_eq!(
1161            sel,
1162            &vec![1, 2, 3, 4],
1163            "range must be stored ascending regardless of click direction"
1164        );
1165    }
1166
1167    #[test]
1168    fn shift_click_anchor_fixed_on_subsequent_clicks() {
1169        use crate::message::Message;
1170        let mut state = GitKraft::new();
1171        state.active_tab_mut().repo_path =
1172            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1173        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1174        state.active_tab_mut().commit_files =
1175            make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1176        // Anchor at index 2
1177        state.active_tab_mut().anchor_file_index = Some(2);
1178        state.active_tab_mut().selected_file_index = Some(2);
1179        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1180
1181        // First Shift+Click: extend to 4 → range {2, 3, 4}
1182        let _ = state.update(Message::SelectDiffByIndex(4));
1183        assert_eq!(
1184            state.active_tab().selected_commit_file_indices,
1185            vec![2, 3, 4]
1186        );
1187
1188        // Second Shift+Click: shrink back to 3 → range {2, 3} (anchor still 2)
1189        let _ = state.update(Message::SelectDiffByIndex(3));
1190        assert_eq!(
1191            state.active_tab().selected_commit_file_indices,
1192            vec![2, 3],
1193            "anchor must stay fixed; second Shift+Click shrinks the range"
1194        );
1195
1196        // Third Shift+Click: extend upward → range {0, 1, 2} (anchor still 2)
1197        let _ = state.update(Message::SelectDiffByIndex(0));
1198        assert_eq!(
1199            state.active_tab().selected_commit_file_indices,
1200            vec![0, 1, 2],
1201            "anchor must stay fixed; can extend range in either direction"
1202        );
1203    }
1204
1205    #[test]
1206    fn shift_click_on_anchor_itself_gives_single_item_range() {
1207        use crate::message::Message;
1208        let mut state = GitKraft::new();
1209        state.active_tab_mut().repo_path =
1210            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1211        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1212        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1213        state.active_tab_mut().anchor_file_index = Some(1);
1214        state.active_tab_mut().selected_file_index = Some(1);
1215
1216        // Shift+Click on the anchor itself → single-item range {1}
1217        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1218        let _ = state.update(Message::SelectDiffByIndex(1));
1219
1220        assert_eq!(state.active_tab().selected_commit_file_indices, vec![1]);
1221        assert!(
1222            state.active_tab().multi_file_diffs.is_empty(),
1223            "single-item range must not populate multi_file_diffs"
1224        );
1225    }
1226
1227    #[test]
1228    fn shift_click_range_is_always_ascending() {
1229        use crate::message::Message;
1230        let mut state = GitKraft::new();
1231        state.active_tab_mut().repo_path =
1232            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1233        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1234        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs"]);
1235        state.active_tab_mut().anchor_file_index = Some(3);
1236        state.active_tab_mut().selected_file_index = Some(3);
1237
1238        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1239        let _ = state.update(Message::SelectDiffByIndex(0));
1240
1241        let sel = &state.active_tab().selected_commit_file_indices;
1242        let is_sorted = sel.windows(2).all(|w| w[0] < w[1]);
1243        assert!(
1244            is_sorted,
1245            "selection must always be stored in ascending order"
1246        );
1247        assert_eq!(sel, &vec![0, 1, 2, 3]);
1248    }
1249
1250    #[test]
1251    fn checkout_file_at_commit_message_variants_exist() {
1252        use crate::message::Message;
1253        // Verify the new message variants can be constructed
1254        let _single =
1255            Message::CheckoutFileAtCommit("abc123".to_string(), "src/main.rs".to_string());
1256        let _multi = Message::CheckoutMultiFilesAtCommit(
1257            "abc123".to_string(),
1258            vec!["a.rs".to_string(), "b.rs".to_string()],
1259        );
1260    }
1261
1262    #[test]
1263    fn checkout_file_at_commit_closes_context_menu() {
1264        use crate::message::Message;
1265        let mut state = GitKraft::new();
1266        state.active_tab_mut().repo_path =
1267            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1268        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1269            oid: "abc123".to_string(),
1270            file_path: "src/main.rs".to_string(),
1271        });
1272        let _ = state.update(Message::CheckoutFileAtCommit(
1273            "abc123".to_string(),
1274            "src/main.rs".to_string(),
1275        ));
1276        assert!(state.active_tab().context_menu.is_none());
1277    }
1278
1279    #[test]
1280    fn checkout_multi_files_at_commit_closes_context_menu() {
1281        use crate::message::Message;
1282        let mut state = GitKraft::new();
1283        state.active_tab_mut().repo_path =
1284            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1285        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1286            oid: "abc123".to_string(),
1287            file_path: "src/main.rs".to_string(),
1288        });
1289        let _ = state.update(Message::CheckoutMultiFilesAtCommit(
1290            "abc123".to_string(),
1291            vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
1292        ));
1293        assert!(state.active_tab().context_menu.is_none());
1294    }
1295
1296    // ── Commit multi-selection ────────────────────────────────────────────
1297
1298    fn make_test_commits(count: usize) -> Vec<gitkraft_core::CommitInfo> {
1299        (0..count)
1300            .map(|i| gitkraft_core::CommitInfo {
1301                oid: i.to_string(),
1302                short_oid: i.to_string(),
1303                summary: String::new(),
1304                message: String::new(),
1305                author_name: String::new(),
1306                author_email: String::new(),
1307                time: Default::default(),
1308                parent_ids: Vec::new(),
1309            })
1310            .collect()
1311    }
1312
1313    #[test]
1314    fn selected_commits_defaults_empty() {
1315        let tab = RepoTab::new_empty();
1316        assert!(tab.selected_commits.is_empty());
1317        assert!(tab.anchor_commit_index.is_none());
1318    }
1319
1320    #[test]
1321    fn regular_click_commit_sets_anchor_and_clears_range() {
1322        use crate::message::Message;
1323        let mut state = GitKraft::new();
1324        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
1325        state.active_tab_mut().commits = make_test_commits(3);
1326        state.active_tab_mut().selected_commits = vec![0, 1, 2];
1327
1328        let _ = state.update(Message::SelectCommit(1));
1329
1330        assert_eq!(state.active_tab().anchor_commit_index, Some(1));
1331        assert!(state.active_tab().selected_commits.is_empty());
1332        assert_eq!(state.active_tab().selected_commit, Some(1));
1333    }
1334
1335    #[test]
1336    fn shift_click_commit_selects_range_from_anchor() {
1337        use crate::message::Message;
1338        let mut state = GitKraft::new();
1339        state.active_tab_mut().commits = make_test_commits(5);
1340        state.active_tab_mut().anchor_commit_index = Some(1);
1341        state.active_tab_mut().selected_commit = Some(1);
1342
1343        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1344        let _ = state.update(Message::SelectCommit(4));
1345
1346        assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3, 4]);
1347    }
1348
1349    #[test]
1350    fn shift_click_commit_range_is_ascending_when_clicking_above_anchor() {
1351        use crate::message::Message;
1352        let mut state = GitKraft::new();
1353        state.active_tab_mut().commits = make_test_commits(5);
1354        state.active_tab_mut().anchor_commit_index = Some(3);
1355        state.active_tab_mut().selected_commit = Some(3);
1356
1357        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1358        let _ = state.update(Message::SelectCommit(1));
1359
1360        assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3]);
1361    }
1362
1363    // ── ExecuteCommitAction message ───────────────────────────────────────
1364
1365    #[test]
1366    fn execute_commit_action_closes_context_menu() {
1367        use crate::message::Message;
1368        let mut state = GitKraft::new();
1369        state.active_tab_mut().repo_path =
1370            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1371        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::Commit {
1372            index: 0,
1373            oid: "abc123".to_string(),
1374        });
1375
1376        let _ = state.update(Message::ExecuteCommitAction(
1377            "abc123".to_string(),
1378            gitkraft_core::CommitAction::CherryPick,
1379        ));
1380
1381        assert!(state.active_tab().context_menu.is_none());
1382    }
1383
1384    #[test]
1385    fn execute_commit_action_sets_loading_when_repo_open() {
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
1391        let _ = state.update(Message::ExecuteCommitAction(
1392            "abc123".to_string(),
1393            gitkraft_core::CommitAction::ResetHard,
1394        ));
1395
1396        assert!(state.active_tab().is_loading);
1397    }
1398
1399    #[test]
1400    fn execute_commit_action_no_repo_does_not_set_loading() {
1401        use crate::message::Message;
1402        let mut state = GitKraft::new();
1403        // No repo_path set
1404
1405        let _ = state.update(Message::ExecuteCommitAction(
1406            "abc123".to_string(),
1407            gitkraft_core::CommitAction::CherryPick,
1408        ));
1409
1410        assert!(!state.active_tab().is_loading);
1411    }
1412
1413    #[test]
1414    fn execute_commit_action_sets_status_message_from_action_label() {
1415        use crate::message::Message;
1416        let mut state = GitKraft::new();
1417        state.active_tab_mut().repo_path =
1418            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1419
1420        let _ = state.update(Message::ExecuteCommitAction(
1421            "abc123".to_string(),
1422            gitkraft_core::CommitAction::Revert,
1423        ));
1424
1425        let status = state.active_tab().status_message.as_deref().unwrap_or("");
1426        // Status message should contain the action's label
1427        assert!(
1428            status.contains("Revert commit"),
1429            "expected status to contain 'Revert commit', got: {status:?}"
1430        );
1431    }
1432
1433    // ── File history / blame / delete state ──────────────────────────────
1434
1435    #[test]
1436    fn file_history_defaults_empty() {
1437        let tab = RepoTab::new_empty();
1438        assert!(tab.file_history_path.is_none());
1439        assert!(tab.file_history_commits.is_empty());
1440        assert_eq!(tab.file_history_scroll, 0.0);
1441    }
1442
1443    #[test]
1444    fn blame_defaults_empty() {
1445        let tab = RepoTab::new_empty();
1446        assert!(tab.blame_path.is_none());
1447        assert!(tab.blame_lines.is_empty());
1448        assert_eq!(tab.blame_scroll, 0.0);
1449    }
1450
1451    #[test]
1452    fn pending_delete_file_defaults_none() {
1453        let tab = RepoTab::new_empty();
1454        assert!(tab.pending_delete_file.is_none());
1455    }
1456
1457    #[test]
1458    fn view_file_history_sets_path_and_clears_blame() {
1459        use crate::message::Message;
1460        let mut state = GitKraft::new();
1461        state.active_tab_mut().repo_path =
1462            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1463        state.active_tab_mut().blame_path = Some("some/file.rs".to_string());
1464
1465        let _ = state.update(Message::ViewFileHistory("src/main.rs".to_string()));
1466
1467        assert_eq!(
1468            state.active_tab().file_history_path.as_deref(),
1469            Some("src/main.rs")
1470        );
1471        // Opening history should close blame
1472        assert!(state.active_tab().blame_path.is_none());
1473    }
1474
1475    #[test]
1476    fn close_file_history_clears_state() {
1477        use crate::message::Message;
1478        let mut state = GitKraft::new();
1479        state.active_tab_mut().file_history_path = Some("src/lib.rs".to_string());
1480        state.active_tab_mut().file_history_commits = vec![gitkraft_core::CommitInfo {
1481            oid: "abc".to_string(),
1482            short_oid: "abc".to_string(),
1483            summary: "s".to_string(),
1484            message: "s".to_string(),
1485            author_name: "a".to_string(),
1486            author_email: "a@b.com".to_string(),
1487            time: Default::default(),
1488            parent_ids: vec![],
1489        }];
1490
1491        let _ = state.update(Message::CloseFileHistory);
1492
1493        assert!(state.active_tab().file_history_path.is_none());
1494        assert!(state.active_tab().file_history_commits.is_empty());
1495    }
1496
1497    #[test]
1498    fn view_file_blame_sets_path_and_clears_history() {
1499        use crate::message::Message;
1500        let mut state = GitKraft::new();
1501        state.active_tab_mut().repo_path =
1502            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1503        state.active_tab_mut().file_history_path = Some("some/file.rs".to_string());
1504
1505        let _ = state.update(Message::ViewFileBlame("src/lib.rs".to_string()));
1506
1507        assert_eq!(state.active_tab().blame_path.as_deref(), Some("src/lib.rs"));
1508        // Opening blame should close history
1509        assert!(state.active_tab().file_history_path.is_none());
1510    }
1511
1512    #[test]
1513    fn selecting_new_commit_closes_blame_overlay() {
1514        use crate::message::Message;
1515        let mut state = GitKraft::new();
1516        state.active_tab_mut().repo_path =
1517            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1518        // Pre-populate a commit list so SelectCommit can find the commit.
1519        state.active_tab_mut().commits = vec![
1520            gitkraft_core::CommitInfo {
1521                oid: "abc1".into(),
1522                short_oid: "abc1".into(),
1523                summary: "first".into(),
1524                message: "first".into(),
1525                author_name: "A".into(),
1526                author_email: "a@a.com".into(),
1527                time: Default::default(),
1528                parent_ids: Vec::new(),
1529            },
1530            gitkraft_core::CommitInfo {
1531                oid: "abc2".into(),
1532                short_oid: "abc2".into(),
1533                summary: "second".into(),
1534                message: "second".into(),
1535                author_name: "A".into(),
1536                author_email: "a@a.com".into(),
1537                time: Default::default(),
1538                parent_ids: Vec::new(),
1539            },
1540        ];
1541        // Blame is currently open for a file from the first commit.
1542        state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1543        state.active_tab_mut().blame_lines = vec![gitkraft_core::BlameLine {
1544            line_number: 1,
1545            content: "fn main() {}".into(),
1546            short_oid: "abc1".into(),
1547            oid: "abc1".into(),
1548            author_name: "A".into(),
1549            time: Default::default(),
1550        }];
1551
1552        // Click a different commit — blame must close automatically.
1553        let _ = state.update(Message::SelectCommit(1));
1554
1555        assert!(
1556            state.active_tab().blame_path.is_none(),
1557            "blame_path must be cleared when a new commit is selected"
1558        );
1559        assert!(
1560            state.active_tab().blame_lines.is_empty(),
1561            "blame_lines must be cleared when a new commit is selected"
1562        );
1563    }
1564
1565    #[test]
1566    fn close_file_blame_clears_state() {
1567        use crate::message::Message;
1568        let mut state = GitKraft::new();
1569        state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
1570
1571        let _ = state.update(Message::CloseFileBlame);
1572
1573        assert!(state.active_tab().blame_path.is_none());
1574        assert!(state.active_tab().blame_lines.is_empty());
1575    }
1576
1577    #[test]
1578    fn delete_file_sets_pending() {
1579        use crate::message::Message;
1580        let mut state = GitKraft::new();
1581
1582        let _ = state.update(Message::DeleteFile("src/old.rs".to_string()));
1583
1584        assert_eq!(
1585            state.active_tab().pending_delete_file.as_deref(),
1586            Some("src/old.rs")
1587        );
1588        assert!(state.active_tab().context_menu.is_none());
1589    }
1590
1591    #[test]
1592    fn cancel_delete_file_clears_pending() {
1593        use crate::message::Message;
1594        let mut state = GitKraft::new();
1595        state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1596
1597        let _ = state.update(Message::CancelDeleteFile);
1598
1599        assert!(state.active_tab().pending_delete_file.is_none());
1600    }
1601
1602    #[test]
1603    fn confirm_delete_file_no_repo_is_noop() {
1604        use crate::message::Message;
1605        let mut state = GitKraft::new();
1606        state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
1607        // No repo_path → should not set is_loading
1608
1609        let _ = state.update(Message::ConfirmDeleteFile);
1610
1611        assert!(!state.active_tab().is_loading);
1612    }
1613
1614    #[test]
1615    fn shift_arrow_down_extends_file_list_selection_when_files_loaded() {
1616        use crate::message::Message;
1617        let mut state = GitKraft::new();
1618        state.active_tab_mut().repo_path =
1619            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1620        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1621        state.active_tab_mut().selected_file_index = Some(0);
1622        state.active_tab_mut().anchor_file_index = Some(0);
1623        // keyboard_modifiers must have SHIFT set for range selection to trigger
1624        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1625
1626        let _ = state.update(Message::ShiftArrowDown);
1627
1628        assert_eq!(state.active_tab().selected_file_index, Some(1));
1629        // Range should now include both files
1630        assert!(state.active_tab().selected_commit_file_indices.contains(&0));
1631        assert!(state.active_tab().selected_commit_file_indices.contains(&1));
1632    }
1633
1634    #[test]
1635    fn shift_arrow_down_falls_through_to_commit_log_when_no_files() {
1636        use crate::message::Message;
1637        let mut state = GitKraft::new();
1638        state.active_tab_mut().commits = make_test_commits(5);
1639        state.active_tab_mut().selected_commit = Some(1);
1640        state.active_tab_mut().anchor_commit_index = Some(1);
1641        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1642        // no commit_files
1643
1644        let _ = state.update(Message::ShiftArrowDown);
1645
1646        assert_eq!(state.active_tab().selected_commit, Some(2));
1647        assert!(state.active_tab().selected_commits.contains(&1));
1648        assert!(state.active_tab().selected_commits.contains(&2));
1649    }
1650
1651    #[test]
1652    fn file_system_changed_triggers_full_refresh() {
1653        use crate::message::Message;
1654        let mut state = GitKraft::new();
1655        state.active_tab_mut().repo_path =
1656            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1657
1658        // FileSystemChanged should call refresh_active_tab() which returns
1659        // a non-none Task.  We verify by checking that is_loading is NOT set
1660        // synchronously (the task is async), but that no error is set either.
1661        let _task = state.update(Message::FileSystemChanged);
1662
1663        // With a repo_path set, the handler must have attempted a refresh
1664        // (it returns a Task, so is_loading is set by the task executor, not here).
1665        // What we CAN check: no error was set, and status is not "error".
1666        assert!(
1667            state.active_tab().error_message.is_none(),
1668            "FileSystemChanged must not set an error message"
1669        );
1670    }
1671}