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 {
540.comment-edit {540.comment-edit {
541 margin-left: auto;541 margin-left: auto;
542}542}
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}
543.comment-edit > summary {549.comment-edit > summary {
544 font-size: 0.9em;550 font-size: 0.9em;
545 color: var(--fb-accent);551 color: var(--fb-accent);
@@ -548,6 +554,7 @@ body.drawer-open .drawer-backdrop {
548 margin-top: 0.5rem;554 margin-top: 0.5rem;
549}555}
550.comment-edit textarea {556.comment-edit textarea {
557 width: 100%;
551 font-family: inherit;558 font-family: inherit;
552}559}
553.issue-sub {560.issue-sub {
@@ -587,6 +594,7 @@ body.drawer-open .drawer-backdrop {
587.comment-head {594.comment-head {
588 display: flex;595 display: flex;
589 align-items: center;596 align-items: center;
597 flex-wrap: wrap;
590 gap: 0.35rem;598 gap: 0.35rem;
591 padding: 0.5rem 0.85rem;599 padding: 0.5rem 0.85rem;
592 border-bottom: 1px solid var(--fb-border);600 border-bottom: 1px solid var(--fb-border);
crates/web/src/issues.rs +68 −31
@@ -257,11 +257,14 @@ pub(crate) async fn view(
257 }257 }
258 div class="issue-main" {258 div class="issue-main" {
259 article class="issue-thread" {259 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()))))
261 @for (c, cauthor) in &comment_rows {263 @for (c, cauthor) in &comment_rows {
262 @let editable = can_write || viewer_id.as_deref() == Some(c.author_id.as_str());264 @let editable = can_write || viewer_id.as_deref() == Some(c.author_id.as_str());
265 @let action = format!("/comment/{}/edit", c.id);
263 (comment_card(cauthor, &c.body, c.created_at,266 (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()))))
265 }268 }
266 @if issue.locked() { (locked_note(can_write)) }269 @if issue.locked() { (locked_note(can_write)) }
267 @if can_write || (is_author && !issue.locked()) {270 @if can_write || (is_author && !issue.locked()) {
@@ -299,7 +302,9 @@ pub(crate) async fn view(
299// ---- shared render helpers ----302// ---- shared render helpers ----
300303
301/// A single comment card (also used for the issue body). When `edit` is304/// 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.
303pub(crate) fn comment_card(308pub(crate) fn comment_card(
304 author: &str,309 author: &str,
305 body: &str,310 body: &str,
@@ -311,10 +316,10 @@ pub(crate) fn comment_card(
311 div class="comment-head muted" {316 div class="comment-head muted" {
312 strong { (author) }317 strong { (author) }
313 span { " commented " (crate::activity::fmt_relative(created_at, crate::now_ms())) }318 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 {
315 details class="comment-edit" {320 details class="comment-edit" {
316 summary { "Edit" }321 summary { "Edit" }
317 form method="post" action=(format!("/comment/{id}/edit")) {322 form method="post" action=(action) {
318 input type="hidden" name="_csrf" value=(csrf);323 input type="hidden" name="_csrf" value=(csrf);
319 textarea name="body" rows="4" required { (body) }324 textarea name="body" rows="4" required { (body) }
320 button class="btn inline-btn" type="submit" { "Save" }325 button class="btn inline-btn" type="submit" { "Save" }
@@ -411,19 +416,18 @@ pub(crate) fn lock_button(issue: &Issue, csrf: &str) -> Markup {
411 }416 }
412}417}
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.)
415pub(crate) fn edit_issue_form(issue: &Issue, kind: Kind, csrf: &str) -> Markup {421pub(crate) fn edit_issue_form(issue: &Issue, kind: Kind, csrf: &str) -> Markup {
416 html! {422 html! {
417 details class="issue-edit" {423 details class="issue-edit" {
418 summary class="btn" { "Edit" }424 summary class="btn" { "Edit" }
419 div class="card" {425 div class="card" {
420 form method="post" action=(format!("/issue/{}/edit", issue.id)) {426 form method="post" action=(format!("/issue/{}/edit-title", issue.id)) {
421 input type="hidden" name="_csrf" value=(csrf);427 input type="hidden" name="_csrf" value=(csrf);
422 label for="edit-title" { "Title" }428 label for="edit-title" { "Title" }
423 input type="text" id="edit-title" name="title" value=(issue.title) required;429 input type="text" id="edit-title" name="title" value=(issue.title) required;
424 label for="edit-body" { "Description" }430 button class="btn btn-primary inline-btn" type="submit" { "Save " (kind.noun) " title" }
425 textarea id="edit-body" name="body" rows="6" { (issue.body) }
426 button class="btn btn-primary inline-btn" type="submit" { "Save " (kind.noun) }
427 }431 }
428 }432 }
429 }433 }
@@ -547,7 +551,9 @@ pub(crate) fn deps_section(
547 button class="btn inline-btn" type="submit" { "Add" }551 button class="btn inline-btn" type="submit" { "Add" }
548 }552 }
549 }553 }
550 @if !dependents.is_empty() {554 }
555 @if !dependents.is_empty() {
556 section {
551 h3 { "Blocks" }557 h3 { "Blocks" }
552 ul class="dep-list" { @for d in dependents { (row(d, false)) } }558 ul class="dep-list" { @for d in dependents { (row(d, false)) } }
553 }559 }
@@ -692,51 +698,38 @@ pub async fn create(
692 respond_redirect(result)698 respond_redirect(result)
693}699}
694700
695/// Edit-issue form (title + description).701/// Edit-title form.
696#[derive(Debug, Deserialize)]702#[derive(Debug, Deserialize)]
697pub struct EditIssueForm {703pub struct EditTitleForm {
698 /// New title.704 /// New title.
699 #[serde(default)]705 #[serde(default)]
700 title: String,706 title: String,
701 /// New markdown description.
702 #[serde(default)]
703 body: String,
704 /// CSRF token.707 /// CSRF token.
705 #[serde(rename = "_csrf")]708 #[serde(rename = "_csrf")]
706 csrf: String,709 csrf: String,
707}710}
708711
709/// `POST /issue/{id}/edit` — edit an issue/PR's title and description (Write or712/// `POST /issue/{id}/edit-title` — edit an issue/PR's title (Write or author).
710/// the author).713pub async fn edit_title(
711pub async fn edit(
712 State(state): State<AppState>,714 State(state): State<AppState>,
713 MaybeUser(viewer): MaybeUser,715 MaybeUser(viewer): MaybeUser,
714 jar: CookieJar,716 jar: CookieJar,
715 headers: HeaderMap,717 headers: HeaderMap,
716 Path(id): Path<String>,718 Path(id): Path<String>,
717 Form(form): Form<EditIssueForm>,719 Form(form): Form<EditTitleForm>,
718) -> Response {720) -> Response {
719 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {721 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
720 return AppError::Forbidden.into_response();722 return AppError::Forbidden.into_response();
721 }723 }
722 let result = async {724 let result = async {
723 let user = viewer.ok_or(AppError::Forbidden)?;725 let (repo, issue) = edit_target(&state, viewer.as_ref(), &id).await?;
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 }
733 let title = form.title.trim();726 let title = form.title.trim();
734 if title.is_empty() {727 if title.is_empty() {
735 return Err(AppError::BadRequest("A title is required.".to_string()));728 return Err(AppError::BadRequest("A title is required.".to_string()));
736 }729 }
737 state730 state
738 .store731 .store
739 .update_issue(&issue.id, title, form.body.trim())732 .update_issue(&issue.id, title, &issue.body)
740 .await?;733 .await?;
741 Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)734 Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
742 }735 }
@@ -744,6 +737,50 @@ pub async fn edit(
744 respond_redirect(result)737 respond_redirect(result)
745}738}
746739
740/// `POST /issue/{id}/edit-body` — edit an issue/PR's description (Write or
741/// author). Reuses [`CommentForm`] (the `body` field).
742pub 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).
766async 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
747/// `POST /comment/{id}/edit` — edit a comment (Write or the comment's author).784/// `POST /comment/{id}/edit` — edit a comment (Write or the comment's author).
748pub async fn comment_edit(785pub async fn comment_edit(
749 State(state): State<AppState>,786 State(state): State<AppState>,
crates/web/src/lib.rs +2 −1
@@ -204,7 +204,8 @@ pub fn build_router(state: AppState) -> Router {
204 .route("/issue/{id}/labels", post(issues::set_labels))204 .route("/issue/{id}/labels", post(issues::set_labels))
205 .route("/issue/{id}/deps/add", post(issues::deps_add))205 .route("/issue/{id}/deps/add", post(issues::deps_add))
206 .route("/issue/{id}/deps/remove", post(issues::deps_remove))206 .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))
208 .route("/issue/{id}/lock", post(issues::set_lock))209 .route("/issue/{id}/lock", post(issues::set_lock))
209 .route("/comment/{id}/edit", post(issues::comment_edit))210 .route("/comment/{id}/edit", post(issues::comment_edit))
210 .route("/pull-new/{repo_id}", post(pulls::create))211 .route("/pull-new/{repo_id}", post(pulls::create))
crates/web/src/pulls.rs +5 −2
@@ -155,11 +155,14 @@ pub(crate) async fn view(
155 }155 }
156 div class="issue-main" {156 div class="issue-main" {
157 article class="issue-thread" {157 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()))))
159 @for (c, cauthor) in &comment_rows {161 @for (c, cauthor) in &comment_rows {
160 @let editable = can_write || viewer_id.as_deref() == Some(c.author_id.as_str());162 @let editable = can_write || viewer_id.as_deref() == Some(c.author_id.as_str());
163 @let action = format!("/comment/{}/edit", c.id);
161 (comment_card(cauthor, &c.body, c.created_at,164 (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()))))
163 }166 }
164 (merge_panel(&issue, &pr, mergeable, blocked, &csrf))167 (merge_panel(&issue, &pr, mergeable, blocked, &csrf))
165 @if issue.locked() { (locked_note(can_write)) }168 @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() {
774 }774 }
775 };775 };
776776
777 // Edit the issue title + body.777 // Edit the issue title (title only; body preserved).
778 let res = post(778 let res = post(
779 format!("/issue/{}/edit", issue.id),779 format!("/issue/{}/edit-title", issue.id),
780 format!("title=Fixed+title&body=new+body&_csrf={token}"),780 format!("title=Fixed+title&_csrf={token}"),
781 cookie.clone(),781 cookie.clone(),
782 )782 )
783 .await;783 .await;
784 assert_eq!(res.status(), StatusCode::SEE_OTHER);784 assert_eq!(res.status(), StatusCode::SEE_OTHER);
785 let updated = state.store.issue_by_id(&issue.id).await.unwrap().unwrap();785 let updated = state.store.issue_by_id(&issue.id).await.unwrap().unwrap();
786 assert_eq!(updated.title, "Fixed title");786 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");
787 assert_eq!(updated.body, "new body");799 assert_eq!(updated.body, "new body");
788800
789 // Edit the comment.801 // Edit the comment.