fabrica

hanna/fabrica

10609 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Diff rendering: highlighted unified and split views.
6//!
7//! Each file's pre- and post-image are highlighted as **whole documents** (§8) —
8//! never per-hunk, which would feed a context-dependent grammar garbage — and the
9//! resulting lines are indexed by line number and zipped with the diff hunks.
10//! Added/removed backgrounds are applied to the row; the highlight spans live
11//! inside it.
12
13use highlight::Line;
14use maud::{Markup, html};
15
16/// One file's diff plus the highlighted lines of each side, ready to zip.
17pub struct RenderedFile {
18 /// The structural diff (paths, status, hunks).
19 pub diff: git::FileDiff,
20 /// Highlighted pre-image lines (old side), indexed from 1.
21 pub old_lines: Vec<Line>,
22 /// Highlighted post-image lines (new side), indexed from 1.
23 pub new_lines: Vec<Line>,
24}
25
26impl RenderedFile {
27 /// The file's display path (new path, or old path for a deletion).
28 #[must_use]
29 pub fn path(&self) -> &str {
30 self.diff
31 .new_path
32 .as_deref()
33 .or(self.diff.old_path.as_deref())
34 .unwrap_or("(unknown)")
35 }
36
37 /// Added / removed line counts, for the summary.
38 #[must_use]
39 pub fn stats(&self) -> (usize, usize) {
40 let mut adds = 0;
41 let mut dels = 0;
42 for hunk in &self.diff.hunks {
43 for line in &hunk.lines {
44 match line.origin {
45 git::LineOrigin::Addition => adds += 1,
46 git::LineOrigin::Deletion => dels += 1,
47 git::LineOrigin::Context => {}
48 }
49 }
50 }
51 (adds, dels)
52 }
53}
54
55/// Render the highlighted spans of line `n` (1-based) from `lines`, or the raw
56/// `fallback` text if the index is out of range.
57fn line_markup(lines: &[Line], n: u32, fallback: &str) -> Markup {
58 let idx = (n as usize).saturating_sub(1);
59 if let Some(line) = lines.get(idx) {
60 html! {
61 @for span in &line.spans {
62 @match span.class {
63 Some(class) => { span class=(class) { (span.text) } }
64 None => { (span.text) }
65 }
66 }
67 }
68 } else {
69 html! { (fallback.trim_end_matches(['\n', '\r'])) }
70 }
71}
72
73/// Render one file's diff in the unified view.
74///
75/// `hx_base`, when given, is the context-endpoint URL prefix
76/// (`…/-/commit/{sha}/context?file={path}`); it enables the "expand hidden lines"
77/// rows between hunks.
78#[must_use]
79pub fn unified(rf: &RenderedFile, hx_base: Option<&str>) -> Markup {
80 html! {
81 table class="diff diff-unified" {
82 tbody {
83 @for (i, hunk) in rf.diff.hunks.iter().enumerate() {
84 @if let Some(base) = hx_base {
85 @if let Some((from, to)) = gap_before(rf, i) {
86 (expander(base, from, to))
87 }
88 }
89 tr class="hunk-header" {
90 td colspan="3" class="lineno" {}
91 td class="code-line" { (hunk.header) }
92 }
93 @for line in &hunk.lines {
94 @let (cls, marker) = row_kind(line.origin);
95 tr class=(cls) {
96 td class="lineno" { (opt_num(line.old_lineno)) }
97 td class="lineno" { (opt_num(line.new_lineno)) }
98 td class="diff-marker" { (marker) }
99 td class="code-line" { (unified_line(rf, line)) }
100 }
101 }
102 }
103 }
104 }
105 }
106}
107
108/// The new-side line range hidden before hunk `i`, or `None` when there is no gap.
109fn gap_before(rf: &RenderedFile, i: usize) -> Option<(u32, u32)> {
110 let first_new = rf
111 .diff
112 .hunks
113 .get(i)?
114 .lines
115 .iter()
116 .find_map(|l| l.new_lineno)?;
117 let prev_last = if i == 0 {
118 0
119 } else {
120 rf.diff.hunks[i - 1]
121 .lines
122 .iter()
123 .filter_map(|l| l.new_lineno)
124 .next_back()
125 .unwrap_or(0)
126 };
127 if first_new > prev_last + 1 {
128 Some((prev_last + 1, first_new - 1))
129 } else {
130 None
131 }
132}
133
134/// An expander row that fetches and swaps in the hidden context lines.
135fn expander(hx_base: &str, from: u32, to: u32) -> Markup {
136 let url = format!("{hx_base}&from={from}&to={to}");
137 html! {
138 tr class="diff-expander" {
139 td colspan="4" {
140 button type="button" class="expand-btn"
141 hx-get=(url) hx-target="closest tr" hx-swap="outerHTML" {
142 "Expand " (to - from + 1) " hidden lines"
143 }
144 }
145 }
146 }
147}
148
149/// The highlighted content for a unified diff line, choosing the correct side.
150fn unified_line(rf: &RenderedFile, line: &git::DiffLine) -> Markup {
151 match line.origin {
152 git::LineOrigin::Deletion => {
153 line_markup(&rf.old_lines, line.old_lineno.unwrap_or(0), &line.content)
154 }
155 // Context and additions both take the post-image line.
156 _ => line_markup(&rf.new_lines, line.new_lineno.unwrap_or(0), &line.content),
157 }
158}
159
160/// Render one file's diff in the split (side-by-side) view.
161#[must_use]
162pub fn split(rf: &RenderedFile) -> Markup {
163 html! {
164 table class="diff diff-split" {
165 tbody {
166 @for hunk in &rf.diff.hunks {
167 tr class="hunk-header" {
168 td colspan="4" class="code-line" { (hunk.header) }
169 }
170 @for row in pair_hunk(hunk) {
171 tr {
172 @match &row.left {
173 Some(l) => {
174 td class="lineno" { (l.0) }
175 td class=(format!("code-line {}", side_class(l.1))) {
176 (line_markup(&rf.old_lines, l.0, &l.2))
177 }
178 }
179 None => { td class="lineno" {} td class="code-line diff-empty" {} }
180 }
181 @match &row.right {
182 Some(r) => {
183 td class="lineno" { (r.0) }
184 td class=(format!("code-line {}", side_class(r.1))) {
185 (line_markup(&rf.new_lines, r.0, &r.2))
186 }
187 }
188 None => { td class="lineno" {} td class="code-line diff-empty" {} }
189 }
190 }
191 }
192 }
193 }
194 }
195 }
196}
197
198/// A paired split row: an optional left (old) and right (new) cell, each carrying
199/// `(lineno, origin, content)`.
200struct SplitRow {
201 left: Option<(u32, git::LineOrigin, String)>,
202 right: Option<(u32, git::LineOrigin, String)>,
203}
204
205/// Pair a hunk's lines into side-by-side rows: context aligns on both sides, and a
206/// run of deletions is paired with the following run of additions.
207fn pair_hunk(hunk: &git::Hunk) -> Vec<SplitRow> {
208 let mut rows = Vec::new();
209 let mut dels: Vec<(u32, String)> = Vec::new();
210 let mut adds: Vec<(u32, String)> = Vec::new();
211
212 let flush =
213 |rows: &mut Vec<SplitRow>, dels: &mut Vec<(u32, String)>, adds: &mut Vec<(u32, String)>| {
214 let n = dels.len().max(adds.len());
215 for i in 0..n {
216 rows.push(SplitRow {
217 left: dels
218 .get(i)
219 .map(|(ln, c)| (*ln, git::LineOrigin::Deletion, c.clone())),
220 right: adds
221 .get(i)
222 .map(|(ln, c)| (*ln, git::LineOrigin::Addition, c.clone())),
223 });
224 }
225 dels.clear();
226 adds.clear();
227 };
228
229 for line in &hunk.lines {
230 match line.origin {
231 git::LineOrigin::Deletion => {
232 dels.push((line.old_lineno.unwrap_or(0), line.content.clone()));
233 }
234 git::LineOrigin::Addition => {
235 adds.push((line.new_lineno.unwrap_or(0), line.content.clone()));
236 }
237 git::LineOrigin::Context => {
238 flush(&mut rows, &mut dels, &mut adds);
239 rows.push(SplitRow {
240 left: Some((
241 line.old_lineno.unwrap_or(0),
242 git::LineOrigin::Context,
243 line.content.clone(),
244 )),
245 right: Some((
246 line.new_lineno.unwrap_or(0),
247 git::LineOrigin::Context,
248 line.content.clone(),
249 )),
250 });
251 }
252 }
253 }
254 flush(&mut rows, &mut dels, &mut adds);
255 rows
256}
257
258/// The CSS class and marker glyph for a unified row.
259fn row_kind(origin: git::LineOrigin) -> (&'static str, &'static str) {
260 match origin {
261 git::LineOrigin::Addition => ("diff-add", "+"),
262 git::LineOrigin::Deletion => ("diff-del", "-"),
263 git::LineOrigin::Context => ("diff-ctx", " "),
264 }
265}
266
267/// The per-cell class for a split row side.
268fn side_class(origin: git::LineOrigin) -> &'static str {
269 match origin {
270 git::LineOrigin::Addition => "diff-add",
271 git::LineOrigin::Deletion => "diff-del",
272 git::LineOrigin::Context => "diff-ctx",
273 }
274}
275
276/// Render an optional line number as a string.
277fn opt_num(n: Option<u32>) -> String {
278 n.map_or_else(String::new, |n| n.to_string())
279}
280
281/// Render context lines (for the expand-context partial) as unified rows.
282#[must_use]
283pub fn context_rows(lines: &[git::DiffLine], highlighted: &[Line]) -> Markup {
284 html! {
285 @for line in lines {
286 tr class="diff-ctx" {
287 td class="lineno" { (opt_num(line.old_lineno)) }
288 td class="lineno" { (opt_num(line.new_lineno)) }
289 td class="diff-marker" {}
290 td class="code-line" {
291 (line_markup(highlighted, line.new_lineno.unwrap_or(0), &line.content))
292 }
293 }
294 }
295 }
296}