a73x

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 %}