//! 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, latest_release: Option, /// Set while the repository has no changes (hub spec §6). quickstart: Option, fork: super::forks::ForkInfo, starred: bool, stars: i64, watchers: i64, topics: Vec, } /// A remote name from the hub's host: its first label when that is a /// plain lowercase word (`earth` for `https://earth.example.org:7343`), /// otherwise `hub`. fn remote_name(base_url: &str) -> String { let host = base_url.split("://").nth(1).unwrap_or("").split(['/', ':']).next().unwrap_or(""); let first = host.split('.').next().unwrap_or(""); let plain = first.len() <= 20 && first.starts_with(|c: char| c.is_ascii_lowercase()) && first.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()); if plain && first != "www" && first != "localhost" { first.to_string() } else { "hub".to_string() } } /// The commands that get a first push into an empty repository, filled /// in for this hub and this repository. pub struct Quickstart { /// A remote name taken from the hub's host name (`earth` for /// `earth.example.org`), `hub` when that is not a plain word. pub remote: String, /// Where to download the hub's certificate, when it is self-signed. pub ca_url: Option, /// Where the commands save that certificate. pub ca_file: String, /// Whether the viewer may push here. pub can_push: bool, } impl Quickstart { pub fn new(app: &Shared, can_push: bool) -> Quickstart { let base = app.config.base_url().trim_end_matches('/').to_string(); let remote = remote_name(&base); let ca_file = format!("~/.config/oxy/{remote}-ca.pem"); let ca_url = app.config.tls.self_signed.then(|| format!("{base}/.well-known/oxyhub-ca.pem")); Quickstart { remote, ca_url, ca_file, can_push } } /// Connect a checkout to this repository and push; the token line /// stays a placeholder so no secret is ever printed. pub fn connect(&self, clone_url: &str) -> String { let r = &self.remote; let mut s = format!("oxy remote add {r} {clone_url}\n"); if let Some(ca) = &self.ca_url { s += &format!("curl -fsSL {ca} -o {}\n", self.ca_file); s += &format!("oxy config remote.{r}.ca {}\n", self.ca_file); } s += &format!("oxy config remote.{r}.token YOUR-TOKEN\n"); s += &format!("oxy push {r}"); s } /// Start a repository from nothing. pub fn fresh(&self, name: &str) -> String { format!("mkdir {name} && cd {name}\noxy init\necho \"# {name}\" > README.md\noxy commit -m \"First change\"") } /// Bring a Git branch across. pub fn git_import(&self, name: &str) -> String { format!("oxy git import --branch main path/to/git-checkout {name}\ncd {name}") } } 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, }; let latest_release = crate::db::releases::latest(&app.db, row.id).await.ok().flatten(); let quickstart = (o.changes == 0).then(|| Quickstart::new(&app, rs.can(crate::auth::Access::Write))); let fork = super::forks::info(&app, &shell, &viewer, &row).await; let starred = match viewer.account() { Some(a) => crate::db::repos::starred(&app.db, a.id, row.id).await.unwrap_or(false), None => false, }; let (stars, watchers) = crate::db::repos::counts(&app.db, row.id).await.unwrap_or((0, 0)); let topics = row.topic_list().into_iter().map(String::from).collect(); Ok(Page(OverviewPage { shell, repo: rs, o, readme_html, watching, latest_release, quickstart, fork, starred, stars, watchers, topics })) } 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!("{}/raw/{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!("{}/raw/{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>, /// The `{ref}` of this state for good (hub spec §6.6). permalink: 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, /// 0 (the file's oldest line) to 9 (its newest), for shading. pub age: u8, /// The state address of the change's dependencies. pub before: Option, } /// Shade steps from `when` within the file's span of times. fn age_step(when: u64, oldest: u64, newest: u64) -> u8 { if newest <= oldest { return 9; } (((when.saturating_sub(oldest)) as u128 * 9 / (newest - oldest) as u128) as u8).min(9) } fn blame_rows( lines: &[oxy_core::repo::BlameLine], before: &std::collections::HashMap>, ) -> Vec { let oldest = lines.iter().map(|l| l.when_ms).min().unwrap_or(0); let newest = lines.iter().map(|l| l.when_ms).max().unwrap_or(0); 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, age: age_step(l.when_ms, oldest, newest), before: before.get(&l.hash).cloned().flatten(), }); } i += run; } rows } 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?; if path == super::flow::RUN_LINK && !has_file(&app, &repo, &r, &path).await { return Ok(axum::response::Redirect::to(&format!("/{owner}/{name}/flow/at/{r}")).into_response()); } 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)?; let rows = blame_rows(&b.lines, &b.before); Ok((b.file, Some(rows))) } 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, raw) = (format!("{}/file/{r}/{dir}", rs.url), format!("{}/raw/{r}/{dir}", rs.url)); (vec![], highlight::language(&f.path), Some(render::markdown::render(text, &base, &raw))) } else { (highlight::lines(&f.path, text), highlight::language(&f.path), None) } } _ => (vec![], None, None), }; let permalink = f.target.permalink(); Ok(Page(FilePage { shell, repo: rs, f, lines, language, markdown_html, is_image, is_markdown, blame, permalink }).into_response()) } /// Whether `path` is a file in the state `r` names. async fn has_file(app: &Shared, repo: &crate::repos::RepoName, r: &str, path: &str) -> bool { let (spec, p) = (r.to_string(), path.to_string()); app.repos .read(repo, move |repo, cache| { let target = view::resolve(repo, cache, &spec)?; Ok(cache.load(repo, &target.heads)?.snapshot.files.get(&p).is_some_and(|f| f.kind != oxy_core::objects::NodeKind::Dir)) }) .await .unwrap_or(false) } /// `GET /{owner}/{name}/raw/{ref}/{path}`: a file's bytes (hub spec §6.3), /// where relative images in rendered Markdown point. `.flow/stamp` is the /// state stamp of `{ref}` unless a real file is there (§13.2). pub async fn raw_file( State(app): State, viewer: Viewer, Path((owner, name, r, path)): Path<(String, String, String, String)>, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/raw/{r}/{path}"), &viewer); let (repo, _row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?; if path == super::flow::STAMP_IMAGE && !has_file(&app, &repo, &r, &path).await { return Ok(axum::response::Redirect::to(&format!("/{owner}/{name}/flow/stamp/{r}")).into_response()); } let (spec, p) = (r.clone(), path.clone()); let (file_name, bytes) = app .repos .read(&repo, move |repo, cache| { let target = view::resolve(repo, cache, &spec)?; view::raw(repo, cache, &target, &p) }) .await .map_err(|e| Error::from_repo(shell.clone(), e))?; Ok(super::raw(bytes, &file_name, true)) } #[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 => "◫", } } } #[derive(Deserialize)] pub struct CodeQuery { #[serde(default)] q: String, #[serde(default, rename = "ref")] r: Option, #[serde(default)] path: String, #[serde(default)] re: Option, #[serde(default)] case: Option, } #[derive(Template)] #[template(path = "code_search.html")] pub struct CodeSearchPage { shell: Shell, repo: RepoShell, q: String, r: String, path: String, regex: bool, case: bool, results: crate::repos::search::Results, error: Option, } /// `GET /{owner}/{name}/search?q=…&ref=…&re=1&case=1&path=…` (hub spec §11). pub async fn code_search( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Query(q): Query, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/search"), &viewer); let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let r = q.r.clone().filter(|x| !x.is_empty()).unwrap_or_else(|| row.default_bookmark.clone()); let (regex, case) = (q.re.is_some(), q.case.is_some()); let mut results = crate::repos::search::Results::default(); let mut error = None; if !q.q.trim().is_empty() { let (text, spec, prefix) = (q.q.clone(), r.clone(), q.path.trim_matches('/').to_string()); let app2 = app.clone(); let out = app .repos .read(&repo, move |repo, cache| { let target = view::resolve(repo, cache, &spec)?; let loaded = cache.load(repo, &target.heads)?; let state = oxy_core::objects::StateObject::new(loaded.heads.clone()).hash().to_hex(); let key = format!("{state}\0{text}\0{regex}\0{case}\0{prefix}"); if let Some(hit) = app2.repos.searches.get(&key) { return Ok(Ok(hit)); } let res = crate::repos::search::run( &loaded, &crate::repos::search::Query { text: &text, regex, case_sensitive: case, path_prefix: &prefix }, ); if let Ok(r) = &res { app2.repos.searches.put(key, r.clone()); } Ok(res) }) .await .map_err(|e| Error::from_repo(shell.clone(), e))?; match out { Ok(r) => results = r, Err(e) => error = Some(e), } } Ok(Page(CodeSearchPage { shell, repo: RepoShell::new(&app, &row, "code").with_role(role), q: q.q, r, path: q.path, regex, case, results, error, })) } #[cfg(test)] mod tests { use super::remote_name; #[test] fn remote_names_come_from_the_host() { assert_eq!(remote_name("https://earth.andymccall.xyz:7343"), "earth"); assert_eq!(remote_name("https://hub.example.org"), "hub"); assert_eq!(remote_name("http://127.0.0.1:7300"), "hub"); assert_eq!(remote_name("http://localhost:7300"), "hub"); assert_eq!(remote_name("https://www.example.org"), "hub"); assert_eq!(remote_name("https://My-Host.example"), "hub"); } }