feat(github): show a pull request's checks, reviews and merge state (#1000)

The PR detail now leads with what it is usually opened to find out:

- Checks: check runs and commit statuses on the head commit, failures
  first, with durations and links to their logs. A section with more
  than five folds the passed and skipped ones into one row.
- Reviews: one row per reviewer (approved, changes requested,
  commented, requested), folding the way GitHub's sidebar reads them.
- A merge-state line under the branches ("Waiting on 1 check",
  "1 check failing", "Merge conflicts", "Ready to merge", ...).

Checks and reviews that cannot be read are left out instead of failing
the whole detail. While a check is running, just the checks are re-read
every 20 seconds (signed in only); once every verdict is in, the detail
is read again for the new merge state.

The list pins the pull request of the pane's branch under the repo row,
looked up through the branch's upstream so a fork's branch is found
under the fork's owner.

Long sections fold: comments past four keep the first and the latest
two, long descriptions and comments are clamped behind "Show full text",
and reviewers and changed files show a few with a "show all" row.
This commit is contained in:
l0ng-ai
2026-09-28 20:46:49 +08:00
committed by GitHub
parent 2938a41a1c
commit 2e0d4de136
15 changed files with 2242 additions and 36 deletions
+587 -23
View File
@@ -3,9 +3,10 @@
//! Title, state, author, labels, the body and the conversation as Markdown —
//! through gpui-component's `TextView`, after `core::github::markdown` has
//! turned images into links and disarmed non-web link targets — and, for a
//! pull request, its branches and changed files. A file opens in the diff
//! overlay, the same surface a commit's files open in, fed the patch GitHub
//! sent rather than one git read.
//! pull request, its branches, where it stands on merging, its checks, its
//! reviewers and its changed files. A file opens in the diff overlay, the
//! same surface a commit's files open in, fed the patch GitHub sent rather
//! than one git read.
use std::sync::Arc;
@@ -13,12 +14,17 @@ use gpui::{AnyElement, Context, SharedString, Window, div, prelude::*, px, rems}
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex};
use tty7_core::core::git::diff::{CommitLabel, DiffBudget, DiffSnapshot, DiffSource};
use tty7_core::core::github::{Comment, Detail, PrFile, RepoSlug};
use tty7_core::core::github::{
Check, CheckState, Checks, Comment, Detail, PrFile, Readiness, RepoSlug, ReviewState, Reviewer,
readiness,
};
use crate::ui::app::{CONTENT_INSET, Tty7App};
use crate::ui::github::now_unix;
use crate::ui::github::{Fold, now_unix};
use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural};
use crate::ui::panel_github::{describe_error, github_tile, label_chip, state_glyph, state_label};
use crate::ui::panel_github::{
check_glyph, check_label, describe_error, github_tile, label_chip, state_glyph, state_label,
};
use crate::ui::right_panel::{
HEADING, META, META_MONO, ROW_FILL_RADIUS, ROW_INSET, TEXT, TEXT_INSET, git_badge,
};
@@ -28,6 +34,27 @@ use crate::ui::scm::status::{status_color, status_glyph};
const ROW_H: f32 = 26.;
const SECTION_GAP: f32 = 16.;
/// Past this many checks, the ones that passed or were skipped fold into one
/// row: a repository with a build matrix has dozens, and the one that failed
/// is what the section is read for.
const CHECKS_FOLD_AT: usize = 5;
/// Past this many changed files, only the first [`FILES_SHOWN_FOLDED`] show
/// until the list is unfolded — the conversation under it stays in reach.
const FILES_FOLD_AT: usize = 10;
const FILES_SHOWN_FOLDED: usize = 8;
/// Past this many reviewers, only the first few show.
const REVIEWERS_FOLD_AT: usize = 5;
/// Past this many comments, the middle folds away the way GitHub's own
/// timeline does: the opening one stays, and the latest few.
const COMMENTS_FOLD_AT: usize = 4;
const COMMENTS_KEPT_LATEST: usize = 2;
/// A description or comment longer than this is clamped to [`CLAMP_HEIGHT`]
/// until unfolded. Judged on the source, since the rendered height is only
/// known after layout; a pasted log or a long template trips it, a few
/// paragraphs do not.
const CLAMP_LINES: usize = 18;
const CLAMP_CHARS: usize = 1600;
const CLAMP_HEIGHT: f32 = 16.;
impl Tty7App {
/// The rows pinned over the detail, and the detail itself.
@@ -74,13 +101,34 @@ impl Tty7App {
let mut body = v_flex()
.pb(px(16.))
.child(self.github_detail_head(&detail, cx))
.child(markdown_block(
format!("gh-body-{}-{number}", slug.full()),
&detail.body,
t(L10nKey::GitHubNoDescription),
.child(self.github_detail_head(&detail, cx));
// What a pull request is usually opened to find out — can it go in,
// and if not, what is it waiting for — before what it says.
if let Some(checks) = detail.checks.as_ref().filter(|c| !c.items.is_empty()) {
let unfolded = self.github_unfolded(slug, number, Fold::Checks);
let toggle = self.github_fold_toggle(slug, number, Fold::Checks, cx);
body = body.child(checks_section(
checks,
&detail.item.html_url,
unfolded,
toggle,
cx,
));
}
if let Some(reviewers) = detail.reviewers.as_ref().filter(|r| !r.is_empty()) {
let unfolded = self.github_unfolded(slug, number, Fold::Reviews);
let toggle = self.github_fold_toggle(slug, number, Fold::Reviews, cx);
body = body.child(reviews_section(reviewers, unfolded, toggle, cx));
}
body = body.child(self.github_long_markdown(
format!("gh-body-{}-{number}", slug.full()),
&detail.body,
t(L10nKey::GitHubNoDescription),
slug,
number,
Fold::Body,
cx,
));
if let Some(err) = error {
// A refresh that failed over a detail already on screen: keep the
// detail, and say why it may be out of date.
@@ -94,6 +142,26 @@ impl Tty7App {
(pinned, body.into_any_element())
}
fn github_unfolded(&self, slug: &RepoSlug, number: u64, fold: Fold) -> bool {
self.github.unfolded.contains(&(slug.clone(), number, fold))
}
fn github_fold_toggle(
&self,
slug: &RepoSlug,
number: u64,
fold: Fold,
cx: &mut Context<Self>,
) -> impl Fn(&gpui::ClickEvent, &mut Window, &mut gpui::App) + 'static {
let key = (slug.clone(), number, fold);
cx.listener(move |this, _, _window, cx| {
if !this.github.unfolded.remove(&key) {
this.github.unfolded.insert(key.clone());
}
cx.notify();
})
}
fn github_back_row(
&self,
repo: &RepoKey,
@@ -247,6 +315,15 @@ impl Tty7App {
.child(t_plural(L10nKey::GitHubCommits, pull.commits as usize, &[])),
),
);
let ready = readiness(
item.state,
pull.merge_state,
detail.checks.as_ref(),
detail.reviewers.as_deref(),
);
if let Some(ready) = ready {
head = head.child(readiness_line(ready, cx));
}
}
if !item.labels.is_empty() {
head = head.child(
@@ -274,8 +351,53 @@ impl Tty7App {
t_plural(L10nKey::GitHubComments, count, &[]),
cx,
));
for c in &detail.comments {
section = section.child(comment_block(slug, number, c, now, fg, muted, cx));
let total = detail.comments.len();
let foldable = total > COMMENTS_FOLD_AT;
let unfolded = self.github_unfolded(slug, number, Fold::Comments);
let hidden = if foldable && !unfolded {
1..total - COMMENTS_KEPT_LATEST
} else {
0..0
};
for (i, c) in detail.comments.iter().enumerate() {
if hidden.contains(&i) {
if i == hidden.start {
let toggle = self.github_fold_toggle(slug, number, Fold::Comments, cx);
let text = t_plural(L10nKey::GitHubShowHiddenComments, hidden.len(), &[]);
section = section.child(
div().pt(px(10.)).px(px(CONTENT_INSET)).child(
fold_row("panel-github-fold-comments", None, text, false, cx)
.on_click(toggle),
),
);
}
continue;
}
let body = self.github_long_markdown(
format!("gh-comment-{}-{number}-{}", slug.full(), c.id),
&c.body,
"",
slug,
number,
Fold::Comment(c.id),
cx,
);
section = section.child(comment_block(c, now, fg, muted, body));
}
if foldable && unfolded {
let toggle = self.github_fold_toggle(slug, number, Fold::Comments, cx);
section = section.child(
div().pt(px(10.)).px(px(CONTENT_INSET)).child(
fold_row(
"panel-github-fold-comments",
None,
t(L10nKey::GitHubShowLess).to_string(),
true,
cx,
)
.on_click(toggle),
),
);
}
if detail.comments_truncated {
section = section.child(more_on_github(
@@ -287,6 +409,55 @@ impl Tty7App {
section.into_any_element()
}
/// Markdown that is clamped, with a row to unfold it, when its source
/// runs long.
#[allow(clippy::too_many_arguments)]
fn github_long_markdown(
&self,
id: String,
source: &str,
empty: &str,
slug: &RepoSlug,
number: u64,
fold: Fold,
cx: &mut Context<Self>,
) -> AnyElement {
let block = markdown_block(id.clone(), source, empty, cx);
if !runs_long(source) {
return block;
}
let unfolded = self.github_unfolded(slug, number, fold);
let toggle = self.github_fold_toggle(slug, number, fold, cx);
let text = if unfolded {
t(L10nKey::GitHubShowLess)
} else {
t(L10nKey::GitHubShowFullText)
};
v_flex()
.child(if unfolded {
block
} else {
div()
.max_h(rems(CLAMP_HEIGHT))
.overflow_hidden()
.child(block)
.into_any_element()
})
.child(
div().pt(px(4.)).px(px(CONTENT_INSET)).child(
fold_row(
SharedString::from(format!("{id}-fold")),
None,
text.to_string(),
unfolded,
cx,
)
.on_click(toggle),
),
)
.into_any_element()
}
fn github_detail_files(
&self,
repo: &RepoKey,
@@ -339,13 +510,37 @@ impl Tty7App {
.child(div().text_color(added_ink).child(format!("+{added}")))
.child(div().text_color(removed_ink).child(format!("−{removed}"))),
);
let number = detail.item.number;
let foldable = files.len() > FILES_FOLD_AT;
let unfolded = self.github_unfolded(slug, number, Fold::Files);
// The file open in the diff overlay stays listed, folded or not.
let shown = |i: usize, f: &PrFile| {
!foldable
|| unfolded
|| i < FILES_SHOWN_FOLDED
|| focused.as_deref() == Some(f.path.as_str())
};
let mut rows = v_flex().px(px(CONTENT_INSET));
for (i, file) in files.iter().enumerate() {
if !shown(i, file) {
continue;
}
let selected = focused.as_deref() == Some(file.path.as_str());
rows = rows.child(
self.github_file_row(i, file, selected, repo, &source, &head_ref, &files, cx),
);
}
if foldable {
let text = if unfolded {
t(L10nKey::GitHubShowLess).to_string()
} else {
t_plural(L10nKey::GitHubShowAllFiles, files.len(), &[])
};
let toggle = self.github_fold_toggle(slug, number, Fold::Files, cx);
rows = rows.child(
fold_row("panel-github-fold-files", None, text, unfolded, cx).on_click(toggle),
);
}
let mut section = v_flex().mt(px(SECTION_GAP)).child(heading).child(rows);
if detail.files_truncated {
section = section.child(more_on_github(
@@ -460,6 +655,351 @@ impl Tty7App {
}
}
/// The merge box's one line: a glyph in the colour of how close it is, and
/// the most pressing reason it is not in yet.
fn readiness_line(ready: Readiness, cx: &gpui::App) -> AnyElement {
let theme = cx.theme();
let (glyph, ink, text) = match ready {
Readiness::Ready => (
CheckState::Passed,
theme.success,
t(L10nKey::GitHubReadyToMerge).to_string(),
),
Readiness::Conflicts => (
CheckState::Failed,
theme.danger,
t(L10nKey::GitHubMergeConflicts).to_string(),
),
Readiness::ChecksFailing(n) => (
CheckState::Failed,
theme.danger,
t_plural(L10nKey::GitHubChecksFailing, n, &[]),
),
Readiness::ChangesRequested => (
CheckState::Failed,
theme.danger,
t(L10nKey::GitHubReviewChangesRequested).to_string(),
),
Readiness::WaitingOnChecks(n) => (
CheckState::Pending,
theme.warning,
t_plural(L10nKey::GitHubWaitingOnChecks, n, &[]),
),
Readiness::ReviewRequired => (
CheckState::Pending,
theme.warning,
t(L10nKey::GitHubReviewRequired).to_string(),
),
Readiness::Behind => (
CheckState::Pending,
theme.warning,
t(L10nKey::GitHubBehindBase).to_string(),
),
Readiness::Blocked => (
CheckState::Failed,
theme.warning,
t(L10nKey::GitHubMergeBlocked).to_string(),
),
};
h_flex()
.items_center()
.gap(px(6.))
.child(check_glyph(glyph, cx))
.child(
div()
.text_size(rems(META))
.font_weight(gpui::FontWeight::MEDIUM)
.text_color(ink)
.child(text),
)
.into_any_element()
}
/// `18s`, `1m 12s`, `14m`, `2h 5m` — as short as GitHub's own check list.
pub(crate) fn short_duration(secs: i64) -> String {
let secs = secs.max(0);
let (h, m, s) = (secs / 3600, secs / 60 % 60, secs % 60);
match (h, m, s) {
(0, 0, s) => format!("{s}s"),
(0, m, 0) => format!("{m}m"),
(0, m, s) if m < 10 => format!("{m}m {s}s"),
(0, m, _) => format!("{m}m"),
(h, 0, _) => format!("{h}h"),
(h, m, _) => format!("{h}h {m}m"),
}
}
/// How long a check took, or has been running. Nothing for a check that
/// never said when it started (a commit status, a queued run).
fn check_duration(check: &Check, now: i64) -> Option<String> {
if check.started_at <= 0 {
return None;
}
let end = match check.state {
CheckState::Pending => now,
_ if check.completed_at >= check.started_at => check.completed_at,
_ => return None,
};
Some(short_duration(end - check.started_at))
}
fn checks_section(
checks: &Checks,
html_url: &str,
unfolded: bool,
toggle: impl Fn(&gpui::ClickEvent, &mut Window, &mut gpui::App) + 'static,
cx: &gpui::App,
) -> AnyElement {
let theme = cx.theme();
let muted = theme.muted_foreground;
let mono = theme.mono_font_family.clone();
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
let now = now_unix();
let counted = checks.counted();
let summary = if counted == 0 {
t(L10nKey::GitHubChecksNoneCounted).to_string()
} else {
t_fmt(
L10nKey::GitHubChecksPassed,
&[
("passed", &checks.count(CheckState::Passed).to_string()),
("total", &counted.to_string()),
],
)
};
let heading = h_flex()
.items_center()
.gap(px(6.))
.min_h(px(22.))
.px(px(TEXT_INSET))
.child(
div()
.text_size(rems(HEADING))
.font_weight(gpui::FontWeight::MEDIUM)
.text_color(muted)
.child(t(L10nKey::GitHubChecks)),
)
.child(div().flex_1())
.child(div().text_size(rems(META)).text_color(muted).child(summary));
let foldable = checks.items.len() > CHECKS_FOLD_AT;
let folded = foldable && !unfolded;
let quiet = |s: CheckState| matches!(s, CheckState::Passed | CheckState::Skipped);
let mut rows = v_flex().px(px(CONTENT_INSET));
for (i, check) in checks.items.iter().enumerate() {
if folded && quiet(check.state) {
continue;
}
let duration = check_duration(check, now);
let state = check_label(check.state);
let mut row = h_flex()
.id(("panel-github-check", i))
.items_center()
.gap(px(8.))
.h(px(ROW_H))
.w_full()
.min_w_0()
.px(px(ROW_INSET))
.rounded(ROW_FILL_RADIUS)
.tooltip(move |window, cx| {
gpui_component::tooltip::Tooltip::new(state).build(window, cx)
})
.child(check_glyph(check.state, cx))
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_size(rems(TEXT))
.text_color(if check.state == CheckState::Skipped {
muted
} else {
gpui::rgb(sf.text_resting).into()
})
.child(check.name.clone()),
)
.children(duration.map(|d| {
div()
.flex_none()
.text_size(rems(META_MONO))
.font_family(mono.clone())
.text_color(muted)
.child(d)
}));
if let Some(url) = check.url.clone() {
row = row
.cursor_pointer()
.hover(|s| s.bg(gpui::rgb(sf.hover)))
.on_click(move |_, _window, cx| cx.open_url(&url));
}
rows = rows.child(row);
}
if foldable {
let (passed, skipped) = (
checks.count(CheckState::Passed),
checks.count(CheckState::Skipped),
);
let (glyph, text) = if unfolded {
(None, t(L10nKey::GitHubShowLess).to_string())
} else {
let mut parts = Vec::new();
if passed > 0 {
parts.push(t_plural(L10nKey::GitHubPassedCount, passed, &[]));
}
if skipped > 0 {
parts.push(t_plural(L10nKey::GitHubSkippedCount, skipped, &[]));
}
let glyph = if passed > 0 {
CheckState::Passed
} else {
CheckState::Skipped
};
(Some(glyph), parts.join(" · "))
};
rows = rows.child(
fold_row("panel-github-fold-checks", glyph, text, unfolded, cx).on_click(toggle),
);
}
let mut section = v_flex().mt(px(SECTION_GAP)).child(heading).child(rows);
if checks.truncated {
section = section.child(more_on_github(
"panel-github-more-checks",
format!("{html_url}/checks"),
cx,
));
}
section.into_any_element()
}
/// The row a folded section ends in: what is tucked away (or "show less"),
/// and a chevron saying which way it goes.
fn fold_row(
id: impl Into<gpui::ElementId>,
glyph: Option<CheckState>,
text: String,
unfolded: bool,
cx: &gpui::App,
) -> gpui::Stateful<gpui::Div> {
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
let muted = cx.theme().muted_foreground;
h_flex()
.id(id)
.items_center()
.gap(px(8.))
.h(px(ROW_H))
.w_full()
.min_w_0()
.px(px(ROW_INSET))
.rounded(ROW_FILL_RADIUS)
.cursor_pointer()
.hover(|s| s.bg(gpui::rgb(sf.hover)))
.children(glyph.map(|g| check_glyph(g, cx)))
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_size(rems(META))
.text_color(muted)
.child(text),
)
.child(
Icon::new(if unfolded {
IconName::ChevronUp
} else {
IconName::ChevronDown
})
.xsmall()
.text_color(muted),
)
}
fn reviews_section(
reviewers: &[Reviewer],
unfolded: bool,
toggle: impl Fn(&gpui::ClickEvent, &mut Window, &mut gpui::App) + 'static,
cx: &gpui::App,
) -> AnyElement {
let theme = cx.theme();
let muted = theme.muted_foreground;
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
let foldable = reviewers.len() > REVIEWERS_FOLD_AT;
let shown = if foldable && !unfolded {
REVIEWERS_FOLD_AT - 1
} else {
reviewers.len()
};
let mut rows = v_flex().px(px(CONTENT_INSET));
for (i, r) in reviewers.iter().enumerate().take(shown) {
let (label, ink) = match r.state {
ReviewState::Approved => (L10nKey::GitHubReviewApproved, theme.success),
ReviewState::ChangesRequested => (L10nKey::GitHubReviewChangesRequested, theme.danger),
ReviewState::Commented => (L10nKey::GitHubReviewCommented, muted),
ReviewState::Requested => (L10nKey::GitHubReviewRequested, muted),
};
let initial = r
.login
.chars()
.next()
.map(|c| c.to_uppercase().to_string())
.unwrap_or_default();
rows = rows.child(
h_flex()
.id(("panel-github-reviewer", i))
.items_center()
.gap(px(8.))
.h(px(ROW_H))
.w_full()
.min_w_0()
.px(px(ROW_INSET))
.child(
div()
.flex_none()
.flex()
.items_center()
.justify_center()
.size(px(16.))
.rounded_full()
.bg(theme.foreground.opacity(0.08))
.text_size(rems(10. / 16.))
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_color(muted)
.child(initial),
)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_size(rems(TEXT))
.text_color(gpui::rgb(sf.text_resting))
.child(r.login.clone()),
)
.child(
div()
.flex_none()
.text_size(rems(META))
.text_color(ink)
.child(t(label)),
),
);
}
if foldable {
let text = if unfolded {
t(L10nKey::GitHubShowLess).to_string()
} else {
t_plural(L10nKey::GitHubShowAllReviewers, reviewers.len(), &[])
};
rows = rows.child(
fold_row("panel-github-fold-reviews", None, text, unfolded, cx).on_click(toggle),
);
}
v_flex()
.mt(px(SECTION_GAP))
.child(section_heading(t(L10nKey::GitHubReviews).to_string(), cx))
.child(rows)
.into_any_element()
}
/// Headings a step or two over the body rather than the document-sized ramp
/// the text view defaults to: an issue's `## What happened?` is a label in a
/// 280px column, not a page title.
@@ -554,15 +1094,12 @@ fn renders_safely(node: &gpui_component::text::markdown_ast::Node) -> bool {
.is_none_or(|children| children.iter().all(renders_safely))
}
#[allow(clippy::too_many_arguments)]
fn comment_block(
slug: &RepoSlug,
number: u64,
c: &Comment,
now: i64,
fg: gpui::Hsla,
muted: gpui::Hsla,
cx: &gpui::App,
body: AnyElement,
) -> AnyElement {
let when = (c.created_at > 0).then(|| relative_time(now, c.created_at));
v_flex()
@@ -582,15 +1119,15 @@ fn comment_block(
)
.children(when.map(|w| div().text_color(muted).child(w))),
)
.child(markdown_block(
format!("gh-comment-{}-{number}-{}", slug.full(), c.id),
&c.body,
"",
cx,
))
.child(body)
.into_any_element()
}
/// Whether a Markdown source is long enough to clamp.
fn runs_long(source: &str) -> bool {
source.chars().count() > CLAMP_CHARS || source.lines().count() > CLAMP_LINES
}
fn more_on_github(id: &'static str, url: String, cx: &gpui::App) -> AnyElement {
h_flex()
.id(id)
@@ -607,3 +1144,30 @@ fn more_on_github(id: &'static str, url: String, cx: &gpui::App) -> AnyElement {
.child(Icon::new(IconName::ExternalLink).xsmall())
.into_any_element()
}
#[cfg(test)]
mod tests {
use super::{runs_long, short_duration};
#[test]
fn a_pasted_log_runs_long_and_a_few_paragraphs_do_not() {
assert!(!runs_long("It crashes.\n\nSteps:\n1. open\n2. resize"));
assert!(runs_long(&"line\n".repeat(40)));
assert!(runs_long(&"word ".repeat(400)));
}
#[test]
fn durations_read_as_short_as_githubs() {
assert_eq!(short_duration(18), "18s");
assert_eq!(short_duration(120), "2m");
assert_eq!(short_duration(72), "1m 12s");
assert_eq!(short_duration(14 * 60 + 5), "14m");
assert_eq!(short_duration(3600), "1h");
assert_eq!(short_duration(2 * 3600 + 5 * 60), "2h 5m");
assert_eq!(
short_duration(-3),
"0s",
"a clock skew is not negative time"
);
}
}
+375
View File
@@ -20,6 +20,12 @@
//! tabs or panes back and forth does not refetch; an entry older than
//! [`STALE_AFTER`] is revalidated in the background while the old rows stay
//! on screen.
//!
//! Two things are fresher than that. A pull request on screen with checks
//! still running has just its checks re-read every [`CHECKS_POLL`], signed in
//! only, so "waiting on 1 check" turns into a verdict without anyone pressing
//! refresh. And the pane's branch is read every [`BRANCH_TTL`], so the pull
//! request pinned over the list follows a `git switch`.
pub(crate) mod detail;
@@ -33,6 +39,9 @@ use tty7_core::core::github::{
ApiError, Detail, GitHubRemote, Item, Kind, RepoSlug, StateFilter, Transport,
};
use tty7_core::core::config::RightPanelTab;
use tty7_core::core::github::CheckState;
use crate::ui::app::Tty7App;
use crate::ui::host_ops::SharedHost;
use crate::ui::scm::panel::RepoLookup;
@@ -41,6 +50,13 @@ use crate::ui::scm::state::RepoKey;
/// How old a cached list or detail may get before a render revalidates it.
pub(crate) const STALE_AFTER: Duration = Duration::from_secs(120);
/// How often an open pull request's running checks are re-read. Two requests
/// a go — well inside a signed-in rate limit, which is the only one polled.
pub(crate) const CHECKS_POLL: Duration = Duration::from_secs(20);
/// How long the pane's branch is trusted before it is read again.
const BRANCH_TTL: Duration = Duration::from_secs(10);
/// How far a list reads on by itself through pages that filter down to
/// nothing (a pull-request-heavy repository's `/issues`), before it leaves
/// the rest to "load more".
@@ -82,6 +98,41 @@ pub(crate) struct DetailCache {
pub(crate) error: Option<ApiError>,
pub(crate) fetched: Option<Instant>,
seq: u64,
/// A checks poll is scheduled or in flight.
polling: bool,
}
/// The pane's branch, and the branch it pushes to.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct BranchHead {
pub(crate) branch: String,
/// `origin/feat/x`, when it tracks one.
pub(crate) upstream: Option<String>,
}
#[derive(Default)]
pub(crate) struct BranchLookup {
/// `None` on a detached HEAD, or before the first answer.
pub(crate) head: Option<BranchHead>,
pub(crate) read_at: Option<Instant>,
pub(crate) loading: bool,
}
/// Which pull request a branch would have: opened against `slug`, from
/// `owner`'s `branch`.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct BranchPullKey {
pub(crate) slug: RepoSlug,
pub(crate) owner: String,
pub(crate) branch: String,
}
#[derive(Default)]
pub(crate) struct BranchPullCache {
pub(crate) item: Option<Item>,
pub(crate) loading: bool,
pub(crate) error: bool,
pub(crate) fetched: Option<Instant>,
}
#[derive(Default)]
@@ -105,6 +156,24 @@ pub(crate) struct GitHubPanelState {
/// The list row under the pointer, by number: the one that shows its
/// labels and age.
pub(crate) hovered: Option<u64>,
pub(crate) branches: HashMap<RepoKey, BranchLookup>,
pub(crate) branch_pulls: HashMap<BranchPullKey, BranchPullCache>,
/// The long sections of a detail the user unfolded.
pub(crate) unfolded: std::collections::HashSet<(RepoSlug, u64, Fold)>,
}
/// A detail section that folds when it runs long.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum Fold {
Checks,
Files,
Reviews,
/// The comments between the first and the last few.
Comments,
/// The description, clamped when long.
Body,
/// One long comment, by id.
Comment(u64),
}
/// What the panel can say about the active pane's repository.
@@ -114,6 +183,7 @@ pub(crate) enum GhTarget {
NotARepo,
NoRemote,
Ready {
host: SharedHost,
repo: RepoKey,
remotes: Arc<Vec<GitHubRemote>>,
chosen: GitHubRemote,
@@ -221,6 +291,7 @@ impl Tty7App {
Some(chosen) => {
let chosen = chosen.clone();
GhTarget::Ready {
host,
repo,
remotes,
chosen,
@@ -377,6 +448,7 @@ impl Tty7App {
}
};
if !due {
self.github_poll_checks(&key, cx);
return;
}
let Some(connection) = self.github_connection(cx) else {
@@ -414,6 +486,213 @@ impl Tty7App {
.detach();
}
/// Re-read the checks of the pull request on screen, after a pause, while
/// any of them are still running. Safe to call every frame: one poll per
/// detail is ever scheduled. Stops by itself once the detail is closed,
/// the tab is hidden, or every check has a verdict — and then marks the
/// detail due, so the merge state is read again with the verdicts in.
fn github_poll_checks(&mut self, key: &(RepoSlug, u64), cx: &mut Context<Self>) {
let Some(entry) = self.github.details.get(key) else {
return;
};
let pending = entry.detail.as_ref().is_some_and(|d| {
d.checks
.as_ref()
.is_some_and(|c| c.count(CheckState::Pending) > 0)
&& d.pull.as_ref().is_some_and(|p| !p.head_sha.is_empty())
});
// Signed out, the hourly allowance is 60 requests; a poll would spend
// it in ten minutes. Refresh still works by hand.
let signed_in = self
.github
.connection
.as_ref()
.is_some_and(|c| c.transport.authenticated());
if entry.polling
|| entry.loading
|| !pending
|| !signed_in
|| !self.github_detail_shown(key)
{
return;
}
if let Some(entry) = self.github.details.get_mut(key) {
entry.polling = true;
}
let key = key.clone();
let timer = cx.background_executor().timer(CHECKS_POLL);
cx.spawn(async move |this, cx| {
timer.await;
let request = this
.update(cx, |this, _cx| {
let entry = this.github.details.get_mut(&key)?;
let sha = entry
.detail
.as_ref()?
.pull
.as_ref()
.map(|p| p.head_sha.clone())?;
let seq = entry.seq;
let transport = this.github.connection.as_ref()?.transport.clone();
if !this.github_detail_shown(&key) {
return None;
}
Some((sha, seq, transport))
})
.ok()
.flatten();
let Some((sha, seq, transport)) = request else {
let _ = this.update(cx, |this, _cx| {
if let Some(entry) = this.github.details.get_mut(&key) {
entry.polling = false;
}
});
return;
};
let slug = key.0.clone();
let result = off_ui(move || api::checks(&*transport, &slug, &sha)).await;
let _ = this.update(cx, |this, cx| {
let Some(entry) = this.github.details.get_mut(&key) else {
return;
};
entry.polling = false;
// A full read landed (or started) meanwhile: it has the
// newer word on the checks.
if entry.seq != seq || entry.loading {
return;
}
let Some(Ok(checks)) = result else {
return;
};
let settled = checks.count(CheckState::Pending) == 0;
if let Some(detail) = entry.detail.as_mut() {
Arc::make_mut(detail).checks = Some(checks);
}
if settled {
entry.fetched = None;
}
cx.notify();
});
})
.detach();
}
/// Whether `key`'s detail is what the right panel is showing.
fn github_detail_shown(&self, key: &(RepoSlug, u64)) -> bool {
self.right_panel_visible
&& self.right_panel_tab == RightPanelTab::GitHub
&& self.github.open.as_ref() == Some(key)
}
/// The branch `repo` is on, re-read every [`BRANCH_TTL`].
pub(crate) fn github_branch(
&mut self,
host: SharedHost,
repo: &RepoKey,
cx: &mut Context<Self>,
) -> Option<BranchHead> {
let entry = self.github.branches.entry(repo.clone()).or_default();
let due = !entry.loading && entry.read_at.is_none_or(|t| t.elapsed() > BRANCH_TTL);
let head = entry.head.clone();
if due {
entry.loading = true;
let root = repo.root.clone();
let repo = repo.clone();
crate::ui::host_ops::HostOps::run(
host,
cx,
move |h| {
use tty7_core::core::git::git;
let branch = git(h, &root, &["symbolic-ref", "-q", "--short", "HEAD"])?
.trim()
.to_string();
if branch.is_empty() {
return None;
}
let upstream = git(
h,
&root,
&[
"rev-parse",
"--abbrev-ref",
"--symbolic-full-name",
&format!("{branch}@{{upstream}}"),
],
)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
Some(BranchHead { branch, upstream })
},
move |this, head, cx| {
let entry = this.github.branches.entry(repo).or_default();
entry.loading = false;
entry.read_at = Some(Instant::now());
if entry.head != head {
entry.head = head;
cx.notify();
}
},
);
}
head
}
/// The pull request the pane's branch has on the repository shown, if
/// any — fetched once and revalidated like a list.
pub(crate) fn github_branch_pull(
&mut self,
host: SharedHost,
repo: &RepoKey,
remotes: &[GitHubRemote],
chosen: &GitHubRemote,
cx: &mut Context<Self>,
) -> Option<Item> {
let head = self.github_branch(host, repo, cx)?;
let key = branch_pull_key(&head, remotes, chosen)?;
let entry = self.github.branch_pulls.entry(key.clone()).or_default();
let item = entry.item.clone();
let due = !entry.loading
&& !entry.error
&& entry.fetched.is_none_or(|t| t.elapsed() > STALE_AFTER);
if !due {
return item;
}
let Some(connection) = self.github_connection(cx) else {
return item;
};
if let Some(entry) = self.github.branch_pulls.get_mut(&key) {
entry.loading = true;
}
cx.spawn(async move |this, cx| {
let transport = connection.transport.clone();
let q = key.clone();
let Some(result) =
off_ui(move || api::pull_for_branch(&*transport, &q.slug, &q.owner, &q.branch))
.await
else {
return;
};
let _ = this.update(cx, |this, cx| {
let entry = this.github.branch_pulls.entry(key.clone()).or_default();
entry.loading = false;
match result {
Ok(item) => {
entry.item = item;
entry.error = false;
entry.fetched = Some(Instant::now());
}
Err(e) => {
log::warn!("github: pull request of {}:{}: {e}", key.owner, key.branch);
entry.error = true;
}
}
cx.notify();
});
})
.detach();
item
}
/// Start over for the repository on screen: resolve the token again (a
/// `gh auth login` since the last try counts), re-read the remotes, and
/// mark the visible list and detail due.
@@ -441,6 +720,13 @@ impl Tty7App {
entry.seq += 1;
}
self.github.details.retain(|_, e| e.detail.is_some());
for entry in self.github.branches.values_mut() {
entry.read_at = None;
}
for entry in self.github.branch_pulls.values_mut() {
entry.error = false;
entry.fetched = None;
}
cx.notify();
}
@@ -471,9 +757,98 @@ impl Tty7App {
}
}
/// Where to look for `head`'s pull request: its upstream's remote, when that
/// is a GitHub remote (a fork's branch lives in the fork), else the repository
/// shown under the local branch's name. `None` when it tracks a branch
/// somewhere other than GitHub.
pub(crate) fn branch_pull_key(
head: &BranchHead,
remotes: &[GitHubRemote],
chosen: &GitHubRemote,
) -> Option<BranchPullKey> {
let (owner, branch) = match &head.upstream {
None => (chosen.slug.owner.clone(), head.branch.clone()),
Some(upstream) => {
// The longest remote name that prefixes it: `origin` must not
// claim `origin-old/x`, and a remote may itself contain a `/`.
let remote = remotes
.iter()
.filter(|r| {
upstream
.strip_prefix(r.remote.as_str())
.is_some_and(|rest| rest.starts_with('/'))
})
.max_by_key(|r| r.remote.len())?;
let branch = upstream[remote.remote.len() + 1..].to_string();
(remote.slug.owner.clone(), branch)
}
};
(!branch.is_empty()).then(|| BranchPullKey {
slug: chosen.slug.clone(),
owner,
branch,
})
}
/// Seconds since the epoch, for relative times.
pub(crate) fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64)
}
#[cfg(test)]
mod tests {
use super::*;
fn remote(name: &str, owner: &str) -> GitHubRemote {
GitHubRemote {
remote: name.into(),
slug: RepoSlug {
owner: owner.into(),
name: "widgets".into(),
},
}
}
fn head(branch: &str, upstream: Option<&str>) -> BranchHead {
BranchHead {
branch: branch.into(),
upstream: upstream.map(str::to_string),
}
}
#[test]
fn a_forks_branch_is_looked_for_under_the_forks_owner() {
let remotes = [remote("origin", "me"), remote("upstream", "acme")];
let key = branch_pull_key(
&head("local-name", Some("origin/feat/x")),
&remotes,
&remotes[1],
)
.unwrap();
assert_eq!(key.slug.owner, "acme", "asked of the repository shown");
assert_eq!((key.owner.as_str(), key.branch.as_str()), ("me", "feat/x"));
}
#[test]
fn an_untracked_branch_is_looked_for_under_its_own_name() {
let remotes = [remote("origin", "acme")];
let key = branch_pull_key(&head("feat/y", None), &remotes, &remotes[0]).unwrap();
assert_eq!(
(key.owner.as_str(), key.branch.as_str()),
("acme", "feat/y")
);
}
#[test]
fn the_longest_remote_name_claims_the_upstream() {
let remotes = [remote("origin", "a"), remote("origin-old", "b")];
let key = branch_pull_key(&head("x", Some("origin-old/x")), &remotes, &remotes[0]).unwrap();
assert_eq!(key.owner, "b");
assert!(
branch_pull_key(&head("x", Some("gitlab/x")), &remotes, &remotes[0]).is_none(),
"a branch pushed somewhere else has no pull request here"
);
}
}