//! 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! {
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! {
})
}