//! History, change and compare pages. use super::{filters, lookup, Error, Page, RepoShell, Shell}; use crate::auth::Viewer; use crate::render::{self, diff, graph}; use crate::repos::view; use crate::Shared; use askama::Template; use axum::extract::{Path, Query, State}; use axum::response::{IntoResponse, Response}; use oxy_core::repo::FileDiff; use serde::Deserialize; use std::collections::BTreeMap; const PAGE: usize = 50; #[derive(Deserialize)] pub struct HistoryQuery { path: Option, offset: Option, } #[derive(Template)] #[template(path = "history.html")] pub struct HistoryPage { shell: Shell, repo: RepoShell, h: view::History, rows: Vec, lanes: usize, } pub async fn history( State(app): State, viewer: Viewer, Path((owner, name, r)): Path<(String, String, String)>, Query(q): Query, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/changes/{r}"), &viewer); let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let spec = r.clone(); let path = q.path.clone().filter(|p| !p.is_empty()); let offset = q.offset.unwrap_or(0); let h = app .repos .read(&repo, move |repo, cache| { let target = view::resolve(repo, cache, &spec)?; view::history(repo, cache, target, path.as_deref(), offset, PAGE) }) .await .map_err(|e| Error::from_repo(shell.clone(), e))?; let mut h = h; super::attribute(&app, &mut h.entries.iter_mut().collect::>()).await; let graph_input: Vec<(String, Vec)> = h.entries.iter().map(|c| (c.hash.clone(), c.deps.clone())).collect(); let rows = graph::layout(&graph_input); let lanes = graph::width(&rows); Ok(Page(HistoryPage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), h, rows, lanes })) } #[derive(Deserialize)] pub struct FunctionQuery { start: usize, end: usize, #[serde(default)] name: String, } #[derive(Template)] #[template(path = "function.html")] pub struct FunctionPage { shell: Shell, repo: RepoShell, r: String, path: String, name: String, start: usize, end: usize, entries: Vec, rows: Vec, lanes: usize, } /// `GET /{owner}/{name}/function/{ref}/{path}?start=…&end=…&name=…`: the /// history of a function (hub spec §6.6): every change that wrote or /// deleted one of its lines, exactly (Oxygen spec §27), with a graph of how /// those changes depend on each other. pub async fn function( State(app): State, viewer: Viewer, Path((owner, name, r, path)): Path<(String, String, String, String)>, Query(q): Query, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/function/{r}/{path}"), &viewer); let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let (spec, p, start, end) = (r.clone(), path.clone(), q.start, q.end); let mut entries = app .repos .read(&repo, move |repo, cache| { let target = view::resolve(repo, cache, &spec)?; let history = repo.line_history(&target.heads, &p, start, end)?; let labels = view::head_labels(repo)?; Ok(history.iter().map(|e| view::change_view_pub(repo, &e.hash, &e.change, e.is_head, &labels)).collect::>()) }) .await .map_err(|e| Error::from_repo(shell.clone(), e))?; // The graph shows how these changes depend on one another. let listed: std::collections::HashSet = entries.iter().map(|c| c.hash.clone()).collect(); for c in &mut entries { c.deps.retain(|d| listed.contains(d)); } super::attribute(&app, &mut entries.iter_mut().collect::>()).await; let graph_input: Vec<(String, Vec)> = entries.iter().map(|c| (c.hash.clone(), c.deps.clone())).collect(); let rows = graph::layout(&graph_input); let lanes = graph::width(&rows); Ok(Page(FunctionPage { shell, repo: RepoShell::new(&app, &row, "code").with_role(role), r, path, name: q.name, start, end, entries, rows, lanes, })) } /// A file's diff, rendered. pub struct FileRows { pub path: String, pub from: Option, pub kind: String, pub lines: Vec, pub added: usize, pub removed: usize, pub anchor: String, } pub fn render_files(files: &[FileDiff], bases: &BTreeMap>) -> Vec { render_files_with(files, bases, diff::Options::default()) } /// `render_files` with diff options (hub spec §6.4: `?w=1` hides whitespace changes). pub fn render_files_with(files: &[FileDiff], bases: &BTreeMap>, opts: diff::Options) -> Vec { files .iter() .enumerate() .map(|(i, f)| { let base = bases.get(&f.path).map(Vec::as_slice).unwrap_or(b""); let lines = diff::unified_with(base, &f.hunks, 3, opts); FileRows { path: f.path.clone(), from: f.from.clone(), kind: f.kind.clone(), added: lines.iter().filter(|l| l.kind == diff::Kind::Add).count(), removed: lines.iter().filter(|l| l.kind == diff::Kind::Del).count(), lines, anchor: format!("diff-{i}"), } }) .collect() } #[derive(Template)] #[template(path = "change.html")] pub struct ChangePage { shell: Shell, repo: RepoShell, c: view::ChangeDetail, files: Vec, body_html: String, ignore_ws: bool, /// Receipts runners signed for the state whose only head is this change. receipts: Vec, } /// `?w=1` on a diff page hides whitespace-only changes. #[derive(Deserialize, Default)] pub struct DiffQuery { #[serde(default)] w: Option, } impl DiffQuery { pub fn options(&self) -> diff::Options { diff::Options { ignore_whitespace: self.w.as_deref() == Some("1") } } } pub async fn change( State(app): State, viewer: Viewer, Path((owner, name, hash)): Path<(String, String, String)>, Query(q): Query, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/change/{hash}"), &viewer); let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let spec = hash.clone(); let c = app .repos .read(&repo, move |repo, cache| { let h = view::resolve_change(repo, cache, &spec)?; view::change(repo, cache, &h) }) .await .map_err(|e| Error::from_repo(shell.clone(), e))?; let mut c = c; { let mut all: Vec<&mut view::ChangeView> = vec![&mut c.view]; all.extend(c.deps.iter_mut()); all.extend(c.dependents.iter_mut()); super::attribute(&app, &mut all).await; } let files = render_files_with(&c.files, &c.bases, q.options()); let ignore_ws = q.options().ignore_whitespace; let body_html = if c.view.body.is_empty() { String::new() } else { format!("
{}
", render::escape(&c.view.body)) }; let receipts = match oxy_core::hash::ChangeHash::from_hex(&c.view.hash) { Some(h) => super::flow::receipts_of(&app, &repo, vec![h]).await, None => vec![], }; Ok(Page(ChangePage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), c, files, body_html, ignore_ws, receipts })) } #[derive(Template)] #[template(path = "compare.html")] pub struct ComparePage { shell: Shell, repo: RepoShell, cmp: view::Compare, files: Vec, ignore_ws: bool, /// The two sides as given, for the pickers. base_spec: String, head_spec: String, /// Bookmarks and tags to pick from. bookmarks: Vec, tags: Vec, propose: Option, } /// "Propose this": head into base, when both are bookmarks and the viewer may write. pub struct Propose { pub url: String, /// An open proposal of head into base already. pub existing: Option, } #[derive(Deserialize)] pub struct CompareForm { base: Option, head: Option, } /// `/compare`: the pickers' form target, or a sensible first comparison: /// the most recently changed other bookmark against the default. pub async fn compare_index( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Query(q): Query, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/compare"), &viewer); let (repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let clean = |s: Option| s.map(|x| x.trim().to_string()).filter(|x| !x.is_empty() && !x.contains("...")); let (base, head) = match (clean(q.base), clean(q.head)) { (Some(b), Some(h)) => (b, h), (b, h) => { let default = row.default_bookmark.clone(); let d = default.clone(); let newest = app .repos .read(&repo, move |r, cache| { Ok(view::bookmarks(r, cache, &d)?.into_iter().filter(|b| !b.default).max_by_key(|b| b.latest_ms).map(|b| b.name)) }) .await .ok() .flatten(); (b.unwrap_or_else(|| default.clone()), h.unwrap_or_else(|| newest.unwrap_or(default))) } }; let enc = |s: &str| s.replace('%', "%25").replace(' ', "%20").replace('?', "%3F").replace('#', "%23"); Ok(axum::response::Redirect::to(&format!("/{owner}/{name}/compare/{}...{}", enc(&base), enc(&head))).into_response()) } /// `/compare/{base}...{head}`. pub async fn compare( State(app): State, viewer: Viewer, Path((owner, name, spec)): Path<(String, String, String)>, Query(q): Query, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/compare/{spec}"), &viewer); let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let Some((a, b)) = spec.split_once("...") else { return Err(Error::bad(shell, "compare takes base...head")); }; let (a, b) = (a.to_string(), b.to_string()); let cmp = app .repos .read(&repo, move |repo, cache| { let base = view::resolve(repo, cache, &a)?; let head = view::resolve(repo, cache, &b)?; view::compare(repo, cache, base, head) }) .await .map_err(|e| Error::from_repo(shell.clone(), e))?; let mut cmp = cmp; super::attribute(&app, &mut cmp.added.iter_mut().collect::>()).await; let files = render_files_with(&cmp.files, &cmp.bases, q.options()); let ignore_ws = q.options().ignore_whitespace; let rs = RepoShell::new(&app, &row, "changes").with_role(role); let (bookmarks, tags) = app .repos .read(&repo, |r, _| { let b: Vec = r.bookmarks()?.into_iter().map(|b| b.name).collect(); let t: Vec = view::tags(r)?.into_iter().map(|t| t.name).collect(); Ok((b, t)) }) .await .unwrap_or_default(); let both_bookmarks = cmp.base.kind == view::RefKind::Bookmark && cmp.head.kind == view::RefKind::Bookmark; let propose = if both_bookmarks && cmp.base.label != cmp.head.label && !cmp.added.is_empty() && rs.can(crate::auth::Access::Write) { let open = crate::db::proposals::list(&app.db, row.id, &["open"]).await.unwrap_or_default(); let existing = open .iter() .find(|p| p.source_kind == "bookmark" && p.source_bookmark == cmp.head.label && p.target == cmp.base.label) .map(|p| p.number); Some(Propose { url: format!("{}/proposals/new?source={}&target={}", rs.url, cmp.head.label, cmp.base.label), existing }) } else { None }; let (base_spec, head_spec) = spec.split_once("...").map(|(a, b)| (a.to_string(), b.to_string())).unwrap_or_default(); Ok(Page(ComparePage { shell, repo: rs, cmp, files, ignore_ws, base_spec, head_spec, bookmarks, tags, propose })) }