Skip to main content

max / makenotwork

11.8 KB · 344 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_declare::declare;
32 use quasi_router::screen::Rest;
33 use quasi_router::{Action, Document, RouteError};
34 use quasi_webview::Webview;
35
36 use crate::{constants, db};
37
38 /// The address, registered whole. See [`super::public_document_mount`].
39 pub const PATH: &str = "/git";
40
41 /// The page's own region, and what the skip link points at.
42 pub const PAGE_REGION: &str = "git-explore";
43
44 const MEASURE: layout::Measure = layout::Measure::Wide;
45
46 /// Everything the screen draws, resolved before it is drawn.
47 pub(crate) struct Loaded {
48 repos: Vec<Repo>,
49 page: usize,
50 has_more: bool,
51 /// Whether to offer the reader their own annotations. See the module header.
52 signed_in: bool,
53 }
54
55 impl Loaded {
56 /// Where this page starts, which is what a `Rest` counts from.
57 ///
58 /// A supplier because the declared form has no arithmetic, and it is the
59 /// same sum [`load`] makes to ask the database.
60 fn offset(&self) -> usize {
61 (self.page - 1).saturating_mul(constants::GIT_REPOS_PER_PAGE)
62 }
63
64 /// The page back, and the page on. Suppliers for [`offset`](Self::offset)'s
65 /// reason.
66 fn previous(&self) -> usize {
67 self.page.saturating_sub(1)
68 }
69
70 fn next(&self) -> usize {
71 self.page + 1
72 }
73 }
74
75 /// One repository in the listing.
76 struct Repo {
77 owner: String,
78 name: String,
79 description: String,
80 }
81
82 /// The one read this page makes, for the mount that serves it from a residual.
83 ///
84 /// The clamp is the shipped handler's, unchanged: a page number out of a query
85 /// string is reader input, and the offset it becomes is multiplied.
86 pub(crate) fn reading(
87 viewer: &super::Viewer,
88 carried: &super::Carried,
89 ) -> Result<Loaded, RouteError> {
90 let page = carried
91 .asked("page")
92 .and_then(|value| value.trim().parse::<usize>().ok())
93 .unwrap_or(1)
94 .clamp(1, 10_000);
95
96 load(viewer, page)
97 }
98
99 /// Read one page of public repositories, plus one row to learn whether there is
100 /// another page.
101 fn load(viewer: &super::Viewer, page: usize) -> Result<Loaded, RouteError> {
102 let limit = constants::GIT_REPOS_PER_PAGE;
103 let offset = (page - 1).saturating_mul(limit);
104
105 let repos = viewer
106 .block_on(db::git_repos::get_all_public_repos(
107 &viewer.app.db,
108 (limit + 1) as i64,
109 offset as i64,
110 ))
111 .map_err(|_| RouteError::internal("those repositories could not be read"))?;
112
113 let has_more = repos.len() > limit;
114
115 Ok(Loaded {
116 repos: repos
117 .into_iter()
118 .take(limit)
119 .map(|repo| Repo {
120 owner: repo.owner_username,
121 name: repo.name,
122 description: repo.description,
123 })
124 .collect(),
125 page,
126 has_more,
127 signed_in: viewer.user.is_some(),
128 })
129 }
130
131 declare! {
132 /// The whole document: the title, the measure, the body.
133 pub(crate) shape page_screen(loaded: &Loaded) -> Screen;
134
135 screen single "Repositories - Git - Makenotwork" {
136 measured MEASURE;
137 documented Document::default().classed(crate::shell::body_class(MEASURE, &[]));
138 summarised "Public repositories on Makenotwork, with git notes rendered on every commit.";
139
140 include page_region(loaded);
141 }
142 }
143
144 declare! {
145 /// The page's one region, split out so it can be staged.
146 #[staged]
147 pub(crate) shape page_region(loaded: &Loaded) -> Slot;
148
149 region PAGE_REGION as Pane {
150 page "Repositories";
151
152 // Notes are the one thing this browser does that no other forge
153 // does, and nothing on a repository page says so to somebody who
154 // has never seen one. The landing page is where that sentence
155 // reaches everybody.
156 include super::own_prose(
157 "Every repository here renders [git notes](/docs/git-notes): annotation attached \
158 to a commit without rewriting it, stored in the repository and carried by a clone."
159 );
160
161 // The only route to an annotation whose target repository is gone:
162 // nothing else links to it once there is no commit page to link
163 // from.
164 include super::own_prose(
165 "[Your annotations](/git/my-annotations), private to you, across every repository \
166 you have read here."
167 ) when loaded.signed_in;
168
169 empty "No public repositories yet." when loaded.repos.is_empty();
170 include listing(loaded) unless loaded.repos.is_empty();
171 }
172 }
173
174 declare! {
175 /// The repositories, as a table, with whatever pages remain.
176 ///
177 /// Two columns, two cells, all four written here, so the row stays
178 /// positional: naming would buy nothing a reader cannot already check by
179 /// looking up four lines.
180 #[staged]
181 shape listing(loaded: &Loaded) -> Node;
182
183 table {
184 column "Repository" {
185 width Content;
186 priority Essential;
187 }
188 column "Description" {
189 width Fill;
190 }
191
192 for repo in loaded.repos.iter() {
193 cells {
194 cell "{repo.owner}/{repo.name}";
195 cell repo.description.clone();
196 activate to get "/git/{repo.owner}/{repo.name}" navigating;
197 }
198 }
199
200 // One page of one is the whole listing, and the pager is what says so.
201 // A table either shows what it has not shown or does not, and that is
202 // what a guard is for.
203 //
204 // Described rather than supplied. A `Rest` has no sentinel and a
205 // supplier of one takes a struct, so a pager handed over whole was a
206 // hole the residual could not hold; everything it carries is a number
207 // or an address, which are the two things a residual already has.
208 // The directions are written back-then-forward because that is the
209 // order they draw in, which `quasi-declare` holds this to.
210 // quasicoherent `cbb63155`.
211 more Rest::page(loaded.offset(), constants::GIT_REPOS_PER_PAGE) {
212 back Action::get("{PATH}?page={loaded.previous()}").navigating()
213 when loaded.page over 1;
214 forward Action::get("{PATH}?page={loaded.next()}").navigating()
215 when loaded.has_more;
216 } when loaded.page over 1 or loaded.has_more;
217 }
218 }
219
220 /// One page of repositories as the tests draw it.
221 ///
222 /// Module-level rather than inside `mod tests` because `quasi::residuals` needs
223 /// one too, and `Loaded` is this module's own type. Test-only.
224 #[cfg(test)]
225 pub(crate) fn loaded(count: usize, page: usize, has_more: bool, signed_in: bool) -> Loaded {
226 Loaded {
227 repos: (0..count)
228 .map(|n| Repo {
229 owner: "ada".into(),
230 name: format!("repo{n}"),
231 description: format!("Number {n}"),
232 })
233 .collect(),
234 page,
235 has_more,
236 signed_in,
237 }
238 }
239
240 /// The document this screen is drawn in.
241 #[must_use]
242 pub fn renderer(viewer: &super::Viewer) -> Webview {
243 Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
244 "{}{}",
245 crate::shell::skip_link(PAGE_REGION),
246 crate::shell::site_header(viewer.user.as_ref()),
247 )))
248 }
249
250 #[cfg(test)]
251 mod tests {
252 use super::*;
253
254 fn html(loaded: &Loaded) -> String {
255 use quasi_axum::Serves as _;
256
257 Webview::new().screen(&page_screen(loaded))
258 }
259
260 /// `2790e5c4`. The template carried the measure alone.
261 #[test]
262 fn the_document_carries_the_class_the_template_carried() {
263 let screen = page_screen(&loaded(1, 1, false, false));
264
265 assert_eq!(screen.document.body_class.as_deref(), Some("padded-page"));
266 let rendered = html(&loaded(1, 1, false, false));
267 assert!(rendered.contains("class=\"padded-page\""), "{rendered}");
268 }
269
270 /// Each row says `owner/name` and opens that repository.
271 #[test]
272 fn every_repository_is_a_row_that_opens_it() {
273 let html = html(&loaded(2, 1, false, false));
274
275 assert!(html.contains("ada/repo0"), "{html}");
276 assert!(html.contains("/git/ada/repo1"), "{html}");
277 }
278
279 /// The notes sentence is the page's reason for existing and reaches
280 /// everybody, signed in or not.
281 #[test]
282 fn the_notes_explanation_is_always_shown() {
283 for signed_in in [true, false] {
284 let html = html(&loaded(1, 1, false, signed_in));
285 assert!(html.contains("/docs/git-notes"), "{html}");
286 }
287 }
288
289 /// The annotations link is the only route to an annotation whose repository
290 /// is gone, and it is only useful to somebody with a session.
291 #[test]
292 fn only_a_signed_in_reader_is_offered_their_annotations() {
293 assert!(html(&loaded(1, 1, false, true)).contains("/git/my-annotations"));
294 assert!(!html(&loaded(1, 1, false, false)).contains("/git/my-annotations"));
295 }
296
297 /// Both headings, which moved from a hand-written `Table::new` list into the
298 /// declaration. A column dropped on the way is silent.
299 #[test]
300 fn every_column_the_listing_had_is_still_named() {
301 let html = html(&loaded(2, 1, false, false));
302
303 assert!(html.contains("Repository"), "{html}");
304 assert!(html.contains("Description"), "{html}");
305 }
306
307 /// A single page of results offers no paging at all, rather than two
308 /// disabled controls.
309 #[test]
310 fn one_page_of_repositories_has_no_rest() {
311 let html = html(&loaded(3, 1, false, false));
312
313 assert!(!html.contains("page="), "{html}");
314 }
315
316 /// Older on the first page, both on a middle page, Newer on the last.
317 #[test]
318 fn the_pager_offers_only_the_directions_that_exist() {
319 let first = html(&loaded(3, 1, true, false));
320 assert!(first.contains("page=2"), "{first}");
321 assert!(!first.contains("page=0"), "{first}");
322
323 let middle = html(&loaded(3, 2, true, false));
324 assert!(
325 middle.contains("page=1") && middle.contains("page=3"),
326 "{middle}"
327 );
328
329 let last = html(&loaded(3, 4, false, false));
330 assert!(last.contains("page=3"), "{last}");
331 assert!(!last.contains("page=5"), "{last}");
332 }
333
334 /// `736f45a5`: this screen's markup carries none of the four spellings.
335 #[test]
336 fn the_page_spells_no_spinner() {
337 let html = html(&loaded(2, 1, true, true));
338
339 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
340 assert!(!html.contains(spelling), "{spelling} survives in {html}");
341 }
342 }
343 }
344