kitgit

tirbofish/kitgit · commit

d03e8ac0517202e8b5ca61459a25474466c4ddb3

Improve repo UX with PR reviews, invite signup, and cleaner chrome. Add pull reviews and full-diff expand, let invite codes bypass locked signups, and rework the branch toolbar / latest-commit bar plus related settings UI polish.

Verified

Thribhu K <4tkbytes@pm.me> · 2026-07-29 01:51

view full diff

diff --git a/migrations/018_pull_reviews.sql b/migrations/018_pull_reviews.sql
new file mode 100644
index 0000000..bbc690f
--- /dev/null
+++ b/migrations/018_pull_reviews.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS pull_reviews (
+  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+  pull_id UUID NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE,
+  reviewer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+  state TEXT NOT NULL CHECK (state IN ('approved','changes_requested','commented')),
+  body TEXT NOT NULL DEFAULT '',
+  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS pull_reviews_pull_id_idx ON pull_reviews(pull_id);
diff --git a/src/db/models.rs b/src/db/models.rs
index c7bd51b..42d2653 100644
--- a/src/db/models.rs
+++ b/src/db/models.rs
@@ -288,6 +288,16 @@ pub struct Comment {
 }
 
 #[derive(Debug, Clone, FromRow)]
+pub struct PullReview {
+    pub id: Uuid,
+    pub pull_id: Uuid,
+    pub reviewer_id: Uuid,
+    pub state: String,
+    pub body: String,
+    pub created_at: DateTime<Utc>,
+}
+
+#[derive(Debug, Clone, FromRow)]
 pub struct Release {
     pub id: Uuid,
     pub repo_id: Uuid,
diff --git a/src/db/queries.rs b/src/db/queries.rs
index ce0cbc9..8df7cab 100644
--- a/src/db/queries.rs
+++ b/src/db/queries.rs
@@ -1326,6 +1326,29 @@ pub async fn user_owns_email(pool: &PgPool, user_id: Uuid, email: &str) -> Resul
     Ok(row.is_some())
 }
 
+/// Resolve a kitgit username from a commit author email (primary or secondary).
+pub async fn username_by_email(pool: &PgPool, email: &str) -> Result<Option<String>> {
+    let email = email.trim().to_ascii_lowercase();
+    if email.is_empty() {
+        return Ok(None);
+    }
+    let row: Option<(String,)> = sqlx::query_as(
+        r#"
+        SELECT u.username FROM users u
+        WHERE lower(u.email) = $1
+        UNION
+        SELECT u.username FROM users u
+        JOIN user_emails e ON e.user_id = u.id
+        WHERE lower(e.email) = $1
+        LIMIT 1
+        "#,
+    )
+    .bind(&email)
+    .fetch_optional(pool)
+    .await?;
+    Ok(row.map(|(u,)| u))
+}
+
 pub async fn list_all_gpg_keys(pool: &PgPool) -> Result<Vec<GpgKey>> {
     Ok(sqlx::query_as::<_, GpgKey>("SELECT * FROM gpg_keys ORDER BY created_at DESC")
         .fetch_all(pool)
@@ -1739,6 +1762,36 @@ pub async fn list_pull_comments(pool: &PgPool, pull_id: Uuid) -> Result<Vec<Comm
     .await?)
 }
 
+pub async fn create_review(
+    pool: &PgPool,
+    pull_id: Uuid,
+    reviewer_id: Uuid,
+    state: &str,
+    body: &str,
+) -> Result<PullReview> {
+    Ok(sqlx::query_as::<_, PullReview>(
+        r#"
+        INSERT INTO pull_reviews (pull_id, reviewer_id, state, body)
+        VALUES ($1, $2, $3, $4) RETURNING *
+        "#,
+    )
+    .bind(pull_id)
+    .bind(reviewer_id)
+    .bind(state)
+    .bind(body)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn list_reviews_for_pull(pool: &PgPool, pull_id: Uuid) -> Result<Vec<PullReview>> {
+    Ok(sqlx::query_as::<_, PullReview>(
+        "SELECT * FROM pull_reviews WHERE pull_id = $1 ORDER BY created_at",
+    )
+    .bind(pull_id)
+    .fetch_all(pool)
+    .await?)
+}
+
 pub async fn create_release(
     pool: &PgPool,
     repo_id: Uuid,
@@ -1918,7 +1971,7 @@ pub async fn rewrite_asset_paths_for_tag(
 }
 
 
-// ΓöÇΓöÇ stars / watches / forks ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ
+// ── stars / watches / forks ──────────────────────────────────────────────────
 
 pub async fn toggle_star(pool: &PgPool, repo_id: Uuid, user_id: Uuid) -> Result<bool> {
     let existing: Option<(Uuid,)> = sqlx::query_as(
@@ -2164,7 +2217,7 @@ pub async fn get_fork_of_user(pool: &PgPool, fork_of_id: Uuid, owner_id: Uuid) -
     .await?)
 }
 
-// ΓöÇΓöÇ reactions ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ
+// ── reactions ────────────────────────────────────────────────────────────────
 
 pub async fn toggle_reaction(
     pool: &PgPool,
@@ -2231,7 +2284,7 @@ pub async fn list_reaction_counts(
     Ok(rows.into_iter().map(|r| (r.emoji, r.count, r.mine)).collect())
 }
 
-// ΓöÇΓöÇ branch rules ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ
+// ── branch rules ─────────────────────────────────────────────────────────────
 
 pub async fn list_branch_rules(pool: &PgPool, repo_id: Uuid) -> Result<Vec<BranchRule>> {
     Ok(sqlx::query_as::<_, BranchRule>(
@@ -2275,7 +2328,7 @@ pub async fn delete_branch_rule(pool: &PgPool, id: Uuid, repo_id: Uuid) -> Resul
     Ok(())
 }
 
-// ΓöÇΓöÇ user emails / privacy / sessions / gpg ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ
+// ── user emails / privacy / sessions / gpg ───────────────────────────────────
 
 pub async fn update_username(pool: &PgPool, id: Uuid, username: &str) -> Result<User> {
     Ok(sqlx::query_as::<_, User>(
@@ -2351,6 +2404,36 @@ pub async fn delete_user_email(pool: &PgPool, id: Uuid, user_id: Uuid) -> Result
     Ok(())
 }
 
+pub async fn set_primary_email(pool: &PgPool, user_id: Uuid, email_id: Uuid) -> Result<()> {
+    let mut tx = pool.begin().await?;
+    let row: Option<(String,)> = sqlx::query_as(
+        "SELECT email FROM user_emails WHERE id = $1 AND user_id = $2",
+    )
+    .bind(email_id)
+    .bind(user_id)
+    .fetch_optional(&mut *tx)
+    .await?;
+    let Some((email,)) = row else {
+        anyhow::bail!("email not found");
+    };
+    sqlx::query("UPDATE user_emails SET is_primary = false WHERE user_id = $1")
+        .bind(user_id)
+        .execute(&mut *tx)
+        .await?;
+    sqlx::query("UPDATE user_emails SET is_primary = true WHERE id = $1 AND user_id = $2")
+        .bind(email_id)
+        .bind(user_id)
+        .execute(&mut *tx)
+        .await?;
+    sqlx::query("UPDATE users SET email = $2, updated_at = now() WHERE id = $1")
+        .bind(user_id)
+        .bind(&email)
+        .execute(&mut *tx)
+        .await?;
+    tx.commit().await?;
+    Ok(())
+}
+
 pub async fn ensure_primary_email(pool: &PgPool, user_id: Uuid, email: &str) -> Result<()> {
     if email.trim().is_empty() {
         return Ok(());
@@ -2667,7 +2750,7 @@ pub async fn lfs_object_size(pool: &PgPool, oid: &str) -> Result<Option<i64>> {
     Ok(row.map(|r| r.0))
 }
 
-// ── repo mirrors ─────────────────────────────────────────────────────────────
+// ── repo mirrors ─────────────────────────────────────────────────────────────
 
 pub async fn get_repo_mirror(pool: &PgPool, repo_id: Uuid) -> Result<Option<RepoMirror>> {
     Ok(sqlx::query_as::<_, RepoMirror>(
@@ -2746,7 +2829,7 @@ pub async fn delete_repo_mirror(pool: &PgPool, repo_id: Uuid) -> Result<()> {
     Ok(())
 }
 
-// ── per-user audit log ───────────────────────────────────────────────────────
+// ── per-user audit log ───────────────────────────────────────────────────────
 
 pub async fn record_audit_log(
     pool: &PgPool,
@@ -2788,7 +2871,7 @@ pub async fn list_audit_log_for_user(
     .await?)
 }
 
-// ── webhooks ─────────────────────────────────────────────────────────────────
+// ── webhooks ─────────────────────────────────────────────────────────────────
 
 pub async fn list_webhooks(pool: &PgPool, repo_id: Uuid) -> Result<Vec<Webhook>> {
     Ok(sqlx::query_as::<_, Webhook>(
@@ -2956,7 +3039,7 @@ pub async fn list_recent_webhook_deliveries(
         .collect())
 }
 
-// ── notifications ────────────────────────────────────────────────────────────
+// ── notifications ────────────────────────────────────────────────────────────
 
 pub async fn create_notification(
     pool: &PgPool,
diff --git a/src/git/repo.rs b/src/git/repo.rs
index 9b0f4e0..16cc994 100644
--- a/src/git/repo.rs
+++ b/src/git/repo.rs
@@ -391,6 +391,13 @@ pub fn list_commits(repo: &G2Repo, reference: &str, limit: usize) -> Result<Vec<
     Ok(out)
 }
 
+pub fn count_commits(repo: &G2Repo, reference: &str) -> Result<usize> {
+    let oid = resolve_ref(repo, reference)?;
+    let mut revwalk = repo.revwalk()?;
+    revwalk.push(oid)?;
+    Ok(revwalk.count())
+}
+
 pub fn get_commit(repo: &G2Repo, id: &str) -> Result<CommitInfo> {
     let oid = Oid::from_str(id)?;
     let c = repo.find_commit(oid)?;
diff --git a/src/web/mod.rs b/src/web/mod.rs
index 2454c8c..2c3401a 100644
--- a/src/web/mod.rs
+++ b/src/web/mod.rs
@@ -4,15 +4,73 @@ pub mod templates;
 
 use crate::git;
 use crate::state::AppState;
+use axum::body::{Body, to_bytes};
 use axum::extract::{DefaultBodyLimit, Request};
-use axum::http::{header, StatusCode};
+use axum::http::{header, HeaderValue, StatusCode};
 use axum::middleware::{from_fn, Next};
 use axum::response::{IntoResponse, Response};
 use axum::routing::{get, post};
 use axum::Router;
+use std::time::{Duration, Instant};
 use tower_http::limit::RequestBodyLimitLayer;
 use tower_http::services::ServeDir;
 
+const RENDER_MS_MARKER: &[u8] = b"<!--kg-render-ms-->";
+
+fn format_render_duration(elapsed: Duration) -> String {
+    let ms = elapsed.as_secs_f64() * 1000.0;
+    if ms < 10.0 {
+        format!("{ms:.1}ms")
+    } else {
+        format!("{:.0}ms", ms.round())
+    }
+}
+
+fn inject_render_marker(html: &[u8], label: &str) -> Option<Vec<u8>> {
+    let pos = html
+        .windows(RENDER_MS_MARKER.len())
+        .position(|window| window == RENDER_MS_MARKER)?;
+    let mut out = Vec::with_capacity(html.len() - RENDER_MS_MARKER.len() + label.len());
+    out.extend_from_slice(&html[..pos]);
+    out.extend_from_slice(label.as_bytes());
+    out.extend_from_slice(&html[pos + RENDER_MS_MARKER.len()..]);
+    Some(out)
+}
+
+/// Measure HTML page handler time and fill the layout footer timing marker.
+async fn inject_render_timing(req: Request, next: Next) -> Response {
+    let start = Instant::now();
+    let res = next.run(req).await;
+    let elapsed = start.elapsed();
+
+    let is_html = res
+        .headers()
+        .get(header::CONTENT_TYPE)
+        .and_then(|v| v.to_str().ok())
+        .is_some_and(|ct| ct.starts_with("text/html"));
+    if !is_html {
+        return res;
+    }
+
+    let (mut parts, body) = res.into_parts();
+    let Ok(bytes) = to_bytes(body, 16 * 1024 * 1024).await else {
+        return Response::from_parts(parts, Body::empty());
+    };
+
+    let label = format_render_duration(elapsed);
+    let body = match inject_render_marker(&bytes, &label) {
+        Some(updated) => {
+            parts.headers.remove(header::CONTENT_LENGTH);
+            if let Ok(len) = HeaderValue::from_str(&updated.len().to_string()) {
+                parts.headers.insert(header::CONTENT_LENGTH, len);
+            }
+            Body::from(updated)
+        }
+        None => Body::from(bytes),
+    };
+    Response::from_parts(parts, body)
+}
+
 /// Rewrite `/owner/repo.git` → `/owner/repo` and drop a trailing slash so
 /// browser pages and git smart-HTTP both work with classic forge URLs.
 fn normalize_repo_path(path: &str) -> String {
@@ -138,6 +196,10 @@ pub fn app_router(state: AppState) -> Router {
             post(routes_extra::account_delete_email),
         )
         .route(
+            "/settings/account/emails/{id}/primary",
+            post(routes_extra::account_set_primary_email),
+        )
+        .route(
             "/settings/account/sessions/{id}/revoke",
             post(routes_extra::account_revoke_session),
         )
@@ -197,20 +259,20 @@ pub fn app_router(state: AppState) -> Router {
         .route("/{owner}/{repo}/upload", post(routes::repo_upload))
         .route("/{owner}/{repo}/branches", get(routes::repo_branches))
         .route(
-            "/{owner}/{repo}/branches/{branch}/rename",
+            "/{owner}/{repo}/branches/rename",
             post(routes_extra::branch_rename),
         )
         .route(
-            "/{owner}/{repo}/branches/{branch}/delete",
+            "/{owner}/{repo}/branches/delete",
             post(routes_extra::branch_delete),
         )
         .route("/{owner}/{repo}/tags", post(routes_extra::tag_create))
         .route(
-            "/{owner}/{repo}/tags/{tag}/rename",
+            "/{owner}/{repo}/tags/rename",
             post(routes_extra::tag_rename),
         )
         .route(
-            "/{owner}/{repo}/tags/{tag}/delete",
+            "/{owner}/{repo}/tags/delete",
             post(routes_extra::tag_delete),
         )
         .route(
@@ -264,6 +326,10 @@ pub fn app_router(state: AppState) -> Router {
             post(routes::pull_milestone_save),
         )
         .route(
+            "/{owner}/{repo}/pulls/{number}/review",
+            post(routes::pull_review),
+        )
+        .route(
             "/{owner}/{repo}/labels",
             get(routes_extra::labels_list).post(routes_extra::label_create),
         )
@@ -384,6 +450,7 @@ pub fn app_router(state: AppState) -> Router {
         )
         .route("/{username}", get(routes::profile))
         .nest_service("/static", ServeDir::new(static_dir))
+        .layer(from_fn(inject_render_timing))
         .layer(from_fn(normalize_git_url))
         .layer(DefaultBodyLimit::max(100 * 1024 * 1024))
         .layer(RequestBodyLimitLayer::new(100 * 1024 * 1024))
@@ -392,7 +459,8 @@ pub fn app_router(state: AppState) -> Router {
 
 #[cfg(test)]
 mod path_tests {
-    use super::normalize_repo_path;
+    use super::{format_render_duration, inject_render_marker, normalize_repo_path};
+    use std::time::Duration;
 
     #[test]
     fn strips_git_suffix_and_slash() {
@@ -402,5 +470,19 @@ mod path_tests {
         assert_eq!(normalize_repo_path("/o/r/"), "/o/r");
         assert_eq!(normalize_repo_path("/"), "/");
     }
+
+    #[test]
+    fn injects_render_timing_marker() {
+        let html = b"<footer>rendered in <!--kg-render-ms--></footer>";
+        let out = inject_render_marker(html, "4.2ms").unwrap();
+        assert_eq!(out, b"<footer>rendered in 4.2ms</footer>");
+        assert!(inject_render_marker(b"<footer>kitgit</footer>", "1ms").is_none());
+    }
+
+    #[test]
+    fn formats_render_duration() {
+        assert_eq!(format_render_duration(Duration::from_micros(4200)), "4.2ms");
+        assert_eq!(format_render_duration(Duration::from_millis(42)), "42ms");
+    }
 }
 
diff --git a/src/web/routes.rs b/src/web/routes.rs
index 10c37ed..b99d0d7 100644
--- a/src/web/routes.rs
+++ b/src/web/routes.rs
@@ -20,7 +20,7 @@ use serde::Deserialize;
 use std::path::PathBuf;
 use uuid::Uuid;
 
-// ── helpers ──────────────────────────────────────────────────────────────────
+// ── helpers ──────────────────────────────────────────────────────────────────
 
 pub type AppResult<T> = Result<T, AppError>;
 
@@ -570,16 +570,14 @@ fn append_diff_line(out: &mut String, line: &str) {
     out.push('\n');
 }
 
-fn render_diff_html(diff: &str) -> String {
+fn render_diff_html(diff: &str, full: bool) -> (String, bool, usize) {
     let lines: Vec<&str> = diff.lines().collect();
-    let (html, truncated, total) = render_diff_lines(&lines, Some(DIFF_RENDER_MAX_LINES));
-    if truncated {
-        format!(
-            "{html}<p class=\"kg-diff__truncated\">Large diffs are not rendered by default. Showing the first {DIFF_RENDER_MAX_LINES} of {total} lines.</p>"
-        )
+    let max = if full {
+        None
     } else {
-        html
-    }
+        Some(DIFF_RENDER_MAX_LINES)
+    };
+    render_diff_lines(&lines, max)
 }
 
 /// Render unified-diff lines. When `max_lines` is set, stop after that many
@@ -625,7 +623,7 @@ fn anchor_for_path(path: &str, idx: usize) -> String {
     format!("diff-{idx}-{safe}")
 }
 
-fn parse_diff_files(diff: &str) -> Vec<DiffFileView> {
+fn parse_diff_files(diff: &str, full: bool) -> Vec<DiffFileView> {
     let mut files = Vec::new();
     if diff.trim().is_empty() {
         return files;
@@ -635,23 +633,23 @@ fn parse_diff_files(diff: &str) -> Vec<DiffFileView> {
     for line in diff.lines() {
         if line.starts_with("diff --git ") {
             if let Some((path, lines)) = current.take() {
-                files.push(build_diff_file(files.len(), path, &lines));
+                files.push(build_diff_file(files.len(), path, &lines, full));
             }
             current = Some((diff_file_path(line), vec![line.to_string()]));
         } else if let Some((_, ref mut lines)) = current {
             lines.push(line.to_string());
         } else {
-            // Preamble without a file header — treat as one blob.
+            // Preamble without a file header — treat as one blob.
             current = Some(("diff".into(), vec![line.to_string()]));
         }
     }
     if let Some((path, lines)) = current {
-        files.push(build_diff_file(files.len(), path, &lines));
+        files.push(build_diff_file(files.len(), path, &lines, full));
     }
     files
 }
 
-fn build_diff_file(idx: usize, path: String, lines: &[String]) -> DiffFileView {
+fn build_diff_file(idx: usize, path: String, lines: &[String], full: bool) -> DiffFileView {
     let mut additions = 0u32;
     let mut deletions = 0u32;
     let mut binary = false;
@@ -666,6 +664,11 @@ fn build_diff_file(idx: usize, path: String, lines: &[String]) -> DiffFileView {
         }
     }
     let refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
+    let max = if full {
+        None
+    } else {
+        Some(DIFF_RENDER_MAX_LINES)
+    };
     let (html, truncated, total_lines) = if binary {
         (
             String::from(
@@ -675,7 +678,7 @@ fn build_diff_file(idx: usize, path: String, lines: &[String]) -> DiffFileView {
             refs.len(),
         )
     } else {
-        render_diff_lines(&refs, Some(DIFF_RENDER_MAX_LINES))
+        render_diff_lines(&refs, max)
     };
     DiffFileView {
         anchor: anchor_for_path(&path, idx),
@@ -803,6 +806,33 @@ fn breadcrumbs(path: &str) -> Vec<(String, String)> {
     out
 }
 
+fn query_full(v: Option<&str>) -> bool {
+    matches!(v, Some("1") | Some("true") | Some("yes") | Some("on"))
+}
+
+async fn review_views(
+    pool: &sqlx::PgPool,
+    reviews: Vec<crate::db::models::PullReview>,
+) -> AppResult<Vec<PullReviewView>> {
+    let mut out = Vec::with_capacity(reviews.len());
+    for r in reviews {
+        let reviewer = queries::get_user_by_id(pool, r.reviewer_id)
+            .await?
+            .ok_or_else(AppError::not_found)?;
+        let avatar_url = avatar_url_for(&reviewer);
+        out.push(PullReviewView {
+            id: r.id,
+            reviewer,
+            avatar_url,
+            state: r.state,
+            body: r.body.clone(),
+            body_html: render_markdown(&r.body),
+            created_at: r.created_at,
+        });
+    }
+    Ok(out)
+}
+
 async fn comment_views(
     pool: &sqlx::PgPool,
     comments: Vec<Comment>,
@@ -2226,16 +2256,11 @@ pub async fn repo_home(
         branches = git::list_branches(g).unwrap_or_default();
         if git::resolve_ref(g, &current_branch).is_ok() {
             empty = false;
-            entries = git::list_tree(g, &current_branch, "")
-                .unwrap_or_default()
-                .into_iter()
-                .map(|e| TreeEntryView {
-                    name: e.name,
-                    path: e.path,
-                    is_dir: e.is_dir,
-                    mode: e.mode,
-                })
-                .collect();
+            entries = tree_entry_views(
+                g,
+                &current_branch,
+                git::list_tree(g, &current_branch, "").unwrap_or_default(),
+            );
             if let Ok(Some((name, src))) = git::find_readme(g, &current_branch, "") {
                 readme_html = Some(readme_to_html(
                     &name,
@@ -2265,6 +2290,10 @@ pub async fn repo_home(
         }
         None => None,
     };
+    let commit_count = match grepo.as_ref() {
+        Some(g) => git::count_commits(g, &current_branch).unwrap_or(0),
+        None => 0,
+    };
     let languages = language_stat_views(languages);
 
     let forked_from = if let Some(fid) = repository.fork_of_id {
@@ -2300,6 +2329,7 @@ pub async fn repo_home(
         languages,
         empty,
         latest_commit,
+        commit_count,
         forked_from,
         starred,
         watching,
@@ -2343,16 +2373,12 @@ pub async fn repo_tree(
     let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)
         .map_err(|_| AppError::not_found())?;
     let (branch, path) = split_ref_path(&grepo, &rest);
-    let entries = git::list_tree(&grepo, &branch, &path)
-        .map_err(|e| AppError::not_found().with_message(e.to_string()))?
-        .into_iter()
-        .map(|e| TreeEntryView {
-            name: e.name,
-            path: e.path,
-            is_dir: e.is_dir,
-            mode: e.mode,
-        })
-        .collect();
+    let entries = tree_entry_views(
+        &grepo,
+        &branch,
+        git::list_tree(&grepo, &branch, &path)
+            .map_err(|e| AppError::not_found().with_message(e.to_string()))?,
+    );
     let readme_html = match git::find_readme(&grepo, &branch, &path) {
         Ok(Some((name, src))) => Some(readme_to_html(
             &name,
@@ -2368,6 +2394,7 @@ pub async fn repo_tree(
     let branches = git::list_branches(&grepo).unwrap_or_default();
     let prepared = prepare_latest_commit(&grepo, &branch);
     let latest_commit = latest_commit_view(&state, prepared).await;
+    let commit_count = git::count_commits(&grepo, &branch).unwrap_or(0);
     let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
     Ok(RepoTreeTemplate {
         viewer,
@@ -2381,6 +2408,7 @@ pub async fn repo_tree(
         entries,
         readme_html,
         latest_commit,
+        commit_count,
         clone_http,
         clone_ssh,
     })
@@ -2441,12 +2469,27 @@ pub async fn repo_blob(
     })
 }
 
-fn blame_time_display(secs: i64) -> String {
-    chrono::DateTime::from_timestamp(secs, 0)
-        .map(|dt| dt.format("%Y-%m-%d").to_string())
-        .unwrap_or_default()
+fn tree_entry_views(grepo: &git2::Repository, branch: &str, entries: Vec<git::TreeEntry>) -> Vec<TreeEntryView> {
+    entries
+        .into_iter()
+        .map(|e| {
+            let (commit_message, commit_time) = git::list_commits_for_path(grepo, branch, &e.path, 1)
+                .ok()
+                .and_then(|mut v| v.pop())
+                .map(|c| (c.message, format_unix_time(c.time)))
+                .unwrap_or_else(|| (String::new(), String::new()));
+            TreeEntryView {
+                name: e.name,
+                path: e.path,
+                is_dir: e.is_dir,
+                commit_message,
+                commit_time,
+            }
+        })
+        .collect()
 }
 
+
 pub async fn repo_blame(
     State(state): State<AppState>,
     headers: HeaderMap,
@@ -2476,7 +2519,7 @@ pub async fn repo_blame(
                 commit_id: l.commit_id,
                 short_id: l.short_id,
                 author: l.author,
-                time_display: blame_time_display(l.time),
+                time_display: format_unix_time(l.time),
                 summary: l.summary,
                 hunk_start: l.hunk_start,
             })
@@ -2587,10 +2630,16 @@ pub async fn repo_commits(
     })
 }
 
+#[derive(Deserialize)]
+pub struct FullDiffQuery {
+    pub full: Option<String>,
+}
+
 pub async fn repo_commit(
     State(state): State<AppState>,
     headers: HeaderMap,
     Path((owner, repo, id)): Path<(String, String, String)>,
+    Query(q): Query<FullDiffQuery>,
 ) -> AppResult<impl IntoResponse> {
     let (repository, owner_user, viewer, access) =
         load_repo_context(&state, &owner, &repo, &headers).await?;
@@ -2599,7 +2648,8 @@ pub async fn repo_commit(
     let commit = git::get_commit(&grepo, &id).map_err(|_| AppError::not_found())?;
     let extracted = git::extract_commit_signature(&grepo, &commit.id);
     let diff = git::commit_diff(&grepo, &id).unwrap_or_default();
-    let diff_html = render_diff_html(&diff);
+    let show_full = query_full(q.full.as_deref());
+    let (diff_html, truncated, total_lines) = render_diff_html(&diff, show_full);
     let message_html = linkify_commit_message(
         &state.pool,
         repository.id,
@@ -2616,6 +2666,9 @@ pub async fn repo_commit(
         commit: commit_view(&state, commit, extracted).await,
         message_html,
         diff_html,
+        show_full,
+        truncated,
+        total_lines,
         clone_http: clone_urls(&state, &owner, &repo).0,
         clone_ssh: clone_urls(&state, &owner, &repo).1,
     })
@@ -2625,6 +2678,7 @@ pub async fn repo_diff(
     State(state): State<AppState>,
     headers: HeaderMap,
     Path((owner, repo, id)): Path<(String, String, String)>,
+    Query(q): Query<FullDiffQuery>,
 ) -> AppResult<impl IntoResponse> {
     let (repository, owner_user, viewer, access) =
         load_repo_context(&state, &owner, &repo, &headers).await?;
@@ -2633,7 +2687,8 @@ pub async fn repo_diff(
     let commit = git::get_commit(&grepo, &id).map_err(|_| AppError::not_found())?;
     let extracted = git::extract_commit_signature(&grepo, &commit.id);
     let diff = git::commit_diff(&grepo, &id).unwrap_or_default();
-    let diff_html = render_diff_html(&diff);
+    let show_full = query_full(q.full.as_deref());
+    let (diff_html, truncated, total_lines) = render_diff_html(&diff, show_full);
     let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
     Ok(RepoDiffTemplate {
         viewer,
@@ -2642,6 +2697,9 @@ pub async fn repo_diff(
         access,
         commit: commit_view(&state, commit, extracted).await,
         diff_html,
+        show_full,
+        truncated,
+        total_lines,
         clone_http,
         clone_ssh,
     })
@@ -2850,7 +2908,7 @@ pub async fn repo_branches(
             let updated = git::list_commits(&grepo, &name, 1)
                 .ok()
                 .and_then(|mut v| v.pop())
-                .map(|c| c.time.to_string())
+                .map(|c| format_unix_time(c.time))
                 .unwrap_or_default();
             BranchRow {
                 name,
@@ -2878,7 +2936,7 @@ pub async fn repo_branches(
             short_id: t.short_id,
             target: t.target,
             message: t.message,
-            updated: t.time.to_string(),
+            updated: format_unix_time(t.time),
         })
         .collect();
     let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
@@ -2933,7 +2991,11 @@ async fn commit_view(
         short_id: c.short_id,
         message: c.message,
         author: c.author,
-        email: c.email,
+        email: c.email.clone(),
+        author_username: queries::username_by_email(&state.pool, &c.email)
+            .await
+            .ok()
+            .flatten(),
         time: c.time,
         signed,
         verified,
@@ -3673,6 +3735,7 @@ pub async fn pull_create(
 #[derive(Deserialize)]
 pub struct PullTabQuery {
     pub tab: Option<String>,
+    pub full: Option<String>,
 }
 
 pub async fn pull_view(
@@ -3698,6 +3761,11 @@ pub async fn pull_view(
         viewer.as_ref().map(|u| u.id),
     )
     .await?;
+    let reviews = review_views(
+        &state.pool,
+        queries::list_reviews_for_pull(&state.pool, pull.id).await?,
+    )
+    .await?;
 
     let tab = match q.tab.as_deref() {
         Some("commits") => "commits",
@@ -3705,6 +3773,7 @@ pub async fn pull_view(
         _ => "conversation",
     }
     .to_string();
+    let show_full = query_full(q.full.as_deref());
 
     let mut commits = Vec::new();
     let mut diff_files = Vec::new();
@@ -3720,7 +3789,7 @@ pub async fn pull_view(
         commits = commit_views(&state, prepared).await;
         let diff =
             git::branch_diff(&g, &pull.source_branch, &pull.target_branch).unwrap_or_default();
-        diff_files = parse_diff_files(&diff);
+        diff_files = parse_diff_files(&diff, show_full);
     }
 
     let can_merge = access.can_write() && pull.state == "open";
@@ -3753,8 +3822,12 @@ pub async fn pull_view(
         .as_ref()
         .map(|u| access.can_write() || pull.author_id == u.id)
         .unwrap_or(false);
+    let is_author = viewer
+        .as_ref()
+        .map(|u| u.id == pull.author_id)
+        .unwrap_or(false);
 
-    let conversation_count = comments.len() + 1;
+    let conversation_count = comments.len() + reviews.len() + 1;
     let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
     Ok(PullViewTemplate {
         viewer,
@@ -3766,10 +3839,12 @@ pub async fn pull_view(
         pull,
         author,
         comments,
+        reviews,
         commits,
         conversation_count,
         diff_files,
         tab,
+        show_full,
         can_merge,
         merge_styles,
         labels,
@@ -3777,6 +3852,7 @@ pub async fn pull_view(
         milestone,
         milestone_options,
         can_triage,
+        is_author,
         clone_http,
         clone_ssh,
     })
@@ -3886,6 +3962,72 @@ pub async fn pull_comment(
 }
 
 #[derive(Deserialize)]
+pub struct PullReviewForm {
+    pub state: String,
+    pub body: Option<String>,
+}
+
+pub async fn pull_review(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo, number)): Path<(String, String, i32)>,
+    Form(form): Form<PullReviewForm>,
+) -> AppResult<Response> {
+    let (repository, _o, viewer, _a) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let user = viewer.ok_or_else(AppError::unauthorized)?;
+    let pull = queries::get_pull(&state.pool, repository.id, number)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+    if pull.state != "open" {
+        return Err(AppError::bad("pull request is not open"));
+    }
+    let state_name = match form.state.trim() {
+        "approved" => "approved",
+        "changes_requested" => "changes_requested",
+        "commented" => "commented",
+        _ => return Err(AppError::bad("invalid review state")),
+    };
+    if user.id == pull.author_id && state_name != "commented" {
+        return Err(AppError::bad(
+            "cannot approve or request changes on your own pull request",
+        ));
+    }
+    let body = form.body.as_deref().unwrap_or("").trim();
+    queries::create_review(&state.pool, pull.id, user.id, state_name, body).await?;
+    queries::record_activity(
+        &state.pool,
+        Some(user.id),
+        Some(repository.id),
+        "pull.review",
+        &format!("reviewed pull #{number} ({state_name})"),
+        serde_json::json!({ "number": number, "state": state_name }),
+    )
+    .await?;
+    let participants = queries::list_pull_participant_ids(&state.pool, pull.id).await?;
+    let href = format!("/{owner}/{repo}/pulls/{number}");
+    let summary = match state_name {
+        "approved" => format!("{} approved pull #{number}", user.username),
+        "changes_requested" => {
+            format!("{} requested changes on pull #{number}", user.username)
+        }
+        _ => format!("{} left a review on pull #{number}", user.username),
+    };
+    queries::notify_users(
+        &state.pool,
+        &participants,
+        user.id,
+        "pull.review",
+        &summary,
+        &notif_snippet(body, 160),
+        &href,
+        Some(repository.id),
+    )
+    .await?;
+    Ok(redirect_see_other(&href))
+}
+
+#[derive(Deserialize)]
 pub struct MergeForm {
     pub style: Option<String>,
 }
diff --git a/src/web/routes_extra.rs b/src/web/routes_extra.rs
index 1ef7990..b5767bf 100644
--- a/src/web/routes_extra.rs
+++ b/src/web/routes_extra.rs
@@ -234,13 +234,14 @@ pub async fn branch_rule_delete(
 
 #[derive(Deserialize)]
 pub struct RenameBranchForm {
+    pub branch: String,
     pub new_name: String,
 }
 
 pub async fn branch_rename(
     State(state): State<AppState>,
     headers: HeaderMap,
-    Path((owner, repo, branch)): Path<(String, String, String)>,
+    Path((owner, repo)): Path<(String, String)>,
     Form(form): Form<RenameBranchForm>,
 ) -> AppResult<Response> {
     let (repository, _, viewer, access) =
@@ -249,22 +250,38 @@ pub async fn branch_rename(
     if !access.can_write() {
         return Err(AppError::forbidden());
     }
+    let branch = form.branch.trim();
+    if branch.is_empty() {
+        return Err(AppError::bad("branch required"));
+    }
     if branch == repository.default_branch {
         return Err(AppError::bad("cannot rename default branch here"));
     }
     let new_name = form.new_name.trim();
-    if new_name.is_empty() || new_name.contains('/') {
+    // Allow hierarchical names like feat/foo; reject empty / traversal / whitespace.
+    if new_name.is_empty()
+        || new_name.contains("..")
+        || new_name.contains(char::is_whitespace)
+        || new_name.starts_with('/')
+        || new_name.ends_with('/')
+    {
         return Err(AppError::bad("invalid branch name"));
     }
     let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)?;
-    git::rename_branch(&grepo, &branch, new_name)?;
+    git::rename_branch(&grepo, branch, new_name)?;
     Ok(redirect_see_other(&format!("/{owner}/{repo}/branches")))
 }
 
+#[derive(Deserialize)]
+pub struct DeleteBranchForm {
+    pub branch: String,
+}
+
 pub async fn branch_delete(
     State(state): State<AppState>,
     headers: HeaderMap,
-    Path((owner, repo, branch)): Path<(String, String, String)>,
+    Path((owner, repo)): Path<(String, String)>,
+    Form(form): Form<DeleteBranchForm>,
 ) -> AppResult<Response> {
     let (repository, _, viewer, access) =
         load_repo_context(&state, &owner, &repo, &headers).await?;
@@ -272,11 +289,15 @@ pub async fn branch_delete(
     if !access.can_write() {
         return Err(AppError::forbidden());
     }
+    let branch = form.branch.trim();
+    if branch.is_empty() {
+        return Err(AppError::bad("branch required"));
+    }
     if branch == repository.default_branch {
         return Err(AppError::bad("cannot delete default branch"));
     }
     let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)?;
-    git::delete_branch(&grepo, &branch)?;
+    git::delete_branch(&grepo, branch)?;
     Ok(redirect_see_other(&format!("/{owner}/{repo}/branches")))
 }
 
@@ -322,13 +343,14 @@ pub async fn tag_create(
 
 #[derive(Deserialize)]
 pub struct RenameTagForm {
+    pub tag: String,
     pub new_name: String,
 }
 
 pub async fn tag_rename(
     State(state): State<AppState>,
     headers: HeaderMap,
-    Path((owner, repo, tag)): Path<(String, String, String)>,
+    Path((owner, repo)): Path<(String, String)>,
     Form(form): Form<RenameTagForm>,
 ) -> AppResult<Response> {
     let (repository, _, viewer, access) =
@@ -337,21 +359,25 @@ pub async fn tag_rename(
     if !access.can_write() {
         return Err(AppError::forbidden());
     }
+    let tag = form.tag.trim();
+    if tag.is_empty() {
+        return Err(AppError::bad("tag required"));
+    }
     let new_name = form.new_name.trim();
     if new_name.is_empty() || new_name.contains("..") {
         return Err(AppError::bad("invalid tag name"));
     }
     let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)?;
-    git::rename_tag(&grepo, &tag, new_name)
+    git::rename_tag(&grepo, tag, new_name)
         .map_err(|e| AppError::bad(format!("rename tag failed: {e}")))?;
     if let Some(release) =
-        queries::rename_release_tag(&state.pool, repository.id, &tag, new_name).await?
+        queries::rename_release_tag(&state.pool, repository.id, tag, new_name).await?
     {
         let old_dir = state
             .config
             .releases_dir()
             .join(repository.id.to_string())
-            .join(&tag);
+            .join(tag);
         let new_dir = state
             .config
             .releases_dir()
@@ -368,10 +394,16 @@ pub async fn tag_rename(
     Ok(redirect_see_other(&format!("/{owner}/{repo}/branches")))
 }
 
+#[derive(Deserialize)]
+pub struct DeleteTagForm {
+    pub tag: String,
+}
+
 pub async fn tag_delete(
     State(state): State<AppState>,
     headers: HeaderMap,
-    Path((owner, repo, tag)): Path<(String, String, String)>,
+    Path((owner, repo)): Path<(String, String)>,
+    Form(form): Form<DeleteTagForm>,
 ) -> AppResult<Response> {
     let (_repository, _, viewer, access) =
         load_repo_context(&state, &owner, &repo, &headers).await?;
@@ -379,12 +411,16 @@ pub async fn tag_delete(
     if !access.can_write() {
         return Err(AppError::forbidden());
     }
+    let tag = form.tag.trim();
+    if tag.is_empty() {
+        return Err(AppError::bad("tag required"));
+    }
     let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)?;
-    git::delete_tag(&grepo, &tag).map_err(|e| AppError::bad(format!("delete tag failed: {e}")))?;
+    git::delete_tag(&grepo, tag).map_err(|e| AppError::bad(format!("delete tag failed: {e}")))?;
     Ok(redirect_see_other(&format!("/{owner}/{repo}/branches")))
 }
 
-// ΓöÇΓöÇ account settings ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ
+// ── account settings ─────────────────────────────────────────────────────────
 
 pub async fn account_settings(
     State(state): State<AppState>,
@@ -782,6 +818,25 @@ pub async fn account_delete_email(
     Ok(redirect_see_other("/settings/account"))
 }
 
+pub async fn account_set_primary_email(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path(id): Path<Uuid>,
+) -> AppResult<Response> {
+    let user = require_login(&state.auth, &headers).await?;
+    queries::set_primary_email(&state.pool, user.id, id).await?;
+    record_audit(
+        &state,
+        &headers,
+        user.id,
+        Some(user.id),
+        "email.primary",
+        serde_json::json!({ "email_id": id }),
+    )
+    .await;
+    Ok(redirect_see_other("/settings/account"))
+}
+
 pub async fn account_revoke_session(
     State(state): State<AppState>,
     headers: HeaderMap,
diff --git a/src/web/templates.rs b/src/web/templates.rs
index 9cdcebb..f62af84 100644
--- a/src/web/templates.rs
+++ b/src/web/templates.rs
@@ -9,7 +9,7 @@ use askama_web::WebTemplate;
 use chrono::{DateTime, Utc};
 use uuid::Uuid;
 
-// ΓöÇΓöÇ view models ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ
+// ── view models ──────────────────────────────────────────────────────────────
 
 pub struct ActivityRow {
     pub kind: String,
@@ -41,6 +41,34 @@ pub struct CommentView {
     pub reactions: Vec<ReactionView>,
 }
 
+pub struct PullReviewView {
+    pub id: Uuid,
+    pub reviewer: User,
+    pub avatar_url: String,
+    pub state: String,
+    pub body: String,
+    pub body_html: String,
+    pub created_at: DateTime<Utc>,
+}
+
+impl PullReviewView {
+    pub fn state_label(&self) -> &'static str {
+        match self.state.as_str() {
+            "approved" => "approved",
+            "changes_requested" => "changes requested",
+            _ => "commented",
+        }
+    }
+
+    pub fn state_class(&self) -> &'static str {
+        match self.state.as_str() {
+            "approved" => "kg-badge--approved",
+            "changes_requested" => "kg-badge--changes",
+            _ => "kg-badge--commented",
+        }
+    }
+}
+
 pub struct DiffFileView {
     pub path: String,
     pub anchor: String,
@@ -56,7 +84,10 @@ pub struct TreeEntryView {
     pub name: String,
     pub path: String,
     pub is_dir: bool,
-    pub mode: String,
+    /// Last commit that touched this path (summary).
+    pub commit_message: String,
+    /// Formatted timestamp for that commit.
+    pub commit_time: String,
 }
 
 pub struct CommitView {
@@ -65,6 +96,8 @@ pub struct CommitView {
     pub message: String,
     pub author: String,
     pub email: String,
+    /// Kitgit username when the commit email matches a known account.
+    pub author_username: Option<String>,
     pub time: i64,
     /// True when a signature blob was present on the commit (SSH or GPG).
     pub signed: bool,
@@ -83,6 +116,17 @@ impl CommitView {
             _ => "Unknown",
         }
     }
+
+    pub fn time_display(&self) -> String {
+        format_unix_time(self.time)
+    }
+}
+
+/// Human-readable UTC date/time from a Unix timestamp (seconds).
+pub fn format_unix_time(secs: i64) -> String {
+    chrono::DateTime::from_timestamp(secs, 0)
+        .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
+        .unwrap_or_default()
 }
 
 pub struct CollaboratorView {
@@ -162,7 +206,7 @@ pub struct ReleaseAssetView {
     pub content_type: String,
 }
 
-// ΓöÇΓöÇ page templates ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ
+// ── page templates ────────────────────────────────────────────────────────────
 
 #[derive(Template, WebTemplate)]
 #[template(path = "home.html")]
@@ -346,6 +390,7 @@ pub struct RepoHomeTemplate {
     pub languages: Vec<LanguageStatView>,
     pub empty: bool,
     pub latest_commit: Option<CommitView>,
+    pub commit_count: usize,
     pub starred: bool,
     pub watching: bool,
     pub forked_from: Option<(String, String)>,
@@ -366,6 +411,7 @@ pub struct RepoTreeTemplate {
     pub entries: Vec<TreeEntryView>,
     pub readme_html: Option<String>,
     pub latest_commit: Option<CommitView>,
+    pub commit_count: usize,
     pub clone_http: String,
     pub clone_ssh: String,
 }
@@ -457,6 +503,9 @@ pub struct RepoCommitTemplate {
     pub commit: CommitView,
     pub message_html: String,
     pub diff_html: String,
+    pub show_full: bool,
+    pub truncated: bool,
+    pub total_lines: usize,
     pub clone_http: String,
     pub clone_ssh: String,
 }
@@ -470,6 +519,9 @@ pub struct RepoDiffTemplate {
     pub access: Access,
     pub commit: CommitView,
     pub diff_html: String,
+    pub show_full: bool,
+    pub truncated: bool,
+    pub total_lines: usize,
     pub clone_http: String,
     pub clone_ssh: String,
 }
@@ -585,9 +637,11 @@ pub struct PullViewTemplate {
     pub author_avatar: String,
     pub body_html: String,
     pub comments: Vec<CommentView>,
+    pub reviews: Vec<PullReviewView>,
     pub commits: Vec<CommitView>,
     pub diff_files: Vec<DiffFileView>,
     pub tab: String,
+    pub show_full: bool,
     pub conversation_count: usize,
     pub can_merge: bool,
     pub merge_styles: Vec<String>,
@@ -596,6 +650,7 @@ pub struct PullViewTemplate {
     pub milestone: Option<Milestone>,
     pub milestone_options: Vec<MilestoneOption>,
     pub can_triage: bool,
+    pub is_author: bool,
     pub clone_http: String,
     pub clone_ssh: String,
 }
diff --git a/static/brand.css b/static/brand.css
index 2c5352b..8f082ff 100644
--- a/static/brand.css
+++ b/static/brand.css
@@ -193,12 +193,21 @@ a:hover {
 }
 
 .kg-footer {
+  display: flex;
+  justify-content: space-between;
+  align-items: baseline;
+  gap: var(--kg-space-4);
   padding: var(--kg-space-4) 0 var(--kg-space-6);
   color: var(--kg-faint);
   font-size: 0.75rem;
   letter-spacing: 0.06em;
 }
 
+.kg-footer__timing {
+  letter-spacing: 0;
+  font-variant-numeric: tabular-nums;
+}
+
 .kg-display {
   font-weight: 700;
   font-size: clamp(1.5rem, 3vw, 2rem);
@@ -279,12 +288,6 @@ a:hover {
   background: var(--kg-danger-bg);
 }
 
-.kg-actions {
-  display: flex;
-  flex-wrap: wrap;
-  gap: var(--kg-space-2);
-}
-
 .kg-list {
   list-style: none;
   margin: 0;
@@ -536,32 +539,145 @@ a:hover {
   min-width: 0;
 }
 
+.kg-toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--kg-space-3);
+  margin: var(--kg-space-3) 0 var(--kg-space-2);
+  min-width: 0;
+}
+
+.kg-toolbar__branch {
+  flex-shrink: 0;
+}
+
+.kg-toolbar__actions {
+  display: flex;
+  align-items: center;
+  gap: var(--kg-space-2);
+  flex-wrap: nowrap;
+  flex-shrink: 0;
+  margin-left: auto;
+}
+
+.kg-latest {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--kg-space-3);
+  flex-wrap: nowrap;
+  border: 1px solid var(--kg-line);
+  padding: 0.55rem 0.75rem;
+  margin: 0 0 var(--kg-space-3);
+  background: var(--kg-bg-raised);
+  min-width: 0;
+}
+
+.kg-latest__main {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  min-width: 0;
+  flex: 1 1 auto;
+  overflow: hidden;
+}
+
+.kg-latest__author {
+  flex-shrink: 0;
+  font-weight: 600;
+  color: var(--kg-fg);
+  text-decoration: none;
+  white-space: nowrap;
+}
+
+a.kg-latest__author:hover {
+  text-decoration: underline;
+  text-underline-offset: 0.15em;
+}
+
+.kg-latest__msg {
+  color: var(--kg-fg);
+  text-decoration: none;
+  font-size: 0.875rem;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  min-width: 0;
+  flex: 1 1 auto;
+}
+
+.kg-latest__msg:hover {
+  text-decoration: underline;
+  text-underline-offset: 0.15em;
+}
+
+.kg-latest__meta {
+  display: flex;
+  align-items: center;
+  gap: 0.65rem;
+  flex-shrink: 0;
+  white-space: nowrap;
+}
+
+.kg-latest__sha {
+  color: var(--kg-muted);
+  text-decoration: none;
+  font-size: 0.8125rem;
+}
+
+.kg-latest__sha:hover {
+  text-decoration: underline;
+  text-underline-offset: 0.15em;
+}
+
+.kg-latest__sha code {
+  font-size: inherit;
+}
+
+.kg-latest__time {
+  white-space: nowrap;
+}
+
+.kg-latest__count {
+  text-decoration: none;
+  white-space: nowrap;
+}
+
+.kg-latest__count:hover {
+  text-decoration: underline;
+  text-underline-offset: 0.15em;
+}
+
 .kg-branchbar {
   display: flex;
   align-items: center;
   justify-content: space-between;
   gap: var(--kg-space-3);
-  flex-wrap: wrap;
+  flex-wrap: nowrap;
   border: 1px solid var(--kg-line);
   padding: 0.55rem 0.75rem;
   margin: var(--kg-space-3) 0;
   background: var(--kg-bg-raised);
+  min-width: 0;
 }
 
 .kg-branchbar__left {
   display: flex;
   align-items: center;
   gap: var(--kg-space-3);
-  flex-wrap: wrap;
+  flex-wrap: nowrap;
   min-width: 0;
   flex: 1 1 auto;
+  overflow: hidden;
 }
 
 .kg-branchbar__actions {
   display: flex;
   align-items: center;
   gap: var(--kg-space-2);
-  flex-wrap: wrap;
+  flex-wrap: nowrap;
+  flex-shrink: 0;
 }
 
 .kg-branchbar__branch {
@@ -571,15 +687,16 @@ a:hover {
   text-decoration: none;
   font-weight: 700;
   color: var(--kg-fg);
+  flex-shrink: 0;
 }
 
 .kg-branchbar__commit {
   display: flex;
   align-items: center;
-  gap: var(--kg-space-2);
+  gap: 0.5rem;
   min-width: 0;
-  flex: 1;
-  justify-content: flex-end;
+  flex: 1 1 auto;
+  overflow: hidden;
 }
 
 .kg-branchbar__sha {
@@ -599,7 +716,8 @@ a:hover {
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
-  max-width: 28rem;
+  min-width: 0;
+  flex: 1 1 auto;
 }
 
 .kg-branchbar__msg:hover,
@@ -785,15 +903,30 @@ a:hover {
 
 .kg-danger-row {
   display: flex;
-  flex-wrap: wrap;
-  align-items: flex-end;
+  flex-wrap: nowrap;
+  align-items: center;
   gap: var(--kg-space-2);
 }
 
-.kg-danger-row label {
-  flex: 1 1 12rem;
+.kg-danger-row label,
+.kg-danger-confirm {
+  flex: 1 1 auto;
   margin: 0;
-  gap: 0.25rem;
+  display: flex;
+  flex-direction: row;
+  flex-wrap: nowrap;
+  align-items: center;
+  gap: 0.5rem;
+  white-space: nowrap;
+}
+
+.kg-danger-confirm input {
+  flex: 1 1 10rem;
+  min-width: 8rem;
+}
+
+.kg-danger-row > button {
+  flex-shrink: 0;
 }
 
 .kg-settings-section {
@@ -808,6 +941,7 @@ a:hover {
 
 .kg-social {
   display: flex;
+  flex-direction: row;
   flex-wrap: wrap;
   gap: var(--kg-space-2);
   margin: 0;
@@ -818,6 +952,29 @@ a:hover {
 
 .kg-social form {
   margin: 0;
+  display: inline-flex;
+}
+
+.kg-branchbar__count {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.35em;
+  text-decoration: none;
+  flex-shrink: 0;
+  white-space: nowrap;
+}
+
+.kg-branchbar__count:hover {
+  text-decoration: underline;
+  text-underline-offset: 0.15em;
+}
+
+.kg-actions {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  gap: var(--kg-space-2);
+  align-items: center;
 }
 
 .kg-reactions {
@@ -1342,12 +1499,38 @@ a:hover {
 
 .kg-tree li {
   display: grid;
-  grid-template-columns: 1.5rem 1fr auto auto;
+  grid-template-columns: 1.5rem minmax(5rem, 12rem) minmax(0, 1fr) 9rem 2rem;
   align-items: center;
   gap: var(--kg-space-2);
   padding: 0.35rem var(--kg-space-3);
   border-bottom: 1px solid var(--kg-line);
   font-size: 0.875rem;
+  min-width: 0;
+}
+
+.kg-tree li > a {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  min-width: 0;
+}
+
+.kg-tree__commit {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  min-width: 0;
+}
+
+.kg-tree__time {
+  text-align: right;
+  white-space: nowrap;
+  flex-shrink: 0;
+}
+
+.kg-tree__dl {
+  justify-self: end;
+  flex-shrink: 0;
 }
 
 .kg-tree li:last-child {
@@ -1633,6 +1816,57 @@ a:hover {
   color: var(--kg-muted);
 }
 
+.kg-badge--approved {
+  color: #1a7f37;
+  border-color: #1a7f37;
+  background: color-mix(in srgb, #1a7f37 12%, transparent);
+}
+
+.kg-badge--changes {
+  color: #cf222e;
+  border-color: #cf222e;
+  background: color-mix(in srgb, #cf222e 12%, transparent);
+}
+
+.kg-badge--commented {
+  color: var(--kg-muted);
+  border-color: var(--kg-line-strong);
+  background: transparent;
+}
+
+.kg-review-state {
+  border: 1px solid var(--kg-line);
+  border-radius: var(--kg-radius);
+  padding: var(--kg-space-3);
+  margin: 0 0 var(--kg-space-3);
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: var(--kg-space-3);
+}
+
+.kg-review-state legend {
+  padding: 0 var(--kg-space-1);
+  font-size: 0.8125rem;
+  color: var(--kg-muted);
+  text-transform: lowercase;
+  width: 100%;
+  float: left;
+  margin-bottom: var(--kg-space-1);
+}
+
+.kg-review-state .row {
+  display: inline-flex;
+  align-items: center;
+  gap: var(--kg-space-2);
+  margin: 0;
+}
+
+.kg-diff__truncated .kg-btn {
+  margin-left: var(--kg-space-2);
+  vertical-align: middle;
+}
+
 .kg-badge--verified {
   color: #1a7f37;
   border-color: #1a7f37;
diff --git a/templates/account_settings.html b/templates/account_settings.html
index 218c9bb..2b8cbee 100644
--- a/templates/account_settings.html
+++ b/templates/account_settings.html
@@ -97,11 +97,16 @@
           {% if e.is_primary %}<span class="kg-badge">primary</span>{% endif %}
           {% if e.verified %}<span class="kg-badge">verified</span>{% endif %}
         </span>
-        {% if !e.is_primary %}
-        <form method="post" action="/settings/account/emails/{{ e.id }}/delete">
-          <button class="kg-btn kg-btn--danger-ghost" type="submit">remove</button>
-        </form>
-        {% endif %}
+        <span class="kg-actions">
+          {% if !e.is_primary %}
+          <form method="post" action="/settings/account/emails/{{ e.id }}/primary">
+            <button class="kg-btn kg-btn--ghost" type="submit">make primary</button>
+          </form>
+          <form method="post" action="/settings/account/emails/{{ e.id }}/delete">
+            <button class="kg-btn kg-btn--danger-ghost" type="submit">remove</button>
+          </form>
+          {% endif %}
+        </span>
       </li>
       {% endfor %}
     </ul>
@@ -191,8 +196,8 @@
     <span class="kg-kicker">delete account</span>
     <form class="kg-form" method="post" action="/settings/account/delete">
       <div class="kg-danger-row">
-        <label>
-          type <code>{{ user.username }}</code> to delete
+        <label class="kg-danger-confirm">
+          <span>type <code>{{ user.username }}</code> to delete</span>
           <input type="text" name="confirm" required autocomplete="off" />
         </label>
         <button class="kg-btn kg-btn--danger" type="submit">delete account</button>
diff --git a/templates/explore.html b/templates/explore.html
index 52b996b..257b735 100644
--- a/templates/explore.html
+++ b/templates/explore.html
@@ -62,7 +62,7 @@
                 <p class="kg-meta" style="margin:0.25rem 0 0">{{ item.repo.description }}</p>
               {% endif %}
             </div>
-            <span class="kg-meta">{{ item.repo.updated_at }}</span>
+            <span class="kg-meta">{{ item.repo.updated_at.format("%Y-%m-%d") }}</span>
           </li>
           {% endfor %}
         </ul>
@@ -113,7 +113,7 @@
                 {{ item.owner }}/{{ item.repo_name }}#{{ item.number }} {{ item.title }}
               </a>
             </div>
-            <span class="kg-meta">{{ item.updated_at }}</span>
+            <span class="kg-meta">{{ item.updated_at.format("%Y-%m-%d") }}</span>
           </li>
           {% endfor %}
         </ul>
@@ -138,7 +138,7 @@
                 {{ item.owner }}/{{ item.repo_name }}#{{ item.number }} {{ item.title }}
               </a>
             </div>
-            <span class="kg-meta">{{ item.updated_at }}</span>
+            <span class="kg-meta">{{ item.updated_at.format("%Y-%m-%d") }}</span>
           </li>
           {% endfor %}
         </ul>
diff --git a/templates/issues_list.html b/templates/issues_list.html
index 2dcf9cc..0cfb259 100644
--- a/templates/issues_list.html
+++ b/templates/issues_list.html
@@ -13,8 +13,8 @@
   {% include "partials/repo_tabs.html" %}
 
   <div class="kg-actions">
-    <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/issues?state=open{% if let Some(lid) = label_filter %}&label={{ lid }}{% endif %}{% if let Some(mid) = milestone_filter %}&milestone={{ mid }}{% endif %}"{% if state_filter == "open" %} aria-current="page"{% endif %}>open</a>
-    <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/issues?state=closed{% if let Some(lid) = label_filter %}&label={{ lid }}{% endif %}{% if let Some(mid) = milestone_filter %}&milestone={{ mid }}{% endif %}"{% if state_filter == "closed" %} aria-current="page"{% endif %}>closed</a>
+    <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/issues?state=open"{% if state_filter == "open" %} aria-current="page"{% endif %}>open</a>
+    <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/issues?state=closed"{% if state_filter == "closed" %} aria-current="page"{% endif %}>closed</a>
     {% if viewer.is_some() %}
       <a class="kg-btn" href="/{{ owner.username }}/{{ repo.name }}/issues/new">new issue</a>
     {% endif %}
@@ -22,29 +22,6 @@
     <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/milestones">milestones</a>
   </div>
 
-  <form class="kg-form kg-filters" method="get" action="/{{ owner.username }}/{{ repo.name }}/issues">
-    <input type="hidden" name="state" value="{{ state_filter }}" />
-    <label>
-      label
-      <select name="label">
-        <option value="">any</option>
-        {% for l in labels %}
-          <option value="{{ l.id }}"{% if let Some(lid) = label_filter %}{% if lid.to_string() == l.id.to_string() %} selected{% endif %}{% endif %}>{{ l.name }}</option>
-        {% endfor %}
-      </select>
-    </label>
-    <label>
-      milestone
-      <select name="milestone">
-        <option value="">any</option>
-        {% for m in milestones %}
-          <option value="{{ m.id }}"{% if let Some(mid) = milestone_filter %}{% if mid.to_string() == m.id.to_string() %} selected{% endif %}{% endif %}>{{ m.title }}</option>
-        {% endfor %}
-      </select>
-    </label>
-    <button class="kg-btn kg-btn--ghost" type="submit">filter</button>
-  </form>
-
   {% if issues.is_empty() %}
     <p class="kg-muted">No {{ state_filter }} issues.</p>
   {% else %}
@@ -61,7 +38,7 @@
             <span class="kg-meta">milestone: {{ m.title }}</span>
           {% endif %}
         </span>
-        <span class="kg-meta">{{ item.issue.updated_at }}</span>
+        <span class="kg-meta">{{ item.issue.updated_at.format("%Y-%m-%d") }}</span>
       </li>
       {% endfor %}
     </ul>
diff --git a/templates/layout.html b/templates/layout.html
index 741a883..16b2e34 100644
--- a/templates/layout.html
+++ b/templates/layout.html
@@ -44,7 +44,10 @@
     <main>
       {% block content %}{% endblock %}
     </main>
-    <footer class="kg-footer">kitgit</footer>
+    <footer class="kg-footer">
+      <span>kitgit</span>
+      <span class="kg-footer__timing">rendered in <!--kg-render-ms--></span>
+    </footer>
   </div>
   <script>
   (async () => {
diff --git a/templates/partials/branch_bar.html b/templates/partials/branch_bar.html
index e5210d2..91ce761 100644
--- a/templates/partials/branch_bar.html
+++ b/templates/partials/branch_bar.html
@@ -1,23 +1,10 @@
-{# Expects: owner, repo, branch, latest_commit: Option<CommitView>, access (optional for upload), upload_path (optional) #}
-<div class="kg-branchbar">
-  <div class="kg-branchbar__left">
-    <a class="kg-branchbar__branch" href="/{{ owner.username }}/{{ repo.name }}/branches">
-      <span class="kg-icon" style="--icon:url('/static/icons/git-branch.svg')"></span>
-      {{ branch }}
-    </a>
-    {% if let Some(c) = latest_commit %}
-    <div class="kg-branchbar__commit">
-      <a class="kg-branchbar__sha" href="/{{ owner.username }}/{{ repo.name }}/commits" title="View commits">
-        <span class="kg-icon" style="--icon:url('/static/icons/git-commit.svg')"></span>
-        {{ c.short_id }}
-      </a>
-      <a class="kg-branchbar__msg" href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}" title="View commit">{{ c.message }}</a>
-      {% let show_unverified = false %}
-      {% include "partials/verified_badge.html" %}
-    </div>
-    {% endif %}
-  </div>
-  <div class="kg-branchbar__actions">
+{# Expects: owner, repo, branch, latest_commit: Option<CommitView>, access, upload_path, commit_count #}
+<div class="kg-toolbar">
+  <a class="kg-btn kg-btn--ghost kg-toolbar__branch" href="/{{ owner.username }}/{{ repo.name }}/branches">
+    <span class="kg-icon" style="--icon:url('/static/icons/git-branch.svg')"></span>
+    {{ branch }}
+  </a>
+  <div class="kg-toolbar__actions">
     {% if upload_path != "" %}
     <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/archive.zip?ref={{ branch }}&path={{ upload_path }}" title="Download this folder as ZIP">
       <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span>
@@ -58,3 +45,27 @@
     {% endif %}
   </div>
 </div>
+
+{% if let Some(c) = latest_commit %}
+<div class="kg-latest">
+  <div class="kg-latest__main">
+    {% if let Some(uname) = c.author_username %}
+      <a class="kg-latest__author" href="/{{ uname }}">{{ c.author }}</a>
+    {% else %}
+      <span class="kg-latest__author">{{ c.author }}</span>
+    {% endif %}
+    <a class="kg-latest__msg" href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}" title="{{ c.message }}">{{ c.message }}</a>
+  </div>
+  <div class="kg-latest__meta">
+    <a class="kg-latest__sha" href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}" title="{{ c.id }}">
+      <code>{{ c.short_id }}</code>
+    </a>
+    {% let show_unverified = false %}
+    {% include "partials/verified_badge.html" %}
+    <time class="kg-meta kg-latest__time">{{ c.time_display() }}</time>
+    <a class="kg-latest__count kg-meta" href="/{{ owner.username }}/{{ repo.name }}/commits" title="{{ commit_count }} commits">
+      {{ commit_count }} commits
+    </a>
+  </div>
+</div>
+{% endif %}
diff --git a/templates/partials/repo_tabs.html b/templates/partials/repo_tabs.html
index 5ca44db..d56147e 100644
--- a/templates/partials/repo_tabs.html
+++ b/templates/partials/repo_tabs.html
@@ -31,6 +31,7 @@
     </a>
     {% endif %}
   </div>
+  {% if tab == "code" %}
   <details class="kg-dropdown">
     <summary class="kg-btn kg-btn--ghost">
       <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span>
@@ -58,4 +59,5 @@
       </div>
     </div>
   </details>
+  {% endif %}
 </nav>
diff --git a/templates/pull_view.html b/templates/pull_view.html
index 1c8c082..843c4e1 100644
--- a/templates/pull_view.html
+++ b/templates/pull_view.html
@@ -38,7 +38,7 @@
       commits
       <span class="kg-pr-tabs__count">{{ commits.len() }}</span>
     </a>
-    <a href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}?tab=files"{% if pr_tab == "files" %} aria-current="page"{% endif %}>
+    <a href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}?tab=files{% if show_full %}&amp;full=1{% endif %}"{% if pr_tab == "files" %} aria-current="page"{% endif %}>
       <span class="kg-icon" style="--icon:url('/static/icons/file.svg')"></span>
       files
       <span class="kg-pr-tabs__count">{{ diff_files.len() }}</span>
@@ -71,6 +71,51 @@
   </article>
   {% endfor %}
 
+  {% for r in reviews %}
+  <article class="kg-comment kg-review">
+    <header>
+      <span>
+        <img class="kg-avatar kg-avatar--sm" src="{{ r.avatar_url }}" alt="" />
+        <a href="/{{ r.reviewer.username }}">{{ r.reviewer.username }}</a>
+        <span class="kg-badge {{ r.state_class() }}">{{ r.state_label() }}</span>
+      </span>
+      <span>{{ r.created_at }}</span>
+    </header>
+    {% if !r.body.is_empty() %}
+    <div class="kg-readme">{{ r.body_html|safe }}</div>
+    {% endif %}
+  </article>
+  {% endfor %}
+
+  {% if viewer.is_some() && pull.state == "open" %}
+    <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}/review">
+      <fieldset class="kg-review-state">
+        <legend>review</legend>
+        {% if !is_author %}
+        <label class="row">
+          <input type="radio" name="state" value="approved" />
+          approve
+        </label>
+        <label class="row">
+          <input type="radio" name="state" value="changes_requested" />
+          request changes
+        </label>
+        {% endif %}
+        <label class="row">
+          <input type="radio" name="state" value="commented" checked />
+          comment
+        </label>
+      </fieldset>
+      <label>
+        review notes
+        <textarea name="body" rows="4" placeholder="Leave a review comment (optional)"></textarea>
+      </label>
+      <div class="kg-actions">
+        <button class="kg-btn" type="submit">submit review</button>
+      </div>
+    </form>
+  {% endif %}
+
   {% if viewer.is_some() %}
     <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}">
       <label>
@@ -159,10 +204,11 @@
               </span>
             </header>
             {{ f.html|safe }}
-            {% if f.truncated %}
+            {% if f.truncated && !show_full %}
               <p class="kg-diff__truncated">
                 Large diffs are not rendered by default.
                 Showing the first 50 of {{ f.total_lines }} lines.
+                <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}?tab=files&amp;full=1#{{ f.anchor }}">Show full diff</a>
               </p>
             {% endif %}
           </section>
diff --git a/templates/pulls_list.html b/templates/pulls_list.html
index 1b26d41..50da817 100644
--- a/templates/pulls_list.html
+++ b/templates/pulls_list.html
@@ -14,13 +14,7 @@
     <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/labels">labels</a>
     <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/milestones">milestones</a>
   </div>
-  <form class="kg-form kg-filters" method="get" action="/{{ owner.username }}/{{ repo.name }}/pulls">
-    <input type="hidden" name="state" value="{{ state_filter }}" />
-    <label>label<select name="label"><option value="">any</option>{% for l in labels %}<option value="{{ l.id }}"{% if let Some(lid) = label_filter %}{% if lid.to_string() == l.id.to_string() %} selected{% endif %}{% endif %}>{{ l.name }}</option>{% endfor %}</select></label>
-    <label>milestone<select name="milestone"><option value="">any</option>{% for m in milestones %}<option value="{{ m.id }}"{% if let Some(mid) = milestone_filter %}{% if mid.to_string() == m.id.to_string() %} selected{% endif %}{% endif %}>{{ m.title }}</option>{% endfor %}</select></label>
-    <button class="kg-btn kg-btn--ghost" type="submit">filter</button>
-  </form>
   {% if pulls.is_empty() %}<p class="kg-muted">No {{ state_filter }} pulls.</p>{% else %}
-  <ul class="kg-list">{% for item in pulls %}<li><span><span class="kg-badge {% if item.pull.state == "open" %}kg-badge--open{% else %}kg-badge--closed{% endif %}">{{ item.pull.state }}</span> <a href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ item.pull.number }}">#{{ item.pull.number }} {{ item.pull.title }}</a> <span class="kg-meta">{{ item.pull.source_branch }} → {{ item.pull.target_branch }}</span>{% for l in item.labels %} <span class="kg-label-chip" style="background:{{ l.bg_color() }};color:{{ l.text_color() }}">{{ l.name }}</span>{% endfor %}{% if let Some(m) = item.milestone %} <span class="kg-meta">milestone: {{ m.title }}</span>{% endif %}</span><span class="kg-meta">{{ item.pull.updated_at }}</span></li>{% endfor %}</ul>{% endif %}
+  <ul class="kg-list">{% for item in pulls %}<li><span><span class="kg-badge {% if item.pull.state == "open" %}kg-badge--open{% else %}kg-badge--closed{% endif %}">{{ item.pull.state }}</span> <a href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ item.pull.number }}">#{{ item.pull.number }} {{ item.pull.title }}</a> <span class="kg-meta">{{ item.pull.source_branch }} → {{ item.pull.target_branch }}</span>{% for l in item.labels %} <span class="kg-label-chip" style="background:{{ l.bg_color() }};color:{{ l.text_color() }}">{{ l.name }}</span>{% endfor %}{% if let Some(m) = item.milestone %} <span class="kg-meta">milestone: {{ m.title }}</span>{% endif %}</span><span class="kg-meta">{{ item.pull.updated_at.format("%Y-%m-%d") }}</span></li>{% endfor %}</ul>{% endif %}
 </section>
 {% endblock %}
diff --git a/templates/repo_branches.html b/templates/repo_branches.html
index 5691acd..03f22f1 100644
--- a/templates/repo_branches.html
+++ b/templates/repo_branches.html
@@ -61,13 +61,15 @@
           <td>
             {% if access.can_write() && !b.is_default %}
             <div class="kg-branch-actions">
-              <form method="post" action="/{{ owner.username }}/{{ repo.name }}/branches/{{ b.name }}/rename" style="display:inline-flex;gap:0.35rem">
+              <form method="post" action="/{{ owner.username }}/{{ repo.name }}/branches/rename" style="display:inline-flex;gap:0.35rem">
+                <input type="hidden" name="branch" value="{{ b.name }}" />
                 <input type="text" name="new_name" placeholder="rename" required style="width:7rem;font:inherit;border:1px solid var(--kg-line);padding:0.25em 0.4em" />
                 <button class="kg-btn kg-btn--ghost" type="submit" title="Rename">
                   <span class="kg-icon" style="--icon:url('/static/icons/pencil.svg')"></span>
                 </button>
               </form>
-              <form method="post" action="/{{ owner.username }}/{{ repo.name }}/branches/{{ b.name }}/delete" style="display:inline" onsubmit="return confirm('Delete branch {{ b.name }}?')">
+              <form method="post" action="/{{ owner.username }}/{{ repo.name }}/branches/delete" style="display:inline" onsubmit="return confirm('Delete branch {{ b.name }}?')">
+                <input type="hidden" name="branch" value="{{ b.name }}" />
                 <button class="kg-btn kg-btn--danger-ghost" type="submit" title="Delete">
                   <span class="kg-icon" style="--icon:url('/static/icons/trash.svg')"></span>
                 </button>
@@ -139,13 +141,15 @@
           <td>
             {% if access.can_write() %}
             <div class="kg-branch-actions">
-              <form method="post" action="/{{ owner.username }}/{{ repo.name }}/tags/{{ t.name }}/rename" style="display:inline-flex;gap:0.35rem">
+              <form method="post" action="/{{ owner.username }}/{{ repo.name }}/tags/rename" style="display:inline-flex;gap:0.35rem">
+                <input type="hidden" name="tag" value="{{ t.name }}" />
                 <input type="text" name="new_name" placeholder="rename" required style="width:7rem;font:inherit;border:1px solid var(--kg-line);padding:0.25em 0.4em" />
                 <button class="kg-btn kg-btn--ghost" type="submit" title="Rename">
                   <span class="kg-icon" style="--icon:url('/static/icons/pencil.svg')"></span>
                 </button>
               </form>
-              <form method="post" action="/{{ owner.username }}/{{ repo.name }}/tags/{{ t.name }}/delete" style="display:inline" onsubmit="return confirm('Delete tag {{ t.name }}?')">
+              <form method="post" action="/{{ owner.username }}/{{ repo.name }}/tags/delete" style="display:inline" onsubmit="return confirm('Delete tag {{ t.name }}?')">
+                <input type="hidden" name="tag" value="{{ t.name }}" />
                 <button class="kg-btn kg-btn--danger-ghost" type="submit" title="Delete">
                   <span class="kg-icon" style="--icon:url('/static/icons/trash.svg')"></span>
                 </button>
diff --git a/templates/repo_commit.html b/templates/repo_commit.html
index 9e5199d..3a965a9 100644
--- a/templates/repo_commit.html
+++ b/templates/repo_commit.html
@@ -19,9 +19,16 @@
     {% let show_unverified = true %}
     {% include "partials/verified_badge.html" %}
   </p>
-  <p class="kg-meta">{{ commit.author }} &lt;{{ commit.email }}&gt; · {{ commit.time }}</p>
+  <p class="kg-meta">{{ commit.author }} &lt;{{ commit.email }}&gt; · {{ commit.time_display() }}</p>
 
-  <p><a href="/{{ owner.username }}/{{ repo.name }}/diff/{{ commit.id }}">view full diff</a></p>
+  <p><a href="/{{ owner.username }}/{{ repo.name }}/diff/{{ commit.id }}{% if show_full %}?full=1{% endif %}">view full diff</a></p>
   {{ diff_html|safe }}
+  {% if truncated && !show_full %}
+  <p class="kg-diff__truncated">
+    Large diffs are not rendered by default.
+    Showing the first 50 of {{ total_lines }} lines.
+    <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/commit/{{ commit.id }}?full=1">Show full diff</a>
+  </p>
+  {% endif %}
 </section>
 {% endblock %}
diff --git a/templates/repo_commits.html b/templates/repo_commits.html
index 25faab0..517248d 100644
--- a/templates/repo_commits.html
+++ b/templates/repo_commits.html
@@ -35,7 +35,7 @@
           {% include "partials/verified_badge.html" %}
           <span class="kg-muted"> — {{ c.author }}</span>
         </span>
-        <span class="kg-meta">{{ c.time }}</span>
+        <span class="kg-meta">{{ c.time_display() }}</span>
       </li>
       {% endfor %}
     </ul>
diff --git a/templates/repo_diff.html b/templates/repo_diff.html
index 4d32b65..122515a 100644
--- a/templates/repo_diff.html
+++ b/templates/repo_diff.html
@@ -20,12 +20,19 @@
     · {{ commit.author }}
   </p>
   <p class="kg-title">
-    <a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ commit.id }}">{{ commit.message }}</a>
+    <a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ commit.id }}{% if show_full %}?full=1{% endif %}">{{ commit.message }}</a>
     {% let c = commit %}
     {% let show_unverified = true %}
     {% include "partials/verified_badge.html" %}
   </p>
 
   {{ diff_html|safe }}
+  {% if truncated && !show_full %}
+  <p class="kg-diff__truncated">
+    Large diffs are not rendered by default.
+    Showing the first 50 of {{ total_lines }} lines.
+    <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/diff/{{ commit.id }}?full=1">Show full diff</a>
+  </p>
+  {% endif %}
 </section>
 {% endblock %}
diff --git a/templates/repo_history.html b/templates/repo_history.html
index 92e644f..d8e95b6 100644
--- a/templates/repo_history.html
+++ b/templates/repo_history.html
@@ -58,7 +58,7 @@
           {% endif %}
           <span class="kg-muted"> — {{ c.author }}</span>
         </span>
-        <span class="kg-meta">{{ c.time }}</span>
+        <span class="kg-meta">{{ c.time_display() }}</span>
       </li>
       {% endfor %}
     </ul>
diff --git a/templates/repo_home.html b/templates/repo_home.html
index 7ee008a..f2e3424 100644
--- a/templates/repo_home.html
+++ b/templates/repo_home.html
@@ -104,7 +104,8 @@
         {% if e.is_dir %}
           <span class="kg-icon" style="--icon:url('/static/icons/folder.svg')"></span>
           <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ current_branch }}/{{ e.path }}">{{ e.name }}</a>
-          <span class="kg-meta">{{ e.mode }}</span>
+          <span class="kg-tree__commit kg-meta" title="{{ e.commit_message }}">{{ e.commit_message }}</span>
+          <span class="kg-tree__time kg-meta">{{ e.commit_time }}</span>
           <a class="kg-tree__dl kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/archive.zip?ref={{ current_branch }}&path={{ e.path }}" title="Download {{ e.name }} as ZIP">
             <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span>
             <span class="kg-sr-only">Download</span>
@@ -112,7 +113,8 @@
         {% else %}
           <span class="kg-icon" style="--icon:url('/static/icons/file.svg')"></span>
           <a href="/{{ owner.username }}/{{ repo.name }}/blob/{{ current_branch }}/{{ e.path }}">{{ e.name }}</a>
-          <span class="kg-meta">{{ e.mode }}</span>
+          <span class="kg-tree__commit kg-meta" title="{{ e.commit_message }}">{{ e.commit_message }}</span>
+          <span class="kg-tree__time kg-meta">{{ e.commit_time }}</span>
         {% endif %}
       </li>
       {% endfor %}
diff --git a/templates/repo_settings.html b/templates/repo_settings.html
index 328e995..e6ae11e 100644
--- a/templates/repo_settings.html
+++ b/templates/repo_settings.html
@@ -385,8 +385,8 @@
 
       <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/danger/delete">
         <div class="kg-danger-row">
-          <label>
-            type <code>{{ repo.name }}</code> to delete
+          <label class="kg-danger-confirm">
+            <span>type <code>{{ repo.name }}</code> to delete</span>
             <input type="text" name="confirm" required autocomplete="off" />
           </label>
           <button class="kg-btn kg-btn--danger" type="submit">delete repository</button>
diff --git a/templates/repo_tree.html b/templates/repo_tree.html
index 9a27884..3124b29 100644
--- a/templates/repo_tree.html
+++ b/templates/repo_tree.html
@@ -27,7 +27,8 @@
       {% if e.is_dir %}
         <span class="kg-icon" style="--icon:url('/static/icons/folder.svg')"></span>
         <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ branch }}/{{ e.path }}">{{ e.name }}</a>
-        <span class="kg-meta">{{ e.mode }}</span>
+        <span class="kg-tree__commit kg-meta" title="{{ e.commit_message }}">{{ e.commit_message }}</span>
+        <span class="kg-tree__time kg-meta">{{ e.commit_time }}</span>
         <a class="kg-tree__dl kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/archive.zip?ref={{ branch }}&path={{ e.path }}" title="Download {{ e.name }} as ZIP">
           <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span>
           <span class="kg-sr-only">Download</span>
@@ -35,7 +36,8 @@
       {% else %}
         <span class="kg-icon" style="--icon:url('/static/icons/file.svg')"></span>
         <a href="/{{ owner.username }}/{{ repo.name }}/blob/{{ branch }}/{{ e.path }}">{{ e.name }}</a>
-        <span class="kg-meta">{{ e.mode }}</span>
+        <span class="kg-tree__commit kg-meta" title="{{ e.commit_message }}">{{ e.commit_message }}</span>
+        <span class="kg-tree__time kg-meta">{{ e.commit_time }}</span>
       {% endif %}
     </li>
     {% endfor %}
diff --git a/templates/signup.html b/templates/signup.html
index 5eceaf6..9ed43de 100644
--- a/templates/signup.html
+++ b/templates/signup.html
@@ -5,7 +5,13 @@
 {% block content %}
 <section class="kg-section kg-auth">
   {% if !signups_enabled %}
-    <div class="kg-flash kg-flash--banner">{{ signup_disabled_message }}</div>
+    <div class="kg-flash kg-flash--banner">
+      {% if invite.is_empty() %}
+        {{ signup_disabled_message }}
+      {% else %}
+        Invite-only signup — your invite code unlocks registration.
+      {% endif %}
+    </div>
   {% endif %}
   <p class="kg-landing__brand">
     <span class="kg-mark__glyph" aria-hidden="true">狐</span>
@@ -15,12 +21,11 @@
   {% if let Some(err) = error %}
     <div class="kg-flash">{{ err }}</div>
   {% endif %}
-  {% if signups_enabled || !invite.is_empty() %}
   <form class="kg-form" method="post" action="/auth/signup" autocomplete="on">
     {% if !signups_enabled %}
     <label>
       invite code
-      <input type="text" name="invite" required value="{{ invite }}" autocomplete="off" spellcheck="false" />
+      <input type="text" name="invite" required value="{{ invite }}" autocomplete="off" spellcheck="false" placeholder="Enter invite code" />
     </label>
     {% endif %}
     <label>
@@ -43,17 +48,6 @@
       <button class="kg-btn" type="submit">create account</button>
     </div>
   </form>
-  {% else %}
-  <form class="kg-form" method="get" action="/auth/signup" style="margin-top:1rem">
-    <label>
-      invite code
-      <input type="text" name="invite" required autocomplete="off" spellcheck="false" placeholder="Enter an invite code" />
-    </label>
-    <div class="kg-actions">
-      <button class="kg-btn" type="submit">continue</button>
-    </div>
-  </form>
-  {% endif %}
   <p class="kg-meta" style="margin-top:1.25rem">
     Already have an account? <a href="/auth/login">Log in</a>
   </p>