| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | use std::collections::HashMap; |
| 12 | |
| 13 | use forge_core::{Side, Thread}; |
| 14 | use forge_git::history::{CommitDiff, DiffLimits, DiffStatus, FileDiff, LineKind}; |
| 15 | use topcoat::Result; |
| 16 | use topcoat::view::{View, component, view}; |
| 17 | |
| 18 | use crate::i18n::Lang; |
| 19 | use crate::i18n::code::{self as g, K}; |
| 20 | use crate::pages::commits::url_path; |
| 21 | use crate::views::review_thread::{ThreadCtx, review_thread_in}; |
| 22 | |
| 23 | |
| 24 | pub const LIMITS: DiffLimits = DiffLimits { |
| 25 | max_files: MAX_FILES, |
| 26 | max_lines_per_file: 2000, |
| 27 | max_total_lines: 20_000, |
| 28 | }; |
| 29 | |
| 30 | |
| 31 | pub const MAX_FILES: usize = 300; |
| 32 | |
| 33 | |
| 34 | |
| 35 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 36 | pub struct DiffLang(pub Lang); |
| 37 | |
| 38 | impl Default for DiffLang { |
| 39 | fn default() -> Self { |
| 40 | DiffLang(Lang::En) |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | |
| 45 | #[derive(Default)] |
| 46 | pub struct LineComments { |
| 47 | |
| 48 | |
| 49 | pub anchors: bool, |
| 50 | |
| 51 | pub threads: HashMap<(usize, Side, u32), Vec<Thread>>, |
| 52 | |
| 53 | pub open_form: Option<(usize, Side, u32)>, |
| 54 | |
| 55 | |
| 56 | pub files_href: String, |
| 57 | |
| 58 | pub has_pending: bool, |
| 59 | |
| 60 | pub ctx: ThreadCtx, |
| 61 | } |
| 62 | |
| 63 | |
| 64 | struct 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 | |
| 77 | struct RowBelow { |
| 78 | threads: Vec<Thread>, |
| 79 | form: Option<InlineForm>, |
| 80 | ctx: ThreadCtx, |
| 81 | } |
| 82 | |
| 83 | |
| 84 | struct DiffRow { |
| 85 | class: &'static str, |
| 86 | old: String, |
| 87 | new: String, |
| 88 | code: String, |
| 89 | anchored: bool, |
| 90 | |
| 91 | anchor: Option<String>, |
| 92 | |
| 93 | plus_href: Option<String>, |
| 94 | below: Option<RowBelow>, |
| 95 | } |
| 96 | |
| 97 | |
| 98 | struct 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 | |
| 111 | impl FileView { |
| 112 | |
| 113 | |
| 114 | |
| 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 | |
| 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 | |
| 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 | |
| 242 | struct NavItem { |
| 243 | anchor_href: String, |
| 244 | label: String, |
| 245 | additions: u32, |
| 246 | deletions: u32, |
| 247 | } |
| 248 | |
| 249 | |
| 250 | |
| 251 | |
| 252 | |
| 253 | |
| 254 | #[allow(clippy::too_many_arguments)] |
| 255 | #[component] |
| 256 | pub 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] |
| 320 | async 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 | |
| 358 | |
| 359 | #[component] |
| 360 | async 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 | |
| 409 | |
| 410 | #[component] |
| 411 | async 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 | } |