Skip to main content

max / makenotwork

31.7 KB · 871 lines History Blame Raw
1 //! One commit at `/git/{owner}/{repo}/commit/{oid}`, described.
2 //!
3 //! Replaces `templates/pages/git/commit.html`, `GitCommitDetailTemplate` and
4 //! the three partials that page and nothing else included:
5 //! `git_notes_edit.html`, `git_annotations_panel.html` and
6 //! `git_annotation_edit.html`. `git_signature.html` stays, because the tags
7 //! page still writes it; [`signature`] is the described half of the same
8 //! ruling.
9 //!
10 //! Served from `routes::git::browsing::commit_detail_page` rather than as a
11 //! quasi route, for [`crate::quasi::git_blame`]'s reason one address over: the
12 //! browse tree is registered as a whole and the neighbouring addresses carry
13 //! wildcard segments, so the two pages are built the same way rather than one
14 //! each.
15 //!
16 //! # The diff carries the ruling
17 //!
18 //! `19d7602d` added [`layout::Change`] and `Row::changed` for this page, and
19 //! this is their only consumer. A diff row says which side it is on and the
20 //! renderer decides what that looks like: a tint in a webview, a leading sign
21 //! in a terminal. What the description does not do is spell `+` or `-` into the
22 //! content, which is what the shipped `origin` column was -- a sign drawn as
23 //! data, unavailable to a renderer that wanted to say it another way and
24 //! duplicated by the tint beside it.
25 //!
26 //! Diff content stops being markup here too, exactly as blame's did:
27 //! `git::diff_commit` escaped each line on the way out, and the renderer
28 //! escapes now.
29 //!
30 //! # The write forms carry no hidden token
31 //!
32 //! `crate::shell::described` sends `X-CSRF-Token` on every request the document
33 //! makes, and `crate::csrf` reads that header before it looks for a `_csrf`
34 //! field. The hidden inputs existed because these were vanilla `<form>`s.
35
36 use makeover_layout as layout;
37 use quasi_declare::declare;
38 use quasi_router::{Document, RegionKind};
39 use quasi_webview::Webview;
40
41 use crate::git::signing::SignatureStatus;
42 use crate::git::{CommitDetail, DiffFile, RefInfo};
43 use crate::routes::git::annotations_view::PersonalAnnotation;
44 use crate::routes::git::notes_view::CommitNote;
45
46 /// The page's own region, and what the skip link points at.
47 pub const PAGE_REGION: &str = "git-commit";
48
49 /// Where the reader's own annotations sit. The shipped markup used this id and
50 /// links off the annotations index point at it.
51 pub const ANNOTATIONS_REGION: &str = "annotations";
52
53 /// Where the annotation write form sits, for the same reason.
54 pub const ANNOTATION_REGION: &str = "annotation";
55
56 const MEASURE: layout::Measure = layout::Measure::Wide;
57
58 /// The longest a note or an annotation may be, as the shipped textareas said.
59 const BODY_LIMIT: u32 = 50_000;
60
61 /// Everything the screen draws, resolved before it is drawn.
62 pub struct View<'a> {
63 pub owner: &'a str,
64 pub repo: &'a str,
65 pub current_ref: &'a str,
66 pub refs: &'a [RefInfo],
67 pub detail: &'a CommitDetail,
68 pub signature: &'a SignatureStatus,
69 pub notes: &'a [CommitNote],
70 /// Whether the write routes will accept a note from this reader.
71 pub can_write_notes: bool,
72 /// A concurrent edit was merged rather than lost, and the reader is owed
73 /// the fact.
74 pub notes_merged: bool,
75 pub can_annotate: bool,
76 pub annotation_source: &'a str,
77 pub personal_annotations: &'a [PersonalAnnotation],
78 pub annotation_merged: bool,
79 pub diff_files: &'a [DiffFile],
80 pub total_files: usize,
81 pub total_additions: usize,
82 pub total_deletions: usize,
83 pub open_issue_count: i64,
84 pub is_owner: bool,
85 }
86
87 impl View<'_> {
88 fn base(&self) -> String {
89 format!("/git/{}/{}", self.owner, self.repo)
90 }
91
92 fn nav(&self) -> super::widgets::git_nav::Nav<'_> {
93 super::widgets::git_nav::Nav {
94 owner: self.owner,
95 repo: self.repo,
96 current_ref: self.current_ref,
97 active_tab: "commit",
98 open_issue_count: self.open_issue_count,
99 is_owner: self.is_owner,
100 refs: self.refs,
101 }
102 }
103 }
104
105 declare! {
106 /// The whole document: the title, the measure, the body.
107 ///
108 /// Four of its members are `-> Option<_>` shapes, and an `Option` is an
109 /// iterator of at most one: `.into_iter()` is the method step that says so
110 /// rather than a production for placing an absence.
111 #[must_use]
112 pub shape screen(view: &View<'_>) -> Screen;
113
114 screen single "{view.detail.short_oid} {view.detail.summary} - {view.repo} - Git - Makenotwork" {
115 measured MEASURE;
116 documented Document::default().classed(crate::shell::body_class(MEASURE, &[]));
117
118 region PAGE_REGION as Pane {
119 include super::widgets::git_nav::heading(view.owner, view.repo);
120 include super::widgets::git_nav::region(&view.nav());
121 include detail(view);
122
123 for notes in super::widgets::git_notes::region(view.notes).into_iter() {
124 include notes;
125 }
126 for edit in notes_edit(view).into_iter() {
127 include edit;
128 }
129 for mine in annotations(view).into_iter() {
130 include mine;
131 }
132 for edit in annotation_edit(view).into_iter() {
133 include edit;
134 }
135
136 include stats(view);
137 for file in view.diff_files.iter() {
138 include diff_file(view, file);
139 }
140 }
141 }
142 }
143
144 /// Whether the person who committed is not the person who wrote it.
145 ///
146 /// Said only when it differs, which is the common case's silence: the same
147 /// person authored and committed nearly every commit anybody reads.
148 fn committer_differs(detail: &CommitDetail) -> bool {
149 detail.committer_name != detail.author_name || detail.committer_email != detail.author_email
150 }
151
152 /// `Parent:` or `Parents:`, which is a fact about how many there are.
153 fn parents_label(detail: &CommitDetail) -> &'static str {
154 if detail.parents.len() == 1 {
155 "Parent:"
156 } else {
157 "Parents:"
158 }
159 }
160
161 declare! {
162 /// The message, its trailers, and who made it when.
163 ///
164 /// Trailers are structured metadata that happens to be stored as the last
165 /// paragraph of the message. Said as rows rather than left in the prose,
166 /// which is how a Co-authored-by line stops being the last line of a
167 /// sentence and starts being a second author.
168 shape detail(view: &View<'_>) -> Node;
169
170 region "git-commit-detail" as Group {
171 text view.detail.message_body.clone();
172
173 list {
174 for trailer in view.detail.trailers.iter() {
175 row trailer.token.clone() {
176 meta trailer.value.clone();
177 }
178 }
179 } unless view.detail.trailers.is_empty();
180
181 region "git-commit-detail-meta" as Group {
182 text "Author: {view.detail.author_name} <{view.detail.author_email}> - \
183 {view.detail.author_time}";
184 text "Committer: {view.detail.committer_name} <{view.detail.committer_email}> - \
185 {view.detail.committer_time}"
186 when committer_differs(view.detail);
187
188 include signature(view) unless signature_kind(view.signature) is "unsigned";
189
190 // The full id, which is machine text: it is copied into a command
191 // far more often than it is read.
192 region "git-commit-oid" as Group {
193 across Wrap {
194 beside Secondary text "Commit:";
195 beside Essential literal view.detail.oid.clone();
196 }
197 }
198
199 region "git-commit-parents" as Group unless view.detail.parents.is_empty() {
200 across Wrap {
201 beside Secondary text parents_label(view.detail);
202 for parent in view.detail.parents.iter() {
203 beside Essential link parent.short_oid.clone()
204 to get "{view.base()}/commit/{parent.oid}" navigating;
205 }
206 }
207 }
208 }
209 }
210 }
211
212 /// Which of the three things a signature has to say this one says.
213 ///
214 /// A supplier because the enum's two carrying variants need a binding pattern
215 /// and the form has none. It hands back a `&'static str`, which keeps it out of
216 /// the population, and the three answers are what the description dispatches
217 /// on.
218 fn signature_kind(status: &SignatureStatus) -> &'static str {
219 match status {
220 SignatureStatus::Unsigned => "unsigned",
221 SignatureStatus::SignedBy { .. } => "signed_by",
222 SignatureStatus::ValidUnknownKey { .. }
223 | SignatureStatus::Invalid
224 | SignatureStatus::Unverified { .. } => "note",
225 }
226 }
227
228 /// Who signed it, when this server knows them.
229 fn signer(status: &SignatureStatus) -> &str {
230 match status {
231 SignatureStatus::SignedBy { username, .. } => username,
232 _ => "",
233 }
234 }
235
236 /// The key that did it. R9: read whether or not the region is placed.
237 fn signer_key(status: &SignatureStatus) -> &str {
238 match status {
239 SignatureStatus::SignedBy { fingerprint, .. } => fingerprint,
240 _ => "",
241 }
242 }
243
244 /// What the other three states say in a line.
245 fn signature_note(status: &SignatureStatus) -> String {
246 match status {
247 SignatureStatus::ValidUnknownKey { .. } => "Signed, key not registered here".to_owned(),
248 SignatureStatus::Invalid => "Signature does not verify".to_owned(),
249 SignatureStatus::Unverified { format } => format!("Signed with {format}, not checked"),
250 _ => String::new(),
251 }
252 }
253
254 /// How that line is toned. Only one of the states deserves to look like a
255 /// problem.
256 fn signature_tone(status: &SignatureStatus) -> layout::Tone {
257 match status {
258 SignatureStatus::Invalid => layout::Tone::Danger,
259 _ => layout::Tone::Neutral,
260 }
261 }
262
263 declare! {
264 /// What the signature says, when there is one.
265 ///
266 /// Nothing renders for an unsigned object, which is `git_signature.html`'s
267 /// ruling and stands: every forge that badges "unsigned" is training people
268 /// to ignore the badge, and the overwhelming majority of commits everywhere
269 /// are unsigned. [`detail`] is where that guard lives, because R2 makes
270 /// this shape one emission and "nothing" is not one.
271 ///
272 /// The verified case names the signer, which is the thing no forge without
273 /// its own key store can do: the key that made the signature is the key
274 /// that authenticates their pushes.
275 shape signature(view: &View<'_>) -> Node;
276
277 given signature_kind(view.signature) {
278 "signed_by" -> region "git-signature" as Group {
279 named "Signed by {signer(view.signature)}, key {signer_key(view.signature)}";
280 across Wrap {
281 beside Essential text "Signed by";
282 beside Essential link signer(view.signature)
283 to get "/u/{signer(view.signature)}" navigating;
284 }
285 }
286 otherwise -> toned signature_note(view.signature) signature_tone(view.signature);
287 }
288 }
289
290 /// The notes on this commit the write routes will accept a save to.
291 ///
292 /// A supplier because `filter` takes a closure. It hands back borrowed notes,
293 /// which is not a vocabulary type and so is not counted.
294 fn writable(notes: &[CommitNote]) -> Vec<&CommitNote> {
295 notes.iter().filter(|note| !note.read_only).collect()
296 }
297
298 declare! {
299 /// The write half for the repository's own notes.
300 ///
301 /// A note in a namespace the write routes refuse gets no form:
302 /// makenot.work writes `refs/notes/mnw/*`, and offering an edit box that
303 /// saves to a 422 is worse than offering nothing.
304 shape notes_edit(view: &View<'_>) -> Option<Node>;
305
306 region "git-notes-edit" as Group when view.can_write_notes {
307 toned "Somebody edited this note while you were writing. Both versions were kept, \
308 so what is below is not exactly what you typed."
309 layout::Tone::Warning
310 when view.notes_merged;
311
312 for note in writable(view.notes) {
313 form post "{view.base()}/commit/{view.detail.oid}/notes" {
314 submit "Save note";
315 // Which note is being saved. The reader does not choose it here
316 // -- they are editing the one in front of them -- so it rides
317 // along rather than being asked.
318 field Hidden "namespace" "Namespace of the note being saved: {note.namespace}" {
319 value note.namespace.clone();
320 }
321 field Textarea "content" "Edit the note in {note.namespace}" {
322 value note.source.clone();
323 limited_to BODY_LIMIT;
324 }
325 }
326 act "Remove the note in {note.namespace}"
327 to post "{view.base()}/commit/{view.detail.oid}/notes/delete"
328 with "namespace" note.namespace.clone() {
329 tone Danger;
330 }
331 }
332
333 form post "{view.base()}/commit/{view.detail.oid}/notes" {
334 submit "Add note";
335 // `commits` is what `git notes` writes with no namespace given, so
336 // a reader running stock git finds a note left at the default.
337 field Text "namespace" "Namespace" {
338 value "commits";
339 required;
340 }
341 field Textarea "content" "Add a note" {
342 limited_to BODY_LIMIT;
343 placeholder "Markdown. Stored in the repository under refs/notes, and it \
344 travels with a clone that fetches them.";
345 }
346 }
347 }
348 }
349
350 /// Whether an annotation says which repository it was written against.
351 fn has_origin(annotation: &PersonalAnnotation) -> bool {
352 annotation.origin.is_some()
353 }
354
355 /// That repository's name, or nothing. R9: read either way.
356 fn origin(annotation: &PersonalAnnotation) -> &str {
357 annotation.origin.as_deref().unwrap_or_default()
358 }
359
360 /// Whether the commit it annotates can still be read here.
361 fn has_link(annotation: &PersonalAnnotation) -> bool {
362 annotation.link.is_some()
363 }
364
365 /// Where to read it, or nowhere.
366 fn link(annotation: &PersonalAnnotation) -> &str {
367 annotation.link.as_deref().unwrap_or_default()
368 }
369
370 declare! {
371 /// The reader's own annotations on this commit.
372 ///
373 /// Nobody but the signed-in reader ever sees one: the handler reads their
374 /// annotation repository and nobody else's, and reads nothing at all for a
375 /// visitor who is signed out.
376 shape annotations(view: &View<'_>) -> Option<Node>;
377
378 region ANNOTATIONS_REGION as Group when view.can_annotate {
379 section "Your annotations";
380 text "Only you can see these. They live in your own annotation repository, not in \
381 this one, and they clone and export like anything else.";
382
383 for annotation in view.personal_annotations.iter() {
384 region "annotation-{annotation.short_target}" as Group {
385 region "annotation-header-{annotation.short_target}" as Group {
386 across Wrap {
387 beside Secondary link origin(annotation)
388 to get link(annotation) navigating
389 when has_origin(annotation) and has_link(annotation);
390 beside Secondary text origin(annotation)
391 when has_origin(annotation) and not has_link(annotation);
392 beside Essential literal annotation.short_target.clone();
393 beside Optional text annotation.updated.clone()
394 unless annotation.updated.is_empty();
395 beside Secondary toned
396 "Annotates a commit makenot.work no longer serves."
397 layout::Tone::Warning
398 when annotation.orphan;
399 }
400 }
401 region annotation_body_region(&annotation.short_target)
402 as RegionKind::handover("a rendered annotation") {}
403 }
404 }
405 }
406 }
407
408 /// Where one annotation's rendered markdown lands.
409 #[must_use]
410 pub fn annotation_body_region(short_target: &str) -> String {
411 format!("annotation-body-{short_target}")
412 }
413
414 declare! {
415 /// The write half for the reader's own annotation.
416 shape annotation_edit(view: &View<'_>) -> Option<Node>;
417
418 region ANNOTATION_REGION as Group when view.can_annotate {
419 toned "You edited this annotation in two places at once. Both versions were kept, \
420 so what is below is not exactly what you typed."
421 layout::Tone::Warning
422 when view.annotation_merged;
423
424 form post "{view.base()}/commit/{view.detail.oid}/annotate" {
425 submit "Save annotation";
426 field Textarea "content" "Your own note on this commit" {
427 value view.annotation_source;
428 limited_to BODY_LIMIT;
429 placeholder "Markdown. Stored in your own annotations repository, private to \
430 you, and it clones and exports like anything else.";
431 }
432 }
433
434 act "Remove your annotation"
435 to post "{view.base()}/commit/{view.detail.oid}/annotate/delete"
436 unless view.annotation_source.is_empty() {
437 tone Danger;
438 }
439 }
440 }
441
442 /// A count and the word for it, singular or not.
443 fn plural(n: usize, one: &str) -> String {
444 if n == 1 {
445 format!("{n} {one}")
446 } else {
447 format!("{n} {one}s")
448 }
449 }
450
451 declare! {
452 /// How much the commit changed, in one line.
453 shape stats(view: &View<'_>) -> Node;
454
455 region "git-diff-stats" as Group {
456 across Wrap {
457 beside Essential text "{plural(view.total_files, \"file\")} changed,";
458 beside Essential toned "+{plural(view.total_additions, \"insertion\")},"
459 layout::Tone::Success;
460 beside Essential toned "-{plural(view.total_deletions, \"deletion\")}"
461 layout::Tone::Danger;
462 }
463 }
464 }
465
466 /// What a file's diff status looks like.
467 ///
468 /// The four colours the shipped `.diff-status-*` rules picked, said as tones
469 /// rather than as a class per status.
470 fn status_tone(file: &DiffFile) -> layout::Tone {
471 match file.status {
472 crate::git::DiffStatus::Added => layout::Tone::Success,
473 crate::git::DiffStatus::Deleted => layout::Tone::Danger,
474 crate::git::DiffStatus::Modified => layout::Tone::Warning,
475 crate::git::DiffStatus::Renamed => layout::Tone::Info,
476 }
477 }
478
479 /// What the file is called, and what it was called before.
480 fn diff_label(file: &DiffFile) -> String {
481 match &file.old_path {
482 Some(old) => format!("{old} -> {}", file.path),
483 None => file.path.clone(),
484 }
485 }
486
487 /// Whether there is a diff to draw rather than a notice to write.
488 fn has_hunks(file: &DiffFile) -> bool {
489 !file.is_binary && !file.hunks.is_empty()
490 }
491
492 declare! {
493 /// One file's diff: what happened to it, and the hunks.
494 shape diff_file(view: &View<'_>, file: &DiffFile) -> Node;
495
496 region "diff-{slug(&file.path)}" as Group {
497 region "diff-header-{slug(&file.path)}" as Group {
498 across Wrap {
499 beside Essential badge file.status.label() {
500 tone status_tone(file);
501 hinted file.status.name();
502 }
503 beside Essential link diff_label(file)
504 to get "{view.base()}/tree/{view.current_ref}/{file.path}" navigating;
505 beside Secondary toned "+{file.additions}" layout::Tone::Success
506 when file.additions over 0;
507 beside Secondary toned "-{file.deletions}" layout::Tone::Danger
508 when file.deletions over 0;
509 }
510 }
511
512 empty "Binary file" when file.is_binary;
513 include hunks(file) when has_hunks(file);
514 toned "Lines truncated" layout::Tone::Warning when has_hunks(file) and file.truncated;
515 }
516 }
517
518 /// One row of a diff: a hunk header, or a line of one of the two sides.
519 ///
520 /// A supplier because the origin character decides the change and a `match` on
521 /// a `char` is not something the form says. `Change` is a `layout` enum, which
522 /// is the smallest type that works and keeps this out of the population.
523 fn change_of(line: &crate::git::DiffLine) -> layout::Change {
524 match line.origin {
525 '+' => layout::Change::Added,
526 '-' => layout::Change::Removed,
527 _ => layout::Change::Context,
528 }
529 }
530
531 /// A line number, or nothing where the line is only on the other side.
532 fn old_lineno(line: &crate::git::DiffLine) -> String {
533 line.old_lineno.map(|n| n.to_string()).unwrap_or_default()
534 }
535
536 /// See [`old_lineno`].
537 fn new_lineno(line: &crate::git::DiffLine) -> String {
538 line.new_lineno.map(|n| n.to_string()).unwrap_or_default()
539 }
540
541 declare! {
542 /// Every hunk of one file, as one table.
543 ///
544 /// One table rather than one per hunk: the hunk header is a row in it,
545 /// which is what the shipped markup did, and it keeps the line-number
546 /// columns aligned down the whole file.
547 ///
548 /// A hunk header and a diff line are two separate constructions and the
549 /// column list is above both of them, so both name their columns rather
550 /// than counting to them.
551 shape hunks(file: &DiffFile) -> Node;
552
553 table {
554 column "Old" {
555 width Content;
556 priority Optional;
557 }
558 column "New" {
559 width Content;
560 priority Secondary;
561 }
562 column "Line" {
563 width Fill;
564 priority Essential;
565 }
566
567 for hunk in file.hunks.iter() {
568 // The hunk header is not a line of either side, so it carries no
569 // change: the absence is what says "this row is not part of the
570 // diff's two sides", and the renderer draws it as the caption it
571 // is. The two line-number cells are empty because a caption has no
572 // line number, not because something has to fill the space.
573 cells {
574 cell at "Old" "";
575 cell at "New" "";
576 cell at "Line" hunk.header.clone();
577 }
578
579 for line in hunk.lines.iter() {
580 cells {
581 changed change_of(line);
582 cell at "Old" old_lineno(line);
583 cell at "New" new_lineno(line);
584 cell at "Line" "" {
585 literal line.content.clone();
586 }
587 }
588 }
589 }
590 }
591 }
592
593 /// A path as an element id can carry it. `git_notes::body_region`'s reason.
594 fn slug(path: &str) -> String {
595 path.chars()
596 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
597 .collect()
598 }
599
600 /// The document this screen is drawn in, with every handover paid.
601 #[must_use]
602 pub fn document(viewer: Option<&crate::auth::SessionUser>, csrf: &str, view: &View<'_>) -> String {
603 use quasi_axum::Serves as _;
604
605 let mut webview =
606 Webview::new().with_shell(super::document_shell(csrf).with_body_first(format!(
607 "{}{}",
608 crate::shell::skip_link(PAGE_REGION),
609 crate::shell::site_header(viewer),
610 )));
611
612 webview = super::widgets::git_notes::fill(webview, view.notes);
613 for annotation in view.personal_annotations {
614 // The app's own classes, `git_notes::fill`'s reason: this markup came
615 // out of docengine and the two panels share their typography.
616 webview = webview.with_fill(
617 annotation_body_region(&annotation.short_target),
618 format!(
619 "<div class=\"git-note-body git-readme-body\">{}</div>",
620 annotation.html
621 ),
622 );
623 }
624
625 webview.screen(&screen(view))
626 }
627
628 #[cfg(test)]
629 mod tests {
630 use super::*;
631 use crate::git::{CommitTrailer, DiffHunk, DiffLine, DiffStatus, ParentRef};
632
633 fn detail() -> CommitDetail {
634 CommitDetail {
635 oid: "abc1234000000000000000000000000000000000".into(),
636 short_oid: "abc1234".into(),
637 summary: "Teach the nav to say itself".into(),
638 full_message: "Teach the nav to say itself\n\nBody.".into(),
639 message_body: "Body.".into(),
640 trailers: vec![CommitTrailer {
641 token: "Co-authored-by".into(),
642 value: "Grace <grace@example.com>".into(),
643 }],
644 author_name: "ada".into(),
645 author_email: "ada@example.com".into(),
646 author_time: "2026-08-01".into(),
647 committer_name: "ada".into(),
648 committer_email: "ada@example.com".into(),
649 committer_time: "2026-08-01".into(),
650 parents: vec![ParentRef {
651 oid: "def5678000000000000000000000000000000000".into(),
652 short_oid: "def5678".into(),
653 }],
654 }
655 }
656
657 fn diff() -> Vec<DiffFile> {
658 vec![DiffFile {
659 path: "src/main.rs".into(),
660 old_path: None,
661 status: DiffStatus::Modified,
662 additions: 1,
663 deletions: 1,
664 hunks: vec![DiffHunk {
665 header: "@@ -1,3 +1,3 @@".into(),
666 lines: vec![
667 DiffLine {
668 origin: ' ',
669 content: "fn main() {".into(),
670 old_lineno: Some(1),
671 new_lineno: Some(1),
672 },
673 DiffLine {
674 origin: '-',
675 content: " let a = 1 & 2;".into(),
676 old_lineno: Some(2),
677 new_lineno: None,
678 },
679 DiffLine {
680 origin: '+',
681 content: " let a = 1 | 2;".into(),
682 old_lineno: None,
683 new_lineno: Some(2),
684 },
685 ],
686 }],
687 is_binary: false,
688 truncated: false,
689 }]
690 }
691
692 fn view<'a>(
693 detail: &'a CommitDetail,
694 signature: &'a SignatureStatus,
695 diff_files: &'a [DiffFile],
696 ) -> View<'a> {
697 View {
698 owner: "ada",
699 repo: "engine",
700 current_ref: "main",
701 refs: &[],
702 detail,
703 signature,
704 notes: &[],
705 can_write_notes: false,
706 notes_merged: false,
707 can_annotate: false,
708 annotation_source: "",
709 personal_annotations: &[],
710 annotation_merged: false,
711 diff_files,
712 total_files: 1,
713 total_additions: 1,
714 total_deletions: 1,
715 open_issue_count: 0,
716 is_owner: false,
717 }
718 }
719
720 fn rendered(view: &View<'_>) -> String {
721 use quasi_axum::Serves as _;
722
723 Webview::new().screen(&screen(view))
724 }
725
726 /// The title names the commit, as the template's did.
727 #[test]
728 fn the_document_is_titled_for_the_commit() {
729 let detail = detail();
730 let files = diff();
731 assert_eq!(
732 screen(&view(&detail, &SignatureStatus::Unsigned, &files)).title,
733 "abc1234 Teach the nav to say itself - engine - Git - Makenotwork"
734 );
735 }
736
737 /// `19d7602d`'s only consumer: each side of the diff says which side it is
738 /// on, and the renderer decides what that looks like.
739 #[test]
740 fn every_diff_line_says_which_side_it_is_on() {
741 let detail = detail();
742 let files = diff();
743 let html = rendered(&view(&detail, &SignatureStatus::Unsigned, &files));
744
745 assert!(html.contains("data-change=\"added\""), "{html}");
746 assert!(html.contains("data-change=\"removed\""), "{html}");
747 assert!(html.contains("data-change=\"context\""), "{html}");
748 }
749
750 /// The sign is the renderer's now. The description carries the side, and
751 /// the content is the line: a `+` written into the text would be a mark a
752 /// terminal could not spell its own way.
753 #[test]
754 fn the_content_carries_no_sign() {
755 let detail = detail();
756 let files = diff();
757 let html = rendered(&view(&detail, &SignatureStatus::Unsigned, &files));
758
759 assert!(html.contains("let a = 1 | 2;"), "{html}");
760 assert!(!html.contains(">+ let a"), "{html}");
761 }
762
763 /// A diff line is escaped once. `git::diff_commit` used to escape on the
764 /// way out and the renderer escapes now.
765 #[test]
766 fn a_diff_line_is_escaped_once() {
767 let detail = detail();
768 let files = diff();
769 let html = rendered(&view(&detail, &SignatureStatus::Unsigned, &files));
770
771 assert!(html.contains("let a = 1 &amp; 2;"), "{html}");
772 assert!(!html.contains("&amp;amp;"), "{html}");
773 }
774
775 /// A hunk header is not a line of either side, so it carries no change and
776 /// no ordinary table in the tree reads as a diff.
777 #[test]
778 fn a_hunk_header_is_not_a_diff_line() {
779 let detail = detail();
780 let files = diff();
781 let html = rendered(&view(&detail, &SignatureStatus::Unsigned, &files));
782
783 let header_row = html
784 .split("<div role=\"row\"")
785 .find(|row| row.contains("@@ -1,3 +1,3 @@"))
786 .expect("a hunk header row");
787 assert!(!header_row.contains("data-change"), "{header_row}");
788 }
789
790 /// Nothing renders for an unsigned object: a badge every commit carries is
791 /// a badge nobody reads.
792 #[test]
793 fn an_unsigned_commit_says_nothing_about_signing() {
794 let detail = detail();
795 let files = diff();
796 let html = rendered(&view(&detail, &SignatureStatus::Unsigned, &files));
797
798 assert!(!html.contains("Signed"), "{html}");
799 assert!(!html.contains("git-signature"), "{html}");
800 }
801
802 /// The verified case names the signer, and links at the account whose key
803 /// it is.
804 #[test]
805 fn a_verified_signature_names_the_signer() {
806 let detail = detail();
807 let files = diff();
808 let signature = SignatureStatus::SignedBy {
809 username: "ada".into(),
810 fingerprint: "SHA256:abc".into(),
811 };
812 let html = rendered(&view(&detail, &signature, &files));
813
814 assert!(html.contains("Signed by"), "{html}");
815 assert!(html.contains("/u/ada"), "{html}");
816 }
817
818 /// A trailer is a second author rather than the last line of a paragraph.
819 #[test]
820 fn a_trailer_is_not_prose() {
821 let detail = detail();
822 let files = diff();
823 let html = rendered(&view(&detail, &SignatureStatus::Unsigned, &files));
824
825 assert!(html.contains("Co-authored-by"), "{html}");
826 assert!(html.contains("Grace &lt;grace@example.com&gt;"), "{html}");
827 }
828
829 /// The write halves are offered to the reader who may use them and to
830 /// nobody else.
831 #[test]
832 fn the_write_forms_are_gated() {
833 let detail = detail();
834 let files = diff();
835 let mut can = view(&detail, &SignatureStatus::Unsigned, &files);
836 assert!(!rendered(&can).contains("Add note"));
837 assert!(!rendered(&can).contains("Save annotation"));
838
839 can.can_write_notes = true;
840 can.can_annotate = true;
841 let html = rendered(&can);
842 assert!(html.contains("Add note"), "{html}");
843 assert!(html.contains("Save annotation"), "{html}");
844 }
845
846 /// The forms post through htmx, which carries the token on a header. A
847 /// hidden field would be a second copy of a token that rotates mid-session.
848 #[test]
849 fn no_form_carries_a_hidden_token() {
850 let detail = detail();
851 let files = diff();
852 let mut can = view(&detail, &SignatureStatus::Unsigned, &files);
853 can.can_write_notes = true;
854 can.can_annotate = true;
855
856 assert!(!rendered(&can).contains("_csrf"));
857 }
858
859 /// `736f45a5`: this screen's markup carries none of the four spellings.
860 #[test]
861 fn the_page_spells_no_spinner() {
862 let detail = detail();
863 let files = diff();
864 let html = rendered(&view(&detail, &SignatureStatus::Unsigned, &files));
865
866 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
867 assert!(!html.contains(spelling), "{spelling} survives in {html}");
868 }
869 }
870 }
871