From 1ba5436af34b48ddd35bd2db99ca095a3d9b6130 Mon Sep 17 00:00:00 2001 From: vcoppe Date: Wed, 14 Oct 2026 19:15:02 +0200 Subject: [PATCH] progress --- Cargo.lock | 724 ++++++++++- gpx-rs/src/api/commands.rs | 21 + gpx-rs/src/api/dto.rs | 57 +- gpx-rs/src/engine/command/file/load.rs | 2 +- gpx-rs/src/engine/command/file/mod.rs | 2 + gpx-rs/src/engine/command/file/sync.rs | 148 +++ gpx-rs/src/engine/command/mod.rs | 2 + tauri/Cargo.toml | 7 +- tauri/src/commands/disk.rs | 139 ++ tauri/src/commands/edit.rs | 2 +- tauri/src/commands/files.rs | 2 +- tauri/src/commands/mod.rs | 1 + tauri/src/commands/queries.rs | 2 +- tauri/src/commands/selection.rs | 2 +- tauri/src/commands/storage.rs | 32 +- tauri/src/lib.rs | 281 +++-- tauri/src/state.rs | 240 +++- tauri/src/store.rs | 284 +++++ tauri/src/tests.rs | 102 +- tauri/src/workspace.rs | 1120 +++++++++++++++++ tauri/tauri.conf.json | 13 +- website/src/lib/actions/file-actions.ts | 83 ++ website/src/lib/engine/backend/tauri.ts | 20 +- website/src/lib/engine/backend/types.ts | 58 + website/src/lib/engine/engine.ts | 132 +- website/src/lib/engine/types.ts | 2 + website/src/lib/engine/workspace.test.ts | 29 + website/src/lib/engine/workspace.ts | 28 + .../src/routes/[[language]]/app/+page.svelte | 3 + 29 files changed, 3348 insertions(+), 190 deletions(-) create mode 100644 gpx-rs/src/engine/command/file/sync.rs create mode 100644 tauri/src/commands/disk.rs create mode 100644 tauri/src/store.rs create mode 100644 tauri/src/workspace.rs create mode 100644 website/src/lib/engine/workspace.test.ts create mode 100644 website/src/lib/engine/workspace.ts diff --git a/Cargo.lock b/Cargo.lock index 04b7f2155..f4de1ec52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,6 +47,126 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.92" @@ -168,6 +288,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "9.0.0" @@ -375,6 +508,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console_error_panic_hook" version = "0.1.7" @@ -779,6 +921,33 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -796,6 +965,36 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -886,6 +1085,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures-channel" version = "0.3.34" @@ -918,6 +1126,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1201,10 +1422,15 @@ name = "gpx-studio-desktop" version = "0.1.0" dependencies = [ "gpx-engine", + "notify", + "notify-debouncer-mini", + "pollster", "serde", "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-single-instance", "uuid", ] @@ -1323,6 +1549,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -1612,6 +1844,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -1788,6 +2040,26 @@ dependencies = [ "serde", ] +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.2", + "libc", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -1852,6 +2124,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -1942,6 +2220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -2003,6 +2282,45 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.2", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-debouncer-mini" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17849edfaabd9a5fef1c606d99cfc615a8e99f7ac4366406d86c7942a3184cf2" +dependencies = [ + "log", + "notify", + "notify-types", + "tempfile", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2174,6 +2492,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2264,6 +2583,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "pango" version = "0.18.3" @@ -2289,6 +2618,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2377,6 +2712,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -2422,6 +2768,26 @@ dependencies = [ "miniz_oxide 0.8.9", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336" + [[package]] name = "portable-atomic" version = "1.15.0" @@ -2671,6 +3037,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2686,6 +3076,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -2974,6 +3377,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -3347,6 +3760,80 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1140cf34a3b3b836a13103dcab17f18831d5cc3534cbd435dc01a5c6daa65aa2" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dca325d6e66e9170f2e1b9631722e4e544bcbc51781575d266abdaba697bd51" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213fae2d85d4b76b06de5915d1862db9fa74541171f9d2c15da6ac9bd6e788c6" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.21", + "toml 1.1.8+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c142ed88deee222bd2d979269d35c73b6c1c0f6ebd5b79b4ff80066fcad6af1" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.21", + "tokio", + "tracing", + "windows-sys 0.61.2", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.12.1" @@ -3447,6 +3934,19 @@ dependencies = [ "toml 1.1.8+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.1" @@ -3717,9 +4217,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -3769,6 +4281,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -4262,7 +4785,16 @@ version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" dependencies = [ - "windows-targets", + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -4280,13 +4812,30 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -4313,42 +4862,90 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" @@ -4477,6 +5074,76 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -4542,3 +5209,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow 1.0.4", +] diff --git a/gpx-rs/src/api/commands.rs b/gpx-rs/src/api/commands.rs index aeb29e8db..1b3a4f4aa 100644 --- a/gpx-rs/src/api/commands.rs +++ b/gpx-rs/src/api/commands.rs @@ -178,6 +178,27 @@ pub fn load_files( edit(engine, Command::LoadFiles(engine::LoadFiles { files })) } +/// Brings changes of the files from the outside in (see [`engine::SyncFiles`]): each file is +/// `(id, data, name)`, and replaces the file of that id, or is added if there is none. The files to +/// `remove` are deleted. The selection is left alone. +pub fn sync_files(engine: &mut Engine, files: &[(Uuid, &[u8], &str)], remove: &[Uuid]) -> Outcome { + let files = files + .iter() + .map(|(id, data, name)| engine::ExternalFile { + id: engine::FileId(*id), + data, + name, + }) + .collect(); + edit( + engine, + Command::SyncFiles(engine::SyncFiles { + files, + remove: file_ids(remove), + }), + ) +} + pub fn duplicate(engine: &mut Engine) -> Outcome { edit(engine, Command::Duplicate(engine::Duplicate)) } diff --git a/gpx-rs/src/api/dto.rs b/gpx-rs/src/api/dto.rs index b922fe6ad..a3696df28 100644 --- a/gpx-rs/src/api/dto.rs +++ b/gpx-rs/src/api/dto.rs @@ -17,7 +17,7 @@ pub fn ids(items: &[FileId]) -> Vec { #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct Outcome { - changed: bool, + pub changed: bool, order_changed: bool, selection_changed: bool, clipboard_changed: bool, @@ -47,11 +47,66 @@ impl Outcome { } } + /// The outcome of a frontend that knows nothing of the engine: all the files are added, and the + /// order, the selection and the clipboard are to be read. + pub fn everything(engine: &engine::Engine) -> Self { + Self { + changed: true, + order_changed: true, + selection_changed: true, + clipboard_changed: true, + added: ids(engine.order()), + modified: vec![], + removed: vec![], + error: None, + saves: false, + } + } + /// The outcome of a call that did nothing and has nothing to say. pub fn failed_none() -> Self { Self::new(&engine::Outcome::default(), None) } + /// Whether the call did nothing and says why. + pub fn has_error(&self) -> bool { + self.error.is_some() + } + + /// What two calls that were made one after the other did together. + pub fn merge(mut self, other: Self) -> Self { + fn union(into: &mut Vec, from: Vec) { + for id in from { + if !into.contains(&id) { + into.push(id); + } + } + } + self.changed |= other.changed; + self.order_changed |= other.order_changed; + self.selection_changed |= other.selection_changed; + self.clipboard_changed |= other.clipboard_changed; + self.saves |= other.saves; + // a file that was added and then modified is still an added one, and one that was added + // and then removed is not there at all + let added_before = self.added.clone(); + union(&mut self.added, other.added); + for id in other.modified { + if !self.added.contains(&id) && !self.modified.contains(&id) { + self.modified.push(id); + } + } + for id in other.removed { + self.added.retain(|added| *added != id); + self.modified.retain(|modified| *modified != id); + if !added_before.contains(&id) && !self.removed.contains(&id) { + self.removed.push(id); + } + } + self.error = self.error.or(other.error); + self + } + /// What a call that does nothing returns. pub fn failed(message: impl Into) -> Self { Self::new(&engine::Outcome::default(), Some(message.into())) diff --git a/gpx-rs/src/engine/command/file/load.rs b/gpx-rs/src/engine/command/file/load.rs index 327b1ffd8..734392be7 100644 --- a/gpx-rs/src/engine/command/file/load.rs +++ b/gpx-rs/src/engine/command/file/load.rs @@ -9,7 +9,7 @@ pub struct Load<'a> { } impl Load<'_> { - fn parse(self, categories: &mut TrackpointCategories) -> Result { + pub(crate) fn parse(self, categories: &mut TrackpointCategories) -> Result { let mut file = parse(self.data, categories) .map_err(|err| CommandError::InvalidData(err.to_string()))?; if file.info.name.trim().is_empty() { diff --git a/gpx-rs/src/engine/command/file/mod.rs b/gpx-rs/src/engine/command/file/mod.rs index 95fd803b9..ed7e3d024 100644 --- a/gpx-rs/src/engine/command/file/mod.rs +++ b/gpx-rs/src/engine/command/file/mod.rs @@ -6,6 +6,7 @@ mod load; mod move_elements; mod new; mod paste; +mod sync; mod transfer; pub use delete::*; @@ -16,4 +17,5 @@ pub use load::*; pub use move_elements::*; pub use new::*; pub use paste::*; +pub use sync::*; pub use transfer::*; diff --git a/gpx-rs/src/engine/command/file/sync.rs b/gpx-rs/src/engine/command/file/sync.rs new file mode 100644 index 000000000..52015bdc3 --- /dev/null +++ b/gpx-rs/src/engine/command/file/sync.rs @@ -0,0 +1,148 @@ +use std::rc::Rc; + +use crate::{Apply, CommandError, FileId, Load, State}; + +/// A file that comes from the outside (the file system), under the identity it has here. +#[derive(Debug)] +pub struct ExternalFile<'a> { + pub id: FileId, + pub data: &'a [u8], + /// Name of the file when the data has none (see [`Load`]). + pub name: &'a str, +} + +/// Brings changes of the files from the outside in, as a single command (one undo step): the files +/// that are given replace the ones of the same id (which stay where they are in the order, and keep +/// their id), or are added at the end if there are none; the ones to `remove` are deleted. +/// +/// Unlike [`crate::LoadFiles`], the selection is left alone (what it holds of a file that was +/// replaced or deleted is dropped by the engine afterwards, as with any edit). +/// +/// The files that cannot be read are skipped, and the files that are here stay as they are. It is an +/// error when nothing could be done. +#[derive(Debug, Default)] +pub struct SyncFiles<'a> { + pub files: Vec>, + pub remove: Vec, +} + +impl Apply for SyncFiles<'_> { + fn apply(self, state: &mut State) -> Result<(), CommandError> { + let mut changed = false; + let mut first_error = None; + for external in self.files { + let load = Load { + data: external.data, + name: external.name, + }; + match load.parse(state.categories) { + Ok(mut file) => { + file.id = external.id; + if state.files.insert(external.id, Rc::new(file)).is_none() { + state.order.0.push(external.id); + } + changed = true; + } + Err(error) => { + first_error.get_or_insert(error); + } + } + } + for id in self.remove { + if state.files.remove(&id).is_some() { + state.order.0.retain(|other| *other != id); + changed = true; + } + } + if changed { + Ok(()) + } else { + Err(first_error.unwrap_or(CommandError::NothingToDo)) + } + } +} + +#[cfg(test)] +mod tests { + use crate::engine::command::fixture::Fixture; + use crate::{New, Selection}; + + use super::*; + + const GPX: &[u8] = br#"from disk"#; + + fn new_file(fx: &mut Fixture, name: &str) -> FileId { + New { + name, + trackpoint: None, + } + .apply(&mut fx.state()) + .unwrap(); + *fx.selected_files().iter().next().unwrap() + } + + fn external(id: FileId) -> ExternalFile<'static> { + ExternalFile { + id, + data: GPX, + name: "name", + } + } + + #[test] + fn test_sync_replaces_a_file_in_place() { + let mut fx = Fixture::default(); + let first = new_file(&mut fx, "first"); + let second = new_file(&mut fx, "second"); + SyncFiles { + files: vec![external(first)], + remove: vec![], + } + .apply(&mut fx.state()) + .unwrap(); + assert_eq!(fx.order.0, [first, second]); + assert_eq!(fx.files[&first].info.name, "from disk"); + assert_eq!(fx.files[&first].id, first); + // the selection is not changed + assert_eq!(fx.selected_files(), [second].into()); + } + + #[test] + fn test_sync_adds_and_removes_files() { + let mut fx = Fixture::default(); + let first = new_file(&mut fx, "first"); + let added = FileId::default(); + SyncFiles { + files: vec![external(added)], + remove: vec![first], + } + .apply(&mut fx.state()) + .unwrap(); + assert_eq!(fx.order.0, [added]); + assert!(!fx.files.contains_key(&first)); + // what was selected is gone + assert!(matches!(fx.selection, Selection::File { .. })); + } + + #[test] + fn test_sync_with_nothing_to_do_or_invalid_data_is_an_error() { + let mut fx = Fixture::default(); + assert_eq!( + SyncFiles::default().apply(&mut fx.state()), + Err(CommandError::NothingToDo) + ); + let invalid = SyncFiles { + files: vec![ExternalFile { + id: FileId::default(), + data: b"", + name: "x", + }], + remove: vec![], + }; + assert!(matches!( + invalid.apply(&mut fx.state()), + Err(CommandError::InvalidData(_)) + )); + assert!(fx.files.is_empty()); + } +} diff --git a/gpx-rs/src/engine/command/mod.rs b/gpx-rs/src/engine/command/mod.rs index e71a95c45..3042dc074 100644 --- a/gpx-rs/src/engine/command/mod.rs +++ b/gpx-rs/src/engine/command/mod.rs @@ -24,6 +24,7 @@ pub enum Command<'a> { New(New<'a>), Load(Load<'a>), LoadFiles(LoadFiles<'a>), + SyncFiles(SyncFiles<'a>), Duplicate(Duplicate), Delete(Delete), Paste(Paste), @@ -58,6 +59,7 @@ impl Apply for Command<'_> { Command::New(c) => c.apply(state), Command::Load(c) => c.apply(state), Command::LoadFiles(c) => c.apply(state), + Command::SyncFiles(c) => c.apply(state), Command::Duplicate(c) => c.apply(state), Command::Delete(c) => c.apply(state), Command::Paste(c) => c.apply(state), diff --git a/tauri/Cargo.toml b/tauri/Cargo.toml index e8395fa8d..cc9b5a0c3 100644 --- a/tauri/Cargo.toml +++ b/tauri/Cargo.toml @@ -2,7 +2,7 @@ name = "gpx-studio-desktop" version = "0.1.0" description = "Desktop shell for gpx.studio" -edition = "2021" +edition = "2024" [lib] name = "gpx_studio_desktop_lib" @@ -20,4 +20,9 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1", features = ["serde"] } gpx-engine = { path = "../gpx-rs" } +pollster = "1.0.1" +notify = "8.2.0" +notify-debouncer-mini = "0.7.0" +tauri-plugin-dialog = "2.8.2" +tauri-plugin-single-instance = "2.5.3" diff --git a/tauri/src/commands/disk.rs b/tauri/src/commands/disk.rs new file mode 100644 index 000000000..e014b4e74 --- /dev/null +++ b/tauri/src/commands/disk.rs @@ -0,0 +1,139 @@ +//! The files of the engine and the file system: picking files and folders, saving, and what the +//! frontend shows of it (see `workspace`). + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use gpx_engine::api; +use tauri::{AppHandle, Runtime, State}; +use tauri_plugin_dialog::{DialogExt, FilePath}; +use uuid::Uuid; + +use crate::state::AppState; +use crate::workspace::{Unsaved, WorkspaceState}; + +fn paths(picked: Vec) -> Vec { + picked + .into_iter() + .filter_map(|path| path.into_path().ok()) + .collect() +} + +#[tauri::command(async)] +pub fn workspace_state(state: State) -> WorkspaceState { + state.core(|core| core.workspace_state()) +} + +/// Opens the files that the user picks. Nothing happens if they pick none. +#[tauri::command(async)] +pub fn open_files(app: AppHandle, state: State) -> api::Outcome { + let picked = app + .dialog() + .file() + .add_filter("GPX", &["gpx"]) + .blocking_pick_files(); + let Some(picked) = picked else { + return api::Outcome::failed_none(); + }; + let files = paths(picked); + state.core(move |core| { + let outcome = core.workspace.open_files(&mut core.engine, &files, true); + core.keep_workspace(); + core.persist(); + outcome + }) +} + +/// Opens the folder that the user picks: its GPX files are opened and it is watched. The folder +/// that was open is closed, `unsaved` says what to do with its files that have unsaved edits. +#[tauri::command(async)] +pub fn open_folder( + app: AppHandle, + state: State, + unsaved: Option, +) -> Result { + let Some(folder) = app + .dialog() + .file() + .blocking_pick_folder() + .and_then(|folder| folder.into_path().ok()) + else { + return Ok(api::Outcome::failed_none()); + }; + state.core(move |core| { + let outcome = core + .workspace + .open_folder(&mut core.engine, &folder, unsaved); + core.keep_workspace(); + core.persist(); + core.watch(); + core.announce(None); + outcome + }) +} + +/// Closes the folder: it is not watched anymore, and its files are closed. `unsaved` says what to +/// do with the ones that have unsaved edits (they stay without it). +#[tauri::command(async)] +pub fn close_folder( + state: State, + unsaved: Option, +) -> Result { + state.core(move |core| { + let outcome = core.workspace.close_folder(&mut core.engine, unsaved); + core.keep_workspace(); + core.persist(); + core.watch(); + core.announce(None); + outcome + }) +} + +/// Writes files to the disk, replacing what is there. The ones that are not on the disk yet go in +/// the open folder, or where the user says if there is none. +#[tauri::command(async)] +pub fn save_files( + app: AppHandle, + state: State, + file_ids: Vec, +) -> Result<(), String> { + let (ids, unlinked, folder) = { + let ids = file_ids.clone(); + state.core(move |core| { + ( + ids.clone(), + core.workspace.unlinked(&core.engine, &ids), + core.workspace.folder.clone(), + ) + }) + }; + let mut chosen = BTreeMap::new(); + // with a folder open, a new file goes in it: nobody is asked + let to_ask = if folder.is_some() { vec![] } else { unlinked }; + for (id, name) in to_ask { + let mut dialog = app + .dialog() + .file() + .add_filter("GPX", &["gpx"]) + .set_file_name(format!("{name}.gpx")); + if let Some(folder) = &folder { + dialog = dialog.set_directory(folder); + } + match dialog + .blocking_save_file() + .and_then(|path| path.into_path().ok()) + { + Some(path) => { + chosen.insert(id, path); + } + // cancelled: the others are not saved either + None => return Ok(()), + } + } + state.core(move |core| { + let result = core.workspace.save(&core.engine, &ids, &chosen); + core.keep_workspace(); + core.announce(None); + result + }) +} diff --git a/tauri/src/commands/edit.rs b/tauri/src/commands/edit.rs index dd30604fd..b91ee1eb1 100644 --- a/tauri/src/commands/edit.rs +++ b/tauri/src/commands/edit.rs @@ -1,7 +1,7 @@ //! Editing the metadata, the style, the structure and the waypoints of the files, and the tools: //! reversing, cropping, reducing, timestamps, merging, cleaning... -use gpx_engine::{api, CleanType, MergeType, SplitType}; +use gpx_engine::{CleanType, MergeType, SplitType, api}; use tauri::State; use uuid::Uuid; diff --git a/tauri/src/commands/files.rs b/tauri/src/commands/files.rs index 01572f8f9..e3d7c2d53 100644 --- a/tauri/src/commands/files.rs +++ b/tauri/src/commands/files.rs @@ -1,8 +1,8 @@ //! Creating, loading, deleting, moving, copying and pasting files and their elements, and undoing. use gpx_engine::api; -use tauri::ipc::{InvokeBody, Request}; use tauri::State; +use tauri::ipc::{InvokeBody, Request}; use uuid::Uuid; use crate::state::AppState; diff --git a/tauri/src/commands/mod.rs b/tauri/src/commands/mod.rs index ad227c01b..a5f783ac8 100644 --- a/tauri/src/commands/mod.rs +++ b/tauri/src/commands/mod.rs @@ -5,6 +5,7 @@ //! The commands that edit the files or change the selection return an `Outcome`. //! They are `async` (so they run off the main thread), the frontend sends them one after the other. +pub mod disk; pub mod edit; pub mod files; pub mod queries; diff --git a/tauri/src/commands/queries.rs b/tauri/src/commands/queries.rs index f58922583..2ae331c7a 100644 --- a/tauri/src/commands/queries.rs +++ b/tauri/src/commands/queries.rs @@ -1,7 +1,7 @@ //! The files, their structure and statistics, the selection, the clipboard, and the coordinates of //! the trackpoints and waypoints (flat `[lng, lat, ...]` little-endian `f64` arrays). -use gpx_engine::{api, ExportOptions}; +use gpx_engine::{ExportOptions, api}; use tauri::State; use uuid::Uuid; diff --git a/tauri/src/commands/selection.rs b/tauri/src/commands/selection.rs index 8555e6dd8..6def5d102 100644 --- a/tauri/src/commands/selection.rs +++ b/tauri/src/commands/selection.rs @@ -1,6 +1,6 @@ //! Changing what is selected. -use gpx_engine::{api, SelectMode}; +use gpx_engine::{SelectMode, api}; use tauri::State; use uuid::Uuid; diff --git a/tauri/src/commands/storage.rs b/tauri/src/commands/storage.rs index df0266e82..b2369e633 100644 --- a/tauri/src/commands/storage.rs +++ b/tauri/src/commands/storage.rs @@ -1,46 +1,48 @@ -//! Keeping the files and the settings. -//! -//! For now nothing is written: the files only live as long as the app, and so do the settings. -//! The file system storage will go here. +//! What the frontend knows of the storage, which is kept by the engine thread (see `state`). +use std::collections::BTreeMap; + +use gpx_engine::api; use serde::Serialize; use tauri::State; use crate::state::AppState; -use gpx_engine::api::Outcome; /// What opening the storage found. #[derive(Serialize)] pub struct StorageOpened { - settings: std::collections::BTreeMap, - outcome: Outcome, + settings: BTreeMap, + /// All the files of the engine as added: the frontend starts with none. + outcome: api::Outcome, } +/// The files were restored when the app started: the frontend is told about all of them. #[tauri::command(async)] pub fn open_storage(state: State) -> StorageOpened { StorageOpened { - settings: state.settings().clone(), - outcome: Outcome::failed_none(), + settings: state.storage().settings(), + outcome: state.read(api::Outcome::everything), } } -/// Resolves when what was changed has been written to the storage. +/// Resolves when what was changed has been written to the storage, which is the case when a command +/// returns. #[tauri::command(async)] pub fn flush_storage() {} -/// Reads the files again if they were written by someone else. +/// There is no other writer. #[tauri::command(async)] -pub fn sync_storage() -> Outcome { - Outcome::failed_none() +pub fn sync_storage() -> api::Outcome { + api::Outcome::failed_none() } /// Saves a setting, `value` being its JSON. #[tauri::command(async)] pub fn set_setting(state: State, key: String, value: String) { - state.settings().insert(key, value); + state.put_setting(&key, &value); } #[tauri::command(async)] pub fn delete_setting(state: State, key: String) { - state.settings().remove(&key); + state.delete_setting(&key); } diff --git a/tauri/src/lib.rs b/tauri/src/lib.rs index b9a21a9ce..d5afcf9cd 100644 --- a/tauri/src/lib.rs +++ b/tauri/src/lib.rs @@ -4,121 +4,186 @@ mod bytes; mod commands; mod state; +mod store; +mod workspace; use commands::*; +use tauri::{Emitter, Manager}; + +/// The GPX files among what the system gave to the app (the arguments of the command line, when a +/// file is opened with it). +fn gpx_paths(arguments: impl IntoIterator) -> Vec { + arguments + .into_iter() + .map(std::path::PathBuf::from) + .filter(|path| { + path.is_file() + && path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("gpx")) + }) + .collect() +} #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - with_engine(tauri::Builder::default()) - .run(tauri::generate_context!()) - .expect("error while running gpx.studio"); + let builder = tauri::Builder::default(); + // a single instance of the app: starting it again shows the one that runs (it has to be the + // first plugin) + #[cfg(desktop)] + let builder = builder.plugin(tauri_plugin_single_instance::init( + |app, arguments, _directory| { + if let Some(window) = app.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } + // a file that was opened with the app, while it runs (Windows, Linux) + if let Some(state) = app.try_state::() { + state.open_external(gpx_paths(arguments.into_iter().skip(1))); + } + }, + )); + register_commands(builder) + .plugin(tauri_plugin_dialog::init()) + .setup(|app| { + let handle = app.handle().clone(); + let emit: state::Emit = std::sync::Arc::new(move |event, payload| { + let _ = handle.emit(event, payload); + }); + // what the engine holds is kept in the data directory of the app + let directory = app.path().app_data_dir()?.join("store"); + let state = state::AppState::open(directory, emit)?; + // a file that was opened with the app, which starts (Windows, Linux) + state.open_external(gpx_paths(std::env::args().skip(1))); + app.manage(state); + Ok(()) + }) + .build(tauri::generate_context!()) + .expect("error while building gpx.studio") + .run(|_app, _event| { + // a file that was opened with the app (macOS) + #[cfg(any(target_os = "macos", target_os = "ios"))] + if let tauri::RunEvent::Opened { urls } = _event + && let Some(state) = _app.try_state::() + { + let paths = urls.iter().filter_map(|url| url.to_file_path().ok()); + state.open_external(gpx_paths( + paths.map(|path| path.to_string_lossy().into_owned()), + )); + } + }); } -/// Gives the builder the engine and the commands that reach it. -fn with_engine(builder: tauri::Builder) -> tauri::Builder { - builder - .manage(state::AppState::default()) - .invoke_handler(tauri::generate_handler![ - // selection - selection::select, - selection::select_all, - selection::arrow_select, - selection::select_tracks, - selection::select_segments, - selection::select_waypoint_group, - selection::select_waypoints, - // files - files::new_file, - files::load_files, - files::duplicate, - files::delete, - files::delete_all, - files::reorder, - files::copy, - files::cut, - files::paste, - files::move_elements, - files::undo, - files::redo, - // edits and tools - edit::metadata, - edit::style, - edit::new_track, - edit::new_track_segment, - edit::new_waypoint, - edit::move_waypoint, - edit::delete_waypoint, - edit::update_waypoint, - edit::reverse, - edit::round_trip, - edit::crop, - edit::reduce, - edit::change_timestamps, - edit::create_artificial_timestamps, - edit::merge, - edit::extract, - edit::elevation, - edit::clean, - edit::route, - edit::insert_anchor, - edit::change_loop_start, - edit::split, - // queries - queries::file_order, - queries::file_structure, - queries::file_statistics, - queries::selection, - queries::clipboard, - queries::can_undo, - queries::can_redo, - queries::can_paste, - queries::waypoint, - queries::trackpoint, - queries::export_file, - queries::exportable_data, - queries::reduction_distances, - queries::segment_coordinates, - queries::waypoint_coordinates, - // statistics - statistics::total_distance, - statistics::slope, - statistics::slope_segment_slope, - statistics::slope_segment_distance, - statistics::lng, - statistics::lat, - statistics::ele, - statistics::speed, - statistics::hr, - statistics::cad, - statistics::atemp, - statistics::power, - statistics::timestamps, - statistics::selection_statistics, - statistics::slice_statistics, - statistics::surface_starts, - statistics::surface_values, - statistics::highway_starts, - statistics::highway_values, - statistics::sac_scale_starts, - statistics::sac_scale_values, - statistics::mtb_scale_starts, - statistics::mtb_scale_values, - statistics::surfaces, - statistics::highways, - statistics::sac_scales, - statistics::mtb_scales, - statistics::anchor_indices, - statistics::anchor_zooms, - statistics::segment_starts, - statistics::segment_ids, - statistics::routing_revision, - // storage - storage::open_storage, - storage::flush_storage, - storage::sync_storage, - storage::set_setting, - storage::delete_setting, - ]) +/// Gives the builder the commands that reach the engine (which it has to be given as `AppState`). +fn register_commands(builder: tauri::Builder) -> tauri::Builder { + builder.invoke_handler(tauri::generate_handler![ + // selection + selection::select, + selection::select_all, + selection::arrow_select, + selection::select_tracks, + selection::select_segments, + selection::select_waypoint_group, + selection::select_waypoints, + // files + files::new_file, + files::load_files, + files::duplicate, + files::delete, + files::delete_all, + files::reorder, + files::copy, + files::cut, + files::paste, + files::move_elements, + files::undo, + files::redo, + // edits and tools + edit::metadata, + edit::style, + edit::new_track, + edit::new_track_segment, + edit::new_waypoint, + edit::move_waypoint, + edit::delete_waypoint, + edit::update_waypoint, + edit::reverse, + edit::round_trip, + edit::crop, + edit::reduce, + edit::change_timestamps, + edit::create_artificial_timestamps, + edit::merge, + edit::extract, + edit::elevation, + edit::clean, + edit::route, + edit::insert_anchor, + edit::change_loop_start, + edit::split, + // queries + queries::file_order, + queries::file_structure, + queries::file_statistics, + queries::selection, + queries::clipboard, + queries::can_undo, + queries::can_redo, + queries::can_paste, + queries::waypoint, + queries::trackpoint, + queries::export_file, + queries::exportable_data, + queries::reduction_distances, + queries::segment_coordinates, + queries::waypoint_coordinates, + // statistics + statistics::total_distance, + statistics::slope, + statistics::slope_segment_slope, + statistics::slope_segment_distance, + statistics::lng, + statistics::lat, + statistics::ele, + statistics::speed, + statistics::hr, + statistics::cad, + statistics::atemp, + statistics::power, + statistics::timestamps, + statistics::selection_statistics, + statistics::slice_statistics, + statistics::surface_starts, + statistics::surface_values, + statistics::highway_starts, + statistics::highway_values, + statistics::sac_scale_starts, + statistics::sac_scale_values, + statistics::mtb_scale_starts, + statistics::mtb_scale_values, + statistics::surfaces, + statistics::highways, + statistics::sac_scales, + statistics::mtb_scales, + statistics::anchor_indices, + statistics::anchor_zooms, + statistics::segment_starts, + statistics::segment_ids, + statistics::routing_revision, + // storage + storage::open_storage, + storage::flush_storage, + storage::sync_storage, + storage::set_setting, + storage::delete_setting, + // the file system + disk::workspace_state, + disk::open_files, + disk::open_folder, + disk::close_folder, + disk::save_files, + ]) } #[cfg(test)] diff --git a/tauri/src/state.rs b/tauri/src/state.rs index 9dc4a357d..de2899cd9 100644 --- a/tauri/src/state.rs +++ b/tauri/src/state.rs @@ -1,48 +1,196 @@ -//! The state the commands share: the engine, and the settings of the frontend. +//! The state the commands share: the engine, and what goes with it. //! -//! The engine is not `Send` (it shares its data with `Rc`), so it lives on a thread of its own and -//! the commands send it jobs: they are done one after the other, in the order they were sent. +//! The engine is not `Send` (it shares its data with `Rc`), so it lives on a thread of its own with +//! everything that works on it (its storage, the link between its files and the file system, the +//! watcher of the open folder), and the commands send that thread jobs: they are done one after the +//! other, in the order they were sent. -use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; use std::sync::mpsc::{self, Sender}; -use std::sync::{Mutex, MutexGuard}; +use std::sync::{Arc, Mutex}; use std::thread; +use std::time::Duration; -use gpx_engine::Engine; +use gpx_engine::{Engine, Persister, Storage, api}; +use notify::RecursiveMode; +use notify_debouncer_mini::{DebounceEventResult, Debouncer, new_debouncer}; +use serde::Serialize; -type Job = Box; +use crate::store::FileStorage; +use crate::workspace::{Workspace, WorkspaceState}; -pub struct AppState { - jobs: Mutex>, - /// The settings of the frontend, as JSON strings by key. - settings: Mutex>, +/// Tells the frontend that something happened that it did not ask for: a name and a JSON value. +pub type Emit = Arc; + +type Job = Box; + +/// Everything that lives on the thread of the engine. +pub struct Core { + pub engine: Engine, + pub workspace: Workspace, + storage: Arc, + persister: Persister, + watcher: Option>, + jobs: Sender, + emit: Emit, } -impl Default for AppState { - fn default() -> Self { - let (jobs, receiver) = mpsc::channel::(); - thread::Builder::new() - .name("engine".into()) - .spawn(move || { - let mut engine = Engine::default(); - while let Ok(job) = receiver.recv() { - job(&mut engine); +#[derive(Clone)] +pub struct AppState { + jobs: Arc>>, + storage: Arc, +} + +impl Core { + fn start(storage: Arc, jobs: Sender, emit: Emit) -> Self { + let mut core = Self { + engine: Engine::default(), + workspace: Workspace::load(storage.root()), + persister: Persister::default(), + storage, + watcher: None, + jobs, + emit, + }; + match pollster::block_on(core.persister.restore(&*core.storage)) { + Ok(restored) => { + core.engine.restore(restored); + } + // what is stored comes from a newer version, which is left alone + Err(gpx_engine::StorageError::Newer) => {} + Err(error) => eprintln!("the files could not be read: {error}"), + } + core.workspace.prune(&core.engine); + core.workspace.verify(&core.engine); + // what happened on the disk while the app was closed + core.workspace.reconcile(&mut core.engine, None); + core.workspace.write(core.storage.root()); + core.watch(); + core.persist(); + core + } + + /// Writes what changed in the engine. + pub fn persist(&mut self) { + match pollster::block_on(self.persister.save(&*self.storage, self.engine.snapshot())) { + Ok(()) | Err(gpx_engine::CommitError::Failed(gpx_engine::StorageError::Newer)) => {} + Err(error) => eprintln!("the files could not be kept: {error}"), + } + } + + /// Opens files that the system gave to the app (open with..., double click, the command line), + /// which get selected. The frontend is told, if it already runs: when it starts it reads what + /// the engine has. + pub fn open_external(&mut self, paths: &[PathBuf]) { + if paths.is_empty() { + return; + } + let outcome = self.workspace.open_files(&mut self.engine, paths, true); + self.keep_workspace(); + self.persist(); + (self.emit)("files-opened", to_json(&outcome)); + self.announce(None); + } + + pub fn keep_workspace(&self) { + self.workspace.write(self.storage.root()); + } + + pub fn workspace_state(&self) -> WorkspaceState { + self.workspace.state(&self.engine) + } + + /// Tells the frontend what changed without it asking, and where the files are. + pub fn announce(&self, outcome: Option<&api::Outcome>) { + if let Some(outcome) = outcome { + (self.emit)("engine-changed", to_json(outcome)); + } + (self.emit)("workspace-changed", to_json(&self.workspace_state())); + } + + /// Watches the open folder (and only it). + pub fn watch(&mut self) { + self.watcher = None; + let Some(folder) = self.workspace.folder.clone() else { + return; + }; + if !Path::new(&folder).is_dir() { + return; + } + let jobs = self.jobs.clone(); + let debouncer = new_debouncer( + Duration::from_millis(400), + move |result: DebounceEventResult| { + let Ok(events) = result else { return }; + let paths: Vec = events.into_iter().map(|event| event.path).collect(); + let _ = jobs.send(Box::new(move |core: &mut Core| { + core.on_disk_changed(&paths) + })); + }, + ); + match debouncer { + Ok(mut debouncer) => { + match debouncer.watcher().watch(&folder, RecursiveMode::Recursive) { + Ok(()) => self.watcher = Some(debouncer), + Err(error) => eprintln!("the folder cannot be watched: {error}"), } - }) - .expect("the engine thread could not be started"); - Self { - jobs: Mutex::new(jobs), - settings: Mutex::default(), + } + Err(error) => eprintln!("the folder cannot be watched: {error}"), + } + } + + fn on_disk_changed(&mut self, paths: &[PathBuf]) { + let outcome = self.workspace.reconcile(&mut self.engine, Some(paths)); + if let Some(outcome) = outcome { + self.workspace.write(self.storage.root()); + self.persist(); + self.announce(Some(&outcome)); } } } +fn to_json(value: &T) -> serde_json::Value { + serde_json::to_value(value).unwrap_or_default() +} + impl AppState { - /// Runs `f` on the engine and waits for its result. - pub fn run(&self, f: impl FnOnce(&mut Engine) -> T + Send + 'static) -> T { + /// Starts the engine, which restores what was kept in `directory`. + pub fn open(directory: impl Into, emit: Emit) -> Result { + let storage = Arc::new(FileStorage::open(directory).map_err(|error| error.to_string())?); + let (jobs, receiver) = mpsc::channel::(); + let (thread_storage, thread_jobs) = (storage.clone(), jobs.clone()); + thread::Builder::new() + .name("engine".into()) + .spawn(move || { + let mut core = Core::start(thread_storage, thread_jobs, emit); + while let Ok(job) = receiver.recv() { + job(&mut core); + } + }) + .map_err(|error| error.to_string())?; + Ok(Self { + jobs: Arc::new(Mutex::new(jobs)), + storage, + }) + } + + /// An engine that keeps its data in a directory of its own, and has nobody to tell. + #[cfg(test)] + pub fn temporary() -> Self { + let directory = std::env::temp_dir().join(format!("gpx-studio-{}", uuid::Uuid::new_v4())); + Self::open(directory, Arc::new(|_, _| {})).expect("the storage can be opened") + } + + /// Opens files that the system gave to the app, see `Core::open_external`. + pub fn open_external(&self, paths: Vec) { + self.core(move |core| core.open_external(&paths)); + } + + /// Runs `f` on the thread of the engine and waits for its result. + pub fn core(&self, f: impl FnOnce(&mut Core) -> T + Send + 'static) -> T { let (reply, result) = mpsc::channel(); - let job: Job = Box::new(move |engine| { - let _ = reply.send(f(engine)); + let job: Job = Box::new(move |core| { + let _ = reply.send(f(core)); }); self.jobs .lock() @@ -52,12 +200,36 @@ impl AppState { result.recv().expect("the engine thread stopped") } - /// Reads from the engine. - pub fn read(&self, f: impl FnOnce(&Engine) -> T + Send + 'static) -> T { - self.run(move |engine| f(engine)) + /// Runs `f` on the engine, which can change: what changed is kept. + pub fn run(&self, f: impl FnOnce(&mut Engine) -> T + Send + 'static) -> T { + self.core(move |core| { + let result = f(&mut core.engine); + core.persist(); + result + }) } - pub fn settings(&self) -> MutexGuard<'_, BTreeMap> { - self.settings.lock().unwrap() + /// Reads from the engine. + pub fn read(&self, f: impl FnOnce(&Engine) -> T + Send + 'static) -> T { + self.core(move |core| f(&core.engine)) + } + + pub fn storage(&self) -> &FileStorage { + &self.storage + } +} + +// the settings only are read and written by the commands, from any thread +impl AppState { + pub fn put_setting(&self, key: &str, value: &str) { + if let Err(error) = pollster::block_on(self.storage.put_setting(key, value)) { + eprintln!("the setting could not be kept: {error}"); + } + } + + pub fn delete_setting(&self, key: &str) { + if let Err(error) = pollster::block_on(self.storage.delete_setting(key)) { + eprintln!("the setting could not be kept: {error}"); + } } } diff --git a/tauri/src/store.rs b/tauri/src/store.rs new file mode 100644 index 000000000..b2bfd82f1 --- /dev/null +++ b/tauri/src/store.rs @@ -0,0 +1,284 @@ +//! The storage of the engine on the file system: what the engine holds is kept between sessions, +//! in a directory of the app (see `gpx_engine::Storage`). +//! +//! ```text +//! head the revision of the last commit +//! order the order of the files +//! files/ the record of each file +//! trackpoints/ the chunks of trackpoints +//! waypoints/ the chunks of waypoints +//! settings.json the settings of the frontend +//! ``` +//! +//! A commit writes the chunks, the records and the order first, then the head, then deletes what +//! is not used anymore: the head moves once all of the new data is there, and nothing that the old +//! data refers to is gone before that. + +use std::collections::BTreeMap; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use gpx_engine::{ + Batch, ChunkKey, ChunkKind, CommitError, FileId, Revision, Storage, StorageError, StoredData, +}; +use uuid::Uuid; + +pub struct FileStorage { + root: PathBuf, + settings: Mutex>, +} + +fn failed(error: impl std::fmt::Display) -> StorageError { + StorageError::Failed(error.to_string()) +} + +/// Writes a file as a whole or not at all. +pub fn write_atomically(path: &Path, bytes: &[u8]) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let temporary = path.with_extension("tmp"); + fs::write(&temporary, bytes)?; + fs::rename(&temporary, path) +} + +fn read_optional(path: &Path) -> io::Result>> { + match fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} + +fn remove(path: &Path) -> io::Result<()> { + match fs::remove_file(path) { + Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error), + _ => Ok(()), + } +} + +impl FileStorage { + /// Opens the storage in `root`, which is created if it does not exist. + pub fn open(root: impl Into) -> Result { + let root = root.into(); + fs::create_dir_all(&root).map_err(failed)?; + let settings = match read_optional(&root.join("settings.json")).map_err(failed)? { + Some(bytes) => serde_json::from_slice(&bytes).map_err(failed)?, + None => BTreeMap::new(), + }; + Ok(Self { + root, + settings: Mutex::new(settings), + }) + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn settings(&self) -> BTreeMap { + self.settings.lock().unwrap().clone() + } + + fn chunk_path(&self, key: &ChunkKey) -> PathBuf { + let directory = match key.kind { + ChunkKind::Trackpoints => "trackpoints", + ChunkKind::Waypoints => "waypoints", + }; + self.root.join(directory).join(key.id.to_string()) + } + + fn file_path(&self, id: &FileId) -> PathBuf { + self.root.join("files").join(id.0.to_string()) + } + + /// The ids and contents of the files of a directory, named by UUIDs. + fn read_directory(&self, name: &str) -> Result)>, StorageError> { + let directory = self.root.join(name); + let entries = match fs::read_dir(&directory) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(vec![]), + Err(error) => return Err(failed(error)), + }; + let mut items = vec![]; + for entry in entries { + let path = entry.map_err(failed)?.path(); + let Some(id) = path + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| Uuid::parse_str(name).ok()) + else { + // temporary files of a write that did not finish + continue; + }; + items.push((id, fs::read(&path).map_err(failed)?)); + } + Ok(items) + } + + fn save_settings(&self, settings: &BTreeMap) -> Result<(), StorageError> { + let json = serde_json::to_vec(settings).map_err(failed)?; + write_atomically(&self.root.join("settings.json"), &json).map_err(failed) + } + + fn read_head(&self) -> Result { + match read_optional(&self.root.join("head")).map_err(failed)? { + Some(bytes) => String::from_utf8_lossy(&bytes) + .trim() + .parse() + .map(Revision) + .map_err(failed), + None => Ok(Revision::default()), + } + } + + fn write_batch(&self, batch: &Batch, head: Revision) -> io::Result<()> { + for (key, bytes) in &batch.put_chunks { + write_atomically(&self.chunk_path(key), bytes)?; + } + for (id, bytes) in &batch.put_files { + write_atomically(&self.file_path(id), bytes)?; + } + if let Some(order) = &batch.order { + write_atomically(&self.root.join("order"), order)?; + } + write_atomically(&self.root.join("head"), head.0.to_string().as_bytes())?; + for id in &batch.delete_files { + remove(&self.file_path(id))?; + } + for key in &batch.delete_chunks { + remove(&self.chunk_path(key))?; + } + Ok(()) + } +} + +impl Storage for FileStorage { + async fn load(&self) -> Result { + let chunks = |kind: ChunkKind, name: &str| -> Result, StorageError> { + Ok(self + .read_directory(name)? + .into_iter() + .map(|(id, bytes)| (ChunkKey { kind, id }, bytes)) + .collect()) + }; + let mut all_chunks = chunks(ChunkKind::Trackpoints, "trackpoints")?; + all_chunks.extend(chunks(ChunkKind::Waypoints, "waypoints")?); + Ok(StoredData { + files: self + .read_directory("files")? + .into_iter() + .map(|(id, bytes)| (FileId(id), bytes)) + .collect(), + chunks: all_chunks, + order: read_optional(&self.root.join("order")).map_err(failed)?, + head: self.read_head()?, + }) + } + + async fn head(&self) -> Result { + self.read_head() + } + + async fn commit(&self, batch: &Batch) -> Result { + let head = self.read_head()?; + if head != batch.expect_head { + return Err(CommitError::Stale { head }); + } + let next = head.next(); + self.write_batch(batch, next) + .map_err(|error| CommitError::Failed(failed(error)))?; + Ok(next) + } + + async fn load_settings(&self) -> Result, StorageError> { + Ok(self + .settings + .lock() + .unwrap() + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect()) + } + + async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError> { + let mut settings = self.settings.lock().unwrap(); + settings.insert(key.to_owned(), value.to_owned()); + self.save_settings(&settings) + } + + async fn delete_setting(&self, key: &str) -> Result<(), StorageError> { + let mut settings = self.settings.lock().unwrap(); + if settings.remove(key).is_some() { + self.save_settings(&settings)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use gpx_engine::{Action, Command, Engine, Load, LoadFiles, Persister}; + + use super::*; + + fn directory() -> PathBuf { + std::env::temp_dir().join(format!("gpx-studio-store-{}", Uuid::new_v4())) + } + + fn engine_with_a_file() -> Engine { + let mut engine = Engine::default(); + let data = std::fs::read("../gpx-rs/data/simple.gpx").unwrap(); + engine + .execute(Action::Edit(Command::LoadFiles(LoadFiles { + files: vec![Load { + data: &data, + name: "simple", + }], + }))) + .unwrap(); + engine + } + + #[test] + fn keeps_the_files_and_the_settings_between_sessions() { + let root = directory(); + let engine = engine_with_a_file(); + let order = engine.order().to_vec(); + + let storage = FileStorage::open(&root).unwrap(); + pollster::block_on(Persister::default().save(&storage, engine.snapshot())).unwrap(); + pollster::block_on(storage.put_setting("units", "\"metric\"")).unwrap(); + + // another session + let storage = FileStorage::open(&root).unwrap(); + let restored = pollster::block_on(Persister::default().restore(&storage)).unwrap(); + let mut other = Engine::default(); + other.restore(restored); + assert_eq!(other.order(), order); + assert_eq!( + other.file_statistics(&order[0]).unwrap().total_distance, + engine.file_statistics(&order[0]).unwrap().total_distance + ); + assert_eq!(storage.settings()["units"], "\"metric\""); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn refuses_a_commit_that_is_not_based_on_the_head() { + let root = directory(); + let storage = FileStorage::open(&root).unwrap(); + let batch = Batch { + order: Some(vec![]), + ..Default::default() + }; + assert_eq!(pollster::block_on(storage.commit(&batch)), Ok(Revision(1))); + assert_eq!( + pollster::block_on(storage.commit(&batch)), + Err(CommitError::Stale { head: Revision(1) }) + ); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/tauri/src/tests.rs b/tauri/src/tests.rs index 2b9d061a3..94f14d4ea 100644 --- a/tauri/src/tests.rs +++ b/tauri/src/tests.rs @@ -1,18 +1,19 @@ //! The commands, called the way the frontend calls them (through the IPC of Tauri), on the sample //! files of the engine. -use serde_json::{json, Value}; -use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use serde_json::{Value, json}; +use tauri::test::{INVOKE_KEY, get_ipc_response, mock_builder, mock_context, noop_assets}; use tauri::webview::InvokeRequest; -use tauri::{ipc::CallbackFn, ipc::InvokeBody, ipc::InvokeResponseBody, WebviewWindow}; +use tauri::{WebviewWindow, ipc::CallbackFn, ipc::InvokeBody, ipc::InvokeResponseBody}; struct App(WebviewWindow); impl App { fn new() -> Self { - let app = crate::with_engine(mock_builder()) - .build(mock_context(noop_assets())) - .unwrap(); + let app = + crate::register_commands(mock_builder().manage(crate::state::AppState::temporary())) + .build(mock_context(noop_assets())) + .unwrap(); let window = tauri::WebviewWindowBuilder::new(&app, "main", Default::default()) .build() .unwrap(); @@ -242,9 +243,11 @@ fn waypoints_exports_and_routing_revisions() { json!({ "fileId": file, "time": true, "hr": true, "cad": true, "atemp": true, "power": true, "osm": true, "asRoute": false }), ); assert_eq!(exported[0], 1); - assert!(String::from_utf8(exported[1..].to_vec()) - .unwrap() - .contains(" = events + .iter() + .filter(|(name, _)| name == "files-opened") + .collect(); + assert_eq!(opened.len(), 2); + assert_eq!(opened[0].1["added"].as_array().unwrap().len(), 1); + assert!(opened[1].1["added"].as_array().unwrap().is_empty()); + assert!(events.iter().any(|(name, _)| name == "workspace-changed")); + std::fs::remove_dir_all(directory).unwrap(); +} diff --git a/tauri/src/workspace.rs b/tauri/src/workspace.rs new file mode 100644 index 000000000..5cfd58e77 --- /dev/null +++ b/tauri/src/workspace.rs @@ -0,0 +1,1120 @@ +//! The files of the engine and the files of the file system. +//! +//! A file of the engine can be linked to a file on the disk (it was opened from it, or saved to +//! it). The engine does not know about it: the links are kept here, with what the two looked like +//! when they last were the same, which tells +//! +//! - whether the file was edited since it was saved (`dirty`), as its structure is not the one it +//! had then (the structure changes with every edit, and an undo brings it back); +//! - whether the file on the disk changed since (its bytes are not the ones that were read or +//! written then), which is the case when something else wrote it. +//! +//! At most one folder is open. It is watched, and the files that are saved without a place are put +//! in it. When a folder is open it is watched: the files that appear, change or disappear on the disk are +//! brought into the engine (see `reconcile`), unless the engine holds edits of that file, which +//! are not overwritten (the file is then flagged as `conflict`). + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use gpx_engine::{Engine, ExportOptions, FileId, api}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::store::write_atomically; + +/// The hash of some bytes (FNV-1a), which is the same in every session. +pub fn hash(bytes: &[u8]) -> u64 { + bytes.iter().fold(0xcbf29ce484222325, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3) + }) +} + +/// What a file of the engine looks like: its structure holds the identity of everything that was +/// edited in it. The identities of the contents change when the engine starts again, which is why +/// `Workspace::verify` checks the content of the files that are linked then. +pub fn export_hash(engine: &Engine, id: Uuid) -> Option { + Some(hash(&engine.export(&FileId(id), ExportOptions::ALL)?)) +} + +pub fn signature(engine: &Engine, id: Uuid) -> Option { + let structure = api::file_structure(engine, id)?; + Some(hash(&serde_json::to_vec(&structure).ok()?)) +} + +/// What to do with the files of a folder that have unsaved edits when the folder is closed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Unsaved { + /// Write them to their file first. + Save, + /// Close them, the edits are lost. + Discard, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Link { + pub path: PathBuf, + /// The signature of the file of the engine when it was last the one on the disk. + pub signature: u64, + /// The hash of the bytes of the file on the disk when it was last read or written here. + pub disk_hash: u64, + /// The hash of what the engine writes for the file (`export`) then, which does not depend on + /// the identities in the engine: it tells whether a file is still the same after a restart. + /// Zero when it is not known. + #[serde(default)] + pub export_hash: u64, + /// The file on the disk changed while the engine held edits of it. + #[serde(default)] + pub conflict: bool, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct Workspace { + /// The folder that is open (and watched). + pub folder: Option, + pub links: BTreeMap, +} + +/// What the frontend shows of a file. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileStatus { + /// Where the file is on the disk, absent for a file that only is in the engine. + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + /// The file has edits that are not in the file on the disk (or has no file on the disk). + pub dirty: bool, + /// The file on the disk was changed by something else while this one had edits. + pub conflict: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceState { + #[serde(skip_serializing_if = "Option::is_none")] + pub folder: Option, + pub files: BTreeMap, +} + +fn is_gpx(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("gpx")) +} + +fn canonical(path: &Path) -> PathBuf { + fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +/// What the engine writes for a file that was read from the disk and not edited, for a link that +/// does not know it: `None` if the file on the disk is not the one that was read anymore. +fn saved_export_hash(link: &Link) -> Option { + let bytes = fs::read(&link.path).ok()?; + if hash(&bytes) != link.disk_hash { + return None; + } + let id = Uuid::new_v4(); + let mut scratch = Engine::default(); + api::sync_files(&mut scratch, &[(id, &bytes, &stem(&link.path))], &[]); + export_hash(&scratch, id) +} + +/// A name that can be the one of a file, on every system. +fn file_name(name: &str) -> String { + let name: String = name + .chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + c if c.is_control() => '_', + c => c, + }) + .collect(); + let name = name.trim().trim_matches('.').trim(); + let name: String = name.chars().take(100).collect(); + if name.is_empty() { + "track".to_owned() + } else { + name + } +} + +fn stem(path: &Path) -> String { + path.file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_default() +} + +/// The GPX files of a folder and of the folders in it. +pub fn scan(folder: &Path) -> Vec { + const MAX_DEPTH: usize = 8; + const MAX_FILES: usize = 5000; + let mut found = vec![]; + let mut pending = vec![(folder.to_path_buf(), 0)]; + while let Some((directory, depth)) = pending.pop() { + let Ok(entries) = fs::read_dir(&directory) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(kind) = entry.file_type() else { + continue; + }; + let hidden = entry.file_name().to_string_lossy().starts_with('.'); + if hidden { + continue; + } + if kind.is_dir() && depth < MAX_DEPTH { + pending.push((path, depth + 1)); + } else if kind.is_file() && is_gpx(&path) && found.len() < MAX_FILES { + found.push(path); + } + } + } + found.sort(); + found +} + +impl Workspace { + pub fn path() -> &'static str { + "workspace.json" + } + + pub fn load(root: &Path) -> Self { + fs::read(root.join(Self::path())) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_default() + } + + pub fn write(&self, root: &Path) { + if let Ok(json) = serde_json::to_vec(self) + && let Err(error) = write_atomically(&root.join(Self::path()), &json) + { + eprintln!("the workspace could not be kept: {error}"); + } + } + + fn id_of(&self, path: &Path) -> Option { + self.links + .iter() + .find_map(|(id, link)| (link.path == path).then_some(*id)) + } + + /// Whether the file has edits that the file on the disk does not have. + pub fn is_dirty(&self, engine: &Engine, id: Uuid) -> bool { + match self.links.get(&id) { + Some(link) => signature(engine, id) != Some(link.signature), + None => true, + } + } + + /// The status of each file of the engine. + pub fn state(&self, engine: &Engine) -> WorkspaceState { + WorkspaceState { + folder: self + .folder + .as_ref() + .map(|folder| folder.to_string_lossy().into_owned()), + files: engine + .order() + .iter() + .map(|FileId(id)| { + let link = self.links.get(id); + ( + id.to_string(), + FileStatus { + path: link.map(|link| link.path.to_string_lossy().into_owned()), + dirty: self.is_dirty(engine, *id), + conflict: link.is_some_and(|link| link.conflict), + }, + ) + }) + .collect(), + } + } + + /// Checks the links when the engine has started again: it gives new identities to what it holds + /// (see `signature`), so a file that is the one that was saved looks edited. A file is the same + /// if what the engine writes for it is what it wrote then; its signature is then the current + /// one, and the file is not unsaved. A file that has really been edited stays so. + /// + /// A link made before the content was kept has the file on the disk to tell what was saved, + /// if it is still the one that was read. + pub fn verify(&mut self, engine: &Engine) { + for (id, link) in self.links.iter_mut() { + let (Some(current), Some(exported)) = + (signature(engine, *id), export_hash(engine, *id)) + else { + continue; + }; + if link.export_hash == 0 { + link.export_hash = saved_export_hash(link).unwrap_or_default(); + } + if current != link.signature && exported == link.export_hash { + link.signature = current; + } + } + } + + /// Forgets the links of the files that are not in the engine anymore. + pub fn prune(&mut self, engine: &Engine) { + self.links + .retain(|id, _| engine.file_structure(&FileId(*id)).is_some()); + } + + /// Opens files from the disk: the ones that are not open yet are added, and `select` selects + /// the first of the files that were given (open or not). Says what changed, and which + /// files could not be read. + pub fn open_files( + &mut self, + engine: &mut Engine, + paths: &[PathBuf], + select: bool, + ) -> api::Outcome { + let mut new = vec![]; + let mut known = vec![]; + let mut unreadable = vec![]; + for path in paths { + let path = canonical(path); + if let Some(id) = self.id_of(&path) { + known.push(id); + continue; + } + match fs::read(&path) { + Ok(bytes) => new.push((Uuid::new_v4(), bytes, stem(&path), path)), + Err(error) => unreadable.push(format!("{}: {error}", path.display())), + } + } + let mut outcome = api::Outcome::failed_none(); + if !new.is_empty() { + let files: Vec<(Uuid, &[u8], &str)> = new + .iter() + .map(|(id, bytes, name, _)| (*id, bytes.as_slice(), name.as_str())) + .collect(); + outcome = api::sync_files(engine, &files, &[]); + for (id, bytes, _, path) in &new { + if let Some(signature) = signature(engine, *id) { + self.links.insert( + *id, + Link { + path: path.clone(), + signature, + disk_hash: hash(bytes), + export_hash: export_hash(engine, *id).unwrap_or_default(), + conflict: false, + }, + ); + known.push(*id); + } + } + } + if select && let Some(first) = known.first() { + outcome = outcome.merge(api::select( + engine, + &[*first], + gpx_engine::SelectMode::Replace, + )); + } + if !outcome.has_error() && !unreadable.is_empty() { + outcome = outcome.merge(api::Outcome::failed(unreadable.join("; "))); + } + outcome + } + + /// Opens a folder: the GPX files in it are opened, and kept in line with it from then on (see + /// `reconcile`). The folder that was open is closed (see `close_folder`, which `unsaved` is + /// for), and nothing changes if that fails. + pub fn open_folder( + &mut self, + engine: &mut Engine, + folder: &Path, + unsaved: Option, + ) -> Result { + let folder = canonical(folder); + let closed = if self.folder.as_ref().is_some_and(|open| *open != folder) { + self.close_folder(engine, unsaved)? + } else { + api::Outcome::failed_none() + }; + self.folder = Some(folder.clone()); + Ok(closed.merge(self.open_files(engine, &scan(&folder), false))) + } + + /// The files that belong to the open folder: the ones in it, and the new ones, which are + /// saved in it. + fn belonging_to_folder(&self, engine: &Engine) -> Vec { + let Some(folder) = &self.folder else { + return vec![]; + }; + engine + .order() + .iter() + .map(|FileId(id)| *id) + .filter(|id| match self.links.get(id) { + Some(link) => link.path.starts_with(folder), + None => true, + }) + .collect() + } + + /// The files of the open folder that have unsaved edits, the new files included. + pub fn unsaved_in_folder(&self, engine: &Engine) -> Vec { + self.belonging_to_folder(engine) + .into_iter() + .filter(|id| self.is_dirty(engine, *id)) + .collect() + } + + /// Closes the open folder: its files are closed. The ones that have unsaved edits, and the new + /// files that were not saved (they are saved in the folder), are saved first or discarded, as + /// `unsaved` says; without it they stay open. + /// + /// Nothing is closed if saving fails. + pub fn close_folder( + &mut self, + engine: &mut Engine, + unsaved: Option, + ) -> Result { + if self.folder.is_none() { + return Ok(api::Outcome::failed_none()); + } + let unsaved_ids = self.unsaved_in_folder(engine); + if unsaved == Some(Unsaved::Save) { + self.save(engine, &unsaved_ids, &BTreeMap::new())?; + } + let closing: Vec = self + .belonging_to_folder(engine) + .into_iter() + .filter(|id| unsaved.is_some() || !unsaved_ids.contains(id)) + .collect(); + self.folder = None; + for id in &closing { + self.links.remove(id); + } + if closing.is_empty() { + return Ok(api::Outcome::failed_none()); + } + Ok(api::sync_files(engine, &[], &closing)) + } + + /// Where to put a file that is not on the disk, in the open folder: named like the file, and + /// not the name of a file that is there. + fn free_path(&self, folder: &Path, name: &str) -> PathBuf { + let name = file_name(name); + let taken = + |path: &Path| path.exists() || self.links.values().any(|link| link.path == path); + let mut path = folder.join(format!("{name}.gpx")); + let mut number = 2; + while taken(&path) { + path = folder.join(format!("{name} ({number}).gpx")); + number += 1; + } + path + } + + /// Brings what changed on the disk into the engine: for the given paths (or everything that is + /// in the folder, and everything that is linked, when there are none). + /// + /// - A file that is not open is added. + /// - A file that changed is read again, unless it has edits that the file on the disk does not + /// have: it is flagged as a conflict, and what is in the engine stays. + /// - A file that is gone is deleted, unless it has edits: it becomes a file that only is in + /// the engine. + /// + /// Gives what that changed in the engine, `None` when nothing. + pub fn reconcile( + &mut self, + engine: &mut Engine, + changed: Option<&[PathBuf]>, + ) -> Option { + let folder = self.folder.clone(); + let candidates: Vec = match changed { + None => { + let mut paths = folder.as_deref().map(scan).unwrap_or_default(); + paths.extend(self.links.values().map(|link| link.path.clone())); + paths + } + Some(changed) => { + let mut paths: Vec = changed + .iter() + .filter(|path| { + folder + .as_ref() + .is_some_and(|folder| path.starts_with(folder)) + }) + .cloned() + .collect(); + // a folder that was removed or moved takes its files with it + paths.extend( + self.links + .values() + .map(|link| link.path.clone()) + .filter(|path| changed.iter().any(|other| path.starts_with(other))), + ); + paths + } + }; + let mut candidates: Vec = candidates + .into_iter() + .filter(|path| is_gpx(path) || self.id_of(path).is_some()) + .collect(); + candidates.sort(); + candidates.dedup(); + + // the files to bring in, with what to record once they are + let mut files: Vec<(Uuid, Vec, String, PathBuf)> = vec![]; + let mut remove = vec![]; + let mut flagged = false; + for path in candidates { + let id = self.id_of(&path); + match fs::read(&path) { + Ok(bytes) => { + let disk_hash = hash(&bytes); + match id { + Some(id) => { + let dirty = self.is_dirty(engine, id); + let link = self.links.get_mut(&id).expect("the link exists"); + if disk_hash == link.disk_hash { + continue; + } + if dirty { + link.disk_hash = disk_hash; + link.conflict = true; + flagged = true; + } else { + files.push((id, bytes, stem(&path), path)); + } + } + None => files.push((Uuid::new_v4(), bytes, stem(&path), path)), + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if let Some(id) = id { + if !self.is_dirty(engine, id) { + remove.push(id); + } + self.links.remove(&id); + flagged = true; + } + } + // a folder, or a file that cannot be read now: it is looked at again when it changes + Err(_) => {} + } + } + + let outcome = if files.is_empty() && remove.is_empty() { + None + } else { + let borrowed: Vec<(Uuid, &[u8], &str)> = files + .iter() + .map(|(id, bytes, name, _)| (*id, bytes.as_slice(), name.as_str())) + .collect(); + let outcome = api::sync_files(engine, &borrowed, &remove); + for (id, bytes, _, path) in &files { + match signature(engine, *id) { + Some(signature) => { + self.links.insert( + *id, + Link { + path: path.clone(), + signature, + disk_hash: hash(bytes), + export_hash: export_hash(engine, *id).unwrap_or_default(), + conflict: false, + }, + ); + } + // it could not be read: it is tried again when it changes + None => { + self.links.remove(id); + } + } + } + for id in &remove { + self.links.remove(id); + } + Some(outcome) + }; + if outcome.is_some() || flagged { + self.prune(engine); + } + outcome.or_else(|| flagged.then(api::Outcome::failed_none)) + } + + /// The files among `ids` that are not on the disk, with the name to propose for them. + pub fn unlinked(&self, engine: &Engine, ids: &[Uuid]) -> Vec<(Uuid, String)> { + ids.iter() + .filter(|id| !self.links.contains_key(id)) + .filter_map(|id| Some((*id, engine.file_structure(&FileId(*id))?.name.clone()))) + .collect() + } + + /// Writes files to the disk, replacing what is there: they are the same as their file from + /// then on. `paths` is where to write the ones that are not on the disk yet. Stops at the + /// first error, the files that were written stay written. + pub fn save( + &mut self, + engine: &Engine, + ids: &[Uuid], + paths: &BTreeMap, + ) -> Result<(), String> { + let mut result = Ok(()); + for id in ids { + let path = match self.links.get(id) { + Some(link) => link.path.clone(), + None => match (paths.get(id), &self.folder) { + (Some(path), _) => canonical(path), + // a new file goes in the folder that is open + (None, Some(folder)) => { + let Some(structure) = engine.file_structure(&FileId(*id)) else { + continue; + }; + self.free_path(folder, &structure.name) + } + (None, None) => continue, + }, + }; + let Some(bytes) = api::export_file(engine, *id, ExportOptions::ALL) else { + continue; + }; + if let Err(error) = write_atomically(&path, &bytes) { + result = Err(format!("{}: {error}", path.display())); + break; + } + let Some(signature) = signature(engine, *id) else { + continue; + }; + // the folder, if there is one, may watch what was just written: the hash tells it is known + self.links.insert( + *id, + Link { + path: canonical(&path), + signature, + disk_hash: hash(&bytes), + export_hash: hash(&bytes), + conflict: false, + }, + ); + } + result + } +} + +#[cfg(test)] +mod tests { + use gpx_engine::SelectMode; + + use super::*; + + fn gpx(name: &str) -> String { + format!( + r#"{name}1020"# + ) + } + + struct Fixture { + folder: PathBuf, + engine: Engine, + workspace: Workspace, + } + + impl Fixture { + fn new() -> Self { + let folder = + canonical(&std::env::temp_dir()).join(format!("gpx-ws-{}", Uuid::new_v4())); + fs::create_dir_all(&folder).unwrap(); + Self { + folder, + engine: Engine::default(), + workspace: Workspace::default(), + } + } + + fn write(&self, name: &str, title: &str) -> PathBuf { + let path = self.folder.join(name); + fs::write(&path, gpx(title)).unwrap(); + path + } + + fn ids(&self) -> Vec { + self.engine.order().iter().map(|id| id.0).collect() + } + + fn name(&self, id: Uuid) -> String { + self.engine + .file_structure(&FileId(id)) + .unwrap() + .name + .clone() + } + + fn rename(&mut self, id: Uuid, name: &str) { + api::select(&mut self.engine, &[id], SelectMode::Replace); + assert!(!api::metadata(&mut self.engine, name, "").has_error()); + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.folder); + } + } + + #[test] + fn an_opened_file_is_clean_until_it_is_edited_and_clean_again_once_saved() { + let mut fx = Fixture::new(); + let path = fx.write("a.gpx", "a"); + fx.workspace + .open_files(&mut fx.engine, std::slice::from_ref(&path), true); + let id = fx.ids()[0]; + assert!(!fx.workspace.is_dirty(&fx.engine, id)); + assert_eq!( + fx.workspace.state(&fx.engine).files[&id.to_string()] + .path + .as_deref(), + path.to_str() + ); + + fx.rename(id, "renamed"); + assert!(fx.workspace.is_dirty(&fx.engine, id)); + // nothing is written before it is saved + assert!(fs::read_to_string(&path).unwrap().contains(">a<")); + + fx.workspace + .save(&fx.engine, &[id], &BTreeMap::new()) + .unwrap(); + assert!(!fx.workspace.is_dirty(&fx.engine, id)); + assert!(fs::read_to_string(&path).unwrap().contains(">renamed<")); + + // going back before the save is an edit again + api::undo(&mut fx.engine); + assert!(fx.workspace.is_dirty(&fx.engine, id)); + api::redo(&mut fx.engine); + assert!(!fx.workspace.is_dirty(&fx.engine, id)); + } + + #[test] + fn a_file_that_is_not_on_the_disk_is_unsaved_until_it_is_given_a_place() { + let mut fx = Fixture::new(); + api::new_file(&mut fx.engine, "fresh", None, None, None); + let id = fx.ids()[0]; + assert!(fx.workspace.is_dirty(&fx.engine, id)); + assert_eq!( + fx.workspace.unlinked(&fx.engine, &[id]), + [(id, "fresh".to_string())] + ); + + let path = fx.folder.join("fresh.gpx"); + let paths = BTreeMap::from([(id, path.clone())]); + fx.workspace.save(&fx.engine, &[id], &paths).unwrap(); + assert!(path.exists()); + assert!(!fx.workspace.is_dirty(&fx.engine, id)); + assert!(fx.workspace.unlinked(&fx.engine, &[id]).is_empty()); + } + + #[test] + fn what_this_app_writes_is_not_a_change_of_the_disk() { + let mut fx = Fixture::new(); + let path = fx.write("a.gpx", "a"); + fx.workspace + .open_folder(&mut fx.engine, &fx.folder.clone(), None) + .unwrap(); + let id = fx.ids()[0]; + fx.rename(id, "renamed"); + fx.workspace + .save(&fx.engine, &[id], &BTreeMap::new()) + .unwrap(); + assert!( + fx.workspace + .reconcile(&mut fx.engine, Some(&[path])) + .is_none() + ); + assert_eq!(fx.name(id), "renamed"); + } + + #[test] + fn opening_a_folder_opens_its_files_and_follows_what_happens_to_them() { + let mut fx = Fixture::new(); + let a = fx.write("a.gpx", "a"); + fx.write("notes.txt", "not a track"); + fs::create_dir(fx.folder.join("sub")).unwrap(); + fx.write("sub/b.gpx", "b"); + fx.workspace + .open_folder(&mut fx.engine, &fx.folder.clone(), None) + .unwrap(); + assert_eq!(fx.ids().len(), 2); + let ids = fx.ids(); + let a_id = ids.iter().copied().find(|id| fx.name(*id) == "a").unwrap(); + + // a new file + let c = fx.write("c.gpx", "c"); + let outcome = fx + .workspace + .reconcile(&mut fx.engine, Some(std::slice::from_ref(&c))) + .unwrap(); + assert!(outcome.changed); + assert_eq!(fx.ids().len(), 3); + + // the same file again: nothing + assert!(fx.workspace.reconcile(&mut fx.engine, Some(&[c])).is_none()); + + // a file that changed is read again, and stays the same file + fx.write("a.gpx", "a, edited elsewhere"); + fx.workspace + .reconcile(&mut fx.engine, Some(std::slice::from_ref(&a))) + .unwrap(); + assert_eq!(fx.name(a_id), "a, edited elsewhere"); + assert!(!fx.workspace.is_dirty(&fx.engine, a_id)); + assert_eq!(fx.ids().len(), 3); + + // a file that is gone + fs::remove_file(&a).unwrap(); + fx.workspace.reconcile(&mut fx.engine, Some(&[a])).unwrap(); + assert_eq!(fx.ids().len(), 2); + assert!(!fx.ids().contains(&a_id)); + } + + #[test] + fn the_edits_of_a_file_are_not_overwritten_by_the_disk() { + let mut fx = Fixture::new(); + let a = fx.write("a.gpx", "a"); + fx.workspace + .open_folder(&mut fx.engine, &fx.folder.clone(), None) + .unwrap(); + let id = fx.ids()[0]; + fx.rename(id, "mine"); + + fx.write("a.gpx", "theirs"); + let outcome = fx + .workspace + .reconcile(&mut fx.engine, Some(std::slice::from_ref(&a))); + assert!(!outcome.is_some_and(|outcome| outcome.changed)); + assert_eq!(fx.name(id), "mine"); + assert!(fx.workspace.state(&fx.engine).files[&id.to_string()].conflict); + + // gone from the disk: it stays, as a file that is only here + fs::remove_file(&a).unwrap(); + fx.workspace.reconcile(&mut fx.engine, Some(&[a])); + assert_eq!(fx.ids(), [id]); + let status = &fx.workspace.state(&fx.engine).files[&id.to_string()]; + assert!(status.path.is_none() && status.dirty); + + // saving it again puts it back + let paths = BTreeMap::from([(id, fx.folder.join("a.gpx"))]); + fx.workspace.save(&fx.engine, &[id], &paths).unwrap(); + assert!(!fx.workspace.state(&fx.engine).files[&id.to_string()].conflict); + } + + #[test] + fn what_changed_while_the_app_was_closed_is_found_when_it_starts() { + let mut fx = Fixture::new(); + let a = fx.write("a.gpx", "a"); + fx.workspace + .open_folder(&mut fx.engine, &fx.folder.clone(), None) + .unwrap(); + let id = fx.ids()[0]; + fx.write("a.gpx", "changed"); + fx.write("new.gpx", "new"); + fx.workspace.reconcile(&mut fx.engine, None).unwrap(); + assert_eq!(fx.name(id), "changed"); + assert_eq!(fx.ids().len(), 2); + fs::remove_file(a).unwrap(); + fx.workspace.reconcile(&mut fx.engine, None).unwrap(); + assert_eq!(fx.ids().len(), 1); + } + + #[test] + fn a_new_file_is_saved_in_the_open_folder_without_a_place() { + let mut fx = Fixture::new(); + fx.write("fresh.gpx", "already there"); + fx.workspace + .open_folder(&mut fx.engine, &fx.folder.clone(), None) + .unwrap(); + api::new_file(&mut fx.engine, "fresh", None, None, None); + api::new_file(&mut fx.engine, "a/b: c", None, None, None); + let new: Vec = fx + .ids() + .into_iter() + .filter(|id| fx.workspace.is_dirty(&fx.engine, *id)) + .collect(); + assert_eq!(new.len(), 2); + + fx.workspace + .save(&fx.engine, &new, &BTreeMap::new()) + .unwrap(); + // named like the file, not over what is there, and with a name that is valid + assert!(fx.folder.join("fresh (2).gpx").exists()); + assert!(fx.folder.join("a_b_ c.gpx").exists()); + assert_eq!( + fx.workspace + .state(&fx.engine) + .files + .values() + .filter(|f| f.dirty) + .count(), + 0 + ); + } + + #[test] + fn opening_another_folder_closes_the_first() { + let mut fx = Fixture::new(); + fx.write("one.gpx", "one"); + let other = fx.folder.join("other"); + fs::create_dir(&other).unwrap(); + fs::write(other.join("two.gpx"), gpx("two")).unwrap(); + // the first folder is not the parent of the second + let first = fx.folder.join("first"); + fs::create_dir(&first).unwrap(); + fs::write(first.join("one.gpx"), gpx("one")).unwrap(); + fx.workspace + .open_folder(&mut fx.engine, &first, None) + .unwrap(); + let one = fx.ids()[0]; + fx.workspace + .open_folder(&mut fx.engine, &other, None) + .unwrap(); + assert_eq!(fx.ids().len(), 1); + assert!(!fx.ids().contains(&one)); + assert_eq!(fx.name(fx.ids()[0]), "two"); + assert_eq!( + fx.workspace.folder.as_deref(), + Some(canonical(&other).as_path()) + ); + + // what has edits stays when the folder is closed, and can still be saved where it is + let two = fx.ids()[0]; + fx.rename(two, "edited"); + let outcome = fx.workspace.close_folder(&mut fx.engine, None).unwrap(); + assert!(!outcome.changed); + assert_eq!(fx.ids(), [two]); + assert!(fx.workspace.folder.is_none()); + fx.workspace + .save(&fx.engine, &[two], &BTreeMap::new()) + .unwrap(); + assert!( + fs::read_to_string(other.join("two.gpx")) + .unwrap() + .contains("edited") + ); + let clean = fx.workspace.close_folder(&mut fx.engine, None).unwrap(); + assert!(!clean.changed); + } + + fn folder_with_an_edited_file() -> (Fixture, Uuid) { + let mut fx = Fixture::new(); + fx.write("a.gpx", "a"); + fx.write("b.gpx", "b"); + fx.workspace + .open_folder(&mut fx.engine, &fx.folder.clone(), None) + .unwrap(); + let id = fx.ids().into_iter().find(|id| fx.name(*id) == "a").unwrap(); + fx.rename(id, "edited"); + (fx, id) + } + + #[test] + fn the_unsaved_files_of_a_folder_are_known() { + let (fx, id) = folder_with_an_edited_file(); + assert_eq!(fx.workspace.unsaved_in_folder(&fx.engine), [id]); + } + + #[test] + fn closing_a_folder_can_save_the_unsaved_files_first() { + let (mut fx, _) = folder_with_an_edited_file(); + let outcome = fx + .workspace + .close_folder(&mut fx.engine, Some(Unsaved::Save)) + .unwrap(); + assert!(outcome.changed); + assert!(fx.ids().is_empty()); + assert!( + fs::read_to_string(fx.folder.join("a.gpx")) + .unwrap() + .contains("edited") + ); + assert!(fx.workspace.folder.is_none() && fx.workspace.links.is_empty()); + } + + #[test] + fn closing_a_folder_can_discard_the_unsaved_files() { + let (mut fx, _) = folder_with_an_edited_file(); + fx.workspace + .close_folder(&mut fx.engine, Some(Unsaved::Discard)) + .unwrap(); + assert!(fx.ids().is_empty()); + assert!( + fs::read_to_string(fx.folder.join("a.gpx")) + .unwrap() + .contains(">a<") + ); + } + + #[test] + fn nothing_is_closed_when_the_unsaved_files_cannot_be_saved() { + let (mut fx, id) = folder_with_an_edited_file(); + // the file cannot be written: its place is taken by a folder + fs::remove_file(fx.folder.join("a.gpx")).unwrap(); + fs::create_dir(fx.folder.join("a.gpx")).unwrap(); + let result = fx + .workspace + .close_folder(&mut fx.engine, Some(Unsaved::Save)); + assert!(result.is_err()); + assert!(fx.workspace.folder.is_some()); + assert!(fx.ids().contains(&id)); + } + + #[test] + fn opening_another_folder_applies_the_choice_to_the_first() { + let (mut fx, _) = folder_with_an_edited_file(); + let other = fx.folder.join("other"); + fs::create_dir(&other).unwrap(); + fs::write(other.join("c.gpx"), gpx("c")).unwrap(); + // the folder that is open contains the other one: use two separate ones instead + let elsewhere = canonical(&std::env::temp_dir()).join(format!("gpx-ws-{}", Uuid::new_v4())); + fs::create_dir_all(&elsewhere).unwrap(); + fs::write(elsewhere.join("d.gpx"), gpx("d")).unwrap(); + fx.workspace + .open_folder(&mut fx.engine, &elsewhere, Some(Unsaved::Discard)) + .unwrap(); + assert_eq!(fx.ids().len(), 1); + assert_eq!(fx.name(fx.ids()[0]), "d"); + assert!( + fs::read_to_string(fx.folder.join("a.gpx")) + .unwrap() + .contains(">a<") + ); + fs::remove_dir_all(elsewhere).unwrap(); + } + + #[test] + fn new_files_belong_to_the_open_folder_and_follow_its_unsaved_choice() { + let (mut fx, edited) = folder_with_an_edited_file(); + api::new_file(&mut fx.engine, "fresh", None, None, None); + let fresh = *fx + .ids() + .iter() + .find(|id| **id != edited && fx.name(**id) == "fresh") + .unwrap(); + let unsaved = fx.workspace.unsaved_in_folder(&fx.engine); + assert!(unsaved.contains(&edited) && unsaved.contains(&fresh)); + + // saved in the folder when it is closed + fx.workspace + .close_folder(&mut fx.engine, Some(Unsaved::Save)) + .unwrap(); + assert!(fx.ids().is_empty()); + assert!(fx.folder.join("fresh.gpx").exists()); + assert!( + fs::read_to_string(fx.folder.join("a.gpx")) + .unwrap() + .contains("edited") + ); + } + + #[test] + fn new_files_are_discarded_with_the_folder_or_stay_without_a_choice() { + let (mut fx, edited) = folder_with_an_edited_file(); + api::new_file(&mut fx.engine, "fresh", None, None, None); + // without a choice the unsaved files stay, the new one as well + fx.workspace.close_folder(&mut fx.engine, None).unwrap(); + assert!(fx.ids().contains(&edited)); + assert!(fx.ids().iter().any(|id| fx.name(*id) == "fresh")); + assert!(fx.workspace.folder.is_none()); + + let (mut fx, _) = folder_with_an_edited_file(); + api::new_file(&mut fx.engine, "fresh", None, None, None); + fx.workspace + .close_folder(&mut fx.engine, Some(Unsaved::Discard)) + .unwrap(); + assert!(fx.ids().is_empty()); + assert!(!fx.folder.join("fresh.gpx").exists()); + } +} + +#[cfg(test)] +mod restart_tests { + use gpx_engine::Persister; + + use super::*; + use crate::store::FileStorage; + + /// What the app does when it starts again: the engine and the links are read from what was + /// kept. + fn restart(engine: &Engine, workspace: &Workspace, root: &Path) -> (Engine, Workspace) { + let storage = FileStorage::open(root).unwrap(); + pollster::block_on(Persister::default().save(&storage, engine.snapshot())).unwrap(); + let mut restarted = Engine::default(); + let restored = pollster::block_on(Persister::default().restore(&storage)).unwrap(); + restarted.restore(restored); + let links = serde_json::to_string(workspace).unwrap(); + (restarted, serde_json::from_str(&links).unwrap()) + } + + fn setup() -> (PathBuf, Engine, Workspace, Vec) { + let root = std::env::temp_dir().join(format!("gpx-restart-{}", Uuid::new_v4())); + let folder = root.join("tracks"); + fs::create_dir_all(&folder).unwrap(); + for name in ["with_tracks", "with_time", "with_waypoint"] { + let _ = fs::copy( + format!("../gpx-rs/data/{name}.gpx"), + folder.join(format!("{name}.gpx")), + ); + } + let mut engine = Engine::default(); + let mut workspace = Workspace::default(); + workspace.open_folder(&mut engine, &folder, None).unwrap(); + let ids = engine.order().iter().map(|id| id.0).collect(); + (root, engine, workspace, ids) + } + + #[test] + fn files_that_were_not_edited_are_not_unsaved_after_a_restart() { + let (root, engine, workspace, ids) = setup(); + assert!(ids.len() >= 2); + let (restarted, mut workspace) = restart(&engine, &workspace, &root.join("store")); + // the files are the same, but the engine knows their contents by new identities + assert!(ids.iter().any(|id| workspace.is_dirty(&restarted, *id))); + workspace.verify(&restarted); + assert!(ids.iter().all(|id| !workspace.is_dirty(&restarted, *id))); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn files_that_were_edited_are_still_unsaved_after_a_restart() { + let (root, mut engine, workspace, ids) = setup(); + api::select(&mut engine, &[ids[0]], gpx_engine::SelectMode::Replace); + api::metadata(&mut engine, "edited", ""); + assert!(workspace.is_dirty(&engine, ids[0])); + let (restarted, mut workspace) = restart(&engine, &workspace, &root.join("store")); + workspace.verify(&restarted); + assert!(workspace.is_dirty(&restarted, ids[0])); + assert!( + ids[1..] + .iter() + .all(|id| !workspace.is_dirty(&restarted, *id)) + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn links_made_before_the_content_was_kept_are_checked_against_the_disk() { + let (root, mut engine, mut workspace, ids) = setup(); + api::select(&mut engine, &[ids[0]], gpx_engine::SelectMode::Replace); + api::metadata(&mut engine, "edited", ""); + for link in workspace.links.values_mut() { + link.export_hash = 0; + } + let (restarted, mut workspace) = restart(&engine, &workspace, &root.join("store")); + workspace.verify(&restarted); + assert!(workspace.is_dirty(&restarted, ids[0])); + assert!( + ids[1..] + .iter() + .all(|id| !workspace.is_dirty(&restarted, *id)) + ); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/tauri/tauri.conf.json b/tauri/tauri.conf.json index 26648db44..42dd411cc 100644 --- a/tauri/tauri.conf.json +++ b/tauri/tauri.conf.json @@ -5,7 +5,7 @@ "identifier": "gpx.studio", "build": { "beforeDevCommand": "npm --prefix ../website run dev -- --port 4283", - "devUrl": "http://localhost:4283/app", + "devUrl": "http://localhost:4283", "beforeBuildCommand": "BASE_PATH='' npm --prefix ../website run build", "frontendDist": "../website/build" }, @@ -14,7 +14,7 @@ { "label": "main", "title": "gpx.studio", - "url": "app.html", + "url": "app", "width": 1400, "height": 900, "minWidth": 800, @@ -26,6 +26,15 @@ "bundle": { "active": true, "targets": "all", + "fileAssociations": [ + { + "ext": ["gpx"], + "name": "GPX file", + "description": "GPS Exchange Format", + "mimeType": "application/gpx+xml", + "role": "Editor" + } + ], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/website/src/lib/actions/file-actions.ts b/website/src/lib/actions/file-actions.ts index 1e2ab0773..01b68c85e 100644 --- a/website/src/lib/actions/file-actions.ts +++ b/website/src/lib/actions/file-actions.ts @@ -4,6 +4,9 @@ import { get } from 'svelte/store'; import { boundsManager } from '$lib/state/bounds'; import { engine } from '$lib/engine'; import { defaultFileName } from '$lib/actions/default-file-name'; +import { askUnsaved } from '$lib/components/file-list/unsaved-prompt.svelte'; +import { subfolder } from '$lib/engine/workspace'; +import type { UnsavedChoice } from '$lib/engine'; /** The name of a new file: the translated default name, numbered if other files have it. */ export function newFileName() { @@ -17,6 +20,86 @@ export function createFile() { currentTool.set(Tool.ROUTING); } +/** Asks for the files to open: with the file picker of the system in the desktop app. */ +export async function openFiles() { + if (get(engine.workspace)) { + const ids = await engine.openFiles(); + if (ids.length > 0) { + boundsManager.fitBoundsOnLoad(ids); + } + } else { + triggerFileInput(); + } +} + +/** + * What to do with the files of the open folder that have unsaved changes, which the user is asked + * when there are some: `undefined` when there are none, `'cancel'` when they do not want to go on. + */ +async function askAboutUnsavedFiles(): Promise { + const workspace = get(engine.workspace); + if (!workspace?.folder) { + return undefined; + } + const files = get(engine.files); + const names = Object.entries(workspace.files) + // the files in the folder, and the new ones, which are saved in it + .filter( + ([, file]) => + file.dirty && + (file.path === undefined || subfolder(workspace.folder, file.path) !== undefined) + ) + .map(([id]) => { + const file = files.get(id); + return file ? get(file).structure.name : id; + }); + return names.length > 0 ? askUnsaved(names) : undefined; +} + +/** + * Opens a folder, which is watched (the desktop app). The folder that was open is closed: the user + * is asked what to do with its unsaved files. + */ +export async function openFolder() { + const unsaved = await askAboutUnsavedFiles(); + if (unsaved === 'cancel') { + return; + } + const ids = await engine.openFolder(unsaved); + if (ids.length > 0) { + boundsManager.fitBoundsOnLoad(ids); + } +} + +/** Closes the open folder and its files: the user is asked what to do with the unsaved ones. */ +export async function closeFolder() { + const unsaved = await askAboutUnsavedFiles(); + if (unsaved !== 'cancel') { + await engine.closeFolder(unsaved); + } +} + +/** Saves the selected files to the file system, all the ones that have unsaved edits if none is. */ +export function saveSelected(selectedFileIds: string[]) { + const workspace = get(engine.workspace); + const ids = + selectedFileIds.length > 0 + ? selectedFileIds + : Object.keys(workspace?.files ?? {}).filter((id) => workspace?.files[id].dirty); + if (ids.length > 0) { + engine.save(ids); + } +} + +/** Saves all the files that have unsaved edits to the file system. */ +export function saveAll() { + const workspace = get(engine.workspace); + const ids = Object.keys(workspace?.files ?? {}).filter((id) => workspace?.files[id].dirty); + if (ids.length > 0) { + engine.save(ids); + } +} + export function triggerFileInput() { const input = document.createElement('input'); input.type = 'file'; diff --git a/website/src/lib/engine/backend/tauri.ts b/website/src/lib/engine/backend/tauri.ts index 764a81a4f..201fe4556 100644 --- a/website/src/lib/engine/backend/tauri.ts +++ b/website/src/lib/engine/backend/tauri.ts @@ -1,6 +1,7 @@ import { invoke } from '@tauri-apps/api/core'; -import type { RouteAttributes, StorageOpened } from 'gpx-wasm'; -import type { Backend } from './types'; +import { listen } from '@tauri-apps/api/event'; +import type { Outcome, RouteAttributes, StorageOpened } from 'gpx-wasm'; +import type { Backend, WorkspaceState } from './types'; /** An array that a command returns as its bytes. */ const array = async ( @@ -207,6 +208,21 @@ export async function createTauriBackend(): Promise { segment_ids: () => invoke('segment_ids'), routing_revision: () => invoke('routing_revision'), + desktop: { + workspace: () => invoke('workspace_state'), + openFiles: () => invoke('open_files'), + openFolder: (unsaved) => invoke('open_folder', { unsaved }), + closeFolder: (unsaved) => invoke('close_folder', { unsaved }), + saveFiles: (fileIds) => invoke('save_files', { fileIds }), + listen: async (onEngineChanged, onWorkspaceChanged, onFilesOpened) => { + await listen('files-opened', (event) => onFilesOpened(event.payload)); + await listen('engine-changed', (event) => onEngineChanged(event.payload)); + await listen('workspace-changed', (event) => + onWorkspaceChanged(event.payload) + ); + }, + }, + importsLegacyData: false, open_storage: () => invoke('open_storage'), flush_storage: () => invoke('flush_storage'), diff --git a/website/src/lib/engine/backend/types.ts b/website/src/lib/engine/backend/types.ts index 491136efe..29d25e958 100644 --- a/website/src/lib/engine/backend/types.ts +++ b/website/src/lib/engine/backend/types.ts @@ -12,6 +12,61 @@ import type { } from 'gpx-wasm'; import type { MergeType, SelectMode, SplitType } from '../types'; +/** What the desktop app knows of a file of the engine and the file system. */ +export type FileStatus = { + /** Where the file is on the disk, absent for a file that only is in the engine. */ + path?: string; + /** The file has edits that the file on the disk does not have (or is not on the disk). */ + dirty: boolean; + /** The file on the disk was changed by something else while this one had edits. */ + conflict: boolean; +}; + +/** What to do with the files of a folder that have unsaved edits when it is closed. */ +export type UnsavedChoice = 'save' | 'discard'; + +export type WorkspaceState = { + /** The folder that is open, and watched. */ + folder?: string; + files: Record; +}; + +/** + * What the desktop app adds: its files are linked to files of the file system. Only the backend of + * the desktop app has it. + */ +export interface DesktopFiles { + workspace(): Promise; + /** Opens the files that the user picks, which get selected. */ + openFiles(): Promise; + /** + * Opens the folder that the user picks: its files are opened, and it is watched. The folder + * that was open is closed (see `closeFolder` for `unsaved`). + */ + openFolder(unsaved?: UnsavedChoice): Promise; + /** + * Closes the open folder: it is not watched anymore, and its files are closed. What to do with + * the ones that have unsaved edits: `save` them first, `discard` the edits, or keep them open + * when it is not said. Rejects, and closes nothing, if they could not be saved. + */ + closeFolder(unsaved?: UnsavedChoice): Promise; + /** + * Writes files to the disk, replacing what is there (the user is asked where to put the ones + * that are not on the disk yet). + */ + saveFiles(fileIds: string[]): Promise; + /** + * Tells when the engine changed without being asked, because the files on the disk did (the + * outcome to apply), when the status of the files changed, and when files were opened with the + * app from the system (the outcome to apply, which selected them). + */ + listen( + onEngineChanged: (outcome: Outcome) => void, + onWorkspaceChanged: (workspace: WorkspaceState) => void, + onFilesOpened: (outcome: Outcome) => void + ): Promise; +} + /** * What the facade needs of the engine of the files, wherever it runs: the WASM module in the * browser (`wasm.ts`), or the Rust process of the desktop app, through its commands (`tauri.ts`). @@ -184,6 +239,9 @@ export interface Backend { segment_ids(): Promise; routing_revision(): Promise; + /** Present when the files are linked to the file system (the desktop app). */ + readonly desktop?: DesktopFiles; + // Storage /** * Whether the files that the previous versions of the app kept in the browser can be imported diff --git a/website/src/lib/engine/engine.ts b/website/src/lib/engine/engine.ts index b354377fa..e6e8bd466 100644 --- a/website/src/lib/engine/engine.ts +++ b/website/src/lib/engine/engine.ts @@ -29,6 +29,8 @@ import { type SelectMode, type SplitType, type StatisticsRequest, + type UnsavedChoice, + type WorkspaceState, type TrackpointDetails, type WaypointDetails, } from './types'; @@ -60,6 +62,7 @@ class Engine { private _selection = writable({ type: 'empty' }); private _lastError = writable(undefined); private _outdated = writable(false); + private _workspace = writable(undefined); private _canUndo = writable(false); private _canRedo = writable(false); private _canPaste = writable(false); @@ -74,6 +77,13 @@ class Engine { readonly outdated: Readable = { subscribe: this._outdated.subscribe }; readonly canUndo: Readable = { subscribe: this._canUndo.subscribe }; readonly canRedo: Readable = { subscribe: this._canRedo.subscribe }; + /** + * Where the files are on the file system, and which ones have edits that are not saved there. + * `undefined` unless the files are linked to the file system (the desktop app). + */ + readonly workspace: Readable = { + subscribe: this._workspace.subscribe, + }; /** Why the last call to the engine did nothing, `undefined` if it did something. */ readonly lastError: Readable = { subscribe: this._lastError.subscribe }; /** What was copied or cut, waiting to be pasted. */ @@ -92,8 +102,9 @@ class Engine { constructor() { this.ready = browser - ? loadBackend().then((backend) => { + ? loadBackend().then(async (backend) => { this.backend = backend; + await this.followDisk(backend); }) : new Promise(() => {}); } @@ -533,6 +544,106 @@ class Engine { return this.run((b) => b.change_loop_start(revision, index)); } + // The file system (the desktop app) + + /** + * Opens the files that the user picks on the file system, which get selected. Resolves to the + * ids of the files that were added. + */ + async openFiles(): Promise { + return this.addedBy((b) => b.desktop!.openFiles()); + } + + /** + * Opens the folder that the user picks (instead of the one that was open, which is closed like + * `closeFolder` does, `unsaved` being what to do with its unsaved files): its files are opened, + * and it is watched: the files that appear, change or disappear in it are brought in the engine. + * New files are saved in it. Resolves to the ids of the files that were added. + */ + async openFolder(unsaved?: UnsavedChoice): Promise { + return this.addedBy((b) => b.desktop!.openFolder(unsaved)); + } + + /** + * Closes the open folder: it is not watched anymore, and its files are closed. `unsaved` says + * what to do with the ones that have unsaved edits: they are saved first, or their edits are + * discarded (they stay open when it is not said). + */ + async closeFolder(unsaved?: UnsavedChoice) { + await this.run((b) => b.desktop!.closeFolder(unsaved)); + await this.enqueue(async () => this.refreshWorkspace(this.backend!)); + } + + /** + * Writes files to the file system, replacing what is there; they are not edited anymore from + * then on. The user is asked where to put the ones that are not on the file system yet. + */ + async save(fileIds: string[]) { + await this.ready; + await this.enqueue(async () => { + const backend = this.backend!; + try { + await backend.desktop!.saveFiles(fileIds); + } catch (error) { + console.error('The files could not be saved', error); + this._lastError.set(String(error)); + } + await this.refreshWorkspace(backend); + }); + } + + /** Runs a call that adds files, and resolves to the ids that it added. */ + private async addedBy(action: (backend: Backend) => Promise): Promise { + const before = new Set(get(this._order)); + await this.run(action); + return get(this._order).filter((id) => !before.has(id)); + } + + private _opened: ((ids: string[]) => void)[] = []; + + /** + * Calls `callback` with the ids of the files that were added when the system opens files with + * the app (open with..., double click), which is already running to receive them. Returns what + * stops it. + */ + onFilesOpened(callback: (ids: string[]) => void): () => void { + this._opened.push(callback); + return () => { + this._opened = this._opened.filter((other) => other !== callback); + }; + } + + private async refreshWorkspace(backend: Backend) { + if (backend.desktop) { + this._workspace.set(await backend.desktop.workspace()); + } + } + + /** + * Follows what happens on the file system when it is not the user who does it: the files that + * changed on the disk are brought in the engine, which says so. + */ + private async followDisk(backend: Backend) { + if (!backend.desktop) { + return; + } + this._workspace.set(await backend.desktop.workspace()); + await backend.desktop.listen( + (outcome) => + void this.enqueue(async () => { + await this.sync(backend, outcome); + this.report(outcome); + }), + (workspace) => this._workspace.set(workspace), + (outcome) => + void this.enqueue(async () => { + await this.sync(backend, outcome); + this.report(outcome); + this._opened.forEach((callback) => callback(outcome.added)); + }) + ); + } + // Visibility, which is kept by the frontend and not by the engine /** Hides or shows the selected elements. */ @@ -685,10 +796,17 @@ class Engine { await this.ready; return this.enqueue(async () => { const backend = this.backend!; - const outcome = await action(backend); - await this.sync(backend, outcome); - this.report(outcome); - return outcome.changed; + try { + const outcome = await action(backend); + await this.sync(backend, outcome); + this.report(outcome); + return outcome.changed; + } catch (error) { + // what the engine refuses to do, like closing a folder whose files cannot be saved + console.error('engine:', error); + this._lastError.set(String(error)); + return false; + } }); } @@ -768,6 +886,10 @@ class Engine { } }); + if (update.added.length + update.modified.length + update.removed.length > 0) { + await this.refreshWorkspace(backend); + } + // the statistics depend on the selection, on its order, and on the files if ( update.selectionChanged || diff --git a/website/src/lib/engine/types.ts b/website/src/lib/engine/types.ts index 7e643ce76..c0017aba6 100644 --- a/website/src/lib/engine/types.ts +++ b/website/src/lib/engine/types.ts @@ -4,6 +4,8 @@ import type { Visibility } from '$lib/engine/file-visibility'; import type { CategoryIntervals } from '$lib/engine/trackpoint-categories'; import type { FileStructure, GlobalStatistics } from 'gpx-wasm'; +export type { FileStatus, UnsavedChoice, WorkspaceState } from './backend/types'; + export type { Clipboard, WaypointDetails, diff --git a/website/src/lib/engine/workspace.test.ts b/website/src/lib/engine/workspace.test.ts new file mode 100644 index 000000000..0854b30df --- /dev/null +++ b/website/src/lib/engine/workspace.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { folderName, subfolder } from '$lib/engine/workspace'; + +describe('subfolder', () => { + it('is where a file is in the folder', () => { + assert.equal(subfolder('/home/me/tracks', '/home/me/tracks/a.gpx'), ''); + assert.equal(subfolder('/home/me/tracks', '/home/me/tracks/2024/may/a.gpx'), '2024/may'); + assert.equal( + subfolder('C:\\Users\\me\\tracks', 'C:\\Users\\me\\tracks\\2024\\a.gpx'), + '2024' + ); + }); + + it('is nothing for a file that is not in the folder', () => { + assert.equal(subfolder('/home/me/tracks', '/home/me/other/a.gpx'), undefined); + assert.equal(subfolder('/home/me/tracks', '/home/me/tracks-old/a.gpx'), undefined); + assert.equal(subfolder('/home/me/tracks', undefined), undefined); + assert.equal(subfolder(undefined, '/home/me/tracks/a.gpx'), undefined); + }); +}); + +describe('folderName', () => { + it('is the last part of the path', () => { + assert.equal(folderName('/home/me/tracks'), 'tracks'); + assert.equal(folderName('/home/me/tracks/'), 'tracks'); + assert.equal(folderName('C:\\Users\\me\\tracks'), 'tracks'); + }); +}); diff --git a/website/src/lib/engine/workspace.ts b/website/src/lib/engine/workspace.ts new file mode 100644 index 000000000..5723c6021 --- /dev/null +++ b/website/src/lib/engine/workspace.ts @@ -0,0 +1,28 @@ +/** `\` and `/` alike. */ +function parts(path: string): string[] { + return path.split(/[\\/]+/).filter((part) => part !== ''); +} + +/** The name of the folder, the last part of its path. */ +export function folderName(folder: string): string { + return parts(folder).at(-1) ?? folder; +} + +/** + * Where a file is in the open folder: the folders between them, as a path (`''` if it is in the + * folder itself). `undefined` for a file that is not in the folder (or not on the disk). + */ +export function subfolder( + folder: string | undefined, + path: string | undefined +): string | undefined { + if (folder === undefined || path === undefined) { + return undefined; + } + const root = parts(folder); + const file = parts(path); + if (file.length <= root.length || root.some((part, i) => part !== file[i])) { + return undefined; + } + return file.slice(root.length, -1).join('/'); +} diff --git a/website/src/routes/[[language]]/app/+page.svelte b/website/src/routes/[[language]]/app/+page.svelte index ed9e5a4e4..063871d12 100644 --- a/website/src/routes/[[language]]/app/+page.svelte +++ b/website/src/routes/[[language]]/app/+page.svelte @@ -19,6 +19,7 @@ import { page } from '$app/state'; import { hoveredPoint, slicedStatistics } from '$lib/state/selection-statistics'; import { engine } from '$lib/engine'; + import { boundsManager } from '$lib/state/bounds'; import { getURLForGoogleDriveFile } from '$lib/components/embedding/embedding'; const outdated = engine.outdated; @@ -38,6 +39,8 @@ ); onMount(async () => { + // files that the system opens with the app while it runs + engine.onFilesOpened((ids) => boundsManager.fitBoundsOnLoad(ids)); settings.connect(await engine.openStorage()); let files: string[] = JSON.parse(page.url.searchParams.get('files') || '[]');