mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-27 02:27:18 +00:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05510ece92 | ||
|
|
8bbcbdc097 | ||
|
|
3012f6bf73 | ||
|
|
5373d0f0d4 | ||
|
|
8fd47d8b18 | ||
|
|
a5c1f6ad33 | ||
|
|
3ef2bf135a | ||
|
|
adbf89e318 | ||
|
|
11709316d2 | ||
|
|
d52b61f707 | ||
|
|
358253b7a9 | ||
|
|
8d6885d58b | ||
|
|
128338aa05 | ||
|
|
c84e1c9a06 | ||
|
|
9f49119eec | ||
|
|
67a47cf065 | ||
|
|
e57949dc01 | ||
|
|
da92fd15d2 | ||
|
|
01443943b3 | ||
|
|
970cf7d1e1 | ||
|
|
593b43e433 | ||
|
|
8fa88680b3 | ||
|
|
b2495d1466 | ||
|
|
ed90ba6a1f | ||
|
|
b0a3c3a9ba | ||
|
|
005cc7b305 |
No files matched your search
Generated
+78
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
@@ -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());
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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, ¤t))).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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)))
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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 }
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user