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