26 Commits
Author SHA1 Message Date
vcoppe 05510ece92 full screen 2026-10-18 00:38:53 +02:00
vcoppe 8bbcbdc097 remove focus-ring 2026-10-18 00:33:03 +02:00
vcoppe 3012f6bf73 fixes 2026-10-18 00:24:16 +02:00
vcoppe 5373d0f0d4 cleaning 2026-10-17 23:55:46 +02:00
vcoppe 8fd47d8b18 drop anywhere in folder 2026-10-17 23:39:44 +02:00
vcoppe a5c1f6ad33 fix folder styling 2026-10-17 23:35:56 +02:00
vcoppe 3ef2bf135a explorer actions 2026-10-17 23:23:29 +02:00
vcoppe adbf89e318 clarify filename 2026-10-17 22:17:17 +02:00
vcoppe 11709316d2 avoid selecting on resize 2026-10-17 20:02:09 +02:00
vcoppe d52b61f707 commit missing changes 2026-10-17 20:01:59 +02:00
vcoppe 358253b7a9 open links in browser 2026-10-17 19:57:50 +02:00
vcoppe 8d6885d58b remember language 2026-10-17 19:36:57 +02:00
vcoppe 128338aa05 fix sorting 2026-10-17 19:10:50 +02:00
vcoppe c84e1c9a06 fix routing on other file 2026-10-17 19:03:33 +02:00
vcoppe 9f49119eec no file delete on desktop 2026-10-17 18:57:40 +02:00
vcoppe 67a47cf065 fix tab size 2026-10-17 18:50:23 +02:00
vcoppe e57949dc01 fix 2026-10-17 18:33:50 +02:00
vcoppe da92fd15d2 fix sortable 2026-10-17 18:28:32 +02:00
vcoppe 01443943b3 fix sortable 2026-10-17 17:21:56 +02:00
vcoppe 970cf7d1e1 improved sticky folders 2026-10-17 16:39:41 +02:00
vcoppe 593b43e433 sticky folders 2026-10-17 16:24:19 +02:00
vcoppe 8fa88680b3 fix 2026-10-17 16:17:45 +02:00
vcoppe b2495d1466 progress 2026-10-17 16:14:17 +02:00
vcoppe ed90ba6a1f file explorer 2026-10-17 14:42:49 +02:00
vcoppe b0a3c3a9ba progress 2026-10-16 18:14:59 +02:00
vcoppe 005cc7b305 fix 2026-10-16 09:05:45 +02:00
55 changed files with 4480 additions and 806 deletions

No files matched your search

