1use crate::html::{crumbs, page};
5use crate::request::esc;
6
7#[must_use]
10pub fn index_page(prefix: &str, repos: &[(String, String)]) -> String {
11 let mut body = String::from("<h1>Repositories</h1>");
12 if repos.is_empty() {
13 body.push_str("<p class=\"muted\">No repositories yet. Push to <code>");
14 body.push_str(&esc(prefix));
15 body.push_str("/<owner>/<repo>.git</code> to create one.</p>");
16 } else {
17 body.push_str("<table><tbody>");
18 for (owner, repo) in repos {
19 let href = format!("{prefix}/{owner}/{repo}");
20 body.push_str(&format!(
21 "<tr><td><a href=\"{}\">{}/{}</a></td></tr>",
22 esc(&href),
23 esc(owner),
24 esc(repo)
25 ));
26 }
27 body.push_str("</tbody></table>");
28 }
29 page("forge", &body)
30}
31
32#[must_use]
34pub fn owner_page(prefix: &str, owner: &str, repos: &[String]) -> String {
35 let mut body = crumbs(&[("forge", Some(prefix.to_string())), (owner, None)]);
36 body.push_str(&format!("<h1>{}</h1>", esc(owner)));
37 if repos.is_empty() {
38 body.push_str("<p class=\"muted\">No repositories.</p>");
39 } else {
40 body.push_str("<table><tbody>");
41 for repo in repos {
42 let href = format!("{prefix}/{owner}/{repo}");
43 body.push_str(&format!(
44 "<tr><td><a href=\"{}\">{}</a></td></tr>",
45 esc(&href),
46 esc(repo)
47 ));
48 }
49 body.push_str("</tbody></table>");
50 }
51 page(&format!("{owner} \u{2013} forge"), &body)
52}
53
54use crate::repo::browse::{EntryKind, TreeEntry};
59use solid_pod_rs_git::api::CommitEntry;
60
61fn child_path(base: &str, name: &str) -> String {
63 if base.is_empty() {
64 name.to_string()
65 } else {
66 format!("{base}/{name}")
67 }
68}
69
70fn repo_tabs(prefix: &str, owner: &str, repo: &str, rev: &str) -> String {
72 let base = format!("{prefix}/{owner}/{repo}");
73 format!(
74 "<nav class=\"crumbs\">\
75<a href=\"{b}\">code</a> \u{b7} \
76<a href=\"{b}/commits/{r}\">commits</a> \u{b7} \
77<a href=\"{b}/branches\">branches</a> \u{b7} \
78<a href=\"{b}/tags\">tags</a></nav>",
79 b = esc(&base),
80 r = esc(rev)
81 )
82}
83
84fn repo_header(prefix: &str, owner: &str, repo: &str, rev: &str) -> String {
85 let mut h = crumbs(&[
86 ("forge", Some(prefix.to_string())),
87 (owner, Some(format!("{prefix}/{owner}"))),
88 (repo, Some(format!("{prefix}/{owner}/{repo}"))),
89 ]);
90 h.push_str(&format!("<h1>{}/{}</h1>", esc(owner), esc(repo)));
91 h.push_str(&repo_tabs(prefix, owner, repo, rev));
92 h
93}
94
95fn tree_table(
97 prefix: &str,
98 owner: &str,
99 repo: &str,
100 rev: &str,
101 path: &str,
102 entries: &[TreeEntry],
103) -> String {
104 let base = format!("{prefix}/{owner}/{repo}");
105 let mut out = String::from("<table><tbody>");
106 if !path.is_empty() {
108 let parent = match path.rsplit_once('/') {
109 Some((p, _)) => p.to_string(),
110 None => String::new(),
111 };
112 out.push_str(&format!(
113 "<tr><td><a href=\"{}/tree/{}/{}\">..</a></td><td></td></tr>",
114 esc(&base),
115 esc(rev),
116 esc(&parent)
117 ));
118 }
119 for e in entries {
120 let cp = child_path(path, &e.name);
121 let (verb, glyph) = match e.kind {
122 EntryKind::Dir => ("tree", "\u{1f4c1}"),
123 EntryKind::Submodule => ("tree", "\u{1f517}"),
124 EntryKind::Symlink => ("blob", "\u{1f517}"),
125 EntryKind::File => ("blob", "\u{1f4c4}"),
126 };
127 let size = e.size.map(|s| s.to_string()).unwrap_or_default();
128 out.push_str(&format!(
129 "<tr><td>{} <a href=\"{}/{}/{}/{}\">{}</a></td><td class=\"muted\">{}</td></tr>",
130 glyph,
131 esc(&base),
132 verb,
133 esc(rev),
134 esc(&cp),
135 esc(&e.name),
136 esc(&size)
137 ));
138 }
139 out.push_str("</tbody></table>");
140 out
141}
142
143#[must_use]
145pub fn repo_overview_page(
146 prefix: &str,
147 owner: &str,
148 repo: &str,
149 rev: &str,
150 entries: &[TreeEntry],
151 readme: Option<&str>,
152) -> String {
153 let mut body = repo_header(prefix, owner, repo, rev);
154 if entries.is_empty() {
155 body.push_str("<p class=\"muted\">This repository is empty.</p>");
156 } else {
157 body.push_str(&tree_table(prefix, owner, repo, rev, "", entries));
158 }
159 if let Some(text) = readme {
160 body.push_str("<h2>README</h2><pre>");
161 body.push_str(&esc(text));
162 body.push_str("</pre>");
163 }
164 page(&format!("{owner}/{repo}"), &body)
165}
166
167#[must_use]
169pub fn tree_page(
170 prefix: &str,
171 owner: &str,
172 repo: &str,
173 rev: &str,
174 path: &str,
175 entries: &[TreeEntry],
176) -> String {
177 let mut body = repo_header(prefix, owner, repo, rev);
178 body.push_str(&format!(
179 "<p class=\"muted\">{} @ {}</p>",
180 esc(path),
181 esc(rev)
182 ));
183 body.push_str(&tree_table(prefix, owner, repo, rev, path, entries));
184 page(&format!("{owner}/{repo}: {path}"), &body)
185}
186
187#[must_use]
190pub fn blob_page(
191 prefix: &str,
192 owner: &str,
193 repo: &str,
194 rev: &str,
195 path: &str,
196 content: Option<&str>,
197) -> String {
198 let base = format!("{prefix}/{owner}/{repo}");
199 let mut body = repo_header(prefix, owner, repo, rev);
200 body.push_str(&format!(
201 "<p class=\"muted\">{} @ {} \u{b7} <a href=\"{}/raw/{}/{}\">raw</a></p>",
202 esc(path),
203 esc(rev),
204 esc(&base),
205 esc(rev),
206 esc(path)
207 ));
208 match content {
209 Some(text) => {
210 body.push_str("<pre>");
211 body.push_str(&esc(text));
212 body.push_str("</pre>");
213 }
214 None => {
215 body.push_str(&format!(
216 "<p>Binary file \u{2014} <a href=\"{}/raw/{}/{}\">download</a>.</p>",
217 esc(&base),
218 esc(rev),
219 esc(path)
220 ));
221 }
222 }
223 page(&format!("{owner}/{repo}: {path}"), &body)
224}
225
226#[must_use]
228pub fn commits_page(
229 prefix: &str,
230 owner: &str,
231 repo: &str,
232 rev: &str,
233 commits: &[CommitEntry],
234 page_num: u32,
235 has_next: bool,
236) -> String {
237 let base = format!("{prefix}/{owner}/{repo}");
238 let mut body = repo_header(prefix, owner, repo, rev);
239 if commits.is_empty() {
240 body.push_str("<p class=\"muted\">No commits.</p>");
241 } else {
242 body.push_str("<table><tbody>");
243 for c in commits {
244 body.push_str(&format!(
245 "<tr><td><a href=\"{}/commit/{}\"><code>{}</code></a></td>\
246<td>{}</td><td class=\"muted\">{} \u{b7} {}</td></tr>",
247 esc(&base),
248 esc(&c.hash),
249 esc(&c.short_hash),
250 esc(&c.message),
251 esc(&c.author),
252 esc(&c.date_relative)
253 ));
254 }
255 body.push_str("</tbody></table>");
256 body.push_str("<p>");
258 if page_num > 1 {
259 body.push_str(&format!(
260 "<a href=\"{}/commits/{}?page={}\">\u{2190} newer</a> ",
261 esc(&base),
262 esc(rev),
263 page_num - 1
264 ));
265 }
266 if has_next {
267 body.push_str(&format!(
268 "<a href=\"{}/commits/{}?page={}\">older \u{2192}</a>",
269 esc(&base),
270 esc(rev),
271 page_num + 1
272 ));
273 }
274 body.push_str("</p>");
275 }
276 page(&format!("{owner}/{repo}: commits"), &body)
277}
278
279#[must_use]
281pub fn commit_page(
282 prefix: &str,
283 owner: &str,
284 repo: &str,
285 meta: &solid_pod_rs_git::api::ResolvedCommit,
286 patch: &str,
287) -> String {
288 let mut body = repo_header(prefix, owner, repo, &meta.hash);
289 body.push_str(&format!(
290 "<h2><code>{}</code></h2><p>{}</p>\
291<p class=\"muted\">{} <{}></p>",
292 esc(&meta.hash),
293 esc(&meta.subject),
294 esc(&meta.author_name),
295 esc(&meta.author_email)
296 ));
297 if let Some(parent) = &meta.parent {
298 body.push_str(&format!(
299 "<p class=\"muted\">parent <a href=\"{p}/{o}/{r}/commit/{par}\"><code>{par}</code></a></p>",
300 p = esc(prefix),
301 o = esc(owner),
302 r = esc(repo),
303 par = esc(parent)
304 ));
305 }
306 body.push_str("<pre>");
307 body.push_str(&esc(patch));
308 body.push_str("</pre>");
309 page(&format!("{owner}/{repo}: commit"), &body)
310}
311
312#[must_use]
315pub fn refs_page(
316 prefix: &str,
317 owner: &str,
318 repo: &str,
319 title: &str,
320 current: Option<&str>,
321 names: &[String],
322 link_verb: &str,
323) -> String {
324 let base = format!("{prefix}/{owner}/{repo}");
325 let mut body = repo_header(prefix, owner, repo, current.unwrap_or("HEAD"));
326 body.push_str(&format!("<h2>{}</h2>", esc(title)));
327 if names.is_empty() {
328 body.push_str("<p class=\"muted\">None.</p>");
329 } else {
330 body.push_str("<table><tbody>");
331 for n in names {
332 let marker = if Some(n.as_str()) == current {
333 " <span class=\"badge\">default</span>"
334 } else {
335 ""
336 };
337 body.push_str(&format!(
338 "<tr><td><a href=\"{}/{}/{}\">{}</a>{}</td></tr>",
339 esc(&base),
340 link_verb,
341 esc(n),
342 esc(n),
343 marker
344 ));
345 }
346 body.push_str("</tbody></table>");
347 }
348 page(&format!("{owner}/{repo}: {title}"), &body)
349}
350
351use crate::bodies::{BodyOutcome, RenderedThread};
356use crate::spine::issues::{IssueEntry, IssueState};
357
358fn state_badge(state: IssueState) -> &'static str {
359 match state {
360 IssueState::Open => "<span class=\"badge state-open\">open</span>",
361 IssueState::Closed => "<span class=\"badge state-closed\">closed</span>",
362 IssueState::Merged => "<span class=\"badge state-merged\">merged</span>",
363 }
364}
365
366#[must_use]
368pub fn issues_list_page(
369 prefix: &str,
370 owner: &str,
371 repo: &str,
372 filter: IssueState,
373 open_count: usize,
374 closed_count: usize,
375 issues: &[&IssueEntry],
376) -> String {
377 let base = format!("{prefix}/{owner}/{repo}");
378 let mut body = repo_header(prefix, owner, repo, "HEAD");
379 body.push_str(&format!(
380 "<p><a href=\"{b}/issues?state=open\">{o} open</a> \u{b7} \
381<a href=\"{b}/issues?state=closed\">{c} closed</a> \u{b7} \
382<a href=\"{b}/issues/new\">new issue</a></p>",
383 b = esc(&base),
384 o = open_count,
385 c = closed_count
386 ));
387 let _ = filter;
388 if issues.is_empty() {
389 body.push_str("<p class=\"muted\">No issues.</p>");
390 } else {
391 body.push_str("<table><tbody>");
392 for e in issues {
393 body.push_str(&format!(
394 "<tr><td>{} <a href=\"{}/issues/{}\">#{} {}</a></td>\
395<td class=\"muted\">{}</td></tr>",
396 state_badge(e.state),
397 esc(&base),
398 e.number,
399 e.number,
400 esc(&e.title),
401 esc(&e.author)
402 ));
403 }
404 body.push_str("</tbody></table>");
405 }
406 page(&format!("{owner}/{repo}: issues"), &body)
407}
408
409#[must_use]
414pub fn issue_new_page(prefix: &str, owner: &str, repo: &str) -> String {
415 let base = format!("{prefix}/{owner}/{repo}");
416 let mut body = repo_header(prefix, owner, repo, "HEAD");
417 body.push_str(&format!(
418 "<h2>New issue</h2>\
419<form method=\"post\" action=\"{b}/issues\">\
420<p><label>Title<br><input name=\"title\" required></label></p>\
421<p><label>Body resource URL (in your pod's forge area)<br>\
422<input name=\"resourceUrl\" size=\"70\" placeholder=\"{b_esc}/public/forge/...\"></label></p>\
423<p><button type=\"submit\">Create</button></p>\
424</form>",
425 b = esc(&base),
426 b_esc = esc(&base)
427 ));
428 page(&format!("{owner}/{repo}: new issue"), &body)
429}
430
431fn render_body_outcome(out: &BodyOutcome) -> String {
432 match out {
433 BodyOutcome::Present(text) => format!("<pre>{}</pre>", esc(text)),
434 BodyOutcome::Removed => {
435 "<p class=\"muted\"><em>content removed by its author</em></p>".to_string()
436 }
437 BodyOutcome::TooLarge => {
438 "<p class=\"muted\"><em>body too large to display</em></p>".to_string()
439 }
440 BodyOutcome::Unavailable(_) => {
441 "<p class=\"muted\"><em>body currently unavailable</em></p>".to_string()
442 }
443 }
444}
445
446#[must_use]
449pub fn issue_detail_page(
450 prefix: &str,
451 owner: &str,
452 repo: &str,
453 entry: &IssueEntry,
454 threads: &[RenderedThread],
455 truncated: bool,
456) -> String {
457 let base = format!("{prefix}/{owner}/{repo}");
458 let mut body = repo_header(prefix, owner, repo, "HEAD");
459 body.push_str(&format!(
460 "<nav class=\"crumbs\"><a href=\"{}/issues\">issues</a></nav>",
461 esc(&base)
462 ));
463 body.push_str(&format!(
464 "<h2>{} #{} {}</h2>",
465 state_badge(entry.state),
466 entry.number,
467 esc(&entry.title)
468 ));
469 for (i, t) in threads.iter().enumerate() {
470 let role = if i == 0 { "opened" } else { "commented" };
471 body.push_str(&format!(
472 "<div><p class=\"muted\">{} {} \u{b7} {}</p>{}</div>",
473 esc(&t.author),
474 role,
475 t.at,
476 render_body_outcome(&t.body)
477 ));
478 }
479 if truncated {
480 body.push_str("<p class=\"muted\"><em>thread truncated at the read cap</em></p>");
481 }
482 body.push_str(&format!(
484 "<h3>Comment</h3>\
485<form method=\"post\" action=\"{b}/issues/{n}\">\
486<p><label>Body resource URL<br><input name=\"resourceUrl\" size=\"70\"></label></p>\
487<p><button type=\"submit\">Comment</button></p>\
488</form>",
489 b = esc(&base),
490 n = entry.number
491 ));
492 page(&format!("{owner}/{repo}: #{}", entry.number), &body)
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498
499 #[test]
500 fn index_empty_shows_hint() {
501 let p = index_page("/forge", &[]);
502 assert!(p.contains("No repositories yet"));
503 }
504
505 #[test]
506 fn index_lists_repos_escaped() {
507 let repos = vec![("alice".to_string(), "<b>".to_string())];
508 let p = index_page("/forge", &repos);
509 assert!(p.contains("/forge/alice/<b>"));
510 assert!(!p.contains("/forge/alice/<b>"));
511 }
512
513 #[test]
514 fn owner_page_renders() {
515 let p = owner_page("/forge", "alice", &["r1".into(), "r2".into()]);
516 assert!(p.contains("<h1>alice</h1>"));
517 assert!(p.contains("/forge/alice/r1"));
518 }
519}