1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
//! Background fetch helpers: inbox, detail, SWR kicks, and scroll clamping.
use std::collections::HashMap;
use tracing::{debug, warn};
use crate::github;
use super::actions::Action;
use super::state::App;
use super::types::{
CommentComposerTarget, DetailKind, DetailRef, FirstRunSuggestion, Focus, MutationRefresh,
PendingMutation, PerTabState,
};
impl App {
/// Spawn a background task that fetches the inbox and sends the result back
/// via the action channel. Guards against concurrent fetches via `fetching`.
pub(super) fn spawn_fetch(&mut self, tx: tokio::sync::mpsc::UnboundedSender<Action>) {
if self.fetching {
debug!("fetch already in progress; skipping");
return;
}
let Some(client) = self.client.clone() else {
debug!("no GitHub client; skipping fetch");
return;
};
self.fetching = true;
send_or_warn(&tx, Action::InboxFetchStarted);
// Spawn a supervisor task that awaits an inner task's JoinHandle. If
// the inner task panics, `JoinHandle::await` returns an `Err(JoinError)`
// with `is_panic() == true`, so we can always emit a terminal action —
// neither `InboxLoaded` nor `FetchFailed` must ever be skipped, or the
// `fetching` guard would pin itself on `true` forever.
// Clone what we need before moving into the async block.
let repos = self.config.repos.clone();
let show_all = self.config.show_all_prs;
tokio::spawn(async move {
let inner = tokio::spawn(async move {
if show_all {
client.fetch_inbox_all(&repos).await
} else {
client.fetch_inbox().await
}
});
let action = match inner.await {
Ok(Ok(inbox)) => Action::InboxLoaded(Box::new(inbox)),
Ok(Err(e)) => Action::FetchFailed(e.to_string()),
Err(join_err) if join_err.is_panic() => {
Action::FetchFailed(format!("fetch task panicked: {join_err}"))
}
Err(join_err) => Action::FetchFailed(format!("fetch task aborted: {join_err}")),
};
send_or_warn(&tx, action);
});
}
/// Handle a successfully fetched inbox: store data, update tab badges, clear error state.
pub(super) fn on_inbox_loaded(&mut self, inbox: github::Inbox) {
let viewer_login = inbox.viewer_login.clone();
// Update each tab's needs_action_count from the new inbox. Every open
// issue assigned to the viewer is counted (the assignment query
// `assignee:@me` already limits the set to items the viewer is
// responsible for), while PRs are filtered by primary_flag to exclude
// Clean and Draft states.
for tab in &mut self.tabs.tabs {
let count = inbox
.prs
.iter()
.filter(|pr| pr.repo == tab.repo)
.filter(|pr| {
let flag = pr.primary_flag(&viewer_login);
flag != crate::github::flags::ActionFlag::Clean
&& flag != crate::github::flags::ActionFlag::Draft
})
.count()
+ inbox.issues.iter().filter(|i| i.repo == tab.repo).count();
tab.needs_action_count = Some(count);
}
// Clamp any stale per-repo selection indices so they cannot point past
// the end of the refreshed list (a blocking render bug if a repo
// shrinks between refreshes). `draw_*_list` clamps defensively too,
// but keeping the canonical state consistent avoids subtle surprises
// elsewhere (e.g. the Phase 4 detail view will key off this index).
for (repo, idx) in &mut self.selection {
let max_pr = inbox.prs.iter().filter(|pr| pr.repo == *repo).count();
let max_issue = inbox.issues.iter().filter(|i| i.repo == *repo).count();
let max = max_pr.max(max_issue);
if max == 0 {
*idx = 0;
} else if *idx >= max {
*idx = max - 1;
}
}
// First-run wizard: when config is still empty and focus is on the
// dashboard (not an overlay or detail), compute repo suggestions from
// the inbox and switch to the wizard focus.
if self.config.repos.is_empty() && self.focus == Focus::Dashboard {
let mut counts: HashMap<String, usize> = HashMap::new();
for pr in &inbox.prs {
*counts.entry(pr.repo.clone()).or_insert(0) += 1;
}
for issue in &inbox.issues {
*counts.entry(issue.repo.clone()).or_insert(0) += 1;
}
if !counts.is_empty() {
// Sort by count descending, then alphabetically by repo for
// stable ordering when counts are equal.
let mut suggestions: Vec<FirstRunSuggestion> = counts
.into_iter()
.map(|(repo, count)| FirstRunSuggestion { repo, count, selected: false })
.collect();
suggestions.sort_unstable_by(|a, b| {
b.count.cmp(&a.count).then_with(|| a.repo.cmp(&b.repo))
});
self.first_run_suggestions = suggestions;
self.first_run_cursor = 0;
self.focus = Focus::FirstRun;
}
}
self.inbox = Some(inbox);
self.inbox_loaded_at = Some(chrono::Utc::now());
self.fetching = false;
self.last_fetch_error = None;
}
/// Handle a failed fetch: record the error, keep any cached inbox.
pub(super) fn on_fetch_failed(&mut self, err: String) {
self.fetching = false;
warn!("GitHub inbox fetch failed: {err}");
self.last_fetch_error = Some(err);
}
/// Spawn a background task that fetches PR or issue detail and sends the
/// result back via the action channel.
///
/// Guards against concurrent detail fetches via `detail_fetching`. Uses the
/// same supervisor-task panic-catching pattern as [`Self::spawn_fetch`] so
/// `detail_fetching` is always reset, even if the inner task panics.
pub fn spawn_detail_fetch(
&mut self,
kind: DetailKind,
repo: String,
number: u32,
tx: tokio::sync::mpsc::UnboundedSender<Action>,
) {
if self.detail_fetching {
debug!("detail fetch already in progress; skipping");
return;
}
let Some(client) = self.client.clone() else {
debug!("no GitHub client; skipping detail fetch");
send_or_warn(&tx, Action::DetailFetchFailed("no GitHub client configured".to_owned()));
return;
};
self.detail_fetching = true;
spawn_supervised_detail_fetch(client, kind, repo, number, tx, "detail fetch");
}
/// Background variant of [`Self::spawn_detail_fetch`] used for
/// stale-while-revalidate kicks and auto-refresh.
///
/// Identical to `spawn_detail_fetch` **except** it does NOT set
/// `detail_fetching = true`, so no spinner appears — the user already
/// sees stale content while the fresh payload arrives silently.
///
/// Returns `false` and is a no-op when no client is available.
pub fn spawn_detail_fetch_background(
&self,
kind: DetailKind,
repo: String,
number: u32,
tx: tokio::sync::mpsc::UnboundedSender<Action>,
) -> bool {
let Some(client) = self.client.clone() else {
debug!("no GitHub client; skipping background detail fetch");
return false;
};
// Intentionally no `detail_fetching` guard: background SWR fetches are
// allowed to run even while a foreground fetch is in progress (the
// arriving action handler checks the active tab before overwriting state).
spawn_supervised_detail_fetch(client, kind, repo, number, tx, "bg detail fetch");
true
}
/// Return the filtered + sorted PR list length for the active repo.
pub(super) fn active_list_len(&self) -> usize {
let Some(repo) = self.tabs.active_tab().map(|t| t.repo.clone()) else {
return 0;
};
let Some(inbox) = &self.inbox else {
return 0;
};
let mode = self.session.view_mode(&repo);
match mode {
crate::state::ViewMode::Prs => inbox.prs.iter().filter(|p| p.repo == repo).count(),
crate::state::ViewMode::Issues => {
inbox.issues.iter().filter(|i| i.repo == repo).count()
}
}
}
/// Rebuild the rendered line buffer for the currently selected detail section.
///
/// Copy-mode cursor motion and selection extraction work against the exact
/// same `Vec<Line>` the renderer produces, so we call the same per-section
/// builder here. Returns an empty `Vec` when no detail is loaded.
pub(super) fn current_detail_lines(&self) -> Vec<ratatui::text::Line<'static>> {
if let Some(detail) = &self.pr_detail {
// Resolve scoped patches for copy-mode line building, same logic
// as the renderer in `pr_detail::draw`.
let scoped_patches: Option<&std::collections::HashMap<String, Option<String>>> =
self.selected_commit.and_then(|idx| {
detail.commits.get(idx).and_then(|c| {
self.detail_cache
.get_commit_patches(&detail.repo, &c.sha)
.map(|cached| &cached.data)
})
});
// Resolve the commit-scope SHA for the Comments section, mirroring
// the same logic in `pr_detail::draw`.
let comments_scope_sha: Option<&str> = self
.selected_commit
.and_then(|idx| detail.commits.get(idx).map(|c| c.sha.as_str()));
if self.pr_detail_selected_section == crate::ui::pr_detail::DetailSection::Files
&& self.selected_commit.is_some()
&& scoped_patches.is_none()
{
return vec![ratatui::text::Line::from(ratatui::text::Span::styled(
"Fetching commit diff...".to_owned(),
ratatui::style::Style::default().fg(self.palette.dim),
))];
}
let (lines, _) = crate::ui::pr_detail::build_section(
self.pr_detail_selected_section,
detail,
self.pr_detail_files_cursor,
self.pr_detail_files_show_diff,
self.detail_comments_expanded,
self.detail_show_outdated,
self.thread_index.as_ref(),
&self.pr_detail_expanded_threads,
&self.pr_detail_diff_cursor,
scoped_patches,
self.commits_cursor,
comments_scope_sha,
&self.palette,
self.config.show_ascii_glyphs,
);
return lines;
}
if let Some(detail) = &self.issue_detail {
let (lines, _) = crate::ui::issue_detail::build_content(
detail,
self.detail_comments_expanded,
&self.palette,
self.config.show_ascii_glyphs,
);
return lines;
}
Vec::new()
}
/// Clamp the active section's scroll offset so it can never exceed
/// `rendered_rows - viewport_height`.
///
/// Without this, `G`, `d`, or the scroll wheel past the last line leaves
/// the scroll counter pointing into the void — the renderer shows a blank
/// screen and the user has to press `k` many times to recover.
///
/// The row count must be the **wrapped** (rendered) row count, not the
/// input line count — `ratatui::widgets::Paragraph` with `Wrap` expands
/// long lines into multiple rows, and clamping against the unwrapped
/// length leaves the tail of a wrapped comment unreachable. We build a
/// throwaway `Paragraph` at the current viewport width and ask for
/// `line_count`, which walks the same word-wrapper the renderer uses.
pub(super) fn clamp_pr_detail_scroll(&mut self) {
if !matches!(self.focus, Focus::Detail) {
return;
}
let area = self.pr_detail_right_viewport.get();
if area.height == 0 || area.width == 0 {
return;
}
let rendered_rows = if let Some(rows) = self.cheap_detail_row_count() {
u16::try_from(rows).unwrap_or(u16::MAX)
} else {
let lines = self.current_detail_lines();
// Mirror the renderer's wrap decision: prose sections
// (Description / Checks / Reviews / Comments) wrap, so count the
// wrapped rows; non-wrapping sections use their line count. Files
// usually take the cheap path above so large diffs are not fully
// rendered merely to clamp a scroll offset.
let wraps = self.pr_detail_selected_section
!= crate::ui::pr_detail::DetailSection::Files
&& self.pr_detail_selected_section != crate::ui::pr_detail::DetailSection::Commits;
if wraps {
let probe = ratatui::widgets::Paragraph::new(lines)
.wrap(ratatui::widgets::Wrap { trim: false });
u16::try_from(probe.line_count(area.width)).unwrap_or(u16::MAX)
} else {
u16::try_from(lines.len()).unwrap_or(u16::MAX)
}
};
let max_scroll = rendered_rows.saturating_sub(area.height);
// Route through `right_pane_scroll_mut` so we clamp whichever map
// currently owns the right-pane scroll — the per-section map for
// Description/Checks/Reviews/Comments, or the per-file diff map for
// Files. Without this, scrolling inside a diff grew unbounded
// because the clamp was operating on a different key entirely.
let scroll = self.right_pane_scroll_mut();
if *scroll > max_scroll {
*scroll = max_scroll;
}
}
fn cheap_detail_row_count(&self) -> Option<usize> {
let detail = self.pr_detail.as_ref()?;
let scoped_patches: Option<&std::collections::HashMap<String, Option<String>>> =
self.selected_commit.and_then(|idx| {
detail.commits.get(idx).and_then(|c| {
self.detail_cache
.get_commit_patches(&detail.repo, &c.sha)
.map(|cached| &cached.data)
})
});
if self.pr_detail_selected_section == crate::ui::pr_detail::DetailSection::Files
&& self.selected_commit.is_some()
&& scoped_patches.is_none()
{
return Some(1);
}
crate::ui::pr_detail::cheap_section_row_count(
self.pr_detail_selected_section,
detail,
self.pr_detail_files_cursor,
self.pr_detail_files_show_diff,
self.thread_index.as_ref(),
&self.pr_detail_expanded_threads,
scoped_patches,
)
}
/// Adjust the active section's scroll offset and `copy_mode.h_scroll` so
/// that the cursor is always visible within the last-rendered viewport.
pub(super) fn ensure_cursor_visible(&mut self, lines: &[ratatui::text::Line<'static>]) {
let area = self.pr_detail_right_viewport.get();
let (vw, vh) = (area.width, area.height);
if vh == 0 {
return;
}
let cursor_row = u16::try_from(self.copy_mode.cursor.row).unwrap_or(u16::MAX);
let section = self.pr_detail_selected_section;
{
let scroll = self.scroll_mut(section);
if cursor_row < *scroll {
*scroll = cursor_row;
} else if cursor_row >= scroll.saturating_add(vh) {
*scroll = cursor_row.saturating_sub(vh).saturating_add(1);
}
}
if vw == 0 {
return;
}
let line = lines.get(self.copy_mode.cursor.row);
let cursor_col = line
.map_or(0, |l| crate::ui::copy_mode::cursor_display_col(l, self.copy_mode.cursor.col));
if cursor_col < self.copy_mode.h_scroll {
self.copy_mode.h_scroll = cursor_col;
} else if cursor_col >= self.copy_mode.h_scroll.saturating_add(vw) {
self.copy_mode.h_scroll = cursor_col.saturating_sub(vw).saturating_add(1);
}
}
/// Record what the user is currently looking at in the active tab, so
/// switching away and coming back can land them on the same detail view
/// they left.
///
/// Only the `(repo, number, kind)` reference is stored — the actual
/// payload is dropped and re-fetched on restore. That costs one GraphQL
/// round-trip (surfacing the existing "Fetching pull request…" spinner)
/// but keeps the data fresh: check runs move, reviews arrive, comments
/// land, all between a tab round-trip.
pub(super) fn save_current_tab_state(&mut self) {
let Some(repo) = self.tabs.active_tab().map(|t| t.repo.clone()) else {
return;
};
let detail_ref = if self.focus == Focus::Detail {
if let Some(d) = &self.pr_detail {
Some(DetailRef { repo: d.repo.clone(), number: d.number, kind: DetailKind::Pr })
} else {
self.issue_detail.as_ref().map(|d| DetailRef {
repo: d.repo.clone(),
number: d.number,
kind: DetailKind::Issue,
})
}
} else {
None
};
self.per_tab_state.insert(repo, PerTabState { detail_ref });
}
/// Shared tail logic for `PrDetailLoaded` / `IssueDetailLoaded`:
/// clear the SWR-in-flight marker when the arriving fetch matches, then
/// mark loading as complete regardless.
///
/// # Arguments
///
/// * `repo` - Repository slug of the arriving detail.
/// * `number` - Issue/PR number of the arriving detail.
pub(super) fn clear_detail_loading_markers(&mut self, repo: &str, number: u32) {
if self.detail_refreshing.as_ref().is_some_and(|(r, n)| r == repo && *n == number) {
self.detail_refreshing = None;
}
self.detail_fetching = false;
self.detail_error = None;
}
/// Serve a single kind of detail from cache (with SWR kick if stale) or
/// dispatch a foreground cold fetch.
///
/// Called from `restore_active_tab_state` for both `DetailKind::Pr` and
/// `DetailKind::Issue` to eliminate the parallel match arms.
///
/// # Arguments
///
/// * `kind` - Whether to restore a PR or issue detail.
/// * `repo` - Repository slug.
/// * `number` - PR/issue number.
pub(super) fn restore_detail_kind(&mut self, kind: DetailKind, repo: String, number: u32) {
// The kind-specific part: look up the cache, copy the cached data into
// the matching detail field, and report freshness. `None` means a
// cache miss; `Some(true)` fresh; `Some(false)` stale.
let mut commit_prefetch: Option<(String, Vec<String>)> = None;
let is_fresh: Option<bool> = match kind {
DetailKind::Pr => self.detail_cache.get_pr(&repo, number).map(|c| {
let fresh = c.is_fresh();
let data = c.data.clone();
commit_prefetch = Some((
data.repo.clone(),
data.commits.iter().map(|commit| commit.sha.clone()).collect(),
));
self.thread_index = Some(crate::ui::pr_detail::build_thread_index(&data));
self.pr_detail = Some(data);
fresh
}),
DetailKind::Issue => self.detail_cache.get_issue(&repo, number).map(|c| {
let fresh = c.is_fresh();
let data = c.data.clone();
self.thread_index = None;
self.issue_detail = Some(data);
fresh
}),
};
if let Some((repo, shas)) = commit_prefetch {
self.prefetch_commit_diffs(&repo, shas);
}
// The shared part: SWR flow is identical for PR and issue.
match is_fresh {
None => {
if let Some(tx) = self.action_tx.clone() {
self.spawn_detail_fetch(kind, repo, number, tx);
}
}
Some(true) => {} // cache hit + fresh: nothing more to do
Some(false) => {
let already_refreshing = self
.detail_refreshing
.as_ref()
.is_some_and(|(r, n)| r == &repo && *n == number);
if !already_refreshing {
self.detail_refreshing = Some((repo.clone(), number));
if let Some(tx) = self.action_tx.clone() {
self.spawn_detail_fetch_background(kind, repo, number, tx);
}
}
}
}
}
/// Restore the saved per-tab state for the active tab, clearing stale
/// detail payload and either serving from cache or dispatching a cold fetch.
///
/// **Stale-while-revalidate** behaviour:
/// - Cache hit, fresh → show immediately, no background fetch.
/// - Cache hit, stale → show immediately AND kick a background re-fetch.
/// - Cache miss (cold) → show spinner, dispatch foreground fetch.
///
/// Called right after `tabs.next()` / `tabs.prev()` / `set_active_by_index`.
/// Falls back to `Focus::Dashboard` when no saved detail ref exists.
pub(super) fn restore_active_tab_state(&mut self) {
// Always clear whatever detail was loaded for the previous tab —
// the renderer should never show that tab's data under a different
// repo's header.
self.pr_detail = None;
self.issue_detail = None;
self.thread_index = None;
self.detail_error = None;
self.detail_fetching = false;
self.selected_commit = None;
self.commits_cursor = 0;
let Some(repo) = self.tabs.active_tab().map(|t| t.repo.clone()) else {
self.focus = Focus::Dashboard;
return;
};
let saved = self.per_tab_state.get(&repo).cloned().unwrap_or_default();
let Some(detail_ref) = saved.detail_ref else {
self.focus = Focus::Dashboard;
return;
};
self.focus = Focus::Detail;
let dref_repo = detail_ref.repo.clone();
let dref_number = detail_ref.number;
self.restore_detail_kind(detail_ref.kind, dref_repo, dref_number);
}
/// Push `text` to the system clipboard and display a flash summarising
/// what happened. Takes `&mut flash` rather than `&mut self` so it can be
/// used from copy-mode branches that already borrow other parts of self.
pub(super) fn yank_and_flash(
flash: &mut Option<crate::ui::status_bar::FlashMessage>,
text: &str,
) {
match crate::actions_util::copy_to_clipboard(text) {
Ok(()) => {
let len = text.chars().count();
*flash = Some(crate::ui::status_bar::FlashMessage::new(
format!("Copied {len} chars"),
std::time::Duration::from_secs(2),
));
}
Err(e) => {
*flash = Some(crate::ui::status_bar::FlashMessage::new(
format!("Copy failed: {e}"),
std::time::Duration::from_secs(3),
));
}
}
}
/// Request a per-commit diff fetch unless it is already cached or in-flight.
///
/// Returns `true` only when a new background task was spawned.
pub(super) fn request_commit_diff_fetch(&mut self, repo: String, sha: String) -> bool {
if self.detail_cache.get_commit_patches(&repo, &sha).is_some() {
return false;
}
let key = (repo.clone(), sha.clone());
if self.commit_diff_fetching.contains(&key) {
return false;
}
let (Some(client), Some(tx)) = (self.client.clone(), self.action_tx.clone()) else {
return false;
};
self.commit_diff_fetching.insert(key);
spawn_commit_diff_fetch(client, repo, sha, tx);
true
}
/// Eagerly warm per-commit diff cache entries for a loaded PR detail.
///
/// PR details already cap the commit list at the last 100 commits, so this
/// remains bounded while making Enter-on-commit feel instant for the common
/// case where the user opens the Commits section after the detail view has
/// had a moment to settle.
pub(super) fn prefetch_commit_diffs(&mut self, repo: &str, shas: Vec<String>) {
for sha in shas {
self.request_commit_diff_fetch(repo.to_owned(), sha);
}
}
/// Spawn a supervised pull-request merge mutation.
pub(super) fn spawn_merge_mutation(
&mut self,
repo: String,
number: u32,
method: crate::github::mutations::MergeMethod,
expected_head_sha: String,
) {
if self.pending_mutation.is_some() {
self.show_flash(
"Another GitHub action is still running",
std::time::Duration::from_secs(3),
);
return;
}
let (Some(client), Some(tx)) = (self.client.clone(), self.action_tx.clone()) else {
self.show_flash("No GitHub client configured", std::time::Duration::from_secs(3));
return;
};
let pending = PendingMutation::MergePullRequest { repo: repo.clone(), number, method };
self.pending_mutation = Some(pending.clone());
self.mutation_error = None;
send_or_warn(&tx, Action::MutationStarted(pending));
spawn_merge_mutation(client, repo, number, method, expected_head_sha, tx);
}
/// Spawn a supervised comment mutation.
pub(super) fn spawn_comment_mutation(&mut self, target: CommentComposerTarget, body: String) {
if self.pending_mutation.is_some() {
self.show_flash(
"Another GitHub action is still running",
std::time::Duration::from_secs(3),
);
return;
}
let (Some(client), Some(tx)) = (self.client.clone(), self.action_tx.clone()) else {
self.show_flash("No GitHub client configured", std::time::Duration::from_secs(3));
return;
};
let pending = PendingMutation::SubmitComment { target: target.clone() };
self.pending_mutation = Some(pending.clone());
self.mutation_error = None;
send_or_warn(&tx, Action::MutationStarted(pending));
spawn_comment_mutation(client, target, body, tx);
}
}
// ── GitHub mutations ──────────────────────────────────────────────────────────
fn spawn_merge_mutation(
client: std::sync::Arc<github::Client>,
repo: String,
number: u32,
method: crate::github::mutations::MergeMethod,
expected_head_sha: String,
tx: tokio::sync::mpsc::UnboundedSender<Action>,
) {
tokio::spawn(async move {
let repo_for_err = repo.clone();
let action =
match client.merge_pull_request(&repo, number, method, &expected_head_sha).await {
Ok(outcome) => Action::MutationSucceeded(MutationRefresh {
detail_ref: DetailRef { repo, number, kind: DetailKind::Pr },
message: format!(
"{} complete: {} ({})",
method.label(),
outcome.message,
outcome.sha.chars().take(7).collect::<String>()
),
}),
Err(err) => Action::MutationFailed(format!(
"{} {repo_for_err}#{number} failed: {err}",
method.label()
)),
};
send_or_warn(&tx, action);
});
}
fn spawn_comment_mutation(
client: std::sync::Arc<github::Client>,
target: CommentComposerTarget,
body: String,
tx: tokio::sync::mpsc::UnboundedSender<Action>,
) {
tokio::spawn(async move {
let detail_ref = target.detail_ref();
let result = match &target {
CommentComposerTarget::TopLevel { subject_id, .. } => {
client.add_comment(subject_id, &body).await
}
CommentComposerTarget::ReviewThreadReply { thread_id, .. } => {
client.reply_to_review_thread(thread_id, &body).await
}
};
let action = match result {
Ok(()) => Action::MutationSucceeded(MutationRefresh {
detail_ref,
message: "Comment posted".to_owned(),
}),
Err(err) => Action::MutationFailed(format!("Comment failed: {err}")),
};
send_or_warn(&tx, action);
});
}
// ── Per-commit diff fetch ─────────────────────────────────────────────────────
/// Spawn a background task that fetches the file-patch map for a single commit
/// and sends [`Action::CommitDiffLoaded`] or [`Action::CommitDiffFailed`] back
/// on `tx`.
///
/// There is intentionally no `detail_fetching` guard: this is an on-demand
/// fetch triggered by the user pressing `Enter` on a commit row, and the key
/// handler verifies the cache before calling here.
///
/// # Arguments
///
/// * `client` - Authenticated GitHub client.
/// * `repo` - Repository slug in `owner/name` form.
/// * `sha` - Full 40-character commit SHA.
/// * `tx` - Action-channel sender for the result.
pub fn spawn_commit_diff_fetch(
client: std::sync::Arc<github::Client>,
repo: String,
sha: String,
tx: tokio::sync::mpsc::UnboundedSender<Action>,
) {
tokio::spawn(async move {
let inner: tokio::task::JoinHandle<anyhow::Result<Action>> = {
let client = client.clone();
let repo2 = repo.clone();
let sha2 = sha.clone();
tokio::spawn(async move {
let patches = client.fetch_commit_diff(&repo2, &sha2).await?;
Ok(Action::CommitDiffLoaded(repo2, sha2, patches))
})
};
let action = match inner.await {
Ok(Ok(action)) => action,
Ok(Err(e)) => Action::CommitDiffFailed(repo, sha, e.to_string()),
Err(join_err) if join_err.is_panic() => Action::CommitDiffFailed(
repo,
sha,
format!("commit diff task panicked: {join_err}"),
),
Err(join_err) => {
Action::CommitDiffFailed(repo, sha, format!("commit diff task aborted: {join_err}"))
}
};
send_or_warn(&tx, action);
});
}
// ── Free helpers shared by the spawn_* methods ────────────────────────────────
/// Send `action` on the action channel, logging a warning if the receiver is
/// gone rather than silently dropping the message.
///
/// Every fetch task terminates by handing one action back to the event loop;
/// a dropped receiver means the TUI has already quit or the event thread
/// crashed. Neither case is recoverable here — but a warn-level log surfaces
/// receiver-shutdown races during development instead of leaving them
/// invisible under a `let _ =`.
fn send_or_warn(tx: &tokio::sync::mpsc::UnboundedSender<Action>, action: Action) {
if let Err(err) = tx.send(action) {
warn!("action channel closed; dropping fetch result: {err}");
}
}
/// Spawn the supervisor + inner-task pair that performs a PR or issue detail
/// fetch and sends the resulting [`Action`] back on `tx`.
///
/// Shared by [`App::spawn_detail_fetch`] (foreground) and
/// [`App::spawn_detail_fetch_background`] (SWR / auto-refresh). The `label`
/// is used only in panic / abort error messages so foreground and background
/// failures are distinguishable in logs.
fn spawn_supervised_detail_fetch(
client: std::sync::Arc<github::Client>,
kind: DetailKind,
repo: String,
number: u32,
tx: tokio::sync::mpsc::UnboundedSender<Action>,
label: &'static str,
) {
tokio::spawn(async move {
let inner: tokio::task::JoinHandle<anyhow::Result<Action>> = tokio::spawn(async move {
match kind {
DetailKind::Pr => {
let detail = client.fetch_pr_detail(&repo, number).await?;
Ok(Action::PrDetailLoaded(Box::new(detail)))
}
DetailKind::Issue => {
let detail = client.fetch_issue_detail(&repo, number).await?;
Ok(Action::IssueDetailLoaded(Box::new(detail)))
}
}
});
let action = match inner.await {
Ok(Ok(action)) => action,
Ok(Err(e)) => Action::DetailFetchFailed(e.to_string()),
Err(join_err) if join_err.is_panic() => {
Action::DetailFetchFailed(format!("{label} task panicked: {join_err}"))
}
Err(join_err) => Action::DetailFetchFailed(format!("{label} task aborted: {join_err}")),
};
send_or_warn(&tx, action);
});
}