~//! `detail` handler renders a single commit view with full metadata, a diff~//! stat, and per-file inline diffs via `gix::diff::blob::UnifiedDiff`.~+use std::collections::HashMap;+use std::ops::ControlFlow;++use askama::Template;+use askama_web::WebTemplate;+use axum::extract::{Path, Query, State};~use axum::response::IntoResponse;+use gix::bstr::ByteSlice;+use serde::Deserialize;++use crate::error::{Error, Result};+use crate::routes::{AppState, RepoName, commit_date, committer_date};++use gix::diff::blob::UnifiedDiff;+use gix::diff::blob::platform::prepare_diff::Operation;+use gix::diff::blob::unified_diff::{ConsumeHunk, ContextSize, DiffLineKind, HunkHeader};++/// Number of commits per page in the log view.+const PAGE_SIZE: usize = 50;++// SVG graph layout constants.+// `ROW_H` is the per-row height, `LANE_W` is the per-lane width (spacing+// between parallel tracks), `NODE_R` is the dot radius, and `BEND_R` is+// the corner radius used when an edge changes lanes.+const ROW_H: f64 = 40.0;+const LANE_W: f64 = 20.0;+const NODE_R: f64 = 5.0;+const OUTER_R: f64 = ROW_H / 2.0;+const STROKE: f64 = 2.0;+const Y_MID: f64 = ROW_H / 2.0;+const BEND_R: f64 = 6.0;++/// Color palette used for SVG graph lanes.+/// Each lane gets one color from this cycle.+const COLORS: &[&str] = &[+ "var(--blue)",+ "var(--red)",+ "var(--green)",+ "var(--yellow)",+ "var(--violet)",+ "var(--magenta)",+ "var(--cyan)",+ "var(--orange)",+];++/// A single commit entry in the commit log.+struct CommitEntry {+ sha: String,+ message: String,+ date: String,+ parents: Vec<String>,+}++/// Loads up to `limit` commits from `HEAD`, walking ancestors.+fn load_commits(git_repo: &gix::Repository, limit: usize) -> Result<Vec<CommitEntry>> {+ let head = git_repo+ .head_commit()+ .map_err(|e| Error::GitCorrupt(e.to_string()))?;++ let entries = head+ .id()+ .ancestors()+ .all()+ .map_err(|e| Error::GitCorrupt(e.to_string()))?+ .filter_map(|info| {+ let info = info.ok()?;+ let commit = info.id().object().ok()?.try_into_commit().ok()?;++ Some(CommitEntry {+ sha: info.id().to_string(),+ message: commit.message().ok()?.summary().to_string(),+ date: commit_date(&commit).unwrap_or_default(),+ parents: commit.parent_ids().map(|id| id.to_string()).collect(),+ })+ })+ .take(limit)+ .collect();++ Ok(entries)+}++/// (row, column) positions for every commit in the current page, plus a+/// snapshot of active lanes entering the page from above so the SVG renderer+/// can draw continuation edges.+struct Layout {+ positions: HashMap<String, (usize, usize)>,+ active_at_page_start: Vec<Option<String>>,+}++/// Assigns each commit a (row, column) position for the SVG graph.+///+/// Uses a column-stealing algorithm: a commit claims the column its first+/// parent occupies, freeing the previous column for other branches. Lane+/// re-use keeps the graph narrow.+fn compute_layout(commits: &[CommitEntry], page_start: usize) -> Layout {+ let mut active: Vec<Option<&str>> = vec![];+ let mut positions = HashMap::new();+ let mut active_at_page_start = vec![];++ for (row, commit) in commits.iter().enumerate() {+ if row == page_start {+ active_at_page_start = active+ .iter()+ .map(|opt| opt.map(|s| s.to_string()))+ .collect();+ }++ let sha = commit.sha.as_str();+ let col = match active.iter().position(|s| *s == Some(sha)) {+ Some(i) => i,+ None => match active.iter().position(|s| s.is_none()) {+ Some(i) => {+ active[i] = Some(sha);+ i+ }+ None => {+ active.push(Some(sha));+ active.len() - 1+ }+ },+ };++ positions.insert(commit.sha.clone(), (row, col));++ if commit.parents.is_empty() {+ active[col] = None;+ while matches!(active.last(), Some(None)) {+ active.pop();+ }+ } else {+ let first = commit.parents[0].as_str();+ if active.iter().position(|s| *s == Some(first)) != Some(col) {+ active[col] = None;+ while matches!(active.last(), Some(None)) {+ active.pop();+ }+ if active.iter().all(|s| *s != Some(first)) {+ match active.iter().position(|s| s.is_none()) {+ Some(i) => active[i] = Some(first),+ None => active.push(Some(first)),+ }+ }+ }++ for parent in commit.parents.iter().skip(1) {+ if active.iter().any(|s| *s == Some(parent.as_str())) {+ continue;+ }+ match active.iter().position(|s| s.is_none()) {+ Some(i) => active[i] = Some(parent),+ None => active.push(Some(parent)),+ }+ }+ }+ }++ Layout {+ positions,+ active_at_page_start,+ }+}++/// Renders the SVG ancestry graph for commits[page_start..page_end].+///+/// Three passes:+/// 1. Continuation edges entering from above the viewport.+/// 2. Edges from each visible commit to its parents (straight lines for+/// same-column, L-shaped bends for lane changes, S-shaped curves for+/// merge arrows).+/// 3. Node circles and lane-highlight backgrounds on top.+fn render_page_svg(+ commits: &[CommitEntry],+ layout: &Layout,+ page_start: usize,+ page_end: usize,+) -> String {+ if page_start >= page_end {+ return String::new();+ }++ let mut all_cols: Vec<usize> = Vec::new();+ for c in &commits[page_start..page_end] {+ if let Some(&(_, col)) = layout.positions.get(&c.sha) {+ all_cols.push(col);+ }+ }++ let min_col = all_cols.iter().copied().min().unwrap_or(0);+ let page_max_col = all_cols.iter().copied().max().unwrap_or(0);+ let clamp_col = |c: usize| c.max(min_col).min(page_max_col);++ let svg_h = (page_end - page_start) as f64 * ROW_H;+ let svg_w = (page_max_col - min_col + 1) as f64 * LANE_W + OUTER_R;++ let lx = |col: usize| -> f64 { (col - min_col) as f64 * LANE_W + OUTER_R };+ let ry =+ |abs_row: usize| -> f64 { (abs_row.saturating_sub(page_start)) as f64 * ROW_H + Y_MID };++ let mut body = String::new();++ // Pass 1: continuation edges entering from above.+ for (col, opt_sha) in layout.active_at_page_start.iter().enumerate() {+ let Some(sha) = opt_sha else { continue };+ let Some(&(abs_row, _)) = layout.positions.get(sha.as_str()) else {+ continue;+ };+ let x = lx(clamp_col(col));+ let c = COLORS[col % COLORS.len()];+ let y_to = if abs_row < page_start {+ 0.0+ } else if abs_row < page_end {+ ry(abs_row)+ } else {+ svg_h+ };++ body.push_str(&format!(+ r#"<line x1="{x:.1}" y1="0.0" x2="{x:.1}" y2="{y_to:.1}" stroke="{c}" stroke-width="{STROKE}"/>"#+ ));+ }++ // Pass 2: edges originating from commits on this page.+ for commit in &commits[page_start..page_end] {+ let &(abs_row, col) = layout.positions.get(&commit.sha).unwrap();+ let x1 = lx(col);+ let y1 = ry(abs_row);+ let c = COLORS[col % COLORS.len()];++ for (idx, parent_sha) in commit.parents.iter().enumerate() {+ let (x2, y2, p_col) = match layout.positions.get(parent_sha.as_str()) {+ Some(&(p_row, p_col)) if p_row >= page_start && p_row < page_end => {+ (lx(p_col), ry(p_row), p_col)+ }+ Some(&(p_row, p_col)) => (+ lx(clamp_col(p_col)),+ if p_row < page_start { 0.0 } else { svg_h },+ p_col,+ ),+ None => {+ body.push_str(&format!(+ r#"<line x1="{x1:.1}" y1="{y1:.1}" x2="{x1:.1}" y2="{svg_h:.1}" stroke="{c}" stroke-width="{STROKE}"/>"#+ ));+ continue;+ }+ };++ if col == p_col {+ body.push_str(&format!(+ r#"<line x1="{x1:.1}" y1="{y1:.1}" x2="{x2:.1}" y2="{y2:.1}" stroke="{c}" stroke-width="{STROKE}"/>"#+ ));+ } else if idx == 0 {+ let r = BEND_R.min((x2 - x1).abs());+ let (arc_x, sweep) = if x2 < x1 { (x1 - r, 1) } else { (x1 + r, 0) };++ body.push_str(&format!(+ r#"<path d="M {x1:.1} {y1:.1} L {x1:.1} {:.1} A {r:.1} {r:.1} 0 0 {sweep} {arc_x:.1} {y2:.1} L {x2:.1} {y2:.1}" stroke="{c}" stroke-width="{STROKE}" fill="none"/>"#,+ y2 - r,+ ));+ } else {+ let pc = COLORS[p_col % COLORS.len()];+ let r = BEND_R.min((x2 - x1).abs());+ let (arc_x, sweep) = if x2 > x1 { (x2 - r, 1) } else { (x2 + r, 0) };++ body.push_str(&format!(+ r#"<path d="M {x1:.1} {y1:.1} L {arc_x:.1} {y1:.1} A {r:.1} {r:.1} 0 0 {sweep} {x2:.1} {:.1} L {x2:.1} {y2:.1}" stroke="{pc}" stroke-width="{STROKE}" fill="none"/>"#,+ y1 + r,+ ));+ }+ }+ }++ // Pass 3: circles on top.+ for commit in &commits[page_start..page_end] {+ let &(abs_row, col) = layout.positions.get(&commit.sha).unwrap();+ let cx = lx(col);+ let cy = ry(abs_row);+ let c = COLORS[col % COLORS.len()];+ let row_top = cy - Y_MID;+ let rect_x = cx - OUTER_R;++ body.push_str(&format!(+ r#"<path d="M {rect_x:.1} {top:.1} A {inner:.1} {inner:.1} 0 0 1 {right:.1} {row_top:.1} L {svg_w:.1} {row_top:.1} L {svg_w:.1} {bot:.1} L {right:.1} {bot:.1} A {inner:.1} {inner:.1} 0 0 1 {rect_x:.1} {bot_sub:.1} Z" fill="{c}" opacity="0.10"/>"#,+ top = row_top + OUTER_R,+ inner = OUTER_R,+ right = rect_x + OUTER_R,+ bot = row_top + ROW_H,+ bot_sub = row_top + ROW_H - OUTER_R,+ ));++ body.push_str(&format!(+ r#"<circle cx="{cx:.1}" cy="{cy:.1}" r="{NODE_R}" fill="{c}"/>"#+ ));+ }++ format!(+ r#"<svg width="{svg_w:.1}" height="{svg_h:.1}" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">{body}</svg>"#+ )+}++/// Query parameters for the commit log page.+#[derive(Deserialize, Default)]+pub struct PageQuery {+ /// Zero-based page index.+ #[serde(default)]+ page: usize,+}++/// A branch or tag ref pointing at a commit.+struct RefLabel {+ name: String,+ kind: String,+}++/// A single row of the commit log, ready for the template.+struct CommitRow {+ sha: String,+ message: String,+ date: String,+ color: String,+ refs: Vec<RefLabel>,+}++/// Template data for the paginated commit log page.+#[derive(Template, WebTemplate)]+#[template(path = "commit_list.html")]+struct CommitList {+ repo: String,+ graph_svg: String,+ commits: Vec<CommitRow>,+ page: usize,+ has_prev: bool,+ has_next: bool,+}~-pub async fn detail() -> impl IntoResponse {- "Commit detail"+/// Renders a paginated commit log with an SVG ancestry graph.+pub async fn list(+ State(state): State<AppState>,+ RepoName(repo): RepoName,+ Query(q): Query<PageQuery>,+) -> Result<impl IntoResponse> {+ let git_repo =+ gix::open(state.root.join(&repo)).map_err(|_| Error::RepoNotFound(repo.clone()))?;++ let page_start = q.page * PAGE_SIZE;+ let page_end = page_start + PAGE_SIZE;++ let entries = load_commits(&git_repo, page_end + 1)?;+ let has_next = entries.len() > page_end;+ let visible_end = page_end.min(entries.len());++ let layout = compute_layout(&entries, page_start);+ let graph_svg = render_page_svg(&entries, &layout, page_start, visible_end);++ let mut refs_by_sha: HashMap<String, Vec<RefLabel>> = HashMap::new();+ if let Ok(refs) = git_repo.references() {+ for mut branch in refs.local_branches().into_iter().flatten().flatten() {+ if let Ok(commit) = branch.peel_to_commit() {+ refs_by_sha+ .entry(commit.id().to_string())+ .or_default()+ .push(RefLabel {+ name: branch.name().shorten().to_string(),+ kind: "branch".into(),+ });+ }+ }++ for mut tag in refs.tags().into_iter().flatten().flatten() {+ if let Ok(commit) = tag.peel_to_commit() {+ refs_by_sha+ .entry(commit.id().to_string())+ .or_default()+ .push(RefLabel {+ name: tag.name().shorten().to_string(),+ kind: "tag".into(),+ });+ }+ }+ }++ let commits = entries[page_start..visible_end]+ .iter()+ .map(|e| CommitRow {+ color: layout+ .positions+ .get(&e.sha)+ .map(|&(_, col)| COLORS[col % COLORS.len()])+ .unwrap_or("")+ .to_string(),+ sha: e.sha.clone(),+ message: e.message.clone(),+ date: e.date.clone(),+ refs: refs_by_sha.remove(e.sha.as_str()).unwrap_or_default(),+ })+ .collect();++ Ok(CommitList {+ repo,+ graph_svg,+ commits,+ page: q.page,+ has_prev: q.page > 0,+ has_next,+ })+}++/// Path parameter for the commit detail page.+#[derive(Deserialize)]+pub(super) struct CommitPath {+ sha: String,+}++/// A single line inside a diff hunk, ready for the template.+///+/// `marker` is the visible prefix character (`+`, `-`, or `~`).+/// `css_class` is the CSS class suffix used to color the marker.+/// `content` is the rest of the line.+struct DiffLine {+ marker: String,+ css_class: String,+ content: String,+}++/// Collects hunks produced by `UnifiedDiff` into a `Vec<Vec<DiffLine>>`,+/// counting added and removed lines at the same time.+struct DiffHunkCollector<'a> {+ hunks: &'a mut Vec<Vec<DiffLine>>,+ file_added: &'a mut u64,+ file_removed: &'a mut u64,+}++impl ConsumeHunk for DiffHunkCollector<'_> {+ type Out = ();++ fn consume_hunk(+ &mut self,+ _header: HunkHeader,+ lines: &[(DiffLineKind, &[u8])],+ ) -> std::io::Result<()> {+ let mut hunk = Vec::new();+ for (kind, content) in lines {+ let content_str = std::str::from_utf8(content)+ .unwrap_or("<binary>")+ .to_string();++ let (marker, css_class) = match kind {+ DiffLineKind::Context => ("~".into(), "ctx".into()),+ DiffLineKind::Add => ("+".into(), "+".into()),+ DiffLineKind::Remove => ("-".into(), "-".into()),+ };++ if matches!(kind, DiffLineKind::Add) {+ *self.file_added += 1;+ } else if matches!(kind, DiffLineKind::Remove) {+ *self.file_removed += 1;+ }++ hunk.push(DiffLine {+ marker,+ css_class,+ content: content_str,+ });+ }++ self.hunks.push(hunk);+ Ok(())+ }++ fn finish(self) -> Self::Out {}+}++/// A single file touched by a commit, with its inline diff hunks.+struct FileChange {+ path: String,+ change_type: String,+ hunks: Vec<Vec<DiffLine>>,+}++/// Template data for the commit detail page.+#[derive(Template, WebTemplate)]+#[template(path = "commit.html")]+struct CommitDetail {+ repo: String,+ sha: String,+ author: String,+ committer: String,+ date: String,+ committer_date: String,+ message: String,+ parents: Vec<(String, String)>,+ refs: Vec<RefLabel>,+ files_changed: u64,+ lines_added: u64,+ lines_removed: u64,+ file_changes: Vec<FileChange>,~}++/// Displays a single commit: metadata, diff stat, and per-file inline diffs.+pub async fn detail(+ State(state): State<AppState>,+ RepoName(repo): RepoName,+ Path(params): Path<CommitPath>,+) -> Result<impl IntoResponse> {+ let sha = params.sha;++ let git_repo =+ gix::open(state.root.join(&repo)).map_err(|_| Error::RepoNotFound(repo.clone()))?;++ let oid = gix::ObjectId::from_hex(sha.as_bytes())+ .map_err(|_| Error::BadRequest(format!("invalid sha: {sha}")))?;++ let commit = git_repo+ .find_object(oid)+ .map_err(|_| Error::NotFound(format!("commit {sha}")))?+ .try_into_commit()+ .map_err(|_| Error::BadRequest(format!("{sha} is not a commit")))?;++ let sha = commit.id().to_string();++ let author = commit+ .author()+ .map(|a| format!("{} <{}>", a.name, a.email))+ .unwrap_or_default();++ let committer = commit+ .committer()+ .map(|c| format!("{} <{}>", c.name, c.email))+ .unwrap_or_default();++ let date = commit_date(&commit).unwrap_or_default();+ let committer_date = committer_date(&commit).unwrap_or_default();+ let message = commit.message_raw_sloppy().to_str_lossy().into_owned();++ let parents: Vec<(String, String)> = commit+ .parent_ids()+ .map(|id| {+ let s = id.to_string();+ let short = s[..8].to_string();+ (s, short)+ })+ .collect();++ // Collect refs (branches + tags) pointing to this commit.+ let mut refs: Vec<RefLabel> = Vec::new();+ if let Ok(rs) = git_repo.references() {+ for mut branch in rs.local_branches().into_iter().flatten().flatten() {+ if let Ok(c) = branch.peel_to_commit()+ && c.id().to_string() == sha+ {+ refs.push(RefLabel {+ name: branch.name().shorten().to_string(),+ kind: "branch".into(),+ });+ }+ }++ for mut tag in rs.tags().into_iter().flatten().flatten() {+ if let Ok(c) = tag.peel_to_commit()+ && c.id().to_string() == sha+ {+ refs.push(RefLabel {+ name: tag.name().shorten().to_string(),+ kind: "tag".into(),+ });+ }+ }+ }++ // Compute diff stat against the first parent (or empty tree for root commits).+ let commit_tree = commit+ .tree()+ .map_err(|e| Error::GitCorrupt(e.to_string()))?;++ let parent_tree = commit+ .parent_ids()+ .next()+ .and_then(|pid| pid.object().ok())+ .and_then(|o| o.try_into_commit().ok())+ .and_then(|c| c.tree().ok());++ let mut resource_cache = git_repo+ .diff_resource_cache_for_tree_diff()+ .map_err(|e| Error::GitCorrupt(e.to_string()))?;++ let mut files_changed = 0u64;+ let mut lines_added = 0u64;+ let mut lines_removed = 0u64;+ let mut file_changes: Vec<FileChange> = Vec::new();++ let source_tree = match parent_tree {+ Some(ref t) => t,+ None => &git_repo.empty_tree(),+ };++ source_tree+ .changes()+ .map_err(|e| Error::GitCorrupt(e.to_string()))?+ .for_each_to_obtain_tree(&commit_tree, |change| {+ // Skip directory entries - only show leaf-level file changes.+ if change.entry_mode().is_tree() {+ return Ok::<_, std::convert::Infallible>(ControlFlow::Continue(()));+ }++ let path = change.location().to_str_lossy().into_owned();+ let change_type = match change {+ gix::object::tree::diff::Change::Addition { .. } => "A",+ gix::object::tree::diff::Change::Deletion { .. } => "D",+ gix::object::tree::diff::Change::Modification { .. } => "M",+ gix::object::tree::diff::Change::Rewrite { .. } => "R",+ };++ files_changed += 1;++ let mut hunks = Vec::new();+ let mut added = 0u64;+ let mut removed = 0u64;++ if let Ok(platform) = change.diff(&mut resource_cache) {+ platform+ .resource_cache+ .options+ .skip_internal_diff_if_external_is_configured = false;+ if let Ok(prep) = platform.resource_cache.prepare_diff() {+ if let Operation::InternalDiff { algorithm } = prep.operation {+ let input = prep.interned_input();+ let collector = DiffHunkCollector {+ hunks: &mut hunks,+ file_added: &mut added,+ file_removed: &mut removed,+ };+ let sink = UnifiedDiff::new(&input, collector, ContextSize::symmetrical(3));+ let _ = gix::diff::blob::diff(algorithm, &input, sink);+ }+ }+ }++ resource_cache.clear_resource_cache_keep_allocation();++ lines_added += added;+ lines_removed += removed;++ file_changes.push(FileChange {+ path,+ change_type: change_type.to_string(),+ hunks,+ });++ Ok::<_, std::convert::Infallible>(ControlFlow::Continue(()))+ })+ .map_err(|e| Error::GitCorrupt(e.to_string()))?;~-pub async fn list() -> impl IntoResponse {- "Commit list"+ Ok(CommitDetail {+ repo,+ sha,+ author,+ committer,+ date,+ committer_date,+ message,+ parents,+ refs,+ files_changed,+ lines_added,+ lines_removed,+ file_changes,+ })~}