git-ents.gitmain
⌘K
foforge
commit 7629f1a
Merge 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>

Joseph D. Carpinelli · 28 days ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

.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).