//! One commit at `/git/{owner}/{repo}/commit/{oid}`, described. //! //! Replaces `templates/pages/git/commit.html`, `GitCommitDetailTemplate` and //! the three partials that page and nothing else included: //! `git_notes_edit.html`, `git_annotations_panel.html` and //! `git_annotation_edit.html`. `git_signature.html` stays, because the tags //! page still writes it; [`signature`] is the described half of the same //! ruling. //! //! Served from `routes::git::browsing::commit_detail_page` rather than as a //! quasi route, for [`crate::quasi::git_blame`]'s reason one address over: the //! browse tree is registered as a whole and the neighbouring addresses carry //! wildcard segments, so the two pages are built the same way rather than one //! each. //! //! # The diff carries the ruling //! //! `19d7602d` added [`layout::Change`] and `Row::changed` for this page, and //! this is their only consumer. A diff row says which side it is on and the //! renderer decides what that looks like: a tint in a webview, a leading sign //! in a terminal. What the description does not do is spell `+` or `-` into the //! content, which is what the shipped `origin` column was -- a sign drawn as //! data, unavailable to a renderer that wanted to say it another way and //! duplicated by the tint beside it. //! //! Diff content stops being markup here too, exactly as blame's did: //! `git::diff_commit` escaped each line on the way out, and the renderer //! escapes now. //! //! # The write forms carry no hidden token //! //! `crate::shell::described` sends `X-CSRF-Token` on every request the document //! makes, and `crate::csrf` reads that header before it looks for a `_csrf` //! field. The hidden inputs existed because these were vanilla `
`s. use makeover_layout as layout; use quasi_declare::declare; use quasi_router::{Document, RegionKind}; use quasi_webview::Webview; use crate::git::signing::SignatureStatus; use crate::git::{CommitDetail, DiffFile, RefInfo}; use crate::routes::git::annotations_view::PersonalAnnotation; use crate::routes::git::notes_view::CommitNote; /// The page's own region, and what the skip link points at. pub const PAGE_REGION: &str = "git-commit"; /// Where the reader's own annotations sit. The shipped markup used this id and /// links off the annotations index point at it. pub const ANNOTATIONS_REGION: &str = "annotations"; /// Where the annotation write form sits, for the same reason. pub const ANNOTATION_REGION: &str = "annotation"; const MEASURE: layout::Measure = layout::Measure::Wide; /// The longest a note or an annotation may be, as the shipped textareas said. const BODY_LIMIT: u32 = 50_000; /// 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 refs: &'a [RefInfo], pub detail: &'a CommitDetail, pub signature: &'a SignatureStatus, pub notes: &'a [CommitNote], /// Whether the write routes will accept a note from this reader. pub can_write_notes: bool, /// A concurrent edit was merged rather than lost, and the reader is owed /// the fact. pub notes_merged: bool, pub can_annotate: bool, pub annotation_source: &'a str, pub personal_annotations: &'a [PersonalAnnotation], pub annotation_merged: bool, pub diff_files: &'a [DiffFile], pub total_files: usize, pub total_additions: usize, pub total_deletions: usize, pub open_issue_count: i64, pub is_owner: bool, } impl View<'_> { fn base(&self) -> String { format!("/git/{}/{}", self.owner, self.repo) } 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: "commit", 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. /// /// Four of its members are `-> Option<_>` shapes, and an `Option` is an /// iterator of at most one: `.into_iter()` is the method step that says so /// rather than a production for placing an absence. #[must_use] pub shape screen(view: &View<'_>) -> Screen; screen single "{view.detail.short_oid} {view.detail.summary} - {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 detail(view); for notes in super::widgets::git_notes::region(view.notes).into_iter() { include notes; } for edit in notes_edit(view).into_iter() { include edit; } for mine in annotations(view).into_iter() { include mine; } for edit in annotation_edit(view).into_iter() { include edit; } include stats(view); for file in view.diff_files.iter() { include diff_file(view, file); } } } } /// Whether the person who committed is not the person who wrote it. /// /// Said only when it differs, which is the common case's silence: the same /// person authored and committed nearly every commit anybody reads. fn committer_differs(detail: &CommitDetail) -> bool { detail.committer_name != detail.author_name || detail.committer_email != detail.author_email } /// `Parent:` or `Parents:`, which is a fact about how many there are. fn parents_label(detail: &CommitDetail) -> &'static str { if detail.parents.len() == 1 { "Parent:" } else { "Parents:" } } declare! { /// The message, its trailers, and who made it when. /// /// Trailers are structured metadata that happens to be stored as the last /// paragraph of the message. Said as rows rather than left in the prose, /// which is how a Co-authored-by line stops being the last line of a /// sentence and starts being a second author. shape detail(view: &View<'_>) -> Node; region "git-commit-detail" as Group { text view.detail.message_body.clone(); list { for trailer in view.detail.trailers.iter() { row trailer.token.clone() { meta trailer.value.clone(); } } } unless view.detail.trailers.is_empty(); region "git-commit-detail-meta" as Group { text "Author: {view.detail.author_name} <{view.detail.author_email}> - \ {view.detail.author_time}"; text "Committer: {view.detail.committer_name} <{view.detail.committer_email}> - \ {view.detail.committer_time}" when committer_differs(view.detail); include signature(view) unless signature_kind(view.signature) is "unsigned"; // The full id, which is machine text: it is copied into a command // far more often than it is read. region "git-commit-oid" as Group { across Wrap { beside Secondary text "Commit:"; beside Essential literal view.detail.oid.clone(); } } region "git-commit-parents" as Group unless view.detail.parents.is_empty() { across Wrap { beside Secondary text parents_label(view.detail); for parent in view.detail.parents.iter() { beside Essential link parent.short_oid.clone() to get "{view.base()}/commit/{parent.oid}" navigating; } } } } } } /// Which of the three things a signature has to say this one says. /// /// A supplier because the enum's two carrying variants need a binding pattern /// and the form has none. It hands back a `&'static str`, which keeps it out of /// the population, and the three answers are what the description dispatches /// on. fn signature_kind(status: &SignatureStatus) -> &'static str { match status { SignatureStatus::Unsigned => "unsigned", SignatureStatus::SignedBy { .. } => "signed_by", SignatureStatus::ValidUnknownKey { .. } | SignatureStatus::Invalid | SignatureStatus::Unverified { .. } => "note", } } /// Who signed it, when this server knows them. fn signer(status: &SignatureStatus) -> &str { match status { SignatureStatus::SignedBy { username, .. } => username, _ => "", } } /// The key that did it. R9: read whether or not the region is placed. fn signer_key(status: &SignatureStatus) -> &str { match status { SignatureStatus::SignedBy { fingerprint, .. } => fingerprint, _ => "", } } /// What the other three states say in a line. fn signature_note(status: &SignatureStatus) -> String { match status { SignatureStatus::ValidUnknownKey { .. } => "Signed, key not registered here".to_owned(), SignatureStatus::Invalid => "Signature does not verify".to_owned(), SignatureStatus::Unverified { format } => format!("Signed with {format}, not checked"), _ => String::new(), } } /// How that line is toned. Only one of the states deserves to look like a /// problem. fn signature_tone(status: &SignatureStatus) -> layout::Tone { match status { SignatureStatus::Invalid => layout::Tone::Danger, _ => layout::Tone::Neutral, } } declare! { /// What the signature says, when there is one. /// /// Nothing renders for an unsigned object, which is `git_signature.html`'s /// ruling and stands: every forge that badges "unsigned" is training people /// to ignore the badge, and the overwhelming majority of commits everywhere /// are unsigned. [`detail`] is where that guard lives, because R2 makes /// this shape one emission and "nothing" is not one. /// /// The verified case names the signer, which is the thing no forge without /// its own key store can do: the key that made the signature is the key /// that authenticates their pushes. shape signature(view: &View<'_>) -> Node; given signature_kind(view.signature) { "signed_by" -> region "git-signature" as Group { named "Signed by {signer(view.signature)}, key {signer_key(view.signature)}"; across Wrap { beside Essential text "Signed by"; beside Essential link signer(view.signature) to get "/u/{signer(view.signature)}" navigating; } } otherwise -> toned signature_note(view.signature) signature_tone(view.signature); } } /// The notes on this commit the write routes will accept a save to. /// /// A supplier because `filter` takes a closure. It hands back borrowed notes, /// which is not a vocabulary type and so is not counted. fn writable(notes: &[CommitNote]) -> Vec<&CommitNote> { notes.iter().filter(|note| !note.read_only).collect() } declare! { /// The write half for the repository's own notes. /// /// A note in a namespace the write routes refuse gets no form: /// makenot.work writes `refs/notes/mnw/*`, and offering an edit box that /// saves to a 422 is worse than offering nothing. shape notes_edit(view: &View<'_>) -> Option; region "git-notes-edit" as Group when view.can_write_notes { toned "Somebody edited this note while you were writing. Both versions were kept, \ so what is below is not exactly what you typed." layout::Tone::Warning when view.notes_merged; for note in writable(view.notes) { form post "{view.base()}/commit/{view.detail.oid}/notes" { submit "Save note"; // Which note is being saved. The reader does not choose it here // -- they are editing the one in front of them -- so it rides // along rather than being asked. field Hidden "namespace" "Namespace of the note being saved: {note.namespace}" { value note.namespace.clone(); } field Textarea "content" "Edit the note in {note.namespace}" { value note.source.clone(); limited_to BODY_LIMIT; } } act "Remove the note in {note.namespace}" to post "{view.base()}/commit/{view.detail.oid}/notes/delete" with "namespace" note.namespace.clone() { tone Danger; } } form post "{view.base()}/commit/{view.detail.oid}/notes" { submit "Add note"; // `commits` is what `git notes` writes with no namespace given, so // a reader running stock git finds a note left at the default. field Text "namespace" "Namespace" { value "commits"; required; } field Textarea "content" "Add a note" { limited_to BODY_LIMIT; placeholder "Markdown. Stored in the repository under refs/notes, and it \ travels with a clone that fetches them."; } } } } /// Whether an annotation says which repository it was written against. fn has_origin(annotation: &PersonalAnnotation) -> bool { annotation.origin.is_some() } /// That repository's name, or nothing. R9: read either way. fn origin(annotation: &PersonalAnnotation) -> &str { annotation.origin.as_deref().unwrap_or_default() } /// Whether the commit it annotates can still be read here. fn has_link(annotation: &PersonalAnnotation) -> bool { annotation.link.is_some() } /// Where to read it, or nowhere. fn link(annotation: &PersonalAnnotation) -> &str { annotation.link.as_deref().unwrap_or_default() } declare! { /// The reader's own annotations on this commit. /// /// Nobody but the signed-in reader ever sees one: the handler reads their /// annotation repository and nobody else's, and reads nothing at all for a /// visitor who is signed out. shape annotations(view: &View<'_>) -> Option; region ANNOTATIONS_REGION as Group when view.can_annotate { section "Your annotations"; text "Only you can see these. They live in your own annotation repository, not in \ this one, and they clone and export like anything else."; for annotation in view.personal_annotations.iter() { region "annotation-{annotation.short_target}" as Group { region "annotation-header-{annotation.short_target}" as Group { across Wrap { beside Secondary link origin(annotation) to get link(annotation) navigating when has_origin(annotation) and has_link(annotation); beside Secondary text origin(annotation) when has_origin(annotation) and not has_link(annotation); beside Essential literal annotation.short_target.clone(); beside Optional text annotation.updated.clone() unless annotation.updated.is_empty(); beside Secondary toned "Annotates a commit makenot.work no longer serves." layout::Tone::Warning when annotation.orphan; } } region annotation_body_region(&annotation.short_target) as RegionKind::handover("a rendered annotation") {} } } } } /// Where one annotation's rendered markdown lands. #[must_use] pub fn annotation_body_region(short_target: &str) -> String { format!("annotation-body-{short_target}") } declare! { /// The write half for the reader's own annotation. shape annotation_edit(view: &View<'_>) -> Option; region ANNOTATION_REGION as Group when view.can_annotate { toned "You edited this annotation in two places at once. Both versions were kept, \ so what is below is not exactly what you typed." layout::Tone::Warning when view.annotation_merged; form post "{view.base()}/commit/{view.detail.oid}/annotate" { submit "Save annotation"; field Textarea "content" "Your own note on this commit" { value view.annotation_source; limited_to BODY_LIMIT; placeholder "Markdown. Stored in your own annotations repository, private to \ you, and it clones and exports like anything else."; } } act "Remove your annotation" to post "{view.base()}/commit/{view.detail.oid}/annotate/delete" unless view.annotation_source.is_empty() { tone Danger; } } } /// A count and the word for it, singular or not. fn plural(n: usize, one: &str) -> String { if n == 1 { format!("{n} {one}") } else { format!("{n} {one}s") } } declare! { /// How much the commit changed, in one line. shape stats(view: &View<'_>) -> Node; region "git-diff-stats" as Group { across Wrap { beside Essential text "{plural(view.total_files, \"file\")} changed,"; beside Essential toned "+{plural(view.total_additions, \"insertion\")}," layout::Tone::Success; beside Essential toned "-{plural(view.total_deletions, \"deletion\")}" layout::Tone::Danger; } } } /// What a file's diff status looks like. /// /// The four colours the shipped `.diff-status-*` rules picked, said as tones /// rather than as a class per status. fn status_tone(file: &DiffFile) -> layout::Tone { match file.status { crate::git::DiffStatus::Added => layout::Tone::Success, crate::git::DiffStatus::Deleted => layout::Tone::Danger, crate::git::DiffStatus::Modified => layout::Tone::Warning, crate::git::DiffStatus::Renamed => layout::Tone::Info, } } /// What the file is called, and what it was called before. fn diff_label(file: &DiffFile) -> String { match &file.old_path { Some(old) => format!("{old} -> {}", file.path), None => file.path.clone(), } } /// Whether there is a diff to draw rather than a notice to write. fn has_hunks(file: &DiffFile) -> bool { !file.is_binary && !file.hunks.is_empty() } declare! { /// One file's diff: what happened to it, and the hunks. shape diff_file(view: &View<'_>, file: &DiffFile) -> Node; region "diff-{slug(&file.path)}" as Group { region "diff-header-{slug(&file.path)}" as Group { across Wrap { beside Essential badge file.status.label() { tone status_tone(file); hinted file.status.name(); } beside Essential link diff_label(file) to get "{view.base()}/tree/{view.current_ref}/{file.path}" navigating; beside Secondary toned "+{file.additions}" layout::Tone::Success when file.additions over 0; beside Secondary toned "-{file.deletions}" layout::Tone::Danger when file.deletions over 0; } } empty "Binary file" when file.is_binary; include hunks(file) when has_hunks(file); toned "Lines truncated" layout::Tone::Warning when has_hunks(file) and file.truncated; } } /// One row of a diff: a hunk header, or a line of one of the two sides. /// /// A supplier because the origin character decides the change and a `match` on /// a `char` is not something the form says. `Change` is a `layout` enum, which /// is the smallest type that works and keeps this out of the population. fn change_of(line: &crate::git::DiffLine) -> layout::Change { match line.origin { '+' => layout::Change::Added, '-' => layout::Change::Removed, _ => layout::Change::Context, } } /// A line number, or nothing where the line is only on the other side. fn old_lineno(line: &crate::git::DiffLine) -> String { line.old_lineno.map(|n| n.to_string()).unwrap_or_default() } /// See [`old_lineno`]. fn new_lineno(line: &crate::git::DiffLine) -> String { line.new_lineno.map(|n| n.to_string()).unwrap_or_default() } declare! { /// Every hunk of one file, as one table. /// /// One table rather than one per hunk: the hunk header is a row in it, /// which is what the shipped markup did, and it keeps the line-number /// columns aligned down the whole file. /// /// A hunk header and a diff line are two separate constructions and the /// column list is above both of them, so both name their columns rather /// than counting to them. shape hunks(file: &DiffFile) -> Node; table { column "Old" { width Content; priority Optional; } column "New" { width Content; priority Secondary; } column "Line" { width Fill; priority Essential; } for hunk in file.hunks.iter() { // The hunk header is not a line of either side, so it carries no // change: the absence is what says "this row is not part of the // diff's two sides", and the renderer draws it as the caption it // is. The two line-number cells are empty because a caption has no // line number, not because something has to fill the space. cells { cell at "Old" ""; cell at "New" ""; cell at "Line" hunk.header.clone(); } for line in hunk.lines.iter() { cells { changed change_of(line); cell at "Old" old_lineno(line); cell at "New" new_lineno(line); cell at "Line" "" { literal line.content.clone(); } } } } } } /// A path as an element id can carry it. `git_notes::body_region`'s reason. fn slug(path: &str) -> String { path.chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) .collect() } /// The document this screen is drawn in, with every handover paid. #[must_use] pub fn document(viewer: Option<&crate::auth::SessionUser>, csrf: &str, view: &View<'_>) -> String { use quasi_axum::Serves as _; let mut webview = Webview::new().with_shell(super::document_shell(csrf).with_body_first(format!( "{}{}", crate::shell::skip_link(PAGE_REGION), crate::shell::site_header(viewer), ))); webview = super::widgets::git_notes::fill(webview, view.notes); for annotation in view.personal_annotations { // The app's own classes, `git_notes::fill`'s reason: this markup came // out of docengine and the two panels share their typography. webview = webview.with_fill( annotation_body_region(&annotation.short_target), format!( "
{}
", annotation.html ), ); } webview.screen(&screen(view)) } #[cfg(test)] mod tests { use super::*; use crate::git::{CommitTrailer, DiffHunk, DiffLine, DiffStatus, ParentRef}; fn detail() -> CommitDetail { CommitDetail { oid: "abc1234000000000000000000000000000000000".into(), short_oid: "abc1234".into(), summary: "Teach the nav to say itself".into(), full_message: "Teach the nav to say itself\n\nBody.".into(), message_body: "Body.".into(), trailers: vec![CommitTrailer { token: "Co-authored-by".into(), value: "Grace ".into(), }], author_name: "ada".into(), author_email: "ada@example.com".into(), author_time: "2026-08-01".into(), committer_name: "ada".into(), committer_email: "ada@example.com".into(), committer_time: "2026-08-01".into(), parents: vec![ParentRef { oid: "def5678000000000000000000000000000000000".into(), short_oid: "def5678".into(), }], } } fn diff() -> Vec { vec![DiffFile { path: "src/main.rs".into(), old_path: None, status: DiffStatus::Modified, additions: 1, deletions: 1, hunks: vec![DiffHunk { header: "@@ -1,3 +1,3 @@".into(), lines: vec![ DiffLine { origin: ' ', content: "fn main() {".into(), old_lineno: Some(1), new_lineno: Some(1), }, DiffLine { origin: '-', content: " let a = 1 & 2;".into(), old_lineno: Some(2), new_lineno: None, }, DiffLine { origin: '+', content: " let a = 1 | 2;".into(), old_lineno: None, new_lineno: Some(2), }, ], }], is_binary: false, truncated: false, }] } fn view<'a>( detail: &'a CommitDetail, signature: &'a SignatureStatus, diff_files: &'a [DiffFile], ) -> View<'a> { View { owner: "ada", repo: "engine", current_ref: "main", refs: &[], detail, signature, notes: &[], can_write_notes: false, notes_merged: false, can_annotate: false, annotation_source: "", personal_annotations: &[], annotation_merged: false, diff_files, total_files: 1, total_additions: 1, total_deletions: 1, open_issue_count: 0, is_owner: false, } } fn rendered(view: &View<'_>) -> String { use quasi_axum::Serves as _; Webview::new().screen(&screen(view)) } /// The title names the commit, as the template's did. #[test] fn the_document_is_titled_for_the_commit() { let detail = detail(); let files = diff(); assert_eq!( screen(&view(&detail, &SignatureStatus::Unsigned, &files)).title, "abc1234 Teach the nav to say itself - engine - Git - Makenotwork" ); } /// `19d7602d`'s only consumer: each side of the diff says which side it is /// on, and the renderer decides what that looks like. #[test] fn every_diff_line_says_which_side_it_is_on() { let detail = detail(); let files = diff(); let html = rendered(&view(&detail, &SignatureStatus::Unsigned, &files)); assert!(html.contains("data-change=\"added\""), "{html}"); assert!(html.contains("data-change=\"removed\""), "{html}"); assert!(html.contains("data-change=\"context\""), "{html}"); } /// The sign is the renderer's now. The description carries the side, and /// the content is the line: a `+` written into the text would be a mark a /// terminal could not spell its own way. #[test] fn the_content_carries_no_sign() { let detail = detail(); let files = diff(); let html = rendered(&view(&detail, &SignatureStatus::Unsigned, &files)); assert!(html.contains("let a = 1 | 2;"), "{html}"); assert!(!html.contains(">+ let a"), "{html}"); } /// A diff line is escaped once. `git::diff_commit` used to escape on the /// way out and the renderer escapes now. #[test] fn a_diff_line_is_escaped_once() { let detail = detail(); let files = diff(); let html = rendered(&view(&detail, &SignatureStatus::Unsigned, &files)); assert!(html.contains("let a = 1 & 2;"), "{html}"); assert!(!html.contains("&amp;"), "{html}"); } /// A hunk header is not a line of either side, so it carries no change and /// no ordinary table in the tree reads as a diff. #[test] fn a_hunk_header_is_not_a_diff_line() { let detail = detail(); let files = diff(); let html = rendered(&view(&detail, &SignatureStatus::Unsigned, &files)); let header_row = html .split("