//! The blame view at `/git/{owner}/{repo}/blame/{ref}/{*path}`, described. //! //! Replaces `templates/pages/git/blame.html` and `GitBlameTemplate`. //! //! # Why this is not a quasi route //! //! The address ends in a file path, which carries slashes. //! `quasi_router`'s matcher takes `{name}` for one segment and has no wildcard, //! deliberately, so the address cannot be registered there. The handler stays //! `routes::git::browsing::blame_view` and renders this screen itself, which is //! `crate::quasi::custom_page`'s arrangement: a described screen does not need //! a described route, it needs a renderer. //! //! [`super::document_shell`] is the join. Everything a mounted document gets -- //! the site's head, the shortcuts chrome, the CSRF meta -- comes off the same //! builder here. //! //! # The lines stop being markup //! //! `git::blame_file` escaped each line into HTML on the way out, which was a //! second escaping story beside the renderer's. A blame line is an inline //! [`Node::Code`] now and arrives as the line, so the escaping is the //! renderer's and there is one of it. //! //! Blame is not highlighted, here or before. A blame table asks who last //! touched a line, and the answer is in the first three columns; classifying the //! fourth would mean re-reading the blob to get a whole file for the lexer, //! which is a read this page does not otherwise make. use std::collections::HashMap; use makeover_layout as layout; use quasi_declare::declare; use quasi_router::Document; use quasi_webview::Webview; use crate::git::{BlameLine, Breadcrumb, RefInfo}; /// The page's own region, and what the skip link points at. pub const PAGE_REGION: &str = "git-blame"; const MEASURE: layout::Measure = layout::Measure::Wide; /// Everything the screen draws, resolved before it is drawn. pub struct View<'a> { pub owner: &'a str, pub repo: &'a str, pub current_ref: &'a str, pub file_path: &'a str, pub filename: &'a str, pub breadcrumbs: &'a [Breadcrumb], pub refs: &'a [RefInfo], pub lines: &'a [BlameLine], /// How many notes each commit on this page carries, by full oid. pub annotated: &'a HashMap, pub open_issue_count: i64, pub is_owner: bool, } impl View<'_> { fn nav(&self) -> super::widgets::git_nav::Nav<'_> { super::widgets::git_nav::Nav { owner: self.owner, repo: self.repo, current_ref: self.current_ref, active_tab: "files", open_issue_count: self.open_issue_count, is_owner: self.is_owner, refs: self.refs, } } } declare! { /// The whole document: the title, the measure, the body. #[must_use] pub shape screen(view: &View<'_>) -> Screen; screen single "Blame {view.filename} - {view.repo} - Git - Makenotwork" { measured MEASURE; documented Document::default().classed(crate::shell::body_class(MEASURE, &[])); region PAGE_REGION as Pane { include super::widgets::git_nav::heading(view.owner, view.repo); include super::widgets::git_nav::region(&view.nav()); include super::widgets::git_nav::breadcrumb( view.owner, view.repo, view.current_ref, view.breadcrumbs ); include header(view); include table(view); } } } declare! { /// The strip over the table: how long the file is, and the other two ways to /// read it. shape header(view: &View<'_>) -> Node; let base = "/git/{view.owner}/{view.repo}"; let count = view.lines.len(); let lines = given count { 1 -> "1 line", otherwise -> "{count} lines", }; region "git-file-header" as Group { across Wrap { beside Secondary text lines; beside Essential link "Source" to get "{base}/tree/{view.current_ref}/{view.file_path}" navigating; beside Essential link "Raw" to get "{base}/raw/{view.current_ref}/{view.file_path}" navigating; } } } declare! { /// One row per line: who last touched it, when, and what it says. shape table(view: &View<'_>) -> Node; let base = "/git/{view.owner}/{view.repo}"; table { // The commit is the identity of the row: everything else is a fact // about it, and a narrow viewport that dropped it would leave a blame // table that blames nobody. column "Commit" { width Content; priority Essential; } column "Author" { width Content; priority Secondary; } column "Date" { width Content; priority Optional; } column "Line" { width Content; priority Secondary; } column "Code" { width Fill; priority Essential; } for line in view.lines.iter() { include row(view, &base, line); } } } /// What the notes link on a blamed line reads. /// /// Empty when the commit carries no annotations, which is R9: the value is /// built whether or not the guard places it. A supplier because the count /// decides between a word and a number, inside an `Option` the form has no /// binding pattern to reach. fn notes_label(view: &View<'_>, line: &BlameLine) -> String { match view.annotated.get(&line.commit_oid) { Some(1) => "note".to_owned(), Some(notes) => format!("{notes} notes"), None => String::new(), } } declare! { /// One blamed line. /// /// The cells name their columns because the column list is a shape away: a /// row written by position here would be lined up against headings nobody /// reading this declaration can see. /// /// The short oid opens the commit; the link beside it opens the same commit /// at its notes. Two addresses on one cell, which is what a cell holding a /// run of leaves is for. shape row(view: &View<'_>, base: &str, line: &BlameLine) -> Row; let commit = "{base}/commit/{line.commit_oid}"; cells { cell at "Commit" "" { link line.commit_short_oid.clone() to get commit.clone() navigating; link notes_label(view, line) to get "{commit}#notes" navigating when view.annotated.contains_key(&line.commit_oid); } cell at "Author" line.author_name.clone(); cell at "Date" line.time_formatted.clone(); cell at "Line" line.lineno.to_string(); cell at "Code" "" { literal &line.content; } } } /// The document this screen is drawn in. #[must_use] pub fn document(viewer: Option<&crate::auth::SessionUser>, csrf: &str, view: &View<'_>) -> String { use quasi_axum::Serves as _; Webview::new() .with_shell(super::document_shell(csrf).with_body_first(format!( "{}{}", crate::shell::skip_link(PAGE_REGION), crate::shell::site_header(viewer), ))) .screen(&screen(view)) } #[cfg(test)] mod tests { use super::*; fn line(lineno: usize, oid: &str, content: &str) -> BlameLine { BlameLine { lineno, commit_oid: format!("{oid}00000000000000000000000000000000"), commit_short_oid: oid.to_owned(), author_name: "ada".into(), time_formatted: "2026-08-01".into(), content: content.to_owned(), is_boundary: false, } } fn view<'a>(lines: &'a [BlameLine], annotated: &'a HashMap) -> View<'a> { View { owner: "ada", repo: "engine", current_ref: "main", file_path: "src/main.rs", filename: "main.rs", breadcrumbs: &[], refs: &[], lines, annotated, open_issue_count: 0, is_owner: false, } } fn rendered(view: &View<'_>) -> String { use quasi_axum::Serves as _; Webview::new().screen(&screen(view)) } /// The header is the first declared shape in the tree, so what it must not /// lose is asserted here rather than left to the shape of the expansion: /// both addresses, both spellings of the count, and the two ways out of the /// blame view. #[test] fn the_header_says_how_long_the_file_is_and_the_two_ways_to_read_it() { let annotated = HashMap::new(); let one = vec![line(1, "abc1234", "fn main() {}")]; let html = rendered(&view(&one, &annotated)); assert!(html.contains("1 line"), "{html}"); assert!(!html.contains("1 lines"), "{html}"); let two = vec![line(1, "abc1234", "fn main() {}"), line(2, "def5678", "}")]; let html = rendered(&view(&two, &annotated)); assert!(html.contains("2 lines"), "{html}"); assert!( html.contains(r#"href="/git/ada/engine/tree/main/src/main.rs""#), "{html}" ); assert!( html.contains(r#"href="/git/ada/engine/raw/main/src/main.rs""#), "{html}" ); } /// The title names the file being blamed, as the template's did. #[test] fn the_document_is_titled_for_the_file() { let lines = vec![line(1, "abc1234", "fn main() {}")]; let annotated = HashMap::new(); assert_eq!( screen(&view(&lines, &annotated)).title, "Blame main.rs - engine - Git - Makenotwork" ); } /// Every line says who last touched it and links to the commit that did. #[test] fn every_line_carries_its_commit() { let lines = vec![line(1, "abc1234", "fn main() {}"), line(2, "def5678", "}")]; let annotated = HashMap::new(); let html = rendered(&view(&lines, &annotated)); assert!(html.contains("abc1234"), "{html}"); assert!(html.contains("def5678"), "{html}"); assert!( html.contains("/git/ada/engine/commit/abc123400000000000000000000000000000"), "{html}" ); assert!(html.contains("fn main() {}"), "{html}"); } /// A commit that carries notes says how many, and links at them rather than /// at the top of the commit. #[test] fn a_commit_with_notes_says_so() { let lines = vec![line(1, "abc1234", "fn main() {}")]; let mut annotated = HashMap::new(); annotated.insert(lines[0].commit_oid.clone(), 2); let html = rendered(&view(&lines, &annotated)); assert!(html.contains("2 notes"), "{html}"); assert!(html.contains("#notes"), "{html}"); let mut one = HashMap::new(); one.insert(lines[0].commit_oid.clone(), 1); let single = rendered(&view(&lines, &one)); assert!(single.contains(">note<"), "{single}"); } /// The line is the line. `git::blame_file` used to escape it into markup on /// the way out; the renderer does it now, and doing both would double every /// ampersand. #[test] fn a_line_is_escaped_once() { let lines = vec![line(1, "abc1234", "if a < b && c > d {")]; let annotated = HashMap::new(); let html = rendered(&view(&lines, &annotated)); assert!(html.contains("if a < b && c > d {"), "{html}"); assert!(!html.contains("&lt;"), "{html}"); } /// A file's own content cannot reach the document as markup. #[test] fn a_line_cannot_smuggle_markup() { let lines = vec![line(1, "abc1234", "")]; let annotated = HashMap::new(); let html = rendered(&view(&lines, &annotated)); assert!(!html.contains("