ghtui 0.2.5

A keyboard-driven terminal client for GitHub, with a pull request diff viewer that goes further than GitHub's
//! A file, and its blame.

use super::*;

/// A file at a revision, and the lines a link points at.
#[derive(Clone, Copy)]
pub struct FileAt<'a> {
    pub repo: &'a RepoId,
    pub rev: &'a str,
    pub path: &'a str,
    /// Marked, and where the page opens.
    pub lines: Option<(u32, u32)>,
}

/// A file: highlighted with line numbers, or Markdown rendered.
pub fn file(page: &mut Page, at: FileAt<'_>, blob: &Blob, keys: Keys<'_>) {
    let FileAt {
        repo,
        rev,
        path,
        lines: marked,
    } = at;
    code_toolbar(page, repo, rev, path, true, None, keys);
    let size = size(blob.size);
    let Some(text) = &blob.text else {
        let why = "Binary file not shown here: open it on GitHub.";
        empty_box(page, meta(size), why);
        return;
    };
    let lines = text.lines().count() as u64;
    let mut info = format!("{} · {size}", plural(lines, "line"));
    if blob.truncated {
        info.push_str(" · only the beginning is shown");
    }
    let blame = url::blame(repo, rev, path);
    let blame = link_seg(page, "Blame", blame, Role::Link);
    let raw = link_seg(page, "Raw", url::raw(repo, rev, path), Role::Link);
    let right = vec![blame, meta("  "), raw];
    page.box_top(vec![meta(info)], right);
    if is_markdown(path) {
        let base = LinkBase::new(repo, rev, path);
        markdown::render(page, text, Some(&base), Frame::Body);
        page.box_bottom();
        return;
    }
    let lines = markdown::highlighted_file(text, crate::diff::Language::from_path(path));
    let digits = lines.len().to_string().len();
    let is_marked = |n: usize| marked.is_some_and(|(a, b)| (a as usize..=b as usize).contains(&n));
    // Each line is a row: selected, `y` copies its link.
    let file_url = url::blob(repo, rev, path);
    let line_url = |n: usize| format!("{file_url}#L{n}");
    for (i, mut segs) in lines.into_iter().enumerate() {
        let n = i + 1;
        segs.insert(0, line_number(n, digits));
        let start = code_line(page, segs, is_marked(n), 1);
        let link = page.link(line_url(n));
        page.item(start, link);
    }
    page.box_bottom();
}

/// A file with, beside each run of lines, the commit that last changed
/// them: its ID, author and age.
pub fn blame(
    page: &mut Page,
    at: FileAt<'_>,
    blob: Fetched<'_, Blob>,
    blame: Fetched<'_, Blame>,
    keys: Keys<'_>,
    now: u64,
) {
    let FileAt {
        repo,
        rev,
        path,
        lines: marked,
    } = at;
    code_toolbar(page, repo, rev, path, true, None, keys);
    let Some(blob) = blob.show(page, "the file") else { return };
    let Some(blame) = blame.show(page, "the blame") else { return };
    let Some(text) = &blob.text else {
        empty_box(page, meta("Blame"), "Binary file not shown.");
        return;
    };
    let file = link_seg(page, "File", url::blob(repo, rev, path), Role::Link);
    let commits = (blame.ranges.iter().map(|r| &r.commit.oid))
        .collect::<std::collections::HashSet<_>>()
        .len() as u64;
    let info = format!("Blame · {}", plural(commits, "commit"));
    page.box_top(vec![meta(info)], vec![file]);
    let lines = markdown::highlighted_file(text, crate::diff::Language::from_path(path));
    let digits = lines.len().to_string().len();
    let is_marked = |n: usize| marked.is_some_and(|(a, b)| (a as usize..=b as usize).contains(&n));
    // Beside each run of lines: its commit, author, age and message, in
    // a column that takes what the page can spare.
    let author_w = (blame.ranges.iter())
        .map(|r| width(r.commit.author.name()))
        .max()
        .unwrap_or(0)
        .min(20);
    let gutter_w = (usize::from(page.width) * 2 / 5).clamp(32, 90);
    let fixed_w = 7 + 1 + author_w + 1 + 8 + 2;
    let message_w = gutter_w.saturating_sub(fixed_w + 2);
    let gutter_w = if message_w >= 8 { gutter_w } else { fixed_w };
    // Each line's run, the first that has it, found in one pass.
    let mut runs = vec![None; lines.len()];
    for r in &blame.ranges {
        let lines = (r.start as usize).saturating_sub(1)..r.end as usize;
        for run in runs.iter_mut().take(lines.end).skip(lines.start) {
            run.get_or_insert(r);
        }
    }
    // Each run is a row opening its commit.
    let mut run_start = 0;
    for (i, mut segs) in lines.into_iter().enumerate() {
        let n = i + 1;
        let range = runs.get(i).copied().flatten();
        if range.is_some_and(|r| r.start as usize == n && n > 1) {
            page.box_rule();
        }
        let mut gutter = match range {
            Some(r) if r.start as usize == n => {
                run_start = page.lines.len();
                let c = &r.commit;
                let author = truncate(c.author.name(), author_w);
                let ago = time::ago_iso(&c.date, now);
                let who = meta(format!(" {author:<author_w$} {ago:>8}  "));
                let mut gutter = vec![sha_seg(page, repo, &c.oid, Role::Code), who];
                if message_w >= 8 {
                    let message = truncate(&c.headline, message_w);
                    gutter.push(Seg::new(format!("{message:<message_w$}  "), Role::Body));
                }
                gutter
            }
            _ => vec![meta(" ".repeat(gutter_w))],
        };
        let fixed = u8::try_from(gutter.len() + 1).unwrap_or(u8::MAX);
        gutter.push(line_number(n, digits));
        gutter.append(&mut segs);
        // A marked line selects the run it's in, pushed once the run ends.
        code_line(page, gutter, is_marked(n), fixed);
        if let Some(r) = range.filter(|r| r.end as usize == n) {
            let link = page.link(url::commit(repo, &r.commit.oid));
            page.item(run_start, link);
        }
    }
    page.box_bottom();
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A blame scrolls to the linked line, not the rule above its run.
    #[test]
    fn a_blame_jumps_to_its_line() {
        let range = |start, end, oid: &str| crate::api::browse::BlameRange {
            start,
            end,
            age: 1,
            commit: CommitInfo {
                oid: oid.into(),
                headline: String::new(),
                author: Person::User(crate::api::browse::Login::unchecked("octocat")),
                date: "2026-10-01T00:00:00Z".into(),
            },
        };
        let blame = Blame {
            ranges: vec![range(1, 2, "aaaaaaa"), range(3, 3, "bbbbbbb")],
        };
        let blob = crate::fixtures::file("a.txt", "one\ntwo\nthree\n");
        let repo = RepoId::new("o", "r");
        let at = FileAt {
            repo: &repo,
            rev: "main",
            path: "a.txt",
            lines: Some((3, 3)),
        };
        let mut page = Page::new(100);
        let (blob, blame) = (Fetched::ready(&blob), Fetched::ready(&blame));
        super::blame(&mut page, at, blob, blame, Keys::default(), 0);
        let line = page
            .jump
            .and_then(|j| page.lines.get(j))
            .map(PageLine::text);
        assert!(
            line.as_deref().is_some_and(|l| l.contains("three")),
            "{line:?}"
        );
    }
}