git-ents.gitmain
⌘K
foforge
files.rs1389 lines · 53.7 KB · rusthistorycomment on this file
1//! `GET /files`, `GET /files/{*path}`: a read-only directory listing and
2//! blob viewer over the `HEAD` tree of the repository `git ents serve` is
3//! serving. A `.md` blob renders via `crate::markdown`, a
4//! `.adoc`/`.asciidoc`/`.asc`/`.adc` blob via `crate::asciidoc`, and
5//! everything else as a line-numbered source view, syntax-highlighted via
6//! [`arborium`] when its filename maps to a known grammar (ported from
7//! `pre-redo:crates/git-ents-server/src/web/pages.rs`'s own `highlight`;
8//! see `highlight`'s own doc), escaped plain text otherwise.
9//!
10//! Tree/blob reads go through `gix`'s high-level `Repository`/`Tree`/`Blob`
11//! types (`repo.head_tree()`, `Tree::lookup_entry_by_path`,
12//! `Entry::object`), opened fresh per request from `state.path` -- the
13//! same `gix::open(repo_path)` pattern `ents_forge::comment::add`/`show`
14//! already use to browse a live working tree, not the
15//! `facet-git-tree`/`gix_object::Find` convention the rest of this crate's
16//! pages use to read typed meta-ref entities (`facet-git-tree` is for
17//! structured meta-ref data; browsing arbitrary repository content is not
18//! that).
19//!
20//! `crumbs` renders only the path trail now -- every action that used to
21//! live at its trailing edge (jump into history, jump to the first
22//! comment, add a comment) moved into `blob_header`'s own right-aligned
23//! action group, rendered above every blob view regardless of how it
24//! renders (raw source, a rendered document, or a binary placeholder); a
25//! directory listing carries neither the actions nor a header, since a
26//! comment anchors to a file, never a tree.
27//!
28//! A blob view also loads and renders the comments anchored to it
29//! (`crate::pages::comments::for_path`). A raw-source view (not a
30//! rendered document or a binary placeholder) interleaves each comment's
31//! card directly after the row naming its anchored range's last line,
32//! full width across the blob's line-number and code columns
33//! (`source_view`); a comment with no current line range (a whole-file
34//! anchor, or `ents_anchor::Projection::Outdated`) has nowhere to
35//! interleave, and renders in a below-the-blob "outdated comments"
36//! section instead (`outdated_comments_section`). Doc-rendered and
37//! binary views keep every comment below the blob, unconditionally
38//! (`crate::pages::comments::comments_section`), since there is no source
39//! line to interleave at.
40//!
41//! A raw-source view additionally carries the client-side hooks
42//! `crate::assets`'s `ents.js` progressively enhances: `div.blob` names its
43//! own `path`/`rev` (`data-path`/`data-rev`, the latter the resolved `HEAD`
44//! commit oid, not the string `"HEAD"`, so a captured selection names the
45//! exact commit being viewed) so a click on a gutter line number can select
46//! a line or a shift-extended range and open an inline comment composer
47//! cloned from a server-rendered `<template id="composer-template">`
48//! (`composer_template`) -- with JS disabled the page stays fully usable
49//! via `blob_header`'s "comment on this file" link and the plain `#L<n>`
50//! anchors `crate::pages::comments::comment_card` already emits.
51
52use std::sync::Arc;
53
54use arborium::{Config, Highlighter, HtmlFormat};
55use axum::extract::{Path, State};
56use gix::bstr::ByteSlice as _;
57use gix_object::{Find, Write};
58use maud::{Markup, PreEscaped, html};
59
60use crate::assets;
61use crate::error::{Error, Result};
62use crate::session::Session;
63use crate::state::AppState;
64
65/// `GET /files`: the repository root directory listing.
66///
67/// # Errors
68///
69/// Propagates a `gix::open`/tree-read failure.
70pub async fn root<O>(
71 State(state): State<Arc<AppState<O>>>,
72 axum::Extension(session): axum::Extension<Session>,
73) -> Result<Markup>
74where
75 O: Find + Write + Send + 'static,
76{
77 at(&state, "", &session)
78}
79
80/// `GET /files/{*path}`: a directory listing or blob view at `path`.
81///
82/// # Errors
83///
84/// [`Error::NotFound`] if `path` does not name a tree or blob entry (or
85/// contains a `.`/`..` component); otherwise propagates a
86/// `gix::open`/tree-read failure.
87pub async fn show<O>(
88 State(state): State<Arc<AppState<O>>>,
89 axum::Extension(session): axum::Extension<Session>,
90 Path(path): Path<String>,
91) -> Result<Markup>
92where
93 O: Find + Write + Send + 'static,
94{
95 at(&state, &path, &session)
96}
97
98/// The shared implementation behind [`root`] and [`show`]: resolve `path`
99/// against `HEAD`'s tree and render whichever of a directory listing or a
100/// blob view it names. `session` is only ever needed on a blob view, to
101/// render [`composer_template`]'s csrf input -- threaded down from the
102/// route handler rather than reached for a second time here.
103fn at<O>(state: &AppState<O>, path: &str, session: &Session) -> Result<Markup>
104where
105 O: Find + Write,
106{
107 if !is_safe_path(path) {
108 return Err(Error::NotFound {
109 what: path.to_owned(),
110 });
111 }
112
113 let repo = gix::open(&state.path).map_err(|source| Error::Repo(source.to_string()))?;
114 let head_tree = match repo.head_tree() {
115 Ok(tree) => tree,
116 // An unborn HEAD (a freshly initialized, still-empty repository)
117 // reads as an empty root directory, not a failure -- mirrors
118 // `pre-redo:crates/git-ents-server/src/web/git.rs`'s `root_tree`,
119 // which returned an empty entry list rather than erroring when the
120 // repository had no `HEAD` yet.
121 Err(_) if path.is_empty() => {
122 return Ok(super::layout(
123 &super::RepoHeader::from_state(state),
124 &super::identity_label(state),
125 super::Tab::Files,
126 "Files",
127 html! {
128 (dir_listing(path, Vec::new()))
129 },
130 ));
131 }
132 Err(_) => {
133 return Err(Error::NotFound {
134 what: path.to_owned(),
135 });
136 }
137 };
138
139 if path.is_empty() {
140 let entries = tree_entries(&head_tree)?;
141 return Ok(super::layout_split(
142 &super::RepoHeader::from_state(state),
143 &super::identity_label(state),
144 super::Tab::Files,
145 "Files",
146 false,
147 tree_sidebar(&head_tree, "", ""),
148 html! {
149 (dir_listing(path, entries))
150 (readme_card(&head_tree))
151 },
152 ));
153 }
154
155 let entry = head_tree
156 .lookup_entry_by_path(path)
157 .map_err(|source| Error::Repo(source.to_string()))?
158 .ok_or_else(|| Error::NotFound {
159 what: path.to_owned(),
160 })?;
161
162 if entry.mode().is_tree() {
163 let subtree = entry
164 .object()
165 .map_err(|source| Error::Repo(source.to_string()))?
166 .try_into_tree()
167 .map_err(|source| Error::Repo(source.to_string()))?;
168 let entries = tree_entries(&subtree)?;
169 Ok(super::layout_split(
170 &super::RepoHeader::from_state(state),
171 &super::identity_label(state),
172 super::Tab::Files,
173 path,
174 true,
175 tree_sidebar(&head_tree, path, path),
176 html! {
177 (crumbs(path))
178 (dir_listing(path, entries))
179 },
180 ))
181 } else if entry.mode().is_blob() {
182 let blob = entry
183 .object()
184 .map_err(|source| Error::Repo(source.to_string()))?
185 .try_into_blob()
186 .map_err(|source| Error::Repo(source.to_string()))?;
187 let name = path.rsplit('/').next().unwrap_or(path);
188 let comments = super::comments::for_path(state, &repo, path);
189 let head_oid = repo
190 .head_id()
191 .map_err(|source| Error::Repo(source.to_string()))?
192 .to_string();
193 let editor = super::editor_open(state, path, None);
194 let (body, below) = blob_view(
195 path, name, &head_oid, session, &blob.data, &comments, editor,
196 )?;
197 let parent = path.rsplit_once('/').map_or("", |(dir, _)| dir);
198 Ok(super::layout_split(
199 &super::RepoHeader::from_state(state),
200 &super::identity_label(state),
201 super::Tab::Files,
202 path,
203 true,
204 tree_sidebar(&head_tree, parent, path),
205 html! {
206 (crumbs(path))
207 (body)
208 (below)
209 },
210 ))
211 } else {
212 // A symlink or a submodule (gitlink) -- neither is a tree or a
213 // blob this browser can render.
214 Err(Error::NotFound {
215 what: path.to_owned(),
216 })
217 }
218}
219
220/// Whether `path` is safe to resolve against a tree: no empty, `.`, or
221/// `..` component. The empty root path is itself safe.
222fn is_safe_path(path: &str) -> bool {
223 path.is_empty()
224 || path
225 .split('/')
226 .all(|s| !s.is_empty() && s != "." && s != "..")
227}
228
229/// One `(name, is_directory, size)` triple per direct child of `tree`, in
230/// tree order (not yet sorted -- [`dir_listing`] sorts for display). `size`
231/// is a blob entry's byte length, read from its odb header
232/// ([`gix::Repository::find_header`], a header-only lookup -- never a
233/// full blob read just to size it) and best-effort (`None`
234/// on a header-read failure); always
235/// `None` for a directory entry, which [`dir_listing`] renders with no
236/// size cell at all.
237fn tree_entries(tree: &gix::Tree<'_>) -> Result<Vec<(String, bool, Option<u64>)>> {
238 tree.iter()
239 .map(|entry| {
240 let entry = entry.map_err(|source| Error::Repo(source.to_string()))?;
241 let is_dir = entry.mode().is_tree();
242 let size = (!is_dir)
243 .then(|| tree.repo.find_header(entry.oid()).ok())
244 .flatten()
245 .map(|header| header.size());
246 Ok((entry.filename().to_str_lossy().into_owned(), is_dir, size))
247 })
248 .collect()
249}
250
251/// The Code split's `.tree` sidebar (`crate::pages::layout_split`): a
252/// crumb trail back to the repository root (plain labels, no icon -- the
253/// same bare style [`crumbs`] renders above the pane), then the entries of
254/// the directory at `dir` (the viewed directory itself, or a viewed blob's
255/// parent) each as an icon
256/// ([`assets::icon_folder`]/[`assets::icon_file`]) beside its name,
257/// directories first -- not a full recursive tree, just enough context to
258/// move one level in any direction. `active` names the full path of the
259/// entry (or trailing crumb) being viewed, and gets `.active`
260/// ([`tree_class`]). Best-effort: a subtree that fails to read renders an
261/// empty entry list rather than failing the page around it.
262fn tree_sidebar(head_tree: &gix::Tree<'_>, dir: &str, active: &str) -> Markup {
263 let mut entries = if dir.is_empty() {
264 tree_entries(head_tree).unwrap_or_default()
265 } else {
266 head_tree
267 .lookup_entry_by_path(dir)
268 .ok()
269 .flatten()
270 .and_then(|entry| entry.object().ok())
271 .and_then(|object| object.try_into_tree().ok())
272 .map(|subtree| tree_entries(&subtree).unwrap_or_default())
273 .unwrap_or_default()
274 };
275 entries.sort_by(|(a_name, a_is_dir, _), (b_name, b_is_dir, _)| {
276 b_is_dir.cmp(a_is_dir).then_with(|| a_name.cmp(b_name))
277 });
278
279 let crumb_parts: Vec<&str> = dir.split('/').filter(|s| !s.is_empty()).collect();
280 let mut crumb_trail: Vec<(String, String)> = Vec::new();
281 let mut acc = String::new();
282 for part in &crumb_parts {
283 if !acc.is_empty() {
284 acc.push('/');
285 }
286 acc.push_str(part);
287 crumb_trail.push(((*part).to_owned(), acc.clone()));
288 }
289 let entry_depth = crumb_parts.len().saturating_add(1);
290
291 html! {
292 a class=(tree_class(true, 0, active.is_empty())) href="/files" { "/" }
293 @for (index, (label, crumb_path)) in crumb_trail.iter().enumerate() {
294 a class=(tree_class(true, index.saturating_add(1), crumb_path == active))
295 href={ "/files/" (crumb_path) } { (label) "/" }
296 }
297 @for (name, is_dir, _) in &entries {
298 @let full = if dir.is_empty() { name.clone() } else { format!("{dir}/{name}") };
299 a class=(tree_class(*is_dir, entry_depth, full == active))
300 href=(child_href(dir, name)) {
301 @if *is_dir { (assets::icon_folder()) } @else { (assets::icon_file()) }
302 (name) @if *is_dir { "/" }
303 }
304 }
305 }
306}
307
308/// The class list for one [`tree_sidebar`] link: `.dir` for a directory,
309/// an `.i{1..3}` indent per crumb depth (capped -- the sidebar shows one
310/// directory's entries, not an unbounded tree), `.active` for the viewed
311/// entry.
312fn tree_class(is_dir: bool, depth: usize, active: bool) -> String {
313 let mut classes = Vec::new();
314 if is_dir {
315 classes.push("dir");
316 }
317 match depth {
318 0 => {}
319 1 => classes.push("i1"),
320 2 => classes.push("i2"),
321 _ => classes.push("i3"),
322 }
323 if active {
324 classes.push("active");
325 }
326 classes.join(" ")
327}
328
329/// The link to a child of the directory at `dir` (empty at the root).
330fn child_href(dir: &str, name: &str) -> String {
331 if dir.is_empty() {
332 format!("/files/{name}")
333 } else {
334 format!("/files/{dir}/{name}")
335 }
336}
337
338/// A directory listing at `dir`: entries sorted directories-first then
339/// alphabetically, each an icon and a link one level deeper, plus a
340/// right-aligned muted size for a blob entry (`span.entry-size`,
341/// [`human_size`]) -- a directory entry carries no size cell, since a
342/// tree's own byte length is not a meaningful measure of it.
343fn dir_listing(dir: &str, mut entries: Vec<(String, bool, Option<u64>)>) -> Markup {
344 entries.sort_by(|(a_name, a_is_dir, _), (b_name, b_is_dir, _)| {
345 b_is_dir.cmp(a_is_dir).then_with(|| a_name.cmp(b_name))
346 });
347 html! {
348 div.card {
349 @if entries.is_empty() {
350 div.card-row.muted { "Empty directory." }
351 }
352 @for (name, is_dir, size) in &entries {
353 div.card-row.is-dir[*is_dir] {
354 a.row-link href=(child_href(dir, name)) {
355 @if *is_dir { (assets::icon_folder()) } @else { (assets::icon_file()) }
356 (name)
357 }
358 @if let Some(size) = size {
359 span.entry-size { (human_size(*size)) }
360 }
361 }
362 }
363 }
364 }
365}
366
367/// The rendered `README` card below the root listing -- re-homed here
368/// from the old overview dashboard (`crate::pages::dashboard` is a work
369/// surface now; the Code root is where the repository introduces itself).
370/// Renders nothing at all when the root holds no renderable `README`.
371fn readme_card(tree: &gix::Tree<'_>) -> Markup {
372 let Some((name, rendered)) = readme(tree) else {
373 return html! {};
374 };
375 html! {
376 div.card {
377 div.card-header { (assets::icon_file()) (name) }
378 div.doc-body { (rendered) }
379 }
380 }
381}
382
383/// The first root-tree blob whose stem is `README` and whose extension
384/// this crate renders (Markdown or AsciiDoc), converted to HTML and paired
385/// with its filename; `None` when there is none or it fails to render
386/// (mirrors `pre-redo:.../pages.rs`'s `readme`).
387fn readme(tree: &gix::Tree<'_>) -> Option<(String, Markup)> {
388 let name = root_readme_name(tree)?;
389 let entry = tree.lookup_entry_by_path(&name).ok()??;
390 let blob = entry.object().ok()?.try_into_blob().ok()?;
391 let text = String::from_utf8_lossy(&blob.data);
392 render_doc(&name, &text).map(|rendered| (name, rendered))
393}
394
395/// The filename of the root's `README`, if it has a renderable one.
396fn root_readme_name(tree: &gix::Tree<'_>) -> Option<String> {
397 for entry in tree.iter() {
398 let Ok(entry) = entry else { continue };
399 if !entry.mode().is_blob() {
400 continue;
401 }
402 let name = entry.filename().to_str_lossy();
403 let is_readme = name
404 .rsplit_once('.')
405 .is_some_and(|(stem, _)| stem.eq_ignore_ascii_case("readme"));
406 if is_readme && (crate::markdown::is_markdown(&name) || crate::asciidoc::is_asciidoc(&name))
407 {
408 return Some(name.into_owned());
409 }
410 }
411 None
412}
413
414/// `text` rendered as its prose format (Markdown or AsciiDoc), or `None`
415/// when it is neither or AsciiDoc rendering fails.
416fn render_doc(name: &str, text: &str) -> Option<Markup> {
417 if crate::markdown::is_markdown(name) {
418 Some(crate::markdown::to_html(text))
419 } else if crate::asciidoc::is_asciidoc(name) {
420 crate::asciidoc::to_html(text).ok()
421 } else {
422 None
423 }
424}
425
426/// Breadcrumb navigation from the repository's files root down through
427/// `path`, `chevron-right` icons separating segments -- pure navigation,
428/// no trailing actions. The history/comment links that used to trail this
429/// nav on a blob view now live in [`blob_header`]'s own action group
430/// instead (see this module's own top-level doc for why). The files root
431/// itself renders no crumbs at all: a lone self-referencing "files" crumb
432/// under the page's own "Files" title (and above the listing card's own
433/// "files" header) named the same place three times.
434fn crumbs(path: &str) -> Markup {
435 let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
436 let mut acc = String::new();
437 let mut trail: Vec<(String, Option<String>)> =
438 vec![("files".to_owned(), Some("/files".to_owned()))];
439 for (index, part) in parts.iter().enumerate() {
440 if !acc.is_empty() {
441 acc.push('/');
442 }
443 acc.push_str(part);
444 let is_last = index.saturating_add(1) == parts.len();
445 let href = (!is_last).then(|| format!("/files/{acc}"));
446 trail.push(((*part).to_owned(), href));
447 }
448 html! {
449 nav.crumbs {
450 @for (index, (label, href)) in trail.iter().enumerate() {
451 @if index > 0 { span.sep { (assets::icon_chevron()) } }
452 @match href {
453 Some(href) => a href=(href) { (label) },
454 None => span.here { (label) },
455 }
456 }
457 }
458 }
459}
460
461/// Format a byte count the way [`blob_header`] and [`dir_listing`] both
462/// show a file's size: whole bytes under 1 KB, otherwise one decimal place
463/// of KB or MB -- integer-only throughout (`checked_div`/`checked_rem`/
464/// `saturating_mul`, this crate's own arithmetic idiom) rather than a
465/// float division, so there is no rounding-mode or precision question to
466/// answer.
467fn human_size(bytes: u64) -> String {
468 const KB: u64 = 1024;
469 const MB: u64 = 1024 * 1024;
470 if bytes < KB {
471 return format!("{bytes} B");
472 }
473 let (scale, unit) = if bytes < MB { (KB, "KB") } else { (MB, "MB") };
474 let whole = bytes.checked_div(scale).unwrap_or(0);
475 let remainder = bytes.checked_rem(scale).unwrap_or(0);
476 let tenths = remainder.saturating_mul(10).checked_div(scale).unwrap_or(0);
477 format!("{whole}.{tenths} {unit}")
478}
479
480/// Whether `bytes` looks like binary content (a NUL byte in the leading
481/// chunk -- the same heuristic git itself uses, carried over from
482/// `pre-redo:crates/git-ents-server/src/web/pages.rs`'s own `is_binary`).
483fn is_binary(bytes: &[u8]) -> bool {
484 bytes.iter().take(8000).any(|b| *b == 0)
485}
486
487/// A single blob's contents, plus whatever comments belong below it: a
488/// Markdown/AsciiDoc document rendered as such via
489/// [`crate::markdown`]/[`crate::asciidoc`] or a binary-content placeholder
490/// -- either way every `comment` renders below, unconditionally
491/// ([`crate::pages::comments::comments_section`]), since there is no
492/// source line to interleave a card at -- or [`source_view`]'s
493/// line-per-row rendering, which interleaves a comment with a current line
494/// range directly into the blob and returns the rest (no current line
495/// range: a whole-file anchor, or `ents_anchor::Projection::Outdated`) as
496/// a separate below-the-blob section ([`outdated_comments_section`]).
497/// Every case renders a [`blob_header`] first -- above `div.card`/
498/// `div.binary` for a doc-rendered or binary view, as `div.blob`'s own
499/// first child for a raw-source view (see [`source_view`]'s own doc).
500///
501/// # Errors
502///
503/// Propagates [`crate::asciidoc::to_html`]'s own [`Error::Asciidoc`].
504fn blob_view(
505 path: &str,
506 name: &str,
507 head_oid: &str,
508 session: &Session,
509 bytes: &[u8],
510 comments: &[super::comments::FileComment],
511 editor: Markup,
512) -> Result<(Markup, Markup)> {
513 let size = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
514 let comment_count = comments.len();
515 let no_line_count_header = || {
516 blob_header(&BlobHeaderMeta {
517 name,
518 path,
519 size,
520 line_count: None,
521 language: None,
522 comments: comment_count,
523 editor: editor.clone(),
524 })
525 };
526 // Every view kind carries the whole-file composer template -- a
527 // doc-rendered or binary view has no per-line gutter to open one with
528 // a specific line range, but "comment on this file" (empty `lines`,
529 // [`composer_template`]'s own default) is exactly as meaningful there
530 // as on a raw-source view.
531 let composer = composer_template(path, head_oid, session);
532 if is_binary(bytes) {
533 return Ok((
534 html! {
535 (no_line_count_header())
536 div.binary { "Binary file (" (bytes.len()) " bytes) not shown." }
537 (composer)
538 },
539 super::comments::comments_section(comments),
540 ));
541 }
542 let Ok(text) = std::str::from_utf8(bytes) else {
543 return Ok((
544 html! {
545 (no_line_count_header())
546 div.binary { "Binary file (" (bytes.len()) " bytes) not shown." }
547 (composer)
548 },
549 super::comments::comments_section(comments),
550 ));
551 };
552 if crate::markdown::is_markdown(name) {
553 return Ok((
554 html! {
555 (no_line_count_header())
556 div.card { div.doc-body { (crate::markdown::to_html(text)) } }
557 (composer)
558 },
559 super::comments::comments_section(comments),
560 ));
561 }
562 if crate::asciidoc::is_asciidoc(name) {
563 return Ok((
564 html! {
565 (no_line_count_header())
566 div.card { div.doc-body { (crate::asciidoc::to_html(text)?) } }
567 (composer)
568 },
569 super::comments::comments_section(comments),
570 ));
571 }
572 let language = arborium::detect_language(name);
573 let highlighted = highlight(name, text);
574 let line_count = text.lines().count().max(1);
575 let header = blob_header(&BlobHeaderMeta {
576 name,
577 path,
578 size,
579 line_count: Some(line_count),
580 language,
581 comments: comment_count,
582 editor,
583 });
584 let below: Vec<(usize, &super::comments::FileComment)> = comments
585 .iter()
586 .enumerate()
587 .filter(|(_, comment)| comment.lines.is_none())
588 .collect();
589 Ok((
590 source_view(
591 path,
592 head_oid,
593 header,
594 composer,
595 text,
596 highlighted,
597 comments,
598 ),
599 outdated_comments_section(&below),
600 ))
601}
602
603/// The metadata [`blob_header`] shows beside a blob's name -- gathered by
604/// [`blob_view`], one per view kind (see that function's own doc for which
605/// fields each kind fills in).
606struct BlobHeaderMeta<'a> {
607 /// The file's own name (the last path segment), shown as the title.
608 name: &'a str,
609 /// The full repository-relative path -- only used to build the
610 /// "comment on this file" link's `?file=` query.
611 path: &'a str,
612 /// The blob's byte length, always known ([`human_size`]).
613 size: u64,
614 /// The raw-source view's line count; `None` for a doc-rendered or
615 /// binary view, which has no source line to count.
616 line_count: Option<usize>,
617 /// [`arborium::detect_language`]'s own identifier, shown as-is when it
618 /// recognized `name`'s grammar; `None` otherwise, or for a
619 /// doc-rendered/binary view (a rendered document is not "highlighted
620 /// as" a language, and a binary blob was never linted for one at all).
621 language: Option<&'static str>,
622 /// How many comments [`super::comments::for_path`] found for this
623 /// blob -- the "N comments" jump link renders only when this is above
624 /// zero (mirrors [`crumbs`]'s own former stance, now moved here).
625 comments: usize,
626 /// The pre-rendered open-in-editor affordance ([`super::editor_open`];
627 /// empty when no editor is recognized), leading the actions so the
628 /// jump back to the desk sits first.
629 editor: Markup,
630}
631
632/// The header bar above every blob view -- a leading file icon
633/// ([`assets::icon_file`]), the file's name and metadata
634/// (`span.blob-title`/`span.blob-meta`), the actions that used to
635/// trail [`crumbs`] on the right (`span.blob-actions`): a jump into
636/// `crate::pages::commits`'s `GET /commits` history (the file browser's
637/// one entry point into commit history, since history is a view of the
638/// code, not a tab of its own -- `crate::pages::mod`'s own doc), a jump
639/// straight to the first comment card (`#comment-0`, in display order --
640/// see [`super::comments::comment_card`]'s own doc) when at least one
641/// comment is already anchored here, and `crate::pages::comments`'s own
642/// add form for this file ("comment on this file" -- the no-JS fallback
643/// entry point into the composer [`composer_template`] otherwise opens
644/// inline). Renders identically whether the view below it is raw source, a
645/// rendered document, or a binary placeholder -- only [`BlobHeaderMeta`]'s
646/// fields differ per kind.
647fn blob_header(meta: &BlobHeaderMeta<'_>) -> Markup {
648 html! {
649 div.blob-header {
650 (assets::icon_file())
651 span.blob-title { (meta.name) }
652 span.blob-meta {
653 @if let Some(lines) = meta.line_count {
654 (lines) @if lines == 1 { " line" } @else { " lines" } " \u{b7} " (human_size(meta.size))
655 } @else {
656 (human_size(meta.size))
657 }
658 @if let Some(language) = meta.language {
659 " \u{b7} " (language)
660 }
661 }
662 span.blob-actions {
663 (meta.editor)
664 a href="/commits" { "history" }
665 @if meta.comments > 0 {
666 a href="#comment-0" {
667 (meta.comments) @if meta.comments == 1 { " comment" } @else { " comments" }
668 }
669 }
670 a.composer-trigger data-composer="composer-template" href={ "/comments?file=" (meta.path) } { "comment on this file" }
671 }
672 }
673 }
674}
675
676/// The composer's server-rendered `<template>`, cloned by `ents.js` when a
677/// reader clicks the gutter's `+` affordance on a raw-source view (see this
678/// module's own top-level doc). Its `form` posts to
679/// `crate::pages::comments`'s own `POST /comments` handler
680/// ([`super::comments::AddForm`]), pre-filled with this file's own
681/// `path`/`rev` (`head_oid`, the resolved `HEAD` commit, exactly what
682/// `div.blob`'s own `data-rev` names -- see [`source_view`]'s own doc) so
683/// the only field `ents.js` ever needs to fill in before submit is the
684/// hidden `lines` input, left empty here. With JS disabled this template
685/// never becomes visible at all (a `<template>` element's contents are
686/// inert, never rendered by a browser on their own), which is exactly why
687/// [`blob_header`]'s "comment on this file" link remains the no-JS path to
688/// the same form.
689// @relation(roots.web-session, scope=function)
690fn composer_template(path: &str, head_oid: &str, session: &Session) -> Markup {
691 html! {
692 template id="composer-template" {
693 form.composer-form method="post" action="/comments" {
694 (super::csrf_input(session))
695 input type="hidden" name="path" value=(path);
696 input type="hidden" name="rev" value=(head_oid);
697 input type="hidden" name="lines" value="";
698 textarea name="body" placeholder="Leave a comment (AsciiDoc)" {}
699 div.composer-buttons {
700 button type="submit" { "Comment" }
701 button.composer-cancel type="button" { "Cancel" }
702 }
703 }
704 }
705 }
706}
707
708/// The raw-source view: `header` and `composer` (see [`blob_header`]/
709/// [`composer_template`]'s own docs) around one table row per line (a
710/// `<tr>` pairing a `.blob-nums` line-number cell carrying the row's
711/// `#L{n}` anchor with a `.blob-code` cell, no wrapper beyond those two
712/// cells -- lean enough that thousands of lines stay cheap), highlighted
713/// via [`highlight`] when `highlighted` is `Some` and falling back to
714/// plain (still per-line, still auto-escaped by `maud`'s own
715/// interpolation) text otherwise. Each
716/// [`FileComment`](super::comments::FileComment) in `comments` whose
717/// [`ents_anchor::LineRange`] is `Some` renders its card
718/// ([`super::comments::comment_card`]) immediately after the row naming
719/// its range's last line, full width across both columns
720/// (`tr.blob-comment-row`, `colspan="2"`) -- multiple comments ending on
721/// the same line stack in `comments`' own order (`comment::list`'s ref
722/// order). A comment with no current line range is [`blob_view`]'s own
723/// concern, not this function's: it never appears here.
724///
725/// `div.blob` itself carries `data-path=(path)`/`data-rev=(head_oid)` --
726/// `ents.js`'s own activation check and the values it writes into
727/// [`composer_template`]'s clone -- so a click on a gutter line number
728/// selects it (and a shift-click extends the selection) with no further
729/// server round trip needed until the reader actually submits a comment.
730fn source_view(
731 path: &str,
732 head_oid: &str,
733 header: Markup,
734 composer: Markup,
735 text: &str,
736 highlighted: Option<String>,
737 comments: &[super::comments::FileComment],
738) -> Markup {
739 let physical_lines: Vec<&str> = text.lines().collect();
740 let line_count = physical_lines.len().max(1);
741
742 let mut code_lines: Vec<Markup> = match &highlighted {
743 Some(html) => split_highlighted_lines(html, line_count)
744 .into_iter()
745 .map(|fragment| html! { (PreEscaped(fragment)) })
746 .collect(),
747 None => physical_lines
748 .iter()
749 .map(|line| html! { (*line) })
750 .collect(),
751 };
752 // Exactly `line_count` rows either way: arborium trims trailing
753 // newlines before highlighting (see `split_highlighted_lines`'s own
754 // doc), so a file ending in blank lines can highlight to fewer
755 // embedded newlines than `text.lines().count()` -- padding (never
756 // truncating in practice, since `split_highlighted_lines` never
757 // returns fewer than one fragment) keeps every gutter number paired
758 // with a code cell, with no per-row fallback indexing needed below.
759 code_lines.resize_with(line_count, Markup::default);
760
761 let mut by_end_line: std::collections::BTreeMap<u64, Vec<usize>> =
762 std::collections::BTreeMap::new();
763 for (index, comment) in comments.iter().enumerate() {
764 if let Some(range) = comment.lines {
765 by_end_line.entry(range.end).or_default().push(index);
766 }
767 }
768
769 html! {
770 // `header` is a sibling before `.blob`, never nested inside it --
771 // `.blob-header`'s own border and top-only radius are meant to sit
772 // flush atop `.blob`'s bottom-only radius as one continuous box
773 // (`ents.css`'s own note), which only holds when the two are
774 // siblings, not when the header sits inset inside `.blob`'s own
775 // padded, fully-bordered box.
776 (header)
777 div.blob data-path=(path) data-rev=(head_oid) {
778 table {
779 tbody {
780 @for (index, code) in code_lines.into_iter().enumerate() {
781 @let n = index.saturating_add(1);
782 tr {
783 td.blob-nums { a id={ "L" (n) } href={ "#L" (n) } { (n) } }
784 @if highlighted.is_some() {
785 td.blob-code { code.code { (code) } }
786 } @else {
787 td.blob-code { code { (code) } }
788 }
789 }
790 @if let Some(indices) = by_end_line.get(&u64::try_from(n).unwrap_or(u64::MAX)) {
791 @for &comment_index in indices {
792 @if let Some(comment) = comments.get(comment_index) {
793 tr.blob-comment-row {
794 td colspan="2" {
795 (super::comments::comment_card(
796 comment_index,
797 comment,
798 super::comments::LinkMode::SameFile,
799 ))
800 }
801 }
802 }
803 }
804 }
805 }
806 }
807 }
808 (composer)
809 }
810 }
811}
812
813/// The below-the-blob section for comments with no current line range to
814/// interleave at (a whole-file anchor, or
815/// `ents_anchor::Projection::Outdated`) -- a warn-glyphed heading
816/// (`div.outdated-head`, [`assets::icon_use`]'s `"i-warn"`) distinguishing
817/// it from the inline cards [`source_view`] interleaves directly into the
818/// blob, since every comment reaching here either predates line-level
819/// anchoring or has literally gone stale, then each comment through the
820/// same [`super::comments::comment_card`] every other blob-adjacent comment
821/// renders through. Renders nothing at all when `comments` is empty
822/// (mirrors [`super::comments::comments_section`]'s identical stance).
823fn outdated_comments_section(comments: &[(usize, &super::comments::FileComment)]) -> Markup {
824 if comments.is_empty() {
825 return html! {};
826 }
827 html! {
828 div.outdated-head {
829 (assets::icon_use("i-warn"))
830 span { "Outdated Comments" }
831 }
832 p.muted { "Anchored to lines that no longer map onto HEAD." }
833 @for &(index, comment) in comments {
834 (super::comments::comment_card(index, comment, super::comments::LinkMode::SameFile))
835 }
836 }
837}
838
839/// Split [`highlight`]'s single HTML string into one HTML fragment per
840/// source line (`line_count` of them, padding with an empty string past
841/// whatever [`tokenize`] actually produced -- arborium trims trailing
842/// newlines from its input before highlighting, so a file ending in
843/// several blank lines can highlight to fewer embedded newlines than
844/// `text.lines().count()`; [`source_view`]'s own row loop indexes
845/// defensively for the same reason).
846///
847/// The hard part: a highlight span **can** cross a newline (a multiline
848/// block comment, a triple-quoted string), so it is not enough to split on
849/// `\n` -- a span open at a line boundary must be closed before the split
850/// and reopened after it, or the two resulting fragments are not
851/// independently well-formed HTML. This walks [`tokenize`]'s token stream
852/// with an explicit stack of open span classes: a `Text` token's embedded
853/// newlines close every open span, end the current line, and reopen them
854/// (in the same order) at the start of the next.
855fn split_highlighted_lines(html: &str, line_count: usize) -> Vec<String> {
856 let mut lines: Vec<String> = Vec::with_capacity(line_count.max(1));
857 let mut current = String::new();
858 let mut open: Vec<&str> = Vec::new();
859
860 for token in tokenize(html) {
861 match token {
862 Token::Open(class) => {
863 current.push_str("<span class=\"");
864 current.push_str(class);
865 current.push_str("\">");
866 open.push(class);
867 }
868 Token::Close => {
869 current.push_str("</span>");
870 open.pop();
871 }
872 Token::Text(text) => {
873 let mut parts = text.split('\n');
874 if let Some(first) = parts.next() {
875 current.push_str(first);
876 }
877 for rest in parts {
878 for _ in &open {
879 current.push_str("</span>");
880 }
881 lines.push(std::mem::take(&mut current));
882 for class in &open {
883 current.push_str("<span class=\"");
884 current.push_str(class);
885 current.push_str("\">");
886 }
887 current.push_str(rest);
888 }
889 }
890 }
891 }
892 lines.push(current);
893 lines
894}
895
896/// One tokenized fragment of arborium's `HtmlFormat::ClassNames` output
897/// (`arborium_highlight::render::spans_to_html`'s own doc): an opening
898/// `<span class="...">`, its matching `</span>`, or a run of
899/// already-escaped text between tags. That renderer never emits any tag
900/// but these two, and every text run it emits is already HTML-escaped
901/// (`&lt;`, `&amp;`, ...) -- [`split_highlighted_lines`] never re-escapes
902/// or splits an entity, since [`Token::Text`] is only ever split on
903/// literal `\n` bytes, never re-parsed.
904#[derive(Debug, Clone, Copy, PartialEq, Eq)]
905enum Token<'a> {
906 /// `<span class="{0}">`.
907 Open(&'a str),
908 /// `</span>`.
909 Close,
910 /// Already-escaped text between tags.
911 Text(&'a str),
912}
913
914/// Tokenize `html` into a stream of [`Token`]s -- see [`Token`]'s own doc
915/// for why a simple `<span class="...">`/`</span>` scan is sufficient
916/// (arborium's own HTML renderer emits no other tag, and every text run is
917/// already escaped so it never contains a literal `<`). Malformed input
918/// (which arborium's own renderer never produces) degrades to treating the
919/// unrecognized byte as plain text rather than panicking or looping
920/// forever.
921fn tokenize(html: &str) -> Vec<Token<'_>> {
922 const OPEN_PREFIX: &str = "<span class=\"";
923 const CLOSE_TAG: &str = "</span>";
924
925 let mut tokens = Vec::new();
926 let mut rest = html;
927 while !rest.is_empty() {
928 // `.get(..)`/`.get(n..)` rather than direct indexing throughout:
929 // every offset here comes from `find`/`strip_prefix`, always a
930 // valid char boundary, but this function still never indexes a
931 // `str` directly (`clippy::string_slice`) or performs raw
932 // arithmetic on an offset (`clippy::arithmetic_side_effects`) --
933 // `.get(end..)` then `strip_prefix('"')` finds "just past the
934 // quote" without ever computing `end + 1`.
935 if let Some(after_prefix) = rest.strip_prefix(OPEN_PREFIX)
936 && let Some(end) = after_prefix.find('"')
937 && let Some(class) = after_prefix.get(..end)
938 && let Some(after_quote) = after_prefix.get(end..).and_then(|s| s.strip_prefix('"'))
939 && let Some(after_gt) = after_quote.strip_prefix('>')
940 {
941 tokens.push(Token::Open(class));
942 rest = after_gt;
943 continue;
944 }
945 if let Some(after) = rest.strip_prefix(CLOSE_TAG) {
946 tokens.push(Token::Close);
947 rest = after;
948 continue;
949 }
950 let next_tag = [rest.find(OPEN_PREFIX), rest.find(CLOSE_TAG)]
951 .into_iter()
952 .flatten()
953 .min();
954 match next_tag {
955 Some(0) | None => {
956 // No recognized tag anywhere ahead (or, defensively, right
957 // at the cursor despite the checks above not matching it
958 // -- malformed input arborium never actually produces):
959 // take the rest as one text run rather than looping.
960 tokens.push(Token::Text(rest));
961 rest = "";
962 }
963 Some(idx) => {
964 let text = rest.get(..idx).unwrap_or(rest);
965 rest = rest.get(idx..).unwrap_or_default();
966 tokens.push(Token::Text(text));
967 }
968 }
969 }
970 tokens
971}
972
973/// Highlighted HTML for `source`, or `None` when `name`'s extension names
974/// no grammar [`arborium::detect_language`] recognizes -- [`blob_view`]
975/// then falls back to escaped plain text. Ported from
976/// `pre-redo:crates/git-ents-server/src/web/pages.rs`'s own `highlight`,
977/// its `HtmlFormat::ClassNames` output matched by
978/// `crate::assets::OVERRIDES`'s `.code .keyword`-family rules.
979///
980/// The [`Highlighter`] is built and used entirely within this synchronous
981/// call -- its grammar store is not `Send`, so it must never be held
982/// across an `.await` (this function itself is never `async`, and neither
983/// is any caller between it and the request handler).
984fn highlight(name: &str, source: &str) -> Option<String> {
985 let language = arborium::detect_language(name)?;
986 let config = Config {
987 html_format: HtmlFormat::ClassNames,
988 ..Default::default()
989 };
990 Highlighter::with_config(config)
991 .highlight(language, source)
992 .ok()
993}
994
995#[cfg(test)]
996mod tests {
997 #![allow(clippy::expect_used, reason = "unit test")]
998
999 use ents_anchor::LineRange;
1000 use rstest::rstest;
1001
1002 use super::*;
1003 use crate::pages::comments::FileComment;
1004
1005 /// A minimal [`FileComment`] fixture -- the `body`/`author`/`seconds`
1006 /// values never matter to a rendering-position assertion, only
1007 /// `lines`.
1008 fn comment(lines: Option<LineRange>) -> FileComment {
1009 FileComment {
1010 author: "commenter".to_owned(),
1011 seconds: 0,
1012 path: "src/main.rs".to_owned(),
1013 lines,
1014 outdated: false,
1015 body: html! { p { "worth a look" } },
1016 editor: html! {},
1017 }
1018 }
1019
1020 /// A minimal [`Session`] fixture -- [`blob_view`]'s own tests only ever
1021 /// need a csrf token to render into [`composer_template`]'s hidden
1022 /// input, never a real [`crate::session::SessionStore`]-minted one.
1023 fn session() -> Session {
1024 Session {
1025 csrf: "test-csrf-token".to_owned(),
1026 member: None,
1027 }
1028 }
1029
1030 #[rstest]
1031 #[case::empty("", true)]
1032 #[case::simple("src/main.rs", true)]
1033 #[case::nested("a/b/c", true)]
1034 #[case::dot(".", false)]
1035 #[case::dotdot("..", false)]
1036 #[case::traversal("a/../b", false)]
1037 #[case::trailing_slash("a/", false)]
1038 #[case::double_slash("a//b", false)]
1039 fn is_safe_path_rejects_dot_components_and_empty_segments(
1040 #[case] path: &str,
1041 #[case] expected: bool,
1042 ) {
1043 assert_eq!(is_safe_path(path), expected);
1044 }
1045
1046 #[test]
1047 fn dir_listing_sorts_directories_first_then_alphabetically() {
1048 let entries = vec![
1049 ("zeta.txt".to_owned(), false, Some(10)),
1050 ("alpha".to_owned(), true, None),
1051 ("beta.txt".to_owned(), false, Some(2048)),
1052 ("gamma".to_owned(), true, None),
1053 ];
1054 let rendered = dir_listing("", entries).into_string();
1055 let alpha = rendered.find("alpha").expect("alpha listed");
1056 let gamma = rendered.find("gamma").expect("gamma listed");
1057 let beta = rendered.find("beta.txt").expect("beta listed");
1058 let zeta = rendered.find("zeta.txt").expect("zeta listed");
1059 assert!(alpha < gamma, "directories sort among themselves");
1060 assert!(gamma < beta, "every directory sorts before every file");
1061 assert!(beta < zeta, "files sort among themselves");
1062 }
1063
1064 #[test]
1065 fn dir_listing_shows_a_size_for_a_file_and_none_for_a_directory() {
1066 let entries = vec![
1067 ("src".to_owned(), true, None),
1068 ("main.rs".to_owned(), false, Some(2048)),
1069 ];
1070 let rendered = dir_listing("", entries).into_string();
1071 let dir_index = rendered.find("src").expect("directory entry renders");
1072 let file_index = rendered.find("main.rs").expect("file entry renders");
1073 assert!(dir_index < file_index, "directories sort before files");
1074 assert!(
1075 !rendered
1076 .get(..file_index)
1077 .expect("slice up to the file entry")
1078 .contains("entry-size"),
1079 "the directory row carries no size cell"
1080 );
1081 assert!(
1082 rendered.contains("entry-size"),
1083 "the file row carries a size span"
1084 );
1085 assert!(rendered.contains("2.0 KB"), "the size is human-formatted");
1086 }
1087
1088 #[rstest]
1089 #[case::bytes(0, "0 B")]
1090 #[case::bytes_under_a_kb(1023, "1023 B")]
1091 #[case::exactly_one_kb(1024, "1.0 KB")]
1092 #[case::fractional_kb(1536, "1.5 KB")]
1093 #[case::just_under_a_mb(1_048_575, "1023.9 KB")]
1094 #[case::exactly_one_mb(1_048_576, "1.0 MB")]
1095 #[case::fractional_mb(1_572_864, "1.5 MB")]
1096 fn human_size_formats_bytes_kb_and_mb(#[case] bytes: u64, #[case] expected: &str) {
1097 assert_eq!(human_size(bytes), expected);
1098 }
1099
1100 #[test]
1101 fn blob_view_renders_markdown_as_a_heading_not_raw_markup() {
1102 let (body, _below) = blob_view(
1103 "readme.md",
1104 "readme.md",
1105 "deadbeef",
1106 &session(),
1107 b"# Title\n",
1108 &[],
1109 maud::html! {},
1110 )
1111 .expect("markdown renders");
1112 assert!(body.into_string().contains("<h1>Title</h1>"));
1113 }
1114
1115 #[test]
1116 fn blob_view_renders_asciidoc_as_a_heading_not_raw_markup() {
1117 let (body, _below) = blob_view(
1118 "readme.adoc",
1119 "readme.adoc",
1120 "deadbeef",
1121 &session(),
1122 b"= Title\n\nBody.\n",
1123 &[],
1124 maud::html! {},
1125 )
1126 .expect("asciidoc renders");
1127 assert!(body.into_string().contains("<h1>Title</h1>"));
1128 }
1129
1130 #[test]
1131 fn blob_view_escapes_plain_text_into_a_line_numbered_code_block() {
1132 let (body, _below) = blob_view(
1133 "notes.txt",
1134 "notes.txt",
1135 "deadbeef",
1136 &session(),
1137 b"1 < 2 and true",
1138 &[],
1139 maud::html! {},
1140 )
1141 .expect("plain text renders");
1142 let rendered = body.into_string();
1143 assert!(rendered.contains("blob-nums"));
1144 assert!(rendered.contains("<td class=\"blob-code\"><code>"));
1145 assert!(rendered.contains("1 &lt; 2"));
1146 }
1147
1148 #[test]
1149 fn blob_view_highlights_a_recognized_language_with_syntax_token_classes() {
1150 let (body, _below) = blob_view(
1151 "src/main.rs",
1152 "main.rs",
1153 "deadbeef",
1154 &session(),
1155 b"fn main() { let x = 1; }",
1156 &[],
1157 maud::html! {},
1158 )
1159 .expect("rust renders");
1160 let rendered = body.into_string();
1161 assert!(rendered.contains("blob-nums"));
1162 assert!(rendered.contains("class=\"code\""));
1163 assert!(rendered.contains("class=\"keyword\""));
1164 }
1165
1166 #[test]
1167 fn blob_view_shows_a_placeholder_for_binary_content() {
1168 let (body, _below) = blob_view(
1169 "data.bin",
1170 "data.bin",
1171 "deadbeef",
1172 &session(),
1173 b"\0\x01\x02binary",
1174 &[],
1175 maud::html! {},
1176 )
1177 .expect("binary placeholder renders");
1178 assert!(body.into_string().contains("Binary file"));
1179 }
1180
1181 #[test]
1182 fn blob_view_routes_a_doc_comment_below_the_blob_never_inline() {
1183 let comments = vec![comment(Some(LineRange { start: 1, end: 1 }))];
1184 let (_body, below) = blob_view(
1185 "readme.md",
1186 "readme.md",
1187 "deadbeef",
1188 &session(),
1189 b"# Title\n",
1190 &comments,
1191 maud::html! {},
1192 )
1193 .expect("markdown renders");
1194 // A doc view has no source line to interleave at: every comment,
1195 // even one with a current line range, renders in the below
1196 // section -- `comments_section`'s plain, untitled list, not
1197 // `outdated_comments_section`'s titled one.
1198 assert!(below.into_string().contains("worth a look"));
1199 }
1200
1201 #[test]
1202 fn blob_view_shows_the_header_with_line_count_size_and_language() {
1203 let (body, _below) = blob_view(
1204 "src/main.rs",
1205 "main.rs",
1206 "deadbeef",
1207 &session(),
1208 b"fn main() {}\n",
1209 &[],
1210 maud::html! {},
1211 )
1212 .expect("rust renders");
1213 let rendered = body.into_string();
1214 assert!(rendered.contains("blob-header"));
1215 assert!(rendered.contains("1 line"));
1216 assert!(rendered.contains("13 B"));
1217 assert!(rendered.contains("rust"));
1218 assert!(rendered.contains("comment on this file"));
1219 }
1220
1221 #[test]
1222 fn blob_view_carries_the_composer_hooks_on_every_view_kind() {
1223 let (body, _below) = blob_view(
1224 "src/main.rs",
1225 "main.rs",
1226 "cafef00dcafef00dcafef00dcafef00dcafef00d",
1227 &session(),
1228 b"fn main() {}\n",
1229 &[],
1230 maud::html! {},
1231 )
1232 .expect("rust renders");
1233 let rendered = body.into_string();
1234 assert!(rendered.contains("data-path=\"src/main.rs\""));
1235 assert!(rendered.contains("data-rev=\"cafef00dcafef00dcafef00dcafef00dcafef00d\""));
1236 assert!(rendered.contains("id=\"composer-template\""));
1237 assert!(rendered.contains("name=\"csrf\""));
1238 assert!(rendered.contains("test-csrf-token"));
1239 assert!(rendered.contains("name=\"path\" value=\"src/main.rs\""));
1240 assert!(
1241 rendered.contains("name=\"rev\" value=\"cafef00dcafef00dcafef00dcafef00dcafef00d\"")
1242 );
1243
1244 let (doc_body, _below) = blob_view(
1245 "readme.md",
1246 "readme.md",
1247 "deadbeef",
1248 &session(),
1249 b"# Title\n",
1250 &[],
1251 maud::html! {},
1252 )
1253 .expect("markdown renders");
1254 assert!(
1255 doc_body.into_string().contains("id=\"composer-template\""),
1256 "a doc-rendered view has no per-line gutter, but \"comment on \
1257 this file\" (empty `lines`) is exactly as meaningful there"
1258 );
1259 }
1260
1261 #[test]
1262 fn source_view_interleaves_a_comment_directly_after_its_last_line() {
1263 let comments = vec![comment(Some(LineRange { start: 1, end: 2 }))];
1264 let rendered = source_view(
1265 "src/main.rs",
1266 "deadbeef",
1267 Markup::default(),
1268 Markup::default(),
1269 "line 1\nline 2\nline 3\n",
1270 None,
1271 &comments,
1272 )
1273 .into_string();
1274 let line2 = rendered.find("id=\"L2\"").expect("line 2 renders");
1275 let card = rendered.find("comment-meta").expect("card renders");
1276 let line3 = rendered.find("id=\"L3\"").expect("line 3 renders");
1277 assert!(
1278 line2 < card && card < line3,
1279 "the card lands strictly between line 2 and line 3: {rendered}"
1280 );
1281 }
1282
1283 #[test]
1284 fn source_view_stacks_multiple_comments_ending_on_the_same_line_in_order() {
1285 let comments = vec![
1286 {
1287 let mut c = comment(Some(LineRange { start: 1, end: 1 }));
1288 c.body = html! { p { "first" } };
1289 c
1290 },
1291 {
1292 let mut c = comment(Some(LineRange { start: 1, end: 1 }));
1293 c.body = html! { p { "second" } };
1294 c
1295 },
1296 ];
1297 let rendered = source_view(
1298 "src/main.rs",
1299 "deadbeef",
1300 Markup::default(),
1301 Markup::default(),
1302 "line 1\nline 2\n",
1303 None,
1304 &comments,
1305 )
1306 .into_string();
1307 let first = rendered.find("first").expect("first comment renders");
1308 let second = rendered.find("second").expect("second comment renders");
1309 assert!(first < second, "stacked comments keep ref order");
1310 }
1311
1312 #[test]
1313 fn source_view_omits_a_comment_with_no_current_line_range() {
1314 let comments = vec![comment(None)];
1315 let rendered = source_view(
1316 "src/main.rs",
1317 "deadbeef",
1318 Markup::default(),
1319 Markup::default(),
1320 "line 1\nline 2\n",
1321 None,
1322 &comments,
1323 )
1324 .into_string();
1325 assert!(
1326 !rendered.contains("worth a look"),
1327 "a comment with no lines has nowhere to interleave -- blob_view routes it below instead"
1328 );
1329 }
1330
1331 #[test]
1332 fn child_href_nests_under_the_current_directory() {
1333 assert_eq!(child_href("", "src"), "/files/src");
1334 assert_eq!(child_href("src", "main.rs"), "/files/src/main.rs");
1335 }
1336
1337 #[test]
1338 fn tokenize_splits_spans_and_text_without_touching_entities() {
1339 let tokens = tokenize("<span class=\"keyword\">fn</span> 1 &lt; 2");
1340 assert_eq!(
1341 tokens,
1342 vec![
1343 Token::Open("keyword"),
1344 Token::Text("fn"),
1345 Token::Close,
1346 Token::Text(" 1 &lt; 2"),
1347 ]
1348 );
1349 }
1350
1351 #[test]
1352 fn split_highlighted_lines_reopens_a_span_that_crosses_a_newline() {
1353 // A three-line block comment as one span, per arborium's own
1354 // `spans_to_html` shape (see that function's own tests): one
1355 // `<span>` whose text contains embedded newlines, followed by an
1356 // unrelated keyword span on the line after.
1357 let html = "<span class=\"comment\">/*\nfoo\nbar*/</span>\n<span class=\"keyword\">fn</span> main() {}";
1358 let lines = split_highlighted_lines(html, 4);
1359 assert_eq!(
1360 lines,
1361 vec![
1362 "<span class=\"comment\">/*</span>".to_owned(),
1363 "<span class=\"comment\">foo</span>".to_owned(),
1364 "<span class=\"comment\">bar*/</span>".to_owned(),
1365 "<span class=\"keyword\">fn</span> main() {}".to_owned(),
1366 ],
1367 "each fragment is independently well-formed and still classed"
1368 );
1369 }
1370
1371 #[test]
1372 fn split_highlighted_lines_never_re_escapes_or_splits_an_entity() {
1373 let html = "<span class=\"operator\">&lt;</span>\nnext";
1374 let lines = split_highlighted_lines(html, 2);
1375 assert_eq!(
1376 lines,
1377 vec![
1378 "<span class=\"operator\">&lt;</span>".to_owned(),
1379 "next".to_owned(),
1380 ]
1381 );
1382 }
1383
1384 #[test]
1385 fn split_highlighted_lines_handles_plain_unhighlighted_text() {
1386 let lines = split_highlighted_lines("a\nb\nc", 3);
1387 assert_eq!(lines, vec!["a".to_owned(), "b".to_owned(), "c".to_owned()]);
1388 }
1389}