Skip to main content

max / makenotwork

22.2 KB · 701 lines History Blame Raw
1 //! Browsing a repository's tree at `/git/{owner}/{repo}/tree/{ref}[/{path}]`,
2 //! described.
3 //!
4 //! Replaces `templates/pages/git/tree.html`, `templates/pages/git/file.html`,
5 //! `GitTreeTemplate` and `GitFileTemplate`. One module because it is one
6 //! address: `routes::git::browsing::tree_or_file` reads the path and finds
7 //! either a tree or a blob, and everything above the table -- the identity
8 //! line, the nav bar, the path trail -- is the same either way.
9 //!
10 //! Served from that handler rather than as a quasi route, for
11 //! [`crate::quasi::git_blame`]'s reason: the address ends in a file path and
12 //! `quasi_router`'s matcher takes `{name}` for one segment with no wildcard.
13 //!
14 //! # There is no seam between the lexer and the document
15 //!
16 //! `SyntaxHighlighter::classify` answers runs and this screen carries them, so
17 //! nothing between the two turns a run into markup. `19d7602d` is what made
18 //! that possible and `src/quasi/widgets/code.rs` was the stand-in that held the
19 //! gap open while the page was still a template; it is gone with the template.
20
21 use makeover_layout as layout;
22 use quasi_declare::declare;
23 use quasi_router::Document;
24 use quasi_router::screen::Lexeme;
25 use quasi_webview::Webview;
26
27 use crate::git::{Breadcrumb, RefInfo, TreeItem, TreeItemKind};
28 use crate::routes::git::notes_view::CommitNote;
29
30 /// The page's own region, and what the skip link points at.
31 pub const PAGE_REGION: &str = "git-browse";
32
33 const MEASURE: layout::Measure = layout::Measure::Wide;
34
35 /// What every browse page carries, whichever of the two it is.
36 pub struct Frame<'a> {
37 pub owner: &'a str,
38 pub repo: &'a str,
39 pub current_ref: &'a str,
40 /// The path being browsed, empty at the root.
41 pub path: &'a str,
42 pub breadcrumbs: &'a [Breadcrumb],
43 pub refs: &'a [RefInfo],
44 pub open_issue_count: i64,
45 pub is_owner: bool,
46 }
47
48 impl Frame<'_> {
49 fn base(&self) -> String {
50 format!("/git/{}/{}", self.owner, self.repo)
51 }
52
53 /// The tree address of a path under the ref being browsed.
54 fn tree(&self, path: &str) -> String {
55 format!("{}/tree/{}/{path}", self.base(), self.current_ref)
56 }
57
58 fn nav(&self) -> super::widgets::git_nav::Nav<'_> {
59 super::widgets::git_nav::Nav {
60 owner: self.owner,
61 repo: self.repo,
62 current_ref: self.current_ref,
63 active_tab: "files",
64 open_issue_count: self.open_issue_count,
65 is_owner: self.is_owner,
66 refs: self.refs,
67 }
68 }
69 }
70
71 /// A directory listing.
72 pub struct Tree<'a> {
73 pub items: &'a [TreeItem],
74 /// Where `..` goes. `None` at the repository root, where there is no up.
75 pub parent: Option<&'a str>,
76 }
77
78 /// One file.
79 pub struct File<'a> {
80 pub filename: &'a str,
81 /// The path within the repository, for the History, Blame and Raw links.
82 pub file_path: &'a str,
83 pub file_size: &'a str,
84 /// One entry per line, already classified by whoever holds the lexer.
85 ///
86 /// Empty for a binary file, which is drawn as the notice instead.
87 pub lines: &'a [Vec<Lexeme>],
88 /// The extension, which is all a renderer is told about the language.
89 pub language: Option<&'a str>,
90 pub is_binary: bool,
91 /// Notes on the blob. A note on a file annotates the object, so it follows
92 /// the content rather than the path.
93 pub notes: &'a [CommitNote],
94 }
95
96 declare! {
97 /// The identity line, the bar and the trail, which both pages open with.
98 ///
99 /// A panel's members rather than the region itself, because both screens
100 /// then add their own content to the same region and a shape that takes a
101 /// container and hands it back is what `custom_page::strip` was refused
102 /// for. `-> Vec<Node>` is the shape for exactly this: members in order,
103 /// with nothing wrapping them.
104 shape opening(frame: &Frame<'_>) -> Vec<Node>;
105
106 include super::widgets::git_nav::heading(frame.owner, frame.repo);
107 include super::widgets::git_nav::region(&frame.nav());
108 include super::widgets::git_nav::breadcrumb(
109 frame.owner,
110 frame.repo,
111 frame.current_ref,
112 frame.breadcrumbs
113 );
114 }
115
116 declare! {
117 /// The directory listing, as a screen.
118 #[must_use]
119 pub shape tree_screen(frame: &Frame<'_>, tree: &Tree<'_>) -> Screen;
120
121 screen single "{frame.path} - {frame.repo} - Git - Makenotwork" {
122 measured MEASURE;
123 documented Document::default().classed(crate::shell::body_class(MEASURE, &[]));
124
125 region PAGE_REGION as Pane {
126 for node in opening(frame) {
127 include node;
128 }
129 include listing(frame, tree);
130 }
131 }
132 }
133
134 declare! {
135 /// The file view, as a screen.
136 ///
137 /// The notes region is an `-> Option<_>` shape, and an `Option` is an
138 /// iterator of at most one: `.into_iter()` is the method step that says so
139 /// rather than a production for placing an absence.
140 #[must_use]
141 pub shape file_screen(frame: &Frame<'_>, file: &File<'_>) -> Screen;
142
143 screen single "{file.filename} - {frame.repo} - Git - Makenotwork" {
144 measured MEASURE;
145 documented Document::default().classed(crate::shell::body_class(MEASURE, &[]));
146
147 region PAGE_REGION as Pane {
148 for node in opening(frame) {
149 include node;
150 }
151 include file_header(frame, file);
152 for notes in super::widgets::git_notes::region(file.notes).into_iter() {
153 include notes;
154 }
155 given file.is_binary {
156 true -> include binary(frame, file);
157 otherwise -> include source(file);
158 }
159 }
160 }
161 }
162
163 /// What an entry is called in the listing.
164 ///
165 /// A trailing slash is how a directory says it is one. The shipped table said
166 /// it twice -- an icon column holding `/` and the slash on the name -- and one
167 /// of the two was a column of one character that carried no fact the name did
168 /// not.
169 fn entry_name(item: &TreeItem) -> String {
170 match item.kind {
171 TreeItemKind::Dir => format!("{}/", item.name),
172 TreeItemKind::File => item.name.clone(),
173 }
174 }
175
176 /// Where it is, under the path being browsed.
177 fn entry_path(frame: &Frame<'_>, item: &TreeItem) -> String {
178 if frame.path.is_empty() {
179 item.name.clone()
180 } else {
181 format!("{}/{}", frame.path, item.name)
182 }
183 }
184
185 /// How big it is, or nothing for a directory.
186 fn entry_size(item: &TreeItem) -> String {
187 item.size
188 .as_ref()
189 .map(crate::routes::git::format_size)
190 .unwrap_or_default()
191 }
192
193 declare! {
194 /// What is in this directory.
195 ///
196 /// `..` is a row like any other, which is what the shipped table made it:
197 /// it is a place in the tree, and giving it its own control would say it
198 /// was a different kind of thing. It is drawn only below the root, which is
199 /// where there is an up, and an `Option` is an iterator of at most one.
200 ///
201 /// The rows are two separate constructions and the columns are declared
202 /// above them, so the cells name their columns rather than counting against
203 /// a heading list neither construction can see.
204 shape listing(frame: &Frame<'_>, tree: &Tree<'_>) -> Node;
205
206 table {
207 column "Name" {
208 width Fill;
209 priority Essential;
210 }
211 column "Size" {
212 width Content;
213 priority Secondary;
214 }
215
216 for parent in tree.parent.into_iter() {
217 cells {
218 cell at "Name" "..";
219 cell at "Size" "";
220 activate to get frame.tree(parent) navigating;
221 }
222 }
223
224 for item in tree.items.iter() {
225 cells {
226 cell at "Name" entry_name(item);
227 cell at "Size" entry_size(item);
228 activate to get frame.tree(&entry_path(frame, item)) navigating;
229 }
230 }
231 }
232 }
233
234 /// How big the file is, and how long, in the strip's one sentence.
235 fn size_line(file: &File<'_>) -> String {
236 let count = file.lines.len();
237 if file.is_binary {
238 file.file_size.to_owned()
239 } else if count == 1 {
240 format!("{} - 1 line", file.file_size)
241 } else {
242 format!("{} - {count} lines", file.file_size)
243 }
244 }
245
246 declare! {
247 /// The strip over a file: how big it is, and the other three ways to read
248 /// it.
249 shape file_header(frame: &Frame<'_>, file: &File<'_>) -> Node;
250
251 let base = frame.base();
252
253 region "git-file-header" as Group {
254 across Wrap {
255 beside Secondary text size_line(file);
256 beside Essential link "History"
257 to get "{base}/log/{frame.current_ref}/{file.file_path}" navigating;
258 beside Essential link "Blame"
259 to get "{base}/blame/{frame.current_ref}/{file.file_path}" navigating;
260 beside Essential link "Raw"
261 to get "{base}/raw/{frame.current_ref}/{file.file_path}" navigating;
262 }
263 }
264 }
265
266 declare! {
267 /// A file nothing can usefully draw, and the way to get it anyway.
268 shape binary(frame: &Frame<'_>, file: &File<'_>) -> Node;
269
270 let base = frame.base();
271
272 region "git-binary-notice" as Group {
273 across Wrap {
274 beside Essential text "Binary file ({file.file_size}).";
275 beside Essential link "Download"
276 to get "{base}/raw/{frame.current_ref}/{file.file_path}" navigating;
277 }
278 }
279 }
280
281 /// One line of the file, numbered.
282 ///
283 /// A supplier because the number comes off `enumerate` and one more than an
284 /// index is arithmetic, which the form admits none of. `Line` is not a
285 /// vocabulary type, so this stays out of the population.
286 struct Line {
287 /// Its number, from one.
288 number: usize,
289 /// What the lexer made of it.
290 runs: Vec<Lexeme>,
291 }
292
293 /// The file's lines, each knowing which one it is.
294 fn numbered(file: &File<'_>) -> Vec<Line> {
295 file.lines
296 .iter()
297 .enumerate()
298 .map(|(at, runs)| Line {
299 number: at + 1,
300 runs: runs.clone(),
301 })
302 .collect()
303 }
304
305 declare! {
306 /// The file, one row per line.
307 ///
308 /// A table rather than a block `Node::Code`, because the line numbers are
309 /// what a reader links to: `#L42` is the address of a line, and a block
310 /// that owned its own lines would have nothing to hang one on.
311 ///
312 /// Two columns, two cells, both written here: the row stays positional
313 /// because the headings it answers to are five lines above it.
314 shape source(file: &File<'_>) -> Node;
315
316 table {
317 column "Line" {
318 width Content;
319 priority Secondary;
320 }
321 column "Code" {
322 width Fill;
323 priority Essential;
324 }
325
326 for line in numbered(file) {
327 cells {
328 // `#L42`, which is what the link below points at and what
329 // `page-git-file.js` scrolls to. `addressed` and not
330 // `identified`: the first is a document address and the second
331 // is the app's own name for the row, and this wrote the second
332 // for as long as the vocabulary had no way to say the first.
333 addressed "L{line.number}";
334 // The number is a link to itself, which is how a reader gets
335 // the address of a line into their clipboard.
336 cell "" {
337 link "{line.number}" to get "#L{line.number}";
338 }
339 cell "" {
340 code line.runs file.language.map(str::to_owned);
341 }
342 }
343 }
344 }
345 }
346
347 /// The document a browse page is drawn in.
348 ///
349 /// One function for both, because the shell is the same: the two screens differ
350 /// in their body and in nothing else.
351 fn drawn(viewer: Option<&crate::auth::SessionUser>, csrf: &str) -> Webview {
352 Webview::new().with_shell(super::document_shell(csrf).with_body_first(format!(
353 "{}{}",
354 crate::shell::skip_link(PAGE_REGION),
355 crate::shell::site_header(viewer),
356 )))
357 }
358
359 /// The line-anchor script, which only the file view wants.
360 ///
361 /// A row identity is not an element id -- quasi-webview emits it as
362 /// `data-value`, since two tables of the same things on one screen is an
363 /// ordinary description and two elements under one id is not -- so the browser
364 /// does not scroll to `#L42` by itself and this does.
365 const LINE_ANCHORS: &str = concat!(
366 "<script src=\"/static/page-git-file.js?v=",
367 env!("STATIC_VERSION"),
368 "\" defer></script>"
369 );
370
371 /// The directory listing, as a document.
372 #[must_use]
373 pub fn tree_document(
374 viewer: Option<&crate::auth::SessionUser>,
375 csrf: &str,
376 frame: &Frame<'_>,
377 tree: &Tree<'_>,
378 ) -> String {
379 use quasi_axum::Serves as _;
380
381 drawn(viewer, csrf).screen(&tree_screen(frame, tree))
382 }
383
384 /// The file view, as a document, with every handover paid.
385 #[must_use]
386 pub fn file_document(
387 viewer: Option<&crate::auth::SessionUser>,
388 csrf: &str,
389 frame: &Frame<'_>,
390 file: &File<'_>,
391 ) -> String {
392 use quasi_axum::Serves as _;
393
394 let mut webview = Webview::new().with_shell(
395 super::document_shell(csrf)
396 .with_body_first(format!(
397 "{}{}",
398 crate::shell::skip_link(PAGE_REGION),
399 crate::shell::site_header(viewer),
400 ))
401 .with_head(LINE_ANCHORS),
402 );
403 webview = super::widgets::git_notes::fill(webview, file.notes);
404
405 webview.screen(&file_screen(frame, file))
406 }
407
408 #[cfg(test)]
409 mod tests {
410 use super::*;
411 use makeover_layout::Syntax;
412
413 fn frame<'a>(path: &'a str, crumbs: &'a [Breadcrumb]) -> Frame<'a> {
414 Frame {
415 owner: "ada",
416 repo: "engine",
417 current_ref: "main",
418 path,
419 breadcrumbs: crumbs,
420 refs: &[],
421 open_issue_count: 0,
422 is_owner: false,
423 }
424 }
425
426 fn items() -> Vec<TreeItem> {
427 vec![
428 TreeItem {
429 name: "src".into(),
430 kind: TreeItemKind::Dir,
431 size: None,
432 },
433 TreeItem {
434 name: "README.md".into(),
435 kind: TreeItemKind::File,
436 size: Some(1024),
437 },
438 ]
439 }
440
441 fn rendered_tree(frame: &Frame<'_>, tree: &Tree<'_>) -> String {
442 use quasi_axum::Serves as _;
443
444 Webview::new().screen(&tree_screen(frame, tree))
445 }
446
447 fn rendered_file(frame: &Frame<'_>, file: &File<'_>) -> String {
448 use quasi_axum::Serves as _;
449
450 Webview::new().screen(&file_screen(frame, file))
451 }
452
453 /// A directory is a row that opens it, and it says it is one with the slash
454 /// the shipped table put on the name.
455 #[test]
456 fn every_entry_is_a_row_that_opens_it() {
457 let html = rendered_tree(
458 &frame("", &[]),
459 &Tree {
460 items: &items(),
461 parent: None,
462 },
463 );
464
465 assert!(html.contains("src/"), "{html}");
466 assert!(html.contains("/git/ada/engine/tree/main/src"), "{html}");
467 assert!(
468 html.contains("/git/ada/engine/tree/main/README.md"),
469 "{html}"
470 );
471 assert!(html.contains("1.0 KB"), "{html}");
472 }
473
474 /// A subdirectory's entries are addressed under it rather than under the
475 /// root, which is the one thing the shipped template's `in_subdir` guard
476 /// was for.
477 #[test]
478 fn a_subdirectory_addresses_its_entries_under_itself() {
479 let html = rendered_tree(
480 &frame("src", &[]),
481 &Tree {
482 items: &items(),
483 parent: Some(""),
484 },
485 );
486
487 assert!(
488 html.contains("/git/ada/engine/tree/main/src/README.md"),
489 "{html}"
490 );
491 }
492
493 /// There is no up from the root: `..` there would be a control that goes
494 /// where the reader already is.
495 #[test]
496 fn the_root_offers_no_way_up() {
497 let root = rendered_tree(
498 &frame("", &[]),
499 &Tree {
500 items: &items(),
501 parent: None,
502 },
503 );
504 assert!(!root.contains(".."), "{root}");
505
506 let sub = rendered_tree(
507 &frame("src", &[]),
508 &Tree {
509 items: &items(),
510 parent: Some(""),
511 },
512 );
513 assert!(sub.contains(".."), "{sub}");
514 }
515
516 /// The classification reaches the document, and a plain run stays bare text
517 /// rather than becoming a span that says "ordinary".
518 #[test]
519 fn a_classified_line_arrives_whole() {
520 let lines = vec![vec![
521 Lexeme::new("fn", Syntax::Keyword),
522 Lexeme::plain(" main() {}"),
523 ]];
524 let html = rendered_file(
525 &frame("src/main.rs", &[]),
526 &File {
527 filename: "main.rs",
528 file_path: "src/main.rs",
529 file_size: "12 B",
530 lines: &lines,
531 language: Some("rs"),
532 is_binary: false,
533 notes: &[],
534 },
535 );
536
537 assert!(html.contains("lex-keyword"), "{html}");
538 assert!(html.contains(" main() {}"), "{html}");
539 }
540
541 /// Every line is addressable, which is what `#L42` is and what the file
542 /// view would lose by numbering rows and naming none of them.
543 #[test]
544 fn every_line_names_itself() {
545 let lines = vec![vec![Lexeme::plain("one")], vec![Lexeme::plain("two")]];
546 let html = rendered_file(
547 &frame("src/main.rs", &[]),
548 &File {
549 filename: "main.rs",
550 file_path: "src/main.rs",
551 file_size: "8 B",
552 lines: &lines,
553 language: None,
554 is_binary: false,
555 notes: &[],
556 },
557 );
558
559 // `id`, not `data-value`. The link beside each number points at `#L2`,
560 // and only an id is what `#L2` reaches -- `Row::identified` is the
561 // app's own name for a row and reaches the document as `data-value`,
562 // which is what this asserted while every anchor pointed at nothing.
563 assert!(html.contains("id=\"L1\""), "{html}");
564 assert!(html.contains("id=\"L2\""), "{html}");
565 assert!(html.contains("href=\"#L2\""), "{html}");
566 }
567
568 /// A blank line renders rather than vanishing: a file view that dropped one
569 /// would renumber every line under it.
570 #[test]
571 fn a_blank_line_still_takes_a_row() {
572 let lines = vec![
573 vec![Lexeme::plain("one")],
574 Vec::new(),
575 vec![Lexeme::plain("three")],
576 ];
577 let html = rendered_file(
578 &frame("src/main.rs", &[]),
579 &File {
580 filename: "main.rs",
581 file_path: "src/main.rs",
582 file_size: "10 B",
583 lines: &lines,
584 language: None,
585 is_binary: false,
586 notes: &[],
587 },
588 );
589
590 assert!(html.contains("id=\"L3\""), "{html}");
591 assert!(html.contains(">three<"), "{html}");
592 }
593
594 /// Source is reader input by the time it reaches here, and it goes into a
595 /// document. The renderer escapes it; this is the test that says so.
596 #[test]
597 fn source_cannot_smuggle_markup() {
598 let lines = vec![vec![Lexeme::plain("<script>alert(1)</script>")]];
599 let html = rendered_file(
600 &frame("src/main.rs", &[]),
601 &File {
602 filename: "main.rs",
603 file_path: "src/main.rs",
604 file_size: "25 B",
605 lines: &lines,
606 language: None,
607 is_binary: false,
608 notes: &[],
609 },
610 );
611
612 assert!(!html.contains("<script>alert"), "{html}");
613 assert!(html.contains("&lt;script&gt;"), "{html}");
614 }
615
616 /// A binary file is the notice and the way to get it anyway, and no table
617 /// of nothing.
618 #[test]
619 fn a_binary_file_offers_the_download() {
620 let html = rendered_file(
621 &frame("logo.png", &[]),
622 &File {
623 filename: "logo.png",
624 file_path: "logo.png",
625 file_size: "40 KB",
626 lines: &[],
627 language: None,
628 is_binary: true,
629 notes: &[],
630 },
631 );
632
633 assert!(html.contains("Binary file (40 KB)"), "{html}");
634 assert!(html.contains("/git/ada/engine/raw/main/logo.png"), "{html}");
635 assert!(!html.contains("role=\"table\""), "{html}");
636 }
637
638 /// The three other ways to read the file, which the strip has always
639 /// carried.
640 #[test]
641 fn the_file_offers_its_history_blame_and_raw() {
642 let lines = vec![vec![Lexeme::plain("one")]];
643 let html = rendered_file(
644 &frame("src/main.rs", &[]),
645 &File {
646 filename: "main.rs",
647 file_path: "src/main.rs",
648 file_size: "4 B",
649 lines: &lines,
650 language: None,
651 is_binary: false,
652 notes: &[],
653 },
654 );
655
656 assert!(
657 html.contains("/git/ada/engine/log/main/src/main.rs"),
658 "{html}"
659 );
660 assert!(
661 html.contains("/git/ada/engine/blame/main/src/main.rs"),
662 "{html}"
663 );
664 assert!(
665 html.contains("/git/ada/engine/raw/main/src/main.rs"),
666 "{html}"
667 );
668 assert!(html.contains("4 B - 1 line"), "{html}");
669 }
670
671 /// `736f45a5`: neither screen's markup carries any of the four spellings.
672 #[test]
673 fn the_pages_spell_no_spinner() {
674 let lines = vec![vec![Lexeme::plain("one")]];
675 let file = rendered_file(
676 &frame("src/main.rs", &[]),
677 &File {
678 filename: "main.rs",
679 file_path: "src/main.rs",
680 file_size: "4 B",
681 lines: &lines,
682 language: None,
683 is_binary: false,
684 notes: &[],
685 },
686 );
687 let tree = rendered_tree(
688 &frame("", &[]),
689 &Tree {
690 items: &items(),
691 parent: None,
692 },
693 );
694
695 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
696 assert!(!file.contains(spelling), "{spelling} survives in {file}");
697 assert!(!tree.contains(spelling), "{spelling} survives in {tree}");
698 }
699 }
700 }
701