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 | |||
| 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; | |
| 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; | |
| 346 | 348 | } | |
| 349 | + | j | |
| 350 | + | } | |
| 347 | 351 | ||
| 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)> { | |
| 349 | 355 | let mut start = None; | |
| 350 | 356 | let mut end = lines.len(); | |
| 351 | 357 | let mut in_fence = false; | |
| ⋯ 19 unchanged lines | |||
| 371 | 377 | } | |
| 372 | 378 | } | |
| 373 | 379 | } | |
| 374 | - | let start = start?; | |
| 380 | + | start.map(|s| (s, end)) | |
| 381 | + | } | |
| 375 | 382 | ||
| 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)> { | |
| 393 | 394 | let mut from = None; | |
| 394 | 395 | let mut to = end; | |
| 395 | 396 | let mut i = start; | |
| ⋯ 8 unchanged lines | |||
| 404 | 405 | if !fence { | |
| 405 | 406 | if let Some((level, rest)) = heading(line) { | |
| 406 | 407 | if level > col { | |
| 407 | - | let body_end = ticket_body_end(i + 1); | |
| 408 | + | let body_end = ticket_body_end(lines, end, i + 1); | |
| 408 | 409 | if strip_marker(rest) == title { | |
| 409 | 410 | from = Some(i); | |
| 410 | 411 | to = body_end; | |
| 411 | 412 | break; | |
| 412 | 413 | } | |
| 413 | - | i = body_end; // skip a non-matching ticket whole | |
| 414 | + | i = body_end; | |
| 414 | 415 | continue; | |
| 415 | 416 | } | |
| 416 | 417 | } else if let Some((_, t)) = task_line(line) { | |
| ⋯ 6 unchanged lines | |||
| 423 | 424 | to = j; | |
| 424 | 425 | break; | |
| 425 | 426 | } | |
| 426 | - | i = j; // skip a non-matching task with its details | |
| 427 | + | i = j; | |
| 427 | 428 | continue; | |
| 428 | 429 | } | |
| 429 | 430 | } | |
| 430 | 431 | i += 1; | |
| 431 | 432 | } | |
| 432 | 433 | let from = from?; | |
| 433 | - | ||
| 434 | - | // Don't swallow the blank separator before the next item/heading. | |
| 435 | 434 | while to > from && lines[to - 1].trim().is_empty() { | |
| 436 | 435 | to -= 1; | |
| 437 | 436 | } | |
| 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)?; | |
| 438 | 495 | ||
| 439 | 496 | let mut out: Vec<String> = lines.iter().map(|l| l.to_string()).collect(); | |
| 440 | 497 | out.drain(from..to); | |
| 441 | - | // Collapse a double blank left where the block was. | |
| 442 | 498 | if from > 0 | |
| 443 | 499 | && from < out.len() | |
| 444 | 500 | && out[from - 1].trim().is_empty() | |
| ⋯ 4 unchanged lines | |||
| 449 | 505 | Some(out.join("\n")) | |
| 450 | 506 | } | |
| 451 | 507 | ||
| 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 | + | ||
| 452 | 565 | /// What the board needs to render per-task delete controls: the repo | |
| 453 | 566 | /// coordinates, the branch tip (compare-and-swap guard), and a CSRF token. | |
| 454 | 567 | /// `None` renders a read-only board. | |
| ⋯ 34 unchanged lines | |||
| 489 | 602 | } | |
| 490 | 603 | } | |
| 491 | 604 | ||
| 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 | + | }); | |
| 492 | 614 | 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)] { | |
| 494 | 617 | @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())] { | |
| 496 | 619 | h3 { | |
| 497 | 620 | @if s.title.is_empty() { "Tasks" } @else { (s.title) } | |
| 498 | 621 | span.count { | |
| ⋯ 6 unchanged lines | |||
| 505 | 628 | } | |
| 506 | 629 | } | |
| 507 | 630 | @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())] { | |
| 509 | 634 | @if let Some(a) = actions { | |
| 510 | 635 | form.card-del method="post" | |
| 511 | 636 | action=(format!("/{}/{}/delete-task/{}/{}", a.owner, a.repo, crate::ui::enc_ref(a.rev), a.path)) { | |
| ⋯ 22 unchanged lines | |||
| 534 | 659 | div.md-body { (render_markdown(¬es)) } | |
| 535 | 660 | } | |
| 536 | 661 | } | |
| 662 | + | @if actions.is_some() { script { (maud::PreEscaped(BOARD_DND_JS)) } } | |
| 537 | 663 | }) | |
| 538 | 664 | } | |
| 539 | 665 | ||
| 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 | + | ||
| 540 | 731 | #[cfg(test)] | |
| 541 | 732 | mod tests { | |
| 542 | 733 | use super::*; | |
| ⋯ 211 unchanged lines | |||
| 754 | 945 | assert!(remove_task(DOC, "Nope", "first task").is_none()); | |
| 755 | 946 | assert!(remove_task(DOC, "Now", "not a real task").is_none()); | |
| 756 | 947 | } | |
| 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 | + | } | |
| 757 | 990 | } | |
modifiedcrates/anvil-web/src/ui.rs+76 −0
| ⋯ 199 unchanged lines | |||
| 200 | 200 | .kanban .card:hover .card-del-btn, .card-del-btn:focus { opacity:1; } | |
| 201 | 201 | .kanban .card-del-btn:hover { color:#cf222e; background:var(--code-bg); } | |
| 202 | 202 | .kanban .card .title { padding-right:14px; } | |
| 203 | + | .kanban .card[draggable="true"] { cursor:grab; } | |
| 204 | + | .kanban .card.dragging { opacity:.4; cursor:grabbing; } | |
| 203 | 205 | .kanban .card .title p { margin:0; font-weight:500; } | |
| 204 | 206 | .kanban .card.done .title { color:var(--muted); text-decoration:line-through; font-weight:400; } | |
| 205 | 207 | .kanban .card details { margin-top:7px; } | |
| ⋯ 162 unchanged lines | |||
| 368 | 370 | "/{owner}/{repo}/delete-task/{rev}/{*path}", | |
| 369 | 371 | post(delete_task), | |
| 370 | 372 | ) | |
| 373 | + | .route("/{owner}/{repo}/move-task/{rev}/{*path}", post(move_task)) | |
| 371 | 374 | .route("/{owner}/{repo}/commits/{rev}", get(commits)) | |
| 372 | 375 | .route("/{owner}/{repo}/commit/{id}", get(commit)) | |
| 373 | 376 | .route("/{owner}/{repo}/ci", get(ci_runs)) | |
| ⋯ 1408 unchanged lines | |||
| 1782 | 1785 | } | |
| 1783 | 1786 | } | |
| 1784 | 1787 | ||
| 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 | + | ||
| 1785 | 1861 | /// The add-task form: a section dropdown, a title field, and a Markdown | |
| 1786 | 1862 | /// description (which supports paste/drop image upload, like the file editor). | |
| 1787 | 1863 | #[allow(clippy::too_many_arguments)] | |
| ⋯ 814 unchanged lines | |||