ghtui 0.2.0

A keyboard-driven terminal client for GitHub, with a pull request diff viewer that goes further than GitHub's
//! Commits: lists of them, and one commit.

use super::*;

pub fn pr_commits(
    page: &mut Page,
    pr: &PrRef,
    d: &PrDetail,
    activity: Fetched<'_, PrActivity>,
    now: u64,
) {
    pr_summary(page, pr, d, now);
    let Some(a) = activity.show(page, "commits") else {
        return;
    };
    commit_rows(page, &pr.repo, Some(pr), &a.commits, now);
}

/// Commits in boxes by day, each linked to its page: first how many
/// earlier ones were left out, which opens the history from the oldest
/// here back (oldest first, as a comparison and a PR list them), last
/// "Load more" if another page loads. A pull request's open as its
/// commits, as GitHub links them.
pub(super) fn commit_rows(
    page: &mut Page,
    repo: &RepoId,
    pr: Option<&PrRef>,
    commits: &impl Rows<CommitInfo>,
    now: u64,
) {
    let rest = match (commits.rest(), commits.rows().first()) {
        (Rest::OnGitHub(n), Some(oldest)) => {
            let earlier = url::commits(repo, &oldest.oid, "");
            let text = format!("… {n} earlier {}", noun(n, "commit"));
            item(page, earlier, |page, link| {
                page.wrapped(vec![Seg::linked(text, Role::Link, link)], 2, Frame::None);
            });
            Rest::All
        }
        (rest, _) => rest,
    };
    let mut current_day = String::new();
    for c in commits.rows() {
        let date = day(&c.date);
        if date == current_day {
            page.box_rule();
        } else {
            if !current_day.is_empty() {
                page.box_bottom();
            }
            page.box_top(
                vec![Seg::new(format!("◷ Commits on {date}"), Role::Meta)],
                Vec::new(),
            );
            current_day = date;
        }
        let commit = match pr {
            Some(pr) => url::pull_tab(pr, &format!("commits/{}", c.oid)),
            None => url::commit(repo, &c.oid),
        };
        item(page, commit, |page, link| {
            page.box_line(
                vec![Seg::linked(c.headline.clone(), Role::Strong, link)],
                vec![Seg::new(crate::ui::text::short_sha(&c.oid), Role::Code)],
                0,
            );
            let who = c.author.name();
            let committed = format!("{who} committed {}", time::ago_iso(&c.date, now));
            body(page, vec![Seg::new(committed, Role::Meta)]);
        });
    }
    if !current_day.is_empty() {
        rest_row(page, commits.rows().len(), rest);
        page.box_bottom();
    }
}

/// A revision's commits (of `path`, if it isn't empty), newest first.
pub fn commit_history(
    page: &mut Page,
    repo: &RepoId,
    rev: &str,
    path: &str,
    history: Fetched<'_, Results<CommitInfo>>,
    now: u64,
) {
    let mut title = vec![Seg::new("Commits", Role::Title)];
    let at = if path.is_empty() {
        format!("  {rev}")
    } else {
        format!("  {rev} · {path}")
    };
    title.push(Seg::new(at, Role::Meta));
    page.wrapped(title, 0, Frame::None);
    page.blank();
    let Some(h) = history.show(page, "commits") else {
        return;
    };
    if h.items.is_empty() {
        empty_box(page, Seg::new("◷ Commits", Role::Meta), "No commits here.");
        return;
    }
    commit_rows(page, repo, None, h, now);
}

/// A commit: its message, who and when, its parents, and a link to its
/// files (`files` is that link's target).
/// A pull request's commit: which of its commits it is, and links to the
/// ones either side.
pub struct PrCommitAt {
    pub n: u64,
    pub of: u64,
    pub pr: String,
    pub number: u64,
    pub previous: Option<String>,
    pub next: Option<String>,
}

/// The line above a pull request's commit: `Commit 3 of 7 in #149`, and
/// the previous and next commits.
pub fn pr_commit_nav(page: &mut Page, at: &PrCommitAt) {
    let pr = link_seg(page, format!("#{}", at.number), at.pr.clone(), Role::Link);
    let mut segs = vec![
        Seg::new(format!("Commit {} of {} in ", at.n, at.of), Role::Meta),
        pr,
    ];
    for (label, url) in [("‹ Previous", &at.previous), ("Next ›", &at.next)] {
        segs.push(Seg::new("   ", Role::Meta));
        segs.push(match url {
            Some(url) => link_seg(page, label, url.clone(), Role::Link),
            None => Seg::new(label, Role::Meta),
        });
    }
    page.line(segs);
    page.blank();
}

pub fn commit(page: &mut Page, repo: &RepoId, d: &CommitDetail, files: &str, now: u64) {
    page.wrapped(
        vec![Seg::new(d.headline.clone(), Role::Title)],
        0,
        Frame::None,
    );
    if !d.body.is_empty() {
        page.blank();
        let base = LinkBase::new(repo, &d.oid, "");
        markdown::render(page, &d.body, Some(&base), Frame::None);
    }
    page.blank();
    let mut who = vec![
        person_seg(page, &d.author, Role::Strong),
        Seg::new(
            format!(" authored {}", time::ago_iso(&d.authored_at, now)),
            Role::Meta,
        ),
    ];
    if let Some(committer) = &d.committer {
        who.push(Seg::new(" · ", Role::Meta));
        who.push(person_seg(page, committer, Role::Strong));
        who.push(Seg::new(
            format!(" committed {}", time::ago_iso(&d.committed_at, now)),
            Role::Meta,
        ));
    }
    match d.verified {
        Some(true) => who.extend([space(), chip("Verified", Bg::SuccessContainer)]),
        Some(false) => who.extend([space(), chip("Unverified", Bg::ErrorContainer)]),
        None => {}
    }
    page.wrapped(who, 0, Frame::None);
    let mut ids = vec![Seg::new(
        format!("commit {}", crate::ui::text::short_sha(&d.oid)),
        Role::Meta,
    )];
    if !d.parents.is_empty() {
        let noun = if d.parents.total() == 1 {
            "parent"
        } else {
            "parents"
        };
        ids.push(Seg::new(format!(" · {noun} "), Role::Meta));
        for (i, parent) in d.parents.iter().enumerate() {
            if i > 0 {
                ids.push(Seg::new(" + ", Role::Meta));
            }
            let short = crate::ui::text::short_sha(parent);
            ids.push(link_seg(page, short, url::commit(repo, parent), Role::Code));
        }
        more_chips(&mut ids, &d.parents);
    }
    page.wrapped(ids, 0, Frame::None);
    page.blank();
    page.box_top(vec![Seg::new("± Files changed", Role::Meta)], Vec::new());
    item(page, files, |page, link| {
        let what = match d.changed_files {
            Some(n) => plural(n, "file"),
            None => "Files".to_owned(),
        };
        let mut segs = vec![Seg::linked(what, Role::Link, link), space()];
        segs.extend(changes(d.additions, d.deletions));
        page.box_line(segs, vec![Seg::new("↵ show the diff", Role::Meta)], 0);
    });
    page.box_bottom();
}