mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-19 09:22:12 +00:00
progress
This commit is contained in:
1 parent
005cc7b305
commit
b0a3c3a9ba
18 files changed
+1721
-714
No files matched your search
+314
-53
@@ -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 ¤t {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user