//! Repository overview, tree, file, raw and blame pages. use super::{filters, lookup, Error, Page, RepoShell, Shell}; use crate::auth::Viewer; use crate::render::{self, highlight}; use crate::repos::view::{self, EntryKind, FileBody}; use crate::Shared; use askama::Template; use axum::extract::{Path, Query, State}; use axum::response::{IntoResponse, Response}; use serde::Deserialize; #[derive(Template)] #[template(path = "overview.html")] pub struct OverviewPage { shell: Shell, repo: RepoShell, o: view::Overview, readme_html: Option, watching: bool, } pub async fn overview( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}"), &viewer); let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let default = row.default_bookmark.clone(); let o = app.repos.read(&repo, move |r, cache| view::overview(r, cache, &default)).await.map_err(|e| Error::from_repo(shell.clone(), e))?; let rs = RepoShell::new(&app, &row, "code").with_role(role); let readme_html = o.readme_html(&rs, &o.tree.target.label); let watching = match viewer.account() { Some(a) => crate::db::notify::watching(&app.db, a.id, row.id).await.unwrap_or(false), None => false, }; Ok(Page(OverviewPage { shell, repo: rs, o, readme_html, watching })) } impl view::Overview { fn readme_html(&self, rs: &RepoShell, r: &str) -> Option { self.tree.readme.as_ref().map(|md| { let dir = md.path.rsplit_once('/').map(|(d, _)| format!("{d}/")).unwrap_or_default(); render::markdown::render(&md.text, &format!("{}/file/{r}/{dir}", rs.url), &format!("{}/file/{r}/{dir}", rs.url)) }) } } #[derive(Template)] #[template(path = "tree.html")] pub struct TreePage { shell: Shell, repo: RepoShell, t: view::Tree, readme_html: Option, } pub async fn tree_root( State(app): State, viewer: Viewer, Path((owner, name, r)): Path<(String, String, String)>, ) -> Result, Error> { tree(State(app), viewer, Path((owner, name, r, String::new()))).await } pub async fn tree( State(app): State, viewer: Viewer, Path((owner, name, r, path)): Path<(String, String, String, String)>, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/tree/{r}/{path}"), &viewer); let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let (spec, dir) = (r.clone(), path.clone()); let t = app .repos .read(&repo, move |repo, cache| { let target = view::resolve(repo, cache, &spec)?; view::tree(repo, cache, target, &dir) }) .await .map_err(|e| Error::from_repo(shell.clone(), e))?; let rs = RepoShell::new(&app, &row, "code").with_role(role); let readme_html = t.readme.as_ref().map(|md| { let dir = md.path.rsplit_once('/').map(|(d, _)| format!("{d}/")).unwrap_or_default(); render::markdown::render(&md.text, &format!("{}/file/{r}/{dir}", rs.url), &format!("{}/file/{r}/{dir}", rs.url)) }); Ok(Page(TreePage { shell, repo: rs, t, readme_html })) } #[derive(Deserialize)] pub struct FileQuery { plain: Option, blame: Option, /// `source` shows a Markdown file's text instead of rendering it. view: Option, } #[derive(Template)] #[template(path = "file.html")] pub struct FilePage { shell: Shell, repo: RepoShell, f: view::FileView, /// Highlighted lines when text. lines: Vec, language: Option<&'static str>, markdown_html: Option, is_image: bool, is_markdown: bool, blame: Option>, } /// A blame gutter entry: shown only on the first line of a run. pub struct BlameRow { pub first: bool, pub hash: String, pub short: String, pub author: String, pub when_ms: u64, pub run: usize, } pub async fn file( State(app): State, viewer: Viewer, Path((owner, name, r, path)): Path<(String, String, String, String)>, Query(q): Query, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/file/{r}/{path}"), &viewer); let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let limit = app.config.limits.render_limit.0; let want_blame = q.blame.is_some(); let (spec, p) = (r.clone(), path.clone()); let (f, blame) = app .repos .read(&repo, move |repo, cache| { let target = view::resolve(repo, cache, &spec)?; if want_blame { let b = view::blame(repo, cache, target, &p, limit)?; Ok((b.file, Some(b.lines))) } else { Ok((view::file(repo, cache, target, &p, limit)?, None)) } }) .await .map_err(|e| Error::from_repo(shell.clone(), e))?; if q.plain.is_some() { return Ok(super::raw(f.bytes, &f.name, true)); } let rs = RepoShell::new(&app, &row, "code").with_role(role); let is_image = super::mime_of(&f.name).starts_with("image/"); let is_markdown = f.name.to_lowercase().ends_with(".md") || f.name.to_lowercase() == "readme"; let want_source = q.view.as_deref() == Some("source") || blame.is_some(); let (lines, language, markdown_html) = match &f.body { FileBody::Text(text) if !is_image => { if is_markdown && !want_source { let dir = f.path.rsplit_once('/').map(|(d, _)| format!("{d}/")).unwrap_or_default(); let base = format!("{}/file/{r}/{dir}", rs.url); (vec![], highlight::language(&f.path), Some(render::markdown::render(text, &base, &base))) } else { (highlight::lines(&f.path, text), highlight::language(&f.path), None) } } _ => (vec![], None, None), }; let blame = blame.map(|lines| { let mut rows: Vec = Vec::with_capacity(lines.len()); let mut i = 0; while i < lines.len() { let mut run = 1; while i + run < lines.len() && lines[i + run].hash == lines[i].hash { run += 1; } for k in 0..run { let l = &lines[i + k]; rows.push(BlameRow { first: k == 0, hash: l.hash.to_hex(), short: l.hash.short(8), author: l.author.clone(), when_ms: l.when_ms, run, }); } i += run; } rows }); Ok(Page(FilePage { shell, repo: rs, f, lines, language, markdown_html, is_image, is_markdown, blame }).into_response()) } #[derive(Deserialize)] pub struct LastQuery { /// Comma-separated paths. paths: String, } /// `GET /{owner}/{name}/last/{ref}?paths=a,b` → JSON of last change per /// path, fetched by the tree page after it paints (hub spec §6.5). pub async fn last_changes( State(app): State, viewer: Viewer, Path((owner, name, r)): Path<(String, String, String)>, Query(q): Query, ) -> Result { let shell = Shell::new(&app, "", &viewer); let (repo, _, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let paths: Vec = q.paths.split(',').filter(|p| !p.is_empty()).take(500).map(String::from).collect(); let spec = r.clone(); let found = app .repos .read(&repo, move |repo, cache| { let target = view::resolve(repo, cache, &spec)?; view::last_changes(repo, cache, &target.heads, &paths) }) .await .map_err(|e| Error::from_repo(shell.clone(), e))?; let now = crate::db::now_ms() as u64; let body: Vec = found .into_iter() .map(|(p, c)| { serde_json::json!({ "path": p, "hash": c.hash, "short": c.short, "title": c.title, "ago": render::time::ago(c.when_ms, now), "when": render::time::absolute(c.when_ms) }) }) .collect(); Ok(axum::Json(body).into_response()) } impl EntryKind { pub fn glyph(&self) -> &'static str { match self { EntryKind::Dir => "▸", EntryKind::File => "·", EntryKind::Symlink => "↪", EntryKind::Subrepo => "◫", } } }