mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-17 08:42:28 +00:00
progress
This commit is contained in:
1 parent
2e1ca00afa
commit
1ba5436af3
29 files changed
+3348
-190
No files matched your search
+6
-1
@@ -2,7 +2,7 @@
|
||||
name = "gpx-studio-desktop"
|
||||
version = "0.1.0"
|
||||
description = "Desktop shell for gpx.studio"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
name = "gpx_studio_desktop_lib"
|
||||
@@ -20,4 +20,9 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["serde"] }
|
||||
gpx-engine = { path = "../gpx-rs" }
|
||||
pollster = "1.0.1"
|
||||
notify = "8.2.0"
|
||||
notify-debouncer-mini = "0.7.0"
|
||||
tauri-plugin-dialog = "2.8.2"
|
||||
tauri-plugin-single-instance = "2.5.3"
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! The files of the engine and the file system: picking files and folders, saving, and what the
|
||||
//! frontend shows of it (see `workspace`).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gpx_engine::api;
|
||||
use tauri::{AppHandle, Runtime, State};
|
||||
use tauri_plugin_dialog::{DialogExt, FilePath};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::workspace::{Unsaved, WorkspaceState};
|
||||
|
||||
fn paths(picked: Vec<FilePath>) -> Vec<PathBuf> {
|
||||
picked
|
||||
.into_iter()
|
||||
.filter_map(|path| path.into_path().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn workspace_state(state: State<AppState>) -> WorkspaceState {
|
||||
state.core(|core| core.workspace_state())
|
||||
}
|
||||
|
||||
/// Opens the files that the user picks. Nothing happens if they pick none.
|
||||
#[tauri::command(async)]
|
||||
pub fn open_files<R: Runtime>(app: AppHandle<R>, state: State<AppState>) -> api::Outcome {
|
||||
let picked = app
|
||||
.dialog()
|
||||
.file()
|
||||
.add_filter("GPX", &["gpx"])
|
||||
.blocking_pick_files();
|
||||
let Some(picked) = picked else {
|
||||
return api::Outcome::failed_none();
|
||||
};
|
||||
let files = paths(picked);
|
||||
state.core(move |core| {
|
||||
let outcome = core.workspace.open_files(&mut core.engine, &files, true);
|
||||
core.keep_workspace();
|
||||
core.persist();
|
||||
outcome
|
||||
})
|
||||
}
|
||||
|
||||
/// Opens the folder that the user picks: its GPX files are opened and it is watched. The folder
|
||||
/// that was open is closed, `unsaved` says what to do with its files that have unsaved edits.
|
||||
#[tauri::command(async)]
|
||||
pub fn open_folder<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
state: State<AppState>,
|
||||
unsaved: Option<Unsaved>,
|
||||
) -> Result<api::Outcome, String> {
|
||||
let Some(folder) = app
|
||||
.dialog()
|
||||
.file()
|
||||
.blocking_pick_folder()
|
||||
.and_then(|folder| folder.into_path().ok())
|
||||
else {
|
||||
return Ok(api::Outcome::failed_none());
|
||||
};
|
||||
state.core(move |core| {
|
||||
let outcome = core
|
||||
.workspace
|
||||
.open_folder(&mut core.engine, &folder, unsaved);
|
||||
core.keep_workspace();
|
||||
core.persist();
|
||||
core.watch();
|
||||
core.announce(None);
|
||||
outcome
|
||||
})
|
||||
}
|
||||
|
||||
/// Closes the folder: it is not watched anymore, and its files are closed. `unsaved` says what to
|
||||
/// do with the ones that have unsaved edits (they stay without it).
|
||||
#[tauri::command(async)]
|
||||
pub fn close_folder(
|
||||
state: State<AppState>,
|
||||
unsaved: Option<Unsaved>,
|
||||
) -> Result<api::Outcome, String> {
|
||||
state.core(move |core| {
|
||||
let outcome = core.workspace.close_folder(&mut core.engine, unsaved);
|
||||
core.keep_workspace();
|
||||
core.persist();
|
||||
core.watch();
|
||||
core.announce(None);
|
||||
outcome
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes files to the disk, replacing what is there. The ones that are not on the disk yet go in
|
||||
/// the open folder, or where the user says if there is none.
|
||||
#[tauri::command(async)]
|
||||
pub fn save_files<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
state: State<AppState>,
|
||||
file_ids: Vec<Uuid>,
|
||||
) -> Result<(), String> {
|
||||
let (ids, unlinked, folder) = {
|
||||
let ids = file_ids.clone();
|
||||
state.core(move |core| {
|
||||
(
|
||||
ids.clone(),
|
||||
core.workspace.unlinked(&core.engine, &ids),
|
||||
core.workspace.folder.clone(),
|
||||
)
|
||||
})
|
||||
};
|
||||
let mut chosen = BTreeMap::new();
|
||||
// with a folder open, a new file goes in it: nobody is asked
|
||||
let to_ask = if folder.is_some() { vec![] } else { unlinked };
|
||||
for (id, name) in to_ask {
|
||||
let mut dialog = app
|
||||
.dialog()
|
||||
.file()
|
||||
.add_filter("GPX", &["gpx"])
|
||||
.set_file_name(format!("{name}.gpx"));
|
||||
if let Some(folder) = &folder {
|
||||
dialog = dialog.set_directory(folder);
|
||||
}
|
||||
match dialog
|
||||
.blocking_save_file()
|
||||
.and_then(|path| path.into_path().ok())
|
||||
{
|
||||
Some(path) => {
|
||||
chosen.insert(id, path);
|
||||
}
|
||||
// cancelled: the others are not saved either
|
||||
None => return Ok(()),
|
||||
}
|
||||
}
|
||||
state.core(move |core| {
|
||||
let result = core.workspace.save(&core.engine, &ids, &chosen);
|
||||
core.keep_workspace();
|
||||
core.announce(None);
|
||||
result
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Editing the metadata, the style, the structure and the waypoints of the files, and the tools:
|
||||
//! reversing, cropping, reducing, timestamps, merging, cleaning...
|
||||
|
||||
use gpx_engine::{api, CleanType, MergeType, SplitType};
|
||||
use gpx_engine::{CleanType, MergeType, SplitType, api};
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Creating, loading, deleting, moving, copying and pasting files and their elements, and undoing.
|
||||
|
||||
use gpx_engine::api;
|
||||
use tauri::ipc::{InvokeBody, Request};
|
||||
use tauri::State;
|
||||
use tauri::ipc::{InvokeBody, Request};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! The commands that edit the files or change the selection return an `Outcome`.
|
||||
//! They are `async` (so they run off the main thread), the frontend sends them one after the other.
|
||||
|
||||
pub mod disk;
|
||||
pub mod edit;
|
||||
pub mod files;
|
||||
pub mod queries;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The files, their structure and statistics, the selection, the clipboard, and the coordinates of
|
||||
//! the trackpoints and waypoints (flat `[lng, lat, ...]` little-endian `f64` arrays).
|
||||
|
||||
use gpx_engine::{api, ExportOptions};
|
||||
use gpx_engine::{ExportOptions, api};
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Changing what is selected.
|
||||
|
||||
use gpx_engine::{api, SelectMode};
|
||||
use gpx_engine::{SelectMode, api};
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
||||
@@ -1,46 +1,48 @@
|
||||
//! Keeping the files and the settings.
|
||||
//!
|
||||
//! For now nothing is written: the files only live as long as the app, and so do the settings.
|
||||
//! The file system storage will go here.
|
||||
//! What the frontend knows of the storage, which is kept by the engine thread (see `state`).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use gpx_engine::api;
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
|
||||
use crate::state::AppState;
|
||||
use gpx_engine::api::Outcome;
|
||||
|
||||
/// What opening the storage found.
|
||||
#[derive(Serialize)]
|
||||
pub struct StorageOpened {
|
||||
settings: std::collections::BTreeMap<String, String>,
|
||||
outcome: Outcome,
|
||||
settings: BTreeMap<String, String>,
|
||||
/// All the files of the engine as added: the frontend starts with none.
|
||||
outcome: api::Outcome,
|
||||
}
|
||||
|
||||
/// The files were restored when the app started: the frontend is told about all of them.
|
||||
#[tauri::command(async)]
|
||||
pub fn open_storage(state: State<AppState>) -> StorageOpened {
|
||||
StorageOpened {
|
||||
settings: state.settings().clone(),
|
||||
outcome: Outcome::failed_none(),
|
||||
settings: state.storage().settings(),
|
||||
outcome: state.read(api::Outcome::everything),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves when what was changed has been written to the storage.
|
||||
/// Resolves when what was changed has been written to the storage, which is the case when a command
|
||||
/// returns.
|
||||
#[tauri::command(async)]
|
||||
pub fn flush_storage() {}
|
||||
|
||||
/// Reads the files again if they were written by someone else.
|
||||
/// There is no other writer.
|
||||
#[tauri::command(async)]
|
||||
pub fn sync_storage() -> Outcome {
|
||||
Outcome::failed_none()
|
||||
pub fn sync_storage() -> api::Outcome {
|
||||
api::Outcome::failed_none()
|
||||
}
|
||||
|
||||
/// Saves a setting, `value` being its JSON.
|
||||
#[tauri::command(async)]
|
||||
pub fn set_setting(state: State<AppState>, key: String, value: String) {
|
||||
state.settings().insert(key, value);
|
||||
state.put_setting(&key, &value);
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn delete_setting(state: State<AppState>, key: String) {
|
||||
state.settings().remove(&key);
|
||||
state.delete_setting(&key);
|
||||
}
|
||||
+173
-108
@@ -4,121 +4,186 @@
|
||||
mod bytes;
|
||||
mod commands;
|
||||
mod state;
|
||||
mod store;
|
||||
mod workspace;
|
||||
|
||||
use commands::*;
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
/// The GPX files among what the system gave to the app (the arguments of the command line, when a
|
||||
/// file is opened with it).
|
||||
fn gpx_paths(arguments: impl IntoIterator<Item = String>) -> Vec<std::path::PathBuf> {
|
||||
arguments
|
||||
.into_iter()
|
||||
.map(std::path::PathBuf::from)
|
||||
.filter(|path| {
|
||||
path.is_file()
|
||||
&& path
|
||||
.extension()
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("gpx"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
with_engine(tauri::Builder::default())
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running gpx.studio");
|
||||
let builder = tauri::Builder::default();
|
||||
// a single instance of the app: starting it again shows the one that runs (it has to be the
|
||||
// first plugin)
|
||||
#[cfg(desktop)]
|
||||
let builder = builder.plugin(tauri_plugin_single_instance::init(
|
||||
|app, arguments, _directory| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
// a file that was opened with the app, while it runs (Windows, Linux)
|
||||
if let Some(state) = app.try_state::<state::AppState>() {
|
||||
state.open_external(gpx_paths(arguments.into_iter().skip(1)));
|
||||
}
|
||||
},
|
||||
));
|
||||
register_commands(builder)
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.setup(|app| {
|
||||
let handle = app.handle().clone();
|
||||
let emit: state::Emit = std::sync::Arc::new(move |event, payload| {
|
||||
let _ = handle.emit(event, payload);
|
||||
});
|
||||
// what the engine holds is kept in the data directory of the app
|
||||
let directory = app.path().app_data_dir()?.join("store");
|
||||
let state = state::AppState::open(directory, emit)?;
|
||||
// a file that was opened with the app, which starts (Windows, Linux)
|
||||
state.open_external(gpx_paths(std::env::args().skip(1)));
|
||||
app.manage(state);
|
||||
Ok(())
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building gpx.studio")
|
||||
.run(|_app, _event| {
|
||||
// a file that was opened with the app (macOS)
|
||||
#[cfg(any(target_os = "macos", target_os = "ios"))]
|
||||
if let tauri::RunEvent::Opened { urls } = _event
|
||||
&& let Some(state) = _app.try_state::<state::AppState>()
|
||||
{
|
||||
let paths = urls.iter().filter_map(|url| url.to_file_path().ok());
|
||||
state.open_external(gpx_paths(
|
||||
paths.map(|path| path.to_string_lossy().into_owned()),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Gives the builder the engine and the commands that reach it.
|
||||
fn with_engine<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Builder<R> {
|
||||
builder
|
||||
.manage(state::AppState::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// selection
|
||||
selection::select,
|
||||
selection::select_all,
|
||||
selection::arrow_select,
|
||||
selection::select_tracks,
|
||||
selection::select_segments,
|
||||
selection::select_waypoint_group,
|
||||
selection::select_waypoints,
|
||||
// files
|
||||
files::new_file,
|
||||
files::load_files,
|
||||
files::duplicate,
|
||||
files::delete,
|
||||
files::delete_all,
|
||||
files::reorder,
|
||||
files::copy,
|
||||
files::cut,
|
||||
files::paste,
|
||||
files::move_elements,
|
||||
files::undo,
|
||||
files::redo,
|
||||
// edits and tools
|
||||
edit::metadata,
|
||||
edit::style,
|
||||
edit::new_track,
|
||||
edit::new_track_segment,
|
||||
edit::new_waypoint,
|
||||
edit::move_waypoint,
|
||||
edit::delete_waypoint,
|
||||
edit::update_waypoint,
|
||||
edit::reverse,
|
||||
edit::round_trip,
|
||||
edit::crop,
|
||||
edit::reduce,
|
||||
edit::change_timestamps,
|
||||
edit::create_artificial_timestamps,
|
||||
edit::merge,
|
||||
edit::extract,
|
||||
edit::elevation,
|
||||
edit::clean,
|
||||
edit::route,
|
||||
edit::insert_anchor,
|
||||
edit::change_loop_start,
|
||||
edit::split,
|
||||
// queries
|
||||
queries::file_order,
|
||||
queries::file_structure,
|
||||
queries::file_statistics,
|
||||
queries::selection,
|
||||
queries::clipboard,
|
||||
queries::can_undo,
|
||||
queries::can_redo,
|
||||
queries::can_paste,
|
||||
queries::waypoint,
|
||||
queries::trackpoint,
|
||||
queries::export_file,
|
||||
queries::exportable_data,
|
||||
queries::reduction_distances,
|
||||
queries::segment_coordinates,
|
||||
queries::waypoint_coordinates,
|
||||
// statistics
|
||||
statistics::total_distance,
|
||||
statistics::slope,
|
||||
statistics::slope_segment_slope,
|
||||
statistics::slope_segment_distance,
|
||||
statistics::lng,
|
||||
statistics::lat,
|
||||
statistics::ele,
|
||||
statistics::speed,
|
||||
statistics::hr,
|
||||
statistics::cad,
|
||||
statistics::atemp,
|
||||
statistics::power,
|
||||
statistics::timestamps,
|
||||
statistics::selection_statistics,
|
||||
statistics::slice_statistics,
|
||||
statistics::surface_starts,
|
||||
statistics::surface_values,
|
||||
statistics::highway_starts,
|
||||
statistics::highway_values,
|
||||
statistics::sac_scale_starts,
|
||||
statistics::sac_scale_values,
|
||||
statistics::mtb_scale_starts,
|
||||
statistics::mtb_scale_values,
|
||||
statistics::surfaces,
|
||||
statistics::highways,
|
||||
statistics::sac_scales,
|
||||
statistics::mtb_scales,
|
||||
statistics::anchor_indices,
|
||||
statistics::anchor_zooms,
|
||||
statistics::segment_starts,
|
||||
statistics::segment_ids,
|
||||
statistics::routing_revision,
|
||||
// storage
|
||||
storage::open_storage,
|
||||
storage::flush_storage,
|
||||
storage::sync_storage,
|
||||
storage::set_setting,
|
||||
storage::delete_setting,
|
||||
])
|
||||
/// Gives the builder the commands that reach the engine (which it has to be given as `AppState`).
|
||||
fn register_commands<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_tracks,
|
||||
selection::select_segments,
|
||||
selection::select_waypoint_group,
|
||||
selection::select_waypoints,
|
||||
// files
|
||||
files::new_file,
|
||||
files::load_files,
|
||||
files::duplicate,
|
||||
files::delete,
|
||||
files::delete_all,
|
||||
files::reorder,
|
||||
files::copy,
|
||||
files::cut,
|
||||
files::paste,
|
||||
files::move_elements,
|
||||
files::undo,
|
||||
files::redo,
|
||||
// edits and tools
|
||||
edit::metadata,
|
||||
edit::style,
|
||||
edit::new_track,
|
||||
edit::new_track_segment,
|
||||
edit::new_waypoint,
|
||||
edit::move_waypoint,
|
||||
edit::delete_waypoint,
|
||||
edit::update_waypoint,
|
||||
edit::reverse,
|
||||
edit::round_trip,
|
||||
edit::crop,
|
||||
edit::reduce,
|
||||
edit::change_timestamps,
|
||||
edit::create_artificial_timestamps,
|
||||
edit::merge,
|
||||
edit::extract,
|
||||
edit::elevation,
|
||||
edit::clean,
|
||||
edit::route,
|
||||
edit::insert_anchor,
|
||||
edit::change_loop_start,
|
||||
edit::split,
|
||||
// queries
|
||||
queries::file_order,
|
||||
queries::file_structure,
|
||||
queries::file_statistics,
|
||||
queries::selection,
|
||||
queries::clipboard,
|
||||
queries::can_undo,
|
||||
queries::can_redo,
|
||||
queries::can_paste,
|
||||
queries::waypoint,
|
||||
queries::trackpoint,
|
||||
queries::export_file,
|
||||
queries::exportable_data,
|
||||
queries::reduction_distances,
|
||||
queries::segment_coordinates,
|
||||
queries::waypoint_coordinates,
|
||||
// statistics
|
||||
statistics::total_distance,
|
||||
statistics::slope,
|
||||
statistics::slope_segment_slope,
|
||||
statistics::slope_segment_distance,
|
||||
statistics::lng,
|
||||
statistics::lat,
|
||||
statistics::ele,
|
||||
statistics::speed,
|
||||
statistics::hr,
|
||||
statistics::cad,
|
||||
statistics::atemp,
|
||||
statistics::power,
|
||||
statistics::timestamps,
|
||||
statistics::selection_statistics,
|
||||
statistics::slice_statistics,
|
||||
statistics::surface_starts,
|
||||
statistics::surface_values,
|
||||
statistics::highway_starts,
|
||||
statistics::highway_values,
|
||||
statistics::sac_scale_starts,
|
||||
statistics::sac_scale_values,
|
||||
statistics::mtb_scale_starts,
|
||||
statistics::mtb_scale_values,
|
||||
statistics::surfaces,
|
||||
statistics::highways,
|
||||
statistics::sac_scales,
|
||||
statistics::mtb_scales,
|
||||
statistics::anchor_indices,
|
||||
statistics::anchor_zooms,
|
||||
statistics::segment_starts,
|
||||
statistics::segment_ids,
|
||||
statistics::routing_revision,
|
||||
// storage
|
||||
storage::open_storage,
|
||||
storage::flush_storage,
|
||||
storage::sync_storage,
|
||||
storage::set_setting,
|
||||
storage::delete_setting,
|
||||
// the file system
|
||||
disk::workspace_state,
|
||||
disk::open_files,
|
||||
disk::open_folder,
|
||||
disk::close_folder,
|
||||
disk::save_files,
|
||||
])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+206
-34
@@ -1,48 +1,196 @@
|
||||
//! The state the commands share: the engine, and the settings of the frontend.
|
||||
//! The state the commands share: the engine, and what goes with it.
|
||||
//!
|
||||
//! The engine is not `Send` (it shares its data with `Rc`), so it lives on a thread of its own and
|
||||
//! the commands send it jobs: they are done one after the other, in the order they were sent.
|
||||
//! The engine is not `Send` (it shares its data with `Rc`), so it lives on a thread of its own with
|
||||
//! everything that works on it (its storage, the link between its files and the file system, the
|
||||
//! watcher of the open folder), and the commands send that thread jobs: they are done one after the
|
||||
//! other, in the order they were sent.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc::{self, Sender};
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use gpx_engine::Engine;
|
||||
use gpx_engine::{Engine, Persister, Storage, api};
|
||||
use notify::RecursiveMode;
|
||||
use notify_debouncer_mini::{DebounceEventResult, Debouncer, new_debouncer};
|
||||
use serde::Serialize;
|
||||
|
||||
type Job = Box<dyn FnOnce(&mut Engine) + Send>;
|
||||
use crate::store::FileStorage;
|
||||
use crate::workspace::{Workspace, WorkspaceState};
|
||||
|
||||
pub struct AppState {
|
||||
jobs: Mutex<Sender<Job>>,
|
||||
/// The settings of the frontend, as JSON strings by key.
|
||||
settings: Mutex<BTreeMap<String, String>>,
|
||||
/// Tells the frontend that something happened that it did not ask for: a name and a JSON value.
|
||||
pub type Emit = Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>;
|
||||
|
||||
type Job = Box<dyn FnOnce(&mut Core) + Send>;
|
||||
|
||||
/// Everything that lives on the thread of the engine.
|
||||
pub struct Core {
|
||||
pub engine: Engine,
|
||||
pub workspace: Workspace,
|
||||
storage: Arc<FileStorage>,
|
||||
persister: Persister,
|
||||
watcher: Option<Debouncer<notify::RecommendedWatcher>>,
|
||||
jobs: Sender<Job>,
|
||||
emit: Emit,
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
let (jobs, receiver) = mpsc::channel::<Job>();
|
||||
thread::Builder::new()
|
||||
.name("engine".into())
|
||||
.spawn(move || {
|
||||
let mut engine = Engine::default();
|
||||
while let Ok(job) = receiver.recv() {
|
||||
job(&mut engine);
|
||||
#[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,
|
||||
jobs,
|
||||
emit,
|
||||
};
|
||||
match pollster::block_on(core.persister.restore(&*core.storage)) {
|
||||
Ok(restored) => {
|
||||
core.engine.restore(restored);
|
||||
}
|
||||
// what is stored comes from a newer version, which is left alone
|
||||
Err(gpx_engine::StorageError::Newer) => {}
|
||||
Err(error) => eprintln!("the files could not be read: {error}"),
|
||||
}
|
||||
core.workspace.prune(&core.engine);
|
||||
core.workspace.verify(&core.engine);
|
||||
// what happened on the disk while the app was closed
|
||||
core.workspace.reconcile(&mut core.engine, None);
|
||||
core.workspace.write(core.storage.root());
|
||||
core.watch();
|
||||
core.persist();
|
||||
core
|
||||
}
|
||||
|
||||
/// Writes what changed in the engine.
|
||||
pub fn persist(&mut self) {
|
||||
match pollster::block_on(self.persister.save(&*self.storage, self.engine.snapshot())) {
|
||||
Ok(()) | Err(gpx_engine::CommitError::Failed(gpx_engine::StorageError::Newer)) => {}
|
||||
Err(error) => eprintln!("the files could not be kept: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens files that the system gave to the app (open with..., double click, the command line),
|
||||
/// which get selected. The frontend is told, if it already runs: when it starts it reads what
|
||||
/// the engine has.
|
||||
pub fn open_external(&mut self, paths: &[PathBuf]) {
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
let outcome = self.workspace.open_files(&mut self.engine, paths, true);
|
||||
self.keep_workspace();
|
||||
self.persist();
|
||||
(self.emit)("files-opened", to_json(&outcome));
|
||||
self.announce(None);
|
||||
}
|
||||
|
||||
pub fn keep_workspace(&self) {
|
||||
self.workspace.write(self.storage.root());
|
||||
}
|
||||
|
||||
pub fn workspace_state(&self) -> WorkspaceState {
|
||||
self.workspace.state(&self.engine)
|
||||
}
|
||||
|
||||
/// Tells the frontend what changed without it asking, and where the files are.
|
||||
pub fn announce(&self, outcome: Option<&api::Outcome>) {
|
||||
if let Some(outcome) = outcome {
|
||||
(self.emit)("engine-changed", to_json(outcome));
|
||||
}
|
||||
(self.emit)("workspace-changed", to_json(&self.workspace_state()));
|
||||
}
|
||||
|
||||
/// Watches the open folder (and only it).
|
||||
pub fn watch(&mut self) {
|
||||
self.watcher = None;
|
||||
let Some(folder) = self.workspace.folder.clone() else {
|
||||
return;
|
||||
};
|
||||
if !Path::new(&folder).is_dir() {
|
||||
return;
|
||||
}
|
||||
let jobs = self.jobs.clone();
|
||||
let debouncer = new_debouncer(
|
||||
Duration::from_millis(400),
|
||||
move |result: DebounceEventResult| {
|
||||
let Ok(events) = result else { return };
|
||||
let paths: Vec<PathBuf> = events.into_iter().map(|event| event.path).collect();
|
||||
let _ = jobs.send(Box::new(move |core: &mut Core| {
|
||||
core.on_disk_changed(&paths)
|
||||
}));
|
||||
},
|
||||
);
|
||||
match debouncer {
|
||||
Ok(mut debouncer) => {
|
||||
match debouncer.watcher().watch(&folder, RecursiveMode::Recursive) {
|
||||
Ok(()) => self.watcher = Some(debouncer),
|
||||
Err(error) => eprintln!("the folder cannot be watched: {error}"),
|
||||
}
|
||||
})
|
||||
.expect("the engine thread could not be started");
|
||||
Self {
|
||||
jobs: Mutex::new(jobs),
|
||||
settings: Mutex::default(),
|
||||
}
|
||||
Err(error) => eprintln!("the folder cannot be watched: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_disk_changed(&mut self, paths: &[PathBuf]) {
|
||||
let outcome = self.workspace.reconcile(&mut self.engine, Some(paths));
|
||||
if let Some(outcome) = outcome {
|
||||
self.workspace.write(self.storage.root());
|
||||
self.persist();
|
||||
self.announce(Some(&outcome));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn to_json<T: Serialize>(value: &T) -> serde_json::Value {
|
||||
serde_json::to_value(value).unwrap_or_default()
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Runs `f` on the engine and waits for its result.
|
||||
pub fn run<T: Send + 'static>(&self, f: impl FnOnce(&mut Engine) -> T + Send + 'static) -> T {
|
||||
/// Starts the engine, which restores what was kept in `directory`.
|
||||
pub fn open(directory: impl Into<PathBuf>, emit: Emit) -> Result<Self, String> {
|
||||
let storage = Arc::new(FileStorage::open(directory).map_err(|error| error.to_string())?);
|
||||
let (jobs, receiver) = mpsc::channel::<Job>();
|
||||
let (thread_storage, thread_jobs) = (storage.clone(), jobs.clone());
|
||||
thread::Builder::new()
|
||||
.name("engine".into())
|
||||
.spawn(move || {
|
||||
let mut core = Core::start(thread_storage, thread_jobs, emit);
|
||||
while let Ok(job) = receiver.recv() {
|
||||
job(&mut core);
|
||||
}
|
||||
})
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(Self {
|
||||
jobs: Arc::new(Mutex::new(jobs)),
|
||||
storage,
|
||||
})
|
||||
}
|
||||
|
||||
/// An engine that keeps its data in a directory of its own, and has nobody to tell.
|
||||
#[cfg(test)]
|
||||
pub fn temporary() -> Self {
|
||||
let directory = std::env::temp_dir().join(format!("gpx-studio-{}", uuid::Uuid::new_v4()));
|
||||
Self::open(directory, Arc::new(|_, _| {})).expect("the storage can be opened")
|
||||
}
|
||||
|
||||
/// Opens files that the system gave to the app, see `Core::open_external`.
|
||||
pub fn open_external(&self, paths: Vec<PathBuf>) {
|
||||
self.core(move |core| core.open_external(&paths));
|
||||
}
|
||||
|
||||
/// Runs `f` on the thread of the engine and waits for its result.
|
||||
pub fn core<T: Send + 'static>(&self, f: impl FnOnce(&mut Core) -> T + Send + 'static) -> T {
|
||||
let (reply, result) = mpsc::channel();
|
||||
let job: Job = Box::new(move |engine| {
|
||||
let _ = reply.send(f(engine));
|
||||
let job: Job = Box::new(move |core| {
|
||||
let _ = reply.send(f(core));
|
||||
});
|
||||
self.jobs
|
||||
.lock()
|
||||
@@ -52,12 +200,36 @@ impl AppState {
|
||||
result.recv().expect("the engine thread stopped")
|
||||
}
|
||||
|
||||
/// Reads from the engine.
|
||||
pub fn read<T: Send + 'static>(&self, f: impl FnOnce(&Engine) -> T + Send + 'static) -> T {
|
||||
self.run(move |engine| f(engine))
|
||||
/// Runs `f` on the engine, which can change: what changed is kept.
|
||||
pub fn run<T: Send + 'static>(&self, f: impl FnOnce(&mut Engine) -> T + Send + 'static) -> T {
|
||||
self.core(move |core| {
|
||||
let result = f(&mut core.engine);
|
||||
core.persist();
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
pub fn settings(&self) -> MutexGuard<'_, BTreeMap<String, String>> {
|
||||
self.settings.lock().unwrap()
|
||||
/// Reads from the engine.
|
||||
pub fn read<T: Send + 'static>(&self, f: impl FnOnce(&Engine) -> T + Send + 'static) -> T {
|
||||
self.core(move |core| f(&core.engine))
|
||||
}
|
||||
|
||||
pub fn storage(&self) -> &FileStorage {
|
||||
&self.storage
|
||||
}
|
||||
}
|
||||
|
||||
// the settings only are read and written by the commands, from any thread
|
||||
impl AppState {
|
||||
pub fn put_setting(&self, key: &str, value: &str) {
|
||||
if let Err(error) = pollster::block_on(self.storage.put_setting(key, value)) {
|
||||
eprintln!("the setting could not be kept: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_setting(&self, key: &str) {
|
||||
if let Err(error) = pollster::block_on(self.storage.delete_setting(key)) {
|
||||
eprintln!("the setting could not be kept: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! The storage of the engine on the file system: what the engine holds is kept between sessions,
|
||||
//! in a directory of the app (see `gpx_engine::Storage`).
|
||||
//!
|
||||
//! ```text
|
||||
//! head the revision of the last commit
|
||||
//! order the order of the files
|
||||
//! files/<id> the record of each file
|
||||
//! trackpoints/<id> the chunks of trackpoints
|
||||
//! waypoints/<id> the chunks of waypoints
|
||||
//! settings.json the settings of the frontend
|
||||
//! ```
|
||||
//!
|
||||
//! A commit writes the chunks, the records and the order first, then the head, then deletes what
|
||||
//! is not used anymore: the head moves once all of the new data is there, and nothing that the old
|
||||
//! data refers to is gone before that.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use gpx_engine::{
|
||||
Batch, ChunkKey, ChunkKind, CommitError, FileId, Revision, Storage, StorageError, StoredData,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct FileStorage {
|
||||
root: PathBuf,
|
||||
settings: Mutex<BTreeMap<String, String>>,
|
||||
}
|
||||
|
||||
fn failed(error: impl std::fmt::Display) -> StorageError {
|
||||
StorageError::Failed(error.to_string())
|
||||
}
|
||||
|
||||
/// Writes a file as a whole or not at all.
|
||||
pub fn write_atomically(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let temporary = path.with_extension("tmp");
|
||||
fs::write(&temporary, bytes)?;
|
||||
fs::rename(&temporary, path)
|
||||
}
|
||||
|
||||
fn read_optional(path: &Path) -> io::Result<Option<Vec<u8>>> {
|
||||
match fs::read(path) {
|
||||
Ok(bytes) => Ok(Some(bytes)),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(path: &Path) -> io::Result<()> {
|
||||
match fs::remove_file(path) {
|
||||
Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
impl FileStorage {
|
||||
/// Opens the storage in `root`, which is created if it does not exist.
|
||||
pub fn open(root: impl Into<PathBuf>) -> Result<Self, StorageError> {
|
||||
let root = root.into();
|
||||
fs::create_dir_all(&root).map_err(failed)?;
|
||||
let settings = match read_optional(&root.join("settings.json")).map_err(failed)? {
|
||||
Some(bytes) => serde_json::from_slice(&bytes).map_err(failed)?,
|
||||
None => BTreeMap::new(),
|
||||
};
|
||||
Ok(Self {
|
||||
root,
|
||||
settings: Mutex::new(settings),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
pub fn settings(&self) -> BTreeMap<String, String> {
|
||||
self.settings.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn chunk_path(&self, key: &ChunkKey) -> PathBuf {
|
||||
let directory = match key.kind {
|
||||
ChunkKind::Trackpoints => "trackpoints",
|
||||
ChunkKind::Waypoints => "waypoints",
|
||||
};
|
||||
self.root.join(directory).join(key.id.to_string())
|
||||
}
|
||||
|
||||
fn file_path(&self, id: &FileId) -> PathBuf {
|
||||
self.root.join("files").join(id.0.to_string())
|
||||
}
|
||||
|
||||
/// The ids and contents of the files of a directory, named by UUIDs.
|
||||
fn read_directory(&self, name: &str) -> Result<Vec<(Uuid, Vec<u8>)>, StorageError> {
|
||||
let directory = self.root.join(name);
|
||||
let entries = match fs::read_dir(&directory) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(vec![]),
|
||||
Err(error) => return Err(failed(error)),
|
||||
};
|
||||
let mut items = vec![];
|
||||
for entry in entries {
|
||||
let path = entry.map_err(failed)?.path();
|
||||
let Some(id) = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.and_then(|name| Uuid::parse_str(name).ok())
|
||||
else {
|
||||
// temporary files of a write that did not finish
|
||||
continue;
|
||||
};
|
||||
items.push((id, fs::read(&path).map_err(failed)?));
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
fn save_settings(&self, settings: &BTreeMap<String, String>) -> Result<(), StorageError> {
|
||||
let json = serde_json::to_vec(settings).map_err(failed)?;
|
||||
write_atomically(&self.root.join("settings.json"), &json).map_err(failed)
|
||||
}
|
||||
|
||||
fn read_head(&self) -> Result<Revision, StorageError> {
|
||||
match read_optional(&self.root.join("head")).map_err(failed)? {
|
||||
Some(bytes) => String::from_utf8_lossy(&bytes)
|
||||
.trim()
|
||||
.parse()
|
||||
.map(Revision)
|
||||
.map_err(failed),
|
||||
None => Ok(Revision::default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_batch(&self, batch: &Batch, head: Revision) -> io::Result<()> {
|
||||
for (key, bytes) in &batch.put_chunks {
|
||||
write_atomically(&self.chunk_path(key), bytes)?;
|
||||
}
|
||||
for (id, bytes) in &batch.put_files {
|
||||
write_atomically(&self.file_path(id), bytes)?;
|
||||
}
|
||||
if let Some(order) = &batch.order {
|
||||
write_atomically(&self.root.join("order"), order)?;
|
||||
}
|
||||
write_atomically(&self.root.join("head"), head.0.to_string().as_bytes())?;
|
||||
for id in &batch.delete_files {
|
||||
remove(&self.file_path(id))?;
|
||||
}
|
||||
for key in &batch.delete_chunks {
|
||||
remove(&self.chunk_path(key))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Storage for FileStorage {
|
||||
async fn load(&self) -> Result<StoredData, StorageError> {
|
||||
let chunks = |kind: ChunkKind, name: &str| -> Result<Vec<_>, StorageError> {
|
||||
Ok(self
|
||||
.read_directory(name)?
|
||||
.into_iter()
|
||||
.map(|(id, bytes)| (ChunkKey { kind, id }, bytes))
|
||||
.collect())
|
||||
};
|
||||
let mut all_chunks = chunks(ChunkKind::Trackpoints, "trackpoints")?;
|
||||
all_chunks.extend(chunks(ChunkKind::Waypoints, "waypoints")?);
|
||||
Ok(StoredData {
|
||||
files: self
|
||||
.read_directory("files")?
|
||||
.into_iter()
|
||||
.map(|(id, bytes)| (FileId(id), bytes))
|
||||
.collect(),
|
||||
chunks: all_chunks,
|
||||
order: read_optional(&self.root.join("order")).map_err(failed)?,
|
||||
head: self.read_head()?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn head(&self) -> Result<Revision, StorageError> {
|
||||
self.read_head()
|
||||
}
|
||||
|
||||
async fn commit(&self, batch: &Batch) -> Result<Revision, CommitError> {
|
||||
let head = self.read_head()?;
|
||||
if head != batch.expect_head {
|
||||
return Err(CommitError::Stale { head });
|
||||
}
|
||||
let next = head.next();
|
||||
self.write_batch(batch, next)
|
||||
.map_err(|error| CommitError::Failed(failed(error)))?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError> {
|
||||
Ok(self
|
||||
.settings
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError> {
|
||||
let mut settings = self.settings.lock().unwrap();
|
||||
settings.insert(key.to_owned(), value.to_owned());
|
||||
self.save_settings(&settings)
|
||||
}
|
||||
|
||||
async fn delete_setting(&self, key: &str) -> Result<(), StorageError> {
|
||||
let mut settings = self.settings.lock().unwrap();
|
||||
if settings.remove(key).is_some() {
|
||||
self.save_settings(&settings)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpx_engine::{Action, Command, Engine, Load, LoadFiles, Persister};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn directory() -> PathBuf {
|
||||
std::env::temp_dir().join(format!("gpx-studio-store-{}", Uuid::new_v4()))
|
||||
}
|
||||
|
||||
fn engine_with_a_file() -> Engine {
|
||||
let mut engine = Engine::default();
|
||||
let data = std::fs::read("../gpx-rs/data/simple.gpx").unwrap();
|
||||
engine
|
||||
.execute(Action::Edit(Command::LoadFiles(LoadFiles {
|
||||
files: vec![Load {
|
||||
data: &data,
|
||||
name: "simple",
|
||||
}],
|
||||
})))
|
||||
.unwrap();
|
||||
engine
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_the_files_and_the_settings_between_sessions() {
|
||||
let root = directory();
|
||||
let engine = engine_with_a_file();
|
||||
let order = engine.order().to_vec();
|
||||
|
||||
let storage = FileStorage::open(&root).unwrap();
|
||||
pollster::block_on(Persister::default().save(&storage, engine.snapshot())).unwrap();
|
||||
pollster::block_on(storage.put_setting("units", "\"metric\"")).unwrap();
|
||||
|
||||
// another session
|
||||
let storage = FileStorage::open(&root).unwrap();
|
||||
let restored = pollster::block_on(Persister::default().restore(&storage)).unwrap();
|
||||
let mut other = Engine::default();
|
||||
other.restore(restored);
|
||||
assert_eq!(other.order(), order);
|
||||
assert_eq!(
|
||||
other.file_statistics(&order[0]).unwrap().total_distance,
|
||||
engine.file_statistics(&order[0]).unwrap().total_distance
|
||||
);
|
||||
assert_eq!(storage.settings()["units"], "\"metric\"");
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_commit_that_is_not_based_on_the_head() {
|
||||
let root = directory();
|
||||
let storage = FileStorage::open(&root).unwrap();
|
||||
let batch = Batch {
|
||||
order: Some(vec![]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(pollster::block_on(storage.commit(&batch)), Ok(Revision(1)));
|
||||
assert_eq!(
|
||||
pollster::block_on(storage.commit(&batch)),
|
||||
Err(CommitError::Stale { head: Revision(1) })
|
||||
);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
+93
-9
@@ -1,18 +1,19 @@
|
||||
//! The commands, called the way the frontend calls them (through the IPC of Tauri), on the sample
|
||||
//! files of the engine.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY};
|
||||
use serde_json::{Value, json};
|
||||
use tauri::test::{INVOKE_KEY, get_ipc_response, mock_builder, mock_context, noop_assets};
|
||||
use tauri::webview::InvokeRequest;
|
||||
use tauri::{ipc::CallbackFn, ipc::InvokeBody, ipc::InvokeResponseBody, WebviewWindow};
|
||||
use tauri::{WebviewWindow, ipc::CallbackFn, ipc::InvokeBody, ipc::InvokeResponseBody};
|
||||
|
||||
struct App(WebviewWindow<tauri::test::MockRuntime>);
|
||||
|
||||
impl App {
|
||||
fn new() -> Self {
|
||||
let app = crate::with_engine(mock_builder())
|
||||
.build(mock_context(noop_assets()))
|
||||
.unwrap();
|
||||
let app =
|
||||
crate::register_commands(mock_builder().manage(crate::state::AppState::temporary()))
|
||||
.build(mock_context(noop_assets()))
|
||||
.unwrap();
|
||||
let window = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
|
||||
.build()
|
||||
.unwrap();
|
||||
@@ -242,9 +243,11 @@ fn waypoints_exports_and_routing_revisions() {
|
||||
json!({ "fileId": file, "time": true, "hr": true, "cad": true, "atemp": true, "power": true, "osm": true, "asRoute": false }),
|
||||
);
|
||||
assert_eq!(exported[0], 1);
|
||||
assert!(String::from_utf8(exported[1..].to_vec())
|
||||
.unwrap()
|
||||
.contains("<wpt"));
|
||||
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(
|
||||
@@ -274,3 +277,84 @@ fn keeps_settings_for_the_session() {
|
||||
app.call("delete_setting", json!({ "key": "units" }));
|
||||
assert_eq!(app.call("open_storage", json!({}))["settings"], json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_files_are_still_there_in_the_next_session() {
|
||||
use crate::state::AppState;
|
||||
use gpx_engine::api;
|
||||
|
||||
let directory =
|
||||
std::env::temp_dir().join(format!("gpx-studio-session-{}", uuid::Uuid::new_v4()));
|
||||
let data = std::fs::read("../gpx-rs/data/simple.gpx").unwrap();
|
||||
let first = AppState::open(&directory, std::sync::Arc::new(|_, _| {})).unwrap();
|
||||
let lengths = [data.len()];
|
||||
let names = ["simple".to_string()];
|
||||
first.run(move |e| api::load_files(e, &data, &lengths, &names));
|
||||
let order = first.read(api::file_order);
|
||||
|
||||
let second = AppState::open(&directory, std::sync::Arc::new(|_, _| {})).unwrap();
|
||||
assert_eq!(second.read(api::file_order), order);
|
||||
let outcome = second.read(api::Outcome::everything);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&outcome).unwrap()["added"],
|
||||
serde_json::json!(order)
|
||||
);
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_gpx_files_that_exist_are_taken_from_what_the_system_gives() {
|
||||
let directory = std::env::temp_dir().join(format!("gpx-studio-args-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
let track = directory.join("track.GPX");
|
||||
std::fs::copy("../gpx-rs/data/simple.gpx", &track).unwrap();
|
||||
let text = directory.join("notes.txt");
|
||||
std::fs::write(&text, "x").unwrap();
|
||||
let arguments = [
|
||||
"--flag".to_string(),
|
||||
track.to_string_lossy().into_owned(),
|
||||
text.to_string_lossy().into_owned(),
|
||||
directory.join("missing.gpx").to_string_lossy().into_owned(),
|
||||
];
|
||||
assert_eq!(crate::gpx_paths(arguments), [track]);
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_opened_with_the_app_is_opened_selected_and_announced() {
|
||||
use crate::state::AppState;
|
||||
use gpx_engine::api;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
let directory = std::env::temp_dir().join(format!("gpx-studio-open-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
let track = directory.join("track.gpx");
|
||||
std::fs::copy("../gpx-rs/data/simple.gpx", &track).unwrap();
|
||||
|
||||
let events = Arc::new(Mutex::new(vec![]));
|
||||
let sink = events.clone();
|
||||
let state = AppState::open(
|
||||
directory.join("store"),
|
||||
Arc::new(move |name, payload| sink.lock().unwrap().push((name.to_string(), payload))),
|
||||
)
|
||||
.unwrap();
|
||||
state.open_external(vec![track.clone()]);
|
||||
// opening it again is a selection, not another file
|
||||
state.open_external(vec![track]);
|
||||
|
||||
assert_eq!(state.read(api::file_order).len(), 1);
|
||||
assert!(matches!(
|
||||
state.read(api::selection),
|
||||
api::Selection::File { .. }
|
||||
));
|
||||
let events = events.lock().unwrap();
|
||||
let opened: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|(name, _)| name == "files-opened")
|
||||
.collect();
|
||||
assert_eq!(opened.len(), 2);
|
||||
assert_eq!(opened[0].1["added"].as_array().unwrap().len(), 1);
|
||||
assert!(opened[1].1["added"].as_array().unwrap().is_empty());
|
||||
assert!(events.iter().any(|(name, _)| name == "workspace-changed"));
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+11
-2
@@ -5,7 +5,7 @@
|
||||
"identifier": "gpx.studio",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm --prefix ../website run dev -- --port 4283",
|
||||
"devUrl": "http://localhost:4283/app",
|
||||
"devUrl": "http://localhost:4283",
|
||||
"beforeBuildCommand": "BASE_PATH='' npm --prefix ../website run build",
|
||||
"frontendDist": "../website/build"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
{
|
||||
"label": "main",
|
||||
"title": "gpx.studio",
|
||||
"url": "app.html",
|
||||
"url": "app",
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"minWidth": 800,
|
||||
@@ -26,6 +26,15 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"fileAssociations": [
|
||||
{
|
||||
"ext": ["gpx"],
|
||||
"name": "GPX file",
|
||||
"description": "GPS Exchange Format",
|
||||
"mimeType": "application/gpx+xml",
|
||||
"role": "Editor"
|
||||
}
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
Reference in new issue
Block a user