Generated
+78
View File
@@ -1430,7 +1430,9 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-single-instance",
"trash",
"uuid",
]
@@ -1870,6 +1872,25 @@ version = "2.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -2577,6 +2598,17 @@ version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "open"
version = "5.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
dependencies = [
"dunce",
"is-wsl",
"libc",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -3818,6 +3850,28 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53810be1b4fed22bb6ac3f4cab5a2b80d9391d1af436bda145607532bc0bb219"
dependencies = [
"dunce",
"glob",
"objc2-app-kit",
"objc2-foundation",
"open",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.21",
"url",
"windows",
"zbus",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.5.3"
@@ -4241,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"
@@ -4317,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"
+14 -2
View File
@@ -183,8 +183,8 @@ pub fn load_files<H: History>(
}
/// Brings changes of the files from the outside in (see [`engine::SyncFiles`]): each file is
/// `(id, data, name)`, and replaces the file of that id, or is added if there is none. The files to
/// `remove` are deleted. The selection is left alone.
/// `(id, data, name)`, and replaces the file of that id, or is added if there is none; it is called
/// `name`, whatever its data says. The files to `remove` are deleted. The selection is left alone.
pub fn sync_files<H: History>(
engine: &mut Engine<H>,
files: &[(Uuid, &[u8], &str)],
@@ -232,6 +232,18 @@ pub fn reorder<H: History>(engine: &mut Engine<H>, file_ids_: &[Uuid], index: us
)
}
/// Names a file in all its states, as if it had always been called so (not undoable): for a file
/// that is written to a file of another name, whose name it takes.
pub fn rename_file<H: History>(engine: &mut Engine<H>, file_id: Uuid, name: &str) -> Outcome {
execute(
engine,
Action::Rename {
file_id: engine::FileId(file_id),
name: name.to_owned(),
},
)
}
/// Puts the selected elements in the clipboard. Nothing happens when nothing is selected.
pub fn copy<H: History>(engine: &mut Engine<H>) -> Outcome {
execute(engine, Action::Copy)
+5
View File
@@ -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()
+7
View File
@@ -36,4 +36,11 @@ pub enum Action<'a> {
file_ids: Vec<FileId>,
index: usize,
},
/// Names a file in all the states of the history, as if it had always been called so (see
/// [`crate::History::rename`]). Not undoable: for a file that is given a name from the outside,
/// like the name of the file it is written to.
Rename {
file_id: FileId,
name: String,
},
}
+9 -3
View File
@@ -7,7 +7,8 @@ use crate::{Apply, CommandError, FileId, Load, State};
pub struct ExternalFile<'a> {
pub id: FileId,
pub data: &'a [u8],
/// Name of the file when the data has none (see [`Load`]).
/// Name of the file outside (the name of the file on the disk, without its extension), which
/// is its name here whatever the data says: a file has a single name.
pub name: &'a str,
}
@@ -15,7 +16,8 @@ pub struct ExternalFile<'a> {
/// that are given replace the ones of the same id (which stay where they are in the order, and keep
/// their id), or are added at the end if there are none; the ones to `remove` are deleted.
///
/// Unlike [`crate::LoadFiles`], the selection is left alone (what it holds of a file that was
/// Unlike [`crate::LoadFiles`], the files are named as they are outside (see [`ExternalFile`]), and
/// the selection is left alone (what it holds of a file that was
/// replaced or deleted is dropped by the engine afterwards, as with any edit).
///
/// The files that cannot be read are skipped, and the files that are here stay as they are. It is an
@@ -38,6 +40,9 @@ impl Apply for SyncFiles<'_> {
match load.parse(state.categories) {
Ok(mut file) => {
file.id = external.id;
if !external.name.trim().is_empty() {
file.info.name = external.name.to_owned();
}
if state.files.insert(external.id, Rc::new(file)).is_none() {
state.order.0.push(external.id);
}
@@ -101,7 +106,8 @@ mod tests {
.apply(&mut fx.state())
.unwrap();
assert_eq!(fx.order.0, [first, second]);
assert_eq!(fx.files[&first].info.name, "from disk");
// named as it is outside, not as the data says
assert_eq!(fx.files[&first].info.name, "name");
assert_eq!(fx.files[&first].id, first);
// the selection is not changed
assert_eq!(fx.selected_files(), [second].into());
+13 -13
View File
@@ -124,9 +124,9 @@ impl Editor for Cleaner<'_> {
}
}
/// Removes what the clean left empty: the emptied segments, the tracks that lost all their
/// segments and the files that lost something and have nothing left. The selection falls back
/// to the closest element still existing.
/// Removes what the clean left empty: the emptied segments and the tracks that lost all their
/// segments. A file is never removed, even if nothing is left in it: it stays, empty. The
/// selection falls back to the closest element still existing.
fn prune(state: &mut State, emptied: &HashSet<TrackSegmentId>, wpt_files: &HashSet<FileId>) {
let ids: Vec<FileId> = state
.files
@@ -142,19 +142,13 @@ fn prune(state: &mut State, emptied: &HashSet<TrackSegmentId>, wpt_files: &HashS
.collect();
for id in ids {
let mut file = (*state.files[&id]).clone();
let lost_something = !emptied.is_empty() || wpt_files.contains(&id);
file.trk.retain_mut(|trk| {
let len = trk.trkseg.len();
trk.trkseg.retain(|seg| !emptied.contains(&seg.id));
trk.trkseg.len() == len || !trk.trkseg.is_empty()
});
if lost_something && file.trk.is_empty() && file.wpt.is_empty() {
state.files.remove(&id);
state.order.0.retain(|f| *f != id);
} else {
state.files.insert(id, Rc::new(file));
}
}
*state.selection = fixed_selection(state);
}
@@ -297,10 +291,16 @@ mod tests {
.apply(&mut fx.state())
.unwrap();
assert_eq!(count(&fx, id), 0);
// nothing is left of the file
assert!(!fx.files.contains_key(&id));
assert!(fx.order.0.is_empty());
assert_eq!(fx.selection, Selection::Empty);
// nothing is left in the file, which stays and is still selected
let file = &fx.files[&id];
assert!(file.trk.is_empty() && file.wpt.is_empty());
assert_eq!(fx.order.0, [id]);
assert_eq!(
fx.selection,
Selection::File {
file_ids: HashSet::from([id])
}
);
}
#[test]
+1
View File
@@ -248,6 +248,7 @@ impl<H: History> Engine<H> {
self.order.move_files(&file_ids, index);
None
}
Action::Rename { file_id, name } => self.history.rename(file_id, &name),
};
let changed = diff.is_some()
|| self.selection != selection_before
+17 -1
View File
@@ -1,6 +1,6 @@
use std::rc::Rc;
use super::{Diff, Files, History, diff_between};
use super::{Diff, Files, History, Renamer, diff_between};
use crate::{CommandError, FileId};
/// One history for the whole workspace: each entry is a state of all the files.
@@ -123,6 +123,22 @@ impl History for GlobalHistory {
None => !self.entries.is_empty(),
}
}
fn rename(&mut self, id: FileId, name: &str) -> Option<Diff> {
let before = self.current()?.get(&id)?.clone();
let mut renamer = Renamer::new(name);
for entry in self.entries.iter_mut() {
if let Some(file) = entry.get(&id) {
let renamed = renamer.rename(file);
entry.insert(id, renamed);
}
}
let after = self.current()?.get(&id)?;
(!Rc::ptr_eq(&before, after)).then(|| Diff {
modified: vec![id],
..Default::default()
})
}
}
#[cfg(test)]
+35
View File
@@ -57,6 +57,41 @@ pub trait History: Default + Debug {
/// Whether [`History::redo`] would do something for these `files`.
fn can_redo(&self, files: &[FileId]) -> bool;
/// Names a file `name` in all its states, as if it had always been called so: there is nothing
/// to undo. Says that the file changed, `None` if there is no such file or it is called so.
fn rename(&mut self, id: FileId, name: &str) -> Option<Diff>;
}
/// Names the states of a file: the ones that were shared stay shared (a state that several
/// entries have is renamed once), and the ones that are called so already are kept.
pub(super) struct Renamer<'a> {
name: &'a str,
renamed: HashMap<*const File, Rc<File>>,
}
impl<'a> Renamer<'a> {
pub(super) fn new(name: &'a str) -> Self {
Self {
name,
renamed: HashMap::new(),
}
}
pub(super) fn rename(&mut self, file: &Rc<File>) -> Rc<File> {
if file.info.name == self.name {
return file.clone();
}
let name = self.name;
self.renamed
.entry(Rc::as_ptr(file))
.or_insert_with(|| {
let mut renamed = (**file).clone();
renamed.info.name = name.to_owned();
Rc::new(renamed)
})
.clone()
}
}
/// Which files were added, removed or modified by a change of the current files.
+15 -1
View File
@@ -1,6 +1,6 @@
use std::rc::Rc;
use super::{Diff, Files, History, diff_between};
use super::{Diff, Files, History, Renamer, diff_between};
use crate::{CommandError, File, FileId};
use std::collections::HashMap;
@@ -139,6 +139,20 @@ impl History for PerFileHistory {
self.any(files, FileHistory::can_undo)
}
fn rename(&mut self, id: FileId, name: &str) -> Option<Diff> {
let history = self.stacks.get_mut(&id)?;
let mut renamer = Renamer::new(name);
for entry in history.entries.iter_mut() {
*entry = renamer.rename(entry);
}
let current = history.current().clone();
let before = self.current.insert(id, current.clone());
(!before.is_some_and(|before| Rc::ptr_eq(&before, &current))).then(|| Diff {
modified: vec![id],
..Default::default()
})
}
fn can_redo(&self, files: &[FileId]) -> bool {
self.any(files, FileHistory::can_redo)
}
+71
View File
@@ -182,6 +182,52 @@ fn test_metadata_renames_selected_file() {
assert_eq!(file.info.desc.as_deref(), Some("about"));
}
#[test]
fn test_rename_names_every_state_of_the_file_and_is_not_undoable() {
let mut engine = TestEngine::default();
new(&mut engine, "first");
new(&mut engine, "other");
let id = engine.order()[0];
let other = engine.order()[1];
let other_file = engine.history.current().unwrap()[&other].clone();
engine.run(Action::Select {
selection: Selection::File {
file_ids: [id].into(),
},
mode: SelectMode::Replace,
});
assert!(edit(
&mut engine,
Command::Metadata(Metadata {
name: "edited",
desc: "",
})
));
assert!(engine.run(Action::Rename {
file_id: id,
name: "written".to_owned(),
}));
assert_eq!(engine.last_diff().unwrap().modified, vec![id]);
assert_eq!(engine.file_structure(&id).unwrap().name, "written");
// nothing to undo for it: the edits before are undone, and the file keeps its name
engine.run(Action::Undo);
engine.run(Action::Undo);
assert_eq!(engine.file_structure(&id).unwrap().name, "written");
engine.run(Action::Redo);
engine.run(Action::Redo);
assert_eq!(engine.file_structure(&id).unwrap().name, "written");
// the other file is still the same object, in every state
assert!(Rc::ptr_eq(
&engine.history.current().unwrap()[&other],
&other_file
));
// naming it as it is already called changes nothing
assert!(!engine.run(Action::Rename {
file_id: id,
name: "written".to_owned(),
}));
}
#[test]
fn test_metadata_without_selection_does_not_modify_files() {
let mut engine = TestEngine::default();
@@ -1469,6 +1515,31 @@ mod per_file_history {
engine.file_structure(&id).unwrap().name.clone()
}
#[test]
fn test_rename_names_every_state_of_the_file() {
let mut engine = Engine::<PerFileHistory>::default();
let id = new_file(&mut engine, "a");
select(&mut engine, &[id]);
rename(&mut engine, "b");
rename(&mut engine, "c");
let outcome = engine
.execute(Action::Rename {
file_id: id,
name: "written".to_owned(),
})
.unwrap();
assert_eq!(outcome.diff.unwrap().modified, vec![id]);
assert_eq!(name_of(&engine, id), "written");
// the edits are still there to undo, the name stays
engine.execute(Action::Undo).unwrap();
assert!(engine.can_undo());
engine.execute(Action::Undo).unwrap();
assert!(!engine.can_undo());
assert_eq!(name_of(&engine, id), "written");
engine.execute(Action::Redo).unwrap();
assert_eq!(name_of(&engine, id), "written");
}
#[test]
fn test_creating_a_file_is_not_undoable() {
let mut engine = Engine::<PerFileHistory>::default();
+2
View File
@@ -25,4 +25,6 @@ notify = "8.2.0"
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"
+372 -64
View File
@@ -1,16 +1,22 @@
//! 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 std::path::{Path, PathBuf};
use gpx_engine::api;
use gpx_engine::{ExportOptions, FileId, api};
use serde::Deserialize;
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::{Unsaved, WorkspaceState};
use crate::workspace::{Entry, Unsaved, WorkspaceState, free_path};
fn paths(picked: Vec<FilePath>) -> Vec<PathBuf> {
picked
@@ -19,6 +25,32 @@ fn paths(picked: Vec<FilePath>) -> Vec<PathBuf> {
.collect()
}
/// Asks where to write a GPX file, proposing `file_name` in `folder`. `None` if the user cancelled.
fn ask_save_path<R: Runtime>(
app: &AppHandle<R>,
file_name: &str,
folder: Option<&Path>,
) -> Option<PathBuf> {
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<R: Runtime>(app: &AppHandle<R>, folder: Option<&Path>) -> Option<PathBuf> {
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<AppState>) -> WorkspaceState {
state.core(|core| core.workspace_state())
@@ -35,17 +67,150 @@ pub fn open_files<R: Runtime>(app: AppHandle<R>, state: State<AppState>) -> api:
let Some(picked) = picked else {
return api::Outcome::failed_none();
};
let files = paths(picked);
open_paths(state, paths(picked), 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`); otherwise they are open for good, even the preview.
#[tauri::command(async)]
pub fn open_paths(
state: State<AppState>,
paths: Vec<PathBuf>,
preview: Option<bool>,
) -> api::Outcome {
state.core(move |core| {
let outcome = core.workspace.open_files(&mut core.engine, &files, true);
core.keep_workspace();
core.persist();
let outcome = core
.workspace
.open(&mut core.engine, &paths, preview.unwrap_or_default());
core.commit(None);
outcome
})
}
/// Opens the folder that the user picks: its GPX files are opened and it is watched. The folder
/// that was open is closed, `unsaved` says what to do with its files that have unsaved edits.
/// Writes the files among `ids` that are not the ones on the disk, asking where to put the ones
/// that have no place: the file dialog for a single one, a folder for several of them. Says whether
/// they were written, which is not the case if the user cancelled.
///
/// The files whose file on the disk was replaced are overwritten: the frontend asks before.
fn write_unsaved<R: Runtime>(
app: &AppHandle<R>,
state: &AppState,
ids: Vec<Uuid>,
) -> Result<bool, String> {
let (unlinked, start) = {
let ids = ids.clone();
state.core(move |core| {
(
core.workspace.unlinked(&core.engine, &ids),
core.workspace.folder.clone(),
)
})
};
let mut chosen = BTreeMap::new();
let mut folder = None;
match unlinked.as_slice() {
[] => {}
[(id, name)] => match ask_save_path(app, &format!("{name}.gpx"), start.as_deref()) {
Some(path) => {
chosen.insert(*id, path);
}
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
.workspace
.save(&mut core.engine, &ids, &chosen, folder.as_deref());
core.wrote(result)
})
}
/// Writes files to the disk, replacing what is there. The ones that are not on the disk yet go in
/// their folder, or where the user says. Says whether they were written (`false` if the user
/// cancelled).
#[tauri::command(async)]
pub fn save_files<R: Runtime>(
app: AppHandle<R>,
state: State<AppState>,
file_ids: Vec<Uuid>,
) -> Result<bool, String> {
write_unsaved(&app, &state, file_ids)
}
/// Writes a file where the user says, which is where the file is from then on. Says whether it was
/// written (`false` if the user cancelled).
#[tauri::command(async)]
pub fn save_file_as<R: Runtime>(
app: AppHandle<R>,
state: State<AppState>,
file_id: Uuid,
) -> Result<bool, String> {
let (name, current, folder) = state.core(move |core| {
let name = core
.engine
.file_structure(&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);
};
// 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| {
let result = core.workspace.save_as(&mut core.engine, file_id, &path);
core.wrote(result)
})
}
/// Reads files again from the disk: the edits they have are lost (they are one undo step).
#[tauri::command(async)]
pub fn reload_files(state: State<AppState>, file_ids: Vec<Uuid>) -> Result<api::Outcome, String> {
state.core(move |core| {
let outcome = core.workspace.reload(&mut core.engine, &file_ids);
core.commit(None);
outcome
})
}
/// The files among `ids` that are not the ones on the disk, saved first if `unsaved` says so.
/// Says whether to go on, which is not the case if the user cancelled saving them.
fn settle<R: Runtime>(
app: &AppHandle<R>,
state: &AppState,
ids: Vec<Uuid>,
unsaved: Option<Unsaved>,
) -> Result<bool, String> {
if unsaved != Some(Unsaved::Save) {
return Ok(true);
}
let unsaved_ids = state.core(move |core| core.workspace.unsaved(&core.engine, &ids));
if unsaved_ids.is_empty() {
return Ok(true);
}
write_unsaved(app, state, unsaved_ids)
}
/// Opens the folder that the user picks, which is watched. Its files are not opened. The folder
/// that was open is closed with its files: `unsaved` says what to do with the ones that have
/// unsaved edits.
#[tauri::command(async)]
pub fn open_folder<R: Runtime>(
app: AppHandle<R>,
@@ -60,80 +225,223 @@ pub fn open_folder<R: Runtime>(
else {
return Ok(api::Outcome::failed_none());
};
let all = state.core(|core| core.workspace.all(&core.engine));
if !settle(&app, &state, all, unsaved)? {
return Ok(api::Outcome::failed_none());
}
state.core(move |core| {
let discard = unsaved.is_some();
let outcome = core
.workspace
.open_folder(&mut core.engine, &folder, unsaved);
core.keep_workspace();
core.persist();
core.watch();
core.announce(None);
outcome
.open_folder(&mut core.engine, &folder, discard);
core.commit(None);
Ok(outcome)
})
}
/// Closes the folder: it is not watched anymore, and its files are closed. `unsaved` says what to
/// do with the ones that have unsaved edits (they stay without it).
/// Closes the folder: it is not watched anymore, and the files are closed with it. `unsaved` says
/// what to do with the ones that have unsaved edits (they stay open without it).
#[tauri::command(async)]
pub fn close_folder(
pub fn close_folder<R: Runtime>(
app: AppHandle<R>,
state: State<AppState>,
unsaved: Option<Unsaved>,
) -> Result<api::Outcome, String> {
let all = state.core(|core| core.workspace.all(&core.engine));
if !settle(&app, &state, all, unsaved)? {
return Ok(api::Outcome::failed_none());
}
state.core(move |core| {
let outcome = core.workspace.close_folder(&mut core.engine, unsaved);
core.keep_workspace();
core.persist();
core.watch();
core.announce(None);
outcome
let outcome = core
.workspace
.close_folder(&mut core.engine, unsaved.is_some());
core.commit(None);
Ok(outcome)
})
}
/// Writes files to the disk, replacing what is there. The ones that are not on the disk yet go in
/// the open folder, or where the user says if there is none.
/// Closes files. `unsaved` says what to do with the ones that have unsaved edits: they are saved
/// first, or the edits are lost (they stay open without it).
#[tauri::command(async)]
pub fn save_files<R: Runtime>(
pub fn close_files<R: Runtime>(
app: AppHandle<R>,
state: State<AppState>,
file_ids: Vec<Uuid>,
) -> Result<(), String> {
let (ids, unlinked, folder) = {
let ids = file_ids.clone();
unsaved: Option<Unsaved>,
) -> Result<api::Outcome, String> {
if !settle(&app, &state, file_ids.clone(), unsaved)? {
return Ok(api::Outcome::failed_none());
}
state.core(move |core| {
(
ids.clone(),
core.workspace.unlinked(&core.engine, &ids),
core.workspace.folder.clone(),
)
let outcome = core
.workspace
.close_files(&mut core.engine, &file_ids, unsaved.is_some());
core.commit(None);
Ok(outcome)
})
};
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(()),
}
}
}
/// The folders and GPX files that are in a folder of the open folder, or in it (the root of the
/// tree when no path is given).
#[tauri::command(async)]
pub fn list_folder(state: State<AppState>, path: Option<PathBuf>) -> Result<Vec<Entry>, String> {
state.core(move |core| {
let result = core.workspace.save(&core.engine, &ids, &chosen);
core.keep_workspace();
core.announce(None);
let Some(path) = path.or_else(|| core.workspace.folder.clone()) else {
return Ok(vec![]);
};
core.workspace.list_folder(&path)
})
}
/// What the files are exported with, see `ExportOptions`.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportArguments {
time: bool,
hr: bool,
cad: bool,
atemp: bool,
power: bool,
osm: bool,
as_route: bool,
}
impl From<ExportArguments> for ExportOptions {
fn from(arguments: ExportArguments) -> Self {
Self {
time: arguments.time,
hr: arguments.hr,
cad: arguments.cad,
atemp: arguments.atemp,
power: arguments.power,
osm: arguments.osm,
as_route: arguments.as_route,
}
}
}
/// Writes copies of files where the user says: a file dialog for a single one, a folder for
/// several. The files stay as they are. Says whether they were written (`false` if the user
/// cancelled).
#[tauri::command(async)]
pub fn export_files<R: Runtime>(
app: AppHandle<R>,
state: State<AppState>,
file_ids: Vec<Uuid>,
options: ExportArguments,
) -> Result<bool, String> {
let options = ExportOptions::from(options);
let files: Vec<(String, Vec<u8>)> = state.core(move |core| {
file_ids
.iter()
.filter_map(|id| {
let name = core.engine.file_structure(&FileId(*id))?.name.clone();
Some((name, api::export_file(&core.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) = ask_save_path(&app, &format!("{name}.gpx"), None) else {
return Ok(false);
};
write(path, bytes).map(|()| true)
}
_ => {
let Some(folder) = ask_folder(&app, None) else {
return Ok(false);
};
let mut written: Vec<PathBuf> = vec![];
for (name, bytes) in &files {
let path = free_path(&folder, name, |path| written.iter().any(|w| w == path));
write(path.clone(), bytes)?;
written.push(path);
}
Ok(true)
}
}
}
/// 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<AppState>,
parent: PathBuf,
name: String,
) -> Result<PathBuf, String> {
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<AppState>,
path: PathBuf,
name: String,
) -> Result<api::Outcome, String> {
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(&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.commit(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<AppState>,
paths: Vec<PathBuf>,
folder: PathBuf,
) -> Result<(), String> {
state.core(move |core| {
let result = core.workspace.move_paths(&paths, &folder);
core.commit(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<AppState>, paths: Vec<PathBuf>) -> Result<api::Outcome, String> {
state.core(move |core| {
let outcome = core
.workspace
.delete_paths(&mut core.engine, &paths, to_trash)?;
core.commit(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()
})
}
+22 -6
View File
@@ -1,15 +1,31 @@
//! Editing the metadata, the style, the structure and the waypoints of the files, and the tools:
//! reversing, cropping, reducing, timestamps, merging, cleaning...
use gpx_engine::{CleanType, MergeType, SplitType, api};
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<AppState>, name: String, desc: String) -> api::Outcome {
state.run(move |e| api::metadata(e, &name, &desc))
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() {
match core.workspace.name_for(id, &name) {
Ok(valid) => name = valid,
Err(error) => return api::Outcome::failed(error),
}
}
}
core.edit(move |e| api::metadata(e, &name, &desc))
}
#[tauri::command(async)]
@@ -139,12 +155,12 @@ pub fn create_artificial_timestamps(
#[tauri::command(async)]
pub fn merge(state: State<AppState>, merge_type: MergeType, remove_gaps: bool) -> api::Outcome {
state.run(move |e| api::merge(e, merge_type, remove_gaps))
state.core(move |core| core.creating(None, false, |e| api::merge(e, merge_type, remove_gaps)))
}
#[tauri::command(async)]
pub fn extract(state: State<AppState>) -> api::Outcome {
state.run(api::extract)
state.core(|core| core.creating(None, false, api::extract))
}
#[tauri::command(async)]
@@ -214,5 +230,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)))
}
+8 -4
View File
@@ -1,5 +1,7 @@
//! Creating, loading, deleting, moving, copying and pasting files and their elements, and undoing.
use std::path::PathBuf;
use gpx_engine::api;
use tauri::State;
use tauri::ipc::{InvokeBody, Request};
@@ -14,8 +16,10 @@ pub fn new_file(
lng: Option<f64>,
lat: Option<f64>,
ele: Option<f64>,
folder: Option<PathBuf>,
) -> api::Outcome {
state.run(move |e| api::new_file(e, &name, lng, lat, ele))
// in `folder` if it is given, otherwise like the selected file, otherwise in the open folder
state.core(move |core| core.creating(folder, true, |e| api::new_file(e, &name, lng, lat, ele)))
}
/// The files are in the body, one after the other, the headers `lengths` and `names` (JSON,
@@ -61,7 +65,7 @@ fn percent_decode(value: &str) -> Option<String> {
#[tauri::command(async)]
pub fn duplicate(state: State<AppState>) -> api::Outcome {
state.run(api::duplicate)
state.core(|core| core.creating(None, false, api::duplicate))
}
#[tauri::command(async)]
@@ -105,10 +109,10 @@ pub fn move_elements(
#[tauri::command(async)]
pub fn undo(state: State<AppState>) -> api::Outcome {
state.run(api::undo)
state.edit(api::undo)
}
#[tauri::command(async)]
pub fn redo(state: State<AppState>) -> api::Outcome {
state.run(api::redo)
state.edit(api::redo)
}
+1
View File
@@ -12,3 +12,4 @@ pub mod queries;
pub mod selection;
pub mod statistics;
pub mod storage;
pub mod system;
+28
View File
@@ -0,0 +1,28 @@
//! What the app asks of the system, outside of the files.
use std::path::PathBuf;
use tauri::{AppHandle, Runtime};
use tauri_plugin_opener::OpenerExt;
/// Opens a web page (or an email to write) with the app of the system for it: the links of the
/// page are not followed in the window of the app. Only `http`, `https` and `mailto` addresses are
/// opened.
#[tauri::command(async)]
pub fn open_url<R: Runtime>(app: AppHandle<R>, url: String) -> Result<(), String> {
let allowed = ["http://", "https://", "mailto:"];
if !allowed.iter().any(|scheme| url.starts_with(scheme)) {
return Err(format!("{url} is not a web address"));
}
app.opener()
.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<R: Runtime>(app: AppHandle<R>, path: PathBuf) -> Result<(), String> {
app.opener()
.reveal_item_in_dir(path)
.map_err(|error| error.to_string())
}
+14
View File
@@ -50,6 +50,7 @@ pub fn run() {
));
register_commands(builder)
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.setup(|app| {
let handle = app.handle().clone();
let emit: state::Emit = std::sync::Arc::new(move |event, payload| {
@@ -184,9 +185,22 @@ fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Bu
// the file system
disk::workspace_state,
disk::open_files,
disk::open_paths,
disk::open_folder,
disk::close_folder,
disk::close_files,
disk::save_files,
disk::save_file_as,
disk::reload_files,
disk::export_files,
disk::list_folder,
disk::create_folder,
disk::rename_path,
disk::move_paths,
disk::delete_paths,
// the system
system::open_url,
system::reveal_path,
])
}
+154 -24
View File
@@ -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;
@@ -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<dyn Fn(&str, serde_json::Value) + Send + Sync>;
@@ -32,6 +32,8 @@ pub struct Core {
storage: Arc<FileStorage>,
persister: Persister,
watcher: Option<Debouncer<notify::RecommendedWatcher>>,
/// What the watcher watches, see `Workspace::watched`.
watching: Vec<(PathBuf, bool)>,
jobs: Sender<Job>,
emit: Emit,
}
@@ -50,6 +52,7 @@ impl Core {
persister: Persister::default(),
storage,
watcher: None,
watching: vec![],
jobs,
emit,
};
@@ -86,15 +89,65 @@ impl Core {
if paths.is_empty() {
return;
}
let outcome = self.workspace.open_files(&mut self.engine, paths, true);
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);
}
pub fn keep_workspace(&self) {
/// Runs `f` on the engine, which can change: what changed is kept, and the files on the disk are
/// renamed like the files of the engine were (an edit of the name, or its undo). Gives what
/// could not be renamed.
fn editing<T>(&mut self, f: impl FnOnce(&mut Engine) -> T) -> (T, Vec<String>) {
let names = self.workspace.names(&self.engine);
let preview = self.workspace.preview;
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
let kept = preview.is_some_and(|id| signature(&self.engine, id) != preview_before);
if let Some(id) = preview.filter(|_| kept) {
self.workspace.keep(&[id]);
}
let (renamed, errors) = self.workspace.follow_names(&mut self.engine, &names);
if kept || renamed {
self.commit(None);
} else {
self.persist();
}
(result, errors)
}
/// Like `editing`, for an edit that can rename files: the outcome tells the first thing that
/// could not be renamed (a key of the dictionaries of the frontend, which shows it).
pub fn edit(&mut self, f: impl FnOnce(&mut Engine) -> api::Outcome) -> api::Outcome {
let (outcome, errors) = self.editing(f);
match errors.first() {
Some(error) if !outcome.has_error() => {
outcome.merge(api::Outcome::failed(error.clone()))
}
_ => outcome,
}
}
/// After files were written (`result`): what changed in the engine (the files are called like
/// 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<api::Outcome, String>) -> Result<bool, String> {
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());
self.watch();
}
pub fn workspace_state(&self) -> WorkspaceState {
@@ -109,13 +162,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,22 +187,86 @@ 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());
}
}
Err(error) => eprintln!("the folder cannot be watched: {error}"),
self.watcher = Some(debouncer);
}
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());
self.persist();
self.announce(Some(&outcome));
// 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.commit(Some(&outcome));
}
}
/// Runs `f`, which creates files (a new file, a copy, the result of a split...). Where they go
/// when they are saved is the folder of the files they come from (the selected ones, before
/// `f`), if it is in the open folder. `default_folder` says whether a file with nothing to
/// come from goes in the open folder: a new file does.
pub fn creating(
&mut self,
folder: Option<PathBuf>,
default_folder: bool,
f: impl FnOnce(&mut Engine) -> api::Outcome,
) -> api::Outcome {
let selected: Vec<uuid::Uuid> = self
.engine
.selection()
.file_ids()
.into_iter()
.map(|gpx_engine::FileId(id)| id)
.collect();
let dir = folder
.map(|folder| crate::workspace::canonical(&folder))
.or_else(|| self.workspace.source_dir(&selected))
.or_else(|| {
default_folder
.then(|| self.workspace.folder.clone())
.flatten()
});
let 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
let made: Vec<uuid::Uuid> = outcome
.added()
.iter()
.filter_map(|id| uuid::Uuid::parse_str(id).ok())
.filter(|id| self.workspace.dirs.contains_key(id))
.collect();
match self.workspace.save_new(&mut self.engine, &made) {
Ok(renamed) => outcome = outcome.merge(renamed),
Err(error) if !outcome.has_error() => {
outcome = outcome.merge(api::Outcome::failed(error));
}
Err(_) => {}
}
}
self.commit(None);
outcome
}
}
@@ -201,15 +321,25 @@ impl AppState {
result.recv().expect("the engine thread stopped")
}
/// Runs `f` on the engine, which can change: what changed is kept.
/// Runs `f` on the engine, which can change: what changed is kept (see `Core::editing`).
pub fn run<T: Send + 'static>(&self, f: impl FnOnce(&mut Engine) -> T + Send + 'static) -> T {
self.core(move |core| {
let result = f(&mut core.engine);
core.persist();
let (result, errors) = core.editing(f);
for error in errors {
eprintln!("{error}");
}
result
})
}
/// Like `run`, for an edit that can rename files: the outcome tells what could not be renamed.
pub fn edit(
&self,
f: impl FnOnce(&mut Engine) -> api::Outcome + Send + 'static,
) -> api::Outcome {
self.core(move |core| core.edit(f))
}
/// Reads from the engine.
pub fn read<T: Send + 'static>(&self, f: impl FnOnce(&Engine) -> T + Send + 'static) -> T {
self.core(move |core| f(&core.engine))
+362
View File
@@ -363,3 +363,365 @@ 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 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"], 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("style", json!({ "color": "#ff0000" }));
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();
}
#[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<String> = 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<String> = 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();
}
#[test]
fn files_can_be_reordered() {
let app = App::new();
let mut ids = vec![];
for name in ["a", "b", "c"] {
let outcome = app.call("new_file", json!({ "name": name }));
ids.push(outcome["added"][0].as_str().unwrap().to_string());
}
assert_eq!(app.call("file_order", json!({})), json!(ids));
let outcome = app.call("reorder", json!({ "fileIds": [ids[2]], "index": 0 }));
assert_eq!(outcome["changed"], true);
assert_eq!(outcome["orderChanged"], true);
assert_eq!(
app.call("file_order", json!({})),
json!([ids[2], ids[0], ids[1]])
);
}
/// A folder of its own, with GPX files whose name inside is not the name of the file.
fn folder_with(files: &[&str]) -> std::path::PathBuf {
let folder = std::env::temp_dir().join(format!("gpx-names-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&folder).unwrap();
for name in files {
std::fs::write(
folder.join(format!("{name}.gpx")),
r#"<?xml version="1.0"?><gpx version="1.1" creator="test"><metadata><name>Inside</name></metadata><trk><trkseg><trkpt lat="50.0" lon="4.0"></trkpt><trkpt lat="50.1" lon="4.1"></trkpt></trkseg></trk></gpx>"#,
)
.unwrap();
}
folder
}
fn name_of(app: &App, id: &str) -> String {
app.call("file_structure", json!({ "fileId": id }))["name"]
.as_str()
.unwrap()
.to_string()
}
#[test]
fn a_file_is_called_like_its_file_and_renaming_it_renames_the_file() {
let app = App::new();
let folder = folder_with(&["outside", "taken"]);
let outcome = app.call(
"open_paths",
json!({ "paths": [folder.join("outside.gpx")] }),
);
let id = outcome["added"][0].as_str().unwrap().to_string();
// the name in the file is not the name of the file: the file wins, and nothing is unsaved
assert_eq!(name_of(&app, &id), "outside");
assert_eq!(
app.call("workspace_state", json!({}))["files"][&id]["dirty"],
false
);
// renaming renames the file on the disk
app.call("select", json!({ "fileIds": [id], "mode": "replace" }));
let outcome = app.call("metadata", json!({ "name": "renamed", "desc": "" }));
assert!(outcome.get("error").is_none());
assert!(folder.join("renamed.gpx").exists());
assert!(!folder.join("outside.gpx").exists());
let path = app.call("workspace_state", json!({}))["files"][&id]["path"].clone();
assert!(path.as_str().unwrap().ends_with("renamed.gpx"));
// the file had nothing unsaved: it is written with its new name, and still has nothing unsaved
let dirty = |app: &App| app.call("workspace_state", json!({}))["files"][&id]["dirty"].clone();
let inside = |name: &str| std::fs::read_to_string(folder.join(format!("{name}.gpx"))).unwrap();
assert_eq!(dirty(&app), false);
assert!(inside("renamed").contains("<name>renamed</name>"));
// undone and redone, on the disk too, still with nothing unsaved
app.call("undo", json!({}));
assert_eq!(name_of(&app, &id), "outside");
assert!(folder.join("outside.gpx").exists() && !folder.join("renamed.gpx").exists());
assert_eq!(dirty(&app), false);
assert!(inside("outside").contains("<name>outside</name>"));
app.call("redo", json!({}));
assert_eq!(name_of(&app, &id), "renamed");
assert!(folder.join("renamed.gpx").exists() && !folder.join("outside.gpx").exists());
assert_eq!(dirty(&app), false);
// the name of another file is not taken, nothing changes
let outcome = app.call("metadata", json!({ "name": "taken", "desc": "" }));
assert_eq!(outcome["error"], crate::workspace::NAME_TAKEN);
assert_eq!(name_of(&app, &id), "renamed");
assert!(folder.join("renamed.gpx").exists());
// a name is one that a file can have
app.call("metadata", json!({ "name": "a/b", "desc": "" }));
assert_eq!(name_of(&app, &id), "a_b");
assert!(folder.join("a_b.gpx").exists());
// a file with edits is renamed, but not written: its edits are not saved for it
app.call("style", json!({ "color": "#ff0000" }));
assert_eq!(dirty(&app), true);
app.call("metadata", json!({ "name": "edited", "desc": "" }));
assert!(folder.join("edited.gpx").exists());
assert_eq!(dirty(&app), true);
assert!(!inside("edited").contains("ff0000"));
app.call("undo", json!({}));
app.call("undo", json!({}));
assert_eq!(name_of(&app, &id), "a_b");
assert_eq!(dirty(&app), false);
// only the case changes: the same file
app.call("metadata", json!({ "name": "A_B", "desc": "" }));
assert_eq!(name_of(&app, &id), "A_B");
let names: Vec<String> = std::fs::read_dir(&folder)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert!(names.contains(&"A_B.gpx".to_string()));
assert_eq!(names.len(), 2);
std::fs::remove_dir_all(folder).unwrap();
}
#[test]
fn a_new_file_written_under_another_name_takes_it() {
let app = App::new();
let folder = folder_with(&["route"]);
// a new file made in the folder is written there at once: route.gpx is taken
let created = app.call(
"new_file",
json!({ "name": "route", "lng": 4.4, "lat": 50.8, "folder": folder }),
);
let id = created["added"][0].as_str().unwrap().to_string();
assert!(folder.join("route (2).gpx").exists());
assert_eq!(name_of(&app, &id), "route (2)");
assert_eq!(
app.call("workspace_state", json!({}))["files"][&id]["dirty"],
false
);
// which is not an edit to undo
app.call("select", json!({ "fileIds": [id], "mode": "replace" }));
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<String> {
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();
}
+1299 -521
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -18,7 +18,9 @@
"width": 1400,
"height": 900,
"minWidth": 800,
"minHeight": 600
"minHeight": 600,
"maximized": true,
"dragDropEnabled": false
}
],
"security": { "csp": null }
+102 -42
View File
@@ -4,8 +4,8 @@ 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 { 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. */
@@ -32,71 +32,131 @@ 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<UnsavedChoice | undefined | 'cancel'> {
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]) => {
return ids.map((id) => {
const file = files.get(id);
return file ? get(file).structure.name : id;
});
return names.length > 0 ? askUnsaved(names) : undefined;
}
/** 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<UnsavedChoice | undefined | 'cancel'> {
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 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) {
engine.save(ids);
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));
}
}
+102 -10
View File
@@ -40,11 +40,14 @@
FolderOpen,
FileStack,
FileX,
Save,
SaveAll,
BookOpenText,
ChartArea,
Maximize,
Maximize2,
Minimize2,
FolderX,
} from '@lucide/svelte';
import { map } from '$lib/components/map/map';
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
@@ -59,10 +62,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 +97,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 +161,18 @@
<Shortcut key="+" ctrl={true} />
</Menubar.Item>
<Menubar.Separator />
<Menubar.Item onclick={triggerFileInput}>
<Menubar.Item onclick={openFiles}>
<FolderOpen size="16" />
{i18n._('menu.open')}
<Shortcut key="O" ctrl={true} />
</Menubar.Item>
{#if desktop}
<Menubar.Item onclick={openFolder}>
<FolderOpen size="16" />
{i18n._('menu.open_folder')}
<Shortcut key="O" ctrl={true} shift={true} />
</Menubar.Item>
{/if}
<Menubar.Separator />
<Menubar.Item onclick={() => engine.duplicate()} disabled={selectionCount == 0}>
<Copy size="16" />
@@ -154,6 +180,46 @@
<Shortcut key="D" ctrl={true} />
</Menubar.Item>
<Menubar.Separator />
{#if desktop}
<Menubar.Item
onclick={() => saveSelected(selectedIds)}
disabled={selectionCount == 0 && !anyUnsaved}
>
<Save size="16" />
{i18n._('menu.save')}
<Shortcut key="S" ctrl={true} />
</Menubar.Item>
<Menubar.Item onclick={saveAll} disabled={!anyUnsaved}>
<Save size="16" />
{i18n._('menu.save_all')}
<Shortcut key="S" ctrl={true} shift={true} />
</Menubar.Item>
<Menubar.Item
onclick={() => saveAs(selectedIds)}
disabled={selectedIds.length !== 1}
>
<SaveAll size="16" />
{i18n._('menu.save_as')}
</Menubar.Item>
<Menubar.Separator />
<Menubar.Item
onclick={() => closeFiles(selectedIds)}
disabled={selectedIds.length === 0}
>
<FileX size="16" />
{i18n._('menu.close')}
<Shortcut key="W" ctrl={true} />
</Menubar.Item>
<Menubar.Item onclick={closeAll} disabled={noFiles}>
<FileX size="16" />
{i18n._('menu.close_all')}
<Shortcut key="W" ctrl={true} shift={true} />
</Menubar.Item>
<Menubar.Item onclick={closeFolder} disabled={!$workspace?.folder}>
<FolderX size="16" />
{i18n._('menu.close_folder')}
</Menubar.Item>
{:else}
<Menubar.Item
onclick={() => engine.delete(true)}
disabled={selectionCount == 0}
@@ -167,6 +233,7 @@
{i18n._('menu.delete_all')}
<Shortcut key="⌫" ctrl={true} shift={true} />
</Menubar.Item>
{/if}
<Menubar.Separator />
<Menubar.Item
onclick={() => (exportState.current = ExportState.SELECTION)}
@@ -174,7 +241,7 @@
>
<Download size="16" />
{i18n._('menu.export')}
<Shortcut key="S" ctrl={true} />
{#if !desktop}<Shortcut key="S" ctrl={true} />{/if}
</Menubar.Item>
<Menubar.Item
onclick={() => (exportState.current = ExportState.ALL)}
@@ -182,7 +249,7 @@
>
<Download size="16" />
{i18n._('menu.export_all')}
<Shortcut key="S" ctrl={true} shift={true} />
{#if !desktop}<Shortcut key="S" ctrl={true} shift={true} />{/if}
</Menubar.Item>
</Menubar.Content>
</Menubar.Menu>
@@ -288,12 +355,17 @@
<Shortcut key="V" ctrl={true} />
</Menubar.Item>
{/if}
{#if !desktop}
<Menubar.Separator />
<Menubar.Item onclick={() => engine.delete()} disabled={selectionCount == 0}>
<Menubar.Item
onclick={() => engine.delete()}
disabled={selectionCount == 0}
>
<Trash2 size="16" />
{i18n._('menu.delete')}
<Shortcut key="⌫" ctrl={true} />
</Menubar.Item>
{/if}
</Menubar.Content>
</Menubar.Menu>
<Menubar.Menu>
@@ -498,6 +570,7 @@
</div>
<Export />
<UnsavedDialog />
<LayerControlSettings bind:open={layerSettingsOpen} />
<svelte:window
@@ -519,8 +592,26 @@
if (e.key === '+' && (e.metaKey || e.ctrlKey)) {
createFile();
e.preventDefault();
} else if (e.key === 'o' && (e.metaKey || e.ctrlKey)) {
triggerFileInput();
} else if ((e.key === 'o' || e.key === 'O') && (e.metaKey || e.ctrlKey)) {
if (desktop && e.shiftKey) {
openFolder();
} else {
openFiles();
}
e.preventDefault();
} else if (desktop && (e.key === 'w' || e.key === 'W') && (e.metaKey || e.ctrlKey)) {
if (e.shiftKey) {
closeAll();
} else {
closeFiles(selectedIds);
}
e.preventDefault();
} else if (desktop && (e.key === 's' || e.key === 'S') && (e.metaKey || e.ctrlKey)) {
if (e.shiftKey) {
saveAll();
} else {
saveSelected(selectedIds);
}
e.preventDefault();
} else if (e.key === 'd' && (e.metaKey || e.ctrlKey)) {
engine.duplicate();
@@ -557,7 +648,8 @@
}
e.preventDefault();
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
if (!targetInput) {
// the desktop app closes files instead
if (!targetInput && !desktop) {
if (e.shiftKey) {
engine.deleteAll();
} else {
+16
View File
@@ -12,6 +12,19 @@
} = $props();
function handleMouseDown(event: PointerEvent) {
// the text on either side is not selected while dragging, and the cursor stays the one of
// the resizer when the pointer leaves it
event.preventDefault();
const { style } = document.body;
const previous = {
userSelect: style.userSelect,
webkitUserSelect: style.webkitUserSelect,
cursor: style.cursor,
};
style.userSelect = 'none';
style.webkitUserSelect = 'none';
style.cursor = orientation === 'col' ? 'col-resize' : 'row-resize';
const startX = event.clientX;
const startY = event.clientY;
const startAfter = after;
@@ -32,10 +45,13 @@
const handleMouseUp = () => {
window.removeEventListener('pointermove', handleMouseMove);
window.removeEventListener('pointerup', handleMouseUp);
window.removeEventListener('pointercancel', handleMouseUp);
Object.assign(style, previous);
};
window.addEventListener('pointermove', handleMouseMove);
window.addEventListener('pointerup', handleMouseUp);
window.addEventListener('pointercancel', handleMouseUp);
}
</script>
@@ -1,28 +1,48 @@
<script lang="ts">
import { setContext, type Snippet } from 'svelte';
import { CollapsibleTreeState } from './utils.svelte';
import { getContext, setContext, type Snippet } from 'svelte';
import { CollapsibleTreeState, StuckRows } from './utils.svelte';
import { stickyKey, type StickyLevel } from './sticky';
const {
defaultState = 'open',
side = 'right',
nohover = false,
slotInsideTrigger = true,
sticky = false,
treeState,
children,
}: {
defaultState?: 'open' | 'closed';
side?: 'left' | 'right';
nohover?: boolean;
slotInsideTrigger?: boolean;
/**
* The rows of the nodes stick to the top of the scrolled area while some of the content of
* their node is in view, each under the rows of the nodes it is in. A tree in a tree is
* like its node.
*/
sticky?: boolean;
/** Which nodes are open, to keep it or to open nodes from the outside. */
treeState?: CollapsibleTreeState;
children: Snippet;
} = $props();
let open = $state(new CollapsibleTreeState(defaultState));
let open = $state(treeState ?? new CollapsibleTreeState(defaultState));
setContext('collapsible-tree-state', open);
setContext('collapsible-tree-side', side);
setContext('collapsible-tree-nohover', nohover);
setContext('collapsible-tree-parent-id', 'root');
setContext('collapsible-tree-slot-inside-trigger', slotInsideTrigger);
setContext<StickyLevel>(
stickyKey,
getContext<StickyLevel | undefined>(stickyKey) ?? {
enabled: sticky,
top: 0,
depth: 0,
stuckRows: new StuckRows(),
}
);
</script>
{@render children()}
@@ -5,31 +5,56 @@
import { getContext, setContext, type Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements';
import type { CollapsibleTreeState } from './utils.svelte';
import { stickyKey, stickyZIndex, trackStuck, type StickyLevel } from './sticky';
const props: {
id: string | number;
class?: ClassValue;
trigger: Snippet;
content: Snippet;
/** Gets whether the node is open, for contents that are only worth building then. */
content: Snippet<[boolean]>;
} = $props();
let state = getContext<CollapsibleTreeState>('collapsible-tree-state');
let treeState = getContext<CollapsibleTreeState>('collapsible-tree-state');
let side = getContext<'left' | 'right'>('collapsible-tree-side');
let nohover = getContext<boolean>('collapsible-tree-nohover');
let slotInsideTrigger = getContext<boolean>('collapsible-tree-slot-inside-trigger');
let parentId = getContext<string>('collapsible-tree-parent-id');
// where the row sticks, under the rows of the nodes this one is in
let level = getContext<StickyLevel>(stickyKey);
let fullId = `${parentId}.${props.id}`;
setContext('collapsible-tree-parent-id', fullId);
let open = state.get(fullId);
let open = treeState.get(fullId);
// the rows of the nodes in this one stick under this row, whatever its height
let rowHeight = $state(0);
setContext<StickyLevel>(stickyKey, {
get enabled() {
return level.enabled;
},
get top() {
return level.top + rowHeight;
},
get depth() {
return level.depth + 1;
},
get stuckRows() {
return level.stuckRows;
},
});
// the row is on top of the content that goes under it
let stuck = $state(false);
// only the deepest row that is stuck has a border: the rows above it run into it
let bordered = $derived(stuck && level.stuckRows.deepest === level.depth);
export function openNode() {
open.current = true;
}
</script>
<Collapsible.Root bind:open={open.current} class={props.class}>
{#snippet row()}
{#if slotInsideTrigger}
<Collapsible.Trigger class="w-full">
<Button
@@ -87,7 +112,36 @@
{/if}
</Button>
{/if}
{/snippet}
<Collapsible.Root bind:open={open.current} class={props.class}>
{#if level.enabled}
<!-- sticks while some of the content of the node is in view, with a border when it does -->
<div
class={['sticky bg-background', bordered && 'border-b']}
style="top: {level.top}px; z-index: {stickyZIndex(level.depth)}"
bind:clientHeight={rowHeight}
{@attach (row) => {
const stop = trackStuck(
row,
() => level.top,
(value) => {
stuck = value;
level.stuckRows.set(fullId, level.depth, value);
}
);
return () => {
stop?.();
level.stuckRows.set(fullId, level.depth, false);
};
}}
>
{@render row()}
</div>
{:else}
{@render row()}
{/if}
<Collapsible.Content>
{@render props.content()}
{@render props.content(open.current)}
</Collapsible.Content>
</Collapsible.Root>
@@ -0,0 +1,79 @@
import type { StuckRows } from './utils.svelte';
/**
* Where the row of a node of a sticky tree sticks: under the rows of the nodes it is in, which stay
* in view as long as some of their content can be seen.
*/
export type StickyLevel = {
/** Whether the rows stick. */
readonly enabled: boolean;
/** How far from the top of the scrolled area the rows of this level stick. */
readonly top: number;
/** How many nodes the rows of this level are in. */
readonly depth: number;
/** Which rows of the tree are stuck. */
readonly stuckRows: StuckRows;
};
/** The key of the context that holds the `StickyLevel`. */
export const stickyKey = 'collapsible-tree-sticky';
/** The rows of the outer levels are above the ones of the inner levels. */
export function stickyZIndex(depth: number): number {
return Math.max(100 - depth, 1);
}
/** The first ancestor that scrolls (or clips), which a sticky row sticks to. */
function scrollParent(node: HTMLElement): HTMLElement | null {
for (let parent = node.parentElement; parent; parent = parent.parentElement) {
if (getComputedStyle(parent).overflowY !== 'visible') {
return parent;
}
}
return null;
}
/**
* Tells whether a sticky row is stuck, and in view: it is held at `top()` from the top of the
* scrolled area, further down than the beginning of the node it is in. A row that is on its way out
* with the end of its node (pushed up under the row of the node above) is not.
*/
export function trackStuck(
row: HTMLElement,
top: () => number,
onChange: (stuck: boolean) => void
) {
const node = row.parentElement;
const scroller = scrollParent(row);
if (!node || !scroller) {
return;
}
let stuck = false;
let frame = 0;
const check = () => {
frame = 0;
const rowTop = row.getBoundingClientRect().top;
const held = scroller.getBoundingClientRect().top + scroller.clientTop + top();
const now =
rowTop - node.getBoundingClientRect().top > 0.5 && Math.abs(rowTop - held) < 0.5;
if (now !== stuck) {
stuck = now;
onChange(stuck);
}
};
const schedule = () => {
if (!frame) {
frame = requestAnimationFrame(check);
}
};
scroller.addEventListener('scroll', schedule, { passive: true });
// the node changes when it is opened or closed, or its content does
const observer = new ResizeObserver(schedule);
observer.observe(node);
schedule();
return () => {
scroller.removeEventListener('scroll', schedule);
observer.disconnect();
cancelAnimationFrame(frame);
};
}
@@ -28,4 +28,35 @@ export class CollapsibleTreeState {
}
return this._open[id];
}
/** The node `from` is now `to` (it was renamed or moved): it stays open or closed, and so do the nodes in it. */
move(from: string, to: string) {
for (const id of Object.keys(this._open)) {
if (id === from || id.startsWith(`${from}.`)) {
this._open[to + id.slice(from.length)] = this._open[id];
delete this._open[id];
}
}
}
}
/**
* The rows of a sticky tree that are stuck, by node, with how deep they are: only the deepest one
* gets the border that shows where the rows end.
*/
export class StuckRows {
private rows: Record<string, number> = $state({});
set(id: string, depth: number, stuck: boolean) {
if (stuck) {
this.rows[id] = depth;
} else if (id in this.rows) {
delete this.rows[id];
}
}
/** The depth of the deepest row that is stuck, -1 if none is. */
get deepest(): number {
return Math.max(-1, ...Object.values(this.rows));
}
}
@@ -100,7 +100,7 @@
<LayerControl />
<GPXLayers />
{#if $files.size > 1}
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
<div class="h-10 w-full pointer-events-none absolute bottom-0 left-0 z-30">
<FileList orientation="horizontal" />
</div>
{/if}
@@ -0,0 +1,188 @@
<script lang="ts">
import { setContext, untrack } from 'svelte';
import { CollapsibleTree, CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
import { CollapsibleTreeState } from '$lib/components/collapsible-tree/utils.svelte';
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
import ExplorerTree from './ExplorerTree.svelte';
import ExplorerRow from './ExplorerRow.svelte';
import {
clickedInExplorer,
explorerFoldersKey,
explorerSelection,
renaming,
treeRows,
type ExplorerFolders,
} 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';
import { folderName, parentOf, subfolder } from '$lib/engine/workspace';
const { workspace, selection } = engine;
let folder = $derived($workspace?.folder);
// which folders are open: new ones when another folder is opened, the folder itself is open at
// first and its folders are closed
let trees = $derived(
folder
? {
root: new CollapsibleTreeState('open'),
folders: new CollapsibleTreeState('closed'),
}
: undefined
);
let container: HTMLElement | undefined = $state();
// 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
const clicked = untrack(() => clickedInExplorer.path);
if (selectedPath === clicked) {
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);
}
});
/**
* 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.
*/
function reveal(folder: string, path: string) {
openFolder(parentOf(path));
let frame = 0;
let attempts = 0;
const scroll = () => {
const row = container?.querySelector(`[data-path="${CSS.escape(path)}"]`);
if (row) {
row.scrollIntoView({ block: 'nearest' });
} else if (attempts++ < 120) {
frame = requestAnimationFrame(scroll);
}
};
frame = requestAnimationFrame(scroll);
return () => cancelAnimationFrame(frame);
}
/**
* The id of the node of a folder of the tree in `trees.folders`: the names of the folders it is
* in, and its own (see `CollapsibleTreeNode`). `undefined` for the open folder itself, which is
* in `trees.root`, and for what is not in it.
*/
function nodeId(path: string): string | undefined {
const names = subfolder(folder, `${path}/_`)?.split('/').filter(Boolean);
return names && names.length > 0 ? ['root', ...names].join('.') : undefined;
}
/** Opens a folder of the tree, and the folders it is in. */
function openFolder(path: string) {
if (!trees) return;
trees.root.get('root.folder').current = true;
const id = nodeId(path);
if (id === undefined) return;
const names = id.split('.');
names.forEach((_, i) => {
if (i > 0) trees!.folders.get(names.slice(0, i + 1).join('.')).current = true;
});
}
setContext<ExplorerFolders>(explorerFoldersKey, {
open: openFolder,
moved(from, to) {
const [fromId, toId] = [nodeId(from), nodeId(to)];
if (trees && fromId !== undefined && toId !== undefined) {
trees.folders.move(fromId, toId);
}
},
});
/** 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();
}
}
</script>
<!--
The tree of the open folder: its folders and GPX files, one level read at a time. The files are
opened from it (the file list has the ones that are open).
-->
{#if folder && trees}
<div class="flex flex-col h-full">
<div class="px-2 py-1 text-sm font-semibold shrink-0">{i18n._('menu.explorer')}</div>
<ScrollArea class="grow min-h-0">
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
bind:this={container}
data-explorer
role="tree"
tabindex="0"
class="flex flex-col pl-1 pr-3 pb-1 outline-none"
{onkeydown}
>
<!-- starts over when another folder is opened -->
{#key folder}
<!-- the folder itself -->
<CollapsibleTree side="left" treeState={trees.root} sticky>
<CollapsibleTreeNode id="folder">
{#snippet trigger()}
<ExplorerRow
path={folder}
name={folderName(folder)}
kind="root"
class="h-7"
/>
{/snippet}
{#snippet content(opened)}
<!-- what is in it is only read when it is open -->
{#if opened}
<div class="ml-4">
<CollapsibleTree side="left" treeState={trees.folders}>
<ExplorerTree />
</CollapsibleTree>
</div>
{/if}
{/snippet}
</CollapsibleTreeNode>
</CollapsibleTree>
{/key}
</div>
</ScrollArea>
</div>
{/if}
<style lang="postcss">
@reference "../../../app.css";
/* a selected file, and a selected folder with its chevron: the whole row of its node */
[data-explorer] :global([data-kind='file'][aria-selected='true']),
[data-explorer] :global([data-slot='button']:has([aria-selected='true'])) {
@apply bg-accent;
}
/* the folder where what is dragged would go */
[data-explorer] :global([data-slot='button']:has([data-drop-target])) {
@apply ring-1 ring-primary bg-accent/60;
}
</style>
@@ -0,0 +1,329 @@
<script lang="ts">
import * as ContextMenu from '$lib/components/ui/context-menu';
import {
ExternalLink,
FileX,
FolderOpen,
FolderX,
PencilLine,
Plus,
Trash2,
} from '@lucide/svelte';
import { getContext, type Snippet } from 'svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import { engine } from '$lib/engine';
import { editableName, fileIdAt, isWithin, parentOf, relocated } from '$lib/engine/workspace';
import { closeFiles, closeFolder, openPaths, showEngineError } from '$lib/actions/file-actions';
import { isMac } from '$lib/utils';
import NameInput from './NameInput.svelte';
import {
clickedInExplorer,
creating,
dragging,
explorerFoldersKey,
explorerSelection,
renaming,
treeRows,
type ExplorerFolders,
} from './state.svelte';
let {
path,
name,
kind,
icon,
class: className = '',
style = '',
}: {
path: string;
/** What the row shows, the name of the file with its extension. */
name: string;
/** The open folder itself, a folder in it, or a GPX file. */
kind: 'root' | 'folder' | 'file';
icon?: Snippet;
class?: string;
style?: string;
} = $props();
const { workspace } = engine;
const folders = getContext<ExplorerFolders>(explorerFoldersKey);
let row: HTMLElement | undefined = $state();
// the open file of the row
let fileId = $derived(kind === 'file' ? fileIdAt($workspace, path) : undefined);
let selected = $derived(explorerSelection.paths.includes(path));
let editing = $derived(renaming.path === path);
let target = $derived(dragging.target === path);
/** What the menu and dragging act on: the selection, if the row is in it. */
function targets(): string[] {
return selected ? explorerSelection.paths : [path];
}
function select(paths: string[]) {
explorerSelection.paths = paths;
explorerSelection.anchor = path;
}
function onclick(event: MouseEvent) {
if (editing || kind === 'root') {
return;
}
if (event.metaKey || event.ctrlKey) {
// added to the selection, or taken out of it, without opening anything
select(
selected
? explorerSelection.paths.filter((other) => other !== path)
: [...explorerSelection.paths, path]
);
event.preventDefault();
event.stopPropagation();
} else if (event.shiftKey && explorerSelection.anchor !== undefined) {
// everything shown between the row where the selection began and this one
const paths = treeRows(row)
.filter((other) => other.kind !== 'root')
.map((other) => other.path);
const [from, to] = [paths.indexOf(explorerSelection.anchor), paths.indexOf(path)];
if (from >= 0 && to >= 0) {
explorerSelection.paths = paths.slice(Math.min(from, to), Math.max(from, to) + 1);
}
event.preventDefault();
event.stopPropagation();
} else if (event.detail <= 1) {
select([path]);
if (kind === 'file') {
// it is in view, the explorer does not have to scroll to it
clickedInExplorer.path = path;
if (fileId !== undefined) {
engine.select([fileId]);
} else {
openPaths([path], true);
}
}
}
}
function ondblclick() {
if (kind === 'file' && !editing) {
clickedInExplorer.path = path;
openPaths([path], false);
}
}
// the menu
/** The folder where what is made from the menu goes: the folder of the row. */
let folder = $derived(kind === 'file' ? undefined : path);
/** Asks for the name of a file or a folder to make in the folder of the row (see `creating`). */
function create(what: 'file' | 'folder') {
if (folder === undefined) return;
// where what is being named is shown
folders.open(folder);
creating.kind = what;
creating.parent = folder;
}
function openSelected() {
const files = treeRows(row)
.filter((other) => other.kind === 'file' && targets().includes(other.path))
.map((other) => other.path);
if (files.length > 0) {
openPaths(files);
}
}
/** The open files among what the menu acts on. */
function openIds(): string[] {
return targets()
.map((other) => fileIdAt($workspace, other))
.filter((id): id is string => id !== undefined);
}
async function remove() {
const paths = targets();
explorerSelection.paths = [];
if (!(await engine.deletePaths(paths))) {
showEngineError();
}
}
// renaming, in the row
async function commit(value: string) {
if (renaming.path !== path) return;
renaming.path = undefined;
const name_ = value.trim();
if (name_ === '' || name_ === editableName(name, kind !== 'file')) return;
// the folder stays open or closed (before its new row is read, back if it is not renamed)
const to = relocated(path, parentOf(path), name_);
folders.moved(path, to);
if (!(await engine.renamePath(path, name_))) {
folders.moved(to, path);
showEngineError();
}
}
// dragging into folders
function ondragstart(event: DragEvent) {
if (kind === 'root' || editing) {
event.preventDefault();
return;
}
const paths = targets();
dragging.paths = paths;
event.dataTransfer?.setData('text/plain', paths.join('\n'));
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
}
}
function ondragend() {
dragging.paths = [];
dragging.target = undefined;
}
/** The folder where what is dropped on the row goes: the folder of a file, among its files. */
let dropFolder = $derived(kind === 'file' ? parentOf(path) : path);
function ondragover(event: DragEvent) {
if (dragging.paths.length === 0) return;
// not into itself, nor where it is already
if (dragging.paths.some((dragged) => isWithin(dropFolder, dragged))) return;
if (dragging.paths.every((dragged) => parentOf(dragged) === dropFolder)) return;
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
// the row of the folder shows it
dragging.target = dropFolder;
}
function ondragleave(event: DragEvent) {
// to another row, which tells where it would go (it was entered first)
const to = event.relatedTarget as Element | null;
if (to?.closest?.('[data-explorer-path]')) return;
dragging.target = undefined;
}
async function ondrop(event: DragEvent) {
event.preventDefault();
const paths = dragging.paths;
ondragend();
if (paths.length === 0) return;
// the folders stay open or closed (before their new rows are read, back if they are not moved)
const moves = paths.map((dragged) => [dragged, relocated(dragged, dropFolder)]);
moves.forEach(([from, to]) => folders.moved(from, to));
if (await engine.movePaths(paths, dropFolder)) {
// where they went is shown
folders.open(dropFolder);
} else {
moves.forEach(([from, to]) => folders.moved(to, from));
showEngineError();
}
}
</script>
<ContextMenu.Root
onOpenChange={(open) => {
if (open && !selected && kind !== 'root') {
select([path]);
}
}}
>
<ContextMenu.Trigger class="grow min-w-0 pointer-events-auto outline-none">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
bind:this={row}
role="treeitem"
aria-selected={selected}
tabindex="-1"
data-explorer-path={path}
data-path={path}
data-kind={kind}
title={path}
draggable={kind !== 'root' && !editing}
data-drop-target={target || undefined}
class="flex flex-row items-center gap-1.5 min-w-0 rounded-md outline-none {className}"
{style}
{onclick}
{ondblclick}
{ondragstart}
{ondragend}
{ondragover}
ondragenter={ondragover}
{ondragleave}
{ondrop}
>
{@render icon?.()}
{#if editing}
<NameInput
value={editableName(name, kind !== 'file')}
oncommit={commit}
oncancel={() => (renaming.path = undefined)}
/>
{:else}
<span class="grow truncate text-left">{name}</span>
{/if}
</div>
</ContextMenu.Trigger>
<!-- the name being edited keeps the focus, which the menu would give back to the row -->
<ContextMenu.Content
onCloseAutoFocus={(event) => {
if (renaming.path !== undefined || creating.parent !== undefined) {
event.preventDefault();
}
}}
>
{#if kind !== 'file'}
<ContextMenu.Item onclick={() => create('file')}>
<Plus size="16" />
{i18n._('menu.explorer_menu.new_file')}
</ContextMenu.Item>
<ContextMenu.Item onclick={() => create('folder')}>
<Plus size="16" />
{i18n._('menu.explorer_menu.new_folder')}
</ContextMenu.Item>
<ContextMenu.Separator />
{:else}
<ContextMenu.Item onclick={openSelected}>
<FolderOpen size="16" />
{i18n._('menu.explorer_menu.open')}
</ContextMenu.Item>
{#if openIds().length > 0}
<ContextMenu.Item onclick={() => closeFiles(openIds())}>
<FileX size="16" />
{i18n._('menu.close')}
</ContextMenu.Item>
{/if}
<ContextMenu.Separator />
{/if}
{#if kind !== 'root'}
<ContextMenu.Item
disabled={targets().length !== 1}
onclick={() => (renaming.path = path)}
>
<PencilLine size="16" />
{i18n._('menu.explorer_menu.rename')}
</ContextMenu.Item>
<ContextMenu.Item onclick={remove}>
<Trash2 size="16" />
{i18n._('menu.delete')}
</ContextMenu.Item>
<ContextMenu.Separator />
{/if}
<ContextMenu.Item onclick={() => engine.revealPath(path)}>
<ExternalLink size="16" />
{i18n._(isMac() ? 'menu.explorer_menu.reveal_finder' : 'menu.explorer_menu.reveal')}
</ContextMenu.Item>
{#if kind === 'root'}
<ContextMenu.Separator />
<ContextMenu.Item onclick={closeFolder}>
<FolderX size="16" />
{i18n._('menu.close_folder')}
</ContextMenu.Item>
{/if}
</ContextMenu.Content>
</ContextMenu.Root>
@@ -0,0 +1,149 @@
<script lang="ts">
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
import { FileText, Folder } from '@lucide/svelte';
import { getContext } from 'svelte';
import { get } from 'svelte/store';
import { toast } from 'svelte-sonner';
import { stickyKey, type StickyLevel } from '$lib/components/collapsible-tree/sticky';
import ExplorerTree from './ExplorerTree.svelte';
import ExplorerRow from './ExplorerRow.svelte';
import { engine, type FolderEntry } from '$lib/engine';
import { editableName, fileIdAt } from '$lib/engine/workspace';
import { i18n } from '$lib/i18n/i18n.svelte';
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { showEngineError } from '$lib/actions/file-actions';
import NameInput from './NameInput.svelte';
import { creating, explorerSelection } from './state.svelte';
let {
path,
}: {
/** The folder whose content is listed, the open folder if it is not given. */
path?: string;
} = $props();
const { workspace, folderVersion } = engine;
// the rows of the folders that stick above the files of this one
const level = getContext<StickyLevel>(stickyKey);
let entries: FolderEntry[] = $state([]);
// the folder of the list, where what is being made goes
let folder = $derived(path ?? $workspace?.folder);
let pending = $derived(
creating.parent !== undefined && creating.parent === folder ? creating.kind : undefined
);
// the folders come first, then the files (and what is being made goes at the top of its kind)
let folders = $derived(entries.filter((entry) => entry.isDir));
let files = $derived(entries.filter((entry) => !entry.isDir));
/**
* Makes the file or folder that was asked for, called `value`: nothing is made without a name,
* nor with the name of something that is there (the user is told, and can change it). A new
* file is selected, for the routing tool.
*/
async function create(value: string, retry: boolean) {
const kind = pending;
const parent = folder;
if (kind === undefined || parent === undefined) return;
const name = value.trim();
const taken = entries.some(
(entry) =>
entry.isDir === (kind === 'folder') &&
editableName(entry.name, entry.isDir).toLowerCase() === name.toLowerCase()
);
if (name !== '' && taken) {
toast.error(i18n._('menu.metadata.name_taken'));
if (retry) return;
}
creating.parent = undefined;
if (name === '' || taken) return;
let created: string | undefined;
if (kind === 'file') {
const id = await engine.newFileIn(name, parent);
created = id === undefined ? undefined : get(workspace)?.files[id]?.path;
if (created !== undefined) {
currentTool.set(Tool.ROUTING);
}
} else {
created = await engine.createFolder(parent, name);
}
if (created === undefined) {
showEngineError();
return;
}
explorerSelection.paths = [created];
explorerSelection.anchor = created;
}
$effect(() => {
// read again when something changed on the file system
$folderVersion;
let current = true;
engine.listFolder(path).then((list) => {
if (current) {
entries = list;
}
});
return () => {
current = false;
};
});
</script>
<!-- the name of what is being made, which it is made with -->
{#snippet named(kind: 'file' | 'folder')}
<div class="flex flex-row items-center h-7 pl-1 pr-1 {kind === 'file' ? 'gap-1.5' : 'gap-1'}">
{#if kind === 'file'}
<FileText size="16" class="shrink-0" />
{:else}
<Folder size="16" class="shrink-0" />
{/if}
<NameInput
placeholder={i18n._(
kind === 'file' ? 'menu.explorer_menu.new_file' : 'menu.explorer_menu.new_folder'
)}
oncommit={create}
oncancel={() => (creating.parent = undefined)}
/>
</div>
{/snippet}
{#if pending === 'folder'}
{@render named('folder')}
{/if}
{#each folders as entry (entry.path)}
<CollapsibleTreeNode id={entry.name}>
{#snippet trigger()}
<ExplorerRow path={entry.path} name={entry.name} kind="folder" class="h-7" />
{/snippet}
{#snippet content(opened)}
<!-- the content of a folder is only read when it is opened -->
{#if opened}
<div class="ml-4">
<ExplorerTree path={entry.path} />
</div>
{/if}
{/snippet}
</CollapsibleTreeNode>
{/each}
{#if pending === 'file'}
{@render named('file')}
{/if}
{#each files as entry (entry.path)}
<!-- the files that are not open are greyed -->
<ExplorerRow
path={entry.path}
name={entry.name}
kind="file"
class="w-full h-7 px-1 text-sm hover:bg-muted {fileIdAt($workspace, entry.path) ===
undefined
? 'text-muted-foreground'
: ''}"
style="scroll-margin-top: {level.top}px"
>
{#snippet icon()}
<FileText size="16" class="shrink-0" />
{/snippet}
</ExplorerRow>
{/each}
@@ -0,0 +1,45 @@
<script lang="ts">
let {
value = '',
placeholder,
oncommit,
oncancel,
}: {
value?: string;
placeholder?: string;
/** The name is given: with Enter (`enter`), or by leaving the field. */
oncommit: (value: string, enter: boolean) => void;
/** Escape: no name is given. */
oncancel: () => void;
} = $props();
function onkeydown(event: KeyboardEvent) {
// not for the row or the explorer it is in (a folder opens with Enter or Space, the selected
// files open with Enter)
event.stopPropagation();
if (event.key === 'Enter') {
oncommit((event.currentTarget as HTMLInputElement).value, true);
} else if (event.key === 'Escape') {
oncancel();
}
}
</script>
<!-- the name of a file or a folder of the explorer, being given or changed -->
<input
class="h-6 grow min-w-0 px-1 text-sm font-normal not-italic bg-background border rounded outline-none"
{value}
{placeholder}
{@attach (input) => {
// once it is shown: it can be in a folder that is opening
const frame = requestAnimationFrame(() => {
input.focus();
input.select();
});
return () => cancelAnimationFrame(frame);
}}
{onkeydown}
onkeyup={(event) => event.stopPropagation()}
onclick={(event) => event.stopPropagation()}
onblur={(event) => oncommit(event.currentTarget.value, false)}
/>
@@ -0,0 +1 @@
export { default as Explorer } from './Explorer.svelte';
@@ -0,0 +1,47 @@
/** 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,
});
/** What the rows of the explorer can do to the folders of its tree (see `Explorer`). */
export type ExplorerFolders = {
/** Opens a folder of the tree, and the folders it is in. */
open(path: string): void;
/** A folder was renamed or moved: it stays open or closed, and so does what is in it. */
moved(from: string, to: string): void;
};
export const explorerFoldersKey = 'explorer-folders';
/** 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<HTMLElement>('[data-explorer-path]') ?? [])].map((row) => ({
path: row.dataset.explorerPath!,
kind: row.dataset.kind!,
}));
}
@@ -31,6 +31,11 @@ export function allFileIds(): string[] {
}
async function exportFiles(fileIds: string[], options: ExportOptions) {
if (get(engine.workspace)) {
// the desktop app: the user chooses where the files go
await engine.exportFiles(fileIds, options);
return;
}
const files = (
await Promise.all(
fileIds.map(async (fileId) => {
@@ -36,21 +36,21 @@
</script>
<ScrollArea
class="shrink-0 {orientation === 'vertical' ? 'p-0 pr-3' : 'h-10 px-1'}"
class="shrink-0 {orientation === 'vertical' ? 'p-0' : 'h-10 px-1'}"
{orientation}
scrollbarXClasses={orientation === 'vertical' ? '' : 'hidden'}
scrollbarYClasses={orientation === 'vertical' ? '' : ''}
>
<div
class="flex {orientation === 'vertical'
? 'flex-col py-1 pl-1 min-h-screen'
? 'flex-col py-1 pl-1 pr-2 min-h-full'
: 'flex-row'} {className ?? ''}"
{style}
>
<FileListNodeContent node={null} />
{#if orientation === 'vertical'}
<ContextMenu.Root>
<ContextMenu.Trigger class="grow" />
<ContextMenu.Trigger class="grow outline-none" />
<ContextMenu.Content>
<ContextMenu.Item onclick={createFile}>
<Plus size="16" />
@@ -17,6 +17,7 @@
Maximize,
Scissors,
FileStack,
FileX,
} from '@lucide/svelte';
import { getContext } from 'svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
@@ -34,10 +35,12 @@
isInClipboard,
isSelected,
selectedElementIds,
selectedFileIds,
selectionSize,
type FileTreeNode,
} from '$lib/engine/selection-helpers';
import { boundsManager } from '$lib/state/bounds';
import { closeFiles } from '$lib/actions/file-actions';
import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
let {
@@ -50,7 +53,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<boolean>('embedding');
@@ -92,6 +95,15 @@
selectedElementIds($selection)[0]?.ids[0] === elementId(node)
);
// the desktop app: the files are linked to the file system, they are closed rather than deleted
let desktop = $derived($workspace !== undefined);
// 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));
@@ -108,13 +120,18 @@
}
}}
>
<ContextMenu.Trigger class="grow truncate">
<!-- A tab of the horizontal list is as wide as its content, said explicitly: a width that is a
percentage of a parent that is as wide as its content is not updated by WebKit when the
content changes (the indicators come and go) -->
<!-- it only catches right-clicks: no focus ring when it has the focus (WebKit gives it on a
click), the selection shows where the arrow keys are -->
<ContextMenu.Trigger class="outline-none {orientation === 'vertical' ? 'grow truncate' : ''}">
<Button
variant="ghost"
class="relative w-full p-0 overflow-hidden border-none focus-visible:ring-0 focus-visible:ring-offset-0 flex flex-row {orientation ===
class="relative p-0 overflow-hidden border-none focus-visible:ring-0 focus-visible:ring-offset-0 flex flex-row {orientation ===
'vertical'
? 'h-7'
: 'h-9 px-1.5'} pointer-events-auto"
? 'w-full h-7'
: 'w-max h-9 px-1.5'} pointer-events-auto"
>
{#if node.type === 'file' || node.type === 'track'}
<MetadataDialog bind:open={openEditMetadata} {fileState} {node} />
@@ -136,9 +153,11 @@
></div>
{/if}
<span
class="grow text-left truncate ml-1 flex flex-row items-center {hidden
class="grow text-left ml-1 flex flex-row items-center {orientation === 'vertical'
? 'truncate'
: 'whitespace-nowrap'} {hidden ? 'text-muted-foreground' : ''} {isCut
? 'text-muted-foreground'
: ''} {isCut ? 'text-muted-foreground' : ''}"
: ''}"
oncontextmenu={(e) => {
if (embedding) {
e.preventDefault();
@@ -180,22 +199,34 @@
<MapPin size="16" class="mx-1 shrink-0" />
{/if}
{/if}
<!-- a tab of the horizontal list is as wide as its content: the name is not cut -->
<span
class="grow select-none truncate {orientation === 'vertical'
? 'last:mr-2'
: ''}"
class:italic={preview}
class="grow select-none {orientation === 'vertical'
? 'truncate last:mr-2'
: 'shrink-0'}"
>
{label}
</span>
{#if unsaved || hidden}
<span
class="flex flex-row items-center gap-1 shrink-0 pl-1 {orientation ===
'vertical'
? 'mr-3'
: ''}"
>
{#if unsaved}
<span class="size-1.5 rounded-full bg-foreground"></span>
{/if}
{#if hidden}
<EyeOff
size="10"
class="shrink-0 size-3.5 ml-1 {orientation === 'vertical'
? 'mr-3'
: 'mt-0.5'}"
class="size-3.5 {orientation === 'vertical' ? '' : 'mt-0.5'}"
/>
{/if}
</span>
{/if}
</span>
</Button>
</ContextMenu.Trigger>
<ContextMenu.Content>
@@ -278,10 +309,18 @@
</ContextMenu.Item>
{/if}
<ContextMenu.Separator />
{#if desktop && node.type === 'file'}
<ContextMenu.Item onclick={() => closeFiles(selectedFileIds($selection))}>
<FileX size="16" />
{i18n._('menu.close')}
<Shortcut key="W" ctrl={true} />
</ContextMenu.Item>
{:else}
<ContextMenu.Item onclick={() => engine.delete()}>
<Trash2 size="16" />
{i18n._('menu.delete')}
<Shortcut key="⌫" ctrl={true} />
</ContextMenu.Item>
{/if}
</ContextMenu.Content>
</ContextMenu.Root>
@@ -0,0 +1,69 @@
<script lang="ts">
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
import { i18n } from '$lib/i18n/i18n.svelte';
import { unsavedPrompt } from './unsaved-prompt.svelte';
let prompt = $derived(unsavedPrompt.current);
/** Closes the dialog, and gives the answer to whoever asked. */
function answer(choice: 'save' | 'discard' | 'reload' | 'cancel') {
const current = unsavedPrompt.current;
unsavedPrompt.current = undefined;
if (current?.kind === 'unsaved' && choice !== 'reload') {
current.resolve(choice);
} else if (current?.kind === 'overwrite') {
current.resolve(
choice === 'save' ? 'overwrite' : choice === 'reload' ? 'reload' : 'cancel'
);
}
}
</script>
<AlertDialog.Root
open={prompt !== undefined}
onOpenChange={(open) => {
if (!open) {
answer('cancel');
}
}}
>
<AlertDialog.Content>
{#if prompt}
<AlertDialog.Header>
<AlertDialog.Title>
{prompt.kind === 'unsaved'
? i18n._('menu.unsaved_title')
: i18n._('menu.overwrite_title')}
</AlertDialog.Title>
<AlertDialog.Description>
{prompt.kind === 'unsaved'
? i18n._('menu.unsaved_description')
: i18n._('menu.overwrite_description')}
</AlertDialog.Description>
</AlertDialog.Header>
<ul class="list-disc pl-6 text-sm max-h-40 overflow-y-auto">
{#each prompt.names as name}
<li>{name}</li>
{/each}
</ul>
<AlertDialog.Footer>
<Button variant="outline" onclick={() => answer('cancel')}>
{i18n._('menu.cancel')}
</Button>
{#if prompt.kind === 'unsaved'}
<Button variant="destructive" onclick={() => answer('discard')}>
{i18n._('menu.discard')}
</Button>
{:else}
<Button variant="destructive" onclick={() => answer('reload')}>
{i18n._('menu.reload')}
</Button>
{/if}
<Button onclick={() => answer('save')}>
{prompt.kind === 'unsaved' ? i18n._('menu.save') : i18n._('menu.overwrite')}
</Button>
</AlertDialog.Footer>
{/if}
</AlertDialog.Content>
</AlertDialog.Root>
@@ -9,6 +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 { showEngineError } from '$lib/actions/file-actions';
let {
fileState,
@@ -53,8 +54,13 @@
// would open the dialog again before the effect below has reset it
editMetadata.current = false;
open = false;
// the metadata of the selected elements are changed
engine.metadata(name, description);
// 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) => {
if (!changed) {
showEngineError();
}
});
}}
>
<Save size="16" />
@@ -32,6 +32,14 @@ export class SortableFileList {
private _sortable: Sortable | null = null;
private _elements: { [id: string]: HTMLElement } = {};
private _updatingSelection: boolean = false;
/**
* Whether a change of the selection that this list made is being applied by the engine. A click
* makes several events, handled after one another, and the engine can answer after them (in
* the desktop app, it is in another process): until it has, the list holds the selection, and
* the engine and the list disagree because the engine is behind, not because the user did
* something else.
*/
private _applying: boolean = false;
/** The elements of the list when a drag from it started, to put them back when it ends. */
private _snapshot: ChildNode[] = [];
private _unsubscribes: (() => void)[] = [];
@@ -74,11 +82,6 @@ export class SortableFileList {
this._unsubscribes.push(
engine.selection.subscribe(() => tick().then(() => this.updateFromSelection()))
);
// after the elements are rendered: sorting a list that does not have the new files yet
// would put them at the beginning
this._unsubscribes.push(
engine.order.subscribe(() => tick().then(() => this.updateFromFileOrder()))
);
}
/** The node of a child of the list. */
@@ -163,8 +166,14 @@ export class SortableFileList {
}
if (from === to && this._sortableLevel === 'file') {
// the order of the files is the one of the list
this.updateToFileOrder();
// the order of the files is the one of the list, which is put back where it was like
// below: Svelte moves the elements when the order changes, it has to find them where
// it left them (or removing a file later removes the elements of other files)
const order = this._sortable?.toArray();
this._snapshot.forEach((node) => e.from.appendChild(node));
if (order) {
this.updateToFileOrder(order);
}
return;
}
@@ -196,6 +205,9 @@ export class SortableFileList {
}
updateFromSelection() {
if (this._applying) {
return;
}
const changed = this.getChangedIds();
if (changed.length === 0) {
return;
@@ -220,7 +232,7 @@ export class SortableFileList {
updateToSelection(e: Sortable.SortableEvent) {
if (!this._sortable) return;
if (this._updatingSelection) return;
if (this._updatingSelection || this._applying) return;
this._updatingSelection = true;
const changed = this.getChangedIds();
if (changed.length == 0) {
@@ -242,53 +254,50 @@ export class SortableFileList {
}
const parent = this._parent;
let request: Promise<boolean>;
if (selected.length === 0 || !this.childNode(selected[0])) {
engine.select([]);
request = engine.select([]);
} else {
switch (this._sortableLevel) {
case 'file':
engine.select(selected);
request = engine.select(selected);
break;
case 'track':
engine.selectTracks(parent!.fileId, selected);
request = engine.selectTracks(parent!.fileId, selected);
break;
case 'segment':
engine.selectSegments(parent!.fileId, (parent as any).trackId, selected);
request = engine.selectSegments(
parent!.fileId,
(parent as any).trackId,
selected
);
break;
case 'waypoints':
engine.selectWaypointGroup(parent!.fileId);
request = engine.selectWaypointGroup(parent!.fileId);
break;
case 'waypoint':
engine.selectWaypoints(parent!.fileId, selected);
request = engine.selectWaypoints(parent!.fileId, selected);
break;
}
}
this._updatingSelection = false;
// the events of the same click that come before the engine answers are not new choices
this._applying = true;
request.finally(() => {
this._applying = false;
// the list shows what the engine selected
this.updateFromSelection();
});
}
updateFromFileOrder() {
/** Makes `sortableOrder` (the ids of the elements, as Sortable left them) the order of the files. */
updateToFileOrder(sortableOrder: string[]) {
if (!this._sortable || this._sortableLevel !== 'file') {
return;
}
const fileOrder_ = get(engine.order);
const sortableOrder = this._sortable.toArray();
if (
fileOrder_.length !== sortableOrder.length ||
fileOrder_.some((value, index) => value !== sortableOrder[index])
) {
this._sortable.sort(fileOrder_);
}
}
updateToFileOrder() {
if (!this._sortable || this._sortableLevel !== 'file') {
return;
}
const fileOrder_ = get(engine.order);
const sortableOrder = this._sortable.toArray();
if (
fileOrder_.length !== sortableOrder.length ||
@@ -299,6 +308,7 @@ export class SortableFileList {
}
updateElements() {
const previous = this._elements;
this._elements = {};
const files = get(engine.files);
this._container.childNodes.forEach((element) => {
@@ -306,6 +316,7 @@ export class SortableFileList {
let attr = element.getAttribute('data-id');
if (attr) {
if (this._sortableLevel === 'file' && !files.has(attr)) {
this.forget(element);
element.remove();
} else {
this._elements[attr] = element;
@@ -313,9 +324,37 @@ export class SortableFileList {
}
}
});
// the elements of what is not there anymore (a closed file, a deleted track...)
Object.values(previous).forEach((element) => {
if (element.parentNode !== this._container) {
this.forget(element);
}
});
}
/**
* Takes an element that left the list out of the selection of Sortable. Sortable keeps the
* selected elements, and puts them all in the list when one of them is dragged: one that Svelte
* removed would come back, as a file that is not there.
*/
private forget(element: HTMLElement) {
if (!element.classList.contains('sortable-selected')) {
return;
}
// Sortable finds the list of an element from its parent
const detached = element.parentNode === null;
if (detached) {
this._container.appendChild(element);
}
Sortable.utils.deselect(element);
if (detached) {
element.remove();
}
}
destroy() {
Object.values(this._elements).forEach((element) => this.forget(element));
this._sortable?.destroy();
this._sortable = null;
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
this._unsubscribes = [];
@@ -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<UnsavedChoice | 'cancel'> {
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 };
});
}
@@ -463,12 +463,21 @@ export class RoutingControls {
// Exit if anchor is being dragged
return;
}
const controlLayerIds = [...this.layers.values()].map((l) => l.id);
const segmentIds = this.anchorData?.segmentIds ?? [];
if (
e.target.queryRenderedFeatures(e.point, {
layers: [...this.listenedFileIds, ...[...this.layers.values()].map((l) => l.id)],
}).length
e.target
.queryRenderedFeatures(e.point, {
layers: [...this.listenedFileIds, ...controlLayerIds],
})
.some(
(feature) =>
controlLayerIds.includes(feature.layer.id) ||
segmentIds.includes(feature.properties?.segmentId)
)
) {
// Clicked on routing control or layer, ignoring
// Clicked on a routing control, or on a segment being routed (an anchor is inserted
// there instead): the other files do not stop the route
return;
}
this.appendAnchorWithCoordinates({ lng: e.lngLat.lng, lat: e.lngLat.lat });
+52
View File
@@ -0,0 +1,52 @@
import { engine } from '$lib/engine';
/** Where the pages of the website are, for the links of the desktop app to them. */
const website = 'https://gpx.studio';
let installed = false;
/** The address to open in the browser for a link: the pages of the website are on the website. */
function browserURL(href: string): string {
const url = new URL(href, location.href);
return url.origin === location.origin
? `${website}${url.pathname}${url.search}${url.hash}`
: url.href;
}
/**
* Opens the links that leave the app in the browser of the system, as the window of the desktop app
* does not follow them: the external links, the links that open a new tab (the pages of the
* website, like the help), and what `window.open` is given. The other links are pages of the app.
* It is done once, for as long as the window is open.
*/
export function openLinksInBrowser() {
if (installed) {
return;
}
installed = true;
const onClick = (e: MouseEvent) => {
// the main button, or the middle one which opens a new tab
if (e.button > 1) {
return;
}
const link = (e.target as Element | null)?.closest?.('a[href]');
if (!(link instanceof HTMLAnchorElement)) {
return;
}
const url = new URL(link.href, location.href);
if (url.origin === location.origin && link.target !== '_blank' && e.button === 0) {
return;
}
e.preventDefault();
engine.openUrl(browserURL(link.href));
};
window.open = (url?: string | URL) => {
if (url !== undefined) {
engine.openUrl(browserURL(String(url)));
}
return null;
};
// before the router of the app, which would follow the link
document.addEventListener('click', onClick, true);
document.addEventListener('auxclick', onClick, true);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import type { Backend } from './types';
export type { Backend } from './types';
/** Whether the page runs in the desktop app, which has the engine in its own process. */
function inDesktopApp() {
export function inDesktopApp() {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
}
+18 -3
View File
@@ -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 <T>(
@@ -211,15 +211,30 @@ export async function createTauriBackend(): Promise<Backend> {
desktop: {
workspace: () => invoke<WorkspaceState>('workspace_state'),
openFiles: () => invoke<Outcome>('open_files'),
openPaths: (paths, preview) => invoke<Outcome>('open_paths', { paths, preview }),
openFolder: (unsaved) => invoke<Outcome>('open_folder', { unsaved }),
closeFolder: (unsaved) => invoke<Outcome>('close_folder', { unsaved }),
saveFiles: (fileIds) => invoke('save_files', { fileIds }),
listen: async (onEngineChanged, onWorkspaceChanged, onFilesOpened) => {
closeFiles: (fileIds, unsaved) => invoke<Outcome>('close_files', { fileIds, unsaved }),
saveFiles: (fileIds) => invoke<boolean>('save_files', { fileIds }),
saveFileAs: (fileId) => invoke<boolean>('save_file_as', { fileId }),
reloadFiles: (fileIds) => invoke<Outcome>('reload_files', { fileIds }),
exportFiles: (fileIds, options) =>
invoke<boolean>('export_files', { fileIds, options }),
listFolder: (path) => invoke<FolderEntry[]>('list_folder', { path }),
openUrl: (url) => invoke('open_url', { url }),
newFile: (name, folder) => invoke<Outcome>('new_file', { name, folder }),
createFolder: (parent, name) => invoke<string>('create_folder', { parent, name }),
renamePath: (path, name) => invoke<Outcome>('rename_path', { path, name }),
movePaths: (paths, folder) => invoke('move_paths', { paths, folder }),
deletePaths: (paths) => invoke<Outcome>('delete_paths', { paths }),
revealPath: (path) => invoke('reveal_path', { path }),
listen: async (onEngineChanged, onWorkspaceChanged, onFilesOpened, onFolderChanged) => {
await listen<Outcome>('files-opened', (event) => onFilesOpened(event.payload));
await listen<Outcome>('engine-changed', (event) => onEngineChanged(event.payload));
await listen<WorkspaceState>('workspace-changed', (event) =>
onWorkspaceChanged(event.payload)
);
await listen<string[]>('folder-changed', () => onFolderChanged());
},
},
+79 -17
View File
@@ -10,16 +10,35 @@ 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;
/** 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. */
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. */
@@ -40,30 +59,73 @@ export interface DesktopFiles {
/** Opens the files that the user picks, which get selected. */
openFiles(): Promise<Outcome>;
/**
* 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 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<Outcome>;
/**
* 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<Outcome>;
/**
* 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<Outcome>;
/**
* 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<void>;
closeFiles(fileIds: string[], unsaved?: UnsavedChoice): Promise<Outcome>;
/**
* 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<boolean>;
/** Writes a file where the user says, which is where it is from then on. False if cancelled. */
saveFileAs(fileId: string): Promise<boolean>;
/** Reads files again from the disk: their edits are lost (one undo step). */
reloadFiles(fileIds: string[]): Promise<Outcome>;
/**
* 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<boolean>;
/** The folders and GPX files in a folder of the open folder (the open folder by default). */
listFolder(path?: string): Promise<FolderEntry[]>;
/** Opens a web page (or an email to write) in the app of the system for it. */
openUrl(url: string): Promise<void>;
/** Makes a new file, written at once in `folder` (a folder of the open folder), and selects it. */
newFile(name: string, folder: string): Promise<Outcome>;
/** Makes a folder in a folder of the open folder (numbered if the name is taken): its path. */
createFolder(parent: string, name: string): Promise<string>;
/**
* 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<Outcome>;
/** Moves files and folders of the open folder into a folder of it. */
movePaths(paths: string[], folder: string): Promise<void>;
/**
* 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<Outcome>;
/** Shows a file or a folder in the file manager of the system. */
revealPath(path: string): Promise<void>;
/**
* 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
* app from the system (the outcome to apply, which selected them).
* outcome to apply), when the status of the files changed, when files were opened with the
* app from the system (the outcome to apply, which selected them), and when something changed
* in the open folder (the tree of its files is to be read again).
*/
listen(
onEngineChanged: (outcome: Outcome) => void,
onWorkspaceChanged: (workspace: WorkspaceState) => void,
onFilesOpened: (outcome: Outcome) => void
onFilesOpened: (outcome: Outcome) => void,
onFolderChanged: () => void
): Promise<void>;
}
+161 -24
View File
@@ -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,
@@ -63,6 +65,7 @@ class Engine {
private _lastError = writable<string | undefined>(undefined);
private _outdated = writable(false);
private _workspace = writable<WorkspaceState | undefined>(undefined);
private _folderVersion = writable(0);
private _canUndo = writable(false);
private _canRedo = writable(false);
private _canPaste = writable(false);
@@ -84,6 +87,11 @@ class Engine {
readonly workspace: Readable<WorkspaceState | undefined> = {
subscribe: this._workspace.subscribe,
};
/**
* A number that changes when something changed in the open folder on the file system, to read
* its tree again.
*/
readonly folderVersion: Readable<number> = { subscribe: this._folderVersion.subscribe };
/** Why the last call to the engine did nothing, `undefined` if it did something. */
readonly lastError: Readable<string | undefined> = { subscribe: this._lastError.subscribe };
/** What was copied or cut, waiting to be pasted. */
@@ -555,47 +563,171 @@ class Engine {
}
/**
* 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.
* 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 openFolder(unsaved?: UnsavedChoice): Promise<string[]> {
return this.addedBy((b) => b.desktop!.openFolder(unsaved));
async openPaths(paths: string[], preview = false): Promise<string[]> {
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,
// `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<string | undefined> {
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<string | undefined> {
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<boolean> {
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<boolean> {
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<boolean> {
return this.desktopRun((b) => b.desktop!.deletePaths(paths), true);
}
/** 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);
}
}
/**
* 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.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));
}
/**
* 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 closeFiles(fileIds: string[], unsaved?: UnsavedChoice) {
await this.desktopRun((b) => b.desktop!.closeFiles(fileIds, unsaved));
}
/**
* 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.
* 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[]) {
async save(fileIds: string[]): Promise<boolean> {
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<boolean> {
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<boolean> {
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.desktopRun((b) => b.desktop!.reloadFiles(fileIds));
}
/** Opens a web page in the browser of the system (the desktop app). */
async openUrl(url: string) {
await this.ready;
await this.enqueue(async () => {
const backend = this.backend!;
try {
await backend.desktop!.saveFiles(fileIds);
await this.backend!.desktop?.openUrl(url);
} catch (error) {
console.error('The files could not be saved', error);
this._lastError.set(String(error));
console.error('The page could not be opened', error);
}
}
/** The folders and GPX files in a folder of the open folder (the open folder by default). */
async listFolder(path?: string): Promise<FolderEntry[]> {
await this.ready;
return this.enqueue(async () => {
try {
return await this.backend!.desktop!.listFolder(path);
} catch (error) {
console.error('The folder could not be read', error);
return [];
}
await this.refreshWorkspace(backend);
});
}
/** Runs a call that adds files, and resolves to the ids that it added. */
private async addedBy(action: (backend: Backend) => Promise<Outcome>): Promise<string[]> {
/**
* 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<T>(
call: (desktop: DesktopFiles) => Promise<T>,
tree = false
): 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 call(backend.desktop!) };
} catch (error) {
console.error('engine:', error);
this._lastError.set(String(error));
}
await this.refreshWorkspace(backend, tree);
return result;
});
}
/**
* 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<Outcome>,
tree = false
): Promise<boolean> {
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<Outcome>,
tree = false
): Promise<string[]> {
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));
}
@@ -613,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);
}
}
/**
@@ -640,7 +776,8 @@ class Engine {
await this.sync(backend, outcome);
this.report(outcome);
this._opened.forEach((callback) => callback(outcome.added));
})
}),
() => this._folderVersion.update((version) => version + 1)
);
}
+1 -1
View File
@@ -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,
+65 -1
View File
@@ -1,6 +1,14 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { folderName, subfolder } from '$lib/engine/workspace';
import {
editableName,
fileIdAt,
folderName,
isWithin,
parentOf,
relocated,
subfolder,
} from '$lib/engine/workspace';
describe('subfolder', () => {
it('is where a file is in the folder', () => {
@@ -27,3 +35,59 @@ describe('folderName', () => {
assert.equal(folderName('C:\\Users\\me\\tracks'), 'tracks');
});
});
describe('fileIdAt', () => {
const workspace = {
files: {
a: { path: '/tracks/a.gpx' },
b: { path: '/tracks/sub/b.gpx' },
c: {},
},
};
it('is the open file that is linked to the path', () => {
assert.equal(fileIdAt(workspace, '/tracks/a.gpx'), 'a');
assert.equal(fileIdAt(workspace, '/tracks/sub/b.gpx'), 'b');
});
it('is nothing for a file that is not open', () => {
assert.equal(fileIdAt(workspace, '/tracks/other.gpx'), undefined);
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('relocated', () => {
it('is where a path is once moved or renamed', () => {
assert.equal(relocated('/me/tracks/a.gpx', '/me/trips'), '/me/trips/a.gpx');
assert.equal(relocated('/me/tracks', '/me', 'rides'), '/me/rides');
assert.equal(relocated('C:\\me\\tracks', 'C:\\me', 'rides'), 'C:\\me\\rides');
});
});
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');
});
});
+31
View File
@@ -26,3 +26,34 @@ export function subfolder(
}
return file.slice(root.length, -1).join('/');
}
/** The id of the open file that is linked to the file at `path`, `undefined` if there is none. */
export function fileIdAt(
workspace: { files: Record<string, { path?: string }> } | undefined,
path: string
): 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;
}
/** Where `path` is once moved into `folder`, renamed `name` if it is given. */
export function relocated(path: string, folder: string, name?: string): string {
const parent = parentOf(path);
return `${folder}${path[parent.length]}${name ?? path.slice(parent.length + 1)}`;
}
/** 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);
}
+8
View File
@@ -15,6 +15,7 @@ import {
import { browser } from '$app/environment';
import type { DistanceUnits, TemperatureUnits, VelocityUnits } from '$lib/units/conversions';
import { engine } from '$lib/engine';
import { languages } from '$lib/i18n/languages';
import {
getArrayValidator,
getLayerTreeValidator,
@@ -156,6 +157,13 @@ export const settings = {
defaultWidth: new Setting('defaultWidth', browser && window.innerWidth < 600 ? 8 : 5),
bottomPanelSize: new Setting('bottomPanelSize', 170),
rightPanelSize: new Setting('rightPanelSize', 240),
explorerPanelSize: new Setting('explorerPanelSize', 0),
/** The language chosen last, for the desktop app, which always opens the same page. */
language: new Setting<string | undefined>(
'language',
undefined,
getValueValidator<string | undefined>(Object.keys(languages), undefined)
),
/** Takes the saved values (see `Engine.openStorage`). */
connect(stored: StoredSettings) {
for (const key in settings) {
+29 -1
View File
@@ -13,6 +13,32 @@
"new_track": "New track",
"new_segment": "New segment",
"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",
"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",
@@ -70,7 +96,9 @@
"button": "Info...",
"name": "Name",
"description": "Description",
"save": "Save"
"save": "Save",
"name_taken": "A file with this name already exists in the folder.",
"rename_failed": "The file could not be renamed."
},
"style": {
"button": "Appearance...",
@@ -2,6 +2,7 @@
import GPXLayers from '$lib/components/map/gpx-layer/GPXLayers.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import FileList from '$lib/components/file-list/FileList.svelte';
import Explorer from '$lib/components/explorer/Explorer.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import Map from '$lib/components/map/Map.svelte';
import Menu from '$lib/components/Menu.svelte';
@@ -17,32 +18,74 @@
import { loadFiles } from '$lib/actions/file-actions';
import { onMount } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { get } from 'svelte/store';
import { getURLForLanguage } from '$lib/utils';
import { inDesktopApp } from '$lib/engine/backend';
import { openLinksInBrowser } from '$lib/desktop/links';
import { hoveredPoint, slicedStatistics } from '$lib/state/selection-statistics';
import { engine } from '$lib/engine';
import { boundsManager } from '$lib/state/bounds';
import { getURLForGoogleDriveFile } from '$lib/components/embedding/embedding';
const outdated = engine.outdated;
const workspace = engine.workspace;
const {
treeFileView,
elevationProfile,
bottomPanelSize,
rightPanelSize,
explorerPanelSize,
additionalDatasets,
elevationFill,
} = settings;
let bottomPanelWidth: number | undefined = $state();
// the file list and the explorer share the right panel, half of it each until it is resized
let rightPanelHeight = $state(0);
let explorerHeight = $derived(
$treeFileView
? Math.min(
Math.max($explorerPanelSize > 0 ? $explorerPanelSize : rightPanelHeight / 2, 60),
Math.max(rightPanelHeight - 80, 60)
)
: rightPanelHeight
);
let bottomPanelOrientation = $derived(
bottomPanelWidth && bottomPanelWidth >= 540 && $elevationProfile ? 'horizontal' : 'vertical'
);
// the language of the page, which is in its address (none for English)
let language = $derived(page.params.language ?? 'en');
// whether the language that was chosen last was gone back to (the desktop app)
let languageRestored = $state(false);
$effect(() => {
if (languageRestored) {
settings.language.set(language);
}
});
onMount(async () => {
// files that the system opens with the app while it runs
engine.onFilesOpened((ids) => boundsManager.fitBoundsOnLoad(ids));
settings.connect(await engine.openStorage());
if (inDesktopApp()) {
openLinksInBrowser();
// the window of the desktop app always opens the same page, in English: it goes to the
// language that was chosen last
const saved = get(settings.language);
if (saved !== undefined && saved !== language) {
await goto(
`${getURLForLanguage(saved, page.url.pathname)}${page.url.search}${page.url.hash}`,
{ replaceState: true }
);
}
languageRestored = true;
}
let files: string[] = JSON.parse(page.url.searchParams.get('files') || '[]');
let ids: string[] = JSON.parse(page.url.searchParams.get('ids') || '[]');
let urls: string[] = files.concat(ids.map(getURLForGoogleDriveFile));
@@ -117,7 +160,7 @@
<CoordinatesPopup />
<Toaster richColors />
{#if !$treeFileView}
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
<div class="h-10 w-full pointer-events-none absolute bottom-0 left-0 z-30">
<FileList orientation="horizontal" />
</div>
{/if}
@@ -153,9 +196,43 @@
{/if}
</div>
</div>
{#if $treeFileView}
{#if $treeFileView || $workspace?.folder}
<Resizer orientation="col" bind:after={$rightPanelSize} minAfter={100} maxAfter={400} />
<FileList orientation="vertical" recursive={true} style="width: {$rightPanelSize}px" />
<div
bind:clientHeight={rightPanelHeight}
class="flex flex-col h-full min-h-0 shrink-0"
style="width: {$rightPanelSize}px"
>
{#if $treeFileView}
{#if $workspace}
<div class="px-2 py-1 text-sm font-semibold shrink-0">
{i18n._('menu.workspace')}
</div>
{/if}
<!-- a row, as the file list was: its scroll area is as high as this, whatever its content -->
<div class="grow min-h-0 flex flex-row">
<FileList
orientation="vertical"
recursive={true}
style="width: {$rightPanelSize}px"
/>
</div>
{/if}
<!-- the open folder (the desktop app) under the files that are open -->
{#if $workspace?.folder}
{#if $treeFileView}
<Resizer
orientation="row"
bind:after={() => explorerHeight, (height) => ($explorerPanelSize = height)}
minAfter={60}
maxAfter={Math.max(rightPanelHeight - 80, 60)}
/>
{/if}
<div class="flex flex-col min-h-0 shrink-0" style="height: {explorerHeight}px">
<Explorer />
</div>
{/if}
</div>
{/if}
</div>