mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-19 09:22:12 +00:00
progress
This commit is contained in:
1 parent
005cc7b305
commit
b0a3c3a9ba
18 files changed
+1685
-678
No files matched your search
@@ -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()
|
||||||
|
|||||||
+308
-47
@@ -1,16 +1,21 @@
|
|||||||
//! 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::PathBuf;
|
||||||
|
|
||||||
use gpx_engine::api;
|
use gpx_engine::{ExportOptions, 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::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
|
||||||
@@ -35,17 +40,186 @@ 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens files that are on the disk, like the ones of the tree of the open folder. They get selected.
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn open_paths(state: State<AppState>, paths: Vec<PathBuf>) -> api::Outcome {
|
||||||
state.core(move |core| {
|
state.core(move |core| {
|
||||||
let outcome = core.workspace.open_files(&mut core.engine, &files, true);
|
let outcome = core.workspace.open_files(&mut core.engine, &paths, true);
|
||||||
core.keep_workspace();
|
core.keep_workspace();
|
||||||
core.persist();
|
core.persist();
|
||||||
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)] => {
|
||||||
|
let mut dialog = app
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.add_filter("GPX", &["gpx"])
|
||||||
|
.set_file_name(format!("{name}.gpx"));
|
||||||
|
if let Some(start) = &start {
|
||||||
|
dialog = dialog.set_directory(start);
|
||||||
|
}
|
||||||
|
match dialog
|
||||||
|
.blocking_save_file()
|
||||||
|
.and_then(|path| path.into_path().ok())
|
||||||
|
{
|
||||||
|
Some(path) => {
|
||||||
|
chosen.insert(*id, path);
|
||||||
|
}
|
||||||
|
None => return Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let mut dialog = app.dialog().file();
|
||||||
|
if let Some(start) = &start {
|
||||||
|
dialog = dialog.set_directory(start);
|
||||||
|
}
|
||||||
|
match dialog
|
||||||
|
.blocking_pick_folder()
|
||||||
|
.and_then(|path| path.into_path().ok())
|
||||||
|
{
|
||||||
|
Some(path) => folder = Some(path),
|
||||||
|
None => return Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.core(move |core| {
|
||||||
|
let result = core
|
||||||
|
.workspace
|
||||||
|
.save(&core.engine, &ids, &chosen, folder.as_deref());
|
||||||
|
core.keep_workspace();
|
||||||
|
core.announce(None);
|
||||||
|
result.map(|()| true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(&gpx_engine::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);
|
||||||
|
};
|
||||||
|
let mut dialog = app.dialog().file().add_filter("GPX", &["gpx"]);
|
||||||
|
match ¤t {
|
||||||
|
Some(path) => {
|
||||||
|
if let Some(file_name) = path.file_name() {
|
||||||
|
dialog = dialog.set_file_name(file_name.to_string_lossy());
|
||||||
|
}
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
dialog = dialog.set_directory(parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
dialog = dialog.set_file_name(format!("{name}.gpx"));
|
||||||
|
if let Some(folder) = &folder {
|
||||||
|
dialog = dialog.set_directory(folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(path) = dialog
|
||||||
|
.blocking_save_file()
|
||||||
|
.and_then(|path| path.into_path().ok())
|
||||||
|
else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
state.core(move |core| {
|
||||||
|
let result = core.workspace.save_as(&core.engine, file_id, &path);
|
||||||
|
core.keep_workspace();
|
||||||
|
core.announce(None);
|
||||||
|
result.map(|()| true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.keep_workspace();
|
||||||
|
core.persist();
|
||||||
|
core.announce(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 +234,167 @@ 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.keep_workspace();
|
||||||
core.persist();
|
core.persist();
|
||||||
core.watch();
|
core.watch();
|
||||||
core.announce(None);
|
core.announce(None);
|
||||||
outcome
|
Ok(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
|
||||||
|
.workspace
|
||||||
|
.close_folder(&mut core.engine, unsaved.is_some());
|
||||||
core.keep_workspace();
|
core.keep_workspace();
|
||||||
core.persist();
|
core.persist();
|
||||||
core.watch();
|
core.watch();
|
||||||
core.announce(None);
|
core.announce(None);
|
||||||
outcome
|
Ok(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)? {
|
||||||
|
return Ok(api::Outcome::failed_none());
|
||||||
|
}
|
||||||
state.core(move |core| {
|
state.core(move |core| {
|
||||||
(
|
let outcome = core
|
||||||
ids.clone(),
|
.workspace
|
||||||
core.workspace.unlinked(&core.engine, &ids),
|
.close_files(&mut core.engine, &file_ids, unsaved.is_some());
|
||||||
core.workspace.folder.clone(),
|
core.keep_workspace();
|
||||||
)
|
core.persist();
|
||||||
|
core.announce(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![]);
|
||||||
};
|
};
|
||||||
let mut chosen = BTreeMap::new();
|
core.workspace.list_folder(&path)
|
||||||
// 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
|
/// 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.read(move |engine| {
|
||||||
|
file_ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| {
|
||||||
|
let name = engine
|
||||||
|
.file_structure(&gpx_engine::FileId(*id))?
|
||||||
|
.name
|
||||||
|
.clone();
|
||||||
|
Some((name, api::export_file(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) = app
|
||||||
.dialog()
|
.dialog()
|
||||||
.file()
|
.file()
|
||||||
.add_filter("GPX", &["gpx"])
|
.add_filter("GPX", &["gpx"])
|
||||||
.set_file_name(format!("{name}.gpx"));
|
.set_file_name(format!("{name}.gpx"))
|
||||||
if let Some(folder) = &folder {
|
|
||||||
dialog = dialog.set_directory(folder);
|
|
||||||
}
|
|
||||||
match dialog
|
|
||||||
.blocking_save_file()
|
.blocking_save_file()
|
||||||
.and_then(|path| path.into_path().ok())
|
.and_then(|path| path.into_path().ok())
|
||||||
{
|
else {
|
||||||
Some(path) => {
|
return Ok(false);
|
||||||
chosen.insert(id, path);
|
};
|
||||||
|
write(path, bytes).map(|()| true)
|
||||||
}
|
}
|
||||||
// cancelled: the others are not saved either
|
_ => {
|
||||||
None => return Ok(()),
|
let Some(folder) = app
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.blocking_pick_folder()
|
||||||
|
.and_then(|path| path.into_path().ok())
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state.core(move |core| {
|
|
||||||
let result = core.workspace.save(&core.engine, &ids, &chosen);
|
|
||||||
core.keep_workspace();
|
|
||||||
core.announce(None);
|
|
||||||
result
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
@@ -139,12 +139,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 +214,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)]
|
||||||
|
|||||||
@@ -184,9 +184,15 @@ 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,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+75
-14
@@ -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;
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
@@ -93,8 +96,10 @@ impl Core {
|
|||||||
self.announce(None);
|
self.announce(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn keep_workspace(&self) {
|
/// 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 +114,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,23 +139,76 @@ 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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => eprintln!("the folder cannot be watched: {error}"),
|
self.watcher = Some(debouncer);
|
||||||
|
}
|
||||||
|
Err(error) => eprintln!("the files cannot be watched: {error}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_disk_changed(&mut self, paths: &[PathBuf]) {
|
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()
|
||||||
|
.filter(|path| {
|
||||||
|
self.workspace
|
||||||
|
.folder
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|f| path.starts_with(f))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if !in_folder.is_empty() {
|
||||||
|
(self.emit)("folder-changed", to_json(&in_folder));
|
||||||
|
}
|
||||||
|
if let Some(outcome) = self.workspace.reconcile(&mut self.engine, Some(paths)) {
|
||||||
|
self.keep_workspace();
|
||||||
self.persist();
|
self.persist();
|
||||||
self.announce(Some(&outcome));
|
self.announce(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 outcome = f(&mut self.engine);
|
||||||
|
if let Some(dir) = dir {
|
||||||
|
self.workspace.inherit(outcome.added(), &dir);
|
||||||
|
}
|
||||||
|
self.keep_workspace();
|
||||||
|
self.persist();
|
||||||
|
outcome
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn to_json<T: Serialize>(value: &T) -> serde_json::Value {
|
fn to_json<T: Serialize>(value: &T) -> serde_json::Value {
|
||||||
|
|||||||
@@ -363,3 +363,95 @@ 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, and is not linked to a place yet
|
||||||
|
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"], true);
|
||||||
|
assert!(state["files"][&new_id]["folder"].is_string());
|
||||||
|
assert!(state["files"][&new_id].get("path").is_none());
|
||||||
|
|
||||||
|
// 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("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();
|
||||||
|
}
|
||||||
+760
-526
File diff suppressed because it is too large.
Load diff
@@ -4,8 +4,7 @@ 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 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 +31,119 @@ 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
|
|
||||||
.filter(
|
|
||||||
([, file]) =>
|
|
||||||
file.dirty &&
|
|
||||||
(file.path === undefined || subfolder(workspace.folder, file.path) !== undefined)
|
|
||||||
)
|
|
||||||
.map(([id]) => {
|
|
||||||
const file = files.get(id);
|
const file = files.get(id);
|
||||||
return file ? get(file).structure.name : id;
|
return file ? get(file).structure.name : id;
|
||||||
});
|
});
|
||||||
return names.length > 0 ? askUnsaved(names) : undefined;
|
}
|
||||||
|
|
||||||
|
/** The ids among `ids` of the files that have changes that are not saved. */
|
||||||
|
function unsavedAmong(ids: string[]): string[] {
|
||||||
|
const workspace = get(engine.workspace);
|
||||||
|
return ids.filter((id) => workspace?.files[id]?.dirty);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens a folder, which is watched (the desktop app). The folder that was open is closed: the user
|
* 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 a file of the tree of the open folder. */
|
||||||
|
export async function openPath(path: string) {
|
||||||
|
const ids = await engine.openPaths([path]);
|
||||||
if (ids.length > 0) {
|
if (ids.length > 0) {
|
||||||
engine.save(ids);
|
boundsManager.fitBoundsOnLoad(ids);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
FileStack,
|
FileStack,
|
||||||
FileX,
|
FileX,
|
||||||
|
Save,
|
||||||
|
SaveAll,
|
||||||
BookOpenText,
|
BookOpenText,
|
||||||
ChartArea,
|
ChartArea,
|
||||||
Maximize,
|
Maximize,
|
||||||
@@ -59,10 +61,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 +96,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 +160,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,6 +179,46 @@
|
|||||||
<Shortcut key="D" ctrl={true} />
|
<Shortcut key="D" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
|
{#if desktop}
|
||||||
|
<Menubar.Item
|
||||||
|
onclick={() => saveSelected(selectedIds)}
|
||||||
|
disabled={selectionCount == 0 && !anyUnsaved}
|
||||||
|
>
|
||||||
|
<Save size="16" />
|
||||||
|
{i18n._('menu.save')}
|
||||||
|
<Shortcut key="S" ctrl={true} />
|
||||||
|
</Menubar.Item>
|
||||||
|
<Menubar.Item onclick={saveAll} disabled={!anyUnsaved}>
|
||||||
|
<Save size="16" />
|
||||||
|
{i18n._('menu.save_all')}
|
||||||
|
<Shortcut key="S" ctrl={true} shift={true} />
|
||||||
|
</Menubar.Item>
|
||||||
|
<Menubar.Item
|
||||||
|
onclick={() => saveAs(selectedIds)}
|
||||||
|
disabled={selectedIds.length !== 1}
|
||||||
|
>
|
||||||
|
<SaveAll size="16" />
|
||||||
|
{i18n._('menu.save_as')}
|
||||||
|
</Menubar.Item>
|
||||||
|
<Menubar.Separator />
|
||||||
|
<Menubar.Item
|
||||||
|
onclick={() => closeFiles(selectedIds)}
|
||||||
|
disabled={selectedIds.length === 0}
|
||||||
|
>
|
||||||
|
<FileX size="16" />
|
||||||
|
{i18n._('menu.close')}
|
||||||
|
<Shortcut key="W" ctrl={true} />
|
||||||
|
</Menubar.Item>
|
||||||
|
<Menubar.Item onclick={closeAll} disabled={noFiles}>
|
||||||
|
<FileX size="16" />
|
||||||
|
{i18n._('menu.close_all')}
|
||||||
|
<Shortcut key="W" ctrl={true} shift={true} />
|
||||||
|
</Menubar.Item>
|
||||||
|
<Menubar.Item onclick={closeFolder} disabled={!$workspace?.folder}>
|
||||||
|
<FileX size="16" />
|
||||||
|
{i18n._('menu.close_folder')}
|
||||||
|
</Menubar.Item>
|
||||||
|
{:else}
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
onclick={() => engine.delete(true)}
|
onclick={() => engine.delete(true)}
|
||||||
disabled={selectionCount == 0}
|
disabled={selectionCount == 0}
|
||||||
@@ -167,6 +232,7 @@
|
|||||||
{i18n._('menu.delete_all')}
|
{i18n._('menu.delete_all')}
|
||||||
<Shortcut key="⌫" ctrl={true} shift={true} />
|
<Shortcut key="⌫" ctrl={true} shift={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
|
{/if}
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
onclick={() => (exportState.current = ExportState.SELECTION)}
|
onclick={() => (exportState.current = ExportState.SELECTION)}
|
||||||
@@ -174,7 +240,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 +248,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>
|
||||||
@@ -498,6 +564,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Export />
|
<Export />
|
||||||
|
<UnsavedDialog />
|
||||||
<LayerControlSettings bind:open={layerSettingsOpen} />
|
<LayerControlSettings bind:open={layerSettingsOpen} />
|
||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
@@ -519,8 +586,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 +642,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 {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,9 +211,16 @@ 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) => invoke<Outcome>('open_paths', { paths }),
|
||||||
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 }),
|
||||||
|
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 }),
|
||||||
listen: async (onEngineChanged, onWorkspaceChanged, onFilesOpened) => {
|
listen: async (onEngineChanged, onWorkspaceChanged, onFilesOpened) => {
|
||||||
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));
|
||||||
|
|||||||
@@ -10,16 +10,33 @@ 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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. */
|
||||||
@@ -39,22 +56,39 @@ export interface DesktopFiles {
|
|||||||
workspace(): Promise<WorkspaceState>;
|
workspace(): Promise<WorkspaceState>;
|
||||||
/** 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 files of the file system by their path, like the ones of the tree of the folder. */
|
||||||
|
openPaths(paths: string[]): Promise<Outcome>;
|
||||||
/**
|
/**
|
||||||
* Opens the folder that the user picks: its files are opened, and it is watched. The folder
|
* Opens the folder that the user picks, which is watched. Its files are not opened: they are
|
||||||
* that was open is closed (see `closeFolder` for `unsaved`).
|
* 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[]>;
|
||||||
/**
|
/**
|
||||||
* 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, and when files were opened with the
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -554,41 +556,88 @@ class Engine {
|
|||||||
return this.addedBy((b) => b.desktop!.openFiles());
|
return this.addedBy((b) => b.desktop!.openFiles());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Opens files of the file system by their path, which get selected. Resolves to the new ids. */
|
||||||
* Opens the folder that the user picks (instead of the one that was open, which is closed like
|
async openPaths(paths: string[]): Promise<string[]> {
|
||||||
* `closeFolder` does, `unsaved` being what to do with its unsaved files): its files are opened,
|
return this.addedBy((b) => b.desktop!.openPaths(paths));
|
||||||
* 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[]> {
|
|
||||||
return this.addedBy((b) => b.desktop!.openFolder(unsaved));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.run((b) => b.desktop!.openFolder(unsaved));
|
||||||
|
await this.enqueue(async () => this.refreshWorkspace(this.backend!));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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.run((b) => b.desktop!.closeFolder(unsaved));
|
||||||
await this.enqueue(async () => this.refreshWorkspace(this.backend!));
|
await this.enqueue(async () => this.refreshWorkspace(this.backend!));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes files to the file system, replacing what is there; they are not edited anymore from
|
* Closes files. `unsaved` says what to do with the ones that have unsaved edits: they are saved
|
||||||
* then on. The user is asked where to put the ones that are not on the file system yet.
|
* first, or their edits are lost (they stay open when it is not said).
|
||||||
*/
|
*/
|
||||||
async save(fileIds: string[]) {
|
async closeFiles(fileIds: string[], unsaved?: UnsavedChoice) {
|
||||||
|
await this.run((b) => b.desktop!.closeFiles(fileIds, unsaved));
|
||||||
|
await this.enqueue(async () => this.refreshWorkspace(this.backend!));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 have no place yet. Resolves to false
|
||||||
|
* when the user cancelled or the files could not be saved.
|
||||||
|
*/
|
||||||
|
async save(fileIds: string[]): Promise<boolean> {
|
||||||
|
return this.desktopCall((desktop) => desktop.saveFiles(fileIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes a file where the user says, which is where it is from then on. */
|
||||||
|
async saveAs(fileId: string): Promise<boolean> {
|
||||||
|
return this.desktopCall((desktop) => desktop.saveFileAs(fileId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes copies of files where the user says. The files stay as they are. */
|
||||||
|
async exportFiles(fileIds: string[], options: ExportOptions): Promise<boolean> {
|
||||||
|
return this.desktopCall((desktop) => desktop.exportFiles(fileIds, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads files again from the file system: their edits are lost (one undo step). */
|
||||||
|
async reload(fileIds: string[]) {
|
||||||
|
await this.run((b) => b.desktop!.reloadFiles(fileIds));
|
||||||
|
await this.enqueue(async () => this.refreshWorkspace(this.backend!));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
await this.ready;
|
||||||
await this.enqueue(async () => {
|
return this.enqueue(async () => {
|
||||||
const backend = this.backend!;
|
|
||||||
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);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calls the file system of the desktop app, and keeps the status of the files up to date. */
|
||||||
|
private async desktopCall(call: (desktop: DesktopFiles) => Promise<boolean>): Promise<boolean> {
|
||||||
|
await this.ready;
|
||||||
|
return this.enqueue(async () => {
|
||||||
|
const backend = this.backend!;
|
||||||
|
let done = false;
|
||||||
|
try {
|
||||||
|
done = await call(backend.desktop!);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('The files could not be written', error);
|
||||||
this._lastError.set(String(error));
|
this._lastError.set(String(error));
|
||||||
}
|
}
|
||||||
await this.refreshWorkspace(backend);
|
await this.refreshWorkspace(backend);
|
||||||
|
return done;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -13,6 +13,21 @@
|
|||||||
"new_track": "New track",
|
"new_track": "New track",
|
||||||
"new_segment": "New segment",
|
"new_segment": "New segment",
|
||||||
"open": "Open...",
|
"open": "Open...",
|
||||||
|
"open_folder": "Open folder...",
|
||||||
|
"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",
|
||||||
|
|||||||
Reference in new issue
Block a user