max / makenotwork
10 files changed,
+675 insertions,
-219 deletions
| @@ -6914,7 +6914,6 @@ | |||
| 6914 | 6914 | .git-note-namespaces a:hover { opacity: 1; } | |
| 6915 | 6915 | .git-note-namespaces a.is-selected { opacity: 1; text-decoration: underline; } | |
| 6916 | 6916 | .git-note-target-kind { opacity: 0.6; } | |
| 6917 | - | .git-explore-intro { margin: 0 0 var(--gap-section); font-size: var(--text-note); opacity: 0.8; } | |
| 6918 | 6917 | .git-notes-fetch { padding-bottom: var(--gap-section); font-size: var(--text-note); } | |
| 6919 | 6918 | .git-notes-fetch p { margin: 0 0 var(--gap-peer); } | |
| 6920 | 6919 | .git-note-feed-cap { font-size: var(--text-fine); opacity: 0.6; padding-top: var(--gap-section); } | |
| @@ -7004,7 +7003,6 @@ | |||
| 7004 | 7003 | .git-annotation-namespace, | |
| 7005 | 7004 | .git-annotation-when { font-family: var(--font-mono); color: var(--content-muted); } | |
| 7006 | 7005 | .git-annotation-orphan { font-size: var(--text-note); color: var(--content-muted); } | |
| 7007 | - | .git-explore-mine { font-size: var(--text-note); } | |
| 7008 | 7006 | .git-diff-stats { | |
| 7009 | 7007 | font-size: var(--text-note); | |
| 7010 | 7008 | padding: var(--gap-section) 0; | |
| @@ -7136,28 +7134,12 @@ | |||
| 7136 | 7134 | } | |
| 7137 | 7135 | .git-blame-boundary td { border-top: 1px solid var(--border); } | |
| 7138 | 7136 | ||
| 7139 | - | /* Git user repos listing */ | |
| 7140 | - | .git-repos-list { list-style: none; } | |
| 7141 | - | .git-repos-item { padding: var(--gap-section) 0; border-bottom: 1px solid var(--border); } | |
| 7142 | - | .git-repos-item:last-child { border-bottom: none; } | |
| 7143 | - | .git-repos-item-header { display: flex; align-items: center; gap: var(--gap-peer); } | |
| 7144 | - | .git-repos-item-name { | |
| 7145 | - | font-size: var(--text-lead); | |
| 7146 | - | font-weight: 600; | |
| 7147 | - | color: var(--content); | |
| 7148 | - | text-decoration: none; | |
| 7149 | - | } | |
| 7150 | - | .git-repos-item-name:hover { text-decoration: underline; } | |
| 7151 | - | .git-repos-visibility { | |
| 7152 | - | font-size: var(--text-fine); | |
| 7153 | - | padding: var(--gap-bound) var(--gap-peer); | |
| 7154 | - | border: 1px solid var(--border); | |
| 7155 | - | border-radius: var(--radius-fine); | |
| 7156 | - | opacity: 0.5; | |
| 7157 | - | } | |
| 7158 | - | .git-repos-item-desc { font-size: var(--text-note); opacity: 0.6; margin: var(--gap-bound) 0 0; }.git-repos-instructions p { margin-bottom: var(--gap-peer); } | |
| 7159 | - | .git-repos-owner { opacity: 0.6; } | |
| 7160 | - | .git-repos-item-name .sep { opacity: 0.4; } | |
| 7137 | + | /* The two repository listings, /git and /git/{owner}, moved to the description | |
| 7138 | + | layer. Both were a <ul> of two-line entries with a visibility chip; both are | |
| 7139 | + | a `Node::Table` now, so which column carries the identity and which can be | |
| 7140 | + | dropped on a narrow viewport is stated rather than left to wrapping, and the | |
| 7141 | + | chip is a `Tag` on a cell. */ | |
| 7142 | + | ||
| 7161 | 7143 | .git-file-log-label { | |
| 7162 | 7144 | font-family: var(--font-mono); | |
| 7163 | 7145 | font-size: var(--text-fine); |
| @@ -51,6 +51,7 @@ | |||
| 51 | 51 | pub mod fan_plus; | |
| 52 | 52 | pub mod feeds; | |
| 53 | 53 | pub mod forum_memberships; | |
| 54 | + | pub mod git_repos; | |
| 54 | 55 | pub mod item_files; | |
| 55 | 56 | pub mod item_sales; | |
| 56 | 57 | pub mod item_tabs; | |
| @@ -397,6 +398,7 @@ | |||
| 397 | 398 | fan_plus::PATH, | |
| 398 | 399 | creators::PATH, | |
| 399 | 400 | collections::PATH, | |
| 401 | + | git_repos::PATH, | |
| 400 | 402 | ]; | |
| 401 | 403 | ||
| 402 | 404 | /// Every screen's switch name, in the same order as [`PATHS`]. | |
| @@ -604,6 +606,22 @@ | |||
| 604 | 606 | collections::renderer, | |
| 605 | 607 | ), | |
| 606 | 608 | ), | |
| 609 | + | // The git browse tree carries a per-IP cap on every read, and a | |
| 610 | + | // described document taking one of its addresses has to carry it too: | |
| 611 | + | // these routes walk bare repositories on disk. The mount is an | |
| 612 | + | // `axum::Router`, so the layer goes on here rather than through a | |
| 613 | + | // parameter, and the limiter is rebuilt from the same constants | |
| 614 | + | // `routes::git` reads. See `git_repos`'s module header. | |
| 615 | + | ( | |
| 616 | + | git_repos::PATH, | |
| 617 | + | public_document_mount(app, git_repos::PATH, git_repos::screen, git_repos::renderer) | |
| 618 | + | .layer(tower_governor::GovernorLayer::new( | |
| 619 | + | crate::helpers::rate_limiter_ms( | |
| 620 | + | crate::constants::GIT_BROWSE_RATE_LIMIT_MS, | |
| 621 | + | crate::constants::GIT_BROWSE_RATE_LIMIT_BURST, | |
| 622 | + | ), | |
| 623 | + | )), | |
| 624 | + | ), | |
| 607 | 625 | ] | |
| 608 | 626 | } | |
| 609 | 627 | ||
| @@ -620,6 +638,20 @@ | |||
| 620 | 638 | /// to be reachable by somebody who cannot sign in. A page that merely *reads* | |
| 621 | 639 | /// better when signed in is still gated; the test is whether refusing a visitor | |
| 622 | 640 | /// is the right answer. | |
| 641 | + | /// | |
| 642 | + | /// # Layers go on the returned router, at the call site | |
| 643 | + | /// | |
| 644 | + | /// This returns an `axum::Router`, so a mount that needs middleware takes it | |
| 645 | + | /// with `.layer(..)` where it is registered rather than through a parameter | |
| 646 | + | /// here. That matters because the middleware is per-address rather than per | |
| 647 | + | /// mount kind: the git browse addresses carry a per-IP cap and the marketing | |
| 648 | + | /// pages carry none, and threading an `Option<Layer>` through every call would | |
| 649 | + | /// make the mount know about a policy that belongs to the route. | |
| 650 | + | /// | |
| 651 | + | /// **Check what the address carried before moving it.** A route lifted out of a | |
| 652 | + | /// router with a `route_layer` silently loses that layer, and for the git tree | |
| 653 | + | /// that would be a rate limit removed from a route that walks repositories on | |
| 654 | + | /// disk. See `git_repos`. | |
| 623 | 655 | fn public_document_mount( | |
| 624 | 656 | app: &AppState, | |
| 625 | 657 | path: &'static str, | |
| @@ -860,6 +892,37 @@ | |||
| 860 | 892 | ); | |
| 861 | 893 | } | |
| 862 | 894 | ||
| 895 | + | /// Every git address carries the browse limiter, checked in the source | |
| 896 | + | /// because there is nothing to ask an `axum::Router` about afterwards. | |
| 897 | + | /// | |
| 898 | + | /// This is the check the whole `/git` conversion turns on. The tree's reads | |
| 899 | + | /// sit under one `route_layer` in `routes::git`, so an address lifted out of | |
| 900 | + | /// it and mounted here loses that layer silently: nothing fails to compile, | |
| 901 | + | /// no test fails, and a route that walks bare repositories on disk stops | |
| 902 | + | /// being capped. A conversion that forgets it fails here instead. | |
| 903 | + | #[test] | |
| 904 | + | fn every_described_git_address_keeps_the_browse_limiter() { | |
| 905 | + | let source = include_str!("mod.rs"); | |
| 906 | + | let mounted = source | |
| 907 | + | .split_once("pub fn public_document_mounts(") | |
| 908 | + | .expect("public_document_mounts exists") | |
| 909 | + | .1 | |
| 910 | + | .split_once("\n}") | |
| 911 | + | .expect("public_document_mounts ends") | |
| 912 | + | .0; | |
| 913 | + | ||
| 914 | + | let git_paths = PUBLIC_DOCUMENT_PATHS | |
| 915 | + | .iter() | |
| 916 | + | .filter(|path| path.starts_with("/git")) | |
| 917 | + | .count(); | |
| 918 | + | assert!(git_paths > 0, "no git document is mounted yet"); | |
| 919 | + | assert_eq!( | |
| 920 | + | mounted.matches("GIT_BROWSE_RATE_LIMIT_MS").count(), | |
| 921 | + | git_paths, | |
| 922 | + | "a described /git address is mounted without the browse limiter" | |
| 923 | + | ); | |
| 924 | + | } | |
| 925 | + | ||
| 863 | 926 | /// The two document lists differ by exactly one thing, and it is the one | |
| 864 | 927 | /// that matters: a gated document refuses a visitor, a public one renders to | |
| 865 | 928 | /// them. Nothing else about the mount changes, so a screen in the wrong list |
| @@ -282,8 +282,6 @@ | |||
| 282 | 282 | GitTagsTemplate, | |
| 283 | 283 | GitReplaceTemplate, | |
| 284 | 284 | GitBlameTemplate, | |
| 285 | - | GitUserReposTemplate, | |
| 286 | - | GitExploreTemplate, | |
| 287 | 285 | GitFileLogTemplate, | |
| 288 | 286 | // Git issues | |
| 289 | 287 | GitIssueListTemplate, |
| @@ -28,9 +28,9 @@ | |||
| 28 | 28 | git, | |
| 29 | 29 | helpers::get_csrf_token, | |
| 30 | 30 | templates::{ | |
| 31 | - | GitBlameTemplate, GitCommitDetailTemplate, GitCommitsTemplate, GitExploreTemplate, | |
| 32 | - | GitFileLogTemplate, GitFileTemplate, GitNotesTemplate, GitReplaceTemplate, GitRepoTemplate, | |
| 33 | - | GitTagsTemplate, GitTreeTemplate, GitUserReposTemplate, | |
| 31 | + | GitBlameTemplate, GitCommitDetailTemplate, GitCommitsTemplate, GitFileLogTemplate, | |
| 32 | + | GitFileTemplate, GitNotesTemplate, GitReplaceTemplate, GitRepoTemplate, GitTagsTemplate, | |
| 33 | + | GitTreeTemplate, | |
| 34 | 34 | }, | |
| 35 | 35 | }; | |
| 36 | 36 | ||
| @@ -925,73 +925,6 @@ | |||
| 925 | 925 | }) | |
| 926 | 926 | } | |
| 927 | 927 | ||
| 928 | - | /// `GET /git/{owner}`: user's repository listing. | |
| 929 | - | #[tracing::instrument(skip_all, name = "git::user_repos")] | |
| 930 | - | pub(super) async fn user_repos( | |
| 931 | - | State(db): State<PgPool>, | |
| 932 | - | session: Session, | |
| 933 | - | MaybeUserVerified(maybe_user): MaybeUserVerified, | |
| 934 | - | Path(owner): Path<String>, | |
| 935 | - | ) -> Result<impl IntoResponse> { | |
| 936 | - | let username = db::Username::new(&owner).map_err(|_| AppError::NotFound)?; | |
| 937 | - | let db_user = db::users::get_user_by_username(&db, &username) | |
| 938 | - | .await? | |
| 939 | - | .ok_or(AppError::NotFound)?; | |
| 940 | - | ||
| 941 | - | let is_owner = maybe_user.as_ref().map(|u| u.id) == Some(db_user.id); | |
| 942 | - | ||
| 943 | - | let repos = if is_owner { | |
| 944 | - | db::git_repos::get_repos_by_user(&db, db_user.id).await? | |
| 945 | - | } else { | |
| 946 | - | db::git_repos::get_public_repos_by_user(&db, db_user.id).await? | |
| 947 | - | }; | |
| 948 | - | ||
| 949 | - | let csrf_token = get_csrf_token(&session).await; | |
| 950 | - | ||
| 951 | - | Ok(GitUserReposTemplate { | |
| 952 | - | csrf_token, | |
| 953 | - | session_user: maybe_user, | |
| 954 | - | owner, | |
| 955 | - | repos, | |
| 956 | - | is_owner, | |
| 957 | - | }) | |
| 958 | - | } | |
| 959 | - | ||
| 960 | - | /// Query params for explore page pagination. | |
| 961 | - | #[derive(Deserialize)] | |
| 962 | - | pub(super) struct ExploreQuery { | |
| 963 | - | page: Option<usize>, | |
| 964 | - | } | |
| 965 | - | ||
| 966 | - | /// `GET /git`: public explore page listing all public repos. | |
| 967 | - | #[tracing::instrument(skip_all, name = "git::git_explore")] | |
| 968 | - | pub(super) async fn git_landing( | |
| 969 | - | State(db): State<PgPool>, | |
| 970 | - | session: Session, | |
| 971 | - | MaybeUserVerified(maybe_user): MaybeUserVerified, | |
| 972 | - | ValidatedQuery(query): ValidatedQuery<ExploreQuery>, | |
| 973 | - | ) -> Result<impl IntoResponse> { | |
| 974 | - | let page = query.page.unwrap_or(1).clamp(1, 10_000); | |
| 975 | - | let limit = constants::GIT_REPOS_PER_PAGE; | |
| 976 | - | let offset = (page - 1).saturating_mul(limit); | |
| 977 | - | ||
| 978 | - | let repos = db::git_repos::get_all_public_repos(&db, (limit + 1) as i64, offset as i64).await?; | |
| 979 | - | let has_more = repos.len() > limit; | |
| 980 | - | let repos: Vec<_> = repos.into_iter().take(limit).collect(); | |
| 981 | - | let total_count = db::git_repos::count_all_public_repos(&db).await?; | |
| 982 | - | ||
| 983 | - | let csrf_token = get_csrf_token(&session).await; | |
| 984 | - | ||
| 985 | - | Ok(GitExploreTemplate { | |
| 986 | - | csrf_token, | |
| 987 | - | session_user: maybe_user, | |
| 988 | - | repos, | |
| 989 | - | page, | |
| 990 | - | has_more, | |
| 991 | - | total_count, | |
| 992 | - | }) | |
| 993 | - | } | |
| 994 | - | ||
| 995 | 928 | /// `GET /git/{owner}/{repo}/log/{ref}/{*path}`: per-file commit history. | |
| 996 | 929 | #[tracing::instrument(skip_all, name = "git::file_log")] | |
| 997 | 930 | pub(super) async fn file_log( |
| @@ -86,8 +86,10 @@ | |||
| 86 | 86 | "/git/my-annotations", | |
| 87 | 87 | get(annotations_view::my_annotations_page), | |
| 88 | 88 | ) | |
| 89 | - | .route("/git/{owner}", get(browsing::user_repos)) | |
| 90 | - | .route("/git", get(browsing::git_landing)) | |
| 89 | + | // `/git/{owner}` is described (`quasi::git_repos`) and registered in | |
| 90 | + | // the CSRF tree with this router's rate limiter on its own mount. | |
| 91 | + | // `/git` is described (`quasi::git_explore`), mounted with this | |
| 92 | + | // router's rate limiter beside `/git/{owner}`. | |
| 91 | 93 | // Smart HTTP clone (repo param includes ".git" suffix, stripped in handlers) | |
| 92 | 94 | .route( | |
| 93 | 95 | "/git/{owner}/{repo}/info/refs", |
| @@ -285,29 +285,6 @@ | |||
| 285 | 285 | pub active_tab: &'static str, | |
| 286 | 286 | } | |
| 287 | 287 | ||
| 288 | - | /// User's repository listing page. | |
| 289 | - | #[derive(Template)] | |
| 290 | - | #[template(path = "pages/git/repos.html")] | |
| 291 | - | pub struct GitUserReposTemplate { | |
| 292 | - | pub csrf_token: CsrfTokenOption, | |
| 293 | - | pub session_user: Option<SessionUser>, | |
| 294 | - | pub owner: String, | |
| 295 | - | pub repos: Vec<crate::db::DbGitRepo>, | |
| 296 | - | pub is_owner: bool, | |
| 297 | - | } | |
| 298 | - | ||
| 299 | - | /// Public explore page listing all public repos across all users. | |
| 300 | - | #[derive(Template)] | |
| 301 | - | #[template(path = "pages/git/explore.html")] | |
| 302 | - | pub struct GitExploreTemplate { | |
| 303 | - | pub csrf_token: CsrfTokenOption, | |
| 304 | - | pub session_user: Option<SessionUser>, | |
| 305 | - | pub repos: Vec<crate::db::git_repos::PublicRepoWithOwner>, | |
| 306 | - | pub page: usize, | |
| 307 | - | pub has_more: bool, | |
| 308 | - | pub total_count: i64, | |
| 309 | - | } | |
| 310 | - | ||
| 311 | 288 | /// Per-file commit history with breadcrumb context. | |
| 312 | 289 | #[derive(Template)] | |
| 313 | 290 | #[template(path = "pages/git/file_log.html")] |
| @@ -1,0 +1,295 @@ | |||
| 1 | + | //! The git landing page at `/git`, described. | |
| 2 | + | //! | |
| 3 | + | //! The eighth public document. It replaces `templates/pages/git/explore.html`, | |
| 4 | + | //! `GitExploreTemplate` and `browsing::git_landing`. | |
| 5 | + | //! | |
| 6 | + | //! Converted in the same pass as [`super::git_repos`] because the two listings | |
| 7 | + | //! shared a stylesheet block: neither page's CSS could go until both had left, | |
| 8 | + | //! and leaving one behind means keeping rules alive for a single caller. | |
| 9 | + | //! | |
| 10 | + | //! # The paging is prev/next and always was | |
| 11 | + | //! | |
| 12 | + | //! `Rest` gives `forward` and `back` for free and numbered pages only when a | |
| 13 | + | //! screen calls `jumping`. `/feed` needed the numbers and this page never had | |
| 14 | + | //! them -- the template drew `Newer` and `Older` and nothing else -- so this is | |
| 15 | + | //! the case `Rest` fits without argument. `has_more` is the one fact the query | |
| 16 | + | //! goes one row over the limit to learn, kept exactly. | |
| 17 | + | //! | |
| 18 | + | //! `total_count` is not carried over. The handler read it with a second | |
| 19 | + | //! `COUNT(*)` over every public repository and the template never rendered it, | |
| 20 | + | //! so the conversion drops a query rather than a feature. | |
| 21 | + | //! | |
| 22 | + | //! # Two paragraphs that are the page's reason for existing | |
| 23 | + | //! | |
| 24 | + | //! The notes sentence and the annotations link are the only place this browser | |
| 25 | + | //! explains what it does that another forge does not, and the only route to an | |
| 26 | + | //! annotation whose repository is gone. Both were template comments explaining | |
| 27 | + | //! themselves; both are carried here, because a conversion that keeps the | |
| 28 | + | //! markup and drops the reason leaves the next reader to rediscover it. | |
| 29 | + | ||
| 30 | + | use makeover_layout as layout; | |
| 31 | + | use quasi_router::screen::{Cell, Cells, Column, Rest}; | |
| 32 | + | use quasi_router::{ | |
| 33 | + | Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot, | |
| 34 | + | }; | |
| 35 | + | use quasi_webview::Webview; | |
| 36 | + | ||
| 37 | + | use crate::{constants, db}; | |
| 38 | + | ||
| 39 | + | /// The address, registered whole. See [`super::public_document_mount`]. | |
| 40 | + | pub const PATH: &str = "/git"; | |
| 41 | + | ||
| 42 | + | /// The page's own region, and what the skip link points at. | |
| 43 | + | pub const PAGE_REGION: &str = "git-explore"; | |
| 44 | + | ||
| 45 | + | const MEASURE: layout::Measure = layout::Measure::Wide; | |
| 46 | + | ||
| 47 | + | /// Everything the screen draws, resolved before it is drawn. | |
| 48 | + | struct Loaded { | |
| 49 | + | repos: Vec<Repo>, | |
| 50 | + | page: usize, | |
| 51 | + | has_more: bool, | |
| 52 | + | /// Whether to offer the reader their own annotations. See the module header. | |
| 53 | + | signed_in: bool, | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// One repository in the listing. | |
| 57 | + | struct Repo { | |
| 58 | + | owner: String, | |
| 59 | + | name: String, | |
| 60 | + | description: String, | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// The page. | |
| 64 | + | pub fn screen(viewer: &super::Viewer, request: Request) -> Result<Response, RouteError> { | |
| 65 | + | // Moved out of the request rather than borrowed: the signature is quasi's. | |
| 66 | + | let carried = request.carried; | |
| 67 | + | // Clamped exactly as the shipped handler clamped it: a page number out of a | |
| 68 | + | // query string is reader input, and the offset it becomes is multiplied. | |
| 69 | + | let page = carried | |
| 70 | + | .get("page") | |
| 71 | + | .and_then(|value| value.trim().parse::<usize>().ok()) | |
| 72 | + | .unwrap_or(1) | |
| 73 | + | .clamp(1, 10_000); | |
| 74 | + | ||
| 75 | + | let loaded = load(viewer, page)?; | |
| 76 | + | ||
| 77 | + | Ok(page_screen(&loaded).into()) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /// Read one page of public repositories, plus one row to learn whether there is | |
| 81 | + | /// another page. | |
| 82 | + | fn load(viewer: &super::Viewer, page: usize) -> Result<Loaded, RouteError> { | |
| 83 | + | let limit = constants::GIT_REPOS_PER_PAGE; | |
| 84 | + | let offset = (page - 1).saturating_mul(limit); | |
| 85 | + | ||
| 86 | + | let repos = viewer | |
| 87 | + | .block_on(db::git_repos::get_all_public_repos( | |
| 88 | + | &viewer.app.db, | |
| 89 | + | (limit + 1) as i64, | |
| 90 | + | offset as i64, | |
| 91 | + | )) | |
| 92 | + | .map_err(|_| RouteError::internal("those repositories could not be read"))?; | |
| 93 | + | ||
| 94 | + | let has_more = repos.len() > limit; | |
| 95 | + | ||
| 96 | + | Ok(Loaded { | |
| 97 | + | repos: repos | |
| 98 | + | .into_iter() | |
| 99 | + | .take(limit) | |
| 100 | + | .map(|repo| Repo { | |
| 101 | + | owner: repo.owner_username.to_string(), | |
| 102 | + | name: repo.name.clone(), | |
| 103 | + | description: repo.description.clone(), | |
| 104 | + | }) | |
| 105 | + | .collect(), | |
| 106 | + | page, | |
| 107 | + | has_more, | |
| 108 | + | signed_in: viewer.user.is_some(), | |
| 109 | + | }) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /// The whole document: the title, the measure, the body. | |
| 113 | + | fn page_screen(loaded: &Loaded) -> Described { | |
| 114 | + | let mut page = Slot::new(PAGE_REGION, RegionKind::Pane) | |
| 115 | + | .with(Node::page("Repositories")) | |
| 116 | + | // Notes are the one thing this browser does that no other forge does, | |
| 117 | + | // and nothing on a repository page says so to somebody who has never | |
| 118 | + | // seen one. The landing page is where that sentence reaches everybody. | |
| 119 | + | .with(Node::rich( | |
| 120 | + | "Every repository here renders [git notes](/docs/git-notes): annotation attached \ | |
| 121 | + | to a commit without rewriting it, stored in the repository and carried by a clone.", | |
| 122 | + | )); | |
| 123 | + | ||
| 124 | + | // The only route to an annotation whose target repository is gone: nothing | |
| 125 | + | // else links to it once there is no commit page to link from. | |
| 126 | + | if loaded.signed_in { | |
| 127 | + | page = page.with(Node::rich( | |
| 128 | + | "[Your annotations](/git/my-annotations), private to you, across every repository \ | |
| 129 | + | you have read here.", | |
| 130 | + | )); | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | page = if loaded.repos.is_empty() { | |
| 134 | + | page.with(Node::empty("No public repositories yet.")) | |
| 135 | + | } else { | |
| 136 | + | page.with(listing(loaded)) | |
| 137 | + | }; | |
| 138 | + | ||
| 139 | + | Described::single("Repositories - Git - Makenotwork") | |
| 140 | + | .measured(MEASURE) | |
| 141 | + | .documented(Document::default().classed(crate::shell::body_class(MEASURE, &[]))) | |
| 142 | + | .summarised("Public repositories on Makenotwork, with git notes rendered on every commit.") | |
| 143 | + | .with(page) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | /// The repositories, as a table, with whatever pages remain. | |
| 147 | + | fn listing(loaded: &Loaded) -> Node { | |
| 148 | + | Node::Table { | |
| 149 | + | columns: vec![ | |
| 150 | + | Column::new("Repository") | |
| 151 | + | .width(layout::Width::Content) | |
| 152 | + | .priority(layout::Priority::Essential), | |
| 153 | + | Column::new("Description").width(layout::Width::Fill), | |
| 154 | + | ], | |
| 155 | + | rows: loaded | |
| 156 | + | .repos | |
| 157 | + | .iter() | |
| 158 | + | .map(|repo| { | |
| 159 | + | Cells::new([ | |
| 160 | + | Cell::new(format!("{}/{}", repo.owner, repo.name)), | |
| 161 | + | Cell::new(repo.description.clone()), | |
| 162 | + | ]) | |
| 163 | + | .activate(Action::get(format!("/git/{}/{}", repo.owner, repo.name)).navigating()) | |
| 164 | + | }) | |
| 165 | + | .collect(), | |
| 166 | + | more: rest(loaded), | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /// What the reader has not been shown, when there is any. | |
| 171 | + | /// | |
| 172 | + | /// Prev/next only. The template drew `Newer` and `Older` and no numbers, and | |
| 173 | + | /// `Rest` draws numbers only for a screen that calls `jumping`, so this is a | |
| 174 | + | /// parity conversion rather than a reduction. | |
| 175 | + | fn rest(loaded: &Loaded) -> Option<Rest> { | |
| 176 | + | let per = constants::GIT_REPOS_PER_PAGE; | |
| 177 | + | let from = (loaded.page - 1) * per; | |
| 178 | + | ||
| 179 | + | if loaded.page == 1 && !loaded.has_more { | |
| 180 | + | return None; | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | let mut rest = Rest::page(from, per); | |
| 184 | + | if loaded.page > 1 { | |
| 185 | + | rest = rest.back(Action::get(format!("{PATH}?page={}", loaded.page - 1)).navigating()); | |
| 186 | + | } | |
| 187 | + | if loaded.has_more { | |
| 188 | + | rest = rest.forward(Action::get(format!("{PATH}?page={}", loaded.page + 1)).navigating()); | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | Some(rest) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | /// The document this screen is drawn in. | |
| 195 | + | #[must_use] | |
| 196 | + | pub fn renderer(viewer: &super::Viewer) -> Webview { | |
| 197 | + | Webview::new().with_shell(viewer.document_shell().with_body_first(format!( | |
| 198 | + | "{}{}", | |
| 199 | + | crate::shell::skip_link(PAGE_REGION), | |
| 200 | + | crate::shell::site_header(viewer.user.as_ref(), Some(&viewer.csrf)), | |
| 201 | + | ))) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | #[cfg(test)] | |
| 205 | + | mod tests { | |
| 206 | + | use super::*; | |
| 207 | + | ||
| 208 | + | fn loaded(count: usize, page: usize, has_more: bool, signed_in: bool) -> Loaded { | |
| 209 | + | Loaded { | |
| 210 | + | repos: (0..count) | |
| 211 | + | .map(|n| Repo { | |
| 212 | + | owner: "ada".into(), | |
| 213 | + | name: format!("repo{n}"), | |
| 214 | + | description: format!("Number {n}"), | |
| 215 | + | }) | |
| 216 | + | .collect(), | |
| 217 | + | page, | |
| 218 | + | has_more, | |
| 219 | + | signed_in, | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | fn html(loaded: &Loaded) -> String { | |
| 224 | + | use quasi_axum::Serves as _; | |
| 225 | + | ||
| 226 | + | Webview::new().screen(&page_screen(loaded)) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | /// `2790e5c4`. The template carried the measure alone. | |
| 230 | + | #[test] | |
| 231 | + | fn the_document_carries_the_class_the_template_carried() { | |
| 232 | + | let screen = page_screen(&loaded(1, 1, false, false)); | |
| 233 | + | ||
| 234 | + | assert_eq!(screen.document.body_class.as_deref(), Some("padded-page")); | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | /// Each row says `owner/name` and opens that repository. | |
| 238 | + | #[test] | |
| 239 | + | fn every_repository_is_a_row_that_opens_it() { | |
| 240 | + | let html = html(&loaded(2, 1, false, false)); | |
| 241 | + | ||
| 242 | + | assert!(html.contains("ada/repo0"), "{html}"); | |
| 243 | + | assert!(html.contains("/git/ada/repo1"), "{html}"); | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | /// The notes sentence is the page's reason for existing and reaches | |
| 247 | + | /// everybody, signed in or not. | |
| 248 | + | #[test] | |
| 249 | + | fn the_notes_explanation_is_always_shown() { | |
| 250 | + | for signed_in in [true, false] { | |
| 251 | + | let html = html(&loaded(1, 1, false, signed_in)); | |
| 252 | + | assert!(html.contains("/docs/git-notes"), "{html}"); | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | /// The annotations link is the only route to an annotation whose repository | |
| 257 | + | /// is gone, and it is only useful to somebody with a session. | |
| 258 | + | #[test] | |
| 259 | + | fn only_a_signed_in_reader_is_offered_their_annotations() { | |
| 260 | + | assert!(html(&loaded(1, 1, false, true)).contains("/git/my-annotations")); | |
| 261 | + | assert!(!html(&loaded(1, 1, false, false)).contains("/git/my-annotations")); | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | /// A single page of results offers no paging at all, rather than two | |
| 265 | + | /// disabled controls. | |
| 266 | + | #[test] | |
| 267 | + | fn one_page_of_repositories_has_no_rest() { | |
| 268 | + | assert!(rest(&loaded(3, 1, false, false)).is_none()); | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | /// Older on the first page, both on a middle page, Newer on the last. | |
| 272 | + | #[test] | |
| 273 | + | fn the_pager_offers_only_the_directions_that_exist() { | |
| 274 | + | let first = html(&loaded(3, 1, true, false)); | |
| 275 | + | assert!(first.contains("page=2"), "{first}"); | |
| 276 | + | assert!(!first.contains("page=0"), "{first}"); | |
| 277 | + | ||
| 278 | + | let middle = html(&loaded(3, 2, true, false)); | |
| 279 | + | assert!(middle.contains("page=1") && middle.contains("page=3"), "{middle}"); | |
| 280 | + | ||
| 281 | + | let last = html(&loaded(3, 4, false, false)); | |
| 282 | + | assert!(last.contains("page=3"), "{last}"); | |
| 283 | + | assert!(!last.contains("page=5"), "{last}"); | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | /// `736f45a5`: this screen's markup carries none of the four spellings. | |
| 287 | + | #[test] | |
| 288 | + | fn the_page_spells_no_spinner() { | |
| 289 | + | let html = html(&loaded(2, 1, true, true)); | |
| 290 | + | ||
| 291 | + | for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] { | |
| 292 | + | assert!(!html.contains(spelling), "{spelling} survives in {html}"); | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | } |
| @@ -1,0 +1,304 @@ | |||
| 1 | + | //! One person's repository listing at `/git/{owner}`, described. | |
| 2 | + | //! | |
| 3 | + | //! The seventh public document, and the first behind a rate limiter. It | |
| 4 | + | //! replaces `templates/pages/git/repos.html`, `GitUserReposTemplate` and | |
| 5 | + | //! `browsing::user_repos`. | |
| 6 | + | //! | |
| 7 | + | //! # The limiter goes on the mount, at the call site | |
| 8 | + | //! | |
| 9 | + | //! The git browse tree carries `route_layer(GovernorLayer::new(browse_rate_limit))` | |
| 10 | + | //! over every read, and a described document that took the address without it | |
| 11 | + | //! would quietly remove a per-IP cap from a route that walks bare repositories | |
| 12 | + | //! on disk. [`super::public_document_mount`] returns an `axum::Router`, so the | |
| 13 | + | //! layer is one call at the registration site and needs no new parameter -- see | |
| 14 | + | //! [`super::public_document_mounts`], which rebuilds the same limiter from the | |
| 15 | + | //! same constants. | |
| 16 | + | //! | |
| 17 | + | //! That was the whole of the supposed blocker. Recorded because it reads as an | |
| 18 | + | //! architectural limit and is a line of wiring. | |
| 19 | + | //! | |
| 20 | + | //! # What the owner sees that a visitor does not | |
| 21 | + | //! | |
| 22 | + | //! Two things, and both come off the same `is_owner` comparison the shipped | |
| 23 | + | //! handler made: private repositories are listed at all, and each one carries | |
| 24 | + | //! its visibility. A visitor gets the public set with no badges, because a badge | |
| 25 | + | //! saying `public` on every row of a list that contains nothing else is noise. | |
| 26 | + | //! | |
| 27 | + | //! The empty state differs too. An owner with no repositories is shown how to | |
| 28 | + | //! push one; a visitor looking at an empty account is told there is nothing | |
| 29 | + | //! here, because the push instructions are not theirs to act on. | |
| 30 | + | ||
| 31 | + | use makeover_layout as layout; | |
| 32 | + | use quasi_router::screen::{Cell, Cells, Column, Tag}; | |
| 33 | + | use quasi_router::{ | |
| 34 | + | Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot, | |
| 35 | + | }; | |
| 36 | + | use quasi_webview::Webview; | |
| 37 | + | ||
| 38 | + | use crate::db; | |
| 39 | + | ||
| 40 | + | /// The address, registered whole. See [`super::public_document_mount`]. | |
| 41 | + | pub const PATH: &str = "/git/{owner}"; | |
| 42 | + | ||
| 43 | + | /// The page's own region, and what the skip link points at. | |
| 44 | + | pub const PAGE_REGION: &str = "git-repos"; | |
| 45 | + | ||
| 46 | + | const MEASURE: layout::Measure = layout::Measure::Wide; | |
| 47 | + | ||
| 48 | + | /// Everything the screen draws, resolved before it is drawn. | |
| 49 | + | struct Loaded { | |
| 50 | + | owner: String, | |
| 51 | + | is_owner: bool, | |
| 52 | + | repos: Vec<Repo>, | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// One repository, as the listing shows it. | |
| 56 | + | struct Repo { | |
| 57 | + | name: String, | |
| 58 | + | description: String, | |
| 59 | + | /// `None` unless the reader owns the account and the repository is not | |
| 60 | + | /// public. See the module header. | |
| 61 | + | visibility: Option<String>, | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | /// The page. | |
| 65 | + | pub fn screen(viewer: &super::Viewer, request: Request) -> Result<Response, RouteError> { | |
| 66 | + | // Moved out because the handler signature is quasi's: the request is | |
| 67 | + | // consumed here rather than borrowed from. | |
| 68 | + | let captures = request.captures; | |
| 69 | + | let owner = captures | |
| 70 | + | .get("owner") | |
| 71 | + | .ok_or_else(|| RouteError::not_found("no such account"))?; | |
| 72 | + | ||
| 73 | + | let loaded = load(viewer, owner)?; | |
| 74 | + | ||
| 75 | + | Ok(page_screen(&loaded).into()) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// Resolve the account and the repositories this reader may see. | |
| 79 | + | fn load(viewer: &super::Viewer, owner: &str) -> Result<Loaded, RouteError> { | |
| 80 | + | let missing = || RouteError::not_found("no such account"); | |
| 81 | + | ||
| 82 | + | let username = db::Username::new(owner).map_err(|_| missing())?; | |
| 83 | + | let db_user = viewer | |
| 84 | + | .block_on(db::users::get_user_by_username(&viewer.app.db, &username)) | |
| 85 | + | .map_err(|_| RouteError::internal("that account could not be read"))? | |
| 86 | + | .ok_or_else(missing)?; | |
| 87 | + | ||
| 88 | + | let is_owner = viewer.user.as_ref().is_some_and(|u| u.id == db_user.id); | |
| 89 | + | ||
| 90 | + | // The owner sees everything; everybody else sees what has been published. | |
| 91 | + | // Two queries rather than one filtered in Rust, which is what shipped: the | |
| 92 | + | // visibility rule belongs in the statement, where it cannot be forgotten. | |
| 93 | + | let repos = viewer | |
| 94 | + | .block_on(async { | |
| 95 | + | if is_owner { | |
| 96 | + | db::git_repos::get_repos_by_user(&viewer.app.db, db_user.id).await | |
| 97 | + | } else { | |
| 98 | + | db::git_repos::get_public_repos_by_user(&viewer.app.db, db_user.id).await | |
| 99 | + | } | |
| 100 | + | }) | |
| 101 | + | .map_err(|_| RouteError::internal("those repositories could not be read"))?; | |
| 102 | + | ||
| 103 | + | Ok(Loaded { | |
| 104 | + | owner: db_user.username.to_string(), | |
| 105 | + | is_owner, | |
| 106 | + | repos: repos | |
| 107 | + | .iter() | |
| 108 | + | .map(|repo| Repo { | |
| 109 | + | name: repo.name.clone(), | |
| 110 | + | description: repo.description.clone(), | |
| 111 | + | visibility: (is_owner && repo.visibility != db::Visibility::Public) | |
| 112 | + | .then(|| repo.visibility.to_string()), | |
| 113 | + | }) | |
| 114 | + | .collect(), | |
| 115 | + | }) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// The whole document: the title, the measure, the body. | |
| 119 | + | fn page_screen(loaded: &Loaded) -> Described { | |
| 120 | + | let heading = format!("{}'s Repositories", loaded.owner); | |
| 121 | + | ||
| 122 | + | let mut page = Slot::new(PAGE_REGION, RegionKind::Pane).with(Node::page(heading.clone())); | |
| 123 | + | ||
| 124 | + | page = if loaded.repos.is_empty() { | |
| 125 | + | empty(page, loaded.is_owner, &loaded.owner) | |
| 126 | + | } else { | |
| 127 | + | page.with(listing(loaded)) | |
| 128 | + | }; | |
| 129 | + | ||
| 130 | + | Described::single(format!("{heading} - Git - Makenotwork")) | |
| 131 | + | .measured(MEASURE) | |
| 132 | + | .documented(Document::default().classed(crate::shell::body_class(MEASURE, &[]))) | |
| 133 | + | .with(page) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | /// An account with nothing published. | |
| 137 | + | /// | |
| 138 | + | /// The owner gets the two commands that fix it; a visitor gets the fact. The | |
| 139 | + | /// shipped template made the same split and it is worth keeping: `git remote | |
| 140 | + | /// add` is not advice a stranger can take. | |
| 141 | + | fn empty(page: Slot, is_owner: bool, owner: &str) -> Slot { | |
| 142 | + | let page = page.with(Node::empty("No repositories yet.")); | |
| 143 | + | ||
| 144 | + | if !is_owner { | |
| 145 | + | return page; | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | page.with(Node::rich(format!( | |
| 149 | + | "Push a new repository:\n\ | |
| 150 | + | \n\ | |
| 151 | + | ```\n\ | |
| 152 | + | git remote add origin https://makenot.work/git/{owner}/my-repo.git\n\ | |
| 153 | + | git push -u origin main\n\ | |
| 154 | + | ```" | |
| 155 | + | ))) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /// The repositories, as a table. | |
| 159 | + | /// | |
| 160 | + | /// The template drew a `<ul>` of two-line entries. As a table the description | |
| 161 | + | /// says which column carries the identity and which can be dropped on a narrow | |
| 162 | + | /// viewport, rather than leaving a stack of divs to wrap however it wraps. | |
| 163 | + | fn listing(loaded: &Loaded) -> Node { | |
| 164 | + | let mut columns = vec![ | |
| 165 | + | Column::new("Repository") | |
| 166 | + | .width(layout::Width::Content) | |
| 167 | + | .priority(layout::Priority::Essential), | |
| 168 | + | Column::new("Description").width(layout::Width::Fill), | |
| 169 | + | ]; | |
| 170 | + | // Nobody but the owner is shown a visibility column, because for everybody | |
| 171 | + | // else every row in it would say the same word. | |
| 172 | + | if loaded.is_owner { | |
| 173 | + | columns.push(Column::new("Visibility").width(layout::Width::Content)); | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | Node::Table { | |
| 177 | + | columns, | |
| 178 | + | rows: loaded | |
| 179 | + | .repos | |
| 180 | + | .iter() | |
| 181 | + | .map(|repo| { | |
| 182 | + | let mut cells = vec![ | |
| 183 | + | Cell::new(repo.name.clone()), | |
| 184 | + | Cell::new(repo.description.clone()), | |
| 185 | + | ]; | |
| 186 | + | if loaded.is_owner { | |
| 187 | + | cells.push(match repo.visibility.as_deref() { | |
| 188 | + | Some(visibility) => Cell::new(String::new()).token(Tag::badge(visibility)), | |
| 189 | + | None => Cell::new(String::new()), | |
| 190 | + | }); | |
| 191 | + | } | |
| 192 | + | Cells::new(cells).activate( | |
| 193 | + | Action::get(format!("/git/{}/{}", loaded.owner, repo.name)).navigating(), | |
| 194 | + | ) | |
| 195 | + | }) | |
| 196 | + | .collect(), | |
| 197 | + | more: None, | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | /// The document this screen is drawn in. | |
| 202 | + | #[must_use] | |
| 203 | + | pub fn renderer(viewer: &super::Viewer) -> Webview { | |
| 204 | + | Webview::new().with_shell(viewer.document_shell().with_body_first(format!( | |
| 205 | + | "{}{}", | |
| 206 | + | crate::shell::skip_link(PAGE_REGION), | |
| 207 | + | crate::shell::site_header(viewer.user.as_ref(), Some(&viewer.csrf)), | |
| 208 | + | ))) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | #[cfg(test)] | |
| 212 | + | mod tests { | |
| 213 | + | use super::*; | |
| 214 | + | ||
| 215 | + | fn loaded(count: usize, is_owner: bool) -> Loaded { | |
| 216 | + | Loaded { | |
| 217 | + | owner: "ada".into(), | |
| 218 | + | is_owner, | |
| 219 | + | repos: (0..count) | |
| 220 | + | .map(|n| Repo { | |
| 221 | + | name: format!("repo{n}"), | |
| 222 | + | description: format!("Number {n}"), | |
| 223 | + | visibility: (is_owner && n == 0).then(|| "private".to_string()), | |
| 224 | + | }) | |
| 225 | + | .collect(), | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | fn html(loaded: &Loaded) -> String { | |
| 230 | + | use quasi_axum::Serves as _; | |
| 231 | + | ||
| 232 | + | Webview::new().screen(&page_screen(loaded)) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | /// `2790e5c4`. This template carried the measure alone, so the slice is | |
| 236 | + | /// empty and the class is just the measure. | |
| 237 | + | #[test] | |
| 238 | + | fn the_document_carries_the_class_the_template_carried() { | |
| 239 | + | let screen = page_screen(&loaded(1, false)); | |
| 240 | + | ||
| 241 | + | assert_eq!(screen.document.body_class.as_deref(), Some("padded-page")); | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | /// The title names whose repositories these are, as the template's did. | |
| 245 | + | #[test] | |
| 246 | + | fn the_document_is_titled_for_the_account() { | |
| 247 | + | let screen = page_screen(&loaded(1, false)); | |
| 248 | + | ||
| 249 | + | assert_eq!(screen.title, "ada's Repositories - Git - Makenotwork"); | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | /// Every repository is a row that opens it. | |
| 253 | + | #[test] | |
| 254 | + | fn every_repository_is_a_row_that_opens_it() { | |
| 255 | + | let html = html(&loaded(3, false)); | |
| 256 | + | ||
| 257 | + | for n in 0..3 { | |
| 258 | + | assert!(html.contains(&format!("repo{n}")), "{html}"); | |
| 259 | + | assert!(html.contains(&format!("/git/ada/repo{n}")), "{html}"); | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | /// A visitor gets no visibility column, because every row of it would say | |
| 264 | + | /// the same word. | |
| 265 | + | #[test] | |
| 266 | + | fn a_visitor_is_shown_no_visibility_column() { | |
| 267 | + | assert!(!html(&loaded(2, false)).contains("Visibility")); | |
| 268 | + | assert!(html(&loaded(2, true)).contains("Visibility")); | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | /// The owner's private repository is marked; their public one is not. | |
| 272 | + | #[test] | |
| 273 | + | fn the_owner_sees_which_of_their_repositories_are_not_public() { | |
| 274 | + | let html = html(&loaded(2, true)); | |
| 275 | + | ||
| 276 | + | assert!(html.contains("private"), "{html}"); | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | /// The push instructions are the owner's. A stranger looking at an empty | |
| 280 | + | /// account is told the fact and not given a command they cannot run. | |
| 281 | + | #[test] | |
| 282 | + | fn only_the_owner_is_told_how_to_push() { | |
| 283 | + | let owner = html(&loaded(0, true)); | |
| 284 | + | let visitor = html(&loaded(0, false)); | |
| 285 | + | ||
| 286 | + | assert!(owner.contains("git remote add origin"), "{owner}"); | |
| 287 | + | assert!( | |
| 288 | + | owner.contains("makenot.work/git/ada/my-repo.git"), | |
| 289 | + | "{owner}" | |
| 290 | + | ); | |
| 291 | + | assert!(!visitor.contains("git remote add"), "{visitor}"); | |
| 292 | + | assert!(visitor.contains("No repositories yet"), "{visitor}"); | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | /// `736f45a5`: this screen's markup carries none of the four spellings. | |
| 296 | + | #[test] | |
| 297 | + | fn the_page_spells_no_spinner() { | |
| 298 | + | let html = html(&loaded(2, true)); | |
| 299 | + | ||
| 300 | + | for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] { | |
| 301 | + | assert!(!html.contains(spelling), "{spelling} survives in {html}"); | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | } |
| @@ -1,58 +1,0 @@ | |||
| 1 | - | {% extends "base.html" %} | |
| 2 | - | {%- import "partials/_ui.html" as ui -%} | |
| 3 | - | ||
| 4 | - | {% block title %}Repositories - Git - Makenotwork{% endblock %} | |
| 5 | - | {% block body_attrs %} class="{{ crate::shell::measure(crate::shell::Measure::Wide) }}"{% endblock %} | |
| 6 | - | ||
| 7 | - | {% block content %} | |
| 8 | - | {% include "partials/site_header.html" %} | |
| 9 | - | ||
| 10 | - | <h1 class="git-repo-name">Repositories</h1> | |
| 11 | - | ||
| 12 | - | {# Notes are the one thing this browser does that no other forge does, and | |
| 13 | - | nothing on a repository page says so to somebody who has never seen a | |
| 14 | - | note. The landing page is where that sentence reaches everybody. #} | |
| 15 | - | <p class="git-explore-intro"> | |
| 16 | - | Every repository here renders <a href="/docs/git-notes">git notes</a>: | |
| 17 | - | annotation attached to a commit without rewriting it, stored in the | |
| 18 | - | repository and carried by a clone. | |
| 19 | - | </p> | |
| 20 | - | ||
| 21 | - | {% if session_user.is_some() %} | |
| 22 | - | {# The only route to an annotation whose target repository is gone: nothing | |
| 23 | - | else links to it once there is no commit page to link from. #} | |
| 24 | - | <p class="git-explore-mine"> | |
| 25 | - | <a href="/git/my-annotations">Your annotations</a>, private to you, | |
| 26 | - | across every repository you have read here. | |
| 27 | - | </p> | |
| 28 | - | {% endif %} | |
| 29 | - | ||
| 30 | - | {% if repos.is_empty() %} | |
| 31 | - | {% call ui::empty_state("", "No public repositories yet.") %}{% endcall %} | |
| 32 | - | {% else %} | |
| 33 | - | <ul class="git-repos-list"> | |
| 34 | - | {% for repo in repos %} | |
| 35 | - | <li class="git-repos-item"> | |
| 36 | - | <div class="git-repos-item-header"> | |
| 37 | - | <a href="/git/{{ repo.owner_username }}/{{ repo.name }}" class="git-repos-item-name"> | |
| 38 | - | <span class="git-repos-owner">{{ repo.owner_username }}</span><span class="sep">/</span>{{ repo.name }} | |
| 39 | - | </a> | |
| 40 | - | </div> | |
| 41 | - | {% if !repo.description.is_empty() %} | |
| 42 | - | <p class="git-repos-item-desc">{{ repo.description }}</p> | |
| 43 | - | {% endif %} | |
| 44 | - | </li> | |
| 45 | - | {% endfor %} | |
| 46 | - | </ul> | |
| 47 | - | ||
| 48 | - | <div class="git-pagination"> | |
| 49 | - | {% if page > 1 %} | |
| 50 | - | <a href="/git?page={{ page - 1 }}">Newer</a> | |
| 51 | - | {% endif %} | |
| 52 | - | {% if has_more %} | |
| 53 | - | <a href="/git?page={{ page + 1 }}">Older</a> | |
| 54 | - | {% endif %} | |
| 55 | - | </div> | |
| 56 | - | {% endif %} | |
| 57 | - | ||
| 58 | - | {% endblock %} |