use crate::api::browse::{
CommitDetail, Comparison, DiscussionsOf, Profile, RepoSort, SearchKind, SearchResults,
};
use crate::api::model::{PrRef, RepoId};
use crate::git::Oid;
use crate::ui::chrome::{Crumb, PageTab};
use crate::ui::pages::{PrTab, ProfileTab};
use ratatui::layout::Rect;
use crate::browse::{Data, DataKey, paged};
use crate::diff_screen::DiffOf;
use crate::route::{OPEN, Route, Target};
use crate::state::{Screen, State};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Chrome {
pub crumbs: Vec<(Crumb, Option<Target>)>,
pub right: Vec<(String, Target)>,
pub tabs: Vec<(PageTab, Target)>,
pub active: Option<usize>,
pub title: Option<PrRef>,
pub open_tabs: Vec<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Layout {
pub header: Rect,
pub title: Option<Rect>,
pub tabs: Option<Rect>,
pub content: Rect,
pub problem: Option<Rect>,
pub status: Rect,
}
fn new_tab(icon: &'static str, label: &str, count: Option<u64>) -> PageTab {
PageTab {
icon,
label: label.to_owned(),
count,
}
}
impl State {
pub fn chrome(&self) -> Chrome {
let mut c = self.screen_chrome();
let requests = DataKey::Search(SearchKind::Pulls, crate::home::REVIEW_REQUESTS.into());
if let Some(SearchResults::Issues(r)) = self.picked::<SearchResults>(&requests)
&& r.total > 0
{
c.right.push((
format!("⇄ {} to review", r.total),
Target::Page(Route::Search {
kind: SearchKind::Pulls,
query: crate::home::REVIEW_REQUESTS.into(),
}),
));
}
if let Some(login) = &self.viewer {
c.right
.push((format!("@{login}"), Target::Page(Route::user(login))));
}
self.tab_strip(&mut c);
c
}
fn screen_chrome(&self) -> Chrome {
let mut c = Chrome::default();
match self.screen() {
Screen::Page(p) => self.page_chrome(&p.route, &mut c),
Screen::Diff(d) => {
repo_crumbs(d.of.repo(), &mut c);
let pr = match &d.of {
DiffOf::Pr(pr) => pr,
DiffOf::Commit(repo, oid) => {
self.commit_tabs(repo, oid, &mut c);
c.active = Some(2);
return c;
}
DiffOf::Range(repo, from, to) => {
let spec = format!("{from}..{to}");
compare_tabs(repo, &spec, Some((from.clone(), to.clone())), None, &mut c);
c.active = Some(1);
return c;
}
};
self.pr_tabs(pr, &mut c);
let diff = self.diffs.get(&d.of);
let label = match diff.and_then(|d| d.range()) {
Some(range) => Some(format!("Files · {}", range.label)),
None if diff.is_some_and(|d| d.doc.since_active()) => {
Some("Files · since your review".to_owned())
}
None => None,
};
c.active = c
.tabs
.iter()
.position(|(_, t)| matches!(t, Target::Files(_)));
if let (Some(label), Some((tab, _))) =
(label, c.active.and_then(|i| c.tabs.get_mut(i)))
{
tab.label = label;
}
c.title = Some(pr.clone());
}
}
c
}
fn page_chrome(&self, route: &Route, c: &mut Chrome) {
match route {
Route::Home => c.crumb("Home", None),
Route::Pr { pr, tab } => {
repo_crumbs(&pr.repo, c);
self.pr_tabs(pr, c);
c.active = Some(match tab {
PrTab::Conversation => 0,
PrTab::Commits => 1,
PrTab::Checks => 2,
});
c.title = Some(pr.clone());
}
Route::Commit { repo, oid } | Route::CommitChecks { repo, oid } => {
repo_crumbs(repo, c);
self.commit_tabs(repo, oid, c);
c.active = Some(usize::from(matches!(route, Route::CommitChecks { .. })));
}
Route::Gist { owner, id } => {
let gist = match self.get(&DataKey::Gist(id.clone())) {
Some(Data::Gist(g)) => g.owner.clone(),
_ => None,
};
let owner = owner.clone().or(gist);
if let Some(owner) = owner {
c.crumb(&owner, Some(Target::Page(Route::user(&owner))));
c.crumb("Gists", Some(Target::Page(Route::Gists(owner.clone()))));
}
c.crumb("Gist", None);
}
Route::Advisories(None) => c.crumb("Advisories", None),
Route::Advisory { repo: None, ghsa } => {
c.crumb("Advisories", Some(Target::Page(Route::Advisories(None))));
c.crumb(ghsa, None);
}
Route::Teams(org) => {
c.crumb(org, Some(Target::Page(Route::user(org))));
c.crumb("Teams", None);
}
Route::Team { org, slug } => {
c.crumb(org, Some(Target::Page(Route::user(org))));
c.crumb("Teams", Some(Target::Page(Route::Teams(org.clone()))));
c.crumb(slug, None);
}
Route::Gists(login) => {
c.crumb(login, Some(Target::Page(Route::user(login))));
c.crumb("Gists", None);
}
Route::Compare { repo, spec } => {
repo_crumbs(repo, c);
self.compare_page_tabs(repo, spec, c);
}
Route::Discussions {
of: DiscussionsOf::Org(org),
..
}
| Route::Discussion {
of: DiscussionsOf::Org(org),
..
} => {
c.crumb(org, Some(Target::Page(Route::user(org))));
c.crumb("Discussions", None);
}
Route::User { login, tab } => {
c.crumb(login, None);
let profile = self.picked::<Profile>(&DataKey::Profile(login.to_lowercase()));
let user = |tab| {
Target::Page(Route::User {
login: login.clone(),
tab,
})
};
let sort = match tab {
ProfileTab::Repositories(sort) => *sort,
_ => RepoSort::Updated,
};
let mut tabs = vec![
("◫", "Overview", None, ProfileTab::Overview),
(
"▤",
"Repositories",
profile.map(|p| p.repos.total()),
ProfileTab::Repositories(sort),
),
];
if profile.is_some_and(|p| p.is_org) {
tabs.push((
"⚇",
"People",
profile.map(|p| p.people.total()),
ProfileTab::People,
));
} else {
tabs.extend([
(
"☆",
"Stars",
profile.map(|p| p.star_count),
ProfileTab::Stars,
),
(
"⚇",
"Followers",
profile.and_then(|p| p.followers),
ProfileTab::Followers,
),
(
"⚇",
"Following",
profile.and_then(|p| p.following),
ProfileTab::Following,
),
]);
}
c.active = tabs.iter().position(|(.., t)| t == tab);
for (icon, label, count, tab) in tabs {
c.tabs.push((new_tab(icon, label, count), user(tab)));
}
}
Route::Search { kind, query } => {
c.crumb("Search", None);
let results = paged(route).and_then(|key| self.picked::<SearchResults>(&key));
let total = results.map(|r| r.counts().0);
let kinds = [
(SearchKind::Repos, "▤", "Repos"),
(SearchKind::Issues, "◉", "Issues"),
(SearchKind::Pulls, "⇄", "PRs"),
(SearchKind::Users, "⚇", "Users"),
(SearchKind::Discussions, "◈", "Discussions"),
(SearchKind::Commits, "◷", "Commits"),
(SearchKind::Code, "<>", "Code"),
];
for (i, (k, icon, label)) in kinds.into_iter().enumerate() {
if k == *kind {
c.active = Some(i);
}
let count = (k == *kind).then_some(total).flatten();
c.tabs.push((
new_tab(icon, label, count),
Target::Page(Route::Search {
kind: k,
query: query.clone(),
}),
));
}
}
_ => {
let Some(repo) = route.repo() else {
return;
};
repo_crumbs(repo, c);
self.repo_tabs(repo, c);
let here = section(route);
c.active = c.tabs.iter().position(|(_, t)| match t {
Target::Page(r) => section(r) == here,
_ => false,
});
}
}
}
fn tab_strip(&self, c: &mut Chrome) {
if self.tab_count() < 2 {
return;
}
let header = Rect::new(0, 0, self.size.0, self.size.1.min(1));
let right: Vec<String> = c.right.iter().map(|(t, _)| t.clone()).collect();
let reserve = Crumb {
text: " ".repeat(32),
current: false,
tab: true,
};
let h = crate::ui::chrome::header_layout(header, &[reserve], &right);
let room = usize::from(h.search.x.saturating_sub(h.logo.right().saturating_add(5)));
let titles: Vec<String> = self
.tab_titles()
.into_iter()
.map(|t| crate::ui::text::truncate(&t, 20))
.collect();
let active = self.active_tab();
let width = |i: usize| titles.get(i).map_or(0, |t| crate::ui::text::width(t) + 3);
let (mut first, mut last) = (active, active);
let mut used = width(active);
loop {
let marker = 8;
let grew_right = last + 1 < titles.len() && used + width(last + 1) + marker <= room;
if grew_right {
last += 1;
used += width(last);
}
let grew_left = first > 0 && used + width(first - 1) + marker <= room;
if grew_left {
first -= 1;
used += width(first);
}
if !grew_right && !grew_left {
break;
}
}
let crumb = |text: String, current| Crumb {
text,
current,
tab: true,
};
c.crumbs.clear();
c.open_tabs.clear();
if first > 0 {
c.crumbs.push((crumb(format!("‹ {first}"), false), None));
c.open_tabs.push(first - 1);
}
for (i, title) in titles.iter().enumerate().take(last + 1).skip(first) {
c.crumbs.push((crumb(title.clone(), i == active), None));
c.open_tabs.push(i);
}
let hidden = titles.len() - last - 1;
if hidden > 0 {
c.crumbs.push((crumb(format!("{hidden} ›"), false), None));
c.open_tabs.push(last + 1);
}
}
fn commit_tabs(&self, repo: &RepoId, oid: &str, c: &mut Chrome) {
let detail = self.picked::<CommitDetail>(&DataKey::Commit(repo.clone(), oid.to_owned()));
let full = detail.map_or(oid, |d| d.oid.as_str());
c.tabs.push((
new_tab("◷", "Commit", None),
Target::Page(Route::Commit {
repo: repo.clone(),
oid: full.to_owned(),
}),
));
c.tabs.push((
new_tab("✓", "Checks", None),
Target::Page(Route::CommitChecks {
repo: repo.clone(),
oid: full.to_owned(),
}),
));
if let Ok(oid) = Oid::parse(full) {
c.tabs.push((
new_tab("±", "Files changed", detail.and_then(|d| d.changed_files)),
Target::Files(DiffOf::Commit(repo.clone(), oid)),
));
}
}
fn compare_page_tabs(&self, repo: &RepoId, spec: &str, c: &mut Chrome) {
let key = DataKey::Compare(repo.clone(), spec.to_owned());
let comparison = match self.get(&key) {
Some(Data::Compare(cmp)) => Some(&**cmp),
_ => None,
};
let range = comparison
.and_then(|cmp| Some((Oid::parse(&cmp.from).ok()?, Oid::parse(&cmp.to).ok()?)));
compare_tabs(repo, spec, range, comparison, c);
c.active = Some(0);
}
fn repo_tabs(&self, repo: &RepoId, c: &mut Chrome) {
let o = self.overview(repo);
c.tabs.push((
new_tab("<>", "Code", None),
Target::Page(Route::Repo(repo.clone())),
));
if o.is_none_or(|o| o.has_issues) {
c.tabs.push((
new_tab("◉", "Issues", o.map(|o| o.open_issues)),
Target::Page(Route::Issues {
repo: repo.clone(),
query: OPEN.into(),
}),
));
}
c.tabs.push((
new_tab("⇄", "Pull requests", o.map(|o| o.open_prs)),
Target::Page(Route::Pulls {
repo: repo.clone(),
query: OPEN.into(),
}),
));
if o.is_some_and(|o| o.has_discussions) {
c.tabs.push((
new_tab("◈", "Discussions", None),
Target::Page(Route::Discussions {
of: DiscussionsOf::Repo(repo.clone()),
category: None,
}),
));
}
c.tabs.push((
new_tab("▶", "Actions", None),
Target::Page(Route::Actions(repo.clone())),
));
if o.is_some_and(|o| o.has_wiki) {
c.tabs.push((
new_tab("▤", "Wiki", None),
Target::Page(Route::Wiki {
repo: repo.clone(),
page: None,
}),
));
}
c.tabs.push((
new_tab("⛨", "Security", None),
Target::Page(Route::Advisories(Some(repo.clone()))),
));
}
fn pr_tabs(&self, pr: &PrRef, c: &mut Chrome) {
let activity = self.activity(pr);
let detail = self.prs.get(pr).and_then(|r| r.data.as_ref());
c.tabs.push((
new_tab("◌", "Conversation", activity.map(|a| a.comments.total())),
Target::Page(Route::pr(pr.clone())),
));
c.tabs.push((
new_tab("◷", "Commits", activity.map(|a| a.commits.total())),
Target::Page(Route::Pr {
pr: pr.clone(),
tab: PrTab::Commits,
}),
));
c.tabs.push((
new_tab("✓", "Checks", None),
Target::Page(Route::Pr {
pr: pr.clone(),
tab: PrTab::Checks,
}),
));
c.tabs.push((
new_tab("±", "Files changed", detail.map(|d| d.changed_files)),
Target::Files(DiffOf::Pr(pr.clone())),
));
}
pub fn layout(&self) -> Layout {
self.layout_of(&self.screen_chrome())
}
pub fn layout_of(&self, c: &Chrome) -> Layout {
let (w, h) = self.size;
let mut y = 0;
let row = |y: u16, height: u16| Rect::new(0, y, w, height.min(h.saturating_sub(y)));
let header = row(y, 1);
y += 1;
let title = c.title.is_some().then(|| {
let r = row(y, 1);
y += 1;
r
});
let tabs = (!c.tabs.is_empty()).then(|| {
let r = row(y, 2);
y += 2;
r
});
let status_y = h.saturating_sub(1);
let problem_y = (!self.problems.is_empty() && status_y > y).then(|| status_y - 1);
let bottom = problem_y.unwrap_or(status_y);
Layout {
header,
title: title.filter(|r| r.height > 0 && r.y < status_y),
tabs: tabs.filter(|r| r.height > 0 && r.y < status_y),
content: Rect::new(0, y.min(bottom), w, bottom.saturating_sub(y)),
problem: problem_y.map(|y| row(y, 1)),
status: row(status_y, 1),
}
}
}
#[derive(PartialEq, Eq)]
enum Section {
Code,
Issues,
Pulls,
Discussions,
Actions,
Wiki,
Security,
}
fn section(route: &Route) -> Section {
match route {
Route::Issues { .. }
| Route::Issue { .. }
| Route::Milestones { .. }
| Route::Milestone { .. } => Section::Issues,
Route::Pulls { .. } | Route::Pr { .. } => Section::Pulls,
Route::Discussions { .. } | Route::Discussion { .. } => Section::Discussions,
Route::Actions(_)
| Route::WorkflowRun { .. }
| Route::Job { .. }
| Route::Workflow { .. } => Section::Actions,
Route::Wiki { .. } => Section::Wiki,
Route::Advisories(_) | Route::Advisory { .. } => Section::Security,
_ => Section::Code,
}
}
fn compare_tabs(
repo: &RepoId,
spec: &str,
range: Option<(Oid, Oid)>,
comparison: Option<&Comparison>,
c: &mut Chrome,
) {
c.tabs.push((
new_tab("◷", "Commits", comparison.map(|cmp| cmp.commits.total())),
Target::Page(Route::Compare {
repo: repo.clone(),
spec: spec.to_owned(),
}),
));
if let Some((from, to)) = range {
c.tabs.push((
new_tab(
"±",
"Files changed",
comparison
.filter(|cmp| !cmp.files_capped)
.map(|cmp| cmp.files),
),
Target::Files(DiffOf::Range(repo.clone(), from, to)),
));
}
}
fn repo_crumbs(repo: &RepoId, c: &mut Chrome) {
c.crumb(&repo.owner, Some(Target::Page(Route::user(&repo.owner))));
c.crumb(&repo.name, Some(Target::Page(Route::Repo(repo.clone()))));
}
impl Chrome {
fn crumb(&mut self, text: &str, target: Option<Target>) {
if let Some((last, _)) = self.crumbs.last_mut() {
last.current = false;
}
let crumb = Crumb {
text: text.to_owned(),
current: true,
tab: false,
};
self.crumbs.push((crumb, target));
}
}
#[cfg(test)]
mod tests {
use crate::route::Route;
use crate::state::tests::state;
#[test]
fn rows_are_kept_only_for_the_chrome_there_is() {
let mut s = state();
let _ = s.push(Route::Gist {
owner: None,
id: "6cad326836d38bd3a7ae".into(),
});
assert!(s.chrome().tabs.is_empty());
let lay = s.layout();
assert_eq!((lay.title, lay.tabs, lay.content.y), (None, None, 1));
}
}