@@ -5,7 +5,7 @@ use crate::ident::{slug, Id};
55use crate::parse::{Kind, Span, TaskState};
66use crate::render::render;
77use crate::tree::NRef;
8−use crate::vault::{NodeKey, Vault};
8+use crate::vault::Vault;
99
1010/// An inverse snapshot for undo (§10.11): file path → prior text. Files that
1111/// did not exist are recorded as None.
@@ -19,7 +19,13 @@ impl Inverse {
1919 pub fn apply(self, vault: &mut Vault) -> std::io::Result<()> {
2020 for (path, text) in self.files {
2121 match text {
22− Some(t) => crate::vault::atomic_write(&vault.dir.join(&path), &t)?,
22+ Some(t) => {
23+ let full = vault.dir.join(&path);
24+ // untouched files keep their bytes and mtime (§11.1)
25+ if std::fs::read_to_string(&full).ok().as_deref() != Some(t.as_str()) {
26+ crate::vault::atomic_write(&full, &t)?;
27+ }
28+ }
2329 None => {
2430 let _ = std::fs::remove_file(vault.dir.join(&path));
2531 }
@@ -77,12 +83,31 @@ fn capture_inner(
7783 find_or_create_day(vault, inbox)?
7884 }
7985 };
86+ // The first line is the title; further lines are the item's body, so a
87+ // captured document (`capture < file.md`) nests under the item instead
88+ // of injecting structure beside it (§4.1): its headings are pushed below
89+ // the destination's level and every line is indented under the item.
90+ let text = text.trim();
91+ let (first, rest) = text.split_once('\n').unwrap_or((text, ""));
8092 let title = if task {
81− format!("[ ] {}", text.trim())
93+ format!("[ ] {}", first.trim())
8294 } else {
83− text.trim().to_string()
95+ first.trim().to_string()
8496 };
85− append_child_line(vault, dest, &format!("- {}", title), task)
97+ let mut line = format!("- {}", title);
98+ if !rest.trim().is_empty() {
99+ let dn = vault.tree.node(dest);
100+ let item_indent = if dn.kind == Kind::Item {
101+ vault.tree.indent(dest) + 2
102+ } else {
103+ vault.tree.indent(dest)
104+ };
105+ let level = vault.tree.level(dest) as isize;
106+ let body = shift_lines(rest.trim_end(), level, item_indent as isize + 2);
107+ line.push('\n');
108+ line.push_str(body.trim_end_matches('\n'));
109+ }
110+ append_child_line(vault, dest, &line, false)
86111}
87112
88113fn find_or_create_inbox(vault: &mut Vault) -> std::io::Result<NRef> {
@@ -160,23 +185,51 @@ fn append_top_section(vault: &mut Vault, title: &str) -> std::io::Result<NRef> {
160185fn append_structural_line(vault: &mut Vault, parent: NRef, line: &str) -> std::io::Result<()> {
161186 let node = vault.tree.node(parent);
162187 let file = parent.0;
163− // insert at the end of the parent's span
164− let mut pos = node.span.end;
165− let text = &vault.tree.files[file].text;
166− // skip trailing newlines of the span to insert before them
167− while pos > node.span.start && text.as_bytes()[pos - 1] == b'\n' {
188+ let text = vault.tree.files[file].text.clone();
189+ let pos = trimmed_end(&text, node.span);
190+ insert_at(vault, file, pos, "\n\n", line)
191+}
192+
193+/// End of a node's span without its trailing newlines: where a new last
194+/// child is inserted.
195+fn trimmed_end(text: &str, span: Span) -> usize {
196+ let mut pos = span.end.min(text.len());
197+ while pos > span.start && text.as_bytes()[pos - 1] == b'\n' {
168198 pos -= 1;
169199 }
170− let insertion = format!("\n\n{}\n", line);
200+ pos
201+}
202+
203+/// Insert `sep` + `body` at `pos`, reusing the newline already there (the
204+/// one `trimmed_end` stepped back over) so no blank line is doubled.
205+fn insert_at(
206+ vault: &mut Vault,
207+ file: usize,
208+ pos: usize,
209+ sep: &str,
210+ body: &str,
211+) -> std::io::Result<()> {
212+ let text = &vault.tree.files[file].text;
213+ let body = body.trim_end_matches('\n');
214+ let insertion = if text[pos..].starts_with('\n') {
215+ format!("{}{}", sep, body)
216+ } else {
217+ format!("{}{}\n", sep, body)
218+ };
171219 vault.write_span(file, Span { start: pos, end: pos }, &insertion)
172220}
173221
174222/// Append an item line as the last child of a node; returns the new node.
223+///
224+/// An item cannot follow a section child: it would parse as that section's
225+/// child. With `section_ok` (the TUI's `N`) the new node is then spelled as a
226+/// section beside the last one; otherwise (capture) it stays an item and goes
227+/// after the parent's other items, before its first section.
175228fn append_child_line(
176229 vault: &mut Vault,
177230 parent: NRef,
178231 line: &str,
179− _task: bool,
232+ section_ok: bool,
180233) -> std::io::Result<NRef> {
181234 let parent_path = vault.tree.path(parent);
182235 let parent_key = vault.key_of(parent);
@@ -189,33 +242,67 @@ fn append_child_line(
189242 } else {
190243 vault.tree.indent(parent)
191244 };
192− let line_indented = format!("{}{}", " ".repeat(indent), line);
193245 let text = vault.tree.files[file].text.clone();
194− let mut pos = node.span.end;
195− while pos > node.span.start && text.as_bytes()[pos - 1] == b'\n' {
196− pos -= 1;
246+ let kids = vault.tree.raw_children(parent);
247+ let is_section = |r: NRef| vault.tree.node(r).kind == Kind::Section;
248+ let first_section = kids.iter().position(|&c| is_section(c));
249+ let index;
250+ match (first_section, section_ok) {
251+ (Some(_), true) => {
252+ // a section sibling of the last section child
253+ let last = *kids.last().unwrap();
254+ let last = if is_section(last) {
255+ last
256+ } else {
257+ *kids.iter().rev().find(|&&c| is_section(c)).unwrap()
258+ };
259+ let title = line.trim_start().strip_prefix("- ").unwrap_or(line.trim_start());
260+ let heading = format!(
261+ "{}{} {}",
262+ " ".repeat(vault.tree.indent(last)),
263+ "#".repeat(vault.tree.level(last)),
264+ title
265+ );
266+ let pos = trimmed_end(&text, node.span);
267+ insert_at(vault, file, pos, "\n\n", &heading)?;
268+ index = kids.len();
269+ }
270+ (Some(fs), false) => {
271+ // before the first section child, after the items and body
272+ let line_indented = format!("{}{}", " ".repeat(indent), line);
273+ let mut pos = vault.tree.node(kids[fs]).span.start;
274+ while pos >= 2 && &text.as_bytes()[pos - 2..pos] == b"\n\n" {
275+ pos -= 1;
276+ }
277+ let prev_is_item = fs > 0 && !is_section(kids[fs - 1]);
278+ let sep = if prev_is_item { "" } else { "\n" };
279+ vault.write_span(
280+ file,
281+ Span { start: pos, end: pos },
282+ &format!("{}{}\n", sep, line_indented),
283+ )?;
284+ index = fs;
285+ }
286+ (None, _) => {
287+ let line_indented = format!("{}{}", " ".repeat(indent), line);
288+ let pos = trimmed_end(&text, node.span);
289+ // blank line before unless the previous sibling is also an item
290+ // (tight list)
291+ let prev_is_item = kids.last().map(|&c| !is_section(c)).unwrap_or(false);
292+ let sep = if prev_is_item { "\n" } else { "\n\n" };
293+ insert_at(vault, file, pos, sep, &line_indented)?;
294+ index = kids.len();
295+ }
197296 }
198− // blank line before unless the previous sibling is also an item (tight
199− // list) — the previous child is the last in document order
200− let prev_is_item = vault
201− .tree
202− .resolved_children(parent)
203− .last()
204− .map(|&c| vault.tree.node(c).kind == Kind::Item)
205− .unwrap_or(false);
206− let insertion = if prev_is_item {
207− format!("\n{}\n", line_indented)
208− } else {
209− format!("\n\n{}\n", line_indented)
210− };
211− vault.write_span(file, Span { start: pos, end: pos }, &insertion)?;
212297 vault.reload()?;
213298 let parent = vault
214299 .find_by_key(&parent_key)
215300 .or_else(|| vault.find_by_path(&parent_path))
216301 .ok_or_else(|| io_err("parent lost after reload"))?;
217− let kids = vault.tree.resolved_children(parent);
218− kids.last()
302+ vault
303+ .tree
304+ .resolved_children(parent)
305+ .get(index)
219306 .copied()
220307 .ok_or_else(|| io_err("child not created"))
221308}
@@ -242,12 +329,16 @@ pub fn toggle_task(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
242329 let file = r.0;
243330 let ts = n.title_span;
244331 let line = ts.text(&vault.tree.files[file].text).to_string();
245− let (from, to) = match n.task {
246− Some(TaskState::Open) => ("[ ]", "[x]"),
247− Some(TaskState::Done) => ("[x]", "[ ]"),
332+ let to = match n.task {
333+ Some(TaskState::Open) => "[x]",
334+ Some(TaskState::Done) => "[ ]", // also rewrites `[X]` / `[-]`
248335 None => return Ok(()), // not a task; `t` makes it one
249336 };
250− let new_line = line.replacen(from, to, 1);
337+ let Some((start, _)) = checkbox_span(&line) else {
338+ return Ok(());
339+ };
340+ let mut new_line = line.clone();
341+ new_line.replace_range(start..start + 3, to);
251342 vault.write_span(file, ts, &new_line)
252343 }
253344}
@@ -272,29 +363,57 @@ pub fn toggle_taskness(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
272363 let ts = n.title_span;
273364 let line = ts.text(&vault.tree.files[file].text).to_string();
274365 let new_line = match n.task {
275− Some(TaskState::Open) => line.replacen("[ ] ", "", 1),
276− Some(TaskState::Done) => line
277− .replacen("[x] ", "", 1)
278− .replacen("[X] ", "", 1)
279− .replacen("[-] ", "", 1),
280− None => {
281− // insert `[ ] ` after the marker
282− if line.trim_start().starts_with('#') {
283− let idx = line.find(' ').map(|i| i + 1).unwrap_or(line.len());
284− format!("{}[ ] {}", &line[..idx], &line[idx..])
285− } else if let Some(pos) = line.find("- ") {
286− let mut s = line.clone();
287− s.replace_range(pos..pos + 2, "- [ ] ");
288− s
289− } else {
290− line.clone()
291− }
292− }
366+ Some(_) => match checkbox_span(&line) {
367+ Some((start, end)) => format!("{}{}", &line[..start], &line[end..]),
368+ None => line.clone(),
369+ },
370+ // insert `[ ] ` right after the marker, whatever the indent
371+ None => match marker_end(&line) {
372+ Some(idx) => format!("{}[ ] {}", &line[..idx], &line[idx..]),
373+ None => line.clone(),
374+ },
293375 };
294376 vault.write_span(file, ts, &new_line)
295377 }
296378}
297379
380+/// Byte offset just past a title line's marker: `#…` plus its spaces, or
381+/// `- ` / `* ` / `+ `, after any indent.
382+fn marker_end(line: &str) -> Option<usize> {
383+ let indent = line.len() - line.trim_start().len();
384+ let rest = &line[indent..];
385+ if rest.starts_with('#') {
386+ let h = rest.bytes().take_while(|&b| b == b'#').count();
387+ let after = &rest[h..];
388+ let sp = after.len() - after.trim_start_matches(' ').len();
389+ if sp == 0 {
390+ return None;
391+ }
392+ Some(indent + h + sp)
393+ } else if rest.starts_with("- ") || rest.starts_with("* ") || rest.starts_with("+ ") {
394+ Some(indent + 2)
395+ } else {
396+ None
397+ }
398+}
399+
400+/// Byte range of the checkbox right after the marker (`[ ]`, `[x]`, `[X]`,
401+/// `[-]`) plus the space after it (§4.2). Checkbox-like text later in the
402+/// title is title text.
403+fn checkbox_span(line: &str) -> Option<(usize, usize)> {
404+ let me = marker_end(line)?;
405+ let rest = &line[me..];
406+ if ["[ ]", "[x]", "[X]", "[-]"].iter().any(|cb| rest.starts_with(cb)) {
407+ let mut end = me + 3;
408+ if line[end..].starts_with(' ') {
409+ end += 1;
410+ }
411+ Some((me, end))
412+ } else {
413+ None
414+ }
415+}
416+
298417/// Write `todo:`/`done:` for a task block (§8.2).
299418pub fn set_block_todo(
300419 vault: &mut Vault,
@@ -414,66 +533,114 @@ pub fn frontmatter_lines(vault: &Vault, file: usize) -> Vec<(String, String, boo
414533
415534// --------------------------------------------------------------- structure
416535
417−/// Delete a subtree: its span leaves its file; block files go to trash (§11.5).
536+/// Delete a subtree: its span leaves its file; block files go to trash (§11.5),
537+/// including blocks nested anywhere under it, so none is left orphaned.
418538pub fn delete_subtree(vault: &mut Vault, r: NRef) -> std::io::Result<String> {
419539 let n = vault.tree.node(r);
420− if n.is_block() {
421− // delete the embed from the parent file and trash the block file
422− let b = n.block.clone().unwrap();
423− if let Some(edge) = b.edge_span {
424− // find which file holds the edge
425− for (fi, f) in vault.tree.files.iter().enumerate() {
426− if f.nodes.iter().any(|nd| nd.embed.as_ref() == b.id.as_ref()) {
427− let embed_node = f
428− .nodes
429− .iter()
430− .position(|nd| nd.embed.as_ref() == b.id.as_ref())
431− .unwrap();
432− let span = vault.tree.files[fi].nodes[embed_node].span;
433− remove_span_with_separator(vault, fi, span)?;
434− break;
435− }
436− }
437− let _ = edge;
438− }
439− let file = r.0;
440− vault.trash_file(file)?;
441− return Ok(format!("block {:?} trashed", b.path));
540+ if n.kind == Kind::Root {
541+ return Err(io_err("cannot delete the root"));
442542 }
443− if n.is_embed() {
444− // delete the embed line and trash the referenced block file
445− let id = n.embed.clone().unwrap();
446− let target = vault.tree.block_by_id(&id);
447− let file = r.0;
448− let span = vault.tree.node(r).span;
449− remove_span_with_separator(vault, file, span)?;
450− if let Some(t) = target {
451− let tf = t.0;
452− vault.trash_file(tf)?;
543+ if n.is_block() || n.is_embed() {
544+ let target = if n.is_embed() { vault.tree.resolved_child(r) } else { r };
545+ if target == r && n.is_embed() {
546+ // broken embed: just the line
547+ let span = n.span;
548+ remove_span_with_separator(vault, r.0, span)?;
549+ return Ok("broken embed removed".into());
550+ }
551+ let ids = nested_block_ids(vault, target);
552+ let path = vault.tree.node(target).block.as_ref().unwrap().path.clone();
553+ for id in ids {
554+ trash_block(vault, &id)?;
453555 }
454− return Ok("embed and block trashed".into());
556+ return Ok(format!("block {:?} trashed", path));
455557 }
456− // plain node: remove its span; trash a copy of the rendered text (§11.5)
558+ let ids = plain_remove(vault, r)?;
559+ for id in ids {
560+ trash_block(vault, &id)?;
561+ }
562+ Ok("subtree trashed".into())
563+}
564+
565+/// Ids of every block in the resolved subtree of `r` (pre-order, `r` itself
566+/// included if it is one).
567+fn nested_block_ids(vault: &Vault, r: NRef) -> Vec<Id> {
568+ let mut ids = Vec::new();
569+ vault.tree.walk(r, &mut |t, n| {
570+ if let Some(id) = t.node(n).block.as_ref().and_then(|b| b.id.clone()) {
571+ ids.push(id);
572+ }
573+ });
574+ ids
575+}
576+
577+/// Remove a plain node's span after writing a trash copy of its text;
578+/// returns the ids of blocks embedded under it, still to be trashed.
579+fn plain_remove(vault: &mut Vault, r: NRef) -> std::io::Result<Vec<Id>> {
580+ let ids = nested_block_ids(vault, r);
457581 let text = render(&vault.tree, r, 1, true);
458582 let name = format!("{}.md", slug(&vault.tree.node(r).title));
459583 vault.trash_text(&name, &text)?;
460− let file = r.0;
461584 let span = vault.tree.node(r).span;
462− remove_span_with_separator(vault, file, span)?;
463− Ok("subtree trashed".into())
585+ remove_span_with_separator(vault, r.0, span)?;
586+ Ok(ids)
587+}
588+
589+/// Remove a block's embed line (if it is still anywhere) and move its file to
590+/// the trash.
591+fn trash_block(vault: &mut Vault, id: &Id) -> std::io::Result<()> {
592+ let embed = vault.tree.files.iter().enumerate().find_map(|(fi, f)| {
593+ f.nodes
594+ .iter()
595+ .position(|nd| nd.embed.as_ref() == Some(id))
596+ .map(|ni| (fi, ni))
597+ });
598+ if let Some(e) = embed {
599+ let span = vault.tree.node(e).span;
600+ remove_span_with_separator(vault, e.0, span)?;
601+ }
602+ if let Some(b) = vault.tree.block_by_id(id) {
603+ vault.trash_file(b.0)?;
604+ }
605+ Ok(())
606+}
607+
608+/// The byte range to cut when removing `span`: the node plus whatever blank
609+/// separator would otherwise be doubled or left dangling. A blank line that
610+/// separated the node from what follows stays when nothing blank precedes
611+/// it, so `- a\n- b\n\n# S` minus `b` keeps its blank before `# S`.
612+fn removal_range(text: &str, span: Span) -> (usize, usize) {
613+ let b = text.as_bytes();
614+ let end = span.end.min(text.len());
615+ let mut start = span.start.min(end);
616+ let mut end = end;
617+ let blank_before = start >= 2 && &b[start - 2..start] == b"\n\n";
618+ let ends_blank = end >= start + 2 && &b[end - 2..end] == b"\n\n";
619+ if end >= text.len() {
620+ if blank_before {
621+ start -= 1; // no trailing blank line at EOF
622+ }
623+ } else if start > 0 && ends_blank && !blank_before {
624+ end -= 1;
625+ }
626+ (start, end)
627+}
628+
629+/// Shift a byte position for the removal of `start..end`.
630+fn after_removal(pos: usize, start: usize, end: usize) -> usize {
631+ if pos >= end {
632+ pos - (end - start)
633+ } else if pos > start {
634+ start
635+ } else {
636+ pos
637+ }
464638}
465639
466640/// Remove a span plus one adjacent blank separator line, then re-parse just
467641/// that file (no full reload, so other files' node refs stay valid).
468642fn remove_span_no_reload(vault: &mut Vault, file: usize, span: Span) -> std::io::Result<()> {
469− let text = vault.tree.files[file].text.clone();
470− let mut start = span.start;
471− let mut end = span.end;
472− if start >= 2 && &text[start - 2..start] == "\n\n" {
473− start -= 1;
474− } else if end < text.len() && text.as_bytes()[end] == b'\n' {
475− end += 1;
476− }
643+ let (start, end) = removal_range(&vault.tree.files[file].text, span);
477644 vault.write_span(file, Span { start, end }, "")
478645}
479646
@@ -499,22 +666,13 @@ pub fn paste(vault: &mut Vault, at: NRef, text: &str, after: bool) -> std::io::R
499666 // re-indent/level the pasted text: parse it standalone and re-emit at
500667 // the target's position.
501668 let shifted = shift_document(text, level_base, indent);
502− let (pos, prefix) = if after {
503− let mut pos = n.span.end;
504− let t = &vault.tree.files[file].text;
505− while pos > n.span.start && t.as_bytes()[pos - 1] == b'\n' {
506− pos -= 1;
507− }
508− (pos, "\n\n")
509− } else {
510− (n.span.start, "")
511− };
512− let insertion = if after {
513− format!("{}{}", prefix, shifted)
669+ if after {
670+ let pos = trimmed_end(&vault.tree.files[file].text, n.span);
671+ insert_at(vault, file, pos, "\n\n", &shifted)
514672 } else {
515− format!("{}\n", shifted)
516− };
517− vault.write_span(file, Span { start: pos, end: pos }, &insertion)
673+ let pos = n.span.start;
674+ vault.write_span(file, Span { start: pos, end: pos }, &format!("{}\n", shifted))
675+ }
518676}
519677
520678/// Parse a standalone document and re-emit it at the given level/indent.
@@ -627,13 +785,21 @@ fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
627785/// Refile: move a subtree under a new parent as its last child (§6.5).
628786pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<()> {
629787 let n = vault.tree.node(r);
630− // guard: cannot refile into own subtree
788+ // guard: cannot refile into own subtree — ancestry followed through
789+ // embeds, so a destination inside a nested block counts too
790+ let embed_of_r = n.block.as_ref().and_then(|b| b.id.clone());
631791 let mut anc = Some(dest);
792+ let mut guard = 0;
632793 while let Some(a) = anc {
633− if a == r {
794+ let an = vault.tree.node(a);
795+ if a == r || (embed_of_r.is_some() && an.embed == embed_of_r) {
634796 return Err(io_err("cannot refile a node into itself"));
635797 }
636− anc = vault.tree.node(a).parent.map(|p| (a.0, p));
798+ guard += 1;
799+ if guard > 10_000 {
800+ break; // embed cycle: a diagnostic elsewhere
801+ }
802+ anc = resolved_parent(&vault.tree, a);
637803 }
638804 if n.is_block() || n.is_embed() {
639805 // move only the embed line (§6.5)
@@ -655,26 +821,34 @@ pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<()> {
655821 None => return Err(io_err("block has no embed")),
656822 }
657823 };
824+ // just the `![[id]]` line, without its old indent: insert_child_text
825+ // indents it for the destination
658826 let embed_text = {
659827 let en = vault.tree.node(embed_ref);
660− en.span.text(&vault.tree.files[embed_ref.0].text).to_string()
828+ en.title_span.text(&vault.tree.files[embed_ref.0].text).trim().to_string()
661829 };
662830 let dest_key = vault.key_of(dest);
663831 let dest_path = vault.tree.path(dest);
664832 // remove embed from source (no reload)
665833 let src_file = embed_ref.0;
666834 let span = vault.tree.node(embed_ref).span;
835+ let src_text = vault.tree.files[src_file].text.clone();
667836 remove_span_no_reload(vault, src_file, span)?;
668− let dest = vault
837+ let Some(dest) = vault
669838 .find_by_key(&dest_key)
670839 .or_else(|| vault.find_by_path(&dest_path))
671− .ok_or_else(|| io_err("destination lost"))?;
840+ else {
841+ // put the source back rather than lose the embed
842+ vault.write_file_text(src_file, &src_text)?;
843+ return Err(io_err("destination lost"));
844+ };
672845 insert_child_text(vault, dest, &embed_text, true)
673846 } else {
674847 // plain node: same-file span surgery when possible (no reload, so
675848 // sibling structure is preserved), cross-file otherwise.
676849 let src_file = r.0;
677− let rendered = render(&vault.tree, r, 1, true);
850+ // on-disk spelling: nested blocks travel as their embed lines
851+ let rendered = render(&vault.tree, r, 1, false);
678852 let span = vault.tree.node(r).span;
679853 if dest.0 == src_file && !vault.tree.node(dest).is_embed() {
680854 let text = vault.tree.files[src_file].text.clone();
@@ -686,21 +860,12 @@ pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<()> {
686860 vault.tree.indent(dest)
687861 };
688862 let shifted = shift_document(&rendered, level, indent);
689− // remove r's span plus a preceding blank separator
690− let mut start = span.start;
691− if start >= 2 && &text[start - 2..start] == "\n\n" {
692− start -= 1;
693− }
863+ let (start, end) = removal_range(&text, span);
694864 let mut new_text = String::with_capacity(text.len());
695865 new_text.push_str(&text[..start]);
696− new_text.push_str(&text[span.end..]);
866+ new_text.push_str(&text[end..]);
697867 // adjust insertion point for the removal
698− let removed = span.end - start;
699− let mut ins = if dest_span.end > start {
700− dest_span.end - removed
701− } else {
702− dest_span.end
703− };
868+ let mut ins = after_removal(dest_span.end.min(text.len()), start, end);
704869 while ins > 0 && new_text.as_bytes()[ins - 1] == b'\n' {
705870 ins -= 1;
706871 }
@@ -715,11 +880,16 @@ pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<()> {
715880 } else {
716881 let dest_key = vault.key_of(dest);
717882 let dest_path = vault.tree.path(dest);
883+ let src_text = vault.tree.files[src_file].text.clone();
718884 remove_span_with_separator(vault, src_file, span)?;
719− let dest = vault
885+ let Some(dest) = vault
720886 .find_by_key(&dest_key)
721887 .or_else(|| vault.find_by_path(&dest_path))
722− .ok_or_else(|| io_err("destination lost"))?;
888+ else {
889+ // never lose the subtree: put the source back
890+ vault.write_file_text(src_file, &src_text)?;
891+ return Err(io_err("destination lost"));
892+ };
723893 insert_child_text(vault, dest, &rendered, false)
724894 }
725895 }
@@ -762,12 +932,8 @@ fn insert_child_text(
762932 let first_line_is_item = shifted.trim_start().starts_with("- ");
763933 let sep = if all_items && first_line_is_item { "\n" } else { "\n\n" };
764934 let t = vault.tree.files[file].text.clone();
765− let mut pos = node.span.end;
766− while pos > node.span.start && t.as_bytes()[pos - 1] == b'\n' {
767− pos -= 1;
768− }
769− let insertion = format!("{}{}", sep, shifted);
770− vault.write_span(file, Span { start: pos, end: pos }, &insertion)?;
935+ let pos = trimmed_end(&t, node.span);
936+ insert_at(vault, file, pos, sep, &shifted)?;
771937 vault.reload()
772938}
773939
@@ -790,6 +956,9 @@ pub fn archive(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
790956
791957/// Clear done: trash every done item with no open descendants under `target`
792958/// (§8.5). Returns the number trashed.
959+///
960+/// Nodes are removed by span, last-in-file first, so no node is ever looked
961+/// up again by its title path (two done items may share a title).
793962pub fn clear_done(vault: &mut Vault, target: NRef) -> std::io::Result<usize> {
794963 let mut to_clear: Vec<NRef> = Vec::new();
795964 vault.tree.walk(target, &mut |t, r| {
@@ -801,28 +970,65 @@ pub fn clear_done(vault: &mut Vault, target: NRef) -> std::io::Result<usize> {
801970 }
802971 }
803972 });
804− // Record reload-stable keys and ancestor relationships before mutating.
805− let keys: Vec<NodeKey> = to_clear.iter().map(|&r| vault.key_of(r)).collect();
806− let paths: Vec<Vec<String>> = to_clear.iter().map(|&r| vault.tree.path(r)).collect();
807− let mut done = 0;
808− // Deepest first; skip nodes whose ancestor is also being cleared.
809− let mut order: Vec<usize> = (0..to_clear.len()).collect();
810− order.sort_by_key(|&i| std::cmp::Reverse(paths[i].len()));
811− for i in order {
812− let is_descendant_of_cleared = to_clear
813− .iter()
814− .enumerate()
815− .any(|(j, _)| j != i && paths[j].len() < paths[i].len()
816− && paths[i][..paths[j].len()] == paths[j][..]);
817− if is_descendant_of_cleared {
818− continue;
819− }
820− if let Some(r) = vault.find_by_key(&keys[i]) {
821− delete_subtree(vault, r)?;
822− done += 1;
973+ // skip nodes under another node being cleared
974+ let tops: Vec<NRef> = to_clear
975+ .iter()
976+ .copied()
977+ .filter(|&r| {
978+ let mut a = resolved_parent(&vault.tree, r);
979+ let mut guard = 0;
980+ while let Some(p) = a {
981+ if to_clear.contains(&p) || guard > 10_000 {
982+ return false;
983+ }
984+ guard += 1;
985+ a = resolved_parent(&vault.tree, p);
986+ }
987+ true
988+ })
989+ .collect();
990+ let mut blocks: Vec<Id> = Vec::new();
991+ let mut plain: Vec<NRef> = Vec::new();
992+ for &r in &tops {
993+ let n = vault.tree.node(r);
994+ match n.block.as_ref().and_then(|b| b.id.clone()) {
995+ Some(id) => blocks.push(id),
996+ None => plain.push(r),
823997 }
824998 }
825− Ok(done)
999+ // plain spans: per file, from the end backwards, so earlier spans stay valid
1000+ plain.sort_by_key(|&(f, n)| (f, std::cmp::Reverse(vault.tree.files[f].nodes[n].span.start)));
1001+ let mut done = 0;
1002+ for r in plain {
1003+ let ids = plain_remove(vault, r)?;
1004+ blocks.extend(ids);
1005+ done += 1;
1006+ }
1007+ let n_block_tops = tops.len() - done;
1008+ for id in blocks {
1009+ trash_block(vault, &id)?;
1010+ }
1011+ Ok(done + n_block_tops)
1012+}
1013+
1014+/// A node's parent in the resolved tree: within its file, or — for a block
1015+/// root — the embed that stitches it in (§4.7).
1016+fn resolved_parent(tree: &crate::tree::Tree, r: NRef) -> Option<NRef> {
1017+ let p = tree.node(r).parent?;
1018+ let pr = (r.0, p);
1019+ if tree.node(pr).kind == Kind::Root && r.0 != tree.root.0 {
1020+ let id = tree.node(r).block.as_ref()?.id.clone()?;
1021+ tree.files.iter().enumerate().find_map(|(fi, f)| {
1022+ f.nodes
1023+ .iter()
1024+ .position(|nd| nd.embed.as_ref() == Some(&id))
1025+ .map(|ni| (fi, ni))
1026+ })
1027+ } else if tree.node(pr).kind == Kind::Root {
1028+ None
1029+ } else {
1030+ Some(pr)
1031+ }
8261032}
8271033
8281034// --------------------------------------------------------------- make block
@@ -853,12 +1059,13 @@ pub fn make_block(vault: &mut Vault, r: NRef) -> std::io::Result<Id> {
8531059 let mut stripped = String::new();
8541060 for (i, l) in body_lines.iter().enumerate() {
8551061 if i == 0 && n.task.is_some() {
856− let l2 = l
857− .replacen("[ ] ", "", 1)
858− .replacen("[x] ", "", 1)
859− .replacen("[X] ", "", 1)
860− .replacen("[-] ", "", 1);
861− stripped.push_str(&l2);
1062+ match checkbox_span(l) {
1063+ Some((start, end)) => {
1064+ stripped.push_str(&l[..start]);
1065+ stripped.push_str(&l[end..]);
1066+ }
1067+ None => stripped.push_str(l),
1068+ }
8621069 } else {
8631070 stripped.push_str(l);
8641071 }
@@ -886,34 +1093,34 @@ pub fn set_property(vault: &mut Vault, r: NRef, key: &str, value: &str) -> std::
8861093 } else if vault.tree.node(r).is_embed() {
8871094 vault.tree.resolved_child(r)
8881095 } else {
889− make_block(vault, r)?;
890− // find the new block by re-resolving: the embed replaced the node
891− let path = vault.tree.path(r);
892− let mut found = None;
893− for (fi, f) in vault.tree.files.iter().enumerate() {
894− for (ni, nd) in f.nodes.iter().enumerate() {
895− if nd.embed.is_some() {
896− let rr = (fi, ni);
897− if vault.tree.path(rr) == path {
898− found = Some(vault.tree.resolved_child(rr));
899− }
900− }
901− }
902− }
903− found.ok_or_else(|| io_err("block not found after make_block"))?
1096+ // make_block reloads, so `r` is stale: find the new block by its id
1097+ let id = make_block(vault, r)?;
1098+ vault
1099+ .tree
1100+ .block_by_id(&id)
1101+ .ok_or_else(|| io_err("block not found after make_block"))?
9041102 };
9051103 set_frontmatter_key(vault, target.0, key, Some(value))
9061104}
9071105
9081106// --------------------------------------------------------------- move/spelling
9091107
910−/// Move a node among its siblings (§10.3 `J`/`K`).
1108+/// Move a node among its siblings (§10.3 `J`/`K`). A block moves by its
1109+/// embed line, so blocks and plain nodes swap freely in the parent file.
9111110pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<()> {
1111+ // a block root stands in its parent file as its embed
1112+ let r = match vault.tree.node(r).block.as_ref().and_then(|b| b.id.clone()) {
1113+ Some(_) if r.0 != vault.tree.root.0 => match resolved_parent(&vault.tree, r) {
1114+ Some(e) if vault.tree.node(e).is_embed() => e,
1115+ _ => return Ok(()),
1116+ },
1117+ _ => r,
1118+ };
9121119 let parent = match vault.tree.node(r).parent {
9131120 Some(p) => (r.0, p),
9141121 None => return Ok(()),
9151122 };
916− let siblings = vault.tree.resolved_children(parent);
1123+ let siblings = vault.tree.raw_children(parent);
9171124 let pos = match siblings.iter().position(|&s| s == r) {
9181125 Some(p) => p,
9191126 None => return Ok(()),
@@ -931,22 +1138,36 @@ pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<(
9311138 }
9321139 let other = siblings[swap_with];
9331140 let file = r.0;
934− if other.0 != file {
935− return Ok(()); // different files: skip (v1 simplicity)
936− }
937− let a = vault.tree.node(r).span;
938− let b = vault.tree.node(other).span;
9391141 let text = vault.tree.files[file].text.clone();
1142+ // swap the nodes' own lines; trailing blank lines stay where they are,
1143+ // so the list's spacing is unchanged
1144+ let core = |sp: Span| {
1145+ let end = sp.end.min(text.len());
1146+ let mut e = end;
1147+ while e >= sp.start + 2 && &text.as_bytes()[e - 2..e] == b"\n\n" {
1148+ e -= 1;
1149+ }
1150+ Span { start: sp.start, end: e }
1151+ };
1152+ let a = core(vault.tree.node(r).span);
1153+ let b = core(vault.tree.node(other).span);
9401154 let (first, second) = if a.start < b.start { (a, b) } else { (b, a) };
941− let between = &text[first.end..second.start];
942− let new_text = format!(
943− "{}{}{}{}{}",
1155+ let with_nl = |t: &str| {
1156+ if t.ends_with('\n') {
1157+ t.to_string()
1158+ } else {
1159+ format!("{}\n", t)
1160+ }
1161+ };
1162+ let mut new_text = format!(
1163+ "{}{}{}{}",
9441164 &text[..first.start],
945− second.text(&text),
946− between,
947− first.text(&text),
948− &text[second.end..]
1165+ with_nl(second.text(&text)),
1166+ &text[first.end..second.start],
1167+ with_nl(first.text(&text)),
9491168 );
1169+ let rest = &text[second.end..];
1170+ new_text.push_str(rest);
9501171 vault.write_file_text(file, &new_text)
9511172}
9521173
@@ -1017,9 +1238,9 @@ pub fn demote(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
10171238 return refile(vault, r, prev);
10181239}
10191240
1020−/// Promote: become the next sibling of the parent (§10.3 `<`).
10211241/// Promote: become the next sibling of the parent (§10.3 `<`). Same-file
1022−/// span surgery, no reload.
1242+/// span surgery; out of a block's root, the node moves to the parent file
1243+/// right after the block's embed.
10231244pub fn promote(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
10241245 let n = vault.tree.node(r);
10251246 let parent = match n.parent {
@@ -1029,26 +1250,64 @@ pub fn promote(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
10291250 if vault.tree.node(parent).kind == Kind::Root {
10301251 return Ok(());
10311252 }
1032− // move out: delete span, insert after parent's span
10331253 let file = r.0;
1034− let rendered = render(&vault.tree, r, 1, true);
1254+ // on-disk spelling: nested blocks travel as their embed lines
1255+ let rendered = render(&vault.tree, r, 1, false);
10351256 let span = n.span;
1257+ let text = vault.tree.files[file].text.clone();
1258+ let (start, end) = removal_range(&text, span);
1259+ let mut new_text = String::with_capacity(text.len());
1260+ new_text.push_str(&text[..start]);
1261+ new_text.push_str(&text[end..]);
1262+ if vault.tree.node(parent).is_block() && file != vault.tree.root.0 {
1263+ // the parent is a block root: its sibling position is beside the embed
1264+ let Some(embed) = resolved_parent(&vault.tree, parent) else {
1265+ return Err(io_err("block has no embed"));
1266+ };
1267+ // a section beside the embed sits one below the embed's enclosing
1268+ // section (the embed line itself is spelled as an item)
1269+ let in_section = vault
1270+ .tree
1271+ .ancestors(embed)
1272+ .iter()
1273+ .any(|&a| vault.tree.node(a).kind == Kind::Section);
1274+ let level = if vault.tree.node(r).kind == Kind::Section && in_section {
1275+ vault.tree.level(embed) + 1
1276+ } else {
1277+ vault.tree.level(embed).max(1)
1278+ };
1279+ let indent = vault.tree.indent(embed);
1280+ let shifted = shift_document(&rendered, level, indent);
1281+ let efile = embed.0;
1282+ let espan = vault.tree.node(embed).title_span;
1283+ let etext = vault.tree.files[efile].text.clone();
1284+ let ins = (espan.end + 1).min(etext.len());
1285+ let tight = vault.tree.node(r).kind == Kind::Item
1286+ && vault.tree.node(parent).kind == Kind::Item;
1287+ let mut out = String::with_capacity(etext.len() + shifted.len() + 2);
1288+ out.push_str(&etext[..ins]);
1289+ if !out.ends_with('\n') {
1290+ out.push('\n');
1291+ }
1292+ if !tight {
1293+ out.push('\n');
1294+ }
1295+ out.push_str(&shifted);
1296+ let after = &etext[ins..];
1297+ if !tight && !after.is_empty() && !after.starts_with('\n') {
1298+ out.push('\n');
1299+ }
1300+ out.push_str(after);
1301+ vault.write_file_text(file, &new_text)?;
1302+ vault.write_file_text(efile, &out)?;
1303+ return vault.reload();
1304+ }
10361305 let parent_span = vault.tree.node(parent).span;
10371306 let level = vault.tree.level(parent);
10381307 let indent = vault.tree.indent(parent);
10391308 let shifted = shift_document(&rendered, level, indent);
1040− let text = vault.tree.files[file].text.clone();
1041− // remove r's span plus a preceding blank separator
1042− let mut start = span.start;
1043− if start >= 2 && &text[start - 2..start] == "\n\n" {
1044− start -= 1;
1045− }
1046− let removed = span.end - start;
1047− let mut new_text = String::with_capacity(text.len());
1048− new_text.push_str(&text[..start]);
1049− new_text.push_str(&text[span.end..]);
10501309 // the parent contains r, so its span end moves back by the removed bytes
1051− let mut ins = parent_span.end - removed;
1310+ let mut ins = after_removal(parent_span.end.min(text.len()), start, end);
10521311 while ins > 0 && new_text.as_bytes()[ins - 1] == b'\n' {
10531312 ins -= 1;
10541313 }
@@ -1104,7 +1363,7 @@ pub fn append_child_public(
11041363 parent: NRef,
11051364 title: &str,
11061365) -> std::io::Result<NRef> {
1107− append_child_line(vault, parent, &format!("- {}", title), false)
1366+ append_child_line(vault, parent, &format!("- {}", title), true)
11081367}
11091368
11101369fn io_err(msg: &str) -> std::io::Error {