use super::*;
pub(super) fn repo_row(page: &mut Page, r: &RepoSummary, now: u64, show_owner: bool) {
let name = if show_owner {
r.repo.to_string()
} else {
r.repo.name.to_string()
};
item(page, url::repo(&r.repo), |page, link| {
let mut segs = vec![Seg::linked(name, Role::Link, link)];
for (on, text) in [
(r.private, "Private"),
(r.fork, "Fork"),
(r.archived, "Archived"),
] {
if on {
tag(&mut segs, text, Bg::SecondaryContainer);
}
}
let right = vec![meta(format!("โ {}", compact(r.stars)))];
page.box_line(segs, right, 0);
if page.compact {
return;
}
if let Some(d) = &r.description {
page.wrapped(vec![Seg::new(d.clone(), Role::Body)], 0, Frame::Body);
}
let mut subline = Vec::new();
if let Some(lang) = &r.language {
subline.push(Seg::new("โ ", Role::Accent));
subline.push(meta(format!("{lang} ")));
}
subline.push(meta(format!("โ {}", compact(r.forks))));
if let Some(p) = &r.pushed_at {
let updated = format!(" Updated {}", time::ago_iso(p, now));
subline.push(meta(updated));
}
body(page, subline);
});
}
pub(super) fn link_line(
page: &mut Page,
text: impl Into<String>,
role: Role,
link: u32,
right: Vec<Seg>,
) {
page.box_line(vec![Seg::linked(text, role, link)], right, 0);
}
pub(super) fn by_update(query: &str) -> bool {
query
.split_whitespace()
.any(|w| w.starts_with("sort:updated"))
}
pub(super) fn issue_row(
page: &mut Page,
i: &IssueSummary,
(show_repo, updated): (bool, bool),
icons: Icons,
now: u64,
review: bool,
) {
let (icon, role) = issue_icon(icons, i.status());
let pr = PrRef {
repo: i.repo.clone(),
number: i.number,
};
let target = match i.kind {
IssueKind::Pr { .. } if review => url::pull_tab(&pr, "files"),
IssueKind::Pr { .. } => url::pull(&pr),
IssueKind::Issue(_) => url::issue(&i.repo, i.number),
};
let place = if show_repo {
format!("{}#{}", i.repo, i.number)
} else {
format!("#{}", i.number)
};
item(page, target, |page, link| {
if page.compact {
let segs = vec![
Seg::new(format!("{icon} "), role),
Seg::linked(i.title.clone(), Role::Strong, link),
];
let mut right = vec![meta(format!("{place} "))];
right.extend(issue_signals(i, icons));
page.box_line(segs, right, 0);
return;
}
let mut segs = vec![Seg::linked(i.title.clone(), Role::Strong, link)];
labels(&mut segs, &i.labels);
hanging(page, Seg::new(format!("{icon} "), role), segs, Frame::Body);
let (verb, at) = if updated {
("updated", &i.updated_at)
} else {
("opened", &i.created_at)
};
let when = format!("{place} {verb} {} by {}", time::ago_iso(at, now), i.author);
let mut subline = vec![meta(when)];
if let IssueKind::Pr {
review, approvals, ..
} = i.kind
{
if let Some(review) = review {
let (text, role) = match review {
ReviewDecision::Approved => ("Approved", Role::Success),
ReviewDecision::ChangesRequested => ("Changes requested", Role::Error),
ReviewDecision::ReviewRequired => ("Review required", Role::Meta),
};
subline.extend([meta(" ยท "), Seg::new(text, role)]);
}
if approvals > 0 {
let text = format!(" ยท {}", plural(approvals, "approval"));
subline.push(meta(text));
}
}
page.box_line(subline, issue_signals(i, icons), 2);
});
}
pub(super) fn issue_signals(i: &IssueSummary, icons: Icons) -> Vec<Seg> {
let mut right = Vec::new();
if let IssueKind::Pr {
checks: Some(checks),
..
} = i.kind
{
let role = match checks {
ChecksState::Passing => Role::Success,
ChecksState::Failing => Role::Error,
ChecksState::Pending => Role::Accent,
};
right.push(Seg::new(format!("{} ", icons.checks(checks)), role));
}
if i.comments > 0 {
right.push(meta(format!("{} {}", icons.comment(), i.comments)));
}
right
}
pub(super) fn user_row(page: &mut Page, u: &UserSummary) {
item(page, url::user(&u.login), |page, link| {
let mut segs = vec![Seg::linked(u.login.clone(), Role::Link, link)];
if let Some(name) = &u.name {
segs.push(strong(format!(" {name}")));
}
if u.is_org {
tag(&mut segs, "Organization", Bg::SecondaryContainer);
}
body(page, segs);
if let Some(bio) = u.bio.as_ref().filter(|_| !page.compact) {
page.wrapped(vec![meta(bio.clone())], 0, Frame::Body);
}
});
}
pub(super) fn counted_box<T>(
page: &mut Page,
name: &str,
right: Vec<Seg>,
list: &impl Rows<T>,
empty: &str,
row: impl FnMut(&mut Page, &T),
) {
list_box(page, counted(name, list), right, list, empty, row);
}
pub fn people_list(
page: &mut Page,
title: &str,
people: Fetched<'_, Results<UserSummary>>,
empty: &str,
) {
paged_list(page, title, people, empty, user_row);
}
pub fn repos(page: &mut Page, title: &str, repos: Fetched<'_, Results<RepoSummary>>, now: u64) {
paged_list(page, title, repos, "None yet.", |page, r| {
repo_row(page, r, now, true);
});
}
pub(super) fn filter_field(page: &mut Page, query: &str, keys: Keys<'_>, verb: &str) {
let room = usize::from(page.room(Frame::None, 0));
let hint = format!("{} to {verb} ", keys.filter);
let text = format!(" โ {query}");
let pad = room.saturating_sub(width(&text) + width(&hint));
let (link, role) = (page.link(Link::Filter), Role::Chip(Bg::ContainerHigh));
let field = Seg::linked(format!("{text}{}", " ".repeat(pad)), role.clone(), link);
page.line(vec![field, Seg::linked(hint, role, link)]);
}
pub fn list_state(query: &str) -> &'static str {
let has = |state: &str| {
query
.split_whitespace()
.any(|w| w.strip_prefix("is:").or_else(|| w.strip_prefix("state:")) == Some(state))
};
let states = ["closed", "open", "unread", "read"];
states.into_iter().find(|s| has(s)).unwrap_or("all")
}
pub fn sort_label(query: &str) -> &'static str {
let sort = query
.split_whitespace()
.find_map(|w| w.strip_prefix("sort:"))
.unwrap_or("created-desc");
SORTS
.iter()
.find(|(q, _)| *q == sort)
.map_or("Best match", |(_, l)| l)
}
pub const SORTS: [(&str, &str); 5] = [
("created-desc", "Newest"),
("created-asc", "Oldest"),
("comments-desc", "Most commented"),
("updated-desc", "Recently updated"),
("reactions-desc", "Most reactions"),
];
pub fn issue_list(
page: &mut Page,
query: &str,
counts: Option<(u64, u64)>,
is_pr: bool,
results: Fetched<'_, Results<IssueSummary>>,
cx: PageCtx<'_>,
) {
let PageCtx { icons, keys, now } = cx;
filter_field(page, query, keys, "filter");
let state = list_state(query);
let (open_state, closed_state) = if is_pr {
(Status::Pr(PrState::Open), Status::Pr(PrState::Merged))
} else {
(
Status::Issue(IssueState::Open),
Status::Issue(IssueState::Closed),
)
};
let (open, closed) = counts.unwrap_or_default();
let tabs = [
("open", open_state, open, "Open"),
("closed", closed_state, closed, "Closed"),
]
.map(|(name, kind, n, label)| {
let (icon, _) = issue_icon(icons, kind);
let text = match counts {
Some(_) => format!("{icon} {} {label}", compact(n)),
None => format!("{icon} {label}"),
};
(text, Link::State(name.to_owned()), state == name)
});
let title = nav_strip(page, &Seg::new(" ", Role::Body), &Role::Meta, tabs);
let sort = format!("Sort: {} โพ", sort_label(query));
let sort = link_seg(page, sort, Link::Sort, Role::Meta);
let Some(r) = results.show_or(page, "the list", |page| {
page.box_top(title.clone(), vec![sort.clone()]);
skeleton(page);
page.box_bottom();
}) else {
return;
};
let empty = "No results matched your search.";
let updated = by_update(query);
list_box(page, title, vec![sort], r, empty, |page, i| {
issue_row(page, i, (false, updated), icons, now, false);
});
}
pub fn search(
page: &mut Page,
kind: SearchKind,
query: &str,
results: Fetched<'_, SearchResults>,
icons: Icons,
keys: Keys<'_>,
now: u64,
) {
filter_field(page, query, keys, "filter");
let what = match kind {
SearchKind::Repos => "repository",
SearchKind::Issues => "issue",
SearchKind::Pulls => "pull request",
SearchKind::Users => "user",
SearchKind::Discussions => "discussion",
SearchKind::Commits => "commit",
SearchKind::Code => "code",
};
let searching = |page: &mut Page| loading_box(page, vec![meta("Searchingโฆ")]);
let Some(results) = results.show_or(page, "results", searching) else { return };
let (total, shown) = results.counts();
let title = format!("{} {what} {}", compact(total), noun(total, "result"));
page.box_top(vec![strong(title)], Vec::new());
if shown == 0 {
empty_row(page, "Your search did not match anything.");
}
let updated = by_update(query);
match results {
SearchResults::Repos(r) => box_rows(page, r, |page, r| repo_row(page, r, now, true)),
SearchResults::Issues(r) => box_rows(page, r, |page, i| {
issue_row(page, i, (true, updated), icons, now, false);
}),
SearchResults::Users(r) => box_rows(page, r, user_row),
SearchResults::Discussions(r) => box_rows(page, r, |page, hit| {
let d = &hit.summary;
let target = format!("{}/discussions/{}", url::repo(&hit.repo), d.number);
discussion_row(page, target, (&hit.repo.to_string(), false), d, now);
}),
SearchResults::Commits(r) => box_rows(page, r, |page, hit| {
let c = &hit.commit;
item(page, url::commit(&hit.repo, &c.oid), |page, link| {
let right = vec![Seg::new(short_sha(&c.oid), Role::Code)];
link_line(page, c.headline.clone(), Role::Strong, link, right);
let (who, when) = (c.author.name(), time::ago_iso(&c.date, now));
let subline = format!("{} ยท {who} committed {when}", hit.repo);
body(page, vec![meta(subline)]);
});
}),
SearchResults::Code(r) => box_rows(page, r, |page, hit| {
let rev = hit.rev.as_deref().unwrap_or("HEAD");
let file = url::blob(&hit.repo, rev, &hit.path);
item(page, file.clone(), |page, link| {
link_line(page, hit.path.clone(), Role::Code, link, Vec::new());
body(page, vec![meta(hit.repo.to_string())]);
});
for (n, text) in &hit.lines {
item(page, format!("{file}#L{n}"), |page, link| {
let number = Seg::linked(format!("{n:>5} "), Role::Meta, link);
let text = truncate(text.trim(), 200);
body(page, vec![number, Seg::new(text, Role::Code)]);
});
}
}),
}
page.box_bottom();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn list_queries_name_their_state_and_sort() {
assert_eq!(list_state("is:open label:bug"), "open");
assert_eq!(list_state("is:closed"), "closed");
assert_eq!(list_state("label:bug"), "all");
assert_eq!(sort_label("is:open sort:comments-desc"), "Most commented");
assert_eq!(sort_label("is:open"), "Newest");
assert_eq!(day("2026-10-01T12:00:00Z"), "Oct 1, 2026");
}
}