//! Browsing a repository's tree at `/git/{owner}/{repo}/tree/{ref}[/{path}]`, //! described. //! //! Replaces `templates/pages/git/tree.html`, `templates/pages/git/file.html`, //! `GitTreeTemplate` and `GitFileTemplate`. One module because it is one //! address: `routes::git::browsing::tree_or_file` reads the path and finds //! either a tree or a blob, and everything above the table -- the identity //! line, the nav bar, the path trail -- is the same either way. //! //! Served from that handler rather than as a quasi route, for //! [`crate::quasi::git_blame`]'s reason: the address ends in a file path and //! `quasi_router`'s matcher takes `{name}` for one segment with no wildcard. //! //! # There is no seam between the lexer and the document //! //! `SyntaxHighlighter::classify` answers runs and this screen carries them, so //! nothing between the two turns a run into markup. `19d7602d` is what made //! that possible and `src/quasi/widgets/code.rs` was the stand-in that held the //! gap open while the page was still a template; it is gone with the template. use makeover_layout as layout; use quasi_declare::declare; use quasi_router::Document; use quasi_router::screen::Lexeme; use quasi_webview::Webview; use crate::git::{Breadcrumb, RefInfo, TreeItem, TreeItemKind}; 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-browse"; const MEASURE: layout::Measure = layout::Measure::Wide; /// What every browse page carries, whichever of the two it is. pub struct Frame<'a> { pub owner: &'a str, pub repo: &'a str, pub current_ref: &'a str, /// The path being browsed, empty at the root. pub path: &'a str, pub breadcrumbs: &'a [Breadcrumb], pub refs: &'a [RefInfo], pub open_issue_count: i64, pub is_owner: bool, } impl Frame<'_> { fn base(&self) -> String { format!("/git/{}/{}", self.owner, self.repo) } /// The tree address of a path under the ref being browsed. fn tree(&self, path: &str) -> String { format!("{}/tree/{}/{path}", self.base(), self.current_ref) } 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, } } } /// A directory listing. pub struct Tree<'a> { pub items: &'a [TreeItem], /// Where `..` goes. `None` at the repository root, where there is no up. pub parent: Option<&'a str>, } /// One file. pub struct File<'a> { pub filename: &'a str, /// The path within the repository, for the History, Blame and Raw links. pub file_path: &'a str, pub file_size: &'a str, /// One entry per line, already classified by whoever holds the lexer. /// /// Empty for a binary file, which is drawn as the notice instead. pub lines: &'a [Vec], /// The extension, which is all a renderer is told about the language. pub language: Option<&'a str>, pub is_binary: bool, /// Notes on the blob. A note on a file annotates the object, so it follows /// the content rather than the path. pub notes: &'a [CommitNote], } declare! { /// The identity line, the bar and the trail, which both pages open with. /// /// A panel's members rather than the region itself, because both screens /// then add their own content to the same region and a shape that takes a /// container and hands it back is what `custom_page::strip` was refused /// for. `-> Vec` is the shape for exactly this: members in order, /// with nothing wrapping them. shape opening(frame: &Frame<'_>) -> Vec; include super::widgets::git_nav::heading(frame.owner, frame.repo); include super::widgets::git_nav::region(&frame.nav()); include super::widgets::git_nav::breadcrumb( frame.owner, frame.repo, frame.current_ref, frame.breadcrumbs ); } declare! { /// The directory listing, as a screen. #[must_use] pub shape tree_screen(frame: &Frame<'_>, tree: &Tree<'_>) -> Screen; screen single "{frame.path} - {frame.repo} - Git - Makenotwork" { measured MEASURE; documented Document::default().classed(crate::shell::body_class(MEASURE, &[])); region PAGE_REGION as Pane { for node in opening(frame) { include node; } include listing(frame, tree); } } } declare! { /// The file view, as a screen. /// /// The notes region is an `-> Option<_>` shape, 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 file_screen(frame: &Frame<'_>, file: &File<'_>) -> Screen; screen single "{file.filename} - {frame.repo} - Git - Makenotwork" { measured MEASURE; documented Document::default().classed(crate::shell::body_class(MEASURE, &[])); region PAGE_REGION as Pane { for node in opening(frame) { include node; } include file_header(frame, file); for notes in super::widgets::git_notes::region(file.notes).into_iter() { include notes; } given file.is_binary { true -> include binary(frame, file); otherwise -> include source(file); } } } } /// What an entry is called in the listing. /// /// A trailing slash is how a directory says it is one. The shipped table said /// it twice -- an icon column holding `/` and the slash on the name -- and one /// of the two was a column of one character that carried no fact the name did /// not. fn entry_name(item: &TreeItem) -> String { match item.kind { TreeItemKind::Dir => format!("{}/", item.name), TreeItemKind::File => item.name.clone(), } } /// Where it is, under the path being browsed. fn entry_path(frame: &Frame<'_>, item: &TreeItem) -> String { if frame.path.is_empty() { item.name.clone() } else { format!("{}/{}", frame.path, item.name) } } /// How big it is, or nothing for a directory. fn entry_size(item: &TreeItem) -> String { item.size .as_ref() .map(crate::routes::git::format_size) .unwrap_or_default() } declare! { /// What is in this directory. /// /// `..` is a row like any other, which is what the shipped table made it: /// it is a place in the tree, and giving it its own control would say it /// was a different kind of thing. It is drawn only below the root, which is /// where there is an up, and an `Option` is an iterator of at most one. /// /// The rows are two separate constructions and the columns are declared /// above them, so the cells name their columns rather than counting against /// a heading list neither construction can see. shape listing(frame: &Frame<'_>, tree: &Tree<'_>) -> Node; table { column "Name" { width Fill; priority Essential; } column "Size" { width Content; priority Secondary; } for parent in tree.parent.into_iter() { cells { cell at "Name" ".."; cell at "Size" ""; activate to get frame.tree(parent) navigating; } } for item in tree.items.iter() { cells { cell at "Name" entry_name(item); cell at "Size" entry_size(item); activate to get frame.tree(&entry_path(frame, item)) navigating; } } } } /// How big the file is, and how long, in the strip's one sentence. fn size_line(file: &File<'_>) -> String { let count = file.lines.len(); if file.is_binary { file.file_size.to_owned() } else if count == 1 { format!("{} - 1 line", file.file_size) } else { format!("{} - {count} lines", file.file_size) } } declare! { /// The strip over a file: how big it is, and the other three ways to read /// it. shape file_header(frame: &Frame<'_>, file: &File<'_>) -> Node; let base = frame.base(); region "git-file-header" as Group { across Wrap { beside Secondary text size_line(file); beside Essential link "History" to get "{base}/log/{frame.current_ref}/{file.file_path}" navigating; beside Essential link "Blame" to get "{base}/blame/{frame.current_ref}/{file.file_path}" navigating; beside Essential link "Raw" to get "{base}/raw/{frame.current_ref}/{file.file_path}" navigating; } } } declare! { /// A file nothing can usefully draw, and the way to get it anyway. shape binary(frame: &Frame<'_>, file: &File<'_>) -> Node; let base = frame.base(); region "git-binary-notice" as Group { across Wrap { beside Essential text "Binary file ({file.file_size})."; beside Essential link "Download" to get "{base}/raw/{frame.current_ref}/{file.file_path}" navigating; } } } /// One line of the file, numbered. /// /// A supplier because the number comes off `enumerate` and one more than an /// index is arithmetic, which the form admits none of. `Line` is not a /// vocabulary type, so this stays out of the population. struct Line { /// Its number, from one. number: usize, /// What the lexer made of it. runs: Vec, } /// The file's lines, each knowing which one it is. fn numbered(file: &File<'_>) -> Vec { file.lines .iter() .enumerate() .map(|(at, runs)| Line { number: at + 1, runs: runs.clone(), }) .collect() } declare! { /// The file, one row per line. /// /// A table rather than a block `Node::Code`, because the line numbers are /// what a reader links to: `#L42` is the address of a line, and a block /// that owned its own lines would have nothing to hang one on. /// /// Two columns, two cells, both written here: the row stays positional /// because the headings it answers to are five lines above it. shape source(file: &File<'_>) -> Node; table { column "Line" { width Content; priority Secondary; } column "Code" { width Fill; priority Essential; } for line in numbered(file) { cells { // `#L42`, which is what the link below points at and what // `page-git-file.js` scrolls to. `addressed` and not // `identified`: the first is a document address and the second // is the app's own name for the row, and this wrote the second // for as long as the vocabulary had no way to say the first. addressed "L{line.number}"; // The number is a link to itself, which is how a reader gets // the address of a line into their clipboard. cell "" { link "{line.number}" to get "#L{line.number}"; } cell "" { code line.runs file.language.map(str::to_owned); } } } } } /// The document a browse page is drawn in. /// /// One function for both, because the shell is the same: the two screens differ /// in their body and in nothing else. fn drawn(viewer: Option<&crate::auth::SessionUser>, csrf: &str) -> Webview { Webview::new().with_shell(super::document_shell(csrf).with_body_first(format!( "{}{}", crate::shell::skip_link(PAGE_REGION), crate::shell::site_header(viewer), ))) } /// The line-anchor script, which only the file view wants. /// /// A row identity is not an element id -- quasi-webview emits it as /// `data-value`, since two tables of the same things on one screen is an /// ordinary description and two elements under one id is not -- so the browser /// does not scroll to `#L42` by itself and this does. const LINE_ANCHORS: &str = concat!( "" ); /// The directory listing, as a document. #[must_use] pub fn tree_document( viewer: Option<&crate::auth::SessionUser>, csrf: &str, frame: &Frame<'_>, tree: &Tree<'_>, ) -> String { use quasi_axum::Serves as _; drawn(viewer, csrf).screen(&tree_screen(frame, tree)) } /// The file view, as a document, with every handover paid. #[must_use] pub fn file_document( viewer: Option<&crate::auth::SessionUser>, csrf: &str, frame: &Frame<'_>, file: &File<'_>, ) -> 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), )) .with_head(LINE_ANCHORS), ); webview = super::widgets::git_notes::fill(webview, file.notes); webview.screen(&file_screen(frame, file)) } #[cfg(test)] mod tests { use super::*; use makeover_layout::Syntax; fn frame<'a>(path: &'a str, crumbs: &'a [Breadcrumb]) -> Frame<'a> { Frame { owner: "ada", repo: "engine", current_ref: "main", path, breadcrumbs: crumbs, refs: &[], open_issue_count: 0, is_owner: false, } } fn items() -> Vec { vec![ TreeItem { name: "src".into(), kind: TreeItemKind::Dir, size: None, }, TreeItem { name: "README.md".into(), kind: TreeItemKind::File, size: Some(1024), }, ] } fn rendered_tree(frame: &Frame<'_>, tree: &Tree<'_>) -> String { use quasi_axum::Serves as _; Webview::new().screen(&tree_screen(frame, tree)) } fn rendered_file(frame: &Frame<'_>, file: &File<'_>) -> String { use quasi_axum::Serves as _; Webview::new().screen(&file_screen(frame, file)) } /// A directory is a row that opens it, and it says it is one with the slash /// the shipped table put on the name. #[test] fn every_entry_is_a_row_that_opens_it() { let html = rendered_tree( &frame("", &[]), &Tree { items: &items(), parent: None, }, ); assert!(html.contains("src/"), "{html}"); assert!(html.contains("/git/ada/engine/tree/main/src"), "{html}"); assert!( html.contains("/git/ada/engine/tree/main/README.md"), "{html}" ); assert!(html.contains("1.0 KB"), "{html}"); } /// A subdirectory's entries are addressed under it rather than under the /// root, which is the one thing the shipped template's `in_subdir` guard /// was for. #[test] fn a_subdirectory_addresses_its_entries_under_itself() { let html = rendered_tree( &frame("src", &[]), &Tree { items: &items(), parent: Some(""), }, ); assert!( html.contains("/git/ada/engine/tree/main/src/README.md"), "{html}" ); } /// There is no up from the root: `..` there would be a control that goes /// where the reader already is. #[test] fn the_root_offers_no_way_up() { let root = rendered_tree( &frame("", &[]), &Tree { items: &items(), parent: None, }, ); assert!(!root.contains(".."), "{root}"); let sub = rendered_tree( &frame("src", &[]), &Tree { items: &items(), parent: Some(""), }, ); assert!(sub.contains(".."), "{sub}"); } /// The classification reaches the document, and a plain run stays bare text /// rather than becoming a span that says "ordinary". #[test] fn a_classified_line_arrives_whole() { let lines = vec![vec![ Lexeme::new("fn", Syntax::Keyword), Lexeme::plain(" main() {}"), ]]; let html = rendered_file( &frame("src/main.rs", &[]), &File { filename: "main.rs", file_path: "src/main.rs", file_size: "12 B", lines: &lines, language: Some("rs"), is_binary: false, notes: &[], }, ); assert!(html.contains("lex-keyword"), "{html}"); assert!(html.contains(" main() {}"), "{html}"); } /// Every line is addressable, which is what `#L42` is and what the file /// view would lose by numbering rows and naming none of them. #[test] fn every_line_names_itself() { let lines = vec![vec![Lexeme::plain("one")], vec![Lexeme::plain("two")]]; let html = rendered_file( &frame("src/main.rs", &[]), &File { filename: "main.rs", file_path: "src/main.rs", file_size: "8 B", lines: &lines, language: None, is_binary: false, notes: &[], }, ); // `id`, not `data-value`. The link beside each number points at `#L2`, // and only an id is what `#L2` reaches -- `Row::identified` is the // app's own name for a row and reaches the document as `data-value`, // which is what this asserted while every anchor pointed at nothing. assert!(html.contains("id=\"L1\""), "{html}"); assert!(html.contains("id=\"L2\""), "{html}"); assert!(html.contains("href=\"#L2\""), "{html}"); } /// A blank line renders rather than vanishing: a file view that dropped one /// would renumber every line under it. #[test] fn a_blank_line_still_takes_a_row() { let lines = vec![ vec![Lexeme::plain("one")], Vec::new(), vec![Lexeme::plain("three")], ]; let html = rendered_file( &frame("src/main.rs", &[]), &File { filename: "main.rs", file_path: "src/main.rs", file_size: "10 B", lines: &lines, language: None, is_binary: false, notes: &[], }, ); assert!(html.contains("id=\"L3\""), "{html}"); assert!(html.contains(">three<"), "{html}"); } /// Source is reader input by the time it reaches here, and it goes into a /// document. The renderer escapes it; this is the test that says so. #[test] fn source_cannot_smuggle_markup() { let lines = vec![vec![Lexeme::plain("")]]; let html = rendered_file( &frame("src/main.rs", &[]), &File { filename: "main.rs", file_path: "src/main.rs", file_size: "25 B", lines: &lines, language: None, is_binary: false, notes: &[], }, ); assert!(!html.contains("