From b2495d146660bb586c228e9954ed51b87953e53b Mon Sep 17 00:00:00 2001 From: vcoppe Date: Sat, 17 Oct 2026 16:14:17 +0200 Subject: [PATCH] progress --- tauri/src/commands/disk.rs | 9 +- tauri/src/state.rs | 31 ++++++- tauri/src/tests.rs | 84 ++++++++++++++++++- tauri/src/workspace.rs | 62 ++++++++++++++ .../components/explorer/ExplorerTree.svelte | 4 - .../file-list/FileListNodeLabel.svelte | 12 ++- 6 files changed, 186 insertions(+), 16 deletions(-) diff --git a/tauri/src/commands/disk.rs b/tauri/src/commands/disk.rs index 971049e1f..80055373f 100644 --- a/tauri/src/commands/disk.rs +++ b/tauri/src/commands/disk.rs @@ -348,15 +348,12 @@ pub fn export_files( options: ExportArguments, ) -> Result { let options = ExportOptions::from(options); - let files: Vec<(String, Vec)> = state.read(move |engine| { + let files: Vec<(String, Vec)> = state.core(move |core| { 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)?)) + let name = core.workspace.display_name(&core.engine, *id)?; + Some((name, api::export_file(&core.engine, *id, options)?)) }) .collect() }); diff --git a/tauri/src/state.rs b/tauri/src/state.rs index 1e0aa91fb..1b8ca6c3a 100644 --- a/tauri/src/state.rs +++ b/tauri/src/state.rs @@ -201,9 +201,38 @@ impl Core { .then(|| self.workspace.folder.clone()) .flatten() }); - let outcome = f(&mut self.engine); + // what the files it comes from are called, by the name in them (the copies have the same one) + let sources: Vec<(String, String)> = selected + .iter() + .filter_map(|id| { + let structure = self.engine.file_structure(&gpx_engine::FileId(*id))?; + Some(( + structure.name.clone(), + self.workspace.display_name(&self.engine, *id)?, + )) + }) + .collect(); + let mut outcome = f(&mut self.engine); if let Some(dir) = dir { self.workspace.inherit(outcome.added(), &dir); + // what is made in a folder is written there at once, so that the tree shows it. A copy + // is named like the file it is a copy of + let made: Vec<(uuid::Uuid, Option)> = outcome + .added() + .iter() + .filter_map(|id| uuid::Uuid::parse_str(id).ok()) + .filter(|id| self.workspace.dirs.contains_key(id)) + .filter_map(|id| { + let name = &self.engine.file_structure(&gpx_engine::FileId(id))?.name; + let source = sources.iter().find(|(source, _)| source == name); + Some((id, source.map(|(_, display)| display.clone()))) + }) + .collect(); + if let Err(error) = self.workspace.save_new(&self.engine, &made) + && !outcome.has_error() + { + outcome = outcome.merge(api::Outcome::failed(error)); + } } self.keep_workspace(); self.persist(); diff --git a/tauri/src/tests.rs b/tauri/src/tests.rs index 74181678b..82495c4e6 100644 --- a/tauri/src/tests.rs +++ b/tauri/src/tests.rs @@ -380,21 +380,23 @@ fn files_are_opened_closed_and_made_in_a_folder() { 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 + // a new file made in a folder is written there at once, so that the tree shows it let created = app.call( "new_file", json!({ "name": "route", "lng": 4.4, "lat": 50.8, "folder": folder }), ); let new_id = created["added"][0].as_str().unwrap().to_string(); let state = app.call("workspace_state", json!({})); - assert_eq!(state["files"][&new_id]["dirty"], true); - assert!(state["files"][&new_id]["folder"].is_string()); - assert!(state["files"][&new_id].get("path").is_none()); + assert_eq!(state["files"][&new_id]["dirty"], false); + assert!(state["files"][&new_id]["path"].is_string()); + assert!(folder.join("route.gpx").exists()); // the tree needs an open folder assert_eq!(app.call("list_folder", json!({})), json!([])); // closing: what is saved goes, what has edits stays unless it is discarded + app.call("select", json!({ "fileIds": [new_id], "mode": "replace" })); + app.call("metadata", json!({ "name": "edited", "desc": "" })); app.call("close_files", json!({ "fileIds": [id, new_id] })); assert_eq!(app.call("file_order", json!({})), json!([new_id])); app.call( @@ -455,3 +457,77 @@ fn a_file_outside_the_open_folder_is_read_again_when_the_disk_changes() { std::fs::remove_dir_all(folder).unwrap(); std::fs::remove_dir_all(directory).unwrap(); } + +#[test] +fn files_made_from_a_file_of_the_open_folder_are_written_next_to_it_at_once() { + let state = crate::state::AppState::temporary(); + let folder = std::env::temp_dir().join(format!("gpx-tests-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(folder.join("sub")).unwrap(); + std::fs::copy("../gpx-rs/data/simple.gpx", folder.join("sub/track.gpx")).unwrap(); + let sub = folder.join("sub"); + let (copied, new_file) = state.core(move |core| { + core.workspace.open_folder(&mut core.engine, &folder, false); + let opened = core + .workspace + .open_files(&mut core.engine, &[sub.join("track.gpx")], true); + core.keep_workspace(); + assert_eq!(opened.added().len(), 1); + let copy = core.creating(None, false, gpx_engine::api::duplicate); + let fresh = core.creating(None, true, |e| { + gpx_engine::api::new_file(e, "fresh", None, None, None) + }); + // a copy of the file goes next to it, a new file with nothing selected in the selection + // of the file that was just made + (copy, fresh) + }); + let sub = std::env::temp_dir(); + let _ = sub; + assert_eq!(copied.added().len(), 1); + assert_eq!(new_file.added().len(), 1); + let status = state.core(|core| core.workspace_state()); + assert!(status.files.values().all(|file| !file.dirty)); + let paths: Vec = status + .files + .values() + .map(|file| file.path.clone().unwrap()) + .collect(); + // the copy is in the subfolder, named after the file + assert!( + paths.iter().any(|path| path.ends_with("sub/track (2).gpx")), + "{paths:?}" + ); + // the new file went where the selected file is + assert!( + paths.iter().any(|path| path.ends_with("sub/fresh.gpx")), + "{paths:?}" + ); +} + +#[test] +fn the_parts_of_a_split_are_numbered_from_one() { + let state = crate::state::AppState::temporary(); + let folder = std::env::temp_dir().join(format!("gpx-tests-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::copy("../gpx-rs/data/simple.gpx", folder.join("track.gpx")).unwrap(); + let root = folder.clone(); + let outcome = state.core(move |core| { + core.workspace.open_folder(&mut core.engine, &root, false); + core.workspace + .open_files(&mut core.engine, &[root.join("track.gpx")], true); + core.keep_workspace(); + let revision = core.engine.routing().revision; + core.creating(None, false, |e| { + gpx_engine::api::split(e, revision, 2, gpx_engine::SplitType::Files) + }) + }); + assert_eq!(outcome.added().len(), 2, "{:?}", outcome.added()); + let mut names: Vec = std::fs::read_dir(&folder) + .unwrap() + .flatten() + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + // the file stays, its two parts are numbered from 1 + assert_eq!(names, ["track (1).gpx", "track (2).gpx", "track.gpx"]); + std::fs::remove_dir_all(folder).unwrap(); +} diff --git a/tauri/src/workspace.rs b/tauri/src/workspace.rs index caeae4332..da4fb8f06 100644 --- a/tauri/src/workspace.rs +++ b/tauri/src/workspace.rs @@ -193,6 +193,16 @@ pub fn free_path(folder: &Path, name: &str, taken: impl Fn(&Path) -> bool) -> Pa path } +/// Where to put one of several files that are called the same in `folder`: `name (1)`, or else the +/// first number that is free. +pub fn numbered_path(folder: &Path, name: &str, taken: impl Fn(&Path) -> bool) -> PathBuf { + let name = file_name(name); + (1..) + .map(|number| folder.join(format!("{name} ({number}).gpx"))) + .find(|path| !path.exists() && !taken(path)) + .expect("there are enough numbers") +} + impl Workspace { pub fn path() -> &'static str { "workspace.json" @@ -595,6 +605,58 @@ impl Workspace { Ok(()) } + /// Writes the new files among `files` in their folder, each under the name that is given (the + /// name in the file when there is none). A name that is the one of something else is changed + /// (`name (2)`). Files that would be called the same, like the parts of a split, are numbered + /// from 1 (`name (1)`, `name (2)`). + pub fn save_new( + &mut self, + engine: &Engine, + files: &[(Uuid, Option)], + ) -> Result<(), String> { + let names: Vec> = files + .iter() + .map(|(id, name)| { + let dir = self.dirs.get(id)?.clone(); + let structure = engine.file_structure(&FileId(*id))?; + Some(( + *id, + dir, + name.clone().unwrap_or_else(|| structure.name.clone()), + )) + }) + .collect(); + for entry in &names { + let Some((id, dir, name)) = entry else { + continue; + }; + let together = names + .iter() + .flatten() + .filter(|(_, other_dir, other)| { + other_dir == dir && file_name(other) == file_name(name) + }) + .count() + > 1; + let path = if together { + numbered_path(dir, name, |p| self.is_taken(p)) + } else { + free_path(dir, name, |p| self.is_taken(p)) + }; + self.write_file(engine, *id, &path)?; + } + Ok(()) + } + + /// The name of a file as the user knows it: the name of the file on the disk, without its + /// extension, or else the name in the file. + pub fn display_name(&self, engine: &Engine, id: Uuid) -> Option { + match self.links.get(&id) { + Some(link) => Some(stem(&link.path)), + None => Some(engine.file_structure(&FileId(id))?.name.clone()), + } + } + /// 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)) diff --git a/website/src/lib/components/explorer/ExplorerTree.svelte b/website/src/lib/components/explorer/ExplorerTree.svelte index cf2f770d0..4f1576ff3 100644 --- a/website/src/lib/components/explorer/ExplorerTree.svelte +++ b/website/src/lib/components/explorer/ExplorerTree.svelte @@ -61,7 +61,6 @@ {:else} {@const id = fileIdAt($workspace, entry.path)} {@const selected = id !== undefined && selectedFileIds($selection).includes(id)} - {@const dirty = id !== undefined && $workspace?.files[id]?.dirty === true} {/if} {/each} diff --git a/website/src/lib/components/file-list/FileListNodeLabel.svelte b/website/src/lib/components/file-list/FileListNodeLabel.svelte index 459726a6b..456092d1e 100644 --- a/website/src/lib/components/file-list/FileListNodeLabel.svelte +++ b/website/src/lib/components/file-list/FileListNodeLabel.svelte @@ -50,7 +50,7 @@ label: string | undefined; } = $props(); - const { selection, clipboard, canPaste } = engine; + const { selection, clipboard, canPaste, workspace } = engine; let orientation = getContext<'vertical' | 'horizontal'>('orientation'); let embedding = getContext('embedding'); @@ -92,6 +92,8 @@ selectedElementIds($selection)[0]?.ids[0] === elementId(node) ); + // the file is not the one on the disk (the desktop app) + let unsaved = $derived(node.type === 'file' && $workspace?.files[node.fileId]?.dirty === true); let hidden = $derived(isHidden(fileState, elementId(node))); // cut elements are greyed until they are pasted let isCut = $derived($clipboard?.cut === true && isInClipboard($clipboard, node)); @@ -187,6 +189,14 @@ > {label} + {#if unsaved} + + {/if} {#if hidden}