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 4528 additions and 854 deletions

No files matched your search

Generated
+78
View File
@@ -1430,7 +1430,9 @@ dependencies = [
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
"trash",
"uuid", "uuid",
] ]
@@ -1870,6 +1872,25 @@ version = "2.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" 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]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@@ -2577,6 +2598,17 @@ version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" 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]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -3818,6 +3850,28 @@ dependencies = [
"url", "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]] [[package]]
name = "tauri-plugin-single-instance" name = "tauri-plugin-single-instance"
version = "2.5.3" version = "2.5.3"
@@ -4241,6 +4295,24 @@ dependencies = [
"once_cell", "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]] [[package]]
name = "tray-icon" name = "tray-icon"
version = "0.25.1" version = "0.25.1"
@@ -4317,6 +4389,12 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "urlpattern" name = "urlpattern"
version = "0.6.0" 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 /// 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 /// `(id, data, name)`, and replaces the file of that id, or is added if there is none; it is called
/// `remove` are deleted. The selection is left alone. /// `name`, whatever its data says. The files to `remove` are deleted. The selection is left alone.
pub fn sync_files<H: History>( pub fn sync_files<H: History>(
engine: &mut Engine<H>, engine: &mut Engine<H>,
files: &[(Uuid, &[u8], &str)], 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. /// Puts the selected elements in the clipboard. Nothing happens when nothing is selected.
pub fn copy<H: History>(engine: &mut Engine<H>) -> Outcome { pub fn copy<H: History>(engine: &mut Engine<H>) -> Outcome {
execute(engine, Action::Copy) execute(engine, Action::Copy)
+5
View File
@@ -68,6 +68,11 @@ impl Outcome {
Self::new(&engine::Outcome::default(), None) 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. /// Whether the call did nothing and says why.
pub fn has_error(&self) -> bool { pub fn has_error(&self) -> bool {
self.error.is_some() self.error.is_some()
+7
View File
@@ -36,4 +36,11 @@ pub enum Action<'a> {
file_ids: Vec<FileId>, file_ids: Vec<FileId>,
index: usize, 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 struct ExternalFile<'a> {
pub id: FileId, pub id: FileId,
pub data: &'a [u8], 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, 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 /// 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. /// 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). /// 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 /// 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) { match load.parse(state.categories) {
Ok(mut file) => { Ok(mut file) => {
file.id = external.id; 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() { if state.files.insert(external.id, Rc::new(file)).is_none() {
state.order.0.push(external.id); state.order.0.push(external.id);
} }
@@ -101,7 +106,8 @@ mod tests {
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
assert_eq!(fx.order.0, [first, second]); 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); assert_eq!(fx.files[&first].id, first);
// the selection is not changed // the selection is not changed
assert_eq!(fx.selected_files(), [second].into()); assert_eq!(fx.selected_files(), [second].into());
+14 -14
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 /// Removes what the clean left empty: the emptied segments and the tracks that lost all their
/// segments and the files that lost something and have nothing left. The selection falls back /// segments. A file is never removed, even if nothing is left in it: it stays, empty. The
/// to the closest element still existing. /// selection falls back to the closest element still existing.
fn prune(state: &mut State, emptied: &HashSet<TrackSegmentId>, wpt_files: &HashSet<FileId>) { fn prune(state: &mut State, emptied: &HashSet<TrackSegmentId>, wpt_files: &HashSet<FileId>) {
let ids: Vec<FileId> = state let ids: Vec<FileId> = state
.files .files
@@ -142,18 +142,12 @@ fn prune(state: &mut State, emptied: &HashSet<TrackSegmentId>, wpt_files: &HashS
.collect(); .collect();
for id in ids { for id in ids {
let mut file = (*state.files[&id]).clone(); let mut file = (*state.files[&id]).clone();
let lost_something = !emptied.is_empty() || wpt_files.contains(&id);
file.trk.retain_mut(|trk| { file.trk.retain_mut(|trk| {
let len = trk.trkseg.len(); let len = trk.trkseg.len();
trk.trkseg.retain(|seg| !emptied.contains(&seg.id)); trk.trkseg.retain(|seg| !emptied.contains(&seg.id));
trk.trkseg.len() == len || !trk.trkseg.is_empty() trk.trkseg.len() == len || !trk.trkseg.is_empty()
}); });
if lost_something && file.trk.is_empty() && file.wpt.is_empty() { state.files.insert(id, Rc::new(file));
state.files.remove(&id);
state.order.0.retain(|f| *f != id);
} else {
state.files.insert(id, Rc::new(file));
}
} }
*state.selection = fixed_selection(state); *state.selection = fixed_selection(state);
} }
@@ -297,10 +291,16 @@ mod tests {
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
assert_eq!(count(&fx, id), 0); assert_eq!(count(&fx, id), 0);
// nothing is left of the file // nothing is left in the file, which stays and is still selected
assert!(!fx.files.contains_key(&id)); let file = &fx.files[&id];
assert!(fx.order.0.is_empty()); assert!(file.trk.is_empty() && file.wpt.is_empty());
assert_eq!(fx.selection, Selection::Empty); assert_eq!(fx.order.0, [id]);
assert_eq!(
fx.selection,
Selection::File {
file_ids: HashSet::from([id])
}
);
} }
#[test] #[test]
+1
View File
@@ -248,6 +248,7 @@ impl<H: History> Engine<H> {
self.order.move_files(&file_ids, index); self.order.move_files(&file_ids, index);
None None
} }
Action::Rename { file_id, name } => self.history.rename(file_id, &name),
}; };
let changed = diff.is_some() let changed = diff.is_some()
|| self.selection != selection_before || self.selection != selection_before
+17 -1
View File
@@ -1,6 +1,6 @@
use std::rc::Rc; use std::rc::Rc;
use super::{Diff, Files, History, diff_between}; use super::{Diff, Files, History, Renamer, diff_between};
use crate::{CommandError, FileId}; use crate::{CommandError, FileId};
/// One history for the whole workspace: each entry is a state of all the files. /// 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(), 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)] #[cfg(test)]
+35
View File
@@ -57,6 +57,41 @@ pub trait History: Default + Debug {
/// Whether [`History::redo`] would do something for these `files`. /// Whether [`History::redo`] would do something for these `files`.
fn can_redo(&self, files: &[FileId]) -> bool; 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. /// 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 std::rc::Rc;
use super::{Diff, Files, History, diff_between}; use super::{Diff, Files, History, Renamer, diff_between};
use crate::{CommandError, File, FileId}; use crate::{CommandError, File, FileId};
use std::collections::HashMap; use std::collections::HashMap;
@@ -139,6 +139,20 @@ impl History for PerFileHistory {
self.any(files, FileHistory::can_undo) 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 { fn can_redo(&self, files: &[FileId]) -> bool {
self.any(files, FileHistory::can_redo) 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")); 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] #[test]
fn test_metadata_without_selection_does_not_modify_files() { fn test_metadata_without_selection_does_not_modify_files() {
let mut engine = TestEngine::default(); let mut engine = TestEngine::default();
@@ -1469,6 +1515,31 @@ mod per_file_history {
engine.file_structure(&id).unwrap().name.clone() 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] #[test]
fn test_creating_a_file_is_not_undoable() { fn test_creating_a_file_is_not_undoable() {
let mut engine = Engine::<PerFileHistory>::default(); let mut engine = Engine::<PerFileHistory>::default();
+2
View File
@@ -25,4 +25,6 @@ notify = "8.2.0"
notify-debouncer-mini = "0.7.0" notify-debouncer-mini = "0.7.0"
tauri-plugin-dialog = "2.8.2" tauri-plugin-dialog = "2.8.2"
tauri-plugin-single-instance = "2.5.3" tauri-plugin-single-instance = "2.5.3"
tauri-plugin-opener = "2"
trash = "5"
+373 -65
View File
@@ -1,16 +1,22 @@
//! The files of the engine and the file system: picking files and folders, saving, and what the //! The files of the engine and the file system: picking files and folders, saving, closing, and
//! frontend shows of it (see `workspace`). //! 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::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::{AppHandle, Runtime, State};
use tauri_plugin_dialog::{DialogExt, FilePath}; use tauri_plugin_dialog::{DialogExt, FilePath};
use uuid::Uuid; use uuid::Uuid;
use crate::commands::edit;
use crate::state::AppState; use crate::state::AppState;
use crate::workspace::{Unsaved, WorkspaceState}; use crate::workspace::{Entry, Unsaved, WorkspaceState, free_path};
fn paths(picked: Vec<FilePath>) -> Vec<PathBuf> { fn paths(picked: Vec<FilePath>) -> Vec<PathBuf> {
picked picked
@@ -19,6 +25,32 @@ fn paths(picked: Vec<FilePath>) -> Vec<PathBuf> {
.collect() .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)] #[tauri::command(async)]
pub fn workspace_state(state: State<AppState>) -> WorkspaceState { pub fn workspace_state(state: State<AppState>) -> WorkspaceState {
state.core(|core| core.workspace_state()) 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 { let Some(picked) = picked else {
return api::Outcome::failed_none(); 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| { state.core(move |core| {
let outcome = core.workspace.open_files(&mut core.engine, &files, true); let outcome = core
core.keep_workspace(); .workspace
core.persist(); .open(&mut core.engine, &paths, preview.unwrap_or_default());
core.commit(None);
outcome outcome
}) })
} }
/// Opens the folder that the user picks: its GPX files are opened and it is watched. The folder /// Writes the files among `ids` that are not the ones on the disk, asking where to put the ones
/// that was open is closed, `unsaved` says what to do with its files that have unsaved edits. /// 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)] #[tauri::command(async)]
pub fn open_folder<R: Runtime>( pub fn open_folder<R: Runtime>(
app: AppHandle<R>, app: AppHandle<R>,
@@ -60,80 +225,223 @@ pub fn open_folder<R: Runtime>(
else { else {
return Ok(api::Outcome::failed_none()); 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| { state.core(move |core| {
let discard = unsaved.is_some();
let outcome = core let outcome = core
.workspace .workspace
.open_folder(&mut core.engine, &folder, unsaved); .open_folder(&mut core.engine, &folder, discard);
core.keep_workspace(); core.commit(None);
core.persist(); Ok(outcome)
core.watch();
core.announce(None);
outcome
}) })
} }
/// Closes the folder: it is not watched anymore, and its files are closed. `unsaved` says what to /// Closes the folder: it is not watched anymore, and the files are closed with it. `unsaved` says
/// do with the ones that have unsaved edits (they stay without it). /// what to do with the ones that have unsaved edits (they stay open without it).
#[tauri::command(async)] #[tauri::command(async)]
pub fn close_folder( pub fn close_folder<R: Runtime>(
app: AppHandle<R>,
state: State<AppState>, state: State<AppState>,
unsaved: Option<Unsaved>, unsaved: Option<Unsaved>,
) -> Result<api::Outcome, String> { ) -> 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| { state.core(move |core| {
let outcome = core.workspace.close_folder(&mut core.engine, unsaved); let outcome = core
core.keep_workspace(); .workspace
core.persist(); .close_folder(&mut core.engine, unsaved.is_some());
core.watch(); core.commit(None);
core.announce(None); Ok(outcome)
outcome
}) })
} }
/// Writes files to the disk, replacing what is there. The ones that are not on the disk yet go in /// Closes files. `unsaved` says what to do with the ones that have unsaved edits: they are saved
/// the open folder, or where the user says if there is none. /// first, or the edits are lost (they stay open without it).
#[tauri::command(async)] #[tauri::command(async)]
pub fn save_files<R: Runtime>( pub fn close_files<R: Runtime>(
app: AppHandle<R>, app: AppHandle<R>,
state: State<AppState>, state: State<AppState>,
file_ids: Vec<Uuid>, file_ids: Vec<Uuid>,
) -> Result<(), String> { unsaved: Option<Unsaved>,
let (ids, unlinked, folder) = { ) -> Result<api::Outcome, String> {
let ids = file_ids.clone(); if !settle(&app, &state, file_ids.clone(), unsaved)? {
state.core(move |core| { return Ok(api::Outcome::failed_none());
(
ids.clone(),
core.workspace.unlinked(&core.engine, &ids),
core.workspace.folder.clone(),
)
})
};
let mut chosen = BTreeMap::new();
// with a folder open, a new file goes in it: nobody is asked
let to_ask = if folder.is_some() { vec![] } else { unlinked };
for (id, name) in to_ask {
let mut dialog = app
.dialog()
.file()
.add_filter("GPX", &["gpx"])
.set_file_name(format!("{name}.gpx"));
if let Some(folder) = &folder {
dialog = dialog.set_directory(folder);
}
match dialog
.blocking_save_file()
.and_then(|path| path.into_path().ok())
{
Some(path) => {
chosen.insert(id, path);
}
// cancelled: the others are not saved either
None => return Ok(()),
}
} }
state.core(move |core| { state.core(move |core| {
let result = core.workspace.save(&core.engine, &ids, &chosen); let outcome = core
core.keep_workspace(); .workspace
core.announce(None); .close_files(&mut core.engine, &file_ids, unsaved.is_some());
core.commit(None);
Ok(outcome)
})
}
/// The folders and GPX files that are in a folder of the open folder, or in it (the root of the
/// tree when no path is given).
#[tauri::command(async)]
pub fn list_folder(state: State<AppState>, path: Option<PathBuf>) -> Result<Vec<Entry>, String> {
state.core(move |core| {
let Some(path) = path.or_else(|| core.workspace.folder.clone()) else {
return Ok(vec![]);
};
core.workspace.list_folder(&path)
})
}
/// What the files are exported with, see `ExportOptions`.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportArguments {
time: bool,
hr: bool,
cad: bool,
atemp: bool,
power: bool,
osm: bool,
as_route: bool,
}
impl From<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 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: //! Editing the metadata, the style, the structure and the waypoints of the files, and the tools:
//! reversing, cropping, reducing, timestamps, merging, cleaning... //! 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 tauri::State;
use uuid::Uuid; 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)] #[tauri::command(async)]
pub fn metadata(state: State<AppState>, name: String, desc: String) -> api::Outcome { 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)] #[tauri::command(async)]
@@ -139,12 +155,12 @@ pub fn create_artificial_timestamps(
#[tauri::command(async)] #[tauri::command(async)]
pub fn merge(state: State<AppState>, merge_type: MergeType, remove_gaps: bool) -> api::Outcome { 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)] #[tauri::command(async)]
pub fn extract(state: State<AppState>) -> api::Outcome { pub fn extract(state: State<AppState>) -> api::Outcome {
state.run(api::extract) state.core(|core| core.creating(None, false, api::extract))
} }
#[tauri::command(async)] #[tauri::command(async)]
@@ -214,5 +230,5 @@ pub fn split(
at: u32, at: u32,
split_type: SplitType, split_type: SplitType,
) -> api::Outcome { ) -> 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. //! Creating, loading, deleting, moving, copying and pasting files and their elements, and undoing.
use std::path::PathBuf;
use gpx_engine::api; use gpx_engine::api;
use tauri::State; use tauri::State;
use tauri::ipc::{InvokeBody, Request}; use tauri::ipc::{InvokeBody, Request};
@@ -14,8 +16,10 @@ pub fn new_file(
lng: Option<f64>, lng: Option<f64>,
lat: Option<f64>, lat: Option<f64>,
ele: Option<f64>, ele: Option<f64>,
folder: Option<PathBuf>,
) -> api::Outcome { ) -> 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, /// 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)] #[tauri::command(async)]
pub fn duplicate(state: State<AppState>) -> api::Outcome { pub fn duplicate(state: State<AppState>) -> api::Outcome {
state.run(api::duplicate) state.core(|core| core.creating(None, false, api::duplicate))
} }
#[tauri::command(async)] #[tauri::command(async)]
@@ -105,10 +109,10 @@ pub fn move_elements(
#[tauri::command(async)] #[tauri::command(async)]
pub fn undo(state: State<AppState>) -> api::Outcome { pub fn undo(state: State<AppState>) -> api::Outcome {
state.run(api::undo) state.edit(api::undo)
} }
#[tauri::command(async)] #[tauri::command(async)]
pub fn redo(state: State<AppState>) -> api::Outcome { 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 selection;
pub mod statistics; pub mod statistics;
pub mod storage; 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) register_commands(builder)
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.setup(|app| { .setup(|app| {
let handle = app.handle().clone(); let handle = app.handle().clone();
let emit: state::Emit = std::sync::Arc::new(move |event, payload| { 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 // the file system
disk::workspace_state, disk::workspace_state,
disk::open_files, disk::open_files,
disk::open_paths,
disk::open_folder, disk::open_folder,
disk::close_folder, disk::close_folder,
disk::close_files,
disk::save_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 //! 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. //! 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::mpsc::{self, Sender};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread; use std::thread;
@@ -18,7 +18,7 @@ use notify_debouncer_mini::{DebounceEventResult, Debouncer, new_debouncer};
use serde::Serialize; use serde::Serialize;
use crate::store::FileStorage; 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. /// 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>; pub type Emit = Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>;
@@ -32,6 +32,8 @@ pub struct Core {
storage: Arc<FileStorage>, storage: Arc<FileStorage>,
persister: Persister, persister: Persister,
watcher: Option<Debouncer<notify::RecommendedWatcher>>, watcher: Option<Debouncer<notify::RecommendedWatcher>>,
/// What the watcher watches, see `Workspace::watched`.
watching: Vec<(PathBuf, bool)>,
jobs: Sender<Job>, jobs: Sender<Job>,
emit: Emit, emit: Emit,
} }
@@ -50,6 +52,7 @@ impl Core {
persister: Persister::default(), persister: Persister::default(),
storage, storage,
watcher: None, watcher: None,
watching: vec![],
jobs, jobs,
emit, emit,
}; };
@@ -86,15 +89,65 @@ impl Core {
if paths.is_empty() { if paths.is_empty() {
return; return;
} }
let outcome = self.workspace.open_files(&mut self.engine, paths, true); let outcome = self.workspace.open(&mut self.engine, paths, false);
self.keep_workspace();
self.persist();
(self.emit)("files-opened", to_json(&outcome)); (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.workspace.write(self.storage.root());
self.watch();
} }
pub fn workspace_state(&self) -> WorkspaceState { pub fn workspace_state(&self) -> WorkspaceState {
@@ -109,13 +162,16 @@ impl Core {
(self.emit)("workspace-changed", to_json(&self.workspace_state())); (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) { pub fn watch(&mut self) {
self.watcher = None; let wanted = self.workspace.watched();
let Some(folder) = self.workspace.folder.clone() else { if wanted == self.watching && (self.watcher.is_some() || wanted.is_empty()) {
return; return;
}; }
if !Path::new(&folder).is_dir() { self.watcher = None;
self.watching = wanted.clone();
if wanted.is_empty() {
return; return;
} }
let jobs = self.jobs.clone(); let jobs = self.jobs.clone();
@@ -131,22 +187,86 @@ impl Core {
); );
match debouncer { match debouncer {
Ok(mut debouncer) => { Ok(mut debouncer) => {
match debouncer.watcher().watch(&folder, RecursiveMode::Recursive) { for (path, recursive) in &wanted {
Ok(()) => self.watcher = Some(debouncer), let mode = if *recursive {
Err(error) => eprintln!("the folder cannot be watched: {error}"), RecursiveMode::Recursive
} else {
RecursiveMode::NonRecursive
};
if let Err(error) = debouncer.watcher().watch(path, mode) {
eprintln!("{} cannot be watched: {error}", path.display());
}
} }
self.watcher = Some(debouncer);
} }
Err(error) => eprintln!("the folder cannot be watched: {error}"), Err(error) => eprintln!("the files cannot be watched: {error}"),
} }
} }
fn on_disk_changed(&mut self, paths: &[PathBuf]) { fn on_disk_changed(&mut self, paths: &[PathBuf]) {
let outcome = self.workspace.reconcile(&mut self.engine, Some(paths)); // the tree of the folder shows the disk
if let Some(outcome) = outcome { let in_folder: Vec<&PathBuf> = paths
self.workspace.write(self.storage.root()); .iter()
self.persist(); .filter(|path| {
self.announce(Some(&outcome)); 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") 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 { pub fn run<T: Send + 'static>(&self, f: impl FnOnce(&mut Engine) -> T + Send + 'static) -> T {
self.core(move |core| { self.core(move |core| {
let result = f(&mut core.engine); let (result, errors) = core.editing(f);
core.persist(); for error in errors {
eprintln!("{error}");
}
result 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. /// Reads from the engine.
pub fn read<T: Send + 'static>(&self, f: impl FnOnce(&Engine) -> T + Send + 'static) -> T { pub fn read<T: Send + 'static>(&self, f: impl FnOnce(&Engine) -> T + Send + 'static) -> T {
self.core(move |core| f(&core.engine)) 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")); assert!(events.iter().any(|(name, _)| name == "workspace-changed"));
std::fs::remove_dir_all(directory).unwrap(); 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();
}
+1316 -538
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -18,7 +18,9 @@
"width": 1400, "width": 1400,
"height": 900, "height": 900,
"minWidth": 800, "minWidth": 800,
"minHeight": 600 "minHeight": 600,
"maximized": true,
"dragDropEnabled": false
} }
], ],
"security": { "csp": null } "security": { "csp": null }
+105 -45
View File
@@ -4,8 +4,8 @@ import { get } from 'svelte/store';
import { boundsManager } from '$lib/state/bounds'; import { boundsManager } from '$lib/state/bounds';
import { engine } from '$lib/engine'; import { engine } from '$lib/engine';
import { defaultFileName } from '$lib/actions/default-file-name'; import { defaultFileName } from '$lib/actions/default-file-name';
import { askUnsaved } from '$lib/components/file-list/unsaved-prompt.svelte'; import { askOverwrite, askUnsaved } from '$lib/components/file-list/unsaved-prompt.svelte';
import { subfolder } from '$lib/engine/workspace'; import { toast } from 'svelte-sonner';
import type { UnsavedChoice } from '$lib/engine'; import type { UnsavedChoice } from '$lib/engine';
/** The name of a new file: the translated default name, numbered if other files have it. */ /** The name of a new file: the translated default name, numbered if other files have it. */
@@ -32,71 +32,131 @@ export async function openFiles() {
} }
} }
/** /** The names of files, to show them to the user. */
* What to do with the files of the open folder that have unsaved changes, which the user is asked function namesOf(ids: string[]): string[] {
* 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;
}
const files = get(engine.files); const files = get(engine.files);
const names = Object.entries(workspace.files) return ids.map((id) => {
// the files in the folder, and the new ones, which are saved in it const file = files.get(id);
.filter( return file ? get(file).structure.name : id;
([, file]) => });
file.dirty && }
(file.path === undefined || subfolder(workspace.folder, file.path) !== undefined)
) /** The ids among `ids` of the files that have changes that are not saved. */
.map(([id]) => { function unsavedAmong(ids: string[]): string[] {
const file = files.get(id); const workspace = get(engine.workspace);
return file ? get(file).structure.name : id; return ids.filter((id) => workspace?.files[id]?.dirty);
});
return names.length > 0 ? askUnsaved(names) : undefined;
} }
/** /**
* Opens a folder, which is watched (the desktop app). The folder that was open is closed: the user * What to do with the files among `ids` that have unsaved changes, which the user is asked when
* is asked what to do with its unsaved files. * 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() { export async function openFolder() {
const unsaved = await askAboutUnsavedFiles(); const unsaved = await askAboutUnsavedFiles(allFileIds());
if (unsaved === 'cancel') { if (unsaved !== 'cancel') {
return; await engine.openFolder(unsaved);
}
const ids = await engine.openFolder(unsaved);
if (ids.length > 0) {
boundsManager.fitBoundsOnLoad(ids);
} }
} }
/** 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() { export async function closeFolder() {
const unsaved = await askAboutUnsavedFiles(); const unsaved = await askAboutUnsavedFiles(allFileIds());
if (unsaved !== 'cancel') { if (unsaved !== 'cancel') {
await engine.closeFolder(unsaved); 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. */ /** Saves the selected files to the file system, all the ones that have unsaved edits if none is. */
export function saveSelected(selectedFileIds: string[]) { export function saveSelected(selectedFileIds: string[]) {
const workspace = get(engine.workspace); return saveFiles(selectedFileIds.length > 0 ? selectedFileIds : unsavedAmong(allFileIds()));
const ids =
selectedFileIds.length > 0
? selectedFileIds
: Object.keys(workspace?.files ?? {}).filter((id) => workspace?.files[id].dirty);
if (ids.length > 0) {
engine.save(ids);
}
} }
/** Saves all the files that have unsaved edits to the file system. */ /** Saves all the files that have unsaved edits to the file system. */
export function saveAll() { export function saveAll() {
const workspace = get(engine.workspace); return saveFiles(unsavedAmong(allFileIds()));
const ids = Object.keys(workspace?.files ?? {}).filter((id) => workspace?.files[id].dirty); }
/** 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) { 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));
} }
} }
+120 -28
View File
@@ -40,11 +40,14 @@
FolderOpen, FolderOpen,
FileStack, FileStack,
FileX, FileX,
Save,
SaveAll,
BookOpenText, BookOpenText,
ChartArea, ChartArea,
Maximize, Maximize,
Maximize2, Maximize2,
Minimize2, Minimize2,
FolderX,
} from '@lucide/svelte'; } from '@lucide/svelte';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte'; import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
@@ -59,10 +62,22 @@
import { languages } from '$lib/i18n/languages'; import { languages } from '$lib/i18n/languages';
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
import { settings } from '$lib/state/settings'; 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 { engine } from '$lib/engine';
import { allHidden } from '$lib/engine/all-hidden'; 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 { boundsManager } from '$lib/state/bounds';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -82,10 +97,14 @@
routing, routing,
} = settings; } = settings;
const { files, selection, canUndo, canRedo, canPaste } = engine; const { files, selection, canUndo, canRedo, canPaste, workspace } = engine;
let selectionCount = $derived(selectionSize($selection)); let selectionCount = $derived(selectionSize($selection));
let noFiles = $derived($files.size === 0); 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 // metadata and style can be edited for files and tracks only
let filesOrTracksSelected = $derived($selection.type === 'file' || $selection.type === 'track'); let filesOrTracksSelected = $derived($selection.type === 'file' || $selection.type === 'track');
@@ -142,11 +161,18 @@
<Shortcut key="+" ctrl={true} /> <Shortcut key="+" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item onclick={triggerFileInput}> <Menubar.Item onclick={openFiles}>
<FolderOpen size="16" /> <FolderOpen size="16" />
{i18n._('menu.open')} {i18n._('menu.open')}
<Shortcut key="O" ctrl={true} /> <Shortcut key="O" ctrl={true} />
</Menubar.Item> </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.Separator />
<Menubar.Item onclick={() => engine.duplicate()} disabled={selectionCount == 0}> <Menubar.Item onclick={() => engine.duplicate()} disabled={selectionCount == 0}>
<Copy size="16" /> <Copy size="16" />
@@ -154,19 +180,60 @@
<Shortcut key="D" ctrl={true} /> <Shortcut key="D" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item {#if desktop}
onclick={() => engine.delete(true)} <Menubar.Item
disabled={selectionCount == 0} onclick={() => saveSelected(selectedIds)}
> disabled={selectionCount == 0 && !anyUnsaved}
<FileX size="16" /> >
{i18n._('menu.delete')} <Save size="16" />
<Shortcut key="⌫" ctrl={true} /> {i18n._('menu.save')}
</Menubar.Item> <Shortcut key="S" ctrl={true} />
<Menubar.Item onclick={() => engine.deleteAll()} disabled={noFiles}> </Menubar.Item>
<FileX size="16" /> <Menubar.Item onclick={saveAll} disabled={!anyUnsaved}>
{i18n._('menu.delete_all')} <Save size="16" />
<Shortcut key="⌫" ctrl={true} shift={true} /> {i18n._('menu.save_all')}
</Menubar.Item> <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}
>
<FileX size="16" />
{i18n._('menu.delete')}
<Shortcut key="⌫" ctrl={true} />
</Menubar.Item>
<Menubar.Item onclick={() => engine.deleteAll()} disabled={noFiles}>
<FileX size="16" />
{i18n._('menu.delete_all')}
<Shortcut key="⌫" ctrl={true} shift={true} />
</Menubar.Item>
{/if}
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item
onclick={() => (exportState.current = ExportState.SELECTION)} onclick={() => (exportState.current = ExportState.SELECTION)}
@@ -174,7 +241,7 @@
> >
<Download size="16" /> <Download size="16" />
{i18n._('menu.export')} {i18n._('menu.export')}
<Shortcut key="S" ctrl={true} /> {#if !desktop}<Shortcut key="S" ctrl={true} />{/if}
</Menubar.Item> </Menubar.Item>
<Menubar.Item <Menubar.Item
onclick={() => (exportState.current = ExportState.ALL)} onclick={() => (exportState.current = ExportState.ALL)}
@@ -182,7 +249,7 @@
> >
<Download size="16" /> <Download size="16" />
{i18n._('menu.export_all')} {i18n._('menu.export_all')}
<Shortcut key="S" ctrl={true} shift={true} /> {#if !desktop}<Shortcut key="S" ctrl={true} shift={true} />{/if}
</Menubar.Item> </Menubar.Item>
</Menubar.Content> </Menubar.Content>
</Menubar.Menu> </Menubar.Menu>
@@ -288,12 +355,17 @@
<Shortcut key="V" ctrl={true} /> <Shortcut key="V" ctrl={true} />
</Menubar.Item> </Menubar.Item>
{/if} {/if}
<Menubar.Separator /> {#if !desktop}
<Menubar.Item onclick={() => engine.delete()} disabled={selectionCount == 0}> <Menubar.Separator />
<Trash2 size="16" /> <Menubar.Item
{i18n._('menu.delete')} onclick={() => engine.delete()}
<Shortcut key="⌫" ctrl={true} /> disabled={selectionCount == 0}
</Menubar.Item> >
<Trash2 size="16" />
{i18n._('menu.delete')}
<Shortcut key="⌫" ctrl={true} />
</Menubar.Item>
{/if}
</Menubar.Content> </Menubar.Content>
</Menubar.Menu> </Menubar.Menu>
<Menubar.Menu> <Menubar.Menu>
@@ -498,6 +570,7 @@
</div> </div>
<Export /> <Export />
<UnsavedDialog />
<LayerControlSettings bind:open={layerSettingsOpen} /> <LayerControlSettings bind:open={layerSettingsOpen} />
<svelte:window <svelte:window
@@ -519,8 +592,26 @@
if (e.key === '+' && (e.metaKey || e.ctrlKey)) { if (e.key === '+' && (e.metaKey || e.ctrlKey)) {
createFile(); createFile();
e.preventDefault(); e.preventDefault();
} else if (e.key === 'o' && (e.metaKey || e.ctrlKey)) { } else if ((e.key === 'o' || e.key === 'O') && (e.metaKey || e.ctrlKey)) {
triggerFileInput(); 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(); e.preventDefault();
} else if (e.key === 'd' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'd' && (e.metaKey || e.ctrlKey)) {
engine.duplicate(); engine.duplicate();
@@ -557,7 +648,8 @@
} }
e.preventDefault(); e.preventDefault();
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) { } 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) { if (e.shiftKey) {
engine.deleteAll(); engine.deleteAll();
} else { } else {
+16
View File
@@ -12,6 +12,19 @@
} = $props(); } = $props();
function handleMouseDown(event: PointerEvent) { 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 startX = event.clientX;
const startY = event.clientY; const startY = event.clientY;
const startAfter = after; const startAfter = after;
@@ -32,10 +45,13 @@
const handleMouseUp = () => { const handleMouseUp = () => {
window.removeEventListener('pointermove', handleMouseMove); window.removeEventListener('pointermove', handleMouseMove);
window.removeEventListener('pointerup', handleMouseUp); window.removeEventListener('pointerup', handleMouseUp);
window.removeEventListener('pointercancel', handleMouseUp);
Object.assign(style, previous);
}; };
window.addEventListener('pointermove', handleMouseMove); window.addEventListener('pointermove', handleMouseMove);
window.addEventListener('pointerup', handleMouseUp); window.addEventListener('pointerup', handleMouseUp);
window.addEventListener('pointercancel', handleMouseUp);
} }
</script> </script>
@@ -1,28 +1,48 @@
<script lang="ts"> <script lang="ts">
import { setContext, type Snippet } from 'svelte'; import { getContext, setContext, type Snippet } from 'svelte';
import { CollapsibleTreeState } from './utils.svelte'; import { CollapsibleTreeState, StuckRows } from './utils.svelte';
import { stickyKey, type StickyLevel } from './sticky';
const { const {
defaultState = 'open', defaultState = 'open',
side = 'right', side = 'right',
nohover = false, nohover = false,
slotInsideTrigger = true, slotInsideTrigger = true,
sticky = false,
treeState,
children, children,
}: { }: {
defaultState?: 'open' | 'closed'; defaultState?: 'open' | 'closed';
side?: 'left' | 'right'; side?: 'left' | 'right';
nohover?: boolean; nohover?: boolean;
slotInsideTrigger?: 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; children: Snippet;
} = $props(); } = $props();
let open = $state(new CollapsibleTreeState(defaultState)); let open = $state(treeState ?? new CollapsibleTreeState(defaultState));
setContext('collapsible-tree-state', open); setContext('collapsible-tree-state', open);
setContext('collapsible-tree-side', side); setContext('collapsible-tree-side', side);
setContext('collapsible-tree-nohover', nohover); setContext('collapsible-tree-nohover', nohover);
setContext('collapsible-tree-parent-id', 'root'); setContext('collapsible-tree-parent-id', 'root');
setContext('collapsible-tree-slot-inside-trigger', slotInsideTrigger); setContext('collapsible-tree-slot-inside-trigger', slotInsideTrigger);
setContext<StickyLevel>(
stickyKey,
getContext<StickyLevel | undefined>(stickyKey) ?? {
enabled: sticky,
top: 0,
depth: 0,
stuckRows: new StuckRows(),
}
);
</script> </script>
{@render children()} {@render children()}
@@ -5,31 +5,56 @@
import { getContext, setContext, type Snippet } from 'svelte'; import { getContext, setContext, type Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements'; import type { ClassValue } from 'svelte/elements';
import type { CollapsibleTreeState } from './utils.svelte'; import type { CollapsibleTreeState } from './utils.svelte';
import { stickyKey, stickyZIndex, trackStuck, type StickyLevel } from './sticky';
const props: { const props: {
id: string | number; id: string | number;
class?: ClassValue; class?: ClassValue;
trigger: Snippet; trigger: Snippet;
content: Snippet; /** Gets whether the node is open, for contents that are only worth building then. */
content: Snippet<[boolean]>;
} = $props(); } = $props();
let state = getContext<CollapsibleTreeState>('collapsible-tree-state'); let treeState = getContext<CollapsibleTreeState>('collapsible-tree-state');
let side = getContext<'left' | 'right'>('collapsible-tree-side'); let side = getContext<'left' | 'right'>('collapsible-tree-side');
let nohover = getContext<boolean>('collapsible-tree-nohover'); let nohover = getContext<boolean>('collapsible-tree-nohover');
let slotInsideTrigger = getContext<boolean>('collapsible-tree-slot-inside-trigger'); let slotInsideTrigger = getContext<boolean>('collapsible-tree-slot-inside-trigger');
let parentId = getContext<string>('collapsible-tree-parent-id'); 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}`; let fullId = `${parentId}.${props.id}`;
setContext('collapsible-tree-parent-id', fullId); 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() { export function openNode() {
open.current = true; open.current = true;
} }
</script> </script>
<Collapsible.Root bind:open={open.current} class={props.class}> {#snippet row()}
{#if slotInsideTrigger} {#if slotInsideTrigger}
<Collapsible.Trigger class="w-full"> <Collapsible.Trigger class="w-full">
<Button <Button
@@ -87,7 +112,36 @@
{/if} {/if}
</Button> </Button>
{/if} {/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> <Collapsible.Content>
{@render props.content()} {@render props.content(open.current)}
</Collapsible.Content> </Collapsible.Content>
</Collapsible.Root> </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]; 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 /> <LayerControl />
<GPXLayers /> <GPXLayers />
{#if $files.size > 1} {#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" /> <FileList orientation="horizontal" />
</div> </div>
{/if} {/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) { 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 = ( const files = (
await Promise.all( await Promise.all(
fileIds.map(async (fileId) => { fileIds.map(async (fileId) => {
@@ -36,21 +36,21 @@
</script> </script>
<ScrollArea <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} {orientation}
scrollbarXClasses={orientation === 'vertical' ? '' : 'hidden'} scrollbarXClasses={orientation === 'vertical' ? '' : 'hidden'}
scrollbarYClasses={orientation === 'vertical' ? '' : ''} scrollbarYClasses={orientation === 'vertical' ? '' : ''}
> >
<div <div
class="flex {orientation === 'vertical' 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 ?? ''}" : 'flex-row'} {className ?? ''}"
{style} {style}
> >
<FileListNodeContent node={null} /> <FileListNodeContent node={null} />
{#if orientation === 'vertical'} {#if orientation === 'vertical'}
<ContextMenu.Root> <ContextMenu.Root>
<ContextMenu.Trigger class="grow" /> <ContextMenu.Trigger class="grow outline-none" />
<ContextMenu.Content> <ContextMenu.Content>
<ContextMenu.Item onclick={createFile}> <ContextMenu.Item onclick={createFile}>
<Plus size="16" /> <Plus size="16" />
@@ -17,6 +17,7 @@
Maximize, Maximize,
Scissors, Scissors,
FileStack, FileStack,
FileX,
} from '@lucide/svelte'; } from '@lucide/svelte';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { i18n } from '$lib/i18n/i18n.svelte'; import { i18n } from '$lib/i18n/i18n.svelte';
@@ -34,10 +35,12 @@
isInClipboard, isInClipboard,
isSelected, isSelected,
selectedElementIds, selectedElementIds,
selectedFileIds,
selectionSize, selectionSize,
type FileTreeNode, type FileTreeNode,
} from '$lib/engine/selection-helpers'; } from '$lib/engine/selection-helpers';
import { boundsManager } from '$lib/state/bounds'; import { boundsManager } from '$lib/state/bounds';
import { closeFiles } from '$lib/actions/file-actions';
import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup'; import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
let { let {
@@ -50,7 +53,7 @@
label: string | undefined; label: string | undefined;
} = $props(); } = $props();
const { selection, clipboard, canPaste } = engine; const { selection, clipboard, canPaste, workspace } = engine;
let orientation = getContext<'vertical' | 'horizontal'>('orientation'); let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let embedding = getContext<boolean>('embedding'); let embedding = getContext<boolean>('embedding');
@@ -92,6 +95,15 @@
selectedElementIds($selection)[0]?.ids[0] === elementId(node) 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))); let hidden = $derived(isHidden(fileState, elementId(node)));
// cut elements are greyed until they are pasted // cut elements are greyed until they are pasted
let isCut = $derived($clipboard?.cut === true && isInClipboard($clipboard, node)); 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 <Button
variant="ghost" 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' 'vertical'
? 'h-7' ? 'w-full h-7'
: 'h-9 px-1.5'} pointer-events-auto" : 'w-max h-9 px-1.5'} pointer-events-auto"
> >
{#if node.type === 'file' || node.type === 'track'} {#if node.type === 'file' || node.type === 'track'}
<MetadataDialog bind:open={openEditMetadata} {fileState} {node} /> <MetadataDialog bind:open={openEditMetadata} {fileState} {node} />
@@ -136,9 +153,11 @@
></div> ></div>
{/if} {/if}
<span <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' ? 'text-muted-foreground'
: ''} {isCut ? 'text-muted-foreground' : ''}" : ''}"
oncontextmenu={(e) => { oncontextmenu={(e) => {
if (embedding) { if (embedding) {
e.preventDefault(); e.preventDefault();
@@ -180,20 +199,32 @@
<MapPin size="16" class="mx-1 shrink-0" /> <MapPin size="16" class="mx-1 shrink-0" />
{/if} {/if}
{/if} {/if}
<!-- a tab of the horizontal list is as wide as its content: the name is not cut -->
<span <span
class="grow select-none truncate {orientation === 'vertical' class:italic={preview}
? 'last:mr-2' class="grow select-none {orientation === 'vertical'
: ''}" ? 'truncate last:mr-2'
: 'shrink-0'}"
> >
{label} {label}
</span> </span>
{#if hidden} {#if unsaved || hidden}
<EyeOff <span
size="10" class="flex flex-row items-center gap-1 shrink-0 pl-1 {orientation ===
class="shrink-0 size-3.5 ml-1 {orientation === 'vertical' 'vertical'
? 'mr-3' ? 'mr-3'
: 'mt-0.5'}" : ''}"
/> >
{#if unsaved}
<span class="size-1.5 rounded-full bg-foreground"></span>
{/if}
{#if hidden}
<EyeOff
size="10"
class="size-3.5 {orientation === 'vertical' ? '' : 'mt-0.5'}"
/>
{/if}
</span>
{/if} {/if}
</span> </span>
</Button> </Button>
@@ -278,10 +309,18 @@
</ContextMenu.Item> </ContextMenu.Item>
{/if} {/if}
<ContextMenu.Separator /> <ContextMenu.Separator />
<ContextMenu.Item onclick={() => engine.delete()}> {#if desktop && node.type === 'file'}
<Trash2 size="16" /> <ContextMenu.Item onclick={() => closeFiles(selectedFileIds($selection))}>
{i18n._('menu.delete')} <FileX size="16" />
<Shortcut key="⌫" ctrl={true} /> {i18n._('menu.close')}
</ContextMenu.Item> <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.Content>
</ContextMenu.Root> </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 { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
import { engine, type FileState } from '$lib/engine'; import { engine, type FileState } from '$lib/engine';
import type { FileTreeNode } from '$lib/engine/selection-helpers'; import type { FileTreeNode } from '$lib/engine/selection-helpers';
import { showEngineError } from '$lib/actions/file-actions';
let { let {
fileState, fileState,
@@ -53,8 +54,13 @@
// would open the dialog again before the effect below has reset it // would open the dialog again before the effect below has reset it
editMetadata.current = false; editMetadata.current = false;
open = false; open = false;
// the metadata of the selected elements are changed // the metadata of the selected elements are changed. In the desktop app, a file on
engine.metadata(name, description); // 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" /> <Save size="16" />
@@ -32,6 +32,14 @@ export class SortableFileList {
private _sortable: Sortable | null = null; private _sortable: Sortable | null = null;
private _elements: { [id: string]: HTMLElement } = {}; private _elements: { [id: string]: HTMLElement } = {};
private _updatingSelection: boolean = false; 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. */ /** The elements of the list when a drag from it started, to put them back when it ends. */
private _snapshot: ChildNode[] = []; private _snapshot: ChildNode[] = [];
private _unsubscribes: (() => void)[] = []; private _unsubscribes: (() => void)[] = [];
@@ -74,11 +82,6 @@ export class SortableFileList {
this._unsubscribes.push( this._unsubscribes.push(
engine.selection.subscribe(() => tick().then(() => this.updateFromSelection())) 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. */ /** The node of a child of the list. */
@@ -163,8 +166,14 @@ export class SortableFileList {
} }
if (from === to && this._sortableLevel === 'file') { if (from === to && this._sortableLevel === 'file') {
// the order of the files is the one of the list // the order of the files is the one of the list, which is put back where it was like
this.updateToFileOrder(); // 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; return;
} }
@@ -196,6 +205,9 @@ export class SortableFileList {
} }
updateFromSelection() { updateFromSelection() {
if (this._applying) {
return;
}
const changed = this.getChangedIds(); const changed = this.getChangedIds();
if (changed.length === 0) { if (changed.length === 0) {
return; return;
@@ -220,7 +232,7 @@ export class SortableFileList {
updateToSelection(e: Sortable.SortableEvent) { updateToSelection(e: Sortable.SortableEvent) {
if (!this._sortable) return; if (!this._sortable) return;
if (this._updatingSelection) return; if (this._updatingSelection || this._applying) return;
this._updatingSelection = true; this._updatingSelection = true;
const changed = this.getChangedIds(); const changed = this.getChangedIds();
if (changed.length == 0) { if (changed.length == 0) {
@@ -242,53 +254,50 @@ export class SortableFileList {
} }
const parent = this._parent; const parent = this._parent;
let request: Promise<boolean>;
if (selected.length === 0 || !this.childNode(selected[0])) { if (selected.length === 0 || !this.childNode(selected[0])) {
engine.select([]); request = engine.select([]);
} else { } else {
switch (this._sortableLevel) { switch (this._sortableLevel) {
case 'file': case 'file':
engine.select(selected); request = engine.select(selected);
break; break;
case 'track': case 'track':
engine.selectTracks(parent!.fileId, selected); request = engine.selectTracks(parent!.fileId, selected);
break; break;
case 'segment': case 'segment':
engine.selectSegments(parent!.fileId, (parent as any).trackId, selected); request = engine.selectSegments(
parent!.fileId,
(parent as any).trackId,
selected
);
break; break;
case 'waypoints': case 'waypoints':
engine.selectWaypointGroup(parent!.fileId); request = engine.selectWaypointGroup(parent!.fileId);
break; break;
case 'waypoint': case 'waypoint':
engine.selectWaypoints(parent!.fileId, selected); request = engine.selectWaypoints(parent!.fileId, selected);
break; break;
} }
} }
this._updatingSelection = false; 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') { if (!this._sortable || this._sortableLevel !== 'file') {
return; return;
} }
const fileOrder_ = get(engine.order); 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 ( if (
fileOrder_.length !== sortableOrder.length || fileOrder_.length !== sortableOrder.length ||
@@ -299,6 +308,7 @@ export class SortableFileList {
} }
updateElements() { updateElements() {
const previous = this._elements;
this._elements = {}; this._elements = {};
const files = get(engine.files); const files = get(engine.files);
this._container.childNodes.forEach((element) => { this._container.childNodes.forEach((element) => {
@@ -306,6 +316,7 @@ export class SortableFileList {
let attr = element.getAttribute('data-id'); let attr = element.getAttribute('data-id');
if (attr) { if (attr) {
if (this._sortableLevel === 'file' && !files.has(attr)) { if (this._sortableLevel === 'file' && !files.has(attr)) {
this.forget(element);
element.remove(); element.remove();
} else { } else {
this._elements[attr] = element; 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() { destroy() {
Object.values(this._elements).forEach((element) => this.forget(element));
this._sortable?.destroy();
this._sortable = null; this._sortable = null;
this._unsubscribes.forEach((unsubscribe) => unsubscribe()); this._unsubscribes.forEach((unsubscribe) => unsubscribe());
this._unsubscribes = []; 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 // Exit if anchor is being dragged
return; return;
} }
const controlLayerIds = [...this.layers.values()].map((l) => l.id);
const segmentIds = this.anchorData?.segmentIds ?? [];
if ( if (
e.target.queryRenderedFeatures(e.point, { e.target
layers: [...this.listenedFileIds, ...[...this.layers.values()].map((l) => l.id)], .queryRenderedFeatures(e.point, {
}).length 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; return;
} }
this.appendAnchorWithCoordinates({ lng: e.lngLat.lng, lat: e.lngLat.lat }); 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'; export type { Backend } from './types';
/** Whether the page runs in the desktop app, which has the engine in its own process. */ /** 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; return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
} }
+18 -3
View File
@@ -1,7 +1,7 @@
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import type { Outcome, RouteAttributes, StorageOpened } from 'gpx-wasm'; 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. */ /** An array that a command returns as its bytes. */
const array = async <T>( const array = async <T>(
@@ -211,15 +211,30 @@ export async function createTauriBackend(): Promise<Backend> {
desktop: { desktop: {
workspace: () => invoke<WorkspaceState>('workspace_state'), workspace: () => invoke<WorkspaceState>('workspace_state'),
openFiles: () => invoke<Outcome>('open_files'), openFiles: () => invoke<Outcome>('open_files'),
openPaths: (paths, preview) => invoke<Outcome>('open_paths', { paths, preview }),
openFolder: (unsaved) => invoke<Outcome>('open_folder', { unsaved }), openFolder: (unsaved) => invoke<Outcome>('open_folder', { unsaved }),
closeFolder: (unsaved) => invoke<Outcome>('close_folder', { unsaved }), closeFolder: (unsaved) => invoke<Outcome>('close_folder', { unsaved }),
saveFiles: (fileIds) => invoke('save_files', { fileIds }), closeFiles: (fileIds, unsaved) => invoke<Outcome>('close_files', { fileIds, unsaved }),
listen: async (onEngineChanged, onWorkspaceChanged, onFilesOpened) => { 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>('files-opened', (event) => onFilesOpened(event.payload));
await listen<Outcome>('engine-changed', (event) => onEngineChanged(event.payload)); await listen<Outcome>('engine-changed', (event) => onEngineChanged(event.payload));
await listen<WorkspaceState>('workspace-changed', (event) => await listen<WorkspaceState>('workspace-changed', (event) =>
onWorkspaceChanged(event.payload) onWorkspaceChanged(event.payload)
); );
await listen<string[]>('folder-changed', () => onFolderChanged());
}, },
}, },
+79 -17
View File
@@ -10,16 +10,35 @@ import type {
TrackpointDetails, TrackpointDetails,
WaypointDetails, WaypointDetails,
} from 'gpx-wasm'; } 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. */ /** What the desktop app knows of a file of the engine and the file system. */
export type FileStatus = { export type FileStatus = {
/** Where the file is on the disk, absent for a file that only is in the engine. */ /** Where the file is on the disk, absent for a file that only is in the engine. */
path?: string; 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; dirty: boolean;
/** The file on the disk was changed by something else while this one had edits. */ /** The file on the disk was replaced by something else: saving overwrites it. */
conflict: boolean; 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. */ /** 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. */ /** Opens the files that the user picks, which get selected. */
openFiles(): Promise<Outcome>; openFiles(): Promise<Outcome>;
/** /**
* Opens the folder that the user picks: its files are opened, and it is watched. The folder * Opens files of the file system by their path, like the ones of the tree of the folder. With
* that was open is closed (see `closeFolder` for `unsaved`). * `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>; openFolder(unsaved?: UnsavedChoice): Promise<Outcome>;
/** /** Closes the open folder: it is not watched anymore, and its files are closed. */
* 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.
*/
closeFolder(unsaved?: UnsavedChoice): Promise<Outcome>; closeFolder(unsaved?: UnsavedChoice): Promise<Outcome>;
/** /**
* Writes files to the disk, replacing what is there (the user is asked where to put the ones * Closes files. What to do with the ones that have unsaved edits: `save` them first (the user
* that are not on the disk yet). * 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 * 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 * 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). * 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( listen(
onEngineChanged: (outcome: Outcome) => void, onEngineChanged: (outcome: Outcome) => void,
onWorkspaceChanged: (workspace: WorkspaceState) => void, onWorkspaceChanged: (workspace: WorkspaceState) => void,
onFilesOpened: (outcome: Outcome) => void onFilesOpened: (outcome: Outcome) => void,
onFolderChanged: () => void
): Promise<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 { selectedElementIds, type FileTreeNode } from '$lib/engine/selection-helpers';
import type { RouteAttributes } from 'gpx-wasm'; import type { RouteAttributes } from 'gpx-wasm';
import { loadBackend, type Backend } from './backend'; import { loadBackend, type Backend } from './backend';
import type { DesktopFiles } from './backend/types';
import { readFileState } from './file-state'; import { readFileState } from './file-state';
import { StatisticsReader } from './statistics'; import { StatisticsReader } from './statistics';
import { openStoredFiles } from './storage'; import { openStoredFiles } from './storage';
@@ -29,6 +30,7 @@ import {
type SelectMode, type SelectMode,
type SplitType, type SplitType,
type StatisticsRequest, type StatisticsRequest,
type FolderEntry,
type UnsavedChoice, type UnsavedChoice,
type WorkspaceState, type WorkspaceState,
type TrackpointDetails, type TrackpointDetails,
@@ -63,6 +65,7 @@ class Engine {
private _lastError = writable<string | undefined>(undefined); private _lastError = writable<string | undefined>(undefined);
private _outdated = writable(false); private _outdated = writable(false);
private _workspace = writable<WorkspaceState | undefined>(undefined); private _workspace = writable<WorkspaceState | undefined>(undefined);
private _folderVersion = writable(0);
private _canUndo = writable(false); private _canUndo = writable(false);
private _canRedo = writable(false); private _canRedo = writable(false);
private _canPaste = writable(false); private _canPaste = writable(false);
@@ -84,6 +87,11 @@ class Engine {
readonly workspace: Readable<WorkspaceState | undefined> = { readonly workspace: Readable<WorkspaceState | undefined> = {
subscribe: this._workspace.subscribe, 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. */ /** Why the last call to the engine did nothing, `undefined` if it did something. */
readonly lastError: Readable<string | undefined> = { subscribe: this._lastError.subscribe }; readonly lastError: Readable<string | undefined> = { subscribe: this._lastError.subscribe };
/** What was copied or cut, waiting to be pasted. */ /** 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 * Opens files of the file system by their path, which get selected: as a preview with
* `closeFolder` does, `unsaved` being what to do with its unsaved files): its files are opened, * `preview`, see `DesktopFiles.openPaths`. Resolves to the new ids.
* and it is watched: the files that appear, change or disappear in it are brought in the engine.
* New files are saved in it. Resolves to the ids of the files that were added.
*/ */
async openFolder(unsaved?: UnsavedChoice): Promise<string[]> { async openPaths(paths: string[], preview = false): Promise<string[]> {
return this.addedBy((b) => b.desktop!.openFolder(unsaved)); 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 * Opens the folder that the user picks (instead of the one that was open, which is closed with
* what to do with the ones that have unsaved edits: they are saved first, or their edits are * its files like `closeFiles` does, `unsaved` being what to do with the unsaved ones). The
* discarded (they stay open when it is not said). * 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) { async closeFolder(unsaved?: UnsavedChoice) {
await this.run((b) => b.desktop!.closeFolder(unsaved)); await this.desktopRun((b) => b.desktop!.closeFolder(unsaved));
await this.enqueue(async () => this.refreshWorkspace(this.backend!)); }
/**
* 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 * 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.ready;
await this.enqueue(async () => { try {
const backend = this.backend!; await this.backend!.desktop?.openUrl(url);
} catch (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 { try {
await backend.desktop!.saveFiles(fileIds); return await this.backend!.desktop!.listFolder(path);
} catch (error) { } catch (error) {
console.error('The files could not be saved', error); console.error('The folder could not be read', error);
this._lastError.set(String(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)); 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)); 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) { if (backend.desktop) {
this._workspace.set(await backend.desktop.workspace()); 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); await this.sync(backend, outcome);
this.report(outcome); this.report(outcome);
this._opened.forEach((callback) => callback(outcome.added)); 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 { CategoryIntervals } from '$lib/engine/trackpoint-categories';
import type { FileStructure, GlobalStatistics } from 'gpx-wasm'; 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 { export type {
Clipboard, Clipboard,
+65 -1
View File
@@ -1,6 +1,14 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { describe, it } from 'node:test'; 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', () => { describe('subfolder', () => {
it('is where a file is in the folder', () => { it('is where a file is in the folder', () => {
@@ -27,3 +35,59 @@ describe('folderName', () => {
assert.equal(folderName('C:\\Users\\me\\tracks'), 'tracks'); 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('/'); 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 { browser } from '$app/environment';
import type { DistanceUnits, TemperatureUnits, VelocityUnits } from '$lib/units/conversions'; import type { DistanceUnits, TemperatureUnits, VelocityUnits } from '$lib/units/conversions';
import { engine } from '$lib/engine'; import { engine } from '$lib/engine';
import { languages } from '$lib/i18n/languages';
import { import {
getArrayValidator, getArrayValidator,
getLayerTreeValidator, getLayerTreeValidator,
@@ -156,6 +157,13 @@ export const settings = {
defaultWidth: new Setting('defaultWidth', browser && window.innerWidth < 600 ? 8 : 5), defaultWidth: new Setting('defaultWidth', browser && window.innerWidth < 600 ? 8 : 5),
bottomPanelSize: new Setting('bottomPanelSize', 170), bottomPanelSize: new Setting('bottomPanelSize', 170),
rightPanelSize: new Setting('rightPanelSize', 240), 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`). */ /** Takes the saved values (see `Engine.openStorage`). */
connect(stored: StoredSettings) { connect(stored: StoredSettings) {
for (const key in settings) { for (const key in settings) {
+29 -1
View File
@@ -13,6 +13,32 @@
"new_track": "New track", "new_track": "New track",
"new_segment": "New segment", "new_segment": "New segment",
"open": "Open...", "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", "duplicate": "Duplicate",
"copy": "Copy", "copy": "Copy",
"paste": "Paste", "paste": "Paste",
@@ -70,7 +96,9 @@
"button": "Info...", "button": "Info...",
"name": "Name", "name": "Name",
"description": "Description", "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": { "style": {
"button": "Appearance...", "button": "Appearance...",
@@ -2,6 +2,7 @@
import GPXLayers from '$lib/components/map/gpx-layer/GPXLayers.svelte'; import GPXLayers from '$lib/components/map/gpx-layer/GPXLayers.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte'; import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import FileList from '$lib/components/file-list/FileList.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 GPXStatistics from '$lib/components/GPXStatistics.svelte';
import Map from '$lib/components/map/Map.svelte'; import Map from '$lib/components/map/Map.svelte';
import Menu from '$lib/components/Menu.svelte'; import Menu from '$lib/components/Menu.svelte';
@@ -17,32 +18,74 @@
import { loadFiles } from '$lib/actions/file-actions'; import { loadFiles } from '$lib/actions/file-actions';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/state'; 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 { hoveredPoint, slicedStatistics } from '$lib/state/selection-statistics';
import { engine } from '$lib/engine'; import { engine } from '$lib/engine';
import { boundsManager } from '$lib/state/bounds'; import { boundsManager } from '$lib/state/bounds';
import { getURLForGoogleDriveFile } from '$lib/components/embedding/embedding'; import { getURLForGoogleDriveFile } from '$lib/components/embedding/embedding';
const outdated = engine.outdated; const outdated = engine.outdated;
const workspace = engine.workspace;
const { const {
treeFileView, treeFileView,
elevationProfile, elevationProfile,
bottomPanelSize, bottomPanelSize,
rightPanelSize, rightPanelSize,
explorerPanelSize,
additionalDatasets, additionalDatasets,
elevationFill, elevationFill,
} = settings; } = settings;
let bottomPanelWidth: number | undefined = $state(); 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( let bottomPanelOrientation = $derived(
bottomPanelWidth && bottomPanelWidth >= 540 && $elevationProfile ? 'horizontal' : 'vertical' 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 () => { onMount(async () => {
// files that the system opens with the app while it runs // files that the system opens with the app while it runs
engine.onFilesOpened((ids) => boundsManager.fitBoundsOnLoad(ids)); engine.onFilesOpened((ids) => boundsManager.fitBoundsOnLoad(ids));
settings.connect(await engine.openStorage()); 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 files: string[] = JSON.parse(page.url.searchParams.get('files') || '[]');
let ids: string[] = JSON.parse(page.url.searchParams.get('ids') || '[]'); let ids: string[] = JSON.parse(page.url.searchParams.get('ids') || '[]');
let urls: string[] = files.concat(ids.map(getURLForGoogleDriveFile)); let urls: string[] = files.concat(ids.map(getURLForGoogleDriveFile));
@@ -117,7 +160,7 @@
<CoordinatesPopup /> <CoordinatesPopup />
<Toaster richColors /> <Toaster richColors />
{#if !$treeFileView} {#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" /> <FileList orientation="horizontal" />
</div> </div>
{/if} {/if}
@@ -153,9 +196,43 @@
{/if} {/if}
</div> </div>
</div> </div>
{#if $treeFileView} {#if $treeFileView || $workspace?.folder}
<Resizer orientation="col" bind:after={$rightPanelSize} minAfter={100} maxAfter={400} /> <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} {/if}
</div> </div>