roots: redesign the file browser's blob view with a header toolbar
commit 080b769
roots: redesign the file browser's blob view with a header toolbar
Crumbs used to end in a row of trailing action links, which crowded
the path trail and gave the blob no obvious place to show its own
metadata. Move those actions (history, jump-to-comments, comment on
this file) into a new blob-header bar that also names the file’s line
count, size, and detected language, and give a raw-source view an
inline comment composer template so a future line-selection UI has
something to open.
Directory listings gain a per-entry byte size for files.
refactor: crumbs() renders only the path trail, no trailing actions
feat: add blob_header with line count, size, language, and moved actions
feat: add composer_template with a csrf-protected comment form
feat: thread path/HEAD oid through blob_view/source_view for the composer
feat: show a human-formatted byte size on each directory listing file entry
Assisted-by: Claude:claude-sonnet-5
No reviews of this commit yet — record a verdict below.
Start a review
crates/cli/ents-web/tests/router.rs
@@ -1184,6 +1184,168 @@
assert!(body.contains("class=\"outdated\""));
}
+/// A raw-source blob view carries the client-side hooks `assets/ents.js`
+/// needs: `div.blob`'s own `data-path`/`data-rev` (the latter a full
+/// 40-hex `HEAD` commit oid, not the string `"HEAD"`), and a
+/// `<template id="composer-template">` whose form carries a csrf input and
+/// hidden `path`/`rev` inputs pre-filled with this exact file and commit.
+#[tokio::test]
+async fn files_blob_view_carries_data_path_data_rev_and_the_composer_template() {
+ let dir = seed_repo(&[("src/main.rs", "fn main() {}\n")]);
+ let oid = head_oid(dir.path());
+ assert_eq!(oid.len(), 40, "a full sha1 hex oid is 40 characters");
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/files/src/main.rs")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(body.contains("data-path=\"src/main.rs\""));
+ assert!(body.contains(&format!("data-rev=\"{oid}\"")));
+ let template_start = body
+ .find("id=\"composer-template\"")
+ .expect("composer template renders");
+ let template = body.get(template_start..).expect("template slice");
+ assert!(
+ template.contains("name=\"csrf\""),
+ "the composer's own form carries a csrf input"
+ );
+ assert!(template.contains(r#"name="path" value="src/main.rs""#));
+ assert!(template.contains(&format!(r#"name="rev" value="{oid}""#)));
+}
+
+/// The blob header bar (`crate::pages::files::blob_header`) shows a raw
+/// source file's line count, human-formatted size, and detected language,
+/// plus the "comment on this file" no-JS fallback link -- moved here from
+/// `crumbs`'s own trailing edge.
+#[tokio::test]
+async fn files_blob_header_shows_line_count_size_language_and_the_comment_link() {
+ let dir = seed_repo(&[("src/main.rs", "fn main() {\n let ok = 1;\n}\n")]);
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/files/src/main.rs")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(body.contains("blob-header"));
+ assert!(body.contains("3 lines"));
+ assert!(body.contains("rust"));
+ assert!(body.contains("comment on this file"));
+}
+
+/// `GET /files` (a directory listing): a file entry carries a
+/// human-formatted size (`span.entry-size`), rendered after the
+/// directory-first entries, which carry none.
+#[tokio::test]
+async fn files_root_listing_shows_a_size_for_a_file_but_not_a_directory() {
+ let dir = seed_repo(&[("README.md", "# hi\n"), ("src/main.rs", "fn main() {}\n")]);
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(Request::get("/files").body(Body::empty()).expect("request"))
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ let src_index = body
+ .find("/files/src\"")
+ .expect("the src directory links in");
+ let size_index = body.find("entry-size").expect("a size span renders");
+ assert!(
+ src_index < size_index,
+ "the directory row (sorted first, no size cell) renders before the file row's own size"
+ );
+}
+
+/// A doc-rendered (Markdown) blob view carries no composer template at
+/// all: there is no source line row for `assets/ents.js` to anchor an
+/// inline composer after, so `ents_web::pages::files::blob_view` never
+/// renders one for this view kind.
+#[tokio::test]
+async fn files_markdown_blob_view_has_no_composer_template() {
+ let dir = seed_repo(&[("docs/x.md", "# Doc Title\n\nSome text.\n")]);
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/files/docs/x.md")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(
+ !body.contains("composer-template"),
+ "a doc-rendered view has no source line to anchor a composer to"
+ );
+}
+
/// `GET /comments?file=<path>&lines=<range>` pre-fills the add-comment
/// form's `path`/`lines` fields -- the entry point `crate::pages::files`'s
/// own "comment on this file" link uses.
crates/cli/ents-web/src/assets/ents.css
@@ -14,9 +14,13 @@
* pre-redo sheet: `.meta-layout`/`.meta-rail` for the `meta` tab's
* page-family rail (`crate::pages::META_SECTIONS`) and `.id-chip` for the
* header's signing-identity link, both new to this crate's own four-tab
- * restructure; and `.comment-meta`/`.outdated` for the file-anchored
+ * restructure; `.comment-meta`/`.outdated` for the file-anchored
* comment cards `crate::pages::comments::comments_section` renders below a
- * blob view, which otherwise reuse `.card`/`.doc-body` as-is.
+ * blob view, which otherwise reuse `.card`/`.doc-body` as-is; and
+ * `.blob-header`/`.blob-actions`/`.entry-size` for
+ * `crate::pages::files::blob_header`/`dir_listing`'s own metadata, plus
+ * `.composer-*` for its server-rendered comment-composer template --
+ * pre-redo had no blob header bar and no comment system at all.
*/
:root {
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
@@ -155,6 +159,11 @@
.card-row a.row-link { display: flex; align-items: center; gap: .65rem; text-decoration: none; }
.card-row:has(.row-link):hover { background: var(--color-code-bg); }
+/* A directory listing's per-entry byte size (`crate::pages::files::dir_listing`,
+ * `human_size`) -- muted, mono, right-aligned, absent for a directory
+ * entry. */
+.entry-size { margin-left: auto; font-family: var(--font-mono); font-size: .82rem; color: var(--color-text-muted); white-space: nowrap; }
+
/* Repository overview (`GET /`): the rendered README beside a sticky aside
* of a contents rail and a language breakdown (pre-redo:.../style.css's
* `.overview`/`.aside`/`.lang`). */
@@ -261,12 +270,24 @@
form button { align-self: flex-start; font-size: .88rem; font-weight: 600; color: var(--color-bg); background: var(--color-accent); border: none; border-radius: var(--radius-sm); padding: .45rem 1rem; cursor: pointer; transition: background .15s; }
form button:hover { background: var(--color-link-hover); }
-/* Breadcrumbs and the file browser (`crate::pages::files`). */
+/* Breadcrumbs (pure path navigation) and the file browser
+ * (`crate::pages::files`). */
.crumbs { font-family: var(--font-mono); font-size: .92rem; margin-bottom: 1.25rem; display: flex; flex-wrap: wrap; align-items: center; gap: .3rem; word-break: break-all; }
.crumbs a { text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); }
.crumbs .sep { color: var(--color-text-muted); opacity: .55; }
.crumbs .here { color: var(--color-text-muted); }
-.crumbs-history { margin-left: auto; font-size: .82rem; color: var(--color-text-muted); }
+
+/* The blob header bar (`crate::pages::files::blob_header`): a file's name
+ * and metadata on the left, the history/comment actions that used to
+ * trail `.crumbs` on the right -- rendered above every blob view (raw
+ * source, a rendered document, or a binary placeholder), matching the
+ * `.card-header` family look it sits beside. */
+.blob-header { display: flex; align-items: center; flex-wrap: wrap; gap: .5rem .9rem; padding: .55rem 1.1rem; border-bottom: 1px solid var(--color-border); background: var(--color-code-bg); border-radius: var(--radius-sm) var(--radius-sm) 0 0; }
+.blob-title { font-family: var(--font-mono); font-weight: 600; }
+.blob-meta { font-size: .78rem; color: var(--color-text-muted); }
+.blob-actions { margin-left: auto; display: flex; align-items: center; gap: .9rem; font-size: .82rem; color: var(--color-text-muted); }
+.blob-actions a { color: inherit; text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); }
+.blob-actions a:hover { color: var(--color-accent); }
/* Commit history and view (`crate::pages::commits`). */
.commit-subject { font-weight: 600; font-size: 1.05rem; margin-bottom: .3rem; }
@@ -294,6 +315,18 @@
.blob tr.blob-comment-row .card { margin: .5rem 1rem; }
.binary { padding: 2.5rem; text-align: center; font-family: var(--font-mono); font-size: .85rem; color: var(--color-text-muted); }
+/* The inline comment composer (`crate::pages::files::composer_template`,
+ * cloned and shown by `assets/ents.js`): mirrors `tr.blob-comment-row
+ * .card`'s own margin so it lands flush with the comment cards it
+ * follows. */
+.blob tr.blob-composer td { padding: 0; background: var(--color-surface); }
+.composer-form { margin: .5rem 1rem; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); padding: .75rem 1rem; }
+.composer-form textarea { min-height: 6rem; }
+.composer-buttons { display: flex; flex-direction: row; gap: .5rem; }
+.composer-buttons button { margin: 0; }
+.composer-cancel { align-self: flex-start; font-size: .88rem; font-weight: 600; color: var(--color-text); background: transparent; border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: .45rem 1rem; cursor: pointer; transition: border-color .15s, color .15s; }
+.composer-cancel:hover { color: var(--color-accent); border-color: var(--color-accent); }
+
/* File-anchored comment cards (`crate::pages::comments::comment_card`),
* whether interleaved into a blob's own table (`tr.blob-comment-row`,
* above) or listed below it (`comments_section`/`outdated_comments_section`)
crates/cli/ents-web/src/pages/files.rs
@@ -17,21 +17,37 @@
//! structured meta-ref data; browsing arbitrary repository content is not
//! that).
//!
+//! [`crumbs`] renders only the path trail now -- every action that used to
+//! live at its trailing edge (jump into history, jump to the first
+//! comment, add a comment) moved into [`blob_header`]'s own right-aligned
+//! action group, rendered above every blob view regardless of how it
+//! renders (raw source, a rendered document, or a binary placeholder); a
+//! directory listing carries neither the actions nor a header, since a
+//! comment anchors to a file, never a tree.
+//!
//! A blob view also loads and renders the comments anchored to it
-//! (`crate::pages::comments::for_path`), and [`crumbs`] grows a "comment on
-//! this file" link (plus a jump to the first card, once there is at least
-//! one) beside its own trailing "history" link -- a directory listing
-//! carries neither, since a comment anchors to a file, never a tree. A
-//! raw-source view (not a rendered document or a binary placeholder)
-//! interleaves each comment's card directly after the row naming its
-//! anchored range's last line, full width across the blob's line-number
-//! and code columns ([`source_view`]); a comment with no current line
-//! range (a whole-file anchor, or `ents_anchor::Projection::Outdated`) has
-//! nowhere to interleave, and renders in a below-the-blob "outdated
-//! comments" section instead ([`outdated_comments_section`]). Doc-rendered
-//! and binary views keep every comment below the blob, unconditionally
+//! (`crate::pages::comments::for_path`). A raw-source view (not a
+//! rendered document or a binary placeholder) interleaves each comment's
+//! card directly after the row naming its anchored range's last line,
+//! full width across the blob's line-number and code columns
+//! ([`source_view`]); a comment with no current line range (a whole-file
+//! anchor, or `ents_anchor::Projection::Outdated`) has nowhere to
+//! interleave, and renders in a below-the-blob "outdated comments"
+//! section instead ([`outdated_comments_section`]). Doc-rendered and
+//! binary views keep every comment below the blob, unconditionally
//! (`crate::pages::comments::comments_section`), since there is no source
//! line to interleave at.
+//!
+//! A raw-source view additionally carries the client-side hooks
+//! `crate::assets`'s `ents.js` progressively enhances: `div.blob` names its
+//! own `path`/`rev` (`data-path`/`data-rev`, the latter the resolved `HEAD`
+//! commit oid, not the string `"HEAD"`, so a captured selection names the
+//! exact commit being viewed) so a click on a gutter line number can select
+//! a line or a shift-extended range and open an inline comment composer
+//! cloned from a server-rendered `<template id="composer-template">`
+//! ([`composer_template`]) -- with JS disabled the page stays fully usable
+//! via [`blob_header`]'s "comment on this file" link and the plain `#L<n>`
+//! anchors [`crate::pages::comments::comment_card`] already emits.
use std::sync::Arc;
@@ -43,6 +59,7 @@
use crate::assets;
use crate::error::{Error, Result};
+use crate::session::Session;
use crate::state::AppState;
/// `GET /files`: the repository root directory listing.
@@ -50,11 +67,14 @@
/// # Errors
///
/// Propagates a `gix::open`/tree-read failure.
-pub async fn root<O>(State(state): State<Arc<AppState<O>>>) -> Result<Markup>
+pub async fn root<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+) -> Result<Markup>
where
O: Find + Write + Send + 'static,
{
- at(&state, "")
+ at(&state, "", &session)
}
/// `GET /files/{*path}`: a directory listing or blob view at `path`.
@@ -66,18 +86,21 @@
/// `gix::open`/tree-read failure.
pub async fn show<O>(
State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
Path(path): Path<String>,
) -> Result<Markup>
where
O: Find + Write + Send + 'static,
{
- at(&state, &path)
+ at(&state, &path, &session)
}
/// The shared implementation behind [`root`] and [`show`]: resolve `path`
/// against `HEAD`'s tree and render whichever of a directory listing or a
-/// blob view it names.
-fn at<O>(state: &AppState<O>, path: &str) -> Result<Markup>
+/// blob view it names. `session` is only ever needed on a blob view, to
+/// render [`composer_template`]'s csrf input -- threaded down from the
+/// route handler rather than reached for a second time here.
+fn at<O>(state: &AppState<O>, path: &str, session: &Session) -> Result<Markup>
where
O: Find + Write,
{
@@ -102,7 +125,7 @@
super::Tab::Files,
"files",
html! {
- (crumbs(path, None))
+ (crumbs(path))
(dir_listing(path, Vec::new()))
},
));
@@ -122,7 +145,7 @@
super::Tab::Files,
"files",
html! {
- (crumbs(path, None))
+ (crumbs(path))
(dir_listing(path, entries))
},
));
@@ -148,7 +171,7 @@
super::Tab::Files,
path,
html! {
- (crumbs(path, None))
+ (crumbs(path))
(dir_listing(path, entries))
},
))
@@ -160,14 +183,18 @@
.map_err(|source| Error::Repo(source.to_string()))?;
let name = path.rsplit('/').next().unwrap_or(path);
let comments = super::comments::for_path(state, &repo, path);
- let (body, below) = blob_view(name, &blob.data, &comments)?;
+ let head_oid = repo
+ .head_id()
+ .map_err(|source| Error::Repo(source.to_string()))?
+ .to_string();
+ let (body, below) = blob_view(path, name, &head_oid, session, &blob.data, &comments)?;
Ok(super::layout(
&super::RepoHeader::from_state(state),
&super::identity_label(state),
super::Tab::Files,
path,
html! {
- (crumbs(path, Some(comments.len())))
+ (crumbs(path))
(body)
(below)
},
@@ -190,16 +217,25 @@
.all(|s| !s.is_empty() && s != "." && s != "..")
}
-/// One `(name, is_directory)` pair per direct child of `tree`, in tree
-/// order (not yet sorted -- [`dir_listing`] sorts for display).
-fn tree_entries(tree: &gix::Tree<'_>) -> Result<Vec<(String, bool)>> {
+/// One `(name, is_directory, size)` triple per direct child of `tree`, in
+/// tree order (not yet sorted -- [`dir_listing`] sorts for display). `size`
+/// is a blob entry's byte length, read from its odb header
+/// ([`gix::Repository::find_header`], the same header-only read
+/// `crate::pages::dashboard::languages` weighs its language breakdown
+/// by -- never a full blob read just to size it) and best-effort (`None`
+/// on a header-read failure, same as that function's own stance); always
+/// `None` for a directory entry, which [`dir_listing`] renders with no
+/// size cell at all.
+fn tree_entries(tree: &gix::Tree<'_>) -> Result<Vec<(String, bool, Option<u64>)>> {
tree.iter()
.map(|entry| {
let entry = entry.map_err(|source| Error::Repo(source.to_string()))?;
- Ok((
- entry.filename().to_str_lossy().into_owned(),
- entry.mode().is_tree(),
- ))
+ let is_dir = entry.mode().is_tree();
+ let size = (!is_dir)
+ .then(|| tree.repo.find_header(entry.oid()).ok())
+ .flatten()
+ .map(|header| header.size());
+ Ok((entry.filename().to_str_lossy().into_owned(), is_dir, size))
})
.collect()
}
@@ -214,9 +250,12 @@
}
/// A directory listing at `dir`: entries sorted directories-first then
-/// alphabetically, each an icon and a link one level deeper.
-fn dir_listing(dir: &str, mut entries: Vec<(String, bool)>) -> Markup {
- entries.sort_by(|(a_name, a_is_dir), (b_name, b_is_dir)| {
+/// alphabetically, each an icon and a link one level deeper, plus a
+/// right-aligned muted size for a blob entry (`span.entry-size`,
+/// [`human_size`]) -- a directory entry carries no size cell, since a
+/// tree's own byte length is not a meaningful measure of it.
+fn dir_listing(dir: &str, mut entries: Vec<(String, bool, Option<u64>)>) -> Markup {
+ entries.sort_by(|(a_name, a_is_dir, _), (b_name, b_is_dir, _)| {
b_is_dir.cmp(a_is_dir).then_with(|| a_name.cmp(b_name))
});
html! {
@@ -225,12 +264,15 @@
@if entries.is_empty() {
div.card-row.muted { "Empty directory." }
}
- @for (name, is_dir) in &entries {
+ @for (name, is_dir, size) in &entries {
div.card-row.is-dir[*is_dir] {
a.row-link href=(child_href(dir, name)) {
@if *is_dir { (assets::icon_folder()) } @else { (assets::icon_file()) }
(name)
}
+ @if let Some(size) = size {
+ span.entry-size { (human_size(*size)) }
+ }
}
}
}
@@ -238,18 +280,11 @@
}
/// Breadcrumb navigation from the repository's files root down through
-/// `path`, `chevron-right` icons separating segments, plus trailing links
-/// into `crate::pages::commits`'s `GET /commits` history (the file
-/// browser's one entry point into commit history, since history is a view
-/// of the code, not a tab of its own -- `crate::pages::mod`'s own doc) and,
-/// on a blob view (`comments` is `Some`), `crate::pages::comments`'s own
-/// add form for this file ("comment on this file") plus a jump straight to
-/// the first comment card (`id="comment-0"`, in display order -- see
-/// [`super::comments::comment_card`]'s own doc) when there is at least one
-/// comment already anchored here, wherever it renders (inline or below the
-/// blob). `comments` is `None` on a directory listing, where neither link
-/// makes sense.
-fn crumbs(path: &str, comments: Option<usize>) -> Markup {
+/// `path`, `chevron-right` icons separating segments -- pure navigation,
+/// no trailing actions. The history/comment links that used to trail this
+/// nav on a blob view now live in [`blob_header`]'s own action group
+/// instead (see this module's own top-level doc for why).
+fn crumbs(path: &str) -> Markup {
let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
let mut acc = String::new();
let mut trail: Vec<(String, Option<String>)> =
@@ -272,19 +307,30 @@
None => span.here { (label) },
}
}
- a.crumbs-history href="/commits" { "history" }
- @if let Some(count) = comments {
- a.crumbs-history href={ "/comments?file=" (path) } { "comment on this file" }
- @if count > 0 {
- a.crumbs-history href="#comment-0" {
- (count) @if count == 1 { " comment" } @else { " comments" }
- }
- }
- }
}
}
}
+/// Format a byte count the way [`blob_header`] and [`dir_listing`] both
+/// show a file's size: whole bytes under 1 KB, otherwise one decimal place
+/// of KB or MB -- integer-only throughout (`checked_div`/`checked_rem`/
+/// `saturating_mul`, this crate's own arithmetic idiom, e.g.
+/// `crate::pages::dashboard::languages`'s percentage math) rather than a
+/// float division, so there is no rounding-mode or precision question to
+/// answer.
+fn human_size(bytes: u64) -> String {
+ const KB: u64 = 1024;
+ const MB: u64 = 1024 * 1024;
+ if bytes < KB {
+ return format!("{bytes} B");
+ }
+ let (scale, unit) = if bytes < MB { (KB, "KB") } else { (MB, "MB") };
+ let whole = bytes.checked_div(scale).unwrap_or(0);
+ let remainder = bytes.checked_rem(scale).unwrap_or(0);
+ let tenths = remainder.saturating_mul(10).checked_div(scale).unwrap_or(0);
+ format!("{whole}.{tenths} {unit}")
+}
+
/// Whether `bytes` looks like binary content (a NUL byte in the leading
/// chunk -- the same heuristic git itself uses, carried over from
/// `pre-redo:crates/git-ents-server/src/web/pages.rs`'s own `is_binary`).
@@ -302,57 +348,206 @@
/// range directly into the blob and returns the rest (no current line
/// range: a whole-file anchor, or `ents_anchor::Projection::Outdated`) as
/// a separate below-the-blob section ([`outdated_comments_section`]).
+/// Every case renders a [`blob_header`] first -- above `div.card`/
+/// `div.binary` for a doc-rendered or binary view, as `div.blob`'s own
+/// first child for a raw-source view (see [`source_view`]'s own doc).
///
/// # Errors
///
/// Propagates [`crate::asciidoc::to_html`]'s own [`Error::Asciidoc`].
fn blob_view(
+ path: &str,
name: &str,
+ head_oid: &str,
+ session: &Session,
bytes: &[u8],
comments: &[super::comments::FileComment],
) -> Result<(Markup, Markup)> {
+ let size = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
+ let comment_count = comments.len();
+ let no_line_count_header = || {
+ blob_header(&BlobHeaderMeta {
+ name,
+ path,
+ size,
+ line_count: None,
+ language: None,
+ comments: comment_count,
+ })
+ };
if is_binary(bytes) {
return Ok((
- html! { div.binary { "Binary file (" (bytes.len()) " bytes) not shown." } },
+ html! {
+ (no_line_count_header())
+ div.binary { "Binary file (" (bytes.len()) " bytes) not shown." }
+ },
super::comments::comments_section(comments),
));
}
let Ok(text) = std::str::from_utf8(bytes) else {
return Ok((
- html! { div.binary { "Binary file (" (bytes.len()) " bytes) not shown." } },
+ html! {
+ (no_line_count_header())
+ div.binary { "Binary file (" (bytes.len()) " bytes) not shown." }
+ },
super::comments::comments_section(comments),
));
};
if crate::markdown::is_markdown(name) {
return Ok((
- html! { div.card { div.doc-body { (crate::markdown::to_html(text)) } } },
+ html! {
+ (no_line_count_header())
+ div.card { div.doc-body { (crate::markdown::to_html(text)) } }
+ },
super::comments::comments_section(comments),
));
}
if crate::asciidoc::is_asciidoc(name) {
return Ok((
- html! { div.card { div.doc-body { (crate::asciidoc::to_html(text)?) } } },
+ html! {
+ (no_line_count_header())
+ div.card { div.doc-body { (crate::asciidoc::to_html(text)?) } }
+ },
super::comments::comments_section(comments),
));
}
+ let language = arborium::detect_language(name);
let highlighted = highlight(name, text);
+ let line_count = text.lines().count().max(1);
+ let header = blob_header(&BlobHeaderMeta {
+ name,
+ path,
+ size,
+ line_count: Some(line_count),
+ language,
+ comments: comment_count,
+ });
+ let composer = composer_template(path, head_oid, session);
let below: Vec<(usize, &super::comments::FileComment)> = comments
.iter()
.enumerate()
.filter(|(_, comment)| comment.lines.is_none())
.collect();
Ok((
- source_view(text, highlighted, comments),
+ source_view(
+ path,
+ head_oid,
+ header,
+ composer,
+ text,
+ highlighted,
+ comments,
+ ),
outdated_comments_section(&below),
))
}
-/// The raw-source view: one table row per line (a `<tr>` pairing a
-/// `.blob-nums` line-number cell carrying the row's `#L{n}` anchor with a
-/// `.blob-code` cell, no wrapper beyond those two cells -- lean enough that
-/// thousands of lines stay cheap), highlighted via [`highlight`] when
-/// `highlighted` is `Some` and falling back to plain (still per-line,
-/// still auto-escaped by `maud`'s own interpolation) text otherwise. Each
+/// The metadata [`blob_header`] shows beside a blob's name -- gathered by
+/// [`blob_view`], one per view kind (see that function's own doc for which
+/// fields each kind fills in).
+struct BlobHeaderMeta<'a> {
+ /// The file's own name (the last path segment), shown as the title.
+ name: &'a str,
+ /// The full repository-relative path -- only used to build the
+ /// "comment on this file" link's `?file=` query.
+ path: &'a str,
+ /// The blob's byte length, always known ([`human_size`]).
+ size: u64,
+ /// The raw-source view's line count; `None` for a doc-rendered or
+ /// binary view, which has no source line to count.
+ line_count: Option<usize>,
+ /// [`arborium::detect_language`]'s own identifier, shown as-is when it
+ /// recognized `name`'s grammar; `None` otherwise, or for a
+ /// doc-rendered/binary view (a rendered document is not "highlighted
+ /// as" a language, and a binary blob was never linted for one at all).
+ language: Option<&'static str>,
+ /// How many comments [`super::comments::for_path`] found for this
+ /// blob -- the "N comments" jump link renders only when this is above
+ /// zero (mirrors [`crumbs`]'s own former stance, now moved here).
+ comments: usize,
+}
+
+/// The header bar above every blob view -- the file's name and metadata on
+/// the left (`span.blob-title`/`span.blob-meta`), the actions that used to
+/// trail [`crumbs`] on the right (`span.blob-actions`): a jump into
+/// `crate::pages::commits`'s `GET /commits` history (the file browser's
+/// one entry point into commit history, since history is a view of the
+/// code, not a tab of its own -- `crate::pages::mod`'s own doc), a jump
+/// straight to the first comment card (`#comment-0`, in display order --
+/// see [`super::comments::comment_card`]'s own doc) when at least one
+/// comment is already anchored here, and `crate::pages::comments`'s own
+/// add form for this file ("comment on this file" -- the no-JS fallback
+/// entry point into the composer [`composer_template`] otherwise opens
+/// inline). Renders identically whether the view below it is raw source, a
+/// rendered document, or a binary placeholder -- only [`BlobHeaderMeta`]'s
+/// fields differ per kind.
+fn blob_header(meta: &BlobHeaderMeta<'_>) -> Markup {
+ html! {
+ div.blob-header {
+ span.blob-title { (meta.name) }
+ span.blob-meta {
+ @if let Some(lines) = meta.line_count {
+ (lines) @if lines == 1 { " line" } @else { " lines" } " \u{b7} " (human_size(meta.size))
+ } @else {
+ (human_size(meta.size))
+ }
+ @if let Some(language) = meta.language {
+ " \u{b7} " (language)
+ }
+ }
+ span.blob-actions {
+ a href="/commits" { "history" }
+ @if meta.comments > 0 {
+ a href="#comment-0" {
+ (meta.comments) @if meta.comments == 1 { " comment" } @else { " comments" }
+ }
+ }
+ a href={ "/comments?file=" (meta.path) } { "comment on this file" }
+ }
+ }
+ }
+}
+
+/// The composer's server-rendered `<template>`, cloned by `ents.js` when a
+/// reader clicks the gutter's `+` affordance on a raw-source view (see this
+/// module's own top-level doc). Its `form` posts to
+/// `crate::pages::comments`'s own `POST /comments` handler
+/// ([`super::comments::AddForm`]), pre-filled with this file's own
+/// `path`/`rev` (`head_oid`, the resolved `HEAD` commit, exactly what
+/// `div.blob`'s own `data-rev` names -- see [`source_view`]'s own doc) so
+/// the only field `ents.js` ever needs to fill in before submit is the
+/// hidden `lines` input, left empty here. With JS disabled this template
+/// never becomes visible at all (a `<template>` element's contents are
+/// inert, never rendered by a browser on their own), which is exactly why
+/// [`blob_header`]'s "comment on this file" link remains the no-JS path to
+/// the same form.
+// @relation(roots.web-session, scope=function)
+fn composer_template(path: &str, head_oid: &str, session: &Session) -> Markup {
+ html! {
+ template id="composer-template" {
+ form.composer-form method="post" action="/comments" {
+ (super::csrf_input(session))
+ input type="hidden" name="path" value=(path);
+ input type="hidden" name="rev" value=(head_oid);
+ input type="hidden" name="lines" value="";
+ textarea name="body" placeholder="Leave a comment (AsciiDoc)" {}
+ div.composer-buttons {
+ button type="submit" { "Comment" }
+ button.composer-cancel type="button" { "Cancel" }
+ }
+ }
+ }
+ }
+}
+
+/// The raw-source view: `header` and `composer` (see [`blob_header`]/
+/// [`composer_template`]'s own docs) around one table row per line (a
+/// `<tr>` pairing a `.blob-nums` line-number cell carrying the row's
+/// `#L{n}` anchor with a `.blob-code` cell, no wrapper beyond those two
+/// cells -- lean enough that thousands of lines stay cheap), highlighted
+/// via [`highlight`] when `highlighted` is `Some` and falling back to
+/// plain (still per-line, still auto-escaped by `maud`'s own
+/// interpolation) text otherwise. Each
/// [`FileComment`](super::comments::FileComment) in `comments` whose
/// [`ents_anchor::LineRange`] is `Some` renders its card
/// ([`super::comments::comment_card`]) immediately after the row naming
@@ -361,7 +556,17 @@
/// the same line stack in `comments`' own order (`comment::list`'s ref
/// order). A comment with no current line range is [`blob_view`]'s own
/// concern, not this function's: it never appears here.
+///
+/// `div.blob` itself carries `data-path=(path)`/`data-rev=(head_oid)` --
+/// `ents.js`'s own activation check and the values it writes into
+/// [`composer_template`]'s clone -- so a click on a gutter line number
+/// selects it (and a shift-click extends the selection) with no further
+/// server round trip needed until the reader actually submits a comment.
fn source_view(
+ path: &str,
+ head_oid: &str,
+ header: Markup,
+ composer: Markup,
text: &str,
highlighted: Option<String>,
comments: &[super::comments::FileComment],
@@ -397,7 +602,8 @@
}
html! {
- div.blob {
+ div.blob data-path=(path) data-rev=(head_oid) {
+ (header)
table {
tbody {
@for (index, code) in code_lines.into_iter().enumerate() {
@@ -428,6 +634,7 @@
}
}
}
+ (composer)
}
}
}
@@ -632,6 +839,15 @@
}
}
+ /// A minimal [`Session`] fixture -- [`blob_view`]'s own tests only ever
+ /// need a csrf token to render into [`composer_template`]'s hidden
+ /// input, never a real [`crate::session::SessionStore`]-minted one.
+ fn session() -> Session {
+ Session {
+ csrf: "test-csrf-token".to_owned(),
+ }
+ }
+
#[rstest]
#[case::empty("", true)]
#[case::simple("src/main.rs", true)]
@@ -651,10 +867,10 @@
#[test]
fn dir_listing_sorts_directories_first_then_alphabetically() {
let entries = vec![
- ("zeta.txt".to_owned(), false),
- ("alpha".to_owned(), true),
- ("beta.txt".to_owned(), false),
- ("gamma".to_owned(), true),
+ ("zeta.txt".to_owned(), false, Some(10)),
+ ("alpha".to_owned(), true, None),
+ ("beta.txt".to_owned(), false, Some(2048)),
+ ("gamma".to_owned(), true, None),
];
let rendered = dir_listing("", entries).into_string();
let alpha = rendered.find("alpha").expect("alpha listed");
@@ -666,23 +882,81 @@
assert!(beta < zeta, "files sort among themselves");
}
+ #[test]
+ fn dir_listing_shows_a_size_for_a_file_and_none_for_a_directory() {
+ let entries = vec![
+ ("src".to_owned(), true, None),
+ ("main.rs".to_owned(), false, Some(2048)),
+ ];
+ let rendered = dir_listing("", entries).into_string();
+ let dir_index = rendered.find("src").expect("directory entry renders");
+ let file_index = rendered.find("main.rs").expect("file entry renders");
+ assert!(dir_index < file_index, "directories sort before files");
+ assert!(
+ !rendered
+ .get(..file_index)
+ .expect("slice up to the file entry")
+ .contains("entry-size"),
+ "the directory row carries no size cell"
+ );
+ assert!(
+ rendered.contains("entry-size"),
+ "the file row carries a size span"
+ );
+ assert!(rendered.contains("2.0 KB"), "the size is human-formatted");
+ }
+
+ #[rstest]
+ #[case::bytes(0, "0 B")]
+ #[case::bytes_under_a_kb(1023, "1023 B")]
+ #[case::exactly_one_kb(1024, "1.0 KB")]
+ #[case::fractional_kb(1536, "1.5 KB")]
+ #[case::just_under_a_mb(1_048_575, "1023.9 KB")]
+ #[case::exactly_one_mb(1_048_576, "1.0 MB")]
+ #[case::fractional_mb(1_572_864, "1.5 MB")]
+ fn human_size_formats_bytes_kb_and_mb(#[case] bytes: u64, #[case] expected: &str) {
+ assert_eq!(human_size(bytes), expected);
+ }
+
#[test]
fn blob_view_renders_markdown_as_a_heading_not_raw_markup() {
- let (body, _below) = blob_view("readme.md", b"# Title\n", &[]).expect("markdown renders");
+ let (body, _below) = blob_view(
+ "readme.md",
+ "readme.md",
+ "deadbeef",
+ &session(),
+ b"# Title\n",
+ &[],
+ )
+ .expect("markdown renders");
assert!(body.into_string().contains("<h1>Title</h1>"));
}
#[test]
fn blob_view_renders_asciidoc_as_a_heading_not_raw_markup() {
- let (body, _below) =
- blob_view("readme.adoc", b"= Title\n\nBody.\n", &[]).expect("asciidoc renders");
+ let (body, _below) = blob_view(
+ "readme.adoc",
+ "readme.adoc",
+ "deadbeef",
+ &session(),
+ b"= Title\n\nBody.\n",
+ &[],
+ )
+ .expect("asciidoc renders");
assert!(body.into_string().contains("<h1>Title</h1>"));
}
#[test]
fn blob_view_escapes_plain_text_into_a_line_numbered_code_block() {
- let (body, _below) =
- blob_view("notes.txt", b"1 < 2 and true", &[]).expect("plain text renders");
+ let (body, _below) = blob_view(
+ "notes.txt",
+ "notes.txt",
+ "deadbeef",
+ &session(),
+ b"1 < 2 and true",
+ &[],
+ )
+ .expect("plain text renders");
let rendered = body.into_string();
assert!(rendered.contains("blob-nums"));
assert!(rendered.contains("<td class=\"blob-code\"><code>"));
@@ -691,8 +965,15 @@
#[test]
fn blob_view_highlights_a_recognized_language_with_syntax_token_classes() {
- let (body, _below) =
- blob_view("main.rs", b"fn main() { let x = 1; }", &[]).expect("rust renders");
+ let (body, _below) = blob_view(
+ "src/main.rs",
+ "main.rs",
+ "deadbeef",
+ &session(),
+ b"fn main() { let x = 1; }",
+ &[],
+ )
+ .expect("rust renders");
let rendered = body.into_string();
assert!(rendered.contains("blob-nums"));
assert!(rendered.contains("class=\"code\""));
@@ -701,16 +982,30 @@
#[test]
fn blob_view_shows_a_placeholder_for_binary_content() {
- let (body, _below) =
- blob_view("data.bin", b"\0\x01\x02binary", &[]).expect("binary placeholder renders");
+ let (body, _below) = blob_view(
+ "data.bin",
+ "data.bin",
+ "deadbeef",
+ &session(),
+ b"\0\x01\x02binary",
+ &[],
+ )
+ .expect("binary placeholder renders");
assert!(body.into_string().contains("Binary file"));
}
#[test]
fn blob_view_routes_a_doc_comment_below_the_blob_never_inline() {
let comments = vec![comment(Some(LineRange { start: 1, end: 1 }))];
- let (_body, below) =
- blob_view("readme.md", b"# Title\n", &comments).expect("markdown renders");
+ let (_body, below) = blob_view(
+ "readme.md",
+ "readme.md",
+ "deadbeef",
+ &session(),
+ b"# Title\n",
+ &comments,
+ )
+ .expect("markdown renders");
// A doc view has no source line to interleave at: every comment,
// even one with a current line range, renders in the below
// section -- `comments_section`'s plain, untitled list, not
@@ -718,10 +1013,75 @@
assert!(below.into_string().contains("worth a look"));
}
+ #[test]
+ fn blob_view_shows_the_header_with_line_count_size_and_language() {
+ let (body, _below) = blob_view(
+ "src/main.rs",
+ "main.rs",
+ "deadbeef",
+ &session(),
+ b"fn main() {}\n",
+ &[],
+ )
+ .expect("rust renders");
+ let rendered = body.into_string();
+ assert!(rendered.contains("blob-header"));
+ assert!(rendered.contains("1 line"));
+ assert!(rendered.contains("13 B"));
+ assert!(rendered.contains("rust"));
+ assert!(rendered.contains("comment on this file"));
+ }
+
+ #[test]
+ fn blob_view_carries_the_composer_hooks_only_on_a_raw_source_view() {
+ let (body, _below) = blob_view(
+ "src/main.rs",
+ "main.rs",
+ "cafef00dcafef00dcafef00dcafef00dcafef00d",
+ &session(),
+ b"fn main() {}\n",
+ &[],
+ )
+ .expect("rust renders");
+ let rendered = body.into_string();
+ assert!(rendered.contains("data-path=\"src/main.rs\""));
+ assert!(rendered.contains("data-rev=\"cafef00dcafef00dcafef00dcafef00dcafef00d\""));
+ assert!(rendered.contains("id=\"composer-template\""));
+ assert!(rendered.contains("name=\"csrf\""));
+ assert!(rendered.contains("test-csrf-token"));
+ assert!(rendered.contains("name=\"path\" value=\"src/main.rs\""));
+ assert!(
+ rendered.contains("name=\"rev\" value=\"cafef00dcafef00dcafef00dcafef00dcafef00d\"")
+ );
+
+ let (doc_body, _below) = blob_view(
+ "readme.md",
+ "readme.md",
+ "deadbeef",
+ &session(),
+ b"# Title\n",
+ &[],
+ )
+ .expect("markdown renders");
+ assert!(
+ !doc_body.into_string().contains("composer-template"),
+ "a doc-rendered view has no source line to anchor a composer to"
+ );
+ }
+
#[test]
fn source_view_interleaves_a_comment_directly_after_its_last_line() {
let comments = vec![comment(Some(LineRange { start: 1, end: 2 }))];
- let rendered = source_view("line 1\nline 2\nline 3\n", None, &comments).into_string();
+ let rendered = source_view(
+ "src/main.rs",
+ "deadbeef",
+ Markup::default(),
+ Markup::default(),
+ "line 1\nline 2\nline 3\n",
+ None,
+ &comments,
+ )
+ .into_string();
let line2 = rendered.find("id=\"L2\"").expect("line 2 renders");
let card = rendered.find("comment-meta").expect("card renders");
let line3 = rendered.find("id=\"L3\"").expect("line 3 renders");
@@ -745,7 +1105,16 @@
c
},
];
- let rendered = source_view("line 1\nline 2\n", None, &comments).into_string();
+ let rendered = source_view(
+ "src/main.rs",
+ "deadbeef",
+ Markup::default(),
+ Markup::default(),
+ "line 1\nline 2\n",
+ None,
+ &comments,
+ )
+ .into_string();
let first = rendered.find("first").expect("first comment renders");
let second = rendered.find("second").expect("second comment renders");
assert!(first < second, "stacked comments keep ref order");
@@ -754,7 +1123,16 @@
#[test]
fn source_view_omits_a_comment_with_no_current_line_range() {
let comments = vec![comment(None)];
- let rendered = source_view("line 1\nline 2\n", None, &comments).into_string();
+ let rendered = source_view(
+ "src/main.rs",
+ "deadbeef",
+ Markup::default(),
+ Markup::default(),
+ "line 1\nline 2\n",
+ None,
+ &comments,
+ )
+ .into_string();
assert!(
!rendered.contains("worth a look"),
"a comment with no lines has nowhere to interleave -- blob_view routes it below instead"