This commit is contained in:
vcoppe committed 2026-10-16 18:14:59 +02:00
1 parent 005cc7b305
commit b0a3c3a9ba
18 files changed
+1721 -714

No files matched your search

+314 -53
View File
@@ -1,16 +1,21 @@
//! The files of the engine and the file system: picking files and folders, saving, and what the
//! frontend shows of it (see `workspace`).
//! 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::PathBuf;
use gpx_engine::api;
use gpx_engine::{ExportOptions, 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::{Unsaved, WorkspaceState};
use crate::workspace::{Entry, Unsaved, WorkspaceState, free_path};
fn paths(picked: Vec<FilePath>) -> Vec<PathBuf> {
picked
@@ -35,17 +40,186 @@ pub fn open_files<R: Runtime>(app: AppHandle<R>, state: State<AppState>) -> api:
let Some(picked) = picked else {
return api::Outcome::failed_none();
};
let files = paths(picked);
open_paths(state, paths(picked))
}
/// Opens files that are on the disk, like the ones of the tree of the open folder. They get selected.
#[tauri::command(async)]
pub fn open_paths(state: State<AppState>, paths: Vec<PathBuf>) -> api::Outcome {
state.core(move |core| {
let outcome = core.workspace.open_files(&mut core.engine, &files, true);
let outcome = core.workspace.open_files(&mut core.engine, &paths, 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.
/// 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)] => {
let mut dialog = app
.dialog()
.file()
.add_filter("GPX", &["gpx"])
.set_file_name(format!("{name}.gpx"));
if let Some(start) = &start {
dialog = dialog.set_directory(start);
}
match dialog
.blocking_save_file()
.and_then(|path| path.into_path().ok())
{
Some(path) => {
chosen.insert(*id, path);
}
None => return Ok(false),
}
}
_ => {
let mut dialog = app.dialog().file();
if let Some(start) = &start {
dialog = dialog.set_directory(start);
}
match dialog
.blocking_pick_folder()
.and_then(|path| path.into_path().ok())
{
Some(path) => folder = Some(path),
None => return Ok(false),
}
}
}
state.core(move |core| {
let result = core
.workspace
.save(&core.engine, &ids, &chosen, folder.as_deref());
core.keep_workspace();
core.announce(None);
result.map(|()| true)
})
}
/// 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(&gpx_engine::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);
};
let mut dialog = app.dialog().file().add_filter("GPX", &["gpx"]);
match &current {
Some(path) => {
if let Some(file_name) = path.file_name() {
dialog = dialog.set_file_name(file_name.to_string_lossy());
}
if let Some(parent) = path.parent() {
dialog = dialog.set_directory(parent);
}
}
None => {
dialog = dialog.set_file_name(format!("{name}.gpx"));
if let Some(folder) = &folder {
dialog = dialog.set_directory(folder);
}
}
}
let Some(path) = dialog
.blocking_save_file()
.and_then(|path| path.into_path().ok())
else {
return Ok(false);
};
state.core(move |core| {
let result = core.workspace.save_as(&core.engine, file_id, &path);
core.keep_workspace();
core.announce(None);
result.map(|()| true)
})
}
/// 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.keep_workspace();
core.persist();
core.announce(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>,
@@ -60,80 +234,167 @@ pub fn open_folder<R: Runtime>(
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, unsaved);
.open_folder(&mut core.engine, &folder, discard);
core.keep_workspace();
core.persist();
core.watch();
core.announce(None);
outcome
Ok(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).
/// 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(
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);
let outcome = core
.workspace
.close_folder(&mut core.engine, unsaved.is_some());
core.keep_workspace();
core.persist();
core.watch();
core.announce(None);
outcome
Ok(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.
/// 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 save_files<R: Runtime>(
pub fn close_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(()),
}
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 result = core.workspace.save(&core.engine, &ids, &chosen);
let outcome = core
.workspace
.close_files(&mut core.engine, &file_ids, unsaved.is_some());
core.keep_workspace();
core.persist();
core.announce(None);
result
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,
as_route: 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,
as_route: arguments.as_route,
}
}
}
/// 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.read(move |engine| {
file_ids
.iter()
.filter_map(|id| {
let name = engine
.file_structure(&gpx_engine::FileId(*id))?
.name
.clone();
Some((name, api::export_file(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) = app
.dialog()
.file()
.add_filter("GPX", &["gpx"])
.set_file_name(format!("{name}.gpx"))
.blocking_save_file()
.and_then(|path| path.into_path().ok())
else {
return Ok(false);
};
write(path, bytes).map(|()| true)
}
_ => {
let Some(folder) = app
.dialog()
.file()
.blocking_pick_folder()
.and_then(|path| path.into_path().ok())
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)
}
}
}
+3 -3
View File
@@ -139,12 +139,12 @@ pub fn create_artificial_timestamps(
#[tauri::command(async)]
pub fn merge(state: State<AppState>, merge_type: MergeType, remove_gaps: bool) -> api::Outcome {
state.run(move |e| api::merge(e, merge_type, remove_gaps))
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.run(api::extract)
state.core(|core| core.creating(None, false, api::extract))
}
#[tauri::command(async)]
@@ -214,5 +214,5 @@ pub fn split(
at: u32,
split_type: SplitType,
) -> api::Outcome {
state.run(move |e| api::split(e, revision, at, split_type))
state.core(move |core| core.creating(None, false, |e| api::split(e, revision, at, split_type)))
}
+6 -2
View File
@@ -1,5 +1,7 @@
//! 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};
@@ -14,8 +16,10 @@ pub fn new_file(
lng: Option<f64>,
lat: Option<f64>,
ele: Option<f64>,
folder: Option<PathBuf>,
) -> api::Outcome {
state.run(move |e| api::new_file(e, &name, lng, lat, ele))
// 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)))
}
/// The files are in the body, one after the other, the headers `lengths` and `names` (JSON,
@@ -61,7 +65,7 @@ fn percent_decode(value: &str) -> Option<String> {
#[tauri::command(async)]
pub fn duplicate(state: State<AppState>) -> api::Outcome {
state.run(api::duplicate)
state.core(|core| core.creating(None, false, api::duplicate))
}
#[tauri::command(async)]
+6
View File
@@ -184,9 +184,15 @@ fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Bu
// the file system
disk::workspace_state,
disk::open_files,
disk::open_paths,
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,
])
}
+75 -14
View File
@@ -5,7 +5,7 @@
//! 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::{Path, PathBuf};
use std::path::PathBuf;
use std::sync::mpsc::{self, Sender};
use std::sync::{Arc, Mutex};
use std::thread;
@@ -32,6 +32,8 @@ pub struct Core {
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,
}
@@ -50,6 +52,7 @@ impl Core {
persister: Persister::default(),
storage,
watcher: None,
watching: vec![],
jobs,
emit,
};
@@ -93,8 +96,10 @@ impl Core {
self.announce(None);
}
pub fn keep_workspace(&self) {
/// 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 {
@@ -109,13 +114,16 @@ impl Core {
(self.emit)("workspace-changed", to_json(&self.workspace_state()));
}
/// Watches the open folder (and only it).
/// 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) {
self.watcher = None;
let Some(folder) = self.workspace.folder.clone() else {
let wanted = self.workspace.watched();
if wanted == self.watching && (self.watcher.is_some() || wanted.is_empty()) {
return;
};
if !Path::new(&folder).is_dir() {
}
self.watcher = None;
self.watching = wanted.clone();
if wanted.is_empty() {
return;
}
let jobs = self.jobs.clone();
@@ -131,23 +139,76 @@ impl Core {
);
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}"),
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 folder cannot be watched: {error}"),
Err(error) => eprintln!("the files 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());
// 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.keep_workspace();
self.persist();
self.announce(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 outcome = f(&mut self.engine);
if let Some(dir) = dir {
self.workspace.inherit(outcome.added(), &dir);
}
self.keep_workspace();
self.persist();
outcome
}
}
fn to_json<T: Serialize>(value: &T) -> serde_json::Value {
+92
View File
@@ -363,3 +363,95 @@ fn a_file_opened_with_the_app_is_opened_selected_and_announced() {
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, and is not linked to a place yet
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"], true);
assert!(state["files"][&new_id]["folder"].is_string());
assert!(state["files"][&new_id].get("path").is_none());
// 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("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();
let name = |state: &crate::state::AppState| {
state.core(move |core| {
core.engine
.file_structure(&gpx_engine::FileId(id))
.map(|structure| structure.name.clone())
})
};
let before = name(&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!(name(&state), before);
std::fs::remove_dir_all(folder).unwrap();
std::fs::remove_dir_all(directory).unwrap();
}
+774 -540
View File
File diff suppressed because it is too large. Load diff