Skip to main content

gitkraft_tui/
app.rs

1use std::path::PathBuf;
2
3use ratatui::widgets::ListState;
4use std::sync::mpsc;
5
6use gitkraft_core::*;
7
8// ── Background task macros ────────────────────────────────────────────────────
9
10/// Spawn a background task that requires a repo path.
11///
12/// Extracts `repo_path` from the active tab, sets `is_loading = true` and
13/// a status message, clones `bg_tx`, then spawns a thread that runs `$body`
14/// and sends the result wrapped in `$variant`.
15///
16/// `$body` may use `?` – it is executed inside an immediately-invoked closure.
17macro_rules! bg_task {
18    ($self:expr, $status:expr, $variant:path, |$rp:ident| $body:expr) => {{
19        let $rp = match $self.tab().repo_path.clone() {
20            Some(p) => p,
21            None => return,
22        };
23        $self.tab_mut().is_loading = true;
24        $self.tab_mut().status_message = Some($status.into());
25        let tx = $self.bg_tx.clone();
26        std::thread::spawn(move || {
27            let _ = tx.send($variant((|| $body)()));
28        });
29    }};
30}
31
32/// Spawn a background task that produces an `OperationDone` result.
33///
34/// `$body` must return `Result<String, String>` where `Ok(msg)` is the success
35/// message and `Err(msg)` is the error message. `?` may be used inside the body.
36///
37/// Use `staging` to trigger only a staging refresh on success, or `refresh`
38/// to trigger a full repo refresh.
39macro_rules! bg_op {
40    ($self:expr, $status:expr, staging, |$rp:ident| $body:expr) => {
41        bg_op!(@inner $self, $status, false, true, |$rp| $body)
42    };
43    ($self:expr, $status:expr, refresh, |$rp:ident| $body:expr) => {
44        bg_op!(@inner $self, $status, true, false, |$rp| $body)
45    };
46    (@inner $self:expr, $status:expr, $nr:expr, $nsr:expr, |$rp:ident| $body:expr) => {{
47        let $rp = match $self.tab().repo_path.clone() {
48            Some(p) => p,
49            None => return,
50        };
51        $self.tab_mut().is_loading = true;
52        $self.tab_mut().status_message = Some($status.into());
53        let tx = $self.bg_tx.clone();
54        std::thread::spawn(move || {
55            let res: Result<String, String> = (|| $body)();
56            let _ = tx.send(BackgroundResult::OperationDone {
57                ok_message: res.as_ref().ok().cloned(),
58                err_message: res.err(),
59                needs_refresh: $nr,
60                needs_staging_refresh: $nsr,
61            });
62        });
63    }};
64}
65
66/// Extract the selected list index, returning early with an error if the
67/// list state has no selection or the index is out of bounds.
68///
69/// Usage: `let idx = selected_idx!(self, list_state_field, vec_field, "error msg");`
70macro_rules! selected_idx {
71    ($self:expr, $state:ident, $vec:ident, $err:expr) => {{
72        let idx = $self.tab().$state.selected().unwrap_or(0);
73        if idx >= $self.tab().$vec.len() {
74            $self.tab_mut().error_message = Some($err.into());
75            return;
76        }
77        idx
78    }};
79}
80
81/// Return early with a status message when the list has no selection.
82///
83/// Unlike [`selected_idx!`] this does NOT check bounds — it only handles the
84/// `None` case, setting `status_message` (not `error_message`) and returning.
85///
86/// Usage: `let idx = require_selection!(self, list_state_field, "msg");`
87macro_rules! require_selection {
88    ($self:expr, $state:ident, $msg:expr) => {{
89        match $self.tab().$state.selected() {
90            Some(i) => i,
91            None => {
92                $self.tab_mut().status_message = Some($msg.into());
93                return;
94            }
95        }
96    }};
97}
98
99// ── Background task results ───────────────────────────────────────────────────
100
101/// Alias for the shared core type — kept for backward-compat with the
102/// `BackgroundResult::RepoLoaded` variant.
103pub type RepoPayload = gitkraft_core::RepoSnapshot;
104
105/// Results produced by background tasks and sent back to the main loop.
106#[derive(Debug)]
107pub enum BackgroundResult {
108    /// A repo open / refresh completed. The `PathBuf` identifies which tab
109    /// initiated the load so the result is applied to the correct tab.
110    RepoLoaded {
111        path: PathBuf,
112        result: Result<RepoPayload, String>,
113    },
114    /// A fetch completed.
115    FetchDone(Result<(), String>),
116    /// A commit-diff load completed.
117    CommitDiffLoaded(Result<Vec<DiffInfo>, String>),
118    /// A staging-only refresh completed (unstaged + staged diffs reloaded).
119    StagingRefreshed(Result<StagingPayload, String>),
120    /// A single-shot operation (stage, unstage, checkout, commit, stash, etc.)
121    /// completed and the staging area should be refreshed.
122    OperationDone {
123        ok_message: Option<String>,
124        err_message: Option<String>,
125        /// If `true`, trigger a full refresh after applying the result.
126        needs_refresh: bool,
127        /// If `true`, trigger only a staging refresh.
128        needs_staging_refresh: bool,
129    },
130    /// A commit file list (lightweight, no diff content) was loaded.
131    CommitFileListLoaded(Result<Vec<gitkraft_core::DiffFileEntry>, String>),
132    /// A single file's diff was loaded.  The `usize` is the file index in `commit_files`.
133    SingleFileDiffLoaded(Result<(usize, gitkraft_core::DiffInfo), String>),
134    /// Commit search results loaded.
135    SearchResults(Result<Vec<gitkraft_core::CommitInfo>, String>),
136    /// Combined range diff across multiple selected commits.
137    CommitRangeDiffLoaded(Result<Vec<gitkraft_core::DiffInfo>, String>),
138    /// File history (commits touching a specific file) loaded.
139    FileHistoryLoaded {
140        path: String,
141        commits: Vec<gitkraft_core::CommitInfo>,
142    },
143    /// File blame lines loaded.
144    FileBlameLoaded {
145        path: String,
146        lines: Vec<gitkraft_core::BlameLine>,
147    },
148    /// The `.git` directory changed from outside the TUI — trigger a full refresh.
149    GitStateChanged,
150}
151
152/// Payload returned by an async staging refresh.
153#[derive(Debug)]
154pub struct StagingPayload {
155    pub unstaged: Vec<DiffInfo>,
156    pub staged: Vec<DiffInfo>,
157}
158
159// ── Enums ─────────────────────────────────────────────────────────────────────
160
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub enum AppScreen {
163    Welcome,
164    DirBrowser,
165    Main,
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum ActivePane {
170    Branches,
171    CommitLog,
172    DiffView,
173    Staging,
174    Stash,
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum InputMode {
179    Normal,
180    Input,
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum InputPurpose {
185    None,
186    CommitMessage,
187    BranchName,
188    RepoPath,
189    SearchQuery,
190    StashMessage,
191    /// First string for a pending commit action (branch name, tag name, etc.).
192    CommitActionInput1,
193    /// Second string for a pending commit action (annotated-tag message).
194    CommitActionInput2,
195}
196
197/// Which sub-list within the staging pane has focus.
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum StagingFocus {
200    Unstaged,
201    Staged,
202}
203
204/// Which half of the split Diff pane currently has keyboard focus.
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub enum DiffSubPane {
207    /// The file-list sidebar on the left.
208    FileList,
209    /// The diff-content area on the right.
210    Content,
211}
212
213// ── Per-repo tab state ────────────────────────────────────────────────────────────
214
215/// All state that belongs to a single repository tab.
216pub struct RepoTab {
217    pub repo_path: Option<PathBuf>,
218    pub repo_info: Option<RepoInfo>,
219
220    pub branches: Vec<BranchInfo>,
221    pub branch_list_state: ListState,
222
223    pub commits: Vec<CommitInfo>,
224    pub graph_rows: Vec<gitkraft_core::GraphRow>,
225    pub commit_list_state: ListState,
226
227    pub unstaged_changes: Vec<DiffInfo>,
228    pub staged_changes: Vec<DiffInfo>,
229    pub unstaged_list_state: ListState,
230    pub staged_list_state: ListState,
231    pub staging_focus: StagingFocus,
232    pub selected_diff: Option<DiffInfo>,
233    pub diff_scroll: u16,
234    /// Which half of the diff split pane has keyboard focus.
235    pub diff_sub_pane: DiffSubPane,
236    /// Anchor index for diff file list range selection (set on plain navigation).
237    pub anchor_file_index: Option<usize>,
238    /// File indices currently multi-selected in the diff file list.
239    pub selected_file_indices: std::collections::HashSet<usize>,
240    /// Cache of per-file diffs keyed by file index (sparse — only loaded files are present).
241    pub commit_diffs: std::collections::HashMap<usize, DiffInfo>,
242    /// Index of the currently selected file in commit_files.
243    pub commit_diff_file_index: usize,
244    /// Lightweight file list for the selected commit.
245    pub commit_files: Vec<gitkraft_core::DiffFileEntry>,
246    /// OID of the currently selected commit (for lazy file diff loading).
247    pub selected_commit_oid: Option<String>,
248
249    pub stashes: Vec<StashEntry>,
250    pub stash_list_state: ListState,
251    pub remotes: Vec<RemoteInfo>,
252
253    /// Search query for commit filtering.
254    pub search_query: String,
255    /// Whether search mode is active.
256    pub search_active: bool,
257    /// Search results (commits matching the query).
258    pub search_results: Vec<CommitInfo>,
259
260    /// Optional stash message (set via input mode before saving).
261    pub stash_message_buffer: String,
262
263    pub status_message: Option<String>,
264    pub error_message: Option<String>,
265
266    /// True while a background task is in flight for this tab.
267    pub is_loading: bool,
268
269    /// When true, the next d press actually discards; otherwise the first
270    /// d sets this flag and shows a confirmation prompt.
271    pub confirm_discard: bool,
272
273    /// Selected unstaged file indices for multi-select.
274    pub selected_unstaged: std::collections::HashSet<usize>,
275    /// Selected staged file indices for multi-select.
276    pub selected_staged: std::collections::HashSet<usize>,
277    /// Anchor index for unstaged range selection (set on plain j/k navigation).
278    pub anchor_unstaged: Option<usize>,
279    /// Anchor index for staged range selection (set on plain j/k navigation).
280    pub anchor_staged: Option<usize>,
281
282    /// Anchor commit index for range selection (Shift+Up/Down).
283    pub anchor_commit_index: Option<usize>,
284    /// Ordered ascending list of commit indices in the current range selection.
285    pub selected_commits: Vec<usize>,
286    /// Combined diff for the current commit range selection.
287    pub commit_range_diffs: Vec<DiffInfo>,
288
289    /// Flat ordered list of all action kinds shown in the popup (built from
290    /// `COMMIT_MENU_GROUPS`, separator positions stored separately).
291    pub commit_action_items: Vec<gitkraft_core::CommitActionKind>,
292    /// Which item in `commit_action_items` is highlighted (0-based).
293    pub commit_action_cursor: usize,
294    /// OID of the commit targeted by the open action popup.
295    pub pending_commit_action_oid: Option<String>,
296    /// Action kind waiting for user input before it can execute.
297    pub pending_action_kind: Option<gitkraft_core::CommitActionKind>,
298    /// First input collected for the pending action (e.g. branch/tag name).
299    pub action_input1: String,
300
301    /// When `Some(path)`, the file-history overlay is visible for that path.
302    pub file_history_path: Option<String>,
303    /// Commits that touch the file in the history overlay (newest first).
304    pub file_history_commits: Vec<gitkraft_core::CommitInfo>,
305    /// Which commit row is highlighted in the history overlay (0-based).
306    pub file_history_cursor: usize,
307
308    /// When `Some(path)`, the blame overlay is visible for that path.
309    pub blame_path: Option<String>,
310    /// Blame lines for the current blame overlay.
311    pub blame_lines: Vec<gitkraft_core::BlameLine>,
312    /// Scroll offset for the blame overlay (rows from top).
313    pub blame_scroll: u16,
314
315    /// When `Some(path)`, show a delete-confirmation prompt for that path.
316    pub confirm_delete_file: Option<String>,
317}
318
319impl RepoTab {
320    #[must_use]
321    pub fn new() -> Self {
322        Self {
323            repo_path: None,
324            repo_info: None,
325
326            branches: Vec::new(),
327            branch_list_state: ListState::default(),
328
329            commits: Vec::new(),
330            graph_rows: Vec::new(),
331            commit_list_state: ListState::default(),
332
333            unstaged_changes: Vec::new(),
334            staged_changes: Vec::new(),
335            unstaged_list_state: ListState::default(),
336            staged_list_state: ListState::default(),
337            staging_focus: StagingFocus::Unstaged,
338            selected_diff: None,
339            diff_scroll: 0,
340            diff_sub_pane: DiffSubPane::FileList,
341            anchor_file_index: None,
342            selected_file_indices: std::collections::HashSet::new(),
343            commit_diffs: std::collections::HashMap::new(),
344            commit_diff_file_index: 0,
345            commit_files: Vec::new(),
346            selected_commit_oid: None,
347
348            stashes: Vec::new(),
349            stash_list_state: ListState::default(),
350            remotes: Vec::new(),
351
352            stash_message_buffer: String::new(),
353
354            search_query: String::new(),
355            search_active: false,
356            search_results: Vec::new(),
357
358            status_message: None,
359            error_message: None,
360
361            is_loading: false,
362
363            confirm_discard: false,
364
365            selected_unstaged: std::collections::HashSet::new(),
366            selected_staged: std::collections::HashSet::new(),
367            anchor_unstaged: None,
368            anchor_staged: None,
369
370            anchor_commit_index: None,
371            selected_commits: Vec::new(),
372            commit_range_diffs: Vec::new(),
373
374            commit_action_items: Vec::new(),
375            commit_action_cursor: 0,
376            pending_commit_action_oid: None,
377            pending_action_kind: None,
378            action_input1: String::new(),
379
380            file_history_path: None,
381            file_history_commits: Vec::new(),
382            file_history_cursor: 0,
383            blame_path: None,
384            blame_lines: Vec::new(),
385            blame_scroll: 0,
386            confirm_delete_file: None,
387        }
388    }
389
390    /// Return a human-readable display name for this tab.
391    /// Uses the last path component of repo_path, or "New Tab" if none.
392    pub fn display_name(&self) -> String {
393        match &self.repo_path {
394            Some(p) => p
395                .file_name()
396                .map(|n| n.to_string_lossy().into_owned())
397                .unwrap_or_else(|| "New Tab".into()),
398            None => "New Tab".into(),
399        }
400    }
401}
402
403impl Default for RepoTab {
404    fn default() -> Self {
405        Self::new()
406    }
407}
408
409// ── App State ───────────────────────────────────────────────────────────────────
410
411pub struct App {
412    pub should_quit: bool,
413    pub screen: AppScreen,
414    pub active_pane: ActivePane,
415    pub input_mode: InputMode,
416    pub input_purpose: InputPurpose,
417    pub tick_count: u64,
418
419    /// Receiver for results from background tasks.
420    pub bg_rx: mpsc::Receiver<BackgroundResult>,
421    /// Sender cloned into each spawned task.
422    pub(crate) bg_tx: mpsc::Sender<BackgroundResult>,
423
424    pub input_buffer: String,
425
426    /// Whether the theme selection panel is visible.
427    pub show_theme_panel: bool,
428    /// Whether the options panel is visible.
429    pub show_options_panel: bool,
430    /// Configured editor for opening files.
431    pub editor: gitkraft_core::Editor,
432    /// Whether the editor picker panel is visible.
433    pub show_editor_panel: bool,
434    /// ListState for the editor picker list.
435    pub editor_list_state: ListState,
436    /// Currently selected theme index (0-26).
437    pub current_theme_index: usize,
438    /// ListState for the theme list widget.
439    pub theme_list_state: ListState,
440
441    /// Recently opened repositories loaded from persistence.
442    pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
443
444    /// Current directory being browsed in the directory picker.
445    pub browser_dir: PathBuf,
446    /// Entries in the current browser directory.
447    pub browser_entries: Vec<std::path::PathBuf>,
448    /// List state for the directory browser.
449    pub browser_list_state: ListState,
450    /// Screen to return to when the directory browser is dismissed.
451    pub browser_return_screen: AppScreen,
452
453    /// Open repository tabs.
454    pub tabs: Vec<RepoTab>,
455    /// Index of the currently active tab.
456    pub active_tab_index: usize,
457
458    /// When `Some`, the event loop in `lib.rs` suspends the TUI, opens ALL listed
459    /// paths in the configured terminal editor, then resumes.
460    /// Supports both single-file (`vec![path]`) and multi-file (`vec![a, b, c]`) opens.
461    pub pending_editor_open: Option<Vec<std::path::PathBuf>>,
462
463    /// Timestamp of the last auto-refresh.
464    pub last_auto_refresh: std::time::Instant,
465
466    /// Timestamp of the last completed `RepoLoaded` result.
467    /// Used to apply a short cooldown to watcher-triggered refreshes so that
468    /// a checkout (which writes HEAD) doesn’t cause two refreshes in quick
469    /// succession: one from `OperationDone` and one from the git watcher.
470    pub last_refresh_completed: std::time::Instant,
471}
472
473impl App {
474    // ── Constructor ──────────────────────────────────────────────────────────
475
476    #[must_use]
477    pub fn new() -> Self {
478        let settings =
479            gitkraft_core::features::persistence::load_tui_settings().unwrap_or_default();
480
481        let theme_index = theme_name_to_index(settings.theme_name.as_deref().unwrap_or(""));
482
483        let recent_repos = settings.recent_repos;
484
485        let (bg_tx, bg_rx) = mpsc::channel();
486
487        Self {
488            should_quit: false,
489            screen: AppScreen::Welcome,
490            active_pane: ActivePane::Branches,
491            input_mode: InputMode::Normal,
492            input_purpose: InputPurpose::None,
493            tick_count: 0,
494
495            bg_rx,
496            bg_tx,
497
498            input_buffer: String::new(),
499
500            show_theme_panel: false,
501            show_options_panel: false,
502            editor: settings
503                .editor_name
504                .as_deref()
505                .map(|name| {
506                    gitkraft_core::EDITOR_NAMES
507                        .iter()
508                        .position(|n| n.eq_ignore_ascii_case(name))
509                        .map(gitkraft_core::Editor::from_index)
510                        .unwrap_or_else(|| {
511                            if name.eq_ignore_ascii_case("none") {
512                                gitkraft_core::Editor::None
513                            } else {
514                                gitkraft_core::Editor::Custom(name.to_string())
515                            }
516                        })
517                })
518                .unwrap_or(gitkraft_core::Editor::None),
519            show_editor_panel: false,
520            editor_list_state: {
521                let mut s = ListState::default();
522                s.select(Some(0));
523                s
524            },
525            current_theme_index: theme_index,
526            theme_list_state: {
527                let mut s = ListState::default();
528                s.select(Some(theme_index));
529                s
530            },
531
532            recent_repos,
533
534            browser_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")),
535            browser_entries: Vec::new(),
536            browser_list_state: ListState::default(),
537            browser_return_screen: AppScreen::Welcome,
538
539            tabs: vec![RepoTab::new()],
540            active_tab_index: 0,
541
542            pending_editor_open: None,
543
544            last_auto_refresh: std::time::Instant::now(),
545            last_refresh_completed: std::time::Instant::now() - std::time::Duration::from_secs(100),
546        }
547    }
548
549    // ── Tab accessors ────────────────────────────────────────────────────────
550
551    /// Return a shared reference to the currently active tab.
552    #[inline]
553    pub fn tab(&self) -> &RepoTab {
554        &self.tabs[self.active_tab_index]
555    }
556
557    /// Return an exclusive reference to the currently active tab.
558    #[inline]
559    pub fn tab_mut(&mut self) -> &mut RepoTab {
560        &mut self.tabs[self.active_tab_index]
561    }
562
563    // ── Tab management ──────────────────────────────────────────────────────
564
565    /// Open a new empty tab and make it active.
566    pub fn new_tab(&mut self) {
567        self.tabs.push(RepoTab::new());
568        self.active_tab_index = self.tabs.len() - 1;
569        self.screen = AppScreen::Welcome;
570        // Reload recent repos so they're fresh on the welcome screen
571        if let Ok(settings) = gitkraft_core::features::persistence::load_tui_settings() {
572            self.recent_repos = settings.recent_repos;
573        }
574        self.save_session();
575    }
576
577    /// Close the current tab. If it is the last tab, replace it with an empty one.
578    pub fn close_tab(&mut self) {
579        if self.tabs.len() <= 1 {
580            self.tabs[0] = RepoTab::new();
581            self.active_tab_index = 0;
582        } else {
583            self.tabs.remove(self.active_tab_index);
584            if self.active_tab_index >= self.tabs.len() {
585                self.active_tab_index = self.tabs.len() - 1;
586            }
587        }
588        self.save_session();
589    }
590
591    /// Switch to the next tab (wrapping around).
592    pub fn next_tab(&mut self) {
593        if !self.tabs.is_empty() {
594            self.active_tab_index = (self.active_tab_index + 1) % self.tabs.len();
595            // Restore the correct screen for the target tab
596            if self.tabs[self.active_tab_index].repo_path.is_some() {
597                self.screen = AppScreen::Main;
598            } else {
599                self.screen = AppScreen::Welcome;
600            }
601        }
602    }
603
604    /// Switch to the previous tab (wrapping around).
605    pub fn prev_tab(&mut self) {
606        if !self.tabs.is_empty() {
607            if self.active_tab_index == 0 {
608                self.active_tab_index = self.tabs.len() - 1;
609            } else {
610                self.active_tab_index -= 1;
611            }
612            // Restore the correct screen for the target tab
613            if self.tabs[self.active_tab_index].repo_path.is_some() {
614                self.screen = AppScreen::Main;
615            } else {
616                self.screen = AppScreen::Welcome;
617            }
618        }
619    }
620}
621
622impl Default for App {
623    fn default() -> Self {
624        Self::new()
625    }
626}
627
628impl App {
629    // ── Theme helpers ────────────────────────────────────────────────────
630
631    pub fn cycle_theme_next(&mut self) {
632        let count = gitkraft_core::THEME_COUNT;
633        self.current_theme_index = (self.current_theme_index + 1) % count;
634        self.theme_list_state.select(Some(self.current_theme_index));
635        self.tab_mut().status_message = Some(format!("Theme: {}", self.current_theme_name()));
636    }
637
638    pub fn cycle_theme_prev(&mut self) {
639        let count = gitkraft_core::THEME_COUNT;
640        if self.current_theme_index == 0 {
641            self.current_theme_index = count - 1;
642        } else {
643            self.current_theme_index -= 1;
644        }
645        self.theme_list_state.select(Some(self.current_theme_index));
646        self.tab_mut().status_message = Some(format!("Theme: {}", self.current_theme_name()));
647    }
648
649    pub fn current_theme_name(&self) -> &'static str {
650        gitkraft_core::THEME_NAMES
651            .get(self.current_theme_index)
652            .copied()
653            .unwrap_or("Default")
654    }
655
656    /// Return the `UiTheme` for the currently selected theme index.
657    pub fn theme(&self) -> crate::features::theme::palette::UiTheme {
658        crate::features::theme::palette::theme_for_index(self.current_theme_index)
659    }
660
661    /// Persist the current theme selection to disk.
662    pub fn save_theme(&self) {
663        let _ = gitkraft_core::features::persistence::save_theme_tui(self.current_theme_name());
664    }
665
666    /// Persist the paths of all open tabs for session restore.
667    pub fn save_session(&self) {
668        let paths: Vec<std::path::PathBuf> = self
669            .tabs
670            .iter()
671            .filter_map(|t| t.repo_path.clone())
672            .collect();
673        let active = self.active_tab_index;
674        let _ = gitkraft_core::features::persistence::save_session_tui(&paths, active);
675    }
676
677    // ── High-level operations ────────────────────────────────────────────
678
679    pub fn open_repo(&mut self, path: PathBuf) {
680        self.tab_mut().error_message = None;
681        self.tab_mut().status_message = Some("Opening repository…".into());
682        self.tab_mut().is_loading = true;
683        self.tab_mut().repo_path = Some(path.clone());
684        self.screen = AppScreen::Main;
685
686        let tx = self.bg_tx.clone();
687        std::thread::spawn(move || {
688            let result = load_repo_blocking(&path);
689            let _ = tx.send(BackgroundResult::RepoLoaded { path, result });
690        });
691        self.save_session();
692    }
693
694    pub fn refresh(&mut self) {
695        self.tab_mut().error_message = None;
696        self.tab_mut().is_loading = true;
697        self.tab_mut().status_message = Some("Refreshing…".into());
698        self.refresh_internal();
699    }
700
701    /// Background refresh triggered by the git watcher — no status message so
702    /// user-set messages (selection counts, etc.) are not overwritten.
703    pub fn refresh_silent(&mut self) {
704        self.tab_mut().error_message = None;
705        self.tab_mut().is_loading = true;
706        self.refresh_internal();
707    }
708
709    fn refresh_internal(&mut self) {
710        let path = match self.tab().repo_path.clone() {
711            Some(p) => p,
712            None => {
713                self.tab_mut().error_message = Some("No repository open".into());
714                self.tab_mut().is_loading = false;
715                return;
716            }
717        };
718
719        let tx = self.bg_tx.clone();
720        std::thread::spawn(move || {
721            let result = load_repo_blocking(&path);
722            let _ = tx.send(BackgroundResult::RepoLoaded { path, result });
723        });
724    }
725
726    /// Process any pending results from background tasks.
727    /// Call this once per tick in the event loop.
728    pub fn poll_background(&mut self) {
729        while let Ok(result) = self.bg_rx.try_recv() {
730            match result {
731                BackgroundResult::RepoLoaded {
732                    path: loaded_path,
733                    result: res,
734                } => {
735                    // Find the tab that initiated this load by matching repo_path.
736                    let tab_idx = self
737                        .tabs
738                        .iter()
739                        .position(|t| t.repo_path.as_ref() == Some(&loaded_path))
740                        .unwrap_or(self.active_tab_index);
741
742                    self.tabs[tab_idx].is_loading = false;
743                    match res {
744                        Ok(payload) => {
745                            let canonical = payload.info.workdir.clone().unwrap_or_else(|| {
746                                self.tabs[tab_idx].repo_path.clone().unwrap_or_default()
747                            });
748                            self.tabs[tab_idx].repo_path = Some(canonical.clone());
749
750                            // Persist
751                            let _ = gitkraft_core::features::persistence::record_repo_opened_tui(
752                                &canonical,
753                            );
754                            if let Ok(settings) =
755                                gitkraft_core::features::persistence::load_tui_settings()
756                            {
757                                self.recent_repos = settings.recent_repos;
758                            }
759
760                            let tab = &mut self.tabs[tab_idx];
761                            tab.repo_info = Some(payload.info);
762                            tab.branches = payload.branches;
763                            clamp_list_state(&mut tab.branch_list_state, tab.branches.len());
764                            tab.graph_rows = payload.graph_rows;
765                            tab.commits = payload.commits;
766                            clamp_list_state(&mut tab.commit_list_state, tab.commits.len());
767                            tab.unstaged_changes = payload.unstaged;
768                            clamp_list_state(
769                                &mut tab.unstaged_list_state,
770                                tab.unstaged_changes.len(),
771                            );
772                            tab.staged_changes = payload.staged;
773                            clamp_list_state(&mut tab.staged_list_state, tab.staged_changes.len());
774                            tab.stashes = payload.stashes;
775                            clamp_list_state(&mut tab.stash_list_state, tab.stashes.len());
776                            tab.remotes = payload.remotes;
777                            // Clear the loading status without replacing it so that
778                            // user-set messages (e.g. "N files selected") survive
779                            // background watcher refreshes.
780                            tab.status_message = None;
781                            self.screen = AppScreen::Main;
782                            self.save_session();
783                            // Stamp completion time so the watcher cooldown works.
784                            self.last_refresh_completed = std::time::Instant::now();
785                        }
786                        Err(e) => {
787                            self.tabs[tab_idx].error_message = Some(e);
788                            self.tabs[tab_idx].status_message = None;
789                        }
790                    }
791                }
792                BackgroundResult::FetchDone(res) => {
793                    self.tab_mut().is_loading = false;
794                    match res {
795                        Ok(()) => {
796                            self.tab_mut().status_message = Some("Fetched from origin".into());
797                            self.refresh();
798                        }
799                        Err(e) => self.tab_mut().error_message = Some(format!("fetch: {e}")),
800                    }
801                }
802                BackgroundResult::CommitDiffLoaded(res) => {
803                    self.tab_mut().is_loading = false;
804                    match res {
805                        Ok(diffs) => {
806                            if diffs.is_empty() {
807                                let tab = self.tab_mut();
808                                tab.selected_diff = None;
809                                tab.commit_diffs.clear();
810                                tab.commit_diff_file_index = 0;
811                                tab.status_message = Some("No changes in this commit".into());
812                            } else {
813                                let tab = self.tab_mut();
814                                tab.commit_diffs = diffs
815                                    .iter()
816                                    .enumerate()
817                                    .map(|(i, d)| (i, d.clone()))
818                                    .collect();
819                                tab.commit_diff_file_index = 0;
820                                tab.selected_diff = Some(diffs[0].clone());
821                                tab.diff_scroll = 0;
822                                if diffs.len() > 1 {
823                                    tab.status_message = Some(format!(
824                                        "Showing file 1/{} — use h/l to switch files",
825                                        diffs.len()
826                                    ));
827                                }
828                            }
829                        }
830                        Err(e) => self.tab_mut().error_message = Some(format!("commit diff: {e}")),
831                    }
832                }
833                BackgroundResult::CommitFileListLoaded(res) => {
834                    self.tab_mut().is_loading = false;
835                    match res {
836                        Ok(files) => {
837                            let count = files.len();
838                            let tab = self.tab_mut();
839                            tab.commit_files = files;
840                            tab.commit_diffs.clear();
841                            tab.commit_diff_file_index = 0;
842                            tab.selected_diff = None;
843                            tab.diff_scroll = 0;
844                            tab.diff_sub_pane = DiffSubPane::FileList;
845                            tab.selected_file_indices.clear();
846
847                            if count == 0 {
848                                tab.status_message = Some("No changes in this commit".into());
849                            } else {
850                                tab.status_message = Some(format!("{count} file(s) changed"));
851                                tab.selected_file_indices.insert(0);
852                                // Auto-load the first file's diff
853                                let first_path = tab.commit_files[0].display_path().to_string();
854                                self.load_single_file_diff(0, first_path);
855                            }
856                        }
857                        Err(e) => self.tab_mut().error_message = Some(format!("file list: {e}")),
858                    }
859                }
860                BackgroundResult::SingleFileDiffLoaded(res) => {
861                    self.tab_mut().is_loading = false;
862                    match res {
863                        Ok((file_index, diff)) => {
864                            let tab = self.tab_mut();
865                            tab.commit_diffs.insert(file_index, diff.clone());
866                            // Update selected_diff only when this is the currently focused file
867                            // and we are NOT showing a multi-file concatenated view.
868                            let is_multi = tab.selected_file_indices.len() > 1;
869                            if !is_multi && file_index == tab.commit_diff_file_index {
870                                tab.selected_diff = Some(diff);
871                                tab.diff_scroll = 0;
872                            }
873                            if tab.commit_files.len() > 1 {
874                                let sel_count = tab.selected_file_indices.len();
875                                if sel_count > 1 {
876                                    tab.status_message = Some(format!(
877                                        "{sel_count} files selected — use Shift+↑/↓ to adjust"
878                                    ));
879                                } else {
880                                    tab.status_message = Some(format!(
881                                        "File {}/{} — use h/l or ↑/↓ to switch",
882                                        file_index + 1,
883                                        tab.commit_files.len()
884                                    ));
885                                }
886                            }
887                        }
888                        Err(e) => self.tab_mut().error_message = Some(format!("file diff: {e}")),
889                    }
890                }
891                BackgroundResult::StagingRefreshed(res) => {
892                    self.tab_mut().is_loading = false;
893                    match res {
894                        Ok(payload) => self.apply_staging_payload(payload),
895                        Err(e) => {
896                            self.tab_mut().error_message = Some(format!("staging refresh: {e}"))
897                        }
898                    }
899                }
900                BackgroundResult::OperationDone {
901                    ok_message,
902                    err_message,
903                    needs_refresh,
904                    needs_staging_refresh,
905                } => {
906                    self.tab_mut().is_loading = false;
907                    if let Some(msg) = err_message {
908                        self.tab_mut().error_message = Some(msg);
909                    } else if let Some(msg) = ok_message {
910                        self.tab_mut().status_message = Some(msg);
911                    }
912                    if needs_refresh {
913                        self.refresh();
914                    } else if needs_staging_refresh {
915                        self.refresh_staging();
916                    }
917                }
918                BackgroundResult::SearchResults(res) => match res {
919                    Ok(results) => {
920                        self.tab_mut().search_results = results;
921                        let count = self.tab().search_results.len();
922                        self.tab_mut().status_message = Some(format!("{count} result(s) found"));
923                    }
924                    Err(e) => {
925                        self.tab_mut().error_message = Some(format!("Search failed: {e}"));
926                    }
927                },
928                BackgroundResult::CommitRangeDiffLoaded(res) => {
929                    self.tab_mut().is_loading = false;
930                    match res {
931                        Ok(diffs) => {
932                            let count = self.tab().selected_commits.len();
933                            let tab = self.tab_mut();
934                            tab.commit_range_diffs = diffs;
935                            tab.diff_scroll = 0;
936                            tab.status_message = Some(format!(
937                                "Combined diff for {count} commits — use j/k to scroll"
938                            ));
939                        }
940                        Err(e) => {
941                            self.tab_mut().error_message = Some(format!("Range diff: {e}"));
942                        }
943                    }
944                }
945
946                BackgroundResult::FileHistoryLoaded { path, commits } => {
947                    let count = commits.len();
948                    let file_name = path.rsplit('/').next().unwrap_or(&path).to_string();
949                    let tab = self.tab_mut();
950                    tab.file_history_path = Some(path);
951                    tab.file_history_commits = commits;
952                    tab.file_history_cursor = 0;
953                    tab.status_message = Some(format!("History: {file_name} ({count} commits)"));
954                }
955
956                BackgroundResult::FileBlameLoaded { path, lines } => {
957                    let count = lines.len();
958                    let file_name = path.rsplit('/').next().unwrap_or(&path).to_string();
959                    let tab = self.tab_mut();
960                    tab.blame_path = Some(path);
961                    tab.blame_lines = lines;
962                    tab.blame_scroll = 0;
963                    tab.status_message = Some(format!("Blame: {file_name} ({count} lines)"));
964                }
965
966                BackgroundResult::GitStateChanged => {
967                    // 2-second cooldown after any completed refresh so that a
968                    // branch checkout (which writes .git/HEAD) doesn't spawn a
969                    // second refresh immediately after the OperationDone one.
970                    const WATCHER_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(2);
971                    if !self.tab().is_loading
972                        && self.last_refresh_completed.elapsed() >= WATCHER_COOLDOWN
973                    {
974                        self.refresh_silent();
975                    }
976                }
977            }
978        }
979    }
980
981    // maybe_auto_refresh removed: the notify-based git watcher in lib.rs fires
982    // reactively on every .git change (< 1 s) and uses a 60-second fallback
983    // for network filesystems.  A second 5-second polling loop was causing an
984    // endless refresh cycle after branch checkouts and other git operations.
985
986    pub fn refresh_staging(&mut self) {
987        let repo_path = match self.tab().repo_path.clone() {
988            Some(p) => p,
989            None => {
990                self.tab_mut().error_message = Some("No repository open".into());
991                return;
992            }
993        };
994        let tx = self.bg_tx.clone();
995        std::thread::spawn(move || {
996            let res = (|| {
997                let repo = open_repo_str(&repo_path)?;
998                let unstaged = gitkraft_core::features::diff::get_working_dir_diff(&repo)
999                    .map_err(|e| e.to_string())?;
1000                let staged = gitkraft_core::features::diff::get_staged_diff(&repo)
1001                    .map_err(|e| e.to_string())?;
1002                Ok::<_, String>(StagingPayload { unstaged, staged })
1003            })();
1004            let _ = tx.send(BackgroundResult::StagingRefreshed(res));
1005        });
1006    }
1007
1008    fn apply_staging_payload(&mut self, payload: StagingPayload) {
1009        self.tab_mut().selected_unstaged.clear();
1010        self.tab_mut().selected_staged.clear();
1011        let tab = self.tab_mut();
1012        tab.unstaged_changes = payload.unstaged;
1013        if tab.unstaged_changes.is_empty() {
1014            tab.unstaged_list_state.select(None);
1015        } else if tab.unstaged_list_state.selected().is_none() {
1016            tab.unstaged_list_state.select(Some(0));
1017        } else if let Some(i) = tab.unstaged_list_state.selected() {
1018            if i >= tab.unstaged_changes.len() {
1019                tab.unstaged_list_state
1020                    .select(Some(tab.unstaged_changes.len() - 1));
1021            }
1022        }
1023
1024        tab.staged_changes = payload.staged;
1025        if tab.staged_changes.is_empty() {
1026            tab.staged_list_state.select(None);
1027        } else if tab.staged_list_state.selected().is_none() {
1028            tab.staged_list_state.select(Some(0));
1029        } else if let Some(i) = tab.staged_list_state.selected() {
1030            if i >= tab.staged_changes.len() {
1031                tab.staged_list_state
1032                    .select(Some(tab.staged_changes.len() - 1));
1033            }
1034        }
1035    }
1036
1037    // ── Staging operations ───────────────────────────────────────────────
1038
1039    pub fn stage_selected(&mut self) {
1040        let idx = require_selection!(self, unstaged_list_state, "No unstaged file selected");
1041        let file_path = self.unstaged_file_path(idx);
1042        bg_op!(self, "Staging…", staging, |repo_path| {
1043            let repo = open_repo_str(&repo_path)?;
1044            gitkraft_core::features::staging::stage_file(&repo, &file_path)
1045                .map_err(|e| format!("stage: {e}"))?;
1046            Ok(format!("Staged: {file_path}"))
1047        });
1048    }
1049
1050    pub fn unstage_selected(&mut self) {
1051        let idx = require_selection!(self, staged_list_state, "No staged file selected");
1052        let file_path = self.staged_file_path(idx);
1053        bg_op!(self, "Unstaging…", staging, |repo_path| {
1054            let repo = open_repo_str(&repo_path)?;
1055            gitkraft_core::features::staging::unstage_file(&repo, &file_path)
1056                .map_err(|e| format!("unstage: {e}"))?;
1057            Ok(format!("Unstaged: {file_path}"))
1058        });
1059    }
1060
1061    pub fn stage_all(&mut self) {
1062        bg_op!(self, "Staging all…", staging, |repo_path| {
1063            let repo = open_repo_str(&repo_path)?;
1064            gitkraft_core::features::staging::stage_all(&repo)
1065                .map_err(|e| format!("stage all: {e}"))?;
1066            Ok("Staged all files".into())
1067        });
1068    }
1069
1070    pub fn unstage_all(&mut self) {
1071        bg_op!(self, "Unstaging all…", staging, |repo_path| {
1072            let repo = open_repo_str(&repo_path)?;
1073            gitkraft_core::features::staging::unstage_all(&repo)
1074                .map_err(|e| format!("unstage all: {e}"))?;
1075            Ok("Unstaged all files".into())
1076        });
1077    }
1078
1079    pub fn discard_selected(&mut self) {
1080        let idx = require_selection!(self, unstaged_list_state, "No unstaged file selected");
1081        let file_path = self.unstaged_file_path(idx);
1082        self.tab_mut().confirm_discard = false;
1083        bg_op!(self, "Discarding…", staging, |repo_path| {
1084            let repo = open_repo_str(&repo_path)?;
1085            gitkraft_core::features::staging::discard_file_changes(&repo, &file_path)
1086                .map_err(|e| format!("discard: {e}"))?;
1087            Ok(format!("Discarded changes: {file_path}"))
1088        });
1089    }
1090
1091    /// Stage multiple files at once.
1092    pub fn stage_files(&mut self, paths: Vec<String>) {
1093        let count = paths.len();
1094        bg_op!(
1095            self,
1096            format!("Staging {count} file(s)…"),
1097            staging,
1098            |repo_path| {
1099                let repo = open_repo_str(&repo_path)?;
1100                for fp in &paths {
1101                    gitkraft_core::features::staging::stage_file(&repo, fp)
1102                        .map_err(|e| e.to_string())?;
1103                }
1104                Ok(format!("{count} file(s) staged"))
1105            }
1106        );
1107    }
1108
1109    /// Unstage multiple files at once.
1110    pub fn unstage_files(&mut self, paths: Vec<String>) {
1111        let count = paths.len();
1112        bg_op!(
1113            self,
1114            format!("Unstaging {count} file(s)…"),
1115            staging,
1116            |repo_path| {
1117                let repo = open_repo_str(&repo_path)?;
1118                for fp in &paths {
1119                    gitkraft_core::features::staging::unstage_file(&repo, fp)
1120                        .map_err(|e| e.to_string())?;
1121                }
1122                Ok(format!("{count} file(s) unstaged"))
1123            }
1124        );
1125    }
1126
1127    /// Discard changes for multiple files at once.
1128    pub fn discard_files(&mut self, paths: Vec<String>) {
1129        let count = paths.len();
1130        bg_op!(
1131            self,
1132            format!("Discarding {count} file(s)…"),
1133            staging,
1134            |repo_path| {
1135                let repo = open_repo_str(&repo_path)?;
1136                for fp in &paths {
1137                    gitkraft_core::features::staging::discard_file_changes(&repo, fp)
1138                        .map_err(|e| e.to_string())?;
1139                }
1140                Ok(format!("{count} file(s) discarded"))
1141            }
1142        );
1143    }
1144
1145    // ── Commit ───────────────────────────────────────────────────────────
1146
1147    pub fn create_commit(&mut self) {
1148        let msg = self.input_buffer.trim().to_string();
1149        if msg.is_empty() {
1150            self.tab_mut().error_message = Some("Commit message cannot be empty".into());
1151            return;
1152        }
1153        self.input_buffer.clear();
1154        bg_op!(self, "Committing…", refresh, |repo_path| {
1155            let repo = open_repo_str(&repo_path)?;
1156            let info = gitkraft_core::features::commits::create_commit(&repo, &msg)
1157                .map_err(|e| format!("commit: {e}"))?;
1158            Ok(format!("Committed: {} {}", info.short_oid, info.summary))
1159        });
1160    }
1161
1162    // ── Branches ─────────────────────────────────────────────────────────
1163
1164    pub fn checkout_selected_branch(&mut self) {
1165        let idx = selected_idx!(self, branch_list_state, branches, "No branch selected");
1166        let name = self.tab().branches[idx].name.clone();
1167        if self.tab().branches[idx].is_head {
1168            self.tab_mut().status_message = Some(format!("Already on '{name}'"));
1169            return;
1170        }
1171        bg_op!(self, "Checking out…", refresh, |repo_path| {
1172            let repo = open_repo_str(&repo_path)?;
1173            gitkraft_core::features::branches::checkout_branch(&repo, &name)
1174                .map_err(|e| format!("checkout: {e}"))?;
1175            Ok(format!("Checked out: {name}"))
1176        });
1177    }
1178
1179    pub fn create_branch(&mut self) {
1180        let name = self.input_buffer.trim().to_string();
1181        if name.is_empty() {
1182            self.tab_mut().error_message = Some("Branch name cannot be empty".into());
1183            return;
1184        }
1185        self.input_buffer.clear();
1186        bg_op!(self, "Creating branch…", refresh, |repo_path| {
1187            let repo = open_repo_str(&repo_path)?;
1188            gitkraft_core::features::branches::create_branch(&repo, &name)
1189                .map_err(|e| format!("create branch: {e}"))?;
1190            Ok(format!("Created branch: {name}"))
1191        });
1192    }
1193
1194    pub fn delete_selected_branch(&mut self) {
1195        let idx = selected_idx!(self, branch_list_state, branches, "No branch selected");
1196        if self.tab().branches[idx].is_head {
1197            self.tab_mut().error_message = Some("Cannot delete the current branch".into());
1198            return;
1199        }
1200        let name = self.tab().branches[idx].name.clone();
1201        bg_op!(self, "Deleting branch…", refresh, |repo_path| {
1202            let repo = open_repo_str(&repo_path)?;
1203            gitkraft_core::features::branches::delete_branch(&repo, &name)
1204                .map_err(|e| format!("delete branch: {e}"))?;
1205            Ok(format!("Deleted branch: {name}"))
1206        });
1207    }
1208
1209    // ── Stash ────────────────────────────────────────────────────────────
1210
1211    pub fn stash_save(&mut self) {
1212        let msg = if self.tab().stash_message_buffer.trim().is_empty() {
1213            None
1214        } else {
1215            Some(self.tab().stash_message_buffer.trim().to_string())
1216        };
1217        self.tab_mut().stash_message_buffer.clear();
1218        bg_op!(self, "Stashing…", refresh, |repo_path| {
1219            let mut repo = open_repo_str(&repo_path)?;
1220            let entry = gitkraft_core::features::stash::stash_save(&mut repo, msg.as_deref())
1221                .map_err(|e| format!("stash save: {e}"))?;
1222            Ok(format!("Stashed: {}", entry.message))
1223        });
1224    }
1225
1226    pub fn stash_pop_selected(&mut self) {
1227        let idx = selected_idx!(self, stash_list_state, stashes, "No stash selected");
1228        bg_op!(self, "Popping stash…", refresh, |repo_path| {
1229            let mut repo = open_repo_str(&repo_path)?;
1230            gitkraft_core::features::stash::stash_pop(&mut repo, idx)
1231                .map_err(|e| format!("stash pop: {e}"))?;
1232            Ok(format!("Stash @{{{idx}}} popped"))
1233        });
1234    }
1235
1236    pub fn stash_drop_selected(&mut self) {
1237        let idx = selected_idx!(self, stash_list_state, stashes, "No stash to drop");
1238        bg_op!(self, "Dropping stash…", refresh, |repo_path| {
1239            let mut repo = open_repo_str(&repo_path)?;
1240            gitkraft_core::features::stash::stash_drop(&mut repo, idx)
1241                .map_err(|e| format!("stash drop: {e}"))?;
1242            Ok(format!("Stash @{{{idx}}} dropped"))
1243        });
1244    }
1245
1246    // ── Diff ─────────────────────────────────────────────────────────────
1247
1248    /// Load the file list for the currently selected commit (phase 1 of two-phase loading).
1249    pub fn load_commit_diff(&mut self) {
1250        let idx = match self.tab().commit_list_state.selected() {
1251            Some(i) => i,
1252            None => return,
1253        };
1254        if idx >= self.tab().commits.len() {
1255            return;
1256        }
1257        let oid = self.tab().commits[idx].oid.clone();
1258        self.tab_mut().selected_commit_oid = Some(oid.clone());
1259        bg_task!(
1260            self,
1261            "Loading files…",
1262            BackgroundResult::CommitFileListLoaded,
1263            |repo_path| {
1264                let repo = open_repo_str(&repo_path)?;
1265                gitkraft_core::features::diff::get_commit_file_list(&repo, &oid)
1266                    .map_err(|e| e.to_string())
1267            }
1268        );
1269    }
1270
1271    /// Load the diff for a single file in the selected commit (phase 2).
1272    pub fn load_single_file_diff(&mut self, file_index: usize, file_path: String) {
1273        let oid = match self.tab().selected_commit_oid.clone() {
1274            Some(o) => o,
1275            None => return,
1276        };
1277        bg_task!(
1278            self,
1279            "Loading diff…",
1280            BackgroundResult::SingleFileDiffLoaded,
1281            |repo_path| {
1282                let repo = open_repo_str(&repo_path)?;
1283                let diff =
1284                    gitkraft_core::features::diff::get_single_file_diff(&repo, &oid, &file_path)
1285                        .map_err(|e| e.to_string())?;
1286                Ok((file_index, diff))
1287            }
1288        );
1289    }
1290
1291    /// Load the diff for a specific file index, skipping if it is already cached.
1292    pub fn load_diff_for_file_index(&mut self, file_index: usize) {
1293        if file_index >= self.tab().commit_files.len() {
1294            return;
1295        }
1296        if self.tab().commit_diffs.contains_key(&file_index) {
1297            return;
1298        }
1299        let file_path = self.tab().commit_files[file_index]
1300            .display_path()
1301            .to_string();
1302        self.load_single_file_diff(file_index, file_path);
1303    }
1304
1305    /// Switch to the next file in the commit diff list.
1306    pub fn next_diff_file(&mut self) {
1307        if self.tab().commit_files.is_empty() {
1308            return;
1309        }
1310        let new_index = (self.tab().commit_diff_file_index + 1) % self.tab().commit_files.len();
1311        self.tab_mut().anchor_file_index = Some(new_index);
1312        self.tab_mut().commit_diff_file_index = new_index;
1313        self.tab_mut().selected_file_indices.clear();
1314        self.tab_mut().selected_file_indices.insert(new_index);
1315        self.tab_mut().diff_scroll = 0;
1316        self.tab_mut().status_message = Some(format!(
1317            "File {}/{}",
1318            new_index + 1,
1319            self.tab().commit_files.len()
1320        ));
1321        if let Some(cached) = self.tab().commit_diffs.get(&new_index).cloned() {
1322            self.tab_mut().selected_diff = Some(cached);
1323        } else {
1324            let file_path = self.tab().commit_files[new_index]
1325                .display_path()
1326                .to_string();
1327            self.load_single_file_diff(new_index, file_path);
1328        }
1329    }
1330
1331    /// Switch to the previous file in the commit diff list.
1332    pub fn prev_diff_file(&mut self) {
1333        if self.tab().commit_files.is_empty() {
1334            return;
1335        }
1336        let new_index = if self.tab().commit_diff_file_index == 0 {
1337            self.tab().commit_files.len() - 1
1338        } else {
1339            self.tab().commit_diff_file_index - 1
1340        };
1341        self.tab_mut().anchor_file_index = Some(new_index);
1342        self.tab_mut().commit_diff_file_index = new_index;
1343        self.tab_mut().selected_file_indices.clear();
1344        self.tab_mut().selected_file_indices.insert(new_index);
1345        self.tab_mut().diff_scroll = 0;
1346        self.tab_mut().status_message = Some(format!(
1347            "File {}/{}",
1348            new_index + 1,
1349            self.tab().commit_files.len()
1350        ));
1351        if let Some(cached) = self.tab().commit_diffs.get(&new_index).cloned() {
1352            self.tab_mut().selected_diff = Some(cached);
1353        } else {
1354            let file_path = self.tab().commit_files[new_index]
1355                .display_path()
1356                .to_string();
1357            self.load_single_file_diff(new_index, file_path);
1358        }
1359    }
1360
1361    /// Close the current repository and return to the welcome screen.
1362    /// Search commits by query string.
1363    pub fn search_commits(&mut self, query: String) {
1364        let repo_path = match self.tab().repo_path.clone() {
1365            Some(p) => p,
1366            None => return,
1367        };
1368        self.tab_mut().search_query = query.clone();
1369        if query.trim().len() < 2 {
1370            self.tab_mut().search_results.clear();
1371            return;
1372        }
1373        let tx = self.bg_tx.clone();
1374        std::thread::spawn(move || {
1375            let res = (|| {
1376                let repo = open_repo_str(&repo_path)?;
1377                gitkraft_core::features::log::search_commits(&repo, &query, 100)
1378                    .map_err(|e| e.to_string())
1379            })();
1380            let _ = tx.send(BackgroundResult::SearchResults(res));
1381        });
1382    }
1383
1384    /// Load the diff for a commit by its OID (used by search results).
1385    pub fn load_commit_diff_by_oid(&mut self) {
1386        let oid = match self.tab().selected_commit_oid.clone() {
1387            Some(o) => o,
1388            None => return,
1389        };
1390        bg_task!(
1391            self,
1392            "Loading files…",
1393            BackgroundResult::CommitFileListLoaded,
1394            |repo_path| {
1395                let repo = open_repo_str(&repo_path)?;
1396                gitkraft_core::features::diff::get_commit_file_list(&repo, &oid)
1397                    .map_err(|e| e.to_string())
1398            }
1399        );
1400    }
1401
1402    /// Load the combined diff for the currently selected commit range.
1403    pub fn load_commit_range_diff(&mut self) {
1404        let selected = self.tab().selected_commits.clone();
1405        if selected.len() < 2 {
1406            return;
1407        }
1408        // selected is ascending; highest index = oldest commit (commits are newest-first)
1409        let oldest_idx = *selected.last().unwrap();
1410        let newest_idx = selected[0];
1411
1412        let oldest_oid = match self.tab().commits.get(oldest_idx).map(|c| c.oid.clone()) {
1413            Some(o) => o,
1414            None => return,
1415        };
1416        let newest_oid = match self.tab().commits.get(newest_idx).map(|c| c.oid.clone()) {
1417            Some(o) => o,
1418            None => return,
1419        };
1420
1421        bg_task!(
1422            self,
1423            "Loading range diff…",
1424            BackgroundResult::CommitRangeDiffLoaded,
1425            |repo_path| {
1426                let repo = open_repo_str(&repo_path)?;
1427                gitkraft_core::features::diff::get_commit_range_diff(
1428                    &repo,
1429                    &oldest_oid,
1430                    &newest_oid,
1431                )
1432                .map_err(|e| e.to_string())
1433            }
1434        );
1435    }
1436
1437    pub fn close_repo(&mut self) {
1438        self.tabs[self.active_tab_index] = RepoTab::new();
1439        self.input_buffer.clear();
1440        self.show_theme_panel = false;
1441        self.show_options_panel = false;
1442        self.screen = AppScreen::Welcome;
1443        // Reload recent repos
1444        if let Ok(settings) = gitkraft_core::features::persistence::load_tui_settings() {
1445            self.recent_repos = settings.recent_repos;
1446        }
1447        self.save_session();
1448    }
1449
1450    /// Populate `browser_entries` with the contents of `browser_dir`.
1451    pub fn refresh_browser(&mut self) {
1452        let mut entries = Vec::new();
1453        if let Ok(read_dir) = std::fs::read_dir(&self.browser_dir) {
1454            for entry in read_dir.flatten() {
1455                let path = entry.path();
1456                // Show only directories to help navigate & identify repos
1457                if path.is_dir() {
1458                    entries.push(path);
1459                }
1460            }
1461        }
1462        entries.sort_by(|a, b| {
1463            let a_name = a
1464                .file_name()
1465                .unwrap_or_default()
1466                .to_string_lossy()
1467                .to_lowercase();
1468            let b_name = b
1469                .file_name()
1470                .unwrap_or_default()
1471                .to_string_lossy()
1472                .to_lowercase();
1473            // Dot-dirs last
1474            let a_dot = a_name.starts_with('.');
1475            let b_dot = b_name.starts_with('.');
1476            a_dot.cmp(&b_dot).then(a_name.cmp(&b_name))
1477        });
1478        self.browser_entries = entries;
1479        self.browser_list_state = ListState::default();
1480        if !self.browser_entries.is_empty() {
1481            self.browser_list_state.select(Some(0));
1482        }
1483    }
1484
1485    /// Open the directory browser starting from a given path.
1486    pub fn open_browser(&mut self, start: PathBuf) {
1487        self.browser_return_screen = self.screen.clone();
1488        self.browser_dir = start;
1489        self.refresh_browser();
1490        self.screen = AppScreen::DirBrowser;
1491    }
1492    /// Open the TUI settings file (`tui-settings.json`) in the configured editor.
1493    /// If no editor is configured, shows the file path in the status bar instead.
1494    pub fn open_settings_in_editor(&mut self) {
1495        let path = match gitkraft_core::features::persistence::ops::tui_settings_json_path() {
1496            Ok(p) => p,
1497            Err(e) => {
1498                self.tab_mut().error_message = Some(format!("Cannot determine settings path: {e}"));
1499                return;
1500            }
1501        };
1502
1503        // Ensure the file exists so the editor can open it immediately.
1504        if !path.exists() {
1505            let snap =
1506                gitkraft_core::features::persistence::load_tui_settings().unwrap_or_default();
1507            let _ = gitkraft_core::features::persistence::save_tui_settings(&snap);
1508        }
1509
1510        let path_str = path.display().to_string();
1511
1512        if self.editor.is_terminal_editor() {
1513            // Terminal editors (Helix, Neovim, Vim, …) need a real TTY.
1514            // Signal the event loop in lib.rs to suspend the TUI, run the
1515            // editor synchronously, then resume.
1516            self.tab_mut().status_message =
1517                Some(format!("Opening settings in {} — {path_str}", self.editor));
1518            self.pending_editor_open = Some(vec![path]);
1519        } else if !matches!(self.editor, gitkraft_core::Editor::None) {
1520            // GUI editor (VS Code, Zed, …): open in background.
1521            // We do NOT fall back to xdg-open / open because JSON files may
1522            // be associated with a browser on many systems.
1523            match self.editor.open_file(&path) {
1524                Ok(()) => {
1525                    self.tab_mut().status_message =
1526                        Some(format!("Settings opened in {} — {path_str}", self.editor));
1527                }
1528                Err(e) => {
1529                    self.tab_mut().error_message =
1530                        Some(format!("Could not open settings ({e}) — path: {path_str}"));
1531                }
1532            }
1533        } else {
1534            // No editor configured — show the path so the user can open it
1535            // manually, and remind them how to configure an editor.
1536            self.tab_mut().status_message = Some(format!(
1537                "Settings: {path_str}  \
1538                 (no editor configured — press E to choose one, or set editor in GUI)"
1539            ));
1540        }
1541    }
1542
1543    /// Load the diff for a selected staging file into the diff pane.
1544    /// Open the currently selected staging file in the configured editor.
1545    pub fn open_selected_in_editor(&mut self) {
1546        if matches!(self.editor, gitkraft_core::Editor::None) {
1547            self.tab_mut().status_message =
1548                Some("No editor configured — press E to choose one".into());
1549            return;
1550        }
1551        let file_path = match self.tab().staging_focus {
1552            StagingFocus::Unstaged => self
1553                .tab()
1554                .unstaged_list_state
1555                .selected()
1556                .and_then(|idx| self.tab().unstaged_changes.get(idx))
1557                .map(|d| d.display_path().to_string()),
1558            StagingFocus::Staged => self
1559                .tab()
1560                .staged_list_state
1561                .selected()
1562                .and_then(|idx| self.tab().staged_changes.get(idx))
1563                .map(|d| d.display_path().to_string()),
1564        };
1565        if let (Some(fp), Some(repo_path)) = (file_path, self.tab().repo_path.as_ref()) {
1566            let full_path = repo_path.join(&fp);
1567            if self.editor.is_terminal_editor() {
1568                // Signal the event loop to suspend the TUI, run the editor
1569                // synchronously with a real TTY, then resume.
1570                self.tab_mut().status_message = Some(format!(
1571                    "Opening {} in {} — suspending TUI",
1572                    fp, self.editor
1573                ));
1574                self.pending_editor_open = Some(vec![full_path]);
1575            } else {
1576                match self.editor.open_file_or_default(&full_path) {
1577                    Ok(method) => {
1578                        self.tab_mut().status_message =
1579                            Some(format!("Opened {} in {}", fp, method));
1580                    }
1581                    Err(e) => {
1582                        self.tab_mut().error_message = Some(format!("Failed to open editor: {e}"));
1583                    }
1584                }
1585            }
1586        }
1587    }
1588
1589    /// Open files from the commit diff file list in the configured editor.
1590    ///
1591    /// If `selected_file_indices` contains 2+ items, opens all of them.
1592    /// Otherwise opens just the currently focused file (`commit_diff_file_index`).
1593    pub fn open_commit_files_in_editor(&mut self) {
1594        let repo_path = match self.tab().repo_path.clone() {
1595            Some(p) => p,
1596            None => {
1597                self.tab_mut().status_message = Some("No repository open".into());
1598                return;
1599            }
1600        };
1601
1602        // Collect files to open: multi-selection takes priority over single cursor.
1603        let indices: Vec<usize> = if self.tab().selected_file_indices.len() > 1 {
1604            let mut v: Vec<usize> = self.tab().selected_file_indices.iter().copied().collect();
1605            v.sort_unstable();
1606            v
1607        } else {
1608            vec![self.tab().commit_diff_file_index]
1609        };
1610
1611        let paths: Vec<std::path::PathBuf> = indices
1612            .iter()
1613            .filter_map(|&i| {
1614                self.tab()
1615                    .commit_files
1616                    .get(i)
1617                    .map(|f| repo_path.join(f.display_path()))
1618            })
1619            .collect();
1620
1621        if paths.is_empty() {
1622            return;
1623        }
1624
1625        let path_strs: Vec<String> = paths.iter().map(|p| p.display().to_string()).collect();
1626        let summary = if path_strs.len() == 1 {
1627            path_strs[0].clone()
1628        } else {
1629            format!("{} files", path_strs.len())
1630        };
1631
1632        if self.editor.is_terminal_editor() {
1633            self.tab_mut().status_message = Some(format!(
1634                "Opening {} in {} — suspending TUI",
1635                summary, self.editor
1636            ));
1637            self.pending_editor_open = Some(paths);
1638        } else if !matches!(self.editor, gitkraft_core::Editor::None) {
1639            // For GUI editors: open each file individually (they handle multiple windows).
1640            let mut last_error: Option<String> = None;
1641            for path in &paths {
1642                if let Err(e) = self.editor.open_file(path) {
1643                    last_error = Some(format!("{e}"));
1644                }
1645            }
1646            if let Some(e) = last_error {
1647                self.tab_mut().error_message = Some(format!("Failed to open in editor: {e}"));
1648            } else {
1649                self.tab_mut().status_message =
1650                    Some(format!("Opened {} in {}", summary, self.editor));
1651            }
1652        } else {
1653            self.tab_mut().status_message = Some(format!(
1654                "Files: {}  (no editor configured — press E to choose one)",
1655                path_strs.join(", ")
1656            ));
1657        }
1658    }
1659
1660    pub fn load_staging_diff(&mut self) {
1661        match self.tab().staging_focus {
1662            StagingFocus::Unstaged => {
1663                if let Some(idx) = self.tab().unstaged_list_state.selected() {
1664                    if idx < self.tab().unstaged_changes.len() {
1665                        let diff = self.tab().unstaged_changes[idx].clone();
1666                        let tab = self.tab_mut();
1667                        tab.selected_diff = Some(diff);
1668                        tab.diff_scroll = 0;
1669                    }
1670                }
1671            }
1672            StagingFocus::Staged => {
1673                if let Some(idx) = self.tab().staged_list_state.selected() {
1674                    if idx < self.tab().staged_changes.len() {
1675                        let diff = self.tab().staged_changes[idx].clone();
1676                        let tab = self.tab_mut();
1677                        tab.selected_diff = Some(diff);
1678                        tab.diff_scroll = 0;
1679                    }
1680                }
1681            }
1682        }
1683    }
1684
1685    // ── Remote ───────────────────────────────────────────────────────────
1686
1687    pub fn fetch_remote(&mut self) {
1688        let repo_path = match self.tab().repo_path.clone() {
1689            Some(p) => p,
1690            None => return,
1691        };
1692        self.tab_mut().is_loading = true;
1693        self.tab_mut().status_message = Some("Fetching…".into());
1694        let tx = self.bg_tx.clone();
1695        std::thread::spawn(move || {
1696            let res = (|| {
1697                let repo = open_repo_str(&repo_path)?;
1698                gitkraft_core::features::remotes::fetch_remote(&repo, "origin")
1699                    .map_err(|e| e.to_string())
1700            })();
1701            let _ = tx.send(BackgroundResult::FetchDone(res));
1702        });
1703    }
1704
1705    pub fn pull_rebase(&mut self) {
1706        let repo_path = match self.tab().repo_path.clone() {
1707            Some(p) => p,
1708            None => return,
1709        };
1710        self.tab_mut().is_loading = true;
1711        self.tab_mut().status_message = Some("Pulling (rebase)…".into());
1712        let tx = self.bg_tx.clone();
1713        std::thread::spawn(move || {
1714            let workdir = std::path::Path::new(&repo_path);
1715            let res = gitkraft_core::features::branches::pull_rebase(workdir, "origin");
1716            let _ = tx.send(BackgroundResult::OperationDone {
1717                ok_message: res
1718                    .as_ref()
1719                    .ok()
1720                    .map(|_| "Pulled (rebase) from origin".into()),
1721                err_message: res.err().map(|e| format!("pull: {e}")),
1722                needs_refresh: true,
1723                needs_staging_refresh: false,
1724            });
1725        });
1726    }
1727
1728    pub fn push_branch(&mut self) {
1729        let repo_path = match self.tab().repo_path.clone() {
1730            Some(p) => p,
1731            None => return,
1732        };
1733        let branch = match self
1734            .tab()
1735            .repo_info
1736            .as_ref()
1737            .and_then(|i| i.head_branch.clone())
1738        {
1739            Some(b) => b,
1740            None => {
1741                self.tab_mut().error_message = Some("No branch checked out".into());
1742                return;
1743            }
1744        };
1745        self.tab_mut().is_loading = true;
1746        self.tab_mut().status_message = Some(format!("Pushing {branch}…"));
1747        let tx = self.bg_tx.clone();
1748        std::thread::spawn(move || {
1749            let workdir = std::path::Path::new(&repo_path);
1750            let res = gitkraft_core::features::branches::push_branch(workdir, &branch, "origin");
1751            let _ = tx.send(BackgroundResult::OperationDone {
1752                ok_message: res
1753                    .as_ref()
1754                    .ok()
1755                    .map(|_| format!("Pushed {branch} to origin")),
1756                err_message: res.err().map(|e| format!("push: {e}")),
1757                needs_refresh: true,
1758                needs_staging_refresh: false,
1759            });
1760        });
1761    }
1762
1763    pub fn force_push_branch(&mut self) {
1764        let repo_path = match self.tab().repo_path.clone() {
1765            Some(p) => p,
1766            None => return,
1767        };
1768        let branch = match self
1769            .tab()
1770            .repo_info
1771            .as_ref()
1772            .and_then(|i| i.head_branch.clone())
1773        {
1774            Some(b) => b,
1775            None => {
1776                self.tab_mut().error_message = Some("No branch checked out".into());
1777                return;
1778            }
1779        };
1780        self.tab_mut().is_loading = true;
1781        self.tab_mut().status_message = Some(format!("Force pushing {branch}…"));
1782        let tx = self.bg_tx.clone();
1783        std::thread::spawn(move || {
1784            let workdir = std::path::Path::new(&repo_path);
1785            let res =
1786                gitkraft_core::features::branches::force_push_branch(workdir, &branch, "origin");
1787            let _ = tx.send(BackgroundResult::OperationDone {
1788                ok_message: res
1789                    .as_ref()
1790                    .ok()
1791                    .map(|_| format!("Force pushed {branch} to origin")),
1792                err_message: res.err().map(|e| format!("force push: {e}")),
1793                needs_refresh: true,
1794                needs_staging_refresh: false,
1795            });
1796        });
1797    }
1798
1799    pub fn merge_selected_branch(&mut self) {
1800        let repo_path = match self.tab().repo_path.clone() {
1801            Some(p) => p,
1802            None => return,
1803        };
1804        let branch_name = match self.tab().branch_list_state.selected() {
1805            Some(idx) => match self.tab().branches.get(idx) {
1806                Some(b) => b.name.clone(),
1807                None => return,
1808            },
1809            None => return,
1810        };
1811        self.tab_mut().is_loading = true;
1812        self.tab_mut().status_message = Some(format!("Merging {branch_name}…"));
1813        let tx = self.bg_tx.clone();
1814        std::thread::spawn(move || {
1815            let res = (|| {
1816                let repo = open_repo_str(&repo_path)?;
1817                gitkraft_core::features::branches::merge_branch(&repo, &branch_name)
1818                    .map_err(|e| e.to_string())
1819            })();
1820            let _ = tx.send(BackgroundResult::OperationDone {
1821                ok_message: res.as_ref().ok().map(|_| format!("Merged {branch_name}")),
1822                err_message: res.err(),
1823                needs_refresh: true,
1824                needs_staging_refresh: false,
1825            });
1826        });
1827    }
1828
1829    pub fn rebase_onto_selected_branch(&mut self) {
1830        let repo_path = match self.tab().repo_path.clone() {
1831            Some(p) => p,
1832            None => return,
1833        };
1834        let branch_name = match self.tab().branch_list_state.selected() {
1835            Some(idx) => match self.tab().branches.get(idx) {
1836                Some(b) => b.name.clone(),
1837                None => return,
1838            },
1839            None => return,
1840        };
1841        self.tab_mut().is_loading = true;
1842        self.tab_mut().status_message = Some(format!("Rebasing onto {branch_name}…"));
1843        let tx = self.bg_tx.clone();
1844        std::thread::spawn(move || {
1845            let workdir = std::path::Path::new(&repo_path);
1846            let res = gitkraft_core::features::branches::rebase_onto(workdir, &branch_name);
1847            let _ = tx.send(BackgroundResult::OperationDone {
1848                ok_message: res
1849                    .as_ref()
1850                    .ok()
1851                    .map(|_| format!("Rebased onto {branch_name}")),
1852                err_message: res.err().map(|e| format!("rebase: {e}")),
1853                needs_refresh: true,
1854                needs_staging_refresh: false,
1855            });
1856        });
1857    }
1858
1859    /// Open the commit-action popup for the currently selected commit.
1860    pub fn open_commit_action_popup(&mut self) {
1861        let idx = match self.tab().commit_list_state.selected() {
1862            Some(i) => i,
1863            None => return,
1864        };
1865        let oid = match self.tab().commits.get(idx).map(|c| c.oid.clone()) {
1866            Some(o) => o,
1867            None => return,
1868        };
1869        // Build the flat item list from COMMIT_MENU_GROUPS
1870        let items: Vec<gitkraft_core::CommitActionKind> = gitkraft_core::COMMIT_MENU_GROUPS
1871            .iter()
1872            .flat_map(|g| g.iter().copied())
1873            .collect();
1874        let tab = self.tab_mut();
1875        tab.pending_commit_action_oid = Some(oid);
1876        tab.commit_action_items = items;
1877        tab.commit_action_cursor = 0;
1878    }
1879
1880    /// Execute a fully-built `CommitAction` on the pending OID in the background.
1881    pub fn execute_commit_action(&mut self, action: gitkraft_core::CommitAction) {
1882        let oid = match self.tab().pending_commit_action_oid.clone() {
1883            Some(o) => o,
1884            None => return,
1885        };
1886        let repo_path = match self.tab().repo_path.clone() {
1887            Some(p) => p,
1888            None => return,
1889        };
1890        let label = action.label().to_string();
1891        let short = oid[..oid.len().min(7)].to_string();
1892        self.tab_mut().is_loading = true;
1893        self.tab_mut().status_message = Some(format!("{label} {short}…"));
1894        self.tab_mut().pending_commit_action_oid = None;
1895        self.tab_mut().pending_action_kind = None;
1896        self.tab_mut().action_input1.clear();
1897        let tx = self.bg_tx.clone();
1898        std::thread::spawn(move || {
1899            let workdir = repo_path.as_path();
1900            let res = action.execute(workdir, &oid);
1901            let _ = tx.send(crate::app::BackgroundResult::OperationDone {
1902                ok_message: res.as_ref().ok().map(|_| format!("{label} complete")),
1903                err_message: res.err().map(|e| e.to_string()),
1904                needs_refresh: true,
1905                needs_staging_refresh: false,
1906            });
1907        });
1908    }
1909
1910    /// Open the file-history overlay for the given repo-relative path.
1911    pub fn open_file_history(&mut self, file_path: String) {
1912        let repo_path = match self.tab().repo_path.clone() {
1913            Some(p) => p,
1914            None => return,
1915        };
1916        let tab = self.tab_mut();
1917        tab.blame_path = None; // close blame if open
1918        tab.file_history_path = Some(file_path.clone());
1919        tab.file_history_commits.clear();
1920        tab.file_history_cursor = 0;
1921        tab.status_message = Some(format!(
1922            "Loading history for {}…",
1923            file_path.rsplit('/').next().unwrap_or(&file_path)
1924        ));
1925        let tx = self.bg_tx.clone();
1926        std::thread::spawn(move || {
1927            let repo = match gitkraft_core::features::repo::open_repo(&repo_path) {
1928                Ok(r) => r,
1929                Err(e) => {
1930                    let _ = tx.send(BackgroundResult::OperationDone {
1931                        ok_message: None,
1932                        err_message: Some(format!("file history: {e}")),
1933                        needs_refresh: false,
1934                        needs_staging_refresh: false,
1935                    });
1936                    return;
1937                }
1938            };
1939            match gitkraft_core::file_history(&repo, &file_path, 500) {
1940                Ok(commits) => {
1941                    let _ = tx.send(BackgroundResult::FileHistoryLoaded {
1942                        path: file_path,
1943                        commits,
1944                    });
1945                }
1946                Err(e) => {
1947                    let _ = tx.send(BackgroundResult::OperationDone {
1948                        ok_message: None,
1949                        err_message: Some(format!("file history: {e}")),
1950                        needs_refresh: false,
1951                        needs_staging_refresh: false,
1952                    });
1953                }
1954            }
1955        });
1956    }
1957
1958    /// Open the blame overlay for the given repo-relative path.
1959    pub fn open_file_blame(&mut self, file_path: String) {
1960        let repo_path = match self.tab().repo_path.clone() {
1961            Some(p) => p,
1962            None => return,
1963        };
1964        let tab = self.tab_mut();
1965        tab.file_history_path = None; // close history if open
1966        tab.blame_path = Some(file_path.clone());
1967        tab.blame_lines.clear();
1968        tab.blame_scroll = 0;
1969        tab.status_message = Some(format!(
1970            "Loading blame for {}…",
1971            file_path.rsplit('/').next().unwrap_or(&file_path)
1972        ));
1973        let tx = self.bg_tx.clone();
1974        std::thread::spawn(move || {
1975            let repo = match gitkraft_core::features::repo::open_repo(&repo_path) {
1976                Ok(r) => r,
1977                Err(e) => {
1978                    let _ = tx.send(BackgroundResult::OperationDone {
1979                        ok_message: None,
1980                        err_message: Some(format!("blame: {e}")),
1981                        needs_refresh: false,
1982                        needs_staging_refresh: false,
1983                    });
1984                    return;
1985                }
1986            };
1987            match gitkraft_core::blame_file(&repo, &file_path) {
1988                Ok(lines) => {
1989                    let _ = tx.send(BackgroundResult::FileBlameLoaded {
1990                        path: file_path,
1991                        lines,
1992                    });
1993                }
1994                Err(e) => {
1995                    let _ = tx.send(BackgroundResult::OperationDone {
1996                        ok_message: None,
1997                        err_message: Some(format!("blame: {e}")),
1998                        needs_refresh: false,
1999                        needs_staging_refresh: false,
2000                    });
2001                }
2002            }
2003        });
2004    }
2005
2006    /// Prompt to delete the given working-tree file (first keypress).
2007    pub fn prompt_delete_file(&mut self, file_path: String) {
2008        let file_name = file_path
2009            .rsplit('/')
2010            .next()
2011            .unwrap_or(&file_path)
2012            .to_string();
2013        self.tab_mut().confirm_delete_file = Some(file_path);
2014        self.tab_mut().status_message = Some(format!(
2015            "Delete '{file_name}'? Press 'd' again to confirm, any other key to cancel"
2016        ));
2017    }
2018
2019    /// Execute the pending file deletion (second keypress confirmation).
2020    pub fn confirm_delete_file(&mut self) {
2021        let path = match self.tab().confirm_delete_file.clone() {
2022            Some(p) => p,
2023            None => return,
2024        };
2025        let repo_path = match self.tab().repo_path.clone() {
2026            Some(p) => p,
2027            None => return,
2028        };
2029        self.tab_mut().confirm_delete_file = None;
2030        self.tab_mut().is_loading = true;
2031        let file_name = path.rsplit('/').next().unwrap_or(&path).to_string();
2032        self.tab_mut().status_message = Some(format!("Deleting '{file_name}'…"));
2033        let tx = self.bg_tx.clone();
2034        std::thread::spawn(move || {
2035            let res = gitkraft_core::delete_file(repo_path.as_path(), &path);
2036            let _ = tx.send(BackgroundResult::OperationDone {
2037                ok_message: res.as_ref().ok().map(|_| format!("Deleted '{file_name}'")),
2038                err_message: res.err().map(|e| e.to_string()),
2039                needs_refresh: true,
2040                needs_staging_refresh: false,
2041            });
2042        });
2043    }
2044
2045    pub fn revert_selected_commit(&mut self) {
2046        let repo_path = match self.tab().repo_path.clone() {
2047            Some(p) => p,
2048            None => return,
2049        };
2050        let oid = match self.tab().commit_list_state.selected() {
2051            Some(idx) => match self.tab().commits.get(idx) {
2052                Some(c) => c.oid.clone(),
2053                None => return,
2054            },
2055            None => return,
2056        };
2057        self.tab_mut().is_loading = true;
2058        self.tab_mut().status_message = Some("Reverting commit…".into());
2059        let tx = self.bg_tx.clone();
2060        std::thread::spawn(move || {
2061            let workdir = std::path::Path::new(&repo_path);
2062            let res = gitkraft_core::features::repo::revert_commit(workdir, &oid);
2063            let _ = tx.send(BackgroundResult::OperationDone {
2064                ok_message: res.as_ref().ok().map(|_| format!("Reverted {}", &oid[..7])),
2065                err_message: res.err().map(|e| format!("revert: {e}")),
2066                needs_refresh: true,
2067                needs_staging_refresh: false,
2068            });
2069        });
2070    }
2071
2072    /// Cherry-pick the selected commit(s) onto the current branch.
2073    ///
2074    /// If `selected_commits` has 2+ entries, cherry-picks all of them in
2075    /// ascending OID order (oldest first so the history is linear).
2076    /// Otherwise cherry-picks only the currently focused commit.
2077    pub fn cherry_pick_selected(&mut self) {
2078        let repo_path = match self.tab().repo_path.clone() {
2079            Some(p) => p,
2080            None => return,
2081        };
2082
2083        // Collect OIDs: multi-selection wins over single cursor.
2084        let oids: Vec<String> = if self.tab().selected_commits.len() > 1 {
2085            // selected_commits is in cursor order; sort descending so we apply
2086            // oldest-first (highest list-index = oldest commit).
2087            let mut sorted = self.tab().selected_commits.clone();
2088            sorted.sort_unstable_by(|a, b| b.cmp(a)); // reverse: highest index = oldest
2089            sorted
2090                .iter()
2091                .filter_map(|&i| self.tab().commits.get(i).map(|c| c.oid.clone()))
2092                .collect()
2093        } else {
2094            match self.tab().commit_list_state.selected() {
2095                Some(i) => self
2096                    .tab()
2097                    .commits
2098                    .get(i)
2099                    .map(|c| vec![c.oid.clone()])
2100                    .unwrap_or_default(),
2101                None => return,
2102            }
2103        };
2104
2105        if oids.is_empty() {
2106            return;
2107        }
2108
2109        let count = oids.len();
2110        let short = oids[0][..oids[0].len().min(7)].to_string();
2111        let label = if count == 1 {
2112            format!("Cherry-picking {short}…")
2113        } else {
2114            format!("Cherry-picking {count} commits…")
2115        };
2116
2117        let tab = self.tab_mut();
2118        tab.is_loading = true;
2119        tab.status_message = Some(label);
2120
2121        let tx = self.bg_tx.clone();
2122        std::thread::spawn(move || {
2123            let res: Result<String, String> = (|| {
2124                for oid in &oids {
2125                    gitkraft_core::features::repo::cherry_pick_commit(&repo_path, oid)
2126                        .map_err(|e| format!("cherry-pick {}: {e}", &oid[..oid.len().min(7)]))?;
2127                }
2128                Ok(format!("Cherry-picked {} commit(s)", oids.len()))
2129            })();
2130            let _ = tx.send(BackgroundResult::OperationDone {
2131                ok_message: res.as_ref().ok().cloned(),
2132                err_message: res.err(),
2133                needs_refresh: true,
2134                needs_staging_refresh: false,
2135            });
2136        });
2137    }
2138
2139    pub fn reset_to_selected_commit(&mut self, mode: &str) {
2140        let repo_path = match self.tab().repo_path.clone() {
2141            Some(p) => p,
2142            None => return,
2143        };
2144        let oid = match self.tab().commit_list_state.selected() {
2145            Some(idx) => match self.tab().commits.get(idx) {
2146                Some(c) => c.oid.clone(),
2147                None => return,
2148            },
2149            None => return,
2150        };
2151        let mode_owned = mode.to_string();
2152        self.tab_mut().is_loading = true;
2153        self.tab_mut().status_message = Some(format!("Resetting ({mode})…"));
2154        let tx = self.bg_tx.clone();
2155        std::thread::spawn(move || {
2156            let workdir = std::path::Path::new(&repo_path);
2157            let res = gitkraft_core::features::repo::reset_to_commit(workdir, &oid, &mode_owned);
2158            let _ = tx.send(BackgroundResult::OperationDone {
2159                ok_message: res
2160                    .as_ref()
2161                    .ok()
2162                    .map(|_| format!("Reset ({mode_owned}) to {}", &oid[..7])),
2163                err_message: res.err().map(|e| format!("reset: {e}")),
2164                needs_refresh: true,
2165                needs_staging_refresh: false,
2166            });
2167        });
2168    }
2169
2170    // ── Path helpers ─────────────────────────────────────────────────────
2171
2172    fn unstaged_file_path(&self, idx: usize) -> String {
2173        if idx >= self.tab().unstaged_changes.len() {
2174            return String::new();
2175        }
2176        self.tab().unstaged_changes[idx].display_path().to_owned()
2177    }
2178
2179    fn staged_file_path(&self, idx: usize) -> String {
2180        if idx >= self.tab().staged_changes.len() {
2181            return String::new();
2182        }
2183        self.tab().staged_changes[idx].display_path().to_owned()
2184    }
2185}
2186
2187// ── Free-standing helpers ─────────────────────────────────────────────────────
2188
2189/// Open a repository, mapping the error to a `String` for background-task results.
2190fn open_repo_str(path: &std::path::Path) -> Result<git2::Repository, String> {
2191    gitkraft_core::features::repo::open_repo(path).map_err(|e| e.to_string())
2192}
2193/// Map a persisted theme name back to its index (0–26).
2194fn theme_name_to_index(name: &str) -> usize {
2195    gitkraft_core::theme_index_by_name(name)
2196}
2197
2198/// Clamp a `ListState` selection to be within `[0, len)`, or `None` if empty.
2199fn clamp_list_state(state: &mut ListState, len: usize) {
2200    if len == 0 {
2201        state.select(None);
2202    } else if state.selected().is_none() {
2203        state.select(Some(0));
2204    } else if let Some(i) = state.selected() {
2205        if i >= len {
2206            state.select(Some(len - 1));
2207        }
2208    }
2209}
2210
2211/// Blocking helper that loads all repo data in one go.
2212/// Runs inside `spawn_blocking` — must not touch any async APIs.
2213fn load_repo_blocking(path: &std::path::Path) -> Result<RepoPayload, String> {
2214    gitkraft_core::load_repo_snapshot(path).map_err(|e| e.to_string())
2215}
2216
2217#[cfg(test)]
2218mod tests {
2219    use super::*;
2220
2221    #[test]
2222    fn new_app_defaults() {
2223        let app = App::new();
2224        assert!(!app.should_quit);
2225        assert_eq!(app.screen, AppScreen::Welcome);
2226        assert_eq!(app.input_mode, InputMode::Normal);
2227        assert!(app.tab().commits.is_empty());
2228        assert!(app.tab().branches.is_empty());
2229        assert!(app.tab().repo_path.is_none());
2230        assert_eq!(app.tabs.len(), 1);
2231        assert_eq!(app.active_tab_index, 0);
2232    }
2233
2234    #[test]
2235    fn cycle_theme_next_wraps() {
2236        let mut app = App::new();
2237        app.current_theme_index = 0;
2238        app.cycle_theme_next();
2239        assert_eq!(app.current_theme_index, 1);
2240        // Cycle to end
2241        for _ in 0..(gitkraft_core::THEME_COUNT - 1) {
2242            app.cycle_theme_next();
2243        }
2244        assert_eq!(app.current_theme_index, 0); // wrapped
2245    }
2246
2247    #[test]
2248    fn cycle_theme_prev_wraps() {
2249        let mut app = App::new();
2250        app.current_theme_index = 0;
2251        app.cycle_theme_prev();
2252        assert_eq!(app.current_theme_index, gitkraft_core::THEME_COUNT - 1); // wrapped to last
2253    }
2254
2255    #[test]
2256    fn theme_returns_struct() {
2257        let mut app = App::new();
2258        app.current_theme_index = 0;
2259        let theme = app.theme();
2260        // Default theme active border comes from the core accent (88, 166, 255)
2261        assert_eq!(
2262            format!("{:?}", theme.border_active),
2263            format!("{:?}", ratatui::style::Color::Rgb(88, 166, 255))
2264        );
2265    }
2266
2267    #[test]
2268    fn theme_name_to_index_known() {
2269        assert_eq!(theme_name_to_index("Default"), 0);
2270        assert_eq!(theme_name_to_index("Dracula"), 8);
2271        assert_eq!(theme_name_to_index("Nord"), 9);
2272    }
2273
2274    #[test]
2275    fn theme_name_to_index_unknown_returns_zero() {
2276        assert_eq!(theme_name_to_index("NonExistentTheme"), 0);
2277        assert_eq!(theme_name_to_index(""), 0);
2278    }
2279
2280    #[test]
2281    fn tab_management_new_tab() {
2282        let mut app = App::new();
2283        assert_eq!(app.tabs.len(), 1);
2284        assert_eq!(app.active_tab_index, 0);
2285
2286        app.new_tab();
2287        assert_eq!(app.tabs.len(), 2);
2288        assert_eq!(app.active_tab_index, 1);
2289
2290        app.new_tab();
2291        assert_eq!(app.tabs.len(), 3);
2292        assert_eq!(app.active_tab_index, 2);
2293    }
2294
2295    #[test]
2296    fn tab_management_close_tab() {
2297        let mut app = App::new();
2298        app.new_tab();
2299        app.new_tab();
2300        assert_eq!(app.tabs.len(), 3);
2301        assert_eq!(app.active_tab_index, 2);
2302
2303        app.close_tab();
2304        assert_eq!(app.tabs.len(), 2);
2305        assert_eq!(app.active_tab_index, 1);
2306
2307        app.close_tab();
2308        assert_eq!(app.tabs.len(), 1);
2309        assert_eq!(app.active_tab_index, 0);
2310
2311        // Close the only tab -- should reset rather than remove
2312        app.close_tab();
2313        assert_eq!(app.tabs.len(), 1);
2314        assert_eq!(app.active_tab_index, 0);
2315    }
2316
2317    #[test]
2318    fn tab_management_next_prev() {
2319        let mut app = App::new();
2320        app.new_tab();
2321        app.new_tab();
2322        // active_tab_index == 2
2323
2324        app.next_tab();
2325        assert_eq!(app.active_tab_index, 0); // wrapped
2326
2327        app.next_tab();
2328        assert_eq!(app.active_tab_index, 1);
2329
2330        app.prev_tab();
2331        assert_eq!(app.active_tab_index, 0);
2332
2333        app.prev_tab();
2334        assert_eq!(app.active_tab_index, 2); // wrapped
2335    }
2336
2337    #[test]
2338    fn repo_tab_display_name() {
2339        let tab = RepoTab::new();
2340        assert_eq!(tab.display_name(), "New Tab");
2341
2342        let mut tab2 = RepoTab::new();
2343        tab2.repo_path = Some(PathBuf::from("/home/user/projects/my-repo"));
2344        assert_eq!(tab2.display_name(), "my-repo");
2345    }
2346
2347    #[test]
2348    fn repo_tab_search_defaults() {
2349        let tab = RepoTab::new();
2350        assert!(!tab.search_active);
2351        assert!(tab.search_query.is_empty());
2352        assert!(tab.search_results.is_empty());
2353    }
2354
2355    #[test]
2356    fn repo_tab_new_has_empty_state() {
2357        let tab = RepoTab::new();
2358        assert!(tab.repo_path.is_none());
2359        assert!(tab.commits.is_empty());
2360        assert!(tab.branches.is_empty());
2361        assert!(tab.unstaged_changes.is_empty());
2362        assert!(tab.staged_changes.is_empty());
2363        assert!(tab.stashes.is_empty());
2364        assert!(tab.remotes.is_empty());
2365        assert!(tab.commit_files.is_empty());
2366        assert!(tab.selected_commit_oid.is_none());
2367        assert!(!tab.is_loading);
2368        assert!(!tab.confirm_discard);
2369        assert_eq!(tab.diff_scroll, 0);
2370        assert_eq!(tab.commit_diff_file_index, 0);
2371    }
2372
2373    #[test]
2374    fn new_tab_switches_to_welcome() {
2375        let mut app = App::new();
2376        app.screen = AppScreen::Main;
2377        app.new_tab();
2378        assert_eq!(app.screen, AppScreen::Welcome);
2379        assert_eq!(app.active_tab_index, 1);
2380    }
2381
2382    #[test]
2383    fn close_tab_last_tab_resets() {
2384        let mut app = App::new();
2385        // Set some state on the only tab
2386        app.tab_mut().search_active = true;
2387        app.tab_mut().search_query = "test".into();
2388
2389        app.close_tab();
2390
2391        // Should reset the tab, not remove it
2392        assert_eq!(app.tabs.len(), 1);
2393        assert!(!app.tab().search_active);
2394        assert!(app.tab().search_query.is_empty());
2395    }
2396
2397    #[test]
2398    fn close_tab_middle_adjusts_index() {
2399        let mut app = App::new();
2400        app.new_tab();
2401        app.new_tab();
2402        // 3 tabs, active = 2
2403
2404        app.active_tab_index = 1; // select middle tab
2405        app.close_tab();
2406
2407        assert_eq!(app.tabs.len(), 2);
2408        assert_eq!(app.active_tab_index, 1); // stays at 1 (now the last)
2409    }
2410
2411    #[test]
2412    fn next_tab_single_tab_no_change() {
2413        let mut app = App::new();
2414        app.next_tab();
2415        assert_eq!(app.active_tab_index, 0);
2416    }
2417
2418    #[test]
2419    fn prev_tab_single_tab_no_change() {
2420        let mut app = App::new();
2421        app.prev_tab();
2422        assert_eq!(app.active_tab_index, 0);
2423    }
2424
2425    #[test]
2426    fn open_browser_sets_dir_browser_screen() {
2427        let mut app = App::new();
2428        app.screen = AppScreen::Main;
2429        app.open_browser(PathBuf::from("/tmp"));
2430        assert_eq!(app.screen, AppScreen::DirBrowser);
2431        assert_eq!(app.browser_return_screen, AppScreen::Main);
2432    }
2433
2434    #[test]
2435    fn repo_tab_selected_defaults_empty() {
2436        let tab = RepoTab::new();
2437        assert!(tab.selected_unstaged.is_empty());
2438        assert!(tab.selected_staged.is_empty());
2439    }
2440
2441    #[test]
2442    fn repo_tab_selected_toggle() {
2443        let mut tab = RepoTab::new();
2444        tab.selected_unstaged.insert(0);
2445        tab.selected_unstaged.insert(2);
2446        assert_eq!(tab.selected_unstaged.len(), 2);
2447        assert!(tab.selected_unstaged.contains(&0));
2448        tab.selected_unstaged.remove(&0);
2449        assert_eq!(tab.selected_unstaged.len(), 1);
2450        assert!(!tab.selected_unstaged.contains(&0));
2451    }
2452
2453    #[test]
2454    fn auto_refresh_field_exists() {
2455        let app = App::new();
2456        assert!(app.last_auto_refresh.elapsed() < std::time::Duration::from_secs(1));
2457    }
2458
2459    #[test]
2460    fn editor_defaults_from_settings() {
2461        let app = App::new();
2462        // Should have loaded from settings or defaulted to None
2463        let _ = app.editor.display_name();
2464    }
2465
2466    #[test]
2467    fn pull_rebase_sets_loading() {
2468        let mut app = App::new();
2469        app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
2470        app.pull_rebase();
2471        assert!(app.tab().is_loading);
2472        assert_eq!(
2473            app.tab().status_message.as_deref(),
2474            Some("Pulling (rebase)…")
2475        );
2476    }
2477
2478    #[test]
2479    fn repo_tab_diff_sub_pane_defaults_to_file_list() {
2480        let tab = RepoTab::new();
2481        assert_eq!(tab.diff_sub_pane, DiffSubPane::FileList);
2482    }
2483
2484    #[test]
2485    fn repo_tab_selected_file_indices_defaults_empty() {
2486        let tab = RepoTab::new();
2487        assert!(tab.selected_file_indices.is_empty());
2488    }
2489
2490    #[test]
2491    fn repo_tab_commit_diffs_defaults_empty_hashmap() {
2492        let tab = RepoTab::new();
2493        assert!(tab.commit_diffs.is_empty());
2494    }
2495
2496    #[test]
2497    fn next_tab_restores_main_screen_for_tab_with_repo() {
2498        let mut app = App::new();
2499        // tab 0 gets a repo path
2500        app.tabs[0].repo_path = Some(PathBuf::from("/tmp/repo-a"));
2501        // create tab 1 (no repo) — new_tab sets screen = Welcome
2502        app.new_tab();
2503        assert_eq!(app.active_tab_index, 1);
2504        // switching forward wraps back to tab 0 which has a repo
2505        app.next_tab();
2506        assert_eq!(app.active_tab_index, 0);
2507        assert_eq!(app.screen, AppScreen::Main);
2508    }
2509
2510    #[test]
2511    fn prev_tab_restores_welcome_for_tab_without_repo() {
2512        let mut app = App::new();
2513        app.tabs[0].repo_path = Some(PathBuf::from("/tmp/repo-a"));
2514        app.new_tab(); // tab 1, no repo, screen = Welcome
2515                       // go back to tab 0 (has repo) and set screen manually
2516        app.active_tab_index = 0;
2517        app.screen = AppScreen::Main;
2518        // prev wraps to tab 1 which has no repo
2519        app.prev_tab();
2520        assert_eq!(app.active_tab_index, 1);
2521        assert_eq!(app.screen, AppScreen::Welcome);
2522    }
2523
2524    #[test]
2525    fn next_diff_file_clears_multi_selection() {
2526        let mut app = App::new();
2527        app.tab_mut().commit_files = vec![
2528            gitkraft_core::DiffFileEntry {
2529                old_file: String::new(),
2530                new_file: "a.rs".to_string(),
2531                status: gitkraft_core::FileStatus::Modified,
2532            },
2533            gitkraft_core::DiffFileEntry {
2534                old_file: String::new(),
2535                new_file: "b.rs".to_string(),
2536                status: gitkraft_core::FileStatus::Modified,
2537            },
2538            gitkraft_core::DiffFileEntry {
2539                old_file: String::new(),
2540                new_file: "c.rs".to_string(),
2541                status: gitkraft_core::FileStatus::Modified,
2542            },
2543        ];
2544        app.tab_mut().commit_diff_file_index = 0;
2545        // pre-populate a multi-selection
2546        app.tab_mut().selected_file_indices.insert(0);
2547        app.tab_mut().selected_file_indices.insert(1);
2548        app.next_diff_file();
2549        // should have exactly one entry: the new current index
2550        assert_eq!(app.tab().selected_file_indices.len(), 1);
2551        assert_eq!(app.tab().commit_diff_file_index, 1);
2552        assert!(app.tab().selected_file_indices.contains(&1));
2553    }
2554
2555    #[test]
2556    fn prev_diff_file_clears_multi_selection() {
2557        let mut app = App::new();
2558        app.tab_mut().commit_files = vec![
2559            gitkraft_core::DiffFileEntry {
2560                old_file: String::new(),
2561                new_file: "a.rs".to_string(),
2562                status: gitkraft_core::FileStatus::Modified,
2563            },
2564            gitkraft_core::DiffFileEntry {
2565                old_file: String::new(),
2566                new_file: "b.rs".to_string(),
2567                status: gitkraft_core::FileStatus::Modified,
2568            },
2569        ];
2570        app.tab_mut().commit_diff_file_index = 1;
2571        app.tab_mut().selected_file_indices.insert(0);
2572        app.tab_mut().selected_file_indices.insert(1);
2573        app.prev_diff_file();
2574        assert_eq!(app.tab().selected_file_indices.len(), 1);
2575        assert_eq!(app.tab().commit_diff_file_index, 0);
2576        assert!(app.tab().selected_file_indices.contains(&0));
2577    }
2578
2579    #[test]
2580    fn load_diff_for_file_index_out_of_bounds_is_noop() {
2581        let mut app = App::new();
2582        // no commit_files — should not panic
2583        app.load_diff_for_file_index(0);
2584        assert!(app.tab().commit_diffs.is_empty());
2585    }
2586
2587    #[test]
2588    fn load_diff_for_file_index_skips_if_already_cached() {
2589        let mut app = App::new();
2590        app.tab_mut().commit_files = vec![gitkraft_core::DiffFileEntry {
2591            old_file: String::new(),
2592            new_file: "a.rs".to_string(),
2593            status: gitkraft_core::FileStatus::Modified,
2594        }];
2595        // insert a fake cached diff so the function short-circuits
2596        app.tab_mut().commit_diffs.insert(
2597            0,
2598            DiffInfo {
2599                old_file: String::new(),
2600                new_file: "a.rs".to_string(),
2601                status: gitkraft_core::FileStatus::Modified,
2602                hunks: Vec::new(),
2603            },
2604        );
2605        // without repo_path the bg task would be a no-op anyway, but is_loading
2606        // should remain false because we skip the load entirely
2607        app.load_diff_for_file_index(0);
2608        assert!(!app.tab().is_loading);
2609    }
2610
2611    #[test]
2612    fn push_branch_requires_head_branch() {
2613        let mut app = App::new();
2614        app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
2615        // No repo_info / head_branch set
2616        app.push_branch();
2617        assert!(app.tab().error_message.is_some());
2618    }
2619
2620    #[test]
2621    fn force_push_requires_head_branch() {
2622        let mut app = App::new();
2623        app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
2624        app.force_push_branch();
2625        assert!(app.tab().error_message.is_some());
2626    }
2627
2628    #[test]
2629    fn merge_selected_branch_no_selection() {
2630        let mut app = App::new();
2631        app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
2632        // No branch selected — should be a no-op (no crash)
2633        app.merge_selected_branch();
2634        assert!(!app.tab().is_loading);
2635    }
2636
2637    #[test]
2638    fn rebase_onto_selected_no_selection() {
2639        let mut app = App::new();
2640        app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
2641        app.rebase_onto_selected_branch();
2642        assert!(!app.tab().is_loading);
2643    }
2644
2645    #[test]
2646    fn revert_selected_commit_no_selection() {
2647        let mut app = App::new();
2648        app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
2649        app.revert_selected_commit();
2650        assert!(!app.tab().is_loading);
2651    }
2652
2653    #[test]
2654    fn reset_to_selected_commit_no_selection() {
2655        let mut app = App::new();
2656        app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
2657        app.reset_to_selected_commit("soft");
2658        assert!(!app.tab().is_loading);
2659    }
2660
2661    #[test]
2662    fn open_repo_creates_new_tab_when_current_has_repo() {
2663        let mut app = App::new();
2664        app.tabs[0].repo_path = Some(PathBuf::from("/tmp/repo1"));
2665        app.screen = AppScreen::Main;
2666        // Simulate browser selecting a repo when one is already open
2667        let initial_tabs = app.tabs.len();
2668        if app.tab().repo_path.is_some() {
2669            app.new_tab();
2670        }
2671        assert_eq!(app.tabs.len(), initial_tabs + 1);
2672    }
2673
2674    // ── Commit action popup ───────────────────────────────────────────────
2675
2676    #[test]
2677    fn commit_action_items_defaults_empty() {
2678        let tab = RepoTab::new();
2679        assert!(tab.commit_action_items.is_empty());
2680        assert_eq!(tab.commit_action_cursor, 0);
2681        assert!(tab.pending_commit_action_oid.is_none());
2682        assert!(tab.pending_action_kind.is_none());
2683        assert!(tab.action_input1.is_empty());
2684    }
2685
2686    #[test]
2687    fn open_commit_action_popup_no_selection_is_noop() {
2688        let mut app = App::new();
2689        // No commit selected, no commits loaded
2690        app.open_commit_action_popup();
2691        assert!(app.tab().pending_commit_action_oid.is_none());
2692        assert!(app.tab().commit_action_items.is_empty());
2693    }
2694
2695    #[test]
2696    fn open_commit_action_popup_fills_items_from_menu_groups() {
2697        let mut app = App::new();
2698        // Add a fake commit and select it
2699        app.tab_mut().commits = vec![gitkraft_core::CommitInfo {
2700            oid: "abc1234567890".to_string(),
2701            short_oid: "abc1234".to_string(),
2702            summary: "test commit".to_string(),
2703            message: "test commit".to_string(),
2704            author_name: "author".to_string(),
2705            author_email: "a@b.com".to_string(),
2706            time: Default::default(),
2707            parent_ids: vec![],
2708        }];
2709        app.tab_mut().commit_list_state.select(Some(0));
2710
2711        app.open_commit_action_popup();
2712
2713        // Should have filled items from COMMIT_MENU_GROUPS (10 total)
2714        let expected: Vec<gitkraft_core::CommitActionKind> = gitkraft_core::COMMIT_MENU_GROUPS
2715            .iter()
2716            .flat_map(|g| g.iter().copied())
2717            .collect();
2718        assert_eq!(app.tab().commit_action_items, expected);
2719        assert_eq!(app.tab().commit_action_items.len(), 10);
2720    }
2721
2722    #[test]
2723    fn open_commit_action_popup_sets_pending_oid() {
2724        let mut app = App::new();
2725        app.tab_mut().commits = vec![gitkraft_core::CommitInfo {
2726            oid: "deadbeef1234567".to_string(),
2727            short_oid: "deadbee".to_string(),
2728            summary: "s".to_string(),
2729            message: "s".to_string(),
2730            author_name: "a".to_string(),
2731            author_email: "a@b.com".to_string(),
2732            time: Default::default(),
2733            parent_ids: vec![],
2734        }];
2735        app.tab_mut().commit_list_state.select(Some(0));
2736
2737        app.open_commit_action_popup();
2738
2739        assert_eq!(
2740            app.tab().pending_commit_action_oid.as_deref(),
2741            Some("deadbeef1234567")
2742        );
2743        assert_eq!(app.tab().commit_action_cursor, 0);
2744    }
2745
2746    #[test]
2747    fn open_commit_action_popup_resets_cursor() {
2748        let mut app = App::new();
2749        app.tab_mut().commits = vec![gitkraft_core::CommitInfo {
2750            oid: "aaa".to_string(),
2751            short_oid: "aaa".to_string(),
2752            summary: "s".to_string(),
2753            message: "s".to_string(),
2754            author_name: "a".to_string(),
2755            author_email: "a@b.com".to_string(),
2756            time: Default::default(),
2757            parent_ids: vec![],
2758        }];
2759        app.tab_mut().commit_list_state.select(Some(0));
2760        // Pre-set cursor to a non-zero value
2761        app.tab_mut().commit_action_cursor = 5;
2762
2763        app.open_commit_action_popup();
2764
2765        assert_eq!(app.tab().commit_action_cursor, 0);
2766    }
2767
2768    #[test]
2769    fn execute_commit_action_no_pending_oid_is_noop() {
2770        let mut app = App::new();
2771        // No pending OID — should not set is_loading
2772        app.execute_commit_action(gitkraft_core::CommitAction::CherryPick);
2773        assert!(!app.tab().is_loading);
2774    }
2775
2776    #[test]
2777    fn execute_commit_action_no_repo_path_is_noop() {
2778        let mut app = App::new();
2779        app.tab_mut().pending_commit_action_oid = Some("abc123".to_string());
2780        // No repo_path set — should not set is_loading
2781        app.execute_commit_action(gitkraft_core::CommitAction::CherryPick);
2782        assert!(!app.tab().is_loading);
2783    }
2784
2785    #[test]
2786    fn execute_commit_action_sets_loading_and_clears_state() {
2787        let mut app = App::new();
2788        app.tab_mut().pending_commit_action_oid = Some("abc123".to_string());
2789        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2790        app.tab_mut().pending_action_kind = Some(gitkraft_core::CommitActionKind::CherryPick);
2791        app.tab_mut().action_input1 = "some-input".to_string();
2792
2793        app.execute_commit_action(gitkraft_core::CommitAction::CherryPick);
2794
2795        assert!(app.tab().is_loading);
2796        // State should be cleared after dispatch
2797        assert!(app.tab().pending_commit_action_oid.is_none());
2798        assert!(app.tab().pending_action_kind.is_none());
2799        assert!(app.tab().action_input1.is_empty());
2800    }
2801
2802    // ── File history / blame / delete ─────────────────────────────────────
2803
2804    #[test]
2805    fn file_history_defaults_empty() {
2806        let tab = RepoTab::new();
2807        assert!(tab.file_history_path.is_none());
2808        assert!(tab.file_history_commits.is_empty());
2809        assert_eq!(tab.file_history_cursor, 0);
2810    }
2811
2812    #[test]
2813    fn blame_defaults_empty() {
2814        let tab = RepoTab::new();
2815        assert!(tab.blame_path.is_none());
2816        assert!(tab.blame_lines.is_empty());
2817        assert_eq!(tab.blame_scroll, 0);
2818    }
2819
2820    #[test]
2821    fn confirm_delete_defaults_none() {
2822        let tab = RepoTab::new();
2823        assert!(tab.confirm_delete_file.is_none());
2824    }
2825
2826    #[test]
2827    fn open_file_history_no_repo_is_noop() {
2828        let mut app = App::new();
2829        // No repo_path — should not set file_history_path
2830        app.open_file_history("src/main.rs".to_string());
2831        assert!(app.tab().file_history_path.is_none());
2832    }
2833
2834    #[test]
2835    fn open_file_history_sets_path_and_clears_blame() {
2836        let mut app = App::new();
2837        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2838        app.tab_mut().blame_path = Some("old.rs".to_string());
2839
2840        app.open_file_history("src/main.rs".to_string());
2841
2842        assert_eq!(app.tab().file_history_path.as_deref(), Some("src/main.rs"));
2843        // blame should be closed
2844        assert!(app.tab().blame_path.is_none());
2845    }
2846
2847    #[test]
2848    fn open_file_blame_sets_path_and_clears_history() {
2849        let mut app = App::new();
2850        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2851        app.tab_mut().file_history_path = Some("old.rs".to_string());
2852
2853        app.open_file_blame("src/lib.rs".to_string());
2854
2855        assert_eq!(app.tab().blame_path.as_deref(), Some("src/lib.rs"));
2856        // history should be closed
2857        assert!(app.tab().file_history_path.is_none());
2858    }
2859
2860    #[test]
2861    fn prompt_delete_file_sets_confirm_and_status() {
2862        let mut app = App::new();
2863        app.prompt_delete_file("src/old.rs".to_string());
2864        assert_eq!(app.tab().confirm_delete_file.as_deref(), Some("src/old.rs"));
2865        assert!(app.tab().status_message.is_some());
2866    }
2867
2868    #[test]
2869    fn confirm_delete_file_no_repo_is_noop() {
2870        let mut app = App::new();
2871        app.tab_mut().confirm_delete_file = Some("src/old.rs".to_string());
2872        // No repo_path
2873        app.confirm_delete_file();
2874        assert!(!app.tab().is_loading);
2875    }
2876
2877    // ── open_commit_files_in_editor ───────────────────────────────────────
2878
2879    #[test]
2880    fn open_commit_files_in_editor_shows_path_when_no_editor() {
2881        let mut app = App::new();
2882        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/repo"));
2883        app.tab_mut().commit_files = vec![gitkraft_core::DiffFileEntry {
2884            old_file: String::new(),
2885            new_file: "src/main.rs".to_string(),
2886            status: gitkraft_core::FileStatus::Modified,
2887        }];
2888        app.tab_mut().commit_diff_file_index = 0;
2889        // editor is Editor::None by default
2890
2891        app.open_commit_files_in_editor();
2892
2893        // With no editor, shows a status message with the file path
2894        assert!(app.tab().status_message.is_some());
2895        let msg = app.tab().status_message.as_deref().unwrap();
2896        assert!(msg.contains("no editor") || msg.contains("src/main.rs"));
2897    }
2898
2899    #[test]
2900    fn open_commit_files_in_editor_queues_multi_file_for_terminal_editor() {
2901        let mut app = App::new();
2902        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/repo"));
2903        app.tab_mut().commit_files = vec![
2904            gitkraft_core::DiffFileEntry {
2905                old_file: String::new(),
2906                new_file: "a.rs".to_string(),
2907                status: gitkraft_core::FileStatus::Modified,
2908            },
2909            gitkraft_core::DiffFileEntry {
2910                old_file: String::new(),
2911                new_file: "b.rs".to_string(),
2912                status: gitkraft_core::FileStatus::Modified,
2913            },
2914        ];
2915        app.tab_mut().selected_file_indices.insert(0);
2916        app.tab_mut().selected_file_indices.insert(1);
2917        app.editor = gitkraft_core::Editor::Helix; // terminal editor
2918
2919        app.open_commit_files_in_editor();
2920
2921        // Both files should be queued for the editor
2922        let queued = app.pending_editor_open.as_ref().unwrap();
2923        assert_eq!(queued.len(), 2);
2924    }
2925
2926    #[test]
2927    fn cherry_pick_selected_single_commit_sets_loading() {
2928        let mut app = App::new();
2929        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2930        app.tab_mut().commits = vec![gitkraft_core::CommitInfo {
2931            oid: "abc1234567890".to_string(),
2932            short_oid: "abc1234".to_string(),
2933            summary: "test".into(),
2934            message: "test".into(),
2935            author_name: "A".into(),
2936            author_email: "a@a.com".into(),
2937            time: Default::default(),
2938            parent_ids: Vec::new(),
2939        }];
2940        app.tab_mut().commit_list_state.select(Some(0));
2941
2942        app.cherry_pick_selected();
2943
2944        assert!(app.tab().is_loading);
2945    }
2946
2947    #[test]
2948    fn cherry_pick_selected_no_repo_path_is_noop() {
2949        let mut app = App::new();
2950        app.tab_mut().commits = vec![gitkraft_core::CommitInfo {
2951            oid: "abc1234567890".to_string(),
2952            short_oid: "abc1234".to_string(),
2953            summary: "test".into(),
2954            message: "test".into(),
2955            author_name: "A".into(),
2956            author_email: "a@a.com".into(),
2957            time: Default::default(),
2958            parent_ids: Vec::new(),
2959        }];
2960        app.tab_mut().commit_list_state.select(Some(0));
2961
2962        app.cherry_pick_selected();
2963
2964        assert!(!app.tab().is_loading);
2965    }
2966
2967    #[test]
2968    fn cherry_pick_selected_no_cursor_is_noop() {
2969        let mut app = App::new();
2970        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2971        app.tab_mut().commits = vec![gitkraft_core::CommitInfo {
2972            oid: "abc1234567890".to_string(),
2973            short_oid: "abc1234".to_string(),
2974            summary: "test".into(),
2975            message: "test".into(),
2976            author_name: "A".into(),
2977            author_email: "a@a.com".into(),
2978            time: Default::default(),
2979            parent_ids: Vec::new(),
2980        }];
2981        // No cursor selected — commit_list_state.selected() returns None
2982
2983        app.cherry_pick_selected();
2984
2985        assert!(
2986            !app.tab().is_loading,
2987            "no cursor → cherry_pick_selected must be a noop"
2988        );
2989    }
2990
2991    #[test]
2992    fn cherry_pick_selected_single_sets_status_message_with_short_oid() {
2993        let mut app = App::new();
2994        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
2995        app.tab_mut().commits = vec![gitkraft_core::CommitInfo {
2996            oid: "deadbeef12345".to_string(),
2997            short_oid: "deadbee".to_string(),
2998            summary: "fix: something".into(),
2999            message: "fix: something".into(),
3000            author_name: "A".into(),
3001            author_email: "a@a.com".into(),
3002            time: Default::default(),
3003            parent_ids: Vec::new(),
3004        }];
3005        app.tab_mut().commit_list_state.select(Some(0));
3006
3007        app.cherry_pick_selected();
3008
3009        let msg = app.tab().status_message.as_deref().unwrap_or("");
3010        assert!(
3011            msg.contains("deadbee"),
3012            "status message must contain the short OID; got: {msg}"
3013        );
3014        assert!(
3015            msg.to_lowercase().contains("cherry"),
3016            "status message must mention cherry-pick; got: {msg}"
3017        );
3018    }
3019
3020    #[test]
3021    fn cherry_pick_selected_multi_uses_selected_commits_and_sets_count_message() {
3022        let mut app = App::new();
3023        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3024        // Three commits newest-first (index 0 = newest, index 2 = oldest).
3025        app.tab_mut().commits = vec![
3026            gitkraft_core::CommitInfo {
3027                oid: "oid_newest".to_string(),
3028                short_oid: "newest".to_string(),
3029                summary: "newest".into(),
3030                message: "newest".into(),
3031                author_name: "A".into(),
3032                author_email: "a@a.com".into(),
3033                time: Default::default(),
3034                parent_ids: Vec::new(),
3035            },
3036            gitkraft_core::CommitInfo {
3037                oid: "oid_middle".to_string(),
3038                short_oid: "middle".to_string(),
3039                summary: "middle".into(),
3040                message: "middle".into(),
3041                author_name: "A".into(),
3042                author_email: "a@a.com".into(),
3043                time: Default::default(),
3044                parent_ids: Vec::new(),
3045            },
3046            gitkraft_core::CommitInfo {
3047                oid: "oid_oldest".to_string(),
3048                short_oid: "oldest".to_string(),
3049                summary: "oldest".into(),
3050                message: "oldest".into(),
3051                author_name: "A".into(),
3052                author_email: "a@a.com".into(),
3053                time: Default::default(),
3054                parent_ids: Vec::new(),
3055            },
3056        ];
3057        // Multi-select all three commits.
3058        app.tab_mut().selected_commits = vec![0, 1, 2];
3059        app.tab_mut().commit_list_state.select(Some(0));
3060
3061        app.cherry_pick_selected();
3062
3063        assert!(
3064            app.tab().is_loading,
3065            "multi cherry-pick must set is_loading"
3066        );
3067        let msg = app.tab().status_message.as_deref().unwrap_or("");
3068        assert!(
3069            msg.contains("3"),
3070            "status message must mention commit count (3); got: {msg}"
3071        );
3072    }
3073
3074    #[test]
3075    fn cherry_pick_selected_multi_orders_oldest_first() {
3076        // Verifies that the OIDs are collected in oldest-first order (highest
3077        // list index first) by checking the status message uses the first
3078        // collected OID — which should be the one at the highest index.
3079        let mut app = App::new();
3080        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3081        app.tab_mut().commits = vec![
3082            gitkraft_core::CommitInfo {
3083                oid: "oid_0_newest".to_string(),
3084                short_oid: "oid0new".to_string(),
3085                summary: "newest".into(),
3086                message: "newest".into(),
3087                author_name: "A".into(),
3088                author_email: "a@a.com".into(),
3089                time: Default::default(),
3090                parent_ids: Vec::new(),
3091            },
3092            gitkraft_core::CommitInfo {
3093                oid: "oid_1_oldest".to_string(),
3094                short_oid: "oid1old".to_string(),
3095                summary: "oldest".into(),
3096                message: "oldest".into(),
3097                author_name: "A".into(),
3098                author_email: "a@a.com".into(),
3099                time: Default::default(),
3100                parent_ids: Vec::new(),
3101            },
3102        ];
3103        app.tab_mut().selected_commits = vec![0, 1];
3104
3105        app.cherry_pick_selected();
3106
3107        // The status message short-OID should be from index 1 (oldest) since
3108        // we sort descending (index 1 > index 0) before iterating.
3109        let msg = app.tab().status_message.as_deref().unwrap_or("");
3110        assert!(
3111            msg.contains("2"),
3112            "multi cherry-pick message should say 2 commits; got: {msg}"
3113        );
3114    }
3115
3116    #[test]
3117    fn open_commit_files_in_editor_uses_single_cursor_when_no_multi_selection() {
3118        let mut app = App::new();
3119        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/repo"));
3120        app.tab_mut().commit_files = vec![
3121            gitkraft_core::DiffFileEntry {
3122                old_file: String::new(),
3123                new_file: "a.rs".to_string(),
3124                status: gitkraft_core::FileStatus::Modified,
3125            },
3126            gitkraft_core::DiffFileEntry {
3127                old_file: String::new(),
3128                new_file: "b.rs".to_string(),
3129                status: gitkraft_core::FileStatus::Modified,
3130            },
3131        ];
3132        app.tab_mut().commit_diff_file_index = 1; // cursor on b.rs
3133        app.editor = gitkraft_core::Editor::Helix;
3134        // selected_file_indices has 1 item (or 0) → should use cursor
3135
3136        app.open_commit_files_in_editor();
3137
3138        let queued = app.pending_editor_open.as_ref().unwrap();
3139        assert_eq!(queued.len(), 1);
3140        assert!(queued[0].ends_with("b.rs"));
3141    }
3142
3143    #[test]
3144    fn git_state_changed_triggers_full_refresh_when_repo_open() {
3145        let mut app = App::new();
3146        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3147        // Simulate receiving GitStateChanged via poll_background
3148        // by sending it through the channel first.
3149        app.bg_tx
3150            .send(crate::app::BackgroundResult::GitStateChanged)
3151            .unwrap();
3152        app.poll_background();
3153        // After GitStateChanged, a full refresh should have been enqueued
3154        // (is_loading set true because refresh() calls load_repo_blocking).
3155        assert!(
3156            app.tab().is_loading,
3157            "GitStateChanged must trigger a full refresh (is_loading should be true)"
3158        );
3159    }
3160
3161    #[test]
3162    fn git_state_changed_is_skipped_when_loading_no_infinite_loop() {
3163        let mut app = App::new();
3164        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3165        app.tab_mut().is_loading = true; // simulate in-flight task
3166
3167        // Send two GitStateChanged messages
3168        app.bg_tx
3169            .send(crate::app::BackgroundResult::GitStateChanged)
3170            .unwrap();
3171        app.bg_tx
3172            .send(crate::app::BackgroundResult::GitStateChanged)
3173            .unwrap();
3174        app.poll_background();
3175
3176        // Must NOT have triggered a refresh (which would set is_loading if it wasn't already)
3177        // The is_loading flag stays as set by the test (not doubled).
3178        // Most importantly: no infinite loop (test completes quickly).
3179        assert!(app.tab().is_loading); // still true from the test setup, not changed
3180    }
3181
3182    #[test]
3183    fn git_state_changed_is_noop_when_already_loading() {
3184        let mut app = App::new();
3185        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3186        app.tab_mut().is_loading = true; // already loading — guard should prevent another refresh
3187        app.bg_tx
3188            .send(crate::app::BackgroundResult::GitStateChanged)
3189            .unwrap();
3190        app.poll_background();
3191        // refresh() sets status_message = "Refreshing…" when called.
3192        // The guard `if !self.tab().is_loading` must have blocked the call,
3193        // so status_message should NOT have been updated to "Refreshing…".
3194        assert_ne!(
3195            app.tab().status_message.as_deref(),
3196            Some("Refreshing\u{2026}"),
3197            "GitStateChanged must be a noop when already loading"
3198        );
3199    }
3200
3201    #[test]
3202    fn watcher_handles_periodic_refresh_last_auto_refresh_field_retained() {
3203        // maybe_auto_refresh() was removed — the notify-based git watcher in
3204        // lib.rs now owns all periodic refreshes (reactive < 1 s, 60 s fallback).
3205        // The last_auto_refresh field is kept to avoid a breaking change for any
3206        // external code; it no longer drives any internal behaviour.
3207        let app = App::new();
3208        // Field still exists and is initialised to a recent instant.
3209        assert!(app.last_auto_refresh.elapsed() < std::time::Duration::from_secs(1));
3210    }
3211
3212    #[test]
3213    fn git_state_changed_respects_2s_cooldown_after_refresh() {
3214        let mut app = App::new();
3215        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3216        // Simulate a refresh that just completed (recent timestamp).
3217        app.last_refresh_completed = std::time::Instant::now();
3218
3219        app.bg_tx
3220            .send(crate::app::BackgroundResult::GitStateChanged)
3221            .unwrap();
3222        app.poll_background();
3223
3224        // Must NOT have triggered a new refresh — cooldown is active.
3225        assert!(
3226            !app.tab().is_loading,
3227            "GitStateChanged within 2s of last refresh must be skipped (cooldown)"
3228        );
3229    }
3230
3231    #[test]
3232    fn git_state_changed_fires_after_cooldown_expires() {
3233        let mut app = App::new();
3234        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3235        // Simulate a refresh that completed 5 seconds ago (cooldown expired).
3236        app.last_refresh_completed = std::time::Instant::now() - std::time::Duration::from_secs(5);
3237
3238        app.bg_tx
3239            .send(crate::app::BackgroundResult::GitStateChanged)
3240            .unwrap();
3241        app.poll_background();
3242
3243        // Cooldown has expired — refresh_silent() should have been called.
3244        assert!(
3245            app.tab().is_loading,
3246            "GitStateChanged after 2s cooldown must trigger a silent refresh"
3247        );
3248    }
3249
3250    #[test]
3251    fn refresh_silent_sets_loading_but_no_status_message() {
3252        let mut app = App::new();
3253        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3254        // Set a user message that should survive the silent refresh.
3255        app.tab_mut().status_message = Some("3 files selected".into());
3256
3257        app.refresh_silent();
3258
3259        assert!(app.tab().is_loading, "refresh_silent must set is_loading");
3260        assert_eq!(
3261            app.tab().status_message.as_deref(),
3262            Some("3 files selected"),
3263            "refresh_silent must NOT overwrite user status messages"
3264        );
3265    }
3266
3267    #[test]
3268    fn refresh_sets_refreshing_status_message() {
3269        let mut app = App::new();
3270        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3271
3272        app.refresh();
3273
3274        assert!(app.tab().is_loading);
3275        assert_eq!(
3276            app.tab().status_message.as_deref(),
3277            Some("Refreshing…"),
3278            "user-triggered refresh must show Refreshing… status"
3279        );
3280    }
3281
3282    #[test]
3283    fn repo_loaded_stamps_last_refresh_completed() {
3284        let mut app = App::new();
3285        let old_ts = app.last_refresh_completed;
3286        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
3287
3288        // Send a successful RepoLoaded result.
3289        let payload = gitkraft_core::RepoSnapshot {
3290            info: gitkraft_core::RepoInfo {
3291                path: std::path::PathBuf::from("/tmp/fake/.git"),
3292                workdir: Some(std::path::PathBuf::from("/tmp/fake")),
3293                head_branch: Some("main".to_string()),
3294                is_bare: false,
3295                state: gitkraft_core::RepoState::Clean,
3296            },
3297            branches: vec![],
3298            commits: vec![],
3299            graph_rows: vec![],
3300            unstaged: vec![],
3301            staged: vec![],
3302            stashes: vec![],
3303            remotes: vec![],
3304        };
3305        app.bg_tx
3306            .send(crate::app::BackgroundResult::RepoLoaded {
3307                path: std::path::PathBuf::from("/tmp/fake"),
3308                result: Ok(payload),
3309            })
3310            .unwrap();
3311        app.poll_background();
3312
3313        assert!(
3314            app.last_refresh_completed > old_ts,
3315            "RepoLoaded must update last_refresh_completed"
3316        );
3317    }
3318
3319    // ── selected_idx! macro paths ─────────────────────────────────────────────
3320
3321    #[test]
3322    fn selected_idx_returns_early_with_error_when_list_is_empty() {
3323        // stash_pop_selected uses selected_idx!(self, stash_list_state, stashes, …)
3324        // With an empty stash list, the macro must set error_message and return.
3325        let mut app = App::new();
3326        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3327        // No stashes, no selection — selected_idx! sees idx=0 >= len=0 → error
3328        app.stash_pop_selected();
3329        assert!(
3330            app.tab().error_message.is_some(),
3331            "selected_idx! must set error_message when list is empty"
3332        );
3333        assert!(!app.tab().is_loading, "must not start a background task");
3334    }
3335
3336    #[test]
3337    fn selected_idx_returns_early_with_error_when_selection_out_of_bounds() {
3338        let mut app = App::new();
3339        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3340        // One stash, but cursor placed beyond it (idx=5, len=1)
3341        app.tab_mut().stashes = vec![gitkraft_core::StashEntry {
3342            index: 0,
3343            message: "WIP".to_string(),
3344            oid: "abc1234deadbeef".to_string(),
3345        }];
3346        app.tab_mut().stash_list_state.select(Some(5)); // out of bounds
3347        app.stash_pop_selected();
3348        assert!(
3349            app.tab().error_message.is_some(),
3350            "selected_idx! must set error_message when selection is out of bounds"
3351        );
3352    }
3353
3354    #[test]
3355    fn selected_idx_succeeds_when_selection_is_valid() {
3356        // With a valid selection, selected_idx! should NOT set error_message and
3357        // the operation should proceed (sets is_loading).
3358        let mut app = App::new();
3359        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3360        app.tab_mut().stashes = vec![gitkraft_core::StashEntry {
3361            index: 0,
3362            message: "WIP".to_string(),
3363            oid: "abc1234deadbeef".to_string(),
3364        }];
3365        app.tab_mut().stash_list_state.select(Some(0));
3366        app.stash_pop_selected();
3367        assert!(app.tab().error_message.is_none());
3368        assert!(app.tab().is_loading, "operation must have started");
3369    }
3370
3371    // ── require_selection! macro paths ───────────────────────────────────────
3372
3373    #[test]
3374    fn require_selection_sets_status_message_when_nothing_selected() {
3375        // stage_selected uses require_selection!(self, unstaged_list_state, …)
3376        let mut app = App::new();
3377        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3378        // No unstaged changes, no selection
3379        app.stage_selected();
3380        assert_eq!(
3381            app.tab().status_message.as_deref(),
3382            Some("No unstaged file selected"),
3383            "require_selection! must set status_message when nothing is selected"
3384        );
3385        assert!(!app.tab().is_loading);
3386    }
3387
3388    #[test]
3389    fn require_selection_proceeds_when_selection_exists() {
3390        let mut app = App::new();
3391        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3392        app.tab_mut().unstaged_changes = vec![gitkraft_core::DiffInfo {
3393            old_file: String::new(),
3394            new_file: "src/main.rs".to_string(),
3395            status: gitkraft_core::FileStatus::Modified,
3396            hunks: vec![],
3397        }];
3398        app.tab_mut().unstaged_list_state.select(Some(0));
3399        app.stage_selected();
3400        // With a valid selection, the operation must start
3401        assert!(
3402            app.tab().is_loading,
3403            "stage_selected must start when file is selected"
3404        );
3405        assert!(app.tab().error_message.is_none());
3406    }
3407
3408    #[test]
3409    fn require_selection_unstage_sets_status_when_nothing_selected() {
3410        let mut app = App::new();
3411        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3412        app.unstage_selected();
3413        assert_eq!(
3414            app.tab().status_message.as_deref(),
3415            Some("No staged file selected"),
3416            "require_selection! must set status_message for unstage when nothing selected"
3417        );
3418        assert!(!app.tab().is_loading);
3419    }
3420
3421    #[test]
3422    fn require_selection_discard_sets_status_when_nothing_selected() {
3423        let mut app = App::new();
3424        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3425        app.discard_selected();
3426        assert_eq!(
3427            app.tab().status_message.as_deref(),
3428            Some("No unstaged file selected"),
3429            "require_selection! must set status_message for discard when nothing selected"
3430        );
3431        assert!(!app.tab().is_loading);
3432    }
3433
3434    // ── bg_task! macro paths ──────────────────────────────────────────────────
3435
3436    #[test]
3437    fn bg_task_returns_early_when_no_repo_path() {
3438        // load_commit_diff_by_oid uses bg_task! and requires repo_path
3439        let mut app = App::new();
3440        // No repo_path set — bg_task! must return without setting is_loading
3441        app.tab_mut().selected_commit_oid = Some("abc123".to_string());
3442        app.load_commit_diff_by_oid();
3443        assert!(
3444            !app.tab().is_loading,
3445            "bg_task! must return early when repo_path is None"
3446        );
3447    }
3448
3449    #[test]
3450    fn bg_task_sets_loading_and_status_when_repo_is_set() {
3451        let mut app = App::new();
3452        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3453        app.tab_mut().selected_commit_oid = Some("abc123deadbeef".to_string());
3454        app.load_commit_diff_by_oid();
3455        assert!(
3456            app.tab().is_loading,
3457            "bg_task! must set is_loading when repo_path is present"
3458        );
3459    }
3460
3461    // ── bg_op! macro paths ────────────────────────────────────────────────────
3462
3463    #[test]
3464    fn bg_op_returns_early_when_no_repo_path() {
3465        // stage_all uses bg_op!
3466        let mut app = App::new();
3467        app.stage_all(); // no repo_path
3468        assert!(
3469            !app.tab().is_loading,
3470            "bg_op! must return early when repo_path is None"
3471        );
3472    }
3473
3474    #[test]
3475    fn bg_op_sets_loading_and_status_when_repo_is_set() {
3476        let mut app = App::new();
3477        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3478        app.stage_all();
3479        assert!(app.tab().is_loading, "bg_op! must set is_loading");
3480        assert!(
3481            app.tab().status_message.is_some(),
3482            "bg_op! must set status_message"
3483        );
3484    }
3485
3486    #[test]
3487    fn bg_op_refresh_variant_triggers_full_refresh_on_success() {
3488        // fetch_remote uses bg_op! with the refresh variant
3489        // We can't run a real git fetch, but we can verify is_loading is set.
3490        let mut app = App::new();
3491        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake-repo"));
3492        app.fetch_remote();
3493        assert!(app.tab().is_loading);
3494    }
3495
3496    // ── Status bar mode indicator ─────────────────────────────────────────────
3497
3498    #[test]
3499    fn input_mode_normal_does_not_set_mode_text_in_spans() {
3500        // The status bar renders nothing for Normal mode — this test ensures
3501        // that `input_mode == Normal` doesn't accidentally produce a status
3502        // message prefixed with "NORMAL".
3503        let app = App::new();
3504        assert_eq!(app.input_mode, crate::app::InputMode::Normal);
3505        // status_message starts as None; no stale "NORMAL" text.
3506        assert!(app.tab().status_message.is_none());
3507    }
3508
3509    #[test]
3510    fn input_mode_input_shows_purpose_label() {
3511        // When in Input mode, status_message is set by the caller.
3512        // The status bar renders the input_buffer directly — ensure the message is set.
3513        let mut app = App::new();
3514        app.input_mode = crate::app::InputMode::Input;
3515        app.input_purpose = crate::app::InputPurpose::CommitMessage;
3516        app.tab_mut().status_message = Some("Enter commit message:".into());
3517        assert_eq!(
3518            app.tab().status_message.as_deref(),
3519            Some("Enter commit message:")
3520        );
3521    }
3522}