c94c308a
add repo overview page with sidebar navigation
a73x 2026-03-30 19:01
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 e1e7eb2..fc411ca 100644 --- a/src/server/http/mod.rs +++ b/src/server/http/mod.rs @@ -1,4 +1,5 @@ pub mod repo_list; pub mod repo; use std::path::PathBuf; use std::sync::Arc; @@ -14,5 +15,6 @@ pub fn router(state: AppState) -> Router { let shared = Arc::new(state); Router::new() .route("/", axum::routing::get(repo_list::handler)) .route("/{repo_name}", axum::routing::get(repo::overview)) .with_state(shared) } diff --git a/src/server/http/repo.rs b/src/server/http/repo.rs new file mode 100644 index 0000000..883e6f6 --- /dev/null +++ b/src/server/http/repo.rs @@ -0,0 +1,172 @@ use std::sync::Arc; use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use chrono::{TimeZone, Utc}; use super::AppState; #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "error.html")] pub struct ErrorTemplate { pub site_title: String, pub status_code: u16, pub message: String, } fn not_found(state: &AppState, message: impl Into<String>) -> Response { let mut response = ErrorTemplate { site_title: state.site_title.clone(), status_code: 404, message: message.into(), } .into_response(); *response.status_mut() = StatusCode::NOT_FOUND; response } fn internal_error(state: &AppState, message: impl Into<String>) -> Response { let mut response = ErrorTemplate { site_title: state.site_title.clone(), status_code: 500, message: message.into(), } .into_response(); *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; response } #[derive(Debug)] pub struct OverviewCommit { pub id: String, pub short_id: String, pub summary: String, pub author: String, pub date: String, } #[derive(Debug)] pub struct OverviewPatch { pub id: String, pub title: String, pub author: String, } #[derive(Debug)] pub struct OverviewIssue { pub id: String, pub title: String, pub author: String, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "repo_overview.html")] pub struct OverviewTemplate { 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 patches: Vec<OverviewPatch>, pub issues: Vec<OverviewIssue>, } fn recent_commits(repo: &git2::Repository, limit: usize) -> Vec<OverviewCommit> { let mut revwalk = match repo.revwalk() { Ok(rw) => rw, Err(_) => return Vec::new(), }; if revwalk.push_head().is_err() { return Vec::new(); } revwalk .take(limit) .filter_map(|oid| { let oid = oid.ok()?; let commit = repo.find_commit(oid).ok()?; 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").to_string()) .unwrap_or_default(); Some(OverviewCommit { id, short_id, summary, author, date }) }) .collect() } fn collab_counts(repo: &git2::Repository) -> (usize, usize) { let open_patches = git_collab::state::list_patches(repo) .unwrap_or_default() .into_iter() .filter(|p| p.status == git_collab::state::PatchStatus::Open) .count(); let open_issues = git_collab::state::list_issues(repo) .unwrap_or_default() .into_iter() .filter(|i| i.status == git_collab::state::IssueStatus::Open) .count(); (open_patches, open_issues) } pub async fn overview( 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, 10); let (open_patches, open_issues) = collab_counts(&repo); let patches = git_collab::state::list_patches(&repo) .unwrap_or_default() .into_iter() .filter(|p| p.status == git_collab::state::PatchStatus::Open) .map(|p| OverviewPatch { id: p.id, title: p.title, author: p.author.name, }) .collect(); let issues = git_collab::state::list_issues(&repo) .unwrap_or_default() .into_iter() .filter(|i| i.status == git_collab::state::IssueStatus::Open) .map(|i| OverviewIssue { id: i.id, title: i.title, author: i.author.name, }) .collect(); OverviewTemplate { site_title: state.site_title.clone(), repo_name, active_section: "overview".to_string(), open_patches, open_issues, commits, patches, issues, } .into_response() } diff --git a/src/server/http/templates/repo_base.html b/src/server/http/templates/repo_base.html new file mode 100644 index 0000000..96bda82 --- /dev/null +++ b/src/server/http/templates/repo_base.html @@ -0,0 +1,20 @@ {% extends "base.html" %} {% block header_right %} <span class="repo-name">{{ repo_name }}</span> {% endblock %} {% block body %} <div class="layout"> <nav class="sidebar"> <a href="/{{ repo_name }}/commits"{% if active_section == "commits" %} class="active"{% endif %}>commits</a> <a href="/{{ repo_name }}/tree"{% if active_section == "tree" %} class="active"{% endif %}>tree</a> <div class="divider"></div> <a href="/{{ repo_name }}/patches"{% if active_section == "patches" %} class="active"{% endif %}>patches <span class="badge">{{ open_patches }}</span></a> <a href="/{{ repo_name }}/issues"{% if active_section == "issues" %} class="active"{% endif %}>issues <span class="badge">{{ open_issues }}</span></a> </nav> <div class="content"> {% block content %}{% endblock %} </div> </div> {% endblock %} diff --git a/src/server/http/templates/repo_overview.html b/src/server/http/templates/repo_overview.html new file mode 100644 index 0000000..d2ac0d1 --- /dev/null +++ b/src/server/http/templates/repo_overview.html @@ -0,0 +1,87 @@ {% extends "repo_base.html" %} {% block title %}{{ repo_name }} — {{ site_title }}{% endblock %} {% block content %} <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 32px;"> <div> <h3 style="margin-top: 0;">Open Patches</h3> {% if patches.is_empty() %} <p style="color: var(--text-muted);">No open patches.</p> {% else %} <table> <thead> <tr> <th>ID</th> <th>Title</th> <th>Author</th> </tr> </thead> <tbody> {% for patch in patches %} <tr> <td class="mono"><a href="/{{ repo_name }}/patches/{{ patch.id }}">{{ patch.id }}</a></td> <td><a href="/{{ repo_name }}/patches/{{ patch.id }}">{{ patch.title }}</a></td> <td style="color: var(--text-muted);">{{ patch.author }}</td> </tr> {% endfor %} </tbody> </table> {% endif %} </div> <div> <h3 style="margin-top: 0;">Open Issues</h3> {% if issues.is_empty() %} <p style="color: var(--text-muted);">No open issues.</p> {% else %} <table> <thead> <tr> <th>ID</th> <th>Title</th> <th>Author</th> </tr> </thead> <tbody> {% for issue in issues %} <tr> <td class="mono"><a href="/{{ repo_name }}/issues/{{ issue.id }}">{{ issue.id }}</a></td> <td><a href="/{{ repo_name }}/issues/{{ issue.id }}">{{ issue.title }}</a></td> <td style="color: var(--text-muted);">{{ issue.author }}</td> </tr> {% endfor %} </tbody> </table> {% endif %} </div> </div> <div> <h3>Recent Commits</h3> {% if commits.is_empty() %} <p style="color: var(--text-muted);">No commits yet.</p> {% else %} <table> <thead> <tr> <th>Commit</th> <th>Summary</th> <th>Author</th> <th>Date</th> </tr> </thead> <tbody> {% for commit in commits %} <tr> <td class="mono"><a href="/{{ repo_name }}/commits/{{ commit.id }}">{{ commit.short_id }}</a></td> <td>{{ commit.summary }}</td> <td style="color: var(--text-muted);">{{ commit.author }}</td> <td class="mono" style="color: var(--text-muted);">{{ commit.date }}</td> </tr> {% endfor %} </tbody> </table> {% endif %} </div> {% endblock %}