Skip to main content

max / makenotwork

12.1 KB · 361 lines History Blame Raw
1 //! The blame view at `/git/{owner}/{repo}/blame/{ref}/{*path}`, described.
2 //!
3 //! Replaces `templates/pages/git/blame.html` and `GitBlameTemplate`.
4 //!
5 //! # Why this is not a quasi route
6 //!
7 //! The address ends in a file path, which carries slashes.
8 //! `quasi_router`'s matcher takes `{name}` for one segment and has no wildcard,
9 //! deliberately, so the address cannot be registered there. The handler stays
10 //! `routes::git::browsing::blame_view` and renders this screen itself, which is
11 //! `crate::quasi::custom_page`'s arrangement: a described screen does not need
12 //! a described route, it needs a renderer.
13 //!
14 //! [`super::document_shell`] is the join. Everything a mounted document gets --
15 //! the site's head, the shortcuts chrome, the CSRF meta -- comes off the same
16 //! builder here.
17 //!
18 //! # The lines stop being markup
19 //!
20 //! `git::blame_file` escaped each line into HTML on the way out, which was a
21 //! second escaping story beside the renderer's. A blame line is an inline
22 //! [`Node::Code`] now and arrives as the line, so the escaping is the
23 //! renderer's and there is one of it.
24 //!
25 //! Blame is not highlighted, here or before. A blame table asks who last
26 //! touched a line, and the answer is in the first three columns; classifying the
27 //! fourth would mean re-reading the blob to get a whole file for the lexer,
28 //! which is a read this page does not otherwise make.
29
30 use std::collections::HashMap;
31
32 use makeover_layout as layout;
33 use quasi_declare::declare;
34 use quasi_router::Document;
35 use quasi_webview::Webview;
36
37 use crate::git::{BlameLine, Breadcrumb, RefInfo};
38
39 /// The page's own region, and what the skip link points at.
40 pub const PAGE_REGION: &str = "git-blame";
41
42 const MEASURE: layout::Measure = layout::Measure::Wide;
43
44 /// Everything the screen draws, resolved before it is drawn.
45 pub struct View<'a> {
46 pub owner: &'a str,
47 pub repo: &'a str,
48 pub current_ref: &'a str,
49 pub file_path: &'a str,
50 pub filename: &'a str,
51 pub breadcrumbs: &'a [Breadcrumb],
52 pub refs: &'a [RefInfo],
53 pub lines: &'a [BlameLine],
54 /// How many notes each commit on this page carries, by full oid.
55 pub annotated: &'a HashMap<String, usize>,
56 pub open_issue_count: i64,
57 pub is_owner: bool,
58 }
59
60 impl View<'_> {
61 fn nav(&self) -> super::widgets::git_nav::Nav<'_> {
62 super::widgets::git_nav::Nav {
63 owner: self.owner,
64 repo: self.repo,
65 current_ref: self.current_ref,
66 active_tab: "files",
67 open_issue_count: self.open_issue_count,
68 is_owner: self.is_owner,
69 refs: self.refs,
70 }
71 }
72 }
73
74 declare! {
75 /// The whole document: the title, the measure, the body.
76 #[must_use]
77 pub shape screen(view: &View<'_>) -> Screen;
78
79 screen single "Blame {view.filename} - {view.repo} - Git - Makenotwork" {
80 measured MEASURE;
81 documented Document::default().classed(crate::shell::body_class(MEASURE, &[]));
82
83 region PAGE_REGION as Pane {
84 include super::widgets::git_nav::heading(view.owner, view.repo);
85 include super::widgets::git_nav::region(&view.nav());
86 include super::widgets::git_nav::breadcrumb(
87 view.owner,
88 view.repo,
89 view.current_ref,
90 view.breadcrumbs
91 );
92 include header(view);
93 include table(view);
94 }
95 }
96 }
97
98 declare! {
99 /// The strip over the table: how long the file is, and the other two ways to
100 /// read it.
101 shape header(view: &View<'_>) -> Node;
102
103 let base = "/git/{view.owner}/{view.repo}";
104 let count = view.lines.len();
105 let lines = given count {
106 1 -> "1 line",
107 otherwise -> "{count} lines",
108 };
109
110 region "git-file-header" as Group {
111 across Wrap {
112 beside Secondary text lines;
113 beside Essential link "Source"
114 to get "{base}/tree/{view.current_ref}/{view.file_path}" navigating;
115 beside Essential link "Raw"
116 to get "{base}/raw/{view.current_ref}/{view.file_path}" navigating;
117 }
118 }
119 }
120
121 declare! {
122 /// One row per line: who last touched it, when, and what it says.
123 shape table(view: &View<'_>) -> Node;
124
125 let base = "/git/{view.owner}/{view.repo}";
126
127 table {
128 // The commit is the identity of the row: everything else is a fact
129 // about it, and a narrow viewport that dropped it would leave a blame
130 // table that blames nobody.
131 column "Commit" {
132 width Content;
133 priority Essential;
134 }
135 column "Author" {
136 width Content;
137 priority Secondary;
138 }
139 column "Date" {
140 width Content;
141 priority Optional;
142 }
143 column "Line" {
144 width Content;
145 priority Secondary;
146 }
147 column "Code" {
148 width Fill;
149 priority Essential;
150 }
151
152 for line in view.lines.iter() {
153 include row(view, &base, line);
154 }
155 }
156 }
157
158 /// What the notes link on a blamed line reads.
159 ///
160 /// Empty when the commit carries no annotations, which is R9: the value is
161 /// built whether or not the guard places it. A supplier because the count
162 /// decides between a word and a number, inside an `Option` the form has no
163 /// binding pattern to reach.
164 fn notes_label(view: &View<'_>, line: &BlameLine) -> String {
165 match view.annotated.get(&line.commit_oid) {
166 Some(1) => "note".to_owned(),
167 Some(notes) => format!("{notes} notes"),
168 None => String::new(),
169 }
170 }
171
172 declare! {
173 /// One blamed line.
174 ///
175 /// The cells name their columns because the column list is a shape away: a
176 /// row written by position here would be lined up against headings nobody
177 /// reading this declaration can see.
178 ///
179 /// The short oid opens the commit; the link beside it opens the same commit
180 /// at its notes. Two addresses on one cell, which is what a cell holding a
181 /// run of leaves is for.
182 shape row(view: &View<'_>, base: &str, line: &BlameLine) -> Row;
183
184 let commit = "{base}/commit/{line.commit_oid}";
185
186 cells {
187 cell at "Commit" "" {
188 link line.commit_short_oid.clone() to get commit.clone() navigating;
189 link notes_label(view, line) to get "{commit}#notes" navigating
190 when view.annotated.contains_key(&line.commit_oid);
191 }
192 cell at "Author" line.author_name.clone();
193 cell at "Date" line.time_formatted.clone();
194 cell at "Line" line.lineno.to_string();
195 cell at "Code" "" {
196 literal &line.content;
197 }
198 }
199 }
200
201 /// The document this screen is drawn in.
202 #[must_use]
203 pub fn document(viewer: Option<&crate::auth::SessionUser>, csrf: &str, view: &View<'_>) -> String {
204 use quasi_axum::Serves as _;
205
206 Webview::new()
207 .with_shell(super::document_shell(csrf).with_body_first(format!(
208 "{}{}",
209 crate::shell::skip_link(PAGE_REGION),
210 crate::shell::site_header(viewer),
211 )))
212 .screen(&screen(view))
213 }
214
215 #[cfg(test)]
216 mod tests {
217 use super::*;
218
219 fn line(lineno: usize, oid: &str, content: &str) -> BlameLine {
220 BlameLine {
221 lineno,
222 commit_oid: format!("{oid}00000000000000000000000000000000"),
223 commit_short_oid: oid.to_owned(),
224 author_name: "ada".into(),
225 time_formatted: "2026-08-01".into(),
226 content: content.to_owned(),
227 is_boundary: false,
228 }
229 }
230
231 fn view<'a>(lines: &'a [BlameLine], annotated: &'a HashMap<String, usize>) -> View<'a> {
232 View {
233 owner: "ada",
234 repo: "engine",
235 current_ref: "main",
236 file_path: "src/main.rs",
237 filename: "main.rs",
238 breadcrumbs: &[],
239 refs: &[],
240 lines,
241 annotated,
242 open_issue_count: 0,
243 is_owner: false,
244 }
245 }
246
247 fn rendered(view: &View<'_>) -> String {
248 use quasi_axum::Serves as _;
249
250 Webview::new().screen(&screen(view))
251 }
252
253 /// The header is the first declared shape in the tree, so what it must not
254 /// lose is asserted here rather than left to the shape of the expansion:
255 /// both addresses, both spellings of the count, and the two ways out of the
256 /// blame view.
257 #[test]
258 fn the_header_says_how_long_the_file_is_and_the_two_ways_to_read_it() {
259 let annotated = HashMap::new();
260
261 let one = vec![line(1, "abc1234", "fn main() {}")];
262 let html = rendered(&view(&one, &annotated));
263 assert!(html.contains("1 line"), "{html}");
264 assert!(!html.contains("1 lines"), "{html}");
265
266 let two = vec![line(1, "abc1234", "fn main() {}"), line(2, "def5678", "}")];
267 let html = rendered(&view(&two, &annotated));
268 assert!(html.contains("2 lines"), "{html}");
269 assert!(
270 html.contains(r#"href="/git/ada/engine/tree/main/src/main.rs""#),
271 "{html}"
272 );
273 assert!(
274 html.contains(r#"href="/git/ada/engine/raw/main/src/main.rs""#),
275 "{html}"
276 );
277 }
278
279 /// The title names the file being blamed, as the template's did.
280 #[test]
281 fn the_document_is_titled_for_the_file() {
282 let lines = vec![line(1, "abc1234", "fn main() {}")];
283 let annotated = HashMap::new();
284
285 assert_eq!(
286 screen(&view(&lines, &annotated)).title,
287 "Blame main.rs - engine - Git - Makenotwork"
288 );
289 }
290
291 /// Every line says who last touched it and links to the commit that did.
292 #[test]
293 fn every_line_carries_its_commit() {
294 let lines = vec![line(1, "abc1234", "fn main() {}"), line(2, "def5678", "}")];
295 let annotated = HashMap::new();
296 let html = rendered(&view(&lines, &annotated));
297
298 assert!(html.contains("abc1234"), "{html}");
299 assert!(html.contains("def5678"), "{html}");
300 assert!(
301 html.contains("/git/ada/engine/commit/abc123400000000000000000000000000000"),
302 "{html}"
303 );
304 assert!(html.contains("fn main() {}"), "{html}");
305 }
306
307 /// A commit that carries notes says how many, and links at them rather than
308 /// at the top of the commit.
309 #[test]
310 fn a_commit_with_notes_says_so() {
311 let lines = vec![line(1, "abc1234", "fn main() {}")];
312 let mut annotated = HashMap::new();
313 annotated.insert(lines[0].commit_oid.clone(), 2);
314 let html = rendered(&view(&lines, &annotated));
315
316 assert!(html.contains("2 notes"), "{html}");
317 assert!(html.contains("#notes"), "{html}");
318
319 let mut one = HashMap::new();
320 one.insert(lines[0].commit_oid.clone(), 1);
321 let single = rendered(&view(&lines, &one));
322 assert!(single.contains(">note<"), "{single}");
323 }
324
325 /// The line is the line. `git::blame_file` used to escape it into markup on
326 /// the way out; the renderer does it now, and doing both would double every
327 /// ampersand.
328 #[test]
329 fn a_line_is_escaped_once() {
330 let lines = vec![line(1, "abc1234", "if a < b && c > d {")];
331 let annotated = HashMap::new();
332 let html = rendered(&view(&lines, &annotated));
333
334 assert!(html.contains("if a &lt; b &amp;&amp; c &gt; d {"), "{html}");
335 assert!(!html.contains("&amp;lt;"), "{html}");
336 }
337
338 /// A file's own content cannot reach the document as markup.
339 #[test]
340 fn a_line_cannot_smuggle_markup() {
341 let lines = vec![line(1, "abc1234", "<script>alert(1)</script>")];
342 let annotated = HashMap::new();
343 let html = rendered(&view(&lines, &annotated));
344
345 assert!(!html.contains("<script>"), "{html}");
346 assert!(html.contains("&lt;script&gt;"), "{html}");
347 }
348
349 /// `736f45a5`: this screen's markup carries none of the four spellings.
350 #[test]
351 fn the_page_spells_no_spinner() {
352 let lines = vec![line(1, "abc1234", "fn main() {}")];
353 let annotated = HashMap::new();
354 let html = rendered(&view(&lines, &annotated));
355
356 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
357 assert!(!html.contains(spelling), "{spelling} survives in {html}");
358 }
359 }
360 }
361