// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Diff rendering: highlighted unified and split views. //! //! Each file's pre- and post-image are highlighted as **whole documents** (§8) — //! never per-hunk, which would feed a context-dependent grammar garbage — and the //! resulting lines are indexed by line number and zipped with the diff hunks. //! Added/removed backgrounds are applied to the row; the highlight spans live //! inside it. use highlight::Line; use maud::{Markup, html}; /// One file's diff plus the highlighted lines of each side, ready to zip. pub struct RenderedFile { /// The structural diff (paths, status, hunks). pub diff: git::FileDiff, /// Highlighted pre-image lines (old side), indexed from 1. pub old_lines: Vec, /// Highlighted post-image lines (new side), indexed from 1. pub new_lines: Vec, } impl RenderedFile { /// The file's display path (new path, or old path for a deletion). #[must_use] pub fn path(&self) -> &str { self.diff .new_path .as_deref() .or(self.diff.old_path.as_deref()) .unwrap_or("(unknown)") } /// Added / removed line counts, for the summary. #[must_use] pub fn stats(&self) -> (usize, usize) { let mut adds = 0; let mut dels = 0; for hunk in &self.diff.hunks { for line in &hunk.lines { match line.origin { git::LineOrigin::Addition => adds += 1, git::LineOrigin::Deletion => dels += 1, git::LineOrigin::Context => {} } } } (adds, dels) } } /// Render the highlighted spans of line `n` (1-based) from `lines`, or the raw /// `fallback` text if the index is out of range. fn line_markup(lines: &[Line], n: u32, fallback: &str) -> Markup { let idx = (n as usize).saturating_sub(1); if let Some(line) = lines.get(idx) { html! { @for span in &line.spans { @match span.class { Some(class) => { span class=(class) { (span.text) } } None => { (span.text) } } } } } else { html! { (fallback.trim_end_matches(['\n', '\r'])) } } } /// Render one file's diff in the unified view. /// /// `hx_base`, when given, is the context-endpoint URL prefix /// (`…/-/commit/{sha}/context?file={path}`); it enables the "expand hidden lines" /// rows between hunks. #[must_use] pub fn unified(rf: &RenderedFile, hx_base: Option<&str>) -> Markup { html! { table class="diff diff-unified" { tbody { @for (i, hunk) in rf.diff.hunks.iter().enumerate() { @if let Some(base) = hx_base { @if let Some((from, to)) = gap_before(rf, i) { (expander(base, from, to)) } } tr class="hunk-header" { td colspan="3" class="lineno" {} td class="code-line" { (hunk.header) } } @for line in &hunk.lines { @let (cls, marker) = row_kind(line.origin); tr class=(cls) { td class="lineno" { (opt_num(line.old_lineno)) } td class="lineno" { (opt_num(line.new_lineno)) } td class="diff-marker" { (marker) } td class="code-line" { (unified_line(rf, line)) } } } } } } } } /// The new-side line range hidden before hunk `i`, or `None` when there is no gap. fn gap_before(rf: &RenderedFile, i: usize) -> Option<(u32, u32)> { let first_new = rf .diff .hunks .get(i)? .lines .iter() .find_map(|l| l.new_lineno)?; let prev_last = if i == 0 { 0 } else { rf.diff.hunks[i - 1] .lines .iter() .filter_map(|l| l.new_lineno) .next_back() .unwrap_or(0) }; if first_new > prev_last + 1 { Some((prev_last + 1, first_new - 1)) } else { None } } /// An expander row that fetches and swaps in the hidden context lines. fn expander(hx_base: &str, from: u32, to: u32) -> Markup { let url = format!("{hx_base}&from={from}&to={to}"); html! { tr class="diff-expander" { td colspan="4" { button type="button" class="expand-btn" hx-get=(url) hx-target="closest tr" hx-swap="outerHTML" { "Expand " (to - from + 1) " hidden lines" } } } } } /// The highlighted content for a unified diff line, choosing the correct side. fn unified_line(rf: &RenderedFile, line: &git::DiffLine) -> Markup { match line.origin { git::LineOrigin::Deletion => { line_markup(&rf.old_lines, line.old_lineno.unwrap_or(0), &line.content) } // Context and additions both take the post-image line. _ => line_markup(&rf.new_lines, line.new_lineno.unwrap_or(0), &line.content), } } /// Render one file's diff in the split (side-by-side) view. #[must_use] pub fn split(rf: &RenderedFile) -> Markup { html! { table class="diff diff-split" { tbody { @for hunk in &rf.diff.hunks { tr class="hunk-header" { td colspan="4" class="code-line" { (hunk.header) } } @for row in pair_hunk(hunk) { tr { @match &row.left { Some(l) => { td class="lineno" { (l.0) } td class=(format!("code-line {}", side_class(l.1))) { (line_markup(&rf.old_lines, l.0, &l.2)) } } None => { td class="lineno" {} td class="code-line diff-empty" {} } } @match &row.right { Some(r) => { td class="lineno" { (r.0) } td class=(format!("code-line {}", side_class(r.1))) { (line_markup(&rf.new_lines, r.0, &r.2)) } } None => { td class="lineno" {} td class="code-line diff-empty" {} } } } } } } } } } /// A paired split row: an optional left (old) and right (new) cell, each carrying /// `(lineno, origin, content)`. struct SplitRow { left: Option<(u32, git::LineOrigin, String)>, right: Option<(u32, git::LineOrigin, String)>, } /// Pair a hunk's lines into side-by-side rows: context aligns on both sides, and a /// run of deletions is paired with the following run of additions. fn pair_hunk(hunk: &git::Hunk) -> Vec { let mut rows = Vec::new(); let mut dels: Vec<(u32, String)> = Vec::new(); let mut adds: Vec<(u32, String)> = Vec::new(); let flush = |rows: &mut Vec, dels: &mut Vec<(u32, String)>, adds: &mut Vec<(u32, String)>| { let n = dels.len().max(adds.len()); for i in 0..n { rows.push(SplitRow { left: dels .get(i) .map(|(ln, c)| (*ln, git::LineOrigin::Deletion, c.clone())), right: adds .get(i) .map(|(ln, c)| (*ln, git::LineOrigin::Addition, c.clone())), }); } dels.clear(); adds.clear(); }; for line in &hunk.lines { match line.origin { git::LineOrigin::Deletion => { dels.push((line.old_lineno.unwrap_or(0), line.content.clone())); } git::LineOrigin::Addition => { adds.push((line.new_lineno.unwrap_or(0), line.content.clone())); } git::LineOrigin::Context => { flush(&mut rows, &mut dels, &mut adds); rows.push(SplitRow { left: Some(( line.old_lineno.unwrap_or(0), git::LineOrigin::Context, line.content.clone(), )), right: Some(( line.new_lineno.unwrap_or(0), git::LineOrigin::Context, line.content.clone(), )), }); } } } flush(&mut rows, &mut dels, &mut adds); rows } /// The CSS class and marker glyph for a unified row. fn row_kind(origin: git::LineOrigin) -> (&'static str, &'static str) { match origin { git::LineOrigin::Addition => ("diff-add", "+"), git::LineOrigin::Deletion => ("diff-del", "-"), git::LineOrigin::Context => ("diff-ctx", " "), } } /// The per-cell class for a split row side. fn side_class(origin: git::LineOrigin) -> &'static str { match origin { git::LineOrigin::Addition => "diff-add", git::LineOrigin::Deletion => "diff-del", git::LineOrigin::Context => "diff-ctx", } } /// Render an optional line number as a string. fn opt_num(n: Option) -> String { n.map_or_else(String::new, |n| n.to_string()) } /// Render context lines (for the expand-context partial) as unified rows. #[must_use] pub fn context_rows(lines: &[git::DiffLine], highlighted: &[Line]) -> Markup { html! { @for line in lines { tr class="diff-ctx" { td class="lineno" { (opt_num(line.old_lineno)) } td class="lineno" { (opt_num(line.new_lineno)) } td class="diff-marker" {} td class="code-line" { (line_markup(highlighted, line.new_lineno.unwrap_or(0), &line.content)) } } } } }