use axum::extract::{Path, Query, State}; use axum::response::Response; use maud::{Markup, html}; use crate::handler::html::error::html_err; use crate::handler::html::{HtmlRepoPath, archived_class, resolve, response_html, short_sha}; use crate::service::GitService; use crate::service::diffs::ChangedPathDiffDto; use crate::share::access::BasicAuth; const MAX_FILES: usize = 50; const MAX_LINES_PER_FILE: usize = 2000; #[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)] pub struct CommitDiffQuery { #[serde(default)] pub commit: Option, #[serde(default)] pub style: Option, } #[utoipa::path( get, path = "/repos/{space_name}/{repo_name}/html/commit-diff", tag = "html", description = "渲染单个提交相对首父的差异 HTML 片段。", operation_id = "render_commit_diff", params(HtmlRepoPath, CommitDiffQuery), responses( (status = 200, description = "commit diff html fragment", content_type = "text/html"), (status = 404, description = "repo or commit not found") ) )] pub async fn render_commit_diff( State(service): State, Path(path): Path, Query(q): Query, auth: BasicAuth, ) -> Response { let ctx = match resolve(service.clone(), path, auth).await { Ok(c) => c, Err(resp) => return resp, }; let commit = match q.commit.as_deref() { Some(c) if !c.trim().is_empty() => c.trim().to_string(), _ => return html_err(404, "commit required"), }; if commit.len() > 256 || commit.contains(['\0', ' ', '\\']) { return html_err(404, "commit invalid"); } let (base, files) = match ctx.service.commit_diff_for_html(&ctx.ctx, &commit).await { Ok(v) => v, Err(e) => { tracing::warn!(error = %e, "commit diff internal"); return html_err(500, "internal"); } }; let style = match q.style.as_deref() { Some("split") => "split", Some("unified") => "unified", _ => { let pref = ctx.service.user_diff_view_style(ctx.uid).await; if pref == "split" { "split" } else { "unified" } } }; let total_files = files.len(); let truncated_files = total_files > MAX_FILES; let shown: Vec<&ChangedPathDiffDto> = files.iter().take(MAX_FILES).collect(); let commit_short = short_sha(&commit); let base_short = short_sha(&base); let is_root = base == "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; let base_cls = "git-commit-diff flex flex-col gap-4"; let cls = archived_class(&ctx.dto, base_cls); let space = ctx.space_name.clone(); let repo = ctx.repo_name.clone(); let markup = html! { div class=(cls) data-component="git-commit-diff" data-space=(space) data-repo=(repo) data-commit=(commit) data-base=(base) data-diff-style=(style) data-total-files=(total_files) data-truncated-files=(if truncated_files { "true" } else { "false" }) data-archived=(if ctx.dto.is_archived { "true" } else { "false" }) { div class="flex flex-wrap items-center gap-2 text-xs text-zinc-500 font-mono" { span class="rounded bg-zinc-100 px-1.5 py-0.5 text-zinc-700" { (commit_short) } span { "vs" } @if is_root { span class="rounded bg-zinc-100 px-1.5 py-0.5" { "root (empty tree)" } } @else { span class="rounded bg-zinc-100 px-1.5 py-0.5" { (base_short) } } span class="ml-auto" { (total_files) " changed files" } } @for f in &shown { (render_file(f, style)) } @if truncated_files { div class="rounded-lg border border-dashed border-zinc-300 bg-zinc-50 p-4 text-center text-xs text-zinc-500" { (total_files - MAX_FILES) " more files not shown" } } } }; response_html(markup, 30) } struct DiffRow { old_no: Option, new_no: Option, kind: RowKind, content: String, } enum RowKind { Add, Del, Ctx, } fn parse_hunk_header(line: &str) -> Option<(u32, u32)> { let rest = line.strip_prefix("@@ ")?.split(" @@").next()?; let mut parts = rest.split(' '); let old = parts.next()?.trim_start_matches('-'); let new = parts.next()?.trim_start_matches('+'); let old_start = old.split(',').next()?.parse().ok()?; let new_start = new.split(',').next()?.parse().ok()?; Some((old_start, new_start)) } fn parse_patch_rows(patch: &str, cap: usize) -> (Vec, bool) { let mut rows = Vec::new(); let mut old_no = 0u32; let mut new_no = 0u32; let mut in_hunk = false; let mut truncated = false; for line in patch.lines() { if truncated { break; } if line.starts_with("@@") { if let Some((o, n)) = parse_hunk_header(line) { old_no = o; new_no = n; } in_hunk = true; continue; } if !in_hunk { continue; } if line.starts_with("\\") { continue; } if rows.len() >= cap { truncated = true; break; } if let Some(rest) = line.strip_prefix('+') { rows.push(DiffRow { old_no: None, new_no: Some(new_no), kind: RowKind::Add, content: rest.to_string(), }); new_no = new_no.saturating_add(1); } else if let Some(rest) = line.strip_prefix('-') { rows.push(DiffRow { old_no: Some(old_no), new_no: None, kind: RowKind::Del, content: rest.to_string(), }); old_no = old_no.saturating_add(1); } else { let rest = line.strip_prefix(' ').unwrap_or(line); rows.push(DiffRow { old_no: Some(old_no), new_no: Some(new_no), kind: RowKind::Ctx, content: rest.to_string(), }); old_no = old_no.saturating_add(1); new_no = new_no.saturating_add(1); } } (rows, truncated) } fn status_badge(status: &str) -> (&'static str, &'static str) { match status { "Added" => ("A", "bg-emerald-100 text-emerald-700"), "Deleted" => ("D", "bg-red-100 text-red-700"), "Renamed" => ("R", "bg-sky-100 text-sky-700"), "Copied" => ("C", "bg-sky-100 text-sky-700"), "TypeChanged" => ("T", "bg-amber-100 text-amber-700"), _ => ("M", "bg-zinc-100 text-zinc-700"), } } fn render_file(f: &ChangedPathDiffDto, style: &str) -> Markup { let (badge, badge_cls) = status_badge(&f.path.status); let display_path = match (&f.path.old_path, &f.path.new_path) { (Some(old), new) if old != new => format!("{old} → {new}"), (_, new) => new.clone(), }; let (rows, truncated) = match (&f.patch, f.binary) { (Some(p), false) => parse_patch_rows(p, MAX_LINES_PER_FILE), _ => (Vec::new(), false), }; html! { div class="rounded-lg border border-zinc-200 bg-white overflow-hidden" data-file=(f.path.new_path) data-status=(f.path.status) data-additions=(f.lines_added) data-deletions=(f.lines_removed) data-binary=(if f.binary { "true" } else { "false" }) { div class="flex flex-wrap items-center gap-2 border-b border-zinc-200 bg-zinc-50 px-3 py-2" { span class={ "rounded px-1.5 py-0.5 font-mono text-[11px] font-semibold " (badge_cls) } { (badge) } span class="font-mono text-xs text-zinc-800 truncate" { (display_path) } span class="ml-auto font-mono text-xs" { span class="text-emerald-600" { "+" (f.lines_added) } " " span class="text-red-600" { "−" (f.lines_removed) } } } @if f.binary { div class="p-3 text-center text-xs text-zinc-500 bg-zinc-50" { "Binary file not shown" } } @else if rows.is_empty() { div class="p-3 text-center text-xs text-zinc-500 bg-zinc-50" { "No content changes" } } @else if style == "split" { (render_split(&rows)) } @else { (render_unified(&rows)) } @if truncated { div class="border-t border-zinc-200 bg-zinc-50 px-3 py-1.5 text-center text-[11px] text-zinc-500" { "diff truncated" } } } } } fn render_unified(rows: &[DiffRow]) -> Markup { html! { div class="overflow-x-auto" { table class="w-full border-collapse text-xs font-mono" { tbody { @for r in rows { @let (row_cls, marker) = match r.kind { RowKind::Add => ("bg-emerald-50", "+"), RowKind::Del => ("bg-red-50", "−"), RowKind::Ctx => ("", " "), }; tr class=(row_cls) { td class="w-10 select-none border-r border-zinc-100 px-2 py-0 text-right text-zinc-400" { (r.old_no.map(|n| n.to_string()).unwrap_or_default()) } td class="w-10 select-none border-r border-zinc-100 px-2 py-0 text-right text-zinc-400" { (r.new_no.map(|n| n.to_string()).unwrap_or_default()) } td class="w-4 select-none text-center" { (marker) } td class="whitespace-pre px-2 py-0 text-zinc-900" { (r.content) } } } } } } } } fn render_split(rows: &[DiffRow]) -> Markup { fn flush( dels: &mut Vec, adds: &mut Vec, pairs: &mut Vec<(Option, Option)>, ) { let n = dels.len().max(adds.len()); for i in 0..n { pairs.push((dels.get(i).copied(), adds.get(i).copied())); } dels.clear(); adds.clear(); } let mut pairs: Vec<(Option, Option)> = Vec::new(); let mut dels: Vec = Vec::new(); let mut adds: Vec = Vec::new(); for (i, r) in rows.iter().enumerate() { match r.kind { RowKind::Del => dels.push(i), RowKind::Add => adds.push(i), RowKind::Ctx => { flush(&mut dels, &mut adds, &mut pairs); pairs.push((Some(i), Some(i))); } } } flush(&mut dels, &mut adds, &mut pairs); html! { div class="overflow-x-auto" { table class="w-full border-collapse text-xs font-mono" { tbody { @for (old_i, new_i) in pairs { @let old = old_i.map(|i| &rows[i]); @let new = new_i.map(|i| &rows[i]); @let old_cls = if old.is_some_and(|r| matches!(r.kind, RowKind::Del)) { "bg-red-50" } else { "" }; @let new_cls = if new.is_some_and(|r| matches!(r.kind, RowKind::Add)) { "bg-emerald-50" } else { "" }; tr { td class={ "w-10 select-none border-r border-zinc-100 px-2 py-0 text-right text-zinc-400 " (old_cls) } { (old.and_then(|r| r.old_no).map(|n| n.to_string()).unwrap_or_default()) } td class={ "w-1/2 whitespace-pre px-2 py-0 border-r border-zinc-200 text-zinc-900 " (old_cls) } { (old.map(|r| r.content.as_str()).unwrap_or("")) } td class={ "w-10 select-none border-r border-zinc-100 px-2 py-0 text-right text-zinc-400 " (new_cls) } { (new.and_then(|r| r.new_no).map(|n| n.to_string()).unwrap_or_default()) } td class={ "w-1/2 whitespace-pre px-2 py-0 text-zinc-900 " (new_cls) } { (new.map(|r| r.content.as_str()).unwrap_or("")) } } } } } } } }