Add file (blob) view page
- New /{repo}/blob/{*path} route serving file content
- Template with path breadcrumb, blob metadata strip (on/at/lines/size/language/mode/encoding), last change strip, and full-width code surface with line-number gutter
- Tree view file entries now link to the blob page
- Handler reads blob via git show, detects language from extension, detects encoding/line-ending, HTML-escapes content
- CSS for file-meta, file-last-change, code-surface, code-gutter, code-body

Assisted-by: Owl Alpha via pi
change oynymvllkpomwmvznqtypnqquwuvnsup
commit ecb30bbf32b7c367ba23c4d9f7507db0d938c378
author Alpha Chen <alpha@kejadlen.dev>
date
parent rvnlmqwz
diff --git a/quire-server/src/quire/web/handlers/file.rs b/quire-server/src/quire/web/handlers/file.rs
new file mode 100644
index 0000000..192105e
--- /dev/null
+++ b/quire-server/src/quire/web/handlers/file.rs
@@ -0,0 +1,208 @@
+//! Handler for the file (blob) view.
+
+use axum::extract::{Path as AxumPath, State};
+use axum::http::StatusCode;
+use axum::response::{IntoResponse, Response};
+
+use super::super::db;
+use super::super::templates::{FileViewTemplate, nav_sections};
+use super::git::RepoView;
+use super::render;
+use crate::Quire;
+
+pub async fn file_view(
+    State(quire): State<Quire>,
+    AxumPath((repo, path)): AxumPath<(String, String)>,
+) -> Response {
+    let repo_display = repo.trim_end_matches(".git").to_string();
+    let repo_name = db::resolve_repo_name(&repo);
+    let git_repo = match quire.repo(&repo_name) {
+        Ok(r) if r.exists() => r,
+        _ => return StatusCode::NOT_FOUND.into_response(),
+    };
+
+    let path_clone = path.clone();
+    let result = tokio::task::spawn_blocking(move || {
+        let reader = RepoView::new(&git_repo);
+        read_file_data(&reader, &path_clone)
+    })
+    .await
+    .unwrap_or(None);
+
+    let file_data = match result {
+        Some(v) => v,
+        None => return StatusCode::NOT_FOUND.into_response(),
+    };
+
+    let tmpl = FileViewTemplate {
+        sections: nav_sections(&repo_display, "tree", false),
+        repo: repo_display,
+        crumbs: vec![],
+        path,
+        bookmark: file_data.bookmark,
+        sha_short: file_data.sha_short.clone(),
+        sha_head: file_data.sha_short[..file_data.sha_short.len().min(4)].to_string(),
+        sha_tail: file_data.sha_short[file_data.sha_short.len().min(4)..].to_string(),
+        last_change_sha: file_data.last_change_sha,
+        last_change_head: file_data.last_change_head,
+        last_change_tail: file_data.last_change_tail,
+        last_change_msg: file_data.last_change_msg,
+        last_change_author: file_data.last_change_author,
+        last_change_age: file_data.last_change_age,
+        line_count: file_data.line_count,
+        file_size: file_data.file_size,
+        language: file_data.language,
+        mode: file_data.mode,
+        encoding: file_data.encoding,
+        line_ending: file_data.line_ending,
+        line_nums: (1..=file_data.line_count).collect(),
+        lines: file_data.lines,
+    };
+    render(&tmpl)
+}
+
+struct FileData {
+    bookmark: String,
+    sha_short: String,
+    last_change_sha: String,
+    last_change_head: String,
+    last_change_tail: String,
+    last_change_msg: String,
+    last_change_author: String,
+    last_change_age: String,
+    line_count: usize,
+    file_size: String,
+    language: String,
+    mode: String,
+    encoding: String,
+    line_ending: String,
+    lines: Vec<String>,
+}
+
+fn read_file_data(reader: &RepoView<'_>, path: &str) -> Option<FileData> {
+    let bookmark = reader
+        .run(&["symbolic-ref", "--short", "HEAD"])
+        .unwrap_or_else(|| "main".to_string());
+
+    let sha_short = reader
+        .run(&["rev-parse", "--short", "HEAD"])
+        .unwrap_or_else(|| "unknown".to_string());
+
+    // Read the blob content.
+    let blob = reader.run(&["show", &format!("HEAD:{path}")])?;
+
+    // File mode.
+    let mode = reader
+        .run(&["ls-tree", "--format=%(objectmode)", "HEAD", path])
+        .unwrap_or_else(|| "100644".to_string());
+
+    // Last commit that touched this file.
+    let log = reader.run(&["log", "-1", "--format=%H|%s|%an|%cr", "HEAD", "--", path])?;
+    let mut log_parts = log.splitn(4, '|');
+    let last_change_sha = log_parts.next()?.to_string();
+    let last_change_head = last_change_sha[..last_change_sha.len().min(4)].to_string();
+    let last_change_tail =
+        last_change_sha[last_change_sha.len().min(4)..last_change_sha.len().min(8)].to_string();
+    let last_change_msg = log_parts.next().unwrap_or("").to_string();
+    let last_change_author = log_parts.next().unwrap_or("").to_string();
+    let last_change_age = log_parts.next().unwrap_or("").to_string();
+
+    // File size.
+    let file_size = reader
+        .run(&["cat-file", "-s", &format!("HEAD:{path}")])
+        .unwrap_or_default();
+    let file_size = format_file_size(file_size.parse().unwrap_or(0));
+
+    // Detect language from extension.
+    let language = detect_language(path);
+
+    // Detect encoding and line ending from raw blob.
+    let raw = reader.run(&["cat-file", "-p", &format!("HEAD:{path}")])?;
+    let encoding = if raw.is_ascii() { "ascii" } else { "utf-8" };
+    let line_ending = if raw.contains("\r\n") { "crlf" } else { "lf" };
+
+    // Split into lines, HTML-escape each line.
+    let lines: Vec<String> = blob.lines().map(html_escape).collect();
+    let line_count = lines.len();
+
+    Some(FileData {
+        bookmark,
+        sha_short,
+        last_change_sha,
+        last_change_head,
+        last_change_tail,
+        last_change_msg,
+        last_change_author,
+        last_change_age,
+        line_count,
+        file_size,
+        language,
+        mode,
+        encoding: encoding.to_string(),
+        line_ending: line_ending.to_string(),
+        lines,
+    })
+}
+
+fn format_file_size(bytes: u64) -> String {
+    if bytes < 1024 {
+        format!("{bytes} B")
+    } else if bytes < 1024 * 1024 {
+        format!("{:.1} KB", bytes as f64 / 1024.0)
+    } else {
+        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
+    }
+}
+
+fn detect_language(path: &str) -> String {
+    let ext = path.rsplit('.').next().unwrap_or("");
+    match ext {
+        "rs" => "rust",
+        "toml" => "toml",
+        "md" => "markdown",
+        "js" => "javascript",
+        "ts" => "typescript",
+        "tsx" => "tsx",
+        "jsx" => "jsx",
+        "py" => "python",
+        "go" => "go",
+        "c" => "c",
+        "h" => "c",
+        "cpp" => "cpp",
+        "hpp" => "cpp",
+        "java" => "java",
+        "rb" => "ruby",
+        "sh" => "shell",
+        "bash" => "shell",
+        "zsh" => "shell",
+        "fish" => "shell",
+        "html" => "html",
+        "css" => "css",
+        "scss" => "scss",
+        "json" => "json",
+        "yaml" | "yml" => "yaml",
+        "xml" => "xml",
+        "sql" => "sql",
+        "fnl" => "fennel",
+        "lua" => "lua",
+        "el" => "elisp",
+        "dockerfile" => "dockerfile",
+        "makefile" => "makefile",
+        "lock" => "toml",
+        "txt" => "text",
+        "gitignore" => "gitignore",
+        "gitattributes" => "gitignore",
+        "editorconfig" => "ini",
+        "justfile" => "just",
+        "nix" => "nix",
+        _ => "text",
+    }
+    .to_string()
+}
+
+fn html_escape(s: &str) -> String {
+    s.replace('&', "&amp;")
+        .replace('<', "&lt;")
+        .replace('>', "&gt;")
+        .replace('"', "&quot;")
+}
diff --git a/quire-server/src/quire/web/handlers/mod.rs b/quire-server/src/quire/web/handlers/mod.rs
index 05e7cf8..1db2fdc 100644
--- a/quire-server/src/quire/web/handlers/mod.rs
+++ b/quire-server/src/quire/web/handlers/mod.rs
@@ -1,11 +1,13 @@
 //! Route handlers for the web view.
 
 mod ci;
