feat: comment from the web and toggle doc blobs between rendered and source
commit
44a6485feat: comment from the web and toggle doc blobs between rendered and source
A signed-in member gets an add-comment form under both file views; the comment anchors to HEAD and lands on refs/meta/comments/<id> through the same signed_edit push settings use, with no admin-registered gate since issues and comments are exactly what self-attested members may write. Prose-format blobs gain a chip switching the rendered document and its highlighted source.
feat: add POST /{repo}/comment landing anchored comments via signed_edit
feat: add /{repo}/source/<path> and a rendered/source toggle on doc blobs
Assisted-by: Claude:claude-fable-5
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
crates/git-ents-server/src/web/mod.rs
@@ -156,10 +156,11 @@
let Some((repo, rel, rest)) = resolve_repo(&state.data_dir, &segments) else {
return not_found().into_response();
};
- if rest != ["settings"] {
- return not_found().into_response();
+ match rest {
+ ["settings"] => save_settings(state, &repo, &rel, cookie, body).await,
+ ["comment"] => save_comment(state, &repo, &rel, cookie, body).await,
+ _ => not_found().into_response(),
}
- save_settings(state, &repo, &rel, cookie, body).await
}
/// Whether this server can actually land browser edits: it needs the signed-push
@@ -195,7 +196,7 @@
state.web_signing_key.clone(),
) else {
return edit_error(
- rel,
+ &format!("/{rel}/settings"),
"Editing is disabled: this server has no web signing key or signed-push gate.",
)
.into_response();
@@ -205,8 +206,11 @@
cookie,
&write::field(&body, "csrf").unwrap_or_default(),
) {
- return edit_error(rel, "the edit could not be verified; reload and try again")
- .into_response();
+ return edit_error(
+ &format!("/{rel}/settings"),
+ "the edit could not be verified; reload and try again",
+ )
+ .into_response();
}
let edit = write::ConfigEdit {
description: write::field(&body, "description").unwrap_or_default(),
@@ -236,10 +240,84 @@
})
.await;
+ let back = format!("/{rel}/settings");
match result {
- Ok(Ok(())) => redirect(&format!("/{rel}/settings"), None),
- Ok(Err(error)) => edit_error(rel, &error).into_response(),
- Err(_join) => edit_error(rel, "the edit did not complete").into_response(),
+ Ok(Ok(())) => redirect(&back, None),
+ Ok(Err(error)) => edit_error(&back, &error).into_response(),
+ Err(_join) => edit_error(&back, "the edit did not complete").into_response(),
+ }
+}
+
+/// Record a code comment posted from a file view, then redirect back to that
+/// file on success or render the reason it was rejected.
+async fn save_comment(
+ state: &AppState,
+ repo: &Path,
+ rel: &str,
+ cookie: Option<&str>,
+ body: Bytes,
+) -> Response {
+ let path = write::field(&body, "path").unwrap_or_default();
+ let back = format!("/{rel}/blob/{path}");
+ let (Some(seed), Some(hooks), Some(signing_key)) = (
+ state.cert_nonce_seed.clone(),
+ state.hooks_dir.clone(),
+ state.web_signing_key.clone(),
+ ) else {
+ return edit_error(
+ &back,
+ "Editing is disabled: this server has no web signing key or signed-push gate.",
+ )
+ .into_response();
+ };
+ if !write::csrf_ok(
+ &state.sessions,
+ cookie,
+ &write::field(&body, "csrf").unwrap_or_default(),
+ ) {
+ return edit_error(
+ &back,
+ "the comment could not be verified; reload and try again",
+ )
+ .into_response();
+ }
+ let lines = match write::parse_lines(&write::field(&body, "lines").unwrap_or_default()) {
+ Ok(lines) => lines,
+ Err(error) => return edit_error(&back, &error).into_response(),
+ };
+ let text = write::field(&body, "body")
+ .unwrap_or_default()
+ .trim()
+ .to_owned();
+ if path.is_empty() || text.is_empty() {
+ return edit_error(&back, "a comment needs a file path and a body").into_response();
+ }
+ let edit = write::CommentEdit {
+ path,
+ lines,
+ body: text,
+ };
+
+ let sessions = state.sessions.clone();
+ let cookie = cookie.map(str::to_owned);
+ let repo = repo.to_owned();
+ let result = tokio::task::spawn_blocking(move || {
+ write::add_comment(
+ &sessions,
+ cookie.as_deref(),
+ &repo,
+ &edit,
+ &seed,
+ &hooks,
+ &signing_key,
+ )
+ })
+ .await;
+
+ match result {
+ Ok(Ok(())) => redirect(&back, None),
+ Ok(Err(error)) => edit_error(&back, &error).into_response(),
+ Err(_join) => edit_error(&back, "the comment did not complete").into_response(),
}
}
@@ -288,9 +366,35 @@
let meta = gather_meta(repo, rel).await;
match rest.split_first() {
None => pages::repo_page(repo, &meta, host).await.into_response(),
- Some((&"files", sub)) => pages::files_page(repo, &meta, sub).await,
+ Some((&"files", sub)) => {
+ let auth = resolve_auth(repo, session).await;
+ pages::files_page(repo, &meta, sub, auth.as_ref(), editing).await
+ }
Some((&"tree", sub)) => pages::tree_page(repo, &meta, sub).await,
- Some((&"blob", sub)) => pages::blob_page(repo, &meta, sub).await,
+ Some((&"blob", sub)) => {
+ let auth = resolve_auth(repo, session).await;
+ pages::blob_page(
+ repo,
+ &meta,
+ sub,
+ auth.as_ref(),
+ editing,
+ pages::BlobView::Rendered,
+ )
+ .await
+ }
+ Some((&"source", sub)) => {
+ let auth = resolve_auth(repo, session).await;
+ pages::blob_page(
+ repo,
+ &meta,
+ sub,
+ auth.as_ref(),
+ editing,
+ pages::BlobView::Source,
+ )
+ .await
+ }
Some((&"commit", &[sha])) => pages::commit_page(repo, &meta, sha).await,
Some((&"releases", &[])) => pages::releases_page(repo, &meta).await.into_response(),
Some((&"checks", &[])) => pages::checks_page(repo, &meta).await.into_response(),
@@ -598,14 +702,15 @@
)
}
-/// A page reporting why a settings edit was rejected, with a way back.
-fn edit_error(rel: &str, error: &str) -> Markup {
+/// A page reporting why a browser write was rejected, with a way back to the
+/// page it was posted from.
+fn edit_error(back: &str, error: &str) -> Markup {
page(
"Edit rejected",
html! {
div.page-header { h1.page-title { "Edit rejected" } }
div.card-row.muted { (error) }
- p { a.btn href={ "/" (rel) "/settings" } { "Back to settings" } }
+ p { a.btn href=(back) { "Back" } }
},
)
}
crates/git-ents-server/src/web/pages.rs
@@ -268,7 +268,13 @@
/// client JavaScript, expanding a folder or opening a file is a link to
/// `/<repo>/files/<path>`; the tree is rendered already expanded along the
/// selected path.
-pub(super) async fn files_page(repo: &Path, meta: &RepoMeta, sub: &[&str]) -> Response {
+pub(super) async fn files_page(
+ repo: &Path,
+ meta: &RepoMeta,
+ sub: &[&str],
+ auth: Option<&super::Auth>,
+ editing: bool,
+) -> Response {
let rel = &meta.rel;
let Some(selected) = browse_path(sub) else {
return not_found().into_response();
@@ -320,7 +326,7 @@
Some(path) => {
let pane = blob_pane(repo, path).await;
let comments = file_comments(repo, path).await;
- html! { (pane) (comments_card(&comments)) }
+ html! { (pane) (comments_card(&comments, comment_form(rel, path, auth, editing))) }
}
None => html! {
div.files-empty {
@@ -502,9 +508,27 @@
.into_response()
}
-/// A single file's contents at `sub`, syntax-highlighted when the language is
-/// recognized and the file is text.
-pub(super) async fn blob_page(repo: &Path, meta: &RepoMeta, sub: &[&str]) -> Response {
+/// Which form of a blob the blob route shows: prose formats (AsciiDoc,
+/// Markdown) rendered as a document, or the underlying source.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(super) enum BlobView {
+ Rendered,
+ Source,
+}
+
+/// A single file's contents at `sub`. A prose format renders as a formatted
+/// document under [`BlobView::Rendered`] and as its source under
+/// [`BlobView::Source`], with a toggle between the two; everything else is
+/// syntax-highlighted source when the language is recognized and the file is
+/// text.
+pub(super) async fn blob_page(
+ repo: &Path,
+ meta: &RepoMeta,
+ sub: &[&str],
+ auth: Option<&super::Auth>,
+ editing: bool,
+ view: BlobView,
+) -> Response {
let rel = &meta.rel;
let Some(path) = browse_path(sub).filter(|p| !p.is_empty()) else {
return not_found().into_response();
@@ -523,15 +547,19 @@
return not_found().into_response();
};
let name = path.rsplit('/').next().unwrap_or(&path);
+ let displayable = !truncated && !is_binary(&bytes);
let body = if truncated {
html! { div.blob { div.binary { "File too large to display (over " (human_size(MAX_RENDER_BYTES)) ")." } } }
- } else if is_binary(&bytes) {
+ } else if !displayable {
html! { div.blob { div.binary { "Binary file (" (human_size(bytes.len())) ") not shown." } } }
} else {
let text = String::from_utf8_lossy(&bytes);
- match doc_html(name, &text) {
- Some(html) => html! { div.card { article.adoc-body { (PreEscaped(html)) } } },
- None => blob_body(name, &text),
+ match view {
+ BlobView::Rendered => match doc_html(name, &text) {
+ Some(html) => html! { div.card { article.adoc-body { (PreEscaped(html)) } } },
+ None => blob_body(name, &text),
+ },
+ BlobView::Source => blob_body(name, &text),
}
};
let comments = file_comments(repo, &path).await;
@@ -541,13 +569,27 @@
name,
html! {
(crumbs(rel, &path, true))
+ @if displayable && is_doc(name) {
+ div.view-toggle {
+ @match view {
+ BlobView::Rendered => a.chip href={ "/" (rel) "/source/" (path) } { "View source" },
+ BlobView::Source => a.chip href={ "/" (rel) "/blob/" (path) } { "View rendered" },
+ }
+ }
+ }
(body)
- (comments_card(&comments))
+ (comments_card(&comments, comment_form(rel, &path, auth, editing)))
},
)
.into_response()
}
+/// Whether `name` is a prose format the forge renders as a document, and so
+/// gets the rendered/source toggle.
+fn is_doc(name: &str) -> bool {
+ crate::asciidoc::is_asciidoc(name) || crate::markdown::is_markdown(name)
+}
+
/// Render text file `source` with a line-number gutter — each number a
/// self-linking `#L<n>` anchor — highlighting via `arborium` when the filename
/// maps to a known grammar.
@@ -645,11 +687,12 @@
.unwrap_or_default()
}
-/// The Comments card under a file view, or nothing when the file has none.
-/// A line-anchored comment links its range to the gutter's `#L<n>` anchors;
-/// an outdated one is flagged instead, since its lines no longer exist.
-fn comments_card(comments: &[FileComment]) -> Markup {
- if comments.is_empty() {
+/// The Comments card under a file view: existing comments, then the add form
+/// when the viewer may comment; nothing when there are neither. A
+/// line-anchored comment links its range to the gutter's `#L<n>` anchors; an
+/// outdated one is flagged instead, since its lines no longer exist.
+fn comments_card(comments: &[FileComment], form: Option<Markup>) -> Markup {
+ if comments.is_empty() && form.is_none() {
return html! {};
}
html! {
@@ -671,10 +714,36 @@
p.comment-body { (comment.body) }
}
}
+ @if let Some(form) = form { (form) }
}
}
}
+/// The add-comment form under a file view, shown when a signed-in member views
+/// a server that can land edits; `None` otherwise, since a submit would only
+/// fail. The comment anchors to `HEAD`'s blob at `path`.
+fn comment_form(
+ rel: &str,
+ path: &str,
+ auth: Option<&super::Auth>,
+ editing: bool,
+) -> Option<Markup> {
+ let auth = auth.filter(|a| editing && a.username.is_some())?;
+ Some(html! {
+ div.comment-row {
+ form.edit-form method="post" action={ "/" (rel) "/comment" } {
+ input type="hidden" name="csrf" value=(auth.csrf);
+ input type="hidden" name="path" value=(path);
+ label { "Lines" }
+ input type="text" name="lines" placeholder="12 or 12:15 — empty for the whole file";
+ label { "Comment" }
+ textarea name="body" rows="3" placeholder="Anchored to this file as of the current HEAD" {}
+ button.btn type="submit" { "Comment" }
+ }
+ }
+ })
+}
+
/// A single commit: its metadata and a colorized unified diff.
pub(super) async fn commit_page(repo: &Path, meta: &RepoMeta, sha: &str) -> Response {
if sha.is_empty() || sha.len() > 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
crates/git-ents-server/src/web/style.css
@@ -228,6 +228,10 @@
.blob-code code { display: block; font-family: inherit; padding: 0 1.25rem; white-space: pre; color: var(--color-text); }
.binary { padding: 2.5rem; text-align: center; font-family: var(--font-mono); font-size: .85rem; color: var(--color-text-muted); }
+.view-toggle { display: flex; justify-content: flex-end; margin-bottom: .6rem; }
+.view-toggle a.chip { cursor: pointer; text-decoration: none; }
+.view-toggle a.chip:hover { color: var(--color-accent); border-color: var(--color-accent); }
+
.file-comments { margin-bottom: 1.5rem; }
.comment-row { padding: .8rem 1.1rem; border-bottom: 1px solid var(--color-border); }
.comment-row:last-child { border-bottom: 0; }
crates/git-ents-server/src/web/write.rs
@@ -64,6 +64,13 @@
pub(super) topics: Vec<String>,
}
+/// The fields of a new code comment posted from a file view.
+pub(super) struct CommentEdit {
+ pub(super) path: String,
+ pub(super) lines: Option<git_anchor::LineRange>,
+ pub(super) body: String,
+}
+
/// Create an empty session table.
pub(crate) fn new_sessions() -> Sessions {
Arc::new(Mutex::new(HashMap::new()))
@@ -208,19 +215,8 @@
hooks: &Path,
signing_key: &Path,
) -> Result<(), String> {
- let token = cookie
- .and_then(token)
+ let public_key = session_public_key(sessions, cookie)
.ok_or_else(|| "sign in to edit settings".to_owned())?;
- let public_key = {
- let table = sessions
- .lock()
- .map_err(|_poisoned| "session store unavailable".to_owned())?;
- table
- .get(&token)
- .ok_or_else(|| "sign in to edit settings".to_owned())?
- .public_key
- .clone()
- };
let store = git_store::Store::open(repo).map_err(|e| format!("cannot open store: {e}"))?;
let username = member_for_public_key_with(&store, &public_key)
@@ -245,6 +241,74 @@
)
}
+/// Land a new code comment on `refs/meta/comments/<id>`, anchored to `HEAD`'s
+/// blob at the commented path. Any signed-in member may comment — including a
+/// self-attested web member, whose allowed writes are exactly issues and
+/// comments — so there is no [`require_admin_registered`] gate here.
+pub(super) fn add_comment(
+ sessions: &Sessions,
+ cookie: Option<&str>,
+ repo: &Path,
+ edit: &CommentEdit,
+ seed: &str,
+ hooks: &Path,
+ signing_key: &Path,
+) -> Result<(), String> {
+ let public_key =
+ session_public_key(sessions, cookie).ok_or_else(|| "sign in to comment".to_owned())?;
+ let store = git_store::Store::open(repo).map_err(|e| format!("cannot open store: {e}"))?;
+ let username = member_for_public_key_with(&store, &public_key)
+ .ok_or_else(|| "your web key is not a member of this repository".to_owned())?;
+
+ let anchor = git_anchor::capture(repo, "HEAD", &edit.path, edit.lines)
+ .map_err(|e| format!("could not anchor the comment: {e}"))?;
+ let comment = git_comment::Comment {
+ body: edit.body.clone(),
+ anchor,
+ issue: None,
+ };
+ let id = git_comment::new_id(None, &comment)
+ .map_err(|e| format!("could not derive the comment id: {e}"))?;
+ let target = format!("{}/{id}", git_comment::COMMENTS_NS);
+ signed_edit(
+ repo,
+ &target,
+ &comment,
+ "Add comment",
+ &username,
+ signing_key,
+ seed,
+ hooks,
+ )
+}
+
+/// Parse an optional `<start>[:<end>]` line-range form field; empty means a
+/// whole-file comment.
+pub(super) fn parse_lines(field: &str) -> Result<Option<git_anchor::LineRange>, String> {
+ let field = field.trim();
+ if field.is_empty() {
+ return Ok(None);
+ }
+ let (start, end) = field.split_once(':').unwrap_or((field, field));
+ let parse = |number: &str| {
+ number
+ .trim()
+ .parse::<u64>()
+ .map_err(|_error| format!("invalid line number {number:?}"))
+ };
+ Ok(Some(git_anchor::LineRange {
+ start: parse(start)?,
+ end: parse(end)?,
+ }))
+}
+
+/// The signed-in session's public key, when `cookie` names a live session.
+fn session_public_key(sessions: &Sessions, cookie: Option<&str>) -> Option<String> {
+ let token = cookie.and_then(token)?;
+ let table = sessions.lock().ok()?;
+ Some(table.get(&token)?.public_key.clone())
+}
+
/// Land `value` onto `target_ref` as a real `git push --signed`, authored by
/// `username` and signed with the server's own `signing_key`, through the
/// same `pre-receive` gate a CLI push traverses — the one landing operation