Skip to main content

gitwig/tabs/
mod.rs

1use crate::app::{App, Mode};
2use crate::keybindings::Action;
3use crate::repo;
4use crossterm::event::KeyEvent;
5
6pub mod branches;
7pub mod file_history;
8pub mod files;
9pub mod graph;
10pub mod home;
11pub mod logs;
12pub mod remotes;
13pub mod stashes;
14pub mod tags;
15pub mod workspace;
16
17pub use branches::BranchesTab;
18pub use file_history::FileHistoryTab;
19pub use files::FilesTab;
20pub use graph::GraphTab;
21pub use home::HomeTab;
22pub use logs::LogsTab;
23pub use remotes::RemotesTab;
24pub use stashes::StashesTab;
25pub use tags::TagsTab;
26pub use workspace::WorkspaceTab;
27
28pub fn route_detail_event(app: &mut App, key: KeyEvent) -> bool {
29    if app.is_bound(Action::Overview, key) {
30        app.mode = Mode::Overview;
31        app.trigger_overview_load_if_needed();
32        return true;
33    }
34
35    if app.is_bound(Action::CloseDetail, key) {
36        if app.inspect_full_diff {
37            app.inspect_full_diff = false;
38            return true;
39        } else if app.commit_list.search_query.is_some() {
40            app.cancel_commit_search();
41            return true;
42        }
43        if app.advanced_tabs {
44            app.advanced_tabs = false;
45            app.detail_tab = 0;
46            app.set_default_focus_for_tab();
47            if app.get_current_resync_on_tab_change() {
48                app.resync_detail();
49            }
50            return true;
51        }
52        app.close_detail();
53        return true;
54    }
55
56    if app.is_bound(Action::DetailHelp, key) {
57        app.open_detail_help();
58        return true;
59    }
60
61    if app.is_bound(Action::CycleFocusForward, key) {
62        app.cycle_detail_focus(false);
63        return true;
64    }
65
66    if app.is_bound(Action::CycleFocusBackward, key) {
67        app.cycle_detail_focus(true);
68        return true;
69    }
70
71    if app.is_bound(Action::GrowPanel, key) {
72        app.resize_focused_panel(true);
73        return true;
74    }
75
76    if app.is_bound(Action::ShrinkPanel, key) {
77        app.resize_focused_panel(false);
78        return true;
79    }
80
81    if app.is_bound(Action::RefreshDetail, key) {
82        app.resync_detail();
83        app.status_message = Some("Refreshed".to_string());
84        return true;
85    }
86
87    if app.is_bound(Action::CycleTabForward, key) {
88        app.inspect_full_diff = false;
89        if app.advanced_tabs {
90            // Advanced tabs are 7..=11; clamping keeps a stray Primary index
91            // from underflowing.
92            let pos = app.detail_tab.saturating_sub(7).min(4);
93            app.detail_tab = 7 + (pos + 1) % 5;
94        } else {
95            app.detail_tab = (app.detail_tab + 1) % 7;
96        }
97        app.commit_list.selection = 0;
98        app.set_default_focus_for_tab();
99        if app.get_current_resync_on_tab_change() {
100            app.resync_detail();
101        }
102        return true;
103    }
104
105    if app.is_bound(Action::CycleTabBackward, key) {
106        app.inspect_full_diff = false;
107        if app.advanced_tabs {
108            let pos = app.detail_tab.saturating_sub(7).min(4);
109            app.detail_tab = 7 + (pos + 4) % 5;
110        } else {
111            app.detail_tab = if app.detail_tab == 0 { 6 } else { app.detail_tab - 1 };
112        }
113        app.commit_list.selection = 0;
114        app.set_default_focus_for_tab();
115        if app.get_current_resync_on_tab_change() {
116            app.resync_detail();
117        }
118        return true;
119    }
120
121    if app.is_bound(Action::ToggleAdvancedTabs, key) {
122        app.inspect_full_diff = false;
123        if app.advanced_tabs {
124            app.advanced_tabs = false;
125            app.detail_tab = 0;
126        } else {
127            app.advanced_tabs = true;
128            app.detail_tab = 7;
129        }
130        app.commit_list.selection = 0;
131        app.set_default_focus_for_tab();
132        if app.get_current_resync_on_tab_change() {
133            app.resync_detail();
134        }
135        return true;
136    }
137
138    if app.is_bound(Action::GoToTab1, key) {
139        app.inspect_full_diff = false;
140        app.detail_tab = if app.advanced_tabs { 7 } else { 0 };
141        app.commit_list.selection = 0;
142        app.set_default_focus_for_tab();
143        if app.get_current_resync_on_tab_change() {
144            app.resync_detail();
145        }
146        return true;
147    }
148
149    if app.is_bound(Action::GoToTab2, key) {
150        app.inspect_full_diff = false;
151        app.detail_tab = if app.advanced_tabs { 8 } else { 1 };
152        app.commit_list.selection = 0;
153        app.set_default_focus_for_tab();
154        if app.get_current_resync_on_tab_change() {
155            app.resync_detail();
156        }
157        return true;
158    }
159
160    if app.is_bound(Action::GoToTab3, key) {
161        app.inspect_full_diff = false;
162        app.detail_tab = if app.advanced_tabs { 9 } else { 2 };
163        app.commit_list.selection = 0;
164        app.set_default_focus_for_tab();
165        if app.get_current_resync_on_tab_change() {
166            app.resync_detail();
167        }
168        return true;
169    }
170
171    if app.is_bound(Action::GoToTab4, key) {
172        app.inspect_full_diff = false;
173        app.detail_tab = if app.advanced_tabs { 10 } else { 3 };
174        app.commit_list.selection = 0;
175        app.set_default_focus_for_tab();
176        if app.get_current_resync_on_tab_change() {
177            app.resync_detail();
178        }
179        return true;
180    }
181
182    if app.is_bound(Action::GoToTab5, key) {
183        app.inspect_full_diff = false;
184        app.detail_tab = if app.advanced_tabs { 11 } else { 4 };
185        app.commit_list.selection = 0;
186        app.set_default_focus_for_tab();
187        if app.get_current_resync_on_tab_change() {
188            app.resync_detail();
189        }
190        return true;
191    }
192
193    if app.is_bound(Action::GoToTab6, key) {
194        if !app.advanced_tabs {
195            app.inspect_full_diff = false;
196            app.detail_tab = 5;
197            app.commit_list.selection = 0;
198            app.set_default_focus_for_tab();
199            if app.get_current_resync_on_tab_change() {
200                app.resync_detail();
201            }
202            return true;
203        }
204    }
205
206    if app.is_bound(Action::GoToTab7, key) {
207        if !app.advanced_tabs {
208            app.inspect_full_diff = false;
209            app.detail_tab = 6;
210            app.commit_list.selection = 0;
211            app.set_default_focus_for_tab();
212            if app.get_current_resync_on_tab_change() {
213                app.resync_detail();
214            }
215            return true;
216        }
217    }
218
219    match app.detail_tab {
220        0 => return WorkspaceTab::handle_event(app, key),
221        1 => return FilesTab::handle_event(app, key),
222        2 => return GraphTab::handle_event(app, key),
223        3 => return BranchesTab::handle_event(app, key),
224        4 => return TagsTab::handle_event(app, key),
225        5 => return RemotesTab::handle_event(app, key),
226        6 => return StashesTab::handle_event(app, key),
227        7 => return handle_worktree_events(app, key),
228        8 => return handle_submodule_events(app, key),
229        9 => return handle_reflog_events(app, key),
230        10 => return handle_forge_events(app, key),
231        11 => return handle_forge_pr_events(app, key),
232        _ => {}
233    }
234    false
235}
236
237fn handle_forge_events(app: &mut App, key: KeyEvent) -> bool {
238    if app.is_bound(Action::ForgeToggleAssigned, key) {
239        app.forge_issues_assigned_only = !app.forge_issues_assigned_only;
240        if let Some(repo::ItemDetail::Repo { resolved, info }) = &mut app.current_detail {
241            info.forge_issues = repo::TabData::Loading;
242            app.forge_issue_selection = 0;
243            let path = resolved.clone();
244            let assigned_only = app.forge_issues_assigned_only;
245            let tx = app.tab_tx.clone();
246            std::thread::spawn(move || {
247                let res = repo::load_tab_forge_issues(&path, assigned_only);
248                let _ = tx.send((
249                    path.to_string_lossy().to_string(),
250                    10, // tab_idx
251                    repo::TabPayload::ForgeIssues(res),
252                ));
253            });
254        }
255        return true;
256    }
257
258    let issues_count = if let Some(repo::ItemDetail::Repo { info, .. }) = &app.current_detail {
259        if let repo::TabData::Loaded(issues) = &info.forge_issues { issues.len() } else { 0 }
260    } else {
261        0
262    };
263
264    if app.is_bound(Action::DetailMoveDown, key) {
265        if issues_count > 0 {
266            app.forge_issue_selection = (app.forge_issue_selection + 1).min(issues_count - 1);
267        }
268        return true;
269    }
270    if app.is_bound(Action::DetailMoveUp, key) {
271        app.forge_issue_selection = app.forge_issue_selection.saturating_sub(1);
272        return true;
273    }
274    if app.is_bound(Action::DetailPageDown, key) {
275        if issues_count > 0 {
276            app.forge_issue_selection =
277                (app.forge_issue_selection + app.config.page_size).min(issues_count - 1);
278        }
279        return true;
280    }
281    if app.is_bound(Action::DetailPageUp, key) {
282        app.forge_issue_selection = app.forge_issue_selection.saturating_sub(app.config.page_size);
283        return true;
284    }
285    if app.is_bound(Action::DetailHome, key) {
286        app.forge_issue_selection = 0;
287        return true;
288    }
289    if app.is_bound(Action::DetailEnd, key) {
290        if issues_count > 0 {
291            app.forge_issue_selection = issues_count - 1;
292        }
293        return true;
294    }
295    if app.is_bound(Action::ForgeCheckout, key) {
296        if let Some(repo::ItemDetail::Repo { info, .. }) = &app.current_detail {
297            if let repo::TabData::Loaded(issues) = &info.forge_issues {
298                if let Some(issue) = issues.get(app.forge_issue_selection) {
299                    app.forge_checkout_target = Some(crate::app::ForgeCheckoutTarget::Issue {
300                        number: issue.number,
301                        title: issue.title.clone(),
302                    });
303                    app.mode = Mode::ForgeCheckoutConfirm;
304                }
305            }
306        }
307        return true;
308    }
309    if app.is_bound(Action::ForgeOpenBrowser, key) {
310        if let Some(repo::ItemDetail::Repo { info, .. }) = &app.current_detail {
311            if let repo::TabData::Loaded(issues) = &info.forge_issues {
312                if let Some(issue) = issues.get(app.forge_issue_selection) {
313                    repo::open_browser(&issue.url);
314                    app.status_message = Some(format!("Opened issue #{} in browser", issue.number));
315                }
316            }
317        }
318        return true;
319    }
320    false
321}
322
323fn handle_forge_pr_events(app: &mut App, key: KeyEvent) -> bool {
324    let prs_count = if let Some(repo::ItemDetail::Repo { info, .. }) = &app.current_detail {
325        if let repo::TabData::Loaded(prs) = &info.forge_prs { prs.len() } else { 0 }
326    } else {
327        0
328    };
329
330    let old_selection = app.forge_pr_selection;
331    let mut handled = false;
332
333    if app.is_bound(Action::DetailMoveDown, key) {
334        if prs_count > 0 {
335            app.forge_pr_selection = (app.forge_pr_selection + 1).min(prs_count - 1);
336        }
337        handled = true;
338    } else if app.is_bound(Action::DetailMoveUp, key) {
339        app.forge_pr_selection = app.forge_pr_selection.saturating_sub(1);
340        handled = true;
341    } else if app.is_bound(Action::DetailPageDown, key) {
342        if prs_count > 0 {
343            app.forge_pr_selection =
344                (app.forge_pr_selection + app.config.page_size).min(prs_count - 1);
345        }
346        handled = true;
347    } else if app.is_bound(Action::DetailPageUp, key) {
348        app.forge_pr_selection = app.forge_pr_selection.saturating_sub(app.config.page_size);
349        handled = true;
350    } else if app.is_bound(Action::DetailHome, key) {
351        app.forge_pr_selection = 0;
352        handled = true;
353    } else if app.is_bound(Action::DetailEnd, key) {
354        if prs_count > 0 {
355            app.forge_pr_selection = prs_count - 1;
356        }
357        handled = true;
358    } else if app.is_bound(Action::ForgeCheckout, key) {
359        if let Some(repo::ItemDetail::Repo { info, .. }) = &app.current_detail {
360            if let repo::TabData::Loaded(prs) = &info.forge_prs {
361                if let Some(pr) = prs.get(app.forge_pr_selection) {
362                    app.forge_checkout_target =
363                        Some(crate::app::ForgeCheckoutTarget::PullRequest {
364                            number: pr.number,
365                            title: pr.title.clone(),
366                        });
367                    app.mode = Mode::ForgeCheckoutConfirm;
368                }
369            }
370        }
371        handled = true;
372    } else if app.is_bound(Action::ForgeOpenBrowser, key) {
373        if let Some(repo::ItemDetail::Repo { info, .. }) = &app.current_detail {
374            if let repo::TabData::Loaded(prs) = &info.forge_prs {
375                if let Some(pr) = prs.get(app.forge_pr_selection) {
376                    repo::open_browser(&pr.url);
377                    app.status_message = Some(format!("Opened PR #{} in browser", pr.number));
378                }
379            }
380        }
381        handled = true;
382    } else if app.is_bound(Action::ForgeAddComment, key) {
383        if prs_count > 0 {
384            app.mode = Mode::ForgeCommentPathInput;
385            app.input_buffer.clear();
386        }
387        handled = true;
388    }
389
390    if app.forge_pr_selection != old_selection {
391        app.load_comments_for_selected_pr();
392    }
393
394    handled
395}
396
397fn handle_reflog_events(app: &mut App, key: KeyEvent) -> bool {
398    let entries_count = if let Some(repo::ItemDetail::Repo { info, .. }) = &app.current_detail {
399        if let repo::TabData::Loaded(reflog) = &info.reflog { reflog.len() } else { 0 }
400    } else {
401        0
402    };
403
404    if app.is_bound(Action::DetailMoveDown, key) {
405        if entries_count > 0 {
406            app.reflog_selection = (app.reflog_selection + 1).min(entries_count - 1);
407        }
408        return true;
409    }
410    if app.is_bound(Action::DetailMoveUp, key) {
411        app.reflog_selection = app.reflog_selection.saturating_sub(1);
412        return true;
413    }
414    if app.is_bound(Action::DetailPageDown, key) {
415        if entries_count > 0 {
416            app.reflog_selection =
417                (app.reflog_selection + app.config.page_size).min(entries_count - 1);
418        }
419        return true;
420    }
421    if app.is_bound(Action::DetailPageUp, key) {
422        app.reflog_selection = app.reflog_selection.saturating_sub(app.config.page_size);
423        return true;
424    }
425    if app.is_bound(Action::DetailHome, key) {
426        app.reflog_selection = 0;
427        return true;
428    }
429    if app.is_bound(Action::DetailEnd, key) {
430        if entries_count > 0 {
431            app.reflog_selection = entries_count - 1;
432        }
433        return true;
434    }
435    if app.is_bound(Action::ReflogCheckout, key) {
436        // Asks first, as a commit checkout from the Workspace tab does.
437        if let Some(repo::ItemDetail::Repo { info, .. }) = &app.current_detail {
438            if let repo::TabData::Loaded(reflog) = &info.reflog {
439                if let Some(entry) = reflog.get(app.reflog_selection) {
440                    app.commit_action_target_oid = Some(entry.target_oid.clone());
441                    app.mode = Mode::CommitCheckoutConfirm;
442                }
443            }
444        }
445        return true;
446    }
447    false
448}
449
450fn handle_submodule_events(app: &mut App, key: KeyEvent) -> bool {
451    let subs_count = if let Some(repo::ItemDetail::Repo { info, .. }) = &app.current_detail {
452        if let repo::TabData::Loaded(subs) = &info.submodules { subs.len() } else { 0 }
453    } else {
454        0
455    };
456
457    if app.is_bound(Action::DetailMoveDown, key) {
458        if subs_count > 0 {
459            app.submodule_selection = (app.submodule_selection + 1).min(subs_count - 1);
460        }
461        return true;
462    }
463    if app.is_bound(Action::DetailMoveUp, key) {
464        app.submodule_selection = app.submodule_selection.saturating_sub(1);
465        return true;
466    }
467    if app.is_bound(Action::DetailPageDown, key) {
468        if subs_count > 0 {
469            app.submodule_selection =
470                (app.submodule_selection + app.config.page_size).min(subs_count - 1);
471        }
472        return true;
473    }
474    if app.is_bound(Action::DetailPageUp, key) {
475        app.submodule_selection = app.submodule_selection.saturating_sub(app.config.page_size);
476        return true;
477    }
478    if app.is_bound(Action::DetailHome, key) {
479        app.submodule_selection = 0;
480        return true;
481    }
482    if app.is_bound(Action::DetailEnd, key) {
483        if subs_count > 0 {
484            app.submodule_selection = subs_count - 1;
485        }
486        return true;
487    }
488    if app.is_bound(Action::SubmodulesAdd, key) {
489        app.submodule_add_url.clear();
490        app.submodule_add_path.clear();
491        app.input_buffer.clear();
492        app.mode = Mode::SubmoduleAddUrlInput;
493        return true;
494    }
495    if app.is_bound(Action::SubmodulesDelete, key) {
496        if let Some(repo::ItemDetail::Repo { info, .. }) = &app.current_detail {
497            if let repo::TabData::Loaded(subs) = &info.submodules {
498                if let Some(sub) = subs.get(app.submodule_selection) {
499                    app.submodule_delete_target = Some(sub.clone());
500                    app.mode = Mode::SubmoduleDeleteConfirm;
501                }
502            }
503        }
504        return true;
505    }
506    if app.is_bound(Action::SubmodulesUpdate, key) {
507        app.request_submodule_update();
508        return true;
509    }
510    if app.is_bound(Action::SubmodulesUpdateAll, key) {
511        app.request_submodule_update_all();
512        return true;
513    }
514    false
515}
516
517fn handle_worktree_events(app: &mut App, key: KeyEvent) -> bool {
518    let wts_count = if let Some(repo::ItemDetail::Repo { info, .. }) = &app.current_detail {
519        if let repo::TabData::Loaded(wts) = &info.worktrees { wts.len() } else { 0 }
520    } else {
521        0
522    };
523
524    if app.is_bound(Action::DetailMoveDown, key) {
525        if wts_count > 0 {
526            app.worktree_selection = (app.worktree_selection + 1).min(wts_count - 1);
527        }
528        return true;
529    }
530    if app.is_bound(Action::DetailMoveUp, key) {
531        app.worktree_selection = app.worktree_selection.saturating_sub(1);
532        return true;
533    }
534    if app.is_bound(Action::DetailPageDown, key) {
535        if wts_count > 0 {
536            app.worktree_selection =
537                (app.worktree_selection + app.config.page_size).min(wts_count - 1);
538        }
539        return true;
540    }
541    if app.is_bound(Action::DetailPageUp, key) {
542        app.worktree_selection = app.worktree_selection.saturating_sub(app.config.page_size);
543        return true;
544    }
545    if app.is_bound(Action::DetailHome, key) {
546        app.worktree_selection = 0;
547        return true;
548    }
549    if app.is_bound(Action::DetailEnd, key) {
550        if wts_count > 0 {
551            app.worktree_selection = wts_count - 1;
552        }
553        return true;
554    }
555    if app.is_bound(Action::WorktreesAdd, key) {
556        app.worktree_add_branch.clear();
557        app.worktree_add_path.clear();
558        app.input_buffer.clear();
559        app.mode = Mode::WorktreeAddBranchInput;
560        return true;
561    }
562    if app.is_bound(Action::WorktreesLock, key) {
563        if let Some(repo::ItemDetail::Repo { resolved, info }) = &app.current_detail {
564            if let repo::TabData::Loaded(wts) = &info.worktrees {
565                if let Some(wt) = wts.get(app.worktree_selection) {
566                    if wt.is_locked {
567                        match repo::worktree_unlock(resolved, &wt.path) {
568                            Ok(_) => {
569                                app.status_message =
570                                    Some("Worktree unlocked successfully".to_string());
571                                app.resync_detail();
572                            }
573                            Err(e) => {
574                                app.status_message = Some(format!("Failed to unlock: {}", e));
575                            }
576                        }
577                    } else {
578                        app.worktree_lock_reason.clear();
579                        app.input_buffer.clear();
580                        app.mode = Mode::WorktreeLockReasonInput;
581                    }
582                }
583            }
584        }
585        return true;
586    }
587    if app.is_bound(Action::WorktreesDelete, key) {
588        if let Some(repo::ItemDetail::Repo { info, .. }) = &app.current_detail {
589            if let repo::TabData::Loaded(wts) = &info.worktrees {
590                if let Some(wt) = wts.get(app.worktree_selection) {
591                    app.worktree_remove_target = Some(wt.clone());
592                    app.input_buffer.clear();
593                    app.mode = Mode::WorktreeRemoveConfirm;
594                }
595            }
596        }
597        return true;
598    }
599    if app.is_bound(Action::WorktreesPrune, key) {
600        let Some(repo::ItemDetail::Repo { resolved, .. }) = &app.current_detail else {
601            return false;
602        };
603        match repo::worktree_prune(resolved) {
604            Ok(_) => {
605                app.status_message = Some("Pruned stale worktree metadata".to_string());
606                app.resync_detail();
607            }
608            Err(e) => {
609                app.status_message = Some(format!("Prune failed: {}", e));
610            }
611        }
612        return true;
613    }
614    if app.is_bound(Action::WorktreesOpen, key) {
615        if let Some(repo::ItemDetail::Repo { info, .. }) = &app.current_detail {
616            if let repo::TabData::Loaded(wts) = &info.worktrees {
617                if let Some(wt) = wts.get(app.worktree_selection) {
618                    let wt_path = wt.path.clone();
619                    if wt_path.exists() {
620                        let wt_path_str = wt_path.to_string_lossy().to_string();
621                        // Open the entry that already tracks it (under any
622                        // spelling), else add it the way the home screen does:
623                        // pushing only to `config.items` left `statuses` one
624                        // short, so the new row read "missing" and a sorted
625                        // list dropped it.
626                        let item = match app.tracked_item(&wt_path_str) {
627                            Some(item) => item,
628                            None => {
629                                app.add_repo_path(wt_path_str.clone());
630                                wt_path_str
631                            }
632                        };
633                        app.open_repo(item);
634                    } else {
635                        app.status_message =
636                            Some("Worktree path does not exist on disk".to_string());
637                    }
638                }
639            }
640        }
641        return true;
642    }
643    false
644}