Merge file view into tree handler, fix blank lines
- Remove separate /blob/ route and file handler — tree handler now detects
whether a path is a dir or file and renders the appropriate view
- All file links use /tree/<path> consistently
- Fix blank lines not rendering: render code body as a single <pre> block
instead of per-line <pre> wrappers (empty <pre> collapses to zero height)
- Remove orphaned file.rs handler and file_entry_url template method
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
deleted file mode 100644
index f0df3aa..0000000
--- a/quire-server/src/quire/web/handlers/file.rs
+++ /dev/null
@@ -1,229 +0,0 @@
-//! 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::{Crumb, 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 crumbs = build_path_crumbs(&repo_display, &path);
-
- let tmpl = FileViewTemplate {
- sections: nav_sections(&repo_display, "tree", false),
- repo: repo_display,
- crumbs,
- 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('"', """)
-}
-
-fn build_path_crumbs(repo: &str, path: &str) -> Vec<Crumb> {
- let mut crumbs = vec![Crumb::with_href("tree", format!("/{}/tree", repo))];
- if path.is_empty() {
- return crumbs;
- }
- let segments: Vec<&str> = path.split('/').collect();
- for (i, seg) in segments.iter().enumerate() {
- let href = if i == segments.len() - 1 {
- // Last segment — link to the blob view.
- format!("/{}/blob/{}", repo, segments[..=i].join("/"))
- } else {
- // Intermediate segment — link to the tree view.
- format!("/{}/tree/{}", repo, segments[..=i].join("/"))
- };
- crumbs.push(Crumb::with_href(*seg, href));
- }
- crumbs
-}
diff --git a/quire-server/src/quire/web/handlers/mod.rs b/quire-server/src/quire/web/handlers/mod.rs
index 1db2fdc..05e7cf8 100644
--- a/quire-server/src/quire/web/handlers/mod.rs
+++ b/quire-server/src/quire/web/handlers/mod.rs
@@ -1,13 +1,11 @@
//! 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/handlers/tree.rs b/quire-server/src/quire/web/handlers/tree.rs
index a633aa0..50fe98a 100644
--- a/quire-server/src/quire/web/handlers/tree.rs
+++ b/quire-server/src/quire/web/handlers/tree.rs
@@ -1,4 +1,4 @@
-//! Handler for the repository tree browser.
+//! Handler for the repository tree browser and file (blob) view.
use axum::extract::{Path as AxumPath, State};
use axum::http::StatusCode;
@@ -6,7 +6,9 @@ use axum::response::{IntoResponse, Response};
use super::super::auth::Auth;
use super::super::db;
-use super::super::templates::{Crumb, TreeEntry, TreeEntryKind, TreeTemplate, nav_sections};
+use super::super::templates::{
+ Crumb, FileViewTemplate, TreeEntry, TreeEntryKind, TreeTemplate, nav_sections,
+};
use super::git::RepoView;
use super::render;
use crate::Quire;
@@ -16,7 +18,7 @@ pub async fn tree_view(
auth: Auth,
AxumPath(repo): AxumPath<String>,
) -> Response {
- tree_at_path(quire, repo, String::new(), auth.0).await
+ tree_or_file_at_path(quire, repo, String::new(), auth.0).await
}
pub async fn tree_view_path(
@@ -24,10 +26,10 @@ pub async fn tree_view_path(
auth: Auth,
AxumPath((repo, path)): AxumPath<(String, String)>,
) -> Response {
- tree_at_path(quire, repo, path, auth.0).await
+ tree_or_file_at_path(quire, repo, path, auth.0).await
}
-async fn tree_at_path(quire: Quire, repo: String, path: String, authed: bool) -> Response {
+async fn tree_or_file_at_path(quire: Quire, repo: String, path: String, authed: bool) -> 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) {
@@ -38,51 +40,94 @@ async fn tree_at_path(quire: Quire, repo: String, path: String, authed: bool) ->
let path_clone = path.clone();
let result = tokio::task::spawn_blocking(move || {
let reader = RepoView::new(&git_repo);
- let tree_data = read_tree_data(&reader, &path_clone)?;
- let bookmarks = reader.bookmarks();
- let tags = reader.tags();
- let recent_changes = reader.recent_changes_for(Some(&path_clone));
- Some((tree_data, bookmarks, tags, recent_changes))
+
+ // Try ls-tree first — if it succeeds, this is a directory.
+ if let Some(tree_data) = read_tree_data(&reader, &path_clone) {
+ let bookmarks = reader.bookmarks();
+ let tags = reader.tags();
+ let recent_changes = reader.recent_changes_for(Some(&path_clone));
+ Some(Either::Left((tree_data, bookmarks, tags, recent_changes)))
+ } else {
+ // ls-tree failed — try reading as a file blob.
+ read_file_data(&reader, &path_clone).map(Either::Right)
+ }
})
.await
.unwrap_or(None);
- let (tree_data, bookmarks, tags, recent_changes) = match result {
- Some(v) => v,
- None => return StatusCode::NOT_FOUND.into_response(),
- };
-
- let crumbs = {
- let mut c = vec![Crumb::with_href("tree", format!("/{}/tree", repo_display))];
- if !path.is_empty() {
- c.push(Crumb::new(
- path.split('/').next_back().unwrap_or(&path).to_string(),
- ));
+ match result {
+ Some(Either::Left((tree_data, bookmarks, tags, recent_changes))) => {
+ let crumbs = build_tree_crumbs(&repo_display, &path);
+ let tmpl = TreeTemplate {
+ sections: nav_sections(&repo_display, "tree", authed),
+ repo: repo_display,
+ crumbs,
+ bookmarks,
+ tags,
+ path,
+ bookmark: tree_data.bookmark,
+ sha_short: tree_data.sha_short,
+ entries: tree_data.entries,
+ recent_changes,
+ };
+ render(&tmpl)
}
- c
- };
+ Some(Either::Right(file_data)) => {
+ let crumbs = build_file_crumbs(&repo_display, &path);
+ let line_nums: Vec<usize> = (1..=file_data.line_count).collect();
+ let tmpl = FileViewTemplate {
+ sections: nav_sections(&repo_display, "tree", authed),
+ repo: repo_display.clone(),
+ crumbs,
+ 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,
+ lines: file_data.lines,
+ };
+ render(&tmpl)
+ }
+ None => StatusCode::NOT_FOUND.into_response(),
+ }
+}
- let tmpl = TreeTemplate {
- sections: nav_sections(&repo_display, "tree", authed),
- repo: repo_display,
- crumbs,
- bookmarks,
- tags,
- path,
- bookmark: tree_data.bookmark,
- sha_short: tree_data.sha_short,
- entries: tree_data.entries,
- recent_changes,
- };
- render(&tmpl)
+enum Either<L, R> {
+ Left(L),
+ Right(R),
}
+// ── Tree (directory) view ──────────────────────────────────────
+
struct TreeData {
bookmark: String,
sha_short: String,
entries: Vec<TreeEntry>,
}
+fn build_tree_crumbs(repo: &str, path: &str) -> Vec<Crumb> {
+ let mut c = vec![Crumb::with_href("tree", format!("/{repo}/tree"))];
+ if !path.is_empty() {
+ c.push(Crumb::new(
+ path.split('/').next_back().unwrap_or(path).to_string(),
+ ));
+ }
+ c
+}
+
fn read_tree_data(reader: &RepoView<'_>, path: &str) -> Option<TreeData> {
let bookmark = reader
.run(&["symbolic-ref", "--short", "HEAD"])
@@ -95,12 +140,11 @@ fn read_tree_data(reader: &RepoView<'_>, path: &str) -> Option<TreeData> {
let ls_target = if path.is_empty() {
"HEAD".to_string()
} else {
- format!("HEAD:{}", path)
+ format!("HEAD:{path}")
};
let ls_out = reader.run(&["ls-tree", &ls_target])?;
- // Parse ls-tree output: "<mode> <type> <sha>\t<name>"
let mut raw: Vec<(TreeEntryKind, String)> = Vec::new();
for line in ls_out.lines() {
let Some((meta, name)) = line.split_once('\t') else {
@@ -119,7 +163,6 @@ fn read_tree_data(reader: &RepoView<'_>, path: &str) -> Option<TreeData> {
raw.push((kind, name.to_string()));
}
- // Dirs/submodules first, then files; each group alphabetical.
raw.sort_by(|(ak, an), (bk, bn)| {
let ao = matches!(ak, TreeEntryKind::Dir | TreeEntryKind::Submodule) as u8;
let bo = matches!(bk, TreeEntryKind::Dir | TreeEntryKind::Submodule) as u8;
@@ -141,7 +184,7 @@ fn read_tree_data(reader: &RepoView<'_>, path: &str) -> Option<TreeData> {
let entry_path = if path.is_empty() {
name.clone()
} else {
- format!("{}/{}", path, name)
+ format!("{path}/{name}")
};
let commit_info = reader.run(&["log", "-1", "--format=%s|%cr", "HEAD", "--", &entry_path]);
let (last_msg, age) = commit_info
@@ -164,3 +207,157 @@ fn read_tree_data(reader: &RepoView<'_>, path: &str) -> Option<TreeData> {
entries,
})
}
+
+// ── File (blob) view ───────────────────────────────────────────
+
+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 build_file_crumbs(repo: &str, path: &str) -> Vec<Crumb> {
+ let mut crumbs = vec![Crumb::with_href("tree", format!("/{repo}/tree"))];
+ if path.is_empty() {
+ return crumbs;
+ }
+ let segments: Vec<&str> = path.split('/').collect();
+ for (i, seg) in segments.iter().enumerate() {
+ let href = format!("/{}/tree/{}", repo, segments[..=i].join("/"));
+ crumbs.push(Crumb::with_href(*seg, href));
+ }
+ crumbs
+}
+
+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());
+
+ let blob = reader.run(&["show", &format!("HEAD:{path}")])?;
+
+ let mode = reader
+ .run(&["ls-tree", "--format=%(objectmode)", "HEAD", path])
+ .unwrap_or_else(|| "100644".to_string());
+
+ 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();
+
+ 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));
+
+ let language = detect_language(path);
+
+ 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" };
+
+ let lines: Vec<String> = blob.lines().map(|l| html_escape(l) + "\n").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/mod.rs b/quire-server/src/quire/web/mod.rs
index 5123102..2467138 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, file_view, repo_home, run_detail, run_list, stylesheet, tree_view, tree_view_path,
+ config, repo_home, run_detail, run_list, stylesheet, tree_view, tree_view_path,
},
};
@@ -39,7 +39,6 @@ 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 db80e7a..eb95d86 100644
--- a/quire-server/src/quire/web/templates.rs
+++ b/quire-server/src/quire/web/templates.rs
@@ -468,14 +468,6 @@ 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)]
}
diff --git a/quire-server/static/style.css b/quire-server/static/style.css
index 9b20cd5..9287539 100644
--- a/quire-server/static/style.css
+++ b/quire-server/static/style.css
@@ -806,19 +806,11 @@ html.dark {
.code-body {
padding: 18px 56px 18px 20px;
overflow: auto;
-}
-
-.code-line {
- white-space: pre;
-}
-
-.code-line pre {
margin: 0;
- padding: 0;
+ white-space: pre;
font-family: inherit;
font-size: inherit;
line-height: inherit;
- white-space: pre;
}
/* ── Arborium syntax highlighting integration ──────────────────── */
diff --git a/quire-server/templates/file.html b/quire-server/templates/file.html
index 38fe437..f90968a 100644
--- a/quire-server/templates/file.html
+++ b/quire-server/templates/file.html
@@ -30,11 +30,7 @@
<div class="code-line-num">{{ line_num }}</div>
{% endfor %}
</div>
- <div class="code-body">
- {% for line in lines %}
- <div class="code-line"><pre>{{ line|safe }}</pre></div>
- {% endfor %}
- </div>
+ <pre class="code-body">{% for line in lines %}{{ line|safe }}{% endfor %}</pre>
</div>
</div>
diff --git a/quire-server/templates/tree.html b/quire-server/templates/tree.html
index ff0e35f..f093399 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 %}
- <a class="tree-name" href="{{ self.file_entry_url(&entry.name) }}">{{ entry.name }}</a>
+ <a class="tree-name" href="{{ self.dir_entry_url(&entry.name) }}">{{ entry.name }}</a>
<span class="tree-msg">{{ entry.last_msg }}</span>
<span class="tree-age">{{ entry.age }}</span>
{% endif %}