use std::sync::Arc;
use std::collections::{BTreeSet, HashMap};
use crate::api::browse::{
Advisory, Blame, Blob, BranchInfo, CheckOutcome, Checks, CommitDetail, CommitInfo, Comparison,
DeploymentList, DiscussionDetail, DiscussionList, DiscussionsOf, Gist, GistSummary,
IssueDetail, Job, MilestoneDetail, MilestoneList, PrActivity, Profile, Readme, Refs, Release,
RepoOverview, RepoSort, RepoSummary, Results, RunSummary, SearchKind, SearchResults, TagInfo,
TeamDetail, TeamSummary, TreeEntry, UserList, UserSummary, WikiPage, Workflow, WorkflowRun,
};
use crate::api::model::{PrRef, RepoId};
use crate::ui::Fetched;
use crate::ui::page::{Page, PageLine, Role, Seg};
use crate::ui::pages::{self, Keys, PrTab, ProfileList, ProfileTab};
use crate::keymap::Action;
use crate::route::Route;
use crate::state::{Remote, State};
const MAX_WIDTH: u16 = 140;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum DataKey {
Repo(RepoId),
Readme(RepoId),
Tree(RepoId, String, String),
Blob(RepoId, String, String),
Search(SearchKind, String),
Issue(RepoId, u64),
PrActivity(PrRef),
Profile(String),
Files(RepoId, String),
Refs(RepoId),
RefIn(RepoId, String),
LastCommits(RepoId, String, String),
Commit(RepoId, String),
History(RepoId, String, String),
PrChecks(PrRef),
BranchChecks(RepoId),
Users(UserList),
Forks(RepoId),
Releases(RepoId),
Release(RepoId, String),
Tags(RepoId),
Branches(RepoId),
Wiki(RepoId, Option<String>),
Advisories(Option<RepoId>),
Advisory(Option<RepoId>, String),
Teams(String),
Team(String, String),
Gist(String),
Gists(String),
Blame(RepoId, String, String),
Compare(RepoId, String),
Deployments(RepoId, Option<String>),
Milestones(RepoId, bool),
Milestone(RepoId, u64),
Discussions(DiscussionsOf, Option<String>),
Discussion(DiscussionsOf, u64),
Run(RepoId, u64, Option<u64>),
Job(RepoId, u64),
JobLog(RepoId, u64),
Workflow(RepoId, String),
WorkflowRuns(RepoId, String),
CommitChecks(RepoId, String),
OwnerRepos(String, RepoSort),
Stars(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Data {
Repo(Box<RepoOverview>),
Readme(Option<Box<Readme>>),
Tree(Vec<TreeEntry>),
Blob(Box<Blob>),
Search(Box<SearchResults>),
Issue(Option<Box<IssueDetail>>),
PrActivity(Box<PrActivity>),
Profile(Box<Profile>),
Files(Arc<Vec<String>>, bool),
Refs(Box<Refs>),
RefIn(Option<String>),
LastCommits(Arc<HashMap<String, CommitInfo>>),
Commit(Box<CommitDetail>),
History(Box<Results<CommitInfo>>),
Checks(Box<Checks>),
Users(Box<Results<UserSummary>>),
RepoPage(Box<Results<RepoSummary>>),
Releases(Box<Results<Release>>),
Discussions(Box<DiscussionList>),
Discussion(Box<DiscussionDetail>),
Run(Box<WorkflowRun>),
Job(Box<Job>),
Log(Arc<crate::api::browse::JobLog>),
Workflow(Box<Workflow>),
Runs(Box<Results<RunSummary>>),
Release(Box<Release>),
Tags(Box<Results<TagInfo>>),
Branches(Box<Results<BranchInfo>>),
Wiki(Box<WikiPage>),
Advisories(Box<Results<Advisory>>),
Advisory(Box<Advisory>),
Teams(Box<Results<TeamSummary>>),
Team(Box<TeamDetail>),
Gist(Box<Gist>),
Gists(Box<Results<GistSummary>>),
Blame(Box<Blame>),
Compare(Box<Comparison>),
Deployments(Box<DeploymentList>),
Milestones(Box<MilestoneList>),
Milestone(Box<MilestoneDetail>),
}
impl Data {
pub fn running(&self) -> bool {
match self {
Data::Log(log) => log.running,
Data::Job(job) => {
job.outcome == CheckOutcome::Pending || job.run.outcome == CheckOutcome::Pending
}
Data::Run(run) => run.outcome == CheckOutcome::Pending,
Data::Checks(c) => c.items.iter().any(|i| i.outcome == CheckOutcome::Pending),
Data::Runs(r) => r.items.iter().any(|r| r.outcome == CheckOutcome::Pending),
_ => false,
}
}
pub fn next_cursor(&self) -> Option<&str> {
match self {
Data::Search(r) => next_cursor(r),
Data::History(r) => r.next.as_deref(),
Data::Users(r) => r.next.as_deref(),
Data::RepoPage(r) => r.next.as_deref(),
Data::Releases(r) => r.next.as_deref(),
Data::Runs(r) => r.next.as_deref(),
Data::Discussions(d) => d.results.next.as_deref(),
Data::Tags(r) => r.next.as_deref(),
Data::Branches(r) => r.next.as_deref(),
Data::Teams(r) => r.next.as_deref(),
Data::Gists(r) => r.next.as_deref(),
Data::Deployments(d) => d.results.next.as_deref(),
Data::Milestones(m) => m.results.next.as_deref(),
Data::Milestone(m) => m.items.next.as_deref(),
Data::Advisories(r) => r.next.as_deref(),
_ => None,
}
}
pub fn append(&mut self, more: Data) {
match (self, more) {
(Data::Search(a), Data::Search(b)) => append(a, *b),
(Data::History(a), Data::History(b)) => extend(a, *b, |c| c.oid.clone()),
(Data::Users(a), Data::Users(b)) => extend(a, *b, |u| u.login.clone()),
(Data::RepoPage(a), Data::RepoPage(b)) => extend(a, *b, |r| r.repo.clone()),
(Data::Releases(a), Data::Releases(b)) => extend(a, *b, |r| r.tag.clone()),
(Data::Runs(a), Data::Runs(b)) => extend(a, *b, |r| r.id),
(Data::Discussions(a), Data::Discussions(b)) => {
extend(&mut a.results, b.results, |d| d.number);
}
(Data::Tags(a), Data::Tags(b)) => extend(a, *b, |t| t.name.clone()),
(Data::Branches(a), Data::Branches(b)) => extend(a, *b, |b| b.name.clone()),
(Data::Teams(a), Data::Teams(b)) => extend(a, *b, |t| t.slug.clone()),
(Data::Gists(a), Data::Gists(b)) => extend(a, *b, |g| g.id.clone()),
(Data::Deployments(a), Data::Deployments(b)) => {
extend(&mut a.results, b.results, |d| {
(d.created_at.clone(), d.environment.clone(), d.oid.clone())
});
}
(Data::Milestones(a), Data::Milestones(b)) => {
extend(&mut a.results, b.results, |m| m.number);
}
(Data::Milestone(a), Data::Milestone(b)) => {
extend(&mut a.items, b.items, |i| (i.repo.clone(), i.number));
}
(Data::Advisories(a), Data::Advisories(b)) => extend(a, *b, |a| a.ghsa.clone()),
_ => {}
}
}
}
pub fn paged(route: &Route) -> Option<DataKey> {
match route {
Route::Commits { repo, rev, path } => {
Some(DataKey::History(repo.clone(), rev.clone(), path.clone()))
}
Route::Stargazers(repo) => Some(DataKey::Users(UserList::Stargazers(repo.clone()))),
Route::Watchers(repo) => Some(DataKey::Users(UserList::Watchers(repo.clone()))),
Route::Forks(repo) => Some(DataKey::Forks(repo.clone())),
Route::Releases(repo) => Some(DataKey::Releases(repo.clone())),
Route::Discussions { of, category } => {
Some(DataKey::Discussions(of.clone(), category.clone()))
}
Route::Workflow { repo, file } => Some(DataKey::WorkflowRuns(repo.clone(), file.clone())),
Route::User { login, tab } => {
let login = login.to_lowercase();
Some(match tab {
ProfileTab::Overview => return None,
ProfileTab::Repositories(sort) => DataKey::OwnerRepos(login, *sort),
ProfileTab::Stars => DataKey::Stars(login),
ProfileTab::Followers => DataKey::Users(UserList::Followers(login)),
ProfileTab::Following => DataKey::Users(UserList::Following(login)),
ProfileTab::People => DataKey::Users(UserList::People(login)),
})
}
Route::Tags(repo) => Some(DataKey::Tags(repo.clone())),
Route::Branches(repo) => Some(DataKey::Branches(repo.clone())),
Route::Teams(org) => Some(DataKey::Teams(org.to_lowercase())),
Route::Gists(login) => Some(DataKey::Gists(login.to_lowercase())),
Route::Compare { repo, spec } => Some(DataKey::Compare(repo.clone(), spec.clone())),
Route::Deployments { repo, environment } => {
Some(DataKey::Deployments(repo.clone(), environment.clone()))
}
Route::Milestones { repo, closed } => Some(DataKey::Milestones(repo.clone(), *closed)),
Route::Milestone { repo, number } => Some(DataKey::Milestone(repo.clone(), *number)),
Route::Advisories(repo) => Some(DataKey::Advisories(repo.clone())),
_ => route
.search()
.map(|(kind, query)| DataKey::Search(kind, query)),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Need {
Pr(PrRef),
Data(DataKey),
}
pub fn needs(route: &Route) -> Vec<Need> {
use DataKey as K;
let header = |repo: &RepoId| Need::Data(K::Repo(repo.clone()));
let list = route
.search()
.map(|(kind, query)| Need::Data(K::Search(kind, query)));
match route {
Route::Home => Vec::new(),
Route::Repo(repo) => vec![
header(repo),
Need::Data(K::Readme(repo.clone())),
Need::Data(K::LastCommits(repo.clone(), "HEAD".into(), String::new())),
],
Route::Tree { repo, rev, path } => vec![
header(repo),
Need::Data(K::Tree(repo.clone(), rev.clone(), path.clone())),
Need::Data(K::LastCommits(repo.clone(), rev.clone(), path.clone())),
],
Route::Blob {
repo, rev, path, ..
} => vec![
header(repo),
Need::Data(K::Blob(repo.clone(), rev.clone(), path.clone())),
],
Route::Blame {
repo, rev, path, ..
} => vec![
header(repo),
Need::Data(K::Blob(repo.clone(), rev.clone(), path.clone())),
Need::Data(K::Blame(repo.clone(), rev.clone(), path.clone())),
],
Route::Unsplit { repo, spot, .. } => vec![
header(repo),
Need::Data(K::RefIn(repo.clone(), spot.clone())),
],
Route::Issues { repo, .. } | Route::Pulls { repo, .. } => {
std::iter::once(header(repo)).chain(list).collect()
}
Route::Search { .. } => list.into_iter().collect(),
Route::Wiki { repo, page } => vec![
header(repo),
Need::Data(K::Wiki(repo.clone(), page.clone())),
],
Route::Advisories(repo) | Route::Advisory { repo, .. } => {
let own = match route {
Route::Advisory { ghsa, .. } => K::Advisory(repo.clone(), ghsa.clone()),
_ => K::Advisories(repo.clone()),
};
repo.iter().map(header).chain([Need::Data(own)]).collect()
}
Route::Gists(_) | Route::Teams(_) => paged(route).map(Need::Data).into_iter().collect(),
Route::Team { org, slug } => vec![Need::Data(K::Team(org.to_lowercase(), slug.clone()))],
Route::Gist { id, .. } => vec![Need::Data(K::Gist(id.clone()))],
Route::Issue { repo, number } => {
vec![header(repo), Need::Data(K::Issue(repo.clone(), *number))]
}
Route::Pr {
pr,
tab: PrTab::Checks,
} => vec![
Need::Pr(pr.clone()),
Need::Data(K::PrActivity(pr.clone())),
Need::Data(K::PrChecks(pr.clone())),
],
Route::Pr { pr, .. } => vec![Need::Pr(pr.clone()), Need::Data(K::PrActivity(pr.clone()))],
Route::Actions(repo) => vec![header(repo), Need::Data(K::BranchChecks(repo.clone()))],
Route::Discussions { of, .. } | Route::Discussion { of, .. } => {
let header = match of {
DiscussionsOf::Repo(repo) => Some(header(repo)),
DiscussionsOf::Org(_) => None,
};
let own = match route {
Route::Discussion { number, .. } => Some(K::Discussion(of.clone(), *number)),
_ => paged(route),
};
header.into_iter().chain(own.map(Need::Data)).collect()
}
Route::WorkflowRun { repo, run, attempt } => {
vec![
header(repo),
Need::Data(K::Run(repo.clone(), *run, *attempt)),
]
}
Route::Job { repo, job, .. } => vec![
header(repo),
Need::Data(K::Job(repo.clone(), *job)),
Need::Data(K::JobLog(repo.clone(), *job)),
],
Route::Workflow { repo, file } => vec![
header(repo),
Need::Data(K::Workflow(repo.clone(), file.clone())),
Need::Data(K::WorkflowRuns(repo.clone(), file.clone())),
],
Route::CommitChecks { repo, oid } => vec![
header(repo),
Need::Data(K::Commit(repo.clone(), oid.clone())),
Need::Data(K::CommitChecks(repo.clone(), oid.clone())),
],
Route::Release { repo, tag } => {
vec![
header(repo),
Need::Data(K::Release(repo.clone(), tag.clone())),
]
}
Route::Stargazers(repo)
| Route::Watchers(repo)
| Route::Forks(repo)
| Route::Releases(repo)
| Route::Tags(repo)
| Route::Branches(repo)
| Route::Compare { repo, .. }
| Route::Deployments { repo, .. }
| Route::Milestones { repo, .. }
| Route::Milestone { repo, .. } => std::iter::once(header(repo))
.chain(paged(route).map(Need::Data))
.collect(),
Route::Commit { repo, oid } => {
vec![
header(repo),
Need::Data(K::Commit(repo.clone(), oid.clone())),
]
}
Route::Commits { repo, rev, path } => vec![
header(repo),
Need::Data(K::History(repo.clone(), rev.clone(), path.clone())),
],
Route::User { login, .. } => std::iter::once(Need::Data(K::Profile(login.to_lowercase())))
.chain(paged(route).map(Need::Data))
.collect(),
}
}
pub fn is_header(route: &Route, need: &Need) -> bool {
matches!(need, Need::Data(DataKey::Repo(_))) && !matches!(route, Route::Repo(_))
}
pub trait Picked {
fn pick(data: &Data) -> Option<&Self>;
}
macro_rules! picked {
($($variant:ident => $t:ty),* $(,)?) => {$(
impl Picked for $t {
fn pick(data: &Data) -> Option<&Self> {
match data {
Data::$variant(x) => Some(std::borrow::Borrow::borrow(x)),
_ => None,
}
}
}
)*};
}
picked!(
Repo => RepoOverview, Readme => Option<Box<Readme>>, Tree => [TreeEntry], Blob => Blob,
Search => SearchResults, Issue => Option<Box<IssueDetail>>, PrActivity => PrActivity,
Profile => Profile, Refs => Refs, RefIn => Option<String>,
LastCommits => HashMap<String, CommitInfo>, Commit => CommitDetail,
History => Results<CommitInfo>, Checks => Checks, Users => Results<UserSummary>,
RepoPage => Results<RepoSummary>, Releases => Results<Release>,
Discussions => DiscussionList, Discussion => DiscussionDetail, Run => WorkflowRun, Job => Job,
Log => crate::api::browse::JobLog, Workflow => Workflow, Runs => Results<RunSummary>,
Release => Release, Tags => Results<TagInfo>, Branches => Results<BranchInfo>,
Wiki => WikiPage, Advisories => Results<Advisory>, Advisory => Advisory,
Teams => Results<TeamSummary>, Team => TeamDetail, Gist => Gist,
Gists => Results<GistSummary>, Blame => Blame, Compare => Comparison,
Deployments => DeploymentList, Milestones => MilestoneList, Milestone => MilestoneDetail,
);
struct Needs<'a> {
state: &'a State,
retry: &'a str,
}
impl<'a> Needs<'a> {
fn of<T>(&self, remote: Option<&'a Remote<T>>) -> Fetched<'a, T> {
Remote::fetched(remote, self.retry)
}
fn get<T: Picked + ?Sized>(&self, key: &DataKey) -> Fetched<'a, T> {
self.of(self.state.data.get(key)).pick(T::pick)
}
fn paged<T: Picked + ?Sized>(&self, route: &Route) -> Fetched<'a, T> {
let remote = paged(route).and_then(|key| self.state.data.get(&key));
self.of(remote).pick(T::pick)
}
}
impl State {
pub fn get(&self, key: &DataKey) -> Option<&Data> {
self.data.get(key).and_then(|r| r.data.as_ref())
}
pub fn picked<T: Picked + ?Sized>(&self, key: &DataKey) -> Option<&T> {
self.get(key).and_then(T::pick)
}
pub fn overview(&self, repo: &RepoId) -> Option<&RepoOverview> {
self.picked(&DataKey::Repo(repo.clone()))
}
pub fn activity(&self, pr: &PrRef) -> Option<&PrActivity> {
self.picked(&DataKey::PrActivity(pr.clone()))
}
pub(crate) fn fetches(&self, route: &Route) -> impl Iterator<Item = Remote<()>> {
self.needs(route).into_iter().filter_map(|need| match need {
Need::Pr(pr) => self.prs.get(&pr).map(Remote::status),
Need::Data(key) => self.data.get(&key).map(Remote::status),
})
}
fn stale_errors(&self, route: &Route) -> Option<String> {
let fetches = self.fetches(route).filter(|r| r.data.is_some());
let errors = BTreeSet::from_iter(fetches.filter_map(|r| r.error));
(!errors.is_empty()).then(|| Vec::from_iter(errors).join("; "))
}
pub fn page_cached_at(&self, route: &Route) -> Option<u64> {
self.fetches(route).filter_map(|r| r.cached_at).min()
}
pub fn compact(&self) -> bool {
match self.density {
crate::config::Density::Compact => true,
crate::config::Density::Comfortable => false,
crate::config::Density::Auto => self.size.1 < 30,
}
}
pub fn page_loading(&self, route: &Route) -> bool {
self.fetches(route).any(|r| r.loading)
}
pub fn first_key(&self, action: Action) -> String {
self.key_here(action)
.unwrap_or_else(|| format!(":{}", action.name().replace('_', " ")))
}
pub fn build_page(&self, route: &Route, width: u16, now: u64) -> Page {
let icons = self.icons;
let (comment, find_file, filter) = (
self.first_key(Action::Comment),
self.first_key(Action::FindFile),
self.first_key(Action::Search),
);
let keys = Keys {
comment: &comment,
find_file: &find_file,
filter: &filter,
};
let cx = pages::PageCtx { icons, keys, now };
let reading = width.min(MAX_WIDTH);
let mut page = match route {
Route::Repo(_)
| Route::Issue { .. }
| Route::Pr {
tab: PrTab::Conversation,
..
} => Page::with_aside(reading),
Route::Blob { path, .. } if pages::is_markdown(path) => Page::new(reading),
Route::Pr {
tab: PrTab::Checks, ..
}
| Route::CommitChecks { .. }
| Route::Actions(_)
| Route::WorkflowRun { .. }
| Route::Job { .. }
| Route::Workflow { .. }
| Route::Blob { .. }
| Route::Blame { .. }
| Route::Branches(_)
| Route::Tags(_)
| Route::Deployments { .. } => Page::new(width),
_ => Page::new(reading),
};
page.compact = self.compact();
if let Some(err) = self.stale_errors(route) {
let when = self
.page_cached_at(route)
.map_or_else(|| "before".to_owned(), |at| crate::ui::time::ago(at, now));
let why = format!("Couldn't refresh: {err}. Showing what was loaded {when}.");
pages::flash(&mut page, &why);
page.blank();
}
let banner = page.lines.len();
let retry = self.first_key(Action::Refresh);
let f = Needs {
state: self,
retry: &retry,
};
let title = route.title();
match route {
Route::Home => {
let sections: Vec<pages::HomeSection<'_>> = self
.home
.iter()
.map(|s| pages::HomeSection {
title: &s.title,
search: s.search.as_ref().map(|q| pages::SectionSearch {
kind: q.kind,
query: &q.query,
rows: q.rows,
results: f.get(&q.key()),
}),
})
.collect();
pages::home(&mut page, §ions, icons, now);
}
Route::Repo(repo) => {
let overview = f.get(&DataKey::Repo(repo.clone()));
#[expect(clippy::disallowed_methods, reason = "extra: shown once loaded")]
pages::repo_title(&mut page, repo, overview.ready_unchecked());
if let Some(o) = overview.show(&mut page, "the repository") {
let key = DataKey::LastCommits(repo.clone(), "HEAD".into(), String::new());
#[expect(clippy::disallowed_methods, reason = "extra: shown once loaded")]
let commits = f.get(&key).ready_unchecked();
let readme = f.get(&DataKey::Readme(repo.clone()));
pages::repo_code(&mut page, repo, o, commits, readme, cx);
}
}
Route::Tree { repo, rev, path } => {
let key = DataKey::LastCommits(repo.clone(), rev.clone(), path.clone());
#[expect(clippy::disallowed_methods, reason = "extra: shown once loaded")]
let commits = f.get(&key).ready_unchecked();
let dir = pages::Listing {
repo,
rev,
path,
entries: f.get(&DataKey::Tree(repo.clone(), rev.clone(), path.clone())),
commits,
};
pages::repo_dir(&mut page, dir, cx);
}
Route::Blob {
repo,
rev,
path,
lines,
} => {
let key = DataKey::Blob(repo.clone(), rev.clone(), path.clone());
if let Some(blob) = f.get(&key).show(&mut page, "the file") {
let file = pages::FileAt {
repo,
rev,
path,
lines: *lines,
};
pages::file(&mut page, file, blob, keys);
}
}
Route::Blame {
repo,
rev,
path,
lines,
} => {
let blob = f.get(&DataKey::Blob(repo.clone(), rev.clone(), path.clone()));
let blame = f.get(&DataKey::Blame(repo.clone(), rev.clone(), path.clone()));
let file = pages::FileAt {
repo,
rev,
path,
lines: *lines,
};
pages::blame(&mut page, file, blob, blame, keys, now);
}
Route::Unsplit { repo, spot, .. } => {
let key = DataKey::RefIn(repo.clone(), spot.clone());
let _ = f.get::<Option<String>>(&key).show(&mut page, "the branch");
}
Route::Issues { repo, query } | Route::Pulls { repo, query } => {
let is_pr = matches!(route, Route::Pulls { .. });
#[expect(clippy::disallowed_methods, reason = "extra: shown once loaded")]
let overview = f
.get::<RepoOverview>(&DataKey::Repo(repo.clone()))
.ready_unchecked();
let counts = overview.map(|o| {
if is_pr {
(o.open_prs, o.closed_prs)
} else {
(o.open_issues, o.closed_issues)
}
});
let results = f.paged(route).pick(|r| match r {
SearchResults::Issues(r) => Some(r),
_ => None,
});
pages::issue_list(&mut page, query, counts, is_pr, results, cx);
}
Route::Search { kind, query } => {
let results = f.paged(route);
pages::search(&mut page, *kind, query, results, icons, keys, now);
}
Route::Issue { repo, number } => {
let issue =
f.get::<Option<Box<IssueDetail>>>(&DataKey::Issue(repo.clone(), *number));
match issue.show(&mut page, &format!("{repo}#{number}")) {
Some(Some(issue)) => pages::issue(&mut page, issue, icons, keys, now),
Some(None) => page.line(vec![Seg::new("Loading…", Role::Meta)]),
None => {}
}
}
Route::Pr { pr, tab } => {
let activity = f.get(&DataKey::PrActivity(pr.clone()));
if let Some(d) = f.of(self.prs.get(pr)).show(&mut page, &pr.to_string()) {
match tab {
PrTab::Conversation => {
pages::pr_conversation(&mut page, pr, d, activity, cx);
}
PrTab::Commits => pages::pr_commits(&mut page, pr, d, activity, now),
PrTab::Checks => {
let checks = f.get(&DataKey::PrChecks(pr.clone()));
pages::pr_checks(&mut page, pr, d, checks, now);
}
}
}
}
Route::Stargazers(_) => pages::people_list(&mut page, "Stargazers", f.paged(route)),
Route::Watchers(_) => pages::people_list(&mut page, "Watchers", f.paged(route)),
Route::Forks(_) => pages::repos(&mut page, "Forks", f.paged(route), now),
Route::Releases(repo) => pages::releases(&mut page, repo, f.paged(route), now),
Route::Tags(repo) => pages::tags(&mut page, repo, f.paged(route), now),
Route::Branches(repo) => pages::branches(&mut page, repo, f.paged(route), icons, now),
Route::Milestones { repo, closed } => {
pages::milestones(&mut page, repo, f.paged(route), *closed, now);
}
Route::Milestone { repo, .. } => {
if let Some(m) = f.paged(route).show(&mut page, &title) {
pages::milestone(&mut page, repo, m, icons, now);
}
}
Route::Compare { repo, spec } => {
if let Some(c) = f.paged(route).show(&mut page, &title) {
pages::compare(&mut page, repo, spec, c, now);
}
}
Route::Wiki { repo, page: name } => {
let key = DataKey::Wiki(repo.clone(), name.clone());
if let Some(w) = f.get(&key).show(&mut page, &title) {
pages::wiki(&mut page, repo, w);
}
}
Route::Advisories(repo) => {
pages::advisories(&mut page, repo.as_ref(), f.paged(route), now);
}
Route::Advisory { repo, ghsa } => {
let key = DataKey::Advisory(repo.clone(), ghsa.clone());
if let Some(a) = f.get(&key).show(&mut page, ghsa) {
pages::advisory(&mut page, a, now);
}
}
Route::Teams(org) => pages::teams(&mut page, org, f.paged(route)),
Route::Team { org, slug } => {
let key = DataKey::Team(org.to_lowercase(), slug.clone());
if let Some(t) = f.get(&key).show(&mut page, &title) {
pages::team(&mut page, org, t);
}
}
Route::Gist { id, .. } => {
if let Some(g) = f.get(&DataKey::Gist(id.clone())).show(&mut page, &title) {
pages::gist(&mut page, g, now);
}
}
Route::Gists(login) => pages::gists(&mut page, login, f.paged(route), now),
Route::Deployments { repo, environment } => {
let env = environment.as_deref();
pages::deployments(&mut page, repo, f.paged(route), env, now);
}
Route::Discussions { of, category } => {
let list = f.paged(route);
let base = Route::Discussions {
of: of.clone(),
category: None,
};
pages::discussions(&mut page, &base.url(), list, category.as_deref(), now);
}
Route::Discussion { of, number } => {
let key = DataKey::Discussion(of.clone(), *number);
if let Some(d) = f.get(&key).show(&mut page, &title) {
pages::discussion(&mut page, d, now);
}
}
Route::WorkflowRun { repo, run, attempt } => {
let key = DataKey::Run(repo.clone(), *run, *attempt);
if let Some(r) = f.get(&key).show(&mut page, &title) {
pages::workflow_run(&mut page, repo, r, now);
}
}
Route::Job {
repo,
job,
step,
query,
..
} => {
let key = DataKey::Job(repo.clone(), *job);
if let Some(j) = f.get(&key).show(&mut page, &title) {
let log = f.get(&DataKey::JobLog(repo.clone(), *job));
let at = pages::JobAt {
step: *step,
query,
keys,
};
pages::job(&mut page, repo, j, log, at, now);
}
}
Route::Workflow { repo, file } => {
let wf = f.get(&DataKey::Workflow(repo.clone(), file.clone()));
pages::workflow(&mut page, repo, wf, f.paged(route), now);
}
Route::CommitChecks { repo, oid } => {
let checks = f.get(&DataKey::CommitChecks(repo.clone(), oid.clone()));
pages::commit_checks(&mut page, checks, now);
}
Route::Release { repo, tag } => {
let key = DataKey::Release(repo.clone(), tag.clone());
if let Some(r) = f.get(&key).show(&mut page, &title) {
pages::release(&mut page, repo, r, now);
}
}
Route::Actions(repo) => {
let checks = f.get(&DataKey::BranchChecks(repo.clone()));
#[expect(clippy::disallowed_methods, reason = "extra: shown once loaded")]
let overview = f
.get::<RepoOverview>(&DataKey::Repo(repo.clone()))
.ready_unchecked();
let branch = overview.and_then(|o| o.default_branch.as_deref());
pages::actions(&mut page, branch, checks, now);
}
Route::Commits { repo, rev, path } => {
pages::commit_history(&mut page, repo, rev, path, f.paged(route), now);
}
Route::Commit { repo, oid } => {
let key = DataKey::Commit(repo.clone(), oid.clone());
if let Some(c) = f.get::<CommitDetail>(&key).show(&mut page, &title) {
let files = Route::Commit {
repo: repo.clone(),
oid: c.oid.clone(),
};
let files = format!("{}#files", files.url());
pages::commit(&mut page, repo, c, &files, now);
}
}
Route::User { login, tab } => {
let key = DataKey::Profile(login.to_lowercase());
if let Some(p) = f.get(&key).show(&mut page, &format!("@{login}")) {
let list = match paged(route) {
Some(key @ DataKey::Users(_)) => ProfileList::People(f.get(&key)),
Some(key) => ProfileList::Repos(f.get(&key)),
None => ProfileList::None,
};
pages::profile(&mut page, p, *tab, list, now);
}
}
}
if let Some(a) = page.aside.as_mut().filter(|a| !a.lines.is_empty()) {
a.lines.splice(0..0, vec![PageLine::default(); banner]);
}
page
}
}
fn extend<T, K: Eq + std::hash::Hash>(a: &mut Results<T>, b: Results<T>, id: impl Fn(&T) -> K) {
let mut seen: std::collections::HashSet<K> = a.items.iter().map(&id).collect();
a.items
.extend(b.items.into_iter().filter(|item| seen.insert(id(item))));
a.next = b.next;
a.total = b.total.max(a.items.len() as u64);
}
pub fn append(results: &mut SearchResults, more: SearchResults) {
match (results, more) {
(SearchResults::Repos(a), SearchResults::Repos(b)) => extend(a, b, |r| r.repo.clone()),
(SearchResults::Issues(a), SearchResults::Issues(b)) => {
extend(a, b, |i| (i.repo.clone(), i.number));
}
(SearchResults::Users(a), SearchResults::Users(b)) => extend(a, b, |u| u.login.clone()),
(SearchResults::Discussions(a), SearchResults::Discussions(b)) => {
extend(a, b, |d| (d.repo.clone(), d.summary.number));
}
(SearchResults::Commits(a), SearchResults::Commits(b)) => {
extend(a, b, |c| (c.repo.clone(), c.commit.oid.clone()));
}
(SearchResults::Code(a), SearchResults::Code(b)) => {
extend(a, b, |c| (c.repo.clone(), c.path.clone()));
}
_ => {}
}
}
pub fn next_cursor(results: &SearchResults) -> Option<&str> {
results.next()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_next_page_leaves_out_what_is_shown() {
let branch = |n: u32| BranchInfo {
name: format!("b{n}"),
..crate::fixtures::branches().items[0].clone()
};
let page = |names: std::ops::Range<u32>, next: bool| Results {
total: 5,
items: names.map(branch).collect(),
next: next.then(|| "c".to_owned()),
};
let mut shown = Data::Branches(Box::new(page(0..3, true)));
shown.append(Data::Branches(Box::new(page(2..5, false))));
let Data::Branches(r) = shown else { panic!() };
let names: Vec<&str> = r.items.iter().map(|b| b.name.as_str()).collect();
assert_eq!(names, ["b0", "b1", "b2", "b3", "b4"]);
assert_eq!(r.next, None);
}
}