//! 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_declare::declare; use quasi_router::screen::Rest; use quasi_router::{Action, Document, RouteError}; 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. pub(crate) struct Loaded { repos: Vec, page: usize, has_more: bool, /// Whether to offer the reader their own annotations. See the module header. signed_in: bool, } impl Loaded { /// Where this page starts, which is what a `Rest` counts from. /// /// A supplier because the declared form has no arithmetic, and it is the /// same sum [`load`] makes to ask the database. fn offset(&self) -> usize { (self.page - 1).saturating_mul(constants::GIT_REPOS_PER_PAGE) } /// The page back, and the page on. Suppliers for [`offset`](Self::offset)'s /// reason. fn previous(&self) -> usize { self.page.saturating_sub(1) } fn next(&self) -> usize { self.page + 1 } } /// One repository in the listing. struct Repo { owner: String, name: String, description: String, } /// The one read this page makes, for the mount that serves it from a residual. /// /// The clamp is the shipped handler's, unchanged: a page number out of a query /// string is reader input, and the offset it becomes is multiplied. pub(crate) fn reading( viewer: &super::Viewer, carried: &super::Carried, ) -> Result { let page = carried .asked("page") .and_then(|value| value.trim().parse::().ok()) .unwrap_or(1) .clamp(1, 10_000); load(viewer, page) } /// 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(), }) } declare! { /// The whole document: the title, the measure, the body. pub(crate) shape page_screen(loaded: &Loaded) -> Screen; screen 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."; include page_region(loaded); } } declare! { /// The page's one region, split out so it can be staged. #[staged] pub(crate) shape page_region(loaded: &Loaded) -> Slot; region PAGE_REGION as Pane { 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. include 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. include super::own_prose( "[Your annotations](/git/my-annotations), private to you, across every repository \ you have read here." ) when loaded.signed_in; empty "No public repositories yet." when loaded.repos.is_empty(); include listing(loaded) unless loaded.repos.is_empty(); } } declare! { /// The repositories, as a table, with whatever pages remain. /// /// Two columns, two cells, all four written here, so the row stays /// positional: naming would buy nothing a reader cannot already check by /// looking up four lines. #[staged] shape listing(loaded: &Loaded) -> Node; table { column "Repository" { width Content; priority Essential; } column "Description" { width Fill; } for repo in loaded.repos.iter() { cells { cell "{repo.owner}/{repo.name}"; cell repo.description.clone(); activate to get "/git/{repo.owner}/{repo.name}" navigating; } } // One page of one is the whole listing, and the pager is what says so. // A table either shows what it has not shown or does not, and that is // what a guard is for. // // Described rather than supplied. A `Rest` has no sentinel and a // supplier of one takes a struct, so a pager handed over whole was a // hole the residual could not hold; everything it carries is a number // or an address, which are the two things a residual already has. // The directions are written back-then-forward because that is the // order they draw in, which `quasi-declare` holds this to. // quasicoherent `cbb63155`. more Rest::page(loaded.offset(), constants::GIT_REPOS_PER_PAGE) { back Action::get("{PATH}?page={loaded.previous()}").navigating() when loaded.page over 1; forward Action::get("{PATH}?page={loaded.next()}").navigating() when loaded.has_more; } when loaded.page over 1 or loaded.has_more; } } /// One page of repositories as the tests draw it. /// /// Module-level rather than inside `mod tests` because `quasi::residuals` needs /// one too, and `Loaded` is this module's own type. Test-only. #[cfg(test)] pub(crate) 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, } } /// 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 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")); } /// Both headings, which moved from a hand-written `Table::new` list into the /// declaration. A column dropped on the way is silent. #[test] fn every_column_the_listing_had_is_still_named() { let html = html(&loaded(2, 1, false, false)); assert!(html.contains("Repository"), "{html}"); assert!(html.contains("Description"), "{html}"); } /// A single page of results offers no paging at all, rather than two /// disabled controls. #[test] fn one_page_of_repositories_has_no_rest() { let html = html(&loaded(3, 1, false, false)); assert!(!html.contains("page="), "{html}"); } /// 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}"); } } }