anvilsign in

collin/anvil · 8be5d6bc

feat: drag-and-drop task reordering on the TODO.md board

Collin Richards · 2026-06-10 16:22 UTC · 8be5d6bcf48eaa4447d6c3403fe089aef7de043b · parent 76805bb1 · browse files

modifiedcrates/anvil-web/src/todomd.rs+272 −39
⋯ 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;
335+/// Where a ticket body ends: the next heading (any level) or `end`. Fenced
336+/// regions are skipped so a `#` inside a code block doesn't end it.
337+fn ticket_body_end(lines: &[&str], end: usize, from: usize) -> usize {
338+ let mut j = from;
339+ let mut f = false;
340+ while j < end {
341+ let l = lines[j].trim_end_matches('\r');
342+ if l.trim_start().starts_with("```") {
343+ f = !f;
344+ } else if !f && heading(l).is_some() {
345+ break;
346+ }
347+ j += 1;
346348 }
349+ j
350+}
347351
348- // The section body span [start, end), exactly as add_task locates it.
352+/// Body line span `[start, end)` of the column-level section named `section`
353+/// (the lines after its heading, up to the next column heading or EOF).
354+fn find_section_body(lines: &[&str], col: u8, section: &str) -> Option<(usize, usize)> {
349355 let mut start = None;
350356 let mut end = lines.len();
351357 let mut in_fence = false;
⋯ 19 unchanged lines
371377 }
372378 }
373379 }
374- let start = start?;
380+ start.map(|s| (s, end))
381+}
375382
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).
383+/// The line span `[from, to)` of the task titled `title` within a section body
384+/// `[start, end)` — a ticket (heading + body) or a checkbox line (+ indented
385+/// details). Trailing blank lines are excluded so the span is the task's own
386+/// content. `None` if not found.
387+fn find_task_block(
388+ lines: &[&str],
389+ col: u8,
390+ start: usize,
391+ end: usize,
392+ title: &str,
393+) -> Option<(usize, usize)> {
393394 let mut from = None;
394395 let mut to = end;
395396 let mut i = start;
⋯ 8 unchanged lines
404405 if !fence {
405406 if let Some((level, rest)) = heading(line) {
406407 if level > col {
407- let body_end = ticket_body_end(i + 1);
408+ let body_end = ticket_body_end(lines, end, i + 1);
408409 if strip_marker(rest) == title {
409410 from = Some(i);
410411 to = body_end;
411412 break;
412413 }
413- i = body_end; // skip a non-matching ticket whole
414+ i = body_end;
414415 continue;
415416 }
416417 } else if let Some((_, t)) = task_line(line) {
⋯ 6 unchanged lines
423424 to = j;
424425 break;
425426 }
426- i = j; // skip a non-matching task with its details
427+ i = j;
427428 continue;
428429 }
429430 }
430431 i += 1;
431432 }
432433 let from = from?;
433-
434- // Don't swallow the blank separator before the next item/heading.
435434 while to > from && lines[to - 1].trim().is_empty() {
436435 to -= 1;
437436 }
437+ Some((from, to))
438+}
439+
440+/// The start line of the `n`-th (0-based) task within `[start, end)`, or `None`
441+/// if there are fewer than `n + 1` tasks (caller appends at the section end).
442+fn nth_task_start(lines: &[&str], col: u8, start: usize, end: usize, n: usize) -> Option<usize> {
443+ let mut count = 0;
444+ let mut i = start;
445+ let mut fence = false;
446+ while i < end {
447+ let line = lines[i].trim_end_matches('\r');
448+ if line.trim_start().starts_with("```") {
449+ fence = !fence;
450+ i += 1;
451+ continue;
452+ }
453+ if !fence {
454+ if let Some((level, _)) = heading(line) {
455+ if level > col {
456+ if count == n {
457+ return Some(i);
458+ }
459+ count += 1;
460+ i = ticket_body_end(lines, end, i + 1);
461+ continue;
462+ }
463+ } else if task_line(line).is_some() {
464+ if count == n {
465+ return Some(i);
466+ }
467+ count += 1;
468+ let mut j = i + 1;
469+ while j < end && lines[j].starts_with(" ") {
470+ j += 1;
471+ }
472+ i = j;
473+ continue;
474+ }
475+ }
476+ i += 1;
477+ }
478+ None
479+}
480+
481+/// The inverse of [`add_task`]: delete the task titled `title` from the section
482+/// named `section`, touching no other byte (round-trip rule). Handles both a
483+/// ticket (a nested `## title` heading and its body, up to the next heading)
484+/// and a `- [ ]` checkbox task (its line plus indented detail lines). Returns
485+/// `None` if the section or task isn't found. Removes its own trailing blank
486+/// lines and collapses the blank seam left behind so spacing stays tidy.
487+pub fn remove_task(text: &str, section: &str, title: &str) -> Option<String> {
488+ let lines: Vec<&str> = text.split('\n').collect();
489+ let col = column_level(text);
490+ if col == 0 {
491+ return None;
492+ }
493+ let (start, end) = find_section_body(&lines, col, section)?;
494+ let (from, to) = find_task_block(&lines, col, start, end, title)?;
438495
439496 let mut out: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
440497 out.drain(from..to);
441- // Collapse a double blank left where the block was.
442498 if from > 0
443499 && from < out.len()
444500 && out[from - 1].trim().is_empty()
⋯ 4 unchanged lines
449505 Some(out.join("\n"))
450506 }
451507
508+/// Move the task titled `title` from `from_section` to position `to_index`
509+/// (0-based, among that section's tasks) of `to_section` — the same section to
510+/// reorder, or a different one to move between columns. The task's own block is
511+/// preserved verbatim; only its placement (and the surrounding blank-line
512+/// spacing) changes. `None` if the task or either section isn't found.
513+pub fn move_task(
514+ text: &str,
515+ title: &str,
516+ from_section: &str,
517+ to_section: &str,
518+ to_index: usize,
519+) -> Option<String> {
520+ let lines: Vec<&str> = text.split('\n').collect();
521+ let col = column_level(text);
522+ if col == 0 {
523+ return None;
524+ }
525+
526+ // Cut the task's block out of its source section.
527+ let (fs, fe) = find_section_body(&lines, col, from_section)?;
528+ let (from, to) = find_task_block(&lines, col, fs, fe, title)?;
529+ let block: Vec<String> = lines[from..to].iter().map(|l| l.to_string()).collect();
530+
531+ let mut out: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
532+ out.drain(from..to);
533+ if from > 0
534+ && from < out.len()
535+ && out[from - 1].trim().is_empty()
536+ && out[from].trim().is_empty()
537+ {
538+ out.remove(from);
539+ }
540+
541+ // Re-locate the target section in the post-removal document and splice the
542+ // block in, keeping one blank line on each side.
543+ let refs: Vec<&str> = out.iter().map(|s| s.as_str()).collect();
544+ let (ts, te) = find_section_body(&refs, col, to_section)?;
545+ match nth_task_start(&refs, col, ts, te, to_index) {
546+ Some(at) => {
547+ let mut ins = block;
548+ ins.push(String::new()); // blank between the moved block and the task below it
549+ out.splice(at..at, ins);
550+ }
551+ None => {
552+ // Append after the section's last non-blank line.
553+ let mut at = te;
554+ while at > ts && refs[at - 1].trim().is_empty() {
555+ at -= 1;
556+ }
557+ let mut ins = vec![String::new()];
558+ ins.extend(block);
559+ out.splice(at..at, ins);
560+ }
561+ }
562+ Some(out.join("\n"))
563+}
564+
452565 /// What the board needs to render per-task delete controls: the repo
453566 /// coordinates, the branch tip (compare-and-swap guard), and a CSRF token.
454567 /// `None` renders a read-only board.
⋯ 34 unchanged lines
489602 }
490603 }
491604
605+ let move_url = actions.map(|a| {
606+ format!(
607+ "/{}/{}/move-task/{}/{}",
608+ a.owner,
609+ a.repo,
610+ crate::ui::enc_ref(a.rev),
611+ a.path
612+ )
613+ });
492614 Some(html! {
493- div.kanban {
615+ div.kanban data-move-url=[move_url.as_deref()]
616+ data-tip=[actions.map(|a| a.tip)] data-csrf=[actions.map(|a| a.csrf)] {
494617 @for s in sections.iter().filter(|s| !s.tasks.is_empty()) {
495- div.col {
618+ div.col data-section=[actions.map(|_| s.title.as_str())] {
496619 h3 {
497620 @if s.title.is_empty() { "Tasks" } @else { (s.title) }
498621 span.count {
⋯ 6 unchanged lines
505628 }
506629 }
507630 @for t in &s.tasks {
508- div.card.done[t.done] {
631+ div.card.done[t.done]
632+ draggable=[actions.map(|_| "true")]
633+ data-title=[actions.map(|_| t.title.as_str())] {
509634 @if let Some(a) = actions {
510635 form.card-del method="post"
511636 action=(format!("/{}/{}/delete-task/{}/{}", a.owner, a.repo, crate::ui::enc_ref(a.rev), a.path)) {
⋯ 22 unchanged lines
534659 div.md-body { (render_markdown(&notes)) }
535660 }
536661 }
662+ @if actions.is_some() { script { (maud::PreEscaped(BOARD_DND_JS)) } }
537663 })
538664 }
539665
666+/// Drag-and-drop reordering for the board: drag a card within or across
667+/// columns; on drop, POST the new (section, index) to the move endpoint and
668+/// reload. Reads the endpoint/tip/csrf from the `.kanban` element's data-attrs.
669+const BOARD_DND_JS: &str = r#"
670+(function(){
671+ var board = document.querySelector('.kanban[data-move-url]');
672+ if (!board) return;
673+ var url = board.dataset.moveUrl, tip = board.dataset.tip, csrf = board.dataset.csrf;
674+ var dragged = null, fromSection = null;
675+
676+ board.addEventListener('dragstart', function(e){
677+ var card = e.target.closest('.card');
678+ if (!card) return;
679+ dragged = card;
680+ fromSection = card.closest('.col').dataset.section;
681+ setTimeout(function(){ card.classList.add('dragging'); }, 0);
682+ e.dataTransfer.effectAllowed = 'move';
683+ });
684+ board.addEventListener('dragend', function(){
685+ if (dragged) dragged.classList.remove('dragging');
686+ dragged = null;
687+ });
688+ function afterCard(col, y){
689+ var cards = [].slice.call(col.querySelectorAll('.card:not(.dragging)'));
690+ var best = null, bestOffset = -Infinity;
691+ cards.forEach(function(c){
692+ var box = c.getBoundingClientRect();
693+ var offset = y - box.top - box.height / 2;
694+ if (offset < 0 && offset > bestOffset) { bestOffset = offset; best = c; }
695+ });
696+ return best;
697+ }
698+ board.addEventListener('dragover', function(e){
699+ if (!dragged) return;
700+ var col = e.target.closest('.col');
701+ if (!col) return;
702+ e.preventDefault();
703+ var after = afterCard(col, e.clientY);
704+ if (after == null) col.appendChild(dragged);
705+ else col.insertBefore(dragged, after);
706+ });
707+ board.addEventListener('drop', function(e){
708+ if (!dragged) return;
709+ e.preventDefault();
710+ var col = dragged.closest('.col');
711+ var cards = [].slice.call(col.querySelectorAll('.card'));
712+ var body = new URLSearchParams();
713+ body.set('csrf', csrf);
714+ body.set('expected_tip', tip);
715+ body.set('from_section', fromSection);
716+ body.set('to_section', col.dataset.section);
717+ body.set('title', dragged.dataset.title);
718+ body.set('to_index', String(cards.indexOf(dragged)));
719+ fetch(url, {
720+ method: 'POST',
721+ headers: {'X-CSRF-Token': csrf, 'Content-Type': 'application/x-www-form-urlencoded'},
722+ body: body.toString()
723+ }).then(function(r){
724+ if (!r.ok) throw new Error('move failed (' + r.status + ')');
725+ location.reload();
726+ }).catch(function(err){ console.error(err); location.reload(); });
727+ });
728+})();
729+"#;
730+
540731 #[cfg(test)]
541732 mod tests {
542733 use super::*;
⋯ 211 unchanged lines
754945 assert!(remove_task(DOC, "Nope", "first task").is_none());
755946 assert!(remove_task(DOC, "Now", "not a real task").is_none());
756947 }
948+
949+ /// Ticket titles of a section, in order — order is what reordering changes.
950+ fn order(text: &str, section: &str) -> Vec<String> {
951+ parse(text)
952+ .into_iter()
953+ .find(|s| s.title == section)
954+ .map(|s| s.tasks.into_iter().map(|t| t.title).collect())
955+ .unwrap_or_default()
956+ }
957+
958+ #[test]
959+ fn move_task_reorders_within_a_section() {
960+ let doc = "# Now\n\n## a\n\n## b\n\n## c\n";
961+ assert_eq!(
962+ order(&move_task(doc, "c", "Now", "Now", 0).unwrap(), "Now"),
963+ ["c", "a", "b"]
964+ );
965+ // to_index past the end appends.
966+ assert_eq!(
967+ order(&move_task(doc, "a", "Now", "Now", 9).unwrap(), "Now"),
968+ ["b", "c", "a"]
969+ );
970+ }
971+
972+ #[test]
973+ fn move_task_across_sections_preserves_body() {
974+ let doc = "# Now\n\n## a\n\nbody of a\n\n# Done\n\n## b\n";
975+ let out = move_task(doc, "a", "Now", "Done", 1).unwrap();
976+ assert_eq!(order(&out, "Now"), Vec::<String>::new());
977+ assert_eq!(order(&out, "Done"), ["b", "a"]);
978+ assert!(
979+ out.contains("## a\n\nbody of a"),
980+ "body travels with the ticket"
981+ );
982+ }
983+
984+ #[test]
985+ fn move_task_rejects_unknown_task_or_section() {
986+ let doc = "# Now\n\n## a\n\n# Done\n";
987+ assert!(move_task(doc, "ghost", "Now", "Done", 0).is_none());
988+ assert!(move_task(doc, "a", "Now", "Nope", 0).is_none());
989+ }
757990 }
modifiedcrates/anvil-web/src/ui.rs+76 −0
⋯ 199 unchanged lines
200200 .kanban .card:hover .card-del-btn, .card-del-btn:focus { opacity:1; }
201201 .kanban .card-del-btn:hover { color:#cf222e; background:var(--code-bg); }
202202 .kanban .card .title { padding-right:14px; }
203+.kanban .card[draggable="true"] { cursor:grab; }
204+.kanban .card.dragging { opacity:.4; cursor:grabbing; }
203205 .kanban .card .title p { margin:0; font-weight:500; }
204206 .kanban .card.done .title { color:var(--muted); text-decoration:line-through; font-weight:400; }
205207 .kanban .card details { margin-top:7px; }
⋯ 162 unchanged lines
368370 "/{owner}/{repo}/delete-task/{rev}/{*path}",
369371 post(delete_task),
370372 )
373+ .route("/{owner}/{repo}/move-task/{rev}/{*path}", post(move_task))
371374 .route("/{owner}/{repo}/commits/{rev}", get(commits))
372375 .route("/{owner}/{repo}/commit/{id}", get(commit))
373376 .route("/{owner}/{repo}/ci", get(ci_runs))
⋯ 1408 unchanged lines
17821785 }
17831786 }
17841787
1788+#[derive(serde::Deserialize)]
1789+struct MoveTaskForm {
1790+ #[serde(default)]
1791+ csrf: String,
1792+ expected_tip: String,
1793+ title: String,
1794+ from_section: String,
1795+ to_section: String,
1796+ to_index: usize,
1797+}
1798+
1799+/// `POST /{owner}/{repo}/move-task/{rev}/{*path}` — reorder/move a task on the
1800+/// board (drag-and-drop). Write-gated, CSRF-checked, compare-and-swap on the
1801+/// branch tip. Driven by `fetch`, so it returns bare status codes.
1802+async fn move_task(
1803+ State(app): State<App>,
1804+ CurrentUser(user): CurrentUser,
1805+ csrf: Csrf,
1806+ Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1807+ Form(form): Form<MoveTaskForm>,
1808+) -> Response {
1809+ let repo_path = match resolve_for_edit(&app, user.as_ref(), &owner, &repo, &rev).await {
1810+ Ok((p, _)) => p,
1811+ Err(resp) => return resp,
1812+ };
1813+ if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
1814+ return resp;
1815+ }
1816+ let user = user.expect("resolve_for_edit requires a logged-in user");
1817+ if !todomd::is_todo_md(&path) {
1818+ return not_found("not a TODO.md");
1819+ }
1820+ let bytes = match browse::read_blob(&repo_path, &rev, &path) {
1821+ Ok(Some(b)) => b,
1822+ Ok(None) => return not_found("file not found"),
1823+ Err(e) => return server_error(e),
1824+ };
1825+ let text = String::from_utf8_lossy(&bytes);
1826+
1827+ let Some(updated) = todomd::move_task(
1828+ &text,
1829+ &form.title,
1830+ &form.from_section,
1831+ &form.to_section,
1832+ form.to_index,
1833+ ) else {
1834+ return (StatusCode::BAD_REQUEST, "could not move task").into_response();
1835+ };
1836+
1837+ let message = if form.from_section == form.to_section {
1838+ format!("Reorder {} in {}", form.title, form.to_section)
1839+ } else {
1840+ format!("Move {} to {}", form.title, form.to_section)
1841+ };
1842+ match anvil_git::edit::commit_file_change(
1843+ &repo_path,
1844+ &rev,
1845+ &form.expected_tip,
1846+ &path,
1847+ updated.as_bytes(),
1848+ &user.username,
1849+ &user.email,
1850+ &message,
1851+ ) {
1852+ // A no-op drop (dropped back in place) is success, not an error.
1853+ Ok(_) | Err(anvil_git::edit::EditError::NoChanges) => StatusCode::OK.into_response(),
1854+ Err(anvil_git::edit::EditError::BranchMoved { .. }) => {
1855+ (StatusCode::CONFLICT, "branch moved — reload").into_response()
1856+ }
1857+ Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
1858+ }
1859+}
1860+
17851861 /// The add-task form: a section dropdown, a title field, and a Markdown
17861862 /// description (which supports paste/drop image upload, like the file editor).
17871863 #[allow(clippy::too_many_arguments)]
⋯ 814 unchanged lines