//! Page: directory listing at `/{owner}/{repo}/tree/{*rest}`. Also the shared directory helpers //! (entry rows, last commits, README) used by the repo home page. use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::time::Duration; use forge_core::Repo as RepoRow; use forge_core::tz::format_in_tz; use forge_git::browse::{self, BlobData, CommitInfo, EntryKind, RefInfo, TreeEntry}; use forge_git::history; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::error::not_found; use topcoat::router::{page, path_param}; use topcoat::view::{Unescaped, View, component, view}; use crate::i18n::code::{self as g, K}; use crate::i18n::{self, Lang}; use crate::pages::refs::{load_refs, ref_links}; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::load_repo; use crate::raw::raw_url; use crate::render::emoji::emojify; use crate::render::markdown::{LinkContext, render_markdown}; use crate::state::AppState; use crate::views::{ Crumb, breadcrumb, iso_time, now_unix, ref_selector, relative_time, repo_href, shell, short_oid, }; /// Files at the root of a directory that count as its README, in priority order. pub const README_NAMES: [&str; 5] = [ "README.md", "readme.md", "README.markdown", "README", "README.txt", ]; /// Largest blob read for display. pub const MAX_BLOB_BYTES: usize = 1 << 20; /// Commits walked to find the last commit per entry. const LAST_COMMIT_WALK: usize = 2000; /// Time allowed for the last-commit walk before the rows are shown without it. const LAST_COMMIT_TIMEOUT: Duration = Duration::from_secs(3); path_param!(owner); path_param!(repo); path_param!(*rest); /// Splits a ref-and-path string into `(ref, commit oid, path)`. None for an unknown ref or path. pub async fn resolve(path: PathBuf, rest: String) -> Result> { Ok(tokio::task::spawn_blocking(move || browse::split_ref_and_path(&path, &rest)).await??) } /// Entries of the directory `dir` at `commit`. None when `dir` is not a directory. pub async fn read_dir( path: PathBuf, commit: String, dir: String, ) -> Result>> { Ok(tokio::task::spawn_blocking(move || browse::read_tree(&path, &commit, &dir)).await??) } /// The blob at `file` in `commit`, capped at `MAX_BLOB_BYTES`. None when missing or a tree. pub async fn read_file(path: PathBuf, commit: String, file: String) -> Result> { Ok(tokio::task::spawn_blocking(move || { browse::read_blob(&path, &commit, &file, MAX_BLOB_BYTES) }) .await??) } /// Last commit per child name. Errors and the timeout give an empty map, so rows still render. pub async fn last_commits( state: &AppState, path: &Path, commit: &str, dir: &str, names: &[String], ) -> HashMap { let walk = history::last_commits(&state.git, path, commit, dir, names, LAST_COMMIT_WALK); match tokio::time::timeout(LAST_COMMIT_TIMEOUT, walk).await { Ok(Ok(map)) => map, Ok(Err(e)) => { tracing::warn!(error = %e, "last-commit walk failed"); HashMap::new() } Err(_) => { tracing::warn!("last-commit walk timed out"); HashMap::new() } } } /// `join(dir, name)` with `""` meaning the repo root. fn child_path(dir: &str, name: &str) -> String { if dir.is_empty() { name.to_owned() } else { format!("{dir}/{name}") } } /// Last commit for one row, already formatted for display. pub struct LastCommit { pub href: String, pub summary: String, pub iso: String, /// Absolute time in the viewer's timezone, for the `title`. pub absolute: String, pub age: String, } /// One row of a directory listing. pub struct EntryRow { /// "dir", "file", "exec", "link" or "submodule". pub kind: &'static str, pub name: String, /// None for submodules, which have no tree in this repo. pub href: Option, /// Short oid, shown for submodules only. pub short: String, pub last: Option, } /// Rows for a directory listing. `last` is keyed by child name. #[allow(clippy::too_many_arguments)] pub fn entry_rows( lang: Lang, repo: &RepoRow, ref_name: &str, dir: &str, entries: Vec, last: &HashMap, now: i64, tz: &str, ) -> Vec { entries .into_iter() .map(|entry| { let rel = child_path(dir, &entry.name); let (kind, href, short) = match entry.kind { EntryKind::Tree => ( "dir", Some(repo_href( &repo.owner_name, &repo.name, "tree", &format!("{ref_name}/{rel}"), )), String::new(), ), EntryKind::Blob => ("file", blob_href(repo, ref_name, &rel), String::new()), EntryKind::Executable => ("exec", blob_href(repo, ref_name, &rel), String::new()), EntryKind::Symlink => ("link", blob_href(repo, ref_name, &rel), String::new()), EntryKind::Submodule => ("submodule", None, short_oid(&entry.oid).to_owned()), }; let last = last .get(&entry.name) .map(|c| last_commit_view(lang, repo, c, now, tz)); EntryRow { kind, name: entry.name, href, short, last, } }) .collect() } /// Display form of a commit: link, summary, ISO time and relative age. pub fn last_commit_view( lang: Lang, repo: &RepoRow, commit: &CommitInfo, now: i64, tz: &str, ) -> LastCommit { LastCommit { href: repo_href(&repo.owner_name, &repo.name, "commit", &commit.oid), summary: emojify(&commit.summary).into_owned(), iso: iso_time(commit.author_time), absolute: format_in_tz(commit.author_time, tz), age: relative_time(lang, commit.author_time, now), } } fn blob_href(repo: &RepoRow, ref_name: &str, rel: &str) -> Option { Some(repo_href( &repo.owner_name, &repo.name, "blob", &format!("{ref_name}/{rel}"), )) } /// Breadcrumb for `path` under `kind` ("tree" or "blob"). Every segment links. pub fn path_crumbs(repo: &RepoRow, ref_name: &str, kind: &str, path: &str) -> Vec { let mut out = vec![Crumb { label: repo.name.clone(), href: Some(repo_href(&repo.owner_name, &repo.name, "", "")), }]; let mut prefix = String::new(); for seg in path.split('/').filter(|s| !s.is_empty()) { prefix = child_path(&prefix, seg); out.push(Crumb { label: seg.to_owned(), href: Some(repo_href( &repo.owner_name, &repo.name, kind, &format!("{ref_name}/{prefix}"), )), }); } out } /// Link context for markdown in the directory `dir`: relative links resolve against `dir`. pub fn link_context(state: &AppState, repo: &RepoRow, ref_name: &str, dir: &str) -> LinkContext { let at = |section: &str| { repo_href( &repo.owner_name, &repo.name, section, &format!("{ref_name}/{dir}"), ) }; LinkContext { tree_base: at("tree"), blob_base: at("blob"), raw_base: raw_url(state, repo, ref_name, dir) .trim_end_matches('/') .to_owned(), issues_base: Some(repo_href(&repo.owner_name, &repo.name, "issues", "")), } } /// A README ready to show. `markdown` is true when `body` is HTML from `render_markdown`; /// otherwise `body` is plain text and the view escapes it. pub struct Readme { pub name: String, pub markdown: bool, pub body: String, } /// The README at the root of `dir`, if one of `README_NAMES` exists as a text blob. pub async fn load_readme( state: &AppState, repo: &RepoRow, path: &Path, ref_name: &str, commit: &str, dir: &str, entries: &[TreeEntry], ) -> Result> { let found = README_NAMES.iter().find_map(|wanted| { entries.iter().find(|e| { e.name == *wanted && matches!(e.kind, EntryKind::Blob | EntryKind::Executable) }) }); let Some(found) = found else { return Ok(None); }; let name = found.name.clone(); let file = child_path(dir, &name); let Some(blob) = read_file(path.to_path_buf(), commit.to_owned(), file).await? else { return Ok(None); }; if blob.is_binary { return Ok(None); } let text = String::from_utf8_lossy(&blob.data).into_owned(); if name.ends_with(".md") || name.ends_with(".markdown") { let ctx = link_context(state, repo, ref_name, dir); Ok(Some(Readme { name, markdown: true, body: render_markdown(&text, Some(&ctx)), })) } else { Ok(Some(Readme { name, markdown: false, body: text, })) } } /// Directory listing table: kind, name, last commit and its age. #[component] pub async fn tree_table(rows: Vec, lang: Lang) -> Result { Ok(view! {
for row in rows.into_iter() { if let Some(last) = row.last { } else { } }
(g::t(lang, K::NameHeader)) (g::t(lang, K::LastCommitHeader)) (g::t(lang, K::AgeHeader))
if row.kind == "dir" { } else { } (g::kind_label(lang, row.kind)) if let Some(href) = row.href { (row.name) } else { (row.name) (row.short) } (last.summary)
}) } /// README card. Markdown HTML is embedded only from `render_markdown`; plain text is escaped. #[component] pub async fn readme_card(name: String, markdown: bool, body: String) -> Result { Ok(view! {

