diff --git a/gpx-rs/src/api/dto.rs b/gpx-rs/src/api/dto.rs index 6de45e207..6e6ca259f 100644 --- a/gpx-rs/src/api/dto.rs +++ b/gpx-rs/src/api/dto.rs @@ -68,6 +68,11 @@ impl Outcome { Self::new(&engine::Outcome::default(), None) } + /// The ids of the files that the call added. + pub fn added(&self) -> &[String] { + &self.added + } + /// Whether the call did nothing and says why. pub fn has_error(&self) -> bool { self.error.is_some() diff --git a/tauri/src/commands/disk.rs b/tauri/src/commands/disk.rs index e014b4e74..971049e1f 100644 --- a/tauri/src/commands/disk.rs +++ b/tauri/src/commands/disk.rs @@ -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) -> Vec { picked @@ -35,17 +40,186 @@ pub fn open_files(app: AppHandle, state: State) -> 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, paths: Vec) -> 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( + app: &AppHandle, + state: &AppState, + ids: Vec, +) -> Result { + 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( + app: AppHandle, + state: State, + file_ids: Vec, +) -> Result { + 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( + app: AppHandle, + state: State, + file_id: Uuid, +) -> Result { + 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, file_ids: Vec) -> Result { + 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( + app: &AppHandle, + state: &AppState, + ids: Vec, + unsaved: Option, +) -> Result { + 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( app: AppHandle, @@ -60,80 +234,167 @@ pub fn open_folder( 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( + app: AppHandle, state: State, unsaved: Option, ) -> Result { + 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( +pub fn close_files( app: AppHandle, state: State, file_ids: Vec, -) -> 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, +) -> Result { + 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, path: Option) -> Result, 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 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( + app: AppHandle, + state: State, + file_ids: Vec, + options: ExportArguments, +) -> Result { + let options = ExportOptions::from(options); + let files: Vec<(String, Vec)> = 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 = 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) + } + } +} diff --git a/tauri/src/commands/edit.rs b/tauri/src/commands/edit.rs index b91ee1eb1..d15cc13ab 100644 --- a/tauri/src/commands/edit.rs +++ b/tauri/src/commands/edit.rs @@ -139,12 +139,12 @@ pub fn create_artificial_timestamps( #[tauri::command(async)] pub fn merge(state: State, 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) -> 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))) } diff --git a/tauri/src/commands/files.rs b/tauri/src/commands/files.rs index e3d7c2d53..d7f210942 100644 --- a/tauri/src/commands/files.rs +++ b/tauri/src/commands/files.rs @@ -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, lat: Option, ele: Option, + folder: Option, ) -> 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 { #[tauri::command(async)] pub fn duplicate(state: State) -> api::Outcome { - state.run(api::duplicate) + state.core(|core| core.creating(None, false, api::duplicate)) } #[tauri::command(async)] diff --git a/tauri/src/lib.rs b/tauri/src/lib.rs index 6a55ac1eb..6951efa22 100644 --- a/tauri/src/lib.rs +++ b/tauri/src/lib.rs @@ -184,9 +184,15 @@ fn register_commands(builder: tauri::Builder) -> 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, ]) } diff --git a/tauri/src/state.rs b/tauri/src/state.rs index 681f606f2..1e0aa91fb 100644 --- a/tauri/src/state.rs +++ b/tauri/src/state.rs @@ -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, persister: Persister, watcher: Option>, + /// What the watcher watches, see `Workspace::watched`. + watching: Vec<(PathBuf, bool)>, jobs: Sender, 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, + default_folder: bool, + f: impl FnOnce(&mut Engine) -> api::Outcome, + ) -> api::Outcome { + let selected: Vec = 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(value: &T) -> serde_json::Value { diff --git a/tauri/src/tests.rs b/tauri/src/tests.rs index b144bc1a8..74181678b 100644 --- a/tauri/src/tests.rs +++ b/tauri/src/tests.rs @@ -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(); +} diff --git a/tauri/src/workspace.rs b/tauri/src/workspace.rs index 1e28bc145..caeae4332 100644 --- a/tauri/src/workspace.rs +++ b/tauri/src/workspace.rs @@ -1,18 +1,29 @@ //! The files of the engine and the files of the file system. //! -//! A file of the engine can be linked to a file on the disk (it was opened from it, or saved to -//! it). The engine does not know about it: the links are kept here, with what the two looked like -//! when they last were the same, which tells +//! A file of the engine has a place, which is one of //! -//! - whether the file was edited since it was saved (`dirty`), as its structure is not the one it -//! had then (the structure changes with every edit, and an undo brings it back); -//! - whether the file on the disk changed since (its bytes are not the ones that were read or -//! written then), which is the case when something else wrote it. +//! - a file on the disk it is linked to (`Link`): it was opened from it, or saved to it; +//! - a folder (`Workspace::dirs`): it comes from a file of that folder (or was created in it) and +//! is written in it, under a name of its own, the first time it is saved; +//! - nothing: it was created when no folder was open, and the user chooses where it goes when it is +//! first saved. //! -//! At most one folder is open. It is watched, and the files that are saved without a place are put -//! in it. When a folder is open it is watched: the files that appear, change or disappear on the disk are -//! brought into the engine (see `reconcile`), unless the engine holds edits of that file, which -//! are not overwritten (the file is then flagged as `conflict`). +//! The engine does not know about it: the places are kept here. A link also has what the two files +//! looked like when they last were the same, which tells +//! +//! - whether the file was edited since it was saved (its structure is not the one it had then: it +//! changes with every edit, and an undo brings it back); +//! - whether the file on the disk changed since (`replaced`: its bytes are not the ones that were +//! read or written then), which is the case when something else wrote it, or is gone (`missing`). +//! +//! A file is shown `dirty` in all those cases, but only `replaced` asks the user before it is +//! saved, as it would overwrite something that was not made here. +//! +//! At most one folder is open. It is watched, and the folder tree is read from the disk when the +//! user opens branches of it (`list_folder`): its files are not opened. The files that are open and +//! are linked are checked again when something changes on the disk (see `reconcile`): a file without +//! edits is read again, a file with edits is flagged `replaced` and the user chooses when saving it +//! whether to overwrite the disk or to read it again (`reload`). use std::collections::BTreeMap; use std::fs; @@ -44,7 +55,7 @@ pub fn signature(engine: &Engine, id: Uuid) -> Option { Some(hash(&serde_json::to_vec(&structure).ok()?)) } -/// What to do with the files of a folder that have unsaved edits when the folder is closed. +/// What to do with the files that have unsaved edits when they are closed. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Unsaved { @@ -66,9 +77,12 @@ pub struct Link { /// Zero when it is not known. #[serde(default)] pub export_hash: u64, - /// The file on the disk changed while the engine held edits of it. + /// The file on the disk is not the one that was read or written: something else replaced it. + #[serde(default, alias = "conflict")] + pub replaced: bool, + /// The file on the disk is gone. #[serde(default)] - pub conflict: bool, + pub missing: bool, } #[derive(Debug, Default, Serialize, Deserialize)] @@ -76,19 +90,28 @@ pub struct Workspace { /// The folder that is open (and watched). pub folder: Option, pub links: BTreeMap, + /// The folder where the files that are not on the disk yet are written. + #[serde(default)] + pub dirs: BTreeMap, } /// What the frontend shows of a file. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct FileStatus { - /// Where the file is on the disk, absent for a file that only is in the engine. + /// Where the file is on the disk, absent for a file that is not on the disk yet. #[serde(skip_serializing_if = "Option::is_none")] pub path: Option, - /// The file has edits that are not in the file on the disk (or has no file on the disk). + /// The folder where the file will be written, for a file that is not on the disk yet. + #[serde(skip_serializing_if = "Option::is_none")] + pub folder: Option, + /// The file is not the one on the disk: it has edits, is not on the disk, or the file on the + /// disk is not what was read (or is gone). pub dirty: bool, - /// The file on the disk was changed by something else while this one had edits. - pub conflict: bool, + /// The file on the disk was replaced by something else: saving overwrites it. + pub replaced: bool, + /// The file on the disk is gone. + pub missing: bool, } #[derive(Debug, Serialize)] @@ -99,13 +122,22 @@ pub struct WorkspaceState { pub files: BTreeMap, } +/// A folder or a GPX file of the open folder, see `Workspace::list_folder`. +#[derive(Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Entry { + pub name: String, + pub path: String, + pub is_dir: bool, +} + fn is_gpx(path: &Path) -> bool { path.extension() .and_then(|extension| extension.to_str()) .is_some_and(|extension| extension.eq_ignore_ascii_case("gpx")) } -fn canonical(path: &Path) -> PathBuf { +pub fn canonical(path: &Path) -> PathBuf { fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } @@ -147,34 +179,18 @@ fn stem(path: &Path) -> String { .unwrap_or_default() } -/// The GPX files of a folder and of the folders in it. -pub fn scan(folder: &Path) -> Vec { - const MAX_DEPTH: usize = 8; - const MAX_FILES: usize = 5000; - let mut found = vec![]; - let mut pending = vec![(folder.to_path_buf(), 0)]; - while let Some((directory, depth)) = pending.pop() { - let Ok(entries) = fs::read_dir(&directory) else { - continue; - }; - for entry in entries.flatten() { - let path = entry.path(); - let Ok(kind) = entry.file_type() else { - continue; - }; - let hidden = entry.file_name().to_string_lossy().starts_with('.'); - if hidden { - continue; - } - if kind.is_dir() && depth < MAX_DEPTH { - pending.push((path, depth + 1)); - } else if kind.is_file() && is_gpx(&path) && found.len() < MAX_FILES { - found.push(path); - } - } +/// Where to put a file named `name` in `folder`: not the place of something that is there, or that +/// `taken` says is. +pub fn free_path(folder: &Path, name: &str, taken: impl Fn(&Path) -> bool) -> PathBuf { + let name = file_name(name); + let taken = |path: &Path| path.exists() || taken(path); + let mut path = folder.join(format!("{name}.gpx")); + let mut number = 2; + while taken(&path) { + path = folder.join(format!("{name} ({number}).gpx")); + number += 1; } - found.sort(); - found + path } impl Workspace { @@ -203,21 +219,22 @@ impl Workspace { .find_map(|(id, link)| (link.path == path).then_some(*id)) } - /// Whether the file has edits that the file on the disk does not have. + /// Whether the file is not the one on the disk: it has edits, or is not on the disk, or the + /// file on the disk is not the one that was read (or is gone). pub fn is_dirty(&self, engine: &Engine, id: Uuid) -> bool { match self.links.get(&id) { - Some(link) => signature(engine, id) != Some(link.signature), + Some(link) => { + link.replaced || link.missing || signature(engine, id) != Some(link.signature) + } None => true, } } /// The status of each file of the engine. pub fn state(&self, engine: &Engine) -> WorkspaceState { + let text = |path: &Path| path.to_string_lossy().into_owned(); WorkspaceState { - folder: self - .folder - .as_ref() - .map(|folder| folder.to_string_lossy().into_owned()), + folder: self.folder.as_deref().map(text), files: engine .order() .iter() @@ -226,9 +243,15 @@ impl Workspace { ( id.to_string(), FileStatus { - path: link.map(|link| link.path.to_string_lossy().into_owned()), + path: link.map(|link| text(&link.path)), + folder: self + .dirs + .get(id) + .filter(|_| link.is_none()) + .map(|dir| text(dir)), dirty: self.is_dirty(engine, *id), - conflict: link.is_some_and(|link| link.conflict), + replaced: link.is_some_and(|link| link.replaced), + missing: link.is_some_and(|link| link.missing), }, ) }) @@ -259,10 +282,11 @@ impl Workspace { } } - /// Forgets the links of the files that are not in the engine anymore. + /// Forgets the places of the files that are not in the engine anymore. pub fn prune(&mut self, engine: &Engine) { - self.links - .retain(|id, _| engine.file_structure(&FileId(*id)).is_some()); + let open = |id: &Uuid| engine.file_structure(&FileId(*id)).is_some(); + self.links.retain(|id, _| open(id)); + self.dirs.retain(|id, _| open(id)); } /// Opens files from the disk: the ones that are not open yet are added, and `select` selects @@ -296,17 +320,8 @@ impl Workspace { .collect(); outcome = api::sync_files(engine, &files, &[]); for (id, bytes, _, path) in &new { - if let Some(signature) = signature(engine, *id) { - self.links.insert( - *id, - Link { - path: path.clone(), - signature, - disk_hash: hash(bytes), - export_hash: export_hash(engine, *id).unwrap_or_default(), - conflict: false, - }, - ); + if let Some(link) = self.new_link(engine, *id, path.clone(), bytes) { + self.links.insert(*id, link); known.push(*id); } } @@ -324,282 +339,324 @@ impl Workspace { outcome } - /// Opens a folder: the GPX files in it are opened, and kept in line with it from then on (see - /// `reconcile`). The folder that was open is closed (see `close_folder`, which `unsaved` is - /// for), and nothing changes if that fails. + /// The link of a file that is the one on the disk, which has the given bytes. `None` if the + /// engine does not have the file. + fn new_link(&self, engine: &Engine, id: Uuid, path: PathBuf, bytes: &[u8]) -> Option { + Some(Link { + path, + signature: signature(engine, id)?, + disk_hash: hash(bytes), + export_hash: export_hash(engine, id).unwrap_or_default(), + replaced: false, + missing: false, + }) + } + + /// Opens a folder, which is watched. Its files are not opened: the user picks them in the tree + /// (`list_folder`). The folder that was open is closed with its files: see `close_folder`, + /// which `discard` is for. pub fn open_folder( &mut self, engine: &mut Engine, folder: &Path, - unsaved: Option, - ) -> Result { + discard: bool, + ) -> api::Outcome { let folder = canonical(folder); let closed = if self.folder.as_ref().is_some_and(|open| *open != folder) { - self.close_folder(engine, unsaved)? + self.close_folder(engine, discard) } else { api::Outcome::failed_none() }; - self.folder = Some(folder.clone()); - Ok(closed.merge(self.open_files(engine, &scan(&folder), false))) + self.folder = Some(folder); + closed } - /// The files that belong to the open folder: the ones in it, and the new ones, which are - /// saved in it. - fn belonging_to_folder(&self, engine: &Engine) -> Vec { - let Some(folder) = &self.folder else { - return vec![]; - }; - engine - .order() - .iter() - .map(|FileId(id)| *id) - .filter(|id| match self.links.get(id) { - Some(link) => link.path.starts_with(folder), - None => true, - }) - .collect() + /// What to watch on the disk: the open folder and the folders in it, and the folder of each + /// linked file that is outside of it (only that folder: the file is replaced when it is saved, + /// which a watch on the file itself would not follow). The `bool` says whether to go into the + /// folders. Only folders that exist. + pub fn watched(&self) -> Vec<(PathBuf, bool)> { + let mut watched: Vec<(PathBuf, bool)> = vec![]; + if let Some(folder) = self.folder.as_ref().filter(|folder| folder.is_dir()) { + watched.push((folder.clone(), true)); + } + let mut parents: Vec = self + .links + .values() + .filter(|link| !self.in_folder(&link.path)) + .filter_map(|link| link.path.parent().map(Path::to_path_buf)) + .filter(|parent| parent.is_dir()) + .collect(); + parents.sort(); + parents.dedup(); + watched.extend(parents.into_iter().map(|parent| (parent, false))); + watched } - /// The files of the open folder that have unsaved edits, the new files included. - pub fn unsaved_in_folder(&self, engine: &Engine) -> Vec { - self.belonging_to_folder(engine) - .into_iter() + /// Whether the path is in the open folder (or is it). + fn in_folder(&self, path: &Path) -> bool { + self.folder + .as_ref() + .is_some_and(|folder| path.starts_with(folder)) + } + + /// The files among `ids` that are not the ones on the disk. + pub fn unsaved(&self, engine: &Engine, ids: &[Uuid]) -> Vec { + ids.iter() + .copied() + .filter(|id| engine.file_structure(&FileId(*id)).is_some()) .filter(|id| self.is_dirty(engine, *id)) .collect() } - /// Closes the open folder: its files are closed. The ones that have unsaved edits, and the new - /// files that were not saved (they are saved in the folder), are saved first or discarded, as - /// `unsaved` says; without it they stay open. - /// - /// Nothing is closed if saving fails. - pub fn close_folder( + /// Every file of the engine. + pub fn all(&self, engine: &Engine) -> Vec { + engine.order().iter().map(|FileId(id)| *id).collect() + } + + /// Closes files, which are not in the engine anymore. The ones that are not the files on the + /// disk stay open, unless `discard` says their edits are lost. + pub fn close_files( &mut self, engine: &mut Engine, - unsaved: Option, - ) -> Result { - if self.folder.is_none() { - return Ok(api::Outcome::failed_none()); - } - let unsaved_ids = self.unsaved_in_folder(engine); - if unsaved == Some(Unsaved::Save) { - self.save(engine, &unsaved_ids, &BTreeMap::new())?; - } - let closing: Vec = self - .belonging_to_folder(engine) - .into_iter() - .filter(|id| unsaved.is_some() || !unsaved_ids.contains(id)) + ids: &[Uuid], + discard: bool, + ) -> api::Outcome { + let closing: Vec = ids + .iter() + .copied() + .filter(|id| engine.file_structure(&FileId(*id)).is_some()) + .filter(|id| discard || !self.is_dirty(engine, *id)) .collect(); - self.folder = None; + if closing.is_empty() { + return api::Outcome::failed_none(); + } for id in &closing { self.links.remove(id); + self.dirs.remove(id); } - if closing.is_empty() { - return Ok(api::Outcome::failed_none()); - } - Ok(api::sync_files(engine, &[], &closing)) + api::sync_files(engine, &[], &closing) } - /// Where to put a file that is not on the disk, in the open folder: named like the file, and - /// not the name of a file that is there. - fn free_path(&self, folder: &Path, name: &str) -> PathBuf { - let name = file_name(name); - let taken = - |path: &Path| path.exists() || self.links.values().any(|link| link.path == path); - let mut path = folder.join(format!("{name}.gpx")); - let mut number = 2; - while taken(&path) { - path = folder.join(format!("{name} ({number}).gpx")); - number += 1; - } - path + /// Closes the open folder, and everything that is open with it: the workspace starts over. + /// What is not the file on the disk stays open unless `discard` says its edits are lost, and + /// the folder is closed anyway. + pub fn close_folder(&mut self, engine: &mut Engine, discard: bool) -> api::Outcome { + let ids = self.all(engine); + let outcome = self.close_files(engine, &ids, discard); + self.folder = None; + outcome } - /// Brings what changed on the disk into the engine: for the given paths (or everything that is - /// in the folder, and everything that is linked, when there are none). + /// Where a new file created from the given files goes: the folder of the first of them that + /// has a place in the open folder, `None` if there is none. A file that is not in the open folder + /// has nothing to hand down. + pub fn source_dir(&self, ids: &[Uuid]) -> Option { + ids.iter().find_map(|id| { + let dir = match self.links.get(id) { + Some(link) => link.path.parent()?.to_path_buf(), + None => self.dirs.get(id)?.clone(), + }; + self.in_folder(&dir).then_some(dir) + }) + } + + /// The new files among `ids` are written in `dir` when they are saved (unless they have a + /// place already). + pub fn inherit(&mut self, ids: &[String], dir: &Path) { + for id in ids.iter().filter_map(|id| Uuid::parse_str(id).ok()) { + if !self.links.contains_key(&id) { + self.dirs.insert(id, dir.to_path_buf()); + } + } + } + + /// Brings what changed on the disk into the open files, for the given paths (or for all of the + /// linked ones when there are none): /// - /// - A file that is not open is added. - /// - A file that changed is read again, unless it has edits that the file on the disk does not - /// have: it is flagged as a conflict, and what is in the engine stays. - /// - A file that is gone is deleted, unless it has edits: it becomes a file that only is in - /// the engine. + /// - a file that has no edits and is not what is on the disk anymore is read again, one undo + /// step: it stays the same file; + /// - a file that has edits is `replaced`, nothing is read: saving it overwrites the file on the + /// disk, which the user is asked first; + /// - a file that is gone is `missing`, it stays open; + /// - the flags are cleared when the file on the disk is the one that was read or written. /// - /// Gives what that changed in the engine, `None` when nothing. + /// Says what changed in the engine, `None` when nothing changed, not even a flag. pub fn reconcile( &mut self, engine: &mut Engine, changed: Option<&[PathBuf]>, ) -> Option { - let folder = self.folder.clone(); - let candidates: Vec = match changed { - None => { - let mut paths = folder.as_deref().map(scan).unwrap_or_default(); - paths.extend(self.links.values().map(|link| link.path.clone())); - paths - } - Some(changed) => { - let mut paths: Vec = changed - .iter() - .filter(|path| { - folder - .as_ref() - .is_some_and(|folder| path.starts_with(folder)) - }) - .cloned() - .collect(); - // a folder that was removed or moved takes its files with it - paths.extend( - self.links - .values() - .map(|link| link.path.clone()) - .filter(|path| changed.iter().any(|other| path.starts_with(other))), - ); - paths - } - }; - let mut candidates: Vec = candidates - .into_iter() - .filter(|path| is_gpx(path) || self.id_of(path).is_some()) - .collect(); - candidates.sort(); - candidates.dedup(); - - // the files to bring in, with what to record once they are - let mut files: Vec<(Uuid, Vec, String, PathBuf)> = vec![]; - let mut remove = vec![]; let mut flagged = false; - for path in candidates { - let id = self.id_of(&path); - match fs::read(&path) { - Ok(bytes) => { - let disk_hash = hash(&bytes); - match id { - Some(id) => { - let dirty = self.is_dirty(engine, id); - let link = self.links.get_mut(&id).expect("the link exists"); - if disk_hash == link.disk_hash { - continue; - } - if dirty { - link.disk_hash = disk_hash; - link.conflict = true; - flagged = true; - } else { - files.push((id, bytes, stem(&path), path)); - } - } - None => files.push((Uuid::new_v4(), bytes, stem(&path), path)), - } - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - if let Some(id) = id { - if !self.is_dirty(engine, id) { - remove.push(id); - } - self.links.remove(&id); - flagged = true; - } - } + let mut to_reload = vec![]; + let ids: Vec = self.links.keys().copied().collect(); + for id in ids { + let edited = signature(engine, id) != Some(self.links[&id].signature); + let link = self.links.get_mut(&id).expect("the link exists"); + if let Some(changed) = changed + && !changed + .iter() + .any(|path| link.path.starts_with(path) || path.starts_with(&link.path)) + { + continue; + } + let (differs, missing) = match fs::read(&link.path) { + Ok(bytes) => (hash(&bytes) != link.disk_hash, false), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => (false, true), // a folder, or a file that cannot be read now: it is looked at again when it changes - Err(_) => {} + Err(_) => continue, + }; + if differs && !edited { + to_reload.push(id); + continue; + } + let replaced = differs; + if (replaced, missing) != (link.replaced, link.missing) { + link.replaced = replaced; + link.missing = missing; + flagged = true; } } - - let outcome = if files.is_empty() && remove.is_empty() { + let reloaded = if to_reload.is_empty() { None } else { - let borrowed: Vec<(Uuid, &[u8], &str)> = files - .iter() - .map(|(id, bytes, name, _)| (*id, bytes.as_slice(), name.as_str())) - .collect(); - let outcome = api::sync_files(engine, &borrowed, &remove); - for (id, bytes, _, path) in &files { - match signature(engine, *id) { - Some(signature) => { - self.links.insert( - *id, - Link { - path: path.clone(), - signature, - disk_hash: hash(bytes), - export_hash: export_hash(engine, *id).unwrap_or_default(), - conflict: false, - }, - ); - } - // it could not be read: it is tried again when it changes - None => { - self.links.remove(id); - } - } - } - for id in &remove { - self.links.remove(id); - } - Some(outcome) + self.reload(engine, &to_reload).ok() }; - if outcome.is_some() || flagged { - self.prune(engine); + match reloaded { + Some(outcome) => Some(outcome), + None => flagged.then(api::Outcome::failed_none), } - outcome.or_else(|| flagged.then(api::Outcome::failed_none)) } - /// The files among `ids` that are not on the disk, with the name to propose for them. + /// Reads files again from the disk: they become the files that are there (one undo step for + /// each), and are not dirty anymore. Stops at the first one that cannot be read. + pub fn reload(&mut self, engine: &mut Engine, ids: &[Uuid]) -> Result { + let mut files = vec![]; + for id in ids { + let Some(link) = self.links.get(id) else { + continue; + }; + let bytes = fs::read(&link.path) + .map_err(|error| format!("{}: {error}", link.path.display()))?; + files.push((*id, bytes, stem(&link.path))); + } + let borrowed: Vec<(Uuid, &[u8], &str)> = files + .iter() + .map(|(id, bytes, name)| (*id, bytes.as_slice(), name.as_str())) + .collect(); + let outcome = api::sync_files(engine, &borrowed, &[]); + for (id, bytes, _) in &files { + let path = self.links[id].path.clone(); + if let Some(link) = self.new_link(engine, *id, path, bytes) { + self.links.insert(*id, link); + } + } + Ok(outcome) + } + + /// The files among `ids` that are written by the first save to a place the user chooses: the + /// ones that are not on the disk, and not linked to a folder either. With the name to propose. pub fn unlinked(&self, engine: &Engine, ids: &[Uuid]) -> Vec<(Uuid, String)> { ids.iter() - .filter(|id| !self.links.contains_key(id)) + .filter(|id| !self.links.contains_key(id) && !self.dirs.contains_key(id)) .filter_map(|id| Some((*id, engine.file_structure(&FileId(*id))?.name.clone()))) .collect() } + fn is_taken(&self, path: &Path) -> bool { + self.links.values().any(|link| link.path == path) + } + /// Writes files to the disk, replacing what is there: they are the same as their file from - /// then on. `paths` is where to write the ones that are not on the disk yet. Stops at the - /// first error, the files that were written stay written. + /// then on. A file that is not on the disk goes in its folder, or at its path in `paths`, or in + /// `folder`, under its name (changed if that is the one of something else). Stops at the first + /// error, the files that were written stay written. pub fn save( &mut self, engine: &Engine, ids: &[Uuid], paths: &BTreeMap, + folder: Option<&Path>, ) -> Result<(), String> { - let mut result = Ok(()); for id in ids { - let path = match self.links.get(id) { - Some(link) => link.path.clone(), - None => match (paths.get(id), &self.folder) { - (Some(path), _) => canonical(path), - // a new file goes in the folder that is open - (None, Some(folder)) => { - let Some(structure) = engine.file_structure(&FileId(*id)) else { - continue; - }; - self.free_path(folder, &structure.name) - } - (None, None) => continue, - }, - }; - let Some(bytes) = api::export_file(engine, *id, ExportOptions::ALL) else { + let Some(structure) = engine.file_structure(&FileId(*id)) else { continue; }; - if let Err(error) = write_atomically(&path, &bytes) { - result = Err(format!("{}: {error}", path.display())); - break; - } - let Some(signature) = signature(engine, *id) else { - continue; + let in_folder = |dir: &Path| free_path(dir, &structure.name, |p| self.is_taken(p)); + let path = match (self.links.get(id), paths.get(id), self.dirs.get(id), folder) { + (Some(link), ..) => link.path.clone(), + (None, Some(path), ..) => canonical(path), + (None, None, Some(dir), _) => in_folder(dir), + (None, None, None, Some(dir)) => in_folder(dir), + (None, None, None, None) => continue, }; - // the folder, if there is one, may watch what was just written: the hash tells it is known - self.links.insert( - *id, - Link { - path: canonical(&path), - signature, - disk_hash: hash(&bytes), - export_hash: hash(&bytes), - conflict: false, - }, - ); + self.write_file(engine, *id, &path)?; } - result + Ok(()) + } + + /// Writes a file to a path of the user's choice, which is the place of the file from then on. + pub fn save_as(&mut self, engine: &Engine, id: Uuid, path: &Path) -> Result<(), String> { + if let Some(other) = self.id_of(&canonical(path)) + && other != id + { + return Err(format!("{} is open already", path.display())); + } + self.write_file(engine, id, path) + } + + fn write_file(&mut self, engine: &Engine, id: Uuid, path: &Path) -> Result<(), String> { + let Some(bytes) = api::export_file(engine, id, ExportOptions::ALL) else { + return Ok(()); + }; + write_atomically(path, &bytes).map_err(|error| format!("{}: {error}", path.display()))?; + let Some(signature) = signature(engine, id) else { + return Ok(()); + }; + // the folder, if there is one, may watch what was just written: the hash tells it is known + self.links.insert( + id, + Link { + path: canonical(path), + signature, + disk_hash: hash(&bytes), + export_hash: hash(&bytes), + replaced: false, + missing: false, + }, + ); + self.dirs.remove(&id); + Ok(()) + } + + /// The folders and the GPX files that are in a folder of the open folder (or in it), the + /// folders first and then alphabetically. Hidden files are left out. + pub fn list_folder(&self, folder: &Path) -> Result, String> { + let folder = canonical(folder); + if !self.in_folder(&folder) { + return Err(format!("{} is not in the open folder", folder.display())); + } + let mut entries: Vec = fs::read_dir(&folder) + .map_err(|error| format!("{}: {error}", folder.display()))? + .flatten() + .filter_map(|entry| { + let name = entry.file_name().to_string_lossy().into_owned(); + let path = entry.path(); + let is_dir = fs::metadata(&path).ok()?.is_dir(); + (!name.starts_with('.') && (is_dir || is_gpx(&path))).then(|| Entry { + name, + path: path.to_string_lossy().into_owned(), + is_dir, + }) + }) + .collect(); + entries.sort_by(|a, b| { + b.is_dir + .cmp(&a.is_dir) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + .then_with(|| a.name.cmp(&b.name)) + }); + Ok(entries) } } @@ -615,6 +672,21 @@ mod tests { ) } + /// The GPX files of a folder and of the folders in it. + pub(super) fn gpx_files(folder: &Path) -> Vec { + let mut found = vec![]; + for entry in fs::read_dir(folder).unwrap().flatten() { + let path = entry.path(); + if path.is_dir() { + found.extend(gpx_files(&path)); + } else if is_gpx(&path) { + found.push(path); + } + } + found.sort(); + found + } + struct Fixture { folder: PathBuf, engine: Engine, @@ -639,10 +711,35 @@ mod tests { path } + /// Opens the folder, and all the files that are in it. + fn open_folder(&mut self) { + let folder = self.folder.clone(); + self.workspace.open_folder(&mut self.engine, &folder, false); + let files = gpx_files(&folder); + self.workspace.open_files(&mut self.engine, &files, false); + } + + fn reconcile(&mut self, changed: Option<&[PathBuf]>) -> Option { + self.workspace.reconcile(&mut self.engine, changed) + } + + fn save(&mut self, ids: &[Uuid]) { + self.workspace + .save(&self.engine, ids, &BTreeMap::new(), None) + .unwrap(); + } + fn ids(&self) -> Vec { self.engine.order().iter().map(|id| id.0).collect() } + fn id_named(&self, name: &str) -> Uuid { + self.ids() + .into_iter() + .find(|id| self.name(*id) == name) + .unwrap() + } + fn name(&self, id: Uuid) -> String { self.engine .file_structure(&FileId(id)) @@ -655,6 +752,14 @@ mod tests { api::select(&mut self.engine, &[id], SelectMode::Replace); assert!(!api::metadata(&mut self.engine, name, "").has_error()); } + + fn status(&self, id: Uuid) -> FileStatus { + self.workspace + .state(&self.engine) + .files + .remove(&id.to_string()) + .unwrap() + } } impl Drop for Fixture { @@ -671,25 +776,19 @@ mod tests { .open_files(&mut fx.engine, std::slice::from_ref(&path), true); let id = fx.ids()[0]; assert!(!fx.workspace.is_dirty(&fx.engine, id)); - assert_eq!( - fx.workspace.state(&fx.engine).files[&id.to_string()] - .path - .as_deref(), - path.to_str() - ); + assert_eq!(fx.status(id).path.as_deref(), path.to_str()); fx.rename(id, "renamed"); assert!(fx.workspace.is_dirty(&fx.engine, id)); // nothing is written before it is saved assert!(fs::read_to_string(&path).unwrap().contains(">a<")); - fx.workspace - .save(&fx.engine, &[id], &BTreeMap::new()) - .unwrap(); + fx.save(&[id]); assert!(!fx.workspace.is_dirty(&fx.engine, id)); assert!(fs::read_to_string(&path).unwrap().contains(">renamed<")); // going back before the save is an edit again + api::select(&mut fx.engine, &[id], SelectMode::Replace); api::undo(&mut fx.engine); assert!(fx.workspace.is_dirty(&fx.engine, id)); api::redo(&mut fx.engine); @@ -706,332 +805,448 @@ mod tests { fx.workspace.unlinked(&fx.engine, &[id]), [(id, "fresh".to_string())] ); + // nowhere to put it without being told + fx.save(&[id]); + assert!(fx.workspace.is_dirty(&fx.engine, id)); let path = fx.folder.join("fresh.gpx"); let paths = BTreeMap::from([(id, path.clone())]); - fx.workspace.save(&fx.engine, &[id], &paths).unwrap(); + fx.workspace.save(&fx.engine, &[id], &paths, None).unwrap(); assert!(path.exists()); assert!(!fx.workspace.is_dirty(&fx.engine, id)); assert!(fx.workspace.unlinked(&fx.engine, &[id]).is_empty()); } + #[test] + fn several_files_without_a_place_go_in_a_folder_with_names_that_are_free() { + let mut fx = Fixture::new(); + fx.write("fresh.gpx", "already there"); + api::new_file(&mut fx.engine, "fresh", None, None, None); + api::new_file(&mut fx.engine, "a/b: c", None, None, None); + api::new_file(&mut fx.engine, "fresh", None, None, None); + let ids = fx.ids(); + let folder = fx.folder.clone(); + fx.workspace + .save(&fx.engine, &ids, &BTreeMap::new(), Some(&folder)) + .unwrap(); + for name in ["fresh (2).gpx", "fresh (3).gpx", "a_b_ c.gpx"] { + assert!(fx.folder.join(name).exists(), "{name}"); + } + assert!(ids.iter().all(|id| !fx.workspace.is_dirty(&fx.engine, *id))); + } + #[test] fn what_this_app_writes_is_not_a_change_of_the_disk() { let mut fx = Fixture::new(); let path = fx.write("a.gpx", "a"); - fx.workspace - .open_folder(&mut fx.engine, &fx.folder.clone(), None) - .unwrap(); + fx.open_folder(); let id = fx.ids()[0]; fx.rename(id, "renamed"); - fx.workspace - .save(&fx.engine, &[id], &BTreeMap::new()) - .unwrap(); - assert!( - fx.workspace - .reconcile(&mut fx.engine, Some(&[path])) - .is_none() - ); + fx.save(&[id]); + assert!(fx.reconcile(Some(&[path])).is_none()); + assert!(!fx.status(id).replaced); assert_eq!(fx.name(id), "renamed"); } #[test] - fn opening_a_folder_opens_its_files_and_follows_what_happens_to_them() { + fn the_folders_of_the_files_outside_the_open_folder_are_watched_too() { let mut fx = Fixture::new(); - let a = fx.write("a.gpx", "a"); - fx.write("notes.txt", "not a track"); - fs::create_dir(fx.folder.join("sub")).unwrap(); - fx.write("sub/b.gpx", "b"); - fx.workspace - .open_folder(&mut fx.engine, &fx.folder.clone(), None) - .unwrap(); - assert_eq!(fx.ids().len(), 2); + let inside = fx.write("in.gpx", "in"); + let elsewhere = canonical(&std::env::temp_dir()).join(format!("gpx-ws-{}", Uuid::new_v4())); + fs::create_dir_all(&elsewhere).unwrap(); + let (one, two) = (elsewhere.join("one.gpx"), elsewhere.join("two.gpx")); + fs::write(&one, gpx("one")).unwrap(); + fs::write(&two, gpx("two")).unwrap(); + let folder = fx.folder.clone(); + + // no folder: only the folder of the files + fx.workspace.open_files(&mut fx.engine, &[one, two], false); + assert_eq!(fx.workspace.watched(), [(elsewhere.clone(), false)]); + + // the open folder as a whole, and the file in it needs nothing more + fx.workspace.open_folder(&mut fx.engine, &folder, false); + fx.workspace.open_files(&mut fx.engine, &[inside], false); + assert_eq!( + fx.workspace.watched(), + [(folder, true), (elsewhere.clone(), false)] + ); + + // closing the files stops it let ids = fx.ids(); - let a_id = ids.iter().copied().find(|id| fx.name(*id) == "a").unwrap(); - - // a new file - let c = fx.write("c.gpx", "c"); - let outcome = fx - .workspace - .reconcile(&mut fx.engine, Some(std::slice::from_ref(&c))) - .unwrap(); - assert!(outcome.changed); - assert_eq!(fx.ids().len(), 3); - - // the same file again: nothing - assert!(fx.workspace.reconcile(&mut fx.engine, Some(&[c])).is_none()); - - // a file that changed is read again, and stays the same file - fx.write("a.gpx", "a, edited elsewhere"); - fx.workspace - .reconcile(&mut fx.engine, Some(std::slice::from_ref(&a))) - .unwrap(); - assert_eq!(fx.name(a_id), "a, edited elsewhere"); - assert!(!fx.workspace.is_dirty(&fx.engine, a_id)); - assert_eq!(fx.ids().len(), 3); - - // a file that is gone - fs::remove_file(&a).unwrap(); - fx.workspace.reconcile(&mut fx.engine, Some(&[a])).unwrap(); - assert_eq!(fx.ids().len(), 2); - assert!(!fx.ids().contains(&a_id)); + fx.workspace.close_files(&mut fx.engine, &ids, true); + assert_eq!(fx.workspace.watched(), [(fx.folder.clone(), true)]); + fs::remove_dir_all(elsewhere).unwrap(); } #[test] - fn the_edits_of_a_file_are_not_overwritten_by_the_disk() { + fn opening_a_folder_does_not_open_its_files() { + let mut fx = Fixture::new(); + fx.write("a.gpx", "a"); + let folder = fx.folder.clone(); + let outcome = fx.workspace.open_folder(&mut fx.engine, &folder, false); + assert!(!outcome.changed); + assert!(fx.ids().is_empty()); + assert_eq!(fx.workspace.folder.as_deref(), Some(folder.as_path())); + } + + #[test] + fn the_tree_of_the_folder_shows_folders_first_and_only_gpx_files() { + let mut fx = Fixture::new(); + fx.write("b.gpx", "b"); + fx.write("A.GPX", "a"); + fx.write("notes.txt", "not a track"); + fx.write(".hidden.gpx", "hidden"); + fs::create_dir(fx.folder.join("zoo")).unwrap(); + fs::create_dir(fx.folder.join("Alps")).unwrap(); + fs::create_dir(fx.folder.join(".git")).unwrap(); + fx.write("zoo/c.gpx", "c"); + let folder = fx.folder.clone(); + fx.workspace.open_folder(&mut fx.engine, &folder, false); + + let names = |entries: Vec| -> Vec<(String, bool)> { + entries.into_iter().map(|e| (e.name, e.is_dir)).collect() + }; + assert_eq!( + names(fx.workspace.list_folder(&folder).unwrap()), + [ + ("Alps".to_string(), true), + ("zoo".to_string(), true), + ("A.GPX".to_string(), false), + ("b.gpx".to_string(), false), + ] + ); + assert_eq!( + names(fx.workspace.list_folder(&folder.join("zoo")).unwrap()), + [("c.gpx".to_string(), false)] + ); + // only what is in the open folder can be listed + let elsewhere = canonical(&std::env::temp_dir()); + assert!(fx.workspace.list_folder(&elsewhere).is_err()); + } + + #[test] + fn a_file_without_edits_is_read_again_when_the_disk_changes() { let mut fx = Fixture::new(); let a = fx.write("a.gpx", "a"); - fx.workspace - .open_folder(&mut fx.engine, &fx.folder.clone(), None) - .unwrap(); + fx.open_folder(); + let id = fx.ids()[0]; + + fx.write("a.gpx", "theirs"); + let outcome = fx.reconcile(Some(std::slice::from_ref(&a))).unwrap(); + assert!(outcome.changed); + // the same file, now the one on the disk + assert_eq!(fx.ids(), [id]); + assert_eq!(fx.name(id), "theirs"); + let status = fx.status(id); + assert!(!status.dirty && !status.replaced); + // looking again changes nothing + assert!(fx.reconcile(Some(std::slice::from_ref(&a))).is_none()); + } + + #[test] + fn a_file_with_edits_is_flagged_when_the_disk_changes_and_not_read_again() { + let mut fx = Fixture::new(); + let a = fx.write("a.gpx", "a"); + fx.open_folder(); let id = fx.ids()[0]; fx.rename(id, "mine"); fx.write("a.gpx", "theirs"); - let outcome = fx - .workspace - .reconcile(&mut fx.engine, Some(std::slice::from_ref(&a))); - assert!(!outcome.is_some_and(|outcome| outcome.changed)); + let outcome = fx.reconcile(Some(std::slice::from_ref(&a))).unwrap(); + assert!(!outcome.changed); assert_eq!(fx.name(id), "mine"); - assert!(fx.workspace.state(&fx.engine).files[&id.to_string()].conflict); + let status = fx.status(id); + assert!(status.dirty && status.replaced && !status.missing); + // looking again changes nothing + assert!(fx.reconcile(Some(std::slice::from_ref(&a))).is_none()); + + // the disk goes back to what was read: only the edits are left + fx.write("a.gpx", "a"); + assert!(fx.reconcile(Some(std::slice::from_ref(&a))).is_some()); + let status = fx.status(id); + assert!(status.dirty && !status.replaced); + } + + #[test] + fn the_user_can_take_the_disk_instead_of_the_edits() { + let mut fx = Fixture::new(); + fx.write("a.gpx", "a"); + fx.open_folder(); + let id = fx.ids()[0]; + fx.rename(id, "mine"); + fx.write("a.gpx", "theirs"); + fx.reconcile(None); + assert!(fx.status(id).replaced); + + fx.workspace.reload(&mut fx.engine, &[id]).unwrap(); + assert_eq!(fx.name(id), "theirs"); + let status = fx.status(id); + assert!(!status.dirty && !status.replaced); + // and it is one undo step + api::select(&mut fx.engine, &[id], SelectMode::Replace); + api::undo(&mut fx.engine); + assert_eq!(fx.name(id), "mine"); + } + + #[test] + fn saving_overwrites_what_replaced_the_file() { + let mut fx = Fixture::new(); + let a = fx.write("a.gpx", "a"); + fx.open_folder(); + let id = fx.ids()[0]; + fx.rename(id, "mine"); + fx.write("a.gpx", "theirs"); + fx.reconcile(None); + assert!(fx.status(id).replaced); + + fx.save(&[id]); + assert!(fs::read_to_string(a).unwrap().contains(">mine<")); + assert!(!fx.status(id).replaced && !fx.status(id).dirty); + } + + #[test] + fn a_file_that_is_gone_from_the_disk_stays_open_and_dirty_until_it_is_saved_again() { + let mut fx = Fixture::new(); + let a = fx.write("a.gpx", "a"); + fx.open_folder(); + let id = fx.ids()[0]; - // gone from the disk: it stays, as a file that is only here fs::remove_file(&a).unwrap(); - fx.workspace.reconcile(&mut fx.engine, Some(&[a])); + assert!(fx.reconcile(Some(std::slice::from_ref(&a))).is_some()); assert_eq!(fx.ids(), [id]); - let status = &fx.workspace.state(&fx.engine).files[&id.to_string()]; - assert!(status.path.is_none() && status.dirty); + let status = fx.status(id); + assert!(status.dirty && status.missing && status.path.is_some()); - // saving it again puts it back - let paths = BTreeMap::from([(id, fx.folder.join("a.gpx"))]); - fx.workspace.save(&fx.engine, &[id], &paths).unwrap(); - assert!(!fx.workspace.state(&fx.engine).files[&id.to_string()].conflict); + // saving puts it back + fx.save(&[id]); + assert!(a.exists()); + let status = fx.status(id); + assert!(!status.dirty && !status.missing); + } + + #[test] + fn a_file_that_comes_back_different_is_read_again() { + let mut fx = Fixture::new(); + let a = fx.write("a.gpx", "a"); + fx.open_folder(); + let id = fx.ids()[0]; + fs::remove_file(&a).unwrap(); + fx.reconcile(None); + assert!(fx.status(id).missing); + + fx.write("a.gpx", "back"); + fx.reconcile(None); + assert_eq!(fx.name(id), "back"); + let status = fx.status(id); + assert!(!status.dirty && !status.missing); } #[test] fn what_changed_while_the_app_was_closed_is_found_when_it_starts() { let mut fx = Fixture::new(); let a = fx.write("a.gpx", "a"); - fx.workspace - .open_folder(&mut fx.engine, &fx.folder.clone(), None) - .unwrap(); + fx.open_folder(); let id = fx.ids()[0]; fx.write("a.gpx", "changed"); - fx.write("new.gpx", "new"); - fx.workspace.reconcile(&mut fx.engine, None).unwrap(); + assert!(fx.reconcile(None).is_some()); assert_eq!(fx.name(id), "changed"); - assert_eq!(fx.ids().len(), 2); fs::remove_file(a).unwrap(); - fx.workspace.reconcile(&mut fx.engine, None).unwrap(); - assert_eq!(fx.ids().len(), 1); + assert!(fx.reconcile(None).is_some()); + assert!(fx.status(id).missing); } #[test] - fn a_new_file_is_saved_in_the_open_folder_without_a_place() { + fn new_files_made_from_a_file_of_the_folder_are_saved_next_to_it() { let mut fx = Fixture::new(); - fx.write("fresh.gpx", "already there"); - fx.workspace - .open_folder(&mut fx.engine, &fx.folder.clone(), None) - .unwrap(); - api::new_file(&mut fx.engine, "fresh", None, None, None); - api::new_file(&mut fx.engine, "a/b: c", None, None, None); - let new: Vec = fx + fs::create_dir(fx.folder.join("sub")).unwrap(); + fx.write("sub/b.gpx", "b"); + fx.write("a.gpx", "a"); + fx.open_folder(); + let b = fx.id_named("b"); + let sub = fx.folder.join("sub"); + assert_eq!(fx.workspace.source_dir(&[b]), Some(canonical(&sub))); + + let before = fx.ids(); + api::select(&mut fx.engine, &[b], SelectMode::Replace); + let outcome = api::duplicate(&mut fx.engine); + let copy = fx .ids() .into_iter() - .filter(|id| fx.workspace.is_dirty(&fx.engine, *id)) - .collect(); - assert_eq!(new.len(), 2); - - fx.workspace - .save(&fx.engine, &new, &BTreeMap::new()) + .find(|id| !before.contains(id)) .unwrap(); - // named like the file, not over what is there, and with a name that is valid - assert!(fx.folder.join("fresh (2).gpx").exists()); - assert!(fx.folder.join("a_b_ c.gpx").exists()); + fx.workspace.inherit(outcome.added(), &canonical(&sub)); + assert_eq!(fx.status(copy).folder.as_deref(), canonical(&sub).to_str()); + assert!(fx.status(copy).path.is_none() && fx.status(copy).dirty); + // nobody needs to be asked where it goes + assert!(fx.workspace.unlinked(&fx.engine, &[copy]).is_empty()); + + fx.save(&[copy]); + assert!(sub.join("b (2).gpx").exists()); + let status = fx.status(copy); + assert!(!status.dirty && status.folder.is_none()); assert_eq!( - fx.workspace - .state(&fx.engine) - .files - .values() - .filter(|f| f.dirty) - .count(), - 0 + status.path.as_deref(), + canonical(&sub.join("b (2).gpx")).to_str() ); + // the copy of the copy is next to it as well + assert_eq!(fx.workspace.source_dir(&[copy]), Some(canonical(&sub))); } #[test] - fn opening_another_folder_closes_the_first() { + fn a_file_outside_the_open_folder_hands_nothing_down() { let mut fx = Fixture::new(); - fx.write("one.gpx", "one"); - let other = fx.folder.join("other"); - fs::create_dir(&other).unwrap(); - fs::write(other.join("two.gpx"), gpx("two")).unwrap(); - // the first folder is not the parent of the second - let first = fx.folder.join("first"); - fs::create_dir(&first).unwrap(); - fs::write(first.join("one.gpx"), gpx("one")).unwrap(); - fx.workspace - .open_folder(&mut fx.engine, &first, None) - .unwrap(); - let one = fx.ids()[0]; - fx.workspace - .open_folder(&mut fx.engine, &other, None) - .unwrap(); - assert_eq!(fx.ids().len(), 1); - assert!(!fx.ids().contains(&one)); - assert_eq!(fx.name(fx.ids()[0]), "two"); - assert_eq!( - fx.workspace.folder.as_deref(), - Some(canonical(&other).as_path()) - ); - - // what has edits stays when the folder is closed, and can still be saved where it is - let two = fx.ids()[0]; - fx.rename(two, "edited"); - let outcome = fx.workspace.close_folder(&mut fx.engine, None).unwrap(); - assert!(!outcome.changed); - assert_eq!(fx.ids(), [two]); - assert!(fx.workspace.folder.is_none()); - fx.workspace - .save(&fx.engine, &[two], &BTreeMap::new()) - .unwrap(); - assert!( - fs::read_to_string(other.join("two.gpx")) - .unwrap() - .contains("edited") - ); - let clean = fx.workspace.close_folder(&mut fx.engine, None).unwrap(); - assert!(!clean.changed); - } - - fn folder_with_an_edited_file() -> (Fixture, Uuid) { - let mut fx = Fixture::new(); - fx.write("a.gpx", "a"); - fx.write("b.gpx", "b"); - fx.workspace - .open_folder(&mut fx.engine, &fx.folder.clone(), None) - .unwrap(); - let id = fx.ids().into_iter().find(|id| fx.name(*id) == "a").unwrap(); - fx.rename(id, "edited"); - (fx, id) - } - - #[test] - fn the_unsaved_files_of_a_folder_are_known() { - let (fx, id) = folder_with_an_edited_file(); - assert_eq!(fx.workspace.unsaved_in_folder(&fx.engine), [id]); - } - - #[test] - fn closing_a_folder_can_save_the_unsaved_files_first() { - let (mut fx, _) = folder_with_an_edited_file(); - let outcome = fx - .workspace - .close_folder(&mut fx.engine, Some(Unsaved::Save)) - .unwrap(); - assert!(outcome.changed); - assert!(fx.ids().is_empty()); - assert!( - fs::read_to_string(fx.folder.join("a.gpx")) - .unwrap() - .contains("edited") - ); - assert!(fx.workspace.folder.is_none() && fx.workspace.links.is_empty()); - } - - #[test] - fn closing_a_folder_can_discard_the_unsaved_files() { - let (mut fx, _) = folder_with_an_edited_file(); - fx.workspace - .close_folder(&mut fx.engine, Some(Unsaved::Discard)) - .unwrap(); - assert!(fx.ids().is_empty()); - assert!( - fs::read_to_string(fx.folder.join("a.gpx")) - .unwrap() - .contains(">a<") - ); - } - - #[test] - fn nothing_is_closed_when_the_unsaved_files_cannot_be_saved() { - let (mut fx, id) = folder_with_an_edited_file(); - // the file cannot be written: its place is taken by a folder - fs::remove_file(fx.folder.join("a.gpx")).unwrap(); - fs::create_dir(fx.folder.join("a.gpx")).unwrap(); - let result = fx - .workspace - .close_folder(&mut fx.engine, Some(Unsaved::Save)); - assert!(result.is_err()); - assert!(fx.workspace.folder.is_some()); - assert!(fx.ids().contains(&id)); - } - - #[test] - fn opening_another_folder_applies_the_choice_to_the_first() { - let (mut fx, _) = folder_with_an_edited_file(); - let other = fx.folder.join("other"); - fs::create_dir(&other).unwrap(); - fs::write(other.join("c.gpx"), gpx("c")).unwrap(); - // the folder that is open contains the other one: use two separate ones instead let elsewhere = canonical(&std::env::temp_dir()).join(format!("gpx-ws-{}", Uuid::new_v4())); fs::create_dir_all(&elsewhere).unwrap(); - fs::write(elsewhere.join("d.gpx"), gpx("d")).unwrap(); + let outside = elsewhere.join("o.gpx"); + fs::write(&outside, gpx("o")).unwrap(); + let folder = fx.folder.clone(); + fx.workspace.open_folder(&mut fx.engine, &folder, false); fx.workspace - .open_folder(&mut fx.engine, &elsewhere, Some(Unsaved::Discard)) - .unwrap(); - assert_eq!(fx.ids().len(), 1); - assert_eq!(fx.name(fx.ids()[0]), "d"); - assert!( - fs::read_to_string(fx.folder.join("a.gpx")) - .unwrap() - .contains(">a<") - ); + .open_files(&mut fx.engine, std::slice::from_ref(&outside), true); + let id = fx.ids()[0]; + assert_eq!(fx.workspace.source_dir(&[id]), None); + // a new file with no source is not linked to a folder by `source_dir`: the caller falls back fs::remove_dir_all(elsewhere).unwrap(); } #[test] - fn new_files_belong_to_the_open_folder_and_follow_its_unsaved_choice() { - let (mut fx, edited) = folder_with_an_edited_file(); - api::new_file(&mut fx.engine, "fresh", None, None, None); - let fresh = *fx - .ids() - .iter() - .find(|id| **id != edited && fx.name(**id) == "fresh") - .unwrap(); - let unsaved = fx.workspace.unsaved_in_folder(&fx.engine); - assert!(unsaved.contains(&edited) && unsaved.contains(&fresh)); + fn closing_files_asks_what_to_do_with_the_unsaved_ones() { + let mut fx = Fixture::new(); + fx.write("a.gpx", "a"); + fx.write("b.gpx", "b"); + fx.open_folder(); + let (a, b) = (fx.id_named("a"), fx.id_named("b")); + fx.rename(a, "edited"); + assert_eq!(fx.workspace.unsaved(&fx.engine, &[a, b]), [a]); - // saved in the folder when it is closed - fx.workspace - .close_folder(&mut fx.engine, Some(Unsaved::Save)) - .unwrap(); + // without a choice what has edits stays + let outcome = fx.workspace.close_files(&mut fx.engine, &[a, b], false); + assert!(outcome.changed); + assert_eq!(fx.ids(), [a]); + + // saved first + fx.save(&[a]); + fx.workspace.close_files(&mut fx.engine, &[a], false); assert!(fx.ids().is_empty()); - assert!(fx.folder.join("fresh.gpx").exists()); assert!( fs::read_to_string(fx.folder.join("a.gpx")) .unwrap() .contains("edited") ); + assert!(fx.workspace.links.is_empty()); } #[test] - fn new_files_are_discarded_with_the_folder_or_stay_without_a_choice() { - let (mut fx, edited) = folder_with_an_edited_file(); + fn closing_files_can_discard_their_edits() { + let mut fx = Fixture::new(); + fx.write("a.gpx", "a"); + fx.open_folder(); + let a = fx.ids()[0]; + fx.rename(a, "edited"); api::new_file(&mut fx.engine, "fresh", None, None, None); - // without a choice the unsaved files stay, the new one as well - fx.workspace.close_folder(&mut fx.engine, None).unwrap(); - assert!(fx.ids().contains(&edited)); - assert!(fx.ids().iter().any(|id| fx.name(*id) == "fresh")); + let fresh = fx.id_named("fresh"); + fx.workspace.close_files(&mut fx.engine, &[a, fresh], true); + assert!(fx.ids().is_empty()); + assert!( + fs::read_to_string(fx.folder.join("a.gpx")) + .unwrap() + .contains(">a<") + ); + } + + #[test] + fn closing_the_folder_closes_every_file() { + let mut fx = Fixture::new(); + fx.write("a.gpx", "a"); + fx.open_folder(); + api::new_file(&mut fx.engine, "fresh", None, None, None); + assert_eq!(fx.ids().len(), 2); + + // the new file has nowhere to go: it has to be discarded + let outcome = fx.workspace.close_folder(&mut fx.engine, false); + assert!(outcome.changed); + assert_eq!(fx.ids().len(), 1); assert!(fx.workspace.folder.is_none()); - let (mut fx, _) = folder_with_an_edited_file(); - api::new_file(&mut fx.engine, "fresh", None, None, None); - fx.workspace - .close_folder(&mut fx.engine, Some(Unsaved::Discard)) - .unwrap(); + let outcome = fx.workspace.close_folder(&mut fx.engine, true); + assert!(outcome.changed); assert!(fx.ids().is_empty()); - assert!(!fx.folder.join("fresh.gpx").exists()); + } + + #[test] + fn opening_another_folder_closes_the_first_with_all_its_files() { + let mut fx = Fixture::new(); + fx.write("a.gpx", "a"); + fx.open_folder(); + let a = fx.ids()[0]; + fx.rename(a, "edited"); + let other = canonical(&std::env::temp_dir()).join(format!("gpx-ws-{}", Uuid::new_v4())); + fs::create_dir_all(&other).unwrap(); + + // the edits stay, nothing is lost without a choice + fx.workspace.open_folder(&mut fx.engine, &other, false); + assert_eq!(fx.ids(), [a]); + assert_eq!(fx.workspace.folder.as_deref(), Some(other.as_path())); + + let first = fx.folder.clone(); + fx.workspace.open_folder(&mut fx.engine, &first, true); + assert!(fx.ids().is_empty()); + assert!( + fs::read_to_string(fx.folder.join("a.gpx")) + .unwrap() + .contains(">a<") + ); + fs::remove_dir_all(other).unwrap(); + } + + #[test] + fn opening_the_same_folder_again_changes_nothing() { + let mut fx = Fixture::new(); + fx.write("a.gpx", "a"); + fx.open_folder(); + let folder = fx.folder.clone(); + let outcome = fx.workspace.open_folder(&mut fx.engine, &folder, true); + assert!(!outcome.changed); + assert_eq!(fx.ids().len(), 1); + } + + #[test] + fn a_failed_save_stops_at_the_file_and_leaves_it_dirty() { + let mut fx = Fixture::new(); + fx.write("a.gpx", "a"); + fx.open_folder(); + let id = fx.ids()[0]; + fx.rename(id, "edited"); + // the place of the file is taken by a folder + fs::remove_file(fx.folder.join("a.gpx")).unwrap(); + fs::create_dir(fx.folder.join("a.gpx")).unwrap(); + let result = fx.workspace.save(&fx.engine, &[id], &BTreeMap::new(), None); + assert!(result.is_err()); + assert!(fx.workspace.is_dirty(&fx.engine, id)); + } + + #[test] + fn saving_as_gives_the_file_another_place() { + let mut fx = Fixture::new(); + let a = fx.write("a.gpx", "a"); + fx.write("b.gpx", "b"); + fx.open_folder(); + let id = fx.id_named("a"); + let other = fx.id_named("b"); + fx.rename(id, "edited"); + + let target = fx.folder.join("copy.gpx"); + fx.workspace.save_as(&fx.engine, id, &target).unwrap(); + assert!(fs::read_to_string(&target).unwrap().contains("edited")); + assert!(fs::read_to_string(a).unwrap().contains(">a<")); + let status = fx.status(id); + assert!(!status.dirty); + assert_eq!(status.path.as_deref(), canonical(&target).to_str()); + + // the place of another open file is not available + let taken = fx.folder.join("b.gpx"); + assert!(fx.workspace.save_as(&fx.engine, id, &taken).is_err()); + assert!(!fx.workspace.is_dirty(&fx.engine, other)); } } @@ -1066,7 +1281,8 @@ mod restart_tests { } let mut engine = Engine::default(); let mut workspace = Workspace::default(); - workspace.open_folder(&mut engine, &folder, None).unwrap(); + workspace.open_folder(&mut engine, &folder, false); + workspace.open_files(&mut engine, &tests::gpx_files(&folder), false); let ids = engine.order().iter().map(|id| id.0).collect(); (root, engine, workspace, ids) } @@ -1118,4 +1334,22 @@ mod restart_tests { ); fs::remove_dir_all(root).unwrap(); } + + #[test] + fn the_folders_of_new_files_and_the_old_flag_name_survive_a_restart() { + let (root, engine, mut workspace, ids) = setup(); + let folder = workspace.folder.clone().unwrap(); + let fresh = Uuid::new_v4(); + workspace.inherit(&[fresh.to_string()], &folder); + workspace.links.get_mut(&ids[0]).unwrap().replaced = true; + let json = serde_json::to_string(&workspace).unwrap(); + // links written by the previous version had `conflict`, which is the same thing + let json = json.replace("\"replaced\"", "\"conflict\""); + assert!(json.contains("\"conflict\":true")); + let read: Workspace = serde_json::from_str(&json).unwrap(); + assert!(read.links[&ids[0]].replaced); + assert_eq!(read.dirs.get(&fresh), Some(&folder)); + let _ = engine; + fs::remove_dir_all(root).unwrap(); + } } diff --git a/website/src/lib/actions/file-actions.ts b/website/src/lib/actions/file-actions.ts index 01b68c85e..e09dae626 100644 --- a/website/src/lib/actions/file-actions.ts +++ b/website/src/lib/actions/file-actions.ts @@ -4,8 +4,7 @@ import { get } from 'svelte/store'; import { boundsManager } from '$lib/state/bounds'; import { engine } from '$lib/engine'; import { defaultFileName } from '$lib/actions/default-file-name'; -import { askUnsaved } from '$lib/components/file-list/unsaved-prompt.svelte'; -import { subfolder } from '$lib/engine/workspace'; +import { askOverwrite, askUnsaved } from '$lib/components/file-list/unsaved-prompt.svelte'; import type { UnsavedChoice } from '$lib/engine'; /** The name of a new file: the translated default name, numbered if other files have it. */ @@ -32,71 +31,119 @@ export async function openFiles() { } } -/** - * What to do with the files of the open folder that have unsaved changes, which the user is asked - * when there are some: `undefined` when there are none, `'cancel'` when they do not want to go on. - */ -async function askAboutUnsavedFiles(): Promise { - const workspace = get(engine.workspace); - if (!workspace?.folder) { - return undefined; - } +/** The names of files, to show them to the user. */ +function namesOf(ids: string[]): string[] { const files = get(engine.files); - const names = Object.entries(workspace.files) - // the files in the folder, and the new ones, which are saved in it - .filter( - ([, file]) => - file.dirty && - (file.path === undefined || subfolder(workspace.folder, file.path) !== undefined) - ) - .map(([id]) => { - const file = files.get(id); - return file ? get(file).structure.name : id; - }); - return names.length > 0 ? askUnsaved(names) : undefined; + return ids.map((id) => { + const file = files.get(id); + return file ? get(file).structure.name : id; + }); +} + +/** The ids among `ids` of the files that have changes that are not saved. */ +function unsavedAmong(ids: string[]): string[] { + const workspace = get(engine.workspace); + return ids.filter((id) => workspace?.files[id]?.dirty); } /** - * Opens a folder, which is watched (the desktop app). The folder that was open is closed: the user - * is asked what to do with its unsaved files. + * What to do with the files among `ids` that have unsaved changes, which the user is asked when + * there are some: `undefined` when there are none, `'cancel'` when they do not want to go on. + */ +async function askAboutUnsavedFiles(ids: string[]): Promise { + const unsaved = unsavedAmong(ids); + return unsaved.length > 0 ? askUnsaved(namesOf(unsaved)) : undefined; +} + +/** All the open files. */ +function allFileIds(): string[] { + return get(engine.order); +} + +/** + * Opens a folder, which is watched (the desktop app). Its files are not opened, they are in its + * tree. The folder that was open is closed with its files: the user is asked what to do with the + * unsaved ones. */ export async function openFolder() { - const unsaved = await askAboutUnsavedFiles(); - if (unsaved === 'cancel') { - return; - } - const ids = await engine.openFolder(unsaved); - if (ids.length > 0) { - boundsManager.fitBoundsOnLoad(ids); + const unsaved = await askAboutUnsavedFiles(allFileIds()); + if (unsaved !== 'cancel') { + await engine.openFolder(unsaved); } } -/** Closes the open folder and its files: the user is asked what to do with the unsaved ones. */ +/** Closes the open folder and all the files: the user is asked what to do with the unsaved ones. */ export async function closeFolder() { - const unsaved = await askAboutUnsavedFiles(); + const unsaved = await askAboutUnsavedFiles(allFileIds()); if (unsaved !== 'cancel') { await engine.closeFolder(unsaved); } } +/** Closes files: the user is asked what to do with the ones that have unsaved changes. */ +export async function closeFiles(ids: string[]) { + if (ids.length === 0) { + return; + } + const unsaved = await askAboutUnsavedFiles(ids); + if (unsaved !== 'cancel') { + await engine.closeFiles(ids, unsaved); + } +} + +/** Closes all the files. */ +export function closeAll() { + return closeFiles(allFileIds()); +} + +/** + * Writes files to the file system. The user is asked first about the ones whose file was also + * changed on the disk, which saving overwrites: they can take the disk instead of their changes. + */ +async function saveFiles(ids: string[]) { + if (ids.length === 0) { + return; + } + const workspace = get(engine.workspace); + const replaced = ids.filter((id) => workspace?.files[id]?.replaced); + if (replaced.length > 0) { + const choice = await askOverwrite(namesOf(replaced)); + if (choice === 'cancel') { + return; + } + if (choice === 'reload') { + // they are the files on the disk now, there is nothing to save + await engine.reload(replaced); + ids = ids.filter((id) => !replaced.includes(id)); + } + } + if (ids.length > 0) { + await engine.save(ids); + } +} + /** Saves the selected files to the file system, all the ones that have unsaved edits if none is. */ export function saveSelected(selectedFileIds: string[]) { - const workspace = get(engine.workspace); - const ids = - selectedFileIds.length > 0 - ? selectedFileIds - : Object.keys(workspace?.files ?? {}).filter((id) => workspace?.files[id].dirty); - if (ids.length > 0) { - engine.save(ids); - } + return saveFiles(selectedFileIds.length > 0 ? selectedFileIds : unsavedAmong(allFileIds())); } /** Saves all the files that have unsaved edits to the file system. */ export function saveAll() { - const workspace = get(engine.workspace); - const ids = Object.keys(workspace?.files ?? {}).filter((id) => workspace?.files[id].dirty); + return saveFiles(unsavedAmong(allFileIds())); +} + +/** Saves a file where the user says, which is where it is from then on (not for several files). */ +export async function saveAs(selectedFileIds: string[]) { + if (selectedFileIds.length === 1) { + await engine.saveAs(selectedFileIds[0]); + } +} + +/** Opens a file of the tree of the open folder. */ +export async function openPath(path: string) { + const ids = await engine.openPaths([path]); if (ids.length > 0) { - engine.save(ids); + boundsManager.fitBoundsOnLoad(ids); } } diff --git a/website/src/lib/components/Menu.svelte b/website/src/lib/components/Menu.svelte index 0bcbb4f16..c89d139b6 100644 --- a/website/src/lib/components/Menu.svelte +++ b/website/src/lib/components/Menu.svelte @@ -40,6 +40,8 @@ FolderOpen, FileStack, FileX, + Save, + SaveAll, BookOpenText, ChartArea, Maximize, @@ -59,10 +61,22 @@ import { languages } from '$lib/i18n/languages'; import { getURLForLanguage } from '$lib/utils'; import { settings } from '$lib/state/settings'; - import { createFile, loadFiles, triggerFileInput } from '$lib/actions/file-actions'; + import { + closeAll, + closeFiles, + closeFolder, + createFile, + loadFiles, + openFiles, + openFolder, + saveAll, + saveAs, + saveSelected, + } from '$lib/actions/file-actions'; + import UnsavedDialog from '$lib/components/file-list/UnsavedDialog.svelte'; import { engine } from '$lib/engine'; import { allHidden } from '$lib/engine/all-hidden'; - import { selectionSize } from '$lib/engine/selection-helpers'; + import { selectedFileIds, selectionSize } from '$lib/engine/selection-helpers'; import { boundsManager } from '$lib/state/bounds'; import { onMount } from 'svelte'; @@ -82,10 +96,14 @@ routing, } = settings; - const { files, selection, canUndo, canRedo, canPaste } = engine; + const { files, selection, canUndo, canRedo, canPaste, workspace } = engine; let selectionCount = $derived(selectionSize($selection)); let noFiles = $derived($files.size === 0); + // the desktop app: the files are linked to the file system + let desktop = $derived($workspace !== undefined); + let selectedIds = $derived(selectedFileIds($selection)); + let anyUnsaved = $derived(Object.values($workspace?.files ?? {}).some((file) => file.dirty)); // metadata and style can be edited for files and tracks only let filesOrTracksSelected = $derived($selection.type === 'file' || $selection.type === 'track'); @@ -142,11 +160,18 @@ - + {i18n._('menu.open')} + {#if desktop} + + + {i18n._('menu.open_folder')} + + + {/if} engine.duplicate()} disabled={selectionCount == 0}> @@ -154,19 +179,60 @@ - engine.delete(true)} - disabled={selectionCount == 0} - > - - {i18n._('menu.delete')} - - - engine.deleteAll()} disabled={noFiles}> - - {i18n._('menu.delete_all')} - - + {#if desktop} + saveSelected(selectedIds)} + disabled={selectionCount == 0 && !anyUnsaved} + > + + {i18n._('menu.save')} + + + + + {i18n._('menu.save_all')} + + + saveAs(selectedIds)} + disabled={selectedIds.length !== 1} + > + + {i18n._('menu.save_as')} + + + closeFiles(selectedIds)} + disabled={selectedIds.length === 0} + > + + {i18n._('menu.close')} + + + + + {i18n._('menu.close_all')} + + + + + {i18n._('menu.close_folder')} + + {:else} + engine.delete(true)} + disabled={selectionCount == 0} + > + + {i18n._('menu.delete')} + + + engine.deleteAll()} disabled={noFiles}> + + {i18n._('menu.delete_all')} + + + {/if} (exportState.current = ExportState.SELECTION)} @@ -174,7 +240,7 @@ > {i18n._('menu.export')} - + {#if !desktop}{/if} (exportState.current = ExportState.ALL)} @@ -182,7 +248,7 @@ > {i18n._('menu.export_all')} - + {#if !desktop}{/if} @@ -498,6 +564,7 @@ + { diff --git a/website/src/lib/components/file-list/UnsavedDialog.svelte b/website/src/lib/components/file-list/UnsavedDialog.svelte new file mode 100644 index 000000000..9ac83a5ef --- /dev/null +++ b/website/src/lib/components/file-list/UnsavedDialog.svelte @@ -0,0 +1,69 @@ + + + { + if (!open) { + answer('cancel'); + } + }} +> + + {#if prompt} + + + {prompt.kind === 'unsaved' + ? i18n._('menu.unsaved_title') + : i18n._('menu.overwrite_title')} + + + {prompt.kind === 'unsaved' + ? i18n._('menu.unsaved_description') + : i18n._('menu.overwrite_description')} + + +
    + {#each prompt.names as name} +
  • {name}
  • + {/each} +
+ + + {#if prompt.kind === 'unsaved'} + + {:else} + + {/if} + + + {/if} +
+
diff --git a/website/src/lib/components/file-list/unsaved-prompt.svelte.ts b/website/src/lib/components/file-list/unsaved-prompt.svelte.ts new file mode 100644 index 000000000..12ad15613 --- /dev/null +++ b/website/src/lib/components/file-list/unsaved-prompt.svelte.ts @@ -0,0 +1,32 @@ +import type { UnsavedChoice } from '$lib/engine'; + +type Prompt = + | { kind: 'unsaved'; names: string[]; resolve: (choice: UnsavedChoice | 'cancel') => void } + | { + kind: 'overwrite'; + names: string[]; + resolve: (choice: 'overwrite' | 'reload' | 'cancel') => void; + }; + +/** The question that the user is asked, shown by `UnsavedDialog`. */ +export const unsavedPrompt: { current: Prompt | undefined } = $state({ current: undefined }); + +/** + * Asks what to do with files that have unsaved changes when they are closed: save them, discard + * the changes, or `'cancel'` to keep going as before. + */ +export function askUnsaved(names: string[]): Promise { + return new Promise((resolve) => { + unsavedPrompt.current = { kind: 'unsaved', names, resolve }; + }); +} + +/** + * Asks what to do with files that have changes and were also changed on the disk: `'overwrite'` the + * disk, `'reload'` the disk (the changes are lost), or `'cancel'`. + */ +export function askOverwrite(names: string[]): Promise<'overwrite' | 'reload' | 'cancel'> { + return new Promise((resolve) => { + unsavedPrompt.current = { kind: 'overwrite', names, resolve }; + }); +} diff --git a/website/src/lib/engine/backend/tauri.ts b/website/src/lib/engine/backend/tauri.ts index 201fe4556..004862191 100644 --- a/website/src/lib/engine/backend/tauri.ts +++ b/website/src/lib/engine/backend/tauri.ts @@ -1,7 +1,7 @@ import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import type { Outcome, RouteAttributes, StorageOpened } from 'gpx-wasm'; -import type { Backend, WorkspaceState } from './types'; +import type { Backend, FolderEntry, WorkspaceState } from './types'; /** An array that a command returns as its bytes. */ const array = async ( @@ -211,9 +211,16 @@ export async function createTauriBackend(): Promise { desktop: { workspace: () => invoke('workspace_state'), openFiles: () => invoke('open_files'), + openPaths: (paths) => invoke('open_paths', { paths }), openFolder: (unsaved) => invoke('open_folder', { unsaved }), closeFolder: (unsaved) => invoke('close_folder', { unsaved }), - saveFiles: (fileIds) => invoke('save_files', { fileIds }), + closeFiles: (fileIds, unsaved) => invoke('close_files', { fileIds, unsaved }), + saveFiles: (fileIds) => invoke('save_files', { fileIds }), + saveFileAs: (fileId) => invoke('save_file_as', { fileId }), + reloadFiles: (fileIds) => invoke('reload_files', { fileIds }), + exportFiles: (fileIds, options) => + invoke('export_files', { fileIds, options }), + listFolder: (path) => invoke('list_folder', { path }), listen: async (onEngineChanged, onWorkspaceChanged, onFilesOpened) => { await listen('files-opened', (event) => onFilesOpened(event.payload)); await listen('engine-changed', (event) => onEngineChanged(event.payload)); diff --git a/website/src/lib/engine/backend/types.ts b/website/src/lib/engine/backend/types.ts index 29d25e958..5ba870fa6 100644 --- a/website/src/lib/engine/backend/types.ts +++ b/website/src/lib/engine/backend/types.ts @@ -10,16 +10,33 @@ import type { TrackpointDetails, WaypointDetails, } from 'gpx-wasm'; -import type { MergeType, SelectMode, SplitType } from '../types'; +import type { ExportOptions, MergeType, SelectMode, SplitType } from '../types'; /** What the desktop app knows of a file of the engine and the file system. */ export type FileStatus = { /** Where the file is on the disk, absent for a file that only is in the engine. */ path?: string; - /** The file has edits that the file on the disk does not have (or is not on the disk). */ + /** + * The folder where the file is written the first time it is saved, for a file that is not on + * the disk and comes from (or was made in) the open folder. + */ + folder?: string; + /** + * The file is not the one on the disk: it has edits, is not on the disk, or the file on the + * disk is not the one that was read (it was replaced, or is gone). + */ dirty: boolean; - /** The file on the disk was changed by something else while this one had edits. */ - conflict: boolean; + /** The file on the disk was replaced by something else: saving overwrites it. */ + replaced: boolean; + /** The file on the disk is gone. */ + missing: boolean; +}; + +/** An entry of the tree of the open folder: a folder, or a GPX file. */ +export type FolderEntry = { + name: string; + path: string; + isDir: boolean; }; /** What to do with the files of a folder that have unsaved edits when it is closed. */ @@ -39,22 +56,39 @@ export interface DesktopFiles { workspace(): Promise; /** Opens the files that the user picks, which get selected. */ openFiles(): Promise; + /** Opens files of the file system by their path, like the ones of the tree of the folder. */ + openPaths(paths: string[]): Promise; /** - * Opens the folder that the user picks: its files are opened, and it is watched. The folder - * that was open is closed (see `closeFolder` for `unsaved`). + * Opens the folder that the user picks, which is watched. Its files are not opened: they are + * in its tree (`listFolder`). The folder that was open is closed with its files (see + * `closeFiles` for `unsaved`). */ openFolder(unsaved?: UnsavedChoice): Promise; - /** - * Closes the open folder: it is not watched anymore, and its files are closed. What to do with - * the ones that have unsaved edits: `save` them first, `discard` the edits, or keep them open - * when it is not said. Rejects, and closes nothing, if they could not be saved. - */ + /** Closes the open folder: it is not watched anymore, and its files are closed. */ closeFolder(unsaved?: UnsavedChoice): Promise; /** - * Writes files to the disk, replacing what is there (the user is asked where to put the ones - * that are not on the disk yet). + * Closes files. What to do with the ones that have unsaved edits: `save` them first (the user + * is asked where to put the ones that have no place), `discard` the edits, or keep them open + * when it is not said. */ - saveFiles(fileIds: string[]): Promise; + closeFiles(fileIds: string[], unsaved?: UnsavedChoice): Promise; + /** + * Writes files to the disk, replacing what is there (the user is asked where to put the ones + * that have no place: a file, or a folder for several of them). Resolves to false if the user + * cancelled. + */ + saveFiles(fileIds: string[]): Promise; + /** Writes a file where the user says, which is where it is from then on. False if cancelled. */ + saveFileAs(fileId: string): Promise; + /** Reads files again from the disk: their edits are lost (one undo step). */ + reloadFiles(fileIds: string[]): Promise; + /** + * Writes copies of files where the user says (a file, or a folder for several). The files stay + * as they are. False if the user cancelled. + */ + exportFiles(fileIds: string[], options: ExportOptions): Promise; + /** The folders and GPX files in a folder of the open folder (the open folder by default). */ + listFolder(path?: string): Promise; /** * Tells when the engine changed without being asked, because the files on the disk did (the * outcome to apply), when the status of the files changed, and when files were opened with the diff --git a/website/src/lib/engine/engine.ts b/website/src/lib/engine/engine.ts index e6e8bd466..cac566810 100644 --- a/website/src/lib/engine/engine.ts +++ b/website/src/lib/engine/engine.ts @@ -11,6 +11,7 @@ import { categoryIntervals } from '$lib/engine/trackpoint-categories'; import { selectedElementIds, type FileTreeNode } from '$lib/engine/selection-helpers'; import type { RouteAttributes } from 'gpx-wasm'; import { loadBackend, type Backend } from './backend'; +import type { DesktopFiles } from './backend/types'; import { readFileState } from './file-state'; import { StatisticsReader } from './statistics'; import { openStoredFiles } from './storage'; @@ -29,6 +30,7 @@ import { type SelectMode, type SplitType, type StatisticsRequest, + type FolderEntry, type UnsavedChoice, type WorkspaceState, type TrackpointDetails, @@ -554,41 +556,88 @@ class Engine { return this.addedBy((b) => b.desktop!.openFiles()); } - /** - * Opens the folder that the user picks (instead of the one that was open, which is closed like - * `closeFolder` does, `unsaved` being what to do with its unsaved files): its files are opened, - * and it is watched: the files that appear, change or disappear in it are brought in the engine. - * New files are saved in it. Resolves to the ids of the files that were added. - */ - async openFolder(unsaved?: UnsavedChoice): Promise { - return this.addedBy((b) => b.desktop!.openFolder(unsaved)); + /** Opens files of the file system by their path, which get selected. Resolves to the new ids. */ + async openPaths(paths: string[]): Promise { + return this.addedBy((b) => b.desktop!.openPaths(paths)); } /** - * Closes the open folder: it is not watched anymore, and its files are closed. `unsaved` says - * what to do with the ones that have unsaved edits: they are saved first, or their edits are - * discarded (they stay open when it is not said). + * Opens the folder that the user picks (instead of the one that was open, which is closed with + * its files like `closeFiles` does, `unsaved` being what to do with the unsaved ones). The + * folder is watched, and its tree is read with `listFolder`: its files are not opened. */ + async openFolder(unsaved?: UnsavedChoice) { + await this.run((b) => b.desktop!.openFolder(unsaved)); + await this.enqueue(async () => this.refreshWorkspace(this.backend!)); + } + + /** Closes the open folder and all the files: see `closeFiles` for `unsaved`. */ async closeFolder(unsaved?: UnsavedChoice) { await this.run((b) => b.desktop!.closeFolder(unsaved)); await this.enqueue(async () => this.refreshWorkspace(this.backend!)); } /** - * Writes files to the file system, replacing what is there; they are not edited anymore from - * then on. The user is asked where to put the ones that are not on the file system yet. + * Closes files. `unsaved` says what to do with the ones that have unsaved edits: they are saved + * first, or their edits are lost (they stay open when it is not said). */ - async save(fileIds: string[]) { + async closeFiles(fileIds: string[], unsaved?: UnsavedChoice) { + await this.run((b) => b.desktop!.closeFiles(fileIds, unsaved)); + await this.enqueue(async () => this.refreshWorkspace(this.backend!)); + } + + /** + * Writes files to the file system, replacing what is there; they are not edited anymore from + * then on. The user is asked where to put the ones that have no place yet. Resolves to false + * when the user cancelled or the files could not be saved. + */ + async save(fileIds: string[]): Promise { + return this.desktopCall((desktop) => desktop.saveFiles(fileIds)); + } + + /** Writes a file where the user says, which is where it is from then on. */ + async saveAs(fileId: string): Promise { + return this.desktopCall((desktop) => desktop.saveFileAs(fileId)); + } + + /** Writes copies of files where the user says. The files stay as they are. */ + async exportFiles(fileIds: string[], options: ExportOptions): Promise { + return this.desktopCall((desktop) => desktop.exportFiles(fileIds, options)); + } + + /** Reads files again from the file system: their edits are lost (one undo step). */ + async reload(fileIds: string[]) { + await this.run((b) => b.desktop!.reloadFiles(fileIds)); + await this.enqueue(async () => this.refreshWorkspace(this.backend!)); + } + + /** The folders and GPX files in a folder of the open folder (the open folder by default). */ + async listFolder(path?: string): Promise { await this.ready; - await this.enqueue(async () => { - const backend = this.backend!; + return this.enqueue(async () => { try { - await backend.desktop!.saveFiles(fileIds); + return await this.backend!.desktop!.listFolder(path); } catch (error) { - console.error('The files could not be saved', error); + console.error('The folder could not be read', error); + return []; + } + }); + } + + /** Calls the file system of the desktop app, and keeps the status of the files up to date. */ + private async desktopCall(call: (desktop: DesktopFiles) => Promise): Promise { + await this.ready; + return this.enqueue(async () => { + const backend = this.backend!; + let done = false; + try { + done = await call(backend.desktop!); + } catch (error) { + console.error('The files could not be written', error); this._lastError.set(String(error)); } await this.refreshWorkspace(backend); + return done; }); } diff --git a/website/src/lib/engine/types.ts b/website/src/lib/engine/types.ts index c0017aba6..959d60c9c 100644 --- a/website/src/lib/engine/types.ts +++ b/website/src/lib/engine/types.ts @@ -4,7 +4,7 @@ import type { Visibility } from '$lib/engine/file-visibility'; import type { CategoryIntervals } from '$lib/engine/trackpoint-categories'; import type { FileStructure, GlobalStatistics } from 'gpx-wasm'; -export type { FileStatus, UnsavedChoice, WorkspaceState } from './backend/types'; +export type { FileStatus, FolderEntry, UnsavedChoice, WorkspaceState } from './backend/types'; export type { Clipboard, diff --git a/website/src/locales/en.json b/website/src/locales/en.json index bf3fdc1b9..337878a6b 100644 --- a/website/src/locales/en.json +++ b/website/src/locales/en.json @@ -13,6 +13,21 @@ "new_track": "New track", "new_segment": "New segment", "open": "Open...", + "open_folder": "Open folder...", + "save": "Save", + "save_all": "Save all", + "save_as": "Save as...", + "close": "Close", + "close_all": "Close all", + "close_folder": "Close folder", + "reload": "Reload from disk", + "discard": "Discard", + "cancel": "Cancel", + "overwrite": "Overwrite", + "unsaved_title": "Unsaved changes", + "unsaved_description": "These files have changes that are not saved:", + "overwrite_title": "Overwrite changes made elsewhere?", + "overwrite_description": "These files have changes, and were also changed on the disk. Overwrite what is on the disk, or reload it and lose your changes?", "duplicate": "Duplicate", "copy": "Copy", "paste": "Paste",