on master help sign in
106 files changed+24498−1907
since splitsnapshots
unifiedsplit
.gitignore+2 Cargo.lock+408−238 Cargo.toml+1−3 README.md+149 SPEC.md+279−115 android/.gitignore+7 android/README.md+137 android/app/build.gradle.kts+217 android/app/proguard-rules.pro+6 android/app/src/main/AndroidManifest.xml+44 android/app/src/main/java/soy/iko/fold/FoldApp.kt+14 android/app/src/main/java/soy/iko/fold/FoldViewModel.kt+732 android/app/src/main/java/soy/iko/fold/MainActivity.kt+59 android/app/src/main/java/soy/iko/fold/Prefs.kt+57 android/app/src/main/java/soy/iko/fold/Storage.kt+84 android/app/src/main/java/soy/iko/fold/Watcher.kt+37 android/app/src/main/java/soy/iko/fold/ui/EditorScreen.kt+209 android/app/src/main/java/soy/iko/fold/ui/Markdown.kt+188 android/app/src/main/java/soy/iko/fold/ui/OutlineScreen.kt+492 android/app/src/main/java/soy/iko/fold/ui/Root.kt+86 android/app/src/main/java/soy/iko/fold/ui/Screens.kt+557 android/app/src/main/java/soy/iko/fold/ui/Sheets.kt+223 android/app/src/main/java/soy/iko/fold/ui/Theme.kt+127 android/app/src/main/java/soy/iko/fold/ui/VaultScreen.kt+145 android/app/src/main/res/mipmap-anydpi/ic_launcher.xml+7 android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp android/app/src/main/res/values-night/colors.xml+4 android/app/src/main/res/values-night/themes.xml+8 android/app/src/main/res/values/colors.xml+5 android/app/src/main/res/values/strings.xml+7 android/app/src/main/res/values/themes.xml+8 android/app/src/main/res/xml/shortcuts.xml+14 android/app/src/test/java/soy/iko/fold/CoreTest.kt+99 android/app/src/test/java/soy/iko/fold/UiTest.kt+331 android/build.gradle.kts+5 android/docs/editor.png android/docs/outline.png android/docs/reading.png android/docs/zoomed.png android/gradle.properties+7 android/gradle/libs.versions.toml+40 android/gradle/wrapper/gradle-wrapper.jar android/gradle/wrapper/gradle-wrapper.properties+8 android/gradlew+251 android/gradlew.bat+94 android/settings.gradle.kts+18 crates/fold-cli/Cargo.toml−1 crates/fold-cli/src/main.rs+22−9 crates/fold-cli/tests/cli.rs+417−8 crates/fold-core/Cargo.toml−3 crates/fold-core/src/check.rs+75−46 crates/fold-core/src/edit.rs+683−82 crates/fold-core/src/lib.rs+1 crates/fold-core/src/merge.rs+255−177 crates/fold-core/src/news.rs+106 crates/fold-core/src/ops.rs+793−223 crates/fold-core/src/parse.rs+152−26 crates/fold-core/src/render.rs+10−2 crates/fold-core/src/tree.rs+120−31 crates/fold-core/src/vault.rs+143−109 crates/fold-core/tests/edit.rs+978−2 crates/fold-core/tests/merge_check.rs+481 crates/fold-core/tests/ops.rs+710 crates/fold-core/tests/ordering.rs+387−3 crates/fold-core/tests/resolve.rs+274 crates/fold-core/tests/roundtrip.rs+109−36 crates/fold-core/tests/undo.rs+159−1 crates/fold-ffi/Cargo.toml+28 crates/fold-ffi/src/edit.rs+563 crates/fold-ffi/src/keys.rs+72 crates/fold-ffi/src/lib.rs+1903 crates/fold-ffi/src/tests.rs+437 crates/fold-ffi/uniffi-bindgen.rs+3 crates/fold-ffi/uniffi.toml+3 crates/fold-tui/Cargo.toml+4−5 crates/fold-tui/src/app.rs+1952−438 crates/fold-tui/src/app/action.rs+12 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+114−42 crates/fold-tui/src/app/ui.rs+403−102 crates/fold-tui/src/app/view.rs+157 crates/fold-tui/src/app/wrap.rs+47−10 crates/fold-tui/tests/conflict_watch.rs+134 crates/fold-tui/tests/editor.rs+1407 crates/fold-tui/tests/fixes.rs+432 crates/fold-tui/tests/mouse.rs+659−4 crates/fold-tui/tests/tui.rs+66−1 crates/fold-tui/tests/ux.rs+3820 crates/fold-tui/tests/vim_search.rs+61 crates/fold-tui/tests/wrap.rs+228−1 docs/icon.png flake.lock+27 flake.nix+75
.gitignore+2
@@ -1,2 +1,4 @@
11target/
22.vix/
3+result
4+result-*
Cargo.lock+408−238
@@ -79,6 +79,59 @@ version = "0.7.8"
7979source = "registry+https://github.com/rust-lang/crates.io-index"
8080checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
8181
82+[[package]]
83+name = "askama"
84+version = "0.16.1"
85+source = "registry+https://github.com/rust-lang/crates.io-index"
86+checksum = "6024d73179f43f15ccd2b881bfea6fee7f3a46ec53f33b52210dea749ebebaa4"
87+dependencies = [
88+ "askama_macros",
89+ "itoa",
90+ "percent-encoding",
91+ "serde",
92+ "serde_json",
93+]
94+
95+[[package]]
96+name = "askama_derive"
97+version = "0.16.1"
98+source = "registry+https://github.com/rust-lang/crates.io-index"
99+checksum = "071ee5ebf2138e3ad180e0aacf6940c2cab5e6d8333741d9925c7bee2b153f39"
100+dependencies = [
101+ "askama_parser",
102+ "basic-toml",
103+ "glob",
104+ "memchr",
105+ "proc-macro2",
106+ "quote",
107+ "rustc-hash",
108+ "serde",
109+ "serde_derive",
110+ "syn 3.0.5",
111+]
112+
113+[[package]]
114+name = "askama_macros"
115+version = "0.16.1"
116+source = "registry+https://github.com/rust-lang/crates.io-index"
117+checksum = "643e1c7cbb6aec1d920332fe51a7c0d8219e273dcb8602db03f5263e4d16487b"
118+dependencies = [
119+ "askama_derive",
120+]
121+
122+[[package]]
123+name = "askama_parser"
124+version = "0.16.1"
125+source = "registry+https://github.com/rust-lang/crates.io-index"
126+checksum = "2c5ae75772275d268b03ab8bdccdd12117b6169ee23256942b34e46c9f476583"
127+dependencies = [
128+ "rustc-hash",
129+ "serde",
130+ "serde_derive",
131+ "unicode-ident",
132+ "winnow",
133+]
134+
82135[[package]]
83136name = "assert_cmd"
84137version = "2.2.2"
@@ -101,20 +154,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
101154checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
102155
103156[[package]]
104−name = "bit-set"
105−version = "0.8.0"
157+name = "basic-toml"
158+version = "0.1.10"
106159source = "registry+https://github.com/rust-lang/crates.io-index"
107−checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
160+checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
108161dependencies = [
109− "bit-vec",
162+ "serde",
110163]
111164
112−[[package]]
113−name = "bit-vec"
114−version = "0.8.0"
115−source = "registry+https://github.com/rust-lang/crates.io-index"
116−checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
117−
118165[[package]]
119166name = "bitflags"
120167version = "1.3.2"
@@ -151,6 +198,45 @@ dependencies = [
151198 "serde_core",
152199]
153200
201+[[package]]
202+name = "bytes"
203+version = "1.12.1"
204+source = "registry+https://github.com/rust-lang/crates.io-index"
205+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
206+
207+[[package]]
208+name = "camino"
209+version = "1.2.6"
210+source = "registry+https://github.com/rust-lang/crates.io-index"
211+checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591"
212+dependencies = [
213+ "serde_core",
214+]
215+
216+[[package]]
217+name = "cargo-platform"
218+version = "0.3.3"
219+source = "registry+https://github.com/rust-lang/crates.io-index"
220+checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba"
221+dependencies = [
222+ "serde",
223+ "serde_core",
224+]
225+
226+[[package]]
227+name = "cargo_metadata"
228+version = "0.23.1"
229+source = "registry+https://github.com/rust-lang/crates.io-index"
230+checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9"
231+dependencies = [
232+ "camino",
233+ "cargo-platform",
234+ "semver",
235+ "serde",
236+ "serde_json",
237+ "thiserror",
238+]
239+
154240[[package]]
155241name = "cassowary"
156242version = "0.3.0"
@@ -242,17 +328,6 @@ dependencies = [
242328 "static_assertions",
243329]
244330
245−[[package]]
246−name = "console"
247−version = "0.16.6"
248−source = "registry+https://github.com/rust-lang/crates.io-index"
249−checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3"
250−dependencies = [
251− "encode_unicode",
252− "libc",
253− "windows-sys 0.61.2",
254−]
255−
256331[[package]]
257332name = "constant_time_eq"
258333version = "0.4.2"
@@ -268,31 +343,6 @@ dependencies = [
268343 "libc",
269344]
270345
271−[[package]]
272−name = "crossbeam-deque"
273−version = "0.8.8"
274−source = "registry+https://github.com/rust-lang/crates.io-index"
275−checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
276−dependencies = [
277− "crossbeam-epoch",
278− "crossbeam-utils",
279−]
280−
281−[[package]]
282−name = "crossbeam-epoch"
283−version = "0.9.21"
284−source = "registry+https://github.com/rust-lang/crates.io-index"
285−checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
286−dependencies = [
287− "crossbeam-utils",
288−]
289−
290−[[package]]
291−name = "crossbeam-utils"
292−version = "0.8.23"
293−source = "registry+https://github.com/rust-lang/crates.io-index"
294−checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
295−
296346[[package]]
297347name = "crossterm"
298348version = "0.28.1"
@@ -416,12 +466,6 @@ version = "1.18.0"
416466source = "registry+https://github.com/rust-lang/crates.io-index"
417467checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
418468
419−[[package]]
420−name = "encode_unicode"
421−version = "1.0.0"
422−source = "registry+https://github.com/rust-lang/crates.io-index"
423−checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
424−
425469[[package]]
426470name = "equivalent"
427471version = "1.0.2"
@@ -459,12 +503,6 @@ dependencies = [
459503 "num-traits",
460504]
461505
462−[[package]]
463−name = "fnv"
464−version = "1.0.7"
465−source = "registry+https://github.com/rust-lang/crates.io-index"
466−checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
467−
468506[[package]]
469507name = "fold-cli"
470508version = "0.1.0"
@@ -474,7 +512,6 @@ dependencies = [
474512 "clap",
475513 "fold-core",
476514 "fold-tui",
477− "jiff",
478515 "predicates",
479516 "tempfile",
480517]
@@ -483,19 +520,27 @@ dependencies = [
483520name = "fold-core"
484521version = "0.1.0"
485522dependencies = [
486− "anyhow",
487523 "blake3",
488524 "directories",
489525 "getrandom 0.3.4",
490526 "indexmap",
491527 "jiff",
492− "proptest",
493528 "similar",
494529 "tempfile",
495− "thiserror",
496530 "unicode-normalization",
497531]
498532
533+[[package]]
534+name = "fold-ffi"
535+version = "0.1.0"
536+dependencies = [
537+ "fold-core",
538+ "jiff",
539+ "tempfile",
540+ "thiserror",
541+ "uniffi",
542+]
543+
499544[[package]]
500545name = "fold-tui"
501546version = "0.1.0"
@@ -503,11 +548,9 @@ dependencies = [
503548 "anyhow",
504549 "crossterm",
505550 "fold-core",
506− "insta",
507− "jiff",
508551 "notify",
509− "nucleo",
510552 "ratatui",
553+ "signal-hook",
511554 "tempfile",
512555 "tree-sitter",
513556 "tree-sitter-ada",
@@ -641,7 +684,6 @@ dependencies = [
641684 "tree-sitter-yaml",
642685 "tree-sitter-zig",
643686 "tree-sitter-zsh",
644− "tui-textarea",
645687 "unicode-width 0.2.0",
646688]
647689
@@ -651,6 +693,15 @@ version = "0.1.5"
651693source = "registry+https://github.com/rust-lang/crates.io-index"
652694checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
653695
696+[[package]]
697+name = "fs-err"
698+version = "3.3.1"
699+source = "registry+https://github.com/rust-lang/crates.io-index"
700+checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a"
701+dependencies = [
702+ "autocfg",
703+]
704+
654705[[package]]
655706name = "fsevent-sys"
656707version = "4.1.0"
@@ -694,6 +745,23 @@ dependencies = [
694745 "r-efi 6.0.0",
695746]
696747
748+[[package]]
749+name = "glob"
750+version = "0.3.4"
751+source = "registry+https://github.com/rust-lang/crates.io-index"
752+checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
753+
754+[[package]]
755+name = "goblin"
756+version = "0.8.2"
757+source = "registry+https://github.com/rust-lang/crates.io-index"
758+checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47"
759+dependencies = [
760+ "log",
761+ "plain",
762+ "scroll",
763+]
764+
697765[[package]]
698766name = "hashbrown"
699767version = "0.15.5"
@@ -731,6 +799,8 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
731799dependencies = [
732800 "equivalent",
733801 "hashbrown 0.17.1",
802+ "serde",
803+ "serde_core",
734804]
735805
736806[[package]]
@@ -762,18 +832,6 @@ dependencies = [
762832 "libc",
763833]
764834
765−[[package]]
766−name = "insta"
767−version = "1.48.0"
768−source = "registry+https://github.com/rust-lang/crates.io-index"
769−checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82"
770−dependencies = [
771− "console",
772− "once_cell",
773− "similar",
774− "tempfile",
775−]
776−
777835[[package]]
778836name = "instability"
779837version = "0.3.13"
@@ -938,6 +996,12 @@ version = "2.8.3"
938996source = "registry+https://github.com/rust-lang/crates.io-index"
939997checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
940998
999+[[package]]
1000+name = "minimal-lexical"
1001+version = "0.2.1"
1002+source = "registry+https://github.com/rust-lang/crates.io-index"
1003+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
1004+
9411005[[package]]
9421006name = "mio"
9431007version = "1.2.3"
@@ -950,6 +1014,16 @@ dependencies = [
9501014 "windows-sys 0.61.2",
9511015]
9521016
1017+[[package]]
1018+name = "nom"
1019+version = "7.1.3"
1020+source = "registry+https://github.com/rust-lang/crates.io-index"
1021+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
1022+dependencies = [
1023+ "memchr",
1024+ "minimal-lexical",
1025+]
1026+
9531027[[package]]
9541028name = "normalize-line-endings"
9551029version = "0.3.0"
@@ -983,27 +1057,6 @@ dependencies = [
9831057 "bitflags 2.13.2",
9841058]
9851059
986−[[package]]
987−name = "nucleo"
988−version = "0.5.0"
989−source = "registry+https://github.com/rust-lang/crates.io-index"
990−checksum = "5262af4c94921c2646c5ac6ff7900c2af9cbb08dc26a797e18130a7019c039d4"
991−dependencies = [
992− "nucleo-matcher",
993− "parking_lot",
994− "rayon",
995−]
996−
997−[[package]]
998−name = "nucleo-matcher"
999−version = "0.3.1"
1000−source = "registry+https://github.com/rust-lang/crates.io-index"
1001−checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85"
1002−dependencies = [
1003− "memchr",
1004− "unicode-segmentation",
1005−]
1006−
10071060[[package]]
10081061name = "num-traits"
10091062version = "0.2.19"
@@ -1060,6 +1113,18 @@ version = "1.0.15"
10601113source = "registry+https://github.com/rust-lang/crates.io-index"
10611114checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
10621115
1116+[[package]]
1117+name = "percent-encoding"
1118+version = "2.3.2"
1119+source = "registry+https://github.com/rust-lang/crates.io-index"
1120+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
1121+
1122+[[package]]
1123+name = "plain"
1124+version = "0.2.3"
1125+source = "registry+https://github.com/rust-lang/crates.io-index"
1126+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
1127+
10631128[[package]]
10641129name = "portable-atomic"
10651130version = "1.15.0"
@@ -1075,15 +1140,6 @@ dependencies = [
10751140 "portable-atomic",
10761141]
10771142
1078−[[package]]
1079−name = "ppv-lite86"
1080−version = "0.2.21"
1081−source = "registry+https://github.com/rust-lang/crates.io-index"
1082−checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
1083−dependencies = [
1084− "zerocopy",
1085−]
1086−
10871143[[package]]
10881144name = "predicates"
10891145version = "3.1.4"
@@ -1123,31 +1179,6 @@ dependencies = [
11231179 "unicode-ident",
11241180]
11251181
1126−[[package]]
1127−name = "proptest"
1128−version = "1.11.0"
1129−source = "registry+https://github.com/rust-lang/crates.io-index"
1130−checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
1131−dependencies = [
1132− "bit-set",
1133− "bit-vec",
1134− "bitflags 2.13.2",
1135− "num-traits",
1136− "rand",
1137− "rand_chacha",
1138− "rand_xorshift",
1139− "regex-syntax",
1140− "rusty-fork",
1141− "tempfile",
1142− "unarray",
1143−]
1144−
1145−[[package]]
1146−name = "quick-error"
1147−version = "1.2.3"
1148−source = "registry+https://github.com/rust-lang/crates.io-index"
1149−checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
1150−
11511182[[package]]
11521183name = "quote"
11531184version = "1.0.47"
@@ -1169,44 +1200,6 @@ version = "6.0.0"
11691200source = "registry+https://github.com/rust-lang/crates.io-index"
11701201checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
11711202
1172−[[package]]
1173−name = "rand"
1174−version = "0.9.5"
1175−source = "registry+https://github.com/rust-lang/crates.io-index"
1176−checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
1177−dependencies = [
1178− "rand_chacha",
1179− "rand_core",
1180−]
1181−
1182−[[package]]
1183−name = "rand_chacha"
1184−version = "0.9.0"
1185−source = "registry+https://github.com/rust-lang/crates.io-index"
1186−checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
1187−dependencies = [
1188− "ppv-lite86",
1189− "rand_core",
1190−]
1191−
1192−[[package]]
1193−name = "rand_core"
1194−version = "0.9.5"
1195−source = "registry+https://github.com/rust-lang/crates.io-index"
1196−checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
1197−dependencies = [
1198− "getrandom 0.3.4",
1199−]
1200−
1201−[[package]]
1202−name = "rand_xorshift"
1203−version = "0.4.0"
1204−source = "registry+https://github.com/rust-lang/crates.io-index"
1205−checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
1206−dependencies = [
1207− "rand_core",
1208−]
1209−
12101203[[package]]
12111204name = "ratatui"
12121205version = "0.29.0"
@@ -1228,26 +1221,6 @@ dependencies = [
12281221 "unicode-width 0.2.0",
12291222]
12301223
1231−[[package]]
1232−name = "rayon"
1233−version = "1.12.0"
1234−source = "registry+https://github.com/rust-lang/crates.io-index"
1235−checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
1236−dependencies = [
1237− "either",
1238− "rayon-core",
1239−]
1240−
1241−[[package]]
1242−name = "rayon-core"
1243−version = "1.13.0"
1244−source = "registry+https://github.com/rust-lang/crates.io-index"
1245−checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
1246−dependencies = [
1247− "crossbeam-deque",
1248− "crossbeam-utils",
1249−]
1250−
12511224[[package]]
12521225name = "redox_syscall"
12531226version = "0.5.18"
@@ -1297,6 +1270,12 @@ version = "0.8.11"
12971270source = "registry+https://github.com/rust-lang/crates.io-index"
12981271checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
12991272
1273+[[package]]
1274+name = "rustc-hash"
1275+version = "2.1.3"
1276+source = "registry+https://github.com/rust-lang/crates.io-index"
1277+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
1278+
13001279[[package]]
13011280name = "rustix"
13021281version = "0.38.44"
@@ -1329,18 +1308,6 @@ version = "1.0.23"
13291308source = "registry+https://github.com/rust-lang/crates.io-index"
13301309checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
13311310
1332−[[package]]
1333−name = "rusty-fork"
1334−version = "0.3.1"
1335−source = "registry+https://github.com/rust-lang/crates.io-index"
1336−checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
1337−dependencies = [
1338− "fnv",
1339− "quick-error",
1340− "tempfile",
1341− "wait-timeout",
1342−]
1343−
13441311[[package]]
13451312name = "ryu"
13461313version = "1.0.23"
@@ -1362,6 +1329,36 @@ version = "1.2.0"
13621329source = "registry+https://github.com/rust-lang/crates.io-index"
13631330checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
13641331
1332+[[package]]
1333+name = "scroll"
1334+version = "0.12.0"
1335+source = "registry+https://github.com/rust-lang/crates.io-index"
1336+checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6"
1337+dependencies = [
1338+ "scroll_derive",
1339+]
1340+
1341+[[package]]
1342+name = "scroll_derive"
1343+version = "0.12.1"
1344+source = "registry+https://github.com/rust-lang/crates.io-index"
1345+checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d"
1346+dependencies = [
1347+ "proc-macro2",
1348+ "quote",
1349+ "syn 2.0.119",
1350+]
1351+
1352+[[package]]
1353+name = "semver"
1354+version = "1.0.28"
1355+source = "registry+https://github.com/rust-lang/crates.io-index"
1356+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
1357+dependencies = [
1358+ "serde",
1359+ "serde_core",
1360+]
1361+
13651362[[package]]
13661363name = "serde"
13671364version = "1.0.229"
@@ -1369,6 +1366,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
13691366checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
13701367dependencies = [
13711368 "serde_core",
1369+ "serde_derive",
13721370]
13731371
13741372[[package]]
@@ -1405,6 +1403,15 @@ dependencies = [
14051403 "zmij",
14061404]
14071405
1406+[[package]]
1407+name = "serde_spanned"
1408+version = "1.1.1"
1409+source = "registry+https://github.com/rust-lang/crates.io-index"
1410+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
1411+dependencies = [
1412+ "serde_core",
1413+]
1414+
14081415[[package]]
14091416name = "shlex"
14101417version = "2.0.1"
@@ -1448,12 +1455,24 @@ version = "2.7.0"
14481455source = "registry+https://github.com/rust-lang/crates.io-index"
14491456checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
14501457
1458+[[package]]
1459+name = "siphasher"
1460+version = "1.0.4"
1461+source = "registry+https://github.com/rust-lang/crates.io-index"
1462+checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
1463+
14511464[[package]]
14521465name = "smallvec"
14531466version = "1.16.1"
14541467source = "registry+https://github.com/rust-lang/crates.io-index"
14551468checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
14561469
1470+[[package]]
1471+name = "smawk"
1472+version = "0.3.3"
1473+source = "registry+https://github.com/rust-lang/crates.io-index"
1474+checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100"
1475+
14571476[[package]]
14581477name = "static_assertions"
14591478version = "1.1.0"
@@ -1535,6 +1554,16 @@ version = "0.5.1"
15351554source = "registry+https://github.com/rust-lang/crates.io-index"
15361555checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
15371556
1557+[[package]]
1558+name = "textwrap"
1559+version = "0.16.4"
1560+source = "registry+https://github.com/rust-lang/crates.io-index"
1561+checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49"
1562+dependencies = [
1563+ "smawk",
1564+ "unicode-width 0.2.0",
1565+]
1566+
15381567[[package]]
15391568name = "thiserror"
15401569version = "2.0.20"
@@ -1570,6 +1599,45 @@ version = "0.1.1"
15701599source = "registry+https://github.com/rust-lang/crates.io-index"
15711600checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
15721601
1602+[[package]]
1603+name = "toml"
1604+version = "1.1.6+spec-1.1.0"
1605+source = "registry+https://github.com/rust-lang/crates.io-index"
1606+checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
1607+dependencies = [
1608+ "indexmap",
1609+ "serde_core",
1610+ "serde_spanned",
1611+ "toml_datetime",
1612+ "toml_parser",
1613+ "toml_writer",
1614+ "winnow",
1615+]
1616+
1617+[[package]]
1618+name = "toml_datetime"
1619+version = "1.1.1+spec-1.1.0"
1620+source = "registry+https://github.com/rust-lang/crates.io-index"
1621+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
1622+dependencies = [
1623+ "serde_core",
1624+]
1625+
1626+[[package]]
1627+name = "toml_parser"
1628+version = "1.1.3+spec-1.1.0"
1629+source = "registry+https://github.com/rust-lang/crates.io-index"
1630+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
1631+dependencies = [
1632+ "winnow",
1633+]
1634+
1635+[[package]]
1636+name = "toml_writer"
1637+version = "1.1.2+spec-1.1.0"
1638+source = "registry+https://github.com/rust-lang/crates.io-index"
1639+checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
1640+
15731641[[package]]
15741642name = "tree-sitter"
15751643version = "0.27.0"
@@ -2901,23 +2969,6 @@ dependencies = [
29012969 "tree-sitter-language",
29022970]
29032971
2904−[[package]]
2905−name = "tui-textarea"
2906−version = "0.7.0"
2907−source = "registry+https://github.com/rust-lang/crates.io-index"
2908−checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae"
2909−dependencies = [
2910− "crossterm",
2911− "ratatui",
2912− "unicode-width 0.2.0",
2913−]
2914−
2915−[[package]]
2916−name = "unarray"
2917−version = "0.1.4"
2918−source = "registry+https://github.com/rust-lang/crates.io-index"
2919−checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
2920−
29212972[[package]]
29222973name = "unicode-ident"
29232974version = "1.0.24"
@@ -2962,6 +3013,127 @@ version = "0.2.0"
29623013source = "registry+https://github.com/rust-lang/crates.io-index"
29633014checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
29643015
3016+[[package]]
3017+name = "uniffi"
3018+version = "0.32.2"
3019+source = "registry+https://github.com/rust-lang/crates.io-index"
3020+checksum = "76407f5f396a2c949a069eff4a9fca9ce462410eb872bffef4c2b7b89804a77a"
3021+dependencies = [
3022+ "anyhow",
3023+ "camino",
3024+ "cargo_metadata",
3025+ "clap",
3026+ "uniffi_bindgen",
3027+ "uniffi_core",
3028+ "uniffi_macros",
3029+ "uniffi_pipeline",
3030+]
3031+
3032+[[package]]
3033+name = "uniffi_bindgen"
3034+version = "0.32.2"
3035+source = "registry+https://github.com/rust-lang/crates.io-index"
3036+checksum = "3df3ced8f0eda3de99f6d50820e8628f443da8e4f447f477d1403ceba29dacbd"
3037+dependencies = [
3038+ "anyhow",
3039+ "askama",
3040+ "camino",
3041+ "cargo_metadata",
3042+ "fs-err",
3043+ "glob",
3044+ "goblin",
3045+ "heck",
3046+ "indexmap",
3047+ "once_cell",
3048+ "serde",
3049+ "tempfile",
3050+ "textwrap",
3051+ "toml",
3052+ "uniffi_internal_macros",
3053+ "uniffi_meta",
3054+ "uniffi_pipeline",
3055+ "uniffi_udl",
3056+]
3057+
3058+[[package]]
3059+name = "uniffi_core"
3060+version = "0.32.2"
3061+source = "registry+https://github.com/rust-lang/crates.io-index"
3062+checksum = "f7530ae8efeaaa488622865966763fe0294832779fff80508c36d69c6a874a6a"
3063+dependencies = [
3064+ "anyhow",
3065+ "bytes",
3066+ "once_cell",
3067+ "static_assertions",
3068+]
3069+
3070+[[package]]
3071+name = "uniffi_internal_macros"
3072+version = "0.32.2"
3073+source = "registry+https://github.com/rust-lang/crates.io-index"
3074+checksum = "0f4bad017164375d99450d70495014810d15a84ba9da6ed14b9628b1424223ba"
3075+dependencies = [
3076+ "anyhow",
3077+ "indexmap",
3078+ "proc-macro2",
3079+ "quote",
3080+ "syn 2.0.119",
3081+]
3082+
3083+[[package]]
3084+name = "uniffi_macros"
3085+version = "0.32.2"
3086+source = "registry+https://github.com/rust-lang/crates.io-index"
3087+checksum = "5b855a58da153739ce6e863f6e296edc3716a093768c4d3d957f17f5e4317c60"
3088+dependencies = [
3089+ "camino",
3090+ "fs-err",
3091+ "once_cell",
3092+ "proc-macro2",
3093+ "quote",
3094+ "serde",
3095+ "syn 2.0.119",
3096+ "toml",
3097+ "uniffi_meta",
3098+]
3099+
3100+[[package]]
3101+name = "uniffi_meta"
3102+version = "0.32.2"
3103+source = "registry+https://github.com/rust-lang/crates.io-index"
3104+checksum = "af0c88664a9cb9559856f5a250283c9708923b303170e2e5c7db8f4ad65e9459"
3105+dependencies = [
3106+ "anyhow",
3107+ "siphasher",
3108+ "uniffi_internal_macros",
3109+ "uniffi_pipeline",
3110+]
3111+
3112+[[package]]
3113+name = "uniffi_pipeline"
3114+version = "0.32.2"
3115+source = "registry+https://github.com/rust-lang/crates.io-index"
3116+checksum = "d00f5c044b4a3dca0b1226c92ddaeea626f337470e22180dd182160c87a06b0b"
3117+dependencies = [
3118+ "anyhow",
3119+ "heck",
3120+ "indexmap",
3121+ "tempfile",
3122+ "uniffi_internal_macros",
3123+]
3124+
3125+[[package]]
3126+name = "uniffi_udl"
3127+version = "0.32.2"
3128+source = "registry+https://github.com/rust-lang/crates.io-index"
3129+checksum = "55dfd9c778d6b39cb5acd541d9ff63431974035d2bad83c897b766a6ee3152ac"
3130+dependencies = [
3131+ "anyhow",
3132+ "textwrap",
3133+ "uniffi_meta",
3134+ "weedle2",
3135+]
3136+
29653137[[package]]
29663138name = "utf8parse"
29673139version = "0.2.2"
@@ -3002,6 +3174,15 @@ dependencies = [
30023174 "wit-bindgen",
30033175]
30043176
3177+[[package]]
3178+name = "weedle2"
3179+version = "5.0.0"
3180+source = "registry+https://github.com/rust-lang/crates.io-index"
3181+checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e"
3182+dependencies = [
3183+ "nom",
3184+]
3185+
30053186[[package]]
30063187name = "winapi"
30073188version = "0.3.9"
@@ -3196,30 +3377,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
31963377checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
31973378
31983379[[package]]
3199−name = "wit-bindgen"
3200−version = "0.57.1"
3201−source = "registry+https://github.com/rust-lang/crates.io-index"
3202−checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
3203−
3204−[[package]]
3205−name = "zerocopy"
3206−version = "0.8.57"
3380+name = "winnow"
3381+version = "1.0.4"
32073382source = "registry+https://github.com/rust-lang/crates.io-index"
3208−checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
3383+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
32093384dependencies = [
3210− "zerocopy-derive",
3385+ "memchr",
32113386]
32123387
32133388[[package]]
3214−name = "zerocopy-derive"
3215−version = "0.8.57"
3389+name = "wit-bindgen"
3390+version = "0.57.1"
32163391source = "registry+https://github.com/rust-lang/crates.io-index"
3217−checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
3218−dependencies = [
3219− "proc-macro2",
3220− "quote",
3221− "syn 2.0.119",
3222−]
3392+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
32233393
32243394[[package]]
32253395name = "zmij"
Cargo.toml+1−3
@@ -1,16 +1,14 @@
11[workspace]
22resolver = "2"
3−members = ["crates/fold-core", "crates/fold-tui", "crates/fold-cli"]
3+members = ["crates/fold-core", "crates/fold-tui", "crates/fold-cli", "crates/fold-ffi"]
44
55[workspace.dependencies]
66ratatui = "0.29"
77crossterm = "0.28"
8−tui-textarea = "0.7"
98notify = "8"
109jiff = "0.2"
1110clap = { version = "4", features = ["derive"] }
1211indexmap = "2"
13−nucleo = "0.5"
1412similar = "2"
1513blake3 = "1"
1614unicode-normalization = "0.1"
README.mdnew file+149
@@ -0,0 +1,149 @@
1+<p align="center"><img src="docs/icon.png" width="128" height="128" alt="fold"></p>
2+
3+# fold
4+
5+One outline of notes and tasks, stored as plain Markdown, in the terminal.
6+
7+Everything you write — projects, notes, tasks, an inbox — is one tree. Headings and
8+bullets are its nodes; you read and edit any subtree on its own, move subtrees around,
9+and check tasks off. The files stay ordinary Markdown that `rg`, your editor and your
10+phone can read, and they sync with Syncthing.
11+
12+```
13+ fold › Homelab › NAS ⌕ Filter + Capture ↶ Undo ↷ Redo ☰ Commands ? Help
14+╭ Outline ───────────────────────╮╭ ZFS layout ▤ ─────────────────────────── ✎ Edit ─ ⋯ ─╮
15+│▾ Homelab 2/4 ││ # ZFS layout │
16+│ ▾ NAS 1/2 ││ ⚑ since 2024-03 │
17+│ ▾ ZFS layout ▤ 1/2 ⋯││ │
18+│ ▾ ☐ Snapshot policy ││ Mirrored pairs, no raidz. Snapshots hourly. │
19+│ ▸ Networking 1/2 ││ ## ☐ Snapshot policy │
20+│▾ Inbox ││ - hourly, keep 24 │
21+╰────────────────────────────────╯╰───────────────────────────────────────────────────────╯
22+```
23+
24+## Run it
25+
26+With Nix:
27+
28+```sh
29+nix run github:iko-soy/fold # the TUI on your vault
30+nix run github:iko-soy/fold -- --keys vim
31+```
32+
33+With Cargo (a recent stable Rust, and a C compiler for the syntax-highlighting grammars):
34+
35+```sh
36+cargo install --path crates/fold-cli
37+fold
38+```
39+
40+The vault is `--vault PATH`, else `$FOLD_VAULT`, else the nearest directory above the
41+current one that contains `root.md`, else `~/fold`. Pointed at an empty directory, the TUI
42+starts a new vault with an `# Inbox`.
43+
44+## Using it
45+
46+The TUI is mouse-first, and every action also has a key.
47+
48+- **Click** a row to select it. Each row shows the first line of its node's text; click
49+ `◨` in the top bar (or press `zp`) for the reading pane, which shows the whole selected
50+ node beside the outline. Click `▸`/`▾` to
51+ fold, `☐` to check a task off, a breadcrumb in the top bar to zoom out.
52+- **Double-click** a row to zoom into it. Double-click a line of text to edit it there.
53+- **Right-click** a row (or click its `⋯`) for everything you can do to a node: edit, new
54+ sibling or child, done, properties, move, indent, make a block, copy, paste, delete.
55+- **Drag** a row onto another row's title to nest it there, or to the left of a title to
56+ put it just before.
57+- **Filter** (`/`) finds nodes by title and text; **Commands** (`:`) lists every action by
58+ name; **Capture** (`c`) drops a note into today's inbox.
59+
60+Keys, in the outline: `j`/`k` move, `h`/`l` fold, `Enter` zooms, `e` edits, `x` checks a
61+task, `n`/`N` add a sibling or child, `J`/`K` move a node, `>`/`<` indent, `r` moves a
62+subtree elsewhere, `u`/`U` undo and redo, `?` (or `F1`) shows everything. The status bar
63+says what a key did, then which keys come next where you are. Everything is saved as you
64+go; `q` quits. The view — the pane, folds, zoom, wrapping, the editor's keys — is remembered
65+per vault for next time.
66+
67+### The editor
68+
69+`e` edits the selected subtree's Markdown in the reading pane. It saves on its own — when
70+you pause, when you leave a part of the tree that lives in another file, and when you're
71+done, or when fold is closed some other way (the window closed, the ssh session dropped,
72+a crash). Text it cannot save because the file changed under it goes to the trash, and
73+fold says where; `fold trash restore` prints it and leaves it there. Pick a keymap with
74+`--keys` or `$FOLD_KEYS`, or click `⌨` in the editor's border:
75+
76+- **normal** (default) — a conventional editor like micro: Shift+arrows select,
77+ `Ctrl-C`/`X`/`V`, `Ctrl-Z`/`Y`, `Ctrl-S`, `Ctrl-F`.
78+- **vim** — modes, counts, motions, operators, text objects, `.`, `/`, `:w`/`:q`.
79+- **helix** — selection first: `w`/`x` select, `d`/`c`/`y` act on the selection.
80+
81+Long lines wrap (`zw` toggles), fenced code is syntax-highlighted in about 140 languages,
82+and pasting from the terminal and copying to the system clipboard both work.
83+
84+## The files
85+
86+A vault is a directory with `root.md` and, optionally, one file per **block**:
87+
88+```markdown
89+# Homelab
90+
91+Two boxes in the closet, one at Hetzner.
92+
93+## NAS
94+
95+### ![[dozzod-binwes-talsun-worbec]]
96+
97+## Networking
98+
99+- [ ] Replace the flaky switch
100+![[racfer-hattes-mislup-nodrys]]
101+```
102+
103+- A heading or a bullet is a node; nesting is the tree. Headings can sit under bullets,
104+ and levels go past six.
105+- A checkbox after the marker makes any node a task: `- [ ]`, `## [x]`.
106+- A node that gets a property (a due date, say) becomes a **block**: its own file,
107+ `racfer~order-new-switch.md`, with YAML frontmatter and an `id`, stitched into its parent
108+ by `![[id]]`. That's how a single task gets a due date, and how the inbox becomes a small
109+ file you can append to from a phone.
110+
111+Nothing else is special. Deleting goes to a trash (`$XDG_STATE_HOME/fold/trash`), and
112+Syncthing conflict copies are merged node by node, with anything that differs kept as a
113+`conflict:` block to resolve in the app: it is marked `⚠` in the outline, and a click on the
114+`⚠` shows both sides.
115+
116+## The command line
117+
118+```sh
119+fold # the TUI
120+fold capture "call the plumber" # append to today's inbox (stdin if no text)
121+fold capture --task --to Homelab/NAS "check the scrub"
122+fold check [--fix] # diagnostics; --fix rewrites in canonical form
123+fold merge [--dry-run] # fold Syncthing conflict files in
124+fold trash list | fold trash restore ID
125+fold help
126+```
127+
128+## On Android
129+
130+[android/](android/README.md) is fold for the phone: the same vault, opened from the
131+folder Syncthing syncs it to, read and written by the same `fold-core`. The outline with
132+folds and checkboxes, zoom, the node menu, capture (also from the share sheet), the
133+subtree editor, properties with a date picker, find, move to, undo, and the conflict view
134+for sync-conflict copies. Build it with `./gradlew assembleDebug` in `android/`; see its
135+README for what it needs.
136+
137+## Developing
138+
139+```sh
140+nix develop # cargo, rustc, clippy, rustfmt, rust-analyzer
141+cargo test --workspace
142+nix flake check # the same tests, in the Nix sandbox
143+```
144+
145+The code is four crates: `fold-core` (parsing, rendering, the operations, merge),
146+`fold-tui` (the ratatui app), `fold-cli` (the `fold` binary) and `fold-ffi` (a session
147+over `fold-core` for other languages, through UniFFI, which the Android app calls).
148+[SPEC.md](SPEC.md) is the design: the data model, the file format, every operation and
149+the laws they keep.
SPEC.md+279−115
@@ -1,7 +1,7 @@
11# SPEC — a tree-shaped plain-text notes and task manager for the terminal
22
33Status: draft 0.40 · 2026-09-26
4−Working name: not chosen yet. This document uses `notes` as the binary name; rename freely.
4+Name: fold. The binary is `fold`; the crates are `fold-*` (§15.1).
55Language: Rust · TUI: ratatui · Sync: Syncthing · History: file versioning on one node
66
77---
@@ -17,8 +17,8 @@ same tree.
1717Principles, in priority order:
1818
19191. **The files are the database.** Every fact the app knows is recoverable from the Markdown.
20− There are no caches, no configuration, and no per-device state beyond the trash. The
21− app never writes a byte it didn't need to change.
20+ There are no caches, no configuration, and no per-device state beyond the trash and the
21+ remembered view (§10.1). The app never writes a byte it didn't need to change.
22222. **Markdown-shaped, with our own syntax where it pays.** Headings, bullets, checkboxes,
2323 fences and YAML frontmatter are used as they are. On top of them the format owns a small,
2424 closed set of things a standard renderer will not understand: `![[id]]` embeds, headings
@@ -135,7 +135,7 @@ Rules:
135135 as it is spelled — a heading or a bullet — and that is where its spelling is defined
136136 (§4.9). The embed repeats it in its form (§4.7) so the parent file parses into the right
137137 shape on its own; the app keeps the two in step (`~` rewrites both), and where they
138− disagree the file wins and `notes check` reports the embed.
138+ disagree the file wins and `fold check` reports the embed.
139139
140140### 3.2 Properties
141141
@@ -235,7 +235,7 @@ Resolution order for a command target (the *refile*, *go to* and *make block* pr
235235 (`2026-09-10`) resolve this way; there is nothing special about them.
236236
237237Ambiguous or missing targets are an error for the command that named them; broken or
238−duplicate embeds are diagnostics in `notes check` (§15.7).
238+duplicate embeds are diagnostics in `fold check` (§15.7).
239239
240240Renaming a block's title does not rename its file (§6.4); a stale name is harmless, since
241241nothing references the filename.
@@ -245,8 +245,9 @@ Titles may not contain `/` (the path separator). `[` and `]` are discouraged.
245245### 3.5 Derived facts
246246
247247Computed by the index, never stored in files: heading level, indent, path, file, span,
248−open/done task counts per subtree (`3/7` in the outline), per-file mtime. Per-node
249−timestamps are not tracked by the app (§12.6).
248+open/done task counts per subtree (`3/7` in the outline; a conflict copy's tasks, §12.4,
249+are not counted above it), per-file mtime. Per-node timestamps are not tracked by the app
250+(§12.6).
250251
251252---
252253
@@ -265,7 +266,7 @@ The vault is flat: every block sits beside `root.md`. Only `root.md` and `.md` f
265266whose frontmatter carries a valid `id` (§6.4) are parsed. Everything else — subdirectories,
266267non-Markdown files, `.md` files without an id — is ignored: never parsed, never written,
267268never deleted. There is no import: to bring a foreign `.md` file in, paste its text into
268−a node (or `notes capture < file.md`) and let the app assign ids as it makes blocks. `notes check`
269+a node (or `fold capture < file.md`) and let the app assign ids as it makes blocks. `fold check`
269270lists ignored `.md` files so nothing is forgotten in the vault.
270271
271272There is no configuration and no configuration file. The inbox is the section titled
@@ -308,7 +309,7 @@ The app **reads** any CommonMark it can make sense of and **writes** this subset
308309 readers agree the text is not part of that node.
309310
310311Canonicalization is **lazy**: a node is rewritten in canonical form only when it is touched
311−(edited, spliced, moved). `notes check --fix` (or *canonicalize* in the palette) rewrites
312+(edited, spliced, moved). `fold check --fix` (or *canonicalize* in the palette) rewrites
312313everything at once.
313314
314315**What is ours.** A standard Markdown renderer will mishandle exactly these four things:
@@ -327,7 +328,7 @@ title := any text not starting with whitespace; may not contain "/"
327328
328329There is no attribute syntax. An `@word` or `key:: value` in a title or body is text, and
329330so is `[[…]]` (reserved, §4.6).
330−A title line with an empty title is invalid; `notes check` reports it, the TUI won't create it.
331+A title line with an empty title is invalid; `fold check` reports it, the TUI won't create it.
331332
332333### 4.4 Frontmatter
333334
@@ -400,7 +401,7 @@ The app writes the form that matches the block's spelling: a block whose root is
400401is embedded with a heading embed, a block whose root is an item with a bare one. The
401402heading form exists because a bare embed after a section sibling would, like any line
402403there, belong to that section (§3.1). Either form is read anywhere; an embed whose form
403−does not match its block's spelling is a diagnostic (§15.7) that `notes check --fix`
404+does not match its block's spelling is a diagnostic (§15.7) that `fold check --fix`
404405rewrites, and it is placed where its form puts it.
405406
406407The embed names the block by id and nothing else; title, checkbox, properties, text and
@@ -414,7 +415,7 @@ decides the embed's parent the same way. The level its position gives is `1 +` i
414415ancestors (§3.1), and that is the level the app writes. A written level deeper than that —
415416left by another editor, or by moving text around by hand — still parses under the same
416417parent (a shallower one would parse elsewhere and is simply a different position), so
417−`notes check` reports it and `check --fix` rewrites it to the positional level; the
418+`fold check` reports it and `check --fix` rewrites it to the positional level; the
418419structure does not change.
419420
420421`![[` anywhere else is body text. The app does not implement general transclusion.
@@ -444,7 +445,7 @@ A block's file is exactly `render(node, 1, resolve_blocks = false)`:
444445 nodes, or text — is adopted as the root's children**, in order, as if it were indented
445446 under it. That is what a phone editor produces by appending `- new thing` to a block
446447 spelled as a bullet, and it must never make the block unusable. Adopted lines are
447− non-canonical (`notes check` notes them); the next write of the block, or `check --fix`,
448+ non-canonical (`fold check` notes them); the next write of the block, or `check --fix`,
448449 writes them in place under the root (indented under a bullet root, re-levelled under a
449450 heading root).
450451- Text before the root other than frontmatter, or an embed before it, is a diagnostic; the
@@ -582,8 +583,9 @@ in it moved. `splice(block)` writes one dirty block:
582583
583584Splice runs without being asked (§10.6): when the cursor moves from a dirty block into
584585another block, after a pause in typing, on leaving the editor, before any outline verb,
585−before a reload, and on quit. A commit may write several files — one per dirty block — but
586−each block is written by its own splice, and the law holds per block.
586+before a reload, and on quit or any other end of the app. A commit may write several
587+files — one per dirty block — but each block is written by its own splice, and the law
588+holds per block.
587589
588590Edge cases follow from the tags, not from rules:
589591
@@ -667,7 +669,7 @@ syllables (vowels `e u y`) — each word `prefix suffix`, words joined by `-`:
667669`racfer-hattes-dozzod-binwes`. No `~` sigil and no scrambling step, since the bits are random
668670rather than sequential. Never derived from content or time. Stored as the `id` frontmatter
669671key and validated against the syllable tables on read; a file whose `id` does not validate
670−is ignored, and `notes check` says why.
672+is ignored, and `fold check` says why.
671673
672674Collisions: 2⁶⁴ values, so two ids minted independently are never expected to collide; the
673675index still checks and redraws locally, and the merge engine still reports an id collision
@@ -694,7 +696,7 @@ splits without knowing how many words the prefix has. Because the prefix alone i
694696can never collide with anything, and `racfer~` already tells you which file you are looking
695697at before you read the rest. Cross-device collisions are found at merge time and resolved
696698the same way (§12.2). The filename carries no identity: a file is parsed because of its
697−`id` key, not its name, so `python-basics.md` with an `id:` is a block (and `notes
699+`id` key, not its name, so `python-basics.md` with an `id:` is a block (and `fold
698700check` asks for the prefix) while `racfer~order-new-switch.md` without one is ignored.
699701Safe on APFS, ext4, Android storage and Windows; case-insensitive-safe because lowercase.
700702
@@ -703,8 +705,8 @@ later title change does not rename the file. Under Syncthing a rename arrives on
703705devices as a delete plus a create; a phone that edits the old name meanwhile turns that
704706into a sync conflict or an orphan, for nothing — the prefix alone identifies the file and
705707nothing references the filename. A file renamed or titled by another tool is still found by
706−its id. `notes check` reports filenames whose prefix is not a leading run of the id (a
707−problem) and names that no longer match the title (stale, harmless); `notes check --fix`
708+its id. `fold check` reports filenames whose prefix is not a leading run of the id (a
709+problem) and names that no longer match the title (stale, harmless); `fold check --fix`
708710renames both, which is the one moment the app renames a file.
709711
710712### 6.5 Refile
@@ -733,8 +735,8 @@ delete it. Nothing in the archive is hidden or dimmed.
733735
734736- The inbox is the top-level section titled `Inbox` (case-insensitive). If none exists,
735737 capture creates `# Inbox` as the last top-level section of `root.md`. If several exist,
736− the first in `root.md` order wins and `notes check` reports the rest.
737−- **Capture** (`c` in the TUI, `notes capture`, a global hotkey via the CLI) appends an item
738+ the first in `root.md` order wins and `fold check` reports the rest.
739+- **Capture** (`c` in the TUI, `fold capture`, a global hotkey via the CLI) appends an item
738740 under today's day: the child section of `Inbox` titled with today's date, `YYYY-MM-DD`,
739741 created on demand as the last child. `--task` or a leading `[ ]` makes it a task.
740742 `--to <target>` captures under any node instead. A captured item is clamped like any
@@ -796,10 +798,12 @@ Dates are ISO, `YYYY-MM-DD`. The property editor refuses anything else for `due`
796798
797799Done items stay where they are: one line each, in the context that gave them
798800meaning, which is the cheapest history there is. The panes dim them, and `zd` toggles
799−hiding them for the session.
801+hiding them (remembered with the view, §10.1). The selection stays on its node; one
802+hidden with them gives way to its next shown sibling, else the row above it.
800803
801804When a list is genuinely finished, *clear done* (palette) trashes every done item with no
802−open descendants under `target` (default: current zoom root); task blocks'
805+open descendants under `target` (default: current zoom root), unless a conflict copy of
806+it stays (§12.5); task blocks'
803807files go to trash too. For whole finished subtrees — a completed project, a concluded
804808meeting series — `za` refiles them under `Archive` (§6.5). The trash (§11.5) and file versioning (§12.6) backstop both.
805809
@@ -837,31 +841,70 @@ the whole vault, live, no syntax. A query language is future work (§17, §19 de
837841Four regions, each of them live under the pointer:
838842
839843- The **top bar**: the breadcrumb of the zoom root (`fold` is the vault root; every
840− segment is a link that zooms there), and buttons for *Filter*, *Capture*, *Undo*,
841− *Redo*, *Commands* and *Help*. Buttons shrink to their icons when the bar is narrow.
844+ segment is a link that zooms there), and buttons for *Filter*, *Capture*, *Reading
845+ pane*, *Undo*, *Redo*, *Commands* and *Help*. Buttons shrink to their icons when the bar
846+ is narrow; a toggle's button looks pressed while it is on.
842847- The **outline pane**: titles, fold markers (`▸ ▾`), task glyphs (`☐ ☑`), the block
843− marker (`▤`), and on the right a due date and the open/total count of the tasks below
844− (a leaf shows no count). Done tasks are dimmed and struck through; sections are bold.
845− Long titles end in `…`. Never bodies.
848+ marker (`▤`), a due date and the open/total count of the tasks below (a leaf shows no
849+ count). Done tasks are dimmed and struck through; sections are bold. Long titles end in
850+ `…`. Beside the reading pane, the date and count sit at the pane's right edge and rows
851+ show titles only. Without it, they follow the title, and after them, dimmed, comes the
852+ first line of the node's own text, so no prose is out of sight. A conflict copy
853+ (§12.5) has `⚠`, in the warning colour, for `▤`, and, in place of its text, whose copy
854+ it is: *other device · PHONE 09-27 10:00*. It starts folded until unfolded or zoomed
855+ into, a fold the view does not remember.
846856- The **reading pane**: `render(target, 1, true)`, the zoom root's or else the selected
847857 node's, with light Markdown styling (§10.9); a block's properties appear as a dimmed
848− `⚑` line under its title, never as text. Its border carries the node's title and the
858+ `⚑` line under its title, never as text, and a conflict copy's title line ends in its
859+ `⚠` and whose copy it is, as its row does. Its border carries the node's title and the
849860 buttons *Edit* and `⋯` (the node menu). While editing, the pane is the editor (§10.6) and
850861 its buttons are *Done* and *Revert*.
851−- The **status bar**: the last message on the left; on the right the unresolved conflicts
852− (click to resolve), *done hidden* when `zd` is on (click to show), the file, and the save
853− state.
854−
855−Split layout, outline on the left at a third of the width (minimum 30 columns); the border
862+- The **status bar**: the last message on the left (at start, a hint at the gestures and
863+ `?`, cut down to *right-click for actions · ? help* where it doesn't fit); on the right
864+ the unresolved conflicts (click to resolve; lit while new ones wait, §10.7), *done
865+ hidden* when `zd` is on (click to show), the file, and the save state. A verb's message
866+ names the node it acted on, in the menus' words, with the next key where one helps —
867+ *deleted “Homelab” (12 nodes) · u undoes*, *moved “rack” to “NAS” — placed before the
868+ sections* when the ordering rule chose the place (§3.1); in the reading pane, whose keys
869+ are its own (§10.4), the step is the menu of the line's node: *copied “NAS” · m, then
870+ choose Paste after*; in the editor, where every key is text (§10.6), it is the
871+ pointer's, short enough to keep the node and the item to choose at 80 columns: *copied
872+ “NAS” · right-click, Paste after*, *deleted “rack” · click ↶ Undo* — and names no file
873+ or id (§1, 3b), but for undo or redo refusing
874+ (§10.10); where the bar is short, the right gives way first, the ⚠ count down to its
875+ number (*⚠ 1*), then the file, until the message fits; then the names are cut down,
876+ the longest first, before the words around them. Once a message is some 5 s old and a key
877+ or click has come since, the next step takes its place: the keys of what is on screen —
878+ *n new · e edit · x done · m menu · / find · ? help* in the outline, *e edit · Enter
879+ zoom/follow · Tab outline* in the reading pane, *Esc done · Ctrl-S save · Ctrl-Z undo* in
880+ the editor (*i insert · :q! revert · :wq done* in Vim and Helix, where `Esc` never
881+ leaves), *n add · Enter change · d delete · Esc close* in the property form, and each
882+ popup's own — cut down by whole parts where they don't fit, the first and the last kept,
883+ so the way on (*? help*, *Esc done*, *:wq done*) stays. An error or a refusal
884+ (*error: …*, *can't …*, *… refused: …*) stays until a key or click that comes once it
885+ is that old. The greeting is the outline's, and gives way at once to the editor's or a
886+ popup's keys. While a key sequence is half typed, the bar says what can follow it:
887+ *z… p pane · w wrap · d hide done · r raw · a archive*, cut down from its end where it
888+ doesn't fit; with done hidden, *d show done* comes first.
889+
890+The outline fills the screen. The reading pane is hidden until asked for: `zp` or the top
891+bar's `◨` (*Reading pane*) shows it, `Tab` shows it and moves focus there, and the editor
892+(§10.6) always opens in it, hiding it again when done if it was hidden before. Shown, the
893+layout splits: outline on the left at a third of the width (minimum 30 columns); the border
856894between the panes is a handle — drag it to resize. Below 80 columns the panes stack.
857895
896+**The view is remembered** per vault, in `$XDG_STATE_HOME/fold/views/`: whether the
897+reading pane is shown, wrapping, hidden done tasks, the editor's keymap, the divider's
898+place, the folds and the zoom. None of it is in the vault; another device keeps its own.
899+A fold or zoom whose node has gone is dropped.
900+
858901**Long lines wrap** in the reading pane and the editor. Prose breaks after a space, and a
859902wrapped list item's continuation rows hang under its text, not under its bullet or
860903checkbox; a word longer than the pane breaks where it must. Lines inside fenced code break
861904at the pane's edge instead, one column early, with `↪` marking each break, so code is
862905never reflowed. Every screen row of a line is that line to the pointer: a click on any
863906row selects it or places the cursor where it lands. `zw` (*Wrap lines*) turns wrapping
864−off for the session, and long lines are cut at the edge. The outline never wraps: a long
907+off, and long lines are cut at the edge. The outline never wraps: a long
865908title ends in `…`.
866909
867910**The pointer.** Everything a key does, the pointer does too, and the screen shows where:
@@ -871,6 +914,7 @@ title ends in `…`.
871914| click | an outline row | select it (the reading pane follows) |
872915| click | `▸` / `▾` | fold / unfold |
873916| click | `☐` / `☑`, in either pane | toggle the task |
917+| click | a conflict copy's `⚠`, in either pane | the conflict view at its pair (§10.7) |
874918| click | `⋯` (shown on the selected and the hovered row) | the node menu |
875919| click | a breadcrumb segment | zoom there |
876920| click | a link in the reading pane | open it |
@@ -887,22 +931,40 @@ title ends in `…`.
887931While dragging, the target row is highlighted (*into*) or marked with `▶` and a bar
888932(*before*), the row being moved is dimmed, the status bar spells out the move, and the pane
889933scrolls when the pointer reaches its edge. Both drops are clamped by the ordering rule
890−(§3.1) and say so when they are; a drop into the node's own subtree is refused. One drag is
891−one undo step.
934+(§3.1) and say so when they are; a drop into the node's own subtree is refused, and so is
935+one into a conflict copy it is not in, which keeping ours trashes (§12.5): over the copy's
936+title or a row in it, nothing is marked and the status bar says why. One drag is one undo
937+step.
938+
939+**No key acts out of sight.** The wheel leaves the selection, and the reading pane's cursor,
940+where they are, even out of view. A key that acts on one — in the outline
941+`x t d s J K > < ~ za r p P`, `e a m y` and the folds; in the reading pane `x`, `Enter` and
942+`e a m o` — first scrolls it back into view, a third of the way down. A key that changes
943+nothing then acts; one that changes something stops there, and until the next key or click
944+the status bar says what a second press does: *press d again to delete “Replace fan”*.
945+Pressed again, with the node in view, it acts. The pointer acts where it points and is
946+never held back.
892947
893948**The node menu** (right-click, `⋯`, or `m`) lists every action on a node, each with its key:
894949*Edit · Zoom in · Properties… | New sibling · New child | Done / reopen · Task on / off ·
895950Heading ↔ bullet · Make block | Move up · Move down · Indent · Outdent · Move to… · Archive |
896−Copy · Paste after · Paste before · Delete*. Hovering highlights an item; a click runs it.
951+Copy · Paste after · Paste before · Delete*, and on either side of a conflict pair, last,
952+*Resolve conflict…*: the conflict view at that pair. Moving the pointer onto an item
953+highlights it, and a click runs it; `↑`/`↓` and the wheel move the highlight on, whatever
954+item the pointer rests on. On a screen too short for it the separators go first, then the
955+list scrolls with the selection.
897956
898957**Pickers instead of typing.** Where an action needs a node — *Move to…*, *Go to…* — the
899958prompt is a list of every node, title first and path dimmed, narrowed as you type (title
900959prefix, then title, then path) and picked with a click or `↑`/`↓` and `Enter`. A typed id,
901−path or title (§3.4) still works. *Move to…* leaves out the moving node's own subtree.
960+path or title (§3.4) still works. *Move to…* leaves out the moving node's own subtree, and
961+its conflict pair's, which moves with it, and conflict copies, which keeping ours trashes
962+(§12.5); *Go to…* marks a node in one `⚠`.
902963
903964Every popup — node menu, prompt, properties, filter, commands, help — has its buttons on
904965its bottom border (*OK*, *Close*, …), a list you can click and scroll, and closes on a click
905−outside it or `Esc`.
966+outside it or `Esc`. It covers the panes: a click, right-click or wheel inside it never
967+reaches what is behind it, so a right-click there opens no node menu.
906968
907969### 10.2 Modes
908970
@@ -922,6 +984,7 @@ the popup — closes the topmost thing.
922984| `zd` | toggle hiding done nodes (both panes) |
923985| `zr` | toggle raw mode: exact source in the reading pane, frontmatter included |
924986| `zw` | toggle wrapping of long lines in the reading pane and the editor (§10.1) |
987+| `zp` | show / hide the reading pane (§10.1) |
925988| `za` | archive: refile subtree under `Archive` (§6.5) |
926989| `-` | go to parent |
927990| `{` / `}` | previous / next sibling |
@@ -929,25 +992,31 @@ the popup — closes the topmost thing.
929992| `Ctrl-d` / `Ctrl-u` | half-page down / up |
930993| `Enter` | zoom: reading pane shows the cursor node; reading pane takes focus |
931994| `Backspace` | zoom out to parent |
932−| `>` / `<` | demote / promote: become the last child of the previous sibling node / the next sibling of the parent, both clamped by the ordering rule (§3.1): an item promoted out of a section lands just before the parent's first section sibling. Spelling unchanged |
933−| `~` | toggle spelling, section ↔ item (subtree unchanged). The node moves to its parent's boundary (§3.1): an item respelled as a section becomes the first section child, a section respelled as an item the last item child, so no sibling changes parent |
934−| `J` / `K` | move node down / up past the next / previous sibling node; text children stay put. An item never moves below a section sibling, nor a section above an item: the move is refused with a message |
995+| `>` / `<` | demote / promote: become the last child of the previous sibling node / the next sibling of the parent, both clamped by the ordering rule (§3.1): an item promoted out of a section lands just before the parent's first section sibling. Spelling unchanged. `>` is refused under a conflict copy, which keeping ours trashes; a node and its conflict copy go as one, from either side (§12.5) |
996+| `~` | toggle spelling, section ↔ item (subtree unchanged). The node moves to its parent's boundary (§3.1): an item respelled as a section becomes the first section child, a section respelled as an item the last item child, so no sibling changes parent. Refused on either side of a conflict pair until it is resolved (§12.5) |
997+| `J` / `K` | move node down / up past the next / previous sibling node; text children stay put. A node and its conflict copy (§12.4) move as one, and a node moves past them as one. An item never moves below a section sibling, nor a section above an item: the move is refused with a message |
935998| `n` / `N` | new sibling after cursor / new last child, spelled like the cursor node / like the last child node (a section if the parent has section children): inserts an empty node and opens it with `e` |
936999| `e` | edit the subtree's Markdown in the built-in editor; saves as you go (§10.6) |
9371000| `a` | property editor: a form over the node's properties (§10.6); first property on a plain node makes it a block |
9381001| `x` | toggle task open / done: the checkbox, plus `done:` on a block |
9391002| `t` | toggle task-ness: adds or removes the checkbox (and a block's `done:`) |
9401003| `s` | make the node a block |
941−| `y` / `d` | yank / delete subtree into the register (delete goes to trash too) |
1004+| `y` / `d` | yank / delete subtree into the register (delete goes to trash too, and takes the node's conflict copies with it, §12.5) |
9421005| `p` / `P` | paste register after / before cursor as sibling, clamped by the ordering rule (§3.1) |
9431006| `r` | refile: fuzzy-pick a destination; `Ctrl-Enter` = as first child |
9441007| `c` / `C` | capture to the inbox as bullet / as task |
9451008| `/` | filter box (§10.5) |
9461009| `m` | the node menu (§10.1) |
947−| `:` | command palette (§10.8); `?` help |
1010+| `:` | command palette (§10.8); `?` or `F1` help |
9481011| `u` / `U` | undo / redo |
9491012| `q` | quit (nothing is ever unsaved in normal mode) |
9501013
1014+A second key that follows the first in no sequence (`zq`) does nothing and says so: *zq
1015+does nothing · after z press p, w, d, r or a*; `Esc` or a click lets the first key go. A
1016+few keys fold has no use for, where one often reaches for them, say what to press instead:
1017+`i` (*i does nothing here: e edits*), `o` (*n adds a node below*), `Delete` (*d deletes*),
1018+`Ctrl-Z` (*u undoes*), `Ctrl-F` (*/ finds*); any other key that does nothing says nothing.
1019+
9511020### 10.4 Reading pane — normal mode
9521021
9531022| Key | Action |
@@ -960,14 +1029,15 @@ the popup — closes the topmost thing.
9601029| `o` | open external link under cursor (`xdg-open` / `open`) |
9611030| `[[` / `]]` | previous / next heading |
9621031| `/` | in-block search; `n` / `N` next / previous match |
1032+| `:` `?` `F1` `m` `c` `C` `u` `U` `z…` | as in the outline; `m` is the menu of the node under the cursor; `i`, `Delete` (*m, then choose Delete*), `Ctrl-Z` and `Ctrl-F` (*/ searches*) say what to press instead |
9631033
9641034### 10.5 Filter box
9651035
9661036`/`, or *Filter* in the top bar, opens a popup with an input and the hits below it:
967−fuzzy title match (nucleo) and full-text match over every text child, each hit shown as its
968−title with its path dimmed. Clicking a hit — or `↑`/`↓` and `Enter` — unfolds its
969−ancestors and selects it. `Esc` or a click outside closes. Creating nodes is `n` / `N`
970−(§10.3).
1037+fuzzy title match (the typed letters in order) and full-text match over every text child, each hit shown as its
1038+title with its path dimmed, and `⚠` after the title of a hit in a conflict copy (§12.5).
1039+Clicking a hit — or `↑`/`↓` and `Enter` — unfolds its ancestors and selects it. `Esc` or a
1040+click outside closes. Creating nodes is `n` / `N` (§10.3).
9711041
9721042### 10.6 Editing
9731043
@@ -984,11 +1054,39 @@ app keeps track. Blocks are saved on their own:
9841054- when the cursor moves out of a block that has changed;
9851055- after 750 ms without a keystroke;
9861056- on `Esc` or *Done* (back to normal mode), on any outline verb, before a reload caused by
987− an external change, and on quit.
988−
989−The pane's border shows the title of the block the cursor is in and a dot while something
990−is unsaved, and lines of other blocks in the subtree are drawn a shade dimmer — the only
991−hints that blocks exist. *Revert* (or `:q!`) discards changes made since the last save.
1057+ an external change, and on quit;
1058+- when the app ends any other way: `SIGTERM`, `SIGHUP` (a closed window, a dropped ssh
1059+ session), `SIGINT`, an I/O error, a panic. A second `SIGTERM` or `SIGINT` ends it at
1060+ once, unsaved, as the signal would: the way out while a write waits on a terminal that
1061+ reads nothing. A `SIGHUP` before one does not make it a second.
1062+
1063+An outline verb that writes re-renders the editor over what it wrote, so the save before it
1064+deletes a block cut there and not pasted back (§5.2). One that writes nothing — a copy, a
1065+zoom, help, the palette, the filter, a view toggle, or a prompt or the property form until
1066+it writes — leaves the editor as it is, a cut block still to paste.
1067+
1068+A save shows in the status bar's save state (§10.1), not as a message, but for `:w` and
1069+`Ctrl-S`, which are asked for and answer *saved*, and one that an outside change forced,
1070+which the news of what came in mentions first, so that where the bar is short the list
1071+of it gives way: *↻ typing saved; changed outside fold: Inbox (+1 item)* (§11.2). News
1072+of a sync conflict says what to do and no more (§10.7).
1073+
1074+The pane's border shows the title of the block the cursor is in, *⚠ conflict copy from
1075+PHONE* after it in a conflict copy (§12.5), and a dot while something is unsaved, and lines
1076+of other blocks in the subtree are drawn a shade dimmer — the only hints that blocks
1077+exist. The border's text stays left of `⌨` and the buttons: where that is short, the title
1078+gives way first, to its first letters, then the words after ⚠ (*⚠ copy from PHONE*, then
1079+*⚠*), then *Editing*; the ⚠, the dot and the Vim or Helix mode stay whole.
1080+*Revert* (or `:q!`) discards changes made since the last save.
1081+
1082+Text a save cannot take — its block changed on disk under it (§5.2) — stays in the editor,
1083+and is never dropped without a copy unless asked twice: *Revert* first writes the editor's
1084+whole text to the trash as `unsaved-<title>.md` (§11.5) and the status line names the entry,
1085+and the app ending any way but a quit does the same, saying where once the terminal is back:
1086+a panic in the save itself too, whether an autosave, `Esc` or leaving a block ran it.
1087+Where the trash cannot be written, *Revert* drops nothing and says so, and a second *Revert*
1088+on the same text drops it. However it ends, but for that second signal, the app leaves the
1089+terminal as it found it, a panic's message printed after.
9921090
9931091**Keymaps.** The editor speaks one of three keymaps, over the same text, cursor, selection,
9941092clipboard and undo:
@@ -1017,10 +1115,14 @@ All three take `:` commands — `:w` save, `:q` / `:wq` / `:x` done, `:q!` / `:e
10171115selects, a double-click selects a word, the wheel scrolls, and text pasted into the
10181116terminal is inserted as typed. Copying also sets the system clipboard (OSC 52). Undo
10191117inside the editor is the editor's own; each save is still one entry in the op log (§10.10).
1118+`F1` shows the help, `?` being text here.
10201119
10211120The keymap is `$FOLD_KEYS` (`normal`, `vim`, `helix`), or `--keys`; the *Editor keys*
1022−action, or the `⌨` label in the editor's border, switches it for the session. Undo inside the editor is the editor's own; each automatic save is one
1023−entry in the session op log (§10.10). The box is not a Markdown editor and stays one.
1121+action, or the `⌨` label in the editor's border, switches it, and the choice is remembered
1122+with the view (§10.1) unless `--keys` or `$FOLD_KEYS` chose the keymap for this run. When
1123+editing ends, focus returns to the pane it came from. Undo inside the editor is the
1124+editor's own; each automatic save is one entry in the session op log (§10.10). The box is
1125+not a Markdown editor and stays one.
10241126
10251127Properties are not text. `a`, or *Properties…* in the node menu, opens the **property
10261128editor**: a small form listing the
@@ -1038,9 +1140,20 @@ and each saves the editor first.
10381140
10391141### 10.7 Conflict view
10401142
1041−Entered when a `.sync-conflict-*` file is detected or a splice hits a changed span. Shows
1042−each conflict pair (ours in place, the `conflict:` block right after it) side by side; `o` keeps ours,
1043−`t` keeps theirs, `b` keeps both, `e` edits, `n` / `N` next / previous, `Enter` finishes.
1143+Opened by the status bar's ⚠ count or *Resolve conflicts*; at a pair, by a copy's `⚠` or
1144+*Resolve conflict…* in either side's node menu; and on its own when a `.sync-conflict-*`
1145+file is detected or a splice hits a changed span — on the first new pair in the outline,
1146+and only from an outline at rest: normal mode, nothing open over it, no key, paste or
1147+click for 2 s. A busy user is not interrupted: the mode stays, the editor keeps its text,
1148+the status line names the node — *sync conflict in “NAS”: click ⚠ to resolve*
1149+— and the ⚠ count is lit until the view opens, from a click or on its own once the outline
1150+is at rest. Opened on its own, for its first half second it ignores `o t b e`, meant for
1151+what was there before. Pairs that come in while it is open leave it on the pair it shows.
1152+
1153+It shows each conflict pair (ours in place, the `conflict:` block right after it) side by side; `o` keeps ours,
1154+`t` keeps theirs, `b` keeps both, `e` edits, `n` / `N` next / previous, `u` / `U` undo / redo,
1155+`Enter` finishes. Each choice names its node — *kept theirs for “NAS” · u undoes* — and is
1156+one undo step.
10441157The view's top bar carries the same as buttons — *Previous*, *Next*, *Keep ours*, *Keep
10451158theirs*, *Keep both*, *Edit ours*, *Close* — and the two versions sit side by side, this
10461159device's on the left.
@@ -1051,14 +1164,16 @@ line until resolved (§12.5).
10511164
10521165`:`, or *Commands* in the top bar, opens a popup listing every action the TUI has — key,
10531166name, what it does — filtered as you type: names that start with the query first, then
1054−names and descriptions that contain it, then fuzzy matches. A click or `Enter` runs the
1055−highlighted action; if it needs an argument — a node for *Move to…* or *Go to…*, text for
1056−*Capture* — its prompt follows (§10.1). `Esc` or a click outside closes.
1167+names and descriptions that contain it, then fuzzy matches. A toggle shows its state
1168+beside its name (*Reading pane · on*). A click or `Enter` runs the highlighted action; if
1169+it needs an argument — a node for *Move to…* or *Go to…*, text for *Capture* — its prompt
1170+follows (§10.1). `Esc` or a click outside closes.
10571171
10581172There is no command syntax: nothing is typed except the search and the argument. Every
10591173action is in the palette under a readable name, and the four that have no key or button
10601174live only there: *Clear done*, *Canonicalize*, *Merge sync conflicts*, *Resolve
1061−conflicts*. `?` is help: the pointer gestures first, then the keys.
1175+conflicts*. `?` is help: the pointer gestures first, then the keys. Where it is taller
1176+than the screen it scrolls, a bar on its right border: `j`/`k`, `↑`/`↓` or the wheel.
10621177
10631178### 10.9 Markdown styling in the reading pane
10641179
@@ -1097,8 +1212,19 @@ before and after, where *absent* stands for a file the operation created or dele
10971212a block, trashing one). An operation that changed nothing leaves no entry, so it does not
10981213clear the redo stack. Undo checks every touched file against its *after* text, and redo
10991214against its *before* text; if any differs — another editor or a sync changed it since — it
1100−refuses with a message naming the file, writes nothing, and keeps the entry. Files the
1215+refuses with a message naming the file, writes nothing, and keeps the entry. An entry is
1216+named in words, by what was done to which node — *mark “rack” done*, *delete “Homelab”*,
1217+*edit “NAS”* for an editor save — and undo and redo say it: *undone: mark “rack” done*,
1218+*undo refused: root.md changed since mark “rack” done; not overwriting*. Files the
11011219operation did not touch are never written by undo, so an external edit to them survives.
1220+A block cut in the editor and not pasted back (§5.2) is trashed in the entry of the save
1221+that wrote its embed out, even when a later save, *Revert* or a change from outside that
1222+re-renders the editor (§11.2) is what deletes it, so one undo puts back its embed and its
1223+file together, or refuses both where that change is in the way. One pasted back after a
1224+save wrote its embed out makes that save, the one that writes the embed back and those
1225+between one entry, so no undo stops where its file is embedded nowhere: one undo puts it
1226+back where it was. Where a file they touched changed from outside between two of them,
1227+they stay apart, as undoing them as one would drop that change.
11021228
11031229---
11041230
@@ -1106,7 +1232,7 @@ operation did not touch are never written by undo, so an external edit to them s
11061232
11071233### 11.1 Writes
11081234
1109−- **Atomic**: write to `.<name>.notes-tmp` in the same directory, `fsync`, `rename`.
1235+- **Atomic**: write to `.<name>.fold-tmp` in the same directory, `fsync`, `rename`.
11101236 Never truncate in place.
11111237- **Span-preserving**: an edit replaces exactly the bytes of the affected node(s) — for a
11121238 property change, exactly the frontmatter lines that changed. Every other byte is copied
@@ -1118,14 +1244,32 @@ operation did not touch are never written by undo, so an external edit to them s
11181244### 11.2 Watcher and reload
11191245
11201246`notify`-based recursive watcher on the vault, 200 ms debounce, ignoring the patterns in
1121−§11.4.
1247+§11.4. Only writes count — a file created, written, renamed or removed — never a file
1248+opened or read, as every reload reads them all. A reload runs only when the files differ
1249+from what the app last read or wrote (a file's text changed, a file came or went) or a new
1250+`*.sync-conflict-*.md` file appeared. The app's own writes, seen once they land, reload
1251+nothing.
11221252
11231253- A changed file that the user is not editing is re-parsed; the cursor is re-attached by
11241254 id, then key (§3.4), then the deepest step of the key that still exists.
1255+- The status line says what came in, by the top-level nodes it touched:
1256+ `↻ changed outside fold: Inbox (+1 item)`.
11251257- A changed file with a built-in edit in progress: the editor saves its dirty blocks first;
11261258 a block whose span hash no longer matches is merged two-way (§5.2 step 5), then the file
1127− is re-parsed and the buffer re-rendered around the cursor.
1128−- A new `*.sync-conflict-*.md` file starts the merge flow (§12).
1259+ is re-parsed and, where the text the buffer shows changed, the buffer re-rendered around
1260+ the cursor; a block cut there and not pasted back is deleted then (§5.2). A change that
1261+ leaves that text as it was — to another node of the same file, as the phone adding to
1262+ the Inbox in `root.md`, or to a file the buffer does not hold — leaves the editor as it
1263+ is, a cut block still to paste.
1264+- A new `*.sync-conflict-*.md` file starts the merge flow (§12). What it merges without a
1265+ pair is said as above, at startup too; a copy that brings nothing in, one it leaves alone
1266+ or one the same as its file, says nothing, nor that the editor saved first for it. A copy
1267+ it leaves alone or fails on is no change by itself, and is tried again with the next
1268+ change. A copy that lands while a reload runs, as the next of a batch Syncthing delivers
1269+ a moment apart, is new to the next reload. A block cut in the editor and not pasted back
1270+ is moved on this device: a copy from before the cut, as the phone's `root.md` with an
1271+ item added to the Inbox, still embeds it where it was, and is merged without that embed
1272+ (§12.3): the block is not put back there, and the paste embeds it once.
11291273- A deleted file that was a block marks its embed broken; nothing is written.
11301274- Editing the same vault in Helix at the same time is a supported workflow.
11311275
@@ -1143,16 +1287,20 @@ during atomic writes.
11431287### 11.4 Ignore patterns
11441288
11451289Never parsed, never written, never deleted: dotfiles and dot-directories (`.git`, `.jj`,
1146−`.stfolder`, `.stversions`, `.stignore`, `.obsidian`), `.syncthing.*.tmp`, `*.notes-tmp`,
1290+`.stfolder`, `.stversions`, `.stignore`, `.obsidian`), `.syncthing.*.tmp`, `*.fold-tmp`,
11471291`*.tmp`, and any file not ending in `.md`. `*.sync-conflict-*.md` is parsed only by the merge
11481292engine.
11491293
11501294### 11.5 Trash
11511295
11521296Deleted subtrees and resolved `conflict:` blocks are written to
1153−`$XDG_STATE_HOME/notes/trash/<timestamp>-<id-or-name>.md` (device-local, never synced) before
1154−removal. `notes trash list|restore` manages it. The app never deletes user content without
1155−a trash copy.
1297+`$XDG_STATE_HOME/fold/trash/<timestamp>-<id-or-name>.md` (device-local, never synced) before
1298+removal; editor text no save could take, to `<timestamp>-unsaved-<title>.md` (§10.6).
1299+`fold trash list|restore` manages it. `restore` moves an entry back into the vault, but an
1300+`unsaved-` entry has no id, so the vault would ignore it as a file (§6.4): it is printed
1301+instead, and stays in the trash. The content decides, not the name: a block file renamed
1302+`unsaved-….md` (§3.4) keeps its id and is moved back. The app never deletes user content
1303+without a trash copy.
11561304
11571305---
11581306
@@ -1164,12 +1312,13 @@ a trash copy.
11641312 winner plus a `name.sync-conflict-<date>-<time>-<device>.md` copy of the loser.
11651313- Therefore: keep concurrently-edited things in different files where possible (a
11661314 `Inbox` as a block; blocks), and make the app the thing that understands conflict files.
1167−- The vault's `.stignore` should contain at minimum: `.git`, `.jj`, `*.notes-tmp`, `*.tmp`.
1315+- The vault's `.stignore` should contain at minimum: `.git`, `.jj`, `*.fold-tmp`, `*.tmp`.
11681316
11691317### 12.2 Detection
11701318
1171−The watcher (or `notes merge`, or the startup scan) finds `X.sync-conflict-*.md` next to
1172−`X.md`. Several conflict files for the same `X.md` are merged oldest first.
1319+The watcher (or `fold merge`, or the startup scan) finds `X.sync-conflict-*.md` next to
1320+`X.md`, as the files are then: an `X.md` that came in with its copy is merged into. Several
1321+conflict files for the same `X.md` are merged oldest first.
11731322
11741323Before merging, the engine compares the two files' `id` keys. If they differ, this is not a
11751324conflict but a **prefix collision** — two blocks that happened to get the same filename
@@ -1180,10 +1329,11 @@ and nothing is lost.
11801329
11811330### 12.3 Merge inputs
11821331
1183−Ours `O` is `X.md`; theirs `T` is the conflict file — or, for an in-session conflict
1184−(§5.2 step 5), the text the user just edited. There is no stored common ancestor (§11.3),
1185−so every merge is **two-way**: the engine can tell *that* two versions differ, not *which*
1186−side changed. One algorithm, one code path.
1332+Ours `O` is `X.md`; theirs `T` is the conflict file, without its embeds of the blocks cut
1333+in the editor and not pasted back (§11.2) — or, for an in-session conflict (§5.2 step 5),
1334+the text the user just edited. There is no stored common ancestor (§11.3), so every merge
1335+is **two-way**: the engine can tell *that* two versions differ, not *which* side changed.
1336+One algorithm, one code path.
11871337
11881338### 12.4 Node-level merge
11891339
@@ -1222,8 +1372,8 @@ algorithm is deterministic and idempotent.
12221372
12231373### 12.5 Resolution
12241374
1225−The conflict view (§10.7) lists every `conflict:` block, each shown against the sibling
1226−before it, and the status line counts them. Resolving a pair:
1375+The conflict view (§10.7) lists every `conflict:` block, in outline order, each shown
1376+against the sibling before it, and the status line counts them. Resolving a pair:
12271377
12281378- **keep ours** — delete the conflict block and its embed (to trash);
12291379- **keep theirs** — replace ours' title and children (text and nodes) with the conflict block's,
@@ -1233,7 +1383,15 @@ before it, and the status line counts them. Resolving a pair:
12331383 sibling.
12341384
12351385Unresolved pairs are ordinary blocks; they sync to every device and are visible in any
1236−editor, and can be lived with indefinitely.
1386+editor, and can be lived with indefinitely. The app marks each copy `⚠` and folds it,
1387+leaves its tasks out of the counts, and offers it as no *Move to…* destination; `>` and a
1388+drop put nothing under it (§10.1, §10.3). No verb puts a node between a copy and the node it
1389+follows, which the copy would then pair with, nor takes one away from the other: `J`/`K`,
1390+*Move to…*, `za`, `>`, `<` and a drop move the two as one, from either side, and into
1391+neither; `d` on the node deletes its copies with it, and on a copy that copy alone, as
1392+keep ours does; *clear done* keeps a done node whose copy it keeps; `~` is refused on
1393+either side until the pair is resolved. A node added, pasted, dropped or outdented between
1394+them goes after the copy.
12371395
12381396### 12.6 History (outside the app)
12391397
@@ -1246,16 +1404,16 @@ notices it.
12461404
12471405## 13. CLI
12481406
1249−All commands take `--vault PATH` (default: `$NOTES_VAULT`, else the nearest ancestor of
1250−`$PWD` containing `root.md`, else `~/notes`).
1407+All commands take `--vault PATH` (default: `$FOLD_VAULT`, else the nearest ancestor of
1408+`$PWD` containing `root.md`, else `~/fold`).
12511409
12521410| Command | Purpose |
12531411|---|---|
1254−| `notes [--keys normal\|vim\|helix]` | open the TUI; creates `root.md` if the directory is empty (§4.1.1); `--keys` picks the editor's keymap (§10.6) |
1255−| `notes capture [TEXT] [--to TARGET] [--task]` | append to the inbox (stdin if no TEXT) |
1256−| `notes check [--fix]` | diagnostics with source spans (§15.7); `--fix` rewrites the vault in canonical form (§4.2) and repairs filenames (§6.4) |
1257−| `notes merge [--dry-run]` | process sync-conflict files non-interactively; list leftovers |
1258−| `notes trash list \| restore ID` | trash management |
1412+| `fold [--keys normal\|vim\|helix]` | open the TUI; creates `root.md` if the directory is empty (§4.1.1); `--keys` picks the editor's keymap (§10.6) |
1413+| `fold capture [TEXT] [--to TARGET] [--task]` | append to the inbox (stdin if no TEXT) |
1414+| `fold check [--fix]` | diagnostics (§15.7); `--fix` rewrites the vault in canonical form (§4.2) and repairs filenames (§6.4) |
1415+| `fold merge [--dry-run]` | process sync-conflict files non-interactively; list leftovers |
1416+| `fold trash list \| restore ID` | trash management |
12591417
12601418That is the whole CLI: the entry points a shell, a hotkey, a cron job on the homelab, or a
12611419recovery session needs. Everything else is a TUI verb.
@@ -1272,17 +1430,19 @@ are property-triggered or manual only; `done:` records a date; conflict copies a
12721430blocks marked `conflict:`. Each was a knob nobody needs to agree on with a file. Two vaults that
12731431contain the same Markdown behave identically.
12741432
1275−There are no device settings either. The app reads no file outside the vault. What other
1276−tools keep in configuration is:
1433+There are no device settings either. The app reads no file outside the vault but the view it
1434+remembers (§10.1). What other tools keep in configuration is:
12771435
1278−- the vault: `--vault PATH`, else `$NOTES_VAULT`, else the nearest ancestor of `$PWD`
1279− containing `root.md`, else `~/notes`;
1280−- the trash location: `$XDG_STATE_HOME/notes/trash/` (§11.5);
1436+- the vault: `--vault PATH`, else `$FOLD_VAULT`, else the nearest ancestor of `$PWD`
1437+ containing `root.md`, else `~/fold`;
1438+- the trash location: `$XDG_STATE_HOME/fold/trash/` (§11.5);
12811439- the editor's keymap: `--keys`, else `$FOLD_KEYS` (`normal`, `vim`, `helix`), else
12821440 `normal` (§10.6);
1283−- everything visual: fixed (§10.1) or a session toggle (`zd`, `zr`).
1441+- everything visual: fixed, or a toggle the view remembers per vault (`zp`, `zw`, `zd`,
1442+ §10.1), or one for the session (`zr`).
12841443
1285−The only thing the app keeps outside the vault is the trash.
1444+The only things the app keeps outside the vault are the trash and the remembered view,
1445+both on the device and never synced.
12861446
12871447---
12881448
@@ -1292,12 +1452,15 @@ The only thing the app keeps outside the vault is the trash.
12921452
12931453```
12941454crates/
1295− notes-core/ parsing, tree, render/splice, index, store, merge — no TUI deps
1296− notes-tui/ ratatui application
1297− notes-cli/ clap binary; depends on both
1455+ fold-core/ parsing, tree, render/splice, index, store, merge, op log — no TUI deps
1456+ fold-tui/ ratatui application
1457+ fold-cli/ clap binary; depends on both
1458+ fold-ffi/ a session over fold-core for other languages, through UniFFI
1459+android/ the Android app, over fold-ffi
12981460```
12991461
1300−`notes-core` is the stable API a future Android client or Helix extension would use.
1462+`fold-core` is the API every front end uses; `fold-ffi` wraps it in a session that holds
1463+what the TUI holds between keys, for the Android app or a Helix extension.
13011464
13021465### 15.2 Core types (sketch)
13031466
@@ -1352,17 +1515,17 @@ exact spans, lossless round-tripping, and a small canonical subset — not a ful
13521515`pulldown-cmark` may be used later for inline styling in the reading pane only.
13531516
13541517Reading tolerance (setext headings, `*` bullets, tabs, `[X]`, `[-]`) is handled in the lexer and
1355−recorded per node as "non-canonical", which `notes check` reports and touching rewrites.
1518+recorded per node as "non-canonical", which `fold check` reports and touching rewrites.
13561519
13571520### 15.4 Dependencies
13581521
1359−`ratatui`, `crossterm`, `tui-textarea` (built-in editor), `notify` (watcher),
1522+`ratatui`, `crossterm`, `notify` (watcher), `signal-hook` (§10.6),
13601523`tree-sitter` + `tree-sitter-highlight` and the grammar crates of §10.9 (code highlighting), `jiff`
1361−(today's date), `clap`, `indexmap`, `nucleo` (fuzzy
1362−filter), `similar` (sequence alignment and diff3 for merge), `blake3`, `tempfile`,
1363−`unicode-normalization`, `unicode-width`, `directories` (XDG),
1364−`getrandom` (64-bit ids; the `@p` syllable tables are a 512-entry constant in `notes-core`),
1365−`ariadne` (diagnostics), `proptest` + `insta` (tests).
1524+(today's date), `clap`, `indexmap`, `similar` (line diff, to find a block's span after an
1525+outside change, §5.2), `blake3`, `unicode-normalization`, `unicode-width`, `directories` (XDG),
1526+`getrandom` (64-bit ids; the `@p` syllable tables are a 512-entry constant in `fold-core`),
1527+`uniffi` (`fold-ffi`), `tempfile`, `assert_cmd` + `predicates` (tests). The built-in editor
1528+(§10.6) and the filter's fuzzy match (§10.5) are fold's own.
13661529
13671530### 15.5 Performance targets
13681531
@@ -1378,7 +1541,8 @@ filter), `similar` (sequence alignment and diff3 for merge), `blake3`, `tempfile
13781541
13791542### 15.6 Testing
13801543
1381−Property-based, since the whole design rests on a few laws:
1544+The whole design rests on a few laws, and the tests check them on examples; property-based
1545+tests over generated trees are the natural next step:
13821546
13831547- `parse(render(t, 1, false))` equals `t` re-levelled, for all generated trees `t`.
13841548- `render(parse(s), 1, false) == s` for all canonical blocks `s`, frontmatter included.
@@ -1398,7 +1562,7 @@ of real-world Markdown (Obsidian, Logseq, FSNotes exports) that must parse witho
13981562
13991563### 15.7 Diagnostics
14001564
1401−`notes check` reports, with `ariadne`-rendered source spans: non-canonical syntax, empty
1565+`fold check` reports, file by file: non-canonical syntax, empty
14021566titles, titles containing `/`, broken, duplicate or cyclic embeds, embeds with children in the
14031567parent file, embeds whose form (bare or heading) does not match their block's spelling,
14041568heading embeds whose level is not the level of their position, text directly after a child
@@ -1425,7 +1589,7 @@ gets wrong. What other tools see:
14251589
14261590## 17. Non-goals for 1.0
14271591
1428−Encryption; CRDT / operation-log sync; a mobile client (the file format is its contract);
1592+Encryption; CRDT / operation-log sync;
14291593a query language, saved views and `--json` output (the ```` ```query ```` fence is reserved); attachment management beyond ignoring `assets/`; full Markdown
14301594rendering or WYSIWYG; plugins; task recurrence; spaced repetition; whiteboards; multiple
14311595vaults open at once; general transclusion; an external `$EDITOR` handoff; properties on non-blocks; export or lowering
@@ -1439,25 +1603,25 @@ task state; wiki-links between nodes and backlinks
14391603
14401604| # | Deliverable | Exit criterion |
14411605|---|---|---|
1442−| M0 | `notes-core`: parser incl. frontmatter, tree, render/splice, canonicalize, ids and names | round-trip laws pass under proptest; `notes check` works |
1606+| M0 | `fold-core`: parser incl. frontmatter, tree, render/splice, canonicalize, ids and names | round-trip laws pass under proptest; `fold check` works |
14431607| M1 | TUI: outline + reading panes, zoom, fold, move, promote/demote, refile, filter box, subtree editing (`e`), undo | daily-drivable on a single `root.md` |
14441608| M2 | Blocks and properties: `s`, property-triggered blocks, task blocks | a task can be given a due date in the editor and its file round-trips |
14451609| M3 | Tasks: toggling on any node, `done:` stamping, dates, clear-done, filter box | replaces a TaskPaper file |
14461610| M4 | Inbox and capture; watcher and reload | safe to edit in Helix and the TUI at once |
1447−| M5 | Sync: conflict detection, two-way node-level merge, `conflict:` blocks, conflict view, `notes merge` | phone + laptop + homelab over Syncthing for two weeks without data loss |
1611+| M5 | Sync: conflict detection, two-way node-level merge, `conflict:` blocks, conflict view, `fold merge` | phone + laptop + homelab over Syncthing for two weeks without data loss |
14481612| M6 | CLI polish, diagnostics, docs | 1.0 |
14491613
14501614---
14511615
14521616## 19. Decisions
14531617
1454−1. **Name.** Binary `notes` is a placeholder.
1618+1. **Name.** `fold`: the binary, the crate prefix and the state directory.
145516192. **Dated inbox, ISO titles, fixed.** Capture creates `## YYYY-MM-DD` under `Inbox`. There
14561620 is no format choice and no way to turn it off except `--to`. Days append oldest-first; a
14571621 long-lived inbox is a long section, which is what `s` (make block) and *clear done* are for.
145816223. **Filenames are named once.** A title change does not rename the file: renames sync as
14591623 delete-plus-create and race with edits on other devices. The id prefix identifies the
1460− file; the name is a hint that `notes check --fix` refreshes on request.
1624+ file; the name is a hint that `fold check --fix` refreshes on request.
146116254. **Id format.** Four-word `@p` names (64 bits): collision-free in practice, with three words
14621626 of slack for the filename prefix to grow into. Pronounceable, typeable from memory, and
14631627 self-validating against the syllable tables; nobody is expected to type all four.
@@ -1488,7 +1652,7 @@ task state; wiki-links between nodes and backlinks
1488165213. **Inbox by title.** Capture finds the inbox by the title `Inbox`. Renaming it means the
14891653 next capture creates a new one. A hidden marker property would be the only setting.
1490165414. **No config file.** Presentation choices are fixed or session toggles; a config format, a
1491− lookup order and a `notes config` command are not worth it. A real per-device need is
1655+ lookup order and a `fold config` command are not worth it. A real per-device need is
14921656 one environment variable: the editor's keymap is `$FOLD_KEYS` (§10.6), and a
14931657 colour-blind palette, say, would be another.
1494165815. **Windows.** Slugs and atomic writes are Windows-safe by design; nothing else is tested.
android/.gitignorenew file+7
@@ -0,0 +1,7 @@
1+.gradle/
2+build/
3+local.properties
4+.idea/
5+*.iml
6+.kotlin/
7+captures/
android/README.mdnew file+137
@@ -0,0 +1,137 @@
1+<p align="center"><img src="../docs/icon.png" width="96" height="96" alt="fold"></p>
2+
3+# fold for Android
4+
5+The same outline on your phone: fold for Android opens the vault the desktop app
6+uses — `root.md` and its block files, synced by Syncthing — and reads and writes
7+it with the same code. Every file it writes comes out of `fold-core`, the crate
8+the TUI and the CLI use, so a vault edited on the phone is byte for byte what
9+the desktop would have written.
10+
11+<p>
12+<img src="docs/outline.png" width="220" alt="The outline">
13+<img src="docs/zoomed.png" width="220" alt="Zoomed into a node">
14+<img src="docs/editor.png" width="220" alt="Editing a subtree">
15+<img src="docs/reading.png" width="220" alt="Reading a node">
16+</p>
17+
18+## What it does
19+
20+- **The outline**: every node a row, folds, `☐`/`☑` to check a task off,
21+ open/total task counts, due dates, a `▤` for nodes with their own file, the
22+ first line of each node's text under its title. Tap a row to zoom into it;
23+ the breadcrumb and Back zoom out. Folds, zoom and "hide done" are remembered
24+ per vault on the phone, never in the vault.
25+- **Long-press a row** (or *More…*) for everything you can do to a node — the
26+ TUI's node menu: edit, zoom, properties, new sibling or child, done/reopen,
27+ task on/off, heading ↔ bullet, give it its own file, move up/down,
28+ indent/outdent, move to…, archive, copy, paste, delete, resolve conflict.
29+- **Capture** drops a note (or a task) under today's day in the Inbox, as
30+ `fold capture` does. Share text from any app to fold, or long-press the
31+ launcher icon for *Capture*.
32+- **Edit** a node: its subtree's Markdown in a plain text box, every nested
33+ block inlined, as the TUI's editor shows it. Each line still belongs to its
34+ block, so each save writes each block to its own file; it saves when you
35+ pause and when you leave. A bar above the keyboard indents, outdents, and
36+ toggles bullets, checkboxes and headings.
37+- **Read** a node as a document, with light Markdown styling, checkboxes you
38+ can tick and links you can open.
39+- **Find** titles and text; **Move to…** picks a destination from a list.
40+- **Properties** in a form — a due date with a date picker; the first one
41+ gives the node its own file, as on the desktop.
42+- **Undo and redo** every change, from the top bar or the message after it.
43+ Undo refuses, writing nothing, when the file changed since (a sync).
44+- **Sync**: fold watches the vault folder, takes in what Syncthing brings, and
45+ says what changed (*↻ changed outside fold: Inbox (+1 item)*). Sync-conflict
46+ copies are merged node by node, and pairs it cannot merge are shown side by
47+ side to keep ours, theirs or both.
48+- **Check the vault** (`fold check`), *Canonicalize* (`fold check --fix`), and
49+ the trash, with restore.
50+
51+## Install
52+
53+Build it (below), then install `app/build/outputs/apk/debug/app-debug.apk` on
54+the phone — `adb install`, or copy the file over and open it.
55+
56+On first start, choose the folder Syncthing syncs your vault to (or keep a
57+vault on the phone only). fold reads and writes the vault's files in place, as
58+Syncthing itself does, so for a folder in shared storage it asks for
59+**all files access** (Android 11+; on Android 8–10, storage permission). It
60+writes nothing outside the vault but its own trash, which lives in the app's
61+private storage. That permission is why this is a build-it-yourself app and not
62+a Play Store one.
63+
64+An empty folder becomes a new vault with an Inbox. As on the desktop, give
65+Syncthing a `.stignore` with `*.fold-tmp` and `*.tmp`.
66+
67+## Build
68+
69+You need:
70+
71+- the Android SDK with platform 37 and NDK 28.2.13676358 (Android Studio
72+ installs both; or `sdkmanager "platforms;android-37.0" "ndk;28.2.13676358"`),
73+- a recent stable Rust with the Android targets and
74+ [cargo-ndk](https://github.com/bbqsrc/cargo-ndk):
75+
76+ ```sh
77+ rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
78+ cargo install cargo-ndk
79+ ```
80+
81+- JDK 17 or newer.
82+
83+Then, from `android/`:
84+
85+```sh
86+./gradlew assembleDebug # app/build/outputs/apk/debug/app-debug.apk
87+./gradlew installDebug # onto a connected phone
88+./gradlew assembleRelease # shrunk; sign it with your own key
89+```
90+
91+Gradle builds the Rust core itself: `cargoNdk` compiles `crates/fold-ffi` for
92+the ABIs in `gradle.properties` (`fold.abis`; drop `armeabi-v7a` or `x86_64`
93+to build faster), and `uniffiBindgen` builds it for this machine and generates
94+its Kotlin bindings. Opening `android/` in Android Studio works the same way.
95+
96+## How it fits together
97+
98+```
99+crates/fold-core the vault: parse, render, splice, every verb, merge
100+crates/fold-ffi a Session over fold-core for other languages (UniFFI):
101+ outline rows, reading lines, verbs with an op log,
102+ the editor's tagged lines, sync and conflicts
103+android/app Jetpack Compose UI; calls the Session on one worker
104+ thread through the generated Kotlin bindings
105+```
106+
107+`fold-ffi` holds what the TUI holds between keys — the undo log, the register,
108+the open editor — and follows the TUI's rules for them: verbs refuse to write
109+over a file changed on disk, an editor save is one undo step, a block cut in
110+the editor and pasted elsewhere moves with its file. Nodes are named by keys
111+([SPEC.md](../SPEC.md) §3.4), so a tap never acts on a node that moved under it.
112+
113+The editor is the one place the phone differs in kind: a text field reports
114+its whole text after each change, so `fold-ffi` finds the span that changed and
115+replays it through the same line rules as the TUI's editor, using the caret to
116+tell, say, Enter at the end of one line from Enter at the start of the next.
117+
118+## Tests
119+
120+```sh
121+cargo test -p fold-ffi # the session, the editor bridge (Rust)
122+./gradlew testDebugUnitTest # the Kotlin bindings over the host build of
123+ # the core, and the UI under Robolectric
124+./gradlew recordRoborazziDebug # the same, saving a screenshot of each screen
125+ # to app/build/outputs/roborazzi
126+```
127+
128+Robolectric downloads an Android framework jar on first use. Offline, or behind
129+a rate-limited mirror, put
130+`android-all-instrumented-15-robolectric-13954326-i7.jar` in a folder and pass
131+`-Probolectric.dependency.dir=/that/folder`.
132+
133+## Not there yet
134+
135+- No drag and drop: rows move with Move up/down, Indent/Outdent and Move to….
136+- No syntax highlighting in fenced code; it is shown in a monospace block.
137+- The editor has no Vim or Helix keys; it is the phone's keyboard.
android/app/build.gradle.ktsnew file+217
@@ -0,0 +1,217 @@
1+import java.io.ByteArrayOutputStream
2+import javax.inject.Inject
3+import org.gradle.process.ExecOperations
4+
5+plugins {
6+ alias(libs.plugins.android.application)
7+ alias(libs.plugins.kotlin.compose)
8+ alias(libs.plugins.roborazzi)
9+}
10+
11+// The Cargo workspace: the repository this Android project sits in.
12+val cargoWorkspace: Directory = rootProject.layout.projectDirectory.dir("..")
13+val minSdkVersion = 26
14+
15+android {
16+ namespace = "soy.iko.fold"
17+ compileSdk = 37
18+ ndkVersion = "28.2.13676358"
19+
20+ defaultConfig {
21+ applicationId = "soy.iko.fold"
22+ minSdk = minSdkVersion
23+ targetSdk = 36
24+ versionCode = 1
25+ versionName = "0.1.0"
26+ // only the ABIs the Rust core is built for: JNA ships more
27+ ndk {
28+ abiFilters += providers.gradleProperty("fold.abis").get().split(",").map(String::trim)
29+ }
30+ }
31+
32+ buildTypes {
33+ release {
34+ isMinifyEnabled = true
35+ isShrinkResources = true
36+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
37+ }
38+ }
39+
40+ buildFeatures {
41+ compose = true
42+ }
43+
44+ compileOptions {
45+ sourceCompatibility = JavaVersion.VERSION_17
46+ targetCompatibility = JavaVersion.VERSION_17
47+ }
48+
49+ testOptions {
50+ unitTests {
51+ isIncludeAndroidResources = true
52+ }
53+ }
54+}
55+
56+// ------------------------------------------------------------ the Rust core
57+
58+/** Builds `crates/fold-ffi` for each Android ABI with cargo-ndk. */
59+abstract class CargoNdk : DefaultTask() {
60+ @get:Inject abstract val exec: ExecOperations
61+
62+ @get:Input abstract val abis: ListProperty<String>
63+
64+ @get:Input abstract val platform: Property<Int>
65+
66+ @get:Internal abstract val workspace: DirectoryProperty
67+
68+ @get:Internal abstract val ndk: DirectoryProperty
69+
70+ @get:InputFiles
71+ @get:PathSensitive(PathSensitivity.RELATIVE)
72+ abstract val sources: ConfigurableFileCollection
73+
74+ @get:OutputDirectory abstract val outputDir: DirectoryProperty
75+
76+ @TaskAction
77+ fun build() {
78+ val out = outputDir.get().asFile
79+ out.deleteRecursively()
80+ out.mkdirs()
81+ exec.exec {
82+ workingDir = workspace.get().asFile
83+ environment("ANDROID_NDK_HOME", ndk.get().asFile.absolutePath)
84+ commandLine(
85+ listOf("cargo", "ndk") +
86+ abis.get().flatMap { listOf("-t", it) } +
87+ listOf("--platform", platform.get().toString(), "-o", out.absolutePath) +
88+ listOf("build", "--release", "--locked", "-p", "fold-ffi", "--lib"),
89+ )
90+ }
91+ }
92+}
93+
94+/**
95+ * Builds `crates/fold-ffi` for this machine and generates its Kotlin
96+ * bindings from it with uniffi-bindgen. The host library is also what the
97+ * JVM unit tests load.
98+ */
99+abstract class UniffiBindgen : DefaultTask() {
100+ @get:Inject abstract val exec: ExecOperations
101+
102+ @get:Internal abstract val workspace: DirectoryProperty
103+
104+ @get:InputFiles
105+ @get:PathSensitive(PathSensitivity.RELATIVE)
106+ abstract val sources: ConfigurableFileCollection
107+
108+ @get:OutputDirectory abstract val outputDir: DirectoryProperty
109+
110+ @get:OutputDirectory abstract val hostLibDir: DirectoryProperty
111+
112+ @TaskAction
113+ fun generate() {
114+ val root = workspace.get().asFile
115+ val os = System.getProperty("os.name").lowercase()
116+ val lib = when {
117+ os.contains("mac") -> "libfold_ffi.dylib"
118+ os.contains("windows") -> "fold_ffi.dll"
119+ else -> "libfold_ffi.so"
120+ }
121+ exec.exec {
122+ workingDir = root
123+ commandLine("cargo", "build", "--release", "--locked", "-p", "fold-ffi", "--lib")
124+ }
125+ val built = root.resolve("target/release/$lib")
126+ val hostDir = hostLibDir.get().asFile
127+ hostDir.mkdirs()
128+ built.copyTo(hostDir.resolve(lib), overwrite = true)
129+ val out = outputDir.get().asFile
130+ out.deleteRecursively()
131+ out.mkdirs()
132+ exec.exec {
133+ workingDir = root
134+ // its own target directory: the bindgen feature would rebuild
135+ // the library above with other features
136+ commandLine(
137+ "cargo", "run", "--release", "--locked", "--target-dir", "target/uniffi-bindgen",
138+ "-p", "fold-ffi", "--features", "bindgen", "--bin", "uniffi-bindgen", "--",
139+ "generate", "--library", hostDir.resolve(lib).absolutePath,
140+ "--language", "kotlin", "--out-dir", out.absolutePath, "--no-format",
141+ )
142+ }
143+ }
144+}
145+
146+val rustSources = files(
147+ cargoWorkspace.file("Cargo.toml"),
148+ cargoWorkspace.file("Cargo.lock"),
149+ cargoWorkspace.dir("crates/fold-core/src"),
150+ cargoWorkspace.dir("crates/fold-ffi/src"),
151+ cargoWorkspace.file("crates/fold-core/Cargo.toml"),
152+ cargoWorkspace.file("crates/fold-ffi/Cargo.toml"),
153+ cargoWorkspace.file("crates/fold-ffi/uniffi.toml"),
154+)
155+
156+val cargoNdk = tasks.register<CargoNdk>("cargoNdk") {
157+ description = "Builds the Rust core for the Android ABIs in fold.abis."
158+ abis.set(providers.gradleProperty("fold.abis").map { it.split(",").map(String::trim).filter(String::isNotEmpty) })
159+ platform.set(minSdkVersion)
160+ workspace.set(cargoWorkspace)
161+ ndk.set(androidComponents.sdkComponents.ndkDirectory)
162+ sources.from(rustSources)
163+ outputDir.set(layout.buildDirectory.dir("rust/jniLibs"))
164+}
165+
166+val uniffiBindgen = tasks.register<UniffiBindgen>("uniffiBindgen") {
167+ description = "Generates the Kotlin bindings of the Rust core."
168+ workspace.set(cargoWorkspace)
169+ sources.from(rustSources)
170+ outputDir.set(layout.buildDirectory.dir("generated/uniffi/kotlin"))
171+ hostLibDir.set(layout.buildDirectory.dir("rust/host"))
172+}
173+
174+androidComponents {
175+ onVariants { variant ->
176+ variant.sources.jniLibs?.addGeneratedSourceDirectory(cargoNdk, CargoNdk::outputDir)
177+ variant.sources.kotlin?.addGeneratedSourceDirectory(uniffiBindgen, UniffiBindgen::outputDir)
178+ }
179+}
180+
181+tasks.withType<Test>().configureEach {
182+ dependsOn(uniffiBindgen)
183+ // JNA finds the host build of the Rust core here
184+ systemProperty("jna.library.path", uniffiBindgen.get().hostLibDir.get().asFile.absolutePath)
185+ // Robolectric downloads its Android jars on first use; with
186+ // -Probolectric.dependency.dir=DIR it reads them from DIR instead
187+ providers.gradleProperty("robolectric.dependency.dir").orNull?.let {
188+ systemProperty("robolectric.offline", "true")
189+ systemProperty("robolectric.dependency.dir", it)
190+ }
191+}
192+
193+dependencies {
194+ implementation(libs.androidx.core)
195+ implementation(libs.androidx.activity.compose)
196+ implementation(libs.androidx.lifecycle.runtime.compose)
197+ implementation(libs.androidx.lifecycle.viewmodel.compose)
198+ implementation(platform(libs.compose.bom))
199+ implementation(libs.compose.ui)
200+ implementation(libs.compose.ui.tooling.preview)
201+ implementation(libs.compose.material3)
202+ implementation(libs.compose.material.icons)
203+ implementation(libs.kotlinx.coroutines.android)
204+ implementation("${libs.jna.get()}@aar")
205+ debugImplementation(libs.compose.ui.tooling)
206+
207+ // the JVM tests load the host build of the core through the desktop JNA
208+ testImplementation(libs.jna)
209+ testImplementation(libs.junit)
210+ testImplementation(libs.robolectric)
211+ testImplementation(libs.roborazzi)
212+ testImplementation(libs.roborazzi.compose)
213+ testImplementation(libs.androidx.test.core)
214+ testImplementation(platform(libs.compose.bom))
215+ testImplementation(libs.compose.ui.test.junit4)
216+ debugImplementation(libs.compose.ui.test.manifest)
217+}
android/app/proguard-rules.pronew file+6
@@ -0,0 +1,6 @@
1+# UniFFI's bindings call into the Rust core through JNA, which finds its
2+# structures and callbacks by reflection.
3+-keep class com.sun.jna.** { *; }
4+-keep class * implements com.sun.jna.** { *; }
5+-keep class soy.iko.fold.core.** { *; }
6+-dontwarn java.awt.**
android/app/src/main/AndroidManifest.xmlnew file+44
@@ -0,0 +1,44 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3+
4+ <!-- The vault is a folder Syncthing (or anything else) keeps in shared
5+ storage; fold reads and writes it as plain files, as on a desktop. -->
6+ <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
7+ <uses-permission
8+ android:name="android.permission.READ_EXTERNAL_STORAGE"
9+ android:maxSdkVersion="29" />
10+ <uses-permission
11+ android:name="android.permission.WRITE_EXTERNAL_STORAGE"
12+ android:maxSdkVersion="29" />
13+
14+ <application
15+ android:name=".FoldApp"
16+ android:allowBackup="false"
17+ android:icon="@mipmap/ic_launcher"
18+ android:label="@string/app_name"
19+ android:requestLegacyExternalStorage="true"
20+ android:roundIcon="@mipmap/ic_launcher"
21+ android:supportsRtl="true"
22+ android:theme="@style/Theme.Fold">
23+
24+ <activity
25+ android:name=".MainActivity"
26+ android:exported="true"
27+ android:launchMode="singleTask"
28+ android:windowSoftInputMode="adjustResize">
29+ <intent-filter>
30+ <action android:name="android.intent.action.MAIN" />
31+ <category android:name="android.intent.category.LAUNCHER" />
32+ </intent-filter>
33+ <!-- Share text to fold: it is captured to today's inbox (§7). -->
34+ <intent-filter android:label="@string/capture_to_fold">
35+ <action android:name="android.intent.action.SEND" />
36+ <category android:name="android.intent.category.DEFAULT" />
37+ <data android:mimeType="text/plain" />
38+ </intent-filter>
39+ <meta-data
40+ android:name="android.app.shortcuts"
41+ android:resource="@xml/shortcuts" />
42+ </activity>
43+ </application>
44+</manifest>
android/app/src/main/java/soy/iko/fold/FoldApp.ktnew file+14
@@ -0,0 +1,14 @@
1+package soy.iko.fold
2+
3+import android.app.Application
4+import android.system.Os
5+
6+class FoldApp : Application() {
7+ override fun onCreate() {
8+ super.onCreate()
9+ // fold keeps its trash under $XDG_STATE_HOME/fold/trash (§11.5): on
10+ // the phone that is the app's own storage, never the synced vault
11+ Os.setenv("HOME", filesDir.absolutePath, true)
12+ Os.setenv("XDG_STATE_HOME", filesDir.resolve("state").absolutePath, true)
13+ }
14+}
android/app/src/main/java/soy/iko/fold/FoldViewModel.ktnew file+732
@@ -0,0 +1,732 @@
1+package soy.iko.fold
2+
3+import android.app.Application
4+import androidx.compose.runtime.getValue
5+import androidx.compose.runtime.mutableStateListOf
6+import androidx.compose.runtime.mutableStateOf
7+import androidx.compose.runtime.setValue
8+import androidx.compose.ui.text.TextRange
9+import androidx.compose.ui.text.input.TextFieldValue
10+import androidx.lifecycle.AndroidViewModel
11+import androidx.lifecycle.viewModelScope
12+import java.io.File
13+import java.util.concurrent.Executors
14+import kotlinx.coroutines.Job
15+import kotlinx.coroutines.asCoroutineDispatcher
16+import kotlinx.coroutines.delay
17+import kotlinx.coroutines.flow.MutableSharedFlow
18+import kotlinx.coroutines.flow.SharedFlow
19+import kotlinx.coroutines.launch
20+import kotlinx.coroutines.withContext
21+import soy.iko.fold.core.ConflictPair
22+import soy.iko.fold.core.Hit
23+import soy.iko.fold.core.Keep
24+import soy.iko.fold.core.NodeInfo
25+import soy.iko.fold.core.OpResult
26+import soy.iko.fold.core.Outline
27+import soy.iko.fold.core.OutlineQuery
28+import soy.iko.fold.core.Property
29+import soy.iko.fold.core.ReadLine
30+import soy.iko.fold.core.Session
31+import soy.iko.fold.core.TrashEntry
32+import soy.iko.fold.core.UndoMark
33+
34+/** What is on screen above the outline. */
35+sealed interface Screen {
36+ data object Search : Screen
37+
38+ data class MoveTo(val key: String, val title: String) : Screen
39+
40+ /** The reading view of a node, or of the whole vault. */
41+ data class Reading(val key: String?) : Screen
42+
43+ data object Editor : Screen
44+
45+ data class Properties(val key: String) : Screen
46+
47+ data object Conflicts : Screen
48+
49+ data object Trash : Screen
50+
51+ data object Checks : Screen
52+
53+ data object Vaults : Screen
54+}
55+
56+/**
57+ * A line for the snackbar, and whether it offers to undo what it says: then
58+ * `mark` names that change, so the offer never undoes a later one.
59+ */
60+data class Message(val text: String, val action: Action? = null, val mark: UndoMark? = null) {
61+ enum class Action { Undo, Resolve }
62+}
63+
64+/** What the input sheet asks for. */
65+data class Input(
66+ val kind: Kind,
67+ /** The node it adds beside or under. */
68+ val key: String? = null,
69+ val text: String = "",
70+ val task: Boolean = false,
71+) {
72+ enum class Kind { Capture, Child, Sibling }
73+}
74+
75+/** The editor (§10.6): the text field's state, and what saving says. */
76+class EditorUi(val node: String, val title: String, text: String, generation: UInt) {
77+ var value by mutableStateOf(TextFieldValue(text))
78+
79+ /** The core's name for the text the field holds (`Session.editUpdate`). */
80+ var generation = generation
81+ var dirty by mutableStateOf(false)
82+ var canUndo by mutableStateOf(false)
83+ var canRedo by mutableStateOf(false)
84+
85+ /** *saved*, or why not. */
86+ var status by mutableStateOf<String?>(null)
87+ var failed by mutableStateOf(false)
88+}
89+
90+class FoldViewModel(app: Application) : AndroidViewModel(app) {
91+ private val prefs = Prefs(app)
92+
93+ /** Every call into the core runs here, one at a time, in order. */
94+ private val executor = Executors.newSingleThreadExecutor { Thread(it, "fold-core") }
95+ private val worker = executor.asCoroutineDispatcher()
96+
97+ private var session: Session? = null
98+ private var watcher: Watcher? = null
99+
100+ // ------------------------------------------------------------ state
101+
102+ var vault by mutableStateOf<String?>(null)
103+ private set
104+ var vaultError by mutableStateOf<String?>(null)
105+ private set
106+
107+ /** The vault that waits for storage access before it can open. */
108+ var waitingForAccess by mutableStateOf<String?>(null)
109+ private set
110+ var recent by mutableStateOf(prefs.recent)
111+ private set
112+
113+ var view by mutableStateOf(ViewState())
114+ private set
115+ var outline by mutableStateOf<Outline?>(null)
116+ private set
117+ var showPreview by mutableStateOf(prefs.showPreview)
118+ private set
119+
120+ /** A row to scroll to and flash: what a verb made or moved. */
121+ var highlight by mutableStateOf<String?>(null)
122+ private set
123+
124+ val screens = mutableStateListOf<Screen>()
125+ var menu by mutableStateOf<NodeInfo?>(null)
126+ private set
127+ var input by mutableStateOf<Input?>(null)
128+ private set
129+ var editor by mutableStateOf<EditorUi?>(null)
130+ private set
131+
132+ var searchQuery by mutableStateOf("")
133+ private set
134+ var hits by mutableStateOf<List<Hit>>(emptyList())
135+ private set
136+ var reading by mutableStateOf<List<ReadLine>>(emptyList())
137+ private set
138+ var props by mutableStateOf<List<Property>>(emptyList())
139+ private set
140+ var conflicts by mutableStateOf<List<ConflictPair>>(emptyList())
141+ private set
142+ var trash by mutableStateOf<List<TrashEntry>>(emptyList())
143+ private set
144+ var trashShown by mutableStateOf<Pair<String, String>?>(null)
145+ private set
146+ var diagnostics by mutableStateOf<List<String>?>(null)
147+ private set
148+ var source by mutableStateOf<String?>(null)
149+ private set
150+
151+ private val _messages = MutableSharedFlow<Message>(extraBufferCapacity = 16)
152+ val messages: SharedFlow<Message> = _messages
153+
154+ private var autosave: Job? = null
155+ private var refreshing: Job? = null
156+ private var searching: Job? = null
157+
158+ /** Text shared to fold before its vault was open. */
159+ private var pendingCapture: String? = null
160+
161+ init {
162+ prefs.vault?.let { open(it) }
163+ }
164+
165+ override fun onCleared() {
166+ watcher?.stop()
167+ val s = session
168+ session = null
169+ // after whatever is queued for it, as `closeVault`
170+ if (s != null) executor.execute { runCatching { s.editKeep(true) }; s.destroy() }
171+ executor.shutdown()
172+ }
173+
174+ private fun say(text: String, action: Message.Action? = null) {
175+ _messages.tryEmit(Message(text, action))
176+ }
177+
178+ /** Offer to undo what was just done: the change now on top of the op log. */
179+ private fun sayUndoable(text: String) {
180+ val o = outline
181+ val mark = o?.undo?.let { UndoMark(o.undoDepth, it) }
182+ _messages.tryEmit(Message(text, if (mark != null) Message.Action.Undo else null, mark))
183+ }
184+
185+ private suspend fun <T> core(f: (Session) -> T): T? {
186+ val s = session ?: return null
187+ return withContext(worker) { f(s) }
188+ }
189+
190+ // ------------------------------------------------------------ vaults
191+
192+ /** Open the vault at `path`, after the storage access it needs. */
193+ fun open(path: String) {
194+ val app = getApplication<Application>()
195+ if (!Storage.hasAccess(app, path)) {
196+ waitingForAccess = path
197+ return
198+ }
199+ waitingForAccess = null
200+ viewModelScope.launch {
201+ closeVault()
202+ val opened = withContext(worker) { runCatching { Session.open(path) } }
203+ opened.onFailure {
204+ vaultError = it.message ?: it.toString()
205+ vault = null
206+ }
207+ val s = opened.getOrNull() ?: return@launch
208+ session = s
209+ vault = path
210+ vaultError = null
211+ prefs.vault = path
212+ prefs.recent = (listOf(path) + prefs.recent.filter { it != path })
213+ recent = prefs.recent
214+ view = prefs.view(path)
215+ screens.clear()
216+ watcher = Watcher(File(path)) { refreshSoon() }.also { it.start() }
217+ // the startup scan: sync-conflict copies already there merge now
218+ refresh(force = true)
219+ pendingCapture?.let {
220+ pendingCapture = null
221+ input = Input(Input.Kind.Capture, text = it)
222+ }
223+ }
224+ }
225+
226+ /** The access fold asked for may have been granted meanwhile. */
227+ fun retryAccess() {
228+ waitingForAccess?.let { open(it) }
229+ }
230+
231+ fun usePrivateVault() = open(Storage.privateVault(getApplication()).absolutePath)
232+
233+ private suspend fun closeVault() {
234+ watcher?.stop()
235+ watcher = null
236+ refreshing?.cancel()
237+ searching?.cancel()
238+ autosave?.cancel()
239+ val s = session ?: return
240+ // no call starts on it from here; those already queued run first,
241+ // then it saves, letting go of a block cut and not pasted back
242+ // (§5.2), and goes
243+ session = null
244+ editor = null
245+ outline = null
246+ withContext(worker) {
247+ val said = s.editKeep(true)
248+ s.destroy()
249+ said
250+ }?.let { say(it) }
251+ }
252+
253+ fun forgetVault() {
254+ viewModelScope.launch {
255+ closeVault()
256+ prefs.vault = null
257+ vault = null
258+ screens.clear()
259+ }
260+ }
261+
262+ // ------------------------------------------------------------ outline
263+
264+ private fun query() = OutlineQuery(
265+ zoom = view.zoom,
266+ folded = view.folded.toList(),
267+ unfolded = view.unfolded.toList(),
268+ hideDone = view.hideDone,
269+ )
270+
271+ /** Ask the core for the outline again, and keep the view in step. */
272+ private suspend fun reload() {
273+ val o = core { it.outline(query()) } ?: return
274+ outline = o
275+ // a zoom whose node went away fell back to its deepest ancestor
276+ val zoom = o.zoom?.key
277+ if (zoom != view.zoom) applyView(view.copy(zoom = zoom))
278+ reloadOpenScreens()
279+ }
280+
281+ private suspend fun reloadOpenScreens() {
282+ for (screen in screens) {
283+ when (screen) {
284+ is Screen.Reading -> reading = core { it.reading(screen.key) } ?: emptyList()
285+ is Screen.Properties -> props = core { it.properties(screen.key) } ?: emptyList()
286+ Screen.Conflicts -> conflicts = core { it.conflicts() } ?: emptyList()
287+ Screen.Search -> if (searchQuery.isNotBlank()) hits = core { it.search(searchQuery) } ?: emptyList()
288+ else -> {}
289+ }
290+ }
291+ }
292+
293+ private fun applyView(v: ViewState) {
294+ view = v
295+ vault?.let { prefs.saveView(it, v) }
296+ }
297+
298+ fun refreshOutline() = viewModelScope.launch { reload() }
299+
300+ fun zoom(key: String?) {
301+ applyView(view.copy(zoom = key))
302+ highlight = null
303+ viewModelScope.launch { reload() }
304+ }
305+
306+ /** Back from a zoom: to its parent in the outline (§10.3 Backspace). */
307+ fun zoomOut(): Boolean {
308+ val crumbs = outline?.crumbs ?: return false
309+ if (view.zoom == null) return false
310+ zoom(crumbs.getOrNull(crumbs.size - 2)?.key)
311+ return true
312+ }
313+
314+ fun toggleFold(key: String, conflictCopy: Boolean) {
315+ applyView(
316+ if (conflictCopy) {
317+ view.copy(unfolded = view.unfolded.toggled(key))
318+ } else {
319+ view.copy(folded = view.folded.toggled(key))
320+ },
321+ )
322+ viewModelScope.launch { reload() }
323+ }
324+
325+ fun toggleHideDone() {
326+ applyView(view.copy(hideDone = !view.hideDone))
327+ say(if (view.hideDone) "done hidden" else "done shown")
328+ viewModelScope.launch { reload() }
329+ }
330+
331+ fun togglePreview() {
332+ showPreview = !showPreview
333+ prefs.showPreview = showPreview
334+ }
335+
336+ fun highlightShown() {
337+ highlight = null
338+ }
339+
340+ /** Show a node: zoom to its parent, so it is a row on screen, and flash it. */
341+ fun reveal(key: String, parent: String?) {
342+ applyView(view.copy(zoom = parent))
343+ screens.removeAll { it is Screen.Search || it is Screen.Reading }
344+ viewModelScope.launch {
345+ reload()
346+ highlight = key
347+ }
348+ }
349+
350+ private fun Set<String>.toggled(k: String) = if (k in this) this - k else this + k
351+
352+ // ------------------------------------------------------------ verbs
353+
354+ /**
355+ * Run a verb on `subject`. A zoom on the node follows it where it moved
356+ * (§10.3); the snackbar says what was done, with Undo.
357+ */
358+ private fun act(subject: String?, undoable: Boolean = true, op: (Session) -> OpResult) {
359+ viewModelScope.launch {
360+ val r = core(op) ?: return@launch
361+ settle(r)
362+ if (r.ok && subject != null && view.zoom == subject && r.node != null) {
363+ applyView(view.copy(zoom = r.node))
364+ }
365+ reload()
366+ if (r.ok) r.node?.let { highlight = it }
367+ if (r.ok && undoable) sayUndoable(r.message) else say(r.message)
368+ }
369+ }
370+
371+ /**
372+ * A verb run while the editor is open saved it first and re-rendered
373+ * it over what the verb wrote (§10.6): the text field takes the new
374+ * text, or the editor closes where its node is gone.
375+ */
376+ private fun settle(r: OpResult) {
377+ val ed = editor ?: return
378+ if (r.editorClosed) {
379+ editor = null
380+ screens.remove(Screen.Editor)
381+ say("the node being edited is gone")
382+ return
383+ }
384+ r.editorText?.let { text ->
385+ val sel = ed.value.selection
386+ ed.value = TextFieldValue(text, TextRange(sel.start.coerceAtMost(text.length), sel.end.coerceAtMost(text.length)))
387+ }
388+ ed.generation = r.editorGeneration
389+ }
390+
391+ fun openMenu(key: String) {
392+ viewModelScope.launch { menu = core { it.node(key) } }
393+ }
394+
395+ fun closeMenu() {
396+ menu = null
397+ }
398+
399+ fun toggleTask(key: String) = act(key) { it.toggleTask(key) }
400+
401+ fun toggleTaskness(key: String) = act(key) { it.toggleTaskness(key) }
402+
403+ fun toggleSpelling(key: String) = act(key) { it.toggleSpelling(key) }
404+
405+ fun makeBlock(key: String) = act(key) { it.makeBlock(key) }
406+
407+ fun moveSibling(key: String, down: Boolean) = act(key) { it.moveSibling(key, down) }
408+
409+ fun indent(key: String) = act(key) { it.indent(key) }
410+
411+ fun outdent(key: String) = act(key) { it.outdent(key) }
412+
413+ fun archive(key: String) = act(key) { it.archive(key) }
414+
415+ fun delete(key: String) = act(key) { it.delete(key) }
416+
417+ fun copy(key: String) = act(key, undoable = false) { it.copy(key) }
418+
419+ fun paste(key: String, after: Boolean) = act(null) { it.paste(key, after) }
420+
421+ fun clearDone() = act(null) { it.clearDone(view.zoom) }
422+
423+ fun canonicalize() = act(null) { it.canonicalize() }.also { checks() }
424+
425+ /** Undo the last change; from a message, only the change it named. */
426+ fun undo(mark: UndoMark? = null) = act(null, undoable = false) { it.undo(mark) }
427+
428+ fun redo() = act(null, undoable = false) { it.redo() }
429+
430+ // ------------------------------------------------------------ input
431+
432+ fun capture(text: String = "") {
433+ input = Input(Input.Kind.Capture, text = text)
434+ }
435+
436+ /** Text shared to fold, or the Capture shortcut: the capture sheet. */
437+ fun captureFromOutside(text: String) {
438+ if (session == null) pendingCapture = text else capture(text)
439+ }
440+
441+ fun addChild(key: String?) {
442+ input = Input(Input.Kind.Child, key = key)
443+ }
444+
445+ fun addSibling(key: String) {
446+ input = Input(Input.Kind.Sibling, key = key)
447+ }
448+
449+ fun dismissInput() {
450+ input = null
451+ }
452+
453+ fun submitInput(text: String, task: Boolean) {
454+ val req = input ?: return
455+ input = null
456+ when (req.kind) {
457+ Input.Kind.Capture -> act(null) { it.capture(text, task) }
458+ Input.Kind.Child -> act(null) { it.addNode(req.key, text, task, true) }
459+ Input.Kind.Sibling -> act(null) { it.addNode(req.key, text, task, false) }
460+ }
461+ }
462+
463+ // ------------------------------------------------------------ screens
464+
465+ fun push(screen: Screen) {
466+ screens.remove(screen)
467+ screens.add(screen)
468+ viewModelScope.launch {
469+ when (screen) {
470+ is Screen.Reading -> {
471+ source = null
472+ reading = core { it.reading(screen.key) } ?: emptyList()
473+ }
474+ is Screen.Properties -> props = core { it.properties(screen.key) } ?: emptyList()
475+ Screen.Conflicts -> conflicts = core { it.conflicts() } ?: emptyList()
476+ Screen.Trash -> {
477+ trashShown = null
478+ trash = core { it.trash() } ?: emptyList()
479+ }
480+ Screen.Checks -> checks()
481+ is Screen.MoveTo -> hits = core { it.targets("", screen.key) } ?: emptyList()
482+ Screen.Search -> {
483+ searchQuery = ""
484+ hits = emptyList()
485+ }
486+ else -> {}
487+ }
488+ }
489+ }
490+
491+ /** Back: the top screen goes; false when only the outline is left. */
492+ fun pop(): Boolean {
493+ val top = screens.lastOrNull() ?: return false
494+ if (top == Screen.Editor) {
495+ editDone()
496+ return true
497+ }
498+ screens.removeAt(screens.lastIndex)
499+ return true
500+ }
501+
502+ fun search(q: String) {
503+ searchQuery = q
504+ val moving = (screens.lastOrNull() as? Screen.MoveTo)?.key
505+ searching?.cancel()
506+ searching = viewModelScope.launch {
507+ delay(80)
508+ hits = if (moving != null) {
509+ core { it.targets(q, moving) } ?: emptyList()
510+ } else {
511+ core { it.search(q) } ?: emptyList()
512+ }
513+ }
514+ }
515+
516+ fun moveTo(key: String, dest: String) {
517+ screens.removeAll { it is Screen.MoveTo }
518+ act(key) { it.moveTo(key, dest) }
519+ }
520+
521+ fun showSource(key: String) {
522+ viewModelScope.launch { source = core { it.source(key) } }
523+ }
524+
525+ fun hideSource() {
526+ source = null
527+ }
528+
529+ fun setProperty(key: String, name: String, value: String) {
530+ viewModelScope.launch {
531+ val r = core { it.setProperty(key, name, value) } ?: return@launch
532+ settle(r)
533+ // a first property makes a block (§6.1): the form follows it
534+ if (r.ok && r.node != null && r.node != key) {
535+ val i = screens.indexOf(Screen.Properties(key))
536+ if (i >= 0) screens[i] = Screen.Properties(r.node!!)
537+ if (view.zoom == key) applyView(view.copy(zoom = r.node))
538+ }
539+ reload()
540+ if (r.ok) sayUndoable(r.message) else say(r.message)
541+ }
542+ }
543+
544+ fun removeProperty(key: String, name: String) = act(key) { it.removeProperty(key, name) }
545+
546+ fun resolve(theirs: String, keep: Keep) = act(null) { it.resolve(theirs, keep) }
547+
548+ fun checks() {
549+ viewModelScope.launch {
550+ diagnostics = null
551+ diagnostics = core { it.diagnostics() } ?: emptyList()
552+ }
553+ }
554+
555+ fun showTrash(name: String?) {
556+ if (name == null) {
557+ trashShown = null
558+ return
559+ }
560+ viewModelScope.launch {
561+ trashShown = core { it.trashText(name) }?.let { name to it }
562+ }
563+ }
564+
565+ fun restore(name: String) {
566+ viewModelScope.launch {
567+ val r = core { it.restore(name) } ?: return@launch
568+ settle(r)
569+ trash = core { it.trash() } ?: emptyList()
570+ trashShown = null
571+ reload()
572+ say(r.message)
573+ }
574+ }
575+
576+ // ------------------------------------------------------------ files
577+
578+ /** A write in the vault directory: look at the files once it settles. */
579+ private fun refreshSoon() {
580+ refreshing?.cancel()
581+ refreshing = viewModelScope.launch {
582+ delay(250)
583+ refresh(force = false)
584+ }
585+ }
586+
587+ /** The app is back in front: something may have synced meanwhile. */
588+ fun onResume() {
589+ if (session != null) viewModelScope.launch { refresh(force = false) }
590+ }
591+
592+ /** The app went to the background, and may never come back (§10.6). */
593+ fun onStop() {
594+ val s = session ?: return
595+ autosave?.cancel()
596+ viewModelScope.launch {
597+ // a block cut and not pasted back stays in transit: the user may
598+ // be off copying something, and back to paste it
599+ withContext(worker) { s.editKeep(false) }?.let { say(it) }
600+ }
601+ }
602+
603+ private suspend fun refresh(force: Boolean) {
604+ val r = core { it.refresh(force) } ?: return
605+ r.editorText?.let { text ->
606+ editor?.let { ed ->
607+ val sel = ed.value.selection
608+ ed.value = TextFieldValue(text, TextRange(sel.start.coerceAtMost(text.length), sel.end.coerceAtMost(text.length)))
609+ ed.generation = r.editorGeneration
610+ }
611+ }
612+ if (r.editorClosed) {
613+ editor = null
614+ screens.remove(Screen.Editor)
615+ say("the node being edited is gone")
616+ }
617+ if (r.changed || outline == null) reload()
618+ r.message?.let { say(it, if (r.raised > 0u) Message.Action.Resolve else null) }
619+ }
620+
621+ // ------------------------------------------------------------ editor
622+
623+ fun edit(key: String) {
624+ viewModelScope.launch {
625+ val v = withContext(worker) { session?.let { s -> runCatching { s.editOpen(key) } } } ?: return@launch
626+ v.onFailure { say(it.message ?: "can't edit") }
627+ val opened = v.getOrNull() ?: return@launch
628+ editor = EditorUi(opened.node, opened.title, opened.text, opened.generation)
629+ menu = null
630+ push(Screen.Editor)
631+ }
632+ }
633+
634+ fun editChange(input: TextFieldValue) {
635+ val ed = editor ?: return
636+ val value = withoutCarriageReturns(input)
637+ val changed = value.text != ed.value.text
638+ ed.value = value
639+ if (!changed) return
640+ val text = value.text
641+ val cursor = text.codePointCount(0, value.selection.end.coerceIn(0, text.length)).toUInt()
642+ val generation = ed.generation
643+ viewModelScope.launch {
644+ val st = core { it.editUpdate(text, cursor, generation) } ?: return@launch
645+ ed.dirty = st.dirty
646+ ed.canUndo = st.canUndo
647+ ed.canRedo = st.canRedo
648+ }
649+ // saves on its own after a pause in typing (§10.6)
650+ autosave?.cancel()
651+ autosave = viewModelScope.launch {
652+ delay(750)
653+ editSave()
654+ }
655+ }
656+
657+ fun editSave() {
658+ val ed = editor ?: return
659+ viewModelScope.launch {
660+ val saved = core { it.editSave() } ?: return@launch
661+ ed.dirty = saved.dirty
662+ ed.failed = !saved.ok
663+ ed.status = if (saved.ok) null else saved.message
664+ }
665+ }
666+
667+ /** Done: save and leave the editor; a refused save keeps it open. */
668+ fun editDone() {
669+ val ed = editor ?: return
670+ autosave?.cancel()
671+ viewModelScope.launch {
672+ val saved = core { it.editClose() } ?: return@launch
673+ if (!saved.ok) {
674+ ed.failed = true
675+ ed.status = saved.message
676+ say("${saved.message} — still editing; Revert drops the changes")
677+ return@launch
678+ }
679+ // its title, and so its key, may be what was edited
680+ if (view.zoom == ed.node && saved.node != null) applyView(view.copy(zoom = saved.node))
681+ editor = null
682+ screens.remove(Screen.Editor)
683+ reload()
684+ saved.node?.let { highlight = it }
685+ }
686+ }
687+
688+ fun editRevert() {
689+ autosave?.cancel()
690+ viewModelScope.launch {
691+ val r = core { it.editRevert() } ?: return@launch
692+ if (r.ok) {
693+ editor = null
694+ screens.remove(Screen.Editor)
695+ reload()
696+ }
697+ say(r.message)
698+ }
699+ }
700+
701+ fun editUndo(redo: Boolean = false) {
702+ val ed = editor ?: return
703+ viewModelScope.launch {
704+ val back = core { if (redo) it.editRedo() else it.editUndo() } ?: return@launch
705+ val text = back.text
706+ val at = ed.value.selection.end.coerceAtMost(text.length)
707+ ed.value = TextFieldValue(text, TextRange(at))
708+ ed.generation = back.generation
709+ val st = core { it.editUpdate(text, null, back.generation) } ?: return@launch
710+ ed.dirty = st.dirty
711+ ed.canUndo = st.canUndo
712+ ed.canRedo = st.canRedo
713+ autosave?.cancel()
714+ autosave = viewModelScope.launch {
715+ delay(750)
716+ editSave()
717+ }
718+ }
719+ }
720+}
721+
722+/** Text pasted from elsewhere may end its lines in `\r\n`; the files end them in `\n` (§4.2). */
723+internal fun withoutCarriageReturns(v: TextFieldValue): TextFieldValue {
724+ if ('\r' !in v.text) return v
725+ val text = v.text.replace("\r\n", "\n").replace('\r', '\n')
726+ // a lone \r became \n; a \r\n lost its \r
727+ fun mapped(i: Int): Int {
728+ val head = v.text.substring(0, i.coerceIn(0, v.text.length))
729+ return head.replace("\r\n", "\n").replace('\r', '\n').length
730+ }
731+ return TextFieldValue(text, TextRange(mapped(v.selection.start), mapped(v.selection.end)))
732+}
android/app/src/main/java/soy/iko/fold/MainActivity.ktnew file+59
@@ -0,0 +1,59 @@
1+package soy.iko.fold
2+
3+import android.content.Intent
4+import android.os.Bundle
5+import androidx.activity.ComponentActivity
6+import androidx.activity.compose.setContent
7+import androidx.activity.enableEdgeToEdge
8+import androidx.activity.viewModels
9+import soy.iko.fold.ui.FoldTheme
10+import soy.iko.fold.ui.Root
11+
12+class MainActivity : ComponentActivity() {
13+ private val vm: FoldViewModel by viewModels()
14+
15+ override fun onCreate(savedInstanceState: Bundle?) {
16+ enableEdgeToEdge()
17+ super.onCreate(savedInstanceState)
18+ if (savedInstanceState == null) handle(intent)
19+ setContent {
20+ FoldTheme { Root(vm) }
21+ }
22+ }
23+
24+ override fun onNewIntent(intent: Intent) {
25+ super.onNewIntent(intent)
26+ handle(intent)
27+ }
28+
29+ override fun onResume() {
30+ super.onResume()
31+ // a vault in shared storage may have been granted access meanwhile,
32+ // and Syncthing may have synced while fold was away
33+ vm.retryAccess()
34+ vm.onResume()
35+ }
36+
37+ override fun onStop() {
38+ super.onStop()
39+ vm.onStop()
40+ }
41+
42+ /** Text shared to fold, or the Capture shortcut: the capture sheet (§7). */
43+ private fun handle(intent: Intent?) {
44+ when (intent?.action) {
45+ Intent.ACTION_SEND -> {
46+ val text = listOfNotNull(
47+ intent.getStringExtra(Intent.EXTRA_SUBJECT),
48+ intent.getStringExtra(Intent.EXTRA_TEXT),
49+ ).joinToString("\n").trim()
50+ if (text.isNotEmpty()) vm.captureFromOutside(text)
51+ }
52+ ACTION_CAPTURE -> vm.captureFromOutside("")
53+ }
54+ }
55+
56+ companion object {
57+ const val ACTION_CAPTURE = "soy.iko.fold.CAPTURE"
58+ }
59+}
android/app/src/main/java/soy/iko/fold/Prefs.ktnew file+57
@@ -0,0 +1,57 @@
1+package soy.iko.fold
2+
3+import android.content.Context
4+import android.util.Base64
5+
6+/**
7+ * What fold remembers on this device (§10.1): which vault is open, and per
8+ * vault the view — folds, zoom, hidden done tasks. None of it is in the
9+ * vault; another device keeps its own.
10+ */
11+class Prefs(context: Context) {
12+ private val prefs = context.getSharedPreferences("fold", Context.MODE_PRIVATE)
13+
14+ var vault: String?
15+ get() = prefs.getString("vault", null)
16+ set(v) = prefs.edit().apply { if (v == null) remove("vault") else putString("vault", v) }.apply()
17+
18+ /** Vaults opened before, most recent first. */
19+ var recent: List<String>
20+ get() = prefs.getString("recent", null)?.split('\n')?.filter { it.isNotEmpty() } ?: emptyList()
21+ set(v) = prefs.edit().putString("recent", v.take(5).joinToString("\n")).apply()
22+
23+ var showPreview: Boolean
24+ get() = prefs.getBoolean("preview", true)
25+ set(v) = prefs.edit().putBoolean("preview", v).apply()
26+
27+ fun view(vault: String): ViewState {
28+ val p = "view:$vault:"
29+ return ViewState(
30+ zoom = prefs.getString(p + "zoom", null)?.let(::unpack),
31+ folded = prefs.getStringSet(p + "folded", emptySet())!!.map(::unpack).toSet(),
32+ hideDone = prefs.getBoolean(p + "hideDone", false),
33+ )
34+ }
35+
36+ fun saveView(vault: String, v: ViewState) {
37+ val p = "view:$vault:"
38+ prefs.edit()
39+ .putString(p + "zoom", v.zoom?.let(::pack))
40+ .putStringSet(p + "folded", v.folded.map(::pack).toSet())
41+ .putBoolean(p + "hideDone", v.hideDone)
42+ .apply()
43+ }
44+
45+ // node keys hold control characters, which the preferences' XML cannot
46+ private fun pack(key: String) = Base64.encodeToString(key.toByteArray(), Base64.NO_WRAP or Base64.URL_SAFE)
47+
48+ private fun unpack(s: String) = String(Base64.decode(s, Base64.NO_WRAP or Base64.URL_SAFE))
49+}
50+
51+data class ViewState(
52+ val zoom: String? = null,
53+ val folded: Set<String> = emptySet(),
54+ val hideDone: Boolean = false,
55+ /** Conflict copies unfolded this run: a copy starts folded (§10.1). */
56+ val unfolded: Set<String> = emptySet(),
57+)
android/app/src/main/java/soy/iko/fold/Storage.ktnew file+84
@@ -0,0 +1,84 @@
1+package soy.iko.fold
2+
3+import android.Manifest
4+import android.content.Context
5+import android.content.Intent
6+import android.content.pm.PackageManager
7+import android.net.Uri
8+import android.os.Build
9+import android.os.Environment
10+import android.provider.DocumentsContract
11+import android.provider.Settings
12+import java.io.File
13+
14+/**
15+ * Where vaults live on a phone, and the access fold needs to them.
16+ *
17+ * fold reads and writes a vault as a directory of plain files, exactly as on
18+ * a desktop (atomic renames, a directory listing, sync-conflict copies next
19+ * to their files), so it works on a real path: a folder in shared storage
20+ * that Syncthing also syncs, with "all files access", or a folder in the
21+ * app's own storage that needs no permission.
22+ */
23+object Storage {
24+ /** The app's own folder: always writable, never synced by others. */
25+ fun privateVault(context: Context): File = context.filesDir.resolve("vault")
26+
27+ /** Whether `path` lies in shared storage, where fold needs a permission. */
28+ fun isShared(context: Context, path: String): Boolean {
29+ val own = listOfNotNull(context.filesDir, context.getExternalFilesDir(null)?.parentFile)
30+ return own.none { path.startsWith(it.absolutePath) }
31+ }
32+
33+ fun hasAccess(context: Context, path: String): Boolean {
34+ if (!isShared(context, path)) return true
35+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
36+ Environment.isExternalStorageManager()
37+ } else {
38+ context.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) ==
39+ PackageManager.PERMISSION_GRANTED
40+ }
41+ }
42+
43+ /** Whether access is granted from the system's settings (Android 11+). */
44+ val accessFromSettings: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
45+
46+ /** The settings page that grants all files access to fold. */
47+ fun accessSettings(context: Context): Intent =
48+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
49+ Intent(
50+ Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
51+ Uri.parse("package:${context.packageName}"),
52+ )
53+ } else {
54+ Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${context.packageName}"))
55+ }
56+
57+ /**
58+ * The path of a folder picked with the system's folder picker: the
59+ * primary storage (`primary:Sync/fold`) or a removable volume
60+ * (`1A2B-3C4D:fold`). Null for anything that is not a local folder, such
61+ * as a cloud provider.
62+ */
63+ fun pathOfTree(uri: Uri): String? {
64+ if (uri.authority != "com.android.externalstorage.documents") return null
65+ val id = runCatching { DocumentsContract.getTreeDocumentId(uri) }.getOrNull() ?: return null
66+ val volume = id.substringBefore(':')
67+ val rest = id.substringAfter(':', "")
68+ val base = if (volume.equals("primary", ignoreCase = true)) {
69+ Environment.getExternalStorageDirectory().absolutePath
70+ } else {
71+ "/storage/$volume"
72+ }
73+ return if (rest.isEmpty()) base else "$base/$rest"
74+ }
75+
76+ /** A short way to show a vault's path. */
77+ fun shown(path: String): String {
78+ val home = Environment.getExternalStorageDirectory().absolutePath
79+ return when {
80+ path.startsWith("$home/") -> path.removePrefix("$home/")
81+ else -> path
82+ }
83+ }
84+}
android/app/src/main/java/soy/iko/fold/Watcher.ktnew file+37
@@ -0,0 +1,37 @@
1+package soy.iko.fold
2+
3+import android.os.Build
4+import android.os.FileObserver
5+import java.io.File
6+
7+/**
8+ * Tells when a file in the vault was written by someone else — Syncthing,
9+ * a text editor (§11.2). The vault is flat, so watching its directory is
10+ * enough. Only writes count; fold's own temp files and dotfiles are noise.
11+ */
12+class Watcher(dir: File, private val onChange: () -> Unit) {
13+ private val mask = FileObserver.CLOSE_WRITE or FileObserver.MOVED_TO or FileObserver.MOVED_FROM or
14+ FileObserver.DELETE or FileObserver.CREATE or FileObserver.MODIFY
15+
16+ private val observer: FileObserver =
17+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
18+ object : FileObserver(dir, mask) {
19+ override fun onEvent(event: Int, path: String?) = seen(path)
20+ }
21+ } else {
22+ @Suppress("DEPRECATION")
23+ object : FileObserver(dir.absolutePath, mask) {
24+ override fun onEvent(event: Int, path: String?) = seen(path)
25+ }
26+ }
27+
28+ private fun seen(name: String?) {
29+ if (name == null || name.startsWith('.') || name.endsWith(".tmp")) return
30+ if (!name.endsWith(".md")) return
31+ onChange()
32+ }
33+
34+ fun start() = observer.startWatching()
35+
36+ fun stop() = observer.stopWatching()
37+}
android/app/src/main/java/soy/iko/fold/ui/EditorScreen.ktnew file+209
@@ -0,0 +1,209 @@
1+@file:OptIn(ExperimentalMaterial3Api::class)
2+
3+package soy.iko.fold.ui
4+
5+import androidx.compose.foundation.background
6+import androidx.compose.foundation.horizontalScroll
7+import androidx.compose.foundation.layout.Box
8+import androidx.compose.foundation.layout.Column
9+import androidx.compose.foundation.layout.Row
10+import androidx.compose.foundation.layout.WindowInsets
11+import androidx.compose.foundation.layout.fillMaxSize
12+import androidx.compose.foundation.layout.fillMaxWidth
13+import androidx.compose.foundation.layout.imePadding
14+import androidx.compose.foundation.layout.navigationBarsPadding
15+import androidx.compose.foundation.layout.padding
16+import androidx.compose.foundation.rememberScrollState
17+import androidx.compose.foundation.text.BasicTextField
18+import androidx.compose.foundation.text.KeyboardOptions
19+import androidx.compose.foundation.verticalScroll
20+import androidx.compose.material.icons.Icons
21+import androidx.compose.material.icons.automirrored.filled.FormatIndentDecrease
22+import androidx.compose.material.icons.automirrored.filled.FormatIndentIncrease
23+import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
24+import androidx.compose.material.icons.automirrored.filled.Redo
25+import androidx.compose.material.icons.automirrored.filled.Undo
26+import androidx.compose.material.icons.filled.Check
27+import androidx.compose.material.icons.filled.CheckBoxOutlineBlank
28+import androidx.compose.material.icons.filled.MoreVert
29+import androidx.compose.material.icons.filled.Tag
30+import androidx.compose.material3.DropdownMenu
31+import androidx.compose.material3.ExperimentalMaterial3Api
32+import androidx.compose.material3.HorizontalDivider
33+import androidx.compose.material3.Icon
34+import androidx.compose.material3.IconButton
35+import androidx.compose.material3.MaterialTheme
36+import androidx.compose.material3.Scaffold
37+import androidx.compose.material3.Surface
38+import androidx.compose.material3.Text
39+import androidx.compose.material3.TopAppBar
40+import androidx.compose.runtime.Composable
41+import androidx.compose.runtime.LaunchedEffect
42+import androidx.compose.runtime.getValue
43+import androidx.compose.runtime.mutableStateOf
44+import androidx.compose.runtime.remember
45+import androidx.compose.runtime.setValue
46+import androidx.compose.ui.Modifier
47+import androidx.compose.ui.focus.FocusRequester
48+import androidx.compose.ui.focus.focusRequester
49+import androidx.compose.ui.graphics.SolidColor
50+import androidx.compose.ui.text.TextRange
51+import androidx.compose.ui.text.font.FontFamily
52+import androidx.compose.ui.text.input.KeyboardCapitalization
53+import androidx.compose.ui.text.input.TextFieldValue
54+import androidx.compose.ui.text.style.TextOverflow
55+import androidx.compose.ui.unit.dp
56+import androidx.compose.ui.unit.sp
57+import soy.iko.fold.EditorUi
58+import soy.iko.fold.FoldViewModel
59+
60+/**
61+ * The editor (§10.6): the selected subtree's Markdown, every nested block
62+ * inlined, in a plain text box. It saves on its own after a pause, and on
63+ * Done; nothing in it shows a file, an id or frontmatter.
64+ */
65+@Composable
66+fun EditorScreen(vm: FoldViewModel, ed: EditorUi) {
67+ val c = LocalFoldColors.current
68+ var more by remember { mutableStateOf(false) }
69+ val focus = remember { FocusRequester() }
70+ LaunchedEffect(ed) { focus.requestFocus() }
71+ Scaffold(
72+ // the bottom is the keyboard's, or the tool bar's over the navigation bar
73+ contentWindowInsets = WindowInsets(0, 0, 0, 0),
74+ topBar = {
75+ TopAppBar(
76+ navigationIcon = {
77+ IconButton(onClick = { vm.editDone() }) { Icon(Icons.Filled.Check, "Done") }
78+ },
79+ title = {
80+ Column {
81+ Text(ed.title.ifEmpty { "(untitled)" }, maxLines = 1, overflow = TextOverflow.Ellipsis)
82+ Text(
83+ when {
84+ ed.failed -> "not saved"
85+ ed.dirty -> "editing…"
86+ else -> "saved"
87+ },
88+ style = MaterialTheme.typography.labelSmall,
89+ color = if (ed.failed) c.warn else c.dim,
90+ )
91+ }
92+ },
93+ actions = {
94+ IconButton(onClick = { vm.editUndo() }, enabled = ed.canUndo) { Icon(Icons.AutoMirrored.Filled.Undo, "Undo") }
95+ IconButton(onClick = { vm.editUndo(redo = true) }, enabled = ed.canRedo) { Icon(Icons.AutoMirrored.Filled.Redo, "Redo") }
96+ Box {
97+ IconButton(onClick = { more = true }) { Icon(Icons.Filled.MoreVert, "More") }
98+ DropdownMenu(expanded = more, onDismissRequest = { more = false }) {
99+ MenuItem("Save now", { more = false }) { vm.editSave() }
100+ MenuItem("Revert", { more = false }) { vm.editRevert() }
101+ }
102+ }
103+ },
104+ )
105+ },
106+ ) { padding ->
107+ Column(Modifier.padding(padding).fillMaxSize().imePadding()) {
108+ ed.status?.let {
109+ Surface(color = c.warn.copy(alpha = 0.14f), modifier = Modifier.fillMaxWidth()) {
110+ Text(it, color = c.warn, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp))
111+ }
112+ }
113+ Box(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState())) {
114+ BasicTextField(
115+ value = ed.value,
116+ onValueChange = { vm.editChange(it) },
117+ textStyle = MaterialTheme.typography.bodyLarge.copy(
118+ fontFamily = FontFamily.Monospace,
119+ fontSize = 15.sp,
120+ lineHeight = 22.sp,
121+ color = MaterialTheme.colorScheme.onSurface,
122+ ),
123+ cursorBrush = SolidColor(c.accent),
124+ keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
125+ modifier = Modifier
126+ .fillMaxWidth()
127+ .padding(horizontal = 16.dp, vertical = 12.dp)
128+ .focusRequester(focus),
129+ )
130+ }
131+ HorizontalDivider()
132+ LineTools(ed.value) { vm.editChange(it) }
133+ }
134+ }
135+}
136+
137+/** Buttons for what a phone keyboard makes slow: indenting, bullets, tasks. */
138+@Composable
139+private fun LineTools(value: TextFieldValue, change: (TextFieldValue) -> Unit) {
140+ Row(
141+ Modifier
142+ .fillMaxWidth()
143+ .background(MaterialTheme.colorScheme.surfaceContainer)
144+ .navigationBarsPadding()
145+ .horizontalScroll(rememberScrollState()),
146+ ) {
147+ IconButton(onClick = { change(indent(value, 1)) }) { Icon(Icons.AutoMirrored.Filled.FormatIndentIncrease, "Indent line") }
148+ IconButton(onClick = { change(indent(value, -1)) }) { Icon(Icons.AutoMirrored.Filled.FormatIndentDecrease, "Outdent line") }
149+ IconButton(onClick = { change(bullet(value)) }) { Icon(Icons.AutoMirrored.Filled.FormatListBulleted, "Bullet") }
150+ IconButton(onClick = { change(checkbox(value)) }) { Icon(Icons.Filled.CheckBoxOutlineBlank, "Checkbox") }
151+ IconButton(onClick = { change(heading(value)) }) { Icon(Icons.Filled.Tag, "Heading") }
152+ }
153+}
154+
155+/** The current line's bounds in the text. */
156+private fun lineAt(v: TextFieldValue): IntRange {
157+ val at = v.selection.start.coerceIn(0, v.text.length)
158+ val start = v.text.lastIndexOf('\n', at - 1) + 1
159+ val end = v.text.indexOf('\n', at).let { if (it < 0) v.text.length else it }
160+ return start until end
161+}
162+
163+/** Replace the current line with `f(line)`, keeping the caret in place. */
164+private fun editLine(v: TextFieldValue, f: (String) -> String): TextFieldValue {
165+ val r = lineAt(v)
166+ val line = v.text.substring(r.first, r.last + 1)
167+ val new = f(line)
168+ val text = v.text.substring(0, r.first) + new + v.text.substring(r.last + 1)
169+ val shift = new.length - line.length
170+ val caret = (v.selection.start + shift).coerceIn(r.first, r.first + new.length)
171+ return TextFieldValue(text, TextRange(caret))
172+}
173+
174+/** Two spaces per level, as the format nests (§4.2). */
175+private fun indent(v: TextFieldValue, dir: Int) = editLine(v) { line ->
176+ if (dir > 0) " $line" else line.removePrefix(if (line.startsWith(" ")) " " else " ")
177+}
178+
179+private val marker = Regex("^( *)(#+ |[-*+] )?(\\[[ xX]] )?")
180+
181+private fun bullet(v: TextFieldValue) = editLine(v) { line ->
182+ val m = marker.find(line)!!
183+ val (sp, mark, box) = m.destructured
184+ val rest = line.substring(m.value.length)
185+ // a bullet goes, its checkbox with it; anything else becomes one
186+ if (mark.isNotEmpty() && !mark.startsWith("#")) "$sp$rest" else "$sp- $box$rest"
187+}
188+
189+private fun checkbox(v: TextFieldValue) = editLine(v) { line ->
190+ val m = marker.find(line)!!
191+ val (sp, mark, box) = m.destructured
192+ val rest = line.substring(m.value.length)
193+ when {
194+ box.isNotEmpty() -> "$sp$mark$rest"
195+ mark.isEmpty() -> "$sp- [ ] $rest"
196+ else -> "$sp$mark[ ] $rest"
197+ }
198+}
199+
200+private fun heading(v: TextFieldValue) = editLine(v) { line ->
201+ val m = marker.find(line)!!
202+ val (sp, mark, box) = m.destructured
203+ val rest = line.substring(m.value.length)
204+ when {
205+ mark.startsWith("#") && mark.length < 7 -> "$sp#$mark$box$rest"
206+ mark.startsWith("#") -> "$sp$box$rest"
207+ else -> "$sp# $box$rest"
208+ }
209+}
android/app/src/main/java/soy/iko/fold/ui/Markdown.ktnew file+188
@@ -0,0 +1,188 @@
1+package soy.iko.fold.ui
2+
3+import androidx.compose.foundation.background
4+import androidx.compose.foundation.layout.Box
5+import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.IntrinsicSize
7+import androidx.compose.foundation.layout.fillMaxHeight
8+import androidx.compose.foundation.layout.Row
9+import androidx.compose.foundation.layout.fillMaxWidth
10+import androidx.compose.foundation.layout.padding
11+import androidx.compose.foundation.layout.width
12+import androidx.compose.foundation.layout.height
13+import androidx.compose.foundation.horizontalScroll
14+import androidx.compose.foundation.rememberScrollState
15+import androidx.compose.material3.MaterialTheme
16+import androidx.compose.material3.Text
17+import androidx.compose.runtime.Composable
18+import androidx.compose.ui.Modifier
19+import androidx.compose.ui.text.AnnotatedString
20+import androidx.compose.ui.text.LinkAnnotation
21+import androidx.compose.ui.text.SpanStyle
22+import androidx.compose.ui.text.TextLinkStyles
23+import androidx.compose.ui.text.TextStyle
24+import androidx.compose.ui.text.buildAnnotatedString
25+import androidx.compose.ui.text.font.FontFamily
26+import androidx.compose.ui.text.font.FontStyle
27+import androidx.compose.ui.text.font.FontWeight
28+import androidx.compose.ui.text.style.TextDecoration
29+import androidx.compose.ui.text.withLink
30+import androidx.compose.ui.text.withStyle
31+import androidx.compose.ui.unit.dp
32+
33+/**
34+ * Light Markdown styling (§10.9): line-based, not a full renderer. Code,
35+ * bold, emphasis and links are styled, and their markers stay on screen,
36+ * dimmed, so what is read is what is in the file.
37+ */
38+fun inline(text: String, c: FoldColors): AnnotatedString = buildAnnotatedString {
39+ var i = 0
40+ val n = text.length
41+ val dim = SpanStyle(color = c.dim)
42+ fun plain(s: String) = append(s)
43+ while (i < n) {
44+ val ch = text[i]
45+ when {
46+ // `code`
47+ ch == '`' -> {
48+ val end = text.indexOf('`', i + 1)
49+ if (end < 0) {
50+ plain(text.substring(i)); i = n
51+ } else {
52+ withStyle(dim) { append('`') }
53+ withStyle(SpanStyle(fontFamily = FontFamily.Monospace, color = c.code, background = c.codeBackground)) {
54+ append(text.substring(i + 1, end))
55+ }
56+ withStyle(dim) { append('`') }
57+ i = end + 1
58+ }
59+ }
60+ // **bold** or __bold__
61+ (ch == '*' || ch == '_') && text.startsWith("$ch$ch", i) -> {
62+ val mark = "$ch$ch"
63+ val end = text.indexOf(mark, i + 2)
64+ if (end <= i + 2) {
65+ plain(mark); i += 2
66+ } else {
67+ withStyle(dim) { append(mark) }
68+ withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(inline(text.substring(i + 2, end), c)) }
69+ withStyle(dim) { append(mark) }
70+ i = end + 2
71+ }
72+ }
73+ // *em* or _em_ (an underscore inside a word is a word)
74+ (ch == '*' || (ch == '_' && (i == 0 || !text[i - 1].isLetterOrDigit()))) &&
75+ i + 1 < n && !text[i + 1].isWhitespace() -> {
76+ val end = text.indexOf(ch, i + 1)
77+ if (end < 0 || text[end - 1].isWhitespace()) {
78+ plain(ch.toString()); i++
79+ } else {
80+ withStyle(dim) { append(ch) }
81+ withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { append(text.substring(i + 1, end)) }
82+ withStyle(dim) { append(ch) }
83+ i = end + 1
84+ }
85+ }
86+ // [text](url)
87+ ch == '[' -> {
88+ val close = text.indexOf("](", i + 1)
89+ val end = if (close > 0) text.indexOf(')', close + 2) else -1
90+ if (close < 0 || end < 0) {
91+ plain("["); i++
92+ } else {
93+ val label = text.substring(i + 1, close)
94+ val url = text.substring(close + 2, end)
95+ withStyle(dim) { append('[') }
96+ withLink(LinkAnnotation.Url(url, TextLinkStyles(SpanStyle(color = c.link, textDecoration = TextDecoration.Underline)))) {
97+ append(label)
98+ }
99+ withStyle(dim) { append("]($url)") }
100+ i = end + 1
101+ }
102+ }
103+ // a bare link
104+ text.startsWith("https://", i) || text.startsWith("http://", i) -> {
105+ var end = i
106+ while (end < n && !text[end].isWhitespace() && text[end] != ')' && text[end] != '>') end++
107+ while (end > i && text[end - 1] in ".,;:!?") end--
108+ val url = text.substring(i, end)
109+ withLink(LinkAnnotation.Url(url, TextLinkStyles(SpanStyle(color = c.link, textDecoration = TextDecoration.Underline)))) {
110+ append(url)
111+ }
112+ i = end
113+ }
114+ else -> {
115+ plain(ch.toString()); i++
116+ }
117+ }
118+ }
119+}
120+
121+/** One kind of text line, as the reading view styles it. */
122+private enum class Look { Text, Fence, Code, Quote, Rule }
123+
124+/**
125+ * A run of body text: paragraphs, quotes, fenced code on a tinted ground
126+ * with its fence dimmed and its info string in the accent colour.
127+ */
128+@Composable
129+fun MarkdownBody(lines: List<String>, modifier: Modifier = Modifier, style: TextStyle = MaterialTheme.typography.bodyLarge) {
130+ val c = LocalFoldColors.current
131+ Column(modifier) {
132+ var fence: String? = null
133+ for (line in lines) {
134+ val trimmed = line.trimStart()
135+ val look = when {
136+ fence != null && trimmed.startsWith(fence) -> {
137+ fence = null
138+ Look.Fence
139+ }
140+ fence != null -> Look.Code
141+ trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
142+ fence = trimmed.takeWhile { it == trimmed[0] }
143+ Look.Fence
144+ }
145+ trimmed.startsWith(">") -> Look.Quote
146+ trimmed.matches(Regex("([-*_])( ?\\1){2,}")) -> Look.Rule
147+ else -> Look.Text
148+ }
149+ BodyLine(line, look, style, c)
150+ }
151+ }
152+}
153+
154+@Composable
155+private fun BodyLine(line: String, look: Look, style: TextStyle, c: FoldColors) {
156+ when (look) {
157+ Look.Text -> if (line.isBlank()) {
158+ Box(Modifier.height(8.dp))
159+ } else {
160+ Text(inline(line, c), style = style)
161+ }
162+ Look.Fence -> {
163+ val t = line.trimStart()
164+ val marks = t.takeWhile { it == '`' || it == '~' }
165+ Text(
166+ buildAnnotatedString {
167+ withStyle(SpanStyle(color = c.dim)) { append(line.substring(0, line.length - t.length + marks.length)) }
168+ withStyle(SpanStyle(color = c.accent)) { append(t.substring(marks.length)) }
169+ },
170+ style = style.copy(fontFamily = FontFamily.Monospace),
171+ modifier = Modifier.fillMaxWidth().background(c.codeBackground).padding(horizontal = 6.dp),
172+ )
173+ }
174+ Look.Code -> Box(Modifier.fillMaxWidth().background(c.codeBackground).horizontalScroll(rememberScrollState())) {
175+ Text(
176+ line.ifEmpty { " " },
177+ style = style.copy(fontFamily = FontFamily.Monospace, color = c.code),
178+ softWrap = false,
179+ modifier = Modifier.padding(horizontal = 6.dp),
180+ )
181+ }
182+ Look.Quote -> Row(Modifier.height(IntrinsicSize.Min)) {
183+ Box(Modifier.width(3.dp).fillMaxHeight().background(c.quote))
184+ Text(inline(line.trimStart().removePrefix(">").trimStart(), c), style = style.copy(color = c.quote), modifier = Modifier.padding(start = 8.dp))
185+ }
186+ Look.Rule -> Box(Modifier.fillMaxWidth().padding(vertical = 8.dp).height(1.dp).background(c.guide))
187+ }
188+}
android/app/src/main/java/soy/iko/fold/ui/OutlineScreen.ktnew file+492
@@ -0,0 +1,492 @@
1+@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
2+
3+package soy.iko.fold.ui
4+
5+import androidx.compose.animation.animateColorAsState
6+import androidx.compose.foundation.ExperimentalFoundationApi
7+import androidx.compose.foundation.background
8+import androidx.compose.foundation.clickable
9+import androidx.compose.foundation.combinedClickable
10+import androidx.compose.foundation.layout.Arrangement
11+import androidx.compose.foundation.layout.Box
12+import androidx.compose.foundation.layout.Column
13+import androidx.compose.foundation.layout.ExperimentalLayoutApi
14+import androidx.compose.foundation.layout.FlowRow
15+import androidx.compose.foundation.layout.PaddingValues
16+import androidx.compose.foundation.layout.Row
17+import androidx.compose.foundation.layout.Spacer
18+import androidx.compose.foundation.layout.fillMaxSize
19+import androidx.compose.foundation.layout.fillMaxWidth
20+import androidx.compose.foundation.layout.height
21+import androidx.compose.foundation.layout.heightIn
22+import androidx.compose.foundation.layout.padding
23+import androidx.compose.foundation.layout.size
24+import androidx.compose.foundation.layout.width
25+import androidx.compose.foundation.lazy.LazyColumn
26+import androidx.compose.foundation.lazy.items
27+import androidx.compose.foundation.lazy.LazyListState
28+import androidx.compose.foundation.shape.RoundedCornerShape
29+import androidx.compose.material.icons.Icons
30+import androidx.compose.material.icons.automirrored.filled.ArrowBack
31+import androidx.compose.material.icons.automirrored.filled.MenuBook
32+import androidx.compose.material.icons.automirrored.filled.Redo
33+import androidx.compose.material.icons.automirrored.filled.Undo
34+import androidx.compose.material.icons.filled.Add
35+import androidx.compose.material.icons.filled.CheckBox
36+import androidx.compose.material.icons.filled.CheckBoxOutlineBlank
37+import androidx.compose.material.icons.filled.ChevronRight
38+import androidx.compose.material.icons.filled.Description
39+import androidx.compose.material.icons.filled.Edit
40+import androidx.compose.material.icons.filled.ExpandMore
41+import androidx.compose.material.icons.filled.MoreVert
42+import androidx.compose.material.icons.filled.Search
43+import androidx.compose.material.icons.filled.Tune
44+import androidx.compose.material.icons.filled.Warning
45+import androidx.compose.material3.AssistChip
46+import androidx.compose.material3.DropdownMenu
47+import androidx.compose.material3.DropdownMenuItem
48+import androidx.compose.material3.ExperimentalMaterial3Api
49+import androidx.compose.material3.ExtendedFloatingActionButton
50+import androidx.compose.material3.HorizontalDivider
51+import androidx.compose.material3.Icon
52+import androidx.compose.material3.IconButton
53+import androidx.compose.material3.MaterialTheme
54+import androidx.compose.material3.Scaffold
55+import androidx.compose.material3.Surface
56+import androidx.compose.material3.Text
57+import androidx.compose.material3.TextButton
58+import androidx.compose.material3.TopAppBar
59+import androidx.compose.material3.TopAppBarDefaults
60+import androidx.compose.runtime.Composable
61+import androidx.compose.runtime.LaunchedEffect
62+import androidx.compose.runtime.getValue
63+import androidx.compose.runtime.mutableStateOf
64+import androidx.compose.runtime.remember
65+import androidx.compose.runtime.setValue
66+import androidx.compose.ui.Alignment
67+import androidx.compose.ui.Modifier
68+import androidx.compose.ui.draw.drawBehind
69+import androidx.compose.ui.geometry.Offset
70+import androidx.compose.ui.graphics.Color
71+import androidx.compose.ui.input.nestedscroll.nestedScroll
72+import androidx.compose.ui.platform.LocalDensity
73+import androidx.compose.ui.semantics.contentDescription
74+import androidx.compose.ui.semantics.semantics
75+import androidx.compose.ui.text.SpanStyle
76+import androidx.compose.ui.text.buildAnnotatedString
77+import androidx.compose.ui.text.font.FontStyle
78+import androidx.compose.ui.text.font.FontWeight
79+import androidx.compose.ui.text.style.TextDecoration
80+import androidx.compose.ui.text.style.TextOverflow
81+import androidx.compose.ui.text.withStyle
82+import androidx.compose.ui.unit.Dp
83+import androidx.compose.ui.unit.dp
84+import kotlinx.coroutines.delay
85+import soy.iko.fold.FoldViewModel
86+import soy.iko.fold.Screen
87+import soy.iko.fold.Storage
88+import soy.iko.fold.core.Header
89+import soy.iko.fold.core.Row as OutlineRow
90+import soy.iko.fold.core.Spelling
91+import soy.iko.fold.core.Task
92+
93+private val INDENT: Dp = 20.dp
94+
95+@Composable
96+fun OutlineScreen(vm: FoldViewModel, list: LazyListState) {
97+ val outline = vm.outline
98+ val zoom = outline?.zoom
99+ val scroll = TopAppBarDefaults.enterAlwaysScrollBehavior()
100+ val rows = outline?.rows ?: emptyList()
101+ // scroll to what a verb made or moved, and let its flash fade
102+ val highlight = vm.highlight
103+ LaunchedEffect(highlight, rows) {
104+ val key = highlight ?: return@LaunchedEffect
105+ val i = rows.indexOfFirst { it.key == key }
106+ if (i >= 0) {
107+ val before = 2 + (if (zoom != null) 1 else 0)
108+ val visible = list.layoutInfo.visibleItemsInfo.any { it.key == key }
109+ if (!visible) list.animateScrollToItem((i + before - 2).coerceAtLeast(0))
110+ }
111+ delay(1600)
112+ vm.highlightShown()
113+ }
114+ Scaffold(
115+ modifier = Modifier.nestedScroll(scroll.nestedScrollConnection),
116+ topBar = { OutlineTopBar(vm, zoom, scroll) },
117+ floatingActionButton = {
118+ if (zoom == null) {
119+ ExtendedFloatingActionButton(
120+ onClick = { vm.capture() },
121+ icon = { Icon(Icons.Filled.Add, null) },
122+ text = { Text("Capture") },
123+ )
124+ } else {
125+ ExtendedFloatingActionButton(
126+ onClick = { vm.addChild(zoom.key) },
127+ icon = { Icon(Icons.Filled.Add, null) },
128+ text = { Text("Add") },
129+ )
130+ }
131+ },
132+ ) { padding ->
133+ LazyColumn(
134+ state = list,
135+ modifier = Modifier.fillMaxSize(),
136+ contentPadding = PaddingValues(top = padding.calculateTopPadding(), bottom = padding.calculateBottomPadding() + 96.dp),
137+ ) {
138+ item(key = "crumbs") {
139+ if (zoom != null) Crumbs(vm)
140+ }
141+ item(key = "conflicts") {
142+ val n = outline?.conflicts?.toInt() ?: 0
143+ if (n > 0) ConflictBanner(n) { vm.push(Screen.Conflicts) }
144+ }
145+ if (zoom != null) {
146+ item(key = "header:" + zoom.key) { ZoomHeader(vm, zoom) }
147+ }
148+ items(rows, key = { it.key }) { row ->
149+ OutlineRowView(
150+ row = row,
151+ highlighted = row.key == highlight,
152+ preview = vm.showPreview,
153+ onOpen = { vm.zoom(row.key) },
154+ onMenu = { vm.openMenu(row.key) },
155+ onFold = { vm.toggleFold(row.key, row.conflict != null) },
156+ onCheck = { vm.toggleTask(row.key) },
157+ modifier = Modifier.animateItem(),
158+ )
159+ }
160+ if (outline != null && rows.isEmpty()) {
161+ item(key = "empty") { Empty(zoom != null) }
162+ }
163+ }
164+ }
165+}
166+
167+@Composable
168+private fun OutlineTopBar(vm: FoldViewModel, zoom: Header?, scroll: androidx.compose.material3.TopAppBarScrollBehavior) {
169+ var more by remember { mutableStateOf(false) }
170+ val outline = vm.outline
171+ TopAppBar(
172+ scrollBehavior = scroll,
173+ title = {
174+ Text(
175+ zoom?.title?.ifEmpty { "(untitled)" } ?: vm.vault?.let { java.io.File(it).name } ?: "fold",
176+ maxLines = 1,
177+ overflow = TextOverflow.Ellipsis,
178+ )
179+ },
180+ navigationIcon = {
181+ if (zoom != null) {
182+ IconButton(onClick = { vm.zoomOut() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zoom out") }
183+ }
184+ },
185+ actions = {
186+ IconButton(onClick = { vm.push(Screen.Search) }) { Icon(Icons.Filled.Search, "Find") }
187+ IconButton(onClick = { vm.undo() }, enabled = outline?.undo != null) {
188+ Icon(Icons.AutoMirrored.Filled.Undo, "Undo")
189+ }
190+ IconButton(onClick = { vm.redo() }, enabled = outline?.redo != null) {
191+ Icon(Icons.AutoMirrored.Filled.Redo, "Redo")
192+ }
193+ Box {
194+ IconButton(onClick = { more = true }) { Icon(Icons.Filled.MoreVert, "More") }
195+ DropdownMenu(expanded = more, onDismissRequest = { more = false }) {
196+ val close = { more = false }
197+ MenuItem("Read as a document", close) { vm.push(Screen.Reading(zoom?.key)) }
198+ MenuItem("Hide done", close, on = vm.view.hideDone) { vm.toggleHideDone() }
199+ MenuItem("Text under titles", close, on = vm.showPreview) { vm.togglePreview() }
200+ MenuItem("Clear done" + (zoom?.let { " here" } ?: ""), close) { vm.clearDone() }
201+ HorizontalDivider()
202+ val n = outline?.conflicts?.toInt() ?: 0
203+ MenuItem(if (n > 0) "Resolve conflicts ($n)" else "Resolve conflicts", close) { vm.push(Screen.Conflicts) }
204+ MenuItem("Check the vault", close) { vm.push(Screen.Checks) }
205+ MenuItem("Trash", close) { vm.push(Screen.Trash) }
206+ MenuItem("Vaults", close) { vm.push(Screen.Vaults) }
207+ }
208+ }
209+ },
210+ )
211+}
212+
213+/** A menu entry; a toggle shows its state beside its name (§10.8). */
214+@Composable
215+fun MenuItem(label: String, close: () -> Unit, on: Boolean? = null, run: () -> Unit) {
216+ DropdownMenuItem(
217+ text = { Text(if (on == null) label else "$label · ${if (on) "on" else "off"}") },
218+ onClick = {
219+ close()
220+ run()
221+ },
222+ )
223+}
224+
225+/** The breadcrumb of the zoom (§10.1): every segment zooms there. */
226+@Composable
227+private fun Crumbs(vm: FoldViewModel) {
228+ val crumbs = vm.outline?.crumbs ?: return
229+ val c = LocalFoldColors.current
230+ FlowRow(
231+ modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 2.dp),
232+ verticalArrangement = Arrangement.Center,
233+ ) {
234+ Crumb(vm.vault?.let { Storage.shown(it).substringAfterLast('/') } ?: "fold") { vm.zoom(null) }
235+ for (crumb in crumbs.dropLast(1)) {
236+ Text("›", color = c.dim, modifier = Modifier.padding(horizontal = 2.dp, vertical = 6.dp))
237+ Crumb(crumb.title.ifEmpty { "(untitled)" }) { vm.zoom(crumb.key) }
238+ }
239+ }
240+}
241+
242+@Composable
243+private fun Crumb(title: String, onClick: () -> Unit) {
244+ Text(
245+ title,
246+ style = MaterialTheme.typography.labelLarge,
247+ color = MaterialTheme.colorScheme.primary,
248+ maxLines = 1,
249+ overflow = TextOverflow.Ellipsis,
250+ modifier = Modifier
251+ .clickable(onClick = onClick)
252+ .padding(horizontal = 4.dp, vertical = 6.dp),
253+ )
254+}
255+
256+@Composable
257+private fun ConflictBanner(n: Int, onClick: () -> Unit) {
258+ val c = LocalFoldColors.current
259+ Surface(
260+ color = c.warn.copy(alpha = 0.14f),
261+ shape = RoundedCornerShape(12.dp),
262+ modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp).clickable(onClick = onClick),
263+ ) {
264+ Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
265+ Icon(Icons.Filled.Warning, null, tint = c.warn)
266+ Spacer(Modifier.width(12.dp))
267+ Text(
268+ if (n == 1) "1 sync conflict to resolve" else "$n sync conflicts to resolve",
269+ style = MaterialTheme.typography.bodyMedium,
270+ modifier = Modifier.weight(1f),
271+ )
272+ Text("Resolve", color = c.warn, style = MaterialTheme.typography.labelLarge)
273+ }
274+ }
275+}
276+
277+/** The zoomed node above its children: title, properties, its body. */
278+@Composable
279+private fun ZoomHeader(vm: FoldViewModel, h: Header) {
280+ val c = LocalFoldColors.current
281+ Column(Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 8.dp)) {
282+ Row(verticalAlignment = Alignment.CenterVertically) {
283+ if (h.task != Task.NONE) {
284+ TaskBox(h.task, size = 28.dp) { vm.toggleTask(h.key) }
285+ Spacer(Modifier.width(8.dp))
286+ }
287+ Text(
288+ inline(h.title.ifEmpty { "(untitled)" }, c),
289+ style = MaterialTheme.typography.headlineSmall.let {
290+ if (h.task == Task.DONE) it.copy(color = c.done, textDecoration = TextDecoration.LineThrough) else it
291+ },
292+ modifier = Modifier.weight(1f).combinedClickable(onClick = { vm.edit(h.key) }, onLongClick = { vm.openMenu(h.key) }),
293+ )
294+ if (h.total > 0u) {
295+ Text("${h.open}/${h.total}", color = c.dim, style = MaterialTheme.typography.labelLarge)
296+ }
297+ }
298+ if (h.conflict != null) {
299+ Text("⚠ conflict copy · other device · ${h.conflict}", color = c.warn, style = MaterialTheme.typography.bodySmall)
300+ }
301+ val shown = h.props.filter { it.editable && it.key != "conflict" }
302+ if (shown.isNotEmpty() || h.block) {
303+ FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(top = 4.dp)) {
304+ for (p in shown) {
305+ AssistChip(
306+ onClick = { vm.push(Screen.Properties(h.key)) },
307+ label = { Text("${p.key} ${p.value}", maxLines = 1, overflow = TextOverflow.Ellipsis) },
308+ )
309+ }
310+ if (shown.isEmpty()) {
311+ AssistChip(onClick = { vm.push(Screen.Properties(h.key)) }, label = { Text("Properties") }, leadingIcon = { Icon(Icons.Filled.Tune, null) })
312+ }
313+ }
314+ }
315+ if (h.body.isNotEmpty()) {
316+ MarkdownBody(
317+ h.body,
318+ modifier = Modifier
319+ .padding(top = 8.dp)
320+ .fillMaxWidth()
321+ .clickable { vm.edit(h.key) },
322+ style = MaterialTheme.typography.bodyLarge,
323+ )
324+ }
325+ Row(Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
326+ TextButton(onClick = { vm.edit(h.key) }) {
327+ Icon(Icons.Filled.Edit, null, Modifier.size(18.dp))
328+ Spacer(Modifier.width(6.dp))
329+ Text("Edit")
330+ }
331+ TextButton(onClick = { vm.push(Screen.Reading(h.key)) }) {
332+ Icon(Icons.AutoMirrored.Filled.MenuBook, null, Modifier.size(18.dp))
333+ Spacer(Modifier.width(6.dp))
334+ Text("Read")
335+ }
336+ TextButton(onClick = { vm.openMenu(h.key) }) { Text("More…") }
337+ }
338+ HorizontalDivider(color = c.guide)
339+ }
340+}
341+
342+@Composable
343+private fun Empty(zoomed: Boolean) {
344+ val c = LocalFoldColors.current
345+ Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
346+ Text(
347+ if (zoomed) "Nothing under it yet — Add makes a child" else "Nothing here yet — Capture adds to the inbox",
348+ color = c.dim,
349+ style = MaterialTheme.typography.bodyMedium,
350+ )
351+ }
352+}
353+
354+/** A task's checkbox, ☐ or ☑ (§10.1), a touch target of its own. */
355+@Composable
356+fun TaskBox(task: Task, size: Dp = 24.dp, onClick: () -> Unit) {
357+ val c = LocalFoldColors.current
358+ IconButton(onClick = onClick, modifier = Modifier.size(size + 16.dp)) {
359+ Icon(
360+ if (task == Task.DONE) Icons.Filled.CheckBox else Icons.Filled.CheckBoxOutlineBlank,
361+ contentDescription = if (task == Task.DONE) "Reopen" else "Done",
362+ tint = if (task == Task.DONE) c.done else c.accent,
363+ modifier = Modifier.size(size),
364+ )
365+ }
366+}
367+
368+/** One row of the outline (§10.1). */
369+@Composable
370+fun OutlineRowView(
371+ row: OutlineRow,
372+ highlighted: Boolean,
373+ preview: Boolean,
374+ onOpen: () -> Unit,
375+ onMenu: () -> Unit,
376+ onFold: () -> Unit,
377+ onCheck: () -> Unit,
378+ modifier: Modifier = Modifier,
379+) {
380+ val c = LocalFoldColors.current
381+ val density = LocalDensity.current
382+ val bg by animateColorAsState(if (highlighted) c.highlight else Color.Transparent, label = "highlight")
383+ val depth = row.depth.toInt()
384+ Row(
385+ modifier = modifier
386+ .fillMaxWidth()
387+ .background(bg)
388+ .combinedClickable(onClick = onOpen, onLongClick = onMenu, onLongClickLabel = "Actions")
389+ .drawBehind {
390+ // a guide line down each level of nesting
391+ val step = with(density) { INDENT.toPx() }
392+ val start = with(density) { 8.dp.toPx() + 12.dp.toPx() }
393+ for (d in 0 until depth) {
394+ val x = start + d * step
395+ drawLine(c.guide, Offset(x, 0f), Offset(x, size.height), strokeWidth = with(density) { 1.dp.toPx() })
396+ }
397+ }
398+ .padding(start = 8.dp + INDENT * depth, end = 8.dp)
399+ .heightIn(min = 48.dp),
400+ verticalAlignment = Alignment.CenterVertically,
401+ ) {
402+ // the fold marker, or a bullet for a leaf
403+ Box(Modifier.size(24.dp).let { if (row.hasChildren) it.clickable(onClick = onFold) else it }, contentAlignment = Alignment.Center) {
404+ if (row.hasChildren) {
405+ Icon(
406+ if (row.folded) Icons.Filled.ChevronRight else Icons.Filled.ExpandMore,
407+ contentDescription = if (row.folded) "Unfold" else "Fold",
408+ tint = c.dim,
409+ )
410+ } else {
411+ Box(Modifier.size(5.dp).background(c.dim, RoundedCornerShape(50)))
412+ }
413+ }
414+ if (row.task != Task.NONE) {
415+ TaskBox(row.task, size = 22.dp, onClick = onCheck)
416+ } else {
417+ Spacer(Modifier.width(6.dp))
418+ }
419+ Column(Modifier.weight(1f).padding(vertical = 6.dp)) {
420+ Row(verticalAlignment = Alignment.CenterVertically) {
421+ val done = row.task == Task.DONE
422+ val title = buildAnnotatedString {
423+ append(inline(row.title.ifEmpty { "(untitled)" }, c))
424+ }
425+ Text(
426+ title,
427+ style = MaterialTheme.typography.bodyLarge.copy(
428+ fontWeight = if (row.spelling == Spelling.SECTION) FontWeight.SemiBold else FontWeight.Normal,
429+ color = if (done) c.done else MaterialTheme.colorScheme.onSurface,
430+ textDecoration = if (done) TextDecoration.LineThrough else null,
431+ ),
432+ maxLines = 2,
433+ overflow = TextOverflow.Ellipsis,
434+ modifier = Modifier.weight(1f, fill = false),
435+ )
436+ if (row.conflict != null) {
437+ Icon(Icons.Filled.Warning, "conflict copy", tint = c.warn, modifier = Modifier.padding(start = 4.dp).size(16.dp))
438+ } else if (row.block) {
439+ Icon(Icons.Filled.Description, "own file", tint = c.dim, modifier = Modifier.padding(start = 4.dp).size(14.dp))
440+ }
441+ if (row.broken) {
442+ Text(" missing", color = c.warn, style = MaterialTheme.typography.labelSmall)
443+ }
444+ }
445+ val second = when {
446+ row.conflict != null -> "other device · ${row.conflict}"
447+ preview -> row.preview
448+ else -> ""
449+ }
450+ if (second.isNotEmpty()) {
451+ Text(
452+ second,
453+ style = MaterialTheme.typography.bodySmall.copy(
454+ color = if (row.conflict != null) c.warn else c.dim,
455+ fontStyle = if (row.conflict != null) FontStyle.Normal else FontStyle.Italic,
456+ ),
457+ maxLines = 1,
458+ overflow = TextOverflow.Ellipsis,
459+ )
460+ }
461+ }
462+ Meta(row)
463+ }
464+}
465+
466+/** The due date and the open/total count of the tasks below (§10.1). */
467+@Composable
468+private fun Meta(row: OutlineRow) {
469+ val c = LocalFoldColors.current
470+ val due = row.due
471+ if (due == null && row.total == 0u) return
472+ Column(horizontalAlignment = Alignment.End, modifier = Modifier.padding(start = 8.dp)) {
473+ if (due != null) {
474+ val overdue = row.task == Task.OPEN && due < java.time.LocalDate.now().toString()
475+ Text(
476+ buildAnnotatedString {
477+ withStyle(SpanStyle(color = if (overdue) c.warn else c.accent)) { append("⏲ ") }
478+ append(if (due.length == 10 && due.startsWith(java.time.LocalDate.now().year.toString())) due.substring(5) else due)
479+ },
480+ style = MaterialTheme.typography.labelMedium,
481+ color = if (overdue) c.warn else c.dim,
482+ modifier = Modifier.semantics { contentDescription = "due $due" },
483+ )
484+ }
485+ if (row.total > 0u) {
486+ Text("${row.open}/${row.total}", style = MaterialTheme.typography.labelMedium, color = c.dim)
487+ }
488+ }
489+}
490+
491+@Composable
492+fun Gap(h: Dp) = Spacer(Modifier.height(h))
android/app/src/main/java/soy/iko/fold/ui/Root.ktnew file+86
@@ -0,0 +1,86 @@
1+package soy.iko.fold.ui
2+
3+import androidx.activity.compose.BackHandler
4+import androidx.compose.foundation.layout.Box
5+import androidx.compose.foundation.layout.fillMaxSize
6+import androidx.compose.foundation.layout.navigationBarsPadding
7+import androidx.compose.foundation.layout.padding
8+import androidx.compose.foundation.lazy.rememberLazyListState
9+import androidx.compose.material3.MaterialTheme
10+import androidx.compose.material3.Snackbar
11+import androidx.compose.material3.SnackbarDuration
12+import androidx.compose.material3.SnackbarHost
13+import androidx.compose.material3.SnackbarHostState
14+import androidx.compose.material3.SnackbarResult
15+import androidx.compose.material3.Surface
16+import androidx.compose.runtime.Composable
17+import androidx.compose.runtime.LaunchedEffect
18+import androidx.compose.runtime.remember
19+import androidx.compose.ui.Alignment
20+import androidx.compose.ui.Modifier
21+import androidx.compose.ui.unit.dp
22+import soy.iko.fold.FoldViewModel
23+import soy.iko.fold.Message
24+import soy.iko.fold.Screen
25+
26+/** The app: the vault chooser until a vault is open, then the outline and what is open over it. */
27+@Composable
28+fun Root(vm: FoldViewModel) {
29+ val snackbar = remember { SnackbarHostState() }
30+ // the outline's place, kept while another screen is open
31+ val outlineList = rememberLazyListState()
32+ LaunchedEffect(vm) {
33+ vm.messages.collect { m ->
34+ snackbar.currentSnackbarData?.dismiss()
35+ val result = snackbar.showSnackbar(
36+ message = m.text,
37+ actionLabel = when (m.action) {
38+ Message.Action.Undo -> "Undo"
39+ Message.Action.Resolve -> "Resolve"
40+ null -> null
41+ },
42+ duration = if (m.action != null) SnackbarDuration.Long else SnackbarDuration.Short,
43+ )
44+ if (result == SnackbarResult.ActionPerformed) {
45+ when (m.action) {
46+ Message.Action.Undo -> vm.undo(m.mark)
47+ Message.Action.Resolve -> vm.push(Screen.Conflicts)
48+ null -> {}
49+ }
50+ }
51+ }
52+ }
53+ Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
54+ Box(Modifier.fillMaxSize()) {
55+ if (vm.vault == null) {
56+ VaultScreen(vm)
57+ } else {
58+ // back: the top screen first, then the zoom, one level at a time
59+ BackHandler(enabled = vm.screens.isNotEmpty() || vm.view.zoom != null) {
60+ if (!vm.pop()) vm.zoomOut()
61+ }
62+ // one screen at a time: a screen over the outline would let
63+ // a touch on its empty space through to the rows beneath
64+ when (val top = vm.screens.lastOrNull()) {
65+ null -> OutlineScreen(vm, outlineList)
66+ Screen.Search -> SearchScreen(vm, null)
67+ is Screen.MoveTo -> SearchScreen(vm, top)
68+ is Screen.Reading -> ReadingScreen(vm, top.key)
69+ Screen.Editor -> vm.editor?.let { EditorScreen(vm, it) }
70+ is Screen.Properties -> PropertiesScreen(vm, top.key)
71+ Screen.Conflicts -> ConflictsScreen(vm)
72+ Screen.Trash -> TrashScreen(vm)
73+ Screen.Checks -> ChecksScreen(vm)
74+ Screen.Vaults -> VaultScreen(vm) { vm.pop() }
75+ }
76+ }
77+ vm.menu?.let { NodeMenu(vm, it) }
78+ vm.input?.let { InputSheet(vm, it) }
79+ vm.source?.let { SourceDialog(it) { vm.hideSource() } }
80+ SnackbarHost(
81+ snackbar,
82+ modifier = Modifier.align(Alignment.BottomCenter).navigationBarsPadding().padding(bottom = 72.dp),
83+ ) { Snackbar(it) }
84+ }
85+ }
86+}
android/app/src/main/java/soy/iko/fold/ui/Screens.ktnew file+557
@@ -0,0 +1,557 @@
1+@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
2+
3+package soy.iko.fold.ui
4+
5+import androidx.compose.foundation.ExperimentalFoundationApi
6+import androidx.compose.foundation.background
7+import androidx.compose.foundation.clickable
8+import androidx.compose.foundation.combinedClickable
9+import androidx.compose.foundation.horizontalScroll
10+import androidx.compose.foundation.layout.Arrangement
11+import androidx.compose.foundation.layout.Box
12+import androidx.compose.foundation.layout.Column
13+import androidx.compose.foundation.layout.PaddingValues
14+import androidx.compose.foundation.layout.Row
15+import androidx.compose.foundation.layout.Spacer
16+import androidx.compose.foundation.layout.fillMaxSize
17+import androidx.compose.foundation.layout.fillMaxWidth
18+import androidx.compose.foundation.layout.height
19+import androidx.compose.foundation.layout.padding
20+import androidx.compose.foundation.layout.size
21+import androidx.compose.foundation.layout.width
22+import androidx.compose.foundation.lazy.LazyColumn
23+import androidx.compose.foundation.lazy.items
24+import androidx.compose.foundation.lazy.itemsIndexed
25+import androidx.compose.foundation.rememberScrollState
26+import androidx.compose.foundation.shape.RoundedCornerShape
27+import androidx.compose.foundation.text.KeyboardOptions
28+import androidx.compose.foundation.verticalScroll
29+import androidx.compose.material.icons.Icons
30+import androidx.compose.material.icons.automirrored.filled.ArrowBack
31+import androidx.compose.material.icons.filled.Add
32+import androidx.compose.material.icons.filled.CalendarMonth
33+import androidx.compose.material.icons.filled.CheckBox
34+import androidx.compose.material.icons.filled.CheckBoxOutlineBlank
35+import androidx.compose.material.icons.filled.Close
36+import androidx.compose.material.icons.filled.Edit
37+import androidx.compose.material.icons.filled.Warning
38+import androidx.compose.material3.AlertDialog
39+import androidx.compose.material3.Button
40+import androidx.compose.material3.DatePicker
41+import androidx.compose.material3.DatePickerDialog
42+import androidx.compose.material3.ExperimentalMaterial3Api
43+import androidx.compose.material3.FilledTonalButton
44+import androidx.compose.material3.HorizontalDivider
45+import androidx.compose.material3.Icon
46+import androidx.compose.material3.IconButton
47+import androidx.compose.material3.ListItem
48+import androidx.compose.material3.MaterialTheme
49+import androidx.compose.material3.OutlinedButton
50+import androidx.compose.material3.OutlinedTextField
51+import androidx.compose.material3.Scaffold
52+import androidx.compose.material3.Surface
53+import androidx.compose.material3.Text
54+import androidx.compose.material3.TextButton
55+import androidx.compose.material3.TextField
56+import androidx.compose.material3.TextFieldDefaults
57+import androidx.compose.material3.TopAppBar
58+import androidx.compose.material3.rememberDatePickerState
59+import androidx.compose.runtime.Composable
60+import androidx.compose.runtime.LaunchedEffect
61+import androidx.compose.runtime.getValue
62+import androidx.compose.runtime.mutableIntStateOf
63+import androidx.compose.runtime.mutableStateOf
64+import androidx.compose.runtime.remember
65+import androidx.compose.runtime.setValue
66+import androidx.compose.ui.Alignment
67+import androidx.compose.ui.Modifier
68+import androidx.compose.ui.focus.FocusRequester
69+import androidx.compose.ui.focus.focusRequester
70+import androidx.compose.ui.graphics.Color
71+import androidx.compose.ui.text.font.FontFamily
72+import androidx.compose.ui.text.font.FontWeight
73+import androidx.compose.ui.text.input.ImeAction
74+import androidx.compose.ui.text.style.TextDecoration
75+import androidx.compose.ui.text.style.TextOverflow
76+import androidx.compose.ui.unit.dp
77+import java.time.Instant
78+import java.time.LocalDate
79+import java.time.ZoneOffset
80+import soy.iko.fold.FoldViewModel
81+import soy.iko.fold.Screen
82+import soy.iko.fold.core.Hit
83+import soy.iko.fold.core.Keep
84+import soy.iko.fold.core.Property
85+import soy.iko.fold.core.ReadKind
86+import soy.iko.fold.core.ReadLine
87+import soy.iko.fold.core.Task
88+
89+/** A screen's top bar with a way back. */
90+@Composable
91+fun BackBar(title: String, onBack: () -> Unit, actions: @Composable () -> Unit = {}) {
92+ TopAppBar(
93+ title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
94+ navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } },
95+ actions = { actions() },
96+ )
97+}
98+
99+// ------------------------------------------------------------ reading
100+
101+/**
102+ * The reading view (§5.4): `render(node, 1, true)` with light styling
103+ * (§10.9). A title zooms the outline there; its checkbox toggles; a long
104+ * press is the node's menu.
105+ */
106+@Composable
107+fun ReadingScreen(vm: FoldViewModel, key: String?) {
108+ val lines = vm.reading
109+ val title = lines.firstOrNull { it.kind == ReadKind.TITLE }?.title?.takeIf { key != null } ?: "Everything"
110+ Scaffold(
111+ topBar = {
112+ BackBar(title, { vm.pop() }) {
113+ if (key != null) IconButton(onClick = { vm.edit(key) }) { Icon(Icons.Filled.Edit, "Edit") }
114+ }
115+ },
116+ ) { padding ->
117+ LazyColumn(
118+ contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = padding.calculateTopPadding() + 4.dp, bottom = padding.calculateBottomPadding() + 32.dp),
119+ ) {
120+ // fenced code is styled as a run, so lines go in groups
121+ val groups = group(lines)
122+ items(groups.size) { i -> ReadGroup(vm, groups[i]) }
123+ }
124+ }
125+}
126+
127+/** Lines in runs: each title line alone, body lines together. */
128+private fun group(lines: List<ReadLine>): List<List<ReadLine>> {
129+ val out = mutableListOf<MutableList<ReadLine>>()
130+ for (l in lines) {
131+ val body = l.kind == ReadKind.BODY || l.kind == ReadKind.BLANK
132+ val last = out.lastOrNull()
133+ if (body && last != null && last.first().kind != ReadKind.TITLE && last.first().kind != ReadKind.EMBED) {
134+ last.add(l)
135+ } else {
136+ out.add(mutableListOf(l))
137+ }
138+ }
139+ return out
140+}
141+
142+@Composable
143+private fun ReadGroup(vm: FoldViewModel, lines: List<ReadLine>) {
144+ val c = LocalFoldColors.current
145+ val first = lines.first()
146+ when (first.kind) {
147+ ReadKind.TITLE -> ReadTitle(vm, first)
148+ ReadKind.EMBED -> Text("▤ ${first.text.trim()} (missing)", color = c.warn, style = MaterialTheme.typography.bodyMedium)
149+ else -> {
150+ // a run of text: dedented by the indent of the node it is under
151+ val indent = lines.filter { it.text.isNotBlank() }.minOfOrNull { it.text.length - it.text.trimStart().length } ?: 0
152+ val key = first.node
153+ MarkdownBody(
154+ lines.map { if (it.text.length >= indent) it.text.substring(indent) else it.text.trimStart() },
155+ modifier = Modifier
156+ .fillMaxWidth()
157+ .padding(start = (indent * 8).dp)
158+ .let { m -> if (key != null) m.combinedClickable(onClick = {}, onDoubleClick = { vm.edit(key) }, onLongClick = { vm.openMenu(key) }) else m },
159+ )
160+ }
161+ }
162+}
163+
164+@Composable
165+private fun ReadTitle(vm: FoldViewModel, l: ReadLine) {
166+ val c = LocalFoldColors.current
167+ val key = l.node ?: return
168+ val style = when (l.heading.toInt()) {
169+ 0 -> MaterialTheme.typography.bodyLarge
170+ 1 -> MaterialTheme.typography.headlineSmall
171+ 2 -> MaterialTheme.typography.titleLarge
172+ 3 -> MaterialTheme.typography.titleMedium
173+ else -> MaterialTheme.typography.titleSmall
174+ }
175+ val done = l.task == Task.DONE
176+ Column(
177+ Modifier
178+ .fillMaxWidth()
179+ .padding(start = (l.indent.toInt() * 8).dp, top = if (l.heading > 0u) 10.dp else 0.dp)
180+ .combinedClickable(onClick = { vm.zoom(key); vm.pop() }, onLongClick = { vm.openMenu(key) }),
181+ ) {
182+ Row(verticalAlignment = Alignment.CenterVertically) {
183+ if (l.heading == 0u && l.task == Task.NONE) {
184+ Text("•", color = c.dim, modifier = Modifier.padding(end = 8.dp))
185+ }
186+ if (l.task != Task.NONE) {
187+ Icon(
188+ if (done) Icons.Filled.CheckBox else Icons.Filled.CheckBoxOutlineBlank,
189+ contentDescription = if (done) "Reopen" else "Done",
190+ tint = if (done) c.done else c.accent,
191+ modifier = Modifier.padding(end = 6.dp).size(22.dp).clickable { vm.toggleTask(key) },
192+ )
193+ }
194+ Text(
195+ inline(l.title.ifEmpty { "(untitled)" }, c),
196+ style = style.copy(
197+ fontWeight = if (l.heading > 0u) FontWeight.SemiBold else style.fontWeight,
198+ color = if (done) c.done else style.color,
199+ textDecoration = if (done) TextDecoration.LineThrough else null,
200+ ),
201+ )
202+ if (l.conflict != null) {
203+ Icon(Icons.Filled.Warning, "conflict copy", tint = c.warn, modifier = Modifier.padding(start = 6.dp).size(16.dp))
204+ }
205+ }
206+ l.props?.let { Text("⚑ $it", color = c.dim, style = MaterialTheme.typography.bodySmall) }
207+ l.conflict?.let { Text("other device · $it", color = c.warn, style = MaterialTheme.typography.bodySmall) }
208+ }
209+}
210+
211+/** A node's exact source, frontmatter included (§10.3 `zr`). */
212+@Composable
213+fun SourceDialog(text: String, onClose: () -> Unit) {
214+ val c = LocalFoldColors.current
215+ AlertDialog(
216+ onDismissRequest = onClose,
217+ confirmButton = { TextButton(onClick = onClose) { Text("Close") } },
218+ title = { Text("Source") },
219+ text = {
220+ Box(Modifier.verticalScroll(rememberScrollState()).horizontalScroll(rememberScrollState()).background(c.codeBackground)) {
221+ Text(text, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall, softWrap = false, modifier = Modifier.padding(8.dp))
222+ }
223+ },
224+ )
225+}
226+
227+// ------------------------------------------------------------ search
228+
229+/**
230+ * The filter box (§10.5), and the *Move to…* picker (§10.1): a list of
231+ * nodes, title first and path dimmed, narrowed as you type.
232+ */
233+@Composable
234+fun SearchScreen(vm: FoldViewModel, moving: Screen.MoveTo?) {
235+ val c = LocalFoldColors.current
236+ val focus = remember { FocusRequester() }
237+ LaunchedEffect(moving) { focus.requestFocus() }
238+ Scaffold(
239+ topBar = {
240+ TopAppBar(
241+ navigationIcon = { IconButton(onClick = { vm.pop() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } },
242+ title = {
243+ TextField(
244+ value = vm.searchQuery,
245+ onValueChange = { vm.search(it) },
246+ placeholder = { Text(if (moving != null) "Move “${moving.title}” to…" else "Find titles and text") },
247+ singleLine = true,
248+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
249+ colors = TextFieldDefaults.colors(
250+ focusedContainerColor = Color.Transparent,
251+ unfocusedContainerColor = Color.Transparent,
252+ focusedIndicatorColor = Color.Transparent,
253+ unfocusedIndicatorColor = Color.Transparent,
254+ ),
255+ modifier = Modifier.fillMaxWidth().focusRequester(focus),
256+ )
257+ },
258+ )
259+ },
260+ ) { padding ->
261+ LazyColumn(contentPadding = padding) {
262+ if (moving == null && vm.searchQuery.isNotBlank() && vm.hits.isEmpty()) {
263+ item { Text("Nothing matches", color = c.dim, modifier = Modifier.padding(24.dp)) }
264+ }
265+ items(vm.hits, key = { it.key }) { hit ->
266+ HitRow(hit) {
267+ if (moving != null) vm.moveTo(moving.key, hit.key) else vm.reveal(hit.key, hit.parent)
268+ }
269+ }
270+ }
271+ }
272+}
273+
274+@Composable
275+private fun HitRow(hit: Hit, onClick: () -> Unit) {
276+ val c = LocalFoldColors.current
277+ ListItem(
278+ modifier = Modifier.clickable(onClick = onClick),
279+ leadingContent = when (hit.task) {
280+ Task.OPEN -> { { Icon(Icons.Filled.CheckBoxOutlineBlank, null, tint = c.accent) } }
281+ Task.DONE -> { { Icon(Icons.Filled.CheckBox, null, tint = c.done) } }
282+ Task.NONE -> null
283+ },
284+ headlineContent = {
285+ Row(verticalAlignment = Alignment.CenterVertically) {
286+ Text(inline(hit.title.ifEmpty { "(untitled)" }, c), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
287+ if (hit.conflict) Text(" ⚠", color = c.warn)
288+ }
289+ },
290+ supportingContent = {
291+ Column {
292+ if (hit.path.isNotEmpty()) Text(hit.path, color = c.dim, maxLines = 1, overflow = TextOverflow.Ellipsis)
293+ hit.excerpt?.let { Text(it, maxLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall) }
294+ }
295+ },
296+ )
297+}
298+
299+// ------------------------------------------------------------ properties
300+
301+/**
302+ * The property form (§10.6): the node's keys and values. Setting the first
303+ * on a node that is not a block makes it one (§6.1); `due` and `done` take
304+ * ISO dates only (§8.4); lines fold does not understand are read-only.
305+ */
306+@Composable
307+fun PropertiesScreen(vm: FoldViewModel, key: String) {
308+ val c = LocalFoldColors.current
309+ var editing by remember { mutableStateOf<Pair<String, String>?>(null) }
310+ var adding by remember { mutableStateOf(false) }
311+ val node = vm.outline?.rows?.firstOrNull { it.key == key }?.title ?: vm.outline?.zoom?.takeIf { it.key == key }?.title
312+ Scaffold(
313+ topBar = { BackBar(node?.let { "Properties · $it" } ?: "Properties", { vm.pop() }) },
314+ floatingActionButton = {
315+ androidx.compose.material3.ExtendedFloatingActionButton(
316+ onClick = { adding = true },
317+ icon = { Icon(Icons.Filled.Add, null) },
318+ text = { Text("Add") },
319+ )
320+ },
321+ ) { padding ->
322+ LazyColumn(contentPadding = padding) {
323+ if (vm.props.isEmpty()) {
324+ item {
325+ Text(
326+ "No properties. Adding one — a due date, say — gives the node its own file.",
327+ color = c.dim,
328+ modifier = Modifier.padding(24.dp),
329+ )
330+ }
331+ }
332+ items(vm.props, key = { it.key }) { p -> PropertyRow(p, { editing = p.key to p.value }) { vm.removeProperty(key, p.key) } }
333+ }
334+ }
335+ editing?.let { (k, v) ->
336+ PropertyDialog(k, v, fixedKey = true, onDismiss = { editing = null }) { name, value ->
337+ editing = null
338+ vm.setProperty(key, name, value)
339+ }
340+ }
341+ if (adding) {
342+ PropertyDialog("", "", fixedKey = false, onDismiss = { adding = false }) { name, value ->
343+ adding = false
344+ vm.setProperty(key, name, value)
345+ }
346+ }
347+}
348+
349+@Composable
350+private fun PropertyRow(p: Property, onEdit: () -> Unit, onDelete: () -> Unit) {
351+ val c = LocalFoldColors.current
352+ ListItem(
353+ modifier = if (p.editable) Modifier.clickable(onClick = onEdit) else Modifier,
354+ headlineContent = { Text(p.value.ifEmpty { "—" }, fontFamily = if (p.editable) null else FontFamily.Monospace) },
355+ overlineContent = { Text(if (p.editable) p.key else "kept as written", color = c.dim) },
356+ trailingContent = {
357+ if (p.editable) IconButton(onClick = onDelete) { Icon(Icons.Filled.Close, "Remove ${p.key}") }
358+ },
359+ )
360+}
361+
362+@Composable
363+private fun PropertyDialog(key: String, value: String, fixedKey: Boolean, onDismiss: () -> Unit, onSave: (String, String) -> Unit) {
364+ var k by remember { mutableStateOf(key) }
365+ var v by remember { mutableStateOf(value) }
366+ var picking by remember { mutableStateOf(false) }
367+ val date = k == "due" || k == "done"
368+ val valid = k.matches(Regex("[A-Za-z_][A-Za-z0-9_-]*")) && k != "id" &&
369+ (!date || v.matches(Regex("\\d{4}-\\d{2}-\\d{2}")))
370+ AlertDialog(
371+ onDismissRequest = onDismiss,
372+ title = { Text(if (fixedKey) key else "New property") },
373+ text = {
374+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
375+ if (!fixedKey) {
376+ OutlinedTextField(k, { k = it.trim() }, label = { Text("Name") }, singleLine = true, placeholder = { Text("due") })
377+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
378+ for (s in listOf("due", "tags", "priority")) {
379+ androidx.compose.material3.SuggestionChip(onClick = { k = s }, label = { Text(s) })
380+ }
381+ }
382+ }
383+ OutlinedTextField(
384+ v,
385+ { v = it.replace("\n", " ") },
386+ label = { Text(if (date) "YYYY-MM-DD" else "Value") },
387+ singleLine = true,
388+ isError = date && v.isNotEmpty() && !v.matches(Regex("\\d{4}-\\d{2}-\\d{2}")),
389+ trailingIcon = {
390+ if (date) IconButton(onClick = { picking = true }) { Icon(Icons.Filled.CalendarMonth, "Pick a date") }
391+ },
392+ )
393+ }
394+ },
395+ confirmButton = { TextButton(onClick = { onSave(k, v) }, enabled = valid) { Text("Save") } },
396+ dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
397+ )
398+ if (picking) {
399+ val initial = runCatching { LocalDate.parse(v) }.getOrNull() ?: LocalDate.now()
400+ val state = rememberDatePickerState(initialSelectedDateMillis = initial.atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli())
401+ DatePickerDialog(
402+ onDismissRequest = { picking = false },
403+ confirmButton = {
404+ TextButton(onClick = {
405+ state.selectedDateMillis?.let { v = Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate().toString() }
406+ picking = false
407+ }) { Text("OK") }
408+ },
409+ dismissButton = { TextButton(onClick = { picking = false }) { Text("Cancel") } },
410+ ) { DatePicker(state) }
411+ }
412+}
413+
414+// ------------------------------------------------------------ conflicts
415+
416+/**
417+ * The conflict view (§10.7): each pair, this device's version and the
418+ * other's, and the choices — keep ours, keep theirs, keep both (§12.5).
419+ */
420+@Composable
421+fun ConflictsScreen(vm: FoldViewModel) {
422+ val c = LocalFoldColors.current
423+ val pairs = vm.conflicts
424+ var at by remember { mutableIntStateOf(0) }
425+ val i = at.coerceIn(0, (pairs.size - 1).coerceAtLeast(0))
426+ Scaffold(
427+ topBar = {
428+ BackBar(if (pairs.isEmpty()) "Conflicts" else "Conflict ${i + 1} of ${pairs.size}", { vm.pop() }) {
429+ TextButton(onClick = { at = i - 1 }, enabled = i > 0) { Text("Previous") }
430+ TextButton(onClick = { at = i + 1 }, enabled = i + 1 < pairs.size) { Text("Next") }
431+ }
432+ },
433+ ) { padding ->
434+ val pair = pairs.getOrNull(i)
435+ if (pair == null) {
436+ Box(Modifier.padding(padding).fillMaxSize(), contentAlignment = Alignment.Center) {
437+ Text("No sync conflicts", color = c.dim)
438+ }
439+ return@Scaffold
440+ }
441+ Column(Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
442+ Text("“${pair.title}”", style = MaterialTheme.typography.titleLarge)
443+ Gap(12.dp)
444+ Version("This device", pair.oursText, MaterialTheme.colorScheme.primary)
445+ Gap(12.dp)
446+ Version("Other device · ${pair.from}", pair.theirsText, c.warn)
447+ Gap(16.dp)
448+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
449+ Button(onClick = { vm.resolve(pair.theirs, Keep.OURS) }, modifier = Modifier.weight(1f)) { Text("Keep ours") }
450+ FilledTonalButton(onClick = { vm.resolve(pair.theirs, Keep.THEIRS) }, modifier = Modifier.weight(1f)) { Text("Keep theirs") }
451+ }
452+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
453+ OutlinedButton(onClick = { vm.resolve(pair.theirs, Keep.BOTH) }, modifier = Modifier.weight(1f)) { Text("Keep both") }
454+ OutlinedButton(onClick = { vm.edit(pair.ours) }, modifier = Modifier.weight(1f)) { Text("Edit ours") }
455+ }
456+ Gap(8.dp)
457+ Text(
458+ "Keep ours trashes the other copy; keep theirs puts its text in place of ours; keep both leaves both as ordinary nodes. Undo takes any choice back.",
459+ color = c.dim,
460+ style = MaterialTheme.typography.bodySmall,
461+ )
462+ }
463+ }
464+}
465+
466+@Composable
467+private fun Version(label: String, text: String, accent: Color) {
468+ val c = LocalFoldColors.current
469+ Surface(shape = RoundedCornerShape(12.dp), color = c.codeBackground, modifier = Modifier.fillMaxWidth()) {
470+ Column(Modifier.padding(12.dp)) {
471+ Row(verticalAlignment = Alignment.CenterVertically) {
472+ Box(Modifier.size(8.dp).background(accent, RoundedCornerShape(50)))
473+ Spacer(Modifier.width(8.dp))
474+ Text(label, style = MaterialTheme.typography.labelLarge, color = accent)
475+ }
476+ Gap(6.dp)
477+ Text(text.trimEnd(), fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall)
478+ }
479+ }
480+}
481+
482+// ------------------------------------------------------------ check, trash
483+
484+/** `fold check` (§15.7), and *Canonicalize* (§4.2). */
485+@Composable
486+fun ChecksScreen(vm: FoldViewModel) {
487+ val c = LocalFoldColors.current
488+ val d = vm.diagnostics
489+ Scaffold(
490+ topBar = {
491+ BackBar("Check the vault", { vm.pop() }) {
492+ TextButton(onClick = { vm.canonicalize() }) { Text("Canonicalize") }
493+ }
494+ },
495+ ) { padding ->
496+ LazyColumn(contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = padding.calculateTopPadding(), bottom = 32.dp)) {
497+ when {
498+ d == null -> item { Text("Checking…", color = c.dim, modifier = Modifier.padding(vertical = 16.dp)) }
499+ d.isEmpty() -> item { Text("All good: every file is as fold writes it.", modifier = Modifier.padding(vertical = 16.dp)) }
500+ else -> {
501+ item {
502+ Text(
503+ "Canonicalize rewrites the vault in canonical form and repairs file names, as `fold check --fix` does.",
504+ color = c.dim,
505+ style = MaterialTheme.typography.bodySmall,
506+ modifier = Modifier.padding(vertical = 8.dp),
507+ )
508+ }
509+ items(d) { line ->
510+ Text(line, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(vertical = 4.dp))
511+ HorizontalDivider(color = c.guide)
512+ }
513+ }
514+ }
515+ }
516+ }
517+}
518+
519+/** The trash (§11.5): what was deleted on this device, newest first. */
520+@Composable
521+fun TrashScreen(vm: FoldViewModel) {
522+ val c = LocalFoldColors.current
523+ Scaffold(topBar = { BackBar("Trash", { vm.pop() }) }) { padding ->
524+ LazyColumn(contentPadding = padding) {
525+ if (vm.trash.isEmpty()) item { Text("The trash is empty", color = c.dim, modifier = Modifier.padding(24.dp)) }
526+ itemsIndexed(vm.trash, key = { _, e -> e.name }) { _, e ->
527+ ListItem(
528+ modifier = Modifier.clickable { vm.showTrash(e.name) },
529+ headlineContent = { Text(e.name.drop(16).ifEmpty { e.name }, maxLines = 1, overflow = TextOverflow.Ellipsis) },
530+ supportingContent = {
531+ val stamp = e.name.take(15)
532+ val shown = if (stamp.length == 15 && stamp[8] == '-') "${stamp.substring(0, 4)}-${stamp.substring(4, 6)}-${stamp.substring(6, 8)} ${stamp.substring(9, 11)}:${stamp.substring(11, 13)}" else ""
533+ Text("$shown · ${e.bytes} bytes", color = c.dim)
534+ },
535+ )
536+ }
537+ }
538+ }
539+ vm.trashShown?.let { (name, text) ->
540+ AlertDialog(
541+ onDismissRequest = { vm.showTrash(null) },
542+ title = { Text(name.drop(16).ifEmpty { name }, maxLines = 2, overflow = TextOverflow.Ellipsis) },
543+ text = {
544+ Box(Modifier.height(360.dp).verticalScroll(rememberScrollState()).background(c.codeBackground)) {
545+ androidx.compose.foundation.text.selection.SelectionContainer {
546+ Text(text, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(8.dp))
547+ }
548+ }
549+ },
550+ confirmButton = { TextButton(onClick = { vm.restore(name) }) { Text("Restore") } },
551+ dismissButton = { TextButton(onClick = { vm.showTrash(null) }) { Text("Close") } },
552+ )
553+ }
554+}
555+
556+@Composable
557+fun Spaced(h: androidx.compose.ui.unit.Dp) = Spacer(Modifier.height(h))
android/app/src/main/java/soy/iko/fold/ui/Sheets.ktnew file+223
@@ -0,0 +1,223 @@
1+@file:OptIn(ExperimentalMaterial3Api::class)
2+
3+package soy.iko.fold.ui
4+
5+import androidx.compose.foundation.clickable
6+import androidx.compose.foundation.layout.Column
7+import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.Spacer
9+import androidx.compose.foundation.layout.fillMaxWidth
10+import androidx.compose.foundation.layout.imePadding
11+import androidx.compose.foundation.layout.navigationBarsPadding
12+import androidx.compose.foundation.layout.padding
13+import androidx.compose.foundation.layout.width
14+import androidx.compose.foundation.rememberScrollState
15+import androidx.compose.foundation.text.KeyboardActions
16+import androidx.compose.foundation.text.KeyboardOptions
17+import androidx.compose.foundation.verticalScroll
18+import androidx.compose.material.icons.Icons
19+import androidx.compose.material.icons.automirrored.filled.DriveFileMove
20+import androidx.compose.material.icons.automirrored.filled.FormatIndentDecrease
21+import androidx.compose.material.icons.automirrored.filled.FormatIndentIncrease
22+import androidx.compose.material.icons.automirrored.filled.MenuBook
23+import androidx.compose.material.icons.automirrored.filled.Notes
24+import androidx.compose.material.icons.filled.AddBox
25+import androidx.compose.material.icons.filled.Archive
26+import androidx.compose.material.icons.filled.ArrowDownward
27+import androidx.compose.material.icons.filled.ArrowUpward
28+import androidx.compose.material.icons.filled.CheckBox
29+import androidx.compose.material.icons.filled.CheckBoxOutlineBlank
30+import androidx.compose.material.icons.filled.Code
31+import androidx.compose.material.icons.filled.ContentCopy
32+import androidx.compose.material.icons.filled.ContentPaste
33+import androidx.compose.material.icons.filled.Delete
34+import androidx.compose.material.icons.filled.Description
35+import androidx.compose.material.icons.filled.Edit
36+import androidx.compose.material.icons.filled.SubdirectoryArrowRight
37+import androidx.compose.material.icons.filled.Title
38+import androidx.compose.material.icons.filled.Tune
39+import androidx.compose.material.icons.filled.Warning
40+import androidx.compose.material.icons.filled.ZoomIn
41+import androidx.compose.material3.Checkbox
42+import androidx.compose.material3.ExperimentalMaterial3Api
43+import androidx.compose.material3.HorizontalDivider
44+import androidx.compose.material3.Icon
45+import androidx.compose.material3.ListItem
46+import androidx.compose.material3.ListItemDefaults
47+import androidx.compose.material3.MaterialTheme
48+import androidx.compose.material3.ModalBottomSheet
49+import androidx.compose.material3.OutlinedTextField
50+import androidx.compose.material3.Text
51+import androidx.compose.material3.TextButton
52+import androidx.compose.material3.rememberModalBottomSheetState
53+import androidx.compose.runtime.Composable
54+import androidx.compose.runtime.LaunchedEffect
55+import androidx.compose.runtime.getValue
56+import androidx.compose.runtime.mutableStateOf
57+import androidx.compose.runtime.remember
58+import androidx.compose.runtime.saveable.rememberSaveable
59+import androidx.compose.runtime.setValue
60+import androidx.compose.ui.Alignment
61+import androidx.compose.ui.Modifier
62+import androidx.compose.ui.focus.FocusRequester
63+import androidx.compose.ui.focus.focusRequester
64+import androidx.compose.ui.graphics.vector.ImageVector
65+import androidx.compose.ui.text.TextRange
66+import androidx.compose.ui.text.input.ImeAction
67+import androidx.compose.ui.text.input.KeyboardCapitalization
68+import androidx.compose.ui.text.input.TextFieldValue
69+import androidx.compose.ui.text.style.TextOverflow
70+import androidx.compose.ui.unit.dp
71+import soy.iko.fold.FoldViewModel
72+import soy.iko.fold.Input
73+import soy.iko.fold.Screen
74+import soy.iko.fold.core.NodeInfo
75+import soy.iko.fold.core.Spelling
76+import soy.iko.fold.core.Task
77+
78+/**
79+ * The node menu (§10.1): every action on a node, in the TUI's order —
80+ * Edit · Zoom in · Properties… | New sibling · New child | Done / reopen ·
81+ * Task on / off · Heading ↔ bullet · Make block | Move up · Move down ·
82+ * Indent · Outdent · Move to… · Archive | Copy · Paste after · Paste before
83+ * · Delete, and Resolve conflict… on either side of a pair.
84+ */
85+@Composable
86+fun NodeMenu(vm: FoldViewModel, node: NodeInfo) {
87+ val state = rememberModalBottomSheetState(skipPartiallyExpanded = false)
88+ val c = LocalFoldColors.current
89+ val key = node.key
90+ val copied = vm.outline?.copied
91+ fun run(action: () -> Unit) = {
92+ vm.closeMenu()
93+ action()
94+ }
95+ ModalBottomSheet(onDismissRequest = { vm.closeMenu() }, sheetState = state) {
96+ Column(Modifier.verticalScroll(rememberScrollState()).navigationBarsPadding()) {
97+ Column(Modifier.padding(horizontal = 24.dp).padding(bottom = 8.dp)) {
98+ Text(
99+ node.title.ifEmpty { "(untitled)" },
100+ style = MaterialTheme.typography.titleLarge,
101+ maxLines = 2,
102+ overflow = TextOverflow.Ellipsis,
103+ )
104+ val path = node.path.dropLast(1).joinToString(" › ") { it.title }
105+ if (path.isNotEmpty()) Text(path, color = c.dim, style = MaterialTheme.typography.bodySmall)
106+ if (node.conflict != null) {
107+ Text("⚠ conflict copy · other device · ${node.conflict}", color = c.warn, style = MaterialTheme.typography.bodySmall)
108+ }
109+ }
110+ HorizontalDivider()
111+ Item(Icons.Filled.Edit, "Edit", run { vm.edit(key) })
112+ Item(Icons.Filled.ZoomIn, "Zoom in", run { vm.zoom(key) })
113+ Item(Icons.AutoMirrored.Filled.MenuBook, "Read", run { vm.push(Screen.Reading(key)) })
114+ Item(Icons.Filled.Tune, "Properties…", run { vm.push(Screen.Properties(key)) })
115+ HorizontalDivider()
116+ Item(Icons.Filled.AddBox, "New sibling", run { vm.addSibling(key) })
117+ Item(Icons.Filled.SubdirectoryArrowRight, "New child", run { vm.addChild(key) })
118+ HorizontalDivider()
119+ when (node.task) {
120+ Task.OPEN -> Item(Icons.Filled.CheckBox, "Done", run { vm.toggleTask(key) })
121+ Task.DONE -> Item(Icons.Filled.CheckBoxOutlineBlank, "Reopen", run { vm.toggleTask(key) })
122+ Task.NONE -> {}
123+ }
124+ Item(
125+ Icons.Filled.CheckBoxOutlineBlank,
126+ if (node.task == Task.NONE) "Task on" else "Task off",
127+ run { vm.toggleTaskness(key) },
128+ )
129+ Item(
130+ if (node.spelling == Spelling.SECTION) Icons.AutoMirrored.Filled.Notes else Icons.Filled.Title,
131+ if (node.spelling == Spelling.SECTION) "Make it a bullet" else "Make it a heading",
132+ run { vm.toggleSpelling(key) },
133+ )
134+ if (!node.block) Item(Icons.Filled.Description, "Give it its own file", run { vm.makeBlock(key) })
135+ HorizontalDivider()
136+ Item(Icons.Filled.ArrowUpward, "Move up", run { vm.moveSibling(key, down = false) })
137+ Item(Icons.Filled.ArrowDownward, "Move down", run { vm.moveSibling(key, down = true) })
138+ Item(Icons.AutoMirrored.Filled.FormatIndentIncrease, "Indent", run { vm.indent(key) })
139+ Item(Icons.AutoMirrored.Filled.FormatIndentDecrease, "Outdent", run { vm.outdent(key) })
140+ Item(Icons.AutoMirrored.Filled.DriveFileMove, "Move to…", run { vm.push(Screen.MoveTo(key, node.title)) })
141+ Item(Icons.Filled.Archive, "Archive", run { vm.archive(key) })
142+ HorizontalDivider()
143+ Item(Icons.Filled.ContentCopy, "Copy", run { vm.copy(key) })
144+ if (copied != null) {
145+ Item(Icons.Filled.ContentPaste, "Paste $copied after", run { vm.paste(key, after = true) })
146+ Item(Icons.Filled.ContentPaste, "Paste $copied before", run { vm.paste(key, after = false) })
147+ }
148+ Item(Icons.Filled.Code, "Source", run { vm.showSource(key) })
149+ Item(Icons.Filled.Delete, "Delete", run { vm.delete(key) }, tint = MaterialTheme.colorScheme.error)
150+ if (node.paired) {
151+ HorizontalDivider()
152+ Item(Icons.Filled.Warning, "Resolve conflict…", run { vm.push(Screen.Conflicts) }, tint = c.warn)
153+ }
154+ }
155+ }
156+}
157+
158+@Composable
159+private fun Item(icon: ImageVector, label: String, onClick: () -> Unit, tint: androidx.compose.ui.graphics.Color? = null) {
160+ ListItem(
161+ headlineContent = { Text(label, color = tint ?: MaterialTheme.colorScheme.onSurface) },
162+ leadingContent = { Icon(icon, null, tint = tint ?: MaterialTheme.colorScheme.onSurfaceVariant) },
163+ colors = ListItemDefaults.colors(containerColor = androidx.compose.ui.graphics.Color.Transparent),
164+ modifier = Modifier.clickable(onClick = onClick),
165+ )
166+}
167+
168+/**
169+ * Capture (§7), or a new node beside or under another (§10.3 `n` / `N`):
170+ * a title, and whether it is a task. Capture takes more lines: the first is
171+ * the title, the rest its text.
172+ */
173+@Composable
174+fun InputSheet(vm: FoldViewModel, req: Input) {
175+ val state = rememberModalBottomSheetState(skipPartiallyExpanded = true)
176+ var value by remember(req) { mutableStateOf(TextFieldValue(req.text, TextRange(req.text.length))) }
177+ var task by rememberSaveable(req) { mutableStateOf(req.task) }
178+ val focus = remember { FocusRequester() }
179+ val capture = req.kind == Input.Kind.Capture
180+ val submit = {
181+ if (value.text.isNotBlank()) vm.submitInput(value.text, task)
182+ }
183+ LaunchedEffect(req) { focus.requestFocus() }
184+ ModalBottomSheet(onDismissRequest = { vm.dismissInput() }, sheetState = state) {
185+ Column(Modifier.padding(horizontal = 20.dp).padding(bottom = 16.dp).imePadding()) {
186+ Text(
187+ when (req.kind) {
188+ Input.Kind.Capture -> "Capture to the inbox"
189+ Input.Kind.Child -> "New child"
190+ Input.Kind.Sibling -> "New sibling"
191+ },
192+ style = MaterialTheme.typography.titleMedium,
193+ )
194+ OutlinedTextField(
195+ value = value,
196+ onValueChange = { value = if (capture) it else it.copy(text = it.text.replace("\n", " ")) },
197+ placeholder = { Text(if (capture) "What's on your mind?" else "Title") },
198+ singleLine = !capture,
199+ maxLines = if (capture) 8 else 1,
200+ keyboardOptions = KeyboardOptions(
201+ capitalization = KeyboardCapitalization.Sentences,
202+ imeAction = if (capture) ImeAction.Default else ImeAction.Done,
203+ ),
204+ keyboardActions = KeyboardActions(onDone = { submit() }),
205+ modifier = Modifier.fillMaxWidth().padding(top = 12.dp).focusRequester(focus),
206+ )
207+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
208+ Row(
209+ verticalAlignment = Alignment.CenterVertically,
210+ modifier = Modifier.weight(1f).clickable { task = !task },
211+ ) {
212+ Checkbox(checked = task, onCheckedChange = { task = it })
213+ Text("Task")
214+ }
215+ TextButton(onClick = { vm.dismissInput() }) { Text("Cancel") }
216+ Spacer(Modifier.width(4.dp))
217+ TextButton(onClick = submit, enabled = value.text.isNotBlank()) {
218+ Text(if (capture) "Capture" else "Add")
219+ }
220+ }
221+ }
222+ }
223+}
android/app/src/main/java/soy/iko/fold/ui/Theme.ktnew file+127
@@ -0,0 +1,127 @@
1+package soy.iko.fold.ui
2+
3+import android.os.Build
4+import androidx.compose.foundation.isSystemInDarkTheme
5+import androidx.compose.material3.ColorScheme
6+import androidx.compose.material3.MaterialTheme
7+import androidx.compose.material3.darkColorScheme
8+import androidx.compose.material3.dynamicDarkColorScheme
9+import androidx.compose.material3.dynamicLightColorScheme
10+import androidx.compose.material3.lightColorScheme
11+import androidx.compose.runtime.Composable
12+import androidx.compose.runtime.Immutable
13+import androidx.compose.runtime.staticCompositionLocalOf
14+import androidx.compose.ui.graphics.Color
15+import androidx.compose.ui.platform.LocalContext
16+
17+// Ink on paper, with the amber of the fold marker.
18+private val Light = lightColorScheme(
19+ primary = Color(0xFF2E3A4A),
20+ onPrimary = Color(0xFFFFFFFF),
21+ primaryContainer = Color(0xFFD9E2EF),
22+ onPrimaryContainer = Color(0xFF16202C),
23+ secondary = Color(0xFF8A5A1C),
24+ onSecondary = Color(0xFFFFFFFF),
25+ secondaryContainer = Color(0xFFF6DFBF),
26+ onSecondaryContainer = Color(0xFF2C1A04),
27+ tertiary = Color(0xFF3D6B5A),
28+ background = Color(0xFFFBFAF7),
29+ onBackground = Color(0xFF1B1C1E),
30+ surface = Color(0xFFFBFAF7),
31+ onSurface = Color(0xFF1B1C1E),
32+ surfaceVariant = Color(0xFFE6E3DC),
33+ onSurfaceVariant = Color(0xFF4A4740),
34+ surfaceContainerLowest = Color(0xFFFFFFFF),
35+ surfaceContainerLow = Color(0xFFF5F3EE),
36+ surfaceContainer = Color(0xFFEFEDE7),
37+ surfaceContainerHigh = Color(0xFFE9E7E1),
38+ surfaceContainerHighest = Color(0xFFE3E1DB),
39+ outline = Color(0xFF7B776F),
40+ outlineVariant = Color(0xFFCCC8BF),
41+ error = Color(0xFFB3261E),
42+)
43+
44+private val Dark = darkColorScheme(
45+ primary = Color(0xFFB7C7DC),
46+ onPrimary = Color(0xFF1E2A38),
47+ primaryContainer = Color(0xFF34414F),
48+ onPrimaryContainer = Color(0xFFD9E2EF),
49+ secondary = Color(0xFFE0A458),
50+ onSecondary = Color(0xFF3F2700),
51+ secondaryContainer = Color(0xFF5B3D10),
52+ onSecondaryContainer = Color(0xFFF6DFBF),
53+ tertiary = Color(0xFF9ED1BB),
54+ background = Color(0xFF15181C),
55+ onBackground = Color(0xFFE3E2DF),
56+ surface = Color(0xFF15181C),
57+ onSurface = Color(0xFFE3E2DF),
58+ surfaceVariant = Color(0xFF3F4246),
59+ onSurfaceVariant = Color(0xFFC3C2BD),
60+ surfaceContainerLowest = Color(0xFF101215),
61+ surfaceContainerLow = Color(0xFF1B1E22),
62+ surfaceContainer = Color(0xFF1F2226),
63+ surfaceContainerHigh = Color(0xFF292C30),
64+ surfaceContainerHighest = Color(0xFF34373B),
65+ outline = Color(0xFF8D8C88),
66+ outlineVariant = Color(0xFF45474B),
67+ error = Color(0xFFF2B8B5),
68+)
69+
70+/** The few colours fold means something by, beyond the scheme's roles. */
71+@Immutable
72+data class FoldColors(
73+ val accent: Color,
74+ val done: Color,
75+ val dim: Color,
76+ val warn: Color,
77+ val code: Color,
78+ val codeBackground: Color,
79+ val quote: Color,
80+ val link: Color,
81+ val highlight: Color,
82+ val guide: Color,
83+)
84+
85+val LocalFoldColors = staticCompositionLocalOf {
86+ FoldColors(
87+ Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified,
88+ Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified,
89+ )
90+}
91+
92+private fun foldColors(scheme: ColorScheme, dark: Boolean) = FoldColors(
93+ accent = if (dark) Color(0xFFE0A458) else Color(0xFFB0711F),
94+ done = scheme.onSurface.copy(alpha = 0.45f),
95+ dim = scheme.onSurfaceVariant.copy(alpha = 0.8f),
96+ warn = if (dark) Color(0xFFFFB86B) else Color(0xFFC25E00),
97+ code = if (dark) Color(0xFFB5D3B5) else Color(0xFF2F5D3A),
98+ codeBackground = if (dark) Color(0xFF22262B) else Color(0xFFF0EEE8),
99+ quote = if (dark) Color(0xFF9DB7D5) else Color(0xFF4A6688),
100+ link = if (dark) Color(0xFF8FC1FF) else Color(0xFF1D5FAD),
101+ highlight = if (dark) Color(0x40E0A458) else Color(0x33E0A458),
102+ guide = scheme.outlineVariant.copy(alpha = 0.7f),
103+)
104+
105+@Composable
106+fun FoldTheme(content: @Composable () -> Unit) {
107+ val dark = isSystemInDarkTheme()
108+ val context = LocalContext.current
109+ val scheme = when {
110+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && dark -> dynamicDarkColorScheme(context)
111+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> dynamicLightColorScheme(context)
112+ dark -> Dark
113+ else -> Light
114+ }
115+ androidx.compose.runtime.CompositionLocalProvider(LocalFoldColors provides foldColors(scheme, dark)) {
116+ MaterialTheme(colorScheme = scheme, content = content)
117+ }
118+}
119+
120+/** The fixed palette, for previews and screenshots that must not vary. */
121+@Composable
122+fun FoldThemeFixed(dark: Boolean = false, content: @Composable () -> Unit) {
123+ val scheme = if (dark) Dark else Light
124+ androidx.compose.runtime.CompositionLocalProvider(LocalFoldColors provides foldColors(scheme, dark)) {
125+ MaterialTheme(colorScheme = scheme, content = content)
126+ }
127+}
android/app/src/main/java/soy/iko/fold/ui/VaultScreen.ktnew file+145
@@ -0,0 +1,145 @@
1+@file:OptIn(ExperimentalMaterial3Api::class)
2+
3+package soy.iko.fold.ui
4+
5+import android.Manifest
6+import android.net.Uri
7+import androidx.activity.compose.rememberLauncherForActivityResult
8+import androidx.activity.result.contract.ActivityResultContracts
9+import androidx.compose.foundation.clickable
10+import androidx.compose.foundation.layout.Arrangement
11+import androidx.compose.foundation.layout.Column
12+import androidx.compose.foundation.layout.fillMaxSize
13+import androidx.compose.foundation.layout.fillMaxWidth
14+import androidx.compose.foundation.layout.padding
15+import androidx.compose.foundation.rememberScrollState
16+import androidx.compose.foundation.shape.RoundedCornerShape
17+import androidx.compose.foundation.verticalScroll
18+import androidx.compose.material.icons.Icons
19+import androidx.compose.material.icons.filled.Folder
20+import androidx.compose.material.icons.filled.FolderOpen
21+import androidx.compose.material.icons.filled.PhoneAndroid
22+import androidx.compose.material3.Button
23+import androidx.compose.material3.ExperimentalMaterial3Api
24+import androidx.compose.material3.Icon
25+import androidx.compose.material3.ListItem
26+import androidx.compose.material3.MaterialTheme
27+import androidx.compose.material3.OutlinedButton
28+import androidx.compose.material3.OutlinedTextField
29+import androidx.compose.material3.Scaffold
30+import androidx.compose.material3.Surface
31+import androidx.compose.material3.Text
32+import androidx.compose.material3.TextButton
33+import androidx.compose.material3.TopAppBar
34+import androidx.compose.runtime.Composable
35+import androidx.compose.runtime.getValue
36+import androidx.compose.runtime.mutableStateOf
37+import androidx.compose.runtime.remember
38+import androidx.compose.runtime.setValue
39+import androidx.compose.ui.Modifier
40+import androidx.compose.ui.platform.LocalContext
41+import androidx.compose.ui.unit.dp
42+import soy.iko.fold.FoldViewModel
43+import soy.iko.fold.Storage
44+
45+/**
46+ * Choosing a vault: a folder Syncthing keeps in step with the desktop, or
47+ * one in the app's own storage. fold needs "all files access" for a folder
48+ * in shared storage, as any app that edits files in place there does.
49+ */
50+@Composable
51+fun VaultScreen(vm: FoldViewModel, onBack: (() -> Unit)? = null) {
52+ val context = LocalContext.current
53+ val c = LocalFoldColors.current
54+ var typed by remember { mutableStateOf("") }
55+ var pickError by remember { mutableStateOf<String?>(null) }
56+ val pick = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
57+ if (uri == null) return@rememberLauncherForActivityResult
58+ val path = Storage.pathOfTree(uri)
59+ if (path == null) {
60+ pickError = "That folder is not on this phone's storage; pick a local folder, such as the one Syncthing syncs."
61+ } else {
62+ pickError = null
63+ vm.open(path)
64+ }
65+ }
66+ // Android 10 and older grant storage with a runtime permission
67+ val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { vm.retryAccess() }
68+ Scaffold(
69+ topBar = {
70+ TopAppBar(
71+ title = { Text(if (onBack == null) "fold" else "Vaults") },
72+ navigationIcon = {
73+ if (onBack != null) TextButton(onClick = onBack) { Text("Back") }
74+ },
75+ )
76+ },
77+ ) { padding ->
78+ Column(
79+ Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp),
80+ verticalArrangement = Arrangement.spacedBy(16.dp),
81+ ) {
82+ Text(
83+ "One outline of notes and tasks, stored as plain Markdown. Open the folder your fold vault syncs to — the same root.md and block files the desktop app uses.",
84+ style = MaterialTheme.typography.bodyLarge,
85+ )
86+ vm.waitingForAccess?.let { path ->
87+ Surface(shape = RoundedCornerShape(12.dp), color = c.warn.copy(alpha = 0.14f)) {
88+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
89+ Text("fold needs access to ${Storage.shown(path)}", style = MaterialTheme.typography.titleSmall)
90+ Text(
91+ "It reads and writes the vault's files in place, as Syncthing does, so it needs \"all files access\". It touches nothing outside the vault but its own trash.",
92+ style = MaterialTheme.typography.bodyMedium,
93+ )
94+ Button(onClick = {
95+ if (Storage.accessFromSettings) {
96+ context.startActivity(Storage.accessSettings(context))
97+ } else {
98+ permission.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
99+ }
100+ }) { Text("Grant access") }
101+ TextButton(onClick = { vm.retryAccess() }) { Text("I've granted it — open the vault") }
102+ }
103+ }
104+ }
105+ vm.vaultError?.let { Text("Could not open the vault: $it", color = MaterialTheme.colorScheme.error) }
106+ pickError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
107+ Button(onClick = { pick.launch(null) }, modifier = Modifier.fillMaxWidth()) {
108+ Icon(Icons.Filled.FolderOpen, null)
109+ Text(" Choose the vault folder")
110+ }
111+ OutlinedButton(onClick = { vm.usePrivateVault() }, modifier = Modifier.fillMaxWidth()) {
112+ Icon(Icons.Filled.PhoneAndroid, null)
113+ Text(" Keep a vault on this phone only")
114+ }
115+ OutlinedTextField(
116+ value = typed,
117+ onValueChange = { typed = it },
118+ label = { Text("Or type its path") },
119+ placeholder = { Text("/storage/emulated/0/Sync/fold") },
120+ singleLine = true,
121+ modifier = Modifier.fillMaxWidth(),
122+ )
123+ if (typed.isNotBlank()) {
124+ Button(onClick = { vm.open(typed.trim()) }) { Text("Open") }
125+ }
126+ val recent = vm.recent
127+ if (recent.isNotEmpty()) {
128+ Text("Recent", style = MaterialTheme.typography.titleSmall, color = c.dim)
129+ for (path in recent) {
130+ ListItem(
131+ modifier = Modifier.clickable { vm.open(path) },
132+ leadingContent = { Icon(Icons.Filled.Folder, null) },
133+ headlineContent = { Text(Storage.shown(path)) },
134+ supportingContent = { if (path == vm.vault) Text("open now", color = c.accent) },
135+ )
136+ }
137+ }
138+ Text(
139+ "An empty folder becomes a new vault with an Inbox. Point Syncthing's .stignore at *.fold-tmp and *.tmp so half-written files never sync.",
140+ style = MaterialTheme.typography.bodySmall,
141+ color = c.dim,
142+ )
143+ }
144+ }
145+}
android/app/src/main/res/mipmap-anydpi/ic_launcher.xmlnew file+7
@@ -0,0 +1,7 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
3+ <background android:drawable="@color/icon_background" />
4+ <foreground android:drawable="@mipmap/ic_launcher_foreground" />
5+ <!-- themed icons (Android 13+): the shape's silhouette, tinted by the system -->
6+ <monochrome android:drawable="@mipmap/ic_launcher_monochrome" />
7+</adaptive-icon>
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webpnew file

Binary file not shown.

android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webpnew file

Binary file not shown.

android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webpnew file

Binary file not shown.

android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webpnew file

Binary file not shown.

android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webpnew file

Binary file not shown.

android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webpnew file

Binary file not shown.

android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webpnew file

Binary file not shown.

android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webpnew file

Binary file not shown.

android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webpnew file

Binary file not shown.

android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webpnew file

Binary file not shown.

android/app/src/main/res/values-night/colors.xmlnew file+4
@@ -0,0 +1,4 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <color name="background">#FF15181C</color>
4+</resources>
android/app/src/main/res/values-night/themes.xmlnew file+8
@@ -0,0 +1,8 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <style name="Theme.Fold" parent="android:Theme.Material.NoActionBar">
4+ <item name="android:windowBackground">@color/background</item>
5+ <item name="android:statusBarColor">@android:color/transparent</item>
6+ <item name="android:navigationBarColor">@android:color/transparent</item>
7+ </style>
8+</resources>
android/app/src/main/res/values/colors.xmlnew file+5
@@ -0,0 +1,5 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <color name="background">#FFFBFAF7</color>
4+ <color name="icon_background">#FFFFFFFF</color>
5+</resources>
android/app/src/main/res/values/strings.xmlnew file+7
@@ -0,0 +1,7 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <string name="app_name">fold</string>
4+ <string name="capture_to_fold">Capture to fold</string>
5+ <string name="shortcut_capture">Capture</string>
6+ <string name="shortcut_capture_long">Capture to the inbox</string>
7+</resources>
android/app/src/main/res/values/themes.xmlnew file+8
@@ -0,0 +1,8 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <style name="Theme.Fold" parent="android:Theme.Material.Light.NoActionBar">
4+ <item name="android:windowBackground">@color/background</item>
5+ <item name="android:statusBarColor">@android:color/transparent</item>
6+ <item name="android:navigationBarColor">@android:color/transparent</item>
7+ </style>
8+</resources>
android/app/src/main/res/xml/shortcuts.xmlnew file+14
@@ -0,0 +1,14 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
3+ <shortcut
4+ android:enabled="true"
5+ android:icon="@mipmap/ic_launcher"
6+ android:shortcutId="capture"
7+ android:shortcutLongLabel="@string/shortcut_capture_long"
8+ android:shortcutShortLabel="@string/shortcut_capture">
9+ <intent
10+ android:action="soy.iko.fold.CAPTURE"
11+ android:targetClass="soy.iko.fold.MainActivity"
12+ android:targetPackage="soy.iko.fold" />
13+ </shortcut>
14+</shortcuts>
android/app/src/test/java/soy/iko/fold/CoreTest.ktnew file+99
@@ -0,0 +1,99 @@
1+package soy.iko.fold
2+
3+import java.io.File
4+import java.nio.file.Files
5+import org.junit.Assert.assertEquals
6+import org.junit.Assert.assertFalse
7+import org.junit.Assert.assertNotNull
8+import org.junit.Assert.assertTrue
9+import org.junit.Before
10+import org.junit.Test
11+import soy.iko.fold.core.OutlineQuery
12+import soy.iko.fold.core.Session
13+import soy.iko.fold.core.Spelling
14+import soy.iko.fold.core.Task
15+
16+/**
17+ * The Kotlin bindings over the Rust core, as the app calls them: the host
18+ * build of `crates/fold-ffi`, loaded through JNA.
19+ */
20+class CoreTest {
21+ private lateinit var dir: File
22+
23+ private val all = OutlineQuery(zoom = null, folded = emptyList(), unfolded = emptyList(), hideDone = false)
24+
25+ @Before
26+ fun vault() {
27+ dir = Files.createTempDirectory("fold").toFile()
28+ dir.resolve("root.md").writeText(ROOT)
29+ dir.resolve("racfer~order-new-switch.md").writeText(BLOCK)
30+ }
31+
32+ @Test
33+ fun outlineReadsTheSameFilesTheDesktopWrites() {
34+ val s = Session.open(dir.path)
35+ val rows = s.outline(all).rows
36+ assertEquals(
37+ listOf("Homelab", "NAS", "Replace fan", "Networking", "Order new switch", "Inbox"),
38+ rows.map { it.title },
39+ )
40+ val switch = rows.first { it.title == "Order new switch" }
41+ assertTrue(switch.block)
42+ assertEquals("2026-09-20", switch.due)
43+ assertEquals(Task.OPEN, switch.task)
44+ assertEquals(Spelling.SECTION, rows.first().spelling)
45+ assertEquals("Two boxes.", rows.first().preview)
46+ s.destroy()
47+ }
48+
49+ @Test
50+ fun verbsWriteMarkdownAndUndo() {
51+ val s = Session.open(dir.path)
52+ val switch = s.outline(all).rows.first { it.title == "Order new switch" }
53+ val r = s.toggleTask(switch.key)
54+ assertTrue(r.message, r.ok)
55+ val block = dir.resolve("racfer~order-new-switch.md").readText()
56+ assertTrue(block, block.contains("- [x] Order new switch"))
57+ assertTrue(block, block.contains("done: "))
58+ assertTrue(s.undo(null).ok)
59+ assertEquals(BLOCK, dir.resolve("racfer~order-new-switch.md").readText())
60+ assertEquals(ROOT, dir.resolve("root.md").readText())
61+
62+ val captured = s.capture("call the plumber", true)
63+ assertTrue(captured.message, captured.ok)
64+ assertTrue(dir.resolve("root.md").readText().contains("- [ ] call the plumber\n"))
65+ s.destroy()
66+ }
67+
68+ @Test
69+ fun theEditorSavesEachBlockToItsFile() {
70+ val s = Session.open(dir.path)
71+ val net = s.outline(all).rows.first { it.title == "Networking" }
72+ val view = s.editOpen(net.key)
73+ assertEquals("# Networking\n\n- [ ] Order new switch\n Two options.", view.text)
74+ val text = view.text.replace("Two options.", "Two options, both cheap.")
75+ assertTrue(s.editUpdate(text, null, view.generation).dirty)
76+ val saved = s.editClose()
77+ assertTrue(saved.message, saved.ok)
78+ assertEquals(ROOT, dir.resolve("root.md").readText())
79+ assertTrue(dir.resolve("racfer~order-new-switch.md").readText().endsWith(" Two options, both cheap.\n"))
80+ s.destroy()
81+ }
82+
83+ @Test
84+ fun aChangeFromOutsideIsTakenIn() {
85+ val s = Session.open(dir.path)
86+ assertFalse(s.refresh(false).changed)
87+ dir.resolve("root.md").appendText("\n- from the phone\n")
88+ val r = s.refresh(false)
89+ assertTrue(r.changed)
90+ assertNotNull(r.message)
91+ assertTrue(s.outline(all).rows.any { it.title == "from the phone" })
92+ s.destroy()
93+ }
94+
95+ companion object {
96+ const val ROOT = "# Homelab\n\nTwo boxes.\n\n## NAS\n\n- [ ] Replace fan\n\n## Networking\n\n![[racfer-hattes-mislup-nodrys]]\n\n# Inbox\n"
97+ const val BLOCK = "---\nid: racfer-hattes-mislup-nodrys\ndue: 2026-09-20\n---\n\n- [ ] Order new switch\n Two options.\n"
98+ }
99+}
android/app/src/test/java/soy/iko/fold/UiTest.ktnew file+331
@@ -0,0 +1,331 @@
1+package soy.iko.fold
2+
3+import android.app.Application
4+import androidx.compose.ui.test.junit4.v2.createComposeRule
5+import androidx.compose.ui.test.filter
6+import androidx.compose.ui.test.onFirst
7+import androidx.compose.ui.test.hasSetTextAction
8+import androidx.compose.ui.test.longClick
9+import androidx.compose.ui.test.onAllNodesWithText
10+import androidx.compose.ui.test.onNodeWithContentDescription
11+import androidx.compose.ui.test.performScrollTo
12+import androidx.compose.ui.test.performTextInput
13+import androidx.compose.ui.test.onNodeWithText
14+import androidx.compose.ui.test.performClick
15+import androidx.compose.ui.test.performTouchInput
16+import androidx.test.core.app.ApplicationProvider
17+import com.github.takahirom.roborazzi.ExperimentalRoborazziApi
18+import com.github.takahirom.roborazzi.captureRoboImage
19+import com.github.takahirom.roborazzi.captureScreenRoboImage
20+import java.io.File
21+import org.junit.Assert.assertTrue
22+import org.junit.Rule
23+import org.junit.Test
24+import org.junit.runner.RunWith
25+import org.robolectric.RobolectricTestRunner
26+import org.robolectric.annotation.Config
27+import org.robolectric.annotation.GraphicsMode
28+import soy.iko.fold.ui.FoldThemeFixed
29+import soy.iko.fold.ui.Root
30+
31+/**
32+ * The app on a sample vault, driven as a person would, with a screenshot of
33+ * each screen (`./gradlew recordRoborazziDebug` writes them under
34+ * app/build/outputs/roborazzi).
35+ */
36+@RunWith(RobolectricTestRunner::class)
37+@GraphicsMode(GraphicsMode.Mode.NATIVE)
38+@Config(sdk = [35], qualifiers = "w411dp-h891dp-xxhdpi")
39+class UiTest {
40+ @get:Rule val compose = createComposeRule()
41+
42+ private val app: Application get() = ApplicationProvider.getApplicationContext()
43+
44+ private fun vault(): File {
45+ // each test has a files directory of its own
46+ val dir = File(app.filesDir, "fold")
47+ dir.mkdirs()
48+ Sample.write(dir)
49+ return dir
50+ }
51+
52+ private fun start(dark: Boolean = false, dir: File = vault()): FoldViewModel {
53+ val vm = FoldViewModel(app)
54+ vm.open(dir.path)
55+ compose.setContent { FoldThemeFixed(dark) { Root(vm) } }
56+ await { vm.outline != null }
57+ compose.waitForIdle()
58+ return vm
59+ }
60+
61+ /**
62+ * Wait for what the view model does off the main thread: its results
63+ * come back as posts to the main looper, which Robolectric runs only
64+ * when it is idled (Compose's waitUntil sleeps without idling it).
65+ */
66+ private fun await(timeout: Long = 10_000, what: () -> Boolean) {
67+ val end = System.currentTimeMillis() + timeout
68+ while (!what()) {
69+ check(System.currentTimeMillis() < end) { "timed out" }
70+ compose.waitForIdle()
71+ Thread.sleep(10)
72+ }
73+ compose.waitForIdle()
74+ }
75+
76+ @OptIn(ExperimentalRoborazziApi::class)
77+ private fun shot(name: String) = captureScreenRoboImage("build/outputs/roborazzi/$name.png")
78+
79+ @Test
80+ fun outline() {
81+ start()
82+ compose.onNodeWithText("Homelab").assertExists()
83+ compose.onNodeWithText("Order new switch").assertExists()
84+ shot("1-outline")
85+ }
86+
87+ @Test
88+ fun outlineDark() {
89+ start(dark = true)
90+ shot("2-outline-dark")
91+ }
92+
93+ @Test
94+ fun zoomAndCheckOff() {
95+ val vm = start()
96+ compose.onNodeWithText("Networking").performClick()
97+ await(5_000) { vm.outline?.zoom?.title == "Networking" }
98+ compose.waitForIdle()
99+ shot("3-zoomed")
100+ // checking a task off writes its checkbox in root.md
101+ val root = File(vm.vault!!, "root.md")
102+ val key = vm.outline!!.rows.first { it.title == "Replace the flaky switch" }.key
103+ vm.toggleTask(key)
104+ await(5_000) { root.readText().contains("- [x] Replace the flaky switch") }
105+ }
106+
107+ @Test
108+ fun nodeMenu() {
109+ val vm = start()
110+ compose.onNodeWithText("Replace the flaky switch").performTouchInput { longClick() }
111+ await(5_000) { vm.menu != null }
112+ compose.waitForIdle()
113+ shot("4-node-menu")
114+ }
115+
116+ @Test
117+ fun editor() {
118+ val vm = start()
119+ val key = vm.outline!!.rows.first { it.title == "Networking" }.key
120+ vm.edit(key)
121+ await(5_000) { vm.editor != null }
122+ compose.waitForIdle()
123+ shot("5-editor")
124+ assertTrue(vm.editor!!.value.text.startsWith("# Networking"))
125+ }
126+
127+ @Test
128+ fun reading() {
129+ val vm = start()
130+ val key = vm.outline!!.rows.first { it.title == "Homelab" }.key
131+ vm.push(Screen.Reading(key))
132+ await(5_000) { vm.reading.isNotEmpty() }
133+ compose.waitForIdle()
134+ shot("6-reading")
135+ }
136+
137+ @Test
138+ fun capture() {
139+ val vm = start()
140+ compose.onNodeWithText("Capture", useUnmergedTree = true).performClick()
141+ await(5_000) { vm.input != null }
142+ compose.waitForIdle()
143+ shot("7-capture")
144+ }
145+
146+ @Test
147+ fun conflicts() {
148+ val dir = vault()
149+ File(dir, "root.sync-conflict-20260930-181500-PHONE.md").writeText(
150+ File(dir, "root.md").readText().replace("Two boxes in the closet, one at Hetzner.", "Two boxes in the closet, one at Hetzner, one at mum's."),
151+ )
152+ val vm = start(dir = dir)
153+ await(5_000) { (vm.outline?.conflicts ?: 0u) > 0u }
154+ compose.waitForIdle()
155+ shot("8-outline-conflict")
156+ vm.push(Screen.Conflicts)
157+ await(5_000) { vm.conflicts.isNotEmpty() }
158+ compose.waitForIdle()
159+ shot("9-conflicts")
160+ }
161+
162+ @Test
163+ fun properties() {
164+ val vm = start()
165+ val key = vm.outline!!.rows.first { it.title == "Order new switch" }.key
166+ vm.push(Screen.Properties(key))
167+ await(5_000) { vm.props.isNotEmpty() }
168+ compose.waitForIdle()
169+ shot("10-properties")
170+ }
171+
172+ @Test
173+ fun searchAndReveal() {
174+ val vm = start()
175+ compose.onNodeWithContentDescription("Find").performClick()
176+ await { vm.screens.lastOrNull() == Screen.Search }
177+ compose.onNode(hasSetTextAction()).performTextInput("plumb")
178+ await { vm.hits.any { it.title == "Call the plumber" } }
179+ shot("11-search")
180+ compose.onNodeWithText("Call the plumber").performClick()
181+ // shown where it is: the outline zoomed to its day, the row flashing
182+ await { vm.outline?.zoom?.title == "2026-09-30" }
183+ assertTrue(vm.outline!!.rows.any { it.title == "Call the plumber" })
184+ }
185+
186+ @Test
187+ fun moveTo() {
188+ val vm = start()
189+ val root = File(vm.vault!!, "root.md")
190+ compose.onNodeWithText("Replace the flaky switch").performTouchInput { longClick() }
191+ await { vm.menu != null }
192+ compose.onNodeWithText("Move to…").performScrollTo().performClick()
193+ await { vm.screens.lastOrNull() is Screen.MoveTo }
194+ compose.onNode(hasSetTextAction()).performTextInput("Inbox")
195+ await { vm.hits.firstOrNull()?.title == "Inbox" }
196+ shot("12-move-to")
197+ // the hit itself, first; under it, a hit whose path is Inbox
198+ compose.onAllNodesWithText("Inbox").filter(!hasSetTextAction()).onFirst().performClick()
199+ // under Inbox now, before its day sections (§3.1), and out of Networking
200+ await { root.readText().indexOf("- [ ] Replace the flaky switch") > root.readText().indexOf("# Inbox") }
201+ assertTrue(root.readText().contains("## Networking\n\n![[racfer-hattes-mislup-nodrys]]"))
202+ await { vm.screens.isEmpty() }
203+ }
204+
205+ @Test
206+ fun typeAndSave() {
207+ val vm = start()
208+ val key = vm.outline!!.rows.first { it.title == "Call the plumber" }.key
209+ vm.edit(key)
210+ await { vm.editor != null }
211+ compose.onNode(hasSetTextAction()).performTextInput("Today: ")
212+ compose.onNodeWithContentDescription("Done").performClick()
213+ await { vm.editor == null }
214+ // typed at the caret, the start of the node's text: before its marker
215+ val root = File(vm.vault!!, "root.md").readText()
216+ assertTrue(root, root.contains("Today: - [ ] Call the plumber\n"))
217+ }
218+
219+ @Test
220+ fun captureWhileEditing() {
221+ val vm = start()
222+ val root = File(vm.vault!!, "root.md")
223+ vm.edit(vm.outline!!.rows.first { it.title == "Call the plumber" }.key)
224+ await { vm.editor != null }
225+ // text shared to fold while the editor is open: the capture saves
226+ // the editor first, and the editor takes typing after it
227+ vm.captureFromOutside("from the share sheet")
228+ await { vm.input != null }
229+ vm.submitInput("from the share sheet", false)
230+ await { root.readText().contains("- from the share sheet") }
231+ compose.onNode(hasSetTextAction()).performTextInput("Today: ")
232+ compose.onNodeWithContentDescription("Done").performClick()
233+ await { vm.editor == null }
234+ assertTrue(root.readText(), root.readText().contains("Today: - [ ] Call the plumber\n"))
235+ }
236+
237+ @Test
238+ fun launcherIcon() {
239+ // the adaptive icon as a launcher draws it, through a round mask
240+ val icon = app.getDrawable(R.mipmap.ic_launcher) as android.graphics.drawable.AdaptiveIconDrawable
241+ val size = 432
242+ val bitmap = android.graphics.Bitmap.createBitmap(size, size, android.graphics.Bitmap.Config.ARGB_8888)
243+ val canvas = android.graphics.Canvas(bitmap)
244+ val round = android.graphics.Path().apply { addCircle(size / 2f, size / 2f, size / 2f, android.graphics.Path.Direction.CW) }
245+ canvas.clipPath(round)
246+ icon.setBounds(0, 0, size, size)
247+ icon.draw(canvas)
248+ assertTrue(icon.monochrome != null)
249+ bitmap.captureRoboImage("build/outputs/roborazzi/13-launcher-icon.png")
250+ }
251+
252+ @Test
253+ fun noVaultYet() {
254+ val vm = FoldViewModel(app)
255+ compose.setContent { FoldThemeFixed { Root(vm) } }
256+ compose.waitForIdle()
257+ shot("0-choose-vault")
258+ }
259+}
260+
261+/** A vault as the desktop app writes one (SPEC.md §4.10). */
262+object Sample {
263+ fun write(dir: File) {
264+ File(dir, "root.md").writeText(
265+ """
266+ |# Homelab
267+ |
268+ |Two boxes in the closet, one at Hetzner.
269+ |
270+ |## NAS
271+ |
272+ |### ![[dozzod-binwes-talsun-worbec]]
273+ |
274+ |## Networking
275+ |
276+ |- [ ] Replace the flaky switch
277+ |![[racfer-hattes-mislup-nodrys]]
278+ |- [x] Label the cables
279+ |
280+ |# Reading list
281+ |
282+ |- **The Mythical Man-Month** — Brooks
283+ |- *A Pattern Language*, Alexander et al.
284+ | Read the bits on [entrances](https://en.wikipedia.org/wiki/A_Pattern_Language).
285+ |
286+ |# Inbox
287+ |
288+ |## 2026-09-30
289+ |
290+ |- Talked to Anya about the venue.
291+ | ### Options
292+ | Warehouse on Ligovsky, or the old bakery. Both need a licence.
293+ |- [x] Send the deposit
294+ |- [ ] Call the plumber
295+ |""".trimMargin(),
296+ )
297+ File(dir, "dozzod~zfs-layout.md").writeText(
298+ """
299+ |---
300+ |id: dozzod-binwes-talsun-worbec
301+ |since: 2024-03
302+ |tags: [storage, homelab] # user-defined; the app preserves it and ignores it
303+ |---
304+ |
305+ |# ZFS layout
306+ |
307+ |Mirrored pairs, no raidz. Snapshots hourly via `sanoid`.
308+ |
309+ |## [ ] Snapshot policy
310+ |
311+ |- hourly, keep 24
312+ |- [x] Move scratch to its own dataset
313+ |
314+ |```sh
315+ |zfs list -t snapshot
316+ |```
317+ |""".trimMargin(),
318+ )
319+ File(dir, "racfer~order-new-switch.md").writeText(
320+ """
321+ |---
322+ |id: racfer-hattes-mislup-nodrys
323+ |due: 2026-10-04
324+ |---
325+ |
326+ |- [ ] Order new switch
327+ | Two options, noted under Networking.
328+ |""".trimMargin(),
329+ )
330+ }
331+}
android/build.gradle.ktsnew file+5
@@ -0,0 +1,5 @@
1+plugins {
2+ alias(libs.plugins.android.application) apply false
3+ alias(libs.plugins.kotlin.compose) apply false
4+ alias(libs.plugins.roborazzi) apply false
5+}
android/docs/editor.pngnew file

Binary file not shown.

android/docs/outline.pngnew file

Binary file not shown.

android/docs/reading.pngnew file

Binary file not shown.

android/docs/zoomed.pngnew file

Binary file not shown.

android/gradle.propertiesnew file+7
@@ -0,0 +1,7 @@
1+org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
2+org.gradle.caching=true
3+android.useAndroidX=true
4+android.nonTransitiveRClass=true
5+# The ABIs the Rust core is built for (comma-separated): phones, old
6+# phones, the emulator.
7+fold.abis=arm64-v8a,armeabi-v7a,x86_64
android/gradle/libs.versions.tomlnew file+40
@@ -0,0 +1,40 @@
1+[versions]
2+agp = "9.4.1"
3+kotlin = "2.4.20"
4+compose-bom = "2026.09.00"
5+material-icons = "1.7.8"
6+activity = "1.13.0"
7+lifecycle = "2.11.0"
8+core = "1.19.1"
9+coroutines = "1.11.0"
10+jna = "5.19.1"
11+junit = "4.13.2"
12+robolectric = "4.17"
13+roborazzi = "1.76.0"
14+androidx-test = "1.7.0"
15+
16+[libraries]
17+androidx-core = { module = "androidx.core:core-ktx", version.ref = "core" }
18+androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
19+androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
20+androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
21+compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
22+compose-ui = { module = "androidx.compose.ui:ui" }
23+compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
24+compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
25+compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
26+compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
27+compose-material3 = { module = "androidx.compose.material3:material3" }
28+compose-material-icons = { module = "androidx.compose.material:material-icons-extended", version.ref = "material-icons" }
29+kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
30+jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
31+junit = { module = "junit:junit", version.ref = "junit" }
32+robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
33+roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi" }
34+roborazzi-compose = { module = "io.github.takahirom.roborazzi:roborazzi-compose", version.ref = "roborazzi" }
35+androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" }
36+
37+[plugins]
38+android-application = { id = "com.android.application", version.ref = "agp" }
39+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
40+roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }
android/gradle/wrapper/gradle-wrapper.jarnew file

Binary file not shown.

android/gradle/wrapper/gradle-wrapper.propertiesnew file+8
@@ -0,0 +1,8 @@
1+distributionBase=GRADLE_USER_HOME
2+distributionPath=wrapper/dists
3+distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
4+networkTimeout=10000
5+validateDistributionUrl=true
6+zipStoreBase=GRADLE_USER_HOME
7+zipStorePath=wrapper/dists
8+distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c
android/gradlewnew file+251
@@ -0,0 +1,251 @@
1+#!/bin/sh
2+
3+#
4+# Copyright © 2015-2021 the original authors.
5+#
6+# Licensed under the Apache License, Version 2.0 (the "License");
7+# you may not use this file except in compliance with the License.
8+# You may obtain a copy of the License at
9+#
10+# https://www.apache.org/licenses/LICENSE-2.0
11+#
12+# Unless required by applicable law or agreed to in writing, software
13+# distributed under the License is distributed on an "AS IS" BASIS,
14+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+# See the License for the specific language governing permissions and
16+# limitations under the License.
17+#
18+# SPDX-License-Identifier: Apache-2.0
19+#
20+
21+##############################################################################
22+#
23+# Gradle start up script for POSIX generated by Gradle.
24+#
25+# Important for running:
26+#
27+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
28+# noncompliant, but you have some other compliant shell such as ksh or
29+# bash, then to run this script, type that shell name before the whole
30+# command line, like:
31+#
32+# ksh Gradle
33+#
34+# Busybox and similar reduced shells will NOT work, because this script
35+# requires all of these POSIX shell features:
36+# * functions;
37+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
38+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
39+# * compound commands having a testable exit status, especially «case»;
40+# * various built-in commands including «command», «set», and «ulimit».
41+#
42+# Important for patching:
43+#
44+# (2) This script targets any POSIX shell, so it avoids extensions provided
45+# by Bash, Ksh, etc; in particular arrays are avoided.
46+#
47+# The "traditional" practice of packing multiple parameters into a
48+# space-separated string is a well documented source of bugs and security
49+# problems, so this is (mostly) avoided, by progressively accumulating
50+# options in "$@", and eventually passing that to Java.
51+#
52+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
53+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
54+# see the in-line comments for details.
55+#
56+# There are tweaks for specific operating systems such as AIX, CygWin,
57+# Darwin, MinGW, and NonStop.
58+#
59+# (3) This script is generated from the Groovy template
60+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
61+# within the Gradle project.
62+#
63+# You can find Gradle at https://github.com/gradle/gradle/.
64+#
65+##############################################################################
66+
67+# Attempt to set APP_HOME
68+
69+# Resolve links: $0 may be a link
70+app_path=$0
71+
72+# Need this for daisy-chained symlinks.
73+while
74+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
75+ [ -h "$app_path" ]
76+do
77+ ls=$( ls -ld "$app_path" )
78+ link=${ls#*' -> '}
79+ case $link in #(
80+ /*) app_path=$link ;; #(
81+ *) app_path=$APP_HOME$link ;;
82+ esac
83+done
84+
85+# This is normally unused
86+# shellcheck disable=SC2034
87+APP_BASE_NAME=${0##*/}
88+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
89+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
90+
91+# Use the maximum available, or set MAX_FD != -1 to use that value.
92+MAX_FD=maximum
93+
94+warn () {
95+ echo "$*"
96+} >&2
97+
98+die () {
99+ echo
100+ echo "$*"
101+ echo
102+ exit 1
103+} >&2
104+
105+# OS specific support (must be 'true' or 'false').
106+cygwin=false
107+msys=false
108+darwin=false
109+nonstop=false
110+case "$( uname )" in #(
111+ CYGWIN* ) cygwin=true ;; #(
112+ Darwin* ) darwin=true ;; #(
113+ MSYS* | MINGW* ) msys=true ;; #(
114+ NONSTOP* ) nonstop=true ;;
115+esac
116+
117+CLASSPATH="\\\"\\\""
118+
119+
120+# Determine the Java command to use to start the JVM.
121+if [ -n "$JAVA_HOME" ] ; then
122+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
123+ # IBM's JDK on AIX uses strange locations for the executables
124+ JAVACMD=$JAVA_HOME/jre/sh/java
125+ else
126+ JAVACMD=$JAVA_HOME/bin/java
127+ fi
128+ if [ ! -x "$JAVACMD" ] ; then
129+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
130+
131+Please set the JAVA_HOME variable in your environment to match the
132+location of your Java installation."
133+ fi
134+else
135+ JAVACMD=java
136+ if ! command -v java >/dev/null 2>&1
137+ then
138+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
139+
140+Please set the JAVA_HOME variable in your environment to match the
141+location of your Java installation."
142+ fi
143+fi
144+
145+# Increase the maximum file descriptors if we can.
146+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
147+ case $MAX_FD in #(
148+ max*)
149+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
150+ # shellcheck disable=SC2039,SC3045
151+ MAX_FD=$( ulimit -H -n ) ||
152+ warn "Could not query maximum file descriptor limit"
153+ esac
154+ case $MAX_FD in #(
155+ '' | soft) :;; #(
156+ *)
157+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
158+ # shellcheck disable=SC2039,SC3045
159+ ulimit -n "$MAX_FD" ||
160+ warn "Could not set maximum file descriptor limit to $MAX_FD"
161+ esac
162+fi
163+
164+# Collect all arguments for the java command, stacking in reverse order:
165+# * args from the command line
166+# * the main class name
167+# * -classpath
168+# * -D...appname settings
169+# * --module-path (only if needed)
170+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
171+
172+# For Cygwin or MSYS, switch paths to Windows format before running java
173+if "$cygwin" || "$msys" ; then
174+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
175+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
176+
177+ JAVACMD=$( cygpath --unix "$JAVACMD" )
178+
179+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
180+ for arg do
181+ if
182+ case $arg in #(
183+ -*) false ;; # don't mess with options #(
184+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
185+ [ -e "$t" ] ;; #(
186+ *) false ;;
187+ esac
188+ then
189+ arg=$( cygpath --path --ignore --mixed "$arg" )
190+ fi
191+ # Roll the args list around exactly as many times as the number of
192+ # args, so each arg winds up back in the position where it started, but
193+ # possibly modified.
194+ #
195+ # NB: a `for` loop captures its iteration list before it begins, so
196+ # changing the positional parameters here affects neither the number of
197+ # iterations, nor the values presented in `arg`.
198+ shift # remove old arg
199+ set -- "$@" "$arg" # push replacement arg
200+ done
201+fi
202+
203+
204+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
205+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
206+
207+# Collect all arguments for the java command:
208+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
209+# and any embedded shellness will be escaped.
210+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
211+# treated as '${Hostname}' itself on the command line.
212+
213+set -- \
214+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
215+ -classpath "$CLASSPATH" \
216+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
217+ "$@"
218+
219+# Stop when "xargs" is not available.
220+if ! command -v xargs >/dev/null 2>&1
221+then
222+ die "xargs is not available"
223+fi
224+
225+# Use "xargs" to parse quoted args.
226+#
227+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
228+#
229+# In Bash we could simply go:
230+#
231+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
232+# set -- "${ARGS[@]}" "$@"
233+#
234+# but POSIX shell has neither arrays nor command substitution, so instead we
235+# post-process each arg (as a line of input to sed) to backslash-escape any
236+# character that might be a shell metacharacter, then use eval to reverse
237+# that process (while maintaining the separation between arguments), and wrap
238+# the whole thing up as a single "set" statement.
239+#
240+# This will of course break if any of these variables contains a newline or
241+# an unmatched quote.
242+#
243+
244+eval "set -- $(
245+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
246+ xargs -n1 |
247+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
248+ tr '\n' ' '
249+ )" '"$@"'
250+
251+exec "$JAVACMD" "$@"
android/gradlew.batnew file+94
@@ -0,0 +1,94 @@
1+@rem
2+@rem Copyright 2015 the original author or authors.
3+@rem
4+@rem Licensed under the Apache License, Version 2.0 (the "License");
5+@rem you may not use this file except in compliance with the License.
6+@rem You may obtain a copy of the License at
7+@rem
8+@rem https://www.apache.org/licenses/LICENSE-2.0
9+@rem
10+@rem Unless required by applicable law or agreed to in writing, software
11+@rem distributed under the License is distributed on an "AS IS" BASIS,
12+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+@rem See the License for the specific language governing permissions and
14+@rem limitations under the License.
15+@rem
16+@rem SPDX-License-Identifier: Apache-2.0
17+@rem
18+
19+@if "%DEBUG%"=="" @echo off
20+@rem ##########################################################################
21+@rem
22+@rem Gradle startup script for Windows
23+@rem
24+@rem ##########################################################################
25+
26+@rem Set local scope for the variables with windows NT shell
27+if "%OS%"=="Windows_NT" setlocal
28+
29+set DIRNAME=%~dp0
30+if "%DIRNAME%"=="" set DIRNAME=.
31+@rem This is normally unused
32+set APP_BASE_NAME=%~n0
33+set APP_HOME=%DIRNAME%
34+
35+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
36+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37+
38+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40+
41+@rem Find java.exe
42+if defined JAVA_HOME goto findJavaFromJavaHome
43+
44+set JAVA_EXE=java.exe
45+%JAVA_EXE% -version >NUL 2>&1
46+if %ERRORLEVEL% equ 0 goto execute
47+
48+echo. 1>&2
49+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50+echo. 1>&2
51+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52+echo location of your Java installation. 1>&2
53+
54+goto fail
55+
56+:findJavaFromJavaHome
57+set JAVA_HOME=%JAVA_HOME:"=%
58+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59+
60+if exist "%JAVA_EXE%" goto execute
61+
62+echo. 1>&2
63+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64+echo. 1>&2
65+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66+echo location of your Java installation. 1>&2
67+
68+goto fail
69+
70+:execute
71+@rem Setup the command line
72+
73+set CLASSPATH=
74+
75+
76+@rem Execute Gradle
77+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
78+
79+:end
80+@rem End local scope for the variables with windows NT shell
81+if %ERRORLEVEL% equ 0 goto mainEnd
82+
83+:fail
84+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
85+rem the _cmd.exe /c_ return code!
86+set EXIT_CODE=%ERRORLEVEL%
87+if %EXIT_CODE% equ 0 set EXIT_CODE=1
88+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
89+exit /b %EXIT_CODE%
90+
91+:mainEnd
92+if "%OS%"=="Windows_NT" endlocal
93+
94+:omega
android/settings.gradle.ktsnew file+18
@@ -0,0 +1,18 @@
1+pluginManagement {
2+ repositories {
3+ google()
4+ mavenCentral()
5+ gradlePluginPortal()
6+ }
7+}
8+
9+dependencyResolutionManagement {
10+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
11+ repositories {
12+ google()
13+ mavenCentral()
14+ }
15+}
16+
17+rootProject.name = "fold"
18+include(":app")
crates/fold-cli/Cargo.toml−1
@@ -11,7 +11,6 @@ path = "src/main.rs"
1111fold-core = { path = "../fold-core" }
1212fold-tui = { path = "../fold-tui" }
1313clap.workspace = true
14−jiff.workspace = true
1514anyhow.workspace = true
1615
1716[dev-dependencies]
crates/fold-cli/src/main.rs+22−9
@@ -171,19 +171,32 @@ fn main() -> anyhow::Result<()> {
171171 1 => {
172172 let e = matches.remove(0);
173173 let name = e.file_name().to_string_lossy().to_string();
174− // strip the <timestamp>- prefix
175− let restored = name
176− .splitn(2, '-')
177− .nth(1)
178− .map(|s| s.splitn(2, |c: char| c.is_ascii_digit()).last().unwrap_or(s))
179− .unwrap_or(&name)
180− .to_string();
181− // simpler: strip "yyyymmdd-hhmmss-"
174+ // strip the `<yyyymmdd-hhmmss>-` stamp (§11.5)
182175 let restored = if name.len() > 16 && name.as_bytes()[8] == b'-' && name.as_bytes()[15] == b'-' {
183176 name[16..].to_string()
184177 } else {
185− restored
178+ name.clone()
186179 };
180+ // text the editor could not save (§10.6) has no id:
181+ // moved in, it would be a file fold ignores, so it
182+ // is given back and stays where it is. The name
183+ // alone does not say so, as a block file may be
184+ // named anything (§3.4): one with an id goes back.
185+ let unsaved = restored
186+ .starts_with("unsaved-")
187+ .then(|| std::fs::read_to_string(e.path()))
188+ .transpose()?
189+ .filter(|text| {
190+ fold_core::parse::parse_frontmatter(text).and_then(|fm| fm.id()).is_none()
191+ });
192+ if let Some(text) = unsaved {
193+ print!("{}", text);
194+ eprintln!(
195+ "{} stays in the trash: it is text the editor could not save, not a file fold reads; paste what you need into the editor",
196+ e.path().display()
197+ );
198+ return Ok(());
199+ }
187200 if !dir.is_dir() {
188201 anyhow::bail!("no vault at {}", dir.display());
189202 }
crates/fold-cli/tests/cli.rs+417−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,407 @@ 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+/// Text the editor could not save (§10.6) is kept in the trash as
166+/// `unsaved-<title>.md`: no file the vault reads, as it has no id. Restore
167+/// gives the text back and leaves the entry where it is, where moving it
168+/// in would leave a file fold ignores and Syncthing spreads.
169+#[test]
170+fn trash_restore_prints_unsaved_text_and_leaves_it_in_the_trash() {
171+ let dir = tempfile::tempdir().unwrap();
172+ let state = tempfile::tempdir().unwrap();
173+ std::fs::write(dir.path().join("root.md"), "# NAS\n\nMirrored pairs.\n").unwrap();
174+ let trash = state.path().join("fold").join("trash");
175+ std::fs::create_dir_all(&trash).unwrap();
176+ let entry = trash.join("20260927-055310-unsaved-nas.md");
177+ let text = "# NAS TYPED-A\n\nMirrored pairs.\n";
178+ std::fs::write(&entry, text).unwrap();
179+ notes(dir.path())
180+ .env("XDG_STATE_HOME", state.path())
181+ .args(["trash", "restore", "20260927-055310-unsaved-nas"])
182+ .assert()
183+ .success()
184+ .stdout(text)
185+ .stderr(predicate::str::contains(entry.display().to_string()));
186+ assert_eq!(std::fs::read_to_string(&entry).unwrap(), text);
187+ let files: Vec<_> = dir.path().read_dir().unwrap().map(|e| e.unwrap().file_name()).collect();
188+ assert_eq!(files, ["root.md"]);
189+ notes(dir.path())
190+ .env("XDG_STATE_HOME", state.path())
191+ .arg("check")
192+ .assert()
193+ .success();
194+}
195+
196+/// A block's filename is decoration (§3.4): one renamed `unsaved-ideas.md`
197+/// and then deleted is still a block file in the trash, with its id, and
198+/// restore moves it back like any other, not printed as editor text.
199+#[test]
200+fn trash_restore_moves_back_a_block_file_named_unsaved() {
201+ let dir = tempfile::tempdir().unwrap();
202+ let state = tempfile::tempdir().unwrap();
203+ std::fs::write(dir.path().join("root.md"), "# Keep\n").unwrap();
204+ let trash = state.path().join("fold").join("trash");
205+ std::fs::create_dir_all(&trash).unwrap();
206+ let entry = trash.join("20260927-114734-unsaved-ideas.md");
207+ let text = "---\nid: racfer-hattes-mislup-nodrys\n---\n\n# Ideas\n\n- a shed\n";
208+ std::fs::write(&entry, text).unwrap();
209+ notes(dir.path())
210+ .env("XDG_STATE_HOME", state.path())
211+ .args(["trash", "restore", "unsaved-ideas"])
212+ .assert()
213+ .success()
214+ .stdout("restored unsaved-ideas.md\n");
215+ assert!(!entry.exists());
216+ assert_eq!(std::fs::read_to_string(dir.path().join("unsaved-ideas.md")).unwrap(), text);
217+}
218+
219+/// The trash lives outside the vault (§11.5), often on another filesystem
220+/// (a Syncthing folder on an external disk vs `$XDG_STATE_HOME`). Merging
221+/// must still move the conflict file to trash (copy + remove across
222+/// filesystems), or every rerun merges the same conflict again and adds
223+/// another duplicate conflict block.
224+#[test]
225+#[cfg(target_os = "linux")]
226+fn merge_trashes_conflict_file_across_filesystems() {
227+ use std::os::unix::fs::MetadataExt;
228+ let dir = tempfile::tempdir().unwrap();
229+ let Ok(state) = tempfile::tempdir_in("/dev/shm") else {
230+ return;
231+ };
232+ let dev = |p: &std::path::Path| std::fs::metadata(p).unwrap().dev();
233+ if dev(dir.path()) == dev(state.path()) {
234+ return; // not a cross-filesystem setup on this machine
235+ }
236+ std::fs::write(dir.path().join("root.md"), "# A\n\n- [ ] t\n").unwrap();
237+ let c = "root.sync-conflict-20260912-100000-phone.md";
238+ std::fs::write(dir.path().join(c), "# A\n\n- [x] t\n").unwrap();
239+ notes(dir.path())
240+ .env("XDG_STATE_HOME", state.path())
241+ .arg("merge")
242+ .assert()
243+ .success();
244+ assert!(!dir.path().join(c).exists(), "conflict file left in the vault");
245+ let trash = state.path().join("fold").join("trash");
246+ assert_eq!(std::fs::read_dir(&trash).unwrap().count(), 1);
247+ // idempotent: a second run finds nothing to merge and adds no blocks
248+ let md_files = |d: &std::path::Path| {
249+ std::fs::read_dir(d)
250+ .unwrap()
251+ .filter(|e| e.as_ref().unwrap().path().extension().is_some_and(|x| x == "md"))
252+ .count()
253+ };
254+ let before = md_files(dir.path());
255+ notes(dir.path())
256+ .env("XDG_STATE_HOME", state.path())
257+ .arg("merge")
258+ .assert()
259+ .success()
260+ .stdout(predicate::str::contains("no sync-conflict files"));
261+ assert_eq!(md_files(dir.path()), before);
262+}
263+
264+// ------------------------------------------------------------ the TUI in a terminal
265+
266+/// `fold` in a terminal of its own, the one util-linux `script` gives it:
267+/// what it writes there, keys typed into it, and its pid.
268+#[cfg(target_os = "linux")]
269+struct Tty {
270+ script: std::process::Child,
271+ keys: std::process::ChildStdin,
272+ out: std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
273+ /// reads what script passes on; done once script closes its output
274+ reader: std::thread::JoinHandle<()>,
275+ /// while set, the reader reads nothing (`stall`)
276+ stalled: std::sync::Arc<std::sync::atomic::AtomicBool>,
277+ pid: u32,
278+}
279+
280+/// Poll `f` for up to 10 s until it gives something.
281+#[cfg(target_os = "linux")]
282+fn until<T>(mut f: impl FnMut() -> Option<T>) -> Option<T> {
283+ let end = std::time::Instant::now() + std::time::Duration::from_secs(10);
284+ loop {
285+ if let Some(t) = f() {
286+ return Some(t);
287+ }
288+ if std::time::Instant::now() > end {
289+ return None;
290+ }
291+ std::thread::sleep(std::time::Duration::from_millis(20));
292+ }
293+}
294+
295+#[cfg(target_os = "linux")]
296+impl Tty {
297+ /// fold on `vault`, with the normal keymap, in a 120×32 terminal, its
298+ /// trash under `state`; None where there is no `script` to run it in.
299+ fn start(vault: &std::path::Path, state: &std::path::Path) -> Option<Tty> {
300+ use std::io::Read;
301+ use std::process::Stdio;
302+ let pidfile = state.join("fold.pid");
303+ let cmd = format!(
304+ "stty cols 120 rows 32; echo $$ > '{}'; exec '{}' --keys normal --vault '{}'",
305+ pidfile.display(),
306+ env!("CARGO_BIN_EXE_fold"),
307+ vault.display()
308+ );
309+ let script = std::process::Command::new("script")
310+ .args(["-qfec", &cmd, "/dev/null"])
311+ .env("SHELL", "/bin/sh")
312+ .env("TERM", "xterm-256color")
313+ .env("XDG_STATE_HOME", state)
314+ .stdin(Stdio::piped())
315+ .stdout(Stdio::piped())
316+ .stderr(Stdio::null())
317+ .spawn();
318+ let Ok(mut script) = script else {
319+ eprintln!("skipped: no script(1) to give fold a terminal");
320+ return None;
321+ };
322+ let keys = script.stdin.take().unwrap();
323+ let mut stdout = script.stdout.take().unwrap();
324+ let out = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
325+ let sink = out.clone();
326+ let stalled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
327+ let held = stalled.clone();
328+ let reader = std::thread::spawn(move || {
329+ let mut buf = [0; 4096];
330+ loop {
331+ // a stalled terminal keeps its end open and reads nothing
332+ while held.load(std::sync::atomic::Ordering::Relaxed) {
333+ std::thread::sleep(std::time::Duration::from_millis(20));
334+ }
335+ let Ok(n @ 1..) = stdout.read(&mut buf) else { return };
336+ sink.lock().unwrap().extend_from_slice(&buf[..n]);
337+ }
338+ });
339+ let pid = until(|| std::fs::read_to_string(&pidfile).ok()?.trim().parse().ok());
340+ let Some(pid) = pid else {
341+ let _ = script.kill();
342+ eprintln!("skipped: script(1) ran no shell");
343+ return None;
344+ };
345+ Some(Tty { script, keys, out, reader, stalled, pid })
346+ }
347+
348+ fn output(&self) -> String {
349+ String::from_utf8_lossy(&self.out.lock().unwrap()).to_string()
350+ }
351+
352+ /// The last of the output, for a failure's message.
353+ fn tail(&self) -> String {
354+ let out = self.output();
355+ let from = out.char_indices().rev().nth(600).map_or(0, |(i, _)| i);
356+ format!("{:?}", &out[from..])
357+ }
358+
359+ /// Whether fold draws `what` within 10 s.
360+ fn shows(&self, what: &str) -> bool {
361+ until(|| self.output().contains(what).then_some(())).is_some()
362+ }
363+
364+ fn type_keys(&mut self, bytes: &str) {
365+ use std::io::Write;
366+ self.keys.write_all(bytes.as_bytes()).unwrap();
367+ self.keys.flush().unwrap();
368+ }
369+
370+ /// Stop reading what fold writes, as a terminal that has stalled (an
371+ /// ssh session gone quiet): once the pipes fill, fold's writes wait.
372+ fn stall(&self) {
373+ self.stalled.store(true, std::sync::atomic::Ordering::Relaxed);
374+ }
375+
376+ /// The syscall fold's main thread is in, by number; None where
377+ /// /proc does not say.
378+ fn syscall(&self) -> Option<String> {
379+ let s = std::fs::read_to_string(format!("/proc/{}/syscall", self.pid)).ok()?;
380+ Some(s.split(' ').next()?.trim().to_string())
381+ }
382+
383+ fn signal(&self, sig: &str) {
384+ let pid = self.pid.to_string();
385+ std::process::Command::new("kill").args(["-s", sig, &pid]).status().unwrap();
386+ }
387+
388+ /// Whether fold has exited: reaped, or a zombie no one has reaped yet.
389+ fn exited(&self) -> bool {
390+ let s = std::fs::read_to_string(format!("/proc/{}/stat", self.pid)).unwrap_or_default();
391+ let state = s.rsplit(") ").next().and_then(|r| r.chars().next());
392+ matches!(state, None | Some('Z'))
393+ }
394+
395+ /// Whether fold is gone within 10 s (reaped, or a zombie no one reaps)
396+ /// and all it wrote has come through: its last bytes can wait in the
397+ /// terminal after it exits, until script passes them on and closes its
398+ /// output.
399+ fn ended(&self) -> bool {
400+ until(|| (self.exited() && self.reader.is_finished()).then_some(())).is_some()
401+ }
402+
403+ /// Open the editor on the first node, and paste ` MYTEXT` at the end of
404+ /// its title line.
405+ fn edit(&mut self) {
406+ assert!(self.shows("Snapshot"), "fold drew nothing:\n{}", self.tail());
407+ self.type_keys("e");
408+ assert!(self.shows("EDIT"), "no editor:\n{}", self.tail());
409+ // End, then a bracketed paste
410+ self.type_keys("\x1b[F\x1b[200~ MYTEXT\x1b[201~");
411+ assert!(self.shows("MYTEXT"), "nothing typed:\n{}", self.tail());
412+ }
413+}
414+
415+#[cfg(target_os = "linux")]
416+impl Drop for Tty {
417+ fn drop(&mut self) {
418+ let _ = std::process::Command::new("kill")
419+ .args(["-9", &self.pid.to_string()])
420+ .stderr(std::process::Stdio::null())
421+ .status();
422+ let _ = self.script.kill();
423+ let _ = self.script.wait();
424+ // the reader reads to the end and is done
425+ self.stalled.store(false, std::sync::atomic::Ordering::Relaxed);
426+ }
427+}
428+
429+/// A vault named as a real one is (tempfile's own names start with a dot),
430+/// and a state directory for its trash.
431+#[cfg(target_os = "linux")]
432+fn vault_and_state(root: &str) -> (tempfile::TempDir, tempfile::TempDir) {
433+ let vault = tempfile::Builder::new().prefix("vault").tempdir().unwrap();
434+ std::fs::write(vault.path().join("root.md"), root).unwrap();
435+ (vault, tempfile::tempdir().unwrap())
436+}
437+
438+/// Where the output puts the terminal back as it was: out of the
439+/// alternate screen, the mouse and bracketed paste turned off before.
440+#[cfg(target_os = "linux")]
441+fn put_back(out: &str) -> Option<usize> {
442+ let at = out.rfind("\x1b[?1049l")?;
443+ let tail = &out[out.rfind("\x1b[?1049h")?..at];
444+ (tail.contains("\x1b[?1000l") && tail.contains("\x1b[?2004l")).then_some(at)
445+}
446+
447+#[test]
448+#[cfg(target_os = "linux")]
449+fn a_signal_saves_the_editor_and_puts_the_terminal_back() {
450+ let (vault, state) = vault_and_state("# Snapshot policy\n\nkeep 24\n");
451+ let Some(mut tty) = Tty::start(vault.path(), state.path()) else { return };
452+ tty.edit();
453+ tty.signal("TERM");
454+ assert!(tty.ended(), "fold still runs");
455+ let root = std::fs::read_to_string(vault.path().join("root.md")).unwrap();
456+ assert_eq!(root, "# Snapshot policy MYTEXT\n\nkeep 24\n");
457+ assert!(put_back(&tty.output()).is_some(), "terminal left as fold had it:\n{}", tty.tail());
458+}
459+
460+#[test]
461+#[cfg(target_os = "linux")]
462+fn a_signal_keeps_text_a_save_was_refused_for_in_the_trash_and_says_where() {
463+ let (vault, state) = vault_and_state("# Snapshot policy\n\nkeep 24\n");
464+ let Some(mut tty) = Tty::start(vault.path(), state.path()) else { return };
465+ tty.edit();
466+ // another program changes the node under the typing: no save can go
467+ // through
468+ std::fs::write(vault.path().join("root.md"), "# Snapshot policy\n\nkeep 48\n").unwrap();
469+ tty.signal("TERM");
470+ assert!(tty.ended(), "fold still runs");
471+ let root = std::fs::read_to_string(vault.path().join("root.md")).unwrap();
472+ assert_eq!(root, "# Snapshot policy\n\nkeep 48\n");
473+ // said once the terminal is back, so it stays on screen
474+ let out = tty.output();
475+ let back = put_back(&out).unwrap_or_else(|| panic!("terminal left as fold had it:\n{}", tty.tail()));
476+ let said = out[back..].find("unsaved text kept in ").unwrap_or_else(|| panic!("nothing said:\n{}", tty.tail()));
477+ let path = out[back + said..]["unsaved text kept in ".len()..].lines().next().unwrap().trim_end();
478+ assert!(path.starts_with(&state.path().join("fold").join("trash").display().to_string()), "{}", path);
479+ assert!(path.ends_with("-unsaved-snapshot-policy.md"), "{}", path);
480+ assert_eq!(std::fs::read_to_string(path).unwrap(), "# Snapshot policy MYTEXT\n\nkeep 24\n");
481+}
482+
483+#[test]
484+#[cfg(target_os = "linux")]
485+fn a_signal_puts_the_terminal_back_though_script_passes_it_on_late() {
486+ let (vault, state) = vault_and_state("# Snapshot policy\n\nkeep 24\n");
487+ let Some(mut tty) = Tty::start(vault.path(), state.path()) else { return };
488+ tty.edit();
489+ // script(1) falls behind, as on a busy machine: fold's last bytes are
490+ // still in the terminal when fold has gone
491+ let script = tty.script.id().to_string();
492+ let to_script = |sig: &str| {
493+ std::process::Command::new("kill").args(["-s", sig, &script]).status().unwrap();
494+ };
495+ to_script("STOP");
496+ tty.signal("TERM");
497+ std::thread::scope(|s| {
498+ s.spawn(|| {
499+ until(|| tty.exited().then_some(()));
500+ std::thread::sleep(std::time::Duration::from_millis(300));
501+ to_script("CONT");
502+ });
503+ assert!(tty.ended(), "fold still runs");
504+ assert!(put_back(&tty.output()).is_some(), "terminal left as fold had it:\n{}", tty.tail());
505+ });
506+}
507+
508+/// write(2)'s number, as /proc/<pid>/syscall gives it.
509+#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
510+const WRITE: &str = "1";
511+#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
512+const WRITE: &str = "64";
513+
514+#[test]
515+#[cfg(all(target_os = "linux", any(target_arch = "x86_64", target_arch = "aarch64")))]
516+fn a_second_signal_ends_fold_stuck_on_a_terminal_that_reads_nothing() {
517+ let root: String = (0..300).map(|i| format!("- item {} of a long outline that fills the screen\n", i)).collect();
518+ let (vault, state) = vault_and_state(&format!("# Snapshot policy\n\n{}", root));
519+ let Some(mut tty) = Tty::start(vault.path(), state.path()) else { return };
520+ assert!(tty.shows("Snapshot"), "fold drew nothing:\n{}", tty.tail());
521+ if tty.syscall().is_none() {
522+ eprintln!("skipped: /proc does not say what fold waits on");
523+ return;
524+ }
525+ // the terminal stops reading; fold, redrawing the whole outline from
526+ // bottom to top and back, fills it and waits in a write
527+ tty.stall();
528+ tty.type_keys(&"Ggg".repeat(200));
529+ let stuck = until(|| (tty.syscall().as_deref() == Some(WRITE)).then_some(()));
530+ assert!(stuck.is_some(), "fold never waited on the terminal: {:?}", tty.syscall());
531+ // the first signal asks fold to end as on a quit, which it cannot
532+ // while it waits; the second ends it all the same
533+ tty.signal("TERM");
534+ std::thread::sleep(std::time::Duration::from_millis(300));
535+ tty.signal("TERM");
536+ assert!(until(|| tty.exited().then_some(())).is_some(), "fold still runs: {:?}", tty.syscall());
537+}
538+
539+#[test]
540+#[cfg(target_os = "linux")]
541+fn a_sigterm_after_a_sighup_still_saves_the_editor() {
542+ let (vault, state) = vault_and_state("# Snapshot policy\n\nkeep 24\n");
543+ let Some(mut tty) = Tty::start(vault.path(), state.path()) else { return };
544+ tty.edit();
545+ // a SIGHUP and a SIGTERM soon after, before the loop sees the first:
546+ // the SIGTERM is not a second one, which would end fold unsaved
547+ let pid = tty.pid.to_string();
548+ let both = format!("kill -s HUP {0}; sleep 0.05; kill -s TERM {0}", pid);
549+ std::process::Command::new("sh").args(["-c", &both]).status().unwrap();
550+ assert!(tty.ended(), "fold still runs");
551+ let root = std::fs::read_to_string(vault.path().join("root.md")).unwrap();
552+ assert_eq!(root, "# Snapshot policy MYTEXT\n\nkeep 24\n");
553+ assert!(put_back(&tty.output()).is_some(), "terminal left as fold had it:\n{}", tty.tail());
554+}
555+
556+#[test]
557+#[cfg(target_os = "linux")]
558+fn closing_the_window_saves_the_editor() {
559+ let (vault, state) = vault_and_state("# Snapshot policy\n\nkeep 24\n");
560+ let Some(mut tty) = Tty::start(vault.path(), state.path()) else { return };
561+ tty.edit();
562+ // the terminal goes away: fold gets SIGHUP, and nowhere to draw
563+ tty.script.kill().unwrap();
564+ assert!(tty.ended(), "fold still runs");
565+ let root = std::fs::read_to_string(vault.path().join("root.md")).unwrap();
566+ assert_eq!(root, "# Snapshot policy MYTEXT\n\nkeep 24\n");
567+}
crates/fold-core/Cargo.toml−3
@@ -11,9 +11,6 @@ blake3.workspace = true
1111unicode-normalization.workspace = true
1212directories.workspace = true
1313getrandom.workspace = true
14−anyhow.workspace = true
15−thiserror.workspace = true
1614
1715[dev-dependencies]
18−proptest = "1"
1916tempfile = "3"
crates/fold-core/src/check.rs+75−46
@@ -1,7 +1,7 @@
1−//! `notes check` diagnostics (§15.7) and `--fix` canonicalization (§4.2).
1+//! `fold 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;
@@ -35,12 +35,6 @@ pub fn check(vault: &Vault) -> Vec<Diagnostic> {
3535 message: format!("non-canonical: {} ({:?})", nc, n.title),
3636 });
3737 }
38− if n.title.contains('/') && n.kind != Kind::Root {
39− out.push(Diagnostic {
40− file: f.path.clone(),
41− message: format!("title contains '/': {:?}", n.title),
42− });
43− }
4438 }
4539 }
4640 // embeds: broken, duplicate, cyclic, with children (§6.2, §15.7)
@@ -93,6 +87,28 @@ pub fn check(vault: &Vault) -> Vec<Diagnostic> {
9387 }
9488 }
9589 }
90+ // cyclic embeds (§6.2): follow each block up through the embed that
91+ // holds it until root.md, a block embedded nowhere, or the block itself
92+ for (r, id) in &t.blocks {
93+ let mut seen: Vec<&Id> = vec![id];
94+ let mut cur = id;
95+ while let Some(e) = t.embed_of(cur) {
96+ // the block whose file holds that embed; none: root.md
97+ let Some((_, holder)) = t.blocks.iter().find(|(b, _)| b.0 == e.0) else { break };
98+ if holder == id {
99+ out.push(Diagnostic {
100+ file: t.node(*r).block.as_ref().unwrap().path.clone(),
101+ message: format!("cyclic embed: block {} is embedded inside itself", id),
102+ });
103+ break;
104+ }
105+ if seen.contains(&holder) {
106+ break; // a cycle above it, reported by the blocks on it
107+ }
108+ seen.push(holder);
109+ cur = holder;
110+ }
111+ }
96112 // block files: exactly one root, valid unique ids (§4.9, §6.2)
97113 let mut seen_ids: Vec<&Id> = Vec::new();
98114 for (r, id) in &t.blocks {
@@ -103,7 +119,6 @@ pub fn check(vault: &Vault) -> Vec<Diagnostic> {
103119 });
104120 }
105121 seen_ids.push(id);
106− let n = t.node(*r);
107122 let f = &t.files[r.0];
108123 let top: Vec<usize> = f.nodes[f.root_node].children.clone();
109124 if top.len() != 1 {
@@ -112,7 +127,6 @@ pub fn check(vault: &Vault) -> Vec<Diagnostic> {
112127 message: "block file must have exactly one node at column 0".into(),
113128 });
114129 }
115− let _ = n;
116130 // filename checks (§6.4)
117131 let b = t.node(*r).block.as_ref().unwrap();
118132 let words = id.words();
@@ -198,6 +212,37 @@ fn embed_level_mismatch(t: &crate::tree::Tree, e: NRef) -> Option<usize> {
198212 .then_some(written)
199213}
200214
215+/// The text of `e`'s file with heading embed `e` at the level of its
216+/// position and the structure unchanged (§4.7). Its later sibling sections
217+/// written deeper than that level move to it too (it is their position's
218+/// level as well): left alone, the shallower embed would take them as its
219+/// children. `None` when `e` has no stale level, or when no level keeps it
220+/// where it is (its parent section is itself written at that level or
221+/// deeper), and `check` goes on reporting it.
222+fn embed_level_fix(t: &crate::tree::Tree, e: NRef) -> Option<String> {
223+ embed_level_mismatch(t, e)?;
224+ let lvl = t.derived_level(e);
225+ let f = &t.files[e.0];
226+ let parent = &f.nodes[f.nodes[e.1].parent?];
227+ if parent.kind == Kind::Section && parent.level.unwrap_or(1) >= lvl {
228+ return None;
229+ }
230+ let from = parent.children.iter().position(|&c| c == e.1)?;
231+ let mut text = f.text.clone();
232+ // last to first, so the spans before each edit stay valid
233+ for &c in parent.children[from..].iter().rev() {
234+ let n = &f.nodes[c];
235+ let Some(w) = n.level.filter(|&w| w > lvl || c == e.1) else { continue };
236+ let line = n.title_span.text(&f.text);
237+ let at = n.title_span.start + line.len() - line.trim_start_matches([' ', '\t']).len();
238+ if !f.text[at..].starts_with(&"#".repeat(w)) {
239+ return None; // not an ATX heading
240+ }
241+ text.replace_range(at..at + w, &"#".repeat(lvl));
242+ }
243+ Some(text)
244+}
245+
201246/// A heading embed must embed a section, a bare one an item (§4.7).
202247fn form_mismatch(embed: Kind, block: Kind) -> bool {
203248 (embed == Kind::Section) != (block == Kind::Section)
@@ -247,7 +292,8 @@ pub fn fix(vault: &mut Vault) -> std::io::Result<usize> {
247292 // frontmatter and intro text, exactly as found; then the root's
248293 // children in order — nodes rendered, text children verbatim,
249294 // one blank line between a node and what follows it unless both
250− // are items of a tight list (§4.2)
295+ // are items of a tight list (§4.2); a line is blank whatever its
296+ // line ending, so a CRLF file keeps its loose lists
251297 let mut s = f.text[..f.nodes[first].span.start].to_string();
252298 let root = &f.nodes[f.root_node];
253299 let from = root
@@ -259,23 +305,23 @@ pub fn fix(vault: &mut Vault) -> std::io::Result<usize> {
259305 for c in &root.content[from..] {
260306 let loose_after_prev = prev.map(|p| {
261307 let pn = &f.nodes[p];
262− pn.kind == Kind::Section || pn.span.text(&f.text).ends_with("\n\n")
308+ pn.kind == Kind::Section || ends_with_blank_line(pn.span.text(&f.text))
263309 });
264310 match *c {
265311 Content::Node(k) => {
266312 let kn = &f.nodes[k];
267313 let blank = match loose_after_prev {
268314 Some(loose) => loose || kn.kind == Kind::Section,
269− None => !s.is_empty() && !s.ends_with("\n\n"),
315+ None => !s.is_empty() && !ends_with_blank_line(&s),
270316 };
271− if blank && !s.ends_with("\n\n") {
317+ if blank && !ends_with_blank_line(&s) {
272318 s.push('\n');
273319 }
274320 s.push_str(&render(&vault.tree, (i, k), 1, false));
275321 prev = Some(k);
276322 }
277323 Content::Text(sp) => {
278− if prev.is_some() && !s.ends_with("\n\n") {
324+ if prev.is_some() && !ends_with_blank_line(&s) {
279325 s.push('\n');
280326 }
281327 let t = sp.text(&f.text);
@@ -287,11 +333,7 @@ pub fn fix(vault: &mut Vault) -> std::io::Result<usize> {
287333 }
288334 s
289335 } else {
290− let malformed = top.len() != 1
291− || f.diagnostics.iter().any(|d| {
292− d.message.contains("more than one node") || d.message.contains("before the block")
293− });
294− if malformed {
336+ if f.malformed_block() {
295337 continue;
296338 }
297339 render(&vault.tree, (i, top[0]), 1, false)
@@ -314,25 +356,15 @@ pub fn fix(vault: &mut Vault) -> std::io::Result<usize> {
314356 crate::ops::respell_embed(vault, e, to_section)?;
315357 count += 1;
316358 }
317− // heading embeds at the level of their position (§4.7); only ever
318− // shallower than written, so no line changes parent
359+ // heading embeds at the level of their position (§4.7), one at a time,
360+ // with no line changing parent
319361 for _ in 0..10_000 {
320362 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))
363+ let fixed = t.files.iter().enumerate().find_map(|(fi, f)| {
364+ (0..f.nodes.len()).find_map(|ni| embed_level_fix(t, (fi, ni)).map(|text| (fi, text)))
325365 });
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)?;
366+ let Some((file, text)) = fixed else { break };
367+ vault.write_file_text(file, &text)?;
336368 count += 1;
337369 }
338370 // repair filenames (§6.4): an existing prefix that is a leading run of
@@ -348,7 +380,8 @@ pub fn fix(vault: &mut Vault) -> std::io::Result<usize> {
348380 })
349381 .collect();
350382 let mut decided: Vec<String> = Vec::new();
351− let mut renames: Vec<(usize, String)> = Vec::new();
383+ // by path: a renamed file takes its place by name among the others
384+ let mut renames: Vec<(String, String)> = Vec::new();
352385 for (i, (r, id)) in vault.tree.blocks.iter().enumerate() {
353386 let b = vault.tree.node(*r).block.as_ref().unwrap();
354387 let words = id.words();
@@ -367,21 +400,17 @@ pub fn fix(vault: &mut Vault) -> std::io::Result<usize> {
367400 });
368401 let want = filename(&prefix, &slug(&vault.tree.node(*r).title));
369402 if want != b.path {
370− renames.push((r.0, want));
403+ renames.push((b.path.clone(), want));
371404 }
372405 decided.push(prefix);
373406 }
374− for (file, want) in renames {
375− let old = vault.tree.files[file].path.clone();
376− let target = vault.dir.join(&want);
377− if target.exists() {
407+ for (old, want) in renames {
408+ if vault.dir.join(&want).exists() {
378409 continue; // never overwrite; check keeps reporting it
379410 }
380− std::fs::rename(vault.dir.join(&old), target)?;
411+ let Some(file) = vault.file_index(&old) else { continue };
412+ vault.rename_file(file, &want)?;
381413 count += 1;
382414 }
383− if count > 0 {
384− vault.reload()?;
385− }
386415 Ok(count)
387416}
crates/fold-core/src/edit.rs+683−82
@@ -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::{fence_transition, 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,36 @@ 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+
210+ /// The blocks this buffer holds in transit (§5.2), with their files:
211+ /// nested here, with no line left in it, embedded nowhere, and still in
212+ /// the vault.
213+ pub fn transit(&self, vault: &Vault) -> Vec<(Id, String)> {
214+ let tree = &vault.tree;
215+ self.owners
216+ .iter()
217+ .filter(|&(o, i)| i.parent.is_some() && !self.lines.iter().any(|l| l.owner == *o))
218+ .filter_map(|(_, i)| i.id.as_ref())
219+ .filter(|id| tree.embed_of(id).is_none())
220+ .filter_map(|id| Some((id.clone(), tree.files[tree.block_by_id(id)?.0].path.clone())))
221+ .collect()
222+ }
223+
168224 /// Insert a line after `idx`; it inherits that line's tag (§5.2).
169225 pub fn insert_line(&mut self, idx: usize, text: String) {
170226 let owner = if self.lines.is_empty() {
@@ -194,21 +250,16 @@ impl EditBuffer {
194250 }
195251 }
196252
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> {
253+ /// Where the block is now: its file, and for a block or a file's Root
254+ /// its node in the current tree (it may have been re-parsed since the
255+ /// buffer was built). A render root that is not a block is no node but
256+ /// the region `start..end` of its file.
257+ fn locate(&self, vault: &Vault, info: &OwnerInfo) -> Option<(usize, Option<usize>)> {
200258 if let Some(id) = &info.id {
201− return vault.tree.block_by_id(id);
259+ return vault.tree.block_by_id(id).map(|(f, n)| (f, Some(n)));
202260 }
203261 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))
262+ Some((file, info.is_root.then_some(vault.tree.files[file].root_node)))
212263 }
213264
214265 /// The owner directly nested in `owner` that `o` belongs to, if any.
@@ -222,18 +273,288 @@ impl EditBuffer {
222273 None
223274 }
224275
225− /// Splice one dirty block (§5.2): collect its lines, put nested blocks
226− /// back as embeds, shift back, write the one file atomically.
276+ /// Each nested block's embed goes to the block its title line (at
277+ /// `titles`) now sits in (§5.2), out of the block it was in or into one
278+ /// nested there, however the line got there: moved, re-indented or
279+ /// re-spelled. Of the block holding the line above the title line (the
280+ /// blank lines between nest nothing) and the blocks that one is nested
281+ /// in, the innermost whose title line holds it (`holds`). Blocks go in
282+ /// buffer order, so where a block sits is settled before the blocks
283+ /// after it.
284+ pub fn reparent(&mut self, titles: &BTreeMap<Owner, usize>) {
285+ let mut order: Vec<(usize, Owner)> = titles.iter().map(|(&o, &t)| (t, o)).collect();
286+ order.sort();
287+ for (t, o) in order {
288+ let Some(p) = self.owners.get(&o).and_then(|i| i.parent) else { continue };
289+ let lines = &self.lines[..t.min(self.lines.len())];
290+ let Some(above) = lines.iter().rev().find(|l| !l.text.trim().is_empty()) else { continue };
291+ let mut q = Some(above.owner);
292+ while let Some(c) = q {
293+ if c != o && self.nested_in(c, o).is_none() && self.holds(titles, c, o) {
294+ break;
295+ }
296+ q = self.owners.get(&c).and_then(|i| i.parent);
297+ }
298+ let Some(q) = q.filter(|&q| q != p) else { continue };
299+ if let Some(i) = self.owners.get_mut(&o) {
300+ i.parent = Some(q);
301+ }
302+ self.mark_dirty(p);
303+ self.mark_dirty(q);
304+ }
305+ }
306+
307+ /// Whether block `q`'s title line holds block `o`'s, written below its
308+ /// lines, as the parser nests title lines (§3.1): the edited node's
309+ /// block holds every line, an item what is indented past it, a section
310+ /// what is indented past it (under an item of its own) and, at its
311+ /// indent, items and deeper sections. Each title line as it is written
312+ /// now (`title_form`), not as the buffer was built: one re-indented or
313+ /// re-spelled nests where it sits (else where it was last written).
314+ fn holds(&self, titles: &BTreeMap<Owner, usize>, q: Owner, o: Owner) -> bool {
315+ if self.owners.get(&q).is_some_and(|i| i.parent.is_none()) {
316+ return true;
317+ }
318+ let form = |o: Owner| {
319+ titles
320+ .get(&o)
321+ .and_then(|&t| self.lines.get(t))
322+ .and_then(|l| title_form(&l.text))
323+ .or_else(|| self.placed(o))
324+ };
325+ let (Some((qi, qh)), Some((oi, oh))) = (form(q), form(o)) else { return false };
326+ oi > qi || (oi == qi && qh.is_some_and(|q| oh.is_none_or(|o| o > q)))
327+ }
328+
329+ /// The line where nested block `o`'s text starts (§5.2): the first title
330+ /// line of either kind at any indent named as the block is in its file
331+ /// (`file_title`), whatever lines of the block come before it, typed or
332+ /// split off in front of it; else its first line if that is a title line
333+ /// of its kind at its display indent — a heading for a section, a
334+ /// bullet for an item — or, re-indented or re-spelled, a title line of
335+ /// either kind at any indent that is not one of the block's children
336+ /// (`left_first`); else the first title line of its kind at its display
337+ /// indent below it, a heading no deeper than the block's level (deeper
338+ /// ones are its children). None: its title line was deleted.
339+ fn title_line(&self, vault: &Vault, o: Owner) -> Option<usize> {
340+ let info = self.owners.get(&o)?;
341+ let named = self.file_title(vault, o);
342+ let mut fence = None;
343+ let mut first = true;
344+ let mut found = None;
345+ for (i, l) in self.lines.iter().enumerate().filter(|(_, l)| l.owner == o) {
346+ let in_code = fence_transition(&l.text, &mut fence) || fence.is_some();
347+ if let Some((indent, heading)) = title_form(&l.text).filter(|_| !in_code) {
348+ if named == Some(after_marker(&l.text)) {
349+ return Some(i);
350+ }
351+ let own = indent == info.indent && heading.is_some() == info.section;
352+ let title = (first && (own || !self.left_first(vault, o, &l.text)))
353+ || (own && heading.is_none_or(|h| h <= info.level.max(1)));
354+ if title && found.is_none() {
355+ found = Some(i);
356+ }
357+ }
358+ first = false;
359+ }
360+ found
361+ }
362+
363+ /// Nested block `o`'s title in its file as last read or written, after
364+ /// its marker (`after_marker`).
365+ fn file_title<'v>(&self, vault: &'v Vault, o: Owner) -> Option<&'v str> {
366+ let (file, root) = self.owners.get(&o).and_then(|i| self.locate(vault, i))?;
367+ let f = &vault.tree.files[file];
368+ let n = f.nodes.get(root?)?;
369+ Some(after_marker(&f.text[n.title_span.start..n.title_span.end.min(f.text.len())]))
370+ }
371+
372+ /// Whether `line`, first of block `o`'s lines, is the title line of one
373+ /// of its children, not its own: a node below its title in its file as
374+ /// last read or written, not named as the block is. So deleting a
375+ /// block's title line, which may leave a child's first, deletes it
376+ /// (§5.2), while its title line re-indented stays its title.
377+ fn left_first(&self, vault: &Vault, o: Owner, line: &str) -> bool {
378+ let Some((file, Some(root))) = self.owners.get(&o).and_then(|i| self.locate(vault, i)) else {
379+ return false;
380+ };
381+ let f = &vault.tree.files[file];
382+ let title = |n: &Node| after_marker(&f.text[n.title_span.start..n.title_span.end.min(f.text.len())]);
383+ let t = after_marker(line);
384+ t != title(&f.nodes[root])
385+ && f.nodes.iter().enumerate().any(|(i, n)| {
386+ i != root && n.kind != Kind::Root && !n.is_embed() && title(n) == t
387+ })
388+ }
389+
390+ /// Where nested block `o`'s embed was last written: its title line's
391+ /// indent and heading level (`title_form`), as built or since saved.
392+ fn placed(&self, o: Owner) -> Option<(usize, Option<usize>)> {
393+ self.placed.get(&o).copied().or_else(|| {
394+ let i = self.owners.get(&o)?;
395+ Some((i.indent, i.section.then_some(i.level)))
396+ })
397+ }
398+
399+ /// §5.2's edge cases, applied from the tags before a splice. A nested
400+ /// block's text starts at its title line: lines it owns above that line
401+ /// sit in the enclosing block and become its text. Deleting the title
402+ /// line deletes the block: every line it still owned is re-tagged to
403+ /// the enclosing block, the blocks nested in it are nested in that
404+ /// block, and its file goes to trash once that block is written without
405+ /// its embed — unless it is in transit, its title line cut (`hold`).
406+ /// Re-indenting or re-spelling the title line moves the block: the
407+ /// enclosing block is dirty, its embed written where the line now sits,
408+ /// in the block that line sits in (`reparent`).
409+ fn settle(&mut self, vault: &Vault) {
410+ // lines of a deleted block put back (by the editor's own undo) are
411+ // the enclosing block's text like the rest of them
412+ let dropped = self.dropped.clone();
413+ self.dirty.retain(|o| !dropped.contains(o));
414+ for i in 0..self.lines.len() {
415+ let s = self.surviving(self.lines[i].owner);
416+ if s != self.lines[i].owner {
417+ self.lines[i].owner = s;
418+ self.mark_dirty(s);
419+ }
420+ }
421+ // and so are the blocks nested in it, whatever parents the editor's
422+ // undo put back: no embed is written into a deleted block
423+ let parents: Vec<(Owner, Owner)> = self
424+ .owners
425+ .iter()
426+ .filter_map(|(&o, i)| Some((o, i.parent?)))
427+ .filter(|&(o, p)| !dropped.contains(&o) && self.surviving(p) != p)
428+ .collect();
429+ for (o, p) in parents {
430+ let s = self.surviving(p);
431+ if let Some(i) = self.owners.get_mut(&o) {
432+ i.parent = Some(s);
433+ }
434+ self.mark_dirty(s);
435+ }
436+ loop {
437+ let mut changed = false;
438+ for o in self.dirty.clone() {
439+ let Some(parent) = self.owners.get(&o).and_then(|i| i.parent) else {
440+ continue;
441+ };
442+ let title = self.title_line(vault, o);
443+ let form = title.and_then(|t| title_form(&self.lines[t].text));
444+ if form.is_some() && form != self.placed(o) && !self.dirty.contains(&parent) {
445+ self.mark_dirty(parent);
446+ changed = true;
447+ }
448+ // a title line emptied to be typed again (vim `cc`) is not
449+ // a deleted one; splice refuses to write the block meanwhile
450+ let first = self.lines.iter().find(|l| l.owner == o);
451+ if title.is_none() && first.is_some_and(|l| l.text.trim().is_empty()) {
452+ continue;
453+ }
454+ // one whose title line the clipboard holds, with nothing of
455+ // it or of the blocks in it left here, is in transit (`hold`)
456+ let transit = first.is_none()
457+ && self.held.contains(&o)
458+ && !self.lines.iter().any(|l| self.nested_in(l.owner, o).is_some());
459+ let mut moved = title.is_none();
460+ let above = title.unwrap_or(self.lines.len());
461+ for l in self.lines[..above].iter_mut().filter(|l| l.owner == o) {
462+ l.owner = parent;
463+ moved = true;
464+ }
465+ if title.is_none() {
466+ self.dirty.retain(|d| *d != o);
467+ }
468+ if title.is_none() && !transit {
469+ for i in self.owners.values_mut() {
470+ if i.parent == Some(o) {
471+ i.parent = Some(parent);
472+ }
473+ }
474+ self.dropped.push(o);
475+ }
476+ if moved {
477+ self.mark_dirty(parent);
478+ changed = true;
479+ }
480+ }
481+ if !changed {
482+ break;
483+ }
484+ }
485+ // and each goes to the block its title line now sits in, so no
486+ // embed is written nested under another's line (§4.7)
487+ let titles: BTreeMap<Owner, usize> = self
488+ .owners
489+ .iter()
490+ .filter(|(_, i)| i.parent.is_some())
491+ .filter_map(|(&o, _)| Some((o, self.title_line(vault, o)?)))
492+ .collect();
493+ self.reparent(&titles);
494+ }
495+
496+ /// The block that holds a deleted block's lines now.
497+ fn surviving(&self, mut o: Owner) -> Owner {
498+ while self.dropped.contains(&o) {
499+ match self.owners.get(&o).and_then(|i| i.parent) {
500+ Some(p) => o = p,
501+ None => break,
502+ }
503+ }
504+ o
505+ }
506+
507+ /// Move deleted blocks' files to the trash (§11.5) once no file embeds
508+ /// them any more: once the block that held the embed has been written.
509+ /// That is the block its lines went to, and it must be saved (not
510+ /// dirty, its save not refused) with its file on disk as written: a
511+ /// file another program changed may embed the block again.
512+ fn trash_dropped(&self, vault: &mut Vault) -> std::io::Result<()> {
513+ loop {
514+ let file = self.dropped.iter().find_map(|o| {
515+ let id = self.owners.get(o)?.id.as_ref()?;
516+ let s = self.surviving(*o);
517+ if self.dirty.contains(&s) || vault.tree.embed_of(id).is_some() {
518+ return None;
519+ }
520+ let (holder, _) = self.locate(vault, self.owners.get(&s)?)?;
521+ vault.check_unchanged(holder).ok()?;
522+ vault.tree.block_by_id(id).map(|r| r.0)
523+ });
524+ match file {
525+ Some(f) => vault.trash_file(f)?,
526+ None => return Ok(()),
527+ }
528+ }
529+ }
530+
531+ /// Splice one dirty block (§5.2) — or, if deleting its title line
532+ /// deleted it, the block its lines went to — then trash the files of
533+ /// deleted blocks no longer embedded.
227534 pub fn splice(&mut self, vault: &mut Vault, owner: Owner) -> std::io::Result<()> {
535+ self.settle(vault);
536+ let owner = self.surviving(owner);
537+ self.write(vault, owner)?;
538+ self.trash_dropped(vault)
539+ }
540+
541+ /// Write one block (§5.2): collect its lines, put nested blocks back as
542+ /// embeds, shift back, write the one file atomically. False: there was
543+ /// nothing to write, and the block is no longer dirty either.
544+ fn write(&mut self, vault: &mut Vault, owner: Owner) -> std::io::Result<bool> {
228545 let info = match self.owners.get(&owner) {
229546 Some(i) => i.clone(),
230− None => return Ok(()),
547+ None => {
548+ self.dirty.retain(|o| *o != owner);
549+ return Ok(false);
550+ }
231551 };
232552 // Collect the block's owned lines in buffer order; where a block
233553 // nested in it starts — whatever file it lives in — emit its embed
234554 // (§5.2.1).
235555 let mut text_lines: Vec<Line> = Vec::new();
236556 let mut emitted: Vec<Owner> = Vec::new();
557+ let mut forms = Vec::new();
237558 for l in &self.lines {
238559 if l.owner == owner {
239560 text_lines.push(Line::Text(l.text.clone()));
@@ -245,29 +566,50 @@ impl EditBuffer {
245566 // a heading embed for a heading (§4.7)
246567 let trimmed = l.text.trim_start_matches(' ');
247568 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(' ');
569+ let form = title_form(&l.text);
570+ let hashes = form.and_then(|f| f.1);
571+ let respelled = form.is_some_and(|f| {
572+ Some(f.1.is_some()) != self.placed(nested).map(|p| p.1.is_some())
573+ });
250574 // an existing embed keeps its form, so an unchanged
251− // splice writes it back byte for byte
575+ // splice writes it back byte for byte; a title line
576+ // re-spelled since it was written changes it
252577 let heading = match vault.tree.embed_of(&id) {
578+ _ if respelled => hashes,
253579 Some(e) if vault.tree.node(e).kind == Kind::Section => {
254− Some(if is_heading { hashes } else { info.level + 1 })
580+ Some(hashes.unwrap_or(info.level + 1))
255581 }
256582 Some(_) => None,
257− None => is_heading.then_some(hashes),
583+ None => hashes,
258584 };
259585 text_lines.push(Line::Embed(indent, heading, id));
586+ if let Some(f) = form {
587+ forms.push((nested, f));
588+ }
260589 }
261590 }
262591 }
263592 }
264593 if text_lines.is_empty() {
265− return Ok(());
594+ // every line of the edited node gone: the editor does not delete
595+ // it (a nested block with no lines left was deleted by settle)
596+ self.dirty.retain(|o| *o != owner);
597+ return Ok(false);
266598 }
267599 // 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;
600+ // its file (§5.2.3). A nested block's display position is where its
601+ // title line sits now, re-indented or re-spelled or not: its root
602+ // goes to column 0, a heading to its file's level (§4.9)
603+ let title = info.parent.and_then(|_| self.title_line(vault, owner));
604+ let (level, indent, target_level) = match title.and_then(|t| title_form(&self.lines[t].text)) {
605+ Some((indent, Some(h))) => (h, indent, if info.section { info.target_level } else { 1 }),
606+ // an item shows at its section's level, one above a section's
607+ Some((indent, None)) if info.section => (info.level.saturating_sub(1).max(1), indent, 1),
608+ Some((indent, None)) => (info.level, indent, info.target_level),
609+ None => (info.level, info.indent, info.target_level),
610+ };
611+ let level_delta = target_level as isize - level as isize;
612+ let indent_delta = info.target_indent as isize - indent as isize;
271613 let shift = |cols: usize| (cols as isize + indent_delta).max(0) as usize;
272614 let mut out = String::new();
273615 let mut fence: Option<(char, usize)> = None;
@@ -308,28 +650,54 @@ impl EditBuffer {
308650 while out.ends_with("\n\n") {
309651 out.pop();
310652 }
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;
653+ self.rebase(vault, owner)?;
654+ let info = self.owners.get(&owner).cloned().unwrap_or(info);
655+ let (file, node) = self.locate(vault, &info).ok_or_else(|| not_found(&info))?;
318656 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− }
657+ let region = Span {
658+ start: info.start,
659+ end: info.end,
660+ };
328661 let f = &vault.tree.files[file];
329− let n = &f.nodes[nref.1];
330− if n.is_block() || n.kind == Kind::Root {
662+ // §4.9: a block file with text or an embed before its root is
663+ // read-only until fixed — the bytes before the root are not in the
664+ // buffer, and rewriting the file would drop them
665+ if f.malformed_block() {
666+ return Err(std::io::Error::other(format!(
667+ "{}: text or an embed before the block's root; read-only until fixed",
668+ path
669+ )));
670+ }
671+ // §4.7: an embed line has no lines nested under it, where the tree
672+ // does not see them. A line the parser would nest there (the block's
673+ // own line indented under a nested block's title line, or after one
674+ // re-spelled as a heading) is refused, not hidden, unless the file
675+ // had it so already
676+ let crowded = crowded_embeds(f);
677+ if crowded_embeds(&parse_file(&path, &out, None)).iter().any(|id| !crowded.contains(id)) {
678+ return Err(std::io::Error::other(format!(
679+ "{}: a line would be nested under a block's embed, out of the outline; not saved",
680+ path
681+ )));
682+ }
683+ if let Some(ni) = node {
684+ // §5.2 step 2: a block's text parses to exactly one root-level
685+ // node, so splice never writes a block file it would then treat
686+ // as read-only, or one with no root at all
687+ if let Some(b) = f.nodes[ni].block.as_ref().filter(|b| b.id.is_some()) {
688+ let b = Block {
689+ frontmatter_span: None,
690+ ..b.clone()
691+ };
692+ if parse_file(&path, &out, Some(b)).malformed_block() {
693+ return Err(std::io::Error::other(format!(
694+ "{}: the block's text must start with its title line; not saved",
695+ path
696+ )));
697+ }
698+ }
331699 // the block IS the file: keep its frontmatter, replace the rest
332− let fm_end = n
700+ let fm_end = f.nodes[ni]
333701 .block
334702 .as_ref()
335703 .and_then(|b| b.frontmatter_span)
@@ -341,31 +709,187 @@ impl EditBuffer {
341709 new_text.push_str(&out);
342710 vault.write_file_text(file, &new_text)?;
343711 } 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)?;
712+ // a region inside a file: the blank lines that separate it from
713+ // what follows stay
714+ vault.write_span(file, region, &out)?;
715+ if let Some(i) = self.owners.get_mut(&owner) {
716+ i.start = region.start;
717+ i.end = region.start + out.len();
718+ }
352719 }
353720 self.base_hashes
354721 .insert(path, hash(&vault.tree.files[file].text));
355722 self.dirty.retain(|o| *o != owner);
723+ self.placed.extend(forms);
724+ Ok(true)
725+ }
726+
727+ /// External-change check (§5.2.5) for one block: the block's source
728+ /// span on disk must be what was read (or last written) through this
729+ /// buffer. If the file changed underneath and, unless it was reloaded
730+ /// since, the vault still holds it as this buffer last read or wrote it,
731+ /// but the block's own text is unchanged, the change was elsewhere
732+ /// (another section, a property): the file as it is now is taken as
733+ /// what the buffer has read, the change kept. Otherwise the save is
734+ /// refused.
735+ fn rebase(&mut self, vault: &mut Vault, owner: Owner) -> std::io::Result<()> {
736+ let Some(info) = self.owners.get(&owner).cloned() else {
737+ return Ok(());
738+ };
739+ let (file, node) = self.locate(vault, &info).ok_or_else(|| not_found(&info))?;
740+ let path = vault.tree.files[file].path.clone();
741+ // a block file renamed since (`fold check --fix`, another program)
742+ // is checked against the text read under its old name: no hash
743+ // under the new one is not a file unchanged
744+ if info.path != path {
745+ if let Some(h) = self.base_hashes.get(&info.path).cloned() {
746+ self.base_hashes.insert(path.clone(), h);
747+ }
748+ if let Some(i) = self.owners.get_mut(&owner) {
749+ i.path = path.clone();
750+ }
751+ }
752+ let on_disk = std::fs::read_to_string(vault.dir.join(&path)).unwrap_or_default();
753+ let Some(base) = self.base_hashes.get(&path).filter(|h| **h != hash(&on_disk)) else {
754+ return Ok(());
755+ };
756+ let region = Span {
757+ start: info.start,
758+ end: info.end,
759+ };
760+ let known = &vault.tree.files[file].text;
761+ let span = (*base == hash(known))
762+ .then(|| span_now(known, &on_disk, node.is_none().then_some(region)))
763+ .flatten();
764+ let Some(span) = span else {
765+ return Err(std::io::Error::other(format!(
766+ "{} changed on disk; not overwriting",
767+ path
768+ )));
769+ };
770+ // the vault writes over the file as it is now (its write guard
771+ // compares with it)
772+ vault.reparse(file, &on_disk)?;
773+ self.base_hashes.insert(path, hash(&on_disk));
774+ if node.is_none() {
775+ if let Some(i) = self.owners.get_mut(&owner) {
776+ (i.start, i.end) = (span.start, span.end);
777+ }
778+ }
356779 Ok(())
357780 }
358781
782+ /// Take in what another program changed outside the dirty blocks'
783+ /// source spans (§5.2.5) before they are saved, so that the vault, and
784+ /// a snapshot taken of it for the op-log (§10.10), holds that change:
785+ /// undoing the save then does not revert it. A block whose own span
786+ /// changed is left for its save to refuse.
787+ pub fn rebase_dirty(&mut self, vault: &mut Vault) {
788+ self.settle(vault);
789+ for owner in self.dirty.clone() {
790+ let _ = self.rebase(vault, owner);
791+ }
792+ }
793+
794+ /// Leaving the editor without saving (*Revert*, §10.6): the unsaved
795+ /// text is dropped, not written, but a block a save took out of the
796+ /// block it was in — its title line cut and the parent written in
797+ /// transit, whether the clipboard still holds it or has let it go since
798+ /// (it has no line left here), or its title line deleted and its file
799+ /// not trashed yet — can no longer be pasted back as itself. It is
800+ /// deleted as on leaving the editor any other way (§5.2): its file goes
801+ /// to trash once no file embeds it, never left in the vault embedded
802+ /// nowhere. `vault` must hold the files as they are on disk.
803+ pub fn discard(&self, vault: &mut Vault) -> std::io::Result<()> {
804+ let lineless: Vec<Owner> = self
805+ .owners
806+ .iter()
807+ .filter(|&(o, i)| i.parent.is_some() && !self.lines.iter().any(|l| l.owner == *o))
808+ .map(|(&o, _)| o)
809+ .collect();
810+ loop {
811+ let file = self.held.iter().chain(&self.dropped).chain(&lineless).find_map(|o| {
812+ let id = self.owners.get(o)?.id.as_ref()?;
813+ match vault.tree.embed_of(id) {
814+ Some(_) => None,
815+ None => vault.tree.block_by_id(id).map(|r| r.0),
816+ }
817+ });
818+ match file {
819+ Some(f) => vault.trash_file(f)?,
820+ None => return Ok(()),
821+ }
822+ }
823+ }
824+
825+ /// A reload that changed a file this buffer holds but not what it
826+ /// shows (§11.2): `now`, the buffer the same node renders to from the
827+ /// files as they are, has the same lines, each owned by the same block
828+ /// (by id; the edited node's own by being it), nested as here. Where
829+ /// each of those blocks is and the hash of its file are taken from
830+ /// `now`, so its next save finds the file as it was read; the rest
831+ /// stays as it is: the tags the editor's undo steps and clipboard hold,
832+ /// and a block cut and not pasted back, still in transit (§5.2). False,
833+ /// and nothing taken in, where a line or its block differs, or while
834+ /// text is unsaved, whose save must still see the change (§5.2 step 5).
835+ pub fn take_in(&mut self, now: EditBuffer) -> bool {
836+ if !self.dirty.is_empty() || self.lines.len() != now.lines.len() {
837+ return false;
838+ }
839+ let mut to_now: BTreeMap<Owner, Owner> = BTreeMap::new();
840+ for (a, b) in self.lines.iter().zip(&now.lines) {
841+ let (Some(ai), Some(bi)) = (self.owners.get(&a.owner), now.owners.get(&b.owner)) else {
842+ return false;
843+ };
844+ let same = a.text == b.text && ai.id == bi.id && ai.parent.is_none() == bi.parent.is_none();
845+ if !same || *to_now.entry(a.owner).or_insert(b.owner) != b.owner {
846+ return false;
847+ }
848+ }
849+ // and each nested in the same block as there
850+ let nested = |a: &Owner| self.owners[a].parent.map(|p| to_now.get(&p).copied());
851+ if to_now.iter().any(|(a, b)| nested(a) != now.owners[b].parent.map(Some)) {
852+ return false;
853+ }
854+ for (a, b) in to_now {
855+ let info = OwnerInfo {
856+ parent: self.owners[&a].parent,
857+ ..now.owners[&b].clone()
858+ };
859+ if let Some(h) = now.base_hashes.get(&info.path) {
860+ self.base_hashes.insert(info.path.clone(), h.clone());
861+ }
862+ self.owners.insert(a, info);
863+ }
864+ true
865+ }
866+
359867 /// Splice every dirty block (§10.6: a commit may write several files,
360− /// one splice per dirty block).
868+ /// one splice per dirty block). Each block is written by its own splice
869+ /// (§5.2): one that is refused stays dirty, the others are still
870+ /// written, and the error names every refusal.
361871 pub fn save_all(&mut self, vault: &mut Vault) -> std::io::Result<usize> {
872+ self.settle(vault);
362873 let dirty = self.dirty.clone();
363874 let mut n = 0;
875+ let mut errs = Vec::new();
364876 for owner in dirty {
365− self.splice(vault, owner)?;
366− n += 1;
877+ match self.write(vault, owner) {
878+ Ok(true) => n += 1,
879+ Ok(false) => {}
880+ Err(e) => errs.push(e),
881+ }
882+ }
883+ if let Err(e) = self.trash_dropped(vault) {
884+ errs.push(e);
885+ }
886+ match errs.len() {
887+ 0 => Ok(n),
888+ 1 => Err(errs.remove(0)),
889+ _ => Err(std::io::Error::other(
890+ errs.iter().map(|e| e.to_string()).collect::<Vec<_>>().join("; "),
891+ )),
367892 }
368− Ok(n)
369893 }
370894}
371895
@@ -377,34 +901,111 @@ enum Line {
377901 Embed(usize, Option<usize>, Id),
378902}
379903
904+/// A title line's form (§4.3): its indent, and its level if it is a
905+/// heading rather than a bullet. None: not a title line.
906+fn title_form(line: &str) -> Option<(usize, Option<usize>)> {
907+ let trimmed = line.trim_start_matches(' ');
908+ let indent = line.len() - trimmed.len();
909+ let hashes = trimmed.chars().take_while(|&c| c == '#').count();
910+ let after = &trimmed[hashes..];
911+ if hashes > 0 && (after.is_empty() || after.starts_with(' ')) {
912+ return Some((indent, Some(hashes)));
913+ }
914+ let bullet = ["- ", "* ", "+ "].iter().any(|m| trimmed.starts_with(m))
915+ || ["-", "*", "+"].contains(&trimmed);
916+ bullet.then_some((indent, None))
917+}
918+
919+/// A title line's text after its indent and its heading or bullet marker,
920+/// whichever form and level it is written in.
921+fn after_marker(line: &str) -> &str {
922+ let t = line.trim_start_matches([' ', '\t']);
923+ let t = match t.strip_prefix(['-', '*', '+']) {
924+ Some(rest) => rest,
925+ None => t.trim_start_matches('#'),
926+ };
927+ t.trim()
928+}
929+
930+fn not_found(info: &OwnerInfo) -> std::io::Error {
931+ std::io::Error::new(
932+ std::io::ErrorKind::NotFound,
933+ format!("{}: edited node no longer found", info.path),
934+ )
935+}
936+
380937fn hash(text: &str) -> String {
381938 blake3::hash(text.as_bytes()).to_hex().to_string()
382939}
383940
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;
941+/// Where a block's source span (§5.2 step 5) is in `now`, a newer text of
942+/// its file than `known`, when the block's own text there is as it was:
943+/// `None` if the change touched it, or if the frontmatter names another
944+/// block now. A block (or root.md's Root) is its file after the
945+/// frontmatter (§4.9); another render root is `region` of `known`, whole
946+/// lines, followed through a line diff of `known` against `now`: its lines
947+/// must all be lines the diff keeps, in one unchanged run. Never where its
948+/// bytes merely occur in `now`: an identical node elsewhere is another
949+/// node.
950+fn span_now(known: &str, now: &str, region: Option<Span>) -> Option<Span> {
951+ let (k, n) = (parse_frontmatter(known), parse_frontmatter(now));
952+ let id = |f: &Option<Frontmatter>| f.as_ref().and_then(|f| f.props.get("id").cloned());
953+ if id(&k) != id(&n) {
954+ return None;
393955 }
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− }
956+ let Some(region) = region else {
957+ let body = |f: &Option<Frontmatter>| f.as_ref().map_or(0, |f| f.span.end);
958+ let (kb, nb) = (body(&k), body(&n));
959+ return (known[kb..] == now[nb..]).then_some(Span { start: nb, end: now.len() });
960+ };
961+ let diff = similar::TextDiff::from_lines(known, now);
962+ // the byte offset where each line starts, and the text's end
963+ let starts = |lines: &[&str]| {
964+ let mut at = vec![0];
965+ for l in lines {
966+ at.push(at[at.len() - 1] + l.len());
406967 }
407− }
968+ at
969+ };
970+ let (old, new) = (starts(diff.old_slices()), starts(diff.new_slices()));
971+ let line = |b: usize| old.binary_search(&b.min(known.len())).ok();
972+ let (first, end) = (line(region.start)?, line(region.end)?);
973+ diff.ops().iter().find_map(|op| match *op {
974+ similar::DiffOp::Equal { old_index, new_index, len } if old_index <= first && end <= old_index + len => {
975+ Some(Span {
976+ start: new[new_index + first - old_index],
977+ end: new[new_index + end - old_index],
978+ })
979+ }
980+ _ => None,
981+ })
982+}
983+
984+/// The blocks whose embed in `f` has lines nested under it: nodes, or text
985+/// other than blank lines (§4.7: a diagnostic).
986+fn crowded_embeds(f: &ParsedFile) -> Vec<&Id> {
987+ f.nodes
988+ .iter()
989+ .filter(|n| {
990+ n.content.iter().any(|c| match c {
991+ Content::Text(sp) => !sp.text(&f.text).trim().is_empty(),
992+ Content::Node(_) => true,
993+ })
994+ })
995+ .filter_map(|n| n.embed.as_ref())
996+ .collect()
997+}
998+
999+/// Where a node's own text ends: its span without the blank lines that
1000+/// separate it from what follows.
1001+fn owned_end(text: &str, span: Span) -> usize {
1002+ let old = &text[span.start.min(text.len())..span.end.min(text.len())];
1003+ let content_end = old.trim_end_matches([' ', '\t', '\r', '\n']).len();
1004+ let own = old[content_end..]
1005+ .find('\n')
1006+ .map(|i| content_end + i + 1)
1007+ .unwrap_or(old.len());
1008+ span.start + own
4081009}
4091010
4101011/// What the editor needs to know when it opens: the buffer plus a render of
crates/fold-core/src/lib.rs+1
@@ -4,6 +4,7 @@ pub mod check;
44pub mod edit;
55pub mod ident;
66pub mod merge;
7+pub mod news;
78pub mod ops;
89pub mod parse;
910pub mod reading;
crates/fold-core/src/merge.rs+255−177
@@ -2,7 +2,7 @@
22//! conflicts (`X.sync-conflict-*.md`) and in-session splice conflicts.
33
44use crate::ident::{filename, slug, Id};
5−use crate::parse::{parse_file, Block, Kind, TaskState};
5+use crate::parse::{parse_file, Block, Frontmatter, Kind, TaskState};
66use crate::tree::{NRef, Tree};
77use crate::vault::Vault;
88
@@ -49,6 +49,8 @@ pub fn merge_texts(ours: &str, theirs: &str, device: &str, timestamp: &str) -> M
4949 let o_fm = parse_frontmatter(ours);
5050 let t_fm = parse_frontmatter(theirs);
5151 let block_file = is_block(&o_fm) && is_block(&t_fm);
52+ // where O's frontmatter ends
53+ let o_head = o_fm.as_ref().map(|fm| fm.span.end);
5254 let mut ctx = Ctx {
5355 conflicts: 0,
5456 conflict_blocks: Vec::new(),
@@ -56,8 +58,8 @@ pub fn merge_texts(ours: &str, theirs: &str, device: &str, timestamp: &str) -> M
5658 insertions: 0,
5759 device,
5860 timestamp,
59− o_fm: if block_file { parse_frontmatter(ours) } else { None },
60− t_fm: if block_file { t_fm } else { None },
61+ o_fm: o_fm.filter(|_| block_file),
62+ t_fm: t_fm.filter(|_| block_file),
6163 };
6264 let merged = merge_children(&o, &t, o.root, t.root, &mut ctx, 1, 0);
6365 let text = if ctx.conflicts == 0 && ctx.insertions == 0 {
@@ -66,9 +68,9 @@ pub fn merge_texts(ours: &str, theirs: &str, device: &str, timestamp: &str) -> M
6668 } else {
6769 // O's frontmatter stays verbatim; differing keys were raised as a
6870 // conflict on the block root (§12.4)
69− match &o_fm {
70− Some(fm) => {
71− let mut head = ours[..fm.span.end].to_string();
71+ match o_head {
72+ Some(end) => {
73+ let mut head = ours[..end].to_string();
7274 if !head.ends_with("\n\n") {
7375 head.push('\n');
7476 }
@@ -89,23 +91,8 @@ pub fn merge_texts(ours: &str, theirs: &str, device: &str, timestamp: &str) -> M
8991/// frontmatter is never mistaken for body text (§12.4).
9092fn standalone_tree(text: &str) -> Tree {
9193 let fm = crate::parse::parse_frontmatter(text);
92− let id = fm
93− .as_ref()
94− .and_then(|f| f.props.get("id"))
95− .and_then(|v| Id::parse(v));
96− let block = Block {
97− id,
98− path: "m.md".into(),
99− props: fm.as_ref().map(|f| f.props.clone()).unwrap_or_default(),
100− frontmatter_raw: fm.as_ref().map(|f| f.raw.clone()).unwrap_or_default(),
101− frontmatter_span: fm.as_ref().map(|f| f.span),
102− };
103− let pf = parse_file("m.md", text, 0, Some(block));
104− Tree {
105− files: vec![pf],
106− root: (0, 0),
107− blocks: vec![],
108− }
94+ let block = Block::new("m.md", fm.as_ref().and_then(Frontmatter::id), fm.as_ref());
95+ Tree::new(vec![parse_file("m.md", text, Some(block))])
10996}
11097
11198fn node_sig(t: &Tree, r: NRef) -> (String, Option<TaskState>, String) {
@@ -145,7 +132,7 @@ fn run_lines(t: &Tree, r: NRef, sp: crate::parse::Span) -> Vec<String> {
145132 if l.trim().is_empty() {
146133 String::new()
147134 } else {
148− dedent(l, from).to_string()
135+ crate::render::dedent(l, from).to_string()
149136 }
150137 })
151138 .collect();
@@ -234,9 +221,10 @@ fn merge_children(
234221) -> String {
235222 let o_kids = o.resolved_children(op);
236223 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();
224+ // match: embed by id, else by exact title among unmatched siblings;
225+ // `t_match[i]` is the O kid that T kid `i` matched
226+ let mut t_match: Vec<Option<usize>> = vec![None; t_kids.len()];
227+ let mut pairs: Vec<(NRef, Option<NRef>)> = Vec::new();
240228 // a block file's root is the same node on both sides, whatever its
241229 // title: match it by the file's id (§12.4 rule 1)
242230 let file_id = |tr: &Tree, r: NRef| tr.node(r).block.as_ref().and_then(|b| b.id.clone());
@@ -246,18 +234,18 @@ fn merge_children(
246234 }
247235 _ => None,
248236 };
249− for &ok in &o_kids {
237+ for (j, &ok) in o_kids.iter().enumerate() {
250238 let on = o.node(ok);
251239 if let Some((rok, rtk)) = root_pair {
252240 if ok == rok {
253− t_used[0] = true;
254− pairs.push((Some(ok), Some(rtk)));
241+ t_match[0] = Some(j);
242+ pairs.push((ok, Some(rtk)));
255243 continue;
256244 }
257245 }
258246 let mut found = None;
259247 for (i, &tk) in t_kids.iter().enumerate() {
260− if t_used[i] {
248+ if t_match[i].is_some() {
261249 continue;
262250 }
263251 let tn = t.node(tk);
@@ -274,21 +262,40 @@ fn merge_children(
274262 }
275263 }
276264 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− }
265+ t_match[i] = Some(j);
266+ }
267+ pairs.push((ok, found.map(|i| t_kids[i])));
268+ }
269+ // T-only nodes are insertions, placed relative to their matched
270+ // neighbours (§12.4): after the O partner of the nearest matched node
271+ // before them in T — and after O's text there too, when T has text
272+ // between the two — or first, when no matched node precedes them
273+ let t_trailing = trailing_runs(t, tp);
274+ let mut first: Vec<NRef> = Vec::new();
275+ let mut after_node: Vec<Vec<NRef>> = vec![Vec::new(); o_kids.len()];
276+ let mut after_text: Vec<Vec<NRef>> = vec![Vec::new(); o_kids.len()];
277+ let mut anchor: Option<(usize, usize)> = None; // (T kid, its O kid)
283278 for (i, &tk) in t_kids.iter().enumerate() {
284− if !t_used[i] {
285− pairs.push((None, Some(tk)));
279+ match (t_match[i], anchor) {
280+ (Some(j), _) => anchor = Some((i, j)),
281+ (None, None) => first.push(tk),
282+ (None, Some((ti, j))) => {
283+ let text_between = t_trailing
284+ .iter()
285+ .any(|&(pos, sp)| (ti..i).contains(&pos) && !run_lines(t, tp, sp).is_empty());
286+ if text_between {
287+ after_text[j].push(tk);
288+ } else {
289+ after_node[j].push(tk);
290+ }
291+ }
286292 }
287293 }
294+ ctx.insertions += t_match.iter().filter(|m| m.is_none()).count();
295+ let inserted = |&b: &NRef| Piece::Node(t.node(b).kind, emit_subtree(t, b, level, indent));
288296 // Emit: matched pairs merge field-by-field; single-sided nodes are
289297 // 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.
298+ // children stay where O has them.
292299 let mut pieces: Vec<Piece> = Vec::new();
293300 if o.node(op).kind == Kind::Root {
294301 // the root's own text: O's, plus T's when it differs (never lost)
@@ -302,42 +309,27 @@ fn merge_children(
302309 }
303310 }
304311 }
312+ pieces.extend(first.iter().map(inserted));
305313 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) => {}
314+ for (j, (a, tk)) in pairs.into_iter().enumerate() {
315+ let text = match tk {
316+ Some(b) => merge_pair(o, t, a, b, ctx, level, indent),
317+ None => emit_subtree(o, a, level, indent),
318+ };
319+ pieces.push(Piece::Node(o.node(a).kind, text));
320+ pieces.extend(after_node[j].iter().map(inserted));
321+ for (_, sp) in o_trailing.iter().filter(|(pos, _)| *pos == j) {
322+ pieces.push(Piece::Text(emit_text(o, op, *sp, indent)));
331323 }
324+ pieces.extend(after_text[j].iter().map(inserted));
332325 }
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);
326+ // clamped by the ordering rule (§3.1): an inserted item or text goes no
327+ // later than just before the first section, an inserted section no
328+ // earlier than just after the last item; O's own order already obeys it
329+ let (mut pieces, sections): (Vec<Piece>, Vec<Piece>) = pieces
330+ .into_iter()
331+ .partition(|p| !matches!(p, Piece::Node(Kind::Section, _)));
332+ pieces.extend(sections);
341333 join_pieces(pieces)
342334}
343335
@@ -433,18 +425,24 @@ fn emit_node_with(o: &Tree, a: NRef, level: usize, indent: usize, kids: &str) ->
433425 let mut out = String::new();
434426 let ind = " ".repeat(indent);
435427 match n.kind {
428+ // embeds stay unresolved here (§12.4): the line is the reference
429+ // itself, in its own form (§4.7), never an empty title
430+ Kind::Section | Kind::Item if n.embed.is_some() => {
431+ out.push_str(&crate::render::embed_line(n, level, indent));
432+ out.push('\n');
433+ }
436434 Kind::Section => {
437435 out.push_str(&ind);
438436 out.push_str(&"#".repeat(level.max(1)));
439437 out.push(' ');
440− push_task(n.task, &mut out);
438+ crate::render::push_checkbox(n.task, &mut out);
441439 out.push_str(&n.title);
442440 out.push('\n');
443441 }
444442 Kind::Item => {
445443 out.push_str(&ind);
446444 out.push_str("- ");
447− push_task(n.task, &mut out);
445+ crate::render::push_checkbox(n.task, &mut out);
448446 out.push_str(&n.title);
449447 out.push('\n');
450448 }
@@ -464,7 +462,7 @@ fn emit_node_with(o: &Tree, a: NRef, level: usize, indent: usize, kids: &str) ->
464462 let dedent_by = o.indent(a);
465463 for l in &body {
466464 out.push_str(&ind);
467− out.push_str(dedent(l, dedent_by));
465+ out.push_str(crate::render::dedent(l, dedent_by));
468466 out.push('\n');
469467 }
470468 if !kids.trim().is_empty() {
@@ -502,53 +500,74 @@ fn emit_subtree(t: &Tree, r: NRef, level: usize, indent: usize) -> String {
502500 emit_node_with(t, r, level, indent, &join_pieces(pieces))
503501}
504502
505−fn push_task(task: Option<TaskState>, out: &mut String) {
506− match task {
507− Some(TaskState::Open) => out.push_str("[ ] "),
508− Some(TaskState::Done) => out.push_str("[x] "),
509− None => {}
510− }
511−}
512−
513−fn dedent(line: &str, cols: usize) -> &str {
514− if cols == 0 {
515− return line;
516− }
517− let mut removed = 0;
518− for (i, ch) in line.char_indices() {
519− if removed >= cols {
520− return &line[i..];
521− }
522− match ch {
523− ' ' => removed += 1,
524− '\t' => removed += 4,
525− _ => return &line[i..],
526− }
527− }
528− ""
529−}
530−
531503// ------------------------------------------------------------ vault-level
532504
533−/// Process every `*.sync-conflict-*.md` in the vault (§12.2, §13 `notes merge`).
505+/// Process every `*.sync-conflict-*.md` in the vault (§12.2, §13 `fold merge`).
534506/// Returns a list of human-readable outcomes.
535507pub fn merge_sync_conflicts(vault: &mut Vault, dry_run: bool) -> std::io::Result<Vec<String>> {
508+ merge_sync_conflicts_moving(vault, dry_run, &[])
509+}
510+
511+/// `merge_sync_conflicts` while the blocks `moving` are moved on this
512+/// device: cut in the editor and not pasted back, in transit and embedded
513+/// nowhere (§5.2). A copy made before the cut still embeds one where it
514+/// was, and the merge never deletes (§12.4): it would put the block back
515+/// there, and the paste would embed it a second time. Each copy is read
516+/// without their embeds, as it would be had it been made after the cut.
517+pub fn merge_sync_conflicts_moving(
518+ vault: &mut Vault,
519+ dry_run: bool,
520+ moving: &[Id],
521+) -> std::io::Result<Vec<String>> {
536522 let mut outcomes = Vec::new();
523+ // the files as they are, not as last read: a block that came in with
524+ // its copy is ours to merge into, not an ignored file (§12.2)
525+ vault.reload()?;
537526 for cfile in vault.conflict_files()? {
538527 let base = cfile.split(".sync-conflict-").next().unwrap().to_string() + ".md";
539528 let cpath = vault.dir.join(&cfile);
540529 let bpath = vault.dir.join(&base);
530+ // only root.md and block files are ours to merge: the conflict copy
531+ // of an ignored file is left alone, like the file (§4.1, §11.4)
532+ let parsed = base == "root.md" || vault.file_index(&base).is_some();
533+ if !parsed && bpath.exists() {
534+ outcomes.push(format!("{}: {} is not a vault file, left alone", cfile, base));
535+ continue;
536+ }
541537 let theirs = std::fs::read_to_string(&cpath)?;
542− let ours = std::fs::read_to_string(&bpath).unwrap_or_default();
538+ let tid = crate::parse::parse_frontmatter(&theirs).and_then(|f| f.id());
539+ // X.md gone: a block renamed since is still found by its id (§6.4)
540+ let bpath = match tid.as_ref().and_then(|id| vault.tree.block_by_id(id)) {
541+ Some(r) if !bpath.exists() => {
542+ vault.dir.join(&vault.tree.node(r).block.as_ref().unwrap().path)
543+ }
544+ _ => bpath,
545+ };
546+ let ours = match std::fs::read_to_string(&bpath) {
547+ Ok(text) => text,
548+ // nothing to merge against: T wins as it is, frontmatter and
549+ // all, rather than being merged into an empty O that has none
550+ Err(e) if e.kind() == std::io::ErrorKind::NotFound && (parsed || tid.is_some()) => {
551+ outcomes.push(format!("{}: {} missing, keeping theirs", cfile, base));
552+ if !dry_run {
553+ std::fs::rename(&cpath, &bpath)?;
554+ }
555+ continue;
556+ }
557+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
558+ outcomes.push(format!("{}: not a block file, left alone", cfile));
559+ continue;
560+ }
561+ // an O that cannot be read is never overwritten
562+ Err(e) => {
563+ outcomes.push(format!("{}: cannot read {}: {}, left alone", cfile, base, e));
564+ continue;
565+ }
566+ };
543567 // device + timestamp from the filename
544568 let (device, stamp) = parse_conflict_name(&cfile);
545569 // id check: differing ids mean a prefix collision, not a conflict (§12.2)
546− let oid = crate::parse::parse_frontmatter(&ours)
547− .and_then(|f| f.props.get("id").cloned())
548− .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));
570+ let oid = crate::parse::parse_frontmatter(&ours).and_then(|f| f.id());
552571 match (&oid, &tid) {
553572 (Some(a), Some(b)) if a != b => {
554573 outcomes.push(format!(
@@ -569,12 +588,16 @@ pub fn merge_sync_conflicts(vault: &mut Vault, dry_run: bool) -> std::io::Result
569588 }
570589 _ => {}
571590 }
572− let outcome = merge_texts(&ours, &theirs, &device, &stamp);
591+ let outcome = merge_texts(&ours, &without_embeds(&theirs, moving), &device, &stamp);
573592 outcomes.push(format!(
574593 "{}: {} conflict pair(s)",
575594 cfile, outcome.conflicts
576595 ));
577596 if !dry_run {
597+ // the trash first, so a trash that cannot be made stops the
598+ // merge before anything is written
599+ let trash = crate::vault::trash_dir();
600+ std::fs::create_dir_all(&trash)?;
578601 crate::vault::atomic_write(&bpath, &outcome.text)?;
579602 for (fname, text) in &outcome.conflict_blocks {
580603 let fname = fresh_block_name(vault, fname, text);
@@ -584,11 +607,11 @@ pub fn merge_sync_conflicts(vault: &mut Vault, dry_run: bool) -> std::io::Result
584607 vault.reload()?;
585608 place_sibling_embeds(vault, oid.as_ref(), &outcome.sibling_embeds)?;
586609 }
587− // the conflict file goes to trash (§12.4)
588− let trash = crate::vault::trash_dir();
589− std::fs::create_dir_all(&trash)?;
610+ // the conflict file goes to trash (§12.4), which is often on
611+ // another filesystem: a rename alone would fail there and leave
612+ // it to be merged again
590613 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())))?;
614+ crate::vault::move_file(&cpath, &trash.join(format!("{}-{}", stamp2, cfile.to_lowercase())))?;
592615 }
593616 }
594617 if !dry_run {
@@ -597,12 +620,46 @@ pub fn merge_sync_conflicts(vault: &mut Vault, dry_run: bool) -> std::io::Result
597620 Ok(outcomes)
598621}
599622
623+/// `text` without its embed lines of the blocks `ids` (§4.7), each with
624+/// the blank line after it where one is before it too, so the text around
625+/// it reads as it does where the embed was written out.
626+fn without_embeds(text: &str, ids: &[Id]) -> String {
627+ if ids.is_empty() {
628+ return text.to_string();
629+ }
630+ let t = standalone_tree(text);
631+ let mut lines: Vec<usize> = t.files[0]
632+ .nodes
633+ .iter()
634+ .filter(|n| n.embed.as_ref().is_some_and(|id| ids.contains(id)))
635+ .map(|n| n.title_span.start)
636+ .collect();
637+ lines.sort_unstable();
638+ let line_end = |at: usize| text[at..].find('\n').map_or(text.len(), |i| at + i + 1);
639+ // the line before `at` is blank, or there is none
640+ let blank_before = |at: usize| {
641+ let before = text[..at].strip_suffix('\n');
642+ before.is_none_or(|s| s.rsplit('\n').next().is_some_and(|l| l.trim().is_empty()))
643+ };
644+ let mut out = String::with_capacity(text.len());
645+ let mut kept = 0;
646+ for start in lines {
647+ let mut end = line_end(start);
648+ let after = &text[end..line_end(end)];
649+ if blank_before(start) && !after.is_empty() && after.trim().is_empty() {
650+ end += after.len();
651+ }
652+ out.push_str(&text[kept..start]);
653+ kept = end;
654+ }
655+ out.push_str(&text[kept..]);
656+ out
657+}
658+
600659/// A conflict block's filename that does not clobber an existing file:
601660/// lengthen the prefix of its id until the name is free (§6.4).
602661fn fresh_block_name(vault: &Vault, fname: &str, text: &str) -> String {
603− let id = crate::parse::parse_frontmatter(text)
604− .and_then(|f| f.props.get("id").cloned())
605− .and_then(|v| Id::parse(&v));
662+ let id = crate::parse::parse_frontmatter(text).and_then(|f| f.id());
606663 let (Some(id), Some((_, name))) = (id, crate::ident::split_filename(fname)) else {
607664 return fname.to_string();
608665 };
@@ -619,19 +676,17 @@ fn fresh_block_name(vault: &Vault, fname: &str, text: &str) -> String {
619676/// Insert embeds right after the embed of block `owner` in its parent file,
620677/// at its indent, so each conflict block is the next sibling of the block it
621678/// 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.
679+/// the owner is embedded at the end of `root.md` with them right after it:
680+/// placed alone, they would pair with whatever node happened to be last.
623681fn 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− })
682+ // the embed the owner is stitched in at: a second one reads as broken
683+ // (§6.2) and pairs with nothing
684+ let found = owner.and_then(|oid| vault.tree.embed_of(oid)).map(|(fi, ni)| {
685+ let f = &vault.tree.files[fi];
686+ let nd = &f.nodes[ni];
687+ // the same form as the owner's embed (§4.7)
688+ let heading = (nd.kind == Kind::Section).then(|| nd.level.unwrap_or(1));
689+ (fi, nd.title_span.end.min(f.text.len()), nd.indent, heading)
635690 });
636691 let mut insert = String::new();
637692 match found {
@@ -651,19 +706,40 @@ fn place_sibling_embeds(vault: &mut Vault, owner: Option<&Id>, ids: &[Id]) -> st
651706 vault.write_span(fi, crate::parse::Span { start: end, end }, &insert)
652707 }
653708 None => {
654− let text = vault.tree.files[0].text.clone();
655− let mut new_text = text.clone();
709+ // in the form of the owner's spelling (§4.7); a heading embed at
710+ // the end of root.md is top-level
711+ let heading = owner
712+ .and_then(|oid| vault.tree.block_by_id(oid))
713+ .is_some_and(|r| vault.tree.node(r).kind == Kind::Section);
714+ let lines: Vec<String> = owner
715+ .into_iter()
716+ .chain(ids)
717+ .map(|id| format!("{}![[{}]]\n", if heading { "# " } else { "" }, id))
718+ .collect();
719+ let mut new_text = vault.tree.files[0].text.clone();
656720 if !new_text.is_empty() && !new_text.ends_with("\n\n") {
657721 new_text.push_str(if new_text.ends_with('\n') { "\n" } else { "\n\n" });
658722 }
659− for id in ids {
660− new_text.push_str(&format!("![[{}]]\n", id));
661− }
723+ // sibling sections a blank line apart, items tight (§4.2)
724+ new_text.push_str(&lines.join(if heading { "\n" } else { "" }));
662725 vault.write_file_text(0, &new_text)
663726 }
664727 }
665728}
666729
730+/// Where a conflict copy came from (§12.4), as a screen says it: the
731+/// `conflict:` value *PHONE 20260927-100000* that a merge writes reads
732+/// *PHONE 09-27 10:00*. A value written otherwise is shown as it is.
733+pub fn copy_from(conflict: &str) -> String {
734+ let Some((device, t)) = conflict.rsplit_once(' ') else { return conflict.to_string() };
735+ let digits = |r: std::ops::Range<usize>| t.get(r).is_some_and(|d| d.bytes().all(|b| b.is_ascii_digit()));
736+ if t.len() == 15 && t.as_bytes()[8] == b'-' && digits(0..8) && digits(9..15) {
737+ format!("{} {}-{} {}:{}", device, &t[4..6], &t[6..8], &t[9..11], &t[11..13])
738+ } else {
739+ conflict.to_string()
740+ }
741+}
742+
667743fn parse_conflict_name(name: &str) -> (String, String) {
668744 // name.sync-conflict-<date>-<time>-<device>.md
669745 let mut device = "unknown".to_string();
@@ -687,32 +763,40 @@ pub fn conflict_pairs(vault: &Vault) -> Vec<(NRef, NRef)> {
687763 conflict_pairs_tree(&vault.tree)
688764}
689765
766+/// `conflict_pairs` first in the outline first, as the conflict view lists
767+/// them (§10.7, §12.5): it opens on the first new one in the outline, and
768+/// goes on down it.
769+pub fn conflict_pairs_in_order(vault: &Vault) -> Vec<(NRef, NRef)> {
770+ let mut pairs = conflict_pairs(vault);
771+ if pairs.len() > 1 {
772+ // found by their blocks' files; the outline is walked for their
773+ // order only when there is one to find
774+ let tree = &vault.tree;
775+ let mut order = std::collections::HashMap::new();
776+ tree.walk(tree.root, &mut |_, r| {
777+ let at = order.len();
778+ order.entry(r).or_insert(at);
779+ });
780+ pairs.sort_by_key(|(ours, _)| order.get(ours).copied());
781+ }
782+ pairs
783+}
784+
690785pub fn conflict_pairs_tree(tree: &Tree) -> Vec<(NRef, NRef)> {
691786 let mut out = Vec::new();
692− for (r, _id) in &tree.blocks {
693− let n = tree.node(*r);
694− if let Some(b) = &n.block {
695− if b.prop("conflict").is_some() {
696− let bid = b.id.clone().unwrap();
697− for (fi, f) in tree.files.iter().enumerate() {
698− for (ni, nd) in f.nodes.iter().enumerate() {
699− if nd.embed.as_ref() == Some(&bid) {
700− let p = nd.parent.map(|pp| (fi, pp)).unwrap();
701− let sibs = tree.raw_children(p);
702− if let Some(pos) = sibs.iter().position(|&s| s == (fi, ni)) {
703− // the conflict block is ours' next sibling (§12.4)
704− if let Some(ours) = pos.checked_sub(1).map(|i| sibs[i]) {
705− let ours = {
706− let rc = tree.resolved_child(ours);
707− if rc != ours { rc } else { ours }
708− };
709− out.push((ours, *r));
710− }
711− }
712− }
713− }
714− }
715− }
787+ for (r, id) in &tree.blocks {
788+ if tree.node(*r).conflict().is_none() {
789+ continue;
790+ }
791+ // where it is stitched in: a second embed of it reads as broken
792+ // (§6.2), the side of no pair
793+ let Some(e) = tree.embed_of(id) else { continue };
794+ let Some(p) = tree.node(e).parent else { continue };
795+ let sibs = tree.raw_children((e.0, p));
796+ // the conflict block is ours' next sibling (§12.4)
797+ let ours = sibs.iter().position(|&s| s == e).and_then(|i| i.checked_sub(1)).map(|i| sibs[i]);
798+ if let Some(ours) = ours {
799+ out.push((tree.resolved_child(ours), *r));
716800 }
717801 }
718802 out
@@ -779,7 +863,6 @@ pub fn resolve_keep_theirs(vault: &mut Vault, ours: NRef, theirs: NRef) -> std::
779863 crate::ops::set_frontmatter_key(vault, file, &k, Some(&v))?;
780864 }
781865 // a title change does not rename the file (§6.4)
782− vault.reload()?;
783866 } else {
784867 // plain node: replace its span with theirs' re-levelled text
785868 let parent_level = on.parent.map(|p| vault.tree.level((ours.0, p))).unwrap_or(0);
@@ -810,22 +893,17 @@ fn delete_embed_and_block(
810893 block_ref: NRef,
811894) -> std::io::Result<()> {
812895 // remove the embed line from whichever file holds it
813− if let Some(id) = &b.id {
814− for (fi, f) in vault.tree.files.iter().enumerate() {
815− if let Some(ni) = f.nodes.iter().position(|nd| nd.embed.as_ref() == Some(id)) {
816− let span = vault.tree.files[fi].nodes[ni].span;
817− let text = vault.tree.files[fi].text.clone();
818− let mut start = span.start;
819− let mut end = span.end;
820− if start >= 2 && &text.as_bytes()[start - 2..start] == b"\n\n" {
821− start -= 1;
822− } else if end < text.len() && text.as_bytes()[end] == b'\n' {
823− end += 1;
824− }
825− vault.write_span(fi, crate::parse::Span { start, end }, "")?;
826− break;
827− }
828− }
896+ if let Some(e) = b.id.as_ref().and_then(|id| vault.tree.embed_of(id)) {
897+ let span = vault.tree.node(e).span;
898+ let text = vault.tree.text_of(e);
899+ let mut start = span.start;
900+ let mut end = span.end;
901+ if start >= 2 && &text.as_bytes()[start - 2..start] == b"\n\n" {
902+ start -= 1;
903+ } else if end < text.len() && text.as_bytes()[end] == b'\n' {
904+ end += 1;
905+ }
906+ vault.write_span(e.0, crate::parse::Span { start, end }, "")?;
829907 }
830908 let file = block_ref.0;
831909 vault.trash_file(file)
crates/fold-core/src/news.rsnew file+106
@@ -0,0 +1,106 @@
1+//! What a reload took in, in outline terms (§11.2), as the TUI's status bar
2+//! and the app's message say it: *↻ changed outside fold: Inbox (+1 item)*.
3+
4+use crate::parse::Kind;
5+use crate::vault::{NodeKey, Vault};
6+
7+/// A top-level node before or after a reload: what it holds, and a hash of
8+/// its text, to say what changed.
9+#[derive(Clone)]
10+pub struct Top {
11+ key: NodeKey,
12+ title: String,
13+ items: usize,
14+ sections: usize,
15+ text: u64,
16+}
17+
18+/// A vault's top-level nodes, as `Top`s.
19+pub fn tops(vault: &Vault) -> Vec<Top> {
20+ use std::hash::{Hash, Hasher};
21+ let tree = &vault.tree;
22+ tree.resolved_children(tree.root)
23+ .into_iter()
24+ .map(|top| {
25+ let (mut items, mut sections) = (0, 0);
26+ let mut text = std::collections::hash_map::DefaultHasher::new();
27+ tree.walk(top, &mut |t, r| {
28+ let n = t.node(r);
29+ let file = t.text_of(r);
30+ n.title_span.text(file).hash(&mut text);
31+ n.text_lines(file).hash(&mut text);
32+ n.block.as_ref().map(|b| &b.frontmatter_raw).hash(&mut text);
33+ match n.kind {
34+ _ if r == top => {}
35+ Kind::Item => items += 1,
36+ Kind::Section => sections += 1,
37+ Kind::Root => {}
38+ }
39+ });
40+ Top { key: vault.key_of(top), title: tree.node(top).title.clone(), items, sections, text: text.finish() }
41+ })
42+ .collect()
43+}
44+
45+/// The files as read before the editor's save, `after`, with what a merge
46+/// of sync-conflict copies then took in over them (§11.2): each top-level
47+/// node it changed from `was` to `now` moves by as much, one it brought
48+/// comes in and one gone by then goes. What the save typed, in `was` and
49+/// `now` both, is not laid over.
50+pub fn merged_over(mut after: Vec<Top>, was: &[Top], now: Vec<Top>) -> Vec<Top> {
51+ after.retain(|a| !was.iter().any(|w| w.key == a.key) || now.iter().any(|n| n.key == a.key));
52+ for n in now {
53+ match (was.iter().find(|w| w.key == n.key), after.iter_mut().find(|a| a.key == n.key)) {
54+ (Some(w), Some(a)) if w.text != n.text => {
55+ a.items = (a.items + n.items).saturating_sub(w.items);
56+ a.sections = (a.sections + n.sections).saturating_sub(w.sections);
57+ a.title = n.title;
58+ a.text = n.text;
59+ }
60+ (None, None) => after.push(n),
61+ _ => {}
62+ }
63+ }
64+ after
65+}
66+
67+/// Whether two outlines' top-level nodes are the same, text and all:
68+/// nothing came in (§11.2).
69+pub fn same_tops(before: &[Top], after: &[Top]) -> bool {
70+ before.iter().map(|t| (&t.key, t.text)).eq(after.iter().map(|t| (&t.key, t.text)))
71+}
72+
73+/// What a reload took in, in outline terms (§11.2): each top-level node
74+/// that changed, came or went, and the items or sections it gained or lost.
75+/// With `typed`, that the editor's typing was saved first comes ahead of
76+/// them, which give way where the status bar is short (§10.6).
77+pub fn changed_outside(before: &[Top], after: &[Top], typed: bool) -> String {
78+ let mut parts = Vec::new();
79+ for a in after {
80+ match before.iter().find(|b| b.key == a.key) {
81+ None => parts.push(format!("{} (new)", a.title)),
82+ Some(b) if b.text != a.text => {
83+ let counts: Vec<String> = [(b.items, a.items, "item"), (b.sections, a.sections, "section")]
84+ .into_iter()
85+ .filter(|(was, now, _)| was != now)
86+ .map(|(was, now, what)| {
87+ let n = was.abs_diff(now);
88+ format!("{}{} {}{}", if now > was { "+" } else { "−" }, n, what, if n == 1 { "" } else { "s" })
89+ })
90+ .collect();
91+ let what = if counts.is_empty() { "edited".to_string() } else { counts.join(", ") };
92+ parts.push(format!("{} ({})", a.title, what));
93+ }
94+ Some(_) => {}
95+ }
96+ }
97+ for b in before.iter().filter(|b| !after.iter().any(|a| a.key == b.key)) {
98+ parts.push(format!("{} (removed)", b.title));
99+ }
100+ let lead = if typed { "↻ typing saved; changed outside fold" } else { "↻ changed outside fold" };
101+ match parts.len() {
102+ 0 => lead.into(),
103+ n if n > 3 => format!("{}: {} and {} more", lead, parts[..2].join(", "), n - 2),
104+ _ => format!("{}: {}", lead, parts.join(", ")),
105+ }
106+}
crates/fold-core/src/ops.rs+793−223
@@ -110,6 +110,159 @@ impl Inverse {
110110 }
111111}
112112
113+/// The session op log (§10.10): what undo would take back and redo would
114+/// do again, and the blocks an editor save left in transit (§5.2), each
115+/// by its file, with the length of `undo` once the entry of the save that
116+/// wrote its embed out was in. What the TUI and the app hold between
117+/// verbs.
118+#[derive(Debug, Default)]
119+pub struct OpLog {
120+ undo: Vec<Inverse>,
121+ redo: Vec<Inverse>,
122+ transit: Vec<(String, usize)>,
123+}
124+
125+impl OpLog {
126+ /// The entry undo would take back.
127+ pub fn last_undo(&self) -> Option<&Inverse> {
128+ self.undo.last()
129+ }
130+
131+ /// The entry redo would do again.
132+ pub fn last_redo(&self) -> Option<&Inverse> {
133+ self.redo.last()
134+ }
135+
136+ /// How many entries undo can take back.
137+ pub fn depth(&self) -> usize {
138+ self.undo.len()
139+ }
140+
141+ /// An operation's entry. `None`, an operation that changed nothing,
142+ /// leaves the log as it is, the redo stack too. True when recorded.
143+ pub fn record(&mut self, inv: Option<Inverse>) -> bool {
144+ let Some(inv) = inv else { return false };
145+ self.undo.push(inv);
146+ self.redo.clear();
147+ true
148+ }
149+
150+ /// `record` for a save of the editor, its *Revert*, or a reload that
151+ /// re-renders it (§11.2): a block an earlier save left in transit
152+ /// (§5.2) and this one deletes is deleted in the entry of that save,
153+ /// which wrote its embed out, so one undo puts back its file and its
154+ /// embed together, never the file embedded nowhere. One this save
155+ /// pastes back, writing its embed again, makes that save, this one and
156+ /// those between one entry, so no undo stops where its file is embedded
157+ /// nowhere either. `in_transit` are the files of the blocks the editor
158+ /// holds in transit now, remembered with this save's entry until their
159+ /// file is deleted or embedded again; `editing`, whether it is still
160+ /// open. True when there was an entry to record.
161+ pub fn record_edit(&mut self, inv: Option<Inverse>, vault: &Vault, editing: bool, in_transit: Vec<String>) -> bool {
162+ // the length of `undo` once this save's entry is in, if it has one
163+ let mut at = None;
164+ let recorded = inv.is_some();
165+ if let Some(mut inv) = inv {
166+ let (transit, undo) = (&self.transit, &mut self.undo);
167+ inv.changes.retain(|c| {
168+ let entry = transit.iter().find(|(p, _)| *p == c.path && c.after.is_none());
169+ // that entry, and none since, left the file as it was
170+ let Some(&(_, n)) = entry.filter(|&&(_, n)| n > 0 && n <= undo.len()) else { return true };
171+ if undo[n - 1..].iter().any(|e| e.changes.iter().any(|x| x.path == c.path)) {
172+ return true;
173+ }
174+ undo[n - 1].changes.push(c.clone());
175+ false
176+ });
177+ if !inv.changes.is_empty() {
178+ self.undo.push(inv);
179+ at = Some(self.undo.len());
180+ }
181+ self.redo.clear();
182+ }
183+ // pasted back: joined from the earliest save that wrote out the
184+ // embed of a block embedded again now
185+ let mut back: Vec<usize> =
186+ self.transit.iter().filter(|(p, _)| embedded(vault, p) == Some(true)).map(|&(_, n)| n).collect();
187+ back.sort_unstable();
188+ if let Some(n) = back.into_iter().find(|&n| n > 0 && self.join(n - 1)) {
189+ // the entries from `n - 1` on are the one at `n - 1` now, if any
190+ let joined = self.undo.len() == n;
191+ at = at.and(joined.then_some(n));
192+ self.transit.retain_mut(|(_, m)| {
193+ *m = (*m).min(n);
194+ *m < n || joined
195+ });
196+ }
197+ // still in transit, or pasted back with its embed not written yet
198+ self.transit.retain(|(p, _)| editing && embedded(vault, p) == Some(false));
199+ if let Some(n) = at {
200+ for p in in_transit {
201+ if !self.transit.iter().any(|(q, _)| *q == p) {
202+ self.transit.push((p, n));
203+ }
204+ }
205+ }
206+ recorded
207+ }
208+
209+ /// Make the entries from `from` on one: for each file, its text before
210+ /// the first and after the last; one that ends as it began is left
211+ /// out, and an entry left with none is dropped. False, with the entries
212+ /// as they were, where a file changed from outside between two of
213+ /// them: undoing them as one would drop that change.
214+ fn join(&mut self, from: usize) -> bool {
215+ let Some(first) = self.undo.get(from) else { return false };
216+ let description = first.description.clone();
217+ let mut changes: Vec<Change> = Vec::new();
218+ for c in self.undo[from..].iter().flat_map(|e| &e.changes) {
219+ match changes.iter_mut().find(|x| x.path == c.path) {
220+ Some(x) if x.after == c.before => x.after = c.after.clone(),
221+ Some(_) => return false,
222+ None => changes.push(c.clone()),
223+ }
224+ }
225+ changes.retain(|c| c.before != c.after);
226+ self.undo.truncate(from);
227+ if !changes.is_empty() {
228+ self.undo.push(Inverse { changes, description });
229+ }
230+ true
231+ }
232+
233+ /// Undo the last entry, or with `undo` false redo the last one undone
234+ /// (§10.10), and say which by its description. `None` when there is
235+ /// none. Refused — a file it touched changed since — it stays where
236+ /// it was. Either way the blocks in transit are let go: the entries
237+ /// they were remembered with may go.
238+ pub fn step(&mut self, vault: &mut Vault, undo: bool) -> Option<std::io::Result<String>> {
239+ self.transit.clear();
240+ let (from, to) = if undo { (&mut self.undo, &mut self.redo) } else { (&mut self.redo, &mut self.undo) };
241+ let inv = from.pop()?;
242+ let res = if undo { inv.undo(vault) } else { inv.redo(vault) };
243+ Some(match res {
244+ Ok(()) => {
245+ let done = inv.description.clone();
246+ to.push(inv);
247+ Ok(done)
248+ }
249+ Err(e) => {
250+ from.push(inv);
251+ Err(e)
252+ }
253+ })
254+ }
255+}
256+
257+/// Whether the block whose file is `path` is embedded now; `None` where
258+/// the file is gone.
259+fn embedded(vault: &Vault, path: &str) -> Option<bool> {
260+ let tree = &vault.tree;
261+ let f = vault.file_index(path)?;
262+ let (_, id) = tree.blocks.iter().find(|(r, _)| r.0 == f)?;
263+ Some(tree.embed_of(id).is_some())
264+}
265+
113266fn today() -> String {
114267 jiff::Zoned::now().date().to_string()
115268}
@@ -136,13 +289,6 @@ fn capture_inner(
136289 task: bool,
137290 target: Option<NRef>,
138291) -> 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− };
146292 // The first line is the title; further lines are the item's body, so a
147293 // captured document (`capture < file.md`) nests under the item instead
148294 // of injecting structure beside it (§4.1): its headings are pushed below
@@ -154,14 +300,17 @@ fn capture_inner(
154300 } else {
155301 first.trim().to_string()
156302 };
157− let mut line = format!("- {}", title);
303+ // refused before the inbox or its day is created
304+ let mut line = item_line(&title)?;
305+ let dest = match target {
306+ Some(t) => write_target(&vault.tree, t)?,
307+ None => {
308+ let inbox = find_or_create_inbox(vault)?;
309+ find_or_create_day(vault, inbox)?
310+ }
311+ };
158312 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− };
313+ let item_indent = child_indent(&vault.tree, dest);
165314 let level = vault.tree.level(dest) as isize;
166315 let body = shift_lines(rest.trim_end(), level, item_indent as isize + 2);
167316 line.push('\n');
@@ -188,9 +337,13 @@ fn find_or_create_day(vault: &mut Vault, inbox: NRef) -> std::io::Result<NRef> {
188337 return Ok(c);
189338 }
190339 }
191− // `## <today>` as the last child of Inbox (§7)
340+ // `## <today>` as the last child of Inbox (§7), at its child indent: an
341+ // Inbox respelled as an item is still the inbox (§3.1); no deeper than
342+ // a day before it as written, which would take it as its child
343+ let last = vault.tree.raw_children(inbox).last().copied();
192344 let level = vault.tree.level(inbox) + 1;
193− let indent = vault.tree.indent(inbox);
345+ let level = written_level(&vault.tree, last).map_or(level, |w| level.min(w));
346+ let indent = child_indent(&vault.tree, inbox);
194347 let heading = format!(
195348 "{}{} {}",
196349 " ".repeat(indent),
@@ -199,10 +352,9 @@ fn find_or_create_day(vault: &mut Vault, inbox: NRef) -> std::io::Result<NRef> {
199352 );
200353 let inbox_key = vault.key_of(inbox);
201354 append_structural_line(vault, inbox, &heading)?;
202− vault.reload()?;
203355 let inbox = vault
204356 .find_by_key(&inbox_key)
205− .ok_or_else(|| io_err("inbox lost after reload"))?;
357+ .ok_or_else(|| io_err("inbox lost after the write"))?;
206358 for c in vault.tree.resolved_children(inbox) {
207359 if vault.tree.node(c).title == day {
208360 return Ok(c);
@@ -282,20 +434,22 @@ fn append_child_line(
282434 line: &str,
283435 section_ok: bool,
284436) -> std::io::Result<NRef> {
437+ // under the node an embed stands for: an embed line has no children
438+ // in its file (§6.2)
439+ let parent = write_target(&vault.tree, parent)?;
285440 let parent_key = vault.key_of(parent);
286441 let node = vault.tree.node(parent);
287442 let file = parent.0;
288443 // Items under a section sit at the section's own indent; items under an
289444 // 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− };
445+ let indent = child_indent(&vault.tree, parent);
295446 let text = vault.tree.files[file].text.clone();
296447 let kids = vault.tree.raw_children(parent);
297448 let is_section = |r: NRef| vault.tree.node(r).kind == Kind::Section;
298449 let first_section = kids.iter().position(|&c| is_section(c));
450+ // §4.2 puts one blank line between a node's body and its first child:
451+ // an item with no body has its first child right under its title
452+ let bare_item = node.kind == Kind::Item && node.body_lines(&text).is_empty();
299453 let index;
300454 match (first_section, section_ok) {
301455 (Some(_), true) => {
@@ -307,10 +461,12 @@ fn append_child_line(
307461 *kids.iter().rev().find(|&&c| is_section(c)).unwrap()
308462 };
309463 let title = line.trim_start().strip_prefix("- ").unwrap_or(line.trim_start());
464+ // at the last section's written indent, which its parent
465+ // reaches, and written level, which keeps it beside it
310466 let heading = format!(
311467 "{}{} {}",
312− " ".repeat(vault.tree.indent(last)),
313− "#".repeat(vault.tree.level(last)),
468+ " ".repeat(vault.tree.node(last).indent),
469+ "#".repeat(written_level(&vault.tree, Some(last)).unwrap_or(1)),
314470 title
315471 );
316472 let pos = trimmed_end(&text, node.span);
@@ -325,7 +481,7 @@ fn append_child_line(
325481 pos -= 1;
326482 }
327483 let prev_is_item = fs > 0 && !is_section(kids[fs - 1]);
328− let sep = if prev_is_item { "" } else { "\n" };
484+ let sep = if prev_is_item || (fs == 0 && bare_item) { "" } else { "\n" };
329485 vault.write_span(
330486 file,
331487 Span { start: pos, end: pos },
@@ -337,17 +493,16 @@ fn append_child_line(
337493 let line_indented = format!("{}{}", " ".repeat(indent), line);
338494 let pos = trimmed_end(&text, node.span);
339495 // 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);
496+ // (tight list), or there is none under a bare item
497+ let prev_is_item = kids.last().map(|&c| !is_section(c)).unwrap_or(bare_item);
342498 let sep = if prev_is_item { "\n" } else { "\n\n" };
343499 insert_at(vault, file, pos, sep, &line_indented)?;
344500 index = kids.len();
345501 }
346502 }
347− vault.reload()?;
348503 let parent = vault
349504 .find_by_key(&parent_key)
350− .ok_or_else(|| io_err("parent lost after reload"))?;
505+ .ok_or_else(|| io_err("parent lost after the write"))?;
351506 vault
352507 .tree
353508 .resolved_children(parent)
@@ -372,7 +527,7 @@ pub fn toggle_task(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
372527
373528/// Toggle task-ness itself (§10.3 `t`).
374529pub fn toggle_taskness(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
375− let r = vault.tree.resolved_child(r);
530+ let r = write_target(&vault.tree, r)?;
376531 let state = match vault.tree.node(r).task {
377532 Some(_) => None,
378533 None => Some(TaskState::Open),
@@ -387,8 +542,11 @@ pub fn set_task(vault: &mut Vault, r: NRef, state: Option<TaskState>) -> std::io
387542 let n = vault.tree.node(r);
388543 let is_block = n.is_block();
389544 let file = r.0;
390− let ts = n.title_span;
391− let line = ts.text(&vault.tree.files[file].text).to_string();
545+ // a setext title has no marker to put the checkbox after: it is
546+ // rewritten as an ATX heading, underline and all (§4.2)
547+ let (ts, line) = setext_as_atx(&vault.tree, r).unwrap_or_else(|| {
548+ (n.title_span, n.title_span.text(&vault.tree.files[file].text).to_string())
549+ });
392550 let mark = |st: TaskState| match st {
393551 TaskState::Open => "[ ]",
394552 TaskState::Done => "[x]", // also rewrites `[X]` / `[-]`
@@ -407,7 +565,9 @@ pub fn set_task(vault: &mut Vault, r: NRef, state: Option<TaskState>) -> std::io
407565 },
408566 (None, None) => line.clone(),
409567 };
410− if new_line != line {
568+ // `- [ ] ---` unchecked is `- ---`, a thematic break (§4.4)
569+ not_a_break(&new_line)?;
570+ if new_line != ts.text(&vault.tree.files[file].text) {
411571 vault.write_span(file, ts, &new_line)?;
412572 }
413573 if is_block {
@@ -440,6 +600,26 @@ fn marker_end(line: &str) -> Option<usize> {
440600 }
441601}
442602
603+/// A setext section's title (§4.2: read, converted on write) as an ATX
604+/// heading at its written level and indent, with the span it replaces: the
605+/// title line and its underline, the line after it. `None` for any other
606+/// node. The written level keeps what nests under the heading unchanged.
607+fn setext_as_atx(tree: &crate::tree::Tree, r: NRef) -> Option<(Span, String)> {
608+ let n = tree.node(r);
609+ let text = tree.text_of(r);
610+ let line = n.title_span.text(text);
611+ let t = line.trim_start();
612+ // every other section's title line is an ATX heading (or a heading embed)
613+ if n.kind != Kind::Section || atx_hashes(t).is_some() {
614+ return None;
615+ }
616+ let under = text[n.title_span.end..].find('\n').map_or(text.len(), |i| n.title_span.end + i + 1);
617+ let end = text[under..].find('\n').map_or(text.len(), |i| under + i);
618+ let indent = &line[..line.len() - t.len()];
619+ let atx = format!("{}{} {}", indent, "#".repeat(n.level.unwrap_or(1)), n.title);
620+ Some((Span { start: n.title_span.start, end }, atx))
621+}
622+
443623/// Byte range of the checkbox right after the marker (`[ ]`, `[x]`, `[X]`,
444624/// `[-]`) plus the space after it (§4.2). Checkbox-like text later in the
445625/// title is title text.
@@ -468,17 +648,13 @@ pub fn set_frontmatter_key(
468648 value: Option<&str>,
469649) -> std::io::Result<()> {
470650 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());
651+ let block = file_block(&f).cloned();
473652 let (mut raw, fm_span) = match &block {
474653 Some(b) => (b.frontmatter_raw.clone(), b.frontmatter_span),
475654 None => (String::new(), None),
476655 };
477656 // rewrite the line for `key` inside raw
478657 let mut lines: Vec<String> = raw.split_inclusive('\n').map(|s| s.to_string()).collect();
479− if !lines.is_empty() && !lines.last().unwrap().ends_with('\n') {
480− // keep as is; we append a newline when needed
481− }
482658 let key_prefix = format!("{}:", key);
483659 let mut found = false;
484660 for l in lines.iter_mut() {
@@ -525,14 +701,18 @@ pub fn set_frontmatter_key(
525701 vault.write_file_text(file, &new_text)
526702}
527703
704+/// The block whose frontmatter a file holds: a block file's root, its
705+/// first node (§4.9); root.md's, the implicit Root, whose frontmatter holds
706+/// vault-level properties.
707+fn file_block(f: &crate::parse::ParsedFile) -> Option<&crate::parse::Block> {
708+ let root = &f.nodes[f.root_node];
709+ root.block.as_ref().or_else(|| f.nodes[*root.children.first()?].block.as_ref())
710+}
711+
528712/// All top-level keys of a block's frontmatter, for the property editor.
529713pub fn frontmatter_lines(vault: &Vault, file: usize) -> Vec<(String, String, bool)> {
530714 // (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 {
715+ let Some(b) = file_block(&vault.tree.files[file]) else {
536716 return Vec::new();
537717 };
538718 let mut out = Vec::new();
@@ -555,31 +735,54 @@ pub fn frontmatter_lines(vault: &Vault, file: usize) -> Vec<(String, String, boo
555735
556736/// Delete a subtree: its span leaves its file; block files go to trash (§11.5),
557737/// including blocks nested anywhere under it, so none is left orphaned.
558−pub fn delete_subtree(vault: &mut Vault, r: NRef) -> std::io::Result<String> {
738+/// Returns how many nodes went, the node itself included, for the TUI to
739+/// say (§10.1).
740+pub fn delete_subtree(vault: &mut Vault, r: NRef) -> std::io::Result<usize> {
741+ // a node's conflict copies go with it, or the first would pair with
742+ // the node before it (§12.5); a copy goes alone, as keeping ours
743+ // sends it
744+ let copies: Vec<Id> = conflict_copies(&vault.tree, r)
745+ .into_iter()
746+ .filter_map(|c| vault.tree.node(c).block.as_ref()?.id.clone())
747+ .collect();
748+ let mut count = delete_one(vault, r)?;
749+ for id in copies {
750+ if let Some(c) = vault.tree.block_by_id(&id) {
751+ count += delete_one(vault, c)?;
752+ }
753+ }
754+ Ok(count)
755+}
756+
757+/// `delete_subtree` for `r` alone.
758+fn delete_one(vault: &mut Vault, r: NRef) -> std::io::Result<usize> {
559759 let n = vault.tree.node(r);
560760 if n.kind == Kind::Root {
561761 return Err(io_err("cannot delete the root"));
562762 }
763+ let mut count = 0;
764+ vault.tree.walk(vault.tree.resolved_child(r), &mut |_, _| count += 1);
563765 if n.is_block() || n.is_embed() {
564766 let target = if n.is_embed() { vault.tree.resolved_child(r) } else { r };
565767 if target == r && n.is_embed() {
566− // broken embed: just the line
567− let span = n.span;
568− remove_span_with_separator(vault, r.0, span)?;
569− return Ok("broken embed removed".into());
768+ // broken embed: just the line. So for a second embed of a
769+ // block, which renders as broken (§6.2): the block, and the
770+ // embed it is stitched in at, are not this line's
771+ let span = embed_line_span(&vault.tree, r);
772+ remove_span(vault, r.0, span)?;
773+ return Ok(1);
570774 }
775+ // the block itself first: it leaves by its own embed
571776 let ids = nested_block_ids(vault, target);
572− let path = vault.tree.node(target).block.as_ref().unwrap().path.clone();
573− for id in ids {
574− trash_block(vault, &id)?;
777+ if let Some((own, nested)) = ids.split_first() {
778+ trash_block(vault, own)?;
779+ trash_nested(vault, nested)?;
575780 }
576− return Ok(format!("block {:?} trashed", path));
781+ return Ok(count);
577782 }
578783 let ids = plain_remove(vault, r)?;
579− for id in ids {
580− trash_block(vault, &id)?;
581− }
582− Ok("subtree trashed".into())
784+ trash_nested(vault, &ids)?;
785+ Ok(count)
583786}
584787
585788/// Ids of every block in the resolved subtree of `r` (pre-order, `r` itself
@@ -602,22 +805,16 @@ fn plain_remove(vault: &mut Vault, r: NRef) -> std::io::Result<Vec<Id>> {
602805 let name = format!("{}.md", slug(&vault.tree.node(r).title));
603806 vault.trash_text(&name, &text)?;
604807 let span = vault.tree.node(r).span;
605− remove_span_with_separator(vault, r.0, span)?;
808+ remove_span(vault, r.0, span)?;
606809 Ok(ids)
607810}
608811
609−/// Remove a block's embed line (if it is still anywhere) and move its file to
610−/// the trash.
812+/// Remove the embed line a block is stitched in at (if it is still
813+/// anywhere) and move its file to the trash.
611814fn 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;
620− remove_span_with_separator(vault, e.0, span)?;
815+ if let Some(e) = vault.tree.embed_of(id) {
816+ let span = embed_line_span(&vault.tree, e);
817+ remove_span(vault, e.0, span)?;
621818 }
622819 if let Some(b) = vault.tree.block_by_id(id) {
623820 vault.trash_file(b.0)?;
@@ -625,6 +822,36 @@ fn trash_block(vault: &mut Vault, id: &Id) -> std::io::Result<()> {
625822 Ok(())
626823}
627824
825+/// Move the files of blocks nested in a removed subtree to the trash. Their
826+/// embeds went with the lines and files that held them: an embed of one
827+/// still elsewhere is a second embed, a line of its own (§6.2), and stays.
828+fn trash_nested(vault: &mut Vault, ids: &[Id]) -> std::io::Result<()> {
829+ for id in ids {
830+ if let Some(b) = vault.tree.block_by_id(id) {
831+ vault.trash_file(b.0)?;
832+ }
833+ }
834+ Ok(())
835+}
836+
837+/// The part of an embed's span that is the embed itself: its line and the
838+/// blank lines after it. Lines nested under an embed are a diagnostic
839+/// (§4.7) that the reading pane shows in the parent, so they stay there
840+/// when the embed goes: nothing else holds a copy of them (§11.5).
841+fn embed_line_span(tree: &crate::tree::Tree, e: NRef) -> Span {
842+ let n = tree.node(e);
843+ let text = tree.text_of(e);
844+ let end = n.span.end.min(text.len());
845+ let mut at = text[n.title_span.end..end].find('\n').map_or(end, |i| n.title_span.end + i + 1);
846+ for l in text[at..end].split_inclusive('\n') {
847+ if !l.trim().is_empty() {
848+ break;
849+ }
850+ at += l.len();
851+ }
852+ Span { start: n.span.start, end: at }
853+}
854+
628855/// The byte range to cut when removing `span`: the node plus whatever blank
629856/// separator would otherwise be doubled or left dangling. A blank line that
630857/// separated the node from what follows stays when nothing blank precedes
@@ -658,17 +885,12 @@ fn after_removal(pos: usize, start: usize, end: usize) -> usize {
658885}
659886
660887/// Remove a span plus one adjacent blank separator line, then re-parse just
661−/// that file (no full reload, so other files' node refs stay valid).
662−fn remove_span_no_reload(vault: &mut Vault, file: usize, span: Span) -> std::io::Result<()> {
888+/// that file, so other files' node refs stay valid.
889+fn remove_span(vault: &mut Vault, file: usize, span: Span) -> std::io::Result<()> {
663890 let (start, end) = removal_range(&vault.tree.files[file].text, span);
664891 vault.write_span(file, Span { start, end }, "")
665892}
666893
667−/// Remove a span plus one adjacent blank separator line.
668−fn remove_span_with_separator(vault: &mut Vault, file: usize, span: Span) -> std::io::Result<()> {
669− remove_span_no_reload(vault, file, span)
670−}
671−
672894/// Yank: render the subtree resolved (the register's text) (§10.3).
673895pub fn yank(vault: &Vault, r: NRef) -> String {
674896 render(&vault.tree, r, 1, true)
@@ -676,7 +898,7 @@ pub fn yank(vault: &Vault, r: NRef) -> String {
676898
677899/// Paste rendered text after/before a node as siblings (§10.3 `p`/`P`),
678900/// clamped by the ordering rule (§3.1).
679−pub fn paste(vault: &mut Vault, at: NRef, text: &str, after: bool) -> std::io::Result<bool> {
901+pub fn paste(vault: &mut Vault, at: NRef, text: &str, after: bool) -> std::io::Result<Placed> {
680902 if vault.tree.node(at).kind == Kind::Root {
681903 return Err(io_err("cannot paste beside the root"));
682904 }
@@ -687,7 +909,8 @@ pub fn paste(vault: &mut Vault, at: NRef, text: &str, after: bool) -> std::io::R
687909 };
688910 let pos = vault.tree.raw_children(parent).iter().position(|&k| k == at).unwrap_or(0);
689911 let shifted = shift_document(text, vault.tree.level(parent), child_indent(&vault.tree, parent));
690− place(vault, None, parent, pos + after as usize, &shifted)
912+ let (nodes, clamped) = place(vault, &[], parent, pos + after as usize, &shifted)?;
913+ Placed::of(&nodes, 0, clamped)
691914}
692915
693916/// Parse a standalone document and re-emit it as children of a node whose
@@ -696,19 +919,9 @@ pub fn paste(vault: &mut Vault, at: NRef, text: &str, after: bool) -> std::io::R
696919/// in a section of level `parent_level`. Everything inside keeps its position
697920/// relative to its top-level node.
698921pub fn shift_document(text: &str, parent_level: usize, indent: usize) -> String {
699− let block = crate::parse::Block {
700− id: None,
701− path: "clip.md".into(),
702− props: Default::default(),
703− frontmatter_raw: String::new(),
704− frontmatter_span: None,
705− };
706− 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− };
922+ let block = crate::parse::Block::new("clip.md", None, None);
923+ let pf = crate::parse::parse_file("clip.md", text, Some(block));
924+ let tree = crate::tree::Tree::new(vec![pf]);
712925 let mut out = String::new();
713926 let kids = tree.resolved_children(tree.root);
714927 for (i, k) in kids.iter().enumerate() {
@@ -720,11 +933,9 @@ pub fn shift_document(text: &str, parent_level: usize, indent: usize) -> String
720933 let t = first.trim_start();
721934 // render(_, 1) writes a section root at level 1 and an item root in
722935 // 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
936+ let ld = match atx_hashes(t) {
937+ Some(cur_level) => parent_level as isize + 1 - cur_level as isize,
938+ None => parent_level as isize - 1,
728939 };
729940 let cur_indent = first.len() - t.len();
730941 let id = indent as isize - cur_indent as isize;
@@ -740,26 +951,33 @@ pub fn shift_document(text: &str, parent_level: usize, indent: usize) -> String
740951}
741952
742953/// Shift every line of a rendered document by (level_delta, indent_delta).
954+/// Fenced code is shifted too, so it stays in its node's region (§3.3), but
955+/// never re-levelled.
743956fn shift_lines(raw: &str, level_delta: isize, indent_delta: isize) -> String {
744957 let mut out = String::new();
745958 let mut fence: Option<(char, usize)> = None;
746959 for line in raw.split_inclusive('\n') {
747960 let l = line.strip_suffix('\n').unwrap_or(line);
748961 let nl = if line.ends_with('\n') { "\n" } else { "" };
749− if fence_transition(l, &mut fence) || fence.is_some() {
750− out.push_str(l);
962+ let in_code = crate::parse::fence_transition(l, &mut fence) || fence.is_some();
963+ let trimmed = l.trim_start();
964+ if trimmed.is_empty() {
751965 out.push_str(nl);
752966 continue;
753967 }
754− let trimmed = l.trim_start();
755− if trimmed.is_empty() {
968+ if in_code {
969+ // only the leading spaces change: a tab, or any indentation
970+ // inside the code, stays as written
971+ let code = l.trim_start_matches(' ');
972+ let cur_indent = l.len() - code.len();
973+ out.push_str(&" ".repeat((cur_indent as isize + indent_delta).max(0) as usize));
974+ out.push_str(code);
756975 out.push_str(nl);
757976 continue;
758977 }
759978 let cur_indent = l.len() - trimmed.len();
760979 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();
980+ if let Some(hashes) = atx_hashes(trimmed) {
763981 let new_level = (hashes as isize + level_delta).max(1) as usize;
764982 out.push_str(&" ".repeat(new_indent));
765983 out.push_str(&"#".repeat(new_level));
@@ -774,35 +992,17 @@ fn shift_lines(raw: &str, level_delta: isize, indent_delta: isize) -> String {
774992 out
775993}
776994
777−fn 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− }
995+/// The level of an ATX heading line, given without its indent: `#`+ then a
996+/// space or the end of the line (§4.2). `#tag` is text, as the parser reads it.
997+fn atx_hashes(trimmed: &str) -> Option<usize> {
998+ let hashes = trimmed.bytes().take_while(|&b| b == b'#').count();
999+ let after = &trimmed[hashes..];
1000+ (hashes > 0 && (after.is_empty() || after.starts_with(' '))).then_some(hashes)
8011001}
8021002
8031003/// Refile: move a subtree under a new parent as its last child (§6.5).
804−pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<bool> {
805− let dest = vault.tree.resolved_child(dest);
1004+pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<Placed> {
1005+ let dest = write_target(&vault.tree, dest)?;
8061006 let n = vault.tree.node(r);
8071007 // guard: cannot refile into own subtree — ancestry followed through
8081008 // embeds, so a destination inside a nested block counts too
@@ -816,7 +1016,7 @@ pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<bool> {
8161016 }
8171017 guard += 1;
8181018 if guard > 10_000 {
819− break; // embed cycle: a diagnostic elsewhere
1019+ break; // embed cycle: `check` reports it (§6.2)
8201020 }
8211021 anc = resolved_parent(&vault.tree, a);
8221022 }
@@ -826,9 +1026,61 @@ pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<bool> {
8261026 if vault.tree.node(moving).is_block() {
8271027 return Err(io_err("block has no embed"));
8281028 }
829− let rendered = render(&vault.tree, moving, 1, false);
830− let shifted = shift_document(&rendered, vault.tree.level(dest), child_indent(&vault.tree, dest));
831− place(vault, Some(moving), dest, usize::MAX, &shifted)
1029+ // with its conflict copies, or with the node a copy is of (§12.5)
1030+ let run = pair_run(&vault.tree, moving);
1031+ if run.iter().any(|&m| m != moving && within(&vault.tree, dest, m)) {
1032+ return Err(io_err(PAIR_INTO_ITSELF));
1033+ }
1034+ let doc = run_doc(&vault.tree, &run, dest);
1035+ let (nodes, clamped) = place(vault, &run, dest, usize::MAX, &doc)?;
1036+ Placed::of(&nodes, run.iter().position(|&m| m == moving).unwrap_or(0), clamped)
1037+}
1038+
1039+/// A node and its conflict copy move as one (§12.5): neither into the
1040+/// other.
1041+const PAIR_INTO_ITSELF: &str = "cannot move a node and its conflict copy into either of them";
1042+
1043+/// `run` (a node and its conflict copies, §12.5, as they stand in their
1044+/// parent's file) as a document of children of `parent`: each in its
1045+/// on-disk spelling, shifted there, a blank line before a heading or
1046+/// where there was one.
1047+fn run_doc(tree: &crate::tree::Tree, run: &[NRef], parent: NRef) -> String {
1048+ let (level, indent) = (tree.level(parent), child_indent(tree, parent));
1049+ let mut out = String::new();
1050+ for (i, &m) in run.iter().enumerate() {
1051+ if i > 0 {
1052+ let (sp, text) = (tree.node(run[i - 1]).span, tree.text_of(run[i - 1]));
1053+ if tree.node(m).kind == Kind::Section || text[sp.start..sp.end.min(text.len())].ends_with("\n\n") {
1054+ out.push('\n');
1055+ }
1056+ }
1057+ out.push_str(&shift_document(&render(tree, m, 1, false), level, indent));
1058+ }
1059+ out
1060+}
1061+
1062+/// Where a verb put the node it moved or made, by position — never found
1063+/// again by its title (§3.4): the node as the outline shows it (a block,
1064+/// not its embed), and whether the ordering rule (§3.1), not the verb,
1065+/// chose the place.
1066+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1067+pub struct Placed {
1068+ pub node: NRef,
1069+ pub clamped: bool,
1070+}
1071+
1072+impl Placed {
1073+ /// A verb that left `r` where it was.
1074+ fn stayed(tree: &crate::tree::Tree, r: NRef) -> Placed {
1075+ Placed { node: tree.resolved_child(r), clamped: false }
1076+ }
1077+
1078+ /// The `i`-th of the nodes `place` put (a run's, in order: the one a
1079+ /// verb moved among its conflict copies).
1080+ fn of(nodes: &[NRef], i: usize, clamped: bool) -> std::io::Result<Placed> {
1081+ let node = *nodes.get(i).ok_or_else(|| io_err("the placed node is not where it was written"))?;
1082+ Ok(Placed { node, clamped })
1083+ }
8321084}
8331085
8341086/// Where a dragged node is dropped (§10.3): before a node, as its sibling,
@@ -840,11 +1092,11 @@ pub enum Drop {
8401092}
8411093
8421094/// Move `r` relative to `target` (§10.3 drag and drop). Refuses a move into
843−/// `r`'s own subtree. Returns whether the ordering rule changed the place.
844−pub fn move_node(vault: &mut Vault, r: NRef, target: NRef, drop: Drop) -> std::io::Result<bool> {
1095+/// `r`'s own subtree.
1096+pub fn move_node(vault: &mut Vault, r: NRef, target: NRef, drop: Drop) -> std::io::Result<Placed> {
8451097 let target = vault.tree.resolved_child(target);
8461098 if target == vault.tree.resolved_child(r) {
847− return Ok(false);
1099+ return Ok(Placed::stayed(&vault.tree, r));
8481100 }
8491101 match drop {
8501102 Drop::Into => refile(vault, r, target),
@@ -858,11 +1110,27 @@ pub fn move_node(vault: &mut Vault, r: NRef, target: NRef, drop: Drop) -> std::i
8581110 return Err(io_err("cannot move a node into itself"));
8591111 }
8601112 let moving = stand_in(&vault.tree, r);
861− let idx = vault.tree.raw_children(parent).iter().position(|&k| k == t).unwrap_or(0);
862− let rendered = render(&vault.tree, moving, 1, false);
863− let shifted =
864− shift_document(&rendered, vault.tree.level(parent), child_indent(&vault.tree, parent));
865− place(vault, Some(moving), parent, idx, &shifted)
1113+ // with its conflict copies, or with the node a copy is of
1114+ // (§12.5); before either of them, it stays where it is
1115+ let run = pair_run(&vault.tree, moving);
1116+ if run.contains(&t) {
1117+ return Ok(Placed::stayed(&vault.tree, r));
1118+ }
1119+ if run.iter().any(|&m| m != moving && within(&vault.tree, parent, m)) {
1120+ return Err(io_err(PAIR_INTO_ITSELF));
1121+ }
1122+ // the target's index among the children `place` sees: without
1123+ // the moving nodes, which may come before it
1124+ let idx = vault
1125+ .tree
1126+ .raw_children(parent)
1127+ .into_iter()
1128+ .filter(|k| !run.contains(k))
1129+ .position(|k| k == t)
1130+ .unwrap_or(0);
1131+ let doc = run_doc(&vault.tree, &run, parent);
1132+ let (nodes, clamped) = place(vault, &run, parent, idx, &doc)?;
1133+ Placed::of(&nodes, run.iter().position(|&m| m == moving).unwrap_or(0), clamped)
8661134 }
8671135 }
8681136}
@@ -900,12 +1168,29 @@ fn stand_in(tree: &crate::tree::Tree, r: NRef) -> NRef {
9001168 r
9011169}
9021170
1171+/// The node a verb writes into at `r`: a block's own embed is the block
1172+/// (§4.7). An embed that resolves to nothing, broken or a second embed of
1173+/// a block (§6.2), is only a line of the file that holds it: it has no
1174+/// checkbox, properties or children of its own, so the verb is refused
1175+/// rather than writing them into that file.
1176+fn write_target(tree: &crate::tree::Tree, r: NRef) -> std::io::Result<NRef> {
1177+ let t = tree.resolved_child(r);
1178+ if tree.node(t).is_embed() {
1179+ return Err(io_err("a broken or duplicate embed has no node to write to"));
1180+ }
1181+ Ok(t)
1182+}
1183+
9031184/// Indent of a child of `parent` (§3.1): items nest under items, everything
904−/// under a section stays at the section's own indent.
1185+/// under a section stays at the section's own indent. Measured from the
1186+/// parent's written indent, not its derived one: nesting written with tabs
1187+/// or 4 spaces is read (§4.2), and a line at the derived child indent could
1188+/// fall short of such a parent and parse as its sibling.
9051189fn 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),
1190+ let n = tree.node(parent);
1191+ match n.kind {
1192+ Kind::Item => n.indent + 2,
1193+ Kind::Section => n.indent,
9091194 Kind::Root => 0,
9101195 }
9111196}
@@ -913,7 +1198,7 @@ fn child_indent(tree: &crate::tree::Tree, parent: NRef) -> usize {
9131198/// Spellings of a document's top-level nodes, in order. A heading embed is
9141199/// a section, a bare embed an item (§4.7).
9151200fn top_kinds(doc: &str) -> Vec<Kind> {
916− let pf = crate::parse::parse_file("clip.md", doc, 0, None);
1201+ let pf = crate::parse::parse_file("clip.md", doc, None);
9171202 pf.nodes[pf.root_node]
9181203 .children
9191204 .iter()
@@ -921,6 +1206,12 @@ fn top_kinds(doc: &str) -> Vec<Kind> {
9211206 .collect()
9221207}
9231208
1209+/// The indent a document's first top-level node is written at.
1210+fn top_indent(doc: &str) -> Option<usize> {
1211+ let pf = crate::parse::parse_file("clip.md", doc, None);
1212+ pf.nodes[pf.root_node].children.first().map(|&c| pf.nodes[c].indent)
1213+}
1214+
9241215/// Clamp a wanted child index by the ordering rule `(text | item)*
9251216/// section*` (§3.1): items no later than the first section child, sections
9261217/// no earlier. `kids` are the parent's child nodes without the one moving.
@@ -934,41 +1225,98 @@ fn clamp_index(tree: &crate::tree::Tree, kids: &[NRef], want: usize, kinds: &[Ki
9341225 want.min(kids.len()).clamp(lo, hi)
9351226}
9361227
1228+/// The level of `k` as written, if it is a section. A written level is
1229+/// honoured (§4.7): a section written after it deeper than that is its
1230+/// child, and one written before it shallower than that takes it (and
1231+/// every section after it) as a child (§3.1). The level `k`'s position
1232+/// gives can be deeper: under an item, sections may be written shallower.
1233+fn written_level(tree: &crate::tree::Tree, k: Option<NRef>) -> Option<usize> {
1234+ k.map(|k| tree.node(k)).filter(|n| n.kind == Kind::Section).map(|n| n.level.unwrap_or(1))
1235+}
1236+
1237+/// `doc` (shifted to a child position) with its top-level sections, and
1238+/// everything under them, re-levelled to stay siblings of the sections
1239+/// around them (`written_level`): no deeper than `prev`, the sibling
1240+/// section they are written after, and no shallower than `next`, the one
1241+/// they are written before.
1242+fn level_among(tree: &crate::tree::Tree, prev: Option<NRef>, next: Option<NRef>, doc: &str) -> String {
1243+ let (most, least) = (written_level(tree, prev), written_level(tree, next));
1244+ if most.is_none() && least.is_none() {
1245+ return doc.to_string();
1246+ }
1247+ let pf = crate::parse::parse_file("clip.md", doc, None);
1248+ let tops = &pf.nodes[pf.root_node].children;
1249+ // a document's sections come after its items (§3.1)
1250+ let Some(first) = tops.iter().map(|&c| &pf.nodes[c]).find(|n| n.kind == Kind::Section) else {
1251+ return doc.to_string();
1252+ };
1253+ // indentation closes a section before its level does (§4.2): a sibling
1254+ // written deeper than the sections, before them, or shallower, after
1255+ // them, is no parent or child of theirs, whatever its level
1256+ let most = most.filter(|_| prev.is_some_and(|p| tree.node(p).indent <= first.indent));
1257+ let least = least.filter(|_| next.is_some_and(|k| tree.node(k).indent >= first.indent));
1258+ let have = tops.last().and_then(|&c| pf.nodes[c].level).unwrap_or(1);
1259+ let want = most.map_or(have, |m| have.min(m)).max(least.unwrap_or(1));
1260+ if want == have {
1261+ return doc.to_string();
1262+ }
1263+ let at = first.span.start;
1264+ format!("{}{}", &doc[..at], shift_lines(&doc[at..], want as isize - have as isize, 0))
1265+}
1266+
9371267/// Write `doc` (already shifted to a child position of `parent`) as the
9381268/// `want`-th child node of `parent`, clamped by the ordering rule (§3.1).
939−/// With `moving`, that node's own lines are removed in the same edit: text
940−/// children around it stay where they are. The insertion is written before
941−/// any removal in another file, so a failure never loses text. Returns
942−/// whether the ordering rule moved it from the wanted index.
1269+/// The `moving` nodes (siblings, in order: a node and its conflict copies,
1270+/// §12.5), have their own lines removed in the same edit: text children
1271+/// around them stay where they are. The insertion is written before any
1272+/// removal in another file, so a failure never loses text. Returns the
1273+/// document's top-level nodes where they are now, found by the position
1274+/// they were written at, and whether the ordering rule moved them from
1275+/// the wanted index.
9431276fn place(
9441277 vault: &mut Vault,
945− moving: Option<NRef>,
1278+ moving: &[NRef],
9461279 parent: NRef,
9471280 want: usize,
9481281 doc: &str,
949−) -> std::io::Result<bool> {
1282+) -> std::io::Result<(Vec<NRef>, bool)> {
9501283 let tree = &vault.tree;
9511284 let pnode = tree.node(parent);
9521285 let kids: Vec<NRef> = tree
9531286 .raw_children(parent)
9541287 .into_iter()
955− .filter(|&k| Some(k) != moving)
1288+ .filter(|k| !moving.contains(k))
9561289 .collect();
9571290 let kinds = top_kinds(doc);
9581291 if kinds.is_empty() {
9591292 return Err(io_err("nothing to place"));
9601293 }
961− let idx = clamp_index(tree, &kids, want, &kinds);
1294+ let clamped = clamp_index(tree, &kids, want, &kinds);
1295+ // nothing goes between a conflict copy and the node before it, which
1296+ // it would pair with (§12.5): past the copies, unless the node they
1297+ // follow is the one moving, back in its place
1298+ let mut idx = clamped;
1299+ let orig = tree.raw_children(parent);
1300+ let back = |k: NRef| {
1301+ let before = orig.iter().position(|&o| o == k).and_then(|i| i.checked_sub(1)).map(|i| orig[i]);
1302+ before.is_some_and(|b| moving.contains(&b))
1303+ };
1304+ if !kids.get(idx).is_some_and(|&k| back(k)) {
1305+ let copy_at = |i: usize| kids.get(i).is_some_and(|&k| is_copy(tree, k));
1306+ while copy_at(idx) && clamp_index(tree, &kids, idx + 1, &kinds) == idx + 1 {
1307+ idx += 1;
1308+ }
1309+ }
9621310 let first = kinds[0];
9631311 let last = *kinds.last().unwrap();
9641312 let file = parent.0;
9651313 let mut text = tree.files[file].text.clone();
966− // the parent's content as it will be, without the moving node
1314+ // the parent's content as it will be, without the moving nodes
9671315 let content: Vec<crate::parse::Content> = pnode
9681316 .content
9691317 .iter()
9701318 .copied()
971− .filter(|c| !matches!(c, crate::parse::Content::Node(n) if Some((file, *n)) == moving))
1319+ .filter(|c| !matches!(c, crate::parse::Content::Node(n) if moving.contains(&(file, *n))))
9721320 .collect();
9731321 // positions in the original text, then adjusted for a same-file removal
9741322 let next = kids.get(idx).copied();
@@ -996,17 +1344,52 @@ fn place(
9961344 Some(crate::parse::Content::Text(_)) => false,
9971345 None => pnode.kind == Kind::Item,
9981346 };
999− let next_kind = next.map(|k| tree.node(k).kind);
1000− let removal = match moving {
1001− Some(m) if m.0 == file => Some(removal_range(&text, tree.node(m).span)),
1002− _ => None,
1347+ // inserting before an item: after another item, the list is as tight as
1348+ // the text there says (read below); starting the list, it is read in
1349+ // the list as found, the moving nodes still in it: between `next` and
1350+ // the item after it, or else between the last moving node and `next`
1351+ // right after it. A list of one item is tight.
1352+ let is_item = |c: Option<&crate::parse::Content>| {
1353+ matches!(c, Some(crate::parse::Content::Node(k)) if tree.files[file].nodes[*k].kind == Kind::Item)
1354+ };
1355+ let next_at = next.and_then(|k| content.iter().position(|c| *c == crate::parse::Content::Node(k.1)));
1356+ let starts_run = next_at.is_some_and(|i| i == 0 || !is_item(content.get(i - 1)));
1357+ let found = &pnode.content;
1358+ let found_at = next.and_then(|k| found.iter().position(|c| *c == crate::parse::Content::Node(k.1)));
1359+ let no_blank_after = |k: NRef| {
1360+ let sp = tree.node(k).span;
1361+ !text[sp.start..sp.end.min(text.len())].ends_with("\n\n")
1362+ };
1363+ let run_tight = match (next, found_at) {
1364+ (Some(k), Some(i)) if is_item(found.get(i + 1)) => no_blank_after(k),
1365+ (Some(_), Some(i)) => match moving.last() {
1366+ Some(&m) if i > 0 && m.0 == file && found[i - 1] == crate::parse::Content::Node(m.1) => {
1367+ no_blank_after(m)
1368+ }
1369+ _ => true,
1370+ },
1371+ _ => true,
10031372 };
1004− if let Some((start, end)) = removal {
1373+ let next_kind = next.map(|k| tree.node(k).kind);
1374+ // the last first, so the spans before it stay where they are
1375+ for &m in moving.iter().rev().filter(|m| m.0 == file) {
1376+ let (start, end) = removal_range(&text, tree.node(m).span);
10051377 text = format!("{}{}", &text[..start], &text[end..]);
10061378 pos = after_removal(pos, start, end);
10071379 }
1380+ // before a sibling written deeper than the node (tab or 4-space
1381+ // nesting is read, §4.2), the node goes at that sibling's indent: any
1382+ // shallower, the sibling would parse as its child
1383+ let doc = match (next.map(|k| tree.node(k).indent), top_indent(doc)) {
1384+ (Some(want), Some(have)) if want > have => shift_lines(doc, 0, (want - have) as isize),
1385+ _ => doc.to_string(),
1386+ };
1387+ let prev = idx.checked_sub(1).map(|i| kids[i]);
1388+ let doc = level_among(tree, prev, next, &doc);
10081389 let body = doc.trim_end_matches('\n');
1009− let insertion = match next_kind {
1390+ let tops = top_starts(body);
1391+ // the insertion, and where `body` starts in it
1392+ let (insertion, at) = match next_kind {
10101393 Some(nk) => {
10111394 // before the next child node: keep a blank line before a
10121395 // heading and after anything that is not a tight item run
@@ -1015,8 +1398,10 @@ fn place(
10151398 } else {
10161399 ""
10171400 };
1018− let tight = last == Kind::Item && nk == Kind::Item && !text[..pos].ends_with("\n\n");
1019− format!("{}{}\n{}", lead, body, if tight { "" } else { "\n" })
1401+ let tight = last == Kind::Item
1402+ && nk == Kind::Item
1403+ && if starts_run { run_tight } else { !text[..pos].ends_with("\n\n") };
1404+ (format!("{}{}\n{}", lead, body, if tight { "" } else { "\n" }), lead.len())
10201405 }
10211406 None => {
10221407 while pos > 0 && text.as_bytes()[pos - 1] == b'\n' {
@@ -1030,23 +1415,37 @@ fn place(
10301415 "\n\n"
10311416 };
10321417 let nl = if text[pos..].starts_with('\n') { "" } else { "\n" };
1033− format!("{}{}{}", sep, body, nl)
1418+ (format!("{}{}{}", sep, body, nl), sep.len())
10341419 }
10351420 };
1421+ let start = pos + at;
10361422 text.insert_str(pos, &insertion);
10371423 vault.write_file_text(file, &text)?;
1038− if let Some(m) = moving {
1039− if m.0 != file {
1040− // the other file's node refs are untouched by the write above
1041− remove_span_no_reload(vault, m.0, vault.tree.node(m).span)?;
1042− }
1424+ // the other file's node refs are untouched by the write above; each
1425+ // removal re-parses that file, so the spans are read first
1426+ let away: Vec<(usize, Span)> =
1427+ moving.iter().filter(|m| m.0 != file).map(|&m| (m.0, vault.tree.node(m).span)).collect();
1428+ for (f, span) in away.into_iter().rev() {
1429+ remove_span(vault, f, span)?;
10431430 }
1044− vault.reload()?;
1045− Ok(idx != want.min(kids.len()))
1431+ // `file` is as written: each node starts where its line went in
1432+ let f = &vault.tree.files[file];
1433+ let placed = tops
1434+ .iter()
1435+ .filter_map(|&t| f.nodes.iter().position(|n| n.kind != Kind::Root && n.title_span.start == start + t))
1436+ .map(|i| vault.tree.resolved_child((file, i)))
1437+ .collect();
1438+ Ok((placed, clamped != want.min(kids.len())))
1439+}
1440+
1441+/// Where each of a document's top-level nodes starts in it.
1442+fn top_starts(doc: &str) -> Vec<usize> {
1443+ let pf = crate::parse::parse_file("clip.md", doc, None);
1444+ pf.nodes[pf.root_node].children.iter().map(|&c| pf.nodes[c].title_span.start).collect()
10461445}
10471446
10481447/// Archive: refile under the top-level `Archive` section (§6.5).
1049−pub fn archive(vault: &mut Vault, r: NRef) -> std::io::Result<bool> {
1448+pub fn archive(vault: &mut Vault, r: NRef) -> std::io::Result<Placed> {
10501449 let root = vault.tree.root;
10511450 let mut dest = None;
10521451 for c in vault.tree.resolved_children(root) {
@@ -1072,12 +1471,22 @@ pub fn clear_done(vault: &mut Vault, target: NRef) -> std::io::Result<usize> {
10721471 vault.tree.walk(target, &mut |t, r| {
10731472 let n = t.node(r);
10741473 if n.task == Some(TaskState::Done) {
1075− let (open, _total) = t.task_counts(r);
1076− if open == 0 {
1474+ // any open task below keeps it, one in a conflict copy too,
1475+ // which task counts leave out (§3.5)
1476+ let mut open = false;
1477+ t.walk(r, &mut |t, c| open |= t.node(c).task == Some(TaskState::Open));
1478+ if !open {
10771479 to_clear.push(r);
10781480 }
10791481 }
10801482 });
1483+ // a node's conflict copies go with it (§12.5): one whose copies are
1484+ // not all cleared too stays, or they would pair with the node before
1485+ let to_clear: Vec<NRef> = to_clear
1486+ .iter()
1487+ .copied()
1488+ .filter(|&r| conflict_copies(&vault.tree, r).iter().all(|c| to_clear.contains(c)))
1489+ .collect();
10811490 // skip nodes under another node being cleared
10821491 let tops: Vec<NRef> = to_clear
10831492 .iter()
@@ -1097,10 +1506,17 @@ pub fn clear_done(vault: &mut Vault, target: NRef) -> std::io::Result<usize> {
10971506 .collect();
10981507 let mut blocks: Vec<Id> = Vec::new();
10991508 let mut plain: Vec<NRef> = Vec::new();
1509+ let mut nested: Vec<Id> = Vec::new();
11001510 for &r in &tops {
11011511 let n = vault.tree.node(r);
11021512 match n.block.as_ref().and_then(|b| b.id.clone()) {
1103− Some(id) => blocks.push(id),
1513+ Some(id) => {
1514+ // the block leaves by its own embed; blocks embedded in its
1515+ // file go to the trash with it, as delete_subtree sends them
1516+ let ids = nested_block_ids(vault, r);
1517+ nested.extend(ids.into_iter().filter(|i| *i != id));
1518+ blocks.push(id);
1519+ }
11041520 None => plain.push(r),
11051521 }
11061522 }
@@ -1109,13 +1525,14 @@ pub fn clear_done(vault: &mut Vault, target: NRef) -> std::io::Result<usize> {
11091525 let mut done = 0;
11101526 for r in plain {
11111527 let ids = plain_remove(vault, r)?;
1112− blocks.extend(ids);
1528+ nested.extend(ids);
11131529 done += 1;
11141530 }
11151531 let n_block_tops = tops.len() - done;
11161532 for id in blocks {
11171533 trash_block(vault, &id)?;
11181534 }
1535+ trash_nested(vault, &nested)?;
11191536 Ok(done + n_block_tops)
11201537}
11211538
@@ -1125,13 +1542,7 @@ fn resolved_parent(tree: &crate::tree::Tree, r: NRef) -> Option<NRef> {
11251542 let p = tree.node(r).parent?;
11261543 let pr = (r.0, p);
11271544 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− })
1545+ tree.embed_of(tree.node(r).block.as_ref()?.id.as_ref()?)
11351546 } else if tree.node(pr).kind == Kind::Root {
11361547 None
11371548 } else {
@@ -1155,14 +1566,28 @@ pub fn make_block(vault: &mut Vault, r: NRef) -> std::io::Result<Id> {
11551566 file_text.push_str(&body);
11561567 let prefix = vault.unique_prefix(&id);
11571568 let fname = crate::ident::filename(&prefix, &slug(&n.title));
1158− crate::vault::atomic_write(&vault.dir.join(&fname), &file_text)?;
1569+ // the embed cannot be written into a file changed on disk (§11.2), and
1570+ // a block file without it would be embedded nowhere: check first
1571+ let file = r.0;
1572+ vault.check_unchanged(file)?;
1573+ let full = vault.dir.join(&fname);
1574+ crate::vault::atomic_write(&full, &file_text)?;
11591575 // replace the node's span with an embed in the node's form (§6.1.3,
11601576 // §4.7), keeping the blank lines that separated it from what follows
1161− let file = r.0;
11621577 let span = n.span;
11631578 let indent = " ".repeat(n.indent);
1579+ // at its level, but kept a sibling of the sections around it as they
1580+ // are written: no deeper than the one before it, which would take the
1581+ // embed as its child, nor shallower than the one after it
1582+ let kids = n.parent.map(|p| vault.tree.raw_children((file, p))).unwrap_or_default();
1583+ let at = kids.iter().position(|&c| c == r);
1584+ let prev = at.and_then(|i| i.checked_sub(1)).map(|i| kids[i]);
1585+ let next = at.and_then(|i| kids.get(i + 1).copied());
11641586 let mut embed = match n.kind {
1165− Kind::Section => format!("{}{} ![[{}]]\n", indent, "#".repeat(vault.tree.level(r)), id),
1587+ Kind::Section => {
1588+ let line = format!("{}{} ![[{}]]\n", indent, "#".repeat(vault.tree.level(r)), id);
1589+ level_among(&vault.tree, prev, next, &line)
1590+ }
11661591 _ => format!("{}![[{}]]\n", indent, id),
11671592 };
11681593 let old = span.text(&vault.tree.files[file].text);
@@ -1170,8 +1595,13 @@ pub fn make_block(vault: &mut Vault, r: NRef) -> std::io::Result<Id> {
11701595 for _ in 1..trailing {
11711596 embed.push('\n');
11721597 }
1173− vault.write_span(file, span, &embed)?;
1174− vault.reload()?;
1598+ if let Err(e) = vault.write_span(file, span, &embed) {
1599+ // changed in between after all: nothing refers to the new file yet,
1600+ // and the node is still in its parent
1601+ let _ = std::fs::remove_file(&full);
1602+ return Err(e);
1603+ }
1604+ vault.add_file(&fname, &file_text);
11751605 Ok(id)
11761606}
11771607
@@ -1181,7 +1611,7 @@ pub fn set_property(vault: &mut Vault, r: NRef, key: &str, value: &str) -> std::
11811611 let target = if vault.tree.node(r).is_block() {
11821612 r
11831613 } else if vault.tree.node(r).is_embed() {
1184− vault.tree.resolved_child(r)
1614+ write_target(&vault.tree, r)?
11851615 } else {
11861616 // make_block reloads, so `r` is stale: find the new block by its id
11871617 let id = make_block(vault, r)?;
@@ -1199,6 +1629,9 @@ pub fn set_property(vault: &mut Vault, r: NRef, key: &str, value: &str) -> std::
11991629
12001630/// Move a node among its siblings (§10.3 `J`/`K`). A block moves by its
12011631/// embed line, so blocks and plain nodes swap freely in the parent file.
1632+/// A node and the conflict copies after it are one (§12.5): they move
1633+/// together, and a node moves past them together, so no copy is left
1634+/// after another node, which it would pair with.
12021635pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<()> {
12031636 // a block root stands in its parent file as its embed
12041637 let r = stand_in(&vault.tree, r);
@@ -1214,20 +1647,21 @@ pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<(
12141647 Some(p) => p,
12151648 None => return Ok(()),
12161649 };
1217− let swap_with = if down {
1218− pos + 1
1650+ let run = |i: usize| paired_run(&vault.tree, &siblings, i);
1651+ let (a, b) = run(pos);
1652+ let other = if down {
1653+ if b + 1 >= siblings.len() {
1654+ return Ok(());
1655+ }
1656+ run(b + 1)
12191657 } else {
1220− match pos.checked_sub(1) {
1221− Some(p) => p,
1658+ match a.checked_sub(1) {
1659+ Some(p) => run(p),
12221660 None => return Ok(()),
12231661 }
12241662 };
1225− if swap_with >= siblings.len() {
1226− return Ok(());
1227− }
1228− let other = siblings[swap_with];
12291663 // the ordering rule (§3.1): an item never goes below a section sibling
1230− let (rk, ok) = (vault.tree.node(r).kind, vault.tree.node(other).kind);
1664+ let (rk, ok) = (vault.tree.node(siblings[a]).kind, vault.tree.node(siblings[other.0]).kind);
12311665 if rk != ok {
12321666 return Err(io_err(if rk == Kind::Item {
12331667 "an item cannot move below a section"
@@ -1237,7 +1671,7 @@ pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<(
12371671 }
12381672 let file = r.0;
12391673 let text = vault.tree.files[file].text.clone();
1240− // swap the nodes' own lines; trailing blank lines stay where they are,
1674+ // swap the runs' own lines; trailing blank lines stay where they are,
12411675 // so the list's spacing is unchanged
12421676 let core = |sp: Span| {
12431677 let end = sp.end.min(text.len());
@@ -1247,9 +1681,12 @@ pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<(
12471681 }
12481682 Span { start: sp.start, end: e }
12491683 };
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) };
1684+ let (fr, sr) = if down { ((a, b), other) } else { (other, (a, b)) };
1685+ let lines = |(s, e): (usize, usize)| Span {
1686+ start: vault.tree.node(siblings[s]).span.start,
1687+ end: core(vault.tree.node(siblings[e]).span).end,
1688+ };
1689+ let (first, second) = (lines(fr), lines(sr));
12531690 let with_nl = |t: &str| {
12541691 if t.ends_with('\n') {
12551692 t.to_string()
@@ -1257,18 +1694,95 @@ pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<(
12571694 format!("{}\n", t)
12581695 }
12591696 };
1697+ // The written level and indent decide the parent: a sibling written
1698+ // deeper than the one after it (a skipped heading level, §4.7, or a
1699+ // wider indent) would nest under that one once below it, and J/K keep
1700+ // every other node's parent (§15.6). So the nodes moving down are
1701+ // written at the level and indent of the one moving up, their subtrees
1702+ // re-levelled with them, as a moved node is (§4.2).
1703+ let (fnode, snode) = (vault.tree.node(siblings[fr.0]), vault.tree.node(siblings[sr.0]));
1704+ let lower = if (fnode.level, fnode.indent) == (snode.level, snode.indent) {
1705+ with_nl(first.text(&text))
1706+ } else {
1707+ let base = snode.level.unwrap_or_else(|| vault.tree.level(siblings[fr.0]));
1708+ let pad = " ".repeat(snode.indent);
1709+ let mut out = String::new();
1710+ for i in fr.0..=fr.1 {
1711+ let k = siblings[i];
1712+ out.extend(
1713+ render(&vault.tree, k, base, false)
1714+ .lines()
1715+ .map(|l| if l.is_empty() { "\n".to_string() } else { format!("{}{}\n", pad, l) }),
1716+ );
1717+ // the blank lines between a node and its copy stay
1718+ if i < fr.1 {
1719+ let next = vault.tree.node(siblings[i + 1]).span.start;
1720+ out.push_str(&text[core(vault.tree.node(k).span).end..next]);
1721+ }
1722+ }
1723+ out
1724+ };
12601725 let mut new_text = format!(
12611726 "{}{}{}{}",
12621727 &text[..first.start],
12631728 with_nl(second.text(&text)),
12641729 &text[first.end..second.start],
1265− with_nl(first.text(&text)),
1730+ lower,
12661731 );
12671732 let rest = &text[second.end..];
12681733 new_text.push_str(rest);
12691734 vault.write_file_text(file, &new_text)
12701735}
12711736
1737+/// Whether the child `k` is a conflict copy, which pairs with the node
1738+/// before it (§12.4).
1739+fn is_copy(tree: &crate::tree::Tree, k: NRef) -> bool {
1740+ tree.node(tree.resolved_child(k)).conflict().is_some()
1741+}
1742+
1743+/// The run of `kids` that the `i`-th is in: a node and the conflict copies
1744+/// right after it, which pair with it and each other (§12.4), as indices.
1745+fn paired_run(tree: &crate::tree::Tree, kids: &[NRef], i: usize) -> (usize, usize) {
1746+ let mut a = i;
1747+ while a > 0 && is_copy(tree, kids[a]) {
1748+ a -= 1;
1749+ }
1750+ let mut b = i;
1751+ while b + 1 < kids.len() && is_copy(tree, kids[b + 1]) {
1752+ b += 1;
1753+ }
1754+ (a, b)
1755+}
1756+
1757+/// The run `m` (a node as it stands in its parent's file, §4.7) is in
1758+/// there: a node and its conflict copies, which a verb that moves or
1759+/// deletes the one takes with it, or a copy left behind would pair with
1760+/// the node before it (§12.5). `m` alone when it is in no pair.
1761+fn pair_run(tree: &crate::tree::Tree, m: NRef) -> Vec<NRef> {
1762+ let Some(p) = tree.node(m).parent else { return vec![m] };
1763+ let kids = tree.raw_children((m.0, p));
1764+ let Some(i) = kids.iter().position(|&k| k == m) else { return vec![m] };
1765+ let (a, b) = paired_run(tree, &kids, i);
1766+ kids[a..=b].to_vec()
1767+}
1768+
1769+/// The conflict pair `r` is in (§12.4): the node, then its copies, as the
1770+/// outline shows them; `r` alone when it is in none.
1771+pub fn conflict_pair(tree: &crate::tree::Tree, r: NRef) -> Vec<NRef> {
1772+ let m = stand_in(tree, tree.resolved_child(r));
1773+ pair_run(tree, m).into_iter().map(|k| tree.resolved_child(k)).collect()
1774+}
1775+
1776+/// The conflict copies of `r` (§12.4), as the outline shows them: none
1777+/// when it is a copy itself.
1778+pub fn conflict_copies(tree: &crate::tree::Tree, r: NRef) -> Vec<NRef> {
1779+ let r = tree.resolved_child(r);
1780+ match conflict_pair(tree, r).split_first() {
1781+ Some((&o, copies)) if o == r && tree.node(r).conflict().is_none() => copies.to_vec(),
1782+ _ => Vec::new(),
1783+ }
1784+}
1785+
12721786/// Toggle spelling section ↔ item (§10.3 `~`). The checkbox, if any, is kept,
12731787/// and the subtree is re-indented and re-levelled for the new spelling. The
12741788/// node moves to its parent's boundary if it has to (§3.1), so no sibling
@@ -1280,26 +1794,57 @@ pub fn toggle_spelling(vault: &mut Vault, r: NRef) -> std::io::Result<bool> {
12801794 return Ok(false);
12811795 }
12821796 let to_section = n.kind == Kind::Item;
1797+ // a node in a conflict pair keeps its spelling: moved to its boundary
1798+ // it would leave its copy, or a copy its node, to pair with the node
1799+ // before (§12.5), and keeping theirs would write the other one back
1800+ if conflict_pair(&vault.tree, r).len() > 1 {
1801+ return Err(io_err(PAIR_SPELLING));
1802+ }
1803+ // an embed still here after resolving is broken: there is no block to
1804+ // respell, so only the embed's form changes and its id stays (§4.7)
1805+ if n.is_embed() {
1806+ return respell_embed(vault, r, to_section);
1807+ }
1808+ // `## ---` spelled as an item is `- ---`, a thematic break (§4.4)
1809+ if !to_section && n.task.is_none() {
1810+ item_line(&n.title)?;
1811+ }
12831812 let stand = stand_in(&vault.tree, r);
12841813 let file = r.0;
12851814 let text = vault.tree.files[file].text.clone();
12861815 let span = Span { start: n.span.start, end: n.span.end.min(text.len()) };
1816+ // respelled from the subtree as a move writes it (§4.2): every heading
1817+ // in it ATX, a setext one converted, at the level its position gives,
1818+ // so re-levelling keeps each under the node it was under; at the node's
1819+ // indent, with the blank lines that end its span
1820+ let blanks = span.text(&text).lines().rev().take_while(|l| l.trim().is_empty()).count();
1821+ let rendered: String = crate::render::render_lines(&vault.tree, r, vault.tree.level(r), false)
1822+ .into_iter()
1823+ .map(|l| l.text + "\n")
1824+ .collect();
1825+ let src = shift_lines(&rendered, 0, n.indent as isize) + &"\n".repeat(blanks);
12871826 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);
1827+ // a block: respell its file's root at level 1, then its embed. The
1828+ // embed cannot be written into a file changed on disk (§11.2), and
1829+ // the block would be left under an embed of the other form (§4.7):
1830+ // check first
1831+ vault.check_unchanged(stand.0)?;
1832+ let respelled = respell(&src, to_section, 1);
12901833 let id = n.block.as_ref().and_then(|b| b.id.clone());
12911834 vault.write_span(file, span, &respelled)?;
12921835 let Some(e) = id.and_then(|id| vault.tree.embed_of(&id)) else {
1293− vault.reload()?;
12941836 return Ok(false);
12951837 };
12961838 return respell_embed(vault, e, to_section);
12971839 }
12981840 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);
1841+ let respelled = respell(&src, to_section, vault.tree.level(parent) + 1);
13001842 reposition(vault, r, parent, respelled, to_section)
13011843}
13021844
1845+/// Why `~` is refused on either side of a conflict pair (§12.5).
1846+pub const PAIR_SPELLING: &str = "resolve its conflict";
1847+
13031848/// Rewrite an embed in the other form (§4.7) — heading for a section-spelled
13041849/// block, bare for an item — moving it to its parent's boundary if needed.
13051850pub fn respell_embed(vault: &mut Vault, e: NRef, to_section: bool) -> std::io::Result<bool> {
@@ -1335,11 +1880,12 @@ fn reposition(
13351880 let kind = if to_section { Kind::Section } else { Kind::Item };
13361881 if clamp_index(tree, &others, pos, &[kind]) == pos {
13371882 let span = tree.node(r).span;
1883+ let prev = pos.checked_sub(1).map(|i| others[i]);
1884+ let new = level_among(tree, prev, others.get(pos).copied(), &new);
13381885 vault.write_span(r.0, span, &new)?;
1339− vault.reload()?;
13401886 return Ok(false);
13411887 }
1342− place(vault, Some(r), parent, pos, &new).map(|_| true)
1888+ place(vault, &[r], parent, pos, &new).map(|_| true)
13431889}
13441890
13451891/// A node's text respelled: its title line as a section at `level` or as an
@@ -1377,15 +1923,18 @@ fn respell(span_text: &str, to_section: bool, level: usize) -> String {
13771923
13781924/// Demote: become the last child of the previous sibling node (§10.3 `>`),
13791925/// clamped by the ordering rule (§3.1).
1380−pub fn demote(vault: &mut Vault, r: NRef) -> std::io::Result<bool> {
1926+pub fn demote(vault: &mut Vault, r: NRef) -> std::io::Result<Placed> {
1927+ let stayed = Placed::stayed(&vault.tree, r);
13811928 let m = stand_in(&vault.tree, r);
1382− let Some(parent) = vault.tree.node(m).parent.map(|p| (m.0, p)) else { return Ok(false) };
1929+ let Some(parent) = vault.tree.node(m).parent.map(|p| (m.0, p)) else { return Ok(stayed) };
13831930 let kids = vault.tree.raw_children(parent);
1384− let Some(pos) = kids.iter().position(|&k| k == m) else { return Ok(false) };
1385− if pos == 0 {
1386− return Ok(false);
1931+ let Some(pos) = kids.iter().position(|&k| k == m) else { return Ok(stayed) };
1932+ // under the node before its conflict pair, which moves as one (§12.5)
1933+ let (a, _) = paired_run(&vault.tree, &kids, pos);
1934+ if a == 0 {
1935+ return Ok(stayed);
13871936 }
1388− let prev = vault.tree.resolved_child(kids[pos - 1]);
1937+ let prev = vault.tree.resolved_child(kids[a - 1]);
13891938 refile(vault, r, prev)
13901939}
13911940
@@ -1393,24 +1942,27 @@ pub fn demote(vault: &mut Vault, r: NRef) -> std::io::Result<bool> {
13931942/// ordering rule (§3.1): an item leaving a section lands before the first
13941943/// section among the parent's siblings. Out of a block's root, the node moves
13951944/// to the parent file beside the block's embed.
1396−pub fn promote(vault: &mut Vault, r: NRef) -> std::io::Result<bool> {
1945+pub fn promote(vault: &mut Vault, r: NRef) -> std::io::Result<Placed> {
1946+ let stayed = Placed::stayed(&vault.tree, r);
13971947 let m = stand_in(&vault.tree, r);
1398− let Some(parent) = vault.tree.node(m).parent.map(|p| (m.0, p)) else { return Ok(false) };
1948+ let Some(parent) = vault.tree.node(m).parent.map(|p| (m.0, p)) else { return Ok(stayed) };
13991949 if vault.tree.node(parent).kind == Kind::Root {
1400− return Ok(false);
1950+ return Ok(stayed);
14011951 }
14021952 // the parent's own position: its embed if it is a block root
14031953 let pstand = stand_in(&vault.tree, parent);
14041954 let Some(grand) = vault.tree.node(pstand).parent.map(|p| (pstand.0, p)) else {
1405− return Ok(false);
1955+ return Ok(stayed);
14061956 };
14071957 if vault.tree.node(grand).kind == Kind::Root && pstand.0 != vault.tree.root.0 {
14081958 return Err(io_err("block has no embed"));
14091959 }
14101960 let pos = vault.tree.raw_children(grand).iter().position(|&k| k == pstand).unwrap_or(0);
1411− let rendered = render(&vault.tree, m, 1, false);
1412− let shifted = shift_document(&rendered, vault.tree.level(grand), child_indent(&vault.tree, grand));
1413− place(vault, Some(m), grand, pos + 1, &shifted)
1961+ // with its conflict pair, which moves as one (§12.5)
1962+ let run = pair_run(&vault.tree, m);
1963+ let doc = run_doc(&vault.tree, &run, grand);
1964+ let (nodes, clamped) = place(vault, &run, grand, pos + 1, &doc)?;
1965+ Placed::of(&nodes, run.iter().position(|&k| k == m).unwrap_or(0), clamped)
14141966}
14151967
14161968/// Append a plain item child titled `title` under `parent`; returns the new
@@ -1420,7 +1972,25 @@ pub fn append_child_public(
14201972 parent: NRef,
14211973 title: &str,
14221974) -> std::io::Result<NRef> {
1423− append_child_line(vault, parent, &format!("- {}", title), true)
1975+ append_child_line(vault, parent, &item_line(title)?, true)
1976+}
1977+
1978+/// The item line titled `title`, refused when it would not read back as
1979+/// one: a title of dashes (`---`, `- -`) makes it a thematic break, which
1980+/// CommonMark reads before a bullet, and body text (§4.4). Checked before
1981+/// anything is written, as the TUI won't create an invalid title (§4.3).
1982+fn item_line(title: &str) -> std::io::Result<String> {
1983+ let line = format!("- {}", title);
1984+ not_a_break(&line)?;
1985+ Ok(line)
1986+}
1987+
1988+/// Refuse an item's title line that reads as a thematic break (§4.4).
1989+fn not_a_break(line: &str) -> std::io::Result<()> {
1990+ if crate::parse::is_thematic_break(line.trim_start_matches([' ', '\t'])) {
1991+ return Err(io_err("a title of dashes reads as a thematic break, not an item"));
1992+ }
1993+ Ok(())
14241994}
14251995
14261996fn io_err(msg: &str) -> std::io::Error {
crates/fold-core/src/parse.rs+152−26
@@ -44,6 +44,27 @@ pub struct Block {
4444}
4545
4646impl Block {
47+ /// The block of the file at `path`, as its frontmatter `fm` has it
48+ /// (§4.4); `id` is `None` for `root.md`.
49+ pub fn new(path: &str, id: Option<Id>, fm: Option<&Frontmatter>) -> Block {
50+ let mut b = Block {
51+ id,
52+ path: path.to_string(),
53+ props: IndexMap::new(),
54+ frontmatter_raw: String::new(),
55+ frontmatter_span: None,
56+ };
57+ b.set_frontmatter(fm);
58+ b
59+ }
60+
61+ /// Take the frontmatter as a file now has it; the id and path stay.
62+ pub fn set_frontmatter(&mut self, fm: Option<&Frontmatter>) {
63+ self.props = fm.map(|f| f.props.clone()).unwrap_or_default();
64+ self.frontmatter_raw = fm.map(|f| f.raw.clone()).unwrap_or_default();
65+ self.frontmatter_span = fm.map(|f| f.span);
66+ }
67+
4768 pub fn prop(&self, key: &str) -> Option<&str> {
4869 self.props.get(key).map(String::as_str)
4970 }
@@ -73,8 +94,6 @@ pub struct Node {
7394 /// The node children alone, in order (derived from `content`).
7495 pub children: Vec<usize>,
7596 pub parent: Option<usize>,
76− /// Which file's text this node's spans index into.
77− pub file: usize,
7897 /// `Some` if this node is the root of its own file.
7998 pub block: Option<Block>,
8099 /// `Some(id)` if this node is an embed reference in a parent file.
@@ -131,6 +150,22 @@ impl ParsedFile {
131150 self.nodes[self.root_node].children[0]
132151 }
133152 }
153+
154+ /// A block file with text or an embed before its root node, or with no
155+ /// root at all (§4.9): what comes before the root is in no block, so
156+ /// the file is read-only until fixed by hand. `root.md` never is: text
157+ /// before its first node is its Root's own.
158+ pub fn malformed_block(&self) -> bool {
159+ let root = &self.nodes[self.root_node];
160+ if root.block.is_some() {
161+ return false;
162+ }
163+ let text_before = root.content.iter().any(|c| match c {
164+ Content::Text(sp) => !sp.text(&self.text).trim().is_empty(),
165+ Content::Node(_) => false,
166+ });
167+ text_before || root.children.len() != 1 || self.nodes[root.children[0]].embed.is_some()
168+ }
134169}
135170
136171#[derive(Debug, Clone)]
@@ -171,6 +206,14 @@ pub struct Frontmatter {
171206 pub props: IndexMap<String, String>,
172207}
173208
209+impl Frontmatter {
210+ /// The `id` key, if it validates against the syllable tables (§6.4):
211+ /// what makes a file a block file.
212+ pub fn id(&self) -> Option<Id> {
213+ self.props.get("id").and_then(|v| Id::parse(v))
214+ }
215+}
216+
174217/// Scan frontmatter at byte 0 of a file. Returns None if absent.
175218pub fn parse_frontmatter(text: &str) -> Option<Frontmatter> {
176219 if !text.starts_with("---\n") && !text.starts_with("---\r\n") {
@@ -280,14 +323,12 @@ fn classify_title(raw: &str) -> Option<(usize, TitleInfo)> {
280323 });
281324 }
282325 let (kind, after_marker) = if rest.starts_with('#') {
326+ // any number: levels are unbounded, beyond six too (§3.1, §4.2)
283327 let hashes = rest.chars().take_while(|&c| c == '#').count();
284328 let after = &rest[hashes..];
285329 if !after.is_empty() && !after.starts_with(' ') {
286330 return None; // `#tag` is text
287331 }
288− if hashes > 6 {
289− noncanonical.push("heading level beyond six".into());
290− }
291332 // a heading embed: the heading holds nothing but `![[id]]`
292333 if let Some(inner) = after
293334 .trim()
@@ -310,6 +351,8 @@ fn classify_title(raw: &str) -> Option<(usize, TitleInfo)> {
310351 TitleKind::Section { level: hashes },
311352 after.strip_prefix(' ').unwrap_or(after),
312353 )
354+ } else if is_thematic_break(rest) {
355+ return None; // `- - -`, `* * *`: a break, not a bullet (§4.4)
313356 } else if let Some(after) = rest.strip_prefix("- ") {
314357 (TitleKind::Item, after)
315358 } else if let Some(after) = rest.strip_prefix("* ").or_else(|| rest.strip_prefix("+ ")) {
@@ -362,7 +405,12 @@ fn classify_title(raw: &str) -> Option<(usize, TitleInfo)> {
362405 ))
363406}
364407
365−fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
408+/// Track fenced code blocks (§3.3) one line at a time: true when `raw` opens
409+/// or closes one. Fences are CommonMark's: a backtick fence's info string
410+/// holds no backtick (a line that starts with inline code is text), and a
411+/// closing fence is followed by nothing but spaces or tabs. Public so that
412+/// what the TUI draws as code is what the parser reads as code.
413+pub fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
366414 let t = raw.trim_start_matches([' ', '\t']);
367415 let first = match t.chars().next() {
368416 Some(c) if c == '`' || c == '~' => c,
@@ -372,13 +420,17 @@ fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
372420 if count < 3 {
373421 return false;
374422 }
423+ let rest = &t[count..]; // '`' and '~' are one byte each
375424 match open {
376425 None => {
426+ if first == '`' && rest.contains('`') {
427+ return false;
428+ }
377429 *open = Some((first, count));
378430 true
379431 }
380432 Some((c, n)) => {
381− if *c == first && count >= *n {
433+ if *c == first && count >= *n && rest.trim_matches([' ', '\t']).is_empty() {
382434 *open = None;
383435 true
384436 } else {
@@ -388,6 +440,25 @@ fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
388440 }
389441}
390442
443+/// A thematic break, however it is spaced: three or more of one of `-`, `*`,
444+/// `_` with nothing but spaces or tabs between. CommonMark reads it before a
445+/// list item, so `- - -` and `* * *` are body text like `---` (§4.4).
446+/// `rest` starts at the line's first non-blank character.
447+pub(crate) fn is_thematic_break(rest: &str) -> bool {
448+ let Some(mark) = rest.chars().next().filter(|c| matches!(c, '-' | '*' | '_')) else {
449+ return false;
450+ };
451+ let mut n = 0;
452+ for c in rest.chars() {
453+ if c == mark {
454+ n += 1;
455+ } else if c != ' ' && c != '\t' {
456+ return false;
457+ }
458+ }
459+ n >= 3
460+}
461+
391462/// A setext underline. Only `=` underlines count: a line of dashes is a
392463/// thematic break and body text (§4.4, §5.2 step 2), and a lone `-` is an
393464/// empty bullet.
@@ -407,10 +478,10 @@ struct Frame {
407478 is_embed: bool,
408479}
409480
410−/// Parse one file's text into nodes. `file_idx` tags every node's `file`.
411−/// `block` (with `id: Some`) marks a block file; `id: None` is `root.md`;
412−/// `None` means "plain text, no block attachment" (used by tests).
413−pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>) -> ParsedFile {
481+/// Parse one file's text into nodes. `block` (with `id: Some`) marks a
482+/// block file; `id: None` is `root.md`; `None` means "plain text, no block
483+/// attachment" (used by tests).
484+pub fn parse_file(path: &str, text: &str, block: Option<Block>) -> ParsedFile {
414485 let lines = split_lines(text);
415486 let n = lines.len();
416487 let mut nodes: Vec<Node> = Vec::new();
@@ -429,7 +500,6 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
429500 },
430501 children: Vec::new(),
431502 parent: None,
432− file: file_idx,
433503 block: None,
434504 embed: None,
435505 indent: 0,
@@ -484,7 +554,7 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
484554 // Setext underline?
485555 if let Some(level) = setext_level(&raw) {
486556 if let Some((title_li, title_indent)) = pending_setext.take() {
487− make_setext_section(
557+ let idx = make_setext_section(
488558 &mut nodes,
489559 &mut stack,
490560 &lines,
@@ -492,7 +562,20 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
492562 title_indent,
493563 i,
494564 level,
565+ block_root,
495566 );
567+ // like any title line, a setext heading can be a block
568+ // file's root (§4.9); its title line was then reported as
569+ // text before the root, which it is not
570+ if nodes[idx].parent == Some(root_idx) {
571+ root_level_nodes += 1;
572+ if is_block_file && !root_title_seen {
573+ root_title_seen = true;
574+ block_root = Some(idx);
575+ let at = nodes[idx].title_span.start;
576+ diagnostics.retain(|d: &Diag| d.span.start != at);
577+ }
578+ }
496579 i += 1;
497580 continue;
498581 }
@@ -585,7 +668,6 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
585668 },
586669 children: Vec::new(),
587670 parent: Some(parent),
588− file: file_idx,
589671 block: None,
590672 embed: match &info.kind {
591673 TitleKind::Embed(id, _) => Some(id.clone()),
@@ -620,7 +702,6 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
620702 }
621703 }
622704 if node.title.is_empty() && node.embed.is_none() {
623− node.noncanonical.push("empty title".into());
624705 diagnostics.push(Diag {
625706 span: node.title_span,
626707 message: "title line with an empty title".into(),
@@ -629,7 +710,7 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
629710 if node.title.contains('/') {
630711 diagnostics.push(Diag {
631712 span: node.title_span,
632− message: "title contains '/'".into(),
713+ message: format!("title contains '/': {:?}", node.title),
633714 });
634715 }
635716 let idx = nodes.len();
@@ -712,7 +793,7 @@ pub fn unseparated_text(n: &Node, text: &str) -> Vec<usize> {
712793 let mut out = Vec::new();
713794 for w in n.content.windows(2) {
714795 if let [Content::Node(_), Content::Text(sp)] = w {
715− if sp.start > 0 && !text[..sp.start].ends_with("\n\n") {
796+ if sp.start > 0 && !ends_with_blank_line(&text[..sp.start]) {
716797 out.push(sp.start);
717798 }
718799 }
@@ -720,6 +801,15 @@ pub fn unseparated_text(n: &Node, text: &str) -> Vec<usize> {
720801 out
721802}
722803
804+/// Whether `s`, which ends at a line start, ends with a blank line: one that
805+/// is empty or whitespace, whatever its line ending (`\r\n` is read too).
806+pub(crate) fn ends_with_blank_line(s: &str) -> bool {
807+ let Some(s) = s.strip_suffix('\n') else {
808+ return false;
809+ };
810+ s[s.rfind('\n').map_or(0, |i| i + 1)..].trim().is_empty()
811+}
812+
723813fn push_body(
724814 nodes: &mut Vec<Node>,
725815 stack: &mut Vec<Frame>,
@@ -790,6 +880,9 @@ fn push_body(
790880 extend_spans(nodes, top, line.next);
791881}
792882
883+/// Turn the pending title line and its underline into a section; returns
884+/// its index.
885+#[allow(clippy::too_many_arguments)]
793886fn make_setext_section(
794887 nodes: &mut Vec<Node>,
795888 stack: &mut Vec<Frame>,
@@ -798,14 +891,26 @@ fn make_setext_section(
798891 title_indent: usize,
799892 underline_li: usize,
800893 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.
894+ block_root: Option<usize>,
895+) -> usize {
896+ // The title line is the last text line pushed, so it ends the last text
897+ // child of the node push_body gave it to: the top frame's, or a block
898+ // file's root when that is the Root (§4.9). Take it back out, with the
899+ // adoption note that came with it.
804900 let title = lines[title_li].raw.trim().to_string();
805901 let underline = &lines[underline_li];
806902 let tl = &lines[title_li];
807903 {
808− let owner = stack.last().unwrap().node;
904+ let mut owner = stack.last().unwrap().node;
905+ if owner == 0 {
906+ if let Some(br) = block_root {
907+ owner = br;
908+ let note = "column-0 text in a block file, adopted by its root";
909+ if nodes[br].noncanonical.last().is_some_and(|n| n == note) {
910+ nodes[br].noncanonical.pop();
911+ }
912+ }
913+ }
809914 let nd = &mut nodes[owner];
810915 if let Some(Content::Text(sp)) = nd.content.last_mut() {
811916 if sp.end == tl.next {
@@ -815,22 +920,43 @@ fn make_setext_section(
815920 }
816921 }
817922 }
923+ // push_body grew the owner's span, and its ancestors', over the
924+ // title line: give that back too, so a node the new section does not
925+ // sit in ends before it (sibling spans never overlap). The section's
926+ // own ancestors are grown again below.
927+ let mut cur = Some(owner);
928+ while let Some(c) = cur {
929+ if nodes[c].span.end == tl.next {
930+ nodes[c].span.end = tl.start;
931+ }
932+ cur = nodes[c].parent;
933+ }
818934 }
819− // Pop frames that can't contain a section at this position.
935+ // Pop frames that can't contain a section at this position: those that
936+ // could not contain the ATX heading of its level there (§4.2), so
937+ // writing it as one keeps it where it is.
820938 loop {
821939 let top = stack.last().unwrap();
822940 let tnode = &nodes[top.node];
823941 let can = match tnode.kind {
824942 Kind::Root => true,
825943 Kind::Item => title_indent > top.indent,
826− Kind::Section => title_indent > top.indent || level > top.level.unwrap_or(0),
944+ Kind::Section => title_indent >= top.indent && level > top.level.unwrap_or(0),
827945 };
828946 if can {
829947 break;
830948 }
831949 stack.pop();
832950 }
833− let parent = stack.last().unwrap().node;
951+ // a column-0 section after a block file's root is adopted by it (§4.9)
952+ let mut parent = stack.last().unwrap().node;
953+ let mut noncanonical = vec!["setext heading".to_string()];
954+ if parent == 0 {
955+ if let Some(br) = block_root {
956+ parent = br;
957+ noncanonical.push("column-0 node in a block file, adopted by its root".into());
958+ }
959+ }
834960 let idx = nodes.len();
835961 nodes.push(Node {
836962 kind: Kind::Section,
@@ -847,12 +973,11 @@ fn make_setext_section(
847973 },
848974 children: Vec::new(),
849975 parent: Some(parent),
850− file: nodes[parent].file,
851976 block: None,
852977 embed: None,
853978 indent: title_indent,
854979 level: Some(level),
855− noncanonical: vec!["setext heading".into()],
980+ noncanonical,
856981 });
857982 nodes[parent].children.push(idx);
858983 nodes[parent].content.push(Content::Node(idx));
@@ -863,6 +988,7 @@ fn make_setext_section(
863988 level: Some(level),
864989 is_embed: false,
865990 });
991+ idx
866992}
867993
868994fn extend_spans(nodes: &mut Vec<Node>, idx: usize, end: usize) {
crates/fold-core/src/render.rs+10−2
@@ -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
@@ -250,7 +258,7 @@ pub fn embed_line(n: &crate::parse::Node, dlevel: usize, dindent: usize) -> Stri
250258 }
251259}
252260
253−fn push_checkbox(task: Option<TaskState>, out: &mut String) {
261+pub(crate) fn push_checkbox(task: Option<TaskState>, out: &mut String) {
254262 match task {
255263 Some(TaskState::Open) => out.push_str("[ ] "),
256264 Some(TaskState::Done) => out.push_str("[x] "),
crates/fold-core/src/tree.rs+120−31
@@ -2,6 +2,8 @@
22
33use crate::ident::Id;
44use crate::parse::{Kind, Node, ParsedFile, TaskState};
5+use std::collections::{HashMap, HashSet};
6+use std::sync::OnceLock;
57
68/// The logical tree formed by all parsed files stitched together.
79pub struct Tree {
@@ -10,12 +12,57 @@ pub struct Tree {
1012 pub root: (usize, usize),
1113 /// (file, node) → id for every block.
1214 pub blocks: Vec<((usize, usize), Id)>,
15+ /// `blocks` by id, for `block_by_id`; `restitch` rebuilds it.
16+ block_index: HashMap<Id, NRef>,
17+ /// `embeds`, built at first use; `restitch` drops it.
18+ embed_index: OnceLock<HashMap<Id, NRef>>,
1319}
1420
1521/// A global node reference.
1622pub type NRef = (usize, usize);
1723
1824impl Tree {
25+ /// Stitch parsed files, `root.md` first, into one tree (§4.7).
26+ pub fn new(files: Vec<ParsedFile>) -> Tree {
27+ let mut t = Tree {
28+ files,
29+ root: (0, 0),
30+ blocks: Vec::new(),
31+ block_index: HashMap::new(),
32+ embed_index: OnceLock::new(),
33+ };
34+ t.restitch();
35+ t
36+ }
37+
38+ /// Rebuild the block list, and what is derived from the embeds, after
39+ /// a file was replaced: every block is the first node of its own file
40+ /// (§4.9).
41+ pub fn restitch(&mut self) {
42+ let mut blocks: Vec<(NRef, Id)> = Vec::new();
43+ for (fi, f) in self.files.iter().enumerate() {
44+ if fi == 0 {
45+ continue;
46+ }
47+ if let Some(&first) = f.nodes[f.root_node].children.first() {
48+ if let Some(b) = &f.nodes[first].block {
49+ if let Some(id) = &b.id {
50+ blocks.push(((fi, first), id.clone()));
51+ }
52+ }
53+ }
54+ }
55+ // the first file holding an id is its block, as a reload meets
56+ // them; any other is a duplicate id, which `check` reports (§6.2)
57+ let mut index = HashMap::with_capacity(blocks.len());
58+ for (r, id) in &blocks {
59+ index.entry(id.clone()).or_insert(*r);
60+ }
61+ self.blocks = blocks;
62+ self.block_index = index;
63+ self.embed_index = OnceLock::new();
64+ }
65+
1966 pub fn node(&self, r: NRef) -> &Node {
2067 &self.files[r.0].nodes[r.1]
2168 }
@@ -117,48 +164,74 @@ impl Tree {
117164 segs
118165 }
119166
120− /// Resolve an embed node to the block it references (one level).
167+ /// Resolve an embed node to the block it references (one level). Only
168+ /// the block's own embed (`embed_of`) resolves: any other embed of its
169+ /// id is left as it is and reads as broken everywhere, as §6.2 says,
170+ /// so walk, render, the outline and every verb meet a block once.
121171 pub fn resolved_child(&self, r: NRef) -> NRef {
122172 let n = self.node(r);
123173 if let Some(id) = &n.embed {
124174 if let Some(target) = self.block_by_id(id) {
125− return target;
175+ if self.embed_of(id) == Some(r) {
176+ return target;
177+ }
126178 }
127179 }
128180 r
129181 }
130182
131− /// The embed node that references a block (§4.7), if any.
183+ /// The embed a block is stitched in at (§4.7), if any: see `embeds`.
132184 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))
185+ self.embeds().get(id).copied()
186+ }
187+
188+ /// `embed_of` for every id at once: the embed each block is stitched in
189+ /// at, the first of its id met in tree order from the root, as walk
190+ /// and the outline meet them. Any other embed of the id is a duplicate
191+ /// and reads as broken (§6.2). An id embedded only in files the root
192+ /// does not reach (an orphan block, a cycle) keeps its first embed in
193+ /// file order.
194+ pub fn embeds(&self) -> &HashMap<Id, NRef> {
195+ self.embed_index.get_or_init(|| {
196+ let mut out = HashMap::new();
197+ if self.files.is_empty() {
198+ return out;
199+ }
200+ // pre-order, entering each block at its first embed only, so
201+ // the walk ends even through a cycle
202+ let mut stack = vec![self.root];
203+ while let Some(r) = stack.pop() {
204+ let mut at = r;
205+ if let Some(id) = &self.node(r).embed {
206+ if !out.contains_key(id) {
207+ out.insert(id.clone(), r);
208+ at = self.block_by_id(id).unwrap_or(r);
209+ }
210+ }
211+ stack.extend(self.node(at).children.iter().rev().map(|&c| (at.0, c)));
212+ }
213+ for (fi, f) in self.files.iter().enumerate() {
214+ for (ni, nd) in f.nodes.iter().enumerate() {
215+ if let Some(id) = &nd.embed {
216+ out.entry(id.clone()).or_insert((fi, ni));
217+ }
218+ }
219+ }
220+ out
138221 })
139222 }
140223
141224 pub fn block_by_id(&self, id: &Id) -> Option<NRef> {
142− self.blocks
143− .iter()
144− .find(|(_, bid)| bid == id)
145− .map(|(t, _)| *t)
225+ self.block_index.get(id).copied()
146226 }
147227
148− /// Direct children of a node with embeds resolved (broken embeds kept as-is).
228+ /// Direct children of a node with embeds resolved (broken embeds, and
229+ /// second embeds of a block, kept as-is).
149230 pub fn resolved_children(&self, r: NRef) -> Vec<NRef> {
150231 self.node(r)
151232 .children
152233 .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− })
234+ .map(|&c| self.resolved_child((r.0, c)))
162235 .collect()
163236 }
164237
@@ -167,35 +240,46 @@ impl Tree {
167240 self.node(r).children.iter().map(|&c| (r.0, c)).collect()
168241 }
169242
170− /// Derived open/total task counts for a subtree (§3.5).
243+ /// Derived open/total task counts for a subtree (§3.5). A conflict
244+ /// copy below `r` is left out, with its subtree: it repeats the node
245+ /// before it (§12.4).
171246 pub fn task_counts(&self, r: NRef) -> (usize, usize) {
172247 let mut open = 0;
173248 let mut total = 0;
174− self.walk(r, &mut |t, n| {
249+ let mut count = |t: &Tree, n: NRef| {
175250 if let Some(st) = t.node(n).task {
176251 total += 1;
177252 if st == TaskState::Open {
178253 open += 1;
179254 }
180255 }
181− });
256+ };
257+ let copy = |t: &Tree, n: NRef| t.node(n).conflict().is_some();
258+ self.walk_inner(r, &copy, &mut count, &mut HashSet::new());
182259 (open, total)
183260 }
184261
185262 /// Pre-order walk over the resolved tree (embeds followed, cycles guarded).
186263 pub fn walk(&self, r: NRef, f: &mut dyn FnMut(&Tree, NRef)) {
187− let mut seen = Vec::new();
188− self.walk_inner(r, f, &mut seen);
264+ self.walk_inner(r, &|_, _| false, f, &mut HashSet::new());
189265 }
190266
191− fn walk_inner(&self, r: NRef, f: &mut dyn FnMut(&Tree, NRef), seen: &mut Vec<NRef>) {
192− if seen.contains(&r) {
267+ /// `walk`, passing over each subtree below `r` whose top `skip` picks.
268+ fn walk_inner(
269+ &self,
270+ r: NRef,
271+ skip: &dyn Fn(&Tree, NRef) -> bool,
272+ f: &mut dyn FnMut(&Tree, NRef),
273+ seen: &mut HashSet<NRef>,
274+ ) {
275+ if !seen.insert(r) {
193276 return; // cycle guard
194277 }
195− seen.push(r);
196278 f(self, r);
197279 for cr in self.resolved_children(r) {
198− self.walk_inner(cr, f, seen);
280+ if !skip(self, cr) {
281+ self.walk_inner(cr, skip, f, seen);
282+ }
199283 }
200284 }
201285}
@@ -204,6 +288,11 @@ impl Node {
204288 pub fn is_block(&self) -> bool {
205289 self.block.is_some()
206290 }
291+ /// A conflict copy's `conflict:` value, unquoted: the other device and
292+ /// when (§12.4). `None` for any other node.
293+ pub fn conflict(&self) -> Option<&str> {
294+ self.block.as_ref()?.prop("conflict").map(|v| v.trim_matches('"'))
295+ }
207296 pub fn is_embed(&self) -> bool {
208297 self.embed.is_some()
209298 }
crates/fold-core/src/vault.rs+143−109
@@ -2,7 +2,7 @@
22//! trash (§4.1, §11).
33
44use crate::ident::{split_filename, Id};
5−use crate::parse::{parse_file, parse_frontmatter, Block, Kind, ParsedFile, Span};
5+use crate::parse::{parse_file, parse_frontmatter, Block, Frontmatter, Kind, ParsedFile, Span};
66use crate::tree::{NRef, Tree};
77use std::path::{Path, PathBuf};
88
@@ -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)
@@ -48,21 +44,62 @@ impl Vault {
4844
4945 /// Rebuild the index from the vault (§11.3).
5046 pub fn reload(&mut self) -> std::io::Result<()> {
51− let mut files: Vec<ParsedFile> = Vec::new();
52− let root_text = read_if_exists(&self.dir.join("root.md"))?.unwrap_or_default();
53− let root_block = Block {
54− id: None,
55− path: "root.md".into(),
56− props: parse_frontmatter(&root_text)
57− .map(|f| f.props)
58− .unwrap_or_default(),
59− frontmatter_raw: parse_frontmatter(&root_text)
60− .map(|f| f.raw)
61− .unwrap_or_default(),
62− frontmatter_span: parse_frontmatter(&root_text).map(|f| f.span),
47+ let files = self.read_files()?.iter().map(|(name, text)| parse_vault_file(name, text)).collect();
48+ // Stitch: collect blocks, resolve embed edges (§4.7).
49+ self.tree = Tree::new(files);
50+ Ok(())
51+ }
52+
53+ /// Take a file this vault has just written into the index, where a
54+ /// reload would put it (`root.md` first, then by name), without reading
55+ /// any other file again: what another program changed in them since
56+ /// they were read is not taken in as part of the operation, nor into its
57+ /// op-log entry, so undoing the operation cannot revert it (§10.10).
58+ /// Node references into the files after it move up one place. A file
59+ /// the index holds already is parsed again in its place.
60+ pub fn add_file(&mut self, name: &str, text: &str) {
61+ if let Some(i) = self.file_index(name) {
62+ self.tree.files[i] = parse_vault_file(name, text);
63+ self.tree.restitch();
64+ return;
65+ }
66+ let at = self
67+ .tree
68+ .files
69+ .iter()
70+ .skip(1)
71+ .position(|f| f.path.as_str() > name)
72+ .map_or(self.tree.files.len(), |i| i + 1);
73+ self.tree.files.insert(at, parse_vault_file(name, text));
74+ self.tree.restitch();
75+ }
76+
77+ /// Rename a file the index holds, on disk and in the index, which
78+ /// keeps the order a reload gives it; nothing else is read again (see
79+ /// `add_file`). Node references into the files from it on may move.
80+ pub fn rename_file(&mut self, file: usize, name: &str) -> std::io::Result<()> {
81+ std::fs::rename(self.dir.join(&self.tree.files[file].path), self.dir.join(name))?;
82+ let f = self.tree.files.remove(file);
83+ self.add_file(name, &f.text);
84+ Ok(())
85+ }
86+
87+ /// The vault as it is on disk now, parsed apart from this one: what a
88+ /// reload would make of it, for saying what it changed (§11.2).
89+ pub fn on_disk(&self) -> std::io::Result<Vault> {
90+ let mut v = Vault {
91+ dir: self.dir.clone(),
92+ tree: Tree::new(Vec::new()),
6393 };
64− files.push(parse_file("root.md", &root_text, 0, Some(root_block)));
94+ v.reload()?;
95+ Ok(v)
96+ }
6597
98+ /// The files a reload parses, as they are on disk: `root.md` (empty
99+ /// when missing), then every other `.md` file with a valid id, by name.
100+ fn read_files(&self) -> std::io::Result<Vec<(String, String)>> {
101+ let root_text = read_if_exists(&self.dir.join("root.md"))?.unwrap_or_default();
102+ let mut files = vec![("root.md".to_string(), root_text)];
66103 let mut entries: Vec<String> = std::fs::read_dir(&self.dir)?
67104 .filter_map(|e| e.ok())
68105 .map(|e| e.file_name().to_string_lossy().to_string())
@@ -79,50 +116,26 @@ impl Vault {
79116 Some(t) => t,
80117 None => continue,
81118 };
82− let fm = parse_frontmatter(&text);
83− let id = fm
84− .as_ref()
85− .and_then(|f| f.props.get("id"))
86− .and_then(|v| Id::parse(v));
87− let id = match id {
88− Some(id) => id,
89− None => continue, // ignored: never parsed (§4.1)
90− };
91− let idx = files.len();
92− let block = Block {
93− id: Some(id),
94− path: name.clone(),
95− props: fm.as_ref().map(|f| f.props.clone()).unwrap_or_default(),
96− frontmatter_raw: fm.as_ref().map(|f| f.raw.clone()).unwrap_or_default(),
97− frontmatter_span: fm.as_ref().map(|f| f.span),
98− };
99− files.push(parse_file(&name, &text, idx, Some(block)));
100− }
101−
102− // 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− }
119+ if parse_frontmatter(&text).as_ref().and_then(Frontmatter::id).is_none() {
120+ continue; // ignored: never parsed (§4.1)
114121 }
122+ files.push((name, text));
115123 }
124+ Ok(files)
125+ }
116126
117− self.tree = Tree {
118− files,
119− root: (0, 0),
120− blocks,
121− };
122− Ok(())
127+ /// Whether a reload would find anything the index does not hold: a
128+ /// file's text changed, or a file came or went (§11.2). What the app
129+ /// wrote itself, or a file written back as it was, is no change.
130+ pub fn changed_on_disk(&self) -> std::io::Result<bool> {
131+ let files = self.read_files()?;
132+ Ok(files.len() != self.tree.files.len()
133+ || files.iter().any(|(name, text)| {
134+ self.file_index(name).is_none_or(|i| self.tree.files[i].text != *text)
135+ }))
123136 }
124137
125− /// Files ignored by the parser, for `notes check` (§4.1).
138+ /// Files ignored by the parser, for `fold check` (§4.1).
126139 pub fn ignored_files(&self) -> std::io::Result<Vec<IgnoredFile>> {
127140 let mut out = Vec::new();
128141 for e in std::fs::read_dir(&self.dir)?.filter_map(|e| e.ok()) {
@@ -180,66 +193,54 @@ impl Vault {
180193 self.write_file_text(file, &new_text)
181194 }
182195
183− /// Write a whole file atomically, then re-parse it.
196+ /// Write a whole file atomically, then re-parse it. Refuses, writing
197+ /// nothing, when the file on disk is no longer the text it was parsed
198+ /// from (`check_unchanged`).
184199 pub fn write_file_text(&mut self, file: usize, new_text: &str) -> std::io::Result<()> {
200+ self.check_unchanged(file)?;
185201 let path = self.tree.files[file].path.clone();
186202 atomic_write(&self.dir.join(&path), new_text)?;
187203 self.reparse(file, new_text)
188204 }
189205
190− /// Re-parse one file from given text (after our own write) and re-stitch.
206+ /// Refuse when the file on disk is no longer the text it was parsed
207+ /// from: every op computes its new text from the tree, so writing would
208+ /// silently drop a change (a sync, another editor) not reloaded yet
209+ /// (§1 principle 4, §5.2 step 5, §11.2).
210+ pub fn check_unchanged(&self, file: usize) -> std::io::Result<()> {
211+ let path = &self.tree.files[file].path;
212+ // a missing file reads as empty, as `reload` reads a missing root.md
213+ if read_if_exists(&self.dir.join(path))?.unwrap_or_default() != self.tree.files[file].text {
214+ return Err(std::io::Error::other(format!(
215+ "{} changed on disk; not overwriting",
216+ path
217+ )));
218+ }
219+ Ok(())
220+ }
221+
222+ /// Re-parse one file from given text (after our own write, or as read
223+ /// from disk) and re-stitch.
191224 pub fn reparse(&mut self, file: usize, text: &str) -> std::io::Result<()> {
192225 let path = self.tree.files[file].path.clone();
193226 let old_block = self.tree.files[file]
194227 .nodes
195228 .iter()
196229 .find_map(|n| n.block.clone());
230+ let fm = parse_frontmatter(text);
197231 let block = if path == "root.md" {
198− Some(Block {
199− id: None,
200− path: path.clone(),
201− props: parse_frontmatter(text).map(|f| f.props).unwrap_or_default(),
202− frontmatter_raw: parse_frontmatter(text).map(|f| f.raw).unwrap_or_default(),
203− frontmatter_span: parse_frontmatter(text).map(|f| f.span),
204− })
232+ Some(Block::new(&path, None, fm.as_ref()))
205233 } else {
206234 old_block.map(|mut b| {
207− let fm = parse_frontmatter(text);
208− if let Some(f) = fm.as_ref() {
209− b.props = f.props.clone();
210− b.frontmatter_raw = f.raw.clone();
211− b.frontmatter_span = Some(f.span);
212− } else {
213− b.props.clear();
214− b.frontmatter_raw.clear();
215− b.frontmatter_span = None;
216− }
235+ b.set_frontmatter(fm.as_ref());
217236 b
218237 })
219238 };
220− self.tree.files[file] = parse_file(&path, text, file, block);
221− self.restitch();
239+ self.tree.files[file] = parse_file(&path, text, block);
240+ self.tree.restitch();
222241 Ok(())
223242 }
224243
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−
243244 /// Shortest prefix of `id` not already used by another file (§6.4).
244245 pub fn unique_prefix(&self, id: &Id) -> String {
245246 self.unique_prefix_except(id, None)
@@ -263,14 +264,18 @@ impl Vault {
263264 .collect()
264265 }
265266
266− /// Move a file to the trash (§11.5) and remove it from the vault.
267+ /// Move a file to the trash (§11.5) and remove it from the vault. Only
268+ /// that file leaves the index: the others are not read again, so a
269+ /// change another program made to one of them (a sync) is not taken in
270+ /// as part of the operation, nor into its op-log entry (§10.10).
267271 pub fn trash_file(&mut self, file: usize) -> std::io::Result<()> {
268272 let path = self.tree.files[file].path.clone();
269273 let trash = trash_dir();
270274 std::fs::create_dir_all(&trash)?;
271275 let target = trash_target(&trash, &path);
272276 move_file(&self.dir.join(&path), &target)?;
273− self.reload()?;
277+ self.tree.files.remove(file);
278+ self.tree.restitch();
274279 Ok(())
275280 }
276281
@@ -315,7 +320,7 @@ fn trash_target(trash: &Path, name: &str) -> PathBuf {
315320pub fn trash_dir() -> PathBuf {
316321 directories::ProjectDirs::from("", "", "fold")
317322 .map(|p| p.state_dir().unwrap_or(p.data_dir()).join("trash"))
318− .unwrap_or_else(|| PathBuf::from(".notes-trash"))
323+ .unwrap_or_else(|| PathBuf::from(".fold-trash"))
319324}
320325
321326/// Write to `.<name>.fold-tmp`, fsync, rename (§11.1).
@@ -345,6 +350,14 @@ pub fn move_file(from: &Path, to: &Path) -> std::io::Result<()> {
345350 }
346351}
347352
353+/// Parse a vault file as a reload reads it: `root.md` is the one file
354+/// without an id; any other is a block file, by the id it holds (§4.9).
355+fn parse_vault_file(name: &str, text: &str) -> ParsedFile {
356+ let fm = parse_frontmatter(text);
357+ let id = if name == "root.md" { None } else { fm.as_ref().and_then(Frontmatter::id) };
358+ parse_file(name, text, Some(Block::new(name, id, fm.as_ref())))
359+}
360+
348361/// Read a vault `.md` file as text; `None` for anything that is not a
349362/// readable UTF-8 regular file.
350363fn read_md_file(path: &Path) -> Option<String> {
@@ -464,6 +477,27 @@ impl Vault {
464477 Some(cur)
465478 }
466479
480+ /// Every node at a case-insensitive title path from the root (§3.4):
481+ /// each step keeps every child whose title matches, so a path is
482+ /// ambiguous only when it names two nodes, whatever titles repeat on
483+ /// the way.
484+ fn find_all_by_path(&self, segs: &[String]) -> Vec<NRef> {
485+ let mut found = vec![self.tree.root];
486+ for seg in segs {
487+ let mut next = Vec::new();
488+ for &p in &found {
489+ for c in self.tree.resolved_children(p) {
490+ // a block embedded twice is still one node
491+ if title_eq(&self.tree.node(c).title, seg) && !next.contains(&c) {
492+ next.push(c);
493+ }
494+ }
495+ }
496+ found = next;
497+ }
498+ found
499+ }
500+
467501 /// Resolve a command target: id (or unique leading run), then path, then
468502 /// unique title (§3.4).
469503 pub fn resolve_target(&self, text: &str) -> Result<NRef, String> {
@@ -487,17 +521,17 @@ impl Vault {
487521 }
488522 // 2. path
489523 if text.contains('/') {
490− let rel = text.strip_prefix("./");
524+ if text.starts_with("./") {
525+ return Err("./ relative resolution needs a zoom root".into());
526+ }
491527 let segs: Vec<String> = text.split('/').map(|s| s.trim().to_string()).collect();
492− let base = match rel {
493− Some(_) => return Err("./ relative resolution needs a zoom root".into()),
494− None => self.tree.root,
528+ // a path that matches twice is an ambiguity, never a guess
529+ let found = self.find_all_by_path(&segs);
530+ return match found.len() {
531+ 1 => Ok(found[0]),
532+ 0 => Err(format!("no node at path {:?}", text)),
533+ _ => Err(format!("path {:?} is ambiguous", text)),
495534 };
496− 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));
501535 }
502536 // 3. unique title
503537 let mut found: Vec<NRef> = Vec::new();
crates/fold-core/tests/edit.rs+978−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,978 @@ 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_block_file_renamed_on_disk_is_checked_against_what_was_read_under_its_old_name() {
654+ // `fold check --fix` renames a block file whose title changed, its
655+ // text as it was: §5.2 step 5 still holds under the new name. The
656+ // phone's line added there after the vault read it is refused, not
657+ // taken as a file never read and written over
658+ let (d, mut v) = vault_with("# A\n\n- task\n");
659+ let a = v.tree.resolved_children(v.tree.root)[0];
660+ let task = v.tree.resolved_children(a)[0];
661+ ops::make_block(&mut v, task).unwrap();
662+ let a = v.tree.resolved_children(v.tree.root)[0];
663+ let mut buf = open_editor(&v, a);
664+ let old = d.path().join(&v.tree.files[1].path);
665+ let renamed = d.path().join(v.tree.files[1].path.replace("~task.md", "~task-renamed.md"));
666+ std::fs::rename(&old, &renamed).unwrap();
667+ v.reload().unwrap();
668+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
669+ buf.set_line(i, "- task XYZ".into());
670+ let theirs = std::fs::read_to_string(&renamed).unwrap() + " - phone child\n";
671+ std::fs::write(&renamed, &theirs).unwrap();
672+ let err = buf.save_all(&mut v).unwrap_err();
673+ assert!(err.to_string().contains("changed on disk"), "{}", err);
674+ // and once the vault has read it, as after any refused save
675+ v.reload().unwrap();
676+ let err = buf.save_all(&mut v).unwrap_err();
677+ assert!(err.to_string().contains("changed on disk"), "{}", err);
678+ assert_eq!(std::fs::read_to_string(&renamed).unwrap(), theirs);
679+ assert!(!buf.dirty.is_empty());
680+ // a rename alone changes nothing the buffer read: the typing is saved
681+ // under the new name, and again after a second rename
682+ let (d, mut v) = vault_with("# A\n\n- task\n");
683+ let a = v.tree.resolved_children(v.tree.root)[0];
684+ let task = v.tree.resolved_children(a)[0];
685+ ops::make_block(&mut v, task).unwrap();
686+ let a = v.tree.resolved_children(v.tree.root)[0];
687+ let mut buf = open_editor(&v, a);
688+ let mut path = d.path().join(&v.tree.files[1].path);
689+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
690+ for (n, name) in ["~task-renamed.md", "~task-again.md"].into_iter().enumerate() {
691+ let to = path.with_file_name(path.file_name().unwrap().to_string_lossy().split('~').next().unwrap().to_string() + name);
692+ std::fs::rename(&path, &to).unwrap();
693+ path = to;
694+ v.reload().unwrap();
695+ buf.set_line(i, format!("- task {}", n));
696+ assert_eq!(buf.save_all(&mut v).unwrap(), 1, "rename {}", n);
697+ assert!(std::fs::read_to_string(&path).unwrap().ends_with(&format!("\n- task {}\n", n)), "rename {}", n);
698+ }
699+}
700+
701+#[test]
702+fn a_save_goes_through_when_the_file_changed_only_outside_the_block() {
703+ // §5.2 step 5 compares the block's source span, not the whole file:
704+ // a sync that changed another section of root.md (and moved this one
705+ // down) while the user typed does not refuse the save, and both
706+ // changes are kept
707+ let (d, mut v) = vault_with("# A\n\nbody\n\n# B\n\nother\n");
708+ let a = v.tree.resolved_children(v.tree.root)[0];
709+ let mut buf = open_editor(&v, a);
710+ let synced = "intro\n\n# A\n\nbody\n\n# B\n\nother, from Helix\n";
711+ std::fs::write(d.path().join("root.md"), synced).unwrap();
712+ let i = buf.lines.iter().position(|l| l.text == "body").unwrap();
713+ buf.set_line(i, "mine".into());
714+ assert_eq!(buf.save_all(&mut v).unwrap(), 1);
715+ let disk = std::fs::read_to_string(d.path().join("root.md")).unwrap();
716+ assert_eq!(disk, "intro\n\n# A\n\nmine\n\n# B\n\nother, from Helix\n");
717+ assert_eq!(v.tree.files[0].text, disk);
718+ assert!(buf.dirty.is_empty());
719+ // the next save starts from what this one wrote
720+ buf.set_line(i, "mine again".into());
721+ assert_eq!(buf.save_all(&mut v).unwrap(), 1);
722+ let disk = std::fs::read_to_string(d.path().join("root.md")).unwrap();
723+ assert_eq!(disk, "intro\n\n# A\n\nmine again\n\n# B\n\nother, from Helix\n");
724+ // a change to the block's own text is still refused (§5.2 step 5)
725+ std::fs::write(d.path().join("root.md"), disk.replace("mine again", "theirs")).unwrap();
726+ buf.set_line(i, "mine, third".into());
727+ let err = buf.save_all(&mut v).unwrap_err();
728+ assert!(err.to_string().contains("changed on disk"), "{}", err);
729+ assert!(std::fs::read_to_string(d.path().join("root.md")).unwrap().contains("theirs"));
730+}
731+
732+#[test]
733+fn a_block_save_keeps_a_property_set_on_disk_meanwhile() {
734+ // a block's source span is its file after the frontmatter: a property
735+ // another device set while the block was edited stays
736+ let (d, mut v) = vault_with("# A\n\n- task\n");
737+ let a = v.tree.resolved_children(v.tree.root)[0];
738+ let task = v.tree.resolved_children(a)[0];
739+ ops::make_block(&mut v, task).unwrap();
740+ let a = v.tree.resolved_children(v.tree.root)[0];
741+ let mut buf = open_editor(&v, a);
742+ let p = d.path().join(&v.tree.files[1].path);
743+ let theirs = std::fs::read_to_string(&p).unwrap().replace("\n---\n", "\ndue: 2026-10-01\n---\n");
744+ std::fs::write(&p, &theirs).unwrap();
745+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
746+ buf.set_line(i, "- task (mine)".into());
747+ assert_eq!(buf.save_all(&mut v).unwrap(), 1);
748+ let disk = std::fs::read_to_string(&p).unwrap();
749+ assert_eq!(disk, theirs.replace("- task", "- task (mine)"));
750+ assert!(disk.contains("due: 2026-10-01\n---\n\n- task (mine)\n"), "{}", disk);
751+}
752+
753+#[test]
754+fn a_held_block_title_is_in_transit_not_deleted() {
755+ // §5.2: cutting a nested block's title line and pasting it moves the
756+ // block. While the editor's clipboard holds the line, a save writes the
757+ // parent without the embed and leaves the block and its file alone; put
758+ // back, the block is embedded where it now sits; released without being
759+ // put back, it is deleted like any block whose title line is.
760+ let (d, mut v) = vault_with("# A\n\n- one\n- task\n- two\n");
761+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
762+ let id = ops::make_block(&mut v, t).unwrap();
763+ let block_path = v.tree.files[1].path.clone();
764+ let block_before = v.tree.files[1].text.clone();
765+ let a = v.tree.resolved_children(v.tree.root)[0];
766+ let mut buf = open_editor(&v, a);
767+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
768+ let cut = buf.lines[i].clone();
769+ buf.delete_line(i);
770+ buf.hold(vec![cut.owner]);
771+ buf.save_all(&mut v).unwrap();
772+ assert_eq!(v.tree.files[0].text, "# A\n\n- one\n- two\n");
773+ assert_eq!(std::fs::read_to_string(d.path().join(&block_path)).unwrap(), block_before);
774+ // pasted below "- two" with its tag: the block and its parent are dirty
775+ let two = buf.lines.iter().position(|l| l.text == "- two").unwrap();
776+ buf.lines.insert(two + 1, cut.clone());
777+ buf.mark_dirty(cut.owner);
778+ buf.mark_dirty(buf.lines[0].owner);
779+ buf.save_all(&mut v).unwrap();
780+ assert_eq!(v.tree.files[0].text, format!("# A\n\n- one\n- two\n![[{}]]\n", id.as_str()));
781+ assert_eq!(std::fs::read_to_string(d.path().join(&block_path)).unwrap(), block_before);
782+ // cut again and saved, then the clipboard replaced: the block is deleted
783+ buf.delete_line(two + 1);
784+ buf.save_all(&mut v).unwrap();
785+ assert!(d.path().join(&block_path).exists());
786+ buf.hold(Vec::new());
787+ buf.save_all(&mut v).unwrap();
788+ assert_eq!(v.tree.files[0].text, "# A\n\n- one\n- two\n");
789+ assert!(!d.path().join(&block_path).exists());
790+ assert!(buf.dirty.is_empty(), "{:?}", buf.dirty);
791+}
792+
793+#[test]
794+fn a_held_block_still_in_the_buffer_is_not_in_transit() {
795+ // the clipboard holding a copy of a block's title line keeps the block
796+ // only while nothing of it is in the buffer; a title line re-spelled in
797+ // place, or deleted while a block nested in it stays, deletes the block
798+ // (§5.2) at once
799+ let (d, mut v) = vault_with("# A\n\n- one\n- task\n");
800+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
801+ ops::make_block(&mut v, t).unwrap();
802+ let block_path = v.tree.files[1].path.clone();
803+ let a = v.tree.resolved_children(v.tree.root)[0];
804+ let mut buf = open_editor(&v, a);
805+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
806+ buf.hold(vec![buf.lines[i].owner]);
807+ buf.set_line(i, "task".into());
808+ buf.save_all(&mut v).unwrap();
809+ assert_eq!(v.tree.files[0].text, "# A\n\n- one\ntask\n");
810+ assert!(!d.path().join(&block_path).exists());
811+
812+ let (d, mut v) = vault_with("# A\n\n- one\n- b\n - c\n- two\n");
813+ let c = v.find_by_path(&["A".into(), "b".into(), "c".into()]).unwrap();
814+ let c = ops::make_block(&mut v, c).unwrap();
815+ let b = v.find_by_path(&["A".into(), "b".into()]).unwrap();
816+ ops::make_block(&mut v, b).unwrap();
817+ let b_path = v.tree.files.iter().find(|f| f.text.contains(&format!("![[{}]]", c.as_str()))).unwrap().path.clone();
818+ let a = v.tree.resolved_children(v.tree.root)[0];
819+ let mut buf = open_editor(&v, a);
820+ let i = buf.lines.iter().position(|l| l.text == "- b").unwrap();
821+ buf.hold(vec![buf.lines[i].owner]);
822+ buf.delete_line(i);
823+ buf.save_all(&mut v).unwrap();
824+ assert_eq!(v.tree.files[0].text, format!("# A\n\n- one\n ![[{}]]\n- two\n", c.as_str()));
825+ assert!(!d.path().join(&b_path).exists());
826+}
827+
828+#[test]
829+fn a_reload_that_leaves_the_text_as_it_was_keeps_a_block_in_transit() {
830+ // §11.2: the phone adds a node above A in root.md and the vault re-reads
831+ // it: the buffer on A takes in where A is now and the file's hash, its
832+ // cut block still in transit, so the paste embeds the block again. A
833+ // change to A's own text is not taken in: the editor is re-rendered
834+ let (d, mut v) = vault_with("# A\n\n- one\n- task\n- two\n");
835+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
836+ let id = ops::make_block(&mut v, t).unwrap();
837+ let block_path = d.path().join(&v.tree.files[1].path);
838+ let block_before = v.tree.files[1].text.clone();
839+ let a = v.tree.resolved_children(v.tree.root)[0];
840+ let mut buf = open_editor(&v, a);
841+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
842+ let cut = buf.lines[i].clone();
843+ buf.delete_line(i);
844+ buf.hold(vec![cut.owner]);
845+ buf.save_all(&mut v).unwrap();
846+ let theirs = "# Inbox\n\n- [ ] from phone\n\n# A\n\n- one\n- two\n";
847+ std::fs::write(d.path().join("root.md"), theirs).unwrap();
848+ v.reload().unwrap();
849+ let a = v.find_by_path(&["A".into()]).unwrap();
850+ assert!(buf.take_in(open_editor(&v, a)));
851+ // pasted below "- two" with its tag
852+ let two = buf.lines.iter().position(|l| l.text == "- two").unwrap();
853+ buf.lines.insert(two + 1, cut.clone());
854+ buf.mark_dirty(cut.owner);
855+ buf.mark_dirty(buf.lines[0].owner);
856+ buf.save_all(&mut v).unwrap();
857+ let embedded = format!("{}![[{}]]\n", theirs, id.as_str());
858+ assert_eq!(std::fs::read_to_string(d.path().join("root.md")).unwrap(), embedded);
859+ assert_eq!(std::fs::read_to_string(&block_path).unwrap(), block_before);
860+ // A's own text changed: nothing taken in
861+ std::fs::write(d.path().join("root.md"), embedded.replace("- one", "- one, from Helix")).unwrap();
862+ v.reload().unwrap();
863+ let a = v.find_by_path(&["A".into()]).unwrap();
864+ assert!(!buf.take_in(open_editor(&v, a)));
865+}
866+
867+#[test]
868+fn review_external_edit_of_the_zoomed_node_never_lands_on_an_identical_one() {
869+ // §5.2 step 5: the zoomed item's own text changed on disk (another
870+ // device edited it) while it was edited here. Its old bytes still occur
871+ // once in the file — as an identical item under another section — but
872+ // that is another node: the save must be refused, not written over it.
873+ let (d, mut v) = vault_with("# X\n\n- call mom\n\n# Y\n\n- call mom\n");
874+ let x_item = v.find_by_path(&["X".into(), "call mom".into()]).unwrap();
875+ let mut buf = open_editor(&v, x_item);
876+ assert_eq!(buf.lines[0].text, "- call mom");
877+ let theirs = "# X\n\n- call mom (phone)\n\n# Y\n\n- call mom\n";
878+ std::fs::write(d.path().join("root.md"), theirs).unwrap();
879+ buf.set_line(0, "- call mom tomorrow".into());
880+ let res = buf.save_all(&mut v);
881+ let disk = std::fs::read_to_string(d.path().join("root.md")).unwrap();
882+ assert!(
883+ res.is_err() && disk == theirs,
884+ "save {:?}; Y's item overwritten:\n{}",
885+ res.map_err(|e| e.to_string()),
886+ disk
887+ );
888+ // changes elsewhere, one of them an identical item added above, still
889+ // let the save through, onto the zoomed node
890+ let (d, mut v) = vault_with("# X\n\n- call mom\n\n# Y\n\n- call mom\n");
891+ let x_item = v.find_by_path(&["X".into(), "call mom".into()]).unwrap();
892+ let mut buf = open_editor(&v, x_item);
893+ let theirs = "# W\n\n- call mom\n\n# X\n\n- call mom\n\n# Y\n\n- call mom, from phone\n";
894+ std::fs::write(d.path().join("root.md"), theirs).unwrap();
895+ buf.set_line(0, "- call mom tomorrow".into());
896+ assert_eq!(buf.save_all(&mut v).unwrap(), 1);
897+ assert_eq!(
898+ std::fs::read_to_string(d.path().join("root.md")).unwrap(),
899+ "# W\n\n- call mom\n\n# X\n\n- call mom tomorrow\n\n# Y\n\n- call mom, from phone\n"
900+ );
901+}
902+
903+#[test]
904+fn review_undoing_an_editor_save_leaves_another_files_sync_alone() {
905+ // §10.10: an op-log entry holds exactly the files the operation
906+ // changed. An editor save that deletes a nested block trashes its file,
907+ // and the trash reloads every file: a sync to another block's file not
908+ // reloaded yet must not become part of the save's entry, or undoing
909+ // the save writes that file's old text back over the sync
910+ let (d, mut v) = vault_with("# A\n\n- task\n\n# N\n\n- note\n");
911+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
912+ ops::make_block(&mut v, t).unwrap();
913+ let n = v.find_by_path(&["N".into(), "note".into()]).unwrap();
914+ let note_id = ops::make_block(&mut v, n).unwrap();
915+ let note = d.path().join(&v.tree.files[v.tree.block_by_id(&note_id).unwrap().0].path);
916+ let a = v.tree.resolved_children(v.tree.root)[0];
917+ let mut buf = open_editor(&v, a);
918+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
919+ buf.delete_line(i);
920+ let synced = std::fs::read_to_string(&note).unwrap().replace("- note", "- note, from phone");
921+ std::fs::write(&note, &synced).unwrap();
922+ // what the app does on a save (§10.10)
923+ buf.rebase_dirty(&mut v);
924+ let snap = ops::Snapshot::take(&v, "edit");
925+ buf.save_all(&mut v).unwrap();
926+ let inv = ops::Inverse::since(snap, &v).unwrap();
927+ inv.undo(&mut v).unwrap();
928+ assert_eq!(
929+ std::fs::read_to_string(&note).unwrap(),
930+ synced,
931+ "undoing the save reverted another device's change to a file it never touched"
932+ );
933+}
934+
935+#[test]
936+fn review_a_block_is_not_trashed_while_the_file_on_disk_embeds_it() {
937+ // A block cut in the editor is in transit: the parent is saved without
938+ // its embed. Another device's root.md, which still embeds it, then
939+ // lands (§12.1). Releasing the clipboard deletes the block only once no
940+ // file embeds it (§5.2): the parent's save is refused (changed on
941+ // disk), so root.md on disk still embeds the block and its file must
942+ // stay, not go to the trash leaving that embed broken
943+ let (d, mut v) = vault_with("# A\n\n- one\n- task\n- two\n");
944+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
945+ let id = ops::make_block(&mut v, t).unwrap();
946+ let block = d.path().join(&v.tree.files[1].path);
947+ let a = v.tree.resolved_children(v.tree.root)[0];
948+ let mut buf = open_editor(&v, a);
949+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
950+ let cut = buf.lines[i].owner;
951+ buf.delete_line(i);
952+ buf.hold(vec![cut]);
953+ buf.save_all(&mut v).unwrap();
954+ assert_eq!(v.tree.files[0].text, "# A\n\n- one\n- two\n");
955+ assert!(block.exists(), "in transit");
956+ let theirs = format!("# A\n\n- one\n![[{}]]\n- two, from phone\n", id.as_str());
957+ std::fs::write(d.path().join("root.md"), &theirs).unwrap();
958+ buf.hold(Vec::new());
959+ let res = buf.save_all(&mut v);
960+ assert_eq!(std::fs::read_to_string(d.path().join("root.md")).unwrap(), theirs);
961+ assert!(
962+ block.exists(),
963+ "save {:?}: root.md embeds {} but its file went to the trash",
964+ res.map_err(|e| e.to_string()),
965+ id.as_str()
966+ );
967+}
968+
969+#[test]
970+fn discarding_the_buffer_deletes_a_block_a_save_left_embedded_nowhere() {
971+ // Revert (§10.6) drops the unsaved text, but a block cut and saved in
972+ // transit cannot be pasted back any more: it is deleted (§5.2). One cut
973+ // and not saved is still embedded, and stays
974+ let (d, mut v) = vault_with("# A\n\n- one\n- task\n- two\n");
975+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
976+ ops::make_block(&mut v, t).unwrap();
977+ let block = d.path().join(&v.tree.files[1].path);
978+ let a = v.tree.resolved_children(v.tree.root)[0];
979+ let cut = |v: &Vault| {
980+ let mut buf = open_editor(v, a);
981+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
982+ let o = buf.lines[i].owner;
983+ buf.delete_line(i);
984+ buf.hold(vec![o]);
985+ buf
986+ };
987+ let buf = cut(&v);
988+ v.reload().unwrap();
989+ buf.discard(&mut v).unwrap();
990+ assert!(block.exists());
991+ assert_eq!(v.tree.files.len(), 2);
992+ let mut buf = cut(&v);
993+ buf.save_all(&mut v).unwrap();
994+ assert!(block.exists(), "in transit");
995+ v.reload().unwrap();
996+ buf.discard(&mut v).unwrap();
997+ assert!(!block.exists());
998+ assert_eq!(v.tree.files.len(), 1);
999+ assert_eq!(std::fs::read_to_string(d.path().join("root.md")).unwrap(), "# A\n\n- one\n- two\n");
1000+}
1001+
1002+#[test]
1003+fn review_indenting_a_nested_block_title_line_does_not_delete_the_block() {
1004+ // §5.2: only deleting a nested block's title line deletes the block.
1005+ // Indenting "- task" under "- one" (Tab, vim `>>`) keeps the title line:
1006+ // the block must survive, embedded where its title line now sits
1007+ let (d, mut v) = vault_with("# A\n\n- one\n- task\n");
1008+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
1009+ let id = ops::make_block(&mut v, t).unwrap();
1010+ let block = d.path().join(&v.tree.files[1].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 == "- task").unwrap();
1014+ buf.set_line(i, " - task".into());
1015+ buf.save_all(&mut v).unwrap();
1016+ let root = std::fs::read_to_string(d.path().join("root.md")).unwrap();
1017+ assert!(
1018+ block.exists() && root.contains(&format!("![[{}]]", id.as_str())),
1019+ "block file exists: {}; root.md:\n{}",
1020+ block.exists(),
1021+ root
1022+ );
1023+}
1024+
1025+#[test]
1026+fn a_re_indented_or_re_spelled_block_title_line_moves_the_block() {
1027+ // §5.2: the embed goes where the title line sits, in its form (a
1028+ // heading embed for a heading); the block's file keeps its root at
1029+ // column 0, a heading at level 1 (§4.9)
1030+ let (_d, mut v) = vault_with("# A\n\n- one\n- task\n body\n");
1031+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
1032+ let id = ops::make_block(&mut v, t).unwrap();
1033+ let fm = format!("---\nid: {}\n---\n\n", id.as_str());
1034+ let a = v.tree.resolved_children(v.tree.root)[0];
1035+ let mut buf = open_editor(&v, a);
1036+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
1037+ buf.set_line(i, " - task".into());
1038+ buf.set_line(i + 1, " body".into());
1039+ buf.save_all(&mut v).unwrap();
1040+ assert_eq!(v.tree.files[0].text, format!("# A\n\n- one\n ![[{}]]\n", id.as_str()));
1041+ assert_eq!(v.tree.files[1].text, format!("{}- task\n body\n", fm));
1042+ // renamed after: still the block's title line
1043+ buf.set_line(i, " - task, renamed".into());
1044+ buf.save_all(&mut v).unwrap();
1045+ assert_eq!(v.tree.files[1].text, format!("{}- task, renamed\n body\n", fm));
1046+ // re-spelled as a heading: a heading embed, a level-1 root
1047+ buf.set_line(i, "## task".into());
1048+ buf.set_line(i + 1, "body".into());
1049+ buf.save_all(&mut v).unwrap();
1050+ assert_eq!(v.tree.files[0].text, format!("# A\n\n- one\n## ![[{}]]\n", id.as_str()));
1051+ assert_eq!(v.tree.files[1].text, format!("{}# task\nbody\n", fm));
1052+ // and back
1053+ buf.set_line(i, "- task".into());
1054+ buf.set_line(i + 1, " body".into());
1055+ buf.save_all(&mut v).unwrap();
1056+ assert_eq!(v.tree.files[0].text, format!("# A\n\n- one\n![[{}]]\n", id.as_str()));
1057+ assert_eq!(v.tree.files[1].text, format!("{}- task\n body\n", fm));
1058+ assert!(buf.dirty.is_empty(), "{:?}", buf.dirty);
1059+
1060+ // a block nested in an item, dedented out of it
1061+ let (_d, mut v) = vault_with("# A\n\n- one\n - c\n");
1062+ let c = v.find_by_path(&["A".into(), "one".into(), "c".into()]).unwrap();
1063+ let id = ops::make_block(&mut v, c).unwrap();
1064+ let a = v.tree.resolved_children(v.tree.root)[0];
1065+ let mut buf = open_editor(&v, a);
1066+ let i = buf.lines.iter().position(|l| l.text == " - c").unwrap();
1067+ buf.set_line(i, "- c".into());
1068+ buf.save_all(&mut v).unwrap();
1069+ assert_eq!(v.tree.files[0].text, format!("# A\n\n- one\n![[{}]]\n", id.as_str()));
1070+ assert!(v.tree.files[1].text.ends_with("---\n\n- c\n"), "{}", v.tree.files[1].text);
1071+
1072+ // deleting the title line still deletes the block when it leaves a
1073+ // child of the block first, whatever its indent (§5.2)
1074+ let (d, mut v) = vault_with("# A\n\n- b\n - x\n");
1075+ let b = v.find_by_path(&["A".into(), "b".into()]).unwrap();
1076+ ops::make_block(&mut v, b).unwrap();
1077+ let b_path = d.path().join(&v.tree.files[1].path);
1078+ let a = v.tree.resolved_children(v.tree.root)[0];
1079+ let mut buf = open_editor(&v, a);
1080+ let i = buf.lines.iter().position(|l| l.text == "- b").unwrap();
1081+ buf.delete_line(i);
1082+ buf.save_all(&mut v).unwrap();
1083+ assert_eq!(v.tree.files[0].text, "# A\n\n - x\n");
1084+ assert!(!b_path.exists());
1085+}
1086+
1087+#[test]
1088+fn review_indenting_a_block_title_line_under_a_sibling_block_keeps_it_in_the_tree() {
1089+ // §5.2: " - c" re-indented under "- b", itself a nested block, sits in
1090+ // b: c's embed goes into b's file, never indented under b's bare embed
1091+ // in A (§4.7: an embed line has no children), where the outline does
1092+ // not see it and deleting A leaves c's file embedded nowhere
1093+ let (d, mut v) = vault_with("# A\n\n- b\n- c\n");
1094+ let b = v.find_by_path(&["A".into(), "b".into()]).unwrap();
1095+ ops::make_block(&mut v, b).unwrap();
1096+ let c = v.find_by_path(&["A".into(), "c".into()]).unwrap();
1097+ let cid = ops::make_block(&mut v, c).unwrap();
1098+ let c_path = d.path().join(&v.tree.files[v.tree.block_by_id(&cid).unwrap().0].path);
1099+ let a = v.tree.resolved_children(v.tree.root)[0];
1100+ let mut buf = open_editor(&v, a);
1101+ let i = buf.lines.iter().position(|l| l.text == "- c").unwrap();
1102+ buf.set_line(i, " - c".into());
1103+ buf.save_all(&mut v).unwrap();
1104+ let root = v.tree.files[0].text.clone();
1105+ let mut titles = Vec::new();
1106+ v.tree.walk(v.tree.root, &mut |t, r| titles.push(t.node(r).title.clone()));
1107+ let a = v.tree.resolved_children(v.tree.root)[0];
1108+ ops::delete_subtree(&mut v, a).unwrap();
1109+ assert!(
1110+ titles.contains(&"c".to_string()) && !c_path.exists(),
1111+ "root.md after the save:\n{}walk: {:?}; after deleting A, c's file still in the vault: {}",
1112+ root,
1113+ titles,
1114+ c_path.exists()
1115+ );
1116+}
1117+
1118+#[test]
1119+fn review_a_line_split_off_above_a_re_indented_block_title_does_not_delete_the_block() {
1120+ // §5.2: only deleting its title line deletes a block; "note" split off
1121+ // above its re-indented title line is the enclosing block's text
1122+ let (d, mut v) = vault_with("# A\n\n- one\n- task\n body\n");
1123+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
1124+ let id = ops::make_block(&mut v, t).unwrap();
1125+ let block = d.path().join(&v.tree.files[1].path);
1126+ let a = v.tree.resolved_children(v.tree.root)[0];
1127+ let mut buf = open_editor(&v, a);
1128+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
1129+ buf.set_line(i, " - task".into());
1130+ buf.set_line(i + 1, " body".into());
1131+ // "note" typed at column 0, then Enter after it: the line splits, both
1132+ // parts keeping the line's tag
1133+ buf.set_line(i, "note - task".into());
1134+ buf.set_line(i, "note".into());
1135+ buf.insert_line(i, " - task".into());
1136+ buf.save_all(&mut v).unwrap();
1137+ assert!(
1138+ block.exists() && v.tree.files[0].text.contains(&format!("![[{}]]", id.as_str())),
1139+ "the block's file was trashed; root.md:\n{}",
1140+ v.tree.files[0].text
1141+ );
1142+}
1143+
1144+#[test]
1145+fn a_line_the_parser_would_nest_under_a_blocks_embed_is_not_saved() {
1146+ // §4.7: an embed line has no lines nested under it in the parent file,
1147+ // where the outline does not see them. The enclosing block's "- two"
1148+ // indented under b's title line, or left after it re-spelled as a
1149+ // heading, would be: the save is refused, the file left as it was
1150+ for (b_line, two) in [("- b", " - two"), ("## b", "- two")] {
1151+ let (_d, mut v) = vault_with("# A\n\n- one\n- b\n- two\n");
1152+ let b = v.find_by_path(&["A".into(), "b".into()]).unwrap();
1153+ ops::make_block(&mut v, b).unwrap();
1154+ let before = v.tree.files[0].text.clone();
1155+ let a = v.tree.resolved_children(v.tree.root)[0];
1156+ let mut buf = open_editor(&v, a);
1157+ let i = buf.lines.iter().position(|l| l.text == "- b").unwrap();
1158+ buf.set_line(i, b_line.into());
1159+ buf.set_line(i + 1, two.into());
1160+ let e = buf.save_all(&mut v).unwrap_err();
1161+ assert!(e.to_string().contains("nested under a block's embed"), "{}", e);
1162+ assert_eq!(v.tree.files[0].text, before);
1163+ // put back, it saves
1164+ buf.set_line(i, "- b".into());
1165+ buf.set_line(i + 1, "- two".into());
1166+ buf.save_all(&mut v).unwrap();
1167+ assert_eq!(v.tree.files[0].text, before);
1168+ assert!(v.tree.files[1].text.ends_with("---\n\n- b\n"), "{}", v.tree.files[1].text);
1169+ }
1170+}
1171+
1172+#[test]
1173+fn discarding_the_buffer_deletes_a_block_cut_saved_in_transit_and_let_go() {
1174+ // a block cut and saved in transit, then let go by the clipboard (it
1175+ // holds something else now) with no save since: Revert (§10.6) deletes
1176+ // it as the next save would have, never leaves its file embedded nowhere
1177+ let (d, mut v) = vault_with("# A\n\n- one\n- task\n- two\n");
1178+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
1179+ ops::make_block(&mut v, t).unwrap();
1180+ let block = d.path().join(&v.tree.files[1].path);
1181+ let a = v.tree.resolved_children(v.tree.root)[0];
1182+ let mut buf = open_editor(&v, a);
1183+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
1184+ let o = buf.lines[i].owner;
1185+ buf.delete_line(i);
1186+ buf.hold(vec![o]);
1187+ buf.save_all(&mut v).unwrap();
1188+ assert!(block.exists(), "in transit");
1189+ buf.hold(Vec::new());
1190+ v.reload().unwrap();
1191+ buf.discard(&mut v).unwrap();
1192+ assert!(!block.exists());
1193+ assert_eq!(v.tree.files.len(), 1);
1194+ assert_eq!(std::fs::read_to_string(d.path().join("root.md")).unwrap(), "# A\n\n- one\n- two\n");
1195+}
1196+
1197+#[test]
1198+fn a_block_title_line_indented_or_re_spelled_under_another_blocks_moves_into_it() {
1199+ // §5.2: the embed goes to the block whose title line holds the title
1200+ // line as it is written now, as the parser nests them (§3.1), blank
1201+ // lines between or not
1202+ let embed = |id: &fold_core::ident::Id| format!("![[{}]]", id.as_str());
1203+ let text = |v: &Vault, id| v.tree.files[v.tree.block_by_id(id).unwrap().0].text.clone();
1204+ // "- c" indented twice, under " - b", a block in the item "- one"
1205+ let (_d, mut v) = vault_with("# A\n\n- one\n - b\n- c\n");
1206+ let b = v.find_by_path(&["A".into(), "one".into(), "b".into()]).unwrap();
1207+ let b = ops::make_block(&mut v, b).unwrap();
1208+ let c = v.find_by_path(&["A".into(), "c".into()]).unwrap();
1209+ let c = ops::make_block(&mut v, c).unwrap();
1210+ let a = v.tree.resolved_children(v.tree.root)[0];
1211+ let mut buf = open_editor(&v, a);
1212+ let i = buf.lines.iter().position(|l| l.text == "- c").unwrap();
1213+ buf.set_line(i, " - c".into());
1214+ buf.save_all(&mut v).unwrap();
1215+ assert_eq!(v.tree.files[0].text, format!("# A\n\n- one\n {}\n", embed(&b)));
1216+ assert!(text(&v, &b).ends_with(&format!("\n- b\n {}\n", embed(&c))), "{}", text(&v, &b));
1217+ // "## c" re-spelled "### c" after a blank line, under the section "## b"
1218+ let (_d, mut v) = vault_with("# A\n\n## b\n\n## c\n");
1219+ let b = v.find_by_path(&["A".into(), "b".into()]).unwrap();
1220+ let b = ops::make_block(&mut v, b).unwrap();
1221+ let c = v.find_by_path(&["A".into(), "c".into()]).unwrap();
1222+ let c = ops::make_block(&mut v, c).unwrap();
1223+ let a = v.tree.resolved_children(v.tree.root)[0];
1224+ let mut buf = open_editor(&v, a);
1225+ let i = buf.lines.iter().position(|l| l.text == "## c").unwrap();
1226+ buf.set_line(i, "### c".into());
1227+ buf.save_all(&mut v).unwrap();
1228+ assert_eq!(v.tree.files[0].text, format!("# A\n\n## {}\n", embed(&b)));
1229+ assert!(text(&v, &b).ends_with(&format!("\n# b\n## {}\n", embed(&c))), "{}", text(&v, &b));
1230+ assert!(text(&v, &c).ends_with("\n# c\n"), "{}", text(&v, &c));
1231+}
crates/fold-core/tests/merge_check.rs+481
@@ -155,6 +155,32 @@ fn check_reports_bad_dates() {
155155 assert!(diags.iter().any(|d| d.message.contains("not an ISO date")), "{:?}", diags.iter().map(|d| &d.message).collect::<Vec<_>>());
156156}
157157
158+/// Each problem is reported once, by name where it has one (§15.7).
159+#[test]
160+fn check_reports_each_problem_once() {
161+ let dir = tempfile::tempdir().unwrap();
162+ std::fs::write(dir.path().join("root.md"), "# a/b\n\n-\n").unwrap();
163+ let v = Vault::open(dir.path()).unwrap();
164+ let diags: Vec<String> = fold_core::check::check(&v).iter().map(|d| d.message.clone()).collect();
165+ assert_eq!(diags, ["title contains '/': \"a/b\"", "title line with an empty title"]);
166+}
167+
168+/// A block file whose one top-level node is an embed has no root of its
169+/// own (§4.9): `--fix` leaves its text as it is, as it does text before
170+/// the root, rather than canonicalize lines it cannot place.
171+#[test]
172+fn fix_leaves_a_block_file_without_a_root_alone() {
173+ let dir = tempfile::tempdir().unwrap();
174+ std::fs::write(dir.path().join("root.md"), "- a\n![[racfer-hattes-mislup-nodrys]]\n").unwrap();
175+ let rootless = "---\nid: racfer-hattes-mislup-nodrys\n---\n\n![[dozzod-binwes-talsun-worbec]]\n * b\n";
176+ // named as its (empty) title slugs, so no rename is due
177+ std::fs::write(dir.path().join("racfer~untitled.md"), rootless).unwrap();
178+ let mut v = Vault::open(dir.path()).unwrap();
179+ assert!(v.tree.files[1].malformed_block());
180+ fold_core::check::fix(&mut v).unwrap();
181+ assert_eq!(std::fs::read_to_string(dir.path().join("racfer~untitled.md")).unwrap(), rootless);
182+}
183+
158184// ---------------------------------------------------------------- regressions
159185
160186const BID: &str = "racfer-hattes-mislup-nodrys";
@@ -311,3 +337,458 @@ fn retitled_block_root_conflicts_and_keep_theirs_keeps_the_filename() {
311337 assert!(text.contains(&format!("id: {}", BID)) && text.contains("- new"), "{}", text);
312338 assert!(merge::conflict_pairs(&v).is_empty());
313339}
340+
341+#[test]
342+fn deep_headings_are_canonical() {
343+ // §3.1 / §4.2: no upper bound on heading level; levels beyond six are ours
344+ // (§4.2 "What is ours"), not non-canonical syntax for `fold check` to report.
345+ let dir = tempfile::tempdir().unwrap();
346+ std::fs::write(
347+ dir.path().join("root.md"),
348+ "# 1\n\n## 2\n\n### 3\n\n#### 4\n\n##### 5\n\n###### 6\n\n####### 7\n",
349+ )
350+ .unwrap();
351+ let v = Vault::open(dir.path()).unwrap();
352+ let diags = fold_core::check::check(&v);
353+ assert!(
354+ diags.is_empty(),
355+ "{:?}",
356+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
357+ );
358+}
359+
360+#[test]
361+fn crlf_separated_text_is_not_flagged_or_padded() {
362+ // "text" already follows `a` after a blank line; only the line endings
363+ // are CRLF, which is read cleanly (§4.2) and must not count as missing
364+ // the separator, nor make --fix invent a second blank line (§3.3).
365+ let dir = tempfile::tempdir().unwrap();
366+ std::fs::write(dir.path().join("root.md"), "# A\r\n\r\n- a\r\n\r\ntext\r\n").unwrap();
367+ let mut v = Vault::open(dir.path()).unwrap();
368+ let diags = fold_core::check::check(&v);
369+ assert!(
370+ !diags.iter().any(|d| d.message.contains("text right after a child node")),
371+ "{:?}",
372+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
373+ );
374+ fold_core::check::fix(&mut v).unwrap();
375+ let text = std::fs::read_to_string(dir.path().join("root.md")).unwrap();
376+ assert_eq!(text, "# A\n\n- a\n\ntext\n");
377+}
378+
379+#[test]
380+fn merge_rewrite_keeps_embeds() {
381+ // §12.4: embeds match by id and every node from O and T survives; a
382+ // re-rendered merge (T adds a node) must not turn `![[id]]` into `- `
383+ let o = format!("# A\n\n![[{}]]\n\n# B\n", BID);
384+ let t = format!("# A\n\n![[{}]]\n\n# B\n\n- new\n", BID);
385+ let out = merge::merge_texts(&o, &t, "d", "ts");
386+ assert!(out.text.contains(&format!("![[{}]]", BID)), "{}", out.text);
387+ assert!(out.text.contains("- new"), "{}", out.text);
388+ // a T-only embed (block made on the other device) must survive too
389+ let o2 = "# A\n\n- x\n";
390+ let t2 = format!("# A\n\n- x\n![[{}]]\n", BID);
391+ let out2 = merge::merge_texts(o2, &t2, "d", "ts");
392+ assert!(out2.text.contains(&format!("![[{}]]", BID)), "{}", out2.text);
393+ // heading form
394+ let o3 = format!("# A\n\n## ![[{}]]\n\n# B\n", BID);
395+ let t3 = format!("# A\n\n## ![[{}]]\n\n# B\n\n- new\n", BID);
396+ let out3 = merge::merge_texts(&o3, &t3, "d", "ts");
397+ assert!(out3.text.contains(&format!("## ![[{}]]", BID)), "{}", out3.text);
398+ // idempotent (§12.4): merging T into the result again adds nothing
399+ for (out, t) in [(&out, &t), (&out2, &t2), (&out3, &t3)] {
400+ assert_eq!(merge::merge_texts(&out.text, t, "d", "ts").text, out.text);
401+ }
402+}
403+
404+#[test]
405+fn sync_conflict_merge_keeps_embeds_in_the_merged_file() {
406+ let dir = tempfile::tempdir().unwrap();
407+ std::fs::write(dir.path().join("root.md"), format!("# A\n\n![[{}]]\n\n# B\n", BID)).unwrap();
408+ std::fs::write(dir.path().join("racfer~t.md"), format!("---\nid: {}\n---\n\n- t\n", BID)).unwrap();
409+ std::fs::write(
410+ dir.path().join("root.sync-conflict-20260912-100000-phone.md"),
411+ format!("# A\n\n![[{}]]\n\n# B\n\n- new\n", BID),
412+ )
413+ .unwrap();
414+ let mut v = Vault::open(dir.path()).unwrap();
415+ merge::merge_sync_conflicts(&mut v, false).unwrap();
416+ let text = std::fs::read_to_string(dir.path().join("root.md")).unwrap();
417+ assert!(text.contains(&format!("![[{}]]", BID)), "{}", text);
418+ assert!(text.contains("- new"), "{}", text);
419+}
420+
421+#[test]
422+fn a_copy_from_before_a_block_was_moved_is_merged_without_its_embed() {
423+ // the block was cut from NAS in the editor, which wrote root.md
424+ // without its embed, and is not pasted yet: moved on this device. The
425+ // phone's copy, from before the cut, still embeds it in NAS; its own
426+ // change comes in, the embed does not, nor into NAS's conflict copy
427+ // where the phone changed NAS's text too
428+ let ours = "# Homelab\n\n## NAS\n\nMirrored pairs.\n\n### [x] Replace fan\n\n# Inbox\n\n- [ ] a\n";
429+ let before = ours.replace("### [x]", &format!("### ![[{}]]\n\n### [x]", BID));
430+ for nas in ["Mirrored pairs.", "Snapshots every 2h."] {
431+ let dir = tempfile::tempdir().unwrap();
432+ std::fs::write(dir.path().join("root.md"), ours).unwrap();
433+ let block = dir.path().join("racfer~snapshot-policy.md");
434+ let text = format!("---\nid: {}\n---\n\n# [ ] Snapshot policy\n\n- hourly\n", BID);
435+ std::fs::write(&block, &text).unwrap();
436+ let copy = "root.sync-conflict-20260927-101010-PHONE77.md";
437+ let theirs = before.replace("- [ ] a\n", "- [ ] a\n- [ ] from phone\n").replace("Mirrored pairs.", nas);
438+ std::fs::write(dir.path().join(copy), theirs).unwrap();
439+ let mut v = Vault::open(dir.path()).unwrap();
440+ let moving = [fold_core::ident::Id::parse(BID).unwrap()];
441+ merge::merge_sync_conflicts_moving(&mut v, false, &moving).unwrap();
442+ assert!(!dir.path().join(copy).exists(), "{}", nas);
443+ let merged = std::fs::read_to_string(dir.path().join("root.md")).unwrap();
444+ let embed = format!("![[{}]]", BID);
445+ assert!(!merged.contains(&embed), "{}: {}", nas, merged);
446+ assert_eq!(std::fs::read_to_string(&block).unwrap(), text, "{}", nas);
447+ if nas == "Mirrored pairs." {
448+ assert_eq!(merged, ours.replace("- [ ] a\n", "- [ ] a\n- [ ] from phone\n"));
449+ assert!(merge::conflict_pairs(&v).is_empty(), "{}", merged);
450+ continue;
451+ }
452+ assert!(merged.contains("- [ ] from phone\n"), "{}", merged);
453+ let pairs = merge::conflict_pairs(&v);
454+ assert_eq!(pairs.len(), 1, "{}", merged);
455+ let theirs = &v.tree.files[pairs[0].1 .0].text;
456+ assert!(theirs.contains("Snapshots every 2h.") && !theirs.contains(&embed), "{}", theirs);
457+ }
458+}
459+
460+#[test]
461+fn merge_leaves_ignored_files_alone() {
462+ // §4.1/§11.4: a `.md` file without an id is ignored — never parsed,
463+ // never written. A sync-conflict copy of it is not ours to merge.
464+ let dir = tempfile::tempdir().unwrap();
465+ std::fs::write(dir.path().join("root.md"), "# A\n").unwrap();
466+ std::fs::write(dir.path().join("readme.md"), "* [ ] first\n").unwrap();
467+ std::fs::write(
468+ dir.path().join("readme.sync-conflict-20260912-100000-phone.md"),
469+ "* [x] first\n",
470+ )
471+ .unwrap();
472+ let mut v = Vault::open(dir.path()).unwrap();
473+ let outcomes = merge::merge_sync_conflicts(&mut v, false).unwrap();
474+ assert!(outcomes.iter().any(|o| o.contains("left alone")), "{:?}", outcomes);
475+ assert_eq!(
476+ std::fs::read_to_string(dir.path().join("readme.md")).unwrap(),
477+ "* [ ] first\n"
478+ );
479+ let mut names: Vec<String> = std::fs::read_dir(dir.path())
480+ .unwrap()
481+ .filter_map(|e| e.ok())
482+ .map(|e| e.file_name().to_string_lossy().to_string())
483+ .collect();
484+ names.sort();
485+ assert_eq!(
486+ names,
487+ vec![
488+ "readme.md".to_string(),
489+ "readme.sync-conflict-20260912-100000-phone.md".to_string(),
490+ "root.md".to_string(),
491+ ]
492+ );
493+ let diags = fold_core::check::check(&v);
494+ assert!(
495+ diags.iter().all(|d| !d.message.contains("unresolved conflict")),
496+ "{:?}",
497+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
498+ );
499+}
500+
501+#[test]
502+fn merge_with_missing_base_keeps_theirs_frontmatter() {
503+ // X.md is gone (deleted, or its deletion synced in first): with no O to
504+ // merge against, T must survive with its frontmatter (id, due) intact
505+ let dir = tempfile::tempdir().unwrap();
506+ std::fs::write(dir.path().join("root.md"), format!("# A\n\n![[{}]]\n", BID)).unwrap();
507+ let theirs = format!("---\nid: {}\ndue: 2026-09-20\n---\n\n- t\n", BID);
508+ let c = "racfer~t.sync-conflict-20260912-100000-phone.md";
509+ std::fs::write(dir.path().join(c), &theirs).unwrap();
510+ let mut v = Vault::open(dir.path()).unwrap();
511+ merge::merge_sync_conflicts(&mut v, false).unwrap();
512+ let text = std::fs::read_to_string(dir.path().join("racfer~t.md")).unwrap();
513+ assert_eq!(text, theirs);
514+ assert!(!dir.path().join(c).exists());
515+ assert!(v.tree.block_by_id(&fold_core::Id::parse(BID).unwrap()).is_some());
516+ // a block renamed since (§6.4) is still O: found by its id, merged
517+ // into, never duplicated
518+ let dir = tempfile::tempdir().unwrap();
519+ std::fs::write(dir.path().join("root.md"), format!("# A\n\n![[{}]]\n", BID)).unwrap();
520+ std::fs::write(dir.path().join("racfer~new.md"), format!("---\nid: {}\n---\n\n- [ ] t\n", BID)).unwrap();
521+ std::fs::write(dir.path().join(c), format!("---\nid: {}\n---\n\n- [x] t\n", BID)).unwrap();
522+ let mut v = Vault::open(dir.path()).unwrap();
523+ merge::merge_sync_conflicts(&mut v, false).unwrap();
524+ assert!(!dir.path().join("racfer~t.md").exists());
525+ assert_eq!(merge::conflict_pairs(&v).len(), 1, "{}", v.tree.files[0].text);
526+ // the copy of a foreign file that is gone is not ours either (§4.1)
527+ let dir = tempfile::tempdir().unwrap();
528+ std::fs::write(dir.path().join("root.md"), "# A\n").unwrap();
529+ let c = "readme.sync-conflict-20260912-100000-phone.md";
530+ std::fs::write(dir.path().join(c), "* [x] first\n").unwrap();
531+ let mut v = Vault::open(dir.path()).unwrap();
532+ let outcomes = merge::merge_sync_conflicts(&mut v, false).unwrap();
533+ assert!(outcomes.iter().any(|o| o.contains("left alone")), "{:?}", outcomes);
534+ assert!(dir.path().join(c).exists() && !dir.path().join("readme.md").exists());
535+}
536+
537+#[test]
538+fn merge_takes_a_block_that_came_in_with_its_copy_for_one() {
539+ // the vault as read before a sync brought a new block, its embed and
540+ // a copy of it in one go: X.md is a block on disk, not an ignored
541+ // file, and its copy is merged into it (§12.2)
542+ let dir = tempfile::tempdir().unwrap();
543+ std::fs::write(dir.path().join("root.md"), "# A\n").unwrap();
544+ let mut v = Vault::open(dir.path()).unwrap();
545+ std::fs::write(dir.path().join("root.md"), format!("# A\n\n![[{}]]\n", BID)).unwrap();
546+ std::fs::write(dir.path().join("racfer~t.md"), format!("---\nid: {}\n---\n\n- [ ] t\n", BID)).unwrap();
547+ let c = "racfer~t.sync-conflict-20260912-100000-phone.md";
548+ std::fs::write(dir.path().join(c), format!("---\nid: {}\n---\n\n- [x] t\n", BID)).unwrap();
549+ let outcomes = merge::merge_sync_conflicts(&mut v, false).unwrap();
550+ assert!(!dir.path().join(c).exists(), "{:?}", outcomes);
551+ assert_eq!(merge::conflict_pairs(&v).len(), 1, "{}", v.tree.files[0].text);
552+}
553+
554+#[test]
555+fn merge_places_insertions_by_their_neighbours() {
556+ // §12.4: a node present on one side only is an insertion, placed
557+ // relative to its matched neighbours — `new` sits between `a` and `b`
558+ let o = "# A\n\n- a\n- b\n- c\n";
559+ let t = "# A\n\n- a\n- new\n- b\n- c\n";
560+ let out = merge::merge_texts(o, t, "dev", "ts");
561+ assert_eq!(out.conflicts, 0, "{}", out.text);
562+ let p = |s: &str| out.text.find(s).unwrap();
563+ assert!(p("- a") < p("- new") && p("- new") < p("- b"), "{}", out.text);
564+ // one inserted after the text that follows its neighbour in T stays
565+ // after that text, so merging T in again changes nothing
566+ let o = "# P\n\n- a\n\nnote\n";
567+ let t = "# P\n\n- a\n\nnote\n\n- new\n";
568+ let out = merge::merge_texts(o, t, "dev", "ts");
569+ assert_eq!(out.conflicts, 0, "{}", out.text);
570+ assert_eq!(out.text, t);
571+}
572+
573+#[test]
574+fn merge_places_inserted_sections_by_their_neighbours() {
575+ // the same for a section: `B` sits between `A` and `C`, not last
576+ let o = "# A\n\n# C\n";
577+ let t = "# A\n\n# B\n\n# C\n";
578+ let out = merge::merge_texts(o, t, "dev", "ts");
579+ assert_eq!(out.conflicts, 0, "{}", out.text);
580+ let p = |s: &str| out.text.find(s).unwrap();
581+ assert!(p("# A") < p("# B") && p("# B") < p("# C"), "{}", out.text);
582+}
583+
584+#[test]
585+fn check_reports_embed_cycles() {
586+ // two blocks that embed each other: neither is reachable from root.md,
587+ // and §6.2 / §15.7 say a cycle is a diagnostic
588+ let a = "racfer-hattes-mislup-nodrys";
589+ let b = "dozzod-binwes-talsun-worbec";
590+ let c = "lacnum-walbyn-dirlyn-havtyp";
591+ let dir = tempfile::tempdir().unwrap();
592+ std::fs::write(dir.path().join("root.md"), "# A\n").unwrap();
593+ std::fs::write(
594+ dir.path().join("racfer~x.md"),
595+ format!("---\nid: {}\n---\n\n- x\n ![[{}]]\n", a, b),
596+ )
597+ .unwrap();
598+ std::fs::write(
599+ dir.path().join("dozzod~y.md"),
600+ format!("---\nid: {}\n---\n\n- y\n ![[{}]]\n ![[{}]]\n", b, a, c),
601+ )
602+ .unwrap();
603+ // c hangs off the cycle without being on it
604+ std::fs::write(dir.path().join("lacnum~z.md"), format!("---\nid: {}\n---\n\n- z\n", c)).unwrap();
605+ let v = Vault::open(dir.path()).unwrap();
606+ // every block loads and every embed resolves: the only fault is the cycle
607+ for id in [a, b, c] {
608+ let id = fold_core::Id::parse(id).unwrap();
609+ assert!(v.tree.block_by_id(&id).is_some(), "block {} not loaded", id);
610+ assert!(v.tree.embed_of(&id).is_some(), "embed of {} not parsed", id);
611+ }
612+ let diags = fold_core::check::check(&v);
613+ let msgs: Vec<&String> = diags.iter().map(|d| &d.message).collect();
614+ assert_eq!(
615+ diags.iter().filter(|d| d.message.contains("cycl")).count(),
616+ 2,
617+ "one cyclic-embed diagnostic per block on the cycle: {:?}",
618+ msgs
619+ );
620+ // the same blocks in a chain from root.md: no cycle
621+ std::fs::write(dir.path().join("root.md"), format!("# A\n\n![[{}]]\n", a)).unwrap();
622+ std::fs::write(
623+ dir.path().join("dozzod~y.md"),
624+ format!("---\nid: {}\n---\n\n- y\n ![[{}]]\n", b, c),
625+ )
626+ .unwrap();
627+ let v = Vault::open(dir.path()).unwrap();
628+ let diags = fold_core::check::check(&v);
629+ assert!(diags.is_empty(), "{:?}", diags.iter().map(|d| &d.message).collect::<Vec<_>>());
630+}
631+
632+#[test]
633+fn orphan_block_conflict_is_not_paired_with_an_unrelated_node() {
634+ // the block racfer~t.md is embedded nowhere: its conflict block has no
635+ // embed to follow, so it must not end up as the next sibling of some
636+ // unrelated node that 'keep theirs' would then overwrite (§12.4, §12.5)
637+ let dir = tempfile::tempdir().unwrap();
638+ std::fs::write(dir.path().join("root.md"), "# A\n\n- keep me\n").unwrap();
639+ std::fs::write(
640+ dir.path().join("racfer~t.md"),
641+ format!("---\nid: {}\n---\n\n- [ ] t\n", BID),
642+ )
643+ .unwrap();
644+ std::fs::write(
645+ dir.path().join("racfer~t.sync-conflict-20260912-100000-phone.md"),
646+ format!("---\nid: {}\n---\n\n- [x] t\n", BID),
647+ )
648+ .unwrap();
649+ let mut v = Vault::open(dir.path()).unwrap();
650+ merge::merge_sync_conflicts(&mut v, false).unwrap();
651+ let root = std::fs::read_to_string(dir.path().join("root.md")).unwrap();
652+ let pairs = merge::conflict_pairs(&v);
653+ for &(ours, _) in &pairs {
654+ assert_ne!(v.tree.node(ours).title, "keep me", "root.md:\n{}", root);
655+ }
656+ // it pairs with the block it conflicts with, and keep theirs lands there
657+ assert_eq!(pairs.len(), 1, "root.md:\n{}", root);
658+ let bid = fold_core::Id::parse(BID).unwrap();
659+ assert_eq!(Some(pairs[0].0), v.tree.block_by_id(&bid), "root.md:\n{}", root);
660+ merge::resolve_keep_theirs(&mut v, pairs[0].0, pairs[0].1).unwrap();
661+ assert!(std::fs::read_to_string(dir.path().join("root.md")).unwrap().contains("- keep me"));
662+ let block = std::fs::read_to_string(dir.path().join("racfer~t.md")).unwrap();
663+ assert!(block.contains("- [x] t"), "{}", block);
664+ assert!(fold_core::check::check(&v).is_empty());
665+ // a section-spelled orphan: heading embeds, still a pair (§4.7)
666+ let dir = tempfile::tempdir().unwrap();
667+ std::fs::write(dir.path().join("root.md"), "# A\n\n- keep me\n").unwrap();
668+ std::fs::write(dir.path().join("racfer~t.md"), format!("---\nid: {}\n---\n\n# T\n\nbody o\n", BID))
669+ .unwrap();
670+ std::fs::write(
671+ dir.path().join("racfer~t.sync-conflict-20260912-100000-phone.md"),
672+ format!("---\nid: {}\n---\n\n# T\n\nbody t\n", BID),
673+ )
674+ .unwrap();
675+ let mut v = Vault::open(dir.path()).unwrap();
676+ merge::merge_sync_conflicts(&mut v, false).unwrap();
677+ let root = std::fs::read_to_string(dir.path().join("root.md")).unwrap();
678+ assert!(root.starts_with(&format!("# A\n\n- keep me\n\n# ![[{}]]\n\n# ![[", BID)), "{}", root);
679+ let pairs = merge::conflict_pairs(&v);
680+ assert_eq!(pairs.len(), 1, "root.md:\n{}", root);
681+ assert_eq!(Some(pairs[0].0), v.tree.block_by_id(&bid), "root.md:\n{}", root);
682+ let diags = fold_core::check::check(&v);
683+ assert!(
684+ diags.iter().all(|d| d.message == "unresolved conflict block"),
685+ "{:?}",
686+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
687+ );
688+}
689+
690+#[test]
691+fn crlf_root_md_keeps_its_blank_lines_under_fix() {
692+ // check --fix lays root.md's top-level nodes out itself; a blank line
693+ // is blank whatever its line ending, so a loose list stays loose
694+ // (§4.2) and none is added after text that already ends in one (§3.3)
695+ for (before, after) in [
696+ ("- a\r\n\r\n- b\r\n", "- a\n\n- b\n"),
697+ ("intro\r\n\r\n- a\r\n", "intro\r\n\r\n- a\n"),
698+ ] {
699+ let dir = tempfile::tempdir().unwrap();
700+ std::fs::write(dir.path().join("root.md"), before).unwrap();
701+ let mut v = Vault::open(dir.path()).unwrap();
702+ fold_core::check::fix(&mut v).unwrap();
703+ let text = std::fs::read_to_string(dir.path().join("root.md")).unwrap();
704+ assert_eq!(text, after, "{:?}", before);
705+ }
706+}
707+
708+#[test]
709+fn sync_conflict_on_a_block_embedded_twice_pairs_where_it_is_shown() {
710+ // t is shown under X, where the walk from the root meets it first; its
711+ // second embed, in root.md under B, comes first in file order and reads
712+ // as broken (§6.2). The conflict block goes after t's own embed, so it
713+ // pairs with t (§12.4), not with the broken line
714+ let x = "dozzod-binwes-talsun-worbec";
715+ let dir = tempfile::tempdir().unwrap();
716+ let root = format!("# A\n\n![[{}]]\n\n# B\n\n![[{}]]\n", x, BID);
717+ std::fs::write(dir.path().join("root.md"), &root).unwrap();
718+ std::fs::write(
719+ dir.path().join("dozzod~x.md"),
720+ format!("---\nid: {}\n---\n\n- X\n ![[{}]]\n", x, BID),
721+ )
722+ .unwrap();
723+ std::fs::write(
724+ dir.path().join("racfer~t.md"),
725+ format!("---\nid: {}\n---\n\n- [ ] t\n", BID),
726+ )
727+ .unwrap();
728+ std::fs::write(
729+ dir.path().join("racfer~t.sync-conflict-20260912-100000-phone.md"),
730+ format!("---\nid: {}\n---\n\n- [x] t\n", BID),
731+ )
732+ .unwrap();
733+ let mut v = Vault::open(dir.path()).unwrap();
734+ merge::merge_sync_conflicts(&mut v, false).unwrap();
735+ let pairs = merge::conflict_pairs(&v);
736+ assert_eq!(pairs.len(), 1);
737+ let ours = v.tree.node(pairs[0].0).block.as_ref().and_then(|b| b.id.as_ref());
738+ assert_eq!(ours.map(|i| i.to_string()), Some(BID.to_string()));
739+ assert_eq!(std::fs::read_to_string(dir.path().join("root.md")).unwrap(), root);
740+}
741+
742+/// A vault merged with a phone's copy of its root.md: B's text and the
743+/// third task differ, so B and three each get a conflict copy (§12.4).
744+fn merged_with_copies() -> (tempfile::TempDir, Vault) {
745+ let dir = tempfile::tempdir().unwrap();
746+ std::fs::write(
747+ dir.path().join("root.md"),
748+ "# A\n\n## B\n\nours\n\n- [ ] one\n- [x] two\n\n## C\n\n- [ ] three\n",
749+ )
750+ .unwrap();
751+ std::fs::write(
752+ dir.path().join("root.sync-conflict-20260912-100000-phone.md"),
753+ "# A\n\n## B\n\ntheirs\n\n- [ ] one\n- [x] two\n\n## C\n\n- [x] three\n",
754+ )
755+ .unwrap();
756+ let mut v = Vault::open(dir.path()).unwrap();
757+ merge::merge_sync_conflicts(&mut v, false).unwrap();
758+ assert_eq!(merge::conflict_pairs(&v).len(), 2);
759+ (dir, v)
760+}
761+
762+#[test]
763+fn a_conflict_copy_s_tasks_are_left_out_of_the_counts_above_it() {
764+ // §3.5: a copy repeats the node before it, so its tasks would count
765+ // twice, the one the phone checked as open and done at once
766+ let (_dir, v) = merged_with_copies();
767+ let a = v.find_by_path(&["A".into()]).unwrap();
768+ assert_eq!(v.tree.task_counts(a), (2, 3));
769+ let c = v.find_by_path(&["A".into(), "C".into()]).unwrap();
770+ assert_eq!(v.tree.task_counts(c), (1, 1));
771+ // a copy counts its own
772+ let copy = merge::conflict_pairs(&v).into_iter().map(|(_, t)| t).find(|&t| v.tree.node(t).title == "B").unwrap();
773+ assert_eq!(v.tree.task_counts(copy), (1, 2));
774+}
775+
776+#[test]
777+fn clear_done_keeps_a_done_task_with_an_open_one_in_a_conflict_copy_below() {
778+ // counts leave copies out, clearing does not: the phone reopened e, and
779+ // its copy is not trashed with d (§8.5, §12.5)
780+ let dir = tempfile::tempdir().unwrap();
781+ std::fs::write(dir.path().join("root.md"), "# A\n\n- [x] d\n - [x] e\n").unwrap();
782+ std::fs::write(
783+ dir.path().join("root.sync-conflict-20260912-100000-phone.md"),
784+ "# A\n\n- [x] d\n - [ ] e\n",
785+ )
786+ .unwrap();
787+ let mut v = Vault::open(dir.path()).unwrap();
788+ merge::merge_sync_conflicts(&mut v, false).unwrap();
789+ let a = v.find_by_path(&["A".into()]).unwrap();
790+ fold_core::ops::clear_done(&mut v, a).unwrap();
791+ let text = std::fs::read_to_string(dir.path().join("root.md")).unwrap();
792+ assert!(text.contains("- [x] d\n"), "{}", text);
793+ assert!(text.contains("![["), "{}", text);
794+}
crates/fold-core/tests/ops.rs+710
@@ -246,6 +246,36 @@ fn rename_block_title_keeps_the_filename_until_fix() {
246246 assert!(v.tree.files[1].path.ends_with("~renamed-title.md"), "{}", v.tree.files[1].path);
247247}
248248
249+#[test]
250+fn the_vault_knows_when_the_files_differ_from_what_it_parsed() {
251+ // §11.2: the watcher reloads only when a reload would find something new
252+ let (d, mut v) = vault_with("# A\n\n- task\n");
253+ assert!(!v.changed_on_disk().unwrap());
254+ // its own writes are what it holds; a file written back as it was, a
255+ // file without an id and a sync-conflict copy are nothing to parse
256+ let task = v.find_by_path(&["A".into(), "task".into()]).unwrap();
257+ let id = ops::make_block(&mut v, task).unwrap();
258+ std::fs::write(d.path().join("root.md"), &v.tree.files[0].text).unwrap();
259+ std::fs::write(d.path().join("notes.md"), "no id\n").unwrap();
260+ std::fs::write(d.path().join("root.sync-conflict-20260912-100000-phone.md"), "# A\n").unwrap();
261+ assert!(!v.changed_on_disk().unwrap());
262+ // a changed text, a new block file, a removed one
263+ let block = d.path().join(&v.tree.files[1].path);
264+ let text = std::fs::read_to_string(&block).unwrap();
265+ std::fs::write(&block, text.replace("- task", "- task, from Helix")).unwrap();
266+ assert!(v.changed_on_disk().unwrap());
267+ v.reload().unwrap();
268+ assert!(!v.changed_on_disk().unwrap());
269+ std::fs::write(d.path().join("dozzod~b.md"), "---\nid: dozzod-binwes-talsun-worbec\n---\n\n- b\n").unwrap();
270+ assert!(v.changed_on_disk().unwrap());
271+ v.reload().unwrap();
272+ std::fs::remove_file(&block).unwrap();
273+ assert!(v.changed_on_disk().unwrap());
274+ v.reload().unwrap();
275+ assert!(v.tree.block_by_id(&id).is_none());
276+ assert!(!v.changed_on_disk().unwrap());
277+}
278+
249279#[test]
250280fn prefix_collision_grows_prefix() {
251281 let (d, _v) = vault_with("# A\n");
@@ -567,3 +597,683 @@ fn check_fix_keeps_correct_prefixes_and_is_idempotent() {
567597 assert!(d.path().join("racfer~blk.md").exists());
568598 assert_eq!(fold_core::check::fix(&mut v).unwrap(), 0);
569599}
600+
601+#[test]
602+fn setext_sibling_spans_do_not_overlap() {
603+ // the setext title line is taken back out of A's text: A's span must
604+ // shrink with it, or A and Title overlap
605+ let (_d, mut v) = vault_with("# A\n\nTitle\n===\n\nbody\n");
606+ let kids = v.tree.resolved_children(v.tree.root);
607+ assert_eq!(kids.len(), 2);
608+ let (a, b) = (v.tree.node(kids[0]).span, v.tree.node(kids[1]).span);
609+ assert!(a.end <= b.start, "A {:?} overlaps Title {:?}", a, b);
610+ ops::move_sibling(&mut v, kids[0], true).unwrap();
611+ let kids = v.tree.resolved_children(v.tree.root);
612+ assert_eq!(v.tree.node(kids[0]).title, "Title");
613+ assert_eq!(v.tree.node(kids[1]).title, "A");
614+}
615+
616+#[test]
617+fn move_sibling_before_a_setext_sibling_does_not_panic() {
618+ let (_d, mut v) = vault_with("# A\n\nTitle\n===\n\nbody\n");
619+ let a = v.tree.resolved_children(v.tree.root)[0];
620+ ops::move_sibling(&mut v, a, true).unwrap();
621+ let kids = v.tree.resolved_children(v.tree.root);
622+ assert_eq!(v.tree.node(kids[0]).title, "Title", "{}", v.tree.files[0].text);
623+ assert_eq!(v.tree.node(kids[1]).title, "A", "{}", v.tree.files[0].text);
624+}
625+
626+#[test]
627+fn delete_before_a_setext_sibling_keeps_its_title() {
628+ let (_d, mut v) = vault_with("# A\n\nTitle\n===\n\nbody\n");
629+ let a = v.tree.resolved_children(v.tree.root)[0];
630+ ops::delete_subtree(&mut v, a).unwrap();
631+ let text = v.tree.files[0].text.clone();
632+ assert!(text.contains("Title"), "{}", text);
633+ let top = v.tree.resolved_children(v.tree.root);
634+ assert_eq!(top.len(), 1, "{}", text);
635+ assert_eq!(v.tree.node(top[0]).title, "Title", "{}", text);
636+}
637+
638+#[test]
639+fn capture_before_a_setext_sibling_keeps_it() {
640+ let (_d, mut v) = vault_with("# Inbox\n\nMeeting notes\n=============\n\n- action\n");
641+ let inbox = v.tree.resolved_children(v.tree.root)[0];
642+ ops::capture_to(&mut v, "new", false, inbox).unwrap();
643+ let text = v.tree.files[0].text.clone();
644+ let top = v.tree.resolved_children(v.tree.root);
645+ assert_eq!(top.len(), 2, "{}", text);
646+ assert_eq!(v.tree.node(top[1]).title, "Meeting notes", "{}", text);
647+ let kids = v.tree.resolved_children(top[1]);
648+ assert_eq!(kids.len(), 1, "{}", text);
649+ assert_eq!(v.tree.node(kids[0]).title, "action", "{}", text);
650+}
651+
652+#[test]
653+fn archiving_keeps_hashtag_body_lines() {
654+ // `#done` has no space after the hash: it is body text, not a heading
655+ // (§4.2), so re-levelling the moved node must leave it as written
656+ let (_d, mut v) = vault_with("# Old project\n\n#done wrap-up notes\n");
657+ let r = at(&v, &["Old project"]);
658+ ops::archive(&mut v, r).unwrap();
659+ let t = &v.tree.files[0].text;
660+ assert!(t.contains("## Old project\n"), "{}", t);
661+ assert!(t.contains("\n#done wrap-up notes\n"), "{}", t);
662+ assert!(!t.contains("##done"), "{}", t);
663+}
664+
665+#[test]
666+fn respelling_a_setext_heading_keeps_its_title() {
667+ // §4.2: setext headings are read, and converted on write
668+ let (_d, mut v) = vault_with("Title\n=====\n\nbody\n");
669+ let r = at(&v, &["Title"]);
670+ ops::toggle_spelling(&mut v, r).unwrap();
671+ let text = &v.tree.files[0].text;
672+ assert_eq!(text, "- Title\n\n body\n");
673+ assert!(v.find_by_path(&["Title".into()]).is_some(), "{}", text);
674+}
675+
676+#[test]
677+fn toggle_taskness_on_a_setext_heading_makes_it_a_task() {
678+ // §4.2, §10.3 `t`: the checkbox goes between the marker and the title
679+ let (_d, mut v) = vault_with("Title\n=====\n\nbody\n");
680+ let r = at(&v, &["Title"]);
681+ ops::toggle_taskness(&mut v, r).unwrap();
682+ let text = v.tree.files[0].text.clone();
683+ assert_eq!(text, "# [ ] Title\n\nbody\n");
684+ let r = v.find_by_path(&["Title".into()]);
685+ assert!(r.is_some(), "title changed: {}", text);
686+ assert_eq!(v.tree.node(r.unwrap()).task, Some(TaskState::Open), "{}", text);
687+ // and back: the checkbox goes, the ATX heading stays
688+ ops::toggle_taskness(&mut v, r.unwrap()).unwrap();
689+ assert_eq!(v.tree.files[0].text, "# Title\n\nbody\n");
690+}
691+
692+#[test]
693+fn demote_keeps_a_fenced_code_body_with_its_node() {
694+ // b's body is a fenced code block; demoting b under a must re-indent the
695+ // fence with b, or the code falls out of b's region and becomes a's text
696+ let (d, mut v) = vault_with("- a\n- b\n ```\n code\n ```\n");
697+ let b = at(&v, &["b"]);
698+ ops::demote(&mut v, b).unwrap();
699+ assert_eq!(read(&d, "root.md"), "- a\n - b\n ```\n code\n ```\n");
700+ let b = at(&v, &["a", "b"]);
701+ let text = v.tree.files[0].text.clone();
702+ assert!(
703+ v.tree.node(b).text_lines(&text).iter().any(|l| l.contains("code")),
704+ "code block is no longer b's body:\n{}",
705+ text
706+ );
707+}
708+
709+#[test]
710+fn respelling_keeps_code_in_a_fence_as_written() {
711+ // a fence moves with its node; the code inside keeps its own indent, a
712+ // tab included, and a `#` line in it is code, not a heading (§3.3)
713+ let src = "# P\n\n## S\n\n```\n\tx\n # not a heading\n```\n";
714+ let (_d, mut v) = vault_with(src);
715+ let s = at(&v, &["P", "S"]);
716+ ops::toggle_spelling(&mut v, s).unwrap();
717+ let text = v.tree.files[0].text.clone();
718+ assert_eq!(text, "# P\n\n- S\n\n ```\n \tx\n # not a heading\n ```\n");
719+ let s = at(&v, &["P", "S"]);
720+ ops::toggle_spelling(&mut v, s).unwrap();
721+ assert_eq!(v.tree.files[0].text, src);
722+}
723+
724+// Nesting written with 4 spaces or a tab is accepted on read (§4.2); a child
725+// written under such an item must still nest under it, not beside it.
726+#[test]
727+fn refile_under_a_four_space_nested_item_nests_under_it() {
728+ let (_d, mut v) = vault_with("- a\n - b\n- c\n");
729+ let (c, b) = (at(&v, &["c"]), at(&v, &["a", "b"]));
730+ ops::refile(&mut v, c, b).unwrap();
731+ assert!(
732+ v.find_by_path(&["a".into(), "b".into(), "c".into()]).is_some(),
733+ "{}",
734+ v.tree.files[0].text
735+ );
736+}
737+
738+#[test]
739+fn demote_among_four_space_siblings_nests_the_node() {
740+ let (_d, mut v) = vault_with("- a\n - b\n - c\n");
741+ let c = at(&v, &["a", "c"]);
742+ ops::demote(&mut v, c).unwrap();
743+ assert!(
744+ v.find_by_path(&["a".into(), "b".into(), "c".into()]).is_some(),
745+ "{}",
746+ v.tree.files[0].text
747+ );
748+}
749+
750+#[test]
751+fn new_child_of_a_tab_nested_item() {
752+ let (_d, mut v) = vault_with("- a\n\t- b\n");
753+ let b = at(&v, &["a", "b"]);
754+ let r = ops::append_child_public(&mut v, b, "x");
755+ let text = v.tree.files[0].text.clone();
756+ let r = r.unwrap_or_else(|e| panic!("{e}\n{text:?}"));
757+ assert_eq!(v.tree.node(r).title, "x");
758+ assert!(v.find_by_path(&["a".into(), "b".into(), "x".into()]).is_some(), "{:?}", text);
759+}
760+
761+#[test]
762+fn new_section_child_beside_a_deeper_written_section() {
763+ // N on b, whose section child is written further in than b's derived
764+ // child indent: the new section must still land under b
765+ let (_d, mut v) = vault_with("- a\n - b\n ## S\n");
766+ let b = at(&v, &["a", "b"]);
767+ ops::append_child_public(&mut v, b, "x").unwrap();
768+ let text = v.tree.files[0].text.clone();
769+ assert!(v.find_by_path(&["a".into(), "b".into(), "x".into()]).is_some(), "{:?}", text);
770+ assert!(v.find_by_path(&["a".into(), "b".into(), "S".into()]).is_some(), "{:?}", text);
771+}
772+
773+#[test]
774+fn capture_to_a_four_space_nested_item_nests_under_it() {
775+ let (_d, mut v) = vault_with("- a\n - b\n");
776+ let b = at(&v, &["a", "b"]);
777+ let r = ops::capture_to(&mut v, "x\nmore", false, b).unwrap();
778+ let text = v.tree.files[0].text.clone();
779+ assert!(v.find_by_path(&["a".into(), "b".into(), "x".into()]).is_some(), "{:?}", text);
780+ let body = v.tree.node(r).text_lines(&text).join("\n");
781+ assert!(body.contains("more"), "{:?}", text);
782+}
783+
784+#[test]
785+fn deleting_a_section_block_keeps_lines_nested_under_its_embed() {
786+ // text appended under a heading embed by another editor parses as the
787+ // embed's content; the reading pane shows it in the parent, so deleting
788+ // the block must not take it along (§4.7, §11.5)
789+ let (d, mut v) = vault_files(
790+ &format!("# A\n\n## ![[{}]]\n\nnote under the embed\n", ID_A),
791+ &[("racfer~s.md", &format!("---\nid: {}\n---\n\n# S\n\nbody\n", ID_A))],
792+ );
793+ let s = at(&v, &["A", "S"]);
794+ ops::delete_subtree(&mut v, s).unwrap();
795+ assert!(!d.path().join("racfer~s.md").exists());
796+ assert_eq!(read(&d, "root.md"), "# A\n\nnote under the embed\n");
797+}
798+
799+#[test]
800+fn clearing_a_done_section_block_keeps_lines_nested_under_its_embed() {
801+ let (d, mut v) = vault_files(
802+ &format!("# A\n\n## ![[{}]]\n\nnote under the embed\n\n## B\n", ID_A),
803+ &[("racfer~s.md", &format!("---\nid: {}\n---\n\n# [x] S\n\nbody\n", ID_A))],
804+ );
805+ let a = at(&v, &["A"]);
806+ assert_eq!(ops::clear_done(&mut v, a).unwrap(), 1);
807+ assert!(!d.path().join("racfer~s.md").exists());
808+ assert_eq!(read(&d, "root.md"), "# A\n\nnote under the embed\n\n## B\n");
809+}
810+
811+#[test]
812+fn deleting_a_broken_embed_keeps_lines_nested_under_it() {
813+ let (d, mut v) = vault_with(&format!("# A\n\n## ![[{}]]\n\nnote under the embed\n", ID_A));
814+ let a = at(&v, &["A"]);
815+ let e = v.tree.resolved_children(a)[0];
816+ assert!(v.tree.node(e).is_embed());
817+ ops::delete_subtree(&mut v, e).unwrap();
818+ assert_eq!(read(&d, "root.md"), "# A\n\nnote under the embed\n");
819+}
820+
821+#[test]
822+fn capture_with_an_item_spelled_inbox_does_not_leave_a_stray_day_heading() {
823+ // `- Inbox` (e.g. after `~` on the Inbox section) is still the inbox
824+ // (§3.1: spelling is presentation): today's day goes under it, never a
825+ // top-level day heading written before failing
826+ let (_d, mut v) = vault_with("- Inbox\n");
827+ let r = ops::capture(&mut v, "x", false);
828+ let text = v.tree.files[0].text.clone();
829+ let r = r.unwrap_or_else(|e| panic!("capture failed: {}\n{}", e, text));
830+ assert_eq!(v.tree.node(r).title, "x");
831+ let path = v.tree.path(r);
832+ assert_eq!(path.len(), 3, "{:?}\n{}", path, text);
833+ assert_eq!(path[0], "Inbox");
834+ // a second capture reuses the same day and adds nothing at the top
835+ ops::capture(&mut v, "y", false).unwrap();
836+ let top = v.tree.resolved_children(v.tree.root);
837+ assert_eq!(top.len(), 1, "{}", v.tree.files[0].text);
838+ let days = v.tree.resolved_children(top[0]);
839+ assert_eq!(days.len(), 1, "{}", v.tree.files[0].text);
840+ assert_eq!(v.tree.resolved_children(days[0]).len(), 2, "{}", v.tree.files[0].text);
841+}
842+
843+#[test]
844+fn path_target_with_duplicate_titles_is_ambiguous_not_guessed() {
845+ // §3.4: a title that matches twice is an ambiguity error, never a guess.
846+ let (_d, v) = vault_with("# Homelab\n\n## Notes\n\n- a\n\n## Notes\n\n- b\n");
847+ let r = v.resolve_target("Homelab/Notes");
848+ assert!(r.is_err(), "guessed {:?}", r.map(|r| v.tree.node(r).title.clone()));
849+ assert!(r.unwrap_err().contains("ambiguous"));
850+ // one path through the duplicates is still unique
851+ let r = v.resolve_target("Homelab/Notes/b").unwrap();
852+ assert_eq!(v.tree.node(r).title, "b");
853+}
854+
855+#[test]
856+fn path_target_resolves_through_duplicate_ancestor_titles() {
857+ // exactly one node matches the full path P/Y, though the first P has no Y
858+ let (_d, v) = vault_with("# P\n\n## X\n\n# P\n\n## Y\n");
859+ let r = v.resolve_target("P/Y");
860+ assert!(r.is_ok(), "{:?}", r);
861+ assert_eq!(v.tree.node(r.unwrap()).title, "Y");
862+}
863+
864+#[test]
865+fn respelling_a_broken_embed_keeps_its_id() {
866+ // no block file has the id: `~` changes only the embed's form (§4.7)
867+ let (d, mut v) = vault_with(&format!("# A\n\n![[{}]]\n", ID_A));
868+ let e = v.tree.resolved_children(at(&v, &["A"]))[0];
869+ assert!(v.tree.node(e).is_embed());
870+ ops::toggle_spelling(&mut v, e).unwrap();
871+ assert_eq!(read(&d, "root.md"), format!("# A\n\n## ![[{}]]\n", ID_A));
872+ let e = v.tree.resolved_children(at(&v, &["A"]))[0];
873+ ops::toggle_spelling(&mut v, e).unwrap();
874+ assert_eq!(read(&d, "root.md"), format!("# A\n\n![[{}]]\n", ID_A));
875+ // and moves to its parent's boundary like any node respelled (§3.1)
876+ let (d, mut v) = vault_with(&format!("# A\n\n![[{}]]\n- b\n", ID_A));
877+ let e = v.tree.resolved_children(at(&v, &["A"]))[0];
878+ assert!(ops::toggle_spelling(&mut v, e).unwrap());
879+ assert_eq!(read(&d, "root.md"), format!("# A\n\n- b\n\n## ![[{}]]\n", ID_A));
880+}
881+
882+#[test]
883+fn delete_counts_the_nodes_it_took() {
884+ // the TUI says what went by name and count, never by file (§10.1)
885+ // (names no other test trashes: two trash copies of one name in the
886+ // same second race for its temporary file)
887+ let (_d, mut v) = vault_with("# A\n\n- tally\n - one\n - two\n- stack\n - shelf\n");
888+ let tally = at(&v, &["A", "tally"]);
889+ assert_eq!(ops::delete_subtree(&mut v, tally).unwrap(), 3);
890+ // a block, with a block nested in it
891+ let shelf = at(&v, &["A", "stack", "shelf"]);
892+ ops::make_block(&mut v, shelf).unwrap();
893+ let stack = at(&v, &["A", "stack"]);
894+ ops::make_block(&mut v, stack).unwrap();
895+ let stack = at(&v, &["A", "stack"]);
896+ assert!(v.tree.node(stack).is_block());
897+ assert_eq!(ops::delete_subtree(&mut v, stack).unwrap(), 2);
898+ // a broken embed is the one row it shows as
899+ let (_d, mut v) = vault_with(&format!("# A\n\n![[{}]]\n", ID_A));
900+ let e = v.tree.raw_children(at(&v, &["A"]))[0];
901+ assert!(v.tree.node(e).is_embed());
902+ assert_eq!(ops::delete_subtree(&mut v, e).unwrap(), 1);
903+ assert_eq!(v.tree.files[0].text, "# A\n");
904+}
905+
906+#[test]
907+fn deleting_a_duplicate_embed_removes_only_its_line() {
908+ // a second embed of a block is a diagnostic and renders as broken
909+ // (§6.2): deleting it (from the reading pane) removes that line alone,
910+ // as for a broken embed, and neither trashes the block nor takes the
911+ // first embed, the one the block is stitched in at
912+ let (d, mut v) = vault_files(
913+ &format!("# A\n\n![[{}]]\n\n# B\n\n![[{}]]\n", ID_A, ID_A),
914+ &[("racfer~s.md", &format!("---\nid: {}\n---\n\n- S\n", ID_A))],
915+ );
916+ let dup = v.tree.raw_children(at(&v, &["B"]))[0];
917+ assert!(v.tree.node(dup).is_embed());
918+ ops::delete_subtree(&mut v, dup).unwrap();
919+ assert!(d.path().join("racfer~s.md").exists(), "the block was trashed");
920+ assert_eq!(read(&d, "root.md"), format!("# A\n\n![[{}]]\n\n# B\n", ID_A));
921+ assert!(v.find_by_path(&["A".into(), "S".into()]).is_some());
922+}
923+
924+#[test]
925+fn make_block_on_a_file_changed_on_disk_leaves_no_block_file() {
926+ // the write guard refuses to replace the node with its embed in a file
927+ // that changed on disk since it was read; the block file must not be
928+ // left behind either, orphaned and in no undo entry (§10.10)
929+ let (d, mut v) = vault_with("# A\n\n- x\n");
930+ std::fs::write(d.path().join("root.md"), "# A\n\n- x\n- synced\n").unwrap();
931+ let x = at(&v, &["A", "x"]);
932+ let err = ops::make_block(&mut v, x).unwrap_err();
933+ assert!(err.to_string().contains("changed on disk"), "{}", err);
934+ let names: Vec<String> = std::fs::read_dir(d.path())
935+ .unwrap()
936+ .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
937+ .collect();
938+ assert_eq!(names, ["root.md"]);
939+ assert_eq!(read(&d, "root.md"), "# A\n\n- x\n- synced\n");
940+}
941+
942+#[test]
943+fn first_child_of_an_item_without_a_body_follows_its_title() {
944+ // §4.2: one blank line between a node's body and its first child; an
945+ // item with no body has none to keep its first child from its title
946+ // (§4.10: `- Talked to Anya…` then its child `### Options`)
947+ for (src, parent, want) in [
948+ ("- a\n", &["a"][..], "- a\n - c\n"),
949+ ("- a\n- b\n", &["a"], "- a\n - c\n- b\n"),
950+ ("- a\n\n- b\n", &["a"], "- a\n - c\n\n- b\n"),
951+ ("# S\n\n- a\n", &["S", "a"], "# S\n\n- a\n - c\n"),
952+ // a body keeps its blank line, and so does a section's title
953+ ("- a\n body\n", &["a"], "- a\n body\n\n - c\n"),
954+ ("# S\n", &["S"], "# S\n\n- c\n"),
955+ ] {
956+ let (d, mut v) = vault_with(src);
957+ let p = at(&v, parent);
958+ let c = ops::append_child_public(&mut v, p, "c").unwrap();
959+ assert_eq!(v.tree.node(c).title, "c");
960+ assert_eq!(read(&d, "root.md"), want, "N under {:?}", src);
961+ // capture to it too (§7)
962+ let (d, mut v) = vault_with(src);
963+ let p = at(&v, parent);
964+ ops::capture_to(&mut v, "c", false, p).unwrap();
965+ assert_eq!(read(&d, "root.md"), want, "capture to {:?}", src);
966+ }
967+ // under an item with a section child, the item goes before it: right
968+ // after the title too
969+ let (d, mut v) = vault_with("- a\n # S\n");
970+ let a = at(&v, &["a"]);
971+ ops::capture_to(&mut v, "c", false, a).unwrap();
972+ assert_eq!(read(&d, "root.md"), "- a\n - c\n # S\n");
973+}
974+
975+#[test]
976+fn deleting_a_node_that_holds_a_second_embed_keeps_the_block_and_its_first_embed() {
977+ // the second embed of S (under B/x) renders as broken (§6.2); deleting
978+ // x, the node that holds it, must not trash S nor take its first
979+ // embed, the one under A that S is stitched in at
980+ let (d, mut v) = vault_files(
981+ &format!("# A\n\n![[{}]]\n\n# B\n\n- x\n ![[{}]]\n", ID_A, ID_A),
982+ &[("racfer~s.md", &format!("---\nid: {}\n---\n\n- S\n", ID_A))],
983+ );
984+ let x = at(&v, &["B", "x"]);
985+ ops::delete_subtree(&mut v, x).unwrap();
986+ assert!(d.path().join("racfer~s.md").exists(), "the block was trashed");
987+ assert_eq!(read(&d, "root.md"), format!("# A\n\n![[{}]]\n\n# B\n", ID_A));
988+ // nor may clearing a done x, which removes it the same way
989+ let (d, mut v) = vault_files(
990+ &format!("# A\n\n![[{}]]\n\n# B\n\n- [x] x\n ![[{}]]\n", ID_A, ID_A),
991+ &[("racfer~s.md", &format!("---\nid: {}\n---\n\n- S\n", ID_A))],
992+ );
993+ let b = at(&v, &["B"]);
994+ assert_eq!(ops::clear_done(&mut v, b).unwrap(), 1);
995+ assert!(d.path().join("racfer~s.md").exists(), "the block was trashed");
996+ assert_eq!(read(&d, "root.md"), format!("# A\n\n![[{}]]\n\n# B\n", ID_A));
997+}
998+
999+#[test]
1000+fn deleting_the_node_that_holds_a_blocks_first_embed_leaves_its_second_embed() {
1001+ // deleting A trashes S, which A holds; the second embed of S, under
1002+ // B/x, is x's line, not S's own (§6.2), and stays where it is
1003+ let (d, mut v) = vault_files(
1004+ &format!("# A\n\n![[{}]]\n\n# B\n\n- x\n ![[{}]]\n", ID_A, ID_A),
1005+ &[("racfer~s.md", &format!("---\nid: {}\n---\n\n- S\n", ID_A))],
1006+ );
1007+ let a = at(&v, &["A"]);
1008+ ops::delete_subtree(&mut v, a).unwrap();
1009+ assert!(!d.path().join("racfer~s.md").exists(), "the block was not trashed");
1010+ assert_eq!(read(&d, "root.md"), format!("# B\n\n- x\n ![[{}]]\n", ID_A));
1011+}
1012+
1013+#[test]
1014+fn a_block_embedded_twice_is_inlined_where_the_outline_shows_it() {
1015+ // X (embedded under A) embeds S, and root.md embeds S again under B.
1016+ // The outline (walk, and the TUI's rows) meets S under X first; the
1017+ // reading pane and editor (render) must inline S there too, not at the
1018+ // embed that happens to come first in file order
1019+ let (_d, v) = vault_files(
1020+ &format!("# A\n\n![[{ID_B}]]\n\n# B\n\n![[{ID_A}]]\n"),
1021+ &[
1022+ ("dozzod~x.md", &format!("---\nid: {ID_B}\n---\n\n- X\n ![[{ID_A}]]\n")),
1023+ ("racfer~s.md", &format!("---\nid: {ID_A}\n---\n\n- S\n")),
1024+ ],
1025+ );
1026+ let mut walked = Vec::new();
1027+ v.tree.walk(v.tree.root, &mut |t, r| {
1028+ let n = t.node(r);
1029+ walked.push(if n.is_embed() { "(embed)".to_string() } else { n.title.clone() });
1030+ });
1031+ // B's embed of S is the second one met: it reads as broken (§6.2)
1032+ assert_eq!(walked, ["", "A", "X", "S", "B", "(embed)"]);
1033+ let x = at(&v, &["A", "X"]);
1034+ let r = fold_core::render::render(&v.tree, x, 1, true);
1035+ assert_eq!(r, "- X\n - S\n");
1036+ let b = at(&v, &["B"]);
1037+ let r = fold_core::render::render(&v.tree, b, 1, true);
1038+ assert_eq!(r, format!("# B\n\n![[{ID_A}]]\n"));
1039+ // and the embed S is stitched in at, which promote and refile act
1040+ // beside (§4.7), is X's
1041+ let id = fold_core::Id::parse(ID_A).unwrap();
1042+ let e = v.tree.embed_of(&id).unwrap();
1043+ assert_eq!(v.tree.files[e.0].path, "dozzod~x.md");
1044+}
1045+
1046+#[test]
1047+fn a_block_an_orphan_embeds_too_is_inlined_where_the_root_reaches_it() {
1048+ // an orphan block file (embedded nowhere) that sorts before C also
1049+ // embeds S: S belongs where the root reaches it, under C, not at the
1050+ // orphan's embed, which nothing shows
1051+ const ID_O: &str = "lacnum-walbyn-dirlyn-havtyp";
1052+ let (_d, v) = vault_files(
1053+ &format!("# A\n\n![[{ID_B}]]\n"),
1054+ &[
1055+ ("bacwes~o.md", &format!("---\nid: {ID_O}\n---\n\n- O\n ![[{ID_A}]]\n")),
1056+ ("dozzod~c.md", &format!("---\nid: {ID_B}\n---\n\n- C\n ![[{ID_A}]]\n")),
1057+ ("racfer~s.md", &format!("---\nid: {ID_A}\n---\n\n- S\n")),
1058+ ],
1059+ );
1060+ let r = fold_core::render::render(&v.tree, v.tree.root, 1, true);
1061+ assert_eq!(r, "# A\n\n- C\n - S\n");
1062+ assert!(v.find_by_path(&["A".into(), "C".into(), "S".into()]).is_some());
1063+}
1064+
1065+#[test]
1066+fn respelling_an_item_keeps_its_setext_child_under_it() {
1067+ // a setext heading is read (§4.2); respelling its parent must convert
1068+ // it with the rest of the subtree, not leave a level-1 underline that
1069+ // takes it out of the tree it was in
1070+ let (d, mut v) = vault_with("# P\n\n- a\n Sub\n ===\n body\n");
1071+ let a = at(&v, &["P", "a"]);
1072+ assert_eq!(v.tree.resolved_children(a).len(), 1);
1073+ ops::toggle_spelling(&mut v, a).unwrap();
1074+ assert!(
1075+ v.find_by_path(&["P".into(), "a".into(), "Sub".into()]).is_some(),
1076+ "{}",
1077+ read(&d, "root.md")
1078+ );
1079+ // so for a heading under an item written shallower than its position
1080+ // gives (§3.1): it stays one below its parent, respelled and back
1081+ let (d, mut v) = vault_with("# P\n\n- a\n # Sub\n body\n\n- b\n");
1082+ let a = at(&v, &["P", "a"]);
1083+ ops::toggle_spelling(&mut v, a).unwrap();
1084+ assert!(v.find_by_path(&["P".into(), "a".into(), "Sub".into()]).is_some(), "{}", read(&d, "root.md"));
1085+ let a = at(&v, &["P", "a"]);
1086+ ops::toggle_spelling(&mut v, a).unwrap();
1087+ assert!(v.find_by_path(&["P".into(), "a".into(), "Sub".into()]).is_some(), "{}", read(&d, "root.md"));
1088+ assert!(v.find_by_path(&["P".into(), "b".into()]).is_some(), "{}", read(&d, "root.md"));
1089+}
1090+
1091+#[test]
1092+fn respelling_a_block_whose_parent_changed_on_disk_writes_nothing() {
1093+ // `~` on a block writes its file, then its embed in the parent file. A
1094+ // sync not reloaded yet in the parent file makes the vault refuse the
1095+ // embed (§11.2); the block file must not be left respelled under an
1096+ // embed of the other form (§4.7), as make_block checks first
1097+ let (d, mut v) = vault_with("# A\n\n- task\n");
1098+ let t = at(&v, &["A", "task"]);
1099+ ops::make_block(&mut v, t).unwrap();
1100+ let bf = d.path().join(&v.tree.files[1].path);
1101+ let block_before = std::fs::read_to_string(&bf).unwrap();
1102+ let synced = format!("{}- from phone\n", read(&d, "root.md"));
1103+ std::fs::write(d.path().join("root.md"), &synced).unwrap();
1104+ let t = at(&v, &["A", "task"]);
1105+ let res = ops::toggle_spelling(&mut v, t);
1106+ assert!(res.is_err());
1107+ assert_eq!(read(&d, "root.md"), synced);
1108+ assert_eq!(
1109+ std::fs::read_to_string(&bf).unwrap(),
1110+ block_before,
1111+ "the block was respelled, its embed was not"
1112+ );
1113+}
1114+
1115+#[test]
1116+fn capturing_a_title_of_dashes_writes_nothing() {
1117+ // `- ---`, `- --` and `- - -` are thematic breaks, which CommonMark
1118+ // reads before a bullet: body text, not an item (§4.4). Capture refuses
1119+ // such a title before it writes anything, as it would an empty one
1120+ // (§4.3), instead of leaving a break under the day and failing after
1121+ for title in ["---", "--", "- -", "-- -"] {
1122+ let (d, mut v) = vault_with("# Projects\n");
1123+ let res = ops::capture(&mut v, title, false);
1124+ assert!(res.is_err(), "{:?} was captured", title);
1125+ assert_eq!(read(&d, "root.md"), "# Projects\n", "{:?}", title);
1126+ let (d, mut v) = vault_with("# A\n\n- x\n");
1127+ let a = at(&v, &["A"]);
1128+ let res = ops::capture_to(&mut v, title, false, a);
1129+ assert!(res.is_err(), "{:?} was captured", title);
1130+ assert_eq!(read(&d, "root.md"), "# A\n\n- x\n", "{:?}", title);
1131+ }
1132+ // a title that only starts with dashes, or a task's, is an item
1133+ let (_d, mut v) = vault_with("# A\n\n- x\n");
1134+ let a = at(&v, &["A"]);
1135+ let r = ops::capture_to(&mut v, "-- note", false, a).unwrap();
1136+ assert_eq!(v.tree.node(r).title, "-- note");
1137+ let a = at(&v, &["A"]);
1138+ let r = ops::capture_to(&mut v, "---", true, a).unwrap();
1139+ assert_eq!(v.tree.node(r).title, "---");
1140+ let a = at(&v, &["A"]);
1141+ let r = ops::capture_to(&mut v, "-", false, a).unwrap();
1142+ assert_eq!(v.tree.node(r).title, "-");
1143+}
1144+
1145+#[test]
1146+fn a_title_of_dashes_is_not_respelled_or_unchecked_into_a_thematic_break() {
1147+ // `~` on `## ---` would write `- ---`, and `t` on `- [ ] ---` would
1148+ // leave `- ---`: both thematic breaks, body text (§4.4), so the node
1149+ // would be gone and `y` under it taken in by `A`. Both are refused
1150+ // before they write
1151+ let (d, mut v) = vault_with("# A\n\n## ---\n\n- y\n");
1152+ let s = at(&v, &["A", "---"]);
1153+ assert!(ops::toggle_spelling(&mut v, s).is_err());
1154+ assert_eq!(read(&d, "root.md"), "# A\n\n## ---\n\n- y\n");
1155+ let (d, mut v) = vault_with("# A\n\n- [ ] ---\n- y\n");
1156+ let t = at(&v, &["A", "---"]);
1157+ assert!(ops::toggle_taskness(&mut v, t).is_err());
1158+ assert_eq!(read(&d, "root.md"), "# A\n\n- [ ] ---\n- y\n");
1159+ // checked, and as a heading, it is still a title
1160+ let t = at(&v, &["A", "---"]);
1161+ ops::toggle_task(&mut v, t).unwrap();
1162+ assert_eq!(read(&d, "root.md"), "# A\n\n- [x] ---\n- y\n");
1163+ let t = at(&v, &["A", "---"]);
1164+ ops::toggle_spelling(&mut v, t).unwrap();
1165+ assert!(read(&d, "root.md").contains("\n## [x] ---\n"), "{}", read(&d, "root.md"));
1166+ at(&v, &["A", "---"]);
1167+ let (d, mut v) = vault_with("# A\n\n## [ ] ---\n");
1168+ let s = at(&v, &["A", "---"]);
1169+ ops::toggle_taskness(&mut v, s).unwrap();
1170+ assert_eq!(read(&d, "root.md"), "# A\n\n## ---\n");
1171+}
1172+
1173+#[test]
1174+fn clearing_a_done_block_trashes_the_blocks_embedded_in_it() {
1175+ // T is embedded in S's file only: clearing a done S trashes T too, as
1176+ // deleting S does (§11.5), instead of leaving it embedded nowhere
1177+ let (d, mut v) = vault_files(
1178+ &format!("# A\n\n![[{}]]\n- open\n", ID_A),
1179+ &[
1180+ ("racfer~s.md", &format!("---\nid: {}\n---\n\n- [x] S\n ![[{}]]\n", ID_A, ID_B)),
1181+ ("dozzod~t.md", &format!("---\nid: {}\n---\n\n- T\n", ID_B)),
1182+ ],
1183+ );
1184+ let a = at(&v, &["A"]);
1185+ assert_eq!(ops::clear_done(&mut v, a).unwrap(), 1);
1186+ assert!(!d.path().join("racfer~s.md").exists(), "S was not trashed");
1187+ assert!(!d.path().join("dozzod~t.md").exists(), "T was left embedded nowhere");
1188+ assert_eq!(read(&d, "root.md"), "# A\n\n- open\n");
1189+}
1190+
1191+#[test]
1192+fn setting_a_property_on_a_second_embed_never_writes_the_file_that_holds_it() {
1193+ const ID_S: &str = "lacnum-walbyn-dirlyn-havtyp";
1194+ let x_text = format!("---\nid: {ID_B}\n---\n\n- X\n ![[{ID_S}]]\n");
1195+ let (d, mut v) = vault_files(
1196+ &format!("# A\n\n![[{ID_S}]]\n\n# B\n\n![[{ID_B}]]\n"),
1197+ &[("dozzod~x.md", &x_text), ("lacnum~s.md", &format!("---\nid: {ID_S}\n---\n\n- S\n"))],
1198+ );
1199+ let x = at(&v, &["B", "X"]);
1200+ let second = v.tree.resolved_children(x)[0];
1201+ assert!(v.tree.node(second).is_embed(), "the second embed reads as broken");
1202+ let res = ops::set_property(&mut v, second, "due", "2026-10-01");
1203+ assert_eq!(
1204+ read(&d, "dozzod~x.md"),
1205+ x_text,
1206+ "set_property on S's second embed ({:?}) wrote into X's file",
1207+ res.map_err(|e| e.to_string())
1208+ );
1209+}
1210+
1211+#[test]
1212+fn verbs_that_write_into_a_node_refuse_a_second_embed() {
1213+ // a second embed of S reads as broken (§6.2): no node is there to hold
1214+ // a checkbox, properties or children, and an embed line has no
1215+ // children in its file. Each verb refuses, writing nothing, instead of
1216+ // writing into X's file, which holds the embed
1217+ const ID_S: &str = "lacnum-walbyn-dirlyn-havtyp";
1218+ fn second(v: &Vault) -> fold_core::tree::NRef {
1219+ v.tree.resolved_children(at(v, &["B", "X"]))[0]
1220+ }
1221+ type Verb = fn(&mut Vault) -> std::io::Result<()>;
1222+ let verbs: [(&str, Verb); 7] = [
1223+ ("toggle_taskness", |v| {
1224+ let e = second(v);
1225+ ops::toggle_taskness(v, e)
1226+ }),
1227+ ("set_property", |v| {
1228+ let e = second(v);
1229+ ops::set_property(v, e, "due", "2026-10-01").map(drop)
1230+ }),
1231+ ("append_child_public", |v| {
1232+ let e = second(v);
1233+ ops::append_child_public(v, e, "new").map(drop)
1234+ }),
1235+ ("capture_to", |v| {
1236+ let e = second(v);
1237+ ops::capture_to(v, "new", false, e).map(drop)
1238+ }),
1239+ ("refile", |v| {
1240+ let (c, e) = (at(v, &["C", "c"]), second(v));
1241+ ops::refile(v, c, e).map(drop)
1242+ }),
1243+ ("move_node into", |v| {
1244+ let (c, e) = (at(v, &["C", "c"]), second(v));
1245+ ops::move_node(v, c, e, ops::Drop::Into).map(drop)
1246+ }),
1247+ ("demote", |v| {
1248+ let y = at(v, &["B", "X", "y"]);
1249+ ops::demote(v, y).map(drop)
1250+ }),
1251+ ];
1252+ let root = format!("# A\n\n![[{ID_S}]]\n\n# B\n\n![[{ID_B}]]\n\n# C\n\n- c\n");
1253+ let x_text = format!("---\nid: {ID_B}\n---\n\n- X\n ![[{ID_S}]]\n - y\n");
1254+ let s_text = format!("---\nid: {ID_S}\n---\n\n- S\n");
1255+ for (name, verb) in verbs {
1256+ let (d, mut v) = vault_files(&root, &[("dozzod~x.md", &x_text), ("lacnum~s.md", &s_text)]);
1257+ assert!(v.tree.node(second(&v)).is_embed(), "the second embed reads as broken");
1258+ let res = verb(&mut v);
1259+ assert_eq!(read(&d, "dozzod~x.md"), x_text, "{} wrote into X's file", name);
1260+ assert_eq!(read(&d, "root.md"), root, "{} wrote root.md", name);
1261+ assert_eq!(read(&d, "lacnum~s.md"), s_text, "{} wrote S's file", name);
1262+ assert!(res.is_err(), "{} did nothing and said nothing", name);
1263+ }
1264+}
1265+
1266+#[test]
1267+fn a_key_set_in_root_md_goes_into_its_own_frontmatter() {
1268+ // root.md's frontmatter holds vault-level properties (§4.9): a key set
1269+ // there joins them, never a second frontmatter above them that turns
1270+ // them into text
1271+ let (d, mut v) = vault_with("---\nfoo: bar\n---\n\n# A\n");
1272+ ops::set_frontmatter_key(&mut v, 0, "due", Some("2026-10-01")).unwrap();
1273+ assert_eq!(read(&d, "root.md"), "---\nfoo: bar\ndue: 2026-10-01\n---\n\n# A\n");
1274+ ops::set_frontmatter_key(&mut v, 0, "foo", None).unwrap();
1275+ assert_eq!(read(&d, "root.md"), "---\ndue: 2026-10-01\n---\n\n# A\n");
1276+ let root = v.tree.root;
1277+ ops::set_property(&mut v, root, "due", "2026-10-02").unwrap();
1278+ assert_eq!(read(&d, "root.md"), "---\ndue: 2026-10-02\n---\n\n# A\n");
1279+}
crates/fold-core/tests/ordering.rs+387−3
@@ -219,7 +219,7 @@ fn paste_item_after_a_section_is_clamped() {
219219 let (_d, mut v) = vault_with("# P\n\n- a\n\n## S\n");
220220 let r = at(&v, "P/S");
221221 // clamped, and reported as such
222− assert!(ops::paste(&mut v, r, "- new\n", true).unwrap());
222+ assert!(ops::paste(&mut v, r, "- new\n", true).unwrap().clamped);
223223 assert!(v.find_by_path(&["P".into(), "new".into()]).is_some(), "{}", root_text(&v));
224224 assert!(v.find_by_path(&["P".into(), "S".into(), "new".into()]).is_none());
225225 assert_ordered(&v);
@@ -505,7 +505,7 @@ fn check_reports_and_fixes_heading_embed_levels() {
505505fn placement_as_asked_is_not_reported_as_moved() {
506506 let (_d, mut v) = vault_with("# P\n\n- a\n- b\n");
507507 let r = at(&v, "P/a");
508− assert!(!ops::paste(&mut v, r, "- new\n", true).unwrap());
508+ assert!(!ops::paste(&mut v, r, "- new\n", true).unwrap().clamped);
509509 let kids: Vec<String> = v
510510 .tree
511511 .resolved_children(at(&v, "P"))
@@ -544,7 +544,391 @@ fn drop_refuses_own_subtree() {
544544fn drop_section_before_an_item_is_clamped() {
545545 let (_d, mut v) = vault_with("# P\n\n- a\n- b\n\n# S\n");
546546 let (r, t) = (at(&v, "S"), at(&v, "P/a"));
547− assert!(ops::move_node(&mut v, r, t, ops::Drop::Before).unwrap());
547+ assert!(ops::move_node(&mut v, r, t, ops::Drop::Before).unwrap().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().clamped);
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+}
859+
860+// ---------------------------------------------------------------- placed
861+
862+/// The node a verb moved, where it landed, found by where it was written
863+/// (§3.4), never again by its title: beside a namesake it is still itself.
864+#[test]
865+fn a_moved_node_is_found_where_it_landed_beside_a_namesake() {
866+ let (_d, mut v) = vault_with("# A\n\n- x\n moved\n\n# B\n\n- x\n stays\n");
867+ let (x, b) = (at(&v, "A/x"), at(&v, "B"));
868+ let p = ops::refile(&mut v, x, b).unwrap();
869+ assert!(!p.clamped);
870+ assert_eq!(v.tree.node(p.node).title, "x");
871+ assert_eq!(v.tree.node(p.node).text_lines(v.tree.text_of(p.node)), [" moved"]);
872+ let b = at(&v, "B");
873+ assert_eq!(v.tree.resolved_children(b).last(), Some(&p.node));
874+}
875+
876+#[test]
877+fn a_pasted_node_is_found_where_it_landed() {
878+ let (_d, mut v) = vault_with("# A\n\n- x\n- y\n\n## S\n");
879+ let x = at(&v, "A/x");
880+ let p = ops::paste(&mut v, x, "- x\n", true).unwrap();
881+ assert!(!p.clamped);
882+ assert_eq!(v.tree.resolved_children(at(&v, "A"))[1], p.node);
883+ // after a section it goes before the sections, and says so (§3.1)
884+ let s = at(&v, "A/S");
885+ let p = ops::paste(&mut v, s, "- z\n", true).unwrap();
886+ assert!(p.clamped);
887+ assert_eq!(v.tree.node(p.node).title, "z");
888+ assert_eq!(v.tree.resolved_children(at(&v, "A"))[3], p.node);
889+}
890+
891+/// A block moves by its embed, and the node is the block, as the outline
892+/// shows it; out of a block's file, it lands in the parent file.
893+#[test]
894+fn a_moved_block_is_the_block_and_promoting_leaves_its_file() {
895+ let (d, mut v) = vault_with("# A\n\n![[racfer-hattes-mislup-nodrys]]\n\n# B\n");
896+ std::fs::write(
897+ d.path().join("racfer~blk.md"),
898+ "---\nid: racfer-hattes-mislup-nodrys\n---\n\n- blk\n - inner\n",
899+ )
900+ .unwrap();
901+ v.reload().unwrap();
902+ let (blk, b) = (at(&v, "A/blk"), at(&v, "B"));
903+ let p = ops::refile(&mut v, blk, b).unwrap();
904+ assert!(v.tree.node(p.node).is_block());
905+ assert_eq!(v.tree.node(p.node).title, "blk");
906+ let inner = at(&v, "B/blk/inner");
907+ let p = ops::promote(&mut v, inner).unwrap();
908+ assert_eq!(p.node.0, 0, "in root.md");
909+ assert_eq!(v.tree.node(p.node).title, "inner");
910+ assert_eq!(v.tree.resolved_children(at(&v, "B")), [at(&v, "B/blk"), p.node]);
911+}
912+
913+/// A node and its conflict copy move as one (§12.5): the node a verb
914+/// reports is the one it was asked to move, the node or its copy.
915+#[test]
916+fn a_node_and_its_copy_are_each_found_where_they_landed() {
917+ let (d, mut v) = vault_with("# A\n\n- x\n![[racfer-hattes-mislup-nodrys]]\n\n# B\n");
918+ std::fs::write(
919+ d.path().join("racfer~x.md"),
920+ "---\nid: racfer-hattes-mislup-nodrys\nconflict: \"PHONE 20260927-100000\"\n---\n\n- x\n",
921+ )
922+ .unwrap();
923+ v.reload().unwrap();
924+ let x = v.tree.resolved_children(at(&v, "A"))[0];
925+ let b = at(&v, "B");
926+ let p = ops::refile(&mut v, x, b).unwrap();
927+ assert!(v.tree.node(p.node).conflict().is_none(), "the node, not its copy");
928+ let copy = v.tree.resolved_children(at(&v, "B"))[1];
929+ assert!(v.tree.node(copy).conflict().is_some());
930+ let a = at(&v, "A");
931+ let p = ops::refile(&mut v, copy, a).unwrap();
932+ assert!(v.tree.node(p.node).conflict().is_some(), "the copy, not its node");
933+ assert_eq!(v.tree.resolved_children(at(&v, "A")).len(), 2);
934+}
crates/fold-core/tests/resolve.rs+274
@@ -1,4 +1,6 @@
11use fold_core::merge;
2+use fold_core::ops;
3+use fold_core::tree::NRef;
24use fold_core::vault::Vault;
35
46fn make_conflict_vault() -> (tempfile::TempDir, Vault) {
@@ -25,6 +27,21 @@ fn pairs_found_after_merge() {
2527 assert!(v.tree.node(theirs).block.as_ref().unwrap().prop("conflict").is_some());
2628}
2729
30+/// A second embed of a conflict block reads as broken (§6.2): the block is
31+/// the side of the one pair at its own embed, never of the node before a
32+/// stray copy of the line.
33+#[test]
34+fn a_second_embed_of_a_copy_pairs_with_nothing() {
35+ let (d, mut v) = make_conflict_vault();
36+ let (_, theirs) = merge::conflict_pairs(&v)[0];
37+ let id = v.tree.node(theirs).block.as_ref().unwrap().id.clone().unwrap();
38+ let root = std::fs::read_to_string(d.path().join("root.md")).unwrap();
39+ std::fs::write(d.path().join("root.md"), format!("{}\n# B\n\n- other\n![[{}]]\n", root, id)).unwrap();
40+ v.reload().unwrap();
41+ let pairs: Vec<String> = merge::conflict_pairs(&v).iter().map(|&(o, _)| v.tree.node(o).title.clone()).collect();
42+ assert_eq!(pairs, ["task"]);
43+}
44+
2845#[test]
2946fn keep_ours_removes_conflict_block() {
3047 let (_d, mut v) = make_conflict_vault();
@@ -71,3 +88,260 @@ fn keep_both_drops_conflict_key() {
7188 assert!(!bf.text.contains("conflict:"), "{}", bf.text);
7289 assert!(bf.text.contains("- [x] task"), "{}", bf.text);
7390}
91+
92+/// A copy of `task`, which the phone checked, right after it and its
93+/// child, among its siblings `one` and `two` (§12.4).
94+fn pair_among_siblings() -> (tempfile::TempDir, Vault) {
95+ let dir = tempfile::tempdir().unwrap();
96+ std::fs::write(dir.path().join("root.md"), "# A\n\n- one\n- [ ] task\n - sub\n- two\n").unwrap();
97+ std::fs::write(
98+ dir.path().join("root.sync-conflict-20260912-100000-phone.md"),
99+ "# A\n\n- one\n- [x] task\n - sub\n- two\n",
100+ )
101+ .unwrap();
102+ let mut v = Vault::open(dir.path()).unwrap();
103+ merge::merge_sync_conflicts(&mut v, false).unwrap();
104+ assert_eq!(ours(&v), ["task"]);
105+ (dir, v)
106+}
107+
108+/// The node each copy pairs with: the one before it (§12.5).
109+fn ours(v: &Vault) -> Vec<String> {
110+ merge::conflict_pairs(v).into_iter().map(|(o, _)| v.tree.node(o).title.clone()).collect()
111+}
112+
113+fn node(v: &Vault, path: &[&str]) -> NRef {
114+ v.find_by_path(&path.iter().map(|s| s.to_string()).collect::<Vec<_>>()).unwrap()
115+}
116+
117+fn copy(v: &Vault) -> NRef {
118+ merge::conflict_pairs(v)[0].1
119+}
120+
121+/// Titles of A's children, in order: the copy as `task⚠`.
122+fn order(v: &Vault) -> Vec<String> {
123+ let a = node(v, &["A"]);
124+ v.tree
125+ .resolved_children(a)
126+ .into_iter()
127+ .map(|c| {
128+ let n = v.tree.node(c);
129+ format!("{}{}", n.title, if n.conflict().is_some() { "⚠" } else { "" })
130+ })
131+ .collect()
132+}
133+
134+#[test]
135+fn move_sibling_moves_a_node_and_its_conflict_copy_as_one() {
136+ let (_d, mut v) = pair_among_siblings();
137+ let text = v.tree.files[0].text.clone();
138+ let mut step = |at: &dyn Fn(&Vault) -> NRef, down: bool| {
139+ let r = at(&v);
140+ ops::move_sibling(&mut v, r, down).unwrap();
141+ assert_eq!(ours(&v), ["task"], "{}", v.tree.files[0].text);
142+ (order(&v), v.tree.files[0].text.clone())
143+ };
144+ // a node steps over the pair, up or down
145+ let two = |v: &Vault| node(v, &["A", "two"]);
146+ assert_eq!(step(&two, false).0, ["one", "two", "task", "task⚠"]);
147+ assert_eq!(step(&two, true).1, text);
148+ let one = |v: &Vault| node(v, &["A", "one"]);
149+ assert_eq!(step(&one, true).0, ["task", "task⚠", "one", "two"]);
150+ assert_eq!(step(&one, false).1, text);
151+ // the pair moves as one, by either side
152+ assert_eq!(step(&|v| node(v, &["A", "task"]), true).0, ["one", "two", "task", "task⚠"]);
153+ assert_eq!(step(&copy, false).1, text);
154+ assert_eq!(step(&copy, false).0, ["task", "task⚠", "one", "two"]);
155+}
156+
157+#[test]
158+fn a_node_placed_between_a_node_and_its_conflict_copy_goes_after_the_copy() {
159+ let after_copy = ["one", "task", "task⚠", "new", "two"];
160+ // pasted after ours, or before the copy
161+ let (_d, mut v) = pair_among_siblings();
162+ let task = node(&v, &["A", "task"]);
163+ assert!(!ops::paste(&mut v, task, "- new\n", true).unwrap().clamped);
164+ assert_eq!(order(&v), after_copy);
165+ let (_d, mut v) = pair_among_siblings();
166+ let c = copy(&v);
167+ ops::paste(&mut v, c, "- new\n", false).unwrap();
168+ assert_eq!(order(&v), after_copy);
169+ // dropped before the copy
170+ let (_d, mut v) = pair_among_siblings();
171+ let (one, c) = (node(&v, &["A", "one"]), copy(&v));
172+ ops::move_node(&mut v, one, c, ops::Drop::Before).unwrap();
173+ assert_eq!(order(&v), ["task", "task⚠", "one", "two"]);
174+ // outdented out of ours
175+ let (_d, mut v) = pair_among_siblings();
176+ let sub = node(&v, &["A", "task", "sub"]);
177+ ops::promote(&mut v, sub).unwrap();
178+ assert_eq!(order(&v), ["one", "task", "task⚠", "sub", "two"]);
179+ assert_eq!(ours(&v), ["task"]);
180+ // ours itself, dropped before its own copy, stays where it is
181+ let (_d, mut v) = pair_among_siblings();
182+ let text = v.tree.files[0].text.clone();
183+ let (task, c) = (node(&v, &["A", "task"]), copy(&v));
184+ ops::move_node(&mut v, task, c, ops::Drop::Before).unwrap();
185+ assert_eq!(v.tree.files[0].text, text);
186+}
187+
188+#[test]
189+fn a_pair_written_deeper_than_the_sibling_it_moves_below_stays_its_sibling() {
190+ // X and its copy are written a level deeper than Y, as a hand edit may
191+ // leave them (§4.7): below Y they are written at its level, or they
192+ // would nest under it
193+ let dir = tempfile::tempdir().unwrap();
194+ let id = "bacbec-bacbec-bacbec-bacbec";
195+ std::fs::write(dir.path().join("root.md"), format!("# A\n\n### X\n\nmine\n\n### ![[{}]]\n\n## Y\n\nwhy\n", id)).unwrap();
196+ std::fs::write(
197+ dir.path().join("bacbec~x.md"),
198+ format!("---\nid: {}\nconflict: \"PHONE 20260926-090000\"\n---\n\n# X\n\ntheirs\n", id),
199+ )
200+ .unwrap();
201+ let mut v = Vault::open(dir.path()).unwrap();
202+ assert_eq!(ours(&v), ["X"]);
203+ let y = node(&v, &["A", "Y"]);
204+ ops::move_sibling(&mut v, y, false).unwrap();
205+ assert_eq!(v.tree.files[0].text, format!("# A\n\n## Y\n\nwhy\n\n## X\n\nmine\n\n## ![[{}]]\n", id));
206+ assert_eq!(order(&v), ["Y", "X", "X⚠"]);
207+ assert_eq!(ours(&v), ["X"]);
208+}
209+
210+/// `pair_among_siblings`, with a section B after A to move things to.
211+fn pair_and_b() -> (tempfile::TempDir, Vault) {
212+ let dir = tempfile::tempdir().unwrap();
213+ let text = "# A\n\n- one\n- [ ] task\n - sub\n- two\n\n# B\n\n- b\n";
214+ std::fs::write(dir.path().join("root.md"), text).unwrap();
215+ std::fs::write(
216+ dir.path().join("root.sync-conflict-20260912-100000-phone.md"),
217+ text.replace("[ ] task", "[x] task"),
218+ )
219+ .unwrap();
220+ let mut v = Vault::open(dir.path()).unwrap();
221+ merge::merge_sync_conflicts(&mut v, false).unwrap();
222+ assert_eq!(ours(&v), ["task"]);
223+ (dir, v)
224+}
225+
226+/// Titles of the children of the node at `path`, in order: a copy as
227+/// `task⚠`.
228+fn under(v: &Vault, path: &[&str]) -> Vec<String> {
229+ v.tree
230+ .resolved_children(node(v, path))
231+ .into_iter()
232+ .map(|c| {
233+ let n = v.tree.node(c);
234+ format!("{}{}", n.title, if n.conflict().is_some() { "⚠" } else { "" })
235+ })
236+ .collect()
237+}
238+
239+/// Either side of the pair: the node, then its copy.
240+const SIDES: [fn(&Vault) -> NRef; 2] = [|v| merge::conflict_pairs(v)[0].0, copy];
241+
242+#[test]
243+fn a_node_moved_away_takes_its_conflict_copy_with_it_from_either_side() {
244+ // the copy left behind would pair with the node before it, `one`,
245+ // and keeping theirs would replace that (§12.5)
246+ let moved = |v: &Vault, into: &[&str], want: &[&str]| {
247+ assert_eq!(ours(v), ["task"], "{}", v.tree.files[0].text);
248+ assert_eq!(under(v, into), want, "{}", v.tree.files[0].text);
249+ };
250+ for side in SIDES {
251+ // Move to…, and archive
252+ let (_d, mut v) = pair_and_b();
253+ let (r, b) = (side(&v), node(&v, &["B"]));
254+ ops::refile(&mut v, r, b).unwrap();
255+ moved(&v, &["B"], &["b", "task", "task⚠"]);
256+ assert_eq!(under(&v, &["A"]), ["one", "two"]);
257+ let id = &v.tree.node(copy(&v)).block.as_ref().unwrap().id.clone().unwrap();
258+ // right after it, in its spelling, as the merge wrote it (§12.4)
259+ let text = &v.tree.files[0].text;
260+ assert!(text.contains("# B\n\n- b\n- [ ] task\n"), "{}", text);
261+ assert!(text.ends_with(&format!(" - sub\n![[{}]]\n", id)), "{}", text);
262+ let (_d, mut v) = pair_and_b();
263+ let r = side(&v);
264+ ops::archive(&mut v, r).unwrap();
265+ moved(&v, &["Archive"], &["task", "task⚠"]);
266+ // a drop into B, or before b
267+ let (_d, mut v) = pair_and_b();
268+ let (r, b) = (side(&v), node(&v, &["B"]));
269+ ops::move_node(&mut v, r, b, ops::Drop::Into).unwrap();
270+ moved(&v, &["B"], &["b", "task", "task⚠"]);
271+ let (_d, mut v) = pair_and_b();
272+ let (r, b) = (side(&v), node(&v, &["B", "b"]));
273+ ops::move_node(&mut v, r, b, ops::Drop::Before).unwrap();
274+ moved(&v, &["B"], &["task", "task⚠", "b"]);
275+ // indented: under one, the node before the pair; then outdented
276+ let (_d, mut v) = pair_and_b();
277+ let r = side(&v);
278+ ops::demote(&mut v, r).unwrap();
279+ moved(&v, &["A", "one"], &["task", "task⚠"]);
280+ assert_eq!(under(&v, &["A"]), ["one", "two"]);
281+ let r = side(&v);
282+ ops::promote(&mut v, r).unwrap();
283+ moved(&v, &["A"], &["one", "task", "task⚠", "two"]);
284+ }
285+}
286+
287+#[test]
288+fn a_node_and_its_conflict_copy_move_into_neither() {
289+ for side in SIDES {
290+ let (_d, mut v) = pair_and_b();
291+ let text = v.tree.files[0].text.clone();
292+ let r = side(&v);
293+ let other = if r == copy(&v) { node(&v, &["A", "task"]) } else { copy(&v) };
294+ assert!(ops::move_node(&mut v, r, other, ops::Drop::Into).is_err());
295+ let sub = v.tree.resolved_children(other)[0];
296+ assert!(ops::refile(&mut v, r, sub).is_err());
297+ assert_eq!(v.tree.files[0].text, text);
298+ }
299+}
300+
301+#[test]
302+fn deleting_a_node_deletes_its_conflict_copy_and_a_copy_goes_alone() {
303+ // the node and sub, then the copy of both
304+ let (_d, mut v) = pair_and_b();
305+ let task = node(&v, &["A", "task"]);
306+ assert_eq!(ops::delete_subtree(&mut v, task).unwrap(), 4);
307+ assert_eq!(under(&v, &["A"]), ["one", "two"]);
308+ assert_eq!(v.tree.files.len(), 1, "the copy's file goes to the trash");
309+ // the copy alone, as keeping ours does (§12.5)
310+ let (_d, mut v) = pair_and_b();
311+ let c = copy(&v);
312+ assert_eq!(ops::delete_subtree(&mut v, c).unwrap(), 2);
313+ assert_eq!(under(&v, &["A"]), ["one", "task", "two"]);
314+}
315+
316+#[test]
317+fn a_node_in_a_conflict_pair_keeps_its_spelling_until_it_is_resolved() {
318+ // made a heading, the node would go after the items and its copy
319+ // stay, to pair with `one` (§12.5)
320+ for side in SIDES {
321+ let (_d, mut v) = pair_and_b();
322+ let text = v.tree.files[0].text.clone();
323+ let r = side(&v);
324+ assert!(ops::toggle_spelling(&mut v, r).is_err());
325+ assert_eq!(v.tree.files[0].text, text);
326+ assert_eq!(ours(&v), ["task"]);
327+ }
328+}
329+
330+#[test]
331+fn clear_done_keeps_a_done_node_whose_conflict_copy_is_open() {
332+ let dir = tempfile::tempdir().unwrap();
333+ let text = "# A\n\n- one\n- [x] task\n- [x] old\n";
334+ std::fs::write(dir.path().join("root.md"), text).unwrap();
335+ std::fs::write(
336+ dir.path().join("root.sync-conflict-20260912-100000-phone.md"),
337+ text.replace("[x] task", "[ ] task"),
338+ )
339+ .unwrap();
340+ let mut v = Vault::open(dir.path()).unwrap();
341+ merge::merge_sync_conflicts(&mut v, false).unwrap();
342+ assert_eq!(ours(&v), ["task"]);
343+ let root = v.tree.root;
344+ assert_eq!(ops::clear_done(&mut v, root).unwrap(), 1);
345+ assert_eq!(under(&v, &["A"]), ["one", "task", "task⚠"]);
346+ assert_eq!(ours(&v), ["task"]);
347+}
crates/fold-core/tests/roundtrip.rs+109−36
@@ -11,13 +11,9 @@ fn tree_of(text: &str) -> Tree {
1111 frontmatter_raw: String::new(),
1212 frontmatter_span: None,
1313 };
14− let pf = parse_file("root.md", text, 0, Some(block));
14+ let pf = parse_file("root.md", text, 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]
@@ -175,25 +171,21 @@ fn block_file_tree() -> Tree {
175171 frontmatter_raw: String::new(),
176172 frontmatter_span: None,
177173 };
178− let root = parse_file("root.md", root_text, 0, Some(root_block));
174+ let root = parse_file("root.md", root_text, Some(root_block));
179175
180176 let block_text = "---\nid: dozzod-binwes-talsun-worbec\nsince: 2024-03\ntags: [storage, homelab] # user comment\n---\n\n# ZFS layout\n\nMirrored pairs, no raidz. Snapshots hourly via sanoid.\n\n## [ ] Snapshot policy\n\n- hourly, keep 24\n- [x] Move scratch to its own dataset\n";
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,
188184 frontmatter_span: Some(fm.span),
189185 };
190− let bf = parse_file("dozzod~zfs-layout.md", block_text, 1, Some(block));
186+ let bf = parse_file("dozzod~zfs-layout.md", block_text, 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]
@@ -244,18 +236,14 @@ fn bullet_root_block_file() {
244236 frontmatter_raw: fm.raw,
245237 frontmatter_span: Some(fm.span),
246238 };
247− let pf = parse_file("racfer~order-new-switch.md", text, 0, Some(block));
239+ let pf = parse_file("racfer~order-new-switch.md", text, Some(block));
248240 assert!(pf.diagnostics.is_empty(), "diags: {:?}", pf.diagnostics);
249241 let root_node = pf.nodes[0].children[0];
250242 let n = &pf.nodes[root_node];
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}
@@ -270,22 +258,18 @@ fn tree_with_block(root_text: &str, block_text: &str, block_path: &str) -> Tree
270258 frontmatter_raw: String::new(),
271259 frontmatter_span: None,
272260 };
273− let root = parse_file("root.md", root_text, 0, Some(root_block));
261+ let root = parse_file("root.md", root_text, Some(root_block));
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 };
283− 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− }
271+ let bf = parse_file(block_path, block_text, Some(block));
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";
@@ -428,12 +412,8 @@ fn empty_title_nodes_parse_everywhere() {
428412 ("# A\n\n- x\n - \n", 1),
429413 ];
430414 for (text, kids) in cases {
431− 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− };
415+ let pf = parse_file("root.md", text, None);
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);
@@ -444,9 +424,102 @@ fn empty_title_nodes_parse_everywhere() {
444424 assert_eq!(t.node(n).title, "", "{:?}", text);
445425 // rendering keeps the node (without trailing whitespace)
446426 let r = render(&t, t.root, 1, false);
447− let pf2 = parse_file("root.md", &r, 0, None);
427+ let pf2 = parse_file("root.md", &r, None);
448428 assert_eq!(pf2.nodes.len(), t.files[0].nodes.len(), "{:?} → {:?}", text, r);
449429 }
450− let pf = parse_file("root.md", "# A\n\n- [ ] \n", 0, None);
430+ let pf = parse_file("root.md", "# A\n\n- [ ] \n", 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+159−1
@@ -1,7 +1,7 @@
11//! The session op log (§10.10): an entry holds exactly the files an
22//! operation touched; undo and redo refuse when one changed since.
33
4−use fold_core::ops::{self, Inverse, Snapshot};
4+use fold_core::ops::{self, Inverse, OpLog, Snapshot};
55use fold_core::vault::Vault;
66
77fn vault_with(root: &str) -> (tempfile::TempDir, Vault) {
@@ -82,3 +82,161 @@ 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+}
106+
107+const OTHER_SYNCED: &str = "---\nid: racfer-hattes-mislup-nodrys\n---\n\n- other, from phone\n";
108+
109+/// A sync changes a file the verb does not touch, and it is not reloaded
110+/// yet (the watcher's debounce): the verb's entry holds only what the verb
111+/// wrote, so undoing it leaves the sync's change be (§10.10). A verb that
112+/// re-read the whole vault would take the change in as its own, and its
113+/// undo would revert it.
114+fn keeps_a_sync_it_did_not_make(d: &tempfile::TempDir, v: &mut Vault, verb: impl FnOnce(&mut Vault)) {
115+ std::fs::write(d.path().join("racfer~other.md"), OTHER_SYNCED).unwrap();
116+ let snap = Snapshot::take(v, "verb");
117+ verb(v);
118+ let inv = Inverse::since(snap, v).expect("the verb changed something");
119+ let paths: Vec<&str> = inv.changes.iter().map(|c| c.path.as_str()).collect();
120+ assert!(!paths.contains(&"racfer~other.md"), "{:?}", paths);
121+ inv.undo(v).unwrap();
122+ assert_eq!(read(d, "racfer~other.md"), OTHER_SYNCED);
123+}
124+
125+/// `keeps_a_sync_it_did_not_make` on a vault with `WITH_OTHER` as its root.
126+fn verb_keeps_a_sync(verb: impl FnOnce(&mut Vault)) {
127+ let (d, mut v) = vault_with("# A\n\n- t\n- u\n\n![[racfer-hattes-mislup-nodrys]]\n\n# B\n");
128+ keeps_a_sync_it_did_not_make(&d, &mut v, verb);
129+}
130+
131+fn at(v: &Vault, path: &[&str]) -> fold_core::tree::NRef {
132+ v.find_by_path(&path.iter().map(|s| s.to_string()).collect::<Vec<_>>()).unwrap()
133+}
134+
135+#[test]
136+fn make_block_keeps_a_sync_it_did_not_make() {
137+ verb_keeps_a_sync(|v| {
138+ ops::make_block(v, at(v, &["A", "t"])).unwrap();
139+ });
140+}
141+
142+#[test]
143+fn set_property_keeps_a_sync_it_did_not_make() {
144+ verb_keeps_a_sync(|v| {
145+ ops::set_property(v, at(v, &["A", "t"]), "due", "2026-10-01").unwrap();
146+ });
147+}
148+
149+#[test]
150+fn refile_keeps_a_sync_it_did_not_make() {
151+ verb_keeps_a_sync(|v| {
152+ ops::refile(v, at(v, &["A", "t"]), at(v, &["B"])).unwrap();
153+ });
154+}
155+
156+#[test]
157+fn paste_keeps_a_sync_it_did_not_make() {
158+ verb_keeps_a_sync(|v| {
159+ ops::paste(v, at(v, &["A", "u"]), "- pasted\n", true).unwrap();
160+ });
161+}
162+
163+#[test]
164+fn capture_keeps_a_sync_it_did_not_make() {
165+ verb_keeps_a_sync(|v| {
166+ ops::capture(v, "call the plumber", false).unwrap();
167+ });
168+}
169+
170+#[test]
171+fn a_new_child_keeps_a_sync_it_did_not_make() {
172+ verb_keeps_a_sync(|v| {
173+ ops::append_child_public(v, at(v, &["B"]), "new").unwrap();
174+ });
175+}
176+
177+#[test]
178+fn respelling_keeps_a_sync_it_did_not_make() {
179+ verb_keeps_a_sync(|v| {
180+ ops::toggle_spelling(v, at(v, &["B"])).unwrap();
181+ });
182+}
183+
184+#[test]
185+fn canonicalizing_keeps_a_sync_it_did_not_make() {
186+ // a non-canonical bullet to rewrite, and a block whose name is stale,
187+ // to rename (§4.2, §6.4)
188+ let (d, mut v) = vault_with("# A\n\n* t\n\n![[racfer-hattes-mislup-nodrys]]\n![[dozzod-binwes-talsun-worbec]]\n");
189+ let block = "---\nid: dozzod-binwes-talsun-worbec\n---\n\n- renamed since\n";
190+ std::fs::write(d.path().join("dozzod~old-name.md"), block).unwrap();
191+ v.reload().unwrap();
192+ keeps_a_sync_it_did_not_make(&d, &mut v, |v| {
193+ assert_eq!(fold_core::check::fix(v).unwrap(), 2);
194+ // the index knows the file by its new name
195+ assert!(v.file_index("dozzod~renamed-since.md").is_some());
196+ assert!(v.file_index("dozzod~old-name.md").is_none());
197+ });
198+ assert_eq!(read(&d, "dozzod~old-name.md"), block);
199+}
200+
201+#[test]
202+fn keeping_theirs_keeps_a_sync_it_did_not_make() {
203+ // ours is a block, its conflict copy right after it (§12.4)
204+ let (d, mut v) = vault_with(
205+ "- other\n![[racfer-hattes-mislup-nodrys]]\n![[dozzod-binwes-talsun-worbec]]\n![[lacnum-walbyn-dirlyn-havtyp]]\n",
206+ );
207+ std::fs::write(d.path().join("dozzod~ours.md"), "---\nid: dozzod-binwes-talsun-worbec\ndue: 2026-10-01\n---\n\n- ours\n").unwrap();
208+ std::fs::write(
209+ d.path().join("lacnum~ours.md"),
210+ "---\nid: lacnum-walbyn-dirlyn-havtyp\nconflict: \"PHONE 20260927-100000\"\n---\n\n- theirs\n",
211+ )
212+ .unwrap();
213+ v.reload().unwrap();
214+ keeps_a_sync_it_did_not_make(&d, &mut v, |v| {
215+ let (ours, theirs) = fold_core::merge::conflict_pairs(v)[0];
216+ fold_core::merge::resolve_keep_theirs(v, ours, theirs).unwrap();
217+ });
218+ assert_eq!(read(&d, "lacnum~ours.md").lines().last(), Some("- theirs"));
219+}
220+
221+#[test]
222+fn the_op_log_steps_back_and_forth() {
223+ let (d, mut v) = vault_with("# A\n\n- [ ] t\n");
224+ let mut log = OpLog::default();
225+ assert!(log.step(&mut v, true).is_none(), "nothing to undo");
226+ let snap = Snapshot::take(&v, "mark “t” done");
227+ let t = at(&v, &["A", "t"]);
228+ ops::toggle_task(&mut v, t).unwrap();
229+ assert!(log.record(Inverse::since(snap, &v)));
230+ // a verb that changed nothing leaves no entry
231+ assert!(!log.record(Inverse::since(Snapshot::take(&v, "nothing"), &v)));
232+ assert_eq!(log.depth(), 1);
233+ assert_eq!(log.step(&mut v, true).unwrap().unwrap(), "mark “t” done");
234+ assert_eq!(read(&d, "root.md"), "# A\n\n- [ ] t\n");
235+ assert_eq!(log.last_redo().map(|e| e.description.as_str()), Some("mark “t” done"));
236+ log.step(&mut v, false).unwrap().unwrap();
237+ assert_eq!(read(&d, "root.md"), "# A\n\n- [x] t\n");
238+ // refused when the file changed since: the entry stays
239+ std::fs::write(d.path().join("root.md"), "# A\n\n- [x] t\n- more\n").unwrap();
240+ assert!(log.step(&mut v, true).unwrap().is_err());
241+ assert_eq!(log.depth(), 1);
242+}
crates/fold-ffi/Cargo.tomlnew file+28
@@ -0,0 +1,28 @@
1+[package]
2+name = "fold-ffi"
3+version = "0.1.0"
4+edition = "2021"
5+description = "fold-core for other languages: the session API the Android app calls, through UniFFI"
6+
7+[lib]
8+crate-type = ["cdylib", "lib"]
9+name = "fold_ffi"
10+
11+[[bin]]
12+# generates the Kotlin bindings from the built library:
13+# cargo run -p fold-ffi --features bindgen --bin uniffi-bindgen -- generate --library …
14+name = "uniffi-bindgen"
15+path = "uniffi-bindgen.rs"
16+required-features = ["bindgen"]
17+
18+[features]
19+bindgen = ["uniffi/cli"]
20+
21+[dependencies]
22+fold-core = { path = "../fold-core" }
23+uniffi = "0.32"
24+thiserror.workspace = true
25+
26+[dev-dependencies]
27+tempfile = "3"
28+jiff.workspace = true
crates/fold-ffi/src/edit.rsnew file+563
@@ -0,0 +1,563 @@
1+//! The editor behind a platform text field (§10.6).
2+//!
3+//! A text field hands over its whole text after every change. `apply`
4+//! finds the one span that changed and replays it on the tagged buffer
5+//! (`fold_core::edit::EditBuffer`) through the same rules the TUI's editor
6+//! follows (`fold-tui/src/app/editor/mod.rs`): every line keeps or inherits
7+//! the tag of the block that owns it (§5.2), whole lines cut with a nested
8+//! block's title line hold the block in transit, and pasting those lines
9+//! back puts the block where they land.
10+
11+use fold_core::edit::{EditBuffer, EditLine, Owner};
12+use std::collections::{BTreeMap, BTreeSet};
13+use std::time::{Duration, Instant};
14+
15+/// A position: line, and column in characters.
16+#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord, Default)]
17+pub struct Pos {
18+ pub line: usize,
19+ pub col: usize,
20+}
21+
22+impl Pos {
23+ pub fn new(line: usize, col: usize) -> Pos {
24+ Pos { line, col }
25+ }
26+}
27+
28+/// Whole lines cut in this editor, and the block of each line whose title
29+/// line was cut with it (§5.2: cut and paste moves its embed).
30+#[derive(Clone, Debug)]
31+struct Clip {
32+ text: String,
33+ tags: Vec<Option<Owner>>,
34+}
35+
36+/// The text before a change, for the editor's own undo.
37+struct Snap {
38+ lines: Vec<EditLine>,
39+ parents: BTreeMap<Owner, Option<Owner>>,
40+}
41+
42+/// Small changes this close together are one undo step (typing).
43+const TYPING: Duration = Duration::from_secs(2);
44+
45+pub struct TextEditor {
46+ pub buf: EditBuffer,
47+ clip: Option<Clip>,
48+ undo: Vec<Snap>,
49+ redo: Vec<Snap>,
50+ /// When the last small change (a character typed or erased) was made:
51+ /// the next one within `TYPING` joins its undo step.
52+ typing: Option<Instant>,
53+}
54+
55+impl TextEditor {
56+ pub fn new(buf: EditBuffer) -> TextEditor {
57+ TextEditor {
58+ buf,
59+ clip: None,
60+ undo: Vec::new(),
61+ redo: Vec::new(),
62+ typing: None,
63+ }
64+ }
65+
66+ /// The buffer's text, lines joined by `\n`.
67+ pub fn text(&self) -> String {
68+ self.buf.lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>().join("\n")
69+ }
70+
71+ pub fn dirty(&self) -> bool {
72+ !self.buf.dirty.is_empty()
73+ }
74+
75+ /// Take in the text field's new text. `cursor`, the caret after the
76+ /// change in characters, decides where a change that could have been
77+ /// made in more than one place was made: Enter at the end of a line or
78+ /// at the start of the next leaves the same text, but the new line
79+ /// belongs to a different block (§5.2). False when nothing changed.
80+ pub fn apply(&mut self, new: &str, cursor: Option<usize>) -> bool {
81+ let old: Vec<char> = self.text().chars().collect();
82+ let new: Vec<char> = new.chars().collect();
83+ if old == new {
84+ return false;
85+ }
86+ let mut p = old.iter().zip(&new).take_while(|(a, b)| a == b).count();
87+ let room = old.len().min(new.len()) - p;
88+ let s = old.iter().rev().zip(new.iter().rev()).take(room).take_while(|(a, b)| a == b).count();
89+ let (del, ins) = (old.len() - p - s, new.len() - p - s);
90+ // where the caret says the change was, if the text agrees
91+ if let Some(c) = cursor {
92+ let start = if del == 0 { c.checked_sub(ins) } else if ins == 0 { Some(c) } else { None };
93+ if let Some(start) = start.filter(|&st| st != p) {
94+ let fits = start + del <= old.len()
95+ && start + ins <= new.len()
96+ && old[..start] == new[..start]
97+ && old[start + del..] == new[start + ins..];
98+ if fits {
99+ p = start;
100+ }
101+ }
102+ }
103+ let small = del <= 1 && ins <= 1;
104+ let joins = small && self.typing.is_some_and(|t| t.elapsed() < TYPING);
105+ if !joins {
106+ self.checkpoint();
107+ }
108+ self.typing = small.then(Instant::now);
109+ let at = pos_of(&old, p);
110+ if del > 0 {
111+ let end = pos_of(&old, p + del);
112+ self.remove(at, end);
113+ }
114+ if ins > 0 {
115+ let s: String = new[p..p + ins].iter().collect();
116+ self.put(at, &s);
117+ }
118+ true
119+ }
120+
121+ /// Delete `[a, b)`: whole lines, from the start of one to the start of
122+ /// another, that hold a nested block's title line are cut as the TUI's
123+ /// linewise cut is, so the block is held in transit until they are
124+ /// pasted back (§5.2); anything else is deleted.
125+ fn remove(&mut self, a: Pos, b: Pos) {
126+ if a.col == 0 && b.col == 0 && b.line > a.line && self.has_nested_title(a.line, b.line - 1) {
127+ self.cut_lines(a.line, b.line - 1);
128+ } else {
129+ self.delete(a, b);
130+ }
131+ }
132+
133+ /// Insert `s` at `at`: the lines the last cut took, put back in front of
134+ /// a line, go back with their tags (§5.2); anything else is typed.
135+ fn put(&mut self, at: Pos, s: &str) {
136+ let clip = self.clip.clone().filter(|c| c.tags.iter().any(|t| t.is_some()));
137+ let body = clip.as_ref().map(|c| c.text.strip_suffix('\n').unwrap_or(&c.text).to_string());
138+ match (clip, body) {
139+ // in front of a line: the lines go above it
140+ (Some(clip), _) if at.col == 0 && clip.text == s => self.put_clip_lines(at.line, &clip, false),
141+ // a line break and the lines at the end of a line: below it
142+ (Some(clip), Some(body)) if at.col == self.len(at.line) && s.strip_prefix('\n') == Some(body.as_str()) => {
143+ self.put_clip_lines(at.line, &clip, true)
144+ }
145+ _ => {
146+ self.insert(at, s);
147+ }
148+ }
149+ }
150+
151+ fn has_nested_title(&self, l1: usize, l2: usize) -> bool {
152+ let titles = self.titles();
153+ (l1..=l2.min(self.lines() - 1)).any(|l| {
154+ let o = self.buf.lines[l].owner;
155+ self.nested(o) && titles.get(&o) == Some(&l)
156+ })
157+ }
158+
159+ fn nested(&self, o: Owner) -> bool {
160+ self.buf.owners.get(&o).is_some_and(|i| i.parent.is_some())
161+ }
162+
163+ // ------------------------------------------------------------ text
164+
165+ fn lines(&self) -> usize {
166+ self.buf.lines.len().max(1)
167+ }
168+
169+ fn line(&self, l: usize) -> &str {
170+ self.buf.lines.get(l).map(|x| x.text.as_str()).unwrap_or("")
171+ }
172+
173+ fn len(&self, l: usize) -> usize {
174+ self.line(l).chars().count()
175+ }
176+
177+ /// The text of `[a, b)`.
178+ fn text_between(&self, a: Pos, b: Pos) -> String {
179+ let (a, b) = order(a, b);
180+ if a.line == b.line {
181+ let l = self.line(a.line);
182+ return l.chars().skip(a.col).take(b.col.saturating_sub(a.col)).collect();
183+ }
184+ let mut s: String = self.line(a.line).chars().skip(a.col).collect();
185+ for l in a.line + 1..b.line {
186+ s.push('\n');
187+ s.push_str(self.line(l));
188+ }
189+ s.push('\n');
190+ s.extend(self.line(b.line).chars().take(b.col));
191+ s
192+ }
193+
194+ /// Insert text at a position; returns the position after it. New lines
195+ /// belong to the block of the line they follow (§5.2): the line they
196+ /// split, or at column 0 the line above, so a line opened or pasted above
197+ /// a nested block's title line is not written into that block.
198+ fn insert(&mut self, at: Pos, s: &str) -> Pos {
199+ if s.is_empty() {
200+ return at;
201+ }
202+ if self.buf.lines.is_empty() {
203+ self.buf.lines.push(EditLine { text: String::new(), owner: Owner { file: 0, block_ord: 0 } });
204+ }
205+ let line = self.line(at.line).to_string();
206+ let b = byte(&line, at.col);
207+ let (pre, post) = line.split_at(b);
208+ let parts: Vec<&str> = s.split('\n').collect();
209+ if parts.len() == 1 {
210+ self.buf.set_line(at.line, format!("{}{}{}", pre, s, post));
211+ return Pos::new(at.line, at.col + s.chars().count());
212+ }
213+ let owner = self.buf.lines[at.line].owner;
214+ let block_title = self.nested(owner) && !self.buf.lines[..at.line].iter().any(|l| l.owner == owner);
215+ if at.col == 0 && at.line > 0 && block_title && !(parts[0].is_empty() && post.is_empty()) {
216+ // in front of a nested block's title line, whole lines go in
217+ // after the line above, taking its tag: they are not the block's
218+ // text; the title line keeps its own tag and only gains the last
219+ // part
220+ let mut l = at.line - 1;
221+ for p in &parts[..parts.len() - 1] {
222+ self.buf.insert_line(l, p.to_string());
223+ l += 1;
224+ }
225+ let last = parts[parts.len() - 1];
226+ self.buf.set_line(l + 1, format!("{}{}", last, post));
227+ return Pos::new(l + 1, last.chars().count());
228+ }
229+ self.buf.set_line(at.line, format!("{}{}", pre, parts[0]));
230+ let mut l = at.line;
231+ for p in &parts[1..parts.len() - 1] {
232+ self.buf.insert_line(l, p.to_string());
233+ l += 1;
234+ }
235+ let last = parts[parts.len() - 1];
236+ self.buf.insert_line(l, format!("{}{}", last, post));
237+ Pos::new(l + 1, last.chars().count())
238+ }
239+
240+ /// Delete `[a, b)`; returns what was deleted. A line deleted whole takes
241+ /// its tag with it (§5.2): what is left of a joined line keeps the tag of
242+ /// line `a`, unless the range starts at column 0 and leaves some of line
243+ /// `b`, or ends at its start (short of the end of the text): then line
244+ /// `b` is what is left. A range from column 0 through the end of line
245+ /// `b` deletes lines `a` to `b` whole, and the empty line left keeps the
246+ /// tag of a block whose title line was not among them (`kept`).
247+ fn delete(&mut self, a: Pos, b: Pos) -> String {
248+ let (a, mut b) = order(a, b);
249+ if b.line >= self.lines() {
250+ b = Pos::new(self.lines() - 1, self.len(self.lines() - 1));
251+ }
252+ let gone = self.text_between(a, b);
253+ if gone.is_empty() {
254+ return gone;
255+ }
256+ let first = self.line(a.line).to_string();
257+ let last = self.line(b.line).to_string();
258+ let rest = &last[byte(&last, b.col)..];
259+ if a.col == 0 && a.line < b.line && (!rest.is_empty() || (b.col == 0 && b.line + 1 < self.lines())) {
260+ for l in (a.line..b.line).rev() {
261+ self.buf.delete_line(l);
262+ }
263+ self.buf.set_line(a.line, rest.to_string());
264+ return gone;
265+ }
266+ let whole = (a.col == 0 && a.line < b.line).then(|| self.kept(a.line, b.line));
267+ let joined = format!("{}{}", &first[..byte(&first, a.col)], rest);
268+ for l in (a.line + 1..=b.line).rev() {
269+ self.buf.delete_line(l);
270+ }
271+ self.buf.set_line(a.line, joined);
272+ if let Some(o) = whole {
273+ let was = std::mem::replace(&mut self.buf.lines[a.line].owner, o);
274+ self.buf.mark_dirty(was);
275+ self.buf.mark_dirty(o);
276+ }
277+ gone
278+ }
279+
280+ /// The block the empty line left where lines `l1..=l2` are deleted whole
281+ /// belongs to: one whose title line (§5.2) is not among them — line
282+ /// `l1`'s, else line `l2`'s, else the block they sit in.
283+ fn kept(&self, l1: usize, l2: usize) -> Owner {
284+ let titles = self.titles();
285+ let gone = |o: Owner| self.nested(o) && titles.get(&o).is_some_and(|t| (l1..=l2).contains(t));
286+ let (mut o, last) = (self.buf.lines[l1].owner, self.buf.lines[l2].owner);
287+ if gone(o) && !gone(last) {
288+ return last;
289+ }
290+ while gone(o) {
291+ match self.buf.owners.get(&o).and_then(|i| i.parent) {
292+ Some(p) => o = p,
293+ None => break,
294+ }
295+ }
296+ o
297+ }
298+
299+ /// Delete whole lines `l1..=l2`; returns them, each ending in `\n`.
300+ fn delete_lines(&mut self, l1: usize, l2: usize) -> String {
301+ let l2 = l2.min(self.lines() - 1);
302+ let mut gone = String::new();
303+ for l in l1..=l2 {
304+ gone.push_str(self.line(l));
305+ gone.push('\n');
306+ }
307+ if l1 == 0 && l2 + 1 >= self.lines() {
308+ // the buffer keeps one (empty) line
309+ for l in (1..self.lines()).rev() {
310+ self.buf.delete_line(l);
311+ }
312+ self.buf.set_line(0, String::new());
313+ } else {
314+ for l in (l1..=l2).rev() {
315+ self.buf.delete_line(l);
316+ }
317+ }
318+ gone
319+ }
320+
321+ /// Cut whole lines `l1..=l2`. A nested block whose title line goes
322+ /// keeps its tag in the clip, and its lines left behind go to the block
323+ /// they now sit in, so pasting the lines moves the block (§5.2).
324+ fn cut_lines(&mut self, l1: usize, l2: usize) {
325+ let l2 = l2.min(self.lines() - 1);
326+ let titles = self.titles();
327+ let tags: Vec<Option<Owner>> = (l1..=l2)
328+ .map(|l| {
329+ let o = self.buf.lines.get(l)?.owner;
330+ (self.nested(o) && titles.get(&o).is_some_and(|&t| t >= l1)).then_some(o)
331+ })
332+ .collect();
333+ let text = self.delete_lines(l1, l2);
334+ let n = l2 + 1 - l1;
335+ let left = titles
336+ .into_iter()
337+ .map(|(o, t)| (o, if t < l1 { Some(t) } else if t > l2 { Some(t - n) } else { None }))
338+ .collect();
339+ self.settle(&left);
340+ self.set_clip(text, tags);
341+ }
342+
343+ /// Put the clip's whole lines above or below line `l`. They take the tag
344+ /// of the line above (§5.2), except the lines of a nested block cut with
345+ /// its title line and no longer in the buffer: those go back with their
346+ /// own tag, so the block moves.
347+ fn put_clip_lines(&mut self, l: usize, clip: &Clip, below: bool) {
348+ let titles = self.titles();
349+ let first = if below {
350+ let body = clip.text.strip_suffix('\n').unwrap_or(&clip.text);
351+ self.insert(Pos::new(l, self.len(l)), &format!("\n{}", body));
352+ l + 1
353+ } else {
354+ self.insert(Pos::new(l, 0), &clip.text);
355+ l
356+ };
357+ let n = clip.tags.len();
358+ if n == 0 || clip.text.matches('\n').count() != n || first + n > self.buf.lines.len() {
359+ return;
360+ }
361+ let present: BTreeSet<Owner> = self
362+ .buf
363+ .lines
364+ .iter()
365+ .enumerate()
366+ .filter(|(i, _)| *i < first || *i >= first + n)
367+ .map(|(_, x)| x.owner)
368+ .collect();
369+ let mut back: BTreeMap<Owner, Option<usize>> = BTreeMap::new();
370+ for (i, tag) in clip.tags.iter().enumerate() {
371+ let Some(o) = tag.filter(|o| !present.contains(o) && self.buf.owners.contains_key(o)) else { continue };
372+ let was = std::mem::replace(&mut self.buf.lines[first + i].owner, o);
373+ self.buf.mark_dirty(was);
374+ self.touch(o);
375+ back.entry(o).or_insert(Some(first + i));
376+ }
377+ if !back.is_empty() {
378+ for (o, t) in titles {
379+ back.insert(o, Some(if t >= first { t + n } else { t }));
380+ }
381+ self.settle(&back);
382+ }
383+ }
384+
385+ /// The first line of each block: its title line (§5.2).
386+ fn titles(&self) -> BTreeMap<Owner, usize> {
387+ let mut t = BTreeMap::new();
388+ for (i, l) in self.buf.lines.iter().enumerate() {
389+ t.entry(l.owner).or_insert(i);
390+ }
391+ t
392+ }
393+
394+ /// Whether block `o` is `outer` or nested in it.
395+ fn within(&self, mut o: Owner, outer: Owner) -> bool {
396+ while o != outer {
397+ match self.buf.owners.get(&o).and_then(|i| i.parent) {
398+ Some(p) => o = p,
399+ None => return false,
400+ }
401+ }
402+ true
403+ }
404+
405+ /// A block changed and moved: it is dirty, and so is the block its embed
406+ /// sits in (§5.2).
407+ fn touch(&mut self, o: Owner) {
408+ self.buf.mark_dirty(o);
409+ if let Some(p) = self.buf.owners.get(&o).and_then(|i| i.parent) {
410+ self.buf.mark_dirty(p);
411+ }
412+ }
413+
414+ /// After lines moved with their tags, each goes with the block it now
415+ /// sits in: `retag_strays`, then each nested block's embed goes to the
416+ /// block its title line sits in (`EditBuffer::reparent`).
417+ fn settle(&mut self, titles: &BTreeMap<Owner, Option<usize>>) {
418+ self.retag_strays(titles);
419+ let now = self.titles();
420+ self.buf.reparent(&now);
421+ }
422+
423+ /// A line of a nested block that is no longer contiguous with the
424+ /// block's title line (at `titles`, or gone) is re-tagged to the block it
425+ /// now sits in, that of the line above (§5.2).
426+ fn retag_strays(&mut self, titles: &BTreeMap<Owner, Option<usize>>) {
427+ let n = self.buf.lines.len();
428+ for (&o, &title) in titles {
429+ if !self.nested(o) {
430+ continue;
431+ }
432+ let (t, mut end) = match title {
433+ Some(t) => (t, t + 1),
434+ None => (n, n),
435+ };
436+ while end < n && self.within(self.buf.lines[end].owner, o) {
437+ end += 1;
438+ }
439+ for i in 1..n {
440+ if (i < t || i >= end) && self.buf.lines[i].owner == o {
441+ let above = self.buf.lines[i - 1].owner;
442+ self.buf.lines[i].owner = above;
443+ self.buf.mark_dirty(above);
444+ self.buf.mark_dirty(o);
445+ }
446+ }
447+ }
448+ }
449+
450+ /// The block each block's embed sits in.
451+ fn parents(&self) -> BTreeMap<Owner, Option<Owner>> {
452+ self.buf.owners.iter().map(|(&o, i)| (o, i.parent)).collect()
453+ }
454+
455+ /// What splice writes for each block (§5.2): its own lines, and the
456+ /// first line of each block nested in it, where that block's embed goes.
457+ fn splice_views(
458+ lines: &[EditLine],
459+ parents: &BTreeMap<Owner, Option<Owner>>,
460+ ) -> BTreeMap<Owner, Vec<(Owner, String)>> {
461+ let mut views: BTreeMap<Owner, Vec<(Owner, String)>> = BTreeMap::new();
462+ let mut seen = BTreeSet::new();
463+ for l in lines {
464+ views.entry(l.owner).or_default().push((l.owner, l.text.clone()));
465+ let mut o = l.owner;
466+ while let Some(p) = parents.get(&o).copied().flatten() {
467+ if seen.insert(o) {
468+ views.entry(p).or_default().push((o, l.text.clone()));
469+ }
470+ o = p;
471+ }
472+ }
473+ views
474+ }
475+
476+ // ------------------------------------------------------------ clip
477+
478+ /// The blocks whose title lines the clip holds are in transit until
479+ /// they are pasted back or the clip is replaced: the buffer holds them,
480+ /// so a save meanwhile does not delete them (§5.2).
481+ fn set_clip(&mut self, text: String, tags: Vec<Option<Owner>>) {
482+ self.buf.hold(tags.iter().flatten().copied().collect());
483+ self.clip = Some(Clip { text, tags });
484+ }
485+
486+ /// Leaving the editor: a block whose title line was cut and not pasted
487+ /// back is deleted now (§5.2).
488+ pub fn release_clip(&mut self) {
489+ self.clip = None;
490+ self.buf.hold(Vec::new());
491+ }
492+
493+ // ------------------------------------------------------------ undo
494+
495+ fn checkpoint(&mut self) {
496+ self.redo.clear();
497+ self.undo.push(Snap { lines: self.buf.lines.clone(), parents: self.parents() });
498+ }
499+
500+ pub fn undo(&mut self) -> bool {
501+ let Some(s) = self.undo.pop() else { return false };
502+ let cur = self.restore(s);
503+ self.redo.push(cur);
504+ true
505+ }
506+
507+ pub fn redo(&mut self) -> bool {
508+ let Some(s) = self.redo.pop() else { return false };
509+ let cur = self.restore(s);
510+ self.undo.push(cur);
511+ true
512+ }
513+
514+ pub fn can_undo(&self) -> bool {
515+ !self.undo.is_empty()
516+ }
517+
518+ pub fn can_redo(&self) -> bool {
519+ !self.redo.is_empty()
520+ }
521+
522+ /// Put a snapshot back; every block whose text differs is dirty again,
523+ /// and so is every block a nested block moved in.
524+ fn restore(&mut self, s: Snap) -> Snap {
525+ self.typing = None;
526+ let cur = Snap { lines: std::mem::take(&mut self.buf.lines), parents: self.parents() };
527+ let was = Self::splice_views(&cur.lines, &cur.parents);
528+ let now = Self::splice_views(&s.lines, &s.parents);
529+ for o in was.keys().chain(now.keys()) {
530+ if was.get(o) != now.get(o) {
531+ self.buf.mark_dirty(*o);
532+ }
533+ }
534+ self.buf.lines = s.lines;
535+ for (o, p) in s.parents {
536+ if let Some(i) = self.buf.owners.get_mut(&o) {
537+ i.parent = p;
538+ }
539+ }
540+ cur
541+ }
542+}
543+
544+/// The position of character `i` of `text`.
545+fn pos_of(text: &[char], i: usize) -> Pos {
546+ let before = &text[..i.min(text.len())];
547+ let line = before.iter().filter(|&&c| c == '\n').count();
548+ let col = before.iter().rev().take_while(|&&c| c != '\n').count();
549+ Pos::new(line, col)
550+}
551+
552+fn order(a: Pos, b: Pos) -> (Pos, Pos) {
553+ if a <= b {
554+ (a, b)
555+ } else {
556+ (b, a)
557+ }
558+}
559+
560+/// Byte offset of a character column.
561+fn byte(s: &str, col: usize) -> usize {
562+ s.char_indices().nth(col).map(|(i, _)| i).unwrap_or(s.len())
563+}
crates/fold-ffi/src/keys.rsnew file+72
@@ -0,0 +1,72 @@
1+//! Node keys as strings (§3.4): what the app holds to name a node across
2+//! re-parses — the selection, the zoom, the folds — opaque to it.
3+
4+use fold_core::vault::NodeKey;
5+use fold_core::Id;
6+
7+/// Between the steps of a path key, and between a step's ordinal and its
8+/// title: control characters no title line holds.
9+const STEP: char = '\u{1f}';
10+const ORDINAL: char = '\u{1e}';
11+
12+pub fn encode(k: &NodeKey) -> String {
13+ match k {
14+ NodeKey::Root => "r".into(),
15+ NodeKey::Id(id) => format!("i{}", id),
16+ NodeKey::Path { block, steps } => {
17+ let mut s = String::from("p");
18+ if let Some(b) = block {
19+ s.push_str(b.as_str());
20+ }
21+ for (title, ordinal) in steps {
22+ s.push(STEP);
23+ s.push_str(&ordinal.to_string());
24+ s.push(ORDINAL);
25+ s.push_str(title);
26+ }
27+ s
28+ }
29+ }
30+}
31+
32+pub fn decode(s: &str) -> Option<NodeKey> {
33+ match s.chars().next()? {
34+ 'r' if s.len() == 1 => Some(NodeKey::Root),
35+ 'i' => Id::parse(&s[1..]).map(NodeKey::Id),
36+ 'p' => {
37+ let mut parts = s[1..].split(STEP);
38+ let block = match parts.next()? {
39+ "" => None,
40+ b => Some(Id::parse(b)?),
41+ };
42+ let steps = parts
43+ .map(|step| {
44+ let (ordinal, title) = step.split_once(ORDINAL)?;
45+ Some((title.to_string(), ordinal.parse().ok()?))
46+ })
47+ .collect::<Option<Vec<_>>>()?;
48+ Some(NodeKey::Path { block, steps })
49+ }
50+ _ => None,
51+ }
52+}
53+
54+#[cfg(test)]
55+mod tests {
56+ use super::*;
57+
58+ #[test]
59+ fn round_trip() {
60+ let id = Id::parse("racfer-hattes-mislup-nodrys").unwrap();
61+ for k in [
62+ NodeKey::Root,
63+ NodeKey::Id(id.clone()),
64+ NodeKey::Path { block: None, steps: vec![("Homelab".into(), 0), ("".into(), 2)] },
65+ NodeKey::Path { block: Some(id), steps: vec![("a/b · “c”".into(), 1)] },
66+ ] {
67+ assert_eq!(decode(&encode(&k)), Some(k));
68+ }
69+ assert_eq!(decode("x"), None);
70+ assert_eq!(decode("inot-an-id"), None);
71+ }
72+}
crates/fold-ffi/src/lib.rsnew file+1903
@@ -0,0 +1,1903 @@
1+//! fold-ffi: fold-core for other languages, through UniFFI. It is what the
2+//! Android app (`android/`) calls: one `Session` over a vault, holding what
3+//! the TUI holds between keys (the op log, the register, the open editor)
4+//! and answering in the terms a screen needs — outline rows, reading lines,
5+//! a message after every verb. Nodes are named by keys (§3.4), strings the
6+//! app keeps and hands back; every write goes through fold-core, so the
7+//! files come out exactly as the TUI and the CLI write them.
8+
9+mod edit;
10+mod keys;
11+
12+use edit::TextEditor;
13+use fold_core::edit::{open_editor, OwnerInfo};
14+use fold_core::ident::Id;
15+use fold_core::news;
16+use fold_core::ops;
17+use fold_core::parse::{Kind, TaskState};
18+use fold_core::render::{render, render_lines, LineKind};
19+use fold_core::tree::NRef;
20+use fold_core::vault::{NodeKey, Vault};
21+use std::path::{Path, PathBuf};
22+use std::sync::{Arc, Mutex, MutexGuard};
23+
24+uniffi::setup_scaffolding!();
25+
26+// ------------------------------------------------------------ the types
27+
28+#[derive(Debug, thiserror::Error, uniffi::Error)]
29+#[uniffi(flat_error)]
30+pub enum FoldError {
31+ #[error("{0}")]
32+ Failed(String),
33+}
34+
35+/// A node's checkbox (§4.5).
36+#[derive(uniffi::Enum, Clone, Copy, Debug, PartialEq, Eq)]
37+pub enum Task {
38+ None,
39+ Open,
40+ Done,
41+}
42+
43+/// How a node is spelled (§3.1).
44+#[derive(uniffi::Enum, Clone, Copy, Debug, PartialEq, Eq)]
45+pub enum Spelling {
46+ Section,
47+ Item,
48+}
49+
50+/// What a verb did, in the status bar's words (§10.1).
51+#[derive(uniffi::Record, Clone, Debug)]
52+pub struct OpResult {
53+ pub ok: bool,
54+ pub message: String,
55+ /// The node the verb acted on or made, where it is now.
56+ pub node: Option<String>,
57+ /// A verb run while the editor is open saves it first and re-renders it
58+ /// over what it wrote (§10.6): its new text, the generation the text
59+ /// field's updates now name, and whether its node is gone and it closed.
60+ pub editor_text: Option<String>,
61+ pub editor_generation: u32,
62+ pub editor_closed: bool,
63+}
64+
65+impl OpResult {
66+ fn new(ok: bool, message: impl Into<String>, node: Option<String>) -> OpResult {
67+ OpResult {
68+ ok,
69+ message: message.into(),
70+ node,
71+ editor_text: None,
72+ editor_generation: 0,
73+ editor_closed: false,
74+ }
75+ }
76+}
77+
78+/// The op-log entry an undo is meant for: the one a message offered to
79+/// undo, so a tap on it never undoes a later change (§10.10).
80+#[derive(uniffi::Record, Clone, Debug)]
81+pub struct UndoMark {
82+ /// How many entries the op log held with it on top.
83+ pub depth: u32,
84+ pub description: String,
85+}
86+
87+#[derive(uniffi::Record, Clone, Debug)]
88+pub struct OutlineQuery {
89+ /// The zoom root; none for the whole vault.
90+ pub zoom: Option<String>,
91+ /// Folded nodes (§10.1).
92+ pub folded: Vec<String>,
93+ /// Conflict copies unfolded: a copy starts folded (§10.1).
94+ pub unfolded: Vec<String>,
95+ /// `zd`: done tasks hidden (§8.5).
96+ pub hide_done: bool,
97+}
98+
99+/// One row of the outline (§10.1).
100+#[derive(uniffi::Record, Clone, Debug)]
101+pub struct Row {
102+ pub key: String,
103+ pub title: String,
104+ pub depth: u32,
105+ pub spelling: Spelling,
106+ pub task: Task,
107+ /// Has its own file (`▤`).
108+ pub block: bool,
109+ /// A conflict copy: whose copy it is, *PHONE 09-27 10:00* (§12.5).
110+ pub conflict: Option<String>,
111+ /// An embed whose block is missing or embedded elsewhere (§6.2).
112+ pub broken: bool,
113+ pub has_children: bool,
114+ pub folded: bool,
115+ /// Open and total tasks below it, itself not counted (§3.5).
116+ pub open: u32,
117+ pub total: u32,
118+ pub due: Option<String>,
119+ /// The first line of its own text (§10.1).
120+ pub preview: String,
121+}
122+
123+#[derive(uniffi::Record, Clone, Debug)]
124+pub struct Crumb {
125+ pub key: String,
126+ pub title: String,
127+}
128+
129+#[derive(uniffi::Record, Clone, Debug)]
130+pub struct Property {
131+ pub key: String,
132+ pub value: String,
133+ /// False for a frontmatter line fold does not understand: kept as it
134+ /// is, never edited (§3.2).
135+ pub editable: bool,
136+}
137+
138+/// The zoom root, shown above its children.
139+#[derive(uniffi::Record, Clone, Debug)]
140+pub struct Header {
141+ pub key: String,
142+ pub title: String,
143+ pub spelling: Spelling,
144+ pub task: Task,
145+ pub block: bool,
146+ pub conflict: Option<String>,
147+ pub due: Option<String>,
148+ pub open: u32,
149+ pub total: u32,
150+ /// Its body: the text before its first child (§3.3), as rendered.
151+ pub body: Vec<String>,
152+ pub props: Vec<Property>,
153+}
154+
155+#[derive(uniffi::Record, Clone, Debug)]
156+pub struct Outline {
157+ pub zoom: Option<Header>,
158+ pub crumbs: Vec<Crumb>,
159+ pub rows: Vec<Row>,
160+ /// Unresolved conflict pairs (§12.5).
161+ pub conflicts: u32,
162+ /// What undo and redo would do, named (§10.10).
163+ pub undo: Option<String>,
164+ pub redo: Option<String>,
165+ /// The op log's depth, for `UndoMark`.
166+ pub undo_depth: u32,
167+ /// What the register holds, for *Paste*.
168+ pub copied: Option<String>,
169+}
170+
171+#[derive(uniffi::Record, Clone, Debug)]
172+pub struct NodeInfo {
173+ pub key: String,
174+ pub title: String,
175+ pub spelling: Spelling,
176+ pub task: Task,
177+ pub block: bool,
178+ pub conflict: Option<String>,
179+ /// One side of a conflict pair (§12.5).
180+ pub paired: bool,
181+ pub path: Vec<Crumb>,
182+}
183+
184+#[derive(uniffi::Enum, Clone, Copy, Debug, PartialEq, Eq)]
185+pub enum ReadKind {
186+ Title,
187+ Body,
188+ Embed,
189+ Blank,
190+}
191+
192+/// One line of the reading view: `render(node, 1, true)` (§5.1).
193+#[derive(uniffi::Record, Clone, Debug)]
194+pub struct ReadLine {
195+ pub kind: ReadKind,
196+ pub text: String,
197+ pub node: Option<String>,
198+ /// For a title line: its indent, heading level (0 for a bullet), task
199+ /// and title, and a block's properties as one dimmed line (§10.1).
200+ pub indent: u32,
201+ pub heading: u32,
202+ pub task: Task,
203+ pub title: String,
204+ pub props: Option<String>,
205+ pub conflict: Option<String>,
206+}
207+
208+#[derive(uniffi::Record, Clone, Debug)]
209+pub struct Hit {
210+ pub key: String,
211+ pub title: String,
212+ /// Its ancestors' titles, `Homelab › NAS`.
213+ pub path: String,
214+ /// Its parent in the outline; none at the top.
215+ pub parent: Option<String>,
216+ pub task: Task,
217+ pub conflict: bool,
218+ /// For a match in its text rather than its title: that line.
219+ pub excerpt: Option<String>,
220+}
221+
222+#[derive(uniffi::Record, Clone, Debug)]
223+pub struct ConflictPair {
224+ pub ours: String,
225+ pub theirs: String,
226+ pub title: String,
227+ pub from: String,
228+ pub ours_text: String,
229+ pub theirs_text: String,
230+}
231+
232+#[derive(uniffi::Enum, Clone, Copy, Debug, PartialEq, Eq)]
233+pub enum Keep {
234+ Ours,
235+ Theirs,
236+ Both,
237+}
238+
239+/// What a look at the files found (§11.2).
240+#[derive(uniffi::Record, Clone, Debug)]
241+pub struct Refresh {
242+ /// The vault was re-read: everything shown should be asked for again.
243+ pub changed: bool,
244+ pub message: Option<String>,
245+ /// Conflict pairs a merge just raised (§12.4).
246+ pub raised: u32,
247+ /// The open editor was re-rendered over a change from outside: its new
248+ /// text, and the generation the text field's updates now name.
249+ pub editor_text: Option<String>,
250+ pub editor_generation: u32,
251+ /// The node the editor was open on is gone, and the editor closed.
252+ pub editor_closed: bool,
253+}
254+
255+#[derive(uniffi::Record, Clone, Debug)]
256+pub struct EditorView {
257+ pub text: String,
258+ pub title: String,
259+ pub node: String,
260+ /// What `edit_update` names the text by (`Session::edit_update`).
261+ pub generation: u32,
262+}
263+
264+/// The editor's text after its own undo or redo, and its new generation.
265+#[derive(uniffi::Record, Clone, Debug)]
266+pub struct EditorText {
267+ pub text: String,
268+ pub generation: u32,
269+}
270+
271+/// After a save or a close of the editor.
272+#[derive(uniffi::Record, Clone, Debug)]
273+pub struct Saved {
274+ pub ok: bool,
275+ pub message: String,
276+ pub dirty: bool,
277+ /// Where the edited node is now: its title, and so its key, may be what
278+ /// was edited.
279+ pub node: Option<String>,
280+}
281+
282+#[derive(uniffi::Record, Clone, Debug)]
283+pub struct EditorState {
284+ pub dirty: bool,
285+ pub can_undo: bool,
286+ pub can_redo: bool,
287+}
288+
289+#[derive(uniffi::Record, Clone, Debug)]
290+pub struct TrashEntry {
291+ pub name: String,
292+ pub bytes: u64,
293+}
294+
295+// ------------------------------------------------------------ the session
296+
297+#[derive(uniffi::Object)]
298+pub struct Session {
299+ state: Mutex<State>,
300+}
301+
302+struct State {
303+ vault: Vault,
304+ log: ops::OpLog,
305+ /// The register (§10.3 `y`/`d`): text, what it is, its spelling.
306+ register: Option<(String, String, Kind)>,
307+ editor: Option<TextEditor>,
308+ /// Bumped whenever the editor's text is replaced from this side (a
309+ /// re-render, its undo): an update from the text field made before the
310+ /// field took the new text names an older one, and is dropped.
311+ edit_generation: u32,
312+ /// A save of the editor's text failed and none has gone through since.
313+ edit_refused: bool,
314+ /// The editor's text as it was when *Revert* could not copy it to the
315+ /// trash: *Revert* again on the same text drops it.
316+ edit_uncopied: Option<String>,
317+ /// The sync-conflict copies the last look at the files listed, and
318+ /// still there after it: a new one is a change to take in (§11.2).
319+ conflict_copies: Vec<String>,
320+}
321+
322+#[uniffi::export]
323+impl Session {
324+ /// Open a vault, creating `root.md` in an empty directory (§4.1.1).
325+ #[uniffi::constructor]
326+ pub fn open(dir: String) -> Result<Arc<Session>, FoldError> {
327+ let vault = Vault::open(Path::new(&dir)).map_err(|e| FoldError::Failed(format!("{}: {}", dir, e)))?;
328+ Ok(Arc::new(Session {
329+ state: Mutex::new(State {
330+ vault,
331+ log: ops::OpLog::default(),
332+ register: None,
333+ editor: None,
334+ edit_generation: 0,
335+ edit_refused: false,
336+ edit_uncopied: None,
337+ // none yet: the first look at the files merges any there
338+ // (§12.2, the startup scan)
339+ conflict_copies: Vec::new(),
340+ }),
341+ }))
342+ }
343+
344+ pub fn dir(&self) -> String {
345+ self.lock().vault.dir.to_string_lossy().into_owned()
346+ }
347+
348+ // ------------------------------------------------------------ reading
349+
350+ pub fn outline(&self, query: OutlineQuery) -> Outline {
351+ self.lock().outline(&query)
352+ }
353+
354+ pub fn node(&self, key: String) -> Option<NodeInfo> {
355+ let s = self.lock();
356+ let r = s.find(&key)?;
357+ Some(s.node_info(r))
358+ }
359+
360+ /// The reading view of a node, the vault's root for none (§5.4).
361+ pub fn reading(&self, key: Option<String>) -> Vec<ReadLine> {
362+ let s = self.lock();
363+ let r = match key {
364+ Some(k) => match s.find(&k) {
365+ Some(r) => r,
366+ None => return Vec::new(),
367+ },
368+ None => s.vault.tree.root,
369+ };
370+ s.reading(r)
371+ }
372+
373+ /// Exact source, frontmatter included (§10.3 `zr`).
374+ pub fn source(&self, key: String) -> Option<String> {
375+ let s = self.lock();
376+ let r = s.find(&key)?;
377+ let tree = &s.vault.tree;
378+ let n = tree.node(r);
379+ let text = tree.text_of(r);
380+ Some(if n.is_block() { text.to_string() } else { n.span.text(text).to_string() })
381+ }
382+
383+ /// The filter box (§10.5): titles first, then text.
384+ pub fn search(&self, query: String) -> Vec<Hit> {
385+ self.lock().search(&query)
386+ }
387+
388+ /// *Move to…* candidates (§10.1): every node `moving` may go under.
389+ pub fn targets(&self, query: String, moving: Option<String>) -> Vec<Hit> {
390+ self.lock().targets(&query, moving.as_deref())
391+ }
392+
393+ pub fn properties(&self, key: String) -> Vec<Property> {
394+ let s = self.lock();
395+ s.find(&key).map(|r| s.properties(r)).unwrap_or_default()
396+ }
397+
398+ pub fn conflicts(&self) -> Vec<ConflictPair> {
399+ self.lock().conflicts()
400+ }
401+
402+ /// `fold check` (§15.7).
403+ pub fn diagnostics(&self) -> Vec<String> {
404+ // ignored files among them (§4.1)
405+ fold_core::check::check(&self.lock().vault).iter().map(|d| d.to_string()).collect()
406+ }
407+
408+ // ------------------------------------------------------------ verbs
409+
410+ /// `x`: toggle a task open / done (§8.1).
411+ pub fn toggle_task(&self, key: String) -> OpResult {
412+ self.lock().with_editor_saved(|s| s.toggle_task(&key))
413+ }
414+
415+ /// `t`: add or remove the checkbox (§10.3).
416+ pub fn toggle_taskness(&self, key: String) -> OpResult {
417+ self.lock().with_editor_saved(|s| s.toggle_taskness(&key))
418+ }
419+
420+ /// `n` / `N`: a new node titled `title` after `key`, or as its last
421+ /// child; under the vault's root for no key.
422+ pub fn add_node(&self, key: Option<String>, title: String, task: bool, child: bool) -> OpResult {
423+ self.lock().with_editor_saved(|s| s.add_node(key.as_deref(), &title, task, child))
424+ }
425+
426+ pub fn delete(&self, key: String) -> OpResult {
427+ self.lock().with_editor_saved(|s| s.delete(&key))
428+ }
429+
430+ pub fn copy(&self, key: String) -> OpResult {
431+ self.lock().copy(&key)
432+ }
433+
434+ pub fn paste(&self, key: String, after: bool) -> OpResult {
435+ self.lock().with_editor_saved(|s| s.paste(&key, after))
436+ }
437+
438+ /// `J` / `K` (§10.3).
439+ pub fn move_sibling(&self, key: String, down: bool) -> OpResult {
440+ self.lock().with_editor_saved(|s| s.move_sibling(&key, down))
441+ }
442+
443+ /// `>` (§10.3).
444+ pub fn indent(&self, key: String) -> OpResult {
445+ self.lock().with_editor_saved(|s| s.indent(&key))
446+ }
447+
448+ /// `<` (§10.3).
449+ pub fn outdent(&self, key: String) -> OpResult {
450+ self.lock().with_editor_saved(|s| s.outdent(&key))
451+ }
452+
453+ /// *Move to…* (§6.5).
454+ pub fn move_to(&self, key: String, dest: String) -> OpResult {
455+ self.lock().with_editor_saved(|s| s.move_to(&key, &dest))
456+ }
457+
458+ /// `za` (§6.5).
459+ pub fn archive(&self, key: String) -> OpResult {
460+ self.lock().with_editor_saved(|s| s.archive(&key))
461+ }
462+
463+ /// `~` (§10.3).
464+ pub fn toggle_spelling(&self, key: String) -> OpResult {
465+ self.lock().with_editor_saved(|s| s.toggle_spelling(&key))
466+ }
467+
468+ /// `s` (§6.1).
469+ pub fn make_block(&self, key: String) -> OpResult {
470+ self.lock().with_editor_saved(|s| s.make_block(&key))
471+ }
472+
473+ /// A property, from the property form (§10.6); the first one on a plain
474+ /// node makes it a block.
475+ pub fn set_property(&self, key: String, name: String, value: String) -> OpResult {
476+ self.lock().with_editor_saved(|s| s.set_property(&key, &name, &value))
477+ }
478+
479+ pub fn remove_property(&self, key: String, name: String) -> OpResult {
480+ self.lock().with_editor_saved(|s| s.remove_property(&key, &name))
481+ }
482+
483+ /// `c` / `C` (§7).
484+ pub fn capture(&self, text: String, task: bool) -> OpResult {
485+ self.lock().with_editor_saved(|s| s.capture(&text, task))
486+ }
487+
488+ /// *Clear done* (§8.5), under `zoom` or everywhere.
489+ pub fn clear_done(&self, zoom: Option<String>) -> OpResult {
490+ self.lock().with_editor_saved(|s| s.clear_done(zoom.as_deref()))
491+ }
492+
493+ /// The conflict view's choices (§12.5), on the pair whose copy is
494+ /// `theirs`.
495+ pub fn resolve(&self, theirs: String, keep: Keep) -> OpResult {
496+ self.lock().with_editor_saved(|s| s.resolve(&theirs, keep))
497+ }
498+
499+ /// *Canonicalize*: `fold check --fix` (§4.2).
500+ pub fn canonicalize(&self) -> OpResult {
501+ self.lock().with_editor_saved(|s| s.canonicalize())
502+ }
503+
504+ /// Undo the last change; with `mark`, only if it is still the change
505+ /// the mark names.
506+ pub fn undo(&self, mark: Option<UndoMark>) -> OpResult {
507+ let mut s = self.lock();
508+ if let Some(m) = mark {
509+ let top = s.log.last_undo().map(|e| e.description.as_str());
510+ if s.log.depth() as u32 != m.depth || top != Some(m.description.as_str()) {
511+ return State::refused(match top {
512+ Some(d) => format!("not undone: the last change is now {}", d),
513+ None => "nothing to undo".into(),
514+ });
515+ }
516+ }
517+ s.undo_redo(true)
518+ }
519+
520+ pub fn redo(&self) -> OpResult {
521+ self.lock().undo_redo(false)
522+ }
523+
524+ // ------------------------------------------------------------ files
525+
526+ /// Look at the files (§11.2): take in what another program or a sync
527+ /// changed, merge new sync-conflict copies (§12.2), save the editor
528+ /// first and re-render it where what it shows changed. With `force`,
529+ /// re-read the vault even when nothing seems to have changed.
530+ pub fn refresh(&self, force: bool) -> Refresh {
531+ self.lock().refresh(force)
532+ }
533+
534+ pub fn trash(&self) -> Vec<TrashEntry> {
535+ let mut out: Vec<TrashEntry> = std::fs::read_dir(fold_core::vault::trash_dir())
536+ .map(|rd| {
537+ rd.filter_map(|e| e.ok())
538+ .map(|e| TrashEntry {
539+ name: e.file_name().to_string_lossy().into_owned(),
540+ bytes: e.metadata().map(|m| m.len()).unwrap_or(0),
541+ })
542+ .collect()
543+ })
544+ .unwrap_or_default();
545+ out.sort_by(|a, b| b.name.cmp(&a.name));
546+ out
547+ }
548+
549+ pub fn trash_text(&self, name: String) -> Option<String> {
550+ let path = trash_entry(&name)?;
551+ std::fs::read_to_string(path).ok()
552+ }
553+
554+ /// `fold trash restore` (§11.5).
555+ pub fn restore(&self, name: String) -> OpResult {
556+ self.lock().with_editor_saved(|s| s.restore(&name))
557+ }
558+
559+ // ------------------------------------------------------------ editor
560+
561+ /// Open the editor on a node: `render(node, 1, true)` with every line
562+ /// tagged with its block (§5.2, §10.6).
563+ pub fn edit_open(&self, key: String) -> Result<EditorView, FoldError> {
564+ self.lock().edit_open(&key).map_err(FoldError::Failed)
565+ }
566+
567+ /// The text field's text after a change; `cursor`, the caret after it,
568+ /// in characters. `generation` is the one the field's text came with:
569+ /// a change typed while the editor's text was being replaced (a sync
570+ /// re-rendered it, its undo ran) was made to text that is gone, and is
571+ /// dropped rather than laid over the new text.
572+ pub fn edit_update(&self, text: String, cursor: Option<u32>, generation: u32) -> EditorState {
573+ let mut s = self.lock();
574+ let current = s.edit_generation == generation;
575+ if let Some(ed) = s.editor.as_mut().filter(|_| current) {
576+ ed.apply(&text, cursor.map(|c| c as usize));
577+ }
578+ s.editor_state()
579+ }
580+
581+ /// The editor's text, as the buffer has it.
582+ pub fn edit_text(&self) -> Option<String> {
583+ self.lock().editor.as_ref().map(|e| e.text())
584+ }
585+
586+ /// Save the dirty blocks: after a pause in typing, or when asked (§10.6).
587+ pub fn edit_save(&self) -> Saved {
588+ let mut s = self.lock();
589+ let res = s.write_editor(false);
590+ s.saved(res)
591+ }
592+
593+ /// Done: save, and leave the editor. Refused saves keep it open with
594+ /// its text (§10.6).
595+ pub fn edit_close(&self) -> Saved {
596+ let mut s = self.lock();
597+ let res = s.write_editor(true);
598+ let saved = s.saved(res);
599+ if saved.ok {
600+ s.editor = None;
601+ }
602+ saved
603+ }
604+
605+ /// *Revert* (§10.6): drop what was typed since the last save.
606+ pub fn edit_revert(&self) -> OpResult {
607+ self.lock().edit_revert()
608+ }
609+
610+ /// The editor's own undo, inside it (§10.6); the new text, if any.
611+ pub fn edit_undo(&self) -> Option<EditorText> {
612+ self.lock().edit_history(true)
613+ }
614+
615+ pub fn edit_redo(&self) -> Option<EditorText> {
616+ self.lock().edit_history(false)
617+ }
618+
619+ /// The app is going away, maybe for good (§10.6): save what can be
620+ /// saved; text no save can take goes to the trash whole (§11.5). What to
621+ /// say, if anything. With `release`, as when the vault closes, a block
622+ /// cut and not pasted back is deleted as on leaving the editor (§5.2);
623+ /// without, as when the app only goes to the background, it stays in
624+ /// transit for the paste the user may be on the way to.
625+ pub fn edit_keep(&self, release: bool) -> Option<String> {
626+ self.lock().edit_keep(release)
627+ }
628+}
629+
630+/// A trash entry by name, never outside the trash.
631+fn trash_entry(name: &str) -> Option<PathBuf> {
632+ if name.contains('/') || name.contains('\\') || name.starts_with('.') {
633+ return None;
634+ }
635+ let p = fold_core::vault::trash_dir().join(name);
636+ p.is_file().then_some(p)
637+}
638+
639+impl Session {
640+ fn lock(&self) -> MutexGuard<'_, State> {
641+ // a panic in one call leaves the state as it was written so far;
642+ // the files are the truth and the next look at them reloads
643+ self.state.lock().unwrap_or_else(|e| e.into_inner())
644+ }
645+}
646+
647+/// A title as messages name it: quoted.
648+fn quoted(title: &str) -> String {
649+ format!("“{}”", if title.is_empty() { "(untitled)" } else { title })
650+}
651+
652+fn task_of(t: Option<TaskState>) -> Task {
653+ match t {
654+ None => Task::None,
655+ Some(TaskState::Open) => Task::Open,
656+ Some(TaskState::Done) => Task::Done,
657+ }
658+}
659+
660+fn spelling_of(k: Kind) -> Spelling {
661+ match k {
662+ Kind::Section => Spelling::Section,
663+ _ => Spelling::Item,
664+ }
665+}
666+
667+/// What a verb's message adds when the ordering rule chose the place (§3.1).
668+fn with_rule_note(what: &str, moved: bool, kind: Kind) -> String {
669+ match (moved, kind) {
670+ (false, _) => what.to_string(),
671+ (true, Kind::Section) => format!("{} — placed after the items", what),
672+ (true, _) => format!("{} — placed before the sections", what),
673+ }
674+}
675+
676+fn fuzzy_match(needle: &str, hay: &str) -> bool {
677+ let mut n = needle.chars().peekable();
678+ for c in hay.chars() {
679+ if n.peek() == Some(&c) {
680+ n.next();
681+ }
682+ }
683+ n.peek().is_none()
684+}
685+
686+type Verb<'a> = Box<dyn FnOnce(&mut State) -> Result<(String, Option<String>), String> + 'a>;
687+
688+impl State {
689+ // ------------------------------------------------------------ finding
690+
691+ fn key(&self, r: NRef) -> String {
692+ keys::encode(&self.vault.key_of(r))
693+ }
694+
695+ /// The node a key names, only if it is that very node: no falling back
696+ /// to the deepest step that still exists.
697+ fn find(&self, key: &str) -> Option<NRef> {
698+ let k = keys::decode(key)?;
699+ self.find_exact(&k)
700+ }
701+
702+ fn find_exact(&self, k: &NodeKey) -> Option<NRef> {
703+ self.vault.find_by_key(k).filter(|&r| self.vault.key_of(r) == *k)
704+ }
705+
706+ fn named(&self, r: NRef) -> String {
707+ let tree = &self.vault.tree;
708+ quoted(&tree.node(tree.resolved_child(r)).title)
709+ }
710+
711+ /// A node and its ancestors, top first, following block roots up to the
712+ /// embeds that stitch them in (so a block's path is its place in the
713+ /// tree, not its file).
714+ fn chain(&self, r: NRef) -> Vec<NRef> {
715+ let tree = &self.vault.tree;
716+ let mut out = Vec::new();
717+ let mut cur = Some(r);
718+ let mut guard = 0;
719+ while let Some(c) = cur {
720+ guard += 1;
721+ if guard > 1000 {
722+ break;
723+ }
724+ let n = tree.node(c);
725+ if n.kind == Kind::Root {
726+ break;
727+ }
728+ if !n.is_embed() {
729+ out.push(c);
730+ }
731+ cur = match n.parent.map(|p| (c.0, p)) {
732+ Some(p) if tree.node(p).kind != Kind::Root => Some(p),
733+ Some(_) if c.0 != tree.root.0 => {
734+ n.block.as_ref().and_then(|b| b.id.as_ref()).and_then(|id| tree.embed_of(id))
735+ }
736+ _ => None,
737+ };
738+ }
739+ out.reverse();
740+ out
741+ }
742+
743+ /// A node's parent in the outline, `None` at the top.
744+ fn outline_parent(&self, r: NRef) -> Option<NRef> {
745+ let chain = self.chain(r);
746+ chain.len().checked_sub(2).map(|i| chain[i])
747+ }
748+
749+ fn crumbs(&self, r: NRef) -> Vec<Crumb> {
750+ self.chain(r)
751+ .into_iter()
752+ .map(|c| Crumb { key: self.key(c), title: self.vault.tree.node(c).title.clone() })
753+ .collect()
754+ }
755+
756+ // ------------------------------------------------------------ outline
757+
758+ fn outline(&self, q: &OutlineQuery) -> Outline {
759+ let tree = &self.vault.tree;
760+ // a zoom whose node went away zooms out to its deepest surviving
761+ // ancestor (§10.1)
762+ let zoom = q
763+ .zoom
764+ .as_deref()
765+ .and_then(keys::decode)
766+ .and_then(|k| self.vault.find_by_key(&k))
767+ .filter(|&r| tree.node(r).kind != Kind::Root);
768+ let folded: Vec<NodeKey> = q.folded.iter().filter_map(|k| keys::decode(k)).collect();
769+ let unfolded: Vec<NodeKey> = q.unfolded.iter().filter_map(|k| keys::decode(k)).collect();
770+ let mut rows = Vec::new();
771+ let mut seen = Vec::new();
772+ let root = zoom.unwrap_or(tree.root);
773+ if let Some(z) = zoom {
774+ seen.push(z);
775+ }
776+ for c in tree.resolved_children(root) {
777+ self.flatten(c, 0, q.hide_done, &folded, &unfolded, &mut rows, &mut seen);
778+ }
779+ Outline {
780+ zoom: zoom.map(|z| self.header(z)),
781+ crumbs: zoom.map(|z| self.crumbs(z)).unwrap_or_default(),
782+ rows,
783+ conflicts: fold_core::merge::conflict_pairs(&self.vault).len() as u32,
784+ undo: self.log.last_undo().map(|e| e.description.clone()),
785+ redo: self.log.last_redo().map(|e| e.description.clone()),
786+ undo_depth: self.log.depth() as u32,
787+ copied: self.register.as_ref().map(|(_, name, _)| name.clone()),
788+ }
789+ }
790+
791+ #[allow(clippy::too_many_arguments)]
792+ fn flatten(
793+ &self,
794+ r: NRef,
795+ depth: u32,
796+ hide_done: bool,
797+ folded: &[NodeKey],
798+ unfolded: &[NodeKey],
799+ out: &mut Vec<Row>,
800+ seen: &mut Vec<NRef>,
801+ ) {
802+ let tree = &self.vault.tree;
803+ let n = tree.node(r);
804+ // an embed cycle or a second embed of a block shows it once (§6.2)
805+ if n.is_block() {
806+ if seen.contains(&r) {
807+ return;
808+ }
809+ seen.push(r);
810+ }
811+ if hide_done && n.task == Some(TaskState::Done) {
812+ return;
813+ }
814+ let key = self.vault.key_of(r);
815+ let kids = tree.resolved_children(r);
816+ let is_folded = match n.conflict() {
817+ Some(_) => !unfolded.contains(&key),
818+ None => folded.contains(&key),
819+ };
820+ let (open, total) = self.counts(r, !kids.is_empty());
821+ out.push(Row {
822+ key: keys::encode(&key),
823+ title: n.title.clone(),
824+ depth,
825+ spelling: spelling_of(n.kind),
826+ task: task_of(n.task),
827+ block: n.is_block(),
828+ conflict: n.conflict().map(fold_core::merge::copy_from),
829+ broken: n.is_embed(),
830+ has_children: !kids.is_empty(),
831+ folded: is_folded,
832+ open,
833+ total,
834+ due: n.block.as_ref().and_then(|b| b.prop("due")).map(str::to_string),
835+ preview: self.preview(r),
836+ });
837+ if is_folded {
838+ return;
839+ }
840+ for c in kids {
841+ self.flatten(c, depth + 1, hide_done, folded, unfolded, out, seen);
842+ }
843+ }
844+
845+ /// The open/total count of the tasks below a node, itself not counted
846+ /// (§3.5); a leaf shows none.
847+ fn counts(&self, r: NRef, kids: bool) -> (u32, u32) {
848+ let n = self.vault.tree.node(r);
849+ let (open, total) = self.vault.tree.task_counts(r);
850+ let own = n.task.is_some() as usize;
851+ if !kids || total <= own {
852+ return (0, 0);
853+ }
854+ let own_open = (n.task == Some(TaskState::Open)) as usize;
855+ ((open.saturating_sub(own_open)) as u32, (total - own) as u32)
856+ }
857+
858+ /// The first line of a node's own text, whitespace collapsed.
859+ fn preview(&self, r: NRef) -> String {
860+ let tree = &self.vault.tree;
861+ let t = tree.resolved_child(r);
862+ tree.node(t)
863+ .text_lines(tree.text_of(t))
864+ .into_iter()
865+ .map(str::trim)
866+ .find(|l| !l.is_empty() && !l.starts_with("```"))
867+ .map(|l| l.split_whitespace().collect::<Vec<_>>().join(" "))
868+ .unwrap_or_default()
869+ }
870+
871+ fn header(&self, r: NRef) -> Header {
872+ let tree = &self.vault.tree;
873+ let n = tree.node(r);
874+ let (open, total) = self.counts(r, !tree.resolved_children(r).is_empty());
875+ // the body: the rendered lines after the title, up to its first child
876+ let body: Vec<String> = render_lines(tree, r, 1, true)
877+ .into_iter()
878+ .skip(1)
879+ .take_while(|l| l.node == r && l.kind != LineKind::Title)
880+ .map(|l| l.text)
881+ .collect();
882+ let mut body = body;
883+ while body.last().is_some_and(|l| l.trim().is_empty()) {
884+ body.pop();
885+ }
886+ while body.first().is_some_and(|l| l.trim().is_empty()) {
887+ body.remove(0);
888+ }
889+ Header {
890+ key: self.key(r),
891+ title: n.title.clone(),
892+ spelling: spelling_of(n.kind),
893+ task: task_of(n.task),
894+ block: n.is_block(),
895+ conflict: n.conflict().map(fold_core::merge::copy_from),
896+ due: n.block.as_ref().and_then(|b| b.prop("due")).map(str::to_string),
897+ open,
898+ total,
899+ body,
900+ props: self.properties(r),
901+ }
902+ }
903+
904+ fn node_info(&self, r: NRef) -> NodeInfo {
905+ let n = self.vault.tree.node(r);
906+ NodeInfo {
907+ key: self.key(r),
908+ title: n.title.clone(),
909+ spelling: spelling_of(n.kind),
910+ task: task_of(n.task),
911+ block: n.is_block(),
912+ conflict: n.conflict().map(fold_core::merge::copy_from),
913+ paired: ops::conflict_pair(&self.vault.tree, r).len() > 1 || n.conflict().is_some(),
914+ path: self.crumbs(r),
915+ }
916+ }
917+
918+ fn properties(&self, r: NRef) -> Vec<Property> {
919+ let tree = &self.vault.tree;
920+ let r = tree.resolved_child(r);
921+ if !tree.node(r).is_block() {
922+ return Vec::new();
923+ }
924+ ops::frontmatter_lines(&self.vault, r.0)
925+ .into_iter()
926+ .filter(|(k, _, _)| k != "id")
927+ .map(|(key, value, editable)| Property { key, value, editable })
928+ .collect()
929+ }
930+
931+ fn reading(&self, root: NRef) -> Vec<ReadLine> {
932+ let tree = &self.vault.tree;
933+ render_lines(tree, root, 1, true)
934+ .into_iter()
935+ .map(|l| {
936+ let node = (l.kind != LineKind::Blank).then(|| self.key(l.node));
937+ let mut line = ReadLine {
938+ kind: match l.kind {
939+ LineKind::Title => ReadKind::Title,
940+ LineKind::Body => ReadKind::Body,
941+ LineKind::Embed => ReadKind::Embed,
942+ LineKind::Blank => ReadKind::Blank,
943+ },
944+ text: l.text.clone(),
945+ node,
946+ indent: 0,
947+ heading: 0,
948+ task: Task::None,
949+ title: String::new(),
950+ props: None,
951+ conflict: None,
952+ };
953+ if l.kind == LineKind::Title {
954+ let n = tree.node(l.node);
955+ let trimmed = l.text.trim_start_matches(' ');
956+ line.indent = (l.text.len() - trimmed.len()) as u32;
957+ line.heading = match n.kind {
958+ Kind::Section => trimmed.bytes().take_while(|&b| b == b'#').count() as u32,
959+ _ => 0,
960+ };
961+ line.task = task_of(n.task);
962+ line.title = n.title.clone();
963+ line.conflict = n.conflict().map(fold_core::merge::copy_from);
964+ let props: Vec<String> = self
965+ .properties(l.node)
966+ .into_iter()
967+ .filter(|p| p.editable && p.key != "conflict")
968+ .map(|p| format!("{} {}", p.key, p.value))
969+ .collect();
970+ line.props = (!props.is_empty()).then(|| props.join(" · "));
971+ }
972+ line
973+ })
974+ .collect()
975+ }
976+
977+ fn hit(&self, r: NRef, excerpt: Option<String>) -> Hit {
978+ let tree = &self.vault.tree;
979+ let n = tree.node(r);
980+ let mut chain = self.chain(r);
981+ chain.pop();
982+ Hit {
983+ key: self.key(r),
984+ parent: chain.last().map(|&p| self.key(p)),
985+ title: n.title.clone(),
986+ path: chain.iter().map(|&c| tree.node(c).title.as_str()).collect::<Vec<_>>().join(" › "),
987+ task: task_of(n.task),
988+ conflict: n.conflict().is_some() || chain.iter().any(|&c| tree.node(c).conflict().is_some()),
989+ excerpt,
990+ }
991+ }
992+
993+ fn search(&self, query: &str) -> Vec<Hit> {
994+ let q = query.trim().to_lowercase();
995+ if q.is_empty() {
996+ return Vec::new();
997+ }
998+ let tree = &self.vault.tree;
999+ let mut scored: Vec<(u8, usize, NRef, Option<String>)> = Vec::new();
1000+ let mut order = 0;
1001+ tree.walk(tree.root, &mut |t, r| {
1002+ let n = t.node(r);
1003+ if n.kind == Kind::Root || n.is_embed() {
1004+ return;
1005+ }
1006+ order += 1;
1007+ let title = n.title.to_lowercase();
1008+ let score = if title.starts_with(&q) {
1009+ Some(0)
1010+ } else if title.contains(&q) {
1011+ Some(1)
1012+ } else if fuzzy_match(&q, &title) {
1013+ Some(2)
1014+ } else {
1015+ None
1016+ };
1017+ if let Some(s) = score {
1018+ scored.push((s, order, r, None));
1019+ return;
1020+ }
1021+ // full text over its own text children (§10.5)
1022+ let line = n.text_lines(t.text_of(r)).into_iter().find(|l| l.to_lowercase().contains(&q));
1023+ if let Some(l) = line {
1024+ scored.push((3, order, r, Some(l.trim().to_string())));
1025+ }
1026+ });
1027+ scored.sort_by_key(|&(s, o, _, _)| (s, o));
1028+ scored.into_iter().take(200).map(|(_, _, r, ex)| self.hit(r, ex)).collect()
1029+ }
1030+
1031+ fn targets(&self, query: &str, moving: Option<&str>) -> Vec<Hit> {
1032+ let q = query.trim().to_lowercase();
1033+ let tree = &self.vault.tree;
1034+ // a node cannot move into its own subtree, nor into the node its
1035+ // conflict copy is of, which moves with it (§12.5)
1036+ let moving = moving.and_then(|k| self.find(k));
1037+ let moving = moving.map(|r| ops::conflict_pair(tree, r)).unwrap_or_default();
1038+ let mut nodes: Vec<NRef> = Vec::new();
1039+ tree.walk(tree.root, &mut |t, r| {
1040+ if t.node(r).kind != Kind::Root && !t.node(r).is_embed() {
1041+ nodes.push(r);
1042+ }
1043+ });
1044+ let mut scored: Vec<(u8, usize, NRef)> = Vec::new();
1045+ for r in nodes {
1046+ let chain = self.chain(r);
1047+ if moving.iter().any(|m| chain.contains(m)) {
1048+ continue;
1049+ }
1050+ // nor into a conflict copy, which keeping ours trashes (§12.5)
1051+ if chain.iter().any(|&c| tree.node(c).conflict().is_some()) {
1052+ continue;
1053+ }
1054+ let title = tree.node(r).title.to_lowercase();
1055+ let path = chain.iter().map(|&c| tree.node(c).title.as_str()).collect::<Vec<_>>().join(" › ").to_lowercase();
1056+ let score = if q.is_empty() || title.starts_with(&q) {
1057+ 0
1058+ } else if title.contains(&q) {
1059+ 1
1060+ } else if fuzzy_match(&q, &path) {
1061+ 2
1062+ } else {
1063+ continue;
1064+ };
1065+ scored.push((score, path.len(), r));
1066+ }
1067+ if q.is_empty() {
1068+ // nothing typed: the outline's order
1069+ scored.sort_by_key(|&(s, _, _)| s);
1070+ } else {
1071+ scored.sort_by_key(|&(s, len, _)| (s, len));
1072+ }
1073+ scored.into_iter().take(200).map(|(_, _, r)| self.hit(r, None)).collect()
1074+ }
1075+
1076+ fn conflicts(&self) -> Vec<ConflictPair> {
1077+ let tree = &self.vault.tree;
1078+ fold_core::merge::conflict_pairs_in_order(&self.vault)
1079+ .into_iter()
1080+ .map(|(ours, theirs)| ConflictPair {
1081+ ours: self.key(ours),
1082+ theirs: self.key(theirs),
1083+ title: tree.node(ours).title.clone(),
1084+ from: tree.node(theirs).conflict().map(fold_core::merge::copy_from).unwrap_or_default(),
1085+ ours_text: render(tree, ours, 1, true),
1086+ theirs_text: render(tree, theirs, 1, true),
1087+ })
1088+ .collect()
1089+ }
1090+
1091+ // ------------------------------------------------------------ verbs
1092+
1093+ /// Run a verb from the app (`fold-tui`'s `editor_before_write` and
1094+ /// `editor_after_write`, §10.6): an open editor saves first, and only
1095+ /// then does the verb find its nodes, as the save re-parses what it
1096+ /// writes; after it, the editor is re-rendered over what the verb wrote,
1097+ /// and the result says so, so the text field takes the new text.
1098+ fn with_editor_saved(&mut self, f: impl FnOnce(&mut State) -> OpResult) -> OpResult {
1099+ let open = self.editor.is_some();
1100+ let edit = self.editor_before_write();
1101+ let generation = self.edit_generation;
1102+ let mut r = f(self);
1103+ self.editor_after_write(edit);
1104+ if open {
1105+ match &self.editor {
1106+ None => r.editor_closed = true,
1107+ Some(ed) if self.edit_generation != generation => r.editor_text = Some(ed.text()),
1108+ Some(_) => {}
1109+ }
1110+ }
1111+ r.editor_generation = self.edit_generation;
1112+ r
1113+ }
1114+
1115+ /// Run a verb as one op-log entry (§10.10): the entry holds exactly the
1116+ /// files the verb changed, written before a failure too.
1117+ fn verb(&mut self, desc: &str, f: Verb<'_>) -> OpResult {
1118+ let snap = ops::Snapshot::take(&self.vault, desc);
1119+ let res = f(self);
1120+ self.record_undo(snap);
1121+ match res {
1122+ Ok((message, node)) => OpResult::new(true, message, node),
1123+ Err(message) => OpResult::new(false, message, None),
1124+ }
1125+ }
1126+
1127+ fn refused(message: impl Into<String>) -> OpResult {
1128+ OpResult::new(false, message, None)
1129+ }
1130+
1131+ fn gone() -> OpResult {
1132+ Self::refused("that node is gone")
1133+ }
1134+
1135+ fn record_undo(&mut self, snap: ops::Snapshot) {
1136+ self.log.record(ops::Inverse::since(snap, &self.vault));
1137+ }
1138+
1139+ fn toggle_task(&mut self, key: &str) -> OpResult {
1140+ let Some(r) = self.find(key) else { return Self::gone() };
1141+ let name = self.named(r);
1142+ let tree = &self.vault.tree;
1143+ let (words, step) = match tree.node(tree.resolved_child(r)).task {
1144+ Some(TaskState::Open) => (format!("done: {}", name), format!("mark {} done", name)),
1145+ Some(TaskState::Done) => (format!("reopened: {}", name), format!("reopen {}", name)),
1146+ None => return Self::refused(format!("{} isn't a task · Task on makes it one", name)),
1147+ };
1148+ let k = self.vault.key_of(r);
1149+ self.verb(
1150+ &step,
1151+ Box::new(move |s| {
1152+ ops::toggle_task(&mut s.vault, r).map_err(|e| format!("error: {}", e))?;
1153+ Ok((words, s.vault.find_by_key(&k).map(|r| s.key(r))))
1154+ }),
1155+ )
1156+ }
1157+
1158+ fn toggle_taskness(&mut self, key: &str) -> OpResult {
1159+ let Some(r) = self.find(key) else { return Self::gone() };
1160+ let name = self.named(r);
1161+ let tree = &self.vault.tree;
1162+ let (words, step) = match tree.node(tree.resolved_child(r)).task {
1163+ Some(_) => (format!("removed the checkbox from {}", name), format!("remove the checkbox from {}", name)),
1164+ None => (format!("made {} a task", name), format!("make {} a task", name)),
1165+ };
1166+ let k = self.vault.key_of(r);
1167+ self.verb(
1168+ &step,
1169+ Box::new(move |s| {
1170+ ops::toggle_taskness(&mut s.vault, r).map_err(|e| format!("error: {}", e))?;
1171+ Ok((words, s.vault.find_by_key(&k).map(|r| s.key(r))))
1172+ }),
1173+ )
1174+ }
1175+
1176+ fn add_node(&mut self, key: Option<&str>, title: &str, task: bool, child: bool) -> OpResult {
1177+ let title = title.trim();
1178+ if title.is_empty() {
1179+ return Self::refused("a node needs a title");
1180+ }
1181+ if title.contains('\n') {
1182+ return Self::refused("a title is one line");
1183+ }
1184+ let at = match key {
1185+ Some(k) => match self.find(k) {
1186+ Some(r) => r,
1187+ None => return Self::gone(),
1188+ },
1189+ None => self.vault.tree.root,
1190+ };
1191+ let root = self.vault.tree.node(at).kind == Kind::Root;
1192+ let child = child || root;
1193+ let shown = if task { format!("[ ] {}", title) } else { title.to_string() };
1194+ let desc = if root {
1195+ "add a node".to_string()
1196+ } else {
1197+ format!("add a node {} {}", if child { "under" } else { "after" }, self.named(at))
1198+ };
1199+ let words = format!("added {}", quoted(title));
1200+ self.verb(
1201+ &desc,
1202+ Box::new(move |s| {
1203+ if child {
1204+ let r = ops::append_child_public(&mut s.vault, at, &shown).map_err(|e| format!("error: {}", e))?;
1205+ return Ok((words, Some(s.key(r))));
1206+ }
1207+ // a sibling, spelled like the node it follows (§10.3 `n`)
1208+ let kind = s.vault.tree.node(at).kind;
1209+ let line = match kind {
1210+ Kind::Section => format!("# {}\n", shown),
1211+ _ => format!("- {}\n", shown),
1212+ };
1213+ let p = ops::paste(&mut s.vault, at, &line, true).map_err(|e| format!("error: {}", e))?;
1214+ Ok((with_rule_note(&words, p.clamped, kind), Some(s.key(p.node))))
1215+ }),
1216+ )
1217+ }
1218+
1219+ fn delete(&mut self, key: &str) -> OpResult {
1220+ let Some(r) = self.find(key) else { return Self::gone() };
1221+ self.put_register(r);
1222+ let name = self.named(r);
1223+ // a node's conflict copies go with it (§12.5)
1224+ let what = match ops::conflict_copies(&self.vault.tree, r).len() {
1225+ 0 => name.clone(),
1226+ 1 => format!("{} and its conflict copy", name),
1227+ n => format!("{} and its {} conflict copies", name, n),
1228+ };
1229+ self.verb(
1230+ &format!("delete {}", name),
1231+ Box::new(move |s| match ops::delete_subtree(&mut s.vault, r) {
1232+ Ok(1) => Ok((format!("deleted {}", what), None)),
1233+ Ok(n) => Ok((format!("deleted {} ({} nodes)", what, n), None)),
1234+ Err(e) => Err(format!("error: {}", e)),
1235+ }),
1236+ )
1237+ }
1238+
1239+ /// Put `r`'s subtree in the register (§10.3 `y`, `d`).
1240+ fn put_register(&mut self, r: NRef) {
1241+ let tree = &self.vault.tree;
1242+ let kind = tree.node(tree.resolved_child(r)).kind;
1243+ self.register = Some((ops::yank(&self.vault, r), self.named(r), kind));
1244+ }
1245+
1246+ fn copy(&mut self, key: &str) -> OpResult {
1247+ let Some(r) = self.find(key) else { return Self::gone() };
1248+ self.put_register(r);
1249+ OpResult::new(true, format!("copied {}", self.named(r)), Some(key.to_string()))
1250+ }
1251+
1252+ fn paste(&mut self, key: &str, after: bool) -> OpResult {
1253+ let Some((text, name, kind)) = self.register.clone() else {
1254+ return Self::refused("nothing copied yet · Copy a node first");
1255+ };
1256+ let Some(r) = self.find(key) else { return Self::gone() };
1257+ self.verb(
1258+ &format!("paste {}", name),
1259+ Box::new(move |s| {
1260+ let p = ops::paste(&mut s.vault, r, &text, after).map_err(|e| format!("error: {}", e))?;
1261+ Ok((with_rule_note(&format!("pasted {}", name), p.clamped, kind), None))
1262+ }),
1263+ )
1264+ }
1265+
1266+ fn move_sibling(&mut self, key: &str, down: bool) -> OpResult {
1267+ let Some(r) = self.find(key) else { return Self::gone() };
1268+ let name = self.named(r);
1269+ let k = self.vault.key_of(r);
1270+ let dir = if down { "down" } else { "up" };
1271+ self.verb(
1272+ &format!("move {} {}", name, dir),
1273+ Box::new(move |s| {
1274+ ops::move_sibling(&mut s.vault, r, down).map_err(|e| format!("can't move {} {}: {}", name, dir, e))?;
1275+ Ok((format!("moved {} {}", name, dir), s.vault.find_by_key(&k).map(|r| s.key(r))))
1276+ }),
1277+ )
1278+ }
1279+
1280+ fn indent(&mut self, key: &str) -> OpResult {
1281+ let Some(sub) = self.find(key) else { return Self::gone() };
1282+ let tree = &self.vault.tree;
1283+ let r = tree.resolved_child(sub);
1284+ let kind = tree.node(r).kind;
1285+ // it goes under the node before it (§10.3), with its conflict
1286+ // pair, which moves as one (§12.5)
1287+ let sibs = tree.resolved_children(self.outline_parent(r).unwrap_or(tree.root));
1288+ let first = ops::conflict_pair(tree, r)[0];
1289+ let prev = sibs.iter().position(|&c| c == first).and_then(|i| i.checked_sub(1)).map(|i| sibs[i]);
1290+ let Some(prev) = prev else {
1291+ return Self::refused(format!("can't indent {}: nothing above it", self.named(r)));
1292+ };
1293+ if tree.node(prev).conflict().is_some() {
1294+ return Self::refused(format!("can't indent {} into a conflict copy", self.named(r)));
1295+ }
1296+ let name = self.named(r);
1297+ self.verb(
1298+ &format!("indent {}", name),
1299+ Box::new(move |s| {
1300+ let p = ops::demote(&mut s.vault, sub).map_err(|e| format!("can't indent {}: {}", name, e))?;
1301+ Ok((with_rule_note(&format!("indented {}", name), p.clamped, kind), Some(s.key(p.node))))
1302+ }),
1303+ )
1304+ }
1305+
1306+ fn outdent(&mut self, key: &str) -> OpResult {
1307+ let Some(sub) = self.find(key) else { return Self::gone() };
1308+ let tree = &self.vault.tree;
1309+ let r = tree.resolved_child(sub);
1310+ let kind = tree.node(r).kind;
1311+ // it goes beside its parent, right after it (§10.3)
1312+ if self.outline_parent(r).is_none() {
1313+ return Self::refused(format!("can't outdent {}: it's at the top level", self.named(r)));
1314+ }
1315+ let name = self.named(r);
1316+ self.verb(
1317+ &format!("outdent {}", name),
1318+ Box::new(move |s| {
1319+ let p = ops::promote(&mut s.vault, sub).map_err(|e| format!("can't outdent {}: {}", name, e))?;
1320+ Ok((with_rule_note(&format!("outdented {}", name), p.clamped, kind), Some(s.key(p.node))))
1321+ }),
1322+ )
1323+ }
1324+
1325+ fn move_to(&mut self, key: &str, dest: &str) -> OpResult {
1326+ let Some(r) = self.find(key) else { return Self::gone() };
1327+ let Some(dest) = self.find(dest) else { return Self::refused("that destination is gone") };
1328+ let tree = &self.vault.tree;
1329+ let rr = tree.resolved_child(r);
1330+ let kind = tree.node(rr).kind;
1331+ let (name, to) = (self.named(rr), self.named(dest));
1332+ self.verb(
1333+ &format!("move {} to {}", name, to),
1334+ Box::new(move |s| {
1335+ let p = ops::refile(&mut s.vault, r, dest).map_err(|e| format!("can't move {}: {}", name, e))?;
1336+ Ok((with_rule_note(&format!("moved {} to {}", name, to), p.clamped, kind), Some(s.key(p.node))))
1337+ }),
1338+ )
1339+ }
1340+
1341+ fn archive(&mut self, key: &str) -> OpResult {
1342+ let Some(r) = self.find(key) else { return Self::gone() };
1343+ let tree = &self.vault.tree;
1344+ let (name, kind) = (self.named(r), tree.node(tree.resolved_child(r)).kind);
1345+ self.verb(
1346+ &format!("archive {}", name),
1347+ Box::new(move |s| {
1348+ let p = ops::archive(&mut s.vault, r).map_err(|e| format!("can't archive {}: {}", name, e))?;
1349+ Ok((with_rule_note(&format!("archived {}", name), p.clamped, kind), None))
1350+ }),
1351+ )
1352+ }
1353+
1354+ fn toggle_spelling(&mut self, key: &str) -> OpResult {
1355+ let Some(r) = self.find(key) else { return Self::gone() };
1356+ let tree = &self.vault.tree;
1357+ let (spelling, kind) = match tree.node(tree.resolved_child(r)).kind {
1358+ Kind::Section => ("a bullet", Kind::Item),
1359+ _ => ("a heading", Kind::Section),
1360+ };
1361+ let name = self.named(r);
1362+ // a node in a conflict pair keeps its spelling (§12.5)
1363+ if ops::conflict_pair(tree, r).len() > 1 {
1364+ return Self::refused(format!("can't respell {}: {}", name, ops::PAIR_SPELLING));
1365+ }
1366+ let k = self.vault.key_of(r);
1367+ self.verb(
1368+ &format!("make {} {}", name, spelling),
1369+ Box::new(move |s| {
1370+ let moved = ops::toggle_spelling(&mut s.vault, r).map_err(|e| format!("error: {}", e))?;
1371+ let node = s.vault.find_by_key(&k).map(|r| s.key(r));
1372+ Ok((with_rule_note(&format!("made {} {}", name, spelling), moved, kind), node))
1373+ }),
1374+ )
1375+ }
1376+
1377+ fn make_block(&mut self, key: &str) -> OpResult {
1378+ let Some(r) = self.find(key) else { return Self::gone() };
1379+ let tree = &self.vault.tree;
1380+ if tree.node(tree.resolved_child(r)).is_block() {
1381+ return Self::refused(format!("{} has its own file already", self.named(r)));
1382+ }
1383+ let name = self.named(r);
1384+ self.verb(
1385+ &format!("give {} its own file", name),
1386+ Box::new(move |s| {
1387+ let id = ops::make_block(&mut s.vault, r).map_err(|e| format!("error: {}", e))?;
1388+ // no id, no file name (§1 principle 3b)
1389+ let node = s.vault.tree.block_by_id(&id).map(|r| s.key(r));
1390+ Ok((format!("gave {} its own file", name), node))
1391+ }),
1392+ )
1393+ }
1394+
1395+ fn set_property(&mut self, key: &str, name: &str, value: &str) -> OpResult {
1396+ let Some(r) = self.find(key) else { return Self::gone() };
1397+ let (name, value) = (name.trim().to_string(), value.trim().to_string());
1398+ if !fold_core::parse::is_valid_key(&name) || name == "id" {
1399+ return Self::refused(format!("“{}” can't be a property name: letters, digits, _ and -", name));
1400+ }
1401+ // dates are checked (§8.4)
1402+ if (name == "due" || name == "done") && !fold_core::check::is_iso_date(&value) {
1403+ return Self::refused(format!("{}: must be YYYY-MM-DD", name));
1404+ }
1405+ if value.contains('\n') {
1406+ return Self::refused("a property is one line");
1407+ }
1408+ let node = self.named(r);
1409+ self.verb(
1410+ &format!("set {} of {}", name, node),
1411+ Box::new(move |s| {
1412+ let block = ops::set_property(&mut s.vault, r, &name, &value).map_err(|e| format!("error: {}", e))?;
1413+ Ok((format!("{} set", name), Some(s.key(block))))
1414+ }),
1415+ )
1416+ }
1417+
1418+ fn remove_property(&mut self, key: &str, name: &str) -> OpResult {
1419+ let Some(r) = self.find(key) else { return Self::gone() };
1420+ let r = self.vault.tree.resolved_child(r);
1421+ if !self.vault.tree.node(r).is_block() {
1422+ return Self::refused("no properties to remove");
1423+ }
1424+ match self.properties(r).iter().find(|p| p.key == name) {
1425+ None => return Self::refused(format!("no property {}", name)),
1426+ Some(p) if !p.editable => return Self::refused("read-only line (preserved verbatim)"),
1427+ Some(_) => {}
1428+ }
1429+ let (name, node) = (name.to_string(), self.named(r));
1430+ self.verb(
1431+ &format!("remove {} from {}", name, node),
1432+ Box::new(move |s| {
1433+ ops::set_frontmatter_key(&mut s.vault, r.0, &name, None).map_err(|e| format!("error: {}", e))?;
1434+ Ok((format!("{} removed", name), None))
1435+ }),
1436+ )
1437+ }
1438+
1439+ fn capture(&mut self, text: &str, task: bool) -> OpResult {
1440+ let mut text = text.trim().to_string();
1441+ let mut task = task;
1442+ if let Some(rest) = text.strip_prefix("[ ]") {
1443+ text = rest.trim().to_string();
1444+ task = true;
1445+ }
1446+ if text.is_empty() {
1447+ return Self::refused("nothing to capture");
1448+ }
1449+ let first = text.lines().next().unwrap_or("").to_string();
1450+ self.verb(
1451+ &format!("capture {}", quoted(&first)),
1452+ Box::new(move |s| {
1453+ let r = ops::capture(&mut s.vault, &text, task).map_err(|e| format!("error: {}", e))?;
1454+ Ok(("captured".into(), Some(s.key(r))))
1455+ }),
1456+ )
1457+ }
1458+
1459+ fn clear_done(&mut self, zoom: Option<&str>) -> OpResult {
1460+ let zoom = zoom.and_then(|k| self.find(k));
1461+ let under = zoom.map(|z| format!(" under {}", self.named(z))).unwrap_or_default();
1462+ let target = zoom.unwrap_or(self.vault.tree.root);
1463+ self.verb(
1464+ &format!("clear done tasks{}", under),
1465+ Box::new(move |s| match ops::clear_done(&mut s.vault, target) {
1466+ Ok(0) => Ok((format!("no done tasks to clear{}", under), None)),
1467+ Ok(1) => Ok((format!("cleared 1 done task{}", under), None)),
1468+ Ok(n) => Ok((format!("cleared {} done tasks{}", n, under), None)),
1469+ Err(e) => Err(format!("error: {}", e)),
1470+ }),
1471+ )
1472+ }
1473+
1474+ fn resolve(&mut self, theirs: &str, keep: Keep) -> OpResult {
1475+ let Some(t) = self.find(theirs) else { return Self::gone() };
1476+ let Some((ours, theirs)) = fold_core::merge::conflict_pairs_in_order(&self.vault).into_iter().find(|&(_, x)| x == t) else {
1477+ return Self::refused("that conflict is resolved already");
1478+ };
1479+ let side = match keep {
1480+ Keep::Ours => "ours",
1481+ Keep::Theirs => "theirs",
1482+ Keep::Both => "both",
1483+ };
1484+ let name = self.named(ours);
1485+ let k = self.vault.key_of(ours);
1486+ self.verb(
1487+ &format!("keep {} for {}", side, name),
1488+ Box::new(move |s| {
1489+ match keep {
1490+ Keep::Ours => fold_core::merge::resolve_keep_ours(&mut s.vault, theirs),
1491+ Keep::Theirs => fold_core::merge::resolve_keep_theirs(&mut s.vault, ours, theirs),
1492+ Keep::Both => fold_core::merge::resolve_keep_both(&mut s.vault, theirs),
1493+ }
1494+ .map_err(|e| format!("error: {}", e))?;
1495+ Ok((format!("kept {} for {}", side, name), s.vault.find_by_key(&k).map(|r| s.key(r))))
1496+ }),
1497+ )
1498+ }
1499+
1500+ fn canonicalize(&mut self) -> OpResult {
1501+ self.verb(
1502+ "canonicalize",
1503+ Box::new(|s| {
1504+ // fix keeps the index as it renames: nothing else is read
1505+ // again, so the entry holds what it wrote and no more
1506+ let n = fold_core::check::fix(&mut s.vault).map_err(|e| format!("error: {}", e))?;
1507+ Ok((format!("{} file{} rewritten or renamed", n, if n == 1 { "" } else { "s" }), None))
1508+ }),
1509+ )
1510+ }
1511+
1512+ fn undo_redo(&mut self, undo: bool) -> OpResult {
1513+ if self.editor.is_some() {
1514+ return Self::refused("finish editing first");
1515+ }
1516+ let (done, verb) = if undo { ("undone", "undo") } else { ("redone", "redo") };
1517+ match self.log.step(&mut self.vault, undo) {
1518+ None => Self::refused(format!("nothing to {}", verb)),
1519+ Some(Ok(what)) => OpResult::new(true, format!("{}: {}", done, what), None),
1520+ // refused: an external change since; the entry stays
1521+ Some(Err(e)) => Self::refused(format!("{} refused: {}", verb, e)),
1522+ }
1523+ }
1524+
1525+ fn restore(&mut self, name: &str) -> OpResult {
1526+ let Some(path) = trash_entry(name) else { return Self::refused("no such trash entry") };
1527+ // strip the `<yyyymmdd-hhmmss>-` stamp
1528+ let restored = if name.len() > 16 && name.as_bytes()[8] == b'-' && name.as_bytes()[15] == b'-' {
1529+ name[16..].to_string()
1530+ } else {
1531+ name.to_string()
1532+ };
1533+ // text the editor could not save has no id: moved in, it would be a
1534+ // file fold ignores, so it stays in the trash (§11.5)
1535+ let text = std::fs::read_to_string(&path).unwrap_or_default();
1536+ let has_id = fold_core::parse::parse_frontmatter(&text).and_then(|fm| fm.id()).is_some();
1537+ if !has_id {
1538+ return Self::refused("this is text, not a file fold reads: copy what you need from it");
1539+ }
1540+ let dir = self.vault.dir.clone();
1541+ // never restore over an existing file, root.md least of all
1542+ let target = if restored != "root.md" && !dir.join(&restored).exists() {
1543+ restored.clone()
1544+ } else {
1545+ let stem = restored.strip_suffix(".md").unwrap_or(&restored);
1546+ (2..).map(|i| format!("{}-restored-{}.md", stem, i)).find(|n| !dir.join(n).exists()).unwrap()
1547+ };
1548+ if let Err(e) = fold_core::vault::move_file(&path, &dir.join(&target)) {
1549+ return Self::refused(format!("error: {}", e));
1550+ }
1551+ // the file came in: read the vault again
1552+ if let Err(e) = self.vault.reload() {
1553+ return Self::refused(format!("error: {}", e));
1554+ }
1555+ OpResult::new(true, format!("restored {} — check lists it until it is embedded somewhere", target), None)
1556+ }
1557+
1558+ // ------------------------------------------------------------ files
1559+
1560+ fn refresh(&mut self, force: bool) -> Refresh {
1561+ let copies = self.vault.conflict_files().unwrap_or_default();
1562+ let new_copy = copies.iter().any(|c| !self.conflict_copies.contains(c));
1563+ let changed = self.vault.changed_on_disk().unwrap_or(true);
1564+ let mut out = Refresh {
1565+ changed: false,
1566+ message: None,
1567+ raised: 0,
1568+ editor_text: None,
1569+ editor_generation: self.edit_generation,
1570+ editor_closed: false,
1571+ };
1572+ if !force && !new_copy && !changed {
1573+ return out;
1574+ }
1575+ out.changed = true;
1576+ // what came in: the files as they are against what was read, taken
1577+ // before the editor's save writes what was typed
1578+ let before = news::tops(&self.vault);
1579+ let after = self.vault.on_disk().map(|v| news::tops(&v));
1580+ let dirty = self.editor_dirty();
1581+ let refused = self.write_editor(false).err();
1582+ let edit = self.editor_key().map(|k| (k, self.editor_files()));
1583+ let typed = dirty && !self.editor_dirty();
1584+ let mut message = None;
1585+ if !copies.is_empty() {
1586+ let read = after.as_ref().ok().filter(|_| !dirty).cloned();
1587+ let was = read.unwrap_or_else(|| self.vault.on_disk().map(|v| news::tops(&v)).unwrap_or_else(|_| news::tops(&self.vault)));
1588+ match self.merge_conflict_files() {
1589+ Ok((0, _)) => {
1590+ let now = news::merged_over(after.unwrap_or_else(|_| was.clone()), &was, news::tops(&self.vault));
1591+ if !news::same_tops(&before, &now) {
1592+ message = Some(news::changed_outside(&before, &now, typed));
1593+ }
1594+ }
1595+ Ok((n, said)) => {
1596+ out.raised = n;
1597+ message = Some(said);
1598+ }
1599+ Err(e) => message = Some(format!("merge error: {}", e)),
1600+ }
1601+ } else {
1602+ match self.vault.reload() {
1603+ Ok(()) => {
1604+ let after = after.unwrap_or_else(|_| news::tops(&self.vault));
1605+ if !news::same_tops(&before, &after) || typed {
1606+ message = Some(news::changed_outside(&before, &after, typed));
1607+ }
1608+ }
1609+ Err(e) => message = Some(format!("reload error: {}", e)),
1610+ }
1611+ }
1612+ // a change to what the editor shows re-renders it; one elsewhere in
1613+ // a file it holds is taken in where it is (§11.2)
1614+ if let Some((key, files)) = edit {
1615+ if files != self.editor_files() && !self.editor_takes_in(&key) && self.drop_cut_blocks() {
1616+ match self.find_exact(&key) {
1617+ Some(r) => {
1618+ self.editor = Some(TextEditor::new(open_editor(&self.vault, r)));
1619+ self.edit_generation += 1;
1620+ out.editor_text = self.editor.as_ref().map(|e| e.text());
1621+ out.editor_generation = self.edit_generation;
1622+ }
1623+ None => {
1624+ self.editor = None;
1625+ out.editor_closed = true;
1626+ }
1627+ }
1628+ }
1629+ }
1630+ if let Some(e) = refused {
1631+ message = Some(match message {
1632+ Some(m) => format!("{} · {}", m, e),
1633+ None => e,
1634+ });
1635+ }
1636+ self.conflict_copies = self.vault.conflict_files().unwrap_or_default();
1637+ self.conflict_copies.retain(|c| copies.contains(c));
1638+ out.message = message;
1639+ out
1640+ }
1641+
1642+ /// The merge flow (§12.2): merge the sync-conflict copies. The pairs it
1643+ /// raised and what to say of them; a block cut in the editor and not
1644+ /// pasted back is moved, and not put back where it was (§5.2).
1645+ fn merge_conflict_files(&mut self) -> std::io::Result<(u32, String)> {
1646+ let before: Vec<NodeKey> = fold_core::merge::conflict_pairs_in_order(&self.vault).into_iter().map(|(_, t)| self.vault.key_of(t)).collect();
1647+ let moving: Vec<Id> = self.transit().into_iter().map(|(id, _)| id).collect();
1648+ fold_core::merge::merge_sync_conflicts_moving(&mut self.vault, false, &moving)?;
1649+ let raised: Vec<NRef> = fold_core::merge::conflict_pairs_in_order(&self.vault)
1650+ .into_iter()
1651+ .filter(|&(_, t)| !before.contains(&self.vault.key_of(t)))
1652+ .map(|(o, _)| o)
1653+ .collect();
1654+ let said = match raised.as_slice() {
1655+ [] => String::new(),
1656+ [ours] => format!("sync conflict in {}", self.named(*ours)),
1657+ [ours, rest @ ..] => format!("sync conflicts in {} and {} more", self.named(*ours), rest.len()),
1658+ };
1659+ Ok((raised.len() as u32, said))
1660+ }
1661+
1662+ // ------------------------------------------------------------ editor
1663+
1664+ fn edit_history(&mut self, undo: bool) -> Option<EditorText> {
1665+ let ed = self.editor.as_mut()?;
1666+ let done = if undo { ed.undo() } else { ed.redo() };
1667+ if !done {
1668+ return None;
1669+ }
1670+ let text = ed.text();
1671+ self.edit_generation += 1;
1672+ Some(EditorText { text, generation: self.edit_generation })
1673+ }
1674+
1675+ fn editor_dirty(&self) -> bool {
1676+ self.editor.as_ref().is_some_and(|e| e.dirty())
1677+ }
1678+
1679+ fn editor_state(&self) -> EditorState {
1680+ let ed = self.editor.as_ref();
1681+ EditorState {
1682+ dirty: ed.is_some_and(|e| e.dirty()),
1683+ can_undo: ed.is_some_and(|e| e.can_undo()),
1684+ can_redo: ed.is_some_and(|e| e.can_redo()),
1685+ }
1686+ }
1687+
1688+ fn edit_open(&mut self, key: &str) -> Result<EditorView, String> {
1689+ // an editor already open saves first, as on leaving it
1690+ self.write_editor(true)?;
1691+ let Some(r) = self.find(key) else { return Err("that node is gone".into()) };
1692+ let r = self.vault.tree.resolved_child(r);
1693+ let ed = TextEditor::new(open_editor(&self.vault, r));
1694+ self.edit_generation += 1;
1695+ let view = EditorView {
1696+ text: ed.text(),
1697+ title: self.vault.tree.node(r).title.clone(),
1698+ node: self.key(r),
1699+ generation: self.edit_generation,
1700+ };
1701+ self.editor = Some(ed);
1702+ self.edit_refused = false;
1703+ self.edit_uncopied = None;
1704+ Ok(view)
1705+ }
1706+
1707+ fn saved(&self, res: Result<(), String>) -> Saved {
1708+ let node = self.editor_node().map(|r| self.key(r));
1709+ match res {
1710+ Ok(()) => Saved { ok: true, message: "saved".into(), dirty: self.editor_dirty(), node },
1711+ Err(e) => Saved { ok: false, message: e, dirty: self.editor_dirty(), node },
1712+ }
1713+ }
1714+
1715+ /// Write the editor's dirty blocks (`fold-tui`'s `write_editor`); with
1716+ /// `release`, to leave it or before it is re-rendered: a block cut and
1717+ /// not pasted back is let go, and deleted (§5.2). A refused save keeps
1718+ /// the text, and says why.
1719+ fn write_editor(&mut self, release: bool) -> Result<(), String> {
1720+ if release && !self.editor_dirty() {
1721+ if let Some(ed) = self.editor.as_mut() {
1722+ ed.release_clip();
1723+ }
1724+ }
1725+ if !self.editor_dirty() {
1726+ return Ok(());
1727+ }
1728+ let Some(ed) = self.editor.as_mut() else { return Ok(()) };
1729+ // what another program changed beside the edited blocks is taken in
1730+ // before the snapshot, so undoing this save leaves it be
1731+ ed.buf.rebase_dirty(&mut self.vault);
1732+ let snap = ops::Snapshot::take(&self.vault, &format!("edit {}", edited(ed)));
1733+ let mut res = ed.buf.save_all(&mut self.vault);
1734+ if let Some(n) = res.as_ref().ok().copied().filter(|_| release) {
1735+ ed.release_clip();
1736+ res = ed.buf.save_all(&mut self.vault).map(|m| m.max(n));
1737+ }
1738+ self.record_edit(snap);
1739+ self.edit_refused = res.is_err();
1740+ res.map(|_| ()).map_err(|e| format!("error: {}", e))
1741+ }
1742+
1743+ /// `record_undo` for a save of the editor, which keeps a block cut in
1744+ /// it in one entry with its file (`OpLog::record_edit`, §10.10).
1745+ fn record_edit(&mut self, snap: ops::Snapshot) {
1746+ let inv = ops::Inverse::since(snap, &self.vault);
1747+ let in_transit = self.transit().into_iter().map(|(_, path)| path).collect();
1748+ self.log.record_edit(inv, &self.vault, self.editor.is_some(), in_transit);
1749+ }
1750+
1751+ /// The blocks the open editor holds in transit (§5.2), with their files.
1752+ fn transit(&self) -> Vec<(Id, String)> {
1753+ self.editor.as_ref().map(|ed| ed.buf.transit(&self.vault)).unwrap_or_default()
1754+ }
1755+
1756+ /// The editor's render root in the tree as the editor last read or
1757+ /// wrote it.
1758+ fn editor_node(&self) -> Option<NRef> {
1759+ let ed = self.editor.as_ref()?;
1760+ let info = ed.buf.owners.values().find(|i| i.parent.is_none())?;
1761+ match &info.id {
1762+ Some(id) => self.vault.tree.block_by_id(id),
1763+ None => {
1764+ let file = self.vault.file_index(&info.path)?;
1765+ let f = &self.vault.tree.files[file];
1766+ if info.is_root {
1767+ Some((file, f.root_node))
1768+ } else {
1769+ let i = f.nodes.iter().position(|n| n.kind != Kind::Root && n.span.start == info.start)?;
1770+ Some((file, i))
1771+ }
1772+ }
1773+ }
1774+ }
1775+
1776+ fn editor_key(&self) -> Option<NodeKey> {
1777+ self.editor_node().map(|r| self.vault.key_of(r))
1778+ }
1779+
1780+ /// The text of each file the open editor holds; None for one gone.
1781+ fn editor_files(&self) -> Vec<Option<String>> {
1782+ let Some(ed) = self.editor.as_ref() else { return Vec::new() };
1783+ let tree = &self.vault.tree;
1784+ let file = |i: &OwnerInfo| match &i.id {
1785+ Some(id) => tree.block_by_id(id).map(|r| r.0),
1786+ None => self.vault.file_index(&i.path),
1787+ };
1788+ ed.buf.owners.values().map(|i| file(i).map(|f| tree.files[f].text.clone())).collect()
1789+ }
1790+
1791+ /// After a reload changed a file the editor holds (§11.2): where its
1792+ /// node renders as the buffer shows it, the buffer takes in the files as
1793+ /// they are and stays.
1794+ fn editor_takes_in(&mut self, key: &NodeKey) -> bool {
1795+ let Some(r) = self.find_exact(key) else { return false };
1796+ let now = open_editor(&self.vault, r);
1797+ self.editor.as_mut().is_some_and(|ed| ed.buf.take_in(now))
1798+ }
1799+
1800+ /// Before the editor is re-rendered over text a reload changed: a block
1801+ /// cut there and not pasted back is deleted, as on a Revert (§5.2).
1802+ /// True when the editor can be re-rendered.
1803+ fn drop_cut_blocks(&mut self) -> bool {
1804+ if self.editor_dirty() {
1805+ return false;
1806+ }
1807+ let Some(ed) = self.editor.as_mut() else { return false };
1808+ let snap = ops::Snapshot::take(&self.vault, &format!("edit {}", edited(ed)));
1809+ let res = ed.buf.discard(&mut self.vault);
1810+ if res.is_ok() {
1811+ ed.release_clip();
1812+ ed.buf.dirty.clear();
1813+ }
1814+ self.record_edit(snap);
1815+ res.is_ok()
1816+ }
1817+
1818+ /// Before a verb writes files under an open editor (§10.6): the editor
1819+ /// saves first. Returns what it is open on and the files as they are.
1820+ fn editor_before_write(&mut self) -> Option<(NodeKey, ops::Snapshot)> {
1821+ self.editor.as_ref()?;
1822+ let _ = self.write_editor(true);
1823+ let key = self.editor_key()?;
1824+ Some((key, ops::Snapshot::take(&self.vault, "")))
1825+ }
1826+
1827+ /// After it: if any file changed, the editor is re-rendered over the
1828+ /// files as they now are, so its next save is not refused.
1829+ fn editor_after_write(&mut self, before: Option<(NodeKey, ops::Snapshot)>) {
1830+ let Some((key, snap)) = before else { return };
1831+ if ops::Inverse::since(snap, &self.vault).is_none() || self.editor_dirty() {
1832+ return;
1833+ }
1834+ self.editor = self.find_exact(&key).map(|r| TextEditor::new(open_editor(&self.vault, r)));
1835+ self.edit_generation += 1;
1836+ }
1837+
1838+ fn edit_revert(&mut self) -> OpResult {
1839+ let mut said = String::from("changes discarded");
1840+ if let Some((name, text)) = self.editor_text().filter(|_| self.edit_refused && self.editor_dirty()) {
1841+ match self.vault.trash_text(&name, &text) {
1842+ Ok(p) => {
1843+ let entry = p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
1844+ said = format!("{}; a copy is in the trash: {}", said, entry);
1845+ }
1846+ Err(_) if self.edit_uncopied.as_ref() == Some(&text) => said = format!("{}; no copy kept", said),
1847+ Err(e) => {
1848+ self.edit_uncopied = Some(text);
1849+ return Self::refused(format!("can't copy to the trash; Revert again drops the changes — {}", e));
1850+ }
1851+ }
1852+ }
1853+ self.edit_refused = false;
1854+ self.edit_uncopied = None;
1855+ let ed = self.editor.take();
1856+ let _ = self.vault.reload();
1857+ if let Some(ed) = ed {
1858+ // a block a save took out of its parent cannot be pasted back
1859+ // as itself any more: it is deleted now (§5.2)
1860+ let snap = ops::Snapshot::take(&self.vault, &format!("revert {}", edited(&ed)));
1861+ if let Err(e) = ed.buf.discard(&mut self.vault) {
1862+ said = format!("{}; error: {}", said, e);
1863+ }
1864+ self.record_edit(snap);
1865+ }
1866+ OpResult::new(true, said, None)
1867+ }
1868+
1869+ /// The editor's whole text, and the name the trash keeps it under.
1870+ fn editor_text(&self) -> Option<(String, String)> {
1871+ let ed = self.editor.as_ref()?;
1872+ let title = ed.buf.owners.values().find(|i| i.parent.is_none()).map(|i| i.title.as_str()).unwrap_or("");
1873+ let text = ed.buf.lines.iter().map(|l| format!("{}\n", l.text)).collect();
1874+ Some((format!("unsaved-{}.md", fold_core::slug(title)), text))
1875+ }
1876+
1877+ fn edit_keep(&mut self, release: bool) -> Option<String> {
1878+ let (name, text) = self.editor_text()?;
1879+ if self.write_editor(release).is_ok() || !self.editor_dirty() {
1880+ return None;
1881+ }
1882+ Some(match self.vault.trash_text(&name, &text) {
1883+ Ok(p) => format!(
1884+ "unsaved text kept in the trash: {}",
1885+ p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
1886+ ),
1887+ Err(e) => format!("unsaved text could not go to the trash: {}", e),
1888+ })
1889+ }
1890+}
1891+
1892+/// The block a save is named by: the one dirty block, else the edited node.
1893+fn edited(ed: &TextEditor) -> String {
1894+ let one = match ed.buf.dirty.as_slice() {
1895+ [o] => Some(o),
1896+ _ => None,
1897+ };
1898+ let info = one.and_then(|o| ed.buf.owners.get(o)).or_else(|| ed.buf.owners.values().find(|i| i.parent.is_none()));
1899+ quoted(info.map_or("", |i| i.title.as_str()))
1900+}
1901+
1902+#[cfg(test)]
1903+mod tests;
crates/fold-ffi/src/tests.rsnew file+437
@@ -0,0 +1,437 @@
1+//! The session as the app drives it: verbs, the op log, the editor's text
2+//! field, changes from outside.
3+
4+use super::*;
5+
6+fn vault(files: &[(&str, &str)]) -> (tempfile::TempDir, Arc<Session>) {
7+ // the trash is device-local state (§11.5): one for the whole run, as
8+ // the environment is the process's, whichever test runs
9+ static STATE: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
10+ let state = STATE.get_or_init(|| tempfile::tempdir().unwrap());
11+ std::env::set_var("XDG_STATE_HOME", state.path());
12+ let dir = tempfile::tempdir().unwrap();
13+ for (name, text) in files {
14+ std::fs::write(dir.path().join(name), text).unwrap();
15+ }
16+ let s = Session::open(dir.path().to_string_lossy().into_owned()).unwrap();
17+ (dir, s)
18+}
19+
20+fn read(dir: &tempfile::TempDir, name: &str) -> String {
21+ std::fs::read_to_string(dir.path().join(name)).unwrap()
22+}
23+
24+/// `edit_update` with the editor's current generation.
25+fn upd(s: &Session, text: String, cursor: Option<u32>) -> EditorState {
26+ let g = s.lock().edit_generation;
27+ s.edit_update(text, cursor, g)
28+}
29+
30+fn all() -> OutlineQuery {
31+ OutlineQuery { zoom: None, folded: vec![], unfolded: vec![], hide_done: false }
32+}
33+
34+fn row(s: &Session, title: &str) -> Row {
35+ s.outline(all()).rows.into_iter().find(|r| r.title == title).unwrap_or_else(|| panic!("no row {:?}", title))
36+}
37+
38+const ROOT: &str = "# Homelab\n\nTwo boxes.\n\n## NAS\n\n- [ ] Replace fan\n- [x] Scrub\n\n## Networking\n\n# Inbox\n";
39+
40+#[test]
41+fn outline_rows_counts_and_preview() {
42+ let (_d, s) = vault(&[("root.md", ROOT)]);
43+ let o = s.outline(all());
44+ let titles: Vec<(&str, u32)> = o.rows.iter().map(|r| (r.title.as_str(), r.depth)).collect();
45+ assert_eq!(
46+ titles,
47+ [("Homelab", 0), ("NAS", 1), ("Replace fan", 2), ("Scrub", 2), ("Networking", 1), ("Inbox", 0)]
48+ );
49+ let home = &o.rows[0];
50+ assert_eq!((home.open, home.total), (1, 2));
51+ assert_eq!(home.preview, "Two boxes.");
52+ assert_eq!(home.spelling, Spelling::Section);
53+ assert_eq!(o.rows[2].task, Task::Open);
54+ // folded, its children go; done hidden, Scrub goes
55+ let q = OutlineQuery { folded: vec![home.key.clone()], ..all() };
56+ assert_eq!(s.outline(q).rows.len(), 2);
57+ let q = OutlineQuery { hide_done: true, ..all() };
58+ assert!(!s.outline(q).rows.iter().any(|r| r.title == "Scrub"));
59+ // zoomed: the header and its children
60+ let nas = row(&s, "NAS");
61+ let o = s.outline(OutlineQuery { zoom: Some(nas.key.clone()), ..all() });
62+ assert_eq!(o.zoom.as_ref().unwrap().title, "NAS");
63+ assert_eq!(o.crumbs.iter().map(|c| c.title.as_str()).collect::<Vec<_>>(), ["Homelab", "NAS"]);
64+ assert_eq!(o.rows.len(), 2);
65+}
66+
67+#[test]
68+fn verbs_write_files_and_undo() {
69+ let (d, s) = vault(&[("root.md", ROOT)]);
70+ let fan = row(&s, "Replace fan");
71+ let r = s.toggle_task(fan.key.clone());
72+ assert!(r.ok, "{}", r.message);
73+ assert_eq!(r.message, "done: “Replace fan”");
74+ assert!(read(&d, "root.md").contains("- [x] Replace fan\n"));
75+ assert_eq!(s.outline(all()).undo.as_deref(), Some("mark “Replace fan” done"));
76+ let r = s.undo(None);
77+ assert!(r.ok, "{}", r.message);
78+ assert_eq!(read(&d, "root.md"), ROOT);
79+ assert!(s.redo().ok);
80+ assert!(read(&d, "root.md").contains("- [x] Replace fan\n"));
81+ // a node that is no task says so and writes nothing
82+ let nas = row(&s, "NAS");
83+ let r = s.toggle_task(nas.key.clone());
84+ assert!(!r.ok);
85+ // a stale key is refused, never another node
86+ assert!(!s.toggle_task("pnonsense".into()).ok);
87+}
88+
89+#[test]
90+fn add_move_indent_delete() {
91+ let (d, s) = vault(&[("root.md", ROOT)]);
92+ let fan = row(&s, "Replace fan");
93+ let r = s.add_node(Some(fan.key.clone()), "Order fan".into(), true, false);
94+ assert!(r.ok, "{}", r.message);
95+ assert!(read(&d, "root.md").contains("- [ ] Replace fan\n- [ ] Order fan\n- [x] Scrub\n"), "{}", read(&d, "root.md"));
96+ let new = r.node.unwrap();
97+ assert_eq!(s.node(new.clone()).unwrap().title, "Order fan");
98+ let r = s.indent(new.clone());
99+ assert!(r.ok, "{}", r.message);
100+ assert!(read(&d, "root.md").contains("- [ ] Replace fan\n - [ ] Order fan\n"), "{}", read(&d, "root.md"));
101+ let moved = r.node.unwrap();
102+ let r = s.outdent(moved.clone());
103+ assert!(r.ok, "{}", r.message);
104+ let r = s.move_sibling(r.node.unwrap(), false);
105+ assert!(r.ok, "{}", r.message);
106+ assert!(read(&d, "root.md").contains("- [ ] Order fan\n- [ ] Replace fan\n"), "{}", read(&d, "root.md"));
107+ let r = s.delete(r.node.unwrap());
108+ assert!(r.ok, "{}", r.message);
109+ assert_eq!(read(&d, "root.md"), ROOT);
110+ // the register holds it: paste it back after Scrub
111+ let r = s.paste(row(&s, "Scrub").key, true);
112+ assert!(r.ok, "{}", r.message);
113+ assert!(read(&d, "root.md").contains("- [x] Scrub\n- [ ] Order fan\n"), "{}", read(&d, "root.md"));
114+ // a child under a section with section children is a section
115+ let r = s.add_node(Some(row(&s, "Homelab").key), "Power".into(), false, true);
116+ assert!(r.ok, "{}", r.message);
117+ assert!(read(&d, "root.md").contains("## Networking\n\n## Power\n"), "{}", read(&d, "root.md"));
118+ assert!(!s.add_node(None, " ".into(), false, false).ok);
119+}
120+
121+#[test]
122+fn properties_make_a_block() {
123+ let (d, s) = vault(&[("root.md", ROOT)]);
124+ let fan = row(&s, "Replace fan");
125+ assert!(!s.set_property(fan.key.clone(), "due".into(), "next week".into()).ok);
126+ let r = s.set_property(fan.key.clone(), "due".into(), "2026-10-05".into());
127+ assert!(r.ok, "{}", r.message);
128+ let block = r.node.unwrap();
129+ let props = s.properties(block.clone());
130+ assert_eq!(props.len(), 1);
131+ assert_eq!((props[0].key.as_str(), props[0].value.as_str()), ("due", "2026-10-05"));
132+ let row = row(&s, "Replace fan");
133+ assert!(row.block);
134+ assert_eq!(row.due.as_deref(), Some("2026-10-05"));
135+ let files: Vec<String> = std::fs::read_dir(d.path())
136+ .unwrap()
137+ .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
138+ .filter(|n| n.ends_with(".md") && n != "root.md")
139+ .collect();
140+ assert_eq!(files.len(), 1);
141+ assert!(files[0].ends_with("~replace-fan.md"), "{:?}", files);
142+ let text = read(&d, &files[0]);
143+ assert!(text.starts_with("---\nid: "), "{}", text);
144+ assert!(text.contains("due: 2026-10-05\n---\n\n- [ ] Replace fan\n"), "{}", text);
145+ // checking a task block stamps done: (§8.2)
146+ assert!(s.toggle_task(row.key.clone()).ok);
147+ assert!(read(&d, &files[0]).contains("done: "));
148+ assert!(s.remove_property(block, "due".into()).ok);
149+ assert!(!read(&d, &files[0]).contains("due:"));
150+}
151+
152+#[test]
153+fn capture_lands_in_todays_inbox() {
154+ let (d, s) = vault(&[("root.md", ROOT)]);
155+ let r = s.capture("[ ] call the plumber".into(), false);
156+ assert!(r.ok, "{}", r.message);
157+ let today = jiff::Zoned::now().date().to_string();
158+ assert!(read(&d, "root.md").ends_with(&format!("# Inbox\n\n## {}\n\n- [ ] call the plumber\n", today)), "{}", read(&d, "root.md"));
159+ assert_eq!(s.node(r.node.unwrap()).unwrap().title, "call the plumber");
160+}
161+
162+#[test]
163+fn editor_saves_through_tags() {
164+ let block = "---\nid: racfer-hattes-mislup-nodrys\ndue: 2026-09-20\n---\n\n- [ ] Order new switch\n Two options.\n";
165+ let root = "# Networking\n\n- [ ] Replace the flaky switch\n![[racfer-hattes-mislup-nodrys]]\n";
166+ let (d, s) = vault(&[("root.md", root), ("racfer~order-new-switch.md", block)]);
167+ let net = row(&s, "Networking");
168+ let v = s.edit_open(net.key.clone()).unwrap();
169+ assert_eq!(v.text, "# Networking\n\n- [ ] Replace the flaky switch\n- [ ] Order new switch\n Two options.");
170+ // type into the block's line: only its file changes
171+ let text = v.text.replace("Two options.", "Two options, both cheap.");
172+ let st = upd(&s, text.clone(), None);
173+ assert!(st.dirty);
174+ let saved = s.edit_save();
175+ assert!(saved.ok, "{}", saved.message);
176+ assert_eq!(read(&d, "root.md"), root);
177+ assert!(read(&d, "racfer~order-new-switch.md").ends_with("- [ ] Order new switch\n Two options, both cheap.\n"));
178+ // Enter at the end of the parent's last line: the new line is root.md's
179+ let at = text.find("\n- [ ] Order").unwrap();
180+ let text2 = format!("{}\n- Label cables{}", &text[..at], &text[at..]);
181+ let cursor = text2[..at + "\n- Label cables".len()].chars().count() as u32;
182+ upd(&s, text2, Some(cursor));
183+ let saved = s.edit_close();
184+ assert!(saved.ok, "{}", saved.message);
185+ assert_eq!(read(&d, "root.md"), "# Networking\n\n- [ ] Replace the flaky switch\n- Label cables\n![[racfer-hattes-mislup-nodrys]]\n");
186+ // each save is one undo step
187+ assert_eq!(s.outline(all()).undo.as_deref(), Some("edit “Networking”"));
188+ assert!(s.undo(None).ok);
189+ assert_eq!(read(&d, "root.md"), root);
190+}
191+
192+#[test]
193+fn editor_cut_and_paste_moves_a_block() {
194+ let block = "---\nid: racfer-hattes-mislup-nodrys\ndue: 2026-09-20\n---\n\n- [ ] Order new switch\n";
195+ let root = "# Networking\n\n- a\n![[racfer-hattes-mislup-nodrys]]\n- b\n";
196+ let (d, s) = vault(&[("root.md", root), ("racfer~order-new-switch.md", block)]);
197+ let v = s.edit_open(row(&s, "Networking").key).unwrap();
198+ assert_eq!(v.text, "# Networking\n\n- a\n- [ ] Order new switch\n- b");
199+ // cut the block's line ...
200+ let cut = "# Networking\n\n- a\n- b";
201+ upd(&s, cut.into(), Some("# Networking\n\n- a\n".chars().count() as u32));
202+ // ... an autosave meanwhile keeps its file (in transit)
203+ assert!(s.edit_save().ok);
204+ assert!(d.path().join("racfer~order-new-switch.md").exists());
205+ // ... and paste it on a new line after b: Enter, then paste
206+ let enter = "# Networking\n\n- a\n- b\n";
207+ upd(&s, enter.into(), Some(enter.chars().count() as u32));
208+ let pasted = "# Networking\n\n- a\n- b\n- [ ] Order new switch\n";
209+ upd(&s, pasted.into(), Some(pasted.chars().count() as u32));
210+ let saved = s.edit_close();
211+ assert!(saved.ok, "{}", saved.message);
212+ assert_eq!(read(&d, "root.md"), "# Networking\n\n- a\n- b\n![[racfer-hattes-mislup-nodrys]]\n");
213+ assert_eq!(read(&d, "racfer~order-new-switch.md"), block);
214+ // and again, the lines pasted at the end of the last line in one go
215+ let (d, s) = vault(&[("root.md", root), ("racfer~order-new-switch.md", block)]);
216+ s.edit_open(row(&s, "Networking").key).unwrap();
217+ upd(&s, cut.into(), Some("# Networking\n\n- a\n".chars().count() as u32));
218+ let pasted = "# Networking\n\n- a\n- b\n- [ ] Order new switch";
219+ upd(&s, pasted.into(), Some(pasted.chars().count() as u32));
220+ let saved = s.edit_close();
221+ assert!(saved.ok, "{}", saved.message);
222+ assert_eq!(read(&d, "root.md"), "# Networking\n\n- a\n- b\n![[racfer-hattes-mislup-nodrys]]\n");
223+ assert_eq!(read(&d, "racfer~order-new-switch.md"), block);
224+}
225+
226+#[test]
227+fn editor_deleting_a_block_line_trashes_it_on_close() {
228+ let block = "---\nid: racfer-hattes-mislup-nodrys\n---\n\n- Order new switch\n";
229+ let root = "# Networking\n\n- a\n![[racfer-hattes-mislup-nodrys]]\n";
230+ let (d, s) = vault(&[("root.md", root), ("racfer~order-new-switch.md", block)]);
231+ s.edit_open(row(&s, "Networking").key).unwrap();
232+ // the line erased and joined to the one above, as Backspace does
233+ upd(&s, "# Networking\n\n- a".into(), Some(17));
234+ let saved = s.edit_close();
235+ assert!(saved.ok, "{}", saved.message);
236+ assert_eq!(read(&d, "root.md"), "# Networking\n\n- a\n");
237+ assert!(!d.path().join("racfer~order-new-switch.md").exists());
238+ // one undo puts back the embed and the file together (§10.10)
239+ let r = s.undo(None);
240+ assert!(r.ok, "{}", r.message);
241+ assert_eq!(read(&d, "root.md"), root);
242+ assert_eq!(read(&d, "racfer~order-new-switch.md"), block);
243+}
244+
245+#[test]
246+fn editor_undo_and_revert() {
247+ let (d, s) = vault(&[("root.md", "# A\n\ntext\n")]);
248+ let v = s.edit_open(row(&s, "A").key).unwrap();
249+ upd(&s, format!("{} more", v.text), None);
250+ let undone = s.edit_undo().unwrap();
251+ assert_eq!(undone.text, "# A\n\ntext");
252+ // a change made to the text before the undo replaced it is dropped
253+ let stale = s.edit_update("# A\n\ntext more, stale".into(), None, undone.generation - 1);
254+ assert!(!stale.dirty || s.edit_text().as_deref() == Some("# A\n\ntext"));
255+ assert_eq!(s.edit_text().as_deref(), Some("# A\n\ntext"));
256+ assert_eq!(s.edit_redo().map(|t| t.text).as_deref(), Some("# A\n\ntext more"));
257+ let r = s.edit_revert();
258+ assert!(r.ok);
259+ assert_eq!(read(&d, "root.md"), "# A\n\ntext\n");
260+ assert!(s.edit_text().is_none());
261+}
262+
263+#[test]
264+fn outside_changes_are_taken_in_and_said() {
265+ let (d, s) = vault(&[("root.md", ROOT)]);
266+ assert!(!s.refresh(false).changed);
267+ std::fs::write(d.path().join("root.md"), format!("{}\n- from the phone\n", ROOT.trim_end())).unwrap();
268+ let r = s.refresh(false);
269+ assert!(r.changed);
270+ assert_eq!(r.message.as_deref(), Some("↻ changed outside fold: Inbox (+1 item)"));
271+ assert!(s.outline(all()).rows.iter().any(|r| r.title == "from the phone"));
272+ // a verb on a file changed under it refuses, writing nothing
273+ let fan = row(&s, "Replace fan");
274+ std::fs::write(d.path().join("root.md"), "# Other\n").unwrap();
275+ let r = s.toggle_task(fan.key);
276+ assert!(!r.ok);
277+ assert_eq!(read(&d, "root.md"), "# Other\n");
278+}
279+
280+#[test]
281+fn sync_conflict_copies_merge_into_pairs() {
282+ let (d, s) = vault(&[("root.md", "# Notes\n\n- milk\n")]);
283+ std::fs::write(d.path().join("root.sync-conflict-20260927-100000-PHONE.md"), "# Notes\n\n- milk, oat\n").unwrap();
284+ let r = s.refresh(false);
285+ assert!(r.changed);
286+ assert_eq!(r.raised, 0, "{:?}", r.message);
287+ // milk and milk, oat are two nodes: nothing is ever treated as deleted
288+ let o = s.outline(all());
289+ assert!(o.rows.iter().any(|r| r.title == "milk, oat"));
290+ std::fs::write(d.path().join("root.md"), "# Notes\n\nmine\n").unwrap();
291+ std::fs::write(d.path().join("root.sync-conflict-20260927-110000-PHONE.md"), "# Notes\n\ntheirs\n").unwrap();
292+ let r = s.refresh(false);
293+ assert_eq!(r.raised, 1, "{:?}", r.message);
294+ let pairs = s.conflicts();
295+ assert_eq!(pairs.len(), 1);
296+ assert_eq!(pairs[0].from, "PHONE 09-27 11:00");
297+ let r = s.resolve(pairs[0].theirs.clone(), Keep::Theirs);
298+ assert!(r.ok, "{}", r.message);
299+ assert!(s.conflicts().is_empty());
300+ assert!(read(&d, "root.md").contains("theirs"));
301+}
302+
303+#[test]
304+fn search_and_targets() {
305+ let (_d, s) = vault(&[("root.md", ROOT)]);
306+ let hits = s.search("fan".into());
307+ assert_eq!(hits[0].title, "Replace fan");
308+ assert_eq!(hits[0].path, "Homelab › NAS");
309+ let hits = s.search("boxes".into());
310+ assert_eq!(hits[0].title, "Homelab");
311+ assert_eq!(hits[0].excerpt.as_deref(), Some("Two boxes."));
312+ // a node never goes into its own subtree
313+ let home = row(&s, "Homelab");
314+ let t = s.targets(String::new(), Some(home.key));
315+ assert_eq!(t.iter().map(|h| h.title.as_str()).collect::<Vec<_>>(), ["Inbox"]);
316+}
317+
318+#[test]
319+fn reading_lines_carry_nodes() {
320+ let (_d, s) = vault(&[("root.md", ROOT)]);
321+ let lines = s.reading(Some(row(&s, "NAS").key));
322+ assert_eq!(lines[0].kind, ReadKind::Title);
323+ assert_eq!(lines[0].heading, 1);
324+ assert_eq!(lines[0].text, "# NAS");
325+ let fan = lines.iter().find(|l| l.title == "Replace fan").unwrap();
326+ assert_eq!(fan.task, Task::Open);
327+ assert_eq!(fan.node.as_deref(), Some(row(&s, "Replace fan").key.as_str()));
328+}
329+
330+#[test]
331+fn trash_and_restore() {
332+ let block = "---\nid: racfer-hattes-mislup-nodrys\n---\n\n- Order new switch\n";
333+ let root = "# Networking\n\n![[racfer-hattes-mislup-nodrys]]\n";
334+ let (d, s) = vault(&[("root.md", root), ("racfer~order-new-switch.md", block)]);
335+ assert!(s.delete(row(&s, "Order new switch").key).ok);
336+ let trash = s.trash();
337+ let entry = trash.iter().find(|e| e.name.ends_with("racfer~order-new-switch.md")).unwrap();
338+ assert_eq!(s.trash_text(entry.name.clone()).as_deref(), Some(block));
339+ assert!(s.trash_text("../root.md".into()).is_none());
340+ let r = s.restore(entry.name.clone());
341+ assert!(r.ok, "{}", r.message);
342+ assert_eq!(read(&d, "racfer~order-new-switch.md"), block);
343+}
344+
345+#[test]
346+fn a_sync_under_the_editor_re_renders_it_and_drops_stale_typing() {
347+ let (d, s) = vault(&[("root.md", "# Notes\n\n- milk\n")]);
348+ let v = s.edit_open(row(&s, "Notes").key).unwrap();
349+ // the phone adds to the same node meanwhile
350+ std::fs::write(d.path().join("root.md"), "# Notes\n\n- milk\n- eggs\n").unwrap();
351+ let r = s.refresh(false);
352+ assert_eq!(r.editor_text.as_deref(), Some("# Notes\n\n- milk\n- eggs"));
353+ assert!(r.editor_generation > v.generation);
354+ // typing that was on its way, made to the old text, is not laid over it
355+ let st = s.edit_update("# Notes\n\n- milk!".into(), None, v.generation);
356+ assert!(!st.dirty);
357+ assert!(s.edit_close().ok);
358+ assert_eq!(read(&d, "root.md"), "# Notes\n\n- milk\n- eggs\n");
359+}
360+
361+#[test]
362+fn a_verb_under_the_editor_saves_it_first_and_hands_back_its_text() {
363+ let (d, s) = vault(&[("root.md", "# Notes\n\n- milk\n\n# Inbox\n")]);
364+ let v = s.edit_open(row(&s, "Notes").key).unwrap();
365+ upd(&s, "# Notes\n\n- milk\n- bread".into(), Some(22));
366+ // text shared to fold while the editor is open
367+ let r = s.capture("from share".into(), false);
368+ assert!(r.ok, "{}", r.message);
369+ assert!(read(&d, "root.md").contains("- milk\n- bread\n"), "{}", read(&d, "root.md"));
370+ // the editor was re-rendered: the result says over what, and the new
371+ // generation takes typing again
372+ assert!(r.editor_generation > v.generation);
373+ let text = r.editor_text.unwrap_or_else(|| s.edit_text().unwrap());
374+ let st = s.edit_update(format!("{}\n- eggs", text), None, r.editor_generation);
375+ assert!(st.dirty);
376+ assert!(s.edit_close().ok);
377+ assert!(read(&d, "root.md").contains("- bread\n- eggs\n"), "{}", read(&d, "root.md"));
378+}
379+
380+#[test]
381+fn a_verb_under_the_editor_finds_its_node_after_the_save() {
382+ let (d, s) = vault(&[("root.md", "# A\n\n- [ ] t1\n- [ ] t2\n")]);
383+ let t2 = row(&s, "t2").key;
384+ s.edit_open(row(&s, "A").key).unwrap();
385+ // a line typed above t2, not saved yet
386+ upd(&s, "# A\n\n- [ ] t1\n- new\n- [ ] t2".into(), Some(19));
387+ let r = s.toggle_task(t2);
388+ assert!(r.ok, "{}", r.message);
389+ assert_eq!(r.message, "done: “t2”");
390+ assert_eq!(read(&d, "root.md"), "# A\n\n- [ ] t1\n- new\n- [x] t2\n");
391+}
392+
393+#[test]
394+fn a_cut_block_released_by_a_verb_does_not_leave_stale_nodes() {
395+ let one = "---\nid: dozzod-binwes-talsun-worbec\n---\n\n- one\n";
396+ let two = "---\nid: racfer-hattes-mislup-nodrys\n---\n\n- [ ] two\n";
397+ let root = "# A\n\n![[dozzod-binwes-talsun-worbec]]\n- after\n\n# B\n\n![[racfer-hattes-mislup-nodrys]]\n";
398+ let (d, s) = vault(&[("root.md", root), ("dozzod~one.md", one), ("racfer~two.md", two)]);
399+ let two_key = row(&s, "two").key;
400+ let v = s.edit_open(row(&s, "A").key).unwrap();
401+ assert_eq!(v.text, "# A\n\n- one\n- after");
402+ // the block's line cut, and saved in transit
403+ upd(&s, "# A\n\n- after".into(), Some(5));
404+ assert!(s.edit_save().ok);
405+ assert!(d.path().join("dozzod~one.md").exists());
406+ // a verb elsewhere: the editor's save lets the block go, trashing its
407+ // file and renumbering the files after it
408+ let r = s.toggle_task(two_key);
409+ assert!(r.ok, "{}", r.message);
410+ assert!(!d.path().join("dozzod~one.md").exists());
411+ assert!(read(&d, "racfer~two.md").contains("- [x] two"));
412+}
413+
414+#[test]
415+fn undo_from_a_message_undoes_only_that_change() {
416+ let (d, s) = vault(&[("root.md", ROOT)]);
417+ assert!(s.toggle_task(row(&s, "Replace fan").key).ok);
418+ let o = s.outline(all());
419+ let mark = UndoMark { depth: o.undo_depth, description: o.undo.clone().unwrap() };
420+ // something else changed since the message went up
421+ assert!(s.toggle_task(row(&s, "Scrub").key).ok);
422+ let r = s.undo(Some(mark.clone()));
423+ assert!(!r.ok);
424+ assert!(r.message.contains("“Scrub”"), "{}", r.message);
425+ assert!(s.undo(None).ok);
426+ let r = s.undo(Some(mark));
427+ assert!(r.ok, "{}", r.message);
428+ assert_eq!(read(&d, "root.md"), ROOT);
429+}
430+
431+/// `fold check` as the app shows it: an ignored file once (§4.1, §15.7).
432+#[test]
433+fn diagnostics_list_an_ignored_file_once() {
434+ let (_d, s) = vault(&[("root.md", ROOT), ("foreign.md", "# no id here\n")]);
435+ let ignored: Vec<String> = s.diagnostics().into_iter().filter(|d| d.starts_with("foreign.md")).collect();
436+ assert_eq!(ignored, ["foreign.md: ignored: no valid id in frontmatter"]);
437+}
crates/fold-ffi/uniffi-bindgen.rsnew file+3
@@ -0,0 +1,3 @@
1+fn main() {
2+ uniffi::uniffi_bindgen_main()
3+}
crates/fold-ffi/uniffi.tomlnew file+3
@@ -0,0 +1,3 @@
1+[bindings.kotlin]
2+package_name = "soy.iko.fold.core"
3+cdylib_name = "fold_ffi"
crates/fold-tui/Cargo.toml+4−5
@@ -7,10 +7,7 @@ edition = "2021"
77fold-core = { path = "../fold-core" }
88ratatui.workspace = true
99crossterm.workspace = true
10−tui-textarea.workspace = true
1110notify.workspace = true
12−jiff.workspace = true
13−nucleo.workspace = true
1411unicode-width.workspace = true
1512anyhow.workspace = true
1613# syntax highlighting of fenced code in the reading pane (§10.9); grammar
@@ -148,7 +145,9 @@ tree-sitter-yaml = "=0.7.2"
148145tree-sitter-zig = "=1.1.2"
149146tree-sitter-zsh = "=0.63.4"
150147
148+# SIGTERM, SIGHUP and SIGINT end a session as a quit does (§10.6)
149+[target.'cfg(unix)'.dependencies]
150+signal-hook = "0.3.18"
151+
151152[dev-dependencies]
152−insta = "1"
153153tempfile = "3"
154−crossterm.workspace = true
crates/fold-tui/src/app.rs+1952−438

This diff is too big to show here.

crates/fold-tui/src/app/action.rs+12

This diff is too big to show here.

crates/fold-tui/src/app/editor/helix.rs+154−54

This diff is too big to show here.

crates/fold-tui/src/app/editor/mod.rs+389−55

This diff is too big to show here.

crates/fold-tui/src/app/editor/normal.rs+28−12

This diff is too big to show here.

crates/fold-tui/src/app/editor/vim.rs+230−50

This diff is too big to show here.

crates/fold-tui/src/app/markdown.rs+49−18

This diff is too big to show here.

crates/fold-tui/src/app/mouse.rs+114−42

This diff is too big to show here.

crates/fold-tui/src/app/ui.rs+403−102

This diff is too big to show here.

crates/fold-tui/src/app/view.rsnew file+157

This diff is too big to show here.

crates/fold-tui/src/app/wrap.rs+47−10

This diff is too big to show here.

crates/fold-tui/tests/conflict_watch.rs+134

This diff is too big to show here.

crates/fold-tui/tests/editor.rs+1407

This diff is too big to show here.

crates/fold-tui/tests/fixes.rs+432

This diff is too big to show here.

crates/fold-tui/tests/mouse.rs+659−4

This diff is too big to show here.

crates/fold-tui/tests/tui.rs+66−1

This diff is too big to show here.

crates/fold-tui/tests/ux.rsnew file+3820

This diff is too big to show here.

crates/fold-tui/tests/vim_search.rsnew file+61

This diff is too big to show here.

crates/fold-tui/tests/wrap.rs+228−1

This diff is too big to show here.

docs/icon.pngnew file

Binary file not shown.

flake.locknew file+27

This diff is too big to show here.

flake.nixnew file+75

This diff is too big to show here.