//! Commit diff rendering, shared by the commit page, the comparison page and the pull-request //! Files tab: the changed-file summary, the per-file index and each file's unified diff in a //! scroll wrapper. Uses the `diff-*` classes from the stylesheet. `view!` escapes text and //! attribute values. //! //! The Files tab turns on `LineComments`: each numbered line gets an id and a "+" link, the //! review threads of a line render in a full-width row under it, and the line whose inline //! comment form is open gets that form. Every line is its own ``, so those rows can span //! the table. Commit and comparison pages pass the default, which leaves all of that out. use std::collections::HashMap; use forge_core::{Side, Thread}; use forge_git::history::{CommitDiff, DiffLimits, DiffStatus, FileDiff, LineKind}; use topcoat::Result; use topcoat::view::{View, component, view}; use crate::i18n::Lang; use crate::i18n::code::{self as g, K}; use crate::pages::commits::url_path; use crate::views::review_thread::{ThreadCtx, review_thread_in}; /// Per-diff limits used by the commit and pull-request pages. pub const LIMITS: DiffLimits = DiffLimits { max_files: MAX_FILES, max_lines_per_file: 2000, max_total_lines: 20_000, }; /// Files shown before the diff reports truncation. Matches `LIMITS.max_files`. pub const MAX_FILES: usize = 300; /// The language of a diff section. It defaults to English, so a page that does not pass one (the /// comparison and Files tab pages, for now) keeps its English text. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct DiffLang(pub Lang); impl Default for DiffLang { fn default() -> Self { DiffLang(Lang::En) } } /// Line comments on a diff. The default leaves the diff as commit and comparison pages show it. #[derive(Default)] pub struct LineComments { /// Line ids, the "+" link on each numbered line and the inline form. Set only for a viewer /// who may comment, so anonymous viewers see no comment controls. pub anchors: bool, /// Threads by (file index, side, line). Each renders in the row under its line. pub threads: HashMap<(usize, Side, u32), Vec>, /// The line whose inline comment form is open. pub open_form: Option<(usize, Side, u32)>, /// URL of the Files tab, such as `/o/r/pulls/3/files`. The "+" links and the form's cancel /// link point here. pub files_href: String, /// The viewer has a pending review, so the form offers "Add review comment". pub has_pending: bool, /// What the threads and the form need: the review POST base, CSRF, viewer and permissions. pub ctx: ThreadCtx, } /// The inline comment form under an open line. struct InlineForm { action: String, cancel_href: String, csrf: String, file_index: String, side: &'static str, line: String, head_oid: String, has_pending: bool, can_review: bool, } /// What renders under a line: its threads and, if open, the form. struct RowBelow { threads: Vec, form: Option, ctx: ThreadCtx, } /// One rendered row of a file's diff table. struct DiffRow { class: &'static str, old: String, new: String, code: String, anchored: bool, /// The `L---` id, when anchors are on and the line has a number. anchor: Option, /// Target of the "+" link on this line. plus_href: Option, below: Option, } /// One file of the diff, flattened for the view. struct FileView { anchor: String, label: String, status: &'static str, additions: u32, deletions: u32, binary: bool, truncated: bool, has_blob: bool, blob_href: String, rows: Vec, } impl FileView { /// `oid` is the commit being shown; `parent` is its first parent, used for the blob link of /// deleted files (they are absent at `oid`). `comments` supplies the review data and is /// drained of the threads it renders. #[allow(clippy::too_many_arguments)] fn new( index: usize, file: &FileDiff, oid: &str, parent: Option<&str>, owner: &str, repo: &str, lang: Lang, comments: &mut LineComments, ) -> Self { let label = match (&file.old_path, &file.new_path) { (Some(old), Some(new)) if old != new => format!("{old} → {new}"), (_, Some(new)) => new.clone(), (Some(old), None) => old.clone(), (None, None) => String::new(), }; let status = match file.status { DiffStatus::Added => g::t(lang, K::StatusAdded), DiffStatus::Deleted => g::t(lang, K::StatusDeleted), DiffStatus::Modified => g::t(lang, K::StatusModified), DiffStatus::Renamed => g::t(lang, K::StatusRenamed), DiffStatus::Copied => g::t(lang, K::StatusCopied), DiffStatus::TypeChanged => g::t(lang, K::StatusTypeChanged), }; // A deleted file is absent at this commit, so its blob is read from the parent. let blob_commit = match file.status { DiffStatus::Deleted => parent, _ => Some(oid), }; let blob_path = file.new_path.as_ref().or(file.old_path.as_ref()); let blob_href = match (blob_commit, blob_path) { (Some(commit), Some(path)) => { format!("/{owner}/{repo}/blob/{commit}/{}", url_path(path)) } _ => String::new(), }; let mut rows = Vec::new(); for hunk in &file.hunks { rows.push(DiffRow { class: "diff-line hunk", old: String::new(), new: String::new(), code: hunk.header.clone(), anchored: comments.anchors, anchor: None, plus_href: None, below: None, }); for line in &hunk.lines { let (class, code) = match line.kind { LineKind::Add => ("diff-line add", format!("+{}", line.text)), LineKind::Del => ("diff-line del", format!("-{}", line.text)), LineKind::Context => ("diff-line ctx", format!(" {}", line.text)), LineKind::NoNewline => ("diff-line ctx", line.text.clone()), }; // Deleted lines sit on the old side; added and context lines on the new side. let key = match line.kind { LineKind::Add | LineKind::Context => line.new_no.map(|n| (Side::New, n)), LineKind::Del => line.old_no.map(|n| (Side::Old, n)), LineKind::NoNewline => None, }; let (anchor, plus_href, below) = match key { Some((side, number)) => { let id = format!("L-{index}-{}-{number}", side.as_str()); let anchor = comments.anchors.then(|| id.clone()); let plus_href = comments.anchors.then(|| { format!( "{}?comment={index}:{}:{number}#{id}", comments.files_href, side.as_str() ) }); let target = (index, side, number); let threads = comments.threads.remove(&target).unwrap_or_default(); let form = (comments.anchors && comments.open_form == Some(target)) .then(|| InlineForm { action: format!("{}/review_comments", comments.ctx.pr_href), cancel_href: comments.files_href.clone(), csrf: comments.ctx.csrf.clone(), file_index: index.to_string(), side: side.as_str(), line: number.to_string(), head_oid: oid.to_owned(), has_pending: comments.has_pending, can_review: comments.ctx.can_review, }); let below = (!threads.is_empty() || form.is_some()).then(|| RowBelow { threads, form, ctx: comments.ctx.clone(), }); (anchor, plus_href, below) } None => (None, None, None), }; rows.push(DiffRow { class, old: line.old_no.map(|n| n.to_string()).unwrap_or_default(), new: line.new_no.map(|n| n.to_string()).unwrap_or_default(), code, anchored: comments.anchors, anchor, plus_href, below, }); } } Self { anchor: format!("file-{}", index + 1), label, status, additions: file.additions, deletions: file.deletions, binary: file.is_binary, truncated: file.truncated, has_blob: !blob_href.is_empty(), blob_href, rows, } } } /// Entry in the file index at the top of the diff. struct NavItem { anchor_href: String, label: String, additions: u32, deletions: u32, } /// The whole diff section: a summary heading with totals, the file index and every file's /// diff. `owner` and `repo` name the repository for "view file" links, `oid` is the commit the /// diff belongs to, and `parent` is its first parent (`None` for a root commit). `subject` names /// what the diff shows in the truncation note, such as "this commit" or "this comparison". /// `comments` turns on the line comments of the Files tab; the default renders a plain diff. #[allow(clippy::too_many_arguments)] #[component] pub async fn diff_section( diff: &CommitDiff, owner: &str, repo: &str, oid: &str, parent: Option<&str>, subject: &str, #[default] lang: DiffLang, #[default] comments: LineComments, ) -> Result { let lang = lang.0; let mut comments = comments; let files: Vec = diff .files .iter() .enumerate() .map(|(i, file)| FileView::new(i, file, oid, parent, owner, repo, lang, &mut comments)) .collect(); let nav: Vec = files .iter() .map(|f| NavItem { anchor_href: format!("#{}", f.anchor), label: f.label.clone(), additions: f.additions, deletions: f.deletions, }) .collect(); let total_add: u32 = files.iter().map(|f| f.additions).sum(); let total_del: u32 = files.iter().map(|f| f.deletions).sum(); let file_count = files.len(); let no_files = files.is_empty(); let files_label = g::changed_files(lang, file_count); let truncated_note = g::diff_truncated(lang, MAX_FILES, subject); let truncated_files = diff.truncated_files; let add_label = format!("+{total_add}"); let del_label = format!("-{total_del}"); Ok(view! {

