mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-17 08:42:28 +00:00
progress
This commit is contained in:
1 parent
2e1ca00afa
commit
1ba5436af3
29 files changed
+3240
-82
No files matched your search
Generated
+716
-8
@@ -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",
|
||||
]
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
+56
-1
@@ -17,7 +17,7 @@ pub fn ids(items: &[FileId]) -> Vec<String> {
|
||||
#[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<String>, from: Vec<String>) {
|
||||
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<String>) -> Self {
|
||||
Self::new(&engine::Outcome::default(), Some(message.into()))
|
||||
|
||||
@@ -9,7 +9,7 @@ pub struct Load<'a> {
|
||||
}
|
||||
|
||||
impl Load<'_> {
|
||||
fn parse(self, categories: &mut TrackpointCategories) -> Result<File, CommandError> {
|
||||
pub(crate) fn parse(self, categories: &mut TrackpointCategories) -> Result<File, CommandError> {
|
||||
let mut file = parse(self.data, categories)
|
||||
.map_err(|err| CommandError::InvalidData(err.to_string()))?;
|
||||
if file.info.name.trim().is_empty() {
|
||||
|
||||
@@ -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::*;
|
||||
@@ -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<ExternalFile<'a>>,
|
||||
pub remove: Vec<FileId>,
|
||||
}
|
||||
|
||||
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#"<gpx version="1.1"><metadata><name>from disk</name></metadata></gpx>"#;
|
||||
|
||||
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"<gpx><trk></gpx>",
|
||||
name: "x",
|
||||
}],
|
||||
remove: vec![],
|
||||
};
|
||||
assert!(matches!(
|
||||
invalid.apply(&mut fx.state()),
|
||||
Err(CommandError::InvalidData(_))
|
||||
));
|
||||
assert!(fx.files.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
+6
-1
@@ -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"
|
||||
|
||||
@@ -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<FilePath>) -> Vec<PathBuf> {
|
||||
picked
|
||||
.into_iter()
|
||||
.filter_map(|path| path.into_path().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn workspace_state(state: State<AppState>) -> 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<R: Runtime>(app: AppHandle<R>, state: State<AppState>) -> 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<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
state: State<AppState>,
|
||||
unsaved: Option<Unsaved>,
|
||||
) -> Result<api::Outcome, String> {
|
||||
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<AppState>,
|
||||
unsaved: Option<Unsaved>,
|
||||
) -> Result<api::Outcome, String> {
|
||||
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<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
state: State<AppState>,
|
||||
file_ids: Vec<Uuid>,
|
||||
) -> 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
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Changing what is selected.
|
||||
|
||||
use gpx_engine::{api, SelectMode};
|
||||
use gpx_engine::{SelectMode, api};
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
||||
@@ -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<String, String>,
|
||||
outcome: Outcome,
|
||||
settings: BTreeMap<String, String>,
|
||||
/// 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<AppState>) -> 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<AppState>, key: String, value: String) {
|
||||
state.settings().insert(key, value);
|
||||
state.put_setting(&key, &value);
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn delete_setting(state: State<AppState>, key: String) {
|
||||
state.settings().remove(&key);
|
||||
state.delete_setting(&key);
|
||||
}
|
||||
+73
-8
@@ -4,21 +4,80 @@
|
||||
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<Item = String>) -> Vec<std::path::PathBuf> {
|
||||
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::AppState>() {
|
||||
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::<state::AppState>()
|
||||
{
|
||||
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<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Builder<R> {
|
||||
builder
|
||||
.manage(state::AppState::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
/// Gives the builder the commands that reach the engine (which it has to be given as `AppState`).
|
||||
fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Builder<R> {
|
||||
builder.invoke_handler(tauri::generate_handler![
|
||||
// selection
|
||||
selection::select,
|
||||
selection::select_all,
|
||||
@@ -118,6 +177,12 @@ fn with_engine<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Builder<
|
||||
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,
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
+205
-33
@@ -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<dyn FnOnce(&mut Engine) + Send>;
|
||||
use crate::store::FileStorage;
|
||||
use crate::workspace::{Workspace, WorkspaceState};
|
||||
|
||||
/// Tells the frontend that something happened that it did not ask for: a name and a JSON value.
|
||||
pub type Emit = Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>;
|
||||
|
||||
type Job = Box<dyn FnOnce(&mut Core) + Send>;
|
||||
|
||||
/// Everything that lives on the thread of the engine.
|
||||
pub struct Core {
|
||||
pub engine: Engine,
|
||||
pub workspace: Workspace,
|
||||
storage: Arc<FileStorage>,
|
||||
persister: Persister,
|
||||
watcher: Option<Debouncer<notify::RecommendedWatcher>>,
|
||||
jobs: Sender<Job>,
|
||||
emit: Emit,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
jobs: Mutex<Sender<Job>>,
|
||||
/// The settings of the frontend, as JSON strings by key.
|
||||
settings: Mutex<BTreeMap<String, String>>,
|
||||
jobs: Arc<Mutex<Sender<Job>>>,
|
||||
storage: Arc<FileStorage>,
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
let (jobs, receiver) = mpsc::channel::<Job>();
|
||||
thread::Builder::new()
|
||||
.name("engine".into())
|
||||
.spawn(move || {
|
||||
let mut engine = Engine::default();
|
||||
while let Ok(job) = receiver.recv() {
|
||||
job(&mut engine);
|
||||
impl Core {
|
||||
fn start(storage: Arc<FileStorage>, jobs: Sender<Job>, 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);
|
||||
}
|
||||
})
|
||||
.expect("the engine thread could not be started");
|
||||
Self {
|
||||
jobs: Mutex::new(jobs),
|
||||
settings: Mutex::default(),
|
||||
// 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<PathBuf> = 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}"),
|
||||
}
|
||||
}
|
||||
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<T: Serialize>(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<T: Send + 'static>(&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<PathBuf>, emit: Emit) -> Result<Self, String> {
|
||||
let storage = Arc::new(FileStorage::open(directory).map_err(|error| error.to_string())?);
|
||||
let (jobs, receiver) = mpsc::channel::<Job>();
|
||||
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<PathBuf>) {
|
||||
self.core(move |core| core.open_external(&paths));
|
||||
}
|
||||
|
||||
/// Runs `f` on the thread of the engine and waits for its result.
|
||||
pub fn core<T: Send + 'static>(&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<T: Send + 'static>(&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<T: Send + 'static>(&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<String, String>> {
|
||||
self.settings.lock().unwrap()
|
||||
/// Reads from the engine.
|
||||
pub fn read<T: Send + 'static>(&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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/<id> the record of each file
|
||||
//! trackpoints/<id> the chunks of trackpoints
|
||||
//! waypoints/<id> 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<BTreeMap<String, String>>,
|
||||
}
|
||||
|
||||
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<Option<Vec<u8>>> {
|
||||
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<PathBuf>) -> Result<Self, StorageError> {
|
||||
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<String, String> {
|
||||
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<Vec<(Uuid, Vec<u8>)>, 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<String, String>) -> 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<Revision, StorageError> {
|
||||
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<StoredData, StorageError> {
|
||||
let chunks = |kind: ChunkKind, name: &str| -> Result<Vec<_>, 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<Revision, StorageError> {
|
||||
self.read_head()
|
||||
}
|
||||
|
||||
async fn commit(&self, batch: &Batch) -> Result<Revision, CommitError> {
|
||||
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<Vec<(String, String)>, 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();
|
||||
}
|
||||
}
|
||||
+90
-6
@@ -1,16 +1,17 @@
|
||||
//! 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<tauri::test::MockRuntime>);
|
||||
|
||||
impl App {
|
||||
fn new() -> Self {
|
||||
let app = crate::with_engine(mock_builder())
|
||||
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())
|
||||
@@ -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())
|
||||
assert!(
|
||||
String::from_utf8(exported[1..].to_vec())
|
||||
.unwrap()
|
||||
.contains("<wpt"));
|
||||
.contains("<wpt")
|
||||
);
|
||||
assert_eq!(app.call("exportable_data", json!({ "fileIds": [file] })), 0);
|
||||
|
||||
let stale = app.call(
|
||||
@@ -274,3 +277,84 @@ fn keeps_settings_for_the_session() {
|
||||
app.call("delete_setting", json!({ "key": "units" }));
|
||||
assert_eq!(app.call("open_storage", json!({}))["settings"], json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_files_are_still_there_in_the_next_session() {
|
||||
use crate::state::AppState;
|
||||
use gpx_engine::api;
|
||||
|
||||
let directory =
|
||||
std::env::temp_dir().join(format!("gpx-studio-session-{}", uuid::Uuid::new_v4()));
|
||||
let data = std::fs::read("../gpx-rs/data/simple.gpx").unwrap();
|
||||
let first = AppState::open(&directory, std::sync::Arc::new(|_, _| {})).unwrap();
|
||||
let lengths = [data.len()];
|
||||
let names = ["simple".to_string()];
|
||||
first.run(move |e| api::load_files(e, &data, &lengths, &names));
|
||||
let order = first.read(api::file_order);
|
||||
|
||||
let second = AppState::open(&directory, std::sync::Arc::new(|_, _| {})).unwrap();
|
||||
assert_eq!(second.read(api::file_order), order);
|
||||
let outcome = second.read(api::Outcome::everything);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&outcome).unwrap()["added"],
|
||||
serde_json::json!(order)
|
||||
);
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_gpx_files_that_exist_are_taken_from_what_the_system_gives() {
|
||||
let directory = std::env::temp_dir().join(format!("gpx-studio-args-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
let track = directory.join("track.GPX");
|
||||
std::fs::copy("../gpx-rs/data/simple.gpx", &track).unwrap();
|
||||
let text = directory.join("notes.txt");
|
||||
std::fs::write(&text, "x").unwrap();
|
||||
let arguments = [
|
||||
"--flag".to_string(),
|
||||
track.to_string_lossy().into_owned(),
|
||||
text.to_string_lossy().into_owned(),
|
||||
directory.join("missing.gpx").to_string_lossy().into_owned(),
|
||||
];
|
||||
assert_eq!(crate::gpx_paths(arguments), [track]);
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_opened_with_the_app_is_opened_selected_and_announced() {
|
||||
use crate::state::AppState;
|
||||
use gpx_engine::api;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
let directory = std::env::temp_dir().join(format!("gpx-studio-open-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
let track = directory.join("track.gpx");
|
||||
std::fs::copy("../gpx-rs/data/simple.gpx", &track).unwrap();
|
||||
|
||||
let events = Arc::new(Mutex::new(vec![]));
|
||||
let sink = events.clone();
|
||||
let state = AppState::open(
|
||||
directory.join("store"),
|
||||
Arc::new(move |name, payload| sink.lock().unwrap().push((name.to_string(), payload))),
|
||||
)
|
||||
.unwrap();
|
||||
state.open_external(vec![track.clone()]);
|
||||
// opening it again is a selection, not another file
|
||||
state.open_external(vec![track]);
|
||||
|
||||
assert_eq!(state.read(api::file_order).len(), 1);
|
||||
assert!(matches!(
|
||||
state.read(api::selection),
|
||||
api::Selection::File { .. }
|
||||
));
|
||||
let events = events.lock().unwrap();
|
||||
let opened: Vec<_> = 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();
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+11
-2
@@ -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",
|
||||
|
||||
@@ -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<UnsavedChoice | undefined | 'cancel'> {
|
||||
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';
|
||||
|
||||
@@ -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 <T>(
|
||||
@@ -207,6 +208,21 @@ export async function createTauriBackend(): Promise<Backend> {
|
||||
segment_ids: () => invoke('segment_ids'),
|
||||
routing_revision: () => invoke('routing_revision'),
|
||||
|
||||
desktop: {
|
||||
workspace: () => invoke<WorkspaceState>('workspace_state'),
|
||||
openFiles: () => invoke<Outcome>('open_files'),
|
||||
openFolder: (unsaved) => invoke<Outcome>('open_folder', { unsaved }),
|
||||
closeFolder: (unsaved) => invoke<Outcome>('close_folder', { unsaved }),
|
||||
saveFiles: (fileIds) => invoke('save_files', { fileIds }),
|
||||
listen: async (onEngineChanged, onWorkspaceChanged, onFilesOpened) => {
|
||||
await listen<Outcome>('files-opened', (event) => onFilesOpened(event.payload));
|
||||
await listen<Outcome>('engine-changed', (event) => onEngineChanged(event.payload));
|
||||
await listen<WorkspaceState>('workspace-changed', (event) =>
|
||||
onWorkspaceChanged(event.payload)
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
importsLegacyData: false,
|
||||
open_storage: () => invoke<StorageOpened>('open_storage'),
|
||||
flush_storage: () => invoke('flush_storage'),
|
||||
|
||||
@@ -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<string, FileStatus>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<WorkspaceState>;
|
||||
/** Opens the files that the user picks, which get selected. */
|
||||
openFiles(): Promise<Outcome>;
|
||||
/**
|
||||
* 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<Outcome>;
|
||||
/**
|
||||
* 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<Outcome>;
|
||||
/**
|
||||
* 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<void>;
|
||||
/**
|
||||
* 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<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string[]>;
|
||||
routing_revision(): Promise<number>;
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -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<Selection>({ type: 'empty' });
|
||||
private _lastError = writable<string | undefined>(undefined);
|
||||
private _outdated = writable(false);
|
||||
private _workspace = writable<WorkspaceState | undefined>(undefined);
|
||||
private _canUndo = writable(false);
|
||||
private _canRedo = writable(false);
|
||||
private _canPaste = writable(false);
|
||||
@@ -74,6 +77,13 @@ class Engine {
|
||||
readonly outdated: Readable<boolean> = { subscribe: this._outdated.subscribe };
|
||||
readonly canUndo: Readable<boolean> = { subscribe: this._canUndo.subscribe };
|
||||
readonly canRedo: Readable<boolean> = { 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<WorkspaceState | undefined> = {
|
||||
subscribe: this._workspace.subscribe,
|
||||
};
|
||||
/** Why the last call to the engine did nothing, `undefined` if it did something. */
|
||||
readonly lastError: Readable<string | undefined> = { 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<string[]> {
|
||||
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<string[]> {
|
||||
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<Outcome>): Promise<string[]> {
|
||||
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!;
|
||||
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 ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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('/');
|
||||
}
|
||||
@@ -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') || '[]');
|
||||
|
||||
Reference in new issue
Block a user