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/// Payload produced by a background `refresh` / `open_repo` task.
69#[derive(Debug)]
70pub struct RepoPayload {
71    pub info: RepoInfo,
72    pub branches: Vec<BranchInfo>,
73    pub commits: Vec<CommitInfo>,
74    pub graph_rows: Vec<gitkraft_core::GraphRow>,
75    pub unstaged: Vec<DiffInfo>,
76    pub staged: Vec<DiffInfo>,
77    pub stashes: Vec<StashEntry>,
78    pub remotes: Vec<RemoteInfo>,
79}
80
81/// Results produced by background tasks and sent back to the main loop.
82#[derive(Debug)]
83pub enum BackgroundResult {
84    /// A repo open / refresh completed. The `PathBuf` identifies which tab
85    /// initiated the load so the result is applied to the correct tab.
86    RepoLoaded {
87        path: PathBuf,
88        result: Result<RepoPayload, String>,
89    },
90    /// A fetch completed.
91    FetchDone(Result<(), String>),
92    /// A commit-diff load completed.
93    CommitDiffLoaded(Result<Vec<DiffInfo>, String>),
94    /// A staging-only refresh completed (unstaged + staged diffs reloaded).
95    StagingRefreshed(Result<StagingPayload, String>),
96    /// A single-shot operation (stage, unstage, checkout, commit, stash, etc.)
97    /// completed and the staging area should be refreshed.
98    OperationDone {
99        ok_message: Option<String>,
100        err_message: Option<String>,
101        /// If `true`, trigger a full refresh after applying the result.
102        needs_refresh: bool,
103        /// If `true`, trigger only a staging refresh.
104        needs_staging_refresh: bool,
105    },
106    /// A commit file list (lightweight, no diff content) was loaded.
107    CommitFileListLoaded(Result<Vec<gitkraft_core::DiffFileEntry>, String>),
108    /// A single file's diff was loaded.
109    SingleFileDiffLoaded(Result<gitkraft_core::DiffInfo, String>),
110    /// Commit search results loaded.
111    SearchResults(Result<Vec<gitkraft_core::CommitInfo>, String>),
112}
113
114/// Payload returned by an async staging refresh.
115#[derive(Debug)]
116pub struct StagingPayload {
117    pub unstaged: Vec<DiffInfo>,
118    pub staged: Vec<DiffInfo>,
119}
120
121// ── Enums ─────────────────────────────────────────────────────────────────────
122
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub enum AppScreen {
125    Welcome,
126    DirBrowser,
127    Main,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum ActivePane {
132    Branches,
133    CommitLog,
134    DiffView,
135    Staging,
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum InputMode {
140    Normal,
141    Input,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum InputPurpose {
146    None,
147    CommitMessage,
148    BranchName,
149    RepoPath,
150    SearchQuery,
151    StashMessage,
152}
153
154/// Which sub-list within the staging pane has focus.
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum StagingFocus {
157    Unstaged,
158    Staged,
159}
160
161// ── Per-repo tab state ────────────────────────────────────────────────────────────
162
163/// All state that belongs to a single repository tab.
164pub struct RepoTab {
165    pub repo_path: Option<PathBuf>,
166    pub repo_info: Option<RepoInfo>,
167
168    pub branches: Vec<BranchInfo>,
169    pub branch_list_state: ListState,
170
171    pub commits: Vec<CommitInfo>,
172    pub graph_rows: Vec<gitkraft_core::GraphRow>,
173    pub commit_list_state: ListState,
174
175    pub unstaged_changes: Vec<DiffInfo>,
176    pub staged_changes: Vec<DiffInfo>,
177    pub unstaged_list_state: ListState,
178    pub staged_list_state: ListState,
179    pub staging_focus: StagingFocus,
180    pub selected_diff: Option<DiffInfo>,
181    pub diff_scroll: u16,
182    /// All file diffs for the currently viewed commit (for per-file navigation).
183    pub commit_diffs: Vec<DiffInfo>,
184    /// Index of the currently selected file in commit_diffs.
185    pub commit_diff_file_index: usize,
186    /// Lightweight file list for the selected commit.
187    pub commit_files: Vec<gitkraft_core::DiffFileEntry>,
188    /// OID of the currently selected commit (for lazy file diff loading).
189    pub selected_commit_oid: Option<String>,
190
191    pub stashes: Vec<StashEntry>,
192    pub stash_list_state: ListState,
193    pub remotes: Vec<RemoteInfo>,
194
195    /// Search query for commit filtering.
196    pub search_query: String,
197    /// Whether search mode is active.
198    pub search_active: bool,
199    /// Search results (commits matching the query).
200    pub search_results: Vec<CommitInfo>,
201
202    /// Optional stash message (set via input mode before saving).
203    pub stash_message_buffer: String,
204
205    pub status_message: Option<String>,
206    pub error_message: Option<String>,
207
208    /// True while a background task is in flight for this tab.
209    pub is_loading: bool,
210
211    /// When true, the next d press actually discards; otherwise the first
212    /// d sets this flag and shows a confirmation prompt.
213    pub confirm_discard: bool,
214
215    /// Selected unstaged file indices for multi-select.
216    pub selected_unstaged: std::collections::HashSet<usize>,
217    /// Selected staged file indices for multi-select.
218    pub selected_staged: std::collections::HashSet<usize>,
219}
220
221impl RepoTab {
222    #[must_use]
223    pub fn new() -> Self {
224        Self {
225            repo_path: None,
226            repo_info: None,
227
228            branches: Vec::new(),
229            branch_list_state: ListState::default(),
230
231            commits: Vec::new(),
232            graph_rows: Vec::new(),
233            commit_list_state: ListState::default(),
234
235            unstaged_changes: Vec::new(),
236            staged_changes: Vec::new(),
237            unstaged_list_state: ListState::default(),
238            staged_list_state: ListState::default(),
239            staging_focus: StagingFocus::Unstaged,
240            selected_diff: None,
241            diff_scroll: 0,
242            commit_diffs: Vec::new(),
243            commit_diff_file_index: 0,
244            commit_files: Vec::new(),
245            selected_commit_oid: None,
246
247            stashes: Vec::new(),
248            stash_list_state: ListState::default(),
249            remotes: Vec::new(),
250
251            stash_message_buffer: String::new(),
252
253            search_query: String::new(),
254            search_active: false,
255            search_results: Vec::new(),
256
257            status_message: None,
258            error_message: None,
259
260            is_loading: false,
261
262            confirm_discard: false,
263
264            selected_unstaged: std::collections::HashSet::new(),
265            selected_staged: std::collections::HashSet::new(),
266        }
267    }
268
269    /// Return a human-readable display name for this tab.
270    /// Uses the last path component of repo_path, or "New Tab" if none.
271    pub fn display_name(&self) -> String {
272        match &self.repo_path {
273            Some(p) => p
274                .file_name()
275                .map(|n| n.to_string_lossy().into_owned())
276                .unwrap_or_else(|| "New Tab".into()),
277            None => "New Tab".into(),
278        }
279    }
280}
281
282impl Default for RepoTab {
283    fn default() -> Self {
284        Self::new()
285    }
286}
287
288// ── App State ───────────────────────────────────────────────────────────────────
289
290pub struct App {
291    pub should_quit: bool,
292    pub screen: AppScreen,
293    pub active_pane: ActivePane,
294    pub input_mode: InputMode,
295    pub input_purpose: InputPurpose,
296    pub tick_count: u64,
297
298    /// Receiver for results from background tasks.
299    pub bg_rx: mpsc::Receiver<BackgroundResult>,
300    /// Sender cloned into each spawned task.
301    pub(crate) bg_tx: mpsc::Sender<BackgroundResult>,
302
303    pub input_buffer: String,
304
305    /// Whether the theme selection panel is visible.
306    pub show_theme_panel: bool,
307    /// Whether the options panel is visible.
308    pub show_options_panel: bool,
309    /// Configured editor for opening files.
310    pub editor: gitkraft_core::Editor,
311    /// Whether the editor picker panel is visible.
312    pub show_editor_panel: bool,
313    /// ListState for the editor picker list.
314    pub editor_list_state: ListState,
315    /// Currently selected theme index (0-26).
316    pub current_theme_index: usize,
317    /// ListState for the theme list widget.
318    pub theme_list_state: ListState,
319
320    /// Recently opened repositories loaded from persistence.
321    pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
322
323    /// Current directory being browsed in the directory picker.
324    pub browser_dir: PathBuf,
325    /// Entries in the current browser directory.
326    pub browser_entries: Vec<std::path::PathBuf>,
327    /// List state for the directory browser.
328    pub browser_list_state: ListState,
329    /// Screen to return to when the directory browser is dismissed.
330    pub browser_return_screen: AppScreen,
331
332    /// Open repository tabs.
333    pub tabs: Vec<RepoTab>,
334    /// Index of the currently active tab.
335    pub active_tab_index: usize,
336
337    /// Timestamp of the last auto-refresh.
338    pub last_auto_refresh: std::time::Instant,
339}
340
341impl App {
342    // ── Constructor ──────────────────────────────────────────────────────────
343
344    #[must_use]
345    pub fn new() -> Self {
346        let settings = gitkraft_core::features::persistence::load_settings().unwrap_or_default();
347
348        let theme_index = theme_name_to_index(settings.theme_name.as_deref().unwrap_or(""));
349
350        let recent_repos = settings.recent_repos;
351
352        let (bg_tx, bg_rx) = mpsc::channel();
353
354        Self {
355            should_quit: false,
356            screen: AppScreen::Welcome,
357            active_pane: ActivePane::Branches,
358            input_mode: InputMode::Normal,
359            input_purpose: InputPurpose::None,
360            tick_count: 0,
361
362            bg_rx,
363            bg_tx,
364
365            input_buffer: String::new(),
366
367            show_theme_panel: false,
368            show_options_panel: false,
369            editor: settings
370                .editor_name
371                .as_deref()
372                .map(|name| {
373                    gitkraft_core::EDITOR_NAMES
374                        .iter()
375                        .position(|n| n.eq_ignore_ascii_case(name))
376                        .map(gitkraft_core::Editor::from_index)
377                        .unwrap_or_else(|| {
378                            if name.eq_ignore_ascii_case("none") {
379                                gitkraft_core::Editor::None
380                            } else {
381                                gitkraft_core::Editor::Custom(name.to_string())
382                            }
383                        })
384                })
385                .unwrap_or(gitkraft_core::Editor::None),
386            show_editor_panel: false,
387            editor_list_state: {
388                let mut s = ListState::default();
389                s.select(Some(0));
390                s
391            },
392            current_theme_index: theme_index,
393            theme_list_state: {
394                let mut s = ListState::default();
395                s.select(Some(theme_index));
396                s
397            },
398
399            recent_repos,
400
401            browser_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")),
402            browser_entries: Vec::new(),
403            browser_list_state: ListState::default(),
404            browser_return_screen: AppScreen::Welcome,
405
406            tabs: vec![RepoTab::new()],
407            active_tab_index: 0,
408
409            last_auto_refresh: std::time::Instant::now(),
410        }
411    }
412
413    // ── Tab accessors ────────────────────────────────────────────────────────
414
415    /// Return a shared reference to the currently active tab.
416    #[inline]
417    pub fn tab(&self) -> &RepoTab {
418        &self.tabs[self.active_tab_index]
419    }
420
421    /// Return an exclusive reference to the currently active tab.
422    #[inline]
423    pub fn tab_mut(&mut self) -> &mut RepoTab {
424        &mut self.tabs[self.active_tab_index]
425    }
426
427    // ── Tab management ──────────────────────────────────────────────────────
428
429    /// Open a new empty tab and make it active.
430    pub fn new_tab(&mut self) {
431        self.tabs.push(RepoTab::new());
432        self.active_tab_index = self.tabs.len() - 1;
433        self.screen = AppScreen::Welcome;
434        // Reload recent repos so they're fresh on the welcome screen
435        if let Ok(settings) = gitkraft_core::features::persistence::load_settings() {
436            self.recent_repos = settings.recent_repos;
437        }
438        self.save_session();
439    }
440
441    /// Close the current tab. If it is the last tab, replace it with an empty one.
442    pub fn close_tab(&mut self) {
443        if self.tabs.len() <= 1 {
444            self.tabs[0] = RepoTab::new();
445            self.active_tab_index = 0;
446        } else {
447            self.tabs.remove(self.active_tab_index);
448            if self.active_tab_index >= self.tabs.len() {
449                self.active_tab_index = self.tabs.len() - 1;
450            }
451        }
452        self.save_session();
453    }
454
455    /// Switch to the next tab (wrapping around).
456    pub fn next_tab(&mut self) {
457        if !self.tabs.is_empty() {
458            self.active_tab_index = (self.active_tab_index + 1) % self.tabs.len();
459        }
460    }
461
462    /// Switch to the previous tab (wrapping around).
463    pub fn prev_tab(&mut self) {
464        if !self.tabs.is_empty() {
465            if self.active_tab_index == 0 {
466                self.active_tab_index = self.tabs.len() - 1;
467            } else {
468                self.active_tab_index -= 1;
469            }
470        }
471    }
472}
473
474impl Default for App {
475    fn default() -> Self {
476        Self::new()
477    }
478}
479
480impl App {
481    // ── Theme helpers ────────────────────────────────────────────────────
482
483    pub fn cycle_theme_next(&mut self) {
484        let count = 27; // number of themes
485        self.current_theme_index = (self.current_theme_index + 1) % count;
486        self.theme_list_state.select(Some(self.current_theme_index));
487        self.tab_mut().status_message = Some(format!("Theme: {}", self.current_theme_name()));
488    }
489
490    pub fn cycle_theme_prev(&mut self) {
491        let count = 27;
492        if self.current_theme_index == 0 {
493            self.current_theme_index = count - 1;
494        } else {
495            self.current_theme_index -= 1;
496        }
497        self.theme_list_state.select(Some(self.current_theme_index));
498        self.tab_mut().status_message = Some(format!("Theme: {}", self.current_theme_name()));
499    }
500
501    pub fn current_theme_name(&self) -> &'static str {
502        gitkraft_core::THEME_NAMES
503            .get(self.current_theme_index)
504            .copied()
505            .unwrap_or("Default")
506    }
507
508    /// Return the `UiTheme` for the currently selected theme index.
509    pub fn theme(&self) -> crate::features::theme::palette::UiTheme {
510        crate::features::theme::palette::theme_for_index(self.current_theme_index)
511    }
512
513    /// Persist the current theme selection to disk.
514    pub fn save_theme(&self) {
515        let _ = gitkraft_core::features::persistence::save_theme(self.current_theme_name());
516    }
517
518    /// Persist the paths of all open tabs for session restore.
519    pub fn save_session(&self) {
520        let paths: Vec<std::path::PathBuf> = self
521            .tabs
522            .iter()
523            .filter_map(|t| t.repo_path.clone())
524            .collect();
525        let active = self.active_tab_index;
526        let _ = gitkraft_core::features::persistence::save_session(&paths, active);
527    }
528
529    // ── High-level operations ────────────────────────────────────────────
530
531    pub fn open_repo(&mut self, path: PathBuf) {
532        self.tab_mut().error_message = None;
533        self.tab_mut().status_message = Some("Opening repository…".into());
534        self.tab_mut().is_loading = true;
535        self.tab_mut().repo_path = Some(path.clone());
536        self.screen = AppScreen::Main;
537
538        let tx = self.bg_tx.clone();
539        std::thread::spawn(move || {
540            let result = load_repo_blocking(&path);
541            let _ = tx.send(BackgroundResult::RepoLoaded { path, result });
542        });
543        self.save_session();
544    }
545
546    pub fn refresh(&mut self) {
547        self.tab_mut().error_message = None;
548        self.tab_mut().is_loading = true;
549        self.tab_mut().status_message = Some("Refreshing…".into());
550
551        let path = match self.tab().repo_path.clone() {
552            Some(p) => p,
553            None => {
554                self.tab_mut().error_message = Some("No repository open".into());
555                self.tab_mut().is_loading = false;
556                return;
557            }
558        };
559
560        let tx = self.bg_tx.clone();
561        std::thread::spawn(move || {
562            let result = load_repo_blocking(&path);
563            let _ = tx.send(BackgroundResult::RepoLoaded { path, result });
564        });
565    }
566
567    /// Process any pending results from background tasks.
568    /// Call this once per tick in the event loop.
569    pub fn poll_background(&mut self) {
570        while let Ok(result) = self.bg_rx.try_recv() {
571            match result {
572                BackgroundResult::RepoLoaded {
573                    path: loaded_path,
574                    result: res,
575                } => {
576                    // Find the tab that initiated this load by matching repo_path.
577                    let tab_idx = self
578                        .tabs
579                        .iter()
580                        .position(|t| t.repo_path.as_ref() == Some(&loaded_path))
581                        .unwrap_or(self.active_tab_index);
582
583                    self.tabs[tab_idx].is_loading = false;
584                    match res {
585                        Ok(payload) => {
586                            let canonical = payload.info.workdir.clone().unwrap_or_else(|| {
587                                self.tabs[tab_idx].repo_path.clone().unwrap_or_default()
588                            });
589                            self.tabs[tab_idx].repo_path = Some(canonical.clone());
590
591                            // Persist
592                            let _ = gitkraft_core::features::persistence::record_repo_opened(
593                                &canonical,
594                            );
595                            if let Ok(settings) =
596                                gitkraft_core::features::persistence::load_settings()
597                            {
598                                self.recent_repos = settings.recent_repos;
599                            }
600
601                            let tab = &mut self.tabs[tab_idx];
602                            tab.repo_info = Some(payload.info);
603                            tab.branches = payload.branches;
604                            clamp_list_state(&mut tab.branch_list_state, tab.branches.len());
605                            tab.graph_rows = payload.graph_rows;
606                            tab.commits = payload.commits;
607                            clamp_list_state(&mut tab.commit_list_state, tab.commits.len());
608                            tab.unstaged_changes = payload.unstaged;
609                            clamp_list_state(
610                                &mut tab.unstaged_list_state,
611                                tab.unstaged_changes.len(),
612                            );
613                            tab.staged_changes = payload.staged;
614                            clamp_list_state(&mut tab.staged_list_state, tab.staged_changes.len());
615                            tab.stashes = payload.stashes;
616                            clamp_list_state(&mut tab.stash_list_state, tab.stashes.len());
617                            tab.remotes = payload.remotes;
618                            tab.status_message = Some("Repository loaded".into());
619                            self.screen = AppScreen::Main;
620                            self.save_session();
621                        }
622                        Err(e) => {
623                            self.tabs[tab_idx].error_message = Some(e);
624                            self.tabs[tab_idx].status_message = None;
625                        }
626                    }
627                }
628                BackgroundResult::FetchDone(res) => {
629                    self.tab_mut().is_loading = false;
630                    match res {
631                        Ok(()) => {
632                            self.tab_mut().status_message = Some("Fetched from origin".into());
633                            self.refresh();
634                        }
635                        Err(e) => self.tab_mut().error_message = Some(format!("fetch: {e}")),
636                    }
637                }
638                BackgroundResult::CommitDiffLoaded(res) => {
639                    self.tab_mut().is_loading = false;
640                    match res {
641                        Ok(diffs) => {
642                            if diffs.is_empty() {
643                                let tab = self.tab_mut();
644                                tab.selected_diff = None;
645                                tab.commit_diffs.clear();
646                                tab.commit_diff_file_index = 0;
647                                tab.status_message = Some("No changes in this commit".into());
648                            } else {
649                                let tab = self.tab_mut();
650                                tab.commit_diffs = diffs.clone();
651                                tab.commit_diff_file_index = 0;
652                                tab.selected_diff = Some(diffs[0].clone());
653                                tab.diff_scroll = 0;
654                                if diffs.len() > 1 {
655                                    tab.status_message = Some(format!(
656                                        "Showing file 1/{} — use h/l to switch files",
657                                        diffs.len()
658                                    ));
659                                }
660                            }
661                        }
662                        Err(e) => self.tab_mut().error_message = Some(format!("commit diff: {e}")),
663                    }
664                }
665                BackgroundResult::CommitFileListLoaded(res) => {
666                    self.tab_mut().is_loading = false;
667                    match res {
668                        Ok(files) => {
669                            let count = files.len();
670                            let tab = self.tab_mut();
671                            tab.commit_files = files;
672                            tab.commit_diffs.clear();
673                            tab.commit_diff_file_index = 0;
674                            tab.selected_diff = None;
675                            tab.diff_scroll = 0;
676
677                            if count == 0 {
678                                tab.status_message = Some("No changes in this commit".into());
679                            } else {
680                                tab.status_message = Some(format!("{count} file(s) changed"));
681                                // Auto-load the first file's diff
682                                let first_path = tab.commit_files[0].display_path().to_string();
683                                self.load_single_file_diff(first_path);
684                            }
685                        }
686                        Err(e) => self.tab_mut().error_message = Some(format!("file list: {e}")),
687                    }
688                }
689                BackgroundResult::SingleFileDiffLoaded(res) => {
690                    self.tab_mut().is_loading = false;
691                    match res {
692                        Ok(diff) => {
693                            let tab = self.tab_mut();
694                            // Store in commit_diffs for the file list sidebar.
695                            if tab.commit_diffs.len() <= tab.commit_diff_file_index {
696                                tab.commit_diffs.push(diff.clone());
697                            } else {
698                                tab.commit_diffs[tab.commit_diff_file_index] = diff.clone();
699                            }
700                            tab.selected_diff = Some(diff);
701                            tab.diff_scroll = 0;
702                            if tab.commit_files.len() > 1 {
703                                tab.status_message = Some(format!(
704                                    "File {}/{} — use h/l to switch files",
705                                    tab.commit_diff_file_index + 1,
706                                    tab.commit_files.len()
707                                ));
708                            }
709                        }
710                        Err(e) => self.tab_mut().error_message = Some(format!("file diff: {e}")),
711                    }
712                }
713                BackgroundResult::StagingRefreshed(res) => {
714                    self.tab_mut().is_loading = false;
715                    match res {
716                        Ok(payload) => self.apply_staging_payload(payload),
717                        Err(e) => {
718                            self.tab_mut().error_message = Some(format!("staging refresh: {e}"))
719                        }
720                    }
721                }
722                BackgroundResult::OperationDone {
723                    ok_message,
724                    err_message,
725                    needs_refresh,
726                    needs_staging_refresh,
727                } => {
728                    self.tab_mut().is_loading = false;
729                    if let Some(msg) = err_message {
730                        self.tab_mut().error_message = Some(msg);
731                    } else if let Some(msg) = ok_message {
732                        self.tab_mut().status_message = Some(msg);
733                    }
734                    if needs_refresh {
735                        self.refresh();
736                    } else if needs_staging_refresh {
737                        self.refresh_staging();
738                    }
739                }
740                BackgroundResult::SearchResults(res) => match res {
741                    Ok(results) => {
742                        self.tab_mut().search_results = results;
743                        let count = self.tab().search_results.len();
744                        self.tab_mut().status_message = Some(format!("{count} result(s) found"));
745                    }
746                    Err(e) => {
747                        self.tab_mut().error_message = Some(format!("Search failed: {e}"));
748                    }
749                },
750            }
751        }
752    }
753
754    /// Reload only the staging area (unstaged + staged diffs).
755    /// Check if enough time has passed and trigger a staging refresh.
756    pub fn maybe_auto_refresh(&mut self) {
757        if self.tab().repo_path.is_some()
758            && !self.tab().is_loading
759            && self.last_auto_refresh.elapsed() >= std::time::Duration::from_secs(3)
760        {
761            self.last_auto_refresh = std::time::Instant::now();
762            self.refresh_staging();
763        }
764    }
765
766    pub fn refresh_staging(&mut self) {
767        let repo_path = match self.tab().repo_path.clone() {
768            Some(p) => p,
769            None => {
770                self.tab_mut().error_message = Some("No repository open".into());
771                return;
772            }
773        };
774        let tx = self.bg_tx.clone();
775        std::thread::spawn(move || {
776            let res = (|| {
777                let repo = open_repo_str(&repo_path)?;
778                let unstaged = gitkraft_core::features::diff::get_working_dir_diff(&repo)
779                    .map_err(|e| e.to_string())?;
780                let staged = gitkraft_core::features::diff::get_staged_diff(&repo)
781                    .map_err(|e| e.to_string())?;
782                Ok::<_, String>(StagingPayload { unstaged, staged })
783            })();
784            let _ = tx.send(BackgroundResult::StagingRefreshed(res));
785        });
786    }
787
788    fn apply_staging_payload(&mut self, payload: StagingPayload) {
789        self.tab_mut().selected_unstaged.clear();
790        self.tab_mut().selected_staged.clear();
791        let tab = self.tab_mut();
792        tab.unstaged_changes = payload.unstaged;
793        if tab.unstaged_changes.is_empty() {
794            tab.unstaged_list_state.select(None);
795        } else if tab.unstaged_list_state.selected().is_none() {
796            tab.unstaged_list_state.select(Some(0));
797        } else if let Some(i) = tab.unstaged_list_state.selected() {
798            if i >= tab.unstaged_changes.len() {
799                tab.unstaged_list_state
800                    .select(Some(tab.unstaged_changes.len() - 1));
801            }
802        }
803
804        tab.staged_changes = payload.staged;
805        if tab.staged_changes.is_empty() {
806            tab.staged_list_state.select(None);
807        } else if tab.staged_list_state.selected().is_none() {
808            tab.staged_list_state.select(Some(0));
809        } else if let Some(i) = tab.staged_list_state.selected() {
810            if i >= tab.staged_changes.len() {
811                tab.staged_list_state
812                    .select(Some(tab.staged_changes.len() - 1));
813            }
814        }
815    }
816
817    // ── Staging operations ───────────────────────────────────────────────
818
819    pub fn stage_selected(&mut self) {
820        let idx = match self.tab().unstaged_list_state.selected() {
821            Some(i) => i,
822            None => {
823                self.tab_mut().status_message = Some("No unstaged file selected".into());
824                return;
825            }
826        };
827        let file_path = self.unstaged_file_path(idx);
828        bg_op!(self, "Staging…", staging, |repo_path| {
829            let repo = open_repo_str(&repo_path)?;
830            gitkraft_core::features::staging::stage_file(&repo, &file_path)
831                .map_err(|e| format!("stage: {e}"))?;
832            Ok(format!("Staged: {file_path}"))
833        });
834    }
835
836    pub fn unstage_selected(&mut self) {
837        let idx = match self.tab().staged_list_state.selected() {
838            Some(i) => i,
839            None => {
840                self.tab_mut().status_message = Some("No staged file selected".into());
841                return;
842            }
843        };
844        let file_path = self.staged_file_path(idx);
845        bg_op!(self, "Unstaging…", staging, |repo_path| {
846            let repo = open_repo_str(&repo_path)?;
847            gitkraft_core::features::staging::unstage_file(&repo, &file_path)
848                .map_err(|e| format!("unstage: {e}"))?;
849            Ok(format!("Unstaged: {file_path}"))
850        });
851    }
852
853    pub fn stage_all(&mut self) {
854        bg_op!(self, "Staging all…", staging, |repo_path| {
855            let repo = open_repo_str(&repo_path)?;
856            gitkraft_core::features::staging::stage_all(&repo)
857                .map_err(|e| format!("stage all: {e}"))?;
858            Ok("Staged all files".into())
859        });
860    }
861
862    pub fn unstage_all(&mut self) {
863        bg_op!(self, "Unstaging all…", staging, |repo_path| {
864            let repo = open_repo_str(&repo_path)?;
865            gitkraft_core::features::staging::unstage_all(&repo)
866                .map_err(|e| format!("unstage all: {e}"))?;
867            Ok("Unstaged all files".into())
868        });
869    }
870
871    pub fn discard_selected(&mut self) {
872        let idx = match self.tab().unstaged_list_state.selected() {
873            Some(i) => i,
874            None => {
875                self.tab_mut().status_message = Some("No unstaged file selected".into());
876                return;
877            }
878        };
879        let file_path = self.unstaged_file_path(idx);
880        self.tab_mut().confirm_discard = false;
881        bg_op!(self, "Discarding…", staging, |repo_path| {
882            let repo = open_repo_str(&repo_path)?;
883            gitkraft_core::features::staging::discard_file_changes(&repo, &file_path)
884                .map_err(|e| format!("discard: {e}"))?;
885            Ok(format!("Discarded changes: {file_path}"))
886        });
887    }
888
889    /// Stage multiple files at once.
890    pub fn stage_files(&mut self, paths: Vec<String>) {
891        let count = paths.len();
892        bg_op!(
893            self,
894            format!("Staging {count} file(s)…"),
895            staging,
896            |repo_path| {
897                let repo = open_repo_str(&repo_path)?;
898                for fp in &paths {
899                    gitkraft_core::features::staging::stage_file(&repo, fp)
900                        .map_err(|e| e.to_string())?;
901                }
902                Ok(format!("{count} file(s) staged"))
903            }
904        );
905    }
906
907    /// Unstage multiple files at once.
908    pub fn unstage_files(&mut self, paths: Vec<String>) {
909        let count = paths.len();
910        bg_op!(
911            self,
912            format!("Unstaging {count} file(s)…"),
913            staging,
914            |repo_path| {
915                let repo = open_repo_str(&repo_path)?;
916                for fp in &paths {
917                    gitkraft_core::features::staging::unstage_file(&repo, fp)
918                        .map_err(|e| e.to_string())?;
919                }
920                Ok(format!("{count} file(s) unstaged"))
921            }
922        );
923    }
924
925    /// Discard changes for multiple files at once.
926    pub fn discard_files(&mut self, paths: Vec<String>) {
927        let count = paths.len();
928        bg_op!(
929            self,
930            format!("Discarding {count} file(s)…"),
931            staging,
932            |repo_path| {
933                let repo = open_repo_str(&repo_path)?;
934                for fp in &paths {
935                    gitkraft_core::features::staging::discard_file_changes(&repo, fp)
936                        .map_err(|e| e.to_string())?;
937                }
938                Ok(format!("{count} file(s) discarded"))
939            }
940        );
941    }
942
943    // ── Commit ───────────────────────────────────────────────────────────
944
945    pub fn create_commit(&mut self) {
946        let msg = self.input_buffer.trim().to_string();
947        if msg.is_empty() {
948            self.tab_mut().error_message = Some("Commit message cannot be empty".into());
949            return;
950        }
951        self.input_buffer.clear();
952        bg_op!(self, "Committing…", refresh, |repo_path| {
953            let repo = open_repo_str(&repo_path)?;
954            let info = gitkraft_core::features::commits::create_commit(&repo, &msg)
955                .map_err(|e| format!("commit: {e}"))?;
956            Ok(format!("Committed: {} {}", info.short_oid, info.summary))
957        });
958    }
959
960    // ── Branches ─────────────────────────────────────────────────────────
961
962    pub fn checkout_selected_branch(&mut self) {
963        let idx = match self.tab().branch_list_state.selected() {
964            Some(i) => i,
965            None => return,
966        };
967        if idx >= self.tab().branches.len() {
968            return;
969        }
970        let name = self.tab().branches[idx].name.clone();
971        if self.tab().branches[idx].is_head {
972            self.tab_mut().status_message = Some(format!("Already on '{name}'"));
973            return;
974        }
975        bg_op!(self, "Checking out…", refresh, |repo_path| {
976            let repo = open_repo_str(&repo_path)?;
977            gitkraft_core::features::branches::checkout_branch(&repo, &name)
978                .map_err(|e| format!("checkout: {e}"))?;
979            Ok(format!("Checked out: {name}"))
980        });
981    }
982
983    pub fn create_branch(&mut self) {
984        let name = self.input_buffer.trim().to_string();
985        if name.is_empty() {
986            self.tab_mut().error_message = Some("Branch name cannot be empty".into());
987            return;
988        }
989        self.input_buffer.clear();
990        bg_op!(self, "Creating branch…", refresh, |repo_path| {
991            let repo = open_repo_str(&repo_path)?;
992            gitkraft_core::features::branches::create_branch(&repo, &name)
993                .map_err(|e| format!("create branch: {e}"))?;
994            Ok(format!("Created branch: {name}"))
995        });
996    }
997
998    pub fn delete_selected_branch(&mut self) {
999        let idx = match self.tab().branch_list_state.selected() {
1000            Some(i) => i,
1001            None => return,
1002        };
1003        if idx >= self.tab().branches.len() {
1004            return;
1005        }
1006        if self.tab().branches[idx].is_head {
1007            self.tab_mut().error_message = Some("Cannot delete the current branch".into());
1008            return;
1009        }
1010        let name = self.tab().branches[idx].name.clone();
1011        bg_op!(self, "Deleting branch…", refresh, |repo_path| {
1012            let repo = open_repo_str(&repo_path)?;
1013            gitkraft_core::features::branches::delete_branch(&repo, &name)
1014                .map_err(|e| format!("delete branch: {e}"))?;
1015            Ok(format!("Deleted branch: {name}"))
1016        });
1017    }
1018
1019    // ── Stash ────────────────────────────────────────────────────────────
1020
1021    pub fn stash_save(&mut self) {
1022        let msg = if self.tab().stash_message_buffer.trim().is_empty() {
1023            None
1024        } else {
1025            Some(self.tab().stash_message_buffer.trim().to_string())
1026        };
1027        self.tab_mut().stash_message_buffer.clear();
1028        bg_op!(self, "Stashing…", refresh, |repo_path| {
1029            let mut repo = open_repo_str(&repo_path)?;
1030            let entry = gitkraft_core::features::stash::stash_save(&mut repo, msg.as_deref())
1031                .map_err(|e| format!("stash save: {e}"))?;
1032            Ok(format!("Stashed: {}", entry.message))
1033        });
1034    }
1035
1036    pub fn stash_pop_selected(&mut self) {
1037        let idx = self.tab().stash_list_state.selected().unwrap_or(0);
1038        if idx >= self.tab().stashes.len() {
1039            self.tab_mut().error_message = Some("No stash selected".into());
1040            return;
1041        }
1042        bg_op!(self, "Popping stash…", refresh, |repo_path| {
1043            let mut repo = open_repo_str(&repo_path)?;
1044            gitkraft_core::features::stash::stash_pop(&mut repo, idx)
1045                .map_err(|e| format!("stash pop: {e}"))?;
1046            Ok(format!("Stash @{{{idx}}} popped"))
1047        });
1048    }
1049
1050    pub fn stash_drop_selected(&mut self) {
1051        let idx = self.tab().stash_list_state.selected().unwrap_or(0);
1052        if idx >= self.tab().stashes.len() {
1053            self.tab_mut().error_message = Some("No stash to drop".into());
1054            return;
1055        }
1056        bg_op!(self, "Dropping stash…", refresh, |repo_path| {
1057            let mut repo = open_repo_str(&repo_path)?;
1058            gitkraft_core::features::stash::stash_drop(&mut repo, idx)
1059                .map_err(|e| format!("stash drop: {e}"))?;
1060            Ok(format!("Stash @{{{idx}}} dropped"))
1061        });
1062    }
1063
1064    // ── Diff ─────────────────────────────────────────────────────────────
1065
1066    /// Load the file list for the currently selected commit (phase 1 of two-phase loading).
1067    pub fn load_commit_diff(&mut self) {
1068        let idx = match self.tab().commit_list_state.selected() {
1069            Some(i) => i,
1070            None => return,
1071        };
1072        if idx >= self.tab().commits.len() {
1073            return;
1074        }
1075        let oid = self.tab().commits[idx].oid.clone();
1076        self.tab_mut().selected_commit_oid = Some(oid.clone());
1077        bg_task!(
1078            self,
1079            "Loading files…",
1080            BackgroundResult::CommitFileListLoaded,
1081            |repo_path| {
1082                let repo = open_repo_str(&repo_path)?;
1083                gitkraft_core::features::diff::get_commit_file_list(&repo, &oid)
1084                    .map_err(|e| e.to_string())
1085            }
1086        );
1087    }
1088
1089    /// Load the diff for a single file in the selected commit (phase 2).
1090    pub fn load_single_file_diff(&mut self, file_path: String) {
1091        let oid = match self.tab().selected_commit_oid.clone() {
1092            Some(o) => o,
1093            None => return,
1094        };
1095        bg_task!(
1096            self,
1097            "Loading diff…",
1098            BackgroundResult::SingleFileDiffLoaded,
1099            |repo_path| {
1100                let repo = open_repo_str(&repo_path)?;
1101                gitkraft_core::features::diff::get_single_file_diff(&repo, &oid, &file_path)
1102                    .map_err(|e| e.to_string())
1103            }
1104        );
1105    }
1106
1107    /// Switch to the next file in the commit diff list.
1108    pub fn next_diff_file(&mut self) {
1109        if self.tab().commit_files.is_empty() {
1110            return;
1111        }
1112        let new_index = (self.tab().commit_diff_file_index + 1) % self.tab().commit_files.len();
1113        self.tab_mut().commit_diff_file_index = new_index;
1114        let file_path = self.tab().commit_files[self.tab().commit_diff_file_index]
1115            .display_path()
1116            .to_string();
1117        self.tab_mut().diff_scroll = 0;
1118        self.tab_mut().status_message = Some(format!(
1119            "File {}/{}",
1120            self.tab().commit_diff_file_index + 1,
1121            self.tab().commit_files.len()
1122        ));
1123        self.load_single_file_diff(file_path);
1124    }
1125
1126    /// Switch to the previous file in the commit diff list.
1127    pub fn prev_diff_file(&mut self) {
1128        if self.tab().commit_files.is_empty() {
1129            return;
1130        }
1131        let new_index = if self.tab().commit_diff_file_index == 0 {
1132            self.tab().commit_files.len() - 1
1133        } else {
1134            self.tab().commit_diff_file_index - 1
1135        };
1136        self.tab_mut().commit_diff_file_index = new_index;
1137        let file_path = self.tab().commit_files[self.tab().commit_diff_file_index]
1138            .display_path()
1139            .to_string();
1140        self.tab_mut().diff_scroll = 0;
1141        self.tab_mut().status_message = Some(format!(
1142            "File {}/{}",
1143            self.tab().commit_diff_file_index + 1,
1144            self.tab().commit_files.len()
1145        ));
1146        self.load_single_file_diff(file_path);
1147    }
1148
1149    /// Close the current repository and return to the welcome screen.
1150    /// Search commits by query string.
1151    pub fn search_commits(&mut self, query: String) {
1152        let repo_path = match self.tab().repo_path.clone() {
1153            Some(p) => p,
1154            None => return,
1155        };
1156        self.tab_mut().search_query = query.clone();
1157        if query.trim().len() < 2 {
1158            self.tab_mut().search_results.clear();
1159            return;
1160        }
1161        let tx = self.bg_tx.clone();
1162        std::thread::spawn(move || {
1163            let res = (|| {
1164                let repo = open_repo_str(&repo_path)?;
1165                gitkraft_core::features::log::search_commits(&repo, &query, 100)
1166                    .map_err(|e| e.to_string())
1167            })();
1168            let _ = tx.send(BackgroundResult::SearchResults(res));
1169        });
1170    }
1171
1172    /// Load the diff for a commit by its OID (used by search results).
1173    pub fn load_commit_diff_by_oid(&mut self) {
1174        let oid = match self.tab().selected_commit_oid.clone() {
1175            Some(o) => o,
1176            None => return,
1177        };
1178        bg_task!(
1179            self,
1180            "Loading files…",
1181            BackgroundResult::CommitFileListLoaded,
1182            |repo_path| {
1183                let repo = open_repo_str(&repo_path)?;
1184                gitkraft_core::features::diff::get_commit_file_list(&repo, &oid)
1185                    .map_err(|e| e.to_string())
1186            }
1187        );
1188    }
1189
1190    pub fn close_repo(&mut self) {
1191        self.tabs[self.active_tab_index] = RepoTab::new();
1192        self.input_buffer.clear();
1193        self.show_theme_panel = false;
1194        self.show_options_panel = false;
1195        self.screen = AppScreen::Welcome;
1196        // Reload recent repos
1197        if let Ok(settings) = gitkraft_core::features::persistence::load_settings() {
1198            self.recent_repos = settings.recent_repos;
1199        }
1200        self.save_session();
1201    }
1202
1203    /// Populate `browser_entries` with the contents of `browser_dir`.
1204    pub fn refresh_browser(&mut self) {
1205        let mut entries = Vec::new();
1206        if let Ok(read_dir) = std::fs::read_dir(&self.browser_dir) {
1207            for entry in read_dir.flatten() {
1208                let path = entry.path();
1209                // Show only directories to help navigate & identify repos
1210                if path.is_dir() {
1211                    entries.push(path);
1212                }
1213            }
1214        }
1215        entries.sort_by(|a, b| {
1216            let a_name = a
1217                .file_name()
1218                .unwrap_or_default()
1219                .to_string_lossy()
1220                .to_lowercase();
1221            let b_name = b
1222                .file_name()
1223                .unwrap_or_default()
1224                .to_string_lossy()
1225                .to_lowercase();
1226            // Dot-dirs last
1227            let a_dot = a_name.starts_with('.');
1228            let b_dot = b_name.starts_with('.');
1229            a_dot.cmp(&b_dot).then(a_name.cmp(&b_name))
1230        });
1231        self.browser_entries = entries;
1232        self.browser_list_state = ListState::default();
1233        if !self.browser_entries.is_empty() {
1234            self.browser_list_state.select(Some(0));
1235        }
1236    }
1237
1238    /// Open the directory browser starting from a given path.
1239    pub fn open_browser(&mut self, start: PathBuf) {
1240        self.browser_return_screen = self.screen.clone();
1241        self.browser_dir = start;
1242        self.refresh_browser();
1243        self.screen = AppScreen::DirBrowser;
1244    }
1245    /// Load the diff for a selected staging file into the diff pane.
1246    /// Open the currently selected staging file in the configured editor.
1247    pub fn open_selected_in_editor(&mut self) {
1248        if matches!(self.editor, gitkraft_core::Editor::None) {
1249            self.tab_mut().status_message =
1250                Some("No editor configured — press E to choose one".into());
1251            return;
1252        }
1253        let file_path = match self.tab().staging_focus {
1254            StagingFocus::Unstaged => self
1255                .tab()
1256                .unstaged_list_state
1257                .selected()
1258                .and_then(|idx| self.tab().unstaged_changes.get(idx))
1259                .map(|d| d.display_path().to_string()),
1260            StagingFocus::Staged => self
1261                .tab()
1262                .staged_list_state
1263                .selected()
1264                .and_then(|idx| self.tab().staged_changes.get(idx))
1265                .map(|d| d.display_path().to_string()),
1266        };
1267        if let (Some(fp), Some(repo_path)) = (file_path, self.tab().repo_path.as_ref()) {
1268            let full_path = repo_path.join(&fp);
1269            match self.editor.open_file(&full_path) {
1270                Ok(()) => {
1271                    self.tab_mut().status_message =
1272                        Some(format!("Opened {} in {}", fp, self.editor));
1273                }
1274                Err(e) => {
1275                    self.tab_mut().error_message = Some(format!("Failed to open editor: {e}"));
1276                }
1277            }
1278        }
1279    }
1280
1281    pub fn load_staging_diff(&mut self) {
1282        match self.tab().staging_focus {
1283            StagingFocus::Unstaged => {
1284                if let Some(idx) = self.tab().unstaged_list_state.selected() {
1285                    if idx < self.tab().unstaged_changes.len() {
1286                        let diff = self.tab().unstaged_changes[idx].clone();
1287                        let tab = self.tab_mut();
1288                        tab.selected_diff = Some(diff);
1289                        tab.diff_scroll = 0;
1290                    }
1291                }
1292            }
1293            StagingFocus::Staged => {
1294                if let Some(idx) = self.tab().staged_list_state.selected() {
1295                    if idx < self.tab().staged_changes.len() {
1296                        let diff = self.tab().staged_changes[idx].clone();
1297                        let tab = self.tab_mut();
1298                        tab.selected_diff = Some(diff);
1299                        tab.diff_scroll = 0;
1300                    }
1301                }
1302            }
1303        }
1304    }
1305
1306    // ── Remote ───────────────────────────────────────────────────────────
1307
1308    pub fn fetch_remote(&mut self) {
1309        let repo_path = match self.tab().repo_path.clone() {
1310            Some(p) => p,
1311            None => return,
1312        };
1313        self.tab_mut().is_loading = true;
1314        self.tab_mut().status_message = Some("Fetching…".into());
1315        let tx = self.bg_tx.clone();
1316        std::thread::spawn(move || {
1317            let res = (|| {
1318                let repo = open_repo_str(&repo_path)?;
1319                gitkraft_core::features::remotes::fetch_remote(&repo, "origin")
1320                    .map_err(|e| e.to_string())
1321            })();
1322            let _ = tx.send(BackgroundResult::FetchDone(res));
1323        });
1324    }
1325
1326    pub fn pull_rebase(&mut self) {
1327        let repo_path = match self.tab().repo_path.clone() {
1328            Some(p) => p,
1329            None => return,
1330        };
1331        self.tab_mut().is_loading = true;
1332        self.tab_mut().status_message = Some("Pulling (rebase)…".into());
1333        let tx = self.bg_tx.clone();
1334        std::thread::spawn(move || {
1335            let workdir = std::path::Path::new(&repo_path);
1336            let res = gitkraft_core::features::branches::pull_rebase(workdir, "origin");
1337            let _ = tx.send(BackgroundResult::OperationDone {
1338                ok_message: res
1339                    .as_ref()
1340                    .ok()
1341                    .map(|_| "Pulled (rebase) from origin".into()),
1342                err_message: res.err().map(|e| format!("pull: {e}")),
1343                needs_refresh: true,
1344                needs_staging_refresh: false,
1345            });
1346        });
1347    }
1348
1349    pub fn push_branch(&mut self) {
1350        let repo_path = match self.tab().repo_path.clone() {
1351            Some(p) => p,
1352            None => return,
1353        };
1354        let branch = match self
1355            .tab()
1356            .repo_info
1357            .as_ref()
1358            .and_then(|i| i.head_branch.clone())
1359        {
1360            Some(b) => b,
1361            None => {
1362                self.tab_mut().error_message = Some("No branch checked out".into());
1363                return;
1364            }
1365        };
1366        self.tab_mut().is_loading = true;
1367        self.tab_mut().status_message = Some(format!("Pushing {branch}…"));
1368        let tx = self.bg_tx.clone();
1369        std::thread::spawn(move || {
1370            let workdir = std::path::Path::new(&repo_path);
1371            let res = gitkraft_core::features::branches::push_branch(workdir, &branch, "origin");
1372            let _ = tx.send(BackgroundResult::OperationDone {
1373                ok_message: res
1374                    .as_ref()
1375                    .ok()
1376                    .map(|_| format!("Pushed {branch} to origin")),
1377                err_message: res.err().map(|e| format!("push: {e}")),
1378                needs_refresh: true,
1379                needs_staging_refresh: false,
1380            });
1381        });
1382    }
1383
1384    pub fn force_push_branch(&mut self) {
1385        let repo_path = match self.tab().repo_path.clone() {
1386            Some(p) => p,
1387            None => return,
1388        };
1389        let branch = match self
1390            .tab()
1391            .repo_info
1392            .as_ref()
1393            .and_then(|i| i.head_branch.clone())
1394        {
1395            Some(b) => b,
1396            None => {
1397                self.tab_mut().error_message = Some("No branch checked out".into());
1398                return;
1399            }
1400        };
1401        self.tab_mut().is_loading = true;
1402        self.tab_mut().status_message = Some(format!("Force pushing {branch}…"));
1403        let tx = self.bg_tx.clone();
1404        std::thread::spawn(move || {
1405            let workdir = std::path::Path::new(&repo_path);
1406            let res =
1407                gitkraft_core::features::branches::force_push_branch(workdir, &branch, "origin");
1408            let _ = tx.send(BackgroundResult::OperationDone {
1409                ok_message: res
1410                    .as_ref()
1411                    .ok()
1412                    .map(|_| format!("Force pushed {branch} to origin")),
1413                err_message: res.err().map(|e| format!("force push: {e}")),
1414                needs_refresh: true,
1415                needs_staging_refresh: false,
1416            });
1417        });
1418    }
1419
1420    pub fn merge_selected_branch(&mut self) {
1421        let repo_path = match self.tab().repo_path.clone() {
1422            Some(p) => p,
1423            None => return,
1424        };
1425        let branch_name = match self.tab().branch_list_state.selected() {
1426            Some(idx) => match self.tab().branches.get(idx) {
1427                Some(b) => b.name.clone(),
1428                None => return,
1429            },
1430            None => return,
1431        };
1432        self.tab_mut().is_loading = true;
1433        self.tab_mut().status_message = Some(format!("Merging {branch_name}…"));
1434        let tx = self.bg_tx.clone();
1435        std::thread::spawn(move || {
1436            let res = (|| {
1437                let repo = open_repo_str(&repo_path)?;
1438                gitkraft_core::features::branches::merge_branch(&repo, &branch_name)
1439                    .map_err(|e| e.to_string())
1440            })();
1441            let _ = tx.send(BackgroundResult::OperationDone {
1442                ok_message: res.as_ref().ok().map(|_| format!("Merged {branch_name}")),
1443                err_message: res.err(),
1444                needs_refresh: true,
1445                needs_staging_refresh: false,
1446            });
1447        });
1448    }
1449
1450    pub fn rebase_onto_selected_branch(&mut self) {
1451        let repo_path = match self.tab().repo_path.clone() {
1452            Some(p) => p,
1453            None => return,
1454        };
1455        let branch_name = match self.tab().branch_list_state.selected() {
1456            Some(idx) => match self.tab().branches.get(idx) {
1457                Some(b) => b.name.clone(),
1458                None => return,
1459            },
1460            None => return,
1461        };
1462        self.tab_mut().is_loading = true;
1463        self.tab_mut().status_message = Some(format!("Rebasing onto {branch_name}…"));
1464        let tx = self.bg_tx.clone();
1465        std::thread::spawn(move || {
1466            let workdir = std::path::Path::new(&repo_path);
1467            let res = gitkraft_core::features::branches::rebase_onto(workdir, &branch_name);
1468            let _ = tx.send(BackgroundResult::OperationDone {
1469                ok_message: res
1470                    .as_ref()
1471                    .ok()
1472                    .map(|_| format!("Rebased onto {branch_name}")),
1473                err_message: res.err().map(|e| format!("rebase: {e}")),
1474                needs_refresh: true,
1475                needs_staging_refresh: false,
1476            });
1477        });
1478    }
1479
1480    pub fn revert_selected_commit(&mut self) {
1481        let repo_path = match self.tab().repo_path.clone() {
1482            Some(p) => p,
1483            None => return,
1484        };
1485        let oid = match self.tab().commit_list_state.selected() {
1486            Some(idx) => match self.tab().commits.get(idx) {
1487                Some(c) => c.oid.clone(),
1488                None => return,
1489            },
1490            None => return,
1491        };
1492        self.tab_mut().is_loading = true;
1493        self.tab_mut().status_message = Some("Reverting commit…".into());
1494        let tx = self.bg_tx.clone();
1495        std::thread::spawn(move || {
1496            let workdir = std::path::Path::new(&repo_path);
1497            let res = gitkraft_core::features::repo::revert_commit(workdir, &oid);
1498            let _ = tx.send(BackgroundResult::OperationDone {
1499                ok_message: res.as_ref().ok().map(|_| format!("Reverted {}", &oid[..7])),
1500                err_message: res.err().map(|e| format!("revert: {e}")),
1501                needs_refresh: true,
1502                needs_staging_refresh: false,
1503            });
1504        });
1505    }
1506
1507    pub fn reset_to_selected_commit(&mut self, mode: &str) {
1508        let repo_path = match self.tab().repo_path.clone() {
1509            Some(p) => p,
1510            None => return,
1511        };
1512        let oid = match self.tab().commit_list_state.selected() {
1513            Some(idx) => match self.tab().commits.get(idx) {
1514                Some(c) => c.oid.clone(),
1515                None => return,
1516            },
1517            None => return,
1518        };
1519        let mode_owned = mode.to_string();
1520        self.tab_mut().is_loading = true;
1521        self.tab_mut().status_message = Some(format!("Resetting ({mode})…"));
1522        let tx = self.bg_tx.clone();
1523        std::thread::spawn(move || {
1524            let workdir = std::path::Path::new(&repo_path);
1525            let res = gitkraft_core::features::repo::reset_to_commit(workdir, &oid, &mode_owned);
1526            let _ = tx.send(BackgroundResult::OperationDone {
1527                ok_message: res
1528                    .as_ref()
1529                    .ok()
1530                    .map(|_| format!("Reset ({mode_owned}) to {}", &oid[..7])),
1531                err_message: res.err().map(|e| format!("reset: {e}")),
1532                needs_refresh: true,
1533                needs_staging_refresh: false,
1534            });
1535        });
1536    }
1537
1538    // ── Path helpers ─────────────────────────────────────────────────────
1539
1540    fn unstaged_file_path(&self, idx: usize) -> String {
1541        if idx >= self.tab().unstaged_changes.len() {
1542            return String::new();
1543        }
1544        self.tab().unstaged_changes[idx].display_path().to_owned()
1545    }
1546
1547    fn staged_file_path(&self, idx: usize) -> String {
1548        if idx >= self.tab().staged_changes.len() {
1549            return String::new();
1550        }
1551        self.tab().staged_changes[idx].display_path().to_owned()
1552    }
1553}
1554
1555// ── Free-standing helpers ─────────────────────────────────────────────────────
1556
1557/// Open a repository, mapping the error to a `String` for background-task results.
1558fn open_repo_str(path: &std::path::Path) -> Result<git2::Repository, String> {
1559    gitkraft_core::features::repo::open_repo(path).map_err(|e| e.to_string())
1560}
1561/// Map a persisted theme name back to its index (0–26).
1562fn theme_name_to_index(name: &str) -> usize {
1563    gitkraft_core::theme_index_by_name(name)
1564}
1565
1566/// Clamp a `ListState` selection to be within `[0, len)`, or `None` if empty.
1567fn clamp_list_state(state: &mut ListState, len: usize) {
1568    if len == 0 {
1569        state.select(None);
1570    } else if state.selected().is_none() {
1571        state.select(Some(0));
1572    } else if let Some(i) = state.selected() {
1573        if i >= len {
1574            state.select(Some(len - 1));
1575        }
1576    }
1577}
1578
1579/// Blocking helper that loads all repo data in one go.
1580/// Runs inside `spawn_blocking` — must not touch any async APIs.
1581fn load_repo_blocking(path: &std::path::Path) -> Result<RepoPayload, String> {
1582    let mut repo = open_repo_str(path)?;
1583
1584    let info = gitkraft_core::features::repo::get_repo_info(&repo).map_err(|e| e.to_string())?;
1585    let branches =
1586        gitkraft_core::features::branches::list_branches(&repo).map_err(|e| e.to_string())?;
1587    let commits =
1588        gitkraft_core::features::commits::list_commits(&repo, 500).map_err(|e| e.to_string())?;
1589    let graph_rows = gitkraft_core::features::graph::build_graph(&commits);
1590    let unstaged =
1591        gitkraft_core::features::diff::get_working_dir_diff(&repo).map_err(|e| e.to_string())?;
1592    let staged =
1593        gitkraft_core::features::diff::get_staged_diff(&repo).map_err(|e| e.to_string())?;
1594    let remotes =
1595        gitkraft_core::features::remotes::list_remotes(&repo).map_err(|e| e.to_string())?;
1596    let stashes =
1597        gitkraft_core::features::stash::list_stashes(&mut repo).map_err(|e| e.to_string())?;
1598
1599    Ok(RepoPayload {
1600        info,
1601        branches,
1602        commits,
1603        graph_rows,
1604        unstaged,
1605        staged,
1606        stashes,
1607        remotes,
1608    })
1609}
1610
1611#[cfg(test)]
1612mod tests {
1613    use super::*;
1614
1615    #[test]
1616    fn new_app_defaults() {
1617        let app = App::new();
1618        assert!(!app.should_quit);
1619        assert_eq!(app.screen, AppScreen::Welcome);
1620        assert_eq!(app.input_mode, InputMode::Normal);
1621        assert!(app.tab().commits.is_empty());
1622        assert!(app.tab().branches.is_empty());
1623        assert!(app.tab().repo_path.is_none());
1624        assert_eq!(app.tabs.len(), 1);
1625        assert_eq!(app.active_tab_index, 0);
1626    }
1627
1628    #[test]
1629    fn cycle_theme_next_wraps() {
1630        let mut app = App::new();
1631        app.current_theme_index = 0;
1632        app.cycle_theme_next();
1633        assert_eq!(app.current_theme_index, 1);
1634        // Cycle to end
1635        for _ in 0..26 {
1636            app.cycle_theme_next();
1637        }
1638        assert_eq!(app.current_theme_index, 0); // wrapped
1639    }
1640
1641    #[test]
1642    fn cycle_theme_prev_wraps() {
1643        let mut app = App::new();
1644        app.current_theme_index = 0;
1645        app.cycle_theme_prev();
1646        assert_eq!(app.current_theme_index, 26); // wrapped to last
1647    }
1648
1649    #[test]
1650    fn theme_returns_struct() {
1651        let mut app = App::new();
1652        app.current_theme_index = 0;
1653        let theme = app.theme();
1654        // Default theme active border comes from the core accent (88, 166, 255)
1655        assert_eq!(
1656            format!("{:?}", theme.border_active),
1657            format!("{:?}", ratatui::style::Color::Rgb(88, 166, 255))
1658        );
1659    }
1660
1661    #[test]
1662    fn theme_name_to_index_known() {
1663        assert_eq!(theme_name_to_index("Default"), 0);
1664        assert_eq!(theme_name_to_index("Dracula"), 8);
1665        assert_eq!(theme_name_to_index("Nord"), 9);
1666    }
1667
1668    #[test]
1669    fn theme_name_to_index_unknown_returns_zero() {
1670        assert_eq!(theme_name_to_index("NonExistentTheme"), 0);
1671        assert_eq!(theme_name_to_index(""), 0);
1672    }
1673
1674    #[test]
1675    fn tab_management_new_tab() {
1676        let mut app = App::new();
1677        assert_eq!(app.tabs.len(), 1);
1678        assert_eq!(app.active_tab_index, 0);
1679
1680        app.new_tab();
1681        assert_eq!(app.tabs.len(), 2);
1682        assert_eq!(app.active_tab_index, 1);
1683
1684        app.new_tab();
1685        assert_eq!(app.tabs.len(), 3);
1686        assert_eq!(app.active_tab_index, 2);
1687    }
1688
1689    #[test]
1690    fn tab_management_close_tab() {
1691        let mut app = App::new();
1692        app.new_tab();
1693        app.new_tab();
1694        assert_eq!(app.tabs.len(), 3);
1695        assert_eq!(app.active_tab_index, 2);
1696
1697        app.close_tab();
1698        assert_eq!(app.tabs.len(), 2);
1699        assert_eq!(app.active_tab_index, 1);
1700
1701        app.close_tab();
1702        assert_eq!(app.tabs.len(), 1);
1703        assert_eq!(app.active_tab_index, 0);
1704
1705        // Close the only tab -- should reset rather than remove
1706        app.close_tab();
1707        assert_eq!(app.tabs.len(), 1);
1708        assert_eq!(app.active_tab_index, 0);
1709    }
1710
1711    #[test]
1712    fn tab_management_next_prev() {
1713        let mut app = App::new();
1714        app.new_tab();
1715        app.new_tab();
1716        // active_tab_index == 2
1717
1718        app.next_tab();
1719        assert_eq!(app.active_tab_index, 0); // wrapped
1720
1721        app.next_tab();
1722        assert_eq!(app.active_tab_index, 1);
1723
1724        app.prev_tab();
1725        assert_eq!(app.active_tab_index, 0);
1726
1727        app.prev_tab();
1728        assert_eq!(app.active_tab_index, 2); // wrapped
1729    }
1730
1731    #[test]
1732    fn repo_tab_display_name() {
1733        let tab = RepoTab::new();
1734        assert_eq!(tab.display_name(), "New Tab");
1735
1736        let mut tab2 = RepoTab::new();
1737        tab2.repo_path = Some(PathBuf::from("/home/user/projects/my-repo"));
1738        assert_eq!(tab2.display_name(), "my-repo");
1739    }
1740
1741    #[test]
1742    fn repo_tab_search_defaults() {
1743        let tab = RepoTab::new();
1744        assert!(!tab.search_active);
1745        assert!(tab.search_query.is_empty());
1746        assert!(tab.search_results.is_empty());
1747    }
1748
1749    #[test]
1750    fn repo_tab_new_has_empty_state() {
1751        let tab = RepoTab::new();
1752        assert!(tab.repo_path.is_none());
1753        assert!(tab.commits.is_empty());
1754        assert!(tab.branches.is_empty());
1755        assert!(tab.unstaged_changes.is_empty());
1756        assert!(tab.staged_changes.is_empty());
1757        assert!(tab.stashes.is_empty());
1758        assert!(tab.remotes.is_empty());
1759        assert!(tab.commit_files.is_empty());
1760        assert!(tab.selected_commit_oid.is_none());
1761        assert!(!tab.is_loading);
1762        assert!(!tab.confirm_discard);
1763        assert_eq!(tab.diff_scroll, 0);
1764        assert_eq!(tab.commit_diff_file_index, 0);
1765    }
1766
1767    #[test]
1768    fn new_tab_switches_to_welcome() {
1769        let mut app = App::new();
1770        app.screen = AppScreen::Main;
1771        app.new_tab();
1772        assert_eq!(app.screen, AppScreen::Welcome);
1773        assert_eq!(app.active_tab_index, 1);
1774    }
1775
1776    #[test]
1777    fn close_tab_last_tab_resets() {
1778        let mut app = App::new();
1779        // Set some state on the only tab
1780        app.tab_mut().search_active = true;
1781        app.tab_mut().search_query = "test".into();
1782
1783        app.close_tab();
1784
1785        // Should reset the tab, not remove it
1786        assert_eq!(app.tabs.len(), 1);
1787        assert!(!app.tab().search_active);
1788        assert!(app.tab().search_query.is_empty());
1789    }
1790
1791    #[test]
1792    fn close_tab_middle_adjusts_index() {
1793        let mut app = App::new();
1794        app.new_tab();
1795        app.new_tab();
1796        // 3 tabs, active = 2
1797
1798        app.active_tab_index = 1; // select middle tab
1799        app.close_tab();
1800
1801        assert_eq!(app.tabs.len(), 2);
1802        assert_eq!(app.active_tab_index, 1); // stays at 1 (now the last)
1803    }
1804
1805    #[test]
1806    fn next_tab_single_tab_no_change() {
1807        let mut app = App::new();
1808        app.next_tab();
1809        assert_eq!(app.active_tab_index, 0);
1810    }
1811
1812    #[test]
1813    fn prev_tab_single_tab_no_change() {
1814        let mut app = App::new();
1815        app.prev_tab();
1816        assert_eq!(app.active_tab_index, 0);
1817    }
1818
1819    #[test]
1820    fn open_browser_sets_dir_browser_screen() {
1821        let mut app = App::new();
1822        app.screen = AppScreen::Main;
1823        app.open_browser(PathBuf::from("/tmp"));
1824        assert_eq!(app.screen, AppScreen::DirBrowser);
1825        assert_eq!(app.browser_return_screen, AppScreen::Main);
1826    }
1827
1828    #[test]
1829    fn repo_tab_selected_defaults_empty() {
1830        let tab = RepoTab::new();
1831        assert!(tab.selected_unstaged.is_empty());
1832        assert!(tab.selected_staged.is_empty());
1833    }
1834
1835    #[test]
1836    fn repo_tab_selected_toggle() {
1837        let mut tab = RepoTab::new();
1838        tab.selected_unstaged.insert(0);
1839        tab.selected_unstaged.insert(2);
1840        assert_eq!(tab.selected_unstaged.len(), 2);
1841        assert!(tab.selected_unstaged.contains(&0));
1842        tab.selected_unstaged.remove(&0);
1843        assert_eq!(tab.selected_unstaged.len(), 1);
1844        assert!(!tab.selected_unstaged.contains(&0));
1845    }
1846
1847    #[test]
1848    fn auto_refresh_field_exists() {
1849        let app = App::new();
1850        assert!(app.last_auto_refresh.elapsed() < std::time::Duration::from_secs(1));
1851    }
1852
1853    #[test]
1854    fn editor_defaults_from_settings() {
1855        let app = App::new();
1856        // Should have loaded from settings or defaulted to None
1857        let _ = app.editor.display_name();
1858    }
1859
1860    #[test]
1861    fn pull_rebase_sets_loading() {
1862        let mut app = App::new();
1863        app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
1864        app.pull_rebase();
1865        assert!(app.tab().is_loading);
1866        assert_eq!(
1867            app.tab().status_message.as_deref(),
1868            Some("Pulling (rebase)…")
1869        );
1870    }
1871
1872    #[test]
1873    fn push_branch_requires_head_branch() {
1874        let mut app = App::new();
1875        app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
1876        // No repo_info / head_branch set
1877        app.push_branch();
1878        assert!(app.tab().error_message.is_some());
1879    }
1880
1881    #[test]
1882    fn force_push_requires_head_branch() {
1883        let mut app = App::new();
1884        app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
1885        app.force_push_branch();
1886        assert!(app.tab().error_message.is_some());
1887    }
1888
1889    #[test]
1890    fn merge_selected_branch_no_selection() {
1891        let mut app = App::new();
1892        app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
1893        // No branch selected — should be a no-op (no crash)
1894        app.merge_selected_branch();
1895        assert!(!app.tab().is_loading);
1896    }
1897
1898    #[test]
1899    fn rebase_onto_selected_no_selection() {
1900        let mut app = App::new();
1901        app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
1902        app.rebase_onto_selected_branch();
1903        assert!(!app.tab().is_loading);
1904    }
1905
1906    #[test]
1907    fn revert_selected_commit_no_selection() {
1908        let mut app = App::new();
1909        app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
1910        app.revert_selected_commit();
1911        assert!(!app.tab().is_loading);
1912    }
1913
1914    #[test]
1915    fn reset_to_selected_commit_no_selection() {
1916        let mut app = App::new();
1917        app.tabs[0].repo_path = Some(PathBuf::from("/tmp/fake-repo"));
1918        app.reset_to_selected_commit("soft");
1919        assert!(!app.tab().is_loading);
1920    }
1921
1922    #[test]
1923    fn open_repo_creates_new_tab_when_current_has_repo() {
1924        let mut app = App::new();
1925        app.tabs[0].repo_path = Some(PathBuf::from("/tmp/repo1"));
1926        app.screen = AppScreen::Main;
1927        // Simulate browser selecting a repo when one is already open
1928        let initial_tabs = app.tabs.len();
1929        if app.tab().repo_path.is_some() {
1930            app.new_tab();
1931        }
1932        assert_eq!(app.tabs.len(), initial_tabs + 1);
1933    }
1934}