acbf7ea
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<String>,
#[serde(default)]
pub style: Option<String>,
}
#[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<GitService>,
Path(path): Path<HtmlRepoPath>,
Query(q): Query<CommitDiffQuery>,
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<u32>,
new_no: Option<u32>,
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<DiffRow>, 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<usize>,
adds: &mut Vec<usize>,
pairs: &mut Vec<(Option<usize>, Option<usize>)>,
) {
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<usize>, Option<usize>)> = Vec::new();
let mut dels: Vec<usize> = Vec::new();
let mut adds: Vec<usize> = 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("")) }
}
}
}
}
}
}
}