diff --git a/tauri/src/commands/disk.rs b/tauri/src/commands/disk.rs index 3b3c1bba6..b46ea489a 100644 --- a/tauri/src/commands/disk.rs +++ b/tauri/src/commands/disk.rs @@ -6,9 +6,9 @@ //! open if it does not say. use std::collections::BTreeMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -use gpx_engine::{ExportOptions, api}; +use gpx_engine::{ExportOptions, FileId, api}; use serde::Deserialize; use tauri::{AppHandle, Runtime, State}; use tauri_plugin_dialog::{DialogExt, FilePath}; @@ -25,6 +25,32 @@ fn paths(picked: Vec) -> Vec { .collect() } +/// Asks where to write a GPX file, proposing `file_name` in `folder`. `None` if the user cancelled. +fn ask_save_path( + app: &AppHandle, + file_name: &str, + folder: Option<&Path>, +) -> Option { + let mut dialog = app + .dialog() + .file() + .add_filter("GPX", &["gpx"]) + .set_file_name(file_name); + if let Some(folder) = folder { + dialog = dialog.set_directory(folder); + } + dialog.blocking_save_file()?.into_path().ok() +} + +/// Asks for a folder, starting in `folder`. `None` if the user cancelled. +fn ask_folder(app: &AppHandle, folder: Option<&Path>) -> Option { + let mut dialog = app.dialog().file(); + if let Some(folder) = folder { + dialog = dialog.set_directory(folder); + } + dialog.blocking_pick_folder()?.into_path().ok() +} + #[tauri::command(async)] pub fn workspace_state(state: State) -> WorkspaceState { state.core(|core| core.workspace_state()) @@ -46,7 +72,7 @@ pub fn open_files(app: AppHandle, state: State) -> api: /// 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. +/// `Workspace::open`); otherwise they are open for good, even the preview. #[tauri::command(async)] pub fn open_paths( state: State, @@ -54,18 +80,10 @@ pub fn open_paths( preview: Option, ) -> api::Outcome { state.core(move |core| { - 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); + let outcome = core + .workspace + .open(&mut core.engine, &paths, preview.unwrap_or_default()); + core.commit(None); outcome }) } @@ -93,38 +111,16 @@ fn write_unsaved( 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); + [(id, name)] => match ask_save_path(app, &format!("{name}.gpx"), start.as_deref()) { + Some(path) => { + chosen.insert(*id, path); } - 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), - } - } + None => return Ok(false), + }, + _ => match ask_folder(app, start.as_deref()) { + Some(path) => folder = Some(path), + None => return Ok(false), + }, } state.core(move |core| { let result = core @@ -157,7 +153,7 @@ pub fn save_file_as( let (name, current, folder) = state.core(move |core| { let name = core .engine - .file_structure(&gpx_engine::FileId(file_id)) + .file_structure(&FileId(file_id)) .map(|structure| structure.name.clone()); let status = core.workspace_state(); let file = status.files.get(&file_id.to_string()); @@ -170,27 +166,12 @@ pub fn save_file_as( 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 { + // where the file is, or else where it would be written + let folder = current + .as_ref() + .and_then(|path| path.parent().map(Path::to_path_buf)) + .or(folder); + let Some(path) = ask_save_path(&app, &format!("{name}.gpx"), folder.as_deref()) else { return Ok(false); }; state.core(move |core| { @@ -204,9 +185,7 @@ pub fn save_file_as( 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); + core.commit(None); outcome }) } @@ -255,10 +234,7 @@ pub fn open_folder( let outcome = core .workspace .open_folder(&mut core.engine, &folder, discard); - core.keep_workspace(); - core.persist(); - core.watch(); - core.announce(None); + core.commit(None); Ok(outcome) }) } @@ -279,10 +255,7 @@ pub fn close_folder( let outcome = core .workspace .close_folder(&mut core.engine, unsaved.is_some()); - core.keep_workspace(); - core.persist(); - core.watch(); - core.announce(None); + core.commit(None); Ok(outcome) }) } @@ -303,9 +276,7 @@ pub fn close_files( let outcome = core .workspace .close_files(&mut core.engine, &file_ids, unsaved.is_some()); - core.keep_workspace(); - core.persist(); - core.announce(None); + core.commit(None); Ok(outcome) }) } @@ -364,7 +335,7 @@ pub fn export_files( file_ids .iter() .filter_map(|id| { - let name = core.workspace.display_name(&core.engine, *id)?; + let name = core.engine.file_structure(&FileId(*id))?.name.clone(); Some((name, api::export_file(&core.engine, *id, options)?)) }) .collect() @@ -376,25 +347,13 @@ pub fn export_files( 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 { + let Some(path) = ask_save_path(&app, &format!("{name}.gpx"), None) 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 { + let Some(folder) = ask_folder(&app, None) else { return Ok(false); }; let mut written: Vec = vec![]; @@ -433,14 +392,13 @@ pub fn rename_path( let selected = api::select(&mut core.engine, &[id], gpx_engine::SelectMode::Replace); let desc = core .engine - .file_structure(&gpx_engine::FileId(id)) + .file_structure(&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); + core.commit(None); Ok(api::Outcome::failed_none()) }) } @@ -454,8 +412,7 @@ pub fn move_paths( ) -> Result<(), String> { state.core(move |core| { let result = core.workspace.move_paths(&paths, &folder); - core.keep_workspace(); - core.announce(None); + core.commit(None); result }) } @@ -468,9 +425,7 @@ pub fn delete_paths(state: State, paths: Vec) -> Result api::Out 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); + match core.workspace.name_for(id, &name) { + Ok(valid) => name = valid, + Err(error) => return api::Outcome::failed(error), } } } diff --git a/tauri/src/state.rs b/tauri/src/state.rs index 2c37f7bdc..1f30ae119 100644 --- a/tauri/src/state.rs +++ b/tauri/src/state.rs @@ -18,7 +18,7 @@ use notify_debouncer_mini::{DebounceEventResult, Debouncer, new_debouncer}; use serde::Serialize; use crate::store::FileStorage; -use crate::workspace::{Workspace, WorkspaceState}; +use crate::workspace::{Workspace, WorkspaceState, signature}; /// Tells the frontend that something happened that it did not ask for: a name and a JSON value. pub type Emit = Arc; @@ -89,13 +89,9 @@ impl Core { if paths.is_empty() { 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(); + let outcome = self.workspace.open(&mut self.engine, paths, false); (self.emit)("files-opened", to_json(&outcome)); - self.announce(None); + self.commit(None); } /// Runs `f` on the engine, which can change: what changed is kept, and the files on the disk are @@ -104,22 +100,19 @@ impl Core { 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 preview_before = preview.and_then(|id| 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 - { + let kept = preview.is_some_and(|id| signature(&self.engine, id) != preview_before); + if let Some(id) = preview.filter(|_| kept) { 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(); - self.announce(None); + if kept || renamed { + self.commit(None); + } else { + self.persist(); } - self.persist(); (result, errors) } @@ -139,17 +132,18 @@ impl Core { /// the files they were written to) is kept and told, with where the files are. Says whether /// they were written. pub fn wrote(&mut self, result: Result) -> Result { - self.keep_workspace(); - match &result { - Ok(outcome) if outcome.changed => { - self.persist(); - self.announce(Some(outcome)); - } - _ => self.announce(None), - } + self.commit(result.as_ref().ok().filter(|outcome| outcome.changed)); result.map(|_| true) } + /// After a change of the files or of where they are: the workspace and the engine are kept, and + /// the frontend is told (`outcome` is what changed in the engine, if it did not ask for it). + pub fn commit(&mut self, outcome: Option<&api::Outcome>) { + self.keep_workspace(); + self.persist(); + self.announce(outcome); + } + /// 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()); @@ -224,9 +218,7 @@ impl Core { (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)); + self.commit(Some(&outcome)); } } @@ -255,32 +247,15 @@ impl Core { .then(|| self.workspace.folder.clone()) .flatten() }); - // 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 + // what is made in a folder is written there at once, so that the tree shows it + let made: Vec = 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(); match self.workspace.save_new(&mut self.engine, &made) { Ok(renamed) => outcome = outcome.merge(renamed), @@ -290,8 +265,7 @@ impl Core { Err(_) => {} } } - self.keep_workspace(); - self.persist(); + self.commit(None); outcome } } diff --git a/tauri/src/workspace.rs b/tauri/src/workspace.rs index 23ca5077b..220294288 100644 --- a/tauri/src/workspace.rs +++ b/tauri/src/workspace.rs @@ -420,10 +420,23 @@ impl Workspace { outcome } + /// Opens files from the disk, the first of which is selected. With `preview`, a single file is + /// open as a preview (see `open_preview`); otherwise they are open for good, the preview too if + /// it is among them. + pub fn open(&mut self, engine: &mut Engine, paths: &[PathBuf], preview: bool) -> api::Outcome { + if let ([path], true) = (paths, preview) { + return self.open_preview(engine, path); + } + let outcome = self.open_files(engine, paths, true); + let ids: Vec = paths.iter().filter_map(|path| self.linked(path)).collect(); + self.keep(&ids); + 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 { + 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); @@ -444,11 +457,6 @@ impl Workspace { } } - /// 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 { @@ -674,10 +682,6 @@ impl Workspace { .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. 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), which the file then @@ -694,7 +698,8 @@ impl Workspace { let Some(structure) = engine.file_structure(&FileId(*id)) else { continue; }; - let in_folder = |dir: &Path| free_path(dir, &structure.name, |p| self.is_taken(p)); + let in_folder = + |dir: &Path| free_path(dir, &structure.name, |p| self.id_of(p).is_some()); 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), @@ -707,25 +712,17 @@ impl Workspace { Ok(outcome) } - /// 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)`). The files are called like the file they are written to. - pub fn save_new( - &mut self, - engine: &mut Engine, - files: &[(Uuid, Option)], - ) -> Result { - let names: Vec> = files + /// Writes the new files among `ids` in their folder, under their name. 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)`). The files are called like the file + /// they are written to. + pub fn save_new(&mut self, engine: &mut Engine, ids: &[Uuid]) -> Result { + let names: Vec> = ids .iter() - .map(|(id, name)| { + .map(|id| { 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()), - )) + let name = engine.file_structure(&FileId(*id))?.name.clone(); + Some((*id, dir, name)) }) .collect(); let mut outcome = api::Outcome::failed_none(); @@ -741,10 +738,11 @@ impl Workspace { }) .count() > 1; + let taken = |p: &Path| self.id_of(p).is_some(); let path = if together { - numbered_path(dir, name, |p| self.is_taken(p)) + numbered_path(dir, name, taken) } else { - free_path(dir, name, |p| self.is_taken(p)) + free_path(dir, name, taken) }; outcome = outcome.merge(self.write_file(engine, *id, &path)?); } @@ -763,20 +761,21 @@ impl Workspace { .collect() } - /// The name that a file on the disk can have: the name of a file, without its extension (see - /// `file_name`). Others are given to the files that are not on the disk yet when they are - /// written. - pub fn valid_name(&self, id: Uuid, name: &str) -> String { - if self.links.contains_key(&id) { - file_name(name) - } else { - name.to_owned() + /// The name that a file can be given. A file on the disk is called like its file: the name is + /// one that a file can have (see `file_name`), and not the one of another file next to it + /// (`NAME_TAKEN`). The other files get theirs when they are written. + pub fn name_for(&self, id: Uuid, name: &str) -> Result { + if !self.links.contains_key(&id) { + return Ok(name.to_owned()); } + let name = file_name(name); + self.renamed_path(id, &name)?; + Ok(name) } /// Where a file on the disk goes when it is called `name`: next to where it is, with the same /// extension. `None` if it is there already, an error if something else is. - pub fn renamed_path(&self, id: Uuid, name: &str) -> Result, String> { + fn renamed_path(&self, id: Uuid, name: &str) -> Result, String> { let Some(link) = self.links.get(&id) else { return Ok(None); }; @@ -848,15 +847,6 @@ impl Workspace { (renamed, errors) } - /// 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, diff --git a/website/src/lib/actions/file-actions.ts b/website/src/lib/actions/file-actions.ts index e09dae626..9e812cdad 100644 --- a/website/src/lib/actions/file-actions.ts +++ b/website/src/lib/actions/file-actions.ts @@ -5,6 +5,7 @@ import { boundsManager } from '$lib/state/bounds'; import { engine } from '$lib/engine'; import { defaultFileName } from '$lib/actions/default-file-name'; import { askOverwrite, askUnsaved } from '$lib/components/file-list/unsaved-prompt.svelte'; +import { toast } from 'svelte-sonner'; import type { UnsavedChoice } from '$lib/engine'; /** The name of a new file: the translated default name, numbered if other files have it. */ @@ -139,14 +140,26 @@ export async function saveAs(selectedFileIds: string[]) { } } -/** Opens a file of the tree of the open folder. */ -export async function openPath(path: string) { - const ids = await engine.openPaths([path]); +/** + * Opens files of the file system, like the ones of the tree of the open folder: as a preview + * (`preview`, a single click), which the next preview replaces, or for good. The map fits the files + * that were opened. + */ +export async function openPaths(paths: string[], preview = false) { + const ids = await engine.openPaths(paths, preview); if (ids.length > 0) { boundsManager.fitBoundsOnLoad(ids); } } +/** Tells the user why the engine did not do what was asked (a key of the dictionaries, or text). */ +export function showEngineError() { + const error = get(engine.lastError); + if (error !== undefined && error !== 'nothing to do') { + toast.error(i18n._(error, error)); + } +} + export function triggerFileInput() { const input = document.createElement('input'); input.type = 'file'; diff --git a/website/src/lib/components/explorer/Explorer.svelte b/website/src/lib/components/explorer/Explorer.svelte index 94931bbe5..557d8081f 100644 --- a/website/src/lib/components/explorer/Explorer.svelte +++ b/website/src/lib/components/explorer/Explorer.svelte @@ -5,8 +5,8 @@ import { ScrollArea } from '$lib/components/ui/scroll-area/index'; import ExplorerTree from './ExplorerTree.svelte'; import ExplorerRow from './ExplorerRow.svelte'; - import { openFiles } from './actions'; - import { clickedInExplorer, explorerSelection, renaming } from './state.svelte'; + import { clickedInExplorer, explorerSelection, renaming, treeRows } from './state.svelte'; + import { openPaths } from '$lib/actions/file-actions'; import { i18n } from '$lib/i18n/i18n.svelte'; import { engine } from '$lib/engine'; import { selectedFileIds } from '$lib/engine/selection-helpers'; @@ -52,22 +52,6 @@ } }); - /** 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. @@ -95,6 +79,18 @@ frame = requestAnimationFrame(scroll); return () => cancelAnimationFrame(frame); } + + /** Enter opens the selected files for good. */ + function onkeydown(event: KeyboardEvent) { + if (renaming.path !== undefined || event.key !== 'Enter') return; + const files = treeRows(container) + .filter((row) => row.kind === 'file' && explorerSelection.paths.includes(row.path)) + .map((row) => row.path); + if (files.length > 0) { + openPaths(files); + event.preventDefault(); + } + } + { + input.focus(); + input.select(); + }} + {onkeydown} + onkeyup={(event) => event.stopPropagation()} + onclick={(event) => event.stopPropagation()} + onblur={(event) => oncommit(event.currentTarget.value, false)} +/> diff --git a/website/src/lib/components/explorer/actions.ts b/website/src/lib/components/explorer/actions.ts deleted file mode 100644 index f78ae59dc..000000000 --- a/website/src/lib/components/explorer/actions.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 383c4f60e..c7c2ca358 100644 --- a/website/src/lib/components/explorer/state.svelte.ts +++ b/website/src/lib/components/explorer/state.svelte.ts @@ -28,24 +28,11 @@ export const dragging: { paths: string[]; target: string | undefined } = $state( target: undefined, }); -/** `\` and `/` alike. */ -function isSeparator(char: string | undefined) { - return char === '/' || char === '\\'; -} - -/** The folder that holds `path`. */ -export function parentOf(path: string): string { - const index = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); - return index > 0 ? path.slice(0, index) : path; -} - -/** 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); +/** The rows of the explorer that `element` is in, as they are shown: their path and kind. */ +export function treeRows(element: Element | null | undefined): { path: string; kind: string }[] { + const tree = element?.closest('[data-explorer]'); + return [...(tree?.querySelectorAll('[data-explorer-path]') ?? [])].map((row) => ({ + path: row.dataset.explorerPath!, + kind: row.dataset.kind!, + })); } diff --git a/website/src/lib/components/file-list/metadata/MetadataDialog.svelte b/website/src/lib/components/file-list/metadata/MetadataDialog.svelte index 9f40b5c68..e24240bc3 100644 --- a/website/src/lib/components/file-list/metadata/MetadataDialog.svelte +++ b/website/src/lib/components/file-list/metadata/MetadataDialog.svelte @@ -9,8 +9,7 @@ import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte'; import { engine, type FileState } from '$lib/engine'; import type { FileTreeNode } from '$lib/engine/selection-helpers'; - import { toast } from 'svelte-sonner'; - import { get } from 'svelte/store'; + import { showEngineError } from '$lib/actions/file-actions'; let { fileState, @@ -58,10 +57,8 @@ // the metadata of the selected elements are changed. In the desktop app, a file on // the disk is renamed with it, which is refused if the name is the one of another engine.metadata(name, description).then((changed) => { - const error = get(engine.lastError); - if (!changed && error !== undefined && error !== 'nothing to do') { - // a key of the dictionaries, when it comes from the desktop app - toast.error(i18n._(error, error)); + if (!changed) { + showEngineError(); } }); }} diff --git a/website/src/lib/engine/engine.ts b/website/src/lib/engine/engine.ts index 525e80c97..c147ee6e3 100644 --- a/website/src/lib/engine/engine.ts +++ b/website/src/lib/engine/engine.ts @@ -567,10 +567,7 @@ class Engine { * `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; + return this.addedBy((b) => b.desktop!.openPaths(paths, preview)); } // What the explorer does to the tree of the open folder (the desktop app). When it fails, @@ -581,35 +578,29 @@ class Engine { * 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); + const [id] = await this.addedBy((b) => b.desktop!.newFile(name, folder), true); 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; + return (await this.desktopCall((desktop) => desktop.createFolder(parent, name), true)) + .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; + return this.desktopRun((b) => b.desktop!.renamePath(path, name), true); } /** 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; + return (await this.desktopCall((desktop) => desktop.movePaths(paths, folder), true)).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; + return this.desktopRun((b) => b.desktop!.deletePaths(paths), true); } /** Shows a file or a folder in the file manager of the system. */ @@ -622,43 +613,18 @@ class Engine { } } - /** - * 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; - }); - } - /** * 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!)); + await this.desktopRun((b) => b.desktop!.openFolder(unsaved)); } /** 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!)); + await this.desktopRun((b) => b.desktop!.closeFolder(unsaved)); } /** @@ -666,8 +632,7 @@ class Engine { * first, or their edits are lost (they stay open when it is not said). */ async closeFiles(fileIds: string[], unsaved?: UnsavedChoice) { - await this.run((b) => b.desktop!.closeFiles(fileIds, unsaved)); - await this.enqueue(async () => this.refreshWorkspace(this.backend!)); + await this.desktopRun((b) => b.desktop!.closeFiles(fileIds, unsaved)); } /** @@ -676,23 +641,23 @@ class Engine { * when the user cancelled or the files could not be saved. */ async save(fileIds: string[]): Promise { - return this.desktopCall((desktop) => desktop.saveFiles(fileIds)); + return (await this.desktopCall((desktop) => desktop.saveFiles(fileIds))).value === true; } /** 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)); + return (await this.desktopCall((desktop) => desktop.saveFileAs(fileId))).value === true; } /** 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)); + const exported = await this.desktopCall((desktop) => desktop.exportFiles(fileIds, options)); + return exported.value === true; } /** 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!)); + await this.desktopRun((b) => b.desktop!.reloadFiles(fileIds)); } /** Opens a web page in the browser of the system (the desktop app). */ @@ -718,27 +683,51 @@ class Engine { }); } - /** 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 { + /** + * Calls the file system of the desktop app, then reads where the files are again (without + * waiting for the app to tell), and the tree of the open folder with `tree`. Resolves to whether + * it was done, with what it gives; when it was not, `lastError` tells why. + */ + private async desktopCall( + call: (desktop: DesktopFiles) => Promise, + tree = false + ): Promise<{ done: boolean; value?: T }> { await this.ready; return this.enqueue(async () => { const backend = this.backend!; - let done = false; + let result: { done: boolean; value?: T } = { done: false }; try { - done = await call(backend.desktop!); + result = { done: true, value: await call(backend.desktop!) }; } catch (error) { - console.error('The files could not be written', error); + console.error('engine:', error); this._lastError.set(String(error)); } - await this.refreshWorkspace(backend); - return done; + await this.refreshWorkspace(backend, tree); + return result; }); } - /** Runs a call that adds files, and resolves to the ids that it added. */ - private async addedBy(action: (backend: Backend) => Promise): Promise { + /** + * Like `run`, for a call of the desktop app that can change where the files are (and the tree + * of the open folder with `tree`), which are read again. Resolves to whether it was done. + */ + private async desktopRun( + action: (backend: Backend) => Promise, + tree = false + ): Promise { + const changed = await this.run(action); + const failed = get(this._lastError) !== undefined; + await this.enqueue(() => this.refreshWorkspace(this.backend!, tree)); + return changed || !failed; + } + + /** Runs a call of the desktop app that adds files (see `desktopRun`): resolves to their ids. */ + private async addedBy( + action: (backend: Backend) => Promise, + tree = false + ): Promise { const before = new Set(get(this._order)); - await this.run(action); + await this.desktopRun(action, tree); return get(this._order).filter((id) => !before.has(id)); } @@ -756,10 +745,14 @@ class Engine { }; } - private async refreshWorkspace(backend: Backend) { + /** Reads where the files are again, and the tree of the open folder with `tree`. */ + private async refreshWorkspace(backend: Backend, tree = false) { if (backend.desktop) { this._workspace.set(await backend.desktop.workspace()); } + if (tree) { + this._folderVersion.update((version) => version + 1); + } } /** diff --git a/website/src/lib/engine/workspace.test.ts b/website/src/lib/engine/workspace.test.ts index 8579e5286..073bf62f7 100644 --- a/website/src/lib/engine/workspace.test.ts +++ b/website/src/lib/engine/workspace.test.ts @@ -1,6 +1,13 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { fileIdAt, folderName, subfolder } from '$lib/engine/workspace'; +import { + editableName, + fileIdAt, + folderName, + isWithin, + parentOf, + subfolder, +} from '$lib/engine/workspace'; describe('subfolder', () => { it('is where a file is in the folder', () => { @@ -47,3 +54,31 @@ describe('fileIdAt', () => { assert.equal(fileIdAt(undefined, '/tracks/a.gpx'), undefined); }); }); + +describe('parentOf', () => { + it('is the folder that holds the path', () => { + assert.equal(parentOf('/home/me/tracks/a.gpx'), '/home/me/tracks'); + assert.equal(parentOf('C:\\Users\\me\\a.gpx'), 'C:\\Users\\me'); + }); +}); + +describe('isWithin', () => { + it('is the folder itself or what is in it', () => { + assert.ok(isWithin('/home/me/tracks', '/home/me/tracks')); + assert.ok(isWithin('/home/me/tracks/2024/a.gpx', '/home/me/tracks')); + assert.ok(isWithin('C:\\me\\tracks\\a.gpx', 'C:\\me\\tracks')); + }); + + it('is not a folder whose name begins the same', () => { + assert.ok(!isWithin('/home/me/tracks-old/a.gpx', '/home/me/tracks')); + assert.ok(!isWithin('/home/me', '/home/me/tracks')); + }); +}); + +describe('editableName', () => { + it('leaves out the extension of a file, not of a folder', () => { + assert.equal(editableName('ride.gpx', false), 'ride'); + assert.equal(editableName('my.trips', true), 'my.trips'); + assert.equal(editableName('.hidden', false), '.hidden'); + }); +}); diff --git a/website/src/lib/engine/workspace.ts b/website/src/lib/engine/workspace.ts index a44ef4ea3..a7abcf394 100644 --- a/website/src/lib/engine/workspace.ts +++ b/website/src/lib/engine/workspace.ts @@ -34,3 +34,20 @@ export function fileIdAt( ): string | undefined { return Object.entries(workspace?.files ?? {}).find(([, file]) => file.path === path)?.[0]; } + +/** The folder that holds `path`. */ +export function parentOf(path: string): string { + const index = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + return index > 0 ? path.slice(0, index) : path; +} + +/** Whether `path` is `folder` or something in it. */ +export function isWithin(path: string, folder: string): boolean { + return path === folder || (path.startsWith(folder) && /[\\/]/.test(path[folder.length])); +} + +/** The name of a file without its extension, or the name of a folder (`isDir`). */ +export function editableName(name: string, isDir: boolean): string { + const dot = name.lastIndexOf('.'); + return isDir || dot <= 0 ? name : name.slice(0, dot); +}