@@ -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+}