collin/anvil · 76805bb1
feat: delete a task from the TODO.md board
Collin Richards · 2026-06-10 16:14 UTC · 76805bb11c22707e0aff302b9d1fcd7a5190ee76 · parent 0425e5f9 · browse files
modifiedcrates/anvil-web/src/todomd.rs+182 −4
| ⋯ 331 unchanged lines | |||
| 332 | 332 | out | |
| 333 | 333 | } | |
| 334 | 334 | ||
| 335 | + | /// The inverse of [`add_task`]: delete the task titled `title` from the section | |
| 336 | + | /// named `section`, touching no other byte (round-trip rule). Handles both a | |
| 337 | + | /// ticket (a nested `## title` heading and its body, up to the next heading) | |
| 338 | + | /// and a `- [ ]` checkbox task (its line plus indented detail lines). Returns | |
| 339 | + | /// `None` if the section or task isn't found. Removes its own trailing blank | |
| 340 | + | /// lines and collapses the blank seam left behind so spacing stays tidy. | |
| 341 | + | pub fn remove_task(text: &str, section: &str, title: &str) -> Option<String> { | |
| 342 | + | let lines: Vec<&str> = text.split('\n').collect(); | |
| 343 | + | let col = column_level(text); | |
| 344 | + | if col == 0 { | |
| 345 | + | return None; | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | // The section body span [start, end), exactly as add_task locates it. | |
| 349 | + | let mut start = None; | |
| 350 | + | let mut end = lines.len(); | |
| 351 | + | let mut in_fence = false; | |
| 352 | + | for (i, raw) in lines.iter().enumerate() { | |
| 353 | + | let line = raw.trim_end_matches('\r'); | |
| 354 | + | if line.trim_start().starts_with("```") { | |
| 355 | + | in_fence = !in_fence; | |
| 356 | + | continue; | |
| 357 | + | } | |
| 358 | + | if in_fence { | |
| 359 | + | continue; | |
| 360 | + | } | |
| 361 | + | if let Some((level, rest)) = heading(line) | |
| 362 | + | && level == col | |
| 363 | + | { | |
| 364 | + | match start { | |
| 365 | + | None if strip_marker(rest) == section => start = Some(i + 1), | |
| 366 | + | Some(_) => { | |
| 367 | + | end = i; | |
| 368 | + | break; | |
| 369 | + | } | |
| 370 | + | None => {} | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | } | |
| 374 | + | let start = start?; | |
| 375 | + | ||
| 376 | + | // The end of a ticket body: the next heading (any level) or section end. | |
| 377 | + | let ticket_body_end = |from: usize| { | |
| 378 | + | let mut j = from; | |
| 379 | + | let mut f = false; | |
| 380 | + | while j < end { | |
| 381 | + | let l = lines[j].trim_end_matches('\r'); | |
| 382 | + | if l.trim_start().starts_with("```") { | |
| 383 | + | f = !f; | |
| 384 | + | } else if !f && heading(l).is_some() { | |
| 385 | + | break; | |
| 386 | + | } | |
| 387 | + | j += 1; | |
| 388 | + | } | |
| 389 | + | j | |
| 390 | + | }; | |
| 391 | + | ||
| 392 | + | // Walk the section body to find the task and its line span [from, to). | |
| 393 | + | let mut from = None; | |
| 394 | + | let mut to = end; | |
| 395 | + | let mut i = start; | |
| 396 | + | let mut fence = false; | |
| 397 | + | while i < end { | |
| 398 | + | let line = lines[i].trim_end_matches('\r'); | |
| 399 | + | if line.trim_start().starts_with("```") { | |
| 400 | + | fence = !fence; | |
| 401 | + | i += 1; | |
| 402 | + | continue; | |
| 403 | + | } | |
| 404 | + | if !fence { | |
| 405 | + | if let Some((level, rest)) = heading(line) { | |
| 406 | + | if level > col { | |
| 407 | + | let body_end = ticket_body_end(i + 1); | |
| 408 | + | if strip_marker(rest) == title { | |
| 409 | + | from = Some(i); | |
| 410 | + | to = body_end; | |
| 411 | + | break; | |
| 412 | + | } | |
| 413 | + | i = body_end; // skip a non-matching ticket whole | |
| 414 | + | continue; | |
| 415 | + | } | |
| 416 | + | } else if let Some((_, t)) = task_line(line) { | |
| 417 | + | let mut j = i + 1; | |
| 418 | + | while j < end && lines[j].starts_with(" ") { | |
| 419 | + | j += 1; | |
| 420 | + | } | |
| 421 | + | if t == title { | |
| 422 | + | from = Some(i); | |
| 423 | + | to = j; | |
| 424 | + | break; | |
| 425 | + | } | |
| 426 | + | i = j; // skip a non-matching task with its details | |
| 427 | + | continue; | |
| 428 | + | } | |
| 429 | + | } | |
| 430 | + | i += 1; | |
| 431 | + | } | |
| 432 | + | let from = from?; | |
| 433 | + | ||
| 434 | + | // Don't swallow the blank separator before the next item/heading. | |
| 435 | + | while to > from && lines[to - 1].trim().is_empty() { | |
| 436 | + | to -= 1; | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | let mut out: Vec<String> = lines.iter().map(|l| l.to_string()).collect(); | |
| 440 | + | out.drain(from..to); | |
| 441 | + | // Collapse a double blank left where the block was. | |
| 442 | + | if from > 0 | |
| 443 | + | && from < out.len() | |
| 444 | + | && out[from - 1].trim().is_empty() | |
| 445 | + | && out[from].trim().is_empty() | |
| 446 | + | { | |
| 447 | + | out.remove(from); | |
| 448 | + | } | |
| 449 | + | Some(out.join("\n")) | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | /// What the board needs to render per-task delete controls: the repo | |
| 453 | + | /// coordinates, the branch tip (compare-and-swap guard), and a CSRF token. | |
| 454 | + | /// `None` renders a read-only board. | |
| 455 | + | pub struct BoardActions<'a> { | |
| 456 | + | pub owner: &'a str, | |
| 457 | + | pub repo: &'a str, | |
| 458 | + | pub rev: &'a str, | |
| 459 | + | pub path: &'a str, | |
| 460 | + | pub tip: &'a str, | |
| 461 | + | pub csrf: &'a str, | |
| 462 | + | } | |
| 463 | + | ||
| 335 | 464 | /// Render a todo-md document as a kanban board (columns = task-bearing | |
| 336 | 465 | /// sections) with prose sections as a notes area below. `None` if the file | |
| 337 | 466 | /// contains no tasks at all — callers fall back to plain markdown. | |
| 338 | - | pub fn render_board(text: &str) -> Option<Markup> { | |
| 467 | + | pub fn render_board(text: &str, actions: Option<&BoardActions>) -> Option<Markup> { | |
| 339 | 468 | let sections = parse(text); | |
| 340 | 469 | if sections.iter().all(|s| s.tasks.is_empty()) { | |
| 341 | 470 | return None; | |
| ⋯ 35 unchanged lines | |||
| 377 | 506 | } | |
| 378 | 507 | @for t in &s.tasks { | |
| 379 | 508 | div.card.done[t.done] { | |
| 509 | + | @if let Some(a) = actions { | |
| 510 | + | form.card-del method="post" | |
| 511 | + | action=(format!("/{}/{}/delete-task/{}/{}", a.owner, a.repo, crate::ui::enc_ref(a.rev), a.path)) { | |
| 512 | + | (crate::ui::csrf_input(a.csrf)) | |
| 513 | + | input type="hidden" name="expected_tip" value=(a.tip); | |
| 514 | + | input type="hidden" name="section" value=(s.title); | |
| 515 | + | input type="hidden" name="title" value=(t.title); | |
| 516 | + | button.card-del-btn type="submit" title="Delete task" aria-label="Delete task" { "×" } | |
| 517 | + | } | |
| 518 | + | } | |
| 380 | 519 | div.title { (render_markdown(&t.title)) } | |
| 381 | 520 | @if !t.details.trim().is_empty() { | |
| 382 | 521 | details { | |
| ⋯ 108 unchanged lines | |||
| 491 | 630 | ||
| 492 | 631 | #[test] | |
| 493 | 632 | fn board_renders_columns_tickets_and_falls_back() { | |
| 494 | - | let board = render_board(DOC).expect("has tasks").into_string(); | |
| 633 | + | let board = render_board(DOC, None).expect("has tasks").into_string(); | |
| 495 | 634 | assert!(board.contains("kanban")); | |
| 496 | 635 | assert!(board.contains("Now")); | |
| 497 | 636 | assert!(board.contains("first task")); | |
| ⋯ 3 unchanged lines | |||
| 501 | 640 | assert!(!board.contains("<input"), "no checkbox clutter: {board}"); | |
| 502 | 641 | assert!(!board.contains("ticket\""), "no ticket card class: {board}"); | |
| 503 | 642 | assert!(board.contains("just prose"), "notes area kept: {board}"); | |
| 504 | - | assert!(render_board("# readme\n\nonly prose\n").is_none()); | |
| 643 | + | assert!(render_board("# readme\n\nonly prose\n", None).is_none()); | |
| 505 | 644 | } | |
| 506 | 645 | ||
| 507 | 646 | #[test] | |
| ⋯ 58 unchanged lines | |||
| 566 | 705 | #[test] | |
| 567 | 706 | fn added_ticket_renders_as_a_board_card() { | |
| 568 | 707 | let out = add_task(DOC, "Now", "Wire uploads", "").unwrap(); | |
| 569 | - | let board = render_board(&out).expect("has tasks").into_string(); | |
| 708 | + | let board = render_board(&out, None).expect("has tasks").into_string(); | |
| 570 | 709 | assert!(board.contains("Wire uploads")); | |
| 571 | 710 | } | |
| 572 | 711 | ||
| ⋯ 3 unchanged lines | |||
| 576 | 715 | assert!(task_sections("# readme\n\nonly prose\n") == ["readme"]); | |
| 577 | 716 | assert!(task_sections("no headings at all\n").is_empty()); | |
| 578 | 717 | } | |
| 718 | + | ||
| 719 | + | #[test] | |
| 720 | + | fn add_then_remove_task_round_trips() { | |
| 721 | + | // Removing a just-added ticket restores the document byte-for-byte. | |
| 722 | + | let added = add_task(DOC, "Now", "throwaway", "with a body line").unwrap(); | |
| 723 | + | assert_eq!( | |
| 724 | + | remove_task(&added, "Now", "throwaway").as_deref(), | |
| 725 | + | Some(DOC) | |
| 726 | + | ); | |
| 727 | + | } | |
| 728 | + | ||
| 729 | + | #[test] | |
| 730 | + | fn remove_task_deletes_ticket_block() { | |
| 731 | + | let doc = "# Now\n\n## first\n\nbody one\n\n## second\n\nbody two\n"; | |
| 732 | + | assert_eq!( | |
| 733 | + | remove_task(doc, "Now", "first").unwrap(), | |
| 734 | + | "# Now\n\n## second\n\nbody two\n" | |
| 735 | + | ); | |
| 736 | + | // Removing the last ticket leaves the section heading intact. | |
| 737 | + | assert_eq!( | |
| 738 | + | remove_task(doc, "Now", "second").unwrap(), | |
| 739 | + | "# Now\n\n## first\n\nbody one\n" | |
| 740 | + | ); | |
| 741 | + | } | |
| 742 | + | ||
| 743 | + | #[test] | |
| 744 | + | fn remove_task_deletes_checkbox_with_its_details() { | |
| 745 | + | let doc = "# Now\n\n- [ ] a\n detail of a\n- [x] b\n\n# Done\n"; | |
| 746 | + | assert_eq!( | |
| 747 | + | remove_task(doc, "Now", "a").unwrap(), | |
| 748 | + | "# Now\n\n- [x] b\n\n# Done\n" | |
| 749 | + | ); | |
| 750 | + | } | |
| 751 | + | ||
| 752 | + | #[test] | |
| 753 | + | fn remove_task_rejects_missing_section_or_task() { | |
| 754 | + | assert!(remove_task(DOC, "Nope", "first task").is_none()); | |
| 755 | + | assert!(remove_task(DOC, "Now", "not a real task").is_none()); | |
| 756 | + | } | |
| 579 | 757 | } | |
modifiedcrates/anvil-web/src/ui.rs+88 −7
| ⋯ 193 unchanged lines | |||
| 194 | 194 | .kanban .col { flex:1 1 0; min-width:240px; } | |
| 195 | 195 | .kanban .col h3 { margin:0 0 12px; padding:0 2px 8px; font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--muted); display:flex; align-items:baseline; gap:8px; border-bottom:1px solid var(--border); } | |
| 196 | 196 | .kanban .col h3 .count { font-weight:400; letter-spacing:0; text-transform:none; font-size:12px; margin-left:auto; } | |
| 197 | - | .kanban .card { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:9px 12px; margin-bottom:8px; font-size:13px; line-height:1.45; box-shadow:0 1px 2px rgba(27,31,36,.05); } | |
| 197 | + | .kanban .card { position:relative; background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:9px 12px; margin-bottom:8px; font-size:13px; line-height:1.45; box-shadow:0 1px 2px rgba(27,31,36,.05); } | |
| 198 | + | .kanban .card-del { position:absolute; top:3px; right:4px; margin:0; } | |
| 199 | + | .kanban .card-del-btn { border:0; background:none; color:var(--muted); cursor:pointer; font-size:16px; line-height:1; padding:1px 5px; border-radius:4px; opacity:0; transition:opacity .1s,background .1s; } | |
| 200 | + | .kanban .card:hover .card-del-btn, .card-del-btn:focus { opacity:1; } | |
| 201 | + | .kanban .card-del-btn:hover { color:#cf222e; background:var(--code-bg); } | |
| 202 | + | .kanban .card .title { padding-right:14px; } | |
| 198 | 203 | .kanban .card .title p { margin:0; font-weight:500; } | |
| 199 | 204 | .kanban .card.done .title { color:var(--muted); text-decoration:line-through; font-weight:400; } | |
| 200 | 205 | .kanban .card details { margin-top:7px; } | |
| ⋯ 158 unchanged lines | |||
| 359 | 364 | "/{owner}/{repo}/add-task/{rev}/{*path}", | |
| 360 | 365 | get(add_task_form).post(add_task_submit), | |
| 361 | 366 | ) | |
| 367 | + | .route( | |
| 368 | + | "/{owner}/{repo}/delete-task/{rev}/{*path}", | |
| 369 | + | post(delete_task), | |
| 370 | + | ) | |
| 362 | 371 | .route("/{owner}/{repo}/commits/{rev}", get(commits)) | |
| 363 | 372 | .route("/{owner}/{repo}/commit/{id}", get(commit)) | |
| 364 | 373 | .route("/{owner}/{repo}/ci", get(ci_runs)) | |
| ⋯ 783 unchanged lines | |||
| 1148 | 1157 | .find(|e| !e.is_dir && e.name.eq_ignore_ascii_case("todo.md")) | |
| 1149 | 1158 | .and_then(|e| { | |
| 1150 | 1159 | let bytes = browse::read_blob(&path, &rev, &e.name).ok().flatten()?; | |
| 1151 | - | let board = todomd::render_board(&String::from_utf8_lossy(&bytes))?; | |
| 1160 | + | let board = todomd::render_board(&String::from_utf8_lossy(&bytes), None)?; | |
| 1152 | 1161 | Some((board, e.name.clone())) | |
| 1153 | 1162 | }); | |
| 1154 | 1163 | ||
| ⋯ 83 unchanged lines | |||
| 1238 | 1247 | async fn blob( | |
| 1239 | 1248 | State(app): State<App>, | |
| 1240 | 1249 | CurrentUser(user): CurrentUser, | |
| 1250 | + | csrf: Csrf, | |
| 1241 | 1251 | Path((owner, repo, rev, path)): Path<(String, String, String, String)>, | |
| 1242 | 1252 | Query(query): Query<HashMap<String, String>>, | |
| 1243 | 1253 | ) -> Result<Markup, Response> { | |
| ⋯ 3 unchanged lines | |||
| 1247 | 1257 | .ok_or_else(|| not_found("file not found"))?; | |
| 1248 | 1258 | ||
| 1249 | 1259 | // Editing writes a commit onto a branch, so it's offered only to writers | |
| 1250 | - | // viewing a text file at a branch tip (not a tag or detached commit). | |
| 1251 | - | let can_edit = !is_binary(&bytes) | |
| 1252 | - | && access::can_write(&meta, user.as_ref()) | |
| 1253 | - | && browse::resolve_commit(&repo_path, &format!("refs/heads/{rev}")).is_ok(); | |
| 1260 | + | // viewing a text file at a branch tip (not a tag or detached commit). The | |
| 1261 | + | // resolved tip is the compare-and-swap guard for board delete actions. | |
| 1262 | + | let edit_tip = (!is_binary(&bytes) && access::can_write(&meta, user.as_ref())) | |
| 1263 | + | .then(|| browse::resolve_commit(&repo_path, &format!("refs/heads/{rev}")).ok()) | |
| 1264 | + | .flatten(); | |
| 1265 | + | let can_edit = edit_tip.is_some(); | |
| 1254 | 1266 | ||
| 1255 | 1267 | let markdown = is_markdown(&path) && !is_binary(&bytes); | |
| 1256 | 1268 | // Custom renderers for well-known filenames (the plugin point — add new | |
| 1257 | 1269 | // filename → renderer pairs here). TODO.md defaults to a kanban board. | |
| 1258 | 1270 | let is_todo = todomd::is_todo_md(&path) && !is_binary(&bytes); | |
| 1271 | + | let board_actions = edit_tip.as_ref().map(|tip| todomd::BoardActions { | |
| 1272 | + | owner: &owner, | |
| 1273 | + | repo: &repo, | |
| 1274 | + | rev: &rev, | |
| 1275 | + | path: &path, | |
| 1276 | + | tip, | |
| 1277 | + | csrf: &csrf.0, | |
| 1278 | + | }); | |
| 1259 | 1279 | let board = (is_todo && !query.contains_key("plain") && !query.contains_key("md")) | |
| 1260 | - | .then(|| todomd::render_board(&String::from_utf8_lossy(&bytes))) | |
| 1280 | + | .then(|| todomd::render_board(&String::from_utf8_lossy(&bytes), board_actions.as_ref())) | |
| 1261 | 1281 | .flatten(); | |
| 1262 | 1282 | let rendered = markdown && !query.contains_key("plain") && board.is_none(); | |
| 1263 | 1283 | ||
| ⋯ 437 unchanged lines | |||
| 1701 | 1721 | } | |
| 1702 | 1722 | } | |
| 1703 | 1723 | ||
| 1724 | + | #[derive(serde::Deserialize)] | |
| 1725 | + | struct DeleteTaskForm { | |
| 1726 | + | #[serde(default)] | |
| 1727 | + | csrf: String, | |
| 1728 | + | expected_tip: String, | |
| 1729 | + | section: String, | |
| 1730 | + | title: String, | |
| 1731 | + | } | |
| 1732 | + | ||
| 1733 | + | /// `POST /{owner}/{repo}/delete-task/{rev}/{*path}` — remove a task/ticket from | |
| 1734 | + | /// a `TODO.md` (the ✕ on a board card) and commit. Compare-and-swap guarded by | |
| 1735 | + | /// `expected_tip`, so a concurrent change is rejected rather than clobbered. | |
| 1736 | + | async fn delete_task( | |
| 1737 | + | State(app): State<App>, | |
| 1738 | + | CurrentUser(user): CurrentUser, | |
| 1739 | + | csrf: Csrf, | |
| 1740 | + | Path((owner, repo, rev, path)): Path<(String, String, String, String)>, | |
| 1741 | + | Form(form): Form<DeleteTaskForm>, | |
| 1742 | + | ) -> Response { | |
| 1743 | + | let repo_path = match resolve_for_edit(&app, user.as_ref(), &owner, &repo, &rev).await { | |
| 1744 | + | Ok((p, _)) => p, | |
| 1745 | + | Err(resp) => return resp, | |
| 1746 | + | }; | |
| 1747 | + | if let Err(resp) = verify_csrf(&csrf, &form.csrf) { | |
| 1748 | + | return resp; | |
| 1749 | + | } | |
| 1750 | + | let user = user.expect("resolve_for_edit requires a logged-in user"); | |
| 1751 | + | if !todomd::is_todo_md(&path) { | |
| 1752 | + | return not_found("not a TODO.md"); | |
| 1753 | + | } | |
| 1754 | + | let bytes = match browse::read_blob(&repo_path, &rev, &path) { | |
| 1755 | + | Ok(Some(b)) => b, | |
| 1756 | + | Ok(None) => return not_found("file not found"), | |
| 1757 | + | Err(e) => return server_error(e), | |
| 1758 | + | }; | |
| 1759 | + | let text = String::from_utf8_lossy(&bytes); | |
| 1760 | + | ||
| 1761 | + | let Some(updated) = todomd::remove_task(&text, &form.section, &form.title) else { | |
| 1762 | + | // Already gone (e.g. a double submit) — just show the current board. | |
| 1763 | + | return Redirect::to(&format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(&rev))) | |
| 1764 | + | .into_response(); | |
| 1765 | + | }; | |
| 1766 | + | ||
| 1767 | + | let message = format!("Delete task: {}", form.title); | |
| 1768 | + | match anvil_git::edit::commit_file_change( | |
| 1769 | + | &repo_path, | |
| 1770 | + | &rev, | |
| 1771 | + | &form.expected_tip, | |
| 1772 | + | &path, | |
| 1773 | + | updated.as_bytes(), | |
| 1774 | + | &user.username, | |
| 1775 | + | &user.email, | |
| 1776 | + | &message, | |
| 1777 | + | ) { | |
| 1778 | + | Ok(_) => { | |
| 1779 | + | Redirect::to(&format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(&rev))).into_response() | |
| 1780 | + | } | |
| 1781 | + | Err(e) => bad_request_page(Some(&user), &format!("Couldn't delete the task: {e}")), | |
| 1782 | + | } | |
| 1783 | + | } | |
| 1784 | + | ||
| 1704 | 1785 | /// The add-task form: a section dropdown, a title field, and a Markdown | |
| 1705 | 1786 | /// description (which supports paste/drop image upload, like the file editor). | |
| 1706 | 1787 | #[allow(clippy::too_many_arguments)] | |
| ⋯ 814 unchanged lines | |||