(name)

if markdown {
(Unescaped::new_unchecked(body))
} else {
(body)
}
}) } #[page("/{owner}/{repo}/tree/{*rest}")] async fn tree_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let rest = path_param::(cx).collect::>().join("/"); let ctx = load_repo(cx, owner, name).await?; let state = app_context::(cx); let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user)); let repo = &ctx.repo; let Some((ref_name, commit, dir)) = resolve(ctx.path.clone(), rest).await? else { return Err(not_found().into()); }; let Some(entries) = read_dir(ctx.path.clone(), commit.clone(), dir.clone()).await? else { return Err(not_found().into()); }; let names: Vec = entries.iter().map(|e| e.name.clone()).collect(); let last = last_commits(state, &ctx.path, &commit, &dir, &names).await; let refs: Vec = load_refs(ctx.path.clone()).await?; let readme = load_readme(state, repo, &ctx.path, &ref_name, &commit, &dir, &entries).await?; let rows = entry_rows( lang, repo, &ref_name, &dir, entries, &last, now_unix(), ctx.viewer_tz(), ); let (branches, tags) = ref_links(&refs, &ref_name, &repo.default_branch, repo); let title = format!("{}/{}", repo.owner_name, repo.name); let crumbs = path_crumbs(repo, &ref_name, "tree", &dir); let header = ctx.head(state, "code").await?; Ok(view! { shell(title: title.as_str(), viewer: ctx.viewer.as_ref(), repo_head(head: header)
ref_selector(lang: lang, current: ref_name.clone(), branches: branches, tags: tags) breadcrumb(lang: lang, crumbs: crumbs)
tree_table(rows: rows, lang: lang) if let Some(readme) = readme { readme_card(name: readme.name, markdown: readme.markdown, body: readme.body) } ) }) }