mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-19 09:22:12 +00:00
cleaning
This commit is contained in:
1 parent
8fd47d8b18
commit
5373d0f0d4
15 files changed
+339
-408
No files matched your search
+60
-105
@@ -6,9 +6,9 @@
|
|||||||
//! open if it does not say.
|
//! 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::{ExportOptions, api};
|
use gpx_engine::{ExportOptions, FileId, api};
|
||||||
use serde::Deserialize;
|
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};
|
||||||
@@ -25,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())
|
||||||
@@ -46,7 +72,7 @@ pub fn open_files<R: Runtime>(app: AppHandle<R>, state: State<AppState>) -> api:
|
|||||||
|
|
||||||
/// Opens files that are on the disk, like the ones of the tree of the open folder. They get selected.
|
/// 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
|
/// With `preview`, a single file is open as a preview (a single click in the tree, see
|
||||||
/// `Workspace::open_preview`); otherwise they are open for good, even the preview.
|
/// `Workspace::open`); otherwise they are open for good, even the preview.
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
pub fn open_paths(
|
pub fn open_paths(
|
||||||
state: State<AppState>,
|
state: State<AppState>,
|
||||||
@@ -54,18 +80,10 @@ pub fn open_paths(
|
|||||||
preview: Option<bool>,
|
preview: Option<bool>,
|
||||||
) -> api::Outcome {
|
) -> api::Outcome {
|
||||||
state.core(move |core| {
|
state.core(move |core| {
|
||||||
let outcome = match paths.as_slice() {
|
let outcome = core
|
||||||
[path] if preview == Some(true) => core.workspace.open_preview(&mut core.engine, path),
|
.workspace
|
||||||
_ => {
|
.open(&mut core.engine, &paths, preview.unwrap_or_default());
|
||||||
let outcome = core.workspace.open_files(&mut core.engine, &paths, true);
|
core.commit(None);
|
||||||
let ids = core.workspace.ids_at(&paths);
|
|
||||||
core.workspace.keep(&ids);
|
|
||||||
outcome
|
|
||||||
}
|
|
||||||
};
|
|
||||||
core.keep_workspace();
|
|
||||||
core.persist();
|
|
||||||
core.announce(None);
|
|
||||||
outcome
|
outcome
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -93,38 +111,16 @@ fn write_unsaved<R: Runtime>(
|
|||||||
let mut folder = None;
|
let mut folder = None;
|
||||||
match unlinked.as_slice() {
|
match unlinked.as_slice() {
|
||||||
[] => {}
|
[] => {}
|
||||||
[(id, name)] => {
|
[(id, name)] => match ask_save_path(app, &format!("{name}.gpx"), start.as_deref()) {
|
||||||
let mut dialog = app
|
Some(path) => {
|
||||||
.dialog()
|
chosen.insert(*id, path);
|
||||||
.file()
|
|
||||||
.add_filter("GPX", &["gpx"])
|
|
||||||
.set_file_name(format!("{name}.gpx"));
|
|
||||||
if let Some(start) = &start {
|
|
||||||
dialog = dialog.set_directory(start);
|
|
||||||
}
|
}
|
||||||
match dialog
|
None => return Ok(false),
|
||||||
.blocking_save_file()
|
},
|
||||||
.and_then(|path| path.into_path().ok())
|
_ => match ask_folder(app, start.as_deref()) {
|
||||||
{
|
Some(path) => folder = Some(path),
|
||||||
Some(path) => {
|
None => return Ok(false),
|
||||||
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| {
|
state.core(move |core| {
|
||||||
let result = core
|
let result = core
|
||||||
@@ -157,7 +153,7 @@ pub fn save_file_as<R: Runtime>(
|
|||||||
let (name, current, folder) = state.core(move |core| {
|
let (name, current, folder) = state.core(move |core| {
|
||||||
let name = core
|
let name = core
|
||||||
.engine
|
.engine
|
||||||
.file_structure(&gpx_engine::FileId(file_id))
|
.file_structure(&FileId(file_id))
|
||||||
.map(|structure| structure.name.clone());
|
.map(|structure| structure.name.clone());
|
||||||
let status = core.workspace_state();
|
let status = core.workspace_state();
|
||||||
let file = status.files.get(&file_id.to_string());
|
let file = status.files.get(&file_id.to_string());
|
||||||
@@ -170,27 +166,12 @@ pub fn save_file_as<R: Runtime>(
|
|||||||
let Some(name) = name else {
|
let Some(name) = name else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
let mut dialog = app.dialog().file().add_filter("GPX", &["gpx"]);
|
// where the file is, or else where it would be written
|
||||||
match ¤t {
|
let folder = current
|
||||||
Some(path) => {
|
.as_ref()
|
||||||
if let Some(file_name) = path.file_name() {
|
.and_then(|path| path.parent().map(Path::to_path_buf))
|
||||||
dialog = dialog.set_file_name(file_name.to_string_lossy());
|
.or(folder);
|
||||||
}
|
let Some(path) = ask_save_path(&app, &format!("{name}.gpx"), folder.as_deref()) else {
|
||||||
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);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
state.core(move |core| {
|
state.core(move |core| {
|
||||||
@@ -204,9 +185,7 @@ pub fn save_file_as<R: Runtime>(
|
|||||||
pub fn reload_files(state: State<AppState>, file_ids: Vec<Uuid>) -> Result<api::Outcome, String> {
|
pub fn reload_files(state: State<AppState>, file_ids: Vec<Uuid>) -> Result<api::Outcome, String> {
|
||||||
state.core(move |core| {
|
state.core(move |core| {
|
||||||
let outcome = core.workspace.reload(&mut core.engine, &file_ids);
|
let outcome = core.workspace.reload(&mut core.engine, &file_ids);
|
||||||
core.keep_workspace();
|
core.commit(None);
|
||||||
core.persist();
|
|
||||||
core.announce(None);
|
|
||||||
outcome
|
outcome
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -255,10 +234,7 @@ pub fn open_folder<R: Runtime>(
|
|||||||
let outcome = core
|
let outcome = core
|
||||||
.workspace
|
.workspace
|
||||||
.open_folder(&mut core.engine, &folder, discard);
|
.open_folder(&mut core.engine, &folder, discard);
|
||||||
core.keep_workspace();
|
core.commit(None);
|
||||||
core.persist();
|
|
||||||
core.watch();
|
|
||||||
core.announce(None);
|
|
||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -279,10 +255,7 @@ pub fn close_folder<R: Runtime>(
|
|||||||
let outcome = core
|
let outcome = core
|
||||||
.workspace
|
.workspace
|
||||||
.close_folder(&mut core.engine, unsaved.is_some());
|
.close_folder(&mut core.engine, unsaved.is_some());
|
||||||
core.keep_workspace();
|
core.commit(None);
|
||||||
core.persist();
|
|
||||||
core.watch();
|
|
||||||
core.announce(None);
|
|
||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -303,9 +276,7 @@ pub fn close_files<R: Runtime>(
|
|||||||
let outcome = core
|
let outcome = core
|
||||||
.workspace
|
.workspace
|
||||||
.close_files(&mut core.engine, &file_ids, unsaved.is_some());
|
.close_files(&mut core.engine, &file_ids, unsaved.is_some());
|
||||||
core.keep_workspace();
|
core.commit(None);
|
||||||
core.persist();
|
|
||||||
core.announce(None);
|
|
||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -364,7 +335,7 @@ pub fn export_files<R: Runtime>(
|
|||||||
file_ids
|
file_ids
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|id| {
|
.filter_map(|id| {
|
||||||
let name = core.workspace.display_name(&core.engine, *id)?;
|
let name = core.engine.file_structure(&FileId(*id))?.name.clone();
|
||||||
Some((name, api::export_file(&core.engine, *id, options)?))
|
Some((name, api::export_file(&core.engine, *id, options)?))
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -376,25 +347,13 @@ pub fn export_files<R: Runtime>(
|
|||||||
match files.as_slice() {
|
match files.as_slice() {
|
||||||
[] => Ok(false),
|
[] => Ok(false),
|
||||||
[(name, bytes)] => {
|
[(name, bytes)] => {
|
||||||
let Some(path) = app
|
let Some(path) = ask_save_path(&app, &format!("{name}.gpx"), None) else {
|
||||||
.dialog()
|
|
||||||
.file()
|
|
||||||
.add_filter("GPX", &["gpx"])
|
|
||||||
.set_file_name(format!("{name}.gpx"))
|
|
||||||
.blocking_save_file()
|
|
||||||
.and_then(|path| path.into_path().ok())
|
|
||||||
else {
|
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
write(path, bytes).map(|()| true)
|
write(path, bytes).map(|()| true)
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let Some(folder) = app
|
let Some(folder) = ask_folder(&app, None) else {
|
||||||
.dialog()
|
|
||||||
.file()
|
|
||||||
.blocking_pick_folder()
|
|
||||||
.and_then(|path| path.into_path().ok())
|
|
||||||
else {
|
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
let mut written: Vec<PathBuf> = vec![];
|
let mut written: Vec<PathBuf> = vec![];
|
||||||
@@ -433,14 +392,13 @@ pub fn rename_path(
|
|||||||
let selected = api::select(&mut core.engine, &[id], gpx_engine::SelectMode::Replace);
|
let selected = api::select(&mut core.engine, &[id], gpx_engine::SelectMode::Replace);
|
||||||
let desc = core
|
let desc = core
|
||||||
.engine
|
.engine
|
||||||
.file_structure(&gpx_engine::FileId(id))
|
.file_structure(&FileId(id))
|
||||||
.and_then(|structure| structure.desc.clone())
|
.and_then(|structure| structure.desc.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
return Ok(selected.merge(edit::rename_selection(core, name, desc)));
|
return Ok(selected.merge(edit::rename_selection(core, name, desc)));
|
||||||
}
|
}
|
||||||
core.workspace.rename_path(&path, &name)?;
|
core.workspace.rename_path(&path, &name)?;
|
||||||
core.keep_workspace();
|
core.commit(None);
|
||||||
core.announce(None);
|
|
||||||
Ok(api::Outcome::failed_none())
|
Ok(api::Outcome::failed_none())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -454,8 +412,7 @@ pub fn move_paths(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
state.core(move |core| {
|
state.core(move |core| {
|
||||||
let result = core.workspace.move_paths(&paths, &folder);
|
let result = core.workspace.move_paths(&paths, &folder);
|
||||||
core.keep_workspace();
|
core.commit(None);
|
||||||
core.announce(None);
|
|
||||||
result
|
result
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -468,9 +425,7 @@ pub fn delete_paths(state: State<AppState>, paths: Vec<PathBuf>) -> Result<api::
|
|||||||
let outcome = core
|
let outcome = core
|
||||||
.workspace
|
.workspace
|
||||||
.delete_paths(&mut core.engine, &paths, to_trash)?;
|
.delete_paths(&mut core.engine, &paths, to_trash)?;
|
||||||
core.keep_workspace();
|
core.commit(None);
|
||||||
core.persist();
|
|
||||||
core.announce(None);
|
|
||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ pub fn rename_selection(core: &mut Core, name: String, desc: String) -> api::Out
|
|||||||
let mut name = name;
|
let mut name = name;
|
||||||
if let Selection::File { file_ids } = core.engine.selection() {
|
if let Selection::File { file_ids } = core.engine.selection() {
|
||||||
for FileId(id) in file_ids.clone() {
|
for FileId(id) in file_ids.clone() {
|
||||||
name = core.workspace.valid_name(id, &name);
|
match core.workspace.name_for(id, &name) {
|
||||||
if let Err(error) = core.workspace.renamed_path(id, &name) {
|
Ok(valid) => name = valid,
|
||||||
return api::Outcome::failed(error);
|
Err(error) => return api::Outcome::failed(error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-49
@@ -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>;
|
||||||
@@ -89,13 +89,9 @@ 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);
|
||||||
let ids = self.workspace.ids_at(paths);
|
|
||||||
self.workspace.keep(&ids);
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs `f` on the engine, which can change: what changed is kept, and the files on the disk are
|
/// Runs `f` on the engine, which can change: what changed is kept, and the files on the disk are
|
||||||
@@ -104,22 +100,19 @@ impl Core {
|
|||||||
fn editing<T>(&mut self, f: impl FnOnce(&mut Engine) -> T) -> (T, Vec<String>) {
|
fn editing<T>(&mut self, f: impl FnOnce(&mut Engine) -> T) -> (T, Vec<String>) {
|
||||||
let names = self.workspace.names(&self.engine);
|
let names = self.workspace.names(&self.engine);
|
||||||
let preview = self.workspace.preview;
|
let preview = self.workspace.preview;
|
||||||
let preview_before = preview.and_then(|id| crate::workspace::signature(&self.engine, id));
|
let preview_before = preview.and_then(|id| signature(&self.engine, id));
|
||||||
let result = f(&mut self.engine);
|
let result = f(&mut self.engine);
|
||||||
// the preview is open for good once something is done to it
|
// the preview is open for good once something is done to it
|
||||||
if let Some(id) = preview
|
let kept = preview.is_some_and(|id| signature(&self.engine, id) != preview_before);
|
||||||
&& crate::workspace::signature(&self.engine, id) != preview_before
|
if let Some(id) = preview.filter(|_| kept) {
|
||||||
{
|
|
||||||
self.workspace.keep(&[id]);
|
self.workspace.keep(&[id]);
|
||||||
self.keep_workspace();
|
|
||||||
self.announce(None);
|
|
||||||
}
|
}
|
||||||
let (renamed, errors) = self.workspace.follow_names(&mut self.engine, &names);
|
let (renamed, errors) = self.workspace.follow_names(&mut self.engine, &names);
|
||||||
if renamed {
|
if kept || renamed {
|
||||||
self.keep_workspace();
|
self.commit(None);
|
||||||
self.announce(None);
|
} else {
|
||||||
|
self.persist();
|
||||||
}
|
}
|
||||||
self.persist();
|
|
||||||
(result, errors)
|
(result, errors)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,17 +132,18 @@ impl Core {
|
|||||||
/// the files they were written to) is kept and told, with where the files are. Says whether
|
/// the files they were written to) is kept and told, with where the files are. Says whether
|
||||||
/// they were written.
|
/// they were written.
|
||||||
pub fn wrote(&mut self, result: Result<api::Outcome, String>) -> Result<bool, String> {
|
pub fn wrote(&mut self, result: Result<api::Outcome, String>) -> Result<bool, String> {
|
||||||
self.keep_workspace();
|
self.commit(result.as_ref().ok().filter(|outcome| outcome.changed));
|
||||||
match &result {
|
|
||||||
Ok(outcome) if outcome.changed => {
|
|
||||||
self.persist();
|
|
||||||
self.announce(Some(outcome));
|
|
||||||
}
|
|
||||||
_ => self.announce(None),
|
|
||||||
}
|
|
||||||
result.map(|_| true)
|
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).
|
/// Writes the workspace, and watches what it holds (a file may have been opened or closed).
|
||||||
pub fn keep_workspace(&mut self) {
|
pub fn keep_workspace(&mut self) {
|
||||||
self.workspace.write(self.storage.root());
|
self.workspace.write(self.storage.root());
|
||||||
@@ -224,9 +218,7 @@ impl Core {
|
|||||||
(self.emit)("folder-changed", to_json(&in_folder));
|
(self.emit)("folder-changed", to_json(&in_folder));
|
||||||
}
|
}
|
||||||
if let Some(outcome) = self.workspace.reconcile(&mut self.engine, Some(paths)) {
|
if let Some(outcome) = self.workspace.reconcile(&mut self.engine, Some(paths)) {
|
||||||
self.keep_workspace();
|
self.commit(Some(&outcome));
|
||||||
self.persist();
|
|
||||||
self.announce(Some(&outcome));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,32 +247,15 @@ impl Core {
|
|||||||
.then(|| self.workspace.folder.clone())
|
.then(|| self.workspace.folder.clone())
|
||||||
.flatten()
|
.flatten()
|
||||||
});
|
});
|
||||||
// what the files it comes from are called, by the name in them (the copies have the same one)
|
|
||||||
let sources: Vec<(String, String)> = selected
|
|
||||||
.iter()
|
|
||||||
.filter_map(|id| {
|
|
||||||
let structure = self.engine.file_structure(&gpx_engine::FileId(*id))?;
|
|
||||||
Some((
|
|
||||||
structure.name.clone(),
|
|
||||||
self.workspace.display_name(&self.engine, *id)?,
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let mut outcome = f(&mut self.engine);
|
let mut outcome = f(&mut self.engine);
|
||||||
if let Some(dir) = dir {
|
if let Some(dir) = dir {
|
||||||
self.workspace.inherit(outcome.added(), &dir);
|
self.workspace.inherit(outcome.added(), &dir);
|
||||||
// what is made in a folder is written there at once, so that the tree shows it. A copy
|
// what is made in a folder is written there at once, so that the tree shows it
|
||||||
// is named like the file it is a copy of
|
let made: Vec<uuid::Uuid> = outcome
|
||||||
let made: Vec<(uuid::Uuid, Option<String>)> = outcome
|
|
||||||
.added()
|
.added()
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|id| uuid::Uuid::parse_str(id).ok())
|
.filter_map(|id| uuid::Uuid::parse_str(id).ok())
|
||||||
.filter(|id| self.workspace.dirs.contains_key(id))
|
.filter(|id| self.workspace.dirs.contains_key(id))
|
||||||
.filter_map(|id| {
|
|
||||||
let name = &self.engine.file_structure(&gpx_engine::FileId(id))?.name;
|
|
||||||
let source = sources.iter().find(|(source, _)| source == name);
|
|
||||||
Some((id, source.map(|(_, display)| display.clone())))
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
match self.workspace.save_new(&mut self.engine, &made) {
|
match self.workspace.save_new(&mut self.engine, &made) {
|
||||||
Ok(renamed) => outcome = outcome.merge(renamed),
|
Ok(renamed) => outcome = outcome.merge(renamed),
|
||||||
@@ -290,8 +265,7 @@ impl Core {
|
|||||||
Err(_) => {}
|
Err(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.keep_workspace();
|
self.commit(None);
|
||||||
self.persist();
|
|
||||||
outcome
|
outcome
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-48
@@ -420,10 +420,23 @@ impl Workspace {
|
|||||||
outcome
|
outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opens files from the disk, the first of which is selected. With `preview`, a single file is
|
||||||
|
/// open as a preview (see `open_preview`); otherwise they are open for good, the preview too if
|
||||||
|
/// it is among them.
|
||||||
|
pub fn open(&mut self, engine: &mut Engine, paths: &[PathBuf], preview: bool) -> api::Outcome {
|
||||||
|
if let ([path], true) = (paths, preview) {
|
||||||
|
return self.open_preview(engine, path);
|
||||||
|
}
|
||||||
|
let outcome = self.open_files(engine, paths, true);
|
||||||
|
let ids: Vec<Uuid> = paths.iter().filter_map(|path| self.linked(path)).collect();
|
||||||
|
self.keep(&ids);
|
||||||
|
outcome
|
||||||
|
}
|
||||||
|
|
||||||
/// Opens a file as a preview, which is selected: it replaces the file that was the preview,
|
/// Opens a file as a preview, which is selected: it replaces the file that was the preview,
|
||||||
/// which is closed (it has no edits, it would not be the preview anymore). It is the preview
|
/// which is closed (it has no edits, it would not be the preview anymore). It is the preview
|
||||||
/// until it is edited or opened for good (`keep`). A file that is open already is selected.
|
/// until it is edited or opened for good (`keep`). A file that is open already is selected.
|
||||||
pub fn open_preview(&mut self, engine: &mut Engine, path: &Path) -> api::Outcome {
|
fn open_preview(&mut self, engine: &mut Engine, path: &Path) -> api::Outcome {
|
||||||
let path = canonical(path);
|
let path = canonical(path);
|
||||||
if self.id_of(&path).is_some() {
|
if self.id_of(&path).is_some() {
|
||||||
return self.open_files(engine, &[path], true);
|
return self.open_files(engine, &[path], true);
|
||||||
@@ -444,11 +457,6 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The open files that are linked to these paths.
|
|
||||||
pub fn ids_at(&self, paths: &[PathBuf]) -> Vec<Uuid> {
|
|
||||||
paths.iter().filter_map(|path| self.linked(path)).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The link of a file that is the one on the disk, which has the given bytes. `None` if the
|
/// The link of a file that is the one on the disk, which has the given bytes. `None` if the
|
||||||
/// engine does not have the file.
|
/// engine does not have the file.
|
||||||
fn new_link(&self, engine: &Engine, id: Uuid, path: PathBuf, bytes: &[u8]) -> Option<Link> {
|
fn new_link(&self, engine: &Engine, id: Uuid, path: PathBuf, bytes: &[u8]) -> Option<Link> {
|
||||||
@@ -674,10 +682,6 @@ impl Workspace {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_taken(&self, path: &Path) -> bool {
|
|
||||||
self.links.values().any(|link| link.path == path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Writes files to the disk, replacing what is there: they are the same as their file from
|
/// Writes files to the disk, replacing what is there: they are the same as their file from
|
||||||
/// then on. A file that is not on the disk goes in its folder, or at its path in `paths`, or in
|
/// then on. A file that is not on the disk goes in its folder, or at its path in `paths`, or in
|
||||||
/// `folder`, under its name (changed if that is the one of something else), which the file then
|
/// `folder`, under its name (changed if that is the one of something else), which the file then
|
||||||
@@ -694,7 +698,8 @@ impl Workspace {
|
|||||||
let Some(structure) = engine.file_structure(&FileId(*id)) else {
|
let Some(structure) = engine.file_structure(&FileId(*id)) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let in_folder = |dir: &Path| free_path(dir, &structure.name, |p| self.is_taken(p));
|
let in_folder =
|
||||||
|
|dir: &Path| free_path(dir, &structure.name, |p| self.id_of(p).is_some());
|
||||||
let path = match (self.links.get(id), paths.get(id), self.dirs.get(id), folder) {
|
let path = match (self.links.get(id), paths.get(id), self.dirs.get(id), folder) {
|
||||||
(Some(link), ..) => link.path.clone(),
|
(Some(link), ..) => link.path.clone(),
|
||||||
(None, Some(path), ..) => canonical(path),
|
(None, Some(path), ..) => canonical(path),
|
||||||
@@ -707,25 +712,17 @@ impl Workspace {
|
|||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes the new files among `files` in their folder, each under the name that is given (the
|
/// Writes the new files among `ids` in their folder, under their name. A name that is the one of
|
||||||
/// name in the file when there is none). A name that is the one of something else is changed
|
/// something else is changed (`name (2)`). Files that would be called the same, like the parts
|
||||||
/// (`name (2)`). Files that would be called the same, like the parts of a split, are numbered
|
/// of a split, are numbered from 1 (`name (1)`, `name (2)`). The files are called like the file
|
||||||
/// from 1 (`name (1)`, `name (2)`). The files are called like the file they are written to.
|
/// they are written to.
|
||||||
pub fn save_new(
|
pub fn save_new(&mut self, engine: &mut Engine, ids: &[Uuid]) -> Result<api::Outcome, String> {
|
||||||
&mut self,
|
let names: Vec<Option<(Uuid, PathBuf, String)>> = ids
|
||||||
engine: &mut Engine,
|
|
||||||
files: &[(Uuid, Option<String>)],
|
|
||||||
) -> Result<api::Outcome, String> {
|
|
||||||
let names: Vec<Option<(Uuid, PathBuf, String)>> = files
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(id, name)| {
|
.map(|id| {
|
||||||
let dir = self.dirs.get(id)?.clone();
|
let dir = self.dirs.get(id)?.clone();
|
||||||
let structure = engine.file_structure(&FileId(*id))?;
|
let name = engine.file_structure(&FileId(*id))?.name.clone();
|
||||||
Some((
|
Some((*id, dir, name))
|
||||||
*id,
|
|
||||||
dir,
|
|
||||||
name.clone().unwrap_or_else(|| structure.name.clone()),
|
|
||||||
))
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let mut outcome = api::Outcome::failed_none();
|
let mut outcome = api::Outcome::failed_none();
|
||||||
@@ -741,10 +738,11 @@ impl Workspace {
|
|||||||
})
|
})
|
||||||
.count()
|
.count()
|
||||||
> 1;
|
> 1;
|
||||||
|
let taken = |p: &Path| self.id_of(p).is_some();
|
||||||
let path = if together {
|
let path = if together {
|
||||||
numbered_path(dir, name, |p| self.is_taken(p))
|
numbered_path(dir, name, taken)
|
||||||
} else {
|
} else {
|
||||||
free_path(dir, name, |p| self.is_taken(p))
|
free_path(dir, name, taken)
|
||||||
};
|
};
|
||||||
outcome = outcome.merge(self.write_file(engine, *id, &path)?);
|
outcome = outcome.merge(self.write_file(engine, *id, &path)?);
|
||||||
}
|
}
|
||||||
@@ -763,20 +761,21 @@ impl Workspace {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The name that a file on the disk can have: the name of a file, without its extension (see
|
/// The name that a file can be given. A file on the disk is called like its file: the name is
|
||||||
/// `file_name`). Others are given to the files that are not on the disk yet when they are
|
/// one that a file can have (see `file_name`), and not the one of another file next to it
|
||||||
/// written.
|
/// (`NAME_TAKEN`). The other files get theirs when they are written.
|
||||||
pub fn valid_name(&self, id: Uuid, name: &str) -> String {
|
pub fn name_for(&self, id: Uuid, name: &str) -> Result<String, String> {
|
||||||
if self.links.contains_key(&id) {
|
if !self.links.contains_key(&id) {
|
||||||
file_name(name)
|
return Ok(name.to_owned());
|
||||||
} else {
|
|
||||||
name.to_owned()
|
|
||||||
}
|
}
|
||||||
|
let name = file_name(name);
|
||||||
|
self.renamed_path(id, &name)?;
|
||||||
|
Ok(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where a file on the disk goes when it is called `name`: next to where it is, with the same
|
/// Where a file on the disk goes when it is called `name`: next to where it is, with the same
|
||||||
/// extension. `None` if it is there already, an error if something else is.
|
/// extension. `None` if it is there already, an error if something else is.
|
||||||
pub fn renamed_path(&self, id: Uuid, name: &str) -> Result<Option<PathBuf>, String> {
|
fn renamed_path(&self, id: Uuid, name: &str) -> Result<Option<PathBuf>, String> {
|
||||||
let Some(link) = self.links.get(&id) else {
|
let Some(link) = self.links.get(&id) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
@@ -848,15 +847,6 @@ impl Workspace {
|
|||||||
(renamed, errors)
|
(renamed, errors)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The name of a file as the user knows it: the name of the file on the disk, without its
|
|
||||||
/// extension, or else the name in the file.
|
|
||||||
pub fn display_name(&self, engine: &Engine, id: Uuid) -> Option<String> {
|
|
||||||
match self.links.get(&id) {
|
|
||||||
Some(link) => Some(stem(&link.path)),
|
|
||||||
None => Some(engine.file_structure(&FileId(id))?.name.clone()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Writes a file to a path of the user's choice, which is the place of the file from then on.
|
/// Writes a file to a path of the user's choice, which is the place of the file from then on.
|
||||||
pub fn save_as(
|
pub fn save_as(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 { askOverwrite, askUnsaved } from '$lib/components/file-list/unsaved-prompt.svelte';
|
import { askOverwrite, askUnsaved } from '$lib/components/file-list/unsaved-prompt.svelte';
|
||||||
|
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. */
|
||||||
@@ -139,14 +140,26 @@ export async function saveAs(selectedFileIds: string[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Opens a file of the tree of the open folder. */
|
/**
|
||||||
export async function openPath(path: string) {
|
* Opens files of the file system, like the ones of the tree of the open folder: as a preview
|
||||||
const ids = await engine.openPaths([path]);
|
* (`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) {
|
||||||
boundsManager.fitBoundsOnLoad(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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function triggerFileInput() {
|
export function triggerFileInput() {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'file';
|
input.type = 'file';
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
|
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
|
||||||
import ExplorerTree from './ExplorerTree.svelte';
|
import ExplorerTree from './ExplorerTree.svelte';
|
||||||
import ExplorerRow from './ExplorerRow.svelte';
|
import ExplorerRow from './ExplorerRow.svelte';
|
||||||
import { openFiles } from './actions';
|
import { clickedInExplorer, explorerSelection, renaming, treeRows } from './state.svelte';
|
||||||
import { clickedInExplorer, explorerSelection, renaming } from './state.svelte';
|
import { openPaths } from '$lib/actions/file-actions';
|
||||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { engine } from '$lib/engine';
|
import { engine } from '$lib/engine';
|
||||||
import { selectedFileIds } from '$lib/engine/selection-helpers';
|
import { selectedFileIds } from '$lib/engine/selection-helpers';
|
||||||
@@ -52,22 +52,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Enter opens the selected files for good. */
|
|
||||||
function onkeydown(event: KeyboardEvent) {
|
|
||||||
if (renaming.path !== undefined || !container || event.key !== 'Enter') return;
|
|
||||||
const kinds = new Map(
|
|
||||||
[...container.querySelectorAll<HTMLElement>('[data-explorer-path]')].map((row) => [
|
|
||||||
row.dataset.explorerPath!,
|
|
||||||
row.dataset.kind,
|
|
||||||
])
|
|
||||||
);
|
|
||||||
const files = explorerSelection.paths.filter((path) => kinds.get(path) === 'file');
|
|
||||||
if (files.length > 0) {
|
|
||||||
openFiles(files, false);
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the folders that hold a file and scrolls to the file, if it is not in view already. Its
|
* 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.
|
* row may not be there yet, because the content of a folder is read when it is opened.
|
||||||
@@ -95,6 +79,18 @@
|
|||||||
frame = requestAnimationFrame(scroll);
|
frame = requestAnimationFrame(scroll);
|
||||||
return () => cancelAnimationFrame(frame);
|
return () => cancelAnimationFrame(frame);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Enter opens the selected files for good. */
|
||||||
|
function onkeydown(event: KeyboardEvent) {
|
||||||
|
if (renaming.path !== undefined || event.key !== 'Enter') return;
|
||||||
|
const files = treeRows(container)
|
||||||
|
.filter((row) => row.kind === 'file' && explorerSelection.paths.includes(row.path))
|
||||||
|
.map((row) => row.path);
|
||||||
|
if (files.length > 0) {
|
||||||
|
openPaths(files);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -12,19 +12,17 @@
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { engine } from '$lib/engine';
|
import { engine } from '$lib/engine';
|
||||||
import { fileIdAt } from '$lib/engine/workspace';
|
import { editableName, fileIdAt, isWithin, parentOf } from '$lib/engine/workspace';
|
||||||
import { closeFiles, closeFolder } from '$lib/actions/file-actions';
|
import { closeFiles, closeFolder, openPaths, showEngineError } from '$lib/actions/file-actions';
|
||||||
import { isMac } from '$lib/utils';
|
import { isMac } from '$lib/utils';
|
||||||
import { openFiles, showError } from './actions';
|
import NameInput from './NameInput.svelte';
|
||||||
import {
|
import {
|
||||||
clickedInExplorer,
|
clickedInExplorer,
|
||||||
creating,
|
creating,
|
||||||
dragging,
|
dragging,
|
||||||
editableName,
|
|
||||||
explorerSelection,
|
explorerSelection,
|
||||||
isWithin,
|
|
||||||
parentOf,
|
|
||||||
renaming,
|
renaming,
|
||||||
|
treeRows,
|
||||||
} from './state.svelte';
|
} from './state.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -58,17 +56,6 @@
|
|||||||
let editing = $derived(renaming.path === path);
|
let editing = $derived(renaming.path === path);
|
||||||
let target = $derived(dragging.target === path);
|
let target = $derived(dragging.target === path);
|
||||||
|
|
||||||
/** The rows of the tree, as they are shown. */
|
|
||||||
function rows(): HTMLElement[] {
|
|
||||||
const tree = row?.closest('[data-explorer]');
|
|
||||||
return tree ? [...tree.querySelectorAll<HTMLElement>('[data-explorer-path]')] : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether a path of the tree is a GPX file, a folder, or the open folder. */
|
|
||||||
function kindOf(other: string): string | undefined {
|
|
||||||
return rows().find((r) => r.dataset.explorerPath === other)?.dataset.kind;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What the menu and dragging act on: the selection, if the row is in it. */
|
/** What the menu and dragging act on: the selection, if the row is in it. */
|
||||||
function targets(): string[] {
|
function targets(): string[] {
|
||||||
return selected ? explorerSelection.paths : [path];
|
return selected ? explorerSelection.paths : [path];
|
||||||
@@ -94,9 +81,9 @@
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
} else if (event.shiftKey && explorerSelection.anchor !== undefined) {
|
} else if (event.shiftKey && explorerSelection.anchor !== undefined) {
|
||||||
// everything shown between the row where the selection began and this one
|
// everything shown between the row where the selection began and this one
|
||||||
const paths = rows()
|
const paths = treeRows(row)
|
||||||
.filter((r) => r.dataset.kind !== 'root')
|
.filter((other) => other.kind !== 'root')
|
||||||
.map((r) => r.dataset.explorerPath!);
|
.map((other) => other.path);
|
||||||
const [from, to] = [paths.indexOf(explorerSelection.anchor), paths.indexOf(path)];
|
const [from, to] = [paths.indexOf(explorerSelection.anchor), paths.indexOf(path)];
|
||||||
if (from >= 0 && to >= 0) {
|
if (from >= 0 && to >= 0) {
|
||||||
explorerSelection.paths = paths.slice(Math.min(from, to), Math.max(from, to) + 1);
|
explorerSelection.paths = paths.slice(Math.min(from, to), Math.max(from, to) + 1);
|
||||||
@@ -111,7 +98,7 @@
|
|||||||
if (fileId !== undefined) {
|
if (fileId !== undefined) {
|
||||||
engine.select([fileId]);
|
engine.select([fileId]);
|
||||||
} else {
|
} else {
|
||||||
openFiles([path], true);
|
openPaths([path], true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,7 +107,7 @@
|
|||||||
function ondblclick() {
|
function ondblclick() {
|
||||||
if (kind === 'file' && !editing) {
|
if (kind === 'file' && !editing) {
|
||||||
clickedInExplorer.path = path;
|
clickedInExplorer.path = path;
|
||||||
openFiles([path], false);
|
openPaths([path], false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,9 +125,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openSelected() {
|
function openSelected() {
|
||||||
const files = targets().filter((other) => kindOf(other) === 'file');
|
const files = treeRows(row)
|
||||||
|
.filter((other) => other.kind === 'file' && targets().includes(other.path))
|
||||||
|
.map((other) => other.path);
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
openFiles(files, false);
|
openPaths(files);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +144,7 @@
|
|||||||
const paths = targets();
|
const paths = targets();
|
||||||
explorerSelection.paths = [];
|
explorerSelection.paths = [];
|
||||||
if (!(await engine.deletePaths(paths))) {
|
if (!(await engine.deletePaths(paths))) {
|
||||||
showError();
|
showEngineError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,26 +156,10 @@
|
|||||||
const name_ = value.trim();
|
const name_ = value.trim();
|
||||||
if (name_ === '' || name_ === editableName(name, kind !== 'file')) return;
|
if (name_ === '' || name_ === editableName(name, kind !== 'file')) return;
|
||||||
if (!(await engine.renamePath(path, name_))) {
|
if (!(await engine.renamePath(path, name_))) {
|
||||||
showError();
|
showEngineError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onkeydown(event: KeyboardEvent) {
|
|
||||||
// not for the folder row it is in, which would open or close
|
|
||||||
event.stopPropagation();
|
|
||||||
const input = event.currentTarget as HTMLInputElement;
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
commit(input.value);
|
|
||||||
} else if (event.key === 'Escape') {
|
|
||||||
renaming.path = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function focus(input: HTMLInputElement) {
|
|
||||||
input.focus();
|
|
||||||
input.select();
|
|
||||||
}
|
|
||||||
|
|
||||||
// dragging into folders
|
// dragging into folders
|
||||||
|
|
||||||
function ondragstart(event: DragEvent) {
|
function ondragstart(event: DragEvent) {
|
||||||
@@ -235,7 +208,7 @@
|
|||||||
const paths = dragging.paths;
|
const paths = dragging.paths;
|
||||||
ondragend();
|
ondragend();
|
||||||
if (paths.length > 0 && !(await engine.movePaths(paths, dropFolder))) {
|
if (paths.length > 0 && !(await engine.movePaths(paths, dropFolder))) {
|
||||||
showError();
|
showEngineError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -273,14 +246,10 @@
|
|||||||
>
|
>
|
||||||
{@render icon?.()}
|
{@render icon?.()}
|
||||||
{#if editing}
|
{#if editing}
|
||||||
<input
|
<NameInput
|
||||||
class="h-6 grow min-w-0 px-1 text-sm font-normal not-italic bg-background border rounded outline-none"
|
|
||||||
value={editableName(name, kind !== 'file')}
|
value={editableName(name, kind !== 'file')}
|
||||||
{@attach focus}
|
oncommit={commit}
|
||||||
{onkeydown}
|
oncancel={() => (renaming.path = undefined)}
|
||||||
onkeyup={(event) => event.stopPropagation()}
|
|
||||||
onclick={(event) => event.stopPropagation()}
|
|
||||||
onblur={(event) => commit(event.currentTarget.value)}
|
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="grow truncate text-left">{name}</span>
|
<span class="grow truncate text-left">{name}</span>
|
||||||
|
|||||||
@@ -8,11 +8,12 @@
|
|||||||
import ExplorerTree from './ExplorerTree.svelte';
|
import ExplorerTree from './ExplorerTree.svelte';
|
||||||
import ExplorerRow from './ExplorerRow.svelte';
|
import ExplorerRow from './ExplorerRow.svelte';
|
||||||
import { engine, type FolderEntry } from '$lib/engine';
|
import { engine, type FolderEntry } from '$lib/engine';
|
||||||
import { fileIdAt } from '$lib/engine/workspace';
|
import { editableName, fileIdAt } from '$lib/engine/workspace';
|
||||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||||
import { showError } from './actions';
|
import { showEngineError } from '$lib/actions/file-actions';
|
||||||
import { creating, editableName, explorerSelection } from './state.svelte';
|
import NameInput from './NameInput.svelte';
|
||||||
|
import { creating, explorerSelection } from './state.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
path,
|
path,
|
||||||
@@ -69,24 +70,13 @@
|
|||||||
created = await engine.createFolder(parent, name);
|
created = await engine.createFolder(parent, name);
|
||||||
}
|
}
|
||||||
if (created === undefined) {
|
if (created === undefined) {
|
||||||
showError();
|
showEngineError();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
explorerSelection.paths = [created];
|
explorerSelection.paths = [created];
|
||||||
explorerSelection.anchor = created;
|
explorerSelection.anchor = created;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onkeydown(event: KeyboardEvent) {
|
|
||||||
// not for the explorer, which opens the selected files with Enter
|
|
||||||
event.stopPropagation();
|
|
||||||
const input = event.currentTarget as HTMLInputElement;
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
create(input.value, true);
|
|
||||||
} else if (event.key === 'Escape') {
|
|
||||||
creating.parent = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// read again when something changed on the file system
|
// read again when something changed on the file system
|
||||||
$folderVersion;
|
$folderVersion;
|
||||||
@@ -110,14 +100,12 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<Folder size="16" class="shrink-0" />
|
<Folder size="16" class="shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
<input
|
<NameInput
|
||||||
class="h-6 grow min-w-0 px-1 text-sm bg-background border rounded outline-none"
|
|
||||||
placeholder={i18n._(
|
placeholder={i18n._(
|
||||||
kind === 'file' ? 'menu.explorer_menu.new_file' : 'menu.explorer_menu.new_folder'
|
kind === 'file' ? 'menu.explorer_menu.new_file' : 'menu.explorer_menu.new_folder'
|
||||||
)}
|
)}
|
||||||
{@attach (input) => input.focus()}
|
oncommit={create}
|
||||||
{onkeydown}
|
oncancel={() => (creating.parent = undefined)}
|
||||||
onblur={(event) => create(event.currentTarget.value, false)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<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) => {
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}}
|
||||||
|
{onkeydown}
|
||||||
|
onkeyup={(event) => event.stopPropagation()}
|
||||||
|
onclick={(event) => event.stopPropagation()}
|
||||||
|
onblur={(event) => oncommit(event.currentTarget.value, false)}
|
||||||
|
/>
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { get } from 'svelte/store';
|
|
||||||
import { toast } from 'svelte-sonner';
|
|
||||||
import { engine } from '$lib/engine';
|
|
||||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
|
||||||
import { boundsManager } from '$lib/state/bounds';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens files of the tree: as a preview (a single click), which the next preview replaces, or for
|
|
||||||
* good. The map fits the files that were opened.
|
|
||||||
*/
|
|
||||||
export async function openFiles(paths: string[], preview: boolean) {
|
|
||||||
const ids = await engine.openPaths(paths, preview);
|
|
||||||
if (ids.length > 0) {
|
|
||||||
boundsManager.fitBoundsOnLoad(ids);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tells why what the explorer did failed (a key of the dictionaries, or a message). */
|
|
||||||
export function showError() {
|
|
||||||
const error = get(engine.lastError);
|
|
||||||
if (error !== undefined && error !== 'nothing to do') {
|
|
||||||
toast.error(i18n._(error, error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -28,24 +28,11 @@ export const dragging: { paths: string[]; target: string | undefined } = $state(
|
|||||||
target: undefined,
|
target: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** `\` and `/` alike. */
|
/** The rows of the explorer that `element` is in, as they are shown: their path and kind. */
|
||||||
function isSeparator(char: string | undefined) {
|
export function treeRows(element: Element | null | undefined): { path: string; kind: string }[] {
|
||||||
return char === '/' || char === '\\';
|
const tree = element?.closest('[data-explorer]');
|
||||||
}
|
return [...(tree?.querySelectorAll<HTMLElement>('[data-explorer-path]') ?? [])].map((row) => ({
|
||||||
|
path: row.dataset.explorerPath!,
|
||||||
/** The folder that holds `path`. */
|
kind: row.dataset.kind!,
|
||||||
export function parentOf(path: string): string {
|
}));
|
||||||
const index = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
|
|
||||||
return index > 0 ? path.slice(0, index) : path;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether `path` is `folder` or something in it. */
|
|
||||||
export function isWithin(path: string, folder: string): boolean {
|
|
||||||
return path === folder || (path.startsWith(folder) && isSeparator(path[folder.length]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The name of a file without its extension, or the name of a folder. */
|
|
||||||
export function editableName(name: string, isDir: boolean): string {
|
|
||||||
const dot = name.lastIndexOf('.');
|
|
||||||
return isDir || dot <= 0 ? name : name.slice(0, dot);
|
|
||||||
}
|
}
|
||||||
@@ -9,8 +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 { toast } from 'svelte-sonner';
|
import { showEngineError } from '$lib/actions/file-actions';
|
||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
fileState,
|
fileState,
|
||||||
@@ -58,10 +57,8 @@
|
|||||||
// the metadata of the selected elements are changed. In the desktop app, a file on
|
// the metadata of the selected elements are changed. In the desktop app, a file on
|
||||||
// the disk is renamed with it, which is refused if the name is the one of another
|
// the disk is renamed with it, which is refused if the name is the one of another
|
||||||
engine.metadata(name, description).then((changed) => {
|
engine.metadata(name, description).then((changed) => {
|
||||||
const error = get(engine.lastError);
|
if (!changed) {
|
||||||
if (!changed && error !== undefined && error !== 'nothing to do') {
|
showEngineError();
|
||||||
// a key of the dictionaries, when it comes from the desktop app
|
|
||||||
toast.error(i18n._(error, error));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -567,10 +567,7 @@ class Engine {
|
|||||||
* `preview`, see `DesktopFiles.openPaths`. Resolves to the new ids.
|
* `preview`, see `DesktopFiles.openPaths`. Resolves to the new ids.
|
||||||
*/
|
*/
|
||||||
async openPaths(paths: string[], preview = false): Promise<string[]> {
|
async openPaths(paths: string[], preview = false): Promise<string[]> {
|
||||||
const added = await this.addedBy((b) => b.desktop!.openPaths(paths, preview));
|
return this.addedBy((b) => b.desktop!.openPaths(paths, preview));
|
||||||
// which file is the preview may have changed, without a file changing
|
|
||||||
await this.enqueue(async () => this.refreshWorkspace(this.backend!));
|
|
||||||
return added;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// What the explorer does to the tree of the open folder (the desktop app). When it fails,
|
// What the explorer does to the tree of the open folder (the desktop app). When it fails,
|
||||||
@@ -581,35 +578,29 @@ class Engine {
|
|||||||
* Resolves to its id.
|
* Resolves to its id.
|
||||||
*/
|
*/
|
||||||
async newFileIn(name: string, folder: string): Promise<string | undefined> {
|
async newFileIn(name: string, folder: string): Promise<string | undefined> {
|
||||||
const [id] = await this.addedBy((b) => b.desktop!.newFile(name, folder));
|
const [id] = await this.addedBy((b) => b.desktop!.newFile(name, folder), true);
|
||||||
this._folderVersion.update((version) => version + 1);
|
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Makes a folder in a folder of the open folder: resolves to its path. */
|
/** Makes a folder in a folder of the open folder: resolves to its path. */
|
||||||
async createFolder(parent: string, name: string): Promise<string | undefined> {
|
async createFolder(parent: string, name: string): Promise<string | undefined> {
|
||||||
return (await this.treeOperation((desktop) => desktop.createFolder(parent, name))).value;
|
return (await this.desktopCall((desktop) => desktop.createFolder(parent, name), true))
|
||||||
|
.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Renames a file or a folder of the open folder. Resolves to whether it was done. */
|
/** Renames a file or a folder of the open folder. Resolves to whether it was done. */
|
||||||
async renamePath(path: string, name: string): Promise<boolean> {
|
async renamePath(path: string, name: string): Promise<boolean> {
|
||||||
const changed = await this.run((b) => b.desktop!.renamePath(path, name));
|
return this.desktopRun((b) => b.desktop!.renamePath(path, name), true);
|
||||||
const failed = get(this._lastError) !== undefined;
|
|
||||||
await this.treeOperation(async () => {});
|
|
||||||
return changed || !failed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Moves files and folders of the open folder into a folder of it. */
|
/** Moves files and folders of the open folder into a folder of it. */
|
||||||
async movePaths(paths: string[], folder: string): Promise<boolean> {
|
async movePaths(paths: string[], folder: string): Promise<boolean> {
|
||||||
return (await this.treeOperation((desktop) => desktop.movePaths(paths, folder))).done;
|
return (await this.desktopCall((desktop) => desktop.movePaths(paths, folder), true)).done;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Moves files and folders of the open folder to the trash. */
|
/** Moves files and folders of the open folder to the trash. */
|
||||||
async deletePaths(paths: string[]): Promise<boolean> {
|
async deletePaths(paths: string[]): Promise<boolean> {
|
||||||
const changed = await this.run((b) => b.desktop!.deletePaths(paths));
|
return this.desktopRun((b) => b.desktop!.deletePaths(paths), true);
|
||||||
const failed = get(this._lastError) !== undefined;
|
|
||||||
await this.treeOperation(async () => {});
|
|
||||||
return changed || !failed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shows a file or a folder in the file manager of the system. */
|
/** Shows a file or a folder in the file manager of the system. */
|
||||||
@@ -622,43 +613,18 @@ class Engine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Does something to the file system, then reads where the files are and the tree again
|
|
||||||
* (without waiting for the watcher). Resolves to whether it was done, with what it gives.
|
|
||||||
*/
|
|
||||||
private async treeOperation<T>(
|
|
||||||
action: (desktop: DesktopFiles) => Promise<T>
|
|
||||||
): Promise<{ done: boolean; value?: T }> {
|
|
||||||
await this.ready;
|
|
||||||
return this.enqueue(async () => {
|
|
||||||
const backend = this.backend!;
|
|
||||||
let result: { done: boolean; value?: T } = { done: false };
|
|
||||||
try {
|
|
||||||
result = { done: true, value: await action(backend.desktop!) };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('engine:', error);
|
|
||||||
this._lastError.set(String(error));
|
|
||||||
}
|
|
||||||
await this.refreshWorkspace(backend);
|
|
||||||
this._folderVersion.update((version) => version + 1);
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the folder that the user picks (instead of the one that was open, which is closed with
|
* Opens the folder that the user picks (instead of the one that was open, which is closed with
|
||||||
* its files like `closeFiles` does, `unsaved` being what to do with the unsaved ones). The
|
* its files like `closeFiles` does, `unsaved` being what to do with the unsaved ones). The
|
||||||
* folder is watched, and its tree is read with `listFolder`: its files are not opened.
|
* folder is watched, and its tree is read with `listFolder`: its files are not opened.
|
||||||
*/
|
*/
|
||||||
async openFolder(unsaved?: UnsavedChoice) {
|
async openFolder(unsaved?: UnsavedChoice) {
|
||||||
await this.run((b) => b.desktop!.openFolder(unsaved));
|
await this.desktopRun((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`. */
|
/** 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!));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -666,8 +632,7 @@ class Engine {
|
|||||||
* first, or their edits are lost (they stay open when it is not said).
|
* first, or their edits are lost (they stay open when it is not said).
|
||||||
*/
|
*/
|
||||||
async closeFiles(fileIds: string[], unsaved?: UnsavedChoice) {
|
async closeFiles(fileIds: string[], unsaved?: UnsavedChoice) {
|
||||||
await this.run((b) => b.desktop!.closeFiles(fileIds, unsaved));
|
await this.desktopRun((b) => b.desktop!.closeFiles(fileIds, unsaved));
|
||||||
await this.enqueue(async () => this.refreshWorkspace(this.backend!));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -676,23 +641,23 @@ class Engine {
|
|||||||
* when the user cancelled or the files could not be saved.
|
* when the user cancelled or the files could not be saved.
|
||||||
*/
|
*/
|
||||||
async save(fileIds: string[]): Promise<boolean> {
|
async save(fileIds: string[]): Promise<boolean> {
|
||||||
return this.desktopCall((desktop) => desktop.saveFiles(fileIds));
|
return (await this.desktopCall((desktop) => desktop.saveFiles(fileIds))).value === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Writes a file where the user says, which is where it is from then on. */
|
/** Writes a file where the user says, which is where it is from then on. */
|
||||||
async saveAs(fileId: string): Promise<boolean> {
|
async saveAs(fileId: string): Promise<boolean> {
|
||||||
return this.desktopCall((desktop) => desktop.saveFileAs(fileId));
|
return (await this.desktopCall((desktop) => desktop.saveFileAs(fileId))).value === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Writes copies of files where the user says. The files stay as they are. */
|
/** Writes copies of files where the user says. The files stay as they are. */
|
||||||
async exportFiles(fileIds: string[], options: ExportOptions): Promise<boolean> {
|
async exportFiles(fileIds: string[], options: ExportOptions): Promise<boolean> {
|
||||||
return this.desktopCall((desktop) => desktop.exportFiles(fileIds, options));
|
const exported = await this.desktopCall((desktop) => desktop.exportFiles(fileIds, options));
|
||||||
|
return exported.value === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reads files again from the file system: their edits are lost (one undo step). */
|
/** Reads files again from the file system: their edits are lost (one undo step). */
|
||||||
async reload(fileIds: string[]) {
|
async reload(fileIds: string[]) {
|
||||||
await this.run((b) => b.desktop!.reloadFiles(fileIds));
|
await this.desktopRun((b) => b.desktop!.reloadFiles(fileIds));
|
||||||
await this.enqueue(async () => this.refreshWorkspace(this.backend!));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Opens a web page in the browser of the system (the desktop app). */
|
/** Opens a web page in the browser of the system (the desktop app). */
|
||||||
@@ -718,27 +683,51 @@ class Engine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Calls the file system of the desktop app, and keeps the status of the files up to date. */
|
/**
|
||||||
private async desktopCall(call: (desktop: DesktopFiles) => Promise<boolean>): Promise<boolean> {
|
* 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;
|
await this.ready;
|
||||||
return this.enqueue(async () => {
|
return this.enqueue(async () => {
|
||||||
const backend = this.backend!;
|
const backend = this.backend!;
|
||||||
let done = false;
|
let result: { done: boolean; value?: T } = { done: false };
|
||||||
try {
|
try {
|
||||||
done = await call(backend.desktop!);
|
result = { done: true, value: await call(backend.desktop!) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('The files could not be written', error);
|
console.error('engine:', error);
|
||||||
this._lastError.set(String(error));
|
this._lastError.set(String(error));
|
||||||
}
|
}
|
||||||
await this.refreshWorkspace(backend);
|
await this.refreshWorkspace(backend, tree);
|
||||||
return done;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Runs a call that adds files, and resolves to the ids that it added. */
|
/**
|
||||||
private async addedBy(action: (backend: Backend) => Promise<Outcome>): Promise<string[]> {
|
* 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));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -756,10 +745,14 @@ class Engine {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshWorkspace(backend: Backend) {
|
/** Reads where the files are again, and the tree of the open folder with `tree`. */
|
||||||
|
private async refreshWorkspace(backend: Backend, tree = false) {
|
||||||
if (backend.desktop) {
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
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 { fileIdAt, folderName, subfolder } from '$lib/engine/workspace';
|
import {
|
||||||
|
editableName,
|
||||||
|
fileIdAt,
|
||||||
|
folderName,
|
||||||
|
isWithin,
|
||||||
|
parentOf,
|
||||||
|
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', () => {
|
||||||
@@ -47,3 +54,31 @@ describe('fileIdAt', () => {
|
|||||||
assert.equal(fileIdAt(undefined, '/tracks/a.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('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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -34,3 +34,20 @@ export function fileIdAt(
|
|||||||
): string | undefined {
|
): string | undefined {
|
||||||
return Object.entries(workspace?.files ?? {}).find(([, file]) => file.path === path)?.[0];
|
return Object.entries(workspace?.files ?? {}).find(([, file]) => file.path === path)?.[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The folder that holds `path`. */
|
||||||
|
export function parentOf(path: string): string {
|
||||||
|
const index = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
|
||||||
|
return index > 0 ? path.slice(0, index) : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `path` is `folder` or something in it. */
|
||||||
|
export function isWithin(path: string, folder: string): boolean {
|
||||||
|
return path === folder || (path.startsWith(folder) && /[\\/]/.test(path[folder.length]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The name of a file without its extension, or the name of a folder (`isDir`). */
|
||||||
|
export function editableName(name: string, isDir: boolean): string {
|
||||||
|
const dot = name.lastIndexOf('.');
|
||||||
|
return isDir || dot <= 0 ? name : name.slice(0, dot);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user