//! The git landing page at `/git`, described. //! //! The eighth public document. It replaces `templates/pages/git/explore.html`, //! `GitExploreTemplate` and `browsing::git_landing`. //! //! Converted in the same pass as [`super::git_repos`] because the two listings //! shared a stylesheet block: neither page's CSS could go until both had left, //! and leaving one behind means keeping rules alive for a single caller. //! //! # The paging is prev/next and always was //! //! `Rest` gives `forward` and `back` for free and numbered pages only when a //! screen calls `jumping`. `/feed` needed the numbers and this page never had //! them -- the template drew `Newer` and `Older` and nothing else -- so this is //! the case `Rest` fits without argument. `has_more` is the one fact the query //! goes one row over the limit to learn, kept exactly. //! //! `total_count` is not carried over. The handler read it with a second //! `COUNT(*)` over every public repository and the template never rendered it, //! so the conversion drops a query rather than a feature. //! //! # Two paragraphs that are the page's reason for existing //! //! The notes sentence and the annotations link are the only place this browser //! explains what it does that another forge does not, and the only route to an //! annotation whose repository is gone. Both were template comments explaining //! themselves; both are carried here, because a conversion that keeps the //! markup and drops the reason leaves the next reader to rediscover it. use makeover_layout as layout; use quasi_router::screen::{Cell, Cells, Column, Rest}; use quasi_router::{ Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot, }; use quasi_webview::Webview; use crate::{constants, db}; /// The address, registered whole. See [`super::public_document_mount`]. pub const PATH: &str = "/git"; /// The page's own region, and what the skip link points at. pub const PAGE_REGION: &str = "git-explore"; const MEASURE: layout::Measure = layout::Measure::Wide; /// Everything the screen draws, resolved before it is drawn. struct Loaded { repos: Vec, page: usize, has_more: bool, /// Whether to offer the reader their own annotations. See the module header. signed_in: bool, } /// One repository in the listing. struct Repo { owner: String, name: String, description: String, } /// The page. pub fn screen(viewer: &super::Viewer, request: Request) -> Result { // Moved out of the request rather than borrowed: the signature is quasi's. let carried = request.carried; // Clamped exactly as the shipped handler clamped it: a page number out of a // query string is reader input, and the offset it becomes is multiplied. let page = carried .get("page") .and_then(|value| value.trim().parse::().ok()) .unwrap_or(1) .clamp(1, 10_000); let loaded = load(viewer, page)?; Ok(page_screen(&loaded).into()) } /// Read one page of public repositories, plus one row to learn whether there is /// another page. fn load(viewer: &super::Viewer, page: usize) -> Result { let limit = constants::GIT_REPOS_PER_PAGE; let offset = (page - 1).saturating_mul(limit); let repos = viewer .block_on(db::git_repos::get_all_public_repos( &viewer.app.db, (limit + 1) as i64, offset as i64, )) .map_err(|_| RouteError::internal("those repositories could not be read"))?; let has_more = repos.len() > limit; Ok(Loaded { repos: repos .into_iter() .take(limit) .map(|repo| Repo { owner: repo.owner_username, name: repo.name, description: repo.description, }) .collect(), page, has_more, signed_in: viewer.user.is_some(), }) } /// The whole document: the title, the measure, the body. fn page_screen(loaded: &Loaded) -> Described { let mut page = Slot::new(PAGE_REGION, RegionKind::Pane) .with(Node::page("Repositories")) // Notes are the one thing this browser does that no other forge does, // and nothing on a repository page says so to somebody who has never // seen one. The landing page is where that sentence reaches everybody. .with(super::own_prose( "Every repository here renders [git notes](/docs/git-notes): annotation attached \ to a commit without rewriting it, stored in the repository and carried by a clone.", )); // The only route to an annotation whose target repository is gone: nothing // else links to it once there is no commit page to link from. if loaded.signed_in { page = page.with(super::own_prose( "[Your annotations](/git/my-annotations), private to you, across every repository \ you have read here.", )); } page = if loaded.repos.is_empty() { page.with(Node::empty("No public repositories yet.")) } else { page.with(listing(loaded)) }; Described::single("Repositories - Git - Makenotwork") .measured(MEASURE) .documented(Document::default().classed(crate::shell::body_class(MEASURE, &[]))) .summarised("Public repositories on Makenotwork, with git notes rendered on every commit.") .with(page) } /// The repositories, as a table, with whatever pages remain. fn listing(loaded: &Loaded) -> Node { Node::Table { columns: vec![ Column::new("Repository") .width(layout::Width::Content) .priority(layout::Priority::Essential), Column::new("Description").width(layout::Width::Fill), ], rows: loaded .repos .iter() .map(|repo| { Cells::new([ Cell::new(format!("{}/{}", repo.owner, repo.name)), Cell::new(repo.description.clone()), ]) .activate(Action::get(format!("/git/{}/{}", repo.owner, repo.name)).navigating()) }) .collect(), more: rest(loaded), } } /// What the reader has not been shown, when there is any. /// /// Prev/next only. The template drew `Newer` and `Older` and no numbers, and /// `Rest` draws numbers only for a screen that calls `jumping`, so this is a /// parity conversion rather than a reduction. fn rest(loaded: &Loaded) -> Option { let per = constants::GIT_REPOS_PER_PAGE; let from = (loaded.page - 1) * per; if loaded.page == 1 && !loaded.has_more { return None; } let mut rest = Rest::page(from, per); if loaded.page > 1 { rest = rest.back(Action::get(format!("{PATH}?page={}", loaded.page - 1)).navigating()); } if loaded.has_more { rest = rest.forward(Action::get(format!("{PATH}?page={}", loaded.page + 1)).navigating()); } Some(rest) } /// The document this screen is drawn in. #[must_use] pub fn renderer(viewer: &super::Viewer) -> Webview { Webview::new().with_shell(viewer.document_shell().with_body_first(format!( "{}{}", crate::shell::skip_link(PAGE_REGION), crate::shell::site_header(viewer.user.as_ref()), ))) } #[cfg(test)] mod tests { use super::*; fn loaded(count: usize, page: usize, has_more: bool, signed_in: bool) -> Loaded { Loaded { repos: (0..count) .map(|n| Repo { owner: "ada".into(), name: format!("repo{n}"), description: format!("Number {n}"), }) .collect(), page, has_more, signed_in, } } fn html(loaded: &Loaded) -> String { use quasi_axum::Serves as _; Webview::new().screen(&page_screen(loaded)) } /// `2790e5c4`. The template carried the measure alone. #[test] fn the_document_carries_the_class_the_template_carried() { let screen = page_screen(&loaded(1, 1, false, false)); assert_eq!(screen.document.body_class.as_deref(), Some("padded-page")); let rendered = html(&loaded(1, 1, false, false)); assert!(rendered.contains("class=\"padded-page\""), "{rendered}"); } /// Each row says `owner/name` and opens that repository. #[test] fn every_repository_is_a_row_that_opens_it() { let html = html(&loaded(2, 1, false, false)); assert!(html.contains("ada/repo0"), "{html}"); assert!(html.contains("/git/ada/repo1"), "{html}"); } /// The notes sentence is the page's reason for existing and reaches /// everybody, signed in or not. #[test] fn the_notes_explanation_is_always_shown() { for signed_in in [true, false] { let html = html(&loaded(1, 1, false, signed_in)); assert!(html.contains("/docs/git-notes"), "{html}"); } } /// The annotations link is the only route to an annotation whose repository /// is gone, and it is only useful to somebody with a session. #[test] fn only_a_signed_in_reader_is_offered_their_annotations() { assert!(html(&loaded(1, 1, false, true)).contains("/git/my-annotations")); assert!(!html(&loaded(1, 1, false, false)).contains("/git/my-annotations")); } /// A single page of results offers no paging at all, rather than two /// disabled controls. #[test] fn one_page_of_repositories_has_no_rest() { assert!(rest(&loaded(3, 1, false, false)).is_none()); } /// Older on the first page, both on a middle page, Newer on the last. #[test] fn the_pager_offers_only_the_directions_that_exist() { let first = html(&loaded(3, 1, true, false)); assert!(first.contains("page=2"), "{first}"); assert!(!first.contains("page=0"), "{first}"); let middle = html(&loaded(3, 2, true, false)); assert!( middle.contains("page=1") && middle.contains("page=3"), "{middle}" ); let last = html(&loaded(3, 4, false, false)); assert!(last.contains("page=3"), "{last}"); assert!(!last.contains("page=5"), "{last}"); } /// `736f45a5`: this screen's markup carries none of the four spellings. #[test] fn the_page_spells_no_spinner() { let html = html(&loaded(2, 1, true, true)); for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] { assert!(!html.contains(spelling), "{spelling} survives in {html}"); } } }