a73x

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