From 3ef2bf135a8f14976b7bd4ce6583e7bc0e511beb Mon Sep 17 00:00:00 2001 From: vcoppe Date: Sat, 17 Oct 2026 23:23:29 +0200 Subject: [PATCH] explorer actions --- Cargo.lock | 25 ++ tauri/Cargo.toml | 1 + tauri/src/commands/disk.rs | 105 +++++- tauri/src/commands/edit.rs | 25 +- tauri/src/commands/system.rs | 10 + tauri/src/lib.rs | 5 + tauri/src/state.rs | 12 + tauri/src/tests.rs | 55 +++ tauri/src/workspace.rs | 258 +++++++++++++ website/src/lib/components/Menu.svelte | 3 +- .../lib/components/explorer/Explorer.svelte | 56 ++- .../components/explorer/ExplorerRow.svelte | 341 ++++++++++++++++++ .../components/explorer/ExplorerTree.svelte | 151 ++++++-- .../src/lib/components/explorer/actions.ts | 24 ++ .../lib/components/explorer/state.svelte.ts | 43 +++ .../file-list/FileListNodeLabel.svelte | 5 + website/src/lib/engine/backend/tauri.ts | 8 +- website/src/lib/engine/backend/types.ts | 28 +- website/src/lib/engine/engine.ts | 84 ++++- website/src/locales/en.json | 9 + 20 files changed, 1183 insertions(+), 65 deletions(-) create mode 100644 website/src/lib/components/explorer/ExplorerRow.svelte create mode 100644 website/src/lib/components/explorer/actions.ts diff --git a/Cargo.lock b/Cargo.lock index 54e6eebea..162d5b9ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1432,6 +1432,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-opener", "tauri-plugin-single-instance", + "trash", "uuid", ] @@ -4294,6 +4295,24 @@ dependencies = [ "once_cell", ] +[[package]] +name = "trash" +version = "5.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be89b3fe156965d29ac4f8522f3a640c655affdd9f21cb4f36857f0c92c00317" +dependencies = [ + "chrono", + "libc", + "log", + "objc2", + "objc2-foundation", + "once_cell", + "percent-encoding", + "scopeguard", + "urlencoding", + "windows", +] + [[package]] name = "tray-icon" version = "0.25.1" @@ -4370,6 +4389,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.6.0" diff --git a/tauri/Cargo.toml b/tauri/Cargo.toml index a9d821760..89663d709 100644 --- a/tauri/Cargo.toml +++ b/tauri/Cargo.toml @@ -26,4 +26,5 @@ notify-debouncer-mini = "0.7.0" tauri-plugin-dialog = "2.8.2" tauri-plugin-single-instance = "2.5.3" tauri-plugin-opener = "2" +trash = "5" diff --git a/tauri/src/commands/disk.rs b/tauri/src/commands/disk.rs index 16c41533e..3b3c1bba6 100644 --- a/tauri/src/commands/disk.rs +++ b/tauri/src/commands/disk.rs @@ -14,6 +14,7 @@ use tauri::{AppHandle, Runtime, State}; use tauri_plugin_dialog::{DialogExt, FilePath}; use uuid::Uuid; +use crate::commands::edit; use crate::state::AppState; use crate::workspace::{Entry, Unsaved, WorkspaceState, free_path}; @@ -40,16 +41,31 @@ pub fn open_files(app: AppHandle, state: State) -> api: let Some(picked) = picked else { return api::Outcome::failed_none(); }; - open_paths(state, paths(picked)) + open_paths(state, paths(picked), None) } /// Opens files that are on the disk, like the ones of the tree of the open folder. They get selected. +/// With `preview`, a single file is open as a preview (a single click in the tree, see +/// `Workspace::open_preview`); otherwise they are open for good, even the preview. #[tauri::command(async)] -pub fn open_paths(state: State, paths: Vec) -> api::Outcome { +pub fn open_paths( + state: State, + paths: Vec, + preview: Option, +) -> api::Outcome { state.core(move |core| { - let outcome = core.workspace.open_files(&mut core.engine, &paths, true); + let outcome = match paths.as_slice() { + [path] if preview == Some(true) => core.workspace.open_preview(&mut core.engine, path), + _ => { + let outcome = core.workspace.open_files(&mut core.engine, &paths, true); + let ids = core.workspace.ids_at(&paths); + core.workspace.keep(&ids); + outcome + } + }; core.keep_workspace(); core.persist(); + core.announce(None); outcome }) } @@ -391,3 +407,86 @@ pub fn export_files( } } } + +/// Makes a folder in a folder of the open folder, called `name` (numbered if that is taken). Gives +/// its path. +#[tauri::command(async)] +pub fn create_folder( + state: State, + parent: PathBuf, + name: String, +) -> Result { + state.core(move |core| core.workspace.create_folder(&parent, &name)) +} + +/// Renames a file or a folder of the open folder. An open file is renamed like in its info (it is +/// selected), which can be undone; the rest is renamed on the disk, and the open files that are in +/// a folder that is renamed follow. +#[tauri::command(async)] +pub fn rename_path( + state: State, + path: PathBuf, + name: String, +) -> Result { + state.core(move |core| { + if let Some(id) = core.workspace.linked(&path) { + let selected = api::select(&mut core.engine, &[id], gpx_engine::SelectMode::Replace); + let desc = core + .engine + .file_structure(&gpx_engine::FileId(id)) + .and_then(|structure| structure.desc.clone()) + .unwrap_or_default(); + return Ok(selected.merge(edit::rename_selection(core, name, desc))); + } + core.workspace.rename_path(&path, &name)?; + core.keep_workspace(); + core.announce(None); + Ok(api::Outcome::failed_none()) + }) +} + +/// Moves files and folders of the open folder into a folder of it. The open files follow. +#[tauri::command(async)] +pub fn move_paths( + state: State, + paths: Vec, + folder: PathBuf, +) -> Result<(), String> { + state.core(move |core| { + let result = core.workspace.move_paths(&paths, &folder); + core.keep_workspace(); + core.announce(None); + result + }) +} + +/// Moves files and folders of the open folder to the trash. The open files that were among them +/// are closed, unless they have edits: they stay open, as gone from the disk. +#[tauri::command(async)] +pub fn delete_paths(state: State, paths: Vec) -> Result { + state.core(move |core| { + let outcome = core + .workspace + .delete_paths(&mut core.engine, &paths, to_trash)?; + core.keep_workspace(); + core.persist(); + core.announce(None); + Ok(outcome) + }) +} + +/// Moves files and folders to the trash of the system. +fn to_trash(paths: &[PathBuf]) -> Result<(), String> { + #[allow(unused_mut)] + let mut trash = trash::TrashContext::default(); + // without asking the Finder, which needs a permission + #[cfg(target_os = "macos")] + { + use trash::macos::{DeleteMethod, TrashContextExtMacos}; + trash.set_delete_method(DeleteMethod::NsFileManager); + } + trash.delete_all(paths).map_err(|error| { + eprintln!("the files cannot be deleted: {error}"); + crate::workspace::DELETE_FAILED.to_owned() + }) +} diff --git a/tauri/src/commands/edit.rs b/tauri/src/commands/edit.rs index 8f2a31fc2..fed73b48c 100644 --- a/tauri/src/commands/edit.rs +++ b/tauri/src/commands/edit.rs @@ -5,24 +5,27 @@ use gpx_engine::{CleanType, FileId, MergeType, Selection, SplitType, api}; use tauri::State; use uuid::Uuid; -use crate::state::AppState; +use crate::state::{AppState, Core}; /// Names and describes the selected files or tracks. A file on the disk is renamed with it (and back /// when it is undone): its name is one that a file can have, and not the one of another file. #[tauri::command(async)] pub fn metadata(state: State, name: String, desc: String) -> api::Outcome { - state.core(move |core| { - let mut name = name; - if let Selection::File { file_ids } = core.engine.selection() { - for FileId(id) in file_ids.clone() { - name = core.workspace.valid_name(id, &name); - if let Err(error) = core.workspace.renamed_path(id, &name) { - return api::Outcome::failed(error); - } + state.core(move |core| rename_selection(core, name, desc)) +} + +/// See `metadata`. +pub fn rename_selection(core: &mut Core, name: String, desc: String) -> api::Outcome { + let mut name = name; + if let Selection::File { file_ids } = core.engine.selection() { + for FileId(id) in file_ids.clone() { + name = core.workspace.valid_name(id, &name); + if let Err(error) = core.workspace.renamed_path(id, &name) { + return api::Outcome::failed(error); } } - core.edit(move |e| api::metadata(e, &name, &desc)) - }) + } + core.edit(move |e| api::metadata(e, &name, &desc)) } #[tauri::command(async)] diff --git a/tauri/src/commands/system.rs b/tauri/src/commands/system.rs index f7937a8b4..7627626fc 100644 --- a/tauri/src/commands/system.rs +++ b/tauri/src/commands/system.rs @@ -1,5 +1,7 @@ //! What the app asks of the system, outside of the files. +use std::path::PathBuf; + use tauri::{AppHandle, Runtime}; use tauri_plugin_opener::OpenerExt; @@ -16,3 +18,11 @@ pub fn open_url(app: AppHandle, url: String) -> Result<(), String .open_url(url, None::<&str>) .map_err(|error| error.to_string()) } + +/// Shows a file or a folder in the file manager of the system (the Finder...). +#[tauri::command(async)] +pub fn reveal_path(app: AppHandle, path: PathBuf) -> Result<(), String> { + app.opener() + .reveal_item_in_dir(path) + .map_err(|error| error.to_string()) +} diff --git a/tauri/src/lib.rs b/tauri/src/lib.rs index 679ab02a4..cd5900ba1 100644 --- a/tauri/src/lib.rs +++ b/tauri/src/lib.rs @@ -194,8 +194,13 @@ fn register_commands(builder: tauri::Builder) -> tauri::Bu disk::reload_files, disk::export_files, disk::list_folder, + disk::create_folder, + disk::rename_path, + disk::move_paths, + disk::delete_paths, // the system system::open_url, + system::reveal_path, ]) } diff --git a/tauri/src/state.rs b/tauri/src/state.rs index e021b0210..2c37f7bdc 100644 --- a/tauri/src/state.rs +++ b/tauri/src/state.rs @@ -90,6 +90,8 @@ impl Core { return; } let outcome = self.workspace.open_files(&mut self.engine, paths, true); + let ids = self.workspace.ids_at(paths); + self.workspace.keep(&ids); self.keep_workspace(); self.persist(); (self.emit)("files-opened", to_json(&outcome)); @@ -101,7 +103,17 @@ impl Core { /// could not be renamed. fn editing(&mut self, f: impl FnOnce(&mut Engine) -> T) -> (T, Vec) { let names = self.workspace.names(&self.engine); + let preview = self.workspace.preview; + let preview_before = preview.and_then(|id| crate::workspace::signature(&self.engine, id)); let result = f(&mut self.engine); + // the preview is open for good once something is done to it + if let Some(id) = preview + && crate::workspace::signature(&self.engine, id) != preview_before + { + self.workspace.keep(&[id]); + self.keep_workspace(); + self.announce(None); + } let (renamed, errors) = self.workspace.follow_names(&mut self.engine, &names); if renamed { self.keep_workspace(); diff --git a/tauri/src/tests.rs b/tauri/src/tests.rs index 6958b8250..91236e448 100644 --- a/tauri/src/tests.rs +++ b/tauri/src/tests.rs @@ -670,3 +670,58 @@ fn a_new_file_written_under_another_name_takes_it() { assert_eq!(app.call("can_undo", json!({})), false); std::fs::remove_dir_all(folder).unwrap(); } + +#[test] +fn a_file_open_as_a_preview_is_replaced_until_it_is_kept_or_edited() { + let app = App::new(); + let folder = folder_with(&["a", "b", "c", "d"]); + let path = |name: &str| folder.join(format!("{name}.gpx")); + let open = |name: &str, preview: bool| { + app.call( + "open_paths", + json!({ "paths": [path(name)], "preview": preview }), + ) + }; + let state = |app: &App| app.call("workspace_state", json!({}))["files"].clone(); + let names = |app: &App| -> Vec { + let order = app.call("file_order", json!({})); + order + .as_array() + .unwrap() + .iter() + .map(|id| name_of(app, id.as_str().unwrap())) + .collect() + }; + + let a = open("a", true)["added"][0].as_str().unwrap().to_string(); + assert_eq!(state(&app)[&a]["preview"], true); + // the next preview replaces it + let b = open("b", true)["added"][0].as_str().unwrap().to_string(); + assert_eq!(names(&app), ["b"]); + assert_eq!(state(&app)[&b]["preview"], true); + // opened for good (a double click), it stays when another one is previewed + open("b", false); + assert_eq!(state(&app)[&b]["preview"], false); + let c = open("c", true)["added"][0].as_str().unwrap().to_string(); + assert_eq!(names(&app), ["b", "c"]); + // something done to the preview keeps it too + app.call("style", json!({ "color": "#ff0000" })); + assert_eq!(state(&app)[&c]["preview"], false); + open("d", true); + assert_eq!(names(&app), ["b", "c", "d"]); + // several files at once are open for good + app.call( + "open_paths", + json!({ "paths": [path("a"), path("d")], "preview": true }), + ); + assert_eq!(names(&app), ["b", "c", "d", "a"]); + let files = state(&app); + assert!( + files + .as_object() + .unwrap() + .values() + .all(|file| file["preview"] == false) + ); + std::fs::remove_dir_all(folder).unwrap(); +} diff --git a/tauri/src/workspace.rs b/tauri/src/workspace.rs index 11f259a39..23ca5077b 100644 --- a/tauri/src/workspace.rs +++ b/tauri/src/workspace.rs @@ -63,6 +63,8 @@ pub fn signature(engine: &Engine, id: Uuid) -> Option { pub const NAME_TAKEN: &str = "menu.metadata.name_taken"; /// A file cannot be renamed on the disk. pub const RENAME_FAILED: &str = "menu.metadata.rename_failed"; +/// Files cannot be moved to the trash. +pub const DELETE_FAILED: &str = "menu.explorer_menu.delete_failed"; /// What to do with the files that have unsaved edits when they are closed. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] @@ -102,6 +104,10 @@ pub struct Workspace { /// The folder where the files that are not on the disk yet are written. #[serde(default)] pub dirs: BTreeMap, + /// The file that is open as a preview (a single click in the tree): the next one replaces it, + /// until it is edited or opened for good (see `open_preview`). + #[serde(default)] + pub preview: Option, } /// What the frontend shows of a file. @@ -121,6 +127,8 @@ pub struct FileStatus { pub replaced: bool, /// The file on the disk is gone. pub missing: bool, + /// The file is open as a preview, which the next one replaces. + pub preview: bool, } #[derive(Debug, Serialize)] @@ -323,6 +331,7 @@ impl Workspace { dirty: self.is_dirty(engine, *id), replaced: link.is_some_and(|link| link.replaced), missing: link.is_some_and(|link| link.missing), + preview: self.preview == Some(*id), }, ) }) @@ -358,6 +367,7 @@ impl Workspace { let open = |id: &Uuid| engine.file_structure(&FileId(*id)).is_some(); self.links.retain(|id, _| open(id)); self.dirs.retain(|id, _| open(id)); + self.preview = self.preview.filter(open); } /// Opens files from the disk: the ones that are not open yet are added, and `select` selects @@ -410,6 +420,35 @@ impl Workspace { outcome } + /// Opens a file as a preview, which is selected: it replaces the file that was the preview, + /// which is closed (it has no edits, it would not be the preview anymore). It is the preview + /// until it is edited or opened for good (`keep`). A file that is open already is selected. + pub fn open_preview(&mut self, engine: &mut Engine, path: &Path) -> api::Outcome { + let path = canonical(path); + if self.id_of(&path).is_some() { + return self.open_files(engine, &[path], true); + } + let closed = match self.preview.take() { + Some(previous) => self.close_files(engine, &[previous], false), + None => api::Outcome::failed_none(), + }; + let opened = self.open_files(engine, std::slice::from_ref(&path), true); + self.preview = self.id_of(&path); + closed.merge(opened) + } + + /// The files are open for good: none of them is the preview anymore. + pub fn keep(&mut self, ids: &[Uuid]) { + if self.preview.is_some_and(|preview| ids.contains(&preview)) { + self.preview = None; + } + } + + /// The open files that are linked to these paths. + pub fn ids_at(&self, paths: &[PathBuf]) -> Vec { + paths.iter().filter_map(|path| self.linked(path)).collect() + } + /// 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 { @@ -506,6 +545,7 @@ impl Workspace { self.links.remove(id); self.dirs.remove(id); } + self.keep(&closing); api::sync_files(engine, &[], &closing) } @@ -910,6 +950,153 @@ impl Workspace { }); Ok(entries) } + + // What the explorer does to the tree of the open folder. The paths are in the open folder, and + // the open files follow what happens to their file. None of it is undone. + + /// A folder of the tree: the open folder or a folder in it. + fn tree_folder(&self, path: &Path) -> Result { + let path = canonical(path); + if !self.in_folder(&path) || !path.is_dir() { + return Err(format!( + "{} is not a folder of the open folder", + path.display() + )); + } + Ok(path) + } + + /// Something in the tree, which can be renamed, moved or deleted: not the open folder itself. + fn tree_item(&self, path: &Path) -> Result { + let path = canonical(path); + if !self.in_folder(&path) || self.folder.as_ref() == Some(&path) || !path.exists() { + return Err(format!("{} is not in the open folder", path.display())); + } + Ok(path) + } + + /// Whether `to` can be the new place of `from`: nothing else is there (the same file under + /// another case is not something else). + fn free_for(from: &Path, to: &Path) -> Result<(), String> { + if to.exists() && !same_file(to, from) { + return Err(NAME_TAKEN.to_owned()); + } + Ok(()) + } + + /// The open file whose file is at `path`. + pub fn linked(&self, path: &Path) -> Option { + self.id_of(&canonical(path)) + } + + /// The places of the files follow what was at `from`, which is now at `to` (a file, or a + /// folder with what is in it). + fn moved(&mut self, from: &Path, to: &Path) { + let follow = |path: &mut PathBuf| { + if let Ok(rest) = path.strip_prefix(from) { + *path = if rest.as_os_str().is_empty() { + to.to_path_buf() + } else { + to.join(rest) + }; + } + }; + for link in self.links.values_mut() { + follow(&mut link.path); + } + for dir in self.dirs.values_mut() { + follow(dir); + } + } + + /// Makes a folder in a folder of the tree, called `name` (with a number if that is taken: `name + /// 2`). Gives its path. + pub fn create_folder(&self, parent: &Path, name: &str) -> Result { + let parent = self.tree_folder(parent)?; + let name = file_name(name); + let path = (1..) + .map(|number| match number { + 1 => parent.join(&name), + _ => parent.join(format!("{name} {number}")), + }) + .find(|path| !path.exists()) + .expect("there are enough numbers"); + fs::create_dir(&path).map_err(|error| format!("{}: {error}", path.display()))?; + Ok(path) + } + + /// Renames a file or a folder of the tree that is not an open file (which is renamed in the + /// engine, see `follow_names`). A file keeps its extension. Gives the new path. + pub fn rename_path(&mut self, path: &Path, name: &str) -> Result { + let path = self.tree_item(path)?; + let name = file_name(name); + let new_name = match path.extension().filter(|_| path.is_file()) { + Some(extension) => format!("{name}.{}", extension.to_string_lossy()), + None => name, + }; + let to = path.with_file_name(new_name); + if to == path { + return Ok(path); + } + Self::free_for(&path, &to)?; + fs::rename(&path, &to).map_err(|error| { + eprintln!("{} cannot be renamed: {error}", path.display()); + RENAME_FAILED.to_owned() + })?; + self.moved(&path, &to); + Ok(to) + } + + /// Moves files and folders of the tree into a folder of it. What is there already stays where + /// it is; stops at the first that cannot be moved. + pub fn move_paths(&mut self, paths: &[PathBuf], folder: &Path) -> Result<(), String> { + let folder = self.tree_folder(folder)?; + for path in paths { + let path = self.tree_item(path)?; + if path.parent() == Some(folder.as_path()) { + continue; + } + if folder.starts_with(&path) { + return Err(format!("{} cannot go into itself", path.display())); + } + let to = folder.join(path.file_name().unwrap_or_default()); + Self::free_for(&path, &to)?; + fs::rename(&path, &to).map_err(|error| { + eprintln!("{} cannot be moved: {error}", path.display()); + RENAME_FAILED.to_owned() + })?; + self.moved(&path, &to); + } + Ok(()) + } + + /// Deletes files and folders of the tree with `delete` (to the trash). The open files that + /// were among them are closed if they have no edits, the others stay open, as gone from the + /// disk. Says what changed in the engine. + pub fn delete_paths( + &mut self, + engine: &mut Engine, + paths: &[PathBuf], + delete: impl FnOnce(&[PathBuf]) -> Result<(), String>, + ) -> Result { + let paths = paths + .iter() + .map(|path| self.tree_item(path)) + .collect::, _>>()?; + delete(&paths)?; + let gone: Vec = self + .links + .iter() + .filter(|(_, link)| paths.iter().any(|path| link.path.starts_with(path))) + .map(|(id, _)| *id) + .collect(); + let closed = self.close_files(engine, &gone, false); + let flagged = self.reconcile(engine, Some(&paths)); + Ok(match flagged { + Some(outcome) => closed.merge(outcome), + None => closed, + }) + } } #[cfg(test)] @@ -1151,6 +1338,77 @@ mod tests { assert_eq!(fx.workspace.folder.as_deref(), Some(folder.as_path())); } + #[test] + fn folders_are_made_renamed_and_moved_and_the_open_files_follow() { + let mut fx = Fixture::new(); + let a = fx.write("a.gpx", "a"); + fx.open_folder(); + let id = fx.ids()[0]; + let root = fx.folder.clone(); + let path_of = |fx: &Fixture| PathBuf::from(fx.status(id).path.unwrap()); + + let first = fx.workspace.create_folder(&root, "New folder").unwrap(); + let second = fx.workspace.create_folder(&root, "New folder").unwrap(); + assert_eq!(first, root.join("New folder")); + assert_eq!(second, root.join("New folder 2")); + + fx.workspace.move_paths(&[a], &first).unwrap(); + assert_eq!(path_of(&fx), first.join("a.gpx")); + assert!(first.join("a.gpx").exists() && !fx.status(id).dirty); + + let trips = fx.workspace.rename_path(&first, "Trips").unwrap(); + assert_eq!(path_of(&fx), root.join("Trips/a.gpx")); + // a folder does not go into itself, nor onto something else + assert!( + fx.workspace + .move_paths(std::slice::from_ref(&trips), &trips) + .is_err() + ); + assert_eq!( + fx.workspace.rename_path(&trips, "New folder 2"), + Err(NAME_TAKEN.to_owned()) + ); + fx.workspace.move_paths(&[trips], &second).unwrap(); + assert_eq!(path_of(&fx), second.join("Trips/a.gpx")); + + // a file keeps its extension + let closed = fx.write("c.gpx", "c"); + assert_eq!( + fx.workspace.rename_path(&closed, "d").unwrap(), + root.join("d.gpx") + ); + // the open folder and what is not in it are left alone + assert!(fx.workspace.rename_path(&root, "x").is_err()); + let elsewhere = canonical(&std::env::temp_dir()); + assert!(fx.workspace.rename_path(&elsewhere, "x").is_err()); + assert!(fx.workspace.create_folder(&elsewhere, "x").is_err()); + } + + #[test] + fn deleted_files_are_closed_unless_they_have_edits() { + let mut fx = Fixture::new(); + let a = fx.write("a.gpx", "a"); + let b = fx.write("b.gpx", "b"); + fx.open_folder(); + let (id_a, id_b) = (fx.id_named("a"), fx.id_named("b")); + api::select(&mut fx.engine, &[id_b], SelectMode::Replace); + assert!(!api::style(&mut fx.engine, Some("#ff0000"), None, None).has_error()); + + let outcome = fx + .workspace + .delete_paths(&mut fx.engine, &[a.clone(), b.clone()], |paths| { + paths.iter().for_each(|path| fs::remove_file(path).unwrap()); + Ok(()) + }) + .unwrap(); + assert!(outcome.changed); + assert_eq!(fx.ids(), [id_b]); + assert!(!fx.ids().contains(&id_a)); + let status = fx.status(id_b); + assert!(status.dirty && status.missing); + assert!(!a.exists() && !b.exists()); + } + #[test] fn the_tree_of_the_folder_shows_folders_first_and_only_gpx_files() { let mut fx = Fixture::new(); diff --git a/website/src/lib/components/Menu.svelte b/website/src/lib/components/Menu.svelte index 224c75c81..37778bdcd 100644 --- a/website/src/lib/components/Menu.svelte +++ b/website/src/lib/components/Menu.svelte @@ -47,6 +47,7 @@ Maximize, Maximize2, Minimize2, + FolderX, } from '@lucide/svelte'; import { map } from '$lib/components/map/map'; import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte'; @@ -215,7 +216,7 @@ - + {i18n._('menu.close_folder')} {:else} diff --git a/website/src/lib/components/explorer/Explorer.svelte b/website/src/lib/components/explorer/Explorer.svelte index 0cf0dae67..820988efb 100644 --- a/website/src/lib/components/explorer/Explorer.svelte +++ b/website/src/lib/components/explorer/Explorer.svelte @@ -4,7 +4,9 @@ import { CollapsibleTreeState } from '$lib/components/collapsible-tree/utils.svelte'; import { ScrollArea } from '$lib/components/ui/scroll-area/index'; import ExplorerTree from './ExplorerTree.svelte'; - import { clickedInExplorer } from './state.svelte'; + import ExplorerRow from './ExplorerRow.svelte'; + import { openFiles } from './actions'; + import { clickedInExplorer, explorerSelection, renaming } from './state.svelte'; import { i18n } from '$lib/i18n/i18n.svelte'; import { engine } from '$lib/engine'; import { selectedFileIds } from '$lib/engine/selection-helpers'; @@ -27,12 +29,13 @@ let container: HTMLElement | undefined = $state(); - // the file that is selected, if it is in the folder - let selectedPath = $derived.by(() => { - const id = selectedFileIds($selection)[0]; - const path = id === undefined ? undefined : $workspace?.files[id]?.path; - return subfolder(folder, path) === undefined ? undefined : path; - }); + // the files that are selected, if they are in the folder + let selectedPaths = $derived( + selectedFileIds($selection) + .map((id) => $workspace?.files[id]?.path) + .filter((path): path is string => subfolder(folder, path) !== undefined) + ); + let selectedPath = $derived(selectedPaths[0]); $effect(() => { // not what the click that was just made depends on, only the selection and the folder @@ -41,11 +44,30 @@ return; } clickedInExplorer.path = undefined; + // what is selected elsewhere (the file list, the map) is selected here + explorerSelection.paths = untrack(() => selectedPaths); + explorerSelection.anchor = selectedPath; if (folder && selectedPath !== undefined) { return reveal(folder, selectedPath); } }); + /** Enter opens the selected files for good. */ + function onkeydown(event: KeyboardEvent) { + if (renaming.path !== undefined || !container || event.key !== 'Enter') return; + const kinds = new Map( + [...container.querySelectorAll('[data-explorer-path]')].map((row) => [ + row.dataset.explorerPath!, + row.dataset.kind, + ]) + ); + const files = explorerSelection.paths.filter((path) => kinds.get(path) === 'file'); + if (files.length > 0) { + openFiles(files, false); + event.preventDefault(); + } + } + /** * Opens the folders that hold a file and scrolls to the file, if it is not in view already. Its * row may not be there yet, because the content of a folder is read when it is opened. @@ -83,16 +105,28 @@
{i18n._('menu.explorer')}
-
+ +
{#key folder} {#snippet trigger()} - - {folderName(folder)} - + + trees && (trees.root.get('root.folder').current = true)} + /> {/snippet} {#snippet content(opened)} diff --git a/website/src/lib/components/explorer/ExplorerRow.svelte b/website/src/lib/components/explorer/ExplorerRow.svelte new file mode 100644 index 000000000..c7b5a639f --- /dev/null +++ b/website/src/lib/components/explorer/ExplorerRow.svelte @@ -0,0 +1,341 @@ + + + { + if (open && !selected && kind !== 'root') { + select([path]); + } + }} +> + + +
+ {@render icon?.()} + {#if editing} + event.stopPropagation()} + onclick={(event) => event.stopPropagation()} + onblur={(event) => commit(event.currentTarget.value)} + /> + {:else} + {name} + {/if} +
+
+ + { + if (renaming.path !== undefined || creating.parent !== undefined) { + event.preventDefault(); + } + }} + > + {#if kind !== 'file'} + create('file')}> + + {i18n._('menu.explorer_menu.new_file')} + + create('folder')}> + + {i18n._('menu.explorer_menu.new_folder')} + + + {:else} + + + {i18n._('menu.explorer_menu.open')} + + {#if openIds().length > 0} + closeFiles(openIds())}> + + {i18n._('menu.close')} + + {/if} + + {/if} + {#if kind !== 'root'} + (renaming.path = path)} + > + + {i18n._('menu.explorer_menu.rename')} + + + + {i18n._('menu.delete')} + + + {/if} + engine.revealPath(path)}> + + {i18n._(isMac() ? 'menu.explorer_menu.reveal_finder' : 'menu.explorer_menu.reveal')} + + {#if kind === 'root'} + + + + {i18n._('menu.close_folder')} + + {/if} + +
diff --git a/website/src/lib/components/explorer/ExplorerTree.svelte b/website/src/lib/components/explorer/ExplorerTree.svelte index c1b371c94..e014baf95 100644 --- a/website/src/lib/components/explorer/ExplorerTree.svelte +++ b/website/src/lib/components/explorer/ExplorerTree.svelte @@ -1,15 +1,18 @@ + +{#snippet named(kind: 'file' | 'folder')} +
+ {#if kind === 'file'} + + {:else} + + {/if} + input.focus()} + {onkeydown} + onblur={(event) => create(event.currentTarget.value, false)} + /> +
+{/snippet} + +{#if pending === 'folder'} + {@render named('folder')} +{/if} {#each entries as entry (entry.path)} + {#if pending === 'file' && entry === firstFile} + {@render named('file')} + {/if} {#if entry.isDir} - + {#snippet trigger()} - {entry.name} + nodes[entry.path]?.openNode()} + /> {/snippet} {#snippet content(opened)} @@ -67,20 +149,23 @@ {/snippet} {:else} - {@const id = fileIdAt($workspace, entry.path)} - {@const selected = id !== undefined && selectedFileIds($selection).includes(id)} - + {#snippet icon()} + + {/snippet} +
{/if} {/each} +{#if pending === 'file' && firstFile === undefined} + {@render named('file')} +{/if} diff --git a/website/src/lib/components/explorer/actions.ts b/website/src/lib/components/explorer/actions.ts new file mode 100644 index 000000000..f78ae59dc --- /dev/null +++ b/website/src/lib/components/explorer/actions.ts @@ -0,0 +1,24 @@ +import { get } from 'svelte/store'; +import { toast } from 'svelte-sonner'; +import { engine } from '$lib/engine'; +import { i18n } from '$lib/i18n/i18n.svelte'; +import { boundsManager } from '$lib/state/bounds'; + +/** + * Opens files of the tree: as a preview (a single click), which the next preview replaces, or for + * good. The map fits the files that were opened. + */ +export async function openFiles(paths: string[], preview: boolean) { + const ids = await engine.openPaths(paths, preview); + if (ids.length > 0) { + boundsManager.fitBoundsOnLoad(ids); + } +} + +/** Tells why what the explorer did failed (a key of the dictionaries, or a message). */ +export function showError() { + const error = get(engine.lastError); + if (error !== undefined && error !== 'nothing to do') { + toast.error(i18n._(error, error)); + } +} diff --git a/website/src/lib/components/explorer/state.svelte.ts b/website/src/lib/components/explorer/state.svelte.ts index 05976e966..46c3fe2c2 100644 --- a/website/src/lib/components/explorer/state.svelte.ts +++ b/website/src/lib/components/explorer/state.svelte.ts @@ -1,2 +1,45 @@ /** The file that the user just selected by clicking it in the explorer, until the selection changes. */ export const clickedInExplorer: { path: string | undefined } = $state({ path: undefined }); + +/** + * What is selected in the explorer (files and folders, by path), which the menu and dragging act + * on, and where a range selected with shift begins. + */ +export const explorerSelection: { paths: string[]; anchor: string | undefined } = $state({ + paths: [], + anchor: undefined, +}); + +/** The file or folder whose name is being edited in the explorer. */ +export const renaming: { path: string | undefined } = $state({ path: undefined }); + +/** + * The file or folder that is being made in a folder (`parent`) of the explorer: it is only made once + * the user gave it a name. + */ +export const creating: { parent: string | undefined; kind: 'file' | 'folder' } = $state({ + parent: undefined, + kind: 'file', +}); + +/** What is dragged in the explorer, and the folder it would be dropped in. */ +export const dragging: { paths: string[]; target: string | undefined } = $state({ + paths: [], + target: undefined, +}); + +/** `\` and `/` alike. */ +function isSeparator(char: string | undefined) { + return char === '/' || char === '\\'; +} + +/** Whether `path` is `folder` or something in it. */ +export function isWithin(path: string, folder: string): boolean { + return path === folder || (path.startsWith(folder) && isSeparator(path[folder.length])); +} + +/** The name of a file without its extension, or the name of a folder. */ +export function editableName(name: string, isDir: boolean): string { + const dot = name.lastIndexOf('.'); + return isDir || dot <= 0 ? name : name.slice(0, dot); +} diff --git a/website/src/lib/components/file-list/FileListNodeLabel.svelte b/website/src/lib/components/file-list/FileListNodeLabel.svelte index 5193890e1..1a1580844 100644 --- a/website/src/lib/components/file-list/FileListNodeLabel.svelte +++ b/website/src/lib/components/file-list/FileListNodeLabel.svelte @@ -100,6 +100,10 @@ // the file is not the one on the disk (the desktop app) let unsaved = $derived(node.type === 'file' && $workspace?.files[node.fileId]?.dirty === true); + // the file is open as a preview, which the next one replaces (the desktop app) + let preview = $derived( + node.type === 'file' && $workspace?.files[node.fileId]?.preview === 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)); @@ -195,6 +199,7 @@ {/if} { desktop: { workspace: () => invoke('workspace_state'), openFiles: () => invoke('open_files'), - openPaths: (paths) => invoke('open_paths', { paths }), + openPaths: (paths, preview) => invoke('open_paths', { paths, preview }), openFolder: (unsaved) => invoke('open_folder', { unsaved }), closeFolder: (unsaved) => invoke('close_folder', { unsaved }), closeFiles: (fileIds, unsaved) => invoke('close_files', { fileIds, unsaved }), @@ -222,6 +222,12 @@ export async function createTauriBackend(): Promise { invoke('export_files', { fileIds, options }), listFolder: (path) => invoke('list_folder', { path }), openUrl: (url) => invoke('open_url', { url }), + newFile: (name, folder) => invoke('new_file', { name, folder }), + createFolder: (parent, name) => invoke('create_folder', { parent, name }), + renamePath: (path, name) => invoke('rename_path', { path, name }), + movePaths: (paths, folder) => invoke('move_paths', { paths, folder }), + deletePaths: (paths) => invoke('delete_paths', { paths }), + revealPath: (path) => invoke('reveal_path', { path }), listen: async (onEngineChanged, onWorkspaceChanged, onFilesOpened, onFolderChanged) => { 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 6333eaf77..f56a965f1 100644 --- a/website/src/lib/engine/backend/types.ts +++ b/website/src/lib/engine/backend/types.ts @@ -30,6 +30,8 @@ export type FileStatus = { replaced: boolean; /** The file on the disk is gone. */ missing: boolean; + /** The file is open as a preview (a single click in the tree), which the next one replaces. */ + preview: boolean; }; /** An entry of the tree of the open folder: a folder, or a GPX file. */ @@ -56,8 +58,12 @@ 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 files of the file system by their path, like the ones of the tree of the folder. With + * `preview`, a single file is open as a preview: the next preview replaces it, until it is + * edited or opened for good. Otherwise they are open for good. + */ + openPaths(paths: string[], preview?: boolean): Promise; /** * 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 @@ -91,6 +97,24 @@ export interface DesktopFiles { listFolder(path?: string): Promise; /** Opens a web page (or an email to write) in the app of the system for it. */ openUrl(url: string): Promise; + /** Makes a new file, written at once in `folder` (a folder of the open folder), and selects it. */ + newFile(name: string, folder: string): Promise; + /** Makes a folder in a folder of the open folder (numbered if the name is taken): its path. */ + createFolder(parent: string, name: string): Promise; + /** + * Renames a file or a folder of the open folder. An open file is renamed like in its info (it + * is selected), which can be undone. + */ + renamePath(path: string, name: string): Promise; + /** Moves files and folders of the open folder into a folder of it. */ + movePaths(paths: string[], folder: string): Promise; + /** + * Moves files and folders of the open folder to the trash. The open files among them are + * closed, unless they have edits. + */ + deletePaths(paths: string[]): Promise; + /** Shows a file or a folder in the file manager of the system. */ + revealPath(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, when files were opened with the diff --git a/website/src/lib/engine/engine.ts b/website/src/lib/engine/engine.ts index f16439170..525e80c97 100644 --- a/website/src/lib/engine/engine.ts +++ b/website/src/lib/engine/engine.ts @@ -562,9 +562,87 @@ class Engine { return this.addedBy((b) => b.desktop!.openFiles()); } - /** 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)); + /** + * Opens files of the file system by their path, which get selected: as a preview with + * `preview`, see `DesktopFiles.openPaths`. Resolves to the new ids. + */ + async openPaths(paths: string[], preview = false): Promise { + const added = await this.addedBy((b) => b.desktop!.openPaths(paths, preview)); + // which file is the preview may have changed, without a file changing + await this.enqueue(async () => this.refreshWorkspace(this.backend!)); + return added; + } + + // What the explorer does to the tree of the open folder (the desktop app). When it fails, + // `lastError` tells why (a key of the dictionaries, or a message). + + /** + * Makes a new file in a folder of the open folder, which is written there at once and selected. + * Resolves to its id. + */ + async newFileIn(name: string, folder: string): Promise { + const [id] = await this.addedBy((b) => b.desktop!.newFile(name, folder)); + this._folderVersion.update((version) => version + 1); + return id; + } + + /** Makes a folder in a folder of the open folder: resolves to its path. */ + async createFolder(parent: string, name: string): Promise { + return (await this.treeOperation((desktop) => desktop.createFolder(parent, name))).value; + } + + /** Renames a file or a folder of the open folder. Resolves to whether it was done. */ + async renamePath(path: string, name: string): Promise { + const changed = await this.run((b) => b.desktop!.renamePath(path, name)); + const failed = get(this._lastError) !== undefined; + await this.treeOperation(async () => {}); + return changed || !failed; + } + + /** Moves files and folders of the open folder into a folder of it. */ + async movePaths(paths: string[], folder: string): Promise { + return (await this.treeOperation((desktop) => desktop.movePaths(paths, folder))).done; + } + + /** Moves files and folders of the open folder to the trash. */ + async deletePaths(paths: string[]): Promise { + const changed = await this.run((b) => b.desktop!.deletePaths(paths)); + const failed = get(this._lastError) !== undefined; + await this.treeOperation(async () => {}); + return changed || !failed; + } + + /** Shows a file or a folder in the file manager of the system. */ + async revealPath(path: string) { + await this.ready; + try { + await this.backend!.desktop?.revealPath(path); + } catch (error) { + console.error('engine:', error); + } + } + + /** + * Does something to the file system, then reads where the files are and the tree again + * (without waiting for the watcher). Resolves to whether it was done, with what it gives. + */ + private async treeOperation( + action: (desktop: DesktopFiles) => Promise + ): Promise<{ done: boolean; value?: T }> { + await this.ready; + return this.enqueue(async () => { + const backend = this.backend!; + let result: { done: boolean; value?: T } = { done: false }; + try { + result = { done: true, value: await action(backend.desktop!) }; + } catch (error) { + console.error('engine:', error); + this._lastError.set(String(error)); + } + await this.refreshWorkspace(backend); + this._folderVersion.update((version) => version + 1); + return result; + }); } /** diff --git a/website/src/locales/en.json b/website/src/locales/en.json index d7dce2a87..6ff99a30c 100644 --- a/website/src/locales/en.json +++ b/website/src/locales/en.json @@ -15,6 +15,15 @@ "open": "Open...", "open_folder": "Open folder...", "explorer": "Explorer", + "explorer_menu": { + "open": "Open", + "new_file": "New file...", + "new_folder": "New folder...", + "rename": "Rename...", + "reveal_finder": "Reveal in Finder", + "reveal": "Reveal in file explorer", + "delete_failed": "The files could not be moved to the trash." + }, "workspace": "Workspace", "save": "Save", "save_all": "Save all",