Merge main into worktree-agent-sessions
commit
7629f1aMerge main into worktree-agent-sessions
Brings the Reviews/Commits rail split, git ents bootstrap, signed-push
restore, and the workbench polish under the agent-sessions branch.
Resolutions: union of the two appended ents.js IIFEs (agent chat
popup composers), rail docs and tests cover both Reviews and Agents,
agents pages pass the new path_title argument to layout_split. Full
workspace green: 829/829 (main’s cfd7165 also fixed the previously
failing serve_identity_chip test).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
.dockerignore
@@ -1,6 +1,10 @@
-# The Dockerfile only ever COPYs docker/*; ignore everything else so the
-# build context stays a few KB instead of the whole workspace (including
-# target/, which alone can run into the tens of GB).
+# The Dockerfile's builder stage compiles the workspace, so the build
+# context needs the actual sources — but never `target/`, which alone can
+# run into the tens of GB and would balloon every build upload.
*
+!Cargo.toml
+!Cargo.lock
+!crates/
+!crates/**
!docker/
!docker/**
.gitignore
@@ -1,5 +1,2 @@
.DS_Store
target/
-# Materialized from refs/meta/releases/<sha> just before `docker build`;
-# never a normal tracked file.
-docker/bin/
Dockerfile
@@ -4,11 +4,24 @@
# nginx+fcgiwrap) invokes its `pre-receive`/`post-receive` hooks. No
# Postgres/Tigris/gix-receive here — that is `git-ents-server`, phase 8.
#
-# No Rust toolchain, no cargo build, in this image: `docker/bin/git-ents` is
-# a musl static binary cross-compiled on the host (`cargo zigbuild --target
-# x86_64-unknown-linux-musl`) and materialized here from the on-disk blob
-# recorded at `refs/meta/releases/<source-commit-sha>` — never a normal
-# tracked file on `refs/heads` (see `.gitignore`).
+# The binary builds *in* this image, from the same source tree the rest
+# of the deploy comes from — never a separately cross-compiled artifact
+# copied in by hand. That used to be `docker/bin/git-ents`, a musl binary
+# built on the host and materialized here before `docker build`; the
+# whole point of that indirection was to skip a slow in-container Rust
+# build, but it silently deployed stale code whenever someone forgot the
+# manual rebuild step, exactly the failure mode a deploy pipeline exists
+# to prevent.
+FROM rust:1-slim-bookworm AS builder
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends musl-tools \
+ && rm -rf /var/lib/apt/lists/*
+RUN rustup target add x86_64-unknown-linux-musl
+WORKDIR /src
+COPY Cargo.toml Cargo.lock ./
+COPY crates crates
+RUN cargo build --release --locked --target x86_64-unknown-linux-musl -p git-ents
+
FROM debian:bookworm-slim AS runtime
WORKDIR /app
# git: the bare repo + git-http-backend CGI itself.
@@ -26,7 +39,7 @@
RUN curl -fsSL https://sprites.dev/install.sh \
| env SPRITE_INSTALL_PREFERRED_DIRS=/usr/local/bin \
SPRITE_INSTALL_DEFAULT_BIN_DIR=/usr/local/bin bash
-COPY docker/bin/git-ents /usr/local/bin/git-ents
+COPY --from=builder /src/target/x86_64-unknown-linux-musl/release/git-ents /usr/local/bin/git-ents
RUN chmod +x /usr/local/bin/git-ents
COPY docker/nginx.conf /etc/git-ents/nginx.conf
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
docker/entrypoint.sh
@@ -10,7 +10,7 @@
# upgrade path if either process starts crash-looping.
set -eu
-repo=/data/repo.git
+repo=/data/git-ents/git-ents.git
key=/data/hosted_signing_key
public_host="${PUBLIC_HOST:-git.ents.cloud}"
@@ -34,6 +34,16 @@
# and the ssh path an operator needs to *do* the enrolling — down in a
# crash loop. The web surface stays fail-closed (nothing listens on 4880
# until enrollment succeeds); the enroll command prints every attempt.
+#
+# Fresh-volume runbook: enrollment is deliberately NOT automated here.
+# Auto-enrolling would spend the self-admitting first push
+# (`gate.bootstrap`) on the server's own key, making the machine the
+# trust root instead of the operator. From a local clone, one command —
+# it enrolls the operator (signed by `user.signingkey`), then vouches
+# for the server key, discovered from nginx's /.ents/server-key (its
+# private half never leaves this volume):
+# git ents bootstrap <you>
+# The retry below then admits the web UI within one 15s cycle.
(
until git-ents serve --hosted --key "$key" --public-host "$public_host" \
--port 4880 "$repo"; do
docker/nginx.conf
@@ -12,10 +12,13 @@
server {
listen 8080;
- # git smart-HTTP: any *.git path goes to stock git's own
- # http-backend (`roots.single-node-hosted`: stock git stays the
- # one git transport; the web process adds none of its own).
- location ~ ^/[^/]+\.git(/.*)?$ {
+ # git smart-HTTP for the single hosted repository, addressed the
+ # same way any GitHub-style remote is (`git-ents/git-ents.git`)
+ # rather than a bespoke `/repo.git` — still exactly one
+ # repository (`roots.single-node-hosted`: stock git stays the
+ # one git transport; the web process adds none of its own),
+ # just no longer named like it might be more than one.
+ location ~ ^/git-ents/git-ents\.git(/.*)?$ {
# git push can be large; never buffer it into a temp file.
client_max_body_size 0;
gzip off;
@@ -33,6 +36,34 @@
fastcgi_param PATH "/usr/local/bin:/usr/bin:/bin";
}
+ # A clone URL without the trailing `.git`
+ # (`git clone https://git.ents.cloud/git-ents/git-ents`) redirects
+ # to the canonical `.git` path above. git's http client re-issues
+ # every request of the clone/fetch/push against the redirected
+ # base, not just this first one, so one redirect here is enough.
+ #
+ # Hardcoded `https://`, not `$scheme`: Fly's edge terminates TLS
+ # and always forwards plain HTTP to this app (`force_https=true`
+ # in fly.toml already guarantees no real client reaches here over
+ # HTTP), so `$scheme` as nginx sees it is always `http` and would
+ # downgrade every redirected client to plaintext.
+ location = /git-ents/git-ents {
+ return 301 https://$host/git-ents/git-ents.git;
+ }
+ location ~ ^/git-ents/git-ents/(.*)$ {
+ return 301 https://$host/git-ents/git-ents.git/$1$is_args$args;
+ }
+
+ # The server key's public half (`git ents setup --hosted` writes
+ # `<key>.pub`): served by nginx itself, not the web process, so
+ # `git ents bootstrap` can discover the identity to vouch for
+ # during the exact window the web UI is still fail-closed
+ # awaiting that enrollment (`roots.web-signing`).
+ location = /.ents/server-key {
+ default_type text/plain;
+ alias /data/hosted_signing_key.pub;
+ }
+
# Everything else: the hosted web UI (`git ents serve --hosted`),
# loopback-only inside this machine — nginx is the sole external
# listener.
crates/cli/ents-web/src/error.rs
@@ -170,6 +170,25 @@
}
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
- (status, self.to_string()).into_response()
+ let reason = status.canonical_reason().unwrap_or("Error");
+ let body = maud::html! {
+ (maud::DOCTYPE)
+ html lang="en" {
+ head {
+ meta charset="utf-8";
+ meta name="viewport" content="width=device-width, initial-scale=1";
+ meta name="color-scheme" content="light dark";
+ title { "git ents: " (status.as_u16()) " " (reason) }
+ link rel="stylesheet" href="/style.css";
+ }
+ body {
+ main.content {
+ div.page-header { h1.page-title { (status.as_u16()) " " (reason) } }
+ div.blankslate { h2 { (reason) } p { (self.to_string()) } }
+ }
+ }
+ }
+ };
+ (status, body).into_response()
}
}
crates/cli/ents-web/src/router.rs
@@ -69,10 +69,12 @@
.route("/commits", get(pages::commits::list::<O>))
.route("/commit/{oid}", get(pages::commits::show::<O>))
.route("/commit/{oid}/review", post(pages::commits::review::<O>))
+ .route("/commit/{oid}/comment", post(pages::commits::comment::<O>))
.route(
"/reviews/{target}/{member}/comment",
post(pages::commits::review_comment::<O>),
)
+ .route("/reviews", get(pages::reviews::list::<O>))
.route("/files", get(pages::files::root::<O>))
.route("/files/{*path}", get(pages::files::show::<O>))
.route("/meta", get(pages::meta::show::<O>))
crates/cli/ents-web/tests/router.rs
@@ -1094,6 +1094,7 @@
"/",
"/files",
"/commits",
+ "/reviews",
"/issues",
"/agents",
"/comments",
@@ -1105,7 +1106,9 @@
"the rail links {href}"
);
}
- for label in ["Dashboard", "Code", "Review", "Issues", "Agents", "Threads"] {
+ for label in [
+ "Dashboard", "Code", "Commits", "Reviews", "Issues", "Agents", "Threads",
+ ] {
assert!(
overview.contains(&format!("title=\"{label}\"")),
"the rail tooltips {label}"
@@ -1744,12 +1747,13 @@
);
}
-/// A doc-rendered (Markdown) blob view carries no composer template at
-/// all: there is no source line row for `assets/ents.js` to anchor an
-/// inline composer after, so `ents_web::pages::files::blob_view` never
-/// renders one for this view kind.
+/// A doc-rendered (Markdown) blob view still carries the whole-file
+/// composer template: there is no per-line gutter row for `assets/ents.js`
+/// to open one against a specific line range, but "comment on this file"
+/// (the template's own empty `lines` default) is exactly as meaningful on
+/// a rendered document as on a raw-source view.
#[tokio::test]
-async fn files_markdown_blob_view_has_no_composer_template() {
+async fn files_markdown_blob_view_carries_the_composer_template() {
let dir = seed_repo(&[("docs/x.md", "# Doc Title\n\nSome text.\n")]);
let state = build_state_at(
FixtureIdentity {
@@ -1777,8 +1781,9 @@
.to_bytes();
let body = String::from_utf8(body.to_vec()).expect("utf8 html");
assert!(
- !body.contains("composer-template"),
- "a doc-rendered view has no source line to anchor a composer to"
+ body.contains("id=\"composer-template\""),
+ "a doc-rendered view has no per-line gutter, but \"comment on this \
+ file\" (empty `lines`) is exactly as meaningful there"
);
}
crates/cli/git-ents/src/cli.rs
@@ -61,6 +61,35 @@
#[facet(args::positional, default)]
path: Option<PathBuf>,
},
+ /// Bootstrap a fresh hosted root from a clone of it: enroll yourself
+ /// as the self-admitting first member (`gate.bootstrap`), then vouch
+ /// for the server's own key (`roots.web-signing`) so its fail-closed
+ /// web UI can boot, pushing both enrollments to the remote. Run from
+ /// your clone, never on the server — enrolling server-side would
+ /// make the machine the trust root instead of the operator.
+ Bootstrap {
+ /// Your username to enroll (`refs/meta/member/<username>`).
+ #[facet(args::positional)]
+ username: String,
+ /// The server's public key to vouch for; defaults to fetching
+ /// `/.ents/server-key` from the remote's host — the hosted
+ /// root's front proxy publishes the key's public half there
+ /// while the web UI awaits this enrollment. Required when the
+ /// remote is not http(s).
+ #[facet(args::named)]
+ server_pubkey: Option<String>,
+ /// The username the server key is enrolled under; defaults to
+ /// `forge`.
+ #[facet(args::named)]
+ server_name: Option<String>,
+ /// The remote to push both enrollments to; defaults to `origin`.
+ #[facet(args::named)]
+ remote: Option<String>,
+ /// Key to sign both enrollments with; defaults to
+ /// `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
/// Manage the repository members at `refs/meta/member/<username>`.
Members {
/// The member action to run.
crates/cli/git-ents/src/error.rs
@@ -77,6 +77,19 @@
source: std::io::Error,
},
+ /// A `git push` run on the operator's behalf failed: the remote's
+ /// gate refused the enrollment, or the transport itself failed.
+ #[error("push of {refspec} to {remote} failed:\n{stderr}")]
+ Push {
+ /// The refspec being pushed.
+ refspec: String,
+ /// The remote pushed to.
+ remote: String,
+ /// git's own stderr, which carries the gate's refusal
+ /// (`gate.verdict-reason`) when there is one.
+ stderr: String,
+ },
+
/// A malformed command-line argument that passed `figue`'s own parsing
/// but fails a semantic check this crate makes (an invalid line range,
/// an unparsable oid, ...).
crates/cli/git-ents/src/exe.rs
@@ -41,9 +41,32 @@
} => {
let root = LocalRoot::discover(".")?;
let key_path = commands::setup::run(&root, key)?;
+ commands::setup::configure_global_signing_defaults()?;
let _ = writeln!(out, "signing key: {}", key_path.display());
+ let _ = writeln!(
+ out,
+ "global git config: commit.gpgsign=true, tag.gpgsign=true, push.gpgsign=if-asked"
+ );
Ok(())
}
+ Top::Bootstrap {
+ username,
+ server_pubkey,
+ server_name,
+ remote,
+ key,
+ } => {
+ let root = LocalRoot::discover(".")?;
+ commands::bootstrap::run(
+ &root,
+ &username,
+ server_pubkey,
+ server_name.as_deref().unwrap_or("forge"),
+ remote.as_deref().unwrap_or("origin"),
+ key,
+ out,
+ )
+ }
Top::Members { action } => run_members(action, out),
Top::Account { action } => run_account(action, out),
Top::Effect { action } => run_effect(action, out),
crates/cli/git-ents/src/hook.rs
@@ -85,15 +85,17 @@
)]
use std::io::{BufRead, Read, Write};
+use std::path::Path;
use ents_effect::run::run_one;
-use ents_model::Effect;
+use ents_model::{Effect, MemberState};
use ents_query::Query;
use ents_receive::Mode;
use gix::refs::FullName;
use gix_hash::ObjectId;
use gix_object::{CommitRef, Find, Kind};
use gix_ref_store::RefStoreRead;
+use ssh_key::{PublicKey, SshSig};
use crate::error::{Error, Result};
use crate::root::HostedRoot;
@@ -144,16 +146,24 @@
}
/// Run as git's own `pre-receive` hook (see this module's own doc for the
-/// design). Reads transitions from `input`, evaluates the gate against
-/// each, and refuses the whole push (returns `Err`) if any fails under the
-/// mandatory gate. Rejection reasons are written to `report`.
+/// design). Reads transitions from `input`, requires a verified
+/// signed-push certificate once any member is enrolled
+/// ([`verify_push_certificate`]), evaluates the gate against each
+/// transition, and refuses the whole push (returns `Err`) if any check
+/// fails under the mandatory gate. Rejection reasons are written to
+/// `report`.
///
/// # Errors
///
-/// [`Error::Refused`] if any transition's verdict fails; propagates a
-/// parse or gate-evaluation failure otherwise.
+/// [`Error::Refused`] if the push certificate is missing, stale, or
+/// unverifiable, or if any transition's verdict fails; propagates a parse
+/// or gate-evaluation failure otherwise.
pub fn pre_receive(root: &HostedRoot, input: impl BufRead, mut report: impl Write) -> Result<()> {
let transitions = parse_stdin_transitions(input)?;
+ if let Err(error) = verify_push_certificate(root) {
+ let _ = writeln!(report, "refused: {error}");
+ return Err(error);
+ }
let mut failures = Vec::new();
for transition in &transitions {
let verdict = ents_gate::verify(
@@ -176,6 +186,97 @@
}
}
+/// Require the push git is about to apply to carry a valid signed-push
+/// certificate from an enrolled, active member, once any member is
+/// enrolled — the transport-authentication counterpart of
+/// `gate.bootstrap`'s own open window: before any member exists (a fresh
+/// hosted root), every push, including the one that enrolls the first
+/// member, is allowed unsigned, so bootstrapping is possible at all.
+///
+/// A push certificate carries no meta-ref semantics and is never
+/// consulted by `ents_gate::verify` (`gate.signature-artifact`); this is
+/// the one place in the hosted root that reads one, and only to answer
+/// "did an authorized member make this connection", not to decide
+/// anything the gate itself decides from repository state.
+fn verify_push_certificate(root: &HostedRoot) -> Result<()> {
+ let active: Vec<_> = crate::commands::members::list(&root.refs, &root.objects)?
+ .into_iter()
+ .map(|(_, member)| member)
+ .filter(|member| member.state == MemberState::Active)
+ .collect();
+ if active.is_empty() {
+ return Ok(());
+ }
+ let cert_oid = std::env::var("GIT_PUSH_CERT")
+ .ok()
+ .filter(|value| !value.is_empty())
+ .ok_or_else(|| {
+ Error::Refused(
+ "this repository requires a signed push: rerun with `git push --signed`"
+ .to_owned(),
+ )
+ })?;
+ if std::env::var("GIT_PUSH_CERT_NONCE_STATUS").ok().as_deref() != Some("OK") {
+ return Err(Error::Refused(
+ "push certificate nonce was missing or stale".to_owned(),
+ ));
+ }
+ let certificate = cat_blob(&root.path, &cert_oid)?;
+ if active
+ .iter()
+ .any(|member| certificate_verifies(&member.key, &certificate))
+ {
+ Ok(())
+ } else {
+ Err(Error::Refused(
+ "push is not signed by an authorized key".to_owned(),
+ ))
+ }
+}
+
+/// Whether `certificate` (git's raw push-cert text, as recorded in the
+/// blob `GIT_PUSH_CERT` names) carries a valid SSH signature over its own
+/// signed payload, verified against `key` (an OpenSSH public key line) —
+/// the transport-authentication counterpart of `ents_gate::signature`'s
+/// identical commit-signature check, including its "git" SSHSIG
+/// namespace (the same one git signs push certificates under).
+fn certificate_verifies(key: &str, certificate: &str) -> bool {
+ const MARKER: &str = "-----BEGIN SSH SIGNATURE-----";
+ const NAMESPACE: &str = "git";
+ let Some(split) = certificate.find(MARKER) else {
+ return false;
+ };
+ let (payload, signature) = certificate.split_at(split);
+ let Ok(key) = PublicKey::from_openssh(key) else {
+ return false;
+ };
+ let Ok(sig) = SshSig::from_pem(signature) else {
+ return false;
+ };
+ key.verify(NAMESPACE, payload.as_bytes(), &sig).is_ok()
+}
+
+/// Read blob `oid` from `repo_path` as text: `GIT_PUSH_CERT` names the
+/// object holding the raw certificate, not the certificate bytes
+/// directly.
+fn cat_blob(repo_path: &Path, oid: &str) -> Result<String> {
+ let output = std::process::Command::new("git")
+ .arg("-C")
+ .arg(repo_path)
+ .args(["cat-file", "blob", oid])
+ .output()
+ .map_err(|source| Error::Io {
+ path: repo_path.to_owned(),
+ source,
+ })?;
+ if !output.status.success() {
+ return Err(Error::Refused(format!(
+ "could not read push certificate blob {oid}"
+ )));
+ }
+ Ok(String::from_utf8_lossy(&output.stdout).into_owned())
+}
+
/// Run as git's own `post-receive` hook: reconcile outstanding effect
/// obligations and run every one of them via `executor`, writing each
/// result back through the ordinary `receive` path
crates/cli/git-ents/tests/hosted_root.rs
@@ -43,6 +43,17 @@
.expect("git-ents runs");
assert!(output.status.success(), "{output:?}");
+ // The public half published for `git ents bootstrap`'s discovery —
+ // written next to the key `setup --hosted` resolved (here, the one it
+ // generated under the scratch HOME).
+ let pub_path = scratch_home.path().join(".ssh").join("id_ed25519.pub");
+ assert!(
+ pub_path.exists(),
+ "setup --hosted must write the key's public half"
+ );
+ let pubkey = std::fs::read_to_string(&pub_path).expect("readable");
+ assert!(pubkey.starts_with("ssh-"), "{pubkey:?}");
+
for hook in ["pre-receive", "post-receive"] {
let path = bare.join("hooks").join(hook);
assert!(path.exists(), "setup --hosted must install {hook}");
@@ -106,6 +117,9 @@
let key = common::write_key_in(clone_dir.path(), 21);
build_member_commit(clone_dir.path(), &key, "jdc");
+ // The very first push, before any member is enrolled, is admitted
+ // unsigned — the bootstrap window (`gate.bootstrap`)'s transport
+ // counterpart: nobody is enrolled yet to have signed it as.
let push = git(
clone_dir.path(),
&["push", "origin", "refs/meta/member/jdc"],
@@ -124,6 +138,58 @@
);
}
+/// The operator bootstrap porcelain (`git ents bootstrap`) against a
+/// fresh hosted root: one command from a clone enrolls the operator
+/// under the self-admitting window (`gate.bootstrap`), then the server
+/// key under the operator's own signature (`roots.web-signing`), landing
+/// both refs on the bare repository over real pushes — the whole
+/// first-boot runbook `docker/entrypoint.sh` waits on.
+// @relation(gate.bootstrap, roots.web-signing, scope=function, role=Verifies)
+#[test]
+fn bootstrap_command_enrolls_operator_then_server_key() {
+ let bare = common::Fixture::new_bare(30);
+ setup_hosted(bare.path());
+
+ let clone_dir = tempfile::tempdir().expect("tempdir");
+ let clone_output = git(
+ clone_dir.path(),
+ &["clone", "--quiet", bare.path().to_str().expect("utf8"), "."],
+ );
+ assert!(clone_output.status.success(), "{clone_output:?}");
+
+ let operator_key = common::write_key_in(clone_dir.path(), 31);
+ let server_key = clone_dir.path().join(".server_key");
+ common::write_key(&server_key, 32);
+ let server_pubkey = git_ents::sign::Signer::load(&server_key)
+ .expect("loads server key")
+ .public_openssh();
+
+ let output = Command::new(common::bin_path())
+ .args(["bootstrap", "jdc", "--server-pubkey"])
+ .arg(&server_pubkey)
+ .arg("--key")
+ .arg(&operator_key)
+ .current_dir(clone_dir.path())
+ .env("GIT_AUTHOR_NAME", "test")
+ .env("GIT_AUTHOR_EMAIL", "test@ents.test")
+ .env("GIT_COMMITTER_NAME", "test")
+ .env("GIT_COMMITTER_EMAIL", "test@ents.test")
+ .env("GIT_CONFIG_GLOBAL", "/dev/null")
+ .env("GIT_CONFIG_SYSTEM", "/dev/null")
+ .env_remove("HOME")
+ .output()
+ .expect("git-ents runs");
+ assert!(output.status.success(), "{output:?}");
+
+ for member in ["jdc", "forge"] {
+ let show = git(bare.path(), &["show-ref", &format!("refs/meta/member/{member}")]);
+ assert!(
+ show.status.success(),
+ "refs/meta/member/{member} must land on the hosted root: {show:?}"
+ );
+ }
+}
+
/// A second push, from a *different, unenrolled* signer, straight onto a
/// canonical meta-ref with no admin standing, must be refused by the
/// mandatory gate — and because `pre-receive` rejects the whole batch
@@ -187,7 +253,13 @@
)
.expect("evaluates");
git_ents::mutate::outcome_to_result(outcome, None).expect("admin may set the epoch");
- let push = git(admin_clone.path(), &["push", "origin", "refs/meta/config"]);
+ // Admin is now an enrolled, active member, so this push must itself
+ // carry a valid signed-push certificate under the admin's key.
+ common::configure_signing(admin_clone.path(), &admin_key);
+ let push = git(
+ admin_clone.path(),
+ &["push", "--signed=if-asked", "origin", "refs/meta/config"],
+ );
assert!(push.status.success(), "{push:?}");
// Now a second, unenrolled signer tries to enroll a member directly —
@@ -210,9 +282,20 @@
assert!(fetch.status.success(), "{fetch:?}");
build_member_commit(outsider_clone.path(), &outsider_key, "mallory");
+ // Admin is already enrolled by this point, so the hosted root now
+ // requires every push to be signed; sign this one too, so the
+ // rejection below demonstrates the mandatory gate refusing an
+ // unauthorized (if honestly identified) signer, not merely an
+ // unsigned push.
+ common::configure_signing(outsider_clone.path(), &outsider_key);
let push = git(
outsider_clone.path(),
- &["push", "origin", "refs/meta/member/mallory"],
+ &[
+ "push",
+ "--signed=if-asked",
+ "origin",
+ "refs/meta/member/mallory",
+ ],
);
assert!(
!push.status.success(),
crates/cli/git-ents/tests/serve.rs
@@ -93,7 +93,7 @@
)
.expect("utf8 html");
assert!(
- body.contains(r#"class="id-chip" href="/account">jdc</a>"#),
+ body.contains(r#"class="id-chip" href="/account""#) && body.contains("<span>jdc</span>"),
"the id-chip must show the enrolled member's own username: {body}"
);
}
crates/cli/ents-web/src/assets/ents.css
@@ -154,6 +154,10 @@
h1, h2, h3, h4, h5, h6 { font-family: var(--sans); font-weight: 600; }
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-2); text-decoration: underline; }
+/* Every bare `<code>` (a short oid, a CLI command name, ...) reads as a
+ * small code chip by default; contexts that already carry their own
+ * monospace flow (`.blob-code`, `.doc-body`) reset it back below. */
+code { font-family: var(--mono); font-size: .9em; background: var(--surface-2); border: 1px solid var(--line); border-radius: 5px; padding: 1px 6px; }
::selection { background: var(--accent-soft); }
a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible, summary:focus-visible {
outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px;
@@ -252,7 +256,7 @@
/* ===================== CONTENT / SPLIT ===================== */
.content { max-width: 1120px; width: 100%; margin: 0 auto; padding: 30px 34px 60px; flex: 1; }
-.readable { max-width: 760px; }
+.readable { max-width: 760px; margin-inline: auto; }
/* Master-detail (`crate::pages::layout_split`): a sticky scrollable
* sidebar beside a `min-height:0` detail pane. */
.split { display: grid; grid-template-columns: 300px minmax(0, 1fr); height: calc(100vh - 56px); }
@@ -306,10 +310,30 @@
}
/* ===================== PAGE HEADER ===================== */
-.page-header { margin-bottom: 22px; display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
+/* Bare `h2` and `p.muted` in page flow are the section-heading and
+ * lead-in idiom (mock: 14px/600 with 26px above; muted lead-ins breathe
+ * instead of sitting flush on the card below). */
+.content h2, .pane h2 { margin: 26px 0 12px; font-size: 14px; font-weight: 600; }
+.content h2:first-child, .pane h2:first-child { margin-top: 0; }
+p.muted { margin-bottom: 16px; font-size: 13px; text-wrap: pretty; }
+.page-header {
+ margin-bottom: 22px; padding-bottom: 16px; border-bottom: 1px solid var(--line);
+ display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap;
+}
.page-title { font-size: 22px; font-weight: 600; letter-spacing: -.3px; line-height: 1.25; }
+/* A page-title that is itself a repository path (a tree/blob view -- the
+ * `.crumbs` trail right under it names the exact same path) -- monospace so
+ * it reads as the path it is, not prose competing with the crumb trail for
+ * the same information. */
+.page-title.path { font-family: var(--mono); font-size: 18px; word-break: break-all; }
.page-sub { margin-top: 5px; color: var(--ink-3); font-size: 13px; }
.page-header .mono { font-family: var(--mono); color: var(--ink-2); }
+/* A `.crumbs` trail directly under a `.page-header` reads as its subtitle --
+ * hug it to the title instead of the header's own bottom border/margin, and
+ * move that border/gap to below the crumbs, in front of the page's content,
+ * so the title and its path trail read as one grouped header. */
+.page-header:has(+ .crumbs) { margin-bottom: 6px; padding-bottom: 0; border-bottom: none; }
+.page-header + .crumbs { margin-bottom: 22px; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
/* ========================= CARDS ========================= */
.card {
@@ -322,6 +346,15 @@
font-size: 13px; font-weight: 600; color: var(--ink);
}
.card-header .btn-ghost { margin-left: auto; }
+/* A form as a card's body gets the mock composer's inner padding, its
+ * action row right-aligned as the mock's composer footer. */
+.card > form { padding: 18px 20px; margin-bottom: 0; }
+.card > form .composer-buttons { justify-content: flex-end; }
+.tree-head .btn { height: 24px; }
+/* Stacked card rows (meta index): name over blurb, sans, left-aligned. */
+.card-row.stack { flex-direction: column; align-items: flex-start; gap: 2px; padding: 13px 16px; font-family: var(--sans); }
+.card-row.stack a { flex: none; font-size: 13.5px; font-weight: 600; color: var(--accent); text-transform: capitalize; }
+.card-row.stack span { font-size: 12.5px; color: var(--ink-3); }
.card-row {
display: flex; align-items: center; gap: 10px; padding: 9px 16px;
font-family: var(--mono); font-size: 12.5px;
@@ -352,6 +385,9 @@
.history .card-row a:hover { text-decoration: underline; }
.desk-subject { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--sans); font-size: 13px; color: var(--ink); }
.desk-when { flex: none; font-size: 12px; color: var(--ink-3); }
+/* Commit-row trailing author/when: sans, quiet, never wrapping (mock). */
+.row-author { flex: none; font-family: var(--sans); font-size: 12px; color: var(--ink-3); }
+.row-when { flex: none; font-family: var(--sans); font-size: 12px; color: var(--ink-4); white-space: nowrap; }
@media (max-width: 1100px) { .desk { grid-template-columns: minmax(0, 1fr); } }
/* ===================== BUTTONS ===================== */
@@ -359,6 +395,7 @@
display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 16px;
font-size: 13px; font-weight: 500; color: #fff; background: var(--accent);
border: none; border-radius: var(--radius-inner); cursor: pointer;
+ white-space: nowrap; flex: none;
}
.btn:hover { background: var(--accent-2); text-decoration: none; color: #fff; }
.btn-sm { height: 24px; padding: 0 10px; font-size: 11.5px; border-radius: var(--radius-chip); }
@@ -486,7 +523,7 @@
text-align: center; padding: 40px 24px; border: 1px dashed var(--line);
border-radius: var(--radius-card); background: var(--surface);
}
-.blankslate h2 { font-size: 13.5px; font-weight: 600; color: var(--ink-2); margin-bottom: 4px; }
+.blankslate h2 { font-size: 13.5px; font-weight: 600; color: var(--ink-2); margin: 0 0 4px; }
.blankslate p { color: var(--ink-3); font-size: 12.5px; }
.blankslate code { font-family: var(--mono); background: var(--surface-2); border: 1px solid var(--line); padding: 1px 6px; border-radius: 5px; font-size: 12px; }
.card .blankslate { border: none; }
@@ -529,7 +566,7 @@
* buttons where the current option fills with its own chip color. */
.picker { display: flex; flex-wrap: wrap; gap: 6px; }
.picker button, .picker .opt {
- display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 13px;
+ display: inline-flex; flex-direction: row; align-items: center; gap: 6px; height: 32px; padding: 0 13px;
border-radius: var(--radius-inner); cursor: pointer; font-size: 12.5px; font-weight: 500;
color: var(--ink-2); background: var(--surface-2); border: 1px solid var(--line);
}
@@ -544,6 +581,21 @@
}
.label-chip.on { color: var(--accent); background: var(--accent-soft); border-color: var(--accent-line); font-weight: 600; }
+/* ===================== DISCLOSURES ===================== *
+ * An edit affordance folded shut (issue edit, login-mapping edit): a
+ * bordered card-let whose summary is the whole closed state. */
+.disclosure {
+ background: var(--surface); border: 1px solid var(--line); border-radius: 10px;
+ overflow: hidden; margin-bottom: 12px;
+}
+.disclosure > summary {
+ padding: 11px 16px; cursor: pointer; list-style: none; user-select: none; -webkit-user-select: none;
+ font-size: 12.5px; font-weight: 500; color: var(--ink-2);
+}
+.disclosure > summary::-webkit-details-marker { display: none; }
+.disclosure > summary:hover { color: var(--accent); }
+.disclosure form { padding: 4px 16px 16px; margin-bottom: 0; }
+
/* ===================== CRUMBS ===================== */
.crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-bottom: 16px; font-family: var(--mono); font-size: 12px; color: var(--ink-3); word-break: break-all; }
.crumbs a { color: var(--ink-3); }
@@ -556,6 +608,7 @@
* `crate::pages::files`: a blob header bar over the line-per-row source
* table. */
.blob-header {
+ position: relative;
display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; padding: 11px 15px;
background: var(--surface); border: 1px solid var(--line); border-bottom: none;
border-radius: var(--radius-inner) var(--radius-inner) 0 0;
@@ -566,6 +619,11 @@
.blob-actions { margin-left: auto; display: flex; align-items: center; gap: 12px; font-size: 12.5px; color: var(--ink-3); }
.blob-actions a { color: var(--ink-2); }
.blob-actions a:hover { color: var(--accent); }
+/* A "comment on this file/commit" trigger's popup (`assets/ents.js`):
+ * floats below its trigger's own header row instead of pushing the
+ * header/content box's own seamless border pairing apart. */
+.standalone-composer { position: absolute; top: calc(100% + 8px); right: 14px; z-index: 20; width: min(480px, calc(100vw - 60px)); }
+.standalone-composer .composer-form { margin: 0; max-width: none; }
.blob {
overflow-x: auto; background: var(--surface); border: 1px solid var(--line);
border-radius: 0 0 var(--radius-inner) var(--radius-inner); margin-bottom: 18px; padding: 8px 0 10px;
@@ -581,7 +639,7 @@
.blob-nums a:hover { color: var(--accent); }
.blob-nums a:target { color: var(--accent); font-weight: 600; }
.blob td.blob-code { padding: 0 16px 0 4px; white-space: pre; color: var(--ink); vertical-align: top; }
-.blob-code code { font-family: inherit; }
+.blob-code code { font-family: inherit; font-size: inherit; background: none; border: none; padding: 0; border-radius: 0; }
.blob tr.blob-comment-row td { padding: 0; background: var(--surface); }
.blob tr.blob-comment-row .card { margin: 5px 26px 7px 52px; }
.binary { padding: 40px; text-align: center; font-family: var(--mono); font-size: 12.5px; color: var(--ink-3); }
@@ -602,7 +660,7 @@
/* Inline comment composer (`assets/ents.js` clones `composer-template`). */
.blob tr.blob-composer td { padding: 0; background: var(--surface); }
.composer-form {
- margin: 5px 26px 8px 52px; background: var(--surface); border: 1px solid var(--accent);
+ margin: 5px 26px 8px 52px; max-width: 640px; background: var(--surface); border: 1px solid var(--accent);
border-radius: 9px; padding: 12px 13px; box-shadow: 0 4px 14px rgba(74, 63, 196, .1);
}
.composer-form textarea { min-height: 74px; }
@@ -616,9 +674,14 @@
.composer-cancel:hover { color: var(--accent); border-color: var(--accent-line); }
/* ===================== COMMITS ===================== */
+.commit { padding: 16px 18px 18px; }
.commit-subject { font-size: 19px; font-weight: 600; letter-spacing: -.2px; margin-bottom: 8px; }
-.commit-msg { font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap; word-break: break-word; color: var(--ink-2); margin-bottom: 8px; }
-.commit-meta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; color: var(--ink-3); font-size: 12px; font-family: var(--mono); margin-top: 6px; }
+/* The commit message body renders through `crate::asciidoc::to_html`, the
+ * same pipeline an issue body or a `.adoc` blob does -- `.doc-body`'s own
+ * element rules (headings, lists, code spans) apply, scaled down to this
+ * compact metadata context instead of a full document card's. */
+.commit-msg.doc-body { padding: 0; max-width: none; font-size: 13px; margin-bottom: 8px; }
+.commit-meta { position: relative; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; color: var(--ink-3); font-size: 12px; font-family: var(--mono); margin-top: 6px; }
.commit-meta a { color: var(--ink-3); }
.commit-meta a:hover { color: var(--accent); }
crates/cli/ents-web/src/assets/ents.js
@@ -333,3 +333,45 @@
});
});
})();
+
+/*
+ * Standalone comment triggers -- "comment on this file"
+ * (`crate::pages::files::blob_header`) and "comment on this commit"
+ * (`crate::pages::commits::commit_comment_template`) -- toggle a
+ * server-rendered `<template>` as a floating popup right under their own
+ * header/meta row, instead of navigating to a full add-comment page.
+ * Each trigger's `href` stays a real no-JS fallback.
+ */
+(function () {
+ "use strict";
+
+ document.querySelectorAll("a.composer-trigger[data-composer]").forEach(function (trigger) {
+ var template = document.getElementById(trigger.getAttribute("data-composer"));
+ var host = trigger.closest(".blob-header, .commit-meta");
+ if (!template || !host) {
+ return;
+ }
+ trigger.addEventListener("click", function (event) {
+ event.preventDefault();
+ var existing = host.querySelector(".standalone-composer");
+ if (existing) {
+ existing.remove();
+ return;
+ }
+ var wrapper = document.createElement("div");
+ wrapper.className = "standalone-composer";
+ wrapper.appendChild(template.content.cloneNode(true));
+ var cancel = wrapper.querySelector(".composer-cancel");
+ if (cancel) {
+ cancel.addEventListener("click", function () {
+ wrapper.remove();
+ });
+ }
+ host.appendChild(wrapper);
+ var textarea = wrapper.querySelector("textarea");
+ if (textarea) {
+ textarea.focus();
+ }
+ });
+ });
+})();
crates/cli/ents-web/src/pages/account.rs
@@ -76,7 +76,7 @@
"Signed in as the member below. Every web edit is a "
"mutation commit signed with this key, exactly as "
code { "git ents" }
- " itself would sign it -- a local root has no separate login."
+ " itself would sign it — a local root has no separate login."
}
(super::members::member_card(username.as_str(), member, true))
}
@@ -104,15 +104,15 @@
p.muted {
"A hosted deployment maps an external login to an enrolled "
"member so its pushes can be attributed. A local root never "
- "needs one -- the key above is the identity."
+ "needs one — the key above is the identity."
}
(view)
- details {
+ details.disclosure {
summary { "Edit login mapping" }
form method="post" action="/account" {
(super::csrf_input(&session))
- label { "member" input type="text" name="member" value=(member_value) list="members"; }
- label { "login" input type="text" name="login" value=(login_value); }
+ label { "Member" input type="text" name="member" value=(member_value) list="members"; }
+ label { "Login" input type="text" name="login" value=(login_value); }
button type="submit" { "Save" }
}
(super::members_datalist(&state))
crates/cli/ents-web/src/pages/agents.rs
@@ -66,6 +66,7 @@
&super::identity_label(&state),
super::Tab::Agents,
"Agents",
+ false,
agents_sidebar(&rows, None),
html! {
div.readable {
@@ -182,6 +183,7 @@
&super::identity_label(&state),
super::Tab::Agents,
&title,
+ false,
agents_sidebar(&rows, Some(&id)),
html! {
(super::child_crumbs("agents", "/agents", ents_forge::abbreviate_id(&id)))
crates/cli/ents-web/src/pages/comments.rs
@@ -101,8 +101,10 @@
(listing_card(&state, id, comment))
}
}
- h2 { "Add a Comment" }
- (add_form(&query.rev, &session, &query.file, &query.lines))
+ div.card {
+ div.card-header { "Add a comment" }
+ (add_form(&query.rev, &session, &query.file, &query.lines))
+ }
}
},
))
@@ -484,7 +486,7 @@
form method="post" action=(format!("/comments/{id}/reply")) {
(super::csrf_input(session))
input type="hidden" name="return_to" value=(return_to);
- label { "reply" textarea name="body" {} }
+ label { "Reply" textarea name="body" {} }
button type="submit" { "Reply" }
}
@if resolved {
@@ -578,10 +580,10 @@
html! {
form method="post" action="/comments" {
(super::csrf_input(session))
- label { "path" input type="text" name="path" value=(prefill_path); }
- label { "rev" input type="text" name="rev" value=(default_rev); }
- label { "lines" input type="text" name="lines" value=(prefill_lines); }
- label { "body" textarea name="body" {} }
+ label { "Path" input type="text" name="path" value=(prefill_path); }
+ label { "Rev" input type="text" name="rev" value=(default_rev); }
+ label { "Lines" input type="text" name="lines" value=(prefill_lines); }
+ label { "Body" textarea name="body" {} }
button type="submit" { "Comment" }
}
}
crates/cli/ents-web/src/pages/commits.rs
@@ -102,7 +102,6 @@
(blankslate())
} @else {
div.card.history {
- div.card-header { "commits" }
@for row in &rows {
(commit_row(row))
}
@@ -203,8 +202,8 @@
},
None => { span.desk-subject { (row.subject) } },
}
- span.muted { (row.author) }
- span.entry-size { (row.ago) }
+ span.row-author { (row.author) }
+ span.row-when { (row.ago) }
}
}
}
@@ -274,12 +273,20 @@
let checks = checks_section(&state, object_id);
let reviews = reviews_section(&state, &session, object_id, &oid);
let (sidebar_rows, _older) = commit_rows(&state, None, PAGE_SIZE);
+ let commit_context = format!("commits/{oid}");
+ let commit_thread = ents_forge::comment::thread(
+ state.refs.as_ref(),
+ &*state.objects(),
+ &commit_context,
+ )
+ .unwrap_or_default();
Ok(super::layout_split(
&super::RepoHeader::from_state(&state),
&super::identity_label(&state),
super::Tab::Commits,
&subject,
+ false,
commits_sidebar(&sidebar_rows, object_id),
html! {
(super::child_crumbs("commits", "/commits", &super::short_oid(&object_id)))
@@ -293,7 +300,9 @@
div.commit {
div.commit-subject { (subject) }
@if let Some(body) = &body {
- div.commit-msg { (body) }
+ div.commit-msg.doc-body {
+ (crate::asciidoc::to_html(body).unwrap_or_else(|_| html! { p { (body) } }))
+ }
}
div.commit-meta {
(author_name)
@@ -311,8 +320,10 @@
" \u{b7} root commit"
}
" \u{b7} "
- a href={ "/comments?rev=" (object_id) } { "comment on this commit" }
+ a.composer-trigger data-composer="commit-composer-template"
+ href={ "/comments?rev=" (object_id) } { "comment on this commit" }
}
+ (commit_comment_template(&oid, &session))
}
}
(checks)
@@ -322,12 +333,13 @@
@if truncated {
div.card { div.binary { "Diff truncated (over " (MAX_DIFF_BYTES / (1024 * 1024)) " MiB)." } }
}
- @if !comments.is_empty() {
+ @if !comments.is_empty() || !commit_thread.is_empty() {
div.readable {
h2 { "Conversation" }
@for (index, comment) in comments.iter().enumerate() {
(super::comments::comment_card(index, comment, super::comments::LinkMode::CrossFile))
}
+ (super::comments::thread_section(&state, &session, &commit_thread, &format!("/commit/{oid}")))
}
}
},
@@ -530,7 +542,7 @@
form method="post" action=(format!("/reviews/{target}/{member}/comment")) {
(super::csrf_input(session))
input type="hidden" name="return_to" value=(return_to);
- label { "comment on this review" textarea name="body" {} }
+ label { "Comment on this review" textarea name="body" {} }
button type="submit" { "Comment" }
}
}
@@ -603,6 +615,95 @@
/// `model.review` makes it a hard enum, unlike issue and comment states --
/// defaulting to `approve`, the same default a bare `select`'s first option
/// would submit.
+/// The commit-level comment composer's own hidden `<template>`
+/// (`crate::pages::files::composer_template`'s counterpart for a commit,
+/// which has no file of its own to anchor a path-based comment to): posts
+/// to [`comment`], naming `commits/<oid>` as its context
+/// (`model.comment-context`) rather than anchoring to a path, exactly the
+/// way [`review_comment_form`] names `reviews/<target>/<member>`. Cloned
+/// by `assets/ents.js`'s standalone-composer trigger, opened from the
+/// "comment on this commit" link beside the parents list; with JS
+/// disabled that link remains a real navigation to `/comments?rev=`
+/// instead (a page-less no-JS fallback for this specific context would be
+/// its own added surface, so it stays the plain path-anchored form for
+/// now).
+fn commit_comment_template(oid: &str, session: &Session) -> Markup {
+ html! {
+ template id="commit-composer-template" {
+ form.composer-form method="post" action=(format!("/commit/{oid}/comment")) {
+ (super::csrf_input(session))
+ input type="hidden" name="return_to" value=(format!("/commit/{oid}"));
+ textarea name="body" placeholder="Leave a comment on this commit" {}
+ div.composer-buttons {
+ button type="submit" { "Comment" }
+ button.composer-cancel type="button" { "Cancel" }
+ }
+ }
+ }
+ }
+}
+
+/// The form fields `POST /commit/{oid}/comment` accepts.
+#[derive(Debug, Deserialize)]
+pub struct CommitCommentForm {
+ /// The comment's body text.
+ body: String,
+ /// The per-session CSRF token (`roots.web-session`).
+ csrf: String,
+ /// Where to send the browser back to; honored only when it is a
+ /// same-origin path.
+ #[serde(default)]
+ return_to: String,
+}
+
+/// `POST /commit/{oid}/comment`: a comment naming `commits/<oid>` as its
+/// context (`model.comment-context`) -- unanchored, exactly like
+/// [`review_comment`], since a comment about the commit as a whole has no
+/// path to anchor to the way a file-anchored comment does.
+///
+/// # Errors
+///
+/// [`Error::BadCsrf`] if `form.csrf` does not match; otherwise propagates
+/// [`ents_forge::comment::add`]'s own failures.
+// @relation(model.comment-context, roots.web-signing, roots.web-session, scope=function)
+pub async fn comment<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Path(oid): Path<String>,
+ Form(form): Form<CommitCommentForm>,
+) -> Result<impl IntoResponse>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+ let identity = state.identity.as_ref();
+ let new = ents_forge::comment::NewComment {
+ body: form.body,
+ path: None,
+ lines: None,
+ rev: "HEAD".to_owned(),
+ worktree: false,
+ context: Some(format!("commits/{oid}")),
+ parent: None,
+ };
+ let (_comment_id, outcome) = ents_forge::comment::add(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &state.path,
+ new,
+ &crate::receive_identity!(identity, crate::pages::member_author(&session)),
+ state.mode,
+ )?;
+ crate::error::outcome_to_result(outcome)?;
+ let target = if form.return_to.starts_with('/') {
+ form.return_to
+ } else {
+ format!("/commit/{oid}")
+ };
+ Ok(Redirect::to(&target))
+}
+
fn start_review_form(session: &Session, oid: &str) -> Markup {
html! {
h3 { "Start a review" }
@@ -626,7 +727,7 @@
"comment"
}
}
- label { "body" textarea name="body" {} }
+ label { "Body" textarea name="body" {} }
button type="submit" { "Start a Review" }
}
}
crates/cli/ents-web/src/pages/effects.rs
@@ -57,8 +57,10 @@
html! {
(crate::render::unreadable_disclosure(&failures))
(table)
- h2 { "Define an Effect" }
- (add_form(&session))
+ div.card {
+ div.card-header { "Define an effect" }
+ (add_form(&session))
+ }
},
))
}
@@ -69,14 +71,14 @@
html! {
form method="post" action="/effects" {
(super::csrf_input(session))
- label { "name" input type="text" name="name"; }
+ label { "Name" input type="text" name="name"; }
label {
- "trigger"
+ "Trigger"
input type="text" name="trigger" placeholder="query.grammar trigger";
}
- label { "run" input type="text" name="run" placeholder="command to run"; }
+ label { "Run" input type="text" name="run" placeholder="command to run"; }
label {
- "toolchains"
+ "Toolchains"
input type="text" name="toolchains" placeholder="rust, node";
}
button type="submit" { "Define Effect" }
crates/cli/ents-web/src/pages/files.rs
@@ -143,6 +143,7 @@
&super::identity_label(state),
super::Tab::Files,
"Files",
+ false,
tree_sidebar(&head_tree, "", ""),
html! {
(dir_listing(path, entries))
@@ -170,6 +171,7 @@
&super::identity_label(state),
super::Tab::Files,
path,
+ true,
tree_sidebar(&head_tree, path, path),
html! {
(crumbs(path))
@@ -198,6 +200,7 @@
&super::identity_label(state),
super::Tab::Files,
path,
+ true,
tree_sidebar(&head_tree, parent, path),
html! {
(crumbs(path))
@@ -343,7 +346,6 @@
});
html! {
div.card {
- div.card-header { "files" }
@if entries.is_empty() {
div.card-row.muted { "Empty directory." }
}
@@ -521,11 +523,18 @@
editor: editor.clone(),
})
};
+ // Every view kind carries the whole-file composer template -- a
+ // doc-rendered or binary view has no per-line gutter to open one with
+ // a specific line range, but "comment on this file" (empty `lines`,
+ // [`composer_template`]'s own default) is exactly as meaningful there
+ // as on a raw-source view.
+ let composer = composer_template(path, head_oid, session);
if is_binary(bytes) {
return Ok((
html! {
(no_line_count_header())
div.binary { "Binary file (" (bytes.len()) " bytes) not shown." }
+ (composer)
},
super::comments::comments_section(comments),
));
@@ -535,6 +544,7 @@
html! {
(no_line_count_header())
div.binary { "Binary file (" (bytes.len()) " bytes) not shown." }
+ (composer)
},
super::comments::comments_section(comments),
));
@@ -544,6 +554,7 @@
html! {
(no_line_count_header())
div.card { div.doc-body { (crate::markdown::to_html(text)) } }
+ (composer)
},
super::comments::comments_section(comments),
));
@@ -553,6 +564,7 @@
html! {
(no_line_count_header())
div.card { div.doc-body { (crate::asciidoc::to_html(text)?) } }
+ (composer)
},
super::comments::comments_section(comments),
));
@@ -569,7 +581,6 @@
comments: comment_count,
editor,
});
- let composer = composer_template(path, head_oid, session);
let below: Vec<(usize, &super::comments::FileComment)> = comments
.iter()
.enumerate()
@@ -656,7 +667,7 @@
(meta.comments) @if meta.comments == 1 { " comment" } @else { " comments" }
}
}
- a href={ "/comments?file=" (meta.path) } { "comment on this file" }
+ a.composer-trigger data-composer="composer-template" href={ "/comments?file=" (meta.path) } { "comment on this file" }
}
}
}
@@ -756,8 +767,14 @@
}
html! {
+ // `header` is a sibling before `.blob`, never nested inside it --
+ // `.blob-header`'s own border and top-only radius are meant to sit
+ // flush atop `.blob`'s bottom-only radius as one continuous box
+ // (`ents.css`'s own note), which only holds when the two are
+ // siblings, not when the header sits inset inside `.blob`'s own
+ // padded, fully-bordered box.
+ (header)
div.blob data-path=(path) data-rev=(head_oid) {
- (header)
table {
tbody {
@for (index, code) in code_lines.into_iter().enumerate() {
@@ -1202,7 +1219,7 @@
}
#[test]
- fn blob_view_carries_the_composer_hooks_only_on_a_raw_source_view() {
+ fn blob_view_carries_the_composer_hooks_on_every_view_kind() {
let (body, _below) = blob_view(
"src/main.rs",
"main.rs",
@@ -1235,8 +1252,9 @@
)
.expect("markdown renders");
assert!(
- !doc_body.into_string().contains("composer-template"),
- "a doc-rendered view has no source line to anchor a composer to"
+ doc_body.into_string().contains("id=\"composer-template\""),
+ "a doc-rendered view has no per-line gutter, but \"comment on \
+ this file\" (empty `lines`) is exactly as meaningful there"
);
}
crates/cli/ents-web/src/pages/issues.rs
@@ -56,6 +56,7 @@
&super::identity_label(&state),
super::Tab::Issues,
"Issues",
+ false,
issues_sidebar(&rows, None),
html! {
div.readable {
@@ -98,13 +99,13 @@
span.locator {
(issue.state)
" \u{b7} "
- @if issue.assignees.is_empty() {
- "unassigned"
- } @else {
- "@" (issue.assignees[0].as_str())
+ @if let Some(first) = issue.assignees.first() {
+ "@" (first.as_str())
@if issue.assignees.len() > 1 {
" +" (issue.assignees.len() - 1)
}
+ } @else {
+ "unassigned"
}
" \u{b7} "
@if issue.labels.is_empty() { "no labels" } @else { (issue.labels.join(", ")) }
@@ -171,6 +172,7 @@
&super::identity_label(&state),
super::Tab::Issues,
&issue.title,
+ false,
issues_sidebar(&rows, Some(&id)),
html! {
(super::child_crumbs("issues", "/issues", ents_forge::abbreviate_id(&id)))
@@ -203,7 +205,7 @@
}
div.doc-body { (body) }
}
- details {
+ details.disclosure {
summary { "Edit state, assignees, labels" }
(edit_form(&session, &issue, &labels))
(super::members_datalist(&state))
@@ -217,8 +219,10 @@
} @else {
(crate::pages::comments::thread_section(&state, &session, &thread, &return_to))
}
- h2 { "Add a Comment" }
- (comment_form(&session, &id))
+ div.card {
+ div.card-header { "Add a comment" }
+ (comment_form(&session, &id))
+ }
}
},
))
@@ -412,14 +416,14 @@
html! {
form method="post" action="/issues" {
(super::csrf_input(session))
- label { "title" input type="text" name="title"; }
+ label { "Title" input type="text" name="title"; }
div {
- label { "state" }
+ label { "State" }
(state_picker("open"))
}
- label { "assignees" input type="text" name="assignees" placeholder="alice, bob" list="members"; }
+ label { "Assignees" input type="text" name="assignees" placeholder="alice, bob" list="members"; }
(label_picker(known_labels, &[]))
- label { "body" textarea name="body" {} }
+ label { "Body" textarea name="body" {} }
div.composer-buttons {
a.composer-cancel href="/issues" { "Cancel" }
button type="submit" { "Open Issue" }
@@ -437,11 +441,11 @@
form method="post" action="" {
(super::csrf_input(session))
div {
- label { "state" }
+ label { "State" }
(state_picker(&issue.state))
}
label {
- "assignees"
+ "Assignees"
input type="text" name="assignees" value=(join_members(&issue.assignees)) list="members";
}
(label_picker(known_labels, &issue.labels))
@@ -455,7 +459,7 @@
html! {
form method="post" action=(format!("/issues/{id}/comment")) {
(super::csrf_input(session))
- label { "body" textarea name="body" {} }
+ label { "Body" textarea name="body" {} }
button type="submit" { "Comment" }
}
}
@@ -585,7 +589,7 @@
fn label_picker(known: &[String], current: &[String]) -> Markup {
html! {
div {
- label { "labels" }
+ label { "Labels" }
@if !known.is_empty() {
div.picker {
@for label in known {
crates/cli/ents-web/src/pages/meta.rs
@@ -23,16 +23,16 @@
&super::RepoHeader::from_state(&state),
&super::identity_label(&state),
"/meta",
- "Meta",
+ "Repo & governance",
html! {
p.muted {
- "All project metadata -- members, effects, toolchains, "
- "redactions, and the adoption inbox -- lives in this "
+ "All project metadata — members, effects, toolchains, "
+ "redactions, and the adoption inbox — lives in this "
"repository as git objects."
}
div.card {
@for section in super::META_SECTIONS {
- div.card-row {
+ div.card-row.stack {
a href=(section.href) { (section.name) }
span { (section.blurb) }
}
crates/cli/ents-web/src/pages/mod.rs
@@ -15,11 +15,16 @@
//! [`effects`], [`toolchains`], [`redactions`], and [`inbox`] additionally
//! share one `meta` rail item and `META_SECTIONS` rail rather than each
//! carrying its own top-level entry (see `Tab`'s own doc); [`meta`] is that
-//! group's `GET /meta` landing page. [`commits`], [`issues`], and
-//! [`agents`] are rail items of their own -- `Tab::Commits` (Review),
-//! `Tab::Issues` (Issues), and `Tab::Agents` (Agents,
+//! group's `GET /meta` landing page. [`commits`], [`reviews`], [`issues`],
+//! and [`agents`] are rail items of their own -- `Tab::Commits`,
+//! `Tab::Reviews`, `Tab::Issues`, and `Tab::Agents` (Agents,
//! `docs/agent-sessions-plan.adoc`'s Phase 3) in [`layout`]'s icon rail,
-//! alongside the dashboard, code, threads, and meta items. [`search`]
+//! alongside the dashboard, code, threads, and meta items. `reviews::list`
+//! (`GET /reviews`) is a read-only aggregate across every commit's own
+//! reviews (`commits::reviews_section` renders the same
+//! [`ents_forge::review`] entities scoped to one commit; this module has no
+//! writes of its own -- every mutation still posts through `commits`'s own
+//! routes). [`search`]
//! renders with no rail item active at all; it is reached from the
//! `.wb-bar`'s own `.palette` search form rather than any rail item.
@@ -37,6 +42,7 @@
pub mod members;
pub mod meta;
pub mod redactions;
+pub mod reviews;
pub mod search;
pub mod toolchains;
@@ -114,21 +120,28 @@
/// (the pre-redo `Tab` enum, carried through the workbench restructure:
/// the horizontal tab strip became the vertical icon rail, but the
/// "handler names its own section" contract is unchanged). The rail reads,
-/// top to bottom: Dashboard (`Overview`), Code (`Files`), Review
-/// (`Commits`), Issues, Agents (`docs/agent-sessions-plan.adoc`'s Phase 3),
-/// Threads (`Comments`); then, past the
-/// spacer, Repo & governance (`Meta`) and Account. `Meta` covers five page
-/// families ([`super::members`], [`super::effects`], [`super::toolchains`],
+/// top to bottom: Dashboard (`Overview`), Code (`Files`), Commits, Reviews,
+/// Issues, Agents (`docs/agent-sessions-plan.adoc`'s Phase 3), Threads
+/// (`Comments`); then, past the spacer, Repo & governance
+/// (`Meta`) and Account. Commits and Reviews are two rail items, not one,
+/// even though every review still lives on its own commit's page
+/// (`super::commits::reviews_section`) -- browsing history and judging a
+/// specific commit are different reasons to be on this rail, so they get
+/// their own icons (`super::reviews` is the read-only aggregate list; no
+/// mutation route lives there). `Meta` covers five page families
+/// ([`super::members`], [`super::effects`], [`super::toolchains`],
/// [`super::redactions`], [`super::inbox`]) behind one rail item and the
/// [`META_SECTIONS`] rail (see [`layout_meta`]) rather than an item each --
-/// nine equal entries did not scale as page families grew. `None`
-/// highlights nothing at all, for a page that is not part of any rail
-/// item's own section ([`super::search`]'s results page).
+/// unrelated to the Commits/Reviews split above: those five are one page
+/// family each with no reason to be found separately, unlike Commits and
+/// Reviews. `None` highlights nothing at all, for a page that is not part
+/// of any rail item's own section ([`super::search`]'s results page).
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Tab {
Overview,
Files,
Commits,
+ Reviews,
Issues,
Agents,
Comments,
@@ -299,7 +312,8 @@
span.nav-mark { "ge" }
(rail_link(active, Tab::Overview, "/", "Dashboard", "i-home"))
(rail_link(active, Tab::Files, "/files", "Code", "i-files"))
- (rail_link(active, Tab::Commits, "/commits", "Review", "i-commit"))
+ (rail_link(active, Tab::Commits, "/commits", "Commits", "i-commit"))
+ (rail_link(active, Tab::Reviews, "/reviews", "Reviews", "i-review"))
(rail_link(active, Tab::Issues, "/issues", "Issues", "i-issue"))
(rail_link(active, Tab::Agents, "/agents", "Agents", "i-agent"))
(rail_link(active, Tab::Comments, "/comments", "Threads", "i-comment"))
@@ -372,11 +386,18 @@
/// page's own `.page-header` title and `pane` body) on the right. Every
/// selection in the sidebar is a real URL and the sidebar always renders,
/// so the split stays SSR-friendly (`docs/web-workbench-plan.adoc`).
+///
+/// `path_title` marks `title` itself as a repository-relative path
+/// (`super::files`'s tree/blob views, the only pages whose title is a path
+/// rather than a name) so the title renders in `.page-title.path`'s
+/// monospace, matching the `.crumbs` trail underneath it instead of
+/// clashing with it in the ordinary heading font.
pub(crate) fn layout_split(
repo: &RepoHeader,
identity: &str,
active: Tab,
title: &str,
+ path_title: bool,
sidebar: Markup,
pane: Markup,
) -> Markup {
@@ -389,7 +410,7 @@
div.split {
nav.tree { (sidebar) }
main.pane {
- div.page-header { h1.page-title { (title) } }
+ div.page-header { h1.page-title.path[path_title] { (title) } }
(pane)
}
}
Diff truncated (over 1 MiB).