de75c0cd
add commits page
a73x 2026-03-30 19:07
Adds CommitsTemplate and commits handler showing last 200 commits with
links to the diff page. Wires up /{repo_name}/commits route.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
diff --git a/src/server/http/mod.rs b/src/server/http/mod.rs index fc411ca..5629541 100644 --- a/src/server/http/mod.rs +++ b/src/server/http/mod.rs @@ -16,5 +16,14 @@ pub fn router(state: AppState) -> Router { Router::new() .route("/", axum::routing::get(repo_list::handler)) .route("/{repo_name}", axum::routing::get(repo::overview)) .route("/{repo_name}/commits", axum::routing::get(repo::commits)) .route("/{repo_name}/tree", axum::routing::get(repo::tree_root)) .route("/{repo_name}/tree/{*rest}", axum::routing::get(repo::tree)) .route("/{repo_name}/blob/{*rest}", axum::routing::get(repo::blob)) .route("/{repo_name}/diff/{oid}", axum::routing::get(repo::diff)) .route("/{repo_name}/patches", axum::routing::get(repo::patches)) .route("/{repo_name}/patches/{id}", axum::routing::get(repo::patch_detail)) .route("/{repo_name}/issues", axum::routing::get(repo::issues)) .route("/{repo_name}/issues/{id}", axum::routing::get(repo::issue_detail)) .with_state(shared) } diff --git a/src/server/http/repo.rs b/src/server/http/repo.rs index 883e6f6..0be0628 100644 --- a/src/server/http/repo.rs +++ b/src/server/http/repo.rs @@ -170,3 +170,774 @@ pub async fn overview( } .into_response() } // ── Task 8: Commits page ────────────────────────────────────────────────────── #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "commits.html")] pub struct CommitsTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub commits: Vec<OverviewCommit>, } pub async fn commits( Path(repo_name): Path<String>, State(state): State<Arc<AppState>>, ) -> Response { let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) { Some(e) => e, None => return not_found(&state, format!("Repository '{}' not found.", repo_name)), }; let repo = match crate::repos::open(&entry) { Ok(r) => r, Err(_) => return internal_error(&state, "Failed to open repository."), }; let commits = recent_commits(&repo, 200); let (open_patches, open_issues) = collab_counts(&repo); CommitsTemplate { site_title: state.site_title.clone(), repo_name, active_section: "commits".to_string(), open_patches, open_issues, commits, } .into_response() } // ── Task 9: Tree and blob pages ─────────────────────────────────────────────── #[derive(Debug)] pub struct TreeEntry { pub name: String, pub full_path: String, pub is_dir: bool, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "tree.html")] pub struct TreeTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub ref_name: String, pub path_display: String, pub show_parent: bool, pub parent_path: String, pub entries: Vec<TreeEntry>, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "blob.html")] pub struct BlobTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub ref_name: String, pub file_path: String, pub content: String, pub is_binary: bool, pub size_display: String, } /// Split "ref/path/to/file" — first segment is ref, rest joined is path. /// Returns ("HEAD", "") for empty input. fn split_ref_path(input: &str) -> (String, String) { if input.is_empty() { return ("HEAD".to_string(), String::new()); } let parts: Vec<&str> = input.splitn(2, '/').collect(); let ref_name = parts[0].to_string(); let path = if parts.len() > 1 { parts[1].to_string() } else { String::new() }; (ref_name, path) } fn build_tree_response( state: &AppState, repo_name: String, repo: &git2::Repository, ref_name: String, path: String, ) -> Response { let (open_patches, open_issues) = collab_counts(repo); // Resolve the ref to a tree let obj = match repo.revparse_single(&ref_name) { Ok(o) => o, Err(_) => return not_found(state, format!("Ref '{}' not found.", ref_name)), }; let commit = match obj.peel_to_commit() { Ok(c) => c, Err(_) => return not_found(state, "Could not resolve ref to a commit."), }; let root_tree = match commit.tree() { Ok(t) => t, Err(_) => return internal_error(state, "Failed to get commit tree."), }; // Navigate into subdirectory if path is non-empty let tree = if path.is_empty() { root_tree } else { let entry = match root_tree.get_path(std::path::Path::new(&path)) { Ok(e) => e, Err(_) => return not_found(state, format!("Path '{}' not found.", path)), }; let obj = match entry.to_object(repo) { Ok(o) => o, Err(_) => return internal_error(state, "Failed to resolve path object."), }; match obj.into_tree() { Ok(t) => t, Err(_) => return not_found(state, "Path is not a directory."), } }; let mut entries: Vec<TreeEntry> = tree .iter() .map(|e| { let name = e.name().unwrap_or("").to_string(); let full_path = if path.is_empty() { name.clone() } else { format!("{}/{}", path, name) }; let is_dir = e.kind() == Some(git2::ObjectType::Tree); TreeEntry { name, full_path, is_dir } }) .collect(); // Sort dirs first, then files, alphabetically within each group entries.sort_by(|a, b| { match (a.is_dir, b.is_dir) { (true, false) => std::cmp::Ordering::Less, (false, true) => std::cmp::Ordering::Greater, _ => a.name.cmp(&b.name), } }); let (show_parent, parent_path) = if path.is_empty() { (false, String::new()) } else { let parent = path.rfind('/').map(|i| &path[..i]).unwrap_or("").to_string(); (true, parent) }; TreeTemplate { site_title: state.site_title.clone(), repo_name, active_section: "tree".to_string(), open_patches, open_issues, ref_name, path_display: path, show_parent, parent_path, entries, } .into_response() } pub async fn tree_root( Path(repo_name): Path<String>, State(state): State<Arc<AppState>>, ) -> Response { let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) { Some(e) => e, None => return not_found(&state, format!("Repository '{}' not found.", repo_name)), }; let repo = match crate::repos::open(&entry) { Ok(r) => r, Err(_) => return internal_error(&state, "Failed to open repository."), }; build_tree_response(&state, repo_name, &repo, "HEAD".to_string(), String::new()) } pub async fn tree( Path((repo_name, rest)): Path<(String, String)>, State(state): State<Arc<AppState>>, ) -> Response { let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) { Some(e) => e, None => return not_found(&state, format!("Repository '{}' not found.", repo_name)), }; let repo = match crate::repos::open(&entry) { Ok(r) => r, Err(_) => return internal_error(&state, "Failed to open repository."), }; let (ref_name, path) = split_ref_path(&rest); build_tree_response(&state, repo_name, &repo, ref_name, path) } pub async fn blob( Path((repo_name, rest)): Path<(String, String)>, State(state): State<Arc<AppState>>, ) -> Response { let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) { Some(e) => e, None => return not_found(&state, format!("Repository '{}' not found.", repo_name)), }; let repo = match crate::repos::open(&entry) { Ok(r) => r, Err(_) => return internal_error(&state, "Failed to open repository."), }; let (open_patches, open_issues) = collab_counts(&repo); let (ref_name, file_path) = split_ref_path(&rest); let obj = match repo.revparse_single(&ref_name) { Ok(o) => o, Err(_) => return not_found(&state, format!("Ref '{}' not found.", ref_name)), }; let commit = match obj.peel_to_commit() { Ok(c) => c, Err(_) => return not_found(&state, "Could not resolve ref to a commit."), }; let tree = match commit.tree() { Ok(t) => t, Err(_) => return internal_error(&state, "Failed to get commit tree."), }; let entry_obj = match tree.get_path(std::path::Path::new(&file_path)) { Ok(e) => e, Err(_) => return not_found(&state, format!("File '{}' not found.", file_path)), }; let obj = match entry_obj.to_object(&repo) { Ok(o) => o, Err(_) => return internal_error(&state, "Failed to resolve file object."), }; let blob = match obj.into_blob() { Ok(b) => b, Err(_) => return not_found(&state, "Path is not a file."), }; let is_binary = blob.is_binary(); let size = blob.size(); let size_display = if size < 1024 { format!("{} B", size) } else if size < 1024 * 1024 { format!("{:.1} KiB", size as f64 / 1024.0) } else { format!("{:.1} MiB", size as f64 / (1024.0 * 1024.0)) }; let content = if is_binary { String::new() } else { String::from_utf8_lossy(blob.content()).into_owned() }; BlobTemplate { site_title: state.site_title.clone(), repo_name, active_section: "tree".to_string(), open_patches, open_issues, ref_name, file_path, content, is_binary, size_display, } .into_response() } // ── Task 10: Diff page ──────────────────────────────────────────────────────── #[derive(Debug)] pub struct DiffLine { pub kind: String, pub text: String, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "diff.html")] pub struct DiffTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub short_id: String, pub summary: String, pub body: String, pub author: String, pub date: String, pub diff_lines: Vec<DiffLine>, } fn compute_diff_lines(repo: &git2::Repository, oid: git2::Oid) -> Vec<DiffLine> { let commit = match repo.find_commit(oid) { Ok(c) => c, Err(_) => return Vec::new(), }; let new_tree = match commit.tree() { Ok(t) => t, Err(_) => return Vec::new(), }; let old_tree = commit .parent(0) .ok() .and_then(|p| p.tree().ok()); let diff = match repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), None) { Ok(d) => d, Err(_) => return Vec::new(), }; let mut lines: Vec<DiffLine> = Vec::new(); let _ = diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { let text = String::from_utf8_lossy(line.content()).into_owned(); let kind = match line.origin() { '+' => "add", '-' => "del", '@' => "hunk", 'F' => "file", _ => "ctx", } .to_string(); lines.push(DiffLine { kind, text }); true }); lines } pub async fn diff( Path((repo_name, oid_str)): Path<(String, String)>, State(state): State<Arc<AppState>>, ) -> Response { let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) { Some(e) => e, None => return not_found(&state, format!("Repository '{}' not found.", repo_name)), }; let repo = match crate::repos::open(&entry) { Ok(r) => r, Err(_) => return internal_error(&state, "Failed to open repository."), }; let (open_patches, open_issues) = collab_counts(&repo); let oid = match git2::Oid::from_str(&oid_str) { Ok(o) => o, Err(_) => return not_found(&state, format!("Invalid OID: {}", oid_str)), }; let commit = match repo.find_commit(oid) { Ok(c) => c, Err(_) => return not_found(&state, format!("Commit '{}' not found.", oid_str)), }; let id = oid.to_string(); let short_id = id[..8.min(id.len())].to_string(); let summary = commit.summary().unwrap_or("").to_string(); let author = commit.author().name().unwrap_or("").to_string(); let secs = commit.time().seconds(); let date = Utc .timestamp_opt(secs, 0) .single() .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) .unwrap_or_default(); // Body = message minus the first line (summary) let body = commit .message() .map(|m| { let lines: Vec<&str> = m.splitn(2, '\n').collect(); if lines.len() > 1 { lines[1].trim_start_matches('\n').to_string() } else { String::new() } }) .unwrap_or_default(); let diff_lines = compute_diff_lines(&repo, oid); DiffTemplate { site_title: state.site_title.clone(), repo_name, active_section: "commits".to_string(), open_patches, open_issues, short_id, summary, body, author, date, diff_lines, } .into_response() } // ── Task 11: Patches list and detail ───────────────────────────────────────── #[derive(Debug)] pub struct PatchListItem { pub id: String, pub short_id: String, pub status: String, pub title: String, pub author: String, pub branch: String, pub updated: String, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "patches.html")] pub struct PatchesTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub patches: Vec<PatchListItem>, } #[derive(Debug)] pub struct RevisionView { pub number: u32, pub commit: String, pub timestamp: String, pub body: Option<String>, } #[derive(Debug)] pub struct ReviewView { pub author: String, pub verdict: String, pub body: String, pub timestamp: String, pub revision: Option<u32>, } #[derive(Debug)] pub struct InlineCommentView { pub author: String, pub file: String, pub line: u32, pub body: String, pub timestamp: String, pub revision: Option<u32>, } #[derive(Debug)] pub struct CommentView { pub author: String, pub body: String, pub timestamp: String, } #[derive(Debug)] pub struct PatchDetailView { pub id: String, pub title: String, pub body: String, pub status: String, pub author: String, pub branch: String, pub base_ref: String, pub revisions: Vec<RevisionView>, pub reviews: Vec<ReviewView>, pub inline_comments: Vec<InlineCommentView>, pub comments: Vec<CommentView>, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "patch_detail.html")] pub struct PatchDetailTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub patch: PatchDetailView, } pub async fn patches( Path(repo_name): Path<String>, State(state): State<Arc<AppState>>, ) -> Response { let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) { Some(e) => e, None => return not_found(&state, format!("Repository '{}' not found.", repo_name)), }; let repo = match crate::repos::open(&entry) { Ok(r) => r, Err(_) => return internal_error(&state, "Failed to open repository."), }; let (open_patches, open_issues) = collab_counts(&repo); let all_patches = git_collab::state::list_patches_with_archived(&repo).unwrap_or_default(); let patches = all_patches .into_iter() .map(|p| { let id = p.id.clone(); let short_id = id[..8.min(id.len())].to_string(); PatchListItem { short_id, id, status: p.status.as_str().to_string(), title: p.title, author: p.author.name, branch: p.branch, updated: p.last_updated, } }) .collect(); PatchesTemplate { site_title: state.site_title.clone(), repo_name, active_section: "patches".to_string(), open_patches, open_issues, patches, } .into_response() } pub async fn patch_detail( Path((repo_name, patch_id)): Path<(String, String)>, State(state): State<Arc<AppState>>, ) -> Response { let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) { Some(e) => e, None => return not_found(&state, format!("Repository '{}' not found.", repo_name)), }; let repo = match crate::repos::open(&entry) { Ok(r) => r, Err(_) => return internal_error(&state, "Failed to open repository."), }; let (open_patches, open_issues) = collab_counts(&repo); let (ref_name, full_id) = match git_collab::state::resolve_patch_ref(&repo, &patch_id) { Ok(r) => r, Err(_) => return not_found(&state, format!("Patch '{}' not found.", patch_id)), }; let ps = match git_collab::state::PatchState::from_ref(&repo, &ref_name, &full_id) { Ok(s) => s, Err(_) => return internal_error(&state, "Failed to load patch state."), }; let patch = PatchDetailView { id: ps.id, title: ps.title, body: ps.body, status: ps.status.as_str().to_string(), author: ps.author.name, branch: ps.branch, base_ref: ps.base_ref, revisions: ps.revisions.into_iter().map(|r| RevisionView { number: r.number, commit: r.commit, timestamp: r.timestamp, body: r.body, }).collect(), reviews: ps.reviews.into_iter().map(|r| ReviewView { author: r.author.name, verdict: r.verdict.as_str().to_string(), body: r.body, timestamp: r.timestamp, revision: r.revision, }).collect(), inline_comments: ps.inline_comments.into_iter().map(|ic| InlineCommentView { author: ic.author.name, file: ic.file, line: ic.line, body: ic.body, timestamp: ic.timestamp, revision: ic.revision, }).collect(), comments: ps.comments.into_iter().map(|c| CommentView { author: c.author.name, body: c.body, timestamp: c.timestamp, }).collect(), }; PatchDetailTemplate { site_title: state.site_title.clone(), repo_name, active_section: "patches".to_string(), open_patches, open_issues, patch, } .into_response() } // ── Task 12: Issues list and detail ────────────────────────────────────────── #[derive(Debug)] pub struct IssueListItem { pub id: String, pub short_id: String, pub status: String, pub title: String, pub author: String, pub labels: String, pub updated: String, } #[derive(Debug)] pub struct IssueDetailView { pub id: String, pub title: String, pub body: String, pub status: String, pub author: String, pub labels: String, pub assignees: String, pub close_reason: Option<String>, pub comments: Vec<CommentView>, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "issues.html")] pub struct IssuesTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub issues: Vec<IssueListItem>, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "issue_detail.html")] pub struct IssueDetailTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub issue: IssueDetailView, } pub async fn issues( Path(repo_name): Path<String>, State(state): State<Arc<AppState>>, ) -> Response { let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) { Some(e) => e, None => return not_found(&state, format!("Repository '{}' not found.", repo_name)), }; let repo = match crate::repos::open(&entry) { Ok(r) => r, Err(_) => return internal_error(&state, "Failed to open repository."), }; let (open_patches, open_issues) = collab_counts(&repo); let all_issues = git_collab::state::list_issues_with_archived(&repo).unwrap_or_default(); let issues = all_issues .into_iter() .map(|i| { let id = i.id.clone(); let short_id = id[..8.min(id.len())].to_string(); IssueListItem { short_id, id, status: i.status.as_str().to_string(), title: i.title, author: i.author.name, labels: i.labels.join(", "), updated: i.last_updated, } }) .collect(); IssuesTemplate { site_title: state.site_title.clone(), repo_name, active_section: "issues".to_string(), open_patches, open_issues, issues, } .into_response() } pub async fn issue_detail( Path((repo_name, issue_id)): Path<(String, String)>, State(state): State<Arc<AppState>>, ) -> Response { let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) { Some(e) => e, None => return not_found(&state, format!("Repository '{}' not found.", repo_name)), }; let repo = match crate::repos::open(&entry) { Ok(r) => r, Err(_) => return internal_error(&state, "Failed to open repository."), }; let (open_patches, open_issues) = collab_counts(&repo); let (ref_name, full_id) = match git_collab::state::resolve_issue_ref(&repo, &issue_id) { Ok(r) => r, Err(_) => return not_found(&state, format!("Issue '{}' not found.", issue_id)), }; let is = match git_collab::state::IssueState::from_ref(&repo, &ref_name, &full_id) { Ok(s) => s, Err(_) => return internal_error(&state, "Failed to load issue state."), }; let issue = IssueDetailView { id: is.id, title: is.title, body: is.body, status: is.status.as_str().to_string(), author: is.author.name, labels: is.labels.join(", "), assignees: is.assignees.join(", "), close_reason: is.close_reason, comments: is.comments.into_iter().map(|c| CommentView { author: c.author.name, body: c.body, timestamp: c.timestamp, }).collect(), }; IssueDetailTemplate { site_title: state.site_title.clone(), repo_name, active_section: "issues".to_string(), open_patches, open_issues, issue, } .into_response() } diff --git a/src/server/http/templates/commits.html b/src/server/http/templates/commits.html new file mode 100644 index 0000000..fd113b5 --- /dev/null +++ b/src/server/http/templates/commits.html @@ -0,0 +1,31 @@ {% extends "repo_base.html" %} {% block title %}Commits — {{ repo_name }} — {{ site_title }}{% endblock %} {% block content %} <h2>Commits</h2> {% if commits.is_empty() %} <p style="color: var(--text-muted);">No commits yet.</p> {% else %} <table> <thead> <tr> <th>Hash</th> <th>Message</th> <th>Author</th> <th>Date</th> </tr> </thead> <tbody> {% for c in commits %} <tr> <td class="mono"><a href="/{{ repo_name }}/diff/{{ c.id }}">{{ c.short_id }}</a></td> <td>{{ c.summary }}</td> <td style="color: var(--text-muted);">{{ c.author }}</td> <td class="mono" style="color: var(--text-muted);">{{ c.date }}</td> </tr> {% endfor %} </tbody> </table> {% endif %} {% endblock %}