+mod file;
 mod git;
 mod repo;
 mod tree;
 
 pub use ci::{run_detail, run_list};
+pub use file::file_view;
 pub use repo::repo_home;
 pub use tree::{tree_view, tree_view_path};
 
diff --git a/quire-server/src/quire/web/mod.rs b/quire-server/src/quire/web/mod.rs
index 2467138..5123102 100644
--- a/quire-server/src/quire/web/mod.rs
+++ b/quire-server/src/quire/web/mod.rs
@@ -18,7 +18,7 @@ use axum::{Router, routing::get};
 use crate::{
     Quire,
     quire::web::handlers::{
-        config, repo_home, run_detail, run_list, stylesheet, tree_view, tree_view_path,
+        config, file_view, repo_home, run_detail, run_list, stylesheet, tree_view, tree_view_path,
     },
 };
 
@@ -39,6 +39,7 @@ pub fn public_router(quire: Quire) -> Router {
         .route("/{repo}", get(repo_home))
         .route("/{repo}/tree", get(tree_view))
         .route("/{repo}/tree/{*path}", get(tree_view_path))
+        .route("/{repo}/blob/{*path}", get(file_view))
         .route("/config", get(config))
         .with_state(quire)
 }
diff --git a/quire-server/src/quire/web/templates.rs b/quire-server/src/quire/web/templates.rs
index 3229bbf..db80e7a 100644
--- a/quire-server/src/quire/web/templates.rs
+++ b/quire-server/src/quire/web/templates.rs
@@ -468,6 +468,14 @@ impl TreeTemplate {
         self.entries.iter().filter(|e| e.is_file()).count()
     }
 
