Migration to new Rust-based engine (#401)
* wasm first steps * parsing start * parsing continued * progress * progress * progress * progress * progress * progress * progress * progress * fixes * reorganize * change type * fix * progress * refactoring * progress * progress * progress * keep style info * progress * progress * remove (copy) in name * progress * merge methods into 1 * splice * progress * progress * progress * cleaning * progress * progress * progress * visibility * progress * progress * progress * progress * progress * fixes * progress * fix markers * fix * progress * progress * progress * progress * progress * progress * small fix * use engine info * fix popups * small fix * progress * progress * progress * clean tool * handle self closing elements * reduce data exchanges across boundary * copy on wasm side to avoid any data corruption * speed fix * routing * refactoring * reverse * reverse * crop * split * merge * progress * time tool * fix bounds for waypoints * reduce * fix reactivity * handle corrupt time data * export * progress * remove gpx js package * remove todo * update readme * cleaning * progress * fix link fields * remove unused fields * refactoring and tests * import and export of routes, ref #192 * per-chunk categories storage * cross-tab sync * outdated state * refactoring * reorder methods * refactoring * fix * improve flow * renaming * progress * progress * fix * progress * per file history * keep original flag * use history variant * fix * progress * file explorer * progress * fix * sticky folders * improved sticky folders * fix sortable * fix sortable * fix * fix tab size * no file delete on desktop * fix routing on other file * fix sorting * remember language * open links in browser * commit missing changes * avoid selecting on resize * clarify filename * explorer actions * fix folder styling * drop anywhere in folder * cleaning * fixes * remove focus-ring * full screen * small fix * consistent highlight color * improved preview * performance improvements * sparser data transfers * sparser data transfers * show popup once data is loaded * fix routing control size * fix * sparse desktop build * remove custom ign styles * use new endpoint for openhikingmap * strip native menu * reorder context menu items * route full support * route - track conversion * create route file action * route export, filter out anchors at high zoom level * clarify name handling * move hide & center to view * routes continued * update docs * fix power format, closes #378 * lint fixes * fix display of additional data with gaps, closes #361 * fix linting issues * fix import modules * fix multidrag * change base to resolve * fix type errors * fix layer typings * fix last type error * each keys * improve typings * deploy to cloudflare --------- Co-authored-by: vcoppe <vcoppe@hexaly.com>
No files matched your search
@@ -0,0 +1,2 @@
|
||||
/target/
|
||||
/gen/
|
||||
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "gpx-studio-desktop"
|
||||
version = "0.1.0"
|
||||
description = "Desktop shell for gpx.studio"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
name = "gpx_studio_desktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[dev-dependencies]
|
||||
tauri = { version = "2", features = ["test"] }
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["serde"] }
|
||||
gpx-engine = { path = "../gpx-rs" }
|
||||
pollster = "1.0.1"
|
||||
notify = "8.2.0"
|
||||
notify-debouncer-mini = "0.7.0"
|
||||
tauri-plugin-dialog = "2.8.2"
|
||||
tauri-plugin-single-instance = "2.5.3"
|
||||
tauri-plugin-opener = "2"
|
||||
trash = "5"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Permissions for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 820 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 800 B |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "tauri",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "cargo tauri dev",
|
||||
"build": "cargo tauri build"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Typed arrays and files cross as raw bytes (little-endian), not as JSON.
|
||||
|
||||
pub type Bytes = tauri::ipc::Response;
|
||||
|
||||
pub fn f64s(values: &[f64]) -> Bytes {
|
||||
Bytes::new(
|
||||
values
|
||||
.iter()
|
||||
.flat_map(|v| v.to_le_bytes())
|
||||
.collect::<Vec<u8>>(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn u32s(values: &[u32]) -> Bytes {
|
||||
Bytes::new(
|
||||
values
|
||||
.iter()
|
||||
.flat_map(|v| v.to_le_bytes())
|
||||
.collect::<Vec<u8>>(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn u8s(values: &[u8]) -> Bytes {
|
||||
Bytes::new(values.to_vec())
|
||||
}
|
||||
|
||||
/// An array that can be absent: the first byte tells whether it is there, the array follows.
|
||||
pub fn optional(array: Option<Vec<u8>>) -> Bytes {
|
||||
let mut bytes = vec![u8::from(array.is_some())];
|
||||
bytes.extend(array.unwrap_or_default());
|
||||
Bytes::new(bytes)
|
||||
}
|
||||
|
||||
pub fn f64_bytes(values: &[f64]) -> Vec<u8> {
|
||||
values.iter().flat_map(|v| v.to_le_bytes()).collect()
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
//! The files of the engine and the file system: picking files and folders, saving, closing, and
|
||||
//! what the frontend shows of it (see `workspace`).
|
||||
//!
|
||||
//! The commands that can lose the edits of a file (closing, opening another folder) take what to do
|
||||
//! with the files that have some: the frontend asks the user, and the files that have edits stay
|
||||
//! open if it does not say.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use gpx_engine::{ExportOptions, FileId, api};
|
||||
use serde::Deserialize;
|
||||
use tauri::{AppHandle, Runtime, State};
|
||||
use tauri_plugin_dialog::{DialogExt, FilePath};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::workspace::{Entry, Unsaved, WorkspaceState, free_path};
|
||||
|
||||
fn paths(picked: Vec<FilePath>) -> Vec<PathBuf> {
|
||||
picked
|
||||
.into_iter()
|
||||
.filter_map(|path| path.into_path().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Asks where to write a GPX file, proposing `file_name` in `folder`. `None` if the user cancelled.
|
||||
fn ask_save_path<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
file_name: &str,
|
||||
folder: Option<&Path>,
|
||||
) -> Option<PathBuf> {
|
||||
let mut dialog = app
|
||||
.dialog()
|
||||
.file()
|
||||
.add_filter("GPX", &["gpx"])
|
||||
.set_file_name(file_name);
|
||||
if let Some(folder) = folder {
|
||||
dialog = dialog.set_directory(folder);
|
||||
}
|
||||
dialog.blocking_save_file()?.into_path().ok()
|
||||
}
|
||||
|
||||
/// Asks for a folder, starting in `folder`. `None` if the user cancelled.
|
||||
fn ask_folder<R: Runtime>(app: &AppHandle<R>, folder: Option<&Path>) -> Option<PathBuf> {
|
||||
let mut dialog = app.dialog().file();
|
||||
if let Some(folder) = folder {
|
||||
dialog = dialog.set_directory(folder);
|
||||
}
|
||||
dialog.blocking_pick_folder()?.into_path().ok()
|
||||
}
|
||||
|
||||
#[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();
|
||||
};
|
||||
open_paths(state, paths(picked), None)
|
||||
}
|
||||
|
||||
/// Opens files that are on the disk, like the ones of the tree of the open folder. They get selected.
|
||||
/// With `preview`, a single file is open as a preview (a single click in the tree, see
|
||||
/// `Workspace::open`); otherwise they are open for good, even the preview.
|
||||
#[tauri::command(async)]
|
||||
pub fn open_paths(
|
||||
state: State<AppState>,
|
||||
paths: Vec<PathBuf>,
|
||||
preview: Option<bool>,
|
||||
) -> api::Outcome {
|
||||
state.core(move |core| {
|
||||
let outcome = core
|
||||
.workspace
|
||||
.open(&mut core.engine, &paths, preview.unwrap_or_default());
|
||||
core.commit(None);
|
||||
outcome
|
||||
})
|
||||
}
|
||||
|
||||
/// The files are open for good: the preview among them is not a preview anymore (the user did
|
||||
/// something to it that the engine does not see, like hiding it).
|
||||
#[tauri::command(async)]
|
||||
pub fn keep_files(state: State<AppState>, file_ids: Vec<Uuid>) {
|
||||
state.core(move |core| {
|
||||
core.workspace.keep(&file_ids);
|
||||
core.commit(None);
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes the files among `ids` that are not the ones on the disk, asking where to put the ones
|
||||
/// that have no place: the file dialog for a single one, a folder for several of them. Says whether
|
||||
/// they were written, which is not the case if the user cancelled.
|
||||
///
|
||||
/// The files whose file on the disk was replaced are overwritten: the frontend asks before.
|
||||
fn write_unsaved<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
state: &AppState,
|
||||
ids: Vec<Uuid>,
|
||||
) -> Result<bool, String> {
|
||||
let (unlinked, start) = {
|
||||
let ids = ids.clone();
|
||||
state.core(move |core| {
|
||||
(
|
||||
core.workspace.unlinked(&core.engine, &ids),
|
||||
core.workspace.folder.clone(),
|
||||
)
|
||||
})
|
||||
};
|
||||
let mut chosen = BTreeMap::new();
|
||||
let mut folder = None;
|
||||
match unlinked.as_slice() {
|
||||
[] => {}
|
||||
[(id, name)] => match ask_save_path(app, &format!("{name}.gpx"), start.as_deref()) {
|
||||
Some(path) => {
|
||||
chosen.insert(*id, path);
|
||||
}
|
||||
None => return Ok(false),
|
||||
},
|
||||
_ => match ask_folder(app, start.as_deref()) {
|
||||
Some(path) => folder = Some(path),
|
||||
None => return Ok(false),
|
||||
},
|
||||
}
|
||||
state.core(move |core| {
|
||||
let result = core
|
||||
.workspace
|
||||
.save(&mut core.engine, &ids, &chosen, folder.as_deref());
|
||||
core.wrote(result)
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes files to the disk, replacing what is there. The ones that are not on the disk yet go in
|
||||
/// their folder, or where the user says. Says whether they were written (`false` if the user
|
||||
/// cancelled).
|
||||
#[tauri::command(async)]
|
||||
pub fn save_files<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
state: State<AppState>,
|
||||
file_ids: Vec<Uuid>,
|
||||
) -> Result<bool, String> {
|
||||
write_unsaved(&app, &state, file_ids)
|
||||
}
|
||||
|
||||
/// Writes a file where the user says, which is where the file is from then on. Says whether it was
|
||||
/// written (`false` if the user cancelled).
|
||||
#[tauri::command(async)]
|
||||
pub fn save_file_as<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
state: State<AppState>,
|
||||
file_id: Uuid,
|
||||
) -> Result<bool, String> {
|
||||
let (name, current, folder) = state.core(move |core| {
|
||||
let name = core
|
||||
.engine
|
||||
.file_structure(&FileId(file_id))
|
||||
.map(|structure| structure.name.clone());
|
||||
let status = core.workspace_state();
|
||||
let file = status.files.get(&file_id.to_string());
|
||||
let current = file.and_then(|file| file.path.clone().map(PathBuf::from));
|
||||
let folder = file
|
||||
.and_then(|file| file.folder.clone().map(PathBuf::from))
|
||||
.or(status.folder.map(PathBuf::from));
|
||||
(name, current, folder)
|
||||
});
|
||||
let Some(name) = name else {
|
||||
return Ok(false);
|
||||
};
|
||||
// where the file is, or else where it would be written
|
||||
let folder = current
|
||||
.as_ref()
|
||||
.and_then(|path| path.parent().map(Path::to_path_buf))
|
||||
.or(folder);
|
||||
let Some(path) = ask_save_path(&app, &format!("{name}.gpx"), folder.as_deref()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
state.core(move |core| {
|
||||
let result = core.workspace.save_as(&mut core.engine, file_id, &path);
|
||||
core.wrote(result)
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads files again from the disk: the edits they have are lost (they are one undo step).
|
||||
#[tauri::command(async)]
|
||||
pub fn reload_files(state: State<AppState>, file_ids: Vec<Uuid>) -> Result<api::Outcome, String> {
|
||||
state.core(move |core| {
|
||||
let outcome = core.workspace.reload(&mut core.engine, &file_ids);
|
||||
core.commit(None);
|
||||
outcome
|
||||
})
|
||||
}
|
||||
|
||||
/// The files among `ids` that are not the ones on the disk, saved first if `unsaved` says so.
|
||||
/// Says whether to go on, which is not the case if the user cancelled saving them.
|
||||
fn settle<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
state: &AppState,
|
||||
ids: Vec<Uuid>,
|
||||
unsaved: Option<Unsaved>,
|
||||
) -> Result<bool, String> {
|
||||
if unsaved != Some(Unsaved::Save) {
|
||||
return Ok(true);
|
||||
}
|
||||
let unsaved_ids = state.core(move |core| core.workspace.unsaved(&core.engine, &ids));
|
||||
if unsaved_ids.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
write_unsaved(app, state, unsaved_ids)
|
||||
}
|
||||
|
||||
/// Opens the folder that the user picks, which is watched. Its files are not opened. The folder
|
||||
/// that was open is closed with its files: `unsaved` says what to do with the ones 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());
|
||||
};
|
||||
let all = state.core(|core| core.workspace.all(&core.engine));
|
||||
if !settle(&app, &state, all, unsaved)? {
|
||||
return Ok(api::Outcome::failed_none());
|
||||
}
|
||||
state.core(move |core| {
|
||||
let discard = unsaved.is_some();
|
||||
let outcome = core
|
||||
.workspace
|
||||
.open_folder(&mut core.engine, &folder, discard);
|
||||
core.commit(None);
|
||||
Ok(outcome)
|
||||
})
|
||||
}
|
||||
|
||||
/// Closes the folder: it is not watched anymore, and the files are closed with it. `unsaved` says
|
||||
/// what to do with the ones that have unsaved edits (they stay open without it).
|
||||
#[tauri::command(async)]
|
||||
pub fn close_folder<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
state: State<AppState>,
|
||||
unsaved: Option<Unsaved>,
|
||||
) -> Result<api::Outcome, String> {
|
||||
let all = state.core(|core| core.workspace.all(&core.engine));
|
||||
if !settle(&app, &state, all, unsaved)? {
|
||||
return Ok(api::Outcome::failed_none());
|
||||
}
|
||||
state.core(move |core| {
|
||||
let outcome = core
|
||||
.workspace
|
||||
.close_folder(&mut core.engine, unsaved.is_some());
|
||||
core.commit(None);
|
||||
Ok(outcome)
|
||||
})
|
||||
}
|
||||
|
||||
/// Closes files. `unsaved` says what to do with the ones that have unsaved edits: they are saved
|
||||
/// first, or the edits are lost (they stay open without it).
|
||||
#[tauri::command(async)]
|
||||
pub fn close_files<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
state: State<AppState>,
|
||||
file_ids: Vec<Uuid>,
|
||||
unsaved: Option<Unsaved>,
|
||||
) -> Result<api::Outcome, String> {
|
||||
if !settle(&app, &state, file_ids.clone(), unsaved)? {
|
||||
return Ok(api::Outcome::failed_none());
|
||||
}
|
||||
state.core(move |core| {
|
||||
let outcome = core
|
||||
.workspace
|
||||
.close_files(&mut core.engine, &file_ids, unsaved.is_some());
|
||||
core.commit(None);
|
||||
Ok(outcome)
|
||||
})
|
||||
}
|
||||
|
||||
/// The folders and GPX files that are in a folder of the open folder, or in it (the root of the
|
||||
/// tree when no path is given).
|
||||
#[tauri::command(async)]
|
||||
pub fn list_folder(state: State<AppState>, path: Option<PathBuf>) -> Result<Vec<Entry>, String> {
|
||||
state.core(move |core| {
|
||||
let Some(path) = path.or_else(|| core.workspace.folder.clone()) else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
core.workspace.list_folder(&path)
|
||||
})
|
||||
}
|
||||
|
||||
/// What the files are exported with, see `ExportOptions`.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExportArguments {
|
||||
time: bool,
|
||||
hr: bool,
|
||||
cad: bool,
|
||||
atemp: bool,
|
||||
power: bool,
|
||||
osm: bool,
|
||||
}
|
||||
|
||||
impl From<ExportArguments> for ExportOptions {
|
||||
fn from(arguments: ExportArguments) -> Self {
|
||||
Self {
|
||||
time: arguments.time,
|
||||
hr: arguments.hr,
|
||||
cad: arguments.cad,
|
||||
atemp: arguments.atemp,
|
||||
power: arguments.power,
|
||||
osm: arguments.osm,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes copies of files where the user says: a file dialog for a single one, a folder for
|
||||
/// several. The files stay as they are. Says whether they were written (`false` if the user
|
||||
/// cancelled).
|
||||
#[tauri::command(async)]
|
||||
pub fn export_files<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
state: State<AppState>,
|
||||
file_ids: Vec<Uuid>,
|
||||
options: ExportArguments,
|
||||
) -> Result<bool, String> {
|
||||
let options = ExportOptions::from(options);
|
||||
let files: Vec<(String, Vec<u8>)> = state.core(move |core| {
|
||||
file_ids
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let name = core.engine.file_structure(&FileId(*id))?.name.clone();
|
||||
Some((name, api::export_file(&core.engine, *id, options)?))
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let write = |path: PathBuf, bytes: &[u8]| {
|
||||
crate::store::write_atomically(&path, bytes)
|
||||
.map_err(|error| format!("{}: {error}", path.display()))
|
||||
};
|
||||
match files.as_slice() {
|
||||
[] => Ok(false),
|
||||
[(name, bytes)] => {
|
||||
let Some(path) = ask_save_path(&app, &format!("{name}.gpx"), None) else {
|
||||
return Ok(false);
|
||||
};
|
||||
write(path, bytes).map(|()| true)
|
||||
}
|
||||
_ => {
|
||||
let Some(folder) = ask_folder(&app, None) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let mut written: Vec<PathBuf> = vec![];
|
||||
for (name, bytes) in &files {
|
||||
let path = free_path(&folder, name, |path| written.iter().any(|w| w == path));
|
||||
write(path.clone(), bytes)?;
|
||||
written.push(path);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes a folder in a folder of the open folder, called `name` (numbered if that is taken). Gives
|
||||
/// its path.
|
||||
#[tauri::command(async)]
|
||||
pub fn create_folder(
|
||||
state: State<AppState>,
|
||||
parent: PathBuf,
|
||||
name: String,
|
||||
) -> Result<PathBuf, String> {
|
||||
state.core(move |core| core.workspace.create_folder(&parent, &name))
|
||||
}
|
||||
|
||||
/// Renames a file or a folder of the open folder, on the disk only: an open file keeps its name,
|
||||
/// and follows. Not undoable.
|
||||
#[tauri::command(async)]
|
||||
pub fn rename_path(state: State<AppState>, path: PathBuf, name: String) -> Result<(), String> {
|
||||
state.core(move |core| {
|
||||
core.workspace.rename_path(&path, &name)?;
|
||||
core.commit(None);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Renames the file on the disk of an open file, next to where it is (see `Workspace::rename_file`).
|
||||
/// Not undoable, the name of the file in the engine is not changed. Says whether it was renamed:
|
||||
/// not for a file that is not on the disk.
|
||||
#[tauri::command(async)]
|
||||
pub fn rename_file(state: State<AppState>, file_id: Uuid, name: String) -> Result<bool, String> {
|
||||
state.core(move |core| {
|
||||
let renamed = core.workspace.rename_file(file_id, &name)?;
|
||||
if renamed {
|
||||
core.commit(None);
|
||||
}
|
||||
Ok(renamed)
|
||||
})
|
||||
}
|
||||
|
||||
/// Moves files and folders of the open folder into a folder of it. The open files follow.
|
||||
#[tauri::command(async)]
|
||||
pub fn move_paths(
|
||||
state: State<AppState>,
|
||||
paths: Vec<PathBuf>,
|
||||
folder: PathBuf,
|
||||
) -> Result<(), String> {
|
||||
state.core(move |core| {
|
||||
let result = core.workspace.move_paths(&paths, &folder);
|
||||
core.commit(None);
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
/// Moves files and folders of the open folder to the trash. The open files that were among them
|
||||
/// are closed, unless they have edits: they stay open, as gone from the disk.
|
||||
#[tauri::command(async)]
|
||||
pub fn delete_paths(state: State<AppState>, paths: Vec<PathBuf>) -> Result<api::Outcome, String> {
|
||||
state.core(move |core| {
|
||||
let outcome = core
|
||||
.workspace
|
||||
.delete_paths(&mut core.engine, &paths, to_trash)?;
|
||||
core.commit(None);
|
||||
Ok(outcome)
|
||||
})
|
||||
}
|
||||
|
||||
/// Moves files and folders to the trash of the system.
|
||||
fn to_trash(paths: &[PathBuf]) -> Result<(), String> {
|
||||
#[allow(unused_mut)]
|
||||
let mut trash = trash::TrashContext::default();
|
||||
// without asking the Finder, which needs a permission
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use trash::macos::{DeleteMethod, TrashContextExtMacos};
|
||||
trash.set_delete_method(DeleteMethod::NsFileManager);
|
||||
}
|
||||
trash.delete_all(paths).map_err(|error| {
|
||||
eprintln!("the files cannot be deleted: {error}");
|
||||
crate::workspace::DELETE_FAILED.to_owned()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Editing the metadata, the style, the structure and the waypoints of the files, and the tools:
|
||||
//! reversing, cropping, reducing, timestamps, merging, cleaning...
|
||||
|
||||
use gpx_engine::{CleanType, MergeType, SplitType, api};
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Names and describes the selected files or tracks. The files on the disk keep their name: see
|
||||
/// `disk::rename_file`.
|
||||
#[tauri::command(async)]
|
||||
pub fn metadata(state: State<AppState>, name: String, desc: String) -> api::Outcome {
|
||||
state.run(move |e| api::metadata(e, &name, &desc))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn style(
|
||||
state: State<AppState>,
|
||||
color: Option<String>,
|
||||
opacity: Option<f64>,
|
||||
width: Option<f64>,
|
||||
) -> api::Outcome {
|
||||
state.run(move |e| api::style(e, color.as_deref(), opacity, width))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn convert(state: State<AppState>) -> api::Outcome {
|
||||
state.run(api::convert)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn new_route(state: State<AppState>) -> api::Outcome {
|
||||
state.run(api::new_route)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn new_track(state: State<AppState>) -> api::Outcome {
|
||||
state.run(api::new_track)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn new_track_segment(state: State<AppState>) -> api::Outcome {
|
||||
state.run(api::new_track_segment)
|
||||
}
|
||||
|
||||
// Waypoints
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new_waypoint(
|
||||
state: State<AppState>,
|
||||
lng: f64,
|
||||
lat: f64,
|
||||
ele: f64,
|
||||
name: String,
|
||||
desc: String,
|
||||
icon: String,
|
||||
link: String,
|
||||
) -> api::Outcome {
|
||||
state.run(move |e| api::new_waypoint(e, lng, lat, ele, &name, &desc, &icon, &link))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn move_waypoint(
|
||||
state: State<AppState>,
|
||||
file_id: Uuid,
|
||||
waypoint_id: Uuid,
|
||||
lng: f64,
|
||||
lat: f64,
|
||||
ele: f64,
|
||||
) -> api::Outcome {
|
||||
state.run(move |e| api::move_waypoint(e, file_id, waypoint_id, lng, lat, ele))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn delete_waypoint(state: State<AppState>, file_id: Uuid, waypoint_id: Uuid) -> api::Outcome {
|
||||
state.run(move |e| api::delete_waypoint(e, file_id, waypoint_id))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn update_waypoint(
|
||||
state: State<AppState>,
|
||||
file_id: Uuid,
|
||||
waypoint_id: Uuid,
|
||||
lng: f64,
|
||||
lat: f64,
|
||||
ele: f64,
|
||||
name: String,
|
||||
desc: String,
|
||||
icon: String,
|
||||
link: String,
|
||||
) -> api::Outcome {
|
||||
state.run(move |e| {
|
||||
api::update_waypoint(
|
||||
e,
|
||||
file_id,
|
||||
waypoint_id,
|
||||
lng,
|
||||
lat,
|
||||
ele,
|
||||
&name,
|
||||
&desc,
|
||||
&icon,
|
||||
&link,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Tools
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn reverse(state: State<AppState>) -> api::Outcome {
|
||||
state.run(api::reverse)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn round_trip(state: State<AppState>) -> api::Outcome {
|
||||
state.run(api::round_trip)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn crop(state: State<AppState>, start: u32, end: u32) -> api::Outcome {
|
||||
state.run(move |e| api::crop(e, start, end))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn reduce(state: State<AppState>, tolerance: f64) -> api::Outcome {
|
||||
state.run(move |e| api::reduce(e, tolerance))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn change_timestamps(
|
||||
state: State<AppState>,
|
||||
start_time: f64,
|
||||
speed: f64,
|
||||
ratio: f64,
|
||||
) -> api::Outcome {
|
||||
state.run(move |e| api::change_timestamps(e, start_time, speed, ratio))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn create_artificial_timestamps(
|
||||
state: State<AppState>,
|
||||
start_time: f64,
|
||||
total_time: f64,
|
||||
) -> api::Outcome {
|
||||
state.run(move |e| api::create_artificial_timestamps(e, start_time, total_time))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn merge(state: State<AppState>, merge_type: MergeType, remove_gaps: bool) -> api::Outcome {
|
||||
state.core(move |core| core.creating(None, false, |e| api::merge(e, merge_type, remove_gaps)))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn extract(state: State<AppState>) -> api::Outcome {
|
||||
state.core(|core| core.creating(None, false, api::extract))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn elevation(state: State<AppState>, ele: Vec<f64>) -> api::Outcome {
|
||||
state.run(move |e| api::elevation(e, &ele))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn clean(
|
||||
state: State<AppState>,
|
||||
west: f64,
|
||||
south: f64,
|
||||
east: f64,
|
||||
north: f64,
|
||||
clean_type: CleanType,
|
||||
trkpt: bool,
|
||||
wpt: bool,
|
||||
) -> api::Outcome {
|
||||
state.run(move |e| api::clean(e, west, south, east, north, clean_type, trkpt, wpt))
|
||||
}
|
||||
|
||||
// Routing
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn route(
|
||||
state: State<AppState>,
|
||||
revision: u32,
|
||||
start: u32,
|
||||
end: u32,
|
||||
lng: Vec<f64>,
|
||||
lat: Vec<f64>,
|
||||
ele: Vec<f64>,
|
||||
attributes: api::RouteAttributes,
|
||||
anchors: Vec<u32>,
|
||||
) -> api::Outcome {
|
||||
state.run(move |e| {
|
||||
api::route(
|
||||
e,
|
||||
revision,
|
||||
start,
|
||||
end,
|
||||
&lng,
|
||||
&lat,
|
||||
&ele,
|
||||
&attributes,
|
||||
&anchors,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn insert_anchor(state: State<AppState>, revision: u32, lng: f64, lat: f64) -> api::Outcome {
|
||||
state.run(move |e| api::insert_anchor(e, revision, lng, lat))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn change_loop_start(state: State<AppState>, revision: u32, index: u32) -> api::Outcome {
|
||||
state.run(move |e| api::change_loop_start(e, revision, index))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn split(
|
||||
state: State<AppState>,
|
||||
revision: u32,
|
||||
at: u32,
|
||||
split_type: SplitType,
|
||||
) -> api::Outcome {
|
||||
state.core(move |core| core.creating(None, false, |e| api::split(e, revision, at, split_type)))
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Creating, loading, deleting, moving, copying and pasting files and their elements, and undoing.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gpx_engine::api;
|
||||
use tauri::State;
|
||||
use tauri::ipc::{InvokeBody, Request};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn new_file(
|
||||
state: State<AppState>,
|
||||
name: String,
|
||||
lng: Option<f64>,
|
||||
lat: Option<f64>,
|
||||
ele: Option<f64>,
|
||||
route: Option<bool>,
|
||||
folder: Option<PathBuf>,
|
||||
) -> api::Outcome {
|
||||
let route = route.unwrap_or_default();
|
||||
// in `folder` if it is given, otherwise like the selected file, otherwise in the open folder
|
||||
state.core(move |core| {
|
||||
core.creating(folder, true, |e| {
|
||||
api::new_file(e, &name, lng, lat, ele, route)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The files are in the body, one after the other, the headers `lengths` and `names` (JSON,
|
||||
/// percent-encoded) telling the number of bytes and the name of each one.
|
||||
#[tauri::command(async)]
|
||||
pub fn load_files(state: State<AppState>, request: Request<'_>) -> api::Outcome {
|
||||
let InvokeBody::Raw(data) = request.body() else {
|
||||
return api::Outcome::failed("the files are missing");
|
||||
};
|
||||
let header = |key: &str| {
|
||||
request
|
||||
.headers()
|
||||
.get(key)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(percent_decode)
|
||||
};
|
||||
let (Some(lengths), Some(names)) = (
|
||||
header("lengths").and_then(|json| serde_json::from_str::<Vec<usize>>(&json).ok()),
|
||||
header("names").and_then(|json| serde_json::from_str::<Vec<String>>(&json).ok()),
|
||||
) else {
|
||||
return api::Outcome::failed("the lengths or the names of the files are not valid");
|
||||
};
|
||||
let data = data.clone();
|
||||
state.run(move |e| api::load_files(e, &data, &lengths, &names))
|
||||
}
|
||||
|
||||
fn percent_decode(value: &str) -> Option<String> {
|
||||
let bytes = value.as_bytes();
|
||||
let mut decoded = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' {
|
||||
let hex = value.get(i + 1..i + 3)?;
|
||||
decoded.push(u8::from_str_radix(hex, 16).ok()?);
|
||||
i += 3;
|
||||
} else {
|
||||
decoded.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8(decoded).ok()
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn duplicate(state: State<AppState>) -> api::Outcome {
|
||||
state.core(|core| core.creating(None, false, api::duplicate))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn delete(state: State<AppState>, whole_files: bool) -> api::Outcome {
|
||||
state.run(move |e| api::delete(e, whole_files))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn delete_all(state: State<AppState>) -> api::Outcome {
|
||||
state.run(api::delete_all)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn reorder(state: State<AppState>, file_ids: Vec<Uuid>, index: usize) -> api::Outcome {
|
||||
state.run(move |e| api::reorder(e, &file_ids, index))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn copy(state: State<AppState>) -> api::Outcome {
|
||||
state.run(api::copy)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn cut(state: State<AppState>) -> api::Outcome {
|
||||
state.run(api::cut)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn paste(state: State<AppState>) -> api::Outcome {
|
||||
state.run(api::paste)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn move_elements(
|
||||
state: State<AppState>,
|
||||
what: api::Selection,
|
||||
to: api::MoveTarget,
|
||||
) -> api::Outcome {
|
||||
state.run(move |e| api::move_elements(e, what, to))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn undo(state: State<AppState>) -> api::Outcome {
|
||||
state.run(api::undo)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn redo(state: State<AppState>) -> api::Outcome {
|
||||
state.run(api::redo)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! The functions of the frontend's interface to the engine, as Tauri commands. They are the
|
||||
//! counterparts of the functions that `gpx-rs/wasm` exports, and cross the same data: see
|
||||
//! `website/src/lib/engine/backend`.
|
||||
//!
|
||||
//! The commands that edit the files or change the selection return an `Outcome`.
|
||||
//! They are `async` (so they run off the main thread), the frontend sends them one after the other.
|
||||
|
||||
pub mod disk;
|
||||
pub mod edit;
|
||||
pub mod files;
|
||||
pub mod queries;
|
||||
pub mod selection;
|
||||
pub mod statistics;
|
||||
pub mod storage;
|
||||
pub mod system;
|
||||
@@ -0,0 +1,112 @@
|
||||
//! The files, their structure and statistics, the selection, the clipboard, and the coordinates of
|
||||
//! the trackpoints and waypoints (flat `[lng, lat, ...]` little-endian `f64` arrays).
|
||||
|
||||
use gpx_engine::{ExportOptions, api};
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::bytes::*;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn file_order(state: State<AppState>) -> Vec<String> {
|
||||
state.read(api::file_order)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn file_structure(state: State<AppState>, file_id: Uuid) -> Option<api::FileStructure> {
|
||||
state.read(move |e| api::file_structure(e, file_id))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn file_statistics(state: State<AppState>, file_id: Uuid) -> Option<api::GlobalStatistics> {
|
||||
state.read(move |e| api::file_statistics(e, file_id))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn selection(state: State<AppState>) -> api::Selection {
|
||||
state.read(api::selection)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn clipboard(state: State<AppState>) -> Option<api::Clipboard> {
|
||||
state.read(api::clipboard)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn can_undo(state: State<AppState>) -> bool {
|
||||
state.read(api::can_undo)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn can_redo(state: State<AppState>) -> bool {
|
||||
state.read(api::can_redo)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn can_paste(state: State<AppState>) -> bool {
|
||||
state.read(api::can_paste)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn waypoint(
|
||||
state: State<AppState>,
|
||||
file_id: Uuid,
|
||||
waypoint_id: Uuid,
|
||||
) -> Option<api::WaypointDetails> {
|
||||
state.read(move |e| api::waypoint(e, file_id, waypoint_id))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn trackpoint(
|
||||
state: State<AppState>,
|
||||
file_id: Uuid,
|
||||
segment_id: Uuid,
|
||||
index: usize,
|
||||
) -> Option<api::TrackpointDetails> {
|
||||
state.read(move |e| api::trackpoint(e, file_id, segment_id, index))
|
||||
}
|
||||
|
||||
/// A file as GPX (UTF-8 bytes), the first byte telling whether the file exists.
|
||||
#[tauri::command(async)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn export_file(
|
||||
state: State<AppState>,
|
||||
file_id: Uuid,
|
||||
time: bool,
|
||||
hr: bool,
|
||||
cad: bool,
|
||||
atemp: bool,
|
||||
power: bool,
|
||||
osm: bool,
|
||||
) -> Bytes {
|
||||
let options = ExportOptions {
|
||||
time,
|
||||
hr,
|
||||
cad,
|
||||
atemp,
|
||||
power,
|
||||
osm,
|
||||
};
|
||||
optional(state.read(move |e| api::export_file(e, file_id, options)))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn exportable_data(state: State<AppState>, file_ids: Vec<Uuid>) -> u8 {
|
||||
state.read(move |e| api::exportable_data(e, &file_ids))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn reduction_distances(state: State<AppState>) -> Bytes {
|
||||
f64s(&state.read(api::reduction_distances))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn segment_coordinates(state: State<AppState>, segment_id: Uuid) -> Bytes {
|
||||
f64s(&state.read(move |e| api::segment_coordinates(e, segment_id).to_vec()))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn waypoint_coordinates(state: State<AppState>, file_id: Uuid) -> Bytes {
|
||||
f64s(&state.read(move |e| api::waypoint_coordinates(e, file_id).to_vec()))
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Changing what is selected.
|
||||
|
||||
use gpx_engine::{SelectMode, api};
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn select(state: State<AppState>, file_ids: Vec<Uuid>, mode: SelectMode) -> api::Outcome {
|
||||
state.run(move |e| api::select(e, &file_ids, mode))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn select_all(state: State<AppState>) -> api::Outcome {
|
||||
state.run(api::select_all)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn arrow_select(state: State<AppState>, down: bool, add: bool) -> api::Outcome {
|
||||
state.run(move |e| api::arrow_select(e, down, add))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn select_routes(
|
||||
state: State<AppState>,
|
||||
file_id: Uuid,
|
||||
route_ids: Vec<Uuid>,
|
||||
mode: SelectMode,
|
||||
) -> api::Outcome {
|
||||
state.run(move |e| api::select_routes(e, file_id, &route_ids, mode))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn select_tracks(
|
||||
state: State<AppState>,
|
||||
file_id: Uuid,
|
||||
track_ids: Vec<Uuid>,
|
||||
mode: SelectMode,
|
||||
) -> api::Outcome {
|
||||
state.run(move |e| api::select_tracks(e, file_id, &track_ids, mode))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn select_segments(
|
||||
state: State<AppState>,
|
||||
file_id: Uuid,
|
||||
track_id: Uuid,
|
||||
segment_ids: Vec<Uuid>,
|
||||
mode: SelectMode,
|
||||
) -> api::Outcome {
|
||||
state.run(move |e| api::select_segments(e, file_id, track_id, &segment_ids, mode))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn select_waypoint_group(
|
||||
state: State<AppState>,
|
||||
file_id: Uuid,
|
||||
mode: SelectMode,
|
||||
) -> api::Outcome {
|
||||
state.run(move |e| api::select_waypoint_group(e, file_id, mode))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn select_waypoints(
|
||||
state: State<AppState>,
|
||||
file_id: Uuid,
|
||||
waypoint_ids: Vec<Uuid>,
|
||||
mode: SelectMode,
|
||||
) -> api::Outcome {
|
||||
state.run(move |e| api::select_waypoints(e, file_id, &waypoint_ids, mode))
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! The statistics of the selection and the anchors of the routing tool, as little-endian arrays
|
||||
//! (one per call, copies of the buffers of the engine). Optional ones start with a byte telling
|
||||
//! whether they are there.
|
||||
|
||||
use gpx_engine::api;
|
||||
use tauri::State;
|
||||
|
||||
use crate::bytes::*;
|
||||
use crate::state::AppState;
|
||||
|
||||
macro_rules! f64_getters {
|
||||
($($name:ident),*) => {$(
|
||||
#[tauri::command(async)]
|
||||
pub fn $name(state: State<AppState>) -> Bytes {
|
||||
f64s(&state.read(|e| api::$name(e).to_vec()))
|
||||
}
|
||||
)*};
|
||||
}
|
||||
|
||||
f64_getters!(total_distance, slope_segment_slopes, lng, lat, ele);
|
||||
|
||||
macro_rules! optional_f64_getters {
|
||||
($($name:ident),*) => {$(
|
||||
#[tauri::command(async)]
|
||||
pub fn $name(state: State<AppState>) -> Bytes {
|
||||
optional(state.read(|e| api::$name(e).map(f64_bytes)))
|
||||
}
|
||||
)*};
|
||||
}
|
||||
|
||||
optional_f64_getters!(speed, hr, cad, atemp, power);
|
||||
|
||||
/// The index of the first trackpoint of each smoothed elevation segment of the selection.
|
||||
#[tauri::command(async)]
|
||||
pub fn slope_segment_starts(state: State<AppState>) -> Bytes {
|
||||
u32s(&state.read(|e| api::slope_segment_starts(e).to_vec()))
|
||||
}
|
||||
|
||||
/// What the buffers do not hold for each trackpoint of the selection, for the one at `index`.
|
||||
#[tauri::command(async)]
|
||||
pub fn selection_point(state: State<AppState>, index: usize) -> Option<api::SelectionPoint> {
|
||||
state.read(move |e| api::selection_point(e, index))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn selection_statistics(state: State<AppState>) -> api::GlobalStatistics {
|
||||
state.read(api::selection_statistics)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn slice_statistics(
|
||||
state: State<AppState>,
|
||||
start: usize,
|
||||
end: usize,
|
||||
) -> Option<api::GlobalStatistics> {
|
||||
state.read(move |e| api::slice_statistics(e, start, end))
|
||||
}
|
||||
|
||||
macro_rules! category_getters {
|
||||
($starts:ident, $values:ident, $names:ident) => {
|
||||
#[tauri::command(async)]
|
||||
pub fn $starts(state: State<AppState>) -> Bytes {
|
||||
u32s(&state.read(|e| api::$starts(e).to_vec()))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn $values(state: State<AppState>) -> Bytes {
|
||||
u8s(&state.read(|e| api::$values(e).to_vec()))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn $names(state: State<AppState>) -> Vec<String> {
|
||||
state.read(|e| api::$names(e).to_vec())
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
category_getters!(surface_starts, surface_values, surfaces);
|
||||
category_getters!(highway_starts, highway_values, highways);
|
||||
category_getters!(sac_scale_starts, sac_scale_values, sac_scales);
|
||||
category_getters!(mtb_scale_starts, mtb_scale_values, mtb_scales);
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn anchor_indices(state: State<AppState>) -> Bytes {
|
||||
u32s(&state.read(|e| api::anchor_indices(e).to_vec()))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn anchor_zooms(state: State<AppState>) -> Bytes {
|
||||
u8s(&state.read(|e| api::anchor_zooms(e).to_vec()))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn segment_starts(state: State<AppState>) -> Bytes {
|
||||
u32s(&state.read(|e| api::segment_starts(e).to_vec()))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn segment_ids(state: State<AppState>) -> Vec<String> {
|
||||
state.read(api::segment_ids)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn routing_revision(state: State<AppState>) -> u32 {
|
||||
state.read(api::routing_revision)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! What the frontend knows of the storage, which is kept by the engine thread (see `state`).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use gpx_engine::api;
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// What opening the storage found.
|
||||
#[derive(Serialize)]
|
||||
pub struct StorageOpened {
|
||||
settings: BTreeMap<String, String>,
|
||||
/// All the files of the engine as added: the frontend starts with none.
|
||||
outcome: api::Outcome,
|
||||
}
|
||||
|
||||
/// The files were restored when the app started: the frontend is told about all of them.
|
||||
#[tauri::command(async)]
|
||||
pub fn open_storage(state: State<AppState>) -> StorageOpened {
|
||||
StorageOpened {
|
||||
settings: state.storage().settings(),
|
||||
outcome: state.read(api::Outcome::everything),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves when what was changed has been written to the storage, which is the case when a command
|
||||
/// returns.
|
||||
#[tauri::command(async)]
|
||||
pub fn flush_storage() {}
|
||||
|
||||
/// There is no other writer.
|
||||
#[tauri::command(async)]
|
||||
pub fn sync_storage() -> api::Outcome {
|
||||
api::Outcome::failed_none()
|
||||
}
|
||||
|
||||
/// Saves a setting, `value` being its JSON.
|
||||
#[tauri::command(async)]
|
||||
pub fn set_setting(state: State<AppState>, key: String, value: String) {
|
||||
state.put_setting(&key, &value);
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn delete_setting(state: State<AppState>, key: String) {
|
||||
state.delete_setting(&key);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! What the app asks of the system, outside of the files.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
/// Opens a web page (or an email to write) with the app of the system for it: the links of the
|
||||
/// page are not followed in the window of the app. Only `http`, `https` and `mailto` addresses are
|
||||
/// opened.
|
||||
#[tauri::command(async)]
|
||||
pub fn open_url<R: Runtime>(app: AppHandle<R>, url: String) -> Result<(), String> {
|
||||
let allowed = ["http://", "https://", "mailto:"];
|
||||
if !allowed.iter().any(|scheme| url.starts_with(scheme)) {
|
||||
return Err(format!("{url} is not a web address"));
|
||||
}
|
||||
app.opener()
|
||||
.open_url(url, None::<&str>)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Shows a file or a folder in the file manager of the system (the Finder...).
|
||||
#[tauri::command(async)]
|
||||
pub fn reveal_path<R: Runtime>(app: AppHandle<R>, path: PathBuf) -> Result<(), String> {
|
||||
app.opener()
|
||||
.reveal_item_in_dir(path)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//! The desktop shell of gpx.studio. The engine of the files (`gpx-rs/engine`) runs in this
|
||||
//! process, and the frontend talks to it through the commands of `commands`.
|
||||
|
||||
mod bytes;
|
||||
mod commands;
|
||||
mod state;
|
||||
mod store;
|
||||
mod workspace;
|
||||
|
||||
use commands::*;
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
/// The engine of the app: each file has its own history, so that undoing only concerns the
|
||||
/// selected files, and opening or closing a file is not something to undo.
|
||||
pub(crate) type Engine = gpx_engine::Engine<gpx_engine::PerFileHistory>;
|
||||
|
||||
/// The GPX files among what the system gave to the app (the arguments of the command line, when a
|
||||
/// file is opened with it).
|
||||
fn gpx_paths(arguments: impl IntoIterator<Item = String>) -> Vec<std::path::PathBuf> {
|
||||
arguments
|
||||
.into_iter()
|
||||
.map(std::path::PathBuf::from)
|
||||
.filter(|path| {
|
||||
path.is_file()
|
||||
&& path
|
||||
.extension()
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("gpx"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let builder = tauri::Builder::default();
|
||||
// a single instance of the app: starting it again shows the one that runs (it has to be the
|
||||
// first plugin)
|
||||
#[cfg(desktop)]
|
||||
let builder = builder.plugin(tauri_plugin_single_instance::init(
|
||||
|app, arguments, _directory| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
// a file that was opened with the app, while it runs (Windows, Linux)
|
||||
if let Some(state) = app.try_state::<state::AppState>() {
|
||||
state.open_external(gpx_paths(arguments.into_iter().skip(1)));
|
||||
}
|
||||
},
|
||||
));
|
||||
// on macOS, the app has a menu bar of its own (Windows and Linux have none by default)
|
||||
#[cfg(target_os = "macos")]
|
||||
let builder = builder.menu(menu);
|
||||
register_commands(builder)
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::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()),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn menu<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<tauri::menu::Menu<R>> {
|
||||
use tauri::menu::{AboutMetadata, Menu, PredefinedMenuItem, Submenu};
|
||||
// the name of the product, as macOS would otherwise name the items after the binary
|
||||
let name = app.package_info().name.clone();
|
||||
let about = AboutMetadata {
|
||||
name: Some(name.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
Menu::with_items(
|
||||
app,
|
||||
&[&Submenu::with_items(
|
||||
app,
|
||||
&name,
|
||||
true,
|
||||
&[
|
||||
&PredefinedMenuItem::about(app, Some(&format!("About {name}")), Some(about))?,
|
||||
&PredefinedMenuItem::separator(app)?,
|
||||
&PredefinedMenuItem::quit(app, Some(&format!("Quit {name}")))?,
|
||||
],
|
||||
)?],
|
||||
)
|
||||
}
|
||||
|
||||
/// Gives the builder the commands that reach the engine (which it has to be given as `AppState`).
|
||||
fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Builder<R> {
|
||||
builder.invoke_handler(tauri::generate_handler![
|
||||
// selection
|
||||
selection::select,
|
||||
selection::select_all,
|
||||
selection::arrow_select,
|
||||
selection::select_routes,
|
||||
selection::select_tracks,
|
||||
selection::select_segments,
|
||||
selection::select_waypoint_group,
|
||||
selection::select_waypoints,
|
||||
// files
|
||||
files::new_file,
|
||||
files::load_files,
|
||||
files::duplicate,
|
||||
files::delete,
|
||||
files::delete_all,
|
||||
files::reorder,
|
||||
files::copy,
|
||||
files::cut,
|
||||
files::paste,
|
||||
files::move_elements,
|
||||
files::undo,
|
||||
files::redo,
|
||||
// edits and tools
|
||||
edit::metadata,
|
||||
edit::style,
|
||||
edit::convert,
|
||||
edit::new_route,
|
||||
edit::new_track,
|
||||
edit::new_track_segment,
|
||||
edit::new_waypoint,
|
||||
edit::move_waypoint,
|
||||
edit::delete_waypoint,
|
||||
edit::update_waypoint,
|
||||
edit::reverse,
|
||||
edit::round_trip,
|
||||
edit::crop,
|
||||
edit::reduce,
|
||||
edit::change_timestamps,
|
||||
edit::create_artificial_timestamps,
|
||||
edit::merge,
|
||||
edit::extract,
|
||||
edit::elevation,
|
||||
edit::clean,
|
||||
edit::route,
|
||||
edit::insert_anchor,
|
||||
edit::change_loop_start,
|
||||
edit::split,
|
||||
// queries
|
||||
queries::file_order,
|
||||
queries::file_structure,
|
||||
queries::file_statistics,
|
||||
queries::selection,
|
||||
queries::clipboard,
|
||||
queries::can_undo,
|
||||
queries::can_redo,
|
||||
queries::can_paste,
|
||||
queries::waypoint,
|
||||
queries::trackpoint,
|
||||
queries::export_file,
|
||||
queries::exportable_data,
|
||||
queries::reduction_distances,
|
||||
queries::segment_coordinates,
|
||||
queries::waypoint_coordinates,
|
||||
// statistics
|
||||
statistics::total_distance,
|
||||
statistics::slope_segment_starts,
|
||||
statistics::slope_segment_slopes,
|
||||
statistics::lng,
|
||||
statistics::lat,
|
||||
statistics::ele,
|
||||
statistics::speed,
|
||||
statistics::hr,
|
||||
statistics::cad,
|
||||
statistics::atemp,
|
||||
statistics::power,
|
||||
statistics::selection_point,
|
||||
statistics::selection_statistics,
|
||||
statistics::slice_statistics,
|
||||
statistics::surface_starts,
|
||||
statistics::surface_values,
|
||||
statistics::highway_starts,
|
||||
statistics::highway_values,
|
||||
statistics::sac_scale_starts,
|
||||
statistics::sac_scale_values,
|
||||
statistics::mtb_scale_starts,
|
||||
statistics::mtb_scale_values,
|
||||
statistics::surfaces,
|
||||
statistics::highways,
|
||||
statistics::sac_scales,
|
||||
statistics::mtb_scales,
|
||||
statistics::anchor_indices,
|
||||
statistics::anchor_zooms,
|
||||
statistics::segment_starts,
|
||||
statistics::segment_ids,
|
||||
statistics::routing_revision,
|
||||
// storage
|
||||
storage::open_storage,
|
||||
storage::flush_storage,
|
||||
storage::sync_storage,
|
||||
storage::set_setting,
|
||||
storage::delete_setting,
|
||||
// the file system
|
||||
disk::workspace_state,
|
||||
disk::open_files,
|
||||
disk::open_paths,
|
||||
disk::keep_files,
|
||||
disk::open_folder,
|
||||
disk::close_folder,
|
||||
disk::close_files,
|
||||
disk::save_files,
|
||||
disk::save_file_as,
|
||||
disk::reload_files,
|
||||
disk::export_files,
|
||||
disk::list_folder,
|
||||
disk::create_folder,
|
||||
disk::rename_path,
|
||||
disk::rename_file,
|
||||
disk::move_paths,
|
||||
disk::delete_paths,
|
||||
// the system
|
||||
system::open_url,
|
||||
system::reveal_path,
|
||||
])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents an additional console window on Windows in release.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
gpx_studio_desktop_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
//! 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 with
|
||||
//! everything that works on it (its storage, the link between its files and the file system, the
|
||||
//! watcher of the open folder), and the commands send that thread jobs: they are done one after the
|
||||
//! other, in the order they were sent.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::{self, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::Engine;
|
||||
use gpx_engine::{Persister, Storage, api};
|
||||
use notify::RecursiveMode;
|
||||
use notify_debouncer_mini::{DebounceEventResult, Debouncer, new_debouncer};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::store::FileStorage;
|
||||
use crate::workspace::{Workspace, WorkspaceState, signature};
|
||||
|
||||
/// Tells the frontend that something happened that it did not ask for: a name and a JSON value.
|
||||
pub type Emit = Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>;
|
||||
|
||||
type Job = Box<dyn FnOnce(&mut Core) + Send>;
|
||||
|
||||
/// Everything that lives on the thread of the engine.
|
||||
pub struct Core {
|
||||
pub engine: Engine,
|
||||
pub workspace: Workspace,
|
||||
storage: Arc<FileStorage>,
|
||||
persister: Persister,
|
||||
watcher: Option<Debouncer<notify::RecommendedWatcher>>,
|
||||
/// What the watcher watches, see `Workspace::watched`.
|
||||
watching: Vec<(PathBuf, bool)>,
|
||||
jobs: Sender<Job>,
|
||||
emit: Emit,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
jobs: Arc<Mutex<Sender<Job>>>,
|
||||
storage: Arc<FileStorage>,
|
||||
}
|
||||
|
||||
impl Core {
|
||||
fn start(storage: Arc<FileStorage>, jobs: Sender<Job>, emit: Emit) -> Self {
|
||||
let mut core = Self {
|
||||
engine: Engine::default(),
|
||||
workspace: Workspace::load(storage.root()),
|
||||
persister: Persister::default(),
|
||||
storage,
|
||||
watcher: None,
|
||||
watching: vec![],
|
||||
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(&mut self.engine, paths, false);
|
||||
(self.emit)("files-opened", to_json(&outcome));
|
||||
self.commit(None);
|
||||
}
|
||||
|
||||
/// Runs `f` on the engine, which can change: what changed is kept.
|
||||
fn editing<T>(&mut self, f: impl FnOnce(&mut Engine) -> T) -> T {
|
||||
let preview = self.workspace.preview;
|
||||
let preview_before = preview.and_then(|id| signature(&self.engine, id));
|
||||
let result = f(&mut self.engine);
|
||||
// the preview is open for good once something is done to it
|
||||
let kept = preview.is_some_and(|id| signature(&self.engine, id) != preview_before);
|
||||
if let Some(id) = preview.filter(|_| kept) {
|
||||
self.workspace.keep(&[id]);
|
||||
}
|
||||
// and it goes when something else is selected
|
||||
let dropped = self.workspace.drop_preview(&mut self.engine);
|
||||
if kept || dropped.is_some() {
|
||||
self.commit(dropped.as_ref());
|
||||
} else {
|
||||
self.persist();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// After files were written (`result`): what changed in the engine is kept and told, with where
|
||||
/// the files are. Says whether they were written.
|
||||
pub fn wrote(&mut self, result: Result<api::Outcome, String>) -> Result<bool, String> {
|
||||
self.commit(result.as_ref().ok().filter(|outcome| outcome.changed));
|
||||
result.map(|_| true)
|
||||
}
|
||||
|
||||
/// After a change of the files or of where they are: the workspace and the engine are kept, and
|
||||
/// the frontend is told (`outcome` is what changed in the engine, if it did not ask for it).
|
||||
pub fn commit(&mut self, outcome: Option<&api::Outcome>) {
|
||||
self.keep_workspace();
|
||||
self.persist();
|
||||
self.announce(outcome);
|
||||
}
|
||||
|
||||
/// Writes the workspace, and watches what it holds (a file may have been opened or closed).
|
||||
pub fn keep_workspace(&mut self) {
|
||||
self.workspace.write(self.storage.root());
|
||||
self.watch();
|
||||
}
|
||||
|
||||
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 the folders of the files that are linked outside of it. Does
|
||||
/// nothing if that is what is watched already.
|
||||
pub fn watch(&mut self) {
|
||||
let wanted = self.workspace.watched();
|
||||
if wanted == self.watching && (self.watcher.is_some() || wanted.is_empty()) {
|
||||
return;
|
||||
}
|
||||
self.watcher = None;
|
||||
self.watching = wanted.clone();
|
||||
if wanted.is_empty() {
|
||||
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) => {
|
||||
for (path, recursive) in &wanted {
|
||||
let mode = if *recursive {
|
||||
RecursiveMode::Recursive
|
||||
} else {
|
||||
RecursiveMode::NonRecursive
|
||||
};
|
||||
if let Err(error) = debouncer.watcher().watch(path, mode) {
|
||||
eprintln!("{} cannot be watched: {error}", path.display());
|
||||
}
|
||||
}
|
||||
self.watcher = Some(debouncer);
|
||||
}
|
||||
Err(error) => eprintln!("the files cannot be watched: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_disk_changed(&mut self, paths: &[PathBuf]) {
|
||||
// the tree of the folder shows the disk
|
||||
let in_folder: Vec<&PathBuf> = paths
|
||||
.iter()
|
||||
.filter(|path| {
|
||||
self.workspace
|
||||
.folder
|
||||
.as_ref()
|
||||
.is_some_and(|f| path.starts_with(f))
|
||||
})
|
||||
.collect();
|
||||
if !in_folder.is_empty() {
|
||||
(self.emit)("folder-changed", to_json(&in_folder));
|
||||
}
|
||||
if let Some(outcome) = self.workspace.reconcile(&mut self.engine, Some(paths)) {
|
||||
self.commit(Some(&outcome));
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f`, which creates files (a new file, a copy, the result of a split...). Where they go
|
||||
/// when they are saved is the folder of the files they come from (the selected ones, before
|
||||
/// `f`), if it is in the open folder. `default_folder` says whether a file with nothing to
|
||||
/// come from goes in the open folder: a new file does.
|
||||
pub fn creating(
|
||||
&mut self,
|
||||
folder: Option<PathBuf>,
|
||||
default_folder: bool,
|
||||
f: impl FnOnce(&mut Engine) -> api::Outcome,
|
||||
) -> api::Outcome {
|
||||
let selected: Vec<uuid::Uuid> = self
|
||||
.engine
|
||||
.selection()
|
||||
.file_ids()
|
||||
.into_iter()
|
||||
.map(|gpx_engine::FileId(id)| id)
|
||||
.collect();
|
||||
let dir = folder
|
||||
.map(|folder| crate::workspace::canonical(&folder))
|
||||
.or_else(|| self.workspace.source_dir(&selected))
|
||||
.or_else(|| {
|
||||
default_folder
|
||||
.then(|| self.workspace.folder.clone())
|
||||
.flatten()
|
||||
});
|
||||
let mut outcome = f(&mut self.engine);
|
||||
if let Some(dir) = dir {
|
||||
self.workspace.inherit(outcome.added(), &dir);
|
||||
// what is made in a folder is written there at once, so that the tree shows it
|
||||
let made: Vec<uuid::Uuid> = outcome
|
||||
.added()
|
||||
.iter()
|
||||
.filter_map(|id| uuid::Uuid::parse_str(id).ok())
|
||||
.filter(|id| self.workspace.dirs.contains_key(id))
|
||||
.collect();
|
||||
match self.workspace.save_new(&mut self.engine, &made) {
|
||||
Ok(renamed) => outcome = outcome.merge(renamed),
|
||||
Err(error) if !outcome.has_error() => {
|
||||
outcome = outcome.merge(api::Outcome::failed(error));
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
self.commit(None);
|
||||
outcome
|
||||
}
|
||||
}
|
||||
|
||||
fn to_json<T: Serialize>(value: &T) -> serde_json::Value {
|
||||
serde_json::to_value(value).unwrap_or_default()
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Starts the engine, which restores what was kept in `directory`.
|
||||
pub fn open(directory: impl Into<PathBuf>, emit: Emit) -> Result<Self, String> {
|
||||
let storage = Arc::new(FileStorage::open(directory).map_err(|error| error.to_string())?);
|
||||
let (jobs, receiver) = mpsc::channel::<Job>();
|
||||
let (thread_storage, thread_jobs) = (storage.clone(), jobs.clone());
|
||||
thread::Builder::new()
|
||||
.name("engine".into())
|
||||
.spawn(move || {
|
||||
let mut core = Core::start(thread_storage, thread_jobs, emit);
|
||||
while let Ok(job) = receiver.recv() {
|
||||
job(&mut core);
|
||||
}
|
||||
})
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(Self {
|
||||
jobs: Arc::new(Mutex::new(jobs)),
|
||||
storage,
|
||||
})
|
||||
}
|
||||
|
||||
/// An engine that keeps its data in a directory of its own, and has nobody to tell.
|
||||
#[cfg(test)]
|
||||
pub fn temporary() -> Self {
|
||||
let directory = std::env::temp_dir().join(format!("gpx-studio-{}", uuid::Uuid::new_v4()));
|
||||
Self::open(directory, Arc::new(|_, _| {})).expect("the storage can be opened")
|
||||
}
|
||||
|
||||
/// Opens files that the system gave to the app, see `Core::open_external`.
|
||||
pub fn open_external(&self, paths: Vec<PathBuf>) {
|
||||
self.core(move |core| core.open_external(&paths));
|
||||
}
|
||||
|
||||
/// Runs `f` on the thread of the engine and waits for its result.
|
||||
pub fn core<T: Send + 'static>(&self, f: impl FnOnce(&mut Core) -> T + Send + 'static) -> T {
|
||||
let (reply, result) = mpsc::channel();
|
||||
let job: Job = Box::new(move |core| {
|
||||
let _ = reply.send(f(core));
|
||||
});
|
||||
self.jobs
|
||||
.lock()
|
||||
.unwrap()
|
||||
.send(job)
|
||||
.expect("the engine thread stopped");
|
||||
result.recv().expect("the engine thread stopped")
|
||||
}
|
||||
|
||||
/// Runs `f` on the engine, which can change: what changed is kept (see `Core::editing`).
|
||||
pub fn run<T: Send + 'static>(&self, f: impl FnOnce(&mut Engine) -> T + Send + 'static) -> T {
|
||||
self.core(move |core| core.editing(f))
|
||||
}
|
||||
|
||||
/// Reads from the engine.
|
||||
pub fn read<T: Send + 'static>(&self, f: impl FnOnce(&Engine) -> T + Send + 'static) -> T {
|
||||
self.core(move |core| f(&core.engine))
|
||||
}
|
||||
|
||||
pub fn storage(&self) -> &FileStorage {
|
||||
&self.storage
|
||||
}
|
||||
}
|
||||
|
||||
// the settings only are read and written by the commands, from any thread
|
||||
impl AppState {
|
||||
pub fn put_setting(&self, key: &str, value: &str) {
|
||||
if let Err(error) = pollster::block_on(self.storage.put_setting(key, value)) {
|
||||
eprintln!("the setting could not be kept: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_setting(&self, key: &str) {
|
||||
if let Err(error) = pollster::block_on(self.storage.delete_setting(key)) {
|
||||
eprintln!("the setting could not be kept: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//! 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 crate::Engine;
|
||||
use gpx_engine::{Action, Command, 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,787 @@
|
||||
//! The commands, called the way the frontend calls them (through the IPC of Tauri), on the sample
|
||||
//! files of the engine.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use tauri::test::{INVOKE_KEY, get_ipc_response, mock_builder, mock_context, noop_assets};
|
||||
use tauri::webview::InvokeRequest;
|
||||
use tauri::{WebviewWindow, ipc::CallbackFn, ipc::InvokeBody, ipc::InvokeResponseBody};
|
||||
|
||||
struct App(WebviewWindow<tauri::test::MockRuntime>);
|
||||
|
||||
impl App {
|
||||
fn new() -> Self {
|
||||
let app =
|
||||
crate::register_commands(mock_builder().manage(crate::state::AppState::temporary()))
|
||||
.build(mock_context(noop_assets()))
|
||||
.unwrap();
|
||||
let window = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
|
||||
.build()
|
||||
.unwrap();
|
||||
// the app has to outlive its window
|
||||
std::mem::forget(app);
|
||||
Self(window)
|
||||
}
|
||||
|
||||
fn request(
|
||||
&self,
|
||||
cmd: &str,
|
||||
body: InvokeBody,
|
||||
headers: tauri::http::HeaderMap,
|
||||
) -> InvokeResponseBody {
|
||||
get_ipc_response(
|
||||
&self.0,
|
||||
InvokeRequest {
|
||||
cmd: cmd.into(),
|
||||
callback: CallbackFn(0),
|
||||
error: CallbackFn(1),
|
||||
url: "tauri://localhost".parse().unwrap(),
|
||||
body,
|
||||
headers,
|
||||
invoke_key: INVOKE_KEY.to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("{cmd} failed: {error}"))
|
||||
}
|
||||
|
||||
fn call(&self, cmd: &str, args: Value) -> Value {
|
||||
match self.request(cmd, InvokeBody::Json(args), Default::default()) {
|
||||
InvokeResponseBody::Json(json) => serde_json::from_str(&json).unwrap(),
|
||||
InvokeResponseBody::Raw(_) => panic!("{cmd} returned bytes"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The error of a command that fails.
|
||||
fn error(&self, cmd: &str, args: Value) -> Value {
|
||||
let request = InvokeRequest {
|
||||
cmd: cmd.into(),
|
||||
callback: CallbackFn(0),
|
||||
error: CallbackFn(1),
|
||||
url: "tauri://localhost".parse().unwrap(),
|
||||
body: InvokeBody::Json(args),
|
||||
headers: Default::default(),
|
||||
invoke_key: INVOKE_KEY.to_string(),
|
||||
};
|
||||
get_ipc_response(&self.0, request).expect_err(&format!("{cmd} did not fail"))
|
||||
}
|
||||
|
||||
fn bytes(&self, cmd: &str, args: Value) -> Vec<u8> {
|
||||
match self.request(cmd, InvokeBody::Json(args), Default::default()) {
|
||||
InvokeResponseBody::Raw(bytes) => bytes,
|
||||
InvokeResponseBody::Json(_) => panic!("{cmd} returned JSON"),
|
||||
}
|
||||
}
|
||||
|
||||
fn load(&self, names: &[&str]) -> Value {
|
||||
let mut data = vec![];
|
||||
let mut lengths = vec![];
|
||||
for name in names {
|
||||
let file = std::fs::read(format!("../gpx-rs/data/{name}.gpx")).unwrap();
|
||||
lengths.push(file.len());
|
||||
data.extend(file);
|
||||
}
|
||||
let header = |value: Value| value.to_string().replace('"', "%22").parse().unwrap();
|
||||
let mut headers = tauri::http::HeaderMap::new();
|
||||
headers.insert("lengths", header(json!(lengths)));
|
||||
headers.insert("names", header(json!(names)));
|
||||
match self.request("load_files", InvokeBody::Raw(data), headers) {
|
||||
InvokeResponseBody::Json(json) => serde_json::from_str(&json).unwrap(),
|
||||
InvokeResponseBody::Raw(_) => panic!("load_files returned bytes"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn f64s(bytes: &[u8]) -> Vec<f64> {
|
||||
bytes
|
||||
.as_chunks::<8>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|chunk| f64::from_le_bytes(*chunk))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_files_and_describes_them() {
|
||||
let app = App::new();
|
||||
let outcome = app.load(&["simple", "with_tracks"]);
|
||||
assert_eq!(outcome["changed"], true);
|
||||
assert_eq!(outcome["added"].as_array().unwrap().len(), 2);
|
||||
assert!(outcome.get("error").is_none());
|
||||
|
||||
let order = app.call("file_order", json!({}));
|
||||
let first = order[0].as_str().unwrap();
|
||||
assert_eq!(
|
||||
app.call("selection", json!({})),
|
||||
json!({ "type": "file", "fileIds": [first] })
|
||||
);
|
||||
|
||||
let structure = app.call("file_structure", json!({ "fileId": first }));
|
||||
assert_eq!(structure["name"], "simple");
|
||||
let segment = &structure["tracks"][0]["segments"][0];
|
||||
let length = segment["length"].as_f64().unwrap() as usize;
|
||||
assert!(length > 0);
|
||||
|
||||
// coordinates are little-endian doubles, lng and lat for each trackpoint
|
||||
let coordinates =
|
||||
f64s(&app.bytes("segment_coordinates", json!({ "segmentId": segment["id"] })));
|
||||
assert_eq!(coordinates.len(), 2 * length);
|
||||
|
||||
let statistics = app.call("file_statistics", json!({ "fileId": first }));
|
||||
assert!(statistics["totalDistance"].as_f64().unwrap() > 0.0);
|
||||
assert!(
|
||||
statistics["bounds"]["west"].as_f64().unwrap()
|
||||
<= statistics["bounds"]["east"].as_f64().unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
app.call(
|
||||
"file_structure",
|
||||
json!({ "fileId": "00000000-0000-0000-0000-000000000000" })
|
||||
),
|
||||
Value::Null
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_statistics_of_the_selection() {
|
||||
let app = App::new();
|
||||
app.load(&["simple"]);
|
||||
let distance = f64s(&app.bytes("total_distance", json!({})));
|
||||
assert!(distance.len() > 1);
|
||||
assert_eq!(f64s(&app.bytes("lng", json!({}))).len(), distance.len());
|
||||
assert!(
|
||||
app.call("selection_statistics", json!({}))["totalDistance"]
|
||||
.as_f64()
|
||||
.unwrap()
|
||||
> 0.0
|
||||
);
|
||||
// optional arrays start with a byte telling whether they are there
|
||||
let hr = app.bytes("hr", json!({}));
|
||||
assert_eq!(hr[0], 0);
|
||||
// the slope segments are intervals of trackpoints
|
||||
let starts = app.bytes("slope_segment_starts", json!({}));
|
||||
let slopes = f64s(&app.bytes("slope_segment_slopes", json!({})));
|
||||
assert_eq!(starts.len() / 4, slopes.len());
|
||||
assert!(!slopes.is_empty() && slopes.len() < distance.len());
|
||||
let point = app.call("selection_point", json!({ "index": 1 }));
|
||||
assert!(point["slopeSegmentDistance"].is_number());
|
||||
assert!(point.get("time").is_none());
|
||||
assert_eq!(
|
||||
app.call("selection_point", json!({ "index": distance.len() })),
|
||||
Value::Null
|
||||
);
|
||||
assert!(
|
||||
app.call("slice_statistics", json!({ "start": 1, "end": 3 }))["totalDistance"].is_number()
|
||||
);
|
||||
assert_eq!(
|
||||
app.call("slice_statistics", json!({ "start": 0, "end": 100000 })),
|
||||
Value::Null
|
||||
);
|
||||
|
||||
app.load(&["with_hr"]);
|
||||
app.call("select_all", json!({}));
|
||||
let hr = app.bytes("hr", json!({}));
|
||||
assert_eq!(hr[0], 1);
|
||||
assert_eq!((hr.len() - 1) % 8, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_undoes_and_selects() {
|
||||
let app = App::new();
|
||||
let outcome = app.call(
|
||||
"new_file",
|
||||
json!({ "name": "route", "lng": 4.4, "lat": 50.8 }),
|
||||
);
|
||||
assert_eq!(outcome["changed"], true);
|
||||
// each file has its own history, which starts when it appears
|
||||
assert_eq!(app.call("can_undo", json!({})), false);
|
||||
|
||||
let id = app.call("file_order", json!({}))[0].clone();
|
||||
let outcome = app.call("metadata", json!({ "name": "renamed", "desc": "" }));
|
||||
assert_eq!(outcome["modified"], json!([id]));
|
||||
assert_eq!(
|
||||
app.call("file_structure", json!({ "fileId": id }))["name"],
|
||||
"renamed"
|
||||
);
|
||||
|
||||
let outcome = app.call("select", json!({ "fileIds": [], "mode": "replace" }));
|
||||
assert_eq!(outcome["selectionChanged"], true);
|
||||
assert_eq!(app.call("selection", json!({})), json!({ "type": "empty" }));
|
||||
app.call("select", json!({ "fileIds": [id], "mode": "toggle" }));
|
||||
assert_eq!(app.call("can_undo", json!({})), true);
|
||||
|
||||
app.call("undo", json!({}));
|
||||
assert_eq!(
|
||||
app.call("file_structure", json!({ "fileId": id }))["name"],
|
||||
"route"
|
||||
);
|
||||
assert_eq!(app.call("can_redo", json!({})), true);
|
||||
// creating the file is not something to undo
|
||||
assert_eq!(app.call("can_undo", json!({})), false);
|
||||
let outcome = app.call("undo", json!({}));
|
||||
assert_eq!(outcome["changed"], false);
|
||||
assert_eq!(app.call("file_order", json!({})), json!([id]));
|
||||
|
||||
// what cannot be done says why
|
||||
let outcome = app.call("reverse", json!({}));
|
||||
assert_eq!(outcome["changed"], false);
|
||||
assert!(outcome["error"].is_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_and_clipboard() {
|
||||
let app = App::new();
|
||||
app.load(&["simple", "with_tracks"]);
|
||||
let ids = app.call("file_order", json!({}));
|
||||
app.call("select", json!({ "fileIds": [ids[0]], "mode": "replace" }));
|
||||
app.call("copy", json!({}));
|
||||
assert_eq!(
|
||||
app.call("clipboard", json!({})),
|
||||
json!({ "type": "files", "ids": [ids[0]], "cut": false })
|
||||
);
|
||||
|
||||
let outcome = app.call(
|
||||
"move_elements",
|
||||
json!({
|
||||
"what": { "type": "file", "fileIds": [ids[0]] },
|
||||
"to": { "type": "files", "index": 9007199254740991u64 },
|
||||
}),
|
||||
);
|
||||
assert_eq!(outcome["orderChanged"], true);
|
||||
assert_eq!(app.call("file_order", json!({})), json!([ids[1], ids[0]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waypoints_exports_and_routing_revisions() {
|
||||
let app = App::new();
|
||||
app.call("new_file", json!({ "name": "file" }));
|
||||
let outcome = app.call(
|
||||
"new_waypoint",
|
||||
json!({ "lng": 4.4, "lat": 50.8, "ele": 100, "name": "summit", "desc": "", "icon": "Summit", "link": "https://example.com" }),
|
||||
);
|
||||
assert_eq!(outcome["changed"], true);
|
||||
let file = app.call("file_order", json!({}))[0].clone();
|
||||
let waypoint =
|
||||
app.call("file_structure", json!({ "fileId": file }))["waypoints"][0]["id"].clone();
|
||||
let details = app.call(
|
||||
"waypoint",
|
||||
json!({ "fileId": file, "waypointId": waypoint }),
|
||||
);
|
||||
assert_eq!(details["name"], "summit");
|
||||
assert_eq!(details["sym"], "Summit");
|
||||
assert_eq!(details["links"][0]["href"], "https://example.com");
|
||||
|
||||
let exported = app.bytes(
|
||||
"export_file",
|
||||
json!({ "fileId": file, "time": true, "hr": true, "cad": true, "atemp": true, "power": true, "osm": true }),
|
||||
);
|
||||
assert_eq!(exported[0], 1);
|
||||
assert!(
|
||||
String::from_utf8(exported[1..].to_vec())
|
||||
.unwrap()
|
||||
.contains("<wpt")
|
||||
);
|
||||
assert_eq!(app.call("exportable_data", json!({ "fileIds": [file] })), 0);
|
||||
|
||||
let stale = app.call(
|
||||
"route",
|
||||
json!({
|
||||
"revision": 12345, "start": 0, "end": 0,
|
||||
"lng": [4.4], "lat": [50.8], "ele": [0],
|
||||
"attributes": { "surface": { "starts": [0], "values": [0], "names": [] } },
|
||||
"anchors": [],
|
||||
}),
|
||||
);
|
||||
assert_eq!(stale["changed"], false);
|
||||
assert!(stale["error"].as_str().unwrap().contains("routing buffers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_settings_for_the_session() {
|
||||
let app = App::new();
|
||||
app.call(
|
||||
"set_setting",
|
||||
json!({ "key": "units", "value": "\"metric\"" }),
|
||||
);
|
||||
assert_eq!(
|
||||
app.call("open_storage", json!({}))["settings"]["units"],
|
||||
"\"metric\""
|
||||
);
|
||||
app.call("delete_setting", json!({ "key": "units" }));
|
||||
assert_eq!(app.call("open_storage", json!({}))["settings"], json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_files_are_still_there_in_the_next_session() {
|
||||
use crate::state::AppState;
|
||||
use gpx_engine::api;
|
||||
|
||||
let directory =
|
||||
std::env::temp_dir().join(format!("gpx-studio-session-{}", uuid::Uuid::new_v4()));
|
||||
let data = std::fs::read("../gpx-rs/data/simple.gpx").unwrap();
|
||||
let first = AppState::open(&directory, std::sync::Arc::new(|_, _| {})).unwrap();
|
||||
let lengths = [data.len()];
|
||||
let names = ["simple".to_string()];
|
||||
first.run(move |e| api::load_files(e, &data, &lengths, &names));
|
||||
let order = first.read(api::file_order);
|
||||
|
||||
let second = AppState::open(&directory, std::sync::Arc::new(|_, _| {})).unwrap();
|
||||
assert_eq!(second.read(api::file_order), order);
|
||||
let outcome = second.read(api::Outcome::everything);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&outcome).unwrap()["added"],
|
||||
serde_json::json!(order)
|
||||
);
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_gpx_files_that_exist_are_taken_from_what_the_system_gives() {
|
||||
let directory = std::env::temp_dir().join(format!("gpx-studio-args-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
let track = directory.join("track.GPX");
|
||||
std::fs::copy("../gpx-rs/data/simple.gpx", &track).unwrap();
|
||||
let text = directory.join("notes.txt");
|
||||
std::fs::write(&text, "x").unwrap();
|
||||
let arguments = [
|
||||
"--flag".to_string(),
|
||||
track.to_string_lossy().into_owned(),
|
||||
text.to_string_lossy().into_owned(),
|
||||
directory.join("missing.gpx").to_string_lossy().into_owned(),
|
||||
];
|
||||
assert_eq!(crate::gpx_paths(arguments), [track]);
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_opened_with_the_app_is_opened_selected_and_announced() {
|
||||
use crate::state::AppState;
|
||||
use gpx_engine::api;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
let directory = std::env::temp_dir().join(format!("gpx-studio-open-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
let track = directory.join("track.gpx");
|
||||
std::fs::copy("../gpx-rs/data/simple.gpx", &track).unwrap();
|
||||
|
||||
let events = Arc::new(Mutex::new(vec![]));
|
||||
let sink = events.clone();
|
||||
let state = AppState::open(
|
||||
directory.join("store"),
|
||||
Arc::new(move |name, payload| sink.lock().unwrap().push((name.to_string(), payload))),
|
||||
)
|
||||
.unwrap();
|
||||
state.open_external(vec![track.clone()]);
|
||||
// opening it again is a selection, not another file
|
||||
state.open_external(vec![track]);
|
||||
|
||||
assert_eq!(state.read(api::file_order).len(), 1);
|
||||
assert!(matches!(
|
||||
state.read(api::selection),
|
||||
api::Selection::File { .. }
|
||||
));
|
||||
let events = events.lock().unwrap();
|
||||
let opened: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|(name, _)| name == "files-opened")
|
||||
.collect();
|
||||
assert_eq!(opened.len(), 2);
|
||||
assert_eq!(opened[0].1["added"].as_array().unwrap().len(), 1);
|
||||
assert!(opened[1].1["added"].as_array().unwrap().is_empty());
|
||||
assert!(events.iter().any(|(name, _)| name == "workspace-changed"));
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_are_opened_closed_and_made_in_a_folder() {
|
||||
let app = App::new();
|
||||
let folder = std::env::temp_dir().join(format!("gpx-tests-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
let path = folder.join("track.gpx");
|
||||
std::fs::copy("../gpx-rs/data/simple.gpx", &path).unwrap();
|
||||
|
||||
// opened from the disk, clean
|
||||
let outcome = app.call("open_paths", json!({ "paths": [path] }));
|
||||
assert_eq!(outcome["added"].as_array().unwrap().len(), 1);
|
||||
let id = outcome["added"][0].as_str().unwrap().to_string();
|
||||
let state = app.call("workspace_state", json!({}));
|
||||
assert_eq!(state["files"][&id]["dirty"], false);
|
||||
assert!(state["files"][&id]["path"].is_string());
|
||||
|
||||
// a new file made in a folder is written there at once, so that the tree shows it
|
||||
let created = app.call(
|
||||
"new_file",
|
||||
json!({ "name": "route", "lng": 4.4, "lat": 50.8, "folder": folder }),
|
||||
);
|
||||
let new_id = created["added"][0].as_str().unwrap().to_string();
|
||||
let state = app.call("workspace_state", json!({}));
|
||||
assert_eq!(state["files"][&new_id]["dirty"], false);
|
||||
assert!(state["files"][&new_id]["path"].is_string());
|
||||
assert!(folder.join("route.gpx").exists());
|
||||
|
||||
// the tree needs an open folder
|
||||
assert_eq!(app.call("list_folder", json!({})), json!([]));
|
||||
|
||||
// closing: what is saved goes, what has edits stays unless it is discarded
|
||||
app.call("select", json!({ "fileIds": [new_id], "mode": "replace" }));
|
||||
app.call("style", json!({ "color": "#ff0000" }));
|
||||
app.call("close_files", json!({ "fileIds": [id, new_id] }));
|
||||
assert_eq!(app.call("file_order", json!({})), json!([new_id]));
|
||||
app.call(
|
||||
"close_files",
|
||||
json!({ "fileIds": [new_id], "unsaved": "discard" }),
|
||||
);
|
||||
assert_eq!(app.call("file_order", json!({})), json!([]));
|
||||
std::fs::remove_dir_all(folder).unwrap();
|
||||
}
|
||||
|
||||
/// A file that is open from outside of any folder follows the disk too.
|
||||
#[test]
|
||||
fn a_file_outside_the_open_folder_is_read_again_when_the_disk_changes() {
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
let (events, received) = mpsc::channel();
|
||||
let directory = std::env::temp_dir().join(format!("gpx-store-{}", uuid::Uuid::new_v4()));
|
||||
let state = crate::state::AppState::open(
|
||||
directory.clone(),
|
||||
std::sync::Arc::new(move |name, _| {
|
||||
let _ = events.send(name.to_string());
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let folder = std::env::temp_dir().join(format!("gpx-tests-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
let path = folder.join("track.gpx");
|
||||
std::fs::copy("../gpx-rs/data/simple.gpx", &path).unwrap();
|
||||
let opened = path.clone();
|
||||
let id = state.core(move |core| {
|
||||
let outcome = core.workspace.open_files(&mut core.engine, &[opened], true);
|
||||
core.keep_workspace();
|
||||
outcome.added()[0].clone()
|
||||
});
|
||||
let id = uuid::Uuid::parse_str(&id).unwrap();
|
||||
// the name comes from the path, which does not change: the content does
|
||||
let signature = |state: &crate::state::AppState| {
|
||||
state.core(move |core| crate::workspace::signature(&core.engine, id))
|
||||
};
|
||||
let before = signature(&state);
|
||||
|
||||
std::fs::copy("../gpx-rs/data/with_time.gpx", &path).unwrap();
|
||||
// the debounced watcher tells the frontend that the engine changed
|
||||
let mut changed = false;
|
||||
while let Ok(event) = received.recv_timeout(Duration::from_secs(10)) {
|
||||
if event == "engine-changed" {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(changed, "the file was not read again");
|
||||
assert_ne!(signature(&state), before);
|
||||
std::fs::remove_dir_all(folder).unwrap();
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_made_from_a_file_of_the_open_folder_are_written_next_to_it_at_once() {
|
||||
let state = crate::state::AppState::temporary();
|
||||
let folder = std::env::temp_dir().join(format!("gpx-tests-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(folder.join("sub")).unwrap();
|
||||
std::fs::copy("../gpx-rs/data/simple.gpx", folder.join("sub/track.gpx")).unwrap();
|
||||
let sub = folder.join("sub");
|
||||
let (copied, new_file) = state.core(move |core| {
|
||||
core.workspace.open_folder(&mut core.engine, &folder, false);
|
||||
let opened = core
|
||||
.workspace
|
||||
.open_files(&mut core.engine, &[sub.join("track.gpx")], true);
|
||||
core.keep_workspace();
|
||||
assert_eq!(opened.added().len(), 1);
|
||||
let copy = core.creating(None, false, gpx_engine::api::duplicate);
|
||||
let fresh = core.creating(None, true, |e| {
|
||||
gpx_engine::api::new_file(e, "fresh", None, None, None, false)
|
||||
});
|
||||
// a copy of the file goes next to it, a new file with nothing selected in the selection
|
||||
// of the file that was just made
|
||||
(copy, fresh)
|
||||
});
|
||||
let sub = std::env::temp_dir();
|
||||
let _ = sub;
|
||||
assert_eq!(copied.added().len(), 1);
|
||||
assert_eq!(new_file.added().len(), 1);
|
||||
let status = state.core(|core| core.workspace_state());
|
||||
assert!(status.files.values().all(|file| !file.dirty));
|
||||
let paths: Vec<String> = status
|
||||
.files
|
||||
.values()
|
||||
.map(|file| file.path.clone().unwrap())
|
||||
.collect();
|
||||
// the copy is in the subfolder, named after its name (the one in the file, not of the file)
|
||||
assert!(
|
||||
paths.iter().any(|path| path.ends_with("sub/simple.gpx")),
|
||||
"{paths:?}"
|
||||
);
|
||||
// the new file went where the selected file is
|
||||
assert!(
|
||||
paths.iter().any(|path| path.ends_with("sub/fresh.gpx")),
|
||||
"{paths:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_parts_of_a_split_are_numbered_from_one() {
|
||||
let state = crate::state::AppState::temporary();
|
||||
let folder = std::env::temp_dir().join(format!("gpx-tests-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
std::fs::copy("../gpx-rs/data/simple.gpx", folder.join("track.gpx")).unwrap();
|
||||
let root = folder.clone();
|
||||
let outcome = state.core(move |core| {
|
||||
core.workspace.open_folder(&mut core.engine, &root, false);
|
||||
core.workspace
|
||||
.open_files(&mut core.engine, &[root.join("track.gpx")], true);
|
||||
core.keep_workspace();
|
||||
let revision = core.engine.routing().revision;
|
||||
core.creating(None, false, |e| {
|
||||
gpx_engine::api::split(e, revision, 2, gpx_engine::SplitType::Files)
|
||||
})
|
||||
});
|
||||
assert_eq!(outcome.added().len(), 2, "{:?}", outcome.added());
|
||||
let mut names: Vec<String> = std::fs::read_dir(&folder)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|entry| entry.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
names.sort();
|
||||
// the file stays, its two parts are numbered from 1, under their name (the one in the file)
|
||||
assert_eq!(names, ["simple (1).gpx", "simple (2).gpx", "track.gpx"]);
|
||||
std::fs::remove_dir_all(folder).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_can_be_reordered() {
|
||||
let app = App::new();
|
||||
let mut ids = vec![];
|
||||
for name in ["a", "b", "c"] {
|
||||
let outcome = app.call("new_file", json!({ "name": name }));
|
||||
ids.push(outcome["added"][0].as_str().unwrap().to_string());
|
||||
}
|
||||
assert_eq!(app.call("file_order", json!({})), json!(ids));
|
||||
|
||||
let outcome = app.call("reorder", json!({ "fileIds": [ids[2]], "index": 0 }));
|
||||
assert_eq!(outcome["changed"], true);
|
||||
assert_eq!(outcome["orderChanged"], true);
|
||||
assert_eq!(
|
||||
app.call("file_order", json!({})),
|
||||
json!([ids[2], ids[0], ids[1]])
|
||||
);
|
||||
}
|
||||
|
||||
/// A folder of its own, with GPX files whose name inside is not the name of the file.
|
||||
fn folder_with(files: &[&str]) -> std::path::PathBuf {
|
||||
let folder = std::env::temp_dir().join(format!("gpx-names-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
for name in files {
|
||||
std::fs::write(
|
||||
folder.join(format!("{name}.gpx")),
|
||||
r#"<?xml version="1.0"?><gpx version="1.1" creator="test"><metadata><name>Inside</name></metadata><trk><trkseg><trkpt lat="50.0" lon="4.0"></trkpt><trkpt lat="50.1" lon="4.1"></trkpt></trkseg></trk></gpx>"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
folder
|
||||
}
|
||||
|
||||
fn name_of(app: &App, id: &str) -> String {
|
||||
app.call("file_structure", json!({ "fileId": id }))["name"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// The name of the file on the disk of an open file, without its extension.
|
||||
fn file_stem_of(app: &App, id: &str) -> String {
|
||||
let path = app.call("workspace_state", json!({}))["files"][id]["path"].clone();
|
||||
std::path::Path::new(path.as_str().unwrap())
|
||||
.file_stem()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_keeps_its_name_and_its_file_is_renamed_apart() {
|
||||
let app = App::new();
|
||||
let folder = folder_with(&["outside", "taken"]);
|
||||
let outcome = app.call(
|
||||
"open_paths",
|
||||
json!({ "paths": [folder.join("outside.gpx")] }),
|
||||
);
|
||||
let id = outcome["added"][0].as_str().unwrap().to_string();
|
||||
let dirty = |app: &App| app.call("workspace_state", json!({}))["files"][&id]["dirty"].clone();
|
||||
let inside = |name: &str| std::fs::read_to_string(folder.join(format!("{name}.gpx"))).unwrap();
|
||||
|
||||
// the name in the file is its name, not the one of the file
|
||||
assert_eq!(name_of(&app, &id), "Inside");
|
||||
assert_eq!(file_stem_of(&app, &id), "outside");
|
||||
assert_eq!(dirty(&app), false);
|
||||
|
||||
// renaming it is an edit of the file only, which is undone
|
||||
app.call("select", json!({ "fileIds": [id], "mode": "replace" }));
|
||||
let outcome = app.call("metadata", json!({ "name": "renamed", "desc": "" }));
|
||||
assert!(outcome.get("error").is_none());
|
||||
assert_eq!(name_of(&app, &id), "renamed");
|
||||
assert_eq!(file_stem_of(&app, &id), "outside");
|
||||
assert_eq!(dirty(&app), true);
|
||||
app.call("undo", json!({}));
|
||||
assert_eq!(name_of(&app, &id), "Inside");
|
||||
assert_eq!(dirty(&app), false);
|
||||
|
||||
// the file on the disk is renamed apart: not an edit, the file is as saved as it was
|
||||
let renamed = app.call("rename_file", json!({ "fileId": id, "name": "moved" }));
|
||||
assert_eq!(renamed, true);
|
||||
assert!(folder.join("moved.gpx").exists() && !folder.join("outside.gpx").exists());
|
||||
assert_eq!(file_stem_of(&app, &id), "moved");
|
||||
assert_eq!(name_of(&app, &id), "Inside");
|
||||
assert_eq!(dirty(&app), false);
|
||||
assert_eq!(app.call("can_undo", json!({})), false);
|
||||
// saving writes to the new place, under the name of the file
|
||||
app.call("metadata", json!({ "name": "saved", "desc": "" }));
|
||||
app.call("save_files", json!({ "fileIds": [id] }));
|
||||
assert!(inside("moved").contains("<name>saved</name>"));
|
||||
assert_eq!(name_of(&app, &id), "saved");
|
||||
|
||||
// the name of another file is not taken
|
||||
let error = app.error("rename_file", json!({ "fileId": id, "name": "taken" }));
|
||||
assert_eq!(error, crate::workspace::NAME_TAKEN);
|
||||
assert!(folder.join("moved.gpx").exists());
|
||||
|
||||
// a name is one that a file can have
|
||||
app.call("rename_file", json!({ "fileId": id, "name": "a/b" }));
|
||||
assert!(folder.join("a_b.gpx").exists());
|
||||
|
||||
// only the case changes: the same file
|
||||
app.call("rename_file", json!({ "fileId": id, "name": "A_B" }));
|
||||
let names: Vec<String> = std::fs::read_dir(&folder)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert!(names.contains(&"A_B.gpx".to_string()));
|
||||
assert_eq!(names.len(), 2);
|
||||
assert_eq!(name_of(&app, &id), "saved");
|
||||
std::fs::remove_dir_all(folder).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_file_written_under_another_name_keeps_its_own() {
|
||||
let app = App::new();
|
||||
let folder = folder_with(&["route"]);
|
||||
// a new file made in the folder is written there at once: route.gpx is taken
|
||||
let created = app.call(
|
||||
"new_file",
|
||||
json!({ "name": "route", "lng": 4.4, "lat": 50.8, "folder": folder }),
|
||||
);
|
||||
let id = created["added"][0].as_str().unwrap().to_string();
|
||||
assert!(folder.join("route (2).gpx").exists());
|
||||
// the file keeps its name, whatever the name of the file it was written to
|
||||
assert_eq!(name_of(&app, &id), "route");
|
||||
assert_eq!(
|
||||
app.call("workspace_state", json!({}))["files"][&id]["dirty"],
|
||||
false
|
||||
);
|
||||
// which is not an edit to undo
|
||||
app.call("select", json!({ "fileIds": [id], "mode": "replace" }));
|
||||
assert_eq!(app.call("can_undo", json!({})), false);
|
||||
std::fs::remove_dir_all(folder).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_open_as_a_preview_is_replaced_until_it_is_kept_or_edited() {
|
||||
let app = App::new();
|
||||
let folder = folder_with(&["a", "b", "c", "d"]);
|
||||
let path = |name: &str| folder.join(format!("{name}.gpx"));
|
||||
let open = |name: &str, preview: bool| {
|
||||
app.call(
|
||||
"open_paths",
|
||||
json!({ "paths": [path(name)], "preview": preview }),
|
||||
)
|
||||
};
|
||||
let state = |app: &App| app.call("workspace_state", json!({}))["files"].clone();
|
||||
let names = |app: &App| -> Vec<String> {
|
||||
let order = app.call("file_order", json!({}));
|
||||
order
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|id| file_stem_of(app, id.as_str().unwrap()))
|
||||
.collect()
|
||||
};
|
||||
|
||||
let a = open("a", true)["added"][0].as_str().unwrap().to_string();
|
||||
assert_eq!(state(&app)[&a]["preview"], true);
|
||||
// the next preview replaces it
|
||||
let b = open("b", true)["added"][0].as_str().unwrap().to_string();
|
||||
assert_eq!(names(&app), ["b"]);
|
||||
assert_eq!(state(&app)[&b]["preview"], true);
|
||||
// opened for good (a double click), it stays when another one is previewed
|
||||
open("b", false);
|
||||
assert_eq!(state(&app)[&b]["preview"], false);
|
||||
let c = open("c", true)["added"][0].as_str().unwrap().to_string();
|
||||
assert_eq!(names(&app), ["b", "c"]);
|
||||
// something done to the preview keeps it too
|
||||
app.call("style", json!({ "color": "#ff0000" }));
|
||||
assert_eq!(state(&app)[&c]["preview"], false);
|
||||
open("d", true);
|
||||
assert_eq!(names(&app), ["b", "c", "d"]);
|
||||
// several files at once are open for good
|
||||
app.call(
|
||||
"open_paths",
|
||||
json!({ "paths": [path("a"), path("d")], "preview": true }),
|
||||
);
|
||||
assert_eq!(names(&app), ["b", "c", "d", "a"]);
|
||||
let files = state(&app);
|
||||
assert!(
|
||||
files
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.values()
|
||||
.all(|file| file["preview"] == false)
|
||||
);
|
||||
std::fs::remove_dir_all(folder).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preview_goes_when_something_else_is_selected_unless_it_was_kept() {
|
||||
let app = App::new();
|
||||
let folder = folder_with(&["a", "b", "c"]);
|
||||
let open = |name: &str, preview: bool| {
|
||||
app.call(
|
||||
"open_paths",
|
||||
json!({ "paths": [folder.join(format!("{name}.gpx"))], "preview": preview }),
|
||||
)["added"][0]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
};
|
||||
let order = |app: &App| app.call("file_order", json!({})).as_array().unwrap().len();
|
||||
let a = open("a", false).unwrap();
|
||||
|
||||
// looked at, then something else is selected: it goes
|
||||
let b = open("b", true).unwrap();
|
||||
assert_eq!(order(&app), 2);
|
||||
app.call("select", json!({ "fileIds": [a], "mode": "replace" }));
|
||||
assert_eq!(order(&app), 1);
|
||||
|
||||
// nothing selected: it stays
|
||||
open("b", true);
|
||||
app.call("select", json!({ "fileIds": [], "mode": "replace" }));
|
||||
assert_eq!(order(&app), 2);
|
||||
|
||||
// kept (hidden, its menu opened...): it stays when something else is selected
|
||||
let c = open("c", true).unwrap();
|
||||
assert_eq!(order(&app), 2, "c replaces b");
|
||||
app.call("keep_files", json!({ "fileIds": [c] }));
|
||||
assert_eq!(
|
||||
app.call("workspace_state", json!({}))["files"][&c]["preview"],
|
||||
false
|
||||
);
|
||||
app.call("select", json!({ "fileIds": [a], "mode": "replace" }));
|
||||
assert_eq!(order(&app), 2);
|
||||
let _ = b;
|
||||
std::fs::remove_dir_all(folder).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "gpx.studio",
|
||||
"version": "0.1.0",
|
||||
"identifier": "gpx.studio",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm --prefix ../website run dev -- --port 4283",
|
||||
"devUrl": "http://localhost:4283",
|
||||
"beforeBuildCommand": "npm --prefix ../website run build:desktop",
|
||||
"frontendDist": "../website/build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "gpx.studio",
|
||||
"url": "app",
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"maximized": true,
|
||||
"dragDropEnabled": false
|
||||
}
|
||||
],
|
||||
"security": { "csp": null }
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"fileAssociations": [
|
||||
{
|
||||
"ext": ["gpx"],
|
||||
"name": "GPX file",
|
||||
"description": "GPS Exchange Format",
|
||||
"mimeType": "application/gpx+xml",
|
||||
"role": "Editor"
|
||||
}
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||