forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
17.3 KiB
1//! Commit diff rendering, shared by the commit page, the comparison page and the pull-request
2//! Files tab: the changed-file summary, the per-file index and each file's unified diff in a
3//! scroll wrapper. Uses the `diff-*` classes from the stylesheet. `view!` escapes text and
4//! attribute values.
5//!
6//! The Files tab turns on `LineComments`: each numbered line gets an id and a "+" link, the
7//! review threads of a line render in a full-width row under it, and the line whose inline
8//! comment form is open gets that form. Every line is its own `<tbody>`, so those rows can span
9//! the table. Commit and comparison pages pass the default, which leaves all of that out.
10
11use std::collections::HashMap;
12
13use forge_core::{Side, Thread};
14use forge_git::history::{CommitDiff, DiffLimits, DiffStatus, FileDiff, LineKind};
15use topcoat::Result;
16use topcoat::view::{View, component, view};
17
18use crate::i18n::Lang;
19use crate::i18n::code::{self as g, K};
20use crate::pages::commits::url_path;
21use crate::views::review_thread::{ThreadCtx, review_thread_in};
22
23/// Per-diff limits used by the commit and pull-request pages.
24pub const LIMITS: DiffLimits = DiffLimits {
25 max_files: MAX_FILES,
26 max_lines_per_file: 2000,
27 max_total_lines: 20_000,
28};
29
30/// Files shown before the diff reports truncation. Matches `LIMITS.max_files`.
31pub const MAX_FILES: usize = 300;
32
33/// The language of a diff section. It defaults to English, so a page that does not pass one (the
34/// comparison and Files tab pages, for now) keeps its English text.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub struct DiffLang(pub Lang);
37
38impl Default for DiffLang {
39 fn default() -> Self {
40 DiffLang(Lang::En)
41 }
42}
43
44/// Line comments on a diff. The default leaves the diff as commit and comparison pages show it.
45#[derive(Default)]
46pub struct LineComments {
47 /// Line ids, the "+" link on each numbered line and the inline form. Set only for a viewer
48 /// who may comment, so anonymous viewers see no comment controls.
49 pub anchors: bool,
50 /// Threads by (file index, side, line). Each renders in the row under its line.
51 pub threads: HashMap<(usize, Side, u32), Vec<Thread>>,
52 /// The line whose inline comment form is open.
53 pub open_form: Option<(usize, Side, u32)>,
54 /// URL of the Files tab, such as `/o/r/pulls/3/files`. The "+" links and the form's cancel
55 /// link point here.
56 pub files_href: String,
57 /// The viewer has a pending review, so the form offers "Add review comment".
58 pub has_pending: bool,
59 /// What the threads and the form need: the review POST base, CSRF, viewer and permissions.
60 pub ctx: ThreadCtx,
61}
62
63/// The inline comment form under an open line.
64struct InlineForm {
65 action: String,
66 cancel_href: String,
67 csrf: String,
68 file_index: String,
69 side: &'static str,
70 line: String,
71 head_oid: String,
72 has_pending: bool,
73 can_review: bool,
74}
75
76/// What renders under a line: its threads and, if open, the form.
77struct RowBelow {
78 threads: Vec<Thread>,
79 form: Option<InlineForm>,
80 ctx: ThreadCtx,
81}
82
83/// One rendered row of a file's diff table.
84struct DiffRow {
85 class: &'static str,
86 old: String,
87 new: String,
88 code: String,
89 anchored: bool,
90 /// The `L-<file>-<side>-<line>` id, when anchors are on and the line has a number.
91 anchor: Option<String>,
92 /// Target of the "+" link on this line.
93 plus_href: Option<String>,
94 below: Option<RowBelow>,
95}
96
97/// One file of the diff, flattened for the view.
98struct FileView {
99 anchor: String,
100 label: String,
101 status: &'static str,
102 additions: u32,
103 deletions: u32,
104 binary: bool,
105 truncated: bool,
106 has_blob: bool,
107 blob_href: String,
108 rows: Vec<DiffRow>,
109}
110
111impl FileView {
112 /// `oid` is the commit being shown; `parent` is its first parent, used for the blob link of
113 /// deleted files (they are absent at `oid`). `comments` supplies the review data and is
114 /// drained of the threads it renders.
115 #[allow(clippy::too_many_arguments)]
116 fn new(
117 index: usize,
118 file: &FileDiff,
119 oid: &str,
120 parent: Option<&str>,
121 owner: &str,
122 repo: &str,
123 lang: Lang,
124 comments: &mut LineComments,
125 ) -> Self {
126 let label = match (&file.old_path, &file.new_path) {
127 (Some(old), Some(new)) if old != new => format!("{old} → {new}"),
128 (_, Some(new)) => new.clone(),
129 (Some(old), None) => old.clone(),
130 (None, None) => String::new(),
131 };
132 let status = match file.status {
133 DiffStatus::Added => g::t(lang, K::StatusAdded),
134 DiffStatus::Deleted => g::t(lang, K::StatusDeleted),
135 DiffStatus::Modified => g::t(lang, K::StatusModified),
136 DiffStatus::Renamed => g::t(lang, K::StatusRenamed),
137 DiffStatus::Copied => g::t(lang, K::StatusCopied),
138 DiffStatus::TypeChanged => g::t(lang, K::StatusTypeChanged),
139 };
140 // A deleted file is absent at this commit, so its blob is read from the parent.
141 let blob_commit = match file.status {
142 DiffStatus::Deleted => parent,
143 _ => Some(oid),
144 };
145 let blob_path = file.new_path.as_ref().or(file.old_path.as_ref());
146 let blob_href = match (blob_commit, blob_path) {
147 (Some(commit), Some(path)) => {
148 format!("/{owner}/{repo}/blob/{commit}/{}", url_path(path))
149 }
150 _ => String::new(),
151 };
152
153 let mut rows = Vec::new();
154 for hunk in &file.hunks {
155 rows.push(DiffRow {
156 class: "diff-line hunk",
157 old: String::new(),
158 new: String::new(),
159 code: hunk.header.clone(),
160 anchored: comments.anchors,
161 anchor: None,
162 plus_href: None,
163 below: None,
164 });
165 for line in &hunk.lines {
166 let (class, code) = match line.kind {
167 LineKind::Add => ("diff-line add", format!("+{}", line.text)),
168 LineKind::Del => ("diff-line del", format!("-{}", line.text)),
169 LineKind::Context => ("diff-line ctx", format!(" {}", line.text)),
170 LineKind::NoNewline => ("diff-line ctx", line.text.clone()),
171 };
172 // Deleted lines sit on the old side; added and context lines on the new side.
173 let key = match line.kind {
174 LineKind::Add | LineKind::Context => line.new_no.map(|n| (Side::New, n)),
175 LineKind::Del => line.old_no.map(|n| (Side::Old, n)),
176 LineKind::NoNewline => None,
177 };
178 let (anchor, plus_href, below) =
179 match key {
180 Some((side, number)) => {
181 let id = format!("L-{index}-{}-{number}", side.as_str());
182 let anchor = comments.anchors.then(|| id.clone());
183 let plus_href = comments.anchors.then(|| {
184 format!(
185 "{}?comment={index}:{}:{number}#{id}",
186 comments.files_href,
187 side.as_str()
188 )
189 });
190 let target = (index, side, number);
191 let threads = comments.threads.remove(&target).unwrap_or_default();
192 let form = (comments.anchors && comments.open_form == Some(target))
193 .then(|| InlineForm {
194 action: format!("{}/review_comments", comments.ctx.pr_href),
195 cancel_href: comments.files_href.clone(),
196 csrf: comments.ctx.csrf.clone(),
197 file_index: index.to_string(),
198 side: side.as_str(),
199 line: number.to_string(),
200 head_oid: oid.to_owned(),
201 has_pending: comments.has_pending,
202 can_review: comments.ctx.can_review,
203 });
204 let below = (!threads.is_empty() || form.is_some()).then(|| RowBelow {
205 threads,
206 form,
207 ctx: comments.ctx.clone(),
208 });
209 (anchor, plus_href, below)
210 }
211 None => (None, None, None),
212 };
213 rows.push(DiffRow {
214 class,
215 old: line.old_no.map(|n| n.to_string()).unwrap_or_default(),
216 new: line.new_no.map(|n| n.to_string()).unwrap_or_default(),
217 code,
218 anchored: comments.anchors,
219 anchor,
220 plus_href,
221 below,
222 });
223 }
224 }
225
226 Self {
227 anchor: format!("file-{}", index + 1),
228 label,
229 status,
230 additions: file.additions,
231 deletions: file.deletions,
232 binary: file.is_binary,
233 truncated: file.truncated,
234 has_blob: !blob_href.is_empty(),
235 blob_href,
236 rows,
237 }
238 }
239}
240
241/// Entry in the file index at the top of the diff.
242struct NavItem {
243 anchor_href: String,
244 label: String,
245 additions: u32,
246 deletions: u32,
247}
248
249/// The whole diff section: a summary heading with totals, the file index and every file's
250/// diff. `owner` and `repo` name the repository for "view file" links, `oid` is the commit the
251/// diff belongs to, and `parent` is its first parent (`None` for a root commit). `subject` names
252/// what the diff shows in the truncation note, such as "this commit" or "this comparison".
253/// `comments` turns on the line comments of the Files tab; the default renders a plain diff.
254#[allow(clippy::too_many_arguments)]
255#[component]
256pub async fn diff_section(
257 diff: &CommitDiff,
258 owner: &str,
259 repo: &str,
260 oid: &str,
261 parent: Option<&str>,
262 subject: &str,
263 #[default] lang: DiffLang,
264 #[default] comments: LineComments,
265) -> Result<impl View> {
266 let lang = lang.0;
267 let mut comments = comments;
268 let files: Vec<FileView> = diff
269 .files
270 .iter()
271 .enumerate()
272 .map(|(i, file)| FileView::new(i, file, oid, parent, owner, repo, lang, &mut comments))
273 .collect();
274 let nav: Vec<NavItem> = files
275 .iter()
276 .map(|f| NavItem {
277 anchor_href: format!("#{}", f.anchor),
278 label: f.label.clone(),
279 additions: f.additions,
280 deletions: f.deletions,
281 })
282 .collect();
283 let total_add: u32 = files.iter().map(|f| f.additions).sum();
284 let total_del: u32 = files.iter().map(|f| f.deletions).sum();
285 let file_count = files.len();
286 let no_files = files.is_empty();
287 let files_label = g::changed_files(lang, file_count);
288 let truncated_note = g::diff_truncated(lang, MAX_FILES, subject);
289 let truncated_files = diff.truncated_files;
290 let add_label = format!("+{total_add}");
291 let del_label = format!("-{total_del}");
292
293 Ok(view! {
294 <section class="diff-section">
295 <h2>(files_label.as_str()) " " <span class="diff-stats"><span class="add">(add_label.as_str())</span> " " <span class="del">(del_label.as_str())</span></span></h2>
296 if truncated_files {
297 <p class="muted">(truncated_note.as_str())</p>
298 }
299 if no_files {
300 <p class="muted">(g::t(lang, K::NoFileChanges))</p>
301 } else {
302 <ul class="diff-index">
303 for item in nav.iter() {
304 <li>
305 <a href=(item.anchor_href.as_str())>(item.label.as_str())</a>
306 " "
307 <span class="diff-stats"><span class="add">(format!("+{}", item.additions))</span> " " <span class="del">(format!("-{}", item.deletions))</span></span>
308 </li>
309 }
310 </ul>
311 for file in files.into_iter() {
312 diff_file(file: file, lang: lang)
313 }
314 }
315 </section>
316 })
317}
318
319#[component]
320async fn diff_file(file: FileView, lang: Lang) -> Result<impl View> {
321 let add_label = format!("+{}", file.additions);
322 let del_label = format!("-{}", file.deletions);
323 let rows = file.rows;
324 Ok(view! {
325 <section class="diff-file" id=(file.anchor.as_str())>
326 <header class="diff-file-header">
327 <code class="diff-path">(file.label.as_str())</code>
328 <span class="diff-file-meta">
329 <span class="muted small">(file.status)</span>
330 <span class="diff-stats"><span class="add">(add_label.as_str())</span> " " <span class="del">(del_label.as_str())</span></span>
331 </span>
332 </header>
333 if file.binary {
334 <p class="muted diff-collapsed">(g::t(lang, K::BinaryFileChanged))</p>
335 } else {
336 if file.truncated {
337 if file.has_blob {
338 <p class="muted diff-collapsed">(g::t(lang, K::DiffTooLargePrefix)) <a href=(file.blob_href.as_str())>(g::t(lang, K::ViewFile))</a></p>
339 } else {
340 <p class="muted diff-collapsed">(g::t(lang, K::DiffTooLarge))</p>
341 }
342 }
343 if !rows.is_empty() {
344 <div class="diff-file-body">
345 <table class="diff-table">
346 for row in rows.into_iter() {
347 diff_line(row: row, lang: lang)
348 }
349 </table>
350 </div>
351 }
352 }
353 </section>
354 })
355}
356
357/// One diff line as a `<tbody>`: the line itself, then, when it has threads or an open form, a
358/// full-width row holding them.
359#[component]
360async fn diff_line(row: DiffRow, lang: Lang) -> Result<impl View> {
361 let DiffRow {
362 class,
363 old,
364 new,
365 code,
366 anchored,
367 anchor,
368 plus_href,
369 below,
370 } = row;
371 let (threads, form, ctx) = match below {
372 Some(below) => (below.threads, below.form, Some(below.ctx)),
373 None => (Vec::new(), None, None),
374 };
375 let span = if anchored { "4" } else { "3" };
376 Ok(view! {
377 <tbody>
378 <tr class=(class) id=(anchor.as_deref())>
379 if anchored {
380 <td class="diff-plus">
381 if let Some(href) = plus_href {
382 <a href=(href.as_str()) title=(g::t(lang, K::CommentOnLine)) aria-label=(g::t(lang, K::CommentOnLine))>"+"</a>
383 }
384 </td>
385 }
386 <td class="diff-ln">(old.as_str())</td>
387 <td class="diff-ln">(new.as_str())</td>
388 <td class="diff-code">(code.as_str())</td>
389 </tr>
390 if let Some(ctx) = ctx {
391 <tr class="review-row">
392 <td colspan=(span)>
393 <div class="review-threads">
394 for thread in threads.into_iter() {
395 review_thread_in(lang: lang, thread: thread, ctx: ctx.clone(), show_hunk: false, return_to: "files")
396 }
397 if let Some(form) = form {
398 inline_comment_form(form: form, lang: lang)
399 }
400 </div>
401 </td>
402 </tr>
403 }
404 </tbody>
405 })
406}
407
408/// The inline comment form under a line. "Add single comment" posts one comment; the review
409/// button starts a review, or adds to the pending one.
410#[component]
411async fn inline_comment_form(form: InlineForm, lang: Lang) -> Result<impl View> {
412 let InlineForm {
413 action,
414 cancel_href,
415 csrf,
416 file_index,
417 side,
418 line,
419 head_oid,
420 has_pending,
421 can_review,
422 } = form;
423 let review_label = if has_pending {
424 g::t(lang, K::AddReviewComment)
425 } else {
426 g::t(lang, K::StartReview)
427 };
428 Ok(view! {
429 <form method="post" action=(action.as_str()) class="review-inline-form stack">
430 <input type="hidden" name="csrf" value=(csrf.as_str())>
431 <input type="hidden" name="file_index" value=(file_index.as_str())>
432 <input type="hidden" name="side" value=(side)>
433 <input type="hidden" name="line" value=(line.as_str())>
434 <input type="hidden" name="head_oid" value=(head_oid.as_str())>
435 <label>
436 <span>(g::t(lang, K::CommentLabel))</span>
437 <textarea name="body" rows="4"></textarea>
438 </label>
439 <div class="issue-actions">
440 <button type="submit" name="action" value="single">(g::t(lang, K::AddSingleComment))</button>
441 if can_review {
442 <button type="submit" name="action" value="review">(review_label)</button>
443 }
444 <a href=(cancel_href.as_str())>(g::t(lang, K::CancelLink))</a>
445 </div>
446 </form>
447 })
448}