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 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, 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 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 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}