Skip to main content

solid_pod_rs_forge/html/
views.rs

1//! Concrete page renderers. Phase 0 ships the index shell; browse and
2//! issue views are layered on in later phases.
3
4use crate::html::{crumbs, page};
5use crate::request::esc;
6
7/// The global landing page listing `(owner, repo)` pairs. Each row links
8/// to the repo overview. An empty list renders an explanatory note.
9#[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("/&lt;owner&gt;/&lt;repo&gt;.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/// A single owner's repo list.
33#[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
54// ---------------------------------------------------------------------------
55// Tier 1 browse views
56// ---------------------------------------------------------------------------
57
58use crate::repo::browse::{EntryKind, TreeEntry};
59use solid_pod_rs_git::api::CommitEntry;
60
61/// Join a listing base path with an entry name for a child URL segment.
62fn 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
70/// A repo-scoped tab bar (overview / commits / branches / tags).
71fn 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
95/// Render a directory listing table for `entries` at `<rev>:<path>`.
96fn 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    // Parent-dir row when not at the repo root.
107    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/// Repo overview: header, root tree at `rev`, and an optional README.
144#[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/// A subdirectory listing view.
168#[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/// A blob (file) view. `content` is `Some(text)` for a textual blob or
188/// `None` for a binary one (a download link is shown instead).
189#[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/// Paginated commit log view.
227#[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        // Pager.
257        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/// Single-commit view: metadata + the unified-diff patch in a `<pre>`.
280#[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\">{} &lt;{}&gt;</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/// A ref list (branches or tags). `link_verb` is `"commits"` for branches
313/// (link to the branch log) or `"tree"` for tags.
314#[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
351// ---------------------------------------------------------------------------
352// Tier 2 issue views
353// ---------------------------------------------------------------------------
354
355use 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/// The issues list, with an open/closed filter and a "new issue" link.
367#[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/// The new-issue form. The body document is PUT to the author's pod by
410/// the client first; this form submits the resulting `resourceUrl`
411/// pointer plus a title. (Podless agents submit the body inline — Tier
412/// 2.5.) Server-rendered, no script.
413#[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/// A single issue thread view: title/state header plus each re-fetched
447/// body. `truncated` warns when the thread exceeded the read cap.
448#[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    // Comment form (points to the same POST endpoint as the detail route).
483    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/&lt;b&gt;"));
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}