//! One person's repository listing at `/git/{owner}`, described. //! //! The seventh public document, and the first behind a rate limiter. It //! replaces `templates/pages/git/repos.html`, `GitUserReposTemplate` and //! `browsing::user_repos`. //! //! # The limiter goes on the mount, at the call site //! //! The git browse tree carries `route_layer(GovernorLayer::new(browse_rate_limit))` //! over every read, and a described document that took the address without it //! would quietly remove a per-IP cap from a route that walks bare repositories //! on disk. [`super::public_document_mount`] returns an `axum::Router`, so the //! layer is one call at the registration site and needs no new parameter -- see //! [`super::public_document_mounts`], which rebuilds the same limiter from the //! same constants. //! //! That was the whole of the supposed blocker. Recorded because it reads as an //! architectural limit and is a line of wiring. //! //! # What the owner sees that a visitor does not //! //! Two things, and both come off the same `is_owner` comparison the shipped //! handler made: private repositories are listed at all, and each one carries //! its visibility. A visitor gets the public set with no badges, because a badge //! saying `public` on every row of a list that contains nothing else is noise. //! //! The empty state differs too. An owner with no repositories is shown how to //! push one; a visitor looking at an empty account is told there is nothing //! here, because the push instructions are not theirs to act on. use makeover_layout as layout; use quasi_declare::declare; use quasi_router::screen::Tag; use quasi_router::{Document, Request, Response, RouteError}; use quasi_webview::Webview; use crate::db; /// The address, registered whole. See [`super::public_document_mount`]. pub const PATH: &str = "/git/{owner}"; /// The page's own region, and what the skip link points at. pub const PAGE_REGION: &str = "git-repos"; const MEASURE: layout::Measure = layout::Measure::Wide; /// The region an account with nothing published is drawn in. const EMPTY: &str = "git-repos-empty"; /// Everything the screen draws, resolved before it is drawn. pub(crate) struct Loaded { owner: String, is_owner: bool, repos: Vec, } /// One repository, as the listing shows it. pub(crate) struct Repo { name: String, description: String, /// `None` unless the reader owns the account and the repository is not /// public. See the module header. visibility: Option, } impl Repo { /// What the visibility cell says, or the empty string. /// /// The cell is guarded on the `Option` and this reads it, because a /// description reads a value and does not bind one out of a pattern. fn visibility_label(&self) -> &str { self.visibility.as_deref().unwrap_or_default() } } /// The page. pub fn screen(viewer: &super::Viewer, request: Request) -> Result { // Moved out because the handler signature is quasi's: the request is // consumed here rather than borrowed from. let captures = request.captures; let owner = captures .get("owner") .ok_or_else(|| RouteError::not_found("no such account"))?; let loaded = load(viewer, owner)?; Ok(page_screen(&loaded).into()) } /// The one read this page makes, for the mount that serves it from a residual. pub(crate) fn reading( viewer: &super::Viewer, carried: &super::Carried, ) -> Result { load(viewer, carried.capture("owner")?) } /// Resolve the account and the repositories this reader may see. fn load(viewer: &super::Viewer, owner: &str) -> Result { let missing = || RouteError::not_found("no such account"); let username = db::Username::new(owner).map_err(|_| missing())?; let db_user = viewer .block_on(db::users::get_user_by_username(&viewer.app.db, &username)) .map_err(|_| RouteError::internal("that account could not be read"))? .ok_or_else(missing)?; let is_owner = viewer.user.as_ref().is_some_and(|u| u.id == db_user.id); // The owner sees everything; everybody else sees what has been published. // Two queries rather than one filtered in Rust, which is what shipped: the // visibility rule belongs in the statement, where it cannot be forgotten. let repos = viewer .block_on(async { if is_owner { db::git_repos::get_repos_by_user(&viewer.app.db, db_user.id).await } else { db::git_repos::get_public_repos_by_user(&viewer.app.db, db_user.id).await } }) .map_err(|_| RouteError::internal("those repositories could not be read"))?; Ok(Loaded { owner: db_user.username.to_string(), is_owner, repos: repos .iter() .map(|repo| Repo { name: repo.name.clone(), description: repo.description.clone(), visibility: (is_owner && repo.visibility != db::Visibility::Public) .then(|| repo.visibility.to_string()), }) .collect(), }) } declare! { /// The whole document: the title, the measure, the body. pub(crate) shape page_screen(loaded: &Loaded) -> Screen; let heading = "{loaded.owner}'s Repositories"; screen single "{heading} - Git - Makenotwork" { measured MEASURE; documented Document::default().classed(crate::shell::body_class(MEASURE, &[])); 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 "{loaded.owner}'s Repositories"; include empty(loaded.is_owner, &loaded.owner) when loaded.repos.is_empty(); include owner_listing(loaded) when loaded.is_owner and not loaded.repos.is_empty(); include listing(loaded) unless loaded.is_owner or loaded.repos.is_empty(); } } declare! { /// An account with nothing published. /// /// The owner gets the two commands that fix it; a visitor gets the fact. /// The shipped template made the same split and it is worth keeping: `git /// remote add` is not advice a stranger can take. /// /// # The prose is written here rather than passed to `own_prose` /// /// `own_prose` takes the markdown **source** as one parameter, so a caller /// that formats the string first hands a staged shape one sentinel where /// the whole document should be: the derivation renders `

ZQH...HQZ

