Skip to main content

max / makenotwork

Describe both git repository listings, with the browse limiter on the mount /git and /git/{owner}, converted together because they shared a stylesheet block: neither page's CSS could go until both had left. The limiter was the supposed blocker and is a line of wiring. public_document_mount returns an axum::Router, so the per-IP cap the git browse tree carries goes on with .layer() at the registration site, rebuilt from the same constants routes::git reads. A source-level test in quasi::tests asserts every described /git address carries it, because the failure mode is silent: a route lifted out of a router with a route_layer loses the layer, nothing fails to compile, and a route that walks bare repositories on disk stops being capped. Both listings were a <ul> of two-line entries and are Node::Table now, so the identity column and the droppable one are stated rather than left to wrapping. The owner keeps the visibility chip as a Tag on a cell; a visitor is shown no visibility column at all, because every row of it would say the same word. The push instructions stay the owner's: git remote add is not advice a stranger can take. /git keeps prev/next paging, which is what Rest gives without jumping, and drops the total_count query the template never rendered. Deletes both templates, GitUserReposTemplate, GitExploreTemplate, browsing::user_repos, browsing::git_landing, ExploreQuery and 22 lines of style.css.
Author: Max Johnson <me@maxj.phd> · 2026-09-01 14:14 UTC
Signed with PGP, not checked
Commit: 834c82f59ec6f686ee7e8477efab976d842c7cbb
Parent: ce5599f
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 %}