use std::path::PathBuf; use api::http::{Path, Query, Response, State}; use maud::html; use crate::handler::html::{ HtmlRepoPath, archived_class, clamp_limit, error, first_line, iso_time_unix, relative_time_unix, resolve_view, response_html, short_sha, }; use crate::service::GitService; use crate::service::commits::ListCommitsCmd; use ::session::Session; #[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)] pub struct CommitsQuery { #[serde(default)] pub revision: Option, #[serde(default)] pub path: Option, #[serde(default)] pub limit: Option, #[serde(default)] pub skip: Option, } #[utoipa::path( get, path = "/repos/{space_name}/{repo_name}/html/commits", tag = "html", description = "渲染带分页操作的提交历史列表片段。", operation_id = "render_commits", params(HtmlRepoPath, CommitsQuery), responses( (status = 200, description = "commits list html fragment", content_type = "text/html"), (status = 404, description = "repo or ref not found") ) )] pub async fn render_commits( State(service): State, Path(path): Path, Query(q): Query, session: Session, ) -> Response { let view = match resolve_view(service.clone(), path, session, q.revision, q.path, false).await { Ok(v) => v, Err(resp) => return resp, }; let ctx = view.ctx; let revision = view.revision; let clean_path = view.clean_path; let limit = clamp_limit(q.limit).min(100); let cmd = ListCommitsCmd { revision: revision.clone(), path: if clean_path.is_empty() { None } else { Some(PathBuf::from(clean_path.clone())) }, max_count: Some(limit as u32), skip: q.skip.map(|s| s as u32), since: None, until: None, first_parent: false, }; let repo_ctx = match service .context(ctx.space_name.clone(), ctx.repo_name.clone()) .await { Ok(c) => c, Err(e) => { tracing::warn!(error = %e, "commits context internal"); return error::html_err(500, "internal"); } }; let page = match service.list_commits(&repo_ctx, cmd, ctx.uid).await { Ok(p) => p, Err(e) => { tracing::warn!(error = %e, "commits list internal"); return error::html_err(500, "internal"); } }; let commits = page.commits; let base = "git-commits w-full rounded-lg border border-zinc-200 bg-white overflow-hidden text-sm"; let cls = archived_class(&ctx.dto, base); let space = ctx.space_name.clone(); let repo = ctx.repo_name.clone(); let skip = page.skip as usize; let older_skip = skip + limit; let has_older = page.has_more; let markup = html! { div class=(cls) data-component="git-commits" data-space=(space) data-repo=(repo) data-revision=(revision) data-path=(clean_path.as_str()) data-count=(commits.len()) data-limit=(page.limit) data-skip=(page.skip) data-total=(page.total) data-has-more=(if page.has_more { "true" } else { "false" }) data-diff-style=(page.diff_view_style) data-archived=(if ctx.dto.is_archived { "true" } else { "false" }) { div class="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 text-xs text-zinc-500 font-medium" { span { "Commit history" } span class="font-mono text-[11px]" { @if page.total == 0 { "0 commits" } @else { (skip + 1) "–" (skip + commits.len()) " of " (page.total) " commits" } } } @if commits.is_empty() { div class="p-8 text-center text-xs text-zinc-500" { "No commits found" } } @else { ul class="divide-y divide-zinc-100" { @for c in &commits { @let sha = c.id.clone(); @let short = short_sha(&sha); @let subject = first_line(&c.message); @let author_name = c.author_name.clone(); @let time_iso = iso_time_unix(c.author_time); @let time_rel = relative_time_unix(c.author_time); @let commit_url = format!("/{space}/{repo}/commit/{sha}"); @let tree_url = format!("/{space}/{repo}/tree/{sha}"); li class="flex items-center justify-between gap-4 p-3 hover:bg-zinc-50 transition-colors" { div class="flex flex-col gap-1 min-w-0 flex-1" { a class="font-medium text-zinc-900 hover:text-zinc-600 truncate text-sm" href=(commit_url) title=(format!("{sha}\n{subject}\n{author_name}\n{time_iso}")) { (subject) } div class="flex items-center gap-2 text-xs text-zinc-500" { span class="font-medium text-zinc-700" { (author_name) } span { "committed" } time datetime=(time_iso) title=(time_iso) { (time_rel) } } } div class="flex items-center gap-2 shrink-0 font-mono text-xs" { a class="rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-zinc-700 hover:bg-zinc-100 font-mono" href=(commit_url) { (short) } a class="rounded border border-zinc-200 bg-white p-1 text-zinc-500 hover:text-zinc-800 hover:bg-zinc-50" href=(tree_url) title="Browse repository at this point" { svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" { path d="M4.72 3.22a.75.75 0 011.06 1.06L2.06 8l3.72 3.72a.75.75 0 11-1.06 1.06L.47 8.53a.75.75 0 010-1.06l4.25-4.25zm6.56 0a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06L14.94 8l-3.72-3.72a.75.75 0 010-1.06z" {} } } } } } } } @if page.total > page.limit.into() { div class="flex items-center justify-between border-t border-zinc-200 bg-zinc-50 px-4 py-2 text-xs" { @if skip > 0 { a class="rounded border border-zinc-200 bg-white px-3 py-1 font-medium text-zinc-700 hover:bg-zinc-100" href=(format!("?revision={}&path={}&limit={}&skip={}", revision, clean_path, limit, skip.saturating_sub(limit))) { "Newer" } } @else { span class="rounded border border-zinc-200 bg-zinc-50 px-3 py-1 font-medium text-zinc-300 cursor-default" { "Newer" } } span class="text-zinc-500 font-mono" { "page " (skip / limit + 1) " / " ((page.total as usize + limit - 1) / limit.max(1)) } @if has_older { a class="rounded border border-zinc-200 bg-white px-3 py-1 font-medium text-zinc-700 hover:bg-zinc-100" href=(format!("?revision={}&path={}&limit={}&skip={}", revision, clean_path, limit, older_skip)) { "Older" } } @else { span class="rounded border border-zinc-200 bg-zinc-50 px-3 py-1 font-medium text-zinc-300 cursor-default" { "Older" } } } } } }; response_html(markup, 30) }