on master help sign in
91 files changed+15673−1258
since splitsnapshots
unifiedsplit
Cargo.lock+408−238 Cargo.toml+1−3 README.md+24−6 SPEC.md+249−103 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+359 crates/fold-core/Cargo.toml−3 crates/fold-core/src/check.rs+9−24 crates/fold-core/src/edit.rs+71−24 crates/fold-core/src/lib.rs+1 crates/fold-core/src/merge.rs+128−101 crates/fold-core/src/news.rs+106 crates/fold-core/src/ops.rs+462−130 crates/fold-core/src/parse.rs+50−11 crates/fold-core/src/render.rs+1−1 crates/fold-core/src/tree.rs+37−14 crates/fold-core/src/vault.rs+87−67 crates/fold-core/tests/edit.rs+88 crates/fold-core/tests/merge_check.rs+137−1 crates/fold-core/tests/ops.rs+54 crates/fold-core/tests/ordering.rs+80−4 crates/fold-core/tests/resolve.rs+274 crates/fold-core/tests/roundtrip.rs+9−9 crates/fold-core/tests/undo.rs+138−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+1341−417 crates/fold-tui/src/app/action.rs+6 crates/fold-tui/src/app/mouse.rs+87−36 crates/fold-tui/src/app/ui.rs+264−42 crates/fold-tui/tests/editor.rs+8−8 crates/fold-tui/tests/fixes.rs+12 crates/fold-tui/tests/ux.rs+3820 docs/icon.png
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.md+24−6
@@ -1,3 +1,5 @@
1+<p align="center"><img src="docs/icon.png" width="128" height="128" alt="fold"></p>
2+
13# fold
24
35One outline of notes and tasks, stored as plain Markdown, in the terminal.
@@ -57,7 +59,8 @@ The TUI is mouse-first, and every action also has a key.
5759
5860Keys, in the outline: `j`/`k` move, `h`/`l` fold, `Enter` zooms, `e` edits, `x` checks a
5961task, `n`/`N` add a sibling or child, `J`/`K` move a node, `>`/`<` indent, `r` moves a
60−subtree elsewhere, `u`/`U` undo and redo, `?` shows everything. Everything is saved as you
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
6164go; `q` quits. The view — the pane, folds, zoom, wrapping, the editor's keys — is remembered
6265per vault for next time.
6366
@@ -65,7 +68,10 @@ per vault for next time.
6568
6669`e` edits the selected subtree's Markdown in the reading pane. It saves on its own — when
6770you pause, when you leave a part of the tree that lives in another file, and when you're
68−done. Pick a keymap with `--keys` or `$FOLD_KEYS`, or click `⌨` in the editor's border:
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:
6975
7076- **normal** (default) — a conventional editor like micro: Shift+arrows select,
7177 `Ctrl-C`/`X`/`V`, `Ctrl-Z`/`Y`, `Ctrl-S`, `Ctrl-F`.
@@ -104,7 +110,8 @@ Two boxes in the closet, one at Hetzner.
104110
105111Nothing else is special. Deleting goes to a trash (`$XDG_STATE_HOME/fold/trash`), and
106112Syncthing conflict copies are merged node by node, with anything that differs kept as a
107−`conflict:` block to resolve in the app.
113+`conflict:` block to resolve in the app: it is marked `⚠` in the outline, and a click on the
114+`⚠` shows both sides.
108115
109116## The command line
110117
@@ -118,6 +125,15 @@ fold trash list | fold trash restore ID
118125fold help
119126```
120127
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+
121137## Developing
122138
123139```sh
@@ -126,6 +142,8 @@ cargo test --workspace
126142nix flake check # the same tests, in the Nix sandbox
127143```
128144
129−The code is three crates: `fold-core` (parsing, rendering, the operations, merge),
130−`fold-tui` (the ratatui app) and `fold-cli` (the `fold` binary). [SPEC.md](SPEC.md) is the
131−design: the data model, the file format, every operation and the laws they keep.
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+249−103
@@ -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 (remembered with the view, §10.1).
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
@@ -845,16 +849,43 @@ Four regions, each of them live under the pointer:
845849 count). Done tasks are dimmed and struck through; sections are bold. Long titles end in
846850 `…`. Beside the reading pane, the date and count sit at the pane's right edge and rows
847851 show titles only. Without it, they follow the title, and after them, dimmed, comes the
848− first line of the node's own text, so no prose is out of sight.
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.
849856- The **reading pane**: `render(target, 1, true)`, the zoom root's or else the selected
850857 node's, with light Markdown styling (§10.9); a block's properties appear as a dimmed
851− `⚑` 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
852860 buttons *Edit* and `⋯` (the node menu). While editing, the pane is the editor (§10.6) and
853861 its buttons are *Done* and *Revert*.
854862- The **status bar**: the last message on the left (at start, a hint at the gestures and
855863 `?`, cut down to *right-click for actions · ? help* where it doesn't fit); on the right
856− the unresolved conflicts (click to resolve), *done hidden* when `zd` is on (click to
857− show), the file, and the save state.
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.
858889
859890The outline fills the screen. The reading pane is hidden until asked for: `zp` or the top
860891bar's `◨` (*Reading pane*) shows it, `Tab` shows it and moves focus there, and the editor
@@ -883,6 +914,7 @@ title ends in `…`.
883914| click | an outline row | select it (the reading pane follows) |
884915| click | `▸` / `▾` | fold / unfold |
885916| click | `☐` / `☑`, in either pane | toggle the task |
917+| click | a conflict copy's `⚠`, in either pane | the conflict view at its pair (§10.7) |
886918| click | `⋯` (shown on the selected and the hovered row) | the node menu |
887919| click | a breadcrumb segment | zoom there |
888920| click | a link in the reading pane | open it |
@@ -899,22 +931,40 @@ title ends in `…`.
899931While dragging, the target row is highlighted (*into*) or marked with `▶` and a bar
900932(*before*), the row being moved is dimmed, the status bar spells out the move, and the pane
901933scrolls when the pointer reaches its edge. Both drops are clamped by the ordering rule
902−(§3.1) and say so when they are; a drop into the node's own subtree is refused. One drag is
903−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.
904947
905948**The node menu** (right-click, `⋯`, or `m`) lists every action on a node, each with its key:
906949*Edit · Zoom in · Properties… | New sibling · New child | Done / reopen · Task on / off ·
907950Heading ↔ bullet · Make block | Move up · Move down · Indent · Outdent · Move to… · Archive |
908−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.
909956
910957**Pickers instead of typing.** Where an action needs a node — *Move to…*, *Go to…* — the
911958prompt is a list of every node, title first and path dimmed, narrowed as you type (title
912959prefix, then title, then path) and picked with a click or `↑`/`↓` and `Enter`. A typed id,
913−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 `⚠`.
914963
915964Every popup — node menu, prompt, properties, filter, commands, help — has its buttons on
916965its bottom border (*OK*, *Close*, …), a list you can click and scroll, and closes on a click
917−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.
918968
919969### 10.2 Modes
920970
@@ -942,25 +992,31 @@ the popup — closes the topmost thing.
942992| `Ctrl-d` / `Ctrl-u` | half-page down / up |
943993| `Enter` | zoom: reading pane shows the cursor node; reading pane takes focus |
944994| `Backspace` | zoom out to parent |
945−| `>` / `<` | 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 |
946−| `~` | 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 |
947−| `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 |
948998| `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` |
949999| `e` | edit the subtree's Markdown in the built-in editor; saves as you go (§10.6) |
9501000| `a` | property editor: a form over the node's properties (§10.6); first property on a plain node makes it a block |
9511001| `x` | toggle task open / done: the checkbox, plus `done:` on a block |
9521002| `t` | toggle task-ness: adds or removes the checkbox (and a block's `done:`) |
9531003| `s` | make the node a block |
954−| `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) |
9551005| `p` / `P` | paste register after / before cursor as sibling, clamped by the ordering rule (§3.1) |
9561006| `r` | refile: fuzzy-pick a destination; `Ctrl-Enter` = as first child |
9571007| `c` / `C` | capture to the inbox as bullet / as task |
9581008| `/` | filter box (§10.5) |
9591009| `m` | the node menu (§10.1) |
960−| `:` | command palette (§10.8); `?` help |
1010+| `:` | command palette (§10.8); `?` or `F1` help |
9611011| `u` / `U` | undo / redo |
9621012| `q` | quit (nothing is ever unsaved in normal mode) |
9631013
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+
9641020### 10.4 Reading pane — normal mode
9651021
9661022| Key | Action |
@@ -973,15 +1029,15 @@ the popup — closes the topmost thing.
9731029| `o` | open external link under cursor (`xdg-open` / `open`) |
9741030| `[[` / `]]` | previous / next heading |
9751031| `/` | in-block search; `n` / `N` next / previous match |
976−| `:` `?` `m` `c` `C` `u` `U` `z…` | as in the outline; `m` is the menu of the node under the cursor |
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 |
9771033
9781034### 10.5 Filter box
9791035
9801036`/`, or *Filter* in the top bar, opens a popup with an input and the hits below it:
981−fuzzy title match (nucleo) and full-text match over every text child, each hit shown as its
982−title with its path dimmed. Clicking a hit — or `↑`/`↓` and `Enter` — unfolds its
983−ancestors and selects it. `Esc` or a click outside closes. Creating nodes is `n` / `N`
984−(§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).
9851041
9861042### 10.6 Editing
9871043
@@ -998,11 +1054,39 @@ app keeps track. Blocks are saved on their own:
9981054- when the cursor moves out of a block that has changed;
9991055- after 750 ms without a keystroke;
10001056- on `Esc` or *Done* (back to normal mode), on any outline verb, before a reload caused by
1001− an external change, and on quit.
1002−
1003−The pane's border shows the title of the block the cursor is in and a dot while something
1004−is unsaved, and lines of other blocks in the subtree are drawn a shade dimmer — the only
1005−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.
10061090
10071091**Keymaps.** The editor speaks one of three keymaps, over the same text, cursor, selection,
10081092clipboard and undo:
@@ -1031,6 +1115,7 @@ All three take `:` commands — `:w` save, `:q` / `:wq` / `:x` done, `:q!` / `:e
10311115selects, a double-click selects a word, the wheel scrolls, and text pasted into the
10321116terminal is inserted as typed. Copying also sets the system clipboard (OSC 52). Undo
10331117inside 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.
10341119
10351120The keymap is `$FOLD_KEYS` (`normal`, `vim`, `helix`), or `--keys`; the *Editor keys*
10361121action, or the `⌨` label in the editor's border, switches it, and the choice is remembered
@@ -1055,9 +1140,20 @@ and each saves the editor first.
10551140
10561141### 10.7 Conflict view
10571142
1058−Entered when a `.sync-conflict-*` file is detected or a splice hits a changed span. Shows
1059−each conflict pair (ours in place, the `conflict:` block right after it) side by side; `o` keeps ours,
1060−`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.
10611157The view's top bar carries the same as buttons — *Previous*, *Next*, *Keep ours*, *Keep
10621158theirs*, *Keep both*, *Edit ours*, *Close* — and the two versions sit side by side, this
10631159device's on the left.
@@ -1076,7 +1172,8 @@ follows (§10.1). `Esc` or a click outside closes.
10761172There is no command syntax: nothing is typed except the search and the argument. Every
10771173action is in the palette under a readable name, and the four that have no key or button
10781174live only there: *Clear done*, *Canonicalize*, *Merge sync conflicts*, *Resolve
1079−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.
10801177
10811178### 10.9 Markdown styling in the reading pane
10821179
@@ -1115,8 +1212,19 @@ before and after, where *absent* stands for a file the operation created or dele
11151212a block, trashing one). An operation that changed nothing leaves no entry, so it does not
11161213clear the redo stack. Undo checks every touched file against its *after* text, and redo
11171214against its *before* text; if any differs — another editor or a sync changed it since — it
1118−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
11191219operation 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.
11201228
11211229---
11221230
@@ -1124,7 +1232,7 @@ operation did not touch are never written by undo, so an external edit to them s
11241232
11251233### 11.1 Writes
11261234
1127−- **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`.
11281236 Never truncate in place.
11291237- **Span-preserving**: an edit replaces exactly the bytes of the affected node(s) — for a
11301238 property change, exactly the frontmatter lines that changed. Every other byte is copied
@@ -1136,14 +1244,32 @@ operation did not touch are never written by undo, so an external edit to them s
11361244### 11.2 Watcher and reload
11371245
11381246`notify`-based recursive watcher on the vault, 200 ms debounce, ignoring the patterns in
1139−§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.
11401252
11411253- A changed file that the user is not editing is re-parsed; the cursor is re-attached by
11421254 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)`.
11431257- A changed file with a built-in edit in progress: the editor saves its dirty blocks first;
11441258 a block whose span hash no longer matches is merged two-way (§5.2 step 5), then the file
1145− is re-parsed and the buffer re-rendered around the cursor.
1146−- 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.
11471273- A deleted file that was a block marks its embed broken; nothing is written.
11481274- Editing the same vault in Helix at the same time is a supported workflow.
11491275
@@ -1161,16 +1287,20 @@ during atomic writes.
11611287### 11.4 Ignore patterns
11621288
11631289Never parsed, never written, never deleted: dotfiles and dot-directories (`.git`, `.jj`,
1164−`.stfolder`, `.stversions`, `.stignore`, `.obsidian`), `.syncthing.*.tmp`, `*.notes-tmp`,
1290+`.stfolder`, `.stversions`, `.stignore`, `.obsidian`), `.syncthing.*.tmp`, `*.fold-tmp`,
11651291`*.tmp`, and any file not ending in `.md`. `*.sync-conflict-*.md` is parsed only by the merge
11661292engine.
11671293
11681294### 11.5 Trash
11691295
11701296Deleted subtrees and resolved `conflict:` blocks are written to
1171−`$XDG_STATE_HOME/notes/trash/<timestamp>-<id-or-name>.md` (device-local, never synced) before
1172−removal. `notes trash list|restore` manages it. The app never deletes user content without
1173−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.
11741304
11751305---
11761306
@@ -1182,12 +1312,13 @@ a trash copy.
11821312 winner plus a `name.sync-conflict-<date>-<time>-<device>.md` copy of the loser.
11831313- Therefore: keep concurrently-edited things in different files where possible (a
11841314 `Inbox` as a block; blocks), and make the app the thing that understands conflict files.
1185−- 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`.
11861316
11871317### 12.2 Detection
11881318
1189−The watcher (or `notes merge`, or the startup scan) finds `X.sync-conflict-*.md` next to
1190−`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.
11911322
11921323Before merging, the engine compares the two files' `id` keys. If they differ, this is not a
11931324conflict but a **prefix collision** — two blocks that happened to get the same filename
@@ -1198,10 +1329,11 @@ and nothing is lost.
11981329
11991330### 12.3 Merge inputs
12001331
1201−Ours `O` is `X.md`; theirs `T` is the conflict file — or, for an in-session conflict
1202−(§5.2 step 5), the text the user just edited. There is no stored common ancestor (§11.3),
1203−so every merge is **two-way**: the engine can tell *that* two versions differ, not *which*
1204−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.
12051337
12061338### 12.4 Node-level merge
12071339
@@ -1240,8 +1372,8 @@ algorithm is deterministic and idempotent.
12401372
12411373### 12.5 Resolution
12421374
1243−The conflict view (§10.7) lists every `conflict:` block, each shown against the sibling
1244−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:
12451377
12461378- **keep ours** — delete the conflict block and its embed (to trash);
12471379- **keep theirs** — replace ours' title and children (text and nodes) with the conflict block's,
@@ -1251,7 +1383,15 @@ before it, and the status line counts them. Resolving a pair:
12511383 sibling.
12521384
12531385Unresolved pairs are ordinary blocks; they sync to every device and are visible in any
1254−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.
12551395
12561396### 12.6 History (outside the app)
12571397
@@ -1264,16 +1404,16 @@ notices it.
12641404
12651405## 13. CLI
12661406
1267−All commands take `--vault PATH` (default: `$NOTES_VAULT`, else the nearest ancestor of
1268−`$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`).
12691409
12701410| Command | Purpose |
12711411|---|---|
1272−| `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) |
1273−| `notes capture [TEXT] [--to TARGET] [--task]` | append to the inbox (stdin if no TEXT) |
1274−| `notes check [--fix]` | diagnostics with source spans (§15.7); `--fix` rewrites the vault in canonical form (§4.2) and repairs filenames (§6.4) |
1275−| `notes merge [--dry-run]` | process sync-conflict files non-interactively; list leftovers |
1276−| `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 |
12771417
12781418That is the whole CLI: the entry points a shell, a hotkey, a cron job on the homelab, or a
12791419recovery session needs. Everything else is a TUI verb.
@@ -1290,17 +1430,19 @@ are property-triggered or manual only; `done:` records a date; conflict copies a
12901430blocks marked `conflict:`. Each was a knob nobody needs to agree on with a file. Two vaults that
12911431contain the same Markdown behave identically.
12921432
1293−There are no device settings either. The app reads no file outside the vault. What other
1294−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:
12951435
1296−- the vault: `--vault PATH`, else `$NOTES_VAULT`, else the nearest ancestor of `$PWD`
1297− containing `root.md`, else `~/notes`;
1298−- 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);
12991439- the editor's keymap: `--keys`, else `$FOLD_KEYS` (`normal`, `vim`, `helix`), else
13001440 `normal` (§10.6);
1301−- 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`).
13021443
1303−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.
13041446
13051447---
13061448
@@ -1310,12 +1452,15 @@ The only thing the app keeps outside the vault is the trash.
13101452
13111453```
13121454crates/
1313− notes-core/ parsing, tree, render/splice, index, store, merge — no TUI deps
1314− notes-tui/ ratatui application
1315− 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
13161460```
13171461
1318−`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.
13191464
13201465### 15.2 Core types (sketch)
13211466
@@ -1370,17 +1515,17 @@ exact spans, lossless round-tripping, and a small canonical subset — not a ful
13701515`pulldown-cmark` may be used later for inline styling in the reading pane only.
13711516
13721517Reading tolerance (setext headings, `*` bullets, tabs, `[X]`, `[-]`) is handled in the lexer and
1373−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.
13741519
13751520### 15.4 Dependencies
13761521
1377−`ratatui`, `crossterm`, `tui-textarea` (built-in editor), `notify` (watcher),
1522+`ratatui`, `crossterm`, `notify` (watcher), `signal-hook` (§10.6),
13781523`tree-sitter` + `tree-sitter-highlight` and the grammar crates of §10.9 (code highlighting), `jiff`
1379−(today's date), `clap`, `indexmap`, `nucleo` (fuzzy
1380−filter), `similar` (sequence alignment and diff3 for merge), `blake3`, `tempfile`,
1381−`unicode-normalization`, `unicode-width`, `directories` (XDG),
1382−`getrandom` (64-bit ids; the `@p` syllable tables are a 512-entry constant in `notes-core`),
1383−`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.
13841529
13851530### 15.5 Performance targets
13861531
@@ -1396,7 +1541,8 @@ filter), `similar` (sequence alignment and diff3 for merge), `blake3`, `tempfile
13961541
13971542### 15.6 Testing
13981543
1399−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:
14001546
14011547- `parse(render(t, 1, false))` equals `t` re-levelled, for all generated trees `t`.
14021548- `render(parse(s), 1, false) == s` for all canonical blocks `s`, frontmatter included.
@@ -1416,7 +1562,7 @@ of real-world Markdown (Obsidian, Logseq, FSNotes exports) that must parse witho
14161562
14171563### 15.7 Diagnostics
14181564
1419−`notes check` reports, with `ariadne`-rendered source spans: non-canonical syntax, empty
1565+`fold check` reports, file by file: non-canonical syntax, empty
14201566titles, titles containing `/`, broken, duplicate or cyclic embeds, embeds with children in the
14211567parent file, embeds whose form (bare or heading) does not match their block's spelling,
14221568heading embeds whose level is not the level of their position, text directly after a child
@@ -1443,7 +1589,7 @@ gets wrong. What other tools see:
14431589
14441590## 17. Non-goals for 1.0
14451591
1446−Encryption; CRDT / operation-log sync; a mobile client (the file format is its contract);
1592+Encryption; CRDT / operation-log sync;
14471593a query language, saved views and `--json` output (the ```` ```query ```` fence is reserved); attachment management beyond ignoring `assets/`; full Markdown
14481594rendering or WYSIWYG; plugins; task recurrence; spaced repetition; whiteboards; multiple
14491595vaults open at once; general transclusion; an external `$EDITOR` handoff; properties on non-blocks; export or lowering
@@ -1457,25 +1603,25 @@ task state; wiki-links between nodes and backlinks
14571603
14581604| # | Deliverable | Exit criterion |
14591605|---|---|---|
1460−| 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 |
14611607| M1 | TUI: outline + reading panes, zoom, fold, move, promote/demote, refile, filter box, subtree editing (`e`), undo | daily-drivable on a single `root.md` |
14621608| 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 |
14631609| M3 | Tasks: toggling on any node, `done:` stamping, dates, clear-done, filter box | replaces a TaskPaper file |
14641610| M4 | Inbox and capture; watcher and reload | safe to edit in Helix and the TUI at once |
1465−| 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 |
14661612| M6 | CLI polish, diagnostics, docs | 1.0 |
14671613
14681614---
14691615
14701616## 19. Decisions
14711617
1472−1. **Name.** Binary `notes` is a placeholder.
1618+1. **Name.** `fold`: the binary, the crate prefix and the state directory.
147316192. **Dated inbox, ISO titles, fixed.** Capture creates `## YYYY-MM-DD` under `Inbox`. There
14741620 is no format choice and no way to turn it off except `--to`. Days append oldest-first; a
14751621 long-lived inbox is a long section, which is what `s` (make block) and *clear done* are for.
147616223. **Filenames are named once.** A title change does not rename the file: renames sync as
14771623 delete-plus-create and race with edits on other devices. The id prefix identifies the
1478− 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.
147916254. **Id format.** Four-word `@p` names (64 bits): collision-free in practice, with three words
14801626 of slack for the filename prefix to grow into. Pronounceable, typeable from memory, and
14811627 self-validating against the syllable tables; nobody is expected to type all four.
@@ -1506,7 +1652,7 @@ task state; wiki-links between nodes and backlinks
1506165213. **Inbox by title.** Capture finds the inbox by the title `Inbox`. Renaming it means the
15071653 next capture creates a new one. A hidden marker property would be the only setting.
1508165414. **No config file.** Presentation choices are fixed or session toggles; a config format, a
1509− 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
15101656 one environment variable: the editor's keymap is `$FOLD_KEYS` (§10.6), and a
15111657 colour-blind palette, say, would be another.
1512165815. **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+359
@@ -162,6 +162,60 @@ fn trash_restore_never_overwrites_root_md() {
162162 assert!(dir.path().join("root-restored-2.md").exists());
163163}
164164
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+
165219/// The trash lives outside the vault (§11.5), often on another filesystem
166220/// (a Syncthing folder on an external disk vs `$XDG_STATE_HOME`). Merging
167221/// must still move the conflict file to trash (copy + remove across
@@ -206,3 +260,308 @@ fn merge_trashes_conflict_file_across_filesystems() {
206260 .stdout(predicate::str::contains("no sync-conflict files"));
207261 assert_eq!(md_files(dir.path()), before);
208262}
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+9−24
@@ -1,4 +1,4 @@
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};
44use crate::parse::{ends_with_blank_line, Content, Kind};
@@ -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)
@@ -125,7 +119,6 @@ pub fn check(vault: &Vault) -> Vec<Diagnostic> {
125119 });
126120 }
127121 seen_ids.push(id);
128− let n = t.node(*r);
129122 let f = &t.files[r.0];
130123 let top: Vec<usize> = f.nodes[f.root_node].children.clone();
131124 if top.len() != 1 {
@@ -134,7 +127,6 @@ pub fn check(vault: &Vault) -> Vec<Diagnostic> {
134127 message: "block file must have exactly one node at column 0".into(),
135128 });
136129 }
137− let _ = n;
138130 // filename checks (§6.4)
139131 let b = t.node(*r).block.as_ref().unwrap();
140132 let words = id.words();
@@ -341,11 +333,7 @@ pub fn fix(vault: &mut Vault) -> std::io::Result<usize> {
341333 }
342334 s
343335 } else {
344− let malformed = top.len() != 1
345− || f.diagnostics.iter().any(|d| {
346− d.message.contains("more than one node") || d.message.contains("before the block")
347− });
348− if malformed {
336+ if f.malformed_block() {
349337 continue;
350338 }
351339 render(&vault.tree, (i, top[0]), 1, false)
@@ -392,7 +380,8 @@ pub fn fix(vault: &mut Vault) -> std::io::Result<usize> {
392380 })
393381 .collect();
394382 let mut decided: Vec<String> = Vec::new();
395− 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();
396385 for (i, (r, id)) in vault.tree.blocks.iter().enumerate() {
397386 let b = vault.tree.node(*r).block.as_ref().unwrap();
398387 let words = id.words();
@@ -411,21 +400,17 @@ pub fn fix(vault: &mut Vault) -> std::io::Result<usize> {
411400 });
412401 let want = filename(&prefix, &slug(&vault.tree.node(*r).title));
413402 if want != b.path {
414− renames.push((r.0, want));
403+ renames.push((b.path.clone(), want));
415404 }
416405 decided.push(prefix);
417406 }
418− for (file, want) in renames {
419− let old = vault.tree.files[file].path.clone();
420− let target = vault.dir.join(&want);
421− if target.exists() {
407+ for (old, want) in renames {
408+ if vault.dir.join(&want).exists() {
422409 continue; // never overwrite; check keeps reporting it
423410 }
424− std::fs::rename(vault.dir.join(&old), target)?;
411+ let Some(file) = vault.file_index(&old) else { continue };
412+ vault.rename_file(file, &want)?;
425413 count += 1;
426414 }
427− if count > 0 {
428− vault.reload()?;
429− }
430415 Ok(count)
431416}
crates/fold-core/src/edit.rs+71−24
@@ -8,7 +8,7 @@
88//! replaces the block's span atomically.
99
1010use crate::ident::Id;
11−use crate::parse::{parse_file, parse_frontmatter, Block, Content, Frontmatter, Kind, Node, ParsedFile, Span};
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;
@@ -207,6 +207,20 @@ impl EditBuffer {
207207 }
208208 }
209209
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+
210224 /// Insert a line after `idx`; it inherits that line's tag (§5.2).
211225 pub fn insert_line(&mut self, idx: usize, text: String) {
212226 let owner = if self.lines.is_empty() {
@@ -648,7 +662,7 @@ impl EditBuffer {
648662 // §4.9: a block file with text or an embed before its root is
649663 // read-only until fixed — the bytes before the root are not in the
650664 // buffer, and rewriting the file would drop them
651− if malformed_block_file(f) {
665+ if f.malformed_block() {
652666 return Err(std::io::Error::other(format!(
653667 "{}: text or an embed before the block's root; read-only until fixed",
654668 path
@@ -660,7 +674,7 @@ impl EditBuffer {
660674 // re-spelled as a heading) is refused, not hidden, unless the file
661675 // had it so already
662676 let crowded = crowded_embeds(f);
663− if crowded_embeds(&parse_file(&path, &out, file, None)).iter().any(|id| !crowded.contains(id)) {
677+ if crowded_embeds(&parse_file(&path, &out, None)).iter().any(|id| !crowded.contains(id)) {
664678 return Err(std::io::Error::other(format!(
665679 "{}: a line would be nested under a block's embed, out of the outline; not saved",
666680 path
@@ -675,7 +689,7 @@ impl EditBuffer {
675689 frontmatter_span: None,
676690 ..b.clone()
677691 };
678− if malformed_block_file(&parse_file(&path, &out, file, Some(b))) {
692+ if parse_file(&path, &out, Some(b)).malformed_block() {
679693 return Err(std::io::Error::other(format!(
680694 "{}: the block's text must start with its title line; not saved",
681695 path
@@ -724,6 +738,17 @@ impl EditBuffer {
724738 };
725739 let (file, node) = self.locate(vault, &info).ok_or_else(|| not_found(&info))?;
726740 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+ }
727752 let on_disk = std::fs::read_to_string(vault.dir.join(&path)).unwrap_or_default();
728753 let Some(base) = self.base_hashes.get(&path).filter(|h| **h != hash(&on_disk)) else {
729754 return Ok(());
@@ -797,6 +822,48 @@ impl EditBuffer {
797822 }
798823 }
799824
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+
800867 /// Splice every dirty block (§10.6: a commit may write several files,
801868 /// one splice per dirty block). Each block is written by its own splice
802869 /// (§5.2): one that is refused stays dirty, the others are still
@@ -914,21 +981,6 @@ fn span_now(known: &str, now: &str, region: Option<Span>) -> Option<Span> {
914981 })
915982}
916983
917−/// A block file with text or an embed before its root node, or with no
918−/// root at all (§4.9). `root.md` never is: text before its first node is
919−/// its Root's own.
920−fn malformed_block_file(f: &ParsedFile) -> bool {
921− let root = &f.nodes[f.root_node];
922− if root.block.is_some() {
923− return false;
924− }
925− let text_before = root.content.iter().any(|c| match c {
926− Content::Text(sp) => !sp.text(&f.text).trim().is_empty(),
927− Content::Node(_) => false,
928− });
929− text_before || root.children.len() != 1 || f.nodes[root.children[0]].is_embed()
930−}
931−
932984/// The blocks whose embed in `f` has lines nested under it: nodes, or text
933985/// other than blank lines (§4.7: a diagnostic).
934986fn crowded_embeds(f: &ParsedFile) -> Vec<&Id> {
@@ -956,11 +1008,6 @@ fn owned_end(text: &str, span: Span) -> usize {
9561008 span.start + own
9571009}
9581010
959−fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
960− // fences as the parser reads them, so splice re-levels what it does
961− crate::parse::fence_transition(raw, open)
962−}
963−
9641011/// What the editor needs to know when it opens: the buffer plus a render of
9651012/// the same text for display (the lines ARE the display).
9661013pub fn open_editor(vault: &Vault, root: NRef) -> EditBuffer {
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+128−101
@@ -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,19 +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::new(vec![pf])
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))])
10596}
10697
10798fn node_sig(t: &Tree, r: NRef) -> (String, Option<TaskState>, String) {
@@ -141,7 +132,7 @@ fn run_lines(t: &Tree, r: NRef, sp: crate::parse::Span) -> Vec<String> {
141132 if l.trim().is_empty() {
142133 String::new()
143134 } else {
144− dedent(l, from).to_string()
135+ crate::render::dedent(l, from).to_string()
145136 }
146137 })
147138 .collect();
@@ -444,14 +435,14 @@ fn emit_node_with(o: &Tree, a: NRef, level: usize, indent: usize, kids: &str) ->
444435 out.push_str(&ind);
445436 out.push_str(&"#".repeat(level.max(1)));
446437 out.push(' ');
447− push_task(n.task, &mut out);
438+ crate::render::push_checkbox(n.task, &mut out);
448439 out.push_str(&n.title);
449440 out.push('\n');
450441 }
451442 Kind::Item => {
452443 out.push_str(&ind);
453444 out.push_str("- ");
454− push_task(n.task, &mut out);
445+ crate::render::push_checkbox(n.task, &mut out);
455446 out.push_str(&n.title);
456447 out.push('\n');
457448 }
@@ -471,7 +462,7 @@ fn emit_node_with(o: &Tree, a: NRef, level: usize, indent: usize, kids: &str) ->
471462 let dedent_by = o.indent(a);
472463 for l in &body {
473464 out.push_str(&ind);
474− out.push_str(dedent(l, dedent_by));
465+ out.push_str(crate::render::dedent(l, dedent_by));
475466 out.push('\n');
476467 }
477468 if !kids.trim().is_empty() {
@@ -509,38 +500,29 @@ fn emit_subtree(t: &Tree, r: NRef, level: usize, indent: usize) -> String {
509500 emit_node_with(t, r, level, indent, &join_pieces(pieces))
510501}
511502
512−fn push_task(task: Option<TaskState>, out: &mut String) {
513− match task {
514− Some(TaskState::Open) => out.push_str("[ ] "),
515− Some(TaskState::Done) => out.push_str("[x] "),
516− None => {}
517− }
518−}
519−
520−fn dedent(line: &str, cols: usize) -> &str {
521− if cols == 0 {
522− return line;
523− }
524− let mut removed = 0;
525− for (i, ch) in line.char_indices() {
526− if removed >= cols {
527− return &line[i..];
528− }
529− match ch {
530− ' ' => removed += 1,
531− '\t' => removed += 4,
532− _ => return &line[i..],
533− }
534− }
535− ""
536−}
537−
538503// ------------------------------------------------------------ vault-level
539504
540−/// 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`).
541506/// Returns a list of human-readable outcomes.
542507pub 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>> {
543522 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()?;
544526 for cfile in vault.conflict_files()? {
545527 let base = cfile.split(".sync-conflict-").next().unwrap().to_string() + ".md";
546528 let cpath = vault.dir.join(&cfile);
@@ -553,9 +535,7 @@ pub fn merge_sync_conflicts(vault: &mut Vault, dry_run: bool) -> std::io::Result
553535 continue;
554536 }
555537 let theirs = std::fs::read_to_string(&cpath)?;
556− let tid = crate::parse::parse_frontmatter(&theirs)
557− .and_then(|f| f.props.get("id").cloned())
558− .and_then(|v| Id::parse(&v));
538+ let tid = crate::parse::parse_frontmatter(&theirs).and_then(|f| f.id());
559539 // X.md gone: a block renamed since is still found by its id (§6.4)
560540 let bpath = match tid.as_ref().and_then(|id| vault.tree.block_by_id(id)) {
561541 Some(r) if !bpath.exists() => {
@@ -587,9 +567,7 @@ pub fn merge_sync_conflicts(vault: &mut Vault, dry_run: bool) -> std::io::Result
587567 // device + timestamp from the filename
588568 let (device, stamp) = parse_conflict_name(&cfile);
589569 // id check: differing ids mean a prefix collision, not a conflict (§12.2)
590− let oid = crate::parse::parse_frontmatter(&ours)
591− .and_then(|f| f.props.get("id").cloned())
592− .and_then(|v| Id::parse(&v));
570+ let oid = crate::parse::parse_frontmatter(&ours).and_then(|f| f.id());
593571 match (&oid, &tid) {
594572 (Some(a), Some(b)) if a != b => {
595573 outcomes.push(format!(
@@ -610,7 +588,7 @@ pub fn merge_sync_conflicts(vault: &mut Vault, dry_run: bool) -> std::io::Result
610588 }
611589 _ => {}
612590 }
613− let outcome = merge_texts(&ours, &theirs, &device, &stamp);
591+ let outcome = merge_texts(&ours, &without_embeds(&theirs, moving), &device, &stamp);
614592 outcomes.push(format!(
615593 "{}: {} conflict pair(s)",
616594 cfile, outcome.conflicts
@@ -642,12 +620,46 @@ pub fn merge_sync_conflicts(vault: &mut Vault, dry_run: bool) -> std::io::Result
642620 Ok(outcomes)
643621}
644622
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+
645659/// A conflict block's filename that does not clobber an existing file:
646660/// lengthen the prefix of its id until the name is free (§6.4).
647661fn fresh_block_name(vault: &Vault, fname: &str, text: &str) -> String {
648− let id = crate::parse::parse_frontmatter(text)
649− .and_then(|f| f.props.get("id").cloned())
650− .and_then(|v| Id::parse(&v));
662+ let id = crate::parse::parse_frontmatter(text).and_then(|f| f.id());
651663 let (Some(id), Some((_, name))) = (id, crate::ident::split_filename(fname)) else {
652664 return fname.to_string();
653665 };
@@ -715,6 +727,19 @@ fn place_sibling_embeds(vault: &mut Vault, owner: Option<&Id>, ids: &[Id]) -> st
715727 }
716728}
717729
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+
718743fn parse_conflict_name(name: &str) -> (String, String) {
719744 // name.sync-conflict-<date>-<time>-<device>.md
720745 let mut device = "unknown".to_string();
@@ -738,32 +763,40 @@ pub fn conflict_pairs(vault: &Vault) -> Vec<(NRef, NRef)> {
738763 conflict_pairs_tree(&vault.tree)
739764}
740765
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+
741785pub fn conflict_pairs_tree(tree: &Tree) -> Vec<(NRef, NRef)> {
742786 let mut out = Vec::new();
743− for (r, _id) in &tree.blocks {
744− let n = tree.node(*r);
745− if let Some(b) = &n.block {
746− if b.prop("conflict").is_some() {
747− let bid = b.id.clone().unwrap();
748− for (fi, f) in tree.files.iter().enumerate() {
749− for (ni, nd) in f.nodes.iter().enumerate() {
750− if nd.embed.as_ref() == Some(&bid) {
751− let p = nd.parent.map(|pp| (fi, pp)).unwrap();
752− let sibs = tree.raw_children(p);
753− if let Some(pos) = sibs.iter().position(|&s| s == (fi, ni)) {
754− // the conflict block is ours' next sibling (§12.4)
755− if let Some(ours) = pos.checked_sub(1).map(|i| sibs[i]) {
756− let ours = {
757− let rc = tree.resolved_child(ours);
758− if rc != ours { rc } else { ours }
759− };
760− out.push((ours, *r));
761− }
762− }
763− }
764− }
765− }
766− }
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));
767800 }
768801 }
769802 out
@@ -830,7 +863,6 @@ pub fn resolve_keep_theirs(vault: &mut Vault, ours: NRef, theirs: NRef) -> std::
830863 crate::ops::set_frontmatter_key(vault, file, &k, Some(&v))?;
831864 }
832865 // a title change does not rename the file (§6.4)
833− vault.reload()?;
834866 } else {
835867 // plain node: replace its span with theirs' re-levelled text
836868 let parent_level = on.parent.map(|p| vault.tree.level((ours.0, p))).unwrap_or(0);
@@ -861,22 +893,17 @@ fn delete_embed_and_block(
861893 block_ref: NRef,
862894) -> std::io::Result<()> {
863895 // remove the embed line from whichever file holds it
864− if let Some(id) = &b.id {
865− for (fi, f) in vault.tree.files.iter().enumerate() {
866− if let Some(ni) = f.nodes.iter().position(|nd| nd.embed.as_ref() == Some(id)) {
867− let span = vault.tree.files[fi].nodes[ni].span;
868− let text = vault.tree.files[fi].text.clone();
869− let mut start = span.start;
870− let mut end = span.end;
871− if start >= 2 && &text.as_bytes()[start - 2..start] == b"\n\n" {
872− start -= 1;
873− } else if end < text.len() && text.as_bytes()[end] == b'\n' {
874− end += 1;
875− }
876− vault.write_span(fi, crate::parse::Span { start, end }, "")?;
877− break;
878− }
879− }
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 }, "")?;
880907 }
881908 let file = block_ref.0;
882909 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+462−130
@@ -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}
@@ -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);
@@ -348,10 +500,9 @@ fn append_child_line(
348500 index = kids.len();
349501 }
350502 }
351− vault.reload()?;
352503 let parent = vault
353504 .find_by_key(&parent_key)
354− .ok_or_else(|| io_err("parent lost after reload"))?;
505+ .ok_or_else(|| io_err("parent lost after the write"))?;
355506 vault
356507 .tree
357508 .resolved_children(parent)
@@ -504,9 +655,6 @@ pub fn set_frontmatter_key(
504655 };
505656 // rewrite the line for `key` inside raw
506657 let mut lines: Vec<String> = raw.split_inclusive('\n').map(|s| s.to_string()).collect();
507− if !lines.is_empty() && !lines.last().unwrap().ends_with('\n') {
508− // keep as is; we append a newline when needed
509− }
510658 let key_prefix = format!("{}:", key);
511659 let mut found = false;
512660 for l in lines.iter_mut() {
@@ -587,34 +735,54 @@ pub fn frontmatter_lines(vault: &Vault, file: usize) -> Vec<(String, String, boo
587735
588736/// Delete a subtree: its span leaves its file; block files go to trash (§11.5),
589737/// including blocks nested anywhere under it, so none is left orphaned.
590−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> {
591759 let n = vault.tree.node(r);
592760 if n.kind == Kind::Root {
593761 return Err(io_err("cannot delete the root"));
594762 }
763+ let mut count = 0;
764+ vault.tree.walk(vault.tree.resolved_child(r), &mut |_, _| count += 1);
595765 if n.is_block() || n.is_embed() {
596766 let target = if n.is_embed() { vault.tree.resolved_child(r) } else { r };
597767 if target == r && n.is_embed() {
598768 // broken embed: just the line. So for a second embed of a
599769 // block, which renders as broken (§6.2): the block, and the
600770 // embed it is stitched in at, are not this line's
601− let duplicate = n.embed.as_ref().is_some_and(|id| vault.tree.block_by_id(id).is_some());
602771 let span = embed_line_span(&vault.tree, r);
603− remove_span_with_separator(vault, r.0, span)?;
604− return Ok(if duplicate { "duplicate embed removed" } else { "broken embed removed" }.into());
772+ remove_span(vault, r.0, span)?;
773+ return Ok(1);
605774 }
606775 // the block itself first: it leaves by its own embed
607776 let ids = nested_block_ids(vault, target);
608− let path = vault.tree.node(target).block.as_ref().unwrap().path.clone();
609777 if let Some((own, nested)) = ids.split_first() {
610778 trash_block(vault, own)?;
611779 trash_nested(vault, nested)?;
612780 }
613− return Ok(format!("block {:?} trashed", path));
781+ return Ok(count);
614782 }
615783 let ids = plain_remove(vault, r)?;
616784 trash_nested(vault, &ids)?;
617− Ok("subtree trashed".into())
785+ Ok(count)
618786}
619787
620788/// Ids of every block in the resolved subtree of `r` (pre-order, `r` itself
@@ -637,7 +805,7 @@ fn plain_remove(vault: &mut Vault, r: NRef) -> std::io::Result<Vec<Id>> {
637805 let name = format!("{}.md", slug(&vault.tree.node(r).title));
638806 vault.trash_text(&name, &text)?;
639807 let span = vault.tree.node(r).span;
640− remove_span_with_separator(vault, r.0, span)?;
808+ remove_span(vault, r.0, span)?;
641809 Ok(ids)
642810}
643811
@@ -646,7 +814,7 @@ fn plain_remove(vault: &mut Vault, r: NRef) -> std::io::Result<Vec<Id>> {
646814fn trash_block(vault: &mut Vault, id: &Id) -> std::io::Result<()> {
647815 if let Some(e) = vault.tree.embed_of(id) {
648816 let span = embed_line_span(&vault.tree, e);
649− remove_span_with_separator(vault, e.0, span)?;
817+ remove_span(vault, e.0, span)?;
650818 }
651819 if let Some(b) = vault.tree.block_by_id(id) {
652820 vault.trash_file(b.0)?;
@@ -717,17 +885,12 @@ fn after_removal(pos: usize, start: usize, end: usize) -> usize {
717885}
718886
719887/// Remove a span plus one adjacent blank separator line, then re-parse just
720−/// that file (no full reload, so other files' node refs stay valid).
721−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<()> {
722890 let (start, end) = removal_range(&vault.tree.files[file].text, span);
723891 vault.write_span(file, Span { start, end }, "")
724892}
725893
726−/// Remove a span plus one adjacent blank separator line.
727−fn remove_span_with_separator(vault: &mut Vault, file: usize, span: Span) -> std::io::Result<()> {
728− remove_span_no_reload(vault, file, span)
729−}
730−
731894/// Yank: render the subtree resolved (the register's text) (§10.3).
732895pub fn yank(vault: &Vault, r: NRef) -> String {
733896 render(&vault.tree, r, 1, true)
@@ -735,7 +898,7 @@ pub fn yank(vault: &Vault, r: NRef) -> String {
735898
736899/// Paste rendered text after/before a node as siblings (§10.3 `p`/`P`),
737900/// clamped by the ordering rule (§3.1).
738−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> {
739902 if vault.tree.node(at).kind == Kind::Root {
740903 return Err(io_err("cannot paste beside the root"));
741904 }
@@ -746,7 +909,8 @@ pub fn paste(vault: &mut Vault, at: NRef, text: &str, after: bool) -> std::io::R
746909 };
747910 let pos = vault.tree.raw_children(parent).iter().position(|&k| k == at).unwrap_or(0);
748911 let shifted = shift_document(text, vault.tree.level(parent), child_indent(&vault.tree, parent));
749− 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)
750914}
751915
752916/// Parse a standalone document and re-emit it as children of a node whose
@@ -755,14 +919,8 @@ pub fn paste(vault: &mut Vault, at: NRef, text: &str, after: bool) -> std::io::R
755919/// in a section of level `parent_level`. Everything inside keeps its position
756920/// relative to its top-level node.
757921pub fn shift_document(text: &str, parent_level: usize, indent: usize) -> String {
758− let block = crate::parse::Block {
759− id: None,
760− path: "clip.md".into(),
761− props: Default::default(),
762− frontmatter_raw: String::new(),
763− frontmatter_span: None,
764− };
765− let pf = crate::parse::parse_file("clip.md", text, 0, Some(block));
922+ let block = crate::parse::Block::new("clip.md", None, None);
923+ let pf = crate::parse::parse_file("clip.md", text, Some(block));
766924 let tree = crate::tree::Tree::new(vec![pf]);
767925 let mut out = String::new();
768926 let kids = tree.resolved_children(tree.root);
@@ -801,7 +959,7 @@ fn shift_lines(raw: &str, level_delta: isize, indent_delta: isize) -> String {
801959 for line in raw.split_inclusive('\n') {
802960 let l = line.strip_suffix('\n').unwrap_or(line);
803961 let nl = if line.ends_with('\n') { "\n" } else { "" };
804− let in_code = fence_transition(l, &mut fence) || fence.is_some();
962+ let in_code = crate::parse::fence_transition(l, &mut fence) || fence.is_some();
805963 let trimmed = l.trim_start();
806964 if trimmed.is_empty() {
807965 out.push_str(nl);
@@ -842,13 +1000,8 @@ fn atx_hashes(trimmed: &str) -> Option<usize> {
8421000 (hashes > 0 && (after.is_empty() || after.starts_with(' '))).then_some(hashes)
8431001}
8441002
845−fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
846− // fences as the parser reads them, so shifting re-levels what it does
847− crate::parse::fence_transition(raw, open)
848−}
849−
8501003/// Refile: move a subtree under a new parent as its last child (§6.5).
851−pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<bool> {
1004+pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<Placed> {
8521005 let dest = write_target(&vault.tree, dest)?;
8531006 let n = vault.tree.node(r);
8541007 // guard: cannot refile into own subtree — ancestry followed through
@@ -873,9 +1026,61 @@ pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<bool> {
8731026 if vault.tree.node(moving).is_block() {
8741027 return Err(io_err("block has no embed"));
8751028 }
876− let rendered = render(&vault.tree, moving, 1, false);
877− let shifted = shift_document(&rendered, vault.tree.level(dest), child_indent(&vault.tree, dest));
878− 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+ }
8791084}
8801085
8811086/// Where a dragged node is dropped (§10.3): before a node, as its sibling,
@@ -887,11 +1092,11 @@ pub enum Drop {
8871092}
8881093
8891094/// Move `r` relative to `target` (§10.3 drag and drop). Refuses a move into
890−/// `r`'s own subtree. Returns whether the ordering rule changed the place.
891−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> {
8921097 let target = vault.tree.resolved_child(target);
8931098 if target == vault.tree.resolved_child(r) {
894− return Ok(false);
1099+ return Ok(Placed::stayed(&vault.tree, r));
8951100 }
8961101 match drop {
8971102 Drop::Into => refile(vault, r, target),
@@ -905,19 +1110,27 @@ pub fn move_node(vault: &mut Vault, r: NRef, target: NRef, drop: Drop) -> std::i
9051110 return Err(io_err("cannot move a node into itself"));
9061111 }
9071112 let moving = stand_in(&vault.tree, r);
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+ }
9081122 // the target's index among the children `place` sees: without
909− // the moving node, which may come before it
1123+ // the moving nodes, which may come before it
9101124 let idx = vault
9111125 .tree
9121126 .raw_children(parent)
9131127 .into_iter()
914− .filter(|&k| k != moving)
1128+ .filter(|k| !run.contains(k))
9151129 .position(|k| k == t)
9161130 .unwrap_or(0);
917− let rendered = render(&vault.tree, moving, 1, false);
918− let shifted =
919− shift_document(&rendered, vault.tree.level(parent), child_indent(&vault.tree, parent));
920− place(vault, Some(moving), parent, idx, &shifted)
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)
9211134 }
9221135 }
9231136}
@@ -985,7 +1198,7 @@ fn child_indent(tree: &crate::tree::Tree, parent: NRef) -> usize {
9851198/// Spellings of a document's top-level nodes, in order. A heading embed is
9861199/// a section, a bare embed an item (§4.7).
9871200fn top_kinds(doc: &str) -> Vec<Kind> {
988− let pf = crate::parse::parse_file("clip.md", doc, 0, None);
1201+ let pf = crate::parse::parse_file("clip.md", doc, None);
9891202 pf.nodes[pf.root_node]
9901203 .children
9911204 .iter()
@@ -995,7 +1208,7 @@ fn top_kinds(doc: &str) -> Vec<Kind> {
9951208
9961209/// The indent a document's first top-level node is written at.
9971210fn top_indent(doc: &str) -> Option<usize> {
998− let pf = crate::parse::parse_file("clip.md", doc, 0, None);
1211+ let pf = crate::parse::parse_file("clip.md", doc, None);
9991212 pf.nodes[pf.root_node].children.first().map(|&c| pf.nodes[c].indent)
10001213}
10011214
@@ -1031,7 +1244,7 @@ fn level_among(tree: &crate::tree::Tree, prev: Option<NRef>, next: Option<NRef>,
10311244 if most.is_none() && least.is_none() {
10321245 return doc.to_string();
10331246 }
1034− let pf = crate::parse::parse_file("clip.md", doc, 0, None);
1247+ let pf = crate::parse::parse_file("clip.md", doc, None);
10351248 let tops = &pf.nodes[pf.root_node].children;
10361249 // a document's sections come after its items (§3.1)
10371250 let Some(first) = tops.iter().map(|&c| &pf.nodes[c]).find(|n| n.kind == Kind::Section) else {
@@ -1053,39 +1266,57 @@ fn level_among(tree: &crate::tree::Tree, prev: Option<NRef>, next: Option<NRef>,
10531266
10541267/// Write `doc` (already shifted to a child position of `parent`) as the
10551268/// `want`-th child node of `parent`, clamped by the ordering rule (§3.1).
1056−/// With `moving`, that node's own lines are removed in the same edit: text
1057−/// children around it stay where they are. The insertion is written before
1058−/// any removal in another file, so a failure never loses text. Returns
1059−/// 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.
10601276fn place(
10611277 vault: &mut Vault,
1062− moving: Option<NRef>,
1278+ moving: &[NRef],
10631279 parent: NRef,
10641280 want: usize,
10651281 doc: &str,
1066−) -> std::io::Result<bool> {
1282+) -> std::io::Result<(Vec<NRef>, bool)> {
10671283 let tree = &vault.tree;
10681284 let pnode = tree.node(parent);
10691285 let kids: Vec<NRef> = tree
10701286 .raw_children(parent)
10711287 .into_iter()
1072− .filter(|&k| Some(k) != moving)
1288+ .filter(|k| !moving.contains(k))
10731289 .collect();
10741290 let kinds = top_kinds(doc);
10751291 if kinds.is_empty() {
10761292 return Err(io_err("nothing to place"));
10771293 }
1078− 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+ }
10791310 let first = kinds[0];
10801311 let last = *kinds.last().unwrap();
10811312 let file = parent.0;
10821313 let mut text = tree.files[file].text.clone();
1083− // 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
10841315 let content: Vec<crate::parse::Content> = pnode
10851316 .content
10861317 .iter()
10871318 .copied()
1088− .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))))
10891320 .collect();
10901321 // positions in the original text, then adjusted for a same-file removal
10911322 let next = kids.get(idx).copied();
@@ -1115,9 +1346,9 @@ fn place(
11151346 };
11161347 // inserting before an item: after another item, the list is as tight as
11171348 // the text there says (read below); starting the list, it is read in
1118− // the list as found, the moving node still in it: between `next` and
1119− // the item after it, or else between the moving node and `next` right
1120− // after it. A list of one item is tight.
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.
11211352 let is_item = |c: Option<&crate::parse::Content>| {
11221353 matches!(c, Some(crate::parse::Content::Node(k)) if tree.files[file].nodes[*k].kind == Kind::Item)
11231354 };
@@ -1131,8 +1362,8 @@ fn place(
11311362 };
11321363 let run_tight = match (next, found_at) {
11331364 (Some(k), Some(i)) if is_item(found.get(i + 1)) => no_blank_after(k),
1134− (Some(_), Some(i)) => match moving {
1135− Some(m) if i > 0 && m.0 == file && found[i - 1] == crate::parse::Content::Node(m.1) => {
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) => {
11361367 no_blank_after(m)
11371368 }
11381369 _ => true,
@@ -1140,11 +1371,9 @@ fn place(
11401371 _ => true,
11411372 };
11421373 let next_kind = next.map(|k| tree.node(k).kind);
1143− let removal = match moving {
1144− Some(m) if m.0 == file => Some(removal_range(&text, tree.node(m).span)),
1145− _ => None,
1146− };
1147− if let Some((start, end)) = removal {
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);
11481377 text = format!("{}{}", &text[..start], &text[end..]);
11491378 pos = after_removal(pos, start, end);
11501379 }
@@ -1158,7 +1387,9 @@ fn place(
11581387 let prev = idx.checked_sub(1).map(|i| kids[i]);
11591388 let doc = level_among(tree, prev, next, &doc);
11601389 let body = doc.trim_end_matches('\n');
1161− 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 {
11621393 Some(nk) => {
11631394 // before the next child node: keep a blank line before a
11641395 // heading and after anything that is not a tight item run
@@ -1170,7 +1401,7 @@ fn place(
11701401 let tight = last == Kind::Item
11711402 && nk == Kind::Item
11721403 && if starts_run { run_tight } else { !text[..pos].ends_with("\n\n") };
1173− format!("{}{}\n{}", lead, body, if tight { "" } else { "\n" })
1404+ (format!("{}{}\n{}", lead, body, if tight { "" } else { "\n" }), lead.len())
11741405 }
11751406 None => {
11761407 while pos > 0 && text.as_bytes()[pos - 1] == b'\n' {
@@ -1184,23 +1415,37 @@ fn place(
11841415 "\n\n"
11851416 };
11861417 let nl = if text[pos..].starts_with('\n') { "" } else { "\n" };
1187− format!("{}{}{}", sep, body, nl)
1418+ (format!("{}{}{}", sep, body, nl), sep.len())
11881419 }
11891420 };
1421+ let start = pos + at;
11901422 text.insert_str(pos, &insertion);
11911423 vault.write_file_text(file, &text)?;
1192− if let Some(m) = moving {
1193− if m.0 != file {
1194− // the other file's node refs are untouched by the write above
1195− remove_span_no_reload(vault, m.0, vault.tree.node(m).span)?;
1196− }
1197− }
1198− vault.reload()?;
1199− Ok(idx != want.min(kids.len()))
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)?;
1430+ }
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()
12001445}
12011446
12021447/// Archive: refile under the top-level `Archive` section (§6.5).
1203−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> {
12041449 let root = vault.tree.root;
12051450 let mut dest = None;
12061451 for c in vault.tree.resolved_children(root) {
@@ -1226,12 +1471,22 @@ pub fn clear_done(vault: &mut Vault, target: NRef) -> std::io::Result<usize> {
12261471 vault.tree.walk(target, &mut |t, r| {
12271472 let n = t.node(r);
12281473 if n.task == Some(TaskState::Done) {
1229− let (open, _total) = t.task_counts(r);
1230− 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 {
12311479 to_clear.push(r);
12321480 }
12331481 }
12341482 });
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();
12351490 // skip nodes under another node being cleared
12361491 let tops: Vec<NRef> = to_clear
12371492 .iter()
@@ -1346,7 +1601,7 @@ pub fn make_block(vault: &mut Vault, r: NRef) -> std::io::Result<Id> {
13461601 let _ = std::fs::remove_file(&full);
13471602 return Err(e);
13481603 }
1349− vault.reload()?;
1604+ vault.add_file(&fname, &file_text);
13501605 Ok(id)
13511606}
13521607
@@ -1374,6 +1629,9 @@ pub fn set_property(vault: &mut Vault, r: NRef, key: &str, value: &str) -> std::
13741629
13751630/// Move a node among its siblings (§10.3 `J`/`K`). A block moves by its
13761631/// 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.
13771635pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<()> {
13781636 // a block root stands in its parent file as its embed
13791637 let r = stand_in(&vault.tree, r);
@@ -1389,20 +1647,21 @@ pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<(
13891647 Some(p) => p,
13901648 None => return Ok(()),
13911649 };
1392− let swap_with = if down {
1393− 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)
13941657 } else {
1395− match pos.checked_sub(1) {
1396− Some(p) => p,
1658+ match a.checked_sub(1) {
1659+ Some(p) => run(p),
13971660 None => return Ok(()),
13981661 }
13991662 };
1400− if swap_with >= siblings.len() {
1401− return Ok(());
1402− }
1403− let other = siblings[swap_with];
14041663 // the ordering rule (§3.1): an item never goes below a section sibling
1405− 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);
14061665 if rk != ok {
14071666 return Err(io_err(if rk == Kind::Item {
14081667 "an item cannot move below a section"
@@ -1412,7 +1671,7 @@ pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<(
14121671 }
14131672 let file = r.0;
14141673 let text = vault.tree.files[file].text.clone();
1415− // 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,
14161675 // so the list's spacing is unchanged
14171676 let core = |sp: Span| {
14181677 let end = sp.end.min(text.len());
@@ -1422,12 +1681,12 @@ pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<(
14221681 }
14231682 Span { start: sp.start, end: e }
14241683 };
1425− let (fr, sr) = if vault.tree.node(r).span.start < vault.tree.node(other).span.start {
1426− (r, other)
1427− } else {
1428− (other, r)
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,
14291688 };
1430− let (first, second) = (core(vault.tree.node(fr).span), core(vault.tree.node(sr).span));
1689+ let (first, second) = (lines(fr), lines(sr));
14311690 let with_nl = |t: &str| {
14321691 if t.ends_with('\n') {
14331692 t.to_string()
@@ -1438,19 +1697,30 @@ pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<(
14381697 // The written level and indent decide the parent: a sibling written
14391698 // deeper than the one after it (a skipped heading level, §4.7, or a
14401699 // wider indent) would nest under that one once below it, and J/K keep
1441− // every other node's parent (§15.6). So the node moving down is written
1442− // at the level and indent of the one moving up, its subtree re-levelled
1443− // with it, as a moved node is (§4.2).
1444− let (fnode, snode) = (vault.tree.node(fr), vault.tree.node(sr));
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]));
14451704 let lower = if (fnode.level, fnode.indent) == (snode.level, snode.indent) {
14461705 with_nl(first.text(&text))
14471706 } else {
1448− let base = snode.level.unwrap_or_else(|| vault.tree.level(fr));
1707+ let base = snode.level.unwrap_or_else(|| vault.tree.level(siblings[fr.0]));
14491708 let pad = " ".repeat(snode.indent);
1450− render(&vault.tree, fr, base, false)
1451− .lines()
1452− .map(|l| if l.is_empty() { "\n".to_string() } else { format!("{}{}\n", pad, l) })
1453− .collect()
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
14541724 };
14551725 let mut new_text = format!(
14561726 "{}{}{}{}",
@@ -1464,6 +1734,55 @@ pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<(
14641734 vault.write_file_text(file, &new_text)
14651735}
14661736
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+
14671786/// Toggle spelling section ↔ item (§10.3 `~`). The checkbox, if any, is kept,
14681787/// and the subtree is re-indented and re-levelled for the new spelling. The
14691788/// node moves to its parent's boundary if it has to (§3.1), so no sibling
@@ -1475,6 +1794,12 @@ pub fn toggle_spelling(vault: &mut Vault, r: NRef) -> std::io::Result<bool> {
14751794 return Ok(false);
14761795 }
14771796 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+ }
14781803 // an embed still here after resolving is broken: there is no block to
14791804 // respell, so only the embed's form changes and its id stays (§4.7)
14801805 if n.is_embed() {
@@ -1508,7 +1833,6 @@ pub fn toggle_spelling(vault: &mut Vault, r: NRef) -> std::io::Result<bool> {
15081833 let id = n.block.as_ref().and_then(|b| b.id.clone());
15091834 vault.write_span(file, span, &respelled)?;
15101835 let Some(e) = id.and_then(|id| vault.tree.embed_of(&id)) else {
1511− vault.reload()?;
15121836 return Ok(false);
15131837 };
15141838 return respell_embed(vault, e, to_section);
@@ -1518,6 +1842,9 @@ pub fn toggle_spelling(vault: &mut Vault, r: NRef) -> std::io::Result<bool> {
15181842 reposition(vault, r, parent, respelled, to_section)
15191843}
15201844
1845+/// Why `~` is refused on either side of a conflict pair (§12.5).
1846+pub const PAIR_SPELLING: &str = "resolve its conflict";
1847+
15211848/// Rewrite an embed in the other form (§4.7) — heading for a section-spelled
15221849/// block, bare for an item — moving it to its parent's boundary if needed.
15231850pub fn respell_embed(vault: &mut Vault, e: NRef, to_section: bool) -> std::io::Result<bool> {
@@ -1556,10 +1883,9 @@ fn reposition(
15561883 let prev = pos.checked_sub(1).map(|i| others[i]);
15571884 let new = level_among(tree, prev, others.get(pos).copied(), &new);
15581885 vault.write_span(r.0, span, &new)?;
1559− vault.reload()?;
15601886 return Ok(false);
15611887 }
1562− place(vault, Some(r), parent, pos, &new).map(|_| true)
1888+ place(vault, &[r], parent, pos, &new).map(|_| true)
15631889}
15641890
15651891/// A node's text respelled: its title line as a section at `level` or as an
@@ -1597,15 +1923,18 @@ fn respell(span_text: &str, to_section: bool, level: usize) -> String {
15971923
15981924/// Demote: become the last child of the previous sibling node (§10.3 `>`),
15991925/// clamped by the ordering rule (§3.1).
1600−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);
16011928 let m = stand_in(&vault.tree, r);
1602− 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) };
16031930 let kids = vault.tree.raw_children(parent);
1604− let Some(pos) = kids.iter().position(|&k| k == m) else { return Ok(false) };
1605− if pos == 0 {
1606− 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);
16071936 }
1608− let prev = vault.tree.resolved_child(kids[pos - 1]);
1937+ let prev = vault.tree.resolved_child(kids[a - 1]);
16091938 refile(vault, r, prev)
16101939}
16111940
@@ -1613,24 +1942,27 @@ pub fn demote(vault: &mut Vault, r: NRef) -> std::io::Result<bool> {
16131942/// ordering rule (§3.1): an item leaving a section lands before the first
16141943/// section among the parent's siblings. Out of a block's root, the node moves
16151944/// to the parent file beside the block's embed.
1616−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);
16171947 let m = stand_in(&vault.tree, r);
1618− 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) };
16191949 if vault.tree.node(parent).kind == Kind::Root {
1620− return Ok(false);
1950+ return Ok(stayed);
16211951 }
16221952 // the parent's own position: its embed if it is a block root
16231953 let pstand = stand_in(&vault.tree, parent);
16241954 let Some(grand) = vault.tree.node(pstand).parent.map(|p| (pstand.0, p)) else {
1625− return Ok(false);
1955+ return Ok(stayed);
16261956 };
16271957 if vault.tree.node(grand).kind == Kind::Root && pstand.0 != vault.tree.root.0 {
16281958 return Err(io_err("block has no embed"));
16291959 }
16301960 let pos = vault.tree.raw_children(grand).iter().position(|&k| k == pstand).unwrap_or(0);
1631− let rendered = render(&vault.tree, m, 1, false);
1632− let shifted = shift_document(&rendered, vault.tree.level(grand), child_indent(&vault.tree, grand));
1633− 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)
16341966}
16351967
16361968/// Append a plain item child titled `title` under `parent`; returns the new
crates/fold-core/src/parse.rs+50−11
@@ -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") {
@@ -435,10 +478,10 @@ struct Frame {
435478 is_embed: bool,
436479}
437480
438−/// Parse one file's text into nodes. `file_idx` tags every node's `file`.
439−/// `block` (with `id: Some`) marks a block file; `id: None` is `root.md`;
440−/// `None` means "plain text, no block attachment" (used by tests).
441−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 {
442485 let lines = split_lines(text);
443486 let n = lines.len();
444487 let mut nodes: Vec<Node> = Vec::new();
@@ -457,7 +500,6 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
457500 },
458501 children: Vec::new(),
459502 parent: None,
460− file: file_idx,
461503 block: None,
462504 embed: None,
463505 indent: 0,
@@ -626,7 +668,6 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
626668 },
627669 children: Vec::new(),
628670 parent: Some(parent),
629− file: file_idx,
630671 block: None,
631672 embed: match &info.kind {
632673 TitleKind::Embed(id, _) => Some(id.clone()),
@@ -661,7 +702,6 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
661702 }
662703 }
663704 if node.title.is_empty() && node.embed.is_none() {
664− node.noncanonical.push("empty title".into());
665705 diagnostics.push(Diag {
666706 span: node.title_span,
667707 message: "title line with an empty title".into(),
@@ -670,7 +710,7 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
670710 if node.title.contains('/') {
671711 diagnostics.push(Diag {
672712 span: node.title_span,
673− message: "title contains '/'".into(),
713+ message: format!("title contains '/': {:?}", node.title),
674714 });
675715 }
676716 let idx = nodes.len();
@@ -933,7 +973,6 @@ fn make_setext_section(
933973 },
934974 children: Vec::new(),
935975 parent: Some(parent),
936− file: nodes[parent].file,
937976 block: None,
938977 embed: None,
939978 indent: title_indent,
crates/fold-core/src/render.rs+1−1
@@ -258,7 +258,7 @@ pub fn embed_line(n: &crate::parse::Node, dlevel: usize, dindent: usize) -> Stri
258258 }
259259}
260260
261−fn push_checkbox(task: Option<TaskState>, out: &mut String) {
261+pub(crate) fn push_checkbox(task: Option<TaskState>, out: &mut String) {
262262 match task {
263263 Some(TaskState::Open) => out.push_str("[ ] "),
264264 Some(TaskState::Done) => out.push_str("[x] "),
crates/fold-core/src/tree.rs+37−14
@@ -2,7 +2,7 @@
22
33use crate::ident::Id;
44use crate::parse::{Kind, Node, ParsedFile, TaskState};
5−use std::collections::HashMap;
5+use std::collections::{HashMap, HashSet};
66use std::sync::OnceLock;
77
88/// The logical tree formed by all parsed files stitched together.
@@ -12,6 +12,8 @@ pub struct Tree {
1212 pub root: (usize, usize),
1313 /// (file, node) → id for every block.
1414 pub blocks: Vec<((usize, usize), Id)>,
15+ /// `blocks` by id, for `block_by_id`; `restitch` rebuilds it.
16+ block_index: HashMap<Id, NRef>,
1517 /// `embeds`, built at first use; `restitch` drops it.
1618 embed_index: OnceLock<HashMap<Id, NRef>>,
1719}
@@ -26,6 +28,7 @@ impl Tree {
2628 files,
2729 root: (0, 0),
2830 blocks: Vec::new(),
31+ block_index: HashMap::new(),
2932 embed_index: OnceLock::new(),
3033 };
3134 t.restitch();
@@ -49,7 +52,14 @@ impl Tree {
4952 }
5053 }
5154 }
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+ }
5261 self.blocks = blocks;
62+ self.block_index = index;
5363 self.embed_index = OnceLock::new();
5464 }
5565
@@ -212,10 +222,7 @@ impl Tree {
212222 }
213223
214224 pub fn block_by_id(&self, id: &Id) -> Option<NRef> {
215− self.blocks
216− .iter()
217− .find(|(_, bid)| bid == id)
218− .map(|(t, _)| *t)
225+ self.block_index.get(id).copied()
219226 }
220227
221228 /// Direct children of a node with embeds resolved (broken embeds, and
@@ -233,35 +240,46 @@ impl Tree {
233240 self.node(r).children.iter().map(|&c| (r.0, c)).collect()
234241 }
235242
236− /// 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).
237246 pub fn task_counts(&self, r: NRef) -> (usize, usize) {
238247 let mut open = 0;
239248 let mut total = 0;
240− self.walk(r, &mut |t, n| {
249+ let mut count = |t: &Tree, n: NRef| {
241250 if let Some(st) = t.node(n).task {
242251 total += 1;
243252 if st == TaskState::Open {
244253 open += 1;
245254 }
246255 }
247− });
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());
248259 (open, total)
249260 }
250261
251262 /// Pre-order walk over the resolved tree (embeds followed, cycles guarded).
252263 pub fn walk(&self, r: NRef, f: &mut dyn FnMut(&Tree, NRef)) {
253− let mut seen = Vec::new();
254− self.walk_inner(r, f, &mut seen);
264+ self.walk_inner(r, &|_, _| false, f, &mut HashSet::new());
255265 }
256266
257− fn walk_inner(&self, r: NRef, f: &mut dyn FnMut(&Tree, NRef), seen: &mut Vec<NRef>) {
258− 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) {
259276 return; // cycle guard
260277 }
261− seen.push(r);
262278 f(self, r);
263279 for cr in self.resolved_children(r) {
264− self.walk_inner(cr, f, seen);
280+ if !skip(self, cr) {
281+ self.walk_inner(cr, skip, f, seen);
282+ }
265283 }
266284 }
267285}
@@ -270,6 +288,11 @@ impl Node {
270288 pub fn is_block(&self) -> bool {
271289 self.block.is_some()
272290 }
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+ }
273296 pub fn is_embed(&self) -> bool {
274297 self.embed.is_some()
275298 }
crates/fold-core/src/vault.rs+87−67
@@ -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
@@ -44,21 +44,62 @@ impl Vault {
4444
4545 /// Rebuild the index from the vault (§11.3).
4646 pub fn reload(&mut self) -> std::io::Result<()> {
47− let mut files: Vec<ParsedFile> = Vec::new();
48− let root_text = read_if_exists(&self.dir.join("root.md"))?.unwrap_or_default();
49− let root_block = Block {
50− id: None,
51− path: "root.md".into(),
52− props: parse_frontmatter(&root_text)
53− .map(|f| f.props)
54− .unwrap_or_default(),
55− frontmatter_raw: parse_frontmatter(&root_text)
56− .map(|f| f.raw)
57− .unwrap_or_default(),
58− 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()),
5993 };
60− files.push(parse_file("root.md", &root_text, 0, Some(root_block)));
94+ v.reload()?;
95+ Ok(v)
96+ }
6197
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)];
62103 let mut entries: Vec<String> = std::fs::read_dir(&self.dir)?
63104 .filter_map(|e| e.ok())
64105 .map(|e| e.file_name().to_string_lossy().to_string())
@@ -75,32 +116,26 @@ impl Vault {
75116 Some(t) => t,
76117 None => continue,
77118 };
78− let fm = parse_frontmatter(&text);
79− let id = fm
80− .as_ref()
81− .and_then(|f| f.props.get("id"))
82− .and_then(|v| Id::parse(v));
83− let id = match id {
84− Some(id) => id,
85− None => continue, // ignored: never parsed (§4.1)
86− };
87− let idx = files.len();
88− let block = Block {
89− id: Some(id),
90− path: name.clone(),
91− props: fm.as_ref().map(|f| f.props.clone()).unwrap_or_default(),
92− frontmatter_raw: fm.as_ref().map(|f| f.raw.clone()).unwrap_or_default(),
93− frontmatter_span: fm.as_ref().map(|f| f.span),
94− };
95− files.push(parse_file(&name, &text, idx, Some(block)));
119+ if parse_frontmatter(&text).as_ref().and_then(Frontmatter::id).is_none() {
120+ continue; // ignored: never parsed (§4.1)
121+ }
122+ files.push((name, text));
96123 }
124+ Ok(files)
125+ }
97126
98− // Stitch: collect blocks, resolve embed edges (§4.7).
99− self.tree = Tree::new(files);
100− 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+ }))
101136 }
102137
103− /// Files ignored by the parser, for `notes check` (§4.1).
138+ /// Files ignored by the parser, for `fold check` (§4.1).
104139 pub fn ignored_files(&self) -> std::io::Result<Vec<IgnoredFile>> {
105140 let mut out = Vec::new();
106141 for e in std::fs::read_dir(&self.dir)?.filter_map(|e| e.ok()) {
@@ -192,30 +227,16 @@ impl Vault {
192227 .nodes
193228 .iter()
194229 .find_map(|n| n.block.clone());
230+ let fm = parse_frontmatter(text);
195231 let block = if path == "root.md" {
196− Some(Block {
197− id: None,
198− path: path.clone(),
199− props: parse_frontmatter(text).map(|f| f.props).unwrap_or_default(),
200− frontmatter_raw: parse_frontmatter(text).map(|f| f.raw).unwrap_or_default(),
201− frontmatter_span: parse_frontmatter(text).map(|f| f.span),
202− })
232+ Some(Block::new(&path, None, fm.as_ref()))
203233 } else {
204234 old_block.map(|mut b| {
205− let fm = parse_frontmatter(text);
206− if let Some(f) = fm.as_ref() {
207− b.props = f.props.clone();
208− b.frontmatter_raw = f.raw.clone();
209− b.frontmatter_span = Some(f.span);
210− } else {
211− b.props.clear();
212− b.frontmatter_raw.clear();
213− b.frontmatter_span = None;
214− }
235+ b.set_frontmatter(fm.as_ref());
215236 b
216237 })
217238 };
218− self.tree.files[file] = parse_file(&path, text, file, block);
239+ self.tree.files[file] = parse_file(&path, text, block);
219240 self.tree.restitch();
220241 Ok(())
221242 }
@@ -254,12 +275,6 @@ impl Vault {
254275 let target = trash_target(&trash, &path);
255276 move_file(&self.dir.join(&path), &target)?;
256277 self.tree.files.remove(file);
257− // the files after it move down one place
258− for (fi, f) in self.tree.files.iter_mut().enumerate().skip(file) {
259− for n in &mut f.nodes {
260− n.file = fi;
261− }
262− }
263278 self.tree.restitch();
264279 Ok(())
265280 }
@@ -305,7 +320,7 @@ fn trash_target(trash: &Path, name: &str) -> PathBuf {
305320pub fn trash_dir() -> PathBuf {
306321 directories::ProjectDirs::from("", "", "fold")
307322 .map(|p| p.state_dir().unwrap_or(p.data_dir()).join("trash"))
308− .unwrap_or_else(|| PathBuf::from(".notes-trash"))
323+ .unwrap_or_else(|| PathBuf::from(".fold-trash"))
309324}
310325
311326/// Write to `.<name>.fold-tmp`, fsync, rename (§11.1).
@@ -335,6 +350,14 @@ pub fn move_file(from: &Path, to: &Path) -> std::io::Result<()> {
335350 }
336351}
337352
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+
338361/// Read a vault `.md` file as text; `None` for anything that is not a
339362/// readable UTF-8 regular file.
340363fn read_md_file(path: &Path) -> Option<String> {
@@ -498,13 +521,10 @@ impl Vault {
498521 }
499522 // 2. path
500523 if text.contains('/') {
501− let rel = text.strip_prefix("./");
524+ if text.starts_with("./") {
525+ return Err("./ relative resolution needs a zoom root".into());
526+ }
502527 let segs: Vec<String> = text.split('/').map(|s| s.trim().to_string()).collect();
503− let base = match rel {
504− Some(_) => return Err("./ relative resolution needs a zoom root".into()),
505− None => self.tree.root,
506− };
507− let _ = base;
508528 // a path that matches twice is an ambiguity, never a guess
509529 let found = self.find_all_by_path(&segs);
510530 return match found.len() {
crates/fold-core/tests/edit.rs+88
@@ -649,6 +649,55 @@ fn one_refused_block_does_not_block_other_saves() {
649649 assert!(!buf.dirty.iter().any(|o| o.file == 0));
650650}
651651
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+
652701#[test]
653702fn a_save_goes_through_when_the_file_changed_only_outside_the_block() {
654703 // §5.2 step 5 compares the block's source span, not the whole file:
@@ -776,6 +825,45 @@ fn a_held_block_still_in_the_buffer_is_not_in_transit() {
776825 assert!(!d.path().join(&b_path).exists());
777826}
778827
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+
779867#[test]
780868fn review_external_edit_of_the_zoomed_node_never_lands_on_an_identical_one() {
781869 // §5.2 step 5: the zoomed item's own text changed on disk (another
crates/fold-core/tests/merge_check.rs+137−1
@@ -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";
@@ -315,7 +341,7 @@ fn retitled_block_root_conflicts_and_keep_theirs_keeps_the_filename() {
315341#[test]
316342fn deep_headings_are_canonical() {
317343 // §3.1 / §4.2: no upper bound on heading level; levels beyond six are ours
318− // (§4.2 "What is ours"), not non-canonical syntax for `notes check` to report.
344+ // (§4.2 "What is ours"), not non-canonical syntax for `fold check` to report.
319345 let dir = tempfile::tempdir().unwrap();
320346 std::fs::write(
321347 dir.path().join("root.md"),
@@ -392,6 +418,45 @@ fn sync_conflict_merge_keeps_embeds_in_the_merged_file() {
392418 assert!(text.contains("- new"), "{}", text);
393419}
394420
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+
395460#[test]
396461fn merge_leaves_ignored_files_alone() {
397462 // §4.1/§11.4: a `.md` file without an id is ignored — never parsed,
@@ -469,6 +534,23 @@ fn merge_with_missing_base_keeps_theirs_frontmatter() {
469534 assert!(dir.path().join(c).exists() && !dir.path().join("readme.md").exists());
470535}
471536
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+
472554#[test]
473555fn merge_places_insertions_by_their_neighbours() {
474556 // §12.4: a node present on one side only is an insertion, placed
@@ -656,3 +738,57 @@ fn sync_conflict_on_a_block_embedded_twice_pairs_where_it_is_shown() {
656738 assert_eq!(ours.map(|i| i.to_string()), Some(BID.to_string()));
657739 assert_eq!(std::fs::read_to_string(dir.path().join("root.md")).unwrap(), root);
658740}
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+54
@@ -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");
@@ -849,6 +879,30 @@ fn respelling_a_broken_embed_keeps_its_id() {
849879 assert_eq!(read(&d, "root.md"), format!("# A\n\n- b\n\n## ![[{}]]\n", ID_A));
850880}
851881
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+
852906#[test]
853907fn deleting_a_duplicate_embed_removes_only_its_line() {
854908 // a second embed of a block is a diagnostic and renders as broken
crates/fold-core/tests/ordering.rs+80−4
@@ -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,7 @@ 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}
@@ -554,7 +554,7 @@ fn drop_before_a_later_sibling_lands_before_it() {
554554 let src = "# A\n\n- a\n- b\n- c\n";
555555 let (_d, mut v) = vault_with(src);
556556 let (r, t) = (at(&v, "A/a"), at(&v, "A/c"));
557− assert!(!ops::move_node(&mut v, r, t, ops::Drop::Before).unwrap());
557+ assert!(!ops::move_node(&mut v, r, t, ops::Drop::Before).unwrap().clamped);
558558 assert_eq!(root_text(&v), "# A\n\n- b\n- a\n- c\n");
559559 // dropping a node before its immediate next sibling leaves it in place,
560560 // in a tight list or a loose one (§4.2: preserved as found)
@@ -856,3 +856,79 @@ fn drop_second_of_two_loose_items_before_the_first_keeps_the_list_loose() {
856856 ops::move_node(&mut v, b, a, ops::Drop::Before).unwrap();
857857 assert_eq!(root_text(&v), "# A\n\n- b\n- a\n");
858858}
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+9−9
@@ -11,7 +11,7 @@ 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);
1616 Tree::new(vec![pf])
1717}
@@ -171,7 +171,7 @@ fn block_file_tree() -> Tree {
171171 frontmatter_raw: String::new(),
172172 frontmatter_span: None,
173173 };
174− let root = parse_file("root.md", root_text, 0, Some(root_block));
174+ let root = parse_file("root.md", root_text, Some(root_block));
175175
176176 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";
177177 let fm = fold_core::parse::parse_frontmatter(block_text).unwrap();
@@ -183,7 +183,7 @@ fn block_file_tree() -> Tree {
183183 frontmatter_raw: fm.raw,
184184 frontmatter_span: Some(fm.span),
185185 };
186− 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));
187187 assert!(bf.diagnostics.is_empty(), "diags: {:?}", bf.diagnostics);
188188 Tree::new(vec![root, bf])
189189}
@@ -236,7 +236,7 @@ fn bullet_root_block_file() {
236236 frontmatter_raw: fm.raw,
237237 frontmatter_span: Some(fm.span),
238238 };
239− 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));
240240 assert!(pf.diagnostics.is_empty(), "diags: {:?}", pf.diagnostics);
241241 let root_node = pf.nodes[0].children[0];
242242 let n = &pf.nodes[root_node];
@@ -258,7 +258,7 @@ fn tree_with_block(root_text: &str, block_text: &str, block_path: &str) -> Tree
258258 frontmatter_raw: String::new(),
259259 frontmatter_span: None,
260260 };
261− let root = parse_file("root.md", root_text, 0, Some(root_block));
261+ let root = parse_file("root.md", root_text, Some(root_block));
262262 let fm = fold_core::parse::parse_frontmatter(block_text).unwrap();
263263 let id = Id::parse(fm.props.get("id").unwrap()).unwrap();
264264 let block = Block {
@@ -268,7 +268,7 @@ fn tree_with_block(root_text: &str, block_text: &str, block_path: &str) -> Tree
268268 frontmatter_raw: fm.raw,
269269 frontmatter_span: Some(fm.span),
270270 };
271− let bf = parse_file(block_path, block_text, 1, Some(block));
271+ let bf = parse_file(block_path, block_text, Some(block));
272272 Tree::new(vec![root, bf])
273273}
274274
@@ -412,7 +412,7 @@ fn empty_title_nodes_parse_everywhere() {
412412 ("# A\n\n- x\n - \n", 1),
413413 ];
414414 for (text, kids) in cases {
415− let pf = parse_file("root.md", text, 0, None);
415+ let pf = parse_file("root.md", text, None);
416416 let t = Tree::new(vec![pf]);
417417 let a = t.resolved_children(t.root)[0];
418418 let ch = t.resolved_children(a);
@@ -424,10 +424,10 @@ fn empty_title_nodes_parse_everywhere() {
424424 assert_eq!(t.node(n).title, "", "{:?}", text);
425425 // rendering keeps the node (without trailing whitespace)
426426 let r = render(&t, t.root, 1, false);
427− let pf2 = parse_file("root.md", &r, 0, None);
427+ let pf2 = parse_file("root.md", &r, None);
428428 assert_eq!(pf2.nodes.len(), t.files[0].nodes.len(), "{:?} → {:?}", text, r);
429429 }
430− let pf = parse_file("root.md", "# A\n\n- [ ] \n", 0, None);
430+ let pf = parse_file("root.md", "# A\n\n- [ ] \n", None);
431431 assert!(pf.nodes.iter().any(|n| n.task == Some(TaskState::Open) && n.title.is_empty()));
432432}
433433
crates/fold-core/tests/undo.rs+138−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) {
@@ -103,3 +103,140 @@ fn a_verb_refuses_to_overwrite_a_change_not_reloaded_yet() {
103103 ops::toggle_task(&mut v, a).unwrap();
104104 assert_eq!(read(&d, "root.md"), "- [x] a\n- [ ] b\n- [ ] from phone\n");
105105}
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+1341−417
@@ -9,6 +9,8 @@ use crossterm::terminal::{
99use crossterm::cursor::SetCursorStyle;
1010use crossterm::event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture};
1111use crossterm::ExecutableCommand;
12+use fold_core::ident::Id;
13+use fold_core::news::{changed_outside, merged_over, same_tops, tops};
1214use fold_core::ops;
1315use fold_core::parse::{Kind, TaskState};
1416use fold_core::tree::NRef;
@@ -36,7 +38,11 @@ pub use editor::Keys as EditKeys;
3638
3739/// Where an action sits in the node menu (for tests and scripted clicks).
3840pub fn node_menu_index(a: Action) -> usize {
39− action::NODE_MENU.iter().position(|i| *i == Some(a)).expect("in the node menu")
41+ action::NODE_MENU
42+ .iter()
43+ .chain(action::CONFLICT_MENU)
44+ .position(|i| *i == Some(a))
45+ .expect("in the node menu")
4046}
4147pub use ui::Hit;
4248pub use view::View;
@@ -70,6 +76,9 @@ pub struct FlatRow {
7076pub(crate) const HINT: &str = "double-click to zoom · right-click for actions · drag to move · ? help";
7177pub(crate) const HINT_SHORT: &str = "right-click for actions · ? help";
7278
79+/// How long a message stays up once something else was done (§10.1).
80+const MESSAGE_LIFE: Duration = Duration::from_secs(5);
81+
7382pub struct App {
7483 pub(crate) vault: Vault,
7584 mode: Mode,
@@ -83,6 +92,9 @@ pub struct App {
8392 zoom_anchor: Option<NodeKey>,
8493 pub cursor: usize,
8594 folded: Vec<NodeKey>,
95+ /// The conflict copies unfolded: a copy starts folded, a fold of this
96+ /// run's view alone, never remembered (§10.1).
97+ unfolded_copies: Vec<NodeKey>,
8698 hide_done: bool,
8799 raw_mode: bool,
88100 /// Long lines wrap in the reading pane and the editor (§10.1); `zw`.
@@ -91,11 +103,13 @@ pub struct App {
91103 /// the outline takes the screen and the pane appears only to edit.
92104 pub show_reading: bool,
93105 register: String,
106+ /// The node the register holds, as the status bar names it, and its
107+ /// spelling, for what the ordering rule did with a paste (§3.1).
108+ copied: (String, Kind),
94109 status: String,
95110 status_time: Instant,
96− undo: Vec<ops::Inverse>,
97− redo: Vec<ops::Inverse>,
98− // snapshot taken when a verb started, settled into `undo` after it
111+ log: ops::OpLog,
112+ // snapshot taken when a verb started, settled into `log` after it
99113 pending_undo: Option<ops::Snapshot>,
100114 filter: String,
101115 filter_rows: Vec<NRef>,
@@ -118,6 +132,15 @@ pub struct App {
118132 /// Where an outline verb moved the node the editor is open on: the
119133 /// editor is re-rendered there after the verb (`follow`).
120134 edit_moved: Option<NRef>,
135+ /// A save of the editor's text failed and none has gone through since:
136+ /// *Revert* keeps a copy of the text in the trash (§10.6).
137+ edit_refused: bool,
138+ /// The editor's text as it was when *Revert* could not copy it to the
139+ /// trash: *Revert* again on the same text drops it without one.
140+ edit_uncopied: Option<String>,
141+ /// Every editor save panics where it writes: for tests of what fold
142+ /// keeps when it crashes (`panic_in_saves`).
143+ save_panics: bool,
121144 // property editor (§10.6)
122145 props_target: Option<NRef>,
123146 props_rows: Vec<(String, String, bool)>,
@@ -131,16 +154,31 @@ pub struct App {
131154 read_match_idx: usize,
132155 // conflict view (§10.7)
133156 conflict_idx: usize,
157+ /// When the view last opened on its own: for a moment it takes no side,
158+ /// a key on its way being meant for what was there before.
159+ conflict_opened: Option<Instant>,
160+ /// Pairs a merge raised while the user was busy, by conflict block:
161+ /// the view opens on them once the outline is at rest, and the ⚠ count
162+ /// is lit until it does.
163+ conflicts_waiting: Vec<NodeKey>,
164+ /// The last key, paste or click.
165+ last_input: Option<Instant>,
134166 // watcher (§11.2)
135167 watcher: Option<notify::RecommendedWatcher>,
136168 watch_rx: Option<std::sync::mpsc::Receiver<notify::Result<notify::Event>>>,
137169 last_watch_event: Instant,
138170 self_write_until: Instant,
139171 pending_reload: bool,
172+ /// The sync-conflict copies the last reload listed before its merge,
173+ /// and still there after it: a new one is a change to take in (§11.2).
174+ conflict_copies: Vec<String>,
140175 // layout and pointer state (§10.1): panes, scroll offsets, the hit map
141176 pane_outline: Rect,
142177 pane_reading: Rect,
143178 outline_scroll: usize,
179+ /// The help's first line shown: taller than the screen, it scrolls
180+ /// (§10.8), and opens at its top.
181+ help_scroll: usize,
144182 ui: ui::Ui,
145183 // the node a menu or button acts on, when it is not the cursor's
146184 action_target: Option<NRef>,
@@ -148,6 +186,9 @@ pub struct App {
148186 filter_sel: usize,
149187 // two-key sequences: `z…`, `gg`, `[[` / `]]`
150188 pending: Option<char>,
189+ /// What the status bar says of a verb held for a selection out of view
190+ /// (§10.1), until the next key or click.
191+ held_words: Option<String>,
151192 // the node the reading cursor belongs to; a new target resets it
152193 read_key: Option<NodeKey>,
153194 // whether the reading pane shows the property header as line 1
@@ -182,6 +223,7 @@ enum PromptAction {
182223impl App {
183224 pub fn new(dir: &Path) -> std::io::Result<App> {
184225 let vault = Vault::open(dir)?;
226+ let conflict_copies = vault.conflict_files()?;
185227 Ok(App {
186228 vault,
187229 mode: Mode::Normal,
@@ -190,15 +232,16 @@ impl App {
190232 zoom_anchor: None,
191233 cursor: 0,
192234 folded: Vec::new(),
235+ unfolded_copies: Vec::new(),
193236 hide_done: false,
194237 raw_mode: false,
195238 wrap: true,
196239 show_reading: false,
197240 register: String::new(),
241+ copied: (String::new(), Kind::Item),
198242 status: HINT.into(),
199243 status_time: Instant::now(),
200− undo: Vec::new(),
201− redo: Vec::new(),
244+ log: ops::OpLog::default(),
202245 pending_undo: None,
203246 filter: String::new(),
204247 filter_rows: Vec::new(),
@@ -215,6 +258,9 @@ impl App {
215258 edit_last_key: Instant::now(),
216259 edit_return: Focus::Outline,
217260 edit_moved: None,
261+ edit_refused: false,
262+ edit_uncopied: None,
263+ save_panics: false,
218264 props_target: None,
219265 props_rows: Vec::new(),
220266 props_sel: 0,
@@ -224,19 +270,25 @@ impl App {
224270 read_matches: Vec::new(),
225271 read_match_idx: 0,
226272 conflict_idx: 0,
273+ conflict_opened: None,
274+ conflicts_waiting: Vec::new(),
275+ last_input: None,
227276 watcher: None,
228277 watch_rx: None,
229278 last_watch_event: Instant::now(),
230279 self_write_until: Instant::now() - Duration::from_secs(1),
231280 pending_reload: false,
281+ conflict_copies,
232282 pane_outline: Rect::default(),
233283 pane_reading: Rect::default(),
234284 outline_scroll: 0,
285+ help_scroll: 0,
235286 ui: ui::Ui::default(),
236287 action_target: None,
237288 palette_sel: 0,
238289 filter_sel: 0,
239290 pending: None,
291+ held_words: None,
240292 read_key: None,
241293 read_header: false,
242294 })
@@ -285,14 +337,158 @@ impl App {
285337 self.status_time = Instant::now();
286338 }
287339
340+ /// What the status bar says on its left (§10.1), and whether it is a
341+ /// hint, which the bar shortens by whole parts: while a key sequence is
342+ /// half typed, what can follow it; else the last message, until it is
343+ /// stale; then the keys of what is on screen. The greeting is the
344+ /// outline's: over anything else, that thing's keys.
345+ pub(crate) fn status_left(&self) -> (String, bool) {
346+ if let Some(p) = self.pending {
347+ let follows: Vec<String> = self.follows(p).iter().map(|(k, what)| format!("{} {}", k, what)).collect();
348+ return (format!("{}… {}", p, follows.join(" · ")), true);
349+ }
350+ let over = self.mode != Mode::Normal || self.prompt.is_some() || self.ui.menu.is_some();
351+ if self.stale() || (self.status == HINT && over) {
352+ return (self.keys_hint().to_string(), true);
353+ }
354+ (self.status.clone(), false)
355+ }
356+
357+ /// Whether the message has had its time (§10.1): some 5 s old, with a
358+ /// key or click since. An error or a refusal waits for a key or click
359+ /// that came once it was that old: one pressed while reading it does
360+ /// not take it away.
361+ fn stale(&self) -> bool {
362+ let old = self.status_time + MESSAGE_LIFE;
363+ match self.last_input {
364+ _ if self.status.is_empty() => true,
365+ None => false,
366+ Some(t) if lasting(&self.status) => t >= old,
367+ Some(t) => t > self.status_time && Instant::now() >= old,
368+ }
369+ }
370+
371+ /// The keys of what is on screen (§10.1): the topmost popup's, else the
372+ /// mode's; the editor's by its keymap and mode.
373+ fn keys_hint(&self) -> &'static str {
374+ if self.ui.menu.is_some() {
375+ return "↑↓ choose · Enter run · Esc close";
376+ }
377+ if let Some(p) = &self.prompt {
378+ return match p.action {
379+ PromptAction::Refile | PromptAction::GoTo => "↑↓ pick · Enter ok · Esc cancel",
380+ _ => "Enter ok · Esc cancel",
381+ };
382+ }
383+ match self.mode {
384+ Mode::Normal if self.focus == Focus::Reading => "e edit · Enter zoom/follow · Tab outline",
385+ Mode::Normal => "n new · e edit · x done · m menu · / find · ? help",
386+ // in Vim and Helix, Esc never leaves (§10.6); :wq, the way on,
387+ // is last, so it stays where the bar is short
388+ Mode::Edit => match self.editor.as_ref().map(|e| (e.keys, e.mode)) {
389+ Some((editor::Keys::Normal, _)) | None => "Esc done · Ctrl-S save · Ctrl-Z undo",
390+ Some((_, editor::Mode::Normal)) => "i insert · :q! revert · :wq done",
391+ Some(_) => "Esc normal mode · :q! revert · :wq done",
392+ },
393+ Mode::Props => "n add · Enter change · d delete · Esc close",
394+ Mode::Filter => "↑↓ pick · Enter go · Esc close",
395+ Mode::Picker => "↑↓ pick · Enter run · Esc close",
396+ Mode::Conflict if fold_core::merge::conflict_pairs(&self.vault).is_empty() => "Esc close",
397+ Mode::Conflict => "o ours · t theirs · b both · n next · Esc close",
398+ Mode::Help => "↑↓ scroll · Esc close",
399+ }
400+ }
401+
402+ /// What can follow the first key of a sequence (§10.3): each second
403+ /// key, and what it does, in the status bar's words, the one to keep
404+ /// longest where the bar is short first.
405+ fn follows(&self, first: char) -> Vec<(char, &'static str)> {
406+ match first {
407+ // done hidden, the way back to them leads (§10.1)
408+ 'z' if self.hide_done => vec![('d', "show done"), ('p', "pane"), ('w', "wrap"), ('r', "raw"), ('a', "archive")],
409+ 'z' => vec![('p', "pane"), ('w', "wrap"), ('d', "hide done"), ('r', "raw"), ('a', "archive")],
410+ 'g' => vec![('g', "top")],
411+ '[' => vec![('[', "previous heading")],
412+ ']' => vec![(']', "next heading")],
413+ _ => Vec::new(),
414+ }
415+ }
416+
417+ /// A second key nothing follows the first with: said, not swallowed
418+ /// (§10.3). `Esc` lets the first key go, quietly, as a click does.
419+ fn no_sequence(&mut self, first: char, key: KeyEvent) {
420+ if key.code == KeyCode::Esc {
421+ return;
422+ }
423+ let seq = match key.code {
424+ KeyCode::Char(c) if c != ' ' && key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => format!("{}{}", first, c),
425+ _ => format!("{} {}", first, key_name(key)),
426+ };
427+ let keys: Vec<String> = self.follows(first).iter().map(|(k, _)| k.to_string()).collect();
428+ let keys = match keys.split_last() {
429+ Some((last, [])) => last.clone(),
430+ Some((last, rest)) => format!("{} or {}", rest.join(", "), last),
431+ None => String::new(),
432+ };
433+ self.say(format!("{} does nothing · after {} press {}", seq, first, keys));
434+ }
435+
436+ /// A key fold has no use for, where one often reaches for it (§10.3):
437+ /// what to press instead. Any other key that does nothing says nothing.
438+ fn misfire(&mut self, key: KeyEvent) {
439+ let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
440+ let reading = self.focus == Focus::Reading;
441+ let instead = match key.code {
442+ KeyCode::Char('z') if ctrl => "u undoes",
443+ KeyCode::Char('f') if ctrl && reading => "/ searches",
444+ KeyCode::Char('f') if ctrl => "/ finds",
445+ _ if ctrl => return,
446+ KeyCode::Char('i') => "e edits",
447+ // the reading pane's o opens a link
448+ KeyCode::Char('o') => "n adds a node below",
449+ // the reading pane's m is the menu of the line's node; Tab,
450+ // then d, would delete the outline's selection (§10.4)
451+ KeyCode::Delete if reading => "m, then choose Delete",
452+ KeyCode::Delete => "d deletes",
453+ _ => return,
454+ };
455+ self.say(format!("{} does nothing here: {}", key_name(key), instead));
456+ }
457+
458+ /// What the main loop does between events: the editor's autosave, then
459+ /// a debounced reload. True when it reloaded.
460+ pub fn tick(&mut self) -> bool {
461+ // autosave after 750 ms without a keystroke (§10.6)
462+ if self.mode == Mode::Edit && self.editor_dirty() && self.edit_last_key.elapsed() > Duration::from_millis(750) {
463+ self.save_editor();
464+ }
465+ // external changes: debounced reload (§11.2)
466+ let due = self.poll_watcher();
467+ if due {
468+ self.reload_external();
469+ }
470+ // pairs that came in while the user was busy, once at rest (§10.7)
471+ self.open_waiting_conflicts();
472+ due
473+ }
474+
288475 /// Drain watcher events; returns true if a debounced reload should run.
289476 /// Debounce state: events are drained immediately; a reload is due when
290477 /// at least one relevant event arrived and 200 ms have passed since the
291− /// last one (§11.2).
478+ /// last one (§11.2), and the files hold something the vault does not.
292479 pub fn poll_watcher(&mut self) -> bool {
480+ use notify::event::{AccessKind, AccessMode, EventKind, ModifyKind};
293481 if let Some(rx) = &self.watch_rx {
294482 while let Ok(res) = rx.try_recv() {
295483 if let Ok(event) = res {
484+ // only a write can change a file's text: opening or
485+ // reading one, as every reload does, or its times and
486+ // mode changing is no change (§11.2)
487+ let write = match event.kind {
488+ EventKind::Access(AccessKind::Close(AccessMode::Write)) => true,
489+ EventKind::Access(_) | EventKind::Modify(ModifyKind::Metadata(_)) => false,
490+ _ => true,
491+ };
296492 // ignore our own temp files and ignored patterns (§11.4)
297493 let relevant = event.paths.iter().any(|p| {
298494 let name = p.file_name().map(|n| n.to_string_lossy()).unwrap_or_default();
@@ -300,7 +496,7 @@ impl App {
300496 && !name.ends_with(".fold-tmp")
301497 && !name.ends_with(".tmp")
302498 });
303− if relevant {
499+ if write && relevant {
304500 self.pending_reload = true;
305501 self.last_watch_event = Instant::now();
306502 }
@@ -313,34 +509,103 @@ impl App {
313509 if due {
314510 self.pending_reload = false;
315511 }
316− due
512+ // our own writes are seen once the self-write window is over:
513+ // nothing to take in, and no reload to save the editor for
514+ due && self.outside_change()
515+ }
516+
517+ /// Whether the files hold something the vault does not (§11.2): a file
518+ /// another program wrote, added or removed, or a new sync-conflict copy.
519+ /// An unreadable vault counts: the reload says why.
520+ fn outside_change(&self) -> bool {
521+ self.new_conflict_copy() || self.vault.changed_on_disk().unwrap_or(true)
522+ }
523+
524+ /// Whether a sync-conflict copy is in the vault that was not at the
525+ /// last reload (§11.2): one the merge left alone or failed on stays,
526+ /// and is no change by itself; the next change tries it again.
527+ fn new_conflict_copy(&self) -> bool {
528+ let copies = self.vault.conflict_files().unwrap_or_default();
529+ copies.iter().any(|c| !self.conflict_copies.contains(c))
317530 }
318531
319532 /// Reload after an external change (§11.2): editor saves first, then
320533 /// re-parse, cursor re-attached by id, key, deepest surviving step, and
321− /// the editor re-rendered. A new sync-conflict file starts the merge
322− /// flow (§12).
534+ /// the editor re-rendered if what it shows changed. A new sync-conflict
535+ /// file starts the merge flow (§12).
323536 pub fn reload_external(&mut self) {
324537 // what popups show is held by key: the editor's save and the reload
325538 // re-parse files, renumbering their nodes. A closed property form's
326539 // node is stale after any verb since, and is let go
327540 let props_key = self.props_target.filter(|_| self.mode == Mode::Props).map(|t| self.vault.key_of(t));
328541 let filter_key = self.filter_rows.get(self.filter_sel).filter(|_| self.mode == Mode::Filter).map(|&r| self.vault.key_of(r));
329− let edit = self.editor_before_write("external change");
542+ // what came in: the files as they are against what was read, taken
543+ // before the editor's save writes what was typed; that the save
544+ // took the typing, or why not, is said beside it
545+ let before = tops(&self.vault);
546+ let after = self.vault.on_disk().map(|v| tops(&v));
547+ let status = std::mem::take(&mut self.status);
548+ let dirty = self.editor_dirty();
549+ // a block cut in the editor stays in transit across the save: a
550+ // change to nothing it shows leaves it as it is (§5.2)
551+ self.save_editor();
552+ let edit = self.editor_key().map(|k| (k, self.editor_files()));
553+ let saved = std::mem::replace(&mut self.status, status);
554+ let typed = saved.is_empty() && dirty && !self.editor_dirty();
330555 let cursor_key = self.current().map(|r| self.vault.key_of(r));
331556 // the zoom is held by key (§11.2): the merge flow re-parses the
332557 // vault, then may close the editor, which reads the outline, before
333558 // the reload is over
334559 self.anchor_zoom();
335− // sync-conflict files start the merge flow (§11.2)
336− match self.vault.conflict_files() {
337− Ok(files) if !files.is_empty() => self.merge_conflict_files("merged"),
338− _ => {
339− if let Err(e) = self.vault.reload() {
340− self.say(format!("reload error: {}", e));
560+ // a sync-conflict file starts the merge flow (§11.2): a new one, or
561+ // one a merge left alone or failed on, which may merge now; what it
562+ // took in without a pair is said as any change is, with what came in
563+ // beside it, the save's typing not part of it. A copy that brings
564+ // nothing in, one left alone or the same as its file, says nothing,
565+ // as at startup. That the save took the typing is said ahead of what
566+ // came in, which gives way where the bar is short; news of a pair,
567+ // or an error, says no more (§10.6)
568+ let mut came_in = true;
569+ // listed before the merge: a copy that lands while it runs is new
570+ // to the next reload, not seen with these
571+ let copies = self.vault.conflict_files().unwrap_or_default();
572+ if !copies.is_empty() {
573+ // the merge reads the files again, as the save left them, typing
574+ // and all; where nothing was saved, as read above
575+ let read = after.as_ref().ok().filter(|_| !dirty).cloned();
576+ let was = read.unwrap_or_else(|| self.vault.on_disk().map(|v| tops(&v)).unwrap_or_else(|_| tops(&self.vault)));
577+ if !self.merge_conflict_files() {
578+ // what came in: the files against what was read, then what
579+ // the merge took in over them. Not the vault as the save left
580+ // it: the save took in what changed outside in a file it
581+ // wrote (§5.2)
582+ let now = merged_over(after.unwrap_or_else(|_| was.clone()), &was, tops(&self.vault));
583+ came_in = !same_tops(&before, &now);
584+ if came_in {
585+ self.say(changed_outside(&before, &now, typed));
341586 }
342587 }
588+ } else {
589+ match self.vault.reload() {
590+ Ok(()) => self.say(changed_outside(&before, &after.unwrap_or_else(|_| tops(&self.vault)), typed)),
591+ Err(e) => self.say(format!("reload error: {}", e)),
592+ }
343593 }
594+ // a change to what it shows re-renders it (below): its cut blocks
595+ // are deleted first, before the zoom and the cursor are found again.
596+ // One elsewhere in a file it holds, as to another node of root.md,
597+ // is taken in where it is
598+ let edit = edit.filter(|(key, files)| {
599+ *files != self.editor_files() && !self.editor_takes_in(key) && self.drop_cut_blocks()
600+ });
601+ // a refused save says why beside what came in, or on its own where
602+ // nothing came in
603+ if !saved.is_empty() {
604+ let msg = if came_in { format!("{} · {}", self.status, saved) } else { saved };
605+ self.say(msg);
606+ }
607+ self.conflict_copies = self.vault.conflict_files().unwrap_or_default();
608+ self.conflict_copies.retain(|c| copies.contains(c));
344609 self.settle_zoom();
345610 if let Some(k) = cursor_key {
346611 if let Some(r) = self.vault.find_by_key(&k) {
@@ -366,32 +631,180 @@ impl App {
366631 self.filter_sel = i;
367632 }
368633 }
369− self.editor_after_write(edit);
634+ if let Some((key, _)) = edit {
635+ self.rebuild_editor(self.find_exact(&key));
636+ }
370637 }
371638
372− /// The merge flow (§12.2): merge the sync-conflict copies, then open
373− /// the conflict view on the pairs the merge raised. A copy the merge
374− /// leaves alone (an ignored file's, one with nothing to merge against)
375− /// raises none and stays, so it must not reopen the view, closing the
376− /// editor, on every reload; nor must pairs lived with (§12.5).
377− fn merge_conflict_files(&mut self, what: &str) {
639+ /// The text of each file the open editor holds, found as the editor
640+ /// finds it: by id, else by path; None for one gone.
641+ fn editor_files(&self) -> Vec<Option<String>> {
642+ let Some(ed) = self.editor.as_ref() else { return Vec::new() };
643+ let tree = &self.vault.tree;
644+ let file = |i: &fold_core::edit::OwnerInfo| match &i.id {
645+ Some(id) => tree.block_by_id(id).map(|r| r.0),
646+ None => self.vault.file_index(&i.path),
647+ };
648+ ed.buf.owners.values().map(|i| file(i).map(|f| tree.files[f].text.clone())).collect()
649+ }
650+
651+ /// After a reload changed a file the editor holds (§11.2): where its
652+ /// node renders as the buffer shows it, line for line, the buffer takes
653+ /// in the files as they are and stays, a block cut there still to paste
654+ /// (§5.2). False where the node is gone or what it shows changed.
655+ fn editor_takes_in(&mut self, key: &NodeKey) -> bool {
656+ let Some(r) = self.find_exact(key) else { return false };
657+ let now = fold_core::edit::open_editor(&self.vault, r);
658+ self.editor.as_mut().is_some_and(|ed| ed.buf.take_in(now))
659+ }
660+
661+ /// Before the editor is re-rendered over text a reload changed
662+ /// (§11.2): a block cut there and not pasted back cannot be pasted as
663+ /// itself any more, and is deleted as on a Revert (§5.2), in the
664+ /// op-log entry of the save that wrote its embed out (§10.10). True
665+ /// when the editor can be re-rendered: text a refused save still holds
666+ /// is not, nor is one whose cut blocks stay.
667+ fn drop_cut_blocks(&mut self) -> bool {
668+ if self.editor_dirty() {
669+ return false;
670+ }
671+ let Some(ed) = self.editor.as_mut() else { return false };
672+ let snap = ops::Snapshot::take(&self.vault, &format!("edit {}", edited(ed, None)));
673+ let res = ed.buf.discard(&mut self.vault);
674+ if res.is_ok() {
675+ // let go of: what it held is deleted here, not by the next save
676+ ed.release_clip();
677+ ed.buf.dirty.clear();
678+ }
679+ self.record_edit(snap);
680+ if let Err(e) = &res {
681+ self.say(format!("error: {}", e));
682+ }
683+ res.is_ok()
684+ }
685+
686+ /// The merge flow (§12.2): merge the sync-conflict copies, then show
687+ /// the pairs the merge raised. A copy the merge leaves alone (an
688+ /// ignored file's, one with nothing to merge against) raises none and
689+ /// stays, so it must not reopen the view on every reload; nor must
690+ /// pairs lived with (§12.5). New pairs never take the screen from a
691+ /// busy user (§10.7): the view opens on them once the outline is at
692+ /// rest, at once if it is, and an open view stays on its pair. True
693+ /// when it said something: the pairs raised, or why it failed; what a
694+ /// merge without pairs took in is the caller's to say. A block cut in
695+ /// the editor and not pasted back is moved here: a copy from before the
696+ /// cut does not put it back where it was (§5.2, §11.2).
697+ fn merge_conflict_files(&mut self) -> bool {
378698 let before = self.conflict_blocks();
379− match fold_core::merge::merge_sync_conflicts(&mut self.vault, false) {
380− Ok(outcomes) => {
381− self.say(format!("{}: {}", what, outcomes.join("; ")));
382− let raised = self.conflict_blocks().iter().any(|b| !before.contains(b));
383− if raised && (self.editor.is_none() || self.close_editor()) {
384− self.mode = Mode::Conflict;
385− self.conflict_idx = 0;
699+ let shown = before.get(self.conflict_idx).cloned().filter(|_| self.mode == Mode::Conflict);
700+ let moving: Vec<Id> = self.transit().into_iter().map(|(id, _)| id).collect();
701+ match fold_core::merge::merge_sync_conflicts_moving(&mut self.vault, false, &moving) {
702+ Ok(_) => {
703+ let blocks = self.conflict_blocks();
704+ let raised: Vec<NodeKey> = blocks.iter().filter(|b| !before.contains(b)).cloned().collect();
705+ if raised.is_empty() {
706+ return false;
707+ }
708+ if self.mode == Mode::Conflict {
709+ // the pair on screen stays there; a view that showed
710+ // none takes no side for a moment, as on opening
711+ match shown.and_then(|k| blocks.iter().position(|b| *b == k)) {
712+ Some(i) => self.conflict_idx = i,
713+ None => self.conflict_opened = Some(Instant::now()),
714+ }
715+ self.say(self.conflict_news(&self.pairs_among(&raised)));
716+ return true;
717+ }
718+ self.conflicts_waiting.extend(raised);
719+ if !self.open_waiting_conflicts() {
720+ let news = self.conflict_news(&self.pairs_among(&self.conflicts_waiting));
721+ // the lit ⚠ count beside it says how many (§10.7)
722+ self.say(format!("{}: click ⚠ to resolve", news));
386723 }
387724 }
388725 Err(e) => self.say(format!("merge error: {}", e)),
389726 }
727+ true
728+ }
729+
730+ /// The startup scan (§12.2): a sync-conflict file present at startup
731+ /// starts the merge flow. What it took in without a pair is said as a
732+ /// reload says it (§11.2); a copy it left alone leaves the greeting be.
733+ pub fn merge_on_startup(&mut self) {
734+ if !self.vault.conflict_files().is_ok_and(|files| !files.is_empty()) {
735+ return;
736+ }
737+ let was = tops(&self.vault);
738+ if !self.merge_conflict_files() {
739+ let now = tops(&self.vault);
740+ if !same_tops(&was, &now) {
741+ self.say(changed_outside(&was, &now, false));
742+ }
743+ }
390744 }
391745
392− /// The conflict blocks of the unresolved pairs, by id (§12.5).
746+ /// Open the conflict view on its own (§10.7), on the pairs that came in
747+ /// while the user was busy, once the outline is at rest; true when it
748+ /// opened. For its first half second it takes no side (`key_conflict`).
749+ fn open_waiting_conflicts(&mut self) -> bool {
750+ if self.conflicts_waiting.is_empty() || !self.at_rest() {
751+ return false;
752+ }
753+ // pairs resolved meanwhile, elsewhere, are not waited on
754+ let keys = std::mem::take(&mut self.conflicts_waiting);
755+ let waiting = self.pairs_among(&keys);
756+ let Some(&(i, _)) = waiting.first() else { return false };
757+ self.say(self.conflict_news(&waiting));
758+ self.mode = Mode::Conflict;
759+ self.conflict_idx = i;
760+ self.conflict_opened = Some(Instant::now());
761+ true
762+ }
763+
764+ /// The outline at rest (§10.7): normal mode with nothing open over it,
765+ /// no key sequence or drag begun, and no key, paste or click for 2 s.
766+ fn at_rest(&self) -> bool {
767+ self.mode == Mode::Normal
768+ && self.editor.is_none()
769+ && self.prompt.is_none()
770+ && self.ui.menu.is_none()
771+ && self.pending.is_none()
772+ && !self.ui.press.is_some_and(|p| p.dragging)
773+ && !self.ui.resizing
774+ && self.last_input.is_none_or(|t| t.elapsed() >= Duration::from_secs(2))
775+ }
776+
777+ /// The pairs whose conflict blocks are `keys` (§12.5), first in the
778+ /// outline first: each one's place in the view's list, and its node.
779+ fn pairs_among(&self, keys: &[NodeKey]) -> Vec<(usize, NRef)> {
780+ fold_core::merge::conflict_pairs_in_order(&self.vault)
781+ .into_iter()
782+ .enumerate()
783+ .filter(|(_, (_, theirs))| keys.contains(&self.vault.key_of(*theirs)))
784+ .map(|(i, (ours, _))| (i, ours))
785+ .collect()
786+ }
787+
788+ /// What came in, by the node of the first of those pairs in the
789+ /// outline: *sync conflict in “NAS”*, *sync conflicts in “NAS” and 2
790+ /// more*.
791+ fn conflict_news(&self, pairs: &[(usize, NRef)]) -> String {
792+ match pairs {
793+ [] => "sync conflict".into(),
794+ [(_, ours)] => format!("sync conflict in {}", self.named(*ours)),
795+ [(_, ours), rest @ ..] => format!("sync conflicts in {} and {} more", self.named(*ours), rest.len()),
796+ }
797+ }
798+
799+ /// A node as the status bar names it: its title, quoted.
800+ fn named(&self, r: NRef) -> String {
801+ quoted(&self.vault.tree.node(self.vault.tree.resolved_child(r)).title)
802+ }
803+
804+ /// The conflict blocks of the unresolved pairs, by id, as the view
805+ /// lists them (§12.5).
393806 fn conflict_blocks(&self) -> Vec<NodeKey> {
394− fold_core::merge::conflict_pairs(&self.vault)
807+ fold_core::merge::conflict_pairs_in_order(&self.vault)
395808 .into_iter()
396809 .map(|(_, theirs)| self.vault.key_of(theirs))
397810 .collect()
@@ -414,7 +827,13 @@ impl App {
414827 }
415828 }
416829
830+ /// A copy's own fold hides nothing it is zoomed into to show (§10.1),
831+ /// however the zoom gets there: Enter, Backspace, a breadcrumb or the
832+ /// view remembered.
417833 fn set_zoom(&mut self, r: Option<NRef>) {
834+ if let Some(z) = r.filter(|&z| self.vault.tree.node(z).conflict().is_some()) {
835+ self.set_folded(z, false);
836+ }
418837 self.zoom_root = r;
419838 self.zoom_anchor = None;
420839 }
@@ -426,9 +845,16 @@ impl App {
426845 self.zoom_anchor = self.zoom_root.map(|z| self.vault.key_of(z));
427846 }
428847
429− /// The verb is over: the zoom it found by key holds.
848+ /// The verb is over: the zoom it found by key holds, with the fold it
849+ /// had; one that fell back to an ancestor lands there as any zoom does.
430850 fn settle_zoom(&mut self) {
431− self.set_zoom(self.zoom());
851+ let z = self.zoom();
852+ if self.zoom_anchor.is_some() && z.map(|z| self.vault.key_of(z)) != self.zoom_anchor {
853+ self.set_zoom(z);
854+ } else {
855+ self.zoom_root = z;
856+ self.zoom_anchor = None;
857+ }
432858 }
433859
434860 /// A node's parent in the outline, `None` at the top: a block root's
@@ -464,8 +890,7 @@ impl App {
464890 depth,
465891 via_embed,
466892 });
467− let key = self.vault.key_of(r);
468− if self.folded.contains(&key) {
893+ if self.is_folded(r) {
469894 return;
470895 }
471896 for c in self.vault.tree.resolved_children(r) {
@@ -497,25 +922,77 @@ impl App {
497922 }
498923 }
499924
925+ /// `zd` (§8.5): hide or show done tasks. The selection, and with it the
926+ /// reading pane, stays on its node, not its row number: the next verb
927+ /// must not act on whatever took its row. A node hidden gives way to
928+ /// the next shown sibling of the outermost done task it is in, else to
929+ /// the row above that task.
930+ fn toggle_hide_done(&mut self) {
931+ let before = self.rows();
932+ let at = self.cursor.min(before.len().saturating_sub(1));
933+ self.hide_done = !self.hide_done;
934+ let after = self.rows();
935+ let shown: std::collections::HashSet<NRef> = after.iter().map(|r| r.nref).collect();
936+ let mut target = before.get(at).map(|r| r.nref).filter(|r| shown.contains(r));
937+ if target.is_none() && at < before.len() {
938+ // the outermost hidden row it is under: its ancestors are the
939+ // rows above it, each shallower than the last
940+ let (mut top, mut depth) = (at, before[at].depth);
941+ for i in (0..at).rev() {
942+ if before[i].depth < depth {
943+ depth = before[i].depth;
944+ if !shown.contains(&before[i].nref) {
945+ top = i;
946+ }
947+ }
948+ }
949+ let d = before[top].depth;
950+ target = before[top + 1..]
951+ .iter()
952+ .take_while(|r| r.depth >= d)
953+ .find(|r| r.depth == d && shown.contains(&r.nref))
954+ .or_else(|| before[..top].iter().rev().find(|r| shown.contains(&r.nref)))
955+ .map(|r| r.nref);
956+ }
957+ if let Some(i) = target.and_then(|t| after.iter().position(|r| r.nref == t)) {
958+ self.cursor = i;
959+ }
960+ self.clamp_cursor();
961+ self.say(if self.hide_done { "done hidden" } else { "done shown" });
962+ }
963+
500964 /// The node a key names, only if it is that very node: no falling back
501965 /// to the deepest step that still exists.
502966 fn find_exact(&self, key: &NodeKey) -> Option<NRef> {
503967 self.vault.find_by_key(key).filter(|&r| self.vault.key_of(r) == *key)
504968 }
505969
970+ /// A conflict copy is folded until unfolded (§10.1); any other node
971+ /// once folded.
506972 fn is_folded(&self, r: NRef) -> bool {
507− self.folded.contains(&self.vault.key_of(r))
973+ let key = self.vault.key_of(r);
974+ match self.vault.tree.node(r).conflict() {
975+ Some(_) => !self.unfolded_copies.contains(&key),
976+ None => self.folded.contains(&key),
977+ }
508978 }
509979
510− fn toggle_fold(&mut self, r: NRef) {
980+ fn set_folded(&mut self, r: NRef, fold: bool) {
511981 let key = self.vault.key_of(r);
512− if let Some(i) = self.folded.iter().position(|k| *k == key) {
513− self.folded.remove(i);
514− } else {
515− self.folded.push(key);
982+ let (keys, listed) = match self.vault.tree.node(r).conflict() {
983+ Some(_) => (&mut self.unfolded_copies, !fold),
984+ None => (&mut self.folded, fold),
985+ };
986+ keys.retain(|k| *k != key);
987+ if listed {
988+ keys.push(key);
516989 }
517990 }
518991
992+ fn toggle_fold(&mut self, r: NRef) {
993+ self.set_folded(r, !self.is_folded(r));
994+ }
995+
519996 fn refresh_after(&mut self, action: &str) {
520997 self.clamp_cursor();
521998 self.self_write_until = Instant::now() + Duration::from_millis(500);
@@ -531,29 +1008,75 @@ impl App {
5311008
5321009 fn act_toggle_task(&mut self) {
5331010 let Some(r) = self.subject() else { return };
534− self.push_undo("act_toggle_task");
1011+ let Some((words, step)) = self.done_words(r) else {
1012+ // nothing changes, and nothing is left to undo
1013+ self.say(self.not_a_task(r));
1014+ return;
1015+ };
1016+ self.push_undo(&step);
5351017 let key = self.vault.key_of(r);
5361018 match ops::toggle_task(&mut self.vault, r) {
5371019 Ok(()) => {
5381020 if let Some(nr) = self.vault.find_by_key(&key) {
5391021 self.move_cursor_to(nr);
5401022 }
541− self.refresh_after("toggled");
1023+ self.refresh_after(&words);
5421024 }
5431025 Err(e) => self.say(format!("error: {}", e)),
5441026 }
5451027 }
5461028
1029+ /// What `x` says on `r`, a node that is no task (§10.1).
1030+ fn not_a_task(&self, r: NRef) -> String {
1031+ format!("{} isn't a task · {}", self.named(r), self.next_step(Action::ToggleTask, "makes it one"))
1032+ }
1033+
1034+ /// The next step a verb's message names (§10.1): `a`'s key and what it
1035+ /// does. The reading pane's keys are its own (§10.4), and Tab, then
1036+ /// the key, would act on the outline's selection: there, a node's
1037+ /// verb is in the menu of the line's node. In the editor every key is
1038+ /// text (§10.6): the step is the pointer's, a row's menu or the top
1039+ /// bar's button, in few enough words that at 80 columns the node and
1040+ /// the item to choose stay whole beside them.
1041+ fn next_step(&self, a: Action, does: &str) -> String {
1042+ let on_node = action::NODE_MENU.contains(&Some(a));
1043+ if self.mode == Mode::Edit {
1044+ return match a.icon() {
1045+ Some(icon) if !on_node => format!("click {} {}", icon, a.label()),
1046+ _ => format!("right-click, {}", a.label()),
1047+ };
1048+ }
1049+ match a.key() {
1050+ Some(k) if self.focus != Focus::Reading || !on_node => format!("{} {}", k, does),
1051+ _ => format!("m, then choose {}", a.label()),
1052+ }
1053+ }
1054+
1055+ /// What `x` does to `r`, in the status bar's words (§10.1), and as
1056+ /// its op-log entry names it (§10.10); `None` when it is no task.
1057+ fn done_words(&self, r: NRef) -> Option<(String, String)> {
1058+ let name = self.named(r);
1059+ Some(match self.vault.tree.node(self.vault.tree.resolved_child(r)).task? {
1060+ TaskState::Open => (format!("done: {}", name), format!("mark {} done", name)),
1061+ TaskState::Done => (format!("reopened: {}", name), format!("reopen {}", name)),
1062+ })
1063+ }
1064+
5471065 fn act_toggle_taskness(&mut self) {
5481066 let Some(r) = self.subject() else { return };
549− self.push_undo("act_toggle_taskness");
1067+ let name = self.named(r);
1068+ let (words, step) = match self.vault.tree.node(self.vault.tree.resolved_child(r)).task {
1069+ Some(_) => (format!("removed the checkbox from {}", name), format!("remove the checkbox from {}", name)),
1070+ None => (format!("made {} a task", name), format!("make {} a task", name)),
1071+ };
1072+ self.push_undo(&step);
5501073 let key = self.vault.key_of(r);
5511074 match ops::toggle_taskness(&mut self.vault, r) {
5521075 Ok(()) => {
5531076 if let Some(nr) = self.vault.find_by_key(&key) {
5541077 self.move_cursor_to(nr);
5551078 }
556− self.refresh_after("task-ness toggled");
1079+ self.refresh_after(&words);
5571080 }
5581081 Err(e) => self.say(format!("error: {}", e)),
5591082 }
@@ -561,11 +1084,13 @@ impl App {
5611084
5621085 fn act_make_block(&mut self) {
5631086 let Some(r) = self.subject() else { return };
564− self.push_undo("act_make_block");
1087+ let name = self.named(r);
1088+ self.push_undo(&format!("give {} its own file", name));
5651089 let on = self.on_node(r);
5661090 match ops::make_block(&mut self.vault, r) {
5671091 Ok(id) => {
568− self.say(format!("block {}", id));
1092+ // no id, no file name (§1 principle 3b)
1093+ self.say(format!("gave {} its own file", name));
5691094 // the cursor (a zoom, the editor) stays on the node, now a block
5701095 if let Some(nr) = self.vault.tree.block_by_id(&id) {
5711096 self.follow(on, nr);
@@ -596,37 +1121,53 @@ impl App {
5961121
5971122 fn act_delete(&mut self) {
5981123 let Some(r) = self.subject() else { return };
599− self.push_undo("act_delete");
600− self.register = ops::yank(&self.vault, r);
1124+ self.copy(r);
1125+ let name = self.copied.0.clone();
1126+ self.push_undo(&format!("delete {}", name));
1127+ // a node's conflict copies go with it (§12.5)
1128+ let what = match ops::conflict_copies(&self.vault.tree, r).len() {
1129+ 0 => name,
1130+ 1 => format!("{} and its conflict copy", name),
1131+ n => format!("{} and its {} conflict copies", name, n),
1132+ };
1133+ let undo = self.next_step(Action::Undo, "undoes");
6011134 match ops::delete_subtree(&mut self.vault, r) {
602− Ok(m) => self.refresh_after(&m),
1135+ Ok(1) => self.refresh_after(&format!("deleted {} · {}", what, undo)),
1136+ Ok(n) => self.refresh_after(&format!("deleted {} ({} nodes) · {}", what, n, undo)),
6031137 Err(e) => self.say(format!("error: {}", e)),
6041138 }
6051139 }
6061140
1141+ /// Put `r`'s subtree in the register (§10.3 `y`, `d`), and what it is.
1142+ fn copy(&mut self, r: NRef) {
1143+ self.register = ops::yank(&self.vault, r);
1144+ self.copied = (self.named(r), self.vault.tree.node(self.vault.tree.resolved_child(r)).kind);
1145+ }
1146+
6071147 fn act_yank(&mut self) {
6081148 let Some(r) = self.subject() else { return };
609− self.register = ops::yank(&self.vault, r);
610− self.say("yanked");
1149+ self.copy(r);
1150+ self.say(format!("copied {} · {}", self.copied.0, self.next_step(Action::PasteAfter, "pastes")));
6111151 }
6121152
6131153 fn act_paste(&mut self, after: bool) {
6141154 if self.register.is_empty() {
615− self.say("register empty");
1155+ self.say(format!("nothing copied yet · {}", self.next_step(Action::Copy, "copies a node")));
6161156 return;
6171157 }
6181158 let Some(r) = self.subject() else { return };
619− self.push_undo("act_paste");
6201159 let text = self.register.clone();
1160+ let (name, kind) = self.copied.clone();
1161+ self.push_undo(&format!("paste {}", name));
6211162 match ops::paste(&mut self.vault, r, &text, after) {
622− Ok(moved) => self.refresh_after(&with_rule_note("pasted", moved)),
1163+ Ok(p) => self.refresh_after(&with_rule_note(&format!("pasted {}", name), p.clamped, kind)),
6231164 Err(e) => self.say(format!("error: {}", e)),
6241165 }
6251166 }
6261167
6271168 fn act_move(&mut self, down: bool) {
6281169 let Some(r) = self.subject() else { return };
629− self.push_undo("act_move");
1170+ self.push_undo(&format!("move {} {}", self.named(r), if down { "down" } else { "up" }));
6301171 let key = self.vault.key_of(r);
6311172 match ops::move_sibling(&mut self.vault, r, down) {
6321173 Ok(()) => {
@@ -640,16 +1181,24 @@ impl App {
6401181
6411182 fn act_spelling(&mut self) {
6421183 let Some(r) = self.subject() else { return };
643− self.push_undo("act_spelling");
1184+ let (spelling, kind) = match self.vault.tree.node(self.vault.tree.resolved_child(r)).kind {
1185+ Kind::Section => ("a bullet", Kind::Item),
1186+ _ => ("a heading", Kind::Section),
1187+ };
1188+ let words = format!("made {} {}", self.named(r), spelling);
1189+ // a node in a conflict pair keeps its spelling (§12.5)
1190+ if ops::conflict_pair(&self.vault.tree, r).len() > 1 {
1191+ self.say(format!("can't respell {}: {}", self.named(r), ops::PAIR_SPELLING));
1192+ return;
1193+ }
1194+ self.push_undo(&format!("make {} {}", self.named(r), spelling));
6441195 let key = self.vault.key_of(r);
6451196 match ops::toggle_spelling(&mut self.vault, r) {
6461197 Ok(moved) => {
6471198 if let Some(nr) = self.vault.find_by_key(&key) {
6481199 self.move_cursor_to(nr);
6491200 }
650− if moved {
651− self.say(with_rule_note("respelled", true));
652− }
1201+ self.say(with_rule_note(&words, moved, kind));
6531202 }
6541203 Err(e) => self.say(format!("error: {}", e)),
6551204 }
@@ -657,28 +1206,33 @@ impl App {
6571206
6581207 fn act_demote(&mut self) {
6591208 let Some(s) = self.subject() else { return };
660− self.push_undo("act_demote");
6611209 let r = self.vault.tree.resolved_child(s);
662− let (key, kind) = (self.vault.key_of(r), self.vault.tree.node(r).kind);
663− // it goes under the node before it (§10.3)
1210+ self.push_undo(&format!("indent {}", self.named(r)));
1211+ let kind = self.vault.tree.node(r).kind;
1212+ // it goes under the node before it (§10.3), with its conflict
1213+ // pair, which moves as one (§12.5)
6641214 let sibs = self.vault.tree.resolved_children(self.outline_parent(r).unwrap_or(self.vault.tree.root));
665− let prev = sibs.iter().position(|&c| c == r).and_then(|i| i.checked_sub(1)).map(|i| self.vault.key_of(sibs[i]));
1215+ let first = ops::conflict_pair(&self.vault.tree, r)[0];
1216+ let prev = sibs.iter().position(|&c| c == first).and_then(|i| i.checked_sub(1)).map(|i| sibs[i]);
1217+ let Some(prev) = prev else {
1218+ self.say(format!("can't indent {}: nothing above it", self.named(r)));
1219+ return;
1220+ };
1221+ // nor into a conflict copy, which keeping ours trashes (§12.5); a
1222+ // node in one moves within it
1223+ if self.vault.tree.node(prev).conflict().is_some() {
1224+ self.say(format!("can't indent {} into a conflict copy", self.named(r)));
1225+ return;
1226+ }
1227+ let name = self.named(r);
6661228 let on = self.on_node(r);
6671229 match ops::demote(&mut self.vault, s) {
668− Ok(moved) => {
1230+ Ok(p) => {
6691231 // the cursor (a zoom, the editor) stays on the node where it
6701232 // landed
671− let landed = match &prev {
672− Some(dest) => self.moved_node(&key, kind, dest, None),
673− None => self.find_exact(&key),
674− };
675− if let Some(nr) = landed {
676− self.follow(on, nr);
677− self.move_cursor_to(nr);
678− }
679− if moved {
680− self.say(with_rule_note("demoted", true));
681− }
1233+ self.follow(on, p.node);
1234+ self.move_cursor_to(p.node);
1235+ self.say(with_rule_note(&format!("indented {}", name), p.clamped, kind));
6821236 }
6831237 Err(e) => self.say(format!("error: {}", e)),
6841238 }
@@ -686,34 +1240,24 @@ impl App {
6861240
6871241 fn act_promote(&mut self) {
6881242 let Some(s) = self.subject() else { return };
689− self.push_undo("act_promote");
6901243 let r = self.vault.tree.resolved_child(s);
691− let (key, kind) = (self.vault.key_of(r), self.vault.tree.node(r).kind);
1244+ self.push_undo(&format!("outdent {}", self.named(r)));
1245+ let kind = self.vault.tree.node(r).kind;
6921246 // it goes beside its parent, right after it, under the parent's
6931247 // parent (§10.3)
694− let parent = self.outline_parent(r);
695− let grand = parent.map(|p| self.outline_parent(p).unwrap_or(self.vault.tree.root));
696− let rank = parent.zip(grand).and_then(|(p, g)| {
697− let at = self.vault.tree.resolved_children(g).iter().position(|&c| c == p)?;
698− Some(self.namesakes_before(r, g, at + 1))
699− });
700− let grand = grand.map(|g| self.vault.key_of(g));
1248+ if self.outline_parent(r).is_none() {
1249+ self.say(format!("can't outdent {}: it's at the top level", self.named(r)));
1250+ return;
1251+ }
1252+ let name = self.named(r);
7011253 let on = self.on_node(r);
7021254 match ops::promote(&mut self.vault, s) {
703− Ok(moved) => {
1255+ Ok(p) => {
7041256 // the cursor (a zoom, the editor) stays on the node where it
7051257 // landed
706− let landed = match &grand {
707− Some(dest) => self.moved_node(&key, kind, dest, rank),
708− None => self.find_exact(&key),
709− };
710− if let Some(nr) = landed {
711− self.follow(on, nr);
712− self.move_cursor_to(nr);
713− }
714− if moved {
715− self.say(with_rule_note("promoted", true));
716− }
1258+ self.follow(on, p.node);
1259+ self.move_cursor_to(p.node);
1260+ self.say(with_rule_note(&format!("outdented {}", name), p.clamped, kind));
7171261 }
7181262 Err(e) => self.say(format!("error: {}", e)),
7191263 }
@@ -721,82 +1265,44 @@ impl App {
7211265
7221266 fn act_archive(&mut self) {
7231267 let Some(r) = self.subject() else { return };
724− self.push_undo("act_archive");
1268+ let (name, kind) = (self.named(r), self.vault.tree.node(self.vault.tree.resolved_child(r)).kind);
1269+ self.push_undo(&format!("archive {}", name));
7251270 match ops::archive(&mut self.vault, r) {
726− Ok(moved) => self.refresh_after(&with_rule_note("archived", moved)),
1271+ Ok(p) => self.refresh_after(&with_rule_note(&format!("archived {}", name), p.clamped, kind)),
7271272 Err(e) => self.say(format!("error: {}", e)),
7281273 }
7291274 }
7301275
7311276 fn act_clear_done(&mut self) {
732− self.push_undo("act_clear_done");
733− let target = self.zoom().unwrap_or(self.vault.tree.root);
1277+ let zoom = self.zoom();
1278+ let under = zoom.map(|z| format!(" under {}", self.named(z))).unwrap_or_default();
1279+ self.push_undo(&format!("clear done tasks{}", under));
1280+ let target = zoom.unwrap_or(self.vault.tree.root);
7341281 match ops::clear_done(&mut self.vault, target) {
735− Ok(n) => self.refresh_after(&format!("{} done item(s) trashed", n)),
1282+ Ok(0) => self.refresh_after(&format!("no done tasks to clear{}", under)),
1283+ Ok(1) => self.refresh_after(&format!("cleared 1 done task{}", under)),
1284+ Ok(n) => self.refresh_after(&format!("cleared {} done tasks{}", n, under)),
7361285 Err(e) => self.say(format!("error: {}", e)),
7371286 }
7381287 }
7391288
7401289 /// *Move to…* (§6.5): the prompt's moving node goes under `dest`.
7411290 fn refile_to(&mut self, r: NRef, dest: NRef) {
742− self.push_undo("move to");
743− let (rr, dest_key) = (self.vault.tree.resolved_child(r), self.vault.key_of(self.vault.tree.resolved_child(dest)));
744− let (key, kind) = (self.vault.key_of(rr), self.vault.tree.node(rr).kind);
1291+ let rr = self.vault.tree.resolved_child(r);
1292+ let kind = self.vault.tree.node(rr).kind;
1293+ let words = format!("moved {} to {}", self.named(rr), self.named(dest));
1294+ self.push_undo(&format!("move {} to {}", self.named(rr), self.named(dest)));
7451295 let on = self.on_node(rr);
7461296 match ops::refile(&mut self.vault, r, dest) {
747− Ok(moved) => {
748− self.refresh_after(&with_rule_note("moved", moved));
749− if let Some(nr) = self.moved_node(&key, kind, &dest_key, None) {
750− self.follow(on, nr);
751− self.reveal(nr);
752− }
1297+ Ok(p) => {
1298+ self.refresh_after(&with_rule_note(&words, p.clamped, kind));
1299+ self.follow(on, p.node);
1300+ self.reveal(p.node);
7531301 }
7541302 Err(e) => self.say(format!("error: {}", e)),
7551303 }
7561304 }
7571305
758− /// Where a node a verb moved under `dest` (a key taken before the move:
759− /// the move renumbers the nodes of the files it writes) landed: a block
760− /// by its id; else, among `dest`'s children with the node's title and
761− /// kind, the one the ordering rule (§3.1) put it at. Moved in as the
762− /// last child, that is the last of them (an item goes after the items,
763− /// a section after the sections); put at a place among them, the one
764− /// after the `rank` namesakes that `namesakes_before` counted there.
765− fn moved_node(&self, key: &NodeKey, kind: Kind, dest: &NodeKey, rank: Option<usize>) -> Option<NRef> {
766− let title = match key {
767− NodeKey::Path { steps, .. } => steps.last().map(|(t, _)| t.clone()),
768− NodeKey::Id(id) => return self.vault.tree.block_by_id(id),
769− NodeKey::Root => None,
770− }?;
771− let kids = self.vault.tree.resolved_children(self.find_exact(dest)?);
772− let mut hits = kids.iter().filter(|&&c| {
773− let n = self.vault.tree.node(c);
774− n.title == title && n.kind == kind
775− });
776− match rank {
777− Some(i) => hits.nth(i).copied(),
778− None => hits.next_back().copied(),
779− }
780− }
781−
782− /// Before a verb puts `r` among `dest`'s children, just before the
783− /// `at`-th: how many of them, other than `r`, share its title and kind
784− /// and come before that place, for `moved_node`. Clamped by the
785− /// ordering rule (§3.1), an item goes no later than the first section
786− /// and a section no earlier than after the last item, so it still comes
787− /// right after these namesakes.
788− fn namesakes_before(&self, r: NRef, dest: NRef, at: usize) -> usize {
789− let n = self.vault.tree.node(r);
790− let kids = self.vault.tree.resolved_children(dest);
791− kids.iter()
792− .take(at)
793− .filter(|&&c| {
794− let m = self.vault.tree.node(c);
795− c != r && m.title == n.title && m.kind == n.kind
796− })
797− .count()
798− }
799−
8001306 /// The node an action applies to: a menu's target, else the cursor's.
8011307 fn subject(&self) -> Option<NRef> {
8021308 self.action_target.or_else(|| self.current())
@@ -804,50 +1310,27 @@ impl App {
8041310
8051311 fn act_new_node(&mut self, child: bool) {
8061312 let Some(r) = self.subject() else { return };
807− self.push_undo("new node");
808− let res = if child {
809− match ops::append_child_public(&mut self.vault, r, "") {
810− Ok(nr) => {
811− self.edit_new_node(nr);
812− return;
813− }
814− Err(e) => Err(e),
815− }
1313+ self.push_undo(&format!("add a node {} {}", if child { "under" } else { "after" }, self.named(r)));
1314+ let made = if child {
1315+ ops::append_child_public(&mut self.vault, r, "")
8161316 } else {
817− let n = self.vault.tree.node(r);
818− let line = match n.kind {
1317+ let line = match self.vault.tree.node(r).kind {
8191318 Kind::Section => format!("{} ", "#".repeat(self.vault.tree.level(r))),
8201319 _ => "- ".to_string(),
8211320 };
8221321 let key = self.vault.key_of(r);
823− match ops::paste(&mut self.vault, r, &format!("{}\n", line), true) {
824− Ok(_) => {
825− // the new sibling: the node after the cursor node among
826− // its siblings in the outline, with an empty title
827− let r = self.vault.find_by_key(&key).unwrap_or(r);
828− let parent = self.outline_parent(r).unwrap_or(self.vault.tree.root);
829− let kids = self.vault.tree.resolved_children(parent);
830− let new = kids
831− .iter()
832− .position(|&c| c == r)
833− .and_then(|i| kids.get(i + 1).copied())
834− .filter(|&c| self.vault.tree.node(c).title.is_empty());
835− if let Some(nr) = new {
836− // a sibling of the zoomed node is outside the zoom:
837− // widen it to their parent
838− if self.zoom() == Some(r) {
839− self.set_zoom(self.outline_parent(r));
840− }
841− self.edit_new_node(nr);
842− return;
843− }
844− Ok(())
1322+ ops::paste(&mut self.vault, r, &format!("{}\n", line), true).map(|p| {
1323+ // a sibling of the zoomed node is outside the zoom: widen
1324+ // it to their parent
1325+ let r = self.vault.find_by_key(&key).unwrap_or(r);
1326+ if self.zoom() == Some(r) {
1327+ self.set_zoom(self.outline_parent(r));
8451328 }
846− Err(e) => Err(e),
847− }
1329+ p.node
1330+ })
8481331 };
849− match res {
850− Ok(()) => self.refresh_after("node created"),
1332+ match made {
1333+ Ok(nr) => self.edit_new_node(nr),
8511334 Err(e) => self.say(format!("error: {}", e)),
8521335 }
8531336 }
@@ -941,7 +1424,7 @@ impl App {
9411424 fn open_editor_on(&mut self, target: NRef) {
9421425 // an editor already open saves first, as on leaving it; one whose
9431426 // save is refused stays, with its text and its clipboard
944− if !self.save_editor_releasing("switch") {
1427+ if !self.save_editor_releasing() {
9451428 return;
9461429 }
9471430 if let Some(ed) = self.editor.take() {
@@ -949,6 +1432,8 @@ impl App {
9491432 }
9501433 let buf = fold_core::edit::open_editor(&self.vault, target);
9511434 self.editor = Some(editor::Editor::new(buf, self.edit_keys, self.edit_clip.clone()));
1435+ self.edit_refused = false;
1436+ self.edit_uncopied = None;
9521437 if self.mode != Mode::Edit {
9531438 self.edit_return = self.focus;
9541439 }
@@ -968,24 +1453,26 @@ impl App {
9681453
9691454 /// Write the editor's dirty blocks; true when nothing is left unsaved.
9701455 /// A refused save (the file changed on disk) keeps the text in the
971− /// editor and says why.
972− fn save_editor(&mut self, why: &str) -> bool {
973− self.write_editor(why, false)
1456+ /// editor and says why. One that goes through says nothing: the status
1457+ /// bar's ✓ saved says it (§10.6).
1458+ fn save_editor(&mut self) -> bool {
1459+ self.write_editor(false)
9741460 }
9751461
9761462 /// Save the editor to leave it, or before it is re-rendered (§10.6): a
9771463 /// block cut and not pasted back cannot be pasted as itself any more,
9781464 /// so once the save went through it is let go, and deleted (§5.2). It
9791465 /// is saved first still in transit, so a refused save leaves the editor
980− /// as it was, its clipboard too; and the save and the deletion are one
981− /// op-log entry (§10.10), so one undo puts back the block's file and
982− /// its embed together.
983− fn save_editor_releasing(&mut self, why: &str) -> bool {
984− self.write_editor(why, true)
1466+ /// as it was, its clipboard too; and the deletion goes into the op-log
1467+ /// entry of the save that wrote its embed out, this one or an autosave
1468+ /// before it (`record_edit`), so one undo puts back the block's file
1469+ /// and its embed together.
1470+ fn save_editor_releasing(&mut self) -> bool {
1471+ self.write_editor(true)
9851472 }
9861473
9871474 /// `save_editor`; with `release`, `save_editor_releasing`.
988− fn write_editor(&mut self, why: &str, release: bool) -> bool {
1475+ fn write_editor(&mut self, release: bool) -> bool {
9891476 if release && !self.editor_dirty() {
9901477 // nothing to save first, so nothing a refusal keeps in transit
9911478 if let Some(ed) = self.editor.as_mut() {
@@ -997,39 +1484,45 @@ impl App {
9971484 }
9981485 self.settle_undo();
9991486 let cursor = self.current().map(|r| self.vault.key_of(r));
1487+ // an open property form's node too: its OK saves the editor first
1488+ let props = self.props_target.filter(|_| self.mode == Mode::Props).map(|t| self.vault.key_of(t));
10001489 let on_editor = self.anchor_zoom_for_save();
1001− let Some(mut ed) = self.editor.take() else { return true };
1490+ // the editor saves where it is: a panic in the save leaves its text
1491+ // for `keep_unsaved` to keep (§10.6)
1492+ let Some(ed) = self.editor.as_mut() else { return true };
10021493 // what another program changed beside the edited blocks is taken in
10031494 // before the snapshot, so undoing this save leaves it be
10041495 ed.buf.rebase_dirty(&mut self.vault);
1005− let snap = ops::Snapshot::take(&self.vault, "edit");
1496+ let snap = ops::Snapshot::take(&self.vault, &format!("edit {}", edited(ed, None)));
1497+ if self.save_panics {
1498+ panic!("the editor's save panicked");
1499+ }
10061500 let mut res = ed.buf.save_all(&mut self.vault);
10071501 if let Some(n) = res.as_ref().ok().copied().filter(|_| release) {
10081502 ed.release_clip();
10091503 // the block that held the embed is written again: counted once
10101504 res = ed.buf.save_all(&mut self.vault).map(|m| m.max(n));
10111505 }
1012− self.editor = Some(ed);
10131506 self.settle_zoom_after_save(on_editor);
10141507 // blocks written before a refusal are an op too
1015− self.record_undo(snap);
1508+ self.record_edit(snap);
10161509 // the save re-parsed what it wrote: the outline cursor stays on its
1017− // node, so a verb that saves the editor first acts on the row clicked
1510+ // node, so a verb that saves the editor first acts on the row
1511+ // clicked, and the property form on its own
10181512 if let Some(r) = cursor.and_then(|k| self.find_exact(&k)) {
10191513 self.move_cursor_to(r);
10201514 }
1515+ if let Some(k) = props {
1516+ self.props_target = self.find_exact(&k);
1517+ }
10211518 let saved = match res {
1022− Ok(n) => {
1023− if n > 0 {
1024− self.say(format!("saved {} block(s) ({})", n, why));
1025− }
1026− true
1027− }
1519+ Ok(_) => true,
10281520 Err(e) => {
10291521 self.say(format!("error: {}", e));
10301522 false
10311523 }
10321524 };
1525+ self.edit_refused = !saved;
10331526 self.edit_last_key = Instant::now();
10341527 saved
10351528 }
@@ -1058,7 +1551,7 @@ impl App {
10581551 /// is dropped except by *Revert*.
10591552 fn close_editor(&mut self) -> bool {
10601553 // a block cut and not pasted back is deleted once saved (§5.2)
1061− if !self.save_editor_releasing("exit") {
1554+ if !self.save_editor_releasing() {
10621555 self.say(format!("{} — still editing; Revert (:q!) drops the changes", self.status));
10631556 return false;
10641557 }
@@ -1098,16 +1591,16 @@ impl App {
10981591 Some(r)
10991592 }
11001593
1101− /// Before something writes files under an open editor, an outline verb
1102− /// or a reload (§10.6, §11.2): the editor saves first. Returns what it
1103− /// is open on and the files as they are, for `editor_after_write`.
1594+ /// Before an outline verb writes files under an open editor (§10.6):
1595+ /// the editor saves first. Returns what it is open on and the files as
1596+ /// they are, for `editor_after_write`.
11041597 fn editor_before_write(&mut self, why: &str) -> Option<(NodeKey, ops::Snapshot)> {
11051598 // the editor is re-rendered after, so a block cut and not pasted
11061599 // back cannot be pasted as itself any more: once the editor is
11071600 // saved, it is deleted (§5.2). A refused save keeps it in transit,
11081601 // as the editor keeps its text (it is not re-rendered)
11091602 self.editor.as_ref()?;
1110− self.save_editor_releasing(why);
1603+ self.save_editor_releasing();
11111604 let key = self.editor_key()?;
11121605 Some((key, ops::Snapshot::take(&self.vault, why)))
11131606 }
@@ -1175,8 +1668,9 @@ impl App {
11751668 self.discard_editor();
11761669 return;
11771670 }
1178− if out.save {
1179− self.save_editor("save");
1671+ // `:w`, Ctrl-S: asked for, so answered
1672+ if out.save && self.save_editor() {
1673+ self.say("saved");
11801674 }
11811675 if out.close {
11821676 self.close_editor();
@@ -1186,14 +1680,19 @@ impl App {
11861680 if before != after && self.editor.as_ref().is_some_and(|e| e.buf.dirty.contains(&before)) {
11871681 self.settle_undo();
11881682 let on_editor = self.anchor_zoom_for_save();
1189− let mut ed = self.editor.take().unwrap();
1683+ // saved where it is, as in `write_editor`
1684+ let ed = self.editor.as_mut().unwrap();
11901685 ed.buf.rebase_dirty(&mut self.vault);
1191− let snap = ops::Snapshot::take(&self.vault, "edit");
1686+ let snap = ops::Snapshot::take(&self.vault, &format!("edit {}", edited(ed, Some(&before))));
1687+ if self.save_panics {
1688+ panic!("the editor's save panicked");
1689+ }
11921690 let res = ed.buf.splice(&mut self.vault, before);
1193− self.editor = Some(ed);
11941691 self.settle_zoom_after_save(on_editor);
11951692 if res.is_ok() {
1196− self.record_undo(snap);
1693+ self.record_edit(snap);
1694+ } else {
1695+ self.edit_refused = true;
11971696 }
11981697 }
11991698 }
@@ -1201,6 +1700,7 @@ impl App {
12011700 /// Text pasted into the terminal (bracketed paste): into the editor or
12021701 /// the open prompt.
12031702 pub fn handle_paste(&mut self, text: &str) {
1703+ self.last_input = Some(Instant::now());
12041704 if let Some(p) = self.prompt.as_mut() {
12051705 p.text.push_str(text.lines().next().unwrap_or(""));
12061706 self.refresh_picks();
@@ -1257,18 +1757,23 @@ impl App {
12571757
12581758 fn delete_prop(&mut self, i: usize) {
12591759 let Some((k, _, editable)) = self.props_rows.get(i).cloned() else { return };
1260− let Some(t) = self.props_target else { return };
1760+ if self.props_target.is_none() {
1761+ return;
1762+ }
12611763 if !editable {
12621764 self.say("read-only line (preserved verbatim)");
12631765 return;
12641766 }
12651767 let edit = self.editor_before_write("outline verb");
1266− self.push_undo("delete property");
1267− match ops::set_frontmatter_key(&mut self.vault, t.0, &k, None) {
1268− Ok(()) => self.say(format!("{} removed", k)),
1269− Err(e) => self.say(format!("error: {}", e)),
1768+ // the save may renumber the form's node, and finds it again
1769+ if let Some(t) = self.props_target {
1770+ self.push_undo(&format!("remove {} from {}", k, self.named(t)));
1771+ match ops::set_frontmatter_key(&mut self.vault, t.0, &k, None) {
1772+ Ok(()) => self.say(format!("{} removed", k)),
1773+ Err(e) => self.say(format!("error: {}", e)),
1774+ }
1775+ self.reopen_props();
12701776 }
1271− self.reopen_props();
12721777 self.editor_after_write(edit);
12731778 }
12741779
@@ -1353,7 +1858,7 @@ impl App {
13531858 Err(e) => self.say(e),
13541859 },
13551860 PromptAction::CaptureText(task) => {
1356− self.push_undo("capture");
1861+ self.push_undo(&format!("capture {}", quoted(p.text.trim())));
13571862 match ops::capture(&mut self.vault, &p.text, task) {
13581863 Ok(r) => {
13591864 self.reveal(r);
@@ -1371,7 +1876,7 @@ impl App {
13711876 self.prompt = Some(p);
13721877 return;
13731878 }
1374− self.push_undo("set property");
1879+ self.push_undo(&format!("set {} of {}", k, self.named(t)));
13751880 let on = self.on_node(t);
13761881 match ops::set_property(&mut self.vault, t, &k, &p.text) {
13771882 Ok(block) => {
@@ -1405,11 +1910,19 @@ impl App {
14051910 }
14061911
14071912 /// Accept a prompt as an outline verb: the editor saves first and is
1408− /// re-rendered after (§10.6).
1913+ /// re-rendered after (§10.6). One that writes nothing, as *Go to…*, or
1914+ /// only opens the next prompt, saves it as a pause in typing does: a
1915+ /// block cut in it stays in transit (§5.2).
14091916 fn accept_prompt_saving_editor(&mut self, mut p: Prompt) {
14101917 // the save re-parses what it wrote: the pick is found again by key
14111918 let picked = p.picks.get(p.sel).map(|&r| self.vault.key_of(r));
1412− let edit = self.editor_before_write("outline verb");
1919+ let edit = match p.action {
1920+ PromptAction::GoTo | PromptAction::PropNew | PromptAction::ReadSearch => {
1921+ self.save_editor();
1922+ None
1923+ }
1924+ _ => self.editor_before_write("outline verb"),
1925+ };
14131926 match picked.map(|k| self.find_exact(&k)) {
14141927 Some(None) => self.say("that node is gone"),
14151928 Some(Some(r)) => {
@@ -1446,8 +1959,11 @@ impl App {
14461959 return;
14471960 }
14481961 let q = p.text.to_lowercase();
1449− // a node cannot move into its own subtree
1450− let moving = p.moving.as_ref().and_then(|k| self.find_exact(k)).map(|r| self.vault.tree.resolved_child(r));
1962+ let refile = matches!(p.action, PromptAction::Refile);
1963+ // a node cannot move into its own subtree, nor into the node its
1964+ // conflict copy is of, which moves with it (§12.5)
1965+ let moving = p.moving.as_ref().and_then(|k| self.find_exact(k));
1966+ let moving = moving.map(|r| ops::conflict_pair(&self.vault.tree, r)).unwrap_or_default();
14511967 let mut nodes: Vec<NRef> = Vec::new();
14521968 self.vault.tree.walk(self.vault.tree.root, &mut |t, r| {
14531969 if t.node(r).kind != Kind::Root && !t.node(r).is_embed() {
@@ -1457,7 +1973,11 @@ impl App {
14571973 let mut scored: Vec<(u8, usize, NRef)> = Vec::new();
14581974 for r in nodes {
14591975 let chain = self.chain(r);
1460− if moving.map(|m| chain.contains(&m)).unwrap_or(false) {
1976+ if moving.iter().any(|m| chain.contains(m)) {
1977+ continue;
1978+ }
1979+ // nor into a conflict copy, which keeping ours trashes (§12.5)
1980+ if refile && chain.iter().any(|&c| self.vault.tree.node(c).conflict().is_some()) {
14611981 continue;
14621982 }
14631983 let title = self.vault.tree.node(r).title.to_lowercase();
@@ -1488,8 +2008,7 @@ impl App {
14882008 let mut chain = self.chain(r);
14892009 chain.pop();
14902010 for a in chain {
1491− let k = self.vault.key_of(a);
1492− self.folded.retain(|f| f != &k);
2011+ self.set_folded(a, false);
14932012 }
14942013 if self.zoom().is_some() && !self.rows().iter().any(|row| row.nref == r) {
14952014 self.set_zoom(None);
@@ -1509,13 +2028,43 @@ impl App {
15092028 if self.editor.is_some() && !self.close_editor() {
15102029 return;
15112030 }
2031+ // on the first of the pairs that came in unseen, if any (§10.7)
2032+ let waiting = std::mem::take(&mut self.conflicts_waiting);
2033+ self.conflict_idx = self.pairs_among(&waiting).first().map_or(0, |&(i, _)| i);
2034+ self.conflict_opened = None;
15122035 self.mode = Mode::Conflict;
1513− self.conflict_idx = 0;
2036+ }
2037+
2038+ /// The copy in the pair `r` is a side of (§12.5): itself, if it is a
2039+ /// copy with a node to pair with, else the copy right after it.
2040+ fn copy_of_pair(&self, r: NRef) -> Option<NRef> {
2041+ let r = self.vault.tree.resolved_child(r);
2042+ let pairs = fold_core::merge::conflict_pairs(&self.vault);
2043+ let pair = pairs.iter().find(|&&(_, t)| t == r).or_else(|| pairs.iter().find(|&&(o, _)| o == r));
2044+ pair.map(|&(_, t)| t)
2045+ }
2046+
2047+ /// The conflict view at the pair `r` is a side of: a click on a copy's
2048+ /// ⚠, or *Resolve conflict…* in either side's node menu (§10.7).
2049+ fn enter_conflict_view_at(&mut self, r: NRef) {
2050+ let copy = self.copy_of_pair(r).map(|t| self.vault.key_of(t));
2051+ self.enter_conflict_view();
2052+ // found again by key: the editor, closing, saves and renumbers
2053+ let at = copy.and_then(|k| self.conflict_blocks().iter().position(|b| *b == k));
2054+ if let Some(i) = at.filter(|_| self.mode == Mode::Conflict) {
2055+ self.conflict_idx = i;
2056+ }
15142057 }
15152058
15162059 pub fn key_conflict_pub(&mut self, key: KeyEvent) { self.key_conflict(key) }
15172060 fn key_conflict(&mut self, key: KeyEvent) {
1518− let pairs = fold_core::merge::conflict_pairs(&self.vault);
2061+ // a view that just opened on its own takes no side, nor edits: a
2062+ // key on its way was meant for what was there before (§10.7)
2063+ let fresh = self.conflict_opened.is_some_and(|t| t.elapsed() < Duration::from_millis(500));
2064+ if fresh && matches!(key.code, KeyCode::Char('o' | 't' | 'b' | 'e')) {
2065+ return;
2066+ }
2067+ let pairs = fold_core::merge::conflict_pairs_in_order(&self.vault);
15192068 match key.code {
15202069 KeyCode::Esc => {
15212070 self.mode = Mode::Normal;
@@ -1533,29 +2082,22 @@ impl App {
15332082 KeyCode::Char('N') => {
15342083 self.conflict_idx = self.conflict_idx.saturating_sub(1);
15352084 }
1536− KeyCode::Char('o') => {
1537− if let Some(&(_, theirs)) = pairs.get(self.conflict_idx) {
1538− self.push_undo("keep ours");
1539− match fold_core::merge::resolve_keep_ours(&mut self.vault, theirs) {
1540− Ok(()) => self.say("kept ours"),
1541− Err(e) => self.say(format!("error: {}", e)),
1542− }
1543− }
1544− }
1545− KeyCode::Char('t') => {
2085+ KeyCode::Char(c @ ('o' | 't' | 'b')) => {
15462086 if let Some(&(ours, theirs)) = pairs.get(self.conflict_idx) {
1547− self.push_undo("keep theirs");
1548− match fold_core::merge::resolve_keep_theirs(&mut self.vault, ours, theirs) {
1549− Ok(()) => self.say("kept theirs"),
1550− Err(e) => self.say(format!("error: {}", e)),
1551− }
1552− }
1553− }
1554− KeyCode::Char('b') => {
1555− if let Some(&(_, theirs)) = pairs.get(self.conflict_idx) {
1556− self.push_undo("keep both");
1557− match fold_core::merge::resolve_keep_both(&mut self.vault, theirs) {
1558− Ok(()) => self.say("kept both"),
2087+ let side = match c {
2088+ 'o' => "ours",
2089+ 't' => "theirs",
2090+ _ => "both",
2091+ };
2092+ let name = self.named(ours);
2093+ self.push_undo(&format!("keep {} for {}", side, name));
2094+ let res = match c {
2095+ 'o' => fold_core::merge::resolve_keep_ours(&mut self.vault, theirs),
2096+ 't' => fold_core::merge::resolve_keep_theirs(&mut self.vault, ours, theirs),
2097+ _ => fold_core::merge::resolve_keep_both(&mut self.vault, theirs),
2098+ };
2099+ match res {
2100+ Ok(()) => self.say(format!("kept {} for {} · u undoes", side, name)),
15592101 Err(e) => self.say(format!("error: {}", e)),
15602102 }
15612103 }
@@ -1565,6 +2107,19 @@ impl App {
15652107 self.open_editor_on(ours);
15662108 }
15672109 }
2110+ // undo and redo as in the outline (§10.10); a pair put back is
2111+ // the one shown
2112+ KeyCode::Char(c @ ('u' | 'U')) => {
2113+ let before = self.conflict_blocks();
2114+ if c == 'u' {
2115+ self.act_undo();
2116+ } else {
2117+ self.act_redo();
2118+ }
2119+ if let Some(i) = self.conflict_blocks().iter().position(|b| !before.contains(b)) {
2120+ self.conflict_idx = i;
2121+ }
2122+ }
15682123 _ => {}
15692124 }
15702125 // a resolved pair leaves the list: stay on the pair now shown
@@ -1573,10 +2128,22 @@ impl App {
15732128 }
15742129
15752130 pub fn handle_key(&mut self, key: KeyEvent) {
2131+ self.last_input = Some(Instant::now());
2132+ let held = self.held_words.take();
15762133 self.handle_key_inner(key);
2134+ self.drop_held_words(held);
15772135 self.settle_undo();
15782136 }
15792137
2138+ /// A held verb's words are let go once the next key or click is handled
2139+ /// (§10.1): the verb has acted, or the selection may have moved. What
2140+ /// that key or click said, or a verb held again, stays.
2141+ fn drop_held_words(&mut self, held: Option<String>) {
2142+ if self.held_words.is_none() && held.is_some_and(|w| w == self.status) {
2143+ self.say("");
2144+ }
2145+ }
2146+
15802147 fn handle_key_inner(&mut self, key: KeyEvent) {
15812148 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
15822149 if ctrl && key.code == KeyCode::Char('c') && self.mode != Mode::Edit {
@@ -1585,16 +2152,16 @@ impl App {
15852152 }
15862153 if let Some(p) = self.pending.take() {
15872154 match (p, key.code) {
1588− ('z', KeyCode::Char('d')) => {
1589− self.hide_done = !self.hide_done;
1590− self.clamp_cursor();
1591− self.say(if self.hide_done { "done hidden" } else { "done shown" });
1592− }
2155+ ('z', KeyCode::Char('d')) => self.toggle_hide_done(),
15932156 ('z', KeyCode::Char('r')) => {
15942157 self.raw_mode = !self.raw_mode;
15952158 self.say(if self.raw_mode { "raw" } else { "styled" });
15962159 }
1597− ('z', KeyCode::Char('a')) => self.act_archive(),
2160+ ('z', KeyCode::Char('a')) => {
2161+ if !self.outline_held("za") {
2162+ self.act_archive();
2163+ }
2164+ }
15982165 ('z', KeyCode::Char('w')) => self.run_action(Action::Wrap),
15992166 ('z', KeyCode::Char('p')) => self.run_action(Action::ReadingPane),
16002167 ('g', KeyCode::Char('g')) => match self.focus {
@@ -1606,7 +2173,7 @@ impl App {
16062173 let doc = self.reading_doc();
16072174 self.jump_heading(&doc, if p == ']' { 1 } else { -1 });
16082175 }
1609− _ => {}
2176+ _ => self.no_sequence(p, key),
16102177 }
16112178 return;
16122179 }
@@ -1636,39 +2203,63 @@ impl App {
16362203 }
16372204 Mode::Filter => self.key_filter(key),
16382205 Mode::Picker => self.key_palette(key),
2206+ // help as from the top bar's ?, which the editor types (§10.8)
2207+ Mode::Edit if key.code == KeyCode::F(1) => self.run_action(Action::Help),
16392208 // the keymap decides what Ctrl-c means (copy, or Vim's escape)
16402209 Mode::Edit => self.key_edit(key),
16412210 Mode::Props => self.key_props(key),
1642− Mode::Help => {
1643− if matches!(
1644− key.code,
1645− KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') | KeyCode::Char('?')
1646− ) {
1647− self.mode = self.base_mode();
2211+ Mode::Help => match key.code {
2212+ KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') | KeyCode::Char('?') | KeyCode::F(1) => {
2213+ self.mode = self.base_mode()
16482214 }
1649− }
2215+ // a line at a time; drawing stops it at the last line
2216+ KeyCode::Char('j') | KeyCode::Down => self.help_scroll += 1,
2217+ KeyCode::Char('k') | KeyCode::Up => self.help_scroll = self.help_scroll.saturating_sub(1),
2218+ _ => {}
2219+ },
16502220 Mode::Conflict => self.key_conflict(key),
16512221 }
16522222 }
16532223
1654− /// Drop the editor's unsaved changes (§10.6: *Revert*, `:q!`).
2224+ /// Drop the editor's unsaved changes (§10.6: *Revert*, `:q!`). Text a
2225+ /// save was refused for is copied to the trash first (§11.5), and the
2226+ /// status line names the copy; one that cannot be written drops
2227+ /// nothing, but for a second *Revert* on the same text: the way out
2228+ /// where the trash takes nothing.
16552229 fn discard_editor(&mut self) {
2230+ let mut said = String::from("changes discarded");
2231+ if let Some((name, text)) = self.editor_text().filter(|_| self.edit_refused && self.editor_dirty()) {
2232+ match self.vault.trash_text(&name, &text) {
2233+ Ok(p) => {
2234+ let entry = p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
2235+ said = format!("{}; a copy is in the trash: {}", said, entry);
2236+ }
2237+ Err(_) if self.edit_uncopied.as_ref() == Some(&text) => said = format!("{}; no copy kept", said),
2238+ Err(e) => {
2239+ // what to press first: the error is what the bar cuts
2240+ self.say(format!("can't copy to the trash; Revert (:q!) again drops the changes — {}", e));
2241+ self.edit_uncopied = Some(text);
2242+ return;
2243+ }
2244+ }
2245+ }
2246+ self.edit_refused = false;
2247+ self.edit_uncopied = None;
16562248 let ed = self.editor.take();
16572249 self.mode = Mode::Normal;
16582250 self.focus = self.edit_return;
16592251 // the reload may renumber nodes: files can have changed on disk
16602252 self.anchor_zoom();
16612253 let _ = self.vault.reload();
1662− let mut said = String::from("changes discarded");
16632254 if let Some(ed) = ed {
16642255 // a block a save took out of its parent (cut and not pasted
16652256 // back, or deleted) cannot be pasted back as itself any more:
16662257 // it is deleted now, as on leaving any other way (§5.2)
1667− let snap = ops::Snapshot::take(&self.vault, "revert");
2258+ let snap = ops::Snapshot::take(&self.vault, &format!("revert {}", edited(&ed, None)));
16682259 if let Err(e) = ed.buf.discard(&mut self.vault) {
16692260 said = format!("{}; error: {}", said, e);
16702261 }
1671− self.record_undo(snap);
2262+ self.record_edit(snap);
16722263 self.edit_clip = ed.clip;
16732264 }
16742265 self.settle_zoom();
@@ -1676,6 +2267,122 @@ impl App {
16762267 self.say(said);
16772268 }
16782269
2270+ /// When fold ends any way but a quit — a signal, an error, a panic —
2271+ /// the editor is saved as on a quit (§10.6), and text no save can take
2272+ /// goes to the trash whole (§11.5). What to say once the terminal is
2273+ /// back, if anything.
2274+ pub fn keep_unsaved(&mut self) -> Option<String> {
2275+ // the text is taken first: after a panic, the save may panic too
2276+ let (name, text) = self.editor_text()?;
2277+ let dirty = self.editor_dirty();
2278+ // saved even when clean: a block cut and not pasted back, left in
2279+ // transit by the autosave, is deleted on any end of the app (§5.2)
2280+ let saved = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.save_editor_releasing()));
2281+ if saved.unwrap_or(false) || !dirty {
2282+ return None;
2283+ }
2284+ Some(match self.vault.trash_text(&name, &text) {
2285+ Ok(p) => format!("unsaved text kept in {}", p.display()),
2286+ Err(e) => format!("unsaved text could not go to the trash ({}); here it is:\n\n{}", e, text),
2287+ })
2288+ }
2289+
2290+ /// Make every editor save panic where it writes, as a bug in the splice
2291+ /// would at every pause: for tests of what fold keeps when it crashes
2292+ /// (§10.6).
2293+ #[doc(hidden)]
2294+ pub fn panic_in_saves(&mut self) {
2295+ self.save_panics = true;
2296+ }
2297+
2298+ /// The editor's whole text, and the name the trash keeps it under:
2299+ /// `unsaved-<title>.md`, by the node it is open on.
2300+ fn editor_text(&self) -> Option<(String, String)> {
2301+ let ed = self.editor.as_ref()?;
2302+ let title = ed.buf.owners.values().find(|i| i.parent.is_none()).map(|i| i.title.as_str()).unwrap_or("");
2303+ let text = ed.buf.lines.iter().map(|l| format!("{}\n", l.text)).collect();
2304+ Some((format!("unsaved-{}.md", fold_core::slug(title)), text))
2305+ }
2306+
2307+ /// A key verb on the selection while the wheel has left it out of view
2308+ /// (§10.1): the outline scrolls it back first, a third of the way down.
2309+ /// A verb that changes something is held there, the status bar naming
2310+ /// the node, and acts when pressed again with the node in view; one
2311+ /// that changes nothing goes on. True when the verb is held.
2312+ fn outline_held(&mut self, key: &str) -> bool {
2313+ let (view, len) = (self.ui.outline_view, self.rows().len());
2314+ let at = self.cursor.min(len.saturating_sub(1));
2315+ // before the first frame there is no view to be out of
2316+ if view == 0 || len == 0 || ui::in_view(self.outline_scroll, at, view, len) {
2317+ return false;
2318+ }
2319+ self.outline_scroll = at.saturating_sub(view / 3);
2320+ self.held(key, self.current())
2321+ }
2322+
2323+ /// The same for the reading pane's verbs on the line under its cursor
2324+ /// (§10.4).
2325+ fn reading_held(&mut self, key: &str, doc: &fold_core::reading::ReadingDoc) -> bool {
2326+ use fold_core::reading::LineRef;
2327+ let view = self.ui.reading_view;
2328+ let Some(at) = self.ui.read_rows.iter().position(|&d| d == Some(self.read_cursor)) else { return false };
2329+ if view == 0 || ui::in_view(self.scroll_reading, at, view, self.ui.read_rows.len()) {
2330+ return false;
2331+ }
2332+ self.scroll_reading = at.saturating_sub(view / 3);
2333+ // Enter acts on a title line only
2334+ let r = match fold_core::reading::node_at(doc, self.read_cursor) {
2335+ Some(LineRef::Title(r)) => Some(r),
2336+ Some(LineRef::Body(r) | LineRef::Embed(r)) if key != "Enter" => Some(r),
2337+ _ => None,
2338+ };
2339+ self.held(key, r)
2340+ }
2341+
2342+ /// Whether a verb just brought into view waits for a second press: one
2343+ /// that would change `r` does, and says so, the key first: the name is
2344+ /// what gives way where the bar is short.
2345+ fn held(&mut self, key: &str, r: Option<NRef>) -> bool {
2346+ let Some(r) = r else { return false };
2347+ let Some(what) = self.change_words(key, r) else { return false };
2348+ let words = format!("press {} again to {}", key, what);
2349+ self.say(words.clone());
2350+ self.held_words = Some(words);
2351+ true
2352+ }
2353+
2354+ /// What a key would do to `r`, in the status bar's words, naming it;
2355+ /// `None` when it changes nothing: `e a m y`, the folds, `o`, `x` on a
2356+ /// node that is no task, a paste with nothing copied.
2357+ fn change_words(&self, key: &str, r: NRef) -> Option<String> {
2358+ let n = self.vault.tree.node(self.vault.tree.resolved_child(r));
2359+ let what = match key {
2360+ // Enter zooms into a heading, and toggles a task item (§10.4)
2361+ "Enter" if n.kind == Kind::Section => return None,
2362+ "x" | "Enter" => match n.task? {
2363+ TaskState::Open => "mark {} done",
2364+ TaskState::Done => "reopen {}",
2365+ },
2366+ "t" if n.task.is_some() => "remove the checkbox from {}",
2367+ "t" => "make {} a task",
2368+ "d" => "delete {}",
2369+ "s" => "make {} a block",
2370+ "J" => "move {} down",
2371+ "K" => "move {} up",
2372+ ">" => "indent {}",
2373+ "<" => "outdent {}",
2374+ "~" if n.kind == Kind::Section => "make {} a bullet",
2375+ "~" => "make {} a heading",
2376+ "r" => "move {} elsewhere",
2377+ "za" => "archive {}",
2378+ "p" | "P" if self.register.is_empty() => return None,
2379+ "p" => "paste after {}",
2380+ "P" => "paste before {}",
2381+ _ => return None,
2382+ };
2383+ Some(what.replacen("{}", &self.named(r), 1))
2384+ }
2385+
16792386 pub fn key_normal(&mut self, key: KeyEvent) {
16802387 let rows = self.rows();
16812388 match self.focus {
@@ -1688,6 +2395,16 @@ impl App {
16882395 let len = rows.len();
16892396 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
16902397 let half = (self.pane_outline.height.saturating_sub(2) as usize / 2).max(1);
2398+ // a verb on a selection the wheel left out of view shows it first
2399+ let verb = match key.code {
2400+ _ if ctrl => None,
2401+ KeyCode::Char(c) if "xtdsJK><~rpPeamyhlHL".contains(c) => Some(c.to_string()),
2402+ KeyCode::Left | KeyCode::Right => Some("h".into()),
2403+ _ => None,
2404+ };
2405+ if verb.is_some_and(|k| self.outline_held(&k)) {
2406+ return;
2407+ }
16912408 match key.code {
16922409 KeyCode::Char('d') if ctrl => {
16932410 self.cursor = (self.cursor + half).min(len.saturating_sub(1));
@@ -1695,7 +2412,7 @@ impl App {
16952412 KeyCode::Char('u') if ctrl => {
16962413 self.cursor = self.cursor.saturating_sub(half);
16972414 }
1698− _ if ctrl => {}
2415+ _ if ctrl => self.misfire(key),
16992416 KeyCode::Char('q') => self.quit = true,
17002417 KeyCode::Tab => {
17012418 self.show_reading = true;
@@ -1736,27 +2453,24 @@ impl App {
17362453 KeyCode::Char('H') => {
17372454 if let Some(r) = self.current() {
17382455 // fold everything under cursor
1739− let mut keys = Vec::new();
2456+ let mut nodes = Vec::new();
17402457 self.vault.tree.walk(r, &mut |t, n| {
17412458 if !t.resolved_children(n).is_empty() {
1742− keys.push(self.vault.key_of(n));
2459+ nodes.push(n);
17432460 }
17442461 });
1745− for k in keys {
1746− if !self.folded.contains(&k) {
1747− self.folded.push(k);
1748− }
2462+ for n in nodes {
2463+ self.set_folded(n, true);
17492464 }
17502465 }
17512466 }
17522467 KeyCode::Char('L') => {
17532468 if let Some(r) = self.current() {
1754− let mut keys = Vec::new();
1755− self.vault.tree.walk(r, &mut |t, n| {
1756− keys.push(self.vault.key_of(n));
1757− let _ = t;
1758− });
1759− self.folded.retain(|k| !keys.contains(k));
2469+ let mut nodes = Vec::new();
2470+ self.vault.tree.walk(r, &mut |_, n| nodes.push(n));
2471+ for n in nodes {
2472+ self.set_folded(n, false);
2473+ }
17602474 }
17612475 }
17622476 KeyCode::Char('-') => self.goto_parent_row(&rows),
@@ -1788,56 +2502,42 @@ impl App {
17882502 KeyCode::Char('C') => self.run_action(Action::CaptureTask),
17892503 KeyCode::Char('/') => self.run_action(Action::Filter),
17902504 KeyCode::Char(':') => self.run_action(Action::Palette),
1791− KeyCode::Char('?') => self.run_action(Action::Help),
2505+ KeyCode::Char('?') | KeyCode::F(1) => self.run_action(Action::Help),
17922506 KeyCode::Char('u') => self.act_undo(),
17932507 KeyCode::Char('U') => self.act_redo(),
17942508 KeyCode::Char('e') => self.act_edit(),
17952509 KeyCode::Char('a') => self.act_props(),
17962510 KeyCode::Char('r') => self.run_action(Action::Refile),
1797− _ => {}
2511+ _ => self.misfire(key),
17982512 }
17992513 }
18002514
18012515 fn act_undo(&mut self) {
1802− self.settle_undo();
1803− let Some(inv) = self.undo.pop() else {
1804− self.say("nothing to undo");
1805− return;
1806− };
1807− self.mark_self_write();
1808− self.anchor_zoom();
1809− match inv.undo(&mut self.vault) {
1810− Ok(()) => {
1811− self.say(format!("undone: {}", inv.description));
1812− self.redo.push(inv);
1813− self.clamp_cursor();
1814− }
1815− Err(e) => {
1816− // refused: an external change since; the entry stays
1817− self.say(format!("undo refused: {}", e));
1818− self.undo.push(inv);
1819− }
1820− }
2516+ self.undo_redo(true);
18212517 }
18222518
18232519 fn act_redo(&mut self) {
2520+ self.undo_redo(false);
2521+ }
2522+
2523+ /// Undo the last change, or redo the last one undone (§10.10), and say
2524+ /// which in words. One refused — a file it touched changed since —
2525+ /// stays, and says why.
2526+ fn undo_redo(&mut self, undo: bool) {
18242527 self.settle_undo();
1825− let Some(inv) = self.redo.pop() else {
1826− self.say("nothing to redo");
1827− return;
1828− };
1829− self.mark_self_write();
1830− self.anchor_zoom();
1831− match inv.redo(&mut self.vault) {
1832− Ok(()) => {
1833− self.say(format!("redone: {}", inv.description));
1834− self.undo.push(inv);
2528+ let (done, verb) = if undo { ("undone", "undo") } else { ("redone", "redo") };
2529+ if if undo { self.log.last_undo() } else { self.log.last_redo() }.is_some() {
2530+ // what it writes is ours, and may renumber the zoom's nodes
2531+ self.mark_self_write();
2532+ self.anchor_zoom();
2533+ }
2534+ match self.log.step(&mut self.vault, undo) {
2535+ None => self.say(format!("nothing to {}", verb)),
2536+ Some(Ok(what)) => {
2537+ self.say(format!("{}: {}", done, what));
18352538 self.clamp_cursor();
18362539 }
1837− Err(e) => {
1838− self.say(format!("redo refused: {}", e));
1839− self.redo.push(inv);
1840− }
2540+ Some(Err(e)) => self.say(format!("{} refused: {}", verb, e)),
18412541 }
18422542 }
18432543
@@ -1861,13 +2561,27 @@ impl App {
18612561 }
18622562
18632563 fn record_undo(&mut self, snap: ops::Snapshot) {
1864− if let Some(inv) = ops::Inverse::since(snap, &self.vault) {
1865− self.undo.push(inv);
1866− self.redo.clear();
2564+ if self.log.record(ops::Inverse::since(snap, &self.vault)) {
2565+ self.mark_self_write();
2566+ }
2567+ }
2568+
2569+ /// `record_undo` for a save of the editor, its *Revert*, or a reload
2570+ /// that re-renders it (§11.2), which keeps a block cut in the editor in
2571+ /// one entry with its file (`OpLog::record_edit`).
2572+ fn record_edit(&mut self, snap: ops::Snapshot) {
2573+ let inv = ops::Inverse::since(snap, &self.vault);
2574+ let in_transit = self.transit().into_iter().map(|(_, path)| path).collect();
2575+ if self.log.record_edit(inv, &self.vault, self.editor.is_some(), in_transit) {
18672576 self.mark_self_write();
18682577 }
18692578 }
18702579
2580+ /// The blocks the open editor holds in transit (§5.2), with their files.
2581+ fn transit(&self) -> Vec<(Id, String)> {
2582+ self.editor.as_ref().map(|ed| ed.buf.transit(&self.vault)).unwrap_or_default()
2583+ }
2584+
18712585 /// Parent and siblings are found on the flattened rows rather than in
18722586 /// the tree: a block row's tree parent is its own file's root, but its
18732587 /// outline parent is the row that embeds it.
@@ -1901,6 +2615,15 @@ impl App {
19012615 let doc = self.reading_doc();
19022616 let nlines = doc.lines.len();
19032617 let half = (self.pane_reading.height.saturating_sub(2) as usize / 2).max(1);
2618+ // a verb on a cursor line the wheel left out of view shows it first
2619+ let verb = match key.code {
2620+ KeyCode::Enter => Some("Enter".to_string()),
2621+ KeyCode::Char(c @ ('x' | 'e' | 'a' | 'm' | 'o')) => Some(c.to_string()),
2622+ _ => None,
2623+ };
2624+ if verb.is_some_and(|k| self.reading_held(&k, &doc)) {
2625+ return;
2626+ }
19042627 match key.code {
19052628 KeyCode::Tab => self.focus = Focus::Outline,
19062629 KeyCode::Char('j') | KeyCode::Down => {
@@ -1928,21 +2651,15 @@ impl App {
19282651 // it); a task item toggles (§10.4)
19292652 let n = self.vault.tree.node(r);
19302653 if n.kind == Kind::Section {
1931− self.set_zoom(Some(r));
1932− self.read_cursor = 0;
1933− self.scroll_reading = 0;
2654+ self.zoom_into(r);
19342655 } else if n.task.is_some() {
1935− self.act_on_node(r, |app, r| {
1936− let _ = ops::toggle_task(&mut app.vault, r);
1937− });
2656+ self.toggle_read_task(r);
19382657 }
19392658 }
19402659 Some(LineRef::Embed(e)) => {
19412660 let t = self.vault.tree.resolved_child(e);
19422661 if t != e {
1943− self.set_zoom(Some(t));
1944− self.read_cursor = 0;
1945− self.scroll_reading = 0;
2662+ self.zoom_into(t);
19462663 }
19472664 }
19482665 _ => {}
@@ -1959,9 +2676,7 @@ impl App {
19592676 }
19602677 KeyCode::Char('x') => {
19612678 if let Some(r) = self.read_node() {
1962− self.act_on_node(r, |app, r| {
1963− let _ = ops::toggle_task(&mut app.vault, r);
1964− });
2679+ self.toggle_read_task(r);
19652680 }
19662681 }
19672682 KeyCode::Char('e') => {
@@ -1977,8 +2692,8 @@ impl App {
19772692 }
19782693 KeyCode::Char('o') => self.open_link_under_cursor(&doc),
19792694 KeyCode::Char('/') => self.open_prompt("search", PromptAction::ReadSearch, String::new()),
1980− KeyCode::Char('n') => self.next_match(&doc, 1),
1981− KeyCode::Char('N') => self.next_match(&doc, -1),
2695+ KeyCode::Char('n') => self.next_match(1),
2696+ KeyCode::Char('N') => self.next_match(-1),
19822697 KeyCode::Char('q') => self.quit = true,
19832698 // what isn't the pane's own works as it does in the outline
19842699 KeyCode::Char('m') => {
@@ -1988,11 +2703,11 @@ impl App {
19882703 self.run_action(Action::NodeMenu);
19892704 self.action_target = None;
19902705 }
1991− KeyCode::Char(':' | '?' | 'c' | 'C' | 'u' | 'U') => {
2706+ KeyCode::Char(':' | '?' | 'c' | 'C' | 'u' | 'U') | KeyCode::F(1) => {
19922707 let rows = self.rows();
19932708 self.key_outline(key, rows);
19942709 }
1995− _ => {}
2710+ _ => self.misfire(key),
19962711 }
19972712 }
19982713
@@ -2032,10 +2747,18 @@ impl App {
20322747 }
20332748 }
20342749
2035− /// Run a mutation on a node from the reading pane, keeping the cursor.
2036− fn act_on_node(&mut self, r: NRef, f: impl Fn(&mut App, NRef)) {
2037− self.push_undo("reading-pane edit");
2038− f(self, r);
2750+ /// Check or uncheck a task from the reading pane (§10.4), keeping the
2751+ /// cursor, and say which by name as `x` in the outline does.
2752+ fn toggle_read_task(&mut self, r: NRef) {
2753+ let Some((words, step)) = self.done_words(r) else {
2754+ self.say(self.not_a_task(r));
2755+ return;
2756+ };
2757+ self.push_undo(&step);
2758+ match ops::toggle_task(&mut self.vault, r) {
2759+ Ok(()) => self.say(words),
2760+ Err(e) => self.say(format!("error: {}", e)),
2761+ }
20392762 }
20402763
20412764 fn jump_heading(&mut self, doc: &fold_core::reading::ReadingDoc, dir: i32) {
@@ -2069,12 +2792,11 @@ impl App {
20692792 self.say(format!("{} match(es)", self.read_matches.len()));
20702793 }
20712794
2072− fn next_match(&mut self, doc: &fold_core::reading::ReadingDoc, dir: i32) {
2795+ fn next_match(&mut self, dir: i32) {
20732796 if self.read_matches.is_empty() {
20742797 self.say("no search (use /)");
20752798 return;
20762799 }
2077− let _ = doc;
20782800 let n = self.read_matches.len() as i32;
20792801 self.read_match_idx = ((self.read_match_idx as i32 + dir).rem_euclid(n)) as usize;
20802802 let m = self.read_matches[self.read_match_idx];
@@ -2217,6 +2939,7 @@ impl App {
22172939 x,
22182940 y,
22192941 sel: 0,
2942+ top: 0,
22202943 });
22212944 }
22222945
@@ -2226,29 +2949,43 @@ impl App {
22262949 self.find_exact(&self.ui.menu.as_ref()?.target)
22272950 }
22282951
2952+ /// The node menu's items for its node (§10.1): on either side of a
2953+ /// conflict pair, *Resolve conflict…* after the rest.
2954+ fn menu_items(&self) -> Vec<Option<Action>> {
2955+ let mut items = action::NODE_MENU.to_vec();
2956+ if self.menu_target().is_some_and(|r| self.copy_of_pair(r).is_some()) {
2957+ items.extend_from_slice(action::CONFLICT_MENU);
2958+ }
2959+ items
2960+ }
2961+
22292962 fn key_menu(&mut self, key: KeyEvent) {
2230− let Some(menu) = self.ui.menu.as_mut() else { return };
2231− let items: Vec<usize> = (0..action::NODE_MENU.len())
2232− .filter(|&i| action::NODE_MENU[i].is_some())
2233− .collect();
2234− let pos = items.iter().position(|&i| i == menu.sel).unwrap_or(0);
2963+ let Some(menu) = self.ui.menu.as_ref() else { return };
22352964 match key.code {
22362965 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('m') => self.ui.menu = None,
2237− KeyCode::Up | KeyCode::Char('k') => menu.sel = items[pos.saturating_sub(1)],
2238− KeyCode::Down | KeyCode::Char('j') => menu.sel = items[(pos + 1).min(items.len() - 1)],
2239− KeyCode::Enter => {
2240− let i = menu.sel;
2241− self.run_menu_item(i);
2242− }
2966+ KeyCode::Up | KeyCode::Char('k') => self.menu_step(-1),
2967+ KeyCode::Down | KeyCode::Char('j') => self.menu_step(1),
2968+ KeyCode::Enter => self.run_menu_item(menu.sel),
22432969 _ => {}
22442970 }
22452971 }
22462972
2973+ /// Move the node menu's highlight `by` items, over its separators: the
2974+ /// keys one, the wheel three (§10.1).
2975+ fn menu_step(&mut self, by: i32) {
2976+ let all = self.menu_items();
2977+ let Some(menu) = self.ui.menu.as_mut() else { return };
2978+ let items: Vec<usize> = (0..all.len()).filter(|&i| all[i].is_some()).collect();
2979+ let pos = items.iter().position(|&i| i == menu.sel).unwrap_or(0) as i64;
2980+ menu.sel = items[(pos + by as i64).clamp(0, items.len() as i64 - 1) as usize];
2981+ }
2982+
22472983 /// Run a node-menu item on the menu's target, found by its key; the
22482984 /// verb holds it by key across the editor's save (`run_action`).
22492985 fn run_menu_item(&mut self, i: usize) {
2986+ let items = self.menu_items();
22502987 let Some(menu) = self.ui.menu.take() else { return };
2251− let Some(Some(a)) = action::NODE_MENU.get(i) else { return };
2988+ let Some(Some(a)) = items.get(i) else { return };
22522989 let Some(target) = self.find_exact(&menu.target) else {
22532990 self.say("that node is gone");
22542991 return;
@@ -2269,7 +3006,39 @@ impl App {
22693006 // node is held by key across it
22703007 let target = self.action_target.map(|t| self.vault.key_of(t));
22713008 let edit = match a {
2272− Action::EditRevert => None,
3009+ // help, the popups and the view toggles write nothing and go
3010+ // back to the editor as it was: a block cut in it is still
3011+ // there to paste (§5.2)
3012+ Action::EditRevert
3013+ | Action::Help
3014+ | Action::Palette
3015+ | Action::Filter
3016+ | Action::Close
3017+ | Action::HideDone
3018+ | Action::ReadingPane
3019+ | Action::Wrap
3020+ | Action::RawMode
3021+ | Action::EditorKeys => None,
3022+ // nor do a copy, a zoom and the node menu, nor what opens a
3023+ // prompt or the property form, whose OK is the verb
3024+ // (`accept_prompt_saving_editor`): the editor saves as after a
3025+ // pause, the copy taking what was typed, and is not
3026+ // re-rendered, so a block cut in it stays in transit
3027+ Action::Copy
3028+ | Action::Zoom
3029+ | Action::ZoomOut
3030+ | Action::NodeMenu
3031+ | Action::GoTo
3032+ | Action::Refile
3033+ | Action::Props
3034+ | Action::PropAdd
3035+ | Action::Capture
3036+ | Action::CaptureTask => {
3037+ self.save_editor();
3038+ None
3039+ }
3040+ // the prompt's OK saves it as Enter does, its pick held by key
3041+ Action::PromptOk => None,
22733042 _ => self.editor_before_write("outline verb"),
22743043 };
22753044 self.action_target = target.as_ref().and_then(|k| self.find_exact(k));
@@ -2334,19 +3103,20 @@ impl App {
23343103 self.palette.clear();
23353104 self.palette_sel = 0;
23363105 }
2337− Action::Help => self.mode = Mode::Help,
3106+ Action::Help => {
3107+ self.mode = Mode::Help;
3108+ self.help_scroll = 0;
3109+ }
23383110 Action::Quit => self.quit = true,
3111+ Action::Undo if self.mode == Mode::Conflict => self.key_conflict(key_of('u')),
3112+ Action::Redo if self.mode == Mode::Conflict => self.key_conflict(key_of('U')),
23393113 Action::Undo => self.act_undo(),
23403114 Action::Redo => self.act_redo(),
23413115 Action::Capture => self.open_prompt("capture", PromptAction::CaptureText(false), String::new()),
23423116 Action::CaptureTask => {
23433117 self.open_prompt("capture task", PromptAction::CaptureText(true), String::new())
23443118 }
2345− Action::HideDone => {
2346− self.hide_done = !self.hide_done;
2347− self.clamp_cursor();
2348− self.say(if self.hide_done { "done hidden" } else { "done shown" });
2349− }
3119+ Action::HideDone => self.toggle_hide_done(),
23503120 Action::ReadingPane => {
23513121 self.show_reading = !self.show_reading;
23523122 if !self.show_reading && self.focus == Focus::Reading {
@@ -2365,7 +3135,8 @@ impl App {
23653135 Action::GoTo => self.open_prompt("go to", PromptAction::GoTo, String::new()),
23663136 Action::ClearDone => self.act_clear_done(),
23673137 Action::Canonicalize => {
2368− self.anchor_zoom();
3138+ // one op-log entry, as in the app (§10.10)
3139+ self.push_undo("canonicalize");
23693140 match fold_core::check::fix(&mut self.vault) {
23703141 Ok(n) => self.say(format!("{} file(s) canonicalized", n)),
23713142 Err(e) => self.say(format!("error: {}", e)),
@@ -2384,6 +3155,11 @@ impl App {
23843155 }
23853156 }
23863157 Action::ResolveConflicts => self.enter_conflict_view(),
3158+ Action::ResolveConflict => {
3159+ if let Some(r) = self.subject() {
3160+ self.enter_conflict_view_at(r);
3161+ }
3162+ }
23873163 Action::EditorKeys => self.set_edit_keys(self.edit_keys.next()),
23883164 Action::EditDone => {
23893165 self.close_editor();
@@ -2392,7 +3168,7 @@ impl App {
23923168 Action::Close => self.close_top(),
23933169 Action::PromptOk => {
23943170 if let Some(p) = self.prompt.take() {
2395− self.accept_prompt(p);
3171+ self.accept_prompt_saving_editor(p);
23963172 }
23973173 }
23983174 Action::PropAdd => self.open_prompt("new property", PromptAction::PropNew, String::new()),
@@ -2482,13 +3258,13 @@ impl App {
24823258
24833259}
24843260
2485−/// A status message, noting when the ordering rule placed the node other
2486−/// than asked (§3.1: items before sections).
2487−fn with_rule_note(what: &str, moved: bool) -> String {
2488− if moved {
2489− format!("{} — placed at the item/section boundary (§3.1)", what)
2490− } else {
2491− what.to_string()
3261+/// A status message, saying so when the ordering rule placed a node of
3262+/// `kind` other than asked (§3.1: items before sections).
3263+fn with_rule_note(what: &str, moved: bool, kind: Kind) -> String {
3264+ match (moved, kind) {
3265+ (false, _) => what.to_string(),
3266+ (true, Kind::Section) => format!("{} — placed after the items", what),
3267+ (true, _) => format!("{} — placed before the sections", what),
24923268 }
24933269}
24943270
@@ -2499,6 +3275,7 @@ pub fn help_text() -> Vec<Line<'static>> {
24993275 ("", ""),
25003276 ("MOUSE", ""),
25013277 ("click", "select · ▸/▾ fold · ☐ toggle · breadcrumb segments zoom out"),
3278+ ("", " · ⚠ on a conflict copy: both sides, to resolve"),
25023279 ("double-click", "a row zooms in · in the text: zoom a heading, follow a"),
25033280 ("", " block, or edit that very line"),
25043281 ("right-click", "or ⋯ on a row — every action on that node"),
@@ -2513,7 +3290,7 @@ pub fn help_text() -> Vec<Line<'static>> {
25133290 ("J/K > < ~", "move · indent · outdent · heading ↔ bullet"),
25143291 ("r y d p/P", "move to… · copy · delete · paste after / before"),
25153292 ("s za zd zr zw zp", "make block · archive · hide done · raw source · wrap lines · reading pane"),
2516− ("/ : ?", "filter · command palette · this help"),
3293+ ("/ : ? F1", "filter · command palette · this help (F1 in the editor too)"),
25173294 ("u/U q", "undo / redo · quit (everything is always saved)"),
25183295 ("Tab", "switch panes · in the text: [[ ]] headings, / search, o link"),
25193296 ("", ""),
@@ -2545,6 +3322,57 @@ pub fn help_text() -> Vec<Line<'static>> {
25453322 .collect()
25463323}
25473324
3325+/// What an editor save's op-log entry names (§10.10), *edit “ZFS
3326+/// layout”*: the block it writes when it writes one, else the node the
3327+/// editor is open on.
3328+fn edited(ed: &editor::Editor, owner: Option<&fold_core::edit::Owner>) -> String {
3329+ let one = owner.or(match ed.buf.dirty.as_slice() {
3330+ [o] => Some(o),
3331+ _ => None,
3332+ });
3333+ let info = one.and_then(|o| ed.buf.owners.get(o)).or_else(|| ed.buf.owners.values().find(|i| i.parent.is_none()));
3334+ quoted(info.map_or("", |i| i.title.as_str()))
3335+}
3336+
3337+/// A title as the status bar names it: quoted.
3338+fn quoted(title: &str) -> String {
3339+ format!("“{}”", if title.is_empty() { "(untitled)" } else { title })
3340+}
3341+
3342+/// The status bar's count of unresolved pairs (§10.1), as its ⚠ reads.
3343+fn conflict_count(n: usize) -> String {
3344+ format!("⚠ {} conflict{}", n, if n == 1 { "" } else { "s" })
3345+}
3346+
3347+/// An error or a refusal, which the status bar keeps until the next key
3348+/// after it was read (§10.1).
3349+fn lasting(msg: &str) -> bool {
3350+ msg.starts_with("error:") || msg.starts_with("can't ") || [" error:", " refused:", " failed:"].iter().any(|w| msg.contains(w))
3351+}
3352+
3353+/// A key as the status bar names it: *q*, *Enter*, *Ctrl-Z*.
3354+fn key_name(key: KeyEvent) -> String {
3355+ let name = match key.code {
3356+ KeyCode::Char(' ') => "Space".into(),
3357+ KeyCode::Char(c) if key.modifiers.contains(KeyModifiers::CONTROL) => c.to_uppercase().to_string(),
3358+ KeyCode::Char(c) => c.to_string(),
3359+ KeyCode::F(n) => format!("F{}", n),
3360+ KeyCode::Up => "↑".into(),
3361+ KeyCode::Down => "↓".into(),
3362+ KeyCode::Left => "←".into(),
3363+ KeyCode::Right => "→".into(),
3364+ KeyCode::BackTab => "Shift-Tab".into(),
3365+ KeyCode::PageUp => "PgUp".into(),
3366+ KeyCode::PageDown => "PgDn".into(),
3367+ code => format!("{:?}", code),
3368+ };
3369+ if key.modifiers.contains(KeyModifiers::CONTROL) {
3370+ format!("Ctrl-{}", name)
3371+ } else {
3372+ name
3373+ }
3374+}
3375+
25483376fn key_of(c: char) -> KeyEvent {
25493377 KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
25503378}
@@ -2589,7 +3417,9 @@ fn extract_url(line: &str) -> Option<String> {
25893417// ------------------------------------------------------------ main loop
25903418
25913419/// Run the TUI on a vault. `keys` picks the editor's keymap (`normal`,
2592−/// `vim`, `helix`), overriding `$FOLD_KEYS`.
3420+/// `vim`, `helix`), overriding `$FOLD_KEYS`. However the session ends — a
3421+/// quit, a signal, an error, a panic — the terminal is put back, and the
3422+/// editor saved or its text kept in the trash (§10.6).
25933423pub fn run(dir: &Path, keys: Option<&str>) -> anyhow::Result<()> {
25943424 let mut app = App::new(dir)?;
25953425 if let Some(k) = keys {
@@ -2606,24 +3436,101 @@ pub fn run(dir: &Path, keys: Option<&str>) -> anyhow::Result<()> {
26063436 app.apply_view(v, keys_chosen);
26073437 }
26083438 app.start_watcher();
2609− // a sync-conflict file present at startup starts the merge flow (§12.2)
2610− if app.vault.conflict_files().is_ok_and(|files| !files.is_empty()) {
2611− app.merge_conflict_files("merged on startup");
2612− }
2613− enable_raw_mode()?;
2614− std::io::stdout().execute(EnterAlternateScreen)?;
2615− std::io::stdout().execute(EnableMouseCapture)?;
2616− std::io::stdout().execute(EnableBracketedPaste)?;
2617− let backend = CrosstermBackend::new(std::io::stdout());
2618− let mut terminal = Terminal::new(backend)?;
2619− let res = run_loop(&mut terminal, &mut app);
3439+ app.merge_on_startup();
3440+ let stop = stop_signals()?;
3441+ let screen = Screen::enter()?;
3442+ // a panic is caught here once its message is out (`Screen`), so the
3443+ // editor is still saved or kept before it goes on
3444+ let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3445+ let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stdout()))?;
3446+ run_loop(&mut terminal, &mut app, &stop)
3447+ }));
3448+ // a quit closed the editor; any other end saves it, or keeps its text
3449+ let kept = app.keep_unsaved();
26203450 let _ = view::save(dir, &app.view());
2621− disable_raw_mode()?;
2622− std::io::stdout().execute(DisableBracketedPaste)?;
2623− std::io::stdout().execute(DisableMouseCapture)?;
2624− std::io::stdout().execute(SetCursorStyle::DefaultUserShape)?;
2625− std::io::stdout().execute(LeaveAlternateScreen)?;
2626− res
3451+ drop(screen);
3452+ if let Some(words) = kept {
3453+ use std::io::Write;
3454+ // the terminal may be gone (a closed window): nothing to say it on
3455+ let _ = writeln!(std::io::stderr(), "{}", words);
3456+ }
3457+ match res {
3458+ Ok(res) => res,
3459+ Err(panic) => std::panic::resume_unwind(panic),
3460+ }
3461+}
3462+
3463+/// SIGTERM, SIGHUP (a closed window, a dropped ssh session) and SIGINT
3464+/// set a flag the main loop checks, so fold ends as on a quit, the editor
3465+/// saved or its text kept (§10.6), instead of dying mid-sentence.
3466+fn stop_signals() -> std::io::Result<std::sync::Arc<std::sync::atomic::AtomicBool>> {
3467+ let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
3468+ // set by SIGTERM and SIGINT alone: a SIGHUP before one does not make
3469+ // it a second
3470+ #[cfg(unix)]
3471+ let asked = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
3472+ #[cfg(unix)]
3473+ for sig in [signal_hook::consts::SIGTERM, signal_hook::consts::SIGHUP, signal_hook::consts::SIGINT] {
3474+ // a second SIGTERM or SIGINT ends fold as the signal would (§10.6):
3475+ // the loop never sees the flag while a write waits on a terminal
3476+ // that reads nothing. not SIGHUP, which a closed window can send twice
3477+ if sig != signal_hook::consts::SIGHUP {
3478+ signal_hook::flag::register_conditional_default(sig, asked.clone())?;
3479+ signal_hook::flag::register(sig, asked.clone())?;
3480+ }
3481+ signal_hook::flag::register(sig, stop.clone())?;
3482+ }
3483+ Ok(stop)
3484+}
3485+
3486+thread_local! {
3487+ /// Whether this thread has the terminal as the TUI sets it up.
3488+ static ON_SCREEN: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
3489+}
3490+
3491+/// The terminal as the TUI sets it up — raw, the alternate screen, mouse
3492+/// capture, bracketed paste — put back as it was when this drops, however
3493+/// the session ends. A panic puts it back before its message is printed,
3494+/// so the message is not lost with the alternate screen.
3495+struct Screen;
3496+
3497+impl Screen {
3498+ fn enter() -> std::io::Result<Screen> {
3499+ let hook = std::panic::take_hook();
3500+ std::panic::set_hook(Box::new(move |info| {
3501+ // a panic on another thread leaves the screen be
3502+ leave_screen();
3503+ hook(info);
3504+ }));
3505+ enable_raw_mode()?;
3506+ ON_SCREEN.set(true);
3507+ let screen = Screen;
3508+ std::io::stdout().execute(EnterAlternateScreen)?;
3509+ std::io::stdout().execute(EnableMouseCapture)?;
3510+ std::io::stdout().execute(EnableBracketedPaste)?;
3511+ Ok(screen)
3512+ }
3513+}
3514+
3515+impl Drop for Screen {
3516+ fn drop(&mut self) {
3517+ leave_screen();
3518+ }
3519+}
3520+
3521+/// Put the terminal back, once, if this thread set it up. Errors are
3522+/// ignored: the terminal may be gone.
3523+fn leave_screen() {
3524+ if !ON_SCREEN.try_with(|s| s.replace(false)).unwrap_or(false) {
3525+ return;
3526+ }
3527+ let _ = disable_raw_mode();
3528+ let mut out = std::io::stdout();
3529+ let _ = out.execute(DisableBracketedPaste);
3530+ let _ = out.execute(DisableMouseCapture);
3531+ let _ = out.execute(SetCursorStyle::DefaultUserShape);
3532+ let _ = out.execute(crossterm::cursor::Show);
3533+ let _ = out.execute(LeaveAlternateScreen);
26273534}
26283535
26293536/// Standard base64, for OSC 52.
@@ -2643,9 +3550,15 @@ fn base64(bytes: &[u8]) -> String {
26433550fn run_loop(
26443551 terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
26453552 app: &mut App,
3553+ stop: &std::sync::atomic::AtomicBool,
26463554) -> anyhow::Result<()> {
3555+ let events = read_events();
26473556 let mut block = None;
26483557 loop {
3558+ // a signal ends the session (`stop_signals`)
3559+ if stop.load(std::sync::atomic::Ordering::Relaxed) {
3560+ return Ok(());
3561+ }
26493562 terminal.draw(|f| app.draw(f))?;
26503563 // the cursor's shape follows the editor's mode
26513564 let want = app.cursor_block();
@@ -2669,21 +3582,32 @@ fn run_loop(
26693582 }
26703583 app.quit = false;
26713584 }
2672− // autosave after 750 ms without a keystroke (§10.6)
2673− if app.mode == Mode::Edit && app.editor_dirty() && app.edit_last_key.elapsed() > Duration::from_millis(750) {
2674− app.save_editor("pause");
2675− }
2676− // external changes: debounced reload (§11.2)
2677− if app.poll_watcher() {
2678− app.reload_external();
2679− }
2680− if event::poll(Duration::from_millis(200))? {
2681− match event::read()? {
2682− Event::Mouse(m) => app.handle_mouse(m),
2683− Event::Key(key) => app.handle_key(key),
2684− Event::Paste(text) => app.handle_paste(&text),
2685− _ => {}
2686− }
3585+ app.tick();
3586+ let event = match events.recv_timeout(Duration::from_millis(200)) {
3587+ Ok(event) => event?,
3588+ Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
3589+ Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => anyhow::bail!("no more input from the terminal"),
3590+ };
3591+ match event {
3592+ Event::Mouse(m) => app.handle_mouse(m),
3593+ Event::Key(key) => app.handle_key(key),
3594+ Event::Paste(text) => app.handle_paste(&text),
3595+ _ => {}
26873596 }
26883597 }
26893598}
3599+
3600+/// The terminal's events, read on a thread of their own: once the terminal
3601+/// is gone (a closed window), crossterm reads it without end, and the main
3602+/// loop must still come round to the signal that says so.
3603+fn read_events() -> std::sync::mpsc::Receiver<std::io::Result<Event>> {
3604+ let (tx, rx) = std::sync::mpsc::channel();
3605+ std::thread::spawn(move || loop {
3606+ let event = event::read();
3607+ let failed = event.is_err();
3608+ if tx.send(event).is_err() || failed {
3609+ return;
3610+ }
3611+ });
3612+ rx
3613+}
crates/fold-tui/src/app/action.rs+6
@@ -43,6 +43,7 @@ pub enum Action {
4343 Canonicalize,
4444 Merge,
4545 ResolveConflicts,
46+ ResolveConflict,
4647 // editor
4748 EditorKeys,
4849 EditDone,
@@ -102,6 +103,7 @@ impl Action {
102103 Canonicalize => "Canonicalize",
103104 Merge => "Merge sync conflicts",
104105 ResolveConflicts => "Resolve conflicts",
106+ ResolveConflict => "Resolve conflict…",
105107 EditorKeys => "Editor keys",
106108 EditDone => "Done",
107109 EditRevert => "Revert",
@@ -233,6 +235,7 @@ impl Action {
233235 Canonicalize => "rewrite the vault in canonical form",
234236 Merge => "fold sync-conflict files in",
235237 ResolveConflicts => "review conflict pairs",
238+ ResolveConflict => "the conflict view at this node's pair",
236239 EditorKeys => "switch the editor between normal, Vim and Helix keys",
237240 EditDone => "save and leave the editor",
238241 EditRevert => "drop changes since the last save",
@@ -276,6 +279,9 @@ pub const NODE_MENU: &[Option<Action>] = &[
276279 Some(Action::Delete),
277280];
278281
282+/// What the node menu adds on either side of a conflict pair (§12.5).
283+pub const CONFLICT_MENU: &[Option<Action>] = &[None, Some(Action::ResolveConflict)];
284+
279285/// Everything in the command palette, in order.
280286pub const PALETTE: &[Action] = &[
281287 Action::Edit,
crates/fold-tui/src/app/mouse.rs+87−36
@@ -7,18 +7,49 @@ use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
77use fold_core::ops::{self, Drop};
88use fold_core::parse::Kind;
99use fold_core::reading::LineRef;
10+use fold_core::tree::NRef;
1011use std::time::{Duration, Instant};
1112
1213impl App {
1314 pub fn handle_mouse(&mut self, m: MouseEvent) {
15+ // a pointer only passing over is no use of it (§10.7); nor is the
16+ // button let go after a click, or wobbled while down: that is the
17+ // click, not one since what it said (§10.1). A drag's moves and
18+ // release are.
19+ let dragging = self.ui.press.is_some_and(|p| p.dragging) || self.ui.resizing;
20+ let used = match m.kind {
21+ MouseEventKind::Moved => false,
22+ MouseEventKind::Drag(_) | MouseEventKind::Up(_) => dragging,
23+ _ => true,
24+ };
25+ if used {
26+ self.last_input = Some(Instant::now());
27+ }
28+ let held = match m.kind {
29+ MouseEventKind::Down(_) => self.held_words.take(),
30+ _ => None,
31+ };
32+ // a click lets a half-typed key go (§10.3): what it opens takes the
33+ // next key, which no `z` or `g` before it turns into a verb
34+ if matches!(m.kind, MouseEventKind::Down(_)) {
35+ self.pending = None;
36+ }
1437 self.handle_mouse_inner(m);
38+ self.drop_held_words(held);
1539 self.settle_undo();
1640 }
1741
1842 fn handle_mouse_inner(&mut self, m: MouseEvent) {
1943 let (x, y) = (m.column, m.row);
2044 match m.kind {
21− MouseEventKind::Moved => self.ui.hover = Some((x, y)),
45+ MouseEventKind::Moved => {
46+ self.ui.hover = Some((x, y));
47+ // the pointer moving onto a menu item highlights it; one
48+ // resting there leaves the keys and the wheel to move it
49+ if let (Some(Hit::MenuItem(i)), Some(m)) = (self.ui.hit_at(x, y), self.ui.menu.as_mut()) {
50+ m.sel = i;
51+ }
52+ }
2253 MouseEventKind::Down(MouseButton::Left) => {
2354 self.ui.hover = Some((x, y));
2455 self.mouse_down(x, y);
@@ -70,6 +101,7 @@ impl App {
70101 let Some(hit) = self.ui.hit_at(x, y) else { return };
71102 match hit {
72103 Hit::Backdrop => self.close_top(),
104+ Hit::Popup => {}
73105 Hit::Button(a, target) => {
74106 self.action_target = target;
75107 self.run_action(a);
@@ -116,6 +148,11 @@ impl App {
116148 self.open_menu(r, x.saturating_sub(24), y + 1);
117149 }
118150 }
151+ Hit::Conflict(i) => {
152+ self.focus_pane(Focus::Outline);
153+ self.cursor = i;
154+ self.run_action(super::Action::ResolveConflict);
155+ }
119156 Hit::DocLine(i) => {
120157 self.focus_pane(Focus::Reading);
121158 self.read_cursor = i;
@@ -127,6 +164,12 @@ impl App {
127164 self.read_cursor = i;
128165 self.toggle_doc_line(i);
129166 }
167+ Hit::DocConflict(i) => {
168+ self.read_cursor = i;
169+ if let Some(r) = self.doc_line_node(i) {
170+ self.enter_conflict_view_at(r);
171+ }
172+ }
130173 Hit::Link(i) => {
131174 self.read_cursor = i;
132175 let doc = self.reading_doc();
@@ -217,7 +260,7 @@ impl App {
217260 // only over an outline row (§10.1): released anywhere else, over the
218261 // reading pane or a border, the drag does nothing
219262 let target = match self.ui.hit_at(x, y) {
220− Some(Hit::Row(j) | Hit::Fold(j) | Hit::Check(j) | Hit::RowMenu(j)) => self
263+ Some(Hit::Row(j) | Hit::Fold(j) | Hit::Check(j) | Hit::RowMenu(j) | Hit::Conflict(j)) => self
221264 .ui
222265 .rows_geom
223266 .iter()
@@ -225,9 +268,16 @@ impl App {
225268 .map(|&(j, _, title_x)| (j, if x < title_x { Drop::Before } else { Drop::Into })),
226269 _ => None,
227270 };
228− self.ui.drop = target.filter(|(j, _)| *j != from);
271+ let target = target.filter(|(j, _)| *j != from);
272+ // nor into a conflict copy, which keeping ours trashes (§12.5): the
273+ // copy is no target, and the bar says why
274+ let into_copy = target.is_some_and(|(j, how)| {
275+ rows.get(from).zip(rows.get(j)).is_some_and(|(f, t)| self.drop_into_copy(f.nref, t.nref, how))
276+ });
277+ self.ui.drop = target.filter(|_| !into_copy);
229278 let name = |i: usize| rows.get(i).map(|r| self.vault.tree.node(r.nref).title.clone()).unwrap_or_default();
230279 let msg = match self.ui.drop {
280+ _ if into_copy => format!("can't move “{}” into a conflict copy", name(from)),
231281 Some((j, Drop::Before)) => format!("move “{}” before “{}”", name(from), name(j)),
232282 Some((j, Drop::Into)) => format!("move “{}” into “{}”", name(from), name(j)),
233283 None => format!("moving “{}” — drop on a title to nest, left of it to place before", name(from)),
@@ -250,6 +300,11 @@ impl App {
250300 }
251301 let rows = self.rows();
252302 let (Some(from), Some(to)) = (press.row.and_then(|i| rows.get(i)), rows.get(j)) else { return };
303+ // the rows may have changed since the drag last said where it goes
304+ if self.drop_into_copy(from.nref, to.nref, how) {
305+ self.say(format!("can't move {} into a conflict copy", self.named(from.nref)));
306+ return;
307+ }
253308 // a drop is an outline verb: the editor saves first (§10.6), which
254309 // re-parses what it wrote, so both nodes are found again by key
255310 let keys = (self.vault.key_of(from.nref), self.vault.key_of(to.nref));
@@ -259,50 +314,45 @@ impl App {
259314 return;
260315 };
261316 let (rr, rt) = (self.vault.tree.resolved_child(r), self.vault.tree.resolved_child(target));
262− let (key, kind) = (self.vault.key_of(rr), self.vault.tree.node(rr).kind);
263− let title = self.vault.tree.node(rr).title.clone();
264− // it goes into the target, last, or just before it, under the
265− // target's parent
266− let (dest, rank) = match how {
267− Drop::Into => (rt, None),
268− Drop::Before => {
269− let p = self.outline_parent(rt).unwrap_or(self.vault.tree.root);
270− let at = self.vault.tree.resolved_children(p).iter().position(|&c| c == rt);
271− (p, at.map(|at| self.namesakes_before(rr, p, at)))
272− }
273− };
274− let dest = self.vault.key_of(dest);
317+ let kind = self.vault.tree.node(rr).kind;
318+ let name = self.named(rr);
275319 let on = self.on_node(r);
276− self.push_undo("move");
320+ self.push_undo(&format!("move {} {} {}", name, if how == Drop::Into { "into" } else { "before" }, self.named(rt)));
277321 match ops::move_node(&mut self.vault, r, target, how) {
278− Ok(moved) => {
279− self.say(super::with_rule_note(&format!("moved “{}”", title), moved));
280− // find it where it landed, by its kind and place too: a
281− // namesake of it may be there already
282− match self.moved_node(&key, kind, &dest, rank) {
283− Some(n) => {
284− // the editor open on it follows it (§10.6)
285− self.follow(on, n);
286− self.reveal(n);
287− }
288− None => self.clamp_cursor(),
289− }
322+ Ok(p) => {
323+ self.say(super::with_rule_note(&format!("moved {}", name), p.clamped, kind));
324+ // the editor open on it follows it (§10.6)
325+ self.follow(on, p.node);
326+ self.reveal(p.node);
290327 }
291328 Err(e) => self.say(format!("can't move: {}", e)),
292329 }
293330 self.editor_after_write(edit);
294331 }
295332
333+ /// Whether dropping `r` on `target` puts it into a conflict copy it is
334+ /// not in already (§12.5): onto the copy's title, or onto or left of a
335+ /// row in it. Left of the copy's own row, it goes after the copy.
336+ fn drop_into_copy(&self, r: NRef, target: NRef, how: Drop) -> bool {
337+ let tree = &self.vault.tree;
338+ let own = self.chain(tree.resolved_child(r));
339+ let mut dest = self.chain(tree.resolved_child(target));
340+ if how == Drop::Before {
341+ dest.pop();
342+ }
343+ dest.iter().any(|c| tree.node(*c).conflict().is_some() && !own.contains(c))
344+ }
345+
296346 fn mouse_right(&mut self, x: u16, y: u16) {
297347 match self.ui.hit_at(x, y) {
298− Some(Hit::Row(i) | Hit::Fold(i) | Hit::Check(i) | Hit::RowMenu(i)) => {
348+ Some(Hit::Row(i) | Hit::Fold(i) | Hit::Check(i) | Hit::RowMenu(i) | Hit::Conflict(i)) => {
299349 self.focus_pane(Focus::Outline);
300350 self.cursor = i;
301351 if let Some(r) = self.current() {
302352 self.open_menu(r, x, y + 1);
303353 }
304354 }
305− Some(Hit::DocLine(i) | Hit::DocCheck(i) | Hit::Link(i)) => {
355+ Some(Hit::DocLine(i) | Hit::DocCheck(i) | Hit::DocConflict(i) | Hit::Link(i)) => {
306356 self.read_cursor = i;
307357 if let Some(r) = self.doc_line_node(i).or_else(|| self.reading_target()) {
308358 self.open_menu(r, x, y + 1);
@@ -321,6 +371,7 @@ impl App {
321371 fn mouse_wheel(&mut self, x: u16, y: u16, delta: i32) {
322372 let step = |v: usize| (v as i64 + delta as i64).max(0) as usize;
323373 if self.ui.menu.is_some() {
374+ self.menu_step(delta);
324375 return;
325376 }
326377 if self.prompt.is_some() {
@@ -331,16 +382,17 @@ impl App {
331382 }
332383 match self.mode {
333384 Mode::Picker => self.palette_sel = step(self.palette_sel),
385+ Mode::Help => self.help_scroll = step(self.help_scroll),
334386 Mode::Filter => {
335387 self.filter_sel = step(self.filter_sel).min(self.filter_rows.len().saturating_sub(1))
336388 }
337389 _ => match self.ui.hit_at(x, y) {
338390 Some(Hit::EditArea) => self.ui.edit_scroll = step(self.ui.edit_scroll),
339− Some(Hit::ReadingPane | Hit::DocLine(_) | Hit::DocCheck(_) | Hit::Link(_)) => {
391+ Some(Hit::ReadingPane | Hit::DocLine(_) | Hit::DocCheck(_) | Hit::DocConflict(_) | Hit::Link(_)) => {
340392 self.scroll_reading = step(self.scroll_reading)
341393 }
342394 Some(
343− Hit::OutlinePane | Hit::Row(_) | Hit::Fold(_) | Hit::Check(_) | Hit::RowMenu(_),
395+ Hit::OutlinePane | Hit::Row(_) | Hit::Fold(_) | Hit::Check(_) | Hit::RowMenu(_) | Hit::Conflict(_),
344396 ) => self.outline_scroll = step(self.outline_scroll),
345397 _ => {}
346398 },
@@ -348,7 +400,7 @@ impl App {
348400 }
349401
350402 /// The node a reading-pane line belongs to.
351− fn doc_line_node(&self, i: usize) -> Option<fold_core::tree::NRef> {
403+ fn doc_line_node(&self, i: usize) -> Option<NRef> {
352404 let doc = self.reading_doc();
353405 match fold_core::reading::node_at(&doc, i) {
354406 Some(LineRef::Title(r)) | Some(LineRef::Body(r)) | Some(LineRef::Embed(r)) => Some(r),
@@ -359,8 +411,7 @@ impl App {
359411 fn toggle_doc_line(&mut self, i: usize) {
360412 if let Some(r) = self.doc_line_node(i) {
361413 if self.vault.tree.node(self.vault.tree.resolved_child(r)).task.is_some() {
362− self.push_undo("toggle");
363− let _ = ops::toggle_task(&mut self.vault, r);
414+ self.toggle_read_task(r);
364415 }
365416 }
366417 }
crates/fold-tui/src/app/ui.rs+264−42
@@ -1,7 +1,7 @@
11//! Drawing (§10.1). Everything clickable registers a hit region while it
22//! is drawn, so what the pointer can do is exactly what is on screen.
33
4−use super::action::{Action, NODE_MENU};
4+use super::action::Action;
55use super::markdown::{fences, style_line, Code};
66use super::{App, Focus, Mode, PromptAction};
77use fold_core::ops::Drop;
@@ -48,6 +48,9 @@ pub mod theme {
4848pub enum Hit {
4949 /// Everything outside an open popup: a click closes it.
5050 Backdrop,
51+ /// Inside a popup, off its list and buttons: it covers what is behind,
52+ /// so a click or right-click there does nothing (§10.1).
53+ Popup,
5154 /// A button; with a node, the action applies to that node.
5255 Button(Action, Option<NRef>),
5356 /// A breadcrumb segment; `None` is the vault root.
@@ -60,8 +63,12 @@ pub enum Hit {
6063 Fold(usize),
6164 Check(usize),
6265 RowMenu(usize),
66+ /// A conflict copy's ⚠ on its row (§10.7).
67+ Conflict(usize),
6368 DocLine(usize),
6469 DocCheck(usize),
70+ /// A conflict copy's ⚠ on its title line.
71+ DocConflict(usize),
6572 Link(usize),
6673 EditArea,
6774 MenuItem(usize),
@@ -72,15 +79,17 @@ pub enum Hit {
7279 PropDelete(usize),
7380}
7481
75−/// The node menu: which node, where, and the highlighted item. The node is
76−/// kept by its key (§3.4), since files can change under an open menu — a
77−/// reload, an editor save — and renumber every node (§11.2).
82+/// The node menu: which node, where, the highlighted item and the first
83+/// row shown on a screen too short for it. The node is kept by its key
84+/// (§3.4), since files can change under an open menu — a reload, an
85+/// editor save — and renumber every node (§11.2).
7886#[derive(Debug, Clone)]
7987pub struct Menu {
8088 pub target: NodeKey,
8189 pub x: u16,
8290 pub y: u16,
8391 pub sel: usize,
92+ pub top: usize,
8493}
8594
8695/// The left button is down: where it went down, on which outline row, and
@@ -118,6 +127,9 @@ pub struct Ui {
118127 pub outline_view: usize,
119128 pub reading_view: usize,
120129 pub reading_len: usize,
130+ /// The doc line each screen row of the reading pane showed, `None` for
131+ /// the property header and raw source.
132+ pub read_rows: Vec<Option<usize>>,
121133 /// Highlighted code blocks by (info string, code).
122134 pub highlight_cache: std::collections::HashMap<(String, String), Vec<Vec<Span<'static>>>>,
123135}
@@ -164,6 +176,13 @@ fn follow(scroll: &mut usize, cursor: usize, moved: bool, view: usize, len: usiz
164176 *scroll = (*scroll).min(len.saturating_sub(view.max(1)));
165177}
166178
179+/// Whether line `at` of `len` is in a view of `view` lines scrolled to
180+/// `scroll`, clamped as `follow` clamps it.
181+pub(super) fn in_view(scroll: usize, at: usize, view: usize, len: usize) -> bool {
182+ let top = scroll.min(len.saturating_sub(view.max(1)));
183+ at >= top && at < top + view
184+}
185+
167186fn put(buf: &mut Buffer, x: u16, y: u16, s: &str, max: u16, style: Style) -> u16 {
168187 if max == 0 {
169188 return 0;
@@ -191,6 +210,85 @@ fn fit(s: &str, w: usize) -> String {
191210 out
192211}
193212
213+/// Cut a message to `w` columns: the nodes it names, quoted, give way
214+/// first, the longest down to the next, and all down to `…`, so the
215+/// words around them stay whole where that is enough; then its end.
216+/// Where the words before a name leave no room for it, it and what
217+/// follows go with the end.
218+fn fit_named(s: &str, w: usize) -> String {
219+ let over = s.width().saturating_sub(w);
220+ if over == 0 {
221+ return s.to_string();
222+ }
223+ let mut names = Vec::new();
224+ let mut from = 0;
225+ while let Some(a) = s[from..].find('“').map(|i| from + i + '“'.len_utf8()) {
226+ let Some(n) = s[a..].find('”') else { break };
227+ names.push((a, a + n));
228+ from = a + n;
229+ }
230+ // the most columns a name keeps: the fewest names cut, by the least
231+ let widths: Vec<usize> = names.iter().map(|&(a, b)| s[a..b].width()).collect();
232+ let cut = |keep: usize| widths.iter().map(|n| n.saturating_sub(keep)).sum::<usize>();
233+ let top = widths.iter().copied().max().unwrap_or(0);
234+ let keep = (1..top).rev().find(|&k| cut(k) >= over).unwrap_or(1);
235+ let mut out = String::new();
236+ let mut last = 0;
237+ for &(a, b) in &names {
238+ if out.width() + s[last..a].width() + 3 > w {
239+ break;
240+ }
241+ out.push_str(&s[last..a]);
242+ out.push_str(&fit(&s[a..b], keep));
243+ last = b;
244+ }
245+ out.push_str(&s[last..]);
246+ fit(&out, w)
247+}
248+
249+/// The editor's border title in `w` columns: *Editing*, the block's
250+/// title, *⚠ conflict copy from PHONE* in a copy from `device`, then
251+/// `tail`, the dot and the mode, which stay whole. Where it is short,
252+/// the block's title gives way first, to its first few letters, then
253+/// the words after ⚠, then *Editing*, and last the title, down to `…`.
254+fn editor_title(name: &str, device: Option<&str>, tail: Vec<Span<'static>>, w: usize) -> Line<'static> {
255+ let warns = match device {
256+ Some(d) => vec![format!(" ⚠ conflict copy from {}", d), format!(" ⚠ copy from {}", d), " ⚠".to_string()],
257+ None => vec![String::new()],
258+ };
259+ let rest: usize = tail.iter().map(|s| s.width()).sum();
260+ let few = name.width().min(8);
261+ let bare = warns[warns.len() - 1].as_str();
262+ let (lead, warn) = warns
263+ .iter()
264+ .map(|warn| (" Editing ", warn.as_str(), few))
265+ .chain([(" ", bare, 1)])
266+ .find(|(lead, warn, least)| lead.width() + warn.width() + rest + least <= w)
267+ .map_or((" ", bare), |(lead, warn, _)| (lead, warn));
268+ let mut spans = vec![
269+ Span::styled(lead.to_string(), Style::default().fg(theme::ACCENT)),
270+ Span::styled(fit(name, w.saturating_sub(lead.width() + warn.width() + rest)), Style::default().add_modifier(Modifier::BOLD)),
271+ ];
272+ if !warn.is_empty() {
273+ spans.push(Span::styled(warn.to_string(), Style::default().fg(theme::WARN)));
274+ }
275+ spans.extend(tail);
276+ Line::from(spans)
277+}
278+
279+/// Shorten a hint of `·`-separated parts to `w` columns by whole parts:
280+/// the first stays, and the last where it is the way on (as *? help* or
281+/// *Esc close*); those before it go first. What can follow a key has no
282+/// way on: its keys go from the end.
283+fn fit_parts(s: &str, w: usize, way_on: bool) -> String {
284+ let mut parts: Vec<&str> = s.split(" · ").collect();
285+ let keep = if way_on { 2 } else { 1 };
286+ while parts.join(" · ").width() > w && parts.len() > keep {
287+ parts.remove(parts.len() - keep);
288+ }
289+ parts.join(" · ")
290+}
291+
194292/// A title as drawn: the terminal drops a tab, so it shows as the spaces
195293/// to its next stop, counted from the title's start (raw text is never
196294/// hidden, §10.9).
@@ -198,6 +296,11 @@ fn title_text(title: &str) -> String {
198296 super::wrap::shown(title, 0, usize::MAX)
199297}
200298
299+/// A conflict copy's device alone, for *conflict copy from PHONE*.
300+fn copy_device(conflict: &str) -> &str {
301+ conflict.rsplit_once(' ').map_or(conflict, |(d, _)| d)
302+}
303+
201304/// One screen row of the reading pane.
202305struct Drawn {
203306 doc: Option<usize>,
@@ -205,6 +308,8 @@ struct Drawn {
205308 code: bool,
206309 check: Option<usize>,
207310 link: Option<(usize, usize)>,
311+ /// A conflict copy's ⚠ on its title line.
312+ conflict: Option<usize>,
208313}
209314
210315/// Break a styled line into screen rows at `cols` columns (§10.1): each row
@@ -499,22 +604,51 @@ impl App {
499604 let conflicts = fold_core::merge::conflict_pairs(&self.vault).len();
500605 let mut right: Vec<(String, Style, Option<Action>)> = Vec::new();
501606 if conflicts > 0 {
502− right.push((format!(" ⚠ {} conflict{} ", conflicts, if conflicts == 1 { "" } else { "s" }), Style::default().fg(theme::WARN).bg(theme::BAR), Some(Action::ResolveConflicts)));
607+ // lit while pairs that came in as the user worked wait (§10.7)
608+ let style = if self.conflicts_waiting.is_empty() {
609+ Style::default().fg(theme::WARN).bg(theme::BAR)
610+ } else {
611+ Style::default().fg(ratatui::style::Color::Black).bg(theme::WARN).add_modifier(Modifier::BOLD)
612+ };
613+ right.push((format!(" {} ", super::conflict_count(conflicts)), style, Some(Action::ResolveConflicts)));
503614 }
504615 if self.hide_done {
505616 right.push((" done hidden ".into(), Style::default().fg(theme::DIM).bg(theme::BAR), Some(Action::HideDone)));
506617 }
618+ let file_at = right.len();
507619 right.push((format!(" {} ", file), Style::default().fg(theme::DIM).bg(theme::BAR), None));
508620 right.push((
509621 if unsaved { " ● unsaved ".into() } else { " ✓ saved ".into() },
510622 Style::default().fg(if unsaved { theme::WARN } else { theme::DIM }).bg(theme::BAR),
511623 None,
512624 ));
625+ let (msg, hint) = self.status_left();
626+ let room = |right: &[(String, Style, Option<Action>)]| {
627+ let rw: u16 = right.iter().map(|(t, _, _)| t.width() as u16).sum();
628+ (area.x + area.width).saturating_sub(rw).saturating_sub(x + 1) as usize
629+ };
630+ // a message the bar is short for takes room from the right before
631+ // it is cut (§10.1): the ⚠ count comes down to its number, then the
632+ // file goes, for as long as the message shows; a hint is cut by its
633+ // parts instead
634+ let plain = !hint && msg != super::HINT;
635+ if plain && conflicts > 0 && msg.width() > room(&right) {
636+ right[0].0 = format!(" ⚠ {} ", conflicts);
637+ }
638+ if plain && msg.width() > room(&right) {
639+ right.remove(file_at);
640+ }
513641 let rw: u16 = right.iter().map(|(t, _, _)| t.width() as u16).sum();
514642 let mut rx = (area.x + area.width).saturating_sub(rw);
515643 let msg_room = rx.saturating_sub(x + 1);
516− let msg = if self.status == super::HINT && super::HINT.width() > msg_room as usize { super::HINT_SHORT } else { self.status.as_str() };
517− put(buf, x, area.y, &fit(msg, msg_room as usize), msg_room, Style::default().bg(theme::BAR));
644+ let msg = if hint {
645+ fit_parts(&msg, msg_room as usize, self.pending.is_none())
646+ } else if msg == super::HINT && super::HINT.width() > msg_room as usize {
647+ super::HINT_SHORT.to_string()
648+ } else {
649+ fit_named(&msg, msg_room as usize)
650+ };
651+ put(buf, x, area.y, &fit(&msg, msg_room as usize), msg_room, Style::default().bg(theme::BAR));
518652 for (t, style, a) in right {
519653 let w = t.width() as u16;
520654 let r = Rect { x: rx, y: area.y, width: w, height: 1 };
@@ -625,9 +759,21 @@ impl App {
625759 style = style.add_modifier(Modifier::DIM);
626760 }
627761 let title = if n.title.is_empty() { "(untitled)".to_string() } else { title_text(&n.title) };
628− let marker = if n.is_block() || n.is_embed() { " ▤" } else { "" };
629− let shown = fit(&format!("{}{}", title, marker), title_room as usize);
630− put(buf, title_x, y, &shown, title_room, style);
762+ let copy = n.conflict();
763+ let shown = match copy {
764+ // a conflict copy's ⚠ stands where ▤ would, and is never cut
765+ Some(_) => format!("{} ⚠", fit(&title, (title_room as usize).saturating_sub(2))),
766+ None => {
767+ let marker = if n.is_block() || n.is_embed() { " ▤" } else { "" };
768+ fit(&format!("{}{}", title, marker), title_room as usize)
769+ }
770+ };
771+ let end = title_x + put(buf, title_x, y, &shown, title_room, style);
772+ if copy.is_some() && shown.width() as u16 <= title_room {
773+ // a click away from its pair (§10.7)
774+ put(buf, end - 1, y, "⚠", 1, base.fg(theme::WARN).remove_modifier(Modifier::CROSSED_OUT));
775+ self.ui.push(Rect { x: end - 1, y, width: 1, height: 1 }, Hit::Conflict(vi));
776+ }
631777 if inline {
632778 meta_x = title_x + shown.width() as u16 + 2;
633779 }
@@ -636,10 +782,14 @@ impl App {
636782 }
637783 if inline {
638784 let px = if meta_w > 0 { meta_x + meta_w + 2 } else { meta_x };
639− let preview = self.preview(row.nref);
785+ // where a copy's text would go, whose copy it is
786+ let (preview, look) = match copy {
787+ Some(c) => (format!("other device · {}", fold_core::merge::copy_from(c)), base.fg(theme::DIM)),
788+ None => (self.preview(row.nref), base.fg(theme::DIM).add_modifier(Modifier::ITALIC)),
789+ };
640790 if !preview.is_empty() && px + 4 < right {
641791 let room = right - px - 1;
642− put(buf, px, y, &fit(&preview, room as usize), room, base.fg(theme::DIM).add_modifier(Modifier::ITALIC));
792+ put(buf, px, y, &fit(&preview, room as usize), room, look);
643793 }
644794 }
645795 if handle {
@@ -697,7 +847,9 @@ impl App {
697847 let n = self.vault.tree.node(r);
698848 let t = if n.kind == Kind::Root { "fold".into() } else { title_text(&n.title) };
699849 spans.push(Span::styled(t, Style::default().add_modifier(Modifier::BOLD)));
700− if n.is_block() {
850+ if n.conflict().is_some() {
851+ spans.push(Span::styled(" ⚠", Style::default().fg(theme::WARN)));
852+ } else if n.is_block() {
701853 spans.push(Span::styled(" ▤", Style::default().fg(theme::DIM)));
702854 }
703855 }
@@ -726,6 +878,8 @@ impl App {
726878 // display lines: (doc line or None for the property header, text)
727879 let mut shown: Vec<(Option<usize>, String)> = Vec::new();
728880 let mut header_at: Option<usize> = None;
881+ // the doc lines that are conflict copies' titles, and whose each is
882+ let mut copies: Vec<(usize, String)> = Vec::new();
729883 self.read_header = false;
730884 if self.raw_mode {
731885 for l in render(&self.vault.tree, r, 1, false).lines() {
@@ -735,6 +889,11 @@ impl App {
735889 let doc = fold_core::reading::build(&self.vault, r);
736890 for (i, l) in doc.lines.iter().enumerate() {
737891 shown.push((Some(i), l.clone()));
892+ if let fold_core::reading::LineRef::Title(t) = doc.refs[i] {
893+ if let Some(c) = self.vault.tree.node(t).conflict() {
894+ copies.push((i, fold_core::merge::copy_from(c)));
895+ }
896+ }
738897 }
739898 if let Some(b) = &self.vault.tree.node(r).block {
740899 let props: Vec<String> = b.props.iter().filter(|(k, _)| k.as_str() != "id").map(|(k, v)| format!("{} {}", k, v)).collect();
@@ -754,7 +913,7 @@ impl App {
754913 let cols = if self.wrap { inner.width as usize } else { usize::MAX / 2 };
755914 let mut rows: Vec<Drawn> = Vec::new();
756915 for (si, (doc_line, text)) in shown.iter().enumerate() {
757− let styled = if header_at == Some(si) {
916+ let mut styled = if header_at == Some(si) {
758917 super::markdown::Styled {
759918 line: Line::from(Span::styled(text.clone(), Style::default().fg(theme::DIM).add_modifier(Modifier::ITALIC))),
760919 check: None,
@@ -766,10 +925,18 @@ impl App {
766925 None => style_line(text, fenced[si]),
767926 }
768927 };
928+ // a copy's title line is marked as its row is (§10.1)
929+ let mut warn = None;
930+ if let Some((_, from)) = copies.iter().find(|(i, _)| Some(*i) == *doc_line) {
931+ warn = Some(styled.line.spans.iter().map(|s| s.content.chars().count()).sum::<usize>() + 1);
932+ styled.line.spans.push(Span::raw(" "));
933+ styled.line.spans.push(Span::styled("⚠", Style::default().fg(theme::WARN)));
934+ styled.line.spans.push(Span::styled(format!(" other device · {}", from), Style::default().fg(theme::DIM)));
935+ }
769936 let parts = wrap_styled(&styled.line, cols, code[si].is_some());
770937 // where each character is drawn: the clickable parts are
771938 // character columns, and wide characters and tabs take more
772− let xs = if styled.check.is_some() || styled.link.is_some() {
939+ let xs = if styled.check.is_some() || styled.link.is_some() || warn.is_some() {
773940 super::wrap::columns(&styled.line.spans.iter().map(|s| s.content.as_ref()).collect::<String>())
774941 } else {
775942 Vec::new()
@@ -785,10 +952,12 @@ impl App {
785952 code: code[si].is_some(),
786953 check: styled.check.filter(|c| within(*c)).map(at),
787954 link: styled.link.filter(|l| on(*l)).map(|(a, b)| (at(a), at(b))),
955+ conflict: warn.filter(|c| within(*c)).map(at),
788956 });
789957 }
790958 }
791959 self.ui.reading_len = rows.len();
960+ self.ui.read_rows = rows.iter().map(|d| d.doc).collect();
792961 let cursor_row = rows.iter().position(|d| d.doc == Some(self.read_cursor)).unwrap_or(0);
793962 let moved = self.ui.last_read_cursor != Some(self.read_cursor);
794963 self.ui.last_read_cursor = Some(self.read_cursor);
@@ -822,6 +991,9 @@ impl App {
822991 if let Some(c) = d.check.filter(|c| *c < w) {
823992 self.ui.push(Rect { x: inner.x + c as u16, y, width: 1, height: 1 }, Hit::DocCheck(di));
824993 }
994+ if let Some(c) = d.conflict.filter(|c| *c < w) {
995+ self.ui.push(Rect { x: inner.x + c as u16, y, width: 1, height: 1 }, Hit::DocConflict(di));
996+ }
825997 if let Some((a, b)) = d.link.filter(|(a, _)| *a < w) {
826998 self.ui.push(Rect { x: inner.x + a as u16, y, width: b.min(w).saturating_sub(a).max(1) as u16, height: 1 }, Hit::Link(di));
827999 }
@@ -900,11 +1072,24 @@ impl App {
9001072 let Some(ed) = &self.editor else { return };
9011073 let owner = ed.buf.owner_at(ed.cursor.line);
9021074 let owner_title = ed.buf.owners.get(&owner).map(|o| title_text(&o.title)).unwrap_or_default();
903− let mut title = vec![
904− Span::styled(" Editing ", Style::default().fg(theme::ACCENT)),
905− Span::styled(owner_title, Style::default().add_modifier(Modifier::BOLD)),
906− if ed.buf.dirty.is_empty() { Span::raw(" ") } else { Span::styled(" ● ", Style::default().fg(theme::WARN)) },
907− ];
1075+ // the cursor in a conflict copy, or a block within one (§10.6)
1076+ let mut device = None;
1077+ let mut at = Some(owner);
1078+ while let Some(info) = at.and_then(|o| ed.buf.owners.get(&o)) {
1079+ let node = info.id.as_ref().and_then(|id| self.vault.tree.block_by_id(id)).map(|r| self.vault.tree.node(r));
1080+ if let Some(c) = node.and_then(|n| n.conflict()) {
1081+ device = Some(copy_device(c).to_string());
1082+ break;
1083+ }
1084+ at = info.parent;
1085+ }
1086+ // or the node the editor is open on in one: a node of a copy's
1087+ // file, or a block within it
1088+ if device.is_none() {
1089+ let up = self.editor_node().map(|r| self.chain(r)).unwrap_or_default();
1090+ device = up.iter().rev().find_map(|&c| self.vault.tree.node(c).conflict()).map(|c| copy_device(c).to_string());
1091+ }
1092+ let mut tail = vec![if ed.buf.dirty.is_empty() { Span::raw(" ") } else { Span::styled(" ● ", Style::default().fg(theme::WARN)) }];
9081093 let mode = ed.mode_name();
9091094 if !mode.is_empty() {
9101095 let bg = match mode {
@@ -912,11 +1097,11 @@ impl App {
9121097 "NORMAL" => theme::ACCENT,
9131098 _ => ratatui::style::Color::Magenta,
9141099 };
915− title.push(Span::styled(format!(" {} ", mode), Style::default().bg(bg).fg(ratatui::style::Color::Black).add_modifier(Modifier::BOLD)));
916− title.push(Span::raw(" "));
1100+ tail.push(Span::styled(format!(" {} ", mode), Style::default().bg(bg).fg(ratatui::style::Color::Black).add_modifier(Modifier::BOLD)));
1101+ tail.push(Span::raw(" "));
9171102 }
9181103 let keys_label = ed.keys.name();
919− let block = rounded(Line::from(title), true);
1104+ let block = rounded(Line::default(), true);
9201105 let inner = block.inner(area);
9211106 f.render_widget(block, area);
9221107 let room = area.width.saturating_sub(4) / 2;
@@ -924,12 +1109,19 @@ impl App {
9241109 // the keymap, a click away from the next one
9251110 let label = format!(" ⌨ {} ", keys_label);
9261111 let lw = label.width() as u16;
1112+ let mut end = start;
9271113 if start > area.x + lw + 2 {
9281114 let r = Rect { x: start - lw - 1, y: area.y, width: lw, height: 1 };
9291115 let bg = if self.ui.hovered(r) { theme::BUTTON_HOVER } else { theme::BUTTON };
9301116 put(f.buffer_mut(), r.x, r.y, &label, lw, Style::default().bg(bg).fg(ratatui::style::Color::White));
9311117 self.ui.push(r, Hit::Button(Action::EditorKeys, None));
1118+ end = r.x;
9321119 }
1120+ // the title left of them, never under them: its last blank is the
1121+ // label's or the button's own
1122+ let w = end.saturating_sub(area.x);
1123+ let title = editor_title(&owner_title, device.as_deref(), tail, w as usize);
1124+ f.buffer_mut().set_line(area.x + 1, area.y, &title, w.saturating_sub(1));
9331125 self.ui.edit_area = inner;
9341126 self.ui.push(inner, Hit::EditArea);
9351127 // a command, search or message takes the pane's last line
@@ -1020,7 +1212,7 @@ impl App {
10201212 fn popup(&mut self, f: &mut Frame, screen: Rect, r: Rect, title: &str, buttons: &[Action]) -> Rect {
10211213 let r = r.intersection(screen);
10221214 self.ui.push(screen, Hit::Backdrop);
1023− self.ui.push(r, Hit::ReadingPane);
1215+ self.ui.push(r, Hit::Popup);
10241216 f.render_widget(Clear, r);
10251217 let block = rounded(Line::from(Span::styled(format!(" {} ", title), Style::default().add_modifier(Modifier::BOLD))), true);
10261218 let inner = block.inner(r);
@@ -1073,8 +1265,16 @@ impl App {
10731265 self.ui.menu = None;
10741266 return;
10751267 };
1268+ let items = self.menu_items();
1269+ // on a screen too short for it the separators go first, then the
1270+ // list scrolls with the selection (§10.1)
1271+ let shown: Vec<usize> = if items.len() + 2 > screen.height as usize {
1272+ (0..items.len()).filter(|&i| items[i].is_some()).collect()
1273+ } else {
1274+ (0..items.len()).collect()
1275+ };
10761276 let w: u16 = 28;
1077− let h = NODE_MENU.len() as u16 + 2;
1277+ let h = (shown.len() as u16 + 2).min(screen.height);
10781278 let x = menu.x.min(screen.x + screen.width.saturating_sub(w));
10791279 let y = if menu.y + h > screen.y + screen.height { screen.y + screen.height.saturating_sub(h) } else { menu.y };
10801280 let r = Rect { x, y, width: w, height: h }.intersection(screen);
@@ -1084,11 +1284,15 @@ impl App {
10841284 let block = rounded(Line::from(Span::styled(format!(" {} ", fit(&title, 20)), Style::default().add_modifier(Modifier::BOLD))), true);
10851285 let inner = block.inner(r);
10861286 f.render_widget(block, r);
1087− for (i, item) in NODE_MENU.iter().enumerate() {
1088− let yy = inner.y + i as u16;
1089− if yy >= inner.y + inner.height {
1090− break;
1091− }
1287+ let view = inner.height as usize;
1288+ let mut top = menu.top;
1289+ follow(&mut top, shown.iter().position(|&i| i == menu.sel).unwrap_or(0), true, view, shown.len());
1290+ if let Some(m) = self.ui.menu.as_mut() {
1291+ m.top = top;
1292+ }
1293+ for (at, &i) in shown.iter().enumerate().skip(top).take(view) {
1294+ let item = &items[i];
1295+ let yy = inner.y + (at - top) as u16;
10921296 let row = Rect { x: inner.x, y: yy, width: inner.width, height: 1 };
10931297 let buf = f.buffer_mut();
10941298 match item {
@@ -1096,14 +1300,17 @@ impl App {
10961300 put(buf, inner.x, yy, &"─".repeat(inner.width as usize), inner.width, Style::default().fg(theme::DIM));
10971301 }
10981302 Some(a) => {
1099− let hovered = self.ui.hovered(row);
1100− if hovered {
1101− if let Some(m) = self.ui.menu.as_mut() {
1102− m.sel = i;
1103− }
1104− }
1105− let sel = self.ui.menu.as_ref().map(|m| m.sel == i).unwrap_or(false);
1106− let base = if sel { Style::default().bg(theme::SEL) } else { Style::default() };
1303+ // the item the pointer moved onto is highlighted
1304+ // (mouse.rs); once the keys or the wheel move on, the
1305+ // row it rests on is only hovered
1306+ let bg = if menu.sel == i {
1307+ Some(theme::SEL)
1308+ } else if self.ui.hovered(row) {
1309+ Some(theme::HOVER)
1310+ } else {
1311+ None
1312+ };
1313+ let base = bg.map(|b| Style::default().bg(b)).unwrap_or_default();
11071314 buf.set_style(row, base);
11081315 let label_style = if *a == Action::Delete { base.fg(theme::DANGER) } else { base };
11091316 put(buf, inner.x + 1, yy, a.label(), inner.width.saturating_sub(2), label_style);
@@ -1174,11 +1381,15 @@ impl App {
11741381
11751382 /// A list row for a node: its title, then its parents dimmed.
11761383 fn path_spans(&self, r: NRef) -> Vec<(String, Style)> {
1177− let path: Vec<String> = self.path_titles(r).iter().map(|t| title_text(t)).collect();
1384+ let chain = self.chain(r);
1385+ let path: Vec<String> = chain.iter().map(|&c| title_text(&self.vault.tree.node(c).title)).collect();
11781386 let (last, parents) = path.split_last().map(|(l, p)| (l.clone(), p.join(" › "))).unwrap_or_default();
11791387 let last = if last.is_empty() { "(untitled)".into() } else { last };
1388+ // one in a conflict copy, or the copy, is marked as the copy's row is
1389+ let copy = chain.iter().any(|&c| self.vault.tree.node(c).conflict().is_some());
11801390 vec![
11811391 (last, Style::default().add_modifier(Modifier::BOLD)),
1392+ (if copy { " ⚠".into() } else { String::new() }, Style::default().fg(theme::WARN)),
11821393 (if parents.is_empty() { String::new() } else { format!(" {}", parents) }, Style::default().fg(theme::DIM)),
11831394 ]
11841395 }
@@ -1251,11 +1462,22 @@ impl App {
12511462 let lines = super::help_text();
12521463 let r = Self::centered(screen, 78, lines.len() as u16 + 2);
12531464 let inner = self.popup(f, screen, r, "Help", &[Action::Close]);
1254− f.render_widget(Paragraph::new(lines), inner);
1465+ // where the screen is short it scrolls, a bar on its right border
1466+ let (view, len) = (inner.height as usize, lines.len());
1467+ follow(&mut self.help_scroll, 0, false, view, len);
1468+ f.render_widget(Paragraph::new(lines).scroll((self.help_scroll as u16, 0)), inner);
1469+ if len > view {
1470+ let mut st = ScrollbarState::new(len - view).position(self.help_scroll);
1471+ f.render_stateful_widget(
1472+ Scrollbar::new(ScrollbarOrientation::VerticalRight).begin_symbol(None).end_symbol(None),
1473+ Rect { y: r.y + 1, height: r.height.saturating_sub(2), ..r },
1474+ &mut st,
1475+ );
1476+ }
12551477 }
12561478
12571479 fn draw_conflict(&mut self, f: &mut Frame, area: Rect) {
1258− let pairs = fold_core::merge::conflict_pairs(&self.vault);
1480+ let pairs = fold_core::merge::conflict_pairs_in_order(&self.vault);
12591481 let buf = f.buffer_mut();
12601482 let head = Rect { height: 1, ..area };
12611483 buf.set_style(head, Style::default().bg(theme::BAR));
@@ -1279,7 +1501,7 @@ impl App {
12791501 let half = body.width / 2;
12801502 let left = Rect { width: half, ..body };
12811503 let right = Rect { x: body.x + half, width: body.width - half, ..body };
1282− let who = self.vault.tree.node(theirs).block.as_ref().and_then(|b| b.prop("conflict").map(|s| s.trim_matches('"').to_string())).unwrap_or_default();
1504+ let who = self.vault.tree.node(theirs).conflict().map(fold_core::merge::copy_from).unwrap_or_default();
12831505 for (rect, r, title, color) in [
12841506 (left, ours, " This device ".to_string(), theme::ACCENT),
12851507 (right, theirs, format!(" Other device · {} ", who), theme::WARN),
crates/fold-tui/tests/editor.rs+8−8
@@ -854,10 +854,10 @@ fn a_cut_block_title_not_pasted_back_deletes_the_block() {
854854
855855#[test]
856856fn a_line_cut_before_a_reload_and_pasted_after_it_is_saved() {
857− // another program changes the file while a nested block's title line is
858− // cut: the editor is re-rendered over the files as they are (§11.2), and
859− // the pasted line is text of the block it lands in, not a line of a
860− // block the new buffer does not have, written nowhere
857+ // another program changes the edited node while a nested block's title
858+ // line is cut: the editor is re-rendered over the files as they are
859+ // (§11.2), and the pasted line is text of the block it lands in, not a
860+ // line of a block the new buffer does not have, written nowhere
861861 let d = tempfile::tempdir().unwrap();
862862 std::fs::write(d.path().join("root.md"), "# A\n\n- one\n- task\n- two\n\n# B\n\nb\n").unwrap();
863863 let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
@@ -873,12 +873,12 @@ fn a_line_cut_before_a_reload_and_pasted_after_it_is_saved() {
873873 ctrl(&mut app, 'k');
874874 ctrl(&mut app, 's');
875875 assert_eq!(root(&d), "# A\n\n- one\n- two\n\n# B\n\nb\n");
876− std::fs::write(d.path().join("root.md"), "# A\n\n- one\n- two\n\n# B\n\nb, from Helix\n").unwrap();
876+ std::fs::write(d.path().join("root.md"), "# A\n\n- one, from Helix\n- two\n\n# B\n\nb\n").unwrap();
877877 app.reload_external();
878878 ctrl(&mut app, 'v');
879879 keys(&mut app, "⎋");
880880 assert_eq!(app.mode_pub(), "normal");
881− assert_eq!(root(&d), "# A\n\n- one\n- task\n- two\n\n# B\n\nb, from Helix\n");
881+ assert_eq!(root(&d), "# A\n\n- one, from Helix\n- task\n- two\n\n# B\n\nb\n");
882882}
883883
884884#[test]
@@ -999,10 +999,10 @@ fn a_cut_block_title_is_deleted_when_a_reload_re_renders_the_editor() {
999999 ctrl(&mut app, 'k');
10001000 ctrl(&mut app, 's');
10011001 assert!(block.exists(), "in transit");
1002− std::fs::write(d.path().join("root.md"), "# A\n\n- one\n body\n- two\n\n# C\n").unwrap();
1002+ std::fs::write(d.path().join("root.md"), "# A\n\n- one\n body\n- two\n- three\n").unwrap();
10031003 app.reload_external();
10041004 assert!(!block.exists());
1005− assert_eq!(root(&d), "# A\n\n- one\n body\n- two\n\n# C\n");
1005+ assert_eq!(root(&d), "# A\n\n- one\n body\n- two\n- three\n");
10061006}
10071007
10081008#[test]
crates/fold-tui/tests/fixes.rs+12
@@ -614,3 +614,15 @@ fn one_undo_after_leaving_with_a_cut_block_title_leaves_no_block_file_embedded_n
614614 assert_eq!(r, format!("# A\n\n- one\n{}\n- two\n", embed));
615615 assert!(block.exists());
616616}
617+
618+/// *Canonicalize* is one op-log entry, as every verb is (§10.10): `u`
619+/// puts the files back as they were.
620+#[test]
621+fn canonicalize_is_undone_by_u() {
622+ let (d, mut app) = app_with("# A\n\n* x\n");
623+ app.run_action(fold_tui::app::Action::Canonicalize);
624+ let read = || std::fs::read_to_string(d.path().join("root.md")).unwrap();
625+ assert_eq!(read(), "# A\n\n- x\n");
626+ press(&mut app, "u");
627+ assert_eq!(read(), "# A\n\n* x\n");
628+}
crates/fold-tui/tests/ux.rsnew file+3820
@@ -0,0 +1,3820 @@
1+//! What the app does over time, as `fold` runs it: the watcher, the
2+//! autosave and the status line (§10.6, §11.2), where the selection goes
3+//! when rows come and go (§8.5), keys on a selection the wheel left out
4+//! of view (§10.1), editor text no save can take when fold ends or
5+//! reverts (§10.6), sync conflicts that come in while you work (§10.7),
6+//! the conflict copies they leave in the outline (§10.1, §12.5), what
7+//! the status line says a verb did (§10.1), the next step it shows once
8+//! that is old, or a key is half typed or does nothing (§10.1), the
9+//! pointer inside a popup, which never reaches the panes behind (§10.1),
10+//! and the help, which scrolls where the screen is short (§10.8).
11+
12+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
13+use fold_tui::app::{node_menu_index, Action, App, EditKeys, Hit};
14+use ratatui::backend::TestBackend;
15+use ratatui::Terminal;
16+use std::time::{Duration, Instant};
17+
18+fn key(code: KeyCode) -> KeyEvent {
19+ KeyEvent::new(code, KeyModifiers::NONE)
20+}
21+
22+fn ctrl(c: char) -> KeyEvent {
23+ KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
24+}
25+
26+/// A vault in a directory named as a real one would be: tempfile's own
27+/// names start with a dot, which the watcher skips.
28+fn vault(root: &str) -> tempfile::TempDir {
29+ let d = tempfile::Builder::new().prefix("vault").tempdir().unwrap();
30+ std::fs::write(d.path().join("root.md"), root).unwrap();
31+ d
32+}
33+
34+/// The app as `fold` starts it: the watcher, then the startup scan for
35+/// sync-conflict copies, which reads the vault directory.
36+fn start(d: &tempfile::TempDir) -> App {
37+ let mut app = App::new(d.path()).unwrap();
38+ app.start_watcher();
39+ app.merge_on_startup();
40+ app
41+}
42+
43+/// The main loop for `ms` milliseconds with no key pressed; the number of
44+/// reloads it ran.
45+fn idle(app: &mut App, ms: u64) -> usize {
46+ let end = Instant::now() + Duration::from_millis(ms);
47+ let mut reloads = 0;
48+ while Instant::now() < end {
49+ std::thread::sleep(Duration::from_millis(50));
50+ reloads += app.tick() as usize;
51+ }
52+ reloads
53+}
54+
55+fn root(d: &tempfile::TempDir) -> String {
56+ std::fs::read_to_string(d.path().join("root.md")).unwrap()
57+}
58+
59+fn status_line(app: &mut App) -> String {
60+ let (w, h) = (160, 20);
61+ let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
62+ t.draw(|f| app.draw(f)).unwrap();
63+ let b = t.backend().buffer().clone();
64+ (0..h).map(|y| (0..w).map(|x| b[(x, y)].symbol()).collect::<String>()).find(|l| l.contains("saved")).unwrap_or_default()
65+}
66+
67+/// Watch the vault as strace would: every file the app opens there.
68+fn spy(d: &tempfile::TempDir) -> (impl Sized, impl Fn() -> usize) {
69+ use notify::event::{AccessKind, EventKind};
70+ use notify::Watcher;
71+ let (tx, rx) = std::sync::mpsc::channel();
72+ let mut w = notify::recommended_watcher(move |res| {
73+ let _ = tx.send(res);
74+ })
75+ .unwrap();
76+ w.watch(d.path(), notify::RecursiveMode::Recursive).unwrap();
77+ let opens = move || {
78+ rx.try_iter()
79+ .filter(|e: &notify::Result<notify::Event>| {
80+ e.as_ref().is_ok_and(|e| matches!(e.kind, EventKind::Access(AccessKind::Open(_))))
81+ })
82+ .count()
83+ };
84+ (w, opens)
85+}
86+
87+#[test]
88+fn an_idle_app_does_not_reload_on_its_own_reads_or_writes() {
89+ // a reload reads every file; reading one is no change to take in
90+ let d = vault("# Inbox\n\n- [ ] a\n");
91+ let mut app = start(&d);
92+ let (_w, opens) = spy(&d);
93+ assert_eq!(idle(&mut app, 2500), 0, "reloads while idle");
94+ assert_eq!(opens(), 0, "files read while idle");
95+ // nor is what fold wrote itself, seen once its self-write window is over
96+ app.handle_key(key(KeyCode::Char('j')));
97+ app.handle_key(key(KeyCode::Char('x')));
98+ assert_eq!(root(&d), "# Inbox\n\n- [x] a\n");
99+ assert_eq!(idle(&mut app, 1500), 0, "reloads after checking a task");
100+}
101+
102+#[test]
103+fn typing_in_the_editor_is_one_save_not_split_by_reloads() {
104+ let text = "# Inbox\n\nnotes\n";
105+ let d = vault(text);
106+ let mut app = start(&d);
107+ app.set_edit_keys(EditKeys::Normal);
108+ app.handle_key(key(KeyCode::Char('e')));
109+ app.handle_key(key(KeyCode::Down));
110+ app.handle_key(key(KeyCode::Down));
111+ app.handle_key(key(KeyCode::End));
112+ // ten words over some 2.5 s, never pausing long enough to autosave
113+ let typed = " on the quick brown fox and the lazy dog";
114+ for c in typed.chars() {
115+ app.handle_key(key(KeyCode::Char(c)));
116+ std::thread::sleep(Duration::from_millis(60));
117+ assert!(!app.tick(), "reloaded while typing");
118+ }
119+ assert_eq!(root(&d), text, "saved while typing");
120+ app.handle_key(key(KeyCode::Esc));
121+ assert_eq!(root(&d), format!("# Inbox\n\nnotes{}\n", typed));
122+ // one undo takes back all of it
123+ app.handle_key(key(KeyCode::Char('u')));
124+ assert_eq!(root(&d), text);
125+}
126+
127+#[test]
128+fn a_nested_block_cut_in_the_editor_survives_until_it_is_pasted() {
129+ // §5.2: cutting a nested block's title line and pasting it elsewhere
130+ // moves the block, however long the pause between the two
131+ let d = vault("# A\n\n- one\n- task\n- two\n");
132+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
133+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
134+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
135+ let block = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
136+ drop(v);
137+ let embed = format!("![[{}]]", id.as_str());
138+ assert_eq!(root(&d), format!("# A\n\n- one\n{}\n- two\n", embed));
139+ let before = std::fs::read_to_string(&block).unwrap();
140+ let mut app = start(&d);
141+ app.set_edit_keys(EditKeys::Normal);
142+ app.handle_key(key(KeyCode::Char('e')));
143+ for _ in 0..3 {
144+ app.handle_key(key(KeyCode::Down));
145+ }
146+ app.handle_key(ctrl('k'));
147+ // the pause autosaves A without the line; the block waits in transit
148+ let reloads = idle(&mut app, 2000);
149+ assert!(block.exists(), "the cut block was deleted before it was pasted");
150+ assert_eq!(root(&d), "# A\n\n- one\n- two\n");
151+ assert_eq!(reloads, 0, "reloads during the pause");
152+ // paste it above "- one"
153+ app.handle_key(key(KeyCode::Up));
154+ app.handle_key(ctrl('v'));
155+ app.handle_key(key(KeyCode::Esc));
156+ assert_eq!(root(&d), format!("# A\n\n{}\n- one\n- two\n", embed));
157+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before);
158+}
159+
160+#[test]
161+fn help_and_the_view_toggles_from_the_editor_keep_a_cut_block_to_paste() {
162+ // §5.2: help, the palette and the view toggles write nothing, so a
163+ // block cut in the editor is still moved once pasted after them
164+ let d = vault("# A\n\n- one\n- task\n- two\n");
165+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
166+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
167+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
168+ let block = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
169+ drop(v);
170+ let embed = format!("![[{}]]", id.as_str());
171+ let before = std::fs::read_to_string(&block).unwrap();
172+ let mut app = start(&d);
173+ app.set_edit_keys(EditKeys::Normal);
174+ app.handle_key(key(KeyCode::Char('e')));
175+ for _ in 0..3 {
176+ app.handle_key(key(KeyCode::Down));
177+ }
178+ app.handle_key(ctrl('k'));
179+ idle(&mut app, 1000);
180+ assert_eq!(root(&d), "# A\n\n- one\n- two\n");
181+ // F1, then Esc back to the editor
182+ app.handle_key(key(KeyCode::F(1)));
183+ assert_eq!(app.mode_pub(), "help");
184+ app.handle_key(key(KeyCode::Esc));
185+ assert!(block.exists(), "F1 deleted the cut block");
186+ // the top bar's ?, closed with its ✕
187+ click_button(&mut app, Action::Help);
188+ assert_eq!(app.mode_pub(), "help");
189+ click_button(&mut app, Action::Close);
190+ assert!(block.exists(), "the ? button deleted the cut block");
191+ // wrap lines from ☰ Commands
192+ click_button(&mut app, Action::Palette);
193+ press(&mut app, "wrap");
194+ app.handle_key(key(KeyCode::Enter));
195+ assert!(block.exists(), "a view toggle deleted the cut block");
196+ assert_eq!(app.mode_pub(), "edit");
197+ // paste it above "- one"
198+ app.handle_key(key(KeyCode::Up));
199+ app.handle_key(ctrl('v'));
200+ app.handle_key(key(KeyCode::Esc));
201+ assert_eq!(root(&d), format!("# A\n\n{}\n- one\n- two\n", embed));
202+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before);
203+}
204+
205+/// Alpha open in the editor with its "task", a block of its own, cut and
206+/// a pause after, beside Bravo, a block too: the autosave writes Alpha
207+/// without the embed, and the block waits in transit (§5.2). Bravo's file
208+/// is listed after the cut block's, so deleting that one renumbers it.
209+/// With the two files.
210+fn cut_beside_bravo() -> (tempfile::TempDir, App, std::path::PathBuf, std::path::PathBuf) {
211+ let d = vault(&format!("# Alpha\n\n- one\n![[{}]]\n- two\n\n# ![[{}]]\n", FIRST, LAST));
212+ let block = d.path().join("bacbec~task.md");
213+ let bravo = d.path().join("worzod~bravo.md");
214+ std::fs::write(&block, format!("---\nid: {}\n---\n\n- task\n", FIRST)).unwrap();
215+ std::fs::write(&bravo, format!("---\nid: {}\ntag: old\n---\n\n# Bravo\n\n- bravo one\n- bravo two\n", LAST)).unwrap();
216+ let mut app = start(&d);
217+ select(&mut app, "Alpha");
218+ app.set_edit_keys(EditKeys::Normal);
219+ app.handle_key(key(KeyCode::Char('e')));
220+ for _ in 0..3 {
221+ app.handle_key(key(KeyCode::Down));
222+ }
223+ app.handle_key(ctrl('k'));
224+ idle(&mut app, 1000);
225+ assert_eq!(root(&d), format!("# Alpha\n\n- one\n- two\n\n# ![[{}]]\n", LAST));
226+ assert!(block.exists(), "in transit");
227+ (d, app, block, bravo)
228+}
229+
230+/// Run `name` from ☰ Commands.
231+fn command(app: &mut App, name: &str) {
232+ click_button(app, Action::Palette);
233+ press(app, name);
234+ app.handle_key(key(KeyCode::Enter));
235+}
236+
237+#[test]
238+fn copy_zoom_and_what_opens_a_prompt_from_the_editor_keep_a_cut_block_to_paste() {
239+ // §5.2: a copy and a zoom write nothing, nor do a prompt and the
240+ // property form before their OK: run from a row's menu, a button, ☰
241+ // Commands or a double-click while editing, a block cut in the editor
242+ // is still moved once pasted after them
243+ let cases = ["Copy", "Zoom in, out", "Properties…", "Move to…", "+ Capture", "Capture task", "Go to…", "double-click"];
244+ for how in cases {
245+ let (d, mut app, block, _) = cut_beside_bravo();
246+ let text = std::fs::read_to_string(&block).unwrap();
247+ match how {
248+ "Copy" => {
249+ right_click(&mut app, "bravo one", Action::Copy);
250+ assert!(said(&mut app).starts_with("copied “bravo one”"), "{}", said(&mut app));
251+ }
252+ "Zoom in, out" => {
253+ right_click(&mut app, "Bravo", Action::Zoom);
254+ assert!(screen(&mut app).contains("› Bravo"), "{}", screen(&mut app));
255+ assert!(block.exists(), "Zoom in deleted the cut block");
256+ command(&mut app, "Zoom out");
257+ assert!(!screen(&mut app).contains("› Bravo"), "{}", screen(&mut app));
258+ }
259+ "Properties…" => {
260+ right_click(&mut app, "Bravo", Action::Props);
261+ assert_eq!(app.mode_pub(), "props");
262+ app.handle_key(key(KeyCode::Esc));
263+ }
264+ "Move to…" => {
265+ right_click(&mut app, "bravo one", Action::Refile);
266+ press(&mut app, "Alpha");
267+ app.handle_key(key(KeyCode::Esc));
268+ }
269+ "+ Capture" => {
270+ click_button(&mut app, Action::Capture);
271+ press(&mut app, "milk");
272+ app.handle_key(key(KeyCode::Esc));
273+ }
274+ "Capture task" => {
275+ command(&mut app, "Capture task");
276+ press(&mut app, "milk");
277+ click_button(&mut app, Action::Close);
278+ }
279+ "Go to…" => {
280+ command(&mut app, "Go to");
281+ press(&mut app, "bravo two");
282+ app.handle_key(key(KeyCode::Enter));
283+ assert_eq!(selected(&app), "bravo two");
284+ }
285+ _ => {
286+ let (x, y) = find(&frame(&mut app), "Bravo").unwrap();
287+ click_at(&mut app, x, y, MouseButton::Left);
288+ click_at(&mut app, x, y, MouseButton::Left);
289+ assert!(screen(&mut app).contains("› Bravo"), "{}", screen(&mut app));
290+ }
291+ }
292+ assert!(block.exists(), "{}: the cut block was deleted", how);
293+ assert_eq!(app.mode_pub(), "edit", "{}", how);
294+ // paste it above "- one"
295+ app.handle_key(key(KeyCode::Up));
296+ app.handle_key(ctrl('v'));
297+ app.handle_key(key(KeyCode::Esc));
298+ let moved = format!("# Alpha\n\n![[{}]]\n- one\n- two\n\n# ![[{}]]\n", FIRST, LAST);
299+ assert_eq!(root(&d), moved, "{}: pasted as plain text", how);
300+ assert_eq!(std::fs::read_to_string(&block).unwrap(), text, "{}", how);
301+ }
302+}
303+
304+#[test]
305+fn a_prompt_or_the_property_form_opened_over_a_cut_block_writes_where_it_was_opened() {
306+ // §5.2, §10.6: what writes from a prompt or the property form saves
307+ // the editor first, which deletes a block cut there and renumbers the
308+ // files after its own, Bravo's here: the pick and the form's node are
309+ // found again, and the write goes to Bravo
310+ for how in ["Move to… OK", "Add a property", "Delete a property"] {
311+ let (d, mut app, block, bravo) = cut_beside_bravo();
312+ match how {
313+ "Move to… OK" => {
314+ right_click(&mut app, "two", Action::Refile);
315+ press(&mut app, "Bravo");
316+ click_button(&mut app, Action::PromptOk);
317+ assert_eq!(root(&d), format!("# Alpha\n\n- one\n\n# ![[{}]]\n", LAST));
318+ }
319+ "Add a property" => {
320+ right_click(&mut app, "Bravo", Action::Props);
321+ click_button(&mut app, Action::PropAdd);
322+ press(&mut app, "owner");
323+ app.handle_key(key(KeyCode::Enter));
324+ press(&mut app, "me");
325+ app.handle_key(key(KeyCode::Enter));
326+ }
327+ _ => {
328+ right_click(&mut app, "Bravo", Action::Props);
329+ press(&mut app, "d");
330+ }
331+ }
332+ assert!(!block.exists(), "{}: the cut block outlived the write", how);
333+ let text = std::fs::read_to_string(&bravo).unwrap();
334+ let want = match how {
335+ "Move to… OK" => format!("---\nid: {}\ntag: old\n---\n\n# Bravo\n\n- bravo one\n- bravo two\n- two\n", LAST),
336+ "Add a property" => format!("---\nid: {}\ntag: old\nowner: me\n---\n\n# Bravo\n\n- bravo one\n- bravo two\n", LAST),
337+ _ => format!("---\nid: {}\n---\n\n# Bravo\n\n- bravo one\n- bravo two\n", LAST),
338+ };
339+ assert_eq!(text, want, "{}: {}", how, said(&mut app));
340+ }
341+}
342+
343+#[test]
344+fn a_change_from_outside_to_a_file_the_editor_does_not_hold_keeps_a_cut_block_to_paste() {
345+ cut_then_the_phone_writes_the_inbox(false);
346+}
347+
348+#[test]
349+fn a_change_from_outside_beside_a_copy_the_merge_left_alone_keeps_a_cut_block_to_paste() {
350+ cut_then_the_phone_writes_the_inbox(true);
351+}
352+
353+/// §5.2, §11.2: the phone writing the Inbox's own file leaves the editor
354+/// on A as it is, so a block cut there is still moved once pasted; beside
355+/// the conflict copy of an ignored file (`copy`) too, which the merge
356+/// leaves alone.
357+fn cut_then_the_phone_writes_the_inbox(copy: bool) {
358+ let d = vault("# A\n\n- one\n- task\n- two\n\n# Inbox\n\n- [ ] a\n");
359+ if copy {
360+ std::fs::write(d.path().join("notes.md"), "mine\n").unwrap();
361+ std::fs::write(d.path().join("notes.sync-conflict-20260926-150000-PHONE.md"), "theirs\n").unwrap();
362+ }
363+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
364+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
365+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
366+ let block = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
367+ let i = v.find_by_path(&["Inbox".into()]).unwrap();
368+ let inbox_id = fold_core::ops::make_block(&mut v, i).unwrap();
369+ let inbox = v.dir.join(&v.tree.files[v.tree.block_by_id(&inbox_id).unwrap().0].path);
370+ drop(v);
371+ let embed = format!("![[{}]]", id.as_str());
372+ let inbox_embed = format!("# ![[{}]]", inbox_id.as_str());
373+ let before = std::fs::read_to_string(&block).unwrap();
374+ let mut app = start(&d);
375+ app.set_edit_keys(EditKeys::Normal);
376+ app.handle_key(key(KeyCode::Char('e')));
377+ for _ in 0..3 {
378+ app.handle_key(key(KeyCode::Down));
379+ }
380+ app.handle_key(ctrl('k'));
381+ idle(&mut app, 1000);
382+ assert_eq!(root(&d), format!("# A\n\n- one\n- two\n\n{}\n", inbox_embed));
383+ // the phone appends to the Inbox
384+ {
385+ use std::io::Write;
386+ let mut f = std::fs::OpenOptions::new().append(true).open(&inbox).unwrap();
387+ f.write_all(b"- [ ] from phone\n").unwrap();
388+ }
389+ until_reload(&mut app);
390+ assert!(app.rows().iter().any(|r| app.title_of(r.nref) == "from phone"));
391+ assert!(block.exists(), "a change to another file deleted the cut block");
392+ assert_eq!(app.mode_pub(), "edit");
393+ // paste it above "- one"
394+ app.handle_key(key(KeyCode::Up));
395+ app.handle_key(ctrl('v'));
396+ app.handle_key(key(KeyCode::Esc));
397+ assert_eq!(root(&d), format!("# A\n\n{}\n- one\n- two\n\n{}\n", embed, inbox_embed));
398+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before);
399+}
400+
401+#[test]
402+fn a_change_from_outside_to_another_node_of_the_editor_s_file_keeps_a_cut_block_to_paste() {
403+ // §5.2, §11.2: the phone adds to the Inbox in root.md, the file the
404+ // editor on A is in, after A or before it: what the editor shows is as
405+ // it was, so a block cut there is still moved once pasted
406+ for inbox_first in [false, true] {
407+ let (a, inbox) = ("# A\n\n- one\n- task\n- two\n", "# Inbox\n\n- [ ] a\n");
408+ let text = if inbox_first { format!("{}\n{}", inbox, a) } else { format!("{}\n{}", a, inbox) };
409+ let d = vault(&text);
410+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
411+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
412+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
413+ let block = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
414+ drop(v);
415+ let embed = format!("![[{}]]", id.as_str());
416+ let before = std::fs::read_to_string(&block).unwrap();
417+ let mut app = start(&d);
418+ select(&mut app, "A");
419+ app.set_edit_keys(EditKeys::Normal);
420+ app.handle_key(key(KeyCode::Char('e')));
421+ for _ in 0..3 {
422+ app.handle_key(key(KeyCode::Down));
423+ }
424+ app.handle_key(ctrl('k'));
425+ idle(&mut app, 1000);
426+ assert_eq!(root(&d), text.replace("- task\n", ""), "inbox first: {}", inbox_first);
427+ // the phone adds to the Inbox
428+ let phone = text.replace("- task\n", "").replace("- [ ] a\n", "- [ ] a\n- [ ] from phone\n");
429+ std::fs::write(d.path().join("root.md"), &phone).unwrap();
430+ until_reload(&mut app);
431+ assert!(app.rows().iter().any(|r| app.title_of(r.nref) == "from phone"));
432+ assert!(block.exists(), "inbox first: {}: the cut block was deleted", inbox_first);
433+ assert_eq!(app.mode_pub(), "edit");
434+ // paste it above "- one"
435+ app.handle_key(key(KeyCode::Up));
436+ app.handle_key(ctrl('v'));
437+ app.handle_key(key(KeyCode::Esc));
438+ let moved = phone.replace("- one\n", &format!("{}\n- one\n", embed));
439+ assert_eq!(root(&d), moved, "inbox first: {}", inbox_first);
440+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before, "inbox first: {}", inbox_first);
441+ }
442+}
443+
444+#[test]
445+fn a_copy_merged_into_the_node_the_editor_shows_deletes_a_block_cut_there() {
446+ // §5.2, §11.2: the merge changes A, so the editor is re-rendered and
447+ // the cut line can no longer be pasted as the block: it is deleted,
448+ // never left in a file embedded nowhere
449+ let d = vault("# A\n\n- one\n- task\n- two\n\n# B\n\nb\n");
450+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
451+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
452+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
453+ let block = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
454+ drop(v);
455+ let mut app = start(&d);
456+ app.set_edit_keys(EditKeys::Normal);
457+ app.handle_key(key(KeyCode::Char('e')));
458+ for _ in 0..3 {
459+ app.handle_key(key(KeyCode::Down));
460+ }
461+ app.handle_key(ctrl('k'));
462+ idle(&mut app, 1000);
463+ assert_eq!(root(&d), "# A\n\n- one\n- two\n\n# B\n\nb\n");
464+ assert!(block.exists(), "in transit");
465+ std::fs::write(
466+ d.path().join("root.sync-conflict-20260927-100000-PHONE.md"),
467+ "# A\n\n- one\n- two\n- from phone\n\n# B\n\nb\n",
468+ )
469+ .unwrap();
470+ until_reload(&mut app);
471+ assert!(app.vault_conflict_files().unwrap().is_empty(), "the copy was not merged");
472+ let merged = root(&d);
473+ assert!(merged.contains("- from phone\n"), "{}", merged);
474+ assert!(!block.exists(), "a cut block was left embedded nowhere");
475+ assert_eq!(app.mode_pub(), "edit");
476+ // the line pasted back is text of A
477+ app.handle_key(key(KeyCode::Up));
478+ app.handle_key(ctrl('v'));
479+ app.handle_key(key(KeyCode::Esc));
480+ assert_eq!(root(&d), merged.replace("- one\n", "- task\n- one\n"));
481+ // one undo takes back the paste; the block went in the step that cut
482+ // it, which the merge refuses to undo, so its file stays in the trash
483+ // rather than come back embedded nowhere (§10.10)
484+ app.handle_key(key(KeyCode::Char('u')));
485+ assert_eq!(root(&d), merged);
486+ assert!(!block.exists());
487+ app.handle_key(key(KeyCode::Char('u')));
488+ assert_eq!(said(&mut app), "undo refused: root.md changed since edit “A”; not overwriting");
489+ assert_eq!(root(&d), merged);
490+ assert!(!block.exists(), "undo put back the block's file embedded nowhere");
491+}
492+
493+#[test]
494+fn a_copy_from_before_a_cut_brings_in_its_own_change_and_the_paste_still_moves_the_block() {
495+ // §5.2, §11.2, §12.4: the phone adds to the Inbox while "task" is cut
496+ // in the editor on A. root.md changed on both sides, so Syncthing
497+ // delivers the phone's as a copy of root.md from before the cut, the
498+ // block's embed still in A. The block is moved here, not put back where
499+ // it was cut: the copy's own change comes in, the editor stays as it
500+ // is, and the paste embeds the block once. With A's text changed on the
501+ // phone too, its conflict copy holds no embed of the block either
502+ for phone_edits_a in [false, true] {
503+ let d = vault("# A\n\n- one\n- task\n- two\n\n# Inbox\n\n- [ ] a\n");
504+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
505+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
506+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
507+ let block = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
508+ drop(v);
509+ let embed = format!("![[{}]]", id.as_str());
510+ let embedded = root(&d);
511+ let before = std::fs::read_to_string(&block).unwrap();
512+ let mut app = start(&d);
513+ select(&mut app, "A");
514+ app.set_edit_keys(EditKeys::Normal);
515+ app.handle_key(key(KeyCode::Char('e')));
516+ for _ in 0..3 {
517+ app.handle_key(key(KeyCode::Down));
518+ }
519+ app.handle_key(ctrl('k'));
520+ idle(&mut app, 1000);
521+ assert_eq!(root(&d), "# A\n\n- one\n- two\n\n# Inbox\n\n- [ ] a\n");
522+ let mut phone = embedded.replace("- [ ] a\n", "- [ ] a\n- [ ] from phone\n");
523+ if phone_edits_a {
524+ phone = phone.replace("# A\n\n", "# A\n\nfrom phone too\n\n");
525+ }
526+ land(d.path(), "root.sync-conflict-20260927-101010-PHONE77.md", &phone);
527+ until_reload(&mut app);
528+ let case = format!("A edited on the phone: {}", phone_edits_a);
529+ assert!(app.vault_conflict_files().unwrap().is_empty(), "{}: the copy was not merged", case);
530+ assert!(root(&d).contains("- [ ] a\n- [ ] from phone\n"), "{}: {}", case, root(&d));
531+ assert!(!root(&d).contains(&embed), "{}: the cut block was put back where it was cut: {}", case, root(&d));
532+ assert!(block.exists(), "{}: the cut block was deleted", case);
533+ assert_eq!(app.mode_pub(), "edit", "{}", case);
534+ if phone_edits_a {
535+ assert_eq!(pairs(&mut app), 1, "{}", case);
536+ } else {
537+ assert_eq!(said(&mut app), "↻ changed outside fold: Inbox (+1 item)", "{}", case);
538+ }
539+ // paste it above "- one"
540+ app.handle_key(key(KeyCode::Up));
541+ app.handle_key(ctrl('v'));
542+ app.handle_key(key(KeyCode::Esc));
543+ assert!(root(&d).starts_with(&format!("# A\n\n{}\n- one\n- two\n", embed)), "{}: {}", case, root(&d));
544+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before, "{}", case);
545+ // one embed of the block in the vault, and its line nowhere else
546+ let mut embeds = 0;
547+ for f in std::fs::read_dir(d.path()).unwrap() {
548+ let path = f.unwrap().path();
549+ let text = std::fs::read_to_string(&path).unwrap_or_default();
550+ embeds += text.matches(&embed).count();
551+ assert!(path == block || !text.contains("task"), "{}: pasted as plain text: {}", case, text);
552+ }
553+ assert_eq!(embeds, 1, "{}: the block is embedded {} times", case, embeds);
554+ }
555+}
556+
557+/// A, its "task" a block of its own, open in the editor with that line
558+/// cut and a pause after: the autosave writes A without its embed, and
559+/// the block waits in transit (§5.2). With the block's file, and root.md
560+/// as it was before the cut.
561+fn cut_and_paused() -> (tempfile::TempDir, App, std::path::PathBuf, String) {
562+ let d = vault("# A\n\n- one\n- task\n- two\n");
563+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
564+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
565+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
566+ let block = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
567+ drop(v);
568+ let embedded = format!("# A\n\n- one\n![[{}]]\n- two\n", id.as_str());
569+ assert_eq!(root(&d), embedded);
570+ let mut app = start(&d);
571+ app.set_edit_keys(EditKeys::Normal);
572+ app.handle_key(key(KeyCode::Char('e')));
573+ for _ in 0..3 {
574+ app.handle_key(key(KeyCode::Down));
575+ }
576+ app.handle_key(ctrl('k'));
577+ idle(&mut app, 1000);
578+ assert_eq!(root(&d), "# A\n\n- one\n- two\n");
579+ assert!(block.exists(), "in transit");
580+ (d, app, block, embedded)
581+}
582+
583+#[test]
584+fn leaving_the_editor_after_the_autosave_deletes_a_cut_block_that_one_undo_puts_back_embedded() {
585+ // §5.2, §10.10: the block is deleted with the save that wrote its
586+ // embed out, so one undo never puts back its file embedded nowhere
587+ let (d, mut app, block, embedded) = cut_and_paused();
588+ let text = std::fs::read_to_string(&block).unwrap();
589+ app.handle_key(key(KeyCode::Esc));
590+ assert_eq!(app.mode_pub(), "normal");
591+ assert!(!block.exists(), "the cut block outlived the editor");
592+ assert_eq!(root(&d), "# A\n\n- one\n- two\n");
593+ press(&mut app, "u");
594+ assert_eq!(said(&mut app), "undone: edit “A”");
595+ assert_eq!(root(&d), embedded, "undo put back the block's file embedded nowhere");
596+ assert_eq!(std::fs::read_to_string(&block).unwrap(), text);
597+ // redo deletes it again, embed and file
598+ press(&mut app, "U");
599+ assert_eq!(root(&d), "# A\n\n- one\n- two\n");
600+ assert!(!block.exists());
601+}
602+
603+#[test]
604+fn a_cut_block_deleted_after_more_saves_or_on_revert_comes_back_embedded_with_the_undo_of_its_cut() {
605+ // typing saved after the cut, a second cut that lets go of the first,
606+ // or Revert: whichever save deletes the block, undo puts it back with
607+ // the step that cut it (§5.2, §10.10)
608+ for how in ["typing", "second cut", "revert"] {
609+ let (d, mut app, block, embedded) = cut_and_paused();
610+ let cut = root(&d);
611+ match how {
612+ "typing" => {
613+ app.handle_key(key(KeyCode::End));
614+ press(&mut app, " more");
615+ idle(&mut app, 1000);
616+ assert_eq!(root(&d), "# A\n\n- one\n- two more\n");
617+ app.handle_key(key(KeyCode::Esc));
618+ }
619+ "second cut" => {
620+ app.handle_key(ctrl('k'));
621+ idle(&mut app, 1000);
622+ assert_eq!(root(&d), "# A\n\n- one\n");
623+ app.handle_key(key(KeyCode::Esc));
624+ }
625+ _ => app.run_action(Action::EditRevert),
626+ }
627+ assert_eq!(app.mode_pub(), "normal", "{}", how);
628+ assert!(!block.exists(), "{}: the cut block outlived the editor", how);
629+ if how != "revert" {
630+ // the later save is undone on its own, the block still gone
631+ press(&mut app, "u");
632+ assert_eq!(root(&d), cut, "{}", how);
633+ assert!(!block.exists(), "{}: undo put back the block's file embedded nowhere", how);
634+ }
635+ press(&mut app, "u");
636+ assert_eq!(said(&mut app), "undone: edit “A”", "{}", how);
637+ assert_eq!(root(&d), embedded, "{}", how);
638+ assert!(block.exists(), "{}: undo did not put back the cut block's file", how);
639+ }
640+}
641+
642+#[test]
643+fn a_cut_block_deleted_by_a_change_from_outside_is_never_undone_to_a_file_embedded_nowhere() {
644+ // the phone adds to A, the node the editor shows: it is re-rendered
645+ // and the cut block deleted (§11.2), in the entry of the save that
646+ // wrote its embed out, which the change refuses to undo; no undo puts
647+ // back its file without the embed (§5.2, §10.10)
648+ let (d, mut app, block, _) = cut_and_paused();
649+ let phone = "# A\n\n- one\n- two\n- from phone\n";
650+ std::fs::write(d.path().join("root.md"), phone).unwrap();
651+ until_reload(&mut app);
652+ assert!(!block.exists(), "a cut block was left embedded nowhere");
653+ app.handle_key(key(KeyCode::Esc));
654+ assert_eq!(app.mode_pub(), "normal");
655+ for _ in 0..2 {
656+ press(&mut app, "u");
657+ assert_eq!(root(&d), phone, "undo overwrote the change from outside");
658+ assert!(!block.exists(), "undo put back the block's file embedded nowhere: {}", said(&mut app));
659+ }
660+ assert_eq!(said(&mut app), "undo refused: root.md changed since edit “A”; not overwriting");
661+}
662+
663+#[test]
664+fn a_cut_block_pasted_back_after_the_autosave_goes_back_where_it_was_with_one_undo() {
665+ // the saves from the one that wrote the embed out to the one that
666+ // writes it back are one step: no undo stops between them, where the
667+ // block's file is embedded nowhere (§5.2, §10.10)
668+ for how in ["leave", "autosave", "typing"] {
669+ let (d, mut app, block, embedded) = cut_and_paused();
670+ let text = std::fs::read_to_string(&block).unwrap();
671+ let embed = embedded.lines().nth(3).unwrap().to_string();
672+ let mut moved = format!("# A\n\n{}\n- one\n- two\n", embed);
673+ if how == "typing" {
674+ app.handle_key(key(KeyCode::End));
675+ press(&mut app, " more");
676+ idle(&mut app, 1000);
677+ assert_eq!(root(&d), "# A\n\n- one\n- two more\n");
678+ moved = moved.replace("- two", "- two more");
679+ }
680+ // the cut line goes back above "one"
681+ app.handle_key(key(KeyCode::Up));
682+ app.handle_key(ctrl('v'));
683+ if how == "autosave" {
684+ idle(&mut app, 1000);
685+ assert_eq!(root(&d), moved);
686+ }
687+ app.handle_key(key(KeyCode::Esc));
688+ assert_eq!(app.mode_pub(), "normal", "{}", how);
689+ assert_eq!(root(&d), moved, "{}", how);
690+ press(&mut app, "u");
691+ assert_eq!(said(&mut app), "undone: edit “A”", "{}", how);
692+ assert_eq!(root(&d), embedded, "{}: undo left the block's file embedded nowhere", how);
693+ assert_eq!(std::fs::read_to_string(&block).unwrap(), text, "{}", how);
694+ // redo moves it again, as one step
695+ press(&mut app, "U");
696+ assert_eq!(root(&d), moved, "{}", how);
697+ assert!(block.exists(), "{}", how);
698+ }
699+ // pasted back where it was: nothing changed, so nothing to undo
700+ let (d, mut app, block, embedded) = cut_and_paused();
701+ app.handle_key(ctrl('v'));
702+ app.handle_key(key(KeyCode::Esc));
703+ assert_eq!(root(&d), embedded);
704+ press(&mut app, "u");
705+ assert_eq!(said(&mut app), "nothing to undo");
706+ assert_eq!(root(&d), embedded);
707+ assert!(block.exists());
708+}
709+
710+#[test]
711+fn a_cut_block_pasted_back_where_a_save_is_refused_first_still_goes_back_with_one_undo() {
712+ // pasted above " - sub", which would nest under its embed: the save
713+ // is refused (§4.7) until the line is outdented, and the block waits
714+ // with its line back and its embed not written (§5.2, §10.10)
715+ let d = vault("# A\n\n- one\n - sub\n- task\n- two\n");
716+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
717+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
718+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
719+ let block = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
720+ drop(v);
721+ let embed = format!("![[{}]]", id.as_str());
722+ let embedded = format!("# A\n\n- one\n - sub\n{}\n- two\n", embed);
723+ assert_eq!(root(&d), embedded);
724+ let mut app = start(&d);
725+ app.set_edit_keys(EditKeys::Normal);
726+ app.handle_key(key(KeyCode::Char('e')));
727+ for _ in 0..4 {
728+ app.handle_key(key(KeyCode::Down));
729+ }
730+ app.handle_key(ctrl('k'));
731+ idle(&mut app, 1000);
732+ assert_eq!(root(&d), "# A\n\n- one\n - sub\n- two\n");
733+ app.handle_key(key(KeyCode::Up));
734+ app.handle_key(ctrl('v'));
735+ idle(&mut app, 1000);
736+ assert!(said(&mut app).contains("not saved"), "{}", said(&mut app));
737+ assert_eq!(root(&d), "# A\n\n- one\n - sub\n- two\n");
738+ // " - sub", under the cursor, outdented, and the save goes through
739+ app.handle_key(key(KeyCode::BackTab));
740+ app.handle_key(key(KeyCode::Esc));
741+ assert_eq!(app.mode_pub(), "normal");
742+ assert_eq!(root(&d), format!("# A\n\n- one\n{}\n- sub\n- two\n", embed));
743+ press(&mut app, "u");
744+ assert_eq!(said(&mut app), "undone: edit “A”");
745+ assert_eq!(root(&d), embedded, "undo left the block's file embedded nowhere");
746+ assert!(block.exists());
747+}
748+
749+#[test]
750+fn a_change_from_outside_is_taken_in_and_announced() {
751+ let d = vault("# NAS\n\n- disks\n\n# Inbox\n\n- [ ] a\n");
752+ let mut app = start(&d);
753+ idle(&mut app, 300);
754+ // another program appends a task, as `echo >> root.md` would
755+ {
756+ use std::io::Write;
757+ let mut f = std::fs::OpenOptions::new().append(true).open(d.path().join("root.md")).unwrap();
758+ f.write_all(b"- [ ] phone-added task\n").unwrap();
759+ }
760+ let end = Instant::now() + Duration::from_secs(5);
761+ let mut reloads = 0;
762+ while reloads == 0 && Instant::now() < end {
763+ std::thread::sleep(Duration::from_millis(50));
764+ reloads += app.tick() as usize;
765+ }
766+ assert_eq!(reloads, 1, "the change was not taken in");
767+ let titles: Vec<String> = app.rows().iter().map(|r| app.title_of(r.nref)).collect();
768+ assert!(titles.contains(&"phone-added task".to_string()), "{:?}", titles);
769+ let s = status_line(&mut app);
770+ assert!(s.contains("↻ changed outside fold: Inbox (+1 item)"), "{}", s);
771+ assert!(!s.contains("NAS"), "{}", s);
772+ // and taken in once
773+ assert_eq!(idle(&mut app, 1000), 0);
774+}
775+
776+#[test]
777+fn a_change_from_outside_while_editing_saves_the_editor_first_and_says_both() {
778+ let d = vault("# NAS\n\n- disks\n\n# Inbox\n\n- [ ] a\n");
779+ let mut app = start(&d);
780+ app.set_edit_keys(EditKeys::Normal);
781+ app.handle_key(key(KeyCode::Char('e')));
782+ app.handle_key(key(KeyCode::End));
783+ app.handle_key(key(KeyCode::Char('!')));
784+ {
785+ use std::io::Write;
786+ let mut f = std::fs::OpenOptions::new().append(true).open(d.path().join("root.md")).unwrap();
787+ f.write_all(b"- [ ] phone-added task\n").unwrap();
788+ }
789+ // the reload comes before the autosave would
790+ let end = Instant::now() + Duration::from_millis(700);
791+ let mut reloads = 0;
792+ while reloads == 0 && Instant::now() < end {
793+ std::thread::sleep(Duration::from_millis(20));
794+ reloads += app.tick() as usize;
795+ }
796+ assert_eq!(reloads, 1, "the change was not taken in");
797+ assert_eq!(root(&d), "# NAS!\n\n- disks\n\n# Inbox\n\n- [ ] a\n- [ ] phone-added task\n");
798+ // the typing is not what came from outside
799+ let s = status_line(&mut app);
800+ assert!(s.contains("↻ typing saved; changed outside fold: Inbox (+1 item)"), "{}", s);
801+}
802+
803+#[test]
804+fn a_block_file_renamed_outside_fold_is_still_not_overwritten_over_a_change_there() {
805+ // `fold check --fix` renames the file of "task", a block the editor
806+ // on A shows, its text as it was; the phone then adds to it while the
807+ // editor has typing unsaved, typed before the rename or after: the
808+ // save is refused as it would be under the old name (§5.2 step 5),
809+ // never written over the phone's line once the vault has read it
810+ for typed_first in [false, true] {
811+ let d = vault("# A\n\n- one\n- task\n- two\n");
812+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
813+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
814+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
815+ let old = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
816+ drop(v);
817+ let renamed = old.with_file_name(old.file_name().unwrap().to_string_lossy().replace("~task.md", "~task-renamed.md"));
818+ let mut app = start(&d);
819+ app.set_edit_keys(EditKeys::Normal);
820+ app.handle_key(key(KeyCode::Char('e')));
821+ for _ in 0..3 {
822+ app.handle_key(key(KeyCode::Down));
823+ }
824+ app.handle_key(key(KeyCode::End));
825+ if !typed_first {
826+ std::fs::rename(&old, &renamed).unwrap();
827+ until_reload(&mut app);
828+ assert_eq!(app.mode_pub(), "edit");
829+ }
830+ press(&mut app, " XYZ");
831+ if typed_first {
832+ std::fs::rename(&old, &renamed).unwrap();
833+ }
834+ let theirs = std::fs::read_to_string(&renamed).unwrap() + " - phone child\n";
835+ std::fs::write(&renamed, &theirs).unwrap();
836+ until_reload(&mut app);
837+ // the autosave, some pauses on
838+ idle(&mut app, 1600);
839+ assert_eq!(std::fs::read_to_string(&renamed).unwrap(), theirs, "typed first: {}", typed_first);
840+ assert!(said(&mut app).contains("changed on disk; not overwriting"), "typed first: {}: {}", typed_first, said(&mut app));
841+ assert_eq!(app.mode_pub(), "edit");
842+ }
843+}
844+
845+#[test]
846+fn where_the_bar_is_short_what_came_in_gives_way_to_the_typing_saved_for_it() {
847+ // 80×24 while editing: the EDIT badge leaves 53 columns, and with done
848+ // hidden 40, 49 once the file gives way
849+ for hide in [false, true] {
850+ let d = vault("# NAS\n\n- disks\n\n# Inbox\n\n- [ ] a\n");
851+ let mut app = App::new(d.path()).unwrap();
852+ app.set_edit_keys(EditKeys::Normal);
853+ if hide {
854+ press(&mut app, "zd");
855+ }
856+ app.handle_key(key(KeyCode::Char('e')));
857+ app.handle_key(key(KeyCode::End));
858+ press(&mut app, " typed");
859+ std::fs::write(d.path().join("root.md"), root(&d) + "- [ ] phone note\n").unwrap();
860+ app.reload_external();
861+ assert_eq!(app.mode_pub(), "edit");
862+ let s = status80(&mut app);
863+ if hide {
864+ assert!(s.starts_with(" EDIT ↻ typing saved; changed outside fold: Inbox (+1 … done hidden ✓ saved "), "{}", s);
865+ } else {
866+ assert!(s.starts_with(" EDIT ↻ typing saved; changed outside fold: Inbox (+1 item) root.md "), "{}", s);
867+ }
868+ }
869+}
870+
871+/// The main loop until it has run a reload, for up to 5 s.
872+fn until_reload(app: &mut App) {
873+ let end = Instant::now() + Duration::from_secs(5);
874+ while Instant::now() < end {
875+ std::thread::sleep(Duration::from_millis(50));
876+ if app.tick() {
877+ return;
878+ }
879+ }
880+ panic!("the change was not taken in");
881+}
882+
883+#[test]
884+fn a_change_from_outside_beside_a_copy_the_merge_left_alone_is_announced() {
885+ // notes.md has no id: the copy of an ignored file, which the merge
886+ // leaves alone (§12.2), is in the vault for good
887+ let d = vault("# NAS\n\n- disks\n\n# Inbox\n\n- [ ] a\n");
888+ std::fs::write(d.path().join("notes.md"), "mine\n").unwrap();
889+ std::fs::write(d.path().join("notes.sync-conflict-20260926-150000-PHONE.md"), "theirs\n").unwrap();
890+ let mut app = start(&d);
891+ // nothing came in: the greeting stays
892+ assert!(status_line(&mut app).contains("? help"), "{}", status_line(&mut app));
893+ idle(&mut app, 300);
894+ for task in ["from phone", "from laptop"] {
895+ let text = root(&d);
896+ std::fs::write(d.path().join("root.md"), format!("{}- [ ] {}\n", text, task)).unwrap();
897+ until_reload(&mut app);
898+ assert!(app.rows().iter().any(|r| app.title_of(r.nref) == task));
899+ let s = status_line(&mut app);
900+ assert!(s.contains("↻ changed outside fold: Inbox (+1 item)"), "{}", s);
901+ assert!(!s.contains("notes") && !s.contains("merged"), "{}", s);
902+ }
903+}
904+
905+#[test]
906+fn a_change_from_outside_to_the_file_typed_in_beside_a_copy_the_merge_left_alone_is_announced() {
907+ // the editor's save takes the phone's line into root.md before the
908+ // merge runs for the copy it leaves alone (§11.2): what came in is still
909+ // what the files held against what was read
910+ let d = vault("# NAS\n\n- disks\n\n# Inbox\n\n- [ ] a\n");
911+ std::fs::write(d.path().join("notes.md"), "mine\n").unwrap();
912+ std::fs::write(d.path().join("notes.sync-conflict-20260926-150000-PHONE.md"), "theirs\n").unwrap();
913+ let mut app = App::new(d.path()).unwrap();
914+ app.set_edit_keys(EditKeys::Normal);
915+ app.handle_key(key(KeyCode::Char('e')));
916+ app.handle_key(key(KeyCode::End));
917+ press(&mut app, " typed");
918+ std::fs::write(d.path().join("root.md"), root(&d) + "- [ ] phone note\n").unwrap();
919+ app.reload_external();
920+ assert_eq!(root(&d), "# NAS typed\n\n- disks\n\n# Inbox\n\n- [ ] a\n- [ ] phone note\n");
921+ assert_eq!(app.mode_pub(), "edit");
922+ assert_eq!(said(&mut app), "↻ typing saved; changed outside fold: Inbox (+1 item)");
923+}
924+
925+#[test]
926+fn a_file_typed_in_and_its_copy_that_land_together_are_both_announced() {
927+ // Syncthing delivers the laptop's root.md and the phone's copy of it
928+ // while the editor adds to NAS: the save takes the laptop's line in, the
929+ // merge the phone's (§11.2), and both came in from outside
930+ let d = vault("# NAS\n\n- disks\n\n# Inbox\n\n- [ ] a\n");
931+ let mut app = App::new(d.path()).unwrap();
932+ app.set_edit_keys(EditKeys::Normal);
933+ app.handle_key(key(KeyCode::Char('e')));
934+ app.handle_key(key(KeyCode::Down));
935+ app.handle_key(key(KeyCode::Down));
936+ app.handle_key(key(KeyCode::End));
937+ app.handle_key(key(KeyCode::Enter));
938+ press(&mut app, "- cables");
939+ let text = root(&d);
940+ std::fs::write(d.path().join("root.md"), text.clone() + "- [ ] from laptop\n").unwrap();
941+ std::fs::write(d.path().join("root.sync-conflict-20260927-100000-PHONE.md"), text + "- [ ] from phone\n").unwrap();
942+ app.reload_external();
943+ assert!(app.vault_conflict_files().unwrap().is_empty(), "the copy was not merged");
944+ assert_eq!(pairs(&mut app), 0);
945+ let r = root(&d);
946+ assert!(r.contains("- cables\n") && r.contains("- [ ] from laptop\n") && r.contains("- [ ] from phone\n"), "{}", r);
947+ assert_eq!(app.mode_pub(), "edit");
948+ assert_eq!(said(&mut app), "↻ typing saved; changed outside fold: Inbox (+2 items)");
949+}
950+
951+#[test]
952+fn a_copy_merged_without_a_pair_is_announced_as_what_came_in() {
953+ let d = vault("# NAS\n\n- disks\n\n# Inbox\n\n- [ ] a\n");
954+ let mut app = start(&d);
955+ idle(&mut app, 300);
956+ std::fs::write(
957+ d.path().join("root.sync-conflict-20260927-100000-PHONE.md"),
958+ "# NAS\n\n- disks\n\n# Inbox\n\n- [ ] a\n- [ ] from phone\n",
959+ )
960+ .unwrap();
961+ until_reload(&mut app);
962+ assert!(app.vault_conflict_files().unwrap().is_empty(), "the copy was not merged");
963+ assert!(root(&d).contains("- [ ] from phone\n"), "{}", root(&d));
964+ assert_eq!(pairs(&mut app), 0);
965+ assert_eq!(app.mode_pub(), "normal");
966+ let s = status_line(&mut app);
967+ assert!(s.contains("↻ changed outside fold: Inbox (+1 item)"), "{}", s);
968+ assert!(!s.contains("sync-conflict") && !s.contains("pair"), "{}", s);
969+}
970+
971+#[test]
972+fn a_new_copy_that_changes_nothing_is_not_announced() {
973+ // §11.2: a copy the same as its file, which the merge trashes, and the
974+ // copy of an ignored file (notes.md has no id), which it leaves alone,
975+ // bring nothing in: the greeting stays, as it does at startup
976+ let d = vault("# NAS\n\n- disks\n\n# Inbox\n\n- [ ] a\n");
977+ std::fs::write(d.path().join("notes.md"), "mine\n").unwrap();
978+ let mut app = start(&d);
979+ idle(&mut app, 300);
980+ let rows = |app: &App| app.rows().iter().map(|r| app.title_of(r.nref)).collect::<Vec<_>>();
981+ let was = rows(&app);
982+ assert!(said(&mut app).ends_with("? help"), "{}", said(&mut app));
983+ std::fs::write(d.path().join("root.sync-conflict-20260927-100000-PHONE.md"), root(&d)).unwrap();
984+ until_reload(&mut app);
985+ assert!(app.vault_conflict_files().unwrap().is_empty(), "the copy was not merged");
986+ assert_eq!(rows(&app), was);
987+ assert!(said(&mut app).ends_with("? help"), "{}", said(&mut app));
988+ std::fs::write(d.path().join("notes.sync-conflict-20260927-100000-PHONE.md"), "theirs\n").unwrap();
989+ until_reload(&mut app);
990+ assert_eq!(rows(&app), was);
991+ assert!(said(&mut app).ends_with("? help"), "{}", said(&mut app));
992+ // nor, while typing, is a save said to have made way for it
993+ app.set_edit_keys(EditKeys::Normal);
994+ app.handle_key(key(KeyCode::Char('e')));
995+ app.handle_key(key(KeyCode::End));
996+ app.handle_key(key(KeyCode::Char('!')));
997+ let editing = said(&mut app);
998+ std::fs::write(d.path().join("notes.sync-conflict-20260927-110000-LAPTOP.md"), "theirs too\n").unwrap();
999+ until_reload(&mut app);
1000+ assert_eq!(app.mode_pub(), "edit");
1001+ let s = said(&mut app);
1002+ assert!(!s.contains("changed outside") && !s.contains("typing saved"), "{}", s);
1003+ assert_eq!(s, editing);
1004+}
1005+
1006+#[test]
1007+fn a_copy_merged_at_startup_without_a_pair_is_announced_as_what_came_in() {
1008+ let d = vault("# NAS\n\n- disks\n\n# Inbox\n\n- [ ] a\n");
1009+ std::fs::write(
1010+ d.path().join("root.sync-conflict-20260927-100000-PHONE.md"),
1011+ "# NAS\n\n- disks\n\n# Inbox\n\n- [ ] a\n- [ ] from phone\n",
1012+ )
1013+ .unwrap();
1014+ let mut app = start(&d);
1015+ assert!(app.vault_conflict_files().unwrap().is_empty(), "the copy was not merged");
1016+ assert!(app.rows().iter().any(|r| app.title_of(r.nref) == "from phone"));
1017+ let s = status_line(&mut app);
1018+ assert!(s.contains("↻ changed outside fold: Inbox (+1 item)"), "{}", s);
1019+ assert!(!s.contains("sync-conflict") && !s.contains("pair"), "{}", s);
1020+ assert_eq!(idle(&mut app, 1000), 0);
1021+}
1022+
1023+/// A file as Syncthing puts one in the vault: written to a dot-temp file,
1024+/// then renamed into place.
1025+fn land(dir: &std::path::Path, name: &str, text: &str) {
1026+ let tmp = dir.join(format!(".syncthing.{}.tmp", name));
1027+ std::fs::write(&tmp, text).unwrap();
1028+ std::fs::rename(&tmp, dir.join(name)).unwrap();
1029+}
1030+
1031+#[test]
1032+fn a_copy_that_lands_while_the_one_before_is_merged_is_merged_next() {
1033+ // Syncthing delivers a batch of copies a moment apart: one that lands
1034+ // while a reload merges the one before is new to the next (§11.2),
1035+ // not seen with it. A big vault makes the reload long enough to land in
1036+ let backlog: String = (0..5000).map(|i| format!("- [ ] backlog {}\n", i)).collect();
1037+ let mut text = format!("# Inbox\n\n- [ ] a\n\n# Backlog\n\n{}\n# Blocks\n\n", backlog);
1038+ let d = vault("");
1039+ for i in 0..500u64 {
1040+ let id = fold_core::ident::Id::from_bytes((i + 1).to_be_bytes());
1041+ let name = fold_core::ident::filename(id.as_str(), &format!("block-{}", i));
1042+ std::fs::write(d.path().join(name), format!("---\nid: {}\n---\n\n- [ ] block {}\n", id, i)).unwrap();
1043+ text.push_str(&format!("![[{}]]\n", id));
1044+ }
1045+ std::fs::write(d.path().join("root.md"), &text).unwrap();
1046+ let mut app = start(&d);
1047+ idle(&mut app, 300);
1048+ let phone = "root.sync-conflict-20260927-120000-PHONE.md";
1049+ land(d.path(), phone, &text.replace("- [ ] a\n", "- [ ] a\n- [ ] from A\n"));
1050+ // the tablet's copy lands as soon as the merge has taken the phone's
1051+ let tablet = {
1052+ let dir = d.path().to_path_buf();
1053+ let text = text.replace("- [ ] a\n", "- [ ] a\n- [ ] from B\n");
1054+ std::thread::spawn(move || {
1055+ let end = Instant::now() + Duration::from_secs(10);
1056+ while dir.join(phone).exists() && Instant::now() < end {
1057+ std::thread::yield_now();
1058+ }
1059+ land(&dir, "root.sync-conflict-20260927-120005-TABLET.md", &text);
1060+ })
1061+ };
1062+ until_reload(&mut app);
1063+ tablet.join().unwrap();
1064+ assert!(root(&d).contains("- [ ] from A\n"), "the phone's copy was not merged");
1065+ let end = Instant::now() + Duration::from_secs(5);
1066+ while !app.vault_conflict_files().unwrap().is_empty() && Instant::now() < end {
1067+ std::thread::sleep(Duration::from_millis(50));
1068+ app.tick();
1069+ }
1070+ assert!(app.vault_conflict_files().unwrap().is_empty(), "the tablet's copy waits unmerged");
1071+ assert!(root(&d).contains("- [ ] from B\n"), "{}", &root(&d)[..60]);
1072+ assert!(app.rows().iter().any(|r| app.title_of(r.nref) == "from B"));
1073+}
1074+
1075+// ------------------------------------------------------------ hiding done
1076+
1077+/// Two lists with done tasks above the cursor's rows, as in the sample
1078+/// vault.
1079+const TASKS: &str = "# NAS\n\n- [x] Replace fan\n- [ ] Snapshot policy\n\n# Networking\n\n- [ ] Label the cables\n - [x] patch panel\n - [ ] rack\n\n# Atlas\n\nQuarterly planning notes.\nDeadline is end of month.\n\n- [ ] Draft the RFC\n- [ ] Review PR\n- [x] Kickoff meeting\n\n# Reading list\n\n- The Rust Book\n";
1080+
1081+fn press(app: &mut App, keys: &str) {
1082+ for c in keys.chars() {
1083+ app.handle_key(key(KeyCode::Char(c)));
1084+ }
1085+}
1086+
1087+/// The selected node's title.
1088+fn selected(app: &App) -> String {
1089+ app.current().map(|r| app.title_of(r)).unwrap_or_default()
1090+}
1091+
1092+/// Put the cursor on the row titled `title`.
1093+fn select(app: &mut App, title: &str) {
1094+ app.cursor = app.rows().iter().position(|r| app.title_of(r.nref) == title).unwrap();
1095+}
1096+
1097+fn draw(app: &mut App) {
1098+ let mut t = Terminal::new(TestBackend::new(120, 32)).unwrap();
1099+ t.draw(|f| app.draw(f)).unwrap();
1100+}
1101+
1102+/// Draw, then click the button the frame drew for `a`.
1103+fn click_button(app: &mut App, a: Action) {
1104+ draw(app);
1105+ let (x, y) = app.button_pos(a).unwrap_or_else(|| panic!("no button {:?}", a));
1106+ app.handle_mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column: x, row: y, modifiers: KeyModifiers::NONE });
1107+}
1108+
1109+#[test]
1110+fn hiding_and_showing_done_keeps_the_selected_node() {
1111+ let d = vault(TASKS);
1112+ let mut app = App::new(d.path()).unwrap();
1113+ select(&mut app, "Draft the RFC");
1114+ // two done tasks above it go: the row number changes, the node does not
1115+ press(&mut app, "zd");
1116+ assert_eq!(selected(&app), "Draft the RFC");
1117+ press(&mut app, "zd");
1118+ assert_eq!(selected(&app), "Draft the RFC");
1119+ // the next verb acts on it
1120+ press(&mut app, "x");
1121+ assert!(root(&d).contains("- [x] Draft the RFC"), "{}", root(&d));
1122+}
1123+
1124+#[test]
1125+fn hiding_the_selected_done_task_selects_its_next_shown_sibling() {
1126+ let d = vault(TASKS);
1127+ let mut app = App::new(d.path()).unwrap();
1128+ select(&mut app, "patch panel");
1129+ press(&mut app, "zd");
1130+ assert_eq!(selected(&app), "rack");
1131+ press(&mut app, "zd");
1132+ select(&mut app, "Replace fan");
1133+ press(&mut app, "zd");
1134+ assert_eq!(selected(&app), "Snapshot policy");
1135+ // the last one of its list: the row above
1136+ press(&mut app, "zd");
1137+ select(&mut app, "Kickoff meeting");
1138+ press(&mut app, "zd");
1139+ assert_eq!(selected(&app), "Review PR");
1140+}
1141+
1142+#[test]
1143+fn hiding_done_skips_done_siblings_and_leaves_a_done_task_s_children() {
1144+ let d = vault("# A\n\n- [x] Order parts\n - [ ] check fan model\n- [x] Call shop\n- [ ] Fit it\n- [ ] Test it\n");
1145+ let mut app = App::new(d.path()).unwrap();
1146+ // under a done task, it goes with it: the task's next shown sibling
1147+ select(&mut app, "check fan model");
1148+ press(&mut app, "zd");
1149+ assert_eq!(selected(&app), "Fit it");
1150+ press(&mut app, "zd");
1151+ select(&mut app, "Order parts");
1152+ press(&mut app, "zd");
1153+ assert_eq!(selected(&app), "Fit it");
1154+}
1155+
1156+#[test]
1157+fn hiding_done_from_the_palette_or_the_status_bar_keeps_the_selected_node() {
1158+ let d = vault(TASKS);
1159+ let mut app = App::new(d.path()).unwrap();
1160+ select(&mut app, "Draft the RFC");
1161+ press(&mut app, ":hide");
1162+ app.handle_key(key(KeyCode::Enter));
1163+ assert!(app.action_on(Action::HideDone).unwrap());
1164+ assert_eq!(selected(&app), "Draft the RFC");
1165+ // *done hidden* in the status bar shows them again
1166+ click_button(&mut app, Action::HideDone);
1167+ assert!(!app.action_on(Action::HideDone).unwrap());
1168+ assert_eq!(selected(&app), "Draft the RFC");
1169+}
1170+
1171+#[test]
1172+fn hiding_done_keeps_the_reading_pane_on_its_node() {
1173+ let d = vault(TASKS);
1174+ let mut app = App::new(d.path()).unwrap();
1175+ app.show_reading = true;
1176+ select(&mut app, "Atlas");
1177+ app.handle_key(key(KeyCode::Tab));
1178+ press(&mut app, "jj");
1179+ draw(&mut app);
1180+ let at = app.read_cursor_pub();
1181+ assert!(at > 0);
1182+ press(&mut app, "zd");
1183+ draw(&mut app);
1184+ assert_eq!(selected(&app), "Atlas");
1185+ assert_eq!(app.read_cursor_pub(), at, "the reading cursor went back to the top");
1186+}
1187+
1188+// ------------------------------------------------------------ out of view
1189+
1190+/// A list longer than the screen, as in the report: `# Tasks` and 80
1191+/// tasks under it.
1192+fn long_list() -> String {
1193+ let tasks: String = (1..=80).map(|i| format!("- [ ] task number {}\n", i)).collect();
1194+ format!("# Tasks\n\n{}", tasks)
1195+}
1196+
1197+fn has_line(d: &tempfile::TempDir, line: &str) -> bool {
1198+ root(d).lines().any(|l| l == line)
1199+}
1200+
1201+/// Draw a frame, as the app does after every event: the screen's text.
1202+fn screen(app: &mut App) -> String {
1203+ let (w, h) = (120, 32);
1204+ let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
1205+ t.draw(|f| app.draw(f)).unwrap();
1206+ let b = t.backend().buffer().clone();
1207+ (0..h).map(|y| (0..w).map(|x| b[(x, y)].symbol()).collect::<String>() + "\n").collect()
1208+}
1209+
1210+/// The status bar, as the next frame draws it.
1211+fn status(app: &mut App) -> String {
1212+ screen(app).lines().last().unwrap_or_default().to_string()
1213+}
1214+
1215+/// Turn the wheel over what the last frame drew for `hit`, `notches`
1216+/// times down (up if negative), a frame after each.
1217+fn wheel(app: &mut App, hit: Hit, notches: i32) {
1218+ let kind = if notches > 0 { MouseEventKind::ScrollDown } else { MouseEventKind::ScrollUp };
1219+ for _ in 0..notches.abs() {
1220+ screen(app);
1221+ let (x, y) = app.hit_pos(hit).unwrap_or_else(|| panic!("nothing drawn for {:?}", hit));
1222+ app.handle_mouse(MouseEvent { kind, column: x, row: y, modifiers: KeyModifiers::NONE });
1223+ }
1224+ screen(app);
1225+}
1226+
1227+/// Whether the last frame drew outline row `i`.
1228+fn shown(app: &App, i: usize) -> bool {
1229+ app.hit_pos(Hit::Row(i)).is_some()
1230+}
1231+
1232+#[test]
1233+fn a_key_that_changes_a_selection_out_of_view_shows_it_and_waits_for_a_second_press() {
1234+ let d = vault(&long_list());
1235+ let mut app = App::new(d.path()).unwrap();
1236+ press(&mut app, "jj");
1237+ assert_eq!(selected(&app), "task number 2");
1238+ wheel(&mut app, Hit::OutlinePane, 10);
1239+ assert!(!shown(&app, 2));
1240+ // the first x shows it and says what a second would do
1241+ press(&mut app, "x");
1242+ assert!(has_line(&d, "- [ ] task number 2"), "checked out of view");
1243+ let s = status(&mut app);
1244+ assert!(shown(&app, 2), "not brought into view");
1245+ assert!(s.contains("press x again to mark “task number 2” done"), "{}", s);
1246+ press(&mut app, "x");
1247+ assert!(has_line(&d, "- [x] task number 2"));
1248+ // d likewise: nothing trashed until it is in view
1249+ wheel(&mut app, Hit::OutlinePane, 10);
1250+ press(&mut app, "d");
1251+ assert!(has_line(&d, "- [x] task number 2"), "deleted out of view");
1252+ let s = status(&mut app);
1253+ assert!(s.contains("press d again to delete “task number 2”"), "{}", s);
1254+ press(&mut app, "d");
1255+ assert!(!root(&d).contains("task number 2\n"), "{}", root(&d));
1256+}
1257+
1258+#[test]
1259+fn a_selection_brought_into_view_sits_a_third_of_the_way_down_until_the_wheel_moves_it_again() {
1260+ let d = vault(&long_list());
1261+ let mut app = App::new(d.path()).unwrap();
1262+ select(&mut app, "task number 40");
1263+ wheel(&mut app, Hit::OutlinePane, -10);
1264+ assert!(!shown(&app, 40));
1265+ press(&mut app, "t");
1266+ assert!(has_line(&d, "- [ ] task number 40"));
1267+ let s = screen(&mut app);
1268+ let top = s.lines().position(|l| l.contains("Outline")).unwrap();
1269+ let at = s.lines().position(|l| l.contains("task number 40 ")).expect("not brought into view");
1270+ let (view, down) = (32 - 4, at - top - 1);
1271+ assert!(down >= view / 4 && down <= view * 2 / 5, "{} rows down of {}", down, view);
1272+ // the wheel takes it out of view again: the next t is a first press
1273+ wheel(&mut app, Hit::OutlinePane, -10);
1274+ press(&mut app, "t");
1275+ assert!(has_line(&d, "- [ ] task number 40"), "changed out of view");
1276+ press(&mut app, "t");
1277+ assert!(has_line(&d, "- task number 40"), "{}", root(&d));
1278+ // za, a two-key verb, too
1279+ wheel(&mut app, Hit::OutlinePane, -10);
1280+ press(&mut app, "za");
1281+ assert!(has_line(&d, "- task number 40"), "archived out of view");
1282+ let s = status(&mut app);
1283+ assert!(s.contains("press za again to archive “task number 40”"), "{}", s);
1284+ press(&mut app, "za");
1285+ assert!(root(&d).contains("# Archive"), "{}", root(&d));
1286+}
1287+
1288+#[test]
1289+fn a_key_that_changes_nothing_shows_a_selection_out_of_view_and_acts_at_once() {
1290+ let d = vault(&long_list());
1291+ let mut app = App::new(d.path()).unwrap();
1292+ press(&mut app, "jj");
1293+ wheel(&mut app, Hit::OutlinePane, 10);
1294+ press(&mut app, "m");
1295+ screen(&mut app);
1296+ assert!(app.hit_pos(Hit::MenuItem(0)).is_some(), "no node menu");
1297+ assert!(shown(&app, 2));
1298+ app.handle_key(key(KeyCode::Esc));
1299+ wheel(&mut app, Hit::OutlinePane, 10);
1300+ press(&mut app, "e");
1301+ assert_eq!(app.mode_pub(), "edit");
1302+ screen(&mut app);
1303+ assert!(shown(&app, 2));
1304+}
1305+
1306+#[test]
1307+fn the_reading_pane_shows_its_cursor_line_before_changing_it() {
1308+ let prose: String = (1..=60).map(|i| format!("line {} of the notes\n", i)).collect();
1309+ let d = vault(&format!("# Notes\n\n{}\n- [ ] buried task\n", prose));
1310+ let mut app = App::new(d.path()).unwrap();
1311+ app.show_reading = true;
1312+ screen(&mut app);
1313+ app.handle_key(key(KeyCode::Tab));
1314+ press(&mut app, "G");
1315+ let task = app.read_cursor_pub();
1316+ assert!(app.reading_doc_pub().lines[task].contains("buried task"));
1317+ wheel(&mut app, Hit::ReadingPane, -10);
1318+ assert!(app.hit_pos(Hit::DocLine(task)).is_none());
1319+ press(&mut app, "x");
1320+ assert!(has_line(&d, "- [ ] buried task"), "checked out of view");
1321+ let s = status(&mut app);
1322+ assert!(app.hit_pos(Hit::DocLine(task)).is_some(), "not brought into view");
1323+ assert!(s.contains("press x again to mark “buried task” done"), "{}", s);
1324+ press(&mut app, "x");
1325+ assert!(has_line(&d, "- [x] buried task"));
1326+ // Enter on a task toggles it: the same
1327+ wheel(&mut app, Hit::ReadingPane, -10);
1328+ app.handle_key(key(KeyCode::Enter));
1329+ assert!(has_line(&d, "- [x] buried task"), "reopened out of view");
1330+ let s = status(&mut app);
1331+ assert!(s.contains("press Enter again to reopen “buried task”"), "{}", s);
1332+ app.handle_key(key(KeyCode::Enter));
1333+ assert!(has_line(&d, "- [ ] buried task"));
1334+ assert!(!status(&mut app).contains("again"), "the words outlived the verb");
1335+}
1336+
1337+#[test]
1338+fn a_held_verb_s_words_go_once_it_acts_or_the_selection_moves() {
1339+ let d = vault(&long_list());
1340+ let mut app = App::new(d.path()).unwrap();
1341+ press(&mut app, "jj");
1342+ wheel(&mut app, Hit::OutlinePane, 10);
1343+ // J says nothing when it moves a node: the words must not stay
1344+ press(&mut app, "J");
1345+ let s = status(&mut app);
1346+ assert!(s.contains("press J again to move “task number 2” down"), "{}", s);
1347+ press(&mut app, "J");
1348+ assert!(root(&d).contains("- [ ] task number 3\n- [ ] task number 2\n"), "{}", root(&d));
1349+ let s = status(&mut app);
1350+ assert!(!s.contains("again"), "{}", s);
1351+ // a click selects another node: the words named the one before
1352+ wheel(&mut app, Hit::OutlinePane, 10);
1353+ press(&mut app, "d");
1354+ assert!(status(&mut app).contains("press d again"));
1355+ let (x, y) = app.hit_pos(Hit::Row(10)).unwrap();
1356+ for kind in [MouseEventKind::Down(MouseButton::Left), MouseEventKind::Up(MouseButton::Left)] {
1357+ app.handle_mouse(MouseEvent { kind, column: x, row: y, modifiers: KeyModifiers::NONE });
1358+ }
1359+ let s = status(&mut app);
1360+ assert!(!s.contains("again"), "{}", s);
1361+}
1362+
1363+#[test]
1364+fn the_pointer_acts_on_a_selection_out_of_view_at_once() {
1365+ let d = vault(&long_list());
1366+ let mut app = App::new(d.path()).unwrap();
1367+ app.show_reading = true;
1368+ press(&mut app, "jj");
1369+ wheel(&mut app, Hit::OutlinePane, 10);
1370+ assert!(!shown(&app, 2));
1371+ // the reading pane's ⋯ is the selected node's menu
1372+ click_button(&mut app, Action::NodeMenu);
1373+ screen(&mut app);
1374+ let (x, y) = app.hit_pos(Hit::MenuItem(node_menu_index(Action::Delete))).unwrap();
1375+ app.handle_mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column: x, row: y, modifiers: KeyModifiers::NONE });
1376+ assert!(!root(&d).contains("task number 2\n"), "{}", root(&d));
1377+}
1378+
1379+/// The status bar at 80×24, as the next frame draws it.
1380+fn status80(app: &mut App) -> String {
1381+ let mut t = Terminal::new(TestBackend::new(80, 24)).unwrap();
1382+ t.draw(|f| app.draw(f)).unwrap();
1383+ let b = t.backend().buffer().clone();
1384+ (0..80).map(|x| b[(x, 23)].symbol()).collect()
1385+}
1386+
1387+/// Turn the wheel down over the outline at 80×24, `notches` times.
1388+fn wheel80(app: &mut App, notches: usize) {
1389+ for _ in 0..notches {
1390+ status80(app);
1391+ let (x, y) = app.hit_pos(Hit::OutlinePane).unwrap();
1392+ app.handle_mouse(MouseEvent { kind: MouseEventKind::ScrollDown, column: x, row: y, modifiers: KeyModifiers::NONE });
1393+ }
1394+ status80(app);
1395+}
1396+
1397+#[test]
1398+fn a_held_verb_s_words_keep_the_key_and_what_it_does_at_80_columns() {
1399+ // a title too long for the bar gives way, not the words after it
1400+ let title = "Replace the flaky switch in the closet before Friday";
1401+ let d = vault(&long_list().replace("task number 2\n", &format!("{}\n", title)));
1402+ let mut app = App::new(d.path()).unwrap();
1403+ select(&mut app, title);
1404+ wheel80(&mut app, 10);
1405+ press(&mut app, "x");
1406+ let s = status80(&mut app);
1407+ assert!(s.starts_with(" press x again to mark “Replace the flaky"), "{}", s);
1408+ assert!(s.contains("…” done "), "{}", s);
1409+ press(&mut app, "x");
1410+ assert!(has_line(&d, &format!("- [x] {}", title)), "{}", root(&d));
1411+ // with the ⚠ count and done hidden on the right, a short title still fits
1412+ let text = format!("# Networking\n\n- [ ] Label the cables\n - [x] patch panel\n - [ ] rack\n\n{}", long_list());
1413+ let d = vault(&text);
1414+ std::fs::write(d.path().join("root.sync-conflict-20260927-100000-PHONE.md"), text.replace("[ ] task number 80", "[x] task number 80")).unwrap();
1415+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
1416+ fold_core::merge::merge_sync_conflicts(&mut v, false).unwrap();
1417+ drop(v);
1418+ let mut app = App::new(d.path()).unwrap();
1419+ select(&mut app, "rack");
1420+ press(&mut app, "zd");
1421+ wheel80(&mut app, 10);
1422+ press(&mut app, "d");
1423+ let s = status80(&mut app);
1424+ assert!(s.contains(" ⚠ 1 conflict done hidden "), "{}", s);
1425+ assert!(s.starts_with(" press d again to delete “rack” "), "{}", s);
1426+ press(&mut app, "d");
1427+ assert!(!root(&d).contains("rack"), "{}", root(&d));
1428+}
1429+
1430+// ------------------------------------------------------------ unsaved text
1431+
1432+/// The trash entries for editor text on `title`'s node that fold could
1433+/// not save (§11.5), `<timestamp>-unsaved-<title>.md`, as the returned
1434+/// closure finds them: those made after this call, since the trash
1435+/// outlives a test run.
1436+fn kept(title: &str) -> impl Fn() -> Vec<std::path::PathBuf> {
1437+ let suffix = format!("-unsaved-{}.md", title);
1438+ let entries = move || {
1439+ let mut out: Vec<_> = std::fs::read_dir(fold_core::vault::trash_dir())
1440+ .map(|rd| rd.filter_map(|e| e.ok()).map(|e| e.path()).collect())
1441+ .unwrap_or_default();
1442+ out.retain(|p: &std::path::PathBuf| p.file_name().is_some_and(|n| n.to_string_lossy().ends_with(&suffix)));
1443+ out
1444+ };
1445+ let before = entries();
1446+ move || entries().into_iter().filter(|p| !before.contains(p)).collect()
1447+}
1448+
1449+/// The editor open on the vault's first node, `typed` typed at the end of
1450+/// its title line.
1451+fn typing(d: &tempfile::TempDir, typed: &str) -> App {
1452+ let mut app = App::new(d.path()).unwrap();
1453+ app.set_edit_keys(EditKeys::Normal);
1454+ press(&mut app, "e");
1455+ app.handle_key(key(KeyCode::End));
1456+ press(&mut app, typed);
1457+ app
1458+}
1459+
1460+#[test]
1461+fn reverting_text_a_save_was_refused_for_keeps_a_copy_in_the_trash() {
1462+ let d = vault("# Rotation schedule\n\nkeep 24\n");
1463+ let kept = kept("rotation-schedule");
1464+ let mut app = typing(&d, " hourly");
1465+ // another program changes the node: the save is refused, and the
1466+ // editor stays open with the text
1467+ std::fs::write(d.path().join("root.md"), "# Rotation schedule\n\nkeep 48\n").unwrap();
1468+ app.handle_key(key(KeyCode::Esc));
1469+ assert_eq!(app.mode_pub(), "edit");
1470+ // :q! leaves it, and the text goes to the trash first
1471+ app.handle_key(ctrl('e'));
1472+ press(&mut app, "q!");
1473+ app.handle_key(key(KeyCode::Enter));
1474+ assert_eq!(app.mode_pub(), "normal");
1475+ assert_eq!(root(&d), "# Rotation schedule\n\nkeep 48\n");
1476+ let copies = kept();
1477+ assert_eq!(copies.len(), 1, "{:?}", copies);
1478+ assert_eq!(std::fs::read_to_string(&copies[0]).unwrap(), "# Rotation schedule hourly\n\nkeep 24\n");
1479+ // the status line says where
1480+ let name = copies[0].file_name().unwrap().to_string_lossy().to_string();
1481+ let s = status(&mut app);
1482+ assert!(s.contains(&format!("changes discarded; a copy is in the trash: {}", name)), "{}", s);
1483+}
1484+
1485+#[test]
1486+fn reverting_text_no_save_refused_keeps_no_copy() {
1487+ let d = vault("# Retention window\n\nkeep 24\n");
1488+ let kept = kept("retention-window");
1489+ let mut app = typing(&d, " days");
1490+ app.run_action(Action::EditRevert);
1491+ assert_eq!(app.mode_pub(), "normal");
1492+ assert_eq!(root(&d), "# Retention window\n\nkeep 24\n");
1493+ assert!(kept().is_empty());
1494+ let s = status(&mut app);
1495+ assert!(s.contains("changes discarded") && !s.contains("trash"), "{}", s);
1496+}
1497+
1498+#[test]
1499+fn ending_fold_while_editing_saves_the_editor() {
1500+ // a signal, an error or a crash: the editor is saved as on a quit
1501+ let d = vault("# Offsite copy\n\nnightly\n");
1502+ let kept = kept("offsite-copy");
1503+ let mut app = typing(&d, " to B2");
1504+ assert_eq!(app.keep_unsaved(), None);
1505+ assert_eq!(root(&d), "# Offsite copy to B2\n\nnightly\n");
1506+ assert!(kept().is_empty());
1507+ // nothing unsaved, nothing to keep
1508+ assert_eq!(app.keep_unsaved(), None);
1509+}
1510+
1511+#[test]
1512+fn ending_fold_while_editing_deletes_a_cut_block_the_autosave_left_in_transit() {
1513+ // §5.2: a block cut and not pasted back is deleted on any end of the
1514+ // app, even once the autosave has left nothing unsaved
1515+ let d = vault("# A\n\n- one\n- task\n- two\n");
1516+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
1517+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
1518+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
1519+ let block = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
1520+ drop(v);
1521+ let kept = kept("a");
1522+ let mut app = start(&d);
1523+ app.set_edit_keys(EditKeys::Normal);
1524+ app.handle_key(key(KeyCode::Char('e')));
1525+ for _ in 0..3 {
1526+ app.handle_key(key(KeyCode::Down));
1527+ }
1528+ app.handle_key(ctrl('k'));
1529+ idle(&mut app, 1000);
1530+ assert_eq!(root(&d), "# A\n\n- one\n- two\n");
1531+ assert!(block.exists());
1532+ assert_eq!(app.keep_unsaved(), None);
1533+ assert!(!block.exists(), "the cut block outlived the app");
1534+ assert_eq!(root(&d), "# A\n\n- one\n- two\n");
1535+ assert!(kept().is_empty());
1536+}
1537+
1538+#[test]
1539+fn ending_fold_with_text_a_save_was_refused_for_keeps_it_in_the_trash() {
1540+ let d = vault("# Scrub cadence\n\n- monthly\n- [ ] scrub now\n");
1541+ let kept = kept("scrub-cadence");
1542+ let mut app = typing(&d, " weekly");
1543+ std::fs::write(d.path().join("root.md"), "# Scrub cadence\n\n- monthly, from the phone\n- [ ] scrub now\n").unwrap();
1544+ let words = app.keep_unsaved().expect("nothing kept");
1545+ let copies = kept();
1546+ assert_eq!(copies.len(), 1, "{:?}", copies);
1547+ assert_eq!(words, format!("unsaved text kept in {}", copies[0].display()));
1548+ // the editor's whole text, as it was
1549+ assert_eq!(std::fs::read_to_string(&copies[0]).unwrap(), "# Scrub cadence weekly\n\n- monthly\n- [ ] scrub now\n");
1550+ // and the other program's change stays
1551+ assert_eq!(root(&d), "# Scrub cadence\n\n- monthly, from the phone\n- [ ] scrub now\n");
1552+}
1553+
1554+#[test]
1555+fn a_panic_in_the_autosave_keeps_the_editor_s_text_in_the_trash() {
1556+ // a bug that panics where the editor saves (§10.6): `run` catches it,
1557+ // and the text the save was writing is kept as on any other end
1558+ let d = vault("# Snapshot rotation\n\nkeep 24\n");
1559+ let kept = kept("snapshot-rotation");
1560+ let mut app = typing(&d, " hourly");
1561+ app.panic_in_saves();
1562+ std::thread::sleep(Duration::from_millis(800));
1563+ let tick = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| app.tick()));
1564+ assert!(tick.is_err(), "the autosave did not panic");
1565+ let words = app.keep_unsaved().expect("nothing kept");
1566+ let copies = kept();
1567+ assert_eq!(copies.len(), 1, "{:?}", copies);
1568+ assert_eq!(words, format!("unsaved text kept in {}", copies[0].display()));
1569+ assert_eq!(std::fs::read_to_string(&copies[0]).unwrap(), "# Snapshot rotation hourly\n\nkeep 24\n");
1570+ assert_eq!(root(&d), "# Snapshot rotation\n\nkeep 24\n");
1571+}
1572+
1573+#[test]
1574+fn a_panic_in_the_save_on_leaving_a_block_keeps_the_editor_s_text_in_the_trash() {
1575+ // moving to another block saves the one left (§10.6): the same
1576+ let d = vault("# Scrub plan\n\n- one\n- task\n- two\n");
1577+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
1578+ let t = v.find_by_path(&["Scrub plan".into(), "task".into()]).unwrap();
1579+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
1580+ let block = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
1581+ let before = std::fs::read_to_string(&block).unwrap();
1582+ drop(v);
1583+ let kept = kept("scrub-plan");
1584+ let mut app = App::new(d.path()).unwrap();
1585+ app.set_edit_keys(EditKeys::Normal);
1586+ press(&mut app, "e");
1587+ for _ in 0..3 {
1588+ app.handle_key(key(KeyCode::Down));
1589+ }
1590+ app.handle_key(key(KeyCode::End));
1591+ press(&mut app, " weekly");
1592+ app.panic_in_saves();
1593+ let up = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| app.handle_key(key(KeyCode::Up))));
1594+ assert!(up.is_err(), "leaving the block did not save it");
1595+ let words = app.keep_unsaved().expect("nothing kept");
1596+ let copies = kept();
1597+ assert_eq!(copies.len(), 1, "{:?}", copies);
1598+ assert_eq!(words, format!("unsaved text kept in {}", copies[0].display()));
1599+ assert_eq!(std::fs::read_to_string(&copies[0]).unwrap(), "# Scrub plan\n\n- one\n- task weekly\n- two\n");
1600+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before);
1601+}
1602+
1603+/// Run the test named `test` again in a process of its own, with a trash
1604+/// nothing can be written to: a plain file where its directory would be
1605+/// (§11.5). The trash is the process's, where `$XDG_STATE_HOME` says, so
1606+/// the other tests keep theirs. True in that process, where the test goes
1607+/// on; false in this one, once it has passed there.
1608+fn with_trash_unwritable(test: &str) -> bool {
1609+ if std::env::var_os("FOLD_TEST_TRASH_UNWRITABLE").is_some() {
1610+ return true;
1611+ }
1612+ let state = tempfile::tempdir().unwrap();
1613+ std::fs::create_dir(state.path().join("fold")).unwrap();
1614+ std::fs::write(state.path().join("fold").join("trash"), "").unwrap();
1615+ let out = std::process::Command::new(std::env::current_exe().unwrap())
1616+ .args([test, "--exact", "--nocapture"])
1617+ .env("XDG_STATE_HOME", state.path())
1618+ .env("FOLD_TEST_TRASH_UNWRITABLE", "1")
1619+ .output()
1620+ .unwrap();
1621+ let said = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
1622+ assert!(out.status.success() && said.contains("1 passed"), "{}", said);
1623+ false
1624+}
1625+
1626+#[test]
1627+fn reverting_text_the_trash_cannot_take_drops_it_when_asked_again() {
1628+ if !with_trash_unwritable("reverting_text_the_trash_cannot_take_drops_it_when_asked_again") {
1629+ return;
1630+ }
1631+ let d = vault("# Rotation schedule\n\nkeep 24\n");
1632+ let mut app = typing(&d, " hourly");
1633+ std::fs::write(d.path().join("root.md"), "# Rotation schedule\n\nkeep 48\n").unwrap();
1634+ app.handle_key(key(KeyCode::Esc));
1635+ assert_eq!(app.mode_pub(), "edit");
1636+ // :q! finds no trash to copy the text to: it drops nothing, and says
1637+ // what does
1638+ let revert = |app: &mut App| {
1639+ app.handle_key(ctrl('e'));
1640+ press(app, "q!");
1641+ app.handle_key(key(KeyCode::Enter));
1642+ };
1643+ revert(&mut app);
1644+ assert_eq!(app.mode_pub(), "edit");
1645+ let s = status_line(&mut app);
1646+ assert!(s.contains("can't copy to the trash; Revert (:q!) again drops the changes — File exists (os error 17)"), "{}", s);
1647+ // text typed since is new to it: that is asked about again
1648+ press(&mut app, " and");
1649+ app.run_action(Action::EditRevert);
1650+ assert_eq!(app.mode_pub(), "edit");
1651+ assert!(status_line(&mut app).contains("can't copy to the trash"));
1652+ // asked again, it drops them
1653+ revert(&mut app);
1654+ assert_eq!(app.mode_pub(), "normal");
1655+ assert!(status_line(&mut app).contains("changes discarded; no copy kept"));
1656+ assert_eq!(root(&d), "# Rotation schedule\n\nkeep 48\n");
1657+ // and a quit ends fold
1658+ app.run_action(Action::Quit);
1659+ assert!(app.quit_requested() && app.mode_pub() == "normal");
1660+}
1661+
1662+// ------------------------------------------------------------ sync conflicts
1663+
1664+/// Conflict block ids whose files list first and last: a merge names its
1665+/// blocks by fresh ids, and pairs are found in their blocks' files' order,
1666+/// where the view lists them in the outline's (§12.5).
1667+const FIRST: &str = "bacbec-bacbec-bacbec-bacbec";
1668+const LAST: &str = "worzod-worzod-worzod-worzod";
1669+
1670+/// A small homelab vault. With `old`, a pair left from an earlier sync
1671+/// conflict (§12.5) on the flaky switch, its conflict block under that id.
1672+fn homelab(old: Option<&str>) -> tempfile::TempDir {
1673+ let embed = old.map(|id| format!("![[{}]]\n", id)).unwrap_or_default();
1674+ let d = vault(&format!(
1675+ "# Homelab\n\n## NAS\n\nMirrored pairs.\n\n## Networking\n\n- [ ] Replace the flaky switch\n{}- [ ] Label the cables\n",
1676+ embed
1677+ ));
1678+ if let Some(id) = old {
1679+ let prefix = id.split('-').next().unwrap();
1680+ std::fs::write(
1681+ d.path().join(format!("{}~replace-the-flaky-switch.md", prefix)),
1682+ format!("---\nid: {}\nconflict: \"PHONE 20260926-090000\"\n---\n\n- [x] Replace the flaky switch\n", id),
1683+ )
1684+ .unwrap();
1685+ }
1686+ d
1687+}
1688+
1689+/// Syncthing's copy of root.md from a phone that changed NAS's text and
1690+/// checked the cables: two pairs once merged.
1691+fn phone_copy(d: &tempfile::TempDir) {
1692+ std::fs::write(
1693+ d.path().join("root.sync-conflict-20260927-100000-PHONE.md"),
1694+ "# Homelab\n\n## NAS\n\nMirrored pairs, no raidz.\n\n## Networking\n\n- [ ] Replace the flaky switch\n- [x] Label the cables\n",
1695+ )
1696+ .unwrap();
1697+}
1698+
1699+fn pairs(app: &mut App) -> usize {
1700+ fold_core::merge::conflict_pairs(app.vault_mut()).len()
1701+}
1702+
1703+/// Whether the status bar's ⚠ count is lit, as it is while pairs that came
1704+/// in as you worked wait to be seen.
1705+fn lit(app: &mut App) -> bool {
1706+ let (w, h) = (120, 32);
1707+ let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
1708+ t.draw(|f| app.draw(f)).unwrap();
1709+ let b = t.backend().buffer();
1710+ // the count is on the right; the message on the left may name it too
1711+ let badge = (0..w).rev().map(|x| &b[(x, h - 1)]).find(|c| c.symbol() == "⚠").expect("no ⚠ in the status bar");
1712+ badge.bg != b[(0, h - 1)].bg
1713+}
1714+
1715+#[test]
1716+fn a_sync_conflict_while_typing_leaves_the_editor_open_and_the_keys_in_it() {
1717+ let d = homelab(None);
1718+ let mut app = start(&d);
1719+ app.set_edit_keys(EditKeys::Normal);
1720+ select(&mut app, "NAS");
1721+ press(&mut app, "e");
1722+ app.handle_key(key(KeyCode::Down));
1723+ app.handle_key(key(KeyCode::Down));
1724+ app.handle_key(key(KeyCode::End));
1725+ // a key every 120 ms, the main loop running between them; the phone's
1726+ // copy lands mid-word
1727+ let type_slowly = |app: &mut App, text: &str| {
1728+ for c in text.chars() {
1729+ app.handle_key(key(KeyCode::Char(c)));
1730+ std::thread::sleep(Duration::from_millis(120));
1731+ app.tick();
1732+ }
1733+ };
1734+ type_slowly(&mut app, " bot");
1735+ phone_copy(&d);
1736+ type_slowly(&mut app, "tom note, both boot targets");
1737+ assert!(app.vault_conflict_files().unwrap().is_empty(), "the copy was not taken in");
1738+ // o, t, b and e went on typing: no pair was picked unseen
1739+ assert_eq!(app.mode_pub(), "edit");
1740+ assert_eq!(pairs(&mut app), 2);
1741+ let s = status_line(&mut app);
1742+ assert!(s.contains("sync conflicts in “NAS” and 1 more: click ⚠ to resolve"), "{}", s);
1743+ assert!(lit(&mut app));
1744+ app.handle_key(key(KeyCode::Esc));
1745+ assert!(has_line(&d, "Mirrored pairs. bottom note, both boot targets"), "{}", root(&d));
1746+ // the ⚠ opens the view on the first of them, and it takes o at once
1747+ assert!(lit(&mut app));
1748+ click_button(&mut app, Action::ResolveConflicts);
1749+ assert_eq!(app.mode_pub(), "conflict");
1750+ assert!(!lit(&mut app));
1751+ assert!(screen(&mut app).contains("Mirrored pairs, no raidz."));
1752+ press(&mut app, "o");
1753+ assert_eq!(pairs(&mut app), 1);
1754+}
1755+
1756+#[test]
1757+fn pairs_that_came_in_while_busy_open_once_the_outline_is_idle() {
1758+ let d = homelab(Some(FIRST));
1759+ let mut app = App::new(d.path()).unwrap();
1760+ assert_eq!(pairs(&mut app), 1);
1761+ press(&mut app, "j");
1762+ phone_copy(&d);
1763+ app.reload_external();
1764+ // a key a moment ago: the outline stays
1765+ assert_eq!(app.mode_pub(), "normal");
1766+ let s = status(&mut app);
1767+ assert!(s.contains("sync conflicts in “NAS” and 1 more: click ⚠ to resolve"), "{}", s);
1768+ assert!(lit(&mut app));
1769+ assert_eq!(idle(&mut app, 1500), 0);
1770+ assert_eq!(app.mode_pub(), "normal");
1771+ // two seconds without a key: the view opens on NAS, not on the pair
1772+ // left from before, whose block's file is listed first
1773+ idle(&mut app, 1000);
1774+ assert_eq!(app.mode_pub(), "conflict");
1775+ let s = screen(&mut app);
1776+ assert!(s.contains("Mirrored pairs, no raidz.") && !s.contains("flaky"), "{}", s);
1777+ assert!(!lit(&mut app));
1778+ // closed, it stays closed
1779+ app.handle_key(key(KeyCode::Esc));
1780+ std::thread::sleep(Duration::from_millis(2100));
1781+ app.tick();
1782+ assert_eq!(app.mode_pub(), "normal");
1783+}
1784+
1785+#[test]
1786+fn the_conflict_view_lists_its_pairs_in_outline_order_from_the_one_it_opens_on() {
1787+ // a pair left from before on the flaky switch, its block's file listed
1788+ // first; the phone's copy raises NAS, above it, and the cables, below
1789+ let d = homelab(Some(FIRST));
1790+ let mut app = App::new(d.path()).unwrap();
1791+ press(&mut app, "j");
1792+ phone_copy(&d);
1793+ app.reload_external();
1794+ assert_eq!(pairs(&mut app), 3);
1795+ // the ⚠ opens the view on NAS, the first new pair, and counts it first
1796+ click_button(&mut app, Action::ResolveConflicts);
1797+ assert_eq!(app.mode_pub(), "conflict");
1798+ let s = screen(&mut app);
1799+ assert!(s.contains("Conflict 1 of 3") && s.contains("no raidz"), "{}", s);
1800+ // n goes down the outline to the last pair, N back up
1801+ press(&mut app, "n");
1802+ let s = screen(&mut app);
1803+ assert!(s.contains("Conflict 2 of 3") && s.contains("flaky"), "{}", s);
1804+ press(&mut app, "n");
1805+ let s = screen(&mut app);
1806+ assert!(s.contains("Conflict 3 of 3") && s.contains("Label the cables"), "{}", s);
1807+ press(&mut app, "NN");
1808+ let s = screen(&mut app);
1809+ assert!(s.contains("Conflict 1 of 3") && s.contains("no raidz"), "{}", s);
1810+}
1811+
1812+#[test]
1813+fn where_the_bar_is_short_the_news_of_a_sync_conflict_takes_room_from_the_right_first() {
1814+ // 80×24, a key a moment ago: the lit ⚠ count beside it comes down to
1815+ // its number, and the news is whole
1816+ let d = homelab(None);
1817+ let mut app = App::new(d.path()).unwrap();
1818+ press(&mut app, "j");
1819+ phone_copy(&d);
1820+ app.reload_external();
1821+ assert_eq!(app.mode_pub(), "normal");
1822+ let s = status80(&mut app);
1823+ assert!(s.starts_with(" sync conflicts in “NAS” and 1 more: click ⚠ to resolve "), "{}", s);
1824+ assert!(s.ends_with(" ⚠ 2 root.md ✓ saved "), "{}", s);
1825+ // wider, the count says what it counts
1826+ let s = status(&mut app);
1827+ assert!(s.contains("sync conflicts in “NAS” and 1 more: click ⚠ to resolve "), "{}", s);
1828+ assert!(s.contains(" ⚠ 2 conflicts "), "{}", s);
1829+ // typing on NAS, with less room still: the file goes too
1830+ let d = homelab(None);
1831+ let mut app = App::new(d.path()).unwrap();
1832+ select(&mut app, "NAS");
1833+ press(&mut app, "e");
1834+ phone_copy(&d);
1835+ app.reload_external();
1836+ assert_eq!(app.mode_pub(), "edit");
1837+ let s = status80(&mut app);
1838+ assert!(s.starts_with(" EDIT sync conflicts in “NAS” and 1 more: click ⚠ to resolve "), "{}", s);
1839+ assert!(s.ends_with(" ⚠ 2 ✓ saved "), "{}", s);
1840+}
1841+
1842+#[test]
1843+fn news_of_a_sync_conflict_while_typing_says_what_to_do_and_no_more() {
1844+ // the typing on NAS is saved before the merge, as the save state on the
1845+ // right says; the news keeps to the pairs and what to do
1846+ let d = homelab(None);
1847+ let mut app = App::new(d.path()).unwrap();
1848+ app.set_edit_keys(EditKeys::Normal);
1849+ select(&mut app, "NAS");
1850+ press(&mut app, "e");
1851+ app.handle_key(key(KeyCode::Down));
1852+ app.handle_key(key(KeyCode::Down));
1853+ app.handle_key(key(KeyCode::End));
1854+ press(&mut app, " typed");
1855+ phone_copy(&d);
1856+ app.reload_external();
1857+ assert_eq!(app.mode_pub(), "edit");
1858+ assert!(has_line(&d, "Mirrored pairs. typed"), "{}", root(&d));
1859+ // 80×24: the right side gives way first, as with nothing typed
1860+ let s = status80(&mut app);
1861+ assert!(s.starts_with(" EDIT sync conflicts in “NAS” and 1 more: click ⚠ to resolve "), "{}", s);
1862+ assert!(s.ends_with(" ⚠ 2 ✓ saved "), "{}", s);
1863+ // 120 columns: whole, the name too
1864+ let s = status(&mut app);
1865+ assert!(s.starts_with(" EDIT sync conflicts in “NAS” and 1 more: click ⚠ to resolve "), "{}", s);
1866+ assert!(!s.contains("typing"), "{}", s);
1867+}
1868+
1869+#[test]
1870+fn at_80_columns_news_of_a_sync_conflict_in_the_editor_keeps_the_node_and_what_to_do() {
1871+ // 80×24 while editing: the right side gives way before the news does,
1872+ // the ⚠ count down to its number
1873+ let d = vault(SAMPLE);
1874+ let mut app = App::new(d.path()).unwrap();
1875+ select(&mut app, "NAS");
1876+ press(&mut app, "e");
1877+ std::fs::write(d.path().join("root.sync-conflict-20260927-100000-PHONE77.md"), SAMPLE.replace("no raidz.", "no raidz, one spare.")).unwrap();
1878+ app.reload_external();
1879+ assert_eq!(app.mode_pub(), "edit");
1880+ assert_eq!(pairs(&mut app), 1);
1881+ let s = status80(&mut app);
1882+ assert!(s.starts_with(" EDIT sync conflict in “NAS”: click ⚠ to resolve "), "{}", s);
1883+ assert!(s.ends_with(" ⚠ 1 root.md ✓ saved "), "{}", s);
1884+ // with room, the count says what it counts
1885+ assert!(status(&mut app).contains(" ⚠ 1 conflict root.md ✓ saved "), "{}", status(&mut app));
1886+}
1887+
1888+#[test]
1889+fn at_80_columns_news_of_a_sync_conflict_in_a_block_file_keeps_the_node_and_what_to_do() {
1890+ // the selection in a block file, whose name is long: the file gives way
1891+ // too, in the editor and in the outline after it
1892+ let d = vault(SAMPLE);
1893+ let mut app = App::new(d.path()).unwrap();
1894+ select(&mut app, "Project Atlas");
1895+ press(&mut app, "s");
1896+ let block = std::fs::read_dir(d.path())
1897+ .unwrap()
1898+ .map(|e| e.unwrap().path())
1899+ .find(|p| p.to_string_lossy().ends_with("~project-atlas.md"))
1900+ .unwrap();
1901+ let file = block.file_name().unwrap().to_string_lossy().into_owned();
1902+ let text = std::fs::read_to_string(&block).unwrap();
1903+ press(&mut app, "e");
1904+ std::fs::write(
1905+ d.path().join(file.replace(".md", ".sync-conflict-20260927-100000-PHONE77.md")),
1906+ text.replace("[ ] Draft", "[x] Draft"),
1907+ )
1908+ .unwrap();
1909+ app.reload_external();
1910+ assert_eq!(app.mode_pub(), "edit");
1911+ assert_eq!(pairs(&mut app), 1);
1912+ let s = status80(&mut app);
1913+ assert!(s.starts_with(" EDIT sync conflict in “Draft the RFC”: click ⚠ to resolve "), "{}", s);
1914+ assert!(s.ends_with(" ⚠ 1 ✓ saved "), "{}", s);
1915+ app.handle_key(key(KeyCode::Esc));
1916+ assert_eq!(app.mode_pub(), "normal");
1917+ let s = status80(&mut app);
1918+ assert!(s.starts_with(" sync conflict in “Draft the RFC”: click ⚠ to resolve "), "{}", s);
1919+ assert!(!s.contains(&file), "{}", s);
1920+ // with room, the file is there
1921+ assert!(status(&mut app).contains(&format!(" ⚠ 1 conflict {} ✓ saved ", file)), "{}", status(&mut app));
1922+}
1923+
1924+#[test]
1925+fn a_view_that_opens_on_its_own_takes_no_side_for_half_a_second() {
1926+ let d = homelab(None);
1927+ let mut app = App::new(d.path()).unwrap();
1928+ phone_copy(&d);
1929+ // idle in the outline: the view opens at once
1930+ app.reload_external();
1931+ assert_eq!(app.mode_pub(), "conflict");
1932+ let s = status(&mut app);
1933+ assert!(s.contains("sync conflicts in “NAS” and 1 more"), "{}", s);
1934+ // keys meant for the outline pick nothing, nor edit
1935+ press(&mut app, "otbe");
1936+ assert_eq!(app.mode_pub(), "conflict");
1937+ assert_eq!(pairs(&mut app), 2);
1938+ std::thread::sleep(Duration::from_millis(550));
1939+ press(&mut app, "t");
1940+ assert_eq!(pairs(&mut app), 1);
1941+ assert!(has_line(&d, "Mirrored pairs, no raidz."), "{}", root(&d));
1942+}
1943+
1944+#[test]
1945+fn resolving_a_pair_names_its_node_and_u_in_the_view_undoes_it() {
1946+ let d = homelab(None);
1947+ let mut app = App::new(d.path()).unwrap();
1948+ phone_copy(&d);
1949+ fold_core::merge::merge_sync_conflicts(app.vault_mut(), false).unwrap();
1950+ app.enter_conflict_view();
1951+ let shown = |app: &mut App| if screen(app).contains("Mirrored pairs, no raidz.") { "NAS" } else { "Label the cables" };
1952+ let first = shown(&mut app);
1953+ press(&mut app, "t");
1954+ assert!(status(&mut app).contains(&format!("kept theirs for “{}” · u undoes", first)), "{}", status(&mut app));
1955+ // u, in the view, puts the pair back and shows it
1956+ press(&mut app, "u");
1957+ assert_eq!(pairs(&mut app), 2);
1958+ assert_eq!(app.mode_pub(), "conflict");
1959+ assert_eq!(shown(&mut app), first);
1960+ assert!(status(&mut app).contains(&format!("undone: keep theirs for “{}”", first)), "{}", status(&mut app));
1961+ // the buttons say the same
1962+ click_button(&mut app, Action::ConflictOurs);
1963+ assert!(status(&mut app).contains(&format!("kept ours for “{}” · u undoes", first)), "{}", status(&mut app));
1964+ let second = shown(&mut app);
1965+ assert_ne!(second, first);
1966+ press(&mut app, "b");
1967+ assert!(status(&mut app).contains(&format!("kept both for “{}” · u undoes", second)), "{}", status(&mut app));
1968+ assert_eq!(pairs(&mut app), 0);
1969+}
1970+
1971+#[test]
1972+fn pairs_that_come_in_while_the_view_is_open_leave_it_on_the_pair_it_shows() {
1973+ let d = homelab(Some(LAST));
1974+ let mut app = App::new(d.path()).unwrap();
1975+ app.enter_conflict_view();
1976+ assert!(screen(&mut app).contains("flaky"));
1977+ phone_copy(&d);
1978+ app.reload_external();
1979+ // listed after NAS now, the pair on screen stays on screen
1980+ assert_eq!(app.mode_pub(), "conflict");
1981+ assert_eq!(pairs(&mut app), 3);
1982+ let s = screen(&mut app);
1983+ assert!(s.contains("Conflict 2 of 3") && s.contains("flaky"), "{}", s);
1984+ press(&mut app, "o");
1985+ assert!(!root(&d).contains(LAST), "{}", root(&d));
1986+ assert_eq!(pairs(&mut app), 2);
1987+}
1988+
1989+/// A task of its own in the homelab's Networking list, as a block.
1990+fn milk_block(d: &tempfile::TempDir, file: &str, task: &str) {
1991+ std::fs::write(d.path().join(file), format!("---\nid: {}\n---\n\n- {} Buy milk\n", LAST, task)).unwrap();
1992+}
1993+
1994+#[test]
1995+fn a_block_that_comes_in_with_its_copy_is_merged_with_it() {
1996+ let d = homelab(None);
1997+ let mut app = start(&d);
1998+ idle(&mut app, 300);
1999+ // one sync brings a block from the laptop, its embed, and the phone's
2000+ // copy of it
2001+ milk_block(&d, "worzod~buy-milk.md", "[ ]");
2002+ milk_block(&d, "worzod~buy-milk.sync-conflict-20260927-100000-PHONE.md", "[x]");
2003+ std::fs::write(d.path().join("root.md"), format!("{}![[{}]]\n", root(&d), LAST)).unwrap();
2004+ until_reload(&mut app);
2005+ assert!(app.vault_conflict_files().unwrap().is_empty(), "the copy was left alone");
2006+ assert_eq!(pairs(&mut app), 1);
2007+ assert_eq!(app.mode_pub(), "conflict");
2008+}
2009+
2010+#[test]
2011+fn copies_a_merge_failed_on_are_merged_with_the_next_change() {
2012+ let d = homelab(None);
2013+ std::fs::write(d.path().join("root.md"), format!("{}![[{}]]\n", root(&d), LAST)).unwrap();
2014+ milk_block(&d, "worzod~buy-milk.md", "[ ]");
2015+ let mut app = start(&d);
2016+ idle(&mut app, 300);
2017+ // root.md cannot be written for now, as when the trash cannot be: the
2018+ // merge stops at its copy, before the one after it
2019+ let tmp = d.path().join(".root.md.fold-tmp");
2020+ std::fs::create_dir(&tmp).unwrap();
2021+ phone_copy(&d);
2022+ milk_block(&d, "worzod~buy-milk.sync-conflict-20260927-100000-PHONE.md", "[x]");
2023+ until_reload(&mut app);
2024+ assert!(status(&mut app).contains("merge error"), "{}", status(&mut app));
2025+ assert_eq!(app.vault_conflict_files().unwrap().len(), 2);
2026+ // it can be again: the next change from outside takes both in
2027+ std::fs::remove_dir(&tmp).unwrap();
2028+ std::fs::write(d.path().join("root.md"), format!("{}- [ ] from laptop\n", root(&d))).unwrap();
2029+ until_reload(&mut app);
2030+ assert!(app.vault_conflict_files().unwrap().is_empty(), "the copies were not merged");
2031+ assert!(has_line(&d, "- [ ] from laptop"), "{}", root(&d));
2032+ assert_eq!(pairs(&mut app), 3);
2033+}
2034+
2035+// ------------------------------------------------------------ conflict copies
2036+
2037+/// A homelab vault merged with a phone's copy of it that changed NAS's
2038+/// text and checked the cables: a copy of NAS, with the tasks under it,
2039+/// and a copy of the cables task, each right after its own (§12.4).
2040+fn lab() -> (tempfile::TempDir, App) {
2041+ let text = "# Homelab\n\n## NAS\n\nMirrored pairs.\n\n- [ ] Snapshot policy\n- [x] Replace fan\n\n## Networking\n\n- [ ] Label the cables\n";
2042+ let d = vault(text);
2043+ std::fs::write(
2044+ d.path().join("root.sync-conflict-20260927-100000-PHONE.md"),
2045+ text.replace("pairs.", "pairs, no raidz.").replace("[ ] Label", "[x] Label"),
2046+ )
2047+ .unwrap();
2048+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
2049+ fold_core::merge::merge_sync_conflicts(&mut v, false).unwrap();
2050+ assert_eq!(fold_core::merge::conflict_pairs(&v).len(), 2);
2051+ drop(v);
2052+ let app = App::new(d.path()).unwrap();
2053+ (d, app)
2054+}
2055+
2056+/// Draw a frame: the screen as cells, to read colours and find things.
2057+fn frame(app: &mut App) -> ratatui::buffer::Buffer {
2058+ let mut t = Terminal::new(TestBackend::new(120, 32)).unwrap();
2059+ t.draw(|f| app.draw(f)).unwrap();
2060+ t.backend().buffer().clone()
2061+}
2062+
2063+fn line(b: &ratatui::buffer::Buffer, y: u16) -> String {
2064+ (0..b.area.width).map(|x| b[(x, y)].symbol()).collect()
2065+}
2066+
2067+fn text(b: &ratatui::buffer::Buffer) -> String {
2068+ (0..b.area.height).map(|y| line(b, y) + "\n").collect()
2069+}
2070+
2071+/// Where `s` starts on screen, the topmost first.
2072+fn find(b: &ratatui::buffer::Buffer, s: &str) -> Option<(u16, u16)> {
2073+ (0..b.area.height).find_map(|y| {
2074+ let l = line(b, y);
2075+ l.find(s).map(|i| (l[..i].chars().count() as u16, y))
2076+ })
2077+}
2078+
2079+/// Where the ⚠ after `s` is on screen.
2080+fn warning_after(b: &ratatui::buffer::Buffer, s: &str) -> (u16, u16) {
2081+ let at = format!("{} ⚠", s);
2082+ let (x, y) = find(b, &at).unwrap_or_else(|| panic!("no “{}” on screen:\n{}", at, text(b)));
2083+ (x + at.chars().count() as u16 - 1, y)
2084+}
2085+
2086+fn click_at(app: &mut App, x: u16, y: u16, button: MouseButton) {
2087+ app.handle_mouse(MouseEvent { kind: MouseEventKind::Down(button), column: x, row: y, modifiers: KeyModifiers::NONE });
2088+ app.handle_mouse(MouseEvent { kind: MouseEventKind::Up(button), column: x, row: y, modifiers: KeyModifiers::NONE });
2089+}
2090+
2091+/// Which pair the conflict view shows, by what is on screen.
2092+fn pair_shown(app: &mut App) -> &'static str {
2093+ assert_eq!(app.mode_pub(), "conflict");
2094+ let s = screen(app);
2095+ match (s.contains("no raidz"), s.contains("Label the cables")) {
2096+ (true, false) => "NAS",
2097+ (false, true) => "Label the cables",
2098+ _ => panic!("{}", s),
2099+ }
2100+}
2101+
2102+#[test]
2103+fn a_conflict_copy_s_row_shows_a_warning_and_whose_copy_it_is() {
2104+ let (_d, mut app) = lab();
2105+ let b = frame(&mut app);
2106+ // ⚠ in the warning colour where a block's ▤ goes, and where its text
2107+ // would go, the device and time it came from
2108+ let (x, y) = warning_after(&b, "NAS");
2109+ assert_eq!(b[(x, y)].fg, ratatui::style::Color::Yellow);
2110+ let row = line(&b, y);
2111+ assert!(row.contains("NAS ⚠ 1/2 other device · PHONE 09-27 10:00"), "{}", row);
2112+ assert!(!row.contains("raidz"), "{}", row);
2113+ warning_after(&b, "☑ Label the cables");
2114+ assert!(!text(&b).contains('▤'), "{}", text(&b));
2115+ // their tasks are not counted twice above them
2116+ assert!(text(&b).contains("Homelab 2/3"), "{}", text(&b));
2117+ assert!(text(&b).contains("Networking 1/1"), "{}", text(&b));
2118+}
2119+
2120+#[test]
2121+fn a_conflict_copy_starts_folded_and_that_fold_is_not_remembered() {
2122+ let (_d, mut app) = lab();
2123+ let s = screen(&mut app);
2124+ assert!(s.contains("▸ NAS ⚠"), "{}", s);
2125+ assert_eq!(s.matches("Snapshot policy").count(), 1, "{}", s);
2126+ // a click unfolds it as any row, for this run alone
2127+ let b = frame(&mut app);
2128+ let (x, y) = find(&b, "▸ NAS ⚠").unwrap();
2129+ click_at(&mut app, x, y, MouseButton::Left);
2130+ let s = screen(&mut app);
2131+ assert_eq!(s.matches("Snapshot policy").count(), 2, "{}", s);
2132+ assert!(app.view().folded.is_empty());
2133+ press(&mut app, "j");
2134+ let b = frame(&mut app);
2135+ let (x, y) = find(&b, "▾ NAS ⚠").unwrap();
2136+ click_at(&mut app, x, y, MouseButton::Left);
2137+ assert_eq!(screen(&mut app).matches("Snapshot policy").count(), 1);
2138+ assert!(app.view().folded.is_empty());
2139+ // zoomed into, it shows what is in it
2140+ app.cursor = app.rows().iter().rposition(|r| app.title_of(r.nref) == "NAS").unwrap();
2141+ app.handle_key(key(KeyCode::Enter));
2142+ let s = screen(&mut app);
2143+ assert!(s.contains("▾ NAS ⚠") && s.contains("Snapshot policy"), "{}", s);
2144+}
2145+
2146+#[test]
2147+fn a_click_on_a_copy_s_warning_opens_the_conflict_view_at_its_pair() {
2148+ // the view lists pairs by their copies' files, so each is tried
2149+ let (_d, mut app) = lab();
2150+ for copy in ["NAS", "Label the cables"] {
2151+ let b = frame(&mut app);
2152+ let (x, y) = warning_after(&b, copy);
2153+ click_at(&mut app, x, y, MouseButton::Left);
2154+ assert_eq!(pair_shown(&mut app), copy);
2155+ app.handle_key(key(KeyCode::Esc));
2156+ }
2157+}
2158+
2159+#[test]
2160+fn resolve_conflict_in_the_node_menu_of_either_side_opens_its_pair() {
2161+ let (_d, mut app) = lab();
2162+ // right-click ours, then a copy: the menu ends in Resolve conflict…
2163+ for side in ["Label the cables", "NAS ⚠"] {
2164+ let b = frame(&mut app);
2165+ let (x, y) = find(&b, side).unwrap();
2166+ click_at(&mut app, x, y, MouseButton::Right);
2167+ let b = frame(&mut app);
2168+ let (x, y) = find(&b, "Resolve conflict…").unwrap_or_else(|| panic!("{}", text(&b)));
2169+ click_at(&mut app, x, y, MouseButton::Left);
2170+ assert_eq!(pair_shown(&mut app), side.trim_end_matches(" ⚠"));
2171+ app.handle_key(key(KeyCode::Esc));
2172+ }
2173+ // a node in no pair has none
2174+ select(&mut app, "Networking");
2175+ press(&mut app, "m");
2176+ let s = screen(&mut app);
2177+ assert!(s.contains("Move to…") && !s.contains("Resolve conflict"), "{}", s);
2178+}
2179+
2180+/// Draw a frame on a `w`×`h` terminal: the screen as cells.
2181+fn frame_at(app: &mut App, w: u16, h: u16) -> ratatui::buffer::Buffer {
2182+ let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
2183+ t.draw(|f| app.draw(f)).unwrap();
2184+ t.backend().buffer().clone()
2185+}
2186+
2187+#[test]
2188+fn the_node_menu_of_a_pair_fits_a_short_terminal_resolve_conflict_and_all() {
2189+ let (_d, mut app) = lab();
2190+ // 80×24: the separators give way, every item shows and a click runs it
2191+ select(&mut app, "NAS");
2192+ press(&mut app, "m");
2193+ let b = frame_at(&mut app, 80, 24);
2194+ for item in ["│ Edit ", "│ Delete ", "│ Resolve conflict…"] {
2195+ assert!(find(&b, item).is_some(), "no {}:\n{}", item, text(&b));
2196+ }
2197+ let (x, y) = find(&b, "Resolve conflict…").unwrap();
2198+ click_at(&mut app, x, y, MouseButton::Left);
2199+ assert_eq!(pair_shown(&mut app), "NAS");
2200+ app.handle_key(key(KeyCode::Esc));
2201+ // shorter still, the list scrolls with the selection, down to the last
2202+ // item and back up to the first
2203+ press(&mut app, "m");
2204+ let b = frame_at(&mut app, 80, 14);
2205+ assert!(find(&b, "│ Edit ").is_some() && find(&b, "Resolve conflict…").is_none(), "{}", text(&b));
2206+ for _ in 0..30 {
2207+ app.handle_key(key(KeyCode::Down));
2208+ frame_at(&mut app, 80, 14);
2209+ }
2210+ let b = frame_at(&mut app, 80, 14);
2211+ assert!(find(&b, "Resolve conflict…").is_some() && find(&b, "│ Edit ").is_none(), "{}", text(&b));
2212+ for _ in 0..30 {
2213+ app.handle_key(key(KeyCode::Up));
2214+ frame_at(&mut app, 80, 14);
2215+ }
2216+ assert!(find(&frame_at(&mut app, 80, 14), "│ Edit ").is_some());
2217+ for _ in 0..30 {
2218+ app.handle_key(key(KeyCode::Down));
2219+ }
2220+ app.handle_key(key(KeyCode::Enter));
2221+ assert_eq!(pair_shown(&mut app), "NAS");
2222+}
2223+
2224+/// The pointer moves to a point and rests there.
2225+fn point(app: &mut App, x: u16, y: u16) {
2226+ app.handle_mouse(MouseEvent { kind: MouseEventKind::Moved, column: x, row: y, modifiers: KeyModifiers::NONE });
2227+}
2228+
2229+/// Right-click NAS on an 80×14 terminal: its menu fills the screen and
2230+/// stops short of Resolve conflict…. The column its items are drawn in,
2231+/// and its first and last item rows.
2232+fn short_menu(app: &mut App) -> (u16, u16, u16) {
2233+ let b = frame_at(app, 80, 14);
2234+ let (x, y) = find(&b, "NAS").unwrap();
2235+ point(app, x, y);
2236+ click_at(app, x, y, MouseButton::Right);
2237+ let b = frame_at(app, 80, 14);
2238+ assert!(find(&b, "│ Edit ").is_some() && find(&b, "Resolve conflict…").is_none(), "{}", text(&b));
2239+ let x = find(&b, "│ Edit ").unwrap().0 + 2;
2240+ let rows: Vec<u16> = (0..14).filter(|&y| matches!(app.hit_at(x, y), Some(Hit::MenuItem(_)))).collect();
2241+ (x, rows[0], *rows.last().unwrap())
2242+}
2243+
2244+#[test]
2245+fn on_a_short_terminal_the_wheel_scrolls_the_node_menu() {
2246+ let (_d, mut app) = lab();
2247+ let (x, top, _) = short_menu(&mut app);
2248+ // over its first item, the wheel goes down to the last item, back up
2249+ // to the first, and down again for a click to run the last
2250+ for (kind, to, gone) in [
2251+ (MouseEventKind::ScrollDown, "Resolve conflict…", "│ Edit "),
2252+ (MouseEventKind::ScrollUp, "│ Edit ", "Resolve conflict…"),
2253+ (MouseEventKind::ScrollDown, "Resolve conflict…", "│ Edit "),
2254+ ] {
2255+ for _ in 0..10 {
2256+ app.handle_mouse(MouseEvent { kind, column: x, row: top, modifiers: KeyModifiers::NONE });
2257+ frame_at(&mut app, 80, 14);
2258+ }
2259+ let b = frame_at(&mut app, 80, 14);
2260+ assert!(find(&b, to).is_some() && find(&b, gone).is_none(), "{:?}:\n{}", kind, text(&b));
2261+ }
2262+ let (x, y) = find(&frame_at(&mut app, 80, 14), "Resolve conflict…").unwrap();
2263+ click_at(&mut app, x, y, MouseButton::Left);
2264+ assert_eq!(pair_shown(&mut app), "NAS");
2265+}
2266+
2267+#[test]
2268+fn a_pointer_resting_on_the_node_menu_leaves_the_keys_to_scroll_it() {
2269+ let (_d, mut app) = lab();
2270+ let (x, _, bottom) = short_menu(&mut app);
2271+ // moved onto the last row, the pointer highlights its item
2272+ point(&mut app, x, bottom);
2273+ assert_eq!(frame_at(&mut app, 80, 14)[(x, bottom)].bg, ratatui::style::Color::Indexed(24));
2274+ // resting there, ↓ goes down to the last item and ↑ back up to the
2275+ // first, which Enter runs
2276+ for _ in 0..30 {
2277+ app.handle_key(key(KeyCode::Down));
2278+ frame_at(&mut app, 80, 14);
2279+ }
2280+ let b = frame_at(&mut app, 80, 14);
2281+ assert!(find(&b, "Resolve conflict…").is_some() && find(&b, "│ Edit ").is_none(), "{}", text(&b));
2282+ for _ in 0..30 {
2283+ app.handle_key(key(KeyCode::Up));
2284+ frame_at(&mut app, 80, 14);
2285+ }
2286+ let b = frame_at(&mut app, 80, 14);
2287+ assert!(find(&b, "│ Edit ").is_some() && find(&b, "Resolve conflict…").is_none(), "{}", text(&b));
2288+ app.handle_key(key(KeyCode::Enter));
2289+ assert_eq!(app.mode_pub(), "edit");
2290+}
2291+
2292+#[test]
2293+fn the_reading_pane_marks_a_conflict_copy_s_title_line() {
2294+ let (_d, mut app) = lab();
2295+ app.show_reading = true;
2296+ select(&mut app, "Homelab");
2297+ let b = frame(&mut app);
2298+ let (x, y) = warning_after(&b, "## NAS");
2299+ assert_eq!(b[(x, y)].fg, ratatui::style::Color::Yellow);
2300+ assert!(line(&b, y).contains("## NAS ⚠ other device · PHONE 09-27 10:00"), "{}", line(&b, y));
2301+ assert_eq!(text(&b).matches("## NAS").count(), 2, "{}", text(&b));
2302+ warning_after(&b, "☑ Label the cables");
2303+ // its ⚠ opens its pair, and the pane is on Homelab after
2304+ click_at(&mut app, x, y, MouseButton::Left);
2305+ assert_eq!(pair_shown(&mut app), "NAS");
2306+ app.handle_key(key(KeyCode::Esc));
2307+ assert_eq!(selected(&app), "Homelab");
2308+ // the copy's own pane says so in its border
2309+ app.cursor = app.rows().iter().rposition(|r| app.title_of(r.nref) == "NAS").unwrap();
2310+ let b = frame(&mut app);
2311+ assert!(find(&b, "╭ NAS ⚠ ─").is_some(), "{}", text(&b));
2312+}
2313+
2314+#[test]
2315+fn enter_on_a_copy_s_heading_in_the_reading_pane_zooms_into_it_unfolded() {
2316+ let (_d, mut app) = lab();
2317+ app.show_reading = true;
2318+ select(&mut app, "Homelab");
2319+ app.handle_key(key(KeyCode::Tab));
2320+ // down to the copy's heading, the second ## NAS
2321+ let at = app.reading_doc_pub().lines.iter().rposition(|l| l.starts_with("## NAS")).unwrap();
2322+ for _ in 0..at {
2323+ press(&mut app, "j");
2324+ draw(&mut app);
2325+ }
2326+ assert_eq!(app.read_cursor_pub(), at);
2327+ app.handle_key(key(KeyCode::Enter));
2328+ // as a double-click or the outline's Enter: it shows what is in it
2329+ let s = screen(&mut app);
2330+ assert!(s.contains("▾ NAS ⚠") && s.contains("Snapshot policy"), "{}", s);
2331+ assert_eq!(app.rows().len(), 3, "{}", s);
2332+}
2333+
2334+/// fold started again on `app`'s view, as it remembers it (§10.1).
2335+fn restarted(d: &tempfile::TempDir, app: &App) -> App {
2336+ let mut again = App::new(d.path()).unwrap();
2337+ again.apply_view(fold_tui::app::View::parse(&app.view().to_text()).unwrap(), false);
2338+ again
2339+}
2340+
2341+#[test]
2342+fn a_zoom_on_a_conflict_copy_shows_what_is_in_it_after_a_restart() {
2343+ let (d, mut app) = lab();
2344+ app.cursor = app.rows().iter().rposition(|r| app.title_of(r.nref) == "NAS").unwrap();
2345+ app.handle_key(key(KeyCode::Enter));
2346+ assert_eq!(app.rows().len(), 3);
2347+ // the zoom is remembered, the copy's fold is not: it starts unfolded
2348+ let mut again = restarted(&d, &app);
2349+ let s = screen(&mut again);
2350+ assert!(s.contains("▾ NAS ⚠") && s.contains("Snapshot policy"), "{}", s);
2351+ assert_eq!(again.rows().len(), 3, "{}", s);
2352+ // folded there by hand, it stays folded through a change from outside
2353+ press(&mut again, "h");
2354+ assert_eq!(again.rows().len(), 1);
2355+ again.reload_external();
2356+ assert_eq!(again.rows().len(), 1, "{}", screen(&mut again));
2357+}
2358+
2359+#[test]
2360+fn zooming_out_or_a_breadcrumb_to_a_conflict_copy_shows_what_is_in_it() {
2361+ let (d, mut app) = lab();
2362+ // zoomed into a task in the copy when fold ends
2363+ app.cursor = app.rows().iter().rposition(|r| app.title_of(r.nref) == "NAS").unwrap();
2364+ app.handle_key(key(KeyCode::Enter));
2365+ select(&mut app, "Snapshot policy");
2366+ app.handle_key(key(KeyCode::Enter));
2367+ assert!(screen(&mut app).contains("fold › Homelab › NAS › Snapshot policy"), "{}", screen(&mut app));
2368+ // Backspace, in the outline or the reading pane, lands on the copy
2369+ for reading in [false, true] {
2370+ let mut again = restarted(&d, &app);
2371+ if reading {
2372+ again.handle_key(key(KeyCode::Tab));
2373+ }
2374+ again.handle_key(key(KeyCode::Backspace));
2375+ let s = screen(&mut again);
2376+ assert!(s.contains("▾ NAS ⚠") && s.contains("Replace fan"), "{}", s);
2377+ assert_eq!(again.rows().len(), 3, "{}", s);
2378+ }
2379+ // so does its breadcrumb
2380+ let mut again = restarted(&d, &app);
2381+ let b = frame(&mut again);
2382+ let (x, y) = find(&b, "NAS › Snapshot policy").unwrap_or_else(|| panic!("{}", text(&b)));
2383+ click_at(&mut again, x, y, MouseButton::Left);
2384+ let s = screen(&mut again);
2385+ assert!(s.contains("▾ NAS ⚠") && s.contains("Replace fan"), "{}", s);
2386+ assert_eq!(again.rows().len(), 3, "{}", s);
2387+ // and so does deleting the task: the zoom falls back to the copy
2388+ let mut again = restarted(&d, &app);
2389+ press(&mut again, "d");
2390+ let s = screen(&mut again);
2391+ assert!(s.contains("▾ NAS ⚠") && s.contains("Replace fan"), "{}", s);
2392+ assert_eq!(again.rows().len(), 2, "{}", s);
2393+}
2394+
2395+#[test]
2396+fn move_to_leaves_conflict_copies_out_and_other_pickers_mark_them() {
2397+ let (_d, mut app) = lab();
2398+ select(&mut app, "Replace fan");
2399+ press(&mut app, "r");
2400+ press(&mut app, "Snapshot");
2401+ draw(&mut app);
2402+ assert!(app.hit_pos(Hit::PickRow(0)).is_some());
2403+ assert!(app.hit_pos(Hit::PickRow(1)).is_none(), "{}", screen(&mut app));
2404+ app.handle_key(key(KeyCode::Esc));
2405+ // Go to… and the filter list both, the one in the copy with its ⚠
2406+ app.run_action(Action::GoTo);
2407+ press(&mut app, "Snapshot");
2408+ let b = frame(&mut app);
2409+ assert!(app.hit_pos(Hit::PickRow(1)).is_some(), "{}", text(&b));
2410+ assert!(find(&b, "Snapshot policy ⚠ Homelab › NAS").is_some(), "{}", text(&b));
2411+ app.handle_key(key(KeyCode::Esc));
2412+ press(&mut app, "/Snapshot");
2413+ let b = frame(&mut app);
2414+ assert!(app.hit_pos(Hit::FilterRow(1)).is_some(), "{}", text(&b));
2415+ assert_eq!(text(&b).matches("Snapshot policy ⚠ Homelab › NAS").count(), 1, "{}", text(&b));
2416+}
2417+
2418+#[test]
2419+fn indent_puts_nothing_into_a_conflict_copy() {
2420+ // the node above Networking is the copy of NAS, folded, which keeping
2421+ // ours trashes (§12.5)
2422+ let (d, mut app) = lab();
2423+ select(&mut app, "Networking");
2424+ press(&mut app, ">");
2425+ assert_eq!(said(&mut app), "can't indent “Networking” into a conflict copy");
2426+ assert_eq!(selected(&app), "Networking");
2427+ assert!(has_line(&d, "## Networking") && has_line(&d, "- [ ] Label the cables"), "{}", root(&d));
2428+ // inside a copy, a node still goes under the one above it
2429+ app.cursor = app.rows().iter().rposition(|r| app.title_of(r.nref) == "NAS").unwrap();
2430+ app.handle_key(key(KeyCode::Enter));
2431+ select(&mut app, "Replace fan");
2432+ press(&mut app, ">");
2433+ assert_eq!(said(&mut app), "indented “Replace fan”");
2434+}
2435+
2436+/// The node each conflict copy pairs with, the one before it (§12.4).
2437+fn paired(app: &mut App) -> Vec<String> {
2438+ let v = app.vault_mut();
2439+ let mut out: Vec<String> =
2440+ fold_core::merge::conflict_pairs(v).into_iter().map(|(ours, _)| v.tree.node(ours).title.clone()).collect();
2441+ out.sort();
2442+ out
2443+}
2444+
2445+/// The lab's pairs, each copy right after its own node.
2446+const PAIRED: [&str; 2] = ["Label the cables", "NAS"];
2447+
2448+/// Put the cursor on the copy of NAS, the second row titled so.
2449+fn select_copy(app: &mut App) {
2450+ app.cursor = app.rows().iter().rposition(|r| app.title_of(r.nref) == "NAS").unwrap();
2451+}
2452+
2453+#[test]
2454+fn j_and_k_move_a_node_and_its_conflict_copy_as_one() {
2455+ // a copy pairs with the node before it (§12.4): a node moved in
2456+ // between would pair with it, and keeping theirs would replace it
2457+ let (d, mut app) = lab();
2458+ let before = root(&d);
2459+ select(&mut app, "Networking");
2460+ press(&mut app, "K");
2461+ assert_eq!(paired(&mut app), PAIRED, "{}", root(&d));
2462+ assert_eq!(selected(&app), "Networking");
2463+ let r = root(&d);
2464+ assert!(r.find("## Networking").unwrap() < r.find("## NAS").unwrap(), "{}", r);
2465+ // keeping theirs replaces NAS, not what K put above its copy
2466+ let b = frame(&mut app);
2467+ let (x, y) = warning_after(&b, "NAS");
2468+ click_at(&mut app, x, y, MouseButton::Left);
2469+ assert_eq!(pair_shown(&mut app), "NAS");
2470+ press(&mut app, "t");
2471+ assert!(has_line(&d, "## Networking") && has_line(&d, "- [ ] Label the cables"), "{}", root(&d));
2472+ assert!(has_line(&d, "Mirrored pairs, no raidz."), "{}", root(&d));
2473+ press(&mut app, "u");
2474+ app.handle_key(key(KeyCode::Esc));
2475+ // J steps over the pair as K did
2476+ select(&mut app, "Networking");
2477+ press(&mut app, "J");
2478+ assert_eq!(root(&d), before);
2479+ // J and K on either side move both
2480+ select(&mut app, "NAS");
2481+ press(&mut app, "J");
2482+ assert_eq!(paired(&mut app), PAIRED, "{}", root(&d));
2483+ assert_eq!(selected(&app), "NAS");
2484+ let r = root(&d);
2485+ assert!(r.find("## Networking").unwrap() < r.find("## NAS").unwrap(), "{}", r);
2486+ select_copy(&mut app);
2487+ press(&mut app, "K");
2488+ assert_eq!(root(&d), before);
2489+ select(&mut app, "Label the cables");
2490+ press(&mut app, "J");
2491+ assert_eq!(root(&d), before, "nothing below it to go past");
2492+}
2493+
2494+#[test]
2495+fn a_node_added_pasted_or_dropped_beside_a_conflict_pair_goes_after_the_copy() {
2496+ let (d, mut app) = lab();
2497+ let before = root(&d);
2498+ // n on NAS: the new node, and the editor on it, after NAS's copy
2499+ select(&mut app, "NAS");
2500+ press(&mut app, "n");
2501+ assert_eq!(app.mode_pub(), "edit");
2502+ press(&mut app, "Backups");
2503+ app.handle_key(key(KeyCode::Esc));
2504+ assert_eq!(paired(&mut app), PAIRED, "{}", root(&d));
2505+ assert_eq!(selected(&app), "Backups");
2506+ let r = root(&d);
2507+ assert!(r.find("## ![[").unwrap() < r.find("## Backups").unwrap(), "{}", r);
2508+ // the title, then the node
2509+ press(&mut app, "uu");
2510+ assert_eq!(root(&d), before);
2511+ // p on NAS and P on its copy
2512+ select(&mut app, "Networking");
2513+ press(&mut app, "y");
2514+ select(&mut app, "NAS");
2515+ press(&mut app, "p");
2516+ assert_eq!(paired(&mut app), PAIRED, "{}", root(&d));
2517+ press(&mut app, "u");
2518+ select_copy(&mut app);
2519+ press(&mut app, "P");
2520+ assert_eq!(paired(&mut app), PAIRED, "{}", root(&d));
2521+ press(&mut app, "u");
2522+ assert_eq!(root(&d), before);
2523+ // a drop to the left of the copy's row
2524+ draw(&mut app);
2525+ let rows = app.rows();
2526+ let copy = rows.iter().rposition(|r| app.title_of(r.nref) == "NAS").unwrap();
2527+ let from = app.hit_pos(Hit::Row(copy + 1)).unwrap();
2528+ let to = (2, app.hit_pos(Hit::Row(copy)).unwrap().1);
2529+ let at = |kind, (column, row)| MouseEvent { kind, column, row, modifiers: KeyModifiers::NONE };
2530+ app.handle_mouse(at(MouseEventKind::Down(MouseButton::Left), from));
2531+ app.handle_mouse(at(MouseEventKind::Drag(MouseButton::Left), to));
2532+ draw(&mut app);
2533+ app.handle_mouse(at(MouseEventKind::Up(MouseButton::Left), to));
2534+ assert_eq!(paired(&mut app), PAIRED, "{}", root(&d));
2535+ assert_eq!(selected(&app), "Networking");
2536+}
2537+
2538+#[test]
2539+fn a_node_dropped_before_a_conflict_copy_is_selected_where_it_lands() {
2540+ // a second NAS, after Networking, dropped to the left of the copy of
2541+ // NAS: it lands after the copy, whose title it shares, and it, not
2542+ // the copy, is selected there
2543+ let (d, mut app) = lab();
2544+ select(&mut app, "NAS");
2545+ press(&mut app, "y");
2546+ select(&mut app, "Networking");
2547+ press(&mut app, "p");
2548+ draw(&mut app);
2549+ let rows = app.rows();
2550+ let nas: Vec<usize> = rows.iter().enumerate().filter(|(_, r)| app.title_of(r.nref) == "NAS").map(|(i, _)| i).collect();
2551+ let [_, copy, second] = nas[..] else { panic!("{:?}", nas) };
2552+ let from = app.hit_pos(Hit::Row(second)).unwrap();
2553+ let to = (2, app.hit_pos(Hit::Row(copy)).unwrap().1);
2554+ let at = |kind, (column, row)| MouseEvent { kind, column, row, modifiers: KeyModifiers::NONE };
2555+ app.handle_mouse(at(MouseEventKind::Down(MouseButton::Left), from));
2556+ app.handle_mouse(at(MouseEventKind::Drag(MouseButton::Left), to));
2557+ draw(&mut app);
2558+ app.handle_mouse(at(MouseEventKind::Up(MouseButton::Left), to));
2559+ assert_eq!(paired(&mut app), PAIRED, "{}", root(&d));
2560+ let cursor = app.cursor;
2561+ assert_eq!(cursor, copy + 1, "{}", screen(&mut app));
2562+ assert_eq!(selected(&app), "NAS");
2563+}
2564+
2565+/// Work's two sections, then the Inbox, merged with a phone's copy that
2566+/// added a line under Reading list: a copy of it after its list (§12.4).
2567+fn reading() -> (tempfile::TempDir, App) {
2568+ let text = "# Work\n\n## Project Atlas\n\n- [ ] Draft the RFC\n\n## Reading list\n\n- The Rust Book\n\n# Inbox\n\n- call the plumber\n";
2569+ let d = vault(text);
2570+ std::fs::write(
2571+ d.path().join("root.sync-conflict-20260927-161600-PHONE77.md"),
2572+ text.replace("## Reading list\n\n", "## Reading list\n\nBooks to read this winter.\n\n"),
2573+ )
2574+ .unwrap();
2575+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
2576+ fold_core::merge::merge_sync_conflicts(&mut v, false).unwrap();
2577+ assert_eq!(fold_core::merge::conflict_pairs(&v).len(), 1);
2578+ drop(v);
2579+ let app = App::new(d.path()).unwrap();
2580+ (d, app)
2581+}
2582+
2583+/// The row of the node titled `title`, not of its copy.
2584+fn row_of(app: &App, title: &str) -> usize {
2585+ app.rows().iter().position(|r| app.title_of(r.nref) == title).unwrap()
2586+}
2587+
2588+/// The row of the copy of `title`, the last row titled so.
2589+fn copy_row(app: &App, title: &str) -> usize {
2590+ app.rows().iter().rposition(|r| app.title_of(r.nref) == title).unwrap()
2591+}
2592+
2593+/// Drag row `from` onto row `to`: onto its title, or left of it.
2594+fn drag(app: &mut App, from: usize, to: usize, into: bool) {
2595+ draw(app);
2596+ let from = app.hit_pos(Hit::Row(from)).unwrap();
2597+ let to = app.hit_pos(Hit::Row(to)).unwrap();
2598+ let to = if into { to } else { (2, to.1) };
2599+ let at = |kind, (column, row)| MouseEvent { kind, column, row, modifiers: KeyModifiers::NONE };
2600+ app.handle_mouse(at(MouseEventKind::Down(MouseButton::Left), from));
2601+ app.handle_mouse(at(MouseEventKind::Drag(MouseButton::Left), to));
2602+ draw(app);
2603+ app.handle_mouse(at(MouseEventKind::Up(MouseButton::Left), to));
2604+}
2605+
2606+#[test]
2607+fn move_to_takes_a_node_s_conflict_copy_with_it() {
2608+ // left behind, the copy of Reading list would pair with Project
2609+ // Atlas, and keeping theirs would replace that (§12.5)
2610+ let (d, mut app) = reading();
2611+ select(&mut app, "Reading list");
2612+ press(&mut app, "r");
2613+ press(&mut app, "Inbox");
2614+ app.handle_key(key(KeyCode::Enter));
2615+ assert_eq!(said(&mut app), "moved “Reading list” to “Inbox”");
2616+ assert_eq!(paired(&mut app), ["Reading list"], "{}", root(&d));
2617+ let r = root(&d);
2618+ assert!(r.find("# Inbox").unwrap() < r.find("## Reading list").unwrap(), "{}", r);
2619+ assert!(r.find("## Reading list").unwrap() < r.find("## ![[").unwrap(), "{}", r);
2620+ // the selection is on the node, not on its copy
2621+ assert_eq!(app.cursor, row_of(&app, "Reading list"));
2622+ // keeping theirs replaces Reading list, and Project Atlas stays
2623+ let b = frame(&mut app);
2624+ let (x, y) = warning_after(&b, "Reading list");
2625+ click_at(&mut app, x, y, MouseButton::Left);
2626+ press(&mut app, "t");
2627+ assert!(has_line(&d, "## Project Atlas") && has_line(&d, "- [ ] Draft the RFC"), "{}", root(&d));
2628+ assert!(has_line(&d, "Books to read this winter."), "{}", root(&d));
2629+ // from the copy, both go, and the selection stays on the copy; its
2630+ // node, and what is under it, is no destination, as its own subtree
2631+ let (d, mut app) = reading();
2632+ app.cursor = copy_row(&app, "Reading list");
2633+ press(&mut app, "rRust");
2634+ draw(&mut app);
2635+ assert!(app.hit_pos(Hit::PickRow(0)).is_none(), "{}", screen(&mut app));
2636+ app.handle_key(key(KeyCode::Esc));
2637+ press(&mut app, "r");
2638+ press(&mut app, "Inbox");
2639+ app.handle_key(key(KeyCode::Enter));
2640+ assert_eq!(paired(&mut app), ["Reading list"], "{}", root(&d));
2641+ let r = root(&d);
2642+ assert!(r.find("# Inbox").unwrap() < r.find("## Reading list").unwrap(), "{}", r);
2643+ assert_eq!(app.cursor, copy_row(&app, "Reading list"));
2644+}
2645+
2646+#[test]
2647+fn deleting_a_node_deletes_its_conflict_copy_with_it() {
2648+ // left behind, the copy of NAS would be first under Homelab, marked
2649+ // and folded with nothing to pair with
2650+ let (d, mut app) = lab();
2651+ let before = root(&d);
2652+ select(&mut app, "NAS");
2653+ press(&mut app, "d");
2654+ assert_eq!(said(&mut app), "deleted “NAS” and its conflict copy (6 nodes) · u undoes");
2655+ assert_eq!(paired(&mut app), ["Label the cables"], "{}", root(&d));
2656+ assert!(!app.rows().iter().any(|r| app.title_of(r.nref) == "NAS"), "{}", screen(&mut app));
2657+ assert_eq!(root(&d).matches("![[").count(), 1, "{}", root(&d));
2658+ // one u brings both back
2659+ press(&mut app, "u");
2660+ assert_eq!(root(&d), before);
2661+ assert_eq!(paired(&mut app), PAIRED);
2662+ // a copy goes alone, as keeping ours sends it
2663+ select_copy(&mut app);
2664+ press(&mut app, "d");
2665+ assert_eq!(said(&mut app), "deleted “NAS” (3 nodes) · u undoes");
2666+ assert_eq!(paired(&mut app), ["Label the cables"], "{}", root(&d));
2667+ assert!(has_line(&d, "## NAS") && has_line(&d, "Mirrored pairs."), "{}", root(&d));
2668+}
2669+
2670+#[test]
2671+fn a_drag_an_indent_or_an_outdent_takes_a_node_s_conflict_copy_with_it() {
2672+ let (d, mut app) = reading();
2673+ let before = root(&d);
2674+ // > on the copy: both go under Project Atlas, the node before them
2675+ app.cursor = copy_row(&app, "Reading list");
2676+ press(&mut app, ">");
2677+ assert_eq!(said(&mut app), "indented “Reading list”");
2678+ assert_eq!(paired(&mut app), ["Reading list"], "{}", root(&d));
2679+ assert!(has_line(&d, "### Reading list") && has_line(&d, "- [ ] Draft the RFC"), "{}", root(&d));
2680+ assert_eq!(app.cursor, copy_row(&app, "Reading list"));
2681+ // < on the node: both back after Project Atlas
2682+ select(&mut app, "Reading list");
2683+ press(&mut app, "<");
2684+ assert_eq!(said(&mut app), "outdented “Reading list”");
2685+ assert_eq!(paired(&mut app), ["Reading list"], "{}", root(&d));
2686+ assert_eq!(root(&d), before);
2687+ assert_eq!(app.cursor, row_of(&app, "Reading list"));
2688+ // the copy dragged into Inbox
2689+ let (from, to) = (copy_row(&app, "Reading list"), row_of(&app, "Inbox"));
2690+ drag(&mut app, from, to, true);
2691+ assert_eq!(paired(&mut app), ["Reading list"], "{}", root(&d));
2692+ let r = root(&d);
2693+ assert!(r.find("# Inbox").unwrap() < r.find("## Reading list").unwrap(), "{}", r);
2694+ // an item and its copy dragged before a section: before NAS
2695+ let (d, mut app) = lab();
2696+ let (from, to) = (row_of(&app, "Label the cables"), row_of(&app, "NAS"));
2697+ drag(&mut app, from, to, false);
2698+ assert_eq!(paired(&mut app), PAIRED, "{}", root(&d));
2699+ let r = root(&d);
2700+ assert!(r.find("- [ ] Label the cables").unwrap() < r.find("## NAS").unwrap(), "{}", r);
2701+ assert_eq!(app.cursor, row_of(&app, "Label the cables"));
2702+ // > on a copy with nothing above its node
2703+ app.cursor = copy_row(&app, "Label the cables");
2704+ press(&mut app, ">");
2705+ assert_eq!(said(&mut app), "can't indent “Label the cables”: nothing above it");
2706+}
2707+
2708+#[test]
2709+fn a_drop_puts_nothing_into_a_conflict_copy() {
2710+ // dropped on the title of the copy of NAS, Networking would go out of
2711+ // sight under its fold, and keeping ours would trash it with the copy
2712+ // (§12.5): the drag says so while it is over the copy, and letting go
2713+ // does nothing
2714+ let (d, mut app) = lab();
2715+ let before = root(&d);
2716+ draw(&mut app);
2717+ let from = app.hit_pos(Hit::Row(row_of(&app, "Networking"))).unwrap();
2718+ let to = app.hit_pos(Hit::Row(copy_row(&app, "NAS"))).unwrap();
2719+ let at = |kind, (column, row)| MouseEvent { kind, column, row, modifiers: KeyModifiers::NONE };
2720+ app.handle_mouse(at(MouseEventKind::Down(MouseButton::Left), from));
2721+ app.handle_mouse(at(MouseEventKind::Drag(MouseButton::Left), to));
2722+ assert_eq!(said(&mut app), "can't move “Networking” into a conflict copy");
2723+ app.handle_mouse(at(MouseEventKind::Up(MouseButton::Left), to));
2724+ assert_eq!(said(&mut app), "can't move “Networking” into a conflict copy");
2725+ assert_eq!(root(&d), before);
2726+ assert_eq!(paired(&mut app), PAIRED, "{}", root(&d));
2727+ // nor onto a row in the copy unfolded, or left of one
2728+ app.cursor = copy_row(&app, "NAS");
2729+ press(&mut app, "l");
2730+ let (from, to) = (row_of(&app, "Networking"), copy_row(&app, "Snapshot policy"));
2731+ drag(&mut app, from, to, true);
2732+ assert_eq!(said(&mut app), "can't move “Networking” into a conflict copy");
2733+ let (from, to) = (row_of(&app, "Label the cables"), copy_row(&app, "Replace fan"));
2734+ drag(&mut app, from, to, false);
2735+ assert_eq!(said(&mut app), "can't move “Label the cables” into a conflict copy");
2736+ assert_eq!(root(&d), before);
2737+ // a node in the copy still moves within it, as > moves it
2738+ let (from, to) = (copy_row(&app, "Replace fan"), copy_row(&app, "Snapshot policy"));
2739+ drag(&mut app, from, to, false);
2740+ assert_eq!(said(&mut app), "moved “Replace fan”");
2741+ assert_eq!(copy_row(&app, "Replace fan") + 1, copy_row(&app, "Snapshot policy"), "{}", screen(&mut app));
2742+ // the rows changing under a drag: the row it lets go on is now in the
2743+ // copy, and that is refused too
2744+ let (d, mut app) = lab();
2745+ let before = root(&d);
2746+ draw(&mut app);
2747+ let from = app.hit_pos(Hit::Row(row_of(&app, "Replace fan"))).unwrap();
2748+ let to = app.hit_pos(Hit::Row(row_of(&app, "Networking"))).unwrap();
2749+ app.handle_mouse(at(MouseEventKind::Down(MouseButton::Left), from));
2750+ app.handle_mouse(at(MouseEventKind::Drag(MouseButton::Left), to));
2751+ assert_eq!(said(&mut app), "move “Replace fan” into “Networking”");
2752+ app.cursor = copy_row(&app, "NAS");
2753+ press(&mut app, "l");
2754+ app.handle_mouse(at(MouseEventKind::Up(MouseButton::Left), to));
2755+ assert_eq!(said(&mut app), "can't move “Replace fan” into a conflict copy");
2756+ assert_eq!(root(&d), before);
2757+}
2758+
2759+#[test]
2760+fn a_node_in_a_conflict_pair_keeps_its_spelling_until_it_is_resolved() {
2761+ // a heading made a bullet goes before the headings, and would leave
2762+ // its copy to pair with the node before it (§12.5)
2763+ let (d, mut app) = lab();
2764+ let before = root(&d);
2765+ select(&mut app, "NAS");
2766+ press(&mut app, "~");
2767+ assert_eq!(said(&mut app), "can't respell “NAS”: resolve its conflict");
2768+ select_copy(&mut app);
2769+ press(&mut app, "~");
2770+ assert_eq!(said(&mut app), "can't respell “NAS”: resolve its conflict");
2771+ select(&mut app, "Label the cables");
2772+ press(&mut app, "~");
2773+ assert_eq!(said(&mut app), "can't respell “Label the cables”: resolve its conflict");
2774+ assert_eq!(root(&d), before);
2775+ assert_eq!(paired(&mut app), PAIRED);
2776+}
2777+
2778+#[test]
2779+fn the_editor_s_border_says_when_the_cursor_is_in_a_conflict_copy() {
2780+ let (_d, mut app) = lab();
2781+ app.set_edit_keys(EditKeys::Normal);
2782+ select(&mut app, "Homelab");
2783+ press(&mut app, "e");
2784+ let b = frame(&mut app);
2785+ assert!(find(&b, "Editing Homelab").is_some() && find(&b, "conflict copy").is_none(), "{}", text(&b));
2786+ // down through ours, into the copy of NAS
2787+ let mut seen = None;
2788+ for _ in 0..20 {
2789+ app.handle_key(key(KeyCode::Down));
2790+ let b = frame(&mut app);
2791+ if let Some((_, y)) = find(&b, "conflict copy") {
2792+ seen = Some(line(&b, y));
2793+ break;
2794+ }
2795+ }
2796+ let border = seen.expect("never said so");
2797+ assert!(border.contains("Editing NAS ⚠ conflict copy from PHONE"), "{}", border);
2798+}
2799+
2800+#[test]
2801+fn the_editor_s_border_says_so_when_opened_on_a_node_inside_a_conflict_copy() {
2802+ // typing there lands in the copy, which keeping ours trashes
2803+ let (_d, mut app) = lab();
2804+ app.set_edit_keys(EditKeys::Normal);
2805+ // ours: no warning
2806+ select(&mut app, "Snapshot policy");
2807+ press(&mut app, "e");
2808+ let b = frame(&mut app);
2809+ assert!(find(&b, "Editing Snapshot policy").is_some() && find(&b, "conflict copy").is_none(), "{}", text(&b));
2810+ app.handle_key(key(KeyCode::Esc));
2811+ // the copy's, a node of its file that is no block of its own
2812+ select_copy(&mut app);
2813+ press(&mut app, "l");
2814+ app.cursor = app.rows().iter().rposition(|r| app.title_of(r.nref) == "Snapshot policy").unwrap();
2815+ press(&mut app, "e");
2816+ let b = frame(&mut app);
2817+ let (_, y) = find(&b, "Editing Snapshot").unwrap_or_else(|| panic!("no editor:\n{}", text(&b)));
2818+ assert!(line(&b, y).contains("⚠ conflict copy from PHONE"), "{}", line(&b, y));
2819+ app.handle_key(key(KeyCode::Esc));
2820+ // a block of its own inside the copy
2821+ press(&mut app, "s");
2822+ let r = app.current().unwrap();
2823+ assert!(app.vault_mut().tree.node(r).is_block(), "{}", status(&mut app));
2824+ press(&mut app, "e");
2825+ let b = frame(&mut app);
2826+ let (_, y) = find(&b, "Editing Snapshot").unwrap_or_else(|| panic!("no editor:\n{}", text(&b)));
2827+ assert!(line(&b, y).contains("⚠ conflict copy from PHONE"), "{}", line(&b, y));
2828+}
2829+
2830+#[test]
2831+fn where_the_editor_s_border_is_short_its_mode_and_warning_stay_whole_left_of_its_buttons() {
2832+ // in a copy, Vim's and Helix's border says more than fits left of ⌨
2833+ // at 120 columns, and far more at 80
2834+ for (keys, name) in [(EditKeys::Vim, "vim"), (EditKeys::Helix, "helix")] {
2835+ let (_d, mut app) = lab();
2836+ app.set_edit_keys(keys);
2837+ select(&mut app, "Homelab");
2838+ press(&mut app, "e");
2839+ // down into the copy of NAS, where a wide border says so in full
2840+ for _ in 0..20 {
2841+ if find(&frame_at(&mut app, 200, 32), "conflict copy").is_some() {
2842+ break;
2843+ }
2844+ app.handle_key(key(KeyCode::Down));
2845+ }
2846+ assert!(find(&frame_at(&mut app, 200, 32), "conflict copy").is_some(), "{}", text(&frame_at(&mut app, 200, 32)));
2847+ // clean, in insert mode, then with something typed
2848+ for (typed, mode) in [("", "NORMAL"), ("i", "INSERT"), ("z", "INSERT")] {
2849+ press(&mut app, typed);
2850+ for (w, h) in [(120, 32), (80, 24)] {
2851+ let b = frame_at(&mut app, w, h);
2852+ let (_, y) = find(&b, "⌨").unwrap_or_else(|| panic!("no ⌨:\n{}", text(&b)));
2853+ let row = line(&b, y);
2854+ let border = &row[row.rfind('╭').unwrap()..];
2855+ let (left, right) = border.split_at(border.find('⌨').unwrap());
2856+ let why = format!("{} {}×{} after “{}”: {}", name, w, h, typed, border);
2857+ // the mode and the ⚠ whole, left of ⌨
2858+ assert!(left.contains(&format!(" {} ", mode)) && left.contains('⚠'), "{}", why);
2859+ if w == 120 {
2860+ assert!(left.contains("Editing NAS ⚠"), "{}", why);
2861+ }
2862+ // right of it only the keymap and the buttons
2863+ let rest = right.replacen(&format!("⌨ {}", name), "", 1).replace("✓ Done", "").replace("↺ Revert", "");
2864+ assert!(rest.chars().all(|c| matches!(c, '─' | ' ' | '╮' | '✓' | '↺')), "{}", why);
2865+ }
2866+ }
2867+ }
2868+}
2869+
2870+// ------------------------------------------------------------ messages
2871+
2872+/// The sample vault, as `fold` ships it, cut down.
2873+const SAMPLE: &str = "# Homelab\n\nTwo boxes in the closet.\n\n## NAS\n\nMirrored pairs, no raidz.\n\n### [ ] Snapshot policy\n\n- hourly, keep 24\n- daily, keep 30\n\n### [x] Replace fan\n\n## Networking\n\n- [ ] Replace the flaky switch\n- [ ] Label the cables\n - [x] patch panel\n - [ ] rack\n- VLANs: 10 home, 20 iot, 30 guest\n\n# Work\n\n## Project Atlas\n\n- [ ] Draft the RFC\n- [ ] Review PR\n- [x] Kickoff meeting\n\n## Reading list\n\n- The Rust Book\n\n# Inbox\n\n- call the plumber\n- [ ] renew passport\n";
2874+
2875+/// What the status bar says on its left, past a mode's badge, as the
2876+/// next frame draws it.
2877+fn said(app: &mut App) -> String {
2878+ let s = status(app);
2879+ let mut parts = s.split(" ").map(str::trim).filter(|p| !p.is_empty());
2880+ let first = parts.next().unwrap_or_default();
2881+ let badge = first.chars().all(|c| c.is_ascii_uppercase());
2882+ if badge { parts.next().unwrap_or_default() } else { first }.to_string()
2883+}
2884+
2885+#[test]
2886+fn copy_and_paste_name_the_node_in_the_menu_s_words() {
2887+ let d = vault(SAMPLE);
2888+ let mut app = App::new(d.path()).unwrap();
2889+ select(&mut app, "NAS");
2890+ press(&mut app, "p");
2891+ assert_eq!(said(&mut app), "nothing copied yet · y copies a node");
2892+ press(&mut app, "y");
2893+ assert_eq!(said(&mut app), "copied “NAS” · p pastes");
2894+ select(&mut app, "Reading list");
2895+ press(&mut app, "p");
2896+ assert_eq!(said(&mut app), "pasted “NAS”");
2897+ // the Copy button of the node menu says the same
2898+ select(&mut app, "Work");
2899+ app.run_action(Action::Copy);
2900+ assert_eq!(said(&mut app), "copied “Work” · p pastes");
2901+}
2902+
2903+#[test]
2904+fn checking_a_task_names_it_and_a_node_that_is_no_task_says_so() {
2905+ let d = vault(SAMPLE);
2906+ let mut app = App::new(d.path()).unwrap();
2907+ select(&mut app, "NAS");
2908+ press(&mut app, "x");
2909+ assert_eq!(said(&mut app), "“NAS” isn't a task · t makes it one");
2910+ assert_eq!(root(&d), SAMPLE);
2911+ press(&mut app, "u");
2912+ assert_eq!(said(&mut app), "nothing to undo");
2913+ select(&mut app, "Draft the RFC");
2914+ press(&mut app, "x");
2915+ assert_eq!(said(&mut app), "done: “Draft the RFC”");
2916+ press(&mut app, "x");
2917+ assert_eq!(said(&mut app), "reopened: “Draft the RFC”");
2918+ press(&mut app, "t");
2919+ assert_eq!(said(&mut app), "removed the checkbox from “Draft the RFC”");
2920+ press(&mut app, "t");
2921+ assert_eq!(said(&mut app), "made “Draft the RFC” a task");
2922+ // x in the reading pane, on the line of a task, says the same
2923+ app.show_reading = true;
2924+ select(&mut app, "Project Atlas");
2925+ app.handle_key(key(KeyCode::Tab));
2926+ press(&mut app, "jj");
2927+ press(&mut app, "x");
2928+ assert!(root(&d).contains("- [x] Draft the RFC"), "{}", root(&d));
2929+ assert_eq!(said(&mut app), "done: “Draft the RFC”");
2930+}
2931+
2932+#[test]
2933+fn delete_and_make_block_name_the_node_and_show_no_file_or_id() {
2934+ let d = vault(SAMPLE);
2935+ let mut app = App::new(d.path()).unwrap();
2936+ select(&mut app, "Homelab");
2937+ press(&mut app, "d");
2938+ assert_eq!(said(&mut app), "deleted “Homelab” (12 nodes) · u undoes");
2939+ press(&mut app, "u");
2940+ select(&mut app, "rack");
2941+ press(&mut app, "s");
2942+ assert_eq!(said(&mut app), "gave “rack” its own file");
2943+ // a block is deleted like any node: by name, its file unsaid
2944+ press(&mut app, "d");
2945+ assert_eq!(said(&mut app), "deleted “rack” · u undoes");
2946+}
2947+
2948+#[test]
2949+fn moves_name_the_node_and_say_in_words_where_the_ordering_rule_put_it() {
2950+ let d = vault(SAMPLE);
2951+ let mut app = App::new(d.path()).unwrap();
2952+ select(&mut app, "rack");
2953+ press(&mut app, ">");
2954+ assert_eq!(said(&mut app), "indented “rack”");
2955+ press(&mut app, "<");
2956+ assert_eq!(said(&mut app), "outdented “rack”");
2957+ // nowhere to go: nothing changed, and the words do not say it did
2958+ let before = root(&d);
2959+ select(&mut app, "patch panel");
2960+ press(&mut app, ">");
2961+ assert_eq!(said(&mut app), "can't indent “patch panel”: nothing above it");
2962+ select(&mut app, "Homelab");
2963+ press(&mut app, "<");
2964+ assert_eq!(said(&mut app), "can't outdent “Homelab”: it's at the top level");
2965+ assert_eq!(root(&d), before);
2966+ // out of a heading among headings, an item goes before them
2967+ select(&mut app, "hourly, keep 24");
2968+ press(&mut app, "<");
2969+ assert_eq!(said(&mut app), "outdented “hourly, keep 24” — placed before the sections");
2970+ // a bullet made a heading goes after the bullets
2971+ select(&mut app, "Replace the flaky switch");
2972+ press(&mut app, "~");
2973+ assert_eq!(said(&mut app), "made “Replace the flaky switch” a heading — placed after the items");
2974+ // a heading made a bullet, before the headings
2975+ select(&mut app, "Replace fan");
2976+ press(&mut app, "~");
2977+ assert_eq!(said(&mut app), "made “Replace fan” a bullet — placed before the sections");
2978+ // Move to… names where to
2979+ select(&mut app, "call the plumber");
2980+ press(&mut app, "r");
2981+ press(&mut app, "Project Atlas");
2982+ app.handle_key(key(KeyCode::Enter));
2983+ assert_eq!(said(&mut app), "moved “call the plumber” to “Project Atlas”");
2984+ select(&mut app, "renew passport");
2985+ press(&mut app, "r");
2986+ press(&mut app, "Homelab");
2987+ app.handle_key(key(KeyCode::Enter));
2988+ assert_eq!(said(&mut app), "moved “renew passport” to “Homelab” — placed before the sections");
2989+ select(&mut app, "Kickoff meeting");
2990+ press(&mut app, "za");
2991+ assert_eq!(said(&mut app), "archived “Kickoff meeting”");
2992+ assert!(!status(&mut app).contains('§'));
2993+}
2994+
2995+#[test]
2996+fn a_drop_the_ordering_rule_placed_says_where_in_words() {
2997+ let d = vault("# A\n\n- a1\n\n## S\n\n# B\n\n- b1\n");
2998+ let mut app = App::new(d.path()).unwrap();
2999+ // rows: A, a1, S, B, b1; b1 onto A's title goes into A, before S
3000+ draw(&mut app);
3001+ let (from, onto) = (app.hit_pos(Hit::Row(4)).unwrap(), app.hit_pos(Hit::Row(0)).unwrap());
3002+ let at = |kind, (column, row)| MouseEvent { kind, column, row, modifiers: KeyModifiers::NONE };
3003+ app.handle_mouse(at(MouseEventKind::Down(MouseButton::Left), from));
3004+ app.handle_mouse(at(MouseEventKind::Drag(MouseButton::Left), onto));
3005+ draw(&mut app);
3006+ app.handle_mouse(at(MouseEventKind::Up(MouseButton::Left), onto));
3007+ let r = root(&d);
3008+ assert!(r.find("- b1").is_some_and(|i| i < r.find("## S").unwrap()), "{}", r);
3009+ assert_eq!(said(&mut app), "moved “b1” — placed before the sections");
3010+}
3011+
3012+#[test]
3013+fn clear_done_counts_the_done_tasks_it_cleared() {
3014+ let d = vault(SAMPLE);
3015+ let mut app = App::new(d.path()).unwrap();
3016+ // under the zoom, only there
3017+ select(&mut app, "Networking");
3018+ app.handle_key(key(KeyCode::Enter));
3019+ app.run_action(Action::ClearDone);
3020+ assert_eq!(said(&mut app), "cleared 1 done task under “Networking”");
3021+ app.handle_key(key(KeyCode::Backspace));
3022+ app.handle_key(key(KeyCode::Backspace));
3023+ app.run_action(Action::ClearDone);
3024+ assert_eq!(said(&mut app), "cleared 2 done tasks");
3025+ app.run_action(Action::ClearDone);
3026+ assert_eq!(said(&mut app), "no done tasks to clear");
3027+}
3028+
3029+#[test]
3030+fn the_editor_s_own_saves_say_nothing_and_ctrl_s_says_saved() {
3031+ let d = vault("# Inbox\n\nnotes\n");
3032+ let mut app = App::new(d.path()).unwrap();
3033+ app.set_edit_keys(EditKeys::Normal);
3034+ press(&mut app, "e");
3035+ app.handle_key(key(KeyCode::End));
3036+ app.handle_key(key(KeyCode::Char('!')));
3037+ // the pause saves it; the status bar's right side says so, the left
3038+ // side nothing
3039+ std::thread::sleep(Duration::from_millis(800));
3040+ app.tick();
3041+ assert_eq!(root(&d), "# Inbox!\n\nnotes\n");
3042+ let s = said(&mut app);
3043+ assert!(!s.contains("saved") && !s.contains("block"), "{}", s);
3044+ // leaving saves too, as quietly
3045+ app.handle_key(key(KeyCode::Char('?')));
3046+ app.handle_key(key(KeyCode::Esc));
3047+ assert_eq!(root(&d), "# Inbox!?\n\nnotes\n");
3048+ let s = said(&mut app);
3049+ assert!(!s.contains("saved") && !s.contains("block"), "{}", s);
3050+ // Ctrl-S is asked for, and answered
3051+ press(&mut app, "e");
3052+ app.handle_key(key(KeyCode::End));
3053+ app.handle_key(key(KeyCode::Char('.')));
3054+ app.handle_key(ctrl('s'));
3055+ assert_eq!(root(&d), "# Inbox!?.\n\nnotes\n");
3056+ assert_eq!(said(&mut app), "saved");
3057+}
3058+
3059+#[test]
3060+fn where_the_bar_is_short_a_message_naming_two_nodes_cuts_the_longer_name_first() {
3061+ // 80×24: the file gives way, then the long destination, not the node
3062+ // that moved nor the words after it
3063+ let title = "Project Atlas quarterly planning and review notes";
3064+ let d = vault(&format!("# Networking\n\n- [ ] Label the cables\n - [ ] rack\n\n# {}\n\n- [ ] Draft the RFC\n\n## Milestones\n", title));
3065+ let mut app = App::new(d.path()).unwrap();
3066+ select(&mut app, "rack");
3067+ press(&mut app, "r");
3068+ press(&mut app, "Project Atlas");
3069+ app.handle_key(key(KeyCode::Enter));
3070+ let s = status80(&mut app);
3071+ assert!(s.starts_with(" moved “rack” to “Project Atlas quarter…” — placed before the sections ✓ saved "), "{}", s);
3072+ // wider, it is all there
3073+ assert_eq!(said(&mut app), format!("moved “rack” to “{}” — placed before the sections", title));
3074+ // the undo names both too
3075+ press(&mut app, "u");
3076+ let s = status80(&mut app);
3077+ assert!(s.starts_with(" undone: move “rack” to “Project Atlas quarterly planning and review…” ✓ saved "), "{}", s);
3078+}
3079+
3080+#[test]
3081+fn indent_and_outdent_refusals_fit_beside_the_badges_at_80_columns() {
3082+ // under a conflict copy, which comes with the ⚠ count: the count
3083+ // comes down to its number, and the reason and the name stay whole
3084+ let text = "# Networking\n\n- [ ] Replace the flaky switch\n- [ ] Label the cables\n";
3085+ let d = vault(text);
3086+ std::fs::write(d.path().join("root.sync-conflict-20260927-100000-PHONE.md"), text.replace("[ ] Replace", "[x] Replace")).unwrap();
3087+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
3088+ fold_core::merge::merge_sync_conflicts(&mut v, false).unwrap();
3089+ drop(v);
3090+ let mut app = App::new(d.path()).unwrap();
3091+ press(&mut app, "G>");
3092+ let s = status80(&mut app);
3093+ assert!(s.starts_with(" can't indent “Label the cables” into a conflict copy "), "{}", s);
3094+ assert!(s.ends_with(" ⚠ 1 root.md ✓ saved "), "{}", s);
3095+ assert_eq!(said(&mut app), "can't indent “Label the cables” into a conflict copy");
3096+ // with done hidden, nothing above and the top level, name and all
3097+ let d = vault(SAMPLE);
3098+ let mut app = App::new(d.path()).unwrap();
3099+ press(&mut app, "zd");
3100+ select(&mut app, "Homelab");
3101+ press(&mut app, ">");
3102+ let s = status80(&mut app);
3103+ assert!(s.starts_with(" can't indent “Homelab”: nothing above it "), "{}", s);
3104+ assert!(s.contains(" done hidden "), "{}", s);
3105+ press(&mut app, "<");
3106+ let s = status80(&mut app);
3107+ assert!(s.starts_with(" can't outdent “Homelab”: it's at the top level "), "{}", s);
3108+ assert_eq!(root(&d), SAMPLE);
3109+}
3110+
3111+#[test]
3112+fn the_spelling_refusal_on_a_conflict_pair_fits_beside_the_badges_at_80_columns() {
3113+ // it comes only while a pair exists, so with the ⚠ count, and done
3114+ // hidden is a remembered view: the reason stays whole, and the name
3115+ let d = vault(SAMPLE);
3116+ let theirs = SAMPLE.replace("no raidz.", "no raidz, one spare.").replace("[ ] Replace the", "[x] Replace the");
3117+ std::fs::write(d.path().join("root.sync-conflict-20260927-100000-PHONE.md"), theirs).unwrap();
3118+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
3119+ fold_core::merge::merge_sync_conflicts(&mut v, false).unwrap();
3120+ drop(v);
3121+ let before = root(&d);
3122+ let mut app = App::new(d.path()).unwrap();
3123+ press(&mut app, "zd");
3124+ select(&mut app, "NAS");
3125+ press(&mut app, "~");
3126+ let s = status80(&mut app);
3127+ assert!(s.starts_with(" can't respell “NAS”: resolve its conflict "), "{}", s);
3128+ assert!(s.contains(" done hidden "), "{}", s);
3129+ select_copy(&mut app);
3130+ press(&mut app, "~");
3131+ let s = status80(&mut app);
3132+ assert!(s.starts_with(" can't respell “NAS”: resolve its conflict "), "{}", s);
3133+ // a long name gives way, not the reason
3134+ select(&mut app, "Replace the flaky switch");
3135+ press(&mut app, "~");
3136+ let s = status80(&mut app);
3137+ assert!(s.starts_with(" can't respell “Replace the …”: resolve its conflict "), "{}", s);
3138+ assert_eq!(said(&mut app), "can't respell “Replace the flaky switch”: resolve its conflict");
3139+ assert_eq!(root(&d), before);
3140+}
3141+
3142+// ------------------------------------------------------------ the next step
3143+
3144+/// What the status bar says once its message has gone: the keys of what
3145+/// is on screen (§10.1).
3146+const OUTLINE_KEYS: &str = "n new · e edit · x done · m menu · / find · ? help";
3147+const READING_KEYS: &str = "e edit · Enter zoom/follow · Tab outline";
3148+const EDITOR_KEYS: &str = "Esc done · Ctrl-S save · Ctrl-Z undo";
3149+const VIM_KEYS: &str = "i insert · :q! revert · :wq done";
3150+const VIM_INSERT_KEYS: &str = "Esc normal mode · :q! revert · :wq done";
3151+const PROPS_KEYS: &str = "n add · Enter change · d delete · Esc close";
3152+
3153+#[test]
3154+fn a_message_gives_way_to_the_keys_once_it_is_old_and_something_was_done_since() {
3155+ let d = vault(SAMPLE);
3156+ let mut app = App::new(d.path()).unwrap();
3157+ select(&mut app, "NAS");
3158+ press(&mut app, "y");
3159+ assert_eq!(said(&mut app), "copied “NAS” · p pastes");
3160+ // a key since, but the message is new: it stays
3161+ press(&mut app, "j");
3162+ assert_eq!(said(&mut app), "copied “NAS” · p pastes");
3163+ // five seconds on, with no key since the key: its time is over
3164+ std::thread::sleep(Duration::from_millis(5100));
3165+ assert_eq!(said(&mut app), OUTLINE_KEYS);
3166+}
3167+
3168+#[test]
3169+fn a_message_no_key_came_after_stays_until_one_does() {
3170+ // for someone who looked away: the message waits for them
3171+ let d = vault(SAMPLE);
3172+ let mut app = App::new(d.path()).unwrap();
3173+ select(&mut app, "Draft the RFC");
3174+ press(&mut app, "x");
3175+ std::thread::sleep(Duration::from_millis(5100));
3176+ assert_eq!(said(&mut app), "done: “Draft the RFC”");
3177+ press(&mut app, "k");
3178+ assert_eq!(said(&mut app), OUTLINE_KEYS);
3179+ // the greeting is a message like any other
3180+ let mut app = App::new(d.path()).unwrap();
3181+ assert!(said(&mut app).contains("right-click for actions"), "{}", said(&mut app));
3182+}
3183+
3184+#[test]
3185+fn a_message_a_click_said_stays_through_the_click_s_own_release() {
3186+ // the button let go, or a wobble while it is down, is the click that
3187+ // said it, not a click since; a drag's release ends the drag, and
3188+ // what the drag said on its way goes as before
3189+ let at = |kind, (column, row)| MouseEvent { kind, column, row, modifiers: KeyModifiers::NONE };
3190+ let (down, drag, up) = (
3191+ MouseEventKind::Down(MouseButton::Left),
3192+ MouseEventKind::Drag(MouseButton::Left),
3193+ MouseEventKind::Up(MouseButton::Left),
3194+ );
3195+ let on = |app: &mut App, title: &str, hit: fn(usize) -> Hit| {
3196+ draw(app);
3197+ let i = app.rows().iter().position(|r| app.title_of(r.nref) == title).unwrap();
3198+ app.hit_pos(hit(i)).unwrap()
3199+ };
3200+ // rack's ☐, clicked
3201+ let d1 = vault(SAMPLE);
3202+ let mut clicked = App::new(d1.path()).unwrap();
3203+ let p = on(&mut clicked, "rack", Hit::Check);
3204+ clicked.handle_mouse(at(down, p));
3205+ clicked.handle_mouse(at(up, p));
3206+ assert_eq!(said(&mut clicked), "done: “rack”");
3207+ // Review PR's, with the pointer a cell off by the time it is let go
3208+ let d2 = vault(SAMPLE);
3209+ let mut wobbled = App::new(d2.path()).unwrap();
3210+ let p = on(&mut wobbled, "Review PR", Hit::Check);
3211+ wobbled.handle_mouse(at(down, p));
3212+ wobbled.handle_mouse(at(drag, (p.0 + 1, p.1)));
3213+ wobbled.handle_mouse(at(up, (p.0 + 1, p.1)));
3214+ assert_eq!(said(&mut wobbled), "done: “Review PR”");
3215+ // NAS dragged along its own row and let go there
3216+ let d3 = vault(SAMPLE);
3217+ let mut dragged = App::new(d3.path()).unwrap();
3218+ let p = on(&mut dragged, "NAS", Hit::Row);
3219+ let over = (p.0 + 5, p.1);
3220+ dragged.handle_mouse(at(down, p));
3221+ dragged.handle_mouse(at(drag, over));
3222+ dragged.handle_mouse(at(up, over));
3223+ assert!(said(&mut dragged).starts_with("moving “NAS”"), "{}", said(&mut dragged));
3224+ std::thread::sleep(Duration::from_millis(5100));
3225+ assert_eq!(said(&mut clicked), "done: “rack”");
3226+ assert_eq!(said(&mut wobbled), "done: “Review PR”");
3227+ assert_eq!(said(&mut dragged), OUTLINE_KEYS);
3228+ // the next click is one since
3229+ let p = on(&mut clicked, "NAS", Hit::Row);
3230+ clicked.handle_mouse(at(down, p));
3231+ clicked.handle_mouse(at(up, p));
3232+ assert_eq!(said(&mut clicked), OUTLINE_KEYS);
3233+}
3234+
3235+#[test]
3236+fn an_error_or_refusal_stays_until_the_next_key_after_it_was_read() {
3237+ let d = vault(SAMPLE);
3238+ let mut app = App::new(d.path()).unwrap();
3239+ select(&mut app, "Homelab");
3240+ press(&mut app, "<");
3241+ let refusal = "can't outdent “Homelab”: it's at the top level";
3242+ assert_eq!(said(&mut app), refusal);
3243+ // a key right after, while it was being read: it stays, however long
3244+ press(&mut app, "j");
3245+ std::thread::sleep(Duration::from_millis(5100));
3246+ assert_eq!(said(&mut app), refusal);
3247+ // the next key gets it out of the way
3248+ press(&mut app, "k");
3249+ assert_eq!(said(&mut app), OUTLINE_KEYS);
3250+}
3251+
3252+#[test]
3253+fn each_mode_shows_its_own_keys() {
3254+ let d = vault(SAMPLE);
3255+ let mut app = App::new(d.path()).unwrap();
3256+ // the outline's greeting does not follow into the editor, where ?
3257+ // types a ?
3258+ select(&mut app, "NAS");
3259+ press(&mut app, "e");
3260+ let s = said(&mut app);
3261+ assert!(s == EDITOR_KEYS || s == VIM_KEYS, "{}", s);
3262+ app.handle_key(key(KeyCode::Esc));
3263+ // once a message has gone, the keys of what is on screen
3264+ app.set_edit_keys(EditKeys::Normal);
3265+ app.say("");
3266+ assert_eq!(said(&mut app), OUTLINE_KEYS);
3267+ press(&mut app, "e");
3268+ assert_eq!(said(&mut app), EDITOR_KEYS);
3269+ app.handle_key(key(KeyCode::Esc));
3270+ // in Vim and Helix, Esc never leaves the editor
3271+ for keys in [EditKeys::Vim, EditKeys::Helix] {
3272+ app.set_edit_keys(keys);
3273+ press(&mut app, "e");
3274+ app.say("");
3275+ assert_eq!(said(&mut app), VIM_KEYS);
3276+ press(&mut app, "i");
3277+ assert_eq!(said(&mut app), VIM_INSERT_KEYS);
3278+ app.handle_key(key(KeyCode::Esc));
3279+ assert_eq!(said(&mut app), VIM_KEYS);
3280+ press(&mut app, ":wq");
3281+ app.handle_key(key(KeyCode::Enter));
3282+ assert_eq!(app.mode_pub(), "normal");
3283+ assert_eq!(said(&mut app), OUTLINE_KEYS);
3284+ }
3285+ press(&mut app, "a");
3286+ assert_eq!(said(&mut app), PROPS_KEYS);
3287+ app.handle_key(key(KeyCode::Esc));
3288+ app.show_reading = true;
3289+ app.handle_key(key(KeyCode::Tab));
3290+ assert_eq!(said(&mut app), READING_KEYS);
3291+ assert_eq!(root(&d), SAMPLE);
3292+}
3293+
3294+#[test]
3295+fn the_conflict_view_shows_its_keys_while_it_has_a_pair_to_resolve() {
3296+ let (_d, mut app) = lab();
3297+ app.run_action(Action::ResolveConflicts);
3298+ app.say("");
3299+ assert_eq!(said(&mut app), "o ours · t theirs · b both · n next · Esc close");
3300+ press(&mut app, "tt");
3301+ assert_eq!(pairs(&mut app), 0);
3302+ app.say("");
3303+ assert_eq!(said(&mut app), "Esc close");
3304+}
3305+
3306+#[test]
3307+fn z_and_g_show_what_can_follow_and_a_wrong_second_key_says_so() {
3308+ let d = vault(SAMPLE);
3309+ let mut app = App::new(d.path()).unwrap();
3310+ press(&mut app, "z");
3311+ assert_eq!(said(&mut app), "z… p pane · w wrap · d hide done · r raw · a archive");
3312+ // zq quits nothing, and says so
3313+ press(&mut app, "q");
3314+ assert!(!app.quit_requested());
3315+ assert_eq!(said(&mut app), "zq does nothing · after z press p, w, d, r or a");
3316+ press(&mut app, "g");
3317+ assert_eq!(said(&mut app), "g… g top");
3318+ app.handle_key(key(KeyCode::Enter));
3319+ assert_eq!(said(&mut app), "g Enter does nothing · after g press g");
3320+ // Esc lets the first key go, quietly
3321+ press(&mut app, "z");
3322+ app.handle_key(key(KeyCode::Esc));
3323+ assert_eq!(said(&mut app), "g Enter does nothing · after g press g");
3324+ // zd's words say what it would do now, and lead while done is hidden
3325+ press(&mut app, "zd");
3326+ press(&mut app, "z");
3327+ assert_eq!(said(&mut app), "z… d show done · p pane · w wrap · r raw · a archive");
3328+ app.handle_key(key(KeyCode::Esc));
3329+ // [[ and ]] in the reading pane
3330+ app.show_reading = true;
3331+ app.handle_key(key(KeyCode::Tab));
3332+ press(&mut app, "]");
3333+ assert_eq!(said(&mut app), "]… ] next heading");
3334+ assert_eq!(root(&d), SAMPLE);
3335+}
3336+
3337+#[test]
3338+fn a_click_lets_a_half_typed_key_go() {
3339+ // z, then a click on ⌕ Filter: what is typed next is the filter's,
3340+ // and its a archives nothing
3341+ let d = vault(SAMPLE);
3342+ let mut app = App::new(d.path()).unwrap();
3343+ select(&mut app, "NAS");
3344+ press(&mut app, "z");
3345+ click_button(&mut app, Action::Filter);
3346+ assert_eq!(app.mode_pub(), "filter");
3347+ assert_eq!(said(&mut app), "↑↓ pick · Enter go · Esc close");
3348+ press(&mut app, "archive");
3349+ let s = screen(&mut app);
3350+ assert!(s.contains("archive"), "{}", s);
3351+ assert!(!s.contains("does nothing"), "{}", s);
3352+ assert_eq!(root(&d), SAMPLE, "a key after z and a click archived");
3353+ app.handle_key(key(KeyCode::Esc));
3354+ // + Capture: the prompt takes every letter
3355+ press(&mut app, "g");
3356+ click_button(&mut app, Action::Capture);
3357+ press(&mut app, "gas bill");
3358+ app.handle_key(key(KeyCode::Enter));
3359+ assert!(root(&d).contains("\n- gas bill\n"), "{}", root(&d));
3360+ // ✎ Edit, over the reading pane: the editor takes the letter
3361+ app.set_edit_keys(EditKeys::Normal);
3362+ app.show_reading = true;
3363+ select(&mut app, "call the plumber");
3364+ press(&mut app, "z");
3365+ click_button(&mut app, Action::Edit);
3366+ assert_eq!(app.mode_pub(), "edit");
3367+ press(&mut app, "a");
3368+ app.handle_key(key(KeyCode::Esc));
3369+ assert!(root(&d).contains("\na- call the plumber\n"), "{}", root(&d));
3370+ // a click on a row: the next key is a key of its own
3371+ press(&mut app, "z");
3372+ let (x, y) = find(&frame(&mut app), "Networking").unwrap();
3373+ click_at(&mut app, x, y, MouseButton::Left);
3374+ assert!(!said(&mut app).starts_with("z…"), "{}", said(&mut app));
3375+ press(&mut app, "a");
3376+ assert_eq!(app.mode_pub(), "props");
3377+ app.handle_key(key(KeyCode::Esc));
3378+ assert!(root(&d).contains("## Networking\n"), "{}", root(&d));
3379+}
3380+
3381+#[test]
3382+fn keys_fold_does_not_use_say_what_to_press_instead() {
3383+ let d = vault(SAMPLE);
3384+ let mut app = App::new(d.path()).unwrap();
3385+ select(&mut app, "NAS");
3386+ press(&mut app, "i");
3387+ assert_eq!(said(&mut app), "i does nothing here: e edits");
3388+ press(&mut app, "o");
3389+ assert_eq!(said(&mut app), "o does nothing here: n adds a node below");
3390+ app.handle_key(key(KeyCode::Delete));
3391+ assert_eq!(said(&mut app), "Delete does nothing here: d deletes");
3392+ app.handle_key(ctrl('z'));
3393+ assert_eq!(said(&mut app), "Ctrl-Z does nothing here: u undoes");
3394+ app.handle_key(ctrl('f'));
3395+ assert_eq!(said(&mut app), "Ctrl-F does nothing here: / finds");
3396+ assert_eq!(root(&d), SAMPLE);
3397+ assert_eq!(app.mode_pub(), "normal");
3398+ // F1 is help, from the outline, the reading pane and the editor, where
3399+ // ? types a ?
3400+ app.handle_key(key(KeyCode::F(1)));
3401+ assert_eq!(app.mode_pub(), "help");
3402+ app.handle_key(key(KeyCode::Esc));
3403+ app.show_reading = true;
3404+ app.handle_key(key(KeyCode::Tab));
3405+ press(&mut app, "i");
3406+ assert_eq!(said(&mut app), "i does nothing here: e edits");
3407+ app.handle_key(ctrl('f'));
3408+ assert_eq!(said(&mut app), "Ctrl-F does nothing here: / searches");
3409+ app.handle_key(key(KeyCode::Delete));
3410+ assert_eq!(said(&mut app), "Delete does nothing here: m, then choose Delete");
3411+ app.handle_key(key(KeyCode::F(1)));
3412+ assert_eq!(app.mode_pub(), "help");
3413+ app.handle_key(key(KeyCode::Esc));
3414+ app.set_edit_keys(EditKeys::Normal);
3415+ press(&mut app, "e");
3416+ app.handle_key(key(KeyCode::F(1)));
3417+ assert_eq!(app.mode_pub(), "help");
3418+ app.handle_key(key(KeyCode::Esc));
3419+ assert_eq!(app.mode_pub(), "edit");
3420+ app.handle_key(key(KeyCode::Esc));
3421+ assert_eq!(root(&d), SAMPLE);
3422+}
3423+
3424+#[test]
3425+fn delete_in_the_reading_pane_points_at_the_menu_of_the_line_s_node() {
3426+ let d = vault(SAMPLE);
3427+ let mut app = App::new(d.path()).unwrap();
3428+ app.show_reading = true;
3429+ select(&mut app, "NAS");
3430+ app.handle_key(key(KeyCode::Tab));
3431+ // down to a child's heading: the pane shows NAS, the line is Replace fan
3432+ let at = app.reading_doc_pub().lines.iter().position(|l| l.contains("Replace fan")).unwrap();
3433+ for _ in 0..at {
3434+ press(&mut app, "j");
3435+ draw(&mut app);
3436+ }
3437+ app.handle_key(key(KeyCode::Delete));
3438+ // Tab, then d would delete NAS, the outline's selection
3439+ assert_eq!(said(&mut app), "Delete does nothing here: m, then choose Delete");
3440+ assert_eq!(root(&d), SAMPLE);
3441+ // done as it says, it deletes the line's node and nothing else
3442+ press(&mut app, "m");
3443+ // ↓ down to Delete, the menu's last item
3444+ for _ in 0..node_menu_index(Action::Delete) {
3445+ app.handle_key(key(KeyCode::Down));
3446+ }
3447+ app.handle_key(key(KeyCode::Enter));
3448+ assert_eq!(said(&mut app), "deleted “Replace fan” · u undoes");
3449+ assert_eq!(root(&d), SAMPLE.replace("### [x] Replace fan\n\n", ""));
3450+}
3451+
3452+/// `m` in the reading pane, then a click on `a` in the menu it opens.
3453+fn choose(app: &mut App, a: Action) {
3454+ press(app, "m");
3455+ draw(app);
3456+ let (x, y) = app.hit_pos(Hit::MenuItem(node_menu_index(a))).unwrap();
3457+ app.handle_mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column: x, row: y, modifiers: KeyModifiers::NONE });
3458+}
3459+
3460+#[test]
3461+fn a_verb_from_the_reading_pane_s_menu_names_the_next_step_in_that_menu() {
3462+ let d = vault(SAMPLE);
3463+ let mut app = App::new(d.path()).unwrap();
3464+ app.show_reading = true;
3465+ select(&mut app, "NAS");
3466+ app.handle_key(key(KeyCode::Tab));
3467+ // y, p, P and t are the outline's: here they do nothing
3468+ press(&mut app, "ypPt");
3469+ assert_eq!(root(&d), SAMPLE);
3470+ choose(&mut app, Action::PasteAfter);
3471+ assert_eq!(said(&mut app), "nothing copied yet · m, then choose Copy");
3472+ press(&mut app, "x");
3473+ assert_eq!(said(&mut app), "“NAS” isn't a task · m, then choose Task on / off");
3474+ choose(&mut app, Action::Copy);
3475+ assert_eq!(said(&mut app), "copied “NAS” · m, then choose Paste after");
3476+ press(&mut app, "pP");
3477+ assert_eq!(root(&d), SAMPLE);
3478+ // the menu's Done / reopen says what the pane's x does
3479+ choose(&mut app, Action::ToggleDone);
3480+ assert_eq!(said(&mut app), "“NAS” isn't a task · m, then choose Task on / off");
3481+ assert_eq!(root(&d), SAMPLE);
3482+ // done as each says, it acts on the line's node
3483+ choose(&mut app, Action::ToggleTask);
3484+ assert_eq!(said(&mut app), "made “NAS” a task");
3485+ let at = app.reading_doc_pub().lines.iter().position(|l| l.contains("Replace fan")).unwrap();
3486+ for _ in 0..at {
3487+ press(&mut app, "j");
3488+ draw(&mut app);
3489+ }
3490+ choose(&mut app, Action::PasteAfter);
3491+ assert_eq!(said(&mut app), "pasted “NAS”");
3492+ assert_eq!(root(&d).matches("NAS\n").count(), 2, "{}", root(&d));
3493+ // in the outline, the keys are the outline's
3494+ app.handle_key(key(KeyCode::Tab));
3495+ press(&mut app, "y");
3496+ assert!(said(&mut app).ends_with(" · p pastes"), "{}", said(&mut app));
3497+}
3498+
3499+/// Right-click the outline row that starts `title`, then click `a` in the
3500+/// menu it opens.
3501+fn right_click(app: &mut App, title: &str, a: Action) {
3502+ let b = frame(app);
3503+ let (x, y) = find(&b, title).unwrap_or_else(|| panic!("no “{}” on screen:\n{}", title, text(&b)));
3504+ click_at(app, x, y, MouseButton::Right);
3505+ draw(app);
3506+ let (x, y) = app.hit_pos(Hit::MenuItem(node_menu_index(a))).unwrap();
3507+ click_at(app, x, y, MouseButton::Left);
3508+}
3509+
3510+#[test]
3511+fn a_verb_run_while_editing_names_the_pointer_s_next_step_not_a_key_the_editor_types() {
3512+ let d = vault(SAMPLE);
3513+ let mut app = App::new(d.path()).unwrap();
3514+ select(&mut app, "NAS");
3515+ press(&mut app, "e");
3516+ // every key is text here (§10.6): the step is a row's menu, or ↶ Undo
3517+ right_click(&mut app, "Networking", Action::PasteAfter);
3518+ assert_eq!(said(&mut app), "nothing copied yet · right-click, Copy");
3519+ right_click(&mut app, "Networking", Action::ToggleDone);
3520+ assert_eq!(said(&mut app), "“Networking” isn't a task · right-click, Task on / off");
3521+ right_click(&mut app, "Networking", Action::Copy);
3522+ assert_eq!(said(&mut app), "copied “Networking” · right-click, Paste after");
3523+ right_click(&mut app, "VLANs: 10 home", Action::Delete);
3524+ assert_eq!(said(&mut app), "deleted “VLANs: 10 home, 20 iot, 30 guest” · click ↶ Undo");
3525+ assert_eq!(app.mode_pub(), "edit");
3526+ // done as it says, it undoes, and the editor stays
3527+ click_button(&mut app, Action::Undo);
3528+ assert_eq!(root(&d), SAMPLE);
3529+ assert_eq!(app.mode_pub(), "edit");
3530+ // the palette's verbs, run while editing, say the same
3531+ click_button(&mut app, Action::Palette);
3532+ press(&mut app, "Copy");
3533+ app.handle_key(key(KeyCode::Enter));
3534+ assert!(said(&mut app).ends_with(" · right-click, Paste after"), "{}", said(&mut app));
3535+ // the editor done, the keys are the outline's again
3536+ app.handle_key(key(KeyCode::Esc));
3537+ select(&mut app, "Networking");
3538+ press(&mut app, "y");
3539+ assert_eq!(said(&mut app), "copied “Networking” · p pastes");
3540+ select(&mut app, "rack");
3541+ press(&mut app, "d");
3542+ assert_eq!(said(&mut app), "deleted “rack” · u undoes");
3543+}
3544+
3545+#[test]
3546+fn at_80_columns_the_editor_s_pointer_steps_keep_the_node_and_the_item_to_choose() {
3547+ // 80×24 while editing: EDIT and ✓ saved leave 62 columns once the file
3548+ // gives way, 53 beside it, and with done hidden 49 (§10.1)
3549+ for hide in [false, true] {
3550+ let d = vault(SAMPLE);
3551+ let mut app = App::new(d.path()).unwrap();
3552+ select(&mut app, "NAS");
3553+ if hide {
3554+ press(&mut app, "zd");
3555+ }
3556+ press(&mut app, "e");
3557+ right_click(&mut app, "Networking", Action::PasteAfter);
3558+ let s = status80(&mut app);
3559+ assert!(s.starts_with(" EDIT nothing copied yet · right-click, Copy "), "{}", s);
3560+ assert!(s.ends_with(" root.md ✓ saved "), "{}", s);
3561+ right_click(&mut app, "Networking", Action::Copy);
3562+ let s = status80(&mut app);
3563+ assert!(s.starts_with(" EDIT copied “Networking” · right-click, Paste after "), "{}", s);
3564+ assert!(s.ends_with(if hide { " done hidden ✓ saved " } else { " root.md ✓ saved " }), "{}", s);
3565+ // the longest: the file gives way, and with done hidden the name
3566+ // is cut, never the item to choose
3567+ right_click(&mut app, "Networking", Action::ToggleDone);
3568+ let s = status80(&mut app);
3569+ if hide {
3570+ assert!(s.starts_with(" EDIT “Netw…” isn't a task · right-click, Task on / off done hidden "), "{}", s);
3571+ } else {
3572+ assert!(s.starts_with(" EDIT “Networking” isn't a task · right-click, Task on / off "), "{}", s);
3573+ assert!(s.ends_with(" ✓ saved "), "{}", s);
3574+ }
3575+ assert_eq!(root(&d), SAMPLE);
3576+ assert_eq!(app.mode_pub(), "edit");
3577+ }
3578+}
3579+
3580+#[test]
3581+fn undo_and_redo_say_in_words_what_they_undid() {
3582+ let d = vault(SAMPLE);
3583+ let mut app = App::new(d.path()).unwrap();
3584+ select(&mut app, "Draft the RFC");
3585+ press(&mut app, "xu");
3586+ assert_eq!(said(&mut app), "undone: mark “Draft the RFC” done");
3587+ press(&mut app, "U");
3588+ assert_eq!(said(&mut app), "redone: mark “Draft the RFC” done");
3589+ press(&mut app, "xu");
3590+ assert_eq!(said(&mut app), "undone: reopen “Draft the RFC”");
3591+ press(&mut app, "U");
3592+ assert_eq!(said(&mut app), "redone: reopen “Draft the RFC”");
3593+ select(&mut app, "Homelab");
3594+ press(&mut app, "du");
3595+ assert_eq!(said(&mut app), "undone: delete “Homelab”");
3596+ select(&mut app, "rack");
3597+ press(&mut app, ">u");
3598+ assert_eq!(said(&mut app), "undone: indent “rack”");
3599+ select(&mut app, "rack");
3600+ press(&mut app, "tu");
3601+ assert_eq!(said(&mut app), "undone: remove the checkbox from “rack”");
3602+ select(&mut app, "call the plumber");
3603+ press(&mut app, "r");
3604+ press(&mut app, "Project Atlas");
3605+ app.handle_key(key(KeyCode::Enter));
3606+ press(&mut app, "u");
3607+ assert_eq!(said(&mut app), "undone: move “call the plumber” to “Project Atlas”");
3608+ // the editor's saves, by the node written
3609+ app.set_edit_keys(EditKeys::Normal);
3610+ select(&mut app, "NAS");
3611+ press(&mut app, "e");
3612+ app.handle_key(key(KeyCode::End));
3613+ press(&mut app, " box");
3614+ app.handle_key(key(KeyCode::Esc));
3615+ press(&mut app, "u");
3616+ assert_eq!(said(&mut app), "undone: edit “NAS”");
3617+ // the reading pane's x, as the outline's
3618+ app.show_reading = true;
3619+ select(&mut app, "Project Atlas");
3620+ app.handle_key(key(KeyCode::Tab));
3621+ press(&mut app, "jjxu");
3622+ assert_eq!(said(&mut app), "undone: mark “Draft the RFC” done");
3623+ assert_eq!(root(&d), SAMPLE);
3624+ // a refusal names the file, and the step in words (§10.10)
3625+ app.handle_key(key(KeyCode::Tab));
3626+ select(&mut app, "rack");
3627+ press(&mut app, "x");
3628+ std::fs::write(d.path().join("root.md"), root(&d).replace("VLANs", "VLAN")).unwrap();
3629+ press(&mut app, "u");
3630+ assert_eq!(said(&mut app), "undo refused: root.md changed since mark “rack” done; not overwriting");
3631+}
3632+
3633+#[test]
3634+fn a_hint_too_long_for_the_bar_loses_whole_parts_and_keeps_the_way_to_help() {
3635+ let d = vault(SAMPLE);
3636+ let mut app = App::new(d.path()).unwrap();
3637+ press(&mut app, "zd");
3638+ app.say("");
3639+ // 80 columns, with done hidden, the file and the save state on the right
3640+ let mut t = Terminal::new(TestBackend::new(80, 24)).unwrap();
3641+ t.draw(|f| app.draw(f)).unwrap();
3642+ let b = t.backend().buffer().clone();
3643+ let bar: String = (0..80).map(|x| b[(x, 23)].symbol()).collect();
3644+ assert!(bar.contains("n new · e edit · x done · m menu · ? help "), "{}", bar);
3645+ assert!(bar.contains("done hidden") && !bar.contains('…'), "{}", bar);
3646+}
3647+
3648+#[test]
3649+fn a_short_bar_keeps_the_key_that_shows_hidden_done_tasks_in_the_z_hint() {
3650+ // 80 columns, with the conflicts and done hidden on the right
3651+ let (_d, mut app) = lab();
3652+ press(&mut app, "zd");
3653+ app.say("");
3654+ press(&mut app, "z");
3655+ let bar = status80(&mut app);
3656+ assert!(bar.contains("⚠ 2 conflicts") && bar.contains("done hidden"), "{}", bar);
3657+ assert!(bar.contains("d show done") && bar.contains("p pane"), "{}", bar);
3658+ assert_eq!(bar.matches('…').count(), 1, "{}", bar);
3659+ // Esc lets z go; zd shows them again
3660+ app.handle_key(key(KeyCode::Esc));
3661+ press(&mut app, "zd");
3662+ assert!(!status80(&mut app).contains("done hidden"));
3663+}
3664+
3665+#[test]
3666+fn a_short_bar_keeps_the_vim_and_helix_editor_s_way_out_that_saves() {
3667+ // 80 columns, editing a block: its file on the right leaves the keys
3668+ // too little room, and :q! revert goes before :wq done
3669+ for title in ["Snapshot policy", "Snapshot policy for tank"] {
3670+ let d = vault(&SAMPLE.replace("Snapshot policy", title));
3671+ let mut app = App::new(d.path()).unwrap();
3672+ select(&mut app, title);
3673+ press(&mut app, "s");
3674+ for keys in [EditKeys::Vim, EditKeys::Helix] {
3675+ app.set_edit_keys(keys);
3676+ press(&mut app, "e");
3677+ app.say("");
3678+ let bar = status80(&mut app);
3679+ assert!(bar.contains("i insert") && bar.contains(":wq done"), "{}", bar);
3680+ press(&mut app, "i");
3681+ let bar = status80(&mut app);
3682+ assert!(bar.contains("Esc normal mode") && bar.contains(":wq done"), "{}", bar);
3683+ assert!(!bar.contains(":q! revert"), "{}", bar);
3684+ app.handle_key(key(KeyCode::Esc));
3685+ press(&mut app, ":wq");
3686+ app.handle_key(key(KeyCode::Enter));
3687+ assert_eq!(app.mode_pub(), "normal");
3688+ }
3689+ }
3690+}
3691+
3692+// ------------------------------------------------------------ popups
3693+
3694+/// Right-click inside the popup whose top border starts `title`, off its
3695+/// rows and buttons: the first cell within its border.
3696+fn right_click_in(app: &mut App, title: &str) {
3697+ let b = frame(app);
3698+ let at = format!("╭ {}", title);
3699+ let (x, y) = find(&b, &at).unwrap_or_else(|| panic!("no “{}” on screen:\n{}", at, text(&b)));
3700+ click_at(app, x + 1, y + 1, MouseButton::Right);
3701+}
3702+
3703+/// Whether the last frame drew the node menu.
3704+fn menu_shown(app: &App) -> bool {
3705+ app.hit_pos(Hit::MenuItem(node_menu_index(Action::Delete))).is_some()
3706+}
3707+
3708+#[test]
3709+fn a_right_click_in_the_property_form_opens_no_menu_on_the_node_behind_it() {
3710+ // the form covers the reading pane: a right-click in it is on no row
3711+ // or line (§10.1). The node menu it opened there ran Delete on the
3712+ // form's own node, and the next frame drew the form on a node gone
3713+ let d = vault(SAMPLE);
3714+ let mut app = App::new(d.path()).unwrap();
3715+ press(&mut app, "G");
3716+ assert_eq!(selected(&app), "renew passport");
3717+ press(&mut app, "a");
3718+ right_click_in(&mut app, "Properties — renew passport");
3719+ let b = frame(&mut app);
3720+ assert!(!menu_shown(&app), "{}", text(&b));
3721+ assert_eq!(app.mode_pub(), "props");
3722+ assert!(text(&b).contains("Properties — renew passport"), "{}", text(&b));
3723+ assert_eq!(root(&d), SAMPLE);
3724+ // Esc closes the form, and the selection is where it was
3725+ app.handle_key(key(KeyCode::Esc));
3726+ assert_eq!(app.mode_pub(), "normal");
3727+ assert_eq!(selected(&app), "renew passport");
3728+}
3729+
3730+#[test]
3731+fn a_right_click_in_any_popup_opens_no_menu_on_the_node_behind_it() {
3732+ // the filter's hits and a prompt's picks are nodes too: a verb from a
3733+ // menu behind them renumbers what they list
3734+ let d = vault(SAMPLE);
3735+ let mut app = App::new(d.path()).unwrap();
3736+ app.show_reading = true;
3737+ select(&mut app, "NAS");
3738+ for (keys, title) in [("/", "Filter"), (":", "Commands"), ("?", "Help"), ("r", "Move to — pick a node")] {
3739+ press(&mut app, keys);
3740+ right_click_in(&mut app, title);
3741+ let b = frame(&mut app);
3742+ assert!(!menu_shown(&app), "{}: {}", title, text(&b));
3743+ assert!(find(&b, &format!("╭ {}", title)).is_some(), "{} closed:\n{}", title, text(&b));
3744+ app.handle_key(key(KeyCode::Esc));
3745+ assert_eq!(app.mode_pub(), "normal", "{}", title);
3746+ }
3747+ assert_eq!(root(&d), SAMPLE);
3748+}
3749+
3750+/// The help's lines as its popup starts them: 60 characters, room for
3751+/// which there is at 80 columns.
3752+fn help_lines() -> Vec<String> {
3753+ fold_tui::app::help_text()
3754+ .iter()
3755+ .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::<String>())
3756+ .map(|l| l.chars().take(60).collect::<String>().trim_end().to_string())
3757+ .filter(|l| !l.is_empty())
3758+ .collect()
3759+}
3760+
3761+#[test]
3762+fn the_help_scrolls_where_the_terminal_is_too_short_for_it() {
3763+ // every popup is a list you can scroll (§10.1). From the Vim editor at
3764+ // 120×32 the help ended at the normal keymap's ^K line, the vim/helix
3765+ // and mouse lines out of reach, and at 80×24 so was the whole EDITOR
3766+ // section: j, ↓ and the wheel did nothing
3767+ let lines = help_lines();
3768+ let (first, last) = (&lines[0], &lines[lines.len() - 1]);
3769+ let d = vault(SAMPLE);
3770+ let mut app = App::new(d.path()).unwrap();
3771+ // its keys say so
3772+ press(&mut app, "?");
3773+ assert_eq!(said(&mut app), "↑↓ scroll · Esc close");
3774+ app.handle_key(key(KeyCode::Esc));
3775+ app.set_edit_keys(EditKeys::Vim);
3776+ press(&mut app, "je");
3777+ for (w, h) in [(120, 32), (80, 24)] {
3778+ for (down, up) in [(key(KeyCode::Char('j')), key(KeyCode::Char('k'))), (key(KeyCode::Down), key(KeyCode::Up))] {
3779+ app.handle_key(key(KeyCode::F(1)));
3780+ let mut seen = text(&frame_at(&mut app, w, h));
3781+ assert!(seen.contains(first.as_str()) && !seen.contains(last.as_str()), "{}×{}:\n{}", w, h, seen);
3782+ // down to its last line, every line on the way
3783+ for _ in 0..lines.len() {
3784+ app.handle_key(down);
3785+ seen += &text(&frame_at(&mut app, w, h));
3786+ }
3787+ let b = text(&frame_at(&mut app, w, h));
3788+ assert!(b.contains(last.as_str()) && !b.contains(first.as_str()), "{}×{} {:?}:\n{}", w, h, down.code, b);
3789+ for l in &lines {
3790+ assert!(seen.contains(l.as_str()), "{}×{} {:?}: never shown: {}", w, h, down.code, l);
3791+ }
3792+ // and back up to its first
3793+ for _ in 0..lines.len() {
3794+ app.handle_key(up);
3795+ }
3796+ let b = text(&frame_at(&mut app, w, h));
3797+ assert!(b.contains(first.as_str()) && !b.contains(last.as_str()), "{}×{} {:?}:\n{}", w, h, up.code, b);
3798+ app.handle_key(key(KeyCode::Esc));
3799+ assert_eq!(app.mode_pub(), "edit");
3800+ }
3801+ }
3802+ // the wheel over it scrolls it too, where it reached nothing
3803+ app.handle_key(key(KeyCode::F(1)));
3804+ wheel(&mut app, Hit::Popup, 10);
3805+ let s = screen(&mut app);
3806+ assert!(s.contains("vim/helix the usual modes") && s.contains(last.as_str()), "{}", s);
3807+ wheel(&mut app, Hit::Popup, -10);
3808+ let s = screen(&mut app);
3809+ assert!(s.contains(first.as_str()) && !s.contains(last.as_str()), "{}", s);
3810+ // opened again, it starts at the top
3811+ wheel(&mut app, Hit::Popup, 10);
3812+ app.handle_key(key(KeyCode::Esc));
3813+ app.handle_key(key(KeyCode::F(1)));
3814+ assert!(screen(&mut app).contains(first.as_str()));
3815+ app.handle_key(key(KeyCode::Esc));
3816+ // none of it reached the editor behind
3817+ assert_eq!(app.mode_pub(), "edit");
3818+ app.run_action(Action::EditDone);
3819+ assert_eq!(root(&d), SAMPLE);
3820+}
docs/icon.pngnew file

Binary file not shown.