` /// and the filler writes the request's markdown into that paragraph /// unparsed. See [`super::own_prose`], where the rule is. /// /// Said here, the account's name is a hole **inside** the source, so the /// derivation parses the fence with a sentinel in it and the residual holds /// docengine's `
` around a gap. Same markup as before, and the
    /// two lines that are `own_prose`'s body are inlined with it.
    #[staged]
    shape empty(is_owner: bool, owner: &str) -> Slot;

    region EMPTY as Group {
        empty "No repositories yet.";
        rich "Push a new repository:\n\n```\ngit remote add origin \
              https://makenot.work/git/{owner}/my-repo.git\ngit push -u origin main\n```"
            when is_owner
        {
            trust quasi_router::Trust::Trusted;
        }
    }
}

declare! {
    /// The repositories, as a table, in the shape this reader is owed.
    ///
    /// The template drew a `
    ` of two-line entries. As a table the /// description says which column carries the identity and which can be /// dropped on a narrow viewport, rather than leaving a stack of divs to wrap /// however it wraps. /// /// Nobody but the owner is shown a visibility column, because for everybody /// else every row in it would say the same word. /// /// # One question, asked once, and the seam is what insisted /// /// This was one table with a guarded `Visibility` column and a guarded /// visibility cell, both reading ownership. That is two conditionals that /// have to agree, and the module used to note that nothing checked they /// did. Something does now: **a derivation renders combinations the screen /// cannot produce.** It varies one guard at a time to read each span, so it /// renders the column absent with the cell present, and a cell naming a /// column that is not there is a cell silently lost -- which the renderer /// panics on rather than dropping. /// /// So the rule the seam imposes is that a screen's guards are independent, /// and two that must agree are one question written twice. Here the /// question is ownership and it is asked in [`page_region`], which picks /// the table rather than patching one. The two column lists repeat two /// lines and buy back a coupling nothing was holding. #[staged] shape owner_listing(loaded: &Loaded) -> Node; table { column "Repository" { width Content; priority Essential; } column "Description" { width Fill; } column "Visibility" { width Content; } for repo in loaded.repos.iter() { cells { cell at "Repository" repo.name.clone(); cell at "Description" repo.description.clone(); // Absent for a public repository, which is the owner's common // case and the reason the column is theirs alone. The guard is // on the cell rather than on the token because a token is a // setting on the cell, and a staged shape cannot guard one: a // setting renders in its container's opening tag, so the branch // it makes is not where the declaration wrote it. cell at "Visibility" "" when repo.visibility.is_some() { token Tag::badge(repo.visibility_label()); } activate to get "/git/{loaded.owner}/{repo.name}" navigating; } } } } declare! { /// The same listing for everybody else, which is the public set with no /// visibility to report. See [`owner_listing`] for why this is a second /// shape rather than a guard. #[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 at "Repository" repo.name.clone(); cell at "Description" repo.description.clone(); activate to get "/git/{loaded.owner}/{repo.name}" navigating; } } } } /// 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()), ))) } /// An account as the tests draw it, with `count` repositories. /// /// 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 sample(count: usize, is_owner: bool) -> Loaded { Loaded { owner: "ada".into(), is_owner, repos: (0..count) .map(|n| Repo { name: format!("repo{n}"), description: format!("Number {n}"), visibility: (is_owner && n == 0).then(|| "private".to_string()), }) .collect(), } } #[cfg(test)] mod tests { use super::*; use super::sample as loaded; fn html(loaded: &Loaded) -> String { use quasi_axum::Serves as _; Webview::new().screen(&page_screen(loaded)) } /// `2790e5c4`. This template carried the measure alone, so the slice is /// empty and the class is just the measure. #[test] fn the_document_carries_the_class_the_template_carried() { let screen = page_screen(&loaded(1, false)); assert_eq!(screen.document.body_class.as_deref(), Some("padded-page")); let rendered = html(&loaded(1, false)); assert!(rendered.contains("class=\"padded-page\""), "{rendered}"); } /// The title names whose repositories these are, as the template's did. #[test] fn the_document_is_titled_for_the_account() { let screen = page_screen(&loaded(1, false)); assert_eq!(screen.title, "ada's Repositories - Git - Makenotwork"); } /// Every repository is a row that opens it. #[test] fn every_repository_is_a_row_that_opens_it() { let html = html(&loaded(3, false)); for n in 0..3 { assert!(html.contains(&format!("repo{n}")), "{html}"); assert!(html.contains(&format!("/git/ada/repo{n}")), "{html}"); } } /// A visitor gets no visibility column, because every row of it would say /// the same word. #[test] fn a_visitor_is_shown_no_visibility_column() { assert!(!html(&loaded(2, false)).contains("Visibility")); assert!(html(&loaded(2, true)).contains("Visibility")); } /// The owner's private repository is marked; their public one is not. #[test] fn the_owner_sees_which_of_their_repositories_are_not_public() { let html = html(&loaded(2, true)); assert!(html.contains("private"), "{html}"); } /// The push instructions are the owner's. A stranger looking at an empty /// account is told the fact and not given a command they cannot run. #[test] fn only_the_owner_is_told_how_to_push() { let owner = html(&loaded(0, true)); let visitor = html(&loaded(0, false)); assert!(owner.contains("git remote add origin"), "{owner}"); assert!( owner.contains("makenot.work/git/ada/my-repo.git"), "{owner}" ); assert!(!visitor.contains("git remote add"), "{visitor}"); assert!(visitor.contains("No repositories yet"), "{visitor}"); } /// `736f45a5`: this screen's markup carries none of the four spellings. #[test] fn the_page_spells_no_spinner() { let html = html(&loaded(2, true)); for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] { assert!(!html.contains(spelling), "{spelling} survives in {html}"); } } }