(files_label.as_str()) " " (add_label.as_str()) " " (del_label.as_str())

if truncated_files {

(truncated_note.as_str())

} if no_files {

(g::t(lang, K::NoFileChanges))

} else {
    for item in nav.iter() {
  • (item.label.as_str()) " " (format!("+{}", item.additions)) " " (format!("-{}", item.deletions))
  • }
for file in files.into_iter() { diff_file(file: file, lang: lang) } }
}) } #[component] async fn diff_file(file: FileView, lang: Lang) -> Result { let add_label = format!("+{}", file.additions); let del_label = format!("-{}", file.deletions); let rows = file.rows; Ok(view! {
(file.label.as_str()) (file.status) (add_label.as_str()) " " (del_label.as_str())
if file.binary {

(g::t(lang, K::BinaryFileChanged))

} else { if file.truncated { if file.has_blob {

(g::t(lang, K::DiffTooLargePrefix)) (g::t(lang, K::ViewFile))

} else {

(g::t(lang, K::DiffTooLarge))

} } if !rows.is_empty() {
for row in rows.into_iter() { diff_line(row: row, lang: lang) }
} }
}) } /// One diff line as a ``: the line itself, then, when it has threads or an open form, a /// full-width row holding them. #[component] async fn diff_line(row: DiffRow, lang: Lang) -> Result { let DiffRow { class, old, new, code, anchored, anchor, plus_href, below, } = row; let (threads, form, ctx) = match below { Some(below) => (below.threads, below.form, Some(below.ctx)), None => (Vec::new(), None, None), }; let span = if anchored { "4" } else { "3" }; Ok(view! { if anchored { if let Some(href) = plus_href { "+" } } (old.as_str()) (new.as_str()) (code.as_str()) if let Some(ctx) = ctx {
for thread in threads.into_iter() { review_thread_in(lang: lang, thread: thread, ctx: ctx.clone(), show_hunk: false, return_to: "files") } if let Some(form) = form { inline_comment_form(form: form, lang: lang) }
} }) } /// The inline comment form under a line. "Add single comment" posts one comment; the review /// button starts a review, or adds to the pending one. #[component] async fn inline_comment_form(form: InlineForm, lang: Lang) -> Result { let InlineForm { action, cancel_href, csrf, file_index, side, line, head_oid, has_pending, can_review, } = form; let review_label = if has_pending { g::t(lang, K::AddReviewComment) } else { g::t(lang, K::StartReview) }; Ok(view! {
if can_review { } (g::t(lang, K::CancelLink))
}) }