anvilsign in

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
332332 out
333333 }
334334
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+
335464 /// Render a todo-md document as a kanban board (columns = task-bearing
336465 /// sections) with prose sections as a notes area below. `None` if the file
337466 /// 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> {
339468 let sections = parse(text);
340469 if sections.iter().all(|s| s.tasks.is_empty()) {
341470 return None;
⋯ 35 unchanged lines
377506 }
378507 @for t in &s.tasks {
379508 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+ }
380519 div.title { (render_markdown(&t.title)) }
381520 @if !t.details.trim().is_empty() {
382521 details {
⋯ 108 unchanged lines
491630
492631 #[test]
493632 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();
495634 assert!(board.contains("kanban"));
496635 assert!(board.contains("Now"));
497636 assert!(board.contains("first task"));
⋯ 3 unchanged lines
501640 assert!(!board.contains("<input"), "no checkbox clutter: {board}");
502641 assert!(!board.contains("ticket\""), "no ticket card class: {board}");
503642 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());
505644 }
506645
507646 #[test]
⋯ 58 unchanged lines
566705 #[test]
567706 fn added_ticket_renders_as_a_board_card() {
568707 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();
570709 assert!(board.contains("Wire uploads"));
571710 }
572711
⋯ 3 unchanged lines
576715 assert!(task_sections("# readme\n\nonly prose\n") == ["readme"]);
577716 assert!(task_sections("no headings at all\n").is_empty());
578717 }
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+ }
579757 }
modifiedcrates/anvil-web/src/ui.rs+88 −7
⋯ 193 unchanged lines
194194 .kanban .col { flex:1 1 0; min-width:240px; }
195195 .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); }
196196 .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; }
198203 .kanban .card .title p { margin:0; font-weight:500; }
199204 .kanban .card.done .title { color:var(--muted); text-decoration:line-through; font-weight:400; }
200205 .kanban .card details { margin-top:7px; }
⋯ 158 unchanged lines
359364 "/{owner}/{repo}/add-task/{rev}/{*path}",
360365 get(add_task_form).post(add_task_submit),
361366 )
367+ .route(
368+ "/{owner}/{repo}/delete-task/{rev}/{*path}",
369+ post(delete_task),
370+ )
362371 .route("/{owner}/{repo}/commits/{rev}", get(commits))
363372 .route("/{owner}/{repo}/commit/{id}", get(commit))
364373 .route("/{owner}/{repo}/ci", get(ci_runs))
⋯ 783 unchanged lines
11481157 .find(|e| !e.is_dir && e.name.eq_ignore_ascii_case("todo.md"))
11491158 .and_then(|e| {
11501159 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)?;
11521161 Some((board, e.name.clone()))
11531162 });
11541163
⋯ 83 unchanged lines
12381247 async fn blob(
12391248 State(app): State<App>,
12401249 CurrentUser(user): CurrentUser,
1250+ csrf: Csrf,
12411251 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
12421252 Query(query): Query<HashMap<String, String>>,
12431253 ) -> Result<Markup, Response> {
⋯ 3 unchanged lines
12471257 .ok_or_else(|| not_found("file not found"))?;
12481258
12491259 // 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();
12541266
12551267 let markdown = is_markdown(&path) && !is_binary(&bytes);
12561268 // Custom renderers for well-known filenames (the plugin point — add new
12571269 // filename → renderer pairs here). TODO.md defaults to a kanban board.
12581270 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+ });
12591279 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()))
12611281 .flatten();
12621282 let rendered = markdown && !query.contains_key("plain") && board.is_none();
12631283
⋯ 437 unchanged lines
17011721 }
17021722 }
17031723
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+
17041785 /// The add-task form: a section dropdown, a title field, and a Markdown
17051786 /// description (which supports paste/drop image upload, like the file editor).
17061787 #[allow(clippy::too_many_arguments)]
⋯ 814 unchanged lines