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
190impl RepoTab {
191    /// Create an empty tab (no repo open — shows welcome screen).
192    pub fn new_empty() -> Self {
193        Self {
194            repo_path: None,
195            repo_info: None,
196            branches: Vec::new(),
197            current_branch: None,
198            commits: Vec::new(),
199            selected_commit: None,
200            anchor_commit_index: None,
201            selected_commits: Vec::new(),
202            graph_rows: Vec::new(),
203            unstaged_changes: Vec::new(),
204            staged_changes: Vec::new(),
205            commit_files: Vec::new(),
206            selected_commit_oid: None,
207            selected_file_index: None,
208            is_loading_file_diff: false,
209            anchor_file_index: None,
210            selected_commit_file_indices: Vec::new(),
211            multi_file_diffs: Vec::new(),
212            commit_range_diffs: Vec::new(),
213            selected_diff: None,
214            commit_message: String::new(),
215            stashes: Vec::new(),
216            remotes: Vec::new(),
217            show_commit_detail: false,
218            new_branch_name: String::new(),
219            show_branch_create: false,
220            local_branches_expanded: true,
221            remote_branches_expanded: true,
222            stash_message: String::new(),
223            selected_unstaged: std::collections::HashSet::new(),
224            selected_staged: std::collections::HashSet::new(),
225            pending_discard: None,
226            status_message: None,
227            error_message: None,
228            is_loading: false,
229            context_menu: None,
230            context_menu_pos: (0.0, 0.0),
231            rename_branch_target: None,
232            rename_branch_input: String::new(),
233            create_tag_target_oid: None,
234            create_tag_annotated: false,
235            create_tag_name: String::new(),
236            create_tag_message: String::new(),
237            create_branch_at_oid: None,
238            commit_scroll_offset: 0.0,
239            diff_scroll_offset: 0.0,
240            commit_display: Vec::new(),
241            has_more_commits: true,
242            is_loading_more_commits: false,
243        }
244    }
245
246    /// Whether a repository is currently open in this tab.
247    pub fn has_repo(&self) -> bool {
248        self.repo_path.is_some()
249    }
250
251    /// Display name for the tab (last path component, or "New Tab").
252    pub fn display_name(&self) -> &str {
253        self.repo_path
254            .as_ref()
255            .and_then(|p| p.file_name())
256            .and_then(|n| n.to_str())
257            .unwrap_or("New Tab")
258    }
259
260    /// Apply a full repo payload to this tab, resetting transient UI state.
261    pub fn apply_payload(
262        &mut self,
263        payload: crate::message::RepoPayload,
264        path: std::path::PathBuf,
265    ) {
266        self.current_branch = payload.info.head_branch.clone();
267        self.repo_path = Some(path);
268        self.repo_info = Some(payload.info);
269        self.branches = payload.branches;
270        self.commits = payload.commits;
271        self.graph_rows = payload.graph_rows;
272        self.unstaged_changes = payload.unstaged;
273        self.staged_changes = payload.staged;
274        self.stashes = payload.stashes;
275        self.remotes = payload.remotes;
276
277        // Reset transient UI state.
278        self.selected_commit = None;
279        self.anchor_commit_index = None;
280        self.selected_commits.clear();
281        self.selected_diff = None;
282        self.commit_files.clear();
283        self.selected_commit_oid = None;
284        self.selected_file_index = None;
285        self.is_loading_file_diff = false;
286        self.commit_message.clear();
287        self.error_message = None;
288        self.status_message = Some("Repository loaded.".into());
289        self.commit_scroll_offset = 0.0;
290        self.diff_scroll_offset = 0.0;
291        self.has_more_commits = true;
292        self.is_loading_more_commits = false;
293        self.selected_unstaged.clear();
294        self.selected_staged.clear();
295        self.anchor_file_index = None;
296        self.selected_commit_file_indices.clear();
297        self.multi_file_diffs.clear();
298        self.commit_range_diffs.clear();
299    }
300}
301
302// ── Top-level application state ───────────────────────────────────────────────
303
304/// Top-level application state for the GitKraft GUI.
305pub struct GitKraft {
306    // ── Tabs ──────────────────────────────────────────────────────────────
307    /// All open repository tabs.
308    pub tabs: Vec<RepoTab>,
309    /// Index of the currently active/visible tab.
310    pub active_tab: usize,
311
312    // ── UI state (global, not per-tab) ────────────────────────────────────
313    /// Whether the left sidebar is expanded.
314    pub sidebar_expanded: bool,
315
316    // ── Pane widths / heights (pixels) ────────────────────────────────────
317    /// Width of the left sidebar in pixels.
318    pub sidebar_width: f32,
319    /// Width of the commit-log panel in pixels.
320    pub commit_log_width: f32,
321    /// Height of the staging area in pixels.
322    pub staging_height: f32,
323    /// Width of the diff file-list sidebar in pixels.
324    pub diff_file_list_width: f32,
325
326    /// UI scale factor (1.0 = default). Adjusted with Ctrl+/Ctrl- keyboard shortcuts.
327    pub ui_scale: f32,
328
329    // ── Drag state ────────────────────────────────────────────────────────
330    /// Which vertical divider is being dragged (if any).
331    pub dragging: Option<DragTarget>,
332    /// Which horizontal divider is being dragged (if any).
333    pub dragging_h: Option<DragTargetH>,
334    /// Last known mouse X position during a drag (absolute window coords).
335    pub drag_start_x: f32,
336    /// Last known mouse Y position during a drag (absolute window coords).
337    pub drag_start_y: f32,
338    /// Whether the first move event has been received for the current vertical drag.
339    /// `false` right after `PaneDragStart` — the first `PaneDragMove` sets the
340    /// real start position instead of computing a bogus delta from 0.0.
341    pub drag_initialized: bool,
342    /// Same as `drag_initialized` but for horizontal drags.
343    pub drag_initialized_h: bool,
344
345    // ── Cursor ────────────────────────────────────────────────────────────
346    /// Last known cursor position in window coordinates.
347    /// Updated on every mouse-move event so context menus open at the
348    /// exact spot the user right-clicked.
349    pub cursor_pos: Point,
350
351    // ── Theme ─────────────────────────────────────────────────────────────
352    /// Index into `gitkraft_core::THEME_NAMES` for the currently active theme.
353    pub current_theme_index: usize,
354
355    // ── Persistence ───────────────────────────────────────────────────────
356    /// Recently opened repositories (loaded from settings on startup).
357    pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
358
359    // ── Search ────────────────────────────────────────────────────────────
360    /// Whether the search overlay is visible.
361    pub search_visible: bool,
362    /// Current search query text.
363    pub search_query: String,
364    /// Search results (commit infos matching the query).
365    pub search_results: Vec<gitkraft_core::CommitInfo>,
366    /// Index of the selected search result.
367    pub search_selected: Option<usize>,
368
369    /// Files changed between the selected search commit and working tree.
370    pub search_diff_files: Vec<gitkraft_core::DiffFileEntry>,
371    /// Selected file indices in the search diff file list.
372    pub search_diff_selected: HashSet<usize>,
373    /// The diff content for the currently viewed search diff file(s).
374    pub search_diff_content: Vec<gitkraft_core::DiffInfo>,
375    /// OID of the commit being diffed against working tree in search.
376    pub search_diff_oid: Option<String>,
377
378    /// Configured editor for "Open in editor" actions.
379    pub editor: gitkraft_core::Editor,
380
381    /// Current keyboard modifier state (updated via subscription).
382    pub keyboard_modifiers: iced::keyboard::Modifiers,
383}
384
385impl Default for GitKraft {
386    fn default() -> Self {
387        Self::new()
388    }
389}
390
391impl GitKraft {
392    /// Build application state from persisted [`AppSettings`].
393    ///
394    /// Starts with a single empty tab regardless of what was saved — callers
395    /// that want to restore the full session should use
396    /// [`Self::new_with_session_paths`] instead.
397    fn from_settings(settings: gitkraft_core::AppSettings) -> Self {
398        let current_theme_index = settings
399            .theme_name
400            .as_deref()
401            .map(gitkraft_core::theme_index_by_name)
402            .unwrap_or(0);
403
404        let recent_repos = settings.recent_repos;
405
406        let (
407            sidebar_width,
408            commit_log_width,
409            staging_height,
410            diff_file_list_width,
411            sidebar_expanded,
412            ui_scale,
413        ) = if let Some(ref layout) = settings.layout {
414            (
415                layout.sidebar_width.unwrap_or(220.0),
416                layout.commit_log_width.unwrap_or(500.0),
417                layout.staging_height.unwrap_or(200.0),
418                layout.diff_file_list_width.unwrap_or(180.0),
419                layout.sidebar_expanded.unwrap_or(true),
420                layout.ui_scale.unwrap_or(1.0),
421            )
422        } else {
423            (220.0, 500.0, 200.0, 180.0, true, 1.0)
424        };
425
426        Self {
427            tabs: vec![RepoTab::new_empty()],
428            active_tab: 0,
429
430            sidebar_expanded,
431
432            sidebar_width,
433            commit_log_width,
434            staging_height,
435            diff_file_list_width,
436
437            ui_scale,
438
439            dragging: None,
440            dragging_h: None,
441            drag_start_x: 0.0,
442            drag_start_y: 0.0,
443            drag_initialized: false,
444            drag_initialized_h: false,
445            cursor_pos: Point::ORIGIN,
446
447            current_theme_index,
448
449            recent_repos,
450
451            search_visible: false,
452            search_query: String::new(),
453            search_results: Vec::new(),
454            search_selected: None,
455            search_diff_files: Vec::new(),
456            search_diff_selected: HashSet::new(),
457            search_diff_content: Vec::new(),
458            search_diff_oid: None,
459
460            keyboard_modifiers: iced::keyboard::Modifiers::default(),
461
462            editor: settings
463                .editor_name
464                .as_deref()
465                .map(|name| {
466                    // Try to map persisted name back to Editor variant
467                    gitkraft_core::EDITOR_NAMES
468                        .iter()
469                        .position(|n| n.eq_ignore_ascii_case(name))
470                        .map(gitkraft_core::Editor::from_index)
471                        .unwrap_or_else(|| {
472                            if name.eq_ignore_ascii_case("none") {
473                                gitkraft_core::Editor::None
474                            } else {
475                                gitkraft_core::Editor::Custom(name.to_string())
476                            }
477                        })
478                })
479                .unwrap_or_else(detect_system_editor),
480        }
481    }
482
483    /// Create a fresh application state with sensible defaults.
484    ///
485    /// Loads persisted settings (theme, recent repos) from disk when available.
486    /// Always starts with one empty tab — use [`Self::new_with_session_paths`] to
487    /// restore the full multi-tab session.
488    pub fn new() -> Self {
489        Self::from_settings(
490            gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default(),
491        )
492    }
493
494    /// Create state and also return the saved tab paths for startup restore.
495    ///
496    /// Call this from `main.rs` instead of [`Self::new`]; it sets up loading tabs
497    /// for every path in the persisted session and returns those paths so the
498    /// caller can spawn parallel `load_repo_at` tasks.
499    pub fn new_with_session_paths() -> (Self, Vec<PathBuf>) {
500        let settings =
501            gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default();
502        let open_tabs = settings.open_tabs.clone();
503        let active_tab_index = settings.active_tab_index;
504
505        let mut state = Self::from_settings(settings);
506
507        if !open_tabs.is_empty() {
508            state.tabs = open_tabs
509                .iter()
510                .map(|path| {
511                    let mut tab = RepoTab::new_empty();
512                    // Set the path now so the tab bar shows the right name
513                    // while the repo is being loaded in the background.
514                    tab.repo_path = Some(path.clone());
515                    if path.exists() {
516                        tab.is_loading = true;
517                        tab.status_message = Some(format!(
518                            "Loading {}…",
519                            path.file_name().unwrap_or_default().to_string_lossy()
520                        ));
521                    } else {
522                        tab.error_message =
523                            Some(format!("Repository not found: {}", path.display()));
524                    }
525                    tab
526                })
527                .collect();
528            state.active_tab = active_tab_index.min(state.tabs.len().saturating_sub(1));
529        }
530
531        (state, open_tabs)
532    }
533
534    /// Paths of all tabs where a repository has been fully loaded
535    /// (`repo_info` is populated). Used to persist the multi-tab session.
536    pub fn open_tab_paths(&self) -> Vec<PathBuf> {
537        self.tabs
538            .iter()
539            .filter(|t| t.repo_info.is_some())
540            .filter_map(|t| t.repo_path.clone())
541            .collect()
542    }
543
544    /// Get a reference to the currently active tab.
545    pub fn active_tab(&self) -> &RepoTab {
546        &self.tabs[self.active_tab]
547    }
548
549    /// Get a mutable reference to the currently active tab.
550    pub fn active_tab_mut(&mut self) -> &mut RepoTab {
551        &mut self.tabs[self.active_tab]
552    }
553
554    /// Whether the active tab has a repository open.
555    pub fn has_repo(&self) -> bool {
556        self.active_tab().has_repo()
557    }
558
559    /// Helper: the display name for the active tab's repo.
560    pub fn repo_display_name(&self) -> &str {
561        self.active_tab().display_name()
562    }
563
564    /// Derive the full [`ThemeColors`] from the currently active core theme.
565    ///
566    /// Call this at the top of view functions:
567    /// ```ignore
568    /// let c = state.colors();
569    /// ```
570    pub fn colors(&self) -> ThemeColors {
571        ThemeColors::from_core(&gitkraft_core::theme_by_index(self.current_theme_index))
572    }
573
574    /// Return a **custom** `iced::Theme` whose `Palette` is derived from the
575    /// active core theme.
576    ///
577    /// This is the key to making every built-in Iced widget (text inputs,
578    /// pick-lists, scrollbars, buttons without explicit `.style()`, etc.)
579    /// inherit the correct background, text, accent, success and danger
580    /// colours.  Without this, Iced falls back to its generic Dark/Light
581    /// palette and the UI looks wrong for every non-default theme.
582    pub fn iced_theme(&self) -> iced::Theme {
583        let core = gitkraft_core::theme_by_index(self.current_theme_index);
584        let name = self.current_theme_name().to_string();
585
586        let palette = iced::theme::Palette {
587            background: rgb_to_iced(core.background),
588            text: rgb_to_iced(core.text_primary),
589            primary: rgb_to_iced(core.accent),
590            success: rgb_to_iced(core.success),
591            warning: rgb_to_iced(core.warning),
592            danger: rgb_to_iced(core.error),
593        };
594
595        iced::Theme::custom(name, palette)
596    }
597
598    /// The display name of the currently active theme.
599    pub fn current_theme_name(&self) -> &'static str {
600        gitkraft_core::THEME_NAMES
601            .get(self.current_theme_index)
602            .copied()
603            .unwrap_or("Default")
604    }
605
606    /// Refresh all data for the currently active tab's repository.
607    ///
608    /// Returns [`Task::none()`] if no repository is open in the active tab.
609    pub fn refresh_active_tab(&mut self) -> Task<Message> {
610        match self.active_tab().repo_path.clone() {
611            Some(path) => crate::features::repo::commands::refresh_repo(path),
612            None => Task::none(),
613        }
614    }
615
616    /// Handle a `Result<(), String>` from a git operation that should trigger
617    /// a full repository refresh on success.
618    ///
619    /// * `Ok(())` — clears `is_loading`, sets `status_message`, refreshes.
620    /// * `Err(e)` — clears `is_loading`, sets `error_message`, returns
621    ///   [`Task::none()`].
622    pub fn on_ok_refresh(
623        &mut self,
624        result: Result<(), String>,
625        ok_msg: &str,
626        err_prefix: &str,
627    ) -> Task<Message> {
628        match result {
629            Ok(()) => {
630                {
631                    let tab = self.active_tab_mut();
632                    tab.is_loading = false;
633                    tab.status_message = Some(ok_msg.to_string());
634                }
635                self.refresh_active_tab()
636            }
637            Err(e) => {
638                let tab = self.active_tab_mut();
639                tab.is_loading = false;
640                tab.error_message = Some(format!("{err_prefix}: {e}"));
641                tab.status_message = None;
642                Task::none()
643            }
644        }
645    }
646
647    /// Build a [`LayoutSettings`] snapshot from the current pane dimensions.
648    pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
649        gitkraft_core::LayoutSettings {
650            sidebar_width: Some(self.sidebar_width),
651            commit_log_width: Some(self.commit_log_width),
652            staging_height: Some(self.staging_height),
653            diff_file_list_width: Some(self.diff_file_list_width),
654            sidebar_expanded: Some(self.sidebar_expanded),
655            ui_scale: Some(self.ui_scale),
656        }
657    }
658}
659
660/// Convert a core [`gitkraft_core::Rgb`] to an [`iced::Color`].
661fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
662    Color::from_rgb8(rgb.r, rgb.g, rgb.b)
663}
664
665/// Try to detect the system's preferred editor from environment variables.
666fn detect_system_editor() -> gitkraft_core::Editor {
667    for var in ["VISUAL", "EDITOR"] {
668        if let Ok(val) = std::env::var(var) {
669            let bin = val.split('/').next_back().unwrap_or(&val).trim();
670            return match bin {
671                "nvim" | "neovim" => gitkraft_core::Editor::Neovim,
672                "vim" => gitkraft_core::Editor::Vim,
673                "hx" | "helix" => gitkraft_core::Editor::Helix,
674                "nano" => gitkraft_core::Editor::Nano,
675                "micro" => gitkraft_core::Editor::Micro,
676                "emacs" => gitkraft_core::Editor::Emacs,
677                "code" => gitkraft_core::Editor::VSCode,
678                "zed" => gitkraft_core::Editor::Zed,
679                "subl" => gitkraft_core::Editor::Sublime,
680                _ => gitkraft_core::Editor::Custom(val),
681            };
682        }
683    }
684    gitkraft_core::Editor::None
685}
686
687// ── Tests ─────────────────────────────────────────────────────────────────────
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    #[test]
694    fn new_defaults() {
695        let state = GitKraft::new();
696        assert!(state.active_tab().repo_path.is_none());
697        assert!(!state.has_repo());
698        assert_eq!(state.repo_display_name(), "New Tab");
699        assert!(state.active_tab().commits.is_empty());
700        assert!(state.sidebar_expanded);
701        // Default theme index should be valid
702        assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
703        // Pane defaults
704        assert!(state.sidebar_width > 0.0);
705        assert!(state.commit_log_width > 0.0);
706        assert!(state.staging_height > 0.0);
707        assert!(state.dragging.is_none());
708        assert!(state.dragging_h.is_none());
709        // Should start with one empty tab
710        assert_eq!(state.tabs.len(), 1);
711        assert_eq!(state.active_tab, 0);
712    }
713
714    #[test]
715    fn repo_display_name_extracts_basename() {
716        let mut state = GitKraft::new();
717        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
718        assert_eq!(state.repo_display_name(), "my-project");
719    }
720
721    #[test]
722    fn colors_returns_theme_colors() {
723        let state = GitKraft::new();
724        let c = state.colors();
725        // The default theme (index 0) is dark, so background should be dark
726        assert!(c.bg.r < 0.5);
727    }
728
729    #[test]
730    fn iced_theme_is_custom_with_correct_palette() {
731        let mut state = GitKraft::new();
732
733        // Index 0 = Default (dark) — custom theme with dark background
734        state.current_theme_index = 0;
735        let iced_t = state.iced_theme();
736        let pal = iced_t.palette();
737        assert!(pal.background.r < 0.5, "Default theme bg should be dark");
738        assert_eq!(iced_t.to_string(), "Default");
739
740        // Index 11 = Solarized Light — custom theme with light background
741        state.current_theme_index = 11;
742        let iced_t = state.iced_theme();
743        let pal = iced_t.palette();
744        assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
745        assert_eq!(iced_t.to_string(), "Solarized Light");
746
747        // Index 12 = Gruvbox Dark — accent should come from core
748        state.current_theme_index = 12;
749        let iced_t = state.iced_theme();
750        let pal = iced_t.palette();
751        let core = gitkraft_core::theme_by_index(12);
752        let expected_accent = rgb_to_iced(core.accent);
753        assert!(
754            (pal.primary.r - expected_accent.r).abs() < 0.01
755                && (pal.primary.g - expected_accent.g).abs() < 0.01
756                && (pal.primary.b - expected_accent.b).abs() < 0.01,
757            "Gruvbox Dark accent should match core accent"
758        );
759    }
760
761    #[test]
762    fn iced_theme_name_round_trips_through_core() {
763        // Ensure the custom theme name matches a core THEME_NAMES entry so
764        // that ThemeColors::from_theme() can map it back to the right index.
765        for i in 0..gitkraft_core::THEME_COUNT {
766            let mut state = GitKraft::new();
767            state.current_theme_index = i;
768            let iced_t = state.iced_theme();
769            let name = iced_t.to_string();
770            let resolved = gitkraft_core::theme_index_by_name(&name);
771            assert_eq!(
772                resolved,
773                i,
774                "theme index {i} ({}) did not round-trip through iced_theme name",
775                gitkraft_core::THEME_NAMES[i]
776            );
777        }
778    }
779
780    #[test]
781    fn current_theme_name_round_trips() {
782        let mut state = GitKraft::new();
783        state.current_theme_index = 8;
784        assert_eq!(state.current_theme_name(), "Dracula");
785        state.current_theme_index = 0;
786        assert_eq!(state.current_theme_name(), "Default");
787    }
788
789    #[test]
790    fn repo_tab_new_empty() {
791        let tab = RepoTab::new_empty();
792        assert!(tab.repo_path.is_none());
793        assert!(!tab.has_repo());
794        assert_eq!(tab.display_name(), "New Tab");
795        assert!(tab.commits.is_empty());
796        assert!(tab.branches.is_empty());
797        assert!(!tab.is_loading);
798    }
799
800    #[test]
801    fn repo_tab_display_name_with_path() {
802        let mut tab = RepoTab::new_empty();
803        tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
804        assert!(tab.has_repo());
805        assert_eq!(tab.display_name(), "cool-repo");
806    }
807
808    #[test]
809    fn search_defaults() {
810        let state = GitKraft::new();
811        assert!(!state.search_visible);
812        assert!(state.search_query.is_empty());
813        assert!(state.search_results.is_empty());
814        assert!(state.search_selected.is_none());
815    }
816
817    #[test]
818    fn context_menu_variants_exist() {
819        // Verify all context menu variants can be constructed
820        use crate::state::ContextMenu;
821
822        let _branch = ContextMenu::Branch {
823            name: "main".to_string(),
824            is_current: true,
825            local_index: 0,
826        };
827        let _remote = ContextMenu::RemoteBranch {
828            name: "origin/main".to_string(),
829        };
830        let _commit = ContextMenu::Commit {
831            index: 0,
832            oid: "abc1234".to_string(),
833        };
834        let _stash = ContextMenu::Stash { index: 0 };
835        let _unstaged = ContextMenu::UnstagedFile {
836            path: "src/main.rs".to_string(),
837        };
838        let _staged = ContextMenu::StagedFile {
839            path: "src/lib.rs".to_string(),
840        };
841    }
842
843    #[test]
844    fn repo_tab_context_menu_defaults_to_none() {
845        let tab = crate::state::RepoTab::new_empty();
846        assert!(tab.context_menu.is_none());
847    }
848
849    #[test]
850    fn context_menu_variants_constructable() {
851        use crate::state::ContextMenu;
852        let _ = ContextMenu::Stash { index: 0 };
853        let _ = ContextMenu::UnstagedFile {
854            path: "a.rs".into(),
855        };
856        let _ = ContextMenu::StagedFile {
857            path: "b.rs".into(),
858        };
859    }
860
861    #[test]
862    fn selected_unstaged_defaults_empty() {
863        let tab = crate::state::RepoTab::new_empty();
864        assert!(tab.selected_unstaged.is_empty());
865        assert!(tab.selected_staged.is_empty());
866    }
867
868    #[test]
869    fn selected_unstaged_toggle() {
870        let mut tab = crate::state::RepoTab::new_empty();
871        tab.selected_unstaged.insert("a.rs".to_string());
872        tab.selected_unstaged.insert("b.rs".to_string());
873        assert_eq!(tab.selected_unstaged.len(), 2);
874        assert!(tab.selected_unstaged.contains("a.rs"));
875        tab.selected_unstaged.remove("a.rs");
876        assert_eq!(tab.selected_unstaged.len(), 1);
877        assert!(!tab.selected_unstaged.contains("a.rs"));
878    }
879
880    #[test]
881    fn detect_system_editor_returns_valid() {
882        // Just verify it doesn't panic
883        let editor = super::detect_system_editor();
884        let _ = editor.display_name();
885    }
886
887    // ── Multi-file commit diff selection ──────────────────────────────────
888
889    #[test]
890    fn selected_commit_file_indices_defaults_to_empty_vec() {
891        let tab = RepoTab::new_empty();
892        assert!(tab.selected_commit_file_indices.is_empty());
893        // Must be a Vec (ordered), not a HashSet — check it supports indexing
894        let v: &Vec<usize> = &tab.selected_commit_file_indices;
895        assert_eq!(v.len(), 0);
896    }
897
898    #[test]
899    fn multi_file_diffs_defaults_empty() {
900        let tab = RepoTab::new_empty();
901        assert!(tab.multi_file_diffs.is_empty());
902    }
903
904    #[test]
905    fn keyboard_modifiers_default_has_no_shift() {
906        let state = GitKraft::new();
907        assert!(!state.keyboard_modifiers.shift());
908    }
909
910    #[test]
911    fn selected_commit_file_indices_preserves_insertion_order() {
912        let mut tab = RepoTab::new_empty();
913        tab.selected_commit_file_indices.push(5);
914        tab.selected_commit_file_indices.push(2);
915        tab.selected_commit_file_indices.push(8);
916        assert_eq!(tab.selected_commit_file_indices, vec![5, 2, 8]);
917    }
918
919    #[test]
920    fn selected_commit_file_indices_cleared_on_reset() {
921        let mut tab = RepoTab::new_empty();
922        tab.selected_commit_file_indices.push(0);
923        tab.selected_commit_file_indices.push(1);
924        tab.selected_commit_file_indices.clear();
925        assert!(tab.selected_commit_file_indices.is_empty());
926    }
927
928    #[test]
929    fn multi_file_diffs_cleared_on_reset() {
930        let mut tab = RepoTab::new_empty();
931        tab.multi_file_diffs.push(gitkraft_core::DiffInfo {
932            old_file: String::new(),
933            new_file: "a.rs".to_string(),
934            status: gitkraft_core::FileStatus::Modified,
935            hunks: vec![],
936        });
937        tab.multi_file_diffs.clear();
938        assert!(tab.multi_file_diffs.is_empty());
939    }
940
941    #[test]
942    fn commit_range_diffs_defaults_empty() {
943        let tab = RepoTab::new_empty();
944        assert!(tab.commit_range_diffs.is_empty());
945    }
946
947    #[test]
948    fn commit_range_diffs_cleared_on_apply_payload() {
949        // verify the field is reset — just check it's accessible and clearable
950        let mut tab = RepoTab::new_empty();
951        tab.commit_range_diffs.push(gitkraft_core::DiffInfo {
952            old_file: String::new(),
953            new_file: "x.rs".to_string(),
954            status: gitkraft_core::FileStatus::Modified,
955            hunks: vec![],
956        });
957        tab.commit_range_diffs.clear();
958        assert!(tab.commit_range_diffs.is_empty());
959    }
960
961    // ── ModifiersChanged update ───────────────────────────────────────────
962
963    #[test]
964    fn modifiers_changed_sets_shift_state() {
965        use crate::message::Message;
966        let mut state = GitKraft::new();
967        assert!(!state.keyboard_modifiers.shift());
968
969        let _ = state.update(Message::ModifiersChanged(iced::keyboard::Modifiers::SHIFT));
970        assert!(state.keyboard_modifiers.shift());
971
972        let _ = state.update(Message::ModifiersChanged(
973            iced::keyboard::Modifiers::default(),
974        ));
975        assert!(!state.keyboard_modifiers.shift());
976    }
977
978    // ── SelectDiffByIndex update ──────────────────────────────────────────
979
980    fn make_commit_files(names: &[&str]) -> Vec<gitkraft_core::DiffFileEntry> {
981        names
982            .iter()
983            .map(|name| gitkraft_core::DiffFileEntry {
984                old_file: String::new(),
985                new_file: name.to_string(),
986                status: gitkraft_core::FileStatus::Modified,
987            })
988            .collect()
989    }
990
991    #[test]
992    fn select_diff_by_index_regular_click_clears_multi_selection() {
993        use crate::message::Message;
994        let mut state = GitKraft::new();
995        // Provide a repo_path and oid so the update handler can reach the
996        // `selected_file_index = Some(index)` assignment (the async task it
997        // spawns is dropped without execution — no real repo is needed).
998        state.active_tab_mut().repo_path =
999            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1000        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1001        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1002        // Pre-populate a multi-selection
1003        state.active_tab_mut().selected_commit_file_indices = vec![0, 1];
1004
1005        // Regular click (no Shift) — should collapse to single-file selection
1006        let _ = state.update(Message::SelectDiffByIndex(0));
1007
1008        assert!(state.active_tab().selected_commit_file_indices.is_empty());
1009        assert!(state.active_tab().multi_file_diffs.is_empty());
1010        assert_eq!(state.active_tab().selected_file_index, Some(0));
1011    }
1012
1013    #[test]
1014    fn select_diff_by_index_shift_click_adds_both_files_to_selection() {
1015        use crate::message::Message;
1016        let mut state = GitKraft::new();
1017        state.active_tab_mut().repo_path =
1018            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1019        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1020        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1021        state.active_tab_mut().selected_file_index = Some(0);
1022
1023        // Shift+Click on file 1 should anchor 0 and add 1
1024        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1025        let _ = state.update(Message::SelectDiffByIndex(1));
1026
1027        let sel = &state.active_tab().selected_commit_file_indices;
1028        assert!(sel.contains(&0), "anchor file 0 should be selected");
1029        assert!(sel.contains(&1), "newly clicked file 1 should be selected");
1030        assert_eq!(sel.len(), 2);
1031    }
1032
1033    #[test]
1034    fn anchor_file_index_defaults_to_none() {
1035        let tab = RepoTab::new_empty();
1036        assert!(tab.anchor_file_index.is_none());
1037    }
1038
1039    #[test]
1040    fn regular_click_sets_anchor() {
1041        use crate::message::Message;
1042        let mut state = GitKraft::new();
1043        state.active_tab_mut().repo_path =
1044            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1045        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1046        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1047
1048        let _ = state.update(Message::SelectDiffByIndex(2));
1049
1050        assert_eq!(
1051            state.active_tab().anchor_file_index,
1052            Some(2),
1053            "regular click must set anchor to the clicked index"
1054        );
1055    }
1056
1057    #[test]
1058    fn shift_click_selects_range_downward_from_anchor() {
1059        use crate::message::Message;
1060        let mut state = GitKraft::new();
1061        state.active_tab_mut().repo_path =
1062            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1063        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1064        state.active_tab_mut().commit_files =
1065            make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1066        // Anchor at index 1
1067        state.active_tab_mut().anchor_file_index = Some(1);
1068        state.active_tab_mut().selected_file_index = Some(1);
1069
1070        // Shift+Click on index 4 — should select 1, 2, 3, 4
1071        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1072        let _ = state.update(Message::SelectDiffByIndex(4));
1073
1074        let sel = &state.active_tab().selected_commit_file_indices;
1075        assert_eq!(
1076            sel,
1077            &vec![1, 2, 3, 4],
1078            "range must be contiguous from anchor to click"
1079        );
1080    }
1081
1082    #[test]
1083    fn shift_click_selects_range_upward_from_anchor() {
1084        use crate::message::Message;
1085        let mut state = GitKraft::new();
1086        state.active_tab_mut().repo_path =
1087            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1088        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1089        state.active_tab_mut().commit_files =
1090            make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1091        // Anchor at index 4 (bottom)
1092        state.active_tab_mut().anchor_file_index = Some(4);
1093        state.active_tab_mut().selected_file_index = Some(4);
1094
1095        // Shift+Click on index 1 — should select 1, 2, 3, 4 (ascending)
1096        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1097        let _ = state.update(Message::SelectDiffByIndex(1));
1098
1099        let sel = &state.active_tab().selected_commit_file_indices;
1100        assert_eq!(
1101            sel,
1102            &vec![1, 2, 3, 4],
1103            "range must be stored ascending regardless of click direction"
1104        );
1105    }
1106
1107    #[test]
1108    fn shift_click_anchor_fixed_on_subsequent_clicks() {
1109        use crate::message::Message;
1110        let mut state = GitKraft::new();
1111        state.active_tab_mut().repo_path =
1112            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1113        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1114        state.active_tab_mut().commit_files =
1115            make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
1116        // Anchor at index 2
1117        state.active_tab_mut().anchor_file_index = Some(2);
1118        state.active_tab_mut().selected_file_index = Some(2);
1119        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1120
1121        // First Shift+Click: extend to 4 → range {2, 3, 4}
1122        let _ = state.update(Message::SelectDiffByIndex(4));
1123        assert_eq!(
1124            state.active_tab().selected_commit_file_indices,
1125            vec![2, 3, 4]
1126        );
1127
1128        // Second Shift+Click: shrink back to 3 → range {2, 3} (anchor still 2)
1129        let _ = state.update(Message::SelectDiffByIndex(3));
1130        assert_eq!(
1131            state.active_tab().selected_commit_file_indices,
1132            vec![2, 3],
1133            "anchor must stay fixed; second Shift+Click shrinks the range"
1134        );
1135
1136        // Third Shift+Click: extend upward → range {0, 1, 2} (anchor still 2)
1137        let _ = state.update(Message::SelectDiffByIndex(0));
1138        assert_eq!(
1139            state.active_tab().selected_commit_file_indices,
1140            vec![0, 1, 2],
1141            "anchor must stay fixed; can extend range in either direction"
1142        );
1143    }
1144
1145    #[test]
1146    fn shift_click_on_anchor_itself_gives_single_item_range() {
1147        use crate::message::Message;
1148        let mut state = GitKraft::new();
1149        state.active_tab_mut().repo_path =
1150            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1151        state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
1152        state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
1153        state.active_tab_mut().anchor_file_index = Some(1);
1154        state.active_tab_mut().selected_file_index = Some(1);
1155
1156        // Shift+Click on the anchor itself → single-item range {1}
1157        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1158        let _ = state.update(Message::SelectDiffByIndex(1));
1159
1160        assert_eq!(state.active_tab().selected_commit_file_indices, vec![1]);
1161        assert!(
1162            state.active_tab().multi_file_diffs.is_empty(),
1163            "single-item range must not populate multi_file_diffs"
1164        );
1165    }
1166
1167    #[test]
1168    fn shift_click_range_is_always_ascending() {
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 = make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs"]);
1175        state.active_tab_mut().anchor_file_index = Some(3);
1176        state.active_tab_mut().selected_file_index = Some(3);
1177
1178        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1179        let _ = state.update(Message::SelectDiffByIndex(0));
1180
1181        let sel = &state.active_tab().selected_commit_file_indices;
1182        let is_sorted = sel.windows(2).all(|w| w[0] < w[1]);
1183        assert!(
1184            is_sorted,
1185            "selection must always be stored in ascending order"
1186        );
1187        assert_eq!(sel, &vec![0, 1, 2, 3]);
1188    }
1189
1190    #[test]
1191    fn checkout_file_at_commit_message_variants_exist() {
1192        use crate::message::Message;
1193        // Verify the new message variants can be constructed
1194        let _single =
1195            Message::CheckoutFileAtCommit("abc123".to_string(), "src/main.rs".to_string());
1196        let _multi = Message::CheckoutMultiFilesAtCommit(
1197            "abc123".to_string(),
1198            vec!["a.rs".to_string(), "b.rs".to_string()],
1199        );
1200    }
1201
1202    #[test]
1203    fn checkout_file_at_commit_closes_context_menu() {
1204        use crate::message::Message;
1205        let mut state = GitKraft::new();
1206        state.active_tab_mut().repo_path =
1207            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1208        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1209            oid: "abc123".to_string(),
1210            file_path: "src/main.rs".to_string(),
1211        });
1212        let _ = state.update(Message::CheckoutFileAtCommit(
1213            "abc123".to_string(),
1214            "src/main.rs".to_string(),
1215        ));
1216        assert!(state.active_tab().context_menu.is_none());
1217    }
1218
1219    #[test]
1220    fn checkout_multi_files_at_commit_closes_context_menu() {
1221        use crate::message::Message;
1222        let mut state = GitKraft::new();
1223        state.active_tab_mut().repo_path =
1224            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1225        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
1226            oid: "abc123".to_string(),
1227            file_path: "src/main.rs".to_string(),
1228        });
1229        let _ = state.update(Message::CheckoutMultiFilesAtCommit(
1230            "abc123".to_string(),
1231            vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
1232        ));
1233        assert!(state.active_tab().context_menu.is_none());
1234    }
1235
1236    // ── Commit multi-selection ────────────────────────────────────────────
1237
1238    fn make_test_commits(count: usize) -> Vec<gitkraft_core::CommitInfo> {
1239        (0..count)
1240            .map(|i| gitkraft_core::CommitInfo {
1241                oid: i.to_string(),
1242                short_oid: i.to_string(),
1243                summary: String::new(),
1244                message: String::new(),
1245                author_name: String::new(),
1246                author_email: String::new(),
1247                time: Default::default(),
1248                parent_ids: Vec::new(),
1249            })
1250            .collect()
1251    }
1252
1253    #[test]
1254    fn selected_commits_defaults_empty() {
1255        let tab = RepoTab::new_empty();
1256        assert!(tab.selected_commits.is_empty());
1257        assert!(tab.anchor_commit_index.is_none());
1258    }
1259
1260    #[test]
1261    fn regular_click_commit_sets_anchor_and_clears_range() {
1262        use crate::message::Message;
1263        let mut state = GitKraft::new();
1264        state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
1265        state.active_tab_mut().commits = make_test_commits(3);
1266        state.active_tab_mut().selected_commits = vec![0, 1, 2];
1267
1268        let _ = state.update(Message::SelectCommit(1));
1269
1270        assert_eq!(state.active_tab().anchor_commit_index, Some(1));
1271        assert!(state.active_tab().selected_commits.is_empty());
1272        assert_eq!(state.active_tab().selected_commit, Some(1));
1273    }
1274
1275    #[test]
1276    fn shift_click_commit_selects_range_from_anchor() {
1277        use crate::message::Message;
1278        let mut state = GitKraft::new();
1279        state.active_tab_mut().commits = make_test_commits(5);
1280        state.active_tab_mut().anchor_commit_index = Some(1);
1281        state.active_tab_mut().selected_commit = Some(1);
1282
1283        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1284        let _ = state.update(Message::SelectCommit(4));
1285
1286        assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3, 4]);
1287    }
1288
1289    #[test]
1290    fn shift_click_commit_range_is_ascending_when_clicking_above_anchor() {
1291        use crate::message::Message;
1292        let mut state = GitKraft::new();
1293        state.active_tab_mut().commits = make_test_commits(5);
1294        state.active_tab_mut().anchor_commit_index = Some(3);
1295        state.active_tab_mut().selected_commit = Some(3);
1296
1297        state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
1298        let _ = state.update(Message::SelectCommit(1));
1299
1300        assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3]);
1301    }
1302
1303    // ── ExecuteCommitAction message ───────────────────────────────────────
1304
1305    #[test]
1306    fn execute_commit_action_closes_context_menu() {
1307        use crate::message::Message;
1308        let mut state = GitKraft::new();
1309        state.active_tab_mut().repo_path =
1310            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1311        state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::Commit {
1312            index: 0,
1313            oid: "abc123".to_string(),
1314        });
1315
1316        let _ = state.update(Message::ExecuteCommitAction(
1317            "abc123".to_string(),
1318            gitkraft_core::CommitAction::CherryPick,
1319        ));
1320
1321        assert!(state.active_tab().context_menu.is_none());
1322    }
1323
1324    #[test]
1325    fn execute_commit_action_sets_loading_when_repo_open() {
1326        use crate::message::Message;
1327        let mut state = GitKraft::new();
1328        state.active_tab_mut().repo_path =
1329            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1330
1331        let _ = state.update(Message::ExecuteCommitAction(
1332            "abc123".to_string(),
1333            gitkraft_core::CommitAction::ResetHard,
1334        ));
1335
1336        assert!(state.active_tab().is_loading);
1337    }
1338
1339    #[test]
1340    fn execute_commit_action_no_repo_does_not_set_loading() {
1341        use crate::message::Message;
1342        let mut state = GitKraft::new();
1343        // No repo_path set
1344
1345        let _ = state.update(Message::ExecuteCommitAction(
1346            "abc123".to_string(),
1347            gitkraft_core::CommitAction::CherryPick,
1348        ));
1349
1350        assert!(!state.active_tab().is_loading);
1351    }
1352
1353    #[test]
1354    fn execute_commit_action_sets_status_message_from_action_label() {
1355        use crate::message::Message;
1356        let mut state = GitKraft::new();
1357        state.active_tab_mut().repo_path =
1358            Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
1359
1360        let _ = state.update(Message::ExecuteCommitAction(
1361            "abc123".to_string(),
1362            gitkraft_core::CommitAction::Revert,
1363        ));
1364
1365        let status = state.active_tab().status_message.as_deref().unwrap_or("");
1366        // Status message should contain the action's label
1367        assert!(
1368            status.contains("Revert commit"),
1369            "expected status to contain 'Revert commit', got: {status:?}"
1370        );
1371    }
1372}