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
216impl RepoTab {
217    #[must_use]
218    pub fn new() -> Self {
219        Self {
220            repo_path: None,
221            repo_info: None,
222
223            branches: Vec::new(),
224            branch_list_state: ListState::default(),
225
226            commits: Vec::new(),
227            graph_rows: Vec::new(),
228            commit_list_state: ListState::default(),
229
230            unstaged_changes: Vec::new(),
231            staged_changes: Vec::new(),
232            unstaged_list_state: ListState::default(),
233            staged_list_state: ListState::default(),
234            staging_focus: StagingFocus::Unstaged,
235            selected_diff: None,
236            diff_scroll: 0,
237            commit_diffs: Vec::new(),
238            commit_diff_file_index: 0,
239            commit_files: Vec::new(),
240            selected_commit_oid: None,
241
242            stashes: Vec::new(),
243            stash_list_state: ListState::default(),
244            remotes: Vec::new(),
245
246            stash_message_buffer: String::new(),
247
248            search_query: String::new(),
249            search_active: false,
250            search_results: Vec::new(),
251
252            status_message: None,
253            error_message: None,
254
255            is_loading: false,
256
257            confirm_discard: false,
258        }
259    }
260
261    /// Return a human-readable display name for this tab.
262    /// Uses the last path component of repo_path, or "New Tab" if none.
263    pub fn display_name(&self) -> String {
264        match &self.repo_path {
265            Some(p) => p
266                .file_name()
267                .map(|n| n.to_string_lossy().into_owned())
268                .unwrap_or_else(|| "New Tab".into()),
269            None => "New Tab".into(),
270        }
271    }
272}
273
274impl Default for RepoTab {
275    fn default() -> Self {
276        Self::new()
277    }
278}
279
280// ── App State ───────────────────────────────────────────────────────────────────
281
282pub struct App {
283    pub should_quit: bool,
284    pub screen: AppScreen,
285    pub active_pane: ActivePane,
286    pub input_mode: InputMode,
287    pub input_purpose: InputPurpose,
288    pub tick_count: u64,
289
290    /// Receiver for results from background tasks.
291    pub bg_rx: mpsc::Receiver<BackgroundResult>,
292    /// Sender cloned into each spawned task.
293    pub(crate) bg_tx: mpsc::Sender<BackgroundResult>,
294
295    pub input_buffer: String,
296
297    /// Whether the theme selection panel is visible.
298    pub show_theme_panel: bool,
299    /// Whether the options panel is visible.
300    pub show_options_panel: bool,
301    /// Currently selected theme index (0-26).
302    pub current_theme_index: usize,
303    /// ListState for the theme list widget.
304    pub theme_list_state: ListState,
305
306    /// Recently opened repositories loaded from persistence.
307    pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
308
309    /// Current directory being browsed in the directory picker.
310    pub browser_dir: PathBuf,
311    /// Entries in the current browser directory.
312    pub browser_entries: Vec<std::path::PathBuf>,
313    /// List state for the directory browser.
314    pub browser_list_state: ListState,
315    /// Screen to return to when the directory browser is dismissed.
316    pub browser_return_screen: AppScreen,
317
318    /// Open repository tabs.
319    pub tabs: Vec<RepoTab>,
320    /// Index of the currently active tab.
321    pub active_tab_index: usize,
322}
323
324impl App {
325    // ── Constructor ──────────────────────────────────────────────────────────
326
327    #[must_use]
328    pub fn new() -> Self {
329        let settings = gitkraft_core::features::persistence::load_settings().unwrap_or_default();
330
331        let theme_index = theme_name_to_index(settings.theme_name.as_deref().unwrap_or(""));
332
333        let recent_repos = settings.recent_repos;
334
335        let (bg_tx, bg_rx) = mpsc::channel();
336
337        Self {
338            should_quit: false,
339            screen: AppScreen::Welcome,
340            active_pane: ActivePane::Branches,
341            input_mode: InputMode::Normal,
342            input_purpose: InputPurpose::None,
343            tick_count: 0,
344
345            bg_rx,
346            bg_tx,
347
348            input_buffer: String::new(),
349
350            show_theme_panel: false,
351            show_options_panel: false,
352            current_theme_index: theme_index,
353            theme_list_state: {
354                let mut s = ListState::default();
355                s.select(Some(theme_index));
356                s
357            },
358
359            recent_repos,
360
361            browser_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")),
362            browser_entries: Vec::new(),
363            browser_list_state: ListState::default(),
364            browser_return_screen: AppScreen::Welcome,
365
366            tabs: vec![RepoTab::new()],
367            active_tab_index: 0,
368        }
369    }
370
371    // ── Tab accessors ────────────────────────────────────────────────────────
372
373    /// Return a shared reference to the currently active tab.
374    #[inline]
375    pub fn tab(&self) -> &RepoTab {
376        &self.tabs[self.active_tab_index]
377    }
378
379    /// Return an exclusive reference to the currently active tab.
380    #[inline]
381    pub fn tab_mut(&mut self) -> &mut RepoTab {
382        &mut self.tabs[self.active_tab_index]
383    }
384
385    // ── Tab management ──────────────────────────────────────────────────────
386
387    /// Open a new empty tab and make it active.
388    pub fn new_tab(&mut self) {
389        self.tabs.push(RepoTab::new());
390        self.active_tab_index = self.tabs.len() - 1;
391        self.screen = AppScreen::Welcome;
392        // Reload recent repos so they're fresh on the welcome screen
393        if let Ok(settings) = gitkraft_core::features::persistence::load_settings() {
394            self.recent_repos = settings.recent_repos;
395        }
396        self.save_session();
397    }
398
399    /// Close the current tab. If it is the last tab, replace it with an empty one.
400    pub fn close_tab(&mut self) {
401        if self.tabs.len() <= 1 {
402            self.tabs[0] = RepoTab::new();
403            self.active_tab_index = 0;
404        } else {
405            self.tabs.remove(self.active_tab_index);
406            if self.active_tab_index >= self.tabs.len() {
407                self.active_tab_index = self.tabs.len() - 1;
408            }
409        }
410        self.save_session();
411    }
412
413    /// Switch to the next tab (wrapping around).
414    pub fn next_tab(&mut self) {
415        if !self.tabs.is_empty() {
416            self.active_tab_index = (self.active_tab_index + 1) % self.tabs.len();
417        }
418    }
419
420    /// Switch to the previous tab (wrapping around).
421    pub fn prev_tab(&mut self) {
422        if !self.tabs.is_empty() {
423            if self.active_tab_index == 0 {
424                self.active_tab_index = self.tabs.len() - 1;
425            } else {
426                self.active_tab_index -= 1;
427            }
428        }
429    }
430}
431
432impl Default for App {
433    fn default() -> Self {
434        Self::new()
435    }
436}
437
438impl App {
439    // ── Theme helpers ────────────────────────────────────────────────────
440
441    pub fn cycle_theme_next(&mut self) {
442        let count = 27; // number of themes
443        self.current_theme_index = (self.current_theme_index + 1) % count;
444        self.theme_list_state.select(Some(self.current_theme_index));
445        self.tab_mut().status_message = Some(format!("Theme: {}", self.current_theme_name()));
446    }
447
448    pub fn cycle_theme_prev(&mut self) {
449        let count = 27;
450        if self.current_theme_index == 0 {
451            self.current_theme_index = count - 1;
452        } else {
453            self.current_theme_index -= 1;
454        }
455        self.theme_list_state.select(Some(self.current_theme_index));
456        self.tab_mut().status_message = Some(format!("Theme: {}", self.current_theme_name()));
457    }
458
459    pub fn current_theme_name(&self) -> &'static str {
460        gitkraft_core::THEME_NAMES
461            .get(self.current_theme_index)
462            .copied()
463            .unwrap_or("Default")
464    }
465
466    /// Return the `UiTheme` for the currently selected theme index.
467    pub fn theme(&self) -> crate::features::theme::palette::UiTheme {
468        crate::features::theme::palette::theme_for_index(self.current_theme_index)
469    }
470
471    /// Persist the current theme selection to disk.
472    pub fn save_theme(&self) {
473        let _ = gitkraft_core::features::persistence::save_theme(self.current_theme_name());
474    }
475
476    /// Persist the paths of all open tabs for session restore.
477    pub fn save_session(&self) {
478        let paths: Vec<std::path::PathBuf> = self
479            .tabs
480            .iter()
481            .filter_map(|t| t.repo_path.clone())
482            .collect();
483        let active = self.active_tab_index;
484        let _ = gitkraft_core::features::persistence::save_session(&paths, active);
485    }
486
487    // ── High-level operations ────────────────────────────────────────────
488
489    pub fn open_repo(&mut self, path: PathBuf) {
490        self.tab_mut().error_message = None;
491        self.tab_mut().status_message = Some("Opening repository…".into());
492        self.tab_mut().is_loading = true;
493        self.tab_mut().repo_path = Some(path.clone());
494        self.screen = AppScreen::Main;
495
496        let tx = self.bg_tx.clone();
497        std::thread::spawn(move || {
498            let result = load_repo_blocking(&path);
499            let _ = tx.send(BackgroundResult::RepoLoaded { path, result });
500        });
501        self.save_session();
502    }
503
504    pub fn refresh(&mut self) {
505        self.tab_mut().error_message = None;
506        self.tab_mut().is_loading = true;
507        self.tab_mut().status_message = Some("Refreshing…".into());
508
509        let path = match self.tab().repo_path.clone() {
510            Some(p) => p,
511            None => {
512                self.tab_mut().error_message = Some("No repository open".into());
513                self.tab_mut().is_loading = false;
514                return;
515            }
516        };
517
518        let tx = self.bg_tx.clone();
519        std::thread::spawn(move || {
520            let result = load_repo_blocking(&path);
521            let _ = tx.send(BackgroundResult::RepoLoaded { path, result });
522        });
523    }
524
525    /// Process any pending results from background tasks.
526    /// Call this once per tick in the event loop.
527    pub fn poll_background(&mut self) {
528        while let Ok(result) = self.bg_rx.try_recv() {
529            match result {
530                BackgroundResult::RepoLoaded {
531                    path: loaded_path,
532                    result: res,
533                } => {
534                    // Find the tab that initiated this load by matching repo_path.
535                    let tab_idx = self
536                        .tabs
537                        .iter()
538                        .position(|t| t.repo_path.as_ref() == Some(&loaded_path))
539                        .unwrap_or(self.active_tab_index);
540
541                    self.tabs[tab_idx].is_loading = false;
542                    match res {
543                        Ok(payload) => {
544                            let canonical = payload.info.workdir.clone().unwrap_or_else(|| {
545                                self.tabs[tab_idx].repo_path.clone().unwrap_or_default()
546                            });
547                            self.tabs[tab_idx].repo_path = Some(canonical.clone());
548
549                            // Persist
550                            let _ = gitkraft_core::features::persistence::record_repo_opened(
551                                &canonical,
552                            );
553                            if let Ok(settings) =
554                                gitkraft_core::features::persistence::load_settings()
555                            {
556                                self.recent_repos = settings.recent_repos;
557                            }
558
559                            let tab = &mut self.tabs[tab_idx];
560                            tab.repo_info = Some(payload.info);
561                            tab.branches = payload.branches;
562                            clamp_list_state(&mut tab.branch_list_state, tab.branches.len());
563                            tab.graph_rows = payload.graph_rows;
564                            tab.commits = payload.commits;
565                            clamp_list_state(&mut tab.commit_list_state, tab.commits.len());
566                            tab.unstaged_changes = payload.unstaged;
567                            clamp_list_state(
568                                &mut tab.unstaged_list_state,
569                                tab.unstaged_changes.len(),
570                            );
571                            tab.staged_changes = payload.staged;
572                            clamp_list_state(&mut tab.staged_list_state, tab.staged_changes.len());
573                            tab.stashes = payload.stashes;
574                            clamp_list_state(&mut tab.stash_list_state, tab.stashes.len());
575                            tab.remotes = payload.remotes;
576                            tab.status_message = Some("Repository loaded".into());
577                            self.screen = AppScreen::Main;
578                            self.save_session();
579                        }
580                        Err(e) => {
581                            self.tabs[tab_idx].error_message = Some(e);
582                            self.tabs[tab_idx].status_message = None;
583                        }
584                    }
585                }
586                BackgroundResult::FetchDone(res) => {
587                    self.tab_mut().is_loading = false;
588                    match res {
589                        Ok(()) => {
590                            self.tab_mut().status_message = Some("Fetched from origin".into());
591                            self.refresh();
592                        }
593                        Err(e) => self.tab_mut().error_message = Some(format!("fetch: {e}")),
594                    }
595                }
596                BackgroundResult::CommitDiffLoaded(res) => {
597                    self.tab_mut().is_loading = false;
598                    match res {
599                        Ok(diffs) => {
600                            if diffs.is_empty() {
601                                let tab = self.tab_mut();
602                                tab.selected_diff = None;
603                                tab.commit_diffs.clear();
604                                tab.commit_diff_file_index = 0;
605                                tab.status_message = Some("No changes in this commit".into());
606                            } else {
607                                let tab = self.tab_mut();
608                                tab.commit_diffs = diffs.clone();
609                                tab.commit_diff_file_index = 0;
610                                tab.selected_diff = Some(diffs[0].clone());
611                                tab.diff_scroll = 0;
612                                if diffs.len() > 1 {
613                                    tab.status_message = Some(format!(
614                                        "Showing file 1/{} — use h/l to switch files",
615                                        diffs.len()
616                                    ));
617                                }
618                            }
619                        }
620                        Err(e) => self.tab_mut().error_message = Some(format!("commit diff: {e}")),
621                    }
622                }
623                BackgroundResult::CommitFileListLoaded(res) => {
624                    self.tab_mut().is_loading = false;
625                    match res {
626                        Ok(files) => {
627                            let count = files.len();
628                            let tab = self.tab_mut();
629                            tab.commit_files = files;
630                            tab.commit_diffs.clear();
631                            tab.commit_diff_file_index = 0;
632                            tab.selected_diff = None;
633                            tab.diff_scroll = 0;
634
635                            if count == 0 {
636                                tab.status_message = Some("No changes in this commit".into());
637                            } else {
638                                tab.status_message = Some(format!("{count} file(s) changed"));
639                                // Auto-load the first file's diff
640                                let first_path = tab.commit_files[0].display_path().to_string();
641                                self.load_single_file_diff(first_path);
642                            }
643                        }
644                        Err(e) => self.tab_mut().error_message = Some(format!("file list: {e}")),
645                    }
646                }
647                BackgroundResult::SingleFileDiffLoaded(res) => {
648                    self.tab_mut().is_loading = false;
649                    match res {
650                        Ok(diff) => {
651                            let tab = self.tab_mut();
652                            // Store in commit_diffs for the file list sidebar.
653                            if tab.commit_diffs.len() <= tab.commit_diff_file_index {
654                                tab.commit_diffs.push(diff.clone());
655                            } else {
656                                tab.commit_diffs[tab.commit_diff_file_index] = diff.clone();
657                            }
658                            tab.selected_diff = Some(diff);
659                            tab.diff_scroll = 0;
660                            if tab.commit_files.len() > 1 {
661                                tab.status_message = Some(format!(
662                                    "File {}/{} — use h/l to switch files",
663                                    tab.commit_diff_file_index + 1,
664                                    tab.commit_files.len()
665                                ));
666                            }
667                        }
668                        Err(e) => self.tab_mut().error_message = Some(format!("file diff: {e}")),
669                    }
670                }
671                BackgroundResult::StagingRefreshed(res) => {
672                    self.tab_mut().is_loading = false;
673                    match res {
674                        Ok(payload) => self.apply_staging_payload(payload),
675                        Err(e) => {
676                            self.tab_mut().error_message = Some(format!("staging refresh: {e}"))
677                        }
678                    }
679                }
680                BackgroundResult::OperationDone {
681                    ok_message,
682                    err_message,
683                    needs_refresh,
684                    needs_staging_refresh,
685                } => {
686                    self.tab_mut().is_loading = false;
687                    if let Some(msg) = err_message {
688                        self.tab_mut().error_message = Some(msg);
689                    } else if let Some(msg) = ok_message {
690                        self.tab_mut().status_message = Some(msg);
691                    }
692                    if needs_refresh {
693                        self.refresh();
694                    } else if needs_staging_refresh {
695                        self.refresh_staging();
696                    }
697                }
698                BackgroundResult::SearchResults(res) => match res {
699                    Ok(results) => {
700                        self.tab_mut().search_results = results;
701                        let count = self.tab().search_results.len();
702                        self.tab_mut().status_message = Some(format!("{count} result(s) found"));
703                    }
704                    Err(e) => {
705                        self.tab_mut().error_message = Some(format!("Search failed: {e}"));
706                    }
707                },
708            }
709        }
710    }
711
712    /// Reload only the staging area (unstaged + staged diffs).
713    pub fn refresh_staging(&mut self) {
714        let repo_path = match self.tab().repo_path.clone() {
715            Some(p) => p,
716            None => {
717                self.tab_mut().error_message = Some("No repository open".into());
718                return;
719            }
720        };
721        let tx = self.bg_tx.clone();
722        std::thread::spawn(move || {
723            let res = (|| {
724                let repo = open_repo_str(&repo_path)?;
725                let unstaged = gitkraft_core::features::diff::get_working_dir_diff(&repo)
726                    .map_err(|e| e.to_string())?;
727                let staged = gitkraft_core::features::diff::get_staged_diff(&repo)
728                    .map_err(|e| e.to_string())?;
729                Ok::<_, String>(StagingPayload { unstaged, staged })
730            })();
731            let _ = tx.send(BackgroundResult::StagingRefreshed(res));
732        });
733    }
734
735    fn apply_staging_payload(&mut self, payload: StagingPayload) {
736        let tab = self.tab_mut();
737        tab.unstaged_changes = payload.unstaged;
738        if tab.unstaged_changes.is_empty() {
739            tab.unstaged_list_state.select(None);
740        } else if tab.unstaged_list_state.selected().is_none() {
741            tab.unstaged_list_state.select(Some(0));
742        } else if let Some(i) = tab.unstaged_list_state.selected() {
743            if i >= tab.unstaged_changes.len() {
744                tab.unstaged_list_state
745                    .select(Some(tab.unstaged_changes.len() - 1));
746            }
747        }
748
749        tab.staged_changes = payload.staged;
750        if tab.staged_changes.is_empty() {
751            tab.staged_list_state.select(None);
752        } else if tab.staged_list_state.selected().is_none() {
753            tab.staged_list_state.select(Some(0));
754        } else if let Some(i) = tab.staged_list_state.selected() {
755            if i >= tab.staged_changes.len() {
756                tab.staged_list_state
757                    .select(Some(tab.staged_changes.len() - 1));
758            }
759        }
760    }
761
762    // ── Staging operations ───────────────────────────────────────────────
763
764    pub fn stage_selected(&mut self) {
765        let idx = match self.tab().unstaged_list_state.selected() {
766            Some(i) => i,
767            None => {
768                self.tab_mut().status_message = Some("No unstaged file selected".into());
769                return;
770            }
771        };
772        let file_path = self.unstaged_file_path(idx);
773        bg_op!(self, "Staging…", staging, |repo_path| {
774            let repo = open_repo_str(&repo_path)?;
775            gitkraft_core::features::staging::stage_file(&repo, &file_path)
776                .map_err(|e| format!("stage: {e}"))?;
777            Ok(format!("Staged: {file_path}"))
778        });
779    }
780
781    pub fn unstage_selected(&mut self) {
782        let idx = match self.tab().staged_list_state.selected() {
783            Some(i) => i,
784            None => {
785                self.tab_mut().status_message = Some("No staged file selected".into());
786                return;
787            }
788        };
789        let file_path = self.staged_file_path(idx);
790        bg_op!(self, "Unstaging…", staging, |repo_path| {
791            let repo = open_repo_str(&repo_path)?;
792            gitkraft_core::features::staging::unstage_file(&repo, &file_path)
793                .map_err(|e| format!("unstage: {e}"))?;
794            Ok(format!("Unstaged: {file_path}"))
795        });
796    }
797
798    pub fn stage_all(&mut self) {
799        bg_op!(self, "Staging all…", staging, |repo_path| {
800            let repo = open_repo_str(&repo_path)?;
801            gitkraft_core::features::staging::stage_all(&repo)
802                .map_err(|e| format!("stage all: {e}"))?;
803            Ok("Staged all files".into())
804        });
805    }
806
807    pub fn unstage_all(&mut self) {
808        bg_op!(self, "Unstaging all…", staging, |repo_path| {
809            let repo = open_repo_str(&repo_path)?;
810            gitkraft_core::features::staging::unstage_all(&repo)
811                .map_err(|e| format!("unstage all: {e}"))?;
812            Ok("Unstaged all files".into())
813        });
814    }
815
816    pub fn discard_selected(&mut self) {
817        let idx = match self.tab().unstaged_list_state.selected() {
818            Some(i) => i,
819            None => {
820                self.tab_mut().status_message = Some("No unstaged file selected".into());
821                return;
822            }
823        };
824        let file_path = self.unstaged_file_path(idx);
825        self.tab_mut().confirm_discard = false;
826        bg_op!(self, "Discarding…", staging, |repo_path| {
827            let repo = open_repo_str(&repo_path)?;
828            gitkraft_core::features::staging::discard_file_changes(&repo, &file_path)
829                .map_err(|e| format!("discard: {e}"))?;
830            Ok(format!("Discarded changes: {file_path}"))
831        });
832    }
833
834    // ── Commit ───────────────────────────────────────────────────────────
835
836    pub fn create_commit(&mut self) {
837        let msg = self.input_buffer.trim().to_string();
838        if msg.is_empty() {
839            self.tab_mut().error_message = Some("Commit message cannot be empty".into());
840            return;
841        }
842        self.input_buffer.clear();
843        bg_op!(self, "Committing…", refresh, |repo_path| {
844            let repo = open_repo_str(&repo_path)?;
845            let info = gitkraft_core::features::commits::create_commit(&repo, &msg)
846                .map_err(|e| format!("commit: {e}"))?;
847            Ok(format!("Committed: {} {}", info.short_oid, info.summary))
848        });
849    }
850
851    // ── Branches ─────────────────────────────────────────────────────────
852
853    pub fn checkout_selected_branch(&mut self) {
854        let idx = match self.tab().branch_list_state.selected() {
855            Some(i) => i,
856            None => return,
857        };
858        if idx >= self.tab().branches.len() {
859            return;
860        }
861        let name = self.tab().branches[idx].name.clone();
862        if self.tab().branches[idx].is_head {
863            self.tab_mut().status_message = Some(format!("Already on '{name}'"));
864            return;
865        }
866        bg_op!(self, "Checking out…", refresh, |repo_path| {
867            let repo = open_repo_str(&repo_path)?;
868            gitkraft_core::features::branches::checkout_branch(&repo, &name)
869                .map_err(|e| format!("checkout: {e}"))?;
870            Ok(format!("Checked out: {name}"))
871        });
872    }
873
874    pub fn create_branch(&mut self) {
875        let name = self.input_buffer.trim().to_string();
876        if name.is_empty() {
877            self.tab_mut().error_message = Some("Branch name cannot be empty".into());
878            return;
879        }
880        self.input_buffer.clear();
881        bg_op!(self, "Creating branch…", refresh, |repo_path| {
882            let repo = open_repo_str(&repo_path)?;
883            gitkraft_core::features::branches::create_branch(&repo, &name)
884                .map_err(|e| format!("create branch: {e}"))?;
885            Ok(format!("Created branch: {name}"))
886        });
887    }
888
889    pub fn delete_selected_branch(&mut self) {
890        let idx = match self.tab().branch_list_state.selected() {
891            Some(i) => i,
892            None => return,
893        };
894        if idx >= self.tab().branches.len() {
895            return;
896        }
897        if self.tab().branches[idx].is_head {
898            self.tab_mut().error_message = Some("Cannot delete the current branch".into());
899            return;
900        }
901        let name = self.tab().branches[idx].name.clone();
902        bg_op!(self, "Deleting branch…", refresh, |repo_path| {
903            let repo = open_repo_str(&repo_path)?;
904            gitkraft_core::features::branches::delete_branch(&repo, &name)
905                .map_err(|e| format!("delete branch: {e}"))?;
906            Ok(format!("Deleted branch: {name}"))
907        });
908    }
909
910    // ── Stash ────────────────────────────────────────────────────────────
911
912    pub fn stash_save(&mut self) {
913        let msg = if self.tab().stash_message_buffer.trim().is_empty() {
914            None
915        } else {
916            Some(self.tab().stash_message_buffer.trim().to_string())
917        };
918        self.tab_mut().stash_message_buffer.clear();
919        bg_op!(self, "Stashing…", refresh, |repo_path| {
920            let mut repo = open_repo_str(&repo_path)?;
921            let entry = gitkraft_core::features::stash::stash_save(&mut repo, msg.as_deref())
922                .map_err(|e| format!("stash save: {e}"))?;
923            Ok(format!("Stashed: {}", entry.message))
924        });
925    }
926
927    pub fn stash_pop_selected(&mut self) {
928        let idx = self.tab().stash_list_state.selected().unwrap_or(0);
929        if idx >= self.tab().stashes.len() {
930            self.tab_mut().error_message = Some("No stash selected".into());
931            return;
932        }
933        bg_op!(self, "Popping stash…", refresh, |repo_path| {
934            let mut repo = open_repo_str(&repo_path)?;
935            gitkraft_core::features::stash::stash_pop(&mut repo, idx)
936                .map_err(|e| format!("stash pop: {e}"))?;
937            Ok(format!("Stash @{{{idx}}} popped"))
938        });
939    }
940
941    pub fn stash_drop_selected(&mut self) {
942        let idx = self.tab().stash_list_state.selected().unwrap_or(0);
943        if idx >= self.tab().stashes.len() {
944            self.tab_mut().error_message = Some("No stash to drop".into());
945            return;
946        }
947        bg_op!(self, "Dropping stash…", refresh, |repo_path| {
948            let mut repo = open_repo_str(&repo_path)?;
949            gitkraft_core::features::stash::stash_drop(&mut repo, idx)
950                .map_err(|e| format!("stash drop: {e}"))?;
951            Ok(format!("Stash @{{{idx}}} dropped"))
952        });
953    }
954
955    // ── Diff ─────────────────────────────────────────────────────────────
956
957    /// Load the file list for the currently selected commit (phase 1 of two-phase loading).
958    pub fn load_commit_diff(&mut self) {
959        let idx = match self.tab().commit_list_state.selected() {
960            Some(i) => i,
961            None => return,
962        };
963        if idx >= self.tab().commits.len() {
964            return;
965        }
966        let oid = self.tab().commits[idx].oid.clone();
967        self.tab_mut().selected_commit_oid = Some(oid.clone());
968        bg_task!(
969            self,
970            "Loading files…",
971            BackgroundResult::CommitFileListLoaded,
972            |repo_path| {
973                let repo = open_repo_str(&repo_path)?;
974                gitkraft_core::features::diff::get_commit_file_list(&repo, &oid)
975                    .map_err(|e| e.to_string())
976            }
977        );
978    }
979
980    /// Load the diff for a single file in the selected commit (phase 2).
981    pub fn load_single_file_diff(&mut self, file_path: String) {
982        let oid = match self.tab().selected_commit_oid.clone() {
983            Some(o) => o,
984            None => return,
985        };
986        bg_task!(
987            self,
988            "Loading diff…",
989            BackgroundResult::SingleFileDiffLoaded,
990            |repo_path| {
991                let repo = open_repo_str(&repo_path)?;
992                gitkraft_core::features::diff::get_single_file_diff(&repo, &oid, &file_path)
993                    .map_err(|e| e.to_string())
994            }
995        );
996    }
997
998    /// Switch to the next file in the commit diff list.
999    pub fn next_diff_file(&mut self) {
1000        if self.tab().commit_files.is_empty() {
1001            return;
1002        }
1003        let new_index = (self.tab().commit_diff_file_index + 1) % self.tab().commit_files.len();
1004        self.tab_mut().commit_diff_file_index = new_index;
1005        let file_path = self.tab().commit_files[self.tab().commit_diff_file_index]
1006            .display_path()
1007            .to_string();
1008        self.tab_mut().diff_scroll = 0;
1009        self.tab_mut().status_message = Some(format!(
1010            "File {}/{}",
1011            self.tab().commit_diff_file_index + 1,
1012            self.tab().commit_files.len()
1013        ));
1014        self.load_single_file_diff(file_path);
1015    }
1016
1017    /// Switch to the previous file in the commit diff list.
1018    pub fn prev_diff_file(&mut self) {
1019        if self.tab().commit_files.is_empty() {
1020            return;
1021        }
1022        let new_index = if self.tab().commit_diff_file_index == 0 {
1023            self.tab().commit_files.len() - 1
1024        } else {
1025            self.tab().commit_diff_file_index - 1
1026        };
1027        self.tab_mut().commit_diff_file_index = new_index;
1028        let file_path = self.tab().commit_files[self.tab().commit_diff_file_index]
1029            .display_path()
1030            .to_string();
1031        self.tab_mut().diff_scroll = 0;
1032        self.tab_mut().status_message = Some(format!(
1033            "File {}/{}",
1034            self.tab().commit_diff_file_index + 1,
1035            self.tab().commit_files.len()
1036        ));
1037        self.load_single_file_diff(file_path);
1038    }
1039
1040    /// Close the current repository and return to the welcome screen.
1041    /// Search commits by query string.
1042    pub fn search_commits(&mut self, query: String) {
1043        let repo_path = match self.tab().repo_path.clone() {
1044            Some(p) => p,
1045            None => return,
1046        };
1047        self.tab_mut().search_query = query.clone();
1048        if query.trim().len() < 2 {
1049            self.tab_mut().search_results.clear();
1050            return;
1051        }
1052        let tx = self.bg_tx.clone();
1053        std::thread::spawn(move || {
1054            let res = (|| {
1055                let repo = open_repo_str(&repo_path)?;
1056                gitkraft_core::features::log::search_commits(&repo, &query, 100)
1057                    .map_err(|e| e.to_string())
1058            })();
1059            let _ = tx.send(BackgroundResult::SearchResults(res));
1060        });
1061    }
1062
1063    /// Load the diff for a commit by its OID (used by search results).
1064    pub fn load_commit_diff_by_oid(&mut self) {
1065        let oid = match self.tab().selected_commit_oid.clone() {
1066            Some(o) => o,
1067            None => return,
1068        };
1069        bg_task!(
1070            self,
1071            "Loading files…",
1072            BackgroundResult::CommitFileListLoaded,
1073            |repo_path| {
1074                let repo = open_repo_str(&repo_path)?;
1075                gitkraft_core::features::diff::get_commit_file_list(&repo, &oid)
1076                    .map_err(|e| e.to_string())
1077            }
1078        );
1079    }
1080
1081    pub fn close_repo(&mut self) {
1082        self.tabs[self.active_tab_index] = RepoTab::new();
1083        self.input_buffer.clear();
1084        self.show_theme_panel = false;
1085        self.show_options_panel = false;
1086        self.screen = AppScreen::Welcome;
1087        // Reload recent repos
1088        if let Ok(settings) = gitkraft_core::features::persistence::load_settings() {
1089            self.recent_repos = settings.recent_repos;
1090        }
1091        self.save_session();
1092    }
1093
1094    /// Populate `browser_entries` with the contents of `browser_dir`.
1095    pub fn refresh_browser(&mut self) {
1096        let mut entries = Vec::new();
1097        if let Ok(read_dir) = std::fs::read_dir(&self.browser_dir) {
1098            for entry in read_dir.flatten() {
1099                let path = entry.path();
1100                // Show only directories to help navigate & identify repos
1101                if path.is_dir() {
1102                    entries.push(path);
1103                }
1104            }
1105        }
1106        entries.sort_by(|a, b| {
1107            let a_name = a
1108                .file_name()
1109                .unwrap_or_default()
1110                .to_string_lossy()
1111                .to_lowercase();
1112            let b_name = b
1113                .file_name()
1114                .unwrap_or_default()
1115                .to_string_lossy()
1116                .to_lowercase();
1117            // Dot-dirs last
1118            let a_dot = a_name.starts_with('.');
1119            let b_dot = b_name.starts_with('.');
1120            a_dot.cmp(&b_dot).then(a_name.cmp(&b_name))
1121        });
1122        self.browser_entries = entries;
1123        self.browser_list_state = ListState::default();
1124        if !self.browser_entries.is_empty() {
1125            self.browser_list_state.select(Some(0));
1126        }
1127    }
1128
1129    /// Open the directory browser starting from a given path.
1130    pub fn open_browser(&mut self, start: PathBuf) {
1131        self.browser_return_screen = self.screen.clone();
1132        self.browser_dir = start;
1133        self.refresh_browser();
1134        self.screen = AppScreen::DirBrowser;
1135    }
1136    /// Load the diff for a selected staging file into the diff pane.
1137    pub fn load_staging_diff(&mut self) {
1138        match self.tab().staging_focus {
1139            StagingFocus::Unstaged => {
1140                if let Some(idx) = self.tab().unstaged_list_state.selected() {
1141                    if idx < self.tab().unstaged_changes.len() {
1142                        let diff = self.tab().unstaged_changes[idx].clone();
1143                        let tab = self.tab_mut();
1144                        tab.selected_diff = Some(diff);
1145                        tab.diff_scroll = 0;
1146                    }
1147                }
1148            }
1149            StagingFocus::Staged => {
1150                if let Some(idx) = self.tab().staged_list_state.selected() {
1151                    if idx < self.tab().staged_changes.len() {
1152                        let diff = self.tab().staged_changes[idx].clone();
1153                        let tab = self.tab_mut();
1154                        tab.selected_diff = Some(diff);
1155                        tab.diff_scroll = 0;
1156                    }
1157                }
1158            }
1159        }
1160    }
1161
1162    // ── Remote ───────────────────────────────────────────────────────────
1163
1164    pub fn fetch_remote(&mut self) {
1165        let repo_path = match self.tab().repo_path.clone() {
1166            Some(p) => p,
1167            None => return,
1168        };
1169        self.tab_mut().is_loading = true;
1170        self.tab_mut().status_message = Some("Fetching…".into());
1171        let tx = self.bg_tx.clone();
1172        std::thread::spawn(move || {
1173            let res = (|| {
1174                let repo = open_repo_str(&repo_path)?;
1175                gitkraft_core::features::remotes::fetch_remote(&repo, "origin")
1176                    .map_err(|e| e.to_string())
1177            })();
1178            let _ = tx.send(BackgroundResult::FetchDone(res));
1179        });
1180    }
1181
1182    // ── Path helpers ─────────────────────────────────────────────────────
1183
1184    fn unstaged_file_path(&self, idx: usize) -> String {
1185        if idx >= self.tab().unstaged_changes.len() {
1186            return String::new();
1187        }
1188        self.tab().unstaged_changes[idx].display_path().to_owned()
1189    }
1190
1191    fn staged_file_path(&self, idx: usize) -> String {
1192        if idx >= self.tab().staged_changes.len() {
1193            return String::new();
1194        }
1195        self.tab().staged_changes[idx].display_path().to_owned()
1196    }
1197}
1198
1199// ── Free-standing helpers ─────────────────────────────────────────────────────
1200
1201/// Open a repository, mapping the error to a `String` for background-task results.
1202fn open_repo_str(path: &std::path::Path) -> Result<git2::Repository, String> {
1203    gitkraft_core::features::repo::open_repo(path).map_err(|e| e.to_string())
1204}
1205/// Map a persisted theme name back to its index (0–26).
1206fn theme_name_to_index(name: &str) -> usize {
1207    gitkraft_core::theme_index_by_name(name)
1208}
1209
1210/// Clamp a `ListState` selection to be within `[0, len)`, or `None` if empty.
1211fn clamp_list_state(state: &mut ListState, len: usize) {
1212    if len == 0 {
1213        state.select(None);
1214    } else if state.selected().is_none() {
1215        state.select(Some(0));
1216    } else if let Some(i) = state.selected() {
1217        if i >= len {
1218            state.select(Some(len - 1));
1219        }
1220    }
1221}
1222
1223/// Blocking helper that loads all repo data in one go.
1224/// Runs inside `spawn_blocking` — must not touch any async APIs.
1225fn load_repo_blocking(path: &std::path::Path) -> Result<RepoPayload, String> {
1226    let mut repo = open_repo_str(path)?;
1227
1228    let info = gitkraft_core::features::repo::get_repo_info(&repo).map_err(|e| e.to_string())?;
1229    let branches =
1230        gitkraft_core::features::branches::list_branches(&repo).map_err(|e| e.to_string())?;
1231    let commits =
1232        gitkraft_core::features::commits::list_commits(&repo, 500).map_err(|e| e.to_string())?;
1233    let graph_rows = gitkraft_core::features::graph::build_graph(&commits);
1234    let unstaged =
1235        gitkraft_core::features::diff::get_working_dir_diff(&repo).map_err(|e| e.to_string())?;
1236    let staged =
1237        gitkraft_core::features::diff::get_staged_diff(&repo).map_err(|e| e.to_string())?;
1238    let remotes =
1239        gitkraft_core::features::remotes::list_remotes(&repo).map_err(|e| e.to_string())?;
1240    let stashes =
1241        gitkraft_core::features::stash::list_stashes(&mut repo).map_err(|e| e.to_string())?;
1242
1243    Ok(RepoPayload {
1244        info,
1245        branches,
1246        commits,
1247        graph_rows,
1248        unstaged,
1249        staged,
1250        stashes,
1251        remotes,
1252    })
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257    use super::*;
1258
1259    #[test]
1260    fn new_app_defaults() {
1261        let app = App::new();
1262        assert!(!app.should_quit);
1263        assert_eq!(app.screen, AppScreen::Welcome);
1264        assert_eq!(app.input_mode, InputMode::Normal);
1265        assert!(app.tab().commits.is_empty());
1266        assert!(app.tab().branches.is_empty());
1267        assert!(app.tab().repo_path.is_none());
1268        assert_eq!(app.tabs.len(), 1);
1269        assert_eq!(app.active_tab_index, 0);
1270    }
1271
1272    #[test]
1273    fn cycle_theme_next_wraps() {
1274        let mut app = App::new();
1275        app.current_theme_index = 0;
1276        app.cycle_theme_next();
1277        assert_eq!(app.current_theme_index, 1);
1278        // Cycle to end
1279        for _ in 0..26 {
1280            app.cycle_theme_next();
1281        }
1282        assert_eq!(app.current_theme_index, 0); // wrapped
1283    }
1284
1285    #[test]
1286    fn cycle_theme_prev_wraps() {
1287        let mut app = App::new();
1288        app.current_theme_index = 0;
1289        app.cycle_theme_prev();
1290        assert_eq!(app.current_theme_index, 26); // wrapped to last
1291    }
1292
1293    #[test]
1294    fn theme_returns_struct() {
1295        let mut app = App::new();
1296        app.current_theme_index = 0;
1297        let theme = app.theme();
1298        // Default theme active border comes from the core accent (88, 166, 255)
1299        assert_eq!(
1300            format!("{:?}", theme.border_active),
1301            format!("{:?}", ratatui::style::Color::Rgb(88, 166, 255))
1302        );
1303    }
1304
1305    #[test]
1306    fn theme_name_to_index_known() {
1307        assert_eq!(theme_name_to_index("Default"), 0);
1308        assert_eq!(theme_name_to_index("Dracula"), 8);
1309        assert_eq!(theme_name_to_index("Nord"), 9);
1310    }
1311
1312    #[test]
1313    fn theme_name_to_index_unknown_returns_zero() {
1314        assert_eq!(theme_name_to_index("NonExistentTheme"), 0);
1315        assert_eq!(theme_name_to_index(""), 0);
1316    }
1317
1318    #[test]
1319    fn tab_management_new_tab() {
1320        let mut app = App::new();
1321        assert_eq!(app.tabs.len(), 1);
1322        assert_eq!(app.active_tab_index, 0);
1323
1324        app.new_tab();
1325        assert_eq!(app.tabs.len(), 2);
1326        assert_eq!(app.active_tab_index, 1);
1327
1328        app.new_tab();
1329        assert_eq!(app.tabs.len(), 3);
1330        assert_eq!(app.active_tab_index, 2);
1331    }
1332
1333    #[test]
1334    fn tab_management_close_tab() {
1335        let mut app = App::new();
1336        app.new_tab();
1337        app.new_tab();
1338        assert_eq!(app.tabs.len(), 3);
1339        assert_eq!(app.active_tab_index, 2);
1340
1341        app.close_tab();
1342        assert_eq!(app.tabs.len(), 2);
1343        assert_eq!(app.active_tab_index, 1);
1344
1345        app.close_tab();
1346        assert_eq!(app.tabs.len(), 1);
1347        assert_eq!(app.active_tab_index, 0);
1348
1349        // Close the only tab -- should reset rather than remove
1350        app.close_tab();
1351        assert_eq!(app.tabs.len(), 1);
1352        assert_eq!(app.active_tab_index, 0);
1353    }
1354
1355    #[test]
1356    fn tab_management_next_prev() {
1357        let mut app = App::new();
1358        app.new_tab();
1359        app.new_tab();
1360        // active_tab_index == 2
1361
1362        app.next_tab();
1363        assert_eq!(app.active_tab_index, 0); // wrapped
1364
1365        app.next_tab();
1366        assert_eq!(app.active_tab_index, 1);
1367
1368        app.prev_tab();
1369        assert_eq!(app.active_tab_index, 0);
1370
1371        app.prev_tab();
1372        assert_eq!(app.active_tab_index, 2); // wrapped
1373    }
1374
1375    #[test]
1376    fn repo_tab_display_name() {
1377        let tab = RepoTab::new();
1378        assert_eq!(tab.display_name(), "New Tab");
1379
1380        let mut tab2 = RepoTab::new();
1381        tab2.repo_path = Some(PathBuf::from("/home/user/projects/my-repo"));
1382        assert_eq!(tab2.display_name(), "my-repo");
1383    }
1384
1385    #[test]
1386    fn repo_tab_search_defaults() {
1387        let tab = RepoTab::new();
1388        assert!(!tab.search_active);
1389        assert!(tab.search_query.is_empty());
1390        assert!(tab.search_results.is_empty());
1391    }
1392
1393    #[test]
1394    fn repo_tab_new_has_empty_state() {
1395        let tab = RepoTab::new();
1396        assert!(tab.repo_path.is_none());
1397        assert!(tab.commits.is_empty());
1398        assert!(tab.branches.is_empty());
1399        assert!(tab.unstaged_changes.is_empty());
1400        assert!(tab.staged_changes.is_empty());
1401        assert!(tab.stashes.is_empty());
1402        assert!(tab.remotes.is_empty());
1403        assert!(tab.commit_files.is_empty());
1404        assert!(tab.selected_commit_oid.is_none());
1405        assert!(!tab.is_loading);
1406        assert!(!tab.confirm_discard);
1407        assert_eq!(tab.diff_scroll, 0);
1408        assert_eq!(tab.commit_diff_file_index, 0);
1409    }
1410
1411    #[test]
1412    fn new_tab_switches_to_welcome() {
1413        let mut app = App::new();
1414        app.screen = AppScreen::Main;
1415        app.new_tab();
1416        assert_eq!(app.screen, AppScreen::Welcome);
1417        assert_eq!(app.active_tab_index, 1);
1418    }
1419
1420    #[test]
1421    fn close_tab_last_tab_resets() {
1422        let mut app = App::new();
1423        // Set some state on the only tab
1424        app.tab_mut().search_active = true;
1425        app.tab_mut().search_query = "test".into();
1426
1427        app.close_tab();
1428
1429        // Should reset the tab, not remove it
1430        assert_eq!(app.tabs.len(), 1);
1431        assert!(!app.tab().search_active);
1432        assert!(app.tab().search_query.is_empty());
1433    }
1434
1435    #[test]
1436    fn close_tab_middle_adjusts_index() {
1437        let mut app = App::new();
1438        app.new_tab();
1439        app.new_tab();
1440        // 3 tabs, active = 2
1441
1442        app.active_tab_index = 1; // select middle tab
1443        app.close_tab();
1444
1445        assert_eq!(app.tabs.len(), 2);
1446        assert_eq!(app.active_tab_index, 1); // stays at 1 (now the last)
1447    }
1448
1449    #[test]
1450    fn next_tab_single_tab_no_change() {
1451        let mut app = App::new();
1452        app.next_tab();
1453        assert_eq!(app.active_tab_index, 0);
1454    }
1455
1456    #[test]
1457    fn prev_tab_single_tab_no_change() {
1458        let mut app = App::new();
1459        app.prev_tab();
1460        assert_eq!(app.active_tab_index, 0);
1461    }
1462
1463    #[test]
1464    fn open_browser_sets_dir_browser_screen() {
1465        let mut app = App::new();
1466        app.screen = AppScreen::Main;
1467        app.open_browser(PathBuf::from("/tmp"));
1468        assert_eq!(app.screen, AppScreen::DirBrowser);
1469        assert_eq!(app.browser_return_screen, AppScreen::Main);
1470    }
1471}