forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
14.1 KiB
1//! Page: directory listing at `/{owner}/{repo}/tree/{*rest}`. Also the shared directory helpers
2//! (entry rows, last commits, README) used by the repo home page.
3
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use std::time::Duration;
7
8use forge_core::Repo as RepoRow;
9use forge_core::tz::format_in_tz;
10use forge_git::browse::{self, BlobData, CommitInfo, EntryKind, RefInfo, TreeEntry};
11use forge_git::history;
12use topcoat::Result;
13use topcoat::context::{Cx, app_context};
14use topcoat::router::error::not_found;
15use topcoat::router::{page, path_param};
16use topcoat::view::{Unescaped, View, component, view};
17
18use crate::i18n::code::{self as g, K};
19use crate::i18n::{self, Lang};
20use crate::pages::refs::{load_refs, ref_links};
21use crate::pages::repo::repo_head;
22use crate::pages::repo_ctx::load_repo;
23use crate::raw::raw_url;
24use crate::render::emoji::emojify;
25use crate::render::markdown::{LinkContext, render_markdown};
26use crate::state::AppState;
27use crate::views::{
28 Crumb, breadcrumb, iso_time, now_unix, ref_selector, relative_time, repo_href, shell, short_oid,
29};
30
31/// Files at the root of a directory that count as its README, in priority order.
32pub const README_NAMES: [&str; 5] = [
33 "README.md",
34 "readme.md",
35 "README.markdown",
36 "README",
37 "README.txt",
38];
39
40/// Largest blob read for display.
41pub const MAX_BLOB_BYTES: usize = 1 << 20;
42/// Commits walked to find the last commit per entry.
43const LAST_COMMIT_WALK: usize = 2000;
44/// Time allowed for the last-commit walk before the rows are shown without it.
45const LAST_COMMIT_TIMEOUT: Duration = Duration::from_secs(3);
46
47path_param!(owner);
48path_param!(repo);
49path_param!(*rest);
50
51/// Splits a ref-and-path string into `(ref, commit oid, path)`. None for an unknown ref or path.
52pub async fn resolve(path: PathBuf, rest: String) -> Result<Option<(String, String, String)>> {
53 Ok(tokio::task::spawn_blocking(move || browse::split_ref_and_path(&path, &rest)).await??)
54}
55
56/// Entries of the directory `dir` at `commit`. None when `dir` is not a directory.
57pub async fn read_dir(
58 path: PathBuf,
59 commit: String,
60 dir: String,
61) -> Result<Option<Vec<TreeEntry>>> {
62 Ok(tokio::task::spawn_blocking(move || browse::read_tree(&path, &commit, &dir)).await??)
63}
64
65/// The blob at `file` in `commit`, capped at `MAX_BLOB_BYTES`. None when missing or a tree.
66pub async fn read_file(path: PathBuf, commit: String, file: String) -> Result<Option<BlobData>> {
67 Ok(tokio::task::spawn_blocking(move || {
68 browse::read_blob(&path, &commit, &file, MAX_BLOB_BYTES)
69 })
70 .await??)
71}
72
73/// Last commit per child name. Errors and the timeout give an empty map, so rows still render.
74pub async fn last_commits(
75 state: &AppState,
76 path: &Path,
77 commit: &str,
78 dir: &str,
79 names: &[String],
80) -> HashMap<String, CommitInfo> {
81 let walk = history::last_commits(&state.git, path, commit, dir, names, LAST_COMMIT_WALK);
82 match tokio::time::timeout(LAST_COMMIT_TIMEOUT, walk).await {
83 Ok(Ok(map)) => map,
84 Ok(Err(e)) => {
85 tracing::warn!(error = %e, "last-commit walk failed");
86 HashMap::new()
87 }
88 Err(_) => {
89 tracing::warn!("last-commit walk timed out");
90 HashMap::new()
91 }
92 }
93}
94
95/// `join(dir, name)` with `""` meaning the repo root.
96fn child_path(dir: &str, name: &str) -> String {
97 if dir.is_empty() {
98 name.to_owned()
99 } else {
100 format!("{dir}/{name}")
101 }
102}
103
104/// Last commit for one row, already formatted for display.
105pub struct LastCommit {
106 pub href: String,
107 pub summary: String,
108 pub iso: String,
109 /// Absolute time in the viewer's timezone, for the `title`.
110 pub absolute: String,
111 pub age: String,
112}
113
114/// One row of a directory listing.
115pub struct EntryRow {
116 /// "dir", "file", "exec", "link" or "submodule".
117 pub kind: &'static str,
118 pub name: String,
119 /// None for submodules, which have no tree in this repo.
120 pub href: Option<String>,
121 /// Short oid, shown for submodules only.
122 pub short: String,
123 pub last: Option<LastCommit>,
124}
125
126/// Rows for a directory listing. `last` is keyed by child name.
127#[allow(clippy::too_many_arguments)]
128pub fn entry_rows(
129 lang: Lang,
130 repo: &RepoRow,
131 ref_name: &str,
132 dir: &str,
133 entries: Vec<TreeEntry>,
134 last: &HashMap<String, CommitInfo>,
135 now: i64,
136 tz: &str,
137) -> Vec<EntryRow> {
138 entries
139 .into_iter()
140 .map(|entry| {
141 let rel = child_path(dir, &entry.name);
142 let (kind, href, short) = match entry.kind {
143 EntryKind::Tree => (
144 "dir",
145 Some(repo_href(
146 &repo.owner_name,
147 &repo.name,
148 "tree",
149 &format!("{ref_name}/{rel}"),
150 )),
151 String::new(),
152 ),
153 EntryKind::Blob => ("file", blob_href(repo, ref_name, &rel), String::new()),
154 EntryKind::Executable => ("exec", blob_href(repo, ref_name, &rel), String::new()),
155 EntryKind::Symlink => ("link", blob_href(repo, ref_name, &rel), String::new()),
156 EntryKind::Submodule => ("submodule", None, short_oid(&entry.oid).to_owned()),
157 };
158 let last = last
159 .get(&entry.name)
160 .map(|c| last_commit_view(lang, repo, c, now, tz));
161 EntryRow {
162 kind,
163 name: entry.name,
164 href,
165 short,
166 last,
167 }
168 })
169 .collect()
170}
171
172/// Display form of a commit: link, summary, ISO time and relative age.
173pub fn last_commit_view(
174 lang: Lang,
175 repo: &RepoRow,
176 commit: &CommitInfo,
177 now: i64,
178 tz: &str,
179) -> LastCommit {
180 LastCommit {
181 href: repo_href(&repo.owner_name, &repo.name, "commit", &commit.oid),
182 summary: emojify(&commit.summary).into_owned(),
183 iso: iso_time(commit.author_time),
184 absolute: format_in_tz(commit.author_time, tz),
185 age: relative_time(lang, commit.author_time, now),
186 }
187}
188
189fn blob_href(repo: &RepoRow, ref_name: &str, rel: &str) -> Option<String> {
190 Some(repo_href(
191 &repo.owner_name,
192 &repo.name,
193 "blob",
194 &format!("{ref_name}/{rel}"),
195 ))
196}
197
198/// Breadcrumb for `path` under `kind` ("tree" or "blob"). Every segment links.
199pub fn path_crumbs(repo: &RepoRow, ref_name: &str, kind: &str, path: &str) -> Vec<Crumb> {
200 let mut out = vec![Crumb {
201 label: repo.name.clone(),
202 href: Some(repo_href(&repo.owner_name, &repo.name, "", "")),
203 }];
204 let mut prefix = String::new();
205 for seg in path.split('/').filter(|s| !s.is_empty()) {
206 prefix = child_path(&prefix, seg);
207 out.push(Crumb {
208 label: seg.to_owned(),
209 href: Some(repo_href(
210 &repo.owner_name,
211 &repo.name,
212 kind,
213 &format!("{ref_name}/{prefix}"),
214 )),
215 });
216 }
217 out
218}
219
220/// Link context for markdown in the directory `dir`: relative links resolve against `dir`.
221pub fn link_context(state: &AppState, repo: &RepoRow, ref_name: &str, dir: &str) -> LinkContext {
222 let at = |section: &str| {
223 repo_href(
224 &repo.owner_name,
225 &repo.name,
226 section,
227 &format!("{ref_name}/{dir}"),
228 )
229 };
230 LinkContext {
231 tree_base: at("tree"),
232 blob_base: at("blob"),
233 raw_base: raw_url(state, repo, ref_name, dir)
234 .trim_end_matches('/')
235 .to_owned(),
236 issues_base: Some(repo_href(&repo.owner_name, &repo.name, "issues", "")),
237 }
238}
239
240/// A README ready to show. `markdown` is true when `body` is HTML from `render_markdown`;
241/// otherwise `body` is plain text and the view escapes it.
242pub struct Readme {
243 pub name: String,
244 pub markdown: bool,
245 pub body: String,
246}
247
248/// The README at the root of `dir`, if one of `README_NAMES` exists as a text blob.
249pub async fn load_readme(
250 state: &AppState,
251 repo: &RepoRow,
252 path: &Path,
253 ref_name: &str,
254 commit: &str,
255 dir: &str,
256 entries: &[TreeEntry],
257) -> Result<Option<Readme>> {
258 let found = README_NAMES.iter().find_map(|wanted| {
259 entries.iter().find(|e| {
260 e.name == *wanted && matches!(e.kind, EntryKind::Blob | EntryKind::Executable)
261 })
262 });
263 let Some(found) = found else {
264 return Ok(None);
265 };
266 let name = found.name.clone();
267 let file = child_path(dir, &name);
268 let Some(blob) = read_file(path.to_path_buf(), commit.to_owned(), file).await? else {
269 return Ok(None);
270 };
271 if blob.is_binary {
272 return Ok(None);
273 }
274 let text = String::from_utf8_lossy(&blob.data).into_owned();
275 if name.ends_with(".md") || name.ends_with(".markdown") {
276 let ctx = link_context(state, repo, ref_name, dir);
277 Ok(Some(Readme {
278 name,
279 markdown: true,
280 body: render_markdown(&text, Some(&ctx)),
281 }))
282 } else {
283 Ok(Some(Readme {
284 name,
285 markdown: false,
286 body: text,
287 }))
288 }
289}
290
291/// Directory listing table: kind, name, last commit and its age.
292#[component]
293pub async fn tree_table(rows: Vec<EntryRow>, lang: Lang) -> Result<impl View> {
294 Ok(view! {
295 <div class="table-wrap">
296 <table class="table tree-table">
297 <thead>
298 <tr>
299 <th>(g::t(lang, K::NameHeader))</th>
300 <th>(g::t(lang, K::LastCommitHeader))</th>
301 <th class="age">(g::t(lang, K::AgeHeader))</th>
302 </tr>
303 </thead>
304 <tbody>
305 for row in rows.into_iter() {
306 <tr>
307 <td class="tree-name">
308 <span class="kind">
309 if row.kind == "dir" {
310 <svg class="kind-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>
311 } else {
312 <svg class="kind-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg>
313 }
314 <span class="sr-only">(g::kind_label(lang, row.kind))</span>
315 </span>
316 <span class="tree-label">
317 if let Some(href) = row.href {
318 <a href=(href)>(row.name)</a>
319 } else {
320 <span>(row.name)</span>
321 <span class="muted small">(row.short)</span>
322 }
323 </span>
324 </td>
325 if let Some(last) = row.last {
326 <td class="tree-commit"><a href=(last.href)>(last.summary)</a></td>
327 <td class="age">
328 <time datetime=(last.iso.clone()) title=(last.absolute)>(last.age)</time>
329 </td>
330 } else {
331 <td class="tree-commit"></td>
332 <td class="age"></td>
333 }
334 </tr>
335 }
336 </tbody>
337 </table>
338 </div>
339 })
340}
341
342/// README card. Markdown HTML is embedded only from `render_markdown`; plain text is escaped.
343#[component]
344pub async fn readme_card(name: String, markdown: bool, body: String) -> Result<impl View> {
345 Ok(view! {
346 <section class="card readme">
347 <p class="readme-name muted small">(name)</p>
348 if markdown {
349 <div class="markdown-body">(Unescaped::new_unchecked(body))</div>
350 } else {
351 <pre class="readme-text">(body)</pre>
352 }
353 </section>
354 })
355}
356
357#[page("/{owner}/{repo}/tree/{*rest}")]
358async fn tree_page(cx: &Cx) -> Result<impl View> {
359 let owner = path_param::<Owner>(cx);
360 let name = path_param::<Repo>(cx);
361 let rest = path_param::<Rest>(cx).collect::<Vec<_>>().join("/");
362 let ctx = load_repo(cx, owner, name).await?;
363 let state = app_context::<AppState>(cx);
364 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user));
365 let repo = &ctx.repo;
366
367 let Some((ref_name, commit, dir)) = resolve(ctx.path.clone(), rest).await? else {
368 return Err(not_found().into());
369 };
370 let Some(entries) = read_dir(ctx.path.clone(), commit.clone(), dir.clone()).await? else {
371 return Err(not_found().into());
372 };
373 let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
374 let last = last_commits(state, &ctx.path, &commit, &dir, &names).await;
375 let refs: Vec<RefInfo> = load_refs(ctx.path.clone()).await?;
376 let readme = load_readme(state, repo, &ctx.path, &ref_name, &commit, &dir, &entries).await?;
377 let rows = entry_rows(
378 lang,
379 repo,
380 &ref_name,
381 &dir,
382 entries,
383 &last,
384 now_unix(),
385 ctx.viewer_tz(),
386 );
387 let (branches, tags) = ref_links(&refs, &ref_name, &repo.default_branch, repo);
388
389 let title = format!("{}/{}", repo.owner_name, repo.name);
390 let crumbs = path_crumbs(repo, &ref_name, "tree", &dir);
391 let header = ctx.head(state, "code").await?;
392 Ok(view! {
393 shell(title: title.as_str(), viewer: ctx.viewer.as_ref(),
394 repo_head(head: header)
395 <div class="browse-bar">
396 ref_selector(lang: lang, current: ref_name.clone(), branches: branches, tags: tags)
397 breadcrumb(lang: lang, crumbs: crumbs)
398 </div>
399 tree_table(rows: rows, lang: lang)
400 if let Some(readme) = readme {
401 readme_card(name: readme.name, markdown: readme.markdown, body: readme.body)
402 }
403 )
404 })
405}