git-ents.gitmain
⌘K
foforge
comments.rs907 lines · 36.2 KB · rusthistorycomment on this file
1//! `GET /comments`, `GET /comments/{id}`, `POST /comments`: a custom (not
2//! generic) page family, per this crate's own top-level doc -- a
3//! comment's anchor needs projection against a live working tree
4//! (`anchor.projection`) to render meaningfully, which is exactly the
5//! kind of domain-specific view `ents-forge`'s own `comment::show`
6//! already returns structured data for, rather than a bare reflected
7//! field list.
8//!
9//! `for_path`/`comment_card`/`comments_section` are this module's
10//! second entry point: `crate::pages::files`'s blob view calls them to
11//! render the comments anchored to the file it is showing -- inline,
12//! interleaved at the anchored line, or in a below-the-blob section for
13//! one with no current line to interleave at -- rather than duplicating
14//! this module's own read-project-render pattern or its card markup.
15//! `for_commit` is a third: `crate::pages::commits::show`'s own
16//! "conversation" section, listing every comment whose anchor was captured
17//! against that exact commit (`Anchor::commit`, not a projection onto any
18//! revision -- a commit page shows what was written about that commit,
19//! not merely reachable from it).
20
21use std::sync::Arc;
22
23use axum::Form;
24use axum::extract::{Path, Query as PathQuery, State};
25use axum::response::{IntoResponse, Redirect};
26use ents_anchor::{Anchor, LineRange, Projection};
27use ents_forge::comment;
28use gix_hash::ObjectId;
29use gix_object::{Find, Write};
30use maud::{Markup, html};
31use serde::Deserialize;
32
33use crate::error::Result;
34use crate::session::Session;
35use crate::state::AppState;
36
37/// The query parameters `GET /comments` accepts: `file`/`lines`/`rev`
38/// prefill the add-comment form (e.g. a link from `crate::pages::files`'s
39/// "comment on this file", or `crate::pages::commits::show`'s "comment on
40/// this commit"), rather than changing what the page lists. All three
41/// default to empty except `rev`, which defaults to `HEAD` exactly as the
42/// add form always has -- an absent or nonsensical `file`/`lines` value
43/// (neither is ever parsed here, only echoed back into the form) is
44/// exactly as inert as an absent one.
45#[derive(Debug, Deserialize)]
46pub struct ListQuery {
47 /// Pre-fills the add form's `path` field.
48 #[serde(default)]
49 file: String,
50 /// Pre-fills the add form's `lines` field.
51 #[serde(default)]
52 lines: String,
53 /// Pre-fills the add form's `rev` field; defaults to `HEAD`.
54 #[serde(default = "default_rev_field")]
55 rev: String,
56}
57
58impl Default for ListQuery {
59 fn default() -> Self {
60 Self {
61 file: String::new(),
62 lines: String::new(),
63 rev: default_rev_field(),
64 }
65 }
66}
67
68/// `GET /comments?file=<path>&lines=<range>&rev=<rev>`.
69///
70/// # Errors
71///
72/// Propagates a ref-store or object read failure.
73pub async fn list<O>(
74 State(state): State<Arc<AppState<O>>>,
75 axum::Extension(session): axum::Extension<Session>,
76 PathQuery(query): PathQuery<ListQuery>,
77) -> Result<maud::Markup>
78where
79 O: Find + Write + Send + 'static,
80{
81 let (rows, unreadable) = comment::list_all(state.refs.as_ref(), &*state.objects())?;
82 let failures: Vec<(String, String)> = unreadable
83 .into_iter()
84 .map(|entry| (entry.refname, entry.error))
85 .collect();
86 Ok(super::layout(
87 &super::RepoHeader::from_state(&state),
88 &super::identity_label(&state),
89 super::Tab::Comments,
90 "Comments",
91 html! {
92 div.readable {
93 (crate::render::unreadable_disclosure(&failures))
94 @if rows.is_empty() {
95 (super::blankslate(
96 "No comments yet",
97 html! { "Anchor one to a file with the form below." },
98 ))
99 } @else {
100 @for (id, comment) in &rows {
101 (listing_card(&state, id, comment))
102 }
103 }
104 div.card {
105 div.card-header { "Add a comment" }
106 (add_form(&query.rev, &session, &query.file, &query.lines))
107 }
108 }
109 },
110 ))
111}
112
113/// The query parameters `GET /comments/{id}` accepts: which revision to
114/// project the anchor onto (defaults to `HEAD`).
115#[derive(Debug, Deserialize)]
116pub struct ShowQuery {
117 /// The revision to project onto; defaults to `HEAD`.
118 #[serde(default = "default_rev_field")]
119 rev: String,
120}
121
122fn default_rev_field() -> String {
123 "HEAD".to_owned()
124}
125
126/// `GET /comments/{id}?rev=...`: the comment's body, its anchor, and the
127/// projection of that anchor onto `rev` (`anchor.projection`). Its state
128/// (`model.comment-state`) and the reply/resolve/reopen actions
129/// (`action_forms`, `model.comment-thread`, `model.comment-state`) render
130/// alongside, so a comment is a conversation from its own page and not only
131/// from an issue's or a review's.
132///
133/// # Errors
134///
135/// [`crate::Error::Forge`] (wrapping [`ents_forge::Error::NotFound`]) if
136/// `id` has no comment ref at all; a comment ref whose stored tree this
137/// build cannot read back degrades to [`crate::render::unreadable`]'s
138/// marker card instead of erroring.
139pub async fn show<O>(
140 State(state): State<Arc<AppState<O>>>,
141 axum::Extension(session): axum::Extension<Session>,
142 Path(id): Path<String>,
143 PathQuery(query): PathQuery<ShowQuery>,
144) -> Result<maud::Markup>
145where
146 O: Find + Write + Send + 'static,
147{
148 let (comment, projected) = match comment::show(
149 state.refs.as_ref(),
150 &*state.objects(),
151 &state.path,
152 &id,
153 &query.rev,
154 false,
155 ) {
156 Ok(read) => read,
157 // No ref at all stays a real 404; any other failure (a tree this
158 // build's shape cannot read back, written by an older schema) is
159 // an existing entity this page degrades to the plain unreadable
160 // card for, never a 404 or a 500.
161 Err(source @ ents_forge::Error::NotFound { .. }) => return Err(source.into()),
162 Err(source) => {
163 return Ok(super::layout(
164 &super::RepoHeader::from_state(&state),
165 &super::identity_label(&state),
166 super::Tab::Comments,
167 &format!("Comment {}", ents_forge::abbreviate_id(&id)),
168 html! {
169 (super::child_crumbs("comments", "/comments", ents_forge::abbreviate_id(&id)))
170 div.readable { (crate::render::unreadable(&source.to_string())) }
171 },
172 ));
173 }
174 };
175 let resolved = comment.state == "resolved";
176 let return_to = format!("/comments/{id}");
177 let authorship = ents_model::namespace::comment_ref(&id)
178 .ok()
179 .and_then(|ref_name| state.refs.get(ref_name.as_ref()).ok().flatten())
180 .and_then(|tip| super::commit_authorship(&*state.objects(), tip).ok());
181 let body =
182 crate::asciidoc::to_html(&comment.body).unwrap_or_else(|_| html! { p { (comment.body) } });
183 Ok(super::layout(
184 &super::RepoHeader::from_state(&state),
185 &super::identity_label(&state),
186 super::Tab::Comments,
187 &format!("Comment {}", ents_forge::abbreviate_id(&id)),
188 html! {
189 (super::child_crumbs("comments", "/comments", ents_forge::abbreviate_id(&id)))
190 div.readable {
191 div.card {
192 div.comment-meta {
193 @if let Some((author, seconds)) = &authorship {
194 (super::avatar(author))
195 span.author { (author) }
196 span { (super::ago(*seconds)) }
197 }
198 @if let Some(parent) = &comment.parent {
199 a.reply href={ "/comments/" (parent) } {
200 "\u{21b3} reply to " (ents_forge::abbreviate_id(parent))
201 }
202 }
203 @if let Some(context) = &comment.context {
204 (context_link(context))
205 }
206 span.spacer {}
207 @if let Some((anchor, _)) = &projected {
208 a href={ "/files/" (anchor.path) (line_fragment(anchor.lines)) } {
209 (anchor.path) (line_label(anchor.lines))
210 }
211 (super::editor_open(&state, &anchor.path, anchor.lines.map(|range| range.start)))
212 } @else {
213 span { "unanchored" }
214 }
215 @if let Some((_, Projection::Outdated { .. })) = &projected {
216 span.outdated { "outdated" }
217 }
218 span.comment-state { (comment.state) }
219 }
220 div.comment-body { (body) }
221 @if let Some((_, projection)) = &projected {
222 div.comment-meta {
223 span { "at " (query.rev) ": " (projection_label(projection)) }
224 }
225 }
226 }
227 (action_forms(&session, &id, resolved, &return_to))
228 }
229 },
230 ))
231}
232
233/// The `#L<start>[-L<end>]` fragment a files link carries for an anchored
234/// range, or nothing for a whole-file anchor -- the same fragment shape
235/// `crate::pages::files`'s gutter anchors and `ents.js`'s hash handling
236/// use.
237fn line_fragment(lines: Option<LineRange>) -> String {
238 match lines {
239 Some(range) if range.start == range.end => format!("#L{}", range.start),
240 Some(range) => format!("#L{}-L{}", range.start, range.end),
241 None => String::new(),
242 }
243}
244
245/// The `:21` / `:21-23` suffix a path locator shows for an anchored range,
246/// or nothing for a whole-file anchor.
247fn line_label(lines: Option<LineRange>) -> String {
248 match lines {
249 Some(range) if range.start == range.end => format!(":{}", range.start),
250 Some(range) => format!(":{}-{}", range.start, range.end),
251 None => String::new(),
252 }
253}
254
255/// One human sentence for a projection result (`anchor.projection`) --
256/// never the enum's `Debug` form; the outdated case names
257/// [`Projection::label`]'s own word rather than repeating it as a literal.
258fn projection_label(projection: &Projection) -> String {
259 match projection {
260 Projection::Current => "anchored lines unchanged".to_owned(),
261 Projection::Relocated { path, lines } => {
262 format!("moved to {path}{}", line_label(*lines))
263 }
264 Projection::Outdated { path } => {
265 format!(
266 "{} \u{2014} the anchored lines in {path} have been edited",
267 projection.label()
268 )
269 }
270 Projection::Deleted => "the anchored file no longer exists".to_owned(),
271 }
272}
273
274/// A context's own page link: `issues/<id>` and `reviews/<target>/<member>`
275/// land on the issue and commit pages that render those threads
276/// (`model.comment-context`); any other context renders as plain text.
277fn context_link(context: &str) -> Markup {
278 if let Some(id) = context.strip_prefix("issues/") {
279 html! { a href={ "/issues/" (id) } { "on issue " (ents_forge::abbreviate_id(id)) } }
280 } else if let Some(rest) = context.strip_prefix("reviews/") {
281 let target = rest.split('/').next().unwrap_or(rest);
282 html! { a href={ "/commit/" (target) } { "on a review of " (ents_forge::abbreviate_id(target)) } }
283 } else {
284 html! { span { (context) } }
285 }
286}
287
288/// One `GET /comments` row: the comment's own card -- an abbreviated-id
289/// link to its page, author and age off its ref's tip commit (best
290/// effort, like [`thread_comment_card`]'s), its state badge, its context
291/// or anchor locator, and its body rendered as AsciiDoc like every other
292/// comment card in this crate.
293fn listing_card<O: Find + Write>(
294 state: &AppState<O>,
295 id: &str,
296 comment: &comment::Comment,
297) -> Markup {
298 let authorship = ents_model::namespace::comment_ref(id)
299 .ok()
300 .and_then(|ref_name| state.refs.get(ref_name.as_ref()).ok().flatten())
301 .and_then(|tip| super::commit_authorship(&*state.objects(), tip).ok());
302 let anchor = comment
303 .anchor
304 .as_ref()
305 .and_then(|raw| facet_git_tree::deserialize::<Anchor>(&raw.oid(), &*state.objects()).ok());
306 let body =
307 crate::asciidoc::to_html(&comment.body).unwrap_or_else(|_| html! { p { (comment.body) } });
308 html! {
309 div.card {
310 div.comment-meta {
311 @if let Some((author, seconds)) = &authorship {
312 (super::avatar(author))
313 span.author { (author) }
314 span { (super::ago(*seconds)) }
315 }
316 a href={ "/comments/" (id) } { (ents_forge::abbreviate_id(id)) }
317 @if comment.parent.is_some() {
318 span.reply { "\u{21b3} reply" }
319 }
320 @if let Some(context) = &comment.context {
321 (context_link(context))
322 }
323 span.spacer {}
324 @if let Some(anchor) = &anchor {
325 a href={ "/files/" (anchor.path) (line_fragment(anchor.lines)) } {
326 (anchor.path) (line_label(anchor.lines))
327 }
328 (super::editor_open(state, &anchor.path, anchor.lines.map(|range| range.start)))
329 } @else {
330 span { "unanchored" }
331 }
332 span.comment-state { (comment.state) }
333 }
334 div.comment-body { (body) }
335 }
336 }
337}
338
339/// The form fields the reply route accepts.
340#[derive(Debug, Deserialize)]
341pub struct ReplyForm {
342 /// The reply's body text.
343 body: String,
344 /// The per-session CSRF token (`roots.web-session`).
345 csrf: String,
346 /// Where to send the browser back to after the reply lands
347 /// ([`redirect_back`]) -- the issue, review, or comment page the reply
348 /// was composed on.
349 #[serde(default)]
350 return_to: String,
351}
352
353/// The form fields the resolve and reopen routes accept: a CSRF token and a
354/// return path, no body.
355#[derive(Debug, Deserialize)]
356pub struct ActionForm {
357 /// The per-session CSRF token (`roots.web-session`).
358 csrf: String,
359 /// Where to send the browser back to ([`redirect_back`]).
360 #[serde(default)]
361 return_to: String,
362}
363
364/// `POST /comments/{id}/reply`: a reply to `id` (`model.comment-thread`),
365/// signed (`roots.web-signing`) on behalf of the current session
366/// (`roots.web-session`) -- a caller of [`ents_forge::comment::reply`],
367/// never a second thread-building path.
368///
369/// # Errors
370///
371/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise
372/// propagates [`ents_forge::comment::reply`]'s own failures (including
373/// [`ents_forge::Error::NotFound`] when `id` names no comment).
374// @relation(model.comment-thread, roots.web-signing, roots.web-session, scope=function)
375pub async fn reply<O>(
376 State(state): State<Arc<AppState<O>>>,
377 axum::Extension(session): axum::Extension<Session>,
378 Path(id): Path<String>,
379 Form(form): Form<ReplyForm>,
380) -> Result<impl IntoResponse>
381where
382 O: Find + Write + Send + 'static,
383{
384 super::require_csrf(&session, &form.csrf)?;
385 let identity = state.identity.as_ref();
386 let (_reply_id, outcome) = comment::reply(
387 state.refs.as_ref(),
388 &*state.objects(),
389 state.events.as_ref(),
390 &id,
391 form.body,
392 &crate::receive_identity!(identity, crate::pages::member_author(&session)),
393 state.mode,
394 )?;
395 crate::error::outcome_to_result(outcome)?;
396 Ok(redirect_back(&form.return_to, &id))
397}
398
399/// `POST /comments/{id}/resolve`: record state `resolved` on `id`
400/// (`model.comment-state`), signed on behalf of the current session.
401///
402/// # Errors
403///
404/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise
405/// propagates [`ents_forge::comment::resolve`]'s own failures.
406// @relation(model.comment-state, roots.web-signing, roots.web-session, scope=function)
407pub async fn resolve<O>(
408 State(state): State<Arc<AppState<O>>>,
409 axum::Extension(session): axum::Extension<Session>,
410 Path(id): Path<String>,
411 Form(form): Form<ActionForm>,
412) -> Result<impl IntoResponse>
413where
414 O: Find + Write + Send + 'static,
415{
416 super::require_csrf(&session, &form.csrf)?;
417 let identity = state.identity.as_ref();
418 let outcome = comment::resolve(
419 state.refs.as_ref(),
420 &*state.objects(),
421 state.events.as_ref(),
422 &id,
423 &crate::receive_identity!(identity, crate::pages::member_author(&session)),
424 state.mode,
425 Some(&identity.public_openssh()),
426 )?;
427 crate::error::outcome_to_result(outcome)?;
428 Ok(redirect_back(&form.return_to, &id))
429}
430
431/// `POST /comments/{id}/reopen`: record state `open` on `id` again
432/// (`model.comment-state`), the way [`resolve`] records `resolved`.
433///
434/// # Errors
435///
436/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise
437/// propagates [`ents_forge::comment::reopen`]'s own failures.
438// @relation(model.comment-state, roots.web-signing, roots.web-session, scope=function)
439pub async fn reopen<O>(
440 State(state): State<Arc<AppState<O>>>,
441 axum::Extension(session): axum::Extension<Session>,
442 Path(id): Path<String>,
443 Form(form): Form<ActionForm>,
444) -> Result<impl IntoResponse>
445where
446 O: Find + Write + Send + 'static,
447{
448 super::require_csrf(&session, &form.csrf)?;
449 let identity = state.identity.as_ref();
450 let outcome = comment::reopen(
451 state.refs.as_ref(),
452 &*state.objects(),
453 state.events.as_ref(),
454 &id,
455 &crate::receive_identity!(identity, crate::pages::member_author(&session)),
456 state.mode,
457 Some(&identity.public_openssh()),
458 )?;
459 crate::error::outcome_to_result(outcome)?;
460 Ok(redirect_back(&form.return_to, &id))
461}
462
463/// Where a reply/resolve/reopen sends the browser after the mutation lands:
464/// back to `return_to` when it is a same-origin path (the issue, review, or
465/// comment page the action was taken on), or the comment's own page as a
466/// safe fallback. Only a value beginning with `/` is honored, so a crafted
467/// `return_to` can never redirect off-site.
468fn redirect_back(return_to: &str, id: &str) -> Redirect {
469 if return_to.starts_with('/') {
470 Redirect::to(return_to)
471 } else {
472 Redirect::to(&format!("/comments/{id}"))
473 }
474}
475
476/// The reply and resolve/reopen action forms every comment carries, on its
477/// own page and in an issue's or review's thread alike: a reply composer
478/// (`model.comment-thread`) and a single state toggle showing `resolve`
479/// when open or `reopen` when resolved (`model.comment-state`). `return_to`
480/// is echoed into a hidden field so [`redirect_back`] can return to
481/// whichever page rendered these forms.
482pub(crate) fn action_forms(
483 session: &Session,
484 id: &str,
485 resolved: bool,
486 return_to: &str,
487) -> maud::Markup {
488 html! {
489 div.comment-actions {
490 form method="post" action=(format!("/comments/{id}/reply")) {
491 (super::csrf_input(session))
492 input type="hidden" name="return_to" value=(return_to);
493 label { "Reply" textarea name="body" {} }
494 button type="submit" { "Reply" }
495 }
496 @if resolved {
497 form method="post" action=(format!("/comments/{id}/reopen")) {
498 (super::csrf_input(session))
499 input type="hidden" name="return_to" value=(return_to);
500 button type="submit" { "Reopen" }
501 }
502 } @else {
503 form method="post" action=(format!("/comments/{id}/resolve")) {
504 (super::csrf_input(session))
505 input type="hidden" name="return_to" value=(return_to);
506 button type="submit" { "Resolve" }
507 }
508 }
509 }
510 }
511}
512
513/// The form fields `POST /comments` accepts.
514#[derive(Debug, Deserialize)]
515pub struct AddForm {
516 /// The repository-relative path to anchor to.
517 path: String,
518 /// The comment's text.
519 body: String,
520 /// An optional `<start>[:<end>]` line range.
521 #[serde(default)]
522 lines: String,
523 /// The revision to anchor against.
524 rev: String,
525 /// The per-session CSRF token (`roots.web-session`).
526 csrf: String,
527}
528
529/// `POST /comments`: anchor `body` to `path` at `rev`, signed
530/// (`roots.web-signing`) on behalf of the current session
531/// (`roots.web-session`).
532///
533/// # Errors
534///
535/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise
536/// propagates [`ents_forge::comment::add`]'s own failures.
537// @relation(roots.web-signing, roots.web-session, scope=function)
538pub async fn add<O>(
539 State(state): State<Arc<AppState<O>>>,
540 axum::Extension(session): axum::Extension<Session>,
541 Form(form): Form<AddForm>,
542) -> Result<impl IntoResponse>
543where
544 O: Find + Write + Send + 'static,
545{
546 super::require_csrf(&session, &form.csrf)?;
547 let lines = (!form.lines.trim().is_empty()).then(|| form.lines.trim().to_owned());
548
549 let identity = state.identity.as_ref();
550 let new = ents_forge::comment::NewComment {
551 body: form.body,
552 path: Some(form.path),
553 lines,
554 rev: form.rev,
555 worktree: false,
556 context: None,
557 parent: None,
558 };
559 let (id, outcome) = comment::add(
560 state.refs.as_ref(),
561 &*state.objects(),
562 state.events.as_ref(),
563 &state.path,
564 new,
565 &crate::receive_identity!(identity, crate::pages::member_author(&session)),
566 state.mode,
567 )?;
568 crate::error::outcome_to_result(outcome)?;
569 Ok(Redirect::to(&format!("/comments/{id}")))
570}
571
572/// The add-comment form, its `path`/`lines` fields pre-filled from
573/// [`ListQuery`] when `list` was reached with `?file=`/`?lines=` (e.g.
574/// `crate::pages::files`'s "comment on this file" link) -- maud escapes
575/// both into the `value` attribute the same as any other interpolation,
576/// so neither can break out of the form markup, and an empty prefill
577/// renders exactly as the unfilled field always did.
578fn add_form(
579 default_rev: &str,
580 session: &Session,
581 prefill_path: &str,
582 prefill_lines: &str,
583) -> maud::Markup {
584 html! {
585 form method="post" action="/comments" {
586 (super::csrf_input(session))
587 label { "Path" input type="text" name="path" value=(prefill_path); }
588 label { "Rev" input type="text" name="rev" value=(default_rev); }
589 label { "Lines" input type="text" name="lines" value=(prefill_lines); }
590 label { "Body" textarea name="body" {} }
591 button type="submit" { "Comment" }
592 }
593 }
594}
595
596/// One comment as `crate::pages::files`'s blob view shows it: who wrote it
597/// and when ([`super::ago`]), where its anchor lands (a path plus a line
598/// range, when it has one to interleave at -- [`comment_card`]'s own doc),
599/// and its body rendered as AsciiDoc ([`crate::asciidoc`], this crate's
600/// default prose treatment for text with no filename of its own to infer a
601/// MIME type from). Mirrors `pre-redo:crates/git-ents-server/src/web/pages.rs`'s
602/// own `FileComment`, salvaged per this crate's PORT-and-reverify policy:
603/// author/timestamp there came from `git_comment::provenance`'s shell-out,
604/// here from [`super::commit_authorship`] reading the comment ref's own tip
605/// commit through `gix_object::Find`.
606pub(crate) struct FileComment {
607 /// The comment ref's own tip commit's author display name
608 /// (`model.comment`: a comment stores no author field of its own).
609 pub(crate) author: String,
610 /// [`super::ago`] renders this against the current time.
611 pub(crate) seconds: i64,
612 /// The repository-relative path this comment's anchor lands on: the
613 /// file [`for_path`] was called for (it filters to exactly that path),
614 /// or the anchor's own recorded path for [`for_commit`] (a commit's
615 /// conversation spans every file the commit touched, so there is no
616 /// single implied path the way a blob view has one).
617 pub(crate) path: String,
618 /// The anchored range as it lands on the displayed file at `HEAD`, or
619 /// `None` for a whole-file anchor or an outdated projection -- either
620 /// way, nothing for [`crate::pages::files`]'s blob view to interleave
621 /// the card after, so it renders in a below-the-blob section instead.
622 /// [`for_commit`] always uses the anchor's own recorded range as-is
623 /// (never projected), since a commit's conversation is about that
624 /// commit specifically, not about `HEAD`.
625 pub(crate) lines: Option<LineRange>,
626 /// Set when [`ents_anchor::project`] reports
627 /// [`Projection::Outdated`]: the anchored lines themselves were
628 /// edited, so no line link is shown, only the marker -- the comment
629 /// itself is never dropped from the page. Always `false` for
630 /// [`for_commit`]'s own rows: "outdated" is a projection-onto-`HEAD`
631 /// concept, and a commit page shows the anchor exactly as captured.
632 pub(crate) outdated: bool,
633 /// The body, rendered as AsciiDoc ([`crate::asciidoc::to_html`]),
634 /// falling back to escaped plain text on a render failure -- a file
635 /// view degrades, it never 500s over one unparsable comment.
636 pub(crate) body: Markup,
637 /// The pre-rendered open-in-editor affordance for this comment's
638 /// landing spot ([`crate::pages::editor_open`]; empty when no editor
639 /// is recognized) -- built where `state` is at hand so
640 /// [`comment_card`] stays a pure markup function.
641 pub(crate) editor: Markup,
642}
643
644/// Every comment whose anchor projects onto `path` at `HEAD` in `repo` --
645/// [`crate::pages::files`]'s own read of this domain, built on the same
646/// [`comment::list`] read [`list`] itself uses and the same
647/// [`ents_anchor::project`] call [`show`] itself uses, rather than a third
648/// way to read a comment. Best effort throughout: a comment whose anchor
649/// or body fails to read, parse, or project is skipped from this file's
650/// own view only -- it still shows up on `GET /comments` and its own `GET
651/// /comments/{id}` page -- and a projection landing anywhere other than
652/// `path` (moved elsewhere, or deleted) is likewise not this file's
653/// comment to show. A projection that still lands at `path` but comes
654/// back [`Projection::Outdated`] is the one case this function keeps and
655/// flags (`outdated: true`) rather than skips: the anchored lines
656/// changed, not the comment's relevance to this file.
657pub(crate) fn for_path<O: Find + Write>(
658 state: &AppState<O>,
659 repo: &gix::Repository,
660 path: &str,
661) -> Vec<FileComment> {
662 let Ok(rows) = comment::list(state.refs.as_ref(), &*state.objects()) else {
663 return Vec::new();
664 };
665 let mut out = Vec::new();
666 for (id, comment) in rows {
667 let Some(raw) = &comment.anchor else {
668 // An unanchored comment (context or reply aboutness only) has
669 // no line in any file to land on.
670 continue;
671 };
672 let Ok(anchor) = facet_git_tree::deserialize::<Anchor>(&raw.oid(), &*state.objects())
673 else {
674 continue;
675 };
676 let Ok(projection) = ents_anchor::project(repo, &anchor, "HEAD") else {
677 continue;
678 };
679 let (landed, lines, outdated) = match projection {
680 Projection::Current => (anchor.path.clone(), anchor.lines, false),
681 Projection::Relocated { path, lines } => (path, lines, false),
682 Projection::Outdated { path } => (path, None, true),
683 Projection::Deleted => continue,
684 };
685 if landed != path {
686 continue;
687 }
688 let Ok(ref_name) = ents_model::namespace::comment_ref(&id) else {
689 continue;
690 };
691 let Some(tip) = state.refs.get(ref_name.as_ref()).ok().flatten() else {
692 continue;
693 };
694 let Ok((author, seconds)) = super::commit_authorship(&*state.objects(), tip) else {
695 continue;
696 };
697 let body = crate::asciidoc::to_html(&comment.body)
698 .unwrap_or_else(|_| html! { p { (comment.body) } });
699 let editor = super::editor_open(state, &landed, lines.map(|range| range.start));
700 out.push(FileComment {
701 author,
702 seconds,
703 path: landed,
704 lines,
705 outdated,
706 body,
707 editor,
708 });
709 }
710 out
711}
712
713/// Every comment whose anchor was captured against `commit_id` exactly --
714/// `crate::pages::commits::show`'s own "conversation" section. Filtered by
715/// [`Anchor::commit`] (the resolved commit oid `ents_anchor::capture`
716/// records at write time), not by projecting onto any revision the way
717/// [`for_path`] does: a commit page shows what was written about that
718/// commit specifically, so an anchor is read here exactly as captured,
719/// never re-projected (`lines`/`path` mirror [`Anchor::lines`]/
720/// [`Anchor::path`] verbatim, `outdated` is always `false`). Best effort
721/// throughout, mirroring [`for_path`]'s own stance: a comment whose anchor
722/// or body fails to read or parse is skipped from this commit's own view
723/// only.
724pub(crate) fn for_commit<O: Find + Write>(
725 state: &AppState<O>,
726 commit_id: ObjectId,
727) -> Vec<FileComment> {
728 let Ok(rows) = comment::list(state.refs.as_ref(), &*state.objects()) else {
729 return Vec::new();
730 };
731 let mut out = Vec::new();
732 for (id, comment) in rows {
733 let Some(raw) = &comment.anchor else {
734 continue;
735 };
736 let Ok(anchor) = facet_git_tree::deserialize::<Anchor>(&raw.oid(), &*state.objects())
737 else {
738 continue;
739 };
740 if anchor.commit() != commit_id {
741 continue;
742 }
743 let Ok(ref_name) = ents_model::namespace::comment_ref(&id) else {
744 continue;
745 };
746 let Some(tip) = state.refs.get(ref_name.as_ref()).ok().flatten() else {
747 continue;
748 };
749 let Ok((author, seconds)) = super::commit_authorship(&*state.objects(), tip) else {
750 continue;
751 };
752 let body = crate::asciidoc::to_html(&comment.body)
753 .unwrap_or_else(|_| html! { p { (comment.body) } });
754 let editor = super::editor_open(state, &anchor.path, anchor.lines.map(|range| range.start));
755 out.push(FileComment {
756 author,
757 seconds,
758 path: anchor.path.clone(),
759 lines: anchor.lines,
760 outdated: false,
761 body,
762 editor,
763 });
764 }
765 out
766}
767
768/// Where a [`FileComment`]'s line-range link points -- [`comment_card`]'s
769/// own mode switch between the pages that render one.
770#[derive(Clone, Copy, PartialEq, Eq)]
771pub(crate) enum LinkMode {
772 /// The comment renders on the same page as the file it anchors to
773 /// (`crate::pages::files`'s blob view, whether interleaved at its own
774 /// line or in the below-the-blob section): the link is an in-page
775 /// fragment (`#L<n>`), labeled just the line range -- the path is
776 /// implied by the page itself.
777 SameFile,
778 /// The comment renders on a page about something else
779 /// (`crate::pages::commits::show`'s "conversation" section, which can
780 /// span several files): the link crosses into the file browser
781 /// (`/files/<path>#L<n>`), labeled with the path so the reader knows
782 /// where it lands.
783 CrossFile,
784}
785
786/// One comment's card: author, [`super::ago`] time, its line-range link
787/// (per `link`'s [`LinkMode`]) or the muted `outdated` marker, and its body
788/// -- the single rendering every comment-showing page in this crate shares
789/// ([`comments_section`]'s below-the-blob list, `crate::pages::files`'s own
790/// inline-interleaved rows, `crate::pages::commits::show`'s "conversation"
791/// section), so a comment's markup is defined in exactly one place. `index`
792/// names this card's `id="comment-<index>"` anchor, stable within
793/// whichever page rendered it (not a global id): `crate::pages::files`'s
794/// crumbs "N comments" jump link targets `comment-0`, the first comment in
795/// display order, regardless of whether it landed inline or below the
796/// blob.
797pub(crate) fn comment_card(index: usize, comment: &FileComment, link: LinkMode) -> Markup {
798 html! {
799 div.card id={ "comment-" (index) } {
800 div.comment-meta {
801 (super::avatar(&comment.author))
802 span.author { (comment.author) }
803 span { (super::ago(comment.seconds)) }
804 span.spacer {}
805 @if let Some(range) = comment.lines {
806 @match link {
807 LinkMode::SameFile => {
808 a href={ "#L" (range.start) } {
809 @if range.start == range.end { "line " (range.start) }
810 @else { "lines " (range.start) "-" (range.end) }
811 }
812 }
813 LinkMode::CrossFile => {
814 a href={ "/files/" (comment.path) "#L" (range.start) } {
815 (comment.path) "#L" (range.start)
816 @if range.start != range.end { "-" (range.end) }
817 }
818 }
819 }
820 }
821 (comment.editor)
822 @if comment.outdated {
823 span.outdated { "outdated" }
824 }
825 }
826 div.comment-body { (comment.body) }
827 }
828 }
829}
830
831/// One comment in an entity's discussion thread -- an issue's
832/// (`crate::pages::issues::show`) or a review's
833/// (`crate::pages::commits::show`) -- rendered from an aggregation query
834/// (`comment::thread`, `model.comment-context`), never a list any entity
835/// stores. Author and time come from the comment ref's own tip commit
836/// (`super::commit_authorship`, `model.comment`: no stored author field),
837/// its state (`model.comment-state`) shows as a badge, its body renders as
838/// AsciiDoc, and it carries the same [`action_forms`] every comment does,
839/// with `return_to` pointing back at the entity page rendering it so a
840/// reply or resolve returns there. Best effort: a comment whose tip commit
841/// cannot be read still renders, only without an author line.
842pub(crate) fn thread_comment_card<O: Find + Write>(
843 state: &AppState<O>,
844 session: &Session,
845 id: &str,
846 comment: &ents_forge::comment::Comment,
847 return_to: &str,
848) -> Markup {
849 let authorship = ents_model::namespace::comment_ref(id)
850 .ok()
851 .and_then(|ref_name| state.refs.get(ref_name.as_ref()).ok().flatten())
852 .and_then(|tip| super::commit_authorship(&*state.objects(), tip).ok());
853 let body =
854 crate::asciidoc::to_html(&comment.body).unwrap_or_else(|_| html! { p { (comment.body) } });
855 html! {
856 div.card id={ "thread-" (id) } {
857 div.comment-meta {
858 @if let Some((author, seconds)) = &authorship {
859 (super::avatar(author))
860 span.author { (author) }
861 span { (super::ago(*seconds)) }
862 }
863 @if comment.parent.is_some() {
864 span.reply { "\u{21b3} reply" }
865 }
866 span.spacer {}
867 span.comment-state { (comment.state) }
868 }
869 div.comment-body { (body) }
870 (action_forms(session, id, comment.state == "resolved", return_to))
871 }
872 }
873}
874
875/// An entity's whole discussion thread as a stack of [`thread_comment_card`]s
876/// (`model.comment-context`, `model.comment-thread`) -- what
877/// `crate::pages::issues::show` and `crate::pages::commits::show` render a
878/// `comment::thread` result through. Renders nothing when the thread is
879/// empty.
880pub(crate) fn thread_section<O: Find + Write>(
881 state: &AppState<O>,
882 session: &Session,
883 thread: &[(String, ents_forge::comment::Comment)],
884 return_to: &str,
885) -> Markup {
886 html! {
887 @for (id, comment) in thread {
888 (thread_comment_card(state, session, id, comment, return_to))
889 }
890 }
891}
892
893/// The comment cards under a blob view (a rendered document, a binary
894/// placeholder, or -- for a raw-source view -- the ones with no current
895/// line range to interleave at; see `crate::pages::files::source_view`),
896/// one [`comment_card`] per entry (in [`LinkMode::SameFile`]). Renders
897/// nothing at all -- not even an empty container -- when `comments` is
898/// empty, so a file with no comments carries no extra markup
899/// (`crate::pages::files`'s own blob view calls this unconditionally
900/// rather than checking first).
901pub(crate) fn comments_section(comments: &[FileComment]) -> Markup {
902 html! {
903 @for (index, comment) in comments.iter().enumerate() {
904 (comment_card(index, comment, LinkMode::SameFile))
905 }
906 }
907}