on master help sign in
661 files changed+41541−6915
since splitsnapshots
unifiedsplit
.gitignore+3 .vix/access_stats.db .vix/history.txt−106 Cargo.lock+1991−323 Cargo.toml+1−3 README.md+149 SPEC.md+727−503 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+49−13 crates/fold-cli/tests/cli.rs+442−8 crates/fold-core/Cargo.toml−3 crates/fold-core/src/check.rs+225−49 crates/fold-core/src/edit.rs+839−281 crates/fold-core/src/ident.rs+11−4 crates/fold-core/src/lib.rs+1 crates/fold-core/src/merge.rs+567−290 crates/fold-core/src/news.rs+106 crates/fold-core/src/ops.rs+1542−656 crates/fold-core/src/parse.rs+336−127 crates/fold-core/src/reading.rs+10−125 crates/fold-core/src/render.rs+205−212 crates/fold-core/src/tree.rs+165−51 crates/fold-core/src/vault.rs+276−253 crates/fold-core/tests/edit.rs+1110−8 crates/fold-core/tests/merge_check.rs+639−1 crates/fold-core/tests/ops.rs+999−8 crates/fold-core/tests/ordering.rs+934 crates/fold-core/tests/resolve.rs+274 crates/fold-core/tests/roundtrip.rs+288−27 crates/fold-core/tests/undo.rs+242 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+138−5 crates/fold-tui/queries/README.md+38 crates/fold-tui/queries/ada.scm+196 crates/fold-tui/queries/applesoft.scm+124 crates/fold-tui/queries/caddy.scm+52 crates/fold-tui/queries/cfml.scm+400 crates/fold-tui/queries/cfquery.scm+383 crates/fold-tui/queries/cfscript.scm+408 crates/fold-tui/queries/d.scm+259 crates/fold-tui/queries/dafny.scm+99 crates/fold-tui/queries/dm.scm+147 crates/fold-tui/queries/fsharp-signature.scm+298 crates/fold-tui/queries/gomod.scm+20 crates/fold-tui/queries/gwbasic.scm+12 crates/fold-tui/queries/ink.scm+30 crates/fold-tui/queries/integerbasic.scm+117 crates/fold-tui/queries/julia.scm+325 crates/fold-tui/queries/matlab.scm+157 crates/fold-tui/queries/merlin6502.scm+184 crates/fold-tui/queries/msbasic2.scm+11 crates/fold-tui/queries/newick.scm+8 crates/fold-tui/queries/nginx.scm+59 crates/fold-tui/queries/pascal.scm+345 crates/fold-tui/queries/pgn.scm+29 crates/fold-tui/queries/plpgsql.scm+153 crates/fold-tui/queries/postgres.scm+358 crates/fold-tui/queries/prisma.scm+39 crates/fold-tui/queries/proto.scm+190 crates/fold-tui/queries/qbasic.scm+19 crates/fold-tui/queries/systemverilog.scm+325 crates/fold-tui/queries/templ.scm+51 crates/fold-tui/queries/vue.scm+162 crates/fold-tui/src/app.rs+2643−1947 crates/fold-tui/src/app/action.rs+324 crates/fold-tui/src/app/editor/helix.rs+593 crates/fold-tui/src/app/editor/mod.rs+1584 crates/fold-tui/src/app/editor/normal.rs+312 crates/fold-tui/src/app/editor/vim.rs+943 crates/fold-tui/src/app/grammars.rs+154 crates/fold-tui/src/app/highlight.rs+188 crates/fold-tui/src/app/markdown.rs+265 crates/fold-tui/src/app/mouse.rs+457 crates/fold-tui/src/app/ui.rs+1519 crates/fold-tui/src/app/view.rs+157 crates/fold-tui/src/app/wrap.rs+189 crates/fold-tui/src/lib.rs+3−2 crates/fold-tui/tests/conflict_watch.rs+144−3 crates/fold-tui/tests/editor.rs+1495 crates/fold-tui/tests/fixes.rs+628 crates/fold-tui/tests/mouse.rs+912−80 crates/fold-tui/tests/mouse_all.rs−174 crates/fold-tui/tests/tui.rs+89−3 crates/fold-tui/tests/ux.rs+3820 crates/fold-tui/tests/vim_search.rs+61 crates/fold-tui/tests/wrap.rs+356 docs/icon.png flake.lock+27 flake.nix+75 target/.rustc_info.json−1 target/CACHEDIR.TAG−3 target/debug/.cargo-artifact-lock target/debug/.cargo-build-lock target/debug/.cargo-lock target/debug/.fingerprint/anyhow-99f87dcb46c432ce/dep-lib-anyhow target/debug/.fingerprint/anyhow-99f87dcb46c432ce/invoked.timestamp−1 target/debug/.fingerprint/anyhow-99f87dcb46c432ce/lib-anyhow−1 target/debug/.fingerprint/anyhow-99f87dcb46c432ce/lib-anyhow.json−1 target/debug/.fingerprint/anyhow-9f4141b86fb623e3/run-build-script-build-script-build−1 target/debug/.fingerprint/anyhow-9f4141b86fb623e3/run-build-script-build-script-build.json−1 target/debug/.fingerprint/anyhow-e271561cf353a746/build-script-build-script-build−1 target/debug/.fingerprint/anyhow-e271561cf353a746/build-script-build-script-build.json−1 target/debug/.fingerprint/anyhow-e271561cf353a746/dep-build-script-build-script-build target/debug/.fingerprint/anyhow-e271561cf353a746/invoked.timestamp−1 target/debug/.fingerprint/arrayvec-a65658d7c78abafa/dep-lib-arrayvec target/debug/.fingerprint/arrayvec-a65658d7c78abafa/invoked.timestamp−1 target/debug/.fingerprint/arrayvec-a65658d7c78abafa/lib-arrayvec−1 target/debug/.fingerprint/arrayvec-a65658d7c78abafa/lib-arrayvec.json−1 target/debug/.fingerprint/autocfg-bc1dc19eff92fee9/dep-lib-autocfg target/debug/.fingerprint/autocfg-bc1dc19eff92fee9/invoked.timestamp−1 target/debug/.fingerprint/autocfg-bc1dc19eff92fee9/lib-autocfg−1 target/debug/.fingerprint/autocfg-bc1dc19eff92fee9/lib-autocfg.json−1 target/debug/.fingerprint/bit-set-3941883ce9e954cc/dep-lib-bit_set target/debug/.fingerprint/bit-set-3941883ce9e954cc/invoked.timestamp−1 target/debug/.fingerprint/bit-set-3941883ce9e954cc/lib-bit_set−1 target/debug/.fingerprint/bit-set-3941883ce9e954cc/lib-bit_set.json−1 target/debug/.fingerprint/bit-vec-c46a512d79021dae/dep-lib-bit_vec target/debug/.fingerprint/bit-vec-c46a512d79021dae/invoked.timestamp−1 target/debug/.fingerprint/bit-vec-c46a512d79021dae/lib-bit_vec−1 target/debug/.fingerprint/bit-vec-c46a512d79021dae/lib-bit_vec.json−1 target/debug/.fingerprint/bitflags-0aef64864726e470/dep-lib-bitflags target/debug/.fingerprint/bitflags-0aef64864726e470/invoked.timestamp−1 target/debug/.fingerprint/bitflags-0aef64864726e470/lib-bitflags−1 target/debug/.fingerprint/bitflags-0aef64864726e470/lib-bitflags.json−1 target/debug/.fingerprint/blake3-83205a5d53645d81/build-script-build-script-build−1 target/debug/.fingerprint/blake3-83205a5d53645d81/build-script-build-script-build.json−1 target/debug/.fingerprint/blake3-83205a5d53645d81/dep-build-script-build-script-build target/debug/.fingerprint/blake3-83205a5d53645d81/invoked.timestamp−1 target/debug/.fingerprint/blake3-a9908e5bb688d4e7/run-build-script-build-script-build−1 target/debug/.fingerprint/blake3-a9908e5bb688d4e7/run-build-script-build-script-build.json−1 target/debug/.fingerprint/blake3-f46fd160759017ba/dep-lib-blake3 target/debug/.fingerprint/blake3-f46fd160759017ba/invoked.timestamp−1 target/debug/.fingerprint/blake3-f46fd160759017ba/lib-blake3−1 target/debug/.fingerprint/blake3-f46fd160759017ba/lib-blake3.json−1 target/debug/.fingerprint/cc-8e3410aecb4a0c7b/dep-lib-cc target/debug/.fingerprint/cc-8e3410aecb4a0c7b/invoked.timestamp−1 target/debug/.fingerprint/cc-8e3410aecb4a0c7b/lib-cc−1 target/debug/.fingerprint/cc-8e3410aecb4a0c7b/lib-cc.json−1 target/debug/.fingerprint/cfg-if-de76fd6d6c5730e4/dep-lib-cfg_if target/debug/.fingerprint/cfg-if-de76fd6d6c5730e4/invoked.timestamp−1 target/debug/.fingerprint/cfg-if-de76fd6d6c5730e4/lib-cfg_if−1 target/debug/.fingerprint/cfg-if-de76fd6d6c5730e4/lib-cfg_if.json−1 target/debug/.fingerprint/constant_time_eq-fdb5336b45adb589/dep-lib-constant_time_eq target/debug/.fingerprint/constant_time_eq-fdb5336b45adb589/invoked.timestamp−1 target/debug/.fingerprint/constant_time_eq-fdb5336b45adb589/lib-constant_time_eq−1 target/debug/.fingerprint/constant_time_eq-fdb5336b45adb589/lib-constant_time_eq.json−1 target/debug/.fingerprint/cpufeatures-e4a310f8b3b6f725/dep-lib-cpufeatures target/debug/.fingerprint/cpufeatures-e4a310f8b3b6f725/invoked.timestamp−1 target/debug/.fingerprint/cpufeatures-e4a310f8b3b6f725/lib-cpufeatures−1 target/debug/.fingerprint/cpufeatures-e4a310f8b3b6f725/lib-cpufeatures.json−1 target/debug/.fingerprint/directories-b0d5d95843c3fb66/dep-lib-directories target/debug/.fingerprint/directories-b0d5d95843c3fb66/invoked.timestamp−1 target/debug/.fingerprint/directories-b0d5d95843c3fb66/lib-directories−1 target/debug/.fingerprint/directories-b0d5d95843c3fb66/lib-directories.json−1 target/debug/.fingerprint/dirs-sys-6b2d7e2d17beea4e/dep-lib-dirs_sys target/debug/.fingerprint/dirs-sys-6b2d7e2d17beea4e/invoked.timestamp−1 target/debug/.fingerprint/dirs-sys-6b2d7e2d17beea4e/lib-dirs_sys−1 target/debug/.fingerprint/dirs-sys-6b2d7e2d17beea4e/lib-dirs_sys.json−1 target/debug/.fingerprint/equivalent-65b26217b03f15f5/dep-lib-equivalent target/debug/.fingerprint/equivalent-65b26217b03f15f5/invoked.timestamp−1 target/debug/.fingerprint/equivalent-65b26217b03f15f5/lib-equivalent−1 target/debug/.fingerprint/equivalent-65b26217b03f15f5/lib-equivalent.json−1 target/debug/.fingerprint/fastrand-afd1857ca89a7afc/dep-lib-fastrand target/debug/.fingerprint/fastrand-afd1857ca89a7afc/invoked.timestamp−1 target/debug/.fingerprint/fastrand-afd1857ca89a7afc/lib-fastrand−1 target/debug/.fingerprint/fastrand-afd1857ca89a7afc/lib-fastrand.json−1 target/debug/.fingerprint/find-msvc-tools-71a62bc007db7fa9/dep-lib-find_msvc_tools target/debug/.fingerprint/find-msvc-tools-71a62bc007db7fa9/invoked.timestamp−1 target/debug/.fingerprint/find-msvc-tools-71a62bc007db7fa9/lib-find_msvc_tools−1 target/debug/.fingerprint/find-msvc-tools-71a62bc007db7fa9/lib-find_msvc_tools.json−1 target/debug/.fingerprint/fnv-9fde446a8c1365fc/dep-lib-fnv target/debug/.fingerprint/fnv-9fde446a8c1365fc/invoked.timestamp−1 target/debug/.fingerprint/fnv-9fde446a8c1365fc/lib-fnv−1 target/debug/.fingerprint/fnv-9fde446a8c1365fc/lib-fnv.json−1 target/debug/.fingerprint/getrandom-0eaf42379121fb38/dep-lib-getrandom target/debug/.fingerprint/getrandom-0eaf42379121fb38/invoked.timestamp−1 target/debug/.fingerprint/getrandom-0eaf42379121fb38/lib-getrandom−1 target/debug/.fingerprint/getrandom-0eaf42379121fb38/lib-getrandom.json−1 target/debug/.fingerprint/getrandom-106dc7dc80dfd512/run-build-script-build-script-build−1 target/debug/.fingerprint/getrandom-106dc7dc80dfd512/run-build-script-build-script-build.json−1 target/debug/.fingerprint/getrandom-3c9ae9aa3ee1b316/build-script-build-script-build−1 target/debug/.fingerprint/getrandom-3c9ae9aa3ee1b316/build-script-build-script-build.json−1 target/debug/.fingerprint/getrandom-3c9ae9aa3ee1b316/dep-build-script-build-script-build target/debug/.fingerprint/getrandom-3c9ae9aa3ee1b316/invoked.timestamp−1 target/debug/.fingerprint/getrandom-5863645493ec0e6c/build-script-build-script-build−1 target/debug/.fingerprint/getrandom-5863645493ec0e6c/build-script-build-script-build.json−1 target/debug/.fingerprint/getrandom-5863645493ec0e6c/dep-build-script-build-script-build target/debug/.fingerprint/getrandom-5863645493ec0e6c/invoked.timestamp−1 target/debug/.fingerprint/getrandom-85f2e85dbc3c82b3/run-build-script-build-script-build−1 target/debug/.fingerprint/getrandom-85f2e85dbc3c82b3/run-build-script-build-script-build.json−1 target/debug/.fingerprint/getrandom-b67675fe179fd3f5/run-build-script-build-script-build−1 target/debug/.fingerprint/getrandom-b67675fe179fd3f5/run-build-script-build-script-build.json−1 target/debug/.fingerprint/getrandom-b6fea70e6916ddac/dep-lib-getrandom target/debug/.fingerprint/getrandom-b6fea70e6916ddac/invoked.timestamp−1 target/debug/.fingerprint/getrandom-b6fea70e6916ddac/lib-getrandom−1 target/debug/.fingerprint/getrandom-b6fea70e6916ddac/lib-getrandom.json−1 target/debug/.fingerprint/getrandom-c3baaaae3fc12015/build-script-build-script-build−1 target/debug/.fingerprint/getrandom-c3baaaae3fc12015/build-script-build-script-build.json−1 target/debug/.fingerprint/getrandom-c3baaaae3fc12015/dep-build-script-build-script-build target/debug/.fingerprint/getrandom-c3baaaae3fc12015/invoked.timestamp−1 target/debug/.fingerprint/getrandom-d98d449a36bafbe0/dep-lib-getrandom target/debug/.fingerprint/getrandom-d98d449a36bafbe0/invoked.timestamp−1 target/debug/.fingerprint/getrandom-d98d449a36bafbe0/lib-getrandom−1 target/debug/.fingerprint/getrandom-d98d449a36bafbe0/lib-getrandom.json−1 target/debug/.fingerprint/hashbrown-ac220fc3bcae53c0/dep-lib-hashbrown target/debug/.fingerprint/hashbrown-ac220fc3bcae53c0/invoked.timestamp−1 target/debug/.fingerprint/hashbrown-ac220fc3bcae53c0/lib-hashbrown−1 target/debug/.fingerprint/hashbrown-ac220fc3bcae53c0/lib-hashbrown.json−1 target/debug/.fingerprint/indexmap-047470861a2cca77/dep-lib-indexmap target/debug/.fingerprint/indexmap-047470861a2cca77/invoked.timestamp−1 target/debug/.fingerprint/indexmap-047470861a2cca77/lib-indexmap−1 target/debug/.fingerprint/indexmap-047470861a2cca77/lib-indexmap.json−1 target/debug/.fingerprint/jiff-68f68fe57a2700b2/dep-lib-jiff target/debug/.fingerprint/jiff-68f68fe57a2700b2/invoked.timestamp−1 target/debug/.fingerprint/jiff-68f68fe57a2700b2/lib-jiff−1 target/debug/.fingerprint/jiff-68f68fe57a2700b2/lib-jiff.json−1 target/debug/.fingerprint/jiff-core-787542659066c338/dep-lib-jiff_core target/debug/.fingerprint/jiff-core-787542659066c338/invoked.timestamp−1 target/debug/.fingerprint/jiff-core-787542659066c338/lib-jiff_core−1 target/debug/.fingerprint/jiff-core-787542659066c338/lib-jiff_core.json−1 target/debug/.fingerprint/libc-b783701222a4d8e3/dep-lib-libc target/debug/.fingerprint/libc-b783701222a4d8e3/invoked.timestamp−1 target/debug/.fingerprint/libc-b783701222a4d8e3/lib-libc−1 target/debug/.fingerprint/libc-b783701222a4d8e3/lib-libc.json−1 target/debug/.fingerprint/libc-d9611a1b0ec3f5c1/build-script-build-script-build−1 target/debug/.fingerprint/libc-d9611a1b0ec3f5c1/build-script-build-script-build.json−1 target/debug/.fingerprint/libc-d9611a1b0ec3f5c1/dep-build-script-build-script-build target/debug/.fingerprint/libc-d9611a1b0ec3f5c1/invoked.timestamp−1 target/debug/.fingerprint/libc-fd26b7e10e2f01da/run-build-script-build-script-build−1 target/debug/.fingerprint/libc-fd26b7e10e2f01da/run-build-script-build-script-build.json−1 target/debug/.fingerprint/linux-raw-sys-748a127bddffab6c/dep-lib-linux_raw_sys target/debug/.fingerprint/linux-raw-sys-748a127bddffab6c/invoked.timestamp−1 target/debug/.fingerprint/linux-raw-sys-748a127bddffab6c/lib-linux_raw_sys−1 target/debug/.fingerprint/linux-raw-sys-748a127bddffab6c/lib-linux_raw_sys.json−1 target/debug/.fingerprint/notes-core-8f1ce0f90d61e5a7/dep-lib-notes_core target/debug/.fingerprint/notes-core-8f1ce0f90d61e5a7/invoked.timestamp−1 target/debug/.fingerprint/notes-core-8f1ce0f90d61e5a7/lib-notes_core−1 target/debug/.fingerprint/notes-core-8f1ce0f90d61e5a7/lib-notes_core.json−1 target/debug/.fingerprint/num-traits-0b5ce95e951c04fe/dep-lib-num_traits target/debug/.fingerprint/num-traits-0b5ce95e951c04fe/invoked.timestamp−1 target/debug/.fingerprint/num-traits-0b5ce95e951c04fe/lib-num_traits−1 target/debug/.fingerprint/num-traits-0b5ce95e951c04fe/lib-num_traits.json−1 target/debug/.fingerprint/num-traits-900b9f55b6b9e772/run-build-script-build-script-build−1 target/debug/.fingerprint/num-traits-900b9f55b6b9e772/run-build-script-build-script-build.json−1 target/debug/.fingerprint/num-traits-91041b392e74a985/build-script-build-script-build−1 target/debug/.fingerprint/num-traits-91041b392e74a985/build-script-build-script-build.json−1 target/debug/.fingerprint/num-traits-91041b392e74a985/dep-build-script-build-script-build target/debug/.fingerprint/num-traits-91041b392e74a985/invoked.timestamp−1 target/debug/.fingerprint/once_cell-d72f51d4d3ff5e42/dep-lib-once_cell target/debug/.fingerprint/once_cell-d72f51d4d3ff5e42/invoked.timestamp−1 target/debug/.fingerprint/once_cell-d72f51d4d3ff5e42/lib-once_cell−1 target/debug/.fingerprint/once_cell-d72f51d4d3ff5e42/lib-once_cell.json−1 target/debug/.fingerprint/option-ext-f95e8c5597ca1435/dep-lib-option_ext target/debug/.fingerprint/option-ext-f95e8c5597ca1435/invoked.timestamp−1 target/debug/.fingerprint/option-ext-f95e8c5597ca1435/lib-option_ext−1 target/debug/.fingerprint/option-ext-f95e8c5597ca1435/lib-option_ext.json−1 target/debug/.fingerprint/ppv-lite86-4c2cb8748d60e5c7/dep-lib-ppv_lite86 target/debug/.fingerprint/ppv-lite86-4c2cb8748d60e5c7/invoked.timestamp−1 target/debug/.fingerprint/ppv-lite86-4c2cb8748d60e5c7/lib-ppv_lite86−1 target/debug/.fingerprint/ppv-lite86-4c2cb8748d60e5c7/lib-ppv_lite86.json−1 target/debug/.fingerprint/proc-macro2-6b336f0ddb28b27a/dep-lib-proc_macro2 target/debug/.fingerprint/proc-macro2-6b336f0ddb28b27a/invoked.timestamp−1 target/debug/.fingerprint/proc-macro2-6b336f0ddb28b27a/lib-proc_macro2−1 target/debug/.fingerprint/proc-macro2-6b336f0ddb28b27a/lib-proc_macro2.json−1 target/debug/.fingerprint/proc-macro2-9816b18a9c79987d/run-build-script-build-script-build−1 target/debug/.fingerprint/proc-macro2-9816b18a9c79987d/run-build-script-build-script-build.json−1 target/debug/.fingerprint/proc-macro2-bcbb7de91551b4e2/build-script-build-script-build−1 target/debug/.fingerprint/proc-macro2-bcbb7de91551b4e2/build-script-build-script-build.json−1 target/debug/.fingerprint/proc-macro2-bcbb7de91551b4e2/dep-build-script-build-script-build target/debug/.fingerprint/proc-macro2-bcbb7de91551b4e2/invoked.timestamp−1 target/debug/.fingerprint/proptest-c71b6fc3656545fd/dep-lib-proptest target/debug/.fingerprint/proptest-c71b6fc3656545fd/invoked.timestamp−1 target/debug/.fingerprint/proptest-c71b6fc3656545fd/lib-proptest−1 target/debug/.fingerprint/proptest-c71b6fc3656545fd/lib-proptest.json−1 target/debug/.fingerprint/quick-error-8abe3b1ca8b7a8b9/dep-lib-quick_error target/debug/.fingerprint/quick-error-8abe3b1ca8b7a8b9/invoked.timestamp−1 target/debug/.fingerprint/quick-error-8abe3b1ca8b7a8b9/lib-quick_error−1 target/debug/.fingerprint/quick-error-8abe3b1ca8b7a8b9/lib-quick_error.json−1 target/debug/.fingerprint/quote-027634fac3d1dd29/dep-lib-quote target/debug/.fingerprint/quote-027634fac3d1dd29/invoked.timestamp−1 target/debug/.fingerprint/quote-027634fac3d1dd29/lib-quote−1 target/debug/.fingerprint/quote-027634fac3d1dd29/lib-quote.json−1 target/debug/.fingerprint/quote-0696611ec86f8910/build-script-build-script-build−1 target/debug/.fingerprint/quote-0696611ec86f8910/build-script-build-script-build.json−1 target/debug/.fingerprint/quote-0696611ec86f8910/dep-build-script-build-script-build target/debug/.fingerprint/quote-0696611ec86f8910/invoked.timestamp−1 target/debug/.fingerprint/quote-de3ef8ebb8667ca9/run-build-script-build-script-build−1 target/debug/.fingerprint/quote-de3ef8ebb8667ca9/run-build-script-build-script-build.json−1 target/debug/.fingerprint/rand-41f5c89bf90a2c40/dep-lib-rand target/debug/.fingerprint/rand-41f5c89bf90a2c40/invoked.timestamp−1 target/debug/.fingerprint/rand-41f5c89bf90a2c40/lib-rand−1 target/debug/.fingerprint/rand-41f5c89bf90a2c40/lib-rand.json−1 target/debug/.fingerprint/rand_chacha-a13f4556a4cb172c/dep-lib-rand_chacha target/debug/.fingerprint/rand_chacha-a13f4556a4cb172c/invoked.timestamp−1 target/debug/.fingerprint/rand_chacha-a13f4556a4cb172c/lib-rand_chacha−1 target/debug/.fingerprint/rand_chacha-a13f4556a4cb172c/lib-rand_chacha.json−1 target/debug/.fingerprint/rand_core-6b2fc663fdff125a/dep-lib-rand_core target/debug/.fingerprint/rand_core-6b2fc663fdff125a/invoked.timestamp−1 target/debug/.fingerprint/rand_core-6b2fc663fdff125a/lib-rand_core−1 target/debug/.fingerprint/rand_core-6b2fc663fdff125a/lib-rand_core.json−1 target/debug/.fingerprint/rand_xorshift-85f984f4f18ce3d5/dep-lib-rand_xorshift target/debug/.fingerprint/rand_xorshift-85f984f4f18ce3d5/invoked.timestamp−1 target/debug/.fingerprint/rand_xorshift-85f984f4f18ce3d5/lib-rand_xorshift−1 target/debug/.fingerprint/rand_xorshift-85f984f4f18ce3d5/lib-rand_xorshift.json−1 target/debug/.fingerprint/regex-syntax-7e413990257ae280/dep-lib-regex_syntax target/debug/.fingerprint/regex-syntax-7e413990257ae280/invoked.timestamp−1 target/debug/.fingerprint/regex-syntax-7e413990257ae280/lib-regex_syntax−1 target/debug/.fingerprint/regex-syntax-7e413990257ae280/lib-regex_syntax.json−1 target/debug/.fingerprint/rustix-038e468382ead565/run-build-script-build-script-build−1 target/debug/.fingerprint/rustix-038e468382ead565/run-build-script-build-script-build.json−1 target/debug/.fingerprint/rustix-ebe468a694014bd7/dep-lib-rustix target/debug/.fingerprint/rustix-ebe468a694014bd7/invoked.timestamp−1 target/debug/.fingerprint/rustix-ebe468a694014bd7/lib-rustix−1 target/debug/.fingerprint/rustix-ebe468a694014bd7/lib-rustix.json−1 target/debug/.fingerprint/rustix-f2e39f54096cf2ef/build-script-build-script-build−1 target/debug/.fingerprint/rustix-f2e39f54096cf2ef/build-script-build-script-build.json−1 target/debug/.fingerprint/rustix-f2e39f54096cf2ef/dep-build-script-build-script-build target/debug/.fingerprint/rustix-f2e39f54096cf2ef/invoked.timestamp−1 target/debug/.fingerprint/rusty-fork-c05f13651af4f8b1/dep-lib-rusty_fork target/debug/.fingerprint/rusty-fork-c05f13651af4f8b1/invoked.timestamp−1 target/debug/.fingerprint/rusty-fork-c05f13651af4f8b1/lib-rusty_fork−1 target/debug/.fingerprint/rusty-fork-c05f13651af4f8b1/lib-rusty_fork.json−1 target/debug/.fingerprint/shlex-eafb49743ebdab94/dep-lib-shlex target/debug/.fingerprint/shlex-eafb49743ebdab94/invoked.timestamp−1 target/debug/.fingerprint/shlex-eafb49743ebdab94/lib-shlex−1 target/debug/.fingerprint/shlex-eafb49743ebdab94/lib-shlex.json−1 target/debug/.fingerprint/similar-dd62e180fdd9fd69/dep-lib-similar target/debug/.fingerprint/similar-dd62e180fdd9fd69/invoked.timestamp−1 target/debug/.fingerprint/similar-dd62e180fdd9fd69/lib-similar−1 target/debug/.fingerprint/similar-dd62e180fdd9fd69/lib-similar.json−1 target/debug/.fingerprint/syn-efaac48046ba42aa/dep-lib-syn target/debug/.fingerprint/syn-efaac48046ba42aa/invoked.timestamp−1 target/debug/.fingerprint/syn-efaac48046ba42aa/lib-syn−1 target/debug/.fingerprint/syn-efaac48046ba42aa/lib-syn.json−1 target/debug/.fingerprint/tempfile-a08e3a6e73f18dc2/dep-lib-tempfile target/debug/.fingerprint/tempfile-a08e3a6e73f18dc2/invoked.timestamp−1 target/debug/.fingerprint/tempfile-a08e3a6e73f18dc2/lib-tempfile−1 target/debug/.fingerprint/tempfile-a08e3a6e73f18dc2/lib-tempfile.json−1 target/debug/.fingerprint/thiserror-3c068656744c89de/run-build-script-build-script-build−1 target/debug/.fingerprint/thiserror-3c068656744c89de/run-build-script-build-script-build.json−1 target/debug/.fingerprint/thiserror-6616919d71b1f055/build-script-build-script-build−1 target/debug/.fingerprint/thiserror-6616919d71b1f055/build-script-build-script-build.json−1 target/debug/.fingerprint/thiserror-6616919d71b1f055/dep-build-script-build-script-build target/debug/.fingerprint/thiserror-6616919d71b1f055/invoked.timestamp−1 target/debug/.fingerprint/thiserror-d582988ee49bfd38/dep-lib-thiserror target/debug/.fingerprint/thiserror-d582988ee49bfd38/invoked.timestamp−1 target/debug/.fingerprint/thiserror-d582988ee49bfd38/lib-thiserror−1 target/debug/.fingerprint/thiserror-d582988ee49bfd38/lib-thiserror.json−1 target/debug/.fingerprint/thiserror-impl-054c82e8cb318cbf/dep-lib-thiserror_impl target/debug/.fingerprint/thiserror-impl-054c82e8cb318cbf/invoked.timestamp−1 target/debug/.fingerprint/thiserror-impl-054c82e8cb318cbf/lib-thiserror_impl−1 target/debug/.fingerprint/thiserror-impl-054c82e8cb318cbf/lib-thiserror_impl.json−1 target/debug/.fingerprint/tinyvec-b3705465be29f173/dep-lib-tinyvec target/debug/.fingerprint/tinyvec-b3705465be29f173/invoked.timestamp−1 target/debug/.fingerprint/tinyvec-b3705465be29f173/lib-tinyvec−1 target/debug/.fingerprint/tinyvec-b3705465be29f173/lib-tinyvec.json−1 target/debug/.fingerprint/tinyvec_macros-b3c8ffa612202339/dep-lib-tinyvec_macros target/debug/.fingerprint/tinyvec_macros-b3c8ffa612202339/invoked.timestamp−1 target/debug/.fingerprint/tinyvec_macros-b3c8ffa612202339/lib-tinyvec_macros−1 target/debug/.fingerprint/tinyvec_macros-b3c8ffa612202339/lib-tinyvec_macros.json−1 target/debug/.fingerprint/unarray-1317844f3e0f5c07/dep-lib-unarray target/debug/.fingerprint/unarray-1317844f3e0f5c07/invoked.timestamp−1 target/debug/.fingerprint/unarray-1317844f3e0f5c07/lib-unarray−1 target/debug/.fingerprint/unarray-1317844f3e0f5c07/lib-unarray.json−1 target/debug/.fingerprint/unicode-ident-07077bdd941af1f1/dep-lib-unicode_ident target/debug/.fingerprint/unicode-ident-07077bdd941af1f1/invoked.timestamp−1 target/debug/.fingerprint/unicode-ident-07077bdd941af1f1/lib-unicode_ident−1 target/debug/.fingerprint/unicode-ident-07077bdd941af1f1/lib-unicode_ident.json−1 target/debug/.fingerprint/unicode-normalization-5a956f8b1f85d8dd/dep-lib-unicode_normalization target/debug/.fingerprint/unicode-normalization-5a956f8b1f85d8dd/invoked.timestamp−1 target/debug/.fingerprint/unicode-normalization-5a956f8b1f85d8dd/lib-unicode_normalization−1 target/debug/.fingerprint/unicode-normalization-5a956f8b1f85d8dd/lib-unicode_normalization.json−1 target/debug/.fingerprint/wait-timeout-6586e67c7f2c4a6f/dep-lib-wait_timeout target/debug/.fingerprint/wait-timeout-6586e67c7f2c4a6f/invoked.timestamp−1 target/debug/.fingerprint/wait-timeout-6586e67c7f2c4a6f/lib-wait_timeout−1 target/debug/.fingerprint/wait-timeout-6586e67c7f2c4a6f/lib-wait_timeout.json−1 target/debug/.fingerprint/zerocopy-079dd2896db74193/build-script-build-script-build−1 target/debug/.fingerprint/zerocopy-079dd2896db74193/build-script-build-script-build.json−1 target/debug/.fingerprint/zerocopy-079dd2896db74193/dep-build-script-build-script-build target/debug/.fingerprint/zerocopy-079dd2896db74193/invoked.timestamp−1 target/debug/.fingerprint/zerocopy-ecaaca3561f2eb29/dep-lib-zerocopy target/debug/.fingerprint/zerocopy-ecaaca3561f2eb29/invoked.timestamp−1 target/debug/.fingerprint/zerocopy-ecaaca3561f2eb29/lib-zerocopy−1 target/debug/.fingerprint/zerocopy-ecaaca3561f2eb29/lib-zerocopy.json−1 target/debug/.fingerprint/zerocopy-ed7b5ac9e96d0173/run-build-script-build-script-build−1 target/debug/.fingerprint/zerocopy-ed7b5ac9e96d0173/run-build-script-build-script-build.json−1 target/debug/build/anyhow-9f4141b86fb623e3/invoked.timestamp−1 target/debug/build/anyhow-9f4141b86fb623e3/output−7 target/debug/build/anyhow-9f4141b86fb623e3/root-output−1 target/debug/build/anyhow-9f4141b86fb623e3/stderr target/debug/build/anyhow-e271561cf353a746/build-script-build target/debug/build/anyhow-e271561cf353a746/build_script_build-e271561cf353a746 target/debug/build/anyhow-e271561cf353a746/build_script_build-e271561cf353a746.d−5 target/debug/build/blake3-83205a5d53645d81/build_script_build-83205a5d53645d81.d−5 target/debug/build/blake3-a9908e5bb688d4e7/invoked.timestamp−1 target/debug/build/blake3-a9908e5bb688d4e7/out/b8423798394d5395-blake3_avx2_x86-64_unix.o target/debug/build/blake3-a9908e5bb688d4e7/out/b8423798394d5395-blake3_avx512_x86-64_unix.o target/debug/build/blake3-a9908e5bb688d4e7/out/b8423798394d5395-blake3_sse2_x86-64_unix.o target/debug/build/blake3-a9908e5bb688d4e7/out/b8423798394d5395-blake3_sse41_x86-64_unix.o target/debug/build/blake3-a9908e5bb688d4e7/out/flag_check target/debug/build/blake3-a9908e5bb688d4e7/out/flag_check.c−1 target/debug/build/blake3-a9908e5bb688d4e7/out/libblake3_avx512_assembly.a target/debug/build/blake3-a9908e5bb688d4e7/out/libblake3_sse2_sse41_avx2_assembly.a target/debug/build/blake3-a9908e5bb688d4e7/output−165 target/debug/build/blake3-a9908e5bb688d4e7/root-output−1 target/debug/build/blake3-a9908e5bb688d4e7/stderr target/debug/build/getrandom-106dc7dc80dfd512/invoked.timestamp−1 target/debug/build/getrandom-106dc7dc80dfd512/output−1 target/debug/build/getrandom-106dc7dc80dfd512/root-output−1 target/debug/build/getrandom-106dc7dc80dfd512/stderr target/debug/build/getrandom-3c9ae9aa3ee1b316/build-script-build target/debug/build/getrandom-3c9ae9aa3ee1b316/build_script_build-3c9ae9aa3ee1b316 target/debug/build/getrandom-3c9ae9aa3ee1b316/build_script_build-3c9ae9aa3ee1b316.d−5 target/debug/build/getrandom-5863645493ec0e6c/build-script-build target/debug/build/getrandom-5863645493ec0e6c/build_script_build-5863645493ec0e6c target/debug/build/getrandom-5863645493ec0e6c/build_script_build-5863645493ec0e6c.d−5 target/debug/build/getrandom-85f2e85dbc3c82b3/invoked.timestamp−1 target/debug/build/getrandom-85f2e85dbc3c82b3/output−1 target/debug/build/getrandom-85f2e85dbc3c82b3/root-output−1 target/debug/build/getrandom-85f2e85dbc3c82b3/stderr target/debug/build/getrandom-b67675fe179fd3f5/invoked.timestamp−1 target/debug/build/getrandom-b67675fe179fd3f5/output−1 target/debug/build/getrandom-b67675fe179fd3f5/root-output−1 target/debug/build/getrandom-b67675fe179fd3f5/stderr target/debug/build/getrandom-c3baaaae3fc12015/build-script-build target/debug/build/getrandom-c3baaaae3fc12015/build_script_build-c3baaaae3fc12015 target/debug/build/getrandom-c3baaaae3fc12015/build_script_build-c3baaaae3fc12015.d−5 target/debug/build/libc-d9611a1b0ec3f5c1/build-script-build target/debug/build/libc-d9611a1b0ec3f5c1/build_script_build-d9611a1b0ec3f5c1 target/debug/build/libc-d9611a1b0ec3f5c1/build_script_build-d9611a1b0ec3f5c1.d−5 target/debug/build/libc-fd26b7e10e2f01da/invoked.timestamp−1 target/debug/build/libc-fd26b7e10e2f01da/output−25 target/debug/build/libc-fd26b7e10e2f01da/root-output−1 target/debug/build/libc-fd26b7e10e2f01da/stderr target/debug/build/num-traits-900b9f55b6b9e772/invoked.timestamp−1 target/debug/build/num-traits-900b9f55b6b9e772/output−3 target/debug/build/num-traits-900b9f55b6b9e772/root-output−1 target/debug/build/num-traits-900b9f55b6b9e772/stderr target/debug/build/num-traits-91041b392e74a985/build-script-build target/debug/build/num-traits-91041b392e74a985/build_script_build-91041b392e74a985 target/debug/build/num-traits-91041b392e74a985/build_script_build-91041b392e74a985.d−5 target/debug/build/proc-macro2-9816b18a9c79987d/invoked.timestamp−1 target/debug/build/proc-macro2-9816b18a9c79987d/output−23 target/debug/build/proc-macro2-9816b18a9c79987d/root-output−1 target/debug/build/proc-macro2-9816b18a9c79987d/stderr target/debug/build/proc-macro2-bcbb7de91551b4e2/build-script-build target/debug/build/proc-macro2-bcbb7de91551b4e2/build_script_build-bcbb7de91551b4e2 target/debug/build/proc-macro2-bcbb7de91551b4e2/build_script_build-bcbb7de91551b4e2.d−5 target/debug/build/quote-0696611ec86f8910/build-script-build target/debug/build/quote-0696611ec86f8910/build_script_build-0696611ec86f8910 target/debug/build/quote-0696611ec86f8910/build_script_build-0696611ec86f8910.d−5 target/debug/build/quote-de3ef8ebb8667ca9/invoked.timestamp−1 target/debug/build/quote-de3ef8ebb8667ca9/output−2 target/debug/build/quote-de3ef8ebb8667ca9/root-output−1 target/debug/build/quote-de3ef8ebb8667ca9/stderr target/debug/build/rustix-038e468382ead565/invoked.timestamp−1 target/debug/build/rustix-038e468382ead565/out/rustix_test_can_compile target/debug/build/rustix-038e468382ead565/output−13 target/debug/build/rustix-038e468382ead565/root-output−1 target/debug/build/rustix-038e468382ead565/stderr target/debug/build/rustix-f2e39f54096cf2ef/build-script-build target/debug/build/rustix-f2e39f54096cf2ef/build_script_build-f2e39f54096cf2ef target/debug/build/rustix-f2e39f54096cf2ef/build_script_build-f2e39f54096cf2ef.d−5 target/debug/build/thiserror-3c068656744c89de/invoked.timestamp−1 target/debug/build/thiserror-3c068656744c89de/out/private.rs−5 target/debug/build/thiserror-3c068656744c89de/output−5 target/debug/build/thiserror-3c068656744c89de/root-output−1 target/debug/build/thiserror-3c068656744c89de/stderr target/debug/build/thiserror-6616919d71b1f055/build-script-build target/debug/build/thiserror-6616919d71b1f055/build_script_build-6616919d71b1f055 target/debug/build/thiserror-6616919d71b1f055/build_script_build-6616919d71b1f055.d−5 target/debug/build/zerocopy-079dd2896db74193/build-script-build target/debug/build/zerocopy-079dd2896db74193/build_script_build-079dd2896db74193 target/debug/build/zerocopy-079dd2896db74193/build_script_build-079dd2896db74193.d−5 target/debug/build/zerocopy-ed7b5ac9e96d0173/invoked.timestamp−1 target/debug/build/zerocopy-ed7b5ac9e96d0173/output−28 target/debug/build/zerocopy-ed7b5ac9e96d0173/root-output−1 target/debug/build/zerocopy-ed7b5ac9e96d0173/stderr target/debug/deps/anyhow-99f87dcb46c432ce.d−17 target/debug/deps/arrayvec-a65658d7c78abafa.d−13 target/debug/deps/autocfg-bc1dc19eff92fee9.d−10 target/debug/deps/bit_set-3941883ce9e954cc.d−7 target/debug/deps/bit_vec-c46a512d79021dae.d−7 target/debug/deps/bitflags-0aef64864726e470.d−13 target/debug/deps/blake3-f46fd160759017ba.d−17 target/debug/deps/cc-8e3410aecb4a0c7b.d−18 target/debug/deps/cfg_if-de76fd6d6c5730e4.d−7 target/debug/deps/constant_time_eq-fdb5336b45adb589.d−10 target/debug/deps/cpufeatures-e4a310f8b3b6f725.d−9 target/debug/deps/directories-b0d5d95843c3fb66.d−8 target/debug/deps/dirs_sys-6b2d7e2d17beea4e.d−8 target/debug/deps/equivalent-65b26217b03f15f5.d−7 target/debug/deps/fastrand-afd1857ca89a7afc.d−8 target/debug/deps/find_msvc_tools-71a62bc007db7fa9.d−9 target/debug/deps/fnv-9fde446a8c1365fc.d−7 target/debug/deps/getrandom-0eaf42379121fb38.d−15 target/debug/deps/getrandom-b6fea70e6916ddac.d−17 target/debug/deps/getrandom-d98d449a36bafbe0.d−16 target/debug/deps/hashbrown-ac220fc3bcae53c0.d−22 target/debug/deps/indexmap-047470861a2cca77.d−24 target/debug/deps/jiff-68f68fe57a2700b2.d−93 target/debug/deps/jiff_core-787542659066c338.d−26 target/debug/deps/libanyhow-99f87dcb46c432ce.rmeta target/debug/deps/libarrayvec-a65658d7c78abafa.rlib target/debug/deps/libarrayvec-a65658d7c78abafa.rmeta target/debug/deps/libautocfg-bc1dc19eff92fee9.rlib target/debug/deps/libautocfg-bc1dc19eff92fee9.rmeta target/debug/deps/libbit_set-3941883ce9e954cc.rlib target/debug/deps/libbit_set-3941883ce9e954cc.rmeta target/debug/deps/libbit_vec-c46a512d79021dae.rlib target/debug/deps/libbit_vec-c46a512d79021dae.rmeta target/debug/deps/libbitflags-0aef64864726e470.rlib target/debug/deps/libbitflags-0aef64864726e470.rmeta target/debug/deps/libblake3-f46fd160759017ba.rmeta target/debug/deps/libc-b783701222a4d8e3.d−55 target/debug/deps/libcc-8e3410aecb4a0c7b.rmeta target/debug/deps/libcfg_if-de76fd6d6c5730e4.rlib target/debug/deps/libcfg_if-de76fd6d6c5730e4.rmeta target/debug/deps/libconstant_time_eq-fdb5336b45adb589.rlib target/debug/deps/libconstant_time_eq-fdb5336b45adb589.rmeta target/debug/deps/libcpufeatures-e4a310f8b3b6f725.rlib target/debug/deps/libcpufeatures-e4a310f8b3b6f725.rmeta target/debug/deps/libdirectories-b0d5d95843c3fb66.rlib target/debug/deps/libdirectories-b0d5d95843c3fb66.rmeta target/debug/deps/libdirs_sys-6b2d7e2d17beea4e.rlib target/debug/deps/libdirs_sys-6b2d7e2d17beea4e.rmeta target/debug/deps/libequivalent-65b26217b03f15f5.rlib target/debug/deps/libequivalent-65b26217b03f15f5.rmeta target/debug/deps/libfastrand-afd1857ca89a7afc.rlib target/debug/deps/libfastrand-afd1857ca89a7afc.rmeta target/debug/deps/libfind_msvc_tools-71a62bc007db7fa9.rlib target/debug/deps/libfind_msvc_tools-71a62bc007db7fa9.rmeta target/debug/deps/libfnv-9fde446a8c1365fc.rlib target/debug/deps/libfnv-9fde446a8c1365fc.rmeta target/debug/deps/libgetrandom-0eaf42379121fb38.rlib target/debug/deps/libgetrandom-0eaf42379121fb38.rmeta target/debug/deps/libgetrandom-b6fea70e6916ddac.rlib target/debug/deps/libgetrandom-b6fea70e6916ddac.rmeta target/debug/deps/libgetrandom-d98d449a36bafbe0.rlib target/debug/deps/libgetrandom-d98d449a36bafbe0.rmeta target/debug/deps/libnotes_core-8f1ce0f90d61e5a7.rmeta target/debug/deps/libonce_cell-d72f51d4d3ff5e42.rlib target/debug/deps/libonce_cell-d72f51d4d3ff5e42.rmeta target/debug/deps/liboption_ext-f95e8c5597ca1435.rlib target/debug/deps/liboption_ext-f95e8c5597ca1435.rmeta target/debug/deps/libproc_macro2-6b336f0ddb28b27a.rmeta target/debug/deps/libquick_error-8abe3b1ca8b7a8b9.rlib target/debug/deps/libquick_error-8abe3b1ca8b7a8b9.rmeta target/debug/deps/libquote-027634fac3d1dd29.rlib target/debug/deps/libquote-027634fac3d1dd29.rmeta target/debug/deps/librand-41f5c89bf90a2c40.rmeta target/debug/deps/librand_chacha-a13f4556a4cb172c.rlib target/debug/deps/librand_chacha-a13f4556a4cb172c.rmeta target/debug/deps/librand_core-6b2fc663fdff125a.rlib target/debug/deps/librand_core-6b2fc663fdff125a.rmeta target/debug/deps/librand_xorshift-85f984f4f18ce3d5.rlib target/debug/deps/librand_xorshift-85f984f4f18ce3d5.rmeta target/debug/deps/librusty_fork-c05f13651af4f8b1.rmeta target/debug/deps/libshlex-eafb49743ebdab94.rlib target/debug/deps/libshlex-eafb49743ebdab94.rmeta target/debug/deps/libsimilar-dd62e180fdd9fd69.rmeta target/debug/deps/libtempfile-a08e3a6e73f18dc2.rmeta target/debug/deps/libthiserror-d582988ee49bfd38.rlib target/debug/deps/libthiserror-d582988ee49bfd38.rmeta target/debug/deps/libtinyvec-b3705465be29f173.rlib target/debug/deps/libtinyvec-b3705465be29f173.rmeta target/debug/deps/libtinyvec_macros-b3c8ffa612202339.rlib target/debug/deps/libtinyvec_macros-b3c8ffa612202339.rmeta target/debug/deps/libunarray-1317844f3e0f5c07.rlib target/debug/deps/libunarray-1317844f3e0f5c07.rmeta target/debug/deps/libunicode_ident-07077bdd941af1f1.rlib target/debug/deps/libunicode_ident-07077bdd941af1f1.rmeta target/debug/deps/libwait_timeout-6586e67c7f2c4a6f.rlib target/debug/deps/libwait_timeout-6586e67c7f2c4a6f.rmeta target/debug/deps/linux_raw_sys-748a127bddffab6c.d−12 target/debug/deps/notes_core-8f1ce0f90d61e5a7.d−18 target/debug/deps/num_traits-0b5ce95e951c04fe.d−25 target/debug/deps/once_cell-d72f51d4d3ff5e42.d−9 target/debug/deps/option_ext-f95e8c5597ca1435.d−8 target/debug/deps/ppv_lite86-4c2cb8748d60e5c7.d−11 target/debug/deps/proc_macro2-6b336f0ddb28b27a.d−17 target/debug/deps/proptest-c71b6fc3656545fd.d−95 target/debug/deps/quick_error-8abe3b1ca8b7a8b9.d−7 target/debug/deps/quote-027634fac3d1dd29.d−13 target/debug/deps/rand-41f5c89bf90a2c40.d−32 target/debug/deps/rand_chacha-a13f4556a4cb172c.d−9 target/debug/deps/rand_core-6b2fc663fdff125a.d−11 target/debug/deps/rand_xorshift-85f984f4f18ce3d5.d−7 target/debug/deps/regex_syntax-7e413990257ae280.d−37 target/debug/deps/rustix-ebe468a694014bd7.d−68 target/debug/deps/rusty_fork-c05f13651af4f8b1.d−13 target/debug/deps/shlex-eafb49743ebdab94.d−8 target/debug/deps/similar-dd62e180fdd9fd69.d−25 target/debug/deps/syn-efaac48046ba42aa.d−49 target/debug/deps/tempfile-a08e3a6e73f18dc2.d−17 target/debug/deps/thiserror-d582988ee49bfd38.d−14 target/debug/deps/thiserror_impl-054c82e8cb318cbf.d−17 target/debug/deps/tinyvec-b3705465be29f173.d−13 target/debug/deps/tinyvec_macros-b3c8ffa612202339.d−7 target/debug/deps/unarray-1317844f3e0f5c07.d−10 target/debug/deps/unicode_ident-07077bdd941af1f1.d−8 target/debug/deps/unicode_normalization-5a956f8b1f85d8dd.d−17 target/debug/deps/wait_timeout-6586e67c7f2c4a6f.d−8 target/debug/deps/zerocopy-ecaaca3561f2eb29.d−27 target/debug/examples/dbg.d−1
.gitignore+3
@@ -1 +1,4 @@
11target/
2+.vix/
3+result
4+result-*
.vix/access_stats.dbdeleted

Binary file not shown.

.vix/history.txtdeleted−106
@@ -1,106 +0,0 @@
1−
2−# 2026-09-10 10:31:43.201822463 +0000 UTC
3−+I want to make a TUI notes application. It is structured as one big tree. nesting ansd folding is the note organization primitive. Help me design a spec for how it should work
4−
5−# 2026-09-10 10:43:55.561317047 +0000 UTC
6−+1 -- I want it stored as multiple markdown files
7−|2 -- elaborate
8−
9−# 2026-09-10 10:51:57.940262665 +0000 UTC
10−+Can we use Tree CRDT (https://martin.kleppmann.com/papers/move-op.pdf)?
11−
12−# 2026-09-10 10:54:34.912496533 +0000 UTC
13−+syncing is not in scope of the project. The folder will be synced by something like syncthing
14−
15−# 2026-09-10 10:56:48.271951857 +0000 UTC
16−+can you make the spec more terse without losing the ideas
17−
18−# 2026-09-10 11:20:28.434848277 +0000 UTC
19−+Remove the one-line/multi-line distimction. Everything can have more than one line
20−
21−# 2026-09-10 11:29:09.144681711 +0000 UTC
22−+Use Miller columns to display the tree
23−
24−# 2026-09-10 16:35:28.934103914 +0000 UTC
25−+is the spec for a notes TUI. Please summarize the important/non-obvious/non-trivial parts of the spec? The parts where I might disagree
26−
27−# 2026-09-10 16:41:31.28784472 +0000 UTC
28−+remove cut suggestions completely
29−
30−# 2026-09-10 16:43:36.095288906 +0000 UTC
31−+remove all reliance on SVC
32−
33−# 2026-09-10 16:44:19.232116161 +0000 UTC
34−+remove all reliance on source control
35−
36−# 2026-09-10 16:46:09.149882793 +0000 UTC
37−+Please summarize the important/non-obvious/non-trivial parts of the spec? The parts where I might disagree
38−
39−# 2026-09-10 16:49:03.153963768 +0000 UTC
40−+what is ` aliases:`?
41−
42−# 2026-09-10 16:49:58.344485529 +0000 UTC
43−+remove aliases
44−
45−# 2026-09-10 16:58:28.040227337 +0000 UTC
46−+Please summarize the important/non-obvious/non-trivial parts of the spec? The parts where I might disagree
47−
48−# 2026-09-10 17:00:24.02568141 +0000 UTC
49−+what are fronzen filenames?
50−
51−# 2026-09-10 17:04:21.460230648 +0000 UTC
52−+Can we implement automatic conflict resolution?
53−
54−# 2026-09-10 17:06:09.717049323 +0000 UTC
55−+Allow moving a subtree to `archive`. The path to the archived subtree should be created in archive if it doesn't exist
56−
57−# 2026-09-10 17:08:04.6402978 +0000 UTC
58−+Please summarize the important/non-obvious/non-trivial parts of the spec? The parts where I might disagree
59−
60−# 2026-09-10 17:13:38.138149354 +0000 UTC
61−+Can we iterate on the UI?
62−
63−# 2026-09-10 18:57:16.434706401 +0000 UTC
64−+Since it is a TUI can you draw actual examples?
65−
66−# 2026-09-10 18:57:57.960774784 +0000 UTC
67−+It is getting a little corrupted
68−
69−# 2026-09-10 19:00:42.42199869 +0000 UTC
70−+3
71−
72−# 2026-09-10 19:01:05.831002961 +0000 UTC
73−+The right side of lines goes wonky
74−
75−# 2026-09-10 19:06:52.403634629 +0000 UTC
76−+Can we iterate on the UI?
77−
78−# 2026-09-12 03:52:10.342354143 +0000 UTC
79−+Implement a TUI notes app from /home/iko/projects/fold/SPEC.md
80−
81−# 2026-09-12 07:43:40.850382694 +0000 UTC
82−+Are you done?
83−
84−# 2026-09-12 07:44:30.030380062 +0000 UTC
85−+implement all of them
86−
87−# 2026-09-12 08:02:08.035115456 +0000 UTC
88−+rename the application to "fold"
89−
90−# 2026-09-12 11:32:45.555652593 +0000 UTC
91−+Are you done?
92−
93−# 2026-09-12 12:51:59.768963915 +0000 UTC
94−+Add a brief "help" command describing how to use it. ? should also bring it up
95−
96−# 2026-09-12 15:49:54.710180529 +0000 UTC
97−+Did you implement mouse support?
98−
99−# 2026-09-12 15:50:33.36588831 +0000 UTC
100−+Implement mouse support
101−
102−# 2026-09-12 15:58:01.495707142 +0000 UTC
103−+How do I edit the text?
104−
105−# 2026-09-12 15:59:01.601032173 +0000 UTC
106−+Make sure EVERYTHING can be done with mouse alone.
Cargo.lock+1991−323
@@ -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"
@@ -168,9 +254,9 @@ dependencies = [
168254
169255[[package]]
170256name = "cc"
171−version = "1.4.5"
257+version = "1.2.67"
172258source = "registry+https://github.com/rust-lang/crates.io-index"
173−checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
259+checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
174260dependencies = [
175261 "find-msvc-tools",
176262 "shlex",
@@ -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,13 +548,142 @@ dependencies = [
503548 "anyhow",
504549 "crossterm",
505550 "fold-core",
506− "insta",
507− "jiff",
508551 "notify",
509− "nucleo",
510552 "ratatui",
553+ "signal-hook",
511554 "tempfile",
512− "tui-textarea",
555+ "tree-sitter",
556+ "tree-sitter-ada",
557+ "tree-sitter-agda",
558+ "tree-sitter-al",
559+ "tree-sitter-applesoft",
560+ "tree-sitter-arduino",
561+ "tree-sitter-arkts",
562+ "tree-sitter-asm",
563+ "tree-sitter-astro-next",
564+ "tree-sitter-autohotkey",
565+ "tree-sitter-bash",
566+ "tree-sitter-batch",
567+ "tree-sitter-bicep",
568+ "tree-sitter-c",
569+ "tree-sitter-c-sharp",
570+ "tree-sitter-caddy",
571+ "tree-sitter-cfml",
572+ "tree-sitter-clojure-orchard",
573+ "tree-sitter-cmake",
574+ "tree-sitter-containerfile",
575+ "tree-sitter-cpp",
576+ "tree-sitter-css",
577+ "tree-sitter-cuda",
578+ "tree-sitter-cypher",
579+ "tree-sitter-d",
580+ "tree-sitter-dafny",
581+ "tree-sitter-dart",
582+ "tree-sitter-devicetree",
583+ "tree-sitter-diff",
584+ "tree-sitter-dm",
585+ "tree-sitter-elisp",
586+ "tree-sitter-elixir",
587+ "tree-sitter-elm",
588+ "tree-sitter-embedded-template",
589+ "tree-sitter-erlang",
590+ "tree-sitter-fortran",
591+ "tree-sitter-fsharp",
592+ "tree-sitter-gdshader",
593+ "tree-sitter-gitcommit",
594+ "tree-sitter-gleam",
595+ "tree-sitter-glsl",
596+ "tree-sitter-go",
597+ "tree-sitter-gomod-orchard",
598+ "tree-sitter-gosum-orchard",
599+ "tree-sitter-gwbasic",
600+ "tree-sitter-handlebars",
601+ "tree-sitter-harn",
602+ "tree-sitter-haskell",
603+ "tree-sitter-heex",
604+ "tree-sitter-highlight",
605+ "tree-sitter-html",
606+ "tree-sitter-ini",
607+ "tree-sitter-ink-lbz",
608+ "tree-sitter-integerbasic",
609+ "tree-sitter-irules",
610+ "tree-sitter-java",
611+ "tree-sitter-javadoc",
612+ "tree-sitter-javascript",
613+ "tree-sitter-jinja2",
614+ "tree-sitter-jsdoc",
615+ "tree-sitter-json",
616+ "tree-sitter-jsonnet",
617+ "tree-sitter-julia",
618+ "tree-sitter-kconfig",
619+ "tree-sitter-kotlin-sg",
620+ "tree-sitter-lambdamoo",
621+ "tree-sitter-llvm",
622+ "tree-sitter-lua",
623+ "tree-sitter-luau",
624+ "tree-sitter-make",
625+ "tree-sitter-matlab",
626+ "tree-sitter-merlin6502",
627+ "tree-sitter-mlir",
628+ "tree-sitter-msbasic2",
629+ "tree-sitter-newick",
630+ "tree-sitter-nginx",
631+ "tree-sitter-nix",
632+ "tree-sitter-objc",
633+ "tree-sitter-ocaml",
634+ "tree-sitter-ocamllex",
635+ "tree-sitter-odin",
636+ "tree-sitter-openscad-ng",
637+ "tree-sitter-pascal",
638+ "tree-sitter-pgn",
639+ "tree-sitter-php",
640+ "tree-sitter-postgres",
641+ "tree-sitter-powershell",
642+ "tree-sitter-printf",
643+ "tree-sitter-prisma-io",
644+ "tree-sitter-prolog",
645+ "tree-sitter-properties",
646+ "tree-sitter-proto",
647+ "tree-sitter-puppet",
648+ "tree-sitter-python",
649+ "tree-sitter-qbasic",
650+ "tree-sitter-ql",
651+ "tree-sitter-qmljs",
652+ "tree-sitter-qsharp",
653+ "tree-sitter-r",
654+ "tree-sitter-racket",
655+ "tree-sitter-regex",
656+ "tree-sitter-requirements",
657+ "tree-sitter-riscv",
658+ "tree-sitter-ruby",
659+ "tree-sitter-rush",
660+ "tree-sitter-rust",
661+ "tree-sitter-sas",
662+ "tree-sitter-scala",
663+ "tree-sitter-scheme",
664+ "tree-sitter-sdml",
665+ "tree-sitter-sequel",
666+ "tree-sitter-sequel-tsql",
667+ "tree-sitter-sfapex",
668+ "tree-sitter-starlark",
669+ "tree-sitter-svelte-ng",
670+ "tree-sitter-svg",
671+ "tree-sitter-swift",
672+ "tree-sitter-sysml",
673+ "tree-sitter-systemverilog",
674+ "tree-sitter-templ",
675+ "tree-sitter-tlaplus",
676+ "tree-sitter-tmux",
677+ "tree-sitter-toml-ng",
678+ "tree-sitter-tsquery",
679+ "tree-sitter-typescript",
680+ "tree-sitter-varlink",
681+ "tree-sitter-vhdl",
682+ "tree-sitter-vue-next",
683+ "tree-sitter-xml",
684+ "tree-sitter-yaml",
685+ "tree-sitter-zig",
686+ "tree-sitter-zsh",
513687 "unicode-width 0.2.0",
514688]
515689
@@ -519,6 +693,15 @@ version = "0.1.5"
519693source = "registry+https://github.com/rust-lang/crates.io-index"
520694checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
521695
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+
522705[[package]]
523706name = "fsevent-sys"
524707version = "4.1.0"
@@ -562,6 +745,23 @@ dependencies = [
562745 "r-efi 6.0.0",
563746]
564747
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+
565765[[package]]
566766name = "hashbrown"
567767version = "0.15.5"
@@ -599,6 +799,8 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
599799dependencies = [
600800 "equivalent",
601801 "hashbrown 0.17.1",
802+ "serde",
803+ "serde_core",
602804]
603805
604806[[package]]
@@ -630,18 +832,6 @@ dependencies = [
630832 "libc",
631833]
632834
633−[[package]]
634−name = "insta"
635−version = "1.48.0"
636−source = "registry+https://github.com/rust-lang/crates.io-index"
637−checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82"
638−dependencies = [
639− "console",
640− "once_cell",
641− "similar",
642− "tempfile",
643−]
644−
645835[[package]]
646836name = "instability"
647837version = "0.3.13"
@@ -806,6 +996,12 @@ version = "2.8.3"
806996source = "registry+https://github.com/rust-lang/crates.io-index"
807997checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
808998
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+
8091005[[package]]
8101006name = "mio"
8111007version = "1.2.3"
@@ -818,6 +1014,16 @@ dependencies = [
8181014 "windows-sys 0.61.2",
8191015]
8201016
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+
8211027[[package]]
8221028name = "normalize-line-endings"
8231029version = "0.3.0"
@@ -851,27 +1057,6 @@ dependencies = [
8511057 "bitflags 2.13.2",
8521058]
8531059
854−[[package]]
855−name = "nucleo"
856−version = "0.5.0"
857−source = "registry+https://github.com/rust-lang/crates.io-index"
858−checksum = "5262af4c94921c2646c5ac6ff7900c2af9cbb08dc26a797e18130a7019c039d4"
859−dependencies = [
860− "nucleo-matcher",
861− "parking_lot",
862− "rayon",
863−]
864−
865−[[package]]
866−name = "nucleo-matcher"
867−version = "0.3.1"
868−source = "registry+https://github.com/rust-lang/crates.io-index"
869−checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85"
870−dependencies = [
871− "memchr",
872− "unicode-segmentation",
873−]
874−
8751060[[package]]
8761061name = "num-traits"
8771062version = "0.2.19"
@@ -928,6 +1113,18 @@ version = "1.0.15"
9281113source = "registry+https://github.com/rust-lang/crates.io-index"
9291114checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
9301115
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+
9311128[[package]]
9321129name = "portable-atomic"
9331130version = "1.15.0"
@@ -943,15 +1140,6 @@ dependencies = [
9431140 "portable-atomic",
9441141]
9451142
946−[[package]]
947−name = "ppv-lite86"
948−version = "0.2.21"
949−source = "registry+https://github.com/rust-lang/crates.io-index"
950−checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
951−dependencies = [
952− "zerocopy",
953−]
954−
9551143[[package]]
9561144name = "predicates"
9571145version = "3.1.4"
@@ -991,31 +1179,6 @@ dependencies = [
9911179 "unicode-ident",
9921180]
9931181
994−[[package]]
995−name = "proptest"
996−version = "1.11.0"
997−source = "registry+https://github.com/rust-lang/crates.io-index"
998−checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
999−dependencies = [
1000− "bit-set",
1001− "bit-vec",
1002− "bitflags 2.13.2",
1003− "num-traits",
1004− "rand",
1005− "rand_chacha",
1006− "rand_xorshift",
1007− "regex-syntax",
1008− "rusty-fork",
1009− "tempfile",
1010− "unarray",
1011−]
1012−
1013−[[package]]
1014−name = "quick-error"
1015−version = "1.2.3"
1016−source = "registry+https://github.com/rust-lang/crates.io-index"
1017−checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
1018−
10191182[[package]]
10201183name = "quote"
10211184version = "1.0.47"
@@ -1037,44 +1200,6 @@ version = "6.0.0"
10371200source = "registry+https://github.com/rust-lang/crates.io-index"
10381201checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
10391202
1040−[[package]]
1041−name = "rand"
1042−version = "0.9.5"
1043−source = "registry+https://github.com/rust-lang/crates.io-index"
1044−checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
1045−dependencies = [
1046− "rand_chacha",
1047− "rand_core",
1048−]
1049−
1050−[[package]]
1051−name = "rand_chacha"
1052−version = "0.9.0"
1053−source = "registry+https://github.com/rust-lang/crates.io-index"
1054−checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
1055−dependencies = [
1056− "ppv-lite86",
1057− "rand_core",
1058−]
1059−
1060−[[package]]
1061−name = "rand_core"
1062−version = "0.9.5"
1063−source = "registry+https://github.com/rust-lang/crates.io-index"
1064−checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
1065−dependencies = [
1066− "getrandom 0.3.4",
1067−]
1068−
1069−[[package]]
1070−name = "rand_xorshift"
1071−version = "0.4.0"
1072−source = "registry+https://github.com/rust-lang/crates.io-index"
1073−checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
1074−dependencies = [
1075− "rand_core",
1076−]
1077−
10781203[[package]]
10791204name = "ratatui"
10801205version = "0.29.0"
@@ -1096,26 +1221,6 @@ dependencies = [
10961221 "unicode-width 0.2.0",
10971222]
10981223
1099−[[package]]
1100−name = "rayon"
1101−version = "1.12.0"
1102−source = "registry+https://github.com/rust-lang/crates.io-index"
1103−checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
1104−dependencies = [
1105− "either",
1106− "rayon-core",
1107−]
1108−
1109−[[package]]
1110−name = "rayon-core"
1111−version = "1.13.0"
1112−source = "registry+https://github.com/rust-lang/crates.io-index"
1113−checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
1114−dependencies = [
1115− "crossbeam-deque",
1116− "crossbeam-utils",
1117−]
1118−
11191224[[package]]
11201225name = "redox_syscall"
11211226version = "0.5.18"
@@ -1165,6 +1270,12 @@ version = "0.8.11"
11651270source = "registry+https://github.com/rust-lang/crates.io-index"
11661271checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
11671272
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+
11681279[[package]]
11691280name = "rustix"
11701281version = "0.38.44"
@@ -1197,18 +1308,6 @@ version = "1.0.23"
11971308source = "registry+https://github.com/rust-lang/crates.io-index"
11981309checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
11991310
1200−[[package]]
1201−name = "rusty-fork"
1202−version = "0.3.1"
1203−source = "registry+https://github.com/rust-lang/crates.io-index"
1204−checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
1205−dependencies = [
1206− "fnv",
1207− "quick-error",
1208− "tempfile",
1209− "wait-timeout",
1210−]
1211−
12121311[[package]]
12131312name = "ryu"
12141313version = "1.0.23"
@@ -1231,200 +1330,1644 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
12311330checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
12321331
12331332[[package]]
1234−name = "serde_core"
1235−version = "1.0.229"
1333+name = "scroll"
1334+version = "0.12.0"
12361335source = "registry+https://github.com/rust-lang/crates.io-index"
1237−checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
1336+checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6"
12381337dependencies = [
1239− "serde_derive",
1338+ "scroll_derive",
12401339]
12411340
12421341[[package]]
1243−name = "serde_derive"
1244−version = "1.0.229"
1342+name = "scroll_derive"
1343+version = "0.12.1"
12451344source = "registry+https://github.com/rust-lang/crates.io-index"
1246−checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
1345+checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d"
12471346dependencies = [
12481347 "proc-macro2",
12491348 "quote",
1250− "syn 3.0.5",
1349+ "syn 2.0.119",
12511350]
12521351
12531352[[package]]
1254−name = "shlex"
1255−version = "2.0.1"
1256−source = "registry+https://github.com/rust-lang/crates.io-index"
1257−checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
1258−
1259−[[package]]
1260−name = "signal-hook"
1261−version = "0.3.18"
1353+name = "semver"
1354+version = "1.0.28"
12621355source = "registry+https://github.com/rust-lang/crates.io-index"
1263−checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
1356+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
12641357dependencies = [
1265− "libc",
1266− "signal-hook-registry",
1358+ "serde",
1359+ "serde_core",
12671360]
12681361
12691362[[package]]
1270−name = "signal-hook-mio"
1271−version = "0.2.5"
1363+name = "serde"
1364+version = "1.0.229"
12721365source = "registry+https://github.com/rust-lang/crates.io-index"
1273−checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
1366+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
12741367dependencies = [
1275− "libc",
1276− "mio",
1277− "signal-hook",
1368+ "serde_core",
1369+ "serde_derive",
12781370]
12791371
12801372[[package]]
1281−name = "signal-hook-registry"
1282−version = "1.4.8"
1373+name = "serde_core"
1374+version = "1.0.229"
12831375source = "registry+https://github.com/rust-lang/crates.io-index"
1284−checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
1376+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
12851377dependencies = [
1286− "errno",
1287− "libc",
1378+ "serde_derive",
12881379]
12891380
12901381[[package]]
1291−name = "similar"
1382+name = "serde_derive"
1383+version = "1.0.229"
1384+source = "registry+https://github.com/rust-lang/crates.io-index"
1385+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
1386+dependencies = [
1387+ "proc-macro2",
1388+ "quote",
1389+ "syn 3.0.5",
1390+]
1391+
1392+[[package]]
1393+name = "serde_json"
1394+version = "1.0.151"
1395+source = "registry+https://github.com/rust-lang/crates.io-index"
1396+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
1397+dependencies = [
1398+ "indexmap",
1399+ "itoa",
1400+ "memchr",
1401+ "serde",
1402+ "serde_core",
1403+ "zmij",
1404+]
1405+
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+
1415+[[package]]
1416+name = "shlex"
1417+version = "2.0.1"
1418+source = "registry+https://github.com/rust-lang/crates.io-index"
1419+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
1420+
1421+[[package]]
1422+name = "signal-hook"
1423+version = "0.3.18"
1424+source = "registry+https://github.com/rust-lang/crates.io-index"
1425+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
1426+dependencies = [
1427+ "libc",
1428+ "signal-hook-registry",
1429+]
1430+
1431+[[package]]
1432+name = "signal-hook-mio"
1433+version = "0.2.5"
1434+source = "registry+https://github.com/rust-lang/crates.io-index"
1435+checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
1436+dependencies = [
1437+ "libc",
1438+ "mio",
1439+ "signal-hook",
1440+]
1441+
1442+[[package]]
1443+name = "signal-hook-registry"
1444+version = "1.4.8"
1445+source = "registry+https://github.com/rust-lang/crates.io-index"
1446+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
1447+dependencies = [
1448+ "errno",
1449+ "libc",
1450+]
1451+
1452+[[package]]
1453+name = "similar"
12921454version = "2.7.0"
12931455source = "registry+https://github.com/rust-lang/crates.io-index"
1294−checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
1456+checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
1457+
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+
1464+[[package]]
1465+name = "smallvec"
1466+version = "1.16.1"
1467+source = "registry+https://github.com/rust-lang/crates.io-index"
1468+checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
1469+
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+
1476+[[package]]
1477+name = "static_assertions"
1478+version = "1.1.0"
1479+source = "registry+https://github.com/rust-lang/crates.io-index"
1480+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
1481+
1482+[[package]]
1483+name = "streaming-iterator"
1484+version = "0.1.9"
1485+source = "registry+https://github.com/rust-lang/crates.io-index"
1486+checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
1487+
1488+[[package]]
1489+name = "strsim"
1490+version = "0.11.1"
1491+source = "registry+https://github.com/rust-lang/crates.io-index"
1492+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
1493+
1494+[[package]]
1495+name = "strum"
1496+version = "0.26.3"
1497+source = "registry+https://github.com/rust-lang/crates.io-index"
1498+checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
1499+dependencies = [
1500+ "strum_macros",
1501+]
1502+
1503+[[package]]
1504+name = "strum_macros"
1505+version = "0.26.4"
1506+source = "registry+https://github.com/rust-lang/crates.io-index"
1507+checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
1508+dependencies = [
1509+ "heck",
1510+ "proc-macro2",
1511+ "quote",
1512+ "rustversion",
1513+ "syn 2.0.119",
1514+]
1515+
1516+[[package]]
1517+name = "syn"
1518+version = "2.0.119"
1519+source = "registry+https://github.com/rust-lang/crates.io-index"
1520+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
1521+dependencies = [
1522+ "proc-macro2",
1523+ "quote",
1524+ "unicode-ident",
1525+]
1526+
1527+[[package]]
1528+name = "syn"
1529+version = "3.0.5"
1530+source = "registry+https://github.com/rust-lang/crates.io-index"
1531+checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
1532+dependencies = [
1533+ "proc-macro2",
1534+ "quote",
1535+ "unicode-ident",
1536+]
1537+
1538+[[package]]
1539+name = "tempfile"
1540+version = "3.27.0"
1541+source = "registry+https://github.com/rust-lang/crates.io-index"
1542+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
1543+dependencies = [
1544+ "fastrand",
1545+ "getrandom 0.4.3",
1546+ "once_cell",
1547+ "rustix 1.1.4",
1548+ "windows-sys 0.61.2",
1549+]
1550+
1551+[[package]]
1552+name = "termtree"
1553+version = "0.5.1"
1554+source = "registry+https://github.com/rust-lang/crates.io-index"
1555+checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
1556+
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+
1567+[[package]]
1568+name = "thiserror"
1569+version = "2.0.20"
1570+source = "registry+https://github.com/rust-lang/crates.io-index"
1571+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
1572+dependencies = [
1573+ "thiserror-impl",
1574+]
1575+
1576+[[package]]
1577+name = "thiserror-impl"
1578+version = "2.0.20"
1579+source = "registry+https://github.com/rust-lang/crates.io-index"
1580+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
1581+dependencies = [
1582+ "proc-macro2",
1583+ "quote",
1584+ "syn 3.0.5",
1585+]
1586+
1587+[[package]]
1588+name = "tinyvec"
1589+version = "1.13.2"
1590+source = "registry+https://github.com/rust-lang/crates.io-index"
1591+checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b"
1592+dependencies = [
1593+ "tinyvec_macros",
1594+]
1595+
1596+[[package]]
1597+name = "tinyvec_macros"
1598+version = "0.1.1"
1599+source = "registry+https://github.com/rust-lang/crates.io-index"
1600+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
1601+
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+
1641+[[package]]
1642+name = "tree-sitter"
1643+version = "0.27.0"
1644+source = "registry+https://github.com/rust-lang/crates.io-index"
1645+checksum = "2038684e0058edba0d17302619f62eabce4a8e11c6ac59506996a8d79848851d"
1646+dependencies = [
1647+ "cc",
1648+ "regex",
1649+ "serde_json",
1650+ "streaming-iterator",
1651+ "tree-sitter-language",
1652+]
1653+
1654+[[package]]
1655+name = "tree-sitter-ada"
1656+version = "0.1.0"
1657+source = "registry+https://github.com/rust-lang/crates.io-index"
1658+checksum = "8d9fcdd64359c98fcc99d72f6d3d6ca5d6d76ce325ac39430b1d283a0fb61ca1"
1659+dependencies = [
1660+ "cc",
1661+ "tree-sitter-language",
1662+]
1663+
1664+[[package]]
1665+name = "tree-sitter-agda"
1666+version = "1.3.3"
1667+source = "registry+https://github.com/rust-lang/crates.io-index"
1668+checksum = "79170741bc75a42d93a69f7e2d39bece006bea602af30650f7f323645094d693"
1669+dependencies = [
1670+ "cc",
1671+ "tree-sitter-language",
1672+]
1673+
1674+[[package]]
1675+name = "tree-sitter-al"
1676+version = "4.4.0"
1677+source = "registry+https://github.com/rust-lang/crates.io-index"
1678+checksum = "f74c9d7887308b7ffd93ae3d0684cff9ad4d6d532497dcd213e69170a6e0d44d"
1679+dependencies = [
1680+ "cc",
1681+ "tree-sitter-language",
1682+]
1683+
1684+[[package]]
1685+name = "tree-sitter-applesoft"
1686+version = "5.0.0"
1687+source = "registry+https://github.com/rust-lang/crates.io-index"
1688+checksum = "95df4aeaf78d227b34a109f57debb1b18b96176fc8b98ef6d60f821f42bbd342"
1689+dependencies = [
1690+ "cc",
1691+ "tree-sitter-language",
1692+]
1693+
1694+[[package]]
1695+name = "tree-sitter-arduino"
1696+version = "0.24.0"
1697+source = "registry+https://github.com/rust-lang/crates.io-index"
1698+checksum = "8a26fe49dcba4e91b7caca0fcfacff90d54029de5dde266af9818af7aab1c4e4"
1699+dependencies = [
1700+ "cc",
1701+ "tree-sitter-language",
1702+]
1703+
1704+[[package]]
1705+name = "tree-sitter-arkts"
1706+version = "0.3.0"
1707+source = "registry+https://github.com/rust-lang/crates.io-index"
1708+checksum = "a3f34bb5f7037c2e13d4d2e42d6828f3cc0899666e77d4c6a0594348900fd629"
1709+dependencies = [
1710+ "cc",
1711+ "tree-sitter-language",
1712+]
1713+
1714+[[package]]
1715+name = "tree-sitter-asm"
1716+version = "0.24.0"
1717+source = "registry+https://github.com/rust-lang/crates.io-index"
1718+checksum = "743a0377253cd5505841e61ba1093a04b3a949b2b751752ff956040dda14f1bc"
1719+dependencies = [
1720+ "cc",
1721+ "tree-sitter-language",
1722+]
1723+
1724+[[package]]
1725+name = "tree-sitter-astro-next"
1726+version = "0.1.1"
1727+source = "registry+https://github.com/rust-lang/crates.io-index"
1728+checksum = "794a4a59fc2d88e49b4bc41fef9522d77184a36f4e68bbaf545cd1eb2364c46e"
1729+dependencies = [
1730+ "cc",
1731+ "tree-sitter-language",
1732+]
1733+
1734+[[package]]
1735+name = "tree-sitter-autohotkey"
1736+version = "1.0.1"
1737+source = "registry+https://github.com/rust-lang/crates.io-index"
1738+checksum = "2dfa4b3017b9a0a5e310128ba29ae1c561dc76d2f0c0c9c420a25c04ffe59481"
1739+dependencies = [
1740+ "cc",
1741+ "tree-sitter-language",
1742+]
1743+
1744+[[package]]
1745+name = "tree-sitter-bash"
1746+version = "0.25.1"
1747+source = "registry+https://github.com/rust-lang/crates.io-index"
1748+checksum = "9e5ec769279cc91b561d3df0d8a5deb26b0ad40d183127f409494d6d8fc53062"
1749+dependencies = [
1750+ "cc",
1751+ "tree-sitter-language",
1752+]
1753+
1754+[[package]]
1755+name = "tree-sitter-batch"
1756+version = "0.11.1"
1757+source = "registry+https://github.com/rust-lang/crates.io-index"
1758+checksum = "46fea9ee100103c3d5fc3c8243da6e8f95163657486040503b8514c9952b79da"
1759+dependencies = [
1760+ "cc",
1761+ "tree-sitter-language",
1762+]
1763+
1764+[[package]]
1765+name = "tree-sitter-bicep"
1766+version = "1.1.0"
1767+source = "registry+https://github.com/rust-lang/crates.io-index"
1768+checksum = "c02553520c500f552f1dee90d02a29717c82d5b395204b3995d356c6d15931f9"
1769+dependencies = [
1770+ "cc",
1771+ "tree-sitter-language",
1772+]
1773+
1774+[[package]]
1775+name = "tree-sitter-c"
1776+version = "0.24.2"
1777+source = "registry+https://github.com/rust-lang/crates.io-index"
1778+checksum = "a9b2eb57a55fed6b00812912e730b7a275cf4fe98bfd6a5d76263d4438371728"
1779+dependencies = [
1780+ "cc",
1781+ "tree-sitter-language",
1782+]
1783+
1784+[[package]]
1785+name = "tree-sitter-c-sharp"
1786+version = "0.23.5"
1787+source = "registry+https://github.com/rust-lang/crates.io-index"
1788+checksum = "c1aac67f1ad71de1d6d39708d34811081c26dfa495658de6c14c34200849357c"
1789+dependencies = [
1790+ "cc",
1791+ "tree-sitter-language",
1792+]
1793+
1794+[[package]]
1795+name = "tree-sitter-caddy"
1796+version = "0.1.0"
1797+source = "registry+https://github.com/rust-lang/crates.io-index"
1798+checksum = "26d38dd2a0da1a89e290b49d54aea92a59caf8ad6babde6a8f74be7f1ba2fd54"
1799+dependencies = [
1800+ "cc",
1801+ "tree-sitter-language",
1802+]
1803+
1804+[[package]]
1805+name = "tree-sitter-cfml"
1806+version = "0.26.39"
1807+source = "registry+https://github.com/rust-lang/crates.io-index"
1808+checksum = "48ea148734644b79470e2e2a4c6b548382520cb0c5444af7713d38462c14a66f"
1809+dependencies = [
1810+ "cc",
1811+ "tree-sitter-language",
1812+]
1813+
1814+[[package]]
1815+name = "tree-sitter-clojure-orchard"
1816+version = "0.2.8"
1817+source = "registry+https://github.com/rust-lang/crates.io-index"
1818+checksum = "67b854ba5935d62a713280b867c09cae08c6ad2286895734b5550ed02122d8fd"
1819+dependencies = [
1820+ "cc",
1821+ "tree-sitter-language",
1822+]
1823+
1824+[[package]]
1825+name = "tree-sitter-cmake"
1826+version = "0.7.5"
1827+source = "registry+https://github.com/rust-lang/crates.io-index"
1828+checksum = "d22893fac54133fdb93497efc45713d53a96d93b035288c7a8ab0c2af77c0ddd"
1829+dependencies = [
1830+ "cc",
1831+ "tree-sitter-language",
1832+]
1833+
1834+[[package]]
1835+name = "tree-sitter-containerfile"
1836+version = "0.9.2"
1837+source = "registry+https://github.com/rust-lang/crates.io-index"
1838+checksum = "f4811d55a5a2c32bb024b441c32b7417c3ec1af1a080a25fa20d321627b65a2b"
1839+dependencies = [
1840+ "cc",
1841+ "tree-sitter-language",
1842+]
1843+
1844+[[package]]
1845+name = "tree-sitter-cpp"
1846+version = "0.23.4"
1847+source = "registry+https://github.com/rust-lang/crates.io-index"
1848+checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743"
1849+dependencies = [
1850+ "cc",
1851+ "tree-sitter-language",
1852+]
1853+
1854+[[package]]
1855+name = "tree-sitter-css"
1856+version = "0.25.0"
1857+source = "registry+https://github.com/rust-lang/crates.io-index"
1858+checksum = "a5cbc5e18f29a2c6d6435891f42569525cf95435a3e01c2f1947abcde178686f"
1859+dependencies = [
1860+ "cc",
1861+ "tree-sitter-language",
1862+]
1863+
1864+[[package]]
1865+name = "tree-sitter-cuda"
1866+version = "0.21.2"
1867+source = "registry+https://github.com/rust-lang/crates.io-index"
1868+checksum = "e88ed4ad300c2fb7ed7dde1d9c1b0926bd93fc8ae1c8766898191bd85f2220f5"
1869+dependencies = [
1870+ "cc",
1871+ "tree-sitter-language",
1872+]
1873+
1874+[[package]]
1875+name = "tree-sitter-cypher"
1876+version = "0.2.6"
1877+source = "registry+https://github.com/rust-lang/crates.io-index"
1878+checksum = "c22e06abe4ed210fabe796dd72bf5ca56f788f633b993ed303525178ca7b4608"
1879+dependencies = [
1880+ "cc",
1881+ "tree-sitter-language",
1882+]
1883+
1884+[[package]]
1885+name = "tree-sitter-d"
1886+version = "0.8.2"
1887+source = "registry+https://github.com/rust-lang/crates.io-index"
1888+checksum = "66c6fa0082e20747a92c5c863cadb7775fbe344f212837f03fc0f7187adf6fd1"
1889+dependencies = [
1890+ "cc",
1891+ "tree-sitter-language",
1892+]
1893+
1894+[[package]]
1895+name = "tree-sitter-dafny"
1896+version = "0.1.0"
1897+source = "registry+https://github.com/rust-lang/crates.io-index"
1898+checksum = "8c0d15ae3182196f73a30b4ddf3e68ee0f72757ba7ec0d133ffcb9d0fa737fc9"
1899+dependencies = [
1900+ "cc",
1901+ "tree-sitter-language",
1902+]
1903+
1904+[[package]]
1905+name = "tree-sitter-dart"
1906+version = "0.2.0"
1907+source = "registry+https://github.com/rust-lang/crates.io-index"
1908+checksum = "325dd1e24ee9ee21111e9c43680ae7d6010aaa9f282b048a99b9c7163c1cf553"
1909+dependencies = [
1910+ "cc",
1911+ "tree-sitter-language",
1912+]
1913+
1914+[[package]]
1915+name = "tree-sitter-devicetree"
1916+version = "0.15.0"
1917+source = "registry+https://github.com/rust-lang/crates.io-index"
1918+checksum = "6264316c288e1d4fed3002fb6c2d7a8e90d65d519e2ecf64a60c5abd0ee35481"
1919+dependencies = [
1920+ "cc",
1921+ "tree-sitter-language",
1922+]
1923+
1924+[[package]]
1925+name = "tree-sitter-diff"
1926+version = "0.1.0"
1927+source = "registry+https://github.com/rust-lang/crates.io-index"
1928+checksum = "dfe1e5ca280a65dfe5ba4205c1bcc84edf486464fed315db53dee6da9a335889"
1929+dependencies = [
1930+ "cc",
1931+ "tree-sitter-language",
1932+]
1933+
1934+[[package]]
1935+name = "tree-sitter-dm"
1936+version = "0.25.4"
1937+source = "registry+https://github.com/rust-lang/crates.io-index"
1938+checksum = "762b578ec076df149d5121d96a2ac76c977702c13541564646f56984fda9731d"
1939+dependencies = [
1940+ "cc",
1941+ "tree-sitter-language",
1942+]
1943+
1944+[[package]]
1945+name = "tree-sitter-elisp"
1946+version = "1.7.2"
1947+source = "registry+https://github.com/rust-lang/crates.io-index"
1948+checksum = "6b8b82ab7474028f20d2119285566f219209ebdbeec767fa28025b70f94f8cf3"
1949+dependencies = [
1950+ "cc",
1951+ "tree-sitter-language",
1952+]
1953+
1954+[[package]]
1955+name = "tree-sitter-elixir"
1956+version = "0.3.5"
1957+source = "registry+https://github.com/rust-lang/crates.io-index"
1958+checksum = "66dd064a762ed95bfc29857fa3cb7403bb1e5cb88112de0f6341b7e47284ba40"
1959+dependencies = [
1960+ "cc",
1961+ "tree-sitter-language",
1962+]
1963+
1964+[[package]]
1965+name = "tree-sitter-elm"
1966+version = "5.9.4"
1967+source = "registry+https://github.com/rust-lang/crates.io-index"
1968+checksum = "807f387a83c88d894b978edeff024f26a2935505de83237f57c77f9aa2de4ee7"
1969+dependencies = [
1970+ "cc",
1971+ "tree-sitter-language",
1972+]
1973+
1974+[[package]]
1975+name = "tree-sitter-embedded-template"
1976+version = "0.25.0"
1977+source = "registry+https://github.com/rust-lang/crates.io-index"
1978+checksum = "833d528e8fcb4e49ddb04d4d6450ddb8ac08f282a58fec94ce981c9c5dbf7e3a"
1979+dependencies = [
1980+ "cc",
1981+ "tree-sitter-language",
1982+]
1983+
1984+[[package]]
1985+name = "tree-sitter-erlang"
1986+version = "0.20.0"
1987+source = "registry+https://github.com/rust-lang/crates.io-index"
1988+checksum = "3107efc581d335488dc38d2f51ca8b7a6a98e36f07f95a8e963f031edda6f568"
1989+dependencies = [
1990+ "cc",
1991+ "tree-sitter-language",
1992+]
1993+
1994+[[package]]
1995+name = "tree-sitter-fortran"
1996+version = "0.6.0"
1997+source = "registry+https://github.com/rust-lang/crates.io-index"
1998+checksum = "ea5fea41363a9b59666001dc26c4b356d4e164da27c6ce31145e00aba6bf9b16"
1999+dependencies = [
2000+ "cc",
2001+ "tree-sitter-language",
2002+]
2003+
2004+[[package]]
2005+name = "tree-sitter-fsharp"
2006+version = "0.3.12"
2007+source = "registry+https://github.com/rust-lang/crates.io-index"
2008+checksum = "87affbae0d85926cebaa1abb18660904540708ab2c9ed7547f4809e6188125db"
2009+dependencies = [
2010+ "cc",
2011+ "tree-sitter-language",
2012+]
2013+
2014+[[package]]
2015+name = "tree-sitter-gdshader"
2016+version = "0.4.0"
2017+source = "registry+https://github.com/rust-lang/crates.io-index"
2018+checksum = "f915f22caa639690f4df0017ef06f6217573cd7ebca8d83d67a5220ec4a7844a"
2019+dependencies = [
2020+ "cc",
2021+ "tree-sitter-language",
2022+]
2023+
2024+[[package]]
2025+name = "tree-sitter-gitcommit"
2026+version = "0.5.0"
2027+source = "registry+https://github.com/rust-lang/crates.io-index"
2028+checksum = "38b051221607cfde94e2d0132ad99ef50a9f78a990c068380717ea0a4ce61899"
2029+dependencies = [
2030+ "cc",
2031+ "tree-sitter-language",
2032+]
2033+
2034+[[package]]
2035+name = "tree-sitter-gleam"
2036+version = "1.0.0"
2037+source = "registry+https://github.com/rust-lang/crates.io-index"
2038+checksum = "f0175c53793bda5d444360dd5add25463d18d66afb7f521d6791e2fc61bf2fb3"
2039+dependencies = [
2040+ "cc",
2041+ "tree-sitter-language",
2042+]
2043+
2044+[[package]]
2045+name = "tree-sitter-glsl"
2046+version = "0.2.0"
2047+source = "registry+https://github.com/rust-lang/crates.io-index"
2048+checksum = "df0ee57cbf4c2b35d3a7f17a01bf33c0ef2e85987cb85e516ac8c6904c4e62ac"
2049+dependencies = [
2050+ "cc",
2051+ "tree-sitter-language",
2052+]
2053+
2054+[[package]]
2055+name = "tree-sitter-go"
2056+version = "0.25.0"
2057+source = "registry+https://github.com/rust-lang/crates.io-index"
2058+checksum = "c8560a4d2f835cc0d4d2c2e03cbd0dde2f6114b43bc491164238d333e28b16ea"
2059+dependencies = [
2060+ "cc",
2061+ "tree-sitter-language",
2062+]
2063+
2064+[[package]]
2065+name = "tree-sitter-gomod-orchard"
2066+version = "0.5.3"
2067+source = "registry+https://github.com/rust-lang/crates.io-index"
2068+checksum = "8755c634adc6dafdd0c622920be61facd377b6583949ff7904eee577ce01645d"
2069+dependencies = [
2070+ "cc",
2071+ "tree-sitter-language",
2072+]
2073+
2074+[[package]]
2075+name = "tree-sitter-gosum-orchard"
2076+version = "0.3.3"
2077+source = "registry+https://github.com/rust-lang/crates.io-index"
2078+checksum = "4a269f866df50eea3531a91b3603c951a2d9459a54f6b19bb89c86d3c0eb05b4"
2079+dependencies = [
2080+ "cc",
2081+ "tree-sitter-language",
2082+]
2083+
2084+[[package]]
2085+name = "tree-sitter-gwbasic"
2086+version = "0.2.0"
2087+source = "registry+https://github.com/rust-lang/crates.io-index"
2088+checksum = "87f9d9d9fef555166cc1e31e57754d199552c24559424179323c27456e64a9aa"
2089+dependencies = [
2090+ "cc",
2091+ "tree-sitter-language",
2092+]
2093+
2094+[[package]]
2095+name = "tree-sitter-handlebars"
2096+version = "0.1.0"
2097+source = "registry+https://github.com/rust-lang/crates.io-index"
2098+checksum = "d62b536144a4cf982ff473e1495a485ffa1229554ecc468b6c33c3ca4c53ae1c"
2099+dependencies = [
2100+ "cc",
2101+ "tree-sitter-language",
2102+]
2103+
2104+[[package]]
2105+name = "tree-sitter-harn"
2106+version = "0.10.141"
2107+source = "registry+https://github.com/rust-lang/crates.io-index"
2108+checksum = "07bb291f02829c851fcca5b3ff532f8acca036602ca5f15f541b6fbf0f250a97"
2109+dependencies = [
2110+ "cc",
2111+ "tree-sitter-language",
2112+]
2113+
2114+[[package]]
2115+name = "tree-sitter-haskell"
2116+version = "0.23.1"
2117+source = "registry+https://github.com/rust-lang/crates.io-index"
2118+checksum = "977c51e504548cba13fc27cb5a2edab2124cf6716a1934915d07ab99523b05a4"
2119+dependencies = [
2120+ "cc",
2121+ "tree-sitter-language",
2122+]
2123+
2124+[[package]]
2125+name = "tree-sitter-heex"
2126+version = "0.8.1"
2127+source = "registry+https://github.com/rust-lang/crates.io-index"
2128+checksum = "732a4d5994094920f52c4d74bc336cd3739536d68d6140939bc62194f04f4f00"
2129+dependencies = [
2130+ "cc",
2131+ "tree-sitter-language",
2132+]
2133+
2134+[[package]]
2135+name = "tree-sitter-highlight"
2136+version = "0.27.0"
2137+source = "registry+https://github.com/rust-lang/crates.io-index"
2138+checksum = "fa86266a9dc1aae627034551d45e84427a45c70914a8629608ed1bd093214c28"
2139+dependencies = [
2140+ "regex",
2141+ "streaming-iterator",
2142+ "thiserror",
2143+ "tree-sitter",
2144+]
2145+
2146+[[package]]
2147+name = "tree-sitter-html"
2148+version = "0.23.2"
2149+source = "registry+https://github.com/rust-lang/crates.io-index"
2150+checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee"
2151+dependencies = [
2152+ "cc",
2153+ "tree-sitter-language",
2154+]
2155+
2156+[[package]]
2157+name = "tree-sitter-ini"
2158+version = "1.4.0"
2159+source = "registry+https://github.com/rust-lang/crates.io-index"
2160+checksum = "387f79682cd53b7c0a5777c96e601a02b9965a787984ef86dbb8952bdab2d62f"
2161+dependencies = [
2162+ "cc",
2163+ "tree-sitter-language",
2164+]
2165+
2166+[[package]]
2167+name = "tree-sitter-ink-lbz"
2168+version = "0.0.5"
2169+source = "registry+https://github.com/rust-lang/crates.io-index"
2170+checksum = "d57e430493707c89d223ca6c9743a12915a20d3b97c5f8fea16065c2e823747e"
2171+dependencies = [
2172+ "cc",
2173+ "tree-sitter-language",
2174+]
2175+
2176+[[package]]
2177+name = "tree-sitter-integerbasic"
2178+version = "3.0.0"
2179+source = "registry+https://github.com/rust-lang/crates.io-index"
2180+checksum = "a85f32b692c4df974d82f0566d7622dba330e7a89514f75d778a97e13ef32b3a"
2181+dependencies = [
2182+ "cc",
2183+ "tree-sitter-language",
2184+]
2185+
2186+[[package]]
2187+name = "tree-sitter-irules"
2188+version = "0.2.0"
2189+source = "registry+https://github.com/rust-lang/crates.io-index"
2190+checksum = "909291f3c6c83dc183990d9daf16cc7bf07401cae30fa786dff107ab4c882646"
2191+dependencies = [
2192+ "cc",
2193+ "tree-sitter-language",
2194+]
2195+
2196+[[package]]
2197+name = "tree-sitter-java"
2198+version = "0.23.5"
2199+source = "registry+https://github.com/rust-lang/crates.io-index"
2200+checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6"
2201+dependencies = [
2202+ "cc",
2203+ "tree-sitter-language",
2204+]
2205+
2206+[[package]]
2207+name = "tree-sitter-javadoc"
2208+version = "0.3.1"
2209+source = "registry+https://github.com/rust-lang/crates.io-index"
2210+checksum = "f383b40994c833d8fb85ca2e0b04ed20b6551846cdfbc39a184ef1fd0880b403"
2211+dependencies = [
2212+ "cc",
2213+ "tree-sitter-language",
2214+]
2215+
2216+[[package]]
2217+name = "tree-sitter-javascript"
2218+version = "0.25.0"
2219+source = "registry+https://github.com/rust-lang/crates.io-index"
2220+checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5"
2221+dependencies = [
2222+ "cc",
2223+ "tree-sitter-language",
2224+]
2225+
2226+[[package]]
2227+name = "tree-sitter-jinja2"
2228+version = "0.0.16"
2229+source = "registry+https://github.com/rust-lang/crates.io-index"
2230+checksum = "124f888283869ed15d544d840b908551d9a5d58c326395236df2492873ee1f86"
2231+dependencies = [
2232+ "cc",
2233+ "tree-sitter-language",
2234+]
2235+
2236+[[package]]
2237+name = "tree-sitter-jsdoc"
2238+version = "0.25.0"
2239+source = "registry+https://github.com/rust-lang/crates.io-index"
2240+checksum = "7a70da85d41e05dae5d323a4b76fa0955d16fa0394415cea2034de046a21ea6a"
2241+dependencies = [
2242+ "cc",
2243+ "tree-sitter-language",
2244+]
2245+
2246+[[package]]
2247+name = "tree-sitter-json"
2248+version = "0.24.8"
2249+source = "registry+https://github.com/rust-lang/crates.io-index"
2250+checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471"
2251+dependencies = [
2252+ "cc",
2253+ "tree-sitter-language",
2254+]
2255+
2256+[[package]]
2257+name = "tree-sitter-jsonnet"
2258+version = "0.0.1"
2259+source = "registry+https://github.com/rust-lang/crates.io-index"
2260+checksum = "38ce8f9839652bd0effc7c384d42d1a1da3ba0c0313b85bb9a723b80cd91ca6c"
2261+dependencies = [
2262+ "cc",
2263+ "tree-sitter-language",
2264+]
2265+
2266+[[package]]
2267+name = "tree-sitter-julia"
2268+version = "0.23.1"
2269+source = "registry+https://github.com/rust-lang/crates.io-index"
2270+checksum = "4144731a178812ee867619b1e98b3b91e54c1652304b26e5ebe3175b701de323"
2271+dependencies = [
2272+ "cc",
2273+ "tree-sitter-language",
2274+]
2275+
2276+[[package]]
2277+name = "tree-sitter-kconfig"
2278+version = "1.3.0"
2279+source = "registry+https://github.com/rust-lang/crates.io-index"
2280+checksum = "ed450ee48fc3fe7690fd62d43d1cffc60158e3769fe0e2e6552fe0d1635af768"
2281+dependencies = [
2282+ "cc",
2283+ "tree-sitter-language",
2284+]
2285+
2286+[[package]]
2287+name = "tree-sitter-kotlin-sg"
2288+version = "0.4.1"
2289+source = "registry+https://github.com/rust-lang/crates.io-index"
2290+checksum = "c06ec43ae3c12165d4ac08afe4e1f5fc6757ffe274fa7bd5af9007ef11ba4319"
2291+dependencies = [
2292+ "cc",
2293+ "tree-sitter-language",
2294+]
2295+
2296+[[package]]
2297+name = "tree-sitter-lambdamoo"
2298+version = "0.2.5"
2299+source = "registry+https://github.com/rust-lang/crates.io-index"
2300+checksum = "7ac00974aa6fb4c27041fe4fe1500a150121b7cef6ff458c085957d9f6b789a0"
2301+dependencies = [
2302+ "cc",
2303+ "tree-sitter-language",
2304+]
2305+
2306+[[package]]
2307+name = "tree-sitter-language"
2308+version = "0.1.8"
2309+source = "registry+https://github.com/rust-lang/crates.io-index"
2310+checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081"
2311+
2312+[[package]]
2313+name = "tree-sitter-llvm"
2314+version = "1.1.0"
2315+source = "registry+https://github.com/rust-lang/crates.io-index"
2316+checksum = "6fb805528a695786436a5eca32ce0ce252e8488c6e2074b7cf2e14d7ef55a42d"
2317+dependencies = [
2318+ "cc",
2319+ "tree-sitter-language",
2320+]
2321+
2322+[[package]]
2323+name = "tree-sitter-lua"
2324+version = "0.5.0"
2325+source = "registry+https://github.com/rust-lang/crates.io-index"
2326+checksum = "8daaf5f4235188a58603c39760d5fa5d4b920d36a299c934adddae757f32a10c"
2327+dependencies = [
2328+ "cc",
2329+ "tree-sitter-language",
2330+]
2331+
2332+[[package]]
2333+name = "tree-sitter-luau"
2334+version = "1.2.0"
2335+source = "registry+https://github.com/rust-lang/crates.io-index"
2336+checksum = "871abeb427d719788bb38094facd7ff082908ef2b93c1d3d6a5f6025330120f4"
2337+dependencies = [
2338+ "cc",
2339+ "tree-sitter-language",
2340+]
2341+
2342+[[package]]
2343+name = "tree-sitter-make"
2344+version = "1.1.1"
2345+source = "registry+https://github.com/rust-lang/crates.io-index"
2346+checksum = "c5998dc7cbcbdab19fae8aefef982bf2d6544513d8d2e69cc44aec4c63810104"
2347+dependencies = [
2348+ "cc",
2349+ "tree-sitter-language",
2350+]
2351+
2352+[[package]]
2353+name = "tree-sitter-matlab"
2354+version = "1.3.1"
2355+source = "registry+https://github.com/rust-lang/crates.io-index"
2356+checksum = "97962a0daf32492453fbea1984d34c4d8460b66b95c6c04feecc4cbf1b00a984"
2357+dependencies = [
2358+ "cc",
2359+ "tree-sitter-language",
2360+]
2361+
2362+[[package]]
2363+name = "tree-sitter-merlin6502"
2364+version = "4.0.0"
2365+source = "registry+https://github.com/rust-lang/crates.io-index"
2366+checksum = "6dc294ec60805a418014c269cbd744a37938b95c8e34a311e285e64b54bf6f2a"
2367+dependencies = [
2368+ "cc",
2369+ "tree-sitter-language",
2370+]
2371+
2372+[[package]]
2373+name = "tree-sitter-mlir"
2374+version = "0.2.0"
2375+source = "registry+https://github.com/rust-lang/crates.io-index"
2376+checksum = "320b666c557a183fc0d2fba89181aefccffa936a6f50249887d3bb5ec5cb3c32"
2377+dependencies = [
2378+ "cc",
2379+ "tree-sitter-language",
2380+]
2381+
2382+[[package]]
2383+name = "tree-sitter-msbasic2"
2384+version = "0.2.0"
2385+source = "registry+https://github.com/rust-lang/crates.io-index"
2386+checksum = "a555f9f0ccd1a94b602d11477b77d5db2957056b090767b70780435068afa7e2"
2387+dependencies = [
2388+ "cc",
2389+ "tree-sitter-language",
2390+]
2391+
2392+[[package]]
2393+name = "tree-sitter-newick"
2394+version = "1.1.0"
2395+source = "registry+https://github.com/rust-lang/crates.io-index"
2396+checksum = "08c5785bcf2ade84e16242b1a8d1f574334ddd26032c15a5d9bcc012e013f4e1"
2397+dependencies = [
2398+ "cc",
2399+ "tree-sitter-language",
2400+]
2401+
2402+[[package]]
2403+name = "tree-sitter-nginx"
2404+version = "1.0.1"
2405+source = "registry+https://github.com/rust-lang/crates.io-index"
2406+checksum = "ad358057b6fa3bd3acaab22ab1c866d4746a2417d9f70b7e91cc7cc8732a5716"
2407+dependencies = [
2408+ "cc",
2409+ "tree-sitter-language",
2410+]
2411+
2412+[[package]]
2413+name = "tree-sitter-nix"
2414+version = "0.3.0"
2415+source = "registry+https://github.com/rust-lang/crates.io-index"
2416+checksum = "4952a9733f3a98f6683a0ccd1035d84ab7a52f7e84eeed58548d86765ad92de3"
2417+dependencies = [
2418+ "cc",
2419+ "tree-sitter-language",
2420+]
2421+
2422+[[package]]
2423+name = "tree-sitter-objc"
2424+version = "3.0.2"
2425+source = "registry+https://github.com/rust-lang/crates.io-index"
2426+checksum = "9ca8bb556423fc176f0535e79d525f783a6684d3c9da81bf9d905303c129e1d2"
2427+dependencies = [
2428+ "cc",
2429+ "tree-sitter-language",
2430+]
2431+
2432+[[package]]
2433+name = "tree-sitter-ocaml"
2434+version = "0.26.0"
2435+source = "registry+https://github.com/rust-lang/crates.io-index"
2436+checksum = "43adb10fcccbcbbce0f4adc9dab7cd55f0a5eafc2d71bd4c03fcc5d6767066e0"
2437+dependencies = [
2438+ "cc",
2439+ "tree-sitter-language",
2440+]
2441+
2442+[[package]]
2443+name = "tree-sitter-ocamllex"
2444+version = "0.24.0"
2445+source = "registry+https://github.com/rust-lang/crates.io-index"
2446+checksum = "2b70dec1fd237eafb123304653c3cf9fb99b4c507a463ea468c64da7b6a57c9c"
2447+dependencies = [
2448+ "cc",
2449+ "tree-sitter-language",
2450+]
2451+
2452+[[package]]
2453+name = "tree-sitter-odin"
2454+version = "1.3.0"
2455+source = "registry+https://github.com/rust-lang/crates.io-index"
2456+checksum = "24db210fe9ba2237c71c5030d7b146c7025420ba72dd8013d13cd822c3a8d77a"
2457+dependencies = [
2458+ "cc",
2459+ "tree-sitter-language",
2460+]
2461+
2462+[[package]]
2463+name = "tree-sitter-openscad-ng"
2464+version = "0.7.1"
2465+source = "registry+https://github.com/rust-lang/crates.io-index"
2466+checksum = "a9286371873e82cb888be0d9394dd3c6a6aaca66fa2fd31879baafb2e4008488"
2467+dependencies = [
2468+ "cc",
2469+ "tree-sitter-language",
2470+]
2471+
2472+[[package]]
2473+name = "tree-sitter-pascal"
2474+version = "0.10.2"
2475+source = "registry+https://github.com/rust-lang/crates.io-index"
2476+checksum = "adb51e9a57493fd237e4517566749f7f7453349261a72a427e5f11d3b34b72a8"
2477+dependencies = [
2478+ "cc",
2479+ "tree-sitter-language",
2480+]
2481+
2482+[[package]]
2483+name = "tree-sitter-pgn"
2484+version = "1.4.3"
2485+source = "registry+https://github.com/rust-lang/crates.io-index"
2486+checksum = "5558419ecc5a8a799306938be9ae8870987650ddd350d648dcaca58face09ab8"
2487+dependencies = [
2488+ "cc",
2489+ "tree-sitter-language",
2490+]
2491+
2492+[[package]]
2493+name = "tree-sitter-php"
2494+version = "0.24.2"
2495+source = "registry+https://github.com/rust-lang/crates.io-index"
2496+checksum = "0d8c17c3ab69052c5eeaa7ff5cd972dd1bc25d1b97ee779fec391ad3b5df5592"
2497+dependencies = [
2498+ "cc",
2499+ "tree-sitter-language",
2500+]
2501+
2502+[[package]]
2503+name = "tree-sitter-postgres"
2504+version = "1.2.4"
2505+source = "registry+https://github.com/rust-lang/crates.io-index"
2506+checksum = "2552ec55c9813f358da70926587b2bbfceff6a7b75456b2ddf8abfb223dcf3a7"
2507+dependencies = [
2508+ "cc",
2509+ "tree-sitter-language",
2510+]
2511+
2512+[[package]]
2513+name = "tree-sitter-powershell"
2514+version = "0.26.4"
2515+source = "registry+https://github.com/rust-lang/crates.io-index"
2516+checksum = "3faf304d44b9ddd4a7d97804bb8de7daf564336dd5a526dc6de5b39238243022"
2517+dependencies = [
2518+ "cc",
2519+ "tree-sitter-language",
2520+]
2521+
2522+[[package]]
2523+name = "tree-sitter-printf"
2524+version = "0.5.1"
2525+source = "registry+https://github.com/rust-lang/crates.io-index"
2526+checksum = "dffcc6f600c438d5df02ebc29d3a197023dd46554940b6ff155448bd3d140a3e"
2527+dependencies = [
2528+ "cc",
2529+ "tree-sitter-language",
2530+]
2531+
2532+[[package]]
2533+name = "tree-sitter-prisma-io"
2534+version = "1.6.0"
2535+source = "registry+https://github.com/rust-lang/crates.io-index"
2536+checksum = "c35912d65a6adb07f781db46d843bf0464935e267da6ed192705516adafc7c24"
2537+dependencies = [
2538+ "cc",
2539+ "tree-sitter-language",
2540+]
2541+
2542+[[package]]
2543+name = "tree-sitter-prolog"
2544+version = "0.1.0"
2545+source = "registry+https://github.com/rust-lang/crates.io-index"
2546+checksum = "1d475fc08c018acb005af5a1030fe9752c918c65d7d263ad5edafe8488157f02"
2547+dependencies = [
2548+ "cc",
2549+ "tree-sitter-language",
2550+]
2551+
2552+[[package]]
2553+name = "tree-sitter-properties"
2554+version = "0.3.0"
2555+source = "registry+https://github.com/rust-lang/crates.io-index"
2556+checksum = "0a6c2f38dea68abd782573bb6ec3dbd14175cce2953f693055743925d785ceeb"
2557+dependencies = [
2558+ "cc",
2559+ "tree-sitter-language",
2560+]
2561+
2562+[[package]]
2563+name = "tree-sitter-proto"
2564+version = "0.6.0"
2565+source = "registry+https://github.com/rust-lang/crates.io-index"
2566+checksum = "9c93b6f1ed20de442e900eb636f2176af6063953dfaa9f76bf168dd3b490a3a1"
2567+dependencies = [
2568+ "cc",
2569+ "tree-sitter-language",
2570+]
2571+
2572+[[package]]
2573+name = "tree-sitter-puppet"
2574+version = "1.3.0"
2575+source = "registry+https://github.com/rust-lang/crates.io-index"
2576+checksum = "f258d076394303cf7b3ddba2204ab9b0c06ff091bb5b58b1d5b68b64eeb5f126"
2577+dependencies = [
2578+ "cc",
2579+ "tree-sitter-language",
2580+]
2581+
2582+[[package]]
2583+name = "tree-sitter-python"
2584+version = "0.25.0"
2585+source = "registry+https://github.com/rust-lang/crates.io-index"
2586+checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c"
2587+dependencies = [
2588+ "cc",
2589+ "tree-sitter-language",
2590+]
2591+
2592+[[package]]
2593+name = "tree-sitter-qbasic"
2594+version = "0.2.0"
2595+source = "registry+https://github.com/rust-lang/crates.io-index"
2596+checksum = "32fcea65fab003a3fe41980be136590da1c3a51eeabde3973e972890e2292e78"
2597+dependencies = [
2598+ "cc",
2599+ "tree-sitter-language",
2600+]
2601+
2602+[[package]]
2603+name = "tree-sitter-ql"
2604+version = "0.23.1"
2605+source = "registry+https://github.com/rust-lang/crates.io-index"
2606+checksum = "80b7bcaf39acefbb199417a6ec2fd0c038083ba115da3e4f4426c820dc76d386"
2607+dependencies = [
2608+ "cc",
2609+ "tree-sitter-language",
2610+]
2611+
2612+[[package]]
2613+name = "tree-sitter-qmljs"
2614+version = "0.3.1"
2615+source = "registry+https://github.com/rust-lang/crates.io-index"
2616+checksum = "e6ed3a7040df54fed1183801ad482139622bf9b9ec1c5f7ee36c5ece25806c58"
2617+dependencies = [
2618+ "cc",
2619+ "tree-sitter-language",
2620+]
2621+
2622+[[package]]
2623+name = "tree-sitter-qsharp"
2624+version = "0.2.0"
2625+source = "registry+https://github.com/rust-lang/crates.io-index"
2626+checksum = "05f8bbbaec6c9de62ca6becbc88dd5f038a074b0d675b8b3022f5ce36a106aed"
2627+dependencies = [
2628+ "cc",
2629+ "tree-sitter-language",
2630+]
2631+
2632+[[package]]
2633+name = "tree-sitter-r"
2634+version = "1.3.0"
2635+source = "registry+https://github.com/rust-lang/crates.io-index"
2636+checksum = "ffc9954ec870dcad6cffdd302b405306c68cf031ed79a78cd9746f6740d9fe20"
2637+dependencies = [
2638+ "cc",
2639+ "tree-sitter-language",
2640+]
2641+
2642+[[package]]
2643+name = "tree-sitter-racket"
2644+version = "0.25.0"
2645+source = "registry+https://github.com/rust-lang/crates.io-index"
2646+checksum = "066bd54f4513d35a21b44f733da74bb065bb6734e565db96b516a79dd7c29e5f"
2647+dependencies = [
2648+ "cc",
2649+ "tree-sitter-language",
2650+]
2651+
2652+[[package]]
2653+name = "tree-sitter-regex"
2654+version = "0.25.0"
2655+source = "registry+https://github.com/rust-lang/crates.io-index"
2656+checksum = "bd8a59be9f0ac131fd8f062eaaba14882b2fa5a6a7882a20134cb1d60df2e625"
2657+dependencies = [
2658+ "cc",
2659+ "tree-sitter-language",
2660+]
2661+
2662+[[package]]
2663+name = "tree-sitter-requirements"
2664+version = "0.6.1"
2665+source = "registry+https://github.com/rust-lang/crates.io-index"
2666+checksum = "ed29064ba695cb72a992d9f3fd9678a1000fd619954f0bc62afd937c61d9bbba"
2667+dependencies = [
2668+ "cc",
2669+ "tree-sitter-language",
2670+]
2671+
2672+[[package]]
2673+name = "tree-sitter-riscv"
2674+version = "0.3.1"
2675+source = "registry+https://github.com/rust-lang/crates.io-index"
2676+checksum = "2d2d6a0e21aa0d6ac067fc14ce1d455de1df53da572faf89e920520811f63f82"
2677+dependencies = [
2678+ "cc",
2679+ "tree-sitter-language",
2680+]
2681+
2682+[[package]]
2683+name = "tree-sitter-ruby"
2684+version = "0.23.1"
2685+source = "registry+https://github.com/rust-lang/crates.io-index"
2686+checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95"
2687+dependencies = [
2688+ "cc",
2689+ "tree-sitter-language",
2690+]
2691+
2692+[[package]]
2693+name = "tree-sitter-rush"
2694+version = "0.2.0"
2695+source = "registry+https://github.com/rust-lang/crates.io-index"
2696+checksum = "5ba64f2e7449ed421cfb95388ca542bf6ce143ee59a9f3f287fc16d0a5abe7a0"
2697+dependencies = [
2698+ "cc",
2699+ "tree-sitter-language",
2700+]
2701+
2702+[[package]]
2703+name = "tree-sitter-rust"
2704+version = "0.24.2"
2705+source = "registry+https://github.com/rust-lang/crates.io-index"
2706+checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45"
2707+dependencies = [
2708+ "cc",
2709+ "tree-sitter-language",
2710+]
2711+
2712+[[package]]
2713+name = "tree-sitter-sas"
2714+version = "0.4.2"
2715+source = "registry+https://github.com/rust-lang/crates.io-index"
2716+checksum = "cb4d6233b3cf8a9d495ecc6ed8fec7e6a29ec15a0c5808ef046504c28e52334c"
2717+dependencies = [
2718+ "cc",
2719+ "tree-sitter-language",
2720+]
12952721
12962722[[package]]
1297−name = "smallvec"
1298−version = "1.16.1"
2723+name = "tree-sitter-scala"
2724+version = "0.26.2"
12992725source = "registry+https://github.com/rust-lang/crates.io-index"
1300−checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
2726+checksum = "24e0ab4505990bfe30051761d40a7bf4033ce5a81c9eda9e20e987a5cdc84826"
2727+dependencies = [
2728+ "cc",
2729+ "tree-sitter-language",
2730+]
13012731
13022732[[package]]
1303−name = "static_assertions"
1304−version = "1.1.0"
2733+name = "tree-sitter-scheme"
2734+version = "0.24.7"
13052735source = "registry+https://github.com/rust-lang/crates.io-index"
1306−checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
2736+checksum = "8a7e7f156bdf38145f26705d1733185698845307d3e9d9c071ecce4375575131"
2737+dependencies = [
2738+ "cc",
2739+ "tree-sitter-language",
2740+]
13072741
13082742[[package]]
1309−name = "strsim"
1310−version = "0.11.1"
2743+name = "tree-sitter-sdml"
2744+version = "0.4.14"
13112745source = "registry+https://github.com/rust-lang/crates.io-index"
1312−checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
2746+checksum = "556ffac44de200072680c34a7a6b5702361c587e2dbcdb0a5f1296066d09dc19"
2747+dependencies = [
2748+ "cc",
2749+ "tree-sitter-language",
2750+]
13132751
13142752[[package]]
1315−name = "strum"
1316−version = "0.26.3"
2753+name = "tree-sitter-sequel"
2754+version = "0.3.11"
13172755source = "registry+https://github.com/rust-lang/crates.io-index"
1318−checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
2756+checksum = "9d198ad3c319c02e43c21efa1ec796b837afcb96ffaef1a40c1978fbdcec7d17"
13192757dependencies = [
1320− "strum_macros",
2758+ "cc",
2759+ "tree-sitter-language",
13212760]
13222761
13232762[[package]]
1324−name = "strum_macros"
1325−version = "0.26.4"
2763+name = "tree-sitter-sequel-tsql"
2764+version = "0.4.2"
13262765source = "registry+https://github.com/rust-lang/crates.io-index"
1327−checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
2766+checksum = "08d02793ddf858f2fc6376c7c12d50a4a29797c834b8ddbafe752290d9cbdb59"
13282767dependencies = [
1329− "heck",
1330− "proc-macro2",
1331− "quote",
1332− "rustversion",
1333− "syn 2.0.119",
2768+ "cc",
2769+ "tree-sitter-language",
13342770]
13352771
13362772[[package]]
1337−name = "syn"
1338−version = "2.0.119"
2773+name = "tree-sitter-sfapex"
2774+version = "3.0.1"
13392775source = "registry+https://github.com/rust-lang/crates.io-index"
1340−checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
2776+checksum = "e902b5d7951d1477ff43ad5ec8b2e6adae029cce593a770c64e10362ee7dec7b"
13412777dependencies = [
1342− "proc-macro2",
1343− "quote",
1344− "unicode-ident",
2778+ "cc",
2779+ "tree-sitter-language",
13452780]
13462781
13472782[[package]]
1348−name = "syn"
1349−version = "3.0.5"
2783+name = "tree-sitter-starlark"
2784+version = "1.3.0"
13502785source = "registry+https://github.com/rust-lang/crates.io-index"
1351−checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
2786+checksum = "8934f282d085cc4b9ee28aa688aa3fbe8aa3766201c2a6252f411d45b4c3a721"
13522787dependencies = [
1353− "proc-macro2",
1354− "quote",
1355− "unicode-ident",
2788+ "cc",
2789+ "tree-sitter-language",
13562790]
13572791
13582792[[package]]
1359−name = "tempfile"
1360−version = "3.27.0"
2793+name = "tree-sitter-svelte-ng"
2794+version = "1.0.2"
13612795source = "registry+https://github.com/rust-lang/crates.io-index"
1362−checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
2796+checksum = "ef0a71f9cf5e94373cc86c64893630c8a29bb25d3390a248268d08af2165fa37"
13632797dependencies = [
1364− "fastrand",
1365− "getrandom 0.4.3",
1366− "once_cell",
1367− "rustix 1.1.4",
1368− "windows-sys 0.61.2",
2798+ "cc",
2799+ "tree-sitter-language",
13692800]
13702801
13712802[[package]]
1372−name = "termtree"
1373−version = "0.5.1"
2803+name = "tree-sitter-svg"
2804+version = "0.3.0"
13742805source = "registry+https://github.com/rust-lang/crates.io-index"
1375−checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
2806+checksum = "1289ec0f2cfad205d328c8f135c903ab53c657a7486acc7832833532b2e8dadb"
2807+dependencies = [
2808+ "cc",
2809+ "tree-sitter-language",
2810+]
13762811
13772812[[package]]
1378−name = "thiserror"
1379−version = "2.0.20"
2813+name = "tree-sitter-swift"
2814+version = "0.7.3"
13802815source = "registry+https://github.com/rust-lang/crates.io-index"
1381−checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
2816+checksum = "fe36052155b9dd69ca82b3b8f1b4ccfb2d867125ac1a4db1dd7331829242668c"
13822817dependencies = [
1383− "thiserror-impl",
2818+ "cc",
2819+ "tree-sitter-language",
13842820]
13852821
13862822[[package]]
1387−name = "thiserror-impl"
1388−version = "2.0.20"
2823+name = "tree-sitter-sysml"
2824+version = "0.1.0"
13892825source = "registry+https://github.com/rust-lang/crates.io-index"
1390−checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
2826+checksum = "72ea5ec06e683683af8b53d3cff9bc42c1d997b75b43e46968bd2504be002f15"
13912827dependencies = [
1392− "proc-macro2",
1393− "quote",
1394− "syn 3.0.5",
2828+ "cc",
2829+ "tree-sitter-language",
13952830]
13962831
13972832[[package]]
1398−name = "tinyvec"
1399−version = "1.13.2"
2833+name = "tree-sitter-systemverilog"
2834+version = "0.4.1"
14002835source = "registry+https://github.com/rust-lang/crates.io-index"
1401−checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b"
2836+checksum = "7d9f02d7fd20691acb5edf44a782f0b9fa278d9987befb1ae597c768707b95e6"
14022837dependencies = [
1403− "tinyvec_macros",
2838+ "cc",
2839+ "tree-sitter-language",
14042840]
14052841
14062842[[package]]
1407−name = "tinyvec_macros"
1408−version = "0.1.1"
2843+name = "tree-sitter-templ"
2844+version = "2.2.0"
14092845source = "registry+https://github.com/rust-lang/crates.io-index"
1410−checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
2846+checksum = "86206f76eb2af238a5a7cc53a3d2c750a57a7e7311dc7f86da121264606d8619"
2847+dependencies = [
2848+ "cc",
2849+ "tree-sitter-language",
2850+]
14112851
14122852[[package]]
1413−name = "tui-textarea"
1414−version = "0.7.0"
2853+name = "tree-sitter-tlaplus"
2854+version = "1.5.0"
14152855source = "registry+https://github.com/rust-lang/crates.io-index"
1416−checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae"
2856+checksum = "ddaacc0337bfee38f233a91a704dc36b887a0c385348163f65344d64b73f79e1"
14172857dependencies = [
1418− "crossterm",
1419− "ratatui",
1420− "unicode-width 0.2.0",
2858+ "cc",
2859+ "tree-sitter-language",
14212860]
14222861
14232862[[package]]
1424−name = "unarray"
2863+name = "tree-sitter-tmux"
14252864version = "0.1.4"
14262865source = "registry+https://github.com/rust-lang/crates.io-index"
1427−checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
2866+checksum = "0736784697bba41dec00519a8808bba8665f7407ace89f62e7f689856af8e790"
2867+dependencies = [
2868+ "cc",
2869+ "tree-sitter-language",
2870+]
2871+
2872+[[package]]
2873+name = "tree-sitter-toml-ng"
2874+version = "0.7.0"
2875+source = "registry+https://github.com/rust-lang/crates.io-index"
2876+checksum = "e9adc2c898ae49730e857d75be403da3f92bb81d8e37a2f918a08dd10de5ebb1"
2877+dependencies = [
2878+ "cc",
2879+ "tree-sitter-language",
2880+]
2881+
2882+[[package]]
2883+name = "tree-sitter-tsquery"
2884+version = "0.8.0"
2885+source = "registry+https://github.com/rust-lang/crates.io-index"
2886+checksum = "c1b31e95ea85379972abf3b6857a59b2f6fb7ef3b5e47e9a4fb6c07111dee5e6"
2887+dependencies = [
2888+ "cc",
2889+ "tree-sitter-language",
2890+]
2891+
2892+[[package]]
2893+name = "tree-sitter-typescript"
2894+version = "0.23.2"
2895+source = "registry+https://github.com/rust-lang/crates.io-index"
2896+checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff"
2897+dependencies = [
2898+ "cc",
2899+ "tree-sitter-language",
2900+]
2901+
2902+[[package]]
2903+name = "tree-sitter-varlink"
2904+version = "0.6.1"
2905+source = "registry+https://github.com/rust-lang/crates.io-index"
2906+checksum = "f341405b5e4e2ac614086fd1e632e9dab8973f63695eab763a2fef8316224318"
2907+dependencies = [
2908+ "cc",
2909+ "tree-sitter-language",
2910+]
2911+
2912+[[package]]
2913+name = "tree-sitter-vhdl"
2914+version = "1.4.0"
2915+source = "registry+https://github.com/rust-lang/crates.io-index"
2916+checksum = "0feb025ddf87c45bb98335ef2a2493cac08ec6b74d33201d1bf75df2be08de23"
2917+dependencies = [
2918+ "cc",
2919+ "tree-sitter-language",
2920+]
2921+
2922+[[package]]
2923+name = "tree-sitter-vue-next"
2924+version = "0.1.0"
2925+source = "registry+https://github.com/rust-lang/crates.io-index"
2926+checksum = "e9ac89acaa8165aaabfa8ae9683aa423b0d2bf42c815dae5ca031a36a75525f1"
2927+dependencies = [
2928+ "cc",
2929+ "tree-sitter-language",
2930+]
2931+
2932+[[package]]
2933+name = "tree-sitter-xml"
2934+version = "0.7.0"
2935+source = "registry+https://github.com/rust-lang/crates.io-index"
2936+checksum = "e670041f591d994f54d597ddcd8f4ebc930e282c4c76a42268743b71f0c8b6b3"
2937+dependencies = [
2938+ "cc",
2939+ "tree-sitter-language",
2940+]
2941+
2942+[[package]]
2943+name = "tree-sitter-yaml"
2944+version = "0.7.2"
2945+source = "registry+https://github.com/rust-lang/crates.io-index"
2946+checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97"
2947+dependencies = [
2948+ "cc",
2949+ "tree-sitter-language",
2950+]
2951+
2952+[[package]]
2953+name = "tree-sitter-zig"
2954+version = "1.1.2"
2955+source = "registry+https://github.com/rust-lang/crates.io-index"
2956+checksum = "ab11fc124851b0db4dd5e55983bbd9631192e93238389dcd44521715e5d53e28"
2957+dependencies = [
2958+ "cc",
2959+ "tree-sitter-language",
2960+]
2961+
2962+[[package]]
2963+name = "tree-sitter-zsh"
2964+version = "0.63.4"
2965+source = "registry+https://github.com/rust-lang/crates.io-index"
2966+checksum = "907f7ea76070507d7a6b054b38f1475caa7db085437911352ca0f361f08f142e"
2967+dependencies = [
2968+ "cc",
2969+ "tree-sitter-language",
2970+]
14282971
14292972[[package]]
14302973name = "unicode-ident"
@@ -1470,6 +3013,127 @@ version = "0.2.0"
14703013source = "registry+https://github.com/rust-lang/crates.io-index"
14713014checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
14723015
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+
14733137[[package]]
14743138name = "utf8parse"
14753139version = "0.2.2"
@@ -1510,6 +3174,15 @@ dependencies = [
15103174 "wit-bindgen",
15113175]
15123176
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+
15133186[[package]]
15143187name = "winapi"
15153188version = "0.3.9"
@@ -1704,27 +3377,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
17043377checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
17053378
17063379[[package]]
1707−name = "wit-bindgen"
1708−version = "0.57.1"
3380+name = "winnow"
3381+version = "1.0.4"
17093382source = "registry+https://github.com/rust-lang/crates.io-index"
1710−checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
3383+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
3384+dependencies = [
3385+ "memchr",
3386+]
17113387
17123388[[package]]
1713−name = "zerocopy"
1714−version = "0.8.57"
3389+name = "wit-bindgen"
3390+version = "0.57.1"
17153391source = "registry+https://github.com/rust-lang/crates.io-index"
1716−checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
1717−dependencies = [
1718− "zerocopy-derive",
1719−]
3392+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
17203393
17213394[[package]]
1722−name = "zerocopy-derive"
1723−version = "0.8.57"
3395+name = "zmij"
3396+version = "1.0.23"
17243397source = "registry+https://github.com/rust-lang/crates.io-index"
1725−checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
1726−dependencies = [
1727− "proc-macro2",
1728− "quote",
1729− "syn 2.0.119",
1730−]
3398+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
Cargo.toml+1−3
@@ -1,16 +1,14 @@
11[workspace]
22resolver = "2"
3−members = ["crates/fold-core", "crates/fold-tui", "crates/fold-cli"]
3+members = ["crates/fold-core", "crates/fold-tui", "crates/fold-cli", "crates/fold-ffi"]
44
55[workspace.dependencies]
66ratatui = "0.29"
77crossterm = "0.28"
8−tui-textarea = "0.7"
98notify = "8"
109jiff = "0.2"
1110clap = { version = "4", features = ["derive"] }
1211indexmap = "2"
13−nucleo = "0.5"
1412similar = "2"
1513blake3 = "1"
1614unicode-normalization = "0.1"
README.mdnew file+149
@@ -0,0 +1,149 @@
1+<p align="center"><img src="docs/icon.png" width="128" height="128" alt="fold"></p>
2+
3+# fold
4+
5+One outline of notes and tasks, stored as plain Markdown, in the terminal.
6+
7+Everything you write — projects, notes, tasks, an inbox — is one tree. Headings and
8+bullets are its nodes; you read and edit any subtree on its own, move subtrees around,
9+and check tasks off. The files stay ordinary Markdown that `rg`, your editor and your
10+phone can read, and they sync with Syncthing.
11+
12+```
13+ fold › Homelab › NAS ⌕ Filter + Capture ↶ Undo ↷ Redo ☰ Commands ? Help
14+╭ Outline ───────────────────────╮╭ ZFS layout ▤ ─────────────────────────── ✎ Edit ─ ⋯ ─╮
15+│▾ Homelab 2/4 ││ # ZFS layout │
16+│ ▾ NAS 1/2 ││ ⚑ since 2024-03 │
17+│ ▾ ZFS layout ▤ 1/2 ⋯││ │
18+│ ▾ ☐ Snapshot policy ││ Mirrored pairs, no raidz. Snapshots hourly. │
19+│ ▸ Networking 1/2 ││ ## ☐ Snapshot policy │
20+│▾ Inbox ││ - hourly, keep 24 │
21+╰────────────────────────────────╯╰───────────────────────────────────────────────────────╯
22+```
23+
24+## Run it
25+
26+With Nix:
27+
28+```sh
29+nix run github:iko-soy/fold # the TUI on your vault
30+nix run github:iko-soy/fold -- --keys vim
31+```
32+
33+With Cargo (a recent stable Rust, and a C compiler for the syntax-highlighting grammars):
34+
35+```sh
36+cargo install --path crates/fold-cli
37+fold
38+```
39+
40+The vault is `--vault PATH`, else `$FOLD_VAULT`, else the nearest directory above the
41+current one that contains `root.md`, else `~/fold`. Pointed at an empty directory, the TUI
42+starts a new vault with an `# Inbox`.
43+
44+## Using it
45+
46+The TUI is mouse-first, and every action also has a key.
47+
48+- **Click** a row to select it. Each row shows the first line of its node's text; click
49+ `◨` in the top bar (or press `zp`) for the reading pane, which shows the whole selected
50+ node beside the outline. Click `▸`/`▾` to
51+ fold, `☐` to check a task off, a breadcrumb in the top bar to zoom out.
52+- **Double-click** a row to zoom into it. Double-click a line of text to edit it there.
53+- **Right-click** a row (or click its `⋯`) for everything you can do to a node: edit, new
54+ sibling or child, done, properties, move, indent, make a block, copy, paste, delete.
55+- **Drag** a row onto another row's title to nest it there, or to the left of a title to
56+ put it just before.
57+- **Filter** (`/`) finds nodes by title and text; **Commands** (`:`) lists every action by
58+ name; **Capture** (`c`) drops a note into today's inbox.
59+
60+Keys, in the outline: `j`/`k` move, `h`/`l` fold, `Enter` zooms, `e` edits, `x` checks a
61+task, `n`/`N` add a sibling or child, `J`/`K` move a node, `>`/`<` indent, `r` moves a
62+subtree elsewhere, `u`/`U` undo and redo, `?` (or `F1`) shows everything. The status bar
63+says what a key did, then which keys come next where you are. Everything is saved as you
64+go; `q` quits. The view — the pane, folds, zoom, wrapping, the editor's keys — is remembered
65+per vault for next time.
66+
67+### The editor
68+
69+`e` edits the selected subtree's Markdown in the reading pane. It saves on its own — when
70+you pause, when you leave a part of the tree that lives in another file, and when you're
71+done, or when fold is closed some other way (the window closed, the ssh session dropped,
72+a crash). Text it cannot save because the file changed under it goes to the trash, and
73+fold says where; `fold trash restore` prints it and leaves it there. Pick a keymap with
74+`--keys` or `$FOLD_KEYS`, or click `⌨` in the editor's border:
75+
76+- **normal** (default) — a conventional editor like micro: Shift+arrows select,
77+ `Ctrl-C`/`X`/`V`, `Ctrl-Z`/`Y`, `Ctrl-S`, `Ctrl-F`.
78+- **vim** — modes, counts, motions, operators, text objects, `.`, `/`, `:w`/`:q`.
79+- **helix** — selection first: `w`/`x` select, `d`/`c`/`y` act on the selection.
80+
81+Long lines wrap (`zw` toggles), fenced code is syntax-highlighted in about 140 languages,
82+and pasting from the terminal and copying to the system clipboard both work.
83+
84+## The files
85+
86+A vault is a directory with `root.md` and, optionally, one file per **block**:
87+
88+```markdown
89+# Homelab
90+
91+Two boxes in the closet, one at Hetzner.
92+
93+## NAS
94+
95+### ![[dozzod-binwes-talsun-worbec]]
96+
97+## Networking
98+
99+- [ ] Replace the flaky switch
100+![[racfer-hattes-mislup-nodrys]]
101+```
102+
103+- A heading or a bullet is a node; nesting is the tree. Headings can sit under bullets,
104+ and levels go past six.
105+- A checkbox after the marker makes any node a task: `- [ ]`, `## [x]`.
106+- A node that gets a property (a due date, say) becomes a **block**: its own file,
107+ `racfer~order-new-switch.md`, with YAML frontmatter and an `id`, stitched into its parent
108+ by `![[id]]`. That's how a single task gets a due date, and how the inbox becomes a small
109+ file you can append to from a phone.
110+
111+Nothing else is special. Deleting goes to a trash (`$XDG_STATE_HOME/fold/trash`), and
112+Syncthing conflict copies are merged node by node, with anything that differs kept as a
113+`conflict:` block to resolve in the app: it is marked `⚠` in the outline, and a click on the
114+`⚠` shows both sides.
115+
116+## The command line
117+
118+```sh
119+fold # the TUI
120+fold capture "call the plumber" # append to today's inbox (stdin if no text)
121+fold capture --task --to Homelab/NAS "check the scrub"
122+fold check [--fix] # diagnostics; --fix rewrites in canonical form
123+fold merge [--dry-run] # fold Syncthing conflict files in
124+fold trash list | fold trash restore ID
125+fold help
126+```
127+
128+## On Android
129+
130+[android/](android/README.md) is fold for the phone: the same vault, opened from the
131+folder Syncthing syncs it to, read and written by the same `fold-core`. The outline with
132+folds and checkboxes, zoom, the node menu, capture (also from the share sheet), the
133+subtree editor, properties with a date picker, find, move to, undo, and the conflict view
134+for sync-conflict copies. Build it with `./gradlew assembleDebug` in `android/`; see its
135+README for what it needs.
136+
137+## Developing
138+
139+```sh
140+nix develop # cargo, rustc, clippy, rustfmt, rust-analyzer
141+cargo test --workspace
142+nix flake check # the same tests, in the Nix sandbox
143+```
144+
145+The code is four crates: `fold-core` (parsing, rendering, the operations, merge),
146+`fold-tui` (the ratatui app), `fold-cli` (the `fold` binary) and `fold-ffi` (a session
147+over `fold-core` for other languages, through UniFFI, which the Android app calls).
148+[SPEC.md](SPEC.md) is the design: the data model, the file format, every operation and
149+the laws they keep.
SPEC.md+727−503
@@ -1,191 +1,15 @@
11# SPEC — a tree-shaped plain-text notes and task manager for the terminal
22
3−Status: draft 0.38 · 2026-09-12
4−Working name: not chosen yet. This document uses `notes` as the binary name; rename freely.
3+Status: draft 0.40 · 2026-09-26
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
7−Changes in 0.38: no external editor in 1.0. `E`, the inline-frontmatter spelling, the
8−`inline` render mode and `$EDITOR` are gone; the built-in editor is the only editor (§10.6,
9−§17). Frontmatter never appears as text anywhere.
10−
11−Changes in 0.37: `E` is scoped to the selected node and replaces its subtree wholesale on
12−exit. The text handed to `$EDITOR` carries every nested block's frontmatter **inline**, as a
13−`---` block under its title line, `id` included, so blocks come back matched exactly by id;
14−no line diff, no heuristics (§4.4, §10.6). `e` is unchanged.
15−
16−Changes in 0.36: editing is continuous. The built-in editor is one text over the resolved
17−subtree with no locked lines, no dimming and no commit key: every line carries its owning
18−block invisibly, edits apply to whichever block the cursor is in, and blocks are saved
19−automatically when the cursor leaves them, after a short pause, and on exit (§5.2, §10.6).
20−The *make block* / *cut* distinction is not visible while typing.
21−
22−Changes in 0.35: vocabulary. *Document*, *part* and *cut* were one concept and are now
23−**block**: a node with an id, a file and properties, edited alone. *Edge* is **embed**,
24−which is what `![[id]]` is. The *document pane* is the *reading pane*. Storage sections
25−still say "file" where they mean bytes on disk.
26−
27−Changes in 0.34: every edit changes exactly one file. The editor still shows the whole
28−resolved subtree, but only the lines owned by one **part** — the file the cursor node lives
29−in — are editable; the rest is context, shown but locked, and the status line names the
30−part. Splice is single-file again; the multi-file distributor is gone (§5.2, §10.6).
31−
32−Changes in 0.33: properties never appear in text. The resolved rendering the user reads and
33−edits has no frontmatter and no property blocks; properties are edited in a form (`a`,
34−§10.6) and shown as a dimmed header in the document pane. Splice never touches
35−frontmatter.
36−
37−Changes in 0.32: files are invisible when editing. The user edits a slice of one large
38−document: `e`/`E` open the **resolved** subtree — documents inlined, no `![[id]]`, no ids —
39−with each node's properties shown as a `---` block under its title line (§4.4). Splice
40−parses the result and distributes it back to whichever files own the text, cutting,
41−moving and trashing files as the text requires (§5.2). Files are storage, not UI.
42−
43−Changes in 0.31: one editing model. `e` opens the selected subtree's Markdown —
44−`render(cursor, 1, false)`, frontmatter included — in an editor and splices it back; `E`
45−does the same in `$EDITOR`. Title editing, the block editor and the property editor are
46−gone: a title, a property, a body are all just text in the subtree (§10.6).
47−
48−Changes in 0.30: the `:` command line is a command palette — a fuzzy picker over every
49−action, showing its key, prompting for arguments after selection (§10.9). No command syntax
50−to learn or type.
51−
52−Changes in 0.29: frontmatter is text. No value types, no `tags` key, no `--set`/`--due` on
53−capture, no relative date forms. The app knows five keys — `id`, `todo`, `due`, `done`,
54−`conflict` — reads them as strings, validates the two dates as ISO when it writes them, and
55−preserves every other line verbatim (§3.2, §4.4).
56−
57−Changes in 0.28: the query language, saved views, query blocks, the `v` picker, result
58−views, `notes query` and `--json` are out of 1.0 (§17). `query` fences are reserved. The
59−fresh vault is `# Inbox` alone. The filter box (§10.5) is the only search.
60−
61−Changes in 0.27: no config file. `config.toml` and its seven keys are gone; the vault comes
62−from `$NOTES_VAULT` or the working directory, the editor from `$EDITOR`, and the rest is
63−fixed or a session toggle (`zd`, `zr`). The trash is the only thing outside the vault (§14).
64−
65−Changes in 0.26: the CLI is six commands: `notes`, `capture`, `query`, `check [--fix]`,
66−`merge`, `trash`. `init`, `config`, `view`, `ls`, `set`, `unset`, `cut`, `archive`,
67−`canonicalize` and `adopt` are gone; the TUI does each of them, and `check --fix` absorbs
68−canonicalize (§13). Foreign `.md` files can no longer be adopted; paste their text instead.
69−
70−Changes in 0.25: more removals. The cancelled task state (`[-]`, `todo: cancelled`,
71−`cancelled:`, `X`) and the `priority` and `repeat` keys are gone. No tabs, no `:sort`, no
72−create-from-filter-box, no `notes inbox` / `props` / `id`, no `keys.*` rebinding or theme,
73−no `Ctrl-w`. The `Waiting` default view is gone and the `Inbox` view is now `Queue`.
74−`notes render`, `:export` and the `--plain` lowering are removed: there is no export in 1.0
75−(§17); `render` remains an internal function.
76−
77−Changes in 0.24: seven removals. One edge shape, `![[id]]`, and a document's file starts
78−with the node as spelled — no more `#`-always rule (§4.7, §4.9). No in-session three-way
79−merge: a splice that finds its span changed writes a conflict document like any other
80−conflict (§5.2). No fuzzy title matching in merge (§12.4). Files without a valid `id` are
81−ignored, not "unmanaged" (§4.1). Archive is plain refile under `Archive` (§6.5). No
82−`--as dir/`: the vault is flat and prefixes are unique vault-wide (§6.4). No `conflicts/`
83−directory and no `conflict_with` key: conflict copies are ordinary documents marked
84−`conflict:`, stitched in next to ours (§12.4).
85−
86−Changes in 0.23: no cache and no per-device state. The index is rebuilt from the vault on
87−every start; there are no shadow copies, so sync-conflict merging is two-way (§11.3, §12.3).
88−The in-session splice conflict keeps its three-way base in memory.
89−
90−Changes in 0.22: `uncut` removed. A cut is permanent; a document stays a file until deleted.
91−Conflict resolution copies text instead of uncutting (§12.5).
92−
93−Changes in 0.21: a document's task state is the frontmatter key `todo: open | done |
94−cancelled`, not a checkbox on its title line. Checkboxes remain the syntax for nodes that
95−are not files. Cut and uncut convert between the two (§4.5, §8).
96−
97−Changes in 0.20: the separator between id prefix and name in filenames is `~`:
98−`racfer~order-new-switch.md`. Words inside the prefix stay `-`-joined.
99−
100−Changes in 0.19: filename prefixes are unique on their own, ignoring the title: no two files
101−in a directory share a prefix, whatever their names (§6.4).
102−
103−Changes in 0.18: ids are 64 bits, four `@p` words (`racfer-hattes-dozzod-binwes`). Filenames
104−start with the shortest prefix of the id — one word by default — that is unique in the
105−directory, and grow a word at a time when a collision is found, locally or at merge (§6.4,
106−§12.2). The `-2` suffix is gone.
107−
108−Changes in 0.17: filenames are `<word>-<name>.md`, where `<word>` is the first word of the
109−id (`racfer-order-new-switch.md`). Same-title documents no longer collide across devices
110−except at 1 in 65,536; the merge engine treats an id mismatch as a name collision, not a
111−conflict (§6.4, §12.2).
112−
113−Changes in 0.16: `@key(value)` annotations removed. All metadata is frontmatter again, so
114−only documents carry it: `done:` and `cancelled:` are stamped on task documents, inline
115−tasks record state only, and nothing records creation time. Conflict copies are documents
116−in `conflicts/` again, with `conflict:` frontmatter. `@` appears only in the query language.
117−
118−Changes in 0.15: the "no syntax of our own" restriction is lifted (§1). Consequences:
119−one structural node kind with two spellings, either nestable under either (§3.1);
120−heading levels unbounded, `§5.3` level overflow gone; any node can be a task, headings
121−included (§4.5); a document's task state lives on its own title line, edges carry none
122−(§4.7, §4.9); the app stamps `@created`, `@done`, `@cancelled` as title-line annotations on
123−any node (§3.2); the id moves from the filename into `id:` frontmatter, filenames are plain
124−slugs, edges are `![[id]]` (§3.4, §6.4); `|` is allowed in titles; merge conflicts are
125−inline `@conflict` siblings, `conflicts/` is gone (§12.4). `notes render --plain` lowers
126−the format to standard Markdown for export (§5.1).
127−
128−Changes in 0.14: `[[wiki-links]]` between nodes removed from 1.0 — no link syntax, resolution,
129−rename rewriting, backlinks or tombstones. `[[…]]` in titles and bodies is opaque text,
130−preserved verbatim and reserved (§4.6, §17). Edges (`![[id-name]]`) and external
131−`[text](url)` links are unchanged.
132−
133−Changes in 0.13: capture appends under a `## <today>` child of `Inbox`, created on demand.
134−Fixed behaviour, not a setting; the day title is ISO `YYYY-MM-DD`.
135−
136−Changes in 0.12: every path component the app writes is lowercase — file names, directory
137−names (including `--as`), temp and trash names. Titles keep their case; resolution is
138−case-insensitive.
139−
140−Changes in 0.11: no vault settings at all. Capture always goes to the level-1 section titled
141−`Inbox`, created if missing. `root.md` has no frontmatter in a fresh vault.
142−
143−Changes in 0.10: `done_precision` and `conflicts` removed. `done:` records a date; conflict
144−documents live in `conflicts/`. `inbox` is the only vault setting.
145−
146−Changes in 0.9: `archive` and `:archive-done` removed — done items stay where they are,
147−a display toggle hides them, and `:clear-done` trashes them when a list is finished.
148−(An `archive` section as a plain refile target returns later — see §6.5.)
149−`suggest_bytes` removed — the app never suggests cuts. Three vault settings remain:
150−`inbox`, `done_precision`, `conflicts`.
151−
152−Changes in 0.8: `capture` renamed `inbox` and is a plain link target (no date template);
153−`children: files` removed, so there are no section settings and no derived directories;
154−`cut`, `rename`, `transliterate` and `id_words` removed — cuts are property-triggered or
155−manual, filenames follow titles, names keep Unicode, ids are two words. Five vault settings
156−remain: `inbox`, `archive`, `done_precision`, `conflicts`, `suggest_bytes`.
157−
158−Changes in 0.7: `dir` and `sort` removed. The only section setting is `children: files`;
159−its files go in a directory named after the section. New children are always appended.
160−
161−Changes in 0.6: `config.toml` is gone. Vault settings are `root.md`'s frontmatter; section
162−settings (`children`) are the section's own frontmatter; saved views are query
163−blocks. A fresh vault is initialised with a `root.md` that lists every setting at its default,
164−commented. Only an optional, device-local file for UI preferences remains.
165−
166−Changes in 0.5: the journal is no longer a concept. Capture appends to a configurable,
167−date-templated target (default `Inbox/{date}`), and "children of this section are always
168−files" is a general `[[cut.auto]]` rule. Journal days become ordinary documents; the id-less
169−filename shape and the date resolution step are gone.
170−
171−Changes in 0.4: ids are two-word phonemic names in Urbit's `@p` syllable scheme
172−(`racfer-hattes`, 32 bits) instead of 8-character base32.
173−
174−Changes in 0.3: document files are named `<id>-<name>.md`, where the id is an 8-character
175−random base32 string and the name is a slug of the title. Links and edges resolve by id, so
176−they survive renames and stale rewrites; the name is decoration for humans and other tools.
177−
178−Changes in 0.2: all inline attribute syntax (`@key(value)`) removed. Properties exist only as
179−YAML frontmatter of a file, so a node that needs properties becomes a file ("document").
180−Items can be cut into documents; their checkbox stays on the edge in the parent list.
181−`@id` removed; the file slug is the identity. Merge conflicts are stored as documents in
182−`conflicts/`. Task-state syntax `[ ]`, `[x]`, `[-]` is unchanged.
183−
1847---
1858
1869## 1. Purpose and principles
18710
188−A keyboard-driven TUI for a single body of notes stored as Markdown. There is one organizing
11+A mouse-first TUI — every action is a click, a drag or the wheel away, and every one also
12+has a key — for a single body of notes stored as Markdown. There is one organizing
18913primitive — the **subtree** — and one organizing verb — **refile** (move a subtree under a
19014different parent). Everything else (inbox, tasks, projects, index) is a view over the
19115same tree.
@@ -193,8 +17,8 @@ same tree.
19317Principles, in priority order:
19418
195191. **The files are the database.** Every fact the app knows is recoverable from the Markdown.
196− There are no caches, no configuration, and no per-device state beyond the trash. The
197− 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.
198222. **Markdown-shaped, with our own syntax where it pays.** Headings, bullets, checkboxes,
19923 fences and YAML frontmatter are used as they are. On top of them the format owns a small,
20024 closed set of things a standard renderer will not understand: `![[id]]` embeds, headings
@@ -221,20 +45,22 @@ Principles, in priority order:
22145|---|---|
22246| Vault | A directory containing `root.md` and, optionally, block files. |
22347| Tree | The single logical outline formed by `root.md` plus all block files stitched in. |
224−| Node | One thing in the outline: a title line, a body, children. Spelled as a section or an item. |
48+| Node | One thing in the outline: a title line and its children. Spelled as a section or an item. |
49+| Children | A node's ordered content: text children and child nodes, interleaved (§3.1). |
50+| Text child | A run of lines among a node's children that is not a node: prose, code, a quote (§3.3). |
22551| Section | A node spelled as an ATX heading `#`, `##`, … (no upper bound). |
22652| Item | A node spelled as a `- ` bullet. |
22753| Spelling | Whether a node is written as a heading or a bullet. Presentation only (§3.1). |
228−| Task | A non-block node with a checkbox after its marker, or a block with a `todo` key. |
54+| Task | A node with a checkbox after its marker — block or not. |
22955| Block | A node with its own identity and file. Only blocks have properties; every edit changes exactly one block. |
23056| Id | A random four-word phonemic name (`racfer-hattes-dozzod-binwes`) in a block's `id:` frontmatter; its stable identity. |
23157| Name | The slug of a block's title. With a prefix of the id in front, its filename. Decoration, not identity. |
23258| Property | A key in a block's YAML frontmatter. |
233−| Body | Block content belonging to a node before its first child. |
59+| Body | A node's leading text children: what comes before its first child node. |
23460| Zoom | Viewing a node as a standalone block via `render(node, base)`. |
23561| Splice | Writing an edited zoomed block back into its source span. |
23662| Make a block | Giving a node an id and its own file, leaving an embed in the parent. |
237−| Embed | `![[id]]` alone on a line at the node's position in the parent. |
63+| Embed | `![[id]]` alone on a line (or as a heading, `## ![[id]]`) at the node's position in the parent. |
23864| Span | A byte range in a file. Every node knows its span. |
23965| Path | A node's address by ancestor titles: `Homelab/NAS/ZFS layout`. |
24066
@@ -252,20 +78,64 @@ Section — spelled as a heading; may hold prose; children of either spelling
25278Item — spelled as a bullet; may hold note lines; children of either spelling
25379```
25480
81+A node's **children** are an ordered list in which two things interleave: **child nodes**
82+and **text children** (§3.3), in the order the file has them:
83+
84+```
85+Trip ← section
86+├─ text "Plan below."
87+├─ item book flights
88+├─ item book hotel
89+└─ text "Budget is tight, so check both before paying."
90+```
91+
25592Rules:
25693
25794- Any node may be the child of any node. A heading under a bullet is a section whose parent
25895 is an item; a bullet under a heading is an item whose parent is a section.
96+- **Ordering rule.** Every node's children match
97+
98+ ```
99+ children := (text | item)* section*
100+ ```
101+
102+ Markdown has no way to close a heading: anything written after a section child — text or
103+ an item at that position — belongs to that section. So once a section child appears, only
104+ section children follow it. The parser produces nothing else; every verb that places a
105+ node (§6.5, §10.3, §12.4) keeps the rule by **clamping**: an item goes no later than just
106+ before its new parent's first section child, and a section no earlier than just after its
107+ last item child. The *boundary* of a node's children is the position between its last
108+ item or text child and its first section child.
109+
110+ **What the rule costs.** A node cannot have items or text after a section child: under
111+ `# Trip` with a `## Budget` subsection, there is no way to put another bullet or paragraph
112+ back under Trip after Budget. Everything written there is Budget's. Plain Markdown cannot
113+ express it either, so the format accepts the limit rather than invent a closing syntax
114+ no other tool would read. The practical consequences are that `>`, `<`, paste, refile and
115+ capture sometimes place an item earlier than asked (before the first section), `J` / `K`
116+ refuse to move an item past a section, and `~` moves the node it respells. The TUI says
117+ so in the status line whenever the rule, not the user, chose the position; none of these
118+ is a bug. The workaround is the obvious one:
119+ put the later items in a section of their own (`## Also`), or keep sections last.
120+- **Text children are content, not outline.** They are not rows in the outline pane, not
121+ targets, never blocks or tasks, and are never moved on their own. A verb that moves,
122+ deletes or refiles a node moves exactly that node's own lines; the text children around it
123+ stay where they are, with the siblings they were between. They move only with the node
124+ that contains them.
259125- Heading level and indent are **derived from position**, never stored:
260126 `level(n) = 1 + number of section ancestors of n`, and `indent(n) = 2 × number of item
261127 ancestors of n`. A section under an item is written at that item's child indent with its
262128 own level. There is no upper bound on levels.
263−- Spelling is presentation: `~` toggles it (§10.3) and nothing else changes. Both spellings
264− can be tasks, hold bodies, be refiled anywhere, and be made into blocks.
129+- Spelling is presentation: `~` toggles it (§10.3) and nothing else about the node changes.
130+ Both spellings can be tasks, hold text, be refiled anywhere, and be made into blocks. The
131+ one consequence of spelling is position: under the ordering rule a node respelled by `~`
132+ moves to its parent's boundary, so its siblings keep their parent.
265133- Sibling order is block order and is meaningful.
266134- Any node may be a **block** (the root of its own file). Its file starts with the node
267− as it is spelled — a heading or a bullet — so the file is the only place its spelling
268− lives (§4.9).
135+ as it is spelled — a heading or a bullet — and that is where its spelling is defined
136+ (§4.9). The embed repeats it in its form (§4.7) so the parent file parses into the right
137+ shape on its own; the app keeps the two in step (`~` rewrites both), and where they
138+ disagree the file wins and `fold check` reports the embed.
269139
270140### 3.2 Properties
271141
@@ -289,7 +159,6 @@ Reserved keys (the app assigns semantics; users may still read/write them by han
289159| Key | Value | Meaning |
290160|---|---|---|
291161| `id` | four `@p` words | The block's identity (§3.4). Present on every managed file; never on `root.md`. |
292−| `todo` | `open` \| `done` | Makes the block a task and holds its state (§4.5). Absent means not a task. |
293162| `due` | `YYYY-MM-DD` | Task due date; shown in the outline. |
294163| `done` | `YYYY-MM-DD` | Written when a task block is checked; removed when unchecked. |
295164| `conflict` | `<device> <timestamp>` | Marks a "theirs" copy written by the merge engine (§12.4). |
@@ -302,16 +171,32 @@ nothing reorders it on insert.
302171Everything else (`tags`, `priority`, `est`, `waiting`, `since`, `author`, `source`, …) is
303172user-defined, and the app never looks at it.
304173
305−### 3.3 Bodies
174+### 3.3 Text children
306175
307−A body is an ordered list of raw lines, opaque to the tree. The parser only needs to know
176+A text child is a run of consecutive raw lines, opaque to the tree, between two structural
177+points: the title line, a child node, or the end of the node. The parser only needs to know
308178enough to find the *next title line*: it tracks fenced code blocks (```` ``` ```` and `~~~`,
309179any indent) so that `#` or `- ` inside a fence never starts a node. Paragraphs, quotes,
310−tables, images, HTML and code are all body.
180+tables, images, HTML and code are all text.
181+
182+A text line belongs to the deepest open node whose region it reaches:
183+
184+- an item's region is lines indented at least `indent + 2`;
185+- a section's region is lines at or beyond its own indent;
186+- the root's region is everything.
187+
188+So a line at column 0 after `- b` under `# Trip` is Trip's text, not b's: it ends the list.
189+(CommonMark would read an unindented line right after a list item with no blank line between
190+as a lazy continuation of that item; this format does not. Canonical form therefore always
191+writes a blank line there (§4.2), and then both agree.)
192+
193+A blank line belongs to the text run it is in, or — between nodes — to the deepest open node
194+before it: the blank line after `- a` is `a`'s trailing separator. Blank lines are therefore
195+never lost or invented when a node is rendered or moved.
311196
312−For items, body lines are those indented at least `indent + 2` that are not child title
313−lines (bullets or headings). For sections, body is everything from the title line to the
314−first child, at the section's own indent.
197+The **body** of a node is its leading text: the text children before its first child node.
198+It is what the reading pane shows under the title before any child, and what "a node with a
199+body" means elsewhere in this spec.
315200
316201### 3.4 Identity and addressing
317202
@@ -323,7 +208,19 @@ when the block is created and stored as the `id` key of its frontmatter. Its fil
323208directory uses as its prefix — usually one — then the slug of its title
324209(`racfer~order-new-switch.md`). The prefix alone identifies the file; the name is for humans. The filename is decoration that any tool may change and the
325210app will repair (§6.4). The one exception is `root.md`, which has no
326−id. A non-block node's identity is its **path**.
211+id. A non-block node has no stored identity. Two things stand in for one, and they are
212+different on purpose:
213+
214+- **Typed targets** (below) name a node by titles. Titles are what a person can type, and a
215+ title that matches twice is an ambiguity error, never a guess.
216+- **Tracking** — re-attaching the cursor, folds and a verb's result across a re-parse —
217+ uses the node's **key**: the block whose file holds it (none for `root.md`), then one step
218+ per level down from that file's root, each step the title *and the node's ordinal among
219+ same-titled siblings*. Two siblings with one title, or two empty new nodes, have
220+ different keys. Keys are never shown and never typed.
221+
222+Verbs never find a node again by its title path: a verb that re-parses finds its result by
223+key, by id, or by position.
327224
328225Embeds reference the id alone (`![[racfer-hattes-dozzod-binwes]]`), so nothing in the vault
329226depends on a filename and a rename touches only the renamed file.
@@ -338,17 +235,19 @@ Resolution order for a command target (the *refile*, *go to* and *make block* pr
338235 (`2026-09-10`) resolve this way; there is nothing special about them.
339236
340237Ambiguous or missing targets are an error for the command that named them; broken or
341−duplicate embeds are diagnostics in `notes check` (§15.7).
238+duplicate embeds are diagnostics in `fold check` (§15.7).
342239
343−Renaming a block's title renames its file (§6.4). Nothing else references the filename.
240+Renaming a block's title does not rename its file (§6.4); a stale name is harmless, since
241+nothing references the filename.
344242
345243Titles may not contain `/` (the path separator). `[` and `]` are discouraged.
346244
347245### 3.5 Derived facts
348246
349247Computed by the index, never stored in files: heading level, indent, path, file, span,
350−open/done task counts per subtree (`3/7` in the outline), per-file mtime. Per-node
351−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).
352251
353252---
354253
@@ -367,7 +266,7 @@ The vault is flat: every block sits beside `root.md`. Only `root.md` and `.md` f
367266whose frontmatter carries a valid `id` (§6.4) are parsed. Everything else — subdirectories,
368267non-Markdown files, `.md` files without an id — is ignored: never parsed, never written,
369268never deleted. There is no import: to bring a foreign `.md` file in, paste its text into
370−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`
371270lists ignored `.md` files so nothing is forgotten in the vault.
372271
373272There is no configuration and no configuration file. The inbox is the section titled
@@ -406,14 +305,16 @@ The app **reads** any CommonMark it can make sense of and **writes** this subset
406305 or bullets alike. `[X]` is accepted on read; `[-]` is read as `[x]`.
407306- Exactly one blank line between a node's body and its first child, and between sibling
408307 sections. Loose/tight lists are preserved as found.
308+- A blank line before any text child that follows a child node (§3.3), so that CommonMark
309+ readers agree the text is not part of that node.
409310
410311Canonicalization is **lazy**: a node is rewritten in canonical form only when it is touched
411−(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
412313everything at once.
413314
414315**What is ours.** A standard Markdown renderer will mishandle exactly these four things:
415−`![[id]]` embeds, heading levels beyond six, checkboxes on headings, and headings indented
416−under bullets. Nothing else in a vault is non-standard.
316+`![[id]]` embeds (bare or as a heading), heading levels beyond six, checkboxes on headings,
317+and headings indented under bullets. Nothing else in a vault is non-standard.
417318
418319### 4.3 Title lines
419320
@@ -427,7 +328,7 @@ title := any text not starting with whitespace; may not contain "/"
427328
428329There is no attribute syntax. An `@word` or `key:: value` in a title or body is text, and
429330so is `[[…]]` (reserved, §4.6).
430−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.
431332
432333### 4.4 Frontmatter
433334
@@ -444,26 +345,34 @@ Inside the app, frontmatter is storage: the reading pane shows a block's propert
444345dimmed header computed from the index, the property editor (`a`, §10.6) changes them, and
445346the built-in editor's text (§5.1, `resolve_blocks = true`) contains none.
446347
447−Frontmatter never appears as text to the user. (An inline `---`-under-the-title spelling
448−was designed for an external editor and is set aside with it, §17.)
348+Frontmatter never appears as text to the user.
449349
450350### 4.5 Tasks
451351
452−Any node can be a task. Where the state is written depends on whether the node is a file:
352+Any node can be a task, and there is one way to write it: a **checkbox after the marker on
353+its title line**. `- [ ]` and `## [ ]` are both open tasks; `[x]` is done. A block is no
354+different — its checkbox is on its own title line, in its file:
355+
356+```markdown
357+---
358+id: racfer-hattes-mislup-nodrys
359+due: 2026-09-20
360+---
361+
362+- [ ] Order new switch
363+```
453364
454−- A node that is **not** a block is a task iff it has a checkbox after its marker.
455− `- [ ]` and `## [ ]` are both open tasks; `[x]` is done. That is all the state it has.
456−- A **block** is a task iff its frontmatter has `todo:`, whose value is the state:
457− `open` or `done`. Its title line in the file carries no checkbox. Checking writes
458− `todo: done` and `done: <date>`; reverting sets `todo: open` and removes the date.
365+What a block adds is a completion record: checking it writes `done: <date>` to its
366+frontmatter, and unchecking removes the date. A plain task has no frontmatter and so no
367+date; that is the whole difference. Because the state is always on the title line,
368+`rg '\[ \]'` over the vault is the complete open-task list.
459369
460370There is no cancelled state. A task that will never be done is either checked off or
461371deleted.
462372
463−Embeds carry no state. Cutting a checkbox task turns the checkbox into `todo:` (§6.1). A
464−checkbox on a block's own title line is
465−accepted on read as `todo` and rewritten on the next touch. A task section has its own
466−state *and* the derived count of its subtree. See §8.
373+Embeds carry no state: the checkbox travels with the title line into the block's file when
374+it is made (§6.1). A task section has its own state *and* the derived count of its subtree.
375+See §8.
467376
468377### 4.6 Links
469378
@@ -475,17 +384,39 @@ External links are ordinary Markdown `[text](url)` and are opened with `xdg-open
475384
476385### 4.7 Embeds
477386
478−An embed is where a block is stitched into the tree: `![[id]]` alone on a line, at the
479−indent the node has at that position.
387+An embed is where a block is stitched into the tree. It has two forms, one per position in
388+the ordering rule (§3.1):
389+
390+- **bare**, `![[id]]` alone on a line at the node's indent — an item position;
391+- **heading**, `## ![[id]]` — a heading line holding nothing but the embed, at the level and
392+ indent a section has at that position.
480393
481394```
482395![[dozzod-binwes-talsun-worbec]]
483396 ![[racfer-hattes-mislup-nodrys]] -- a child of the item above it
397+## ![[lacnum-walbyn-dirlyn-havtyp]] -- a section-position embed
484398```
485399
486−The embed names the block by id and nothing else; title, spelling, checkbox or `todo`,
487−properties, body and children all live in the file. An embed line has no children in the
488−parent file; child lines indented under an embed are a diagnostic.
400+The app writes the form that matches the block's spelling: a block whose root is a section
401+is embedded with a heading embed, a block whose root is an item with a bare one. The
402+heading form exists because a bare embed after a section sibling would, like any line
403+there, belong to that section (§3.1). Either form is read anywhere; an embed whose form
404+does not match its block's spelling is a diagnostic (§15.7) that `fold check --fix`
405+rewrites, and it is placed where its form puts it.
406+
407+The embed names the block by id and nothing else; title, checkbox, properties, text and
408+children all live in the file. The embed's form repeats the block's spelling so that the
409+parent file parses into the right shape without opening the block. An embed line has no
410+children in the parent file; lines indented under a bare embed, or nested under a heading
411+embed, are a diagnostic.
412+
413+A heading embed's **level** is read like any heading's: honoured as written, and it
414+decides the embed's parent the same way. The level its position gives is `1 +` its section
415+ancestors (§3.1), and that is the level the app writes. A written level deeper than that —
416+left by another editor, or by moving text around by hand — still parses under the same
417+parent (a shallower one would parse elsewhere and is simply a different position), so
418+`fold check` reports it and `check --fix` rewrites it to the positional level; the
419+structure does not change.
489420
490421`![[` anywhere else is body text. The app does not implement general transclusion.
491422
@@ -499,20 +430,26 @@ for a later query language (§17); leave such blocks alone and they will start w
499430A block's file is exactly `render(node, 1, resolve_blocks = false)`:
500431
501432```
502−[frontmatter, always beginning with id; todo: if the block is a task]
433+[frontmatter, always beginning with id; done: if the block is a checked task]
503434[the node's title line, as spelled: "# Title" or "- Title"]
504−[body]
505−[children]
435+[its children: text and nodes, in order]
506436```
507437
508438- The root is written as it is spelled in the tree. A block that is a heading is a `#` at column 0; one
509439 that is a bullet is a `- ` at column 0, with its body and children indented under it exactly
510440 as they would be in the parent. The file is the only place the spelling is recorded.
511−- The block's task state is `todo:` in its frontmatter, with a `done:` date beside it
512− when done; there is no checkbox in the file. Toggling the node — from the outline,
513− or the embed — writes to the file.
514−- A file with text before the root other than frontmatter, or with more than one node at
515− column 0, is a diagnostic; the app treats the file as read-only until fixed.
441+- The block's task state is the checkbox on its title line, with a `done:` date in the
442+ frontmatter when it is checked. Toggling the node — from the outline, or the embed —
443+ writes to the file.
444+- The first node at column 0 is the block's root. **Anything after it at column 0 — more
445+ nodes, or text — is adopted as the root's children**, in order, as if it were indented
446+ under it. That is what a phone editor produces by appending `- new thing` to a block
447+ spelled as a bullet, and it must never make the block unusable. Adopted lines are
448+ non-canonical (`fold check` notes them); the next write of the block, or `check --fix`,
449+ writes them in place under the root (indented under a bullet root, re-levelled under a
450+ heading root).
451+- Text before the root other than frontmatter, or an embed before it, is a diagnostic; the
452+ app treats that file as read-only until fixed, since it cannot tell what the block is.
516453- `root.md` is the file of the implicit Root node; its frontmatter holds vault-level
517454 properties (rarely needed) and never an `id`.
518455
@@ -527,7 +464,7 @@ Two boxes in the closet, one at Hetzner.
527464
528465## NAS
529466
530−![[dozzod-binwes-talsun-worbec]]
467+### ![[dozzod-binwes-talsun-worbec]]
531468
532469## Networking
533470
@@ -568,17 +505,18 @@ Mirrored pairs, no raidz. Snapshots hourly via sanoid.
568505```markdown
569506---
570507id: racfer-hattes-mislup-nodrys
571−todo: open
572508due: 2026-09-20
573509---
574510
575−- Order new switch
511+- [ ] Order new switch
576512 Two options, noted under Networking.
577513```
578514
579−Note that "Replace the flaky switch" is a one-line task with no file; "Order new switch"
580−became a block the moment it got a due date, its checkbox became `todo: open`, and its
581−file starts with a bullet because that is what it is. "Options" is
515+Note that "Replace the flaky switch" is a one-line task with no file; "ZFS layout" is a
516+section block, so its embed is a heading at the level it has under NAS, while "Order new
517+switch" is an item block and is embedded bare. "Order new switch" became a block the moment
518+it got a due date; its checkbox moved into the file with its title line, and its file starts
519+with a bullet because that is what it is. "Options" is
582520a section nested under an item, and "Snapshot policy" is a section that is itself a task.
583521
584522---
@@ -592,10 +530,11 @@ Emits the node and its subtree, in document order, as a standalone Markdown docu
5925301. If the node is a block and `base == 1` and `resolve_blocks = false`: its frontmatter.
5935312. The node's title line — checkbox included — at level `base` for a section, or at
594532 indent 0 for an item.
595−3. The node's body, verbatim (bodies dedented by the node's original indent).
596−4. Each descendant, recursively: sections re-levelled by `base − level(node)`; everything
597− re-indented by `−indent(node)`.
598−5. Embeds: with `resolve_blocks = false`, written as embeds. With `resolve_blocks = true`, the
533+3. The node's children in order: each text child verbatim (dedented by the node's original
534+ indent), each child node recursively — sections re-levelled by `base − level(node)`,
535+ everything re-indented by `−indent(node)`.
536+4. Blank lines are text and are reproduced where they are (§3.3); `render` adds none.
537+5. Embeds: with `resolve_blocks = false`, written as embeds in their form (§4.7). With `resolve_blocks = true`, the
599538 block's subtree is inlined at the embed's position, its frontmatter omitted, its own
600539 embeds resolved in turn. The resolved text contains no embed, no id, no frontmatter and no
601540 file boundary: it is titles, checkboxes, bodies and nesting, nothing else.
@@ -628,7 +567,8 @@ A block is **dirty** when any line it owns changed, or when the position of a bl
628567in it moved. `splice(block)` writes one dirty block:
629568
6305691. Collect the block's owned lines in buffer order, and put each nested block back as an
631− embed at the position of that block's title line. The result is exactly an edited
570+ embed at the position of that block's title line — a heading embed if that title line is
571+ a heading, a bare one if it is a bullet (§4.7). The result is exactly an edited
632572 `render(block, 1, false)`: one file's text, embeds intact, ids never typed.
6335732. Parse it: title lines, bodies, nesting, embeds. Exactly one root-level node; a `---`
634574 line is body text.
@@ -643,13 +583,14 @@ in it moved. `splice(block)` writes one dirty block:
643583
644584Splice runs without being asked (§10.6): when the cursor moves from a dirty block into
645585another block, after a pause in typing, on leaving the editor, before any outline verb,
646−before a reload, and on quit. A commit may write several files — one per dirty block — but
647−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.
648589
649590Edge cases follow from the tags, not from rules:
650591
651592- Renaming a nested block is editing its title line; it is that block's line, so its file
652− is rewritten and renamed (§6.4). No second step.
593+ is rewritten (and keeps its name, §6.4). No second step.
653594- Deleting a nested block's title line deletes the block: the file goes to trash, and any
654595 lines it still owned are re-tagged to the enclosing block — they become plain text of the
655596 parent, which is what deleting a heading does to its content in any editor. This is the
@@ -662,7 +603,7 @@ Edge cases follow from the tags, not from rules:
662603### 5.3 Levels
663604
664605Heading levels are unbounded (§3.1), so splice never changes a node's spelling and never
665−demotes anything. The level-overflow rule of earlier drafts is gone.
606+demotes anything.
666607
667608### 5.4 One function, three uses
668609
@@ -683,8 +624,10 @@ demotes anything. The level-overflow rule of earlier drafts is gone.
6836241. Generate an id and a name (§6.4). The file is `<prefix>~<name>.md` in the vault root.
6846252. Write `render(node, 1, resolve_blocks = false)` to the file atomically, with frontmatter
685626 `id: <id>` and, if the block was made by setting a property, that property.
686−3. Replace the node's span in its parent file with an embed, `![[id]]`, at the node's
687− indent. A checkbox on the node becomes `todo: <state>` in the frontmatter (§4.5).
627+3. Replace the node's span in its parent file with an embed at the node's position: a
628+ heading embed at its level for a section, a bare `![[id]]` at its indent for an item
629+ (§4.7). The text children around the node stay in the parent. A checkbox on the node
630+ stays on its title line, now in the block's file (§4.5).
6886314. Update the index.
689632
690633There is no inverse. A block stays a file until it is deleted (`d`, *clear done*, or
@@ -699,7 +642,8 @@ or on `s` / *make block*. There is no other way.
699642
700643- Every block is embedded from **exactly one** embed. A second embed for the same id is
701644 a diagnostic and renders as broken. Cycles are a diagnostic.
702−- A block file has exactly one node at column 0 (§4.9) and its frontmatter has a valid
645+- A block file has one root, its first node at column 0; later column-0 lines are adopted
646+ under it (§4.9). Its frontmatter has a valid
703647 `id`.
704648- Blocks may be nested: a block's file may itself contain embeds.
705649- Embed lines have no children in the parent file.
@@ -725,7 +669,7 @@ syllables (vowels `e u y`) — each word `prefix suffix`, words joined by `-`:
725669`racfer-hattes-dozzod-binwes`. No `~` sigil and no scrambling step, since the bits are random
726670rather than sequential. Never derived from content or time. Stored as the `id` frontmatter
727671key and validated against the syllable tables on read; a file whose `id` does not validate
728−is ignored, and `notes check` says why.
672+is ignored, and `fold check` says why.
729673
730674Collisions: 2⁶⁴ values, so two ids minted independently are never expected to collide; the
731675index still checks and redraws locally, and the merge engine still reports an id collision
@@ -752,15 +696,18 @@ splits without knowing how many words the prefix has. Because the prefix alone i
752696can never collide with anything, and `racfer~` already tells you which file you are looking
753697at before you read the rest. Cross-device collisions are found at merge time and resolved
754698the same way (§12.2). The filename carries no identity: a file is parsed because of its
755−`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
756700check` asks for the prefix) while `racfer~order-new-switch.md` without one is ignored.
757701Safe on APFS, ext4, Android storage and Windows; case-insensitive-safe because lowercase.
758702
759−Renaming a block's title renames the file to the new name (temp-and-rename). Nothing
760−else in the vault references the filename, so nothing else is rewritten. A file renamed or
761−titled by another tool is still found by its id; `notes check` reports filenames whose
762−prefix is not a leading run of the id or whose name no longer matches the title, and
763−`notes check --fix` fixes them.
703+**Names are set once.** The name is the slug of the title when the block is made, and a
704+later title change does not rename the file. Under Syncthing a rename arrives on other
705+devices as a delete plus a create; a phone that edits the old name meanwhile turns that
706+into a sync conflict or an orphan, for nothing — the prefix alone identifies the file and
707+nothing references the filename. A file renamed or titled by another tool is still found by
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`
710+renames both, which is the one moment the app renames a file.
764711
765712### 6.5 Refile
766713
@@ -769,6 +716,12 @@ the re-levelled text at the destination (§5). Refiling a block moves only its e
769716the file does not move. Any node may be refiled under any node;
770717spelling is preserved. A task block's state travels with its file, not its embed.
771718
719+The subtree becomes the destination's **last child, clamped** by the ordering rule (§3.1): a
720+section goes after everything; an item goes after the destination's last item or text child,
721+just before its first section child. `Ctrl-Enter` (first child) is clamped the same way: a
722+section as first child lands just after the destination's last item. Text children of the
723+source's old parent stay behind.
724+
772725**Archive.** Archiving is refile with a fixed destination: the subtree is appended as the
773726last child of the top-level section titled `Archive` (case-insensitive, like `Inbox`;
774727created as the last top-level section of `root.md` if missing). Nothing else happens — no
@@ -782,11 +735,13 @@ delete it. Nothing in the archive is hidden or dimmed.
782735
783736- The inbox is the top-level section titled `Inbox` (case-insensitive). If none exists,
784737 capture creates `# Inbox` as the last top-level section of `root.md`. If several exist,
785− the first in `root.md` order wins and `notes check` reports the rest.
786−- **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
787740 under today's day: the child section of `Inbox` titled with today's date, `YYYY-MM-DD`,
788741 created on demand as the last child. `--task` or a leading `[ ]` makes it a task.
789− `--to <target>` captures under any node instead. Capture never creates a block; dates
742+ `--to <target>` captures under any node instead. A captured item is clamped like any
743+ inserted item (§3.1): under a target with section children it lands just before the first
744+ of them. Capture never creates a block; dates
790745 and properties are set in the TUI afterwards.
791746- Days are ordinary sections, appended in order, so the inbox reads oldest to newest. They
792747 can hold prose, tasks and sub-sections like any section, and `2026-09-11` addresses
@@ -805,7 +760,7 @@ delete it. Nothing in the archive is hidden or dimmed.
805760| Node | open | done |
806761|---|---|---|
807762| not a block | `- [ ] Title` / `## [ ] Title` | `[x]` |
808−| block | `todo: open` | `todo: done` + `done: <date>` |
763+| block | `- [ ] Title` / `## [ ] Title` | `[x]` + `done: <date>` in frontmatter |
809764
810765Any node can be a task. A checkbox task has exactly this much state and nothing else. Its
811766completion date is not recorded; if you need one, make the task a block so toggling
@@ -814,9 +769,9 @@ writes `done:` (§8.2).
814769### 8.2 Task blocks
815770
816771A task that needs a date, a property, a completion record, or a body with structure becomes
817−a block (§6.1). Its checkbox becomes `todo:` in the frontmatter; the embed in the parent
818−is `![[id]]` and the title line in the file is plain. Toggling from anywhere
819−(outline or embed) rewrites `todo:` and stamps or removes `done:`.
772+a block (§6.1). Its checkbox moves into the block's file with its title line; the embed in
773+the parent is `![[id]]`. Toggling from anywhere (outline or embed) rewrites that checkbox and
774+stamps or removes `done:`.
820775The reading pane renders a resolved task embed on one line:
821776
822777```
@@ -843,10 +798,12 @@ Dates are ISO, `YYYY-MM-DD`. The property editor refuses anything else for `due`
843798
844799Done items stay where they are: one line each, in the context that gave them
845800meaning, which is the cheapest history there is. The panes dim them, and `zd` toggles
846−hiding them for the session.
801+hiding them (remembered with the view, §10.1). The selection stays on its node; one
802+hidden with them gives way to its next shown sibling, else the row above it.
847803
848804When a list is genuinely finished, *clear done* (palette) trashes every done item with no
849−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'
850807files go to trash too. For whole finished subtrees — a completed project, a concluded
851808meeting series — `za` refiles them under `Archive` (§6.5). The trash (§11.5) and file versioning (§12.6) backstop both.
852809
@@ -859,46 +816,162 @@ Out of scope for 1.0. Nothing is reserved for it.
859816## 9. Query language
860817
861818Not in 1.0. Search is the filter box (§10.5): fuzzy title match plus full-text match over
862−the whole vault, live, no syntax. A query language with saved views was designed and set
863−aside (§17, open decision 16); the ```` ```query ```` fence is reserved for it (§4.8).
819+the whole vault, live, no syntax. A query language is future work (§17, §19 decision 16); the
820+```` ```query ```` fence is reserved for it (§4.8).
864821
865822---
866823
867824## 10. TUI
868825
869−### 10.1 Layout
826+### 10.1 Layout and the pointer
870827
871828```
872−┌ Homelab › NAS › ZFS layout ────────────────────────── 3/7 open ┐
873−│ outline pane (titles only) │ reading pane (zoomed) │
874−│ ▾ Homelab 2/9 │ # ZFS layout │
875−│ ▾ NAS │ since 2024-03 · storage │
876−│ ▸ ZFS layout ▤ 1/2 ◀ │ │
877−│ Networking 1/2 │ Mirrored pairs, no raidz…│
878−│ ▾ Inbox │ │
879−│ 2026-09-10 1/2 │ ## Snapshot policy │
880−│ │ - hourly, keep 24 │
881−│ │ ☑ Move scratch to its… │
882−├─────────────────────────────────────┴──────────────────────────┤
883−│ : root.md · saved · 12:04 │
884−└────────────────────────────────────────────────────────────────┘
829+ fold › Homelab › NAS ⌕ Filter + Capture ↶ Undo ↷ Redo ☰ Commands ? Help
830+╭ Outline ───────────────────────╮╭ ZFS layout ▤ ─────────────────────────── ✎ Edit ─ ⋯ ─╮
831+│▾ Homelab 2/4 ││ # ZFS layout │
832+│ ▾ NAS 1/2 ││ ⚑ since 2024-03 │
833+│ ▾ ZFS layout ▤ 1/2 ⋯││ │
834+│ ▾ ☐ Snapshot policy ││ Mirrored pairs, no raidz. Snapshots hourly. │
835+│ ▸ Networking 1/2 ││ ## ☐ Snapshot policy │
836+│▾ Inbox ││ - hourly, keep 24 │
837+╰────────────────────────────────╯╰───────────────────────────────────────────────────────╯
838+ moved “Label the cables” ⚠ 1 conflict root.md ✓ saved
885839```
886840
887−- The **outline pane** shows titles, fold state, task glyphs, derived counts, a block
888− marker (`▤`) and, for blocks, `due` dimmed. Never bodies.
889−- The **reading pane** shows `render(cursor, 1, true)` with light Markdown styling; a
890− block's properties appear as a dimmed header under its title, computed from the
891− index, never as text. `zr` (session toggle) shows the text unstyled.
892−- The breadcrumb shows ancestors of the zoom root; the status line shows file, save state,
893− pending conflicts, and the last message.
894−- Split layout, outline on the left at a third of the width (minimum 30 columns); `Tab`
895− cycles focus. Below 80 columns the panes stack and `Tab` switches between them.
841+Four regions, each of them live under the pointer:
842+
843+- The **top bar**: the breadcrumb of the zoom root (`fold` is the vault root; every
844+ segment is a link that zooms there), and buttons for *Filter*, *Capture*, *Reading
845+ pane*, *Undo*, *Redo*, *Commands* and *Help*. Buttons shrink to their icons when the bar
846+ is narrow; a toggle's button looks pressed while it is on.
847+- The **outline pane**: titles, fold markers (`▸ ▾`), task glyphs (`☐ ☑`), the block
848+ marker (`▤`), a due date and the open/total count of the tasks below (a leaf shows no
849+ count). Done tasks are dimmed and struck through; sections are bold. Long titles end in
850+ `…`. Beside the reading pane, the date and count sit at the pane's right edge and rows
851+ show titles only. Without it, they follow the title, and after them, dimmed, comes the
852+ first line of the node's own text, so no prose is out of sight. A conflict copy
853+ (§12.5) has `⚠`, in the warning colour, for `▤`, and, in place of its text, whose copy
854+ it is: *other device · PHONE 09-27 10:00*. It starts folded until unfolded or zoomed
855+ into, a fold the view does not remember.
856+- The **reading pane**: `render(target, 1, true)`, the zoom root's or else the selected
857+ node's, with light Markdown styling (§10.9); a block's properties appear as a dimmed
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
860+ buttons *Edit* and `⋯` (the node menu). While editing, the pane is the editor (§10.6) and
861+ its buttons are *Done* and *Revert*.
862+- The **status bar**: the last message on the left (at start, a hint at the gestures and
863+ `?`, cut down to *right-click for actions · ? help* where it doesn't fit); on the right
864+ the unresolved conflicts (click to resolve; lit while new ones wait, §10.7), *done
865+ hidden* when `zd` is on (click to show), the file, and the save state. A verb's message
866+ names the node it acted on, in the menus' words, with the next key where one helps —
867+ *deleted “Homelab” (12 nodes) · u undoes*, *moved “rack” to “NAS” — placed before the
868+ sections* when the ordering rule chose the place (§3.1); in the reading pane, whose keys
869+ are its own (§10.4), the step is the menu of the line's node: *copied “NAS” · m, then
870+ choose Paste after*; in the editor, where every key is text (§10.6), it is the
871+ pointer's, short enough to keep the node and the item to choose at 80 columns: *copied
872+ “NAS” · right-click, Paste after*, *deleted “rack” · click ↶ Undo* — and names no file
873+ or id (§1, 3b), but for undo or redo refusing
874+ (§10.10); where the bar is short, the right gives way first, the ⚠ count down to its
875+ number (*⚠ 1*), then the file, until the message fits; then the names are cut down,
876+ the longest first, before the words around them. Once a message is some 5 s old and a key
877+ or click has come since, the next step takes its place: the keys of what is on screen —
878+ *n new · e edit · x done · m menu · / find · ? help* in the outline, *e edit · Enter
879+ zoom/follow · Tab outline* in the reading pane, *Esc done · Ctrl-S save · Ctrl-Z undo* in
880+ the editor (*i insert · :q! revert · :wq done* in Vim and Helix, where `Esc` never
881+ leaves), *n add · Enter change · d delete · Esc close* in the property form, and each
882+ popup's own — cut down by whole parts where they don't fit, the first and the last kept,
883+ so the way on (*? help*, *Esc done*, *:wq done*) stays. An error or a refusal
884+ (*error: …*, *can't …*, *… refused: …*) stays until a key or click that comes once it
885+ is that old. The greeting is the outline's, and gives way at once to the editor's or a
886+ popup's keys. While a key sequence is half typed, the bar says what can follow it:
887+ *z… p pane · w wrap · d hide done · r raw · a archive*, cut down from its end where it
888+ doesn't fit; with done hidden, *d show done* comes first.
889+
890+The outline fills the screen. The reading pane is hidden until asked for: `zp` or the top
891+bar's `◨` (*Reading pane*) shows it, `Tab` shows it and moves focus there, and the editor
892+(§10.6) always opens in it, hiding it again when done if it was hidden before. Shown, the
893+layout splits: outline on the left at a third of the width (minimum 30 columns); the border
894+between the panes is a handle — drag it to resize. Below 80 columns the panes stack.
895+
896+**The view is remembered** per vault, in `$XDG_STATE_HOME/fold/views/`: whether the
897+reading pane is shown, wrapping, hidden done tasks, the editor's keymap, the divider's
898+place, the folds and the zoom. None of it is in the vault; another device keeps its own.
899+A fold or zoom whose node has gone is dropped.
900+
901+**Long lines wrap** in the reading pane and the editor. Prose breaks after a space, and a
902+wrapped list item's continuation rows hang under its text, not under its bullet or
903+checkbox; a word longer than the pane breaks where it must. Lines inside fenced code break
904+at the pane's edge instead, one column early, with `↪` marking each break, so code is
905+never reflowed. Every screen row of a line is that line to the pointer: a click on any
906+row selects it or places the cursor where it lands. `zw` (*Wrap lines*) turns wrapping
907+off, and long lines are cut at the edge. The outline never wraps: a long
908+title ends in `…`.
909+
910+**The pointer.** Everything a key does, the pointer does too, and the screen shows where:
911+
912+| Gesture | Where | Does |
913+|---|---|---|
914+| click | an outline row | select it (the reading pane follows) |
915+| click | `▸` / `▾` | fold / unfold |
916+| click | `☐` / `☑`, in either pane | toggle the task |
917+| click | a conflict copy's `⚠`, in either pane | the conflict view at its pair (§10.7) |
918+| click | `⋯` (shown on the selected and the hovered row) | the node menu |
919+| click | a breadcrumb segment | zoom there |
920+| click | a link in the reading pane | open it |
921+| click | a line in the reading pane / the editor | put the cursor there |
922+| double-click | an outline row | zoom into it |
923+| double-click | a line in the reading pane | a heading: zoom into it; an embedded block: follow it; anything else: open the editor with the cursor on that line |
924+| right-click | a row or a line | the node menu for that node |
925+| drag | an outline row onto another row's **title** | move it **into** that node, as its last child |
926+| drag | an outline row onto the space **left of** another row's title | move it **before** that node, as its sibling |
927+| wheel | either pane, the editor, any list | scroll what is under the pointer; the selection stays put |
928+| drag | the border between the panes | resize them |
929+| click | outside any popup | close it |
930+
931+While dragging, the target row is highlighted (*into*) or marked with `▶` and a bar
932+(*before*), the row being moved is dimmed, the status bar spells out the move, and the pane
933+scrolls when the pointer reaches its edge. Both drops are clamped by the ordering rule
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.
947+
948+**The node menu** (right-click, `⋯`, or `m`) lists every action on a node, each with its key:
949+*Edit · Zoom in · Properties… | New sibling · New child | Done / reopen · Task on / off ·
950+Heading ↔ bullet · Make block | Move up · Move down · Indent · Outdent · Move to… · Archive |
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.
956+
957+**Pickers instead of typing.** Where an action needs a node — *Move to…*, *Go to…* — the
958+prompt is a list of every node, title first and path dimmed, narrowed as you type (title
959+prefix, then title, then path) and picked with a click or `↑`/`↓` and `Enter`. A typed id,
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 `⚠`.
963+
964+Every popup — node menu, prompt, properties, filter, commands, help — has its buttons on
965+its bottom border (*OK*, *Close*, …), a list you can click and scroll, and closes on a click
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.
896968
897969### 10.2 Modes
898970
899971`normal` (default), `edit` (the built-in editor over a subtree's Markdown, saving as you go), `filter`
900−(`/` box), `picker` (fuzzy lists, the palette included), `conflict` (§12.5).
901−`Esc` always returns to normal.
972+(`/` box), `picker` (the command palette), `properties` (the property form), `help`,
973+`conflict` (§12.5). The node menu and prompts open over any mode. `Esc` — or a click outside
974+the popup — closes the topmost thing.
902975
903976### 10.3 Outline pane — normal mode
904977
@@ -910,6 +983,8 @@ aside (§17, open decision 16); the ```` ```query ```` fence is reserved for it
910983| `H` / `L` | collapse / expand all under cursor |
911984| `zd` | toggle hiding done nodes (both panes) |
912985| `zr` | toggle raw mode: exact source in the reading pane, frontmatter included |
986+| `zw` | toggle wrapping of long lines in the reading pane and the editor (§10.1) |
987+| `zp` | show / hide the reading pane (§10.1) |
913988| `za` | archive: refile subtree under `Archive` (§6.5) |
914989| `-` | go to parent |
915990| `{` / `}` | previous / next sibling |
@@ -917,24 +992,31 @@ aside (§17, open decision 16); the ```` ```query ```` fence is reserved for it
917992| `Ctrl-d` / `Ctrl-u` | half-page down / up |
918993| `Enter` | zoom: reading pane shows the cursor node; reading pane takes focus |
919994| `Backspace` | zoom out to parent |
920−| `>` / `<` | demote / promote: become the last child of the previous sibling / the next sibling of the parent. Spelling unchanged |
921−| `~` | toggle spelling, section ↔ item (subtree unchanged) |
922−| `J` / `K` | move node down / up among siblings |
923−| `n` / `N` | new sibling after cursor / new last child: inserts an empty node and opens it with `e` |
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 |
998+| `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` |
924999| `e` | edit the subtree's Markdown in the built-in editor; saves as you go (§10.6) |
9251000| `a` | property editor: a form over the node's properties (§10.6); first property on a plain node makes it a block |
926−| `x` | toggle task open / done (checkbox, or `todo:` plus `done:` on a block) |
927−| `t` | toggle task-ness: adds or removes the checkbox, or the `todo` key on a block |
1001+| `x` | toggle task open / done: the checkbox, plus `done:` on a block |
1002+| `t` | toggle task-ness: adds or removes the checkbox (and a block's `done:`) |
9281003| `s` | make the node a block |
929−| `y` / `d` | yank / delete subtree into the register (delete goes to trash too) |
930−| `p` / `P` | paste register after / before cursor as sibling |
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) |
1005+| `p` / `P` | paste register after / before cursor as sibling, clamped by the ordering rule (§3.1) |
9311006| `r` | refile: fuzzy-pick a destination; `Ctrl-Enter` = as first child |
9321007| `c` / `C` | capture to the inbox as bullet / as task |
9331008| `/` | filter box (§10.5) |
934−| `:` | command palette (§10.9); `?` help |
1009+| `m` | the node menu (§10.1) |
1010+| `:` | command palette (§10.8); `?` or `F1` help |
9351011| `u` / `U` | undo / redo |
9361012| `q` | quit (nothing is ever unsaved in normal mode) |
9371013
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+
9381020### 10.4 Reading pane — normal mode
9391021
9401022| Key | Action |
@@ -947,17 +1029,20 @@ aside (§17, open decision 16); the ```` ```query ```` fence is reserved for it
9471029| `o` | open external link under cursor (`xdg-open` / `open`) |
9481030| `[[` / `]]` | previous / next heading |
9491031| `/` | in-block search; `n` / `N` next / previous match |
1032+| `:` `?` `F1` `m` `c` `C` `u` `U` `z…` | as in the outline; `m` is the menu of the node under the cursor; `i`, `Delete` (*m, then choose Delete*), `Ctrl-Z` and `Ctrl-F` (*/ searches*) say what to press instead |
9501033
9511034### 10.5 Filter box
9521035
953−`/` opens a single input over the outline. Typing filters the outline live by fuzzy title
954−match (nucleo) and, after a 150 ms pause, by full-text match, showing ancestors of hits.
955−`Enter` on a highlighted hit zooms to it; `Enter` with no hit does nothing. `Esc` clears.
956−Creating nodes is `n` / `N` (§10.3).
1036+`/`, or *Filter* in the top bar, opens a popup with an input and the hits below it:
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).
9571041
9581042### 10.6 Editing
9591043
960−There is one way to change text: edit the Markdown of the selected subtree. `e` shows
1044+There is one way to change text: edit the Markdown of the selected subtree. `e`, *Edit* on
1045+the reading pane or in the node menu, or a double-click on a line of text shows
9611046`render(cursor, 1, true)` — the node's title line, body and children, with every
9621047nested block inlined so that no embed, id, frontmatter or file boundary appears.
9631048
@@ -968,16 +1053,81 @@ app keeps track. Blocks are saved on their own:
9681053
9691054- when the cursor moves out of a block that has changed;
9701055- after 750 ms without a keystroke;
971−- on `Esc` (back to normal mode), on any outline verb, before a reload caused by an
972− external change, and on quit.
973−
974−The status line shows the title of the block the cursor is in, dimmed, and a dot while
975−something is unsaved — the only two hints that blocks exist. `Ctrl-c` discards changes made
976−since the last save. Undo inside the editor is the editor's own; each automatic save is one
977−entry in the session op log (§10.11). The box is not a Markdown editor and stays one.
978−
979−Properties are not text. `a` opens the **property editor**: a small form listing the
980−node's keys and values, where you add, change and delete entries. `due` and `done` accept
1056+- on `Esc` or *Done* (back to normal mode), on any outline verb, before a reload caused by
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.
1090+
1091+**Keymaps.** The editor speaks one of three keymaps, over the same text, cursor, selection,
1092+clipboard and undo:
1093+
1094+- **normal** (the default): a conventional editor in the manner of micro. Always typing;
1095+ arrows, `Home`/`End`, `PgUp`/`PgDn`, `Ctrl-←/→` by word; `Shift` with any of them
1096+ selects; typing replaces the selection. `Ctrl-C`/`X`/`V` copy, cut, paste (with nothing
1097+ selected, the current line); `Ctrl-Z`/`Y` undo, redo; `Ctrl-A` select all; `Ctrl-K` cut
1098+ the line; `Ctrl-D` duplicate; `Alt-↑/↓` move lines; `Tab`/`Shift-Tab` indent;
1099+ `Ctrl-F` find, `Ctrl-N`/`Ctrl-P` next, previous; `Ctrl-S` save; `Ctrl-E` a `:` command;
1100+ `Esc` clears the selection, then leaves.
1101+- **vim**: normal, insert, visual and visual-line modes; counts; motions `h j k l w b e W
1102+ B E 0 ^ $ gg G { } % f t F T ; ,`; operators `d c y > <` with any motion, doubled for
1103+ lines, and text objects `iw aw i" a" i' a' i( a( i[ a[ i{ a{ i< a< ip ap`; `x X s S D C Y
1104+ r J ~ p P o O i a I A u Ctrl-R .`; `/ ? n N * #`; `ZZ`, `ZQ`.
1105+- **helix**: selection first — `w b e W B E` select, `x` selects (and extends by) lines,
1106+ `% ; Alt-;`, `f t F T`, `gg ge gh gl gs`, `mi` / `ma` + object, `v` to extend; `d c y p P
1107+ R r ~ > < J` act on the selection; `i a I A o O`; `u U`; `/ ? n N *`.
1108+
1109+With wrapping on (§10.1), the normal keymap's `↑`/`↓`/`PgUp`/`PgDn` and Helix's `j`/`k`
1110+move by screen row through a wrapped line, as micro and Helix do; Vim's `j`/`k` move by line,
1111+and `gj`/`gk` by screen row.
1112+
1113+All three take `:` commands — `:w` save, `:q` / `:wq` / `:x` done, `:q!` / `:e!` revert,
1114+`:N` go to line — and in all three a click puts the cursor where it lands, a drag
1115+selects, a double-click selects a word, the wheel scrolls, and text pasted into the
1116+terminal is inserted as typed. Copying also sets the system clipboard (OSC 52). Undo
1117+inside 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.
1119+
1120+The keymap is `$FOLD_KEYS` (`normal`, `vim`, `helix`), or `--keys`; the *Editor keys*
1121+action, or the `⌨` label in the editor's border, switches it, and the choice is remembered
1122+with the view (§10.1) unless `--keys` or `$FOLD_KEYS` chose the keymap for this run. When
1123+editing ends, focus returns to the pane it came from. Undo inside the editor is the
1124+editor's own; each automatic save is one entry in the session op log (§10.10). The box is
1125+not a Markdown editor and stays one.
1126+
1127+Properties are not text. `a`, or *Properties…* in the node menu, opens the **property
1128+editor**: a small form listing the
1129+node's keys and values: click a value to change it (the prompt starts with the current
1130+value), `✕` deletes an entry, *Add* adds one. `due` and `done` accept
9811131only ISO dates; `id` is not shown. Setting the first property on a node that is not a
9821132block makes it one (§6.1), silently except for the `▤` marker; deleting the last one leaves
9831133the block as it is. Frontmatter lines the app does not understand (nested structure,
@@ -988,47 +1138,93 @@ editing are the same gesture. `x`, `t`, `~`, `>`/`<`, `J`/`K`, `r` and `d` are t
9881138structural verbs; they exist because toggling a checkbox should not require the editor,
9891139and each saves the editor first.
9901140
991−### 10.7 (removed)
992−
993−Result views went with the query language (§9).
994−
995−### 10.8 Conflict view
996−
997−Entered when a `.sync-conflict-*` file is detected or a splice hits a changed span. Shows
998−each conflict pair (ours in place, the `conflict:` block right after it) side by side; `o` keeps ours,
999−`t` keeps theirs, `b` keeps both, `e` edits, `n` / `N` next / previous, `Enter` finishes.
1141+### 10.7 Conflict view
1142+
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.
1157+The view's top bar carries the same as buttons — *Previous*, *Next*, *Keep ours*, *Keep
1158+theirs*, *Keep both*, *Edit ours*, *Close* — and the two versions sit side by side, this
1159+device's on the left.
10001160Unresolved pairs remain as `conflict:` blocks and stay listed in the status
10011161line until resolved (§12.5).
10021162
1003−### 10.9 Command palette
1163+### 10.8 Command palette
10041164
1005−`:` opens a popup listing every action the TUI has, filtered live by fuzzy match (nucleo)
1006−on the action's name and description, each row showing its key binding if it has one.
1007−`Enter` runs the highlighted action; if it needs an argument — a target for *refile* or
1008−*go to*, text for *capture* — a single prompt follows, with the same completion the
1009−key-bound form has. `Esc` closes.
1165+`:`, or *Commands* in the top bar, opens a popup listing every action the TUI has — key,
1166+name, what it does — filtered as you type: names that start with the query first, then
1167+names and descriptions that contain it, then fuzzy matches. A toggle shows its state
1168+beside its name (*Reading pane · on*). A click or `Enter` runs the highlighted action; if
1169+it needs an argument — a node for *Move to…* or *Go to…*, text for *Capture* — its prompt
1170+follows (§10.1). `Esc` or a click outside closes.
10101171
10111172There is no command syntax: nothing is typed except the search and the argument. Every
1012−action reachable by key is in the palette under a readable name (*make block*,
1013−*toggle task*, *archive*, …), and the four with no key live only there: *clear done*,
1014−*canonicalize*, *check*, *merge*. `?` is the same popup filtered to show keys, so help and
1015−palette are one thing.
1016−
1017−### 10.10 Markdown styling in the reading pane
1018−
1019−Line-based, not a full renderer: headings by level, task glyphs (`☐ ☑`), resolved blocks'
1020−properties dimmed at the end of the line, underlined external links, styled code fences,
1021−`**bold**` / `*em*` / `` `code` `` inline, blockquote bars. Raw text is never hidden;
1022−`zr` shows exact source, frontmatter included.
1023−
1024−### 10.11 Undo, redo, saving
1173+action is in the palette under a readable name, and the four that have no key or button
1174+live only there: *Clear done*, *Canonicalize*, *Merge sync conflicts*, *Resolve
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.
1177+
1178+### 10.9 Markdown styling in the reading pane
1179+
1180+Line-based, not a full renderer: headings coloured by level, task checkboxes shown as `☐ ☑`
1181+(clickable; done lines dimmed and struck through), links underlined (clickable),
1182+`` `code` `` tinted, `**bold**` and `*em*` styled, quotes in colour.
1183+
1184+**Fenced code** sits on a tinted background, its fence dimmed and its info string in the
1185+accent colour, and is **syntax-highlighted** with tree-sitter when the info string's first
1186+word names a known language. The app compiles in every tree-sitter grammar published on
1187+crates.io that builds against its tree-sitter version and has a highlights query — about
1188+140 languages, from Ada to Zig, including every mainstream programming, shell, markup,
1189+config, query and hardware-description language that has such a crate — keyed by the
1190+language's name and its usual fence aliases (`rs`, `py`, `js`, `ts`, `sh`, `c++`, `c#`,
1191+`kt`, `rb`, `hs`, `ex`, `yml`, `makefile`, `dockerfile`, `sql`, …). Where a grammar crate
1192+ships a highlights query without exporting it, the app carries a copy
1193+(`crates/fold-tui/queries/`, with its provenance). `rust,ignore`, `{.python}` and `sh
1194+title=x` work too. Each grammar is loaded on first use; the block is highlighted as a whole
1195+(so a string or comment spanning lines is right) and cached until its text changes. Any
1196+other language, or a block its grammar cannot parse, is shown in the
1197+plain code colour. Highlighting is the reading pane's only: the editor is a plain text box
1198+(§10.6). Raw text is
1199+never hidden: `#`, `-`, `**` and link targets stay on screen, dimmed. `zr` shows exact
1200+source, frontmatter included.
1201+
1202+### 10.10 Undo, redo, saving
10251203
10261204Every mutation is an operation with an inverse (span edits, file create/move/delete,
10271205including making blocks). The session op log powers `u` / `U`. Outline verbs write
10281206immediately and atomically; the built-in editor writes each dirty block within 750 ms of the
10291207last keystroke, or sooner when the cursor leaves it (§10.6), so the only unsaved text at any
1030−moment is under a second of typing in one block. Undo after an external change re-checks
1031−span hashes and refuses with a message if the target moved.
1208+moment is under a second of typing in one block.
1209+
1210+An op-log entry records **exactly the files the operation touched**: for each, its text
1211+before and after, where *absent* stands for a file the operation created or deleted (making
1212+a block, trashing one). An operation that changed nothing leaves no entry, so it does not
1213+clear the redo stack. Undo checks every touched file against its *after* text, and redo
1214+against its *before* text; if any differs — another editor or a sync changed it since — it
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
1219+operation 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.
10321228
10331229---
10341230
@@ -1036,7 +1232,7 @@ span hashes and refuses with a message if the target moved.
10361232
10371233### 11.1 Writes
10381234
1039−- **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`.
10401236 Never truncate in place.
10411237- **Span-preserving**: an edit replaces exactly the bytes of the affected node(s) — for a
10421238 property change, exactly the frontmatter lines that changed. Every other byte is copied
@@ -1048,14 +1244,32 @@ span hashes and refuses with a message if the target moved.
10481244### 11.2 Watcher and reload
10491245
10501246`notify`-based recursive watcher on the vault, 200 ms debounce, ignoring the patterns in
1051−§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.
10521252
10531253- A changed file that the user is not editing is re-parsed; the cursor is re-attached by
1054− id, then path, then nearest surviving ancestor.
1254+ 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)`.
10551257- A changed file with a built-in edit in progress: the editor saves its dirty blocks first;
10561258 a block whose span hash no longer matches is merged two-way (§5.2 step 5), then the file
1057− is re-parsed and the buffer re-rendered around the cursor.
1058−- 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.
10591273- A deleted file that was a block marks its embed broken; nothing is written.
10601274- Editing the same vault in Helix at the same time is a supported workflow.
10611275
@@ -1073,16 +1287,20 @@ during atomic writes.
10731287### 11.4 Ignore patterns
10741288
10751289Never parsed, never written, never deleted: dotfiles and dot-directories (`.git`, `.jj`,
1076−`.stfolder`, `.stversions`, `.stignore`, `.obsidian`), `.syncthing.*.tmp`, `*.notes-tmp`,
1290+`.stfolder`, `.stversions`, `.stignore`, `.obsidian`), `.syncthing.*.tmp`, `*.fold-tmp`,
10771291`*.tmp`, and any file not ending in `.md`. `*.sync-conflict-*.md` is parsed only by the merge
10781292engine.
10791293
10801294### 11.5 Trash
10811295
10821296Deleted subtrees and resolved `conflict:` blocks are written to
1083−`$XDG_STATE_HOME/notes/trash/<timestamp>-<id-or-name>.md` (device-local, never synced) before
1084−removal. `notes trash list|restore` manages it. The app never deletes user content without
1085−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.
10861304
10871305---
10881306
@@ -1094,12 +1312,13 @@ a trash copy.
10941312 winner plus a `name.sync-conflict-<date>-<time>-<device>.md` copy of the loser.
10951313- Therefore: keep concurrently-edited things in different files where possible (a
10961314 `Inbox` as a block; blocks), and make the app the thing that understands conflict files.
1097−- 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`.
10981316
10991317### 12.2 Detection
11001318
1101−The watcher (or `notes merge`, or the startup scan) finds `X.sync-conflict-*.md` next to
1102−`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.
11031322
11041323Before merging, the engine compares the two files' `id` keys. If they differ, this is not a
11051324conflict but a **prefix collision** — two blocks that happened to get the same filename
@@ -1110,10 +1329,11 @@ and nothing is lost.
11101329
11111330### 12.3 Merge inputs
11121331
1113−Ours `O` is `X.md`; theirs `T` is the conflict file — or, for an in-session conflict
1114−(§5.2 step 5), the text the user just edited. There is no stored common ancestor (§11.3),
1115−so every merge is **two-way**: the engine can tell *that* two versions differ, not *which*
1116−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.
11171337
11181338### 12.4 Node-level merge
11191339
@@ -1127,21 +1347,23 @@ Both versions are parsed into trees (embeds unresolved; each file merges on its
11271347A retitled node therefore appears twice after a merge — once per title. Two-way merging
11281348never deletes, so this is the safe failure; delete the copy you don't want.
11291349
1130−**Per matched node**, for each of title, checkbox state, body (as text), and — for the
1131−file's block — each frontmatter key independently (`todo` included): `O == T → take O`,
1132−otherwise **conflict**. A conflict pair is raised for every field that differs, even when
1350+**Per matched node**, for each of title, checkbox state, text (all its text children, with
1351+their positions among its child nodes, as one field), and — for the file's block — each
1352+frontmatter key independently: `O == T → take O`, otherwise **conflict**. A conflict pair is raised for every field that differs, even when
11331353only one side touched it; the cost of having no per-device state is paid here, in
11341354resolution clicks, never in lost text.
11351355
11361356**Children** are merged as sequences of matched nodes: nodes present on one side only are
1137−insertions, placed relative to their matched neighbours. Nothing is ever treated as
1357+insertions, placed relative to their matched neighbours and clamped by the ordering rule
1358+(§3.1). `O`'s text children are emitted where `O` has them. Nothing is ever treated as
11381359deleted.
11391360
11401361**Conflict output**: the `O` version stays in place. The `T` version is written as a new
11411362block `<prefix>~<name>.md` (a fresh id; the name is `O`'s name) with frontmatter
11421363`conflict: "<device> <timestamp>"` (plus, for a block conflict, `T`'s own properties),
1143−and an embed to it is inserted as the next sibling of `O`. The conflict block is
1144−therefore always the node immediately after the one it conflicts with; no other link
1364+and an embed to it is inserted as the next sibling of `O`, in the form matching `O`'s
1365+spelling (§4.7) — for a section, a heading embed after its whole subtree. The conflict block
1366+is therefore always the node immediately after the one it conflicts with; no other link
11451367between them is stored. No text is ever lost: every node from `O` and `T` appears in the
11461368result exactly once.
11471369
@@ -1150,18 +1372,26 @@ algorithm is deterministic and idempotent.
11501372
11511373### 12.5 Resolution
11521374
1153−The conflict view (§10.8) lists every `conflict:` block, each shown against the sibling
1154−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:
11551377
11561378- **keep ours** — delete the conflict block and its embed (to trash);
1157−- **keep theirs** — replace ours' title, body and children with the conflict block's,
1379+- **keep theirs** — replace ours' title and children (text and nodes) with the conflict block's,
11581380 and its frontmatter with the conflict block's minus `id` and `conflict`; then delete
11591381 the conflict block and its embed (to trash);
11601382- **keep both** — drop the `conflict` key; the block stays where it is as an ordinary
11611383 sibling.
11621384
11631385Unresolved pairs are ordinary blocks; they sync to every device and are visible in any
1164−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.
11651395
11661396### 12.6 History (outside the app)
11671397
@@ -1174,16 +1404,16 @@ notices it.
11741404
11751405## 13. CLI
11761406
1177−All commands take `--vault PATH` (default: `$NOTES_VAULT`, else the nearest ancestor of
1178−`$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`).
11791409
11801410| Command | Purpose |
11811411|---|---|
1182−| `notes` | open the TUI; creates `root.md` if the directory is empty (§4.1.1) |
1183−| `notes capture [TEXT] [--to TARGET] [--task]` | append to the inbox (stdin if no TEXT) |
1184−| `notes check [--fix]` | diagnostics with source spans (§15.7); `--fix` rewrites the vault in canonical form (§4.2) and repairs filenames (§6.4) |
1185−| `notes merge [--dry-run]` | process sync-conflict files non-interactively; list leftovers |
1186−| `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 |
11871417
11881418That is the whole CLI: the entry points a shell, a hotkey, a cron job on the homelab, or a
11891419recovery session needs. Everything else is a TUI verb.
@@ -1192,23 +1422,27 @@ recovery session needs. Everything else is a TUI verb.
11921422
11931423## 14. Settings
11941424
1195−There are no vault settings. Everything that would have been one is either fixed behaviour
1196−or content: the inbox is the section titled `Inbox` (§7);
1425+There are no vault settings. Everything that could be one is either fixed behaviour or
1426+content: the inbox is the section titled `Inbox` (§7);
11971427filenames are an id prefix plus the title; names keep Unicode; ids are four words in
11981428frontmatter (§6.4); making blocks
11991429are property-triggered or manual only; `done:` records a date; conflict copies are
12001430blocks marked `conflict:`. Each was a knob nobody needs to agree on with a file. Two vaults that
12011431contain the same Markdown behave identically.
12021432
1203−There are no device settings either. The app reads no file outside the vault. What used to
1204−be configuration is now:
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:
12051435
1206−- the vault: `--vault PATH`, else `$NOTES_VAULT`, else the nearest ancestor of `$PWD`
1207− containing `root.md`, else `~/notes`;
1208−- the trash location: `$XDG_STATE_HOME/notes/trash/` (§11.5);
1209−- everything visual: fixed (§10.1) or a session toggle (`zd`, `zr`).
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);
1439+- the editor's keymap: `--keys`, else `$FOLD_KEYS` (`normal`, `vim`, `helix`), else
1440+ `normal` (§10.6);
1441+- everything visual: fixed, or a toggle the view remembers per vault (`zp`, `zw`, `zd`,
1442+ §10.1), or one for the session (`zr`).
12101443
1211−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.
12121446
12131447---
12141448
@@ -1218,12 +1452,15 @@ The only thing the app keeps outside the vault is the trash.
12181452
12191453```
12201454crates/
1221− notes-core/ parsing, tree, render/splice, index, store, merge — no TUI deps
1222− notes-tui/ ratatui application
1223− 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
12241460```
12251461
1226−`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.
12271464
12281465### 15.2 Core types (sketch)
12291466
@@ -1235,9 +1472,8 @@ pub enum TaskState { Open, Done }
12351472pub struct Node {
12361473 kind: Kind,
12371474 title: String,
1238− task: Option<TaskState>, // any node; from the checkbox, or `todo` for a block
1239− body: Vec<String>, // raw lines, opaque
1240− children: Vec<NodeId>,
1475+ task: Option<TaskState>, // any node, block or not: the checkbox on its title line
1476+ content: Vec<Content>, // ordered children: text runs and nodes (§3.1)
12411477 parent: Option<NodeId>,
12421478 file: FileId,
12431479 span: Span, // bytes in `file`: title line through subtree
@@ -1246,17 +1482,24 @@ pub struct Node {
12461482
12471483pub struct Block {
12481484 id: Option<Id>, // None only for root.md
1249− name: String, // slug of the title at last rename
1485+ name: String, // slug of the title when made (or at the last `check --fix`)
12501486 path: RelPath, // e.g. "racfer~order-new-switch.md"
12511487 props: IndexMap<String, String>, // top-level `key: value` lines, order-preserving; includes `id`
12521488 frontmatter_raw: String, // verbatim, for lossless rewrite
12531489 edge_span: Span, // the embed line in the parent file
12541490}
12551491
1492+pub enum Content { Text(Span), Node(NodeId) } // `(Text | Item)* Section*` (§3.1)
1493+
12561494pub struct Span { start: usize, end: usize }
12571495
12581496pub struct Tree { nodes: Vec<Node>, root: NodeId, files: Vec<FileState> }
12591497pub struct Cursor { path: Vec<NodeId> } // zipper-style focus; all outline verbs are cursor ops
1498+pub enum NodeKey { // survives re-parses (§3.4)
1499+ Root,
1500+ Id(Id),
1501+ Path { block: Option<Id>, steps: Vec<(String, usize)> }, // title + ordinal among same-titled siblings
1502+}
12601503```
12611504
12621505Levels and indents are computed from ancestry, never stored.
@@ -1272,16 +1515,17 @@ exact spans, lossless round-tripping, and a small canonical subset — not a ful
12721515`pulldown-cmark` may be used later for inline styling in the reading pane only.
12731516
12741517Reading tolerance (setext headings, `*` bullets, tabs, `[X]`, `[-]`) is handled in the lexer and
1275−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.
12761519
12771520### 15.4 Dependencies
12781521
1279−`ratatui`, `crossterm`, `tui-textarea` (built-in editor), `notify` (watcher), `jiff`
1280−(today's date), `clap`, `indexmap`, `nucleo` (fuzzy
1281−filter), `similar` (sequence alignment and diff3 for merge), `blake3`, `tempfile`,
1282−`unicode-normalization`, `unicode-width`, `directories` (XDG),
1283−`getrandom` (64-bit ids; the `@p` syllable tables are a 512-entry constant in `notes-core`),
1284−`ariadne` (diagnostics), `proptest` + `insta` (tests).
1522+`ratatui`, `crossterm`, `notify` (watcher), `signal-hook` (§10.6),
1523+`tree-sitter` + `tree-sitter-highlight` and the grammar crates of §10.9 (code highlighting), `jiff`
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.
12851529
12861530### 15.5 Performance targets
12871531
@@ -1297,11 +1541,15 @@ filter), `similar` (sequence alignment and diff3 for merge), `blake3`, `tempfile
12971541
12981542### 15.6 Testing
12991543
1300−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:
13011546
13021547- `parse(render(t, 1, false))` equals `t` re-levelled, for all generated trees `t`.
13031548- `render(parse(s), 1, false) == s` for all canonical blocks `s`, frontmatter included.
13041549- `canonicalize` is idempotent.
1550+- Every verb (`J`/`K`, `>`/`<`, `~`, `p`/`P`, refile, capture, `n`/`N`, make block, merge)
1551+ leaves every node's children satisfying `(text | item)* section*`, keeps every other node's
1552+ parent, and moves no text child it was not asked to move.
13051553- `splice(node, render(node, 1, true))` is a no-op on disk, for any node.
13061554- Every splice writes exactly one file; a save writes one splice per dirty block.
13071555- `render(make_block(n), 1, false)` equals `render(n, 1, false)` plus the generated frontmatter,
@@ -1314,9 +1562,11 @@ of real-world Markdown (Obsidian, Logseq, FSNotes exports) that must parse witho
13141562
13151563### 15.7 Diagnostics
13161564
1317−`notes check` reports, with `ariadne`-rendered source spans: non-canonical syntax, empty
1565+`fold check` reports, file by file: non-canonical syntax, empty
13181566titles, titles containing `/`, broken, duplicate or cyclic embeds, embeds with children in the
1319−parent file, malformed block files, invalid or duplicate `id` keys, ignored `.md` files,
1567+parent file, embeds whose form (bare or heading) does not match their block's spelling,
1568+heading embeds whose level is not the level of their position, text directly after a child
1569+node with no blank line, column-0 lines adopted by a block's root, malformed block files, invalid or duplicate `id` keys, ignored `.md` files,
13201570filenames whose prefix or name no longer match their id or title, frontmatter outside
13211571byte 0, `due` or `done` values that are not ISO dates, and unresolved conflict blocks.
13221572The TUI
@@ -1331,20 +1581,18 @@ gets wrong. What other tools see:
13311581
13321582| Tool | What it sees | Notes |
13331583|---|---|---|
1334−| `rg`, `fd`, Helix | plain text | `rg '\[ \]'` and `rg '^todo: open'` together are the open task list; `rg '^done: 2026-09'` the blocks finished this month; `rg '^due:'` the dated ones |
1335−| Any phone editor | plain text | capture by appending a `- ` line to the inbox block's file; the app adds nothing to it |
1336−| Obsidian, Logseq, FSNotes | mostly readable, structurally wrong | frontmatter as properties and `- [ ]` bullets render; `![[id]]` shows as a broken embed, `## [ ]` as a literal heading, `#######` as a paragraph, indented headings as code. Fine for reading a file; do not edit structure there |
1584+| `rg`, `fd`, Helix | plain text | `rg '\[ \]'` is the open task list; `rg '^done: 2026-09'` the blocks finished this month; `rg '^due:'` the dated ones |
1585+| Any phone editor | plain text | capture by appending a `- ` line to the inbox block's file, at any indent: a column-0 line is adopted by the block's root (§4.9); the app adds nothing to it |
1586+| Obsidian, Logseq, FSNotes | mostly readable, structurally wrong | frontmatter as properties and `- [ ]` bullets render; `![[id]]` shows as a broken embed (a heading embed as a heading holding one), `## [ ]` as a literal heading, `#######` as a paragraph, indented headings as code. Fine for reading a file; do not edit structure there |
13371587
13381588---
13391589
13401590## 17. Non-goals for 1.0
13411591
1342−Encryption; CRDT / operation-log sync; a mobile client (the file format is its contract);
1592+Encryption; CRDT / operation-log sync;
13431593a query language, saved views and `--json` output (the ```` ```query ```` fence is reserved); attachment management beyond ignoring `assets/`; full Markdown
13441594rendering or WYSIWYG; plugins; task recurrence; spaced repetition; whiteboards; multiple
1345−vaults open at once; general transclusion; an external `$EDITOR` handoff (designed:
1346−resolved subtree with frontmatter inline under each block's title, ids included, replaced
1347−wholesale on exit); properties on non-blocks; export or lowering
1595+vaults open at once; general transclusion; an external `$EDITOR` handoff; properties on non-blocks; export or lowering
13481596to standard Markdown (the four non-standard forms of §4.2 are the whole gap); a cancelled
13491597task state; wiki-links between nodes and backlinks
13501598(`[[…]]` is reserved, §4.6); rendering the format correctly in third-party editors.
@@ -1355,105 +1603,81 @@ task state; wiki-links between nodes and backlinks
13551603
13561604| # | Deliverable | Exit criterion |
13571605|---|---|---|
1358−| 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 |
13591607| M1 | TUI: outline + reading panes, zoom, fold, move, promote/demote, refile, filter box, subtree editing (`e`), undo | daily-drivable on a single `root.md` |
13601608| 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 |
1361−| M3 | Tasks: toggling on any node, `todo:` and `done:` stamping, dates, clear-done, filter box | replaces a TaskPaper file |
1609+| M3 | Tasks: toggling on any node, `done:` stamping, dates, clear-done, filter box | replaces a TaskPaper file |
13621610| M4 | Inbox and capture; watcher and reload | safe to edit in Helix and the TUI at once |
1363−| 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 |
13641612| M6 | CLI polish, diagnostics, docs | 1.0 |
13651613
13661614---
13671615
1368−## 19. Open decisions
1616+## 19. Decisions
13691617
1370−1. **Name.** Binary `notes` is a placeholder.
1618+1. **Name.** `fold`: the binary, the crate prefix and the state directory.
137116192. **Dated inbox, ISO titles, fixed.** Capture creates `## YYYY-MM-DD` under `Inbox`. There
13721620 is no format choice and no way to turn it off except `--to`. Days append oldest-first; a
13731621 long-lived inbox is a long section, which is what `s` (make block) and *clear done* are for.
1374−3. **Filenames follow titles, always.** A title change is a file rename (Syncthing handles
1375− renames by block reuse, so cheap but visible) and nothing else, since the id lives in the
1376− file. The id prefix stays in front through renames, so it is the one part of a filename
1377− that never moves.
1378−3b. **Id format.** Four-word `@p` names (64 bits). Two words (32 bits) were enough for
1379− identity but left the one-word filename prefix with only 16 bits of room to grow; four
1380− words make the id collision-free in practice and give the prefix three words of slack.
1381− Chosen for being pronounceable, typeable from memory, and self-validating against the
1382− syllable tables; nobody is expected to type all four.
1383−4. **Task sections.** Any node can be a task, so a project heading can be checked off. The
1622+3. **Filenames are named once.** A title change does not rename the file: renames sync as
1623+ delete-plus-create and race with edits on other devices. The id prefix identifies the
1624+ file; the name is a hint that `fold check --fix` refreshes on request.
1625+4. **Id format.** Four-word `@p` names (64 bits): collision-free in practice, with three words
1626+ of slack for the filename prefix to grow into. Pronounceable, typeable from memory, and
1627+ self-validating against the syllable tables; nobody is expected to type all four.
1628+5. **Task sections.** Any node can be a task, so a project heading can be checked off. The
13841629 derived count is still shown beside it; there is no roll-up in either direction (§8.3).
1385−4b. **Two spellings of task state.** A checkbox for nodes that have no file, `todo:` for
1386− nodes that do. One mechanism was considered twice: all-frontmatter would make every task
1387− a file, all-checkbox puts structure on a title line the frontmatter already describes.
1388− The split costs one conversion when a block is made and nothing else.
1389−5. **Two task states.** Open and done. Cancelled was removed: it was a third value in every
1390− table, a second date key, and a key binding, for a distinction between "done" and
1391− "won't do" that a deleted line or a done line expresses well enough. `[-]` is read as
1392− done for old vaults.
1393−6. **No un-block.** Earlier drafts had an inverse of making a block that dropped the frontmatter. Removed:
1394− a verb whose defining feature is discarding data is a footgun, and the manual route
1395− (yank, paste, delete) makes the loss explicit. Revisit only if vaults fill with
1396− blocks nobody wanted.
1397−7. **No export.** `notes render` and the `--plain` lowering were removed for 1.0. The
1398− resolved render is now only what the reading pane reads; a standalone-Markdown exporter
1399− is a later feature with a known scope (§4.2's four forms).
1400−8. **No fuzzy matching in merge.** A node retitled on one device and edited on another
1401− comes out of a merge as two nodes. The fuzzy title match that would have paired them
1402− needed a tunable threshold and could pair the wrong nodes; a duplicate is cheaper to
1403− fix than a wrong pairing, so the step was removed.
1404−8b. **Two-way merge, no cache.** Shadow copies would have made sync merges three-way and
1405− resolved most differences automatically; they were the only per-device state and were
1406− removed with it (§11.3). Sync conflicts now produce a conflict pair for every differing
1407− field. The mitigation is structural: keep concurrently-edited things in separate files
1408− (§12.1), so conflicts are small and rare. If they turn out to be neither, a per-device
1409− base is the thing to bring back.
1410−9. **Text is edited as text, properties as a form.** Earlier drafts had a title editor and
1411− a block editor beside an external-editor handoff; those collapsed into one subtree edit,
1412− and the external handoff was then dropped from 1.0 (§17). Properties briefly appeared in
1413− the text as `---` blocks and were taken out again: frontmatter is a storage format, and
1414− showing it would surface files. The property editor is the one non-text edit. The
1415− built-in editor is a text box; if it wants to become a Markdown editor, stop.
1416−10. **Inbox by title.** Capture finds the inbox by the title `Inbox`. Renaming it means the
1417− next capture creates a new one. A hidden marker property was the alternative and would
1418− have been the last setting.
1419−10b. **No config file.** Seven presentation keys, each already available from the environment
1420− or replaceable by a key binding, were not worth a file format, a lookup order, and a
1421− `notes config` command. If a real per-device need appears (a colour-blind palette, say)
1422− it can come back as one environment variable.
1423−11. **Windows.** Slugs and atomic writes are Windows-safe by design; nothing else is tested.
1424−12. **No links in 1.0.** Storing links by id, displaying the live title, and rewriting labels
1425− on rename were all designed and then cut for the first implementation. Embeds already
1426− prove the id machinery; links can return on top of it without changing the file format,
1427− which is why `[[…]]` is reserved rather than free.
1428−13. **No per-node timestamps.** Title-line annotations (`@done(…)`, `@created(…)`) were
1429− tried in 0.15 and removed: one metadata mechanism is worth more than inline completion
1430− dates. An inline task that needs a date becomes a block; the day section is the
1431− only creation stamp anything else gets.
1432−13b. **Flat vault.** `--as dir/` was removed; the app never creates a directory and ignores
1433− any it finds. Hierarchy is embeds, and one place for files keeps prefixes unique
1434− vault-wide with no per-directory rule.
1435−14. **Prefix collisions.** Two blocks whose ids share a first word are `racfer~notes.md`
1630+6. **One spelling of task state.** The checkbox, for every node, so `rg '\[ \]'` finds every
1631+ open task and making a block never converts anything. Frontmatter holds only what a
1632+ checkbox cannot: `done:`.
1633+7. **Two task states.** Open and done. "Won't do" is a deleted line or a done line; a third
1634+ state would cost a value in every table, a second date key and a key binding. `[-]` is
1635+ read as done.
1636+8. **No un-block.** There is no inverse of making a block: it would have to discard the
1637+ frontmatter, and a verb whose defining feature is discarding data is a footgun. The
1638+ manual route (yank, paste, delete) makes the loss explicit.
1639+9. **No export.** The resolved render is what the reading pane shows; a standalone-Markdown
1640+ exporter is future work with a known scope (§4.2's four forms).
1641+10. **No fuzzy matching in merge.** A node retitled on one device and edited on another
1642+ comes out of a merge as two nodes. A fuzzy title match would need a tunable threshold and
1643+ could pair the wrong nodes; a duplicate is cheaper to fix than a wrong pairing.
1644+11. **Two-way merge, no cache.** A per-device base copy would make sync merges three-way,
1645+ but it would be the only per-device state (§11.3). Sync conflicts therefore produce a
1646+ conflict pair for every differing field; the mitigation is structural — keep
1647+ concurrently-edited things in separate files (§12.1), so conflicts are small and rare.
1648+12. **Text is edited as text, properties as a form.** One subtree edit in a text box covers
1649+ titles, bodies and structure. Frontmatter is a storage format, and showing it would
1650+ surface files, so the property editor is the one non-text edit. The built-in editor is a
1651+ text box; if it wants to become a Markdown editor, stop.
1652+13. **Inbox by title.** Capture finds the inbox by the title `Inbox`. Renaming it means the
1653+ next capture creates a new one. A hidden marker property would be the only setting.
1654+14. **No config file.** Presentation choices are fixed or session toggles; a config format, a
1655+ lookup order and a `fold config` command are not worth it. A real per-device need is
1656+ one environment variable: the editor's keymap is `$FOLD_KEYS` (§10.6), and a
1657+ colour-blind palette, say, would be another.
1658+15. **Windows.** Slugs and atomic writes are Windows-safe by design; nothing else is tested.
1659+16. **No links or query language in 1.0.** Embeds prove the id machinery, and links can be
1660+ built on it without changing the file format, which is why `[[…]]` is reserved rather
1661+ than free. The likely query shape is "everything is a key" (`due:..today`, text is
1662+ literal, `-` negates, `..` ranges); until then the filter box is the only search and the
1663+ `query` fence is reserved.
1664+17. **No per-node timestamps.** One metadata mechanism is worth more than inline annotations
1665+ such as `@done(…)`. An inline task that needs a date becomes a block; the day section is
1666+ the only creation stamp anything else gets.
1667+18. **Flat vault.** The app never creates a directory and ignores any it finds. Hierarchy is
1668+ embeds, and one place for files keeps prefixes unique vault-wide with no per-directory
1669+ rule.
1670+19. **Prefix collisions.** Two blocks whose ids share a first word are `racfer~notes.md`
14361671 and `racfer-wolsun~order-new-switch.md`, regardless of their titles; the prefix grows only
1437− as far as needed and never shrinks (§6.4). Uniqueness is judged on the prefix alone, not
1438− the whole filename, so the prefix is a complete address and titles can change freely.
1439− The cost is that roughly one file in 256 needs a second word from day one; that was
1440− judged cheaper than a rule that depends on titles. The full id was rejected as too long
1441− for a name, the bare slug as too collision-prone across devices, and a numeric `-2` as
1442− meaningless.
1443−17. **Ownership travels with lines.** Three other models were tried: distributing a
1444− multi-file edit back by re-matching nodes (needs heuristics and a prompt when they
1445− fail), showing nested blocks as placeholders (leaks files into the text), and locking
1446− every block but one (a seam the user has to work around). Tagging each buffer line with
1447− its block and saving blocks as the cursor leaves them needs no matching, shows no seam,
1448− and still writes each file from its own text. An external editor cannot carry tags;
1449− the design for one (frontmatter inline, ids included, subtree replaced on exit) is
1450− parked in §17.
1451−16. **No query language in 1.0.** Several syntaxes were sketched — boolean with `@key`, a
1452− search box with `is:` and `key:value`, and "everything is a key" (`todo:open
1453− due:..today`, text is literal, `-` negates, `..` ranges). The last is the likely
1454− shape when it returns. Until then the filter box is the only search and the `query`
1455− fence is reserved.
1456−15. **Third-party editors are readers, not editors.** Once the format owns syntax, Obsidian
1457− and friends stop being a safe place to restructure a vault. Editing a body paragraph
1458− there is still fine; moving headings is not. This is the price of §1 principle 2 and is
1459− accepted.
1672+ as far as needed and never shrinks (§6.4). Uniqueness is judged on the prefix alone, so
1673+ the prefix is a complete address and titles can change freely. Roughly one file in 256
1674+ needs a second word from day one, which is cheaper than any rule that depends on titles;
1675+ the full id is too long for a name, the bare slug too collision-prone across devices, and
1676+ a numeric `-2` meaningless.
1677+20. **Ownership travels with lines.** Each editor line is tagged with its block, and blocks
1678+ are saved as the cursor leaves them. That needs no re-matching of nodes after an edit,
1679+ shows no seam between files, and still writes each file from its own text. An external
1680+ editor cannot carry the tags, so there is none in 1.0 (§17).
1681+21. **Third-party editors are readers, not editors.** Once the format owns syntax, Obsidian
1682+ and friends are not a safe place to restructure a vault. Editing a paragraph there is
1683+ fine; moving headings is not. This is the price of §1 principle 2 and is accepted.
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+49−13
@@ -13,6 +13,11 @@ struct Cli {
1313 #[arg(long, global = true)]
1414 vault: Option<PathBuf>,
1515
16+ /// The editor's keymap: normal (micro-like), vim or helix (default:
17+ /// $FOLD_KEYS, else normal).
18+ #[arg(long, value_name = "KEYMAP")]
19+ keys: Option<String>,
20+
1621 #[command(subcommand)]
1722 command: Option<Command>,
1823}
@@ -86,10 +91,16 @@ fn directories_home() -> PathBuf {
8691fn main() -> anyhow::Result<()> {
8792 let cli = Cli::parse();
8893 let dir = vault_dir(&cli.vault);
94+ // only the TUI sets a vault up (§13); a mistyped --vault is an error
95+ // for every other command rather than a new directory
96+ let needs_vault = !matches!(cli.command, None | Some(Command::Help) | Some(Command::Trash { .. }));
97+ if needs_vault && !dir.is_dir() {
98+ anyhow::bail!("no vault at {}", dir.display());
99+ }
89100 match cli.command {
90101 None => {
91102 // open the TUI (§13)
92− fold_tui::run(&dir)?;
103+ fold_tui::run(&dir, cli.keys.as_deref())?;
93104 }
94105 Some(Command::Capture { text, to, task }) => {
95106 let mut text = match text {
@@ -160,22 +171,47 @@ fn main() -> anyhow::Result<()> {
160171 1 => {
161172 let e = matches.remove(0);
162173 let name = e.file_name().to_string_lossy().to_string();
163− // strip the <timestamp>- prefix
164− let restored = name
165− .splitn(2, '-')
166− .nth(1)
167− .map(|s| s.splitn(2, |c: char| c.is_ascii_digit()).last().unwrap_or(s))
168− .unwrap_or(&name)
169− .to_string();
170− // simpler: strip "yyyymmdd-hhmmss-"
174+ // strip the `<yyyymmdd-hhmmss>-` stamp (§11.5)
171175 let restored = if name.len() > 16 && name.as_bytes()[8] == b'-' && name.as_bytes()[15] == b'-' {
172176 name[16..].to_string()
173177 } else {
174− restored
178+ name.clone()
179+ };
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+ }
200+ if !dir.is_dir() {
201+ anyhow::bail!("no vault at {}", dir.display());
202+ }
203+ // never restore over an existing file, root.md least of all
204+ let target = if restored != "root.md" && !dir.join(&restored).exists() {
205+ restored.clone()
206+ } else {
207+ let stem = restored.strip_suffix(".md").unwrap_or(&restored);
208+ (2..)
209+ .map(|i| format!("{}-restored-{}.md", stem, i))
210+ .find(|n| !dir.join(n).exists())
211+ .unwrap()
175212 };
176− std::fs::create_dir_all(&dir)?;
177− std::fs::rename(e.path(), dir.join(&restored))?;
178− println!("restored {}", restored);
213+ fold_core::vault::move_file(&e.path(), &dir.join(&target))?;
214+ println!("restored {}", target);
179215 }
180216 _ => anyhow::bail!("{:?} matches {} trash entries", id, matches.len()),
181217 }
crates/fold-cli/tests/cli.rs+442−8
@@ -78,6 +78,8 @@ fn check_reports_and_fix_canonicalizes() {
7878#[test]
7979fn merge_processes_conflict_files() {
8080 let dir = tempfile::tempdir().unwrap();
81+ // the merged conflict file goes to a trash of the test's own (§11.5)
82+ let state = tempfile::tempdir().unwrap();
8183 std::fs::write(dir.path().join("root.md"), "# A\n\n- ours\n").unwrap();
8284 std::fs::write(
8385 dir.path().join("root.sync-conflict-20260912-100000-phone.md"),
@@ -85,6 +87,7 @@ fn merge_processes_conflict_files() {
8587 )
8688 .unwrap();
8789 notes(dir.path())
90+ .env("XDG_STATE_HOME", state.path())
8891 .args(["merge"])
8992 .assert()
9093 .success()
@@ -96,21 +99,23 @@ fn merge_processes_conflict_files() {
9699#[test]
97100fn trash_list_and_restore() {
98101 let dir = tempfile::tempdir().unwrap();
99− // put something in the trash via a delete through the core
100− std::fs::write(dir.path().join("root.md"), "# A\n\n- doomed\n").unwrap();
101− {
102− let mut v = fold_core::vault::Vault::open(dir.path()).unwrap();
103− let a = v.tree.resolved_children(v.tree.root)[0];
104− let node = v.tree.resolved_children(a)[0];
105− fold_core::ops::delete_subtree(&mut v, node).unwrap();
106− }
102+ // a trash of the test's own (§11.5), not the machine's, where an entry
103+ // left by an earlier run would make "doomed" match twice
104+ let state = tempfile::tempdir().unwrap();
105+ std::fs::write(dir.path().join("root.md"), "# A\n").unwrap();
106+ // a deleted subtree, as a delete leaves it in the trash
107+ let trash = state.path().join("fold").join("trash");
108+ std::fs::create_dir_all(&trash).unwrap();
109+ std::fs::write(trash.join("20260926-101010-doomed.md"), "- doomed\n").unwrap();
107110 notes(dir.path())
111+ .env("XDG_STATE_HOME", state.path())
108112 .args(["trash", "list"])
109113 .assert()
110114 .success()
111115 .stdout(predicate::str::contains("doomed"));
112116 // restore by a unique substring of the trash file name
113117 notes(dir.path())
118+ .env("XDG_STATE_HOME", state.path())
114119 .args(["trash", "restore", "doomed"])
115120 .assert()
116121 .success();
@@ -131,3 +136,432 @@ fn help_prints_usage() {
131136 .stdout(predicate::str::contains("command palette"))
132137 .stdout(predicate::str::contains("make block"));
133138}
139+
140+#[test]
141+fn missing_vault_is_an_error_not_a_new_directory() {
142+ let dir = tempfile::tempdir().unwrap();
143+ let typo = dir.path().join("typo");
144+ notes(&typo).arg("check").assert().failure();
145+ assert!(!typo.exists());
146+}
147+
148+#[test]
149+fn trash_restore_never_overwrites_root_md() {
150+ let dir = tempfile::tempdir().unwrap();
151+ let state = tempfile::tempdir().unwrap();
152+ std::fs::write(dir.path().join("root.md"), "# Keep\n").unwrap();
153+ let trash = state.path().join("fold").join("trash");
154+ std::fs::create_dir_all(&trash).unwrap();
155+ std::fs::write(trash.join("20260926-101010-root.md"), "# Root\n").unwrap();
156+ notes(dir.path())
157+ .env("XDG_STATE_HOME", state.path())
158+ .args(["trash", "restore", "root"])
159+ .assert()
160+ .success();
161+ assert_eq!(std::fs::read_to_string(dir.path().join("root.md")).unwrap(), "# Keep\n");
162+ assert!(dir.path().join("root-restored-2.md").exists());
163+}
164+
165+/// Text the editor could not save (§10.6) is kept in the trash as
166+/// `unsaved-<title>.md`: no file the vault reads, as it has no id. Restore
167+/// gives the text back and leaves the entry where it is, where moving it
168+/// in would leave a file fold ignores and Syncthing spreads.
169+#[test]
170+fn trash_restore_prints_unsaved_text_and_leaves_it_in_the_trash() {
171+ let dir = tempfile::tempdir().unwrap();
172+ let state = tempfile::tempdir().unwrap();
173+ std::fs::write(dir.path().join("root.md"), "# NAS\n\nMirrored pairs.\n").unwrap();
174+ let trash = state.path().join("fold").join("trash");
175+ std::fs::create_dir_all(&trash).unwrap();
176+ let entry = trash.join("20260927-055310-unsaved-nas.md");
177+ let text = "# NAS TYPED-A\n\nMirrored pairs.\n";
178+ std::fs::write(&entry, text).unwrap();
179+ notes(dir.path())
180+ .env("XDG_STATE_HOME", state.path())
181+ .args(["trash", "restore", "20260927-055310-unsaved-nas"])
182+ .assert()
183+ .success()
184+ .stdout(text)
185+ .stderr(predicate::str::contains(entry.display().to_string()));
186+ assert_eq!(std::fs::read_to_string(&entry).unwrap(), text);
187+ let files: Vec<_> = dir.path().read_dir().unwrap().map(|e| e.unwrap().file_name()).collect();
188+ assert_eq!(files, ["root.md"]);
189+ notes(dir.path())
190+ .env("XDG_STATE_HOME", state.path())
191+ .arg("check")
192+ .assert()
193+ .success();
194+}
195+
196+/// A block's filename is decoration (§3.4): one renamed `unsaved-ideas.md`
197+/// and then deleted is still a block file in the trash, with its id, and
198+/// restore moves it back like any other, not printed as editor text.
199+#[test]
200+fn trash_restore_moves_back_a_block_file_named_unsaved() {
201+ let dir = tempfile::tempdir().unwrap();
202+ let state = tempfile::tempdir().unwrap();
203+ std::fs::write(dir.path().join("root.md"), "# Keep\n").unwrap();
204+ let trash = state.path().join("fold").join("trash");
205+ std::fs::create_dir_all(&trash).unwrap();
206+ let entry = trash.join("20260927-114734-unsaved-ideas.md");
207+ let text = "---\nid: racfer-hattes-mislup-nodrys\n---\n\n# Ideas\n\n- a shed\n";
208+ std::fs::write(&entry, text).unwrap();
209+ notes(dir.path())
210+ .env("XDG_STATE_HOME", state.path())
211+ .args(["trash", "restore", "unsaved-ideas"])
212+ .assert()
213+ .success()
214+ .stdout("restored unsaved-ideas.md\n");
215+ assert!(!entry.exists());
216+ assert_eq!(std::fs::read_to_string(dir.path().join("unsaved-ideas.md")).unwrap(), text);
217+}
218+
219+/// The trash lives outside the vault (§11.5), often on another filesystem
220+/// (a Syncthing folder on an external disk vs `$XDG_STATE_HOME`). Merging
221+/// must still move the conflict file to trash (copy + remove across
222+/// filesystems), or every rerun merges the same conflict again and adds
223+/// another duplicate conflict block.
224+#[test]
225+#[cfg(target_os = "linux")]
226+fn merge_trashes_conflict_file_across_filesystems() {
227+ use std::os::unix::fs::MetadataExt;
228+ let dir = tempfile::tempdir().unwrap();
229+ let Ok(state) = tempfile::tempdir_in("/dev/shm") else {
230+ return;
231+ };
232+ let dev = |p: &std::path::Path| std::fs::metadata(p).unwrap().dev();
233+ if dev(dir.path()) == dev(state.path()) {
234+ return; // not a cross-filesystem setup on this machine
235+ }
236+ std::fs::write(dir.path().join("root.md"), "# A\n\n- [ ] t\n").unwrap();
237+ let c = "root.sync-conflict-20260912-100000-phone.md";
238+ std::fs::write(dir.path().join(c), "# A\n\n- [x] t\n").unwrap();
239+ notes(dir.path())
240+ .env("XDG_STATE_HOME", state.path())
241+ .arg("merge")
242+ .assert()
243+ .success();
244+ assert!(!dir.path().join(c).exists(), "conflict file left in the vault");
245+ let trash = state.path().join("fold").join("trash");
246+ assert_eq!(std::fs::read_dir(&trash).unwrap().count(), 1);
247+ // idempotent: a second run finds nothing to merge and adds no blocks
248+ let md_files = |d: &std::path::Path| {
249+ std::fs::read_dir(d)
250+ .unwrap()
251+ .filter(|e| e.as_ref().unwrap().path().extension().is_some_and(|x| x == "md"))
252+ .count()
253+ };
254+ let before = md_files(dir.path());
255+ notes(dir.path())
256+ .env("XDG_STATE_HOME", state.path())
257+ .arg("merge")
258+ .assert()
259+ .success()
260+ .stdout(predicate::str::contains("no sync-conflict files"));
261+ assert_eq!(md_files(dir.path()), before);
262+}
263+
264+// ------------------------------------------------------------ the TUI in a terminal
265+
266+/// `fold` in a terminal of its own, the one util-linux `script` gives it:
267+/// what it writes there, keys typed into it, and its pid.
268+#[cfg(target_os = "linux")]
269+struct Tty {
270+ script: std::process::Child,
271+ keys: std::process::ChildStdin,
272+ out: std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
273+ /// reads what script passes on; done once script closes its output
274+ reader: std::thread::JoinHandle<()>,
275+ /// while set, the reader reads nothing (`stall`)
276+ stalled: std::sync::Arc<std::sync::atomic::AtomicBool>,
277+ pid: u32,
278+}
279+
280+/// Poll `f` for up to 10 s until it gives something.
281+#[cfg(target_os = "linux")]
282+fn until<T>(mut f: impl FnMut() -> Option<T>) -> Option<T> {
283+ let end = std::time::Instant::now() + std::time::Duration::from_secs(10);
284+ loop {
285+ if let Some(t) = f() {
286+ return Some(t);
287+ }
288+ if std::time::Instant::now() > end {
289+ return None;
290+ }
291+ std::thread::sleep(std::time::Duration::from_millis(20));
292+ }
293+}
294+
295+#[cfg(target_os = "linux")]
296+impl Tty {
297+ /// fold on `vault`, with the normal keymap, in a 120×32 terminal, its
298+ /// trash under `state`; None where there is no `script` to run it in.
299+ fn start(vault: &std::path::Path, state: &std::path::Path) -> Option<Tty> {
300+ use std::io::Read;
301+ use std::process::Stdio;
302+ let pidfile = state.join("fold.pid");
303+ let cmd = format!(
304+ "stty cols 120 rows 32; echo $$ > '{}'; exec '{}' --keys normal --vault '{}'",
305+ pidfile.display(),
306+ env!("CARGO_BIN_EXE_fold"),
307+ vault.display()
308+ );
309+ let script = std::process::Command::new("script")
310+ .args(["-qfec", &cmd, "/dev/null"])
311+ .env("SHELL", "/bin/sh")
312+ .env("TERM", "xterm-256color")
313+ .env("XDG_STATE_HOME", state)
314+ .stdin(Stdio::piped())
315+ .stdout(Stdio::piped())
316+ .stderr(Stdio::null())
317+ .spawn();
318+ let Ok(mut script) = script else {
319+ eprintln!("skipped: no script(1) to give fold a terminal");
320+ return None;
321+ };
322+ let keys = script.stdin.take().unwrap();
323+ let mut stdout = script.stdout.take().unwrap();
324+ let out = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
325+ let sink = out.clone();
326+ let stalled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
327+ let held = stalled.clone();
328+ let reader = std::thread::spawn(move || {
329+ let mut buf = [0; 4096];
330+ loop {
331+ // a stalled terminal keeps its end open and reads nothing
332+ while held.load(std::sync::atomic::Ordering::Relaxed) {
333+ std::thread::sleep(std::time::Duration::from_millis(20));
334+ }
335+ let Ok(n @ 1..) = stdout.read(&mut buf) else { return };
336+ sink.lock().unwrap().extend_from_slice(&buf[..n]);
337+ }
338+ });
339+ let pid = until(|| std::fs::read_to_string(&pidfile).ok()?.trim().parse().ok());
340+ let Some(pid) = pid else {
341+ let _ = script.kill();
342+ eprintln!("skipped: script(1) ran no shell");
343+ return None;
344+ };
345+ Some(Tty { script, keys, out, reader, stalled, pid })
346+ }
347+
348+ fn output(&self) -> String {
349+ String::from_utf8_lossy(&self.out.lock().unwrap()).to_string()
350+ }
351+
352+ /// The last of the output, for a failure's message.
353+ fn tail(&self) -> String {
354+ let out = self.output();
355+ let from = out.char_indices().rev().nth(600).map_or(0, |(i, _)| i);
356+ format!("{:?}", &out[from..])
357+ }
358+
359+ /// Whether fold draws `what` within 10 s.
360+ fn shows(&self, what: &str) -> bool {
361+ until(|| self.output().contains(what).then_some(())).is_some()
362+ }
363+
364+ fn type_keys(&mut self, bytes: &str) {
365+ use std::io::Write;
366+ self.keys.write_all(bytes.as_bytes()).unwrap();
367+ self.keys.flush().unwrap();
368+ }
369+
370+ /// Stop reading what fold writes, as a terminal that has stalled (an
371+ /// ssh session gone quiet): once the pipes fill, fold's writes wait.
372+ fn stall(&self) {
373+ self.stalled.store(true, std::sync::atomic::Ordering::Relaxed);
374+ }
375+
376+ /// The syscall fold's main thread is in, by number; None where
377+ /// /proc does not say.
378+ fn syscall(&self) -> Option<String> {
379+ let s = std::fs::read_to_string(format!("/proc/{}/syscall", self.pid)).ok()?;
380+ Some(s.split(' ').next()?.trim().to_string())
381+ }
382+
383+ fn signal(&self, sig: &str) {
384+ let pid = self.pid.to_string();
385+ std::process::Command::new("kill").args(["-s", sig, &pid]).status().unwrap();
386+ }
387+
388+ /// Whether fold has exited: reaped, or a zombie no one has reaped yet.
389+ fn exited(&self) -> bool {
390+ let s = std::fs::read_to_string(format!("/proc/{}/stat", self.pid)).unwrap_or_default();
391+ let state = s.rsplit(") ").next().and_then(|r| r.chars().next());
392+ matches!(state, None | Some('Z'))
393+ }
394+
395+ /// Whether fold is gone within 10 s (reaped, or a zombie no one reaps)
396+ /// and all it wrote has come through: its last bytes can wait in the
397+ /// terminal after it exits, until script passes them on and closes its
398+ /// output.
399+ fn ended(&self) -> bool {
400+ until(|| (self.exited() && self.reader.is_finished()).then_some(())).is_some()
401+ }
402+
403+ /// Open the editor on the first node, and paste ` MYTEXT` at the end of
404+ /// its title line.
405+ fn edit(&mut self) {
406+ assert!(self.shows("Snapshot"), "fold drew nothing:\n{}", self.tail());
407+ self.type_keys("e");
408+ assert!(self.shows("EDIT"), "no editor:\n{}", self.tail());
409+ // End, then a bracketed paste
410+ self.type_keys("\x1b[F\x1b[200~ MYTEXT\x1b[201~");
411+ assert!(self.shows("MYTEXT"), "nothing typed:\n{}", self.tail());
412+ }
413+}
414+
415+#[cfg(target_os = "linux")]
416+impl Drop for Tty {
417+ fn drop(&mut self) {
418+ let _ = std::process::Command::new("kill")
419+ .args(["-9", &self.pid.to_string()])
420+ .stderr(std::process::Stdio::null())
421+ .status();
422+ let _ = self.script.kill();
423+ let _ = self.script.wait();
424+ // the reader reads to the end and is done
425+ self.stalled.store(false, std::sync::atomic::Ordering::Relaxed);
426+ }
427+}
428+
429+/// A vault named as a real one is (tempfile's own names start with a dot),
430+/// and a state directory for its trash.
431+#[cfg(target_os = "linux")]
432+fn vault_and_state(root: &str) -> (tempfile::TempDir, tempfile::TempDir) {
433+ let vault = tempfile::Builder::new().prefix("vault").tempdir().unwrap();
434+ std::fs::write(vault.path().join("root.md"), root).unwrap();
435+ (vault, tempfile::tempdir().unwrap())
436+}
437+
438+/// Where the output puts the terminal back as it was: out of the
439+/// alternate screen, the mouse and bracketed paste turned off before.
440+#[cfg(target_os = "linux")]
441+fn put_back(out: &str) -> Option<usize> {
442+ let at = out.rfind("\x1b[?1049l")?;
443+ let tail = &out[out.rfind("\x1b[?1049h")?..at];
444+ (tail.contains("\x1b[?1000l") && tail.contains("\x1b[?2004l")).then_some(at)
445+}
446+
447+#[test]
448+#[cfg(target_os = "linux")]
449+fn a_signal_saves_the_editor_and_puts_the_terminal_back() {
450+ let (vault, state) = vault_and_state("# Snapshot policy\n\nkeep 24\n");
451+ let Some(mut tty) = Tty::start(vault.path(), state.path()) else { return };
452+ tty.edit();
453+ tty.signal("TERM");
454+ assert!(tty.ended(), "fold still runs");
455+ let root = std::fs::read_to_string(vault.path().join("root.md")).unwrap();
456+ assert_eq!(root, "# Snapshot policy MYTEXT\n\nkeep 24\n");
457+ assert!(put_back(&tty.output()).is_some(), "terminal left as fold had it:\n{}", tty.tail());
458+}
459+
460+#[test]
461+#[cfg(target_os = "linux")]
462+fn a_signal_keeps_text_a_save_was_refused_for_in_the_trash_and_says_where() {
463+ let (vault, state) = vault_and_state("# Snapshot policy\n\nkeep 24\n");
464+ let Some(mut tty) = Tty::start(vault.path(), state.path()) else { return };
465+ tty.edit();
466+ // another program changes the node under the typing: no save can go
467+ // through
468+ std::fs::write(vault.path().join("root.md"), "# Snapshot policy\n\nkeep 48\n").unwrap();
469+ tty.signal("TERM");
470+ assert!(tty.ended(), "fold still runs");
471+ let root = std::fs::read_to_string(vault.path().join("root.md")).unwrap();
472+ assert_eq!(root, "# Snapshot policy\n\nkeep 48\n");
473+ // said once the terminal is back, so it stays on screen
474+ let out = tty.output();
475+ let back = put_back(&out).unwrap_or_else(|| panic!("terminal left as fold had it:\n{}", tty.tail()));
476+ let said = out[back..].find("unsaved text kept in ").unwrap_or_else(|| panic!("nothing said:\n{}", tty.tail()));
477+ let path = out[back + said..]["unsaved text kept in ".len()..].lines().next().unwrap().trim_end();
478+ assert!(path.starts_with(&state.path().join("fold").join("trash").display().to_string()), "{}", path);
479+ assert!(path.ends_with("-unsaved-snapshot-policy.md"), "{}", path);
480+ assert_eq!(std::fs::read_to_string(path).unwrap(), "# Snapshot policy MYTEXT\n\nkeep 24\n");
481+}
482+
483+#[test]
484+#[cfg(target_os = "linux")]
485+fn a_signal_puts_the_terminal_back_though_script_passes_it_on_late() {
486+ let (vault, state) = vault_and_state("# Snapshot policy\n\nkeep 24\n");
487+ let Some(mut tty) = Tty::start(vault.path(), state.path()) else { return };
488+ tty.edit();
489+ // script(1) falls behind, as on a busy machine: fold's last bytes are
490+ // still in the terminal when fold has gone
491+ let script = tty.script.id().to_string();
492+ let to_script = |sig: &str| {
493+ std::process::Command::new("kill").args(["-s", sig, &script]).status().unwrap();
494+ };
495+ to_script("STOP");
496+ tty.signal("TERM");
497+ std::thread::scope(|s| {
498+ s.spawn(|| {
499+ until(|| tty.exited().then_some(()));
500+ std::thread::sleep(std::time::Duration::from_millis(300));
501+ to_script("CONT");
502+ });
503+ assert!(tty.ended(), "fold still runs");
504+ assert!(put_back(&tty.output()).is_some(), "terminal left as fold had it:\n{}", tty.tail());
505+ });
506+}
507+
508+/// write(2)'s number, as /proc/<pid>/syscall gives it.
509+#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
510+const WRITE: &str = "1";
511+#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
512+const WRITE: &str = "64";
513+
514+#[test]
515+#[cfg(all(target_os = "linux", any(target_arch = "x86_64", target_arch = "aarch64")))]
516+fn a_second_signal_ends_fold_stuck_on_a_terminal_that_reads_nothing() {
517+ let root: String = (0..300).map(|i| format!("- item {} of a long outline that fills the screen\n", i)).collect();
518+ let (vault, state) = vault_and_state(&format!("# Snapshot policy\n\n{}", root));
519+ let Some(mut tty) = Tty::start(vault.path(), state.path()) else { return };
520+ assert!(tty.shows("Snapshot"), "fold drew nothing:\n{}", tty.tail());
521+ if tty.syscall().is_none() {
522+ eprintln!("skipped: /proc does not say what fold waits on");
523+ return;
524+ }
525+ // the terminal stops reading; fold, redrawing the whole outline from
526+ // bottom to top and back, fills it and waits in a write
527+ tty.stall();
528+ tty.type_keys(&"Ggg".repeat(200));
529+ let stuck = until(|| (tty.syscall().as_deref() == Some(WRITE)).then_some(()));
530+ assert!(stuck.is_some(), "fold never waited on the terminal: {:?}", tty.syscall());
531+ // the first signal asks fold to end as on a quit, which it cannot
532+ // while it waits; the second ends it all the same
533+ tty.signal("TERM");
534+ std::thread::sleep(std::time::Duration::from_millis(300));
535+ tty.signal("TERM");
536+ assert!(until(|| tty.exited().then_some(())).is_some(), "fold still runs: {:?}", tty.syscall());
537+}
538+
539+#[test]
540+#[cfg(target_os = "linux")]
541+fn a_sigterm_after_a_sighup_still_saves_the_editor() {
542+ let (vault, state) = vault_and_state("# Snapshot policy\n\nkeep 24\n");
543+ let Some(mut tty) = Tty::start(vault.path(), state.path()) else { return };
544+ tty.edit();
545+ // a SIGHUP and a SIGTERM soon after, before the loop sees the first:
546+ // the SIGTERM is not a second one, which would end fold unsaved
547+ let pid = tty.pid.to_string();
548+ let both = format!("kill -s HUP {0}; sleep 0.05; kill -s TERM {0}", pid);
549+ std::process::Command::new("sh").args(["-c", &both]).status().unwrap();
550+ assert!(tty.ended(), "fold still runs");
551+ let root = std::fs::read_to_string(vault.path().join("root.md")).unwrap();
552+ assert_eq!(root, "# Snapshot policy MYTEXT\n\nkeep 24\n");
553+ assert!(put_back(&tty.output()).is_some(), "terminal left as fold had it:\n{}", tty.tail());
554+}
555+
556+#[test]
557+#[cfg(target_os = "linux")]
558+fn closing_the_window_saves_the_editor() {
559+ let (vault, state) = vault_and_state("# Snapshot policy\n\nkeep 24\n");
560+ let Some(mut tty) = Tty::start(vault.path(), state.path()) else { return };
561+ tty.edit();
562+ // the terminal goes away: fold gets SIGHUP, and nowhere to draw
563+ tty.script.kill().unwrap();
564+ assert!(tty.ended(), "fold still runs");
565+ let root = std::fs::read_to_string(vault.path().join("root.md")).unwrap();
566+ assert_eq!(root, "# Snapshot policy MYTEXT\n\nkeep 24\n");
567+}
crates/fold-core/Cargo.toml−3
@@ -11,9 +11,6 @@ blake3.workspace = true
1111unicode-normalization.workspace = true
1212directories.workspace = true
1313getrandom.workspace = true
14−anyhow.workspace = true
15−thiserror.workspace = true
1614
1715[dev-dependencies]
18−proptest = "1"
1916tempfile = "3"
crates/fold-core/src/check.rs+225−49
@@ -1,7 +1,7 @@
1−//! `notes check` diagnostics (§15.7) and `--fix` canonicalization (§4.2).
1+//! `fold check` diagnostics (§15.7) and `--fix` canonicalization (§4.2).
22
33use crate::ident::{filename, slug, split_filename, Id};
4−use crate::parse::Kind;
4+use crate::parse::{ends_with_blank_line, Content, Kind};
55use crate::render::render;
66use crate::tree::NRef;
77use crate::vault::Vault;
@@ -35,25 +35,37 @@ 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)
47− for f in &t.files {
48− for n in &f.nodes {
41+ for (fi, f) in t.files.iter().enumerate() {
42+ for (ni, n) in f.nodes.iter().enumerate() {
4943 if let Some(id) = &n.embed {
5044 match t.block_by_id(id) {
5145 None => out.push(Diagnostic {
5246 file: f.path.clone(),
5347 message: format!("broken embed: no block with id {}", id),
5448 }),
49+ Some(b) if form_mismatch(n.kind, t.node(b).kind) => out.push(Diagnostic {
50+ file: f.path.clone(),
51+ message: format!(
52+ "embed form does not match the block's spelling: {} (§4.7)",
53+ id
54+ ),
55+ }),
5556 Some(_) => {}
5657 }
58+ if let Some(w) = embed_level_mismatch(t, (fi, ni)) {
59+ out.push(Diagnostic {
60+ file: f.path.clone(),
61+ message: format!(
62+ "heading embed at level {} where its position gives {}: {} (§4.7)",
63+ w,
64+ t.derived_level((fi, ni)),
65+ id
66+ ),
67+ });
68+ }
5769 if !n.children.is_empty() {
5870 out.push(Diagnostic {
5971 file: f.path.clone(),
@@ -75,6 +87,28 @@ pub fn check(vault: &Vault) -> Vec<Diagnostic> {
7587 }
7688 }
7789 }
90+ // cyclic embeds (§6.2): follow each block up through the embed that
91+ // holds it until root.md, a block embedded nowhere, or the block itself
92+ for (r, id) in &t.blocks {
93+ let mut seen: Vec<&Id> = vec![id];
94+ let mut cur = id;
95+ while let Some(e) = t.embed_of(cur) {
96+ // the block whose file holds that embed; none: root.md
97+ let Some((_, holder)) = t.blocks.iter().find(|(b, _)| b.0 == e.0) else { break };
98+ if holder == id {
99+ out.push(Diagnostic {
100+ file: t.node(*r).block.as_ref().unwrap().path.clone(),
101+ message: format!("cyclic embed: block {} is embedded inside itself", id),
102+ });
103+ break;
104+ }
105+ if seen.contains(&holder) {
106+ break; // a cycle above it, reported by the blocks on it
107+ }
108+ seen.push(holder);
109+ cur = holder;
110+ }
111+ }
78112 // block files: exactly one root, valid unique ids (§4.9, §6.2)
79113 let mut seen_ids: Vec<&Id> = Vec::new();
80114 for (r, id) in &t.blocks {
@@ -85,7 +119,6 @@ pub fn check(vault: &Vault) -> Vec<Diagnostic> {
85119 });
86120 }
87121 seen_ids.push(id);
88− let n = t.node(*r);
89122 let f = &t.files[r.0];
90123 let top: Vec<usize> = f.nodes[f.root_node].children.clone();
91124 if top.len() != 1 {
@@ -94,7 +127,6 @@ pub fn check(vault: &Vault) -> Vec<Diagnostic> {
94127 message: "block file must have exactly one node at column 0".into(),
95128 });
96129 }
97− let _ = n;
98130 // filename checks (§6.4)
99131 let b = t.node(*r).block.as_ref().unwrap();
100132 let words = id.words();
@@ -111,7 +143,10 @@ pub fn check(vault: &Vault) -> Vec<Diagnostic> {
111143 if name != want {
112144 out.push(Diagnostic {
113145 file: b.path.clone(),
114− message: format!("filename name {} does not match the title ({})", name, want),
146+ message: format!(
147+ "filename name {} does not match the title ({}): stale, harmless; --fix renames",
148+ name, want
149+ ),
115150 });
116151 }
117152 }
@@ -120,15 +155,6 @@ pub fn check(vault: &Vault) -> Vec<Diagnostic> {
120155 message: "filename is not <prefix>~<name>.md".into(),
121156 }),
122157 }
123− // task key sanity
124− if let Some(todo) = b.prop("todo") {
125− if todo != "open" && todo != "done" {
126− out.push(Diagnostic {
127− file: b.path.clone(),
128− message: format!("todo: must be open or done, got {:?}", todo),
129− });
130− }
131− }
132158 for k in ["due", "done"] {
133159 if let Some(v) = b.prop(k) {
134160 if !is_iso_date(v) {
@@ -176,6 +202,52 @@ pub fn check(vault: &Vault) -> Vec<Diagnostic> {
176202 out
177203}
178204
205+/// A heading embed's written level, when it is not the level its position
206+/// gives (§4.7). Levels are honoured as written, like any heading's, so a
207+/// stale one is reported rather than trusted to mean something.
208+fn embed_level_mismatch(t: &crate::tree::Tree, e: NRef) -> Option<usize> {
209+ let n = t.node(e);
210+ let written = n.level?;
211+ (n.embed.is_some() && n.kind == Kind::Section && written != t.derived_level(e))
212+ .then_some(written)
213+}
214+
215+/// The text of `e`'s file with heading embed `e` at the level of its
216+/// position and the structure unchanged (§4.7). Its later sibling sections
217+/// written deeper than that level move to it too (it is their position's
218+/// level as well): left alone, the shallower embed would take them as its
219+/// children. `None` when `e` has no stale level, or when no level keeps it
220+/// where it is (its parent section is itself written at that level or
221+/// deeper), and `check` goes on reporting it.
222+fn embed_level_fix(t: &crate::tree::Tree, e: NRef) -> Option<String> {
223+ embed_level_mismatch(t, e)?;
224+ let lvl = t.derived_level(e);
225+ let f = &t.files[e.0];
226+ let parent = &f.nodes[f.nodes[e.1].parent?];
227+ if parent.kind == Kind::Section && parent.level.unwrap_or(1) >= lvl {
228+ return None;
229+ }
230+ let from = parent.children.iter().position(|&c| c == e.1)?;
231+ let mut text = f.text.clone();
232+ // last to first, so the spans before each edit stay valid
233+ for &c in parent.children[from..].iter().rev() {
234+ let n = &f.nodes[c];
235+ let Some(w) = n.level.filter(|&w| w > lvl || c == e.1) else { continue };
236+ let line = n.title_span.text(&f.text);
237+ let at = n.title_span.start + line.len() - line.trim_start_matches([' ', '\t']).len();
238+ if !f.text[at..].starts_with(&"#".repeat(w)) {
239+ return None; // not an ATX heading
240+ }
241+ text.replace_range(at..at + w, &"#".repeat(lvl));
242+ }
243+ Some(text)
244+}
245+
246+/// A heading embed must embed a section, a bare one an item (§4.7).
247+fn form_mismatch(embed: Kind, block: Kind) -> bool {
248+ (embed == Kind::Section) != (block == Kind::Section)
249+}
250+
179251pub fn is_iso_date(v: &str) -> bool {
180252 v.len() == 10
181253 && v.as_bytes()[4] == b'-'
@@ -187,54 +259,158 @@ pub fn is_iso_date(v: &str) -> bool {
187259
188260/// Canonicalize every file and repair filenames (§4.2, §6.4, §13 `--fix`).
189261/// Returns the number of files rewritten or renamed.
262+///
263+/// Nothing is dropped: root.md keeps its frontmatter and any text before its
264+/// first node verbatim, embeds stay embeds, and a block file that is
265+/// malformed (§4.9: read-only until fixed by hand) is left alone.
190266pub fn fix(vault: &mut Vault) -> std::io::Result<usize> {
191267 let mut count = 0;
192− // rewrite each block file from its canonical render
268+ // a blank line before every text child that follows a child node (§4.2)
269+ for i in 0..vault.tree.files.len() {
270+ let f = &vault.tree.files[i];
271+ let mut at: Vec<usize> = f
272+ .nodes
273+ .iter()
274+ .flat_map(|n| crate::parse::unseparated_text(n, &f.text))
275+ .collect();
276+ if at.is_empty() {
277+ continue;
278+ }
279+ at.sort_unstable();
280+ let mut text = f.text.clone();
281+ for &p in at.iter().rev() {
282+ text.insert(p, '\n');
283+ }
284+ vault.write_file_text(i, &text)?;
285+ count += 1;
286+ }
193287 for i in 0..vault.tree.files.len() {
194288 let f = &vault.tree.files[i];
289+ let top: Vec<usize> = f.nodes[f.root_node].children.clone();
195290 let canonical = if i == 0 {
196− // root.md: re-emit the whole file's top-level nodes
197− let mut s = String::new();
198− let kids = vault.tree.resolved_children(vault.tree.root);
199− for (idx, k) in kids.iter().enumerate() {
200− if idx > 0 && vault.tree.node(*k).kind == Kind::Section {
201− s.push('\n');
291+ let Some(&first) = top.first() else { continue };
292+ // frontmatter and intro text, exactly as found; then the root's
293+ // children in order — nodes rendered, text children verbatim,
294+ // one blank line between a node and what follows it unless both
295+ // are items of a tight list (§4.2); a line is blank whatever its
296+ // line ending, so a CRLF file keeps its loose lists
297+ let mut s = f.text[..f.nodes[first].span.start].to_string();
298+ let root = &f.nodes[f.root_node];
299+ let from = root
300+ .content
301+ .iter()
302+ .position(|c| *c == Content::Node(first))
303+ .unwrap_or(0);
304+ let mut prev: Option<usize> = None;
305+ for c in &root.content[from..] {
306+ let loose_after_prev = prev.map(|p| {
307+ let pn = &f.nodes[p];
308+ pn.kind == Kind::Section || ends_with_blank_line(pn.span.text(&f.text))
309+ });
310+ match *c {
311+ Content::Node(k) => {
312+ let kn = &f.nodes[k];
313+ let blank = match loose_after_prev {
314+ Some(loose) => loose || kn.kind == Kind::Section,
315+ None => !s.is_empty() && !ends_with_blank_line(&s),
316+ };
317+ if blank && !ends_with_blank_line(&s) {
318+ s.push('\n');
319+ }
320+ s.push_str(&render(&vault.tree, (i, k), 1, false));
321+ prev = Some(k);
322+ }
323+ Content::Text(sp) => {
324+ if prev.is_some() && !ends_with_blank_line(&s) {
325+ s.push('\n');
326+ }
327+ let t = sp.text(&f.text);
328+ s.push_str(t.trim_end_matches('\n'));
329+ s.push('\n');
330+ prev = None;
331+ }
202332 }
203− s.push_str(&render(&vault.tree, *k, 1, false));
204333 }
205334 s
206335 } else {
207− let root_node = f.nodes[f.root_node].children[0];
208− render(&vault.tree, (i, root_node), 1, false)
336+ if f.malformed_block() {
337+ continue;
338+ }
339+ render(&vault.tree, (i, top[0]), 1, false)
209340 };
210341 if canonical != f.text {
211342 vault.write_file_text(i, &canonical)?;
212343 count += 1;
213344 }
214345 }
215− // repair filenames (§6.4)
216− let renames: Vec<(usize, String)> = vault
346+ // embeds in the form of their block's spelling (§4.7), one at a time:
347+ // each respell may move an embed and re-parse its file
348+ for _ in 0..vault.tree.blocks.len() {
349+ let t = &vault.tree;
350+ let wrong = t.blocks.iter().find_map(|(b, id)| {
351+ let e = t.embed_of(id)?;
352+ form_mismatch(t.node(e).kind, t.node(*b).kind)
353+ .then(|| (e, t.node(*b).kind == Kind::Section))
354+ });
355+ let Some((e, to_section)) = wrong else { break };
356+ crate::ops::respell_embed(vault, e, to_section)?;
357+ count += 1;
358+ }
359+ // heading embeds at the level of their position (§4.7), one at a time,
360+ // with no line changing parent
361+ for _ in 0..10_000 {
362+ let t = &vault.tree;
363+ let fixed = t.files.iter().enumerate().find_map(|(fi, f)| {
364+ (0..f.nodes.len()).find_map(|ni| embed_level_fix(t, (fi, ni)).map(|text| (fi, text)))
365+ });
366+ let Some((file, text)) = fixed else { break };
367+ vault.write_file_text(file, &text)?;
368+ count += 1;
369+ }
370+ // repair filenames (§6.4): an existing prefix that is a leading run of
371+ // the id is kept — prefixes are never lengthened — unless a file decided
372+ // earlier already holds it (a collision: the later file moves)
373+ let current: Vec<(usize, String)> = vault
217374 .tree
218375 .blocks
219376 .iter()
220− .filter_map(|(r, id)| {
221− let b = vault.tree.node(*r).block.as_ref().unwrap();
222− let prefix = vault.unique_prefix(id);
223− let want = filename(&prefix, &slug(&vault.tree.node(*r).title));
224− if want != b.path {
225− Some((r.0, want))
226− } else {
227− None
228− }
377+ .map(|(r, _)| {
378+ let path = &vault.tree.node(*r).block.as_ref().unwrap().path;
379+ (r.0, split_filename(path).map(|(p, _)| p.to_string()).unwrap_or_default())
229380 })
230381 .collect();
231− for (file, want) in renames {
232− let old = vault.tree.files[file].path.clone();
233− std::fs::rename(vault.dir.join(&old), vault.dir.join(&want))?;
234− count += 1;
382+ let mut decided: Vec<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();
385+ for (i, (r, id)) in vault.tree.blocks.iter().enumerate() {
386+ let b = vault.tree.node(*r).block.as_ref().unwrap();
387+ let words = id.words();
388+ let keep = split_filename(&b.path).and_then(|(p, _)| {
389+ let n = p.split('-').count();
390+ (n <= 4 && words[..n].join("-") == p && !decided.iter().any(|d| d == p))
391+ .then(|| p.to_string())
392+ });
393+ let prefix = keep.unwrap_or_else(|| {
394+ let others: Vec<&str> = decided
395+ .iter()
396+ .map(String::as_str)
397+ .chain(current[i + 1..].iter().map(|(_, p)| p.as_str()))
398+ .collect();
399+ id.shortest_prefix(&|cand| others.contains(&cand))
400+ });
401+ let want = filename(&prefix, &slug(&vault.tree.node(*r).title));
402+ if want != b.path {
403+ renames.push((b.path.clone(), want));
404+ }
405+ decided.push(prefix);
235406 }
236− if count > 0 {
237− vault.reload()?;
407+ for (old, want) in renames {
408+ if vault.dir.join(&want).exists() {
409+ continue; // never overwrite; check keeps reporting it
410+ }
411+ let Some(file) = vault.file_index(&old) else { continue };
412+ vault.rename_file(file, &want)?;
413+ count += 1;
238414 }
239415 Ok(count)
240416}
crates/fold-core/src/edit.rs+839−281
@@ -8,10 +8,11 @@
88//! replaces the block's span atomically.
99
1010use crate::ident::Id;
11−use crate::parse::{Kind, TaskState};
11+use crate::parse::{fence_transition, parse_file, parse_frontmatter, Block, Content, Frontmatter, Kind, Node, ParsedFile, Span};
12+use crate::render::render_lines;
1213use crate::tree::NRef;
1314use crate::vault::Vault;
14−use std::collections::BTreeMap;
15+use std::collections::{BTreeMap, HashMap};
1516
1617/// One buffer line and its owner.
1718#[derive(Debug, Clone)]
@@ -38,8 +39,20 @@ pub struct EditBuffer {
3839 pub owners: BTreeMap<Owner, OwnerInfo>,
3940 /// Owners whose lines changed since last save.
4041 pub dirty: Vec<Owner>,
41− /// blake3 of each file as read when the buffer was built (§5.2.5).
42− base_hashes: Vec<String>,
42+ /// blake3 of each file (by path) as last read or written through this
43+ /// buffer (§5.2.5).
44+ base_hashes: HashMap<String, String>,
45+ /// Nested blocks deleted by deleting their title line (§5.2): their
46+ /// lines are the enclosing block's now, and each file goes to trash
47+ /// once no file embeds it.
48+ dropped: Vec<Owner>,
49+ /// Nested blocks whose title line is in the editor's clipboard, cut
50+ /// and not put back: in transit, not deleted (`hold`).
51+ held: Vec<Owner>,
52+ /// Where each nested block's title line sat when its embed was last
53+ /// written (`title_form`), for those written since the buffer was
54+ /// built: one re-indented or re-spelled since moves the embed (§5.2).
55+ placed: BTreeMap<Owner, (usize, Option<usize>)>,
4356}
4457
4558#[derive(Debug, Clone)]
@@ -48,41 +61,120 @@ pub struct OwnerInfo {
4861 pub id: Option<Id>,
4962 /// The block-root node in the tree at buffer-build time.
5063 pub nref: NRef,
51− /// The level/indent the block's text was shifted by when inlined into
52− /// the buffer (inverse applied on splice, §5.2.3).
64+ /// Display level/indent of the block's top node in the buffer, as
65+ /// built: a nested block's title line may be re-indented or re-spelled
66+ /// since, and splice shifts from where it sits then.
5367 pub level: usize,
5468 pub indent: usize,
69+ /// Whether that node is a section (its title line a heading), not an
70+ /// item.
71+ pub section: bool,
72+ /// Level/indent of that node in its own file; splice shifts by
73+ /// `target − display` (§5.2.3).
74+ pub target_level: usize,
75+ pub target_indent: usize,
76+ /// The owner whose text holds this block's embed (None for the owner
77+ /// of the render root).
78+ pub parent: Option<Owner>,
79+ /// The file the block is written to.
80+ pub path: String,
81+ /// For a render root that is not a block: the bytes `start..end` of its
82+ /// file that the buffer owns — its span without the blank lines after
83+ /// it — and each splice moves `end` to the end of what it wrote. Once
84+ /// its own text changes (a line above its title, the title deleted) no
85+ /// node need start where it did; the base-hash check (§5.2.5) ensures
86+ /// nothing else wrote the file since.
87+ pub start: usize,
88+ pub end: usize,
89+ pub is_root: bool,
5590}
5691
5792impl EditBuffer {
5893 /// Build the editing buffer for `render(node, 1, true)` (§5.2): every
5994 /// line tagged with its owning block.
6095 pub fn build(vault: &Vault, root: NRef) -> EditBuffer {
96+ let tree = &vault.tree;
6197 let mut lines = Vec::new();
6298 let mut owners: BTreeMap<Owner, OwnerInfo> = BTreeMap::new();
63− let mut block_ord = 0usize;
64− build_node(
65− vault,
66− root,
67− 1,
68− 0,
69− &mut block_ord,
70− &mut lines,
71− &mut owners,
72− &mut Vec::new(),
73− );
74− let base_hashes = vault
75− .tree
99+ let mut by_node: Vec<(NRef, Owner)> = Vec::new();
100+ let rlines = render_lines(tree, root, 1, true);
101+ let new_owner = |nref: NRef,
102+ outer: NRef,
103+ level: usize,
104+ indent: usize,
105+ owners: &mut BTreeMap<Owner, OwnerInfo>,
106+ by_node: &mut Vec<(NRef, Owner)>|
107+ -> Owner {
108+ if let Some((_, o)) = by_node.iter().find(|(r, _)| *r == nref) {
109+ return *o;
110+ }
111+ let o = Owner {
112+ file: nref.0,
113+ block_ord: by_node.len(),
114+ };
115+ let n = tree.node(nref);
116+ let is_root = n.kind == Kind::Root;
117+ let (level, indent, target_level, target_indent) = if is_root {
118+ (0, 0, 0, 0)
119+ } else if n.is_block() {
120+ (level, indent, tree.level(nref), 0)
121+ } else {
122+ (level, indent, tree.level(nref), tree.indent(nref))
123+ };
124+ let parent = if outer == nref {
125+ None
126+ } else {
127+ by_node.iter().find(|(r, _)| *r == outer).map(|(_, o)| *o)
128+ };
129+ owners.insert(
130+ o,
131+ OwnerInfo {
132+ title: if is_root { "root".into() } else { n.title.clone() },
133+ id: n.block.as_ref().and_then(|b| b.id.clone()),
134+ nref,
135+ level,
136+ indent,
137+ section: n.kind == Kind::Section,
138+ target_level,
139+ target_indent,
140+ parent,
141+ path: tree.files[nref.0].path.clone(),
142+ start: n.span.start,
143+ end: owned_end(&tree.files[nref.0].text, n.span),
144+ is_root,
145+ },
146+ );
147+ by_node.push((nref, o));
148+ o
149+ };
150+ for l in rlines {
151+ let owner = new_owner(l.owner, l.outer, l.level, l.indent, &mut owners, &mut by_node);
152+ lines.push(EditLine {
153+ text: l.text,
154+ owner,
155+ });
156+ }
157+ if lines.is_empty() {
158+ // an empty document still has one line to type into
159+ let owner = new_owner(root, root, 1, 0, &mut owners, &mut by_node);
160+ lines.push(EditLine {
161+ text: String::new(),
162+ owner,
163+ });
164+ }
165+ let base_hashes = tree
76166 .files
77167 .iter()
78− .enumerate()
79− .map(|(i, _)| vault.hash_of(i).to_string())
168+ .map(|f| (f.path.clone(), hash(&f.text)))
80169 .collect();
81170 EditBuffer {
82171 lines,
83172 owners,
84173 dirty: Vec::new(),
85174 base_hashes,
175+ dropped: Vec::new(),
176+ held: Vec::new(),
177+ placed: BTreeMap::new(),
86178 }
87179 }
88180
@@ -99,6 +191,36 @@ impl EditBuffer {
99191 }
100192 }
101193
194+ /// The nested blocks whose title line the editor's clipboard holds
195+ /// (§5.2: cutting a block's title line and pasting it moves the block).
196+ /// While held, a block with nothing in the buffer (no line of its own or
197+ /// of a block nested in it) is in transit: the enclosing block is
198+ /// written without its embed, and the block and its file are left alone
199+ /// until the line is pasted back. A block released with no line in the
200+ /// buffer is dirty again, so the next save deletes it, as deleting its
201+ /// title line does.
202+ pub fn hold(&mut self, owners: Vec<Owner>) {
203+ for o in std::mem::replace(&mut self.held, owners) {
204+ if !self.held.contains(&o) && !self.lines.iter().any(|l| l.owner == o) {
205+ self.mark_dirty(o);
206+ }
207+ }
208+ }
209+
210+ /// The blocks this buffer holds in transit (§5.2), with their files:
211+ /// nested here, with no line left in it, embedded nowhere, and still in
212+ /// the vault.
213+ pub fn transit(&self, vault: &Vault) -> Vec<(Id, String)> {
214+ let tree = &vault.tree;
215+ self.owners
216+ .iter()
217+ .filter(|&(o, i)| i.parent.is_some() && !self.lines.iter().any(|l| l.owner == *o))
218+ .filter_map(|(_, i)| i.id.as_ref())
219+ .filter(|id| tree.embed_of(id).is_none())
220+ .filter_map(|id| Some((id.clone(), tree.files[tree.block_by_id(id)?.0].path.clone())))
221+ .collect()
222+ }
223+
102224 /// Insert a line after `idx`; it inherits that line's tag (§5.2).
103225 pub fn insert_line(&mut self, idx: usize, text: String) {
104226 let owner = if self.lines.is_empty() {
@@ -128,312 +250,762 @@ impl EditBuffer {
128250 }
129251 }
130252
131− /// Splice one dirty block (§5.2): collect its lines, put nested blocks
132− /// back as embeds, shift back, write the one file atomically.
253+ /// Where the block is now: its file, and for a block or a file's Root
254+ /// its node in the current tree (it may have been re-parsed since the
255+ /// buffer was built). A render root that is not a block is no node but
256+ /// the region `start..end` of its file.
257+ fn locate(&self, vault: &Vault, info: &OwnerInfo) -> Option<(usize, Option<usize>)> {
258+ if let Some(id) = &info.id {
259+ return vault.tree.block_by_id(id).map(|(f, n)| (f, Some(n)));
260+ }
261+ let file = vault.file_index(&info.path)?;
262+ Some((file, info.is_root.then_some(vault.tree.files[file].root_node)))
263+ }
264+
265+ /// The owner directly nested in `owner` that `o` belongs to, if any.
266+ fn nested_in(&self, mut o: Owner, owner: Owner) -> Option<Owner> {
267+ while let Some(p) = self.owners.get(&o).and_then(|i| i.parent) {
268+ if p == owner {
269+ return Some(o);
270+ }
271+ o = p;
272+ }
273+ None
274+ }
275+
276+ /// Each nested block's embed goes to the block its title line (at
277+ /// `titles`) now sits in (§5.2), out of the block it was in or into one
278+ /// nested there, however the line got there: moved, re-indented or
279+ /// re-spelled. Of the block holding the line above the title line (the
280+ /// blank lines between nest nothing) and the blocks that one is nested
281+ /// in, the innermost whose title line holds it (`holds`). Blocks go in
282+ /// buffer order, so where a block sits is settled before the blocks
283+ /// after it.
284+ pub fn reparent(&mut self, titles: &BTreeMap<Owner, usize>) {
285+ let mut order: Vec<(usize, Owner)> = titles.iter().map(|(&o, &t)| (t, o)).collect();
286+ order.sort();
287+ for (t, o) in order {
288+ let Some(p) = self.owners.get(&o).and_then(|i| i.parent) else { continue };
289+ let lines = &self.lines[..t.min(self.lines.len())];
290+ let Some(above) = lines.iter().rev().find(|l| !l.text.trim().is_empty()) else { continue };
291+ let mut q = Some(above.owner);
292+ while let Some(c) = q {
293+ if c != o && self.nested_in(c, o).is_none() && self.holds(titles, c, o) {
294+ break;
295+ }
296+ q = self.owners.get(&c).and_then(|i| i.parent);
297+ }
298+ let Some(q) = q.filter(|&q| q != p) else { continue };
299+ if let Some(i) = self.owners.get_mut(&o) {
300+ i.parent = Some(q);
301+ }
302+ self.mark_dirty(p);
303+ self.mark_dirty(q);
304+ }
305+ }
306+
307+ /// Whether block `q`'s title line holds block `o`'s, written below its
308+ /// lines, as the parser nests title lines (§3.1): the edited node's
309+ /// block holds every line, an item what is indented past it, a section
310+ /// what is indented past it (under an item of its own) and, at its
311+ /// indent, items and deeper sections. Each title line as it is written
312+ /// now (`title_form`), not as the buffer was built: one re-indented or
313+ /// re-spelled nests where it sits (else where it was last written).
314+ fn holds(&self, titles: &BTreeMap<Owner, usize>, q: Owner, o: Owner) -> bool {
315+ if self.owners.get(&q).is_some_and(|i| i.parent.is_none()) {
316+ return true;
317+ }
318+ let form = |o: Owner| {
319+ titles
320+ .get(&o)
321+ .and_then(|&t| self.lines.get(t))
322+ .and_then(|l| title_form(&l.text))
323+ .or_else(|| self.placed(o))
324+ };
325+ let (Some((qi, qh)), Some((oi, oh))) = (form(q), form(o)) else { return false };
326+ oi > qi || (oi == qi && qh.is_some_and(|q| oh.is_none_or(|o| o > q)))
327+ }
328+
329+ /// The line where nested block `o`'s text starts (§5.2): the first title
330+ /// line of either kind at any indent named as the block is in its file
331+ /// (`file_title`), whatever lines of the block come before it, typed or
332+ /// split off in front of it; else its first line if that is a title line
333+ /// of its kind at its display indent — a heading for a section, a
334+ /// bullet for an item — or, re-indented or re-spelled, a title line of
335+ /// either kind at any indent that is not one of the block's children
336+ /// (`left_first`); else the first title line of its kind at its display
337+ /// indent below it, a heading no deeper than the block's level (deeper
338+ /// ones are its children). None: its title line was deleted.
339+ fn title_line(&self, vault: &Vault, o: Owner) -> Option<usize> {
340+ let info = self.owners.get(&o)?;
341+ let named = self.file_title(vault, o);
342+ let mut fence = None;
343+ let mut first = true;
344+ let mut found = None;
345+ for (i, l) in self.lines.iter().enumerate().filter(|(_, l)| l.owner == o) {
346+ let in_code = fence_transition(&l.text, &mut fence) || fence.is_some();
347+ if let Some((indent, heading)) = title_form(&l.text).filter(|_| !in_code) {
348+ if named == Some(after_marker(&l.text)) {
349+ return Some(i);
350+ }
351+ let own = indent == info.indent && heading.is_some() == info.section;
352+ let title = (first && (own || !self.left_first(vault, o, &l.text)))
353+ || (own && heading.is_none_or(|h| h <= info.level.max(1)));
354+ if title && found.is_none() {
355+ found = Some(i);
356+ }
357+ }
358+ first = false;
359+ }
360+ found
361+ }
362+
363+ /// Nested block `o`'s title in its file as last read or written, after
364+ /// its marker (`after_marker`).
365+ fn file_title<'v>(&self, vault: &'v Vault, o: Owner) -> Option<&'v str> {
366+ let (file, root) = self.owners.get(&o).and_then(|i| self.locate(vault, i))?;
367+ let f = &vault.tree.files[file];
368+ let n = f.nodes.get(root?)?;
369+ Some(after_marker(&f.text[n.title_span.start..n.title_span.end.min(f.text.len())]))
370+ }
371+
372+ /// Whether `line`, first of block `o`'s lines, is the title line of one
373+ /// of its children, not its own: a node below its title in its file as
374+ /// last read or written, not named as the block is. So deleting a
375+ /// block's title line, which may leave a child's first, deletes it
376+ /// (§5.2), while its title line re-indented stays its title.
377+ fn left_first(&self, vault: &Vault, o: Owner, line: &str) -> bool {
378+ let Some((file, Some(root))) = self.owners.get(&o).and_then(|i| self.locate(vault, i)) else {
379+ return false;
380+ };
381+ let f = &vault.tree.files[file];
382+ let title = |n: &Node| after_marker(&f.text[n.title_span.start..n.title_span.end.min(f.text.len())]);
383+ let t = after_marker(line);
384+ t != title(&f.nodes[root])
385+ && f.nodes.iter().enumerate().any(|(i, n)| {
386+ i != root && n.kind != Kind::Root && !n.is_embed() && title(n) == t
387+ })
388+ }
389+
390+ /// Where nested block `o`'s embed was last written: its title line's
391+ /// indent and heading level (`title_form`), as built or since saved.
392+ fn placed(&self, o: Owner) -> Option<(usize, Option<usize>)> {
393+ self.placed.get(&o).copied().or_else(|| {
394+ let i = self.owners.get(&o)?;
395+ Some((i.indent, i.section.then_some(i.level)))
396+ })
397+ }
398+
399+ /// §5.2's edge cases, applied from the tags before a splice. A nested
400+ /// block's text starts at its title line: lines it owns above that line
401+ /// sit in the enclosing block and become its text. Deleting the title
402+ /// line deletes the block: every line it still owned is re-tagged to
403+ /// the enclosing block, the blocks nested in it are nested in that
404+ /// block, and its file goes to trash once that block is written without
405+ /// its embed — unless it is in transit, its title line cut (`hold`).
406+ /// Re-indenting or re-spelling the title line moves the block: the
407+ /// enclosing block is dirty, its embed written where the line now sits,
408+ /// in the block that line sits in (`reparent`).
409+ fn settle(&mut self, vault: &Vault) {
410+ // lines of a deleted block put back (by the editor's own undo) are
411+ // the enclosing block's text like the rest of them
412+ let dropped = self.dropped.clone();
413+ self.dirty.retain(|o| !dropped.contains(o));
414+ for i in 0..self.lines.len() {
415+ let s = self.surviving(self.lines[i].owner);
416+ if s != self.lines[i].owner {
417+ self.lines[i].owner = s;
418+ self.mark_dirty(s);
419+ }
420+ }
421+ // and so are the blocks nested in it, whatever parents the editor's
422+ // undo put back: no embed is written into a deleted block
423+ let parents: Vec<(Owner, Owner)> = self
424+ .owners
425+ .iter()
426+ .filter_map(|(&o, i)| Some((o, i.parent?)))
427+ .filter(|&(o, p)| !dropped.contains(&o) && self.surviving(p) != p)
428+ .collect();
429+ for (o, p) in parents {
430+ let s = self.surviving(p);
431+ if let Some(i) = self.owners.get_mut(&o) {
432+ i.parent = Some(s);
433+ }
434+ self.mark_dirty(s);
435+ }
436+ loop {
437+ let mut changed = false;
438+ for o in self.dirty.clone() {
439+ let Some(parent) = self.owners.get(&o).and_then(|i| i.parent) else {
440+ continue;
441+ };
442+ let title = self.title_line(vault, o);
443+ let form = title.and_then(|t| title_form(&self.lines[t].text));
444+ if form.is_some() && form != self.placed(o) && !self.dirty.contains(&parent) {
445+ self.mark_dirty(parent);
446+ changed = true;
447+ }
448+ // a title line emptied to be typed again (vim `cc`) is not
449+ // a deleted one; splice refuses to write the block meanwhile
450+ let first = self.lines.iter().find(|l| l.owner == o);
451+ if title.is_none() && first.is_some_and(|l| l.text.trim().is_empty()) {
452+ continue;
453+ }
454+ // one whose title line the clipboard holds, with nothing of
455+ // it or of the blocks in it left here, is in transit (`hold`)
456+ let transit = first.is_none()
457+ && self.held.contains(&o)
458+ && !self.lines.iter().any(|l| self.nested_in(l.owner, o).is_some());
459+ let mut moved = title.is_none();
460+ let above = title.unwrap_or(self.lines.len());
461+ for l in self.lines[..above].iter_mut().filter(|l| l.owner == o) {
462+ l.owner = parent;
463+ moved = true;
464+ }
465+ if title.is_none() {
466+ self.dirty.retain(|d| *d != o);
467+ }
468+ if title.is_none() && !transit {
469+ for i in self.owners.values_mut() {
470+ if i.parent == Some(o) {
471+ i.parent = Some(parent);
472+ }
473+ }
474+ self.dropped.push(o);
475+ }
476+ if moved {
477+ self.mark_dirty(parent);
478+ changed = true;
479+ }
480+ }
481+ if !changed {
482+ break;
483+ }
484+ }
485+ // and each goes to the block its title line now sits in, so no
486+ // embed is written nested under another's line (§4.7)
487+ let titles: BTreeMap<Owner, usize> = self
488+ .owners
489+ .iter()
490+ .filter(|(_, i)| i.parent.is_some())
491+ .filter_map(|(&o, _)| Some((o, self.title_line(vault, o)?)))
492+ .collect();
493+ self.reparent(&titles);
494+ }
495+
496+ /// The block that holds a deleted block's lines now.
497+ fn surviving(&self, mut o: Owner) -> Owner {
498+ while self.dropped.contains(&o) {
499+ match self.owners.get(&o).and_then(|i| i.parent) {
500+ Some(p) => o = p,
501+ None => break,
502+ }
503+ }
504+ o
505+ }
506+
507+ /// Move deleted blocks' files to the trash (§11.5) once no file embeds
508+ /// them any more: once the block that held the embed has been written.
509+ /// That is the block its lines went to, and it must be saved (not
510+ /// dirty, its save not refused) with its file on disk as written: a
511+ /// file another program changed may embed the block again.
512+ fn trash_dropped(&self, vault: &mut Vault) -> std::io::Result<()> {
513+ loop {
514+ let file = self.dropped.iter().find_map(|o| {
515+ let id = self.owners.get(o)?.id.as_ref()?;
516+ let s = self.surviving(*o);
517+ if self.dirty.contains(&s) || vault.tree.embed_of(id).is_some() {
518+ return None;
519+ }
520+ let (holder, _) = self.locate(vault, self.owners.get(&s)?)?;
521+ vault.check_unchanged(holder).ok()?;
522+ vault.tree.block_by_id(id).map(|r| r.0)
523+ });
524+ match file {
525+ Some(f) => vault.trash_file(f)?,
526+ None => return Ok(()),
527+ }
528+ }
529+ }
530+
531+ /// Splice one dirty block (§5.2) — or, if deleting its title line
532+ /// deleted it, the block its lines went to — then trash the files of
533+ /// deleted blocks no longer embedded.
133534 pub fn splice(&mut self, vault: &mut Vault, owner: Owner) -> std::io::Result<()> {
535+ self.settle(vault);
536+ let owner = self.surviving(owner);
537+ self.write(vault, owner)?;
538+ self.trash_dropped(vault)
539+ }
540+
541+ /// Write one block (§5.2): collect its lines, put nested blocks back as
542+ /// embeds, shift back, write the one file atomically. False: there was
543+ /// nothing to write, and the block is no longer dirty either.
544+ fn write(&mut self, vault: &mut Vault, owner: Owner) -> std::io::Result<bool> {
134545 let info = match self.owners.get(&owner) {
135546 Some(i) => i.clone(),
136− None => return Ok(()),
547+ None => {
548+ self.dirty.retain(|o| *o != owner);
549+ return Ok(false);
550+ }
137551 };
138− // Collect the block's owned lines in buffer order; where a nested
139− // block's title line sits, emit an embed instead (§5.2.1).
140− let mut text_lines: Vec<String> = Vec::new();
141− let mut skip_blocks: Vec<usize> = Vec::new();
552+ // Collect the block's owned lines in buffer order; where a block
553+ // nested in it starts — whatever file it lives in — emit its embed
554+ // (§5.2.1).
555+ let mut text_lines: Vec<Line> = Vec::new();
556+ let mut emitted: Vec<Owner> = Vec::new();
557+ let mut forms = Vec::new();
142558 for l in &self.lines {
143559 if l.owner == owner {
144− text_lines.push(l.text.clone());
145− } else if l.owner.file == owner.file && l.owner.block_ord != owner.block_ord {
146− // a line owned by a nested block in the same file: at the
147− // position of that nested block's *title* line, emit an embed
148− let nested_ord = l.owner.block_ord;
149− if !skip_blocks.contains(&nested_ord) {
150− skip_blocks.push(nested_ord);
151− let nested_owner = Owner { file: owner.file, block_ord: nested_ord };
152− if let Some(ninfo) = self.owners.get(&nested_owner) {
153− if let Some(id) = &ninfo.id {
154− text_lines.push(format!("EMBED:{}", id));
560+ text_lines.push(Line::Text(l.text.clone()));
561+ } else if let Some(nested) = self.nested_in(l.owner, owner) {
562+ if !emitted.contains(&nested) {
563+ emitted.push(nested);
564+ if let Some(id) = self.owners.get(&nested).and_then(|i| i.id.clone()) {
565+ // the embed takes the form of the block's title line:
566+ // a heading embed for a heading (§4.7)
567+ let trimmed = l.text.trim_start_matches(' ');
568+ let indent = l.text.len() - trimmed.len();
569+ let form = title_form(&l.text);
570+ let hashes = form.and_then(|f| f.1);
571+ let respelled = form.is_some_and(|f| {
572+ Some(f.1.is_some()) != self.placed(nested).map(|p| p.1.is_some())
573+ });
574+ // an existing embed keeps its form, so an unchanged
575+ // splice writes it back byte for byte; a title line
576+ // re-spelled since it was written changes it
577+ let heading = match vault.tree.embed_of(&id) {
578+ _ if respelled => hashes,
579+ Some(e) if vault.tree.node(e).kind == Kind::Section => {
580+ Some(hashes.unwrap_or(info.level + 1))
581+ }
582+ Some(_) => None,
583+ None => hashes,
584+ };
585+ text_lines.push(Line::Embed(indent, heading, id));
586+ if let Some(f) = form {
587+ forms.push((nested, f));
155588 }
156589 }
157590 }
158591 }
159592 }
160− // The first line is the block's title line.
161593 if text_lines.is_empty() {
162− return Ok(());
594+ // every line of the edited node gone: the editor does not delete
595+ // it (a nested block with no lines left was deleted by settle)
596+ self.dirty.retain(|o| *o != owner);
597+ return Ok(false);
163598 }
164− // Shift back: re-level sections by +(level(block) − 1), re-indent by
165− // +indent(block) (§5.2.3).
166− let level_delta = info.level as isize - 1;
167− let indent_delta = info.indent as isize;
599+ // Shift back from the display position to the block's position in
600+ // its file (§5.2.3). A nested block's display position is where its
601+ // title line sits now, re-indented or re-spelled or not: its root
602+ // goes to column 0, a heading to its file's level (§4.9)
603+ let title = info.parent.and_then(|_| self.title_line(vault, owner));
604+ let (level, indent, target_level) = match title.and_then(|t| title_form(&self.lines[t].text)) {
605+ Some((indent, Some(h))) => (h, indent, if info.section { info.target_level } else { 1 }),
606+ // an item shows at its section's level, one above a section's
607+ Some((indent, None)) if info.section => (info.level.saturating_sub(1).max(1), indent, 1),
608+ Some((indent, None)) => (info.level, indent, info.target_level),
609+ None => (info.level, info.indent, info.target_level),
610+ };
611+ let level_delta = target_level as isize - level as isize;
612+ let indent_delta = info.target_indent as isize - indent as isize;
613+ let shift = |cols: usize| (cols as isize + indent_delta).max(0) as usize;
168614 let mut out = String::new();
169615 let mut fence: Option<(char, usize)> = None;
170616 for l in &text_lines {
171− if let Some(id) = l.strip_prefix("EMBED:") {
172− out.push_str(&" ".repeat(indent_delta.max(0) as usize));
173− out.push_str("![[");
174− out.push_str(id);
175− out.push_str("]]\n");
176− continue;
177− }
178− let trimmed = l.trim_start();
179− if fence_transition(l, &mut fence) || fence.is_some() {
180− out.push_str(l);
617+ let l = match l {
618+ Line::Embed(indent, heading, id) => {
619+ out.push_str(&" ".repeat(shift(*indent)));
620+ if let Some(h) = heading {
621+ out.push_str(&"#".repeat((*h as isize + level_delta).max(1) as usize));
622+ out.push(' ');
623+ }
624+ out.push_str("![[");
625+ out.push_str(id.as_str());
626+ out.push_str("]]\n");
627+ continue;
628+ }
629+ Line::Text(t) => t,
630+ };
631+ if l.trim().is_empty() {
181632 out.push('\n');
182633 continue;
183634 }
184− let cur_indent = l.len() - trimmed.len();
185− let new_indent = (cur_indent as isize + indent_delta).max(0) as usize;
186− if trimmed.starts_with('#') {
187− let hashes = trimmed.chars().take_while(|&c| c == '#').count();
635+ let trimmed = l.trim_start_matches(' ');
636+ let new_indent = shift(l.len() - trimmed.len());
637+ out.push_str(&" ".repeat(new_indent));
638+ let in_code = fence_transition(l, &mut fence) || fence.is_some();
639+ let hashes = trimmed.chars().take_while(|&c| c == '#').count();
640+ let after = &trimmed[hashes..];
641+ if !in_code && hashes > 0 && (after.is_empty() || after.starts_with(' ')) {
188642 let nl = (hashes as isize + level_delta).max(1) as usize;
189− out.push_str(&" ".repeat(new_indent));
190643 out.push_str(&"#".repeat(nl));
191− out.push_str(&trimmed[hashes..]);
192− out.push('\n');
644+ out.push_str(after);
193645 } else {
194− out.push_str(&" ".repeat(new_indent));
195646 out.push_str(trimmed);
196− out.push('\n');
197647 }
648+ out.push('\n');
649+ }
650+ while out.ends_with("\n\n") {
651+ out.pop();
652+ }
653+ self.rebase(vault, owner)?;
654+ let info = self.owners.get(&owner).cloned().unwrap_or(info);
655+ let (file, node) = self.locate(vault, &info).ok_or_else(|| not_found(&info))?;
656+ let path = vault.tree.files[file].path.clone();
657+ let region = Span {
658+ start: info.start,
659+ end: info.end,
660+ };
661+ let f = &vault.tree.files[file];
662+ // §4.9: a block file with text or an embed before its root is
663+ // read-only until fixed — the bytes before the root are not in the
664+ // buffer, and rewriting the file would drop them
665+ if f.malformed_block() {
666+ return Err(std::io::Error::other(format!(
667+ "{}: text or an embed before the block's root; read-only until fixed",
668+ path
669+ )));
198670 }
199− // External-change check (§5.2.5).
200− let file = owner.file;
201− let current_hash = blake3::hash(vault.tree.files[file].text.as_bytes())
202− .to_hex()
203− .to_string();
204− if current_hash != self.base_hashes[file]
205− && current_hash != vault.hash_of(file)
206− {
207− return Err(std::io::Error::new(
208− std::io::ErrorKind::Other,
209− format!("{} changed on disk; not overwriting", vault.tree.files[file].path),
210− ));
211− }
212− // Replace the block's span (or the whole file for its own block).
213− let nref = info.nref;
214− let n = vault.tree.node(nref);
215− if n.is_block() || n.kind == Kind::Root {
216− // the block IS the file (minus frontmatter): replace everything
217− // after the frontmatter
218− let f = &vault.tree.files[file];
219− let fm_end = f.nodes[f.root_node]
220− .children
221− .first()
222− .and_then(|&rn| f.nodes[rn].block.as_ref())
671+ // §4.7: an embed line has no lines nested under it, where the tree
672+ // does not see them. A line the parser would nest there (the block's
673+ // own line indented under a nested block's title line, or after one
674+ // re-spelled as a heading) is refused, not hidden, unless the file
675+ // had it so already
676+ let crowded = crowded_embeds(f);
677+ if crowded_embeds(&parse_file(&path, &out, None)).iter().any(|id| !crowded.contains(id)) {
678+ return Err(std::io::Error::other(format!(
679+ "{}: a line would be nested under a block's embed, out of the outline; not saved",
680+ path
681+ )));
682+ }
683+ if let Some(ni) = node {
684+ // §5.2 step 2: a block's text parses to exactly one root-level
685+ // node, so splice never writes a block file it would then treat
686+ // as read-only, or one with no root at all
687+ if let Some(b) = f.nodes[ni].block.as_ref().filter(|b| b.id.is_some()) {
688+ let b = Block {
689+ frontmatter_span: None,
690+ ..b.clone()
691+ };
692+ if parse_file(&path, &out, Some(b)).malformed_block() {
693+ return Err(std::io::Error::other(format!(
694+ "{}: the block's text must start with its title line; not saved",
695+ path
696+ )));
697+ }
698+ }
699+ // the block IS the file: keep its frontmatter, replace the rest
700+ let fm_end = f.nodes[ni]
701+ .block
702+ .as_ref()
223703 .and_then(|b| b.frontmatter_span)
224704 .map(|s| s.end)
225− .unwrap_or(0);
705+ .unwrap_or(0)
706+ .min(f.text.len());
226707 let mut new_text = String::new();
227708 new_text.push_str(&f.text[..fm_end]);
228709 new_text.push_str(&out);
229710 vault.write_file_text(file, &new_text)?;
230711 } else {
231− vault.write_span(file, n.span, &out)?;
712+ // a region inside a file: the blank lines that separate it from
713+ // what follows stay
714+ vault.write_span(file, region, &out)?;
715+ if let Some(i) = self.owners.get_mut(&owner) {
716+ i.start = region.start;
717+ i.end = region.start + out.len();
718+ }
232719 }
233− self.base_hashes[file] = blake3::hash(vault.tree.files[file].text.as_bytes())
234− .to_hex()
235− .to_string();
720+ self.base_hashes
721+ .insert(path, hash(&vault.tree.files[file].text));
236722 self.dirty.retain(|o| *o != owner);
237− Ok(())
723+ self.placed.extend(forms);
724+ Ok(true)
238725 }
239726
240− /// Splice every dirty block (§10.6: a commit may write several files,
241− /// one splice per dirty block).
242− pub fn save_all(&mut self, vault: &mut Vault) -> std::io::Result<usize> {
243− let dirty = self.dirty.clone();
244− let mut n = 0;
245− for owner in dirty {
246− self.splice(vault, owner)?;
247− n += 1;
727+ /// External-change check (§5.2.5) for one block: the block's source
728+ /// span on disk must be what was read (or last written) through this
729+ /// buffer. If the file changed underneath and, unless it was reloaded
730+ /// since, the vault still holds it as this buffer last read or wrote it,
731+ /// but the block's own text is unchanged, the change was elsewhere
732+ /// (another section, a property): the file as it is now is taken as
733+ /// what the buffer has read, the change kept. Otherwise the save is
734+ /// refused.
735+ fn rebase(&mut self, vault: &mut Vault, owner: Owner) -> std::io::Result<()> {
736+ let Some(info) = self.owners.get(&owner).cloned() else {
737+ return Ok(());
738+ };
739+ let (file, node) = self.locate(vault, &info).ok_or_else(|| not_found(&info))?;
740+ let path = vault.tree.files[file].path.clone();
741+ // a block file renamed since (`fold check --fix`, another program)
742+ // is checked against the text read under its old name: no hash
743+ // under the new one is not a file unchanged
744+ if info.path != path {
745+ if let Some(h) = self.base_hashes.get(&info.path).cloned() {
746+ self.base_hashes.insert(path.clone(), h);
747+ }
748+ if let Some(i) = self.owners.get_mut(&owner) {
749+ i.path = path.clone();
750+ }
248751 }
249− Ok(n)
752+ let on_disk = std::fs::read_to_string(vault.dir.join(&path)).unwrap_or_default();
753+ let Some(base) = self.base_hashes.get(&path).filter(|h| **h != hash(&on_disk)) else {
754+ return Ok(());
755+ };
756+ let region = Span {
757+ start: info.start,
758+ end: info.end,
759+ };
760+ let known = &vault.tree.files[file].text;
761+ let span = (*base == hash(known))
762+ .then(|| span_now(known, &on_disk, node.is_none().then_some(region)))
763+ .flatten();
764+ let Some(span) = span else {
765+ return Err(std::io::Error::other(format!(
766+ "{} changed on disk; not overwriting",
767+ path
768+ )));
769+ };
770+ // the vault writes over the file as it is now (its write guard
771+ // compares with it)
772+ vault.reparse(file, &on_disk)?;
773+ self.base_hashes.insert(path, hash(&on_disk));
774+ if node.is_none() {
775+ if let Some(i) = self.owners.get_mut(&owner) {
776+ (i.start, i.end) = (span.start, span.end);
777+ }
778+ }
779+ Ok(())
250780 }
251−}
252781
253−fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
254− let t = raw.trim_start();
255− let first = match t.chars().next() {
256− Some(c) if c == '`' || c == '~' => c,
257− _ => return false,
258− };
259− let count = t.chars().take_while(|&c| c == first).count();
260− if count < 3 {
261− return false;
782+ /// Take in what another program changed outside the dirty blocks'
783+ /// source spans (§5.2.5) before they are saved, so that the vault, and
784+ /// a snapshot taken of it for the op-log (§10.10), holds that change:
785+ /// undoing the save then does not revert it. A block whose own span
786+ /// changed is left for its save to refuse.
787+ pub fn rebase_dirty(&mut self, vault: &mut Vault) {
788+ self.settle(vault);
789+ for owner in self.dirty.clone() {
790+ let _ = self.rebase(vault, owner);
791+ }
262792 }
263− match open {
264− None => {
265− *open = Some((first, count));
266− true
267− }
268− Some((c, n)) => {
269− if *c == first && count >= *n {
270− *open = None;
271− true
272− } else {
273− false
793+
794+ /// Leaving the editor without saving (*Revert*, §10.6): the unsaved
795+ /// text is dropped, not written, but a block a save took out of the
796+ /// block it was in — its title line cut and the parent written in
797+ /// transit, whether the clipboard still holds it or has let it go since
798+ /// (it has no line left here), or its title line deleted and its file
799+ /// not trashed yet — can no longer be pasted back as itself. It is
800+ /// deleted as on leaving the editor any other way (§5.2): its file goes
801+ /// to trash once no file embeds it, never left in the vault embedded
802+ /// nowhere. `vault` must hold the files as they are on disk.
803+ pub fn discard(&self, vault: &mut Vault) -> std::io::Result<()> {
804+ let lineless: Vec<Owner> = self
805+ .owners
806+ .iter()
807+ .filter(|&(o, i)| i.parent.is_some() && !self.lines.iter().any(|l| l.owner == *o))
808+ .map(|(&o, _)| o)
809+ .collect();
810+ loop {
811+ let file = self.held.iter().chain(&self.dropped).chain(&lineless).find_map(|o| {
812+ let id = self.owners.get(o)?.id.as_ref()?;
813+ match vault.tree.embed_of(id) {
814+ Some(_) => None,
815+ None => vault.tree.block_by_id(id).map(|r| r.0),
816+ }
817+ });
818+ match file {
819+ Some(f) => vault.trash_file(f)?,
820+ None => return Ok(()),
274821 }
275822 }
276823 }
277−}
278824
279−/// Recursive buffer construction: emit a node's title/body at the display
280−/// position, tag lines with the owning block, descend (§5.2).
281−#[allow(clippy::too_many_arguments)]
282−fn build_node(
283− vault: &Vault,
284− r: NRef,
285− dlevel: usize,
286− dindent: usize,
287− block_ord: &mut usize,
288− lines: &mut Vec<EditLine>,
289− owners: &mut BTreeMap<Owner, OwnerInfo>,
290− seen: &mut Vec<NRef>,
291−) {
292− if seen.contains(&r) {
293− return;
294− }
295− seen.push(r);
296− let n = vault.tree.node(r);
297− // The owning block: nearest ancestor-or-self that is one. The render
298− // root owns until a nested block starts.
299− let is_block_start = n.is_block() && n.kind != Kind::Root;
300− let owner = if is_block_start {
301− *block_ord += 1;
302− let o = Owner { file: r.0, block_ord: *block_ord };
303− owners.insert(
304− o,
305− OwnerInfo {
306− title: n.title.clone(),
307− id: n.block.as_ref().and_then(|b| b.id.clone()),
308− nref: r,
309− level: dlevel,
310− indent: dindent,
311− },
312− );
313− o
314− } else if n.kind == Kind::Root || owners.is_empty() {
315− let o = Owner { file: r.0, block_ord: 0 };
316− owners.entry(o).or_insert_with(|| OwnerInfo {
317− title: if n.kind == Kind::Root {
318− "root".into()
319− } else {
320− n.title.clone()
321− },
322− id: n.block.as_ref().and_then(|b| b.id.clone()),
323− nref: r,
324− level: dlevel,
325− indent: dindent,
326− });
327− o
328− } else {
329− // owned by the enclosing block: the last opened owner
330− *owners.keys().last().unwrap()
331− };
332− // Title line.
333− match n.kind {
334− Kind::Root => {}
335− Kind::Section => {
336− let mut s = " ".repeat(dindent);
337− s.push_str(&"#".repeat(dlevel.max(1)));
338− s.push(' ');
339− if !n.is_block() {
340− push_checkbox(n.task, &mut s);
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;
341847 }
342− s.push_str(n.title.trim_end());
343− lines.push(EditLine { text: s, owner });
344− }
345− Kind::Item => {
346− let mut s = " ".repeat(dindent);
347− s.push_str("- ");
348− if !n.is_block() {
349− push_checkbox(n.task, &mut s);
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());
350861 }
351− s.push_str(n.title.trim_end());
352− lines.push(EditLine { text: s, owner });
862+ self.owners.insert(a, info);
353863 }
864+ true
354865 }
355− // Body.
356− let body = n.body_lines(vault.tree.text_of(r));
357− let mut body: Vec<&str> = body;
358− while body.first().map(|l| l.trim().is_empty()) == Some(true) {
359− body.remove(0);
360− }
361− while body.last().map(|l| l.trim().is_empty()) == Some(true) {
362− body.pop();
363− }
364− if !body.is_empty() && n.kind != Kind::Root {
365− lines.push(EditLine { text: String::new(), owner });
366− }
367− let dedent_by = vault.tree.indent(r);
368− for l in &body {
369− lines.push(EditLine {
370− text: format!("{}{}", " ".repeat(dindent), dedent(l, dedent_by)),
371− owner,
372− });
373− }
374− // Children.
375− let children = vault.tree.resolved_children(r);
376− let mut first = true;
377− for c in children {
378− let cn = vault.tree.node(c);
379− if cn.kind == Kind::Root {
380− continue;
381− }
382− if first {
383− if !body.is_empty() || n.kind != Kind::Root {
384− lines.push(EditLine { text: String::new(), owner });
866+
867+ /// Splice every dirty block (§10.6: a commit may write several files,
868+ /// one splice per dirty block). Each block is written by its own splice
869+ /// (§5.2): one that is refused stays dirty, the others are still
870+ /// written, and the error names every refusal.
871+ pub fn save_all(&mut self, vault: &mut Vault) -> std::io::Result<usize> {
872+ self.settle(vault);
873+ let dirty = self.dirty.clone();
874+ let mut n = 0;
875+ let mut errs = Vec::new();
876+ for owner in dirty {
877+ match self.write(vault, owner) {
878+ Ok(true) => n += 1,
879+ Ok(false) => {}
880+ Err(e) => errs.push(e),
385881 }
386− first = false;
387− } else if cn.kind == Kind::Section {
388− lines.push(EditLine { text: String::new(), owner });
389882 }
390− let cindent = dindent + vault.tree.indent(c).saturating_sub(vault.tree.indent(r));
391− let clevel = match cn.kind {
392− Kind::Section => dlevel + 1,
393− _ => dlevel,
394− };
395− // embeds resolve in the editing buffer (§10.6): the nested block's
396− // lines carry its own tag.
397− let target = vault.tree.resolved_child(c);
398− if target != c {
399− build_node(vault, target, clevel, cindent, block_ord, lines, owners, seen);
400− } else if cn.is_embed() {
401− // broken embed: shown as text, owned here
402− lines.push(EditLine {
403− text: format!("{}![[{}]]", " ".repeat(cindent), cn.embed.as_ref().unwrap()),
404− owner,
405− });
406− } else {
407− build_node(vault, c, clevel, cindent, block_ord, lines, owners, seen);
883+ if let Err(e) = self.trash_dropped(vault) {
884+ errs.push(e);
885+ }
886+ match errs.len() {
887+ 0 => Ok(n),
888+ 1 => Err(errs.remove(0)),
889+ _ => Err(std::io::Error::other(
890+ errs.iter().map(|e| e.to_string()).collect::<Vec<_>>().join("; "),
891+ )),
408892 }
409893 }
410− seen.pop();
411894}
412895
413−fn push_checkbox(task: Option<TaskState>, out: &mut String) {
414− match task {
415− Some(TaskState::Open) => out.push_str("[ ] "),
416− Some(TaskState::Done) => out.push_str("[x] "),
417− None => {}
896+/// A line collected for splice: owned text, or a nested block's embed at
897+/// the display indent of its first line, with the heading level of that line
898+/// if it is a heading.
899+enum Line {
900+ Text(String),
901+ Embed(usize, Option<usize>, Id),
902+}
903+
904+/// A title line's form (§4.3): its indent, and its level if it is a
905+/// heading rather than a bullet. None: not a title line.
906+fn title_form(line: &str) -> Option<(usize, Option<usize>)> {
907+ let trimmed = line.trim_start_matches(' ');
908+ let indent = line.len() - trimmed.len();
909+ let hashes = trimmed.chars().take_while(|&c| c == '#').count();
910+ let after = &trimmed[hashes..];
911+ if hashes > 0 && (after.is_empty() || after.starts_with(' ')) {
912+ return Some((indent, Some(hashes)));
418913 }
914+ let bullet = ["- ", "* ", "+ "].iter().any(|m| trimmed.starts_with(m))
915+ || ["-", "*", "+"].contains(&trimmed);
916+ bullet.then_some((indent, None))
917+}
918+
919+/// A title line's text after its indent and its heading or bullet marker,
920+/// whichever form and level it is written in.
921+fn after_marker(line: &str) -> &str {
922+ let t = line.trim_start_matches([' ', '\t']);
923+ let t = match t.strip_prefix(['-', '*', '+']) {
924+ Some(rest) => rest,
925+ None => t.trim_start_matches('#'),
926+ };
927+ t.trim()
928+}
929+
930+fn not_found(info: &OwnerInfo) -> std::io::Error {
931+ std::io::Error::new(
932+ std::io::ErrorKind::NotFound,
933+ format!("{}: edited node no longer found", info.path),
934+ )
935+}
936+
937+fn hash(text: &str) -> String {
938+ blake3::hash(text.as_bytes()).to_hex().to_string()
419939}
420940
421−fn dedent(line: &str, cols: usize) -> &str {
422− if cols == 0 {
423− return line;
941+/// Where a block's source span (§5.2 step 5) is in `now`, a newer text of
942+/// its file than `known`, when the block's own text there is as it was:
943+/// `None` if the change touched it, or if the frontmatter names another
944+/// block now. A block (or root.md's Root) is its file after the
945+/// frontmatter (§4.9); another render root is `region` of `known`, whole
946+/// lines, followed through a line diff of `known` against `now`: its lines
947+/// must all be lines the diff keeps, in one unchanged run. Never where its
948+/// bytes merely occur in `now`: an identical node elsewhere is another
949+/// node.
950+fn span_now(known: &str, now: &str, region: Option<Span>) -> Option<Span> {
951+ let (k, n) = (parse_frontmatter(known), parse_frontmatter(now));
952+ let id = |f: &Option<Frontmatter>| f.as_ref().and_then(|f| f.props.get("id").cloned());
953+ if id(&k) != id(&n) {
954+ return None;
424955 }
425− let mut removed = 0;
426− for (i, ch) in line.char_indices() {
427− if removed >= cols {
428− return &line[i..];
956+ let Some(region) = region else {
957+ let body = |f: &Option<Frontmatter>| f.as_ref().map_or(0, |f| f.span.end);
958+ let (kb, nb) = (body(&k), body(&n));
959+ return (known[kb..] == now[nb..]).then_some(Span { start: nb, end: now.len() });
960+ };
961+ let diff = similar::TextDiff::from_lines(known, now);
962+ // the byte offset where each line starts, and the text's end
963+ let starts = |lines: &[&str]| {
964+ let mut at = vec![0];
965+ for l in lines {
966+ at.push(at[at.len() - 1] + l.len());
429967 }
430− match ch {
431− ' ' => removed += 1,
432− '\t' => removed += 4,
433− _ => return &line[i..],
968+ at
969+ };
970+ let (old, new) = (starts(diff.old_slices()), starts(diff.new_slices()));
971+ let line = |b: usize| old.binary_search(&b.min(known.len())).ok();
972+ let (first, end) = (line(region.start)?, line(region.end)?);
973+ diff.ops().iter().find_map(|op| match *op {
974+ similar::DiffOp::Equal { old_index, new_index, len } if old_index <= first && end <= old_index + len => {
975+ Some(Span {
976+ start: new[new_index + first - old_index],
977+ end: new[new_index + end - old_index],
978+ })
434979 }
435− }
436− ""
980+ _ => None,
981+ })
982+}
983+
984+/// The blocks whose embed in `f` has lines nested under it: nodes, or text
985+/// other than blank lines (§4.7: a diagnostic).
986+fn crowded_embeds(f: &ParsedFile) -> Vec<&Id> {
987+ f.nodes
988+ .iter()
989+ .filter(|n| {
990+ n.content.iter().any(|c| match c {
991+ Content::Text(sp) => !sp.text(&f.text).trim().is_empty(),
992+ Content::Node(_) => true,
993+ })
994+ })
995+ .filter_map(|n| n.embed.as_ref())
996+ .collect()
997+}
998+
999+/// Where a node's own text ends: its span without the blank lines that
1000+/// separate it from what follows.
1001+fn owned_end(text: &str, span: Span) -> usize {
1002+ let old = &text[span.start.min(text.len())..span.end.min(text.len())];
1003+ let content_end = old.trim_end_matches([' ', '\t', '\r', '\n']).len();
1004+ let own = old[content_end..]
1005+ .find('\n')
1006+ .map(|i| content_end + i + 1)
1007+ .unwrap_or(old.len());
1008+ span.start + own
4371009}
4381010
4391011/// What the editor needs to know when it opens: the buffer plus a render of
@@ -442,17 +1014,3 @@ pub fn open_editor(vault: &Vault, root: NRef) -> EditBuffer {
4421014 EditBuffer::build(vault, root)
4431015}
4441016
445−/// Renames detected by the splice: if a block's title line changed, its file
446−/// is renamed (§5.2 edge case). Returns the new title if the first owned
447−/// line's title differs from the stored one.
448−pub fn title_of_first_line(line: &str) -> Option<String> {
449− let t = line.trim_start();
450− if t.starts_with('#') {
451− let h = t.chars().take_while(|&c| c == '#').count();
452− Some(t[h..].trim().to_string())
453− } else if let Some(rest) = t.strip_prefix("- ") {
454− Some(rest.trim().to_string())
455− } else {
456− None
457− }
458−}
crates/fold-core/src/ident.rs+11−4
@@ -46,16 +46,17 @@ impl Id {
4646 }
4747
4848 /// The shortest leading run of words not used by any prefix in `taken`
49− /// (§6.4). The full id is always a valid prefix, so this terminates.
49+ /// (§6.4). The full id is always a valid prefix: if even it is taken
50+ /// (an id collision, reported elsewhere), it is returned anyway.
5051 pub fn shortest_prefix(&self, taken: &dyn Fn(&str) -> bool) -> String {
5152 let words = self.words();
52− for n in 1..=4 {
53+ for n in 1..4 {
5354 let cand = words[..n].join("-");
5455 if !taken(&cand) {
5556 return cand;
5657 }
5758 }
58− unreachable!("full id is always unique among existing prefixes")
59+ self.0.clone()
5960 }
6061
6162 pub fn as_str(&self) -> &str {
@@ -70,10 +71,11 @@ impl std::fmt::Display for Id {
7071}
7172
7273fn is_word(w: &str) -> bool {
74+ // ASCII first: slicing at byte 3 of a non-ASCII word would panic
7375 w.len() == 6
76+ && w.is_ascii()
7477 && PREFIXES.contains(&&w[..3])
7578 && SUFFIXES.contains(&&w[3..])
76− && w.is_ascii()
7779}
7880
7981/// True if `s` (already stripped of any `![[ ]]`) is a valid leading run of
@@ -177,6 +179,9 @@ mod tests {
177179 assert!(looks_like_id_prefix("racfer-hattes-dozzod-binwes"));
178180 assert!(!looks_like_id_prefix("racfer-hattes-dozzod-binwes-wat"));
179181 assert!(!looks_like_id_prefix("nope"));
182+ // non-ASCII words must be rejected, not panic
183+ assert!(Id::parse("ééaa-racfer-hattes-dozzod").is_none());
184+ assert!(!looks_like_id_prefix("ééé"));
180185 }
181186
182187 #[test]
@@ -196,6 +201,8 @@ mod tests {
196201 id.shortest_prefix(&|p| p == "racfer"),
197202 "racfer-hattes"
198203 );
204+ // every shorter run taken: the full id, never a panic
205+ assert_eq!(id.shortest_prefix(&|_| true), "racfer-hattes-mislup-nodrys");
199206 }
200207
201208 #[test]
crates/fold-core/src/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+567−290
@@ -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
@@ -14,60 +14,197 @@ pub struct MergeOutcome {
1414 pub conflict_blocks: Vec<(String, String)>,
1515 /// Number of conflict pairs raised.
1616 pub conflicts: usize,
17+ /// Conflict blocks raised against the file's own block root. Their embeds
18+ /// cannot go inside a block file (a second column-0 node, §4.9); they
19+ /// belong right after this file's embed in its parent file (§12.4).
20+ pub sibling_embeds: Vec<Id>,
21+}
22+
23+/// Merge state shared across the recursion.
24+struct Ctx<'a> {
25+ conflicts: usize,
26+ conflict_blocks: Vec<(String, String)>,
27+ sibling_embeds: Vec<Id>,
28+ /// Nodes inserted from `T` only; with no conflicts and no insertions the
29+ /// merge is `O` itself, byte for byte (§15.6).
30+ insertions: usize,
31+ device: &'a str,
32+ timestamp: &'a str,
33+ /// `O`'s and `T`'s block-file frontmatter (block files only).
34+ o_fm: Option<crate::parse::Frontmatter>,
35+ t_fm: Option<crate::parse::Frontmatter>,
1736}
1837
1938/// Merge two versions of one file. `device`/`timestamp` stamp the conflict
2039/// blocks (§12.4).
2140pub fn merge_texts(ours: &str, theirs: &str, device: &str, timestamp: &str) -> MergeOutcome {
41+ use crate::parse::parse_frontmatter;
2242 let o = standalone_tree(ours);
2343 let t = standalone_tree(theirs);
2444 // If both are block files, ids must match (§12.2); the caller handles
2545 // the prefix-collision case before calling us.
26− let mut conflicts = 0usize;
27− let mut conflict_blocks = Vec::new();
28− let merged_root = merge_children(
29− &o,
30− &t,
31− o.root,
32− t.root,
33− &mut conflicts,
34− &mut conflict_blocks,
46+ let is_block = |fm: &Option<crate::parse::Frontmatter>| {
47+ fm.as_ref().map(|f| f.props.contains_key("id")).unwrap_or(false)
48+ };
49+ let o_fm = parse_frontmatter(ours);
50+ let t_fm = parse_frontmatter(theirs);
51+ let block_file = is_block(&o_fm) && is_block(&t_fm);
52+ // where O's frontmatter ends
53+ let o_head = o_fm.as_ref().map(|fm| fm.span.end);
54+ let mut ctx = Ctx {
55+ conflicts: 0,
56+ conflict_blocks: Vec::new(),
57+ sibling_embeds: Vec::new(),
58+ insertions: 0,
3559 device,
3660 timestamp,
37− 1,
38− 0,
39− );
40− let mut text = merged_root;
41− // frontmatter merge for block files: key by key (§12.4)
42− text = merge_frontmatter(ours, theirs, &text, &mut conflicts, &mut conflict_blocks, device, timestamp);
61+ o_fm: o_fm.filter(|_| block_file),
62+ t_fm: t_fm.filter(|_| block_file),
63+ };
64+ let merged = merge_children(&o, &t, o.root, t.root, &mut ctx, 1, 0);
65+ let text = if ctx.conflicts == 0 && ctx.insertions == 0 {
66+ // T adds nothing: the merge is O, formatting included
67+ ours.to_string()
68+ } else {
69+ // O's frontmatter stays verbatim; differing keys were raised as a
70+ // conflict on the block root (§12.4)
71+ match o_head {
72+ Some(end) => {
73+ let mut head = ours[..end].to_string();
74+ if !head.ends_with("\n\n") {
75+ head.push('\n');
76+ }
77+ head + &merged
78+ }
79+ None => merged,
80+ }
81+ };
4382 MergeOutcome {
4483 text,
45− conflict_blocks,
46− conflicts,
84+ conflict_blocks: ctx.conflict_blocks,
85+ conflicts: ctx.conflicts,
86+ sibling_embeds: ctx.sibling_embeds,
4787 }
4888}
4989
90+/// Parse one version on its own, frontmatter included, so a block file's
91+/// frontmatter is never mistaken for body text (§12.4).
5092fn standalone_tree(text: &str) -> Tree {
51− let block = Block {
52− id: None,
53− path: "m.md".into(),
54− props: Default::default(),
55− frontmatter_raw: String::new(),
56− frontmatter_span: None,
57− edge_span: None,
58− };
59− let pf = parse_file("m.md", text, 0, Some(block));
60− Tree {
61− files: vec![pf],
62− root: (0, 0),
63− blocks: vec![],
64− }
93+ let fm = crate::parse::parse_frontmatter(text);
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))])
6596}
6697
6798fn node_sig(t: &Tree, r: NRef) -> (String, Option<TaskState>, String) {
6899 let n = t.node(r);
69− let body = n.body_lines(t.text_of(r)).join("\n");
70− (n.title.clone(), n.task, body)
100+ (n.title.clone(), n.task, text_sig(t, r))
101+}
102+
103+/// A node's text children as one field (§12.4): every run's lines, with the
104+/// number of child nodes before it, blank separator lines ignored.
105+fn text_sig(t: &Tree, r: NRef) -> String {
106+ let n = t.node(r);
107+ let mut out = String::new();
108+ for (before, sp) in n.text_runs() {
109+ let lines = run_lines(t, r, sp);
110+ if lines.is_empty() {
111+ continue;
112+ }
113+ out.push_str(&format!("{}\u{1}{}\u{2}", before, lines.join("\n")));
114+ }
115+ out
116+}
117+
118+/// A text run's lines, dedented from the node's content indent, without
119+/// leading or trailing blank lines.
120+fn run_lines(t: &Tree, r: NRef, sp: crate::parse::Span) -> Vec<String> {
121+ let n = t.node(r);
122+ let from = match n.kind {
123+ Kind::Item => n.indent + 2,
124+ Kind::Section => n.indent,
125+ Kind::Root => 0,
126+ };
127+ let mut lines: Vec<String> = sp
128+ .text(t.text_of(r))
129+ .lines()
130+ .map(|l| {
131+ let l = l.strip_suffix('\r').unwrap_or(l);
132+ if l.trim().is_empty() {
133+ String::new()
134+ } else {
135+ crate::render::dedent(l, from).to_string()
136+ }
137+ })
138+ .collect();
139+ while lines.first().map(|l| l.is_empty()) == Some(true) {
140+ lines.remove(0);
141+ }
142+ while lines.last().map(|l| l.is_empty()) == Some(true) {
143+ lines.pop();
144+ }
145+ lines
146+}
147+
148+/// A text run emitted at `indent`, ending in a newline ("" if it is blank).
149+fn emit_text(t: &Tree, r: NRef, sp: crate::parse::Span, indent: usize) -> String {
150+ let ind = " ".repeat(indent);
151+ let mut out = String::new();
152+ for l in run_lines(t, r, sp) {
153+ if !l.is_empty() {
154+ out.push_str(&ind);
155+ out.push_str(&l);
156+ }
157+ out.push('\n');
158+ }
159+ out
160+}
161+
162+/// One emitted child: a node's text or a text child.
163+enum Piece {
164+ Node(Kind, String),
165+ Text(String),
166+}
167+
168+/// Join emitted children: a blank line before and after text and before a
169+/// section; items follow each other tightly.
170+fn join_pieces(pieces: Vec<Piece>) -> String {
171+ let mut out = String::new();
172+ let mut prev_node: Option<Option<Kind>> = None; // Some(None) = text
173+ for p in pieces {
174+ let (kind, text) = match p {
175+ Piece::Node(k, t) => (Some(k), t),
176+ Piece::Text(t) => (None, t),
177+ };
178+ if text.trim().is_empty() {
179+ continue;
180+ }
181+ let blank = match prev_node {
182+ None => false,
183+ Some(prev) => {
184+ kind.is_none()
185+ || prev.is_none()
186+ || kind == Some(Kind::Section)
187+ || prev == Some(Kind::Section)
188+ }
189+ };
190+ if blank && !out.ends_with("\n\n") {
191+ out.push('\n');
192+ }
193+ out.push_str(&text);
194+ prev_node = Some(kind);
195+ }
196+ out
197+}
198+
199+/// Text runs of `r` after its child nodes: child position (0-based) → the
200+/// run that follows that child.
201+fn trailing_runs(t: &Tree, r: NRef) -> Vec<(usize, crate::parse::Span)> {
202+ t.node(r)
203+ .text_runs()
204+ .into_iter()
205+ .filter(|(before, _)| *before > 0)
206+ .map(|(before, sp)| (before - 1, sp))
207+ .collect()
71208}
72209
73210/// Match children of two parents: by embed id, then exact title (§12.4).
@@ -78,23 +215,37 @@ fn merge_children(
78215 t: &Tree,
79216 op: NRef,
80217 tp: NRef,
81− conflicts: &mut usize,
82− conflict_blocks: &mut Vec<(String, String)>,
83− device: &str,
84− timestamp: &str,
218+ ctx: &mut Ctx,
85219 level: usize,
86220 indent: usize,
87221) -> String {
88222 let o_kids = o.resolved_children(op);
89223 let t_kids = t.resolved_children(tp);
90− // match: embed by id, else by exact title among unmatched siblings
91− let mut t_used = vec![false; t_kids.len()];
92− let mut pairs: Vec<(Option<NRef>, Option<NRef>)> = Vec::new();
93− for &ok in &o_kids {
224+ // match: embed by id, else by exact title among unmatched siblings;
225+ // `t_match[i]` is the O kid that T kid `i` matched
226+ let mut t_match: Vec<Option<usize>> = vec![None; t_kids.len()];
227+ let mut pairs: Vec<(NRef, Option<NRef>)> = Vec::new();
228+ // a block file's root is the same node on both sides, whatever its
229+ // title: match it by the file's id (§12.4 rule 1)
230+ let file_id = |tr: &Tree, r: NRef| tr.node(r).block.as_ref().and_then(|b| b.id.clone());
231+ let root_pair = match (o_kids.first(), t_kids.first()) {
232+ (Some(&ok), Some(&tk)) if file_id(o, ok).is_some() && file_id(o, ok) == file_id(t, tk) => {
233+ Some((ok, tk))
234+ }
235+ _ => None,
236+ };
237+ for (j, &ok) in o_kids.iter().enumerate() {
94238 let on = o.node(ok);
239+ if let Some((rok, rtk)) = root_pair {
240+ if ok == rok {
241+ t_match[0] = Some(j);
242+ pairs.push((ok, Some(rtk)));
243+ continue;
244+ }
245+ }
95246 let mut found = None;
96247 for (i, &tk) in t_kids.iter().enumerate() {
97− if t_used[i] {
248+ if t_match[i].is_some() {
98249 continue;
99250 }
100251 let tn = t.node(tk);
@@ -111,49 +262,83 @@ fn merge_children(
111262 }
112263 }
113264 if let Some(i) = found {
114− t_used[i] = true;
115− pairs.push((Some(ok), Some(t_kids[i])));
116− } else {
117− pairs.push((Some(ok), None));
265+ t_match[i] = Some(j);
118266 }
267+ pairs.push((ok, found.map(|i| t_kids[i])));
119268 }
269+ // T-only nodes are insertions, placed relative to their matched
270+ // neighbours (§12.4): after the O partner of the nearest matched node
271+ // before them in T — and after O's text there too, when T has text
272+ // between the two — or first, when no matched node precedes them
273+ let t_trailing = trailing_runs(t, tp);
274+ let mut first: Vec<NRef> = Vec::new();
275+ let mut after_node: Vec<Vec<NRef>> = vec![Vec::new(); o_kids.len()];
276+ let mut after_text: Vec<Vec<NRef>> = vec![Vec::new(); o_kids.len()];
277+ let mut anchor: Option<(usize, usize)> = None; // (T kid, its O kid)
120278 for (i, &tk) in t_kids.iter().enumerate() {
121− if !t_used[i] {
122− pairs.push((None, Some(tk)));
279+ match (t_match[i], anchor) {
280+ (Some(j), _) => anchor = Some((i, j)),
281+ (None, None) => first.push(tk),
282+ (None, Some((ti, j))) => {
283+ let text_between = t_trailing
284+ .iter()
285+ .any(|&(pos, sp)| (ti..i).contains(&pos) && !run_lines(t, tp, sp).is_empty());
286+ if text_between {
287+ after_text[j].push(tk);
288+ } else {
289+ after_node[j].push(tk);
290+ }
291+ }
123292 }
124293 }
294+ ctx.insertions += t_match.iter().filter(|m| m.is_none()).count();
295+ let inserted = |&b: &NRef| Piece::Node(t.node(b).kind, emit_subtree(t, b, level, indent));
125296 // Emit: matched pairs merge field-by-field; single-sided nodes are
126− // insertions; differing fields raise conflict pairs (§12.4).
127− let mut out = String::new();
128− for (ok, tk) in pairs {
129− match (ok, tk) {
130− (Some(a), Some(b)) => {
131− out.push_str(&merge_pair(
132− o, t, a, b, conflicts, conflict_blocks, device, timestamp, level, indent,
133− ));
134− }
135− (Some(a), None) => {
136− out.push_str(&emit_subtree(o, a, level, indent));
137− }
138− (None, Some(b)) => {
139− out.push_str(&emit_subtree(t, b, level, indent));
297+ // insertions; differing fields raise conflict pairs (§12.4). O's text
298+ // children stay where O has them.
299+ let mut pieces: Vec<Piece> = Vec::new();
300+ if o.node(op).kind == Kind::Root {
301+ // the root's own text: O's, plus T's when it differs (never lost)
302+ if let Some(sp) = o.node(op).body_span() {
303+ pieces.push(Piece::Text(emit_text(o, op, sp, indent)));
304+ }
305+ if text_sig(o, op) != text_sig(t, tp) {
306+ ctx.insertions += 1;
307+ for (_, sp) in t.node(tp).text_runs() {
308+ pieces.push(Piece::Text(emit_text(t, tp, sp, indent)));
140309 }
141− (None, None) => {}
142310 }
143311 }
144− out
312+ pieces.extend(first.iter().map(inserted));
313+ let o_trailing = trailing_runs(o, op);
314+ for (j, (a, tk)) in pairs.into_iter().enumerate() {
315+ let text = match tk {
316+ Some(b) => merge_pair(o, t, a, b, ctx, level, indent),
317+ None => emit_subtree(o, a, level, indent),
318+ };
319+ pieces.push(Piece::Node(o.node(a).kind, text));
320+ pieces.extend(after_node[j].iter().map(inserted));
321+ for (_, sp) in o_trailing.iter().filter(|(pos, _)| *pos == j) {
322+ pieces.push(Piece::Text(emit_text(o, op, *sp, indent)));
323+ }
324+ pieces.extend(after_text[j].iter().map(inserted));
325+ }
326+ // clamped by the ordering rule (§3.1): an inserted item or text goes no
327+ // later than just before the first section, an inserted section no
328+ // earlier than just after the last item; O's own order already obeys it
329+ let (mut pieces, sections): (Vec<Piece>, Vec<Piece>) = pieces
330+ .into_iter()
331+ .partition(|p| !matches!(p, Piece::Node(Kind::Section, _)));
332+ pieces.extend(sections);
333+ join_pieces(pieces)
145334}
146335
147−#[allow(clippy::too_many_arguments)]
148336fn merge_pair(
149337 o: &Tree,
150338 t: &Tree,
151339 a: NRef,
152340 b: NRef,
153− conflicts: &mut usize,
154− conflict_blocks: &mut Vec<(String, String)>,
155− device: &str,
156− timestamp: &str,
341+ ctx: &mut Ctx,
157342 level: usize,
158343 indent: usize,
159344) -> String {
@@ -163,9 +348,16 @@ fn merge_pair(
163348 if on.embed.is_some() {
164349 return emit_subtree(o, a, level, indent);
165350 }
351+ // the file's own block root: its frontmatter keys are fields too (§12.4)
352+ let is_file_block = on.block.as_ref().map(|bl| bl.id.is_some()).unwrap_or(false);
166353 let (otitle, otask, obody) = node_sig(o, a);
167354 let (_ttitle, ttask, tbody) = node_sig(t, b);
168− let differ = otitle != tn.title || otask != ttask || obody != tbody;
355+ let mut differ = otitle != tn.title || otask != ttask || obody != tbody;
356+ if is_file_block {
357+ if let (Some(ofm), Some(tfm)) = (&ctx.o_fm, &ctx.t_fm) {
358+ differ |= ofm.props != tfm.props;
359+ }
360+ }
169361 // children merge
170362 let child_level = match on.kind {
171363 Kind::Section => level + 1,
@@ -175,31 +367,53 @@ fn merge_pair(
175367 Kind::Item => indent + 2,
176368 _ => indent,
177369 };
178− let merged_kids = merge_children(
179− o, t, a, b, conflicts, conflict_blocks, device, timestamp, child_level, child_indent,
370+ let merged_kids = merge_children(o, t, a, b, ctx, child_level, child_indent);
371+ if !differ {
372+ return emit_node_with(o, a, level, indent, &merged_kids);
373+ }
374+ ctx.conflicts += 1;
375+ // theirs becomes a conflict block stitched in next to ours (§12.4)
376+ let name = slug(&otitle);
377+ let id = Id::generate();
378+ let prefix = id.words()[0].to_string();
379+ let fname = filename(&prefix, &name);
380+ let mut block_text = format!(
381+ "---\nid: {}\nconflict: \"{} {}\"\n",
382+ id, ctx.device, ctx.timestamp
180383 );
384+ let t_props = if is_file_block {
385+ ctx.t_fm.as_ref().map(|f| f.props.clone())
386+ } else {
387+ None
388+ };
389+ match t_props {
390+ // a block conflict carries T's own properties (§12.4)
391+ Some(props) => {
392+ for (k, v) in props.iter().filter(|(k, _)| *k != "id" && *k != "conflict") {
393+ block_text.push_str(&format!("{}: {}\n", k, v));
394+ }
395+ }
396+ // T's task state travels as the checkbox on its title line (§4.5)
397+ None => {}
398+ }
399+ block_text.push_str("---\n\n");
400+ block_text.push_str(&emit_subtree(t, b, 1, 0));
401+ ctx.conflict_blocks.push((fname, block_text));
402+ if is_file_block {
403+ // a block file has one column-0 node: the embed goes after this
404+ // file's own embed in the parent file (the caller places it)
405+ ctx.sibling_embeds.push(id);
406+ return emit_node_with(o, a, level, indent, &merged_kids);
407+ }
408+ // the embed goes right after ours, as its next sibling, in the form of
409+ // ours' spelling (§4.7): a heading embed after a section's subtree
181410 let mut out = emit_node_with(o, a, level, indent, &merged_kids);
182− if differ {
183− *conflicts += 1;
184− // theirs becomes a conflict block stitched in next to ours (§12.4)
185− let name = slug(&otitle);
186− let id = Id::generate();
187− let prefix = id.words()[0].to_string();
188− let fname = filename(&prefix, &name);
189− let mut block_text = format!("---\nid: {}\nconflict: \"{} {}\"\n", id, device, timestamp);
190− if let Some(ts) = ttask {
191− block_text.push_str(&format!(
192− "todo: {}\n",
193− match ts {
194− TaskState::Open => "open",
195− TaskState::Done => "done",
196− }
197− ));
411+ if on.kind == Kind::Section {
412+ if !out.ends_with("\n\n") {
413+ out.push('\n');
198414 }
199− block_text.push_str("---\n\n");
200− block_text.push_str(&emit_subtree_with_state(t, b, 1, 0));
201− conflict_blocks.push((fname, block_text));
202− // the embed goes right after ours
415+ out.push_str(&format!("{}{} ![[{}]]\n", " ".repeat(indent), "#".repeat(level.max(1)), id));
416+ } else {
203417 out.push_str(&format!("{}![[{}]]\n", " ".repeat(indent), id));
204418 }
205419 out
@@ -207,41 +421,28 @@ fn merge_pair(
207421
208422/// Render a node at (level, indent) with given already-rendered children.
209423fn emit_node_with(o: &Tree, a: NRef, level: usize, indent: usize, kids: &str) -> String {
210− emit_node_with_impl(o, a, level, indent, kids, false)
211−}
212−
213−fn emit_node_with_impl(
214− o: &Tree,
215− a: NRef,
216− level: usize,
217− indent: usize,
218− kids: &str,
219− show_block_state: bool,
220−) -> String {
221424 let n = o.node(a);
222425 let mut out = String::new();
223426 let ind = " ".repeat(indent);
224− // A block root's task state normally lives only in frontmatter (§4.9);
225− // when emitting for resolution we surface it as a checkbox so the
226− // receiving side parses it back as state.
227− let is_block_root = n.is_block() && !show_block_state;
228427 match n.kind {
428+ // embeds stay unresolved here (§12.4): the line is the reference
429+ // itself, in its own form (§4.7), never an empty title
430+ Kind::Section | Kind::Item if n.embed.is_some() => {
431+ out.push_str(&crate::render::embed_line(n, level, indent));
432+ out.push('\n');
433+ }
229434 Kind::Section => {
230435 out.push_str(&ind);
231436 out.push_str(&"#".repeat(level.max(1)));
232437 out.push(' ');
233− if !is_block_root {
234− push_task(n.task, &mut out);
235− }
438+ crate::render::push_checkbox(n.task, &mut out);
236439 out.push_str(&n.title);
237440 out.push('\n');
238441 }
239442 Kind::Item => {
240443 out.push_str(&ind);
241444 out.push_str("- ");
242− if !is_block_root {
243− push_task(n.task, &mut out);
244− }
445+ crate::render::push_checkbox(n.task, &mut out);
245446 out.push_str(&n.title);
246447 out.push('\n');
247448 }
@@ -261,7 +462,7 @@ fn emit_node_with_impl(
261462 let dedent_by = o.indent(a);
262463 for l in &body {
263464 out.push_str(&ind);
264− out.push_str(dedent(l, dedent_by));
465+ out.push_str(crate::render::dedent(l, dedent_by));
265466 out.push('\n');
266467 }
267468 if !kids.trim().is_empty() {
@@ -274,16 +475,6 @@ fn emit_node_with_impl(
274475}
275476
276477fn emit_subtree(t: &Tree, r: NRef, level: usize, indent: usize) -> String {
277− emit_subtree_impl(t, r, level, indent, false)
278−}
279−
280−/// Emit with the block root's `todo:` shown as a checkbox — used for
281−/// conflict blocks so resolution can lift their state back (§12.5).
282−fn emit_subtree_with_state(t: &Tree, r: NRef, level: usize, indent: usize) -> String {
283− emit_subtree_impl(t, r, level, indent, true)
284−}
285−
286−fn emit_subtree_impl(t: &Tree, r: NRef, level: usize, indent: usize, show_block_state: bool) -> String {
287478 let kids = t.resolved_children(r);
288479 let child_level = match t.node(r).kind {
289480 Kind::Section => level + 1,
@@ -293,106 +484,90 @@ fn emit_subtree_impl(t: &Tree, r: NRef, level: usize, indent: usize, show_block_
293484 Kind::Item => indent + 2,
294485 _ => indent,
295486 };
296− let mut kid_text = String::new();
297− for (i, &k) in kids.iter().enumerate() {
298− if i > 0 && t.node(k).kind == Kind::Section {
299− kid_text.push('\n');
300− }
301− kid_text.push_str(&emit_subtree_impl(t, k, child_level, child_indent, show_block_state));
302− }
303− emit_node_with_impl(t, r, level, indent, &kid_text, show_block_state)
304−}
305−
306−fn push_task(task: Option<TaskState>, out: &mut String) {
307− match task {
308− Some(TaskState::Open) => out.push_str("[ ] "),
309− Some(TaskState::Done) => out.push_str("[x] "),
310− None => {}
311− }
312−}
313−
314−fn dedent(line: &str, cols: usize) -> &str {
315− if cols == 0 {
316− return line;
317− }
318− let mut removed = 0;
319− for (i, ch) in line.char_indices() {
320− if removed >= cols {
321− return &line[i..];
322− }
323− match ch {
324− ' ' => removed += 1,
325− '\t' => removed += 4,
326− _ => return &line[i..],
487+ // children in order: nodes and the text runs after them (the leading
488+ // run is the body, emitted with the title)
489+ let trailing = trailing_runs(t, r);
490+ let mut pieces = Vec::new();
491+ for (j, &k) in kids.iter().enumerate() {
492+ pieces.push(Piece::Node(
493+ t.node(k).kind,
494+ emit_subtree(t, k, child_level, child_indent),
495+ ));
496+ for (_, sp) in trailing.iter().filter(|(pos, _)| *pos == j) {
497+ pieces.push(Piece::Text(emit_text(t, r, *sp, child_indent)));
327498 }
328499 }
329− ""
330−}
331−
332−/// Merge the frontmatter of two block files key by key (§12.4). Ours wins
333−/// equal values; differing values keep ours in place and the whole "theirs"
334−/// document becomes a conflict block (already handled at the node level for
335−/// title/body; here we only fold in keys).
336−fn merge_frontmatter(
337− ours: &str,
338− theirs: &str,
339− merged: &str,
340− conflicts: &mut usize,
341− _conflict_blocks: &mut Vec<(String, String)>,
342− _device: &str,
343− _timestamp: &str,
344−) -> String {
345− use crate::parse::parse_frontmatter;
346− let ofm = parse_frontmatter(ours);
347− let tfm = parse_frontmatter(theirs);
348− let (Some(ofm), Some(tfm)) = (ofm, tfm) else {
349− return merged.to_string();
350− };
351− // key-level: every key that differs counts as a conflict; value from O
352− // stays. We re-emit frontmatter with O's raw text plus T-only keys dropped
353− // (they live in the conflict block's own frontmatter).
354− let raw = ofm.raw.clone();
355− for (k, v) in &tfm.props {
356− match ofm.props.get(k) {
357− Some(ov) if ov == v => {}
358− Some(_) => *conflicts += 1,
359− None => *conflicts += 1,
360− }
361− }
362− // Rebuild the file: merged frontmatter + merged body (strip merged's
363− // frontmatter if any).
364− let body = match crate::parse::parse_frontmatter(merged) {
365− Some(mfm) => merged[mfm.span.end..].to_string(),
366− None => merged.to_string(),
367− };
368− if raw.trim().is_empty() {
369− body
370− } else {
371− format!("---\n{}---\n\n{}", raw, body)
372− }
500+ emit_node_with(t, r, level, indent, &join_pieces(pieces))
373501}
374502
375503// ------------------------------------------------------------ vault-level
376504
377−/// 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`).
378506/// Returns a list of human-readable outcomes.
379507pub 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>> {
380522 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()?;
381526 for cfile in vault.conflict_files()? {
382527 let base = cfile.split(".sync-conflict-").next().unwrap().to_string() + ".md";
383528 let cpath = vault.dir.join(&cfile);
384529 let bpath = vault.dir.join(&base);
530+ // only root.md and block files are ours to merge: the conflict copy
531+ // of an ignored file is left alone, like the file (§4.1, §11.4)
532+ let parsed = base == "root.md" || vault.file_index(&base).is_some();
533+ if !parsed && bpath.exists() {
534+ outcomes.push(format!("{}: {} is not a vault file, left alone", cfile, base));
535+ continue;
536+ }
385537 let theirs = std::fs::read_to_string(&cpath)?;
386− let ours = std::fs::read_to_string(&bpath).unwrap_or_default();
538+ let tid = crate::parse::parse_frontmatter(&theirs).and_then(|f| f.id());
539+ // X.md gone: a block renamed since is still found by its id (§6.4)
540+ let bpath = match tid.as_ref().and_then(|id| vault.tree.block_by_id(id)) {
541+ Some(r) if !bpath.exists() => {
542+ vault.dir.join(&vault.tree.node(r).block.as_ref().unwrap().path)
543+ }
544+ _ => bpath,
545+ };
546+ let ours = match std::fs::read_to_string(&bpath) {
547+ Ok(text) => text,
548+ // nothing to merge against: T wins as it is, frontmatter and
549+ // all, rather than being merged into an empty O that has none
550+ Err(e) if e.kind() == std::io::ErrorKind::NotFound && (parsed || tid.is_some()) => {
551+ outcomes.push(format!("{}: {} missing, keeping theirs", cfile, base));
552+ if !dry_run {
553+ std::fs::rename(&cpath, &bpath)?;
554+ }
555+ continue;
556+ }
557+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
558+ outcomes.push(format!("{}: not a block file, left alone", cfile));
559+ continue;
560+ }
561+ // an O that cannot be read is never overwritten
562+ Err(e) => {
563+ outcomes.push(format!("{}: cannot read {}: {}, left alone", cfile, base, e));
564+ continue;
565+ }
566+ };
387567 // device + timestamp from the filename
388568 let (device, stamp) = parse_conflict_name(&cfile);
389569 // id check: differing ids mean a prefix collision, not a conflict (§12.2)
390− let oid = crate::parse::parse_frontmatter(&ours)
391− .and_then(|f| f.props.get("id").cloned())
392− .and_then(|v| Id::parse(&v));
393− let tid = crate::parse::parse_frontmatter(&theirs)
394− .and_then(|f| f.props.get("id").cloned())
395− .and_then(|v| Id::parse(&v));
570+ let oid = crate::parse::parse_frontmatter(&ours).and_then(|f| f.id());
396571 match (&oid, &tid) {
397572 (Some(a), Some(b)) if a != b => {
398573 outcomes.push(format!(
@@ -413,21 +588,30 @@ pub fn merge_sync_conflicts(vault: &mut Vault, dry_run: bool) -> std::io::Result
413588 }
414589 _ => {}
415590 }
416− let outcome = merge_texts(&ours, &theirs, &device, &stamp);
591+ let outcome = merge_texts(&ours, &without_embeds(&theirs, moving), &device, &stamp);
417592 outcomes.push(format!(
418593 "{}: {} conflict pair(s)",
419594 cfile, outcome.conflicts
420595 ));
421596 if !dry_run {
597+ // the trash first, so a trash that cannot be made stops the
598+ // merge before anything is written
599+ let trash = crate::vault::trash_dir();
600+ std::fs::create_dir_all(&trash)?;
422601 crate::vault::atomic_write(&bpath, &outcome.text)?;
423602 for (fname, text) in &outcome.conflict_blocks {
424− crate::vault::atomic_write(&vault.dir.join(fname), text)?;
603+ let fname = fresh_block_name(vault, fname, text);
604+ crate::vault::atomic_write(&vault.dir.join(&fname), text)?;
425605 }
426− // the conflict file goes to trash (§12.4)
427− let trash = crate::vault::trash_dir();
428− std::fs::create_dir_all(&trash)?;
606+ if !outcome.sibling_embeds.is_empty() {
607+ vault.reload()?;
608+ place_sibling_embeds(vault, oid.as_ref(), &outcome.sibling_embeds)?;
609+ }
610+ // the conflict file goes to trash (§12.4), which is often on
611+ // another filesystem: a rename alone would fail there and leave
612+ // it to be merged again
429613 let stamp2 = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S").to_string();
430− std::fs::rename(&cpath, trash.join(format!("{}-{}", stamp2, cfile.to_lowercase())))?;
614+ crate::vault::move_file(&cpath, &trash.join(format!("{}-{}", stamp2, cfile.to_lowercase())))?;
431615 }
432616 }
433617 if !dry_run {
@@ -436,6 +620,126 @@ pub fn merge_sync_conflicts(vault: &mut Vault, dry_run: bool) -> std::io::Result
436620 Ok(outcomes)
437621}
438622
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+
659+/// A conflict block's filename that does not clobber an existing file:
660+/// lengthen the prefix of its id until the name is free (§6.4).
661+fn fresh_block_name(vault: &Vault, fname: &str, text: &str) -> String {
662+ let id = crate::parse::parse_frontmatter(text).and_then(|f| f.id());
663+ let (Some(id), Some((_, name))) = (id, crate::ident::split_filename(fname)) else {
664+ return fname.to_string();
665+ };
666+ let words = id.words();
667+ for n in 1..=words.len() {
668+ let cand = filename(&words[..n].join("-"), name);
669+ if !vault.dir.join(&cand).exists() {
670+ return cand;
671+ }
672+ }
673+ fname.to_string()
674+}
675+
676+/// Insert embeds right after the embed of block `owner` in its parent file,
677+/// at its indent, so each conflict block is the next sibling of the block it
678+/// conflicts with (§12.4). Without an embed to follow (an orphan block),
679+/// the owner is embedded at the end of `root.md` with them right after it:
680+/// placed alone, they would pair with whatever node happened to be last.
681+fn place_sibling_embeds(vault: &mut Vault, owner: Option<&Id>, ids: &[Id]) -> std::io::Result<()> {
682+ // the embed the owner is stitched in at: a second one reads as broken
683+ // (§6.2) and pairs with nothing
684+ let found = owner.and_then(|oid| vault.tree.embed_of(oid)).map(|(fi, ni)| {
685+ let f = &vault.tree.files[fi];
686+ let nd = &f.nodes[ni];
687+ // the same form as the owner's embed (§4.7)
688+ let heading = (nd.kind == Kind::Section).then(|| nd.level.unwrap_or(1));
689+ (fi, nd.title_span.end.min(f.text.len()), nd.indent, heading)
690+ });
691+ let mut insert = String::new();
692+ match found {
693+ Some((fi, end, indent, heading)) => {
694+ for id in ids {
695+ match heading {
696+ Some(h) => insert.push_str(&format!(
697+ "\n\n{}{} ![[{}]]",
698+ " ".repeat(indent),
699+ "#".repeat(h),
700+ id
701+ )),
702+ None => insert.push_str(&format!("\n{}![[{}]]", " ".repeat(indent), id)),
703+ }
704+ }
705+ // right after the embed's own line, before its blank separator
706+ vault.write_span(fi, crate::parse::Span { start: end, end }, &insert)
707+ }
708+ None => {
709+ // in the form of the owner's spelling (§4.7); a heading embed at
710+ // the end of root.md is top-level
711+ let heading = owner
712+ .and_then(|oid| vault.tree.block_by_id(oid))
713+ .is_some_and(|r| vault.tree.node(r).kind == Kind::Section);
714+ let lines: Vec<String> = owner
715+ .into_iter()
716+ .chain(ids)
717+ .map(|id| format!("{}![[{}]]\n", if heading { "# " } else { "" }, id))
718+ .collect();
719+ let mut new_text = vault.tree.files[0].text.clone();
720+ if !new_text.is_empty() && !new_text.ends_with("\n\n") {
721+ new_text.push_str(if new_text.ends_with('\n') { "\n" } else { "\n\n" });
722+ }
723+ // sibling sections a blank line apart, items tight (§4.2)
724+ new_text.push_str(&lines.join(if heading { "\n" } else { "" }));
725+ vault.write_file_text(0, &new_text)
726+ }
727+ }
728+}
729+
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+
439743fn parse_conflict_name(name: &str) -> (String, String) {
440744 // name.sync-conflict-<date>-<time>-<device>.md
441745 let mut device = "unknown".to_string();
@@ -453,38 +757,46 @@ fn parse_conflict_name(name: &str) -> (String, String) {
453757 (device, stamp)
454758}
455759
456−/// Find unresolved conflict blocks: blocks with a `conflict:` key (§10.8).
760+/// Find unresolved conflict blocks: blocks with a `conflict:` key (§10.7).
457761/// Returns (ours, theirs) pairs; theirs is the block right after ours.
458762pub fn conflict_pairs(vault: &Vault) -> Vec<(NRef, NRef)> {
459763 conflict_pairs_tree(&vault.tree)
460764}
461765
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+
462785pub fn conflict_pairs_tree(tree: &Tree) -> Vec<(NRef, NRef)> {
463786 let mut out = Vec::new();
464− for (r, _id) in &tree.blocks {
465− let n = tree.node(*r);
466− if let Some(b) = &n.block {
467− if b.prop("conflict").is_some() {
468− let bid = b.id.clone().unwrap();
469− for (fi, f) in tree.files.iter().enumerate() {
470− for (ni, nd) in f.nodes.iter().enumerate() {
471− if nd.embed.as_ref() == Some(&bid) {
472− let p = nd.parent.map(|pp| (fi, pp)).unwrap();
473− let sibs = tree.raw_children(p);
474− if let Some(pos) = sibs.iter().position(|&s| s == (fi, ni)) {
475− if pos > 0 {
476− let ours = sibs[pos - 1];
477− let ours = {
478− let rc = tree.resolved_child(ours);
479− if rc != ours { rc } else { ours }
480− };
481− out.push((ours, *r));
482− }
483− }
484− }
485− }
486− }
487− }
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));
488800 }
489801 }
490802 out
@@ -505,36 +817,9 @@ pub fn resolve_keep_theirs(vault: &mut Vault, ours: NRef, theirs: NRef) -> std::
505817 let tb = vault.tree.node(theirs).block.clone();
506818 let Some(tb) = tb else { return Ok(()) };
507819 // 1. replace ours' subtree text with theirs' (re-levelled to ours' spot)
508− let mut theirs_body = crate::render::render(&vault.tree, theirs, 1, true);
509− // The conflict block's state lives in its frontmatter (§4.9); surface it
510− // as a checkbox so the receiving side parses it back as task state.
511− if let Some(state) = vault.tree.node(theirs).task {
512− let marker = match state {
513− TaskState::Open => "[ ] ",
514− TaskState::Done => "[x] ",
515− };
516− let mut lines = theirs_body.lines();
517− if let Some(first) = lines.next() {
518− let inserted = if let Some(pos) = first.find("- ") {
519− let mut s = first.to_string();
520− s.insert_str(pos + 2, marker);
521− s
522− } else if let Some(pos) = first.find('#') {
523− let h = first[pos..].find(' ').map(|i| pos + i + 1).unwrap_or(first.len());
524− let mut s = first.to_string();
525− s.insert_str(h, marker);
526− s
527− } else {
528− first.to_string()
529− };
530− let rest: Vec<&str> = lines.collect();
531− theirs_body = if rest.is_empty() {
532− format!("{}\n", inserted)
533− } else {
534− format!("{}\n{}\n", inserted, rest.join("\n"))
535− };
536− }
537− }
820+ let theirs_body = crate::render::render(&vault.tree, theirs, 1, true);
821+ // theirs' task state is the checkbox on its title line, already in the
822+ // rendered text (§4.5)
538823 let on = vault.tree.node(ours);
539824 if on.is_block() {
540825 // ours is a block: rewrite its file (after frontmatter) with theirs'
@@ -577,15 +862,12 @@ pub fn resolve_keep_theirs(vault: &mut Vault, ours: NRef, theirs: NRef) -> std::
577862 for (k, v) in tprops {
578863 crate::ops::set_frontmatter_key(vault, file, &k, Some(&v))?;
579864 }
580− // rename the file if the title changed (§6.4)
581− let new_title = vault.tree.node(ours).title.clone();
582− let _ = new_title;
583− vault.reload()?;
865+ // a title change does not rename the file (§6.4)
584866 } else {
585867 // plain node: replace its span with theirs' re-levelled text
586− let level = vault.tree.level(ours);
868+ let parent_level = on.parent.map(|p| vault.tree.level((ours.0, p))).unwrap_or(0);
587869 let indent = vault.tree.indent(ours);
588− let shifted = crate::ops::shift_document(&theirs_body, level, indent);
870+ let shifted = crate::ops::shift_document(&theirs_body, parent_level, indent);
589871 let file = ours.0;
590872 let span = on.span;
591873 vault.write_span(file, span, &shifted)?;
@@ -611,22 +893,17 @@ fn delete_embed_and_block(
611893 block_ref: NRef,
612894) -> std::io::Result<()> {
613895 // remove the embed line from whichever file holds it
614− if let Some(id) = &b.id {
615− for (fi, f) in vault.tree.files.iter().enumerate() {
616− if let Some(ni) = f.nodes.iter().position(|nd| nd.embed.as_ref() == Some(id)) {
617− let span = vault.tree.files[fi].nodes[ni].span;
618− let text = vault.tree.files[fi].text.clone();
619− let mut start = span.start;
620− let mut end = span.end;
621− if start >= 2 && &text[start - 2..start] == "\n\n" {
622− start -= 1;
623− } else if end < text.len() && text.as_bytes()[end] == b'\n' {
624− end += 1;
625− }
626− vault.write_span(fi, crate::parse::Span { start, end }, "")?;
627− break;
628− }
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;
629905 }
906+ vault.write_span(e.0, crate::parse::Span { start, end }, "")?;
630907 }
631908 let file = block_ref.0;
632909 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+1542−656
@@ -1,46 +1,268 @@
11//! Structural operations on the tree (§5.2, §6, §7, §8). Every op mutates
2−//! files through the vault and returns an inverse for the undo log (§10.11).
2+//! files through the vault; the undo log records what each one changed as
3+//! an `Inverse` (§10.10).
34
45use crate::ident::{slug, Id};
56use crate::parse::{Kind, Span, TaskState};
67use crate::render::render;
78use crate::tree::NRef;
8−use crate::vault::{NodeKey, Vault};
9+use crate::vault::Vault;
910
10−/// An inverse snapshot for undo (§10.11): file path → prior text. Files that
11−/// did not exist are recorded as None.
11+/// Every vault file's text at one moment, taken before an operation.
12+#[derive(Debug, Clone)]
13+pub struct Snapshot {
14+ files: Vec<(String, String)>,
15+ description: String,
16+}
17+
18+impl Snapshot {
19+ pub fn take(vault: &Vault, description: &str) -> Snapshot {
20+ Snapshot {
21+ files: vault
22+ .tree
23+ .files
24+ .iter()
25+ .map(|f| (f.path.clone(), f.text.clone()))
26+ .collect(),
27+ description: description.into(),
28+ }
29+ }
30+}
31+
32+/// One file an operation touched: its text before and after (`None`:
33+/// absent — created or deleted by the operation).
34+#[derive(Debug, Clone)]
35+pub struct Change {
36+ pub path: String,
37+ pub before: Option<String>,
38+ pub after: Option<String>,
39+}
40+
41+/// One entry of the session op log (§10.10): exactly the files an operation
42+/// changed, created or deleted, before and after.
1243#[derive(Debug, Clone)]
1344pub struct Inverse {
14− pub files: Vec<(String, Option<String>)>,
45+ pub changes: Vec<Change>,
1546 pub description: String,
1647}
1748
1849impl Inverse {
19− pub fn apply(self, vault: &mut Vault) -> std::io::Result<()> {
20− for (path, text) in self.files {
21− match text {
22− Some(t) => crate::vault::atomic_write(&vault.dir.join(&path), &t)?,
23− None => {
24− let _ = std::fs::remove_file(vault.dir.join(&path));
25− }
50+ /// What changed since `snap` was taken; `None` if nothing did, so an
51+ /// operation that did nothing leaves no entry.
52+ pub fn since(snap: Snapshot, vault: &Vault) -> Option<Inverse> {
53+ let mut changes = Vec::new();
54+ for (path, before) in &snap.files {
55+ let after = vault.tree.files.iter().find(|f| f.path == *path).map(|f| &f.text);
56+ if after != Some(before) {
57+ changes.push(Change {
58+ path: path.clone(),
59+ before: Some(before.clone()),
60+ after: after.cloned(),
61+ });
62+ }
63+ }
64+ for f in &vault.tree.files {
65+ if !snap.files.iter().any(|(p, _)| *p == f.path) {
66+ changes.push(Change {
67+ path: f.path.clone(),
68+ before: None,
69+ after: Some(f.text.clone()),
70+ });
71+ }
72+ }
73+ (!changes.is_empty()).then(|| Inverse {
74+ changes,
75+ description: snap.description,
76+ })
77+ }
78+
79+ /// Undo: put every touched file back as it was before. Refuses, writing
80+ /// nothing, if any of them is no longer as the operation left it — an
81+ /// external change since then (§10.10).
82+ pub fn undo(&self, vault: &mut Vault) -> std::io::Result<()> {
83+ self.swap(vault, false)
84+ }
85+
86+ /// Redo: the reverse, with the same check against the `before` side.
87+ pub fn redo(&self, vault: &mut Vault) -> std::io::Result<()> {
88+ self.swap(vault, true)
89+ }
90+
91+ fn swap(&self, vault: &mut Vault, forward: bool) -> std::io::Result<()> {
92+ for c in &self.changes {
93+ let expect = if forward { &c.before } else { &c.after };
94+ let now = std::fs::read_to_string(vault.dir.join(&c.path)).ok();
95+ if now != *expect {
96+ return Err(io_err(&format!(
97+ "{} changed since {}; not overwriting",
98+ c.path, self.description
99+ )));
100+ }
101+ }
102+ for c in &self.changes {
103+ let full = vault.dir.join(&c.path);
104+ match if forward { &c.after } else { &c.before } {
105+ Some(t) => crate::vault::atomic_write(&full, t)?,
106+ None => std::fs::remove_file(&full)?,
26107 }
27108 }
28109 vault.reload()
29110 }
30111}
31112
32−fn snapshot(vault: &Vault, description: &str) -> Inverse {
33− Inverse {
34− files: vault
35− .tree
36− .files
37− .iter()
38− .map(|f| (f.path.clone(), Some(f.text.clone())))
39− .collect(),
40− description: description.into(),
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+ })
41254 }
42255}
43256
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+
44266fn today() -> String {
45267 jiff::Zoned::now().date().to_string()
46268}
@@ -49,10 +271,7 @@ fn today() -> String {
49271
50272/// Append an item under today's day section of `Inbox` (§7).
51273pub fn capture(vault: &mut Vault, text: &str, task: bool) -> std::io::Result<NRef> {
52− let inv = snapshot(vault, "capture");
53− let r = capture_inner(vault, text, task, None)?;
54− let _ = inv;
55− Ok(r)
274+ capture_inner(vault, text, task, None)
56275}
57276
58277pub fn capture_to(
@@ -70,19 +289,34 @@ fn capture_inner(
70289 task: bool,
71290 target: Option<NRef>,
72291) -> std::io::Result<NRef> {
292+ // The first line is the title; further lines are the item's body, so a
293+ // captured document (`capture < file.md`) nests under the item instead
294+ // of injecting structure beside it (§4.1): its headings are pushed below
295+ // the destination's level and every line is indented under the item.
296+ let text = text.trim();
297+ let (first, rest) = text.split_once('\n').unwrap_or((text, ""));
298+ let title = if task {
299+ format!("[ ] {}", first.trim())
300+ } else {
301+ first.trim().to_string()
302+ };
303+ // refused before the inbox or its day is created
304+ let mut line = item_line(&title)?;
73305 let dest = match target {
74− Some(t) => t,
306+ Some(t) => write_target(&vault.tree, t)?,
75307 None => {
76308 let inbox = find_or_create_inbox(vault)?;
77309 find_or_create_day(vault, inbox)?
78310 }
79311 };
80− let title = if task {
81− format!("[ ] {}", text.trim())
82− } else {
83− text.trim().to_string()
84− };
85− append_child_line(vault, dest, &format!("- {}", title), task)
312+ if !rest.trim().is_empty() {
313+ let item_indent = child_indent(&vault.tree, dest);
314+ let level = vault.tree.level(dest) as isize;
315+ let body = shift_lines(rest.trim_end(), level, item_indent as isize + 2);
316+ line.push('\n');
317+ line.push_str(body.trim_end_matches('\n'));
318+ }
319+ append_child_line(vault, dest, &line, false)
86320}
87321
88322fn find_or_create_inbox(vault: &mut Vault) -> std::io::Result<NRef> {
@@ -103,20 +337,24 @@ fn find_or_create_day(vault: &mut Vault, inbox: NRef) -> std::io::Result<NRef> {
103337 return Ok(c);
104338 }
105339 }
106− // `## <today>` as the last child of Inbox (§7)
340+ // `## <today>` as the last child of Inbox (§7), at its child indent: an
341+ // Inbox respelled as an item is still the inbox (§3.1); no deeper than
342+ // a day before it as written, which would take it as its child
343+ let last = vault.tree.raw_children(inbox).last().copied();
107344 let level = vault.tree.level(inbox) + 1;
108− let indent = vault.tree.indent(inbox);
345+ let level = written_level(&vault.tree, last).map_or(level, |w| level.min(w));
346+ let indent = child_indent(&vault.tree, inbox);
109347 let heading = format!(
110348 "{}{} {}",
111349 " ".repeat(indent),
112350 "#".repeat(level),
113351 day
114352 );
353+ let inbox_key = vault.key_of(inbox);
115354 append_structural_line(vault, inbox, &heading)?;
116− vault.reload()?;
117355 let inbox = vault
118− .find_by_path(&vault_inbox_path(vault))
119− .ok_or_else(|| io_err("inbox lost after reload"))?;
356+ .find_by_key(&inbox_key)
357+ .ok_or_else(|| io_err("inbox lost after the write"))?;
120358 for c in vault.tree.resolved_children(inbox) {
121359 if vault.tree.node(c).title == day {
122360 return Ok(c);
@@ -125,16 +363,6 @@ fn find_or_create_day(vault: &mut Vault, inbox: NRef) -> std::io::Result<NRef> {
125363 Err(io_err("day section not created"))
126364}
127365
128−fn vault_inbox_path(vault: &Vault) -> Vec<String> {
129− let root = vault.tree.root;
130− for c in vault.tree.resolved_children(root) {
131− if vault.tree.node(c).title.eq_ignore_ascii_case("inbox") {
132− return vault.tree.path(c);
133− }
134− }
135− vec!["Inbox".into()]
136−}
137−
138366fn append_top_section(vault: &mut Vault, title: &str) -> std::io::Result<NRef> {
139367 let root_file = 0;
140368 let old = vault.tree.files[root_file].text.clone();
@@ -160,161 +388,252 @@ fn append_top_section(vault: &mut Vault, title: &str) -> std::io::Result<NRef> {
160388fn append_structural_line(vault: &mut Vault, parent: NRef, line: &str) -> std::io::Result<()> {
161389 let node = vault.tree.node(parent);
162390 let file = parent.0;
163− // insert at the end of the parent's span
164− let mut pos = node.span.end;
165− let text = &vault.tree.files[file].text;
166− // skip trailing newlines of the span to insert before them
167− while pos > node.span.start && text.as_bytes()[pos - 1] == b'\n' {
391+ let text = vault.tree.files[file].text.clone();
392+ let pos = trimmed_end(&text, node.span);
393+ insert_at(vault, file, pos, "\n\n", line)
394+}
395+
396+/// End of a node's span without its trailing newlines: where a new last
397+/// child is inserted.
398+fn trimmed_end(text: &str, span: Span) -> usize {
399+ let mut pos = span.end.min(text.len());
400+ while pos > span.start && text.as_bytes()[pos - 1] == b'\n' {
168401 pos -= 1;
169402 }
170− let insertion = format!("\n\n{}\n", line);
403+ pos
404+}
405+
406+/// Insert `sep` + `body` at `pos`, reusing the newline already there (the
407+/// one `trimmed_end` stepped back over) so no blank line is doubled.
408+fn insert_at(
409+ vault: &mut Vault,
410+ file: usize,
411+ pos: usize,
412+ sep: &str,
413+ body: &str,
414+) -> std::io::Result<()> {
415+ let text = &vault.tree.files[file].text;
416+ let body = body.trim_end_matches('\n');
417+ let insertion = if text[pos..].starts_with('\n') {
418+ format!("{}{}", sep, body)
419+ } else {
420+ format!("{}{}\n", sep, body)
421+ };
171422 vault.write_span(file, Span { start: pos, end: pos }, &insertion)
172423}
173424
174425/// Append an item line as the last child of a node; returns the new node.
426+///
427+/// An item cannot follow a section child: it would parse as that section's
428+/// child. With `section_ok` (the TUI's `N`) the new node is then spelled as a
429+/// section beside the last one; otherwise (capture) it stays an item and goes
430+/// after the parent's other items, before its first section.
175431fn append_child_line(
176432 vault: &mut Vault,
177433 parent: NRef,
178434 line: &str,
179− _task: bool,
435+ section_ok: bool,
180436) -> std::io::Result<NRef> {
181− let parent_path = vault.tree.path(parent);
437+ // under the node an embed stands for: an embed line has no children
438+ // in its file (§6.2)
439+ let parent = write_target(&vault.tree, parent)?;
182440 let parent_key = vault.key_of(parent);
183441 let node = vault.tree.node(parent);
184442 let file = parent.0;
185443 // Items under a section sit at the section's own indent; items under an
186444 // item nest one level deeper (§3.1, §4.10).
187− let indent = if node.kind == Kind::Item {
188− vault.tree.indent(parent) + 2
189− } else {
190− vault.tree.indent(parent)
191− };
192− let line_indented = format!("{}{}", " ".repeat(indent), line);
445+ let indent = child_indent(&vault.tree, parent);
193446 let text = vault.tree.files[file].text.clone();
194− let mut pos = node.span.end;
195− while pos > node.span.start && text.as_bytes()[pos - 1] == b'\n' {
196− pos -= 1;
447+ let kids = vault.tree.raw_children(parent);
448+ let is_section = |r: NRef| vault.tree.node(r).kind == Kind::Section;
449+ let first_section = kids.iter().position(|&c| is_section(c));
450+ // §4.2 puts one blank line between a node's body and its first child:
451+ // an item with no body has its first child right under its title
452+ let bare_item = node.kind == Kind::Item && node.body_lines(&text).is_empty();
453+ let index;
454+ match (first_section, section_ok) {
455+ (Some(_), true) => {
456+ // a section sibling of the last section child
457+ let last = *kids.last().unwrap();
458+ let last = if is_section(last) {
459+ last
460+ } else {
461+ *kids.iter().rev().find(|&&c| is_section(c)).unwrap()
462+ };
463+ let title = line.trim_start().strip_prefix("- ").unwrap_or(line.trim_start());
464+ // at the last section's written indent, which its parent
465+ // reaches, and written level, which keeps it beside it
466+ let heading = format!(
467+ "{}{} {}",
468+ " ".repeat(vault.tree.node(last).indent),
469+ "#".repeat(written_level(&vault.tree, Some(last)).unwrap_or(1)),
470+ title
471+ );
472+ let pos = trimmed_end(&text, node.span);
473+ insert_at(vault, file, pos, "\n\n", &heading)?;
474+ index = kids.len();
475+ }
476+ (Some(fs), false) => {
477+ // before the first section child, after the items and body
478+ let line_indented = format!("{}{}", " ".repeat(indent), line);
479+ let mut pos = vault.tree.node(kids[fs]).span.start;
480+ while pos >= 2 && &text.as_bytes()[pos - 2..pos] == b"\n\n" {
481+ pos -= 1;
482+ }
483+ let prev_is_item = fs > 0 && !is_section(kids[fs - 1]);
484+ let sep = if prev_is_item || (fs == 0 && bare_item) { "" } else { "\n" };
485+ vault.write_span(
486+ file,
487+ Span { start: pos, end: pos },
488+ &format!("{}{}\n", sep, line_indented),
489+ )?;
490+ index = fs;
491+ }
492+ (None, _) => {
493+ let line_indented = format!("{}{}", " ".repeat(indent), line);
494+ let pos = trimmed_end(&text, node.span);
495+ // blank line before unless the previous sibling is also an item
496+ // (tight list), or there is none under a bare item
497+ let prev_is_item = kids.last().map(|&c| !is_section(c)).unwrap_or(bare_item);
498+ let sep = if prev_is_item { "\n" } else { "\n\n" };
499+ insert_at(vault, file, pos, sep, &line_indented)?;
500+ index = kids.len();
501+ }
197502 }
198− // blank line before unless the previous sibling is also an item (tight
199− // list) — the previous child is the last in document order
200− let prev_is_item = vault
201− .tree
202− .resolved_children(parent)
203− .last()
204− .map(|&c| vault.tree.node(c).kind == Kind::Item)
205− .unwrap_or(false);
206− let insertion = if prev_is_item {
207− format!("\n{}\n", line_indented)
208− } else {
209− format!("\n\n{}\n", line_indented)
210− };
211− vault.write_span(file, Span { start: pos, end: pos }, &insertion)?;
212− vault.reload()?;
213503 let parent = vault
214504 .find_by_key(&parent_key)
215− .or_else(|| vault.find_by_path(&parent_path))
216− .ok_or_else(|| io_err("parent lost after reload"))?;
217− let kids = vault.tree.resolved_children(parent);
218− kids.last()
505+ .ok_or_else(|| io_err("parent lost after the write"))?;
506+ vault
507+ .tree
508+ .resolved_children(parent)
509+ .get(index)
219510 .copied()
220511 .ok_or_else(|| io_err("child not created"))
221512}
222513
223514// ---------------------------------------------------------------- tasks
224515
225−/// Toggle task open/done (§8.1, §8.2).
516+/// Toggle task open/done (§8.1, §8.2). The state is the checkbox on the
517+/// title line for every node; a block also gets `done:` stamped or removed.
226518pub fn toggle_task(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
227− let n = vault.tree.node(r);
228− if n.is_block() {
229− let new_state = match n.task {
230− Some(TaskState::Open) | None => TaskState::Done,
231− Some(TaskState::Done) => TaskState::Open,
232− };
233− set_block_todo(vault, r, Some(new_state))
234− } else if n.is_embed() {
235− let target = vault.tree.resolved_child(r);
236− if target != r {
237− return toggle_task(vault, target);
238− }
239− Ok(())
240− } else {
241− // checkbox on the title line
242− let file = r.0;
243− let ts = n.title_span;
244− let line = ts.text(&vault.tree.files[file].text).to_string();
245− let (from, to) = match n.task {
246− Some(TaskState::Open) => ("[ ]", "[x]"),
247− Some(TaskState::Done) => ("[x]", "[ ]"),
248− None => return Ok(()), // not a task; `t` makes it one
249− };
250− let new_line = line.replacen(from, to, 1);
251− vault.write_span(file, ts, &new_line)
252− }
519+ let r = vault.tree.resolved_child(r);
520+ let new_state = match vault.tree.node(r).task {
521+ Some(TaskState::Open) => TaskState::Done,
522+ Some(TaskState::Done) => TaskState::Open,
523+ None => return Ok(()), // not a task; `t` makes it one
524+ };
525+ set_task(vault, r, Some(new_state))
253526}
254527
255528/// Toggle task-ness itself (§10.3 `t`).
256529pub fn toggle_taskness(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
530+ let r = write_target(&vault.tree, r)?;
531+ let state = match vault.tree.node(r).task {
532+ Some(_) => None,
533+ None => Some(TaskState::Open),
534+ };
535+ set_task(vault, r, state)
536+}
537+
538+/// Write a node's task state (§4.5, §8.1): the checkbox on its title line
539+/// (`None` removes it). For a block, `done:` is stamped when it is checked
540+/// and removed otherwise.
541+pub fn set_task(vault: &mut Vault, r: NRef, state: Option<TaskState>) -> std::io::Result<()> {
257542 let n = vault.tree.node(r);
258− if n.is_block() {
259− if n.task.is_some() {
260− set_block_todo(vault, r, None)
261− } else {
262− set_block_todo(vault, r, Some(TaskState::Open))
263− }
264− } else if n.is_embed() {
265− let target = vault.tree.resolved_child(r);
266− if target != r {
267− return toggle_taskness(vault, target);
543+ let is_block = n.is_block();
544+ let file = r.0;
545+ // a setext title has no marker to put the checkbox after: it is
546+ // rewritten as an ATX heading, underline and all (§4.2)
547+ let (ts, line) = setext_as_atx(&vault.tree, r).unwrap_or_else(|| {
548+ (n.title_span, n.title_span.text(&vault.tree.files[file].text).to_string())
549+ });
550+ let mark = |st: TaskState| match st {
551+ TaskState::Open => "[ ]",
552+ TaskState::Done => "[x]", // also rewrites `[X]` / `[-]`
553+ };
554+ let new_line = match (checkbox_span(&line), state) {
555+ (Some((start, _)), Some(st)) => {
556+ let mut l = line.clone();
557+ l.replace_range(start..start + 3, mark(st));
558+ l
268559 }
269− Ok(())
270− } else {
271− let file = r.0;
272− let ts = n.title_span;
273− let line = ts.text(&vault.tree.files[file].text).to_string();
274− let new_line = match n.task {
275− Some(TaskState::Open) => line.replacen("[ ] ", "", 1),
276− Some(TaskState::Done) => line
277− .replacen("[x] ", "", 1)
278− .replacen("[X] ", "", 1)
279− .replacen("[-] ", "", 1),
280− None => {
281− // insert `[ ] ` after the marker
282− if line.trim_start().starts_with('#') {
283− let idx = line.find(' ').map(|i| i + 1).unwrap_or(line.len());
284− format!("{}[ ] {}", &line[..idx], &line[idx..])
285− } else if let Some(pos) = line.find("- ") {
286− let mut s = line.clone();
287− s.replace_range(pos..pos + 2, "- [ ] ");
288− s
289− } else {
290− line.clone()
291− }
292− }
560+ (Some((start, end)), None) => format!("{}{}", &line[..start], &line[end..]),
561+ // insert right after the marker, whatever the indent
562+ (None, Some(st)) => match marker_end(&line) {
563+ Some(idx) => format!("{}{} {}", &line[..idx], mark(st), &line[idx..]),
564+ None => format!("{} {}", line.trim_end(), mark(st)),
565+ },
566+ (None, None) => line.clone(),
567+ };
568+ // `- [ ] ---` unchecked is `- ---`, a thematic break (§4.4)
569+ not_a_break(&new_line)?;
570+ if new_line != ts.text(&vault.tree.files[file].text) {
571+ vault.write_span(file, ts, &new_line)?;
572+ }
573+ if is_block {
574+ let done = match state {
575+ Some(TaskState::Done) => Some(today()),
576+ _ => None,
293577 };
294− vault.write_span(file, ts, &new_line)
578+ set_frontmatter_key(vault, file, "done", done.as_deref())?;
295579 }
580+ Ok(())
296581}
297582
298−/// Write `todo:`/`done:` for a task block (§8.2).
299−pub fn set_block_todo(
300− vault: &mut Vault,
301− r: NRef,
302− state: Option<TaskState>,
303−) -> std::io::Result<()> {
304− let file = r.0;
305− match state {
306− Some(TaskState::Done) => {
307− set_frontmatter_key(vault, file, "todo", Some("done"))?;
308− set_frontmatter_key(vault, file, "done", Some(&today()))
309− }
310− Some(TaskState::Open) => {
311− set_frontmatter_key(vault, file, "todo", Some("open"))?;
312− set_frontmatter_key(vault, file, "done", None)
583+/// Byte offset just past a title line's marker: `#…` plus its spaces, or
584+/// `- ` / `* ` / `+ `, after any indent.
585+fn marker_end(line: &str) -> Option<usize> {
586+ let indent = line.len() - line.trim_start().len();
587+ let rest = &line[indent..];
588+ if rest.starts_with('#') {
589+ let h = rest.bytes().take_while(|&b| b == b'#').count();
590+ let after = &rest[h..];
591+ let sp = after.len() - after.trim_start_matches(' ').len();
592+ if sp == 0 {
593+ return None;
313594 }
314− None => {
315− set_frontmatter_key(vault, file, "todo", None)?;
316− set_frontmatter_key(vault, file, "done", None)
595+ Some(indent + h + sp)
596+ } else if rest.starts_with("- ") || rest.starts_with("* ") || rest.starts_with("+ ") {
597+ Some(indent + 2)
598+ } else {
599+ None
600+ }
601+}
602+
603+/// A setext section's title (§4.2: read, converted on write) as an ATX
604+/// heading at its written level and indent, with the span it replaces: the
605+/// title line and its underline, the line after it. `None` for any other
606+/// node. The written level keeps what nests under the heading unchanged.
607+fn setext_as_atx(tree: &crate::tree::Tree, r: NRef) -> Option<(Span, String)> {
608+ let n = tree.node(r);
609+ let text = tree.text_of(r);
610+ let line = n.title_span.text(text);
611+ let t = line.trim_start();
612+ // every other section's title line is an ATX heading (or a heading embed)
613+ if n.kind != Kind::Section || atx_hashes(t).is_some() {
614+ return None;
615+ }
616+ let under = text[n.title_span.end..].find('\n').map_or(text.len(), |i| n.title_span.end + i + 1);
617+ let end = text[under..].find('\n').map_or(text.len(), |i| under + i);
618+ let indent = &line[..line.len() - t.len()];
619+ let atx = format!("{}{} {}", indent, "#".repeat(n.level.unwrap_or(1)), n.title);
620+ Some((Span { start: n.title_span.start, end }, atx))
621+}
622+
623+/// Byte range of the checkbox right after the marker (`[ ]`, `[x]`, `[X]`,
624+/// `[-]`) plus the space after it (§4.2). Checkbox-like text later in the
625+/// title is title text.
626+fn checkbox_span(line: &str) -> Option<(usize, usize)> {
627+ let me = marker_end(line)?;
628+ let rest = &line[me..];
629+ if ["[ ]", "[x]", "[X]", "[-]"].iter().any(|cb| rest.starts_with(cb)) {
630+ let mut end = me + 3;
631+ if line[end..].starts_with(' ') {
632+ end += 1;
317633 }
634+ Some((me, end))
635+ } else {
636+ None
318637 }
319638}
320639
@@ -329,17 +648,13 @@ pub fn set_frontmatter_key(
329648 value: Option<&str>,
330649) -> std::io::Result<()> {
331650 let f = vault.tree.files[file].clone();
332− let root_node = f.nodes[f.root_node].children.get(0).copied();
333− let block = root_node.and_then(|rn| f.nodes[rn].block.clone());
651+ let block = file_block(&f).cloned();
334652 let (mut raw, fm_span) = match &block {
335653 Some(b) => (b.frontmatter_raw.clone(), b.frontmatter_span),
336654 None => (String::new(), None),
337655 };
338656 // rewrite the line for `key` inside raw
339657 let mut lines: Vec<String> = raw.split_inclusive('\n').map(|s| s.to_string()).collect();
340− if !lines.is_empty() && !lines.last().unwrap().ends_with('\n') {
341− // keep as is; we append a newline when needed
342− }
343658 let key_prefix = format!("{}:", key);
344659 let mut found = false;
345660 for l in lines.iter_mut() {
@@ -386,14 +701,18 @@ pub fn set_frontmatter_key(
386701 vault.write_file_text(file, &new_text)
387702}
388703
704+/// The block whose frontmatter a file holds: a block file's root, its
705+/// first node (§4.9); root.md's, the implicit Root, whose frontmatter holds
706+/// vault-level properties.
707+fn file_block(f: &crate::parse::ParsedFile) -> Option<&crate::parse::Block> {
708+ let root = &f.nodes[f.root_node];
709+ root.block.as_ref().or_else(|| f.nodes[*root.children.first()?].block.as_ref())
710+}
711+
389712/// All top-level keys of a block's frontmatter, for the property editor.
390713pub fn frontmatter_lines(vault: &Vault, file: usize) -> Vec<(String, String, bool)> {
391714 // (key, value, editable) — unknown-structure lines are read-only (§10.6)
392− let f = &vault.tree.files[file];
393− let Some(&rn) = f.nodes[f.root_node].children.first() else {
394− return Vec::new();
395− };
396− let Some(b) = &f.nodes[rn].block else {
715+ let Some(b) = file_block(&vault.tree.files[file]) else {
397716 return Vec::new();
398717 };
399718 let mut out = Vec::new();
@@ -414,72 +733,162 @@ pub fn frontmatter_lines(vault: &Vault, file: usize) -> Vec<(String, String, boo
414733
415734// --------------------------------------------------------------- structure
416735
417−/// Delete a subtree: its span leaves its file; block files go to trash (§11.5).
418−pub fn delete_subtree(vault: &mut Vault, r: NRef) -> std::io::Result<String> {
419− let n = vault.tree.node(r);
420− if n.is_block() {
421− // delete the embed from the parent file and trash the block file
422− let b = n.block.clone().unwrap();
423− if let Some(edge) = b.edge_span {
424− // find which file holds the edge
425− for (fi, f) in vault.tree.files.iter().enumerate() {
426− if f.nodes.iter().any(|nd| nd.embed.as_ref() == b.id.as_ref()) {
427− let embed_node = f
428− .nodes
429− .iter()
430− .position(|nd| nd.embed.as_ref() == b.id.as_ref())
431− .unwrap();
432− let span = vault.tree.files[fi].nodes[embed_node].span;
433− remove_span_with_separator(vault, fi, span)?;
434− break;
435− }
436− }
437− let _ = edge;
736+/// Delete a subtree: its span leaves its file; block files go to trash (§11.5),
737+/// including blocks nested anywhere under it, so none is left orphaned.
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)?;
438752 }
439− let file = r.0;
440− vault.trash_file(file)?;
441− return Ok(format!("block {:?} trashed", b.path));
442753 }
443− if n.is_embed() {
444− // delete the embed line and trash the referenced block file
445− let id = n.embed.clone().unwrap();
446− let target = vault.tree.block_by_id(&id);
447− let file = r.0;
448− let span = vault.tree.node(r).span;
449− remove_span_with_separator(vault, file, span)?;
450− if let Some(t) = target {
451− let tf = t.0;
452− vault.trash_file(tf)?;
754+ Ok(count)
755+}
756+
757+/// `delete_subtree` for `r` alone.
758+fn delete_one(vault: &mut Vault, r: NRef) -> std::io::Result<usize> {
759+ let n = vault.tree.node(r);
760+ if n.kind == Kind::Root {
761+ return Err(io_err("cannot delete the root"));
762+ }
763+ let mut count = 0;
764+ vault.tree.walk(vault.tree.resolved_child(r), &mut |_, _| count += 1);
765+ if n.is_block() || n.is_embed() {
766+ let target = if n.is_embed() { vault.tree.resolved_child(r) } else { r };
767+ if target == r && n.is_embed() {
768+ // broken embed: just the line. So for a second embed of a
769+ // block, which renders as broken (§6.2): the block, and the
770+ // embed it is stitched in at, are not this line's
771+ let span = embed_line_span(&vault.tree, r);
772+ remove_span(vault, r.0, span)?;
773+ return Ok(1);
453774 }
454− return Ok("embed and block trashed".into());
775+ // the block itself first: it leaves by its own embed
776+ let ids = nested_block_ids(vault, target);
777+ if let Some((own, nested)) = ids.split_first() {
778+ trash_block(vault, own)?;
779+ trash_nested(vault, nested)?;
780+ }
781+ return Ok(count);
455782 }
456− // plain node: remove its span; trash a copy of the rendered text (§11.5)
783+ let ids = plain_remove(vault, r)?;
784+ trash_nested(vault, &ids)?;
785+ Ok(count)
786+}
787+
788+/// Ids of every block in the resolved subtree of `r` (pre-order, `r` itself
789+/// included if it is one).
790+fn nested_block_ids(vault: &Vault, r: NRef) -> Vec<Id> {
791+ let mut ids = Vec::new();
792+ vault.tree.walk(r, &mut |t, n| {
793+ if let Some(id) = t.node(n).block.as_ref().and_then(|b| b.id.clone()) {
794+ ids.push(id);
795+ }
796+ });
797+ ids
798+}
799+
800+/// Remove a plain node's span after writing a trash copy of its text;
801+/// returns the ids of blocks embedded under it, still to be trashed.
802+fn plain_remove(vault: &mut Vault, r: NRef) -> std::io::Result<Vec<Id>> {
803+ let ids = nested_block_ids(vault, r);
457804 let text = render(&vault.tree, r, 1, true);
458805 let name = format!("{}.md", slug(&vault.tree.node(r).title));
459806 vault.trash_text(&name, &text)?;
460− let file = r.0;
461807 let span = vault.tree.node(r).span;
462− remove_span_with_separator(vault, file, span)?;
463− Ok("subtree trashed".into())
808+ remove_span(vault, r.0, span)?;
809+ Ok(ids)
464810}
465811
466−/// Remove a span plus one adjacent blank separator line, then re-parse just
467−/// that file (no full reload, so other files' node refs stay valid).
468−fn remove_span_no_reload(vault: &mut Vault, file: usize, span: Span) -> std::io::Result<()> {
469− let text = vault.tree.files[file].text.clone();
470− let mut start = span.start;
471− let mut end = span.end;
472− if start >= 2 && &text[start - 2..start] == "\n\n" {
473− start -= 1;
474− } else if end < text.len() && text.as_bytes()[end] == b'\n' {
475− end += 1;
812+/// Remove the embed line a block is stitched in at (if it is still
813+/// anywhere) and move its file to the trash.
814+fn trash_block(vault: &mut Vault, id: &Id) -> std::io::Result<()> {
815+ if let Some(e) = vault.tree.embed_of(id) {
816+ let span = embed_line_span(&vault.tree, e);
817+ remove_span(vault, e.0, span)?;
476818 }
477− vault.write_span(file, Span { start, end }, "")
819+ if let Some(b) = vault.tree.block_by_id(id) {
820+ vault.trash_file(b.0)?;
821+ }
822+ Ok(())
823+}
824+
825+/// Move the files of blocks nested in a removed subtree to the trash. Their
826+/// embeds went with the lines and files that held them: an embed of one
827+/// still elsewhere is a second embed, a line of its own (§6.2), and stays.
828+fn trash_nested(vault: &mut Vault, ids: &[Id]) -> std::io::Result<()> {
829+ for id in ids {
830+ if let Some(b) = vault.tree.block_by_id(id) {
831+ vault.trash_file(b.0)?;
832+ }
833+ }
834+ Ok(())
835+}
836+
837+/// The part of an embed's span that is the embed itself: its line and the
838+/// blank lines after it. Lines nested under an embed are a diagnostic
839+/// (§4.7) that the reading pane shows in the parent, so they stay there
840+/// when the embed goes: nothing else holds a copy of them (§11.5).
841+fn embed_line_span(tree: &crate::tree::Tree, e: NRef) -> Span {
842+ let n = tree.node(e);
843+ let text = tree.text_of(e);
844+ let end = n.span.end.min(text.len());
845+ let mut at = text[n.title_span.end..end].find('\n').map_or(end, |i| n.title_span.end + i + 1);
846+ for l in text[at..end].split_inclusive('\n') {
847+ if !l.trim().is_empty() {
848+ break;
849+ }
850+ at += l.len();
851+ }
852+ Span { start: n.span.start, end: at }
478853}
479854
480−/// Remove a span plus one adjacent blank separator line.
481−fn remove_span_with_separator(vault: &mut Vault, file: usize, span: Span) -> std::io::Result<()> {
482− remove_span_no_reload(vault, file, span)
855+/// The byte range to cut when removing `span`: the node plus whatever blank
856+/// separator would otherwise be doubled or left dangling. A blank line that
857+/// separated the node from what follows stays when nothing blank precedes
858+/// it, so `- a\n- b\n\n# S` minus `b` keeps its blank before `# S`.
859+fn removal_range(text: &str, span: Span) -> (usize, usize) {
860+ let b = text.as_bytes();
861+ let end = span.end.min(text.len());
862+ let mut start = span.start.min(end);
863+ let mut end = end;
864+ let blank_before = start >= 2 && &b[start - 2..start] == b"\n\n";
865+ let ends_blank = end >= start + 2 && &b[end - 2..end] == b"\n\n";
866+ if end >= text.len() {
867+ if blank_before {
868+ start -= 1; // no trailing blank line at EOF
869+ }
870+ } else if start > 0 && ends_blank && !blank_before {
871+ end -= 1;
872+ }
873+ (start, end)
874+}
875+
876+/// Shift a byte position for the removal of `start..end`.
877+fn after_removal(pos: usize, start: usize, end: usize) -> usize {
878+ if pos >= end {
879+ pos - (end - start)
880+ } else if pos > start {
881+ start
882+ } else {
883+ pos
884+ }
885+}
886+
887+/// Remove a span plus one adjacent blank separator line, then re-parse just
888+/// that file, so other files' node refs stay valid.
889+fn remove_span(vault: &mut Vault, file: usize, span: Span) -> std::io::Result<()> {
890+ let (start, end) = removal_range(&vault.tree.files[file].text, span);
891+ vault.write_span(file, Span { start, end }, "")
483892}
484893
485894/// Yank: render the subtree resolved (the register's text) (§10.3).
@@ -487,54 +896,32 @@ pub fn yank(vault: &Vault, r: NRef) -> String {
487896 render(&vault.tree, r, 1, true)
488897}
489898
490−/// Paste rendered text after/before a node as siblings (§10.3 `p`/`P`).
491−pub fn paste(vault: &mut Vault, at: NRef, text: &str, after: bool) -> std::io::Result<()> {
492− let n = vault.tree.node(at);
493− if n.kind == Kind::Root {
899+/// Paste rendered text after/before a node as siblings (§10.3 `p`/`P`),
900+/// clamped by the ordering rule (§3.1).
901+pub fn paste(vault: &mut Vault, at: NRef, text: &str, after: bool) -> std::io::Result<Placed> {
902+ if vault.tree.node(at).kind == Kind::Root {
494903 return Err(io_err("cannot paste beside the root"));
495904 }
496− let file = at.0;
497− let indent = vault.tree.indent(at);
498− let level_base = vault.tree.level(at);
499− // re-indent/level the pasted text: parse it standalone and re-emit at
500− // the target's position.
501− let shifted = shift_document(text, level_base, indent);
502− let (pos, prefix) = if after {
503− let mut pos = n.span.end;
504− let t = &vault.tree.files[file].text;
505− while pos > n.span.start && t.as_bytes()[pos - 1] == b'\n' {
506− pos -= 1;
507− }
508− (pos, "\n\n")
509− } else {
510− (n.span.start, "")
511− };
512− let insertion = if after {
513− format!("{}{}", prefix, shifted)
514− } else {
515− format!("{}\n", shifted)
905+ // a block root stands in its parent file as its embed
906+ let at = stand_in(&vault.tree, at);
907+ let Some(parent) = vault.tree.node(at).parent.map(|p| (at.0, p)) else {
908+ return Err(io_err("cannot paste beside the root"));
516909 };
517− vault.write_span(file, Span { start: pos, end: pos }, &insertion)
910+ let pos = vault.tree.raw_children(parent).iter().position(|&k| k == at).unwrap_or(0);
911+ let shifted = shift_document(text, vault.tree.level(parent), child_indent(&vault.tree, parent));
912+ let (nodes, clamped) = place(vault, &[], parent, pos + after as usize, &shifted)?;
913+ Placed::of(&nodes, 0, clamped)
518914}
519915
520−/// Parse a standalone document and re-emit it at the given level/indent.
521−/// `level` is the display level the document's *root* should take; `indent`
522−/// its indent. Everything inside keeps its position relative to the root.
523−pub fn shift_document(text: &str, level: usize, indent: usize) -> String {
524− let block = crate::parse::Block {
525− id: None,
526− path: "clip.md".into(),
527− props: Default::default(),
528− frontmatter_raw: String::new(),
529− frontmatter_span: None,
530− edge_span: None,
531− };
532− let pf = crate::parse::parse_file("clip.md", text, 0, Some(block));
533− let tree = crate::tree::Tree {
534− files: vec![pf],
535− root: (0, 0),
536− blocks: vec![],
537− };
916+/// Parse a standalone document and re-emit it as children of a node whose
917+/// level is `parent_level` (the root's is 0), at `indent`: a top-level
918+/// section becomes a level `parent_level + 1` heading, a top-level item sits
919+/// in a section of level `parent_level`. Everything inside keeps its position
920+/// relative to its top-level node.
921+pub fn shift_document(text: &str, parent_level: usize, indent: usize) -> String {
922+ let block = crate::parse::Block::new("clip.md", None, None);
923+ let pf = crate::parse::parse_file("clip.md", text, Some(block));
924+ let tree = crate::tree::Tree::new(vec![pf]);
538925 let mut out = String::new();
539926 let kids = tree.resolved_children(tree.root);
540927 for (i, k) in kids.iter().enumerate() {
@@ -544,13 +931,13 @@ pub fn shift_document(text: &str, level: usize, indent: usize) -> String {
544931 let r = render(&tree, *k, 1, true);
545932 let first = r.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
546933 let t = first.trim_start();
547− let cur_level = if t.starts_with('#') {
548− t.chars().take_while(|&c| c == '#').count()
549− } else {
550− 1
934+ // render(_, 1) writes a section root at level 1 and an item root in
935+ // a section of level 1 (its nested sections at 2)
936+ let ld = match atx_hashes(t) {
937+ Some(cur_level) => parent_level as isize + 1 - cur_level as isize,
938+ None => parent_level as isize - 1,
551939 };
552940 let cur_indent = first.len() - t.len();
553− let ld = level as isize - cur_level as isize;
554941 let id = indent as isize - cur_indent as isize;
555942 out.push_str(&shift_lines(&r, ld, id));
556943 }
@@ -564,26 +951,33 @@ pub fn shift_document(text: &str, level: usize, indent: usize) -> String {
564951}
565952
566953/// Shift every line of a rendered document by (level_delta, indent_delta).
954+/// Fenced code is shifted too, so it stays in its node's region (§3.3), but
955+/// never re-levelled.
567956fn shift_lines(raw: &str, level_delta: isize, indent_delta: isize) -> String {
568957 let mut out = String::new();
569958 let mut fence: Option<(char, usize)> = None;
570959 for line in raw.split_inclusive('\n') {
571960 let l = line.strip_suffix('\n').unwrap_or(line);
572961 let nl = if line.ends_with('\n') { "\n" } else { "" };
573− if fence_transition(l, &mut fence) || fence.is_some() {
574− out.push_str(l);
962+ let in_code = crate::parse::fence_transition(l, &mut fence) || fence.is_some();
963+ let trimmed = l.trim_start();
964+ if trimmed.is_empty() {
575965 out.push_str(nl);
576966 continue;
577967 }
578− let trimmed = l.trim_start();
579− if trimmed.is_empty() {
968+ if in_code {
969+ // only the leading spaces change: a tab, or any indentation
970+ // inside the code, stays as written
971+ let code = l.trim_start_matches(' ');
972+ let cur_indent = l.len() - code.len();
973+ out.push_str(&" ".repeat((cur_indent as isize + indent_delta).max(0) as usize));
974+ out.push_str(code);
580975 out.push_str(nl);
581976 continue;
582977 }
583978 let cur_indent = l.len() - trimmed.len();
584979 let new_indent = (cur_indent as isize + indent_delta).max(0) as usize;
585− if trimmed.starts_with('#') {
586− let hashes = trimmed.chars().take_while(|&c| c == '#').count();
980+ if let Some(hashes) = atx_hashes(trimmed) {
587981 let new_level = (hashes as isize + level_delta).max(1) as usize;
588982 out.push_str(&" ".repeat(new_indent));
589983 out.push_str(&"#".repeat(new_level));
@@ -598,181 +992,460 @@ fn shift_lines(raw: &str, level_delta: isize, indent_delta: isize) -> String {
598992 out
599993}
600994
601−fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
602− let t = raw.trim_start();
603− let first = match t.chars().next() {
604− Some(c) if c == '`' || c == '~' => c,
605− _ => return false,
606− };
607− let count = t.chars().take_while(|&c| c == first).count();
608− if count < 3 {
609− return false;
610− }
611− match open {
612− None => {
613− *open = Some((first, count));
614− true
615− }
616− Some((c, n)) => {
617− if *c == first && count >= *n {
618− *open = None;
619− true
620− } else {
621− false
622− }
623− }
624− }
995+/// The level of an ATX heading line, given without its indent: `#`+ then a
996+/// space or the end of the line (§4.2). `#tag` is text, as the parser reads it.
997+fn atx_hashes(trimmed: &str) -> Option<usize> {
998+ let hashes = trimmed.bytes().take_while(|&b| b == b'#').count();
999+ let after = &trimmed[hashes..];
1000+ (hashes > 0 && (after.is_empty() || after.starts_with(' '))).then_some(hashes)
6251001}
6261002
6271003/// Refile: move a subtree under a new parent as its last child (§6.5).
628−pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<()> {
1004+pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<Placed> {
1005+ let dest = write_target(&vault.tree, dest)?;
6291006 let n = vault.tree.node(r);
630− // guard: cannot refile into own subtree
1007+ // guard: cannot refile into own subtree — ancestry followed through
1008+ // embeds, so a destination inside a nested block counts too
1009+ let embed_of_r = n.block.as_ref().and_then(|b| b.id.clone());
6311010 let mut anc = Some(dest);
1011+ let mut guard = 0;
6321012 while let Some(a) = anc {
633− if a == r {
1013+ let an = vault.tree.node(a);
1014+ if a == r || (embed_of_r.is_some() && an.embed == embed_of_r) {
6341015 return Err(io_err("cannot refile a node into itself"));
6351016 }
636− anc = vault.tree.node(a).parent.map(|p| (a.0, p));
1017+ guard += 1;
1018+ if guard > 10_000 {
1019+ break; // embed cycle: `check` reports it (§6.2)
1020+ }
1021+ anc = resolved_parent(&vault.tree, a);
6371022 }
638− if n.is_block() || n.is_embed() {
639− // move only the embed line (§6.5)
640− let embed_ref = if n.is_embed() {
641− r
642− } else {
643− // find the embed node for this block
644− let id = n.block.as_ref().unwrap().id.clone().unwrap();
645− let mut embed_ref = None;
646− for (fi, f) in vault.tree.files.iter().enumerate() {
647− for (ni, nd) in f.nodes.iter().enumerate() {
648− if nd.embed.as_ref() == Some(&id) {
649− embed_ref = Some((fi, ni));
650− }
651− }
652− }
653− match embed_ref {
654− Some(e) => e,
655− None => return Err(io_err("block has no embed")),
1023+ // a block moves by its embed line (§6.5); a plain node by its span, in
1024+ // its on-disk spelling, so nested blocks travel as their embed lines
1025+ let moving = stand_in(&vault.tree, r);
1026+ if vault.tree.node(moving).is_block() {
1027+ return Err(io_err("block has no embed"));
1028+ }
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');
6561055 }
657− };
658− let embed_text = {
659− let en = vault.tree.node(embed_ref);
660− en.span.text(&vault.tree.files[embed_ref.0].text).to_string()
661− };
662− let dest_key = vault.key_of(dest);
663− let dest_path = vault.tree.path(dest);
664− // remove embed from source (no reload)
665− let src_file = embed_ref.0;
666− let span = vault.tree.node(embed_ref).span;
667− remove_span_no_reload(vault, src_file, span)?;
668− let dest = vault
669− .find_by_key(&dest_key)
670− .or_else(|| vault.find_by_path(&dest_path))
671− .ok_or_else(|| io_err("destination lost"))?;
672− insert_child_text(vault, dest, &embed_text, true)
673− } else {
674− // plain node: same-file span surgery when possible (no reload, so
675− // sibling structure is preserved), cross-file otherwise.
676− let src_file = r.0;
677− let rendered = render(&vault.tree, r, 1, true);
678− let span = vault.tree.node(r).span;
679− if dest.0 == src_file && !vault.tree.node(dest).is_embed() {
680− let text = vault.tree.files[src_file].text.clone();
681− let dest_span = vault.tree.node(dest).span;
682− let level = vault.tree.level(dest) + 1;
683− let indent = if vault.tree.node(dest).kind == Kind::Item {
684− vault.tree.indent(dest) + 2
685− } else {
686− vault.tree.indent(dest)
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+ }
1084+}
1085+
1086+/// Where a dragged node is dropped (§10.3): before a node, as its sibling,
1087+/// or into it, as its last child. Both are clamped by the ordering rule.
1088+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1089+pub enum Drop {
1090+ Before,
1091+ Into,
1092+}
1093+
1094+/// Move `r` relative to `target` (§10.3 drag and drop). Refuses a move into
1095+/// `r`'s own subtree.
1096+pub fn move_node(vault: &mut Vault, r: NRef, target: NRef, drop: Drop) -> std::io::Result<Placed> {
1097+ let target = vault.tree.resolved_child(target);
1098+ if target == vault.tree.resolved_child(r) {
1099+ return Ok(Placed::stayed(&vault.tree, r));
1100+ }
1101+ match drop {
1102+ Drop::Into => refile(vault, r, target),
1103+ Drop::Before => {
1104+ let t = stand_in(&vault.tree, target);
1105+ let Some(parent) = vault.tree.node(t).parent.map(|p| (t.0, p)) else {
1106+ return Err(io_err("cannot move beside the root"));
6871107 };
688− let shifted = shift_document(&rendered, level, indent);
689− // remove r's span plus a preceding blank separator
690− let mut start = span.start;
691− if start >= 2 && &text[start - 2..start] == "\n\n" {
692− start -= 1;
1108+ // the destination parent must not sit inside the moving subtree
1109+ if within(&vault.tree, parent, r) {
1110+ return Err(io_err("cannot move a node into itself"));
6931111 }
694− let mut new_text = String::with_capacity(text.len());
695− new_text.push_str(&text[..start]);
696− new_text.push_str(&text[span.end..]);
697− // adjust insertion point for the removal
698− let removed = span.end - start;
699− let mut ins = if dest_span.end > start {
700− dest_span.end - removed
701− } else {
702− dest_span.end
703− };
704− while ins > 0 && new_text.as_bytes()[ins - 1] == b'\n' {
705− ins -= 1;
1112+ 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+ }
1122+ // the target's index among the children `place` sees: without
1123+ // the moving nodes, which may come before it
1124+ let idx = vault
1125+ .tree
1126+ .raw_children(parent)
1127+ .into_iter()
1128+ .filter(|k| !run.contains(k))
1129+ .position(|k| k == t)
1130+ .unwrap_or(0);
1131+ let doc = run_doc(&vault.tree, &run, parent);
1132+ let (nodes, clamped) = place(vault, &run, parent, idx, &doc)?;
1133+ Placed::of(&nodes, run.iter().position(|&m| m == moving).unwrap_or(0), clamped)
1134+ }
1135+ }
1136+}
1137+
1138+/// Whether `a` is `r` or inside its subtree, following embeds up.
1139+fn within(tree: &crate::tree::Tree, a: NRef, r: NRef) -> bool {
1140+ let r = tree.resolved_child(r);
1141+ let mut cur = Some(tree.resolved_child(a));
1142+ let mut guard = 0;
1143+ while let Some(c) = cur {
1144+ if c == r {
1145+ return true;
1146+ }
1147+ guard += 1;
1148+ if guard > 10_000 {
1149+ return false;
1150+ }
1151+ cur = resolved_parent(tree, c);
1152+ }
1153+ false
1154+}
1155+
1156+// ---------------------------------------------------------------- placement
1157+
1158+/// The node that stands for `r` among its parent's children in a file: a
1159+/// block root's embed (§4.7), else `r` itself.
1160+fn stand_in(tree: &crate::tree::Tree, r: NRef) -> NRef {
1161+ if r.0 != tree.root.0 && tree.node(r).is_block() {
1162+ if let Some(e) = resolved_parent(tree, r) {
1163+ if tree.node(e).is_embed() {
1164+ return e;
7061165 }
707− let kids = vault.tree.resolved_children(dest);
708− let all_items = !kids.is_empty()
709− && kids.iter().all(|&k| vault.tree.node(k).kind == Kind::Item);
710− let first_is_item = shifted.trim_start().starts_with("- ");
711− let sep = if all_items && first_is_item { "\n" } else { "\n\n" };
712− new_text.insert_str(ins, &format!("{}{}", sep, shifted));
713− vault.write_file_text(src_file, &new_text)?;
714− vault.reload()
715− } else {
716− let dest_key = vault.key_of(dest);
717− let dest_path = vault.tree.path(dest);
718− remove_span_with_separator(vault, src_file, span)?;
719− let dest = vault
720− .find_by_key(&dest_key)
721− .or_else(|| vault.find_by_path(&dest_path))
722− .ok_or_else(|| io_err("destination lost"))?;
723− insert_child_text(vault, dest, &rendered, false)
7241166 }
7251167 }
1168+ r
1169+}
1170+
1171+/// The node a verb writes into at `r`: a block's own embed is the block
1172+/// (§4.7). An embed that resolves to nothing, broken or a second embed of
1173+/// a block (§6.2), is only a line of the file that holds it: it has no
1174+/// checkbox, properties or children of its own, so the verb is refused
1175+/// rather than writing them into that file.
1176+fn write_target(tree: &crate::tree::Tree, r: NRef) -> std::io::Result<NRef> {
1177+ let t = tree.resolved_child(r);
1178+ if tree.node(t).is_embed() {
1179+ return Err(io_err("a broken or duplicate embed has no node to write to"));
1180+ }
1181+ Ok(t)
7261182}
7271183
728−/// Insert a rendered document as the last child of `parent`.
729−fn insert_child_text(
1184+/// Indent of a child of `parent` (§3.1): items nest under items, everything
1185+/// under a section stays at the section's own indent. Measured from the
1186+/// parent's written indent, not its derived one: nesting written with tabs
1187+/// or 4 spaces is read (§4.2), and a line at the derived child indent could
1188+/// fall short of such a parent and parse as its sibling.
1189+fn child_indent(tree: &crate::tree::Tree, parent: NRef) -> usize {
1190+ let n = tree.node(parent);
1191+ match n.kind {
1192+ Kind::Item => n.indent + 2,
1193+ Kind::Section => n.indent,
1194+ Kind::Root => 0,
1195+ }
1196+}
1197+
1198+/// Spellings of a document's top-level nodes, in order. A heading embed is
1199+/// a section, a bare embed an item (§4.7).
1200+fn top_kinds(doc: &str) -> Vec<Kind> {
1201+ let pf = crate::parse::parse_file("clip.md", doc, None);
1202+ pf.nodes[pf.root_node]
1203+ .children
1204+ .iter()
1205+ .map(|&c| pf.nodes[c].kind)
1206+ .collect()
1207+}
1208+
1209+/// The indent a document's first top-level node is written at.
1210+fn top_indent(doc: &str) -> Option<usize> {
1211+ let pf = crate::parse::parse_file("clip.md", doc, None);
1212+ pf.nodes[pf.root_node].children.first().map(|&c| pf.nodes[c].indent)
1213+}
1214+
1215+/// Clamp a wanted child index by the ordering rule `(text | item)*
1216+/// section*` (§3.1): items no later than the first section child, sections
1217+/// no earlier. `kids` are the parent's child nodes without the one moving.
1218+fn clamp_index(tree: &crate::tree::Tree, kids: &[NRef], want: usize, kinds: &[Kind]) -> usize {
1219+ let first_section = kids
1220+ .iter()
1221+ .position(|&k| tree.node(k).kind == Kind::Section)
1222+ .unwrap_or(kids.len());
1223+ let lo = if kinds.contains(&Kind::Section) { first_section } else { 0 };
1224+ let hi = if kinds.contains(&Kind::Item) { first_section } else { kids.len() };
1225+ want.min(kids.len()).clamp(lo, hi)
1226+}
1227+
1228+/// The level of `k` as written, if it is a section. A written level is
1229+/// honoured (§4.7): a section written after it deeper than that is its
1230+/// child, and one written before it shallower than that takes it (and
1231+/// every section after it) as a child (§3.1). The level `k`'s position
1232+/// gives can be deeper: under an item, sections may be written shallower.
1233+fn written_level(tree: &crate::tree::Tree, k: Option<NRef>) -> Option<usize> {
1234+ k.map(|k| tree.node(k)).filter(|n| n.kind == Kind::Section).map(|n| n.level.unwrap_or(1))
1235+}
1236+
1237+/// `doc` (shifted to a child position) with its top-level sections, and
1238+/// everything under them, re-levelled to stay siblings of the sections
1239+/// around them (`written_level`): no deeper than `prev`, the sibling
1240+/// section they are written after, and no shallower than `next`, the one
1241+/// they are written before.
1242+fn level_among(tree: &crate::tree::Tree, prev: Option<NRef>, next: Option<NRef>, doc: &str) -> String {
1243+ let (most, least) = (written_level(tree, prev), written_level(tree, next));
1244+ if most.is_none() && least.is_none() {
1245+ return doc.to_string();
1246+ }
1247+ let pf = crate::parse::parse_file("clip.md", doc, None);
1248+ let tops = &pf.nodes[pf.root_node].children;
1249+ // a document's sections come after its items (§3.1)
1250+ let Some(first) = tops.iter().map(|&c| &pf.nodes[c]).find(|n| n.kind == Kind::Section) else {
1251+ return doc.to_string();
1252+ };
1253+ // indentation closes a section before its level does (§4.2): a sibling
1254+ // written deeper than the sections, before them, or shallower, after
1255+ // them, is no parent or child of theirs, whatever its level
1256+ let most = most.filter(|_| prev.is_some_and(|p| tree.node(p).indent <= first.indent));
1257+ let least = least.filter(|_| next.is_some_and(|k| tree.node(k).indent >= first.indent));
1258+ let have = tops.last().and_then(|&c| pf.nodes[c].level).unwrap_or(1);
1259+ let want = most.map_or(have, |m| have.min(m)).max(least.unwrap_or(1));
1260+ if want == have {
1261+ return doc.to_string();
1262+ }
1263+ let at = first.span.start;
1264+ format!("{}{}", &doc[..at], shift_lines(&doc[at..], want as isize - have as isize, 0))
1265+}
1266+
1267+/// Write `doc` (already shifted to a child position of `parent`) as the
1268+/// `want`-th child node of `parent`, clamped by the ordering rule (§3.1).
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.
1276+fn place(
7301277 vault: &mut Vault,
1278+ moving: &[NRef],
7311279 parent: NRef,
732− text: &str,
733− is_embed: bool,
734−) -> std::io::Result<()> {
735− let node = vault.tree.node(parent);
1280+ want: usize,
1281+ doc: &str,
1282+) -> std::io::Result<(Vec<NRef>, bool)> {
1283+ let tree = &vault.tree;
1284+ let pnode = tree.node(parent);
1285+ let kids: Vec<NRef> = tree
1286+ .raw_children(parent)
1287+ .into_iter()
1288+ .filter(|k| !moving.contains(k))
1289+ .collect();
1290+ let kinds = top_kinds(doc);
1291+ if kinds.is_empty() {
1292+ return Err(io_err("nothing to place"));
1293+ }
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+ }
1310+ let first = kinds[0];
1311+ let last = *kinds.last().unwrap();
7361312 let file = parent.0;
737− // Items nest under items; everything under a section stays at the
738− // section's own indent (§3.1, §4.10).
739− let indent = if node.kind == Kind::Item {
740− vault.tree.indent(parent) + 2
741− } else {
742− vault.tree.indent(parent)
1313+ let mut text = tree.files[file].text.clone();
1314+ // the parent's content as it will be, without the moving nodes
1315+ let content: Vec<crate::parse::Content> = pnode
1316+ .content
1317+ .iter()
1318+ .copied()
1319+ .filter(|c| !matches!(c, crate::parse::Content::Node(n) if moving.contains(&(file, *n))))
1320+ .collect();
1321+ // positions in the original text, then adjusted for a same-file removal
1322+ let next = kids.get(idx).copied();
1323+ let mut pos = match next {
1324+ Some(k) => tree.node(k).span.start,
1325+ None => pnode.span.end.min(text.len()),
7431326 };
744− let level = vault.tree.level(parent) + 1;
745− let shifted = if is_embed {
746− let mut s = String::new();
747− for line in text.trim_end_matches('\n').split('\n') {
748− s.push_str(&" ".repeat(indent));
749− s.push_str(line);
750− s.push('\n');
1327+ // appending after the last child: is the list there tight? The last
1328+ // item's own trailing blank may just separate the parent from what
1329+ // follows it, so looseness is read between the items before it.
1330+ let item_kids: Vec<&crate::parse::Node> = kids
1331+ .iter()
1332+ .map(|&k| tree.node(k))
1333+ .filter(|n| n.kind == Kind::Item)
1334+ .collect();
1335+ let tight_after = match content.last() {
1336+ Some(crate::parse::Content::Node(k)) => {
1337+ let kn = &tree.files[file].nodes[*k];
1338+ kn.kind == Kind::Item
1339+ && match item_kids.len() {
1340+ 0 | 1 => true,
1341+ n => !item_kids[n - 2].span.text(&text).ends_with("\n\n"),
1342+ }
7511343 }
752− s
753− } else {
754− shift_document(text, level, indent)
1344+ Some(crate::parse::Content::Text(_)) => false,
1345+ None => pnode.kind == Kind::Item,
7551346 };
756− // Separator: item children of an item-only list stay tight; sections and
757− // first children of a body-less node get a blank line, except in a block
758− // file whose spacing is the file's own (canonical: one blank).
759− let kids = vault.tree.resolved_children(parent);
760− let all_items = !kids.is_empty()
761− && kids.iter().all(|&k| vault.tree.node(k).kind == Kind::Item);
762− let first_line_is_item = shifted.trim_start().starts_with("- ");
763− let sep = if all_items && first_line_is_item { "\n" } else { "\n\n" };
764− let t = vault.tree.files[file].text.clone();
765− let mut pos = node.span.end;
766− while pos > node.span.start && t.as_bytes()[pos - 1] == b'\n' {
767− pos -= 1;
1347+ // inserting before an item: after another item, the list is as tight as
1348+ // the text there says (read below); starting the list, it is read in
1349+ // the list as found, the moving nodes still in it: between `next` and
1350+ // the item after it, or else between the last moving node and `next`
1351+ // right after it. A list of one item is tight.
1352+ let is_item = |c: Option<&crate::parse::Content>| {
1353+ matches!(c, Some(crate::parse::Content::Node(k)) if tree.files[file].nodes[*k].kind == Kind::Item)
1354+ };
1355+ let next_at = next.and_then(|k| content.iter().position(|c| *c == crate::parse::Content::Node(k.1)));
1356+ let starts_run = next_at.is_some_and(|i| i == 0 || !is_item(content.get(i - 1)));
1357+ let found = &pnode.content;
1358+ let found_at = next.and_then(|k| found.iter().position(|c| *c == crate::parse::Content::Node(k.1)));
1359+ let no_blank_after = |k: NRef| {
1360+ let sp = tree.node(k).span;
1361+ !text[sp.start..sp.end.min(text.len())].ends_with("\n\n")
1362+ };
1363+ let run_tight = match (next, found_at) {
1364+ (Some(k), Some(i)) if is_item(found.get(i + 1)) => no_blank_after(k),
1365+ (Some(_), Some(i)) => match moving.last() {
1366+ Some(&m) if i > 0 && m.0 == file && found[i - 1] == crate::parse::Content::Node(m.1) => {
1367+ no_blank_after(m)
1368+ }
1369+ _ => true,
1370+ },
1371+ _ => true,
1372+ };
1373+ let next_kind = next.map(|k| tree.node(k).kind);
1374+ // the last first, so the spans before it stay where they are
1375+ for &m in moving.iter().rev().filter(|m| m.0 == file) {
1376+ let (start, end) = removal_range(&text, tree.node(m).span);
1377+ text = format!("{}{}", &text[..start], &text[end..]);
1378+ pos = after_removal(pos, start, end);
7681379 }
769− let insertion = format!("{}{}", sep, shifted);
770− vault.write_span(file, Span { start: pos, end: pos }, &insertion)?;
771− vault.reload()
1380+ // before a sibling written deeper than the node (tab or 4-space
1381+ // nesting is read, §4.2), the node goes at that sibling's indent: any
1382+ // shallower, the sibling would parse as its child
1383+ let doc = match (next.map(|k| tree.node(k).indent), top_indent(doc)) {
1384+ (Some(want), Some(have)) if want > have => shift_lines(doc, 0, (want - have) as isize),
1385+ _ => doc.to_string(),
1386+ };
1387+ let prev = idx.checked_sub(1).map(|i| kids[i]);
1388+ let doc = level_among(tree, prev, next, &doc);
1389+ let body = doc.trim_end_matches('\n');
1390+ let tops = top_starts(body);
1391+ // the insertion, and where `body` starts in it
1392+ let (insertion, at) = match next_kind {
1393+ Some(nk) => {
1394+ // before the next child node: keep a blank line before a
1395+ // heading and after anything that is not a tight item run
1396+ let lead = if first == Kind::Section && pos > 0 && !text[..pos].ends_with("\n\n") {
1397+ "\n"
1398+ } else {
1399+ ""
1400+ };
1401+ let tight = last == Kind::Item
1402+ && nk == Kind::Item
1403+ && if starts_run { run_tight } else { !text[..pos].ends_with("\n\n") };
1404+ (format!("{}{}\n{}", lead, body, if tight { "" } else { "\n" }), lead.len())
1405+ }
1406+ None => {
1407+ while pos > 0 && text.as_bytes()[pos - 1] == b'\n' {
1408+ pos -= 1;
1409+ }
1410+ let sep = if pos == 0 {
1411+ ""
1412+ } else if first == Kind::Item && tight_after {
1413+ "\n"
1414+ } else {
1415+ "\n\n"
1416+ };
1417+ let nl = if text[pos..].starts_with('\n') { "" } else { "\n" };
1418+ (format!("{}{}{}", sep, body, nl), sep.len())
1419+ }
1420+ };
1421+ let start = pos + at;
1422+ text.insert_str(pos, &insertion);
1423+ vault.write_file_text(file, &text)?;
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()
7721445}
7731446
7741447/// Archive: refile under the top-level `Archive` section (§6.5).
775−pub fn archive(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
1448+pub fn archive(vault: &mut Vault, r: NRef) -> std::io::Result<Placed> {
7761449 let root = vault.tree.root;
7771450 let mut dest = None;
7781451 for c in vault.tree.resolved_children(root) {
@@ -790,39 +1463,91 @@ pub fn archive(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
7901463
7911464/// Clear done: trash every done item with no open descendants under `target`
7921465/// (§8.5). Returns the number trashed.
1466+///
1467+/// Nodes are removed by span, last-in-file first, so no node is ever looked
1468+/// up again by its title path (two done items may share a title).
7931469pub fn clear_done(vault: &mut Vault, target: NRef) -> std::io::Result<usize> {
7941470 let mut to_clear: Vec<NRef> = Vec::new();
7951471 vault.tree.walk(target, &mut |t, r| {
7961472 let n = t.node(r);
7971473 if n.task == Some(TaskState::Done) {
798− let (open, _total) = t.task_counts(r);
799− 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 {
8001479 to_clear.push(r);
8011480 }
8021481 }
8031482 });
804− // Record reload-stable keys and ancestor relationships before mutating.
805− let keys: Vec<NodeKey> = to_clear.iter().map(|&r| vault.key_of(r)).collect();
806− let paths: Vec<Vec<String>> = to_clear.iter().map(|&r| vault.tree.path(r)).collect();
807− let mut done = 0;
808− // Deepest first; skip nodes whose ancestor is also being cleared.
809− let mut order: Vec<usize> = (0..to_clear.len()).collect();
810− order.sort_by_key(|&i| std::cmp::Reverse(paths[i].len()));
811− for i in order {
812− let is_descendant_of_cleared = to_clear
813− .iter()
814− .enumerate()
815− .any(|(j, _)| j != i && paths[j].len() < paths[i].len()
816− && paths[i][..paths[j].len()] == paths[j][..]);
817− if is_descendant_of_cleared {
818− continue;
819− }
820− if let Some(r) = vault.find_by_key(&keys[i]) {
821− delete_subtree(vault, r)?;
822− done += 1;
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();
1490+ // skip nodes under another node being cleared
1491+ let tops: Vec<NRef> = to_clear
1492+ .iter()
1493+ .copied()
1494+ .filter(|&r| {
1495+ let mut a = resolved_parent(&vault.tree, r);
1496+ let mut guard = 0;
1497+ while let Some(p) = a {
1498+ if to_clear.contains(&p) || guard > 10_000 {
1499+ return false;
1500+ }
1501+ guard += 1;
1502+ a = resolved_parent(&vault.tree, p);
1503+ }
1504+ true
1505+ })
1506+ .collect();
1507+ let mut blocks: Vec<Id> = Vec::new();
1508+ let mut plain: Vec<NRef> = Vec::new();
1509+ let mut nested: Vec<Id> = Vec::new();
1510+ for &r in &tops {
1511+ let n = vault.tree.node(r);
1512+ match n.block.as_ref().and_then(|b| b.id.clone()) {
1513+ Some(id) => {
1514+ // the block leaves by its own embed; blocks embedded in its
1515+ // file go to the trash with it, as delete_subtree sends them
1516+ let ids = nested_block_ids(vault, r);
1517+ nested.extend(ids.into_iter().filter(|i| *i != id));
1518+ blocks.push(id);
1519+ }
1520+ None => plain.push(r),
8231521 }
8241522 }
825− Ok(done)
1523+ // plain spans: per file, from the end backwards, so earlier spans stay valid
1524+ plain.sort_by_key(|&(f, n)| (f, std::cmp::Reverse(vault.tree.files[f].nodes[n].span.start)));
1525+ let mut done = 0;
1526+ for r in plain {
1527+ let ids = plain_remove(vault, r)?;
1528+ nested.extend(ids);
1529+ done += 1;
1530+ }
1531+ let n_block_tops = tops.len() - done;
1532+ for id in blocks {
1533+ trash_block(vault, &id)?;
1534+ }
1535+ trash_nested(vault, &nested)?;
1536+ Ok(done + n_block_tops)
1537+}
1538+
1539+/// A node's parent in the resolved tree: within its file, or — for a block
1540+/// root — the embed that stitches it in (§4.7).
1541+fn resolved_parent(tree: &crate::tree::Tree, r: NRef) -> Option<NRef> {
1542+ let p = tree.node(r).parent?;
1543+ let pr = (r.0, p);
1544+ if tree.node(pr).kind == Kind::Root && r.0 != tree.root.0 {
1545+ tree.embed_of(tree.node(r).block.as_ref()?.id.as_ref()?)
1546+ } else if tree.node(pr).kind == Kind::Root {
1547+ None
1548+ } else {
1549+ Some(pr)
1550+ }
8261551}
8271552
8281553// --------------------------------------------------------------- make block
@@ -836,265 +1561,408 @@ pub fn make_block(vault: &mut Vault, r: NRef) -> std::io::Result<Id> {
8361561 let id = Id::generate();
8371562 // the file: render(node, 1, false) with frontmatter (§6.1.2)
8381563 let body = render(&vault.tree, r, 1, false);
839− let mut file_text = format!("---\nid: {}\n", id);
840− // checkbox becomes todo: (§6.1.3)
841− if let Some(state) = n.task {
842− file_text.push_str(&format!(
843− "todo: {}\n",
844− match state {
845− TaskState::Open => "open",
846− TaskState::Done => "done",
847− }
848− ));
849− }
850− file_text.push_str("---\n\n");
851− // strip the checkbox from the root title line (§4.5)
852− let body_lines: Vec<&str> = body.lines().collect();
853− let mut stripped = String::new();
854− for (i, l) in body_lines.iter().enumerate() {
855− if i == 0 && n.task.is_some() {
856− let l2 = l
857− .replacen("[ ] ", "", 1)
858− .replacen("[x] ", "", 1)
859− .replacen("[X] ", "", 1)
860− .replacen("[-] ", "", 1);
861− stripped.push_str(&l2);
862− } else {
863− stripped.push_str(l);
864− }
865− stripped.push('\n');
866− }
867− file_text.push_str(&stripped);
1564+ // the checkbox stays on the title line: it is the state (§4.5)
1565+ let mut file_text = format!("---\nid: {}\n---\n\n", id);
1566+ file_text.push_str(&body);
8681567 let prefix = vault.unique_prefix(&id);
8691568 let fname = crate::ident::filename(&prefix, &slug(&n.title));
870− crate::vault::atomic_write(&vault.dir.join(&fname), &file_text)?;
871− // replace the node's span with an embed (§6.1.3)
1569+ // the embed cannot be written into a file changed on disk (§11.2), and
1570+ // a block file without it would be embedded nowhere: check first
8721571 let file = r.0;
1572+ vault.check_unchanged(file)?;
1573+ let full = vault.dir.join(&fname);
1574+ crate::vault::atomic_write(&full, &file_text)?;
1575+ // replace the node's span with an embed in the node's form (§6.1.3,
1576+ // §4.7), keeping the blank lines that separated it from what follows
8731577 let span = n.span;
874− let indent = vault.tree.indent(r);
875− let embed = format!("{}![[{}]]\n", " ".repeat(indent), id);
876− vault.write_span(file, span, &embed)?;
877− vault.reload()?;
1578+ let indent = " ".repeat(n.indent);
1579+ // at its level, but kept a sibling of the sections around it as they
1580+ // are written: no deeper than the one before it, which would take the
1581+ // embed as its child, nor shallower than the one after it
1582+ let kids = n.parent.map(|p| vault.tree.raw_children((file, p))).unwrap_or_default();
1583+ let at = kids.iter().position(|&c| c == r);
1584+ let prev = at.and_then(|i| i.checked_sub(1)).map(|i| kids[i]);
1585+ let next = at.and_then(|i| kids.get(i + 1).copied());
1586+ let mut embed = match n.kind {
1587+ Kind::Section => {
1588+ let line = format!("{}{} ![[{}]]\n", indent, "#".repeat(vault.tree.level(r)), id);
1589+ level_among(&vault.tree, prev, next, &line)
1590+ }
1591+ _ => format!("{}![[{}]]\n", indent, id),
1592+ };
1593+ let old = span.text(&vault.tree.files[file].text);
1594+ let trailing = old.len() - old.trim_end_matches('\n').len();
1595+ for _ in 1..trailing {
1596+ embed.push('\n');
1597+ }
1598+ if let Err(e) = vault.write_span(file, span, &embed) {
1599+ // changed in between after all: nothing refers to the new file yet,
1600+ // and the node is still in its parent
1601+ let _ = std::fs::remove_file(&full);
1602+ return Err(e);
1603+ }
1604+ vault.add_file(&fname, &file_text);
8781605 Ok(id)
8791606}
8801607
881−/// Set the first property on a plain node: makes it a block, then sets the
882−/// key (§3.2, §6.1).
883−pub fn set_property(vault: &mut Vault, r: NRef, key: &str, value: &str) -> std::io::Result<()> {
1608+/// Set a property; on a plain node the first one makes it a block (§3.2,
1609+/// §6.1). Returns the block that holds the property.
1610+pub fn set_property(vault: &mut Vault, r: NRef, key: &str, value: &str) -> std::io::Result<NRef> {
8841611 let target = if vault.tree.node(r).is_block() {
8851612 r
8861613 } else if vault.tree.node(r).is_embed() {
887− vault.tree.resolved_child(r)
1614+ write_target(&vault.tree, r)?
8881615 } else {
889− make_block(vault, r)?;
890− // find the new block by re-resolving: the embed replaced the node
891− let path = vault.tree.path(r);
892− let mut found = None;
893− for (fi, f) in vault.tree.files.iter().enumerate() {
894− for (ni, nd) in f.nodes.iter().enumerate() {
895− if nd.embed.is_some() {
896− let rr = (fi, ni);
897− if vault.tree.path(rr) == path {
898− found = Some(vault.tree.resolved_child(rr));
899− }
900− }
901− }
902− }
903− found.ok_or_else(|| io_err("block not found after make_block"))?
1616+ // make_block reloads, so `r` is stale: find the new block by its id
1617+ let id = make_block(vault, r)?;
1618+ vault
1619+ .tree
1620+ .block_by_id(&id)
1621+ .ok_or_else(|| io_err("block not found after make_block"))?
9041622 };
905− set_frontmatter_key(vault, target.0, key, Some(value))
1623+ let id = vault.tree.node(target).block.as_ref().and_then(|b| b.id.clone());
1624+ set_frontmatter_key(vault, target.0, key, Some(value))?;
1625+ Ok(id.and_then(|id| vault.tree.block_by_id(&id)).unwrap_or(target))
9061626}
9071627
9081628// --------------------------------------------------------------- move/spelling
9091629
910−/// Move a node among its siblings (§10.3 `J`/`K`).
1630+/// Move a node among its siblings (§10.3 `J`/`K`). A block moves by its
1631+/// 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.
9111635pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<()> {
1636+ // a block root stands in its parent file as its embed
1637+ let r = stand_in(&vault.tree, r);
1638+ if vault.tree.node(r).is_block() {
1639+ return Ok(());
1640+ }
9121641 let parent = match vault.tree.node(r).parent {
9131642 Some(p) => (r.0, p),
9141643 None => return Ok(()),
9151644 };
916− let siblings = vault.tree.resolved_children(parent);
1645+ let siblings = vault.tree.raw_children(parent);
9171646 let pos = match siblings.iter().position(|&s| s == r) {
9181647 Some(p) => p,
9191648 None => return Ok(()),
9201649 };
921− let swap_with = if down {
922− 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)
9231657 } else {
924− match pos.checked_sub(1) {
925− Some(p) => p,
1658+ match a.checked_sub(1) {
1659+ Some(p) => run(p),
9261660 None => return Ok(()),
9271661 }
9281662 };
929− if swap_with >= siblings.len() {
930− return Ok(());
1663+ // the ordering rule (§3.1): an item never goes below a section sibling
1664+ let (rk, ok) = (vault.tree.node(siblings[a]).kind, vault.tree.node(siblings[other.0]).kind);
1665+ if rk != ok {
1666+ return Err(io_err(if rk == Kind::Item {
1667+ "an item cannot move below a section"
1668+ } else {
1669+ "a section cannot move above an item"
1670+ }));
9311671 }
932− let other = siblings[swap_with];
9331672 let file = r.0;
934− if other.0 != file {
935− return Ok(()); // different files: skip (v1 simplicity)
936− }
937− let a = vault.tree.node(r).span;
938− let b = vault.tree.node(other).span;
9391673 let text = vault.tree.files[file].text.clone();
940− let (first, second) = if a.start < b.start { (a, b) } else { (b, a) };
941− let between = &text[first.end..second.start];
942− let new_text = format!(
943− "{}{}{}{}{}",
1674+ // swap the runs' own lines; trailing blank lines stay where they are,
1675+ // so the list's spacing is unchanged
1676+ let core = |sp: Span| {
1677+ let end = sp.end.min(text.len());
1678+ let mut e = end;
1679+ while e >= sp.start + 2 && &text.as_bytes()[e - 2..e] == b"\n\n" {
1680+ e -= 1;
1681+ }
1682+ Span { start: sp.start, end: e }
1683+ };
1684+ let (fr, sr) = if down { ((a, b), other) } else { (other, (a, b)) };
1685+ let lines = |(s, e): (usize, usize)| Span {
1686+ start: vault.tree.node(siblings[s]).span.start,
1687+ end: core(vault.tree.node(siblings[e]).span).end,
1688+ };
1689+ let (first, second) = (lines(fr), lines(sr));
1690+ let with_nl = |t: &str| {
1691+ if t.ends_with('\n') {
1692+ t.to_string()
1693+ } else {
1694+ format!("{}\n", t)
1695+ }
1696+ };
1697+ // The written level and indent decide the parent: a sibling written
1698+ // deeper than the one after it (a skipped heading level, §4.7, or a
1699+ // wider indent) would nest under that one once below it, and J/K keep
1700+ // every other node's parent (§15.6). So the nodes moving down are
1701+ // written at the level and indent of the one moving up, their subtrees
1702+ // re-levelled with them, as a moved node is (§4.2).
1703+ let (fnode, snode) = (vault.tree.node(siblings[fr.0]), vault.tree.node(siblings[sr.0]));
1704+ let lower = if (fnode.level, fnode.indent) == (snode.level, snode.indent) {
1705+ with_nl(first.text(&text))
1706+ } else {
1707+ let base = snode.level.unwrap_or_else(|| vault.tree.level(siblings[fr.0]));
1708+ let pad = " ".repeat(snode.indent);
1709+ let mut out = String::new();
1710+ for i in fr.0..=fr.1 {
1711+ let k = siblings[i];
1712+ out.extend(
1713+ render(&vault.tree, k, base, false)
1714+ .lines()
1715+ .map(|l| if l.is_empty() { "\n".to_string() } else { format!("{}{}\n", pad, l) }),
1716+ );
1717+ // the blank lines between a node and its copy stay
1718+ if i < fr.1 {
1719+ let next = vault.tree.node(siblings[i + 1]).span.start;
1720+ out.push_str(&text[core(vault.tree.node(k).span).end..next]);
1721+ }
1722+ }
1723+ out
1724+ };
1725+ let mut new_text = format!(
1726+ "{}{}{}{}",
9441727 &text[..first.start],
945− second.text(&text),
946− between,
947− first.text(&text),
948− &text[second.end..]
1728+ with_nl(second.text(&text)),
1729+ &text[first.end..second.start],
1730+ lower,
9491731 );
1732+ let rest = &text[second.end..];
1733+ new_text.push_str(rest);
9501734 vault.write_file_text(file, &new_text)
9511735}
9521736
953−/// Toggle spelling section ↔ item (§10.3 `~`). The checkbox, if any, is kept.
954−pub fn toggle_spelling(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
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+
1786+/// Toggle spelling section ↔ item (§10.3 `~`). The checkbox, if any, is kept,
1787+/// and the subtree is re-indented and re-levelled for the new spelling. The
1788+/// node moves to its parent's boundary if it has to (§3.1), so no sibling
1789+/// changes parent; a block's embed changes form and moves with it (§4.7).
1790+pub fn toggle_spelling(vault: &mut Vault, r: NRef) -> std::io::Result<bool> {
1791+ let r = vault.tree.resolved_child(r);
9551792 let n = vault.tree.node(r);
956− if n.is_embed() || n.kind == Kind::Root {
957− return Ok(());
1793+ if n.kind == Kind::Root {
1794+ return Ok(false);
1795+ }
1796+ let to_section = n.kind == Kind::Item;
1797+ // a node in a conflict pair keeps its spelling: moved to its boundary
1798+ // it would leave its copy, or a copy its node, to pair with the node
1799+ // before (§12.5), and keeping theirs would write the other one back
1800+ if conflict_pair(&vault.tree, r).len() > 1 {
1801+ return Err(io_err(PAIR_SPELLING));
1802+ }
1803+ // an embed still here after resolving is broken: there is no block to
1804+ // respell, so only the embed's form changes and its id stays (§4.7)
1805+ if n.is_embed() {
1806+ return respell_embed(vault, r, to_section);
9581807 }
1808+ // `## ---` spelled as an item is `- ---`, a thematic break (§4.4)
1809+ if !to_section && n.task.is_none() {
1810+ item_line(&n.title)?;
1811+ }
1812+ let stand = stand_in(&vault.tree, r);
9591813 let file = r.0;
960− let ts = n.title_span;
961− let line = ts.text(&vault.tree.files[file].text).to_string();
962− let indent_str = &line[..line.len() - line.trim_start().len()];
963− let trimmed = line.trim_start();
964− // split off marker and checkbox
965− let (after_marker, written_level) = if trimmed.starts_with('#') {
966− let h = trimmed.chars().take_while(|&c| c == '#').count();
967− (trimmed[h..].trim_start(), h)
968− } else {
969− (trimmed.trim_start_matches("- "), 0)
970− };
971− let (checkbox, title) = if let Some(rest) = after_marker.strip_prefix("[ ] ") {
972− ("[ ] ", rest)
973− } else if let Some(rest) = after_marker
974− .strip_prefix("[x] ")
975− .or_else(|| after_marker.strip_prefix("[X] "))
976− .or_else(|| after_marker.strip_prefix("[-] "))
977− {
978− ("[x] ", rest)
1814+ let text = vault.tree.files[file].text.clone();
1815+ let span = Span { start: n.span.start, end: n.span.end.min(text.len()) };
1816+ // respelled from the subtree as a move writes it (§4.2): every heading
1817+ // in it ATX, a setext one converted, at the level its position gives,
1818+ // so re-levelling keeps each under the node it was under; at the node's
1819+ // indent, with the blank lines that end its span
1820+ let blanks = span.text(&text).lines().rev().take_while(|l| l.trim().is_empty()).count();
1821+ let rendered: String = crate::render::render_lines(&vault.tree, r, vault.tree.level(r), false)
1822+ .into_iter()
1823+ .map(|l| l.text + "\n")
1824+ .collect();
1825+ let src = shift_lines(&rendered, 0, n.indent as isize) + &"\n".repeat(blanks);
1826+ if stand != r {
1827+ // a block: respell its file's root at level 1, then its embed. The
1828+ // embed cannot be written into a file changed on disk (§11.2), and
1829+ // the block would be left under an embed of the other form (§4.7):
1830+ // check first
1831+ vault.check_unchanged(stand.0)?;
1832+ let respelled = respell(&src, to_section, 1);
1833+ let id = n.block.as_ref().and_then(|b| b.id.clone());
1834+ vault.write_span(file, span, &respelled)?;
1835+ let Some(e) = id.and_then(|id| vault.tree.embed_of(&id)) else {
1836+ return Ok(false);
1837+ };
1838+ return respell_embed(vault, e, to_section);
1839+ }
1840+ let Some(parent) = n.parent.map(|p| (file, p)) else { return Ok(false) };
1841+ let respelled = respell(&src, to_section, vault.tree.level(parent) + 1);
1842+ reposition(vault, r, parent, respelled, to_section)
1843+}
1844+
1845+/// Why `~` is refused on either side of a conflict pair (§12.5).
1846+pub const PAIR_SPELLING: &str = "resolve its conflict";
1847+
1848+/// Rewrite an embed in the other form (§4.7) — heading for a section-spelled
1849+/// block, bare for an item — moving it to its parent's boundary if needed.
1850+pub fn respell_embed(vault: &mut Vault, e: NRef, to_section: bool) -> std::io::Result<bool> {
1851+ let en = vault.tree.node(e);
1852+ let Some(id) = en.embed.clone() else { return Ok(false) };
1853+ let Some(parent) = en.parent.map(|p| (e.0, p)) else { return Ok(false) };
1854+ let text = &vault.tree.files[e.0].text;
1855+ let old = Span { start: en.span.start, end: en.span.end.min(text.len()) }.text(text);
1856+ let rest = old.find('\n').map(|i| &old[i..]).unwrap_or("\n");
1857+ let indent = " ".repeat(en.indent);
1858+ let line = if to_section {
1859+ format!("{}{} ![[{}]]", indent, "#".repeat(vault.tree.level(parent) + 1), id)
9791860 } else {
980− ("", after_marker)
981− };
982− let new_line = match n.kind {
983− Kind::Section => format!("{}- {}{}", indent_str, checkbox, title),
984− Kind::Item => {
985− // item → section: deeper than the nearest section ancestor so
986− // the parser keeps it a child (§3.1)
987− let lvl = (written_level + 1).max(vault.tree.level(r) + 1).max(1);
988− format!("{}{} {}{}", indent_str, "#".repeat(lvl), checkbox, title)
989− }
990− Kind::Root => unreachable!(),
1861+ format!("{}![[{}]]", indent, id)
9911862 };
992− vault.write_span(file, ts, &new_line)
1863+ let new = format!("{}{}", line, rest);
1864+ reposition(vault, e, parent, new, to_section)
9931865}
9941866
995−/// Demote: become the last child of the previous sibling (§10.3 `>`).
996−/// Same-file span surgery, no reload.
997−pub fn demote(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
998− let parent = match vault.tree.node(r).parent {
999− Some(p) => (r.0, p),
1000− None => return Ok(()),
1867+/// Replace `r`'s span with `new` (the same node, respelled), in place when
1868+/// the ordering rule allows it there, else at the parent's boundary.
1869+fn reposition(
1870+ vault: &mut Vault,
1871+ r: NRef,
1872+ parent: NRef,
1873+ new: String,
1874+ to_section: bool,
1875+) -> std::io::Result<bool> {
1876+ let tree = &vault.tree;
1877+ let kids = tree.raw_children(parent);
1878+ let pos = kids.iter().position(|&k| k == r).unwrap_or(0);
1879+ let others: Vec<NRef> = kids.iter().copied().filter(|&k| k != r).collect();
1880+ let kind = if to_section { Kind::Section } else { Kind::Item };
1881+ if clamp_index(tree, &others, pos, &[kind]) == pos {
1882+ let span = tree.node(r).span;
1883+ let prev = pos.checked_sub(1).map(|i| others[i]);
1884+ let new = level_among(tree, prev, others.get(pos).copied(), &new);
1885+ vault.write_span(r.0, span, &new)?;
1886+ return Ok(false);
1887+ }
1888+ place(vault, &[r], parent, pos, &new).map(|_| true)
1889+}
1890+
1891+/// A node's text respelled: its title line as a section at `level` or as an
1892+/// item, and everything under it shifted to match — an item's content sits
1893+/// two columns in, a section's at its own indent, and nested headings stay
1894+/// one below their new parent.
1895+fn respell(span_text: &str, to_section: bool, level: usize) -> String {
1896+ let (first, rest) = match span_text.find('\n') {
1897+ Some(i) => (&span_text[..i], &span_text[i..]),
1898+ None => (span_text, ""),
10011899 };
1002− let siblings = vault.tree.resolved_children(parent);
1003− let pos = match siblings.iter().position(|&s| s == r) {
1004− Some(p) => p,
1005− None => return Ok(()),
1900+ let indent_str = &first[..first.len() - first.trim_start().len()];
1901+ let (checkbox, title) = match checkbox_span(first) {
1902+ Some((s, e)) => {
1903+ let state = if &first[s..s + 3] == "[ ]" { "[ ] " } else { "[x] " };
1904+ (state, &first[e..])
1905+ }
1906+ // an empty title (`-`, `#`) has no marker end
1907+ None => ("", marker_end(first).map(|m| &first[m..]).unwrap_or("")),
10061908 };
1007− if pos == 0 {
1008− return Ok(());
1909+ let line = if to_section {
1910+ format!("{}{} {}{}", indent_str, "#".repeat(level.max(1)), checkbox, title)
1911+ } else {
1912+ format!("{}- {}{}", indent_str, checkbox, title)
1913+ };
1914+ let (dl, di) = if to_section { (1, -2) } else { (-1, 2) };
1915+ // the rest starts with the newline that ends the title line
1916+ let shifted = shift_lines(&rest[1.min(rest.len())..], dl, di);
1917+ if rest.is_empty() {
1918+ line
1919+ } else {
1920+ format!("{}\n{}", line.trim_end(), shifted)
10091921 }
1010− let prev = siblings[pos - 1];
1011− if prev.0 != r.0 || vault.tree.node(r).is_embed() || vault.tree.node(prev).is_embed() {
1012− // cross-file or embed demotion falls back to refile
1013− return refile(vault, r, prev);
1922+}
1923+
1924+/// Demote: become the last child of the previous sibling node (§10.3 `>`),
1925+/// clamped by the ordering rule (§3.1).
1926+pub fn demote(vault: &mut Vault, r: NRef) -> std::io::Result<Placed> {
1927+ let stayed = Placed::stayed(&vault.tree, r);
1928+ let m = stand_in(&vault.tree, r);
1929+ let Some(parent) = vault.tree.node(m).parent.map(|p| (m.0, p)) else { return Ok(stayed) };
1930+ let kids = vault.tree.raw_children(parent);
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);
10141936 }
1015− // Sections and items nest differently: delegate to the same logic refile
1016− // uses so the shifted text is right for the spelling.
1017− return refile(vault, r, prev);
1937+ let prev = vault.tree.resolved_child(kids[a - 1]);
1938+ refile(vault, r, prev)
10181939}
10191940
1020−/// Promote: become the next sibling of the parent (§10.3 `<`).
1021−/// Promote: become the next sibling of the parent (§10.3 `<`). Same-file
1022−/// span surgery, no reload.
1023−pub fn promote(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
1024− let n = vault.tree.node(r);
1025− let parent = match n.parent {
1026− Some(p) => (r.0, p),
1027− None => return Ok(()),
1028− };
1941+/// Promote: become the next sibling of the parent (§10.3 `<`), clamped by the
1942+/// ordering rule (§3.1): an item leaving a section lands before the first
1943+/// section among the parent's siblings. Out of a block's root, the node moves
1944+/// to the parent file beside the block's embed.
1945+pub fn promote(vault: &mut Vault, r: NRef) -> std::io::Result<Placed> {
1946+ let stayed = Placed::stayed(&vault.tree, r);
1947+ let m = stand_in(&vault.tree, r);
1948+ let Some(parent) = vault.tree.node(m).parent.map(|p| (m.0, p)) else { return Ok(stayed) };
10291949 if vault.tree.node(parent).kind == Kind::Root {
1030− return Ok(());
1950+ return Ok(stayed);
10311951 }
1032− // move out: delete span, insert after parent's span
1033− let file = r.0;
1034− let rendered = render(&vault.tree, r, 1, true);
1035− let span = n.span;
1036− let parent_span = vault.tree.node(parent).span;
1037− let level = vault.tree.level(parent);
1038− let indent = vault.tree.indent(parent);
1039− let shifted = shift_document(&rendered, level, indent);
1040− let text = vault.tree.files[file].text.clone();
1041− // remove r's span plus a preceding blank separator
1042− let mut start = span.start;
1043− if start >= 2 && &text[start - 2..start] == "\n\n" {
1044− start -= 1;
1045− }
1046− let removed = span.end - start;
1047− let mut new_text = String::with_capacity(text.len());
1048− new_text.push_str(&text[..start]);
1049− new_text.push_str(&text[span.end..]);
1050− // the parent contains r, so its span end moves back by the removed bytes
1051− let mut ins = parent_span.end - removed;
1052− while ins > 0 && new_text.as_bytes()[ins - 1] == b'\n' {
1053− ins -= 1;
1054− }
1055− new_text.insert_str(ins, &format!("\n\n{}", shifted));
1056− vault.write_file_text(file, &new_text)?;
1057− vault.reload()
1058−}
1059−
1060−/// Rename a node's title (used by the editor when it detects a title change,
1061−/// and by tests).
1062−pub fn rename_title(vault: &mut Vault, r: NRef, new_title: &str) -> std::io::Result<()> {
1063− let n = vault.tree.node(r);
1064− let file = r.0;
1065− let ts = n.title_span;
1066− let line = ts.text(&vault.tree.files[file].text).to_string();
1067− // replace only the title text after the marker/checkbox
1068− let marker_len = line.len() - line.trim_start().len();
1069− let after_indent = &line[marker_len..];
1070− let prefix_len = if after_indent.starts_with('#') {
1071− let h = after_indent.chars().take_while(|&c| c == '#').count();
1072− marker_len + h + 1
1073− } else if after_indent.starts_with("- ") {
1074− marker_len + 2
1075− } else {
1076− marker_len
1077− };
1078− let mut prefix = line[..prefix_len].to_string();
1079− // keep checkbox
1080− let rest = &line[prefix_len..];
1081− let checkbox = if rest.starts_with("[ ] ") {
1082− "[ ] "
1083− } else if rest.starts_with("[x] ") || rest.starts_with("[X] ") || rest.starts_with("[-] ") {
1084− "[x] "
1085− } else {
1086− ""
1952+ // the parent's own position: its embed if it is a block root
1953+ let pstand = stand_in(&vault.tree, parent);
1954+ let Some(grand) = vault.tree.node(pstand).parent.map(|p| (pstand.0, p)) else {
1955+ return Ok(stayed);
10871956 };
1088− if !checkbox.is_empty() {
1089− prefix.push_str(checkbox);
1090− }
1091− let new_line = format!("{}{}", prefix, new_title);
1092− let is_block_root = n.is_block();
1093− vault.write_span(file, ts, &new_line)?;
1094− if is_block_root {
1095− vault.rename_block_file(file, new_title)?;
1957+ if vault.tree.node(grand).kind == Kind::Root && pstand.0 != vault.tree.root.0 {
1958+ return Err(io_err("block has no embed"));
10961959 }
1097− Ok(())
1960+ let pos = vault.tree.raw_children(grand).iter().position(|&k| k == pstand).unwrap_or(0);
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)
10981966}
10991967
11001968/// Append a plain item child titled `title` under `parent`; returns the new
@@ -1104,7 +1972,25 @@ pub fn append_child_public(
11041972 parent: NRef,
11051973 title: &str,
11061974) -> std::io::Result<NRef> {
1107− append_child_line(vault, parent, &format!("- {}", title), false)
1975+ append_child_line(vault, parent, &item_line(title)?, true)
1976+}
1977+
1978+/// The item line titled `title`, refused when it would not read back as
1979+/// one: a title of dashes (`---`, `- -`) makes it a thematic break, which
1980+/// CommonMark reads before a bullet, and body text (§4.4). Checked before
1981+/// anything is written, as the TUI won't create an invalid title (§4.3).
1982+fn item_line(title: &str) -> std::io::Result<String> {
1983+ let line = format!("- {}", title);
1984+ not_a_break(&line)?;
1985+ Ok(line)
1986+}
1987+
1988+/// Refuse an item's title line that reads as a thematic break (§4.4).
1989+fn not_a_break(line: &str) -> std::io::Result<()> {
1990+ if crate::parse::is_thematic_break(line.trim_start_matches([' ', '\t'])) {
1991+ return Err(io_err("a title of dashes reads as a thematic break, not an item"));
1992+ }
1993+ Ok(())
11081994}
11091995
11101996fn io_err(msg: &str) -> std::io::Error {
crates/fold-core/src/parse.rs+336−127
@@ -41,32 +41,59 @@ pub struct Block {
4141 /// Byte span of the whole frontmatter (fences plus one trailing blank
4242 /// line), if present.
4343 pub frontmatter_span: Option<Span>,
44− /// Byte span of the embed line in the parent file (None for root.md).
45− pub edge_span: Option<Span>,
4644}
4745
4846impl 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+
4968 pub fn prop(&self, key: &str) -> Option<&str> {
5069 self.props.get(key).map(String::as_str)
5170 }
5271}
5372
73+/// One entry in a node's ordered content (§3.1): a run of text lines or a
74+/// child node. Text runs are never adjacent to each other.
75+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76+pub enum Content {
77+ Text(Span),
78+ Node(usize),
79+}
80+
5481#[derive(Debug, Clone)]
5582pub struct Node {
5683 pub kind: Kind,
5784 pub title: String,
58− /// From the checkbox on the title line, or `todo:` for a block.
85+ /// From the checkbox on the title line (§4.5).
5986 pub task: Option<TaskState>,
6087 /// Byte span of the title line (no trailing newline).
6188 pub title_span: Span,
62− /// Byte span of the body region (after the title line, before children).
63− pub body_span: Span,
89+ /// The node's children in document order: text runs and child nodes,
90+ /// interleaved (§3.1, §3.3).
91+ pub content: Vec<Content>,
6492 /// Byte span: title line through the whole subtree.
6593 pub span: Span,
94+ /// The node children alone, in order (derived from `content`).
6695 pub children: Vec<usize>,
6796 pub parent: Option<usize>,
68− /// Which file's text this node's spans index into.
69− pub file: usize,
7097 /// `Some` if this node is the root of its own file.
7198 pub block: Option<Block>,
7299 /// `Some(id)` if this node is an embed reference in a parent file.
@@ -79,7 +106,28 @@ pub struct Node {
79106 pub noncanonical: Vec<String>,
80107}
81108
82−impl Node {}
109+impl Node {
110+ /// The leading text children: what precedes the first child node.
111+ pub fn body_span(&self) -> Option<Span> {
112+ match self.content.first() {
113+ Some(Content::Text(sp)) => Some(*sp),
114+ _ => None,
115+ }
116+ }
117+
118+ /// Every text child, with the number of child nodes before it.
119+ pub fn text_runs(&self) -> Vec<(usize, Span)> {
120+ let mut out = Vec::new();
121+ let mut before = 0;
122+ for c in &self.content {
123+ match c {
124+ Content::Text(sp) => out.push((before, *sp)),
125+ Content::Node(_) => before += 1,
126+ }
127+ }
128+ out
129+ }
130+}
83131
84132/// One parsed file: its text and the arena of nodes found in it.
85133#[derive(Debug, Clone)]
@@ -102,6 +150,22 @@ impl ParsedFile {
102150 self.nodes[self.root_node].children[0]
103151 }
104152 }
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+ }
105169}
106170
107171#[derive(Debug, Clone)]
@@ -113,7 +177,8 @@ pub struct Diag {
113177#[derive(Clone)]
114178struct Line {
115179 start: usize,
116− end: usize, // excluding '\n'
180+ end: usize, // excluding the line ending (`\n` or `\r\n`)
181+ next: usize, // start of the next line (past the line ending, or EOF)
117182 raw: String,
118183}
119184
@@ -126,6 +191,7 @@ fn split_lines(text: &str) -> Vec<Line> {
126191 lines.push(Line {
127192 start: off,
128193 end: off + raw.len(),
194+ next: off + l.len(),
129195 raw: raw.to_string(),
130196 });
131197 off += l.len();
@@ -140,9 +206,17 @@ pub struct Frontmatter {
140206 pub props: IndexMap<String, String>,
141207}
142208
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+
143217/// Scan frontmatter at byte 0 of a file. Returns None if absent.
144218pub fn parse_frontmatter(text: &str) -> Option<Frontmatter> {
145− if !text.starts_with("---\n") {
219+ if !text.starts_with("---\n") && !text.starts_with("---\r\n") {
146220 return None;
147221 }
148222 let lines = split_lines(text);
@@ -157,11 +231,10 @@ pub fn parse_frontmatter(text: &str) -> Option<Frontmatter> {
157231 let raw_start = lines[1].start;
158232 let raw_end = lines[close].start;
159233 let raw = text[raw_start..raw_end].to_string();
160− let mut span_end = lines[close].end + 1;
234+ let mut span_end = lines[close].next;
161235 if close + 1 < lines.len() && lines[close + 1].raw.is_empty() {
162− span_end = lines[close + 1].end + 1;
236+ span_end = lines[close + 1].next;
163237 }
164− span_end = span_end.min(text.len());
165238 let mut props = IndexMap::new();
166239 for l in &lines[1..close] {
167240 let raw_line = &l.raw;
@@ -209,7 +282,9 @@ fn indent_cols(raw: &str, tabbed: &mut bool) -> usize {
209282enum TitleKind {
210283 Section { level: usize },
211284 Item,
212− Embed(Id),
285+ /// `![[id]]`, bare (item position) or as a heading (`## ![[id]]`,
286+ /// section position, with its level) (§4.7).
287+ Embed(Id, Option<usize>),
213288}
214289
215290struct TitleInfo {
@@ -239,7 +314,7 @@ fn classify_title(raw: &str) -> Option<(usize, TitleInfo)> {
239314 (
240315 indent,
241316 TitleInfo {
242− kind: TitleKind::Embed(id),
317+ kind: TitleKind::Embed(id, None),
243318 task: None,
244319 title: String::new(),
245320 noncanonical,
@@ -248,18 +323,36 @@ fn classify_title(raw: &str) -> Option<(usize, TitleInfo)> {
248323 });
249324 }
250325 let (kind, after_marker) = if rest.starts_with('#') {
326+ // any number: levels are unbounded, beyond six too (§3.1, §4.2)
251327 let hashes = rest.chars().take_while(|&c| c == '#').count();
252328 let after = &rest[hashes..];
253329 if !after.is_empty() && !after.starts_with(' ') {
254330 return None; // `#tag` is text
255331 }
256− if hashes > 6 {
257− noncanonical.push("heading level beyond six".into());
332+ // a heading embed: the heading holds nothing but `![[id]]`
333+ if let Some(inner) = after
334+ .trim()
335+ .strip_prefix("![[")
336+ .and_then(|s| s.strip_suffix("]]"))
337+ {
338+ if let Some(id) = Id::parse(inner.trim()) {
339+ return Some((
340+ indent,
341+ TitleInfo {
342+ kind: TitleKind::Embed(id, Some(hashes)),
343+ task: None,
344+ title: String::new(),
345+ noncanonical,
346+ },
347+ ));
348+ }
258349 }
259350 (
260351 TitleKind::Section { level: hashes },
261352 after.strip_prefix(' ').unwrap_or(after),
262353 )
354+ } else if is_thematic_break(rest) {
355+ return None; // `- - -`, `* * *`: a break, not a bullet (§4.4)
263356 } else if let Some(after) = rest.strip_prefix("- ") {
264357 (TitleKind::Item, after)
265358 } else if let Some(after) = rest.strip_prefix("* ").or_else(|| rest.strip_prefix("+ ")) {
@@ -312,7 +405,12 @@ fn classify_title(raw: &str) -> Option<(usize, TitleInfo)> {
312405 ))
313406}
314407
315−fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
408+/// Track fenced code blocks (§3.3) one line at a time: true when `raw` opens
409+/// or closes one. Fences are CommonMark's: a backtick fence's info string
410+/// holds no backtick (a line that starts with inline code is text), and a
411+/// closing fence is followed by nothing but spaces or tabs. Public so that
412+/// what the TUI draws as code is what the parser reads as code.
413+pub fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
316414 let t = raw.trim_start_matches([' ', '\t']);
317415 let first = match t.chars().next() {
318416 Some(c) if c == '`' || c == '~' => c,
@@ -322,13 +420,17 @@ fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
322420 if count < 3 {
323421 return false;
324422 }
423+ let rest = &t[count..]; // '`' and '~' are one byte each
325424 match open {
326425 None => {
426+ if first == '`' && rest.contains('`') {
427+ return false;
428+ }
327429 *open = Some((first, count));
328430 true
329431 }
330432 Some((c, n)) => {
331− if *c == first && count >= *n {
433+ if *c == first && count >= *n && rest.trim_matches([' ', '\t']).is_empty() {
332434 *open = None;
333435 true
334436 } else {
@@ -338,15 +440,32 @@ fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
338440 }
339441}
340442
443+/// A thematic break, however it is spaced: three or more of one of `-`, `*`,
444+/// `_` with nothing but spaces or tabs between. CommonMark reads it before a
445+/// list item, so `- - -` and `* * *` are body text like `---` (§4.4).
446+/// `rest` starts at the line's first non-blank character.
447+pub(crate) fn is_thematic_break(rest: &str) -> bool {
448+ let Some(mark) = rest.chars().next().filter(|c| matches!(c, '-' | '*' | '_')) else {
449+ return false;
450+ };
451+ let mut n = 0;
452+ for c in rest.chars() {
453+ if c == mark {
454+ n += 1;
455+ } else if c != ' ' && c != '\t' {
456+ return false;
457+ }
458+ }
459+ n >= 3
460+}
461+
462+/// A setext underline. Only `=` underlines count: a line of dashes is a
463+/// thematic break and body text (§4.4, §5.2 step 2), and a lone `-` is an
464+/// empty bullet.
341465fn setext_level(raw: &str) -> Option<usize> {
342466 let t = raw.trim();
343− if t.is_empty() {
344− return None;
345− }
346− if t.chars().all(|c| c == '=') {
467+ if !t.is_empty() && t.chars().all(|c| c == '=') {
347468 Some(1)
348− } else if t.chars().all(|c| c == '-') {
349− Some(2)
350469 } else {
351470 None
352471 }
@@ -359,10 +478,10 @@ struct Frame {
359478 is_embed: bool,
360479}
361480
362−/// Parse one file's text into nodes. `file_idx` tags every node's `file`.
363−/// `block` (with `id: Some`) marks a block file; `id: None` is `root.md`;
364−/// `None` means "plain text, no block attachment" (used by tests).
365−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 {
366485 let lines = split_lines(text);
367486 let n = lines.len();
368487 let mut nodes: Vec<Node> = Vec::new();
@@ -374,14 +493,13 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
374493 title: String::new(),
375494 task: None,
376495 title_span: Span::default(),
377− body_span: Span::default(),
496+ content: Vec::new(),
378497 span: Span {
379498 start: 0,
380499 end: text.len(),
381500 },
382501 children: Vec::new(),
383502 parent: None,
384− file: file_idx,
385503 block: None,
386504 embed: None,
387505 indent: 0,
@@ -406,6 +524,10 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
406524 let mut fence: Option<(char, usize)> = None;
407525 let mut root_title_seen = !is_block_file;
408526 let mut root_level_nodes = 0usize;
527+ // A block file's root node, once seen: later column-0 nodes and text
528+ // are adopted as its children (§4.9) — what a phone appending to the
529+ // file produces.
530+ let mut block_root: Option<usize> = None;
409531
410532 // Pending setext: (title line index, title line indent) — a plain body
411533 // line that may become a section if the next line is an underline.
@@ -416,22 +538,23 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
416538 let raw = lines[i].raw.clone();
417539 let lstart = lines[i].start;
418540 let lend = lines[i].end;
541+ let lnext = lines[i].next;
419542
420543 if fence_transition(&raw, &mut fence) {
421544 pending_setext = None;
422− push_body(&mut nodes, &mut stack, &lines, i);
545+ push_body(&mut nodes, &mut stack, &lines, i, block_root);
423546 i += 1;
424547 continue;
425548 }
426549 if fence.is_some() {
427− push_body(&mut nodes, &mut stack, &lines, i);
550+ push_body(&mut nodes, &mut stack, &lines, i, block_root);
428551 i += 1;
429552 continue;
430553 }
431554 // Setext underline?
432555 if let Some(level) = setext_level(&raw) {
433556 if let Some((title_li, title_indent)) = pending_setext.take() {
434− make_setext_section(
557+ let idx = make_setext_section(
435558 &mut nodes,
436559 &mut stack,
437560 &lines,
@@ -439,19 +562,32 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
439562 title_indent,
440563 i,
441564 level,
565+ block_root,
442566 );
567+ // like any title line, a setext heading can be a block
568+ // file's root (§4.9); its title line was then reported as
569+ // text before the root, which it is not
570+ if nodes[idx].parent == Some(root_idx) {
571+ root_level_nodes += 1;
572+ if is_block_file && !root_title_seen {
573+ root_title_seen = true;
574+ block_root = Some(idx);
575+ let at = nodes[idx].title_span.start;
576+ diagnostics.retain(|d: &Diag| d.span.start != at);
577+ }
578+ }
443579 i += 1;
444580 continue;
445581 }
446582 // Not a setext: a `---` after a blank or structure is a break (body).
447− push_body(&mut nodes, &mut stack, &lines, i);
583+ push_body(&mut nodes, &mut stack, &lines, i, block_root);
448584 i += 1;
449585 continue;
450586 }
451587 match classify_title(&raw) {
452588 Some((indent, info)) => {
453589 pending_setext = None;
454− let is_embed = matches!(info.kind, TitleKind::Embed(_));
590+ let is_embed = matches!(info.kind, TitleKind::Embed(..));
455591 // Pop until the top can contain this node (§3.1). A node's
456592 // parent is the nearest section above it, or the nearest item
457593 // whose child indent it reaches — items that can't contain it
@@ -461,7 +597,9 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
461597 let tnode = &nodes[top.node];
462598 let can = match tnode.kind {
463599 Kind::Root => true,
464− _ if top.is_embed => indent > top.indent, // diagnostic, but keep spans sane
600+ // lines under a bare embed are a diagnostic, but keep
601+ // spans sane; a heading embed nests like a section
602+ Kind::Item if top.is_embed => indent > top.indent,
465603 Kind::Item => {
466604 // An item contains only what reaches its child
467605 // indent; otherwise look through it.
@@ -471,13 +609,21 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
471609 }
472610 true
473611 }
612+ Kind::Section if indent < top.indent => {
613+ // a section nested under an item holds only
614+ // what reaches its indent
615+ stack.pop();
616+ continue;
617+ }
474618 Kind::Section => {
475619 // A section is contained by the nearest section
476620 // with a smaller level — not by its lexical
477621 // predecessor, so `## C` after `###### B` is a
478622 // sibling of B, not a child. Look through
479623 // intervening items.
480− if let TitleKind::Section { level } = &info.kind {
624+ if let TitleKind::Section { level } | TitleKind::Embed(_, Some(level)) =
625+ &info.kind
626+ {
481627 let mut nearest = None;
482628 for f in stack.iter().rev() {
483629 if nodes[f.node].kind == Kind::Section {
@@ -496,11 +642,18 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
496642 }
497643 stack.pop();
498644 }
499− let parent = stack.last().unwrap().node;
645+ let mut parent = stack.last().unwrap().node;
646+ let mut adopted = false;
647+ if parent == root_idx {
648+ if let Some(br) = block_root {
649+ parent = br;
650+ adopted = true;
651+ }
652+ }
500653 let mut node = Node {
501654 kind: match info.kind {
502− TitleKind::Section { .. } => Kind::Section,
503− TitleKind::Item | TitleKind::Embed(_) => Kind::Item,
655+ TitleKind::Section { .. } | TitleKind::Embed(_, Some(_)) => Kind::Section,
656+ TitleKind::Item | TitleKind::Embed(_, None) => Kind::Item,
504657 },
505658 title: info.title,
506659 task: info.task,
@@ -508,33 +661,37 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
508661 start: lstart,
509662 end: lend,
510663 },
511− body_span: Span {
512− start: lend + 1,
513− end: lend + 1,
514− },
664+ content: Vec::new(),
515665 span: Span {
516666 start: lstart,
517− end: lend + 1,
667+ end: lnext,
518668 },
519669 children: Vec::new(),
520670 parent: Some(parent),
521− file: file_idx,
522671 block: None,
523672 embed: match &info.kind {
524− TitleKind::Embed(id) => Some(id.clone()),
673+ TitleKind::Embed(id, _) => Some(id.clone()),
525674 _ => None,
526675 },
527676 indent,
528677 level: match &info.kind {
529− TitleKind::Section { level } => Some(*level),
678+ TitleKind::Section { level } | TitleKind::Embed(_, Some(level)) => {
679+ Some(*level)
680+ }
530681 _ => None,
531682 },
532683 noncanonical: info.noncanonical,
533684 };
685+ if adopted {
686+ node.noncanonical
687+ .push("column-0 node in a block file, adopted by its root".into());
688+ }
689+ let first_root = parent == root_idx && is_block_file && !root_title_seen
690+ && node.embed.is_none();
534691 if parent == root_idx {
535692 root_level_nodes += 1;
536693 if is_block_file {
537− if !root_title_seen && node.embed.is_none() {
694+ if first_root {
538695 root_title_seen = true;
539696 } else if root_level_nodes > 1 {
540697 diagnostics.push(Diag {
@@ -545,7 +702,6 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
545702 }
546703 }
547704 if node.title.is_empty() && node.embed.is_none() {
548− node.noncanonical.push("empty title".into());
549705 diagnostics.push(Diag {
550706 span: node.title_span,
551707 message: "title line with an empty title".into(),
@@ -554,13 +710,17 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
554710 if node.title.contains('/') {
555711 diagnostics.push(Diag {
556712 span: node.title_span,
557− message: "title contains '/'".into(),
713+ message: format!("title contains '/': {:?}", node.title),
558714 });
559715 }
560716 let idx = nodes.len();
717+ if first_root {
718+ block_root = Some(idx);
719+ }
561720 nodes[parent].children.push(idx);
721+ nodes[parent].content.push(Content::Node(idx));
562722 nodes.push(node);
563− extend_spans(&mut nodes, idx, lend + 1);
723+ extend_spans(&mut nodes, idx, lnext);
564724 stack.push(Frame {
565725 node: idx,
566726 indent,
@@ -586,12 +746,24 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
586746 message: "text before the block's root node".into(),
587747 });
588748 }
589− push_body(&mut nodes, &mut stack, &lines, i);
749+ push_body(&mut nodes, &mut stack, &lines, i, block_root);
590750 i += 1;
591751 }
592752 }
593753 }
594754
755+ // A text child right after a child node, with no blank line between,
756+ // reads here as the parent's text but in CommonMark as a continuation of
757+ // that node: canonical form separates them (§4.2).
758+ for n in 0..nodes.len() {
759+ let tight = unseparated_text(&nodes[n], text);
760+ if !tight.is_empty() {
761+ nodes[n]
762+ .noncanonical
763+ .push("text right after a child node without a blank line".into());
764+ }
765+ }
766+
595767 let mut pf = ParsedFile {
596768 path: path.to_string(),
597769 text: text.to_string(),
@@ -600,44 +772,14 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
600772 diagnostics,
601773 };
602774
603− if let Some(mut b) = block {
775+ if let Some(b) = block {
604776 if is_block_file {
605777 // The block root is the first top-level node.
606778 if let Some(&first) = pf.nodes[root_idx].children.first() {
607− // A checkbox on a block's own title line reads as `todo` (§4.5).
608− if let Some(task) = pf.nodes[first].task {
609− pf.nodes[first]
610− .noncanonical
611− .push("checkbox on a block's own title line".into());
612− // frontmatter todo: wins; else checkbox reads as state
613− if b.prop("todo").is_none() {
614− b.props.insert(
615− "todo".into(),
616− match task {
617− TaskState::Open => "open".into(),
618− TaskState::Done => "done".into(),
619− },
620− );
621− }
622− }
623− // Task state from `todo:` (§4.5).
624− pf.nodes[first].task = match b.prop("todo") {
625− Some("open") => Some(TaskState::Open),
626− Some("done") => Some(TaskState::Done),
627− _ => None,
628− };
629− if matches!(b.prop("todo"), Some(v) if v != "open" && v != "done") {
630− let d = Diag {
631− span: b.frontmatter_span.unwrap_or_default(),
632− message: "todo: value must be open or done".into(),
633− };
634− pf.diagnostics.push(d);
635− }
636779 pf.nodes[first].block = Some(b);
637780 }
638781 } else {
639782 // root.md: attach to the implicit Root.
640− b.edge_span = None;
641783 pf.nodes[root_idx].block = Some(b);
642784 pf.nodes[root_idx].task = None;
643785 }
@@ -645,11 +787,35 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
645787 pf
646788}
647789
790+/// Starts of the text children that follow a child node with no blank line
791+/// between them (§4.2): where canonical form inserts one.
792+pub fn unseparated_text(n: &Node, text: &str) -> Vec<usize> {
793+ let mut out = Vec::new();
794+ for w in n.content.windows(2) {
795+ if let [Content::Node(_), Content::Text(sp)] = w {
796+ if sp.start > 0 && !ends_with_blank_line(&text[..sp.start]) {
797+ out.push(sp.start);
798+ }
799+ }
800+ }
801+ out
802+}
803+
804+/// Whether `s`, which ends at a line start, ends with a blank line: one that
805+/// is empty or whitespace, whatever its line ending (`\r\n` is read too).
806+pub(crate) fn ends_with_blank_line(s: &str) -> bool {
807+ let Some(s) = s.strip_suffix('\n') else {
808+ return false;
809+ };
810+ s[s.rfind('\n').map_or(0, |i| i + 1)..].trim().is_empty()
811+}
812+
648813fn push_body(
649814 nodes: &mut Vec<Node>,
650815 stack: &mut Vec<Frame>,
651816 lines: &[Line],
652817 i: usize,
818+ block_root: Option<usize>,
653819) {
654820 let line = &lines[i];
655821 // A body line belongs to the deepest node whose region it reaches
@@ -674,7 +840,14 @@ fn push_body(
674840 continue; // look through to the item's parent
675841 }
676842 }
677− Kind::Section => true,
843+ Kind::Section => {
844+ let mut t = false;
845+ if indent_cols(&line.raw, &mut t) < top.indent {
846+ stack.pop();
847+ continue; // outdented past a section under an item
848+ }
849+ true
850+ }
678851 Kind::Root => true,
679852 }
680853 };
@@ -683,17 +856,33 @@ fn push_body(
683856 }
684857 stack.pop();
685858 }
686− let top = stack.last().unwrap().node;
687− let n = &mut nodes[top];
688− // body_span starts empty (start == end, right after the title line); the
689− // first body line sets its start.
690− if n.body_span.start == n.body_span.end {
691− n.body_span.start = line.start;
859+ let mut top = stack.last().unwrap().node;
860+ // column-0 text after a block file's root belongs to that root (§4.9)
861+ if top == 0 {
862+ if let Some(br) = block_root {
863+ if !line.raw.trim().is_empty() {
864+ nodes[br]
865+ .noncanonical
866+ .push("column-0 text in a block file, adopted by its root".into());
867+ }
868+ top = br;
869+ }
692870 }
693− n.body_span.end = line.end + 1;
694− extend_spans(nodes, top, line.end + 1);
871+ // consecutive lines extend the node's last text child; a line after a
872+ // child node starts a new one, so text never spans a child (§3.3)
873+ match nodes[top].content.last_mut() {
874+ Some(Content::Text(sp)) if sp.end == line.start => sp.end = line.next,
875+ _ => nodes[top].content.push(Content::Text(Span {
876+ start: line.start,
877+ end: line.next,
878+ })),
879+ }
880+ extend_spans(nodes, top, line.next);
695881}
696882
883+/// Turn the pending title line and its underline into a section; returns
884+/// its index.
885+#[allow(clippy::too_many_arguments)]
697886fn make_setext_section(
698887 nodes: &mut Vec<Node>,
699888 stack: &mut Vec<Frame>,
@@ -702,50 +891,72 @@ fn make_setext_section(
702891 title_indent: usize,
703892 underline_li: usize,
704893 level: usize,
705−) {
706− // The title line is the last body line of whichever node owns it; find it
707− // by scanning down from the top frame whose body span covers it.
894+ block_root: Option<usize>,
895+) -> usize {
896+ // The title line is the last text line pushed, so it ends the last text
897+ // child of the node push_body gave it to: the top frame's, or a block
898+ // file's root when that is the Root (§4.9). Take it back out, with the
899+ // adoption note that came with it.
708900 let title = lines[title_li].raw.trim().to_string();
709901 let underline = &lines[underline_li];
710− // Owner: deepest frame whose body span includes the title line.
711− let mut owner = 0usize;
712− for f in stack.iter() {
713− let nd = &nodes[f.node];
714− if nd.body_span.start <= lines[title_li].start && lines[title_li].start < nd.body_span.end
715− {
716− owner = f.node;
717− }
718− }
719− // Shrink owner's body span to exclude the title line.
902+ let tl = &lines[title_li];
720903 {
904+ let mut owner = stack.last().unwrap().node;
905+ if owner == 0 {
906+ if let Some(br) = block_root {
907+ owner = br;
908+ let note = "column-0 text in a block file, adopted by its root";
909+ if nodes[br].noncanonical.last().is_some_and(|n| n == note) {
910+ nodes[br].noncanonical.pop();
911+ }
912+ }
913+ }
721914 let nd = &mut nodes[owner];
722− if nd.body_span.start == lines[title_li].start {
723− nd.body_span.start = lines[title_li].end + 1;
724− if nd.body_span.start > nd.body_span.end {
725− nd.body_span.start = nd.body_span.end;
915+ if let Some(Content::Text(sp)) = nd.content.last_mut() {
916+ if sp.end == tl.next {
917+ sp.end = tl.start;
918+ if sp.start >= sp.end {
919+ nd.content.pop();
920+ }
726921 }
727− } else {
728− nd.body_span.end = lines[title_li].start;
729− // trim trailing blank lines of the remaining body
730− let text_start = nd.body_span.start;
731− let _ = text_start;
922+ }
923+ // push_body grew the owner's span, and its ancestors', over the
924+ // title line: give that back too, so a node the new section does not
925+ // sit in ends before it (sibling spans never overlap). The section's
926+ // own ancestors are grown again below.
927+ let mut cur = Some(owner);
928+ while let Some(c) = cur {
929+ if nodes[c].span.end == tl.next {
930+ nodes[c].span.end = tl.start;
931+ }
932+ cur = nodes[c].parent;
732933 }
733934 }
734− // Pop frames that can't contain a section at this position.
935+ // Pop frames that can't contain a section at this position: those that
936+ // could not contain the ATX heading of its level there (§4.2), so
937+ // writing it as one keeps it where it is.
735938 loop {
736939 let top = stack.last().unwrap();
737940 let tnode = &nodes[top.node];
738941 let can = match tnode.kind {
739942 Kind::Root => true,
740943 Kind::Item => title_indent > top.indent,
741− Kind::Section => title_indent > top.indent || level > top.level.unwrap_or(0),
944+ Kind::Section => title_indent >= top.indent && level > top.level.unwrap_or(0),
742945 };
743946 if can {
744947 break;
745948 }
746949 stack.pop();
747950 }
748− let parent = stack.last().unwrap().node;
951+ // a column-0 section after a block file's root is adopted by it (§4.9)
952+ let mut parent = stack.last().unwrap().node;
953+ let mut noncanonical = vec!["setext heading".to_string()];
954+ if parent == 0 {
955+ if let Some(br) = block_root {
956+ parent = br;
957+ noncanonical.push("column-0 node in a block file, adopted by its root".into());
958+ }
959+ }
749960 let idx = nodes.len();
750961 nodes.push(Node {
751962 kind: Kind::Section,
@@ -755,31 +966,29 @@ fn make_setext_section(
755966 start: lines[title_li].start,
756967 end: lines[title_li].end,
757968 },
758− body_span: Span {
759− start: underline.end + 1,
760− end: underline.end + 1,
761− },
969+ content: Vec::new(),
762970 span: Span {
763971 start: lines[title_li].start,
764− end: underline.end + 1,
972+ end: underline.next,
765973 },
766974 children: Vec::new(),
767975 parent: Some(parent),
768− file: nodes[parent].file,
769976 block: None,
770977 embed: None,
771978 indent: title_indent,
772979 level: Some(level),
773− noncanonical: vec!["setext heading".into()],
980+ noncanonical,
774981 });
775982 nodes[parent].children.push(idx);
776− extend_spans(nodes, idx, underline.end + 1);
983+ nodes[parent].content.push(Content::Node(idx));
984+ extend_spans(nodes, idx, underline.next);
777985 stack.push(Frame {
778986 node: idx,
779987 indent: title_indent,
780988 level: Some(level),
781989 is_embed: false,
782990 });
991+ idx
783992}
784993
785994fn extend_spans(nodes: &mut Vec<Node>, idx: usize, end: usize) {
crates/fold-core/src/reading.rs+10−125
@@ -1,7 +1,7 @@
11//! The reading pane's document: the resolved render with a map from line
22//! numbers to nodes, so keys can act on "the node under the cursor" (§10.4).
33
4−use crate::parse::{Kind, TaskState};
4+use crate::render::{render_lines, LineKind};
55use crate::tree::NRef;
66use crate::vault::Vault;
77
@@ -23,131 +23,16 @@ pub struct ReadingDoc {
2323pub fn build(vault: &Vault, root: NRef) -> ReadingDoc {
2424 let mut lines = Vec::new();
2525 let mut refs = Vec::new();
26− build_node(vault, root, 1, 0, &mut lines, &mut refs, &mut Vec::new());
27− ReadingDoc { lines, refs }
28−}
29−
30−#[allow(clippy::too_many_arguments)]
31−fn build_node(
32− vault: &Vault,
33− r: NRef,
34− dlevel: usize,
35− dindent: usize,
36− lines: &mut Vec<String>,
37− refs: &mut Vec<LineRef>,
38− seen: &mut Vec<NRef>,
39−) {
40− if seen.contains(&r) {
41− return;
42− }
43− seen.push(r);
44− let n = vault.tree.node(r);
45− match n.kind {
46− Kind::Root => {}
47− Kind::Section => {
48− let mut s = " ".repeat(dindent);
49− s.push_str(&"#".repeat(dlevel.max(1)));
50− s.push(' ');
51− if !n.is_block() {
52− checkbox(n.task, &mut s);
53− }
54− s.push_str(n.title.trim_end());
55− lines.push(s);
56− refs.push(LineRef::Title(r));
57− }
58− Kind::Item => {
59− let mut s = " ".repeat(dindent);
60− s.push_str("- ");
61− if !n.is_block() {
62− checkbox(n.task, &mut s);
63− }
64− s.push_str(n.title.trim_end());
65− lines.push(s);
66− refs.push(LineRef::Title(r));
67− }
68− }
69− // body
70− let body = n.body_lines(vault.tree.text_of(r));
71− let mut body: Vec<&str> = body;
72− while body.first().map(|l| l.trim().is_empty()) == Some(true) {
73− body.remove(0);
74− }
75− while body.last().map(|l| l.trim().is_empty()) == Some(true) {
76− body.pop();
77− }
78− if !body.is_empty() && n.kind != Kind::Root {
79− lines.push(String::new());
80− refs.push(LineRef::Blank);
26+ for l in render_lines(&vault.tree, root, 1, true) {
27+ refs.push(match l.kind {
28+ LineKind::Title => LineRef::Title(l.node),
29+ LineKind::Body => LineRef::Body(l.node),
30+ LineKind::Embed => LineRef::Embed(l.node),
31+ LineKind::Blank => LineRef::Blank,
32+ });
33+ lines.push(l.text);
8134 }
82− let dedent_by = vault.tree.indent(r);
83− for l in &body {
84− lines.push(format!("{}{}", " ".repeat(dindent), dedent(l, dedent_by)));
85− refs.push(LineRef::Body(r));
86− }
87− // children
88− let children = vault.tree.resolved_children(r);
89− let mut first = true;
90− for c in children {
91− let cn = vault.tree.node(c);
92− if cn.kind == Kind::Root {
93− continue;
94− }
95− if first {
96− if !body.is_empty() || n.kind != Kind::Root {
97− lines.push(String::new());
98− refs.push(LineRef::Blank);
99− }
100− first = false;
101− } else if cn.kind == Kind::Section {
102− lines.push(String::new());
103− refs.push(LineRef::Blank);
104− }
105− let cindent = dindent + vault.tree.indent(c).saturating_sub(vault.tree.indent(r));
106− let clevel = match cn.kind {
107− Kind::Section => dlevel + 1,
108− _ => dlevel,
109− };
110− let target = vault.tree.resolved_child(c);
111− if target != c {
112− build_node(vault, target, clevel, cindent, lines, refs, seen);
113− } else if cn.is_embed() {
114− lines.push(format!(
115− "{}![[{}]]",
116− " ".repeat(cindent),
117− cn.embed.as_ref().unwrap()
118− ));
119− refs.push(LineRef::Embed(c));
120− } else {
121− build_node(vault, c, clevel, cindent, lines, refs, seen);
122− }
123− }
124− seen.pop();
125−}
126−
127−fn checkbox(task: Option<TaskState>, out: &mut String) {
128− match task {
129− Some(TaskState::Open) => out.push_str("[ ] "),
130− Some(TaskState::Done) => out.push_str("[x] "),
131− None => {}
132− }
133−}
134−
135−fn dedent(line: &str, cols: usize) -> &str {
136− if cols == 0 {
137− return line;
138− }
139− let mut removed = 0;
140− for (i, ch) in line.char_indices() {
141− if removed >= cols {
142− return &line[i..];
143− }
144− match ch {
145− ' ' => removed += 1,
146− '\t' => removed += 4,
147− _ => return &line[i..],
148− }
149− }
150− ""
35+ ReadingDoc { lines, refs }
15136}
15237
15338/// The nearest node at-or-above a line (blank lines belong to whatever is
crates/fold-core/src/render.rs+205−212
@@ -1,8 +1,39 @@
11//! `render(node, base, resolve_blocks) → String` (§5.1).
2+//!
3+//! One walker produces the lines; `render`, the reading pane (§10.4) and the
4+//! editing buffer (§5.2) all consume it, so what the user reads, what they
5+//! edit and what splice writes back are the same text.
26
3−use crate::parse::{Kind, TaskState};
7+use crate::parse::{Content, Kind, Span, TaskState};
48use crate::tree::{NRef, Tree};
59
10+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11+pub enum LineKind {
12+ Title,
13+ Body,
14+ /// An embed kept as `![[id]]` (unresolved render, or a broken embed).
15+ Embed,
16+ Blank,
17+}
18+
19+/// One rendered line and where it came from.
20+#[derive(Debug, Clone)]
21+pub struct RLine {
22+ pub text: String,
23+ pub kind: LineKind,
24+ /// The node whose title, body or embed this line is.
25+ pub node: NRef,
26+ /// The block that owns the line: the nearest block at or above it in the
27+ /// resolved walk, or the render root when there is none (§5.2).
28+ pub owner: NRef,
29+ /// The owner in effect where `owner` was entered (itself for the
30+ /// render root's owner).
31+ pub outer: NRef,
32+ /// Display level and indent of `node`.
33+ pub level: usize,
34+ pub indent: usize,
35+}
36+
637/// Render the node and its subtree as a standalone Markdown document.
738///
839/// With `resolve_blocks = false` this is the on-disk spelling (embeds kept);
@@ -22,21 +53,10 @@ pub fn render(tree: &Tree, node: NRef, base: usize, resolve_blocks: bool) -> Str
2253 }
2354 }
2455 }
25− // The render root prints at level `base`, indent 0; descendants follow
26− // their position relative to it.
27− let base_level = tree.level(node).min(base);
28− let base_indent = tree.indent(node);
29− render_at(
30− tree,
31− node,
32− base,
33− 0,
34− base_level,
35− base_indent,
36− resolve_blocks,
37− &mut out,
38− &mut Vec::new(),
39− );
56+ for l in render_lines(tree, node, base, resolve_blocks) {
57+ out.push_str(&l.text);
58+ out.push('\n');
59+ }
4060 // canonical: exactly one trailing newline
4161 while out.ends_with("\n\n") {
4262 out.pop();
@@ -47,211 +67,198 @@ pub fn render(tree: &Tree, node: NRef, base: usize, resolve_blocks: bool) -> Str
4767 out
4868}
4969
50−/// Render node `r` at display position (`dlevel`, `dindent`), then its body
51−/// and children. `base_level`/`base_indent` are the tree position of the
52−/// render root, used to compute relative positions of descendants.
53−#[allow(clippy::too_many_arguments)]
54−fn render_at(
55− tree: &Tree,
56− r: NRef,
57− dlevel: usize,
58− dindent: usize,
59− base_level: usize,
60− base_indent: usize,
61− resolve: bool,
62− out: &mut String,
63− seen: &mut Vec<NRef>,
64−) {
65− if seen.contains(&r) {
66− return; // cycle guard
70+/// The lines of `render(node, base, resolve_blocks)` (without frontmatter
71+/// and trailing blank lines), each tagged with its node and owning block.
72+///
73+/// Bodies are verbatim, blank lines included, so the file's own spacing is
74+/// reproduced. Sections are re-levelled by `base − level(node)` and
75+/// everything re-indented by `−indent(node)` (§5.1.4); the implicit Root
76+/// renders its top-level nodes at `base`.
77+pub fn render_lines(tree: &Tree, node: NRef, base: usize, resolve_blocks: bool) -> Vec<RLine> {
78+ let dlevel = if tree.node(node).kind == Kind::Root {
79+ base.saturating_sub(1)
80+ } else {
81+ base
82+ };
83+ let mut out = Vec::new();
84+ let mut w = Walk {
85+ tree,
86+ resolve: resolve_blocks,
87+ out: &mut out,
88+ seen: Vec::new(),
89+ };
90+ w.node(node, dlevel, 0, node, node);
91+ while out.last().map(|l| l.kind == LineKind::Blank) == Some(true) {
92+ out.pop();
6793 }
68− seen.push(r);
69− let n = tree.node(r);
70− let is_block_root = n.is_block();
71− match n.kind {
72− Kind::Root => {}
73− Kind::Section => {
74− push_indent(out, dindent);
75− for _ in 0..dlevel.max(1) {
76− out.push('#');
94+ out
95+}
96+
97+struct Walk<'a> {
98+ tree: &'a Tree,
99+ resolve: bool,
100+ out: &'a mut Vec<RLine>,
101+ seen: Vec<NRef>,
102+}
103+
104+impl Walk<'_> {
105+ /// Emit node `r` at display position (`dlevel`, `dindent`): title line,
106+ /// body, children in document order with any tail text between them.
107+ fn node(&mut self, r: NRef, dlevel: usize, dindent: usize, owner: NRef, outer: NRef) {
108+ if self.seen.contains(&r) {
109+ return; // cycle guard
110+ }
111+ self.seen.push(r);
112+ let tree = self.tree;
113+ let n = tree.node(r);
114+ let (owner, outer) = if n.is_block() && r != owner {
115+ (r, owner)
116+ } else {
117+ (owner, outer)
118+ };
119+ let title = match n.kind {
120+ Kind::Root => None,
121+ _ if n.is_embed() => Some((embed_line(n, dlevel, dindent), LineKind::Embed)),
122+ Kind::Section => {
123+ let mut s = " ".repeat(dindent);
124+ s.push_str(&"#".repeat(dlevel.max(1)));
125+ s.push(' ');
126+ push_checkbox(n.task, &mut s);
127+ s.push_str(&n.title);
128+ Some((s.trim_end().to_string(), LineKind::Title))
77129 }
78− out.push(' ');
79− // A block's task state is frontmatter-only (§4.9): no checkbox
80− // on its own title line.
81− if !is_block_root {
82− push_checkbox(n.task, out);
130+ Kind::Item => {
131+ let mut s = " ".repeat(dindent);
132+ s.push_str("- ");
133+ push_checkbox(n.task, &mut s);
134+ s.push_str(&n.title);
135+ Some((s.trim_end().to_string(), LineKind::Title))
83136 }
84− out.push_str(n.title.trim_end());
85− out.push('\n');
137+ };
138+ if let Some((text, kind)) = title {
139+ self.push(text, kind, r, owner, outer, dlevel, dindent);
86140 }
87− Kind::Item => {
88− push_indent(out, dindent);
89− out.push_str("- ");
90− if !is_block_root {
91− push_checkbox(n.task, out);
141+ for c in &n.content {
142+ match *c {
143+ Content::Text(sp) => self.span_lines(r, sp, dlevel, dindent, owner, outer),
144+ Content::Node(c) => self.child(r, (r.0, c), dlevel, dindent, owner, outer),
92145 }
93− out.push_str(n.title.trim_end());
94− out.push('\n');
95− }
96− }
97− // Body, verbatim, dedented by the node's original indent (§5.1.3) and
98− // re-indented to the display position. For a block root rendered
99− // unresolved the body's leading blank line is kept (it is the file's
100− // separator after the frontmatter/title); otherwise bodies are separated
101− // from the title by one canonical blank line.
102− let keep_file_spacing = is_block_root && !resolve;
103− let body = if keep_file_spacing {
104− // verbatim: the body region as found, minus trailing blank lines
105− let mut lines = tree.node(r).body_lines(tree.text_of(r));
106− while lines.last().map(|l| l.trim().is_empty()) == Some(true) {
107− lines.pop();
108− }
109− lines
110− } else {
111− trimmed_body_keep(tree, r, false)
112− };
113− let has_trailing_sep = body_sep_blank(tree, r);
114− if !body.is_empty() && n.kind != Kind::Root && !is_block_root {
115− out.push('\n');
116− }
117− let body_dedent = tree.indent(r);
118− for l in &body {
119− if keep_file_spacing {
120− // verbatim: the file's own spacing is reproduced exactly
121− out.push_str(dedent_keep_blanks(l, body_dedent));
122− } else {
123− push_indent(out, dindent);
124− out.push_str(dedent(l, body_dedent));
125146 }
126− out.push('\n');
147+ self.seen.pop();
127148 }
128− // Children in document order.
129− let children = tree.resolved_children(r);
130− let all_item_children = !children.is_empty()
131− && children
132− .iter()
133− .all(|&k| tree.node(k).kind == Kind::Item);
134− let blank_rule = if keep_file_spacing { has_trailing_sep } else { !all_item_children };
135− let mut prev: Option<NRef> = None;
136− for c in children {
149+
150+ fn child(&mut self, r: NRef, c: NRef, dlevel: usize, dindent: usize, owner: NRef, outer: NRef) {
151+ let tree = self.tree;
137152 let cn = tree.node(c);
138− if cn.kind == Kind::Root {
139− continue;
140− }
141− let is_first = prev.is_none();
142− let prev_was_section = prev
143− .map(|p| tree.node(p).kind == Kind::Section)
144− .unwrap_or(false);
145− if is_first {
146− if !body.is_empty() || has_trailing_sep || (n.kind != Kind::Root && blank_rule) {
147− out.push('\n');
148− }
149− } else if cn.kind == Kind::Section || prev_was_section {
150− out.push('\n');
151− }
152− // Display position of the child: relative to this node. Written
153− // levels are honoured (§3.1): a section's display level never moves
154− // down from what is written in the file.
153+ // Display position relative to the parent, from positions in the
154+ // parent's file (an embed's target sits at the embed's position).
155155 let cindent = dindent + tree.indent(c).saturating_sub(tree.indent(r));
156− let clevel = match cn.kind {
157− Kind::Section => {
158− let rel = dlevel + section_steps(tree, r, c);
159− if resolve {
160− rel // zoom-out: re-level by base − level(root) (§5.1.4)
156+ if self.resolve && cn.is_embed() {
157+ // only the block's own embed resolves; a second embed of the
158+ // id renders as broken (§6.2), so the block's lines appear, and
159+ // are owned, once whatever the zoom
160+ let t = tree.resolved_child(c);
161+ if t != c {
162+ // A section at this position is one below the parent; an
163+ // item shares its enclosing section's level.
164+ let tlevel = if tree.node(t).kind == Kind::Section
165+ || tree.node(r).kind == Kind::Root
166+ {
167+ dlevel + 1
161168 } else {
162− rel.max(tree.level(c)) // on-disk spelling: written levels honoured
169+ dlevel
170+ };
171+ self.node(t, tlevel, cindent, owner, outer);
172+ // the embed line's own blank separators (and any lines
173+ // wrongly placed under it) stay in the parent
174+ for cc in &cn.content {
175+ match *cc {
176+ Content::Text(sp) => {
177+ self.span_lines(c, sp, dlevel, cindent, owner, outer)
178+ }
179+ Content::Node(k) => self.child(c, (c.0, k), dlevel, cindent, owner, outer),
180+ }
163181 }
182+ return;
164183 }
165− _ => dlevel,
166− };
167− if cn.is_embed() {
168− let id = cn.embed.clone().unwrap();
169− let target = if resolve { tree.resolved_child(c) } else { c };
170− if target == c {
171− push_indent(out, cindent);
172− out.push_str("![[");
173− out.push_str(id.as_str());
174− out.push_str("]]\n");
175− } else {
176− render_at(
177− tree, target, clevel, cindent, base_level, base_indent, resolve, out, seen,
178− );
179− }
180− prev = Some(c);
181− continue;
182184 }
183− render_at(
184− tree, c, clevel, cindent, base_level, base_indent, resolve, out, seen,
185− );
186− prev = Some(c);
185+ // A section under an item can sit shallower than the item's section
186+ // (§3.1: a skipped level above it is honoured), so the shift can
187+ // take it below level 1. It prints at 1 then, and its subtree is
188+ // levelled from there, so a child section still prints deeper.
189+ let min = if cn.kind == Kind::Section { 1 } else { 0 };
190+ let clevel = (dlevel as isize + tree.level(c) as isize - tree.level(r) as isize).max(min);
191+ self.node(c, clevel as usize, cindent, owner, outer);
187192 }
188− seen.pop();
189−}
190193
191−/// Section steps between a node and a descendant (the number of section
192−/// boundaries crossed, counting the descendant itself if it is one).
193−fn section_steps(tree: &Tree, anc: NRef, desc: NRef) -> usize {
194− let mut n = 0usize;
195− let mut cur = Some(desc);
196− while let Some(c) = cur {
197− if c == anc {
198− break;
194+ /// Body lines of a span, verbatim: dedented by the node's written indent
195+ /// (§5.1.3) and re-indented to its display indent. Blank lines stay empty.
196+ fn span_lines(
197+ &mut self,
198+ r: NRef,
199+ span: Span,
200+ dlevel: usize,
201+ dindent: usize,
202+ owner: NRef,
203+ outer: NRef,
204+ ) {
205+ if span.start >= span.end {
206+ return;
199207 }
200− if tree.node(c).kind == Kind::Section {
201− n += 1;
208+ let tree = self.tree;
209+ let cols = tree.node(r).indent;
210+ // an item's text sits two columns in; a line left of that (adopted
211+ // column-0 text in a block file, §4.9) is written there
212+ let min_rel: usize = if tree.node(r).kind == Kind::Item { 2 } else { 0 };
213+ for l in tree.text_of(r)[span.start..span.end].split_inclusive('\n') {
214+ let l = l.strip_suffix('\n').unwrap_or(l);
215+ let l = l.strip_suffix('\r').unwrap_or(l);
216+ if l.trim().is_empty() {
217+ self.push(String::new(), LineKind::Blank, r, owner, outer, dlevel, dindent);
218+ } else {
219+ let rel = dedent(l, cols);
220+ let lead = rel.len() - rel.trim_start_matches(' ').len();
221+ let pad = min_rel.saturating_sub(lead);
222+ let text = format!("{}{}", " ".repeat(dindent + pad), rel);
223+ self.push(text, LineKind::Body, r, owner, outer, dlevel, dindent);
224+ }
202225 }
203− cur = tree.node(c).parent.map(|p| (c.0, p));
204226 }
205− n.max(1)
206−}
207227
208−/// True if the node's body region ends with (or consists of) a blank line —
209−/// i.e. the file had a blank separating the body (or title) from the first
210−/// child.
211−fn body_sep_blank(tree: &Tree, r: NRef) -> bool {
212− let n = tree.node(r);
213− if n.body_span.end <= n.body_span.start {
214− return false;
228+ #[allow(clippy::too_many_arguments)]
229+ fn push(
230+ &mut self,
231+ text: String,
232+ kind: LineKind,
233+ node: NRef,
234+ owner: NRef,
235+ outer: NRef,
236+ level: usize,
237+ indent: usize,
238+ ) {
239+ self.out.push(RLine {
240+ text,
241+ kind,
242+ node,
243+ owner,
244+ outer,
245+ level,
246+ indent,
247+ });
215248 }
216− let region = &tree.text_of(r)[n.body_span.start..n.body_span.end];
217− region
218− .split_inclusive('\n')
219− .map(|l| l.strip_suffix('\n').unwrap_or(l))
220− .last()
221− .map(|l| l.trim().is_empty())
222− .unwrap_or(false)
223249}
224250
225−fn trimmed_body_keep<'a>(tree: &'a Tree, r: NRef, keep_leading: bool) -> Vec<&'a str> { let mut lines = tree.node(r).body_lines(tree.text_of(r));
226− if !keep_leading {
227− while lines.first().map(|l| l.trim().is_empty()) == Some(true) {
228− lines.remove(0);
229− }
230− } else {
231− // keep the file's own separator before the body: a leading blank
232− // line, or — for an item-rooted block — the 2-space body indent,
233− // which reads as a blank after the title in a standalone file.
234− if lines.len() > 1 && lines[0].trim().is_empty() {
235− while lines.len() > 1 && lines[0].trim().is_empty() && lines[1].trim().is_empty() {
236− lines.remove(0);
237− }
238− } else if !lines.is_empty()
239− && lines[0].starts_with(" ")
240− && !lines[0].trim_start().starts_with("- ")
241− && !lines[0].trim_start().starts_with('#')
242− && lines[0].trim_start().len() == lines[0].trim().len()
243− && !lines[0].trim().is_empty()
244− {
245− lines.insert(0, "");
246− }
247− }
248− while lines.last().map(|l| l.trim().is_empty()) == Some(true) {
249− lines.pop();
251+/// An embed line at a display position: a heading embed (`## ![[id]]`) for
252+/// a section-position embed, a bare `![[id]]` for an item position (§4.7).
253+pub fn embed_line(n: &crate::parse::Node, dlevel: usize, dindent: usize) -> String {
254+ let id = n.embed.as_ref().expect("embed node");
255+ match n.kind {
256+ Kind::Section => format!("{}{} ![[{}]]", " ".repeat(dindent), "#".repeat(dlevel.max(1)), id),
257+ _ => format!("{}![[{}]]", " ".repeat(dindent), id),
250258 }
251− lines
252259}
253260
254−fn push_checkbox(task: Option<TaskState>, out: &mut String) {
261+pub(crate) fn push_checkbox(task: Option<TaskState>, out: &mut String) {
255262 match task {
256263 Some(TaskState::Open) => out.push_str("[ ] "),
257264 Some(TaskState::Done) => out.push_str("[x] "),
@@ -259,13 +266,8 @@ fn push_checkbox(task: Option<TaskState>, out: &mut String) {
259266 }
260267}
261268
262−fn push_indent(out: &mut String, cols: usize) {
263− for _ in 0..cols {
264− out.push(' ');
265− }
266−}
267−
268−fn dedent(line: &str, cols: usize) -> &str {
269+/// Remove up to `cols` columns of leading indentation (a tab counts four).
270+pub fn dedent(line: &str, cols: usize) -> &str {
269271 if cols == 0 {
270272 return line;
271273 }
@@ -282,12 +284,3 @@ fn dedent(line: &str, cols: usize) -> &str {
282284 }
283285 ""
284286}
285−
286−/// Dedent only lines that carry content; blank lines stay empty.
287−fn dedent_keep_blanks(line: &str, cols: usize) -> &str {
288− if line.trim().is_empty() {
289− ""
290− } else {
291− dedent(line, cols)
292− }
293−}
crates/fold-core/src/tree.rs+165−51
@@ -2,6 +2,8 @@
22
33use crate::ident::Id;
44use crate::parse::{Kind, Node, ParsedFile, TaskState};
5+use std::collections::{HashMap, HashSet};
6+use std::sync::OnceLock;
57
68/// The logical tree formed by all parsed files stitched together.
79pub struct Tree {
@@ -10,12 +12,57 @@ pub struct Tree {
1012 pub root: (usize, usize),
1113 /// (file, node) → id for every block.
1214 pub blocks: Vec<((usize, usize), Id)>,
15+ /// `blocks` by id, for `block_by_id`; `restitch` rebuilds it.
16+ block_index: HashMap<Id, NRef>,
17+ /// `embeds`, built at first use; `restitch` drops it.
18+ embed_index: OnceLock<HashMap<Id, NRef>>,
1319}
1420
1521/// A global node reference.
1622pub type NRef = (usize, usize);
1723
1824impl Tree {
25+ /// Stitch parsed files, `root.md` first, into one tree (§4.7).
26+ pub fn new(files: Vec<ParsedFile>) -> Tree {
27+ let mut t = Tree {
28+ files,
29+ root: (0, 0),
30+ blocks: Vec::new(),
31+ block_index: HashMap::new(),
32+ embed_index: OnceLock::new(),
33+ };
34+ t.restitch();
35+ t
36+ }
37+
38+ /// Rebuild the block list, and what is derived from the embeds, after
39+ /// a file was replaced: every block is the first node of its own file
40+ /// (§4.9).
41+ pub fn restitch(&mut self) {
42+ let mut blocks: Vec<(NRef, Id)> = Vec::new();
43+ for (fi, f) in self.files.iter().enumerate() {
44+ if fi == 0 {
45+ continue;
46+ }
47+ if let Some(&first) = f.nodes[f.root_node].children.first() {
48+ if let Some(b) = &f.nodes[first].block {
49+ if let Some(id) = &b.id {
50+ blocks.push(((fi, first), id.clone()));
51+ }
52+ }
53+ }
54+ }
55+ // the first file holding an id is its block, as a reload meets
56+ // them; any other is a duplicate id, which `check` reports (§6.2)
57+ let mut index = HashMap::with_capacity(blocks.len());
58+ for (r, id) in &blocks {
59+ index.entry(id.clone()).or_insert(*r);
60+ }
61+ self.blocks = blocks;
62+ self.block_index = index;
63+ self.embed_index = OnceLock::new();
64+ }
65+
1966 pub fn node(&self, r: NRef) -> &Node {
2067 &self.files[r.0].nodes[r.1]
2168 }
@@ -63,6 +110,20 @@ impl Tree {
63110 }
64111 }
65112
113+ /// The level a section has by position alone: 1 + its section ancestors
114+ /// (§3.1), whatever level is written.
115+ pub fn derived_level(&self, r: NRef) -> usize {
116+ let mut level = 1;
117+ let mut cur = self.node(r).parent;
118+ while let Some(p) = cur {
119+ if self.node((r.0, p)).kind == Kind::Section {
120+ level += 1;
121+ }
122+ cur = self.node((r.0, p)).parent;
123+ }
124+ level
125+ }
126+
66127 /// indent(n) = 2 × number of item ancestors (§3.1).
67128 pub fn indent(&self, r: NRef) -> usize {
68129 let mut n = 0usize;
@@ -103,50 +164,74 @@ impl Tree {
103164 segs
104165 }
105166
106− /// The block that owns a node: nearest ancestor-or-self that is one (§5.2).
107− pub fn owning_block(&self, r: NRef) -> NRef {
108− let mut cur = Some(r);
109− while let Some(c) = cur {
110− if self.node(c).is_block() {
111− return c;
112− }
113− cur = self.node(c).parent.map(|p| (c.0, p));
114− }
115− self.root
116− }
117−
118− /// Resolve an embed node to the block it references (one level).
167+ /// Resolve an embed node to the block it references (one level). Only
168+ /// the block's own embed (`embed_of`) resolves: any other embed of its
169+ /// id is left as it is and reads as broken everywhere, as §6.2 says,
170+ /// so walk, render, the outline and every verb meet a block once.
119171 pub fn resolved_child(&self, r: NRef) -> NRef {
120172 let n = self.node(r);
121173 if let Some(id) = &n.embed {
122174 if let Some(target) = self.block_by_id(id) {
123− return target;
175+ if self.embed_of(id) == Some(r) {
176+ return target;
177+ }
124178 }
125179 }
126180 r
127181 }
128182
183+ /// The embed a block is stitched in at (§4.7), if any: see `embeds`.
184+ pub fn embed_of(&self, id: &Id) -> Option<NRef> {
185+ self.embeds().get(id).copied()
186+ }
187+
188+ /// `embed_of` for every id at once: the embed each block is stitched in
189+ /// at, the first of its id met in tree order from the root, as walk
190+ /// and the outline meet them. Any other embed of the id is a duplicate
191+ /// and reads as broken (§6.2). An id embedded only in files the root
192+ /// does not reach (an orphan block, a cycle) keeps its first embed in
193+ /// file order.
194+ pub fn embeds(&self) -> &HashMap<Id, NRef> {
195+ self.embed_index.get_or_init(|| {
196+ let mut out = HashMap::new();
197+ if self.files.is_empty() {
198+ return out;
199+ }
200+ // pre-order, entering each block at its first embed only, so
201+ // the walk ends even through a cycle
202+ let mut stack = vec![self.root];
203+ while let Some(r) = stack.pop() {
204+ let mut at = r;
205+ if let Some(id) = &self.node(r).embed {
206+ if !out.contains_key(id) {
207+ out.insert(id.clone(), r);
208+ at = self.block_by_id(id).unwrap_or(r);
209+ }
210+ }
211+ stack.extend(self.node(at).children.iter().rev().map(|&c| (at.0, c)));
212+ }
213+ for (fi, f) in self.files.iter().enumerate() {
214+ for (ni, nd) in f.nodes.iter().enumerate() {
215+ if let Some(id) = &nd.embed {
216+ out.entry(id.clone()).or_insert((fi, ni));
217+ }
218+ }
219+ }
220+ out
221+ })
222+ }
223+
129224 pub fn block_by_id(&self, id: &Id) -> Option<NRef> {
130− self.blocks
131− .iter()
132− .find(|(_, bid)| bid == id)
133− .map(|(t, _)| *t)
225+ self.block_index.get(id).copied()
134226 }
135227
136− /// Direct children of a node with embeds resolved (broken embeds kept as-is).
228+ /// Direct children of a node with embeds resolved (broken embeds, and
229+ /// second embeds of a block, kept as-is).
137230 pub fn resolved_children(&self, r: NRef) -> Vec<NRef> {
138231 self.node(r)
139232 .children
140233 .iter()
141− .map(|&c| {
142− let cr = (r.0, c);
143− let rc = self.resolved_child(cr);
144− if rc != cr {
145− rc
146− } else {
147− cr
148− }
149− })
234+ .map(|&c| self.resolved_child((r.0, c)))
150235 .collect()
151236 }
152237
@@ -155,35 +240,46 @@ impl Tree {
155240 self.node(r).children.iter().map(|&c| (r.0, c)).collect()
156241 }
157242
158− /// 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).
159246 pub fn task_counts(&self, r: NRef) -> (usize, usize) {
160247 let mut open = 0;
161248 let mut total = 0;
162− self.walk(r, &mut |t, n| {
249+ let mut count = |t: &Tree, n: NRef| {
163250 if let Some(st) = t.node(n).task {
164251 total += 1;
165252 if st == TaskState::Open {
166253 open += 1;
167254 }
168255 }
169− });
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());
170259 (open, total)
171260 }
172261
173262 /// Pre-order walk over the resolved tree (embeds followed, cycles guarded).
174263 pub fn walk(&self, r: NRef, f: &mut dyn FnMut(&Tree, NRef)) {
175− let mut seen = Vec::new();
176− self.walk_inner(r, f, &mut seen);
264+ self.walk_inner(r, &|_, _| false, f, &mut HashSet::new());
177265 }
178266
179− fn walk_inner(&self, r: NRef, f: &mut dyn FnMut(&Tree, NRef), seen: &mut Vec<NRef>) {
180− 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) {
181276 return; // cycle guard
182277 }
183− seen.push(r);
184278 f(self, r);
185279 for cr in self.resolved_children(r) {
186− self.walk_inner(cr, f, seen);
280+ if !skip(self, cr) {
281+ self.walk_inner(cr, skip, f, seen);
282+ }
187283 }
188284 }
189285}
@@ -192,24 +288,42 @@ impl Node {
192288 pub fn is_block(&self) -> bool {
193289 self.block.is_some()
194290 }
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+ }
195296 pub fn is_embed(&self) -> bool {
196297 self.embed.is_some()
197298 }
198− /// Body lines exactly as found in the file text.
299+ /// Body lines (the leading text children) exactly as found in the file.
199300 pub fn body_lines<'a>(&self, file_text: &'a str) -> Vec<&'a str> {
200− if self.body_span.start >= self.body_span.end {
201− return Vec::new();
301+ match self.body_span() {
302+ Some(sp) => content_lines(file_text, sp),
303+ None => Vec::new(),
202304 }
203− let mut lines: Vec<&'a str> = file_text[self.body_span.start..self.body_span.end]
204− .split_inclusive('\n')
205− .map(|l| l.strip_suffix('\n').unwrap_or(l))
206− .collect();
207− // A blank line produced only by the separator before a child node is
208− // not body content, but it is remembered: the renderer uses it to
209− // reproduce the file's spacing.
210− if lines.iter().all(|l| l.trim().is_empty()) {
211− lines.clear();
212− }
213− lines
214305 }
306+
307+ /// The lines of every text child, in order: the node's own prose,
308+ /// wherever it sits among its children (§3.3).
309+ pub fn text_lines<'a>(&self, file_text: &'a str) -> Vec<&'a str> {
310+ self.text_runs()
311+ .into_iter()
312+ .flat_map(|(_, sp)| content_lines(file_text, sp))
313+ .collect()
314+ }
315+}
316+
317+/// The lines of a text run. A run of blank lines alone is a separator, not
318+/// content: the renderer reproduces it, but it has no lines to show.
319+fn content_lines(file_text: &str, sp: crate::parse::Span) -> Vec<&str> {
320+ let mut lines: Vec<&str> = file_text[sp.start..sp.end]
321+ .split_inclusive('\n')
322+ .map(|l| l.strip_suffix('\n').unwrap_or(l))
323+ .map(|l| l.strip_suffix('\r').unwrap_or(l))
324+ .collect();
325+ if lines.iter().all(|l| l.trim().is_empty()) {
326+ lines.clear();
327+ }
328+ lines
215329}
crates/fold-core/src/vault.rs+276−253
@@ -1,17 +1,14 @@
11//! The vault on disk: loading, indexing, atomic span-preserving writes,
22//! trash (§4.1, §11).
33
4−use crate::ident::{filename, slug, split_filename, Id};
5−use crate::parse::{parse_file, parse_frontmatter, Block, Kind, ParsedFile, Span};
4+use crate::ident::{split_filename, Id};
5+use crate::parse::{parse_file, parse_frontmatter, Block, Frontmatter, Kind, ParsedFile, Span};
66use crate::tree::{NRef, Tree};
7−use std::collections::HashMap;
87use std::path::{Path, PathBuf};
98
109pub struct Vault {
1110 pub dir: PathBuf,
1211 pub tree: Tree,
13− /// blake3 hash of each file's text as last read or written by us (§5.2.5).
14− pub hashes: Vec<String>,
1512}
1613
1714/// A file found in the vault directory that the parser ignores (§4.1).
@@ -39,12 +36,7 @@ impl Vault {
3936 }
4037 let mut v = Vault {
4138 dir: dir.to_path_buf(),
42− tree: Tree {
43− files: Vec::new(),
44− root: (0, 0),
45− blocks: Vec::new(),
46− },
47− hashes: Vec::new(),
39+ tree: Tree::new(Vec::new()),
4840 };
4941 v.reload()?;
5042 Ok(v)
@@ -52,111 +44,98 @@ impl Vault {
5244
5345 /// Rebuild the index from the vault (§11.3).
5446 pub fn reload(&mut self) -> std::io::Result<()> {
55− let mut files: Vec<ParsedFile> = Vec::new();
56− let mut hashes = Vec::new();
57− let root_text = read_if_exists(&self.dir.join("root.md"))?.unwrap_or_default();
58− let root_block = Block {
59− id: None,
60− path: "root.md".into(),
61− props: parse_frontmatter(&root_text)
62− .map(|f| f.props)
63− .unwrap_or_default(),
64− frontmatter_raw: parse_frontmatter(&root_text)
65− .map(|f| f.raw)
66− .unwrap_or_default(),
67− frontmatter_span: parse_frontmatter(&root_text).map(|f| f.span),
68− edge_span: None,
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()),
6993 };
70− hashes.push(blake3::hash(root_text.as_bytes()).to_hex().to_string());
71− files.push(parse_file("root.md", &root_text, 0, Some(root_block)));
94+ v.reload()?;
95+ Ok(v)
96+ }
7297
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)];
73103 let mut entries: Vec<String> = std::fs::read_dir(&self.dir)?
74104 .filter_map(|e| e.ok())
75105 .map(|e| e.file_name().to_string_lossy().to_string())
76− .filter(|n| n.ends_with(".md") && n != "root.md")
106+ .filter(|n| n.ends_with(".md") && n != "root.md" && !n.starts_with('.'))
77107 .collect();
78108 entries.sort();
79109 for name in entries {
80110 if name.contains(".sync-conflict-") {
81111 continue; // merge engine only (§11.4)
82112 }
83− let text = match read_if_exists(&self.dir.join(&name))? {
113+ // anything unreadable — a directory named `x.md`, non-UTF-8
114+ // text — is simply ignored (§4.1), never fatal
115+ let text = match read_md_file(&self.dir.join(&name)) {
84116 Some(t) => t,
85117 None => continue,
86118 };
87− let fm = parse_frontmatter(&text);
88− let id = fm
89− .as_ref()
90− .and_then(|f| f.props.get("id"))
91− .and_then(|v| Id::parse(v));
92− let id = match id {
93− Some(id) => id,
94− None => continue, // ignored: never parsed (§4.1)
95− };
96− let idx = files.len();
97− hashes.push(blake3::hash(text.as_bytes()).to_hex().to_string());
98− let block = Block {
99− id: Some(id),
100− path: name.clone(),
101− props: fm.as_ref().map(|f| f.props.clone()).unwrap_or_default(),
102− frontmatter_raw: fm.as_ref().map(|f| f.raw.clone()).unwrap_or_default(),
103− frontmatter_span: fm.as_ref().map(|f| f.span),
104− edge_span: None,
105− };
106− files.push(parse_file(&name, &text, idx, Some(block)));
107− }
108−
109− // Stitch: collect blocks, resolve embed edges (§4.7).
110− let mut blocks: Vec<(NRef, Id)> = Vec::new();
111− for (fi, f) in files.iter().enumerate() {
112− if fi == 0 {
113− continue;
114− }
115− if let Some(&first) = f.nodes[f.root_node].children.first() {
116− if let Some(b) = &f.nodes[first].block {
117− if let Some(id) = &b.id {
118− blocks.push(((fi, first), id.clone()));
119− }
120− }
121− }
122− }
123− // Set each block's edge_span by finding its embed in some file.
124− for f in files.iter_mut() {
125− for n in f.nodes.iter_mut() {
126− n.block.as_mut().map(|b| b.edge_span = None);
127− }
128− }
129− for ((fi, ni), id) in &blocks {
130− let mut found: Option<Span> = None;
131− let mut count = 0;
132− for f in files.iter() {
133− for n in &f.nodes {
134− if n.embed.as_ref() == Some(id) {
135− count += 1;
136− if found.is_none() {
137− found = Some(n.title_span);
138− }
139− }
140− }
141− }
142− if count > 1 {
143− // duplicate embed: diagnostic (§6.2); recorded in check
144− }
145− if let Some(span) = found {
146− files[*fi].nodes[*ni].block.as_mut().unwrap().edge_span = Some(span);
119+ if parse_frontmatter(&text).as_ref().and_then(Frontmatter::id).is_none() {
120+ continue; // ignored: never parsed (§4.1)
147121 }
122+ files.push((name, text));
148123 }
124+ Ok(files)
125+ }
149126
150− self.tree = Tree {
151− files,
152− root: (0, 0),
153− blocks,
154− };
155− self.hashes = hashes;
156− 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+ }))
157136 }
158137
159− /// Files ignored by the parser, for `notes check` (§4.1).
138+ /// Files ignored by the parser, for `fold check` (§4.1).
160139 pub fn ignored_files(&self) -> std::io::Result<Vec<IgnoredFile>> {
161140 let mut out = Vec::new();
162141 for e in std::fs::read_dir(&self.dir)?.filter_map(|e| e.ok()) {
@@ -176,7 +155,13 @@ impl Vault {
176155 }
177156 let known = self.tree.files.iter().any(|f| f.path == name);
178157 if !known {
179− let text = read_if_exists(&e.path())?.unwrap_or_default();
158+ let Some(text) = read_md_file(&e.path()) else {
159+ out.push(IgnoredFile {
160+ path: name,
161+ reason: "not a readable UTF-8 file".into(),
162+ });
163+ continue;
164+ };
180165 let reason = match parse_frontmatter(&text).and_then(|f| f.props.get("id").cloned())
181166 {
182167 Some(v) if Id::parse(&v).is_none() => {
@@ -195,166 +180,102 @@ impl Vault {
195180 self.tree.files.iter().position(|f| f.path == path)
196181 }
197182
198− /// The file a node lives in, and its recorded hash.
199− pub fn hash_of(&self, file: usize) -> &str {
200− &self.hashes[file]
201− }
202−
203183 /// Replace a byte span in a file atomically (§11.1), then re-parse.
204184 pub fn write_span(&mut self, file: usize, span: Span, replacement: &str) -> std::io::Result<()> {
205185 let f = &self.tree.files[file];
186+ // a node's span may run one byte past a file without a final newline
187+ let end = span.end.min(f.text.len());
188+ let start = span.start.min(end);
206189 let mut new_text = String::with_capacity(f.text.len() + replacement.len());
207− new_text.push_str(&f.text[..span.start]);
190+ new_text.push_str(&f.text[..start]);
208191 new_text.push_str(replacement);
209− new_text.push_str(&f.text[span.end..]);
192+ new_text.push_str(&f.text[end..]);
210193 self.write_file_text(file, &new_text)
211194 }
212195
213− /// Write a whole file atomically, then re-parse it.
196+ /// Write a whole file atomically, then re-parse it. Refuses, writing
197+ /// nothing, when the file on disk is no longer the text it was parsed
198+ /// from (`check_unchanged`).
214199 pub fn write_file_text(&mut self, file: usize, new_text: &str) -> std::io::Result<()> {
200+ self.check_unchanged(file)?;
215201 let path = self.tree.files[file].path.clone();
216202 atomic_write(&self.dir.join(&path), new_text)?;
217203 self.reparse(file, new_text)
218204 }
219205
220− /// Re-parse one file from given text (after our own write) and re-stitch.
206+ /// Refuse when the file on disk is no longer the text it was parsed
207+ /// from: every op computes its new text from the tree, so writing would
208+ /// silently drop a change (a sync, another editor) not reloaded yet
209+ /// (§1 principle 4, §5.2 step 5, §11.2).
210+ pub fn check_unchanged(&self, file: usize) -> std::io::Result<()> {
211+ let path = &self.tree.files[file].path;
212+ // a missing file reads as empty, as `reload` reads a missing root.md
213+ if read_if_exists(&self.dir.join(path))?.unwrap_or_default() != self.tree.files[file].text {
214+ return Err(std::io::Error::other(format!(
215+ "{} changed on disk; not overwriting",
216+ path
217+ )));
218+ }
219+ Ok(())
220+ }
221+
222+ /// Re-parse one file from given text (after our own write, or as read
223+ /// from disk) and re-stitch.
221224 pub fn reparse(&mut self, file: usize, text: &str) -> std::io::Result<()> {
222225 let path = self.tree.files[file].path.clone();
223226 let old_block = self.tree.files[file]
224227 .nodes
225228 .iter()
226229 .find_map(|n| n.block.clone());
230+ let fm = parse_frontmatter(text);
227231 let block = if path == "root.md" {
228− Some(Block {
229− id: None,
230− path: path.clone(),
231− props: parse_frontmatter(text).map(|f| f.props).unwrap_or_default(),
232− frontmatter_raw: parse_frontmatter(text).map(|f| f.raw).unwrap_or_default(),
233− frontmatter_span: parse_frontmatter(text).map(|f| f.span),
234− edge_span: None,
235− })
232+ Some(Block::new(&path, None, fm.as_ref()))
236233 } else {
237234 old_block.map(|mut b| {
238− let fm = parse_frontmatter(text);
239− if let Some(f) = fm.as_ref() {
240− b.props = f.props.clone();
241− b.frontmatter_raw = f.raw.clone();
242− b.frontmatter_span = Some(f.span);
243− } else {
244− b.props.clear();
245− b.frontmatter_raw.clear();
246− b.frontmatter_span = None;
247− }
235+ b.set_frontmatter(fm.as_ref());
248236 b
249237 })
250238 };
251− self.tree.files[file] = parse_file(&path, text, file, block);
252− self.hashes[file] = blake3::hash(text.as_bytes()).to_hex().to_string();
253− self.restitch();
239+ self.tree.files[file] = parse_file(&path, text, block);
240+ self.tree.restitch();
254241 Ok(())
255242 }
256243
257− /// Rebuild the block list and edge spans after a re-parse.
258− fn restitch(&mut self) {
259− let mut blocks: Vec<(NRef, Id)> = Vec::new();
260− for (fi, f) in self.tree.files.iter().enumerate() {
261− if fi == 0 {
262− continue;
263− }
264− if let Some(&first) = f.nodes[f.root_node].children.first() {
265− if let Some(b) = &f.nodes[first].block {
266− if let Some(id) = &b.id {
267− blocks.push(((fi, first), id.clone()));
268− }
269− }
270− }
271− }
272− for f in self.tree.files.iter_mut() {
273− for n in f.nodes.iter_mut() {
274− if let Some(b) = n.block.as_mut() {
275− b.edge_span = None;
276− }
277− }
278− }
279− for ((fi, ni), id) in &blocks {
280− let mut edge: Option<Span> = None;
281− for f in self.tree.files.iter() {
282− for n in &f.nodes {
283− if n.embed.as_ref() == Some(id) {
284− edge = Some(n.title_span);
285− }
286− }
287− }
288− if let Some(span) = edge {
289− self.tree.files[*fi].nodes[*ni]
290− .block
291− .as_mut()
292− .unwrap()
293− .edge_span = Some(span);
294− }
295− }
296− self.tree.blocks = blocks;
244+ /// Shortest prefix of `id` not already used by another file (§6.4).
245+ pub fn unique_prefix(&self, id: &Id) -> String {
246+ self.unique_prefix_except(id, None)
297247 }
298248
299− /// Create a new block file (§6.1): picks a unique prefix, writes
300− /// `render(node, 1, false)` with frontmatter, returns the file index.
301− pub fn create_block_file(
302− &mut self,
303− id: &Id,
304− title: &str,
305− body: &str,
306− ) -> std::io::Result<usize> {
307− let prefix = self.unique_prefix(id);
308− let name = slug(title);
309− let fname = filename(&prefix, &name);
310− let text = format!("---\nid: {}\n---\n\n{}", id, body);
311− atomic_write(&self.dir.join(&fname), &text)?;
312− self.reload()?;
313− Ok(self.file_index(&fname).unwrap())
249+ /// Like `unique_prefix`, but ignoring the file at `own` — a block's own
250+ /// file never counts against it.
251+ pub fn unique_prefix_except(&self, id: &Id, own: Option<&str>) -> String {
252+ let taken = self.taken_prefixes(own);
253+ id.shortest_prefix(&|cand| taken.iter().any(|t| t == cand))
314254 }
315255
316− /// Shortest prefix of `id` not already used by another file (§6.4).
317− pub fn unique_prefix(&self, id: &Id) -> String {
318− let taken: Vec<String> = self
319− .tree
256+ /// Prefixes used by block files other than `own` (§6.4).
257+ pub fn taken_prefixes(&self, own: Option<&str>) -> Vec<String> {
258+ self.tree
320259 .files
321260 .iter()
322261 .skip(1)
262+ .filter(|f| Some(f.path.as_str()) != own)
323263 .filter_map(|f| split_filename(&f.path).map(|(p, _)| p.to_string()))
324− .collect();
325− id.shortest_prefix(&|cand| taken.iter().any(|t| t == cand))
264+ .collect()
326265 }
327266
328− /// Rename a block's file after a title change (§6.4).
329− pub fn rename_block_file(&mut self, file: usize, new_title: &str) -> std::io::Result<()> {
330− let old_path = self.tree.files[file].path.clone();
331− let node = &self.tree.files[file];
332− let root_node = node.nodes[node.root_node].children[0];
333− let id = match &node.nodes[root_node].block {
334− Some(b) => b.id.clone().unwrap(),
335− None => return Ok(()),
336− };
337− let prefix = split_filename(&old_path)
338− .map(|(p, _)| p.to_string())
339− .unwrap_or_else(|| self.unique_prefix(&id));
340− let new_name = filename(&prefix, &slug(new_title));
341− if new_name == old_path {
342− return Ok(());
343− }
344− std::fs::rename(self.dir.join(&old_path), self.dir.join(&new_name))?;
345− self.reload()?;
346− Ok(())
347− }
348−
349− /// Move a file to the trash (§11.5) and remove it from the vault.
267+ /// Move a file to the trash (§11.5) and remove it from the vault. Only
268+ /// that file leaves the index: the others are not read again, so a
269+ /// change another program made to one of them (a sync) is not taken in
270+ /// as part of the operation, nor into its op-log entry (§10.10).
350271 pub fn trash_file(&mut self, file: usize) -> std::io::Result<()> {
351272 let path = self.tree.files[file].path.clone();
352273 let trash = trash_dir();
353274 std::fs::create_dir_all(&trash)?;
354− let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S").to_string();
355− let target = trash.join(format!("{}-{}", stamp, path.to_lowercase()));
356− std::fs::rename(self.dir.join(&path), &target)?;
357− self.reload()?;
275+ let target = trash_target(&trash, &path);
276+ move_file(&self.dir.join(&path), &target)?;
277+ self.tree.files.remove(file);
278+ self.tree.restitch();
358279 Ok(())
359280 }
360281
@@ -362,8 +283,7 @@ impl Vault {
362283 pub fn trash_text(&self, name: &str, text: &str) -> std::io::Result<PathBuf> {
363284 let trash = trash_dir();
364285 std::fs::create_dir_all(&trash)?;
365− let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S").to_string();
366− let target = trash.join(format!("{}-{}", stamp, name.to_lowercase()));
286+ let target = trash_target(&trash, name);
367287 atomic_write(&target, text)?;
368288 Ok(target)
369289 }
@@ -381,10 +301,26 @@ impl Vault {
381301 }
382302}
383303
304+/// A fresh trash path `<timestamp>-<name>`; a second entry in the same
305+/// second gets a numeric suffix instead of overwriting the first (§11.5).
306+fn trash_target(trash: &Path, name: &str) -> PathBuf {
307+ let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S").to_string();
308+ let name = name.to_lowercase();
309+ let first = trash.join(format!("{}-{}", stamp, name));
310+ if !first.exists() {
311+ return first;
312+ }
313+ let stem = name.strip_suffix(".md").unwrap_or(&name);
314+ (2..)
315+ .map(|i| trash.join(format!("{}-{}-{}.md", stamp, stem, i)))
316+ .find(|p| !p.exists())
317+ .unwrap()
318+}
319+
384320pub fn trash_dir() -> PathBuf {
385321 directories::ProjectDirs::from("", "", "fold")
386322 .map(|p| p.state_dir().unwrap_or(p.data_dir()).join("trash"))
387− .unwrap_or_else(|| PathBuf::from(".notes-trash"))
323+ .unwrap_or_else(|| PathBuf::from(".fold-trash"))
388324}
389325
390326/// Write to `.<name>.fold-tmp`, fsync, rename (§11.1).
@@ -402,6 +338,35 @@ pub fn atomic_write(path: &Path, text: &str) -> std::io::Result<()> {
402338 Ok(())
403339}
404340
341+/// Rename, falling back to copy-and-remove when the trash and the vault
342+/// are on different filesystems.
343+pub fn move_file(from: &Path, to: &Path) -> std::io::Result<()> {
344+ match std::fs::rename(from, to) {
345+ Ok(()) => Ok(()),
346+ Err(_) => {
347+ std::fs::copy(from, to)?;
348+ std::fs::remove_file(from)
349+ }
350+ }
351+}
352+
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+
361+/// Read a vault `.md` file as text; `None` for anything that is not a
362+/// readable UTF-8 regular file.
363+fn read_md_file(path: &Path) -> Option<String> {
364+ if !std::fs::metadata(path).map(|m| m.is_file()).unwrap_or(false) {
365+ return None;
366+ }
367+ std::fs::read_to_string(path).ok()
368+}
369+
405370fn read_if_exists(path: &Path) -> std::io::Result<Option<String>> {
406371 match std::fs::read_to_string(path) {
407372 Ok(t) => Ok(Some(t)),
@@ -410,13 +375,19 @@ fn read_if_exists(path: &Path) -> std::io::Result<Option<String>> {
410375 }
411376}
412377
413−/// A node reference that survives reloads: block id if the node is a block,
414−/// else its path of titles (§11.2).
378+/// A node reference that survives reloads (§3.4, §11.2): a block by its id;
379+/// any other node by its steps from the root of the file it lives in, each
380+/// step a title and the node's ordinal among same-titled siblings, so two
381+/// siblings with one title (or none) are never confused.
415382#[derive(Debug, Clone, PartialEq, Eq)]
416383pub enum NodeKey {
417384 Root,
418385 Id(Id),
419− Path(Vec<String>),
386+ Path {
387+ /// The block whose file holds the node; `None` for root.md.
388+ block: Option<Id>,
389+ steps: Vec<(String, usize)>,
390+ },
420391}
421392
422393impl Vault {
@@ -430,22 +401,60 @@ impl Vault {
430401 return NodeKey::Id(id.clone());
431402 }
432403 }
433− NodeKey::Path(self.tree.path(r))
404+ let f = &self.tree.files[r.0];
405+ let mut steps = Vec::new();
406+ let mut cur = r.1;
407+ let mut block = None;
408+ while let Some(p) = f.nodes[cur].parent {
409+ let title = &f.nodes[cur].title;
410+ let ordinal = f.nodes[p]
411+ .children
412+ .iter()
413+ .take_while(|&&c| c != cur)
414+ .filter(|&&c| f.nodes[c].title == *title)
415+ .count();
416+ steps.push((title.clone(), ordinal));
417+ if let Some(id) = f.nodes[p].block.as_ref().and_then(|b| b.id.clone()) {
418+ block = Some(id);
419+ break;
420+ }
421+ cur = p;
422+ }
423+ steps.reverse();
424+ NodeKey::Path { block, steps }
434425 }
435426
436− /// Re-attach a cursor after reload: by id, then path, then nearest
437− /// surviving ancestor (§11.2).
427+ /// Re-attach a node after a reload: by id, else by its steps, falling
428+ /// back to the deepest step that still exists (§11.2).
438429 pub fn find_by_key(&self, key: &NodeKey) -> Option<NRef> {
439430 match key {
440431 NodeKey::Root => Some(self.tree.root),
441432 NodeKey::Id(id) => self.tree.block_by_id(id),
442− NodeKey::Path(segs) => {
443− for n in (1..=segs.len()).rev() {
444− if let Some(r) = self.find_by_path(&segs[..n]) {
445− return Some(r);
433+ NodeKey::Path { block, steps } => {
434+ let mut cur = match block {
435+ Some(id) => self.tree.block_by_id(id)?,
436+ None => self.tree.root,
437+ };
438+ let mut found = None;
439+ for (title, ordinal) in steps {
440+ let f = &self.tree.files[cur.0];
441+ let next = f.nodes[cur.1]
442+ .children
443+ .iter()
444+ .filter(|&&c| f.nodes[c].title == *title)
445+ .nth(*ordinal);
446+ match next {
447+ Some(&c) => {
448+ cur = (cur.0, c);
449+ found = Some(cur);
450+ }
451+ None => break,
446452 }
447453 }
448− None
454+ found.or(match block {
455+ Some(id) => self.tree.block_by_id(id),
456+ None => None,
457+ })
449458 }
450459 }
451460 }
@@ -455,7 +464,7 @@ impl Vault {
455464 let mut cur = self.tree.root;
456465 'seg: for seg in segs {
457466 for c in self.tree.resolved_children(cur) {
458− if self.tree.node(c).title.eq_ignore_ascii_case(seg) {
467+ if title_eq(&self.tree.node(c).title, seg) {
459468 cur = c;
460469 continue 'seg;
461470 }
@@ -468,6 +477,27 @@ impl Vault {
468477 Some(cur)
469478 }
470479
480+ /// Every node at a case-insensitive title path from the root (§3.4):
481+ /// each step keeps every child whose title matches, so a path is
482+ /// ambiguous only when it names two nodes, whatever titles repeat on
483+ /// the way.
484+ fn find_all_by_path(&self, segs: &[String]) -> Vec<NRef> {
485+ let mut found = vec![self.tree.root];
486+ for seg in segs {
487+ let mut next = Vec::new();
488+ for &p in &found {
489+ for c in self.tree.resolved_children(p) {
490+ // a block embedded twice is still one node
491+ if title_eq(&self.tree.node(c).title, seg) && !next.contains(&c) {
492+ next.push(c);
493+ }
494+ }
495+ }
496+ found = next;
497+ }
498+ found
499+ }
500+
471501 /// Resolve a command target: id (or unique leading run), then path, then
472502 /// unique title (§3.4).
473503 pub fn resolve_target(&self, text: &str) -> Result<NRef, String> {
@@ -484,28 +514,29 @@ impl Vault {
484514 .collect();
485515 match matches.len() {
486516 1 => return Ok(matches[0]),
487− 0 => return Err(format!("no block with id prefix {:?}", text)),
517+ // a word that merely looks like an id may still be a title
518+ 0 => {}
488519 _ => return Err(format!("id prefix {:?} is ambiguous", text)),
489520 }
490521 }
491522 // 2. path
492523 if text.contains('/') {
493− let rel = text.strip_prefix("./");
524+ if text.starts_with("./") {
525+ return Err("./ relative resolution needs a zoom root".into());
526+ }
494527 let segs: Vec<String> = text.split('/').map(|s| s.trim().to_string()).collect();
495− let base = match rel {
496− Some(_) => return Err("./ relative resolution needs a zoom root".into()),
497− None => self.tree.root,
528+ // a path that matches twice is an ambiguity, never a guess
529+ let found = self.find_all_by_path(&segs);
530+ return match found.len() {
531+ 1 => Ok(found[0]),
532+ 0 => Err(format!("no node at path {:?}", text)),
533+ _ => Err(format!("path {:?} is ambiguous", text)),
498534 };
499− let _ = base;
500− if let Some(r) = self.find_by_path(&segs) {
501− return Ok(r);
502− }
503− return Err(format!("no node at path {:?}", text));
504535 }
505536 // 3. unique title
506537 let mut found: Vec<NRef> = Vec::new();
507538 self.tree.walk(self.tree.root, &mut |t, r| {
508− if t.node(r).title.eq_ignore_ascii_case(text) {
539+ if title_eq(&t.node(r).title, text) {
509540 found.push(r);
510541 }
511542 });
@@ -516,18 +547,10 @@ impl Vault {
516547 }
517548 }
518549
519− /// Collect every node in the resolved tree (for the outline and filter).
520− pub fn all_nodes(&self) -> Vec<NRef> {
521− let mut out = Vec::new();
522− self.tree.walk(self.tree.root, &mut |_, r| out.push(r));
523− out
524− }
525550}
526551
527−/// Map from id string to NRef, for embed resolution in the TUI.
528−pub fn id_map(tree: &Tree) -> HashMap<String, NRef> {
529− tree.blocks
530− .iter()
531− .map(|(r, id)| (id.as_str().to_string(), *r))
532− .collect()
552+/// Case-insensitive title comparison, Unicode-aware (§3.4).
553+pub fn title_eq(a: &str, b: &str) -> bool {
554+ a == b || a.to_lowercase() == b.to_lowercase()
533555}
556+
crates/fold-core/tests/edit.rs+1110−8
@@ -1,4 +1,4 @@
1−use fold_core::edit::{open_editor, title_of_first_line, Owner};
1+use fold_core::edit::{open_editor, Owner};
22use fold_core::ops;
33use fold_core::vault::Vault;
44
@@ -88,7 +88,7 @@ fn edit_nested_block_writes_its_own_file() {
8888 .iter()
8989 .position(|l| l.text.contains("task"))
9090 .unwrap();
91− let owner = buf.lines[idx].owner;
91+ let _owner = buf.lines[idx].owner;
9292 buf.set_line(idx, "- renamed task".into());
9393 buf.save_all(&mut v).unwrap();
9494 // root.md untouched
@@ -104,8 +104,9 @@ fn edit_nested_block_writes_its_own_file() {
104104#[test]
105105fn deleting_nested_block_title_trashes_block() {
106106 // §5.2: deleting a nested block's title line deletes the block; its
107− // other lines would need re-tagging (the TUI does that; here we just
108− // check the file write path).
107+ // other lines are re-tagged to the enclosing block on save (see
108+ // deleting_nested_block_title_keeps_its_text_reachable). Here: the
109+ // block's lines carry its own owner.
109110 let (_d, mut v) = vault_with("# A\n\n- task\n");
110111 let a = v.tree.resolved_children(v.tree.root)[0];
111112 let task = v.tree.resolved_children(a)[0];
@@ -121,9 +122,1110 @@ fn deleting_nested_block_title_trashes_block() {
121122 assert_eq!(block_owner, Owner { file: 1, block_ord: 1 });
122123}
123124
125+// ---------------------------------------------------------------- regressions
126+
127+/// Open the editor on `r`, mark every owner dirty and save: nothing on disk
128+/// may change (§15.6).
129+fn assert_noop_splice(v: &mut Vault, r: fold_core::tree::NRef) {
130+ let before: Vec<String> = v.tree.files.iter().map(|f| f.text.clone()).collect();
131+ let mut buf = open_editor(v, r);
132+ for o in buf.owners.keys().copied().collect::<Vec<_>>() {
133+ buf.mark_dirty(o);
134+ }
135+ buf.save_all(v).unwrap();
136+ let after: Vec<String> = v.tree.files.iter().map(|f| f.text.clone()).collect();
137+ assert_eq!(after, before);
138+}
139+
140+#[test]
141+fn noop_splice_of_zoomed_nodes() {
142+ for (text, path) in [
143+ ("# A\n\n## B\n\nbody\n", vec!["A", "B"]),
144+ ("- A\n - B\n - C\n", vec!["A", "B"]),
145+ ("- A\n - B\n", vec!["A"]),
146+ ("# A\n\nbody\n\n# B\n", vec!["A"]),
147+ ("# Inbox\n\n## day\n\n- Talked\n ### Options\n Warehouse.\n- [x] Send\n", vec!["Inbox", "day", "Talked"]),
148+ ] {
149+ let (_d, mut v) = vault_with(text);
150+ let segs: Vec<String> = path.iter().map(|s| s.to_string()).collect();
151+ let r = v.find_by_path(&segs).unwrap();
152+ assert_noop_splice(&mut v, r);
153+ let root = v.tree.root;
154+ assert_noop_splice(&mut v, root);
155+ }
156+}
157+
158+#[test]
159+fn noop_splice_with_root_frontmatter() {
160+ let (_d, mut v) = vault_with("---\nfoo: bar\n---\n\n# A\n\nbody\n");
161+ let root = v.tree.root;
162+ assert_noop_splice(&mut v, root);
163+}
164+
165+#[test]
166+fn noop_splice_across_nested_blocks() {
167+ let (_d, mut v) = vault_with("# A\n\n## Z\n\nbody\n\n### sub\n\n- t\n");
168+ let z = v.find_by_path(&["A".into(), "Z".into()]).unwrap();
169+ ops::make_block(&mut v, z).unwrap();
170+ let t = v.find_by_path(&["A".into(), "Z".into(), "sub".into(), "t".into()]).unwrap();
171+ ops::make_block(&mut v, t).unwrap();
172+ let block = v.tree.files.iter().find(|f| f.path.contains("~z")).unwrap();
173+ // made from a level-2 section: re-levelled to the file's level 1 (§4.9)
174+ assert!(block.text.contains("\n# Z\n\nbody\n\n## sub\n\n![["), "{}", block.text);
175+ let a = v.tree.resolved_children(v.tree.root)[0];
176+ assert_noop_splice(&mut v, a);
177+ let root = v.tree.root;
178+ assert_noop_splice(&mut v, root);
179+ let z = v.find_by_path(&["A".into(), "Z".into()]).unwrap();
180+ assert_noop_splice(&mut v, z);
181+}
182+
183+#[test]
184+fn splicing_a_parent_keeps_nested_embeds() {
185+ let (_d, mut v) = vault_with("# NAS\n\n- task\n");
186+ let task = v.find_by_path(&["NAS".into(), "task".into()]).unwrap();
187+ ops::make_block(&mut v, task).unwrap();
188+ let nas = v.tree.resolved_children(v.tree.root)[0];
189+ let mut buf = open_editor(&v, nas);
190+ let i = buf.lines.iter().position(|l| l.text == "# NAS").unwrap();
191+ buf.set_line(i, "# NAS box".into());
192+ buf.save_all(&mut v).unwrap();
193+ let root = &v.tree.files[0].text;
194+ assert!(root.starts_with("# NAS box\n\n![["), "{}", root);
195+ assert_eq!(v.tree.resolved_children(v.tree.resolved_children(v.tree.root)[0]).len(), 1);
196+}
197+
198+#[test]
199+fn nested_block_embed_under_item_keeps_indent() {
200+ let (_d, mut v) = vault_with("- A\n - B\n");
201+ let b = v.find_by_path(&["A".into(), "B".into()]).unwrap();
202+ ops::make_block(&mut v, b).unwrap();
203+ assert!(v.tree.files[0].text.starts_with("- A\n ![["), "{}", v.tree.files[0].text);
204+ let a = v.tree.resolved_children(v.tree.root)[0];
205+ let buf = open_editor(&v, a);
206+ let texts: Vec<&str> = buf.lines.iter().map(|l| l.text.as_str()).collect();
207+ assert_eq!(texts, vec!["- A", " - B"]);
208+ assert_noop_splice(&mut v, a);
209+}
210+
211+#[test]
212+fn splice_refuses_to_overwrite_external_change() {
213+ let (d, mut v) = vault_with("# A\n\nbody\n");
214+ let a = v.tree.resolved_children(v.tree.root)[0];
215+ let mut buf = open_editor(&v, a);
216+ std::fs::write(d.path().join("root.md"), "# A\n\nsomeone else\n").unwrap();
217+ let i = buf.lines.iter().position(|l| l.text == "body").unwrap();
218+ buf.set_line(i, "mine".into());
219+ assert!(buf.save_all(&mut v).is_err());
220+ let disk = std::fs::read_to_string(d.path().join("root.md")).unwrap();
221+ assert_eq!(disk, "# A\n\nsomeone else\n");
222+}
223+
224+#[test]
225+fn successive_splices_of_one_file_are_allowed() {
226+ let (_d, mut v) = vault_with("# A\n\nbody\n");
227+ let a = v.tree.resolved_children(v.tree.root)[0];
228+ let mut buf = open_editor(&v, a);
229+ let i = buf.lines.iter().position(|l| l.text == "body").unwrap();
230+ buf.set_line(i, "one".into());
231+ buf.save_all(&mut v).unwrap();
232+ buf.set_line(i, "two".into());
233+ buf.save_all(&mut v).unwrap();
234+ assert_eq!(v.tree.files[0].text, "# A\n\ntwo\n");
235+}
236+
237+#[test]
238+fn noop_splice_of_spec_example_vault() {
239+ // §4.10, all three files: editing from the root and saving every block
240+ // changes nothing.
241+ let dir = tempfile::tempdir().unwrap();
242+ std::fs::write(dir.path().join("root.md"), "# Homelab\n\nTwo boxes in the closet, one at Hetzner.\n\n## NAS\n\n![[dozzod-binwes-talsun-worbec]]\n\n## Networking\n\n- [ ] Replace the flaky switch\n![[racfer-hattes-mislup-nodrys]]\n\n# Inbox\n\n## 2026-09-10\n\n- Talked to Anya about the venue.\n ### Options\n Warehouse on Ligovsky, or the old bakery. Both need a licence.\n- [x] Send the deposit\n").unwrap();
243+ std::fs::write(dir.path().join("dozzod~zfs-layout.md"), "---\nid: dozzod-binwes-talsun-worbec\nsince: 2024-03\ntags: [storage, homelab] # user-defined\n---\n\n# ZFS layout\n\nMirrored pairs, no raidz. Snapshots hourly via sanoid.\n\n## [ ] Snapshot policy\n\n- hourly, keep 24\n- [x] Move scratch to its own dataset\n").unwrap();
244+ std::fs::write(dir.path().join("racfer~order-new-switch.md"), "---\nid: racfer-hattes-mislup-nodrys\ndue: 2026-09-20\n---\n\n- [ ] Order new switch\n Two options, noted under Networking.\n").unwrap();
245+ let mut v = Vault::open(dir.path()).unwrap();
246+ let root = v.tree.root;
247+ let buf = open_editor(&v, root);
248+ let texts: Vec<&str> = buf.lines.iter().map(|l| l.text.as_str()).collect();
249+ assert!(texts.contains(&"### ZFS layout"), "{:#?}", texts);
250+ assert!(texts.contains(&"#### [ ] Snapshot policy"), "{:#?}", texts);
251+ assert!(texts.contains(&"- [ ] Order new switch"), "{:#?}", texts);
252+ assert!(!texts.iter().any(|t| t.contains("![[")), "{:#?}", texts);
253+ assert_noop_splice(&mut v, root);
254+ let homelab = v.tree.resolved_children(v.tree.root)[0];
255+ assert_noop_splice(&mut v, homelab);
256+}
257+
258+/// A vault whose root.md embeds one block file with the given body.
259+fn vault_with_block(body: &str) -> (tempfile::TempDir, Vault, std::path::PathBuf) {
260+ let dir = tempfile::tempdir().unwrap();
261+ let id = "racfer-hattes-mislup-nodrys";
262+ std::fs::write(dir.path().join("root.md"), format!("# ![[{id}]]\n")).unwrap();
263+ let bf = dir.path().join("racfer~inbox.md");
264+ std::fs::write(&bf, format!("---\nid: {id}\n---\n\n{body}")).unwrap();
265+ let v = Vault::open(dir.path()).unwrap();
266+ (dir, v, bf)
267+}
268+
269+#[test]
270+fn setext_heading_in_block_file_survives_noop_splice() {
271+ // §4.9: a column-0 node after a block file's root — here a setext
272+ // heading a phone editor appended — is adopted by the root, so it shows
273+ // under the block and a no-op splice keeps it on disk (§15.6), written
274+ // in place under the root.
275+ let (_d, mut v, bf) = vault_with_block("# Inbox\n\nNotes\n=====\n\nprecious\n");
276+ let inbox = v.tree.resolved_children(v.tree.root)[0];
277+ assert_eq!(v.tree.node(inbox).title, "Inbox");
278+ let kids = v.tree.resolved_children(inbox);
279+ assert_eq!(kids.len(), 1);
280+ assert_eq!(v.tree.node(kids[0]).title, "Notes");
281+ let mut buf = open_editor(&v, inbox);
282+ let owner = buf.lines[0].owner;
283+ buf.mark_dirty(owner);
284+ buf.splice(&mut v, owner).unwrap();
285+ let disk = std::fs::read_to_string(&bf).unwrap();
286+ assert_eq!(
287+ disk,
288+ "---\nid: racfer-hattes-mislup-nodrys\n---\n\n# Inbox\n\n## Notes\n\nprecious\n"
289+ );
290+}
291+
292+#[test]
293+fn setext_heading_after_a_bullet_block_root_is_adopted_once() {
294+ // the title line was adopted into the bullet root's text before its
295+ // underline showed up; it must become the node, not stay behind as text
296+ let (_d, mut v, bf) = vault_with_block("- Order switch\nTitle\n=====\nmore\n");
297+ let order = v.tree.resolved_children(v.tree.root)[0];
298+ let kids = v.tree.resolved_children(order);
299+ assert_eq!(kids.len(), 1);
300+ assert_eq!(v.tree.node(kids[0]).title, "Title");
301+ let diags: Vec<String> = fold_core::check::check(&v)
302+ .into_iter()
303+ .map(|d| d.message)
304+ .collect();
305+ assert!(!diags.iter().any(|m| m.contains("column-0 text")), "{diags:?}");
306+ assert!(diags.iter().any(|m| m.contains("column-0 node")), "{diags:?}");
307+ let mut buf = open_editor(&v, order);
308+ let owner = buf.lines[0].owner;
309+ buf.mark_dirty(owner);
310+ buf.splice(&mut v, owner).unwrap();
311+ let disk = std::fs::read_to_string(&bf).unwrap();
312+ assert_eq!(
313+ disk,
314+ "---\nid: racfer-hattes-mislup-nodrys\n---\n\n- Order switch\n # Title\n more\n"
315+ );
316+}
317+
318+#[test]
319+fn setext_heading_can_be_a_block_files_root() {
320+ // its title line is the root's, not text before the root (§4.9)
321+ let (_d, v, _bf) = vault_with_block("Inbox\n=====\n\nbody\n");
322+ let inbox = v.tree.resolved_children(v.tree.root)[0];
323+ assert_eq!(v.tree.node(inbox).title, "Inbox");
324+ let diags: Vec<String> = fold_core::check::check(&v)
325+ .into_iter()
326+ .map(|d| d.message)
327+ .collect();
328+ assert!(!diags.iter().any(|m| m.contains("before the block")), "{diags:?}");
329+}
330+
331+/// Two embeds of one block (§6.2: a diagnostic; the second renders as
332+/// broken). Returns the vault dir, the vault and the block file's path.
333+fn vault_with_duplicate_embed() -> (tempfile::TempDir, Vault, std::path::PathBuf) {
334+ let dir = tempfile::tempdir().unwrap();
335+ std::fs::write(
336+ dir.path().join("root.md"),
337+ "# Top\n\n## A\n\n![[racfer-hattes-mislup-nodrys]]\n\n## B\n\n![[racfer-hattes-mislup-nodrys]]\n",
338+ )
339+ .unwrap();
340+ let bf = dir.path().join("racfer~task.md");
341+ std::fs::write(&bf, "---\nid: racfer-hattes-mislup-nodrys\n---\n\n- task\n").unwrap();
342+ let v = Vault::open(dir.path()).unwrap();
343+ (dir, v, bf)
344+}
345+
346+#[test]
347+fn duplicate_embed_does_not_duplicate_block_text() {
348+ let (_d, mut v, bf) = vault_with_duplicate_embed();
349+ let top = v.tree.resolved_children(v.tree.root)[0];
350+ // the block is inlined at its first embed only; the second stays an
351+ // embed line, as a broken one would
352+ let r = fold_core::render::render(&v.tree, top, 1, true);
353+ assert_eq!(r.matches("- task").count(), 1, "{r}");
354+ assert_eq!(r.matches("![[racfer-hattes-mislup-nodrys]]").count(), 1, "{r}");
355+ let mut buf = open_editor(&v, top);
356+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
357+ buf.set_line(i, "- task!".into());
358+ buf.save_all(&mut v).unwrap();
359+ assert_eq!(
360+ std::fs::read_to_string(&bf).unwrap(),
361+ "---\nid: racfer-hattes-mislup-nodrys\n---\n\n- task!\n"
362+ );
363+}
364+
365+#[test]
366+fn duplicate_embed_survives_saving_the_parent() {
367+ let (d, mut v, _bf) = vault_with_duplicate_embed();
368+ let top = v.tree.resolved_children(v.tree.root)[0];
369+ let mut buf = open_editor(&v, top);
370+ let i = buf.lines.iter().position(|l| l.text == "# Top").unwrap();
371+ buf.set_line(i, "# Top!".into());
372+ buf.save_all(&mut v).unwrap();
373+ assert_eq!(
374+ std::fs::read_to_string(d.path().join("root.md")).unwrap(),
375+ "# Top!\n\n## A\n\n![[racfer-hattes-mislup-nodrys]]\n\n## B\n\n![[racfer-hattes-mislup-nodrys]]\n"
376+ );
377+ // zoomed into B, the second embed is still the broken one
378+ let top = v.tree.resolved_children(v.tree.root)[0];
379+ let b = v.tree.resolved_children(top)[1];
380+ assert_eq!(
381+ fold_core::render::render(&v.tree, b, 1, true),
382+ "# B\n\n![[racfer-hattes-mislup-nodrys]]\n"
383+ );
384+ assert_noop_splice(&mut v, top);
385+}
386+
124387#[test]
125−fn title_detection() {
126− assert_eq!(title_of_first_line("# Hello"), Some("Hello".into()));
127− assert_eq!(title_of_first_line("- [ ] Task"), Some("[ ] Task".into()));
128− assert_eq!(title_of_first_line("plain"), None);
388+fn line_opened_above_zoomed_title_is_not_duplicated() {
389+ // Editing a plain (non-block) section: a line opened above its title
390+ // (vim `O` on line 0, or Enter at column 0 of line 0) is saved, then a
391+ // later edit is saved from the same buffer. The second splice must
392+ // rewrite the region the first one wrote, not whatever node now starts
393+ // at the section's original byte offset.
394+ let (_d, mut v) = vault_with("# A\n\nbody\n");
395+ let a = v.tree.resolved_children(v.tree.root)[0];
396+ let mut buf = open_editor(&v, a);
397+ // what `O` / Enter at column 0 of line 0 does: split line 0
398+ buf.set_line(0, "# Intro".into());
399+ buf.insert_line(0, "# A".into());
400+ buf.save_all(&mut v).unwrap();
401+ assert_eq!(v.tree.files[0].text, "# Intro\n# A\n\nbody\n");
402+ let i = buf.lines.iter().position(|l| l.text == "body").unwrap();
403+ buf.set_line(i, "body!".into());
404+ buf.save_all(&mut v).unwrap();
405+ assert_eq!(v.tree.files[0].text, "# Intro\n# A\n\nbody!\n");
406+ // a plain first line: no node starts where the section did any more
407+ let (_d, mut v) = vault_with("# A\n\nbody\n\n# B\n\nb\n");
408+ let a = v.tree.resolved_children(v.tree.root)[0];
409+ let mut buf = open_editor(&v, a);
410+ buf.set_line(0, "intro".into());
411+ buf.insert_line(0, "# A".into());
412+ buf.save_all(&mut v).unwrap();
413+ let i = buf.lines.iter().position(|l| l.text == "body").unwrap();
414+ buf.set_line(i, "body!".into());
415+ buf.save_all(&mut v).unwrap();
416+ assert_eq!(v.tree.files[0].text, "intro\n# A\n\nbody!\n\n# B\n\nb\n");
417+}
418+
419+#[test]
420+fn splice_keeps_text_before_block_root() {
421+ // §4.9: text before a block file's root is a diagnostic and the file is
422+ // read-only until fixed; §11.5: never delete user content without a
423+ // trash copy. Saving the block from the editor must not drop it.
424+ let dir = tempfile::tempdir().unwrap();
425+ std::fs::write(
426+ dir.path().join("root.md"),
427+ "# A\n\n![[racfer-hattes-mislup-nodrys]]\n",
428+ )
429+ .unwrap();
430+ let bf = dir.path().join("racfer~task.md");
431+ let malformed = "---\nid: racfer-hattes-mislup-nodrys\n---\n\nphone note\n- task\n";
432+ std::fs::write(&bf, malformed).unwrap();
433+ let mut v = Vault::open(dir.path()).unwrap();
434+ let a = v.tree.resolved_children(v.tree.root)[0];
435+ let mut buf = open_editor(&v, a);
436+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
437+ buf.set_line(i, "- task!".into());
438+ // refused (read-only), and the block stays unsaved
439+ assert!(buf.save_all(&mut v).is_err());
440+ assert_eq!(std::fs::read_to_string(&bf).unwrap(), malformed);
441+ assert!(buf.dirty.iter().any(|o| o.file == 1));
442+}
443+
444+#[test]
445+fn splice_never_writes_a_block_file_without_its_root() {
446+ // §5.2 step 2: a block's text parses to exactly one root-level node.
447+ // Editing a block itself and deleting its title line leaves no root;
448+ // splice refuses rather than write a file with only frontmatter and
449+ // text, which would drop the block from the tree.
450+ let (d, mut v) = vault_with("# A\n\n- task\n note\n");
451+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
452+ ops::make_block(&mut v, t).unwrap();
453+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
454+ let path = d.path().join(&v.tree.files[1].path);
455+ let before = std::fs::read_to_string(&path).unwrap();
456+ let mut buf = open_editor(&v, t);
457+ let texts: Vec<&str> = buf.lines.iter().map(|l| l.text.as_str()).collect();
458+ assert_eq!(texts, ["- task", " note"]);
459+ buf.delete_line(0);
460+ assert!(buf.save_all(&mut v).is_err());
461+ assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
462+ // text typed above the title line would be text before the root
463+ let mut buf = open_editor(&v, t);
464+ buf.set_line(0, "intro".into());
465+ buf.insert_line(0, "- task".into());
466+ assert!(buf.save_all(&mut v).is_err());
467+ assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
468+}
469+
470+#[test]
471+fn deleting_nested_block_title_keeps_its_text_reachable() {
472+ // §5.2: deleting a nested block's title line deletes the block; the
473+ // lines it still owned become plain text of the enclosing block. The
474+ // block file must not be rewritten without a root (which drops the
475+ // block from the tree, breaks the parent's embed and hides "note").
476+ let (d, mut v) = vault_with("# A\n\n- task\n note\n");
477+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
478+ ops::make_block(&mut v, t).unwrap();
479+ let block_path = v.tree.files[1].path.clone();
480+ let a = v.tree.resolved_children(v.tree.root)[0];
481+ let mut buf = open_editor(&v, a);
482+ let texts: Vec<&str> = buf.lines.iter().map(|l| l.text.as_str()).collect();
483+ assert_eq!(texts, ["# A", "", "- task", " note"]);
484+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
485+ buf.delete_line(i);
486+ buf.save_all(&mut v).unwrap();
487+ let a = v.tree.resolved_children(v.tree.root)[0];
488+ let shown = fold_core::render(&v.tree, a, 1, true);
489+ let files: Vec<(&str, &str)> = v
490+ .tree
491+ .files
492+ .iter()
493+ .map(|f| (f.path.as_str(), f.text.as_str()))
494+ .collect();
495+ assert!(
496+ shown.contains("note") && !shown.contains("![["),
497+ "shown:\n{}\nfiles: {:?}",
498+ shown,
499+ files
500+ );
501+ assert_eq!(v.tree.files[0].text, "# A\n\n note\n");
502+ // the block's file went to the trash (§11.5)
503+ assert!(!d.path().join(&block_path).exists());
504+ assert_eq!(v.tree.files.len(), 1);
505+ assert!(buf.dirty.is_empty(), "{:?}", buf.dirty);
506+}
507+
508+#[test]
509+fn deleted_block_title_hands_nested_blocks_to_the_enclosing_block() {
510+ // the block nested in the deleted one survives, embedded where its
511+ // title line sits, now in the enclosing block's text
512+ let (_d, mut v) = vault_with("# A\n\n## B\n\nb text\n\n### C\n\nc\n");
513+ let c = v.find_by_path(&["A".into(), "B".into(), "C".into()]).unwrap();
514+ let c_id = ops::make_block(&mut v, c).unwrap();
515+ let b = v.find_by_path(&["A".into(), "B".into()]).unwrap();
516+ ops::make_block(&mut v, b).unwrap();
517+ let a = v.tree.resolved_children(v.tree.root)[0];
518+ let mut buf = open_editor(&v, a);
519+ let texts: Vec<&str> = buf.lines.iter().map(|l| l.text.as_str()).collect();
520+ assert_eq!(texts, ["# A", "", "## B", "", "b text", "", "### C", "", "c"]);
521+ buf.delete_line(2);
522+ buf.delete_line(2);
523+ buf.save_all(&mut v).unwrap();
524+ // B's other lines are A's; the deeper heading C was never B's title
525+ assert_eq!(
526+ v.tree.files[0].text,
527+ format!("# A\n\nb text\n\n### ![[{}]]\n", c_id.as_str())
528+ );
529+ assert_eq!(v.tree.files.len(), 2);
530+ let a = v.tree.resolved_children(v.tree.root)[0];
531+ let shown = fold_core::render(&v.tree, a, 1, true);
532+ assert!(
533+ shown.starts_with("# A\n\nb text\n\n#") && shown.ends_with(" C\n\nc\n") && !shown.contains("![["),
534+ "{}",
535+ shown
536+ );
537+}
538+
539+#[test]
540+fn line_typed_above_nested_block_title_is_the_enclosing_blocks() {
541+ // Enter at column 0 of a nested block's title line opens a line above
542+ // it with the block's tag; what is typed there sits above the block, in
543+ // its parent (§5.2: lines no longer with the title line are re-tagged
544+ // to the block they sit in), not before the block file's root (§4.9).
545+ let (_d, mut v) = vault_with("# A\n\n- task\n");
546+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
547+ let id = ops::make_block(&mut v, t).unwrap();
548+ let block_before = v.tree.files[1].text.clone();
549+ let a = v.tree.resolved_children(v.tree.root)[0];
550+ let mut buf = open_editor(&v, a);
551+ buf.set_line(2, String::new());
552+ buf.insert_line(2, "- task".into());
553+ buf.set_line(2, "Intro".into());
554+ assert_eq!(buf.lines[2].owner.file, 1);
555+ buf.save_all(&mut v).unwrap();
556+ assert_eq!(v.tree.files[0].text, format!("# A\n\nIntro\n![[{}]]\n", id.as_str()));
557+ assert_eq!(v.tree.files[1].text, block_before);
558+ assert_eq!(buf.lines[2].owner.file, 0);
559+ assert!(buf.dirty.is_empty(), "{:?}", buf.dirty);
560+}
561+
562+#[test]
563+fn emptied_nested_block_title_is_not_a_deleted_one() {
564+ // vim `cc` on a nested block's title line, then a pause (autosave): the
565+ // title is being retyped, so the block is not deleted; it is not written
566+ // without its root either, and saves once it has a title again.
567+ let (_d, mut v) = vault_with("# A\n\n- task\n note\n");
568+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
569+ let id = ops::make_block(&mut v, t).unwrap();
570+ let root_before = v.tree.files[0].text.clone();
571+ let block_before = v.tree.files[1].text.clone();
572+ let a = v.tree.resolved_children(v.tree.root)[0];
573+ let mut buf = open_editor(&v, a);
574+ buf.set_line(2, String::new());
575+ assert!(buf.save_all(&mut v).is_err());
576+ assert_eq!(v.tree.files[0].text, root_before);
577+ assert_eq!(v.tree.files[1].text, block_before);
578+ buf.set_line(2, "- renamed".into());
579+ buf.save_all(&mut v).unwrap();
580+ assert_eq!(v.tree.files[0].text, root_before);
581+ assert_eq!(
582+ v.tree.files[1].text,
583+ format!("---\nid: {}\n---\n\n- renamed\n note\n", id.as_str())
584+ );
585+}
586+
587+#[test]
588+fn deleting_all_lines_of_nested_block_clears_dirty() {
589+ // §5.2: deleting a nested block's title line deletes the block (its
590+ // embed leaves the parent, its file goes to trash). The save must not
591+ // leave its owner dirty forever with nothing written.
592+ let (_d, mut v) = vault_with("# A\n\n- task\n");
593+ let a = v.tree.resolved_children(v.tree.root)[0];
594+ let task = v.tree.resolved_children(a)[0];
595+ ops::make_block(&mut v, task).unwrap();
596+ assert!(v.tree.files[0].text.contains("![["), "{}", v.tree.files[0].text);
597+ let a = v.tree.resolved_children(v.tree.root)[0];
598+ let mut buf = open_editor(&v, a);
599+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
600+ buf.delete_line(i);
601+ assert_eq!(buf.save_all(&mut v).unwrap(), 1);
602+ assert!(buf.dirty.is_empty(), "still dirty after save: {:?}", buf.dirty);
603+ assert!(!v.tree.files[0].text.contains("![["), "{}", v.tree.files[0].text);
604+ assert_eq!(v.tree.files.len(), 1);
605+ // a block with nothing at all left to write (every line of the edited
606+ // node gone) writes nothing, is not counted as saved, and is not left
607+ // dirty to be saved again on every pause
608+ let (_d, mut v) = vault_with("# A\n\nbody\n");
609+ let a = v.tree.resolved_children(v.tree.root)[0];
610+ let mut buf = open_editor(&v, a);
611+ while !buf.lines.is_empty() {
612+ buf.delete_line(0);
613+ }
614+ assert_eq!(buf.save_all(&mut v).unwrap(), 0);
615+ assert!(buf.dirty.is_empty(), "still dirty after save: {:?}", buf.dirty);
616+ assert_eq!(v.tree.files[0].text, "# A\n\nbody\n");
617+}
618+
619+#[test]
620+fn one_refused_block_does_not_block_other_saves() {
621+ // §5.2: each dirty block is written by its own splice. A block whose file
622+ // changed on disk is refused and stays dirty; the other dirty blocks are
623+ // still written.
624+ let (d, mut v) = vault_with("# A\n\n- task\n");
625+ let a = v.tree.resolved_children(v.tree.root)[0];
626+ let task = v.tree.resolved_children(a)[0];
627+ ops::make_block(&mut v, task).unwrap();
628+ let a = v.tree.resolved_children(v.tree.root)[0];
629+ let mut buf = open_editor(&v, a);
630+ // edit the nested block first, then root.md's heading: dirty = [block, root]
631+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
632+ assert_eq!(buf.lines[i].owner.file, 1);
633+ buf.set_line(i, "- task (mine)".into());
634+ assert_eq!(buf.lines[0].owner.file, 0);
635+ buf.set_line(0, "# A renamed".into());
636+ // another editor changes the block file meanwhile
637+ let p = d.path().join(&v.tree.files[1].path);
638+ let s = std::fs::read_to_string(&p).unwrap().replace("- task", "- task (theirs)");
639+ std::fs::write(&p, &s).unwrap();
640+ let err = buf.save_all(&mut v).unwrap_err();
641+ assert!(err.to_string().contains("changed on disk"), "{}", err);
642+ // the refused block is not overwritten and stays dirty
643+ assert_eq!(std::fs::read_to_string(&p).unwrap(), s);
644+ assert!(buf.dirty.iter().any(|o| o.file == 1));
645+ // root.md's edit is written anyway
646+ let disk = std::fs::read_to_string(d.path().join("root.md")).unwrap();
647+ assert!(disk.starts_with("# A renamed"), "{}", disk);
648+ assert!(v.tree.files[0].text.starts_with("# A renamed"), "{}", v.tree.files[0].text);
649+ assert!(!buf.dirty.iter().any(|o| o.file == 0));
650+}
651+
652+#[test]
653+fn a_block_file_renamed_on_disk_is_checked_against_what_was_read_under_its_old_name() {
654+ // `fold check --fix` renames a block file whose title changed, its
655+ // text as it was: §5.2 step 5 still holds under the new name. The
656+ // phone's line added there after the vault read it is refused, not
657+ // taken as a file never read and written over
658+ let (d, mut v) = vault_with("# A\n\n- task\n");
659+ let a = v.tree.resolved_children(v.tree.root)[0];
660+ let task = v.tree.resolved_children(a)[0];
661+ ops::make_block(&mut v, task).unwrap();
662+ let a = v.tree.resolved_children(v.tree.root)[0];
663+ let mut buf = open_editor(&v, a);
664+ let old = d.path().join(&v.tree.files[1].path);
665+ let renamed = d.path().join(v.tree.files[1].path.replace("~task.md", "~task-renamed.md"));
666+ std::fs::rename(&old, &renamed).unwrap();
667+ v.reload().unwrap();
668+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
669+ buf.set_line(i, "- task XYZ".into());
670+ let theirs = std::fs::read_to_string(&renamed).unwrap() + " - phone child\n";
671+ std::fs::write(&renamed, &theirs).unwrap();
672+ let err = buf.save_all(&mut v).unwrap_err();
673+ assert!(err.to_string().contains("changed on disk"), "{}", err);
674+ // and once the vault has read it, as after any refused save
675+ v.reload().unwrap();
676+ let err = buf.save_all(&mut v).unwrap_err();
677+ assert!(err.to_string().contains("changed on disk"), "{}", err);
678+ assert_eq!(std::fs::read_to_string(&renamed).unwrap(), theirs);
679+ assert!(!buf.dirty.is_empty());
680+ // a rename alone changes nothing the buffer read: the typing is saved
681+ // under the new name, and again after a second rename
682+ let (d, mut v) = vault_with("# A\n\n- task\n");
683+ let a = v.tree.resolved_children(v.tree.root)[0];
684+ let task = v.tree.resolved_children(a)[0];
685+ ops::make_block(&mut v, task).unwrap();
686+ let a = v.tree.resolved_children(v.tree.root)[0];
687+ let mut buf = open_editor(&v, a);
688+ let mut path = d.path().join(&v.tree.files[1].path);
689+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
690+ for (n, name) in ["~task-renamed.md", "~task-again.md"].into_iter().enumerate() {
691+ let to = path.with_file_name(path.file_name().unwrap().to_string_lossy().split('~').next().unwrap().to_string() + name);
692+ std::fs::rename(&path, &to).unwrap();
693+ path = to;
694+ v.reload().unwrap();
695+ buf.set_line(i, format!("- task {}", n));
696+ assert_eq!(buf.save_all(&mut v).unwrap(), 1, "rename {}", n);
697+ assert!(std::fs::read_to_string(&path).unwrap().ends_with(&format!("\n- task {}\n", n)), "rename {}", n);
698+ }
699+}
700+
701+#[test]
702+fn a_save_goes_through_when_the_file_changed_only_outside_the_block() {
703+ // §5.2 step 5 compares the block's source span, not the whole file:
704+ // a sync that changed another section of root.md (and moved this one
705+ // down) while the user typed does not refuse the save, and both
706+ // changes are kept
707+ let (d, mut v) = vault_with("# A\n\nbody\n\n# B\n\nother\n");
708+ let a = v.tree.resolved_children(v.tree.root)[0];
709+ let mut buf = open_editor(&v, a);
710+ let synced = "intro\n\n# A\n\nbody\n\n# B\n\nother, from Helix\n";
711+ std::fs::write(d.path().join("root.md"), synced).unwrap();
712+ let i = buf.lines.iter().position(|l| l.text == "body").unwrap();
713+ buf.set_line(i, "mine".into());
714+ assert_eq!(buf.save_all(&mut v).unwrap(), 1);
715+ let disk = std::fs::read_to_string(d.path().join("root.md")).unwrap();
716+ assert_eq!(disk, "intro\n\n# A\n\nmine\n\n# B\n\nother, from Helix\n");
717+ assert_eq!(v.tree.files[0].text, disk);
718+ assert!(buf.dirty.is_empty());
719+ // the next save starts from what this one wrote
720+ buf.set_line(i, "mine again".into());
721+ assert_eq!(buf.save_all(&mut v).unwrap(), 1);
722+ let disk = std::fs::read_to_string(d.path().join("root.md")).unwrap();
723+ assert_eq!(disk, "intro\n\n# A\n\nmine again\n\n# B\n\nother, from Helix\n");
724+ // a change to the block's own text is still refused (§5.2 step 5)
725+ std::fs::write(d.path().join("root.md"), disk.replace("mine again", "theirs")).unwrap();
726+ buf.set_line(i, "mine, third".into());
727+ let err = buf.save_all(&mut v).unwrap_err();
728+ assert!(err.to_string().contains("changed on disk"), "{}", err);
729+ assert!(std::fs::read_to_string(d.path().join("root.md")).unwrap().contains("theirs"));
730+}
731+
732+#[test]
733+fn a_block_save_keeps_a_property_set_on_disk_meanwhile() {
734+ // a block's source span is its file after the frontmatter: a property
735+ // another device set while the block was edited stays
736+ let (d, mut v) = vault_with("# A\n\n- task\n");
737+ let a = v.tree.resolved_children(v.tree.root)[0];
738+ let task = v.tree.resolved_children(a)[0];
739+ ops::make_block(&mut v, task).unwrap();
740+ let a = v.tree.resolved_children(v.tree.root)[0];
741+ let mut buf = open_editor(&v, a);
742+ let p = d.path().join(&v.tree.files[1].path);
743+ let theirs = std::fs::read_to_string(&p).unwrap().replace("\n---\n", "\ndue: 2026-10-01\n---\n");
744+ std::fs::write(&p, &theirs).unwrap();
745+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
746+ buf.set_line(i, "- task (mine)".into());
747+ assert_eq!(buf.save_all(&mut v).unwrap(), 1);
748+ let disk = std::fs::read_to_string(&p).unwrap();
749+ assert_eq!(disk, theirs.replace("- task", "- task (mine)"));
750+ assert!(disk.contains("due: 2026-10-01\n---\n\n- task (mine)\n"), "{}", disk);
751+}
752+
753+#[test]
754+fn a_held_block_title_is_in_transit_not_deleted() {
755+ // §5.2: cutting a nested block's title line and pasting it moves the
756+ // block. While the editor's clipboard holds the line, a save writes the
757+ // parent without the embed and leaves the block and its file alone; put
758+ // back, the block is embedded where it now sits; released without being
759+ // put back, it is deleted like any block whose title line is.
760+ let (d, mut v) = vault_with("# A\n\n- one\n- task\n- two\n");
761+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
762+ let id = ops::make_block(&mut v, t).unwrap();
763+ let block_path = v.tree.files[1].path.clone();
764+ let block_before = v.tree.files[1].text.clone();
765+ let a = v.tree.resolved_children(v.tree.root)[0];
766+ let mut buf = open_editor(&v, a);
767+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
768+ let cut = buf.lines[i].clone();
769+ buf.delete_line(i);
770+ buf.hold(vec![cut.owner]);
771+ buf.save_all(&mut v).unwrap();
772+ assert_eq!(v.tree.files[0].text, "# A\n\n- one\n- two\n");
773+ assert_eq!(std::fs::read_to_string(d.path().join(&block_path)).unwrap(), block_before);
774+ // pasted below "- two" with its tag: the block and its parent are dirty
775+ let two = buf.lines.iter().position(|l| l.text == "- two").unwrap();
776+ buf.lines.insert(two + 1, cut.clone());
777+ buf.mark_dirty(cut.owner);
778+ buf.mark_dirty(buf.lines[0].owner);
779+ buf.save_all(&mut v).unwrap();
780+ assert_eq!(v.tree.files[0].text, format!("# A\n\n- one\n- two\n![[{}]]\n", id.as_str()));
781+ assert_eq!(std::fs::read_to_string(d.path().join(&block_path)).unwrap(), block_before);
782+ // cut again and saved, then the clipboard replaced: the block is deleted
783+ buf.delete_line(two + 1);
784+ buf.save_all(&mut v).unwrap();
785+ assert!(d.path().join(&block_path).exists());
786+ buf.hold(Vec::new());
787+ buf.save_all(&mut v).unwrap();
788+ assert_eq!(v.tree.files[0].text, "# A\n\n- one\n- two\n");
789+ assert!(!d.path().join(&block_path).exists());
790+ assert!(buf.dirty.is_empty(), "{:?}", buf.dirty);
791+}
792+
793+#[test]
794+fn a_held_block_still_in_the_buffer_is_not_in_transit() {
795+ // the clipboard holding a copy of a block's title line keeps the block
796+ // only while nothing of it is in the buffer; a title line re-spelled in
797+ // place, or deleted while a block nested in it stays, deletes the block
798+ // (§5.2) at once
799+ let (d, mut v) = vault_with("# A\n\n- one\n- task\n");
800+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
801+ ops::make_block(&mut v, t).unwrap();
802+ let block_path = v.tree.files[1].path.clone();
803+ let a = v.tree.resolved_children(v.tree.root)[0];
804+ let mut buf = open_editor(&v, a);
805+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
806+ buf.hold(vec![buf.lines[i].owner]);
807+ buf.set_line(i, "task".into());
808+ buf.save_all(&mut v).unwrap();
809+ assert_eq!(v.tree.files[0].text, "# A\n\n- one\ntask\n");
810+ assert!(!d.path().join(&block_path).exists());
811+
812+ let (d, mut v) = vault_with("# A\n\n- one\n- b\n - c\n- two\n");
813+ let c = v.find_by_path(&["A".into(), "b".into(), "c".into()]).unwrap();
814+ let c = ops::make_block(&mut v, c).unwrap();
815+ let b = v.find_by_path(&["A".into(), "b".into()]).unwrap();
816+ ops::make_block(&mut v, b).unwrap();
817+ let b_path = v.tree.files.iter().find(|f| f.text.contains(&format!("![[{}]]", c.as_str()))).unwrap().path.clone();
818+ let a = v.tree.resolved_children(v.tree.root)[0];
819+ let mut buf = open_editor(&v, a);
820+ let i = buf.lines.iter().position(|l| l.text == "- b").unwrap();
821+ buf.hold(vec![buf.lines[i].owner]);
822+ buf.delete_line(i);
823+ buf.save_all(&mut v).unwrap();
824+ assert_eq!(v.tree.files[0].text, format!("# A\n\n- one\n ![[{}]]\n- two\n", c.as_str()));
825+ assert!(!d.path().join(&b_path).exists());
826+}
827+
828+#[test]
829+fn a_reload_that_leaves_the_text_as_it_was_keeps_a_block_in_transit() {
830+ // §11.2: the phone adds a node above A in root.md and the vault re-reads
831+ // it: the buffer on A takes in where A is now and the file's hash, its
832+ // cut block still in transit, so the paste embeds the block again. A
833+ // change to A's own text is not taken in: the editor is re-rendered
834+ let (d, mut v) = vault_with("# A\n\n- one\n- task\n- two\n");
835+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
836+ let id = ops::make_block(&mut v, t).unwrap();
837+ let block_path = d.path().join(&v.tree.files[1].path);
838+ let block_before = v.tree.files[1].text.clone();
839+ let a = v.tree.resolved_children(v.tree.root)[0];
840+ let mut buf = open_editor(&v, a);
841+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
842+ let cut = buf.lines[i].clone();
843+ buf.delete_line(i);
844+ buf.hold(vec![cut.owner]);
845+ buf.save_all(&mut v).unwrap();
846+ let theirs = "# Inbox\n\n- [ ] from phone\n\n# A\n\n- one\n- two\n";
847+ std::fs::write(d.path().join("root.md"), theirs).unwrap();
848+ v.reload().unwrap();
849+ let a = v.find_by_path(&["A".into()]).unwrap();
850+ assert!(buf.take_in(open_editor(&v, a)));
851+ // pasted below "- two" with its tag
852+ let two = buf.lines.iter().position(|l| l.text == "- two").unwrap();
853+ buf.lines.insert(two + 1, cut.clone());
854+ buf.mark_dirty(cut.owner);
855+ buf.mark_dirty(buf.lines[0].owner);
856+ buf.save_all(&mut v).unwrap();
857+ let embedded = format!("{}![[{}]]\n", theirs, id.as_str());
858+ assert_eq!(std::fs::read_to_string(d.path().join("root.md")).unwrap(), embedded);
859+ assert_eq!(std::fs::read_to_string(&block_path).unwrap(), block_before);
860+ // A's own text changed: nothing taken in
861+ std::fs::write(d.path().join("root.md"), embedded.replace("- one", "- one, from Helix")).unwrap();
862+ v.reload().unwrap();
863+ let a = v.find_by_path(&["A".into()]).unwrap();
864+ assert!(!buf.take_in(open_editor(&v, a)));
865+}
866+
867+#[test]
868+fn review_external_edit_of_the_zoomed_node_never_lands_on_an_identical_one() {
869+ // §5.2 step 5: the zoomed item's own text changed on disk (another
870+ // device edited it) while it was edited here. Its old bytes still occur
871+ // once in the file — as an identical item under another section — but
872+ // that is another node: the save must be refused, not written over it.
873+ let (d, mut v) = vault_with("# X\n\n- call mom\n\n# Y\n\n- call mom\n");
874+ let x_item = v.find_by_path(&["X".into(), "call mom".into()]).unwrap();
875+ let mut buf = open_editor(&v, x_item);
876+ assert_eq!(buf.lines[0].text, "- call mom");
877+ let theirs = "# X\n\n- call mom (phone)\n\n# Y\n\n- call mom\n";
878+ std::fs::write(d.path().join("root.md"), theirs).unwrap();
879+ buf.set_line(0, "- call mom tomorrow".into());
880+ let res = buf.save_all(&mut v);
881+ let disk = std::fs::read_to_string(d.path().join("root.md")).unwrap();
882+ assert!(
883+ res.is_err() && disk == theirs,
884+ "save {:?}; Y's item overwritten:\n{}",
885+ res.map_err(|e| e.to_string()),
886+ disk
887+ );
888+ // changes elsewhere, one of them an identical item added above, still
889+ // let the save through, onto the zoomed node
890+ let (d, mut v) = vault_with("# X\n\n- call mom\n\n# Y\n\n- call mom\n");
891+ let x_item = v.find_by_path(&["X".into(), "call mom".into()]).unwrap();
892+ let mut buf = open_editor(&v, x_item);
893+ let theirs = "# W\n\n- call mom\n\n# X\n\n- call mom\n\n# Y\n\n- call mom, from phone\n";
894+ std::fs::write(d.path().join("root.md"), theirs).unwrap();
895+ buf.set_line(0, "- call mom tomorrow".into());
896+ assert_eq!(buf.save_all(&mut v).unwrap(), 1);
897+ assert_eq!(
898+ std::fs::read_to_string(d.path().join("root.md")).unwrap(),
899+ "# W\n\n- call mom\n\n# X\n\n- call mom tomorrow\n\n# Y\n\n- call mom, from phone\n"
900+ );
901+}
902+
903+#[test]
904+fn review_undoing_an_editor_save_leaves_another_files_sync_alone() {
905+ // §10.10: an op-log entry holds exactly the files the operation
906+ // changed. An editor save that deletes a nested block trashes its file,
907+ // and the trash reloads every file: a sync to another block's file not
908+ // reloaded yet must not become part of the save's entry, or undoing
909+ // the save writes that file's old text back over the sync
910+ let (d, mut v) = vault_with("# A\n\n- task\n\n# N\n\n- note\n");
911+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
912+ ops::make_block(&mut v, t).unwrap();
913+ let n = v.find_by_path(&["N".into(), "note".into()]).unwrap();
914+ let note_id = ops::make_block(&mut v, n).unwrap();
915+ let note = d.path().join(&v.tree.files[v.tree.block_by_id(&note_id).unwrap().0].path);
916+ let a = v.tree.resolved_children(v.tree.root)[0];
917+ let mut buf = open_editor(&v, a);
918+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
919+ buf.delete_line(i);
920+ let synced = std::fs::read_to_string(&note).unwrap().replace("- note", "- note, from phone");
921+ std::fs::write(&note, &synced).unwrap();
922+ // what the app does on a save (§10.10)
923+ buf.rebase_dirty(&mut v);
924+ let snap = ops::Snapshot::take(&v, "edit");
925+ buf.save_all(&mut v).unwrap();
926+ let inv = ops::Inverse::since(snap, &v).unwrap();
927+ inv.undo(&mut v).unwrap();
928+ assert_eq!(
929+ std::fs::read_to_string(&note).unwrap(),
930+ synced,
931+ "undoing the save reverted another device's change to a file it never touched"
932+ );
933+}
934+
935+#[test]
936+fn review_a_block_is_not_trashed_while_the_file_on_disk_embeds_it() {
937+ // A block cut in the editor is in transit: the parent is saved without
938+ // its embed. Another device's root.md, which still embeds it, then
939+ // lands (§12.1). Releasing the clipboard deletes the block only once no
940+ // file embeds it (§5.2): the parent's save is refused (changed on
941+ // disk), so root.md on disk still embeds the block and its file must
942+ // stay, not go to the trash leaving that embed broken
943+ let (d, mut v) = vault_with("# A\n\n- one\n- task\n- two\n");
944+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
945+ let id = ops::make_block(&mut v, t).unwrap();
946+ let block = d.path().join(&v.tree.files[1].path);
947+ let a = v.tree.resolved_children(v.tree.root)[0];
948+ let mut buf = open_editor(&v, a);
949+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
950+ let cut = buf.lines[i].owner;
951+ buf.delete_line(i);
952+ buf.hold(vec![cut]);
953+ buf.save_all(&mut v).unwrap();
954+ assert_eq!(v.tree.files[0].text, "# A\n\n- one\n- two\n");
955+ assert!(block.exists(), "in transit");
956+ let theirs = format!("# A\n\n- one\n![[{}]]\n- two, from phone\n", id.as_str());
957+ std::fs::write(d.path().join("root.md"), &theirs).unwrap();
958+ buf.hold(Vec::new());
959+ let res = buf.save_all(&mut v);
960+ assert_eq!(std::fs::read_to_string(d.path().join("root.md")).unwrap(), theirs);
961+ assert!(
962+ block.exists(),
963+ "save {:?}: root.md embeds {} but its file went to the trash",
964+ res.map_err(|e| e.to_string()),
965+ id.as_str()
966+ );
967+}
968+
969+#[test]
970+fn discarding_the_buffer_deletes_a_block_a_save_left_embedded_nowhere() {
971+ // Revert (§10.6) drops the unsaved text, but a block cut and saved in
972+ // transit cannot be pasted back any more: it is deleted (§5.2). One cut
973+ // and not saved is still embedded, and stays
974+ let (d, mut v) = vault_with("# A\n\n- one\n- task\n- two\n");
975+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
976+ ops::make_block(&mut v, t).unwrap();
977+ let block = d.path().join(&v.tree.files[1].path);
978+ let a = v.tree.resolved_children(v.tree.root)[0];
979+ let cut = |v: &Vault| {
980+ let mut buf = open_editor(v, a);
981+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
982+ let o = buf.lines[i].owner;
983+ buf.delete_line(i);
984+ buf.hold(vec![o]);
985+ buf
986+ };
987+ let buf = cut(&v);
988+ v.reload().unwrap();
989+ buf.discard(&mut v).unwrap();
990+ assert!(block.exists());
991+ assert_eq!(v.tree.files.len(), 2);
992+ let mut buf = cut(&v);
993+ buf.save_all(&mut v).unwrap();
994+ assert!(block.exists(), "in transit");
995+ v.reload().unwrap();
996+ buf.discard(&mut v).unwrap();
997+ assert!(!block.exists());
998+ assert_eq!(v.tree.files.len(), 1);
999+ assert_eq!(std::fs::read_to_string(d.path().join("root.md")).unwrap(), "# A\n\n- one\n- two\n");
1000+}
1001+
1002+#[test]
1003+fn review_indenting_a_nested_block_title_line_does_not_delete_the_block() {
1004+ // §5.2: only deleting a nested block's title line deletes the block.
1005+ // Indenting "- task" under "- one" (Tab, vim `>>`) keeps the title line:
1006+ // the block must survive, embedded where its title line now sits
1007+ let (d, mut v) = vault_with("# A\n\n- one\n- task\n");
1008+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
1009+ let id = ops::make_block(&mut v, t).unwrap();
1010+ let block = d.path().join(&v.tree.files[1].path);
1011+ let a = v.tree.resolved_children(v.tree.root)[0];
1012+ let mut buf = open_editor(&v, a);
1013+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
1014+ buf.set_line(i, " - task".into());
1015+ buf.save_all(&mut v).unwrap();
1016+ let root = std::fs::read_to_string(d.path().join("root.md")).unwrap();
1017+ assert!(
1018+ block.exists() && root.contains(&format!("![[{}]]", id.as_str())),
1019+ "block file exists: {}; root.md:\n{}",
1020+ block.exists(),
1021+ root
1022+ );
1023+}
1024+
1025+#[test]
1026+fn a_re_indented_or_re_spelled_block_title_line_moves_the_block() {
1027+ // §5.2: the embed goes where the title line sits, in its form (a
1028+ // heading embed for a heading); the block's file keeps its root at
1029+ // column 0, a heading at level 1 (§4.9)
1030+ let (_d, mut v) = vault_with("# A\n\n- one\n- task\n body\n");
1031+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
1032+ let id = ops::make_block(&mut v, t).unwrap();
1033+ let fm = format!("---\nid: {}\n---\n\n", id.as_str());
1034+ let a = v.tree.resolved_children(v.tree.root)[0];
1035+ let mut buf = open_editor(&v, a);
1036+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
1037+ buf.set_line(i, " - task".into());
1038+ buf.set_line(i + 1, " body".into());
1039+ buf.save_all(&mut v).unwrap();
1040+ assert_eq!(v.tree.files[0].text, format!("# A\n\n- one\n ![[{}]]\n", id.as_str()));
1041+ assert_eq!(v.tree.files[1].text, format!("{}- task\n body\n", fm));
1042+ // renamed after: still the block's title line
1043+ buf.set_line(i, " - task, renamed".into());
1044+ buf.save_all(&mut v).unwrap();
1045+ assert_eq!(v.tree.files[1].text, format!("{}- task, renamed\n body\n", fm));
1046+ // re-spelled as a heading: a heading embed, a level-1 root
1047+ buf.set_line(i, "## task".into());
1048+ buf.set_line(i + 1, "body".into());
1049+ buf.save_all(&mut v).unwrap();
1050+ assert_eq!(v.tree.files[0].text, format!("# A\n\n- one\n## ![[{}]]\n", id.as_str()));
1051+ assert_eq!(v.tree.files[1].text, format!("{}# task\nbody\n", fm));
1052+ // and back
1053+ buf.set_line(i, "- task".into());
1054+ buf.set_line(i + 1, " body".into());
1055+ buf.save_all(&mut v).unwrap();
1056+ assert_eq!(v.tree.files[0].text, format!("# A\n\n- one\n![[{}]]\n", id.as_str()));
1057+ assert_eq!(v.tree.files[1].text, format!("{}- task\n body\n", fm));
1058+ assert!(buf.dirty.is_empty(), "{:?}", buf.dirty);
1059+
1060+ // a block nested in an item, dedented out of it
1061+ let (_d, mut v) = vault_with("# A\n\n- one\n - c\n");
1062+ let c = v.find_by_path(&["A".into(), "one".into(), "c".into()]).unwrap();
1063+ let id = ops::make_block(&mut v, c).unwrap();
1064+ let a = v.tree.resolved_children(v.tree.root)[0];
1065+ let mut buf = open_editor(&v, a);
1066+ let i = buf.lines.iter().position(|l| l.text == " - c").unwrap();
1067+ buf.set_line(i, "- c".into());
1068+ buf.save_all(&mut v).unwrap();
1069+ assert_eq!(v.tree.files[0].text, format!("# A\n\n- one\n![[{}]]\n", id.as_str()));
1070+ assert!(v.tree.files[1].text.ends_with("---\n\n- c\n"), "{}", v.tree.files[1].text);
1071+
1072+ // deleting the title line still deletes the block when it leaves a
1073+ // child of the block first, whatever its indent (§5.2)
1074+ let (d, mut v) = vault_with("# A\n\n- b\n - x\n");
1075+ let b = v.find_by_path(&["A".into(), "b".into()]).unwrap();
1076+ ops::make_block(&mut v, b).unwrap();
1077+ let b_path = d.path().join(&v.tree.files[1].path);
1078+ let a = v.tree.resolved_children(v.tree.root)[0];
1079+ let mut buf = open_editor(&v, a);
1080+ let i = buf.lines.iter().position(|l| l.text == "- b").unwrap();
1081+ buf.delete_line(i);
1082+ buf.save_all(&mut v).unwrap();
1083+ assert_eq!(v.tree.files[0].text, "# A\n\n - x\n");
1084+ assert!(!b_path.exists());
1085+}
1086+
1087+#[test]
1088+fn review_indenting_a_block_title_line_under_a_sibling_block_keeps_it_in_the_tree() {
1089+ // §5.2: " - c" re-indented under "- b", itself a nested block, sits in
1090+ // b: c's embed goes into b's file, never indented under b's bare embed
1091+ // in A (§4.7: an embed line has no children), where the outline does
1092+ // not see it and deleting A leaves c's file embedded nowhere
1093+ let (d, mut v) = vault_with("# A\n\n- b\n- c\n");
1094+ let b = v.find_by_path(&["A".into(), "b".into()]).unwrap();
1095+ ops::make_block(&mut v, b).unwrap();
1096+ let c = v.find_by_path(&["A".into(), "c".into()]).unwrap();
1097+ let cid = ops::make_block(&mut v, c).unwrap();
1098+ let c_path = d.path().join(&v.tree.files[v.tree.block_by_id(&cid).unwrap().0].path);
1099+ let a = v.tree.resolved_children(v.tree.root)[0];
1100+ let mut buf = open_editor(&v, a);
1101+ let i = buf.lines.iter().position(|l| l.text == "- c").unwrap();
1102+ buf.set_line(i, " - c".into());
1103+ buf.save_all(&mut v).unwrap();
1104+ let root = v.tree.files[0].text.clone();
1105+ let mut titles = Vec::new();
1106+ v.tree.walk(v.tree.root, &mut |t, r| titles.push(t.node(r).title.clone()));
1107+ let a = v.tree.resolved_children(v.tree.root)[0];
1108+ ops::delete_subtree(&mut v, a).unwrap();
1109+ assert!(
1110+ titles.contains(&"c".to_string()) && !c_path.exists(),
1111+ "root.md after the save:\n{}walk: {:?}; after deleting A, c's file still in the vault: {}",
1112+ root,
1113+ titles,
1114+ c_path.exists()
1115+ );
1116+}
1117+
1118+#[test]
1119+fn review_a_line_split_off_above_a_re_indented_block_title_does_not_delete_the_block() {
1120+ // §5.2: only deleting its title line deletes a block; "note" split off
1121+ // above its re-indented title line is the enclosing block's text
1122+ let (d, mut v) = vault_with("# A\n\n- one\n- task\n body\n");
1123+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
1124+ let id = ops::make_block(&mut v, t).unwrap();
1125+ let block = d.path().join(&v.tree.files[1].path);
1126+ let a = v.tree.resolved_children(v.tree.root)[0];
1127+ let mut buf = open_editor(&v, a);
1128+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
1129+ buf.set_line(i, " - task".into());
1130+ buf.set_line(i + 1, " body".into());
1131+ // "note" typed at column 0, then Enter after it: the line splits, both
1132+ // parts keeping the line's tag
1133+ buf.set_line(i, "note - task".into());
1134+ buf.set_line(i, "note".into());
1135+ buf.insert_line(i, " - task".into());
1136+ buf.save_all(&mut v).unwrap();
1137+ assert!(
1138+ block.exists() && v.tree.files[0].text.contains(&format!("![[{}]]", id.as_str())),
1139+ "the block's file was trashed; root.md:\n{}",
1140+ v.tree.files[0].text
1141+ );
1142+}
1143+
1144+#[test]
1145+fn a_line_the_parser_would_nest_under_a_blocks_embed_is_not_saved() {
1146+ // §4.7: an embed line has no lines nested under it in the parent file,
1147+ // where the outline does not see them. The enclosing block's "- two"
1148+ // indented under b's title line, or left after it re-spelled as a
1149+ // heading, would be: the save is refused, the file left as it was
1150+ for (b_line, two) in [("- b", " - two"), ("## b", "- two")] {
1151+ let (_d, mut v) = vault_with("# A\n\n- one\n- b\n- two\n");
1152+ let b = v.find_by_path(&["A".into(), "b".into()]).unwrap();
1153+ ops::make_block(&mut v, b).unwrap();
1154+ let before = v.tree.files[0].text.clone();
1155+ let a = v.tree.resolved_children(v.tree.root)[0];
1156+ let mut buf = open_editor(&v, a);
1157+ let i = buf.lines.iter().position(|l| l.text == "- b").unwrap();
1158+ buf.set_line(i, b_line.into());
1159+ buf.set_line(i + 1, two.into());
1160+ let e = buf.save_all(&mut v).unwrap_err();
1161+ assert!(e.to_string().contains("nested under a block's embed"), "{}", e);
1162+ assert_eq!(v.tree.files[0].text, before);
1163+ // put back, it saves
1164+ buf.set_line(i, "- b".into());
1165+ buf.set_line(i + 1, "- two".into());
1166+ buf.save_all(&mut v).unwrap();
1167+ assert_eq!(v.tree.files[0].text, before);
1168+ assert!(v.tree.files[1].text.ends_with("---\n\n- b\n"), "{}", v.tree.files[1].text);
1169+ }
1170+}
1171+
1172+#[test]
1173+fn discarding_the_buffer_deletes_a_block_cut_saved_in_transit_and_let_go() {
1174+ // a block cut and saved in transit, then let go by the clipboard (it
1175+ // holds something else now) with no save since: Revert (§10.6) deletes
1176+ // it as the next save would have, never leaves its file embedded nowhere
1177+ let (d, mut v) = vault_with("# A\n\n- one\n- task\n- two\n");
1178+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
1179+ ops::make_block(&mut v, t).unwrap();
1180+ let block = d.path().join(&v.tree.files[1].path);
1181+ let a = v.tree.resolved_children(v.tree.root)[0];
1182+ let mut buf = open_editor(&v, a);
1183+ let i = buf.lines.iter().position(|l| l.text == "- task").unwrap();
1184+ let o = buf.lines[i].owner;
1185+ buf.delete_line(i);
1186+ buf.hold(vec![o]);
1187+ buf.save_all(&mut v).unwrap();
1188+ assert!(block.exists(), "in transit");
1189+ buf.hold(Vec::new());
1190+ v.reload().unwrap();
1191+ buf.discard(&mut v).unwrap();
1192+ assert!(!block.exists());
1193+ assert_eq!(v.tree.files.len(), 1);
1194+ assert_eq!(std::fs::read_to_string(d.path().join("root.md")).unwrap(), "# A\n\n- one\n- two\n");
1195+}
1196+
1197+#[test]
1198+fn a_block_title_line_indented_or_re_spelled_under_another_blocks_moves_into_it() {
1199+ // §5.2: the embed goes to the block whose title line holds the title
1200+ // line as it is written now, as the parser nests them (§3.1), blank
1201+ // lines between or not
1202+ let embed = |id: &fold_core::ident::Id| format!("![[{}]]", id.as_str());
1203+ let text = |v: &Vault, id| v.tree.files[v.tree.block_by_id(id).unwrap().0].text.clone();
1204+ // "- c" indented twice, under " - b", a block in the item "- one"
1205+ let (_d, mut v) = vault_with("# A\n\n- one\n - b\n- c\n");
1206+ let b = v.find_by_path(&["A".into(), "one".into(), "b".into()]).unwrap();
1207+ let b = ops::make_block(&mut v, b).unwrap();
1208+ let c = v.find_by_path(&["A".into(), "c".into()]).unwrap();
1209+ let c = ops::make_block(&mut v, c).unwrap();
1210+ let a = v.tree.resolved_children(v.tree.root)[0];
1211+ let mut buf = open_editor(&v, a);
1212+ let i = buf.lines.iter().position(|l| l.text == "- c").unwrap();
1213+ buf.set_line(i, " - c".into());
1214+ buf.save_all(&mut v).unwrap();
1215+ assert_eq!(v.tree.files[0].text, format!("# A\n\n- one\n {}\n", embed(&b)));
1216+ assert!(text(&v, &b).ends_with(&format!("\n- b\n {}\n", embed(&c))), "{}", text(&v, &b));
1217+ // "## c" re-spelled "### c" after a blank line, under the section "## b"
1218+ let (_d, mut v) = vault_with("# A\n\n## b\n\n## c\n");
1219+ let b = v.find_by_path(&["A".into(), "b".into()]).unwrap();
1220+ let b = ops::make_block(&mut v, b).unwrap();
1221+ let c = v.find_by_path(&["A".into(), "c".into()]).unwrap();
1222+ let c = ops::make_block(&mut v, c).unwrap();
1223+ let a = v.tree.resolved_children(v.tree.root)[0];
1224+ let mut buf = open_editor(&v, a);
1225+ let i = buf.lines.iter().position(|l| l.text == "## c").unwrap();
1226+ buf.set_line(i, "### c".into());
1227+ buf.save_all(&mut v).unwrap();
1228+ assert_eq!(v.tree.files[0].text, format!("# A\n\n## {}\n", embed(&b)));
1229+ assert!(text(&v, &b).ends_with(&format!("\n# b\n## {}\n", embed(&c))), "{}", text(&v, &b));
1230+ assert!(text(&v, &c).ends_with("\n# c\n"), "{}", text(&v, &c));
1291231}
crates/fold-core/tests/merge_check.rs+639−1
@@ -147,10 +147,648 @@ fn check_reports_bad_dates() {
147147 std::fs::write(dir.path().join("root.md"), "# A\n").unwrap();
148148 std::fs::write(
149149 dir.path().join("racfer~task.md"),
150− format!("---\nid: {}\ntodo: open\ndue: next friday\n---\n\n- task\n", id),
150+ format!("---\nid: {}\ndue: next friday\n---\n\n- [ ] task\n", id),
151151 )
152152 .unwrap();
153153 let v = Vault::open(dir.path()).unwrap();
154154 let diags = fold_core::check::check(&v);
155155 assert!(diags.iter().any(|d| d.message.contains("not an ISO date")), "{:?}", diags.iter().map(|d| &d.message).collect::<Vec<_>>());
156156}
157+
158+/// 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+
184+// ---------------------------------------------------------------- regressions
185+
186+const BID: &str = "racfer-hattes-mislup-nodrys";
187+
188+#[test]
189+fn merge_of_identical_texts_is_byte_identical() {
190+ // §15.6: merge(O, O) == O, loose lists and all
191+ for o in [
192+ "# A\n\n- a\n\n- b\n",
193+ "---\nvault: x\n---\n\nIntro.\n\n# A\n\nbody\n\n## B\n\n- one\n - two\n",
194+ "# A\n\n- [ ] t\n\n# B\n",
195+ ] {
196+ let out = merge::merge_texts(o, o, "d", "ts");
197+ assert_eq!(out.conflicts, 0);
198+ assert_eq!(out.text, o);
199+ }
200+}
201+
202+#[test]
203+fn merge_block_file_keeps_frontmatter_out_of_the_tree() {
204+ let o = format!("---\nid: {}\ndue: 2026-09-20\n---\n\n- t\n", BID);
205+ let t = format!("---\nid: {}\ndue: 2026-09-20\n---\n\n- t\n extra line\n", BID);
206+ let out = merge::merge_texts(&o, &t, "d", "ts");
207+ assert_eq!(out.conflicts, 1);
208+ assert!(!out.text.contains("# due"), "{}", out.text);
209+ assert!(out.text.starts_with(&format!("---\nid: {}\ndue: 2026-09-20\n---\n\n- t\n", BID)), "{}", out.text);
210+ // one column-0 node: the conflict embed is not placed inside the block file
211+ assert!(!out.text.contains("![["), "{}", out.text);
212+ assert_eq!(out.sibling_embeds.len(), 1);
213+}
214+
215+#[test]
216+fn merge_block_frontmatter_difference_raises_conflict_with_their_props() {
217+ let o = format!("---\nid: {}\ndue: 2026-09-20\n---\n\n- t\n", BID);
218+ let t = format!("---\nid: {}\ndue: 2026-10-01\n---\n\n- t\n", BID);
219+ let out = merge::merge_texts(&o, &t, "phone", "ts");
220+ assert_eq!(out.conflicts, 1);
221+ assert_eq!(out.conflict_blocks.len(), 1);
222+ let cb = &out.conflict_blocks[0].1;
223+ assert!(cb.contains("due: 2026-10-01"), "{}", cb);
224+ assert!(cb.contains("conflict: \"phone ts\""), "{}", cb);
225+ assert!(!cb.contains(BID), "{}", cb);
226+ assert!(out.text.contains("due: 2026-09-20"), "{}", out.text);
227+}
228+
229+#[test]
230+fn sync_conflict_on_block_places_embed_after_its_embed() {
231+ let dir = tempfile::tempdir().unwrap();
232+ std::fs::write(dir.path().join("root.md"), format!("# A\n\n![[{}]]\n\n# B\n", BID)).unwrap();
233+ std::fs::write(
234+ dir.path().join("racfer~t.md"),
235+ format!("---\nid: {}\n---\n\n- [ ] t\n", BID),
236+ )
237+ .unwrap();
238+ std::fs::write(
239+ dir.path().join("racfer~t.sync-conflict-20260912-100000-phone.md"),
240+ format!("---\nid: {}\n---\n\n- [x] t\n", BID),
241+ )
242+ .unwrap();
243+ let mut v = Vault::open(dir.path()).unwrap();
244+ merge::merge_sync_conflicts(&mut v, false).unwrap();
245+ let block = std::fs::read_to_string(dir.path().join("racfer~t.md")).unwrap();
246+ assert!(!block.contains("![["), "{}", block);
247+ let pairs = merge::conflict_pairs(&v);
248+ assert_eq!(pairs.len(), 1);
249+ assert_eq!(v.tree.node(pairs[0].0).block.as_ref().unwrap().id.as_ref().unwrap().to_string(), BID);
250+}
251+
252+#[test]
253+fn section_conflict_pairs_with_the_section() {
254+ let dir = tempfile::tempdir().unwrap();
255+ std::fs::write(dir.path().join("root.md"), "# A\n\nbody o\n\n- x\n").unwrap();
256+ std::fs::write(
257+ dir.path().join("root.sync-conflict-20260912-100000-phone.md"),
258+ "# A\n\nbody t\n\n- x\n",
259+ )
260+ .unwrap();
261+ let mut v = Vault::open(dir.path()).unwrap();
262+ merge::merge_sync_conflicts(&mut v, false).unwrap();
263+ let pairs = merge::conflict_pairs(&v);
264+ assert_eq!(pairs.len(), 1, "{}", v.tree.files[0].text);
265+ assert_eq!(v.tree.node(pairs[0].0).title, "A");
266+ // no children: still listed
267+ let dir = tempfile::tempdir().unwrap();
268+ std::fs::write(dir.path().join("root.md"), "# A\n\nbody o\n").unwrap();
269+ std::fs::write(
270+ dir.path().join("root.sync-conflict-20260912-100000-phone.md"),
271+ "# A\n\nbody t\n",
272+ )
273+ .unwrap();
274+ let mut v = Vault::open(dir.path()).unwrap();
275+ merge::merge_sync_conflicts(&mut v, false).unwrap();
276+ let pairs = merge::conflict_pairs(&v);
277+ assert_eq!(pairs.len(), 1, "{}", v.tree.files[0].text);
278+ assert_eq!(v.tree.node(pairs[0].0).title, "A");
279+}
280+
281+#[test]
282+fn keep_theirs_puts_checkbox_after_the_heading_marker() {
283+ let dir = tempfile::tempdir().unwrap();
284+ std::fs::write(dir.path().join("root.md"), "# Fix - thing\n").unwrap();
285+ std::fs::write(
286+ dir.path().join("root.sync-conflict-20260912-100000-phone.md"),
287+ "# [ ] Fix - thing\n",
288+ )
289+ .unwrap();
290+ let mut v = Vault::open(dir.path()).unwrap();
291+ merge::merge_sync_conflicts(&mut v, false).unwrap();
292+ let pairs = merge::conflict_pairs(&v);
293+ assert_eq!(pairs.len(), 1, "{}", v.tree.files[0].text);
294+ merge::resolve_keep_theirs(&mut v, pairs[0].0, pairs[0].1).unwrap();
295+ let text = std::fs::read_to_string(dir.path().join("root.md")).unwrap();
296+ assert!(text.contains("# [ ] Fix - thing"), "{}", text);
297+}
298+
299+#[test]
300+fn keep_ours_handles_multibyte_text_before_embed() {
301+ let dir = tempfile::tempdir().unwrap();
302+ std::fs::write(dir.path().join("root.md"), "- café\n- other\n").unwrap();
303+ std::fs::write(
304+ dir.path().join("root.sync-conflict-20260912-100000-phone.md"),
305+ "- café\n- [x] other\n",
306+ )
307+ .unwrap();
308+ let mut v = Vault::open(dir.path()).unwrap();
309+ merge::merge_sync_conflicts(&mut v, false).unwrap();
310+ let pairs = merge::conflict_pairs(&v);
311+ assert_eq!(pairs.len(), 1);
312+ merge::resolve_keep_ours(&mut v, pairs[0].1).unwrap();
313+ let text = std::fs::read_to_string(dir.path().join("root.md")).unwrap();
314+ assert!(!text.contains("![["), "{}", text);
315+ assert!(text.contains("- café") && text.contains("- other"), "{}", text);
316+}
317+
318+#[test]
319+fn retitled_block_root_conflicts_and_keep_theirs_keeps_the_filename() {
320+ let dir = tempfile::tempdir().unwrap();
321+ std::fs::write(dir.path().join("root.md"), format!("# A\n\n![[{}]]\n", BID)).unwrap();
322+ std::fs::write(dir.path().join("racfer~old.md"), format!("---\nid: {}\n---\n\n- old\n", BID)).unwrap();
323+ std::fs::write(
324+ dir.path().join("racfer~old.sync-conflict-20260912-100000-phone.md"),
325+ format!("---\nid: {}\n---\n\n- new\n", BID),
326+ )
327+ .unwrap();
328+ let mut v = Vault::open(dir.path()).unwrap();
329+ merge::merge_sync_conflicts(&mut v, false).unwrap();
330+ let block = std::fs::read_to_string(dir.path().join("racfer~old.md")).unwrap();
331+ assert_eq!(block, format!("---\nid: {}\n---\n\n- old\n", BID));
332+ let pairs = merge::conflict_pairs(&v);
333+ assert_eq!(pairs.len(), 1);
334+ merge::resolve_keep_theirs(&mut v, pairs[0].0, pairs[0].1).unwrap();
335+ // the title changed, the filename did not (§6.4)
336+ let text = std::fs::read_to_string(dir.path().join("racfer~old.md")).unwrap();
337+ assert!(text.contains(&format!("id: {}", BID)) && text.contains("- new"), "{}", text);
338+ assert!(merge::conflict_pairs(&v).is_empty());
339+}
340+
341+#[test]
342+fn deep_headings_are_canonical() {
343+ // §3.1 / §4.2: no upper bound on heading level; levels beyond six are ours
344+ // (§4.2 "What is ours"), not non-canonical syntax for `fold check` to report.
345+ let dir = tempfile::tempdir().unwrap();
346+ std::fs::write(
347+ dir.path().join("root.md"),
348+ "# 1\n\n## 2\n\n### 3\n\n#### 4\n\n##### 5\n\n###### 6\n\n####### 7\n",
349+ )
350+ .unwrap();
351+ let v = Vault::open(dir.path()).unwrap();
352+ let diags = fold_core::check::check(&v);
353+ assert!(
354+ diags.is_empty(),
355+ "{:?}",
356+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
357+ );
358+}
359+
360+#[test]
361+fn crlf_separated_text_is_not_flagged_or_padded() {
362+ // "text" already follows `a` after a blank line; only the line endings
363+ // are CRLF, which is read cleanly (§4.2) and must not count as missing
364+ // the separator, nor make --fix invent a second blank line (§3.3).
365+ let dir = tempfile::tempdir().unwrap();
366+ std::fs::write(dir.path().join("root.md"), "# A\r\n\r\n- a\r\n\r\ntext\r\n").unwrap();
367+ let mut v = Vault::open(dir.path()).unwrap();
368+ let diags = fold_core::check::check(&v);
369+ assert!(
370+ !diags.iter().any(|d| d.message.contains("text right after a child node")),
371+ "{:?}",
372+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
373+ );
374+ fold_core::check::fix(&mut v).unwrap();
375+ let text = std::fs::read_to_string(dir.path().join("root.md")).unwrap();
376+ assert_eq!(text, "# A\n\n- a\n\ntext\n");
377+}
378+
379+#[test]
380+fn merge_rewrite_keeps_embeds() {
381+ // §12.4: embeds match by id and every node from O and T survives; a
382+ // re-rendered merge (T adds a node) must not turn `![[id]]` into `- `
383+ let o = format!("# A\n\n![[{}]]\n\n# B\n", BID);
384+ let t = format!("# A\n\n![[{}]]\n\n# B\n\n- new\n", BID);
385+ let out = merge::merge_texts(&o, &t, "d", "ts");
386+ assert!(out.text.contains(&format!("![[{}]]", BID)), "{}", out.text);
387+ assert!(out.text.contains("- new"), "{}", out.text);
388+ // a T-only embed (block made on the other device) must survive too
389+ let o2 = "# A\n\n- x\n";
390+ let t2 = format!("# A\n\n- x\n![[{}]]\n", BID);
391+ let out2 = merge::merge_texts(o2, &t2, "d", "ts");
392+ assert!(out2.text.contains(&format!("![[{}]]", BID)), "{}", out2.text);
393+ // heading form
394+ let o3 = format!("# A\n\n## ![[{}]]\n\n# B\n", BID);
395+ let t3 = format!("# A\n\n## ![[{}]]\n\n# B\n\n- new\n", BID);
396+ let out3 = merge::merge_texts(&o3, &t3, "d", "ts");
397+ assert!(out3.text.contains(&format!("## ![[{}]]", BID)), "{}", out3.text);
398+ // idempotent (§12.4): merging T into the result again adds nothing
399+ for (out, t) in [(&out, &t), (&out2, &t2), (&out3, &t3)] {
400+ assert_eq!(merge::merge_texts(&out.text, t, "d", "ts").text, out.text);
401+ }
402+}
403+
404+#[test]
405+fn sync_conflict_merge_keeps_embeds_in_the_merged_file() {
406+ let dir = tempfile::tempdir().unwrap();
407+ std::fs::write(dir.path().join("root.md"), format!("# A\n\n![[{}]]\n\n# B\n", BID)).unwrap();
408+ std::fs::write(dir.path().join("racfer~t.md"), format!("---\nid: {}\n---\n\n- t\n", BID)).unwrap();
409+ std::fs::write(
410+ dir.path().join("root.sync-conflict-20260912-100000-phone.md"),
411+ format!("# A\n\n![[{}]]\n\n# B\n\n- new\n", BID),
412+ )
413+ .unwrap();
414+ let mut v = Vault::open(dir.path()).unwrap();
415+ merge::merge_sync_conflicts(&mut v, false).unwrap();
416+ let text = std::fs::read_to_string(dir.path().join("root.md")).unwrap();
417+ assert!(text.contains(&format!("![[{}]]", BID)), "{}", text);
418+ assert!(text.contains("- new"), "{}", text);
419+}
420+
421+#[test]
422+fn a_copy_from_before_a_block_was_moved_is_merged_without_its_embed() {
423+ // the block was cut from NAS in the editor, which wrote root.md
424+ // without its embed, and is not pasted yet: moved on this device. The
425+ // phone's copy, from before the cut, still embeds it in NAS; its own
426+ // change comes in, the embed does not, nor into NAS's conflict copy
427+ // where the phone changed NAS's text too
428+ let ours = "# Homelab\n\n## NAS\n\nMirrored pairs.\n\n### [x] Replace fan\n\n# Inbox\n\n- [ ] a\n";
429+ let before = ours.replace("### [x]", &format!("### ![[{}]]\n\n### [x]", BID));
430+ for nas in ["Mirrored pairs.", "Snapshots every 2h."] {
431+ let dir = tempfile::tempdir().unwrap();
432+ std::fs::write(dir.path().join("root.md"), ours).unwrap();
433+ let block = dir.path().join("racfer~snapshot-policy.md");
434+ let text = format!("---\nid: {}\n---\n\n# [ ] Snapshot policy\n\n- hourly\n", BID);
435+ std::fs::write(&block, &text).unwrap();
436+ let copy = "root.sync-conflict-20260927-101010-PHONE77.md";
437+ let theirs = before.replace("- [ ] a\n", "- [ ] a\n- [ ] from phone\n").replace("Mirrored pairs.", nas);
438+ std::fs::write(dir.path().join(copy), theirs).unwrap();
439+ let mut v = Vault::open(dir.path()).unwrap();
440+ let moving = [fold_core::ident::Id::parse(BID).unwrap()];
441+ merge::merge_sync_conflicts_moving(&mut v, false, &moving).unwrap();
442+ assert!(!dir.path().join(copy).exists(), "{}", nas);
443+ let merged = std::fs::read_to_string(dir.path().join("root.md")).unwrap();
444+ let embed = format!("![[{}]]", BID);
445+ assert!(!merged.contains(&embed), "{}: {}", nas, merged);
446+ assert_eq!(std::fs::read_to_string(&block).unwrap(), text, "{}", nas);
447+ if nas == "Mirrored pairs." {
448+ assert_eq!(merged, ours.replace("- [ ] a\n", "- [ ] a\n- [ ] from phone\n"));
449+ assert!(merge::conflict_pairs(&v).is_empty(), "{}", merged);
450+ continue;
451+ }
452+ assert!(merged.contains("- [ ] from phone\n"), "{}", merged);
453+ let pairs = merge::conflict_pairs(&v);
454+ assert_eq!(pairs.len(), 1, "{}", merged);
455+ let theirs = &v.tree.files[pairs[0].1 .0].text;
456+ assert!(theirs.contains("Snapshots every 2h.") && !theirs.contains(&embed), "{}", theirs);
457+ }
458+}
459+
460+#[test]
461+fn merge_leaves_ignored_files_alone() {
462+ // §4.1/§11.4: a `.md` file without an id is ignored — never parsed,
463+ // never written. A sync-conflict copy of it is not ours to merge.
464+ let dir = tempfile::tempdir().unwrap();
465+ std::fs::write(dir.path().join("root.md"), "# A\n").unwrap();
466+ std::fs::write(dir.path().join("readme.md"), "* [ ] first\n").unwrap();
467+ std::fs::write(
468+ dir.path().join("readme.sync-conflict-20260912-100000-phone.md"),
469+ "* [x] first\n",
470+ )
471+ .unwrap();
472+ let mut v = Vault::open(dir.path()).unwrap();
473+ let outcomes = merge::merge_sync_conflicts(&mut v, false).unwrap();
474+ assert!(outcomes.iter().any(|o| o.contains("left alone")), "{:?}", outcomes);
475+ assert_eq!(
476+ std::fs::read_to_string(dir.path().join("readme.md")).unwrap(),
477+ "* [ ] first\n"
478+ );
479+ let mut names: Vec<String> = std::fs::read_dir(dir.path())
480+ .unwrap()
481+ .filter_map(|e| e.ok())
482+ .map(|e| e.file_name().to_string_lossy().to_string())
483+ .collect();
484+ names.sort();
485+ assert_eq!(
486+ names,
487+ vec![
488+ "readme.md".to_string(),
489+ "readme.sync-conflict-20260912-100000-phone.md".to_string(),
490+ "root.md".to_string(),
491+ ]
492+ );
493+ let diags = fold_core::check::check(&v);
494+ assert!(
495+ diags.iter().all(|d| !d.message.contains("unresolved conflict")),
496+ "{:?}",
497+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
498+ );
499+}
500+
501+#[test]
502+fn merge_with_missing_base_keeps_theirs_frontmatter() {
503+ // X.md is gone (deleted, or its deletion synced in first): with no O to
504+ // merge against, T must survive with its frontmatter (id, due) intact
505+ let dir = tempfile::tempdir().unwrap();
506+ std::fs::write(dir.path().join("root.md"), format!("# A\n\n![[{}]]\n", BID)).unwrap();
507+ let theirs = format!("---\nid: {}\ndue: 2026-09-20\n---\n\n- t\n", BID);
508+ let c = "racfer~t.sync-conflict-20260912-100000-phone.md";
509+ std::fs::write(dir.path().join(c), &theirs).unwrap();
510+ let mut v = Vault::open(dir.path()).unwrap();
511+ merge::merge_sync_conflicts(&mut v, false).unwrap();
512+ let text = std::fs::read_to_string(dir.path().join("racfer~t.md")).unwrap();
513+ assert_eq!(text, theirs);
514+ assert!(!dir.path().join(c).exists());
515+ assert!(v.tree.block_by_id(&fold_core::Id::parse(BID).unwrap()).is_some());
516+ // a block renamed since (§6.4) is still O: found by its id, merged
517+ // into, never duplicated
518+ let dir = tempfile::tempdir().unwrap();
519+ std::fs::write(dir.path().join("root.md"), format!("# A\n\n![[{}]]\n", BID)).unwrap();
520+ std::fs::write(dir.path().join("racfer~new.md"), format!("---\nid: {}\n---\n\n- [ ] t\n", BID)).unwrap();
521+ std::fs::write(dir.path().join(c), format!("---\nid: {}\n---\n\n- [x] t\n", BID)).unwrap();
522+ let mut v = Vault::open(dir.path()).unwrap();
523+ merge::merge_sync_conflicts(&mut v, false).unwrap();
524+ assert!(!dir.path().join("racfer~t.md").exists());
525+ assert_eq!(merge::conflict_pairs(&v).len(), 1, "{}", v.tree.files[0].text);
526+ // the copy of a foreign file that is gone is not ours either (§4.1)
527+ let dir = tempfile::tempdir().unwrap();
528+ std::fs::write(dir.path().join("root.md"), "# A\n").unwrap();
529+ let c = "readme.sync-conflict-20260912-100000-phone.md";
530+ std::fs::write(dir.path().join(c), "* [x] first\n").unwrap();
531+ let mut v = Vault::open(dir.path()).unwrap();
532+ let outcomes = merge::merge_sync_conflicts(&mut v, false).unwrap();
533+ assert!(outcomes.iter().any(|o| o.contains("left alone")), "{:?}", outcomes);
534+ assert!(dir.path().join(c).exists() && !dir.path().join("readme.md").exists());
535+}
536+
537+#[test]
538+fn merge_takes_a_block_that_came_in_with_its_copy_for_one() {
539+ // the vault as read before a sync brought a new block, its embed and
540+ // a copy of it in one go: X.md is a block on disk, not an ignored
541+ // file, and its copy is merged into it (§12.2)
542+ let dir = tempfile::tempdir().unwrap();
543+ std::fs::write(dir.path().join("root.md"), "# A\n").unwrap();
544+ let mut v = Vault::open(dir.path()).unwrap();
545+ std::fs::write(dir.path().join("root.md"), format!("# A\n\n![[{}]]\n", BID)).unwrap();
546+ std::fs::write(dir.path().join("racfer~t.md"), format!("---\nid: {}\n---\n\n- [ ] t\n", BID)).unwrap();
547+ let c = "racfer~t.sync-conflict-20260912-100000-phone.md";
548+ std::fs::write(dir.path().join(c), format!("---\nid: {}\n---\n\n- [x] t\n", BID)).unwrap();
549+ let outcomes = merge::merge_sync_conflicts(&mut v, false).unwrap();
550+ assert!(!dir.path().join(c).exists(), "{:?}", outcomes);
551+ assert_eq!(merge::conflict_pairs(&v).len(), 1, "{}", v.tree.files[0].text);
552+}
553+
554+#[test]
555+fn merge_places_insertions_by_their_neighbours() {
556+ // §12.4: a node present on one side only is an insertion, placed
557+ // relative to its matched neighbours — `new` sits between `a` and `b`
558+ let o = "# A\n\n- a\n- b\n- c\n";
559+ let t = "# A\n\n- a\n- new\n- b\n- c\n";
560+ let out = merge::merge_texts(o, t, "dev", "ts");
561+ assert_eq!(out.conflicts, 0, "{}", out.text);
562+ let p = |s: &str| out.text.find(s).unwrap();
563+ assert!(p("- a") < p("- new") && p("- new") < p("- b"), "{}", out.text);
564+ // one inserted after the text that follows its neighbour in T stays
565+ // after that text, so merging T in again changes nothing
566+ let o = "# P\n\n- a\n\nnote\n";
567+ let t = "# P\n\n- a\n\nnote\n\n- new\n";
568+ let out = merge::merge_texts(o, t, "dev", "ts");
569+ assert_eq!(out.conflicts, 0, "{}", out.text);
570+ assert_eq!(out.text, t);
571+}
572+
573+#[test]
574+fn merge_places_inserted_sections_by_their_neighbours() {
575+ // the same for a section: `B` sits between `A` and `C`, not last
576+ let o = "# A\n\n# C\n";
577+ let t = "# A\n\n# B\n\n# C\n";
578+ let out = merge::merge_texts(o, t, "dev", "ts");
579+ assert_eq!(out.conflicts, 0, "{}", out.text);
580+ let p = |s: &str| out.text.find(s).unwrap();
581+ assert!(p("# A") < p("# B") && p("# B") < p("# C"), "{}", out.text);
582+}
583+
584+#[test]
585+fn check_reports_embed_cycles() {
586+ // two blocks that embed each other: neither is reachable from root.md,
587+ // and §6.2 / §15.7 say a cycle is a diagnostic
588+ let a = "racfer-hattes-mislup-nodrys";
589+ let b = "dozzod-binwes-talsun-worbec";
590+ let c = "lacnum-walbyn-dirlyn-havtyp";
591+ let dir = tempfile::tempdir().unwrap();
592+ std::fs::write(dir.path().join("root.md"), "# A\n").unwrap();
593+ std::fs::write(
594+ dir.path().join("racfer~x.md"),
595+ format!("---\nid: {}\n---\n\n- x\n ![[{}]]\n", a, b),
596+ )
597+ .unwrap();
598+ std::fs::write(
599+ dir.path().join("dozzod~y.md"),
600+ format!("---\nid: {}\n---\n\n- y\n ![[{}]]\n ![[{}]]\n", b, a, c),
601+ )
602+ .unwrap();
603+ // c hangs off the cycle without being on it
604+ std::fs::write(dir.path().join("lacnum~z.md"), format!("---\nid: {}\n---\n\n- z\n", c)).unwrap();
605+ let v = Vault::open(dir.path()).unwrap();
606+ // every block loads and every embed resolves: the only fault is the cycle
607+ for id in [a, b, c] {
608+ let id = fold_core::Id::parse(id).unwrap();
609+ assert!(v.tree.block_by_id(&id).is_some(), "block {} not loaded", id);
610+ assert!(v.tree.embed_of(&id).is_some(), "embed of {} not parsed", id);
611+ }
612+ let diags = fold_core::check::check(&v);
613+ let msgs: Vec<&String> = diags.iter().map(|d| &d.message).collect();
614+ assert_eq!(
615+ diags.iter().filter(|d| d.message.contains("cycl")).count(),
616+ 2,
617+ "one cyclic-embed diagnostic per block on the cycle: {:?}",
618+ msgs
619+ );
620+ // the same blocks in a chain from root.md: no cycle
621+ std::fs::write(dir.path().join("root.md"), format!("# A\n\n![[{}]]\n", a)).unwrap();
622+ std::fs::write(
623+ dir.path().join("dozzod~y.md"),
624+ format!("---\nid: {}\n---\n\n- y\n ![[{}]]\n", b, c),
625+ )
626+ .unwrap();
627+ let v = Vault::open(dir.path()).unwrap();
628+ let diags = fold_core::check::check(&v);
629+ assert!(diags.is_empty(), "{:?}", diags.iter().map(|d| &d.message).collect::<Vec<_>>());
630+}
631+
632+#[test]
633+fn orphan_block_conflict_is_not_paired_with_an_unrelated_node() {
634+ // the block racfer~t.md is embedded nowhere: its conflict block has no
635+ // embed to follow, so it must not end up as the next sibling of some
636+ // unrelated node that 'keep theirs' would then overwrite (§12.4, §12.5)
637+ let dir = tempfile::tempdir().unwrap();
638+ std::fs::write(dir.path().join("root.md"), "# A\n\n- keep me\n").unwrap();
639+ std::fs::write(
640+ dir.path().join("racfer~t.md"),
641+ format!("---\nid: {}\n---\n\n- [ ] t\n", BID),
642+ )
643+ .unwrap();
644+ std::fs::write(
645+ dir.path().join("racfer~t.sync-conflict-20260912-100000-phone.md"),
646+ format!("---\nid: {}\n---\n\n- [x] t\n", BID),
647+ )
648+ .unwrap();
649+ let mut v = Vault::open(dir.path()).unwrap();
650+ merge::merge_sync_conflicts(&mut v, false).unwrap();
651+ let root = std::fs::read_to_string(dir.path().join("root.md")).unwrap();
652+ let pairs = merge::conflict_pairs(&v);
653+ for &(ours, _) in &pairs {
654+ assert_ne!(v.tree.node(ours).title, "keep me", "root.md:\n{}", root);
655+ }
656+ // it pairs with the block it conflicts with, and keep theirs lands there
657+ assert_eq!(pairs.len(), 1, "root.md:\n{}", root);
658+ let bid = fold_core::Id::parse(BID).unwrap();
659+ assert_eq!(Some(pairs[0].0), v.tree.block_by_id(&bid), "root.md:\n{}", root);
660+ merge::resolve_keep_theirs(&mut v, pairs[0].0, pairs[0].1).unwrap();
661+ assert!(std::fs::read_to_string(dir.path().join("root.md")).unwrap().contains("- keep me"));
662+ let block = std::fs::read_to_string(dir.path().join("racfer~t.md")).unwrap();
663+ assert!(block.contains("- [x] t"), "{}", block);
664+ assert!(fold_core::check::check(&v).is_empty());
665+ // a section-spelled orphan: heading embeds, still a pair (§4.7)
666+ let dir = tempfile::tempdir().unwrap();
667+ std::fs::write(dir.path().join("root.md"), "# A\n\n- keep me\n").unwrap();
668+ std::fs::write(dir.path().join("racfer~t.md"), format!("---\nid: {}\n---\n\n# T\n\nbody o\n", BID))
669+ .unwrap();
670+ std::fs::write(
671+ dir.path().join("racfer~t.sync-conflict-20260912-100000-phone.md"),
672+ format!("---\nid: {}\n---\n\n# T\n\nbody t\n", BID),
673+ )
674+ .unwrap();
675+ let mut v = Vault::open(dir.path()).unwrap();
676+ merge::merge_sync_conflicts(&mut v, false).unwrap();
677+ let root = std::fs::read_to_string(dir.path().join("root.md")).unwrap();
678+ assert!(root.starts_with(&format!("# A\n\n- keep me\n\n# ![[{}]]\n\n# ![[", BID)), "{}", root);
679+ let pairs = merge::conflict_pairs(&v);
680+ assert_eq!(pairs.len(), 1, "root.md:\n{}", root);
681+ assert_eq!(Some(pairs[0].0), v.tree.block_by_id(&bid), "root.md:\n{}", root);
682+ let diags = fold_core::check::check(&v);
683+ assert!(
684+ diags.iter().all(|d| d.message == "unresolved conflict block"),
685+ "{:?}",
686+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
687+ );
688+}
689+
690+#[test]
691+fn crlf_root_md_keeps_its_blank_lines_under_fix() {
692+ // check --fix lays root.md's top-level nodes out itself; a blank line
693+ // is blank whatever its line ending, so a loose list stays loose
694+ // (§4.2) and none is added after text that already ends in one (§3.3)
695+ for (before, after) in [
696+ ("- a\r\n\r\n- b\r\n", "- a\n\n- b\n"),
697+ ("intro\r\n\r\n- a\r\n", "intro\r\n\r\n- a\n"),
698+ ] {
699+ let dir = tempfile::tempdir().unwrap();
700+ std::fs::write(dir.path().join("root.md"), before).unwrap();
701+ let mut v = Vault::open(dir.path()).unwrap();
702+ fold_core::check::fix(&mut v).unwrap();
703+ let text = std::fs::read_to_string(dir.path().join("root.md")).unwrap();
704+ assert_eq!(text, after, "{:?}", before);
705+ }
706+}
707+
708+#[test]
709+fn sync_conflict_on_a_block_embedded_twice_pairs_where_it_is_shown() {
710+ // t is shown under X, where the walk from the root meets it first; its
711+ // second embed, in root.md under B, comes first in file order and reads
712+ // as broken (§6.2). The conflict block goes after t's own embed, so it
713+ // pairs with t (§12.4), not with the broken line
714+ let x = "dozzod-binwes-talsun-worbec";
715+ let dir = tempfile::tempdir().unwrap();
716+ let root = format!("# A\n\n![[{}]]\n\n# B\n\n![[{}]]\n", x, BID);
717+ std::fs::write(dir.path().join("root.md"), &root).unwrap();
718+ std::fs::write(
719+ dir.path().join("dozzod~x.md"),
720+ format!("---\nid: {}\n---\n\n- X\n ![[{}]]\n", x, BID),
721+ )
722+ .unwrap();
723+ std::fs::write(
724+ dir.path().join("racfer~t.md"),
725+ format!("---\nid: {}\n---\n\n- [ ] t\n", BID),
726+ )
727+ .unwrap();
728+ std::fs::write(
729+ dir.path().join("racfer~t.sync-conflict-20260912-100000-phone.md"),
730+ format!("---\nid: {}\n---\n\n- [x] t\n", BID),
731+ )
732+ .unwrap();
733+ let mut v = Vault::open(dir.path()).unwrap();
734+ merge::merge_sync_conflicts(&mut v, false).unwrap();
735+ let pairs = merge::conflict_pairs(&v);
736+ assert_eq!(pairs.len(), 1);
737+ let ours = v.tree.node(pairs[0].0).block.as_ref().and_then(|b| b.id.as_ref());
738+ assert_eq!(ours.map(|i| i.to_string()), Some(BID.to_string()));
739+ assert_eq!(std::fs::read_to_string(dir.path().join("root.md")).unwrap(), root);
740+}
741+
742+/// A vault merged with a phone's copy of its root.md: B's text and the
743+/// third task differ, so B and three each get a conflict copy (§12.4).
744+fn merged_with_copies() -> (tempfile::TempDir, Vault) {
745+ let dir = tempfile::tempdir().unwrap();
746+ std::fs::write(
747+ dir.path().join("root.md"),
748+ "# A\n\n## B\n\nours\n\n- [ ] one\n- [x] two\n\n## C\n\n- [ ] three\n",
749+ )
750+ .unwrap();
751+ std::fs::write(
752+ dir.path().join("root.sync-conflict-20260912-100000-phone.md"),
753+ "# A\n\n## B\n\ntheirs\n\n- [ ] one\n- [x] two\n\n## C\n\n- [x] three\n",
754+ )
755+ .unwrap();
756+ let mut v = Vault::open(dir.path()).unwrap();
757+ merge::merge_sync_conflicts(&mut v, false).unwrap();
758+ assert_eq!(merge::conflict_pairs(&v).len(), 2);
759+ (dir, v)
760+}
761+
762+#[test]
763+fn a_conflict_copy_s_tasks_are_left_out_of_the_counts_above_it() {
764+ // §3.5: a copy repeats the node before it, so its tasks would count
765+ // twice, the one the phone checked as open and done at once
766+ let (_dir, v) = merged_with_copies();
767+ let a = v.find_by_path(&["A".into()]).unwrap();
768+ assert_eq!(v.tree.task_counts(a), (2, 3));
769+ let c = v.find_by_path(&["A".into(), "C".into()]).unwrap();
770+ assert_eq!(v.tree.task_counts(c), (1, 1));
771+ // a copy counts its own
772+ let copy = merge::conflict_pairs(&v).into_iter().map(|(_, t)| t).find(|&t| v.tree.node(t).title == "B").unwrap();
773+ assert_eq!(v.tree.task_counts(copy), (1, 2));
774+}
775+
776+#[test]
777+fn clear_done_keeps_a_done_task_with_an_open_one_in_a_conflict_copy_below() {
778+ // counts leave copies out, clearing does not: the phone reopened e, and
779+ // its copy is not trashed with d (§8.5, §12.5)
780+ let dir = tempfile::tempdir().unwrap();
781+ std::fs::write(dir.path().join("root.md"), "# A\n\n- [x] d\n - [x] e\n").unwrap();
782+ std::fs::write(
783+ dir.path().join("root.sync-conflict-20260912-100000-phone.md"),
784+ "# A\n\n- [x] d\n - [ ] e\n",
785+ )
786+ .unwrap();
787+ let mut v = Vault::open(dir.path()).unwrap();
788+ merge::merge_sync_conflicts(&mut v, false).unwrap();
789+ let a = v.find_by_path(&["A".into()]).unwrap();
790+ fold_core::ops::clear_done(&mut v, a).unwrap();
791+ let text = std::fs::read_to_string(dir.path().join("root.md")).unwrap();
792+ assert!(text.contains("- [x] d\n"), "{}", text);
793+ assert!(text.contains("![["), "{}", text);
794+}
crates/fold-core/tests/ops.rs+999−8

This diff is too big to show here.

crates/fold-core/tests/ordering.rsnew file+934

This diff is too big to show here.

crates/fold-core/tests/resolve.rs+274

This diff is too big to show here.

crates/fold-core/tests/roundtrip.rs+288−27

This diff is too big to show here.

crates/fold-core/tests/undo.rsnew file+242

This diff is too big to show here.

crates/fold-ffi/Cargo.tomlnew file+28

This diff is too big to show here.

crates/fold-ffi/src/edit.rsnew file+563

This diff is too big to show here.

crates/fold-ffi/src/keys.rsnew file+72

This diff is too big to show here.

crates/fold-ffi/src/lib.rsnew file+1903

This diff is too big to show here.

crates/fold-ffi/src/tests.rsnew file+437

This diff is too big to show here.

crates/fold-ffi/uniffi-bindgen.rsnew file+3

This diff is too big to show here.

crates/fold-ffi/uniffi.tomlnew file+3

This diff is too big to show here.

crates/fold-tui/Cargo.toml+138−5

This diff is too big to show here.

crates/fold-tui/queries/README.mdnew file+38

This diff is too big to show here.

crates/fold-tui/queries/ada.scmnew file+196

This diff is too big to show here.

crates/fold-tui/queries/applesoft.scmnew file+124

This diff is too big to show here.

crates/fold-tui/queries/caddy.scmnew file+52

This diff is too big to show here.

crates/fold-tui/queries/cfml.scmnew file+400

This diff is too big to show here.

crates/fold-tui/queries/cfquery.scmnew file+383

This diff is too big to show here.

crates/fold-tui/queries/cfscript.scmnew file+408

This diff is too big to show here.

crates/fold-tui/queries/d.scmnew file+259

This diff is too big to show here.

crates/fold-tui/queries/dafny.scmnew file+99

This diff is too big to show here.

crates/fold-tui/queries/dm.scmnew file+147

This diff is too big to show here.

crates/fold-tui/queries/fsharp-signature.scmnew file+298

This diff is too big to show here.

crates/fold-tui/queries/gomod.scmnew file+20

This diff is too big to show here.

crates/fold-tui/queries/gwbasic.scmnew file+12

This diff is too big to show here.

crates/fold-tui/queries/ink.scmnew file+30

This diff is too big to show here.

crates/fold-tui/queries/integerbasic.scmnew file+117

This diff is too big to show here.

crates/fold-tui/queries/julia.scmnew file+325

This diff is too big to show here.

crates/fold-tui/queries/matlab.scmnew file+157

This diff is too big to show here.

crates/fold-tui/queries/merlin6502.scmnew file+184

This diff is too big to show here.

crates/fold-tui/queries/msbasic2.scmnew file+11

This diff is too big to show here.

crates/fold-tui/queries/newick.scmnew file+8

This diff is too big to show here.

crates/fold-tui/queries/nginx.scmnew file+59

This diff is too big to show here.

crates/fold-tui/queries/pascal.scmnew file+345

This diff is too big to show here.

crates/fold-tui/queries/pgn.scmnew file+29

This diff is too big to show here.

crates/fold-tui/queries/plpgsql.scmnew file+153

This diff is too big to show here.

crates/fold-tui/queries/postgres.scmnew file+358

This diff is too big to show here.

crates/fold-tui/queries/prisma.scmnew file+39

This diff is too big to show here.

crates/fold-tui/queries/proto.scmnew file+190

This diff is too big to show here.

crates/fold-tui/queries/qbasic.scmnew file+19

This diff is too big to show here.

crates/fold-tui/queries/systemverilog.scmnew file+325

This diff is too big to show here.

crates/fold-tui/queries/templ.scmnew file+51

This diff is too big to show here.

crates/fold-tui/queries/vue.scmnew file+162

This diff is too big to show here.

crates/fold-tui/src/app.rs+2643−1947

This diff is too big to show here.

crates/fold-tui/src/app/action.rsnew file+324

This diff is too big to show here.

crates/fold-tui/src/app/editor/helix.rsnew file+593

This diff is too big to show here.

crates/fold-tui/src/app/editor/mod.rsnew file+1584

This diff is too big to show here.

crates/fold-tui/src/app/editor/normal.rsnew file+312

This diff is too big to show here.

crates/fold-tui/src/app/editor/vim.rsnew file+943

This diff is too big to show here.

crates/fold-tui/src/app/grammars.rsnew file+154

This diff is too big to show here.

crates/fold-tui/src/app/highlight.rsnew file+188

This diff is too big to show here.

crates/fold-tui/src/app/markdown.rsnew file+265

This diff is too big to show here.

crates/fold-tui/src/app/mouse.rsnew file+457

This diff is too big to show here.

crates/fold-tui/src/app/ui.rsnew file+1519

This diff is too big to show here.

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

This diff is too big to show here.

crates/fold-tui/src/app/wrap.rsnew file+189

This diff is too big to show here.

crates/fold-tui/src/lib.rs+3−2

This diff is too big to show here.

crates/fold-tui/tests/conflict_watch.rs+144−3

This diff is too big to show here.

crates/fold-tui/tests/editor.rsnew file+1495

This diff is too big to show here.

crates/fold-tui/tests/fixes.rsnew file+628

This diff is too big to show here.

crates/fold-tui/tests/mouse.rs+912−80

This diff is too big to show here.

crates/fold-tui/tests/mouse_all.rsdeleted−174

This diff is too big to show here.

crates/fold-tui/tests/tui.rs+89−3

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

crates/fold-tui/tests/wrap.rsnew file+356

This diff is too big to show here.

docs/icon.pngnew file

Binary file not shown.

flake.locknew file+27

This diff is too big to show here.

flake.nixnew file+75

This diff is too big to show here.

target/.rustc_info.jsondeleted−1

This diff is too big to show here.

target/CACHEDIR.TAGdeleted−3

This diff is too big to show here.

target/debug/.cargo-artifact-lockdeleted

No changes to the content.

target/debug/.cargo-build-lockdeleted

No changes to the content.

target/debug/.cargo-lockdeleted

No changes to the content.

target/debug/.fingerprint/anyhow-99f87dcb46c432ce/dep-lib-anyhowdeleted

Binary file not shown.

target/debug/.fingerprint/anyhow-99f87dcb46c432ce/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/anyhow-99f87dcb46c432ce/lib-anyhowdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/anyhow-99f87dcb46c432ce/lib-anyhow.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/anyhow-9f4141b86fb623e3/run-build-script-build-script-builddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/anyhow-9f4141b86fb623e3/run-build-script-build-script-build.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/anyhow-e271561cf353a746/build-script-build-script-builddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/anyhow-e271561cf353a746/build-script-build-script-build.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/anyhow-e271561cf353a746/dep-build-script-build-script-builddeleted

Binary file not shown.

target/debug/.fingerprint/anyhow-e271561cf353a746/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/arrayvec-a65658d7c78abafa/dep-lib-arrayvecdeleted

Binary file not shown.

target/debug/.fingerprint/arrayvec-a65658d7c78abafa/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/arrayvec-a65658d7c78abafa/lib-arrayvecdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/arrayvec-a65658d7c78abafa/lib-arrayvec.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/autocfg-bc1dc19eff92fee9/dep-lib-autocfgdeleted

Binary file not shown.

target/debug/.fingerprint/autocfg-bc1dc19eff92fee9/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/autocfg-bc1dc19eff92fee9/lib-autocfgdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/autocfg-bc1dc19eff92fee9/lib-autocfg.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/bit-set-3941883ce9e954cc/dep-lib-bit_setdeleted

Binary file not shown.

target/debug/.fingerprint/bit-set-3941883ce9e954cc/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/bit-set-3941883ce9e954cc/lib-bit_setdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/bit-set-3941883ce9e954cc/lib-bit_set.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/bit-vec-c46a512d79021dae/dep-lib-bit_vecdeleted

Binary file not shown.

target/debug/.fingerprint/bit-vec-c46a512d79021dae/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/bit-vec-c46a512d79021dae/lib-bit_vecdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/bit-vec-c46a512d79021dae/lib-bit_vec.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/bitflags-0aef64864726e470/dep-lib-bitflagsdeleted

Binary file not shown.

target/debug/.fingerprint/bitflags-0aef64864726e470/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/bitflags-0aef64864726e470/lib-bitflagsdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/bitflags-0aef64864726e470/lib-bitflags.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/blake3-83205a5d53645d81/build-script-build-script-builddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/blake3-83205a5d53645d81/build-script-build-script-build.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/blake3-83205a5d53645d81/dep-build-script-build-script-builddeleted

Binary file not shown.

target/debug/.fingerprint/blake3-83205a5d53645d81/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/blake3-a9908e5bb688d4e7/run-build-script-build-script-builddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/blake3-a9908e5bb688d4e7/run-build-script-build-script-build.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/blake3-f46fd160759017ba/dep-lib-blake3deleted

Binary file not shown.

target/debug/.fingerprint/blake3-f46fd160759017ba/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/blake3-f46fd160759017ba/lib-blake3deleted−1

This diff is too big to show here.

target/debug/.fingerprint/blake3-f46fd160759017ba/lib-blake3.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/cc-8e3410aecb4a0c7b/dep-lib-ccdeleted

Binary file not shown.

target/debug/.fingerprint/cc-8e3410aecb4a0c7b/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/cc-8e3410aecb4a0c7b/lib-ccdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/cc-8e3410aecb4a0c7b/lib-cc.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/cfg-if-de76fd6d6c5730e4/dep-lib-cfg_ifdeleted

Binary file not shown.

target/debug/.fingerprint/cfg-if-de76fd6d6c5730e4/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/cfg-if-de76fd6d6c5730e4/lib-cfg_ifdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/cfg-if-de76fd6d6c5730e4/lib-cfg_if.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/constant_time_eq-fdb5336b45adb589/dep-lib-constant_time_eqdeleted

Binary file not shown.

target/debug/.fingerprint/constant_time_eq-fdb5336b45adb589/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/constant_time_eq-fdb5336b45adb589/lib-constant_time_eqdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/constant_time_eq-fdb5336b45adb589/lib-constant_time_eq.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/cpufeatures-e4a310f8b3b6f725/dep-lib-cpufeaturesdeleted

Binary file not shown.

target/debug/.fingerprint/cpufeatures-e4a310f8b3b6f725/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/cpufeatures-e4a310f8b3b6f725/lib-cpufeaturesdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/cpufeatures-e4a310f8b3b6f725/lib-cpufeatures.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/directories-b0d5d95843c3fb66/dep-lib-directoriesdeleted

Binary file not shown.

target/debug/.fingerprint/directories-b0d5d95843c3fb66/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/directories-b0d5d95843c3fb66/lib-directoriesdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/directories-b0d5d95843c3fb66/lib-directories.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/dirs-sys-6b2d7e2d17beea4e/dep-lib-dirs_sysdeleted

Binary file not shown.

target/debug/.fingerprint/dirs-sys-6b2d7e2d17beea4e/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/dirs-sys-6b2d7e2d17beea4e/lib-dirs_sysdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/dirs-sys-6b2d7e2d17beea4e/lib-dirs_sys.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/equivalent-65b26217b03f15f5/dep-lib-equivalentdeleted

Binary file not shown.

target/debug/.fingerprint/equivalent-65b26217b03f15f5/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/equivalent-65b26217b03f15f5/lib-equivalentdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/equivalent-65b26217b03f15f5/lib-equivalent.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/fastrand-afd1857ca89a7afc/dep-lib-fastranddeleted

Binary file not shown.

target/debug/.fingerprint/fastrand-afd1857ca89a7afc/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/fastrand-afd1857ca89a7afc/lib-fastranddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/fastrand-afd1857ca89a7afc/lib-fastrand.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/find-msvc-tools-71a62bc007db7fa9/dep-lib-find_msvc_toolsdeleted

Binary file not shown.

target/debug/.fingerprint/find-msvc-tools-71a62bc007db7fa9/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/find-msvc-tools-71a62bc007db7fa9/lib-find_msvc_toolsdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/find-msvc-tools-71a62bc007db7fa9/lib-find_msvc_tools.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/fnv-9fde446a8c1365fc/dep-lib-fnvdeleted

Binary file not shown.

target/debug/.fingerprint/fnv-9fde446a8c1365fc/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/fnv-9fde446a8c1365fc/lib-fnvdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/fnv-9fde446a8c1365fc/lib-fnv.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/getrandom-0eaf42379121fb38/dep-lib-getrandomdeleted

Binary file not shown.

target/debug/.fingerprint/getrandom-0eaf42379121fb38/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/getrandom-0eaf42379121fb38/lib-getrandomdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/getrandom-0eaf42379121fb38/lib-getrandom.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/getrandom-106dc7dc80dfd512/run-build-script-build-script-builddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/getrandom-106dc7dc80dfd512/run-build-script-build-script-build.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/getrandom-3c9ae9aa3ee1b316/build-script-build-script-builddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/getrandom-3c9ae9aa3ee1b316/build-script-build-script-build.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/getrandom-3c9ae9aa3ee1b316/dep-build-script-build-script-builddeleted

Binary file not shown.

target/debug/.fingerprint/getrandom-3c9ae9aa3ee1b316/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/getrandom-5863645493ec0e6c/build-script-build-script-builddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/getrandom-5863645493ec0e6c/build-script-build-script-build.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/getrandom-5863645493ec0e6c/dep-build-script-build-script-builddeleted

Binary file not shown.

target/debug/.fingerprint/getrandom-5863645493ec0e6c/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/getrandom-85f2e85dbc3c82b3/run-build-script-build-script-builddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/getrandom-85f2e85dbc3c82b3/run-build-script-build-script-build.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/getrandom-b67675fe179fd3f5/run-build-script-build-script-builddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/getrandom-b67675fe179fd3f5/run-build-script-build-script-build.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/getrandom-b6fea70e6916ddac/dep-lib-getrandomdeleted

Binary file not shown.

target/debug/.fingerprint/getrandom-b6fea70e6916ddac/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/getrandom-b6fea70e6916ddac/lib-getrandomdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/getrandom-b6fea70e6916ddac/lib-getrandom.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/getrandom-c3baaaae3fc12015/build-script-build-script-builddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/getrandom-c3baaaae3fc12015/build-script-build-script-build.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/getrandom-c3baaaae3fc12015/dep-build-script-build-script-builddeleted

Binary file not shown.

target/debug/.fingerprint/getrandom-c3baaaae3fc12015/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/getrandom-d98d449a36bafbe0/dep-lib-getrandomdeleted

Binary file not shown.

target/debug/.fingerprint/getrandom-d98d449a36bafbe0/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/getrandom-d98d449a36bafbe0/lib-getrandomdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/getrandom-d98d449a36bafbe0/lib-getrandom.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/hashbrown-ac220fc3bcae53c0/dep-lib-hashbrowndeleted

Binary file not shown.

target/debug/.fingerprint/hashbrown-ac220fc3bcae53c0/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/hashbrown-ac220fc3bcae53c0/lib-hashbrowndeleted−1

This diff is too big to show here.

target/debug/.fingerprint/hashbrown-ac220fc3bcae53c0/lib-hashbrown.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/indexmap-047470861a2cca77/dep-lib-indexmapdeleted

Binary file not shown.

target/debug/.fingerprint/indexmap-047470861a2cca77/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/indexmap-047470861a2cca77/lib-indexmapdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/indexmap-047470861a2cca77/lib-indexmap.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/jiff-68f68fe57a2700b2/dep-lib-jiffdeleted

Binary file not shown.

target/debug/.fingerprint/jiff-68f68fe57a2700b2/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/jiff-68f68fe57a2700b2/lib-jiffdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/jiff-68f68fe57a2700b2/lib-jiff.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/jiff-core-787542659066c338/dep-lib-jiff_coredeleted

Binary file not shown.

target/debug/.fingerprint/jiff-core-787542659066c338/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/jiff-core-787542659066c338/lib-jiff_coredeleted−1

This diff is too big to show here.

target/debug/.fingerprint/jiff-core-787542659066c338/lib-jiff_core.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/libc-b783701222a4d8e3/dep-lib-libcdeleted

Binary file not shown.

target/debug/.fingerprint/libc-b783701222a4d8e3/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/libc-b783701222a4d8e3/lib-libcdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/libc-b783701222a4d8e3/lib-libc.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/libc-d9611a1b0ec3f5c1/build-script-build-script-builddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/libc-d9611a1b0ec3f5c1/build-script-build-script-build.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/libc-d9611a1b0ec3f5c1/dep-build-script-build-script-builddeleted

Binary file not shown.

target/debug/.fingerprint/libc-d9611a1b0ec3f5c1/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/libc-fd26b7e10e2f01da/run-build-script-build-script-builddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/libc-fd26b7e10e2f01da/run-build-script-build-script-build.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/linux-raw-sys-748a127bddffab6c/dep-lib-linux_raw_sysdeleted

Binary file not shown.

target/debug/.fingerprint/linux-raw-sys-748a127bddffab6c/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/linux-raw-sys-748a127bddffab6c/lib-linux_raw_sysdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/linux-raw-sys-748a127bddffab6c/lib-linux_raw_sys.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/notes-core-8f1ce0f90d61e5a7/dep-lib-notes_coredeleted

Binary file not shown.

target/debug/.fingerprint/notes-core-8f1ce0f90d61e5a7/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/notes-core-8f1ce0f90d61e5a7/lib-notes_coredeleted−1

This diff is too big to show here.

target/debug/.fingerprint/notes-core-8f1ce0f90d61e5a7/lib-notes_core.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/num-traits-0b5ce95e951c04fe/dep-lib-num_traitsdeleted

Binary file not shown.

target/debug/.fingerprint/num-traits-0b5ce95e951c04fe/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/num-traits-0b5ce95e951c04fe/lib-num_traitsdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/num-traits-0b5ce95e951c04fe/lib-num_traits.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/num-traits-900b9f55b6b9e772/run-build-script-build-script-builddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/num-traits-900b9f55b6b9e772/run-build-script-build-script-build.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/num-traits-91041b392e74a985/build-script-build-script-builddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/num-traits-91041b392e74a985/build-script-build-script-build.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/num-traits-91041b392e74a985/dep-build-script-build-script-builddeleted

Binary file not shown.

target/debug/.fingerprint/num-traits-91041b392e74a985/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/once_cell-d72f51d4d3ff5e42/dep-lib-once_celldeleted

Binary file not shown.

target/debug/.fingerprint/once_cell-d72f51d4d3ff5e42/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/once_cell-d72f51d4d3ff5e42/lib-once_celldeleted−1

This diff is too big to show here.

target/debug/.fingerprint/once_cell-d72f51d4d3ff5e42/lib-once_cell.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/option-ext-f95e8c5597ca1435/dep-lib-option_extdeleted

Binary file not shown.

target/debug/.fingerprint/option-ext-f95e8c5597ca1435/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/option-ext-f95e8c5597ca1435/lib-option_extdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/option-ext-f95e8c5597ca1435/lib-option_ext.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/ppv-lite86-4c2cb8748d60e5c7/dep-lib-ppv_lite86deleted

Binary file not shown.

target/debug/.fingerprint/ppv-lite86-4c2cb8748d60e5c7/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/ppv-lite86-4c2cb8748d60e5c7/lib-ppv_lite86deleted−1

This diff is too big to show here.

target/debug/.fingerprint/ppv-lite86-4c2cb8748d60e5c7/lib-ppv_lite86.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/proc-macro2-6b336f0ddb28b27a/dep-lib-proc_macro2deleted

Binary file not shown.

target/debug/.fingerprint/proc-macro2-6b336f0ddb28b27a/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/proc-macro2-6b336f0ddb28b27a/lib-proc_macro2deleted−1

This diff is too big to show here.

target/debug/.fingerprint/proc-macro2-6b336f0ddb28b27a/lib-proc_macro2.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/proc-macro2-9816b18a9c79987d/run-build-script-build-script-builddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/proc-macro2-9816b18a9c79987d/run-build-script-build-script-build.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/proc-macro2-bcbb7de91551b4e2/build-script-build-script-builddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/proc-macro2-bcbb7de91551b4e2/build-script-build-script-build.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/proc-macro2-bcbb7de91551b4e2/dep-build-script-build-script-builddeleted

Binary file not shown.

target/debug/.fingerprint/proc-macro2-bcbb7de91551b4e2/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/proptest-c71b6fc3656545fd/dep-lib-proptestdeleted

Binary file not shown.

target/debug/.fingerprint/proptest-c71b6fc3656545fd/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/proptest-c71b6fc3656545fd/lib-proptestdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/proptest-c71b6fc3656545fd/lib-proptest.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/quick-error-8abe3b1ca8b7a8b9/dep-lib-quick_errordeleted

Binary file not shown.

target/debug/.fingerprint/quick-error-8abe3b1ca8b7a8b9/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/quick-error-8abe3b1ca8b7a8b9/lib-quick_errordeleted−1

This diff is too big to show here.

target/debug/.fingerprint/quick-error-8abe3b1ca8b7a8b9/lib-quick_error.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/quote-027634fac3d1dd29/dep-lib-quotedeleted

Binary file not shown.

target/debug/.fingerprint/quote-027634fac3d1dd29/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/quote-027634fac3d1dd29/lib-quotedeleted−1

This diff is too big to show here.

target/debug/.fingerprint/quote-027634fac3d1dd29/lib-quote.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/quote-0696611ec86f8910/build-script-build-script-builddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/quote-0696611ec86f8910/build-script-build-script-build.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/quote-0696611ec86f8910/dep-build-script-build-script-builddeleted

Binary file not shown.

target/debug/.fingerprint/quote-0696611ec86f8910/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/quote-de3ef8ebb8667ca9/run-build-script-build-script-builddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/quote-de3ef8ebb8667ca9/run-build-script-build-script-build.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/rand-41f5c89bf90a2c40/dep-lib-randdeleted

Binary file not shown.

target/debug/.fingerprint/rand-41f5c89bf90a2c40/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/rand-41f5c89bf90a2c40/lib-randdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/rand-41f5c89bf90a2c40/lib-rand.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/rand_chacha-a13f4556a4cb172c/dep-lib-rand_chachadeleted

Binary file not shown.

target/debug/.fingerprint/rand_chacha-a13f4556a4cb172c/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/rand_chacha-a13f4556a4cb172c/lib-rand_chachadeleted−1

This diff is too big to show here.

target/debug/.fingerprint/rand_chacha-a13f4556a4cb172c/lib-rand_chacha.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/rand_core-6b2fc663fdff125a/dep-lib-rand_coredeleted

Binary file not shown.

target/debug/.fingerprint/rand_core-6b2fc663fdff125a/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/rand_core-6b2fc663fdff125a/lib-rand_coredeleted−1

This diff is too big to show here.

target/debug/.fingerprint/rand_core-6b2fc663fdff125a/lib-rand_core.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/rand_xorshift-85f984f4f18ce3d5/dep-lib-rand_xorshiftdeleted

Binary file not shown.

target/debug/.fingerprint/rand_xorshift-85f984f4f18ce3d5/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/rand_xorshift-85f984f4f18ce3d5/lib-rand_xorshiftdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/rand_xorshift-85f984f4f18ce3d5/lib-rand_xorshift.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/regex-syntax-7e413990257ae280/dep-lib-regex_syntaxdeleted

Binary file not shown.

target/debug/.fingerprint/regex-syntax-7e413990257ae280/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/regex-syntax-7e413990257ae280/lib-regex_syntaxdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/regex-syntax-7e413990257ae280/lib-regex_syntax.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/rustix-038e468382ead565/run-build-script-build-script-builddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/rustix-038e468382ead565/run-build-script-build-script-build.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/rustix-ebe468a694014bd7/dep-lib-rustixdeleted

Binary file not shown.

target/debug/.fingerprint/rustix-ebe468a694014bd7/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/rustix-ebe468a694014bd7/lib-rustixdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/rustix-ebe468a694014bd7/lib-rustix.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/rustix-f2e39f54096cf2ef/build-script-build-script-builddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/rustix-f2e39f54096cf2ef/build-script-build-script-build.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/rustix-f2e39f54096cf2ef/dep-build-script-build-script-builddeleted

Binary file not shown.

target/debug/.fingerprint/rustix-f2e39f54096cf2ef/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/rusty-fork-c05f13651af4f8b1/dep-lib-rusty_forkdeleted

Binary file not shown.

target/debug/.fingerprint/rusty-fork-c05f13651af4f8b1/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/rusty-fork-c05f13651af4f8b1/lib-rusty_forkdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/rusty-fork-c05f13651af4f8b1/lib-rusty_fork.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/shlex-eafb49743ebdab94/dep-lib-shlexdeleted

Binary file not shown.

target/debug/.fingerprint/shlex-eafb49743ebdab94/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/shlex-eafb49743ebdab94/lib-shlexdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/shlex-eafb49743ebdab94/lib-shlex.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/similar-dd62e180fdd9fd69/dep-lib-similardeleted

Binary file not shown.

target/debug/.fingerprint/similar-dd62e180fdd9fd69/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/similar-dd62e180fdd9fd69/lib-similardeleted−1

This diff is too big to show here.

target/debug/.fingerprint/similar-dd62e180fdd9fd69/lib-similar.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/syn-efaac48046ba42aa/dep-lib-syndeleted

Binary file not shown.

target/debug/.fingerprint/syn-efaac48046ba42aa/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/syn-efaac48046ba42aa/lib-syndeleted−1

This diff is too big to show here.

target/debug/.fingerprint/syn-efaac48046ba42aa/lib-syn.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/tempfile-a08e3a6e73f18dc2/dep-lib-tempfiledeleted

Binary file not shown.

target/debug/.fingerprint/tempfile-a08e3a6e73f18dc2/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/tempfile-a08e3a6e73f18dc2/lib-tempfiledeleted−1

This diff is too big to show here.

target/debug/.fingerprint/tempfile-a08e3a6e73f18dc2/lib-tempfile.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/thiserror-3c068656744c89de/run-build-script-build-script-builddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/thiserror-3c068656744c89de/run-build-script-build-script-build.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/thiserror-6616919d71b1f055/build-script-build-script-builddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/thiserror-6616919d71b1f055/build-script-build-script-build.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/thiserror-6616919d71b1f055/dep-build-script-build-script-builddeleted

Binary file not shown.

target/debug/.fingerprint/thiserror-6616919d71b1f055/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/thiserror-d582988ee49bfd38/dep-lib-thiserrordeleted

Binary file not shown.

target/debug/.fingerprint/thiserror-d582988ee49bfd38/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/thiserror-d582988ee49bfd38/lib-thiserrordeleted−1

This diff is too big to show here.

target/debug/.fingerprint/thiserror-d582988ee49bfd38/lib-thiserror.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/thiserror-impl-054c82e8cb318cbf/dep-lib-thiserror_impldeleted

Binary file not shown.

target/debug/.fingerprint/thiserror-impl-054c82e8cb318cbf/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/thiserror-impl-054c82e8cb318cbf/lib-thiserror_impldeleted−1

This diff is too big to show here.

target/debug/.fingerprint/thiserror-impl-054c82e8cb318cbf/lib-thiserror_impl.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/tinyvec-b3705465be29f173/dep-lib-tinyvecdeleted

Binary file not shown.

target/debug/.fingerprint/tinyvec-b3705465be29f173/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/tinyvec-b3705465be29f173/lib-tinyvecdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/tinyvec-b3705465be29f173/lib-tinyvec.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/tinyvec_macros-b3c8ffa612202339/dep-lib-tinyvec_macrosdeleted

Binary file not shown.

target/debug/.fingerprint/tinyvec_macros-b3c8ffa612202339/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/tinyvec_macros-b3c8ffa612202339/lib-tinyvec_macrosdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/tinyvec_macros-b3c8ffa612202339/lib-tinyvec_macros.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/unarray-1317844f3e0f5c07/dep-lib-unarraydeleted

Binary file not shown.

target/debug/.fingerprint/unarray-1317844f3e0f5c07/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/unarray-1317844f3e0f5c07/lib-unarraydeleted−1

This diff is too big to show here.

target/debug/.fingerprint/unarray-1317844f3e0f5c07/lib-unarray.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/unicode-ident-07077bdd941af1f1/dep-lib-unicode_identdeleted

Binary file not shown.

target/debug/.fingerprint/unicode-ident-07077bdd941af1f1/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/unicode-ident-07077bdd941af1f1/lib-unicode_identdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/unicode-ident-07077bdd941af1f1/lib-unicode_ident.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/unicode-normalization-5a956f8b1f85d8dd/dep-lib-unicode_normalizationdeleted

Binary file not shown.

target/debug/.fingerprint/unicode-normalization-5a956f8b1f85d8dd/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/unicode-normalization-5a956f8b1f85d8dd/lib-unicode_normalizationdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/unicode-normalization-5a956f8b1f85d8dd/lib-unicode_normalization.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/wait-timeout-6586e67c7f2c4a6f/dep-lib-wait_timeoutdeleted

Binary file not shown.

target/debug/.fingerprint/wait-timeout-6586e67c7f2c4a6f/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/wait-timeout-6586e67c7f2c4a6f/lib-wait_timeoutdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/wait-timeout-6586e67c7f2c4a6f/lib-wait_timeout.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/zerocopy-079dd2896db74193/build-script-build-script-builddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/zerocopy-079dd2896db74193/build-script-build-script-build.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/zerocopy-079dd2896db74193/dep-build-script-build-script-builddeleted

Binary file not shown.

target/debug/.fingerprint/zerocopy-079dd2896db74193/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/zerocopy-ecaaca3561f2eb29/dep-lib-zerocopydeleted

Binary file not shown.

target/debug/.fingerprint/zerocopy-ecaaca3561f2eb29/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/.fingerprint/zerocopy-ecaaca3561f2eb29/lib-zerocopydeleted−1

This diff is too big to show here.

target/debug/.fingerprint/zerocopy-ecaaca3561f2eb29/lib-zerocopy.jsondeleted−1

This diff is too big to show here.

target/debug/.fingerprint/zerocopy-ed7b5ac9e96d0173/run-build-script-build-script-builddeleted−1

This diff is too big to show here.

target/debug/.fingerprint/zerocopy-ed7b5ac9e96d0173/run-build-script-build-script-build.jsondeleted−1

This diff is too big to show here.

target/debug/build/anyhow-9f4141b86fb623e3/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/build/anyhow-9f4141b86fb623e3/outputdeleted−7

This diff is too big to show here.

target/debug/build/anyhow-9f4141b86fb623e3/root-outputdeleted−1

This diff is too big to show here.

target/debug/build/anyhow-9f4141b86fb623e3/stderrdeleted

No changes to the content.

target/debug/build/anyhow-e271561cf353a746/build-script-builddeleted

Binary file not shown.

target/debug/build/anyhow-e271561cf353a746/build_script_build-e271561cf353a746deleted

Binary file not shown.

target/debug/build/anyhow-e271561cf353a746/build_script_build-e271561cf353a746.ddeleted−5

This diff is too big to show here.

target/debug/build/blake3-83205a5d53645d81/build_script_build-83205a5d53645d81.ddeleted−5

This diff is too big to show here.

target/debug/build/blake3-a9908e5bb688d4e7/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/build/blake3-a9908e5bb688d4e7/out/b8423798394d5395-blake3_avx2_x86-64_unix.odeleted

Binary file not shown.

target/debug/build/blake3-a9908e5bb688d4e7/out/b8423798394d5395-blake3_avx512_x86-64_unix.odeleted

Binary file not shown.

target/debug/build/blake3-a9908e5bb688d4e7/out/b8423798394d5395-blake3_sse2_x86-64_unix.odeleted

Binary file not shown.

target/debug/build/blake3-a9908e5bb688d4e7/out/b8423798394d5395-blake3_sse41_x86-64_unix.odeleted

Binary file not shown.

target/debug/build/blake3-a9908e5bb688d4e7/out/flag_checkdeleted

Binary file not shown.

target/debug/build/blake3-a9908e5bb688d4e7/out/flag_check.cdeleted−1

This diff is too big to show here.

target/debug/build/blake3-a9908e5bb688d4e7/out/libblake3_avx512_assembly.adeleted

Binary file not shown.

target/debug/build/blake3-a9908e5bb688d4e7/out/libblake3_sse2_sse41_avx2_assembly.adeleted

Binary file not shown.

target/debug/build/blake3-a9908e5bb688d4e7/outputdeleted−165

This diff is too big to show here.

target/debug/build/blake3-a9908e5bb688d4e7/root-outputdeleted−1

This diff is too big to show here.

target/debug/build/blake3-a9908e5bb688d4e7/stderrdeleted

No changes to the content.

target/debug/build/getrandom-106dc7dc80dfd512/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/build/getrandom-106dc7dc80dfd512/outputdeleted−1

This diff is too big to show here.

target/debug/build/getrandom-106dc7dc80dfd512/root-outputdeleted−1

This diff is too big to show here.

target/debug/build/getrandom-106dc7dc80dfd512/stderrdeleted

No changes to the content.

target/debug/build/getrandom-3c9ae9aa3ee1b316/build-script-builddeleted

Binary file not shown.

target/debug/build/getrandom-3c9ae9aa3ee1b316/build_script_build-3c9ae9aa3ee1b316deleted

Binary file not shown.

target/debug/build/getrandom-3c9ae9aa3ee1b316/build_script_build-3c9ae9aa3ee1b316.ddeleted−5

This diff is too big to show here.

target/debug/build/getrandom-5863645493ec0e6c/build-script-builddeleted

Binary file not shown.

target/debug/build/getrandom-5863645493ec0e6c/build_script_build-5863645493ec0e6cdeleted

Binary file not shown.

target/debug/build/getrandom-5863645493ec0e6c/build_script_build-5863645493ec0e6c.ddeleted−5

This diff is too big to show here.

target/debug/build/getrandom-85f2e85dbc3c82b3/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/build/getrandom-85f2e85dbc3c82b3/outputdeleted−1

This diff is too big to show here.

target/debug/build/getrandom-85f2e85dbc3c82b3/root-outputdeleted−1

This diff is too big to show here.

target/debug/build/getrandom-85f2e85dbc3c82b3/stderrdeleted

No changes to the content.

target/debug/build/getrandom-b67675fe179fd3f5/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/build/getrandom-b67675fe179fd3f5/outputdeleted−1

This diff is too big to show here.

target/debug/build/getrandom-b67675fe179fd3f5/root-outputdeleted−1

This diff is too big to show here.

target/debug/build/getrandom-b67675fe179fd3f5/stderrdeleted

No changes to the content.

target/debug/build/getrandom-c3baaaae3fc12015/build-script-builddeleted

Binary file not shown.

target/debug/build/getrandom-c3baaaae3fc12015/build_script_build-c3baaaae3fc12015deleted

Binary file not shown.

target/debug/build/getrandom-c3baaaae3fc12015/build_script_build-c3baaaae3fc12015.ddeleted−5

This diff is too big to show here.

target/debug/build/libc-d9611a1b0ec3f5c1/build-script-builddeleted

Binary file not shown.

target/debug/build/libc-d9611a1b0ec3f5c1/build_script_build-d9611a1b0ec3f5c1deleted

Binary file not shown.

target/debug/build/libc-d9611a1b0ec3f5c1/build_script_build-d9611a1b0ec3f5c1.ddeleted−5

This diff is too big to show here.

target/debug/build/libc-fd26b7e10e2f01da/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/build/libc-fd26b7e10e2f01da/outputdeleted−25

This diff is too big to show here.

target/debug/build/libc-fd26b7e10e2f01da/root-outputdeleted−1

This diff is too big to show here.

target/debug/build/libc-fd26b7e10e2f01da/stderrdeleted

No changes to the content.

target/debug/build/num-traits-900b9f55b6b9e772/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/build/num-traits-900b9f55b6b9e772/outputdeleted−3

This diff is too big to show here.

target/debug/build/num-traits-900b9f55b6b9e772/root-outputdeleted−1

This diff is too big to show here.

target/debug/build/num-traits-900b9f55b6b9e772/stderrdeleted

No changes to the content.

target/debug/build/num-traits-91041b392e74a985/build-script-builddeleted

Binary file not shown.

target/debug/build/num-traits-91041b392e74a985/build_script_build-91041b392e74a985deleted

Binary file not shown.

target/debug/build/num-traits-91041b392e74a985/build_script_build-91041b392e74a985.ddeleted−5

This diff is too big to show here.

target/debug/build/proc-macro2-9816b18a9c79987d/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/build/proc-macro2-9816b18a9c79987d/outputdeleted−23

This diff is too big to show here.

target/debug/build/proc-macro2-9816b18a9c79987d/root-outputdeleted−1

This diff is too big to show here.

target/debug/build/proc-macro2-9816b18a9c79987d/stderrdeleted

No changes to the content.

target/debug/build/proc-macro2-bcbb7de91551b4e2/build-script-builddeleted

Binary file not shown.

target/debug/build/proc-macro2-bcbb7de91551b4e2/build_script_build-bcbb7de91551b4e2deleted

Binary file not shown.

target/debug/build/proc-macro2-bcbb7de91551b4e2/build_script_build-bcbb7de91551b4e2.ddeleted−5

This diff is too big to show here.

target/debug/build/quote-0696611ec86f8910/build-script-builddeleted

Binary file not shown.

target/debug/build/quote-0696611ec86f8910/build_script_build-0696611ec86f8910deleted

Binary file not shown.

target/debug/build/quote-0696611ec86f8910/build_script_build-0696611ec86f8910.ddeleted−5

This diff is too big to show here.

target/debug/build/quote-de3ef8ebb8667ca9/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/build/quote-de3ef8ebb8667ca9/outputdeleted−2

This diff is too big to show here.

target/debug/build/quote-de3ef8ebb8667ca9/root-outputdeleted−1

This diff is too big to show here.

target/debug/build/quote-de3ef8ebb8667ca9/stderrdeleted

No changes to the content.

target/debug/build/rustix-038e468382ead565/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/build/rustix-038e468382ead565/out/rustix_test_can_compiledeleted

Binary file not shown.

target/debug/build/rustix-038e468382ead565/outputdeleted−13

This diff is too big to show here.

target/debug/build/rustix-038e468382ead565/root-outputdeleted−1

This diff is too big to show here.

target/debug/build/rustix-038e468382ead565/stderrdeleted

No changes to the content.

target/debug/build/rustix-f2e39f54096cf2ef/build-script-builddeleted

Binary file not shown.

target/debug/build/rustix-f2e39f54096cf2ef/build_script_build-f2e39f54096cf2efdeleted

Binary file not shown.

target/debug/build/rustix-f2e39f54096cf2ef/build_script_build-f2e39f54096cf2ef.ddeleted−5

This diff is too big to show here.

target/debug/build/thiserror-3c068656744c89de/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/build/thiserror-3c068656744c89de/out/private.rsdeleted−5

This diff is too big to show here.

target/debug/build/thiserror-3c068656744c89de/outputdeleted−5

This diff is too big to show here.

target/debug/build/thiserror-3c068656744c89de/root-outputdeleted−1

This diff is too big to show here.

target/debug/build/thiserror-3c068656744c89de/stderrdeleted

No changes to the content.

target/debug/build/thiserror-6616919d71b1f055/build-script-builddeleted

Binary file not shown.

target/debug/build/thiserror-6616919d71b1f055/build_script_build-6616919d71b1f055deleted

Binary file not shown.

target/debug/build/thiserror-6616919d71b1f055/build_script_build-6616919d71b1f055.ddeleted−5

This diff is too big to show here.

target/debug/build/zerocopy-079dd2896db74193/build-script-builddeleted

Binary file not shown.

target/debug/build/zerocopy-079dd2896db74193/build_script_build-079dd2896db74193deleted

Binary file not shown.

target/debug/build/zerocopy-079dd2896db74193/build_script_build-079dd2896db74193.ddeleted−5

This diff is too big to show here.

target/debug/build/zerocopy-ed7b5ac9e96d0173/invoked.timestampdeleted−1

This diff is too big to show here.

target/debug/build/zerocopy-ed7b5ac9e96d0173/outputdeleted−28

This diff is too big to show here.

target/debug/build/zerocopy-ed7b5ac9e96d0173/root-outputdeleted−1

This diff is too big to show here.

target/debug/build/zerocopy-ed7b5ac9e96d0173/stderrdeleted

No changes to the content.

target/debug/deps/anyhow-99f87dcb46c432ce.ddeleted−17

This diff is too big to show here.

target/debug/deps/arrayvec-a65658d7c78abafa.ddeleted−13

This diff is too big to show here.

target/debug/deps/autocfg-bc1dc19eff92fee9.ddeleted−10

This diff is too big to show here.

target/debug/deps/bit_set-3941883ce9e954cc.ddeleted−7

This diff is too big to show here.

target/debug/deps/bit_vec-c46a512d79021dae.ddeleted−7

This diff is too big to show here.

target/debug/deps/bitflags-0aef64864726e470.ddeleted−13

This diff is too big to show here.

target/debug/deps/blake3-f46fd160759017ba.ddeleted−17

This diff is too big to show here.

target/debug/deps/cc-8e3410aecb4a0c7b.ddeleted−18

This diff is too big to show here.

target/debug/deps/cfg_if-de76fd6d6c5730e4.ddeleted−7

This diff is too big to show here.

target/debug/deps/constant_time_eq-fdb5336b45adb589.ddeleted−10

This diff is too big to show here.

target/debug/deps/cpufeatures-e4a310f8b3b6f725.ddeleted−9

This diff is too big to show here.

target/debug/deps/directories-b0d5d95843c3fb66.ddeleted−8

This diff is too big to show here.

target/debug/deps/dirs_sys-6b2d7e2d17beea4e.ddeleted−8

This diff is too big to show here.

target/debug/deps/equivalent-65b26217b03f15f5.ddeleted−7

This diff is too big to show here.

target/debug/deps/fastrand-afd1857ca89a7afc.ddeleted−8

This diff is too big to show here.

target/debug/deps/find_msvc_tools-71a62bc007db7fa9.ddeleted−9

This diff is too big to show here.

target/debug/deps/fnv-9fde446a8c1365fc.ddeleted−7

This diff is too big to show here.

target/debug/deps/getrandom-0eaf42379121fb38.ddeleted−15

This diff is too big to show here.

target/debug/deps/getrandom-b6fea70e6916ddac.ddeleted−17

This diff is too big to show here.

target/debug/deps/getrandom-d98d449a36bafbe0.ddeleted−16

This diff is too big to show here.

target/debug/deps/hashbrown-ac220fc3bcae53c0.ddeleted−22

This diff is too big to show here.

target/debug/deps/indexmap-047470861a2cca77.ddeleted−24

This diff is too big to show here.

target/debug/deps/jiff-68f68fe57a2700b2.ddeleted−93

This diff is too big to show here.

target/debug/deps/jiff_core-787542659066c338.ddeleted−26

This diff is too big to show here.

target/debug/deps/libanyhow-99f87dcb46c432ce.rmetadeleted

Binary file not shown.

target/debug/deps/libarrayvec-a65658d7c78abafa.rlibdeleted

Binary file not shown.

target/debug/deps/libarrayvec-a65658d7c78abafa.rmetadeleted

Binary file not shown.

target/debug/deps/libautocfg-bc1dc19eff92fee9.rlibdeleted

Binary file not shown.

target/debug/deps/libautocfg-bc1dc19eff92fee9.rmetadeleted

Binary file not shown.

target/debug/deps/libbit_set-3941883ce9e954cc.rlibdeleted

Binary file not shown.

target/debug/deps/libbit_set-3941883ce9e954cc.rmetadeleted

Binary file not shown.

target/debug/deps/libbit_vec-c46a512d79021dae.rlibdeleted

Binary file not shown.

target/debug/deps/libbit_vec-c46a512d79021dae.rmetadeleted

Binary file not shown.

target/debug/deps/libbitflags-0aef64864726e470.rlibdeleted

Binary file not shown.

target/debug/deps/libbitflags-0aef64864726e470.rmetadeleted

Binary file not shown.

target/debug/deps/libblake3-f46fd160759017ba.rmetadeleted

Binary file not shown.

target/debug/deps/libc-b783701222a4d8e3.ddeleted−55

This diff is too big to show here.

target/debug/deps/libcc-8e3410aecb4a0c7b.rmetadeleted

Binary file not shown.

target/debug/deps/libcfg_if-de76fd6d6c5730e4.rlibdeleted

Binary file not shown.

target/debug/deps/libcfg_if-de76fd6d6c5730e4.rmetadeleted

Binary file not shown.

target/debug/deps/libconstant_time_eq-fdb5336b45adb589.rlibdeleted

Binary file not shown.

target/debug/deps/libconstant_time_eq-fdb5336b45adb589.rmetadeleted

Binary file not shown.

target/debug/deps/libcpufeatures-e4a310f8b3b6f725.rlibdeleted

Binary file not shown.

target/debug/deps/libcpufeatures-e4a310f8b3b6f725.rmetadeleted

Binary file not shown.

target/debug/deps/libdirectories-b0d5d95843c3fb66.rlibdeleted

Binary file not shown.

target/debug/deps/libdirectories-b0d5d95843c3fb66.rmetadeleted

Binary file not shown.

target/debug/deps/libdirs_sys-6b2d7e2d17beea4e.rlibdeleted

Binary file not shown.

target/debug/deps/libdirs_sys-6b2d7e2d17beea4e.rmetadeleted

Binary file not shown.

target/debug/deps/libequivalent-65b26217b03f15f5.rlibdeleted

Binary file not shown.

target/debug/deps/libequivalent-65b26217b03f15f5.rmetadeleted

Binary file not shown.

target/debug/deps/libfastrand-afd1857ca89a7afc.rlibdeleted

Binary file not shown.

target/debug/deps/libfastrand-afd1857ca89a7afc.rmetadeleted

Binary file not shown.

target/debug/deps/libfind_msvc_tools-71a62bc007db7fa9.rlibdeleted

Binary file not shown.

target/debug/deps/libfind_msvc_tools-71a62bc007db7fa9.rmetadeleted

Binary file not shown.

target/debug/deps/libfnv-9fde446a8c1365fc.rlibdeleted

Binary file not shown.

target/debug/deps/libfnv-9fde446a8c1365fc.rmetadeleted

Binary file not shown.

target/debug/deps/libgetrandom-0eaf42379121fb38.rlibdeleted

Binary file not shown.

target/debug/deps/libgetrandom-0eaf42379121fb38.rmetadeleted

Binary file not shown.

target/debug/deps/libgetrandom-b6fea70e6916ddac.rlibdeleted

Binary file not shown.

target/debug/deps/libgetrandom-b6fea70e6916ddac.rmetadeleted

Binary file not shown.

target/debug/deps/libgetrandom-d98d449a36bafbe0.rlibdeleted

Binary file not shown.

target/debug/deps/libgetrandom-d98d449a36bafbe0.rmetadeleted

Binary file not shown.

target/debug/deps/libnotes_core-8f1ce0f90d61e5a7.rmetadeleted

Binary file not shown.

target/debug/deps/libonce_cell-d72f51d4d3ff5e42.rlibdeleted

Binary file not shown.

target/debug/deps/libonce_cell-d72f51d4d3ff5e42.rmetadeleted

Binary file not shown.

target/debug/deps/liboption_ext-f95e8c5597ca1435.rlibdeleted

Binary file not shown.

target/debug/deps/liboption_ext-f95e8c5597ca1435.rmetadeleted

Binary file not shown.

target/debug/deps/libproc_macro2-6b336f0ddb28b27a.rmetadeleted

Binary file not shown.

target/debug/deps/libquick_error-8abe3b1ca8b7a8b9.rlibdeleted

Binary file not shown.

target/debug/deps/libquick_error-8abe3b1ca8b7a8b9.rmetadeleted

Binary file not shown.

target/debug/deps/libquote-027634fac3d1dd29.rlibdeleted

Binary file not shown.

target/debug/deps/libquote-027634fac3d1dd29.rmetadeleted

Binary file not shown.

target/debug/deps/librand-41f5c89bf90a2c40.rmetadeleted

Binary file not shown.

target/debug/deps/librand_chacha-a13f4556a4cb172c.rlibdeleted

Binary file not shown.

target/debug/deps/librand_chacha-a13f4556a4cb172c.rmetadeleted

Binary file not shown.

target/debug/deps/librand_core-6b2fc663fdff125a.rlibdeleted

Binary file not shown.

target/debug/deps/librand_core-6b2fc663fdff125a.rmetadeleted

Binary file not shown.

target/debug/deps/librand_xorshift-85f984f4f18ce3d5.rlibdeleted

Binary file not shown.

target/debug/deps/librand_xorshift-85f984f4f18ce3d5.rmetadeleted

Binary file not shown.

target/debug/deps/librusty_fork-c05f13651af4f8b1.rmetadeleted

Binary file not shown.

target/debug/deps/libshlex-eafb49743ebdab94.rlibdeleted

Binary file not shown.

target/debug/deps/libshlex-eafb49743ebdab94.rmetadeleted

Binary file not shown.

target/debug/deps/libsimilar-dd62e180fdd9fd69.rmetadeleted

Binary file not shown.

target/debug/deps/libtempfile-a08e3a6e73f18dc2.rmetadeleted

Binary file not shown.

target/debug/deps/libthiserror-d582988ee49bfd38.rlibdeleted

Binary file not shown.

target/debug/deps/libthiserror-d582988ee49bfd38.rmetadeleted

Binary file not shown.

target/debug/deps/libtinyvec-b3705465be29f173.rlibdeleted

Binary file not shown.

target/debug/deps/libtinyvec-b3705465be29f173.rmetadeleted

Binary file not shown.

target/debug/deps/libtinyvec_macros-b3c8ffa612202339.rlibdeleted

Binary file not shown.

target/debug/deps/libtinyvec_macros-b3c8ffa612202339.rmetadeleted

Binary file not shown.

target/debug/deps/libunarray-1317844f3e0f5c07.rlibdeleted

Binary file not shown.

target/debug/deps/libunarray-1317844f3e0f5c07.rmetadeleted

Binary file not shown.

target/debug/deps/libunicode_ident-07077bdd941af1f1.rlibdeleted

Binary file not shown.

target/debug/deps/libunicode_ident-07077bdd941af1f1.rmetadeleted

Binary file not shown.

target/debug/deps/libwait_timeout-6586e67c7f2c4a6f.rlibdeleted

Binary file not shown.

target/debug/deps/libwait_timeout-6586e67c7f2c4a6f.rmetadeleted

Binary file not shown.

target/debug/deps/linux_raw_sys-748a127bddffab6c.ddeleted−12

This diff is too big to show here.

target/debug/deps/notes_core-8f1ce0f90d61e5a7.ddeleted−18

This diff is too big to show here.

target/debug/deps/num_traits-0b5ce95e951c04fe.ddeleted−25

This diff is too big to show here.

target/debug/deps/once_cell-d72f51d4d3ff5e42.ddeleted−9

This diff is too big to show here.

target/debug/deps/option_ext-f95e8c5597ca1435.ddeleted−8

This diff is too big to show here.

target/debug/deps/ppv_lite86-4c2cb8748d60e5c7.ddeleted−11

This diff is too big to show here.

target/debug/deps/proc_macro2-6b336f0ddb28b27a.ddeleted−17

This diff is too big to show here.

target/debug/deps/proptest-c71b6fc3656545fd.ddeleted−95

This diff is too big to show here.

target/debug/deps/quick_error-8abe3b1ca8b7a8b9.ddeleted−7

This diff is too big to show here.

target/debug/deps/quote-027634fac3d1dd29.ddeleted−13

This diff is too big to show here.

target/debug/deps/rand-41f5c89bf90a2c40.ddeleted−32

This diff is too big to show here.

target/debug/deps/rand_chacha-a13f4556a4cb172c.ddeleted−9

This diff is too big to show here.

target/debug/deps/rand_core-6b2fc663fdff125a.ddeleted−11

This diff is too big to show here.

target/debug/deps/rand_xorshift-85f984f4f18ce3d5.ddeleted−7

This diff is too big to show here.

target/debug/deps/regex_syntax-7e413990257ae280.ddeleted−37

This diff is too big to show here.

target/debug/deps/rustix-ebe468a694014bd7.ddeleted−68

This diff is too big to show here.

target/debug/deps/rusty_fork-c05f13651af4f8b1.ddeleted−13

This diff is too big to show here.

target/debug/deps/shlex-eafb49743ebdab94.ddeleted−8

This diff is too big to show here.

target/debug/deps/similar-dd62e180fdd9fd69.ddeleted−25

This diff is too big to show here.

target/debug/deps/syn-efaac48046ba42aa.ddeleted−49

This diff is too big to show here.

target/debug/deps/tempfile-a08e3a6e73f18dc2.ddeleted−17

This diff is too big to show here.

target/debug/deps/thiserror-d582988ee49bfd38.ddeleted−14

This diff is too big to show here.

target/debug/deps/thiserror_impl-054c82e8cb318cbf.ddeleted−17

This diff is too big to show here.

target/debug/deps/tinyvec-b3705465be29f173.ddeleted−13

This diff is too big to show here.

target/debug/deps/tinyvec_macros-b3c8ffa612202339.ddeleted−7

This diff is too big to show here.

target/debug/deps/unarray-1317844f3e0f5c07.ddeleted−10

This diff is too big to show here.

target/debug/deps/unicode_ident-07077bdd941af1f1.ddeleted−8

This diff is too big to show here.

target/debug/deps/unicode_normalization-5a956f8b1f85d8dd.ddeleted−17

This diff is too big to show here.

target/debug/deps/wait_timeout-6586e67c7f2c4a6f.ddeleted−8

This diff is too big to show here.

target/debug/deps/zerocopy-ecaaca3561f2eb29.ddeleted−27

This diff is too big to show here.

target/debug/examples/dbg.ddeleted−1

This diff is too big to show here.