use crate::rpcgit::commit::types::TreeEntryMode; use crate::rpcgit::tree::types::TreeEntryWithLatest; use axum::extract::{Path, Query, State}; use axum::response::Response; use maud::Markup; use maud::html; use crate::handler::html::error::html_err; use crate::handler::html::{ HtmlRepoPath, archived_class, clamp_limit, first_line, iso_time, relative_time, resolve_view, response_html, short_sha, }; use crate::service::GitService; use crate::share::access::BasicAuth; #[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)] pub struct TreeQuery { #[serde(default)] pub revision: Option, #[serde(default)] pub path: Option, #[serde(default)] pub limit: Option, } #[utoipa::path( get, path = "/repos/{space_name}/{repo_name}/html/tree", tag = "html", description = "渲染目录列表表格及每个条目的最近提交信息。", operation_id = "render_tree", params(HtmlRepoPath, TreeQuery), responses( (status = 200, description = "tree list html fragment", content_type = "text/html"), (status = 404, description = "repo, ref or path not found") ) )] pub async fn render_tree( State(service): State, Path(path): Path, Query(q): Query, auth: BasicAuth, ) -> Response { let view = match resolve_view(service.clone(), path, auth, 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); let rows = match ctx .service .tree_with_commits_html(&ctx.ctx, &revision, &clean_path) .await { Ok(r) => r, Err(crate::share::errors::GitError::NotFound) => { return html_err(404, "ref not found"); } Err(e) => { tracing::warn!(error = %e, "tree internal"); return html_err(500, "internal"); } }; let total_count = rows.len(); let mut entries = rows; entries.sort_by(|a, b| { let dir_a = matches!(a.entry.mode, TreeEntryMode::Tree); let dir_b = matches!(b.entry.mode, TreeEntryMode::Tree); dir_b .cmp(&dir_a) .then_with(|| a.entry.name.cmp(&b.entry.name)) }); let truncated = entries.len() > limit; if truncated { entries.truncate(limit); } let base = "git-tree w-full border-collapse text-sm"; let cls = archived_class(&ctx.dto, base); let space = ctx.space_name.clone(); let repo = ctx.repo_name.clone(); let markup = html! { table class=(cls) data-component="git-tree" data-space=(space) data-repo=(repo) data-revision=(revision) data-path=(clean_path.as_str()) data-count=(total_count) data-truncated=(if truncated { "true" } else { "false" }) data-archived=(if ctx.dto.is_archived { "true" } else { "false" }) { thead class="bg-zinc-50 text-xs uppercase text-zinc-500" { tr { th class="px-2 py-1 text-left" { "name" } th class="px-2 py-1 text-left" { "last commit" } th class="px-2 py-1 text-right" { "date" } } } tbody { @if !clean_path.is_empty() { @let parent_dir = if clean_path.contains('/') { clean_path.rsplit_once('/').map(|(p, _)| p).unwrap_or("") } else { "" }; @let parent_href = if parent_dir.is_empty() { format!("/{space}/{repo}") } else { format!("/{space}/{repo}/tree/{revision}/{parent_dir}/") }; tr class="border-b border-zinc-100 bg-zinc-50/50 hover:bg-zinc-50" { td class="px-2 py-1 font-mono text-xs text-zinc-500" colspan="3" { a class="text-zinc-700 hover:underline flex items-center gap-1 font-semibold" href=(parent_href) { span { ".." } } } } } @if entries.is_empty() { tr { td class="px-2 py-3 text-center text-xs text-zinc-500" colspan="3" { "empty directory" } } } @else { @for row in &entries { (render_row(&ctx, &revision, &clean_path, row)) } } } } }; response_html(markup, 30) } fn render_row( ctx: &crate::handler::html::HtmlCtx, revision: &str, parent_path: &str, row: &TreeEntryWithLatest, ) -> Markup { let mode = match row.entry.mode { TreeEntryMode::Tree => "tree", TreeEntryMode::Submodule => "submodule", TreeEntryMode::Commit => "commit", TreeEntryMode::Symlink => "symlink", TreeEntryMode::Exec => "exec", TreeEntryMode::Blob => "blob", }; let entry_path = if parent_path.is_empty() { row.entry.name.clone() } else { format!("{parent_path}/{}", row.entry.name) }; let href_path = if mode == "tree" || mode == "submodule" { format!("{entry_path}/") } else { entry_path.clone() }; let href = format!( "/{}/{}/{}", ctx.space_name, ctx.repo_name, if mode == "tree" || mode == "submodule" { format!("tree/{revision}/{href_path}") } else { format!("blob/{revision}/{href_path}") } ); let oid_str = row.entry.oid.to_string(); let short_oid = short_sha(&oid_str); let sha = row.latest.id.to_string(); let short_sha_str = short_sha(&sha); let subject = first_line(&row.latest.message); let author = row.latest.author.name.clone(); let iso = iso_time(row.latest.author.time); let rel = relative_time(row.latest.author.time); let commit_url = format!("/{}/{}/commit/{}", ctx.space_name, ctx.repo_name, sha); html! { tr class="border-b border-zinc-100 hover:bg-zinc-50" data-mode=(mode) data-name=(row.entry.name) data-path=(entry_path) data-oid=(short_oid) data-commit=(short_sha_str) { td class="px-2 py-1 font-mono" { a class="text-zinc-900 hover:underline" href=(href) { (row.entry.name) } } td class="px-2 py-1" { a class="flex items-center gap-2 text-zinc-700 hover:text-zinc-900" href=(commit_url) title=(format!("{}\n{}\n{}\n{}", sha, subject, author, iso)) { code class="rounded bg-zinc-100 px-1 text-xs" { (short_sha_str) } span class="truncate max-w-md text-xs" { (subject) } } } td class="px-2 py-1 text-right text-zinc-500 text-xs" data-time=(row.latest.author.time.unix_timestamp()) { (rel) } } } }