use super::*;
use crate::api::model::NodeId;
use crate::api::timeline::Review;
use crate::ui::page::ReviewLink;
use std::collections::HashSet;
#[derive(Debug, Clone, Copy)]
pub struct Glance<'a> {
pub threads: Fetched<'a, Vec<ReviewThread>>,
pub drafts: Option<&'a [DraftComment]>,
pub fresh: &'a HashSet<NodeId>,
pub reviews: Fetched<'a, Capped<Review>>,
pub new_reviews: &'a HashSet<u64>,
}
pub fn pr_review(
page: &mut Page,
pr: &PrRef,
d: &PrDetail,
activity: Fetched<'_, PrActivity>,
glance: Glance<'_>,
cx: PageCtx<'_>,
) {
pr_summary(page, pr, d, cx.now);
review_drafts(page, pr, glance.drafts);
open_threads(page, glance.threads, glance.fresh, cx.now);
reviewers(page, pr, d, activity);
all_reviews(page, pr, d, glance, cx.now);
merge_box(page, pr, d, cx.icons, false);
}
fn reviewers(page: &mut Page, pr: &PrRef, d: &PrDetail, activity: Fetched<'_, PrActivity>) {
let title = Seg::new("Reviewers", Role::Strong);
let loading = |page: &mut Page| empty_box(page, title.clone(), "Loading…");
let Some(a) = activity.show_or(page, "the reviews", loading) else {
return;
};
let latest: Vec<_> = a.latest.iter().filter(|l| l.author != d.author).collect();
page.box_top(vec![title], Vec::new());
if latest.is_empty() && a.requested.is_empty() && a.requested.left_out() == 0 {
empty_row(page, "No reviews yet, and no one is asked for one.");
}
for l in latest {
let ((mark, role), what) = timeline::review_said(l.state);
let target = match l.id {
Some(id) => Link::Review(ReviewLink::Open { pr: pr.clone(), id }),
None => Link::Url(url::user(&l.author)),
};
item(page, target, |page, link| {
let mut segs = vec![
Seg::new(format!("{mark} "), role),
Seg::linked(l.author.clone(), Role::Link, link),
Seg::new(format!(" {what}"), Role::Meta),
];
let stale = l.state == ReviewState::Approved
&& l.commit.as_deref().is_some_and(|c| c != d.head_oid);
if stale {
segs.push(Seg::new(" · before the last push", Role::Meta));
}
for team in &l.for_teams {
segs.push(Seg::new(format!(" · for {team}"), Role::Meta));
}
body(page, segs);
});
}
for r in &a.requested {
let (name, target) = timeline::reviewer_link(&r.who);
item(page, target, |page, link| {
let what = if r.code_owner {
" is asked to review, as a code owner"
} else {
" is asked to review"
};
body(
page,
vec![
Seg::new("● ", Role::Accent),
Seg::linked(name, Role::Link, link),
Seg::new(what, Role::Meta),
],
);
});
}
if a.requested.left_out() > 0 {
let more = format!(
"And {} more asked, that GitHub's API doesn't name (a team you can't see, say).",
a.requested.left_out()
);
empty_row(page, &more);
}
page.box_bottom();
}
fn all_reviews(page: &mut Page, pr: &PrRef, d: &PrDetail, glance: Glance<'_>, now: u64) {
let heading = "Reviews";
let loading = |page: &mut Page| empty_box(page, Seg::new(heading, Role::Strong), "Loading…");
let Some(reviews) = glance.reviews.show_or(page, "the reviews", loading) else {
return;
};
let title = match reviews.total() {
0 => heading.to_owned(),
n => format!("{heading} · {n}"),
};
page.box_top(vec![Seg::new(title, Role::Strong)], Vec::new());
if reviews.is_empty() {
empty_row(page, "None yet.");
}
for (n, r) in reviews.iter().rev().enumerate() {
if n > 0 {
page.box_rule();
}
let Some(id) = r.id else { continue };
let link = Link::Review(ReviewLink::Open { pr: pr.clone(), id });
item(page, link, |page, link| {
let ((mark, role), what) = timeline::review_said(r.state);
let mut segs = vec![
Seg::new(format!("{mark} "), role),
Seg::linked(r.author.clone(), Role::Link, link),
Seg::new(format!(" {what}"), Role::Meta),
];
if glance.new_reviews.contains(&id) {
segs.push(space());
segs.push(chip("New", Bg::TertiaryContainer));
}
let mut right = vec![Seg::new(time::ago_iso(&r.at, now), Role::Meta)];
if r.comments > 0 {
right.insert(
0,
Seg::new(format!("{} · ", plural(r.comments, "comment")), Role::Meta),
);
}
page.box_line(segs, right, 0);
let mut on = Vec::new();
if let Some(c) = &r.commit {
let at = if *c == d.head_oid {
format!("on {} (the head)", short_sha(c))
} else {
format!("on {}", short_sha(c))
};
on.push(Seg::new(at, Role::Meta));
}
let said = first_line(&r.body, page.room(Frame::Body, 0));
if !said.is_empty() {
if !on.is_empty() {
on.push(Seg::new(" · ", Role::Meta));
}
on.push(Seg::new(said, Role::Body));
}
if !on.is_empty() {
page.wrapped(on, 0, Frame::Body);
}
});
}
if reviews.left_out() > 0 {
let text = left_out_text(reviews.left_out(), "more review", "more reviews");
empty_row(page, &text);
}
page.box_bottom();
}
#[expect(clippy::too_many_arguments, reason = "a page's data and context")]
pub fn pr_reviewed(
page: &mut Page,
pr: &PrRef,
d: &PrDetail,
id: u64,
activity: Fetched<'_, PrActivity>,
reviews: Fetched<'_, Capped<Review>>,
marks: Marks<'_>,
cx: PageCtx<'_>,
) {
pr_summary(page, pr, d, cx.now);
let Some(a) = activity.show(page, "the review's comments") else {
return;
};
#[expect(clippy::disallowed_methods, reason = "either says where it is")]
let all = reviews.ready_unchecked();
let found = all
.and_then(|all| all.iter().find(|r| r.id == Some(id)))
.or_else(|| a.reviews().find(|r| r.id == Some(id)));
let Some(r) = found else {
let what = if all.is_some() {
"GitHub has no such review on this pull request."
} else {
"Loading the review…"
};
page.wrapped(vec![Seg::new(what, Role::Meta)], 0, Frame::None);
return;
};
let talk = Conversation {
base: LinkBase::new(&pr.repo, &d.head_oid, ""),
op: &d.author,
now: cx.now,
unfolded: marks.unfolded,
};
let place = timeline::Place {
repo: &pr.repo,
pr: Some(pr),
threads: &a.threads,
icons: cx.icons,
};
let unfolded: HashSet<String> = r.anchor().into_iter().collect();
let marks = Marks {
unfolded: &unfolded,
..marks
};
timeline::review(page, &talk, &place, r, marks);
let Some(commit) = &r.commit else {
return;
};
page.box_top(vec![Seg::new("Its changes", Role::Strong)], Vec::new());
let reviewed = Link::Review(ReviewLink::AsReviewed {
pr: pr.clone(),
commit: commit.clone(),
});
item(page, reviewed, |page, link| {
let text = format!(
"The changes it reviewed, as they were at {}",
short_sha(commit)
);
body(page, vec![Seg::linked(text, Role::Link, link)]);
});
if *commit == d.head_oid {
empty_row(page, "Nothing changed since: it reviewed the head.");
} else {
let since = Link::Review(ReviewLink::Since {
pr: pr.clone(),
commit: commit.clone(),
});
item(page, since, |page, link| {
let text = format!(
"What changed since, from {} to {}",
short_sha(commit),
short_sha(&d.head_oid)
);
body(page, vec![Seg::linked(text, Role::Link, link)]);
});
}
page.box_bottom();
}
pub(super) fn first_line(text: &str, max: u16) -> String {
let line = text.lines().map(str::trim).find(|l| !l.is_empty());
crate::ui::text::truncate(line.unwrap_or_default(), usize::from(max))
}
pub(super) fn place(path: &str, line: Option<u32>) -> String {
line.map_or_else(|| path.to_owned(), |line| format!("{path}:{line}"))
}
pub(super) fn review_drafts(page: &mut Page, pr: &PrRef, drafts: Option<&[DraftComment]>) {
let title = |n: usize| match n {
0 => Seg::new("Your drafts", Role::Strong),
n => Seg::new(format!("Your drafts · {n}"), Role::Strong),
};
let Some(drafts) = drafts else {
empty_box(page, title(0), "Reading your drafts…");
return;
};
if drafts.is_empty() {
let none = "None. Comments you write in Files changed wait here until you submit them.";
empty_box(page, title(0), none);
return;
}
let mut drafts = Vec::from_iter(drafts);
drafts.sort_by_key(|d| (&d.path, d.line));
let right = vec![Seg::new("not submitted yet", Role::Meta)];
page.box_top(vec![title(drafts.len())], right);
for (n, draft) in drafts.into_iter().enumerate() {
if n > 0 {
page.box_rule();
}
let side = match draft.side {
DraftSide::Left => 'L',
DraftSide::Right => 'R',
};
let target = Link::Review(ReviewLink::Draft {
pr: pr.clone(),
id: draft.id,
url: url::diff_line(pr, &draft.path, draft.line.map(|l| (side, l))),
});
item(page, target, |page, link| {
let at = place(&draft.path, draft.line);
body(page, vec![Seg::linked(at, Role::Link, link)]);
let text = first_line(&draft.body, page.room(Frame::Body, 0));
body(page, vec![Seg::new(text, Role::Body)]);
if let Some(err) = &draft.error {
let why = format!("GitHub refused it: {err}");
body(page, vec![Seg::new(why, Role::Error)]);
}
});
}
page.box_bottom();
}
pub(super) fn open_threads(
page: &mut Page,
threads: Fetched<'_, Vec<ReviewThread>>,
fresh: &HashSet<NodeId>,
now: u64,
) {
let heading = "Unresolved threads";
let loading = |page: &mut Page| {
empty_box(page, Seg::new(heading, Role::Strong), "Loading…");
};
let Some(threads) = threads.show_or(page, "the review threads", loading) else {
return;
};
let mut open: Vec<&ReviewThread> = threads.iter().filter(|t| !t.resolved).collect();
open.sort_by_key(|t| (&t.path, t.line.or(t.original_line)));
let resolved = threads.len() - open.len();
let title = match open.len() {
0 => heading.to_owned(),
n => format!("{heading} · {n}"),
};
let right = (resolved > 0).then(|| Seg::new(format!("{resolved} resolved"), Role::Meta));
page.box_top(vec![Seg::new(title, Role::Strong)], Vec::from_iter(right));
if open.is_empty() {
empty_row(page, "None.");
}
for (n, thread) in open.into_iter().enumerate() {
if n > 0 {
page.box_rule();
}
let first = thread.comments.first();
let row = |page: &mut Page, link: Option<u32>| {
let line = if thread.file_level {
None
} else {
thread.line.or(thread.original_line)
};
let at = place(&thread.path, line);
let mut segs = vec![match link {
Some(link) => Seg::linked(at, Role::Link, link),
None => Seg::new(at, Role::Strong),
}];
if thread.outdated {
segs.push(Seg::new(" ", Role::Meta));
segs.push(chip("Outdated", Bg::ContainerHigh));
}
if thread.comments.iter().any(|c| fresh.contains(&c.id)) {
segs.push(Seg::new(" ", Role::Meta));
segs.push(chip("New", Bg::TertiaryContainer));
}
let mut said = Vec::new();
if let Some(c) = first {
said.push(login_seg(page, &c.author, Role::Meta));
let ago = time::ago_iso(&c.created_at, now);
said.push(Seg::new(format!(" · {ago}"), Role::Meta));
}
let replies = thread.comments.total().saturating_sub(1);
if replies > 0 {
said.push(Seg::new(
format!(" · {}", plural(replies, "reply")),
Role::Meta,
));
}
page.box_line(segs, said, 0);
let text = first.map(|c| first_line(&c.body, page.room(Frame::Body, 0)));
body(page, vec![Seg::new(text.unwrap_or_default(), Role::Body)]);
};
match first.map(|c| c.url.clone()).filter(|u| !u.is_empty()) {
Some(url) => item(page, url, |page, link| row(page, Some(link))),
None => row(page, None),
}
}
page.box_bottom();
}