+    pub fn file_entry_url(&self, name: &str) -> String {
+        if self.path.is_empty() {
+            format!("/{}/blob/{}", self.repo, name)
+        } else {
+            format!("/{}/blob/{}/{}", self.repo, self.path, name)
+        }
+    }
+
     pub fn sha_head(&self) -> &str {
         &self.sha_short[..self.sha_short.len().min(4)]
     }
@@ -530,3 +538,38 @@ impl ErrorTemplate {
         pkg_version()
     }
 }
+
+// ── File view ─────────────────────────────────────────────────────
+
+#[derive(Template)]
+#[template(path = "file.html")]
+pub struct FileViewTemplate {
+    pub repo: String,
+    pub crumbs: Vec<Crumb>,
+    pub sections: Vec<SectionLink>,
+    pub path: String,
+    pub bookmark: String,
+    pub sha_short: String,
+    pub sha_head: String,
+    pub sha_tail: String,
+    pub last_change_sha: String,
+    pub last_change_head: String,
+    pub last_change_tail: String,
+    pub last_change_msg: String,
+    pub last_change_author: String,
+    pub last_change_age: String,
+    pub line_count: usize,
+    pub file_size: String,
+    pub language: String,
+    pub mode: String,
+    pub encoding: String,
+    pub line_ending: String,
+    pub line_nums: Vec<usize>,
+    pub lines: Vec<String>,
+}
+
+impl FileViewTemplate {
+    pub fn version(&self) -> &'static str {
+        pkg_version()
+    }
+}
diff --git a/quire-server/static/style.css b/quire-server/static/style.css
index 6adac15..5a767fc 100644
--- a/quire-server/static/style.css
+++ b/quire-server/static/style.css
@@ -697,6 +697,132 @@ html.dark {
   font-style: italic;
 }
 
