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
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('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+}
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 %}