Skip to main content

max / makenotwork

15.2 KB · 436 lines History Blame Raw
1 //! A repository's navigation, described: the identity line, the bar and the
2 //! path trail.
3 //!
4 //! `templates/partials/git_nav.html`'s contents, said in the vocabulary rather
5 //! than written as markup. Ten git templates include that partial, so this is
6 //! the gate on the source browser: none of them can become a described screen
7 //! while the bar they all carry is hand-written HTML.
8 //!
9 //! [`super::carousel`]'s shape, and for its reason: [`region`] is the
10 //! description and [`html`] is the Askama half, so a page that converts later
11 //! embeds the same node instead of re-saying it. Nothing here waits on the
12 //! pages.
13 //!
14 //! # What the bar is, in the vocabulary
15 //!
16 //! Two members, and the split is the one the markup already made.
17 //!
18 //! The tab strip is a column-less [`Node::Table`], which is what a list is:
19 //! rows of the same kind of thing, each
20 //! going somewhere, one of them [`Row::current`]. That is the whole of what the
21 //! six `<a>` elements said, `is-selected` included, and `current` is the
22 //! member for it -- the app's own pointer at where the reader is, which is
23 //! exactly what `aria-current` exists to announce. Drawn as a row rather than a
24 //! column by the stylesheet, which is where "how a set is laid out" has lived
25 //! throughout.
26 //!
27 //! The ref chooser is a [`Field`] of [`layout::FieldKind::Select`]. It is one
28 //! question with many answers, and a list of two hundred branches drawn as
29 //! links would be the page. Its answer goes somewhere, so it
30 //! [`writes`](Field::writes) a navigating read -- the same shape the site
31 //! header's search box has.
32 //!
33 //! ## Why that write needs a route of its own
34 //!
35 //! A described write submits its value as a parameter, and the canonical
36 //! address of a tree is a path segment (`/git/{owner}/{repo}/tree/{ref}`). Those
37 //! are not the same address, and the path one is what every link on the site,
38 //! every bookmark and every crawler already holds, so it is not the one to
39 //! move.
40 //!
41 //! [`crate::routes::git::browsing::ref_chosen`] is the join: it takes the
42 //! parameter and answers a redirect to the canonical path. One address for the
43 //! content, one address for the control, and the reader lands on the first.
44
45 use quasi_declare::declare;
46 use quasi_router::Action;
47 use quasi_router::screen::Choice;
48
49 use crate::git::{Breadcrumb, RefInfo};
50
51 /// The bar's own region, and what a page names to replace it.
52 pub const REGION: &str = "git-nav";
53
54 /// The address the ref chooser's answer goes to. See the module header.
55 pub const CHOOSE_PATH: &str = "/git/{owner}/{repo}/ref";
56
57 /// The parameter the chooser submits under.
58 pub const CHOSEN: &str = "ref";
59
60 /// What a page passes when it is about the repository rather than about a ref.
61 ///
62 /// See [`region`] for why those pages carry no chooser.
63 pub const NO_REFS: &[RefInfo] = &[];
64
65 /// Everything the bar draws, gathered rather than passed as eight arguments.
66 pub struct Nav<'a> {
67 pub owner: &'a str,
68 pub repo: &'a str,
69 /// The branch or tag being browsed, which is the chooser's answer.
70 pub current_ref: &'a str,
71 /// Which tab is current, by the key the strip uses.
72 pub active_tab: &'a str,
73 pub open_issue_count: i64,
74 /// Settings is the owner's, so a visitor is not offered it.
75 pub is_owner: bool,
76 pub refs: &'a [RefInfo],
77 }
78
79 /// One place this repository has, as the strip draws it.
80 ///
81 /// Named members rather than a tuple, for `policy`'s reason: a description
82 /// names what it draws, and `.1` is not a name. Whether the reader is here is
83 /// decided in [`Nav::tabs`] rather than at the row, because `commit` is one of
84 /// the commits Commits lists and the reader is in the same place either way --
85 /// a fact about the set of tabs, not about one of them.
86 struct Tab {
87 /// What the tab is called, count included where there is one.
88 label: String,
89 /// Where it goes.
90 route: String,
91 /// Whether the reader is there.
92 current: bool,
93 }
94
95 impl Nav<'_> {
96 /// The repository's own prefix, which every address here is under.
97 fn base(&self) -> String {
98 format!("/git/{}/{}", self.owner, self.repo)
99 }
100
101 /// Where the chooser's answer goes. See the module header.
102 fn ref_path(&self) -> String {
103 format!("{}/ref", self.base())
104 }
105
106 /// Whether there is anything to choose between.
107 fn has_refs(&self) -> bool {
108 !self.refs.is_empty()
109 }
110
111 /// The six places this repository has, in the order the strip draws them.
112 fn tabs(&self) -> Vec<Tab> {
113 let base = self.base();
114 let current =
115 |key: &str| self.active_tab == key || (key == "commits" && self.active_tab == "commit");
116
117 let mut tabs = vec![
118 Tab {
119 label: "Files".to_owned(),
120 route: format!("{base}/tree/{}", self.current_ref),
121 current: current("files"),
122 },
123 Tab {
124 label: "Commits".to_owned(),
125 route: format!("{base}/commits/{}", self.current_ref),
126 current: current("commits"),
127 },
128 Tab {
129 label: "Tags".to_owned(),
130 route: format!("{base}/tags"),
131 current: current("tags"),
132 },
133 Tab {
134 label: "Notes".to_owned(),
135 route: format!("{base}/notes"),
136 current: current("notes"),
137 },
138 Tab {
139 // The count is part of the name a reader reads, and there is no
140 // second thing to say: a tab reading `Issues` when there are
141 // none and `Issues (3)` when there are three is one label.
142 label: if self.open_issue_count > 0 {
143 format!("Issues ({})", self.open_issue_count)
144 } else {
145 "Issues".to_owned()
146 },
147 route: format!("{base}/issues"),
148 current: current("issues"),
149 },
150 ];
151 if self.is_owner {
152 tabs.push(Tab {
153 label: "Settings".to_owned(),
154 route: format!("{base}/settings"),
155 current: current("settings"),
156 });
157 }
158 tabs
159 }
160 }
161
162 /// What a ref is called in the chooser.
163 ///
164 /// A tag reads as `tag: v1.2.0` and a branch as its bare name, which is the
165 /// distinction the shipped `<option>` drew and the only one the list makes. The
166 /// submitted value is the ref name either way, so the label carrying the word
167 /// costs nothing.
168 ///
169 /// A supplier handing back a `String` rather than a `Choice`, which is the
170 /// smallest type that works and keeps it out of the population.
171 fn ref_label(info: &RefInfo) -> String {
172 if info.is_branch {
173 info.name.clone()
174 } else {
175 format!("tag: {}", info.name)
176 }
177 }
178
179 declare! {
180 /// The bar, for a screen that owns its whole document.
181 ///
182 /// One row that wraps below the breakpoint. Nothing in it may drop: the
183 /// chooser and the strip are the only ways off this page.
184 ///
185 /// The chooser is drawn only where there is something to choose. Issues, a
186 /// single issue and settings are facts about the repository rather than
187 /// about a ref, and their handlers never open the repository at all, so
188 /// offering a ref there would mean walking the object database for a
189 /// control that changes nothing on the page. They pass no refs and get the
190 /// strip.
191 #[must_use]
192 pub shape region(nav: &Nav<'_>) -> Node;
193
194 region REGION as Group {
195 across Wrap {
196 beside Essential include chooser(nav) when nav.has_refs();
197 beside Essential include strip(nav);
198 }
199 }
200 }
201
202 /// The markup, for an Askama template to drop in.
203 #[must_use]
204 pub fn html(nav: &Nav<'_>) -> String {
205 use quasi_axum::Serves as _;
206
207 // No shell: a fragment landing inside a document Askama already built.
208 quasi_webview::Webview::new().fragment(&region(nav))
209 }
210
211 declare! {
212 /// The branch and tag chooser.
213 ///
214 /// One question with many answers, and a list of two hundred branches drawn
215 /// as links would be the page. Its answer goes somewhere, so it writes a
216 /// navigating read to the join route, which redirects to the canonical tree
217 /// address. See the module header.
218 ///
219 /// The options accrete one per ref, which is what `Field::option` is for:
220 /// `Field::options` takes the whole list and the form has no expression to
221 /// hold one in.
222 shape chooser(nav: &Nav<'_>) -> Field;
223
224 field Select CHOSEN "Branch or tag" {
225 for info in nav.refs.iter() {
226 option Choice::new(&info.name, ref_label(info));
227 }
228 value nav.current_ref;
229 writes Action::get(nav.ref_path()).navigating();
230 }
231 }
232
233 declare! {
234 /// The six places this repository has.
235 ///
236 /// Rows of the same kind of thing, each going somewhere, one of them
237 /// current. That is the whole of what the six `<a>` elements said,
238 /// `is-selected` included, and `current` is the member for it -- the app's
239 /// own pointer at where the reader is, which is exactly what `aria-current`
240 /// exists to announce.
241 shape strip(nav: &Nav<'_>) -> Node;
242
243 list {
244 for tab in nav.tabs() {
245 row tab.label {
246 current tab.current;
247 activate to get tab.route navigating;
248 }
249 }
250 }
251 }
252
253 declare! {
254 /// The line that says which repository this is.
255 ///
256 /// Two links and a separator in the shipped markup, and only the links are
257 /// described: a `/` between an owner and a repository is punctuation the
258 /// reader never presses, which is presentation and stays in the stylesheet.
259 /// Named rather than headed, because a heading carries text and this
260 /// carries two addresses.
261 #[must_use]
262 pub shape heading(owner: &str, repo: &str) -> Node;
263
264 region "git-repo-name" as Group {
265 named "{owner} / {repo}";
266 across Wrap {
267 beside Essential link owner to get "/git/{owner}" navigating;
268 beside Essential link repo to get "/git/{owner}/{repo}" navigating;
269 }
270 }
271 }
272
273 declare! {
274 /// The path from the repository root down to what is being shown.
275 ///
276 /// The last crumb is the thing itself and goes nowhere, which is what
277 /// `Breadcrumb::is_link` already says. The separators are the stylesheet's,
278 /// for [`heading`]'s reason.
279 #[must_use]
280 pub shape breadcrumb(
281 owner: &str,
282 repo: &str,
283 current_ref: &str,
284 crumbs: &[Breadcrumb],
285 ) -> Node;
286
287 let tree = "/git/{owner}/{repo}/tree/{current_ref}";
288
289 region "git-breadcrumb" as Group {
290 named "Path";
291 across Wrap {
292 beside Essential link repo to get tree.clone() navigating;
293 for crumb in crumbs.iter() {
294 beside Essential given crumb.is_link {
295 true -> link crumb.name.clone() to get "{tree}/{crumb.path}" navigating;
296 otherwise -> text crumb.name.clone();
297 }
298 }
299 }
300 }
301 }
302
303 #[cfg(test)]
304 mod tests {
305 use super::*;
306
307 fn refs() -> Vec<RefInfo> {
308 vec![
309 RefInfo {
310 name: "main".into(),
311 is_branch: true,
312 },
313 RefInfo {
314 name: "v1.0.0".into(),
315 is_branch: false,
316 },
317 ]
318 }
319
320 fn nav<'a>(refs: &'a [RefInfo], active: &'a str, is_owner: bool, issues: i64) -> Nav<'a> {
321 Nav {
322 owner: "ada",
323 repo: "engine",
324 current_ref: "main",
325 active_tab: active,
326 open_issue_count: issues,
327 is_owner,
328 refs,
329 }
330 }
331
332 /// The strip marks where the reader is, which is what `is-selected` said.
333 #[test]
334 fn the_current_tab_is_the_current_row() {
335 let refs = refs();
336 let html = html(&nav(&refs, "tags", false, 0));
337
338 let tags = html
339 .split("<li")
340 .find(|row| row.contains("/git/ada/engine/tags"))
341 .expect("a Tags row");
342 assert!(tags.contains("row-current"), "{html}");
343 assert_eq!(html.matches("row-current").count(), 1, "{html}");
344 }
345
346 /// A single commit is one of the commits Commits lists, so the reader is in
347 /// the same place and the strip says so.
348 #[test]
349 fn one_commit_marks_the_commits_tab() {
350 let refs = refs();
351 let html = html(&nav(&refs, "commit", false, 0));
352
353 let commits = html
354 .split("<li")
355 .find(|row| row.contains("/git/ada/engine/commits/"))
356 .expect("a Commits row");
357 assert!(commits.contains("row-current"), "{html}");
358 }
359
360 /// Settings is the owner's. A visitor is not offered a page they cannot
361 /// open.
362 #[test]
363 fn only_the_owner_is_offered_settings() {
364 let refs = refs();
365
366 assert!(html(&nav(&refs, "files", true, 0)).contains("/settings"));
367 assert!(!html(&nav(&refs, "files", false, 0)).contains("/settings"));
368 }
369
370 /// The count is part of the label, and a repository with no open issues
371 /// reads `Issues` rather than `Issues (0)`.
372 #[test]
373 fn the_issue_count_is_shown_only_when_there_is_one() {
374 let refs = refs();
375
376 assert!(html(&nav(&refs, "files", false, 3)).contains("Issues (3)"));
377 let none = html(&nav(&refs, "files", false, 0));
378 assert!(none.contains("Issues"), "{none}");
379 assert!(!none.contains("Issues ("), "{none}");
380 }
381
382 /// A branch is its bare name and a tag says it is one, which is the only
383 /// distinction the shipped list drew. The submitted value is the ref either
384 /// way.
385 #[test]
386 fn a_tag_reads_as_a_tag_and_submits_as_its_name() {
387 let refs = refs();
388 let html = html(&nav(&refs, "files", false, 0));
389
390 assert!(
391 html.contains("<option value=\"v1.0.0\">tag: v1.0.0</option>"),
392 "{html}"
393 );
394 assert!(
395 html.contains("<option value=\"main\" selected>main</option>"),
396 "{html}"
397 );
398 }
399
400 /// The chooser's answer goes to the join route, which redirects to the
401 /// canonical tree address. See the module header.
402 #[test]
403 fn choosing_a_ref_asks_the_join_route() {
404 let refs = refs();
405 let html = html(&nav(&refs, "files", false, 0));
406
407 assert!(html.contains("/git/ada/engine/ref"), "{html}");
408 assert!(html.contains("name=\"ref\""), "{html}");
409 }
410
411 /// A page that is about the repository rather than about a ref carries the
412 /// strip and no chooser: there is nothing there for choosing one to change.
413 #[test]
414 fn a_page_with_no_refs_still_carries_the_strip() {
415 let html = html(&nav(NO_REFS, "issues", true, 1));
416
417 assert!(!html.contains("<select"), "{html}");
418 assert!(html.contains("/git/ada/engine/tags"), "{html}");
419 assert!(html.contains("/git/ada/engine/notes"), "{html}");
420 }
421
422 /// A branch name is whatever somebody pushed. It goes into a document, and
423 /// the renderer escapes it.
424 #[test]
425 fn a_branch_name_cannot_smuggle_markup() {
426 let refs = vec![RefInfo {
427 name: "<script>alert(1)</script>".into(),
428 is_branch: true,
429 }];
430 let html = html(&nav(&refs, "files", false, 0));
431
432 assert!(!html.contains("<script>"), "{html}");
433 assert!(html.contains("&lt;script&gt;"), "{html}");
434 }
435 }
436