on master help sign in
fix ~57 audited bugs: parser/render/editor round-trip and data-loss fixes, check --fix, merge, trash, unicode panics, and TUI keymap/undo/cursor/mouse fixes, with regression tests
commit00c71f7ac28753d35bdc70a9b8f2609dfed4af0e authoriko <ilyakooo0@gmail.com> dateTue 15 Sep 2026, 01:26 (1 month ago) parent3d54c76
20 files changed+2652−1194
crates/fold-cli/src/main.rs+21−3 crates/fold-cli/tests/cli.rs+25 crates/fold-core/src/check.rs+64−21 crates/fold-core/src/edit.rs+197−245 crates/fold-core/src/ident.rs+11−4 crates/fold-core/src/merge.rs+273−119 crates/fold-core/src/ops.rs+472−213 crates/fold-core/src/parse.rs+79−47 crates/fold-core/src/reading.rs+10−125 crates/fold-core/src/render.rs+191−214 crates/fold-core/src/tree.rs+1 crates/fold-core/src/vault.rs+81−18 crates/fold-core/tests/edit.rs+134−1 crates/fold-core/tests/merge_check.rs+157 crates/fold-core/tests/ops.rs+262−1 crates/fold-core/tests/roundtrip.rs+196−2 crates/fold-tui/src/app.rs+285−178 crates/fold-tui/tests/conflict_watch.rs+10−3 crates/fold-tui/tests/fixes.rs+182 crates/fold-tui/tests/mouse_all.rs+1
crates/fold-cli/src/main.rs+21−3
@@ -86,6 +86,12 @@ fn directories_home() -> PathBuf {
8686fn main() -> anyhow::Result<()> {
8787 let cli = Cli::parse();
8888 let dir = vault_dir(&cli.vault);
89+ // only the TUI sets a vault up (§13); a mistyped --vault is an error
90+ // for every other command rather than a new directory
91+ let needs_vault = !matches!(cli.command, None | Some(Command::Help) | Some(Command::Trash { .. }));
92+ if needs_vault && !dir.is_dir() {
93+ anyhow::bail!("no vault at {}", dir.display());
94+ }
8995 match cli.command {
9096 None => {
9197 // open the TUI (§13)
@@ -173,9 +179,21 @@ fn main() -> anyhow::Result<()> {
173179 } else {
174180 restored
175181 };
176− std::fs::create_dir_all(&dir)?;
177− std::fs::rename(e.path(), dir.join(&restored))?;
178− println!("restored {}", restored);
182+ if !dir.is_dir() {
183+ anyhow::bail!("no vault at {}", dir.display());
184+ }
185+ // never restore over an existing file, root.md least of all
186+ let target = if restored != "root.md" && !dir.join(&restored).exists() {
187+ restored.clone()
188+ } else {
189+ let stem = restored.strip_suffix(".md").unwrap_or(&restored);
190+ (2..)
191+ .map(|i| format!("{}-restored-{}.md", stem, i))
192+ .find(|n| !dir.join(n).exists())
193+ .unwrap()
194+ };
195+ fold_core::vault::move_file(&e.path(), &dir.join(&target))?;
196+ println!("restored {}", target);
179197 }
180198 _ => anyhow::bail!("{:?} matches {} trash entries", id, matches.len()),
181199 }
crates/fold-cli/tests/cli.rs+25
@@ -131,3 +131,28 @@ fn help_prints_usage() {
131131 .stdout(predicate::str::contains("command palette"))
132132 .stdout(predicate::str::contains("make block"));
133133}
134+
135+#[test]
136+fn missing_vault_is_an_error_not_a_new_directory() {
137+ let dir = tempfile::tempdir().unwrap();
138+ let typo = dir.path().join("typo");
139+ notes(&typo).arg("check").assert().failure();
140+ assert!(!typo.exists());
141+}
142+
143+#[test]
144+fn trash_restore_never_overwrites_root_md() {
145+ let dir = tempfile::tempdir().unwrap();
146+ let state = tempfile::tempdir().unwrap();
147+ std::fs::write(dir.path().join("root.md"), "# Keep\n").unwrap();
148+ let trash = state.path().join("fold").join("trash");
149+ std::fs::create_dir_all(&trash).unwrap();
150+ std::fs::write(trash.join("20260926-101010-root.md"), "# Root\n").unwrap();
151+ notes(dir.path())
152+ .env("XDG_STATE_HOME", state.path())
153+ .args(["trash", "restore", "root"])
154+ .assert()
155+ .success();
156+ assert_eq!(std::fs::read_to_string(dir.path().join("root.md")).unwrap(), "# Keep\n");
157+ assert!(dir.path().join("root-restored-2.md").exists());
158+}
crates/fold-core/src/check.rs+64−21
@@ -187,50 +187,93 @@ pub fn is_iso_date(v: &str) -> bool {
187187
188188/// Canonicalize every file and repair filenames (§4.2, §6.4, §13 `--fix`).
189189/// Returns the number of files rewritten or renamed.
190+///
191+/// Nothing is dropped: root.md keeps its frontmatter and any text before its
192+/// first node verbatim, embeds stay embeds, and a block file that is
193+/// malformed (§4.9: read-only until fixed by hand) is left alone.
190194pub fn fix(vault: &mut Vault) -> std::io::Result<usize> {
191195 let mut count = 0;
192− // rewrite each block file from its canonical render
193196 for i in 0..vault.tree.files.len() {
194197 let f = &vault.tree.files[i];
198+ let top: Vec<usize> = f.nodes[f.root_node].children.clone();
195199 let canonical = if i == 0 {
196− // root.md: re-emit the whole file's top-level nodes
197− let mut s = String::new();
198− let kids = vault.tree.resolved_children(vault.tree.root);
199− for (idx, k) in kids.iter().enumerate() {
200− if idx > 0 && vault.tree.node(*k).kind == Kind::Section {
200+ let Some(&first) = top.first() else { continue };
201+ // frontmatter and intro text, exactly as found
202+ let mut s = f.text[..f.nodes[first].span.start].to_string();
203+ for (idx, &k) in top.iter().enumerate() {
204+ let kr = (i, k);
205+ let kn = vault.tree.node(kr);
206+ if idx > 0 && (kn.kind == Kind::Section || vault.tree.node((i, top[idx - 1])).kind == Kind::Section) {
201207 s.push('\n');
202208 }
203− s.push_str(&render(&vault.tree, *k, 1, false));
209+ match &kn.embed {
210+ Some(id) => {
211+ s.push_str("![[");
212+ s.push_str(id.as_str());
213+ s.push_str("]]\n");
214+ }
215+ None => s.push_str(&render(&vault.tree, kr, 1, false)),
216+ }
204217 }
205218 s
206219 } else {
207− let root_node = f.nodes[f.root_node].children[0];
208− render(&vault.tree, (i, root_node), 1, false)
220+ let malformed = top.len() != 1
221+ || f.diagnostics.iter().any(|d| {
222+ d.message.contains("more than one node") || d.message.contains("before the block")
223+ });
224+ if malformed {
225+ continue;
226+ }
227+ render(&vault.tree, (i, top[0]), 1, false)
209228 };
210229 if canonical != f.text {
211230 vault.write_file_text(i, &canonical)?;
212231 count += 1;
213232 }
214233 }
215− // repair filenames (§6.4)
216− let renames: Vec<(usize, String)> = vault
234+ // repair filenames (§6.4): an existing prefix that is a leading run of
235+ // the id is kept — prefixes are never lengthened — unless a file decided
236+ // earlier already holds it (a collision: the later file moves)
237+ let current: Vec<(usize, String)> = vault
217238 .tree
218239 .blocks
219240 .iter()
220− .filter_map(|(r, id)| {
221− let b = vault.tree.node(*r).block.as_ref().unwrap();
222− let prefix = vault.unique_prefix(id);
223− let want = filename(&prefix, &slug(&vault.tree.node(*r).title));
224− if want != b.path {
225− Some((r.0, want))
226− } else {
227− None
228− }
241+ .map(|(r, _)| {
242+ let path = &vault.tree.node(*r).block.as_ref().unwrap().path;
243+ (r.0, split_filename(path).map(|(p, _)| p.to_string()).unwrap_or_default())
229244 })
230245 .collect();
246+ let mut decided: Vec<String> = Vec::new();
247+ let mut renames: Vec<(usize, String)> = Vec::new();
248+ for (i, (r, id)) in vault.tree.blocks.iter().enumerate() {
249+ let b = vault.tree.node(*r).block.as_ref().unwrap();
250+ let words = id.words();
251+ let keep = split_filename(&b.path).and_then(|(p, _)| {
252+ let n = p.split('-').count();
253+ (n <= 4 && words[..n].join("-") == p && !decided.iter().any(|d| d == p))
254+ .then(|| p.to_string())
255+ });
256+ let prefix = keep.unwrap_or_else(|| {
257+ let others: Vec<&str> = decided
258+ .iter()
259+ .map(String::as_str)
260+ .chain(current[i + 1..].iter().map(|(_, p)| p.as_str()))
261+ .collect();
262+ id.shortest_prefix(&|cand| others.contains(&cand))
263+ });
264+ let want = filename(&prefix, &slug(&vault.tree.node(*r).title));
265+ if want != b.path {
266+ renames.push((r.0, want));
267+ }
268+ decided.push(prefix);
269+ }
231270 for (file, want) in renames {
232271 let old = vault.tree.files[file].path.clone();
233− std::fs::rename(vault.dir.join(&old), vault.dir.join(&want))?;
272+ let target = vault.dir.join(&want);
273+ if target.exists() {
274+ continue; // never overwrite; check keeps reporting it
275+ }
276+ std::fs::rename(vault.dir.join(&old), target)?;
234277 count += 1;
235278 }
236279 if count > 0 {
crates/fold-core/src/edit.rs+197−245
@@ -8,10 +8,11 @@
88//! replaces the block's span atomically.
99
1010use crate::ident::Id;
11−use crate::parse::{Kind, TaskState};
11+use crate::parse::Kind;
12+use crate::render::render_lines;
1213use crate::tree::NRef;
1314use crate::vault::Vault;
14−use std::collections::BTreeMap;
15+use std::collections::{BTreeMap, HashMap};
1516
1617/// One buffer line and its owner.
1718#[derive(Debug, Clone)]
@@ -38,8 +39,9 @@ pub struct EditBuffer {
3839 pub owners: BTreeMap<Owner, OwnerInfo>,
3940 /// Owners whose lines changed since last save.
4041 pub dirty: Vec<Owner>,
41− /// blake3 of each file as read when the buffer was built (§5.2.5).
42− base_hashes: Vec<String>,
42+ /// blake3 of each file (by path) as last read or written through this
43+ /// buffer (§5.2.5).
44+ base_hashes: HashMap<String, String>,
4345}
4446
4547#[derive(Debug, Clone)]
@@ -48,35 +50,99 @@ pub struct OwnerInfo {
4850 pub id: Option<Id>,
4951 /// The block-root node in the tree at buffer-build time.
5052 pub nref: NRef,
51− /// The level/indent the block's text was shifted by when inlined into
52− /// the buffer (inverse applied on splice, §5.2.3).
53+ /// Display level/indent of the block's top node in the buffer.
5354 pub level: usize,
5455 pub indent: usize,
56+ /// Level/indent of that node in its own file; splice shifts by
57+ /// `target − display` (§5.2.3).
58+ pub target_level: usize,
59+ pub target_indent: usize,
60+ /// The owner whose text holds this block's embed (None for the owner
61+ /// of the render root).
62+ pub parent: Option<Owner>,
63+ /// The file the block is written to, and where its span starts (for a
64+ /// render root that is not a block: found again by position after
65+ /// reloads).
66+ pub path: String,
67+ pub start: usize,
68+ pub is_root: bool,
5569}
5670
5771impl EditBuffer {
5872 /// Build the editing buffer for `render(node, 1, true)` (§5.2): every
5973 /// line tagged with its owning block.
6074 pub fn build(vault: &Vault, root: NRef) -> EditBuffer {
75+ let tree = &vault.tree;
6176 let mut lines = Vec::new();
6277 let mut owners: BTreeMap<Owner, OwnerInfo> = BTreeMap::new();
63− let mut block_ord = 0usize;
64− build_node(
65− vault,
66− root,
67− 1,
68− 0,
69− &mut block_ord,
70− &mut lines,
71− &mut owners,
72− &mut Vec::new(),
73− );
74− let base_hashes = vault
75− .tree
78+ let mut by_node: Vec<(NRef, Owner)> = Vec::new();
79+ let rlines = render_lines(tree, root, 1, true);
80+ let new_owner = |nref: NRef,
81+ outer: NRef,
82+ level: usize,
83+ indent: usize,
84+ owners: &mut BTreeMap<Owner, OwnerInfo>,
85+ by_node: &mut Vec<(NRef, Owner)>|
86+ -> Owner {
87+ if let Some((_, o)) = by_node.iter().find(|(r, _)| *r == nref) {
88+ return *o;
89+ }
90+ let o = Owner {
91+ file: nref.0,
92+ block_ord: by_node.len(),
93+ };
94+ let n = tree.node(nref);
95+ let is_root = n.kind == Kind::Root;
96+ let (level, indent, target_level, target_indent) = if is_root {
97+ (0, 0, 0, 0)
98+ } else if n.is_block() {
99+ (level, indent, tree.level(nref), 0)
100+ } else {
101+ (level, indent, tree.level(nref), tree.indent(nref))
102+ };
103+ let parent = if outer == nref {
104+ None
105+ } else {
106+ by_node.iter().find(|(r, _)| *r == outer).map(|(_, o)| *o)
107+ };
108+ owners.insert(
109+ o,
110+ OwnerInfo {
111+ title: if is_root { "root".into() } else { n.title.clone() },
112+ id: n.block.as_ref().and_then(|b| b.id.clone()),
113+ nref,
114+ level,
115+ indent,
116+ target_level,
117+ target_indent,
118+ parent,
119+ path: tree.files[nref.0].path.clone(),
120+ start: n.span.start,
121+ is_root,
122+ },
123+ );
124+ by_node.push((nref, o));
125+ o
126+ };
127+ for l in rlines {
128+ let owner = new_owner(l.owner, l.outer, l.level, l.indent, &mut owners, &mut by_node);
129+ lines.push(EditLine {
130+ text: l.text,
131+ owner,
132+ });
133+ }
134+ if lines.is_empty() {
135+ // an empty document still has one line to type into
136+ let owner = new_owner(root, root, 1, 0, &mut owners, &mut by_node);
137+ lines.push(EditLine {
138+ text: String::new(),
139+ owner,
140+ });
141+ }
142+ let base_hashes = tree
76143 .files
77144 .iter()
78− .enumerate()
79− .map(|(i, _)| vault.hash_of(i).to_string())
145+ .map(|f| (f.path.clone(), hash(&f.text)))
80146 .collect();
81147 EditBuffer {
82148 lines,
@@ -128,6 +194,34 @@ impl EditBuffer {
128194 }
129195 }
130196
197+ /// The block's node in the current tree (it may have been re-parsed
198+ /// since the buffer was built).
199+ fn locate(&self, vault: &Vault, info: &OwnerInfo) -> Option<NRef> {
200+ if let Some(id) = &info.id {
201+ return vault.tree.block_by_id(id);
202+ }
203+ let file = vault.file_index(&info.path)?;
204+ let f = &vault.tree.files[file];
205+ if info.is_root {
206+ return Some((file, f.root_node));
207+ }
208+ f.nodes
209+ .iter()
210+ .position(|n| n.kind != Kind::Root && n.span.start == info.start)
211+ .map(|i| (file, i))
212+ }
213+
214+ /// The owner directly nested in `owner` that `o` belongs to, if any.
215+ fn nested_in(&self, mut o: Owner, owner: Owner) -> Option<Owner> {
216+ while let Some(p) = self.owners.get(&o).and_then(|i| i.parent) {
217+ if p == owner {
218+ return Some(o);
219+ }
220+ o = p;
221+ }
222+ None
223+ }
224+
131225 /// Splice one dirty block (§5.2): collect its lines, put nested blocks
132226 /// back as embeds, shift back, write the one file atomically.
133227 pub fn splice(&mut self, vault: &mut Vault, owner: Owner) -> std::io::Result<()> {
@@ -135,104 +229,111 @@ impl EditBuffer {
135229 Some(i) => i.clone(),
136230 None => return Ok(()),
137231 };
138− // Collect the block's owned lines in buffer order; where a nested
139− // block's title line sits, emit an embed instead (§5.2.1).
140− let mut text_lines: Vec<String> = Vec::new();
141− let mut skip_blocks: Vec<usize> = Vec::new();
232+ // Collect the block's owned lines in buffer order; where a block
233+ // nested in it starts — whatever file it lives in — emit its embed
234+ // (§5.2.1).
235+ let mut text_lines: Vec<Line> = Vec::new();
236+ let mut emitted: Vec<Owner> = Vec::new();
142237 for l in &self.lines {
143238 if l.owner == owner {
144− text_lines.push(l.text.clone());
145− } else if l.owner.file == owner.file && l.owner.block_ord != owner.block_ord {
146− // a line owned by a nested block in the same file: at the
147− // position of that nested block's *title* line, emit an embed
148− let nested_ord = l.owner.block_ord;
149− if !skip_blocks.contains(&nested_ord) {
150− skip_blocks.push(nested_ord);
151− let nested_owner = Owner { file: owner.file, block_ord: nested_ord };
152− if let Some(ninfo) = self.owners.get(&nested_owner) {
153− if let Some(id) = &ninfo.id {
154− text_lines.push(format!("EMBED:{}", id));
155− }
239+ text_lines.push(Line::Text(l.text.clone()));
240+ } else if let Some(nested) = self.nested_in(l.owner, owner) {
241+ if !emitted.contains(&nested) {
242+ emitted.push(nested);
243+ if let Some(id) = self.owners.get(&nested).and_then(|i| i.id.clone()) {
244+ let indent = l.text.len() - l.text.trim_start_matches(' ').len();
245+ text_lines.push(Line::Embed(indent, id));
156246 }
157247 }
158248 }
159249 }
160− // The first line is the block's title line.
161250 if text_lines.is_empty() {
162251 return Ok(());
163252 }
164− // Shift back: re-level sections by +(level(block) − 1), re-indent by
165− // +indent(block) (§5.2.3).
166− let level_delta = info.level as isize - 1;
167− let indent_delta = info.indent as isize;
253+ // Shift back from the display position to the block's position in
254+ // its file (§5.2.3).
255+ let level_delta = info.target_level as isize - info.level as isize;
256+ let indent_delta = info.target_indent as isize - info.indent as isize;
257+ let shift = |cols: usize| (cols as isize + indent_delta).max(0) as usize;
168258 let mut out = String::new();
169259 let mut fence: Option<(char, usize)> = None;
170260 for l in &text_lines {
171− if let Some(id) = l.strip_prefix("EMBED:") {
172− out.push_str(&" ".repeat(indent_delta.max(0) as usize));
173− out.push_str("![[");
174− out.push_str(id);
175− out.push_str("]]\n");
176− continue;
177− }
178− let trimmed = l.trim_start();
179− if fence_transition(l, &mut fence) || fence.is_some() {
180− out.push_str(l);
261+ let l = match l {
262+ Line::Embed(indent, id) => {
263+ out.push_str(&" ".repeat(shift(*indent)));
264+ out.push_str("![[");
265+ out.push_str(id.as_str());
266+ out.push_str("]]\n");
267+ continue;
268+ }
269+ Line::Text(t) => t,
270+ };
271+ if l.trim().is_empty() {
181272 out.push('\n');
182273 continue;
183274 }
184− let cur_indent = l.len() - trimmed.len();
185− let new_indent = (cur_indent as isize + indent_delta).max(0) as usize;
186− if trimmed.starts_with('#') {
187− let hashes = trimmed.chars().take_while(|&c| c == '#').count();
275+ let trimmed = l.trim_start_matches(' ');
276+ let new_indent = shift(l.len() - trimmed.len());
277+ out.push_str(&" ".repeat(new_indent));
278+ let in_code = fence_transition(l, &mut fence) || fence.is_some();
279+ let hashes = trimmed.chars().take_while(|&c| c == '#').count();
280+ let after = &trimmed[hashes..];
281+ if !in_code && hashes > 0 && (after.is_empty() || after.starts_with(' ')) {
188282 let nl = (hashes as isize + level_delta).max(1) as usize;
189− out.push_str(&" ".repeat(new_indent));
190283 out.push_str(&"#".repeat(nl));
191− out.push_str(&trimmed[hashes..]);
192− out.push('\n');
284+ out.push_str(after);
193285 } else {
194− out.push_str(&" ".repeat(new_indent));
195286 out.push_str(trimmed);
196− out.push('\n');
197287 }
288+ out.push('\n');
198289 }
199− // External-change check (§5.2.5).
200− let file = owner.file;
201− let current_hash = blake3::hash(vault.tree.files[file].text.as_bytes())
202− .to_hex()
203− .to_string();
204− if current_hash != self.base_hashes[file]
205− && current_hash != vault.hash_of(file)
206− {
290+ while out.ends_with("\n\n") {
291+ out.pop();
292+ }
293+ let nref = self.locate(vault, &info).ok_or_else(|| {
294+ std::io::Error::new(
295+ std::io::ErrorKind::NotFound,
296+ format!("{}: edited node no longer found", info.path),
297+ )
298+ })?;
299+ let file = nref.0;
300+ let path = vault.tree.files[file].path.clone();
301+ // External-change check (§5.2.5): what is on disk now must be what
302+ // was read (or last written) through this buffer.
303+ let on_disk = std::fs::read_to_string(vault.dir.join(&path)).unwrap_or_default();
304+ if self.base_hashes.get(&path).map(|h| *h != hash(&on_disk)).unwrap_or(false) {
207305 return Err(std::io::Error::new(
208306 std::io::ErrorKind::Other,
209− format!("{} changed on disk; not overwriting", vault.tree.files[file].path),
307+ format!("{} changed on disk; not overwriting", path),
210308 ));
211309 }
212− // Replace the block's span (or the whole file for its own block).
213− let nref = info.nref;
214− let n = vault.tree.node(nref);
310+ let f = &vault.tree.files[file];
311+ let n = &f.nodes[nref.1];
215312 if n.is_block() || n.kind == Kind::Root {
216− // the block IS the file (minus frontmatter): replace everything
217− // after the frontmatter
218− let f = &vault.tree.files[file];
219− let fm_end = f.nodes[f.root_node]
220− .children
221− .first()
222− .and_then(|&rn| f.nodes[rn].block.as_ref())
313+ // the block IS the file: keep its frontmatter, replace the rest
314+ let fm_end = n
315+ .block
316+ .as_ref()
223317 .and_then(|b| b.frontmatter_span)
224318 .map(|s| s.end)
225− .unwrap_or(0);
319+ .unwrap_or(0)
320+ .min(f.text.len());
226321 let mut new_text = String::new();
227322 new_text.push_str(&f.text[..fm_end]);
228323 new_text.push_str(&out);
229324 vault.write_file_text(file, &new_text)?;
230325 } else {
231− vault.write_span(file, n.span, &out)?;
326+ // a span inside a file: keep the blank lines that separate it
327+ // from what follows
328+ let old = n.span.text(&f.text);
329+ let content_end = old.trim_end_matches([' ', '\t', '\r', '\n']).len();
330+ let rest = &old[content_end..];
331+ let sep = rest.find('\n').map(|i| &rest[i + 1..]).unwrap_or("");
332+ let replacement = format!("{}{}", out, sep);
333+ vault.write_span(file, n.span, &replacement)?;
232334 }
233− self.base_hashes[file] = blake3::hash(vault.tree.files[file].text.as_bytes())
234− .to_hex()
235− .to_string();
335+ self.base_hashes
336+ .insert(path, hash(&vault.tree.files[file].text));
236337 self.dirty.retain(|o| *o != owner);
237338 Ok(())
238339 }
@@ -250,6 +351,17 @@ impl EditBuffer {
250351 }
251352}
252353
354+/// A line collected for splice: owned text, or a nested block's embed at
355+/// the display indent of its first line.
356+enum Line {
357+ Text(String),
358+ Embed(usize, Id),
359+}
360+
361+fn hash(text: &str) -> String {
362+ blake3::hash(text.as_bytes()).to_hex().to_string()
363+}
364+
253365fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
254366 let t = raw.trim_start();
255367 let first = match t.chars().next() {
@@ -276,166 +388,6 @@ fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
276388 }
277389}
278390
279−/// Recursive buffer construction: emit a node's title/body at the display
280−/// position, tag lines with the owning block, descend (§5.2).
281−#[allow(clippy::too_many_arguments)]
282−fn build_node(
283− vault: &Vault,
284− r: NRef,
285− dlevel: usize,
286− dindent: usize,
287− block_ord: &mut usize,
288− lines: &mut Vec<EditLine>,
289− owners: &mut BTreeMap<Owner, OwnerInfo>,
290− seen: &mut Vec<NRef>,
291−) {
292− if seen.contains(&r) {
293− return;
294− }
295− seen.push(r);
296− let n = vault.tree.node(r);
297− // The owning block: nearest ancestor-or-self that is one. The render
298− // root owns until a nested block starts.
299− let is_block_start = n.is_block() && n.kind != Kind::Root;
300− let owner = if is_block_start {
301− *block_ord += 1;
302− let o = Owner { file: r.0, block_ord: *block_ord };
303− owners.insert(
304− o,
305− OwnerInfo {
306− title: n.title.clone(),
307− id: n.block.as_ref().and_then(|b| b.id.clone()),
308− nref: r,
309− level: dlevel,
310− indent: dindent,
311− },
312− );
313− o
314− } else if n.kind == Kind::Root || owners.is_empty() {
315− let o = Owner { file: r.0, block_ord: 0 };
316− owners.entry(o).or_insert_with(|| OwnerInfo {
317− title: if n.kind == Kind::Root {
318− "root".into()
319− } else {
320− n.title.clone()
321− },
322− id: n.block.as_ref().and_then(|b| b.id.clone()),
323− nref: r,
324− level: dlevel,
325− indent: dindent,
326− });
327− o
328− } else {
329− // owned by the enclosing block: the last opened owner
330− *owners.keys().last().unwrap()
331− };
332− // Title line.
333− match n.kind {
334− Kind::Root => {}
335− Kind::Section => {
336− let mut s = " ".repeat(dindent);
337− s.push_str(&"#".repeat(dlevel.max(1)));
338− s.push(' ');
339− if !n.is_block() {
340− push_checkbox(n.task, &mut s);
341− }
342− s.push_str(n.title.trim_end());
343− lines.push(EditLine { text: s, owner });
344− }
345− Kind::Item => {
346− let mut s = " ".repeat(dindent);
347− s.push_str("- ");
348− if !n.is_block() {
349− push_checkbox(n.task, &mut s);
350− }
351− s.push_str(n.title.trim_end());
352− lines.push(EditLine { text: s, owner });
353− }
354− }
355− // Body.
356− let body = n.body_lines(vault.tree.text_of(r));
357− let mut body: Vec<&str> = body;
358− while body.first().map(|l| l.trim().is_empty()) == Some(true) {
359− body.remove(0);
360− }
361− while body.last().map(|l| l.trim().is_empty()) == Some(true) {
362− body.pop();
363− }
364− if !body.is_empty() && n.kind != Kind::Root {
365− lines.push(EditLine { text: String::new(), owner });
366− }
367− let dedent_by = vault.tree.indent(r);
368− for l in &body {
369− lines.push(EditLine {
370− text: format!("{}{}", " ".repeat(dindent), dedent(l, dedent_by)),
371− owner,
372− });
373− }
374− // Children.
375− let children = vault.tree.resolved_children(r);
376− let mut first = true;
377− for c in children {
378− let cn = vault.tree.node(c);
379− if cn.kind == Kind::Root {
380− continue;
381− }
382− if first {
383− if !body.is_empty() || n.kind != Kind::Root {
384− lines.push(EditLine { text: String::new(), owner });
385− }
386− first = false;
387− } else if cn.kind == Kind::Section {
388− lines.push(EditLine { text: String::new(), owner });
389− }
390− let cindent = dindent + vault.tree.indent(c).saturating_sub(vault.tree.indent(r));
391− let clevel = match cn.kind {
392− Kind::Section => dlevel + 1,
393− _ => dlevel,
394− };
395− // embeds resolve in the editing buffer (§10.6): the nested block's
396− // lines carry its own tag.
397− let target = vault.tree.resolved_child(c);
398− if target != c {
399− build_node(vault, target, clevel, cindent, block_ord, lines, owners, seen);
400− } else if cn.is_embed() {
401− // broken embed: shown as text, owned here
402− lines.push(EditLine {
403− text: format!("{}![[{}]]", " ".repeat(cindent), cn.embed.as_ref().unwrap()),
404− owner,
405− });
406− } else {
407− build_node(vault, c, clevel, cindent, block_ord, lines, owners, seen);
408− }
409− }
410− seen.pop();
411−}
412−
413−fn push_checkbox(task: Option<TaskState>, out: &mut String) {
414− match task {
415− Some(TaskState::Open) => out.push_str("[ ] "),
416− Some(TaskState::Done) => out.push_str("[x] "),
417− None => {}
418− }
419−}
420−
421−fn dedent(line: &str, cols: usize) -> &str {
422− if cols == 0 {
423− return line;
424− }
425− let mut removed = 0;
426− for (i, ch) in line.char_indices() {
427− if removed >= cols {
428− return &line[i..];
429− }
430− match ch {
431− ' ' => removed += 1,
432− '\t' => removed += 4,
433− _ => return &line[i..],
434− }
435− }
436− ""
437−}
438−
439391/// What the editor needs to know when it opens: the buffer plus a render of
440392/// the same text for display (the lines ARE the display).
441393pub fn open_editor(vault: &Vault, root: NRef) -> EditBuffer {
crates/fold-core/src/ident.rs+11−4
@@ -46,16 +46,17 @@ impl Id {
4646 }
4747
4848 /// The shortest leading run of words not used by any prefix in `taken`
49− /// (§6.4). The full id is always a valid prefix, so this terminates.
49+ /// (§6.4). The full id is always a valid prefix: if even it is taken
50+ /// (an id collision, reported elsewhere), it is returned anyway.
5051 pub fn shortest_prefix(&self, taken: &dyn Fn(&str) -> bool) -> String {
5152 let words = self.words();
52− for n in 1..=4 {
53+ for n in 1..4 {
5354 let cand = words[..n].join("-");
5455 if !taken(&cand) {
5556 return cand;
5657 }
5758 }
58− unreachable!("full id is always unique among existing prefixes")
59+ self.0.clone()
5960 }
6061
6162 pub fn as_str(&self) -> &str {
@@ -70,10 +71,11 @@ impl std::fmt::Display for Id {
7071}
7172
7273fn is_word(w: &str) -> bool {
74+ // ASCII first: slicing at byte 3 of a non-ASCII word would panic
7375 w.len() == 6
76+ && w.is_ascii()
7477 && PREFIXES.contains(&&w[..3])
7578 && SUFFIXES.contains(&&w[3..])
76− && w.is_ascii()
7779}
7880
7981/// True if `s` (already stripped of any `![[ ]]`) is a valid leading run of
@@ -177,6 +179,9 @@ mod tests {
177179 assert!(looks_like_id_prefix("racfer-hattes-dozzod-binwes"));
178180 assert!(!looks_like_id_prefix("racfer-hattes-dozzod-binwes-wat"));
179181 assert!(!looks_like_id_prefix("nope"));
182+ // non-ASCII words must be rejected, not panic
183+ assert!(Id::parse("ééaa-racfer-hattes-dozzod").is_none());
184+ assert!(!looks_like_id_prefix("ééé"));
180185 }
181186
182187 #[test]
@@ -196,6 +201,8 @@ mod tests {
196201 id.shortest_prefix(&|p| p == "racfer"),
197202 "racfer-hattes"
198203 );
204+ // every shorter run taken: the full id, never a panic
205+ assert_eq!(id.shortest_prefix(&|_| true), "racfer-hattes-mislup-nodrys");
199206 }
200207
201208 #[test]
crates/fold-core/src/merge.rs+273−119
@@ -14,46 +14,91 @@ pub struct MergeOutcome {
1414 pub conflict_blocks: Vec<(String, String)>,
1515 /// Number of conflict pairs raised.
1616 pub conflicts: usize,
17+ /// Conflict blocks raised against the file's own block root. Their embeds
18+ /// cannot go inside a block file (a second column-0 node, §4.9); they
19+ /// belong right after this file's embed in its parent file (§12.4).
20+ pub sibling_embeds: Vec<Id>,
21+}
22+
23+/// Merge state shared across the recursion.
24+struct Ctx<'a> {
25+ conflicts: usize,
26+ conflict_blocks: Vec<(String, String)>,
27+ sibling_embeds: Vec<Id>,
28+ /// Nodes inserted from `T` only; with no conflicts and no insertions the
29+ /// merge is `O` itself, byte for byte (§15.6).
30+ insertions: usize,
31+ device: &'a str,
32+ timestamp: &'a str,
33+ /// `O`'s and `T`'s block-file frontmatter (block files only).
34+ o_fm: Option<crate::parse::Frontmatter>,
35+ t_fm: Option<crate::parse::Frontmatter>,
1736}
1837
1938/// Merge two versions of one file. `device`/`timestamp` stamp the conflict
2039/// blocks (§12.4).
2140pub fn merge_texts(ours: &str, theirs: &str, device: &str, timestamp: &str) -> MergeOutcome {
41+ use crate::parse::parse_frontmatter;
2242 let o = standalone_tree(ours);
2343 let t = standalone_tree(theirs);
2444 // If both are block files, ids must match (§12.2); the caller handles
2545 // the prefix-collision case before calling us.
26− let mut conflicts = 0usize;
27− let mut conflict_blocks = Vec::new();
28− let merged_root = merge_children(
29− &o,
30− &t,
31− o.root,
32− t.root,
33− &mut conflicts,
34− &mut conflict_blocks,
46+ let is_block = |fm: &Option<crate::parse::Frontmatter>| {
47+ fm.as_ref().map(|f| f.props.contains_key("id")).unwrap_or(false)
48+ };
49+ let o_fm = parse_frontmatter(ours);
50+ let t_fm = parse_frontmatter(theirs);
51+ let block_file = is_block(&o_fm) && is_block(&t_fm);
52+ let mut ctx = Ctx {
53+ conflicts: 0,
54+ conflict_blocks: Vec::new(),
55+ sibling_embeds: Vec::new(),
56+ insertions: 0,
3557 device,
3658 timestamp,
37− 1,
38− 0,
39− );
40− let mut text = merged_root;
41− // frontmatter merge for block files: key by key (§12.4)
42− text = merge_frontmatter(ours, theirs, &text, &mut conflicts, &mut conflict_blocks, device, timestamp);
59+ o_fm: if block_file { parse_frontmatter(ours) } else { None },
60+ t_fm: if block_file { t_fm } else { None },
61+ };
62+ let merged = merge_children(&o, &t, o.root, t.root, &mut ctx, 1, 0);
63+ let text = if ctx.conflicts == 0 && ctx.insertions == 0 {
64+ // T adds nothing: the merge is O, formatting included
65+ ours.to_string()
66+ } else {
67+ // O's frontmatter stays verbatim; differing keys were raised as a
68+ // conflict on the block root (§12.4)
69+ match &o_fm {
70+ Some(fm) => {
71+ let mut head = ours[..fm.span.end].to_string();
72+ if !head.ends_with("\n\n") {
73+ head.push('\n');
74+ }
75+ head + &merged
76+ }
77+ None => merged,
78+ }
79+ };
4380 MergeOutcome {
4481 text,
45− conflict_blocks,
46− conflicts,
82+ conflict_blocks: ctx.conflict_blocks,
83+ conflicts: ctx.conflicts,
84+ sibling_embeds: ctx.sibling_embeds,
4785 }
4886}
4987
88+/// Parse one version on its own, frontmatter included, so a block file's
89+/// frontmatter is never mistaken for body text (§12.4).
5090fn standalone_tree(text: &str) -> Tree {
91+ let fm = crate::parse::parse_frontmatter(text);
92+ let id = fm
93+ .as_ref()
94+ .and_then(|f| f.props.get("id"))
95+ .and_then(|v| Id::parse(v));
5196 let block = Block {
52− id: None,
97+ id,
5398 path: "m.md".into(),
54− props: Default::default(),
55− frontmatter_raw: String::new(),
56− frontmatter_span: None,
99+ props: fm.as_ref().map(|f| f.props.clone()).unwrap_or_default(),
100+ frontmatter_raw: fm.as_ref().map(|f| f.raw.clone()).unwrap_or_default(),
101+ frontmatter_span: fm.as_ref().map(|f| f.span),
57102 edge_span: None,
58103 };
59104 let pf = parse_file("m.md", text, 0, Some(block));
@@ -78,10 +123,7 @@ fn merge_children(
78123 t: &Tree,
79124 op: NRef,
80125 tp: NRef,
81− conflicts: &mut usize,
82− conflict_blocks: &mut Vec<(String, String)>,
83− device: &str,
84− timestamp: &str,
126+ ctx: &mut Ctx,
85127 level: usize,
86128 indent: usize,
87129) -> String {
@@ -90,8 +132,24 @@ fn merge_children(
90132 // match: embed by id, else by exact title among unmatched siblings
91133 let mut t_used = vec![false; t_kids.len()];
92134 let mut pairs: Vec<(Option<NRef>, Option<NRef>)> = Vec::new();
135+ // a block file's root is the same node on both sides, whatever its
136+ // title: match it by the file's id (§12.4 rule 1)
137+ let file_id = |tr: &Tree, r: NRef| tr.node(r).block.as_ref().and_then(|b| b.id.clone());
138+ let root_pair = match (o_kids.first(), t_kids.first()) {
139+ (Some(&ok), Some(&tk)) if file_id(o, ok).is_some() && file_id(o, ok) == file_id(t, tk) => {
140+ Some((ok, tk))
141+ }
142+ _ => None,
143+ };
93144 for &ok in &o_kids {
94145 let on = o.node(ok);
146+ if let Some((rok, rtk)) = root_pair {
147+ if ok == rok {
148+ t_used[0] = true;
149+ pairs.push((Some(ok), Some(rtk)));
150+ continue;
151+ }
152+ }
95153 let mut found = None;
96154 for (i, &tk) in t_kids.iter().enumerate() {
97155 if t_used[i] {
@@ -126,16 +184,22 @@ fn merge_children(
126184 // insertions; differing fields raise conflict pairs (§12.4).
127185 let mut out = String::new();
128186 for (ok, tk) in pairs {
187+ let first_is_section = ok
188+ .map(|a| o.node(a).kind == Kind::Section)
189+ .or_else(|| tk.map(|b| t.node(b).kind == Kind::Section))
190+ .unwrap_or(false);
191+ if !out.is_empty() && first_is_section && !out.ends_with("\n\n") {
192+ out.push('\n');
193+ }
129194 match (ok, tk) {
130195 (Some(a), Some(b)) => {
131− out.push_str(&merge_pair(
132− o, t, a, b, conflicts, conflict_blocks, device, timestamp, level, indent,
133− ));
196+ out.push_str(&merge_pair(o, t, a, b, ctx, level, indent));
134197 }
135198 (Some(a), None) => {
136199 out.push_str(&emit_subtree(o, a, level, indent));
137200 }
138201 (None, Some(b)) => {
202+ ctx.insertions += 1;
139203 out.push_str(&emit_subtree(t, b, level, indent));
140204 }
141205 (None, None) => {}
@@ -144,16 +208,12 @@ fn merge_children(
144208 out
145209}
146210
147−#[allow(clippy::too_many_arguments)]
148211fn merge_pair(
149212 o: &Tree,
150213 t: &Tree,
151214 a: NRef,
152215 b: NRef,
153− conflicts: &mut usize,
154− conflict_blocks: &mut Vec<(String, String)>,
155− device: &str,
156− timestamp: &str,
216+ ctx: &mut Ctx,
157217 level: usize,
158218 indent: usize,
159219) -> String {
@@ -163,9 +223,16 @@ fn merge_pair(
163223 if on.embed.is_some() {
164224 return emit_subtree(o, a, level, indent);
165225 }
226+ // the file's own block root: its frontmatter keys are fields too (§12.4)
227+ let is_file_block = on.block.as_ref().map(|bl| bl.id.is_some()).unwrap_or(false);
166228 let (otitle, otask, obody) = node_sig(o, a);
167229 let (_ttitle, ttask, tbody) = node_sig(t, b);
168− let differ = otitle != tn.title || otask != ttask || obody != tbody;
230+ let mut differ = otitle != tn.title || otask != ttask || obody != tbody;
231+ if is_file_block {
232+ if let (Some(ofm), Some(tfm)) = (&ctx.o_fm, &ctx.t_fm) {
233+ differ |= ofm.props != tfm.props;
234+ }
235+ }
169236 // children merge
170237 let child_level = match on.kind {
171238 Kind::Section => level + 1,
@@ -175,34 +242,72 @@ fn merge_pair(
175242 Kind::Item => indent + 2,
176243 _ => indent,
177244 };
178− let merged_kids = merge_children(
179− o, t, a, b, conflicts, conflict_blocks, device, timestamp, child_level, child_indent,
245+ let merged_kids = merge_children(o, t, a, b, ctx, child_level, child_indent);
246+ if !differ {
247+ return emit_node_with(o, a, level, indent, &merged_kids);
248+ }
249+ ctx.conflicts += 1;
250+ // theirs becomes a conflict block stitched in next to ours (§12.4)
251+ let name = slug(&otitle);
252+ let id = Id::generate();
253+ let prefix = id.words()[0].to_string();
254+ let fname = filename(&prefix, &name);
255+ let mut block_text = format!(
256+ "---\nid: {}\nconflict: \"{} {}\"\n",
257+ id, ctx.device, ctx.timestamp
180258 );
181− let mut out = emit_node_with(o, a, level, indent, &merged_kids);
182− if differ {
183− *conflicts += 1;
184− // theirs becomes a conflict block stitched in next to ours (§12.4)
185− let name = slug(&otitle);
186− let id = Id::generate();
187− let prefix = id.words()[0].to_string();
188− let fname = filename(&prefix, &name);
189− let mut block_text = format!("---\nid: {}\nconflict: \"{} {}\"\n", id, device, timestamp);
190− if let Some(ts) = ttask {
191− block_text.push_str(&format!(
192− "todo: {}\n",
193− match ts {
194− TaskState::Open => "open",
195− TaskState::Done => "done",
196− }
197− ));
259+ let t_props = if is_file_block {
260+ ctx.t_fm.as_ref().map(|f| f.props.clone())
261+ } else {
262+ None
263+ };
264+ match t_props {
265+ // a block conflict carries T's own properties (§12.4)
266+ Some(props) => {
267+ for (k, v) in props.iter().filter(|(k, _)| *k != "id" && *k != "conflict") {
268+ block_text.push_str(&format!("{}: {}\n", k, v));
269+ }
270+ }
271+ None => {
272+ if let Some(ts) = ttask {
273+ block_text.push_str(&format!(
274+ "todo: {}\n",
275+ match ts {
276+ TaskState::Open => "open",
277+ TaskState::Done => "done",
278+ }
279+ ));
280+ }
198281 }
199− block_text.push_str("---\n\n");
200− block_text.push_str(&emit_subtree_with_state(t, b, 1, 0));
201− conflict_blocks.push((fname, block_text));
202− // the embed goes right after ours
203− out.push_str(&format!("{}![[{}]]\n", " ".repeat(indent), id));
204282 }
205− out
283+ block_text.push_str("---\n\n");
284+ block_text.push_str(&emit_subtree_with_state(t, b, 1, 0));
285+ ctx.conflict_blocks.push((fname, block_text));
286+ let embed = format!("{}![[{}]]\n", " ".repeat(indent), id);
287+ if is_file_block {
288+ // a block file has one column-0 node: the embed goes after this
289+ // file's own embed in the parent file (the caller places it)
290+ ctx.sibling_embeds.push(id);
291+ emit_node_with(o, a, level, indent, &merged_kids)
292+ } else if on.kind == Kind::Section {
293+ // An embed after a section's children would parse as its last
294+ // child; one right after its title and body, as its first child,
295+ // is the closest a section's "next" position can be. The conflict
296+ // view pairs a first-child conflict with its parent (§10.8).
297+ let mut kids = embed;
298+ if !merged_kids.trim().is_empty() {
299+ if merged_kids.trim_start().starts_with('#') {
300+ kids.push('\n');
301+ }
302+ kids.push_str(&merged_kids);
303+ }
304+ emit_node_with(o, a, level, indent, &kids)
305+ } else {
306+ // the embed goes right after ours, as its next sibling
307+ let mut out = emit_node_with(o, a, level, indent, &merged_kids);
308+ out.push_str(&embed);
309+ out
310+ }
206311}
207312
208313/// Render a node at (level, indent) with given already-rendered children.
@@ -329,49 +434,6 @@ fn dedent(line: &str, cols: usize) -> &str {
329434 ""
330435}
331436
332−/// Merge the frontmatter of two block files key by key (§12.4). Ours wins
333−/// equal values; differing values keep ours in place and the whole "theirs"
334−/// document becomes a conflict block (already handled at the node level for
335−/// title/body; here we only fold in keys).
336−fn merge_frontmatter(
337− ours: &str,
338− theirs: &str,
339− merged: &str,
340− conflicts: &mut usize,
341− _conflict_blocks: &mut Vec<(String, String)>,
342− _device: &str,
343− _timestamp: &str,
344−) -> String {
345− use crate::parse::parse_frontmatter;
346− let ofm = parse_frontmatter(ours);
347− let tfm = parse_frontmatter(theirs);
348− let (Some(ofm), Some(tfm)) = (ofm, tfm) else {
349− return merged.to_string();
350− };
351− // key-level: every key that differs counts as a conflict; value from O
352− // stays. We re-emit frontmatter with O's raw text plus T-only keys dropped
353− // (they live in the conflict block's own frontmatter).
354− let raw = ofm.raw.clone();
355− for (k, v) in &tfm.props {
356− match ofm.props.get(k) {
357− Some(ov) if ov == v => {}
358− Some(_) => *conflicts += 1,
359− None => *conflicts += 1,
360− }
361− }
362− // Rebuild the file: merged frontmatter + merged body (strip merged's
363− // frontmatter if any).
364− let body = match crate::parse::parse_frontmatter(merged) {
365− Some(mfm) => merged[mfm.span.end..].to_string(),
366− None => merged.to_string(),
367− };
368− if raw.trim().is_empty() {
369− body
370− } else {
371− format!("---\n{}---\n\n{}", raw, body)
372− }
373−}
374−
375437// ------------------------------------------------------------ vault-level
376438
377439/// Process every `*.sync-conflict-*.md` in the vault (§12.2, §13 `notes merge`).
@@ -421,7 +483,12 @@ pub fn merge_sync_conflicts(vault: &mut Vault, dry_run: bool) -> std::io::Result
421483 if !dry_run {
422484 crate::vault::atomic_write(&bpath, &outcome.text)?;
423485 for (fname, text) in &outcome.conflict_blocks {
424− crate::vault::atomic_write(&vault.dir.join(fname), text)?;
486+ let fname = fresh_block_name(vault, fname, text);
487+ crate::vault::atomic_write(&vault.dir.join(&fname), text)?;
488+ }
489+ if !outcome.sibling_embeds.is_empty() {
490+ vault.reload()?;
491+ place_sibling_embeds(vault, oid.as_ref(), &outcome.sibling_embeds)?;
425492 }
426493 // the conflict file goes to trash (§12.4)
427494 let trash = crate::vault::trash_dir();
@@ -436,6 +503,63 @@ pub fn merge_sync_conflicts(vault: &mut Vault, dry_run: bool) -> std::io::Result
436503 Ok(outcomes)
437504}
438505
506+/// A conflict block's filename that does not clobber an existing file:
507+/// lengthen the prefix of its id until the name is free (§6.4).
508+fn fresh_block_name(vault: &Vault, fname: &str, text: &str) -> String {
509+ let id = crate::parse::parse_frontmatter(text)
510+ .and_then(|f| f.props.get("id").cloned())
511+ .and_then(|v| Id::parse(&v));
512+ let (Some(id), Some((_, name))) = (id, crate::ident::split_filename(fname)) else {
513+ return fname.to_string();
514+ };
515+ let words = id.words();
516+ for n in 1..=words.len() {
517+ let cand = filename(&words[..n].join("-"), name);
518+ if !vault.dir.join(&cand).exists() {
519+ return cand;
520+ }
521+ }
522+ fname.to_string()
523+}
524+
525+/// Insert embeds right after the embed of block `owner` in its parent file,
526+/// at its indent, so each conflict block is the next sibling of the block it
527+/// conflicts with (§12.4). Without an embed to follow (an orphan block),
528+/// they go at the end of `root.md` so they are still reachable.
529+fn place_sibling_embeds(vault: &mut Vault, owner: Option<&Id>, ids: &[Id]) -> std::io::Result<()> {
530+ let found = owner.and_then(|oid| {
531+ vault.tree.files.iter().enumerate().find_map(|(fi, f)| {
532+ f.nodes
533+ .iter()
534+ .find(|nd| nd.embed.as_ref() == Some(oid))
535+ .map(|nd| (fi, nd.span.end.min(f.text.len()), nd.indent))
536+ })
537+ });
538+ let mut insert = String::new();
539+ match found {
540+ Some((fi, end, indent)) => {
541+ for id in ids {
542+ insert.push_str(&format!("\n{}![[{}]]", " ".repeat(indent), id));
543+ }
544+ // the embed line's span may or may not include its newline
545+ let text = &vault.tree.files[fi].text;
546+ let at = if end > 0 && text.as_bytes()[end - 1] == b'\n' { end - 1 } else { end };
547+ vault.write_span(fi, crate::parse::Span { start: at, end: at }, &insert)
548+ }
549+ None => {
550+ let text = vault.tree.files[0].text.clone();
551+ let mut new_text = text.clone();
552+ if !new_text.is_empty() && !new_text.ends_with("\n\n") {
553+ new_text.push_str(if new_text.ends_with('\n') { "\n" } else { "\n\n" });
554+ }
555+ for id in ids {
556+ new_text.push_str(&format!("![[{}]]\n", id));
557+ }
558+ vault.write_file_text(0, &new_text)
559+ }
560+ }
561+}
562+
439563fn parse_conflict_name(name: &str) -> (String, String) {
440564 // name.sync-conflict-<date>-<time>-<device>.md
441565 let mut device = "unknown".to_string();
@@ -472,8 +596,16 @@ pub fn conflict_pairs_tree(tree: &Tree) -> Vec<(NRef, NRef)> {
472596 let p = nd.parent.map(|pp| (fi, pp)).unwrap();
473597 let sibs = tree.raw_children(p);
474598 if let Some(pos) = sibs.iter().position(|&s| s == (fi, ni)) {
475− if pos > 0 {
476− let ours = sibs[pos - 1];
599+ // a section's conflict is its first child
600+ // (see merge_pair); anything else follows ours
601+ let ours = if pos > 0 {
602+ Some(sibs[pos - 1])
603+ } else if tree.node(p).kind == Kind::Section {
604+ Some(p)
605+ } else {
606+ None
607+ };
608+ if let Some(ours) = ours {
477609 let ours = {
478610 let rc = tree.resolved_child(ours);
479611 if rc != ours { rc } else { ours }
@@ -508,24 +640,36 @@ pub fn resolve_keep_theirs(vault: &mut Vault, ours: NRef, theirs: NRef) -> std::
508640 let mut theirs_body = crate::render::render(&vault.tree, theirs, 1, true);
509641 // The conflict block's state lives in its frontmatter (§4.9); surface it
510642 // as a checkbox so the receiving side parses it back as task state.
511− if let Some(state) = vault.tree.node(theirs).task {
643+ // A block's state is `todo:`, never a checkbox in its file (§4.9).
644+ let ours_is_block = vault.tree.node(ours).is_block();
645+ let ours_block_id = vault.tree.node(ours).block.as_ref().and_then(|b| b.id.clone());
646+ let state = if ours_is_block { None } else { vault.tree.node(theirs).task };
647+ if let Some(state) = state {
512648 let marker = match state {
513649 TaskState::Open => "[ ] ",
514650 TaskState::Done => "[x] ",
515651 };
516652 let mut lines = theirs_body.lines();
517653 if let Some(first) = lines.next() {
518− let inserted = if let Some(pos) = first.find("- ") {
519− let mut s = first.to_string();
520− s.insert_str(pos + 2, marker);
521− s
522− } else if let Some(pos) = first.find('#') {
523− let h = first[pos..].find(' ').map(|i| pos + i + 1).unwrap_or(first.len());
524− let mut s = first.to_string();
525− s.insert_str(h, marker);
526− s
654+ // the title line is `#… ` or `- ` after its indent; the checkbox
655+ // goes right after that marker, never inside the title
656+ let lead = first.len() - first.trim_start().len();
657+ let rest = &first[lead..];
658+ let marker_end = if rest.starts_with('#') {
659+ let hashes = rest.len() - rest.trim_start_matches('#').len();
660+ rest[hashes..].starts_with(' ').then_some(lead + hashes + 1)
661+ } else if rest.starts_with("- ") {
662+ Some(lead + 2)
527663 } else {
528− first.to_string()
664+ None
665+ };
666+ let inserted = match marker_end {
667+ Some(m) if !is_checkbox(&first[m..]) => {
668+ let mut s = first.to_string();
669+ s.insert_str(m, marker);
670+ s
671+ }
672+ _ => first.to_string(),
529673 };
530674 let rest: Vec<&str> = lines.collect();
531675 theirs_body = if rest.is_empty() {
@@ -578,9 +722,14 @@ pub fn resolve_keep_theirs(vault: &mut Vault, ours: NRef, theirs: NRef) -> std::
578722 crate::ops::set_frontmatter_key(vault, file, &k, Some(&v))?;
579723 }
580724 // rename the file if the title changed (§6.4)
581− let new_title = vault.tree.node(ours).title.clone();
582− let _ = new_title;
583725 vault.reload()?;
726+ let file = ours_block_id
727+ .as_ref()
728+ .and_then(|id| vault.tree.block_by_id(id))
729+ .map(|r| (r.0, vault.tree.node(r).title.clone()));
730+ if let Some((file, title)) = file {
731+ vault.rename_block_file(file, &title)?;
732+ }
584733 } else {
585734 // plain node: replace its span with theirs' re-levelled text
586735 let level = vault.tree.level(ours);
@@ -598,6 +747,11 @@ pub fn resolve_keep_theirs(vault: &mut Vault, ours: NRef, theirs: NRef) -> std::
598747 Ok(())
599748}
600749
750+fn is_checkbox(s: &str) -> bool {
751+ let b = s.as_bytes();
752+ b.len() >= 3 && b[0] == b'[' && b[2] == b']' && (b.len() == 3 || b[3] == b' ')
753+}
754+
601755/// keep both: drop the `conflict` key; the block stays as an ordinary
602756/// sibling (§12.5).
603757pub fn resolve_keep_both(vault: &mut Vault, theirs: NRef) -> std::io::Result<()> {
@@ -618,7 +772,7 @@ fn delete_embed_and_block(
618772 let text = vault.tree.files[fi].text.clone();
619773 let mut start = span.start;
620774 let mut end = span.end;
621− if start >= 2 && &text[start - 2..start] == "\n\n" {
775+ if start >= 2 && &text.as_bytes()[start - 2..start] == b"\n\n" {
622776 start -= 1;
623777 } else if end < text.len() && text.as_bytes()[end] == b'\n' {
624778 end += 1;
crates/fold-core/src/ops.rs+472−213
@@ -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 {
crates/fold-core/src/parse.rs+79−47
@@ -61,6 +61,9 @@ pub struct Node {
6161 pub title_span: Span,
6262 /// Byte span of the body region (after the title line, before children).
6363 pub body_span: Span,
64+ /// Body lines that follow children (text after a list, between child
65+ /// items): `(number of children before the segment, span)`, in order.
66+ pub tail: Vec<(usize, Span)>,
6467 /// Byte span: title line through the whole subtree.
6568 pub span: Span,
6669 pub children: Vec<usize>,
@@ -113,7 +116,8 @@ pub struct Diag {
113116#[derive(Clone)]
114117struct Line {
115118 start: usize,
116− end: usize, // excluding '\n'
119+ end: usize, // excluding the line ending (`\n` or `\r\n`)
120+ next: usize, // start of the next line (past the line ending, or EOF)
117121 raw: String,
118122}
119123
@@ -126,6 +130,7 @@ fn split_lines(text: &str) -> Vec<Line> {
126130 lines.push(Line {
127131 start: off,
128132 end: off + raw.len(),
133+ next: off + l.len(),
129134 raw: raw.to_string(),
130135 });
131136 off += l.len();
@@ -142,7 +147,7 @@ pub struct Frontmatter {
142147
143148/// Scan frontmatter at byte 0 of a file. Returns None if absent.
144149pub fn parse_frontmatter(text: &str) -> Option<Frontmatter> {
145− if !text.starts_with("---\n") {
150+ if !text.starts_with("---\n") && !text.starts_with("---\r\n") {
146151 return None;
147152 }
148153 let lines = split_lines(text);
@@ -157,11 +162,10 @@ pub fn parse_frontmatter(text: &str) -> Option<Frontmatter> {
157162 let raw_start = lines[1].start;
158163 let raw_end = lines[close].start;
159164 let raw = text[raw_start..raw_end].to_string();
160− let mut span_end = lines[close].end + 1;
165+ let mut span_end = lines[close].next;
161166 if close + 1 < lines.len() && lines[close + 1].raw.is_empty() {
162− span_end = lines[close + 1].end + 1;
167+ span_end = lines[close + 1].next;
163168 }
164− span_end = span_end.min(text.len());
165169 let mut props = IndexMap::new();
166170 for l in &lines[1..close] {
167171 let raw_line = &l.raw;
@@ -338,15 +342,13 @@ fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
338342 }
339343}
340344
345+/// A setext underline. Only `=` underlines count: a line of dashes is a
346+/// thematic break and body text (§4.4, §5.2 step 2), and a lone `-` is an
347+/// empty bullet.
341348fn setext_level(raw: &str) -> Option<usize> {
342349 let t = raw.trim();
343− if t.is_empty() {
344− return None;
345− }
346− if t.chars().all(|c| c == '=') {
350+ if !t.is_empty() && t.chars().all(|c| c == '=') {
347351 Some(1)
348− } else if t.chars().all(|c| c == '-') {
349− Some(2)
350352 } else {
351353 None
352354 }
@@ -375,6 +377,7 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
375377 task: None,
376378 title_span: Span::default(),
377379 body_span: Span::default(),
380+ tail: Vec::new(),
378381 span: Span {
379382 start: 0,
380383 end: text.len(),
@@ -416,6 +419,7 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
416419 let raw = lines[i].raw.clone();
417420 let lstart = lines[i].start;
418421 let lend = lines[i].end;
422+ let lnext = lines[i].next;
419423
420424 if fence_transition(&raw, &mut fence) {
421425 pending_setext = None;
@@ -471,6 +475,12 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
471475 }
472476 true
473477 }
478+ Kind::Section if indent < top.indent => {
479+ // a section nested under an item holds only
480+ // what reaches its indent
481+ stack.pop();
482+ continue;
483+ }
474484 Kind::Section => {
475485 // A section is contained by the nearest section
476486 // with a smaller level — not by its lexical
@@ -509,12 +519,13 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
509519 end: lend,
510520 },
511521 body_span: Span {
512− start: lend + 1,
513− end: lend + 1,
522+ start: lnext,
523+ end: lnext,
514524 },
525+ tail: Vec::new(),
515526 span: Span {
516527 start: lstart,
517− end: lend + 1,
528+ end: lnext,
518529 },
519530 children: Vec::new(),
520531 parent: Some(parent),
@@ -560,7 +571,7 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
560571 let idx = nodes.len();
561572 nodes[parent].children.push(idx);
562573 nodes.push(node);
563− extend_spans(&mut nodes, idx, lend + 1);
574+ extend_spans(&mut nodes, idx, lnext);
564575 stack.push(Frame {
565576 node: idx,
566577 indent,
@@ -674,7 +685,14 @@ fn push_body(
674685 continue; // look through to the item's parent
675686 }
676687 }
677− Kind::Section => true,
688+ Kind::Section => {
689+ let mut t = false;
690+ if indent_cols(&line.raw, &mut t) < top.indent {
691+ stack.pop();
692+ continue; // outdented past a section under an item
693+ }
694+ true
695+ }
678696 Kind::Root => true,
679697 }
680698 };
@@ -685,13 +703,29 @@ fn push_body(
685703 }
686704 let top = stack.last().unwrap().node;
687705 let n = &mut nodes[top];
688− // body_span starts empty (start == end, right after the title line); the
689− // first body line sets its start.
690− if n.body_span.start == n.body_span.end {
691− n.body_span.start = line.start;
706+ if !n.children.is_empty() {
707+ // body text after children goes to a tail segment, so the body span
708+ // never stretches over the children (§3.3)
709+ let after = n.children.len();
710+ match n.tail.last_mut() {
711+ Some((k, sp)) if *k == after && sp.end == line.start => sp.end = line.next,
712+ _ => n.tail.push((
713+ after,
714+ Span {
715+ start: line.start,
716+ end: line.next,
717+ },
718+ )),
719+ }
720+ } else {
721+ // body_span starts empty (start == end, right after the title line);
722+ // the first body line sets its start.
723+ if n.body_span.start == n.body_span.end {
724+ n.body_span.start = line.start;
725+ }
726+ n.body_span.end = line.next;
692727 }
693− n.body_span.end = line.end + 1;
694− extend_spans(nodes, top, line.end + 1);
728+ extend_spans(nodes, top, line.next);
695729}
696730
697731fn make_setext_section(
@@ -703,32 +737,29 @@ fn make_setext_section(
703737 underline_li: usize,
704738 level: usize,
705739) {
706− // The title line is the last body line of whichever node owns it; find it
707− // by scanning down from the top frame whose body span covers it.
740+ // The title line is the last body line pushed, so it ends the top
741+ // frame's body or its last tail segment; take it back out.
708742 let title = lines[title_li].raw.trim().to_string();
709743 let underline = &lines[underline_li];
710− // Owner: deepest frame whose body span includes the title line.
711− let mut owner = 0usize;
712− for f in stack.iter() {
713− let nd = &nodes[f.node];
714− if nd.body_span.start <= lines[title_li].start && lines[title_li].start < nd.body_span.end
715− {
716− owner = f.node;
717− }
718− }
719− // Shrink owner's body span to exclude the title line.
744+ let tl = &lines[title_li];
720745 {
746+ let owner = stack.last().unwrap().node;
721747 let nd = &mut nodes[owner];
722− if nd.body_span.start == lines[title_li].start {
723− nd.body_span.start = lines[title_li].end + 1;
724− if nd.body_span.start > nd.body_span.end {
725− nd.body_span.start = nd.body_span.end;
748+ match nd.tail.last_mut() {
749+ Some((_, sp)) if sp.end == tl.next => {
750+ sp.end = tl.start;
751+ if sp.start >= sp.end {
752+ nd.tail.pop();
753+ }
754+ }
755+ _ => {
756+ if nd.body_span.end == tl.next {
757+ nd.body_span.end = tl.start;
758+ if nd.body_span.start >= nd.body_span.end {
759+ nd.body_span.start = nd.body_span.end;
760+ }
761+ }
726762 }
727− } else {
728− nd.body_span.end = lines[title_li].start;
729− // trim trailing blank lines of the remaining body
730− let text_start = nd.body_span.start;
731− let _ = text_start;
732763 }
733764 }
734765 // Pop frames that can't contain a section at this position.
@@ -756,12 +787,13 @@ fn make_setext_section(
756787 end: lines[title_li].end,
757788 },
758789 body_span: Span {
759− start: underline.end + 1,
760− end: underline.end + 1,
790+ start: underline.next,
791+ end: underline.next,
761792 },
793+ tail: Vec::new(),
762794 span: Span {
763795 start: lines[title_li].start,
764− end: underline.end + 1,
796+ end: underline.next,
765797 },
766798 children: Vec::new(),
767799 parent: Some(parent),
@@ -773,7 +805,7 @@ fn make_setext_section(
773805 noncanonical: vec!["setext heading".into()],
774806 });
775807 nodes[parent].children.push(idx);
776− extend_spans(nodes, idx, underline.end + 1);
808+ extend_spans(nodes, idx, underline.next);
777809 stack.push(Frame {
778810 node: idx,
779811 indent: title_indent,
crates/fold-core/src/reading.rs+10−125
@@ -1,7 +1,7 @@
11//! The reading pane's document: the resolved render with a map from line
22//! numbers to nodes, so keys can act on "the node under the cursor" (§10.4).
33
4−use crate::parse::{Kind, TaskState};
4+use crate::render::{render_lines, LineKind};
55use crate::tree::NRef;
66use crate::vault::Vault;
77
@@ -23,131 +23,16 @@ pub struct ReadingDoc {
2323pub fn build(vault: &Vault, root: NRef) -> ReadingDoc {
2424 let mut lines = Vec::new();
2525 let mut refs = Vec::new();
26− build_node(vault, root, 1, 0, &mut lines, &mut refs, &mut Vec::new());
27− ReadingDoc { lines, refs }
28−}
29−
30−#[allow(clippy::too_many_arguments)]
31−fn build_node(
32− vault: &Vault,
33− r: NRef,
34− dlevel: usize,
35− dindent: usize,
36− lines: &mut Vec<String>,
37− refs: &mut Vec<LineRef>,
38− seen: &mut Vec<NRef>,
39−) {
40− if seen.contains(&r) {
41− return;
42− }
43− seen.push(r);
44− let n = vault.tree.node(r);
45− match n.kind {
46− Kind::Root => {}
47− Kind::Section => {
48− let mut s = " ".repeat(dindent);
49− s.push_str(&"#".repeat(dlevel.max(1)));
50− s.push(' ');
51− if !n.is_block() {
52− checkbox(n.task, &mut s);
53− }
54− s.push_str(n.title.trim_end());
55− lines.push(s);
56− refs.push(LineRef::Title(r));
57− }
58− Kind::Item => {
59− let mut s = " ".repeat(dindent);
60− s.push_str("- ");
61− if !n.is_block() {
62− checkbox(n.task, &mut s);
63− }
64− s.push_str(n.title.trim_end());
65− lines.push(s);
66− refs.push(LineRef::Title(r));
67− }
68− }
69− // body
70− let body = n.body_lines(vault.tree.text_of(r));
71− let mut body: Vec<&str> = body;
72− while body.first().map(|l| l.trim().is_empty()) == Some(true) {
73− body.remove(0);
74− }
75− while body.last().map(|l| l.trim().is_empty()) == Some(true) {
76− body.pop();
77− }
78− if !body.is_empty() && n.kind != Kind::Root {
79− lines.push(String::new());
80− refs.push(LineRef::Blank);
26+ for l in render_lines(&vault.tree, root, 1, true) {
27+ refs.push(match l.kind {
28+ LineKind::Title => LineRef::Title(l.node),
29+ LineKind::Body => LineRef::Body(l.node),
30+ LineKind::Embed => LineRef::Embed(l.node),
31+ LineKind::Blank => LineRef::Blank,
32+ });
33+ lines.push(l.text);
8134 }
82− let dedent_by = vault.tree.indent(r);
83− for l in &body {
84− lines.push(format!("{}{}", " ".repeat(dindent), dedent(l, dedent_by)));
85− refs.push(LineRef::Body(r));
86− }
87− // children
88− let children = vault.tree.resolved_children(r);
89− let mut first = true;
90− for c in children {
91− let cn = vault.tree.node(c);
92− if cn.kind == Kind::Root {
93− continue;
94− }
95− if first {
96− if !body.is_empty() || n.kind != Kind::Root {
97− lines.push(String::new());
98− refs.push(LineRef::Blank);
99− }
100− first = false;
101− } else if cn.kind == Kind::Section {
102− lines.push(String::new());
103− refs.push(LineRef::Blank);
104− }
105− let cindent = dindent + vault.tree.indent(c).saturating_sub(vault.tree.indent(r));
106− let clevel = match cn.kind {
107− Kind::Section => dlevel + 1,
108− _ => dlevel,
109− };
110− let target = vault.tree.resolved_child(c);
111− if target != c {
112− build_node(vault, target, clevel, cindent, lines, refs, seen);
113− } else if cn.is_embed() {
114− lines.push(format!(
115− "{}![[{}]]",
116− " ".repeat(cindent),
117− cn.embed.as_ref().unwrap()
118− ));
119− refs.push(LineRef::Embed(c));
120− } else {
121− build_node(vault, c, clevel, cindent, lines, refs, seen);
122− }
123− }
124− seen.pop();
125−}
126−
127−fn checkbox(task: Option<TaskState>, out: &mut String) {
128− match task {
129− Some(TaskState::Open) => out.push_str("[ ] "),
130− Some(TaskState::Done) => out.push_str("[x] "),
131− None => {}
132− }
133−}
134−
135−fn dedent(line: &str, cols: usize) -> &str {
136− if cols == 0 {
137− return line;
138− }
139− let mut removed = 0;
140− for (i, ch) in line.char_indices() {
141− if removed >= cols {
142− return &line[i..];
143− }
144− match ch {
145− ' ' => removed += 1,
146− '\t' => removed += 4,
147− _ => return &line[i..],
148− }
149− }
150− ""
35+ ReadingDoc { lines, refs }
15136}
15237
15338/// The nearest node at-or-above a line (blank lines belong to whatever is
crates/fold-core/src/render.rs+191−214
@@ -1,8 +1,39 @@
11//! `render(node, base, resolve_blocks) → String` (§5.1).
2+//!
3+//! One walker produces the lines; `render`, the reading pane (§10.4) and the
4+//! editing buffer (§5.2) all consume it, so what the user reads, what they
5+//! edit and what splice writes back are the same text.
26
3−use crate::parse::{Kind, TaskState};
7+use crate::parse::{Kind, Span, TaskState};
48use crate::tree::{NRef, Tree};
59
10+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11+pub enum LineKind {
12+ Title,
13+ Body,
14+ /// An embed kept as `![[id]]` (unresolved render, or a broken embed).
15+ Embed,
16+ Blank,
17+}
18+
19+/// One rendered line and where it came from.
20+#[derive(Debug, Clone)]
21+pub struct RLine {
22+ pub text: String,
23+ pub kind: LineKind,
24+ /// The node whose title, body or embed this line is.
25+ pub node: NRef,
26+ /// The block that owns the line: the nearest block at or above it in the
27+ /// resolved walk, or the render root when there is none (§5.2).
28+ pub owner: NRef,
29+ /// The owner in effect where `owner` was entered (itself for the
30+ /// render root's owner).
31+ pub outer: NRef,
32+ /// Display level and indent of `node`.
33+ pub level: usize,
34+ pub indent: usize,
35+}
36+
637/// Render the node and its subtree as a standalone Markdown document.
738///
839/// With `resolve_blocks = false` this is the on-disk spelling (embeds kept);
@@ -22,21 +53,10 @@ pub fn render(tree: &Tree, node: NRef, base: usize, resolve_blocks: bool) -> Str
2253 }
2354 }
2455 }
25− // The render root prints at level `base`, indent 0; descendants follow
26− // their position relative to it.
27− let base_level = tree.level(node).min(base);
28− let base_indent = tree.indent(node);
29− render_at(
30− tree,
31− node,
32− base,
33− 0,
34− base_level,
35− base_indent,
36− resolve_blocks,
37− &mut out,
38− &mut Vec::new(),
39− );
56+ for l in render_lines(tree, node, base, resolve_blocks) {
57+ out.push_str(&l.text);
58+ out.push('\n');
59+ }
4060 // canonical: exactly one trailing newline
4161 while out.ends_with("\n\n") {
4262 out.pop();
@@ -47,208 +67,179 @@ pub fn render(tree: &Tree, node: NRef, base: usize, resolve_blocks: bool) -> Str
4767 out
4868}
4969
50−/// Render node `r` at display position (`dlevel`, `dindent`), then its body
51−/// and children. `base_level`/`base_indent` are the tree position of the
52−/// render root, used to compute relative positions of descendants.
53−#[allow(clippy::too_many_arguments)]
54−fn render_at(
55− tree: &Tree,
56− r: NRef,
57− dlevel: usize,
58− dindent: usize,
59− base_level: usize,
60− base_indent: usize,
61− resolve: bool,
62− out: &mut String,
63− seen: &mut Vec<NRef>,
64−) {
65− if seen.contains(&r) {
66− return; // cycle guard
67− }
68− seen.push(r);
69− let n = tree.node(r);
70− let is_block_root = n.is_block();
71− match n.kind {
72− Kind::Root => {}
73− Kind::Section => {
74− push_indent(out, dindent);
75− for _ in 0..dlevel.max(1) {
76− out.push('#');
77− }
78− out.push(' ');
79− // A block's task state is frontmatter-only (§4.9): no checkbox
80− // on its own title line.
81− if !is_block_root {
82− push_checkbox(n.task, out);
83− }
84− out.push_str(n.title.trim_end());
85− out.push('\n');
86− }
87− Kind::Item => {
88− push_indent(out, dindent);
89− out.push_str("- ");
90− if !is_block_root {
91− push_checkbox(n.task, out);
92− }
93− out.push_str(n.title.trim_end());
94− out.push('\n');
95− }
96− }
97− // Body, verbatim, dedented by the node's original indent (§5.1.3) and
98− // re-indented to the display position. For a block root rendered
99− // unresolved the body's leading blank line is kept (it is the file's
100− // separator after the frontmatter/title); otherwise bodies are separated
101− // from the title by one canonical blank line.
102− let keep_file_spacing = is_block_root && !resolve;
103− let body = if keep_file_spacing {
104− // verbatim: the body region as found, minus trailing blank lines
105− let mut lines = tree.node(r).body_lines(tree.text_of(r));
106− while lines.last().map(|l| l.trim().is_empty()) == Some(true) {
107− lines.pop();
108− }
109− lines
70+/// The lines of `render(node, base, resolve_blocks)` (without frontmatter
71+/// and trailing blank lines), each tagged with its node and owning block.
72+///
73+/// Bodies are verbatim, blank lines included, so the file's own spacing is
74+/// reproduced. Sections are re-levelled by `base − level(node)` and
75+/// everything re-indented by `−indent(node)` (§5.1.4); the implicit Root
76+/// renders its top-level nodes at `base`.
77+pub fn render_lines(tree: &Tree, node: NRef, base: usize, resolve_blocks: bool) -> Vec<RLine> {
78+ let dlevel = if tree.node(node).kind == Kind::Root {
79+ base.saturating_sub(1)
11080 } else {
111− trimmed_body_keep(tree, r, false)
81+ base
11282 };
113− let has_trailing_sep = body_sep_blank(tree, r);
114− if !body.is_empty() && n.kind != Kind::Root && !is_block_root {
115− out.push('\n');
116− }
117− let body_dedent = tree.indent(r);
118− for l in &body {
119− if keep_file_spacing {
120− // verbatim: the file's own spacing is reproduced exactly
121− out.push_str(dedent_keep_blanks(l, body_dedent));
122− } else {
123− push_indent(out, dindent);
124− out.push_str(dedent(l, body_dedent));
125− }
126− out.push('\n');
83+ let mut out = Vec::new();
84+ let mut w = Walk {
85+ tree,
86+ resolve: resolve_blocks,
87+ out: &mut out,
88+ seen: Vec::new(),
89+ };
90+ w.node(node, dlevel, 0, node, node);
91+ while out.last().map(|l| l.kind == LineKind::Blank) == Some(true) {
92+ out.pop();
12793 }
128− // Children in document order.
129− let children = tree.resolved_children(r);
130− let all_item_children = !children.is_empty()
131− && children
132− .iter()
133− .all(|&k| tree.node(k).kind == Kind::Item);
134− let blank_rule = if keep_file_spacing { has_trailing_sep } else { !all_item_children };
135− let mut prev: Option<NRef> = None;
136− for c in children {
137− let cn = tree.node(c);
138− if cn.kind == Kind::Root {
139− continue;
140− }
141− let is_first = prev.is_none();
142− let prev_was_section = prev
143− .map(|p| tree.node(p).kind == Kind::Section)
144− .unwrap_or(false);
145− if is_first {
146− if !body.is_empty() || has_trailing_sep || (n.kind != Kind::Root && blank_rule) {
147− out.push('\n');
148− }
149− } else if cn.kind == Kind::Section || prev_was_section {
150− out.push('\n');
94+ out
95+}
96+
97+struct Walk<'a> {
98+ tree: &'a Tree,
99+ resolve: bool,
100+ out: &'a mut Vec<RLine>,
101+ seen: Vec<NRef>,
102+}
103+
104+impl Walk<'_> {
105+ /// Emit node `r` at display position (`dlevel`, `dindent`): title line,
106+ /// body, children in document order with any tail text between them.
107+ fn node(&mut self, r: NRef, dlevel: usize, dindent: usize, owner: NRef, outer: NRef) {
108+ if self.seen.contains(&r) {
109+ return; // cycle guard
151110 }
152− // Display position of the child: relative to this node. Written
153− // levels are honoured (§3.1): a section's display level never moves
154− // down from what is written in the file.
155− let cindent = dindent + tree.indent(c).saturating_sub(tree.indent(r));
156− let clevel = match cn.kind {
111+ self.seen.push(r);
112+ let tree = self.tree;
113+ let n = tree.node(r);
114+ let (owner, outer) = if n.is_block() && r != owner {
115+ (r, owner)
116+ } else {
117+ (owner, outer)
118+ };
119+ let title = match n.kind {
120+ Kind::Root => None,
121+ _ if n.is_embed() => Some((
122+ format!("{}![[{}]]", " ".repeat(dindent), n.embed.as_ref().unwrap()),
123+ LineKind::Embed,
124+ )),
157125 Kind::Section => {
158− let rel = dlevel + section_steps(tree, r, c);
159− if resolve {
160− rel // zoom-out: re-level by base − level(root) (§5.1.4)
161− } else {
162− rel.max(tree.level(c)) // on-disk spelling: written levels honoured
126+ let mut s = " ".repeat(dindent);
127+ s.push_str(&"#".repeat(dlevel.max(1)));
128+ s.push(' ');
129+ // A block's task state is frontmatter-only (§4.9): no
130+ // checkbox on its own title line.
131+ if !n.is_block() {
132+ push_checkbox(n.task, &mut s);
133+ }
134+ s.push_str(&n.title);
135+ Some((s.trim_end().to_string(), LineKind::Title))
136+ }
137+ Kind::Item => {
138+ let mut s = " ".repeat(dindent);
139+ s.push_str("- ");
140+ if !n.is_block() {
141+ push_checkbox(n.task, &mut s);
163142 }
143+ s.push_str(&n.title);
144+ Some((s.trim_end().to_string(), LineKind::Title))
164145 }
165− _ => dlevel,
166146 };
167− if cn.is_embed() {
168− let id = cn.embed.clone().unwrap();
169− let target = if resolve { tree.resolved_child(c) } else { c };
170− if target == c {
171− push_indent(out, cindent);
172− out.push_str("![[");
173− out.push_str(id.as_str());
174− out.push_str("]]\n");
175− } else {
176− render_at(
177− tree, target, clevel, cindent, base_level, base_indent, resolve, out, seen,
178− );
147+ if let Some((text, kind)) = title {
148+ self.push(text, kind, r, owner, outer, dlevel, dindent);
149+ }
150+ self.span_lines(r, n.body_span, dlevel, dindent, owner, outer);
151+ for (i, &c) in n.children.iter().enumerate() {
152+ self.child(r, (r.0, c), dlevel, dindent, owner, outer);
153+ for (k, sp) in &n.tail {
154+ if *k == i + 1 {
155+ self.span_lines(r, *sp, dlevel, dindent, owner, outer);
156+ }
179157 }
180− prev = Some(c);
181− continue;
182158 }
183− render_at(
184− tree, c, clevel, cindent, base_level, base_indent, resolve, out, seen,
185− );
186− prev = Some(c);
159+ self.seen.pop();
187160 }
188− seen.pop();
189−}
190161
191−/// Section steps between a node and a descendant (the number of section
192−/// boundaries crossed, counting the descendant itself if it is one).
193−fn section_steps(tree: &Tree, anc: NRef, desc: NRef) -> usize {
194− let mut n = 0usize;
195− let mut cur = Some(desc);
196− while let Some(c) = cur {
197− if c == anc {
198− break;
199− }
200− if tree.node(c).kind == Kind::Section {
201− n += 1;
162+ fn child(&mut self, r: NRef, c: NRef, dlevel: usize, dindent: usize, owner: NRef, outer: NRef) {
163+ let tree = self.tree;
164+ let cn = tree.node(c);
165+ // Display position relative to the parent, from positions in the
166+ // parent's file (an embed's target sits at the embed's position).
167+ let cindent = dindent + tree.indent(c).saturating_sub(tree.indent(r));
168+ if self.resolve && cn.is_embed() {
169+ let t = tree.resolved_child(c);
170+ if t != c {
171+ // A section at this position is one below the parent; an
172+ // item shares its enclosing section's level.
173+ let tlevel = if tree.node(t).kind == Kind::Section
174+ || tree.node(r).kind == Kind::Root
175+ {
176+ dlevel + 1
177+ } else {
178+ dlevel
179+ };
180+ self.node(t, tlevel, cindent, owner, outer);
181+ // the embed line's own blank separators (and any lines
182+ // wrongly indented under it) stay in the parent
183+ self.span_lines(c, cn.body_span, dlevel, cindent, owner, outer);
184+ for &cc in &cn.children {
185+ self.child(c, (c.0, cc), dlevel, cindent, owner, outer);
186+ }
187+ return;
188+ }
202189 }
203− cur = tree.node(c).parent.map(|p| (c.0, p));
204− }
205− n.max(1)
206−}
207−
208−/// True if the node's body region ends with (or consists of) a blank line —
209−/// i.e. the file had a blank separating the body (or title) from the first
210−/// child.
211−fn body_sep_blank(tree: &Tree, r: NRef) -> bool {
212− let n = tree.node(r);
213− if n.body_span.end <= n.body_span.start {
214− return false;
190+ let clevel = (dlevel as isize + tree.level(c) as isize - tree.level(r) as isize).max(0);
191+ self.node(c, clevel as usize, cindent, owner, outer);
215192 }
216− let region = &tree.text_of(r)[n.body_span.start..n.body_span.end];
217− region
218− .split_inclusive('\n')
219− .map(|l| l.strip_suffix('\n').unwrap_or(l))
220− .last()
221− .map(|l| l.trim().is_empty())
222− .unwrap_or(false)
223−}
224193
225−fn trimmed_body_keep<'a>(tree: &'a Tree, r: NRef, keep_leading: bool) -> Vec<&'a str> { let mut lines = tree.node(r).body_lines(tree.text_of(r));
226− if !keep_leading {
227− while lines.first().map(|l| l.trim().is_empty()) == Some(true) {
228− lines.remove(0);
194+ /// Body lines of a span, verbatim: dedented by the node's written indent
195+ /// (§5.1.3) and re-indented to its display indent. Blank lines stay empty.
196+ fn span_lines(
197+ &mut self,
198+ r: NRef,
199+ span: Span,
200+ dlevel: usize,
201+ dindent: usize,
202+ owner: NRef,
203+ outer: NRef,
204+ ) {
205+ if span.start >= span.end {
206+ return;
229207 }
230− } else {
231− // keep the file's own separator before the body: a leading blank
232− // line, or — for an item-rooted block — the 2-space body indent,
233− // which reads as a blank after the title in a standalone file.
234− if lines.len() > 1 && lines[0].trim().is_empty() {
235− while lines.len() > 1 && lines[0].trim().is_empty() && lines[1].trim().is_empty() {
236− lines.remove(0);
208+ let tree = self.tree;
209+ let cols = tree.node(r).indent;
210+ for l in tree.text_of(r)[span.start..span.end].split_inclusive('\n') {
211+ let l = l.strip_suffix('\n').unwrap_or(l);
212+ let l = l.strip_suffix('\r').unwrap_or(l);
213+ if l.trim().is_empty() {
214+ self.push(String::new(), LineKind::Blank, r, owner, outer, dlevel, dindent);
215+ } else {
216+ let text = format!("{}{}", " ".repeat(dindent), dedent(l, cols));
217+ self.push(text, LineKind::Body, r, owner, outer, dlevel, dindent);
237218 }
238− } else if !lines.is_empty()
239− && lines[0].starts_with(" ")
240− && !lines[0].trim_start().starts_with("- ")
241− && !lines[0].trim_start().starts_with('#')
242− && lines[0].trim_start().len() == lines[0].trim().len()
243− && !lines[0].trim().is_empty()
244− {
245− lines.insert(0, "");
246219 }
247220 }
248− while lines.last().map(|l| l.trim().is_empty()) == Some(true) {
249− lines.pop();
221+
222+ #[allow(clippy::too_many_arguments)]
223+ fn push(
224+ &mut self,
225+ text: String,
226+ kind: LineKind,
227+ node: NRef,
228+ owner: NRef,
229+ outer: NRef,
230+ level: usize,
231+ indent: usize,
232+ ) {
233+ self.out.push(RLine {
234+ text,
235+ kind,
236+ node,
237+ owner,
238+ outer,
239+ level,
240+ indent,
241+ });
250242 }
251− lines
252243}
253244
254245fn push_checkbox(task: Option<TaskState>, out: &mut String) {
@@ -259,13 +250,8 @@ fn push_checkbox(task: Option<TaskState>, out: &mut String) {
259250 }
260251}
261252
262−fn push_indent(out: &mut String, cols: usize) {
263− for _ in 0..cols {
264− out.push(' ');
265− }
266−}
267−
268−fn dedent(line: &str, cols: usize) -> &str {
253+/// Remove up to `cols` columns of leading indentation (a tab counts four).
254+pub fn dedent(line: &str, cols: usize) -> &str {
269255 if cols == 0 {
270256 return line;
271257 }
@@ -282,12 +268,3 @@ fn dedent(line: &str, cols: usize) -> &str {
282268 }
283269 ""
284270}
285−
286−/// Dedent only lines that carry content; blank lines stay empty.
287−fn dedent_keep_blanks(line: &str, cols: usize) -> &str {
288− if line.trim().is_empty() {
289− ""
290− } else {
291− dedent(line, cols)
292− }
293−}
crates/fold-core/src/tree.rs+1
@@ -203,6 +203,7 @@ impl Node {
203203 let mut lines: Vec<&'a str> = file_text[self.body_span.start..self.body_span.end]
204204 .split_inclusive('\n')
205205 .map(|l| l.strip_suffix('\n').unwrap_or(l))
206+ .map(|l| l.strip_suffix('\r').unwrap_or(l))
206207 .collect();
207208 // A blank line produced only by the separator before a child node is
208209 // not body content, but it is remembered: the renderer uses it to
crates/fold-core/src/vault.rs+81−18
@@ -73,14 +73,16 @@ impl Vault {
7373 let mut entries: Vec<String> = std::fs::read_dir(&self.dir)?
7474 .filter_map(|e| e.ok())
7575 .map(|e| e.file_name().to_string_lossy().to_string())
76− .filter(|n| n.ends_with(".md") && n != "root.md")
76+ .filter(|n| n.ends_with(".md") && n != "root.md" && !n.starts_with('.'))
7777 .collect();
7878 entries.sort();
7979 for name in entries {
8080 if name.contains(".sync-conflict-") {
8181 continue; // merge engine only (§11.4)
8282 }
83− let text = match read_if_exists(&self.dir.join(&name))? {
83+ // anything unreadable — a directory named `x.md`, non-UTF-8
84+ // text — is simply ignored (§4.1), never fatal
85+ let text = match read_md_file(&self.dir.join(&name)) {
8486 Some(t) => t,
8587 None => continue,
8688 };
@@ -176,7 +178,13 @@ impl Vault {
176178 }
177179 let known = self.tree.files.iter().any(|f| f.path == name);
178180 if !known {
179− let text = read_if_exists(&e.path())?.unwrap_or_default();
181+ let Some(text) = read_md_file(&e.path()) else {
182+ out.push(IgnoredFile {
183+ path: name,
184+ reason: "not a readable UTF-8 file".into(),
185+ });
186+ continue;
187+ };
180188 let reason = match parse_frontmatter(&text).and_then(|f| f.props.get("id").cloned())
181189 {
182190 Some(v) if Id::parse(&v).is_none() => {
@@ -203,10 +211,13 @@ impl Vault {
203211 /// Replace a byte span in a file atomically (§11.1), then re-parse.
204212 pub fn write_span(&mut self, file: usize, span: Span, replacement: &str) -> std::io::Result<()> {
205213 let f = &self.tree.files[file];
214+ // a node's span may run one byte past a file without a final newline
215+ let end = span.end.min(f.text.len());
216+ let start = span.start.min(end);
206217 let mut new_text = String::with_capacity(f.text.len() + replacement.len());
207− new_text.push_str(&f.text[..span.start]);
218+ new_text.push_str(&f.text[..start]);
208219 new_text.push_str(replacement);
209− new_text.push_str(&f.text[span.end..]);
220+ new_text.push_str(&f.text[end..]);
210221 self.write_file_text(file, &new_text)
211222 }
212223
@@ -315,14 +326,25 @@ impl Vault {
315326
316327 /// Shortest prefix of `id` not already used by another file (§6.4).
317328 pub fn unique_prefix(&self, id: &Id) -> String {
318− let taken: Vec<String> = self
319− .tree
329+ self.unique_prefix_except(id, None)
330+ }
331+
332+ /// Like `unique_prefix`, but ignoring the file at `own` — a block's own
333+ /// file never counts against it.
334+ pub fn unique_prefix_except(&self, id: &Id, own: Option<&str>) -> String {
335+ let taken = self.taken_prefixes(own);
336+ id.shortest_prefix(&|cand| taken.iter().any(|t| t == cand))
337+ }
338+
339+ /// Prefixes used by block files other than `own` (§6.4).
340+ pub fn taken_prefixes(&self, own: Option<&str>) -> Vec<String> {
341+ self.tree
320342 .files
321343 .iter()
322344 .skip(1)
345+ .filter(|f| Some(f.path.as_str()) != own)
323346 .filter_map(|f| split_filename(&f.path).map(|(p, _)| p.to_string()))
324− .collect();
325− id.shortest_prefix(&|cand| taken.iter().any(|t| t == cand))
347+ .collect()
326348 }
327349
328350 /// Rename a block's file after a title change (§6.4).
@@ -336,7 +358,7 @@ impl Vault {
336358 };
337359 let prefix = split_filename(&old_path)
338360 .map(|(p, _)| p.to_string())
339− .unwrap_or_else(|| self.unique_prefix(&id));
361+ .unwrap_or_else(|| self.unique_prefix_except(&id, Some(&old_path)));
340362 let new_name = filename(&prefix, &slug(new_title));
341363 if new_name == old_path {
342364 return Ok(());
@@ -351,9 +373,8 @@ impl Vault {
351373 let path = self.tree.files[file].path.clone();
352374 let trash = trash_dir();
353375 std::fs::create_dir_all(&trash)?;
354− let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S").to_string();
355− let target = trash.join(format!("{}-{}", stamp, path.to_lowercase()));
356− std::fs::rename(self.dir.join(&path), &target)?;
376+ let target = trash_target(&trash, &path);
377+ move_file(&self.dir.join(&path), &target)?;
357378 self.reload()?;
358379 Ok(())
359380 }
@@ -362,8 +383,7 @@ impl Vault {
362383 pub fn trash_text(&self, name: &str, text: &str) -> std::io::Result<PathBuf> {
363384 let trash = trash_dir();
364385 std::fs::create_dir_all(&trash)?;
365− let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S").to_string();
366− let target = trash.join(format!("{}-{}", stamp, name.to_lowercase()));
386+ let target = trash_target(&trash, name);
367387 atomic_write(&target, text)?;
368388 Ok(target)
369389 }
@@ -381,6 +401,22 @@ impl Vault {
381401 }
382402}
383403
404+/// A fresh trash path `<timestamp>-<name>`; a second entry in the same
405+/// second gets a numeric suffix instead of overwriting the first (§11.5).
406+fn trash_target(trash: &Path, name: &str) -> PathBuf {
407+ let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S").to_string();
408+ let name = name.to_lowercase();
409+ let first = trash.join(format!("{}-{}", stamp, name));
410+ if !first.exists() {
411+ return first;
412+ }
413+ let stem = name.strip_suffix(".md").unwrap_or(&name);
414+ (2..)
415+ .map(|i| trash.join(format!("{}-{}-{}.md", stamp, stem, i)))
416+ .find(|p| !p.exists())
417+ .unwrap()
418+}
419+
384420pub fn trash_dir() -> PathBuf {
385421 directories::ProjectDirs::from("", "", "fold")
386422 .map(|p| p.state_dir().unwrap_or(p.data_dir()).join("trash"))
@@ -402,6 +438,27 @@ pub fn atomic_write(path: &Path, text: &str) -> std::io::Result<()> {
402438 Ok(())
403439}
404440
441+/// Rename, falling back to copy-and-remove when the trash and the vault
442+/// are on different filesystems.
443+pub fn move_file(from: &Path, to: &Path) -> std::io::Result<()> {
444+ match std::fs::rename(from, to) {
445+ Ok(()) => Ok(()),
446+ Err(_) => {
447+ std::fs::copy(from, to)?;
448+ std::fs::remove_file(from)
449+ }
450+ }
451+}
452+
453+/// Read a vault `.md` file as text; `None` for anything that is not a
454+/// readable UTF-8 regular file.
455+fn read_md_file(path: &Path) -> Option<String> {
456+ if !std::fs::metadata(path).map(|m| m.is_file()).unwrap_or(false) {
457+ return None;
458+ }
459+ std::fs::read_to_string(path).ok()
460+}
461+
405462fn read_if_exists(path: &Path) -> std::io::Result<Option<String>> {
406463 match std::fs::read_to_string(path) {
407464 Ok(t) => Ok(Some(t)),
@@ -455,7 +512,7 @@ impl Vault {
455512 let mut cur = self.tree.root;
456513 'seg: for seg in segs {
457514 for c in self.tree.resolved_children(cur) {
458− if self.tree.node(c).title.eq_ignore_ascii_case(seg) {
515+ if title_eq(&self.tree.node(c).title, seg) {
459516 cur = c;
460517 continue 'seg;
461518 }
@@ -484,7 +541,8 @@ impl Vault {
484541 .collect();
485542 match matches.len() {
486543 1 => return Ok(matches[0]),
487− 0 => return Err(format!("no block with id prefix {:?}", text)),
544+ // a word that merely looks like an id may still be a title
545+ 0 => {}
488546 _ => return Err(format!("id prefix {:?} is ambiguous", text)),
489547 }
490548 }
@@ -505,7 +563,7 @@ impl Vault {
505563 // 3. unique title
506564 let mut found: Vec<NRef> = Vec::new();
507565 self.tree.walk(self.tree.root, &mut |t, r| {
508− if t.node(r).title.eq_ignore_ascii_case(text) {
566+ if title_eq(&t.node(r).title, text) {
509567 found.push(r);
510568 }
511569 });
@@ -524,6 +582,11 @@ impl Vault {
524582 }
525583}
526584
585+/// Case-insensitive title comparison, Unicode-aware (§3.4).
586+pub fn title_eq(a: &str, b: &str) -> bool {
587+ a == b || a.to_lowercase() == b.to_lowercase()
588+}
589+
527590/// Map from id string to NRef, for embed resolution in the TUI.
528591pub fn id_map(tree: &Tree) -> HashMap<String, NRef> {
529592 tree.blocks
crates/fold-core/tests/edit.rs+134−1
@@ -88,7 +88,7 @@ fn edit_nested_block_writes_its_own_file() {
8888 .iter()
8989 .position(|l| l.text.contains("task"))
9090 .unwrap();
91− let owner = buf.lines[idx].owner;
91+ let _owner = buf.lines[idx].owner;
9292 buf.set_line(idx, "- renamed task".into());
9393 buf.save_all(&mut v).unwrap();
9494 // root.md untouched
@@ -127,3 +127,136 @@ fn title_detection() {
127127 assert_eq!(title_of_first_line("- [ ] Task"), Some("[ ] Task".into()));
128128 assert_eq!(title_of_first_line("plain"), None);
129129}
130+
131+// ---------------------------------------------------------------- regressions
132+
133+/// Open the editor on `r`, mark every owner dirty and save: nothing on disk
134+/// may change (§15.6).
135+fn assert_noop_splice(v: &mut Vault, r: fold_core::tree::NRef) {
136+ let before: Vec<String> = v.tree.files.iter().map(|f| f.text.clone()).collect();
137+ let mut buf = open_editor(v, r);
138+ for o in buf.owners.keys().copied().collect::<Vec<_>>() {
139+ buf.mark_dirty(o);
140+ }
141+ buf.save_all(v).unwrap();
142+ let after: Vec<String> = v.tree.files.iter().map(|f| f.text.clone()).collect();
143+ assert_eq!(after, before);
144+}
145+
146+#[test]
147+fn noop_splice_of_zoomed_nodes() {
148+ for (text, path) in [
149+ ("# A\n\n## B\n\nbody\n", vec!["A", "B"]),
150+ ("- A\n - B\n - C\n", vec!["A", "B"]),
151+ ("- A\n - B\n", vec!["A"]),
152+ ("# A\n\nbody\n\n# B\n", vec!["A"]),
153+ ("# Inbox\n\n## day\n\n- Talked\n ### Options\n Warehouse.\n- [x] Send\n", vec!["Inbox", "day", "Talked"]),
154+ ] {
155+ let (_d, mut v) = vault_with(text);
156+ let segs: Vec<String> = path.iter().map(|s| s.to_string()).collect();
157+ let r = v.find_by_path(&segs).unwrap();
158+ assert_noop_splice(&mut v, r);
159+ let root = v.tree.root;
160+ assert_noop_splice(&mut v, root);
161+ }
162+}
163+
164+#[test]
165+fn noop_splice_with_root_frontmatter() {
166+ let (_d, mut v) = vault_with("---\nfoo: bar\n---\n\n# A\n\nbody\n");
167+ let root = v.tree.root;
168+ assert_noop_splice(&mut v, root);
169+}
170+
171+#[test]
172+fn noop_splice_across_nested_blocks() {
173+ let (_d, mut v) = vault_with("# A\n\n## Z\n\nbody\n\n### sub\n\n- t\n");
174+ let z = v.find_by_path(&["A".into(), "Z".into()]).unwrap();
175+ ops::make_block(&mut v, z).unwrap();
176+ let t = v.find_by_path(&["A".into(), "Z".into(), "sub".into(), "t".into()]).unwrap();
177+ ops::make_block(&mut v, t).unwrap();
178+ let block = v.tree.files.iter().find(|f| f.path.contains("~z")).unwrap();
179+ // made from a level-2 section: re-levelled to the file's level 1 (§4.9)
180+ assert!(block.text.contains("\n# Z\n\nbody\n\n## sub\n\n![["), "{}", block.text);
181+ let a = v.tree.resolved_children(v.tree.root)[0];
182+ assert_noop_splice(&mut v, a);
183+ let root = v.tree.root;
184+ assert_noop_splice(&mut v, root);
185+ let z = v.find_by_path(&["A".into(), "Z".into()]).unwrap();
186+ assert_noop_splice(&mut v, z);
187+}
188+
189+#[test]
190+fn splicing_a_parent_keeps_nested_embeds() {
191+ let (_d, mut v) = vault_with("# NAS\n\n- task\n");
192+ let task = v.find_by_path(&["NAS".into(), "task".into()]).unwrap();
193+ ops::make_block(&mut v, task).unwrap();
194+ let nas = v.tree.resolved_children(v.tree.root)[0];
195+ let mut buf = open_editor(&v, nas);
196+ let i = buf.lines.iter().position(|l| l.text == "# NAS").unwrap();
197+ buf.set_line(i, "# NAS box".into());
198+ buf.save_all(&mut v).unwrap();
199+ let root = &v.tree.files[0].text;
200+ assert!(root.starts_with("# NAS box\n\n![["), "{}", root);
201+ assert_eq!(v.tree.resolved_children(v.tree.resolved_children(v.tree.root)[0]).len(), 1);
202+}
203+
204+#[test]
205+fn nested_block_embed_under_item_keeps_indent() {
206+ let (_d, mut v) = vault_with("- A\n - B\n");
207+ let b = v.find_by_path(&["A".into(), "B".into()]).unwrap();
208+ ops::make_block(&mut v, b).unwrap();
209+ assert!(v.tree.files[0].text.starts_with("- A\n ![["), "{}", v.tree.files[0].text);
210+ let a = v.tree.resolved_children(v.tree.root)[0];
211+ let buf = open_editor(&v, a);
212+ let texts: Vec<&str> = buf.lines.iter().map(|l| l.text.as_str()).collect();
213+ assert_eq!(texts, vec!["- A", " - B"]);
214+ assert_noop_splice(&mut v, a);
215+}
216+
217+#[test]
218+fn splice_refuses_to_overwrite_external_change() {
219+ let (d, mut v) = vault_with("# A\n\nbody\n");
220+ let a = v.tree.resolved_children(v.tree.root)[0];
221+ let mut buf = open_editor(&v, a);
222+ std::fs::write(d.path().join("root.md"), "# A\n\nsomeone else\n").unwrap();
223+ let i = buf.lines.iter().position(|l| l.text == "body").unwrap();
224+ buf.set_line(i, "mine".into());
225+ assert!(buf.save_all(&mut v).is_err());
226+ let disk = std::fs::read_to_string(d.path().join("root.md")).unwrap();
227+ assert_eq!(disk, "# A\n\nsomeone else\n");
228+}
229+
230+#[test]
231+fn successive_splices_of_one_file_are_allowed() {
232+ let (_d, mut v) = vault_with("# A\n\nbody\n");
233+ let a = v.tree.resolved_children(v.tree.root)[0];
234+ let mut buf = open_editor(&v, a);
235+ let i = buf.lines.iter().position(|l| l.text == "body").unwrap();
236+ buf.set_line(i, "one".into());
237+ buf.save_all(&mut v).unwrap();
238+ buf.set_line(i, "two".into());
239+ buf.save_all(&mut v).unwrap();
240+ assert_eq!(v.tree.files[0].text, "# A\n\ntwo\n");
241+}
242+
243+#[test]
244+fn noop_splice_of_spec_example_vault() {
245+ // §4.10, all three files: editing from the root and saving every block
246+ // changes nothing.
247+ let dir = tempfile::tempdir().unwrap();
248+ std::fs::write(dir.path().join("root.md"), "# Homelab\n\nTwo boxes in the closet, one at Hetzner.\n\n## NAS\n\n![[dozzod-binwes-talsun-worbec]]\n\n## Networking\n\n- [ ] Replace the flaky switch\n![[racfer-hattes-mislup-nodrys]]\n\n# Inbox\n\n## 2026-09-10\n\n- Talked to Anya about the venue.\n ### Options\n Warehouse on Ligovsky, or the old bakery. Both need a licence.\n- [x] Send the deposit\n").unwrap();
249+ std::fs::write(dir.path().join("dozzod~zfs-layout.md"), "---\nid: dozzod-binwes-talsun-worbec\nsince: 2024-03\ntags: [storage, homelab] # user-defined\n---\n\n# ZFS layout\n\nMirrored pairs, no raidz. Snapshots hourly via sanoid.\n\n## [ ] Snapshot policy\n\n- hourly, keep 24\n- [x] Move scratch to its own dataset\n").unwrap();
250+ std::fs::write(dir.path().join("racfer~order-new-switch.md"), "---\nid: racfer-hattes-mislup-nodrys\ntodo: open\ndue: 2026-09-20\n---\n\n- Order new switch\n Two options, noted under Networking.\n").unwrap();
251+ let mut v = Vault::open(dir.path()).unwrap();
252+ let root = v.tree.root;
253+ let buf = open_editor(&v, root);
254+ let texts: Vec<&str> = buf.lines.iter().map(|l| l.text.as_str()).collect();
255+ assert!(texts.contains(&"### ZFS layout"), "{:#?}", texts);
256+ assert!(texts.contains(&"#### [ ] Snapshot policy"), "{:#?}", texts);
257+ assert!(texts.contains(&"- Order new switch"), "{:#?}", texts);
258+ assert!(!texts.iter().any(|t| t.contains("![[")), "{:#?}", texts);
259+ assert_noop_splice(&mut v, root);
260+ let homelab = v.tree.resolved_children(v.tree.root)[0];
261+ assert_noop_splice(&mut v, homelab);
262+}
crates/fold-core/tests/merge_check.rs+157
@@ -154,3 +154,160 @@ fn check_reports_bad_dates() {
154154 let diags = fold_core::check::check(&v);
155155 assert!(diags.iter().any(|d| d.message.contains("not an ISO date")), "{:?}", diags.iter().map(|d| &d.message).collect::<Vec<_>>());
156156}
157+
158+// ---------------------------------------------------------------- regressions
159+
160+const BID: &str = "racfer-hattes-mislup-nodrys";
161+
162+#[test]
163+fn merge_of_identical_texts_is_byte_identical() {
164+ // §15.6: merge(O, O) == O, loose lists and all
165+ for o in [
166+ "# A\n\n- a\n\n- b\n",
167+ "---\nvault: x\n---\n\nIntro.\n\n# A\n\nbody\n\n## B\n\n- one\n - two\n",
168+ "# A\n\n- [ ] t\n\n# B\n",
169+ ] {
170+ let out = merge::merge_texts(o, o, "d", "ts");
171+ assert_eq!(out.conflicts, 0);
172+ assert_eq!(out.text, o);
173+ }
174+}
175+
176+#[test]
177+fn merge_block_file_keeps_frontmatter_out_of_the_tree() {
178+ let o = format!("---\nid: {}\ndue: 2026-09-20\n---\n\n- t\n", BID);
179+ let t = format!("---\nid: {}\ndue: 2026-09-20\n---\n\n- t\n extra line\n", BID);
180+ let out = merge::merge_texts(&o, &t, "d", "ts");
181+ assert_eq!(out.conflicts, 1);
182+ assert!(!out.text.contains("# due"), "{}", out.text);
183+ assert!(out.text.starts_with(&format!("---\nid: {}\ndue: 2026-09-20\n---\n\n- t\n", BID)), "{}", out.text);
184+ // one column-0 node: the conflict embed is not placed inside the block file
185+ assert!(!out.text.contains("![["), "{}", out.text);
186+ assert_eq!(out.sibling_embeds.len(), 1);
187+}
188+
189+#[test]
190+fn merge_block_frontmatter_difference_raises_conflict_with_their_props() {
191+ let o = format!("---\nid: {}\ndue: 2026-09-20\n---\n\n- t\n", BID);
192+ let t = format!("---\nid: {}\ndue: 2026-10-01\n---\n\n- t\n", BID);
193+ let out = merge::merge_texts(&o, &t, "phone", "ts");
194+ assert_eq!(out.conflicts, 1);
195+ assert_eq!(out.conflict_blocks.len(), 1);
196+ let cb = &out.conflict_blocks[0].1;
197+ assert!(cb.contains("due: 2026-10-01"), "{}", cb);
198+ assert!(cb.contains("conflict: \"phone ts\""), "{}", cb);
199+ assert!(!cb.contains(BID), "{}", cb);
200+ assert!(out.text.contains("due: 2026-09-20"), "{}", out.text);
201+}
202+
203+#[test]
204+fn sync_conflict_on_block_places_embed_after_its_embed() {
205+ let dir = tempfile::tempdir().unwrap();
206+ std::fs::write(dir.path().join("root.md"), format!("# A\n\n![[{}]]\n\n# B\n", BID)).unwrap();
207+ std::fs::write(
208+ dir.path().join("racfer~t.md"),
209+ format!("---\nid: {}\ntodo: open\n---\n\n- t\n", BID),
210+ )
211+ .unwrap();
212+ std::fs::write(
213+ dir.path().join("racfer~t.sync-conflict-20260912-100000-phone.md"),
214+ format!("---\nid: {}\ntodo: done\n---\n\n- t\n", BID),
215+ )
216+ .unwrap();
217+ let mut v = Vault::open(dir.path()).unwrap();
218+ merge::merge_sync_conflicts(&mut v, false).unwrap();
219+ let block = std::fs::read_to_string(dir.path().join("racfer~t.md")).unwrap();
220+ assert!(!block.contains("![["), "{}", block);
221+ let pairs = merge::conflict_pairs(&v);
222+ assert_eq!(pairs.len(), 1);
223+ assert_eq!(v.tree.node(pairs[0].0).block.as_ref().unwrap().id.as_ref().unwrap().to_string(), BID);
224+}
225+
226+#[test]
227+fn section_conflict_pairs_with_the_section() {
228+ let dir = tempfile::tempdir().unwrap();
229+ std::fs::write(dir.path().join("root.md"), "# A\n\nbody o\n\n- x\n").unwrap();
230+ std::fs::write(
231+ dir.path().join("root.sync-conflict-20260912-100000-phone.md"),
232+ "# A\n\nbody t\n\n- x\n",
233+ )
234+ .unwrap();
235+ let mut v = Vault::open(dir.path()).unwrap();
236+ merge::merge_sync_conflicts(&mut v, false).unwrap();
237+ let pairs = merge::conflict_pairs(&v);
238+ assert_eq!(pairs.len(), 1, "{}", v.tree.files[0].text);
239+ assert_eq!(v.tree.node(pairs[0].0).title, "A");
240+ // no children: still listed
241+ let dir = tempfile::tempdir().unwrap();
242+ std::fs::write(dir.path().join("root.md"), "# A\n\nbody o\n").unwrap();
243+ std::fs::write(
244+ dir.path().join("root.sync-conflict-20260912-100000-phone.md"),
245+ "# A\n\nbody t\n",
246+ )
247+ .unwrap();
248+ let mut v = Vault::open(dir.path()).unwrap();
249+ merge::merge_sync_conflicts(&mut v, false).unwrap();
250+ let pairs = merge::conflict_pairs(&v);
251+ assert_eq!(pairs.len(), 1, "{}", v.tree.files[0].text);
252+ assert_eq!(v.tree.node(pairs[0].0).title, "A");
253+}
254+
255+#[test]
256+fn keep_theirs_puts_checkbox_after_the_heading_marker() {
257+ let dir = tempfile::tempdir().unwrap();
258+ std::fs::write(dir.path().join("root.md"), "# Fix - thing\n").unwrap();
259+ std::fs::write(
260+ dir.path().join("root.sync-conflict-20260912-100000-phone.md"),
261+ "# [ ] Fix - thing\n",
262+ )
263+ .unwrap();
264+ let mut v = Vault::open(dir.path()).unwrap();
265+ merge::merge_sync_conflicts(&mut v, false).unwrap();
266+ let pairs = merge::conflict_pairs(&v);
267+ assert_eq!(pairs.len(), 1, "{}", v.tree.files[0].text);
268+ merge::resolve_keep_theirs(&mut v, pairs[0].0, pairs[0].1).unwrap();
269+ let text = std::fs::read_to_string(dir.path().join("root.md")).unwrap();
270+ assert!(text.contains("# [ ] Fix - thing"), "{}", text);
271+}
272+
273+#[test]
274+fn keep_ours_handles_multibyte_text_before_embed() {
275+ let dir = tempfile::tempdir().unwrap();
276+ std::fs::write(dir.path().join("root.md"), "- café\n- other\n").unwrap();
277+ std::fs::write(
278+ dir.path().join("root.sync-conflict-20260912-100000-phone.md"),
279+ "- café\n- [x] other\n",
280+ )
281+ .unwrap();
282+ let mut v = Vault::open(dir.path()).unwrap();
283+ merge::merge_sync_conflicts(&mut v, false).unwrap();
284+ let pairs = merge::conflict_pairs(&v);
285+ assert_eq!(pairs.len(), 1);
286+ merge::resolve_keep_ours(&mut v, pairs[0].1).unwrap();
287+ let text = std::fs::read_to_string(dir.path().join("root.md")).unwrap();
288+ assert!(!text.contains("![["), "{}", text);
289+ assert!(text.contains("- café") && text.contains("- other"), "{}", text);
290+}
291+
292+#[test]
293+fn retitled_block_root_conflicts_and_keep_theirs_renames_file() {
294+ let dir = tempfile::tempdir().unwrap();
295+ std::fs::write(dir.path().join("root.md"), format!("# A\n\n![[{}]]\n", BID)).unwrap();
296+ std::fs::write(dir.path().join("racfer~old.md"), format!("---\nid: {}\n---\n\n- old\n", BID)).unwrap();
297+ std::fs::write(
298+ dir.path().join("racfer~old.sync-conflict-20260912-100000-phone.md"),
299+ format!("---\nid: {}\n---\n\n- new\n", BID),
300+ )
301+ .unwrap();
302+ let mut v = Vault::open(dir.path()).unwrap();
303+ merge::merge_sync_conflicts(&mut v, false).unwrap();
304+ let block = std::fs::read_to_string(dir.path().join("racfer~old.md")).unwrap();
305+ assert_eq!(block, format!("---\nid: {}\n---\n\n- old\n", BID));
306+ let pairs = merge::conflict_pairs(&v);
307+ assert_eq!(pairs.len(), 1);
308+ merge::resolve_keep_theirs(&mut v, pairs[0].0, pairs[0].1).unwrap();
309+ assert!(!dir.path().join("racfer~old.md").exists());
310+ let text = std::fs::read_to_string(dir.path().join("racfer~new.md")).unwrap();
311+ assert!(text.contains(&format!("id: {}", BID)) && text.contains("- new"), "{}", text);
312+ assert!(merge::conflict_pairs(&v).is_empty());
313+}
crates/fold-core/tests/ops.rs+262−1
@@ -242,7 +242,7 @@ fn prefix_collision_grows_prefix() {
242242 format!("---\nid: {}\n---\n\n# Notes\n", id1),
243243 )
244244 .unwrap();
245− let mut v = Vault::open(d.path()).unwrap();
245+ let v = Vault::open(d.path()).unwrap();
246246 let prefix = v.unique_prefix(&id2);
247247 assert_eq!(prefix, "racfer-wolsun");
248248}
@@ -286,3 +286,264 @@ fn paste_inserts_siblings() {
286286 let three_pos = text.find("- three").unwrap();
287287 assert!(one_pos < two_pos && two_pos < three_pos, "{}", text);
288288}
289+
290+// ------------------------------------------------------------ regressions
291+
292+const ID_A: &str = "racfer-hattes-dozzod-binwes";
293+const ID_B: &str = "dozzod-binwes-talsun-worbec";
294+
295+fn vault_files(root: &str, files: &[(&str, &str)]) -> (tempfile::TempDir, Vault) {
296+ let dir = tempfile::tempdir().unwrap();
297+ std::fs::write(dir.path().join("root.md"), root).unwrap();
298+ for (name, text) in files {
299+ std::fs::write(dir.path().join(name), text).unwrap();
300+ }
301+ let v = Vault::open(dir.path()).unwrap();
302+ (dir, v)
303+}
304+
305+fn at(v: &Vault, path: &[&str]) -> fold_core::tree::NRef {
306+ let segs: Vec<String> = path.iter().map(|s| s.to_string()).collect();
307+ v.find_by_path(&segs).unwrap()
308+}
309+
310+fn read(d: &tempfile::TempDir, name: &str) -> String {
311+ std::fs::read_to_string(d.path().join(name)).unwrap()
312+}
313+
314+#[test]
315+fn non_ascii_id_does_not_panic() {
316+ let (_d, v) = vault_files(
317+ "# A\n\n![[ééaa-racfer-hattes-dozzod]]\n",
318+ &[("x.md", "---\nid: ééé-ééé-ééé-ééé\n---\n\n# X\n")],
319+ );
320+ assert_eq!(v.tree.files.len(), 1);
321+ assert!(v.resolve_target("ééé").is_err());
322+}
323+
324+#[test]
325+fn unreadable_and_dot_md_files_are_ignored() {
326+ let dir = tempfile::tempdir().unwrap();
327+ std::fs::write(dir.path().join("root.md"), "# A\n").unwrap();
328+ std::fs::create_dir(dir.path().join("attachments.md")).unwrap();
329+ std::fs::write(dir.path().join("latin1.md"), [0x63u8, 0x61, 0x66, 0xe9, b'\n']).unwrap();
330+ std::fs::write(
331+ dir.path().join(".hidden.md"),
332+ format!("---\nid: {}\n---\n\n# Hidden\n", ID_A),
333+ )
334+ .unwrap();
335+ let v = Vault::open(dir.path()).unwrap();
336+ assert_eq!(v.tree.files.len(), 1);
337+ assert!(v.ignored_files().unwrap().iter().any(|i| i.path == "latin1.md"));
338+}
339+
340+#[test]
341+fn trash_copies_in_the_same_second_do_not_collide() {
342+ let (_d, v) = vault_with("# A\n");
343+ let a = v.trash_text("same.md", "one").unwrap();
344+ let b = v.trash_text("same.md", "two").unwrap();
345+ assert_ne!(a, b);
346+ assert_eq!(std::fs::read_to_string(&a).unwrap(), "one");
347+ assert_eq!(std::fs::read_to_string(&b).unwrap(), "two");
348+}
349+
350+#[test]
351+fn clear_done_with_duplicate_titles_keeps_the_open_one() {
352+ let (_d, mut v) = vault_with("# P\n\n- [ ] Water plants\n- [x] Water plants\n");
353+ let root = v.tree.root;
354+ assert_eq!(ops::clear_done(&mut v, root).unwrap(), 1);
355+ assert_eq!(v.tree.files[0].text, "# P\n\n- [ ] Water plants\n");
356+}
357+
358+#[test]
359+fn delete_after_multibyte_text_does_not_panic() {
360+ let (_d, mut v) = vault_with("- café\n- other\n");
361+ let other = at(&v, &["other"]);
362+ ops::delete_subtree(&mut v, other).unwrap();
363+ assert_eq!(v.tree.files[0].text, "- café\n");
364+}
365+
366+#[test]
367+fn delete_first_child_keeps_the_separator_after_the_title() {
368+ let (_d, mut v) = vault_with("# A\n\n- x\n- y\n");
369+ let x = at(&v, &["A", "x"]);
370+ ops::delete_subtree(&mut v, x).unwrap();
371+ assert_eq!(v.tree.files[0].text, "# A\n\n- y\n");
372+}
373+
374+#[test]
375+fn refile_into_own_subtree_through_an_embed_is_refused() {
376+ let (d, mut v) = vault_files(
377+ &format!("# A\n\nbody of A\n\n![[{}]]\n\n# B\n", ID_A),
378+ &[("racfer~blk.md", &format!("---\nid: {}\n---\n\n- blk\n - inner\n", ID_A))],
379+ );
380+ let a = at(&v, &["A"]);
381+ let inner = at(&v, &["A", "blk", "inner"]);
382+ assert!(ops::refile(&mut v, a, inner).is_err());
383+ assert!(read(&d, "root.md").contains("body of A"));
384+}
385+
386+#[test]
387+fn set_property_inside_a_block_file_that_sorts_late() {
388+ let (d, mut v) = vault_files(
389+ &format!("# A\n\n![[{}]]\n", ID_A),
390+ &[(
391+ "zzzz~notes.md",
392+ &format!("---\nid: {}\n---\n\n- notes\n - one\n - three\n", ID_A),
393+ )],
394+ );
395+ let three = at(&v, &["A", "notes", "three"]);
396+ ops::set_property(&mut v, three, "due", "2026-10-01").unwrap();
397+ let three = at(&v, &["A", "notes", "three"]);
398+ let b = v.tree.node(three).block.as_ref().unwrap();
399+ assert_eq!(b.prop("due"), Some("2026-10-01"));
400+ assert!(!read(&d, "zzzz~notes.md").contains("three"));
401+}
402+
403+#[test]
404+fn promote_out_of_a_block_root_goes_beside_the_embed() {
405+ let (d, mut v) = vault_files(
406+ &format!("# A\n\n- x\n![[{}]]\n- y\n", ID_A),
407+ &[("racfer~blk.md", &format!("---\nid: {}\n---\n\n- blk\n - child\n", ID_A))],
408+ );
409+ let child = at(&v, &["A", "blk", "child"]);
410+ ops::promote(&mut v, child).unwrap();
411+ assert_eq!(read(&d, "racfer~blk.md"), format!("---\nid: {}\n---\n\n- blk\n", ID_A));
412+ assert_eq!(read(&d, "root.md"), format!("# A\n\n- x\n![[{}]]\n- child\n- y\n", ID_A));
413+}
414+
415+#[test]
416+fn move_sibling_swaps_with_an_embed() {
417+ let (d, mut v) = vault_files(
418+ &format!("- a\n![[{}]]\n", ID_A),
419+ &[("racfer~b.md", &format!("---\nid: {}\n---\n\n- b\n", ID_A))],
420+ );
421+ let a = at(&v, &["a"]);
422+ ops::move_sibling(&mut v, a, true).unwrap();
423+ assert_eq!(read(&d, "root.md"), format!("![[{}]]\n- a\n", ID_A));
424+ // and back, starting from the block itself
425+ let b = at(&v, &["b"]);
426+ ops::move_sibling(&mut v, b, true).unwrap();
427+ assert_eq!(read(&d, "root.md"), format!("- a\n![[{}]]\n", ID_A));
428+}
429+
430+#[test]
431+fn move_sibling_without_final_newline() {
432+ let (_d, mut v) = vault_with("- a\n- b");
433+ let b = at(&v, &["b"]);
434+ ops::move_sibling(&mut v, b, false).unwrap();
435+ assert_eq!(v.tree.files[0].text, "- b\n- a\n");
436+}
437+
438+#[test]
439+fn move_sibling_keeps_list_spacing() {
440+ let (_d, mut v) = vault_with("# S\n\n- a\n- b\n\n# T\n");
441+ let b = at(&v, &["S", "b"]);
442+ ops::move_sibling(&mut v, b, false).unwrap();
443+ assert_eq!(v.tree.files[0].text, "# S\n\n- b\n- a\n\n# T\n");
444+}
445+
446+#[test]
447+fn toggle_taskness_on_an_indented_heading() {
448+ let (_d, mut v) = vault_with("- item\n ## Sub\n");
449+ let sub = at(&v, &["item", "Sub"]);
450+ ops::toggle_taskness(&mut v, sub).unwrap();
451+ assert_eq!(v.tree.files[0].text, "- item\n ## [ ] Sub\n");
452+}
453+
454+#[test]
455+fn toggle_task_reopens_capital_x_and_dash() {
456+ let (_d, mut v) = vault_with("- [X] one\n- [-] two\n");
457+ let one = at(&v, &["one"]);
458+ ops::toggle_task(&mut v, one).unwrap();
459+ let two = at(&v, &["two"]);
460+ ops::toggle_task(&mut v, two).unwrap();
461+ assert_eq!(v.tree.files[0].text, "- [ ] one\n- [ ] two\n");
462+}
463+
464+#[test]
465+fn make_block_keeps_checkbox_like_title_text() {
466+ let (_d, mut v) = vault_with("# A\n\n- [ ] Review [x] marks\n");
467+ let t = at(&v, &["A", "Review [x] marks"]);
468+ ops::make_block(&mut v, t).unwrap();
469+ assert!(v.find_by_path(&["A".into(), "Review [x] marks".into()]).is_some());
470+}
471+
472+#[test]
473+fn refile_indented_embed_is_not_double_indented() {
474+ let (d, mut v) = vault_files(
475+ &format!("- parent\n ![[{}]]\n\n# Dest\n", ID_A),
476+ &[("racfer~b.md", &format!("---\nid: {}\n---\n\n- b\n", ID_A))],
477+ );
478+ let b = at(&v, &["parent", "b"]);
479+ let dest = at(&v, &["Dest"]);
480+ ops::refile(&mut v, b, dest).unwrap();
481+ assert_eq!(read(&d, "root.md"), format!("- parent\n\n# Dest\n\n![[{}]]\n", ID_A));
482+}
483+
484+#[test]
485+fn multi_line_capture_nests_under_the_item() {
486+ let (_d, mut v) = vault_with("# Inbox\n");
487+ let r = ops::capture(&mut v, "line one\n# Injected\nsome text", false).unwrap();
488+ assert_eq!(v.tree.node(r).title, "line one");
489+ let top: Vec<String> = v
490+ .tree
491+ .resolved_children(v.tree.root)
492+ .iter()
493+ .map(|&c| v.tree.node(c).title.clone())
494+ .collect();
495+ assert_eq!(top, vec!["Inbox"]);
496+ let kids = v.tree.resolved_children(r);
497+ assert_eq!(v.tree.node(kids[0]).title, "Injected");
498+}
499+
500+#[test]
501+fn targets_resolve_unicode_case_and_id_like_titles() {
502+ let (_d, v) = vault_with("# Заметки\n\n## Проект\n\n# dozzod\n");
503+ assert!(v.resolve_target("заметки/проект").is_ok());
504+ assert!(v.resolve_target("dozzod").is_ok());
505+}
506+
507+#[test]
508+fn new_last_child_after_a_section_child_is_a_section() {
509+ let (_d, mut v) = vault_with("# A\n\n## B\n");
510+ let a = at(&v, &["A"]);
511+ let r = ops::append_child_public(&mut v, a, "C").unwrap();
512+ assert_eq!(v.tree.node(r).title, "C");
513+ assert_eq!(v.tree.files[0].text, "# A\n\n## B\n\n## C\n");
514+ let kids = v.tree.resolved_children(a);
515+ assert_eq!(kids.len(), 2);
516+}
517+
518+#[test]
519+fn capture_to_a_node_with_sections_stays_an_item() {
520+ let (_d, mut v) = vault_with("# P\n\n- a\n\n## S\n");
521+ let p = at(&v, &["P"]);
522+ let r = ops::capture_to(&mut v, "new", false, p).unwrap();
523+ assert_eq!(v.tree.node(r).title, "new");
524+ assert_eq!(v.tree.files[0].text, "# P\n\n- a\n- new\n\n## S\n");
525+}
526+
527+#[test]
528+fn check_fix_keeps_root_frontmatter_intro_and_malformed_files() {
529+ let malformed = format!("---\nid: {}\n---\n\n# One\n\n# Two\n\nprecious\n", ID_A);
530+ let fm_only = format!("---\nid: {}\n---\n", ID_B);
531+ let (d, mut v) = vault_files(
532+ "---\nvault: x\n---\n\nIntro text.\n\n# Inbox\n",
533+ &[("racfer~one.md", &malformed), ("dozzod~empty.md", &fm_only)],
534+ );
535+ fold_core::check::fix(&mut v).unwrap();
536+ assert_eq!(read(&d, "root.md"), "---\nvault: x\n---\n\nIntro text.\n\n# Inbox\n");
537+ assert_eq!(read(&d, "racfer~one.md"), malformed);
538+}
539+
540+#[test]
541+fn check_fix_keeps_correct_prefixes_and_is_idempotent() {
542+ let (d, mut v) = vault_files(
543+ &format!("# A\n\n![[{}]]\n", ID_A),
544+ &[("racfer~blk.md", &format!("---\nid: {}\n---\n\n- blk\n", ID_A))],
545+ );
546+ fold_core::check::fix(&mut v).unwrap();
547+ assert!(d.path().join("racfer~blk.md").exists());
548+ assert_eq!(fold_core::check::fix(&mut v).unwrap(), 0);
549+}
crates/fold-core/tests/roundtrip.rs+196−2
@@ -116,12 +116,13 @@ fn render_item_subtree() {
116116
117117#[test]
118118fn render_section_relevels() {
119− // In the on-disk spelling, written levels are preserved verbatim.
119+ // Descendants are re-levelled by base − level(node) in both spellings
120+ // (§5.1 step 4); this is what a block file made from B contains (§4.9).
120121 let text = "# A\n\n## B\n\n### C\n\nbody\n";
121122 let t = tree_of(text);
122123 let b = t.resolved_children(t.resolved_children(t.root)[0])[0];
123124 let r = render(&t, b, 1, false);
124− assert_eq!(r, "# B\n\n### C\n\nbody\n");
125+ assert_eq!(r, "# B\n\n## C\n\nbody\n");
125126}
126127
127128#[test]
@@ -262,3 +263,196 @@ fn bullet_root_block_file() {
262263 let r = render(&t, (0, root_node), 1, false);
263264 assert_eq!(r, text);
264265}
266+
267+// ---------------------------------------------------------------- regressions
268+
269+fn tree_with_block(root_text: &str, block_text: &str, block_path: &str) -> Tree {
270+ let root_block = Block {
271+ id: None,
272+ path: "root.md".into(),
273+ props: Default::default(),
274+ frontmatter_raw: String::new(),
275+ frontmatter_span: None,
276+ edge_span: None,
277+ };
278+ let root = parse_file("root.md", root_text, 0, Some(root_block));
279+ let fm = fold_core::parse::parse_frontmatter(block_text).unwrap();
280+ let id = Id::parse(fm.props.get("id").unwrap()).unwrap();
281+ let block = Block {
282+ id: Some(id.clone()),
283+ path: block_path.into(),
284+ props: fm.props,
285+ frontmatter_raw: fm.raw,
286+ frontmatter_span: Some(fm.span),
287+ edge_span: None,
288+ };
289+ let bf = parse_file(block_path, block_text, 1, Some(block));
290+ Tree {
291+ root: (0, 0),
292+ blocks: vec![((1, bf.nodes[0].children[0]), id)],
293+ files: vec![root, bf],
294+ }
295+}
296+
297+const SPEC_ROOT: &str = "# Homelab\n\nTwo boxes in the closet, one at Hetzner.\n\n## NAS\n\n![[dozzod-binwes-talsun-worbec]]\n\n## Networking\n\n- [ ] Replace the flaky switch\n![[racfer-hattes-mislup-nodrys]]\n\n# Inbox\n\n## 2026-09-10\n\n- Talked to Anya about the venue.\n ### Options\n Warehouse on Ligovsky, or the old bakery. Both need a licence.\n- [x] Send the deposit\n";
298+
299+#[test]
300+fn item_after_section_under_item_is_a_sibling() {
301+ // §4.10: "Send the deposit" is a sibling of "Talked to Anya", not a
302+ // child of the section nested under it.
303+ let t = tree_of(SPEC_ROOT);
304+ let inbox = t.resolved_children(t.root)[1];
305+ let day = t.resolved_children(inbox)[0];
306+ let kids = t.resolved_children(day);
307+ assert_eq!(kids.len(), 2);
308+ assert_eq!(t.node(kids[1]).title, "Send the deposit");
309+ // and the whole file round-trips through render(root)
310+ assert_eq!(render(&t, t.root, 1, false), SPEC_ROOT);
311+}
312+
313+#[test]
314+fn outdented_heading_leaves_section_under_item() {
315+ let t = tree_of("- a\n ## T\n### X\n");
316+ let top = t.resolved_children(t.root);
317+ assert_eq!(top.len(), 2, "X is top-level");
318+ assert_eq!(t.node(top[1]).title, "X");
319+}
320+
321+#[test]
322+fn body_after_children_does_not_swallow_them() {
323+ let text = "# A\n\npara\n\n- a\n- b\n\nmore text\n";
324+ let t = tree_of(text);
325+ let a = t.resolved_children(t.root)[0];
326+ assert_eq!(t.resolved_children(a).len(), 2);
327+ assert_eq!(render(&t, a, 1, false), text);
328+ assert_eq!(render(&t, a, 1, true), text);
329+}
330+
331+#[test]
332+fn text_between_children_keeps_its_place() {
333+ let text = "# A\n\n- a\n\ntext\n\n- b\n";
334+ let t = tree_of(text);
335+ let a = t.resolved_children(t.root)[0];
336+ assert_eq!(render(&t, a, 1, false), text);
337+}
338+
339+#[test]
340+fn crlf_input_is_read_cleanly() {
341+ let text = "# A\r\n\r\nbody\r\n\r\n## B\r\n";
342+ let t = tree_of(text);
343+ let a = t.resolved_children(t.root)[0];
344+ assert_eq!(t.node(a).body_lines(t.text_of(a)), vec!["", "body", ""]);
345+ assert_eq!(render(&t, a, 1, false), "# A\n\nbody\n\n## B\n");
346+ let fm = fold_core::parse::parse_frontmatter("---\r\nid: x\r\n---\r\n\r\n# T\r\n").unwrap();
347+ assert_eq!(fm.props.get("id").map(String::as_str), Some("x"));
348+ assert_eq!(fm.span.end, "---\r\nid: x\r\n---\r\n\r\n".len());
349+}
350+
351+#[test]
352+fn spacing_is_reproduced() {
353+ for text in [
354+ "- A\n note\n - B\n",
355+ "- A\n code\n",
356+ "- A\n ## S\n - b\n",
357+ "# S\n\n- A\n\n- B\n",
358+ "- A\n - B\n",
359+ "# A\n\n- x\n note\n\n more\n- y\n",
360+ ] {
361+ let t = tree_of(text);
362+ assert_eq!(render(&t, t.root, 1, false), text);
363+ let first = t.resolved_children(t.root)[0];
364+ assert_eq!(render(&t, first, 1, false), text);
365+ assert_eq!(render(&t, first, 1, true), text);
366+ }
367+}
368+
369+#[test]
370+fn render_of_root_starts_at_level_one() {
371+ let text = "# A\n\nbody\n\n## B\n";
372+ let t = tree_of(text);
373+ assert_eq!(render(&t, t.root, 1, false), text);
374+ assert_eq!(render(&t, t.root, 1, true), text);
375+}
376+
377+#[test]
378+fn unresolved_render_keeps_embeds() {
379+ let t = block_file_tree();
380+ let nas = t.resolved_children(t.root)[0];
381+ let r = render(&t, nas, 1, false);
382+ assert_eq!(r, "# NAS\n\n![[dozzod-binwes-talsun-worbec]]\n");
383+}
384+
385+#[test]
386+fn embed_under_item_keeps_its_indent_when_resolved() {
387+ let t = tree_with_block(
388+ "- A\n ![[racfer-hattes-mislup-nodrys]]\n",
389+ "---\nid: racfer-hattes-mislup-nodrys\n---\n\n- B\n",
390+ "racfer~b.md",
391+ );
392+ let a = t.resolved_children(t.root)[0];
393+ assert_eq!(render(&t, a, 1, true), "- A\n - B\n");
394+ assert_eq!(render(&t, a, 1, false), "- A\n ![[racfer-hattes-mislup-nodrys]]\n");
395+}
396+
397+#[test]
398+fn spans_stay_inside_a_file_without_final_newline() {
399+ let text = "- a\n- b";
400+ let t = tree_of(text);
401+ for k in t.resolved_children(t.root) {
402+ assert!(t.node(k).span.end <= text.len());
403+ }
404+ assert_eq!(render(&t, t.root, 1, false), "- a\n- b\n");
405+}
406+
407+#[test]
408+fn dashes_are_a_thematic_break_not_a_setext_underline() {
409+ // §4.4: a `---` line outside frontmatter is body text.
410+ let text = "# A\n\ntext\n---\nmore\n";
411+ let t = tree_of(text);
412+ let a = t.resolved_children(t.root)[0];
413+ assert!(t.resolved_children(a).is_empty());
414+ assert_eq!(render(&t, a, 1, false), text);
415+ // `=` underlines are still setext headings, converted on write (§4.2)
416+ let t = tree_of("Title\n===\n\nbody\n");
417+ let s = t.resolved_children(t.root)[0];
418+ assert_eq!(t.node(s).title, "Title");
419+ assert_eq!(render(&t, s, 1, false), "# Title\n\nbody\n");
420+}
421+
422+#[test]
423+fn empty_title_nodes_parse_everywhere() {
424+ // n / N create empty nodes (§10.3); they must parse as nodes, never as
425+ // setext underlines, wherever they land.
426+ let cases: &[(&str, usize)] = &[
427+ ("# A\n\n- \n", 1),
428+ ("# A\n- \n", 1),
429+ ("# A\n\npara\n- \n", 1),
430+ ("# A\n\npara\n-\n", 1),
431+ ("# A\n\n## \n", 1),
432+ ("# A\n\npara\n## \n", 1),
433+ ("# A\n\n- [ ] \n", 1),
434+ ("# A\n\n- x\n - \n", 1),
435+ ];
436+ for (text, kids) in cases {
437+ let pf = parse_file("root.md", text, 0, None);
438+ let t = Tree {
439+ files: vec![pf],
440+ root: (0, 0),
441+ blocks: vec![],
442+ };
443+ let a = t.resolved_children(t.root)[0];
444+ let ch = t.resolved_children(a);
445+ assert_eq!(ch.len(), *kids, "{:?}", text);
446+ let mut n = ch[0];
447+ while let Some(&k) = t.resolved_children(n).first() {
448+ n = k;
449+ }
450+ assert_eq!(t.node(n).title, "", "{:?}", text);
451+ // rendering keeps the node (without trailing whitespace)
452+ let r = render(&t, t.root, 1, false);
453+ let pf2 = parse_file("root.md", &r, 0, None);
454+ assert_eq!(pf2.nodes.len(), t.files[0].nodes.len(), "{:?} → {:?}", text, r);
455+ }
456+ let pf = parse_file("root.md", "# A\n\n- [ ] \n", 0, None);
457+ assert!(pf.nodes.iter().any(|n| n.task == Some(TaskState::Open) && n.title.is_empty()));
458+}
crates/fold-tui/src/app.rs+285−178
@@ -104,6 +104,12 @@ pub struct App {
104104 palette_rows: Vec<(Rect, &'static str)>,
105105 filter_rows_rects: Vec<(Rect, NRef)>,
106106 props_row_rects: Vec<Rect>,
107+ // two-key sequences: `z…`, `gg`, `[[` / `]]`
108+ pending: Option<char>,
109+ // the node the reading cursor belongs to; a new target resets it
110+ read_key: Option<NodeKey>,
111+ // whether the reading pane shows the property header as line 1
112+ read_header: bool,
107113}
108114
109115struct Prompt {
@@ -174,6 +180,9 @@ impl App {
174180 palette_rows: Vec::new(),
175181 filter_rows_rects: Vec::new(),
176182 props_row_rects: Vec::new(),
183+ pending: None,
184+ read_key: None,
185+ read_header: false,
177186 })
178187 }
179188
@@ -237,6 +246,7 @@ impl App {
237246 }
238247
239248 pub fn read_cursor_pub(&self) -> usize { self.read_cursor }
249+ pub fn quit_requested(&self) -> bool { self.quit }
240250 pub fn mode_pub(&self) -> &'static str {
241251 match self.mode {
242252 Mode::Normal => "normal",
@@ -425,8 +435,8 @@ impl App {
425435 // -------------------------------------------------------- actions
426436
427437 fn act_toggle_task(&mut self) {
428− self.push_undo("act_toggle_task");
429438 let Some(r) = self.current() else { return };
439+ self.push_undo("act_toggle_task");
430440 let key = self.vault.key_of(r);
431441 match ops::toggle_task(&mut self.vault, r) {
432442 Ok(()) => {
@@ -440,8 +450,8 @@ impl App {
440450 }
441451
442452 fn act_toggle_taskness(&mut self) {
443− self.push_undo("act_toggle_taskness");
444453 let Some(r) = self.current() else { return };
454+ self.push_undo("act_toggle_taskness");
445455 let key = self.vault.key_of(r);
446456 match ops::toggle_taskness(&mut self.vault, r) {
447457 Ok(()) => {
@@ -455,8 +465,8 @@ impl App {
455465 }
456466
457467 fn act_make_block(&mut self) {
458− self.push_undo("act_make_block");
459468 let Some(r) = self.current() else { return };
469+ self.push_undo("act_make_block");
460470 let path = self.vault.tree.path(r);
461471 match ops::make_block(&mut self.vault, r) {
462472 Ok(id) => {
@@ -471,8 +481,8 @@ impl App {
471481 }
472482
473483 fn act_delete(&mut self) {
474− self.push_undo("act_delete");
475484 let Some(r) = self.current() else { return };
485+ self.push_undo("act_delete");
476486 self.register = ops::yank(&self.vault, r);
477487 match ops::delete_subtree(&mut self.vault, r) {
478488 Ok(m) => self.refresh_after(&m),
@@ -481,19 +491,18 @@ impl App {
481491 }
482492
483493 fn act_yank(&mut self) {
484− self.push_undo("act_yank");
485494 let Some(r) = self.current() else { return };
486495 self.register = ops::yank(&self.vault, r);
487496 self.say("yanked");
488497 }
489498
490499 fn act_paste(&mut self, after: bool) {
491− self.push_undo("act_paste");
492500 if self.register.is_empty() {
493501 self.say("register empty");
494502 return;
495503 }
496504 let Some(r) = self.current() else { return };
505+ self.push_undo("act_paste");
497506 let text = self.register.clone();
498507 match ops::paste(&mut self.vault, r, &text, after) {
499508 Ok(()) => self.refresh_after("pasted"),
@@ -513,8 +522,8 @@ impl App {
513522 }
514523
515524 fn act_move(&mut self, down: bool) {
516− self.push_undo("act_move");
517525 let Some(r) = self.current() else { return };
526+ self.push_undo("act_move");
518527 let key = self.vault.key_of(r);
519528 match ops::move_sibling(&mut self.vault, r, down) {
520529 Ok(()) => {
@@ -527,8 +536,8 @@ impl App {
527536 }
528537
529538 fn act_spelling(&mut self) {
530− self.push_undo("act_spelling");
531539 let Some(r) = self.current() else { return };
540+ self.push_undo("act_spelling");
532541 let key = self.vault.key_of(r);
533542 match ops::toggle_spelling(&mut self.vault, r) {
534543 Ok(()) => {
@@ -541,8 +550,8 @@ impl App {
541550 }
542551
543552 fn act_demote(&mut self) {
544− self.push_undo("act_demote");
545553 let Some(r) = self.current() else { return };
554+ self.push_undo("act_demote");
546555 let key = self.vault.key_of(r);
547556 match ops::demote(&mut self.vault, r) {
548557 Ok(()) => {
@@ -555,8 +564,8 @@ impl App {
555564 }
556565
557566 fn act_promote(&mut self) {
558− self.push_undo("act_promote");
559567 let Some(r) = self.current() else { return };
568+ self.push_undo("act_promote");
560569 let key = self.vault.key_of(r);
561570 match ops::promote(&mut self.vault, r) {
562571 Ok(()) => {
@@ -569,8 +578,8 @@ impl App {
569578 }
570579
571580 fn act_archive(&mut self) {
572− self.push_undo("act_archive");
573581 let Some(r) = self.current() else { return };
582+ self.push_undo("act_archive");
574583 match ops::archive(&mut self.vault, r) {
575584 Ok(()) => self.refresh_after("archived"),
576585 Err(e) => self.say(format!("error: {}", e)),
@@ -587,8 +596,8 @@ impl App {
587596 }
588597
589598 fn act_refile(&mut self, dest_text: &str) {
590− self.push_undo("act_refile");
591599 let Some(r) = self.current() else { return };
600+ self.push_undo("act_refile");
592601 match self.vault.resolve_target(dest_text) {
593602 Ok(dest) => match ops::refile(&mut self.vault, r, dest) {
594603 Ok(()) => self.refresh_after("refiled"),
@@ -600,7 +609,12 @@ impl App {
600609
601610 fn act_new_node(&mut self, child: bool) {
602611 let Some(r) = self.current() else { return };
612+ self.push_undo("new node");
603613 let res = if child {
614+ // the new child must be visible to put the cursor on it
615+ if self.is_folded(r) {
616+ self.toggle_fold(r);
617+ }
604618 match ops::append_child_public(&mut self.vault, r, "") {
605619 Ok(nr) => {
606620 self.move_cursor_to(nr);
@@ -615,15 +629,23 @@ impl App {
615629 Kind::Section => format!("{} ", "#".repeat(self.vault.tree.level(r))),
616630 _ => "- ".to_string(),
617631 };
632+ let key = self.vault.key_of(r);
633+ let depth = self.rows().get(self.cursor).map(|row| row.depth).unwrap_or(0);
618634 match ops::paste(&mut self.vault, r, &format!("{}\n", line), true) {
619635 Ok(()) => {
620− // move to the new sibling (next row with an empty title)
636+ // move to the new sibling: the first row after the
637+ // cursor node's subtree, at its depth, with an empty title
638+ if let Some(nr) = self.vault.find_by_key(&key) {
639+ self.move_cursor_to(nr);
640+ }
621641 let rows = self.rows();
622− if let Some(i) = rows
623− .iter()
624− .position(|row| self.vault.tree.node(row.nref).title.is_empty())
625− {
626− self.cursor = i;
642+ if let Some(i) = rows.iter().enumerate().skip(self.cursor + 1).position(
643+ |(_, row)| {
644+ row.depth <= depth
645+ && self.vault.tree.node(row.nref).title.is_empty()
646+ },
647+ ) {
648+ self.cursor += 1 + i;
627649 }
628650 self.act_edit();
629651 return;
@@ -655,11 +677,12 @@ impl App {
655677
656678 fn save_editor(&mut self, why: &str) {
657679 let Some(mut buf) = self.edit_buf.take() else { return };
680+ let snap = self.snapshot("edit");
658681 match buf.save_all(&mut self.vault) {
659682 Ok(n) => {
660683 if n > 0 {
684+ self.record_undo(snap);
661685 self.say(format!("saved {} block(s) ({})", n, why));
662− self.mark_self_write();
663686 }
664687 self.edit_saved_dot = false;
665688 }
@@ -687,14 +710,15 @@ impl App {
687710 self.close_editor();
688711 return;
689712 }
690− KeyCode::Up => {
691− if line > 0 {
692− self.edit_cursor.0 = line - 1;
693− }
694− }
695− KeyCode::Down => {
696− if line + 1 < nlines {
697− self.edit_cursor.0 = line + 1;
713+ KeyCode::Up | KeyCode::Down => {
714+ let target = if key.code == KeyCode::Up {
715+ line.checked_sub(1)
716+ } else {
717+ Some(line + 1).filter(|&l| l < nlines)
718+ };
719+ if let Some(t) = target {
720+ let len = buf.lines[t].text.chars().count();
721+ self.edit_cursor = (t, col.min(len));
698722 }
699723 }
700724 KeyCode::Left => {
@@ -752,6 +776,7 @@ impl App {
752776 self.edit_cursor = (line - 1, prev_len);
753777 }
754778 }
779+ KeyCode::Char(_) if key.modifiers.contains(KeyModifiers::CONTROL) => {}
755780 KeyCode::Char(c) => {
756781 if let Some(l) = buf.lines.get(line) {
757782 let byte_col = l
@@ -779,8 +804,11 @@ impl App {
779804 if new_owner != old_owner {
780805 if let (Some(old), Some(buf2)) = (old_owner, self.edit_buf.as_mut()) {
781806 if buf2.dirty.contains(&old) {
807+ let snap = self.snapshot("edit");
782808 let mut tmp = self.edit_buf.take().unwrap();
783− let _ = tmp.splice(&mut self.vault, old);
809+ if tmp.splice(&mut self.vault, old).is_ok() {
810+ self.record_undo(snap);
811+ }
784812 self.edit_buf = Some(tmp);
785813 }
786814 }
@@ -860,6 +888,7 @@ impl App {
860888 if let Some((k, _, editable)) = self.props_rows.get(self.props_sel).cloned() {
861889 if editable {
862890 if let Some(t) = self.props_target {
891+ self.push_undo("delete property");
863892 let _ = ops::set_frontmatter_key(&mut self.vault, t.0, &k, None);
864893 self.props_rows.remove(self.props_sel);
865894 self.props_sel = self.props_sel.saturating_sub(1);
@@ -884,6 +913,7 @@ impl App {
884913 Err(e) => self.say(e),
885914 },
886915 PromptAction::CaptureText(task) => {
916+ self.push_undo("capture");
887917 match ops::capture(&mut self.vault, &p.text, task) {
888918 Ok(r) => {
889919 self.move_cursor_to(r);
@@ -901,6 +931,7 @@ impl App {
901931 self.say(format!("{}: must be YYYY-MM-DD", k));
902932 return;
903933 }
934+ self.push_undo("set property");
904935 match ops::set_property(&mut self.vault, t, &k, &p.text) {
905936 Ok(()) => self.say(format!("{} set", k)),
906937 Err(e) => self.say(format!("error: {}", e)),
@@ -1092,12 +1123,12 @@ impl App {
10921123 self.cursor = c as usize;
10931124 self.focus = Focus::Outline;
10941125 } else if self.point_in(self.pane_reading, x, y) {
1126+ self.sync_read_target();
10951127 let doc = self.reading_doc();
10961128 let max = doc.lines.len().saturating_sub(1) as i32;
10971129 let mut c = self.read_cursor as i32 + delta;
10981130 c = c.clamp(0, max.max(0));
10991131 self.read_cursor = c as usize;
1100− self.scroll_reading = self.read_cursor;
11011132 if self.mode != Mode::Edit {
11021133 self.focus = Focus::Reading;
11031134 } else {
@@ -1125,6 +1156,10 @@ impl App {
11251156 if self.mouse_enabled && self.toolbar_click(x, y) {
11261157 return;
11271158 }
1159+ // the conflict view has no panes; only its toolbar is clickable
1160+ if self.mode == Mode::Conflict {
1161+ return;
1162+ }
11281163 // on-screen keyboard (edit, prompts, filter, palette)
11291164 if self.kb_click(x, y) {
11301165 return;
@@ -1243,24 +1278,27 @@ impl App {
12431278 if y < inner_top {
12441279 return;
12451280 }
1281+ self.sync_read_target();
12461282 let doc = self.reading_doc();
1247− let inner_height = self.pane_reading.height.saturating_sub(2) as usize;
1248− let skip = if self.read_cursor >= inner_height && inner_height > 0 {
1249− self.read_cursor + 1 - inner_height
1250− } else {
1251− self.scroll_reading.min(doc.lines.len().saturating_sub(1))
1252− };
1253− let line = skip + (y - inner_top) as usize;
1283+ let mut line = self.scroll_reading + (y - inner_top) as usize;
1284+ if self.read_header && line >= 1 {
1285+ // the property header is drawn as line 1 but is not a doc line
1286+ if line == 1 {
1287+ return;
1288+ }
1289+ line -= 1;
1290+ }
12541291 if line < doc.lines.len() {
12551292 self.read_cursor = line;
1256− self.scroll_reading = self.scroll_reading.min(self.read_cursor);
12571293 if double {
12581294 use fold_core::reading::LineRef;
12591295 match fold_core::reading::node_at(&doc, line) {
12601296 Some(LineRef::Title(r)) => {
12611297 let n = self.vault.tree.node(r);
12621298 if n.task.is_some() {
1263− let _ = fold_core::ops::toggle_task(&mut self.vault, r);
1299+ self.act_on_node(r, |app, r| {
1300+ let _ = ops::toggle_task(&mut app.vault, r);
1301+ });
12641302 } else if n.kind == Kind::Section {
12651303 self.zoom_root = Some(r);
12661304 self.read_cursor = 0;
@@ -1290,6 +1328,17 @@ impl App {
12901328 .find(|(r, _)| self.point_in(*r, x, y))
12911329 .map(|(_, a)| *a);
12921330 let Some(action) = hit else { return false };
1331+ // labels shared between modes act on the mode's own target
1332+ let mode_key = match (self.mode, action) {
1333+ (Mode::Conflict, "Done") => Some(KeyCode::Enter),
1334+ (Mode::Conflict, "Edit") | (Mode::Props, "Edit") => Some(KeyCode::Char('e')),
1335+ (Mode::Props, "New") => Some(KeyCode::Char('n')),
1336+ _ => None,
1337+ };
1338+ if let Some(code) = mode_key {
1339+ self.handle_key(KeyEvent::new(code, KeyModifiers::NONE));
1340+ return true;
1341+ }
12931342 match action {
12941343 "New" => self.act_new_node(false),
12951344 "Edit" => self.act_edit(),
@@ -1335,12 +1384,7 @@ impl App {
13351384 }
13361385 _ => {}
13371386 },
1338− "Discard" => {
1339− self.edit_buf = None;
1340− self.mode = Mode::Normal;
1341− let _ = self.vault.reload();
1342− self.say("changes discarded");
1343− }
1387+ "Discard" => self.discard_editor(),
13441388 "Ours" => self.key_conflict(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)),
13451389 "Theirs" => self.key_conflict(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE)),
13461390 "Both" => self.key_conflict(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)),
@@ -1396,6 +1440,90 @@ impl App {
13961440 x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height
13971441 }
13981442
1443+ /// Dispatch a key press by mode, including the two-key sequences
1444+ /// (`zd`/`zr`/`za`, `gg`, `[[`/`]]`).
1445+ pub fn handle_key(&mut self, key: KeyEvent) {
1446+ let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1447+ if ctrl && key.code == KeyCode::Char('c') && self.mode != Mode::Edit {
1448+ self.quit = true;
1449+ return;
1450+ }
1451+ if let Some(p) = self.pending.take() {
1452+ match (p, key.code) {
1453+ ('z', KeyCode::Char('d')) => {
1454+ self.hide_done = !self.hide_done;
1455+ self.clamp_cursor();
1456+ self.say(if self.hide_done { "done hidden" } else { "done shown" });
1457+ }
1458+ ('z', KeyCode::Char('r')) => {
1459+ self.raw_mode = !self.raw_mode;
1460+ self.say(if self.raw_mode { "raw" } else { "styled" });
1461+ }
1462+ ('z', KeyCode::Char('a')) => self.act_archive(),
1463+ ('g', KeyCode::Char('g')) => match self.focus {
1464+ Focus::Outline => self.cursor = 0,
1465+ Focus::Reading => self.read_cursor = 0,
1466+ },
1467+ (']', KeyCode::Char(']')) | ('[', KeyCode::Char('[')) => {
1468+ self.sync_read_target();
1469+ let doc = self.reading_doc();
1470+ self.jump_heading(&doc, if p == ']' { 1 } else { -1 });
1471+ }
1472+ _ => {}
1473+ }
1474+ return;
1475+ }
1476+ match self.mode {
1477+ Mode::Normal => {
1478+ if self.prompt.is_some() {
1479+ self.key_prompt(key);
1480+ return;
1481+ }
1482+ let starts_seq = match key.code {
1483+ KeyCode::Char('z') => self.focus == Focus::Outline,
1484+ KeyCode::Char('g') => true,
1485+ KeyCode::Char('[') | KeyCode::Char(']') => self.focus == Focus::Reading,
1486+ _ => false,
1487+ };
1488+ if starts_seq && !ctrl {
1489+ if let KeyCode::Char(c) = key.code {
1490+ self.pending = Some(c);
1491+ }
1492+ return;
1493+ }
1494+ self.key_normal(key)
1495+ }
1496+ Mode::Filter => self.key_filter(key),
1497+ Mode::Picker => self.key_palette(key),
1498+ Mode::Edit => {
1499+ if ctrl && key.code == KeyCode::Char('c') {
1500+ self.discard_editor();
1501+ return;
1502+ }
1503+ self.key_edit(key)
1504+ }
1505+ Mode::Props => self.key_props(key),
1506+ Mode::Help => {
1507+ if matches!(
1508+ key.code,
1509+ KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') | KeyCode::Char('?')
1510+ ) {
1511+ self.mode = Mode::Normal;
1512+ }
1513+ }
1514+ Mode::Conflict => self.key_conflict(key),
1515+ }
1516+ }
1517+
1518+ /// Drop the editor's unsaved changes (§10.6 `Ctrl-c`).
1519+ fn discard_editor(&mut self) {
1520+ self.edit_buf = None;
1521+ self.mode = Mode::Normal;
1522+ let _ = self.vault.reload();
1523+ self.clamp_cursor();
1524+ self.say("changes discarded");
1525+ }
1526+
13991527 pub fn key_normal(&mut self, key: KeyEvent) {
14001528 let rows = self.rows();
14011529 match self.focus {
@@ -1406,7 +1534,16 @@ impl App {
14061534
14071535 pub fn key_outline(&mut self, key: KeyEvent, rows: Vec<FlatRow>) {
14081536 let len = rows.len();
1537+ let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1538+ let half = (self.pane_outline.height.saturating_sub(2) as usize / 2).max(1);
14091539 match key.code {
1540+ KeyCode::Char('d') if ctrl => {
1541+ self.cursor = (self.cursor + half).min(len.saturating_sub(1));
1542+ }
1543+ KeyCode::Char('u') if ctrl => {
1544+ self.cursor = self.cursor.saturating_sub(half);
1545+ }
1546+ _ if ctrl => {}
14101547 KeyCode::Char('q') => self.quit = true,
14111548 KeyCode::Tab => self.focus = Focus::Reading,
14121549 KeyCode::Char('j') | KeyCode::Down => {
@@ -1424,11 +1561,8 @@ impl App {
14241561 let has_kids = !self.vault.tree.resolved_children(r).is_empty();
14251562 if has_kids && !self.is_folded(r) {
14261563 self.toggle_fold(r);
1427− } else if let Some(p) = self.vault.tree.node(r).parent {
1428− let pr = (r.0, p);
1429− if self.vault.tree.node(pr).kind != Kind::Root {
1430− self.move_cursor_to(pr);
1431− }
1564+ } else {
1565+ self.goto_parent_row(&rows);
14321566 }
14331567 }
14341568 }
@@ -1470,24 +1604,9 @@ impl App {
14701604 self.folded.retain(|k| !keys.contains(k));
14711605 }
14721606 }
1473− KeyCode::Char('z') => {
1474− // zd / zr / za
1475− self.say("z… (d: hide done, r: raw, a: archive)");
1476− // handled via pending key in main loop
1477− }
1478− KeyCode::Char('-') => {
1479− if let Some(r) = self.current() {
1480− if let Some(p) = self.vault.tree.node(r).parent {
1481− let pr = (r.0, p);
1482− if self.vault.tree.node(pr).kind != Kind::Root {
1483− self.move_cursor_to(pr);
1484− }
1485− }
1486− }
1487− }
1488− KeyCode::Char('{') => self.sibling(-1),
1489− KeyCode::Char('}') => self.sibling(1),
1490− KeyCode::Char('g') => self.cursor = 0, // gg handled via pending
1607+ KeyCode::Char('-') => self.goto_parent_row(&rows),
1608+ KeyCode::Char('{') => self.sibling(&rows, -1),
1609+ KeyCode::Char('}') => self.sibling(&rows, 1),
14911610 KeyCode::Char('G') => {
14921611 if len > 0 {
14931612 self.cursor = len - 1;
@@ -1576,7 +1695,7 @@ impl App {
15761695 fn act_undo(&mut self) {
15771696 if let Some(inv) = self.undo.pop() {
15781697 let redo_snapshot = self.snapshot("redo point");
1579− match inv.apply(&mut self.vault) {
1698+ match self.restore(inv) {
15801699 Ok(()) => {
15811700 self.redo.push(redo_snapshot);
15821701 self.clamp_cursor();
@@ -1592,7 +1711,7 @@ impl App {
15921711 fn act_redo(&mut self) {
15931712 if let Some(inv) = self.redo.pop() {
15941713 let undo_snapshot = self.snapshot("undo point");
1595− match inv.apply(&mut self.vault) {
1714+ match self.restore(inv) {
15961715 Ok(()) => {
15971716 self.undo.push(undo_snapshot);
15981717 self.clamp_cursor();
@@ -1606,11 +1725,30 @@ impl App {
16061725 }
16071726
16081727 fn push_undo(&mut self, desc: &str) {
1609− self.undo.push(self.snapshot(desc));
1728+ let snap = self.snapshot(desc);
1729+ self.record_undo(snap);
1730+ }
1731+
1732+ /// Record a snapshot taken before a mutation as one op-log entry.
1733+ fn record_undo(&mut self, snap: ops::Inverse) {
1734+ self.undo.push(snap);
16101735 self.redo.clear();
16111736 self.mark_self_write();
16121737 }
16131738
1739+ /// Bring the vault back to a snapshot: rewrite its files and remove
1740+ /// files created since it was taken (new blocks), so undo of a
1741+ /// file-creating op leaves no orphan (§10.11).
1742+ fn restore(&mut self, mut inv: ops::Inverse) -> std::io::Result<()> {
1743+ for f in &self.vault.tree.files {
1744+ if !inv.files.iter().any(|(p, _)| *p == f.path) {
1745+ inv.files.push((f.path.clone(), None));
1746+ }
1747+ }
1748+ self.mark_self_write();
1749+ inv.apply(&mut self.vault)
1750+ }
1751+
16141752 fn snapshot(&self, desc: &str) -> ops::Inverse {
16151753 ops::Inverse {
16161754 files: self
@@ -1624,47 +1762,58 @@ impl App {
16241762 }
16251763 }
16261764
1627− fn sibling(&mut self, dir: i32) {
1628− let Some(r) = self.current() else { return };
1629− let Some(p) = self.vault.tree.node(r).parent else { return };
1630− let sibs = self.vault.tree.resolved_children((r.0, p));
1631− if let Some(pos) = sibs.iter().position(|&s| s == r) {
1632− let np = pos as i32 + dir;
1633− if np >= 0 && (np as usize) < sibs.len() {
1634− self.move_cursor_to(sibs[np as usize]);
1765+ /// Parent and siblings are found on the flattened rows rather than in
1766+ /// the tree: a block row's tree parent is its own file's root, but its
1767+ /// outline parent is the row that embeds it.
1768+ fn goto_parent_row(&mut self, rows: &[FlatRow]) {
1769+ let Some(cur) = rows.get(self.cursor) else { return };
1770+ if let Some(i) = rows[..self.cursor].iter().rposition(|r| r.depth < cur.depth) {
1771+ self.cursor = i;
1772+ }
1773+ }
1774+
1775+ fn sibling(&mut self, rows: &[FlatRow], dir: i32) {
1776+ let Some(cur) = rows.get(self.cursor) else { return };
1777+ let d = cur.depth;
1778+ let mut i = self.cursor as i64 + dir as i64;
1779+ while i >= 0 && (i as usize) < rows.len() {
1780+ let rd = rows[i as usize].depth;
1781+ if rd < d {
1782+ return;
16351783 }
1784+ if rd == d {
1785+ self.cursor = i as usize;
1786+ return;
1787+ }
1788+ i += dir as i64;
16361789 }
16371790 }
16381791
16391792 pub fn key_reading_pub(&mut self, key: KeyEvent) { self.key_reading(key) }
16401793 fn key_reading(&mut self, key: KeyEvent) {
1794+ self.sync_read_target();
16411795 let doc = self.reading_doc();
16421796 let nlines = doc.lines.len();
1797+ let half = (self.pane_reading.height.saturating_sub(2) as usize / 2).max(1);
16431798 match key.code {
16441799 KeyCode::Tab => self.focus = Focus::Outline,
16451800 KeyCode::Char('j') | KeyCode::Down => {
16461801 if self.read_cursor + 1 < nlines.max(1) {
16471802 self.read_cursor += 1;
16481803 }
1649− self.scroll_reading = self.read_cursor;
16501804 }
16511805 KeyCode::Char('k') | KeyCode::Up => {
16521806 self.read_cursor = self.read_cursor.saturating_sub(1);
1653− self.scroll_reading = self.read_cursor;
16541807 }
16551808 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1656− self.read_cursor = (self.read_cursor + 10).min(nlines.saturating_sub(1));
1657− self.scroll_reading = self.read_cursor;
1809+ self.read_cursor = (self.read_cursor + half).min(nlines.saturating_sub(1));
16581810 }
16591811 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1660− self.read_cursor = self.read_cursor.saturating_sub(10);
1661− self.scroll_reading = self.read_cursor;
1812+ self.read_cursor = self.read_cursor.saturating_sub(half);
16621813 }
16631814 KeyCode::Char('G') => {
16641815 self.read_cursor = nlines.saturating_sub(1);
1665− self.scroll_reading = self.read_cursor;
16661816 }
1667− KeyCode::Char('g') => {} // gg via pending
16681817 KeyCode::Enter => {
16691818 use fold_core::reading::LineRef;
16701819 match fold_core::reading::node_at(&doc, self.read_cursor) {
@@ -1750,8 +1899,6 @@ impl App {
17501899 }
17511900 }
17521901 KeyCode::Char('o') => self.open_link_under_cursor(&doc),
1753− KeyCode::Char(']') => self.jump_heading(&doc, 1),
1754− KeyCode::Char('[') => self.jump_heading(&doc, -1),
17551902 KeyCode::Char('/') => {
17561903 self.prompt = Some(Prompt {
17571904 label: "search".into(),
@@ -1766,6 +1913,20 @@ impl App {
17661913 }
17671914 }
17681915
1916+ /// Reset the reading cursor when the pane starts showing another node
1917+ /// (outline cursor moved, zoom changed), and keep it inside the doc.
1918+ fn sync_read_target(&mut self) {
1919+ let key = self.zoom_root.or_else(|| self.current()).map(|r| self.vault.key_of(r));
1920+ if key != self.read_key {
1921+ self.read_key = key;
1922+ self.read_cursor = 0;
1923+ self.scroll_reading = 0;
1924+ self.read_matches.clear();
1925+ }
1926+ let n = self.reading_doc().lines.len();
1927+ self.read_cursor = self.read_cursor.min(n.saturating_sub(1));
1928+ }
1929+
17691930 pub fn reading_doc_pub(&self) -> fold_core::reading::ReadingDoc { self.reading_doc() }
17701931 fn reading_doc(&self) -> fold_core::reading::ReadingDoc {
17711932 let target = self.zoom_root.or_else(|| self.current());
@@ -1790,9 +1951,8 @@ impl App {
17901951
17911952 /// Run a mutation on a node from the reading pane, keeping the cursor.
17921953 fn act_on_node(&mut self, r: NRef, f: impl Fn(&mut App, NRef)) {
1793− let key = self.vault.key_of(r);
1954+ self.push_undo("reading-pane edit");
17941955 f(self, r);
1795− let _ = key;
17961956 }
17971957
17981958 fn jump_heading(&mut self, doc: &fold_core::reading::ReadingDoc, dir: i32) {
@@ -1803,7 +1963,6 @@ impl App {
18031963 let l = &doc.lines[i as usize];
18041964 if l.trim_start().starts_with('#') {
18051965 self.read_cursor = i as usize;
1806− self.scroll_reading = self.read_cursor;
18071966 return;
18081967 }
18091968 }
@@ -1823,7 +1982,6 @@ impl App {
18231982 self.read_match_idx = 0;
18241983 if let Some(&m) = self.read_matches.first() {
18251984 self.read_cursor = m;
1826− self.scroll_reading = m;
18271985 }
18281986 self.say(format!("{} match(es)", self.read_matches.len()));
18291987 }
@@ -1838,7 +1996,6 @@ impl App {
18381996 self.read_match_idx = ((self.read_match_idx as i32 + dir).rem_euclid(n)) as usize;
18391997 let m = self.read_matches[self.read_match_idx];
18401998 self.read_cursor = m;
1841− self.scroll_reading = m;
18421999 }
18432000
18442001 fn open_link_under_cursor(&mut self, doc: &fold_core::reading::ReadingDoc) {
@@ -2158,6 +2315,8 @@ impl App {
21582315 }
21592316
21602317 fn draw_reading(&mut self, f: &mut ratatui::Frame, area: Rect) {
2318+ self.sync_read_target();
2319+ self.read_header = false;
21612320 let focused = self.focus == Focus::Reading;
21622321 let block = WBlock::default()
21632322 .borders(Borders::ALL)
@@ -2206,17 +2365,20 @@ impl App {
22062365 }
22072366 if let Some(h) = prop_header {
22082367 lines.insert(1.min(lines.len()), h);
2368+ self.read_header = true;
22092369 }
22102370 }
22112371 }
22122372 let inner = area.height.saturating_sub(2) as usize;
2213− // keep the reading cursor visible
2214− let skip = if focused && self.read_cursor >= inner && inner > 0 {
2215− self.read_cursor + 1 - inner
2216− } else {
2217− self.scroll_reading.min(lines.len().saturating_sub(1))
2218− };
2219− let lines: Vec<Line> = lines.into_iter().skip(skip).collect();
2373+ // scroll only when the reading cursor leaves the viewport
2374+ let shown = self.read_cursor + (self.read_header && self.read_cursor >= 1) as usize;
2375+ if shown < self.scroll_reading {
2376+ self.scroll_reading = shown;
2377+ } else if inner > 0 && shown >= self.scroll_reading + inner {
2378+ self.scroll_reading = shown + 1 - inner;
2379+ }
2380+ self.scroll_reading = self.scroll_reading.min(lines.len().saturating_sub(1));
2381+ let lines: Vec<Line> = lines.into_iter().skip(self.scroll_reading).collect();
22202382 f.render_widget(Paragraph::new(lines).block(block), area);
22212383 }
22222384
@@ -2260,13 +2422,18 @@ impl App {
22602422 }
22612423
22622424 fn draw_props(&mut self, f: &mut ratatui::Frame, area: Rect) {
2263− let h = (self.props_rows.len() as u16 + 4).min(area.height - 4).max(4);
2425+ let h = (self.props_rows.len() as u16 + 4).min(area.height.saturating_sub(4)).max(4);
22642426 let rect = Rect {
22652427 x: area.x + 6,
22662428 y: area.y + 3,
22672429 width: area.width.saturating_sub(12).min(60),
22682430 height: h,
2269− };
2431+ }
2432+ .intersection(area);
2433+ self.props_row_rects.clear();
2434+ if rect.height < 3 {
2435+ return;
2436+ }
22702437 f.render_widget(Clear, rect);
22712438 let mut lines: Vec<Line> = Vec::new();
22722439 let title = self
@@ -2505,14 +2672,19 @@ impl App {
25052672 y: area.y + 1,
25062673 width: area.width.saturating_sub(4),
25072674 height: h,
2508− };
2675+ }
2676+ .intersection(area);
2677+ self.filter_rows_rects.clear();
2678+ if rect.height < 3 {
2679+ return;
2680+ }
25092681 f.render_widget(Clear, rect);
25102682 let mut lines = vec![Line::from(vec![
25112683 TSpan::styled("/ ", Style::default().add_modifier(Modifier::BOLD)),
25122684 TSpan::raw(self.filter.clone()),
25132685 ])];
25142686 self.filter_rows_rects.clear();
2515− for (i, r) in self.filter_rows.iter().take(h as usize - 2).enumerate() {
2687+ for (i, r) in self.filter_rows.iter().take(rect.height as usize - 2).enumerate() {
25162688 let path = self.vault.tree.path(*r).join(" › ");
25172689 self.filter_rows_rects.push((
25182690 Rect {
@@ -2557,20 +2729,25 @@ impl App {
25572729 || fuzzy_match(&q, &a.desc.to_lowercase())
25582730 })
25592731 .collect();
2560− let h = (hits.len() as u16 + 3).min(area.height - 4).max(3);
2732+ let h = (hits.len() as u16 + 3).min(area.height.saturating_sub(4)).max(3);
25612733 let rect = Rect {
25622734 x: area.x + 4,
25632735 y: area.y + 2,
25642736 width: area.width.saturating_sub(8),
25652737 height: h,
2566− };
2738+ }
2739+ .intersection(area);
2740+ if rect.height < 3 {
2741+ self.palette_rows.clear();
2742+ return;
2743+ }
25672744 f.render_widget(Clear, rect);
25682745 let mut lines = vec![Line::from(vec![
25692746 TSpan::styled(": ", Style::default().add_modifier(Modifier::BOLD)),
25702747 TSpan::raw(self.palette.clone()),
25712748 ])];
25722749 self.palette_rows.clear();
2573− for (i, a) in hits.iter().take(h as usize - 2).enumerate() {
2750+ for (i, a) in hits.iter().take(rect.height as usize - 2).enumerate() {
25742751 self.palette_rows.push((
25752752 Rect {
25762753 x: rect.x + 1,
@@ -2816,8 +2993,6 @@ fn run_loop(
28162993 terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
28172994 app: &mut App,
28182995) -> anyhow::Result<()> {
2819− let mut pending_z = false;
2820− let mut pending_g = false;
28212996 loop {
28222997 terminal.draw(|f| app.draw(f))?;
28232998 if app.quit {
@@ -2841,75 +3016,7 @@ fn run_loop(
28413016 if event::poll(Duration::from_millis(200))? {
28423017 match event::read()? {
28433018 Event::Mouse(m) => app.handle_mouse(m),
2844− Event::Key(key) => {
2845− if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c')
2846− {
2847− return Ok(());
2848− }
2849− // two-key sequences
2850− if pending_z {
2851− pending_z = false;
2852− match key.code {
2853− KeyCode::Char('d') => {
2854− app.hide_done = !app.hide_done;
2855− app.say(if app.hide_done { "done hidden" } else { "done shown" });
2856− }
2857− KeyCode::Char('r') => {
2858− app.raw_mode = !app.raw_mode;
2859− app.say(if app.raw_mode { "raw" } else { "styled" });
2860− }
2861− KeyCode::Char('a') => app.act_archive(),
2862− _ => {}
2863− }
2864− continue;
2865− }
2866− if pending_g {
2867− pending_g = false;
2868− if key.code == KeyCode::Char('g') {
2869− app.cursor = 0;
2870− }
2871− continue;
2872− }
2873− match app.mode {
2874− Mode::Normal => {
2875− if app.prompt.is_some() {
2876− app.key_prompt(key);
2877− continue;
2878− }
2879− if key.code == KeyCode::Char('z') {
2880− pending_z = true;
2881− continue;
2882− }
2883− if key.code == KeyCode::Char('g') {
2884− pending_g = true;
2885− continue;
2886− }
2887− app.key_normal(key)
2888− }
2889− Mode::Filter => app.key_filter(key),
2890− Mode::Picker => app.key_palette(key),
2891− Mode::Edit => {
2892− if key.modifiers.contains(KeyModifiers::CONTROL)
2893− && key.code == KeyCode::Char('c')
2894− {
2895− // discard changes since last save (§10.6)
2896− app.edit_buf = None;
2897− app.mode = Mode::Normal;
2898− app.vault.reload().ok();
2899− app.say("changes discarded");
2900− continue;
2901− }
2902− app.key_edit(key)
2903− }
2904− Mode::Props => app.key_props(key),
2905− Mode::Help => {
2906− if matches!(key.code, KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') | KeyCode::Char('?')) {
2907− app.mode = Mode::Normal;
2908− }
2909− }
2910− Mode::Conflict => app.key_conflict(key),
2911− }
2912− }
3019+ Event::Key(key) => app.handle_key(key),
29133020 _ => {}
29143021 }
29153022 }
crates/fold-tui/tests/conflict_watch.rs+10−3
@@ -104,14 +104,21 @@ fn reading_search_jumps_to_match() {
104104fn reading_heading_jump() {
105105 let (_d, mut app) = app_with("# A\n\ntext\n\n## B\n\nmore\n\n## C\n");
106106 app.key_normal(key(KeyCode::Enter));
107+ // a single `]` does nothing (§10.4: `]]`)
108+ app.handle_key(key(KeyCode::Char(']')));
109+ app.handle_key(key(KeyCode::Char('j')));
110+ assert_eq!(app.read_cursor_pub(), 0);
107111 let doc = app.reading_doc_pub();
108112 let b_line = doc.lines.iter().position(|l| l.contains("## B")).unwrap();
109113 let c_line = doc.lines.iter().position(|l| l.contains("## C")).unwrap();
110− app.key_reading_pub(key(KeyCode::Char(']')));
114+ app.handle_key(key(KeyCode::Char(']')));
115+ app.handle_key(key(KeyCode::Char(']')));
111116 assert_eq!(app.read_cursor_pub(), b_line);
112− app.key_reading_pub(key(KeyCode::Char(']')));
117+ app.handle_key(key(KeyCode::Char(']')));
118+ app.handle_key(key(KeyCode::Char(']')));
113119 assert_eq!(app.read_cursor_pub(), c_line);
114− app.key_reading_pub(key(KeyCode::Char('[')));
120+ app.handle_key(key(KeyCode::Char('[')));
121+ app.handle_key(key(KeyCode::Char('[')));
115122 assert_eq!(app.read_cursor_pub(), b_line);
116123}
117124
crates/fold-tui/tests/fixes.rsnew file+182
@@ -0,0 +1,182 @@
1+//! Regressions for keymap, undo, cursor and small-terminal bugs.
2+
3+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4+use fold_tui::app::App;
5+use ratatui::backend::TestBackend;
6+use ratatui::Terminal;
7+
8+fn key(code: KeyCode) -> KeyEvent {
9+ KeyEvent::new(code, KeyModifiers::NONE)
10+}
11+
12+fn ctrl(c: char) -> KeyEvent {
13+ KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
14+}
15+
16+fn press(app: &mut App, keys: &str) {
17+ for c in keys.chars() {
18+ app.handle_key(key(KeyCode::Char(c)));
19+ }
20+}
21+
22+fn app_with(text: &str) -> (tempfile::TempDir, App) {
23+ let dir = tempfile::tempdir().unwrap();
24+ std::fs::write(dir.path().join("root.md"), text).unwrap();
25+ let app = App::new(dir.path()).unwrap();
26+ (dir, app)
27+}
28+
29+fn draw(app: &mut App, w: u16, h: u16) {
30+ let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
31+ terminal.draw(|f| app.draw(f)).unwrap();
32+}
33+
34+fn root(dir: &tempfile::TempDir) -> String {
35+ std::fs::read_to_string(dir.path().join("root.md")).unwrap()
36+}
37+
38+fn md_files(dir: &tempfile::TempDir) -> Vec<String> {
39+ let mut v: Vec<String> = std::fs::read_dir(dir.path())
40+ .unwrap()
41+ .filter_map(|e| e.ok())
42+ .map(|e| e.file_name().to_string_lossy().into_owned())
43+ .filter(|n| n.ends_with(".md"))
44+ .collect();
45+ v.sort();
46+ v
47+}
48+
49+fn current_title(app: &App) -> String {
50+ app.current().map(|r| app.title_of(r)).unwrap_or_default()
51+}
52+
53+#[test]
54+fn ctrl_d_pages_instead_of_deleting() {
55+ let text = "# A\n\n# B\n\n# C\n";
56+ let (d, mut app) = app_with(text);
57+ draw(&mut app, 100, 24);
58+ app.handle_key(ctrl('d'));
59+ assert_eq!(root(&d), text);
60+ assert_eq!(current_title(&app), "C");
61+ app.handle_key(ctrl('u'));
62+ assert_eq!(current_title(&app), "A");
63+}
64+
65+#[test]
66+fn ctrl_c_in_editor_discards_without_quitting() {
67+ let (_d, mut app) = app_with("# A\n\nbody\n");
68+ press(&mut app, "e");
69+ app.handle_key(ctrl('c'));
70+ assert!(!app.quit_requested());
71+ assert_eq!(app.mode_pub(), "normal");
72+ app.handle_key(ctrl('c'));
73+ assert!(app.quit_requested());
74+}
75+
76+#[test]
77+fn yank_keeps_redo() {
78+ let (d, mut app) = app_with("- [ ] a\n");
79+ press(&mut app, "xu");
80+ assert_eq!(root(&d), "- [ ] a\n");
81+ press(&mut app, "yU");
82+ assert_eq!(root(&d), "- [x] a\n");
83+}
84+
85+#[test]
86+fn undo_make_block_removes_new_file() {
87+ let (d, mut app) = app_with("# A\n\n## B\n\nbody\n");
88+ let before = md_files(&d);
89+ press(&mut app, "js");
90+ assert!(md_files(&d).len() > before.len());
91+ press(&mut app, "u");
92+ assert_eq!(md_files(&d), before);
93+ assert_eq!(root(&d), "# A\n\n## B\n\nbody\n");
94+}
95+
96+#[test]
97+fn noop_verbs_push_no_undo_entry() {
98+ let (d, mut app) = app_with("- [ ] a\n");
99+ // toggle, hide done (no rows left), then a verb with no target
100+ press(&mut app, "xzdx");
101+ press(&mut app, "u");
102+ assert_eq!(root(&d), "- [ ] a\n");
103+}
104+
105+#[test]
106+fn new_sibling_lands_after_cursor() {
107+ let (_d, mut app) = app_with("# A\n\n# \n\n# C\n");
108+ press(&mut app, "G");
109+ assert_eq!(current_title(&app), "C");
110+ press(&mut app, "n");
111+ app.handle_key(key(KeyCode::Esc));
112+ assert_eq!(app.cursor, 3);
113+}
114+
115+#[test]
116+fn new_child_of_folded_node_opens_the_child() {
117+ let (_d, mut app) = app_with("# A\n\n## B\n");
118+ press(&mut app, "hN");
119+ assert_eq!(app.mode_pub(), "edit");
120+ assert_eq!(current_title(&app), "");
121+}
122+
123+#[test]
124+fn hide_done_clamps_cursor() {
125+ let (_d, mut app) = app_with("- a\n- [x] b\n- [x] c\n");
126+ press(&mut app, "G");
127+ press(&mut app, "zd");
128+ assert_eq!(app.cursor, 0);
129+}
130+
131+#[test]
132+fn parent_and_sibling_from_block_row() {
133+ let (_d, mut app) = app_with("# A\n\n## B\n\n## C\n");
134+ press(&mut app, "js");
135+ assert_eq!(current_title(&app), "B");
136+ press(&mut app, "}");
137+ assert_eq!(current_title(&app), "C");
138+ press(&mut app, "{-");
139+ assert_eq!(current_title(&app), "A");
140+}
141+
142+#[test]
143+fn gg_in_reading_pane_keeps_outline_cursor() {
144+ let (_d, mut app) = app_with("# A\n\nx\ny\nz\n\n# B\n");
145+ app.handle_key(key(KeyCode::Tab));
146+ press(&mut app, "jjgg");
147+ assert_eq!(app.read_cursor_pub(), 0);
148+ assert_eq!(app.cursor, 0);
149+}
150+
151+#[test]
152+fn reading_cursor_resets_on_new_target() {
153+ let body: String = (0..30).map(|i| format!("line {}\n", i)).collect();
154+ let (_d, mut app) = app_with(&format!("# A\n\n{}\n# B\n\nb1\n", body));
155+ draw(&mut app, 100, 24);
156+ app.handle_key(key(KeyCode::Tab));
157+ press(&mut app, "G");
158+ assert!(app.read_cursor_pub() > 20);
159+ app.handle_key(key(KeyCode::Tab));
160+ press(&mut app, "j");
161+ draw(&mut app, 100, 24);
162+ assert_eq!(app.read_cursor_pub(), 0);
163+}
164+
165+#[test]
166+fn tiny_terminal_popups_do_not_panic() {
167+ let (_d, mut app) = app_with("# A\n");
168+ press(&mut app, ":");
169+ draw(&mut app, 40, 3);
170+ app.handle_key(key(KeyCode::Esc));
171+ press(&mut app, "a");
172+ draw(&mut app, 40, 3);
173+}
174+
175+#[test]
176+fn tiny_terminal_filter_does_not_panic() {
177+ let (_d, mut app) = app_with("# A\n");
178+ press(&mut app, "/A");
179+ for h in 0..5 {
180+ draw(&mut app, 40, h);
181+ }
182+}
crates/fold-tui/tests/mouse_all.rs+1
@@ -3,6 +3,7 @@ use fold_tui::app::App;
33use ratatui::backend::TestBackend;
44use ratatui::Terminal;
55
6+#[allow(dead_code)]
67fn toolbar_labels(app: &mut App) -> Vec<String> {
78 draw_once(app, 160, 30);
89 app.toolbar_labels()