fabrica

hanna/fabrica

fix(web): title-only header edit, per-comment/body edit, Blocks spacing

5c6c0e0 · hanna committed on 2026-07-25

The header Edit button now edits only the title (/issue/{id}/edit-title). Each
comment header — including the issue/PR description card — gets its own Edit that
posts to /comment/{id}/edit or /issue/{id}/edit-body; the edit form expands to a
full-width row when opened. Split the "Blocked by" and "Blocks" dependency lists
into separate sidebar sections so "Blocks" is properly spaced and styled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
5 files changed · +98 −37UnifiedSplit
assets/base.css +8 −0
@@ -540,6 +540,12 @@ body.drawer-open .drawer-backdrop {
540540 .comment-edit {
541541 margin-left: auto;
542542 }
543+/* When opened, break onto its own full-width row so the form isn't cramped. */
544+.comment-edit[open] {
545+ flex-basis: 100%;
546+ margin-left: 0;
547+ margin-top: 0.4rem;
548+}
543549 .comment-edit > summary {
544550 font-size: 0.9em;
545551 color: var(--fb-accent);
@@ -548,6 +554,7 @@ body.drawer-open .drawer-backdrop {
548554 margin-top: 0.5rem;
549555 }
550556 .comment-edit textarea {
557+ width: 100%;
551558 font-family: inherit;
552559 }
553560 .issue-sub {
@@ -587,6 +594,7 @@ body.drawer-open .drawer-backdrop {
587594 .comment-head {
588595 display: flex;
589596 align-items: center;
597+ flex-wrap: wrap;
590598 gap: 0.35rem;
591599 padding: 0.5rem 0.85rem;
592600 border-bottom: 1px solid var(--fb-border);
crates/web/src/issues.rs +68 −31
@@ -257,11 +257,14 @@ pub(crate) async fn view(
257257 }
258258 div class="issue-main" {
259259 article class="issue-thread" {
260- (comment_card(&author, &issue.body, issue.created_at, None))
260+ @let body_action = format!("/issue/{}/edit-body", issue.id);
261+ (comment_card(&author, &issue.body, issue.created_at,
262+ (can_write || is_author).then_some((body_action.as_str(), csrf.as_str()))))
261263 @for (c, cauthor) in &comment_rows {
262264 @let editable = can_write || viewer_id.as_deref() == Some(c.author_id.as_str());
265+ @let action = format!("/comment/{}/edit", c.id);
263266 (comment_card(cauthor, &c.body, c.created_at,
264- editable.then_some((c.id.as_str(), csrf.as_str()))))
267+ editable.then_some((action.as_str(), csrf.as_str()))))
265268 }
266269 @if issue.locked() { (locked_note(can_write)) }
267270 @if can_write || (is_author && !issue.locked()) {
@@ -299,7 +302,9 @@ pub(crate) async fn view(
299302 // ---- shared render helpers ----
300303
301304 /// A single comment card (also used for the issue body). When `edit` is
302-/// `Some((comment_id, csrf))`, an inline edit form is offered to the viewer.
305+/// `Some((action, csrf))`, the header offers an inline edit form posting to
306+/// `action` — `/comment/{id}/edit` for comments, `/issue/{id}/edit-body` for the
307+/// issue/PR description.
303308 pub(crate) fn comment_card(
304309 author: &str,
305310 body: &str,
@@ -311,10 +316,10 @@ pub(crate) fn comment_card(
311316 div class="comment-head muted" {
312317 strong { (author) }
313318 span { " commented " (crate::activity::fmt_relative(created_at, crate::now_ms())) }
314- @if let Some((id, csrf)) = edit {
319+ @if let Some((action, csrf)) = edit {
315320 details class="comment-edit" {
316321 summary { "Edit" }
317- form method="post" action=(format!("/comment/{id}/edit")) {
322+ form method="post" action=(action) {
318323 input type="hidden" name="_csrf" value=(csrf);
319324 textarea name="body" rows="4" required { (body) }
320325 button class="btn inline-btn" type="submit" { "Save" }
@@ -411,19 +416,18 @@ pub(crate) fn lock_button(issue: &Issue, csrf: &str) -> Markup {
411416 }
412417 }
413418
414-/// The edit-title/description form, revealed by a toggle in the issue header.
419+/// The edit-title form, revealed by a toggle in the issue/PR header. (The
420+/// description is edited from its own comment card.)
415421 pub(crate) fn edit_issue_form(issue: &Issue, kind: Kind, csrf: &str) -> Markup {
416422 html! {
417423 details class="issue-edit" {
418424 summary class="btn" { "Edit" }
419425 div class="card" {
420- form method="post" action=(format!("/issue/{}/edit", issue.id)) {
426+ form method="post" action=(format!("/issue/{}/edit-title", issue.id)) {
421427 input type="hidden" name="_csrf" value=(csrf);
422428 label for="edit-title" { "Title" }
423429 input type="text" id="edit-title" name="title" value=(issue.title) required;
424- label for="edit-body" { "Description" }
425- textarea id="edit-body" name="body" rows="6" { (issue.body) }
426- button class="btn btn-primary inline-btn" type="submit" { "Save " (kind.noun) }
430+ button class="btn btn-primary inline-btn" type="submit" { "Save " (kind.noun) " title" }
427431 }
428432 }
429433 }
@@ -547,7 +551,9 @@ pub(crate) fn deps_section(
547551 button class="btn inline-btn" type="submit" { "Add" }
548552 }
549553 }
550- @if !dependents.is_empty() {
554+ }
555+ @if !dependents.is_empty() {
556+ section {
551557 h3 { "Blocks" }
552558 ul class="dep-list" { @for d in dependents { (row(d, false)) } }
553559 }
@@ -692,51 +698,38 @@ pub async fn create(
692698 respond_redirect(result)
693699 }
694700
695-/// Edit-issue form (title + description).
701+/// Edit-title form.
696702 #[derive(Debug, Deserialize)]
697-pub struct EditIssueForm {
703+pub struct EditTitleForm {
698704 /// New title.
699705 #[serde(default)]
700706 title: String,
701- /// New markdown description.
702- #[serde(default)]
703- body: String,
704707 /// CSRF token.
705708 #[serde(rename = "_csrf")]
706709 csrf: String,
707710 }
708711
709-/// `POST /issue/{id}/edit` — edit an issue/PR's title and description (Write or
710-/// the author).
711-pub async fn edit(
712+/// `POST /issue/{id}/edit-title` — edit an issue/PR's title (Write or author).
713+pub async fn edit_title(
712714 State(state): State<AppState>,
713715 MaybeUser(viewer): MaybeUser,
714716 jar: CookieJar,
715717 headers: HeaderMap,
716718 Path(id): Path<String>,
717- Form(form): Form<EditIssueForm>,
719+ Form(form): Form<EditTitleForm>,
718720 ) -> Response {
719721 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
720722 return AppError::Forbidden.into_response();
721723 }
722724 let result = async {
723- let user = viewer.ok_or(AppError::Forbidden)?;
724- let issue = state
725- .store
726- .issue_by_id(&id)
727- .await?
728- .ok_or(AppError::NotFound)?;
729- let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?;
730- if access < auth::Access::Write && user.id != issue.author_id {
731- return Err(AppError::Forbidden);
732- }
725+ let (repo, issue) = edit_target(&state, viewer.as_ref(), &id).await?;
733726 let title = form.title.trim();
734727 if title.is_empty() {
735728 return Err(AppError::BadRequest("A title is required.".to_string()));
736729 }
737730 state
738731 .store
739- .update_issue(&issue.id, title, form.body.trim())
732+ .update_issue(&issue.id, title, &issue.body)
740733 .await?;
741734 Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
742735 }
@@ -744,6 +737,50 @@ pub async fn edit(
744737 respond_redirect(result)
745738 }
746739
740+/// `POST /issue/{id}/edit-body` — edit an issue/PR's description (Write or
741+/// author). Reuses [`CommentForm`] (the `body` field).
742+pub async fn edit_body(
743+ State(state): State<AppState>,
744+ MaybeUser(viewer): MaybeUser,
745+ jar: CookieJar,
746+ headers: HeaderMap,
747+ Path(id): Path<String>,
748+ Form(form): Form<CommentForm>,
749+) -> Response {
750+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
751+ return AppError::Forbidden.into_response();
752+ }
753+ let result = async {
754+ let (repo, issue) = edit_target(&state, viewer.as_ref(), &id).await?;
755+ state
756+ .store
757+ .update_issue(&issue.id, &issue.title, form.body.trim())
758+ .await?;
759+ Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
760+ }
761+ .await;
762+ respond_redirect(result)
763+}
764+
765+/// Resolve the issue and authorize the viewer to edit it (Write or the author).
766+async fn edit_target(
767+ state: &AppState,
768+ viewer: Option<&User>,
769+ id: &str,
770+) -> AppResult<(model::Repo, Issue)> {
771+ let user = viewer.ok_or(AppError::Forbidden)?;
772+ let issue = state
773+ .store
774+ .issue_by_id(id)
775+ .await?
776+ .ok_or(AppError::NotFound)?;
777+ let (repo, access) = repo_of_issue(state, Some(user), &issue).await?;
778+ if access < auth::Access::Write && user.id != issue.author_id {
779+ return Err(AppError::Forbidden);
780+ }
781+ Ok((repo, issue))
782+}
783+
747784 /// `POST /comment/{id}/edit` — edit a comment (Write or the comment's author).
748785 pub async fn comment_edit(
749786 State(state): State<AppState>,
crates/web/src/lib.rs +2 −1
@@ -204,7 +204,8 @@ pub fn build_router(state: AppState) -> Router {
204204 .route("/issue/{id}/labels", post(issues::set_labels))
205205 .route("/issue/{id}/deps/add", post(issues::deps_add))
206206 .route("/issue/{id}/deps/remove", post(issues::deps_remove))
207- .route("/issue/{id}/edit", post(issues::edit))
207+ .route("/issue/{id}/edit-title", post(issues::edit_title))
208+ .route("/issue/{id}/edit-body", post(issues::edit_body))
208209 .route("/issue/{id}/lock", post(issues::set_lock))
209210 .route("/comment/{id}/edit", post(issues::comment_edit))
210211 .route("/pull-new/{repo_id}", post(pulls::create))
crates/web/src/pulls.rs +5 −2
@@ -155,11 +155,14 @@ pub(crate) async fn view(
155155 }
156156 div class="issue-main" {
157157 article class="issue-thread" {
158- (comment_card(&author, &issue.body, issue.created_at, None))
158+ @let body_action = format!("/issue/{}/edit-body", issue.id);
159+ (comment_card(&author, &issue.body, issue.created_at,
160+ (can_write || is_author).then_some((body_action.as_str(), csrf.as_str()))))
159161 @for (c, cauthor) in &comment_rows {
160162 @let editable = can_write || viewer_id.as_deref() == Some(c.author_id.as_str());
163+ @let action = format!("/comment/{}/edit", c.id);
161164 (comment_card(cauthor, &c.body, c.created_at,
162- editable.then_some((c.id.as_str(), csrf.as_str()))))
165+ editable.then_some((action.as_str(), csrf.as_str()))))
163166 }
164167 (merge_panel(&issue, &pr, mergeable, blocked, &csrf))
165168 @if issue.locked() { (locked_note(can_write)) }
crates/web/src/tests.rs +15 −3
@@ -774,16 +774,28 @@ async fn issue_title_and_comments_are_editable() {
774774 }
775775 };
776776
777- // Edit the issue title + body.
777+ // Edit the issue title (title only; body preserved).
778778 let res = post(
779- format!("/issue/{}/edit", issue.id),
780- format!("title=Fixed+title&body=new+body&_csrf={token}"),
779+ format!("/issue/{}/edit-title", issue.id),
780+ format!("title=Fixed+title&_csrf={token}"),
781781 cookie.clone(),
782782 )
783783 .await;
784784 assert_eq!(res.status(), StatusCode::SEE_OTHER);
785785 let updated = state.store.issue_by_id(&issue.id).await.unwrap().unwrap();
786786 assert_eq!(updated.title, "Fixed title");
787+ assert_eq!(updated.body, "body", "body untouched by title edit");
788+
789+ // Edit the issue description via its own card.
790+ let res = post(
791+ format!("/issue/{}/edit-body", issue.id),
792+ format!("body=new+body&_csrf={token}"),
793+ cookie.clone(),
794+ )
795+ .await;
796+ assert_eq!(res.status(), StatusCode::SEE_OTHER);
797+ let updated = state.store.issue_by_id(&issue.id).await.unwrap().unwrap();
798+ assert_eq!(updated.title, "Fixed title", "title untouched by body edit");
787799 assert_eq!(updated.body, "new body");
788800
789801 // Edit the comment.