on master help sign in
Fix ~140 bugs in core, CLI and TUI found by review (#1)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Claude <noreply@anthropic.com>

commit0645141e213d50a59d9b70680f36e53a4ef6f68d authoriko <ilyakooo0@gmail.com> dateSat 26 Sep 2026, 17:50 (1 month ago) parentdc50672
31 files changed+8410−890
crates/fold-cli/tests/cli.rs+58−8 crates/fold-core/src/check.rs+66−22 crates/fold-core/src/edit.rs+635−81 crates/fold-core/src/merge.rs+131−80 crates/fold-core/src/ops.rs+362−124 crates/fold-core/src/parse.rs+102−15 crates/fold-core/src/render.rs+9−1 crates/fold-core/src/tree.rs+84−18 crates/fold-core/src/vault.rs+65−51 crates/fold-core/tests/edit.rs+890−2 crates/fold-core/tests/merge_check.rs+345 crates/fold-core/tests/ops.rs+656 crates/fold-core/tests/ordering.rs+308 crates/fold-core/tests/roundtrip.rs+100−27 crates/fold-core/tests/undo.rs+21 crates/fold-tui/src/app.rs+668−168 crates/fold-tui/src/app/editor/helix.rs+154−54 crates/fold-tui/src/app/editor/mod.rs+389−55 crates/fold-tui/src/app/editor/normal.rs+28−12 crates/fold-tui/src/app/editor/vim.rs+230−50 crates/fold-tui/src/app/markdown.rs+49−18 crates/fold-tui/src/app/mouse.rs+52−31 crates/fold-tui/src/app/ui.rs+89−60 crates/fold-tui/src/app/wrap.rs+47−10 crates/fold-tui/tests/conflict_watch.rs+133 crates/fold-tui/tests/editor.rs+1407 crates/fold-tui/tests/fixes.rs+420 crates/fold-tui/tests/mouse.rs+563−3 crates/fold-tui/tests/tui.rs+63 crates/fold-tui/tests/vim_search.rs+61 crates/fold-tui/tests/wrap.rs+225
crates/fold-cli/tests/cli.rs+58−8
@@ -78,6 +78,8 @@ fn check_reports_and_fix_canonicalizes() {
7878#[test]
7979fn merge_processes_conflict_files() {
8080 let dir = tempfile::tempdir().unwrap();
81+ // the merged conflict file goes to a trash of the test's own (§11.5)
82+ let state = tempfile::tempdir().unwrap();
8183 std::fs::write(dir.path().join("root.md"), "# A\n\n- ours\n").unwrap();
8284 std::fs::write(
8385 dir.path().join("root.sync-conflict-20260912-100000-phone.md"),
@@ -85,6 +87,7 @@ fn merge_processes_conflict_files() {
8587 )
8688 .unwrap();
8789 notes(dir.path())
90+ .env("XDG_STATE_HOME", state.path())
8891 .args(["merge"])
8992 .assert()
9093 .success()
@@ -96,21 +99,23 @@ fn merge_processes_conflict_files() {
9699#[test]
97100fn trash_list_and_restore() {
98101 let dir = tempfile::tempdir().unwrap();
99− // put something in the trash via a delete through the core
100− std::fs::write(dir.path().join("root.md"), "# A\n\n- doomed\n").unwrap();
101− {
102− let mut v = fold_core::vault::Vault::open(dir.path()).unwrap();
103− let a = v.tree.resolved_children(v.tree.root)[0];
104− let node = v.tree.resolved_children(a)[0];
105− fold_core::ops::delete_subtree(&mut v, node).unwrap();
106− }
102+ // a trash of the test's own (§11.5), not the machine's, where an entry
103+ // left by an earlier run would make "doomed" match twice
104+ let state = tempfile::tempdir().unwrap();
105+ std::fs::write(dir.path().join("root.md"), "# A\n").unwrap();
106+ // a deleted subtree, as a delete leaves it in the trash
107+ let trash = state.path().join("fold").join("trash");
108+ std::fs::create_dir_all(&trash).unwrap();
109+ std::fs::write(trash.join("20260926-101010-doomed.md"), "- doomed\n").unwrap();
107110 notes(dir.path())
111+ .env("XDG_STATE_HOME", state.path())
108112 .args(["trash", "list"])
109113 .assert()
110114 .success()
111115 .stdout(predicate::str::contains("doomed"));
112116 // restore by a unique substring of the trash file name
113117 notes(dir.path())
118+ .env("XDG_STATE_HOME", state.path())
114119 .args(["trash", "restore", "doomed"])
115120 .assert()
116121 .success();
@@ -156,3 +161,48 @@ fn trash_restore_never_overwrites_root_md() {
156161 assert_eq!(std::fs::read_to_string(dir.path().join("root.md")).unwrap(), "# Keep\n");
157162 assert!(dir.path().join("root-restored-2.md").exists());
158163}
164+
165+/// The trash lives outside the vault (§11.5), often on another filesystem
166+/// (a Syncthing folder on an external disk vs `$XDG_STATE_HOME`). Merging
167+/// must still move the conflict file to trash (copy + remove across
168+/// filesystems), or every rerun merges the same conflict again and adds
169+/// another duplicate conflict block.
170+#[test]
171+#[cfg(target_os = "linux")]
172+fn merge_trashes_conflict_file_across_filesystems() {
173+ use std::os::unix::fs::MetadataExt;
174+ let dir = tempfile::tempdir().unwrap();
175+ let Ok(state) = tempfile::tempdir_in("/dev/shm") else {
176+ return;
177+ };
178+ let dev = |p: &std::path::Path| std::fs::metadata(p).unwrap().dev();
179+ if dev(dir.path()) == dev(state.path()) {
180+ return; // not a cross-filesystem setup on this machine
181+ }
182+ std::fs::write(dir.path().join("root.md"), "# A\n\n- [ ] t\n").unwrap();
183+ let c = "root.sync-conflict-20260912-100000-phone.md";
184+ std::fs::write(dir.path().join(c), "# A\n\n- [x] t\n").unwrap();
185+ notes(dir.path())
186+ .env("XDG_STATE_HOME", state.path())
187+ .arg("merge")
188+ .assert()
189+ .success();
190+ assert!(!dir.path().join(c).exists(), "conflict file left in the vault");
191+ let trash = state.path().join("fold").join("trash");
192+ assert_eq!(std::fs::read_dir(&trash).unwrap().count(), 1);
193+ // idempotent: a second run finds nothing to merge and adds no blocks
194+ let md_files = |d: &std::path::Path| {
195+ std::fs::read_dir(d)
196+ .unwrap()
197+ .filter(|e| e.as_ref().unwrap().path().extension().is_some_and(|x| x == "md"))
198+ .count()
199+ };
200+ let before = md_files(dir.path());
201+ notes(dir.path())
202+ .env("XDG_STATE_HOME", state.path())
203+ .arg("merge")
204+ .assert()
205+ .success()
206+ .stdout(predicate::str::contains("no sync-conflict files"));
207+ assert_eq!(md_files(dir.path()), before);
208+}
crates/fold-core/src/check.rs+66−22
@@ -1,7 +1,7 @@
11//! `notes check` diagnostics (§15.7) and `--fix` canonicalization (§4.2).
22
33use crate::ident::{filename, slug, split_filename, Id};
4−use crate::parse::{Content, Kind};
4+use crate::parse::{ends_with_blank_line, Content, Kind};
55use crate::render::render;
66use crate::tree::NRef;
77use crate::vault::Vault;
@@ -93,6 +93,28 @@ pub fn check(vault: &Vault) -> Vec<Diagnostic> {
9393 }
9494 }
9595 }
96+ // cyclic embeds (§6.2): follow each block up through the embed that
97+ // holds it until root.md, a block embedded nowhere, or the block itself
98+ for (r, id) in &t.blocks {
99+ let mut seen: Vec<&Id> = vec![id];
100+ let mut cur = id;
101+ while let Some(e) = t.embed_of(cur) {
102+ // the block whose file holds that embed; none: root.md
103+ let Some((_, holder)) = t.blocks.iter().find(|(b, _)| b.0 == e.0) else { break };
104+ if holder == id {
105+ out.push(Diagnostic {
106+ file: t.node(*r).block.as_ref().unwrap().path.clone(),
107+ message: format!("cyclic embed: block {} is embedded inside itself", id),
108+ });
109+ break;
110+ }
111+ if seen.contains(&holder) {
112+ break; // a cycle above it, reported by the blocks on it
113+ }
114+ seen.push(holder);
115+ cur = holder;
116+ }
117+ }
96118 // block files: exactly one root, valid unique ids (§4.9, §6.2)
97119 let mut seen_ids: Vec<&Id> = Vec::new();
98120 for (r, id) in &t.blocks {
@@ -198,6 +220,37 @@ fn embed_level_mismatch(t: &crate::tree::Tree, e: NRef) -> Option<usize> {
198220 .then_some(written)
199221}
200222
223+/// The text of `e`'s file with heading embed `e` at the level of its
224+/// position and the structure unchanged (§4.7). Its later sibling sections
225+/// written deeper than that level move to it too (it is their position's
226+/// level as well): left alone, the shallower embed would take them as its
227+/// children. `None` when `e` has no stale level, or when no level keeps it
228+/// where it is (its parent section is itself written at that level or
229+/// deeper), and `check` goes on reporting it.
230+fn embed_level_fix(t: &crate::tree::Tree, e: NRef) -> Option<String> {
231+ embed_level_mismatch(t, e)?;
232+ let lvl = t.derived_level(e);
233+ let f = &t.files[e.0];
234+ let parent = &f.nodes[f.nodes[e.1].parent?];
235+ if parent.kind == Kind::Section && parent.level.unwrap_or(1) >= lvl {
236+ return None;
237+ }
238+ let from = parent.children.iter().position(|&c| c == e.1)?;
239+ let mut text = f.text.clone();
240+ // last to first, so the spans before each edit stay valid
241+ for &c in parent.children[from..].iter().rev() {
242+ let n = &f.nodes[c];
243+ let Some(w) = n.level.filter(|&w| w > lvl || c == e.1) else { continue };
244+ let line = n.title_span.text(&f.text);
245+ let at = n.title_span.start + line.len() - line.trim_start_matches([' ', '\t']).len();
246+ if !f.text[at..].starts_with(&"#".repeat(w)) {
247+ return None; // not an ATX heading
248+ }
249+ text.replace_range(at..at + w, &"#".repeat(lvl));
250+ }
251+ Some(text)
252+}
253+
201254/// A heading embed must embed a section, a bare one an item (§4.7).
202255fn form_mismatch(embed: Kind, block: Kind) -> bool {
203256 (embed == Kind::Section) != (block == Kind::Section)
@@ -247,7 +300,8 @@ pub fn fix(vault: &mut Vault) -> std::io::Result<usize> {
247300 // frontmatter and intro text, exactly as found; then the root's
248301 // children in order — nodes rendered, text children verbatim,
249302 // one blank line between a node and what follows it unless both
250− // are items of a tight list (§4.2)
303+ // are items of a tight list (§4.2); a line is blank whatever its
304+ // line ending, so a CRLF file keeps its loose lists
251305 let mut s = f.text[..f.nodes[first].span.start].to_string();
252306 let root = &f.nodes[f.root_node];
253307 let from = root
@@ -259,23 +313,23 @@ pub fn fix(vault: &mut Vault) -> std::io::Result<usize> {
259313 for c in &root.content[from..] {
260314 let loose_after_prev = prev.map(|p| {
261315 let pn = &f.nodes[p];
262− pn.kind == Kind::Section || pn.span.text(&f.text).ends_with("\n\n")
316+ pn.kind == Kind::Section || ends_with_blank_line(pn.span.text(&f.text))
263317 });
264318 match *c {
265319 Content::Node(k) => {
266320 let kn = &f.nodes[k];
267321 let blank = match loose_after_prev {
268322 Some(loose) => loose || kn.kind == Kind::Section,
269− None => !s.is_empty() && !s.ends_with("\n\n"),
323+ None => !s.is_empty() && !ends_with_blank_line(&s),
270324 };
271− if blank && !s.ends_with("\n\n") {
325+ if blank && !ends_with_blank_line(&s) {
272326 s.push('\n');
273327 }
274328 s.push_str(&render(&vault.tree, (i, k), 1, false));
275329 prev = Some(k);
276330 }
277331 Content::Text(sp) => {
278− if prev.is_some() && !s.ends_with("\n\n") {
332+ if prev.is_some() && !ends_with_blank_line(&s) {
279333 s.push('\n');
280334 }
281335 let t = sp.text(&f.text);
@@ -314,25 +368,15 @@ pub fn fix(vault: &mut Vault) -> std::io::Result<usize> {
314368 crate::ops::respell_embed(vault, e, to_section)?;
315369 count += 1;
316370 }
317− // heading embeds at the level of their position (§4.7); only ever
318− // shallower than written, so no line changes parent
371+ // heading embeds at the level of their position (§4.7), one at a time,
372+ // with no line changing parent
319373 for _ in 0..10_000 {
320374 let t = &vault.tree;
321− let wrong = t.files.iter().enumerate().find_map(|(fi, f)| {
322− (0..f.nodes.len())
323− .find(|&ni| embed_level_mismatch(t, (fi, ni)).is_some())
324− .map(|ni| (fi, ni))
375+ let fixed = t.files.iter().enumerate().find_map(|(fi, f)| {
376+ (0..f.nodes.len()).find_map(|ni| embed_level_fix(t, (fi, ni)).map(|text| (fi, text)))
325377 });
326− let Some(e) = wrong else { break };
327− let n = vault.tree.node(e);
328− let line = format!(
329− "{}{} ![[{}]]",
330− " ".repeat(n.indent),
331− "#".repeat(vault.tree.derived_level(e)),
332− n.embed.as_ref().unwrap()
333− );
334− let span = n.title_span;
335− vault.write_span(e.0, span, &line)?;
378+ let Some((file, text)) = fixed else { break };
379+ vault.write_file_text(file, &text)?;
336380 count += 1;
337381 }
338382 // repair filenames (§6.4): an existing prefix that is a leading run of
crates/fold-core/src/edit.rs+635−81
@@ -8,7 +8,7 @@
88//! replaces the block's span atomically.
99
1010use crate::ident::Id;
11−use crate::parse::Kind;
11+use crate::parse::{parse_file, parse_frontmatter, Block, Content, Frontmatter, Kind, Node, ParsedFile, Span};
1212use crate::render::render_lines;
1313use crate::tree::NRef;
1414use crate::vault::Vault;
@@ -42,6 +42,17 @@ pub struct EditBuffer {
4242 /// blake3 of each file (by path) as last read or written through this
4343 /// buffer (§5.2.5).
4444 base_hashes: HashMap<String, String>,
45+ /// Nested blocks deleted by deleting their title line (§5.2): their
46+ /// lines are the enclosing block's now, and each file goes to trash
47+ /// once no file embeds it.
48+ dropped: Vec<Owner>,
49+ /// Nested blocks whose title line is in the editor's clipboard, cut
50+ /// and not put back: in transit, not deleted (`hold`).
51+ held: Vec<Owner>,
52+ /// Where each nested block's title line sat when its embed was last
53+ /// written (`title_form`), for those written since the buffer was
54+ /// built: one re-indented or re-spelled since moves the embed (§5.2).
55+ placed: BTreeMap<Owner, (usize, Option<usize>)>,
4556}
4657
4758#[derive(Debug, Clone)]
@@ -50,9 +61,14 @@ pub struct OwnerInfo {
5061 pub id: Option<Id>,
5162 /// The block-root node in the tree at buffer-build time.
5263 pub nref: NRef,
53− /// Display level/indent of the block's top node in the buffer.
64+ /// Display level/indent of the block's top node in the buffer, as
65+ /// built: a nested block's title line may be re-indented or re-spelled
66+ /// since, and splice shifts from where it sits then.
5467 pub level: usize,
5568 pub indent: usize,
69+ /// Whether that node is a section (its title line a heading), not an
70+ /// item.
71+ pub section: bool,
5672 /// Level/indent of that node in its own file; splice shifts by
5773 /// `target − display` (§5.2.3).
5874 pub target_level: usize,
@@ -60,11 +76,16 @@ pub struct OwnerInfo {
6076 /// The owner whose text holds this block's embed (None for the owner
6177 /// of the render root).
6278 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).
79+ /// The file the block is written to.
6680 pub path: String,
81+ /// For a render root that is not a block: the bytes `start..end` of its
82+ /// file that the buffer owns — its span without the blank lines after
83+ /// it — and each splice moves `end` to the end of what it wrote. Once
84+ /// its own text changes (a line above its title, the title deleted) no
85+ /// node need start where it did; the base-hash check (§5.2.5) ensures
86+ /// nothing else wrote the file since.
6787 pub start: usize,
88+ pub end: usize,
6889 pub is_root: bool,
6990}
7091
@@ -113,11 +134,13 @@ impl EditBuffer {
113134 nref,
114135 level,
115136 indent,
137+ section: n.kind == Kind::Section,
116138 target_level,
117139 target_indent,
118140 parent,
119141 path: tree.files[nref.0].path.clone(),
120142 start: n.span.start,
143+ end: owned_end(&tree.files[nref.0].text, n.span),
121144 is_root,
122145 },
123146 );
@@ -149,6 +172,9 @@ impl EditBuffer {
149172 owners,
150173 dirty: Vec::new(),
151174 base_hashes,
175+ dropped: Vec::new(),
176+ held: Vec::new(),
177+ placed: BTreeMap::new(),
152178 }
153179 }
154180
@@ -165,6 +191,22 @@ impl EditBuffer {
165191 }
166192 }
167193
194+ /// The nested blocks whose title line the editor's clipboard holds
195+ /// (§5.2: cutting a block's title line and pasting it moves the block).
196+ /// While held, a block with nothing in the buffer (no line of its own or
197+ /// of a block nested in it) is in transit: the enclosing block is
198+ /// written without its embed, and the block and its file are left alone
199+ /// until the line is pasted back. A block released with no line in the
200+ /// buffer is dirty again, so the next save deletes it, as deleting its
201+ /// title line does.
202+ pub fn hold(&mut self, owners: Vec<Owner>) {
203+ for o in std::mem::replace(&mut self.held, owners) {
204+ if !self.held.contains(&o) && !self.lines.iter().any(|l| l.owner == o) {
205+ self.mark_dirty(o);
206+ }
207+ }
208+ }
209+
168210 /// Insert a line after `idx`; it inherits that line's tag (§5.2).
169211 pub fn insert_line(&mut self, idx: usize, text: String) {
170212 let owner = if self.lines.is_empty() {
@@ -194,21 +236,16 @@ impl EditBuffer {
194236 }
195237 }
196238
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> {
239+ /// Where the block is now: its file, and for a block or a file's Root
240+ /// its node in the current tree (it may have been re-parsed since the
241+ /// buffer was built). A render root that is not a block is no node but
242+ /// the region `start..end` of its file.
243+ fn locate(&self, vault: &Vault, info: &OwnerInfo) -> Option<(usize, Option<usize>)> {
200244 if let Some(id) = &info.id {
201− return vault.tree.block_by_id(id);
245+ return vault.tree.block_by_id(id).map(|(f, n)| (f, Some(n)));
202246 }
203247 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))
248+ Some((file, info.is_root.then_some(vault.tree.files[file].root_node)))
212249 }
213250
214251 /// The owner directly nested in `owner` that `o` belongs to, if any.
@@ -222,18 +259,288 @@ impl EditBuffer {
222259 None
223260 }
224261
225− /// Splice one dirty block (§5.2): collect its lines, put nested blocks
226− /// back as embeds, shift back, write the one file atomically.
262+ /// Each nested block's embed goes to the block its title line (at
263+ /// `titles`) now sits in (§5.2), out of the block it was in or into one
264+ /// nested there, however the line got there: moved, re-indented or
265+ /// re-spelled. Of the block holding the line above the title line (the
266+ /// blank lines between nest nothing) and the blocks that one is nested
267+ /// in, the innermost whose title line holds it (`holds`). Blocks go in
268+ /// buffer order, so where a block sits is settled before the blocks
269+ /// after it.
270+ pub fn reparent(&mut self, titles: &BTreeMap<Owner, usize>) {
271+ let mut order: Vec<(usize, Owner)> = titles.iter().map(|(&o, &t)| (t, o)).collect();
272+ order.sort();
273+ for (t, o) in order {
274+ let Some(p) = self.owners.get(&o).and_then(|i| i.parent) else { continue };
275+ let lines = &self.lines[..t.min(self.lines.len())];
276+ let Some(above) = lines.iter().rev().find(|l| !l.text.trim().is_empty()) else { continue };
277+ let mut q = Some(above.owner);
278+ while let Some(c) = q {
279+ if c != o && self.nested_in(c, o).is_none() && self.holds(titles, c, o) {
280+ break;
281+ }
282+ q = self.owners.get(&c).and_then(|i| i.parent);
283+ }
284+ let Some(q) = q.filter(|&q| q != p) else { continue };
285+ if let Some(i) = self.owners.get_mut(&o) {
286+ i.parent = Some(q);
287+ }
288+ self.mark_dirty(p);
289+ self.mark_dirty(q);
290+ }
291+ }
292+
293+ /// Whether block `q`'s title line holds block `o`'s, written below its
294+ /// lines, as the parser nests title lines (§3.1): the edited node's
295+ /// block holds every line, an item what is indented past it, a section
296+ /// what is indented past it (under an item of its own) and, at its
297+ /// indent, items and deeper sections. Each title line as it is written
298+ /// now (`title_form`), not as the buffer was built: one re-indented or
299+ /// re-spelled nests where it sits (else where it was last written).
300+ fn holds(&self, titles: &BTreeMap<Owner, usize>, q: Owner, o: Owner) -> bool {
301+ if self.owners.get(&q).is_some_and(|i| i.parent.is_none()) {
302+ return true;
303+ }
304+ let form = |o: Owner| {
305+ titles
306+ .get(&o)
307+ .and_then(|&t| self.lines.get(t))
308+ .and_then(|l| title_form(&l.text))
309+ .or_else(|| self.placed(o))
310+ };
311+ let (Some((qi, qh)), Some((oi, oh))) = (form(q), form(o)) else { return false };
312+ oi > qi || (oi == qi && qh.is_some_and(|q| oh.is_none_or(|o| o > q)))
313+ }
314+
315+ /// The line where nested block `o`'s text starts (§5.2): the first title
316+ /// line of either kind at any indent named as the block is in its file
317+ /// (`file_title`), whatever lines of the block come before it, typed or
318+ /// split off in front of it; else its first line if that is a title line
319+ /// of its kind at its display indent — a heading for a section, a
320+ /// bullet for an item — or, re-indented or re-spelled, a title line of
321+ /// either kind at any indent that is not one of the block's children
322+ /// (`left_first`); else the first title line of its kind at its display
323+ /// indent below it, a heading no deeper than the block's level (deeper
324+ /// ones are its children). None: its title line was deleted.
325+ fn title_line(&self, vault: &Vault, o: Owner) -> Option<usize> {
326+ let info = self.owners.get(&o)?;
327+ let named = self.file_title(vault, o);
328+ let mut fence = None;
329+ let mut first = true;
330+ let mut found = None;
331+ for (i, l) in self.lines.iter().enumerate().filter(|(_, l)| l.owner == o) {
332+ let in_code = fence_transition(&l.text, &mut fence) || fence.is_some();
333+ if let Some((indent, heading)) = title_form(&l.text).filter(|_| !in_code) {
334+ if named == Some(after_marker(&l.text)) {
335+ return Some(i);
336+ }
337+ let own = indent == info.indent && heading.is_some() == info.section;
338+ let title = (first && (own || !self.left_first(vault, o, &l.text)))
339+ || (own && heading.is_none_or(|h| h <= info.level.max(1)));
340+ if title && found.is_none() {
341+ found = Some(i);
342+ }
343+ }
344+ first = false;
345+ }
346+ found
347+ }
348+
349+ /// Nested block `o`'s title in its file as last read or written, after
350+ /// its marker (`after_marker`).
351+ fn file_title<'v>(&self, vault: &'v Vault, o: Owner) -> Option<&'v str> {
352+ let (file, root) = self.owners.get(&o).and_then(|i| self.locate(vault, i))?;
353+ let f = &vault.tree.files[file];
354+ let n = f.nodes.get(root?)?;
355+ Some(after_marker(&f.text[n.title_span.start..n.title_span.end.min(f.text.len())]))
356+ }
357+
358+ /// Whether `line`, first of block `o`'s lines, is the title line of one
359+ /// of its children, not its own: a node below its title in its file as
360+ /// last read or written, not named as the block is. So deleting a
361+ /// block's title line, which may leave a child's first, deletes it
362+ /// (§5.2), while its title line re-indented stays its title.
363+ fn left_first(&self, vault: &Vault, o: Owner, line: &str) -> bool {
364+ let Some((file, Some(root))) = self.owners.get(&o).and_then(|i| self.locate(vault, i)) else {
365+ return false;
366+ };
367+ let f = &vault.tree.files[file];
368+ let title = |n: &Node| after_marker(&f.text[n.title_span.start..n.title_span.end.min(f.text.len())]);
369+ let t = after_marker(line);
370+ t != title(&f.nodes[root])
371+ && f.nodes.iter().enumerate().any(|(i, n)| {
372+ i != root && n.kind != Kind::Root && !n.is_embed() && title(n) == t
373+ })
374+ }
375+
376+ /// Where nested block `o`'s embed was last written: its title line's
377+ /// indent and heading level (`title_form`), as built or since saved.
378+ fn placed(&self, o: Owner) -> Option<(usize, Option<usize>)> {
379+ self.placed.get(&o).copied().or_else(|| {
380+ let i = self.owners.get(&o)?;
381+ Some((i.indent, i.section.then_some(i.level)))
382+ })
383+ }
384+
385+ /// §5.2's edge cases, applied from the tags before a splice. A nested
386+ /// block's text starts at its title line: lines it owns above that line
387+ /// sit in the enclosing block and become its text. Deleting the title
388+ /// line deletes the block: every line it still owned is re-tagged to
389+ /// the enclosing block, the blocks nested in it are nested in that
390+ /// block, and its file goes to trash once that block is written without
391+ /// its embed — unless it is in transit, its title line cut (`hold`).
392+ /// Re-indenting or re-spelling the title line moves the block: the
393+ /// enclosing block is dirty, its embed written where the line now sits,
394+ /// in the block that line sits in (`reparent`).
395+ fn settle(&mut self, vault: &Vault) {
396+ // lines of a deleted block put back (by the editor's own undo) are
397+ // the enclosing block's text like the rest of them
398+ let dropped = self.dropped.clone();
399+ self.dirty.retain(|o| !dropped.contains(o));
400+ for i in 0..self.lines.len() {
401+ let s = self.surviving(self.lines[i].owner);
402+ if s != self.lines[i].owner {
403+ self.lines[i].owner = s;
404+ self.mark_dirty(s);
405+ }
406+ }
407+ // and so are the blocks nested in it, whatever parents the editor's
408+ // undo put back: no embed is written into a deleted block
409+ let parents: Vec<(Owner, Owner)> = self
410+ .owners
411+ .iter()
412+ .filter_map(|(&o, i)| Some((o, i.parent?)))
413+ .filter(|&(o, p)| !dropped.contains(&o) && self.surviving(p) != p)
414+ .collect();
415+ for (o, p) in parents {
416+ let s = self.surviving(p);
417+ if let Some(i) = self.owners.get_mut(&o) {
418+ i.parent = Some(s);
419+ }
420+ self.mark_dirty(s);
421+ }
422+ loop {
423+ let mut changed = false;
424+ for o in self.dirty.clone() {
425+ let Some(parent) = self.owners.get(&o).and_then(|i| i.parent) else {
426+ continue;
427+ };
428+ let title = self.title_line(vault, o);
429+ let form = title.and_then(|t| title_form(&self.lines[t].text));
430+ if form.is_some() && form != self.placed(o) && !self.dirty.contains(&parent) {
431+ self.mark_dirty(parent);
432+ changed = true;
433+ }
434+ // a title line emptied to be typed again (vim `cc`) is not
435+ // a deleted one; splice refuses to write the block meanwhile
436+ let first = self.lines.iter().find(|l| l.owner == o);
437+ if title.is_none() && first.is_some_and(|l| l.text.trim().is_empty()) {
438+ continue;
439+ }
440+ // one whose title line the clipboard holds, with nothing of
441+ // it or of the blocks in it left here, is in transit (`hold`)
442+ let transit = first.is_none()
443+ && self.held.contains(&o)
444+ && !self.lines.iter().any(|l| self.nested_in(l.owner, o).is_some());
445+ let mut moved = title.is_none();
446+ let above = title.unwrap_or(self.lines.len());
447+ for l in self.lines[..above].iter_mut().filter(|l| l.owner == o) {
448+ l.owner = parent;
449+ moved = true;
450+ }
451+ if title.is_none() {
452+ self.dirty.retain(|d| *d != o);
453+ }
454+ if title.is_none() && !transit {
455+ for i in self.owners.values_mut() {
456+ if i.parent == Some(o) {
457+ i.parent = Some(parent);
458+ }
459+ }
460+ self.dropped.push(o);
461+ }
462+ if moved {
463+ self.mark_dirty(parent);
464+ changed = true;
465+ }
466+ }
467+ if !changed {
468+ break;
469+ }
470+ }
471+ // and each goes to the block its title line now sits in, so no
472+ // embed is written nested under another's line (§4.7)
473+ let titles: BTreeMap<Owner, usize> = self
474+ .owners
475+ .iter()
476+ .filter(|(_, i)| i.parent.is_some())
477+ .filter_map(|(&o, _)| Some((o, self.title_line(vault, o)?)))
478+ .collect();
479+ self.reparent(&titles);
480+ }
481+
482+ /// The block that holds a deleted block's lines now.
483+ fn surviving(&self, mut o: Owner) -> Owner {
484+ while self.dropped.contains(&o) {
485+ match self.owners.get(&o).and_then(|i| i.parent) {
486+ Some(p) => o = p,
487+ None => break,
488+ }
489+ }
490+ o
491+ }
492+
493+ /// Move deleted blocks' files to the trash (§11.5) once no file embeds
494+ /// them any more: once the block that held the embed has been written.
495+ /// That is the block its lines went to, and it must be saved (not
496+ /// dirty, its save not refused) with its file on disk as written: a
497+ /// file another program changed may embed the block again.
498+ fn trash_dropped(&self, vault: &mut Vault) -> std::io::Result<()> {
499+ loop {
500+ let file = self.dropped.iter().find_map(|o| {
501+ let id = self.owners.get(o)?.id.as_ref()?;
502+ let s = self.surviving(*o);
503+ if self.dirty.contains(&s) || vault.tree.embed_of(id).is_some() {
504+ return None;
505+ }
506+ let (holder, _) = self.locate(vault, self.owners.get(&s)?)?;
507+ vault.check_unchanged(holder).ok()?;
508+ vault.tree.block_by_id(id).map(|r| r.0)
509+ });
510+ match file {
511+ Some(f) => vault.trash_file(f)?,
512+ None => return Ok(()),
513+ }
514+ }
515+ }
516+
517+ /// Splice one dirty block (§5.2) — or, if deleting its title line
518+ /// deleted it, the block its lines went to — then trash the files of
519+ /// deleted blocks no longer embedded.
227520 pub fn splice(&mut self, vault: &mut Vault, owner: Owner) -> std::io::Result<()> {
521+ self.settle(vault);
522+ let owner = self.surviving(owner);
523+ self.write(vault, owner)?;
524+ self.trash_dropped(vault)
525+ }
526+
527+ /// Write one block (§5.2): collect its lines, put nested blocks back as
528+ /// embeds, shift back, write the one file atomically. False: there was
529+ /// nothing to write, and the block is no longer dirty either.
530+ fn write(&mut self, vault: &mut Vault, owner: Owner) -> std::io::Result<bool> {
228531 let info = match self.owners.get(&owner) {
229532 Some(i) => i.clone(),
230− None => return Ok(()),
533+ None => {
534+ self.dirty.retain(|o| *o != owner);
535+ return Ok(false);
536+ }
231537 };
232538 // Collect the block's owned lines in buffer order; where a block
233539 // nested in it starts — whatever file it lives in — emit its embed
234540 // (§5.2.1).
235541 let mut text_lines: Vec<Line> = Vec::new();
236542 let mut emitted: Vec<Owner> = Vec::new();
543+ let mut forms = Vec::new();
237544 for l in &self.lines {
238545 if l.owner == owner {
239546 text_lines.push(Line::Text(l.text.clone()));
@@ -245,29 +552,50 @@ impl EditBuffer {
245552 // a heading embed for a heading (§4.7)
246553 let trimmed = l.text.trim_start_matches(' ');
247554 let indent = l.text.len() - trimmed.len();
248− let hashes = trimmed.chars().take_while(|&c| c == '#').count();
249− let is_heading = hashes > 0 && trimmed[hashes..].starts_with(' ');
555+ let form = title_form(&l.text);
556+ let hashes = form.and_then(|f| f.1);
557+ let respelled = form.is_some_and(|f| {
558+ Some(f.1.is_some()) != self.placed(nested).map(|p| p.1.is_some())
559+ });
250560 // an existing embed keeps its form, so an unchanged
251− // splice writes it back byte for byte
561+ // splice writes it back byte for byte; a title line
562+ // re-spelled since it was written changes it
252563 let heading = match vault.tree.embed_of(&id) {
564+ _ if respelled => hashes,
253565 Some(e) if vault.tree.node(e).kind == Kind::Section => {
254− Some(if is_heading { hashes } else { info.level + 1 })
566+ Some(hashes.unwrap_or(info.level + 1))
255567 }
256568 Some(_) => None,
257− None => is_heading.then_some(hashes),
569+ None => hashes,
258570 };
259571 text_lines.push(Line::Embed(indent, heading, id));
572+ if let Some(f) = form {
573+ forms.push((nested, f));
574+ }
260575 }
261576 }
262577 }
263578 }
264579 if text_lines.is_empty() {
265− return Ok(());
580+ // every line of the edited node gone: the editor does not delete
581+ // it (a nested block with no lines left was deleted by settle)
582+ self.dirty.retain(|o| *o != owner);
583+ return Ok(false);
266584 }
267585 // Shift back from the display position to the block's position in
268− // its file (§5.2.3).
269− let level_delta = info.target_level as isize - info.level as isize;
270− let indent_delta = info.target_indent as isize - info.indent as isize;
586+ // its file (§5.2.3). A nested block's display position is where its
587+ // title line sits now, re-indented or re-spelled or not: its root
588+ // goes to column 0, a heading to its file's level (§4.9)
589+ let title = info.parent.and_then(|_| self.title_line(vault, owner));
590+ let (level, indent, target_level) = match title.and_then(|t| title_form(&self.lines[t].text)) {
591+ Some((indent, Some(h))) => (h, indent, if info.section { info.target_level } else { 1 }),
592+ // an item shows at its section's level, one above a section's
593+ Some((indent, None)) if info.section => (info.level.saturating_sub(1).max(1), indent, 1),
594+ Some((indent, None)) => (info.level, indent, info.target_level),
595+ None => (info.level, info.indent, info.target_level),
596+ };
597+ let level_delta = target_level as isize - level as isize;
598+ let indent_delta = info.target_indent as isize - indent as isize;
271599 let shift = |cols: usize| (cols as isize + indent_delta).max(0) as usize;
272600 let mut out = String::new();
273601 let mut fence: Option<(char, usize)> = None;
@@ -308,28 +636,54 @@ impl EditBuffer {
308636 while out.ends_with("\n\n") {
309637 out.pop();
310638 }
311− let nref = self.locate(vault, &info).ok_or_else(|| {
312− std::io::Error::new(
313− std::io::ErrorKind::NotFound,
314− format!("{}: edited node no longer found", info.path),
315− )
316− })?;
317− let file = nref.0;
639+ self.rebase(vault, owner)?;
640+ let info = self.owners.get(&owner).cloned().unwrap_or(info);
641+ let (file, node) = self.locate(vault, &info).ok_or_else(|| not_found(&info))?;
318642 let path = vault.tree.files[file].path.clone();
319− // External-change check (§5.2.5): what is on disk now must be what
320− // was read (or last written) through this buffer.
321− let on_disk = std::fs::read_to_string(vault.dir.join(&path)).unwrap_or_default();
322− if self.base_hashes.get(&path).map(|h| *h != hash(&on_disk)).unwrap_or(false) {
323− return Err(std::io::Error::new(
324− std::io::ErrorKind::Other,
325− format!("{} changed on disk; not overwriting", path),
326− ));
327− }
643+ let region = Span {
644+ start: info.start,
645+ end: info.end,
646+ };
328647 let f = &vault.tree.files[file];
329− let n = &f.nodes[nref.1];
330− if n.is_block() || n.kind == Kind::Root {
648+ // §4.9: a block file with text or an embed before its root is
649+ // read-only until fixed — the bytes before the root are not in the
650+ // buffer, and rewriting the file would drop them
651+ if malformed_block_file(f) {
652+ return Err(std::io::Error::other(format!(
653+ "{}: text or an embed before the block's root; read-only until fixed",
654+ path
655+ )));
656+ }
657+ // §4.7: an embed line has no lines nested under it, where the tree
658+ // does not see them. A line the parser would nest there (the block's
659+ // own line indented under a nested block's title line, or after one
660+ // re-spelled as a heading) is refused, not hidden, unless the file
661+ // had it so already
662+ let crowded = crowded_embeds(f);
663+ if crowded_embeds(&parse_file(&path, &out, file, None)).iter().any(|id| !crowded.contains(id)) {
664+ return Err(std::io::Error::other(format!(
665+ "{}: a line would be nested under a block's embed, out of the outline; not saved",
666+ path
667+ )));
668+ }
669+ if let Some(ni) = node {
670+ // §5.2 step 2: a block's text parses to exactly one root-level
671+ // node, so splice never writes a block file it would then treat
672+ // as read-only, or one with no root at all
673+ if let Some(b) = f.nodes[ni].block.as_ref().filter(|b| b.id.is_some()) {
674+ let b = Block {
675+ frontmatter_span: None,
676+ ..b.clone()
677+ };
678+ if malformed_block_file(&parse_file(&path, &out, file, Some(b))) {
679+ return Err(std::io::Error::other(format!(
680+ "{}: the block's text must start with its title line; not saved",
681+ path
682+ )));
683+ }
684+ }
331685 // the block IS the file: keep its frontmatter, replace the rest
332− let fm_end = n
686+ let fm_end = f.nodes[ni]
333687 .block
334688 .as_ref()
335689 .and_then(|b| b.frontmatter_span)
@@ -341,31 +695,134 @@ impl EditBuffer {
341695 new_text.push_str(&out);
342696 vault.write_file_text(file, &new_text)?;
343697 } else {
344− // a span inside a file: keep the blank lines that separate it
345− // from what follows
346− let old = n.span.text(&f.text);
347− let content_end = old.trim_end_matches([' ', '\t', '\r', '\n']).len();
348− let rest = &old[content_end..];
349− let sep = rest.find('\n').map(|i| &rest[i + 1..]).unwrap_or("");
350− let replacement = format!("{}{}", out, sep);
351− vault.write_span(file, n.span, &replacement)?;
698+ // a region inside a file: the blank lines that separate it from
699+ // what follows stay
700+ vault.write_span(file, region, &out)?;
701+ if let Some(i) = self.owners.get_mut(&owner) {
702+ i.start = region.start;
703+ i.end = region.start + out.len();
704+ }
352705 }
353706 self.base_hashes
354707 .insert(path, hash(&vault.tree.files[file].text));
355708 self.dirty.retain(|o| *o != owner);
709+ self.placed.extend(forms);
710+ Ok(true)
711+ }
712+
713+ /// External-change check (§5.2.5) for one block: the block's source
714+ /// span on disk must be what was read (or last written) through this
715+ /// buffer. If the file changed underneath and, unless it was reloaded
716+ /// since, the vault still holds it as this buffer last read or wrote it,
717+ /// but the block's own text is unchanged, the change was elsewhere
718+ /// (another section, a property): the file as it is now is taken as
719+ /// what the buffer has read, the change kept. Otherwise the save is
720+ /// refused.
721+ fn rebase(&mut self, vault: &mut Vault, owner: Owner) -> std::io::Result<()> {
722+ let Some(info) = self.owners.get(&owner).cloned() else {
723+ return Ok(());
724+ };
725+ let (file, node) = self.locate(vault, &info).ok_or_else(|| not_found(&info))?;
726+ let path = vault.tree.files[file].path.clone();
727+ let on_disk = std::fs::read_to_string(vault.dir.join(&path)).unwrap_or_default();
728+ let Some(base) = self.base_hashes.get(&path).filter(|h| **h != hash(&on_disk)) else {
729+ return Ok(());
730+ };
731+ let region = Span {
732+ start: info.start,
733+ end: info.end,
734+ };
735+ let known = &vault.tree.files[file].text;
736+ let span = (*base == hash(known))
737+ .then(|| span_now(known, &on_disk, node.is_none().then_some(region)))
738+ .flatten();
739+ let Some(span) = span else {
740+ return Err(std::io::Error::other(format!(
741+ "{} changed on disk; not overwriting",
742+ path
743+ )));
744+ };
745+ // the vault writes over the file as it is now (its write guard
746+ // compares with it)
747+ vault.reparse(file, &on_disk)?;
748+ self.base_hashes.insert(path, hash(&on_disk));
749+ if node.is_none() {
750+ if let Some(i) = self.owners.get_mut(&owner) {
751+ (i.start, i.end) = (span.start, span.end);
752+ }
753+ }
356754 Ok(())
357755 }
358756
757+ /// Take in what another program changed outside the dirty blocks'
758+ /// source spans (§5.2.5) before they are saved, so that the vault, and
759+ /// a snapshot taken of it for the op-log (§10.10), holds that change:
760+ /// undoing the save then does not revert it. A block whose own span
761+ /// changed is left for its save to refuse.
762+ pub fn rebase_dirty(&mut self, vault: &mut Vault) {
763+ self.settle(vault);
764+ for owner in self.dirty.clone() {
765+ let _ = self.rebase(vault, owner);
766+ }
767+ }
768+
769+ /// Leaving the editor without saving (*Revert*, §10.6): the unsaved
770+ /// text is dropped, not written, but a block a save took out of the
771+ /// block it was in — its title line cut and the parent written in
772+ /// transit, whether the clipboard still holds it or has let it go since
773+ /// (it has no line left here), or its title line deleted and its file
774+ /// not trashed yet — can no longer be pasted back as itself. It is
775+ /// deleted as on leaving the editor any other way (§5.2): its file goes
776+ /// to trash once no file embeds it, never left in the vault embedded
777+ /// nowhere. `vault` must hold the files as they are on disk.
778+ pub fn discard(&self, vault: &mut Vault) -> std::io::Result<()> {
779+ let lineless: Vec<Owner> = self
780+ .owners
781+ .iter()
782+ .filter(|&(o, i)| i.parent.is_some() && !self.lines.iter().any(|l| l.owner == *o))
783+ .map(|(&o, _)| o)
784+ .collect();
785+ loop {
786+ let file = self.held.iter().chain(&self.dropped).chain(&lineless).find_map(|o| {
787+ let id = self.owners.get(o)?.id.as_ref()?;
788+ match vault.tree.embed_of(id) {
789+ Some(_) => None,
790+ None => vault.tree.block_by_id(id).map(|r| r.0),
791+ }
792+ });
793+ match file {
794+ Some(f) => vault.trash_file(f)?,
795+ None => return Ok(()),
796+ }
797+ }
798+ }
799+
359800 /// Splice every dirty block (§10.6: a commit may write several files,
360− /// one splice per dirty block).
801+ /// one splice per dirty block). Each block is written by its own splice
802+ /// (§5.2): one that is refused stays dirty, the others are still
803+ /// written, and the error names every refusal.
361804 pub fn save_all(&mut self, vault: &mut Vault) -> std::io::Result<usize> {
805+ self.settle(vault);
362806 let dirty = self.dirty.clone();
363807 let mut n = 0;
808+ let mut errs = Vec::new();
364809 for owner in dirty {
365− self.splice(vault, owner)?;
366− n += 1;
810+ match self.write(vault, owner) {
811+ Ok(true) => n += 1,
812+ Ok(false) => {}
813+ Err(e) => errs.push(e),
814+ }
815+ }
816+ if let Err(e) = self.trash_dropped(vault) {
817+ errs.push(e);
818+ }
819+ match errs.len() {
820+ 0 => Ok(n),
821+ 1 => Err(errs.remove(0)),
822+ _ => Err(std::io::Error::other(
823+ errs.iter().map(|e| e.to_string()).collect::<Vec<_>>().join("; "),
824+ )),
367825 }
368− Ok(n)
369826 }
370827}
371828
@@ -377,34 +834,131 @@ enum Line {
377834 Embed(usize, Option<usize>, Id),
378835}
379836
837+/// A title line's form (§4.3): its indent, and its level if it is a
838+/// heading rather than a bullet. None: not a title line.
839+fn title_form(line: &str) -> Option<(usize, Option<usize>)> {
840+ let trimmed = line.trim_start_matches(' ');
841+ let indent = line.len() - trimmed.len();
842+ let hashes = trimmed.chars().take_while(|&c| c == '#').count();
843+ let after = &trimmed[hashes..];
844+ if hashes > 0 && (after.is_empty() || after.starts_with(' ')) {
845+ return Some((indent, Some(hashes)));
846+ }
847+ let bullet = ["- ", "* ", "+ "].iter().any(|m| trimmed.starts_with(m))
848+ || ["-", "*", "+"].contains(&trimmed);
849+ bullet.then_some((indent, None))
850+}
851+
852+/// A title line's text after its indent and its heading or bullet marker,
853+/// whichever form and level it is written in.
854+fn after_marker(line: &str) -> &str {
855+ let t = line.trim_start_matches([' ', '\t']);
856+ let t = match t.strip_prefix(['-', '*', '+']) {
857+ Some(rest) => rest,
858+ None => t.trim_start_matches('#'),
859+ };
860+ t.trim()
861+}
862+
863+fn not_found(info: &OwnerInfo) -> std::io::Error {
864+ std::io::Error::new(
865+ std::io::ErrorKind::NotFound,
866+ format!("{}: edited node no longer found", info.path),
867+ )
868+}
869+
380870fn hash(text: &str) -> String {
381871 blake3::hash(text.as_bytes()).to_hex().to_string()
382872}
383873
384−fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
385− let t = raw.trim_start();
386− let first = match t.chars().next() {
387− Some(c) if c == '`' || c == '~' => c,
388− _ => return false,
389− };
390− let count = t.chars().take_while(|&c| c == first).count();
391− if count < 3 {
392− return false;
874+/// Where a block's source span (§5.2 step 5) is in `now`, a newer text of
875+/// its file than `known`, when the block's own text there is as it was:
876+/// `None` if the change touched it, or if the frontmatter names another
877+/// block now. A block (or root.md's Root) is its file after the
878+/// frontmatter (§4.9); another render root is `region` of `known`, whole
879+/// lines, followed through a line diff of `known` against `now`: its lines
880+/// must all be lines the diff keeps, in one unchanged run. Never where its
881+/// bytes merely occur in `now`: an identical node elsewhere is another
882+/// node.
883+fn span_now(known: &str, now: &str, region: Option<Span>) -> Option<Span> {
884+ let (k, n) = (parse_frontmatter(known), parse_frontmatter(now));
885+ let id = |f: &Option<Frontmatter>| f.as_ref().and_then(|f| f.props.get("id").cloned());
886+ if id(&k) != id(&n) {
887+ return None;
393888 }
394− match open {
395− None => {
396− *open = Some((first, count));
397− true
398− }
399− Some((c, n)) => {
400− if *c == first && count >= *n {
401− *open = None;
402− true
403− } else {
404− false
405− }
889+ let Some(region) = region else {
890+ let body = |f: &Option<Frontmatter>| f.as_ref().map_or(0, |f| f.span.end);
891+ let (kb, nb) = (body(&k), body(&n));
892+ return (known[kb..] == now[nb..]).then_some(Span { start: nb, end: now.len() });
893+ };
894+ let diff = similar::TextDiff::from_lines(known, now);
895+ // the byte offset where each line starts, and the text's end
896+ let starts = |lines: &[&str]| {
897+ let mut at = vec![0];
898+ for l in lines {
899+ at.push(at[at.len() - 1] + l.len());
406900 }
901+ at
902+ };
903+ let (old, new) = (starts(diff.old_slices()), starts(diff.new_slices()));
904+ let line = |b: usize| old.binary_search(&b.min(known.len())).ok();
905+ let (first, end) = (line(region.start)?, line(region.end)?);
906+ diff.ops().iter().find_map(|op| match *op {
907+ similar::DiffOp::Equal { old_index, new_index, len } if old_index <= first && end <= old_index + len => {
908+ Some(Span {
909+ start: new[new_index + first - old_index],
910+ end: new[new_index + end - old_index],
911+ })
912+ }
913+ _ => None,
914+ })
915+}
916+
917+/// A block file with text or an embed before its root node, or with no
918+/// root at all (§4.9). `root.md` never is: text before its first node is
919+/// its Root's own.
920+fn malformed_block_file(f: &ParsedFile) -> bool {
921+ let root = &f.nodes[f.root_node];
922+ if root.block.is_some() {
923+ return false;
407924 }
925+ let text_before = root.content.iter().any(|c| match c {
926+ Content::Text(sp) => !sp.text(&f.text).trim().is_empty(),
927+ Content::Node(_) => false,
928+ });
929+ text_before || root.children.len() != 1 || f.nodes[root.children[0]].is_embed()
930+}
931+
932+/// The blocks whose embed in `f` has lines nested under it: nodes, or text
933+/// other than blank lines (§4.7: a diagnostic).
934+fn crowded_embeds(f: &ParsedFile) -> Vec<&Id> {
935+ f.nodes
936+ .iter()
937+ .filter(|n| {
938+ n.content.iter().any(|c| match c {
939+ Content::Text(sp) => !sp.text(&f.text).trim().is_empty(),
940+ Content::Node(_) => true,
941+ })
942+ })
943+ .filter_map(|n| n.embed.as_ref())
944+ .collect()
945+}
946+
947+/// Where a node's own text ends: its span without the blank lines that
948+/// separate it from what follows.
949+fn owned_end(text: &str, span: Span) -> usize {
950+ let old = &text[span.start.min(text.len())..span.end.min(text.len())];
951+ let content_end = old.trim_end_matches([' ', '\t', '\r', '\n']).len();
952+ let own = old[content_end..]
953+ .find('\n')
954+ .map(|i| content_end + i + 1)
955+ .unwrap_or(old.len());
956+ span.start + own
957+}
958+
959+fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
960+ // fences as the parser reads them, so splice re-levels what it does
961+ crate::parse::fence_transition(raw, open)
408962}
409963
410964/// What the editor needs to know when it opens: the buffer plus a render of
crates/fold-core/src/merge.rs+131−80
@@ -101,11 +101,7 @@ fn standalone_tree(text: &str) -> Tree {
101101 frontmatter_span: fm.as_ref().map(|f| f.span),
102102 };
103103 let pf = parse_file("m.md", text, 0, Some(block));
104− Tree {
105− files: vec![pf],
106− root: (0, 0),
107− blocks: vec![],
108− }
104+ Tree::new(vec![pf])
109105}
110106
111107fn node_sig(t: &Tree, r: NRef) -> (String, Option<TaskState>, String) {
@@ -234,9 +230,10 @@ fn merge_children(
234230) -> String {
235231 let o_kids = o.resolved_children(op);
236232 let t_kids = t.resolved_children(tp);
237− // match: embed by id, else by exact title among unmatched siblings
238− let mut t_used = vec![false; t_kids.len()];
239− let mut pairs: Vec<(Option<NRef>, Option<NRef>)> = Vec::new();
233+ // match: embed by id, else by exact title among unmatched siblings;
234+ // `t_match[i]` is the O kid that T kid `i` matched
235+ let mut t_match: Vec<Option<usize>> = vec![None; t_kids.len()];
236+ let mut pairs: Vec<(NRef, Option<NRef>)> = Vec::new();
240237 // a block file's root is the same node on both sides, whatever its
241238 // title: match it by the file's id (§12.4 rule 1)
242239 let file_id = |tr: &Tree, r: NRef| tr.node(r).block.as_ref().and_then(|b| b.id.clone());
@@ -246,18 +243,18 @@ fn merge_children(
246243 }
247244 _ => None,
248245 };
249− for &ok in &o_kids {
246+ for (j, &ok) in o_kids.iter().enumerate() {
250247 let on = o.node(ok);
251248 if let Some((rok, rtk)) = root_pair {
252249 if ok == rok {
253− t_used[0] = true;
254− pairs.push((Some(ok), Some(rtk)));
250+ t_match[0] = Some(j);
251+ pairs.push((ok, Some(rtk)));
255252 continue;
256253 }
257254 }
258255 let mut found = None;
259256 for (i, &tk) in t_kids.iter().enumerate() {
260− if t_used[i] {
257+ if t_match[i].is_some() {
261258 continue;
262259 }
263260 let tn = t.node(tk);
@@ -274,21 +271,40 @@ fn merge_children(
274271 }
275272 }
276273 if let Some(i) = found {
277− t_used[i] = true;
278− pairs.push((Some(ok), Some(t_kids[i])));
279− } else {
280− pairs.push((Some(ok), None));
281− }
282− }
274+ t_match[i] = Some(j);
275+ }
276+ pairs.push((ok, found.map(|i| t_kids[i])));
277+ }
278+ // T-only nodes are insertions, placed relative to their matched
279+ // neighbours (§12.4): after the O partner of the nearest matched node
280+ // before them in T — and after O's text there too, when T has text
281+ // between the two — or first, when no matched node precedes them
282+ let t_trailing = trailing_runs(t, tp);
283+ let mut first: Vec<NRef> = Vec::new();
284+ let mut after_node: Vec<Vec<NRef>> = vec![Vec::new(); o_kids.len()];
285+ let mut after_text: Vec<Vec<NRef>> = vec![Vec::new(); o_kids.len()];
286+ let mut anchor: Option<(usize, usize)> = None; // (T kid, its O kid)
283287 for (i, &tk) in t_kids.iter().enumerate() {
284− if !t_used[i] {
285− pairs.push((None, Some(tk)));
288+ match (t_match[i], anchor) {
289+ (Some(j), _) => anchor = Some((i, j)),
290+ (None, None) => first.push(tk),
291+ (None, Some((ti, j))) => {
292+ let text_between = t_trailing
293+ .iter()
294+ .any(|&(pos, sp)| (ti..i).contains(&pos) && !run_lines(t, tp, sp).is_empty());
295+ if text_between {
296+ after_text[j].push(tk);
297+ } else {
298+ after_node[j].push(tk);
299+ }
300+ }
286301 }
287302 }
303+ ctx.insertions += t_match.iter().filter(|m| m.is_none()).count();
304+ let inserted = |&b: &NRef| Piece::Node(t.node(b).kind, emit_subtree(t, b, level, indent));
288305 // Emit: matched pairs merge field-by-field; single-sided nodes are
289306 // insertions; differing fields raise conflict pairs (§12.4). O's text
290− // children stay where O has them; T-only nodes are placed by the
291− // ordering rule (§3.1): items before the first section, sections last.
307+ // children stay where O has them.
292308 let mut pieces: Vec<Piece> = Vec::new();
293309 if o.node(op).kind == Kind::Root {
294310 // the root's own text: O's, plus T's when it differs (never lost)
@@ -302,42 +318,27 @@ fn merge_children(
302318 }
303319 }
304320 }
321+ pieces.extend(first.iter().map(inserted));
305322 let o_trailing = trailing_runs(o, op);
306− let mut t_items: Vec<Piece> = Vec::new();
307− let mut t_sections: Vec<Piece> = Vec::new();
308− for (ok, tk) in pairs {
309− match (ok, tk) {
310− (Some(a), tk) => {
311− let text = match tk {
312− Some(b) => merge_pair(o, t, a, b, ctx, level, indent),
313− None => emit_subtree(o, a, level, indent),
314− };
315− pieces.push(Piece::Node(o.node(a).kind, text));
316− let j = o_kids.iter().position(|&k| k == a).unwrap_or(usize::MAX);
317− for (_, sp) in o_trailing.iter().filter(|(pos, _)| *pos == j) {
318− pieces.push(Piece::Text(emit_text(o, op, *sp, indent)));
319− }
320− }
321− (None, Some(b)) => {
322− ctx.insertions += 1;
323− let piece = Piece::Node(t.node(b).kind, emit_subtree(t, b, level, indent));
324− if t.node(b).kind == Kind::Section {
325− t_sections.push(piece);
326− } else {
327− t_items.push(piece);
328− }
329− }
330− (None, None) => {}
323+ for (j, (a, tk)) in pairs.into_iter().enumerate() {
324+ let text = match tk {
325+ Some(b) => merge_pair(o, t, a, b, ctx, level, indent),
326+ None => emit_subtree(o, a, level, indent),
327+ };
328+ pieces.push(Piece::Node(o.node(a).kind, text));
329+ pieces.extend(after_node[j].iter().map(inserted));
330+ for (_, sp) in o_trailing.iter().filter(|(pos, _)| *pos == j) {
331+ pieces.push(Piece::Text(emit_text(o, op, *sp, indent)));
331332 }
333+ pieces.extend(after_text[j].iter().map(inserted));
332334 }
333− let first_section = pieces
334− .iter()
335− .position(|p| matches!(p, Piece::Node(Kind::Section, _)))
336− .unwrap_or(pieces.len());
337− let tail = pieces.split_off(first_section);
338− pieces.extend(t_items);
339− pieces.extend(tail);
340− pieces.extend(t_sections);
335+ // clamped by the ordering rule (§3.1): an inserted item or text goes no
336+ // later than just before the first section, an inserted section no
337+ // earlier than just after the last item; O's own order already obeys it
338+ let (mut pieces, sections): (Vec<Piece>, Vec<Piece>) = pieces
339+ .into_iter()
340+ .partition(|p| !matches!(p, Piece::Node(Kind::Section, _)));
341+ pieces.extend(sections);
341342 join_pieces(pieces)
342343}
343344
@@ -433,6 +434,12 @@ fn emit_node_with(o: &Tree, a: NRef, level: usize, indent: usize, kids: &str) ->
433434 let mut out = String::new();
434435 let ind = " ".repeat(indent);
435436 match n.kind {
437+ // embeds stay unresolved here (§12.4): the line is the reference
438+ // itself, in its own form (§4.7), never an empty title
439+ Kind::Section | Kind::Item if n.embed.is_some() => {
440+ out.push_str(&crate::render::embed_line(n, level, indent));
441+ out.push('\n');
442+ }
436443 Kind::Section => {
437444 out.push_str(&ind);
438445 out.push_str(&"#".repeat(level.max(1)));
@@ -538,17 +545,51 @@ pub fn merge_sync_conflicts(vault: &mut Vault, dry_run: bool) -> std::io::Result
538545 let base = cfile.split(".sync-conflict-").next().unwrap().to_string() + ".md";
539546 let cpath = vault.dir.join(&cfile);
540547 let bpath = vault.dir.join(&base);
548+ // only root.md and block files are ours to merge: the conflict copy
549+ // of an ignored file is left alone, like the file (§4.1, §11.4)
550+ let parsed = base == "root.md" || vault.file_index(&base).is_some();
551+ if !parsed && bpath.exists() {
552+ outcomes.push(format!("{}: {} is not a vault file, left alone", cfile, base));
553+ continue;
554+ }
541555 let theirs = std::fs::read_to_string(&cpath)?;
542− let ours = std::fs::read_to_string(&bpath).unwrap_or_default();
556+ let tid = crate::parse::parse_frontmatter(&theirs)
557+ .and_then(|f| f.props.get("id").cloned())
558+ .and_then(|v| Id::parse(&v));
559+ // X.md gone: a block renamed since is still found by its id (§6.4)
560+ let bpath = match tid.as_ref().and_then(|id| vault.tree.block_by_id(id)) {
561+ Some(r) if !bpath.exists() => {
562+ vault.dir.join(&vault.tree.node(r).block.as_ref().unwrap().path)
563+ }
564+ _ => bpath,
565+ };
566+ let ours = match std::fs::read_to_string(&bpath) {
567+ Ok(text) => text,
568+ // nothing to merge against: T wins as it is, frontmatter and
569+ // all, rather than being merged into an empty O that has none
570+ Err(e) if e.kind() == std::io::ErrorKind::NotFound && (parsed || tid.is_some()) => {
571+ outcomes.push(format!("{}: {} missing, keeping theirs", cfile, base));
572+ if !dry_run {
573+ std::fs::rename(&cpath, &bpath)?;
574+ }
575+ continue;
576+ }
577+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
578+ outcomes.push(format!("{}: not a block file, left alone", cfile));
579+ continue;
580+ }
581+ // an O that cannot be read is never overwritten
582+ Err(e) => {
583+ outcomes.push(format!("{}: cannot read {}: {}, left alone", cfile, base, e));
584+ continue;
585+ }
586+ };
543587 // device + timestamp from the filename
544588 let (device, stamp) = parse_conflict_name(&cfile);
545589 // id check: differing ids mean a prefix collision, not a conflict (§12.2)
546590 let oid = crate::parse::parse_frontmatter(&ours)
547591 .and_then(|f| f.props.get("id").cloned())
548592 .and_then(|v| Id::parse(&v));
549− let tid = crate::parse::parse_frontmatter(&theirs)
550− .and_then(|f| f.props.get("id").cloned())
551− .and_then(|v| Id::parse(&v));
552593 match (&oid, &tid) {
553594 (Some(a), Some(b)) if a != b => {
554595 outcomes.push(format!(
@@ -575,6 +616,10 @@ pub fn merge_sync_conflicts(vault: &mut Vault, dry_run: bool) -> std::io::Result
575616 cfile, outcome.conflicts
576617 ));
577618 if !dry_run {
619+ // the trash first, so a trash that cannot be made stops the
620+ // merge before anything is written
621+ let trash = crate::vault::trash_dir();
622+ std::fs::create_dir_all(&trash)?;
578623 crate::vault::atomic_write(&bpath, &outcome.text)?;
579624 for (fname, text) in &outcome.conflict_blocks {
580625 let fname = fresh_block_name(vault, fname, text);
@@ -584,11 +629,11 @@ pub fn merge_sync_conflicts(vault: &mut Vault, dry_run: bool) -> std::io::Result
584629 vault.reload()?;
585630 place_sibling_embeds(vault, oid.as_ref(), &outcome.sibling_embeds)?;
586631 }
587− // the conflict file goes to trash (§12.4)
588− let trash = crate::vault::trash_dir();
589− std::fs::create_dir_all(&trash)?;
632+ // the conflict file goes to trash (§12.4), which is often on
633+ // another filesystem: a rename alone would fail there and leave
634+ // it to be merged again
590635 let stamp2 = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S").to_string();
591− std::fs::rename(&cpath, trash.join(format!("{}-{}", stamp2, cfile.to_lowercase())))?;
636+ crate::vault::move_file(&cpath, &trash.join(format!("{}-{}", stamp2, cfile.to_lowercase())))?;
592637 }
593638 }
594639 if !dry_run {
@@ -619,19 +664,17 @@ fn fresh_block_name(vault: &Vault, fname: &str, text: &str) -> String {
619664/// Insert embeds right after the embed of block `owner` in its parent file,
620665/// at its indent, so each conflict block is the next sibling of the block it
621666/// conflicts with (§12.4). Without an embed to follow (an orphan block),
622−/// they go at the end of `root.md` so they are still reachable.
667+/// the owner is embedded at the end of `root.md` with them right after it:
668+/// placed alone, they would pair with whatever node happened to be last.
623669fn place_sibling_embeds(vault: &mut Vault, owner: Option<&Id>, ids: &[Id]) -> std::io::Result<()> {
624− let found = owner.and_then(|oid| {
625− vault.tree.files.iter().enumerate().find_map(|(fi, f)| {
626− f.nodes
627− .iter()
628− .find(|nd| nd.embed.as_ref() == Some(oid))
629− .map(|nd| {
630− // the same form as the owner's embed (§4.7)
631− let heading = (nd.kind == Kind::Section).then(|| nd.level.unwrap_or(1));
632− (fi, nd.title_span.end.min(f.text.len()), nd.indent, heading)
633− })
634− })
670+ // the embed the owner is stitched in at: a second one reads as broken
671+ // (§6.2) and pairs with nothing
672+ let found = owner.and_then(|oid| vault.tree.embed_of(oid)).map(|(fi, ni)| {
673+ let f = &vault.tree.files[fi];
674+ let nd = &f.nodes[ni];
675+ // the same form as the owner's embed (§4.7)
676+ let heading = (nd.kind == Kind::Section).then(|| nd.level.unwrap_or(1));
677+ (fi, nd.title_span.end.min(f.text.len()), nd.indent, heading)
635678 });
636679 let mut insert = String::new();
637680 match found {
@@ -651,14 +694,22 @@ fn place_sibling_embeds(vault: &mut Vault, owner: Option<&Id>, ids: &[Id]) -> st
651694 vault.write_span(fi, crate::parse::Span { start: end, end }, &insert)
652695 }
653696 None => {
654− let text = vault.tree.files[0].text.clone();
655− let mut new_text = text.clone();
697+ // in the form of the owner's spelling (§4.7); a heading embed at
698+ // the end of root.md is top-level
699+ let heading = owner
700+ .and_then(|oid| vault.tree.block_by_id(oid))
701+ .is_some_and(|r| vault.tree.node(r).kind == Kind::Section);
702+ let lines: Vec<String> = owner
703+ .into_iter()
704+ .chain(ids)
705+ .map(|id| format!("{}![[{}]]\n", if heading { "# " } else { "" }, id))
706+ .collect();
707+ let mut new_text = vault.tree.files[0].text.clone();
656708 if !new_text.is_empty() && !new_text.ends_with("\n\n") {
657709 new_text.push_str(if new_text.ends_with('\n') { "\n" } else { "\n\n" });
658710 }
659− for id in ids {
660− new_text.push_str(&format!("![[{}]]\n", id));
661− }
711+ // sibling sections a blank line apart, items tight (§4.2)
712+ new_text.push_str(&lines.join(if heading { "\n" } else { "" }));
662713 vault.write_file_text(0, &new_text)
663714 }
664715 }
crates/fold-core/src/ops.rs+362−124
@@ -136,13 +136,6 @@ fn capture_inner(
136136 task: bool,
137137 target: Option<NRef>,
138138) -> std::io::Result<NRef> {
139− let dest = match target {
140− Some(t) => t,
141− None => {
142− let inbox = find_or_create_inbox(vault)?;
143− find_or_create_day(vault, inbox)?
144− }
145− };
146139 // The first line is the title; further lines are the item's body, so a
147140 // captured document (`capture < file.md`) nests under the item instead
148141 // of injecting structure beside it (§4.1): its headings are pushed below
@@ -154,14 +147,17 @@ fn capture_inner(
154147 } else {
155148 first.trim().to_string()
156149 };
157− let mut line = format!("- {}", title);
150+ // refused before the inbox or its day is created
151+ let mut line = item_line(&title)?;
152+ let dest = match target {
153+ Some(t) => write_target(&vault.tree, t)?,
154+ None => {
155+ let inbox = find_or_create_inbox(vault)?;
156+ find_or_create_day(vault, inbox)?
157+ }
158+ };
158159 if !rest.trim().is_empty() {
159− let dn = vault.tree.node(dest);
160− let item_indent = if dn.kind == Kind::Item {
161− vault.tree.indent(dest) + 2
162− } else {
163− vault.tree.indent(dest)
164− };
160+ let item_indent = child_indent(&vault.tree, dest);
165161 let level = vault.tree.level(dest) as isize;
166162 let body = shift_lines(rest.trim_end(), level, item_indent as isize + 2);
167163 line.push('\n');
@@ -188,9 +184,13 @@ fn find_or_create_day(vault: &mut Vault, inbox: NRef) -> std::io::Result<NRef> {
188184 return Ok(c);
189185 }
190186 }
191− // `## <today>` as the last child of Inbox (§7)
187+ // `## <today>` as the last child of Inbox (§7), at its child indent: an
188+ // Inbox respelled as an item is still the inbox (§3.1); no deeper than
189+ // a day before it as written, which would take it as its child
190+ let last = vault.tree.raw_children(inbox).last().copied();
192191 let level = vault.tree.level(inbox) + 1;
193− let indent = vault.tree.indent(inbox);
192+ let level = written_level(&vault.tree, last).map_or(level, |w| level.min(w));
193+ let indent = child_indent(&vault.tree, inbox);
194194 let heading = format!(
195195 "{}{} {}",
196196 " ".repeat(indent),
@@ -282,20 +282,22 @@ fn append_child_line(
282282 line: &str,
283283 section_ok: bool,
284284) -> std::io::Result<NRef> {
285+ // under the node an embed stands for: an embed line has no children
286+ // in its file (§6.2)
287+ let parent = write_target(&vault.tree, parent)?;
285288 let parent_key = vault.key_of(parent);
286289 let node = vault.tree.node(parent);
287290 let file = parent.0;
288291 // Items under a section sit at the section's own indent; items under an
289292 // item nest one level deeper (§3.1, §4.10).
290− let indent = if node.kind == Kind::Item {
291− vault.tree.indent(parent) + 2
292− } else {
293− vault.tree.indent(parent)
294− };
293+ let indent = child_indent(&vault.tree, parent);
295294 let text = vault.tree.files[file].text.clone();
296295 let kids = vault.tree.raw_children(parent);
297296 let is_section = |r: NRef| vault.tree.node(r).kind == Kind::Section;
298297 let first_section = kids.iter().position(|&c| is_section(c));
298+ // §4.2 puts one blank line between a node's body and its first child:
299+ // an item with no body has its first child right under its title
300+ let bare_item = node.kind == Kind::Item && node.body_lines(&text).is_empty();
299301 let index;
300302 match (first_section, section_ok) {
301303 (Some(_), true) => {
@@ -307,10 +309,12 @@ fn append_child_line(
307309 *kids.iter().rev().find(|&&c| is_section(c)).unwrap()
308310 };
309311 let title = line.trim_start().strip_prefix("- ").unwrap_or(line.trim_start());
312+ // at the last section's written indent, which its parent
313+ // reaches, and written level, which keeps it beside it
310314 let heading = format!(
311315 "{}{} {}",
312− " ".repeat(vault.tree.indent(last)),
313− "#".repeat(vault.tree.level(last)),
316+ " ".repeat(vault.tree.node(last).indent),
317+ "#".repeat(written_level(&vault.tree, Some(last)).unwrap_or(1)),
314318 title
315319 );
316320 let pos = trimmed_end(&text, node.span);
@@ -325,7 +329,7 @@ fn append_child_line(
325329 pos -= 1;
326330 }
327331 let prev_is_item = fs > 0 && !is_section(kids[fs - 1]);
328− let sep = if prev_is_item { "" } else { "\n" };
332+ let sep = if prev_is_item || (fs == 0 && bare_item) { "" } else { "\n" };
329333 vault.write_span(
330334 file,
331335 Span { start: pos, end: pos },
@@ -337,8 +341,8 @@ fn append_child_line(
337341 let line_indented = format!("{}{}", " ".repeat(indent), line);
338342 let pos = trimmed_end(&text, node.span);
339343 // blank line before unless the previous sibling is also an item
340− // (tight list)
341− let prev_is_item = kids.last().map(|&c| !is_section(c)).unwrap_or(false);
344+ // (tight list), or there is none under a bare item
345+ let prev_is_item = kids.last().map(|&c| !is_section(c)).unwrap_or(bare_item);
342346 let sep = if prev_is_item { "\n" } else { "\n\n" };
343347 insert_at(vault, file, pos, sep, &line_indented)?;
344348 index = kids.len();
@@ -372,7 +376,7 @@ pub fn toggle_task(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
372376
373377/// Toggle task-ness itself (§10.3 `t`).
374378pub fn toggle_taskness(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
375− let r = vault.tree.resolved_child(r);
379+ let r = write_target(&vault.tree, r)?;
376380 let state = match vault.tree.node(r).task {
377381 Some(_) => None,
378382 None => Some(TaskState::Open),
@@ -387,8 +391,11 @@ pub fn set_task(vault: &mut Vault, r: NRef, state: Option<TaskState>) -> std::io
387391 let n = vault.tree.node(r);
388392 let is_block = n.is_block();
389393 let file = r.0;
390− let ts = n.title_span;
391− let line = ts.text(&vault.tree.files[file].text).to_string();
394+ // a setext title has no marker to put the checkbox after: it is
395+ // rewritten as an ATX heading, underline and all (§4.2)
396+ let (ts, line) = setext_as_atx(&vault.tree, r).unwrap_or_else(|| {
397+ (n.title_span, n.title_span.text(&vault.tree.files[file].text).to_string())
398+ });
392399 let mark = |st: TaskState| match st {
393400 TaskState::Open => "[ ]",
394401 TaskState::Done => "[x]", // also rewrites `[X]` / `[-]`
@@ -407,7 +414,9 @@ pub fn set_task(vault: &mut Vault, r: NRef, state: Option<TaskState>) -> std::io
407414 },
408415 (None, None) => line.clone(),
409416 };
410− if new_line != line {
417+ // `- [ ] ---` unchecked is `- ---`, a thematic break (§4.4)
418+ not_a_break(&new_line)?;
419+ if new_line != ts.text(&vault.tree.files[file].text) {
411420 vault.write_span(file, ts, &new_line)?;
412421 }
413422 if is_block {
@@ -440,6 +449,26 @@ fn marker_end(line: &str) -> Option<usize> {
440449 }
441450}
442451
452+/// A setext section's title (§4.2: read, converted on write) as an ATX
453+/// heading at its written level and indent, with the span it replaces: the
454+/// title line and its underline, the line after it. `None` for any other
455+/// node. The written level keeps what nests under the heading unchanged.
456+fn setext_as_atx(tree: &crate::tree::Tree, r: NRef) -> Option<(Span, String)> {
457+ let n = tree.node(r);
458+ let text = tree.text_of(r);
459+ let line = n.title_span.text(text);
460+ let t = line.trim_start();
461+ // every other section's title line is an ATX heading (or a heading embed)
462+ if n.kind != Kind::Section || atx_hashes(t).is_some() {
463+ return None;
464+ }
465+ let under = text[n.title_span.end..].find('\n').map_or(text.len(), |i| n.title_span.end + i + 1);
466+ let end = text[under..].find('\n').map_or(text.len(), |i| under + i);
467+ let indent = &line[..line.len() - t.len()];
468+ let atx = format!("{}{} {}", indent, "#".repeat(n.level.unwrap_or(1)), n.title);
469+ Some((Span { start: n.title_span.start, end }, atx))
470+}
471+
443472/// Byte range of the checkbox right after the marker (`[ ]`, `[x]`, `[X]`,
444473/// `[-]`) plus the space after it (§4.2). Checkbox-like text later in the
445474/// title is title text.
@@ -468,8 +497,7 @@ pub fn set_frontmatter_key(
468497 value: Option<&str>,
469498) -> std::io::Result<()> {
470499 let f = vault.tree.files[file].clone();
471− let root_node = f.nodes[f.root_node].children.get(0).copied();
472− let block = root_node.and_then(|rn| f.nodes[rn].block.clone());
500+ let block = file_block(&f).cloned();
473501 let (mut raw, fm_span) = match &block {
474502 Some(b) => (b.frontmatter_raw.clone(), b.frontmatter_span),
475503 None => (String::new(), None),
@@ -525,14 +553,18 @@ pub fn set_frontmatter_key(
525553 vault.write_file_text(file, &new_text)
526554}
527555
556+/// The block whose frontmatter a file holds: a block file's root, its
557+/// first node (§4.9); root.md's, the implicit Root, whose frontmatter holds
558+/// vault-level properties.
559+fn file_block(f: &crate::parse::ParsedFile) -> Option<&crate::parse::Block> {
560+ let root = &f.nodes[f.root_node];
561+ root.block.as_ref().or_else(|| f.nodes[*root.children.first()?].block.as_ref())
562+}
563+
528564/// All top-level keys of a block's frontmatter, for the property editor.
529565pub fn frontmatter_lines(vault: &Vault, file: usize) -> Vec<(String, String, bool)> {
530566 // (key, value, editable) — unknown-structure lines are read-only (§10.6)
531− let f = &vault.tree.files[file];
532− let Some(&rn) = f.nodes[f.root_node].children.first() else {
533− return Vec::new();
534− };
535− let Some(b) = &f.nodes[rn].block else {
567+ let Some(b) = file_block(&vault.tree.files[file]) else {
536568 return Vec::new();
537569 };
538570 let mut out = Vec::new();
@@ -563,22 +595,25 @@ pub fn delete_subtree(vault: &mut Vault, r: NRef) -> std::io::Result<String> {
563595 if n.is_block() || n.is_embed() {
564596 let target = if n.is_embed() { vault.tree.resolved_child(r) } else { r };
565597 if target == r && n.is_embed() {
566− // broken embed: just the line
567− let span = n.span;
598+ // broken embed: just the line. So for a second embed of a
599+ // block, which renders as broken (§6.2): the block, and the
600+ // embed it is stitched in at, are not this line's
601+ let duplicate = n.embed.as_ref().is_some_and(|id| vault.tree.block_by_id(id).is_some());
602+ let span = embed_line_span(&vault.tree, r);
568603 remove_span_with_separator(vault, r.0, span)?;
569− return Ok("broken embed removed".into());
604+ return Ok(if duplicate { "duplicate embed removed" } else { "broken embed removed" }.into());
570605 }
606+ // the block itself first: it leaves by its own embed
571607 let ids = nested_block_ids(vault, target);
572608 let path = vault.tree.node(target).block.as_ref().unwrap().path.clone();
573− for id in ids {
574− trash_block(vault, &id)?;
609+ if let Some((own, nested)) = ids.split_first() {
610+ trash_block(vault, own)?;
611+ trash_nested(vault, nested)?;
575612 }
576613 return Ok(format!("block {:?} trashed", path));
577614 }
578615 let ids = plain_remove(vault, r)?;
579− for id in ids {
580− trash_block(vault, &id)?;
581− }
616+ trash_nested(vault, &ids)?;
582617 Ok("subtree trashed".into())
583618}
584619
@@ -606,17 +641,11 @@ fn plain_remove(vault: &mut Vault, r: NRef) -> std::io::Result<Vec<Id>> {
606641 Ok(ids)
607642}
608643
609−/// Remove a block's embed line (if it is still anywhere) and move its file to
610−/// the trash.
644+/// Remove the embed line a block is stitched in at (if it is still
645+/// anywhere) and move its file to the trash.
611646fn trash_block(vault: &mut Vault, id: &Id) -> std::io::Result<()> {
612− let embed = vault.tree.files.iter().enumerate().find_map(|(fi, f)| {
613− f.nodes
614− .iter()
615− .position(|nd| nd.embed.as_ref() == Some(id))
616− .map(|ni| (fi, ni))
617− });
618− if let Some(e) = embed {
619− let span = vault.tree.node(e).span;
647+ if let Some(e) = vault.tree.embed_of(id) {
648+ let span = embed_line_span(&vault.tree, e);
620649 remove_span_with_separator(vault, e.0, span)?;
621650 }
622651 if let Some(b) = vault.tree.block_by_id(id) {
@@ -625,6 +654,36 @@ fn trash_block(vault: &mut Vault, id: &Id) -> std::io::Result<()> {
625654 Ok(())
626655}
627656
657+/// Move the files of blocks nested in a removed subtree to the trash. Their
658+/// embeds went with the lines and files that held them: an embed of one
659+/// still elsewhere is a second embed, a line of its own (§6.2), and stays.
660+fn trash_nested(vault: &mut Vault, ids: &[Id]) -> std::io::Result<()> {
661+ for id in ids {
662+ if let Some(b) = vault.tree.block_by_id(id) {
663+ vault.trash_file(b.0)?;
664+ }
665+ }
666+ Ok(())
667+}
668+
669+/// The part of an embed's span that is the embed itself: its line and the
670+/// blank lines after it. Lines nested under an embed are a diagnostic
671+/// (§4.7) that the reading pane shows in the parent, so they stay there
672+/// when the embed goes: nothing else holds a copy of them (§11.5).
673+fn embed_line_span(tree: &crate::tree::Tree, e: NRef) -> Span {
674+ let n = tree.node(e);
675+ let text = tree.text_of(e);
676+ let end = n.span.end.min(text.len());
677+ let mut at = text[n.title_span.end..end].find('\n').map_or(end, |i| n.title_span.end + i + 1);
678+ for l in text[at..end].split_inclusive('\n') {
679+ if !l.trim().is_empty() {
680+ break;
681+ }
682+ at += l.len();
683+ }
684+ Span { start: n.span.start, end: at }
685+}
686+
628687/// The byte range to cut when removing `span`: the node plus whatever blank
629688/// separator would otherwise be doubled or left dangling. A blank line that
630689/// separated the node from what follows stays when nothing blank precedes
@@ -704,11 +763,7 @@ pub fn shift_document(text: &str, parent_level: usize, indent: usize) -> String
704763 frontmatter_span: None,
705764 };
706765 let pf = crate::parse::parse_file("clip.md", text, 0, Some(block));
707− let tree = crate::tree::Tree {
708− files: vec![pf],
709− root: (0, 0),
710− blocks: vec![],
711− };
766+ let tree = crate::tree::Tree::new(vec![pf]);
712767 let mut out = String::new();
713768 let kids = tree.resolved_children(tree.root);
714769 for (i, k) in kids.iter().enumerate() {
@@ -720,11 +775,9 @@ pub fn shift_document(text: &str, parent_level: usize, indent: usize) -> String
720775 let t = first.trim_start();
721776 // render(_, 1) writes a section root at level 1 and an item root in
722777 // a section of level 1 (its nested sections at 2)
723− let ld = if t.starts_with('#') {
724− let cur_level = t.chars().take_while(|&c| c == '#').count();
725− parent_level as isize + 1 - cur_level as isize
726− } else {
727− parent_level as isize - 1
778+ let ld = match atx_hashes(t) {
779+ Some(cur_level) => parent_level as isize + 1 - cur_level as isize,
780+ None => parent_level as isize - 1,
728781 };
729782 let cur_indent = first.len() - t.len();
730783 let id = indent as isize - cur_indent as isize;
@@ -740,26 +793,33 @@ pub fn shift_document(text: &str, parent_level: usize, indent: usize) -> String
740793}
741794
742795/// Shift every line of a rendered document by (level_delta, indent_delta).
796+/// Fenced code is shifted too, so it stays in its node's region (§3.3), but
797+/// never re-levelled.
743798fn shift_lines(raw: &str, level_delta: isize, indent_delta: isize) -> String {
744799 let mut out = String::new();
745800 let mut fence: Option<(char, usize)> = None;
746801 for line in raw.split_inclusive('\n') {
747802 let l = line.strip_suffix('\n').unwrap_or(line);
748803 let nl = if line.ends_with('\n') { "\n" } else { "" };
749− if fence_transition(l, &mut fence) || fence.is_some() {
750− out.push_str(l);
804+ let in_code = fence_transition(l, &mut fence) || fence.is_some();
805+ let trimmed = l.trim_start();
806+ if trimmed.is_empty() {
751807 out.push_str(nl);
752808 continue;
753809 }
754− let trimmed = l.trim_start();
755− if trimmed.is_empty() {
810+ if in_code {
811+ // only the leading spaces change: a tab, or any indentation
812+ // inside the code, stays as written
813+ let code = l.trim_start_matches(' ');
814+ let cur_indent = l.len() - code.len();
815+ out.push_str(&" ".repeat((cur_indent as isize + indent_delta).max(0) as usize));
816+ out.push_str(code);
756817 out.push_str(nl);
757818 continue;
758819 }
759820 let cur_indent = l.len() - trimmed.len();
760821 let new_indent = (cur_indent as isize + indent_delta).max(0) as usize;
761− if trimmed.starts_with('#') {
762− let hashes = trimmed.chars().take_while(|&c| c == '#').count();
822+ if let Some(hashes) = atx_hashes(trimmed) {
763823 let new_level = (hashes as isize + level_delta).max(1) as usize;
764824 out.push_str(&" ".repeat(new_indent));
765825 out.push_str(&"#".repeat(new_level));
@@ -774,35 +834,22 @@ fn shift_lines(raw: &str, level_delta: isize, indent_delta: isize) -> String {
774834 out
775835}
776836
837+/// The level of an ATX heading line, given without its indent: `#`+ then a
838+/// space or the end of the line (§4.2). `#tag` is text, as the parser reads it.
839+fn atx_hashes(trimmed: &str) -> Option<usize> {
840+ let hashes = trimmed.bytes().take_while(|&b| b == b'#').count();
841+ let after = &trimmed[hashes..];
842+ (hashes > 0 && (after.is_empty() || after.starts_with(' '))).then_some(hashes)
843+}
844+
777845fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
778− let t = raw.trim_start();
779− let first = match t.chars().next() {
780− Some(c) if c == '`' || c == '~' => c,
781− _ => return false,
782− };
783− let count = t.chars().take_while(|&c| c == first).count();
784− if count < 3 {
785− return false;
786− }
787− match open {
788− None => {
789− *open = Some((first, count));
790− true
791− }
792− Some((c, n)) => {
793− if *c == first && count >= *n {
794− *open = None;
795− true
796− } else {
797− false
798− }
799− }
800− }
846+ // fences as the parser reads them, so shifting re-levels what it does
847+ crate::parse::fence_transition(raw, open)
801848}
802849
803850/// Refile: move a subtree under a new parent as its last child (§6.5).
804851pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<bool> {
805− let dest = vault.tree.resolved_child(dest);
852+ let dest = write_target(&vault.tree, dest)?;
806853 let n = vault.tree.node(r);
807854 // guard: cannot refile into own subtree — ancestry followed through
808855 // embeds, so a destination inside a nested block counts too
@@ -816,7 +863,7 @@ pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<bool> {
816863 }
817864 guard += 1;
818865 if guard > 10_000 {
819− break; // embed cycle: a diagnostic elsewhere
866+ break; // embed cycle: `check` reports it (§6.2)
820867 }
821868 anc = resolved_parent(&vault.tree, a);
822869 }
@@ -858,7 +905,15 @@ pub fn move_node(vault: &mut Vault, r: NRef, target: NRef, drop: Drop) -> std::i
858905 return Err(io_err("cannot move a node into itself"));
859906 }
860907 let moving = stand_in(&vault.tree, r);
861− let idx = vault.tree.raw_children(parent).iter().position(|&k| k == t).unwrap_or(0);
908+ // the target's index among the children `place` sees: without
909+ // the moving node, which may come before it
910+ let idx = vault
911+ .tree
912+ .raw_children(parent)
913+ .into_iter()
914+ .filter(|&k| k != moving)
915+ .position(|k| k == t)
916+ .unwrap_or(0);
862917 let rendered = render(&vault.tree, moving, 1, false);
863918 let shifted =
864919 shift_document(&rendered, vault.tree.level(parent), child_indent(&vault.tree, parent));
@@ -900,12 +955,29 @@ fn stand_in(tree: &crate::tree::Tree, r: NRef) -> NRef {
900955 r
901956}
902957
958+/// The node a verb writes into at `r`: a block's own embed is the block
959+/// (§4.7). An embed that resolves to nothing, broken or a second embed of
960+/// a block (§6.2), is only a line of the file that holds it: it has no
961+/// checkbox, properties or children of its own, so the verb is refused
962+/// rather than writing them into that file.
963+fn write_target(tree: &crate::tree::Tree, r: NRef) -> std::io::Result<NRef> {
964+ let t = tree.resolved_child(r);
965+ if tree.node(t).is_embed() {
966+ return Err(io_err("a broken or duplicate embed has no node to write to"));
967+ }
968+ Ok(t)
969+}
970+
903971/// Indent of a child of `parent` (§3.1): items nest under items, everything
904−/// under a section stays at the section's own indent.
972+/// under a section stays at the section's own indent. Measured from the
973+/// parent's written indent, not its derived one: nesting written with tabs
974+/// or 4 spaces is read (§4.2), and a line at the derived child indent could
975+/// fall short of such a parent and parse as its sibling.
905976fn child_indent(tree: &crate::tree::Tree, parent: NRef) -> usize {
906− match tree.node(parent).kind {
907− Kind::Item => tree.indent(parent) + 2,
908− Kind::Section => tree.indent(parent),
977+ let n = tree.node(parent);
978+ match n.kind {
979+ Kind::Item => n.indent + 2,
980+ Kind::Section => n.indent,
909981 Kind::Root => 0,
910982 }
911983}
@@ -921,6 +993,12 @@ fn top_kinds(doc: &str) -> Vec<Kind> {
921993 .collect()
922994}
923995
996+/// The indent a document's first top-level node is written at.
997+fn top_indent(doc: &str) -> Option<usize> {
998+ let pf = crate::parse::parse_file("clip.md", doc, 0, None);
999+ pf.nodes[pf.root_node].children.first().map(|&c| pf.nodes[c].indent)
1000+}
1001+
9241002/// Clamp a wanted child index by the ordering rule `(text | item)*
9251003/// section*` (§3.1): items no later than the first section child, sections
9261004/// no earlier. `kids` are the parent's child nodes without the one moving.
@@ -934,6 +1012,45 @@ fn clamp_index(tree: &crate::tree::Tree, kids: &[NRef], want: usize, kinds: &[Ki
9341012 want.min(kids.len()).clamp(lo, hi)
9351013}
9361014
1015+/// The level of `k` as written, if it is a section. A written level is
1016+/// honoured (§4.7): a section written after it deeper than that is its
1017+/// child, and one written before it shallower than that takes it (and
1018+/// every section after it) as a child (§3.1). The level `k`'s position
1019+/// gives can be deeper: under an item, sections may be written shallower.
1020+fn written_level(tree: &crate::tree::Tree, k: Option<NRef>) -> Option<usize> {
1021+ k.map(|k| tree.node(k)).filter(|n| n.kind == Kind::Section).map(|n| n.level.unwrap_or(1))
1022+}
1023+
1024+/// `doc` (shifted to a child position) with its top-level sections, and
1025+/// everything under them, re-levelled to stay siblings of the sections
1026+/// around them (`written_level`): no deeper than `prev`, the sibling
1027+/// section they are written after, and no shallower than `next`, the one
1028+/// they are written before.
1029+fn level_among(tree: &crate::tree::Tree, prev: Option<NRef>, next: Option<NRef>, doc: &str) -> String {
1030+ let (most, least) = (written_level(tree, prev), written_level(tree, next));
1031+ if most.is_none() && least.is_none() {
1032+ return doc.to_string();
1033+ }
1034+ let pf = crate::parse::parse_file("clip.md", doc, 0, None);
1035+ let tops = &pf.nodes[pf.root_node].children;
1036+ // a document's sections come after its items (§3.1)
1037+ let Some(first) = tops.iter().map(|&c| &pf.nodes[c]).find(|n| n.kind == Kind::Section) else {
1038+ return doc.to_string();
1039+ };
1040+ // indentation closes a section before its level does (§4.2): a sibling
1041+ // written deeper than the sections, before them, or shallower, after
1042+ // them, is no parent or child of theirs, whatever its level
1043+ let most = most.filter(|_| prev.is_some_and(|p| tree.node(p).indent <= first.indent));
1044+ let least = least.filter(|_| next.is_some_and(|k| tree.node(k).indent >= first.indent));
1045+ let have = tops.last().and_then(|&c| pf.nodes[c].level).unwrap_or(1);
1046+ let want = most.map_or(have, |m| have.min(m)).max(least.unwrap_or(1));
1047+ if want == have {
1048+ return doc.to_string();
1049+ }
1050+ let at = first.span.start;
1051+ format!("{}{}", &doc[..at], shift_lines(&doc[at..], want as isize - have as isize, 0))
1052+}
1053+
9371054/// Write `doc` (already shifted to a child position of `parent`) as the
9381055/// `want`-th child node of `parent`, clamped by the ordering rule (§3.1).
9391056/// With `moving`, that node's own lines are removed in the same edit: text
@@ -996,6 +1113,32 @@ fn place(
9961113 Some(crate::parse::Content::Text(_)) => false,
9971114 None => pnode.kind == Kind::Item,
9981115 };
1116+ // inserting before an item: after another item, the list is as tight as
1117+ // the text there says (read below); starting the list, it is read in
1118+ // the list as found, the moving node still in it: between `next` and
1119+ // the item after it, or else between the moving node and `next` right
1120+ // after it. A list of one item is tight.
1121+ let is_item = |c: Option<&crate::parse::Content>| {
1122+ matches!(c, Some(crate::parse::Content::Node(k)) if tree.files[file].nodes[*k].kind == Kind::Item)
1123+ };
1124+ let next_at = next.and_then(|k| content.iter().position(|c| *c == crate::parse::Content::Node(k.1)));
1125+ let starts_run = next_at.is_some_and(|i| i == 0 || !is_item(content.get(i - 1)));
1126+ let found = &pnode.content;
1127+ let found_at = next.and_then(|k| found.iter().position(|c| *c == crate::parse::Content::Node(k.1)));
1128+ let no_blank_after = |k: NRef| {
1129+ let sp = tree.node(k).span;
1130+ !text[sp.start..sp.end.min(text.len())].ends_with("\n\n")
1131+ };
1132+ let run_tight = match (next, found_at) {
1133+ (Some(k), Some(i)) if is_item(found.get(i + 1)) => no_blank_after(k),
1134+ (Some(_), Some(i)) => match moving {
1135+ Some(m) if i > 0 && m.0 == file && found[i - 1] == crate::parse::Content::Node(m.1) => {
1136+ no_blank_after(m)
1137+ }
1138+ _ => true,
1139+ },
1140+ _ => true,
1141+ };
9991142 let next_kind = next.map(|k| tree.node(k).kind);
10001143 let removal = match moving {
10011144 Some(m) if m.0 == file => Some(removal_range(&text, tree.node(m).span)),
@@ -1005,6 +1148,15 @@ fn place(
10051148 text = format!("{}{}", &text[..start], &text[end..]);
10061149 pos = after_removal(pos, start, end);
10071150 }
1151+ // before a sibling written deeper than the node (tab or 4-space
1152+ // nesting is read, §4.2), the node goes at that sibling's indent: any
1153+ // shallower, the sibling would parse as its child
1154+ let doc = match (next.map(|k| tree.node(k).indent), top_indent(doc)) {
1155+ (Some(want), Some(have)) if want > have => shift_lines(doc, 0, (want - have) as isize),
1156+ _ => doc.to_string(),
1157+ };
1158+ let prev = idx.checked_sub(1).map(|i| kids[i]);
1159+ let doc = level_among(tree, prev, next, &doc);
10081160 let body = doc.trim_end_matches('\n');
10091161 let insertion = match next_kind {
10101162 Some(nk) => {
@@ -1015,7 +1167,9 @@ fn place(
10151167 } else {
10161168 ""
10171169 };
1018− let tight = last == Kind::Item && nk == Kind::Item && !text[..pos].ends_with("\n\n");
1170+ let tight = last == Kind::Item
1171+ && nk == Kind::Item
1172+ && if starts_run { run_tight } else { !text[..pos].ends_with("\n\n") };
10191173 format!("{}{}\n{}", lead, body, if tight { "" } else { "\n" })
10201174 }
10211175 None => {
@@ -1097,10 +1251,17 @@ pub fn clear_done(vault: &mut Vault, target: NRef) -> std::io::Result<usize> {
10971251 .collect();
10981252 let mut blocks: Vec<Id> = Vec::new();
10991253 let mut plain: Vec<NRef> = Vec::new();
1254+ let mut nested: Vec<Id> = Vec::new();
11001255 for &r in &tops {
11011256 let n = vault.tree.node(r);
11021257 match n.block.as_ref().and_then(|b| b.id.clone()) {
1103− Some(id) => blocks.push(id),
1258+ Some(id) => {
1259+ // the block leaves by its own embed; blocks embedded in its
1260+ // file go to the trash with it, as delete_subtree sends them
1261+ let ids = nested_block_ids(vault, r);
1262+ nested.extend(ids.into_iter().filter(|i| *i != id));
1263+ blocks.push(id);
1264+ }
11041265 None => plain.push(r),
11051266 }
11061267 }
@@ -1109,13 +1270,14 @@ pub fn clear_done(vault: &mut Vault, target: NRef) -> std::io::Result<usize> {
11091270 let mut done = 0;
11101271 for r in plain {
11111272 let ids = plain_remove(vault, r)?;
1112− blocks.extend(ids);
1273+ nested.extend(ids);
11131274 done += 1;
11141275 }
11151276 let n_block_tops = tops.len() - done;
11161277 for id in blocks {
11171278 trash_block(vault, &id)?;
11181279 }
1280+ trash_nested(vault, &nested)?;
11191281 Ok(done + n_block_tops)
11201282}
11211283
@@ -1125,13 +1287,7 @@ fn resolved_parent(tree: &crate::tree::Tree, r: NRef) -> Option<NRef> {
11251287 let p = tree.node(r).parent?;
11261288 let pr = (r.0, p);
11271289 if tree.node(pr).kind == Kind::Root && r.0 != tree.root.0 {
1128− let id = tree.node(r).block.as_ref()?.id.clone()?;
1129− tree.files.iter().enumerate().find_map(|(fi, f)| {
1130− f.nodes
1131− .iter()
1132− .position(|nd| nd.embed.as_ref() == Some(&id))
1133− .map(|ni| (fi, ni))
1134− })
1290+ tree.embed_of(tree.node(r).block.as_ref()?.id.as_ref()?)
11351291 } else if tree.node(pr).kind == Kind::Root {
11361292 None
11371293 } else {
@@ -1155,14 +1311,28 @@ pub fn make_block(vault: &mut Vault, r: NRef) -> std::io::Result<Id> {
11551311 file_text.push_str(&body);
11561312 let prefix = vault.unique_prefix(&id);
11571313 let fname = crate::ident::filename(&prefix, &slug(&n.title));
1158− crate::vault::atomic_write(&vault.dir.join(&fname), &file_text)?;
1314+ // the embed cannot be written into a file changed on disk (§11.2), and
1315+ // a block file without it would be embedded nowhere: check first
1316+ let file = r.0;
1317+ vault.check_unchanged(file)?;
1318+ let full = vault.dir.join(&fname);
1319+ crate::vault::atomic_write(&full, &file_text)?;
11591320 // replace the node's span with an embed in the node's form (§6.1.3,
11601321 // §4.7), keeping the blank lines that separated it from what follows
1161− let file = r.0;
11621322 let span = n.span;
11631323 let indent = " ".repeat(n.indent);
1324+ // at its level, but kept a sibling of the sections around it as they
1325+ // are written: no deeper than the one before it, which would take the
1326+ // embed as its child, nor shallower than the one after it
1327+ let kids = n.parent.map(|p| vault.tree.raw_children((file, p))).unwrap_or_default();
1328+ let at = kids.iter().position(|&c| c == r);
1329+ let prev = at.and_then(|i| i.checked_sub(1)).map(|i| kids[i]);
1330+ let next = at.and_then(|i| kids.get(i + 1).copied());
11641331 let mut embed = match n.kind {
1165− Kind::Section => format!("{}{} ![[{}]]\n", indent, "#".repeat(vault.tree.level(r)), id),
1332+ Kind::Section => {
1333+ let line = format!("{}{} ![[{}]]\n", indent, "#".repeat(vault.tree.level(r)), id);
1334+ level_among(&vault.tree, prev, next, &line)
1335+ }
11661336 _ => format!("{}![[{}]]\n", indent, id),
11671337 };
11681338 let old = span.text(&vault.tree.files[file].text);
@@ -1170,7 +1340,12 @@ pub fn make_block(vault: &mut Vault, r: NRef) -> std::io::Result<Id> {
11701340 for _ in 1..trailing {
11711341 embed.push('\n');
11721342 }
1173− vault.write_span(file, span, &embed)?;
1343+ if let Err(e) = vault.write_span(file, span, &embed) {
1344+ // changed in between after all: nothing refers to the new file yet,
1345+ // and the node is still in its parent
1346+ let _ = std::fs::remove_file(&full);
1347+ return Err(e);
1348+ }
11741349 vault.reload()?;
11751350 Ok(id)
11761351}
@@ -1181,7 +1356,7 @@ pub fn set_property(vault: &mut Vault, r: NRef, key: &str, value: &str) -> std::
11811356 let target = if vault.tree.node(r).is_block() {
11821357 r
11831358 } else if vault.tree.node(r).is_embed() {
1184− vault.tree.resolved_child(r)
1359+ write_target(&vault.tree, r)?
11851360 } else {
11861361 // make_block reloads, so `r` is stale: find the new block by its id
11871362 let id = make_block(vault, r)?;
@@ -1247,9 +1422,12 @@ pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<(
12471422 }
12481423 Span { start: sp.start, end: e }
12491424 };
1250− let a = core(vault.tree.node(r).span);
1251− let b = core(vault.tree.node(other).span);
1252− let (first, second) = if a.start < b.start { (a, b) } else { (b, a) };
1425+ let (fr, sr) = if vault.tree.node(r).span.start < vault.tree.node(other).span.start {
1426+ (r, other)
1427+ } else {
1428+ (other, r)
1429+ };
1430+ let (first, second) = (core(vault.tree.node(fr).span), core(vault.tree.node(sr).span));
12531431 let with_nl = |t: &str| {
12541432 if t.ends_with('\n') {
12551433 t.to_string()
@@ -1257,12 +1435,29 @@ pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<(
12571435 format!("{}\n", t)
12581436 }
12591437 };
1438+ // The written level and indent decide the parent: a sibling written
1439+ // deeper than the one after it (a skipped heading level, §4.7, or a
1440+ // wider indent) would nest under that one once below it, and J/K keep
1441+ // every other node's parent (§15.6). So the node moving down is written
1442+ // at the level and indent of the one moving up, its subtree re-levelled
1443+ // with it, as a moved node is (§4.2).
1444+ let (fnode, snode) = (vault.tree.node(fr), vault.tree.node(sr));
1445+ let lower = if (fnode.level, fnode.indent) == (snode.level, snode.indent) {
1446+ with_nl(first.text(&text))
1447+ } else {
1448+ let base = snode.level.unwrap_or_else(|| vault.tree.level(fr));
1449+ let pad = " ".repeat(snode.indent);
1450+ render(&vault.tree, fr, base, false)
1451+ .lines()
1452+ .map(|l| if l.is_empty() { "\n".to_string() } else { format!("{}{}\n", pad, l) })
1453+ .collect()
1454+ };
12601455 let mut new_text = format!(
12611456 "{}{}{}{}",
12621457 &text[..first.start],
12631458 with_nl(second.text(&text)),
12641459 &text[first.end..second.start],
1265− with_nl(first.text(&text)),
1460+ lower,
12661461 );
12671462 let rest = &text[second.end..];
12681463 new_text.push_str(rest);
@@ -1280,13 +1475,36 @@ pub fn toggle_spelling(vault: &mut Vault, r: NRef) -> std::io::Result<bool> {
12801475 return Ok(false);
12811476 }
12821477 let to_section = n.kind == Kind::Item;
1478+ // an embed still here after resolving is broken: there is no block to
1479+ // respell, so only the embed's form changes and its id stays (§4.7)
1480+ if n.is_embed() {
1481+ return respell_embed(vault, r, to_section);
1482+ }
1483+ // `## ---` spelled as an item is `- ---`, a thematic break (§4.4)
1484+ if !to_section && n.task.is_none() {
1485+ item_line(&n.title)?;
1486+ }
12831487 let stand = stand_in(&vault.tree, r);
12841488 let file = r.0;
12851489 let text = vault.tree.files[file].text.clone();
12861490 let span = Span { start: n.span.start, end: n.span.end.min(text.len()) };
1491+ // respelled from the subtree as a move writes it (§4.2): every heading
1492+ // in it ATX, a setext one converted, at the level its position gives,
1493+ // so re-levelling keeps each under the node it was under; at the node's
1494+ // indent, with the blank lines that end its span
1495+ let blanks = span.text(&text).lines().rev().take_while(|l| l.trim().is_empty()).count();
1496+ let rendered: String = crate::render::render_lines(&vault.tree, r, vault.tree.level(r), false)
1497+ .into_iter()
1498+ .map(|l| l.text + "\n")
1499+ .collect();
1500+ let src = shift_lines(&rendered, 0, n.indent as isize) + &"\n".repeat(blanks);
12871501 if stand != r {
1288− // a block: respell its file's root at level 1, then its embed
1289− let respelled = respell(span.text(&text), to_section, 1);
1502+ // a block: respell its file's root at level 1, then its embed. The
1503+ // embed cannot be written into a file changed on disk (§11.2), and
1504+ // the block would be left under an embed of the other form (§4.7):
1505+ // check first
1506+ vault.check_unchanged(stand.0)?;
1507+ let respelled = respell(&src, to_section, 1);
12901508 let id = n.block.as_ref().and_then(|b| b.id.clone());
12911509 vault.write_span(file, span, &respelled)?;
12921510 let Some(e) = id.and_then(|id| vault.tree.embed_of(&id)) else {
@@ -1296,7 +1514,7 @@ pub fn toggle_spelling(vault: &mut Vault, r: NRef) -> std::io::Result<bool> {
12961514 return respell_embed(vault, e, to_section);
12971515 }
12981516 let Some(parent) = n.parent.map(|p| (file, p)) else { return Ok(false) };
1299− let respelled = respell(span.text(&text), to_section, vault.tree.level(parent) + 1);
1517+ let respelled = respell(&src, to_section, vault.tree.level(parent) + 1);
13001518 reposition(vault, r, parent, respelled, to_section)
13011519}
13021520
@@ -1335,6 +1553,8 @@ fn reposition(
13351553 let kind = if to_section { Kind::Section } else { Kind::Item };
13361554 if clamp_index(tree, &others, pos, &[kind]) == pos {
13371555 let span = tree.node(r).span;
1556+ let prev = pos.checked_sub(1).map(|i| others[i]);
1557+ let new = level_among(tree, prev, others.get(pos).copied(), &new);
13381558 vault.write_span(r.0, span, &new)?;
13391559 vault.reload()?;
13401560 return Ok(false);
@@ -1420,7 +1640,25 @@ pub fn append_child_public(
14201640 parent: NRef,
14211641 title: &str,
14221642) -> std::io::Result<NRef> {
1423− append_child_line(vault, parent, &format!("- {}", title), true)
1643+ append_child_line(vault, parent, &item_line(title)?, true)
1644+}
1645+
1646+/// The item line titled `title`, refused when it would not read back as
1647+/// one: a title of dashes (`---`, `- -`) makes it a thematic break, which
1648+/// CommonMark reads before a bullet, and body text (§4.4). Checked before
1649+/// anything is written, as the TUI won't create an invalid title (§4.3).
1650+fn item_line(title: &str) -> std::io::Result<String> {
1651+ let line = format!("- {}", title);
1652+ not_a_break(&line)?;
1653+ Ok(line)
1654+}
1655+
1656+/// Refuse an item's title line that reads as a thematic break (§4.4).
1657+fn not_a_break(line: &str) -> std::io::Result<()> {
1658+ if crate::parse::is_thematic_break(line.trim_start_matches([' ', '\t'])) {
1659+ return Err(io_err("a title of dashes reads as a thematic break, not an item"));
1660+ }
1661+ Ok(())
14241662}
14251663
14261664fn io_err(msg: &str) -> std::io::Error {
crates/fold-core/src/parse.rs+102−15
@@ -280,14 +280,12 @@ fn classify_title(raw: &str) -> Option<(usize, TitleInfo)> {
280280 });
281281 }
282282 let (kind, after_marker) = if rest.starts_with('#') {
283+ // any number: levels are unbounded, beyond six too (§3.1, §4.2)
283284 let hashes = rest.chars().take_while(|&c| c == '#').count();
284285 let after = &rest[hashes..];
285286 if !after.is_empty() && !after.starts_with(' ') {
286287 return None; // `#tag` is text
287288 }
288− if hashes > 6 {
289− noncanonical.push("heading level beyond six".into());
290− }
291289 // a heading embed: the heading holds nothing but `![[id]]`
292290 if let Some(inner) = after
293291 .trim()
@@ -310,6 +308,8 @@ fn classify_title(raw: &str) -> Option<(usize, TitleInfo)> {
310308 TitleKind::Section { level: hashes },
311309 after.strip_prefix(' ').unwrap_or(after),
312310 )
311+ } else if is_thematic_break(rest) {
312+ return None; // `- - -`, `* * *`: a break, not a bullet (§4.4)
313313 } else if let Some(after) = rest.strip_prefix("- ") {
314314 (TitleKind::Item, after)
315315 } else if let Some(after) = rest.strip_prefix("* ").or_else(|| rest.strip_prefix("+ ")) {
@@ -362,7 +362,12 @@ fn classify_title(raw: &str) -> Option<(usize, TitleInfo)> {
362362 ))
363363}
364364
365−fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
365+/// Track fenced code blocks (§3.3) one line at a time: true when `raw` opens
366+/// or closes one. Fences are CommonMark's: a backtick fence's info string
367+/// holds no backtick (a line that starts with inline code is text), and a
368+/// closing fence is followed by nothing but spaces or tabs. Public so that
369+/// what the TUI draws as code is what the parser reads as code.
370+pub fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
366371 let t = raw.trim_start_matches([' ', '\t']);
367372 let first = match t.chars().next() {
368373 Some(c) if c == '`' || c == '~' => c,
@@ -372,13 +377,17 @@ fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
372377 if count < 3 {
373378 return false;
374379 }
380+ let rest = &t[count..]; // '`' and '~' are one byte each
375381 match open {
376382 None => {
383+ if first == '`' && rest.contains('`') {
384+ return false;
385+ }
377386 *open = Some((first, count));
378387 true
379388 }
380389 Some((c, n)) => {
381− if *c == first && count >= *n {
390+ if *c == first && count >= *n && rest.trim_matches([' ', '\t']).is_empty() {
382391 *open = None;
383392 true
384393 } else {
@@ -388,6 +397,25 @@ fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
388397 }
389398}
390399
400+/// A thematic break, however it is spaced: three or more of one of `-`, `*`,
401+/// `_` with nothing but spaces or tabs between. CommonMark reads it before a
402+/// list item, so `- - -` and `* * *` are body text like `---` (§4.4).
403+/// `rest` starts at the line's first non-blank character.
404+pub(crate) fn is_thematic_break(rest: &str) -> bool {
405+ let Some(mark) = rest.chars().next().filter(|c| matches!(c, '-' | '*' | '_')) else {
406+ return false;
407+ };
408+ let mut n = 0;
409+ for c in rest.chars() {
410+ if c == mark {
411+ n += 1;
412+ } else if c != ' ' && c != '\t' {
413+ return false;
414+ }
415+ }
416+ n >= 3
417+}
418+
391419/// A setext underline. Only `=` underlines count: a line of dashes is a
392420/// thematic break and body text (§4.4, §5.2 step 2), and a lone `-` is an
393421/// empty bullet.
@@ -484,7 +512,7 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
484512 // Setext underline?
485513 if let Some(level) = setext_level(&raw) {
486514 if let Some((title_li, title_indent)) = pending_setext.take() {
487− make_setext_section(
515+ let idx = make_setext_section(
488516 &mut nodes,
489517 &mut stack,
490518 &lines,
@@ -492,7 +520,20 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
492520 title_indent,
493521 i,
494522 level,
523+ block_root,
495524 );
525+ // like any title line, a setext heading can be a block
526+ // file's root (§4.9); its title line was then reported as
527+ // text before the root, which it is not
528+ if nodes[idx].parent == Some(root_idx) {
529+ root_level_nodes += 1;
530+ if is_block_file && !root_title_seen {
531+ root_title_seen = true;
532+ block_root = Some(idx);
533+ let at = nodes[idx].title_span.start;
534+ diagnostics.retain(|d: &Diag| d.span.start != at);
535+ }
536+ }
496537 i += 1;
497538 continue;
498539 }
@@ -712,7 +753,7 @@ pub fn unseparated_text(n: &Node, text: &str) -> Vec<usize> {
712753 let mut out = Vec::new();
713754 for w in n.content.windows(2) {
714755 if let [Content::Node(_), Content::Text(sp)] = w {
715− if sp.start > 0 && !text[..sp.start].ends_with("\n\n") {
756+ if sp.start > 0 && !ends_with_blank_line(&text[..sp.start]) {
716757 out.push(sp.start);
717758 }
718759 }
@@ -720,6 +761,15 @@ pub fn unseparated_text(n: &Node, text: &str) -> Vec<usize> {
720761 out
721762}
722763
764+/// Whether `s`, which ends at a line start, ends with a blank line: one that
765+/// is empty or whitespace, whatever its line ending (`\r\n` is read too).
766+pub(crate) fn ends_with_blank_line(s: &str) -> bool {
767+ let Some(s) = s.strip_suffix('\n') else {
768+ return false;
769+ };
770+ s[s.rfind('\n').map_or(0, |i| i + 1)..].trim().is_empty()
771+}
772+
723773fn push_body(
724774 nodes: &mut Vec<Node>,
725775 stack: &mut Vec<Frame>,
@@ -790,6 +840,9 @@ fn push_body(
790840 extend_spans(nodes, top, line.next);
791841}
792842
843+/// Turn the pending title line and its underline into a section; returns
844+/// its index.
845+#[allow(clippy::too_many_arguments)]
793846fn make_setext_section(
794847 nodes: &mut Vec<Node>,
795848 stack: &mut Vec<Frame>,
@@ -798,14 +851,26 @@ fn make_setext_section(
798851 title_indent: usize,
799852 underline_li: usize,
800853 level: usize,
801−) {
802− // The title line is the last text line pushed, so it ends the top
803− // frame's last text child; take it back out.
854+ block_root: Option<usize>,
855+) -> usize {
856+ // The title line is the last text line pushed, so it ends the last text
857+ // child of the node push_body gave it to: the top frame's, or a block
858+ // file's root when that is the Root (§4.9). Take it back out, with the
859+ // adoption note that came with it.
804860 let title = lines[title_li].raw.trim().to_string();
805861 let underline = &lines[underline_li];
806862 let tl = &lines[title_li];
807863 {
808− let owner = stack.last().unwrap().node;
864+ let mut owner = stack.last().unwrap().node;
865+ if owner == 0 {
866+ if let Some(br) = block_root {
867+ owner = br;
868+ let note = "column-0 text in a block file, adopted by its root";
869+ if nodes[br].noncanonical.last().is_some_and(|n| n == note) {
870+ nodes[br].noncanonical.pop();
871+ }
872+ }
873+ }
809874 let nd = &mut nodes[owner];
810875 if let Some(Content::Text(sp)) = nd.content.last_mut() {
811876 if sp.end == tl.next {
@@ -815,22 +880,43 @@ fn make_setext_section(
815880 }
816881 }
817882 }
883+ // push_body grew the owner's span, and its ancestors', over the
884+ // title line: give that back too, so a node the new section does not
885+ // sit in ends before it (sibling spans never overlap). The section's
886+ // own ancestors are grown again below.
887+ let mut cur = Some(owner);
888+ while let Some(c) = cur {
889+ if nodes[c].span.end == tl.next {
890+ nodes[c].span.end = tl.start;
891+ }
892+ cur = nodes[c].parent;
893+ }
818894 }
819− // Pop frames that can't contain a section at this position.
895+ // Pop frames that can't contain a section at this position: those that
896+ // could not contain the ATX heading of its level there (§4.2), so
897+ // writing it as one keeps it where it is.
820898 loop {
821899 let top = stack.last().unwrap();
822900 let tnode = &nodes[top.node];
823901 let can = match tnode.kind {
824902 Kind::Root => true,
825903 Kind::Item => title_indent > top.indent,
826− Kind::Section => title_indent > top.indent || level > top.level.unwrap_or(0),
904+ Kind::Section => title_indent >= top.indent && level > top.level.unwrap_or(0),
827905 };
828906 if can {
829907 break;
830908 }
831909 stack.pop();
832910 }
833− let parent = stack.last().unwrap().node;
911+ // a column-0 section after a block file's root is adopted by it (§4.9)
912+ let mut parent = stack.last().unwrap().node;
913+ let mut noncanonical = vec!["setext heading".to_string()];
914+ if parent == 0 {
915+ if let Some(br) = block_root {
916+ parent = br;
917+ noncanonical.push("column-0 node in a block file, adopted by its root".into());
918+ }
919+ }
834920 let idx = nodes.len();
835921 nodes.push(Node {
836922 kind: Kind::Section,
@@ -852,7 +938,7 @@ fn make_setext_section(
852938 embed: None,
853939 indent: title_indent,
854940 level: Some(level),
855− noncanonical: vec!["setext heading".into()],
941+ noncanonical,
856942 });
857943 nodes[parent].children.push(idx);
858944 nodes[parent].content.push(Content::Node(idx));
@@ -863,6 +949,7 @@ fn make_setext_section(
863949 level: Some(level),
864950 is_embed: false,
865951 });
952+ idx
866953}
867954
868955fn extend_spans(nodes: &mut Vec<Node>, idx: usize, end: usize) {
crates/fold-core/src/render.rs+9−1
@@ -154,6 +154,9 @@ impl Walk<'_> {
154154 // parent's file (an embed's target sits at the embed's position).
155155 let cindent = dindent + tree.indent(c).saturating_sub(tree.indent(r));
156156 if self.resolve && cn.is_embed() {
157+ // only the block's own embed resolves; a second embed of the
158+ // id renders as broken (§6.2), so the block's lines appear, and
159+ // are owned, once whatever the zoom
157160 let t = tree.resolved_child(c);
158161 if t != c {
159162 // A section at this position is one below the parent; an
@@ -179,7 +182,12 @@ impl Walk<'_> {
179182 return;
180183 }
181184 }
182− let clevel = (dlevel as isize + tree.level(c) as isize - tree.level(r) as isize).max(0);
185+ // A section under an item can sit shallower than the item's section
186+ // (§3.1: a skipped level above it is honoured), so the shift can
187+ // take it below level 1. It prints at 1 then, and its subtree is
188+ // levelled from there, so a child section still prints deeper.
189+ let min = if cn.kind == Kind::Section { 1 } else { 0 };
190+ let clevel = (dlevel as isize + tree.level(c) as isize - tree.level(r) as isize).max(min);
183191 self.node(c, clevel as usize, cindent, owner, outer);
184192 }
185193
crates/fold-core/src/tree.rs+84−18
@@ -2,6 +2,8 @@
22
33use crate::ident::Id;
44use crate::parse::{Kind, Node, ParsedFile, TaskState};
5+use std::collections::HashMap;
6+use std::sync::OnceLock;
57
68/// The logical tree formed by all parsed files stitched together.
79pub struct Tree {
@@ -10,12 +12,47 @@ pub struct Tree {
1012 pub root: (usize, usize),
1113 /// (file, node) → id for every block.
1214 pub blocks: Vec<((usize, usize), Id)>,
15+ /// `embeds`, built at first use; `restitch` drops it.
16+ embed_index: OnceLock<HashMap<Id, NRef>>,
1317}
1418
1519/// A global node reference.
1620pub type NRef = (usize, usize);
1721
1822impl Tree {
23+ /// Stitch parsed files, `root.md` first, into one tree (§4.7).
24+ pub fn new(files: Vec<ParsedFile>) -> Tree {
25+ let mut t = Tree {
26+ files,
27+ root: (0, 0),
28+ blocks: Vec::new(),
29+ embed_index: OnceLock::new(),
30+ };
31+ t.restitch();
32+ t
33+ }
34+
35+ /// Rebuild the block list, and what is derived from the embeds, after
36+ /// a file was replaced: every block is the first node of its own file
37+ /// (§4.9).
38+ pub fn restitch(&mut self) {
39+ let mut blocks: Vec<(NRef, Id)> = Vec::new();
40+ for (fi, f) in self.files.iter().enumerate() {
41+ if fi == 0 {
42+ continue;
43+ }
44+ if let Some(&first) = f.nodes[f.root_node].children.first() {
45+ if let Some(b) = &f.nodes[first].block {
46+ if let Some(id) = &b.id {
47+ blocks.push(((fi, first), id.clone()));
48+ }
49+ }
50+ }
51+ }
52+ self.blocks = blocks;
53+ self.embed_index = OnceLock::new();
54+ }
55+
1956 pub fn node(&self, r: NRef) -> &Node {
2057 &self.files[r.0].nodes[r.1]
2158 }
@@ -117,24 +154,60 @@ impl Tree {
117154 segs
118155 }
119156
120− /// Resolve an embed node to the block it references (one level).
157+ /// Resolve an embed node to the block it references (one level). Only
158+ /// the block's own embed (`embed_of`) resolves: any other embed of its
159+ /// id is left as it is and reads as broken everywhere, as §6.2 says,
160+ /// so walk, render, the outline and every verb meet a block once.
121161 pub fn resolved_child(&self, r: NRef) -> NRef {
122162 let n = self.node(r);
123163 if let Some(id) = &n.embed {
124164 if let Some(target) = self.block_by_id(id) {
125− return target;
165+ if self.embed_of(id) == Some(r) {
166+ return target;
167+ }
126168 }
127169 }
128170 r
129171 }
130172
131− /// The embed node that references a block (§4.7), if any.
173+ /// The embed a block is stitched in at (§4.7), if any: see `embeds`.
132174 pub fn embed_of(&self, id: &Id) -> Option<NRef> {
133− self.files.iter().enumerate().find_map(|(fi, f)| {
134− f.nodes
135− .iter()
136− .position(|nd| nd.embed.as_ref() == Some(id))
137− .map(|ni| (fi, ni))
175+ self.embeds().get(id).copied()
176+ }
177+
178+ /// `embed_of` for every id at once: the embed each block is stitched in
179+ /// at, the first of its id met in tree order from the root, as walk
180+ /// and the outline meet them. Any other embed of the id is a duplicate
181+ /// and reads as broken (§6.2). An id embedded only in files the root
182+ /// does not reach (an orphan block, a cycle) keeps its first embed in
183+ /// file order.
184+ pub fn embeds(&self) -> &HashMap<Id, NRef> {
185+ self.embed_index.get_or_init(|| {
186+ let mut out = HashMap::new();
187+ if self.files.is_empty() {
188+ return out;
189+ }
190+ // pre-order, entering each block at its first embed only, so
191+ // the walk ends even through a cycle
192+ let mut stack = vec![self.root];
193+ while let Some(r) = stack.pop() {
194+ let mut at = r;
195+ if let Some(id) = &self.node(r).embed {
196+ if !out.contains_key(id) {
197+ out.insert(id.clone(), r);
198+ at = self.block_by_id(id).unwrap_or(r);
199+ }
200+ }
201+ stack.extend(self.node(at).children.iter().rev().map(|&c| (at.0, c)));
202+ }
203+ for (fi, f) in self.files.iter().enumerate() {
204+ for (ni, nd) in f.nodes.iter().enumerate() {
205+ if let Some(id) = &nd.embed {
206+ out.entry(id.clone()).or_insert((fi, ni));
207+ }
208+ }
209+ }
210+ out
138211 })
139212 }
140213
@@ -145,20 +218,13 @@ impl Tree {
145218 .map(|(t, _)| *t)
146219 }
147220
148− /// Direct children of a node with embeds resolved (broken embeds kept as-is).
221+ /// Direct children of a node with embeds resolved (broken embeds, and
222+ /// second embeds of a block, kept as-is).
149223 pub fn resolved_children(&self, r: NRef) -> Vec<NRef> {
150224 self.node(r)
151225 .children
152226 .iter()
153− .map(|&c| {
154− let cr = (r.0, c);
155− let rc = self.resolved_child(cr);
156− if rc != cr {
157− rc
158− } else {
159− cr
160− }
161− })
227+ .map(|&c| self.resolved_child((r.0, c)))
162228 .collect()
163229 }
164230
crates/fold-core/src/vault.rs+65−51
@@ -36,11 +36,7 @@ impl Vault {
3636 }
3737 let mut v = Vault {
3838 dir: dir.to_path_buf(),
39− tree: Tree {
40− files: Vec::new(),
41− root: (0, 0),
42− blocks: Vec::new(),
43− },
39+ tree: Tree::new(Vec::new()),
4440 };
4541 v.reload()?;
4642 Ok(v)
@@ -100,25 +96,7 @@ impl Vault {
10096 }
10197
10298 // Stitch: collect blocks, resolve embed edges (§4.7).
103− let mut blocks: Vec<(NRef, Id)> = Vec::new();
104− for (fi, f) in files.iter().enumerate() {
105− if fi == 0 {
106− continue;
107− }
108− if let Some(&first) = f.nodes[f.root_node].children.first() {
109− if let Some(b) = &f.nodes[first].block {
110− if let Some(id) = &b.id {
111− blocks.push(((fi, first), id.clone()));
112− }
113− }
114− }
115− }
116−
117− self.tree = Tree {
118− files,
119− root: (0, 0),
120− blocks,
121− };
99+ self.tree = Tree::new(files);
122100 Ok(())
123101 }
124102
@@ -180,14 +158,34 @@ impl Vault {
180158 self.write_file_text(file, &new_text)
181159 }
182160
183− /// Write a whole file atomically, then re-parse it.
161+ /// Write a whole file atomically, then re-parse it. Refuses, writing
162+ /// nothing, when the file on disk is no longer the text it was parsed
163+ /// from (`check_unchanged`).
184164 pub fn write_file_text(&mut self, file: usize, new_text: &str) -> std::io::Result<()> {
165+ self.check_unchanged(file)?;
185166 let path = self.tree.files[file].path.clone();
186167 atomic_write(&self.dir.join(&path), new_text)?;
187168 self.reparse(file, new_text)
188169 }
189170
190− /// Re-parse one file from given text (after our own write) and re-stitch.
171+ /// Refuse when the file on disk is no longer the text it was parsed
172+ /// from: every op computes its new text from the tree, so writing would
173+ /// silently drop a change (a sync, another editor) not reloaded yet
174+ /// (§1 principle 4, §5.2 step 5, §11.2).
175+ pub fn check_unchanged(&self, file: usize) -> std::io::Result<()> {
176+ let path = &self.tree.files[file].path;
177+ // a missing file reads as empty, as `reload` reads a missing root.md
178+ if read_if_exists(&self.dir.join(path))?.unwrap_or_default() != self.tree.files[file].text {
179+ return Err(std::io::Error::other(format!(
180+ "{} changed on disk; not overwriting",
181+ path
182+ )));
183+ }
184+ Ok(())
185+ }
186+
187+ /// Re-parse one file from given text (after our own write, or as read
188+ /// from disk) and re-stitch.
191189 pub fn reparse(&mut self, file: usize, text: &str) -> std::io::Result<()> {
192190 let path = self.tree.files[file].path.clone();
193191 let old_block = self.tree.files[file]
@@ -218,28 +216,10 @@ impl Vault {
218216 })
219217 };
220218 self.tree.files[file] = parse_file(&path, text, file, block);
221− self.restitch();
219+ self.tree.restitch();
222220 Ok(())
223221 }
224222
225− /// Rebuild the block list and edge spans after a re-parse.
226− fn restitch(&mut self) {
227− let mut blocks: Vec<(NRef, Id)> = Vec::new();
228− for (fi, f) in self.tree.files.iter().enumerate() {
229− if fi == 0 {
230− continue;
231− }
232− if let Some(&first) = f.nodes[f.root_node].children.first() {
233− if let Some(b) = &f.nodes[first].block {
234− if let Some(id) = &b.id {
235− blocks.push(((fi, first), id.clone()));
236− }
237− }
238− }
239− }
240− self.tree.blocks = blocks;
241− }
242−
243223 /// Shortest prefix of `id` not already used by another file (§6.4).
244224 pub fn unique_prefix(&self, id: &Id) -> String {
245225 self.unique_prefix_except(id, None)
@@ -263,14 +243,24 @@ impl Vault {
263243 .collect()
264244 }
265245
266− /// Move a file to the trash (§11.5) and remove it from the vault.
246+ /// Move a file to the trash (§11.5) and remove it from the vault. Only
247+ /// that file leaves the index: the others are not read again, so a
248+ /// change another program made to one of them (a sync) is not taken in
249+ /// as part of the operation, nor into its op-log entry (§10.10).
267250 pub fn trash_file(&mut self, file: usize) -> std::io::Result<()> {
268251 let path = self.tree.files[file].path.clone();
269252 let trash = trash_dir();
270253 std::fs::create_dir_all(&trash)?;
271254 let target = trash_target(&trash, &path);
272255 move_file(&self.dir.join(&path), &target)?;
273− self.reload()?;
256+ self.tree.files.remove(file);
257+ // the files after it move down one place
258+ for (fi, f) in self.tree.files.iter_mut().enumerate().skip(file) {
259+ for n in &mut f.nodes {
260+ n.file = fi;
261+ }
262+ }
263+ self.tree.restitch();
274264 Ok(())
275265 }
276266
@@ -464,6 +454,27 @@ impl Vault {
464454 Some(cur)
465455 }
466456
457+ /// Every node at a case-insensitive title path from the root (§3.4):
458+ /// each step keeps every child whose title matches, so a path is
459+ /// ambiguous only when it names two nodes, whatever titles repeat on
460+ /// the way.
461+ fn find_all_by_path(&self, segs: &[String]) -> Vec<NRef> {
462+ let mut found = vec![self.tree.root];
463+ for seg in segs {
464+ let mut next = Vec::new();
465+ for &p in &found {
466+ for c in self.tree.resolved_children(p) {
467+ // a block embedded twice is still one node
468+ if title_eq(&self.tree.node(c).title, seg) && !next.contains(&c) {
469+ next.push(c);
470+ }
471+ }
472+ }
473+ found = next;
474+ }
475+ found
476+ }
477+
467478 /// Resolve a command target: id (or unique leading run), then path, then
468479 /// unique title (§3.4).
469480 pub fn resolve_target(&self, text: &str) -> Result<NRef, String> {
@@ -494,10 +505,13 @@ impl Vault {
494505 None => self.tree.root,
495506 };
496507 let _ = base;
497− if let Some(r) = self.find_by_path(&segs) {
498− return Ok(r);
499− }
500− return Err(format!("no node at path {:?}", text));
508+ // a path that matches twice is an ambiguity, never a guess
509+ let found = self.find_all_by_path(&segs);
510+ return match found.len() {
511+ 1 => Ok(found[0]),
512+ 0 => Err(format!("no node at path {:?}", text)),
513+ _ => Err(format!("path {:?} is ambiguous", text)),
514+ };
501515 }
502516 // 3. unique title
503517 let mut found: Vec<NRef> = Vec::new();
crates/fold-core/tests/edit.rs+890−2
@@ -104,8 +104,9 @@ fn edit_nested_block_writes_its_own_file() {
104104#[test]
105105fn deleting_nested_block_title_trashes_block() {
106106 // §5.2: deleting a nested block's title line deletes the block; its
107− // other lines would need re-tagging (the TUI does that; here we just
108− // check the file write path).
107+ // other lines are re-tagged to the enclosing block on save (see
108+ // deleting_nested_block_title_keeps_its_text_reachable). Here: the
109+ // block's lines carry its own owner.
109110 let (_d, mut v) = vault_with("# A\n\n- task\n");
110111 let a = v.tree.resolved_children(v.tree.root)[0];
111112 let task = v.tree.resolved_children(a)[0];
@@ -253,3 +254,890 @@ fn noop_splice_of_spec_example_vault() {
253254 let homelab = v.tree.resolved_children(v.tree.root)[0];
254255 assert_noop_splice(&mut v, homelab);
255256}
257+
258+/// A vault whose root.md embeds one block file with the given body.
259+fn vault_with_block(body: &str) -> (tempfile::TempDir, Vault, std::path::PathBuf) {
260+ let dir = tempfile::tempdir().unwrap();
261+ let id = "racfer-hattes-mislup-nodrys";
262+ std::fs::write(dir.path().join("root.md"), format!("# ![[{id}]]\n")).unwrap();
263+ let bf = dir.path().join("racfer~inbox.md");
264+ std::fs::write(&bf, format!("---\nid: {id}\n---\n\n{body}")).unwrap();
265+ let v = Vault::open(dir.path()).unwrap();
266+ (dir, v, bf)
267+}
268+
269+#[test]
270+fn setext_heading_in_block_file_survives_noop_splice() {
271+ // §4.9: a column-0 node after a block file's root — here a setext
272+ // heading a phone editor appended — is adopted by the root, so it shows
273+ // under the block and a no-op splice keeps it on disk (§15.6), written
274+ // in place under the root.
275+ let (_d, mut v, bf) = vault_with_block("# Inbox\n\nNotes\n=====\n\nprecious\n");
276+ let inbox = v.tree.resolved_children(v.tree.root)[0];
277+ assert_eq!(v.tree.node(inbox).title, "Inbox");
278+ let kids = v.tree.resolved_children(inbox);
279+ assert_eq!(kids.len(), 1);
280+ assert_eq!(v.tree.node(kids[0]).title, "Notes");
281+ let mut buf = open_editor(&v, inbox);
282+ let owner = buf.lines[0].owner;
283+ buf.mark_dirty(owner);
284+ buf.splice(&mut v, owner).unwrap();
285+ let disk = std::fs::read_to_string(&bf).unwrap();
286+ assert_eq!(
287+ disk,
288+ "---\nid: racfer-hattes-mislup-nodrys\n---\n\n# Inbox\n\n## Notes\n\nprecious\n"
289+ );
290+}
291+
292+#[test]
293+fn setext_heading_after_a_bullet_block_root_is_adopted_once() {
294+ // the title line was adopted into the bullet root's text before its
295+ // underline showed up; it must become the node, not stay behind as text
296+ let (_d, mut v, bf) = vault_with_block("- Order switch\nTitle\n=====\nmore\n");
297+ let order = v.tree.resolved_children(v.tree.root)[0];
298+ let kids = v.tree.resolved_children(order);
299+ assert_eq!(kids.len(), 1);
300+ assert_eq!(v.tree.node(kids[0]).title, "Title");
301+ let diags: Vec<String> = fold_core::check::check(&v)
302+ .into_iter()
303+ .map(|d| d.message)
304+ .collect();
305+ assert!(!diags.iter().any(|m| m.contains("column-0 text")), "{diags:?}");
306+ assert!(diags.iter().any(|m| m.contains("column-0 node")), "{diags:?}");
307+ let mut buf = open_editor(&v, order);
308+ let owner = buf.lines[0].owner;
309+ buf.mark_dirty(owner);
310+ buf.splice(&mut v, owner).unwrap();
311+ let disk = std::fs::read_to_string(&bf).unwrap();
312+ assert_eq!(
313+ disk,
314+ "---\nid: racfer-hattes-mislup-nodrys\n---\n\n- Order switch\n # Title\n more\n"
315+ );
316+}
317+
318+#[test]
319+fn setext_heading_can_be_a_block_files_root() {
320+ // its title line is the root's, not text before the root (§4.9)
321+ let (_d, v, _bf) = vault_with_block("Inbox\n=====\n\nbody\n");
322+ let inbox = v.tree.resolved_children(v.tree.root)[0];
323+ assert_eq!(v.tree.node(inbox).title, "Inbox");
324+ let diags: Vec<String> = fold_core::check::check(&v)
325+ .into_iter()
326+ .map(|d| d.message)
327+ .collect();
328+ assert!(!diags.iter().any(|m| m.contains("before the block")), "{diags:?}");
329+}
330+
331+/// Two embeds of one block (§6.2: a diagnostic; the second renders as
332+/// broken). Returns the vault dir, the vault and the block file's path.
333+fn vault_with_duplicate_embed() -> (tempfile::TempDir, Vault, std::path::PathBuf) {
334+ let dir = tempfile::tempdir().unwrap();
335+ std::fs::write(
336+ dir.path().join("root.md"),
337+ "# Top\n\n## A\n\n![[racfer-hattes-mislup-nodrys]]\n\n## B\n\n![[racfer-hattes-mislup-nodrys]]\n",
338+ )
339+ .unwrap();
340+ let bf = dir.path().join("racfer~task.md");
341+ std::fs::write(&bf, "---\nid: racfer-hattes-mislup-nodrys\n---\n\n- task\n").unwrap();
342+ let v = Vault::open(dir.path()).unwrap();
343+ (dir, v, bf)
344+}
345+
346+#[test]
347+fn duplicate_embed_does_not_duplicate_block_text() {
348+ let (_d, mut v, bf) = vault_with_duplicate_embed();
349+ let top = v.tree.resolved_children(v.tree.root)[0];
350+ // the block is inlined at its first embed only; the second stays an
351+ // embed line, as a broken one would
352+ let r = fold_core::render::render(&v.tree, top, 1, true);
353+ assert_eq!(r.matches("- task").count(), 1, "{r}");
354+ assert_eq!(r.matches("![[racfer-hattes-mislup-nodrys]]").count(), 1, "{r}");
355+ let mut buf = open_editor(&v, top);
356+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
357+ buf.set_line(i, "- task!".into());
358+ buf.save_all(&mut v).unwrap();
359+ assert_eq!(
360+ std::fs::read_to_string(&bf).unwrap(),
361+ "---\nid: racfer-hattes-mislup-nodrys\n---\n\n- task!\n"
362+ );
363+}
364+
365+#[test]
366+fn duplicate_embed_survives_saving_the_parent() {
367+ let (d, mut v, _bf) = vault_with_duplicate_embed();
368+ let top = v.tree.resolved_children(v.tree.root)[0];
369+ let mut buf = open_editor(&v, top);
370+ let i = buf.lines.iter().position(|l| l.text == "# Top").unwrap();
371+ buf.set_line(i, "# Top!".into());
372+ buf.save_all(&mut v).unwrap();
373+ assert_eq!(
374+ std::fs::read_to_string(d.path().join("root.md")).unwrap(),
375+ "# Top!\n\n## A\n\n![[racfer-hattes-mislup-nodrys]]\n\n## B\n\n![[racfer-hattes-mislup-nodrys]]\n"
376+ );
377+ // zoomed into B, the second embed is still the broken one
378+ let top = v.tree.resolved_children(v.tree.root)[0];
379+ let b = v.tree.resolved_children(top)[1];
380+ assert_eq!(
381+ fold_core::render::render(&v.tree, b, 1, true),
382+ "# B\n\n![[racfer-hattes-mislup-nodrys]]\n"
383+ );
384+ assert_noop_splice(&mut v, top);
385+}
386+
387+#[test]
388+fn line_opened_above_zoomed_title_is_not_duplicated() {
389+ // Editing a plain (non-block) section: a line opened above its title
390+ // (vim `O` on line 0, or Enter at column 0 of line 0) is saved, then a
391+ // later edit is saved from the same buffer. The second splice must
392+ // rewrite the region the first one wrote, not whatever node now starts
393+ // at the section's original byte offset.
394+ let (_d, mut v) = vault_with("# A\n\nbody\n");
395+ let a = v.tree.resolved_children(v.tree.root)[0];
396+ let mut buf = open_editor(&v, a);
397+ // what `O` / Enter at column 0 of line 0 does: split line 0
398+ buf.set_line(0, "# Intro".into());
399+ buf.insert_line(0, "# A".into());
400+ buf.save_all(&mut v).unwrap();
401+ assert_eq!(v.tree.files[0].text, "# Intro\n# A\n\nbody\n");
402+ let i = buf.lines.iter().position(|l| l.text == "body").unwrap();
403+ buf.set_line(i, "body!".into());
404+ buf.save_all(&mut v).unwrap();
405+ assert_eq!(v.tree.files[0].text, "# Intro\n# A\n\nbody!\n");
406+ // a plain first line: no node starts where the section did any more
407+ let (_d, mut v) = vault_with("# A\n\nbody\n\n# B\n\nb\n");
408+ let a = v.tree.resolved_children(v.tree.root)[0];
409+ let mut buf = open_editor(&v, a);
410+ buf.set_line(0, "intro".into());
411+ buf.insert_line(0, "# A".into());
412+ buf.save_all(&mut v).unwrap();
413+ let i = buf.lines.iter().position(|l| l.text == "body").unwrap();
414+ buf.set_line(i, "body!".into());
415+ buf.save_all(&mut v).unwrap();
416+ assert_eq!(v.tree.files[0].text, "intro\n# A\n\nbody!\n\n# B\n\nb\n");
417+}
418+
419+#[test]
420+fn splice_keeps_text_before_block_root() {
421+ // §4.9: text before a block file's root is a diagnostic and the file is
422+ // read-only until fixed; §11.5: never delete user content without a
423+ // trash copy. Saving the block from the editor must not drop it.
424+ let dir = tempfile::tempdir().unwrap();
425+ std::fs::write(
426+ dir.path().join("root.md"),
427+ "# A\n\n![[racfer-hattes-mislup-nodrys]]\n",
428+ )
429+ .unwrap();
430+ let bf = dir.path().join("racfer~task.md");
431+ let malformed = "---\nid: racfer-hattes-mislup-nodrys\n---\n\nphone note\n- task\n";
432+ std::fs::write(&bf, malformed).unwrap();
433+ let mut v = Vault::open(dir.path()).unwrap();
434+ let a = v.tree.resolved_children(v.tree.root)[0];
435+ let mut buf = open_editor(&v, a);
436+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
437+ buf.set_line(i, "- task!".into());
438+ // refused (read-only), and the block stays unsaved
439+ assert!(buf.save_all(&mut v).is_err());
440+ assert_eq!(std::fs::read_to_string(&bf).unwrap(), malformed);
441+ assert!(buf.dirty.iter().any(|o| o.file == 1));
442+}
443+
444+#[test]
445+fn splice_never_writes_a_block_file_without_its_root() {
446+ // §5.2 step 2: a block's text parses to exactly one root-level node.
447+ // Editing a block itself and deleting its title line leaves no root;
448+ // splice refuses rather than write a file with only frontmatter and
449+ // text, which would drop the block from the tree.
450+ let (d, mut v) = vault_with("# A\n\n- task\n note\n");
451+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
452+ ops::make_block(&mut v, t).unwrap();
453+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
454+ let path = d.path().join(&v.tree.files[1].path);
455+ let before = std::fs::read_to_string(&path).unwrap();
456+ let mut buf = open_editor(&v, t);
457+ let texts: Vec<&str> = buf.lines.iter().map(|l| l.text.as_str()).collect();
458+ assert_eq!(texts, ["- task", " note"]);
459+ buf.delete_line(0);
460+ assert!(buf.save_all(&mut v).is_err());
461+ assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
462+ // text typed above the title line would be text before the root
463+ let mut buf = open_editor(&v, t);
464+ buf.set_line(0, "intro".into());
465+ buf.insert_line(0, "- task".into());
466+ assert!(buf.save_all(&mut v).is_err());
467+ assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
468+}
469+
470+#[test]
471+fn deleting_nested_block_title_keeps_its_text_reachable() {
472+ // §5.2: deleting a nested block's title line deletes the block; the
473+ // lines it still owned become plain text of the enclosing block. The
474+ // block file must not be rewritten without a root (which drops the
475+ // block from the tree, breaks the parent's embed and hides "note").
476+ let (d, mut v) = vault_with("# A\n\n- task\n note\n");
477+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
478+ ops::make_block(&mut v, t).unwrap();
479+ let block_path = v.tree.files[1].path.clone();
480+ let a = v.tree.resolved_children(v.tree.root)[0];
481+ let mut buf = open_editor(&v, a);
482+ let texts: Vec<&str> = buf.lines.iter().map(|l| l.text.as_str()).collect();
483+ assert_eq!(texts, ["# A", "", "- task", " note"]);
484+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
485+ buf.delete_line(i);
486+ buf.save_all(&mut v).unwrap();
487+ let a = v.tree.resolved_children(v.tree.root)[0];
488+ let shown = fold_core::render(&v.tree, a, 1, true);
489+ let files: Vec<(&str, &str)> = v
490+ .tree
491+ .files
492+ .iter()
493+ .map(|f| (f.path.as_str(), f.text.as_str()))
494+ .collect();
495+ assert!(
496+ shown.contains("note") && !shown.contains("![["),
497+ "shown:\n{}\nfiles: {:?}",
498+ shown,
499+ files
500+ );
501+ assert_eq!(v.tree.files[0].text, "# A\n\n note\n");
502+ // the block's file went to the trash (§11.5)
503+ assert!(!d.path().join(&block_path).exists());
504+ assert_eq!(v.tree.files.len(), 1);
505+ assert!(buf.dirty.is_empty(), "{:?}", buf.dirty);
506+}
507+
508+#[test]
509+fn deleted_block_title_hands_nested_blocks_to_the_enclosing_block() {
510+ // the block nested in the deleted one survives, embedded where its
511+ // title line sits, now in the enclosing block's text
512+ let (_d, mut v) = vault_with("# A\n\n## B\n\nb text\n\n### C\n\nc\n");
513+ let c = v.find_by_path(&["A".into(), "B".into(), "C".into()]).unwrap();
514+ let c_id = ops::make_block(&mut v, c).unwrap();
515+ let b = v.find_by_path(&["A".into(), "B".into()]).unwrap();
516+ ops::make_block(&mut v, b).unwrap();
517+ let a = v.tree.resolved_children(v.tree.root)[0];
518+ let mut buf = open_editor(&v, a);
519+ let texts: Vec<&str> = buf.lines.iter().map(|l| l.text.as_str()).collect();
520+ assert_eq!(texts, ["# A", "", "## B", "", "b text", "", "### C", "", "c"]);
521+ buf.delete_line(2);
522+ buf.delete_line(2);
523+ buf.save_all(&mut v).unwrap();
524+ // B's other lines are A's; the deeper heading C was never B's title
525+ assert_eq!(
526+ v.tree.files[0].text,
527+ format!("# A\n\nb text\n\n### ![[{}]]\n", c_id.as_str())
528+ );
529+ assert_eq!(v.tree.files.len(), 2);
530+ let a = v.tree.resolved_children(v.tree.root)[0];
531+ let shown = fold_core::render(&v.tree, a, 1, true);
532+ assert!(
533+ shown.starts_with("# A\n\nb text\n\n#") && shown.ends_with(" C\n\nc\n") && !shown.contains("![["),
534+ "{}",
535+ shown
536+ );
537+}
538+
539+#[test]
540+fn line_typed_above_nested_block_title_is_the_enclosing_blocks() {
541+ // Enter at column 0 of a nested block's title line opens a line above
542+ // it with the block's tag; what is typed there sits above the block, in
543+ // its parent (§5.2: lines no longer with the title line are re-tagged
544+ // to the block they sit in), not before the block file's root (§4.9).
545+ let (_d, mut v) = vault_with("# A\n\n- task\n");
546+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
547+ let id = ops::make_block(&mut v, t).unwrap();
548+ let block_before = v.tree.files[1].text.clone();
549+ let a = v.tree.resolved_children(v.tree.root)[0];
550+ let mut buf = open_editor(&v, a);
551+ buf.set_line(2, String::new());
552+ buf.insert_line(2, "- task".into());
553+ buf.set_line(2, "Intro".into());
554+ assert_eq!(buf.lines[2].owner.file, 1);
555+ buf.save_all(&mut v).unwrap();
556+ assert_eq!(v.tree.files[0].text, format!("# A\n\nIntro\n![[{}]]\n", id.as_str()));
557+ assert_eq!(v.tree.files[1].text, block_before);
558+ assert_eq!(buf.lines[2].owner.file, 0);
559+ assert!(buf.dirty.is_empty(), "{:?}", buf.dirty);
560+}
561+
562+#[test]
563+fn emptied_nested_block_title_is_not_a_deleted_one() {
564+ // vim `cc` on a nested block's title line, then a pause (autosave): the
565+ // title is being retyped, so the block is not deleted; it is not written
566+ // without its root either, and saves once it has a title again.
567+ let (_d, mut v) = vault_with("# A\n\n- task\n note\n");
568+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
569+ let id = ops::make_block(&mut v, t).unwrap();
570+ let root_before = v.tree.files[0].text.clone();
571+ let block_before = v.tree.files[1].text.clone();
572+ let a = v.tree.resolved_children(v.tree.root)[0];
573+ let mut buf = open_editor(&v, a);
574+ buf.set_line(2, String::new());
575+ assert!(buf.save_all(&mut v).is_err());
576+ assert_eq!(v.tree.files[0].text, root_before);
577+ assert_eq!(v.tree.files[1].text, block_before);
578+ buf.set_line(2, "- renamed".into());
579+ buf.save_all(&mut v).unwrap();
580+ assert_eq!(v.tree.files[0].text, root_before);
581+ assert_eq!(
582+ v.tree.files[1].text,
583+ format!("---\nid: {}\n---\n\n- renamed\n note\n", id.as_str())
584+ );
585+}
586+
587+#[test]
588+fn deleting_all_lines_of_nested_block_clears_dirty() {
589+ // §5.2: deleting a nested block's title line deletes the block (its
590+ // embed leaves the parent, its file goes to trash). The save must not
591+ // leave its owner dirty forever with nothing written.
592+ let (_d, mut v) = vault_with("# A\n\n- task\n");
593+ let a = v.tree.resolved_children(v.tree.root)[0];
594+ let task = v.tree.resolved_children(a)[0];
595+ ops::make_block(&mut v, task).unwrap();
596+ assert!(v.tree.files[0].text.contains("![["), "{}", v.tree.files[0].text);
597+ let a = v.tree.resolved_children(v.tree.root)[0];
598+ let mut buf = open_editor(&v, a);
599+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
600+ buf.delete_line(i);
601+ assert_eq!(buf.save_all(&mut v).unwrap(), 1);
602+ assert!(buf.dirty.is_empty(), "still dirty after save: {:?}", buf.dirty);
603+ assert!(!v.tree.files[0].text.contains("![["), "{}", v.tree.files[0].text);
604+ assert_eq!(v.tree.files.len(), 1);
605+ // a block with nothing at all left to write (every line of the edited
606+ // node gone) writes nothing, is not counted as saved, and is not left
607+ // dirty to be saved again on every pause
608+ let (_d, mut v) = vault_with("# A\n\nbody\n");
609+ let a = v.tree.resolved_children(v.tree.root)[0];
610+ let mut buf = open_editor(&v, a);
611+ while !buf.lines.is_empty() {
612+ buf.delete_line(0);
613+ }
614+ assert_eq!(buf.save_all(&mut v).unwrap(), 0);
615+ assert!(buf.dirty.is_empty(), "still dirty after save: {:?}", buf.dirty);
616+ assert_eq!(v.tree.files[0].text, "# A\n\nbody\n");
617+}
618+
619+#[test]
620+fn one_refused_block_does_not_block_other_saves() {
621+ // §5.2: each dirty block is written by its own splice. A block whose file
622+ // changed on disk is refused and stays dirty; the other dirty blocks are
623+ // still written.
624+ let (d, mut v) = vault_with("# A\n\n- task\n");
625+ let a = v.tree.resolved_children(v.tree.root)[0];
626+ let task = v.tree.resolved_children(a)[0];
627+ ops::make_block(&mut v, task).unwrap();
628+ let a = v.tree.resolved_children(v.tree.root)[0];
629+ let mut buf = open_editor(&v, a);
630+ // edit the nested block first, then root.md's heading: dirty = [block, root]
631+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
632+ assert_eq!(buf.lines[i].owner.file, 1);
633+ buf.set_line(i, "- task (mine)".into());
634+ assert_eq!(buf.lines[0].owner.file, 0);
635+ buf.set_line(0, "# A renamed".into());
636+ // another editor changes the block file meanwhile
637+ let p = d.path().join(&v.tree.files[1].path);
638+ let s = std::fs::read_to_string(&p).unwrap().replace("- task", "- task (theirs)");
639+ std::fs::write(&p, &s).unwrap();
640+ let err = buf.save_all(&mut v).unwrap_err();
641+ assert!(err.to_string().contains("changed on disk"), "{}", err);
642+ // the refused block is not overwritten and stays dirty
643+ assert_eq!(std::fs::read_to_string(&p).unwrap(), s);
644+ assert!(buf.dirty.iter().any(|o| o.file == 1));
645+ // root.md's edit is written anyway
646+ let disk = std::fs::read_to_string(d.path().join("root.md")).unwrap();
647+ assert!(disk.starts_with("# A renamed"), "{}", disk);
648+ assert!(v.tree.files[0].text.starts_with("# A renamed"), "{}", v.tree.files[0].text);
649+ assert!(!buf.dirty.iter().any(|o| o.file == 0));
650+}
651+
652+#[test]
653+fn a_save_goes_through_when_the_file_changed_only_outside_the_block() {
654+ // §5.2 step 5 compares the block's source span, not the whole file:
655+ // a sync that changed another section of root.md (and moved this one
656+ // down) while the user typed does not refuse the save, and both
657+ // changes are kept
658+ let (d, mut v) = vault_with("# A\n\nbody\n\n# B\n\nother\n");
659+ let a = v.tree.resolved_children(v.tree.root)[0];
660+ let mut buf = open_editor(&v, a);
661+ let synced = "intro\n\n# A\n\nbody\n\n# B\n\nother, from Helix\n";
662+ std::fs::write(d.path().join("root.md"), synced).unwrap();
663+ let i = buf.lines.iter().position(|l| l.text == "body").unwrap();
664+ buf.set_line(i, "mine".into());
665+ assert_eq!(buf.save_all(&mut v).unwrap(), 1);
666+ let disk = std::fs::read_to_string(d.path().join("root.md")).unwrap();
667+ assert_eq!(disk, "intro\n\n# A\n\nmine\n\n# B\n\nother, from Helix\n");
668+ assert_eq!(v.tree.files[0].text, disk);
669+ assert!(buf.dirty.is_empty());
670+ // the next save starts from what this one wrote
671+ buf.set_line(i, "mine again".into());
672+ assert_eq!(buf.save_all(&mut v).unwrap(), 1);
673+ let disk = std::fs::read_to_string(d.path().join("root.md")).unwrap();
674+ assert_eq!(disk, "intro\n\n# A\n\nmine again\n\n# B\n\nother, from Helix\n");
675+ // a change to the block's own text is still refused (§5.2 step 5)
676+ std::fs::write(d.path().join("root.md"), disk.replace("mine again", "theirs")).unwrap();
677+ buf.set_line(i, "mine, third".into());
678+ let err = buf.save_all(&mut v).unwrap_err();
679+ assert!(err.to_string().contains("changed on disk"), "{}", err);
680+ assert!(std::fs::read_to_string(d.path().join("root.md")).unwrap().contains("theirs"));
681+}
682+
683+#[test]
684+fn a_block_save_keeps_a_property_set_on_disk_meanwhile() {
685+ // a block's source span is its file after the frontmatter: a property
686+ // another device set while the block was edited stays
687+ let (d, mut v) = vault_with("# A\n\n- task\n");
688+ let a = v.tree.resolved_children(v.tree.root)[0];
689+ let task = v.tree.resolved_children(a)[0];
690+ ops::make_block(&mut v, task).unwrap();
691+ let a = v.tree.resolved_children(v.tree.root)[0];
692+ let mut buf = open_editor(&v, a);
693+ let p = d.path().join(&v.tree.files[1].path);
694+ let theirs = std::fs::read_to_string(&p).unwrap().replace("\n---\n", "\ndue: 2026-10-01\n---\n");
695+ std::fs::write(&p, &theirs).unwrap();
696+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
697+ buf.set_line(i, "- task (mine)".into());
698+ assert_eq!(buf.save_all(&mut v).unwrap(), 1);
699+ let disk = std::fs::read_to_string(&p).unwrap();
700+ assert_eq!(disk, theirs.replace("- task", "- task (mine)"));
701+ assert!(disk.contains("due: 2026-10-01\n---\n\n- task (mine)\n"), "{}", disk);
702+}
703+
704+#[test]
705+fn a_held_block_title_is_in_transit_not_deleted() {
706+ // §5.2: cutting a nested block's title line and pasting it moves the
707+ // block. While the editor's clipboard holds the line, a save writes the
708+ // parent without the embed and leaves the block and its file alone; put
709+ // back, the block is embedded where it now sits; released without being
710+ // put back, it is deleted like any block whose title line is.
711+ let (d, mut v) = vault_with("# A\n\n- one\n- task\n- two\n");
712+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
713+ let id = ops::make_block(&mut v, t).unwrap();
714+ let block_path = v.tree.files[1].path.clone();
715+ let block_before = v.tree.files[1].text.clone();
716+ let a = v.tree.resolved_children(v.tree.root)[0];
717+ let mut buf = open_editor(&v, a);
718+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
719+ let cut = buf.lines[i].clone();
720+ buf.delete_line(i);
721+ buf.hold(vec![cut.owner]);
722+ buf.save_all(&mut v).unwrap();
723+ assert_eq!(v.tree.files[0].text, "# A\n\n- one\n- two\n");
724+ assert_eq!(std::fs::read_to_string(d.path().join(&block_path)).unwrap(), block_before);
725+ // pasted below "- two" with its tag: the block and its parent are dirty
726+ let two = buf.lines.iter().position(|l| l.text == "- two").unwrap();
727+ buf.lines.insert(two + 1, cut.clone());
728+ buf.mark_dirty(cut.owner);
729+ buf.mark_dirty(buf.lines[0].owner);
730+ buf.save_all(&mut v).unwrap();
731+ assert_eq!(v.tree.files[0].text, format!("# A\n\n- one\n- two\n![[{}]]\n", id.as_str()));
732+ assert_eq!(std::fs::read_to_string(d.path().join(&block_path)).unwrap(), block_before);
733+ // cut again and saved, then the clipboard replaced: the block is deleted
734+ buf.delete_line(two + 1);
735+ buf.save_all(&mut v).unwrap();
736+ assert!(d.path().join(&block_path).exists());
737+ buf.hold(Vec::new());
738+ buf.save_all(&mut v).unwrap();
739+ assert_eq!(v.tree.files[0].text, "# A\n\n- one\n- two\n");
740+ assert!(!d.path().join(&block_path).exists());
741+ assert!(buf.dirty.is_empty(), "{:?}", buf.dirty);
742+}
743+
744+#[test]
745+fn a_held_block_still_in_the_buffer_is_not_in_transit() {
746+ // the clipboard holding a copy of a block's title line keeps the block
747+ // only while nothing of it is in the buffer; a title line re-spelled in
748+ // place, or deleted while a block nested in it stays, deletes the block
749+ // (§5.2) at once
750+ let (d, mut v) = vault_with("# A\n\n- one\n- task\n");
751+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
752+ ops::make_block(&mut v, t).unwrap();
753+ let block_path = v.tree.files[1].path.clone();
754+ let a = v.tree.resolved_children(v.tree.root)[0];
755+ let mut buf = open_editor(&v, a);
756+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
757+ buf.hold(vec![buf.lines[i].owner]);
758+ buf.set_line(i, "task".into());
759+ buf.save_all(&mut v).unwrap();
760+ assert_eq!(v.tree.files[0].text, "# A\n\n- one\ntask\n");
761+ assert!(!d.path().join(&block_path).exists());
762+
763+ let (d, mut v) = vault_with("# A\n\n- one\n- b\n - c\n- two\n");
764+ let c = v.find_by_path(&["A".into(), "b".into(), "c".into()]).unwrap();
765+ let c = ops::make_block(&mut v, c).unwrap();
766+ let b = v.find_by_path(&["A".into(), "b".into()]).unwrap();
767+ ops::make_block(&mut v, b).unwrap();
768+ let b_path = v.tree.files.iter().find(|f| f.text.contains(&format!("![[{}]]", c.as_str()))).unwrap().path.clone();
769+ let a = v.tree.resolved_children(v.tree.root)[0];
770+ let mut buf = open_editor(&v, a);
771+ let i = buf.lines.iter().position(|l| l.text == "- b").unwrap();
772+ buf.hold(vec![buf.lines[i].owner]);
773+ buf.delete_line(i);
774+ buf.save_all(&mut v).unwrap();
775+ assert_eq!(v.tree.files[0].text, format!("# A\n\n- one\n ![[{}]]\n- two\n", c.as_str()));
776+ assert!(!d.path().join(&b_path).exists());
777+}
778+
779+#[test]
780+fn review_external_edit_of_the_zoomed_node_never_lands_on_an_identical_one() {
781+ // §5.2 step 5: the zoomed item's own text changed on disk (another
782+ // device edited it) while it was edited here. Its old bytes still occur
783+ // once in the file — as an identical item under another section — but
784+ // that is another node: the save must be refused, not written over it.
785+ let (d, mut v) = vault_with("# X\n\n- call mom\n\n# Y\n\n- call mom\n");
786+ let x_item = v.find_by_path(&["X".into(), "call mom".into()]).unwrap();
787+ let mut buf = open_editor(&v, x_item);
788+ assert_eq!(buf.lines[0].text, "- call mom");
789+ let theirs = "# X\n\n- call mom (phone)\n\n# Y\n\n- call mom\n";
790+ std::fs::write(d.path().join("root.md"), theirs).unwrap();
791+ buf.set_line(0, "- call mom tomorrow".into());
792+ let res = buf.save_all(&mut v);
793+ let disk = std::fs::read_to_string(d.path().join("root.md")).unwrap();
794+ assert!(
795+ res.is_err() && disk == theirs,
796+ "save {:?}; Y's item overwritten:\n{}",
797+ res.map_err(|e| e.to_string()),
798+ disk
799+ );
800+ // changes elsewhere, one of them an identical item added above, still
801+ // let the save through, onto the zoomed node
802+ let (d, mut v) = vault_with("# X\n\n- call mom\n\n# Y\n\n- call mom\n");
803+ let x_item = v.find_by_path(&["X".into(), "call mom".into()]).unwrap();
804+ let mut buf = open_editor(&v, x_item);
805+ let theirs = "# W\n\n- call mom\n\n# X\n\n- call mom\n\n# Y\n\n- call mom, from phone\n";
806+ std::fs::write(d.path().join("root.md"), theirs).unwrap();
807+ buf.set_line(0, "- call mom tomorrow".into());
808+ assert_eq!(buf.save_all(&mut v).unwrap(), 1);
809+ assert_eq!(
810+ std::fs::read_to_string(d.path().join("root.md")).unwrap(),
811+ "# W\n\n- call mom\n\n# X\n\n- call mom tomorrow\n\n# Y\n\n- call mom, from phone\n"
812+ );
813+}
814+
815+#[test]
816+fn review_undoing_an_editor_save_leaves_another_files_sync_alone() {
817+ // §10.10: an op-log entry holds exactly the files the operation
818+ // changed. An editor save that deletes a nested block trashes its file,
819+ // and the trash reloads every file: a sync to another block's file not
820+ // reloaded yet must not become part of the save's entry, or undoing
821+ // the save writes that file's old text back over the sync
822+ let (d, mut v) = vault_with("# A\n\n- task\n\n# N\n\n- note\n");
823+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
824+ ops::make_block(&mut v, t).unwrap();
825+ let n = v.find_by_path(&["N".into(), "note".into()]).unwrap();
826+ let note_id = ops::make_block(&mut v, n).unwrap();
827+ let note = d.path().join(&v.tree.files[v.tree.block_by_id(&note_id).unwrap().0].path);
828+ let a = v.tree.resolved_children(v.tree.root)[0];
829+ let mut buf = open_editor(&v, a);
830+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
831+ buf.delete_line(i);
832+ let synced = std::fs::read_to_string(&note).unwrap().replace("- note", "- note, from phone");
833+ std::fs::write(&note, &synced).unwrap();
834+ // what the app does on a save (§10.10)
835+ buf.rebase_dirty(&mut v);
836+ let snap = ops::Snapshot::take(&v, "edit");
837+ buf.save_all(&mut v).unwrap();
838+ let inv = ops::Inverse::since(snap, &v).unwrap();
839+ inv.undo(&mut v).unwrap();
840+ assert_eq!(
841+ std::fs::read_to_string(&note).unwrap(),
842+ synced,
843+ "undoing the save reverted another device's change to a file it never touched"
844+ );
845+}
846+
847+#[test]
848+fn review_a_block_is_not_trashed_while_the_file_on_disk_embeds_it() {
849+ // A block cut in the editor is in transit: the parent is saved without
850+ // its embed. Another device's root.md, which still embeds it, then
851+ // lands (§12.1). Releasing the clipboard deletes the block only once no
852+ // file embeds it (§5.2): the parent's save is refused (changed on
853+ // disk), so root.md on disk still embeds the block and its file must
854+ // stay, not go to the trash leaving that embed broken
855+ let (d, mut v) = vault_with("# A\n\n- one\n- task\n- two\n");
856+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
857+ let id = ops::make_block(&mut v, t).unwrap();
858+ let block = d.path().join(&v.tree.files[1].path);
859+ let a = v.tree.resolved_children(v.tree.root)[0];
860+ let mut buf = open_editor(&v, a);
861+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
862+ let cut = buf.lines[i].owner;
863+ buf.delete_line(i);
864+ buf.hold(vec![cut]);
865+ buf.save_all(&mut v).unwrap();
866+ assert_eq!(v.tree.files[0].text, "# A\n\n- one\n- two\n");
867+ assert!(block.exists(), "in transit");
868+ let theirs = format!("# A\n\n- one\n![[{}]]\n- two, from phone\n", id.as_str());
869+ std::fs::write(d.path().join("root.md"), &theirs).unwrap();
870+ buf.hold(Vec::new());
871+ let res = buf.save_all(&mut v);
872+ assert_eq!(std::fs::read_to_string(d.path().join("root.md")).unwrap(), theirs);
873+ assert!(
874+ block.exists(),
875+ "save {:?}: root.md embeds {} but its file went to the trash",
876+ res.map_err(|e| e.to_string()),
877+ id.as_str()
878+ );
879+}
880+
881+#[test]
882+fn discarding_the_buffer_deletes_a_block_a_save_left_embedded_nowhere() {
883+ // Revert (§10.6) drops the unsaved text, but a block cut and saved in
884+ // transit cannot be pasted back any more: it is deleted (§5.2). One cut
885+ // and not saved is still embedded, and stays
886+ let (d, mut v) = vault_with("# A\n\n- one\n- task\n- two\n");
887+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
888+ ops::make_block(&mut v, t).unwrap();
889+ let block = d.path().join(&v.tree.files[1].path);
890+ let a = v.tree.resolved_children(v.tree.root)[0];
891+ let cut = |v: &Vault| {
892+ let mut buf = open_editor(v, a);
893+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
894+ let o = buf.lines[i].owner;
895+ buf.delete_line(i);
896+ buf.hold(vec![o]);
897+ buf
898+ };
899+ let buf = cut(&v);
900+ v.reload().unwrap();
901+ buf.discard(&mut v).unwrap();
902+ assert!(block.exists());
903+ assert_eq!(v.tree.files.len(), 2);
904+ let mut buf = cut(&v);
905+ buf.save_all(&mut v).unwrap();
906+ assert!(block.exists(), "in transit");
907+ v.reload().unwrap();
908+ buf.discard(&mut v).unwrap();
909+ assert!(!block.exists());
910+ assert_eq!(v.tree.files.len(), 1);
911+ assert_eq!(std::fs::read_to_string(d.path().join("root.md")).unwrap(), "# A\n\n- one\n- two\n");
912+}
913+
914+#[test]
915+fn review_indenting_a_nested_block_title_line_does_not_delete_the_block() {
916+ // §5.2: only deleting a nested block's title line deletes the block.
917+ // Indenting "- task" under "- one" (Tab, vim `>>`) keeps the title line:
918+ // the block must survive, embedded where its title line now sits
919+ let (d, mut v) = vault_with("# A\n\n- one\n- task\n");
920+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
921+ let id = ops::make_block(&mut v, t).unwrap();
922+ let block = d.path().join(&v.tree.files[1].path);
923+ let a = v.tree.resolved_children(v.tree.root)[0];
924+ let mut buf = open_editor(&v, a);
925+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
926+ buf.set_line(i, " - task".into());
927+ buf.save_all(&mut v).unwrap();
928+ let root = std::fs::read_to_string(d.path().join("root.md")).unwrap();
929+ assert!(
930+ block.exists() && root.contains(&format!("![[{}]]", id.as_str())),
931+ "block file exists: {}; root.md:\n{}",
932+ block.exists(),
933+ root
934+ );
935+}
936+
937+#[test]
938+fn a_re_indented_or_re_spelled_block_title_line_moves_the_block() {
939+ // §5.2: the embed goes where the title line sits, in its form (a
940+ // heading embed for a heading); the block's file keeps its root at
941+ // column 0, a heading at level 1 (§4.9)
942+ let (_d, mut v) = vault_with("# A\n\n- one\n- task\n body\n");
943+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
944+ let id = ops::make_block(&mut v, t).unwrap();
945+ let fm = format!("---\nid: {}\n---\n\n", id.as_str());
946+ let a = v.tree.resolved_children(v.tree.root)[0];
947+ let mut buf = open_editor(&v, a);
948+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
949+ buf.set_line(i, " - task".into());
950+ buf.set_line(i + 1, " body".into());
951+ buf.save_all(&mut v).unwrap();
952+ assert_eq!(v.tree.files[0].text, format!("# A\n\n- one\n ![[{}]]\n", id.as_str()));
953+ assert_eq!(v.tree.files[1].text, format!("{}- task\n body\n", fm));
954+ // renamed after: still the block's title line
955+ buf.set_line(i, " - task, renamed".into());
956+ buf.save_all(&mut v).unwrap();
957+ assert_eq!(v.tree.files[1].text, format!("{}- task, renamed\n body\n", fm));
958+ // re-spelled as a heading: a heading embed, a level-1 root
959+ buf.set_line(i, "## task".into());
960+ buf.set_line(i + 1, "body".into());
961+ buf.save_all(&mut v).unwrap();
962+ assert_eq!(v.tree.files[0].text, format!("# A\n\n- one\n## ![[{}]]\n", id.as_str()));
963+ assert_eq!(v.tree.files[1].text, format!("{}# task\nbody\n", fm));
964+ // and back
965+ buf.set_line(i, "- task".into());
966+ buf.set_line(i + 1, " body".into());
967+ buf.save_all(&mut v).unwrap();
968+ assert_eq!(v.tree.files[0].text, format!("# A\n\n- one\n![[{}]]\n", id.as_str()));
969+ assert_eq!(v.tree.files[1].text, format!("{}- task\n body\n", fm));
970+ assert!(buf.dirty.is_empty(), "{:?}", buf.dirty);
971+
972+ // a block nested in an item, dedented out of it
973+ let (_d, mut v) = vault_with("# A\n\n- one\n - c\n");
974+ let c = v.find_by_path(&["A".into(), "one".into(), "c".into()]).unwrap();
975+ let id = ops::make_block(&mut v, c).unwrap();
976+ let a = v.tree.resolved_children(v.tree.root)[0];
977+ let mut buf = open_editor(&v, a);
978+ let i = buf.lines.iter().position(|l| l.text == " - c").unwrap();
979+ buf.set_line(i, "- c".into());
980+ buf.save_all(&mut v).unwrap();
981+ assert_eq!(v.tree.files[0].text, format!("# A\n\n- one\n![[{}]]\n", id.as_str()));
982+ assert!(v.tree.files[1].text.ends_with("---\n\n- c\n"), "{}", v.tree.files[1].text);
983+
984+ // deleting the title line still deletes the block when it leaves a
985+ // child of the block first, whatever its indent (§5.2)
986+ let (d, mut v) = vault_with("# A\n\n- b\n - x\n");
987+ let b = v.find_by_path(&["A".into(), "b".into()]).unwrap();
988+ ops::make_block(&mut v, b).unwrap();
989+ let b_path = d.path().join(&v.tree.files[1].path);
990+ let a = v.tree.resolved_children(v.tree.root)[0];
991+ let mut buf = open_editor(&v, a);
992+ let i = buf.lines.iter().position(|l| l.text == "- b").unwrap();
993+ buf.delete_line(i);
994+ buf.save_all(&mut v).unwrap();
995+ assert_eq!(v.tree.files[0].text, "# A\n\n - x\n");
996+ assert!(!b_path.exists());
997+}
998+
999+#[test]
1000+fn review_indenting_a_block_title_line_under_a_sibling_block_keeps_it_in_the_tree() {
1001+ // §5.2: " - c" re-indented under "- b", itself a nested block, sits in
1002+ // b: c's embed goes into b's file, never indented under b's bare embed
1003+ // in A (§4.7: an embed line has no children), where the outline does
1004+ // not see it and deleting A leaves c's file embedded nowhere
1005+ let (d, mut v) = vault_with("# A\n\n- b\n- c\n");
1006+ let b = v.find_by_path(&["A".into(), "b".into()]).unwrap();
1007+ ops::make_block(&mut v, b).unwrap();
1008+ let c = v.find_by_path(&["A".into(), "c".into()]).unwrap();
1009+ let cid = ops::make_block(&mut v, c).unwrap();
1010+ let c_path = d.path().join(&v.tree.files[v.tree.block_by_id(&cid).unwrap().0].path);
1011+ let a = v.tree.resolved_children(v.tree.root)[0];
1012+ let mut buf = open_editor(&v, a);
1013+ let i = buf.lines.iter().position(|l| l.text == "- c").unwrap();
1014+ buf.set_line(i, " - c".into());
1015+ buf.save_all(&mut v).unwrap();
1016+ let root = v.tree.files[0].text.clone();
1017+ let mut titles = Vec::new();
1018+ v.tree.walk(v.tree.root, &mut |t, r| titles.push(t.node(r).title.clone()));
1019+ let a = v.tree.resolved_children(v.tree.root)[0];
1020+ ops::delete_subtree(&mut v, a).unwrap();
1021+ assert!(
1022+ titles.contains(&"c".to_string()) && !c_path.exists(),
1023+ "root.md after the save:\n{}walk: {:?}; after deleting A, c's file still in the vault: {}",
1024+ root,
1025+ titles,
1026+ c_path.exists()
1027+ );
1028+}
1029+
1030+#[test]
1031+fn review_a_line_split_off_above_a_re_indented_block_title_does_not_delete_the_block() {
1032+ // §5.2: only deleting its title line deletes a block; "note" split off
1033+ // above its re-indented title line is the enclosing block's text
1034+ let (d, mut v) = vault_with("# A\n\n- one\n- task\n body\n");
1035+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
1036+ let id = ops::make_block(&mut v, t).unwrap();
1037+ let block = d.path().join(&v.tree.files[1].path);
1038+ let a = v.tree.resolved_children(v.tree.root)[0];
1039+ let mut buf = open_editor(&v, a);
1040+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
1041+ buf.set_line(i, " - task".into());
1042+ buf.set_line(i + 1, " body".into());
1043+ // "note" typed at column 0, then Enter after it: the line splits, both
1044+ // parts keeping the line's tag
1045+ buf.set_line(i, "note - task".into());
1046+ buf.set_line(i, "note".into());
1047+ buf.insert_line(i, " - task".into());
1048+ buf.save_all(&mut v).unwrap();
1049+ assert!(
1050+ block.exists() && v.tree.files[0].text.contains(&format!("![[{}]]", id.as_str())),
1051+ "the block's file was trashed; root.md:\n{}",
1052+ v.tree.files[0].text
1053+ );
1054+}
1055+
1056+#[test]
1057+fn a_line_the_parser_would_nest_under_a_blocks_embed_is_not_saved() {
1058+ // §4.7: an embed line has no lines nested under it in the parent file,
1059+ // where the outline does not see them. The enclosing block's "- two"
1060+ // indented under b's title line, or left after it re-spelled as a
1061+ // heading, would be: the save is refused, the file left as it was
1062+ for (b_line, two) in [("- b", " - two"), ("## b", "- two")] {
1063+ let (_d, mut v) = vault_with("# A\n\n- one\n- b\n- two\n");
1064+ let b = v.find_by_path(&["A".into(), "b".into()]).unwrap();
1065+ ops::make_block(&mut v, b).unwrap();
1066+ let before = v.tree.files[0].text.clone();
1067+ let a = v.tree.resolved_children(v.tree.root)[0];
1068+ let mut buf = open_editor(&v, a);
1069+ let i = buf.lines.iter().position(|l| l.text == "- b").unwrap();
1070+ buf.set_line(i, b_line.into());
1071+ buf.set_line(i + 1, two.into());
1072+ let e = buf.save_all(&mut v).unwrap_err();
1073+ assert!(e.to_string().contains("nested under a block's embed"), "{}", e);
1074+ assert_eq!(v.tree.files[0].text, before);
1075+ // put back, it saves
1076+ buf.set_line(i, "- b".into());
1077+ buf.set_line(i + 1, "- two".into());
1078+ buf.save_all(&mut v).unwrap();
1079+ assert_eq!(v.tree.files[0].text, before);
1080+ assert!(v.tree.files[1].text.ends_with("---\n\n- b\n"), "{}", v.tree.files[1].text);
1081+ }
1082+}
1083+
1084+#[test]
1085+fn discarding_the_buffer_deletes_a_block_cut_saved_in_transit_and_let_go() {
1086+ // a block cut and saved in transit, then let go by the clipboard (it
1087+ // holds something else now) with no save since: Revert (§10.6) deletes
1088+ // it as the next save would have, never leaves its file embedded nowhere
1089+ let (d, mut v) = vault_with("# A\n\n- one\n- task\n- two\n");
1090+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
1091+ ops::make_block(&mut v, t).unwrap();
1092+ let block = d.path().join(&v.tree.files[1].path);
1093+ let a = v.tree.resolved_children(v.tree.root)[0];
1094+ let mut buf = open_editor(&v, a);
1095+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
1096+ let o = buf.lines[i].owner;
1097+ buf.delete_line(i);
1098+ buf.hold(vec![o]);
1099+ buf.save_all(&mut v).unwrap();
1100+ assert!(block.exists(), "in transit");
1101+ buf.hold(Vec::new());
1102+ v.reload().unwrap();
1103+ buf.discard(&mut v).unwrap();
1104+ assert!(!block.exists());
1105+ assert_eq!(v.tree.files.len(), 1);
1106+ assert_eq!(std::fs::read_to_string(d.path().join("root.md")).unwrap(), "# A\n\n- one\n- two\n");
1107+}
1108+
1109+#[test]
1110+fn a_block_title_line_indented_or_re_spelled_under_another_blocks_moves_into_it() {
1111+ // §5.2: the embed goes to the block whose title line holds the title
1112+ // line as it is written now, as the parser nests them (§3.1), blank
1113+ // lines between or not
1114+ let embed = |id: &fold_core::ident::Id| format!("![[{}]]", id.as_str());
1115+ let text = |v: &Vault, id| v.tree.files[v.tree.block_by_id(id).unwrap().0].text.clone();
1116+ // "- c" indented twice, under " - b", a block in the item "- one"
1117+ let (_d, mut v) = vault_with("# A\n\n- one\n - b\n- c\n");
1118+ let b = v.find_by_path(&["A".into(), "one".into(), "b".into()]).unwrap();
1119+ let b = ops::make_block(&mut v, b).unwrap();
1120+ let c = v.find_by_path(&["A".into(), "c".into()]).unwrap();
1121+ let c = ops::make_block(&mut v, c).unwrap();
1122+ let a = v.tree.resolved_children(v.tree.root)[0];
1123+ let mut buf = open_editor(&v, a);
1124+ let i = buf.lines.iter().position(|l| l.text == "- c").unwrap();
1125+ buf.set_line(i, " - c".into());
1126+ buf.save_all(&mut v).unwrap();
1127+ assert_eq!(v.tree.files[0].text, format!("# A\n\n- one\n {}\n", embed(&b)));
1128+ assert!(text(&v, &b).ends_with(&format!("\n- b\n {}\n", embed(&c))), "{}", text(&v, &b));
1129+ // "## c" re-spelled "### c" after a blank line, under the section "## b"
1130+ let (_d, mut v) = vault_with("# A\n\n## b\n\n## c\n");
1131+ let b = v.find_by_path(&["A".into(), "b".into()]).unwrap();
1132+ let b = ops::make_block(&mut v, b).unwrap();
1133+ let c = v.find_by_path(&["A".into(), "c".into()]).unwrap();
1134+ let c = ops::make_block(&mut v, c).unwrap();
1135+ let a = v.tree.resolved_children(v.tree.root)[0];
1136+ let mut buf = open_editor(&v, a);
1137+ let i = buf.lines.iter().position(|l| l.text == "## c").unwrap();
1138+ buf.set_line(i, "### c".into());
1139+ buf.save_all(&mut v).unwrap();
1140+ assert_eq!(v.tree.files[0].text, format!("# A\n\n## {}\n", embed(&b)));
1141+ assert!(text(&v, &b).ends_with(&format!("\n# b\n## {}\n", embed(&c))), "{}", text(&v, &b));
1142+ assert!(text(&v, &c).ends_with("\n# c\n"), "{}", text(&v, &c));
1143+}
crates/fold-core/tests/merge_check.rs+345
@@ -311,3 +311,348 @@ fn retitled_block_root_conflicts_and_keep_theirs_keeps_the_filename() {
311311 assert!(text.contains(&format!("id: {}", BID)) && text.contains("- new"), "{}", text);
312312 assert!(merge::conflict_pairs(&v).is_empty());
313313}
314+
315+#[test]
316+fn deep_headings_are_canonical() {
317+ // §3.1 / §4.2: no upper bound on heading level; levels beyond six are ours
318+ // (§4.2 "What is ours"), not non-canonical syntax for `notes check` to report.
319+ let dir = tempfile::tempdir().unwrap();
320+ std::fs::write(
321+ dir.path().join("root.md"),
322+ "# 1\n\n## 2\n\n### 3\n\n#### 4\n\n##### 5\n\n###### 6\n\n####### 7\n",
323+ )
324+ .unwrap();
325+ let v = Vault::open(dir.path()).unwrap();
326+ let diags = fold_core::check::check(&v);
327+ assert!(
328+ diags.is_empty(),
329+ "{:?}",
330+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
331+ );
332+}
333+
334+#[test]
335+fn crlf_separated_text_is_not_flagged_or_padded() {
336+ // "text" already follows `a` after a blank line; only the line endings
337+ // are CRLF, which is read cleanly (§4.2) and must not count as missing
338+ // the separator, nor make --fix invent a second blank line (§3.3).
339+ let dir = tempfile::tempdir().unwrap();
340+ std::fs::write(dir.path().join("root.md"), "# A\r\n\r\n- a\r\n\r\ntext\r\n").unwrap();
341+ let mut v = Vault::open(dir.path()).unwrap();
342+ let diags = fold_core::check::check(&v);
343+ assert!(
344+ !diags.iter().any(|d| d.message.contains("text right after a child node")),
345+ "{:?}",
346+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
347+ );
348+ fold_core::check::fix(&mut v).unwrap();
349+ let text = std::fs::read_to_string(dir.path().join("root.md")).unwrap();
350+ assert_eq!(text, "# A\n\n- a\n\ntext\n");
351+}
352+
353+#[test]
354+fn merge_rewrite_keeps_embeds() {
355+ // §12.4: embeds match by id and every node from O and T survives; a
356+ // re-rendered merge (T adds a node) must not turn `![[id]]` into `- `
357+ let o = format!("# A\n\n![[{}]]\n\n# B\n", BID);
358+ let t = format!("# A\n\n![[{}]]\n\n# B\n\n- new\n", BID);
359+ let out = merge::merge_texts(&o, &t, "d", "ts");
360+ assert!(out.text.contains(&format!("![[{}]]", BID)), "{}", out.text);
361+ assert!(out.text.contains("- new"), "{}", out.text);
362+ // a T-only embed (block made on the other device) must survive too
363+ let o2 = "# A\n\n- x\n";
364+ let t2 = format!("# A\n\n- x\n![[{}]]\n", BID);
365+ let out2 = merge::merge_texts(o2, &t2, "d", "ts");
366+ assert!(out2.text.contains(&format!("![[{}]]", BID)), "{}", out2.text);
367+ // heading form
368+ let o3 = format!("# A\n\n## ![[{}]]\n\n# B\n", BID);
369+ let t3 = format!("# A\n\n## ![[{}]]\n\n# B\n\n- new\n", BID);
370+ let out3 = merge::merge_texts(&o3, &t3, "d", "ts");
371+ assert!(out3.text.contains(&format!("## ![[{}]]", BID)), "{}", out3.text);
372+ // idempotent (§12.4): merging T into the result again adds nothing
373+ for (out, t) in [(&out, &t), (&out2, &t2), (&out3, &t3)] {
374+ assert_eq!(merge::merge_texts(&out.text, t, "d", "ts").text, out.text);
375+ }
376+}
377+
378+#[test]
379+fn sync_conflict_merge_keeps_embeds_in_the_merged_file() {
380+ let dir = tempfile::tempdir().unwrap();
381+ std::fs::write(dir.path().join("root.md"), format!("# A\n\n![[{}]]\n\n# B\n", BID)).unwrap();
382+ std::fs::write(dir.path().join("racfer~t.md"), format!("---\nid: {}\n---\n\n- t\n", BID)).unwrap();
383+ std::fs::write(
384+ dir.path().join("root.sync-conflict-20260912-100000-phone.md"),
385+ format!("# A\n\n![[{}]]\n\n# B\n\n- new\n", BID),
386+ )
387+ .unwrap();
388+ let mut v = Vault::open(dir.path()).unwrap();
389+ merge::merge_sync_conflicts(&mut v, false).unwrap();
390+ let text = std::fs::read_to_string(dir.path().join("root.md")).unwrap();
391+ assert!(text.contains(&format!("![[{}]]", BID)), "{}", text);
392+ assert!(text.contains("- new"), "{}", text);
393+}
394+
395+#[test]
396+fn merge_leaves_ignored_files_alone() {
397+ // §4.1/§11.4: a `.md` file without an id is ignored — never parsed,
398+ // never written. A sync-conflict copy of it is not ours to merge.
399+ let dir = tempfile::tempdir().unwrap();
400+ std::fs::write(dir.path().join("root.md"), "# A\n").unwrap();
401+ std::fs::write(dir.path().join("readme.md"), "* [ ] first\n").unwrap();
402+ std::fs::write(
403+ dir.path().join("readme.sync-conflict-20260912-100000-phone.md"),
404+ "* [x] first\n",
405+ )
406+ .unwrap();
407+ let mut v = Vault::open(dir.path()).unwrap();
408+ let outcomes = merge::merge_sync_conflicts(&mut v, false).unwrap();
409+ assert!(outcomes.iter().any(|o| o.contains("left alone")), "{:?}", outcomes);
410+ assert_eq!(
411+ std::fs::read_to_string(dir.path().join("readme.md")).unwrap(),
412+ "* [ ] first\n"
413+ );
414+ let mut names: Vec<String> = std::fs::read_dir(dir.path())
415+ .unwrap()
416+ .filter_map(|e| e.ok())
417+ .map(|e| e.file_name().to_string_lossy().to_string())
418+ .collect();
419+ names.sort();
420+ assert_eq!(
421+ names,
422+ vec![
423+ "readme.md".to_string(),
424+ "readme.sync-conflict-20260912-100000-phone.md".to_string(),
425+ "root.md".to_string(),
426+ ]
427+ );
428+ let diags = fold_core::check::check(&v);
429+ assert!(
430+ diags.iter().all(|d| !d.message.contains("unresolved conflict")),
431+ "{:?}",
432+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
433+ );
434+}
435+
436+#[test]
437+fn merge_with_missing_base_keeps_theirs_frontmatter() {
438+ // X.md is gone (deleted, or its deletion synced in first): with no O to
439+ // merge against, T must survive with its frontmatter (id, due) intact
440+ let dir = tempfile::tempdir().unwrap();
441+ std::fs::write(dir.path().join("root.md"), format!("# A\n\n![[{}]]\n", BID)).unwrap();
442+ let theirs = format!("---\nid: {}\ndue: 2026-09-20\n---\n\n- t\n", BID);
443+ let c = "racfer~t.sync-conflict-20260912-100000-phone.md";
444+ std::fs::write(dir.path().join(c), &theirs).unwrap();
445+ let mut v = Vault::open(dir.path()).unwrap();
446+ merge::merge_sync_conflicts(&mut v, false).unwrap();
447+ let text = std::fs::read_to_string(dir.path().join("racfer~t.md")).unwrap();
448+ assert_eq!(text, theirs);
449+ assert!(!dir.path().join(c).exists());
450+ assert!(v.tree.block_by_id(&fold_core::Id::parse(BID).unwrap()).is_some());
451+ // a block renamed since (§6.4) is still O: found by its id, merged
452+ // into, never duplicated
453+ let dir = tempfile::tempdir().unwrap();
454+ std::fs::write(dir.path().join("root.md"), format!("# A\n\n![[{}]]\n", BID)).unwrap();
455+ std::fs::write(dir.path().join("racfer~new.md"), format!("---\nid: {}\n---\n\n- [ ] t\n", BID)).unwrap();
456+ std::fs::write(dir.path().join(c), format!("---\nid: {}\n---\n\n- [x] t\n", BID)).unwrap();
457+ let mut v = Vault::open(dir.path()).unwrap();
458+ merge::merge_sync_conflicts(&mut v, false).unwrap();
459+ assert!(!dir.path().join("racfer~t.md").exists());
460+ assert_eq!(merge::conflict_pairs(&v).len(), 1, "{}", v.tree.files[0].text);
461+ // the copy of a foreign file that is gone is not ours either (§4.1)
462+ let dir = tempfile::tempdir().unwrap();
463+ std::fs::write(dir.path().join("root.md"), "# A\n").unwrap();
464+ let c = "readme.sync-conflict-20260912-100000-phone.md";
465+ std::fs::write(dir.path().join(c), "* [x] first\n").unwrap();
466+ let mut v = Vault::open(dir.path()).unwrap();
467+ let outcomes = merge::merge_sync_conflicts(&mut v, false).unwrap();
468+ assert!(outcomes.iter().any(|o| o.contains("left alone")), "{:?}", outcomes);
469+ assert!(dir.path().join(c).exists() && !dir.path().join("readme.md").exists());
470+}
471+
472+#[test]
473+fn merge_places_insertions_by_their_neighbours() {
474+ // §12.4: a node present on one side only is an insertion, placed
475+ // relative to its matched neighbours — `new` sits between `a` and `b`
476+ let o = "# A\n\n- a\n- b\n- c\n";
477+ let t = "# A\n\n- a\n- new\n- b\n- c\n";
478+ let out = merge::merge_texts(o, t, "dev", "ts");
479+ assert_eq!(out.conflicts, 0, "{}", out.text);
480+ let p = |s: &str| out.text.find(s).unwrap();
481+ assert!(p("- a") < p("- new") && p("- new") < p("- b"), "{}", out.text);
482+ // one inserted after the text that follows its neighbour in T stays
483+ // after that text, so merging T in again changes nothing
484+ let o = "# P\n\n- a\n\nnote\n";
485+ let t = "# P\n\n- a\n\nnote\n\n- new\n";
486+ let out = merge::merge_texts(o, t, "dev", "ts");
487+ assert_eq!(out.conflicts, 0, "{}", out.text);
488+ assert_eq!(out.text, t);
489+}
490+
491+#[test]
492+fn merge_places_inserted_sections_by_their_neighbours() {
493+ // the same for a section: `B` sits between `A` and `C`, not last
494+ let o = "# A\n\n# C\n";
495+ let t = "# A\n\n# B\n\n# C\n";
496+ let out = merge::merge_texts(o, t, "dev", "ts");
497+ assert_eq!(out.conflicts, 0, "{}", out.text);
498+ let p = |s: &str| out.text.find(s).unwrap();
499+ assert!(p("# A") < p("# B") && p("# B") < p("# C"), "{}", out.text);
500+}
501+
502+#[test]
503+fn check_reports_embed_cycles() {
504+ // two blocks that embed each other: neither is reachable from root.md,
505+ // and §6.2 / §15.7 say a cycle is a diagnostic
506+ let a = "racfer-hattes-mislup-nodrys";
507+ let b = "dozzod-binwes-talsun-worbec";
508+ let c = "lacnum-walbyn-dirlyn-havtyp";
509+ let dir = tempfile::tempdir().unwrap();
510+ std::fs::write(dir.path().join("root.md"), "# A\n").unwrap();
511+ std::fs::write(
512+ dir.path().join("racfer~x.md"),
513+ format!("---\nid: {}\n---\n\n- x\n ![[{}]]\n", a, b),
514+ )
515+ .unwrap();
516+ std::fs::write(
517+ dir.path().join("dozzod~y.md"),
518+ format!("---\nid: {}\n---\n\n- y\n ![[{}]]\n ![[{}]]\n", b, a, c),
519+ )
520+ .unwrap();
521+ // c hangs off the cycle without being on it
522+ std::fs::write(dir.path().join("lacnum~z.md"), format!("---\nid: {}\n---\n\n- z\n", c)).unwrap();
523+ let v = Vault::open(dir.path()).unwrap();
524+ // every block loads and every embed resolves: the only fault is the cycle
525+ for id in [a, b, c] {
526+ let id = fold_core::Id::parse(id).unwrap();
527+ assert!(v.tree.block_by_id(&id).is_some(), "block {} not loaded", id);
528+ assert!(v.tree.embed_of(&id).is_some(), "embed of {} not parsed", id);
529+ }
530+ let diags = fold_core::check::check(&v);
531+ let msgs: Vec<&String> = diags.iter().map(|d| &d.message).collect();
532+ assert_eq!(
533+ diags.iter().filter(|d| d.message.contains("cycl")).count(),
534+ 2,
535+ "one cyclic-embed diagnostic per block on the cycle: {:?}",
536+ msgs
537+ );
538+ // the same blocks in a chain from root.md: no cycle
539+ std::fs::write(dir.path().join("root.md"), format!("# A\n\n![[{}]]\n", a)).unwrap();
540+ std::fs::write(
541+ dir.path().join("dozzod~y.md"),
542+ format!("---\nid: {}\n---\n\n- y\n ![[{}]]\n", b, c),
543+ )
544+ .unwrap();
545+ let v = Vault::open(dir.path()).unwrap();
546+ let diags = fold_core::check::check(&v);
547+ assert!(diags.is_empty(), "{:?}", diags.iter().map(|d| &d.message).collect::<Vec<_>>());
548+}
549+
550+#[test]
551+fn orphan_block_conflict_is_not_paired_with_an_unrelated_node() {
552+ // the block racfer~t.md is embedded nowhere: its conflict block has no
553+ // embed to follow, so it must not end up as the next sibling of some
554+ // unrelated node that 'keep theirs' would then overwrite (§12.4, §12.5)
555+ let dir = tempfile::tempdir().unwrap();
556+ std::fs::write(dir.path().join("root.md"), "# A\n\n- keep me\n").unwrap();
557+ std::fs::write(
558+ dir.path().join("racfer~t.md"),
559+ format!("---\nid: {}\n---\n\n- [ ] t\n", BID),
560+ )
561+ .unwrap();
562+ std::fs::write(
563+ dir.path().join("racfer~t.sync-conflict-20260912-100000-phone.md"),
564+ format!("---\nid: {}\n---\n\n- [x] t\n", BID),
565+ )
566+ .unwrap();
567+ let mut v = Vault::open(dir.path()).unwrap();
568+ merge::merge_sync_conflicts(&mut v, false).unwrap();
569+ let root = std::fs::read_to_string(dir.path().join("root.md")).unwrap();
570+ let pairs = merge::conflict_pairs(&v);
571+ for &(ours, _) in &pairs {
572+ assert_ne!(v.tree.node(ours).title, "keep me", "root.md:\n{}", root);
573+ }
574+ // it pairs with the block it conflicts with, and keep theirs lands there
575+ assert_eq!(pairs.len(), 1, "root.md:\n{}", root);
576+ let bid = fold_core::Id::parse(BID).unwrap();
577+ assert_eq!(Some(pairs[0].0), v.tree.block_by_id(&bid), "root.md:\n{}", root);
578+ merge::resolve_keep_theirs(&mut v, pairs[0].0, pairs[0].1).unwrap();
579+ assert!(std::fs::read_to_string(dir.path().join("root.md")).unwrap().contains("- keep me"));
580+ let block = std::fs::read_to_string(dir.path().join("racfer~t.md")).unwrap();
581+ assert!(block.contains("- [x] t"), "{}", block);
582+ assert!(fold_core::check::check(&v).is_empty());
583+ // a section-spelled orphan: heading embeds, still a pair (§4.7)
584+ let dir = tempfile::tempdir().unwrap();
585+ std::fs::write(dir.path().join("root.md"), "# A\n\n- keep me\n").unwrap();
586+ std::fs::write(dir.path().join("racfer~t.md"), format!("---\nid: {}\n---\n\n# T\n\nbody o\n", BID))
587+ .unwrap();
588+ std::fs::write(
589+ dir.path().join("racfer~t.sync-conflict-20260912-100000-phone.md"),
590+ format!("---\nid: {}\n---\n\n# T\n\nbody t\n", BID),
591+ )
592+ .unwrap();
593+ let mut v = Vault::open(dir.path()).unwrap();
594+ merge::merge_sync_conflicts(&mut v, false).unwrap();
595+ let root = std::fs::read_to_string(dir.path().join("root.md")).unwrap();
596+ assert!(root.starts_with(&format!("# A\n\n- keep me\n\n# ![[{}]]\n\n# ![[", BID)), "{}", root);
597+ let pairs = merge::conflict_pairs(&v);
598+ assert_eq!(pairs.len(), 1, "root.md:\n{}", root);
599+ assert_eq!(Some(pairs[0].0), v.tree.block_by_id(&bid), "root.md:\n{}", root);
600+ let diags = fold_core::check::check(&v);
601+ assert!(
602+ diags.iter().all(|d| d.message == "unresolved conflict block"),
603+ "{:?}",
604+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
605+ );
606+}
607+
608+#[test]
609+fn crlf_root_md_keeps_its_blank_lines_under_fix() {
610+ // check --fix lays root.md's top-level nodes out itself; a blank line
611+ // is blank whatever its line ending, so a loose list stays loose
612+ // (§4.2) and none is added after text that already ends in one (§3.3)
613+ for (before, after) in [
614+ ("- a\r\n\r\n- b\r\n", "- a\n\n- b\n"),
615+ ("intro\r\n\r\n- a\r\n", "intro\r\n\r\n- a\n"),
616+ ] {
617+ let dir = tempfile::tempdir().unwrap();
618+ std::fs::write(dir.path().join("root.md"), before).unwrap();
619+ let mut v = Vault::open(dir.path()).unwrap();
620+ fold_core::check::fix(&mut v).unwrap();
621+ let text = std::fs::read_to_string(dir.path().join("root.md")).unwrap();
622+ assert_eq!(text, after, "{:?}", before);
623+ }
624+}
625+
626+#[test]
627+fn sync_conflict_on_a_block_embedded_twice_pairs_where_it_is_shown() {
628+ // t is shown under X, where the walk from the root meets it first; its
629+ // second embed, in root.md under B, comes first in file order and reads
630+ // as broken (§6.2). The conflict block goes after t's own embed, so it
631+ // pairs with t (§12.4), not with the broken line
632+ let x = "dozzod-binwes-talsun-worbec";
633+ let dir = tempfile::tempdir().unwrap();
634+ let root = format!("# A\n\n![[{}]]\n\n# B\n\n![[{}]]\n", x, BID);
635+ std::fs::write(dir.path().join("root.md"), &root).unwrap();
636+ std::fs::write(
637+ dir.path().join("dozzod~x.md"),
638+ format!("---\nid: {}\n---\n\n- X\n ![[{}]]\n", x, BID),
639+ )
640+ .unwrap();
641+ std::fs::write(
642+ dir.path().join("racfer~t.md"),
643+ format!("---\nid: {}\n---\n\n- [ ] t\n", BID),
644+ )
645+ .unwrap();
646+ std::fs::write(
647+ dir.path().join("racfer~t.sync-conflict-20260912-100000-phone.md"),
648+ format!("---\nid: {}\n---\n\n- [x] t\n", BID),
649+ )
650+ .unwrap();
651+ let mut v = Vault::open(dir.path()).unwrap();
652+ merge::merge_sync_conflicts(&mut v, false).unwrap();
653+ let pairs = merge::conflict_pairs(&v);
654+ assert_eq!(pairs.len(), 1);
655+ let ours = v.tree.node(pairs[0].0).block.as_ref().and_then(|b| b.id.as_ref());
656+ assert_eq!(ours.map(|i| i.to_string()), Some(BID.to_string()));
657+ assert_eq!(std::fs::read_to_string(dir.path().join("root.md")).unwrap(), root);
658+}
crates/fold-core/tests/ops.rs+656
@@ -567,3 +567,659 @@ fn check_fix_keeps_correct_prefixes_and_is_idempotent() {
567567 assert!(d.path().join("racfer~blk.md").exists());
568568 assert_eq!(fold_core::check::fix(&mut v).unwrap(), 0);
569569}
570+
571+#[test]
572+fn setext_sibling_spans_do_not_overlap() {
573+ // the setext title line is taken back out of A's text: A's span must
574+ // shrink with it, or A and Title overlap
575+ let (_d, mut v) = vault_with("# A\n\nTitle\n===\n\nbody\n");
576+ let kids = v.tree.resolved_children(v.tree.root);
577+ assert_eq!(kids.len(), 2);
578+ let (a, b) = (v.tree.node(kids[0]).span, v.tree.node(kids[1]).span);
579+ assert!(a.end <= b.start, "A {:?} overlaps Title {:?}", a, b);
580+ ops::move_sibling(&mut v, kids[0], true).unwrap();
581+ let kids = v.tree.resolved_children(v.tree.root);
582+ assert_eq!(v.tree.node(kids[0]).title, "Title");
583+ assert_eq!(v.tree.node(kids[1]).title, "A");
584+}
585+
586+#[test]
587+fn move_sibling_before_a_setext_sibling_does_not_panic() {
588+ let (_d, mut v) = vault_with("# A\n\nTitle\n===\n\nbody\n");
589+ let a = v.tree.resolved_children(v.tree.root)[0];
590+ ops::move_sibling(&mut v, a, true).unwrap();
591+ let kids = v.tree.resolved_children(v.tree.root);
592+ assert_eq!(v.tree.node(kids[0]).title, "Title", "{}", v.tree.files[0].text);
593+ assert_eq!(v.tree.node(kids[1]).title, "A", "{}", v.tree.files[0].text);
594+}
595+
596+#[test]
597+fn delete_before_a_setext_sibling_keeps_its_title() {
598+ let (_d, mut v) = vault_with("# A\n\nTitle\n===\n\nbody\n");
599+ let a = v.tree.resolved_children(v.tree.root)[0];
600+ ops::delete_subtree(&mut v, a).unwrap();
601+ let text = v.tree.files[0].text.clone();
602+ assert!(text.contains("Title"), "{}", text);
603+ let top = v.tree.resolved_children(v.tree.root);
604+ assert_eq!(top.len(), 1, "{}", text);
605+ assert_eq!(v.tree.node(top[0]).title, "Title", "{}", text);
606+}
607+
608+#[test]
609+fn capture_before_a_setext_sibling_keeps_it() {
610+ let (_d, mut v) = vault_with("# Inbox\n\nMeeting notes\n=============\n\n- action\n");
611+ let inbox = v.tree.resolved_children(v.tree.root)[0];
612+ ops::capture_to(&mut v, "new", false, inbox).unwrap();
613+ let text = v.tree.files[0].text.clone();
614+ let top = v.tree.resolved_children(v.tree.root);
615+ assert_eq!(top.len(), 2, "{}", text);
616+ assert_eq!(v.tree.node(top[1]).title, "Meeting notes", "{}", text);
617+ let kids = v.tree.resolved_children(top[1]);
618+ assert_eq!(kids.len(), 1, "{}", text);
619+ assert_eq!(v.tree.node(kids[0]).title, "action", "{}", text);
620+}
621+
622+#[test]
623+fn archiving_keeps_hashtag_body_lines() {
624+ // `#done` has no space after the hash: it is body text, not a heading
625+ // (§4.2), so re-levelling the moved node must leave it as written
626+ let (_d, mut v) = vault_with("# Old project\n\n#done wrap-up notes\n");
627+ let r = at(&v, &["Old project"]);
628+ ops::archive(&mut v, r).unwrap();
629+ let t = &v.tree.files[0].text;
630+ assert!(t.contains("## Old project\n"), "{}", t);
631+ assert!(t.contains("\n#done wrap-up notes\n"), "{}", t);
632+ assert!(!t.contains("##done"), "{}", t);
633+}
634+
635+#[test]
636+fn respelling_a_setext_heading_keeps_its_title() {
637+ // §4.2: setext headings are read, and converted on write
638+ let (_d, mut v) = vault_with("Title\n=====\n\nbody\n");
639+ let r = at(&v, &["Title"]);
640+ ops::toggle_spelling(&mut v, r).unwrap();
641+ let text = &v.tree.files[0].text;
642+ assert_eq!(text, "- Title\n\n body\n");
643+ assert!(v.find_by_path(&["Title".into()]).is_some(), "{}", text);
644+}
645+
646+#[test]
647+fn toggle_taskness_on_a_setext_heading_makes_it_a_task() {
648+ // §4.2, §10.3 `t`: the checkbox goes between the marker and the title
649+ let (_d, mut v) = vault_with("Title\n=====\n\nbody\n");
650+ let r = at(&v, &["Title"]);
651+ ops::toggle_taskness(&mut v, r).unwrap();
652+ let text = v.tree.files[0].text.clone();
653+ assert_eq!(text, "# [ ] Title\n\nbody\n");
654+ let r = v.find_by_path(&["Title".into()]);
655+ assert!(r.is_some(), "title changed: {}", text);
656+ assert_eq!(v.tree.node(r.unwrap()).task, Some(TaskState::Open), "{}", text);
657+ // and back: the checkbox goes, the ATX heading stays
658+ ops::toggle_taskness(&mut v, r.unwrap()).unwrap();
659+ assert_eq!(v.tree.files[0].text, "# Title\n\nbody\n");
660+}
661+
662+#[test]
663+fn demote_keeps_a_fenced_code_body_with_its_node() {
664+ // b's body is a fenced code block; demoting b under a must re-indent the
665+ // fence with b, or the code falls out of b's region and becomes a's text
666+ let (d, mut v) = vault_with("- a\n- b\n ```\n code\n ```\n");
667+ let b = at(&v, &["b"]);
668+ ops::demote(&mut v, b).unwrap();
669+ assert_eq!(read(&d, "root.md"), "- a\n - b\n ```\n code\n ```\n");
670+ let b = at(&v, &["a", "b"]);
671+ let text = v.tree.files[0].text.clone();
672+ assert!(
673+ v.tree.node(b).text_lines(&text).iter().any(|l| l.contains("code")),
674+ "code block is no longer b's body:\n{}",
675+ text
676+ );
677+}
678+
679+#[test]
680+fn respelling_keeps_code_in_a_fence_as_written() {
681+ // a fence moves with its node; the code inside keeps its own indent, a
682+ // tab included, and a `#` line in it is code, not a heading (§3.3)
683+ let src = "# P\n\n## S\n\n```\n\tx\n # not a heading\n```\n";
684+ let (_d, mut v) = vault_with(src);
685+ let s = at(&v, &["P", "S"]);
686+ ops::toggle_spelling(&mut v, s).unwrap();
687+ let text = v.tree.files[0].text.clone();
688+ assert_eq!(text, "# P\n\n- S\n\n ```\n \tx\n # not a heading\n ```\n");
689+ let s = at(&v, &["P", "S"]);
690+ ops::toggle_spelling(&mut v, s).unwrap();
691+ assert_eq!(v.tree.files[0].text, src);
692+}
693+
694+// Nesting written with 4 spaces or a tab is accepted on read (§4.2); a child
695+// written under such an item must still nest under it, not beside it.
696+#[test]
697+fn refile_under_a_four_space_nested_item_nests_under_it() {
698+ let (_d, mut v) = vault_with("- a\n - b\n- c\n");
699+ let (c, b) = (at(&v, &["c"]), at(&v, &["a", "b"]));
700+ ops::refile(&mut v, c, b).unwrap();
701+ assert!(
702+ v.find_by_path(&["a".into(), "b".into(), "c".into()]).is_some(),
703+ "{}",
704+ v.tree.files[0].text
705+ );
706+}
707+
708+#[test]
709+fn demote_among_four_space_siblings_nests_the_node() {
710+ let (_d, mut v) = vault_with("- a\n - b\n - c\n");
711+ let c = at(&v, &["a", "c"]);
712+ ops::demote(&mut v, c).unwrap();
713+ assert!(
714+ v.find_by_path(&["a".into(), "b".into(), "c".into()]).is_some(),
715+ "{}",
716+ v.tree.files[0].text
717+ );
718+}
719+
720+#[test]
721+fn new_child_of_a_tab_nested_item() {
722+ let (_d, mut v) = vault_with("- a\n\t- b\n");
723+ let b = at(&v, &["a", "b"]);
724+ let r = ops::append_child_public(&mut v, b, "x");
725+ let text = v.tree.files[0].text.clone();
726+ let r = r.unwrap_or_else(|e| panic!("{e}\n{text:?}"));
727+ assert_eq!(v.tree.node(r).title, "x");
728+ assert!(v.find_by_path(&["a".into(), "b".into(), "x".into()]).is_some(), "{:?}", text);
729+}
730+
731+#[test]
732+fn new_section_child_beside_a_deeper_written_section() {
733+ // N on b, whose section child is written further in than b's derived
734+ // child indent: the new section must still land under b
735+ let (_d, mut v) = vault_with("- a\n - b\n ## S\n");
736+ let b = at(&v, &["a", "b"]);
737+ ops::append_child_public(&mut v, b, "x").unwrap();
738+ let text = v.tree.files[0].text.clone();
739+ assert!(v.find_by_path(&["a".into(), "b".into(), "x".into()]).is_some(), "{:?}", text);
740+ assert!(v.find_by_path(&["a".into(), "b".into(), "S".into()]).is_some(), "{:?}", text);
741+}
742+
743+#[test]
744+fn capture_to_a_four_space_nested_item_nests_under_it() {
745+ let (_d, mut v) = vault_with("- a\n - b\n");
746+ let b = at(&v, &["a", "b"]);
747+ let r = ops::capture_to(&mut v, "x\nmore", false, b).unwrap();
748+ let text = v.tree.files[0].text.clone();
749+ assert!(v.find_by_path(&["a".into(), "b".into(), "x".into()]).is_some(), "{:?}", text);
750+ let body = v.tree.node(r).text_lines(&text).join("\n");
751+ assert!(body.contains("more"), "{:?}", text);
752+}
753+
754+#[test]
755+fn deleting_a_section_block_keeps_lines_nested_under_its_embed() {
756+ // text appended under a heading embed by another editor parses as the
757+ // embed's content; the reading pane shows it in the parent, so deleting
758+ // the block must not take it along (§4.7, §11.5)
759+ let (d, mut v) = vault_files(
760+ &format!("# A\n\n## ![[{}]]\n\nnote under the embed\n", ID_A),
761+ &[("racfer~s.md", &format!("---\nid: {}\n---\n\n# S\n\nbody\n", ID_A))],
762+ );
763+ let s = at(&v, &["A", "S"]);
764+ ops::delete_subtree(&mut v, s).unwrap();
765+ assert!(!d.path().join("racfer~s.md").exists());
766+ assert_eq!(read(&d, "root.md"), "# A\n\nnote under the embed\n");
767+}
768+
769+#[test]
770+fn clearing_a_done_section_block_keeps_lines_nested_under_its_embed() {
771+ let (d, mut v) = vault_files(
772+ &format!("# A\n\n## ![[{}]]\n\nnote under the embed\n\n## B\n", ID_A),
773+ &[("racfer~s.md", &format!("---\nid: {}\n---\n\n# [x] S\n\nbody\n", ID_A))],
774+ );
775+ let a = at(&v, &["A"]);
776+ assert_eq!(ops::clear_done(&mut v, a).unwrap(), 1);
777+ assert!(!d.path().join("racfer~s.md").exists());
778+ assert_eq!(read(&d, "root.md"), "# A\n\nnote under the embed\n\n## B\n");
779+}
780+
781+#[test]
782+fn deleting_a_broken_embed_keeps_lines_nested_under_it() {
783+ let (d, mut v) = vault_with(&format!("# A\n\n## ![[{}]]\n\nnote under the embed\n", ID_A));
784+ let a = at(&v, &["A"]);
785+ let e = v.tree.resolved_children(a)[0];
786+ assert!(v.tree.node(e).is_embed());
787+ ops::delete_subtree(&mut v, e).unwrap();
788+ assert_eq!(read(&d, "root.md"), "# A\n\nnote under the embed\n");
789+}
790+
791+#[test]
792+fn capture_with_an_item_spelled_inbox_does_not_leave_a_stray_day_heading() {
793+ // `- Inbox` (e.g. after `~` on the Inbox section) is still the inbox
794+ // (§3.1: spelling is presentation): today's day goes under it, never a
795+ // top-level day heading written before failing
796+ let (_d, mut v) = vault_with("- Inbox\n");
797+ let r = ops::capture(&mut v, "x", false);
798+ let text = v.tree.files[0].text.clone();
799+ let r = r.unwrap_or_else(|e| panic!("capture failed: {}\n{}", e, text));
800+ assert_eq!(v.tree.node(r).title, "x");
801+ let path = v.tree.path(r);
802+ assert_eq!(path.len(), 3, "{:?}\n{}", path, text);
803+ assert_eq!(path[0], "Inbox");
804+ // a second capture reuses the same day and adds nothing at the top
805+ ops::capture(&mut v, "y", false).unwrap();
806+ let top = v.tree.resolved_children(v.tree.root);
807+ assert_eq!(top.len(), 1, "{}", v.tree.files[0].text);
808+ let days = v.tree.resolved_children(top[0]);
809+ assert_eq!(days.len(), 1, "{}", v.tree.files[0].text);
810+ assert_eq!(v.tree.resolved_children(days[0]).len(), 2, "{}", v.tree.files[0].text);
811+}
812+
813+#[test]
814+fn path_target_with_duplicate_titles_is_ambiguous_not_guessed() {
815+ // §3.4: a title that matches twice is an ambiguity error, never a guess.
816+ let (_d, v) = vault_with("# Homelab\n\n## Notes\n\n- a\n\n## Notes\n\n- b\n");
817+ let r = v.resolve_target("Homelab/Notes");
818+ assert!(r.is_err(), "guessed {:?}", r.map(|r| v.tree.node(r).title.clone()));
819+ assert!(r.unwrap_err().contains("ambiguous"));
820+ // one path through the duplicates is still unique
821+ let r = v.resolve_target("Homelab/Notes/b").unwrap();
822+ assert_eq!(v.tree.node(r).title, "b");
823+}
824+
825+#[test]
826+fn path_target_resolves_through_duplicate_ancestor_titles() {
827+ // exactly one node matches the full path P/Y, though the first P has no Y
828+ let (_d, v) = vault_with("# P\n\n## X\n\n# P\n\n## Y\n");
829+ let r = v.resolve_target("P/Y");
830+ assert!(r.is_ok(), "{:?}", r);
831+ assert_eq!(v.tree.node(r.unwrap()).title, "Y");
832+}
833+
834+#[test]
835+fn respelling_a_broken_embed_keeps_its_id() {
836+ // no block file has the id: `~` changes only the embed's form (§4.7)
837+ let (d, mut v) = vault_with(&format!("# A\n\n![[{}]]\n", ID_A));
838+ let e = v.tree.resolved_children(at(&v, &["A"]))[0];
839+ assert!(v.tree.node(e).is_embed());
840+ ops::toggle_spelling(&mut v, e).unwrap();
841+ assert_eq!(read(&d, "root.md"), format!("# A\n\n## ![[{}]]\n", ID_A));
842+ let e = v.tree.resolved_children(at(&v, &["A"]))[0];
843+ ops::toggle_spelling(&mut v, e).unwrap();
844+ assert_eq!(read(&d, "root.md"), format!("# A\n\n![[{}]]\n", ID_A));
845+ // and moves to its parent's boundary like any node respelled (§3.1)
846+ let (d, mut v) = vault_with(&format!("# A\n\n![[{}]]\n- b\n", ID_A));
847+ let e = v.tree.resolved_children(at(&v, &["A"]))[0];
848+ assert!(ops::toggle_spelling(&mut v, e).unwrap());
849+ assert_eq!(read(&d, "root.md"), format!("# A\n\n- b\n\n## ![[{}]]\n", ID_A));
850+}
851+
852+#[test]
853+fn deleting_a_duplicate_embed_removes_only_its_line() {
854+ // a second embed of a block is a diagnostic and renders as broken
855+ // (§6.2): deleting it (from the reading pane) removes that line alone,
856+ // as for a broken embed, and neither trashes the block nor takes the
857+ // first embed, the one the block is stitched in at
858+ let (d, mut v) = vault_files(
859+ &format!("# A\n\n![[{}]]\n\n# B\n\n![[{}]]\n", ID_A, ID_A),
860+ &[("racfer~s.md", &format!("---\nid: {}\n---\n\n- S\n", ID_A))],
861+ );
862+ let dup = v.tree.raw_children(at(&v, &["B"]))[0];
863+ assert!(v.tree.node(dup).is_embed());
864+ ops::delete_subtree(&mut v, dup).unwrap();
865+ assert!(d.path().join("racfer~s.md").exists(), "the block was trashed");
866+ assert_eq!(read(&d, "root.md"), format!("# A\n\n![[{}]]\n\n# B\n", ID_A));
867+ assert!(v.find_by_path(&["A".into(), "S".into()]).is_some());
868+}
869+
870+#[test]
871+fn make_block_on_a_file_changed_on_disk_leaves_no_block_file() {
872+ // the write guard refuses to replace the node with its embed in a file
873+ // that changed on disk since it was read; the block file must not be
874+ // left behind either, orphaned and in no undo entry (§10.10)
875+ let (d, mut v) = vault_with("# A\n\n- x\n");
876+ std::fs::write(d.path().join("root.md"), "# A\n\n- x\n- synced\n").unwrap();
877+ let x = at(&v, &["A", "x"]);
878+ let err = ops::make_block(&mut v, x).unwrap_err();
879+ assert!(err.to_string().contains("changed on disk"), "{}", err);
880+ let names: Vec<String> = std::fs::read_dir(d.path())
881+ .unwrap()
882+ .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
883+ .collect();
884+ assert_eq!(names, ["root.md"]);
885+ assert_eq!(read(&d, "root.md"), "# A\n\n- x\n- synced\n");
886+}
887+
888+#[test]
889+fn first_child_of_an_item_without_a_body_follows_its_title() {
890+ // §4.2: one blank line between a node's body and its first child; an
891+ // item with no body has none to keep its first child from its title
892+ // (§4.10: `- Talked to Anya…` then its child `### Options`)
893+ for (src, parent, want) in [
894+ ("- a\n", &["a"][..], "- a\n - c\n"),
895+ ("- a\n- b\n", &["a"], "- a\n - c\n- b\n"),
896+ ("- a\n\n- b\n", &["a"], "- a\n - c\n\n- b\n"),
897+ ("# S\n\n- a\n", &["S", "a"], "# S\n\n- a\n - c\n"),
898+ // a body keeps its blank line, and so does a section's title
899+ ("- a\n body\n", &["a"], "- a\n body\n\n - c\n"),
900+ ("# S\n", &["S"], "# S\n\n- c\n"),
901+ ] {
902+ let (d, mut v) = vault_with(src);
903+ let p = at(&v, parent);
904+ let c = ops::append_child_public(&mut v, p, "c").unwrap();
905+ assert_eq!(v.tree.node(c).title, "c");
906+ assert_eq!(read(&d, "root.md"), want, "N under {:?}", src);
907+ // capture to it too (§7)
908+ let (d, mut v) = vault_with(src);
909+ let p = at(&v, parent);
910+ ops::capture_to(&mut v, "c", false, p).unwrap();
911+ assert_eq!(read(&d, "root.md"), want, "capture to {:?}", src);
912+ }
913+ // under an item with a section child, the item goes before it: right
914+ // after the title too
915+ let (d, mut v) = vault_with("- a\n # S\n");
916+ let a = at(&v, &["a"]);
917+ ops::capture_to(&mut v, "c", false, a).unwrap();
918+ assert_eq!(read(&d, "root.md"), "- a\n - c\n # S\n");
919+}
920+
921+#[test]
922+fn deleting_a_node_that_holds_a_second_embed_keeps_the_block_and_its_first_embed() {
923+ // the second embed of S (under B/x) renders as broken (§6.2); deleting
924+ // x, the node that holds it, must not trash S nor take its first
925+ // embed, the one under A that S is stitched in at
926+ let (d, mut v) = vault_files(
927+ &format!("# A\n\n![[{}]]\n\n# B\n\n- x\n ![[{}]]\n", ID_A, ID_A),
928+ &[("racfer~s.md", &format!("---\nid: {}\n---\n\n- S\n", ID_A))],
929+ );
930+ let x = at(&v, &["B", "x"]);
931+ ops::delete_subtree(&mut v, x).unwrap();
932+ assert!(d.path().join("racfer~s.md").exists(), "the block was trashed");
933+ assert_eq!(read(&d, "root.md"), format!("# A\n\n![[{}]]\n\n# B\n", ID_A));
934+ // nor may clearing a done x, which removes it the same way
935+ let (d, mut v) = vault_files(
936+ &format!("# A\n\n![[{}]]\n\n# B\n\n- [x] x\n ![[{}]]\n", ID_A, ID_A),
937+ &[("racfer~s.md", &format!("---\nid: {}\n---\n\n- S\n", ID_A))],
938+ );
939+ let b = at(&v, &["B"]);
940+ assert_eq!(ops::clear_done(&mut v, b).unwrap(), 1);
941+ assert!(d.path().join("racfer~s.md").exists(), "the block was trashed");
942+ assert_eq!(read(&d, "root.md"), format!("# A\n\n![[{}]]\n\n# B\n", ID_A));
943+}
944+
945+#[test]
946+fn deleting_the_node_that_holds_a_blocks_first_embed_leaves_its_second_embed() {
947+ // deleting A trashes S, which A holds; the second embed of S, under
948+ // B/x, is x's line, not S's own (§6.2), and stays where it is
949+ let (d, mut v) = vault_files(
950+ &format!("# A\n\n![[{}]]\n\n# B\n\n- x\n ![[{}]]\n", ID_A, ID_A),
951+ &[("racfer~s.md", &format!("---\nid: {}\n---\n\n- S\n", ID_A))],
952+ );
953+ let a = at(&v, &["A"]);
954+ ops::delete_subtree(&mut v, a).unwrap();
955+ assert!(!d.path().join("racfer~s.md").exists(), "the block was not trashed");
956+ assert_eq!(read(&d, "root.md"), format!("# B\n\n- x\n ![[{}]]\n", ID_A));
957+}
958+
959+#[test]
960+fn a_block_embedded_twice_is_inlined_where_the_outline_shows_it() {
961+ // X (embedded under A) embeds S, and root.md embeds S again under B.
962+ // The outline (walk, and the TUI's rows) meets S under X first; the
963+ // reading pane and editor (render) must inline S there too, not at the
964+ // embed that happens to come first in file order
965+ let (_d, v) = vault_files(
966+ &format!("# A\n\n![[{ID_B}]]\n\n# B\n\n![[{ID_A}]]\n"),
967+ &[
968+ ("dozzod~x.md", &format!("---\nid: {ID_B}\n---\n\n- X\n ![[{ID_A}]]\n")),
969+ ("racfer~s.md", &format!("---\nid: {ID_A}\n---\n\n- S\n")),
970+ ],
971+ );
972+ let mut walked = Vec::new();
973+ v.tree.walk(v.tree.root, &mut |t, r| {
974+ let n = t.node(r);
975+ walked.push(if n.is_embed() { "(embed)".to_string() } else { n.title.clone() });
976+ });
977+ // B's embed of S is the second one met: it reads as broken (§6.2)
978+ assert_eq!(walked, ["", "A", "X", "S", "B", "(embed)"]);
979+ let x = at(&v, &["A", "X"]);
980+ let r = fold_core::render::render(&v.tree, x, 1, true);
981+ assert_eq!(r, "- X\n - S\n");
982+ let b = at(&v, &["B"]);
983+ let r = fold_core::render::render(&v.tree, b, 1, true);
984+ assert_eq!(r, format!("# B\n\n![[{ID_A}]]\n"));
985+ // and the embed S is stitched in at, which promote and refile act
986+ // beside (§4.7), is X's
987+ let id = fold_core::Id::parse(ID_A).unwrap();
988+ let e = v.tree.embed_of(&id).unwrap();
989+ assert_eq!(v.tree.files[e.0].path, "dozzod~x.md");
990+}
991+
992+#[test]
993+fn a_block_an_orphan_embeds_too_is_inlined_where_the_root_reaches_it() {
994+ // an orphan block file (embedded nowhere) that sorts before C also
995+ // embeds S: S belongs where the root reaches it, under C, not at the
996+ // orphan's embed, which nothing shows
997+ const ID_O: &str = "lacnum-walbyn-dirlyn-havtyp";
998+ let (_d, v) = vault_files(
999+ &format!("# A\n\n![[{ID_B}]]\n"),
1000+ &[
1001+ ("bacwes~o.md", &format!("---\nid: {ID_O}\n---\n\n- O\n ![[{ID_A}]]\n")),
1002+ ("dozzod~c.md", &format!("---\nid: {ID_B}\n---\n\n- C\n ![[{ID_A}]]\n")),
1003+ ("racfer~s.md", &format!("---\nid: {ID_A}\n---\n\n- S\n")),
1004+ ],
1005+ );
1006+ let r = fold_core::render::render(&v.tree, v.tree.root, 1, true);
1007+ assert_eq!(r, "# A\n\n- C\n - S\n");
1008+ assert!(v.find_by_path(&["A".into(), "C".into(), "S".into()]).is_some());
1009+}
1010+
1011+#[test]
1012+fn respelling_an_item_keeps_its_setext_child_under_it() {
1013+ // a setext heading is read (§4.2); respelling its parent must convert
1014+ // it with the rest of the subtree, not leave a level-1 underline that
1015+ // takes it out of the tree it was in
1016+ let (d, mut v) = vault_with("# P\n\n- a\n Sub\n ===\n body\n");
1017+ let a = at(&v, &["P", "a"]);
1018+ assert_eq!(v.tree.resolved_children(a).len(), 1);
1019+ ops::toggle_spelling(&mut v, a).unwrap();
1020+ assert!(
1021+ v.find_by_path(&["P".into(), "a".into(), "Sub".into()]).is_some(),
1022+ "{}",
1023+ read(&d, "root.md")
1024+ );
1025+ // so for a heading under an item written shallower than its position
1026+ // gives (§3.1): it stays one below its parent, respelled and back
1027+ let (d, mut v) = vault_with("# P\n\n- a\n # Sub\n body\n\n- b\n");
1028+ let a = at(&v, &["P", "a"]);
1029+ ops::toggle_spelling(&mut v, a).unwrap();
1030+ assert!(v.find_by_path(&["P".into(), "a".into(), "Sub".into()]).is_some(), "{}", read(&d, "root.md"));
1031+ let a = at(&v, &["P", "a"]);
1032+ ops::toggle_spelling(&mut v, a).unwrap();
1033+ assert!(v.find_by_path(&["P".into(), "a".into(), "Sub".into()]).is_some(), "{}", read(&d, "root.md"));
1034+ assert!(v.find_by_path(&["P".into(), "b".into()]).is_some(), "{}", read(&d, "root.md"));
1035+}
1036+
1037+#[test]
1038+fn respelling_a_block_whose_parent_changed_on_disk_writes_nothing() {
1039+ // `~` on a block writes its file, then its embed in the parent file. A
1040+ // sync not reloaded yet in the parent file makes the vault refuse the
1041+ // embed (§11.2); the block file must not be left respelled under an
1042+ // embed of the other form (§4.7), as make_block checks first
1043+ let (d, mut v) = vault_with("# A\n\n- task\n");
1044+ let t = at(&v, &["A", "task"]);
1045+ ops::make_block(&mut v, t).unwrap();
1046+ let bf = d.path().join(&v.tree.files[1].path);
1047+ let block_before = std::fs::read_to_string(&bf).unwrap();
1048+ let synced = format!("{}- from phone\n", read(&d, "root.md"));
1049+ std::fs::write(d.path().join("root.md"), &synced).unwrap();
1050+ let t = at(&v, &["A", "task"]);
1051+ let res = ops::toggle_spelling(&mut v, t);
1052+ assert!(res.is_err());
1053+ assert_eq!(read(&d, "root.md"), synced);
1054+ assert_eq!(
1055+ std::fs::read_to_string(&bf).unwrap(),
1056+ block_before,
1057+ "the block was respelled, its embed was not"
1058+ );
1059+}
1060+
1061+#[test]
1062+fn capturing_a_title_of_dashes_writes_nothing() {
1063+ // `- ---`, `- --` and `- - -` are thematic breaks, which CommonMark
1064+ // reads before a bullet: body text, not an item (§4.4). Capture refuses
1065+ // such a title before it writes anything, as it would an empty one
1066+ // (§4.3), instead of leaving a break under the day and failing after
1067+ for title in ["---", "--", "- -", "-- -"] {
1068+ let (d, mut v) = vault_with("# Projects\n");
1069+ let res = ops::capture(&mut v, title, false);
1070+ assert!(res.is_err(), "{:?} was captured", title);
1071+ assert_eq!(read(&d, "root.md"), "# Projects\n", "{:?}", title);
1072+ let (d, mut v) = vault_with("# A\n\n- x\n");
1073+ let a = at(&v, &["A"]);
1074+ let res = ops::capture_to(&mut v, title, false, a);
1075+ assert!(res.is_err(), "{:?} was captured", title);
1076+ assert_eq!(read(&d, "root.md"), "# A\n\n- x\n", "{:?}", title);
1077+ }
1078+ // a title that only starts with dashes, or a task's, is an item
1079+ let (_d, mut v) = vault_with("# A\n\n- x\n");
1080+ let a = at(&v, &["A"]);
1081+ let r = ops::capture_to(&mut v, "-- note", false, a).unwrap();
1082+ assert_eq!(v.tree.node(r).title, "-- note");
1083+ let a = at(&v, &["A"]);
1084+ let r = ops::capture_to(&mut v, "---", true, a).unwrap();
1085+ assert_eq!(v.tree.node(r).title, "---");
1086+ let a = at(&v, &["A"]);
1087+ let r = ops::capture_to(&mut v, "-", false, a).unwrap();
1088+ assert_eq!(v.tree.node(r).title, "-");
1089+}
1090+
1091+#[test]
1092+fn a_title_of_dashes_is_not_respelled_or_unchecked_into_a_thematic_break() {
1093+ // `~` on `## ---` would write `- ---`, and `t` on `- [ ] ---` would
1094+ // leave `- ---`: both thematic breaks, body text (§4.4), so the node
1095+ // would be gone and `y` under it taken in by `A`. Both are refused
1096+ // before they write
1097+ let (d, mut v) = vault_with("# A\n\n## ---\n\n- y\n");
1098+ let s = at(&v, &["A", "---"]);
1099+ assert!(ops::toggle_spelling(&mut v, s).is_err());
1100+ assert_eq!(read(&d, "root.md"), "# A\n\n## ---\n\n- y\n");
1101+ let (d, mut v) = vault_with("# A\n\n- [ ] ---\n- y\n");
1102+ let t = at(&v, &["A", "---"]);
1103+ assert!(ops::toggle_taskness(&mut v, t).is_err());
1104+ assert_eq!(read(&d, "root.md"), "# A\n\n- [ ] ---\n- y\n");
1105+ // checked, and as a heading, it is still a title
1106+ let t = at(&v, &["A", "---"]);
1107+ ops::toggle_task(&mut v, t).unwrap();
1108+ assert_eq!(read(&d, "root.md"), "# A\n\n- [x] ---\n- y\n");
1109+ let t = at(&v, &["A", "---"]);
1110+ ops::toggle_spelling(&mut v, t).unwrap();
1111+ assert!(read(&d, "root.md").contains("\n## [x] ---\n"), "{}", read(&d, "root.md"));
1112+ at(&v, &["A", "---"]);
1113+ let (d, mut v) = vault_with("# A\n\n## [ ] ---\n");
1114+ let s = at(&v, &["A", "---"]);
1115+ ops::toggle_taskness(&mut v, s).unwrap();
1116+ assert_eq!(read(&d, "root.md"), "# A\n\n## ---\n");
1117+}
1118+
1119+#[test]
1120+fn clearing_a_done_block_trashes_the_blocks_embedded_in_it() {
1121+ // T is embedded in S's file only: clearing a done S trashes T too, as
1122+ // deleting S does (§11.5), instead of leaving it embedded nowhere
1123+ let (d, mut v) = vault_files(
1124+ &format!("# A\n\n![[{}]]\n- open\n", ID_A),
1125+ &[
1126+ ("racfer~s.md", &format!("---\nid: {}\n---\n\n- [x] S\n ![[{}]]\n", ID_A, ID_B)),
1127+ ("dozzod~t.md", &format!("---\nid: {}\n---\n\n- T\n", ID_B)),
1128+ ],
1129+ );
1130+ let a = at(&v, &["A"]);
1131+ assert_eq!(ops::clear_done(&mut v, a).unwrap(), 1);
1132+ assert!(!d.path().join("racfer~s.md").exists(), "S was not trashed");
1133+ assert!(!d.path().join("dozzod~t.md").exists(), "T was left embedded nowhere");
1134+ assert_eq!(read(&d, "root.md"), "# A\n\n- open\n");
1135+}
1136+
1137+#[test]
1138+fn setting_a_property_on_a_second_embed_never_writes_the_file_that_holds_it() {
1139+ const ID_S: &str = "lacnum-walbyn-dirlyn-havtyp";
1140+ let x_text = format!("---\nid: {ID_B}\n---\n\n- X\n ![[{ID_S}]]\n");
1141+ let (d, mut v) = vault_files(
1142+ &format!("# A\n\n![[{ID_S}]]\n\n# B\n\n![[{ID_B}]]\n"),
1143+ &[("dozzod~x.md", &x_text), ("lacnum~s.md", &format!("---\nid: {ID_S}\n---\n\n- S\n"))],
1144+ );
1145+ let x = at(&v, &["B", "X"]);
1146+ let second = v.tree.resolved_children(x)[0];
1147+ assert!(v.tree.node(second).is_embed(), "the second embed reads as broken");
1148+ let res = ops::set_property(&mut v, second, "due", "2026-10-01");
1149+ assert_eq!(
1150+ read(&d, "dozzod~x.md"),
1151+ x_text,
1152+ "set_property on S's second embed ({:?}) wrote into X's file",
1153+ res.map_err(|e| e.to_string())
1154+ );
1155+}
1156+
1157+#[test]
1158+fn verbs_that_write_into_a_node_refuse_a_second_embed() {
1159+ // a second embed of S reads as broken (§6.2): no node is there to hold
1160+ // a checkbox, properties or children, and an embed line has no
1161+ // children in its file. Each verb refuses, writing nothing, instead of
1162+ // writing into X's file, which holds the embed
1163+ const ID_S: &str = "lacnum-walbyn-dirlyn-havtyp";
1164+ fn second(v: &Vault) -> fold_core::tree::NRef {
1165+ v.tree.resolved_children(at(v, &["B", "X"]))[0]
1166+ }
1167+ type Verb = fn(&mut Vault) -> std::io::Result<()>;
1168+ let verbs: [(&str, Verb); 7] = [
1169+ ("toggle_taskness", |v| {
1170+ let e = second(v);
1171+ ops::toggle_taskness(v, e)
1172+ }),
1173+ ("set_property", |v| {
1174+ let e = second(v);
1175+ ops::set_property(v, e, "due", "2026-10-01").map(drop)
1176+ }),
1177+ ("append_child_public", |v| {
1178+ let e = second(v);
1179+ ops::append_child_public(v, e, "new").map(drop)
1180+ }),
1181+ ("capture_to", |v| {
1182+ let e = second(v);
1183+ ops::capture_to(v, "new", false, e).map(drop)
1184+ }),
1185+ ("refile", |v| {
1186+ let (c, e) = (at(v, &["C", "c"]), second(v));
1187+ ops::refile(v, c, e).map(drop)
1188+ }),
1189+ ("move_node into", |v| {
1190+ let (c, e) = (at(v, &["C", "c"]), second(v));
1191+ ops::move_node(v, c, e, ops::Drop::Into).map(drop)
1192+ }),
1193+ ("demote", |v| {
1194+ let y = at(v, &["B", "X", "y"]);
1195+ ops::demote(v, y).map(drop)
1196+ }),
1197+ ];
1198+ let root = format!("# A\n\n![[{ID_S}]]\n\n# B\n\n![[{ID_B}]]\n\n# C\n\n- c\n");
1199+ let x_text = format!("---\nid: {ID_B}\n---\n\n- X\n ![[{ID_S}]]\n - y\n");
1200+ let s_text = format!("---\nid: {ID_S}\n---\n\n- S\n");
1201+ for (name, verb) in verbs {
1202+ let (d, mut v) = vault_files(&root, &[("dozzod~x.md", &x_text), ("lacnum~s.md", &s_text)]);
1203+ assert!(v.tree.node(second(&v)).is_embed(), "the second embed reads as broken");
1204+ let res = verb(&mut v);
1205+ assert_eq!(read(&d, "dozzod~x.md"), x_text, "{} wrote into X's file", name);
1206+ assert_eq!(read(&d, "root.md"), root, "{} wrote root.md", name);
1207+ assert_eq!(read(&d, "lacnum~s.md"), s_text, "{} wrote S's file", name);
1208+ assert!(res.is_err(), "{} did nothing and said nothing", name);
1209+ }
1210+}
1211+
1212+#[test]
1213+fn a_key_set_in_root_md_goes_into_its_own_frontmatter() {
1214+ // root.md's frontmatter holds vault-level properties (§4.9): a key set
1215+ // there joins them, never a second frontmatter above them that turns
1216+ // them into text
1217+ let (d, mut v) = vault_with("---\nfoo: bar\n---\n\n# A\n");
1218+ ops::set_frontmatter_key(&mut v, 0, "due", Some("2026-10-01")).unwrap();
1219+ assert_eq!(read(&d, "root.md"), "---\nfoo: bar\ndue: 2026-10-01\n---\n\n# A\n");
1220+ ops::set_frontmatter_key(&mut v, 0, "foo", None).unwrap();
1221+ assert_eq!(read(&d, "root.md"), "---\ndue: 2026-10-01\n---\n\n# A\n");
1222+ let root = v.tree.root;
1223+ ops::set_property(&mut v, root, "due", "2026-10-02").unwrap();
1224+ assert_eq!(read(&d, "root.md"), "---\ndue: 2026-10-02\n---\n\n# A\n");
1225+}
crates/fold-core/tests/ordering.rs+308
@@ -548,3 +548,311 @@ fn drop_section_before_an_item_is_clamped() {
548548 assert_ordered(&v);
549549 assert!(v.find_by_path(&["P".into(), "S".into()]).is_some(), "{}", root_text(&v));
550550}
551+
552+#[test]
553+fn drop_before_a_later_sibling_lands_before_it() {
554+ let src = "# A\n\n- a\n- b\n- c\n";
555+ let (_d, mut v) = vault_with(src);
556+ let (r, t) = (at(&v, "A/a"), at(&v, "A/c"));
557+ assert!(!ops::move_node(&mut v, r, t, ops::Drop::Before).unwrap());
558+ assert_eq!(root_text(&v), "# A\n\n- b\n- a\n- c\n");
559+ // dropping a node before its immediate next sibling leaves it in place,
560+ // in a tight list or a loose one (§4.2: preserved as found)
561+ for src in [src, "# A\n\n- a\n\n- b\n\n- c\n"] {
562+ let (_d, mut v) = vault_with(src);
563+ let (r, t) = (at(&v, "A/a"), at(&v, "A/b"));
564+ ops::move_node(&mut v, r, t, ops::Drop::Before).unwrap();
565+ assert_eq!(root_text(&v), src);
566+ }
567+}
568+
569+#[test]
570+fn respelled_item_does_not_adopt_deeper_written_sibling_sections() {
571+ // top-level sections written at `##`: honoured as written, still the root's
572+ let (_d, mut v) = vault_with("- note\n\n## Projects\n\n## Areas\n");
573+ let before = parents(&v);
574+ assert!(v.find_by_path(&["Projects".into()]).is_some());
575+ let r = at(&v, "note");
576+ ops::toggle_spelling(&mut v, r).unwrap();
577+ // `~`: "so no sibling changes parent" (§3.1, §10.3)
578+ assert_eq!(others(&parents(&v), "note"), others(&before, "note"), "{}", root_text(&v));
579+ assert!(v.find_by_path(&["Projects".into()]).is_some(), "{}", root_text(&v));
580+ assert_ordered(&v);
581+}
582+
583+#[test]
584+fn pasted_section_does_not_adopt_deeper_written_next_sibling() {
585+ let (_d, mut v) = vault_with("## A\n\n## B\n");
586+ let before = parents(&v);
587+ let r = at(&v, "A");
588+ ops::paste(&mut v, r, "# T\n\n## T1\n", true).unwrap();
589+ // T lands between A and B as a sibling, its child one below it; B keeps
590+ // the root as its parent
591+ assert_eq!(root_text(&v), "## A\n\n## T\n\n### T1\n\n## B\n");
592+ assert_eq!(others(&others(&parents(&v), "T"), "T1"), others(&before, "T"));
593+ assert!(v.find_by_path(&["T".into(), "T1".into()]).is_some(), "{}", root_text(&v));
594+}
595+
596+#[test]
597+fn promoted_section_does_not_adopt_deeper_written_next_sibling() {
598+ // `<` on S lands it between P and R, top-level sections written at `##`
599+ let (_d, mut v) = vault_with("## P\n\n### S\n\n## R\n");
600+ let before = parents(&v);
601+ let s = at(&v, "P/S");
602+ ops::promote(&mut v, s).unwrap();
603+ assert_eq!(root_text(&v), "## P\n\n## S\n\n## R\n");
604+ assert_eq!(others(&parents(&v), "S"), others(&before, "S"));
605+}
606+
607+#[test]
608+fn fixing_a_heading_embed_level_keeps_following_sections_in_place() {
609+ // B is a sibling of the embed under A; making the embed shallower must
610+ // not make B nest under it (§4.7: "the structure does not change")
611+ let (_d, mut v) = vault_with_block(
612+ "# A\n\n### ![[racfer-hattes-mislup-nodrys]]\n\n### B\n\n#### C\n",
613+ "# S\n",
614+ );
615+ let before = parents(&v);
616+ assert!(before.contains(&("B".to_string(), "A".to_string())), "{:?}", before);
617+ assert!(check::check(&v).iter().any(|d| d.message.contains("level 3 where its position gives 2")));
618+ check::fix(&mut v).unwrap();
619+ assert_eq!(parents(&v), before, "{}", root_text(&v));
620+ assert_eq!(root_text(&v), "# A\n\n## ![[racfer-hattes-mislup-nodrys]]\n\n## B\n\n#### C\n");
621+ assert!(check::check(&v).iter().all(|d| !d.message.contains("embed has children")
622+ && !d.message.contains("heading embed at level")));
623+ // a parent section written deeper than its own position leaves the
624+ // embed no shallower level that keeps it there: it stays, reported
625+ let root = "# A\n\n#### B\n\n###### ![[racfer-hattes-mislup-nodrys]]\n";
626+ let (_d, mut v) = vault_with_block(root, "# S\n");
627+ let before = parents(&v);
628+ assert!(before.contains(&("S".to_string(), "B".to_string())), "{:?}", before);
629+ check::fix(&mut v).unwrap();
630+ assert_eq!(parents(&v), before, "{}", root_text(&v));
631+ assert_eq!(root_text(&v), root);
632+}
633+
634+#[test]
635+fn move_up_past_a_deeper_written_sibling_keeps_parents() {
636+ // `### Note` skips a level but is still Doc's child, a sibling of Part
637+ let (_d, mut v) = vault_with("# Doc\n\n### Note\n\n## Part\n");
638+ assert_eq!(v.tree.node(at(&v, "Doc/Note")).kind, Kind::Section);
639+ let before = others(&parents(&v), "Part");
640+ let r = at(&v, "Doc/Part");
641+ ops::move_sibling(&mut v, r, false).unwrap();
642+ assert_eq!(others(&parents(&v), "Part"), before, "{}", root_text(&v));
643+ assert_ordered(&v);
644+ // the node moving down takes the level of the one moving up, and its
645+ // subtree comes with it
646+ let (_d, mut v) = vault_with("# Doc\n\n### Note\n\ntext\n\n#### Sub\n\n## Part\n");
647+ let before = parents(&v);
648+ let r = at(&v, "Doc/Part");
649+ ops::move_sibling(&mut v, r, false).unwrap();
650+ assert_eq!(root_text(&v), "# Doc\n\n## Part\n\n## Note\n\ntext\n\n### Sub\n");
651+ assert_eq!(parents(&v), before);
652+ // siblings all written deeper stay as written: none nests under another
653+ let src = "# Doc\n\n### A\n\n### B\n\n### C\n";
654+ let (_d, mut v) = vault_with(src);
655+ let before = parents(&v);
656+ let r = at(&v, "Doc/A");
657+ ops::move_sibling(&mut v, r, true).unwrap();
658+ assert_eq!(root_text(&v), "# Doc\n\n### B\n\n### A\n\n### C\n");
659+ assert_eq!(parents(&v), before);
660+ // an item indented deeper than the sibling after it (accepted on read)
661+ let (_d, mut v) = vault_with("- P\n - a\n - kid\n - b\n");
662+ let before = parents(&v);
663+ let r = at(&v, "P/b");
664+ ops::move_sibling(&mut v, r, false).unwrap();
665+ assert_eq!(parents(&v), before, "{}", root_text(&v));
666+ assert_eq!(root_text(&v), "- P\n - b\n - a\n - kid\n");
667+}
668+
669+// P sits under an item under `# S`, so its position gives it level 2 while
670+// it and its sections are written a level shallower (§3.1: honoured as
671+// written). A section written after one of them at the level P's position
672+// gives would parse as that one's child.
673+const SHALLOW: &str = "# S\n\n- i\n # P\n ## C\n";
674+
675+#[test]
676+fn section_refiled_after_a_shallower_written_sibling_is_not_adopted_by_it() {
677+ let (_d, mut v) = vault_with(&format!("{}\n# X\n", SHALLOW));
678+ let before = others(&parents(&v), "X");
679+ let (x, p) = (at(&v, "X"), at(&v, "S/i/P"));
680+ ops::refile(&mut v, x, p).unwrap();
681+ assert!(v.find_by_path(&["S".into(), "i".into(), "P".into(), "X".into()]).is_some(), "{}", root_text(&v));
682+ assert_eq!(others(&parents(&v), "X"), before, "{}", root_text(&v));
683+ assert_eq!(root_text(&v), format!("{}\n ## X\n", SHALLOW));
684+ assert_ordered(&v);
685+}
686+
687+#[test]
688+fn section_pasted_between_shallower_written_siblings_is_not_adopted() {
689+ // no deeper than C before it, no shallower than D after it
690+ let src = format!("{} ## D\n", SHALLOW);
691+ let (_d, mut v) = vault_with(&src);
692+ let before = parents(&v);
693+ let c = at(&v, "S/i/P/C");
694+ ops::paste(&mut v, c, "# T\n", true).unwrap();
695+ assert!(v.find_by_path(&["S".into(), "i".into(), "P".into(), "T".into()]).is_some(), "{}", root_text(&v));
696+ assert_eq!(others(&parents(&v), "T"), before, "{}", root_text(&v));
697+ assert_ordered(&v);
698+}
699+
700+#[test]
701+fn new_last_child_section_after_a_shallower_written_sibling_is_not_adopted() {
702+ // N on P: a section beside C, not under it
703+ let (_d, mut v) = vault_with(SHALLOW);
704+ let before = parents(&v);
705+ let p = at(&v, "S/i/P");
706+ let x = ops::append_child_public(&mut v, p, "x").unwrap();
707+ assert_eq!(v.tree.node(x).title, "x");
708+ assert!(v.find_by_path(&["S".into(), "i".into(), "P".into(), "x".into()]).is_some(), "{}", root_text(&v));
709+ assert_eq!(others(&parents(&v), "x"), before, "{}", root_text(&v));
710+}
711+
712+#[test]
713+fn making_a_block_of_a_section_after_a_shallower_written_sibling_keeps_its_parent() {
714+ // the embed replaces P where it is written; at the level P's position
715+ // gives it would parse as C's child
716+ let (_d, mut v) = vault_with("# S\n\n- i\n # C\n # P\n");
717+ let before = parents(&v);
718+ let p = at(&v, "S/i/P");
719+ ops::make_block(&mut v, p).unwrap();
720+ assert_eq!(parents(&v), before, "{}", root_text(&v));
721+}
722+
723+#[test]
724+fn capture_after_a_shallower_written_day_starts_a_day_beside_it() {
725+ // an Inbox spelled as an item, its day written by hand at `#`
726+ let (_d, mut v) = vault_with("- Inbox\n # 2000-01-01\n - old\n");
727+ let r = ops::capture(&mut v, "new", false).unwrap();
728+ let path = v.tree.path(r);
729+ assert_eq!(path.len(), 3, "{:?}\n{}", path, root_text(&v));
730+ assert_eq!(path[0], "Inbox");
731+ assert_ne!(path[1], "2000-01-01", "{}", root_text(&v));
732+ assert!(v.find_by_path(&["Inbox".into(), "2000-01-01".into(), "old".into()]).is_some());
733+}
734+
735+#[test]
736+fn drop_before_the_next_sibling_in_a_four_space_list_keeps_it_a_sibling() {
737+ // §4.2: 4-space nesting is accepted on read. Dropping a before b, its
738+ // next sibling, leaves it where it was (a no-op); b must not become a's
739+ // child because a was rewritten at two spaces in front of it
740+ let (_d, mut v) = vault_with("# T\n\n- P\n - a\n - b\n");
741+ let (a, b) = (at(&v, "T/P/a"), at(&v, "T/P/b"));
742+ ops::move_node(&mut v, a, b, ops::Drop::Before).unwrap();
743+ assert!(v.find_by_path(&["T".into(), "P".into(), "b".into()]).is_some(), "{}", root_text(&v));
744+ assert!(v.find_by_path(&["T".into(), "P".into(), "a".into(), "b".into()]).is_none(), "{}", root_text(&v));
745+ assert_eq!(root_text(&v), "# T\n\n- P\n - a\n - b\n");
746+}
747+
748+#[test]
749+fn a_node_placed_before_a_sibling_written_deeper_keeps_it_a_sibling() {
750+ // siblings nested with a tab or 4 spaces (§4.2), and items indented
751+ // under a section: a node pasted or dragged in front of one goes at
752+ // its indent, not at the canonical one, where the sibling would parse
753+ // as its child
754+ for src in [
755+ "# T\n\n- P\n - a\n - b\n",
756+ "# T\n\n- P\n\t- a\n\t- b\n",
757+ "# T\n\n- P\n # a\n # b\n",
758+ "# T\n\n## P\n\n - a\n - b\n",
759+ ] {
760+ let (_d, mut v) = vault_with(src);
761+ let before = parents(&v);
762+ let b = at(&v, "T/P/b");
763+ let item = v.tree.node(b).kind == Kind::Item;
764+ ops::paste(&mut v, b, if item { "- c\n" } else { "# c\n" }, false).unwrap();
765+ assert_eq!(others(&parents(&v), "c"), before, "paste in {:?}:\n{}", src, root_text(&v));
766+ assert!(parents(&v).contains(&("c".into(), "P".into())), "{}", root_text(&v));
767+ let (_d, mut v) = vault_with(src);
768+ let (a, b) = (at(&v, "T/P/a"), at(&v, "T/P/b"));
769+ ops::move_node(&mut v, b, a, ops::Drop::Before).unwrap();
770+ assert_eq!(parents(&v), before, "drag in {:?}:\n{}", src, root_text(&v));
771+ let p = at(&v, "T/P");
772+ let kids: Vec<String> =
773+ v.tree.resolved_children(p).iter().map(|&k| v.tree.node(k).title.clone()).collect();
774+ assert_eq!(kids, ["b", "a"], "{}", root_text(&v));
775+ }
776+ // an item before a section child written deeper
777+ let (_d, mut v) = vault_with("# T\n\n- P\n # a\n");
778+ let before = parents(&v);
779+ let a = at(&v, "T/P/a");
780+ ops::paste(&mut v, a, "- c\n", false).unwrap();
781+ assert_eq!(others(&parents(&v), "c"), before, "{}", root_text(&v));
782+ assert!(parents(&v).contains(&("c".into(), "P".into())), "{}", root_text(&v));
783+}
784+
785+#[test]
786+fn making_a_block_after_a_shallower_sibling_written_deeper_keeps_the_next_sibling() {
787+ // Notes is written with a tab (§4.2: accepted on read), Meeting and
788+ // Followup at the app's two spaces: all three are children of Project.
789+ // Notes' shallower level says nothing about Meeting's embed, which sits
790+ // at a smaller indent; clamped to it, the embed takes Followup in.
791+ let (_d, mut v) = vault_with("## S\n\n- Project\n\t# Notes\n ### Meeting\n ### Followup\n");
792+ let before = parents(&v);
793+ assert!(before.contains(&("Followup".into(), "Project".into())));
794+ let m = at(&v, "S/Project/Meeting");
795+ ops::make_block(&mut v, m).unwrap();
796+ assert_eq!(parents(&v), before, "{}", root_text(&v));
797+}
798+
799+#[test]
800+fn a_section_placed_after_a_shallower_sibling_written_deeper_keeps_the_next_sibling() {
801+ // the same for a section pasted between Notes and Meeting, or after
802+ // Notes when it is Project's last child: Notes, closed by indentation,
803+ // bounds neither
804+ let (_d, mut v) = vault_with("## S\n\n- Project\n\t# Notes\n ### Meeting\n ### Followup\n");
805+ let before = parents(&v);
806+ let notes = at(&v, "S/Project/Notes");
807+ ops::paste(&mut v, notes, "# X\n", true).unwrap();
808+ assert_eq!(others(&parents(&v), "X"), before, "{}", root_text(&v));
809+ assert!(parents(&v).contains(&("X".into(), "Project".into())), "{}", root_text(&v));
810+ let (_d, mut v) = vault_with("## S\n\n- Project\n ### Meeting\n\t# Notes\n");
811+ let notes = at(&v, "S/Project/Notes");
812+ ops::paste(&mut v, notes, "# X\n", true).unwrap();
813+ assert!(root_text(&v).ends_with("\t# Notes\n\n ### X\n"), "{}", root_text(&v));
814+}
815+
816+#[test]
817+fn refile_into_a_section_followed_by_an_indented_setext_heading() {
818+ // a setext heading is read (§4.2) as the ATX heading of its level:
819+ // ` Sub\n ===` after `## P` is where ` # Sub` would be, a level-1
820+ // heading that no indent puts under a section. Read as P's child, it
821+ // made the verbs that write it, or write beside it, at its level 1
822+ // (refile into P, `t` on it) move nodes out of P and Top
823+ const SETEXT: &str = "# Top\n\n## P\n\n Sub\n ===\n\n# X\n";
824+ let (_d, mut v) = vault_with(SETEXT);
825+ let before = parents(&v);
826+ assert_eq!(before, parents(&vault_with("# Top\n\n## P\n\n # Sub\n\n# X\n").1));
827+ let (x, p) = (at(&v, "X"), at(&v, "Top/P"));
828+ ops::refile(&mut v, x, p).unwrap();
829+ assert!(v.find_by_path(&["Top".into(), "P".into(), "X".into()]).is_some(), "{}", root_text(&v));
830+ assert_eq!(others(&parents(&v), "X"), others(&before, "X"), "{}", root_text(&v));
831+ let (_d, mut v) = vault_with(SETEXT);
832+ let sub = at(&v, "Sub");
833+ ops::toggle_taskness(&mut v, sub).unwrap();
834+ assert_eq!(parents(&v), before, "{}", root_text(&v));
835+ // under an item, indentation nests it, as it does an ATX heading
836+ let (_d, v) = vault_with("# Top\n\n- i\n\n Sub\n ===\n");
837+ assert_eq!(parents(&v), parents(&vault_with("# Top\n\n- i\n\n # Sub\n").1));
838+ assert!(parents(&v).contains(&("Sub".into(), "i".into())));
839+}
840+
841+#[test]
842+fn drop_second_of_two_loose_items_before_the_first_keeps_the_list_loose() {
843+ // §4.2: loose/tight lists are preserved as found
844+ let (_d, mut v) = vault_with("# A\n\n- a\n\n- b\n");
845+ let (b, a) = (at(&v, "A/b"), at(&v, "A/a"));
846+ ops::move_node(&mut v, b, a, ops::Drop::Before).unwrap();
847+ assert_eq!(root_text(&v), "# A\n\n- b\n\n- a\n");
848+ // and dropping the first before the second, where it is, changes nothing
849+ let (_d, mut v) = vault_with("# A\n\n- a\n\n- b\n");
850+ let (a, b) = (at(&v, "A/a"), at(&v, "A/b"));
851+ ops::move_node(&mut v, a, b, ops::Drop::Before).unwrap();
852+ assert_eq!(root_text(&v), "# A\n\n- a\n\n- b\n");
853+ // a tight list stays tight
854+ let (_d, mut v) = vault_with("# A\n\n- a\n- b\n");
855+ let (b, a) = (at(&v, "A/b"), at(&v, "A/a"));
856+ ops::move_node(&mut v, b, a, ops::Drop::Before).unwrap();
857+ assert_eq!(root_text(&v), "# A\n\n- b\n- a\n");
858+}
crates/fold-core/tests/roundtrip.rs+100−27
@@ -13,11 +13,7 @@ fn tree_of(text: &str) -> Tree {
1313 };
1414 let pf = parse_file("root.md", text, 0, Some(block));
1515 assert!(pf.diagnostics.is_empty(), "diags: {:?}", pf.diagnostics);
16− Tree {
17− files: vec![pf],
18− root: (0, 0),
19− blocks: vec![],
20− }
16+ Tree::new(vec![pf])
2117}
2218
2319#[test]
@@ -181,7 +177,7 @@ fn block_file_tree() -> Tree {
181177 let fm = fold_core::parse::parse_frontmatter(block_text).unwrap();
182178 let id = Id::parse("dozzod-binwes-talsun-worbec").unwrap();
183179 let block = Block {
184− id: Some(id.clone()),
180+ id: Some(id),
185181 path: "dozzod~zfs-layout.md".into(),
186182 props: fm.props,
187183 frontmatter_raw: fm.raw,
@@ -189,11 +185,7 @@ fn block_file_tree() -> Tree {
189185 };
190186 let bf = parse_file("dozzod~zfs-layout.md", block_text, 1, Some(block));
191187 assert!(bf.diagnostics.is_empty(), "diags: {:?}", bf.diagnostics);
192− Tree {
193− root: (0, 0),
194− blocks: vec![((1, bf.nodes[0].children[0]), id)],
195− files: vec![root, bf],
196− }
188+ Tree::new(vec![root, bf])
197189}
198190
199191#[test]
@@ -251,11 +243,7 @@ fn bullet_root_block_file() {
251243 assert_eq!(n.kind, Kind::Item);
252244 assert_eq!(n.title, "Order new switch");
253245 assert_eq!(n.task, Some(TaskState::Open));
254− let t = Tree {
255− files: vec![pf],
256− root: (0, 0),
257− blocks: vec![],
258− };
246+ let t = Tree::new(vec![pf]);
259247 let r = render(&t, (0, root_node), 1, false);
260248 assert_eq!(r, text);
261249}
@@ -274,18 +262,14 @@ fn tree_with_block(root_text: &str, block_text: &str, block_path: &str) -> Tree
274262 let fm = fold_core::parse::parse_frontmatter(block_text).unwrap();
275263 let id = Id::parse(fm.props.get("id").unwrap()).unwrap();
276264 let block = Block {
277− id: Some(id.clone()),
265+ id: Some(id),
278266 path: block_path.into(),
279267 props: fm.props,
280268 frontmatter_raw: fm.raw,
281269 frontmatter_span: Some(fm.span),
282270 };
283271 let bf = parse_file(block_path, block_text, 1, Some(block));
284− Tree {
285− root: (0, 0),
286− blocks: vec![((1, bf.nodes[0].children[0]), id)],
287− files: vec![root, bf],
288− }
272+ Tree::new(vec![root, bf])
289273}
290274
291275const 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";
@@ -429,11 +413,7 @@ fn empty_title_nodes_parse_everywhere() {
429413 ];
430414 for (text, kids) in cases {
431415 let pf = parse_file("root.md", text, 0, None);
432− let t = Tree {
433− files: vec![pf],
434− root: (0, 0),
435− blocks: vec![],
436− };
416+ let t = Tree::new(vec![pf]);
437417 let a = t.resolved_children(t.root)[0];
438418 let ch = t.resolved_children(a);
439419 assert_eq!(ch.len(), *kids, "{:?}", text);
@@ -450,3 +430,96 @@ fn empty_title_nodes_parse_everywhere() {
450430 let pf = parse_file("root.md", "# A\n\n- [ ] \n", 0, None);
451431 assert!(pf.nodes.iter().any(|n| n.task == Some(TaskState::Open) && n.title.is_empty()));
452432}
433+
434+#[test]
435+fn render_keeps_child_sections_under_items_nested() {
436+ // A skipped-level section (§3.1) re-levelled by base − level(node)
437+ // (§5.1) can push a section under an item below level 1; its child
438+ // section must still print deeper than it, so that
439+ // parse(render(t, 1, false)) keeps the tree's shape.
440+ let t = tree_of("### Meeting\n- topic\n ## Notes\n ### Sub\n");
441+ let m = t.resolved_children(t.root)[0];
442+ let topic = t.resolved_children(m)[0];
443+ let notes = t.resolved_children(topic)[0];
444+ assert_eq!(t.resolved_children(notes).len(), 1, "Sub is a child of Notes");
445+ let r = render(&t, m, 1, false);
446+ assert_eq!(r, "# Meeting\n- topic\n # Notes\n ## Sub\n");
447+ let t2 = tree_of(&r);
448+ let m2 = t2.resolved_children(t2.root)[0];
449+ let topic2 = t2.resolved_children(m2)[0];
450+ assert_eq!(t2.resolved_children(topic2).len(), 1, "Notes and Sub became siblings:\n{r}");
451+ let notes2 = t2.resolved_children(topic2)[0];
452+ assert_eq!(t2.node(notes2).title, "Notes");
453+ assert_eq!(t2.resolved_children(notes2).len(), 1, "{r}");
454+}
455+
456+#[test]
457+fn inline_triple_backticks_do_not_open_a_fence() {
458+ // CommonMark: a backtick fence's info string may not contain backticks,
459+ // so "```npm i``` first" is a paragraph with inline code, not a fence.
460+ let t = tree_of("# A\n\n```npm i``` first\n\n## B\n\n- item\n");
461+ let a = t.resolved_children(t.root)[0];
462+ let kids = t.resolved_children(a);
463+ assert_eq!(kids.len(), 1);
464+ assert_eq!(t.node(kids[0]).title, "B");
465+ assert_eq!(t.resolved_children(kids[0]).len(), 1);
466+}
467+
468+#[test]
469+fn fence_line_with_info_string_does_not_close_a_fence() {
470+ // CommonMark: a closing fence may be followed only by spaces or tabs,
471+ // so "```rust" inside an open ``` fence is code, not a closer.
472+ let t = tree_of("# A\n\n```\n```rust\n# not a heading\n```\n\n## B\n");
473+ let top = t.resolved_children(t.root);
474+ assert_eq!(top.len(), 1);
475+ let kids = t.resolved_children(top[0]);
476+ assert_eq!(kids.len(), 1);
477+ assert_eq!(t.node(kids[0]).title, "B");
478+}
479+
480+#[test]
481+fn shifting_keeps_code_after_a_fence_line_with_info_string() {
482+ // the re-levelling passes of paste/refile and of splice read fences as
483+ // the parser does: the code line stays as written
484+ let doc = "# B\n\n```\n```rust\n# not a heading\n```\n";
485+ assert_eq!(
486+ fold_core::ops::shift_document(doc, 1, 0),
487+ "## B\n\n```\n```rust\n# not a heading\n```\n"
488+ );
489+ let dir = tempfile::tempdir().unwrap();
490+ let text = "# Top\n\n## B\n\n```\n```rust\n# not a heading\n```\n";
491+ std::fs::write(dir.path().join("root.md"), text).unwrap();
492+ let mut v = fold_core::vault::Vault::open(dir.path()).unwrap();
493+ let top = v.tree.resolved_children(v.tree.root)[0];
494+ let b = v.tree.resolved_children(top)[0];
495+ let mut buf = fold_core::edit::open_editor(&v, b);
496+ for o in buf.owners.keys().copied().collect::<Vec<_>>() {
497+ buf.mark_dirty(o);
498+ }
499+ buf.save_all(&mut v).unwrap();
500+ assert_eq!(v.tree.files[0].text, text);
501+}
502+
503+#[test]
504+fn spaced_thematic_breaks_are_text() {
505+ // §4.2/§4.4: CommonMark thematic breaks (which win over list items) are
506+ // body text, however they are spelled — not bullets titled "* *" / "- -".
507+ for text in [
508+ "# A\n\npara\n\n* * *\n\nmore\n",
509+ "# A\n\npara\n\n- - -\n\nmore\n",
510+ "# A\n\npara\n\n- - - -\n\nmore\n",
511+ "# A\n\n- x\n\n * * *\n",
512+ ] {
513+ let t = tree_of(text);
514+ let a = t.resolved_children(t.root)[0];
515+ let mut kids: Vec<String> = Vec::new();
516+ t.walk(a, &mut |t, k| kids.push(t.node(k).title.clone()));
517+ kids.retain(|k| k != "A" && k != "x");
518+ assert!(kids.is_empty(), "{:?} parsed as nodes {:?}", text, kids);
519+ assert_eq!(render(&t, a, 1, false), text);
520+ }
521+ // two marks are not a break: still an (empty-ish) bullet
522+ let t = tree_of("# A\n\n- -\n");
523+ let a = t.resolved_children(t.root)[0];
524+ assert_eq!(t.resolved_children(a).len(), 1);
525+}
crates/fold-core/tests/undo.rs+21
@@ -82,3 +82,24 @@ fn undo_and_redo_of_make_block_create_and_remove_the_file() {
8282 assert_eq!(count(), with_block);
8383 assert!(v.find_by_path(&["A".into(), "B".into()]).is_some());
8484}
85+
86+/// A verb computes what it writes from the parsed files. When a sync has
87+/// changed a file since and the change is not reloaded yet (the watcher's
88+/// debounce), the verb refuses rather than write its stale text back over
89+/// the change, which undo could not bring back either (§1 principle 4,
90+/// §11.2).
91+#[test]
92+fn a_verb_refuses_to_overwrite_a_change_not_reloaded_yet() {
93+ let (d, mut v) = vault_with("- [ ] a\n- [ ] b\n");
94+ let synced = "- [ ] a\n- [ ] b\n- [ ] from phone\n";
95+ std::fs::write(d.path().join("root.md"), synced).unwrap();
96+ let a = v.find_by_path(&["a".into()]).unwrap();
97+ let err = ops::toggle_task(&mut v, a).unwrap_err();
98+ assert!(err.to_string().contains("root.md"), "{}", err);
99+ assert_eq!(read(&d, "root.md"), synced);
100+ // once reloaded, it runs on what is there
101+ v.reload().unwrap();
102+ let a = v.find_by_path(&["a".into()]).unwrap();
103+ ops::toggle_task(&mut v, a).unwrap();
104+ assert_eq!(read(&d, "root.md"), "- [x] a\n- [ ] b\n- [ ] from phone\n");
105+}
crates/fold-tui/src/app.rs+668−168
@@ -74,7 +74,13 @@ pub struct App {
7474 pub(crate) vault: Vault,
7575 mode: Mode,
7676 focus: Focus,
77+ /// The zoom root (§10.3). While a verb runs it may be stale: read it
78+ /// through `zoom()`, and set it through `set_zoom()`.
7779 zoom_root: Option<NRef>,
80+ /// The zoom root's key while a verb or undo runs: the files it writes
81+ /// are re-parsed and their nodes renumbered, so until the verb settles
82+ /// the zoom is found by key (§11.2), not by its old index.
83+ zoom_anchor: Option<NodeKey>,
7884 pub cursor: usize,
7985 folded: Vec<NodeKey>,
8086 hide_done: bool,
@@ -101,10 +107,17 @@ pub struct App {
101107 /// The editor's keymap (normal, Vim, Helix), and its clipboard, kept
102108 /// across editing sessions.
103109 edit_keys: editor::Keys,
110+ /// Set when `--keys` or `$FOLD_KEYS` chose this run's keymap: the
111+ /// keymap the view goes on remembering instead (§10.6), the one it was
112+ /// loaded with, or none.
113+ kept_keys: Option<Option<editor::Keys>>,
104114 edit_clip: editor::Clip,
105115 edit_last_key: Instant,
106116 /// The pane that had focus when the editor opened, focused again after.
107117 edit_return: Focus,
118+ /// Where an outline verb moved the node the editor is open on: the
119+ /// editor is re-rendered there after the verb (`follow`).
120+ edit_moved: Option<NRef>,
108121 // property editor (§10.6)
109122 props_target: Option<NRef>,
110123 props_rows: Vec<(String, String, bool)>,
@@ -150,6 +163,10 @@ struct Prompt {
150163 /// clicked or picked with ↑/↓ and Enter.
151164 picks: Vec<NRef>,
152165 sel: usize,
166+ /// The node *Move to…* moves, fixed when the prompt opens: a menu's
167+ /// target is dropped once its item has run, long before the pick. A
168+ /// key, so a reload while the prompt is open cannot leave it stale.
169+ moving: Option<NodeKey>,
153170}
154171
155172#[derive(Clone)]
@@ -170,6 +187,7 @@ impl App {
170187 mode: Mode::Normal,
171188 focus: Focus::Outline,
172189 zoom_root: None,
190+ zoom_anchor: None,
173191 cursor: 0,
174192 folded: Vec::new(),
175193 hide_done: false,
@@ -192,9 +210,11 @@ impl App {
192210 .ok()
193211 .and_then(|k| editor::Keys::parse(&k))
194212 .unwrap_or_default(),
213+ kept_keys: None,
195214 edit_clip: editor::Clip::default(),
196215 edit_last_key: Instant::now(),
197216 edit_return: Focus::Outline,
217+ edit_moved: None,
198218 props_target: None,
199219 props_rows: Vec::new(),
200220 props_sel: 0,
@@ -297,55 +317,142 @@ impl App {
297317 }
298318
299319 /// Reload after an external change (§11.2): editor saves first, then
300− /// re-parse, cursor re-attached by id, key, deepest surviving step. A new
301− /// sync-conflict file starts the merge flow (§12).
320+ /// re-parse, cursor re-attached by id, key, deepest surviving step, and
321+ /// the editor re-rendered. A new sync-conflict file starts the merge
322+ /// flow (§12).
302323 pub fn reload_external(&mut self) {
303− if self.editor.is_some() {
304− self.save_editor("external change");
305− }
324+ // what popups show is held by key: the editor's save and the reload
325+ // re-parse files, renumbering their nodes. A closed property form's
326+ // node is stale after any verb since, and is let go
327+ let props_key = self.props_target.filter(|_| self.mode == Mode::Props).map(|t| self.vault.key_of(t));
328+ let filter_key = self.filter_rows.get(self.filter_sel).filter(|_| self.mode == Mode::Filter).map(|&r| self.vault.key_of(r));
329+ let edit = self.editor_before_write("external change");
306330 let cursor_key = self.current().map(|r| self.vault.key_of(r));
307− let zoom_key = self.zoom_root.map(|z| self.vault.key_of(z));
331+ // the zoom is held by key (§11.2): the merge flow re-parses the
332+ // vault, then may close the editor, which reads the outline, before
333+ // the reload is over
334+ self.anchor_zoom();
308335 // sync-conflict files start the merge flow (§11.2)
309336 match self.vault.conflict_files() {
310− Ok(files) if !files.is_empty() => {
311− match fold_core::merge::merge_sync_conflicts(&mut self.vault, false) {
312− Ok(outcomes) => {
313− self.say(format!("merged: {}", outcomes.join("; ")));
314− self.mode = Mode::Conflict;
315− self.conflict_idx = 0;
316− }
317− Err(e) => self.say(format!("merge error: {}", e)),
318− }
319− }
337+ Ok(files) if !files.is_empty() => self.merge_conflict_files("merged"),
320338 _ => {
321339 if let Err(e) = self.vault.reload() {
322340 self.say(format!("reload error: {}", e));
323341 }
324342 }
325343 }
326− self.zoom_root = zoom_key.and_then(|k| self.vault.find_by_key(&k));
344+ self.settle_zoom();
327345 if let Some(k) = cursor_key {
328346 if let Some(r) = self.vault.find_by_key(&k) {
329347 self.move_cursor_to(r);
330348 }
331349 }
332350 self.clamp_cursor();
351+ // the property form's node is found again, or the form closes; a
352+ // target prompt lists its candidates again, and the filter its hits,
353+ // its selection staying on its node (the menu keeps a key)
354+ self.props_target = props_key.and_then(|k| self.find_exact(&k));
355+ if self.mode == Mode::Props {
356+ match self.props_target {
357+ Some(_) => self.reopen_props(),
358+ None => self.mode = self.base_mode(),
359+ }
360+ }
361+ self.refresh_picks();
362+ if self.mode == Mode::Filter {
363+ self.update_filter();
364+ let sel = filter_key.and_then(|k| self.find_exact(&k));
365+ if let Some(i) = self.filter_rows.iter().position(|&r| Some(r) == sel) {
366+ self.filter_sel = i;
367+ }
368+ }
369+ self.editor_after_write(edit);
370+ }
371+
372+ /// The merge flow (§12.2): merge the sync-conflict copies, then open
373+ /// the conflict view on the pairs the merge raised. A copy the merge
374+ /// leaves alone (an ignored file's, one with nothing to merge against)
375+ /// raises none and stays, so it must not reopen the view, closing the
376+ /// editor, on every reload; nor must pairs lived with (§12.5).
377+ fn merge_conflict_files(&mut self, what: &str) {
378+ let before = self.conflict_blocks();
379+ match fold_core::merge::merge_sync_conflicts(&mut self.vault, false) {
380+ Ok(outcomes) => {
381+ self.say(format!("{}: {}", what, outcomes.join("; ")));
382+ let raised = self.conflict_blocks().iter().any(|b| !before.contains(b));
383+ if raised && (self.editor.is_none() || self.close_editor()) {
384+ self.mode = Mode::Conflict;
385+ self.conflict_idx = 0;
386+ }
387+ }
388+ Err(e) => self.say(format!("merge error: {}", e)),
389+ }
390+ }
391+
392+ /// The conflict blocks of the unresolved pairs, by id (§12.5).
393+ fn conflict_blocks(&self) -> Vec<NodeKey> {
394+ fold_core::merge::conflict_pairs(&self.vault)
395+ .into_iter()
396+ .map(|(_, theirs)| self.vault.key_of(theirs))
397+ .collect()
333398 }
334399
335400 /// Visible outline rows: the zoom subtree, flattened, honouring folds
336401 /// and the hide-done toggle.
337402 pub fn rows(&self) -> Vec<FlatRow> {
338403 let mut out = Vec::new();
339− let root = self.zoom_root.unwrap_or(self.vault.tree.root);
340− self.flatten(root, 0, false, &mut out);
404+ let root = self.zoom().unwrap_or(self.vault.tree.root);
405+ self.flatten(root, 0, false, &mut out, &mut Vec::new());
341406 out
342407 }
343408
344− fn flatten(&self, r: NRef, depth: usize, via_embed: bool, out: &mut Vec<FlatRow>) {
409+ /// The zoom root (§10.3), found again by key while a verb runs.
410+ fn zoom(&self) -> Option<NRef> {
411+ match &self.zoom_anchor {
412+ Some(k) => self.vault.find_by_key(k).filter(|&r| self.vault.tree.node(r).kind != Kind::Root),
413+ None => self.zoom_root,
414+ }
415+ }
416+
417+ fn set_zoom(&mut self, r: Option<NRef>) {
418+ self.zoom_root = r;
419+ self.zoom_anchor = None;
420+ }
421+
422+ /// Hold the zoom by key before a verb or undo rewrites files; a zoomed
423+ /// node that goes away zooms out to its deepest surviving ancestor.
424+ fn anchor_zoom(&mut self) {
425+ self.zoom_root = self.zoom();
426+ self.zoom_anchor = self.zoom_root.map(|z| self.vault.key_of(z));
427+ }
428+
429+ /// The verb is over: the zoom it found by key holds.
430+ fn settle_zoom(&mut self) {
431+ self.set_zoom(self.zoom());
432+ }
433+
434+ /// A node's parent in the outline, `None` at the top: a block root's
435+ /// tree parent is its own file's root, but its outline parent is the
436+ /// node that embeds it (as the breadcrumbs show).
437+ fn outline_parent(&self, r: NRef) -> Option<NRef> {
438+ let chain = self.chain(r);
439+ chain.len().checked_sub(2).map(|i| chain[i])
440+ }
441+
442+ /// `seen` holds the blocks already shown: an embed cycle or a second
443+ /// embed of a block (§6.2 diagnostics) shows it once, as walk and render
444+ /// do. Only a block can be reached twice, so only blocks are recorded.
445+ fn flatten(&self, r: NRef, depth: usize, via_embed: bool, out: &mut Vec<FlatRow>, seen: &mut Vec<NRef>) {
345446 let n = self.vault.tree.node(r);
447+ if n.is_block() {
448+ if seen.contains(&r) {
449+ return;
450+ }
451+ seen.push(r);
452+ }
346453 if n.kind == Kind::Root {
347454 for c in self.vault.tree.resolved_children(r) {
348− self.flatten(c, depth, false, out);
455+ self.flatten(c, depth, false, out, seen);
349456 }
350457 return;
351458 }
@@ -364,7 +471,7 @@ impl App {
364471 for c in self.vault.tree.resolved_children(r) {
365472 let through_embed = self.vault.tree.node(c).is_embed()
366473 && self.vault.tree.resolved_child(c) != c;
367− self.flatten(c, depth + 1, through_embed || via_embed, out);
474+ self.flatten(c, depth + 1, through_embed || via_embed, out, seen);
368475 }
369476 }
370477
@@ -390,6 +497,12 @@ impl App {
390497 }
391498 }
392499
500+ /// The node a key names, only if it is that very node: no falling back
501+ /// to the deepest step that still exists.
502+ fn find_exact(&self, key: &NodeKey) -> Option<NRef> {
503+ self.vault.find_by_key(key).filter(|&r| self.vault.key_of(r) == *key)
504+ }
505+
393506 fn is_folded(&self, r: NRef) -> bool {
394507 self.folded.contains(&self.vault.key_of(r))
395508 }
@@ -449,11 +562,13 @@ impl App {
449562 fn act_make_block(&mut self) {
450563 let Some(r) = self.subject() else { return };
451564 self.push_undo("act_make_block");
565+ let on = self.on_node(r);
452566 match ops::make_block(&mut self.vault, r) {
453567 Ok(id) => {
454568 self.say(format!("block {}", id));
455− // the cursor stays on the node, now a block
569+ // the cursor (a zoom, the editor) stays on the node, now a block
456570 if let Some(nr) = self.vault.tree.block_by_id(&id) {
571+ self.follow(on, nr);
457572 self.move_cursor_to(nr);
458573 }
459574 }
@@ -461,6 +576,24 @@ impl App {
461576 }
462577 }
463578
579+ /// Whether the zoom, and the editor, are on `r`, before a verb moves it.
580+ fn on_node(&self, r: NRef) -> (bool, bool) {
581+ let r = self.vault.tree.resolved_child(r);
582+ (self.zoom() == Some(r), self.editor_node() == Some(r))
583+ }
584+
585+ /// A node a verb moved landed at `nr`: a zoom on it stays on it
586+ /// (§10.3), and the editor open on it is re-rendered there after the
587+ /// verb (§10.6), not over whatever node its old path now leads to.
588+ fn follow(&mut self, (zoomed, editing): (bool, bool), nr: NRef) {
589+ if zoomed {
590+ self.set_zoom(Some(nr));
591+ }
592+ if editing {
593+ self.edit_moved = Some(nr);
594+ }
595+ }
596+
464597 fn act_delete(&mut self) {
465598 let Some(r) = self.subject() else { return };
466599 self.push_undo("act_delete");
@@ -523,12 +656,24 @@ impl App {
523656 }
524657
525658 fn act_demote(&mut self) {
526− let Some(r) = self.subject() else { return };
659+ let Some(s) = self.subject() else { return };
527660 self.push_undo("act_demote");
528− let key = self.vault.key_of(r);
529− match ops::demote(&mut self.vault, r) {
661+ let r = self.vault.tree.resolved_child(s);
662+ let (key, kind) = (self.vault.key_of(r), self.vault.tree.node(r).kind);
663+ // it goes under the node before it (§10.3)
664+ let sibs = self.vault.tree.resolved_children(self.outline_parent(r).unwrap_or(self.vault.tree.root));
665+ let prev = sibs.iter().position(|&c| c == r).and_then(|i| i.checked_sub(1)).map(|i| self.vault.key_of(sibs[i]));
666+ let on = self.on_node(r);
667+ match ops::demote(&mut self.vault, s) {
530668 Ok(moved) => {
531− if let Some(nr) = self.vault.find_by_key(&key) {
669+ // the cursor (a zoom, the editor) stays on the node where it
670+ // landed
671+ let landed = match &prev {
672+ Some(dest) => self.moved_node(&key, kind, dest, None),
673+ None => self.find_exact(&key),
674+ };
675+ if let Some(nr) = landed {
676+ self.follow(on, nr);
532677 self.move_cursor_to(nr);
533678 }
534679 if moved {
@@ -540,12 +685,30 @@ impl App {
540685 }
541686
542687 fn act_promote(&mut self) {
543− let Some(r) = self.subject() else { return };
688+ let Some(s) = self.subject() else { return };
544689 self.push_undo("act_promote");
545− let key = self.vault.key_of(r);
546− match ops::promote(&mut self.vault, r) {
690+ let r = self.vault.tree.resolved_child(s);
691+ let (key, kind) = (self.vault.key_of(r), self.vault.tree.node(r).kind);
692+ // it goes beside its parent, right after it, under the parent's
693+ // parent (§10.3)
694+ let parent = self.outline_parent(r);
695+ let grand = parent.map(|p| self.outline_parent(p).unwrap_or(self.vault.tree.root));
696+ let rank = parent.zip(grand).and_then(|(p, g)| {
697+ let at = self.vault.tree.resolved_children(g).iter().position(|&c| c == p)?;
698+ Some(self.namesakes_before(r, g, at + 1))
699+ });
700+ let grand = grand.map(|g| self.vault.key_of(g));
701+ let on = self.on_node(r);
702+ match ops::promote(&mut self.vault, s) {
547703 Ok(moved) => {
548− if let Some(nr) = self.vault.find_by_key(&key) {
704+ // the cursor (a zoom, the editor) stays on the node where it
705+ // landed
706+ let landed = match &grand {
707+ Some(dest) => self.moved_node(&key, kind, dest, rank),
708+ None => self.find_exact(&key),
709+ };
710+ if let Some(nr) = landed {
711+ self.follow(on, nr);
549712 self.move_cursor_to(nr);
550713 }
551714 if moved {
@@ -567,21 +730,24 @@ impl App {
567730
568731 fn act_clear_done(&mut self) {
569732 self.push_undo("act_clear_done");
570− let target = self.zoom_root.unwrap_or(self.vault.tree.root);
733+ let target = self.zoom().unwrap_or(self.vault.tree.root);
571734 match ops::clear_done(&mut self.vault, target) {
572735 Ok(n) => self.refresh_after(&format!("{} done item(s) trashed", n)),
573736 Err(e) => self.say(format!("error: {}", e)),
574737 }
575738 }
576739
577− fn refile_to(&mut self, dest: NRef) {
578− let Some(r) = self.subject() else { return };
740+ /// *Move to…* (§6.5): the prompt's moving node goes under `dest`.
741+ fn refile_to(&mut self, r: NRef, dest: NRef) {
579742 self.push_undo("move to");
580− let key = self.vault.key_of(r);
743+ let (rr, dest_key) = (self.vault.tree.resolved_child(r), self.vault.key_of(self.vault.tree.resolved_child(dest)));
744+ let (key, kind) = (self.vault.key_of(rr), self.vault.tree.node(rr).kind);
745+ let on = self.on_node(rr);
581746 match ops::refile(&mut self.vault, r, dest) {
582747 Ok(moved) => {
583748 self.refresh_after(&with_rule_note("moved", moved));
584− if let Some(nr) = self.moved_node(&key, dest) {
749+ if let Some(nr) = self.moved_node(&key, kind, &dest_key, None) {
750+ self.follow(on, nr);
585751 self.reveal(nr);
586752 }
587753 }
@@ -589,21 +755,46 @@ impl App {
589755 }
590756 }
591757
592− /// Where a moved node landed: by its key if it survived, else the
593− /// destination's child with its title.
594− fn moved_node(&self, key: &NodeKey, dest: NRef) -> Option<NRef> {
758+ /// Where a node a verb moved under `dest` (a key taken before the move:
759+ /// the move renumbers the nodes of the files it writes) landed: a block
760+ /// by its id; else, among `dest`'s children with the node's title and
761+ /// kind, the one the ordering rule (§3.1) put it at. Moved in as the
762+ /// last child, that is the last of them (an item goes after the items,
763+ /// a section after the sections); put at a place among them, the one
764+ /// after the `rank` namesakes that `namesakes_before` counted there.
765+ fn moved_node(&self, key: &NodeKey, kind: Kind, dest: &NodeKey, rank: Option<usize>) -> Option<NRef> {
595766 let title = match key {
596767 NodeKey::Path { steps, .. } => steps.last().map(|(t, _)| t.clone()),
597768 NodeKey::Id(id) => return self.vault.tree.block_by_id(id),
598769 NodeKey::Root => None,
599770 }?;
600− let dest = self.vault.find_by_key(&self.vault.key_of(dest)).unwrap_or(dest);
601− self.vault
602− .tree
603− .resolved_children(dest)
604− .into_iter()
605− .rev()
606− .find(|&c| self.vault.tree.node(c).title == title)
771+ let kids = self.vault.tree.resolved_children(self.find_exact(dest)?);
772+ let mut hits = kids.iter().filter(|&&c| {
773+ let n = self.vault.tree.node(c);
774+ n.title == title && n.kind == kind
775+ });
776+ match rank {
777+ Some(i) => hits.nth(i).copied(),
778+ None => hits.next_back().copied(),
779+ }
780+ }
781+
782+ /// Before a verb puts `r` among `dest`'s children, just before the
783+ /// `at`-th: how many of them, other than `r`, share its title and kind
784+ /// and come before that place, for `moved_node`. Clamped by the
785+ /// ordering rule (§3.1), an item goes no later than the first section
786+ /// and a section no earlier than after the last item, so it still comes
787+ /// right after these namesakes.
788+ fn namesakes_before(&self, r: NRef, dest: NRef, at: usize) -> usize {
789+ let n = self.vault.tree.node(r);
790+ let kids = self.vault.tree.resolved_children(dest);
791+ kids.iter()
792+ .take(at)
793+ .filter(|&&c| {
794+ let m = self.vault.tree.node(c);
795+ c != r && m.title == n.title && m.kind == n.kind
796+ })
797+ .count()
607798 }
608799
609800 /// The node an action applies to: a menu's target, else the cursor's.
@@ -615,14 +806,9 @@ impl App {
615806 let Some(r) = self.subject() else { return };
616807 self.push_undo("new node");
617808 let res = if child {
618− // the new child must be visible to put the cursor on it
619− if self.is_folded(r) {
620− self.toggle_fold(r);
621− }
622809 match ops::append_child_public(&mut self.vault, r, "") {
623810 Ok(nr) => {
624− self.move_cursor_to(nr);
625− self.act_edit();
811+ self.edit_new_node(nr);
626812 return;
627813 }
628814 Err(e) => Err(e),
@@ -634,25 +820,28 @@ impl App {
634820 _ => "- ".to_string(),
635821 };
636822 let key = self.vault.key_of(r);
637− let depth = self.rows().get(self.cursor).map(|row| row.depth).unwrap_or(0);
638823 match ops::paste(&mut self.vault, r, &format!("{}\n", line), true) {
639824 Ok(_) => {
640− // move to the new sibling: the first row after the
641− // cursor node's subtree, at its depth, with an empty title
642− if let Some(nr) = self.vault.find_by_key(&key) {
643− self.move_cursor_to(nr);
644− }
645− let rows = self.rows();
646− if let Some(i) = rows.iter().enumerate().skip(self.cursor + 1).position(
647− |(_, row)| {
648− row.depth <= depth
649− && self.vault.tree.node(row.nref).title.is_empty()
650− },
651− ) {
652− self.cursor += 1 + i;
825+ // the new sibling: the node after the cursor node among
826+ // its siblings in the outline, with an empty title
827+ let r = self.vault.find_by_key(&key).unwrap_or(r);
828+ let parent = self.outline_parent(r).unwrap_or(self.vault.tree.root);
829+ let kids = self.vault.tree.resolved_children(parent);
830+ let new = kids
831+ .iter()
832+ .position(|&c| c == r)
833+ .and_then(|i| kids.get(i + 1).copied())
834+ .filter(|&c| self.vault.tree.node(c).title.is_empty());
835+ if let Some(nr) = new {
836+ // a sibling of the zoomed node is outside the zoom:
837+ // widen it to their parent
838+ if self.zoom() == Some(r) {
839+ self.set_zoom(self.outline_parent(r));
840+ }
841+ self.edit_new_node(nr);
842+ return;
653843 }
654− self.act_edit();
655− return;
844+ Ok(())
656845 }
657846 Err(e) => Err(e),
658847 }
@@ -663,6 +852,35 @@ impl App {
663852 }
664853 }
665854
855+ /// Open the editor on a node `n` / `N` just made, with the outline
856+ /// cursor on it (§10.6): the new node, not whatever `subject()` is.
857+ fn edit_new_node(&mut self, nr: NRef) {
858+ let focus = self.focus;
859+ self.reveal(nr);
860+ self.focus = focus;
861+ self.open_editor_on(nr);
862+ // an open editor whose save is refused stays, with its text: the
863+ // title is typed only into a fresh editor on the new node
864+ if !self.editor_dirty() && self.editor_node() == Some(nr) {
865+ self.type_new_title();
866+ }
867+ }
868+
869+ /// After `n` / `N` the editor types the new node's title (§10.6): the
870+ /// cursor goes after the marker and its space, in insert mode as after
871+ /// Vim's or Helix's `o`. The space is the buffer's alone until something
872+ /// is typed, so a node left untitled is written as it was.
873+ fn type_new_title(&mut self) {
874+ let Some(ed) = self.editor.as_mut() else { return };
875+ if let Some(l) = ed.buf.lines.first_mut() {
876+ if !l.text.ends_with(' ') {
877+ l.text.push(' ');
878+ }
879+ }
880+ ed.set_cursor(editor::Pos::new(0, ed.len(0)));
881+ ed.mode = editor::Mode::Insert;
882+ }
883+
666884 // -------------------------------------------------------- editor (§10.6)
667885
668886 pub fn act_edit(&mut self) {
@@ -677,25 +895,28 @@ impl App {
677895 show_reading: self.show_reading,
678896 wrap: self.wrap,
679897 hide_done: self.hide_done,
680− keys: Some(self.edit_keys),
898+ keys: self.kept_keys.unwrap_or(Some(self.edit_keys)),
681899 outline_width: self.ui.outline_width,
682− zoom: self.zoom_root.map(|z| self.vault.key_of(z)),
900+ zoom: self.zoom().map(|z| self.vault.key_of(z)),
683901 folded: self.folded.clone(),
684902 }
685903 }
686904
687905 /// Restore remembered view settings; `keep_keys` when `--keys` or
688− /// `$FOLD_KEYS` chose the keymap for this run.
906+ /// `$FOLD_KEYS` chose the keymap for this run: the remembered keymap is
907+ /// then neither applied nor replaced (§10.6).
689908 pub fn apply_view(&mut self, v: View, keep_keys: bool) {
690909 self.show_reading = v.show_reading;
691910 self.wrap = v.wrap;
692911 self.hide_done = v.hide_done;
693− if let (false, Some(k)) = (keep_keys, v.keys) {
912+ if keep_keys {
913+ self.kept_keys = Some(v.keys);
914+ } else if let Some(k) = v.keys {
694915 self.edit_keys = k;
695916 }
696917 self.ui.outline_width = v.outline_width;
697918 self.folded = v.folded;
698− self.zoom_root = v.zoom.and_then(|k| self.vault.find_by_key(&k)).filter(|&r| self.vault.tree.node(r).kind != Kind::Root);
919+ self.set_zoom(v.zoom.and_then(|k| self.vault.find_by_key(&k)).filter(|&r| self.vault.tree.node(r).kind != Kind::Root));
699920 self.cursor = 0;
700921 self.clamp_cursor();
701922 }
@@ -718,6 +939,14 @@ impl App {
718939
719940 /// Open the built-in editor over a node's subtree (§10.6).
720941 fn open_editor_on(&mut self, target: NRef) {
942+ // an editor already open saves first, as on leaving it; one whose
943+ // save is refused stays, with its text and its clipboard
944+ if !self.save_editor_releasing("switch") {
945+ return;
946+ }
947+ if let Some(ed) = self.editor.take() {
948+ self.edit_clip = ed.clip;
949+ }
721950 let buf = fold_core::edit::open_editor(&self.vault, target);
722951 self.editor = Some(editor::Editor::new(buf, self.edit_keys, self.edit_clip.clone()));
723952 if self.mode != Mode::Edit {
@@ -737,30 +966,195 @@ impl App {
737966 self.say(format!("editor keys: {}", keys.name()));
738967 }
739968
740− fn save_editor(&mut self, why: &str) {
741− let Some(mut ed) = self.editor.take() else { return };
969+ /// Write the editor's dirty blocks; true when nothing is left unsaved.
970+ /// A refused save (the file changed on disk) keeps the text in the
971+ /// editor and says why.
972+ fn save_editor(&mut self, why: &str) -> bool {
973+ self.write_editor(why, false)
974+ }
975+
976+ /// Save the editor to leave it, or before it is re-rendered (§10.6): a
977+ /// block cut and not pasted back cannot be pasted as itself any more,
978+ /// so once the save went through it is let go, and deleted (§5.2). It
979+ /// is saved first still in transit, so a refused save leaves the editor
980+ /// as it was, its clipboard too; and the save and the deletion are one
981+ /// op-log entry (§10.10), so one undo puts back the block's file and
982+ /// its embed together.
983+ fn save_editor_releasing(&mut self, why: &str) -> bool {
984+ self.write_editor(why, true)
985+ }
986+
987+ /// `save_editor`; with `release`, `save_editor_releasing`.
988+ fn write_editor(&mut self, why: &str, release: bool) -> bool {
989+ if release && !self.editor_dirty() {
990+ // nothing to save first, so nothing a refusal keeps in transit
991+ if let Some(ed) = self.editor.as_mut() {
992+ ed.release_clip();
993+ }
994+ }
995+ if !self.editor_dirty() {
996+ return true;
997+ }
742998 self.settle_undo();
999+ let cursor = self.current().map(|r| self.vault.key_of(r));
1000+ let on_editor = self.anchor_zoom_for_save();
1001+ let Some(mut ed) = self.editor.take() else { return true };
1002+ // what another program changed beside the edited blocks is taken in
1003+ // before the snapshot, so undoing this save leaves it be
1004+ ed.buf.rebase_dirty(&mut self.vault);
7431005 let snap = ops::Snapshot::take(&self.vault, "edit");
744− match ed.buf.save_all(&mut self.vault) {
1006+ let mut res = ed.buf.save_all(&mut self.vault);
1007+ if let Some(n) = res.as_ref().ok().copied().filter(|_| release) {
1008+ ed.release_clip();
1009+ // the block that held the embed is written again: counted once
1010+ res = ed.buf.save_all(&mut self.vault).map(|m| m.max(n));
1011+ }
1012+ self.editor = Some(ed);
1013+ self.settle_zoom_after_save(on_editor);
1014+ // blocks written before a refusal are an op too
1015+ self.record_undo(snap);
1016+ // the save re-parsed what it wrote: the outline cursor stays on its
1017+ // node, so a verb that saves the editor first acts on the row clicked
1018+ if let Some(r) = cursor.and_then(|k| self.find_exact(&k)) {
1019+ self.move_cursor_to(r);
1020+ }
1021+ let saved = match res {
7451022 Ok(n) => {
746− self.record_undo(snap);
7471023 if n > 0 {
7481024 self.say(format!("saved {} block(s) ({})", n, why));
7491025 }
1026+ true
7501027 }
751− Err(e) => self.say(format!("error: {}", e)),
752− }
753− self.editor = Some(ed);
1028+ Err(e) => {
1029+ self.say(format!("error: {}", e));
1030+ false
1031+ }
1032+ };
7541033 self.edit_last_key = Instant::now();
1034+ saved
7551035 }
7561036
757− fn close_editor(&mut self) {
758− self.save_editor("exit");
1037+ /// Hold the zoom by key across an editor save (§10.3): the save
1038+ /// re-parses the files it writes, renumbering their nodes, and may
1039+ /// trash a block's file, renumbering the files after it. True when the
1040+ /// zoom is the node the editor is open on.
1041+ fn anchor_zoom_for_save(&mut self) -> bool {
1042+ self.anchor_zoom();
1043+ self.zoom_root.is_some() && self.editor_node() == self.zoom_root
1044+ }
1045+
1046+ /// After it, the zoom holds: the editor's own node found as the editor
1047+ /// finds it, since its title, and so its key, may be what was edited;
1048+ /// any other node by its key, or its deepest surviving step.
1049+ fn settle_zoom_after_save(&mut self, on_editor: bool) {
1050+ if let Some(k) = self.editor_key().filter(|_| on_editor) {
1051+ self.zoom_anchor = Some(k);
1052+ }
1053+ self.settle_zoom();
1054+ }
1055+
1056+ /// Leave the editor, saving it (§10.6); true when it closed. When the
1057+ /// save is refused the editor stays open with its text: nothing typed
1058+ /// is dropped except by *Revert*.
1059+ fn close_editor(&mut self) -> bool {
1060+ // a block cut and not pasted back is deleted once saved (§5.2)
1061+ if !self.save_editor_releasing("exit") {
1062+ self.say(format!("{} — still editing; Revert (:q!) drops the changes", self.status));
1063+ return false;
1064+ }
7591065 if let Some(ed) = self.editor.take() {
7601066 self.edit_clip = ed.clip;
7611067 }
7621068 self.mode = Mode::Normal;
7631069 self.focus = self.edit_return;
1070+ true
1071+ }
1072+
1073+ /// The node the editor is open on, as a key that survives the files
1074+ /// changing under it (§3.4): its render root, found in the tree as the
1075+ /// editor last read or wrote it.
1076+ fn editor_key(&self) -> Option<NodeKey> {
1077+ self.editor_node().map(|r| self.vault.key_of(r))
1078+ }
1079+
1080+ /// The editor's render root in the tree as the editor last read or
1081+ /// wrote it.
1082+ fn editor_node(&self) -> Option<NRef> {
1083+ let ed = self.editor.as_ref()?;
1084+ let info = ed.buf.owners.values().find(|i| i.parent.is_none())?;
1085+ let r = match &info.id {
1086+ Some(id) => self.vault.tree.block_by_id(id)?,
1087+ None => {
1088+ let file = self.vault.file_index(&info.path)?;
1089+ let f = &self.vault.tree.files[file];
1090+ if info.is_root {
1091+ (file, f.root_node)
1092+ } else {
1093+ let i = f.nodes.iter().position(|n| n.kind != Kind::Root && n.span.start == info.start)?;
1094+ (file, i)
1095+ }
1096+ }
1097+ };
1098+ Some(r)
1099+ }
1100+
1101+ /// Before something writes files under an open editor, an outline verb
1102+ /// or a reload (§10.6, §11.2): the editor saves first. Returns what it
1103+ /// is open on and the files as they are, for `editor_after_write`.
1104+ fn editor_before_write(&mut self, why: &str) -> Option<(NodeKey, ops::Snapshot)> {
1105+ // the editor is re-rendered after, so a block cut and not pasted
1106+ // back cannot be pasted as itself any more: once the editor is
1107+ // saved, it is deleted (§5.2). A refused save keeps it in transit,
1108+ // as the editor keeps its text (it is not re-rendered)
1109+ self.editor.as_ref()?;
1110+ self.save_editor_releasing(why);
1111+ let key = self.editor_key()?;
1112+ Some((key, ops::Snapshot::take(&self.vault, why)))
1113+ }
1114+
1115+ /// After it: if any file changed, the editor is re-rendered over the
1116+ /// files as they now are, so its next save is not refused: on its node
1117+ /// where the verb moved it (`follow`), else the very node its key names.
1118+ fn editor_after_write(&mut self, before: Option<(NodeKey, ops::Snapshot)>) {
1119+ let moved = self.edit_moved.take();
1120+ let Some((key, snap)) = before else { return };
1121+ if ops::Inverse::since(snap, &self.vault).is_some() {
1122+ self.rebuild_editor(moved.or_else(|| self.find_exact(&key)));
1123+ }
1124+ }
1125+
1126+ /// Re-render a clean editor over the files as they are now (§11.2), on
1127+ /// its node found again, with the cursor where it was: over the same
1128+ /// node's text, never an ancestor's its old path falls back to. Text a
1129+ /// refused save still holds is never replaced.
1130+ fn rebuild_editor(&mut self, target: Option<NRef>) {
1131+ if self.editor.is_none() || self.editor_dirty() {
1132+ return;
1133+ }
1134+ let Some(target) = target else {
1135+ // nothing unsaved, and nothing left to edit
1136+ self.close_editor();
1137+ self.say("the node being edited is gone");
1138+ return;
1139+ };
1140+ let buf = fold_core::edit::open_editor(&self.vault, target);
1141+ let Some(ed) = self.editor.as_mut() else { return };
1142+ let same = buf.lines.len() == ed.buf.lines.len()
1143+ && buf.lines.iter().zip(&ed.buf.lines).all(|(a, b)| a.text == b.text && a.owner == b.owner);
1144+ if same {
1145+ // the same text: the cursor, mode and undo history stay
1146+ ed.buf = buf;
1147+ } else {
1148+ // new text: the old undo steps would write the old text back
1149+ let mut fresh = editor::Editor::new(buf, ed.keys, ed.clip.clone());
1150+ if ed.mode == editor::Mode::Insert {
1151+ fresh.mode = editor::Mode::Insert;
1152+ }
1153+ fresh.cursor = ed.cursor;
1154+ fresh.search = ed.search.take();
1155+ fresh.clamp_cursor();
1156+ *ed = fresh;
1157+ }
7641158 }
7651159
7661160 /// Whether the editor holds changes not yet written.
@@ -791,12 +1185,16 @@ impl App {
7911185 // moving out of a dirty block saves it (§10.6)
7921186 if before != after && self.editor.as_ref().is_some_and(|e| e.buf.dirty.contains(&before)) {
7931187 self.settle_undo();
794− let snap = ops::Snapshot::take(&self.vault, "edit");
1188+ let on_editor = self.anchor_zoom_for_save();
7951189 let mut ed = self.editor.take().unwrap();
796− if ed.buf.splice(&mut self.vault, before).is_ok() {
1190+ ed.buf.rebase_dirty(&mut self.vault);
1191+ let snap = ops::Snapshot::take(&self.vault, "edit");
1192+ let res = ed.buf.splice(&mut self.vault, before);
1193+ self.editor = Some(ed);
1194+ self.settle_zoom_after_save(on_editor);
1195+ if res.is_ok() {
7971196 self.record_undo(snap);
7981197 }
799− self.editor = Some(ed);
8001198 }
8011199 }
8021200
@@ -806,7 +1204,7 @@ impl App {
8061204 if let Some(p) = self.prompt.as_mut() {
8071205 p.text.push_str(text.lines().next().unwrap_or(""));
8081206 self.refresh_picks();
809− } else if let Some(ed) = self.editor.as_mut() {
1207+ } else if let (Mode::Edit, Some(ed)) = (self.mode, self.editor.as_mut()) {
8101208 ed.paste_text(text);
8111209 self.edit_last_key = Instant::now();
8121210 } else if self.mode == Mode::Filter {
@@ -864,18 +1262,20 @@ impl App {
8641262 self.say("read-only line (preserved verbatim)");
8651263 return;
8661264 }
1265+ let edit = self.editor_before_write("outline verb");
8671266 self.push_undo("delete property");
8681267 match ops::set_frontmatter_key(&mut self.vault, t.0, &k, None) {
8691268 Ok(()) => self.say(format!("{} removed", k)),
8701269 Err(e) => self.say(format!("error: {}", e)),
8711270 }
8721271 self.reopen_props();
1272+ self.editor_after_write(edit);
8731273 }
8741274
8751275 pub fn key_props(&mut self, key: KeyEvent) {
8761276 match key.code {
8771277 KeyCode::Esc => {
878− self.mode = Mode::Normal;
1278+ self.mode = self.base_mode();
8791279 }
8801280 KeyCode::Char('j') | KeyCode::Down => {
8811281 if self.props_sel + 1 < self.props_rows.len() {
@@ -917,7 +1317,7 @@ impl App {
9171317 }
9181318 self.prompt = Some(p);
9191319 }
920− KeyCode::Enter => self.accept_prompt(p),
1320+ KeyCode::Enter => self.accept_prompt_saving_editor(p),
9211321 KeyCode::Backspace => {
9221322 p.text.pop();
9231323 self.prompt = Some(p);
@@ -940,7 +1340,12 @@ impl App {
9401340 let picked = p.picks.get(p.sel).copied();
9411341 match p.action {
9421342 PromptAction::Refile => match picked.map(Ok).unwrap_or_else(|| self.vault.resolve_target(&p.text)) {
943− Ok(dest) => self.refile_to(dest),
1343+ Ok(dest) => {
1344+ match p.moving.as_ref().and_then(|k| self.find_exact(k)) {
1345+ Some(r) => self.refile_to(r, dest),
1346+ None => self.say("that node is gone"),
1347+ }
1348+ }
9441349 Err(e) => self.say(e),
9451350 },
9461351 PromptAction::GoTo => match picked.map(Ok).unwrap_or_else(|| self.vault.resolve_target(&p.text)) {
@@ -967,8 +1372,12 @@ impl App {
9671372 return;
9681373 }
9691374 self.push_undo("set property");
1375+ let on = self.on_node(t);
9701376 match ops::set_property(&mut self.vault, t, &k, &p.text) {
9711377 Ok(block) => {
1378+ // a first property makes a block (§6.1): a zoom
1379+ // and the editor follow it
1380+ self.follow(on, block);
9721381 self.say(format!("{} set", k));
9731382 self.props_target = Some(block);
9741383 self.reopen_props();
@@ -995,13 +1404,36 @@ impl App {
9951404 }
9961405 }
9971406
1407+ /// Accept a prompt as an outline verb: the editor saves first and is
1408+ /// re-rendered after (§10.6).
1409+ fn accept_prompt_saving_editor(&mut self, mut p: Prompt) {
1410+ // the save re-parses what it wrote: the pick is found again by key
1411+ let picked = p.picks.get(p.sel).map(|&r| self.vault.key_of(r));
1412+ let edit = self.editor_before_write("outline verb");
1413+ match picked.map(|k| self.find_exact(&k)) {
1414+ Some(None) => self.say("that node is gone"),
1415+ Some(Some(r)) => {
1416+ p.picks = vec![r];
1417+ p.sel = 0;
1418+ self.accept_prompt(p);
1419+ }
1420+ None => self.accept_prompt(p),
1421+ }
1422+ self.editor_after_write(edit);
1423+ }
1424+
9981425 fn open_prompt(&mut self, label: &str, action: PromptAction, text: String) {
1426+ let moving = match action {
1427+ PromptAction::Refile => self.subject().map(|r| self.vault.key_of(r)),
1428+ _ => None,
1429+ };
9991430 self.prompt = Some(Prompt {
10001431 label: label.into(),
10011432 text,
10021433 action,
10031434 picks: Vec::new(),
10041435 sel: 0,
1436+ moving,
10051437 });
10061438 self.refresh_picks();
10071439 }
@@ -1015,10 +1447,7 @@ impl App {
10151447 }
10161448 let q = p.text.to_lowercase();
10171449 // a node cannot move into its own subtree
1018− let moving = match p.action {
1019− PromptAction::Refile => self.subject().map(|r| self.vault.tree.resolved_child(r)),
1020− _ => None,
1021− };
1450+ let moving = p.moving.as_ref().and_then(|k| self.find_exact(k)).map(|r| self.vault.tree.resolved_child(r));
10221451 let mut nodes: Vec<NRef> = Vec::new();
10231452 self.vault.tree.walk(self.vault.tree.root, &mut |t, r| {
10241453 if t.node(r).kind != Kind::Root && !t.node(r).is_embed() {
@@ -1052,14 +1481,18 @@ impl App {
10521481 }
10531482 }
10541483
1055− /// Put the cursor on a node, unfolding and zooming out as needed.
1484+ /// Put the cursor on a node, unfolding and zooming out as needed. The
1485+ /// ancestors unfolded are its outline ancestors, through the embeds
1486+ /// that stitch in the blocks it sits in, not only those in its file.
10561487 fn reveal(&mut self, r: NRef) {
1057− for a in self.vault.tree.ancestors(r) {
1488+ let mut chain = self.chain(r);
1489+ chain.pop();
1490+ for a in chain {
10581491 let k = self.vault.key_of(a);
10591492 self.folded.retain(|f| f != &k);
10601493 }
1061− if self.zoom_root.is_some() && !self.rows().iter().any(|row| row.nref == r) {
1062− self.zoom_root = None;
1494+ if self.zoom().is_some() && !self.rows().iter().any(|row| row.nref == r) {
1495+ self.set_zoom(None);
10631496 }
10641497 self.move_cursor_to(r);
10651498 self.focus = Focus::Outline;
@@ -1072,6 +1505,10 @@ impl App {
10721505 self.say("no conflicts");
10731506 return;
10741507 }
1508+ // the view replaces the editor, which saves and closes first
1509+ if self.editor.is_some() && !self.close_editor() {
1510+ return;
1511+ }
10751512 self.mode = Mode::Conflict;
10761513 self.conflict_idx = 0;
10771514 }
@@ -1103,11 +1540,6 @@ impl App {
11031540 Ok(()) => self.say("kept ours"),
11041541 Err(e) => self.say(format!("error: {}", e)),
11051542 }
1106− self.conflict_idx = self.conflict_idx.saturating_sub(0).min(
1107− fold_core::merge::conflict_pairs(&self.vault)
1108− .len()
1109− .saturating_sub(1),
1110− );
11111543 }
11121544 }
11131545 KeyCode::Char('t') => {
@@ -1135,6 +1567,9 @@ impl App {
11351567 }
11361568 _ => {}
11371569 }
1570+ // a resolved pair leaves the list: stay on the pair now shown
1571+ let n = fold_core::merge::conflict_pairs(&self.vault).len();
1572+ self.conflict_idx = self.conflict_idx.min(n.saturating_sub(1));
11381573 }
11391574
11401575 pub fn handle_key(&mut self, key: KeyEvent) {
@@ -1209,7 +1644,7 @@ impl App {
12091644 key.code,
12101645 KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') | KeyCode::Char('?')
12111646 ) {
1212− self.mode = Mode::Normal;
1647+ self.mode = self.base_mode();
12131648 }
12141649 }
12151650 Mode::Conflict => self.key_conflict(key),
@@ -1218,14 +1653,27 @@ impl App {
12181653
12191654 /// Drop the editor's unsaved changes (§10.6: *Revert*, `:q!`).
12201655 fn discard_editor(&mut self) {
1221− if let Some(ed) = self.editor.take() {
1222− self.edit_clip = ed.clip;
1223− }
1656+ let ed = self.editor.take();
12241657 self.mode = Mode::Normal;
12251658 self.focus = self.edit_return;
1659+ // the reload may renumber nodes: files can have changed on disk
1660+ self.anchor_zoom();
12261661 let _ = self.vault.reload();
1662+ let mut said = String::from("changes discarded");
1663+ if let Some(ed) = ed {
1664+ // a block a save took out of its parent (cut and not pasted
1665+ // back, or deleted) cannot be pasted back as itself any more:
1666+ // it is deleted now, as on leaving any other way (§5.2)
1667+ let snap = ops::Snapshot::take(&self.vault, "revert");
1668+ if let Err(e) = ed.buf.discard(&mut self.vault) {
1669+ said = format!("{}; error: {}", said, e);
1670+ }
1671+ self.record_undo(snap);
1672+ self.edit_clip = ed.clip;
1673+ }
1674+ self.settle_zoom();
12271675 self.clamp_cursor();
1228− self.say("changes discarded");
1676+ self.say(said);
12291677 }
12301678
12311679 pub fn key_normal(&mut self, key: KeyEvent) {
@@ -1357,6 +1805,7 @@ impl App {
13571805 return;
13581806 };
13591807 self.mark_self_write();
1808+ self.anchor_zoom();
13601809 match inv.undo(&mut self.vault) {
13611810 Ok(()) => {
13621811 self.say(format!("undone: {}", inv.description));
@@ -1378,6 +1827,7 @@ impl App {
13781827 return;
13791828 };
13801829 self.mark_self_write();
1830+ self.anchor_zoom();
13811831 match inv.redo(&mut self.vault) {
13821832 Ok(()) => {
13831833 self.say(format!("redone: {}", inv.description));
@@ -1397,12 +1847,14 @@ impl App {
13971847 self.settle_undo();
13981848 self.pending_undo = Some(ops::Snapshot::take(&self.vault, desc));
13991849 self.mark_self_write();
1850+ self.anchor_zoom();
14001851 }
14011852
14021853 /// Turn the pending snapshot into an op-log entry holding exactly the
14031854 /// files the verb changed (§10.10); a verb that changed nothing leaves
14041855 /// no entry and keeps the redo stack.
14051856 fn settle_undo(&mut self) {
1857+ self.settle_zoom();
14061858 if let Some(snap) = self.pending_undo.take() {
14071859 self.record_undo(snap);
14081860 }
@@ -1472,21 +1924,23 @@ impl App {
14721924 use fold_core::reading::LineRef;
14731925 match fold_core::reading::node_at(&doc, self.read_cursor) {
14741926 Some(LineRef::Title(r)) => {
1927+ // a heading zooms, a task heading too (`x` toggles
1928+ // it); a task item toggles (§10.4)
14751929 let n = self.vault.tree.node(r);
1476− if n.task.is_some() {
1930+ if n.kind == Kind::Section {
1931+ self.set_zoom(Some(r));
1932+ self.read_cursor = 0;
1933+ self.scroll_reading = 0;
1934+ } else if n.task.is_some() {
14771935 self.act_on_node(r, |app, r| {
14781936 let _ = ops::toggle_task(&mut app.vault, r);
14791937 });
1480− } else if n.kind == Kind::Section {
1481− self.zoom_root = Some(r);
1482− self.read_cursor = 0;
1483− self.scroll_reading = 0;
14841938 }
14851939 }
14861940 Some(LineRef::Embed(e)) => {
14871941 let t = self.vault.tree.resolved_child(e);
14881942 if t != e {
1489− self.zoom_root = Some(t);
1943+ self.set_zoom(Some(t));
14901944 self.read_cursor = 0;
14911945 self.scroll_reading = 0;
14921946 }
@@ -1495,17 +1949,8 @@ impl App {
14951949 }
14961950 }
14971951 KeyCode::Backspace => {
1498− if let Some(z) = self.zoom_root {
1499− if let Some(p) = self.vault.tree.node(z).parent {
1500− let pr = (z.0, p);
1501− self.zoom_root = if self.vault.tree.node(pr).kind != Kind::Root {
1502− Some(pr)
1503− } else {
1504− None
1505− };
1506− } else {
1507− self.zoom_root = None;
1508− }
1952+ if let Some(z) = self.zoom() {
1953+ self.set_zoom(self.outline_parent(z));
15091954 self.read_cursor = 0;
15101955 self.scroll_reading = 0;
15111956 } else {
@@ -1537,8 +1982,11 @@ impl App {
15371982 KeyCode::Char('q') => self.quit = true,
15381983 // what isn't the pane's own works as it does in the outline
15391984 KeyCode::Char('m') => {
1985+ // the menu keeps its own target; a later outline verb must
1986+ // not inherit this one
15401987 self.action_target = self.read_node();
15411988 self.run_action(Action::NodeMenu);
1989+ self.action_target = None;
15421990 }
15431991 KeyCode::Char(':' | '?' | 'c' | 'C' | 'u' | 'U') => {
15441992 let rows = self.rows();
@@ -1551,7 +1999,7 @@ impl App {
15511999 /// Reset the reading cursor when the pane starts showing another node
15522000 /// (outline cursor moved, zoom changed), and keep it inside the doc.
15532001 fn sync_read_target(&mut self) {
1554− let key = self.zoom_root.or_else(|| self.current()).map(|r| self.vault.key_of(r));
2002+ let key = self.zoom().or_else(|| self.current()).map(|r| self.vault.key_of(r));
15552003 if key != self.read_key {
15562004 self.read_key = key;
15572005 self.read_cursor = 0;
@@ -1564,7 +2012,7 @@ impl App {
15642012
15652013 pub fn reading_doc_pub(&self) -> fold_core::reading::ReadingDoc { self.reading_doc() }
15662014 fn reading_doc(&self) -> fold_core::reading::ReadingDoc {
1567− let target = self.zoom_root.or_else(|| self.current());
2015+ let target = self.zoom().or_else(|| self.current());
15682016 match target {
15692017 Some(r) => fold_core::reading::build(&self.vault, r),
15702018 None => fold_core::reading::ReadingDoc {
@@ -1732,7 +2180,7 @@ impl App {
17322180 let hits = self.palette_hits();
17332181 match key.code {
17342182 KeyCode::Esc => {
1735− self.mode = Mode::Normal;
2183+ self.mode = self.base_mode();
17362184 self.palette.clear();
17372185 }
17382186 KeyCode::Up => self.palette_sel = self.palette_sel.saturating_sub(1),
@@ -1743,7 +2191,7 @@ impl App {
17432191 }
17442192 KeyCode::Enter => {
17452193 let chosen = hits.get(self.palette_sel).copied();
1746− self.mode = Mode::Normal;
2194+ self.mode = self.base_mode();
17472195 self.palette.clear();
17482196 self.palette_sel = 0;
17492197 if let Some(a) = chosen {
@@ -1765,13 +2213,19 @@ impl App {
17652213 /// Open the node menu (§10.3) for `r`, anchored at a screen position.
17662214 fn open_menu(&mut self, r: NRef, x: u16, y: u16) {
17672215 self.ui.menu = Some(ui::Menu {
1768− target: r,
2216+ target: self.vault.key_of(r),
17692217 x,
17702218 y,
17712219 sel: 0,
17722220 });
17732221 }
17742222
2223+ /// The node the open menu is on, found again by its key: `None` once
2224+ /// the files changed under the menu and that node is gone (§11.2).
2225+ fn menu_target(&self) -> Option<NRef> {
2226+ self.find_exact(&self.ui.menu.as_ref()?.target)
2227+ }
2228+
17752229 fn key_menu(&mut self, key: KeyEvent) {
17762230 let Some(menu) = self.ui.menu.as_mut() else { return };
17772231 let items: Vec<usize> = (0..action::NODE_MENU.len())
@@ -1790,11 +2244,16 @@ impl App {
17902244 }
17912245 }
17922246
1793− /// Run a node-menu item on the menu's target.
2247+ /// Run a node-menu item on the menu's target, found by its key; the
2248+ /// verb holds it by key across the editor's save (`run_action`).
17942249 fn run_menu_item(&mut self, i: usize) {
17952250 let Some(menu) = self.ui.menu.take() else { return };
17962251 let Some(Some(a)) = action::NODE_MENU.get(i) else { return };
1797− self.action_target = Some(menu.target);
2252+ let Some(target) = self.find_exact(&menu.target) else {
2253+ self.say("that node is gone");
2254+ return;
2255+ };
2256+ self.action_target = Some(target);
17982257 self.run_action(*a);
17992258 self.action_target = None;
18002259 }
@@ -1802,6 +2261,30 @@ impl App {
18022261 /// Run any action by name (§10.8): what buttons, menus and the palette
18032262 /// do. Node actions apply to `subject()`.
18042263 pub fn run_action(&mut self, a: Action) {
2264+ // §10.6: an outline verb saves the editor first, and the editor is
2265+ // then re-rendered over what the verb wrote. Revert must not save,
2266+ // and an action that opens the editor builds it afresh. The save
2267+ // re-parses what it writes, and deletes a block cut and not pasted
2268+ // back, renumbering the files after it (§5.2): a menu's or button's
2269+ // node is held by key across it
2270+ let target = self.action_target.map(|t| self.vault.key_of(t));
2271+ let edit = match a {
2272+ Action::EditRevert => None,
2273+ _ => self.editor_before_write("outline verb"),
2274+ };
2275+ self.action_target = target.as_ref().and_then(|k| self.find_exact(k));
2276+ let gone = target.is_some() && self.action_target.is_none();
2277+ if gone {
2278+ self.say("that node is gone");
2279+ } else {
2280+ self.run_action_inner(a);
2281+ }
2282+ if gone || !matches!(a, Action::Edit | Action::NewSibling | Action::NewChild | Action::ConflictEdit) {
2283+ self.editor_after_write(edit);
2284+ }
2285+ }
2286+
2287+ fn run_action_inner(&mut self, a: Action) {
18052288 // a node action from a menu first selects its node
18062289 if let Some(t) = self.action_target {
18072290 if self.rows().iter().any(|row| row.nref == t) {
@@ -1881,22 +2364,30 @@ impl App {
18812364 }
18822365 Action::GoTo => self.open_prompt("go to", PromptAction::GoTo, String::new()),
18832366 Action::ClearDone => self.act_clear_done(),
1884− Action::Canonicalize => match fold_core::check::fix(&mut self.vault) {
1885− Ok(n) => self.say(format!("{} file(s) canonicalized", n)),
1886− Err(e) => self.say(format!("error: {}", e)),
1887− },
1888− Action::Merge => match fold_core::merge::merge_sync_conflicts(&mut self.vault, false) {
1889− Ok(o) => {
1890− self.say(format!("{} merge(s)", o.len()));
1891− if !fold_core::merge::conflict_pairs(&self.vault).is_empty() {
1892− self.enter_conflict_view();
2367+ Action::Canonicalize => {
2368+ self.anchor_zoom();
2369+ match fold_core::check::fix(&mut self.vault) {
2370+ Ok(n) => self.say(format!("{} file(s) canonicalized", n)),
2371+ Err(e) => self.say(format!("error: {}", e)),
2372+ }
2373+ }
2374+ Action::Merge => {
2375+ self.anchor_zoom();
2376+ match fold_core::merge::merge_sync_conflicts(&mut self.vault, false) {
2377+ Ok(o) => {
2378+ self.say(format!("{} merge(s)", o.len()));
2379+ if !fold_core::merge::conflict_pairs(&self.vault).is_empty() {
2380+ self.enter_conflict_view();
2381+ }
18932382 }
2383+ Err(e) => self.say(format!("error: {}", e)),
18942384 }
1895− Err(e) => self.say(format!("error: {}", e)),
1896− },
2385+ }
18972386 Action::ResolveConflicts => self.enter_conflict_view(),
18982387 Action::EditorKeys => self.set_edit_keys(self.edit_keys.next()),
1899− Action::EditDone => self.close_editor(),
2388+ Action::EditDone => {
2389+ self.close_editor();
2390+ }
19002391 Action::EditRevert => self.discard_editor(),
19012392 Action::Close => self.close_top(),
19022393 Action::PromptOk => {
@@ -1914,6 +2405,16 @@ impl App {
19142405 }
19152406 }
19162407
2408+ /// The mode under a popup (§10.2): the editor if one is open, since a
2409+ /// popup opened over it closes back to it, else normal.
2410+ fn base_mode(&self) -> Mode {
2411+ if self.editor.is_some() {
2412+ Mode::Edit
2413+ } else {
2414+ Mode::Normal
2415+ }
2416+ }
2417+
19172418 /// Close whatever is on top: a menu, a prompt, then a popup mode.
19182419 fn close_top(&mut self) {
19192420 if self.ui.menu.take().is_some() {
@@ -1923,17 +2424,19 @@ impl App {
19232424 return;
19242425 }
19252426 match self.mode {
1926− Mode::Edit => self.close_editor(),
2427+ Mode::Edit => {
2428+ self.close_editor();
2429+ }
19272430 Mode::Filter => {
1928− self.mode = Mode::Normal;
2431+ self.mode = self.base_mode();
19292432 self.filter.clear();
19302433 self.filter_rows.clear();
19312434 }
19322435 Mode::Picker => {
1933− self.mode = Mode::Normal;
2436+ self.mode = self.base_mode();
19342437 self.palette.clear();
19352438 }
1936− Mode::Props | Mode::Help | Mode::Conflict => self.mode = Mode::Normal,
2439+ Mode::Props | Mode::Help | Mode::Conflict => self.mode = self.base_mode(),
19372440 Mode::Normal => {}
19382441 }
19392442 }
@@ -1941,7 +2444,7 @@ impl App {
19412444 /// Zoom into a node: the reading pane shows it (§10.3 `Enter`).
19422445 fn zoom_into(&mut self, r: NRef) {
19432446 if self.vault.tree.node(r).kind != Kind::Root {
1944− self.zoom_root = Some(r);
2447+ self.set_zoom(Some(r));
19452448 self.cursor = 0;
19462449 self.read_cursor = 0;
19472450 self.scroll_reading = 0;
@@ -1951,12 +2454,12 @@ impl App {
19512454 }
19522455 }
19532456
2457+ /// Zoom out to the parent (§10.3 `Backspace`): the outline parent,
2458+ /// so a zoomed block goes to the node that embeds it.
19542459 fn zoom_out(&mut self) {
1955− if let Some(z) = self.zoom_root {
2460+ if let Some(z) = self.zoom() {
19562461 let key = self.vault.key_of(z);
1957− self.zoom_root = self.vault.tree.node(z).parent.map(|p| (z.0, p)).filter(|&p| {
1958− self.vault.tree.node(p).kind != Kind::Root
1959− });
2462+ self.set_zoom(self.outline_parent(z));
19602463 self.cursor = 0;
19612464 if let Some(r) = self.vault.find_by_key(&key) {
19622465 self.move_cursor_to(r);
@@ -1966,8 +2469,8 @@ impl App {
19662469
19672470 /// Zoom straight to a node, or to the root (breadcrumbs, §10.1).
19682471 fn zoom_to(&mut self, r: Option<NRef>) {
1969− let prev = self.zoom_root;
1970− self.zoom_root = r;
2472+ let prev = self.zoom();
2473+ self.set_zoom(r);
19712474 self.cursor = 0;
19722475 if let Some(p) = prev {
19732476 if !self.rows().iter().any(|row| row.nref == p) {
@@ -2095,21 +2598,17 @@ pub fn run(dir: &Path, keys: Option<&str>) -> anyhow::Result<()> {
20952598 app.edit_keys = parsed;
20962599 }
20972600 let keys_chosen = keys.is_some() || std::env::var_os("FOLD_KEYS").is_some();
2601+ if keys_chosen {
2602+ // no view yet: it remembers no keymap from this run either
2603+ app.kept_keys = Some(None);
2604+ }
20982605 if let Some(v) = view::load(dir) {
20992606 app.apply_view(v, keys_chosen);
21002607 }
21012608 app.start_watcher();
21022609 // a sync-conflict file present at startup starts the merge flow (§12.2)
2103− if let Ok(files) = app.vault.conflict_files() {
2104− if !files.is_empty() {
2105− match fold_core::merge::merge_sync_conflicts(&mut app.vault, false) {
2106− Ok(o) => {
2107− app.say(format!("merged on startup: {}", o.join("; ")));
2108− app.enter_conflict_view();
2109− }
2110− Err(e) => app.say(format!("merge error: {}", e)),
2111− }
2112− }
2610+ if app.vault.conflict_files().is_ok_and(|files| !files.is_empty()) {
2611+ app.merge_conflict_files("merged on startup");
21132612 }
21142613 enable_raw_mode()?;
21152614 std::io::stdout().execute(EnterAlternateScreen)?;
@@ -2163,11 +2662,12 @@ fn run_loop(
21632662 out.flush()?;
21642663 }
21652664 if app.quit {
2166− // save any open editor on quit (§10.6)
2167− if app.editor.is_some() {
2168− app.close_editor();
2665+ // save any open editor on quit (§10.6); one whose save is
2666+ // refused stays open with its text
2667+ if app.editor.is_none() || app.close_editor() {
2668+ return Ok(());
21692669 }
2170− return Ok(());
2670+ app.quit = false;
21712671 }
21722672 // autosave after 750 ms without a keystroke (§10.6)
21732673 if app.mode == Mode::Edit && app.editor_dirty() && app.edit_last_key.elapsed() > Duration::from_millis(750) {
crates/fold-tui/src/app/editor/helix.rs+154−54
@@ -1,7 +1,7 @@
11//! The Helix keymap: selection first. Motions select, actions act on the
22//! selection; `v` makes motions extend it. One selection (no multi-cursor).
33
4−use super::{order, Editor, Group, Mode, Outcome, Pos};
4+use super::{class, order, Editor, Group, Mode, Outcome, Pos};
55use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
66
77#[derive(Default)]
@@ -10,6 +10,18 @@ pub struct State {
1010 prefix: Option<char>,
1111}
1212
13+/// The largest count: more digits than that are this.
14+const MAX_COUNT: usize = 999_999_999;
15+
16+/// The most text a count may make `>` add.
17+const MAX_TEXT: usize = 4 << 20;
18+
19+/// The count typed before a key, 1 if none.
20+fn take_count(st: &mut State) -> usize {
21+ let s = std::mem::take(&mut st.count);
22+ if s.is_empty() { 1 } else { s.parse().map_or(MAX_COUNT, |n: usize| n.clamp(1, MAX_COUNT)) }
23+}
24+
1325/// The selection as `[start, end)`: the anchor to the cursor, both included;
1426/// with no anchor, the character under the cursor.
1527fn range(e: &Editor) -> (Pos, Pos) {
@@ -36,6 +48,68 @@ fn select_to(e: &mut Editor, from: Pos, to: Pos) {
3648 }
3749}
3850
51+/// Helix's word boundary: a change of class, a line end being a class of
52+/// its own.
53+fn boundary(a: char, b: char, big: bool) -> bool {
54+ class(a, big) != class(b, big) || (a == '\n') != (b == '\n')
55+}
56+
57+/// Select the `n`th word forward or back, as Helix's word motions do: each
58+/// walks from the cursor until `target` holds between the character behind
59+/// and the one ahead, and selects what it walked over. A target right at the
60+/// cursor (it ends a word, or the whitespace after one) and line ends right
61+/// ahead move the start past them, so a repeated `w` or `b` selects the next
62+/// word rather than keeping the last character of this one.
63+fn select_words(e: &mut Editor, n: usize, fwd: bool, target: &dyn Fn(char, char) -> bool) {
64+ let mut sel = None;
65+ for _ in 0..n {
66+ let cur = sel.map_or(e.cursor, |(_, c)| c);
67+ match word_step(e, cur, fwd, target) {
68+ Some(s) => sel = Some(s),
69+ None => break,
70+ }
71+ }
72+ if let Some((from, to)) = sel {
73+ select_to(e, from, to);
74+ }
75+}
76+
77+/// One word from `cur`: the selection's anchor and cursor, or `None` at the
78+/// end (start) of the text. A place between two characters is named by the
79+/// position of the one after it.
80+fn word_step(e: &Editor, cur: Pos, fwd: bool, target: &dyn Fn(char, char) -> bool) -> Option<(Pos, Pos)> {
81+ let ahead = |g: Pos| if fwd { e.char_at(g) } else { e.prev(g).and_then(|q| e.char_at(q)) };
82+ let step = |g: Pos| if fwd { e.next(g) } else { e.prev(g) };
83+ // the walk starts on the cursor's far side, the selection's other end
84+ // is on its near side
85+ let (mut h, mut anchor) = if fwd { (e.next(cur)?, cur) } else { (cur, e.next(cur).unwrap_or(cur)) };
86+ ahead(h)?;
87+ let mut behind = e.char_at(cur).unwrap_or('\n');
88+ while ahead(h) == Some('\n') {
89+ behind = '\n';
90+ h = step(h)?;
91+ }
92+ if behind == '\n' {
93+ anchor = h;
94+ }
95+ let start = h;
96+ while let Some(ch) = ahead(h) {
97+ if target(behind, ch) {
98+ if h != start {
99+ break;
100+ }
101+ anchor = h;
102+ }
103+ behind = ch;
104+ h = step(h)?;
105+ }
106+ Some(if fwd {
107+ (anchor, e.prev(h).filter(|q| *q >= anchor).unwrap_or(anchor))
108+ } else {
109+ (e.prev(anchor).filter(|q| *q >= h).unwrap_or(h), h)
110+ })
111+}
112+
39113/// Move the cursor, collapsing the selection (or extending it in select mode).
40114fn move_to(e: &mut Editor, to: Pos) {
41115 if e.mode == Mode::Select {
@@ -57,6 +131,11 @@ pub fn handle(e: &mut Editor, key: KeyEvent) -> Outcome {
57131
58132fn insert(e: &mut Editor, key: KeyEvent) -> Outcome {
59133 let ctl = key.modifiers.contains(KeyModifiers::CONTROL);
134+ // a key that moves the cursor ends a run of typing: what is typed
135+ // after it is its own undo step, as in the other keymaps
136+ if matches!(key.code, KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down | KeyCode::Home | KeyCode::End) {
137+ e.group = Group::None;
138+ }
60139 match key.code {
61140 KeyCode::Esc => {
62141 e.mode = Mode::Normal;
@@ -146,11 +225,11 @@ fn normal(e: &mut Editor, key: KeyEvent) -> Outcome {
146225 e.helix.count.push(c);
147226 return out;
148227 }
149− let n: usize = std::mem::take(&mut e.helix.count).parse().unwrap_or(1).max(1);
228+ let n = take_count(&mut e.helix);
150229 let p = e.cursor;
151230 match (c, alt) {
152231 ('h', _) => move_to(e, Pos::new(p.line, p.col.saturating_sub(n))),
153− ('l', _) => move_to(e, Pos::new(p.line, (p.col + n).min(e.len(p.line)))),
232+ ('l', _) => move_to(e, Pos::new(p.line, p.col.saturating_add(n).min(e.len(p.line)))),
154233 ('j' | 'k', _) => {
155234 if e.mode == Mode::Select {
156235 if e.anchor.is_none() {
@@ -163,24 +242,19 @@ fn normal(e: &mut Editor, key: KeyEvent) -> Outcome {
163242 e.move_visual(if c == 'j' { n as isize } else { -(n as isize) });
164243 }
165244 ('w' | 'W', false) => {
245+ // up to the start of the next word, or a line end
166246 let big = c == 'W';
167− let mut from = p;
168− let mut to = p;
169− for _ in 0..n {
170− from = to;
171− let next = e.word_fwd(to, big);
172− to = if next > to { e.prev(next).unwrap_or(next) } else { next };
173− if to == from {
174− to = e.word_fwd(e.next(to).unwrap_or(to), big);
175− }
176− }
177− select_to(e, from, to);
247+ select_words(e, n, true, &|a, b| boundary(a, b, big) && (b == '\n' || !b.is_whitespace()));
178248 }
179249 ('e' | 'E', false) => {
180250 let big = c == 'E';
181251 let mut from = p;
182252 let mut to = p;
183253 for _ in 0..n {
254+ // a count past the end of the text stops there
255+ if e.word_end(to, big) == to {
256+ break;
257+ }
184258 // from here if the word goes on, else from the next character
185259 let at_end = e.word_end(to, big) != to && e.next(to).map(|q| e.word_at(q, big).0 != e.word_at(to, big).0).unwrap_or(true);
186260 from = if at_end { e.next(to).unwrap_or(to) } else { to };
@@ -189,19 +263,18 @@ fn normal(e: &mut Editor, key: KeyEvent) -> Outcome {
189263 select_to(e, from.min(to), to);
190264 }
191265 ('b' | 'B', false) => {
266+ // back to the start of a word (`a` is the character after `b`)
192267 let big = c == 'B';
193− let mut from = p;
194− let mut to = p;
195− for _ in 0..n {
196− from = to;
197− to = e.word_back(to, big);
198− }
199− select_to(e, from, to);
268+ select_words(e, n, false, &|a, b| boundary(a, b, big) && (a == '\n' || !a.is_whitespace()));
200269 }
201270 ('x', false) => {
202271 // select the line; again, extend by a line
203272 let whole = e.anchor.is_some_and(|a| a.col == 0 && a <= e.cursor) && e.cursor.col >= e.len(e.cursor.line);
204− let (l1, l2) = if whole { (e.anchor.unwrap().line, (e.cursor.line + n).min(e.lines() - 1)) } else { (p.line, (p.line + n - 1).min(e.lines() - 1)) };
273+ let (l1, l2) = if whole {
274+ (e.anchor.unwrap().line, e.cursor.line.saturating_add(n).min(e.lines() - 1))
275+ } else {
276+ (p.line, p.line.saturating_add(n - 1).min(e.lines() - 1))
277+ };
205278 e.anchor = Some(Pos::new(l1, 0));
206279 e.cursor = Pos::new(l2, e.len(l2));
207280 }
@@ -234,10 +307,16 @@ fn normal(e: &mut Editor, key: KeyEvent) -> Outcome {
234307 } else {
235308 e.checkpoint();
236309 }
237− let gone = e.delete(s, en);
238− if !alt {
239− let lw = gone.ends_with('\n') && s.col == 0;
240− e.copy(gone, lw);
310+ if c == 'd' && !alt && s.col == 0 && en.col == 0 && en.line > s.line {
311+ // whole lines are cut, so a nested block's title line put
312+ // back moves the block (§5.2)
313+ e.cut_lines(s.line, en.line - 1);
314+ } else {
315+ let gone = e.delete(s, en);
316+ if !alt {
317+ let lw = gone.ends_with('\n') && s.col == 0;
318+ e.copy(gone, lw);
319+ }
241320 }
242321 e.anchor = None;
243322 e.set_cursor(s);
@@ -259,25 +338,23 @@ fn normal(e: &mut Editor, key: KeyEvent) -> Outcome {
259338 }
260339 e.checkpoint();
261340 let (s, en) = range(e);
262− let at = if clip.linewise {
263− if c == 'p' {
264− if en.col == 0 && en.line > s.line { en } else { Pos::new((en.line + 1).min(e.lines()), 0) }
341+ if clip.linewise {
342+ // whole lines below the selection's last line or above its
343+ // first, a nested block cut with its title line moving back
344+ // with them (§5.2); the lines put in are selected
345+ let first = if c == 'p' {
346+ let l = if en.col == 0 && en.line > s.line { en.line - 1 } else { en.line };
347+ e.put_clip_lines(l, true)
265348 } else {
266− Pos::new(s.line, 0)
267− }
268− } else if c == 'p' {
269− en
270− } else {
271− s
272− };
273− let end = if clip.linewise && at.line >= e.lines() {
274− let last = e.lines() - 1;
275− e.put_lines(last, &clip.text, true);
276− Pos::new(e.lines(), 0)
277− } else {
278− e.insert(at, &clip.text)
279− };
280− let at = if clip.linewise && at.line >= e.lines() { Pos::new(e.lines() - clip.text.matches('\n').count(), 0) } else { at };
349+ e.put_clip_lines(s.line, false)
350+ };
351+ let last = first + clip.text.matches('\n').count().max(1) - 1;
352+ e.anchor = Some(Pos::new(first, 0));
353+ e.cursor = Pos::new(last, e.len(last));
354+ return out;
355+ }
356+ let at = if c == 'p' { en } else { s };
357+ let end = e.insert(at, &clip.text);
281358 e.anchor = Some(at);
282359 e.cursor = e.prev(end).unwrap_or(end);
283360 }
@@ -301,28 +378,50 @@ fn normal(e: &mut Editor, key: KeyEvent) -> Outcome {
301378 e.change_case(s, en, how);
302379 }
303380 ('>' | '<', false) => {
381+ // a count of levels (two spaces each) in one change, as Helix does
304382 let (s, en) = range(e);
305383 let l2 = if en.col == 0 && en.line > s.line { en.line - 1 } else { en.line };
384+ let width = n.saturating_mul(2);
385+ if c == '>' && n > 1 && width.saturating_mul(l2 - s.line + 1) > MAX_TEXT {
386+ e.message = Some("text too long".into());
387+ return out;
388+ }
306389 let a = e.anchor;
307− for _ in 0..n {
308− e.indent(s.line, l2, if c == '>' { 1 } else { -1 });
390+ e.checkpoint();
391+ for l in s.line..=l2 {
392+ if c == '>' {
393+ if !e.line(l).is_empty() {
394+ e.insert(Pos::new(l, 0), &" ".repeat(width));
395+ }
396+ } else {
397+ let k = e.line(l).chars().take(width).take_while(|ch| *ch == ' ').count();
398+ e.delete(Pos::new(l, 0), Pos::new(l, k));
399+ }
309400 }
401+ e.cursor.col = if c == '>' { e.cursor.col + width } else { e.cursor.col.saturating_sub(width) };
310402 e.anchor = a;
311403 }
312404 ('J', false) => {
405+ // the selected lines (a line selection ends on the last one's
406+ // line end), or this one and the next
313407 let (s, en) = range(e);
314− let lines = en.line.saturating_sub(s.line).max(1);
408+ let l2 = if en.col == 0 && en.line > s.line { en.line - 1 } else { en.line };
409+ let lines = l2.saturating_sub(s.line).max(1);
315410 e.join(s.line, lines);
316411 e.anchor = None;
317412 }
318413 ('u', false) => {
319414 for _ in 0..n {
320− e.undo();
415+ if !e.undo() {
416+ break;
417+ }
321418 }
322419 }
323420 ('U', false) => {
324421 for _ in 0..n {
325− e.redo();
422+ if !e.redo() {
423+ break;
424+ }
326425 }
327426 }
328427 ('i', false) => {
@@ -380,7 +479,7 @@ fn normal(e: &mut Editor, key: KeyEvent) -> Outcome {
380479
381480/// The key after `g`, `f`/`t`/`F`/`T`, `r` or `m`.
382481fn prefixed(e: &mut Editor, p: char, c: char) -> Outcome {
383− let n: usize = std::mem::take(&mut e.helix.count).parse().unwrap_or(1).max(1);
482+ let n = take_count(&mut e.helix);
384483 let cur = e.cursor;
385484 match p {
386485 'g' => {
@@ -398,16 +497,17 @@ fn prefixed(e: &mut Editor, p: char, c: char) -> Outcome {
398497 }
399498 }
400499 'f' | 't' | 'F' | 'T' => {
401− if let Some(to) = e.find_char(cur, c, p, n) {
500+ // Helix's `t`/`T` look past a target next to the cursor
501+ if let Some(to) = e.find_char(cur, c, p, n, true) {
402502 select_to(e, cur, to);
403503 }
404504 }
405505 'r' => {
506+ // each character where it is: line ends stay, and so does every
507+ // line's tag (§5.2)
406508 let (s, en) = range(e);
407509 e.checkpoint();
408− let t: String = e.text(s, en).chars().map(|ch| if ch == '\n' { ch } else { c }).collect();
409− e.delete(s, en);
410− e.insert(s, &t);
510+ e.map_chars(s, en, |_| c);
411511 }
412512 'm' => {
413513 // mi( / ma" …: select inside / around a pair
crates/fold-tui/src/app/editor/mod.rs+389−55
@@ -4,7 +4,8 @@
44//!
55//! Every change goes through `insert` and `delete`, which use the buffer's
66//! own line operations, so each line keeps (or inherits) its owning block and
7−//! the blocks it touches become dirty (§5.2).
7+//! the blocks it touches become dirty (§5.2); whole lines moved, or cut and
8+//! put back, carry their tags (`move_lines`, `cut_lines`, `put_clip_lines`).
89
910mod helix;
1011mod normal;
@@ -13,7 +14,7 @@ mod vim;
1314use super::wrap::{self, Row};
1415use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1516use fold_core::edit::{EditBuffer, EditLine, Owner};
16−use std::collections::BTreeSet;
17+use std::collections::{BTreeMap, BTreeSet};
1718
1819/// A position: line, and column in characters.
1920#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord, Default)]
@@ -89,6 +90,9 @@ pub struct Outcome {
8990pub struct Clip {
9091 pub text: String,
9192 pub linewise: bool,
93+ /// Whole lines cut in this editor: each line's block, where that block's
94+ /// title line was cut with it (§5.2: cut and paste moves its embed).
95+ pub tags: Vec<Option<Owner>>,
9296}
9397
9498/// An input line at the bottom of the editor: `:` commands or `/` search.
@@ -107,6 +111,8 @@ enum Group {
107111struct Snap {
108112 lines: Vec<EditLine>,
109113 cursor: Pos,
114+ /// The block each block's embed sits in (moving lines can change it).
115+ parents: BTreeMap<Owner, Option<Owner>>,
110116}
111117
112118pub struct Editor {
@@ -119,6 +125,9 @@ pub struct Editor {
119125 pub clip: Clip,
120126 pub cmdline: Option<CmdLine>,
121127 pub search: Option<String>,
128+ /// Whether the last search went forward (`/`, Vim's `*`) or back (`?`,
129+ /// `#`): Vim's `n` goes on the same way.
130+ search_fwd: bool,
122131 /// A one-line message for the editor's status (a failed search, …).
123132 pub message: Option<String>,
124133 /// Lines on screen, for paging (set by the renderer).
@@ -145,7 +154,9 @@ pub struct Editor {
145154}
146155
147156impl Editor {
148− pub fn new(buf: EditBuffer, keys: Keys, clip: Clip) -> Editor {
157+ pub fn new(buf: EditBuffer, keys: Keys, mut clip: Clip) -> Editor {
158+ // tags name blocks of the buffer they were cut from
159+ clip.tags.clear();
149160 let mode = match keys {
150161 Keys::Normal => Mode::Insert,
151162 Keys::Vim | Keys::Helix => Mode::Normal,
@@ -159,6 +170,7 @@ impl Editor {
159170 clip,
160171 cmdline: None,
161172 search: None,
173+ search_fwd: true,
162174 message: None,
163175 page: 20,
164176 copied: None,
@@ -207,7 +219,13 @@ impl Editor {
207219
208220 /// Text from the terminal's paste (bracketed paste): typed in as is.
209221 pub fn paste_text(&mut self, text: &str) {
222+ if let Some(cl) = self.cmdline.as_mut() {
223+ // an open `:` or `/` line is where typing goes: its first line
224+ cl.text.push_str(text.lines().next().unwrap_or(""));
225+ return;
226+ }
210227 let text = text.replace("\r\n", "\n").replace('\r', "\n");
228+ self.forget_goal();
211229 if self.mode != Mode::Insert && self.keys != Keys::Normal {
212230 // pasting in a normal mode puts the text after the cursor
213231 self.checkpoint();
@@ -223,9 +241,11 @@ impl Editor {
223241
224242 // -------------------------------------------------------------- pointer
225243
226− /// A click: the cursor goes there and any selection is dropped.
244+ /// A click: the cursor goes there and any selection is dropped. Typing
245+ /// after it is a new undo step.
227246 pub fn click(&mut self, p: Pos) {
228247 self.anchor = None;
248+ self.group = Group::None;
229249 if matches!(self.mode, Mode::Visual { .. } | Mode::Select) {
230250 self.mode = Mode::Normal;
231251 }
@@ -260,6 +280,7 @@ impl Editor {
260280 if s == e {
261281 return;
262282 }
283+ self.group = Group::None;
263284 self.anchor = Some(s);
264285 self.cursor = if self.keys == Keys::Normal { e } else { self.prev(e).unwrap_or(e) };
265286 if self.keys == Keys::Vim && self.mode == Mode::Normal {
@@ -366,7 +387,11 @@ impl Editor {
366387 }
367388
368389 /// Insert text at a position; returns the position after it. New lines
369− /// belong to the block of the line they split (§5.2).
390+ /// belong to the block of the line they follow (§5.2): the line they
391+ /// split, or at column 0 the line above, so a line opened or pasted above
392+ /// a nested block's title line is not written into that block. Text
393+ /// starting with a line break at an empty line goes in below it (`o`,
394+ /// `p`, Enter, Ctrl-D there), so those lines are that line's block's.
370395 pub fn insert(&mut self, at: Pos, s: &str) -> Pos {
371396 if s.is_empty() {
372397 return at;
@@ -383,6 +408,23 @@ impl Editor {
383408 self.buf.set_line(at.line, format!("{}{}{}", pre, s, post));
384409 return Pos::new(at.line, at.col + s.chars().count());
385410 }
411+ let owner = self.buf.lines[at.line].owner;
412+ let block_title = self.buf.owners.get(&owner).is_some_and(|i| i.parent.is_some())
413+ && !self.buf.lines[..at.line].iter().any(|l| l.owner == owner);
414+ if at.col == 0 && at.line > 0 && block_title && !(parts[0].is_empty() && post.is_empty()) {
415+ // in front of a nested block's title line, whole lines go in
416+ // after the line above, taking its tag: they are not the block's
417+ // text; the title line keeps its own tag and only gains the last
418+ // part
419+ let mut l = at.line - 1;
420+ for p in &parts[..parts.len() - 1] {
421+ self.buf.insert_line(l, p.to_string());
422+ l += 1;
423+ }
424+ let last = parts[parts.len() - 1];
425+ self.buf.set_line(l + 1, format!("{}{}", last, post));
426+ return Pos::new(l + 1, last.chars().count());
427+ }
386428 self.buf.set_line(at.line, format!("{}{}", pre, parts[0]));
387429 let mut l = at.line;
388430 for p in &parts[1..parts.len() - 1] {
@@ -394,7 +436,15 @@ impl Editor {
394436 Pos::new(l + 1, last.chars().count())
395437 }
396438
397− /// Delete `[a, b)`; returns what was deleted.
439+ /// Delete `[a, b)`; returns what was deleted. A line deleted whole takes
440+ /// its tag with it (§5.2): what is left of a joined line keeps the tag of
441+ /// line `a`, unless the range starts at column 0 and leaves some of line
442+ /// `b`, or ends at its start (short of the end of the text): then line
443+ /// `b` is what is left. A range from column 0 through the end of line
444+ /// `b` deletes lines `a` to `b` whole, and the empty line left keeps the
445+ /// tag of a block whose title line was not among them (`kept`), so a
446+ /// nested block's title line deleted that way deletes the block,
447+ /// whichever end of the range it is at.
398448 pub fn delete(&mut self, a: Pos, b: Pos) -> String {
399449 let (a, mut b) = order(a, b);
400450 if b.line >= self.lines() {
@@ -407,14 +457,50 @@ impl Editor {
407457 self.changes += 1;
408458 let first = self.line(a.line).to_string();
409459 let last = self.line(b.line).to_string();
410− let joined = format!("{}{}", &first[..byte(&first, a.col)], &last[byte(&last, b.col)..]);
460+ let rest = &last[byte(&last, b.col)..];
461+ if a.col == 0 && a.line < b.line && (!rest.is_empty() || (b.col == 0 && b.line + 1 < self.lines())) {
462+ for l in (a.line..b.line).rev() {
463+ self.buf.delete_line(l);
464+ }
465+ self.buf.set_line(a.line, rest.to_string());
466+ return gone;
467+ }
468+ let whole = (a.col == 0 && a.line < b.line).then(|| self.kept(a.line, b.line));
469+ let joined = format!("{}{}", &first[..byte(&first, a.col)], rest);
411470 for l in (a.line + 1..=b.line).rev() {
412471 self.buf.delete_line(l);
413472 }
414473 self.buf.set_line(a.line, joined);
474+ if let Some(o) = whole {
475+ let was = std::mem::replace(&mut self.buf.lines[a.line].owner, o);
476+ self.buf.mark_dirty(was);
477+ self.buf.mark_dirty(o);
478+ }
415479 gone
416480 }
417481
482+ /// The block the empty line left where lines `l1..=l2` are deleted whole
483+ /// belongs to: one whose title line (§5.2) is not among them — line
484+ /// `l1`'s, else line `l2`'s, else the block they sit in.
485+ fn kept(&self, l1: usize, l2: usize) -> Owner {
486+ let titles = self.titles();
487+ let gone = |o: Owner| {
488+ self.buf.owners.get(&o).is_some_and(|i| i.parent.is_some())
489+ && titles.get(&o).is_some_and(|t| (l1..=l2).contains(t))
490+ };
491+ let (mut o, last) = (self.buf.lines[l1].owner, self.buf.lines[l2].owner);
492+ if gone(o) && !gone(last) {
493+ return last;
494+ }
495+ while gone(o) {
496+ match self.buf.owners.get(&o).and_then(|i| i.parent) {
497+ Some(p) => o = p,
498+ None => break,
499+ }
500+ }
501+ o
502+ }
503+
418504 /// Delete whole lines `l1..=l2`; returns them, each ending in `\n`.
419505 pub fn delete_lines(&mut self, l1: usize, l2: usize) -> String {
420506 let l2 = l2.min(self.lines() - 1);
@@ -461,11 +547,215 @@ impl Editor {
461547 Some(gone)
462548 }
463549
550+ // -------------------------------------------------------------- moving lines
551+
552+ /// Move whole lines `l1..=l2` one line down or up, past their neighbour.
553+ /// Each line keeps its tag (§5.2), so moving a nested block's title line
554+ /// moves its embed.
555+ pub fn move_lines(&mut self, l1: usize, l2: usize, down: bool) {
556+ let (lo, hi) = if down { (l1, l2 + 1) } else { (l1.wrapping_sub(1), l2) };
557+ if (!down && l1 == 0) || hi >= self.buf.lines.len() {
558+ return;
559+ }
560+ let titles = self.titles();
561+ if down {
562+ self.buf.lines[lo..=hi].rotate_right(1);
563+ } else {
564+ self.buf.lines[lo..=hi].rotate_left(1);
565+ }
566+ self.changes += 1;
567+ for i in lo..=hi {
568+ let o = self.buf.lines[i].owner;
569+ self.buf.mark_dirty(o);
570+ }
571+ // where the line at `i` went: the neighbour to the far end, the rest by one
572+ let to = |i: usize| {
573+ if i < lo || i > hi {
574+ i
575+ } else if down {
576+ if i == hi { lo } else { i + 1 }
577+ } else if i == lo {
578+ hi
579+ } else {
580+ i - 1
581+ }
582+ };
583+ let mut moved = BTreeMap::new();
584+ for (o, t) in titles {
585+ if (lo..=hi).contains(&t) {
586+ self.touch(o);
587+ }
588+ moved.insert(o, Some(to(t)));
589+ }
590+ self.settle(&moved);
591+ }
592+
593+ /// Cut whole lines `l1..=l2` to the clipboard. A nested block whose title
594+ /// line goes keeps its tag there, and its lines left behind go to the
595+ /// block they now sit in, so pasting the lines moves the block (§5.2).
596+ pub fn cut_lines(&mut self, l1: usize, l2: usize) {
597+ let l2 = l2.min(self.lines() - 1);
598+ let titles = self.titles();
599+ let nested = |e: &Editor, o: Owner| e.buf.owners.get(&o).is_some_and(|i| i.parent.is_some());
600+ let tags = (l1..=l2)
601+ .map(|l| {
602+ let o = self.buf.lines.get(l)?.owner;
603+ (nested(self, o) && titles.get(&o).is_some_and(|&t| t >= l1)).then_some(o)
604+ })
605+ .collect();
606+ let text = self.delete_lines(l1, l2);
607+ let n = l2 + 1 - l1;
608+ let left = titles
609+ .into_iter()
610+ .map(|(o, t)| (o, if t < l1 { Some(t) } else if t > l2 { Some(t - n) } else { None }))
611+ .collect();
612+ self.settle(&left);
613+ self.set_clip(text, true, tags);
614+ }
615+
616+ /// Put the clipboard's whole lines above or below line `l`; returns the
617+ /// first new line. They take the tag of the line above (§5.2), except the
618+ /// lines of a nested block cut with its title line and no longer in the
619+ /// buffer: those go back with their own tag, so the block moves. A tag
620+ /// the buffer does not know (it was re-rendered since the cut, §11.2)
621+ /// names no block to write the line to, and is not put back.
622+ pub fn put_clip_lines(&mut self, l: usize, below: bool) -> usize {
623+ let clip = self.clip.clone();
624+ let titles = self.titles();
625+ let first = self.put_lines(l, &clip.text, below);
626+ let n = clip.tags.len();
627+ if n == 0 || clip.text.matches('\n').count() != n || first + n > self.buf.lines.len() {
628+ return first;
629+ }
630+ let present: BTreeSet<Owner> =
631+ self.buf.lines.iter().enumerate().filter(|(i, _)| *i < first || *i >= first + n).map(|(_, x)| x.owner).collect();
632+ let mut back: BTreeMap<Owner, Option<usize>> = BTreeMap::new();
633+ for (i, tag) in clip.tags.iter().enumerate() {
634+ let Some(o) = tag.filter(|o| !present.contains(o) && self.buf.owners.contains_key(o)) else { continue };
635+ let was = std::mem::replace(&mut self.buf.lines[first + i].owner, o);
636+ self.buf.mark_dirty(was);
637+ self.touch(o);
638+ back.entry(o).or_insert(Some(first + i));
639+ }
640+ if !back.is_empty() {
641+ for (o, t) in titles {
642+ back.insert(o, Some(if t >= first { t + n } else { t }));
643+ }
644+ self.settle(&back);
645+ }
646+ first
647+ }
648+
649+ /// The first line of each block: its title line (§5.2).
650+ fn titles(&self) -> BTreeMap<Owner, usize> {
651+ let mut t = BTreeMap::new();
652+ for (i, l) in self.buf.lines.iter().enumerate() {
653+ t.entry(l.owner).or_insert(i);
654+ }
655+ t
656+ }
657+
658+ /// Whether block `o` is `outer` or nested in it.
659+ fn within(&self, mut o: Owner, outer: Owner) -> bool {
660+ while o != outer {
661+ match self.buf.owners.get(&o).and_then(|i| i.parent) {
662+ Some(p) => o = p,
663+ None => return false,
664+ }
665+ }
666+ true
667+ }
668+
669+ /// A block changed and moved: it is dirty, and so is the block its embed
670+ /// sits in (§5.2).
671+ fn touch(&mut self, o: Owner) {
672+ self.buf.mark_dirty(o);
673+ if let Some(p) = self.buf.owners.get(&o).and_then(|i| i.parent) {
674+ self.buf.mark_dirty(p);
675+ }
676+ }
677+
678+ /// After lines moved with their tags, each goes with the block it now
679+ /// sits in (§5.2): `retag_strays`, then each nested block's embed goes
680+ /// to the block its title line sits in, by the rule the buffer's save
681+ /// follows too (`EditBuffer::reparent`).
682+ fn settle(&mut self, titles: &BTreeMap<Owner, Option<usize>>) {
683+ self.retag_strays(titles);
684+ let now = self.titles();
685+ self.buf.reparent(&now);
686+ }
687+
688+ /// A line of a nested block that is no longer contiguous with the
689+ /// block's title line (at `titles`, or gone) is re-tagged to the block it
690+ /// now sits in, that of the line above (§5.2).
691+ fn retag_strays(&mut self, titles: &BTreeMap<Owner, Option<usize>>) {
692+ let n = self.buf.lines.len();
693+ for (&o, &title) in titles {
694+ if self.buf.owners.get(&o).and_then(|i| i.parent).is_none() {
695+ continue;
696+ }
697+ // the title line, then the lines of the block and the blocks in it
698+ let (t, mut end) = match title {
699+ Some(t) => (t, t + 1),
700+ None => (n, n),
701+ };
702+ while end < n && self.within(self.buf.lines[end].owner, o) {
703+ end += 1;
704+ }
705+ for i in 1..n {
706+ if (i < t || i >= end) && self.buf.lines[i].owner == o {
707+ let above = self.buf.lines[i - 1].owner;
708+ self.buf.lines[i].owner = above;
709+ self.buf.mark_dirty(above);
710+ self.buf.mark_dirty(o);
711+ }
712+ }
713+ }
714+ }
715+
716+ /// The block each block's embed sits in.
717+ fn parents(&self) -> BTreeMap<Owner, Option<Owner>> {
718+ self.buf.owners.iter().map(|(&o, i)| (o, i.parent)).collect()
719+ }
720+
721+ /// What splice writes for each block (§5.2): its own lines, and the
722+ /// first line of each block nested in it, where that block's embed goes.
723+ fn splice_views(lines: &[EditLine], parents: &BTreeMap<Owner, Option<Owner>>) -> BTreeMap<Owner, Vec<(Owner, String)>> {
724+ let mut views: BTreeMap<Owner, Vec<(Owner, String)>> = BTreeMap::new();
725+ let mut seen = BTreeSet::new();
726+ for l in lines {
727+ views.entry(l.owner).or_default().push((l.owner, l.text.clone()));
728+ let mut o = l.owner;
729+ while let Some(p) = parents.get(&o).copied().flatten() {
730+ if seen.insert(o) {
731+ views.entry(p).or_default().push((o, l.text.clone()));
732+ }
733+ o = p;
734+ }
735+ }
736+ views
737+ }
738+
464739 // -------------------------------------------------------------- clipboard
465740
466741 pub fn copy(&mut self, text: String, linewise: bool) {
742+ self.set_clip(text, linewise, Vec::new());
743+ }
744+
745+ /// Fill the clipboard. The blocks whose title lines it holds are in
746+ /// transit until they are pasted back or the clipboard is replaced: the
747+ /// buffer holds them, so a save meanwhile does not delete them (§5.2).
748+ fn set_clip(&mut self, text: String, linewise: bool, tags: Vec<Option<Owner>>) {
467749 self.copied = Some(text.clone());
468− self.clip = Clip { text, linewise };
750+ self.buf.hold(tags.iter().flatten().copied().collect());
751+ self.clip = Clip { text, linewise, tags };
752+ }
753+
754+ /// Leaving the editor: the clipboard keeps its text, but a block whose
755+ /// title line was cut and not pasted back is deleted now (§5.2).
756+ pub fn release_clip(&mut self) {
757+ self.clip.tags.clear();
758+ self.buf.hold(Vec::new());
469759 }
470760
471761 fn after_cursor(&self) -> Pos {
@@ -477,7 +767,7 @@ impl Editor {
477767 /// Remember the text before a change. Consecutive typing is one step.
478768 pub fn checkpoint(&mut self) {
479769 self.redo.clear();
480− self.undo.push(Snap { lines: self.buf.lines.clone(), cursor: self.cursor });
770+ self.undo.push(Snap { lines: self.buf.lines.clone(), cursor: self.cursor, parents: self.parents() });
481771 self.group = Group::None;
482772 }
483773
@@ -508,20 +798,25 @@ impl Editor {
508798 true
509799 }
510800
511− /// Put a snapshot back; every block whose text differs is dirty again.
801+ /// Put a snapshot back; every block whose text differs is dirty again,
802+ /// and so is every block a nested block moved in.
512803 fn restore(&mut self, s: Snap) -> Snap {
513− let cur = Snap { lines: std::mem::take(&mut self.buf.lines), cursor: self.cursor };
514− let text_of = |lines: &[EditLine], o: Owner| -> Vec<String> {
515− lines.iter().filter(|l| l.owner == o).map(|l| l.text.clone()).collect()
516− };
517− let owners: BTreeSet<Owner> = cur.lines.iter().chain(s.lines.iter()).map(|l| l.owner).collect();
518− for o in owners {
519− if text_of(&cur.lines, o) != text_of(&s.lines, o) {
520− self.buf.mark_dirty(o);
804+ let cur = Snap { lines: std::mem::take(&mut self.buf.lines), cursor: self.cursor, parents: self.parents() };
805+ let was = Self::splice_views(&cur.lines, &cur.parents);
806+ let now = Self::splice_views(&s.lines, &s.parents);
807+ for o in was.keys().chain(now.keys()) {
808+ if was.get(o) != now.get(o) {
809+ self.buf.mark_dirty(*o);
521810 }
522811 }
523812 self.buf.lines = s.lines;
813+ for (o, p) in s.parents {
814+ if let Some(i) = self.buf.owners.get_mut(&o) {
815+ i.parent = p;
816+ }
817+ }
524818 self.cursor = s.cursor;
819+ self.forget_goal();
525820 self.anchor = None;
526821 self.changes += 1;
527822 self.group = Group::None;
@@ -551,6 +846,12 @@ impl Editor {
551846
552847 pub fn set_cursor(&mut self, p: Pos) {
553848 self.cursor = p;
849+ self.forget_goal();
850+ }
851+
852+ /// Up and down aim from where the cursor is now, not for the column an
853+ /// earlier vertical move aimed for (after typing, undo, …).
854+ fn forget_goal(&mut self) {
554855 self.want_col = None;
555856 self.want_x = None;
556857 }
@@ -567,18 +868,9 @@ impl Editor {
567868 let mut fence: Option<(char, usize)> = None;
568869 for l in 0..self.lines() {
569870 let text = self.line(l);
570− let t = text.trim_start();
571− let fc = t.chars().next().filter(|c| *c == '`' || *c == '~');
572− let n = fc.map(|c| t.chars().take_while(|x| *x == c).count()).unwrap_or(0);
573− let is_fence = n >= 3;
574− let code = fence.is_some() && !is_fence;
575− if is_fence {
576− fence = match fence {
577− Some((c, m)) if Some(c) == fc && n >= m => None,
578− None => Some((fc.unwrap(), n)),
579− keep => keep,
580− };
581− }
871+ // fences as the parser reads them (§3.3); the fences
872+ // themselves are not code
873+ let code = !fold_core::parse::fence_transition(text, &mut fence) && fence.is_some();
582874 codes.push(code);
583875 rows.push(match self.wrap_cols {
584876 Some(cols) => wrap::wrap(text, cols, code),
@@ -761,19 +1053,23 @@ impl Editor {
7611053 }
7621054
7631055 /// Find a character on the cursor's line: `f` (forward, on it), `t`
764− /// (forward, before it), `F`, `T`.
765− pub fn find_char(&self, from: Pos, c: char, kind: char, count: usize) -> Option<Pos> {
1056+ /// (forward, before it), `F`, `T`. A `t`/`T` target right next to the
1057+ /// cursor is found where the cursor is, as in Vim, unless `skip`: then
1058+ /// the search starts past it, as Helix's `t` and Vim's `;` do.
1059+ pub fn find_char(&self, from: Pos, c: char, kind: char, count: usize, skip: bool) -> Option<Pos> {
7661060 let line: Vec<char> = self.line(from.line).chars().collect();
7671061 let mut col = from.col;
768− for _ in 0..count.max(1) {
1062+ for k in 0..count.max(1) {
1063+ // after the first, each step starts past the target it found
1064+ let past = usize::from(skip || k > 0);
7691065 col = match kind {
7701066 'f' | 't' => {
771− let start = if kind == 't' { col + 2 } else { col + 1 };
1067+ let start = if kind == 't' { col + 1 + past } else { col + 1 };
7721068 let i = (start.min(line.len())..line.len()).find(|&i| line[i] == c)?;
7731069 if kind == 't' { i - 1 } else { i }
7741070 }
7751071 _ => {
776− let end = if kind == 'T' { col.saturating_sub(1) } else { col };
1072+ let end = if kind == 'T' { col.saturating_sub(past) } else { col };
7771073 let i = (0..end).rev().find(|&i| line[i] == c)?;
7781074 if kind == 'T' { i + 1 } else { i }
7791075 }
@@ -909,27 +1205,28 @@ impl Editor {
9091205 /// Typing: replaces a selection, one undo step per run of typing.
9101206 pub fn type_char(&mut self, c: char) {
9111207 self.checkpoint_typing();
1208+ self.forget_goal();
9121209 if self.anchor.is_some() && self.keys == Keys::Normal {
9131210 self.delete_selection();
9141211 }
9151212 self.cursor = self.insert(self.cursor, &c.to_string());
916− self.want_col = None;
9171213 }
9181214
9191215 /// Enter: a new line, keeping the current line's indentation.
9201216 pub fn newline(&mut self) {
9211217 self.checkpoint_typing();
1218+ self.forget_goal();
9221219 if self.anchor.is_some() && self.keys == Keys::Normal {
9231220 self.delete_selection();
9241221 }
9251222 let indent: String = self.line(self.cursor.line).chars().take_while(|c| *c == ' ').collect();
9261223 let indent: String = indent.chars().take(self.cursor.col).collect();
9271224 self.cursor = self.insert(self.cursor, &format!("\n{}", indent));
928− self.want_col = None;
9291225 }
9301226
9311227 pub fn backspace(&mut self) {
9321228 self.checkpoint_typing();
1229+ self.forget_goal();
9331230 if self.anchor.is_some() && self.keys == Keys::Normal {
9341231 self.delete_selection();
9351232 return;
@@ -943,11 +1240,11 @@ impl Editor {
9431240 self.delete(start, self.cursor);
9441241 self.cursor = start;
9451242 }
946− self.want_col = None;
9471243 }
9481244
9491245 pub fn delete_forward(&mut self) {
9501246 self.checkpoint_typing();
1247+ self.forget_goal();
9511248 if self.anchor.is_some() && self.keys == Keys::Normal {
9521249 self.delete_selection();
9531250 return;
@@ -960,6 +1257,7 @@ impl Editor {
9601257 /// Delete back to the start of the word (Ctrl-W, Alt-Backspace).
9611258 pub fn delete_word_back(&mut self) {
9621259 self.checkpoint();
1260+ self.forget_goal();
9631261 let start = self.word_back(self.cursor, false);
9641262 self.delete(start, self.cursor);
9651263 self.cursor = start;
@@ -998,22 +1296,40 @@ impl Editor {
9981296 }
9991297 }
10001298
1001− /// Change the case of `[a, b)`: `u` lower, `U` upper, `~` swap.
1299+ /// Replace each character of `[a, b)` by `f` of it, one line at a time:
1300+ /// line ends stay where they are and every line keeps its tag (§5.2).
1301+ pub fn map_chars(&mut self, a: Pos, b: Pos, f: impl Fn(char) -> char) {
1302+ let (a, mut b) = order(a, b);
1303+ if b.line >= self.lines() {
1304+ b = Pos::new(self.lines() - 1, self.len(self.lines() - 1));
1305+ }
1306+ for l in a.line..=b.line {
1307+ let s = if l == a.line { a.col } else { 0 };
1308+ let e = if l == b.line { b.col } else { usize::MAX };
1309+ let new: String = self.line(l).chars().enumerate().map(|(i, c)| if i >= s && i < e { f(c) } else { c }).collect();
1310+ if new != self.line(l) {
1311+ self.changes += 1;
1312+ self.buf.set_line(l, new);
1313+ }
1314+ }
1315+ }
1316+
1317+ /// Change the case of `[a, b)`: `u` lower, `U` upper, `~` swap. A
1318+ /// character whose other case is not one character ('ß' is "SS") stays
1319+ /// as it is, as in Vim.
10021320 pub fn change_case(&mut self, a: Pos, b: Pos, how: char) {
1003− let t = self.text(a, b);
1004− let n: String = t
1005− .chars()
1006− .map(|c| match how {
1007− 'u' => c.to_lowercase().next().unwrap_or(c),
1008− 'U' => c.to_uppercase().next().unwrap_or(c),
1009− _ if c.is_uppercase() => c.to_lowercase().next().unwrap_or(c),
1010− _ => c.to_uppercase().next().unwrap_or(c),
1011− })
1012− .collect();
1013− if n != t {
1014− self.delete(a, b);
1015− self.insert(a, &n);
1321+ fn one(c: char, mut other: impl Iterator<Item = char>) -> char {
1322+ match (other.next(), other.next()) {
1323+ (Some(x), None) => x,
1324+ _ => c,
1325+ }
10161326 }
1327+ self.map_chars(a, b, |c| match how {
1328+ 'u' => one(c, c.to_lowercase()),
1329+ 'U' => one(c, c.to_uppercase()),
1330+ _ if c.is_uppercase() => one(c, c.to_lowercase()),
1331+ _ => one(c, c.to_uppercase()),
1332+ });
10171333 }
10181334
10191335 // -------------------------------------------------------------- search
@@ -1026,11 +1342,20 @@ impl Editor {
10261342 let pat = pat.to_lowercase();
10271343 let n = self.lines();
10281344 let hits = |l: usize| -> Vec<usize> {
1029− let line = self.line(l).to_lowercase();
1345+ // a character can lowercase to several ('İ' to "i̇"): count
1346+ // columns in the line itself, not in its lowercase
1347+ let mut line = String::new();
1348+ let mut col_of = Vec::new();
1349+ for (col, c) in self.line(l).chars().enumerate() {
1350+ for lc in c.to_lowercase() {
1351+ line.push(lc);
1352+ col_of.push(col);
1353+ }
1354+ }
10301355 let mut out = Vec::new();
10311356 let mut start = 0;
10321357 while let Some(i) = line[start..].find(&pat) {
1033− out.push(line[..start + i].chars().count());
1358+ out.push(col_of[line[..start + i].chars().count()]);
10341359 start += i + pat.len().max(1);
10351360 }
10361361 out
@@ -1053,7 +1378,15 @@ impl Editor {
10531378 /// Jump to the next match of the last search.
10541379 pub fn search_next(&mut self, fwd: bool) -> Option<Pos> {
10551380 let pat = self.search.clone()?;
1056− match self.find(&pat, self.cursor, fwd) {
1381+ // Helix searches on from the selection's end, or back from its start,
1382+ // so the match it has selected is not found again
1383+ let from = match self.anchor {
1384+ Some(a) if self.keys == Keys::Helix => {
1385+ if fwd { a.max(self.cursor) } else { a.min(self.cursor) }
1386+ }
1387+ _ => self.cursor,
1388+ };
1389+ match self.find(&pat, from, fwd) {
10571390 Some(p) => {
10581391 self.set_cursor(p);
10591392 Some(p)
@@ -1099,6 +1432,7 @@ impl Editor {
10991432 self.search = Some(t.to_string());
11001433 }
11011434 let fwd = cl.kind == '/';
1435+ self.search_fwd = fwd;
11021436 if let Some(p) = self.search_next(fwd) {
11031437 if self.keys == Keys::Helix || self.keys == Keys::Normal {
11041438 // select the match
crates/fold-tui/src/app/editor/normal.rs+28−12
@@ -1,7 +1,7 @@
11//! The normal keymap: a conventional editor in the manner of micro. Always
22//! typing; Shift extends a selection; Ctrl does the rest.
33
4−use super::{ctrl, order, Editor, Outcome, Pos};
4+use super::{ctrl, order, Editor, Group, Outcome, Pos};
55use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
66
77pub fn handle(e: &mut Editor, key: KeyEvent) -> Outcome {
@@ -9,6 +9,11 @@ pub fn handle(e: &mut Editor, key: KeyEvent) -> Outcome {
99 let ctl = key.modifiers.contains(KeyModifiers::CONTROL);
1010 let alt = key.modifiers.contains(KeyModifiers::ALT);
1111 let mut out = Outcome::default();
12+ // any other key ends a run of typing: what is typed next is its own undo step
13+ let typing = !ctl && !alt && matches!(key.code, KeyCode::Char(_) | KeyCode::Enter | KeyCode::Backspace | KeyCode::Delete | KeyCode::Tab);
14+ if !typing {
15+ e.group = Group::None;
16+ }
1217
1318 // movement, extending the selection with Shift
1419 let moved = match key.code {
@@ -174,9 +179,17 @@ fn selection_or_line(e: &Editor) -> (String, bool) {
174179 }
175180}
176181
182+/// Ctrl-X / Ctrl-K: the selection, or the current line. Whole lines are
183+/// cut with their tags, so a nested block's title line pasted back moves
184+/// the block (§5.2).
177185fn cut(e: &mut Editor) {
178186 e.checkpoint();
179187 match e.selection() {
188+ Some((s, en)) if s.col == 0 && en.col == 0 && en.line > s.line => {
189+ e.anchor = None;
190+ e.cut_lines(s.line, en.line - 1);
191+ e.set_cursor(s);
192+ }
180193 Some((s, en)) => {
181194 let t = e.delete(s, en);
182195 e.copy(t, false);
@@ -185,8 +198,7 @@ fn cut(e: &mut Editor) {
185198 }
186199 None => {
187200 let l = e.cursor.line;
188− let t = e.delete_lines(l, l);
189− e.copy(t, true);
201+ e.cut_lines(l, l);
190202 }
191203 }
192204}
@@ -204,7 +216,7 @@ fn paste(e: &mut Editor) {
204216 let clip = e.clip.clone();
205217 if clip.linewise {
206218 let l = e.cursor.line;
207− let first = e.put_lines(l, &clip.text, false);
219+ let first = e.put_clip_lines(l, false);
208220 e.set_cursor(Pos::new(first + clip.text.matches('\n').count(), e.cursor.col));
209221 } else {
210222 let end = e.insert(e.cursor, &clip.text);
@@ -212,7 +224,8 @@ fn paste(e: &mut Editor) {
212224 }
213225}
214226
215−/// Alt-Up / Alt-Down: move the current line (or the selected lines).
227+/// Alt-Up / Alt-Down: move the current line (or the selected lines), each
228+/// with its tag, so a nested block's title line takes its embed along (§5.2).
216229fn move_lines(e: &mut Editor, down: bool) {
217230 let (s, en) = e.selection().map(|(s, en)| order(s, en)).unwrap_or((e.cursor, e.cursor));
218231 let (l1, l2) = (s.line, en.line);
@@ -220,13 +233,7 @@ fn move_lines(e: &mut Editor, down: bool) {
220233 return;
221234 }
222235 e.checkpoint();
223− let t = e.delete_lines(l1, l2);
224− let at = if down { l1 + 1 } else { l1 - 1 };
225− if at >= e.lines() {
226− e.put_lines(e.lines() - 1, &t, true);
227− } else {
228− e.put_lines(at, &t, false);
229− }
236+ e.move_lines(l1, l2, down);
230237 let d: isize = if down { 1 } else { -1 };
231238 let shift = |p: Pos| Pos::new((p.line as isize + d) as usize, p.col);
232239 e.cursor = shift(if e.anchor.is_some() { e.cursor } else { Pos::new(l1, 0) });
@@ -285,6 +292,15 @@ mod tests {
285292 assert!(out[0].save);
286293 }
287294
295+ #[test]
296+ fn a_click_ends_a_run_of_typing() {
297+ let (_d, mut e) = editor("one\ntwo\n", Keys::Normal);
298+ keys(&mut e, "A");
299+ e.click(Pos::new(3, 0));
300+ keys(&mut e, "B<C-z>");
301+ assert_eq!(body(&e), "Aone\ntwo");
302+ }
303+
288304 #[test]
289305 fn find_selects_the_match() {
290306 let (_d, mut e) = editor("one two three two\n", Keys::Normal);
crates/fold-tui/src/app/editor/vim.rs+230−50
@@ -1,7 +1,7 @@
11//! The Vim keymap: normal, insert and visual modes; counts; motions,
22//! operators and text objects; `.` repeat; `:` and `/`.
33
4−use super::{order, Editor, Group, Mode, Outcome, Pos};
4+use super::{class, order, Class, Editor, Group, Mode, Outcome, Pos};
55use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
66
77#[derive(Default)]
@@ -34,6 +34,7 @@ pub fn handle(e: &mut Editor, key: KeyEvent) -> Outcome {
3434 if !e.vim.replaying {
3535 if e.vim.record.is_empty() {
3636 e.vim.record_changes = e.changes;
37+ e.vim.record = select_keys(e);
3738 }
3839 e.vim.record.push(key);
3940 }
@@ -45,9 +46,13 @@ pub fn handle(e: &mut Editor, key: KeyEvent) -> Outcome {
4546 e.vim.record.clear();
4647 } else if idle(e) && !e.vim.replaying {
4748 let rec = std::mem::take(&mut e.vim.record);
48− let first = rec.first().map(|k| k.code);
49− let is_undo = matches!(first, Some(KeyCode::Char('u') | KeyCode::Char('.')))
50− || rec.first().is_some_and(|k| k.code == KeyCode::Char('r') && k.modifiers.contains(KeyModifiers::CONTROL));
49+ // undo, redo and `.` itself (after any count) are not changes to repeat
50+ let ctl = |k: &KeyEvent| k.modifiers.contains(KeyModifiers::CONTROL);
51+ let is_undo = rec.get(count_end(&rec, 0)).is_some_and(|k| match k.code {
52+ KeyCode::Char('u') | KeyCode::Char('.') => !ctl(k),
53+ KeyCode::Char('r') => ctl(k),
54+ _ => false,
55+ });
5156 if e.changes != e.vim.record_changes && !is_undo {
5257 e.vim.last_change = rec;
5358 }
@@ -55,6 +60,65 @@ pub fn handle(e: &mut Editor, key: KeyEvent) -> Outcome {
5560 out
5661}
5762
63+/// A visual selection no recorded key made (a drag or a double-click, or one
64+/// left by a `:` command): keys that select as much from the cursor, for the
65+/// record to start with, so `.` acts on as much text as Vim's does. Insert
66+/// mode no recorded key entered (the app's `n`/`N` typing a new node's
67+/// title, §10.6, or an editor re-rendered while typing, §11.2) starts with
68+/// `a`, so `.` types the text again rather than running it as commands.
69+fn select_keys(e: &Editor) -> Vec<KeyEvent> {
70+ let key = |c: char| KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
71+ if e.mode == Mode::Insert {
72+ return vec![key('a')];
73+ }
74+ let (Mode::Visual { line }, Some(a)) = (e.mode, e.anchor) else { return Vec::new() };
75+ let (s, en) = order(a, e.cursor);
76+ let counted = |n: usize, c: char| -> Vec<KeyEvent> {
77+ if n == 0 { Vec::new() } else { n.to_string().chars().chain([c]).map(key).collect() }
78+ };
79+ let mut keys = vec![key(if line { 'V' } else { 'v' })];
80+ if en.line > s.line {
81+ // as many lines down, then (charwise) the same end column
82+ keys.extend(counted(en.line - s.line, 'j'));
83+ if !line {
84+ keys.push(key('0'));
85+ keys.extend(counted(en.col, 'l'));
86+ }
87+ } else if !line {
88+ keys.extend(counted(en.col - s.col, 'l'));
89+ }
90+ keys
91+}
92+
93+/// Where a count starting at `i` in recorded keys ends (`i` if there is none).
94+fn count_end(keys: &[KeyEvent], i: usize) -> usize {
95+ let mut j = i;
96+ while keys.get(j).is_some_and(|k| {
97+ !k.modifiers.contains(KeyModifiers::CONTROL) && matches!(k.code, KeyCode::Char(c) if c.is_ascii_digit() && (c != '0' || j > i))
98+ }) {
99+ j += 1;
100+ }
101+ j
102+}
103+
104+/// A recorded change with its count replaced by `n`, as Vim's `.` does with
105+/// a count: the count before the command and the one after an operator both
106+/// go (`2d3w` becomes `{n}dw`).
107+fn with_count(keys: &[KeyEvent], n: usize) -> Vec<KeyEvent> {
108+ let mut out: Vec<KeyEvent> = n.to_string().chars().map(|c| KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)).collect();
109+ let rest = &keys[count_end(keys, 0)..];
110+ let op = rest.first().is_some_and(|k| {
111+ !k.modifiers.contains(KeyModifiers::CONTROL) && matches!(k.code, KeyCode::Char('d' | 'c' | 'y' | '>' | '<'))
112+ });
113+ if op {
114+ out.push(rest[0]);
115+ out.extend_from_slice(&rest[count_end(rest, 1)..]);
116+ } else {
117+ out.extend_from_slice(rest);
118+ }
119+ out
120+}
121+
58122fn to_insert(e: &mut Editor) {
59123 e.checkpoint();
60124 e.group = Group::Typing;
@@ -64,6 +128,11 @@ fn to_insert(e: &mut Editor) {
64128
65129fn insert(e: &mut Editor, key: KeyEvent) -> Outcome {
66130 let ctl = key.modifiers.contains(KeyModifiers::CONTROL);
131+ // a key that moves the cursor ends a run of typing: what is typed
132+ // after it is its own undo step, as in Vim
133+ if matches!(key.code, KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down | KeyCode::Home | KeyCode::End) {
134+ e.group = Group::None;
135+ }
67136 match key.code {
68137 KeyCode::Esc => leave_insert(e),
69138 KeyCode::Char('c') | KeyCode::Char('[') if ctl => leave_insert(e),
@@ -102,9 +171,29 @@ fn leave_insert(e: &mut Editor) {
102171 e.cursor.col = e.cursor.col.saturating_sub(1);
103172}
104173
174+/// The largest count, as in Vim: more digits than that are this.
175+const MAX_COUNT: usize = 999_999_999;
176+
177+/// The most text a count may make one command put in (Vim's "text too long").
178+const MAX_TEXT: usize = 4 << 20;
179+
105180fn take_count(st: &mut State) -> Option<usize> {
106− let c = std::mem::take(&mut st.count).parse().ok();
107− c
181+ let s = std::mem::take(&mut st.count);
182+ (!s.is_empty()).then(|| s.parse().map_or(MAX_COUNT, |c: usize| c.min(MAX_COUNT)))
183+}
184+
185+/// `f` applied `n` times from `x`, stopping once it no longer moves: a count
186+/// past the end of the text stops there.
187+fn repeat<T: PartialEq + Copy>(n: usize, x: T, f: impl Fn(T) -> T) -> T {
188+ let mut x = x;
189+ for _ in 0..n {
190+ let y = f(x);
191+ if y == x {
192+ break;
193+ }
194+ x = y;
195+ }
196+ x
108197}
109198
110199fn reset(e: &mut Editor) {
@@ -118,11 +207,11 @@ fn motion(e: &mut Editor, c: char, count: Option<usize>) -> Option<(Pos, Kind)>
118207 let n = count.unwrap_or(1).max(1);
119208 let p = e.cursor;
120209 let last = e.lines() - 1;
121− let rep = |e: &Editor, f: &dyn Fn(&Editor, Pos) -> Pos| (0..n).fold(p, |q, _| f(e, q));
210+ let rep = |e: &Editor, f: &dyn Fn(&Editor, Pos) -> Pos| repeat(n, p, |q| f(e, q));
122211 Some(match c {
123212 'h' => (Pos::new(p.line, p.col.saturating_sub(n)), Kind::Excl),
124− 'l' | ' ' => (Pos::new(p.line, (p.col + n).min(e.len(p.line))), Kind::Excl),
125− 'j' => (Pos::new((p.line + n).min(last), p.col), Kind::Line),
213+ 'l' | ' ' => (Pos::new(p.line, p.col.saturating_add(n).min(e.len(p.line))), Kind::Excl),
214+ 'j' => (Pos::new(p.line.saturating_add(n).min(last), p.col), Kind::Line),
126215 'k' => (Pos::new(p.line.saturating_sub(n), p.col), Kind::Line),
127216 'w' => (rep(e, &|e, q| e.word_fwd(q, false)), Kind::Excl),
128217 'W' => (rep(e, &|e, q| e.word_fwd(q, true)), Kind::Excl),
@@ -133,19 +222,28 @@ fn motion(e: &mut Editor, c: char, count: Option<usize>) -> Option<(Pos, Kind)>
133222 '0' => (Pos::new(p.line, 0), Kind::Excl),
134223 '^' => (Pos::new(p.line, e.first_non_blank(p.line)), Kind::Excl),
135224 '$' => {
136− let l = (p.line + n - 1).min(last);
225+ let l = p.line.saturating_add(n - 1).min(last);
137226 (Pos::new(l, e.len(l)), Kind::Excl)
138227 }
139228 'G' => {
140229 let l = count.map(|c| c.saturating_sub(1)).unwrap_or(last).min(last);
141230 (Pos::new(l, e.first_non_blank(l)), Kind::Line)
142231 }
143− '}' => (Pos::new((0..n).fold(p.line, |l, _| e.paragraph(l, true)), 0), Kind::Excl),
144− '{' => (Pos::new((0..n).fold(p.line, |l, _| e.paragraph(l, false)), 0), Kind::Excl),
232+ '}' => {
233+ let l = repeat(n, p.line, |l| e.paragraph(l, true));
234+ if l == last && e.len(l) > 0 {
235+ // no blank line below: on the last character, inclusive (Vim's findpar)
236+ (Pos::new(l, e.len(l) - 1), Kind::Incl)
237+ } else {
238+ (Pos::new(l, 0), Kind::Excl)
239+ }
240+ }
241+ '{' => (Pos::new(repeat(n, p.line, |l| e.paragraph(l, false)), 0), Kind::Excl),
145242 '%' => (e.match_bracket(p)?, Kind::Incl),
243+ // `n` the way the last search went, `N` the other way
146244 'n' | 'N' => {
147245 let pat = e.search.clone()?;
148− (e.find(&pat, p, c == 'n')?, Kind::Excl)
246+ (e.find(&pat, p, (c == 'n') == e.search_fwd)?, Kind::Excl)
149247 }
150248 ';' | ',' => {
151249 let (kind, ch) = e.vim.last_find?;
@@ -159,17 +257,42 @@ fn motion(e: &mut Editor, c: char, count: Option<usize>) -> Option<(Pos, Kind)>
159257 } else {
160258 kind
161259 };
162− find(e, kind, ch, n)?
260+ // a repeat moves: it skips a `t`/`T` target next to the cursor,
261+ // unless given a count (Vim's 'cpoptions' without `;`)
262+ find(e, kind, ch, n, n == 1)?
163263 }
164264 _ => return None,
165265 })
166266}
167267
168−fn find(e: &Editor, kind: char, ch: char, n: usize) -> Option<(Pos, Kind)> {
169− let to = e.find_char(e.cursor, ch, kind, n)?;
268+fn find(e: &Editor, kind: char, ch: char, n: usize, skip: bool) -> Option<(Pos, Kind)> {
269+ let to = e.find_char(e.cursor, ch, kind, n, skip)?;
170270 Some((to, if kind == 'f' || kind == 't' { Kind::Incl } else { Kind::Excl }))
171271}
172272
273+/// `ge`: back to the end of the `n`th word before `p`, as Vim's
274+/// bckend_word: off the word `p` is in, then back over blanks and line ends,
275+/// stopping at an empty line.
276+fn end_back(e: &Editor, p: Pos, n: usize) -> Pos {
277+ let class_at = |q: Pos| e.char_at(q).map(|ch| class(ch, false));
278+ let blank = |q: Pos| class_at(q) == Some(Class::Space) && !(q.col == 0 && e.len(q.line) == 0);
279+ let mut q = p;
280+ for _ in 0..n.max(1) {
281+ let k = class_at(q).filter(|k| *k != Class::Space);
282+ let Some(mut r) = e.prev(q) else { break };
283+ while k.is_some() && class_at(r) == k {
284+ let Some(x) = e.prev(r) else { return r };
285+ r = x;
286+ }
287+ while blank(r) {
288+ let Some(x) = e.prev(r) else { return r };
289+ r = x;
290+ }
291+ q = r;
292+ }
293+ q
294+}
295+
173296fn normal(e: &mut Editor, key: KeyEvent) -> Outcome {
174297 let ctl = key.modifiers.contains(KeyModifiers::CONTROL);
175298 let mut out = Outcome::default();
@@ -244,31 +367,43 @@ fn normal(e: &mut Editor, key: KeyEvent) -> Outcome {
244367 if let Some(op) = e.vim.op {
245368 if c == op || (op == 'c' && c == 'c') {
246369 // dd, cc, yy, >>, <<: whole lines
247− let n = count.unwrap_or(1) * e.vim.op_count.max(1);
248− let l2 = (e.cursor.line + n - 1).min(e.lines() - 1);
370+ let n = count.unwrap_or(1).saturating_mul(e.vim.op_count.max(1)).min(MAX_COUNT);
371+ let l2 = e.cursor.line.saturating_add(n - 1).min(e.lines() - 1);
249372 let from = Pos::new(e.cursor.line, 0);
250373 e.vim.op = None;
251374 apply(e, op, from, Pos::new(l2, 0), Kind::Line);
252375 return out;
253376 }
377+ // the count before the operator multiplies the motion's (`2d3w` is `d6w`)
378+ let n = count.map(|c| c.saturating_mul(e.vim.op_count.max(1)).min(MAX_COUNT)).or(Some(e.vim.op_count).filter(|c| *c > 0));
379+ e.vim.op_count = 0;
254380 match c {
255381 'i' | 'a' | 'f' | 'F' | 't' | 'T' | 'g' => {
256382 e.vim.prefix = Some(c);
257− e.vim.count = count.map(|c| c.to_string()).unwrap_or_default();
383+ e.vim.count = n.map(|c| c.to_string()).unwrap_or_default();
258384 return out;
259385 }
260386 _ => {}
261387 }
262− let n = count.map(|c| c * e.vim.op_count.max(1)).or(Some(e.vim.op_count).filter(|c| *c > 0));
263388 e.vim.op = None;
264− e.vim.op_count = 0;
265389 // cw on a word is ce; dw stops at the end of the line
266− let c = if op == 'c' && (c == 'w' || c == 'W') && e.char_at(e.cursor).is_some_and(|ch| !ch.is_whitespace()) {
390+ let cw = op == 'c' && (c == 'w' || c == 'W') && e.char_at(e.cursor).is_some_and(|ch| !ch.is_whitespace());
391+ let c = if cw {
267392 if c == 'w' { 'e' } else { 'E' }
268393 } else {
269394 c
270395 };
271− if let Some((mut to, kind)) = motion(e, c, n) {
396+ // but from the last character of a word, that character is the
397+ // first word (Vim's end_word with stop): `cw` there changes just it
398+ let m = if cw && e.word_at(e.cursor, c == 'E').1.col == e.cursor.col + 1 {
399+ match n.unwrap_or(1) {
400+ 1 => Some((e.cursor, Kind::Incl)),
401+ k => motion(e, c, Some(k - 1)),
402+ }
403+ } else {
404+ motion(e, c, n)
405+ };
406+ if let Some((mut to, kind)) = m {
272407 if (c == 'w' || c == 'W') && to.line > e.cursor.line {
273408 to = Pos::new(e.cursor.line, e.len(e.cursor.line));
274409 }
@@ -327,7 +462,7 @@ fn normal(e: &mut Editor, key: KeyEvent) -> Outcome {
327462 'x' | 'X' => {
328463 let p = e.cursor;
329464 let (a, b) = if c == 'x' {
330− (p, Pos::new(p.line, (p.col + n).min(e.len(p.line))))
465+ (p, Pos::new(p.line, p.col.saturating_add(n).min(e.len(p.line))))
331466 } else {
332467 (Pos::new(p.line, p.col.saturating_sub(n)), p)
333468 };
@@ -340,14 +475,14 @@ fn normal(e: &mut Editor, key: KeyEvent) -> Outcome {
340475 }
341476 's' => {
342477 let p = e.cursor;
343− let b = Pos::new(p.line, (p.col + n).min(e.len(p.line)));
478+ let b = Pos::new(p.line, p.col.saturating_add(n).min(e.len(p.line)));
344479 to_insert(e);
345480 let t = e.delete(p, b);
346481 e.copy(t, false);
347482 }
348483 'S' => {
349484 let l = e.cursor.line;
350− apply(e, 'c', Pos::new(l, 0), Pos::new((l + n - 1).min(e.lines() - 1), 0), Kind::Line);
485+ apply(e, 'c', Pos::new(l, 0), Pos::new(l.saturating_add(n - 1).min(e.lines() - 1), 0), Kind::Line);
351486 }
352487 'D' | 'C' | 'Y' => {
353488 let op = match c {
@@ -357,9 +492,9 @@ fn normal(e: &mut Editor, key: KeyEvent) -> Outcome {
357492 };
358493 if c == 'Y' {
359494 let l = e.cursor.line;
360− apply(e, 'y', Pos::new(l, 0), Pos::new((l + n - 1).min(e.lines() - 1), 0), Kind::Line);
495+ apply(e, 'y', Pos::new(l, 0), Pos::new(l.saturating_add(n - 1).min(e.lines() - 1), 0), Kind::Line);
361496 } else {
362− let l = (e.cursor.line + n - 1).min(e.lines() - 1);
497+ let l = e.cursor.line.saturating_add(n - 1).min(e.lines() - 1);
363498 let from = e.cursor;
364499 apply(e, op, from, Pos::new(l, e.len(l)), Kind::Excl);
365500 }
@@ -375,7 +510,7 @@ fn normal(e: &mut Editor, key: KeyEvent) -> Outcome {
375510 }
376511 '~' => {
377512 let p = e.cursor;
378− let b = Pos::new(p.line, (p.col + n).min(e.len(p.line)));
513+ let b = Pos::new(p.line, p.col.saturating_add(n).min(e.len(p.line)));
379514 e.checkpoint();
380515 e.change_case(p, b, '~');
381516 e.cursor = Pos::new(p.line, b.col);
@@ -386,18 +521,28 @@ fn normal(e: &mut Editor, key: KeyEvent) -> Outcome {
386521 }
387522 'u' => {
388523 for _ in 0..n {
389− e.undo();
524+ if !e.undo() {
525+ break;
526+ }
390527 }
391528 }
392− '.' => {
529+ // never inside a replay: `.` would replay itself
530+ '.' if !e.vim.replaying => {
531+ // a count replaces the change's own, and stays for the next `.`
532+ if let Some(n) = count.filter(|_| !e.vim.last_change.is_empty()) {
533+ e.vim.last_change = with_count(&e.vim.last_change, n);
534+ }
393535 let keys = e.vim.last_change.clone();
394536 e.vim.replaying = true;
395− for _ in 0..n {
396− for k in &keys {
397− handle(e, *k);
398− }
537+ for k in keys {
538+ handle(e, k);
399539 }
400540 e.vim.replaying = false;
541+ // a replay cut short (say, its selection was not made by keys)
542+ // leaves no operator waiting for the next key
543+ if e.mode == Mode::Normal {
544+ reset(e);
545+ }
401546 }
402547 'v' | 'V' => {
403548 e.mode = Mode::Visual { line: c == 'V' };
@@ -410,6 +555,7 @@ fn normal(e: &mut Editor, key: KeyEvent) -> Outcome {
410555 let w = e.text(s, en);
411556 if !w.trim().is_empty() {
412557 e.search = Some(w);
558+ e.search_fwd = c == '*';
413559 e.set_cursor(s);
414560 e.search_next(c == '*');
415561 }
@@ -427,7 +573,7 @@ fn prefixed(e: &mut Editor, p: char, c: char) -> Outcome {
427573 match p {
428574 'f' | 'F' | 't' | 'T' => {
429575 e.vim.last_find = Some((p, c));
430− if let Some((to, kind)) = find(e, p, c, count.unwrap_or(1)) {
576+ if let Some((to, kind)) = find(e, p, c, count.unwrap_or(1), false) {
431577 if let Some(op) = e.vim.op.take() {
432578 let from = e.cursor;
433579 apply(e, op, from, to, kind);
@@ -441,10 +587,9 @@ fn prefixed(e: &mut Editor, p: char, c: char) -> Outcome {
441587 'r' => {
442588 if matches!(e.mode, Mode::Visual { .. }) {
443589 if let Some((s, en)) = e.selection() {
590+ // each character where it is: line ends and tags stay (§5.2)
444591 e.checkpoint();
445− let t: String = e.text(s, en).chars().map(|ch| if ch == '\n' { ch } else { c }).collect();
446− e.delete(s, en);
447− e.insert(s, &t);
592+ e.map_chars(s, en, |_| c);
448593 e.cursor = s;
449594 e.anchor = None;
450595 e.mode = Mode::Normal;
@@ -452,7 +597,7 @@ fn prefixed(e: &mut Editor, p: char, c: char) -> Outcome {
452597 } else {
453598 let n = count.unwrap_or(1);
454599 let pos = e.cursor;
455− if pos.col + n <= e.len(pos.line) {
600+ if pos.col.checked_add(n).is_some_and(|end| end <= e.len(pos.line)) {
456601 e.checkpoint();
457602 e.delete(pos, Pos::new(pos.line, pos.col + n));
458603 e.insert(pos, &c.to_string().repeat(n));
@@ -471,15 +616,27 @@ fn prefixed(e: &mut Editor, p: char, c: char) -> Outcome {
471616 e.set_cursor(to);
472617 }
473618 }
619+ // ge: back to the end of the previous word, inclusive
474620 'e' => {
475− let to = e.word_back(e.cursor, false);
476− let to = e.prev(to).unwrap_or(to);
477− e.set_cursor(to);
621+ let to = end_back(e, e.cursor, count.unwrap_or(1));
622+ if let Some(op) = e.vim.op.take() {
623+ let from = e.cursor;
624+ apply(e, op, from, to, Kind::Incl);
625+ } else {
626+ e.set_cursor(to);
627+ }
478628 }
479− // gj / gk: by screen row through wrapped lines
629+ // gj / gk: by screen row through wrapped lines; with an operator
630+ // they are charwise and exclusive, not linewise like j / k
480631 'j' | 'k' => {
481632 let n = count.unwrap_or(1) as isize;
633+ let from = e.cursor;
482634 e.move_visual(if c == 'j' { n } else { -n });
635+ if let Some(op) = e.vim.op.take() {
636+ let to = e.cursor;
637+ e.cursor = from;
638+ apply(e, op, from, to, Kind::Excl);
639+ }
483640 }
484641 _ => e.vim.op = None,
485642 },
@@ -518,9 +675,9 @@ fn apply(e: &mut Editor, op: char, from: Pos, to: Pos, kind: Kind) {
518675 let (l1, l2) = (s.line, en.line);
519676 match op {
520677 'd' => {
678+ // cut, so a nested block's title line put back moves the block (§5.2)
521679 e.checkpoint();
522− let t = e.delete_lines(l1, l2);
523− e.copy(t, true);
680+ e.cut_lines(l1, l2);
524681 e.cursor = Pos::new(e.cursor.line, e.first_non_blank(e.cursor.line));
525682 }
526683 'y' => {
@@ -587,11 +744,17 @@ fn put(e: &mut Editor, after: bool, n: usize) {
587744 if clip.text.is_empty() {
588745 return;
589746 }
747+ if n > 1 && clip.text.len().saturating_mul(n) > MAX_TEXT {
748+ e.message = Some("text too long".into());
749+ return;
750+ }
590751 e.checkpoint();
591752 let text = clip.text.repeat(n.max(1));
592753 if clip.linewise {
754+ // one put of cut lines moves a nested block they hold (§5.2);
755+ // more are copies
593756 let l = e.cursor.line;
594− let first = e.put_lines(l, &text, after);
757+ let first = if n > 1 { e.put_lines(l, &text, after) } else { e.put_clip_lines(l, after) };
595758 e.cursor = Pos::new(first, e.first_non_blank(first));
596759 } else {
597760 let at = if after { Pos::new(e.cursor.line, (e.cursor.col + 1).min(e.len(e.cursor.line))) } else { e.cursor };
@@ -643,10 +806,16 @@ fn visual_cmd(e: &mut Editor, c: char, n: usize) -> Outcome {
643806 let clip = e.clip.clone();
644807 let (a, b) = e.selection().unwrap_or((s, en));
645808 e.checkpoint();
646− let gone = e.delete(a, b);
809+ // in V-LINE the lines' text goes and one line is left for the new
810+ // text, so whole lines go in without their last newline, and the
811+ // lines replaced are kept whole
812+ let mut gone = e.delete(a, b);
813+ if line {
814+ gone.push('\n');
815+ }
647816 e.cursor = a;
648− let end = e.insert(a, clip.text.strip_suffix('\n').filter(|_| clip.linewise && !line).unwrap_or(&clip.text));
649− e.cursor = e.prev(end).unwrap_or(end);
817+ let end = e.insert(a, clip.text.strip_suffix('\n').filter(|_| clip.linewise).unwrap_or(&clip.text));
818+ e.cursor = if line { Pos::new(a.line, e.first_non_blank(a.line)) } else { e.prev(end).unwrap_or(end) };
650819 e.copy(gone, line);
651820 exit(e);
652821 }
@@ -750,6 +919,17 @@ mod tests {
750919 assert_eq!(body(&e), "abc DEF\nghi\njkl");
751920 }
752921
922+ #[test]
923+ fn dot_after_typing_in_insert_mode_no_key_entered() {
924+ // the app can start the editor typing (`n`/`N`, a re-render while
925+ // typing): `.` types the text again, never runs it as commands
926+ let (_d, mut e) = editor("x\n", Keys::Vim);
927+ e.mode = Mode::Insert;
928+ e.cursor = Pos::new(2, 1);
929+ keys(&mut e, "Dog<Esc>.");
930+ assert_eq!(body(&e), "xDogDog");
931+ }
932+
753933 #[test]
754934 fn ex_commands() {
755935 let (_d, mut e) = editor("x\n", Keys::Vim);
crates/fold-tui/src/app/markdown.rs+49−18
@@ -5,31 +5,59 @@
55use super::ui::theme;
66use ratatui::style::{Modifier, Style};
77use ratatui::text::{Line, Span};
8−use unicode_width::UnicodeWidthStr;
98
10−/// A styled line and the display columns of its clickable parts.
9+/// A styled line and where its clickable parts are, as character columns
10+/// of the styled text: the reading pane knows where each character is drawn
11+/// (wide characters, tabs, wrapped rows), so it maps them to the screen.
1112pub struct Styled {
1213 pub line: Line<'static>,
1314 /// Column of the checkbox glyph, if the line is a task.
14− pub check: Option<u16>,
15+ pub check: Option<usize>,
1516 /// Columns `[start, end)` of the first link, if any.
16− pub link: Option<(u16, u16)>,
17+ pub link: Option<(usize, usize)>,
1718}
1819
19−/// Style one line of the reading pane. `in_fence` is whether the line sits
20−/// inside a fenced code block (the caller tracks fences).
21−pub fn style_line(l: &str, in_fence: bool) -> Styled {
20+/// Where a line stands to fenced code (§10.9).
21+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
22+pub enum Code {
23+ /// Markdown.
24+ Text,
25+ /// A fence that opens or closes a block.
26+ Fence,
27+ /// A line inside a block.
28+ Inside,
29+}
30+
31+/// Where each of a run of lines stands to fenced code, reading fences as
32+/// the parser does (§3.3), so what is drawn as code is what is parsed as
33+/// code: `` ```a`b `` is inline code, and ```` ``` x ```` closes nothing.
34+pub fn fences<'a>(lines: impl IntoIterator<Item = &'a str>) -> Vec<Code> {
35+ let mut open = None;
36+ lines
37+ .into_iter()
38+ .map(|l| match fold_core::parse::fence_transition(l, &mut open) {
39+ true => Code::Fence,
40+ false if open.is_some() => Code::Inside,
41+ false => Code::Text,
42+ })
43+ .collect()
44+}
45+
46+/// Style one line of the reading pane; `code` is where it stands to fenced
47+/// code (the caller tracks fences with `fences`).
48+pub fn style_line(l: &str, code: Code) -> Styled {
2249 let mut out = Out::default();
2350 let indent_len = l.len() - l.trim_start().len();
2451 let (indent, rest) = l.split_at(indent_len);
2552 out.push(indent, Style::default());
2653
27− if in_fence {
54+ if code == Code::Inside {
2855 out.push(rest, Style::default().fg(theme::CODE));
2956 return out.finish();
3057 }
31− if rest.starts_with("```") || rest.starts_with("~~~") {
32− let n = rest.chars().take_while(|&c| c == '`' || c == '~').count();
58+ if code == Code::Fence {
59+ let fc = rest.chars().next();
60+ let n = rest.chars().take_while(|&c| Some(c) == fc).count();
3361 out.push(&rest[..n], Style::default().fg(theme::DIM));
3462 out.push(&rest[n..], Style::default().fg(theme::ACCENT).add_modifier(Modifier::ITALIC));
3563 return out.finish();
@@ -80,9 +108,9 @@ pub fn style_line(l: &str, in_fence: bool) -> Styled {
80108#[derive(Default)]
81109struct Out {
82110 spans: Vec<Span<'static>>,
83− col: u16,
84− check: Option<u16>,
85− link: Option<(u16, u16)>,
111+ col: usize,
112+ check: Option<usize>,
113+ link: Option<(usize, usize)>,
86114}
87115
88116impl Out {
@@ -90,7 +118,7 @@ impl Out {
90118 if s.is_empty() {
91119 return;
92120 }
93− self.col += s.width() as u16;
121+ self.col += s.chars().count();
94122 self.spans.push(Span::styled(s.to_string(), style));
95123 }
96124
@@ -216,19 +244,22 @@ mod tests {
216244
217245 #[test]
218246 fn nothing_is_hidden_but_checkboxes_become_glyphs() {
219− let s = style_line("- [ ] Buy **milk** and `oat`", false);
247+ let s = style_line("- [ ] Buy **milk** and `oat`", Code::Text);
220248 assert_eq!(text(&s), "- ☐ Buy **milk** and `oat`");
221249 assert_eq!(s.check, Some(2));
222− let s = style_line("#### [x] Snapshot policy", false);
250+ let s = style_line("#### [x] Snapshot policy", Code::Text);
223251 assert_eq!(text(&s), "#### ☑ Snapshot policy");
224252 assert_eq!(s.check, Some(5));
225253 }
226254
227255 #[test]
228256 fn links_report_their_columns() {
229− let s = style_line("see [docs](https://x.org) now", false);
257+ let s = style_line("see [docs](https://x.org) now", Code::Text);
230258 assert_eq!(s.link, Some((5, 9)));
231− let s = style_line(" https://a.b/c tail", false);
259+ let s = style_line(" https://a.b/c tail", Code::Text);
232260 assert_eq!(s.link, Some((2, 15)));
261+ // character columns, however wide the characters are drawn
262+ let s = style_line("漢字 [docs](x)", Code::Text);
263+ assert_eq!(s.link, Some((4, 8)));
233264 }
234265}
crates/fold-tui/src/app/mouse.rs+52−31
@@ -94,14 +94,21 @@ impl App {
9494 Hit::Fold(i) => {
9595 if let Some(row) = self.rows().get(i) {
9696 let r = row.nref;
97+ let sel = self.current();
9798 self.toggle_fold(r);
99+ // a fold click only folds (§10.1): the selection stays on
100+ // its node, or lands on the folded row that now hides it
101+ self.cursor = i;
102+ if let Some(s) = sel {
103+ self.move_cursor_to(s);
104+ }
98105 self.clamp_cursor();
99106 }
100107 }
101108 Hit::Check(i) => {
102109 self.focus_pane(Focus::Outline);
103110 self.cursor = i;
104− self.act_toggle_task();
111+ self.run_action(super::Action::ToggleDone);
105112 }
106113 Hit::RowMenu(i) => {
107114 self.cursor = i;
@@ -148,7 +155,7 @@ impl App {
148155 Hit::PickRow(i) => {
149156 if let Some(mut p) = self.prompt.take() {
150157 p.sel = i;
151− self.accept_prompt(p);
158+ self.accept_prompt_saving_editor(p);
152159 }
153160 }
154161 Hit::FilterRow(i) => self.pick_filter(i),
@@ -207,12 +214,17 @@ impl App {
207214 self.outline_scroll += 1;
208215 }
209216 let rows = self.rows();
210− let target = self
211− .ui
212− .rows_geom
213− .iter()
214− .find(|(_, gy, _)| *gy == y)
215− .map(|&(j, _, title_x)| (j, if x < title_x { Drop::Before } else { Drop::Into }));
217+ // only over an outline row (§10.1): released anywhere else, over the
218+ // reading pane or a border, the drag does nothing
219+ let target = match self.ui.hit_at(x, y) {
220+ Some(Hit::Row(j) | Hit::Fold(j) | Hit::Check(j) | Hit::RowMenu(j)) => self
221+ .ui
222+ .rows_geom
223+ .iter()
224+ .find(|(gj, _, _)| *gj == j)
225+ .map(|&(j, _, title_x)| (j, if x < title_x { Drop::Before } else { Drop::Into })),
226+ _ => None,
227+ };
216228 self.ui.drop = target.filter(|(j, _)| *j != from);
217229 let name = |i: usize| rows.get(i).map(|r| self.vault.tree.node(r.nref).title.clone()).unwrap_or_default();
218230 let msg = match self.ui.drop {
@@ -238,38 +250,47 @@ impl App {
238250 }
239251 let rows = self.rows();
240252 let (Some(from), Some(to)) = (press.row.and_then(|i| rows.get(i)), rows.get(j)) else { return };
241− let (r, target) = (from.nref, to.nref);
242− let title = self.vault.tree.node(self.vault.tree.resolved_child(r)).title.clone();
243− let target_key = self.vault.key_of(self.vault.tree.resolved_child(target));
253+ // a drop is an outline verb: the editor saves first (§10.6), which
254+ // re-parses what it wrote, so both nodes are found again by key
255+ let keys = (self.vault.key_of(from.nref), self.vault.key_of(to.nref));
256+ let edit = self.editor_before_write("outline verb");
257+ let (Some(r), Some(target)) = (self.find_exact(&keys.0), self.find_exact(&keys.1)) else {
258+ self.say("can't move: the outline changed");
259+ return;
260+ };
261+ let (rr, rt) = (self.vault.tree.resolved_child(r), self.vault.tree.resolved_child(target));
262+ let (key, kind) = (self.vault.key_of(rr), self.vault.tree.node(rr).kind);
263+ let title = self.vault.tree.node(rr).title.clone();
264+ // it goes into the target, last, or just before it, under the
265+ // target's parent
266+ let (dest, rank) = match how {
267+ Drop::Into => (rt, None),
268+ Drop::Before => {
269+ let p = self.outline_parent(rt).unwrap_or(self.vault.tree.root);
270+ let at = self.vault.tree.resolved_children(p).iter().position(|&c| c == rt);
271+ (p, at.map(|at| self.namesakes_before(rr, p, at)))
272+ }
273+ };
274+ let dest = self.vault.key_of(dest);
275+ let on = self.on_node(r);
244276 self.push_undo("move");
245277 match ops::move_node(&mut self.vault, r, target, how) {
246278 Ok(moved) => {
247279 self.say(super::with_rule_note(&format!("moved “{}”", title), moved));
248− // find it where it landed: among the target's children, or
249− // its siblings, nearest the target
250− let landed = self.vault.find_by_key(&target_key).and_then(|t| {
251− let pool = match how {
252− Drop::Into => self.vault.tree.resolved_children(t),
253− Drop::Before => self.parent_children(t),
254− };
255− pool.into_iter().rev().find(|&c| self.vault.tree.node(c).title == title)
256− });
257− match landed {
258− Some(n) => self.reveal(n),
280+ // find it where it landed, by its kind and place too: a
281+ // namesake of it may be there already
282+ match self.moved_node(&key, kind, &dest, rank) {
283+ Some(n) => {
284+ // the editor open on it follows it (§10.6)
285+ self.follow(on, n);
286+ self.reveal(n);
287+ }
259288 None => self.clamp_cursor(),
260289 }
261290 }
262291 Err(e) => self.say(format!("can't move: {}", e)),
263292 }
264− }
265−
266− /// A node's siblings (itself included) in the resolved tree.
267− fn parent_children(&self, t: fold_core::tree::NRef) -> Vec<fold_core::tree::NRef> {
268− let chain = self.chain(t);
269− match chain.len() {
270− 0 | 1 => self.vault.tree.resolved_children(self.vault.tree.root),
271− n => self.vault.tree.resolved_children(chain[n - 2]),
272− }
293+ self.editor_after_write(edit);
273294 }
274295
275296 fn mouse_right(&mut self, x: u16, y: u16) {
crates/fold-tui/src/app/ui.rs+89−60
@@ -2,12 +2,13 @@
22//! is drawn, so what the pointer can do is exactly what is on screen.
33
44use super::action::{Action, NODE_MENU};
5−use super::markdown::style_line;
5+use super::markdown::{fences, style_line, Code};
66use super::{App, Focus, Mode, PromptAction};
77use fold_core::ops::Drop;
88use fold_core::parse::{Kind, TaskState};
99use fold_core::render::render;
1010use fold_core::tree::NRef;
11+use fold_core::vault::NodeKey;
1112use ratatui::buffer::Buffer;
1213use ratatui::layout::Rect;
1314use ratatui::style::{Modifier, Style};
@@ -71,10 +72,12 @@ pub enum Hit {
7172 PropDelete(usize),
7273}
7374
74−/// The node menu: which node, where, and the highlighted item.
75−#[derive(Debug, Clone, Copy)]
75+/// The node menu: which node, where, and the highlighted item. The node is
76+/// kept by its key (§3.4), since files can change under an open menu — a
77+/// reload, an editor save — and renumber every node (§11.2).
78+#[derive(Debug, Clone)]
7679pub struct Menu {
77− pub target: NRef,
80+ pub target: NodeKey,
7881 pub x: u16,
7982 pub y: u16,
8083 pub sel: usize,
@@ -188,26 +191,35 @@ fn fit(s: &str, w: usize) -> String {
188191 out
189192}
190193
194+/// A title as drawn: the terminal drops a tab, so it shows as the spaces
195+/// to its next stop, counted from the title's start (raw text is never
196+/// hidden, §10.9).
197+fn title_text(title: &str) -> String {
198+ super::wrap::shown(title, 0, usize::MAX)
199+}
200+
191201/// One screen row of the reading pane.
192202struct Drawn {
193203 doc: Option<usize>,
194204 line: Line<'static>,
195205 code: bool,
196− check: Option<u16>,
197− link: Option<(u16, u16)>,
206+ check: Option<usize>,
207+ link: Option<(usize, usize)>,
198208}
199209
200210/// Break a styled line into screen rows at `cols` columns (§10.1): each row
201−/// its own `Line`, continuation rows indented, and code rows that go on
202−/// ending in `↪`.
211+/// its own `Line`, continuation rows indented, code rows that go on ending
212+/// in `↪`, and tabs as the spaces to their tab stop (the terminal would
213+/// drop them).
203214fn wrap_styled(line: &Line<'static>, cols: usize, hard: bool) -> Vec<(Line<'static>, super::wrap::Row)> {
204215 let cells: Vec<(char, Style)> = line.spans.iter().flat_map(|s| s.content.chars().map(move |c| (c, s.style))).collect();
205216 let text: String = cells.iter().map(|c| c.0).collect();
206217 let rows = super::wrap::wrap(&text, cols, hard);
207218 let n = rows.len();
208− if n == 1 {
219+ if n == 1 && !text.contains('\t') {
209220 return vec![(line.clone(), rows[0])];
210221 }
222+ let xs = super::wrap::columns(&text);
211223 rows.iter()
212224 .enumerate()
213225 .map(|(i, r)| {
@@ -217,12 +229,16 @@ fn wrap_styled(line: &Line<'static>, cols: usize, hard: bool) -> Vec<(Line<'stat
217229 }
218230 let mut run = String::new();
219231 let mut style = None;
220− for &(c, st) in &cells[r.start..r.end] {
232+ for (k, &(c, st)) in cells.iter().enumerate().take(r.end).skip(r.start) {
221233 if style != Some(st) && !run.is_empty() {
222234 spans.push(Span::styled(std::mem::take(&mut run), style.unwrap_or_default()));
223235 }
224236 style = Some(st);
225− run.push(c);
237+ if c == '\t' {
238+ run.push_str(&" ".repeat(xs[k + 1] - xs[k]));
239+ } else {
240+ run.push(c);
241+ }
226242 }
227243 if !run.is_empty() {
228244 spans.push(Span::styled(run, style.unwrap_or_default()));
@@ -416,7 +432,8 @@ impl App {
416432 buf.set_style(area, Style::default().bg(theme::BAR));
417433 let actions = [Action::Filter, Action::Capture, Action::ReadingPane, Action::Undo, Action::Redo, Action::Palette, Action::Help];
418434 let crumbs = self.crumbs();
419− let crumb_w: u16 = crumbs.iter().map(|&c| self.vault.tree.node(c).title.width() as u16 + 3).sum::<u16>() + 6;
435+ let titles: Vec<String> = crumbs.iter().map(|&c| title_text(&self.vault.tree.node(c).title)).collect();
436+ let crumb_w: u16 = titles.iter().map(|t| t.width() as u16 + 3).sum::<u16>() + 6;
420437 let room = area.width.saturating_sub(crumb_w.min(area.width / 2));
421438 let right = area.x + area.width;
422439 let start = self.buttons_right(buf, right, area.y, &actions, None, room);
@@ -428,7 +445,6 @@ impl App {
428445 self.ui.push(home, Hit::Crumb(None));
429446 x += 4;
430447 let limit = start.saturating_sub(1);
431− let titles: Vec<String> = crumbs.iter().map(|&c| self.vault.tree.node(c).title.clone()).collect();
432448 let total: u16 = titles.iter().map(|t| t.width() as u16 + 3).sum();
433449 let mut skip = 0;
434450 let mut need = total;
@@ -608,7 +624,7 @@ impl App {
608624 if dragging_from == Some(vi) {
609625 style = style.add_modifier(Modifier::DIM);
610626 }
611− let title = if n.title.is_empty() { "(untitled)".to_string() } else { n.title.clone() };
627+ let title = if n.title.is_empty() { "(untitled)".to_string() } else { title_text(&n.title) };
612628 let marker = if n.is_block() || n.is_embed() { " ▤" } else { "" };
613629 let shown = fit(&format!("{}{}", title, marker), title_room as usize);
614630 put(buf, title_x, y, &shown, title_room, style);
@@ -679,7 +695,7 @@ impl App {
679695 }
680696 if let Some(r) = r {
681697 let n = self.vault.tree.node(r);
682− let t = if n.kind == Kind::Root { "fold".into() } else { n.title.clone() };
698+ let t = if n.kind == Kind::Root { "fold".into() } else { title_text(&n.title) };
683699 spans.push(Span::styled(t, Style::default().add_modifier(Modifier::BOLD)));
684700 if n.is_block() {
685701 spans.push(Span::styled(" ▤", Style::default().fg(theme::DIM)));
@@ -733,7 +749,8 @@ impl App {
733749 self.ui.reading_view = view;
734750 // every line styled, then laid out in screen rows (§10.1)
735751 let texts: Vec<&str> = shown.iter().map(|(_, t)| t.as_str()).collect();
736− let code = self.code_lines(&texts);
752+ let fenced = fences(texts.iter().copied());
753+ let code = self.code_lines(&texts, &fenced);
737754 let cols = if self.wrap { inner.width as usize } else { usize::MAX / 2 };
738755 let mut rows: Vec<Drawn> = Vec::new();
739756 for (si, (doc_line, text)) in shown.iter().enumerate() {
@@ -746,19 +763,28 @@ impl App {
746763 } else {
747764 match &code[si] {
748765 Some(spans) => super::markdown::Styled { line: Line::from(spans.clone()), check: None, link: None },
749− None => style_line(text, false),
766+ None => style_line(text, fenced[si]),
750767 }
751768 };
752769 let parts = wrap_styled(&styled.line, cols, code[si].is_some());
770+ // where each character is drawn: the clickable parts are
771+ // character columns, and wide characters and tabs take more
772+ let xs = if styled.check.is_some() || styled.link.is_some() {
773+ super::wrap::columns(&styled.line.spans.iter().map(|s| s.content.as_ref()).collect::<String>())
774+ } else {
775+ Vec::new()
776+ };
753777 for (line, row) in parts {
754− let within = |c: u16| (c as usize) >= row.start && (c as usize) < row.end.max(row.start + 1);
755− let at = |c: u16| (row.indent + c as usize - row.start) as u16;
778+ let within = |c: usize| c >= row.start && c < row.end.max(row.start + 1);
779+ // a link wrapped onto the next row is a link there too
780+ let on = |(a, b): (usize, usize)| within(a) || (a < row.start && b > row.start);
781+ let at = |c: usize| row.indent + xs[c.clamp(row.start, row.end)] - xs[row.start];
756782 rows.push(Drawn {
757783 doc: *doc_line,
758784 line,
759785 code: code[si].is_some(),
760786 check: styled.check.filter(|c| within(*c)).map(at),
761− link: styled.link.filter(|(a, _)| within(*a)).map(|(a, b)| (at(a), at((b as usize).min(row.end) as u16))),
787+ link: styled.link.filter(|l| on(*l)).map(|(a, b)| (at(a), at(b))),
762788 });
763789 }
764790 }
@@ -791,11 +817,13 @@ impl App {
791817 buf.set_line(inner.x, y, &d.line, inner.width);
792818 let Some(di) = d.doc else { continue };
793819 self.ui.push(line_rect, Hit::DocLine(di));
794− if let Some(c) = d.check {
795− self.ui.push(Rect { x: inner.x + c, y, width: 1, height: 1 }, Hit::DocCheck(di));
820+ // only what is on screen: a line cut at the edge can be far wider
821+ let w = inner.width as usize;
822+ if let Some(c) = d.check.filter(|c| *c < w) {
823+ self.ui.push(Rect { x: inner.x + c as u16, y, width: 1, height: 1 }, Hit::DocCheck(di));
796824 }
797− if let Some((a, b)) = d.link {
798− self.ui.push(Rect { x: inner.x + a, y, width: b.saturating_sub(a).max(1), height: 1 }, Hit::Link(di));
825+ if let Some((a, b)) = d.link.filter(|(a, _)| *a < w) {
826+ self.ui.push(Rect { x: inner.x + a as u16, y, width: b.min(w).saturating_sub(a).max(1) as u16, height: 1 }, Hit::Link(di));
799827 }
800828 }
801829 if rows.len() > view {
@@ -810,33 +838,23 @@ impl App {
810838
811839 /// The lines inside fenced code blocks, styled: highlighted by the
812840 /// fence's language (§10.9), else in the plain code colour. `None` for
813− /// every line that is not code, fences included.
814− fn code_lines(&mut self, texts: &[&str]) -> Vec<Option<Vec<Span<'static>>>> {
841+ /// every line that is not code, fences included. `fences` is where
842+ /// each line stands to fenced code, as the parser reads it.
843+ fn code_lines(&mut self, texts: &[&str], fences: &[Code]) -> Vec<Option<Vec<Span<'static>>>> {
815844 let mut out: Vec<Option<Vec<Span<'static>>>> = vec![None; texts.len()];
816845 let mut i = 0;
817846 while i < texts.len() {
818− let t = texts[i].trim_start();
819− let indent = texts[i].len() - t.len();
820− let fc = match t.chars().next() {
821− Some(c @ ('`' | '~')) => c,
822− _ => {
823− i += 1;
824− continue;
825− }
826− };
827− let n = t.chars().take_while(|&c| c == fc).count();
828− if n < 3 {
847+ if fences[i] != Code::Fence {
829848 i += 1;
830849 continue;
831850 }
851+ let t = texts[i].trim_start();
852+ let indent = texts[i].len() - t.len();
853+ let fc = t.chars().next();
854+ let n = t.chars().take_while(|&c| Some(c) == fc).count();
832855 let info = t[n..].trim().to_string();
833− // the block runs to a fence of the same character, at least as long
834− let end = (i + 1..texts.len())
835− .find(|&j| {
836− let u = texts[j].trim_start();
837− u.chars().take_while(|&c| c == fc).count() >= n && u.trim_end().chars().all(|c| c == fc)
838− })
839− .unwrap_or(texts.len());
856+ // the block runs to its closing fence, or to the end
857+ let end = (i + 1..texts.len()).find(|&j| fences[j] != Code::Inside).unwrap_or(texts.len());
840858 let body: Vec<&str> = texts[i + 1..end]
841859 .iter()
842860 .map(|l| {
@@ -881,7 +899,7 @@ impl App {
881899 fn draw_editor(&mut self, f: &mut Frame, area: Rect) {
882900 let Some(ed) = &self.editor else { return };
883901 let owner = ed.buf.owner_at(ed.cursor.line);
884− let owner_title = ed.buf.owners.get(&owner).map(|o| o.title.clone()).unwrap_or_default();
902+ let owner_title = ed.buf.owners.get(&owner).map(|o| title_text(&o.title)).unwrap_or_default();
885903 let mut title = vec![
886904 Span::styled(" Editing ", Style::default().fg(theme::ACCENT)),
887905 Span::styled(owner_title, Style::default().add_modifier(Modifier::BOLD)),
@@ -921,7 +939,8 @@ impl App {
921939 (None, Some(m)) => Some((m.clone(), false)),
922940 _ => None,
923941 };
924− let view = inner.height as usize - bottom.is_some() as usize;
942+ // a short terminal can leave the pane no inner rows at all
943+ let view = (inner.height as usize).saturating_sub(bottom.is_some() as usize);
925944 let (sel, block_cur) = (ed.selection(), ed.block_cursor());
926945 let wrap = self.wrap;
927946 let ed = self.editor.as_mut().unwrap();
@@ -952,7 +971,7 @@ impl App {
952971 // context
953972 let style = if *own { Style::default() } else { Style::default().fg(ratatui::style::Color::Gray) };
954973 let chars: Vec<char> = text.chars().collect();
955− let part: String = chars[row.start..row.end].iter().collect();
974+ let part = super::wrap::shown(text, row.start, row.end);
956975 let x0 = inner.x + row.indent as u16;
957976 put(buf, x0, y, &part, inner.width.saturating_sub(row.indent as u16), style);
958977 if codes[*l] && !last {
@@ -963,17 +982,18 @@ impl App {
963982 let from = if *l == s.line { s.col } else { 0 }.max(row.start);
964983 // a selected line end shows as one cell, on the last row
965984 let to = if *l == e.line { e.col } else { chars.len() + 1 };
966− let to = if *last { to } else { to.min(row.end) };
967− for col in from..to {
968− let x = x0 + (col - row.start) as u16;
969− if x < inner.x + inner.width {
970− buf[(x, y)].set_style(Style::default().bg(theme::SEL));
971− }
985+ let to = if *last { to } else { to.min(row.end) }.max(from);
986+ // every cell those characters are drawn over: a wide
987+ // character or a tab takes more than one
988+ let (xs, n) = (super::wrap::columns(text), chars.len());
989+ let x = |c: usize| x0 as usize + xs[c.min(n)] - xs[row.start] + c.saturating_sub(n);
990+ for cx in x(from)..x(to).min((inner.x + inner.width) as usize) {
991+ buf[(cx as u16, y)].set_style(Style::default().bg(theme::SEL));
972992 }
973993 }
974994 }
975995 }
976− if let Some((text, input)) = bottom {
996+ if let Some((text, input)) = bottom.filter(|_| inner.height > 0) {
977997 let y = inner.y + inner.height - 1;
978998 let style = if input { Style::default() } else { Style::default().fg(theme::WARN) };
979999 buf.set_style(Rect { x: inner.x, y, width: inner.width, height: 1 }, Style::default().bg(theme::BAR));
@@ -1047,7 +1067,12 @@ impl App {
10471067 }
10481068
10491069 fn draw_menu(&mut self, f: &mut Frame, screen: Rect) {
1050− let Some(menu) = self.ui.menu else { return };
1070+ let Some(menu) = self.ui.menu.clone() else { return };
1071+ let Some(target) = self.menu_target() else {
1072+ // its node is gone, the files changed under it: the menu closes
1073+ self.ui.menu = None;
1074+ return;
1075+ };
10511076 let w: u16 = 28;
10521077 let h = NODE_MENU.len() as u16 + 2;
10531078 let x = menu.x.min(screen.x + screen.width.saturating_sub(w));
@@ -1055,7 +1080,7 @@ impl App {
10551080 let r = Rect { x, y, width: w, height: h }.intersection(screen);
10561081 self.ui.push(screen, Hit::Backdrop);
10571082 f.render_widget(Clear, r);
1058− let title = self.vault.tree.node(menu.target).title.clone();
1083+ let title = title_text(&self.vault.tree.node(target).title);
10591084 let block = rounded(Line::from(Span::styled(format!(" {} ", fit(&title, 20)), Style::default().add_modifier(Modifier::BOLD))), true);
10601085 let inner = block.inner(r);
10611086 f.render_widget(block, r);
@@ -1077,7 +1102,7 @@ impl App {
10771102 m.sel = i;
10781103 }
10791104 }
1080− let sel = self.ui.menu.map(|m| m.sel == i).unwrap_or(false);
1105+ let sel = self.ui.menu.as_ref().map(|m| m.sel == i).unwrap_or(false);
10811106 let base = if sel { Style::default().bg(theme::SEL) } else { Style::default() };
10821107 buf.set_style(row, base);
10831108 let label_style = if *a == Action::Delete { base.fg(theme::DANGER) } else { base };
@@ -1149,7 +1174,7 @@ impl App {
11491174
11501175 /// A list row for a node: its title, then its parents dimmed.
11511176 fn path_spans(&self, r: NRef) -> Vec<(String, Style)> {
1152− let path = self.path_titles(r);
1177+ let path: Vec<String> = self.path_titles(r).iter().map(|t| title_text(t)).collect();
11531178 let (last, parents) = path.split_last().map(|(l, p)| (l.clone(), p.join(" › "))).unwrap_or_default();
11541179 let last = if last.is_empty() { "(untitled)".into() } else { last };
11551180 vec![
@@ -1193,7 +1218,7 @@ impl App {
11931218 }
11941219
11951220 fn draw_props(&mut self, f: &mut Frame, screen: Rect) {
1196− let title = self.props_target.map(|t| self.vault.tree.node(t).title.clone()).unwrap_or_default();
1221+ let title = self.props_target.map(|t| title_text(&self.vault.tree.node(t).title)).unwrap_or_default();
11971222 let h = (self.props_rows.len() as u16 + 4).max(5);
11981223 let r = Self::centered(screen, 64, h);
11991224 let inner = self.popup(f, screen, r, &format!("Properties — {}", fit(&title, 30)), &[Action::PropAdd, Action::Close]);
@@ -1261,7 +1286,11 @@ impl App {
12611286 ] {
12621287 let text = render(&self.vault.tree, r, 1, true);
12631288 let block = WBlock::default().borders(Borders::ALL).border_type(BorderType::Rounded).border_style(Style::default().fg(color)).title(title);
1264− let lines: Vec<Line> = text.lines().map(|l| style_line(l, false).line).collect();
1289+ let lines: Vec<Line> = text
1290+ .lines()
1291+ .zip(fences(text.lines()))
1292+ .map(|(l, code)| style_line(&super::wrap::shown(l, 0, usize::MAX), code).line)
1293+ .collect();
12651294 f.render_widget(Paragraph::new(lines).block(block), rect);
12661295 }
12671296 }
crates/fold-tui/src/app/wrap.rs+47−10
@@ -40,8 +40,32 @@ pub fn hang(text: &str) -> usize {
4040 h
4141}
4242
43−fn width(c: char) -> usize {
44− UnicodeWidthChar::width(c).unwrap_or(0)
43+/// Tab stops are every 4 columns, as CommonMark and fold-core count them.
44+pub const TAB: usize = 4;
45+
46+/// Where each character of a line starts on screen, counted from the line's
47+/// start, then where the line ends. A tab reaches the next tab stop and is
48+/// drawn as that many spaces (raw text is never hidden, §10.9); anything
49+/// else takes its display width.
50+pub fn columns(text: &str) -> Vec<usize> {
51+ let mut xs = vec![0];
52+ let mut x = 0;
53+ for c in text.chars() {
54+ x += if c == '\t' { TAB - x % TAB } else { UnicodeWidthChar::width(c).unwrap_or(0) };
55+ xs.push(x);
56+ }
57+ xs
58+}
59+
60+/// Characters `[start, end)` of a line as drawn: tabs become spaces.
61+pub fn shown(text: &str, start: usize, end: usize) -> String {
62+ let xs = columns(text);
63+ text.chars()
64+ .enumerate()
65+ .skip(start)
66+ .take(end.saturating_sub(start))
67+ .map(|(i, c)| if c == '\t' { " ".repeat(xs[i + 1] - xs[i]) } else { c.to_string() })
68+ .collect()
4569}
4670
4771/// The rows of a line at `cols` columns. `hard` breaks anywhere and keeps
@@ -49,7 +73,9 @@ fn width(c: char) -> usize {
4973/// space when they can. A line always has at least one row.
5074pub fn wrap(text: &str, cols: usize, hard: bool) -> Vec<Row> {
5175 let chars: Vec<char> = text.chars().collect();
52− if cols < 4 || chars.iter().map(|c| width(*c)).sum::<usize>() <= cols {
76+ let xs = columns(text);
77+ let width = |i: usize| xs[i + 1] - xs[i];
78+ if cols < 4 || xs[chars.len()] <= cols {
5379 return vec![Row { start: 0, end: chars.len(), indent: 0 }];
5480 }
5581 let indent = if hard { 0 } else { hang(text).min(cols / 2) };
@@ -61,8 +87,8 @@ pub fn wrap(text: &str, cols: usize, hard: bool) -> Vec<Row> {
6187 // how many characters fit
6288 let mut w = 0;
6389 let mut i = start;
64− while i < chars.len() && w + width(chars[i]) <= avail {
65− w += width(chars[i]);
90+ while i < chars.len() && w + width(i) <= avail {
91+ w += width(i);
6692 i += 1;
6793 }
6894 let end = if i >= chars.len() {
@@ -86,21 +112,21 @@ pub fn wrap(text: &str, cols: usize, hard: bool) -> Vec<Row> {
86112/// The row and screen column of a character column, given a line's rows.
87113/// A column at a row's end belongs to the next row, except at the line's end.
88114pub fn locate(rows: &[Row], text: &str, col: usize) -> (usize, usize) {
89− let chars: Vec<char> = text.chars().collect();
115+ let xs = columns(text);
90116 let r = rows.iter().rposition(|r| col >= r.start).unwrap_or(0);
91117 let row = rows[r];
92− let x = row.indent + chars[row.start..col.min(chars.len()).max(row.start)].iter().map(|c| width(*c)).sum::<usize>();
118+ let x = row.indent + xs[col.min(xs.len() - 1).max(row.start)] - xs[row.start];
93119 (r, x)
94120}
95121
96122/// The character column under screen column `x` of row `r`.
97123pub fn column_at(rows: &[Row], text: &str, r: usize, x: usize) -> usize {
98− let chars: Vec<char> = text.chars().collect();
124+ let xs = columns(text);
99125 let row = rows[r.min(rows.len() - 1)];
100126 let mut w = row.indent;
101127 let mut col = row.start;
102− while col < row.end && w + width(chars[col]) <= x {
103− w += width(chars[col]);
128+ while col < row.end && w + xs[col + 1] - xs[col] <= x {
129+ w += xs[col + 1] - xs[col];
104130 col += 1;
105131 }
106132 // the last row reaches the line's end; earlier rows stop before theirs
@@ -149,4 +175,15 @@ mod tests {
149175 let (r, _) = locate(&rows, t, t.len());
150176 assert_eq!(r, rows.len() - 1);
151177 }
178+
179+ #[test]
180+ fn tabs_reach_the_next_tab_stop() {
181+ assert_eq!(columns("\tx"), [0, 4, 5]);
182+ assert_eq!(columns("ab\tc"), [0, 1, 2, 4, 5]);
183+ assert_eq!(shown("ab\tc", 0, 4), "ab c");
184+ let rows = wrap("ab\tc", 20, false);
185+ assert_eq!(locate(&rows, "ab\tc", 3), (0, 4));
186+ // a click inside the tab lands on it
187+ assert_eq!(column_at(&rows, "ab\tc", 0, 3), 2);
188+ }
152189}
crates/fold-tui/tests/conflict_watch.rs+133
@@ -177,3 +177,136 @@ fn startup_merge_enters_conflict_view() {
177177 }
178178 assert_eq!(app.mode_pub(), "conflict");
179179}
180+
181+#[test]
182+fn conflict_keys_act_on_the_shown_pair_after_resolving_the_last() {
183+ let pairs = |app: &mut App| fold_core::merge::conflict_pairs(app.vault_mut()).len();
184+ for resolve in ['o', 't', 'b'] {
185+ let dir = tempfile::tempdir().unwrap();
186+ std::fs::write(dir.path().join("root.md"), "# A\n\n- [ ] t1\n- [ ] t2\n").unwrap();
187+ std::fs::write(
188+ dir.path().join("root.sync-conflict-20260912-100000-phone.md"),
189+ "# A\n\n- [x] t1\n- [x] t2\n",
190+ )
191+ .unwrap();
192+ let mut v = fold_core::vault::Vault::open(dir.path()).unwrap();
193+ fold_core::merge::merge_sync_conflicts(&mut v, false).unwrap();
194+ drop(v);
195+ let mut app = App::new(dir.path()).unwrap();
196+ assert_eq!(pairs(&mut app), 2);
197+ app.enter_conflict_view();
198+ // n: pair 2 of 2; resolving it leaves "Conflict 1 of 1" on screen
199+ app.key_conflict_pub(key(KeyCode::Char('n')));
200+ app.key_conflict_pub(key(KeyCode::Char(resolve)));
201+ assert_eq!(pairs(&mut app), 1, "after {}", resolve);
202+ // §10.7: o acts on the pair that is shown
203+ app.key_conflict_pub(key(KeyCode::Char('o')));
204+ assert_eq!(pairs(&mut app), 0, "o after {} did nothing", resolve);
205+ }
206+}
207+
208+#[test]
209+fn editing_a_conflict_pair_before_the_zoom_keeps_the_zoom() {
210+ let titles = |app: &App| -> Vec<String> { app.rows().iter().map(|r| app.title_of(r.nref)).collect() };
211+ let dir = tempfile::tempdir().unwrap();
212+ std::fs::write(dir.path().join("root.md"), "- [ ] task\n- Z\n - z1\n").unwrap();
213+ std::fs::write(
214+ dir.path().join("root.sync-conflict-20260912-100000-phone.md"),
215+ "- [x] task\n- Z\n - z1\n",
216+ )
217+ .unwrap();
218+ let mut v = fold_core::vault::Vault::open(dir.path()).unwrap();
219+ fold_core::merge::merge_sync_conflicts(&mut v, false).unwrap();
220+ drop(v);
221+ let mut app = App::new(dir.path()).unwrap();
222+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
223+ let z = titles(&app).iter().position(|t| t == "Z").unwrap();
224+ for _ in 0..z {
225+ app.handle_key(key(KeyCode::Char('j')));
226+ }
227+ app.handle_key(key(KeyCode::Enter)); // zoom into Z
228+ assert_eq!(titles(&app), ["Z", "z1"]);
229+ // §10.7 `e` edits ours, above Z in the same file: a new item after it
230+ app.enter_conflict_view();
231+ app.handle_key(key(KeyCode::Char('e')));
232+ assert_eq!(app.mode_pub(), "edit");
233+ app.handle_key(key(KeyCode::End));
234+ app.handle_key(key(KeyCode::Enter));
235+ for c in "- new".chars() {
236+ app.handle_key(key(KeyCode::Char(c)));
237+ }
238+ app.handle_key(key(KeyCode::Esc));
239+ let root = std::fs::read_to_string(dir.path().join("root.md")).unwrap();
240+ assert!(root.starts_with("- [ ] task\n- new\n"), "{}", root);
241+ assert_eq!(titles(&app), ["Z", "z1"]);
242+}
243+
244+#[test]
245+fn a_conflict_copy_the_merge_leaves_alone_does_not_reopen_the_conflict_view() {
246+ // notes.md has no id: an ignored file, whose copy merge leaves alone
247+ let (d, mut app) = app_with("# A\n\nbody\n");
248+ std::fs::write(d.path().join("notes.md"), "mine\n").unwrap();
249+ std::fs::write(d.path().join("notes.sync-conflict-20260912-100000-phone.md"), "theirs\n").unwrap();
250+ app.handle_key(key(KeyCode::Char('e')));
251+ assert_eq!(app.mode_pub(), "edit");
252+ app.reload_external();
253+ assert_eq!(app.mode_pub(), "edit");
254+ app.handle_key(key(KeyCode::Esc));
255+ app.reload_external();
256+ assert_eq!(app.mode_pub(), "normal");
257+ // nor do pairs left unresolved (§12.5): only pairs the merge raises
258+ let (d, mut app) = conflict_app();
259+ assert_eq!(fold_core::merge::conflict_pairs(app.vault_mut()).len(), 1);
260+ std::fs::write(d.path().join("notes.md"), "mine\n").unwrap();
261+ std::fs::write(d.path().join("notes.sync-conflict-20260912-100000-phone.md"), "theirs\n").unwrap();
262+ app.reload_external();
263+ assert_eq!(app.mode_pub(), "normal");
264+ // a copy that raises a pair opens it
265+ let text = std::fs::read_to_string(d.path().join("root.md")).unwrap();
266+ std::fs::write(d.path().join("root.md"), format!("{}\n# B\n\nmine\n", text)).unwrap();
267+ std::fs::write(
268+ d.path().join("root.sync-conflict-20260913-100000-phone.md"),
269+ format!("{}\n# B\n\ntheirs\n", text),
270+ )
271+ .unwrap();
272+ app.reload_external();
273+ assert_eq!(fold_core::merge::conflict_pairs(app.vault_mut()).len(), 2);
274+ assert_eq!(app.mode_pub(), "conflict");
275+}
276+
277+#[test]
278+fn a_merge_while_a_refused_edit_is_open_under_a_zoom_does_not_crash() {
279+ let titles = |app: &App| -> Vec<String> { app.rows().iter().map(|r| app.title_of(r.nref)).collect() };
280+ let embeds = " ![[dozzod-binwes-talsun-worbec]]\n ![[lacnum-walbyn-dirlyn-havtyp]]\n ![[racfer-hattes-mislup-nodrys]]\n";
281+ let dir = tempfile::tempdir().unwrap();
282+ let block = |name: &str, id: &str, text: &str| {
283+ std::fs::write(dir.path().join(name), format!("---\nid: {}\n---\n\n{}", id, text)).unwrap();
284+ };
285+ std::fs::write(dir.path().join("root.md"), format!("- Top\n{}- [ ] T\n", embeds)).unwrap();
286+ block("dozzod~b.md", "dozzod-binwes-talsun-worbec", "- B\n");
287+ block("lacnum~l.md", "lacnum-walbyn-dirlyn-havtyp", "- L\n");
288+ block("racfer~z.md", "racfer-hattes-mislup-nodrys", "- Z\n");
289+ let mut app = App::new(dir.path()).unwrap();
290+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
291+ for _ in 0..3 {
292+ app.handle_key(key(KeyCode::Char('j')));
293+ }
294+ app.handle_key(key(KeyCode::Enter)); // zoom into Z, the last file
295+ assert_eq!(titles(&app), ["Z"]);
296+ app.handle_key(key(KeyCode::Char('e')));
297+ app.handle_key(key(KeyCode::End));
298+ app.handle_key(key(KeyCode::Char('x')));
299+ // another device changed Z (the save is refused), deleted B and L, and
300+ // its root.md came back as a sync-conflict copy
301+ block("racfer~z.md", "racfer-hattes-mislup-nodrys", "- Z\n - z1\n");
302+ std::fs::remove_file(dir.path().join("dozzod~b.md")).unwrap();
303+ std::fs::remove_file(dir.path().join("lacnum~l.md")).unwrap();
304+ std::fs::write(
305+ dir.path().join("root.sync-conflict-20260912-100000-phone.md"),
306+ format!("- Top\n{}- [x] T\n", embeds),
307+ )
308+ .unwrap();
309+ app.reload_external();
310+ assert_eq!(app.mode_pub(), "edit");
311+ assert_eq!(titles(&app), ["Z", "z1"]);
312+}
crates/fold-tui/tests/editor.rs+1407
@@ -86,3 +86,1410 @@ fn drag_selects_and_paste_replaces() {
8686 keys(&mut app, "⎋⎋");
8787 assert_eq!(root(&d), "# A\n\ngoodbye world\n");
8888}
89+
90+/// root.md "# A\n\n- one\n![[id]]\n", the embedded block's own file holding
91+/// "- two"; returns the block's file.
92+fn vault_with_bullet_block() -> (tempfile::TempDir, std::path::PathBuf) {
93+ let d = tempfile::tempdir().unwrap();
94+ std::fs::write(d.path().join("root.md"), "# A\n\n- one\n- two\n").unwrap();
95+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
96+ let two = v.find_by_path(&["A".into(), "two".into()]).unwrap();
97+ fold_core::ops::make_block(&mut v, two).unwrap();
98+ drop(v);
99+ let block = std::fs::read_dir(d.path())
100+ .unwrap()
101+ .map(|e| e.unwrap().path())
102+ .find(|p| p.extension().is_some_and(|x| x == "md") && !p.ends_with("root.md"))
103+ .unwrap();
104+ assert!(root(&d).starts_with("# A\n\n- one\n![["), "{}", root(&d));
105+ (d, block)
106+}
107+
108+#[test]
109+fn helix_xyp_above_a_block_leaves_the_block_file_alone() {
110+ // §5.2: a line pasted somewhere takes the tag of the line above it, so
111+ // "- one" duplicated below itself belongs to A, not to the block whose
112+ // title line happens to follow
113+ let (d, block) = vault_with_bullet_block();
114+ let before = std::fs::read_to_string(&block).unwrap();
115+ let mut app = App::new(d.path()).unwrap();
116+ app.set_edit_keys(fold_tui::app::EditKeys::Helix);
117+ keys(&mut app, "ejjxyp:w⏎");
118+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before);
119+ assert!(root(&d).contains("- one\n- one\n![["), "{}", root(&d));
120+}
121+
122+#[test]
123+fn vim_o_above_a_block_leaves_the_block_file_alone() {
124+ // §5.2: a line typed after a tagged line inherits its tag; `O` on the
125+ // block's title line opens a line of A, above the block
126+ let (d, block) = vault_with_bullet_block();
127+ let before = std::fs::read_to_string(&block).unwrap();
128+ let mut app = App::new(d.path()).unwrap();
129+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
130+ keys(&mut app, "ejjjO- new⎋:w⏎");
131+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before);
132+ assert!(root(&d).contains("- one\n- new\n![["), "{}", root(&d));
133+}
134+
135+/// root.md "# A\n\n- one\n![[id]]\n- two\n", the block being "- task", and
136+/// the editor open on A with the cursor on the block's title line; returns
137+/// the embed.
138+fn editing_task_block() -> (tempfile::TempDir, App, String) {
139+ let d = tempfile::tempdir().unwrap();
140+ std::fs::write(d.path().join("root.md"), "# A\n\n- one\n- task\n- two\n").unwrap();
141+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
142+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
143+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
144+ drop(v);
145+ let embed = format!("![[{}]]", id.as_str());
146+ assert_eq!(root(&d), format!("# A\n\n- one\n{}\n- two\n", embed));
147+ let mut app = App::new(d.path()).unwrap();
148+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
149+ keys(&mut app, "e");
150+ // the editor shows "# A", "", "- one", "- task", "- two"
151+ for _ in 0..3 {
152+ app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
153+ }
154+ (d, app, embed)
155+}
156+
157+#[test]
158+fn alt_down_moves_a_nested_block_with_its_embed() {
159+ // §5.2: the tag travels with the line; moving a nested block's title
160+ // line moves its embed rather than turning it into parent text
161+ let (d, mut app, embed) = editing_task_block();
162+ app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::ALT));
163+ keys(&mut app, "⎋");
164+ assert_eq!(app.mode_pub(), "normal");
165+ assert_eq!(root(&d), format!("# A\n\n- one\n- two\n{}\n", embed));
166+}
167+
168+#[test]
169+fn undoing_a_block_move_puts_its_embed_back() {
170+ // the parent's own lines are the same before and after the move; only
171+ // where the block sits in it differs, and undo must write that too
172+ let (d, mut app, embed) = editing_task_block();
173+ app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::ALT));
174+ app.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
175+ assert_eq!(root(&d), format!("# A\n\n- one\n- two\n{}\n", embed));
176+ app.handle_key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL));
177+ keys(&mut app, "⎋");
178+ assert_eq!(root(&d), format!("# A\n\n- one\n{}\n- two\n", embed));
179+}
180+
181+#[test]
182+fn ctrl_k_then_ctrl_v_moves_a_nested_block_with_its_embed() {
183+ // §5.2: cutting a nested block's title line and pasting it elsewhere
184+ // moves its embed
185+ let (d, mut app, embed) = editing_task_block();
186+ app.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
187+ // the cursor is on "- two"; paste above "- one"
188+ app.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
189+ app.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
190+ keys(&mut app, "⎋");
191+ assert_eq!(root(&d), format!("# A\n\n{}\n- one\n- two\n", embed));
192+}
193+
194+/// Like `editing_task_block`, with a body line under "- task"; returns the
195+/// block's file too.
196+fn editing_task_block_with_body() -> (tempfile::TempDir, App, String, std::path::PathBuf) {
197+ let d = tempfile::tempdir().unwrap();
198+ std::fs::write(d.path().join("root.md"), "# A\n\n- one\n- task\n body\n- two\n").unwrap();
199+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
200+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
201+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
202+ let block = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
203+ drop(v);
204+ let embed = format!("![[{}]]", id.as_str());
205+ assert_eq!(root(&d), format!("# A\n\n- one\n{}\n- two\n", embed));
206+ assert!(std::fs::read_to_string(&block).unwrap().ends_with("\n- task\n body\n"));
207+ let mut app = App::new(d.path()).unwrap();
208+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
209+ keys(&mut app, "e");
210+ for _ in 0..3 {
211+ app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
212+ }
213+ (d, app, embed, block)
214+}
215+
216+#[test]
217+fn a_block_title_moved_past_its_body_leaves_the_body_where_it_sits() {
218+ // §5.2: owned lines no longer contiguous with the moved title line go to
219+ // the block they now sit in, here A under "- one"; the block's file must
220+ // never start with its body
221+ let (d, mut app, embed, block) = editing_task_block_with_body();
222+ app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::ALT));
223+ keys(&mut app, "⎋");
224+ assert_eq!(root(&d), format!("# A\n\n- one\n body\n{}\n- two\n", embed));
225+ assert!(std::fs::read_to_string(&block).unwrap().ends_with("\n- task\n"));
226+}
227+
228+#[test]
229+fn cutting_a_block_title_and_pasting_it_moves_the_block() {
230+ // the body left behind goes to the block above; the pasted title line
231+ // brings the block back with its tag
232+ let (d, mut app, embed, block) = editing_task_block_with_body();
233+ app.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
234+ // the cursor is on " body"; paste above "- two"
235+ app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
236+ app.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
237+ keys(&mut app, "⎋");
238+ assert_eq!(root(&d), format!("# A\n\n- one\n body\n{}\n- two\n", embed));
239+ assert!(std::fs::read_to_string(&block).unwrap().ends_with("\n- task\n"));
240+}
241+
242+#[test]
243+fn paste_goes_into_the_open_find_line() {
244+ // §10.6: pasted text is "inserted as typed"; with Ctrl-F's find line open,
245+ // typing goes into the find line, so a paste must too
246+ let (d, mut app) = app_with("# A\n\nhello world\n");
247+ keys(&mut app, "e");
248+ draw(&mut app);
249+ for k in [
250+ KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
251+ KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
252+ KeyEvent::new(KeyCode::End, KeyModifiers::SHIFT),
253+ KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL),
254+ ] {
255+ app.handle_key(k);
256+ }
257+ app.handle_paste("world");
258+ let s = draw(&mut app);
259+ keys(&mut app, "⎋⎋⎋");
260+ assert_eq!(root(&d), "# A\n\nhello world\n");
261+ assert!(s.contains("/world"), "{}", s);
262+}
263+
264+#[test]
265+fn paste_goes_into_the_open_vim_command_line() {
266+ let (d, mut app) = app_with("# A\n\nhello world\n");
267+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
268+ keys(&mut app, "ejj:");
269+ app.handle_paste("w\n");
270+ keys(&mut app, "⏎");
271+ // `:w` ran and the text is as it was
272+ assert_eq!(app.mode_pub(), "edit");
273+ keys(&mut app, ":q⏎");
274+ assert_eq!(root(&d), "# A\n\nhello world\n");
275+}
276+
277+#[test]
278+fn helix_search_after_dotted_capital_i_selects_the_match() {
279+ // 'İ' lowercases to two chars ("i̇"); the match column must still be
280+ // counted in the real line, so `/ab` selects the "ab" at col 1
281+ let (d, mut app) = app_with("# A\n\nİab ab\n");
282+ app.set_edit_keys(fold_tui::app::EditKeys::Helix);
283+ keys(&mut app, "ejj/ab⏎d:wq⏎");
284+ assert_eq!(root(&d), "# A\n\nİ ab\n");
285+}
286+
287+#[test]
288+fn vim_search_after_dotted_capital_i_lands_on_the_match() {
289+ let (d, mut app) = app_with("# A\n\nİİ xab\n");
290+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
291+ keys(&mut app, "ejj/ab⏎x:wq⏎");
292+ assert_eq!(root(&d), "# A\n\nİİ xb\n");
293+}
294+
295+#[test]
296+fn helix_xd_above_a_block_keeps_the_block() {
297+ // §5.2: a deleted line takes its tag with it; "- two" keeps its block's tag
298+ let (d, block) = vault_with_bullet_block();
299+ let before = std::fs::read_to_string(&block).unwrap();
300+ let mut app = App::new(d.path()).unwrap();
301+ app.set_edit_keys(fold_tui::app::EditKeys::Helix);
302+ keys(&mut app, "ejjxd:w⏎");
303+ let r = root(&d);
304+ assert!(r.contains("![[") && !r.contains("- two"), "{}", r);
305+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before);
306+}
307+
308+#[test]
309+fn vim_dgj_above_a_block_keeps_the_block() {
310+ // `dgj` from column 0 of "- one" deletes up to column 0 of the block's
311+ // title line, exclusive: "- one" goes whole, with its tag, and the title
312+ // line stays the block's
313+ let (d, block) = vault_with_bullet_block();
314+ let before = std::fs::read_to_string(&block).unwrap();
315+ let mut app = App::new(d.path()).unwrap();
316+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
317+ keys(&mut app, "ejjdgj:w⏎");
318+ let r = root(&d);
319+ assert!(r.starts_with("# A\n\n![[") && !r.contains("- "), "{}", r);
320+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before);
321+}
322+
323+#[test]
324+fn deleting_a_selected_line_above_a_block_keeps_the_block() {
325+ // normal keymap: Shift-Down selects "- one" and its line end; Delete
326+ // takes that line away and leaves the block's title line as it was
327+ let (d, block) = vault_with_bullet_block();
328+ let before = std::fs::read_to_string(&block).unwrap();
329+ let mut app = App::new(d.path()).unwrap();
330+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
331+ keys(&mut app, "e");
332+ for k in [
333+ KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
334+ KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
335+ KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT),
336+ KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE),
337+ ] {
338+ app.handle_key(k);
339+ }
340+ keys(&mut app, "⎋");
341+ let r = root(&d);
342+ assert!(r.starts_with("# A\n\n![[") && !r.contains("- "), "{}", r);
343+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before);
344+}
345+
346+#[test]
347+fn helix_x_tilde_keeps_line_owners() {
348+ // §5.2: the tag travels with the line; a case change over "- one\n"
349+ // must not re-tag the block's title line below it
350+ let (d, block) = vault_with_bullet_block();
351+ let before = std::fs::read_to_string(&block).unwrap();
352+ let mut app = App::new(d.path()).unwrap();
353+ app.set_edit_keys(fold_tui::app::EditKeys::Helix);
354+ keys(&mut app, "ejjx~:w⏎");
355+ assert!(root(&d).starts_with("# A\n\n- ONE\n![["), "{}", root(&d));
356+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before);
357+}
358+
359+#[test]
360+fn vim_visual_line_tilde_keeps_line_owners() {
361+ // the case of each line changes in place, in the block that owns it
362+ let (d, block) = vault_with_bullet_block();
363+ let before = std::fs::read_to_string(&block).unwrap();
364+ let mut app = App::new(d.path()).unwrap();
365+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
366+ keys(&mut app, "ejjVj~:w⏎");
367+ assert!(root(&d).starts_with("# A\n\n- ONE\n![["), "{}", root(&d));
368+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before.replace("- two", "- TWO"));
369+}
370+
371+#[test]
372+fn helix_tilde_from_mid_line_across_a_block_title_keeps_line_owners() {
373+ // a range that starts inside "- one" and ends inside the block's title
374+ let (d, block) = vault_with_bullet_block();
375+ let before = std::fs::read_to_string(&block).unwrap();
376+ let mut app = App::new(d.path()).unwrap();
377+ app.set_edit_keys(fold_tui::app::EditKeys::Helix);
378+ keys(&mut app, "ejjllvjl~:w⏎");
379+ assert!(root(&d).starts_with("# A\n\n- ONE\n![["), "{}", root(&d));
380+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before.replace("- two", "- TWo"));
381+}
382+
383+#[test]
384+fn vim_case_toggle_keeps_sharp_s() {
385+ // 'ß' has no one-letter capital: like Vim, it stays as it is rather than
386+ // turning into the first letter of "SS"
387+ let (d, mut app) = app_with("# A\n\nstraße fix\n");
388+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
389+ keys(&mut app, "ejjv$~:w⏎");
390+ assert_eq!(root(&d), "# A\n\nSTRAßE fiX\n");
391+}
392+
393+/// root.md "# A\n\n- one\n![[b]]\n- two\n", block b "- b" holding block c
394+/// "- c" as its child; the editor open on A with the cursor on " - c".
395+/// Returns the embeds of b and c and b's file.
396+fn editing_nested_blocks() -> (tempfile::TempDir, App, String, String, std::path::PathBuf) {
397+ let d = tempfile::tempdir().unwrap();
398+ std::fs::write(d.path().join("root.md"), "# A\n\n- one\n- b\n - c\n- two\n").unwrap();
399+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
400+ let c = v.find_by_path(&["A".into(), "b".into(), "c".into()]).unwrap();
401+ let c = fold_core::ops::make_block(&mut v, c).unwrap();
402+ let b = v.find_by_path(&["A".into(), "b".into()]).unwrap();
403+ let b = fold_core::ops::make_block(&mut v, b).unwrap();
404+ let b_file = v.dir.join(&v.tree.files[v.tree.block_by_id(&b).unwrap().0].path);
405+ drop(v);
406+ let (b, c) = (format!("![[{}]]", b.as_str()), format!("![[{}]]", c.as_str()));
407+ assert_eq!(root(&d), format!("# A\n\n- one\n{}\n- two\n", b));
408+ assert!(std::fs::read_to_string(&b_file).unwrap().ends_with(&format!("\n- b\n {}\n", c)));
409+ let mut app = App::new(d.path()).unwrap();
410+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
411+ keys(&mut app, "e");
412+ // the editor shows "# A", "", "- one", "- b", " - c", "- two"
413+ for _ in 0..4 {
414+ app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
415+ }
416+ (d, app, b, c, b_file)
417+}
418+
419+#[test]
420+fn moving_a_block_above_the_block_it_is_in_moves_its_embed_out() {
421+ // §5.2: the embed goes where the title line now sits, in A under "- one";
422+ // b's file must not be left with c's embed ahead of its own title
423+ let (d, mut app, b, c, b_file) = editing_nested_blocks();
424+ app.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
425+ keys(&mut app, "⎋");
426+ assert_eq!(root(&d), format!("# A\n\n- one\n {}\n{}\n- two\n", c, b));
427+ assert!(std::fs::read_to_string(&b_file).unwrap().ends_with("\n- b\n"));
428+}
429+
430+#[test]
431+fn moving_a_block_below_the_block_it_is_in_moves_its_embed_out() {
432+ // under "- two" it is A's, not b's: saved as it is shown
433+ let (d, mut app, b, c, b_file) = editing_nested_blocks();
434+ app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::ALT));
435+ keys(&mut app, "⎋");
436+ assert_eq!(root(&d), format!("# A\n\n- one\n{}\n- two\n {}\n", b, c));
437+ assert!(std::fs::read_to_string(&b_file).unwrap().ends_with("\n- b\n"));
438+}
439+
440+#[test]
441+fn cutting_a_block_out_of_the_block_it_is_in_and_pasting_it_moves_its_embed() {
442+ let (d, mut app, b, c, b_file) = editing_nested_blocks();
443+ app.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
444+ // the cursor is on "- two"; paste above "- one"
445+ app.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
446+ app.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
447+ app.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
448+ keys(&mut app, "⎋");
449+ assert_eq!(root(&d), format!("# A\n\n {}\n- one\n{}\n- two\n", c, b));
450+ assert!(std::fs::read_to_string(&b_file).unwrap().ends_with("\n- b\n"));
451+}
452+
453+#[test]
454+fn undoing_a_move_out_of_a_block_puts_the_embed_back_in_it() {
455+ let (d, mut app, b, c, b_file) = editing_nested_blocks();
456+ let before = std::fs::read_to_string(&b_file).unwrap();
457+ app.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
458+ app.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
459+ assert_eq!(root(&d), format!("# A\n\n- one\n {}\n{}\n- two\n", c, b));
460+ app.handle_key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL));
461+ keys(&mut app, "⎋");
462+ assert_eq!(root(&d), format!("# A\n\n- one\n{}\n- two\n", b));
463+ assert_eq!(std::fs::read_to_string(&b_file).unwrap(), before);
464+}
465+
466+#[test]
467+fn helix_x_r_above_a_block_keeps_the_block() {
468+ // `r` over a whole line and its end replaces that line; the block's
469+ // title line below it keeps its tag (§5.2)
470+ let (d, block) = vault_with_bullet_block();
471+ let before = std::fs::read_to_string(&block).unwrap();
472+ let mut app = App::new(d.path()).unwrap();
473+ app.set_edit_keys(fold_tui::app::EditKeys::Helix);
474+ keys(&mut app, "ejjxr-:w⏎");
475+ assert!(root(&d).starts_with("# A\n\n-----\n![["), "{}", root(&d));
476+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before);
477+}
478+
479+#[test]
480+fn vim_dot_after_a_counted_dot_repeats_the_change() {
481+ let (d, mut app) = app_with("# A\n\na\nb\nc\nd\ne\nf\n");
482+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
483+ keys(&mut app, "ejjdd2.:w⏎");
484+ assert_eq!(root(&d), "# A\n\nd\ne\nf\n");
485+ // `.` repeats `dd` with the count 2 of the last `2.`; it must not replay `2.` into itself
486+ keys(&mut app, ".:w⏎");
487+ assert_eq!(root(&d), "# A\n\nf\n");
488+}
489+
490+#[test]
491+fn vim_counted_undo_is_not_repeated_by_dot() {
492+ let (d, mut app) = app_with("# A\n\na\nb\nc\nd\n");
493+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
494+ keys(&mut app, "ejjdddddd2u:w⏎");
495+ assert_eq!(root(&d), "# A\n\nb\nc\nd\n");
496+ // `.` repeats the last change (`dd`), never the undo
497+ keys(&mut app, "ggjj.:w⏎");
498+ assert_eq!(root(&d), "# A\n\nc\nd\n");
499+}
500+
501+#[test]
502+fn vim_cw_at_the_end_of_a_word_changes_only_that_word() {
503+ // a one-letter word: `cw` changes `a`, not `a cat`
504+ let (d, mut app) = app_with("# A\n\na cat\n");
505+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
506+ keys(&mut app, "ejjcwthe⎋:w⏎");
507+ assert_eq!(root(&d), "# A\n\nthe cat\n");
508+ // the last letter of a word: `cw` on the `e` of `one` changes `e`, not `e two`
509+ let (d, mut app) = app_with("# A\n\none two\n");
510+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
511+ keys(&mut app, "ejjllcwX⎋:w⏎");
512+ assert_eq!(root(&d), "# A\n\nonX two\n");
513+ // with a count, the first word is that character, as in Vim: `2cw` changes `e two`
514+ let (d, mut app) = app_with("# A\n\none two three\n");
515+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
516+ keys(&mut app, "ejjll2cwX⎋:w⏎");
517+ assert_eq!(root(&d), "# A\n\nonX three\n");
518+}
519+
520+#[test]
521+fn vim_visual_line_put_replaces_the_line() {
522+ // `yy` on "a", then `V` on "b" and `p`: "b" becomes "a", no blank line is left behind.
523+ let (d, mut app) = app_with("# A\n\na\nb\nc\n");
524+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
525+ keys(&mut app, "ejjyyjVp:w⏎");
526+ assert_eq!(root(&d), "# A\n\na\na\nc\n");
527+}
528+
529+#[test]
530+fn vim_visual_line_put_saves_the_replaced_line_whole() {
531+ // `V` + `p` puts the replaced line in the register as a whole line (with its newline),
532+ // so a later `2p` puts two lines "b", not one line "bb".
533+ let (d, mut app) = app_with("# A\n\na\nb\nc\n");
534+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
535+ keys(&mut app, "ejjyljVp");
536+ keys(&mut app, "2p:w⏎");
537+ assert_eq!(root(&d), "# A\n\na\na\nb\nb\nc\n");
538+}
539+
540+#[test]
541+fn helix_repeated_w_selects_the_next_word() {
542+ // the second `w` selects "two ", not " two t" from the previous selection's end
543+ let (d, mut app) = app_with("# A\n\none two three\n");
544+ app.set_edit_keys(fold_tui::app::EditKeys::Helix);
545+ keys(&mut app, "ejjwwd:wq⏎");
546+ assert_eq!(root(&d), "# A\n\none three\n");
547+ // `2w` is the same
548+ let (d, mut app) = app_with("# A\n\none two three\n");
549+ app.set_edit_keys(fold_tui::app::EditKeys::Helix);
550+ keys(&mut app, "ejj2wd:wq⏎");
551+ assert_eq!(root(&d), "# A\n\none three\n");
552+ // the last word of a line is selected without the line end, and the next
553+ // `w` selects the first word of the next line: nothing joins the lines
554+ let (d, mut app) = app_with("# A\n\none two\nthree four\n");
555+ app.set_edit_keys(fold_tui::app::EditKeys::Helix);
556+ keys(&mut app, "ejjwwd:wq⏎");
557+ assert_eq!(root(&d), "# A\n\none \nthree four\n");
558+ let (d, mut app) = app_with("# A\n\none two\nthree four\n");
559+ app.set_edit_keys(fold_tui::app::EditKeys::Helix);
560+ keys(&mut app, "ejjwwwd:wq⏎");
561+ assert_eq!(root(&d), "# A\n\none two\nfour\n");
562+}
563+
564+#[test]
565+fn helix_repeated_b_selects_the_previous_word() {
566+ // after `glb` selects "three", a second `b` selects "two ", not "two t"
567+ let (d, mut app) = app_with("# A\n\none two three\n");
568+ app.set_edit_keys(fold_tui::app::EditKeys::Helix);
569+ keys(&mut app, "ejjglbbd:wq⏎");
570+ assert_eq!(root(&d), "# A\n\none three\n");
571+}
572+
573+#[test]
574+fn helix_join_two_selected_lines() {
575+ // `xx` selects lines a and b; `J` joins those two, as Helix does, not c too
576+ let (d, mut app) = app_with("# A\n\na\nb\nc\nd\n");
577+ app.set_edit_keys(fold_tui::app::EditKeys::Helix);
578+ keys(&mut app, "ejjxxJ:wq⏎");
579+ assert_eq!(root(&d), "# A\n\na b\nc\nd\n");
580+}
581+
582+/// Vim: `d` + `gj`/`gk`/`ge` is one complete command — whatever it does, the
583+/// operator is no longer pending, so a following `j` only moves the cursor.
584+#[test]
585+fn vim_operator_with_g_motion_does_not_stay_pending() {
586+ let text = "# A\n\none\ntwo\nthree\nfour\n";
587+ let run = |seq: &str| {
588+ let (d, mut app) = app_with(text);
589+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
590+ keys(&mut app, "e");
591+ draw(&mut app);
592+ // cursor on "two"; Esc drops anything still pending before `:w`
593+ keys(&mut app, "jjj");
594+ keys(&mut app, seq);
595+ keys(&mut app, "⎋:w⏎");
596+ root(&d)
597+ };
598+ for m in ["gj", "gk", "ge"] {
599+ let without = run(&format!("d{}", m));
600+ let with_j = run(&format!("d{}j", m));
601+ assert_eq!(with_j, without, "`j` after `d{}` ran as an operator motion", m);
602+ }
603+ // the reported case: `dgj` then `j` must not also delete "three"
604+ assert!(run("dgjj").contains("three"), "{}", run("dgjj"));
605+ // and the operators act over the motions, charwise as in Vim: `gj`/`gk`
606+ // exclusive, `ge` (back to the end of the previous word) inclusive
607+ assert_eq!(run("dgj"), "# A\n\none\nthree\nfour\n");
608+ assert_eq!(run("dgk"), "# A\n\ntwo\nthree\nfour\n");
609+ assert_eq!(run("dge"), "# A\n\nonwo\nthree\nfour\n");
610+}
611+
612+#[test]
613+fn vim_dot_after_deleting_a_dragged_selection_leaves_no_operator_pending() {
614+ let (d, mut app) = app_with("# A\n\none two\nthree\nfour\n");
615+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
616+ keys(&mut app, "e");
617+ draw(&mut app);
618+ let s = draw(&mut app);
619+ let (y, line) = s.lines().enumerate().find(|(_, l)| l.contains("one two")).unwrap();
620+ let x = line.find("one two").map(|b| line[..b].chars().count()).unwrap() as u16;
621+ let y = y as u16;
622+ // drag over "one": a Vim drag is a visual selection
623+ app.handle_mouse(ev(MouseEventKind::Down(MouseButton::Left), (x, y)));
624+ app.handle_mouse(ev(MouseEventKind::Drag(MouseButton::Left), (x + 2, y)));
625+ app.handle_mouse(ev(MouseEventKind::Up(MouseButton::Left), (x + 2, y)));
626+ keys(&mut app, "d:w⏎");
627+ assert_eq!(root(&d), "# A\n\n two\nthree\nfour\n");
628+ // repeat, then move down: `j` must only move
629+ keys(&mut app, ".j:w⏎");
630+ let text = root(&d);
631+ assert!(text.contains("three") && text.contains("four"), "{:?}", text);
632+ // as in Vim, `.` repeats the delete over as much text as was selected
633+ assert_eq!(text, "# A\n\no\nthree\nfour\n");
634+}
635+
636+#[test]
637+fn vim_dot_after_changing_a_double_clicked_word_changes_as_much_again() {
638+ let (d, mut app) = app_with("# A\n\none two three\n");
639+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
640+ keys(&mut app, "e");
641+ draw(&mut app);
642+ let s = draw(&mut app);
643+ let (y, line) = s.lines().enumerate().find(|(_, l)| l.contains("one two")).unwrap();
644+ let x = line.find("one two").map(|b| line[..b].chars().count()).unwrap() as u16;
645+ let y = y as u16;
646+ // double-click "one", change it to "1", then `w.` changes "two" the same way
647+ for _ in 0..2 {
648+ app.handle_mouse(ev(MouseEventKind::Down(MouseButton::Left), (x, y)));
649+ app.handle_mouse(ev(MouseEventKind::Up(MouseButton::Left), (x, y)));
650+ }
651+ keys(&mut app, "c1⎋w.:w⏎");
652+ assert_eq!(root(&d), "# A\n\n1 1 three\n");
653+}
654+
655+#[test]
656+fn helix_shift_n_selects_the_previous_match() {
657+ // `/foo` from the start of the line selects the second "foo"; `N` must go
658+ // back to the first one, not reselect the match it is standing on
659+ let (d, mut app) = app_with("# A\n\nfoo bar foo\n");
660+ app.set_edit_keys(fold_tui::app::EditKeys::Helix);
661+ keys(&mut app, "ejj/foo⏎Nd:wq⏎");
662+ assert_eq!(root(&d), "# A\n\n bar foo\n");
663+}
664+
665+#[test]
666+fn helix_repeated_backward_search_selects_the_previous_match() {
667+ // `?foo` from the start of the line wraps to the second "foo"; a repeated
668+ // `?` must then select the first one
669+ let (d, mut app) = app_with("# A\n\nfoo bar foo\n");
670+ app.set_edit_keys(fold_tui::app::EditKeys::Helix);
671+ keys(&mut app, "ejj?foo⏎?⏎d:wq⏎");
672+ assert_eq!(root(&d), "# A\n\n bar foo\n");
673+}
674+
675+#[test]
676+fn vim_brace_in_last_paragraph_takes_the_last_line() {
677+ // No blank line after the paragraph (the usual end of an edited subtree):
678+ // Vim's `}` lands on the last character and is inclusive, so `d}` from the
679+ // first line deletes the whole paragraph, last line included.
680+ let (d, mut app) = app_with("# A\n\na\nb\n");
681+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
682+ keys(&mut app, "ejjd}:w⏎");
683+ assert_eq!(root(&d).trim_end(), "# A", "{:?}", root(&d));
684+ // with a blank line after it, `}` stops there (column 0, exclusive)
685+ let (d, mut app) = app_with("# A\n\na\nb\n\nc\n");
686+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
687+ keys(&mut app, "ejjd}:w⏎");
688+ assert_eq!(root(&d), "# A\n\n\nc\n");
689+}
690+
691+#[test]
692+fn vim_count_before_operator_applies_to_find() {
693+ // `2df-` is `d2f-`: a count typed before the operator multiplies into f/t/F/T
694+ let (d, mut app) = app_with("# A\n\na-b-c-d\n");
695+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
696+ keys(&mut app, "ejj2df-:w⏎");
697+ assert_eq!(root(&d), "# A\n\nc-d\n");
698+ // and into `gg`, whose count is a line: `3dgg` from the last line deletes lines 3 to 5
699+ let (d, mut app) = app_with("# A\n\na\nb\nc\n");
700+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
701+ keys(&mut app, "eG3dgg:w⏎");
702+ assert_eq!(root(&d), "# A\n");
703+}
704+
705+#[test]
706+fn vim_huge_count_put_does_not_panic() {
707+ let (d, mut app) = app_with("# A\n\nx\n");
708+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
709+ // usize::MAX copies of the yanked "x": today `str::repeat` panics with
710+ // "capacity overflow" instead of the count being clamped
711+ keys(&mut app, "ejjyl18446744073709551615p");
712+ draw(&mut app);
713+ keys(&mut app, "⎋:q!⏎");
714+ assert_eq!(app.mode_pub(), "normal");
715+ assert_eq!(root(&d), "# A\n\nx\n");
716+}
717+
718+/// A count far past the end of the text acts like one that just reaches it:
719+/// the motion or command stops there, rather than looping on (or
720+/// overflowing) for billions of steps.
721+#[test]
722+fn huge_counts_stop_at_the_end_of_the_text() {
723+ let huge = "18446744073709551615";
724+ let text = "# A\n\none two\n three four\n\nfive\n";
725+ let run = |keymap: fold_tui::app::EditKeys, seq: &str| {
726+ let (d, mut app) = app_with(text);
727+ app.set_edit_keys(keymap);
728+ keys(&mut app, "ejj");
729+ keys(&mut app, seq);
730+ keys(&mut app, "⎋:w⏎");
731+ root(&d)
732+ };
733+ // (`#` stands for the count; nothing here edits the title line)
734+ let vim = [
735+ "#wx", "#bjjx", "#ex", "#}x", "G#{jjx", "x#.", "xx#u", "#lx", "#$x", "2d#w", "#d#w", "#dd", "#J", "#~", "#rz",
736+ "#x", "#s-", "#S-", "#D", "#C-", "#Yp", "#>>", "#jx", "#Gx", "G#ggjjx",
737+ ];
738+ for s in vim {
739+ let small = run(fold_tui::app::EditKeys::Vim, &s.replace('#', "20"));
740+ assert_eq!(run(fold_tui::app::EditKeys::Vim, &s.replace('#', huge)), small, "vim {}", s);
741+ }
742+ let helix = ["#ed", "#wd", "#b;jjd", "#ld", "#xd", "xd#u", "xdu#U", "x>>#<"];
743+ for s in helix {
744+ let small = run(fold_tui::app::EditKeys::Helix, &s.replace('#', "20"));
745+ assert_eq!(run(fold_tui::app::EditKeys::Helix, &s.replace('#', huge)), small, "helix {}", s);
746+ }
747+ // indenting by a count is one step, however deep; too deep is refused
748+ assert_eq!(run(fold_tui::app::EditKeys::Helix, "3>u"), text);
749+ assert_eq!(run(fold_tui::app::EditKeys::Helix, &format!("{}>", huge)), text);
750+}
751+
752+#[test]
753+fn typing_after_n_titles_the_new_node() {
754+ // §10.6: `n` / `N` put the cursor on the new node in the editor, so the
755+ // first thing typed is its title, after the marker
756+ let (d, mut app) = app_with("- a\n");
757+ keys(&mut app, "nb⎋");
758+ assert_eq!(root(&d), "- a\n- b\n");
759+ let (d, mut app) = app_with("# A\n");
760+ keys(&mut app, "nB⎋");
761+ assert_eq!(root(&d), "# A\n\n# B\n");
762+ let (d, mut app) = app_with("- a\n");
763+ keys(&mut app, "Nc⎋");
764+ let text = root(&d);
765+ assert!(text.starts_with("- a\n") && text.lines().any(|l| l == " - c"), "{:?}", text);
766+ // Vim and Helix start typing too, as after `o`
767+ for k in [fold_tui::app::EditKeys::Vim, fold_tui::app::EditKeys::Helix] {
768+ let (d, mut app) = app_with("- a\n");
769+ app.set_edit_keys(k);
770+ keys(&mut app, "nb⎋:wq⏎");
771+ assert_eq!(root(&d), "- a\n- b\n");
772+ assert_eq!(app.mode_pub(), "normal");
773+ }
774+ // a new node left untitled stays as it was written
775+ let (d, mut app) = app_with("- a\n");
776+ keys(&mut app, "n⎋");
777+ assert_eq!(root(&d), "- a\n-\n");
778+}
779+
780+#[test]
781+fn typing_after_an_external_change_to_the_edited_file_is_saved() {
782+ // §11.2: an external change while editing re-renders the buffer, so a
783+ // later save is not refused and the typed text is never thrown away
784+ let (d, mut app) = app_with("# A\n\nbody\n\n# B\n\nother\n");
785+ keys(&mut app, "e");
786+ std::fs::write(d.path().join("root.md"), "# A\n\nbody\n\n# B\n\nother, from Helix\n").unwrap();
787+ app.reload_external();
788+ app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
789+ keys(&mut app, "X⎋");
790+ let t = root(&d);
791+ assert!(t.contains("# AX") && t.contains("other, from Helix"), "{}", t);
792+ assert_eq!(app.mode_pub(), "normal");
793+}
794+
795+#[test]
796+fn a_refused_save_keeps_the_editor_open_with_its_text() {
797+ // a save that fails never drops the buffer: the edited node's text
798+ // changed on disk under typed text (§5.2 step 5), and leaving the
799+ // editor keeps it open with the text
800+ let (d, mut app) = app_with("# A\n\nbody\n\n# B\n\nother\n");
801+ keys(&mut app, "e");
802+ app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
803+ keys(&mut app, "X");
804+ std::fs::write(d.path().join("root.md"), "# A\n\nbody, from Helix\n\n# B\n\nother\n").unwrap();
805+ keys(&mut app, "⎋");
806+ assert_eq!(app.mode_pub(), "edit", "the editor stays open");
807+ assert!(app.editor_dirty(), "with the typed text still in it");
808+ assert!(draw(&mut app).contains("# AX"));
809+}
810+
811+fn ctrl(app: &mut App, c: char) {
812+ app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL));
813+}
814+
815+#[test]
816+fn undoing_a_saved_cut_of_a_block_title_puts_the_block_back() {
817+ // the cut title line is in the clipboard, so a save between the cut and
818+ // the undo leaves the block alone (§5.2); undo puts it back, embed and all
819+ let (d, mut app, embed, block) = editing_task_block_with_body();
820+ let before = std::fs::read_to_string(&block).unwrap();
821+ ctrl(&mut app, 'k');
822+ ctrl(&mut app, 's');
823+ assert_eq!(root(&d), "# A\n\n- one\n body\n- two\n");
824+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before);
825+ ctrl(&mut app, 'z');
826+ keys(&mut app, "⎋");
827+ assert_eq!(root(&d), format!("# A\n\n- one\n{}\n- two\n", embed));
828+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before);
829+}
830+
831+#[test]
832+fn a_cut_block_title_not_pasted_back_deletes_the_block() {
833+ // §5.2: deleting a nested block's title line deletes the block; a cut one
834+ // is deleted once it can no longer be pasted back, when the editor
835+ // closes or when something else is copied over it
836+ let (d, mut app, _, block) = editing_task_block_with_body();
837+ ctrl(&mut app, 'k');
838+ keys(&mut app, "⎋");
839+ assert_eq!(app.mode_pub(), "normal");
840+ assert_eq!(root(&d), "# A\n\n- one\n body\n- two\n");
841+ assert!(!block.exists());
842+ let (d, mut app, _, block) = editing_task_block_with_body();
843+ ctrl(&mut app, 'k');
844+ // the cursor is on " body": copy it
845+ ctrl(&mut app, 'c');
846+ ctrl(&mut app, 's');
847+ assert_eq!(root(&d), "# A\n\n- one\n body\n- two\n");
848+ assert!(!block.exists());
849+ // the clipboard holds " body" alone, pasted as a copy above the cursor
850+ ctrl(&mut app, 'v');
851+ keys(&mut app, "⎋");
852+ assert_eq!(root(&d), "# A\n\n- one\n body\n body\n- two\n");
853+}
854+
855+#[test]
856+fn a_line_cut_before_a_reload_and_pasted_after_it_is_saved() {
857+ // another program changes the file while a nested block's title line is
858+ // cut: the editor is re-rendered over the files as they are (§11.2), and
859+ // the pasted line is text of the block it lands in, not a line of a
860+ // block the new buffer does not have, written nowhere
861+ let d = tempfile::tempdir().unwrap();
862+ std::fs::write(d.path().join("root.md"), "# A\n\n- one\n- task\n- two\n\n# B\n\nb\n").unwrap();
863+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
864+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
865+ fold_core::ops::make_block(&mut v, t).unwrap();
866+ drop(v);
867+ let mut app = App::new(d.path()).unwrap();
868+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
869+ keys(&mut app, "e");
870+ for _ in 0..3 {
871+ app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
872+ }
873+ ctrl(&mut app, 'k');
874+ ctrl(&mut app, 's');
875+ assert_eq!(root(&d), "# A\n\n- one\n- two\n\n# B\n\nb\n");
876+ std::fs::write(d.path().join("root.md"), "# A\n\n- one\n- two\n\n# B\n\nb, from Helix\n").unwrap();
877+ app.reload_external();
878+ ctrl(&mut app, 'v');
879+ keys(&mut app, "⎋");
880+ assert_eq!(app.mode_pub(), "normal");
881+ assert_eq!(root(&d), "# A\n\n- one\n- task\n- two\n\n# B\n\nb, from Helix\n");
882+}
883+
884+#[test]
885+fn vim_t_next_to_its_target_stays_put() {
886+ let run = |keymap: fold_tui::app::EditKeys, text: &str, seq: &str| {
887+ let (d, mut app) = app_with(&format!("# A\n\n{}\n", text));
888+ app.set_edit_keys(keymap);
889+ keys(&mut app, "ejj");
890+ keys(&mut app, seq);
891+ keys(&mut app, "⎋:w⏎");
892+ root(&d).trim_start_matches("# A\n\n").trim_end().to_string()
893+ };
894+ let vim = |text: &str, seq: &str| run(fold_tui::app::EditKeys::Vim, text, seq);
895+ // Vim: `t)` with `)` right after the cursor lands on the cursor, so `dt)`
896+ // deletes the one character before `)`
897+ assert_eq!(vim("foo(a) bar(b)", "fadt)"), "foo() bar(b)");
898+ // `T(` with `(` right before it does not move: `dT(` deletes nothing
899+ assert_eq!(vim("(b(a)", "fadT("), "(b(a)");
900+ // only `;` and `,` repeating a `t`/`T` skip a target next to the cursor,
901+ // and only without a count (Vim's 'cpoptions' without `;`)
902+ assert_eq!(vim("a)b)c)", "t);x"), "a))c)");
903+ assert_eq!(vim("(a(b(c", "$T(;x"), "(a((c");
904+ assert_eq!(vim("a(b(c(d", "fct(,x"), "a((c(d");
905+ assert_eq!(vim("a)b)c)d", "t)2;x"), "a))c)d");
906+ // Helix's `t` always selects up to the next target it does not touch
907+ assert_eq!(run(fold_tui::app::EditKeys::Helix, "a)b)", "t)d"), ")");
908+}
909+
910+#[test]
911+fn deleting_a_block_title_line_in_vim_or_helix_and_putting_it_back_moves_the_block() {
912+ // §5.2: moving a nested block's title line (cut and paste in the editor)
913+ // moves its embed, in every keymap: Vim's `dd` then `P`/`p`, Helix's
914+ // `xd` then `P`/`p`
915+ for (keymap, seq, moved_up) in [
916+ (fold_tui::app::EditKeys::Vim, "ddkP:w⏎", true),
917+ (fold_tui::app::EditKeys::Vim, "ddp:w⏎", false),
918+ (fold_tui::app::EditKeys::Helix, "xdkP:w⏎", true),
919+ (fold_tui::app::EditKeys::Helix, "xdp:w⏎", false),
920+ ] {
921+ let (d, mut app, embed, block) = editing_task_block_with_body();
922+ let before = std::fs::read_to_string(&block).unwrap();
923+ app.set_edit_keys(keymap);
924+ keys(&mut app, seq);
925+ let want = if moved_up {
926+ format!("# A\n\n{}\n- one\n body\n- two\n", embed)
927+ } else {
928+ format!("# A\n\n- one\n body\n{}\n- two\n", embed)
929+ };
930+ assert_eq!(root(&d), want, "{:?} {}", keymap, seq);
931+ // the body left behind is the parent's; the block keeps its title
932+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before.replace(" body\n", ""));
933+ }
934+ // a line yanked and put is a copy, of the block above it: the block is
935+ // not duplicated
936+ for (keymap, seq) in [(fold_tui::app::EditKeys::Vim, "yyjp:w⏎"), (fold_tui::app::EditKeys::Helix, "xyjp:w⏎")] {
937+ let (d, mut app, embed, block) = editing_task_block_with_body();
938+ let before = std::fs::read_to_string(&block).unwrap();
939+ app.set_edit_keys(keymap);
940+ keys(&mut app, seq);
941+ assert_eq!(root(&d), format!("# A\n\n- one\n{}\n- two\n", embed), "{:?} {}", keymap, seq);
942+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before.replace(" body\n", " body\n- task\n"));
943+ }
944+}
945+
946+#[test]
947+fn replacing_characters_from_mid_line_into_a_block_title_keeps_line_owners() {
948+ // helix `r` and vim visual `r` over "one" and the "-" of the block's
949+ // title line below: each character is replaced where it is, and the
950+ // title line stays the block's (§5.2)
951+ for (keymap, seq) in [(fold_tui::app::EditKeys::Helix, "ejjllvjhhr-:w⏎"), (fold_tui::app::EditKeys::Vim, "ejjllvjhhr-:w⏎")] {
952+ let (d, block) = vault_with_bullet_block();
953+ let before = std::fs::read_to_string(&block).unwrap();
954+ let mut app = App::new(d.path()).unwrap();
955+ app.set_edit_keys(keymap);
956+ keys(&mut app, seq);
957+ assert!(root(&d).starts_with("# A\n\n- ---\n![["), "{:?}: {}", keymap, root(&d));
958+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before, "{:?}", keymap);
959+ }
960+}
961+
962+#[test]
963+fn a_cursor_key_in_insert_mode_starts_a_new_undo_step() {
964+ // Vim: what is typed before and after an arrow key, Home or End in
965+ // insert mode is undone separately; Helix's insert mode does the same
966+ for keymap in [fold_tui::app::EditKeys::Vim, fold_tui::app::EditKeys::Helix] {
967+ for k in [KeyCode::Left, KeyCode::Right, KeyCode::Up, KeyCode::Down, KeyCode::Home, KeyCode::End] {
968+ let (d, mut app) = app_with("# A\n\nx\n");
969+ app.set_edit_keys(keymap);
970+ keys(&mut app, "ejjAone");
971+ app.handle_key(KeyEvent::new(k, KeyModifiers::NONE));
972+ keys(&mut app, "two⎋u:w⏎");
973+ assert_eq!(root(&d), "# A\n\nxone\n", "{:?} {:?}", keymap, k);
974+ }
975+ }
976+}
977+
978+#[test]
979+fn undoing_a_save_that_took_in_another_programs_change_keeps_that_change() {
980+ // §5.2 step 5: the save keeps a change made outside the edited node;
981+ // undoing the save takes back only the typing (§10.10)
982+ let (d, mut app) = app_with("# A\n\nbody\n\n# B\n\nother\n");
983+ keys(&mut app, "e");
984+ app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
985+ keys(&mut app, "X");
986+ std::fs::write(d.path().join("root.md"), "# A\n\nbody\n\n# B\n\nother, from Helix\n").unwrap();
987+ keys(&mut app, "⎋");
988+ assert_eq!(app.mode_pub(), "normal");
989+ assert_eq!(root(&d), "# AX\n\nbody\n\n# B\n\nother, from Helix\n");
990+ keys(&mut app, "u");
991+ assert_eq!(root(&d), "# A\n\nbody\n\n# B\n\nother, from Helix\n");
992+}
993+
994+#[test]
995+fn a_cut_block_title_is_deleted_when_a_reload_re_renders_the_editor() {
996+ // the re-rendered editor cannot paste the line back as the block, so the
997+ // block is deleted then (§5.2), not left in a file embedded nowhere
998+ let (d, mut app, _, block) = editing_task_block_with_body();
999+ ctrl(&mut app, 'k');
1000+ ctrl(&mut app, 's');
1001+ assert!(block.exists(), "in transit");
1002+ std::fs::write(d.path().join("root.md"), "# A\n\n- one\n body\n- two\n\n# C\n").unwrap();
1003+ app.reload_external();
1004+ assert!(!block.exists());
1005+ assert_eq!(root(&d), "# A\n\n- one\n body\n- two\n\n# C\n");
1006+}
1007+
1008+#[test]
1009+fn review_undoing_a_saved_block_title_deletion_keeps_the_block_nested_in_it() {
1010+ // §5.2: re-spelling b's title line as plain text deletes b: the save
1011+ // embeds c (nested in b) in A and trashes b's file. Undo puts "- b"
1012+ // back as A's plain text; the next save must keep c embedded, not write
1013+ // an embed of the trashed b in its place (leaving c embedded nowhere)
1014+ let (d, mut app, b, c, b_file) = editing_nested_blocks();
1015+ app.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
1016+ app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
1017+ app.handle_key(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
1018+ app.handle_key(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
1019+ ctrl(&mut app, 's');
1020+ assert_eq!(root(&d), format!("# A\n\n- one\nb\n {}\n- two\n", c));
1021+ assert!(!b_file.exists());
1022+ ctrl(&mut app, 'z');
1023+ keys(&mut app, "⎋");
1024+ let r = root(&d);
1025+ assert!(!r.contains(&b) && r.contains(&c), "b = {} (trashed), c = {}; root.md:\n{}", b, c, r);
1026+}
1027+
1028+#[test]
1029+fn review_undo_after_a_save_that_deleted_a_block_keeps_the_block_in_it() {
1030+ // b's title line joined onto "- one" and saved: b is deleted, its file
1031+ // trashed, and c's embed goes to A (§5.2). Undo in the editor puts the
1032+ // text back; b's lines are A's text now, and A must embed c, not the
1033+ // trashed b
1034+ let (d, mut app, b, c, b_file) = editing_nested_blocks();
1035+ for k in [
1036+ KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
1037+ KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
1038+ KeyEvent::new(KeyCode::End, KeyModifiers::NONE),
1039+ KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE),
1040+ ] {
1041+ app.handle_key(k);
1042+ }
1043+ ctrl(&mut app, 's');
1044+ assert_eq!(root(&d), format!("# A\n\n- one- b\n {}\n- two\n", c));
1045+ assert!(!b_file.exists(), "b's file is trashed");
1046+ ctrl(&mut app, 'z');
1047+ keys(&mut app, "⎋");
1048+ assert_eq!(app.mode_pub(), "normal");
1049+ assert_eq!(root(&d), format!("# A\n\n- one\n- b\n {}\n- two\n", c), "b is {}, trashed", b);
1050+}
1051+
1052+#[test]
1053+fn a_refused_close_keeps_a_cut_block_in_transit() {
1054+ for reload in [false, true] {
1055+ a_refused_save_keeps_a_cut_block_in_transit(reload);
1056+ }
1057+}
1058+
1059+/// On Esc, or before a reload (which saves the editor first, §11.2).
1060+fn a_refused_save_keeps_a_cut_block_in_transit(reload: bool) {
1061+ // A embeds "cut" (racfer, with a due date) and "other" (dozzod)
1062+ let d = tempfile::tempdir().unwrap();
1063+ std::fs::write(
1064+ d.path().join("root.md"),
1065+ "# A\n\n- one\n![[racfer-hattes-mislup-nodrys]]\n![[dozzod-binwes-talsun-worbec]]\n- two\n",
1066+ )
1067+ .unwrap();
1068+ let cut = d.path().join("racfer~cut.md");
1069+ std::fs::write(&cut, "---\nid: racfer-hattes-mislup-nodrys\ndue: 2026-10-01\n---\n\n- cut\n").unwrap();
1070+ let other = d.path().join("dozzod~other.md");
1071+ std::fs::write(&other, "---\nid: dozzod-binwes-talsun-worbec\n---\n\n- other\n").unwrap();
1072+ let mut app = App::new(d.path()).unwrap();
1073+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
1074+ keys(&mut app, "e");
1075+ for _ in 0..3 {
1076+ app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
1077+ }
1078+ // cut "- cut": the block is in transit while the line is in the clipboard
1079+ ctrl(&mut app, 'k');
1080+ ctrl(&mut app, 's');
1081+ assert!(cut.exists(), "in transit");
1082+ // type into "other", which another program changes meanwhile
1083+ app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
1084+ keys(&mut app, "X");
1085+ std::fs::write(&other, "---\nid: dozzod-binwes-talsun-worbec\n---\n\n- other, from Helix\n").unwrap();
1086+ // Esc: the save is refused, so the editor stays open with its text, and
1087+ // the cut line can still be pasted back as the block
1088+ if reload {
1089+ app.reload_external();
1090+ } else {
1091+ keys(&mut app, "⎋");
1092+ }
1093+ assert_eq!(app.mode_pub(), "edit", "still editing");
1094+ assert!(cut.exists(), "a block cut in an editor still open was trashed (reload: {})", reload);
1095+}
1096+
1097+#[test]
1098+fn review_reverting_after_a_saved_cut_leaves_no_block_file_embedded_nowhere() {
1099+ // the cut title line is in transit across the save (§5.2): A is written
1100+ // without the embed and the block's file kept. Revert (`:q!`) leaves the
1101+ // editor; the block must then be deleted, as on any other way out, or
1102+ // still be embedded, never a file that nothing embeds
1103+ let (d, mut app, embed, block) = editing_task_block_with_body();
1104+ ctrl(&mut app, 'k');
1105+ ctrl(&mut app, 's');
1106+ assert_eq!(root(&d), "# A\n\n- one\n body\n- two\n");
1107+ ctrl(&mut app, 'e');
1108+ keys(&mut app, "q!⏎");
1109+ assert_eq!(app.mode_pub(), "normal");
1110+ let r = root(&d);
1111+ assert!(!block.exists() || r.contains(&embed), "{} exists, root.md is {:?}", block.display(), r);
1112+}
1113+
1114+#[test]
1115+fn indenting_a_block_title_line_nests_the_block_where_the_line_sits() {
1116+ // §5.2: Tab at the start of a nested block's title line, or Vim `>>`,
1117+ // nests the block under "- one" — its embed goes where the line sits,
1118+ // the block and its file stay — and Shift-Tab takes it back out
1119+ let task_file = |d: &tempfile::TempDir| {
1120+ std::fs::read_dir(d.path()).unwrap().any(|e| e.unwrap().file_name().to_string_lossy().ends_with("~task.md"))
1121+ };
1122+ let (d, mut app, embed) = editing_task_block();
1123+ app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
1124+ app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
1125+ ctrl(&mut app, 's');
1126+ assert_eq!(root(&d), format!("# A\n\n- one\n {}\n- two\n", embed));
1127+ assert!(task_file(&d));
1128+ app.handle_key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
1129+ keys(&mut app, "⎋");
1130+ assert_eq!(root(&d), format!("# A\n\n- one\n{}\n- two\n", embed));
1131+ assert!(task_file(&d));
1132+ let (d, mut app, embed) = editing_task_block();
1133+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
1134+ keys(&mut app, ">>:w⏎");
1135+ assert_eq!(root(&d), format!("# A\n\n- one\n {}\n- two\n", embed));
1136+ assert!(task_file(&d));
1137+}
1138+
1139+/// root.md "# A\n\n- one\n![[task]]\n\na\nb\n"; returns the embed and the
1140+/// block's file.
1141+fn vault_with_block_then_text() -> (tempfile::TempDir, String, std::path::PathBuf) {
1142+ let d = tempfile::tempdir().unwrap();
1143+ std::fs::write(d.path().join("root.md"), "# A\n\n- one\n- task\n\na\nb\n").unwrap();
1144+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
1145+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
1146+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
1147+ let block = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
1148+ drop(v);
1149+ let embed = format!("![[{}]]", id.as_str());
1150+ assert_eq!(root(&d), format!("# A\n\n- one\n{}\n\na\nb\n", embed));
1151+ (d, embed, block)
1152+}
1153+
1154+#[test]
1155+fn vim_put_below_an_empty_line_after_a_block_stays_in_the_parent() {
1156+ // §5.2: a line pasted somewhere takes the tag of the line above it; `p`
1157+ // on the blank line after the block puts "a" and "b" below that blank
1158+ // line, which is A's, so they are A's text, not the block's
1159+ let (d, embed, block) = vault_with_block_then_text();
1160+ let before = std::fs::read_to_string(&block).unwrap();
1161+ let mut app = App::new(d.path()).unwrap();
1162+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
1163+ // lines: "# A", "", "- one", "- task", "", "a", "b"
1164+ keys(&mut app, "ejjjjjyjkp:w⏎");
1165+ let r = root(&d);
1166+ let b = std::fs::read_to_string(&block).unwrap();
1167+ assert_eq!((r, b), (format!("# A\n\n- one\n{}\n\na\nb\na\nb\n", embed), before));
1168+ // `o` on that blank line opens a line of A below it, as Enter there does
1169+ // in the normal keymap
1170+ let (d, embed, block) = vault_with_block_then_text();
1171+ let before = std::fs::read_to_string(&block).unwrap();
1172+ let mut app = App::new(d.path()).unwrap();
1173+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
1174+ keys(&mut app, "ejjjjonew⎋:w⏎");
1175+ let r = root(&d);
1176+ let b = std::fs::read_to_string(&block).unwrap();
1177+ assert_eq!((r, b), (format!("# A\n\n- one\n{}\n\nnew\na\nb\n", embed), before));
1178+}
1179+
1180+#[test]
1181+fn deleting_whole_lines_up_to_a_block_titles_end_deletes_the_block() {
1182+ // normal keymap: "- one" and "- task" selected from column 0 to the end
1183+ // of "- task" and deleted: the block's title line is deleted, so the
1184+ // block is (§5.2), and leaving the editor saves
1185+ let (d, mut app, _embed) = editing_task_block();
1186+ for k in [
1187+ KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
1188+ KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT),
1189+ KeyEvent::new(KeyCode::End, KeyModifiers::SHIFT),
1190+ KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE),
1191+ ] {
1192+ app.handle_key(k);
1193+ }
1194+ keys(&mut app, "⎋");
1195+ assert_eq!(app.mode_pub(), "normal", "{}", draw(&mut app));
1196+ assert_eq!(root(&d), "# A\n\n\n- two\n");
1197+}
1198+
1199+#[test]
1200+fn cutting_a_block_title_and_pasting_it_back_in_place_keeps_its_nested_block() {
1201+ // Alt-Down then Alt-Up, or Ctrl-K then Ctrl-V (pasted straight back above
1202+ // " - c") on b's title line: the text and every line's place are as they
1203+ // were, so c's embed must still be in b's file, not moved out to A (§5.2)
1204+ let cut_paste = [KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL)];
1205+ let down_up = [KeyEvent::new(KeyCode::Down, KeyModifiers::ALT), KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)];
1206+ for seq in [down_up, cut_paste] {
1207+ let (d, mut app, b, c, b_file) = editing_nested_blocks();
1208+ let before = std::fs::read_to_string(&b_file).unwrap();
1209+ app.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
1210+ for k in seq {
1211+ app.handle_key(k);
1212+ }
1213+ keys(&mut app, "⎋");
1214+ assert_eq!(app.mode_pub(), "normal");
1215+ let r = root(&d);
1216+ let bf = std::fs::read_to_string(&b_file).unwrap();
1217+ assert_eq!((r, bf), (format!("# A\n\n- one\n{}\n- two\n", b), before), "{:?}; c is {}", seq[0], c);
1218+ }
1219+ // Vim's `ddP` and Helix's `xdP` on b's title line put it straight back too
1220+ for (keymap, seq) in [(fold_tui::app::EditKeys::Vim, "kddP:wq⏎"), (fold_tui::app::EditKeys::Helix, "kxdP:wq⏎")] {
1221+ let (d, mut app, b, _c, b_file) = editing_nested_blocks();
1222+ let before = std::fs::read_to_string(&b_file).unwrap();
1223+ app.set_edit_keys(keymap);
1224+ keys(&mut app, seq);
1225+ assert_eq!(app.mode_pub(), "normal");
1226+ let bf = std::fs::read_to_string(&b_file).unwrap();
1227+ assert_eq!((root(&d), bf), (format!("# A\n\n- one\n{}\n- two\n", b), before), "{:?}", keymap);
1228+ }
1229+}
1230+
1231+#[test]
1232+fn moving_a_block_under_another_blocks_title_moves_its_embed_into_that_block() {
1233+ // §5.2: the embed goes to the block the title line now sits in; " - c"
1234+ // moved up from under "- e" to under "- b" is indented past b's title
1235+ // line, so it is b's now, and its embed goes into b's file
1236+ let d = tempfile::tempdir().unwrap();
1237+ std::fs::write(d.path().join("root.md"), "# A\n\n- one\n- b\n- e\n - c\n- two\n").unwrap();
1238+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
1239+ let mut block = |path: &[&str]| {
1240+ let n = v.find_by_path(&path.iter().map(|s| s.to_string()).collect::<Vec<_>>()).unwrap();
1241+ let id = fold_core::ops::make_block(&mut v, n).unwrap();
1242+ (format!("![[{}]]", id.as_str()), v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path))
1243+ };
1244+ let (c, _) = block(&["A", "e", "c"]);
1245+ let (e, e_file) = block(&["A", "e"]);
1246+ let (b, b_file) = block(&["A", "b"]);
1247+ drop(v);
1248+ assert_eq!(root(&d), format!("# A\n\n- one\n{}\n{}\n- two\n", b, e));
1249+ let mut app = App::new(d.path()).unwrap();
1250+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
1251+ keys(&mut app, "e");
1252+ // the editor shows "# A", "", "- one", "- b", "- e", " - c", "- two"
1253+ for _ in 0..5 {
1254+ app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
1255+ }
1256+ app.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
1257+ keys(&mut app, "⎋");
1258+ assert_eq!(app.mode_pub(), "normal");
1259+ assert_eq!(root(&d), format!("# A\n\n- one\n{}\n{}\n- two\n", b, e));
1260+ assert!(std::fs::read_to_string(&b_file).unwrap().ends_with(&format!("\n- b\n {}\n", c)));
1261+ assert!(std::fs::read_to_string(&e_file).unwrap().ends_with("\n- e\n"));
1262+}
1263+
1264+#[test]
1265+fn ctrl_x_of_a_selected_block_title_line_then_ctrl_v_moves_the_block() {
1266+ // normal keymap: Shift-Down selects the whole "- task" line; Ctrl-X then
1267+ // Ctrl-V below " body" moves the line, as Ctrl-K then Ctrl-V does
1268+ // (§5.2: cut and paste moves its embed)
1269+ let (d, mut app, embed, block) = editing_task_block_with_body();
1270+ for k in [
1271+ KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT),
1272+ KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
1273+ KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
1274+ KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL),
1275+ ] {
1276+ app.handle_key(k);
1277+ }
1278+ keys(&mut app, "⎋");
1279+ assert_eq!(app.mode_pub(), "normal");
1280+ assert_eq!(root(&d), format!("# A\n\n- one\n body\n{}\n- two\n", embed));
1281+ assert!(std::fs::read_to_string(&block).unwrap().ends_with("\n- task\n"));
1282+}
1283+
1284+#[test]
1285+fn vim_dot_after_titling_a_new_node_does_not_run_the_title_as_commands() {
1286+ // `n` opens the editor typing the new node's title, as after Vim's `o`
1287+ // (§10.6); `.` then repeats that insertion or does nothing, but never
1288+ // replays the typed letters as normal-mode commands (`D`, `o`, ...)
1289+ let (d, mut app) = app_with("- a\n");
1290+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
1291+ keys(&mut app, "nDog⎋.:wq⏎");
1292+ assert_eq!(app.mode_pub(), "normal");
1293+ let r = root(&d);
1294+ assert!(r.starts_with("- a\n- ") && r.contains("Dog") && r.lines().count() == 2, "{:?}", r);
1295+}
1296+
1297+#[test]
1298+fn a_line_opened_at_the_start_of_a_line_below_a_block_stays_with_that_line() {
1299+ // Enter at column 0 of "- two", just below the nested block "task": the
1300+ // new line is "- two"'s block's (§5.2, a line typed takes the tag of the
1301+ // line it is typed at), so what is typed there is not saved into task
1302+ let (d, mut app, embed) = editing_task_block();
1303+ let block = std::fs::read_dir(d.path())
1304+ .unwrap()
1305+ .map(|e| e.unwrap().path())
1306+ .find(|p| p.extension().is_some_and(|x| x == "md") && !p.ends_with("root.md"))
1307+ .unwrap();
1308+ let before = std::fs::read_to_string(&block).unwrap();
1309+ app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
1310+ app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
1311+ app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
1312+ app.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
1313+ keys(&mut app, "- new⎋");
1314+ assert_eq!(app.mode_pub(), "normal");
1315+ assert_eq!(root(&d), format!("# A\n\n- one\n{}\n- new\n- two\n", embed));
1316+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before);
1317+}
1318+
1319+#[test]
1320+fn review_tab_on_a_block_title_under_a_sibling_block_keeps_the_block_in_the_outline() {
1321+ // §5.2: Tab on "- c" puts it under "- b", itself a nested block: c then
1322+ // belongs in b, as moving the line there does, not an embed indented
1323+ // under b's bare embed (§4.7) that the tree does not walk into
1324+ let d = tempfile::tempdir().unwrap();
1325+ std::fs::write(d.path().join("root.md"), "# A\n\n- b\n- c\n").unwrap();
1326+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
1327+ let b = v.find_by_path(&["A".into(), "b".into()]).unwrap();
1328+ let b = fold_core::ops::make_block(&mut v, b).unwrap();
1329+ let b_file = v.dir.join(&v.tree.files[v.tree.block_by_id(&b).unwrap().0].path);
1330+ let c = v.find_by_path(&["A".into(), "c".into()]).unwrap();
1331+ let c = fold_core::ops::make_block(&mut v, c).unwrap();
1332+ drop(v);
1333+ let (b, c) = (format!("![[{}]]", b.as_str()), format!("![[{}]]", c.as_str()));
1334+ let mut app = App::new(d.path()).unwrap();
1335+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
1336+ keys(&mut app, "e");
1337+ // "# A", "", "- b", "- c"
1338+ for _ in 0..3 {
1339+ app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
1340+ }
1341+ app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
1342+ app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
1343+ ctrl(&mut app, 's');
1344+ let v = fold_core::vault::Vault::open(d.path()).unwrap();
1345+ let mut titles = Vec::new();
1346+ v.tree.walk(v.tree.root, &mut |t, r| titles.push(t.node(r).title.clone()));
1347+ assert!(titles.contains(&"c".to_string()), "walk {:?}; root.md:\n{}", titles, root(&d));
1348+ assert_eq!(root(&d), format!("# A\n\n{}\n", b));
1349+ assert!(std::fs::read_to_string(&b_file).unwrap().ends_with(&format!("\n- b\n {}\n", c)));
1350+ // Shift-Tab takes it back out of b
1351+ app.handle_key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
1352+ keys(&mut app, "⎋");
1353+ assert_eq!(app.mode_pub(), "normal");
1354+ assert_eq!(root(&d), format!("# A\n\n{}\n{}\n", b, c));
1355+ assert!(std::fs::read_to_string(&b_file).unwrap().ends_with("\n- b\n"));
1356+}
1357+
1358+#[test]
1359+fn review_indenting_a_block_title_line_under_a_sibling_blocks_title_keeps_it_in_the_outline() {
1360+ // §5.2: re-indenting a nested block's title line moves the block to where
1361+ // the line sits. Tab on "- c" puts it under "- b", itself a nested block:
1362+ // c then belongs in b (as moving the line there does), not an embed
1363+ // indented under b's bare embed (§4.7: a diagnostic) that the outline
1364+ // does not show
1365+ let d = tempfile::tempdir().unwrap();
1366+ std::fs::write(d.path().join("root.md"), "# A\n\n- one\n- b\n- c\n- two\n").unwrap();
1367+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
1368+ let c = v.find_by_path(&["A".into(), "c".into()]).unwrap();
1369+ fold_core::ops::make_block(&mut v, c).unwrap();
1370+ let b = v.find_by_path(&["A".into(), "b".into()]).unwrap();
1371+ fold_core::ops::make_block(&mut v, b).unwrap();
1372+ drop(v);
1373+ let mut app = App::new(d.path()).unwrap();
1374+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
1375+ keys(&mut app, "e");
1376+ // "# A", "", "- one", "- b", "- c", "- two"
1377+ for _ in 0..4 {
1378+ app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
1379+ }
1380+ app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
1381+ app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
1382+ keys(&mut app, "⎋");
1383+ assert_eq!(app.mode_pub(), "normal");
1384+ let rows: Vec<String> = app.rows().iter().map(|r| app.title_of(r.nref)).collect();
1385+ assert!(rows.contains(&"c".to_string()), "c is gone from the outline: {:?}; root.md {:?}", rows, root(&d));
1386+}
1387+
1388+#[test]
1389+fn review_typing_a_line_above_a_tabbed_block_title_keeps_the_block() {
1390+ // §5.2: "note" typed at the start of task's re-indented title line and
1391+ // split off with Enter is A's text; the title line is still there, so
1392+ // the block and its file stay
1393+ let (d, mut app, embed, block) = editing_task_block_with_body();
1394+ app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
1395+ app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
1396+ app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
1397+ keys(&mut app, "note⏎⎋");
1398+ assert_eq!(app.mode_pub(), "normal");
1399+ assert!(block.exists() && root(&d).contains(&embed), "block file exists: {}; root.md:\n{}", block.exists(), root(&d));
1400+}
1401+
1402+#[test]
1403+fn an_item_typed_at_the_start_of_a_block_title_and_split_off_is_the_parents() {
1404+ // §5.2: "- new" typed at the start of task's title line and split off
1405+ // with Enter is a line in front of the title line: A's, not a new title
1406+ // for task with its old one written under it into task's file
1407+ let (d, mut app, embed, block) = editing_task_block_with_body();
1408+ let before = std::fs::read_to_string(&block).unwrap();
1409+ app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
1410+ keys(&mut app, "- new⏎⎋");
1411+ assert_eq!(app.mode_pub(), "normal");
1412+ assert_eq!(root(&d), format!("# A\n\n- one\n- new\n{}\n- two\n", embed));
1413+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before);
1414+}
1415+
1416+#[test]
1417+fn review_deleting_from_a_block_title_through_a_later_lines_end_deletes_the_block() {
1418+ // the mirror of deleting_whole_lines_up_to_a_block_titles_end_deletes_the_block:
1419+ // "- task" and "- two" selected from column 0 of "- task" through the end
1420+ // of "- two" and deleted; both lines go whole, so the block's title line
1421+ // is deleted and the block with it (§5.2), and leaving the editor saves
1422+ let d = tempfile::tempdir().unwrap();
1423+ std::fs::write(d.path().join("root.md"), "# A\n\n- one\n- task\n- two\n- three\n").unwrap();
1424+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
1425+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
1426+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
1427+ let block = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
1428+ drop(v);
1429+ let mut app = App::new(d.path()).unwrap();
1430+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
1431+ keys(&mut app, "e");
1432+ // "# A", "", "- one", "- task", "- two", "- three"
1433+ for _ in 0..3 {
1434+ app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
1435+ }
1436+ for k in [
1437+ KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT),
1438+ KeyEvent::new(KeyCode::End, KeyModifiers::SHIFT),
1439+ KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE),
1440+ ] {
1441+ app.handle_key(k);
1442+ }
1443+ keys(&mut app, "⎋");
1444+ assert_eq!(app.mode_pub(), "normal", "the editor cannot be left:\n{}", draw(&mut app));
1445+ assert_eq!(root(&d), "# A\n\n- one\n\n- three\n");
1446+ assert!(!block.exists(), "the deleted block's file is still in the vault");
1447+}
1448+
1449+#[test]
1450+fn deleting_a_blocks_title_and_body_whole_deletes_the_block() {
1451+ // "- task" and its body selected from column 0 through the end of the
1452+ // body, normal keymap and vim `vj$d`: every line of the block goes, its
1453+ // title line with them, so the block does (§5.2); the empty line left
1454+ // is A's, and leaving the editor saves
1455+ for vim in [false, true] {
1456+ let (d, mut app, _embed, block) = editing_task_block_with_body();
1457+ if vim {
1458+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
1459+ keys(&mut app, "0vj$d:wq⏎");
1460+ } else {
1461+ for k in [
1462+ KeyEvent::new(KeyCode::Home, KeyModifiers::NONE),
1463+ KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT),
1464+ KeyEvent::new(KeyCode::End, KeyModifiers::SHIFT),
1465+ KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE),
1466+ ] {
1467+ app.handle_key(k);
1468+ }
1469+ keys(&mut app, "⎋");
1470+ }
1471+ assert_eq!(app.mode_pub(), "normal", "vim {}:\n{}", vim, draw(&mut app));
1472+ assert_eq!(root(&d), "# A\n\n- one\n\n- two\n", "vim {}", vim);
1473+ assert!(!block.exists(), "vim {}: the deleted block's file is still in the vault", vim);
1474+ }
1475+}
1476+
1477+#[test]
1478+fn review_reverting_after_a_saved_cut_then_a_copy_leaves_no_block_file_embedded_nowhere() {
1479+ // the cut title line is in transit across the save (§5.2): A is written
1480+ // without the embed. Copying another line replaces the clipboard, so the
1481+ // block can no longer be pasted back; Revert (`:q!`) must then delete it,
1482+ // as leaving any other way does, never leave a file that nothing embeds
1483+ let (d, mut app, embed, block) = editing_task_block_with_body();
1484+ ctrl(&mut app, 'k');
1485+ ctrl(&mut app, 's');
1486+ assert_eq!(root(&d), "# A\n\n- one\n body\n- two\n");
1487+ assert!(block.exists(), "in transit");
1488+ // the cursor is on " body": copy it over the cut line
1489+ ctrl(&mut app, 'c');
1490+ ctrl(&mut app, 'e');
1491+ keys(&mut app, "q!⏎");
1492+ assert_eq!(app.mode_pub(), "normal");
1493+ let r = root(&d);
1494+ assert!(!block.exists() || r.contains(&embed), "{} exists, root.md is {:?}", block.display(), r);
1495+}
crates/fold-tui/tests/fixes.rs+420
@@ -194,3 +194,423 @@ fn respelling_moves_the_node_and_keeps_the_cursor_on_it() {
194194 press(&mut app, "kJ");
195195 assert_eq!(root(&d), "# P\n\n- b\n\n## a\n");
196196}
197+
198+#[test]
199+fn editor_message_in_a_short_terminal_does_not_panic() {
200+ let (_d, mut app) = app_with("# A\n\nbody\n");
201+ press(&mut app, "e");
202+ // the default keymap answers "nothing to undo", a message on the pane's
203+ // last line; the pane has no inner rows at these sizes
204+ app.handle_key(ctrl('z'));
205+ for (w, h) in [(60u16, 5u16), (60, 6), (100, 4), (100, 3)] {
206+ draw(&mut app, w, h);
207+ }
208+}
209+
210+#[test]
211+fn alt_down_keeps_a_multi_line_selection() {
212+ let with = |code, m| KeyEvent::new(code, m);
213+ let (d, mut app) = app_with("# A\n\na\nb\nc\nd\n");
214+ press(&mut app, "e");
215+ draw(&mut app, 100, 24);
216+ // the cursor opens on the title; go to "a", select "a" and "b", move them down twice
217+ app.handle_key(key(KeyCode::Down));
218+ app.handle_key(key(KeyCode::Down));
219+ app.handle_key(with(KeyCode::Down, KeyModifiers::SHIFT));
220+ app.handle_key(with(KeyCode::End, KeyModifiers::SHIFT));
221+ app.handle_key(with(KeyCode::Down, KeyModifiers::ALT));
222+ app.handle_key(with(KeyCode::Down, KeyModifiers::ALT));
223+ app.handle_key(key(KeyCode::Esc));
224+ app.handle_key(key(KeyCode::Esc));
225+ assert_eq!(app.mode_pub(), "normal");
226+ // the first move must leave "a" and "b" selected, so the second moves both
227+ assert_eq!(root(&d), "# A\n\nc\nd\na\nb\n");
228+}
229+
230+#[test]
231+fn editor_undo_stops_at_cursor_moves() {
232+ // normal keymap: typing somewhere else after a cursor move is a new undo
233+ // step, so one Ctrl-Z takes back only the last run of typing
234+ let (d, mut app) = app_with("# A\n\none\ntwo\n");
235+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
236+ press(&mut app, "e");
237+ app.handle_key(key(KeyCode::Down));
238+ app.handle_key(key(KeyCode::Down));
239+ press(&mut app, "A");
240+ app.handle_key(key(KeyCode::Down));
241+ app.handle_key(key(KeyCode::Home));
242+ press(&mut app, "B");
243+ app.handle_key(ctrl('s'));
244+ assert_eq!(root(&d), "# A\n\nAone\nBtwo\n");
245+ app.handle_key(ctrl('z'));
246+ app.handle_key(ctrl('s'));
247+ assert_eq!(root(&d), "# A\n\nAone\ntwo\n");
248+}
249+
250+#[test]
251+fn an_embed_cycle_does_not_overflow_the_stack() {
252+ // §6.2: a cycle is a diagnostic, so a vault may contain one. The outline
253+ // shows the block once and skips the repeat visit, as walk/render do.
254+ let dir = tempfile::tempdir().unwrap();
255+ std::fs::write(dir.path().join("root.md"), "![[racfer-hattes-mislup-nodrys]]\n").unwrap();
256+ std::fs::write(
257+ dir.path().join("racfer~loop.md"),
258+ "---\nid: racfer-hattes-mislup-nodrys\n---\n\n- Loop\n ![[racfer-hattes-mislup-nodrys]]\n",
259+ )
260+ .unwrap();
261+ let mut app = App::new(dir.path()).unwrap();
262+ draw(&mut app, 100, 24);
263+ let rows = app.rows();
264+ assert_eq!(current_title(&app), "Loop");
265+ let loops = rows.iter().filter(|r| app.title_of(r.nref) == "Loop").count();
266+ assert_eq!(loops, 1);
267+ app.show_reading = true;
268+ press(&mut app, "lj");
269+ draw(&mut app, 100, 24);
270+}
271+
272+#[test]
273+fn deleting_the_zoomed_node_zooms_out() {
274+ let (d, mut app) = app_with("# A\n\n# B\n\n- b1\n");
275+ press(&mut app, "j");
276+ app.handle_key(key(KeyCode::Enter)); // zoom into B; the cursor is on B
277+ assert_eq!(current_title(&app), "B");
278+ press(&mut app, "d"); // B is gone: the zoom must not dangle
279+ assert_eq!(root(&d), "# A\n");
280+ assert_eq!(current_title(&app), "A");
281+ draw(&mut app, 100, 24);
282+ // a zoomed node inside another zooms out to it
283+ let (d, mut app) = app_with("# A\n\n## B\n\n# C\n");
284+ press(&mut app, "j");
285+ app.handle_key(key(KeyCode::Enter));
286+ press(&mut app, "d");
287+ assert_eq!(root(&d), "# A\n\n# C\n");
288+ let titles: Vec<String> = app.rows().iter().map(|r| app.title_of(r.nref)).collect();
289+ assert_eq!(titles, ["A"]);
290+}
291+
292+#[test]
293+fn undoing_under_a_zoom_keeps_the_zoom() {
294+ let (_d, mut app) = app_with("- a\n- b\n");
295+ press(&mut app, "ypG"); // paste a copy of a; the cursor is on b
296+ app.handle_key(key(KeyCode::Enter)); // zoom into b
297+ assert_eq!(current_title(&app), "b");
298+ press(&mut app, "u"); // the file shrinks back to a, b
299+ assert_eq!(current_title(&app), "b");
300+ press(&mut app, "U");
301+ assert_eq!(current_title(&app), "b");
302+ draw(&mut app, 100, 24);
303+}
304+
305+#[test]
306+fn moving_the_zoomed_node_keeps_the_zoom_on_it() {
307+ let (d, mut app) = app_with("- A\n- B\n - B1\n");
308+ press(&mut app, "j");
309+ app.handle_key(key(KeyCode::Enter)); // zoom into B
310+ press(&mut app, "K");
311+ assert_eq!(root(&d), "- B\n - B1\n- A\n");
312+ assert_eq!(current_title(&app), "B");
313+ let titles: Vec<String> = app.rows().iter().map(|r| app.title_of(r.nref)).collect();
314+ assert_eq!(titles, ["B", "B1"]);
315+}
316+
317+#[test]
318+fn a_dismissed_reading_pane_menu_does_not_retarget_outline_verbs() {
319+ // B is inside the folded A, so it has no outline row
320+ let (d, mut app) = app_with("# A\n\n## B\n\n# C\n");
321+ press(&mut app, "h");
322+ app.handle_key(key(KeyCode::Tab));
323+ press(&mut app, "jjm"); // the node menu of B, from its line in the pane
324+ app.handle_key(key(KeyCode::Esc));
325+ app.handle_key(key(KeyCode::Tab));
326+ press(&mut app, "j");
327+ assert_eq!(current_title(&app), "C");
328+ press(&mut app, "d");
329+ assert_eq!(root(&d), "# A\n\n## B\n");
330+}
331+
332+#[test]
333+fn new_sibling_of_the_zoomed_node_opens_the_new_node() {
334+ let (d, mut app) = app_with("# A\n\n# B\n\n- b1\n");
335+ press(&mut app, "j");
336+ app.handle_key(key(KeyCode::Enter)); // zoom into B, the cursor on B (row 0)
337+ assert_eq!(current_title(&app), "B");
338+ press(&mut app, "n");
339+ // the empty sibling is written after B's subtree...
340+ assert_eq!(root(&d), "# A\n\n# B\n\n- b1\n\n#\n");
341+ // ...and it, not B, is what the editor opens on (§10.3 `n`), with the
342+ // zoom widened so that it is on screen
343+ assert_eq!(app.mode_pub(), "edit");
344+ assert_eq!(current_title(&app), "");
345+ press(&mut app, "C");
346+ app.handle_key(key(KeyCode::Esc));
347+ assert_eq!(root(&d), "# A\n\n# B\n\n- b1\n\n# C\n");
348+ assert_eq!(current_title(&app), "C");
349+ // a sibling of an embedded block lands after its embed
350+ let d = tempfile::tempdir().unwrap();
351+ std::fs::write(d.path().join("root.md"), "- Homelab\n ![[racfer-hattes-mislup-nodrys]]\n").unwrap();
352+ std::fs::write(
353+ d.path().join("racfer~zfs.md"),
354+ "---\nid: racfer-hattes-mislup-nodrys\n---\n\n- ZFS\n",
355+ )
356+ .unwrap();
357+ let mut app = App::new(d.path()).unwrap();
358+ press(&mut app, "j");
359+ assert_eq!(current_title(&app), "ZFS");
360+ press(&mut app, "nX");
361+ app.handle_key(key(KeyCode::Esc));
362+ assert_eq!(root(&d), "- Homelab\n ![[racfer-hattes-mislup-nodrys]]\n - X\n");
363+ assert_eq!(current_title(&app), "X");
364+}
365+
366+#[test]
367+fn filter_pick_inside_a_block_unfolds_the_embedding_ancestors() {
368+ let dir = tempfile::tempdir().unwrap();
369+ std::fs::write(dir.path().join("root.md"), "- Homelab\n ![[racfer-hattes-mislup-nodrys]]\n").unwrap();
370+ std::fs::write(
371+ dir.path().join("racfer~zfs.md"),
372+ "---\nid: racfer-hattes-mislup-nodrys\n---\n\n- ZFS\n - snapshots\n",
373+ )
374+ .unwrap();
375+ let mut app = App::new(dir.path()).unwrap();
376+ assert_eq!(current_title(&app), "Homelab");
377+ press(&mut app, "h"); // fold Homelab
378+ press(&mut app, "/snapshots");
379+ app.handle_key(key(KeyCode::Enter));
380+ // §10.5: the pick unfolds its ancestors (through the embed) and selects it
381+ assert_eq!(current_title(&app), "snapshots");
382+}
383+
384+#[test]
385+fn backspace_from_a_zoomed_block_goes_to_its_outline_parent() {
386+ // §10.3 `Backspace`: zoom out to parent. A block root's parent in the
387+ // outline is the node that embeds it (as the breadcrumbs show), not
388+ // its own file's root.
389+ let dir = tempfile::tempdir().unwrap();
390+ std::fs::write(
391+ dir.path().join("root.md"),
392+ "- Homelab\n - NAS\n ![[racfer-hattes-mislup-nodrys]]\n",
393+ )
394+ .unwrap();
395+ std::fs::write(
396+ dir.path().join("racfer~zfs.md"),
397+ "---\nid: racfer-hattes-mislup-nodrys\n---\n\n- ZFS\n - snapshots\n",
398+ )
399+ .unwrap();
400+ let mut app = App::new(dir.path()).unwrap();
401+ let titles = |app: &App| -> Vec<String> { app.rows().iter().map(|r| app.title_of(r.nref)).collect() };
402+ press(&mut app, "jj");
403+ assert_eq!(current_title(&app), "ZFS");
404+ app.handle_key(key(KeyCode::Enter));
405+ assert_eq!(titles(&app), ["ZFS", "snapshots"]);
406+ app.handle_key(key(KeyCode::Backspace));
407+ assert_eq!(titles(&app), ["NAS", "ZFS", "snapshots"]);
408+ assert_eq!(current_title(&app), "ZFS");
409+ // the same from the reading pane
410+ app.show_reading = true;
411+ app.handle_key(key(KeyCode::Enter)); // zoom into ZFS; the pane takes focus
412+ assert_eq!(titles(&app), ["ZFS", "snapshots"]);
413+ app.handle_key(key(KeyCode::Backspace));
414+ assert_eq!(titles(&app), ["NAS", "ZFS", "snapshots"]);
415+}
416+
417+#[test]
418+fn enter_on_a_task_heading_in_the_reading_pane_zooms() {
419+ // SPEC §10.4: Enter on a heading zooms into it; `x` toggles a task heading
420+ let (d, mut app) = app_with("# A\n\n## [ ] T\n\nbody\n\n- [ ] i\n");
421+ app.handle_key(key(KeyCode::Tab));
422+ press(&mut app, "jj");
423+ assert!(app.reading_doc_pub().lines[app.read_cursor_pub()].contains("[ ] T"));
424+ app.handle_key(key(KeyCode::Enter));
425+ assert_eq!(root(&d), "# A\n\n## [ ] T\n\nbody\n\n- [ ] i\n", "Enter must not toggle the task");
426+ let doc = app.reading_doc_pub();
427+ assert!(doc.lines[0].contains("T"), "zoomed into T: {:?}", doc.lines);
428+ // on a task item, Enter still toggles
429+ let i = doc.lines.iter().position(|l| l.contains("[ ] i")).unwrap();
430+ for _ in 0..i {
431+ press(&mut app, "j");
432+ }
433+ app.handle_key(key(KeyCode::Enter));
434+ assert_eq!(root(&d), "# A\n\n## [ ] T\n\nbody\n\n- [x] i\n");
435+}
436+
437+/// Z zoomed, in the file after block B's: trashing B's file renumbers Z's.
438+fn zoomed_on_z_over_b() -> (tempfile::TempDir, App) {
439+ let dir = tempfile::tempdir().unwrap();
440+ std::fs::write(dir.path().join("root.md"), "- Top\n ![[racfer-hattes-mislup-nodrys]]\n").unwrap();
441+ std::fs::write(
442+ dir.path().join("racfer~z.md"),
443+ "---\nid: racfer-hattes-mislup-nodrys\n---\n\n- Z\n - c\n ![[dozzod-binwes-talsun-worbec]]\n",
444+ )
445+ .unwrap();
446+ std::fs::write(
447+ dir.path().join("dozzod~b.md"),
448+ "---\nid: dozzod-binwes-talsun-worbec\n---\n\n- B\n - b1\n",
449+ )
450+ .unwrap();
451+ let mut app = App::new(dir.path()).unwrap();
452+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
453+ press(&mut app, "j");
454+ app.handle_key(key(KeyCode::Enter));
455+ (dir, app)
456+}
457+
458+fn titles(app: &App) -> Vec<String> {
459+ app.rows().iter().map(|r| app.title_of(r.nref)).collect()
460+}
461+
462+#[test]
463+fn an_editor_save_keeps_the_zoom_on_its_node() {
464+ let shift = |code| KeyEvent::new(code, KeyModifiers::SHIFT);
465+ // deleting a nested block's title line trashes its file (§5.2); here
466+ // the cursor lands on a line of the enclosing block, which splices B
467+ let (d, mut app) = zoomed_on_z_over_b();
468+ assert_eq!(titles(&app), ["Z", "c", "B", "b1"]);
469+ press(&mut app, "je"); // edit c: "- c", " - B", " - b1"
470+ app.handle_key(key(KeyCode::Down));
471+ app.handle_key(key(KeyCode::Home));
472+ app.handle_key(shift(KeyCode::Down));
473+ app.handle_key(key(KeyCode::Delete));
474+ app.handle_key(key(KeyCode::Up));
475+ assert_eq!(md_files(&d), ["racfer~z.md", "root.md"]);
476+ assert_eq!(titles(&app), ["Z", "c", "b1"]);
477+ draw(&mut app, 100, 24);
478+ // the same deleted from c's line, and saved by Esc
479+ let (d, mut app) = zoomed_on_z_over_b();
480+ press(&mut app, "je");
481+ app.handle_key(key(KeyCode::Down));
482+ app.handle_key(key(KeyCode::End));
483+ app.handle_key(shift(KeyCode::Up));
484+ app.handle_key(shift(KeyCode::End));
485+ app.handle_key(key(KeyCode::Backspace));
486+ assert_eq!(md_files(&d).len(), 3);
487+ app.handle_key(key(KeyCode::Esc));
488+ assert_eq!(app.mode_pub(), "normal");
489+ assert_eq!(md_files(&d), ["racfer~z.md", "root.md"]);
490+ assert_eq!(titles(&app), ["Z", "c", "b1"]);
491+ draw(&mut app, 100, 24);
492+ // the zoomed node's own title edited: the zoom follows it
493+ let (d, mut app) = app_with("# A\n\n# B\n\n- b1\n");
494+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
495+ press(&mut app, "j");
496+ app.handle_key(key(KeyCode::Enter));
497+ press(&mut app, "e");
498+ app.handle_key(key(KeyCode::End));
499+ press(&mut app, "ee");
500+ app.handle_key(key(KeyCode::Esc));
501+ assert_eq!(root(&d), "# A\n\n# Bee\n\n- b1\n");
502+ assert_eq!(titles(&app), ["Bee", "b1"]);
503+ // a child's title edited: the zoom stays on the zoomed node
504+ let (d, mut app) = app_with("# A\n\n- a1\n\n# B\n\n- b1\n");
505+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
506+ press(&mut app, "jj");
507+ app.handle_key(key(KeyCode::Enter)); // zoom into B
508+ assert_eq!(titles(&app), ["B", "b1"]);
509+ press(&mut app, "e");
510+ app.handle_key(key(KeyCode::Down));
511+ app.handle_key(key(KeyCode::Down));
512+ app.handle_key(key(KeyCode::End));
513+ press(&mut app, "x");
514+ app.handle_key(key(KeyCode::Esc));
515+ assert_eq!(root(&d), "# A\n\n- a1\n\n# B\n\n- b1x\n");
516+ assert_eq!(titles(&app), ["B", "b1x"]);
517+ // Revert re-reads the files, and one changed on disk meanwhile
518+ let (d, mut app) = app_with("- a\n- Z\n - z1\n");
519+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
520+ press(&mut app, "j");
521+ app.handle_key(key(KeyCode::Enter));
522+ press(&mut app, "ex");
523+ std::fs::write(d.path().join("root.md"), "- a\n- a2\n- Z\n - z1\n").unwrap();
524+ app.run_action(fold_tui::app::Action::EditRevert);
525+ assert_eq!(app.mode_pub(), "normal");
526+ assert_eq!(titles(&app), ["Z", "z1"]);
527+}
528+
529+#[test]
530+fn move_to_a_later_node_keeps_the_cursor_on_the_moved_node() {
531+ // the destination is found again by the key it had before the move
532+ let (d, mut app) = app_with("# A\n\n- x\n\n# B\n\n# C\n");
533+ press(&mut app, "j");
534+ press(&mut app, "rC");
535+ app.handle_key(key(KeyCode::Enter));
536+ assert_eq!(root(&d), "# A\n\n# B\n\n# C\n\n- x\n");
537+ assert_eq!(current_title(&app), "x");
538+}
539+
540+#[test]
541+fn indenting_the_zoomed_node_keeps_the_zoom_on_it() {
542+ // zoomed into z, `>` makes it a child of s: z is still there, and the
543+ // zoom stays on it, as it does for `s` (make block)
544+ let (d, mut app) = app_with("# P\n\n- s\n- z\n - z1\n");
545+ press(&mut app, "jj");
546+ assert_eq!(current_title(&app), "z");
547+ app.handle_key(key(KeyCode::Enter));
548+ assert_eq!(titles(&app), ["z", "z1"]);
549+ press(&mut app, ">");
550+ assert_eq!(root(&d), "# P\n\n- s\n - z\n - z1\n");
551+ assert_eq!(titles(&app), ["z", "z1"], "the zoom left z");
552+ // and `<` takes it back out of s, the zoom still on it
553+ press(&mut app, "<");
554+ assert_eq!(root(&d), "# P\n\n- s\n- z\n - z1\n");
555+ assert_eq!(titles(&app), ["z", "z1"], "the zoom left z");
556+ // an item out of a section lands before the first section (§3.1)
557+ let (d, mut app) = app_with("# P\n\n## S\n\n- z\n - z1\n\n## T\n");
558+ press(&mut app, "jj");
559+ app.handle_key(key(KeyCode::Enter));
560+ assert_eq!(titles(&app), ["z", "z1"]);
561+ press(&mut app, "<");
562+ assert_eq!(root(&d), "# P\n\n- z\n - z1\n\n## S\n\n## T\n");
563+ assert_eq!(titles(&app), ["z", "z1"], "the zoom left z");
564+}
565+
566+#[test]
567+fn indenting_or_outdenting_a_node_keeps_the_cursor_on_it() {
568+ // among namesakes too: the node lands where the ordering rule puts it
569+ // (§3.1), not wherever its old path now leads
570+ let (d, mut app) = app_with("# P\n\n- s\n - z\n - mine\n- z\n - theirs\n");
571+ press(&mut app, "jj");
572+ assert_eq!(current_title(&app), "z");
573+ press(&mut app, "<");
574+ assert_eq!(root(&d), "# P\n\n- s\n- z\n - mine\n- z\n - theirs\n");
575+ assert_eq!(current_title(&app), "z");
576+ press(&mut app, "j");
577+ assert_eq!(current_title(&app), "mine");
578+ press(&mut app, "k>");
579+ assert_eq!(root(&d), "# P\n\n- s\n - z\n - mine\n- z\n - theirs\n");
580+ press(&mut app, "j");
581+ assert_eq!(current_title(&app), "mine");
582+}
583+
584+#[test]
585+fn one_undo_after_leaving_with_a_cut_block_title_leaves_no_block_file_embedded_nowhere() {
586+ // Ctrl-K on the block's title line, then Esc: leaving deletes the block
587+ // (§5.2), A written without its embed and the file trashed. Leaving is
588+ // one save, one op-log entry (§10.10): one `u` puts the block back
589+ // embedded, or leaves it trashed, never a file that nothing embeds
590+ let d = tempfile::tempdir().unwrap();
591+ std::fs::write(d.path().join("root.md"), "# A\n\n- one\n- task\n body\n- two\n").unwrap();
592+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
593+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
594+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
595+ let block = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
596+ drop(v);
597+ let embed = format!("![[{}]]", id.as_str());
598+ assert_eq!(root(&d), format!("# A\n\n- one\n{}\n- two\n", embed));
599+ let mut app = App::new(d.path()).unwrap();
600+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
601+ press(&mut app, "e");
602+ for _ in 0..3 {
603+ app.handle_key(key(KeyCode::Down));
604+ }
605+ app.handle_key(ctrl('k'));
606+ app.handle_key(key(KeyCode::Esc));
607+ assert_eq!(app.mode_pub(), "normal");
608+ assert_eq!(root(&d), "# A\n\n- one\n body\n- two\n");
609+ assert!(!block.exists());
610+ press(&mut app, "u");
611+ let r = root(&d);
612+ assert!(!block.exists() || r.contains(&embed), "one undo left {} embedded nowhere; root.md is {:?}", block.display(), r);
613+ // the whole of leaving is undone: the block is back where it was
614+ assert_eq!(r, format!("# A\n\n- one\n{}\n- two\n", embed));
615+ assert!(block.exists());
616+}
crates/fold-tui/tests/mouse.rs+563−3
@@ -134,10 +134,11 @@ fn drag_onto_a_title_nests_and_left_of_it_places_before() {
134134 app.handle_mouse(ev(MouseEventKind::Up(MouseButton::Left), onto));
135135 assert_eq!(root(&d), "# A\n\n- a1\n- b1\n\n# B\n");
136136 assert_eq!(title(&app), "b1", "the moved node stays selected");
137− // drag a1 to the far left of b1's row: before b1
137+ // drag b1 to the far left of a1's row: before a1 (a1 dropped before b1,
138+ // its next sibling, would stay where it is)
138139 draw(&mut app);
139− let from = app.hit_pos(Hit::Row(1)).unwrap();
140− let to_row = app.hit_pos(Hit::Row(2)).unwrap();
140+ let from = app.hit_pos(Hit::Row(2)).unwrap();
141+ let to_row = app.hit_pos(Hit::Row(1)).unwrap();
141142 app.handle_mouse(ev(MouseEventKind::Down(MouseButton::Left), from));
142143 app.handle_mouse(ev(MouseEventKind::Drag(MouseButton::Left), (2, to_row.1)));
143144 draw(&mut app);
@@ -369,3 +370,562 @@ fn a_short_hint_where_the_long_one_does_not_fit() {
369370 let last: String = (0..70).map(|x| b[(x, 9)].symbol()).collect();
370371 assert!(last.contains("right-click for actions · ? help"), "{}", last);
371372}
373+
374+#[test]
375+fn move_to_from_a_menu_moves_the_menus_node() {
376+ // B is folded away under A, so it has no outline row: its menu comes
377+ // from its line in the reading pane, and Move to… must move B, not A
378+ let (d, mut app) = app_with("# A\n\n## B\n\n# C\n");
379+ typing(&mut app, "h");
380+ assert_eq!(app.rows().len(), 2, "A folded");
381+ let s = draw(&mut app);
382+ assert!(s.contains("## B"), "{}", s);
383+ right_click(&mut app, Hit::DocLine(2));
384+ click(&mut app, Hit::MenuItem(fold_tui::app::node_menu_index(Action::Refile)));
385+ typing(&mut app, "C");
386+ app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
387+ assert_eq!(root(&d), "# A\n\n# C\n\n## B\n");
388+}
389+
390+#[test]
391+fn a_keymap_chosen_by_flag_is_not_remembered() {
392+ // §10.6: the keymap is remembered with the view unless `--keys` or
393+ // `$FOLD_KEYS` chose it for this run
394+ use fold_tui::app::{EditKeys, View};
395+ let (_d, mut app) = app_with("# A\n");
396+ app.set_edit_keys(EditKeys::Vim); // `--keys vim`, as run() does
397+ app.apply_view(View::parse("keys normal\n").unwrap(), true);
398+ assert_eq!(app.view().keys, Some(EditKeys::Normal));
399+ // nor is a switch made during such a run
400+ app.set_edit_keys(EditKeys::Helix);
401+ assert_eq!(app.view().keys, Some(EditKeys::Normal));
402+ // without --keys, the keymap in use is what the view remembers
403+ let (_d, mut app) = app_with("# A\n");
404+ app.apply_view(View::parse("keys normal\n").unwrap(), false);
405+ app.set_edit_keys(EditKeys::Helix);
406+ assert_eq!(app.view().keys, Some(EditKeys::Helix));
407+}
408+
409+#[test]
410+fn clicking_a_checkbox_while_editing_keeps_the_typed_text() {
411+ // §10.6: any outline verb saves the editor first; the outline's ☐ stays
412+ // drawn and clickable beside the editor
413+ let (d, mut app) = app_with("# A\n\nbody\n\n- [ ] t\n");
414+ typing(&mut app, "e");
415+ assert_eq!(app.mode_pub(), "edit");
416+ for k in [KeyCode::Down, KeyCode::Down, KeyCode::End] {
417+ app.handle_key(KeyEvent::new(k, KeyModifiers::NONE));
418+ }
419+ typing(&mut app, "!");
420+ click(&mut app, Hit::Check(1));
421+ app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
422+ assert_eq!(app.mode_pub(), "normal");
423+ assert_eq!(root(&d), "# A\n\nbody!\n\n- [x] t\n");
424+}
425+
426+#[test]
427+fn a_menu_verb_while_editing_saves_the_editor_and_keeps_editing() {
428+ // the node menu's Delete on another node, then more typing: both saved
429+ let (d, mut app) = app_with("# A\n\nbody\n\n# B\n\n# C\n");
430+ typing(&mut app, "e");
431+ for k in [KeyCode::Down, KeyCode::Down, KeyCode::End] {
432+ app.handle_key(KeyEvent::new(k, KeyModifiers::NONE));
433+ }
434+ typing(&mut app, "!");
435+ right_click(&mut app, Hit::Row(1));
436+ click(&mut app, Hit::MenuItem(fold_tui::app::node_menu_index(Action::Delete)));
437+ assert_eq!(app.mode_pub(), "edit");
438+ assert_eq!(root(&d), "# A\n\nbody!\n\n# C\n");
439+ typing(&mut app, "?");
440+ app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
441+ assert_eq!(app.mode_pub(), "normal");
442+ assert_eq!(root(&d), "# A\n\nbody!?\n\n# C\n");
443+}
444+
445+#[test]
446+fn a_drop_while_editing_saves_the_editor_first() {
447+ // dragging a row is a verb too (§10.1, §10.6)
448+ let (d, mut app) = app_with("# A\n\nbody\n\n# B\n\n- b1\n");
449+ typing(&mut app, "e");
450+ for k in [KeyCode::Down, KeyCode::Down, KeyCode::End] {
451+ app.handle_key(KeyEvent::new(k, KeyModifiers::NONE));
452+ }
453+ typing(&mut app, "!");
454+ // rows: A, B, b1 — drag b1 onto A's title
455+ draw(&mut app);
456+ let from = app.hit_pos(Hit::Row(2)).unwrap();
457+ let onto = app.hit_pos(Hit::Row(0)).unwrap();
458+ app.handle_mouse(ev(MouseEventKind::Down(MouseButton::Left), from));
459+ app.handle_mouse(ev(MouseEventKind::Drag(MouseButton::Left), onto));
460+ draw(&mut app);
461+ app.handle_mouse(ev(MouseEventKind::Up(MouseButton::Left), onto));
462+ app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
463+ assert_eq!(root(&d), "# A\n\nbody!\n\n- b1\n\n# B\n");
464+}
465+
466+#[test]
467+fn a_checkbox_click_toggles_the_row_clicked_when_the_save_first_adds_rows() {
468+ // unsaved text adds a task above `t`; saving it first shifts the rows,
469+ // and the click still toggles `t`
470+ let (d, mut app) = app_with("# A\n\n- [ ] t\n");
471+ typing(&mut app, "e");
472+ app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
473+ app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
474+ typing(&mut app, "- [ ] n");
475+ click(&mut app, Hit::Check(1));
476+ app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
477+ assert_eq!(root(&d), "# A\n- [ ] n\n\n- [x] t\n");
478+}
479+
480+#[test]
481+fn palette_from_the_editor_keeps_its_text() {
482+ let (d, mut app) = app_with("# A\n\nbody\n");
483+ typing(&mut app, "e");
484+ for k in [KeyCode::Down, KeyCode::Down, KeyCode::End] {
485+ app.handle_key(KeyEvent::new(k, KeyModifiers::NONE));
486+ }
487+ typing(&mut app, "!");
488+ assert!(app.editor_dirty());
489+ // ☰ Commands in the top bar, before the 750 ms autosave, then Esc: the
490+ // popup closes back to the editor it opened over (§10.2)
491+ button(&mut app, Action::Palette);
492+ app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
493+ assert_eq!(app.mode_pub(), "edit");
494+ typing(&mut app, "?");
495+ // and a pasted line goes into the editor only while it is on screen
496+ button(&mut app, Action::Help);
497+ app.handle_paste("pasted");
498+ app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
499+ assert_eq!(app.mode_pub(), "edit");
500+ button(&mut app, Action::Filter);
501+ click(&mut app, Hit::Backdrop);
502+ assert_eq!(app.mode_pub(), "edit");
503+ app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
504+ assert_eq!(app.mode_pub(), "normal");
505+ assert_eq!(root(&d), "# A\n\nbody!?\n");
506+}
507+
508+#[test]
509+fn editor_keys_from_the_palette_keeps_the_editor_and_its_text() {
510+ let (d, mut app) = app_with("# A\n\nbody\n");
511+ typing(&mut app, "e");
512+ for k in [KeyCode::Down, KeyCode::Down, KeyCode::End] {
513+ app.handle_key(KeyEvent::new(k, KeyModifiers::NONE));
514+ }
515+ typing(&mut app, "!");
516+ // ☰ → Editor keys, the documented way to change keymaps while editing
517+ button(&mut app, Action::Palette);
518+ typing(&mut app, "editor keys");
519+ app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
520+ assert_eq!(app.mode_pub(), "edit", "switching keymaps leaves the editor open");
521+ click(&mut app, Hit::Button(Action::EditDone, None));
522+ assert_eq!(root(&d), "# A\n\nbody!\n");
523+}
524+
525+#[test]
526+fn resolving_conflicts_from_the_editor_saves_and_leaves_it() {
527+ let dir = tempfile::tempdir().unwrap();
528+ std::fs::write(dir.path().join("root.md"), "# A\n\nbody\n\n- [ ] task\n").unwrap();
529+ std::fs::write(dir.path().join("root.sync-conflict-20260912-100000-phone.md"), "# A\n\nbody\n\n- [x] task\n").unwrap();
530+ let mut v = fold_core::vault::Vault::open(dir.path()).unwrap();
531+ fold_core::merge::merge_sync_conflicts(&mut v, false).unwrap();
532+ drop(v);
533+ let mut app = App::new(dir.path()).unwrap();
534+ app.show_reading = true;
535+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
536+ typing(&mut app, "ejjA!");
537+ app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
538+ assert!(app.cursor_block() && app.editor_dirty(), "Vim normal mode, unsaved");
539+ // the status bar's ⚠ opens the conflict view, which replaces the editor:
540+ // no editor is left behind it, holding the terminal's block cursor
541+ button(&mut app, Action::ResolveConflicts);
542+ assert_eq!(app.mode_pub(), "conflict");
543+ assert!(!app.cursor_block());
544+ app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
545+ assert_eq!(app.mode_pub(), "normal");
546+ assert!(root(&dir).contains("body!"), "{}", root(&dir));
547+ // nothing is left behind the outline: `e` opens a fresh editor
548+ typing(&mut app, "e");
549+ assert_eq!(app.mode_pub(), "edit");
550+ assert!(!app.editor_dirty());
551+}
552+
553+#[test]
554+fn an_open_menu_survives_an_external_rewrite_that_shrinks_the_file() {
555+ let (d, mut app) = app_with("# A\n\n- one\n- two\n- three\n");
556+ right_click(&mut app, Hit::Row(3));
557+ assert!(draw(&mut app).contains("Move down"), "menu open on three");
558+ // another program rewrites the file while the menu is open (§11.2)
559+ std::fs::write(d.path().join("root.md"), "# A\n").unwrap();
560+ app.reload_external();
561+ // the next frame must not index a node that no longer exists
562+ draw(&mut app);
563+}
564+
565+#[test]
566+fn an_open_menu_never_acts_on_a_different_node_after_a_reload() {
567+ let (d, mut app) = app_with("# A\n\n- one\n- two\n- three\n");
568+ right_click(&mut app, Hit::Row(3));
569+ assert!(draw(&mut app).contains("Move down"), "menu open on three");
570+ // a line inserted above renumbers every node below it
571+ std::fs::write(d.path().join("root.md"), "# A\n\n- zero\n- one\n- two\n- three\n").unwrap();
572+ app.reload_external();
573+ // whether the menu closed or still targets "three", Delete never hits "two"
574+ draw(&mut app);
575+ let delete = fold_tui::app::node_menu_index(Action::Delete);
576+ if let Some(p) = app.hit_pos(Hit::MenuItem(delete)) {
577+ app.handle_mouse(ev(MouseEventKind::Down(MouseButton::Left), p));
578+ app.handle_mouse(ev(MouseEventKind::Up(MouseButton::Left), p));
579+ }
580+ let text = root(&d);
581+ assert!(text.contains("- two\n"), "Delete removed a node the menu was not opened on: {:?}", text);
582+ assert_eq!(text, "# A\n\n- zero\n- one\n- two\n", "the menu stayed on three");
583+}
584+
585+#[test]
586+fn an_open_property_form_or_move_to_list_survives_an_external_rewrite() {
587+ let (d, mut app) = app_with("# A\n\n- one\n- two\n- three\n");
588+ click(&mut app, Hit::Row(3));
589+ typing(&mut app, "a");
590+ assert_eq!(app.mode_pub(), "props");
591+ std::fs::write(d.path().join("root.md"), "# A\n").unwrap();
592+ app.reload_external();
593+ draw(&mut app);
594+ assert_eq!(app.mode_pub(), "normal", "the form closes with its node gone");
595+ // Move to…'s list of nodes is made again from the files as they are
596+ let (d, mut app) = app_with("# A\n\n- one\n- two\n- three\n");
597+ right_click(&mut app, Hit::Row(3));
598+ click(&mut app, Hit::MenuItem(fold_tui::app::node_menu_index(Action::Refile)));
599+ std::fs::write(d.path().join("root.md"), "# A\n").unwrap();
600+ app.reload_external();
601+ assert!(draw(&mut app).contains("Move to"));
602+}
603+
604+#[test]
605+fn folding_above_the_selection_keeps_it() {
606+ let (_d, mut app) = app_with("# A\n\n- a1\n- a2\n\n# B\n\n# C\n");
607+ click(&mut app, Hit::Row(3));
608+ assert_eq!(title(&app), "B");
609+ click(&mut app, Hit::Fold(0));
610+ assert_eq!(app.rows().len(), 3, "A folded");
611+ assert_eq!(title(&app), "B", "a fold click above the selection must not move it");
612+ click(&mut app, Hit::Fold(0));
613+ assert_eq!(title(&app), "B", "nor an unfold click");
614+ // folding the node the selection is inside selects the folded node
615+ click(&mut app, Hit::Row(2));
616+ assert_eq!(title(&app), "a2");
617+ click(&mut app, Hit::Fold(0));
618+ assert_eq!(title(&app), "A");
619+}
620+
621+#[test]
622+fn dropping_outside_the_outline_does_nothing() {
623+ let (d, mut app) = app_with("# A\n\n- a1\n\n# B\n\n- b1\n");
624+ // rows: A, a1, B, b1 — drag b1 out into the reading pane, level with A
625+ draw(&mut app);
626+ let from = app.hit_pos(Hit::Row(3)).unwrap();
627+ let a = app.hit_pos(Hit::Row(0)).unwrap();
628+ let (px, _) = app.hit_pos(Hit::ReadingPane).unwrap();
629+ assert!(
630+ matches!(app.hit_at(px, a.1), Some(Hit::ReadingPane | Hit::DocLine(_))),
631+ "the drop point is in the reading pane, not on an outline row"
632+ );
633+ app.handle_mouse(ev(MouseEventKind::Down(MouseButton::Left), from));
634+ app.handle_mouse(ev(MouseEventKind::Drag(MouseButton::Left), (px, a.1)));
635+ draw(&mut app);
636+ app.handle_mouse(ev(MouseEventKind::Up(MouseButton::Left), (px, a.1)));
637+ assert_eq!(root(&d), "# A\n\n- a1\n\n# B\n\n- b1\n");
638+}
639+
640+#[test]
641+fn a_menu_verb_after_cutting_a_block_title_acts_on_the_menus_node() {
642+ // root.md embeds block "cut me" (dozzod, file 1) and block "task"
643+ // (racfer, file 2); the editor is open on A with "cut me"'s title cut
644+ let d = tempfile::tempdir().unwrap();
645+ std::fs::write(
646+ d.path().join("root.md"),
647+ "# A\n\n- one\n![[dozzod-binwes-talsun-worbec]]\n- two\n\n# B\n\n![[racfer-hattes-mislup-nodrys]]\n",
648+ )
649+ .unwrap();
650+ std::fs::write(d.path().join("dozzod~cut.md"), "---\nid: dozzod-binwes-talsun-worbec\n---\n\n- cut me\n").unwrap();
651+ let task = d.path().join("racfer~task.md");
652+ std::fs::write(&task, "---\nid: racfer-hattes-mislup-nodrys\n---\n\n- [ ] task\n").unwrap();
653+ let mut app = App::new(d.path()).unwrap();
654+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
655+ typing(&mut app, "e");
656+ for _ in 0..3 {
657+ app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
658+ }
659+ app.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
660+ // right-click "task" in the outline, then its menu's Toggle done: the
661+ // verb deletes the cut block first, which renumbers the files after it
662+ draw(&mut app);
663+ let row = (0..10).find(|&i| app.rows().get(i).is_some_and(|r| app.title_of(r.nref) == "task")).unwrap();
664+ right_click(&mut app, Hit::Row(row));
665+ click(&mut app, Hit::MenuItem(fold_tui::app::node_menu_index(Action::ToggleDone)));
666+ assert!(std::fs::read_to_string(&task).unwrap().contains("- [x] task"));
667+}
668+
669+#[test]
670+fn an_open_filter_survives_an_external_rewrite_that_shrinks_the_file() {
671+ let (d, mut app) = app_with("# A\n\n- one\n- two\n- three\n");
672+ typing(&mut app, "/thr");
673+ assert!(draw(&mut app).contains("three"), "the filter lists three");
674+ // another program rewrites the file while the filter is open (§11.2)
675+ std::fs::write(d.path().join("root.md"), "# A\n").unwrap();
676+ app.reload_external();
677+ // the next frame must not index a node that no longer exists
678+ draw(&mut app);
679+ // a line inserted above renumbers the nodes: the hits are found again,
680+ // and the selection stays on its node
681+ let (d, mut app) = app_with("# A\n\n- one\n- two\n- three\n");
682+ typing(&mut app, "/t");
683+ app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
684+ std::fs::write(d.path().join("root.md"), "# A\n\n- tea\n- one\n- two\n- three\n").unwrap();
685+ app.reload_external();
686+ assert!(draw(&mut app).contains("tea"), "the filter lists tea");
687+ app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
688+ assert_eq!(title(&app), "three");
689+}
690+
691+#[test]
692+fn a_reload_after_the_property_form_closed_does_not_crash() {
693+ // blocks B (file 1) and T (file 2); T's properties are looked at and
694+ // closed, then deleting B trashes its file, renumbering T's
695+ let d = tempfile::tempdir().unwrap();
696+ std::fs::write(d.path().join("root.md"), "# A\n\n![[dozzod-binwes-talsun-worbec]]\n![[racfer-hattes-mislup-nodrys]]\n").unwrap();
697+ std::fs::write(d.path().join("dozzod~b.md"), "---\nid: dozzod-binwes-talsun-worbec\n---\n\n- B\n").unwrap();
698+ std::fs::write(d.path().join("racfer~t.md"), "---\nid: racfer-hattes-mislup-nodrys\n---\n\n- T\n").unwrap();
699+ let mut app = App::new(d.path()).unwrap();
700+ typing(&mut app, "jja");
701+ assert_eq!(app.mode_pub(), "props");
702+ app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
703+ typing(&mut app, "kd");
704+ assert!(!d.path().join("dozzod~b.md").exists());
705+ // then another program changes a file (§11.2)
706+ std::fs::write(d.path().join("root.md"), "# A\n\n![[racfer-hattes-mislup-nodrys]]\n- new\n").unwrap();
707+ app.reload_external();
708+ assert!(draw(&mut app).contains("new"));
709+}
710+
711+#[test]
712+fn a_new_node_whose_editor_cannot_open_leaves_the_open_editor_alone() {
713+ // the editor is open on A with block b's body typed in, and another
714+ // program changed b's file: its save is refused, and it stays open
715+ let d = tempfile::tempdir().unwrap();
716+ std::fs::write(d.path().join("root.md"), "# A\n\n- one\n![[racfer-hattes-mislup-nodrys]]\n- two\n").unwrap();
717+ let b = d.path().join("racfer~b.md");
718+ std::fs::write(&b, "---\nid: racfer-hattes-mislup-nodrys\n---\n\n- b\n body\n").unwrap();
719+ let mut app = App::new(d.path()).unwrap();
720+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
721+ typing(&mut app, "e");
722+ for _ in 0..4 {
723+ app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
724+ }
725+ app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
726+ typing(&mut app, "X");
727+ std::fs::write(&b, "---\nid: racfer-hattes-mislup-nodrys\n---\n\n- b\n body, from Helix\n").unwrap();
728+ // New child (palette, menu): the node is made in root.md, but the
729+ // editor on A cannot be switched to it
730+ app.run_action(Action::NewChild);
731+ assert_eq!(app.mode_pub(), "edit");
732+ typing(&mut app, "new");
733+ let s = draw(&mut app);
734+ assert!(!s.contains("# A new"), "typing for the new node went into A's title:\n{}", s);
735+ assert!(s.contains("bodyX"), "{}", s);
736+}
737+
738+#[test]
739+fn making_the_edited_node_a_block_keeps_the_editor_on_it() {
740+ // editing b, *Make block* from the palette (or the node menu) on b: the
741+ // editor is re-rendered over what the verb wrote, still on b
742+ let (_d, mut app) = app_with("# A\n\n- a\n- b\n body\n");
743+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
744+ typing(&mut app, "jje");
745+ assert!(draw(&mut app).contains("Editing b"));
746+ app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
747+ app.run_action(Action::MakeBlock);
748+ assert_eq!(app.mode_pub(), "edit");
749+ typing(&mut app, "!");
750+ let s = draw(&mut app);
751+ assert!(s.contains("Editing b") && !s.contains("# A"), "the editor moved off b:\n{}", s);
752+}
753+
754+#[test]
755+fn a_verb_that_moves_the_edited_node_keeps_the_editor_on_it() {
756+ let editing_b = |text: &str| {
757+ let (d, mut app) = app_with(text);
758+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
759+ let b = app.rows().iter().position(|r| app.title_of(r.nref) == "b").unwrap();
760+ typing(&mut app, &"j".repeat(b));
761+ typing(&mut app, "e");
762+ assert!(draw(&mut app).contains("Editing b"));
763+ (d, app)
764+ };
765+ let on_b = |app: &mut App| {
766+ assert_eq!(app.mode_pub(), "edit");
767+ let s = draw(app);
768+ assert!(s.contains("Editing b") && !s.contains("- a"), "the editor moved off b:\n{}", s);
769+ };
770+ // Indent and Outdent (palette, node menu)
771+ let (d, mut app) = editing_b("# A\n\n- a\n- b\n body\n");
772+ app.run_action(Action::Indent);
773+ assert_eq!(root(&d), "# A\n\n- a\n - b\n body\n");
774+ on_b(&mut app);
775+ app.run_action(Action::Outdent);
776+ assert_eq!(root(&d), "# A\n\n- a\n- b\n body\n");
777+ on_b(&mut app);
778+ // Move to…
779+ let (d, mut app) = editing_b("# A\n\n- a\n- b\n body\n\n# C\n");
780+ app.run_action(Action::Refile);
781+ typing(&mut app, "C");
782+ app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
783+ assert!(root(&d).ends_with("# C\n\n- b\n body\n"), "{}", root(&d));
784+ on_b(&mut app);
785+ // a first property, set in the form opened over the editor, makes it
786+ // a block (§6.1)
787+ let (d, mut app) = editing_b("# A\n\n- a\n- b\n body\n");
788+ app.run_action(Action::Props);
789+ typing(&mut app, "ntag");
790+ app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
791+ typing(&mut app, "x");
792+ app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
793+ assert!(!root(&d).contains("- b"), "b is a block: {}", root(&d));
794+ app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
795+ on_b(&mut app);
796+ // dragged onto a's title (§10.1)
797+ let (d, mut app) = editing_b("# A\n\n- a\n- b\n body\n");
798+ draw(&mut app);
799+ let (from, onto) = (app.hit_pos(Hit::Row(2)).unwrap(), app.hit_pos(Hit::Row(1)).unwrap());
800+ app.handle_mouse(ev(MouseEventKind::Down(MouseButton::Left), from));
801+ app.handle_mouse(ev(MouseEventKind::Drag(MouseButton::Left), onto));
802+ draw(&mut app);
803+ app.handle_mouse(ev(MouseEventKind::Up(MouseButton::Left), onto));
804+ assert_eq!(root(&d), "# A\n\n- a\n - b\n body\n");
805+ on_b(&mut app);
806+ // deleted, it leaves nothing to edit: the editor closes instead of
807+ // showing the node above it
808+ let (d, mut app) = editing_b("# A\n\n- a\n- b\n body\n");
809+ app.run_action(Action::Delete);
810+ assert_eq!(root(&d), "# A\n\n- a\n");
811+ assert_eq!(app.mode_pub(), "normal");
812+}
813+
814+#[test]
815+fn edit_from_a_menu_after_cutting_a_block_title_opens_on_the_menus_node() {
816+ // as above, with the menu's Edit: opening an editor saves the open one
817+ // first, which deletes the cut block and renumbers the files after it
818+ let d = tempfile::tempdir().unwrap();
819+ std::fs::write(
820+ d.path().join("root.md"),
821+ "# A\n\n- one\n![[dozzod-binwes-talsun-worbec]]\n- two\n\n# B\n\n![[racfer-hattes-mislup-nodrys]]\n",
822+ )
823+ .unwrap();
824+ std::fs::write(d.path().join("dozzod~cut.md"), "---\nid: dozzod-binwes-talsun-worbec\n---\n\n- cut me\n").unwrap();
825+ std::fs::write(
826+ d.path().join("racfer~task.md"),
827+ "---\nid: racfer-hattes-mislup-nodrys\n---\n\n- task\n - sub\n",
828+ )
829+ .unwrap();
830+ let mut app = App::new(d.path()).unwrap();
831+ app.show_reading = true;
832+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
833+ typing(&mut app, "e");
834+ for _ in 0..3 {
835+ app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
836+ }
837+ app.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
838+ draw(&mut app);
839+ let row = (0..10).find(|&i| app.rows().get(i).is_some_and(|r| app.title_of(r.nref) == "task")).unwrap();
840+ right_click(&mut app, Hit::Row(row));
841+ click(&mut app, Hit::MenuItem(fold_tui::app::node_menu_index(Action::Edit)));
842+ assert_eq!(app.mode_pub(), "edit");
843+ assert!(!d.path().join("dozzod~cut.md").exists(), "the cut block went with the old editor");
844+ let s = draw(&mut app);
845+ assert!(s.contains("- task") && s.contains("- sub"), "{}", s);
846+}
847+
848+#[test]
849+fn dragging_the_edited_node_before_a_sibling_keeps_the_editor_on_it_among_namesakes() {
850+ // editing the first "b" (body "mine"), dragged to the left of t's row:
851+ // before t (§10.1). A later sibling is titled "b" too; the editor must
852+ // stay on the node it was open on, not follow the namesake
853+ let (d, mut app) = app_with("# A\n\n- t\n- b\n mine\n- b\n theirs\n");
854+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
855+ // rows: A, t, b (mine), b (theirs)
856+ typing(&mut app, "jje");
857+ assert!(draw(&mut app).contains("mine"));
858+ let (from, to) = (app.hit_pos(Hit::Row(2)).unwrap(), app.hit_pos(Hit::Row(1)).unwrap());
859+ app.handle_mouse(ev(MouseEventKind::Down(MouseButton::Left), from));
860+ app.handle_mouse(ev(MouseEventKind::Drag(MouseButton::Left), (2, to.1)));
861+ draw(&mut app);
862+ app.handle_mouse(ev(MouseEventKind::Up(MouseButton::Left), (2, to.1)));
863+ assert_eq!(root(&d), "# A\n\n- b\n mine\n- t\n- b\n theirs\n");
864+ assert_eq!(app.mode_pub(), "edit");
865+ // typed at the end of the body line the editor shows
866+ app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
867+ app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
868+ typing(&mut app, "!");
869+ app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
870+ assert_eq!(root(&d), "# A\n\n- b\n mine!\n- t\n- b\n theirs\n");
871+ // dragged before the namesake itself: the key that named the target
872+ // before the drop names the moved node after it
873+ let (d, mut app) = app_with("# A\n\n- t\n- b\n theirs\n- b\n mine\n");
874+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
875+ typing(&mut app, "jjje");
876+ assert!(draw(&mut app).contains("mine"));
877+ let (from, to) = (app.hit_pos(Hit::Row(3)).unwrap(), app.hit_pos(Hit::Row(2)).unwrap());
878+ app.handle_mouse(ev(MouseEventKind::Down(MouseButton::Left), from));
879+ app.handle_mouse(ev(MouseEventKind::Drag(MouseButton::Left), (2, to.1)));
880+ draw(&mut app);
881+ app.handle_mouse(ev(MouseEventKind::Up(MouseButton::Left), (2, to.1)));
882+ assert_eq!(root(&d), "# A\n\n- t\n- b\n mine\n- b\n theirs\n");
883+ app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
884+ app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
885+ typing(&mut app, "!");
886+ app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
887+ assert_eq!(root(&d), "# A\n\n- t\n- b\n mine!\n- b\n theirs\n");
888+}
889+
890+#[test]
891+fn dragging_the_edited_item_into_a_node_keeps_the_editor_on_it_beside_a_section_namesake() {
892+ // editing item b, dropped onto T's title: into T, before T's section
893+ // "b" (§3.1). The editor stays on the item, not on the section
894+ let (d, mut app) = app_with("# A\n\n- b\n mine\n\n## T\n\n### b\n\ntheirs\n");
895+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
896+ // rows: A, b (mine), T, b (theirs)
897+ typing(&mut app, "je");
898+ assert!(draw(&mut app).contains("mine"));
899+ let (from, onto) = (app.hit_pos(Hit::Row(1)).unwrap(), app.hit_pos(Hit::Row(2)).unwrap());
900+ app.handle_mouse(ev(MouseEventKind::Down(MouseButton::Left), from));
901+ app.handle_mouse(ev(MouseEventKind::Drag(MouseButton::Left), onto));
902+ draw(&mut app);
903+ app.handle_mouse(ev(MouseEventKind::Up(MouseButton::Left), onto));
904+ let r = root(&d);
905+ assert!(r.find("- b\n mine\n").is_some_and(|i| i > r.find("## T").unwrap()), "{}", r);
906+ assert_eq!(app.mode_pub(), "edit");
907+ app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
908+ app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
909+ typing(&mut app, "!");
910+ app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
911+ assert_eq!(root(&d), r.replace("mine", "mine!"));
912+}
913+
914+#[test]
915+fn outdenting_the_edited_node_past_a_namesake_parent_keeps_the_editor_on_it() {
916+ // editing the inner "z", Outdent: it lands after its old parent, also
917+ // titled "z" (§10.3). The editor stays on the node it was open on
918+ let (d, mut app) = app_with("# A\n\n- z\n theirs\n - z\n mine\n");
919+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
920+ // rows: A, z (theirs), z (mine)
921+ typing(&mut app, "jje");
922+ assert!(draw(&mut app).contains("mine"));
923+ app.run_action(Action::Outdent);
924+ assert_eq!(root(&d), "# A\n\n- z\n theirs\n- z\n mine\n");
925+ assert_eq!(app.mode_pub(), "edit");
926+ app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
927+ app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
928+ typing(&mut app, "!");
929+ app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
930+ assert_eq!(root(&d), "# A\n\n- z\n theirs\n- z\n mine!\n");
931+}
crates/fold-tui/tests/tui.rs+63
@@ -193,3 +193,66 @@ fn code_blocks_are_highlighted_by_their_language() {
193193 assert_eq!(fns[0], ratatui::style::Color::Magenta, "rust keyword highlighted");
194194 assert_eq!(fns[1], ratatui::style::Color::Yellow, "unknown language: plain code colour");
195195}
196+
197+#[test]
198+fn fences_are_drawn_where_the_parser_reads_them() {
199+ // code breaks hard, marked with ↪, in the reading pane and the editor
200+ // alike; prose wraps at spaces (§10.1)
201+ let long = "Mirrored pairs, no raidz. Snapshots hourly via sanoid and pruned daily, replicated offsite every night.";
202+ let code_in = |text: &str| -> (bool, bool) {
203+ let (_d, mut app) = app_with(text);
204+ app.set_edit_keys(fold_tui::app::EditKeys::Normal);
205+ let reading = screen(&mut app, 100, 24).contains('↪');
206+ app.handle_key(key(KeyCode::Char('e')));
207+ assert_eq!(app.mode_pub(), "edit");
208+ (reading, screen(&mut app, 100, 24).contains('↪'))
209+ };
210+ // §3.3: a backtick fence's info string holds no backtick, so this is a
211+ // paragraph with inline code, and what follows is prose
212+ assert_eq!(code_in(&format!("# A\n\n```npm i``` first\n{}\n", long)), (false, false));
213+ // a closing fence has nothing after it: "``` x" is code, not a closer
214+ assert_eq!(code_in(&format!("# A\n\n```\n``` x\n{}\n```\n", long)), (true, true));
215+ // nor is a shorter run of the same character
216+ assert_eq!(code_in(&format!("# A\n\n~~~~\n~~~\n{}\n~~~~\n", long)), (true, true));
217+ // the line with inline code is styled as text, not as a fence (whose
218+ // info string is in the accent colour)
219+ let (_d, mut app) = app_with("# A\n\n```npm i``` first\n");
220+ let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap();
221+ terminal.draw(|f| app.draw(f)).unwrap();
222+ let buf = terminal.backend().buffer().clone();
223+ let (x, y) = (0..20u16)
224+ .flat_map(|y| (34..90u16).map(move |x| (x, y)))
225+ .find(|&(x, y)| (0..5).map(|i| buf[(x + i, y)].symbol()).collect::<String>() == "npm i")
226+ .expect("drawn");
227+ assert_ne!(buf[(x, y)].fg, ratatui::style::Color::Cyan, "styled as a fence's info string");
228+}
229+
230+#[test]
231+fn the_conflict_view_draws_fenced_code_as_code() {
232+ let dir = tempfile::tempdir().unwrap();
233+ std::fs::write(dir.path().join("root.md"), "- [ ] task\n ```sh\n # a comment\n ```\n").unwrap();
234+ std::fs::write(
235+ dir.path().join("root.sync-conflict-20260912-100000-phone.md"),
236+ "- [x] task\n ```sh\n # a comment\n ```\n",
237+ )
238+ .unwrap();
239+ let mut v = fold_core::vault::Vault::open(dir.path()).unwrap();
240+ fold_core::merge::merge_sync_conflicts(&mut v, false).unwrap();
241+ drop(v);
242+ let mut app = App::new(dir.path()).unwrap();
243+ app.enter_conflict_view();
244+ assert_eq!(app.mode_pub(), "conflict");
245+ let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap();
246+ terminal.draw(|f| app.draw(f)).unwrap();
247+ let buf = terminal.backend().buffer().clone();
248+ let at: Vec<(u16, u16)> = (0..20u16)
249+ .flat_map(|y| (0..92u16).map(move |x| (x, y)))
250+ .filter(|&(x, y)| (0..9).map(|i| buf[(x + i, y)].symbol()).collect::<String>() == "a comment")
251+ .collect();
252+ assert_eq!(at.len(), 2, "both sides drawn");
253+ for (x, y) in at {
254+ // code, not a heading
255+ assert_eq!(buf[(x, y)].fg, ratatui::style::Color::Yellow);
256+ assert!(!buf[(x, y)].modifier.contains(ratatui::style::Modifier::BOLD));
257+ }
258+}
crates/fold-tui/tests/vim_search.rsnew file+61
@@ -0,0 +1,61 @@
1+//! Vim's `n` / `N` repeat the last search in the direction it was made (§10.6).
2+
3+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4+use fold_tui::app::App;
5+
6+fn app_with(text: &str) -> (tempfile::TempDir, App) {
7+ let d = tempfile::tempdir().unwrap();
8+ std::fs::write(d.path().join("root.md"), text).unwrap();
9+ let mut app = App::new(d.path()).unwrap();
10+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
11+ (d, app)
12+}
13+
14+fn keys(app: &mut App, s: &str) {
15+ for c in s.chars() {
16+ let k = match c {
17+ '⎋' => KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
18+ '⏎' => KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
19+ c => KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE),
20+ };
21+ app.handle_key(k);
22+ }
23+}
24+
25+fn root(d: &tempfile::TempDir) -> String {
26+ std::fs::read_to_string(d.path().join("root.md")).unwrap()
27+}
28+
29+#[test]
30+fn vim_n_after_backward_search_goes_backward() {
31+ // `?` searches backward, so `n` goes on backward: from `foo 2` to `foo 1`.
32+ let (d, mut app) = app_with("# A\n\nfoo 1\nfoo 2\nfoo 3\n");
33+ keys(&mut app, "eG?foo⏎nx:w⏎");
34+ assert_eq!(root(&d), "# A\n\noo 1\nfoo 2\nfoo 3\n");
35+}
36+
37+#[test]
38+fn vim_n_after_hash_goes_backward() {
39+ // `#` is a backward search: from `foo 2` it lands on `foo 1`, and `n`
40+ // wraps on backward to `foo 3`.
41+ let (d, mut app) = app_with("# A\n\nfoo 1\nfoo 2\nfoo 3\n");
42+ keys(&mut app, "e4G#nx:w⏎");
43+ assert_eq!(root(&d), "# A\n\nfoo 1\nfoo 2\noo 3\n");
44+}
45+
46+#[test]
47+fn vim_n_after_forward_search_goes_forward() {
48+ // the control: `/` then `n` goes forward, as it does today.
49+ let (d, mut app) = app_with("# A\n\nfoo 1\nfoo 2\nfoo 3\n");
50+ keys(&mut app, "e3G/foo⏎nx:w⏎");
51+ assert_eq!(root(&d), "# A\n\nfoo 1\nfoo 2\noo 3\n");
52+}
53+
54+#[test]
55+fn vim_shift_n_after_backward_search_goes_forward() {
56+ // `N` is the other way from the search: after `?` from `foo 2` lands on
57+ // `foo 1`, forward to `foo 2` again (not back around to `foo 3`).
58+ let (d, mut app) = app_with("# A\n\nfoo 1\nfoo 2\nfoo 3\n");
59+ keys(&mut app, "e4G?foo⏎Nx:w⏎");
60+ assert_eq!(root(&d), "# A\n\nfoo 1\noo 2\nfoo 3\n");
61+}
crates/fold-tui/tests/wrap.rs+225
@@ -129,3 +129,228 @@ fn clicking_a_wrapped_row_in_the_editor_places_the_cursor_there() {
129129 let text = std::fs::read_to_string(app.vault_dir().join("root.md")).unwrap();
130130 assert!(text.contains("the #offsite box"), "{}", text);
131131}
132+
133+#[test]
134+fn tabs_in_code_keep_their_indentation() {
135+ // a tab-indented line (Go, a Makefile recipe) is drawn indented, not
136+ // flush with the line above: raw text is never hidden (§10.9)
137+ let (_d, mut app) = app_with("# A\n\n```\nflush\n\tindented\n```\n");
138+ let s = draw(&mut app);
139+ let f = s.iter().find(|l| l.contains("flush")).unwrap();
140+ let i = s.iter().find(|l| l.contains("indented")).unwrap();
141+ // a tab reaches the next stop, every 4 columns
142+ assert_eq!(col(i, "indented"), col(f, "flush") + 4, "reading pane: {:#?}", s);
143+}
144+
145+#[test]
146+fn tabs_in_code_keep_their_indentation_in_the_editor() {
147+ // the editor shows the tab as whitespace too, and its cursor agrees
148+ let (_d, mut app) = app_with("# A\n\n```\nflush\n\tindented\n```\n");
149+ app.show_reading = false;
150+ keys(&mut app, "e");
151+ let s = draw(&mut app);
152+ let f = s.iter().find(|l| l.contains("flush")).unwrap();
153+ let y = s.iter().position(|l| l.contains("indented")).unwrap();
154+ assert_eq!(col(&s[y], "indented"), col(f, "flush") + 4, "editor: {:#?}", s);
155+ // the cursor just past the tab (Home from the line's end goes to the
156+ // first non-blank) sits on the `i`
157+ for _ in 0..4 {
158+ key(&mut app, KeyCode::Down);
159+ }
160+ key(&mut app, KeyCode::End);
161+ key(&mut app, KeyCode::Home);
162+ let mut t = Terminal::new(TestBackend::new(W, H)).unwrap();
163+ t.draw(|f| app.draw(f)).unwrap();
164+ let p = t.get_cursor_position().unwrap();
165+ assert_eq!((p.x, p.y), (col(&s[y], "indented"), y as u16), "editor: {:#?}", s);
166+}
167+
168+#[test]
169+fn a_very_long_line_does_not_overflow_the_styler() {
170+ // over 65,535 columns in all, with a stray `*` so the styler pushes it in
171+ // several pieces, and a link 65,541 columns in
172+ let (_d, mut app) = app_with(&format!("# A\n\n{} * {} [docs](x)\n", "a".repeat(40000), "b".repeat(25536)));
173+ let s = draw(&mut app);
174+ assert!(s.iter().any(|l| l.contains("aaaa")), "{:#?}", s);
175+ // zw: cut at the edge; the link is far off screen, not wrapped round
176+ // into a u16 over the start of the line
177+ keys(&mut app, "zw");
178+ let s = draw(&mut app);
179+ let y = s.iter().position(|l| l.contains("aaaa")).unwrap();
180+ for x in 0..W {
181+ assert_ne!(app.hit_at(x, y as u16), Some(Hit::Link(2)), "{:#?}", s);
182+ }
183+}
184+
185+#[test]
186+fn a_link_after_wide_text_is_clickable_where_drawn() {
187+ // 28 wide characters fill the first row; the link is drawn on the second
188+ let line = format!("{} see [docs](https://x.org)", "漢".repeat(28));
189+ let (_d, mut app) = app_with(&format!("# A\n\n{}\n", line));
190+ let s = draw(&mut app);
191+ let y = s.iter().position(|l| l.contains("[docs]")).unwrap();
192+ let x = col(&s[y], "docs");
193+ assert_eq!(app.hit_at(x, y as u16), Some(Hit::Link(2)), "{:#?}", s);
194+}
195+
196+#[test]
197+fn wide_text_on_an_earlier_row_does_not_shift_the_link_region() {
198+ // 13 wide characters fill the first row, the link sits early on the second
199+ let line = format!("{} {}see [docs](x) and plain words", "漢".repeat(13), "word ".repeat(6));
200+ let (_d, mut app) = app_with(&format!("# A\n\n{}\n", line));
201+ let s = draw(&mut app);
202+ let y = s.iter().position(|l| l.contains("[docs]")).unwrap();
203+ let docs = col(&s[y], "docs");
204+ let plain = col(&s[y], "plain");
205+ assert_eq!(app.hit_at(plain, y as u16), Some(Hit::DocLine(2)), "plain text is not the link: {:#?}", s);
206+ assert_eq!(app.hit_at(docs, y as u16), Some(Hit::Link(2)), "{:#?}", s);
207+}
208+
209+#[test]
210+fn a_checkbox_and_link_after_a_tab_are_clickable_where_drawn() {
211+ // a tab before them is drawn as spaces to its tab stop
212+ let (_d, mut app) = app_with("# A\n\n- [ ] x\tsee [docs](x)\n");
213+ let s = draw(&mut app);
214+ // the reading pane's row with the item (the outline may show it too, on
215+ // the left)
216+ let y = s.iter().rposition(|l| l.contains("[docs]")).unwrap();
217+ let rcol = |n: &str| s[y][..s[y].rfind(n).unwrap()].chars().count() as u16;
218+ assert_eq!(app.hit_at(rcol("☐"), y as u16), Some(Hit::DocCheck(2)), "{:#?}", s);
219+ // exactly the link text is the link, not the brackets around it
220+ let docs = rcol("docs");
221+ for x in docs..docs + 4 {
222+ assert_eq!(app.hit_at(x, y as u16), Some(Hit::Link(2)), "{:#?}", s);
223+ }
224+ for x in [docs - 1, docs + 4] {
225+ assert_eq!(app.hit_at(x, y as u16), Some(Hit::DocLine(2)), "{:#?}", s);
226+ }
227+}
228+
229+#[test]
230+fn a_link_wrapped_onto_the_next_row_is_clickable_on_both() {
231+ let line = format!("{} [alpha beta gamma delta epsilon zeta](u) end", "x".repeat(40));
232+ let (_d, mut app) = app_with(&format!("# A\n\n{}\n", line));
233+ let s = draw(&mut app);
234+ let y0 = s.iter().position(|l| l.contains("[alpha")).unwrap();
235+ let y1 = s.iter().position(|l| l.contains("gamma")).unwrap();
236+ assert_eq!(y1, y0 + 1, "{:#?}", s);
237+ assert_eq!(app.hit_at(col(&s[y0], "alpha"), y0 as u16), Some(Hit::Link(2)), "{:#?}", s);
238+ assert_eq!(app.hit_at(col(&s[y1], "gamma"), y1 as u16), Some(Hit::Link(2)), "{:#?}", s);
239+ assert_eq!(app.hit_at(col(&s[y1], "end"), y1 as u16), Some(Hit::DocLine(2)), "{:#?}", s);
240+}
241+
242+#[test]
243+fn a_selection_over_wide_text_covers_it() {
244+ let (_d, mut app) = app_with("# 漢字漢字\n");
245+ keys(&mut app, "e");
246+ // normal keymap: Ctrl-A selects all
247+ app.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL));
248+ let mut t = Terminal::new(TestBackend::new(W, H)).unwrap();
249+ t.draw(|f| app.draw(f)).unwrap();
250+ let b = t.backend().buffer().clone();
251+ // the editor's row: the last one on screen showing the heading's text
252+ let y = (0..H).rev().find(|&y| (0..W).any(|x| b[(x, y)].symbol() == "漢")).unwrap();
253+ let x0 = (0..W).find(|&x| b[(x, y)].symbol() == "#").unwrap();
254+ // "# 漢字漢字" spans 2 + 4 * 2 = 10 cells; each character's cell (a wide
255+ // one's second cell is its continuation) carries the selection
256+ let cells: Vec<_> = (x0..x0 + 10).map(|x| (x - x0, b[(x, y)].symbol().to_string(), b[(x, y)].bg)).collect();
257+ for (dx, want) in [(0, "#"), (1, " "), (2, "漢"), (4, "字"), (6, "漢"), (8, "字")] {
258+ let (_, sym, bg) = &cells[dx];
259+ assert_eq!(sym, want, "{:?}", cells);
260+ assert_eq!(*bg, ratatui::style::Color::Indexed(24), "{:?} at x0+{} is not selected: {:?}", want, dx, cells);
261+ }
262+}
263+
264+#[test]
265+fn a_selection_over_a_tab_covers_its_spaces_and_no_more() {
266+ let (_d, mut app) = app_with("# A\n\n```\n\tλ\n```\n");
267+ app.show_reading = false;
268+ keys(&mut app, "e");
269+ // select the tab alone: Shift-Right from the start of its line
270+ for _ in 0..3 {
271+ key(&mut app, KeyCode::Down);
272+ }
273+ key(&mut app, KeyCode::End);
274+ key(&mut app, KeyCode::Home);
275+ key(&mut app, KeyCode::Home);
276+ app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT));
277+ let mut t = Terminal::new(TestBackend::new(W, H)).unwrap();
278+ t.draw(|f| app.draw(f)).unwrap();
279+ let b = t.backend().buffer().clone();
280+ let y = (0..H).find(|&y| (0..W).any(|x| b[(x, y)].symbol() == "λ")).unwrap();
281+ let xx = (0..W).find(|&x| b[(x, y)].symbol() == "λ").unwrap();
282+ let bgs: Vec<_> = (xx - 4..=xx).map(|x| b[(x, y)].bg).collect();
283+ let sel = ratatui::style::Color::Indexed(24);
284+ assert_eq!(bgs[..4], [sel; 4], "the tab's four cells are selected: {:?}", bgs);
285+ assert_ne!(bgs[4], sel, "the λ is not: {:?}", bgs);
286+}
287+
288+#[test]
289+fn typing_resets_the_editors_screen_row_goal_column() {
290+ // wrapping on: after typing, Down aims for the column the cursor is at,
291+ // not the one the previous Down aimed for (as with wrapping off)
292+ let (_d, mut app) = app_with("# A\n\nabcdef\nx\nabcdef\n");
293+ keys(&mut app, "e");
294+ draw(&mut app);
295+ // to column 1 of the first "abcdef", then Down onto the end of "x"
296+ for c in [KeyCode::Down, KeyCode::Down, KeyCode::Right, KeyCode::Down] {
297+ key(&mut app, c);
298+ }
299+ keys(&mut app, "yz");
300+ // the cursor is at column 3 of "xyz"; Down lands at column 3 below
301+ key(&mut app, KeyCode::Down);
302+ keys(&mut app, "Q");
303+ key(&mut app, KeyCode::Esc);
304+ let text = std::fs::read_to_string(app.vault_dir().join("root.md")).unwrap();
305+ assert!(text.contains("\nxyz\nabcQdef\n"), "{}", text);
306+}
307+
308+#[test]
309+fn undo_resets_the_editors_screen_row_goal_column() {
310+ // wrapping on: Ctrl-Z moves the cursor back to where the typing was, and
311+ // the next Down aims from there, not from the column before the undo
312+ let (_d, mut app) = app_with("# A\n\nabcdef\nx\nabcdef\n");
313+ keys(&mut app, "e");
314+ draw(&mut app);
315+ for c in [KeyCode::Down, KeyCode::Down, KeyCode::End] {
316+ key(&mut app, c);
317+ }
318+ keys(&mut app, "yz");
319+ // Home, then Down onto column 0 of "x": the goal column is now 0
320+ key(&mut app, KeyCode::Home);
321+ key(&mut app, KeyCode::Down);
322+ // undo puts the cursor back at column 6 of "abcdef"
323+ app.handle_key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL));
324+ key(&mut app, KeyCode::Down);
325+ keys(&mut app, "Q");
326+ key(&mut app, KeyCode::Esc);
327+ let text = std::fs::read_to_string(app.vault_dir().join("root.md")).unwrap();
328+ assert!(text.contains("\nabcdef\nxQ\nabcdef\n"), "{}", text);
329+}
330+
331+#[test]
332+fn a_tab_in_a_title_is_drawn_as_whitespace() {
333+ // the terminal drops a tab: a title shows it as the spaces to its next
334+ // stop, counted from the title's start (raw text is never hidden, §10.9)
335+ let (_d, mut app) = app_with("- x\ty\n - c\n");
336+ app.set_edit_keys(EditKeys::Normal);
337+ key(&mut app, KeyCode::Enter); // zoom into it
338+ let s = draw(&mut app);
339+ assert!(s[0].contains("fold › x y"), "breadcrumb: {:#?}", s);
340+ assert!(s[1].contains("╭ x y "), "reading pane: {:#?}", s);
341+ assert!(s[2].contains("▾ x y"), "outline: {:#?}", s);
342+ // a node's parents in the filter's hits
343+ key(&mut app, KeyCode::Tab);
344+ keys(&mut app, "/c");
345+ assert!(draw(&mut app).iter().any(|l| l.contains("c x y")), "{:#?}", draw(&mut app));
346+ key(&mut app, KeyCode::Esc);
347+ // the node menu, the property form and the editor name it too
348+ keys(&mut app, "zpm");
349+ assert!(draw(&mut app).iter().any(|l| l.contains("╭ x y ")), "{:#?}", draw(&mut app));
350+ key(&mut app, KeyCode::Esc);
351+ keys(&mut app, "a");
352+ assert!(draw(&mut app).iter().any(|l| l.contains("Properties — x y")), "{:#?}", draw(&mut app));
353+ key(&mut app, KeyCode::Esc);
354+ keys(&mut app, "e");
355+ assert!(draw(&mut app).iter().any(|l| l.contains("Editing x y")), "{:#?}", draw(&mut app));
356+}