This commit is contained in:
vcoppe committed 2026-10-14 19:15:02 +02:00
1 parent 2e1ca00afa
commit 1ba5436af3
29 files changed
+3348 -190

No files matched your search

Generated
+716 -8
View File
@@ -47,6 +47,126 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" 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]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.92" version = "0.1.92"
@@ -168,6 +288,19 @@ dependencies = [
"objc2", "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]] [[package]]
name = "brotli" name = "brotli"
version = "9.0.0" version = "9.0.0"
@@ -375,6 +508,15 @@ dependencies = [
"memchr", "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]] [[package]]
name = "console_error_panic_hook" name = "console_error_panic_hook"
version = "0.1.7" version = "0.1.7"
@@ -779,6 +921,33 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" 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]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -796,6 +965,36 @@ dependencies = [
"typeid", "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]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.5.0" version = "2.5.0"
@@ -886,6 +1085,15 @@ dependencies = [
"percent-encoding", "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]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.34" version = "0.3.34"
@@ -918,6 +1126,19 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" 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]] [[package]]
name = "futures-macro" name = "futures-macro"
version = "0.3.34" version = "0.3.34"
@@ -1201,10 +1422,15 @@ name = "gpx-studio-desktop"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"gpx-engine", "gpx-engine",
"notify",
"notify-debouncer-mini",
"pollster",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-single-instance",
"uuid", "uuid",
] ]
@@ -1323,6 +1549,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
[[package]] [[package]]
name = "hex" name = "hex"
version = "0.4.3" version = "0.4.3"
@@ -1612,6 +1844,26 @@ dependencies = [
"cfb", "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]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.2" version = "2.12.2"
@@ -1788,6 +2040,26 @@ dependencies = [
"serde", "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]] [[package]]
name = "libappindicator" name = "libappindicator"
version = "0.9.0" version = "0.9.0"
@@ -1852,6 +2124,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.3" version = "0.8.3"
@@ -1942,6 +2220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [ dependencies = [
"libc", "libc",
"log",
"wasi", "wasi",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -2003,6 +2282,45 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" 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]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -2174,6 +2492,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2", "block2",
"libc",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -2264,6 +2583,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 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]] [[package]]
name = "pango" name = "pango"
version = "0.18.3" version = "0.18.3"
@@ -2289,6 +2618,12 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -2377,6 +2712,17 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" 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]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.34" version = "0.3.34"
@@ -2422,6 +2768,26 @@ dependencies = [
"miniz_oxide 0.8.9", "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]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.15.0" version = "1.15.0"
@@ -2671,6 +3037,30 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.3" version = "2.1.3"
@@ -2686,6 +3076,19 @@ dependencies = [
"semver", "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]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.23" version = "1.0.23"
@@ -2974,6 +3377,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" 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]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.10" version = "0.3.10"
@@ -3347,6 +3760,80 @@ dependencies = [
"tauri-utils", "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]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.12.1" version = "2.12.1"
@@ -3447,6 +3934,19 @@ dependencies = [
"toml 1.1.8+spec-1.1.0", "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]] [[package]]
name = "tendril" name = "tendril"
version = "0.5.1" version = "0.5.1"
@@ -3717,9 +4217,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"pin-project-lite", "pin-project-lite",
"tracing-attributes",
"tracing-core", "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]] [[package]]
name = "tracing-core" name = "tracing-core"
version = "0.1.36" version = "0.1.36"
@@ -3769,6 +4281,17 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" 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]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@@ -4262,7 +4785,16 @@ version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [ 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]] [[package]]
@@ -4280,13 +4812,30 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [ dependencies = [
"windows_aarch64_gnullvm", "windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc", "windows_aarch64_msvc 0.42.2",
"windows_i686_gnu", "windows_i686_gnu 0.42.2",
"windows_i686_msvc", "windows_i686_msvc 0.42.2",
"windows_x86_64_gnu", "windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm", "windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc", "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]] [[package]]
@@ -4313,42 +4862,90 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.42.2" version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.42.2" version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" 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]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.42.2" version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.42.2" version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" 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]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.42.2" version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" 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]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.42.2" version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" 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]] [[package]]
name = "winnow" name = "winnow"
version = "0.5.40" version = "0.5.40"
@@ -4477,6 +5074,76 @@ dependencies = [
"synstructure", "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]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.8" version = "0.1.8"
@@ -4542,3 +5209,44 @@ name = "zmij"
version = "1.0.23" version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" 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",
]
+21
View File
@@ -178,6 +178,27 @@ pub fn load_files(
edit(engine, Command::LoadFiles(engine::LoadFiles { 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 { pub fn duplicate(engine: &mut Engine) -> Outcome {
edit(engine, Command::Duplicate(engine::Duplicate)) edit(engine, Command::Duplicate(engine::Duplicate))
} }
+56 -1
View File
@@ -17,7 +17,7 @@ pub fn ids(items: &[FileId]) -> Vec<String> {
#[derive(Serialize)] #[derive(Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Outcome { pub struct Outcome {
changed: bool, pub changed: bool,
order_changed: bool, order_changed: bool,
selection_changed: bool, selection_changed: bool,
clipboard_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. /// The outcome of a call that did nothing and has nothing to say.
pub fn failed_none() -> Self { pub fn failed_none() -> Self {
Self::new(&engine::Outcome::default(), None) 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. /// What a call that does nothing returns.
pub fn failed(message: impl Into<String>) -> Self { pub fn failed(message: impl Into<String>) -> Self {
Self::new(&engine::Outcome::default(), Some(message.into())) Self::new(&engine::Outcome::default(), Some(message.into()))
+1 -1
View File
@@ -9,7 +9,7 @@ pub struct Load<'a> {
} }
impl Load<'_> { 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) let mut file = parse(self.data, categories)
.map_err(|err| CommandError::InvalidData(err.to_string()))?; .map_err(|err| CommandError::InvalidData(err.to_string()))?;
if file.info.name.trim().is_empty() { if file.info.name.trim().is_empty() {
+2
View File
@@ -6,6 +6,7 @@ mod load;
mod move_elements; mod move_elements;
mod new; mod new;
mod paste; mod paste;
mod sync;
mod transfer; mod transfer;
pub use delete::*; pub use delete::*;
@@ -16,4 +17,5 @@ pub use load::*;
pub use move_elements::*; pub use move_elements::*;
pub use new::*; pub use new::*;
pub use paste::*; pub use paste::*;
pub use sync::*;
pub use transfer::*; pub use transfer::*;
+148
View File
@@ -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());
}
}
+2
View File
@@ -24,6 +24,7 @@ pub enum Command<'a> {
New(New<'a>), New(New<'a>),
Load(Load<'a>), Load(Load<'a>),
LoadFiles(LoadFiles<'a>), LoadFiles(LoadFiles<'a>),
SyncFiles(SyncFiles<'a>),
Duplicate(Duplicate), Duplicate(Duplicate),
Delete(Delete), Delete(Delete),
Paste(Paste), Paste(Paste),
@@ -58,6 +59,7 @@ impl Apply for Command<'_> {
Command::New(c) => c.apply(state), Command::New(c) => c.apply(state),
Command::Load(c) => c.apply(state), Command::Load(c) => c.apply(state),
Command::LoadFiles(c) => c.apply(state), Command::LoadFiles(c) => c.apply(state),
Command::SyncFiles(c) => c.apply(state),
Command::Duplicate(c) => c.apply(state), Command::Duplicate(c) => c.apply(state),
Command::Delete(c) => c.apply(state), Command::Delete(c) => c.apply(state),
Command::Paste(c) => c.apply(state), Command::Paste(c) => c.apply(state),
+6 -1
View File
@@ -2,7 +2,7 @@
name = "gpx-studio-desktop" name = "gpx-studio-desktop"
version = "0.1.0" version = "0.1.0"
description = "Desktop shell for gpx.studio" description = "Desktop shell for gpx.studio"
edition = "2021" edition = "2024"
[lib] [lib]
name = "gpx_studio_desktop_lib" name = "gpx_studio_desktop_lib"
@@ -20,4 +20,9 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
uuid = { version = "1", features = ["serde"] } uuid = { version = "1", features = ["serde"] }
gpx-engine = { path = "../gpx-rs" } 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"
+139
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
//! Editing the metadata, the style, the structure and the waypoints of the files, and the tools: //! Editing the metadata, the style, the structure and the waypoints of the files, and the tools:
//! reversing, cropping, reducing, timestamps, merging, cleaning... //! reversing, cropping, reducing, timestamps, merging, cleaning...
use gpx_engine::{api, CleanType, MergeType, SplitType}; use gpx_engine::{CleanType, MergeType, SplitType, api};
use tauri::State; use tauri::State;
use uuid::Uuid; use uuid::Uuid;
+1 -1
View File
@@ -1,8 +1,8 @@
//! Creating, loading, deleting, moving, copying and pasting files and their elements, and undoing. //! Creating, loading, deleting, moving, copying and pasting files and their elements, and undoing.
use gpx_engine::api; use gpx_engine::api;
use tauri::ipc::{InvokeBody, Request};
use tauri::State; use tauri::State;
use tauri::ipc::{InvokeBody, Request};
use uuid::Uuid; use uuid::Uuid;
use crate::state::AppState; use crate::state::AppState;
+1
View File
@@ -5,6 +5,7 @@
//! The commands that edit the files or change the selection return an `Outcome`. //! 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. //! 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 edit;
pub mod files; pub mod files;
pub mod queries; pub mod queries;
+1 -1
View File
@@ -1,7 +1,7 @@
//! The files, their structure and statistics, the selection, the clipboard, and the coordinates of //! 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). //! 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 tauri::State;
use uuid::Uuid; use uuid::Uuid;
+1 -1
View File
@@ -1,6 +1,6 @@
//! Changing what is selected. //! Changing what is selected.
use gpx_engine::{api, SelectMode}; use gpx_engine::{SelectMode, api};
use tauri::State; use tauri::State;
use uuid::Uuid; use uuid::Uuid;
+17 -15
View File
@@ -1,46 +1,48 @@
//! Keeping the files and the settings. //! What the frontend knows of the storage, which is kept by the engine thread (see `state`).
//!
//! 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.
use std::collections::BTreeMap;
use gpx_engine::api;
use serde::Serialize; use serde::Serialize;
use tauri::State; use tauri::State;
use crate::state::AppState; use crate::state::AppState;
use gpx_engine::api::Outcome;
/// What opening the storage found. /// What opening the storage found.
#[derive(Serialize)] #[derive(Serialize)]
pub struct StorageOpened { pub struct StorageOpened {
settings: std::collections::BTreeMap<String, String>, settings: BTreeMap<String, String>,
outcome: Outcome, /// 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)] #[tauri::command(async)]
pub fn open_storage(state: State<AppState>) -> StorageOpened { pub fn open_storage(state: State<AppState>) -> StorageOpened {
StorageOpened { StorageOpened {
settings: state.settings().clone(), settings: state.storage().settings(),
outcome: Outcome::failed_none(), 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)] #[tauri::command(async)]
pub fn flush_storage() {} pub fn flush_storage() {}
/// Reads the files again if they were written by someone else. /// There is no other writer.
#[tauri::command(async)] #[tauri::command(async)]
pub fn sync_storage() -> Outcome { pub fn sync_storage() -> api::Outcome {
Outcome::failed_none() api::Outcome::failed_none()
} }
/// Saves a setting, `value` being its JSON. /// Saves a setting, `value` being its JSON.
#[tauri::command(async)] #[tauri::command(async)]
pub fn set_setting(state: State<AppState>, key: String, value: String) { pub fn set_setting(state: State<AppState>, key: String, value: String) {
state.settings().insert(key, value); state.put_setting(&key, &value);
} }
#[tauri::command(async)] #[tauri::command(async)]
pub fn delete_setting(state: State<AppState>, key: String) { pub fn delete_setting(state: State<AppState>, key: String) {
state.settings().remove(&key); state.delete_setting(&key);
} }
+173 -108
View File
@@ -4,121 +4,186 @@
mod bytes; mod bytes;
mod commands; mod commands;
mod state; mod state;
mod store;
mod workspace;
use commands::*; 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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
with_engine(tauri::Builder::default()) let builder = tauri::Builder::default();
.run(tauri::generate_context!()) // a single instance of the app: starting it again shows the one that runs (it has to be the
.expect("error while running gpx.studio"); // 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. /// Gives the builder the commands that reach the engine (which it has to be given as `AppState`).
fn with_engine<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Builder<R> { fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Builder<R> {
builder builder.invoke_handler(tauri::generate_handler![
.manage(state::AppState::default()) // selection
.invoke_handler(tauri::generate_handler![ selection::select,
// selection selection::select_all,
selection::select, selection::arrow_select,
selection::select_all, selection::select_tracks,
selection::arrow_select, selection::select_segments,
selection::select_tracks, selection::select_waypoint_group,
selection::select_segments, selection::select_waypoints,
selection::select_waypoint_group, // files
selection::select_waypoints, files::new_file,
// files files::load_files,
files::new_file, files::duplicate,
files::load_files, files::delete,
files::duplicate, files::delete_all,
files::delete, files::reorder,
files::delete_all, files::copy,
files::reorder, files::cut,
files::copy, files::paste,
files::cut, files::move_elements,
files::paste, files::undo,
files::move_elements, files::redo,
files::undo, // edits and tools
files::redo, edit::metadata,
// edits and tools edit::style,
edit::metadata, edit::new_track,
edit::style, edit::new_track_segment,
edit::new_track, edit::new_waypoint,
edit::new_track_segment, edit::move_waypoint,
edit::new_waypoint, edit::delete_waypoint,
edit::move_waypoint, edit::update_waypoint,
edit::delete_waypoint, edit::reverse,
edit::update_waypoint, edit::round_trip,
edit::reverse, edit::crop,
edit::round_trip, edit::reduce,
edit::crop, edit::change_timestamps,
edit::reduce, edit::create_artificial_timestamps,
edit::change_timestamps, edit::merge,
edit::create_artificial_timestamps, edit::extract,
edit::merge, edit::elevation,
edit::extract, edit::clean,
edit::elevation, edit::route,
edit::clean, edit::insert_anchor,
edit::route, edit::change_loop_start,
edit::insert_anchor, edit::split,
edit::change_loop_start, // queries
edit::split, queries::file_order,
// queries queries::file_structure,
queries::file_order, queries::file_statistics,
queries::file_structure, queries::selection,
queries::file_statistics, queries::clipboard,
queries::selection, queries::can_undo,
queries::clipboard, queries::can_redo,
queries::can_undo, queries::can_paste,
queries::can_redo, queries::waypoint,
queries::can_paste, queries::trackpoint,
queries::waypoint, queries::export_file,
queries::trackpoint, queries::exportable_data,
queries::export_file, queries::reduction_distances,
queries::exportable_data, queries::segment_coordinates,
queries::reduction_distances, queries::waypoint_coordinates,
queries::segment_coordinates, // statistics
queries::waypoint_coordinates, statistics::total_distance,
// statistics statistics::slope,
statistics::total_distance, statistics::slope_segment_slope,
statistics::slope, statistics::slope_segment_distance,
statistics::slope_segment_slope, statistics::lng,
statistics::slope_segment_distance, statistics::lat,
statistics::lng, statistics::ele,
statistics::lat, statistics::speed,
statistics::ele, statistics::hr,
statistics::speed, statistics::cad,
statistics::hr, statistics::atemp,
statistics::cad, statistics::power,
statistics::atemp, statistics::timestamps,
statistics::power, statistics::selection_statistics,
statistics::timestamps, statistics::slice_statistics,
statistics::selection_statistics, statistics::surface_starts,
statistics::slice_statistics, statistics::surface_values,
statistics::surface_starts, statistics::highway_starts,
statistics::surface_values, statistics::highway_values,
statistics::highway_starts, statistics::sac_scale_starts,
statistics::highway_values, statistics::sac_scale_values,
statistics::sac_scale_starts, statistics::mtb_scale_starts,
statistics::sac_scale_values, statistics::mtb_scale_values,
statistics::mtb_scale_starts, statistics::surfaces,
statistics::mtb_scale_values, statistics::highways,
statistics::surfaces, statistics::sac_scales,
statistics::highways, statistics::mtb_scales,
statistics::sac_scales, statistics::anchor_indices,
statistics::mtb_scales, statistics::anchor_zooms,
statistics::anchor_indices, statistics::segment_starts,
statistics::anchor_zooms, statistics::segment_ids,
statistics::segment_starts, statistics::routing_revision,
statistics::segment_ids, // storage
statistics::routing_revision, storage::open_storage,
// storage storage::flush_storage,
storage::open_storage, storage::sync_storage,
storage::flush_storage, storage::set_setting,
storage::sync_storage, storage::delete_setting,
storage::set_setting, // the file system
storage::delete_setting, disk::workspace_state,
]) disk::open_files,
disk::open_folder,
disk::close_folder,
disk::save_files,
])
} }
#[cfg(test)] #[cfg(test)]
+206 -34
View File
@@ -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 engine is not `Send` (it shares its data with `Rc`), so it lives on a thread of its own with
//! the commands send it jobs: they are done one after the other, in the order they were sent. //! 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::mpsc::{self, Sender};
use std::sync::{Mutex, MutexGuard}; use std::sync::{Arc, Mutex};
use std::thread; 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};
pub struct AppState { /// Tells the frontend that something happened that it did not ask for: a name and a JSON value.
jobs: Mutex<Sender<Job>>, pub type Emit = Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>;
/// The settings of the frontend, as JSON strings by key.
settings: Mutex<BTreeMap<String, String>>, 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,
} }
impl Default for AppState { #[derive(Clone)]
fn default() -> Self { pub struct AppState {
let (jobs, receiver) = mpsc::channel::<Job>(); jobs: Arc<Mutex<Sender<Job>>>,
thread::Builder::new() storage: Arc<FileStorage>,
.name("engine".into()) }
.spawn(move || {
let mut engine = Engine::default(); impl Core {
while let Ok(job) = receiver.recv() { fn start(storage: Arc<FileStorage>, jobs: Sender<Job>, emit: Emit) -> Self {
job(&mut engine); let mut core = Self {
engine: Engine::default(),
workspace: Workspace::load(storage.root()),
persister: Persister::default(),
storage,
watcher: None,
jobs,
emit,
};
match pollster::block_on(core.persister.restore(&*core.storage)) {
Ok(restored) => {
core.engine.restore(restored);
}
// what is stored comes from a newer version, which is left alone
Err(gpx_engine::StorageError::Newer) => {}
Err(error) => eprintln!("the files could not be read: {error}"),
}
core.workspace.prune(&core.engine);
core.workspace.verify(&core.engine);
// what happened on the disk while the app was closed
core.workspace.reconcile(&mut core.engine, None);
core.workspace.write(core.storage.root());
core.watch();
core.persist();
core
}
/// Writes what changed in the engine.
pub fn persist(&mut self) {
match pollster::block_on(self.persister.save(&*self.storage, self.engine.snapshot())) {
Ok(()) | Err(gpx_engine::CommitError::Failed(gpx_engine::StorageError::Newer)) => {}
Err(error) => eprintln!("the files could not be kept: {error}"),
}
}
/// Opens files that the system gave to the app (open with..., double click, the command line),
/// which get selected. The frontend is told, if it already runs: when it starts it reads what
/// the engine has.
pub fn open_external(&mut self, paths: &[PathBuf]) {
if paths.is_empty() {
return;
}
let outcome = self.workspace.open_files(&mut self.engine, paths, true);
self.keep_workspace();
self.persist();
(self.emit)("files-opened", to_json(&outcome));
self.announce(None);
}
pub fn keep_workspace(&self) {
self.workspace.write(self.storage.root());
}
pub fn workspace_state(&self) -> WorkspaceState {
self.workspace.state(&self.engine)
}
/// Tells the frontend what changed without it asking, and where the files are.
pub fn announce(&self, outcome: Option<&api::Outcome>) {
if let Some(outcome) = outcome {
(self.emit)("engine-changed", to_json(outcome));
}
(self.emit)("workspace-changed", to_json(&self.workspace_state()));
}
/// Watches the open folder (and only it).
pub fn watch(&mut self) {
self.watcher = None;
let Some(folder) = self.workspace.folder.clone() else {
return;
};
if !Path::new(&folder).is_dir() {
return;
}
let jobs = self.jobs.clone();
let debouncer = new_debouncer(
Duration::from_millis(400),
move |result: DebounceEventResult| {
let Ok(events) = result else { return };
let paths: Vec<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}"),
} }
}) }
.expect("the engine thread could not be started"); Err(error) => eprintln!("the folder cannot be watched: {error}"),
Self { }
jobs: Mutex::new(jobs), }
settings: Mutex::default(),
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 { impl AppState {
/// Runs `f` on the engine and waits for its result. /// Starts the engine, which restores what was kept in `directory`.
pub fn run<T: Send + 'static>(&self, f: impl FnOnce(&mut Engine) -> T + Send + 'static) -> T { 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 (reply, result) = mpsc::channel();
let job: Job = Box::new(move |engine| { let job: Job = Box::new(move |core| {
let _ = reply.send(f(engine)); let _ = reply.send(f(core));
}); });
self.jobs self.jobs
.lock() .lock()
@@ -52,12 +200,36 @@ impl AppState {
result.recv().expect("the engine thread stopped") result.recv().expect("the engine thread stopped")
} }
/// Reads from the engine. /// Runs `f` on the engine, which can change: what changed is kept.
pub fn read<T: Send + 'static>(&self, f: impl FnOnce(&Engine) -> T + Send + 'static) -> T { pub fn run<T: Send + 'static>(&self, f: impl FnOnce(&mut Engine) -> T + Send + 'static) -> T {
self.run(move |engine| f(engine)) self.core(move |core| {
let result = f(&mut core.engine);
core.persist();
result
})
} }
pub fn settings(&self) -> MutexGuard<'_, BTreeMap<String, String>> { /// Reads from the engine.
self.settings.lock().unwrap() 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}");
}
} }
} }
+284
View File
@@ -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();
}
}
+93 -9
View File
@@ -1,18 +1,19 @@
//! The commands, called the way the frontend calls them (through the IPC of Tauri), on the sample //! The commands, called the way the frontend calls them (through the IPC of Tauri), on the sample
//! files of the engine. //! files of the engine.
use serde_json::{json, Value}; use serde_json::{Value, json};
use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; use tauri::test::{INVOKE_KEY, get_ipc_response, mock_builder, mock_context, noop_assets};
use tauri::webview::InvokeRequest; 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>); struct App(WebviewWindow<tauri::test::MockRuntime>);
impl App { impl App {
fn new() -> Self { fn new() -> Self {
let app = crate::with_engine(mock_builder()) let app =
.build(mock_context(noop_assets())) crate::register_commands(mock_builder().manage(crate::state::AppState::temporary()))
.unwrap(); .build(mock_context(noop_assets()))
.unwrap();
let window = tauri::WebviewWindowBuilder::new(&app, "main", Default::default()) let window = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
.build() .build()
.unwrap(); .unwrap();
@@ -242,9 +243,11 @@ fn waypoints_exports_and_routing_revisions() {
json!({ "fileId": file, "time": true, "hr": true, "cad": true, "atemp": true, "power": true, "osm": true, "asRoute": false }), json!({ "fileId": file, "time": true, "hr": true, "cad": true, "atemp": true, "power": true, "osm": true, "asRoute": false }),
); );
assert_eq!(exported[0], 1); assert_eq!(exported[0], 1);
assert!(String::from_utf8(exported[1..].to_vec()) assert!(
.unwrap() String::from_utf8(exported[1..].to_vec())
.contains("<wpt")); .unwrap()
.contains("<wpt")
);
assert_eq!(app.call("exportable_data", json!({ "fileIds": [file] })), 0); assert_eq!(app.call("exportable_data", json!({ "fileIds": [file] })), 0);
let stale = app.call( let stale = app.call(
@@ -274,3 +277,84 @@ fn keeps_settings_for_the_session() {
app.call("delete_setting", json!({ "key": "units" })); app.call("delete_setting", json!({ "key": "units" }));
assert_eq!(app.call("open_storage", json!({}))["settings"], json!({})); 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
View File
@@ -5,7 +5,7 @@
"identifier": "gpx.studio", "identifier": "gpx.studio",
"build": { "build": {
"beforeDevCommand": "npm --prefix ../website run dev -- --port 4283", "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", "beforeBuildCommand": "BASE_PATH='' npm --prefix ../website run build",
"frontendDist": "../website/build" "frontendDist": "../website/build"
}, },
@@ -14,7 +14,7 @@
{ {
"label": "main", "label": "main",
"title": "gpx.studio", "title": "gpx.studio",
"url": "app.html", "url": "app",
"width": 1400, "width": 1400,
"height": 900, "height": 900,
"minWidth": 800, "minWidth": 800,
@@ -26,6 +26,15 @@
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": "all",
"fileAssociations": [
{
"ext": ["gpx"],
"name": "GPX file",
"description": "GPS Exchange Format",
"mimeType": "application/gpx+xml",
"role": "Editor"
}
],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
+83
View File
@@ -4,6 +4,9 @@ import { get } from 'svelte/store';
import { boundsManager } from '$lib/state/bounds'; import { boundsManager } from '$lib/state/bounds';
import { engine } from '$lib/engine'; import { engine } from '$lib/engine';
import { defaultFileName } from '$lib/actions/default-file-name'; 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. */ /** The name of a new file: the translated default name, numbered if other files have it. */
export function newFileName() { export function newFileName() {
@@ -17,6 +20,86 @@ export function createFile() {
currentTool.set(Tool.ROUTING); 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() { export function triggerFileInput() {
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
+18 -2
View File
@@ -1,6 +1,7 @@
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import type { RouteAttributes, StorageOpened } from 'gpx-wasm'; import { listen } from '@tauri-apps/api/event';
import type { Backend } from './types'; import type { Outcome, RouteAttributes, StorageOpened } from 'gpx-wasm';
import type { Backend, WorkspaceState } from './types';
/** An array that a command returns as its bytes. */ /** An array that a command returns as its bytes. */
const array = async <T>( const array = async <T>(
@@ -207,6 +208,21 @@ export async function createTauriBackend(): Promise<Backend> {
segment_ids: () => invoke('segment_ids'), segment_ids: () => invoke('segment_ids'),
routing_revision: () => invoke('routing_revision'), 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, importsLegacyData: false,
open_storage: () => invoke<StorageOpened>('open_storage'), open_storage: () => invoke<StorageOpened>('open_storage'),
flush_storage: () => invoke('flush_storage'), flush_storage: () => invoke('flush_storage'),
+58
View File
@@ -12,6 +12,61 @@ import type {
} from 'gpx-wasm'; } from 'gpx-wasm';
import type { MergeType, SelectMode, SplitType } from '../types'; 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 * 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`). * 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[]>; segment_ids(): Promise<string[]>;
routing_revision(): Promise<number>; routing_revision(): Promise<number>;
/** Present when the files are linked to the file system (the desktop app). */
readonly desktop?: DesktopFiles;
// Storage // Storage
/** /**
* Whether the files that the previous versions of the app kept in the browser can be imported * Whether the files that the previous versions of the app kept in the browser can be imported
+127 -5
View File
@@ -29,6 +29,8 @@ import {
type SelectMode, type SelectMode,
type SplitType, type SplitType,
type StatisticsRequest, type StatisticsRequest,
type UnsavedChoice,
type WorkspaceState,
type TrackpointDetails, type TrackpointDetails,
type WaypointDetails, type WaypointDetails,
} from './types'; } from './types';
@@ -60,6 +62,7 @@ class Engine {
private _selection = writable<Selection>({ type: 'empty' }); private _selection = writable<Selection>({ type: 'empty' });
private _lastError = writable<string | undefined>(undefined); private _lastError = writable<string | undefined>(undefined);
private _outdated = writable(false); private _outdated = writable(false);
private _workspace = writable<WorkspaceState | undefined>(undefined);
private _canUndo = writable(false); private _canUndo = writable(false);
private _canRedo = writable(false); private _canRedo = writable(false);
private _canPaste = writable(false); private _canPaste = writable(false);
@@ -74,6 +77,13 @@ class Engine {
readonly outdated: Readable<boolean> = { subscribe: this._outdated.subscribe }; readonly outdated: Readable<boolean> = { subscribe: this._outdated.subscribe };
readonly canUndo: Readable<boolean> = { subscribe: this._canUndo.subscribe }; readonly canUndo: Readable<boolean> = { subscribe: this._canUndo.subscribe };
readonly canRedo: Readable<boolean> = { subscribe: this._canRedo.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. */ /** Why the last call to the engine did nothing, `undefined` if it did something. */
readonly lastError: Readable<string | undefined> = { subscribe: this._lastError.subscribe }; readonly lastError: Readable<string | undefined> = { subscribe: this._lastError.subscribe };
/** What was copied or cut, waiting to be pasted. */ /** What was copied or cut, waiting to be pasted. */
@@ -92,8 +102,9 @@ class Engine {
constructor() { constructor() {
this.ready = browser this.ready = browser
? loadBackend().then((backend) => { ? loadBackend().then(async (backend) => {
this.backend = backend; this.backend = backend;
await this.followDisk(backend);
}) })
: new Promise(() => {}); : new Promise(() => {});
} }
@@ -533,6 +544,106 @@ class Engine {
return this.run((b) => b.change_loop_start(revision, index)); 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 // Visibility, which is kept by the frontend and not by the engine
/** Hides or shows the selected elements. */ /** Hides or shows the selected elements. */
@@ -685,10 +796,17 @@ class Engine {
await this.ready; await this.ready;
return this.enqueue(async () => { return this.enqueue(async () => {
const backend = this.backend!; const backend = this.backend!;
const outcome = await action(backend); try {
await this.sync(backend, outcome); const outcome = await action(backend);
this.report(outcome); await this.sync(backend, outcome);
return outcome.changed; 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 // the statistics depend on the selection, on its order, and on the files
if ( if (
update.selectionChanged || update.selectionChanged ||
+2
View File
@@ -4,6 +4,8 @@ import type { Visibility } from '$lib/engine/file-visibility';
import type { CategoryIntervals } from '$lib/engine/trackpoint-categories'; import type { CategoryIntervals } from '$lib/engine/trackpoint-categories';
import type { FileStructure, GlobalStatistics } from 'gpx-wasm'; import type { FileStructure, GlobalStatistics } from 'gpx-wasm';
export type { FileStatus, UnsavedChoice, WorkspaceState } from './backend/types';
export type { export type {
Clipboard, Clipboard,
WaypointDetails, WaypointDetails,
+29
View File
@@ -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');
});
});
+28
View File
@@ -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 { page } from '$app/state';
import { hoveredPoint, slicedStatistics } from '$lib/state/selection-statistics'; import { hoveredPoint, slicedStatistics } from '$lib/state/selection-statistics';
import { engine } from '$lib/engine'; import { engine } from '$lib/engine';
import { boundsManager } from '$lib/state/bounds';
import { getURLForGoogleDriveFile } from '$lib/components/embedding/embedding'; import { getURLForGoogleDriveFile } from '$lib/components/embedding/embedding';
const outdated = engine.outdated; const outdated = engine.outdated;
@@ -38,6 +39,8 @@
); );
onMount(async () => { onMount(async () => {
// files that the system opens with the app while it runs
engine.onFilesOpened((ids) => boundsManager.fitBoundsOnLoad(ids));
settings.connect(await engine.openStorage()); settings.connect(await engine.openStorage());
let files: string[] = JSON.parse(page.url.searchParams.get('files') || '[]'); let files: string[] = JSON.parse(page.url.searchParams.get('files') || '[]');