Skip to main content

max / makenotwork

10.5 KB · 301 lines History Blame Raw
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,
102 name: repo.name,
103 description: repo.description,
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(super::own_prose(
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(super::own_prose(
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()),
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 let rendered = html(&loaded(1, 1, false, false));
236 assert!(rendered.contains("class=\"padded-page\""), "{rendered}");
237 }
238
239 /// Each row says `owner/name` and opens that repository.
240 #[test]
241 fn every_repository_is_a_row_that_opens_it() {
242 let html = html(&loaded(2, 1, false, false));
243
244 assert!(html.contains("ada/repo0"), "{html}");
245 assert!(html.contains("/git/ada/repo1"), "{html}");
246 }
247
248 /// The notes sentence is the page's reason for existing and reaches
249 /// everybody, signed in or not.
250 #[test]
251 fn the_notes_explanation_is_always_shown() {
252 for signed_in in [true, false] {
253 let html = html(&loaded(1, 1, false, signed_in));
254 assert!(html.contains("/docs/git-notes"), "{html}");
255 }
256 }
257
258 /// The annotations link is the only route to an annotation whose repository
259 /// is gone, and it is only useful to somebody with a session.
260 #[test]
261 fn only_a_signed_in_reader_is_offered_their_annotations() {
262 assert!(html(&loaded(1, 1, false, true)).contains("/git/my-annotations"));
263 assert!(!html(&loaded(1, 1, false, false)).contains("/git/my-annotations"));
264 }
265
266 /// A single page of results offers no paging at all, rather than two
267 /// disabled controls.
268 #[test]
269 fn one_page_of_repositories_has_no_rest() {
270 assert!(rest(&loaded(3, 1, false, false)).is_none());
271 }
272
273 /// Older on the first page, both on a middle page, Newer on the last.
274 #[test]
275 fn the_pager_offers_only_the_directions_that_exist() {
276 let first = html(&loaded(3, 1, true, false));
277 assert!(first.contains("page=2"), "{first}");
278 assert!(!first.contains("page=0"), "{first}");
279
280 let middle = html(&loaded(3, 2, true, false));
281 assert!(
282 middle.contains("page=1") && middle.contains("page=3"),
283 "{middle}"
284 );
285
286 let last = html(&loaded(3, 4, false, false));
287 assert!(last.contains("page=3"), "{last}");
288 assert!(!last.contains("page=5"), "{last}");
289 }
290
291 /// `736f45a5`: this screen's markup carries none of the four spellings.
292 #[test]
293 fn the_page_spells_no_spinner() {
294 let html = html(&loaded(2, 1, true, true));
295
296 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
297 assert!(!html.contains(spelling), "{spelling} survives in {html}");
298 }
299 }
300 }
301