//! 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 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 })) } /// 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, } /// `?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)) }; Ok(Page(ChangePage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), c, files, body_html, ignore_ws })) } #[derive(Template)] #[template(path = "compare.html")] pub struct ComparePage { shell: Shell, repo: RepoShell, cmp: view::Compare, files: Vec, ignore_ws: bool, } /// `/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; Ok(Page(ComparePage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), cmp, files, ignore_ws })) }