+/* ── File view ─────────────────────────────────────────────────── */
+
+.file-meta {
+  padding: 10px 56px;
+  border-bottom: 1px solid var(--rule);
+  font-family: var(--font-mono);
+  font-size: 12.5px;
+  display: flex;
+  align-items: baseline;
+  gap: 8px;
+  flex-wrap: wrap;
+  color: var(--muted);
+}
+
+.file-meta-item {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.file-meta-key {
+  font-size: 10px;
+  letter-spacing: 0.8px;
+  text-transform: uppercase;
+  color: var(--mutedFaint);
+}
+
+.file-meta-val {
+  color: var(--muted);
+}
+
+.file-meta-link {
+  color: var(--accent);
+  text-decoration: none;
+  border-bottom: 1px dotted var(--rule2);
+}
+
+.file-meta-spacer {
+  flex: 1;
+}
+
+.file-actions {
+  display: inline-flex;
+  gap: 14px;
+}
+
+.file-action-link {
+  color: var(--accent);
+  text-decoration: none;
+}
+
+.file-action-link--muted {
+  color: var(--muted);
+}
+
+/* Last change strip */
+
+.file-last-change {
+  padding: 10px 56px;
+  border-bottom: 1px solid var(--rule2);
+  font-family: var(--font-mono);
+  font-size: 12.5px;
+  display: flex;
+  gap: 16px;
+  align-items: baseline;
+}
+
+.file-last-change-msg {
+  color: var(--ink);
+  flex: 1;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.file-last-change-author {
+  color: var(--mutedFaint);
+}
+
+.file-last-change-age {
+  color: var(--muted);
+}
+
+/* Code surface */
+
+.code-surface {
+  font-family: var(--font-mono);
+  font-size: 13px;
+  line-height: 1.7;
+  display: grid;
+  grid-template-columns: auto 1fr;
+  border-bottom: 1px solid var(--rule);
+}
+
+.code-gutter {
+  background: var(--code);
+  padding: 18px 14px 18px 56px;
+  color: var(--mutedFaint);
+  text-align: right;
+  font-variant-numeric: tabular-nums;
+  user-select: none;
+  border-right: 1px solid var(--rule2);
+}
+
+.code-line-num {
+  white-space: pre;
+}
+
+.code-body {
+  padding: 18px 56px 18px 20px;
+  overflow: auto;
+}
+
+.code-line {
+  white-space: pre;
+}
+
+.code-line pre {
+  margin: 0;
+  padding: 0;
+  font-family: inherit;
+  font-size: inherit;
+  line-height: inherit;
+  white-space: pre;
+}
+
 /* ── Arborium syntax highlighting integration ──────────────────── */
 
 /* Keep quire's paper-toned code background; strip any theme-imposed radius. */
diff --git a/quire-server/templates/file.html b/quire-server/templates/file.html
new file mode 100644
index 0000000..91ed329
--- /dev/null
+++ b/quire-server/templates/file.html
@@ -0,0 +1,79 @@
+{% extends "_base.html" %}
+
+{% block title %}file · {{ repo }} · {{ path }}{% endblock %}
+
+{% block nav %}
+{% include "_nav.html" %}
+{% endblock %}
+
+{% block fullpage %}
+
+<nav class="repo-section-nav">
+  {% include "_repo_section_nav.html" %}
+  <span class="repo-position">
+    <span class="bookmark-glyph">※</span>
+    <span class="bookmark-name">{{ bookmark }}</span>
+    <span class="repo-meta-sep">→</span>
+    <span class="change-id" title="{{ sha_short }}">
+      <span class="change-head">{{ sha_head }}</span><span class="change-tail">{{ sha_tail }}</span>
+    </span>
+  </span>
+</nav>
+
+{# ── Blob metadata + actions ─────────────────────────────────── #}
+<div class="file-meta">
+  <span class="file-meta-item">
+    <span class="file-meta-key">on</span>
+    <span class="bookmark-glyph-sm">※</span>
+    <a class="file-meta-link" href="/{{ repo }}/log">{{ bookmark }}</a>
+  </span>
+  <span class="repo-meta-dot">·</span>
+  <span class="file-meta-item">
+    <span class="file-meta-key">at</span>
+    <a class="change-id" href="/{{ repo }}/log" title="commit {{ sha_short }}">
+      <span class="change-head">{{ last_change_head }}</span><span class="change-tail">{{ last_change_tail }}</span>
+    </a>
+  </span>
+  <span class="repo-meta-dot">·</span>
+  <span class="file-meta-val">{{ line_count }} lines</span>
+  <span class="repo-meta-dot">·</span>
+  <span class="file-meta-val">{{ file_size }}</span>
+  <span class="repo-meta-dot">·</span>
+  <span class="file-meta-val lang-badge">{{ language }}</span>
+  <span class="repo-meta-dot">·</span>
+  <span class="file-meta-val">mode {{ mode }}</span>
+  <span class="repo-meta-dot">·</span>
+  <span class="file-meta-val">{{ encoding }}, {{ line_ending }}</span>
+  <span class="file-meta-spacer"></span>
+  <span class="file-actions">
+    <a class="file-action-link" href="/{{ repo }}/raw/{{ path }}">raw</a>
+    <a class="file-action-link file-action-link--muted" href="/{{ repo }}/blame/{{ path }}">blame</a>
+    <a class="file-action-link file-action-link--muted" href="/{{ repo }}/log/{{ path }}">history</a>
+  </span>
+</div>
+
+{# ── Last change strip ───────────────────────────────────────── #}
+<div class="file-last-change">
+  <a class="change-id" href="/{{ repo }}/log" title="commit {{ last_change_sha }}">
+    <span class="change-head">{{ last_change_head }}</span><span class="change-tail">{{ last_change_tail }}</span>
+  </a>
+  <span class="file-last-change-msg">{{ last_change_msg }}</span>
+  <span class="file-last-change-author">{{ last_change_author }}</span>
+  <span class="file-last-change-age">{{ last_change_age }}</span>
+</div>
+
+{# ── Code surface — full width, gutter + content ─────────────── #}
+<div class="code-surface">
+  <div class="code-gutter">
+    {% for line_num in line_nums %}
+    <div class="code-line-num">{{ line_num }}</div>
+    {% endfor %}
+  </div>
+  <div class="code-body">
+    {% for line in lines %}
+    <div class="code-line"><pre>{{ line }}</pre></div>
+    {% endfor %}
+  </div>
+</div>
+
+{% endblock %}
diff --git a/quire-server/templates/tree.html b/quire-server/templates/tree.html
index 3a4744f..ff0e35f 100644
--- a/quire-server/templates/tree.html
+++ b/quire-server/templates/tree.html
@@ -61,7 +61,7 @@
       <span class="tree-msg">{{ entry.last_msg }}</span>
       <span class="tree-age">{{ entry.age }}</span>
       {% else %}
-      <span class="tree-name">{{ entry.name }}</span>
+      <a class="tree-name" href="{{ self.file_entry_url(&entry.name) }}">{{ entry.name }}</a>
       <span class="tree-msg">{{ entry.last_msg }}</span>
       <span class="tree-age">{{ entry.age }}</span>
       {% endif %}