mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-19 09:22:12 +00:00
explorer actions
This commit is contained in:
1 parent
adbf89e318
commit
3ef2bf135a
20 files changed
+1183
-65
No files matched your search
Generated
+25
@@ -1432,6 +1432,7 @@ dependencies = [
|
|||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
|
"trash",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4294,6 +4295,24 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "trash"
|
||||||
|
version = "5.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "be89b3fe156965d29ac4f8522f3a640c655affdd9f21cb4f36857f0c92c00317"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"objc2",
|
||||||
|
"objc2-foundation",
|
||||||
|
"once_cell",
|
||||||
|
"percent-encoding",
|
||||||
|
"scopeguard",
|
||||||
|
"urlencoding",
|
||||||
|
"windows",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tray-icon"
|
name = "tray-icon"
|
||||||
version = "0.25.1"
|
version = "0.25.1"
|
||||||
@@ -4370,6 +4389,12 @@ dependencies = [
|
|||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urlpattern"
|
name = "urlpattern"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
|
|||||||
@@ -26,4 +26,5 @@ notify-debouncer-mini = "0.7.0"
|
|||||||
tauri-plugin-dialog = "2.8.2"
|
tauri-plugin-dialog = "2.8.2"
|
||||||
tauri-plugin-single-instance = "2.5.3"
|
tauri-plugin-single-instance = "2.5.3"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
|
trash = "5"
|
||||||
|
|
||||||
+102
-3
@@ -14,6 +14,7 @@ use tauri::{AppHandle, Runtime, State};
|
|||||||
use tauri_plugin_dialog::{DialogExt, FilePath};
|
use tauri_plugin_dialog::{DialogExt, FilePath};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::commands::edit;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::workspace::{Entry, Unsaved, WorkspaceState, free_path};
|
use crate::workspace::{Entry, Unsaved, WorkspaceState, free_path};
|
||||||
|
|
||||||
@@ -40,16 +41,31 @@ 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();
|
||||||
};
|
};
|
||||||
open_paths(state, paths(picked))
|
open_paths(state, paths(picked), None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens files that are on the disk, like the ones of the tree of the open folder. They get selected.
|
/// Opens files that are on the disk, like the ones of the tree of the open folder. They get selected.
|
||||||
|
/// With `preview`, a single file is open as a preview (a single click in the tree, see
|
||||||
|
/// `Workspace::open_preview`); otherwise they are open for good, even the preview.
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
pub fn open_paths(state: State<AppState>, paths: Vec<PathBuf>) -> api::Outcome {
|
pub fn open_paths(
|
||||||
|
state: State<AppState>,
|
||||||
|
paths: Vec<PathBuf>,
|
||||||
|
preview: Option<bool>,
|
||||||
|
) -> api::Outcome {
|
||||||
state.core(move |core| {
|
state.core(move |core| {
|
||||||
let outcome = core.workspace.open_files(&mut core.engine, &paths, true);
|
let outcome = match paths.as_slice() {
|
||||||
|
[path] if preview == Some(true) => core.workspace.open_preview(&mut core.engine, path),
|
||||||
|
_ => {
|
||||||
|
let outcome = core.workspace.open_files(&mut core.engine, &paths, true);
|
||||||
|
let ids = core.workspace.ids_at(&paths);
|
||||||
|
core.workspace.keep(&ids);
|
||||||
|
outcome
|
||||||
|
}
|
||||||
|
};
|
||||||
core.keep_workspace();
|
core.keep_workspace();
|
||||||
core.persist();
|
core.persist();
|
||||||
|
core.announce(None);
|
||||||
outcome
|
outcome
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -391,3 +407,86 @@ pub fn export_files<R: Runtime>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Makes a folder in a folder of the open folder, called `name` (numbered if that is taken). Gives
|
||||||
|
/// its path.
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn create_folder(
|
||||||
|
state: State<AppState>,
|
||||||
|
parent: PathBuf,
|
||||||
|
name: String,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
|
state.core(move |core| core.workspace.create_folder(&parent, &name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renames a file or a folder of the open folder. An open file is renamed like in its info (it is
|
||||||
|
/// selected), which can be undone; the rest is renamed on the disk, and the open files that are in
|
||||||
|
/// a folder that is renamed follow.
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn rename_path(
|
||||||
|
state: State<AppState>,
|
||||||
|
path: PathBuf,
|
||||||
|
name: String,
|
||||||
|
) -> Result<api::Outcome, String> {
|
||||||
|
state.core(move |core| {
|
||||||
|
if let Some(id) = core.workspace.linked(&path) {
|
||||||
|
let selected = api::select(&mut core.engine, &[id], gpx_engine::SelectMode::Replace);
|
||||||
|
let desc = core
|
||||||
|
.engine
|
||||||
|
.file_structure(&gpx_engine::FileId(id))
|
||||||
|
.and_then(|structure| structure.desc.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
return Ok(selected.merge(edit::rename_selection(core, name, desc)));
|
||||||
|
}
|
||||||
|
core.workspace.rename_path(&path, &name)?;
|
||||||
|
core.keep_workspace();
|
||||||
|
core.announce(None);
|
||||||
|
Ok(api::Outcome::failed_none())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves files and folders of the open folder into a folder of it. The open files follow.
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn move_paths(
|
||||||
|
state: State<AppState>,
|
||||||
|
paths: Vec<PathBuf>,
|
||||||
|
folder: PathBuf,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state.core(move |core| {
|
||||||
|
let result = core.workspace.move_paths(&paths, &folder);
|
||||||
|
core.keep_workspace();
|
||||||
|
core.announce(None);
|
||||||
|
result
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves files and folders of the open folder to the trash. The open files that were among them
|
||||||
|
/// are closed, unless they have edits: they stay open, as gone from the disk.
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn delete_paths(state: State<AppState>, paths: Vec<PathBuf>) -> Result<api::Outcome, String> {
|
||||||
|
state.core(move |core| {
|
||||||
|
let outcome = core
|
||||||
|
.workspace
|
||||||
|
.delete_paths(&mut core.engine, &paths, to_trash)?;
|
||||||
|
core.keep_workspace();
|
||||||
|
core.persist();
|
||||||
|
core.announce(None);
|
||||||
|
Ok(outcome)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves files and folders to the trash of the system.
|
||||||
|
fn to_trash(paths: &[PathBuf]) -> Result<(), String> {
|
||||||
|
#[allow(unused_mut)]
|
||||||
|
let mut trash = trash::TrashContext::default();
|
||||||
|
// without asking the Finder, which needs a permission
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
use trash::macos::{DeleteMethod, TrashContextExtMacos};
|
||||||
|
trash.set_delete_method(DeleteMethod::NsFileManager);
|
||||||
|
}
|
||||||
|
trash.delete_all(paths).map_err(|error| {
|
||||||
|
eprintln!("the files cannot be deleted: {error}");
|
||||||
|
crate::workspace::DELETE_FAILED.to_owned()
|
||||||
|
})
|
||||||
|
}
|
||||||
+14
-11
@@ -5,24 +5,27 @@ use gpx_engine::{CleanType, FileId, MergeType, Selection, SplitType, api};
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::{AppState, Core};
|
||||||
|
|
||||||
/// Names and describes the selected files or tracks. A file on the disk is renamed with it (and back
|
/// Names and describes the selected files or tracks. A file on the disk is renamed with it (and back
|
||||||
/// when it is undone): its name is one that a file can have, and not the one of another file.
|
/// when it is undone): its name is one that a file can have, and not the one of another file.
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
pub fn metadata(state: State<AppState>, name: String, desc: String) -> api::Outcome {
|
pub fn metadata(state: State<AppState>, name: String, desc: String) -> api::Outcome {
|
||||||
state.core(move |core| {
|
state.core(move |core| rename_selection(core, name, desc))
|
||||||
let mut name = name;
|
}
|
||||||
if let Selection::File { file_ids } = core.engine.selection() {
|
|
||||||
for FileId(id) in file_ids.clone() {
|
/// See `metadata`.
|
||||||
name = core.workspace.valid_name(id, &name);
|
pub fn rename_selection(core: &mut Core, name: String, desc: String) -> api::Outcome {
|
||||||
if let Err(error) = core.workspace.renamed_path(id, &name) {
|
let mut name = name;
|
||||||
return api::Outcome::failed(error);
|
if let Selection::File { file_ids } = core.engine.selection() {
|
||||||
}
|
for FileId(id) in file_ids.clone() {
|
||||||
|
name = core.workspace.valid_name(id, &name);
|
||||||
|
if let Err(error) = core.workspace.renamed_path(id, &name) {
|
||||||
|
return api::Outcome::failed(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
core.edit(move |e| api::metadata(e, &name, &desc))
|
}
|
||||||
})
|
core.edit(move |e| api::metadata(e, &name, &desc))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
//! What the app asks of the system, outside of the files.
|
//! What the app asks of the system, outside of the files.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use tauri::{AppHandle, Runtime};
|
use tauri::{AppHandle, Runtime};
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
@@ -16,3 +18,11 @@ pub fn open_url<R: Runtime>(app: AppHandle<R>, url: String) -> Result<(), String
|
|||||||
.open_url(url, None::<&str>)
|
.open_url(url, None::<&str>)
|
||||||
.map_err(|error| error.to_string())
|
.map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shows a file or a folder in the file manager of the system (the Finder...).
|
||||||
|
#[tauri::command(async)]
|
||||||
|
pub fn reveal_path<R: Runtime>(app: AppHandle<R>, path: PathBuf) -> Result<(), String> {
|
||||||
|
app.opener()
|
||||||
|
.reveal_item_in_dir(path)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
@@ -194,8 +194,13 @@ fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Bu
|
|||||||
disk::reload_files,
|
disk::reload_files,
|
||||||
disk::export_files,
|
disk::export_files,
|
||||||
disk::list_folder,
|
disk::list_folder,
|
||||||
|
disk::create_folder,
|
||||||
|
disk::rename_path,
|
||||||
|
disk::move_paths,
|
||||||
|
disk::delete_paths,
|
||||||
// the system
|
// the system
|
||||||
system::open_url,
|
system::open_url,
|
||||||
|
system::reveal_path,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ impl Core {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let outcome = self.workspace.open_files(&mut self.engine, paths, true);
|
let outcome = self.workspace.open_files(&mut self.engine, paths, true);
|
||||||
|
let ids = self.workspace.ids_at(paths);
|
||||||
|
self.workspace.keep(&ids);
|
||||||
self.keep_workspace();
|
self.keep_workspace();
|
||||||
self.persist();
|
self.persist();
|
||||||
(self.emit)("files-opened", to_json(&outcome));
|
(self.emit)("files-opened", to_json(&outcome));
|
||||||
@@ -101,7 +103,17 @@ impl Core {
|
|||||||
/// could not be renamed.
|
/// could not be renamed.
|
||||||
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_before = preview.and_then(|id| crate::workspace::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
|
||||||
|
if let Some(id) = preview
|
||||||
|
&& crate::workspace::signature(&self.engine, id) != preview_before
|
||||||
|
{
|
||||||
|
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 renamed {
|
||||||
self.keep_workspace();
|
self.keep_workspace();
|
||||||
|
|||||||
@@ -670,3 +670,58 @@ fn a_new_file_written_under_another_name_takes_it() {
|
|||||||
assert_eq!(app.call("can_undo", json!({})), false);
|
assert_eq!(app.call("can_undo", json!({})), false);
|
||||||
std::fs::remove_dir_all(folder).unwrap();
|
std::fs::remove_dir_all(folder).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_open_as_a_preview_is_replaced_until_it_is_kept_or_edited() {
|
||||||
|
let app = App::new();
|
||||||
|
let folder = folder_with(&["a", "b", "c", "d"]);
|
||||||
|
let path = |name: &str| folder.join(format!("{name}.gpx"));
|
||||||
|
let open = |name: &str, preview: bool| {
|
||||||
|
app.call(
|
||||||
|
"open_paths",
|
||||||
|
json!({ "paths": [path(name)], "preview": preview }),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let state = |app: &App| app.call("workspace_state", json!({}))["files"].clone();
|
||||||
|
let names = |app: &App| -> Vec<String> {
|
||||||
|
let order = app.call("file_order", json!({}));
|
||||||
|
order
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|id| name_of(app, id.as_str().unwrap()))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let a = open("a", true)["added"][0].as_str().unwrap().to_string();
|
||||||
|
assert_eq!(state(&app)[&a]["preview"], true);
|
||||||
|
// the next preview replaces it
|
||||||
|
let b = open("b", true)["added"][0].as_str().unwrap().to_string();
|
||||||
|
assert_eq!(names(&app), ["b"]);
|
||||||
|
assert_eq!(state(&app)[&b]["preview"], true);
|
||||||
|
// opened for good (a double click), it stays when another one is previewed
|
||||||
|
open("b", false);
|
||||||
|
assert_eq!(state(&app)[&b]["preview"], false);
|
||||||
|
let c = open("c", true)["added"][0].as_str().unwrap().to_string();
|
||||||
|
assert_eq!(names(&app), ["b", "c"]);
|
||||||
|
// something done to the preview keeps it too
|
||||||
|
app.call("style", json!({ "color": "#ff0000" }));
|
||||||
|
assert_eq!(state(&app)[&c]["preview"], false);
|
||||||
|
open("d", true);
|
||||||
|
assert_eq!(names(&app), ["b", "c", "d"]);
|
||||||
|
// several files at once are open for good
|
||||||
|
app.call(
|
||||||
|
"open_paths",
|
||||||
|
json!({ "paths": [path("a"), path("d")], "preview": true }),
|
||||||
|
);
|
||||||
|
assert_eq!(names(&app), ["b", "c", "d", "a"]);
|
||||||
|
let files = state(&app);
|
||||||
|
assert!(
|
||||||
|
files
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.values()
|
||||||
|
.all(|file| file["preview"] == false)
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(folder).unwrap();
|
||||||
|
}
|
||||||
@@ -63,6 +63,8 @@ pub fn signature(engine: &Engine, id: Uuid) -> Option<u64> {
|
|||||||
pub const NAME_TAKEN: &str = "menu.metadata.name_taken";
|
pub const NAME_TAKEN: &str = "menu.metadata.name_taken";
|
||||||
/// A file cannot be renamed on the disk.
|
/// A file cannot be renamed on the disk.
|
||||||
pub const RENAME_FAILED: &str = "menu.metadata.rename_failed";
|
pub const RENAME_FAILED: &str = "menu.metadata.rename_failed";
|
||||||
|
/// Files cannot be moved to the trash.
|
||||||
|
pub const DELETE_FAILED: &str = "menu.explorer_menu.delete_failed";
|
||||||
|
|
||||||
/// What to do with the files that have unsaved edits when they are closed.
|
/// What to do with the files that have unsaved edits when they are closed.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||||
@@ -102,6 +104,10 @@ pub struct Workspace {
|
|||||||
/// The folder where the files that are not on the disk yet are written.
|
/// The folder where the files that are not on the disk yet are written.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dirs: BTreeMap<Uuid, PathBuf>,
|
pub dirs: BTreeMap<Uuid, PathBuf>,
|
||||||
|
/// The file that is open as a preview (a single click in the tree): the next one replaces it,
|
||||||
|
/// until it is edited or opened for good (see `open_preview`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub preview: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the frontend shows of a file.
|
/// What the frontend shows of a file.
|
||||||
@@ -121,6 +127,8 @@ pub struct FileStatus {
|
|||||||
pub replaced: bool,
|
pub replaced: bool,
|
||||||
/// The file on the disk is gone.
|
/// The file on the disk is gone.
|
||||||
pub missing: bool,
|
pub missing: bool,
|
||||||
|
/// The file is open as a preview, which the next one replaces.
|
||||||
|
pub preview: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -323,6 +331,7 @@ impl Workspace {
|
|||||||
dirty: self.is_dirty(engine, *id),
|
dirty: self.is_dirty(engine, *id),
|
||||||
replaced: link.is_some_and(|link| link.replaced),
|
replaced: link.is_some_and(|link| link.replaced),
|
||||||
missing: link.is_some_and(|link| link.missing),
|
missing: link.is_some_and(|link| link.missing),
|
||||||
|
preview: self.preview == Some(*id),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -358,6 +367,7 @@ impl Workspace {
|
|||||||
let open = |id: &Uuid| engine.file_structure(&FileId(*id)).is_some();
|
let open = |id: &Uuid| engine.file_structure(&FileId(*id)).is_some();
|
||||||
self.links.retain(|id, _| open(id));
|
self.links.retain(|id, _| open(id));
|
||||||
self.dirs.retain(|id, _| open(id));
|
self.dirs.retain(|id, _| open(id));
|
||||||
|
self.preview = self.preview.filter(open);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens files from the disk: the ones that are not open yet are added, and `select` selects
|
/// Opens files from the disk: the ones that are not open yet are added, and `select` selects
|
||||||
@@ -410,6 +420,35 @@ impl Workspace {
|
|||||||
outcome
|
outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// 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 {
|
||||||
|
let path = canonical(path);
|
||||||
|
if self.id_of(&path).is_some() {
|
||||||
|
return self.open_files(engine, &[path], true);
|
||||||
|
}
|
||||||
|
let closed = match self.preview.take() {
|
||||||
|
Some(previous) => self.close_files(engine, &[previous], false),
|
||||||
|
None => api::Outcome::failed_none(),
|
||||||
|
};
|
||||||
|
let opened = self.open_files(engine, std::slice::from_ref(&path), true);
|
||||||
|
self.preview = self.id_of(&path);
|
||||||
|
closed.merge(opened)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The files are open for good: none of them is the preview anymore.
|
||||||
|
pub fn keep(&mut self, ids: &[Uuid]) {
|
||||||
|
if self.preview.is_some_and(|preview| ids.contains(&preview)) {
|
||||||
|
self.preview = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
||||||
@@ -506,6 +545,7 @@ impl Workspace {
|
|||||||
self.links.remove(id);
|
self.links.remove(id);
|
||||||
self.dirs.remove(id);
|
self.dirs.remove(id);
|
||||||
}
|
}
|
||||||
|
self.keep(&closing);
|
||||||
api::sync_files(engine, &[], &closing)
|
api::sync_files(engine, &[], &closing)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -910,6 +950,153 @@ impl Workspace {
|
|||||||
});
|
});
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What the explorer does to the tree of the open folder. The paths are in the open folder, and
|
||||||
|
// the open files follow what happens to their file. None of it is undone.
|
||||||
|
|
||||||
|
/// A folder of the tree: the open folder or a folder in it.
|
||||||
|
fn tree_folder(&self, path: &Path) -> Result<PathBuf, String> {
|
||||||
|
let path = canonical(path);
|
||||||
|
if !self.in_folder(&path) || !path.is_dir() {
|
||||||
|
return Err(format!(
|
||||||
|
"{} is not a folder of the open folder",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Something in the tree, which can be renamed, moved or deleted: not the open folder itself.
|
||||||
|
fn tree_item(&self, path: &Path) -> Result<PathBuf, String> {
|
||||||
|
let path = canonical(path);
|
||||||
|
if !self.in_folder(&path) || self.folder.as_ref() == Some(&path) || !path.exists() {
|
||||||
|
return Err(format!("{} is not in the open folder", path.display()));
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `to` can be the new place of `from`: nothing else is there (the same file under
|
||||||
|
/// another case is not something else).
|
||||||
|
fn free_for(from: &Path, to: &Path) -> Result<(), String> {
|
||||||
|
if to.exists() && !same_file(to, from) {
|
||||||
|
return Err(NAME_TAKEN.to_owned());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The open file whose file is at `path`.
|
||||||
|
pub fn linked(&self, path: &Path) -> Option<Uuid> {
|
||||||
|
self.id_of(&canonical(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The places of the files follow what was at `from`, which is now at `to` (a file, or a
|
||||||
|
/// folder with what is in it).
|
||||||
|
fn moved(&mut self, from: &Path, to: &Path) {
|
||||||
|
let follow = |path: &mut PathBuf| {
|
||||||
|
if let Ok(rest) = path.strip_prefix(from) {
|
||||||
|
*path = if rest.as_os_str().is_empty() {
|
||||||
|
to.to_path_buf()
|
||||||
|
} else {
|
||||||
|
to.join(rest)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for link in self.links.values_mut() {
|
||||||
|
follow(&mut link.path);
|
||||||
|
}
|
||||||
|
for dir in self.dirs.values_mut() {
|
||||||
|
follow(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes a folder in a folder of the tree, called `name` (with a number if that is taken: `name
|
||||||
|
/// 2`). Gives its path.
|
||||||
|
pub fn create_folder(&self, parent: &Path, name: &str) -> Result<PathBuf, String> {
|
||||||
|
let parent = self.tree_folder(parent)?;
|
||||||
|
let name = file_name(name);
|
||||||
|
let path = (1..)
|
||||||
|
.map(|number| match number {
|
||||||
|
1 => parent.join(&name),
|
||||||
|
_ => parent.join(format!("{name} {number}")),
|
||||||
|
})
|
||||||
|
.find(|path| !path.exists())
|
||||||
|
.expect("there are enough numbers");
|
||||||
|
fs::create_dir(&path).map_err(|error| format!("{}: {error}", path.display()))?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renames a file or a folder of the tree that is not an open file (which is renamed in the
|
||||||
|
/// engine, see `follow_names`). A file keeps its extension. Gives the new path.
|
||||||
|
pub fn rename_path(&mut self, path: &Path, name: &str) -> Result<PathBuf, String> {
|
||||||
|
let path = self.tree_item(path)?;
|
||||||
|
let name = file_name(name);
|
||||||
|
let new_name = match path.extension().filter(|_| path.is_file()) {
|
||||||
|
Some(extension) => format!("{name}.{}", extension.to_string_lossy()),
|
||||||
|
None => name,
|
||||||
|
};
|
||||||
|
let to = path.with_file_name(new_name);
|
||||||
|
if to == path {
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
Self::free_for(&path, &to)?;
|
||||||
|
fs::rename(&path, &to).map_err(|error| {
|
||||||
|
eprintln!("{} cannot be renamed: {error}", path.display());
|
||||||
|
RENAME_FAILED.to_owned()
|
||||||
|
})?;
|
||||||
|
self.moved(&path, &to);
|
||||||
|
Ok(to)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves files and folders of the tree into a folder of it. What is there already stays where
|
||||||
|
/// it is; stops at the first that cannot be moved.
|
||||||
|
pub fn move_paths(&mut self, paths: &[PathBuf], folder: &Path) -> Result<(), String> {
|
||||||
|
let folder = self.tree_folder(folder)?;
|
||||||
|
for path in paths {
|
||||||
|
let path = self.tree_item(path)?;
|
||||||
|
if path.parent() == Some(folder.as_path()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if folder.starts_with(&path) {
|
||||||
|
return Err(format!("{} cannot go into itself", path.display()));
|
||||||
|
}
|
||||||
|
let to = folder.join(path.file_name().unwrap_or_default());
|
||||||
|
Self::free_for(&path, &to)?;
|
||||||
|
fs::rename(&path, &to).map_err(|error| {
|
||||||
|
eprintln!("{} cannot be moved: {error}", path.display());
|
||||||
|
RENAME_FAILED.to_owned()
|
||||||
|
})?;
|
||||||
|
self.moved(&path, &to);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes files and folders of the tree with `delete` (to the trash). The open files that
|
||||||
|
/// were among them are closed if they have no edits, the others stay open, as gone from the
|
||||||
|
/// disk. Says what changed in the engine.
|
||||||
|
pub fn delete_paths(
|
||||||
|
&mut self,
|
||||||
|
engine: &mut Engine,
|
||||||
|
paths: &[PathBuf],
|
||||||
|
delete: impl FnOnce(&[PathBuf]) -> Result<(), String>,
|
||||||
|
) -> Result<api::Outcome, String> {
|
||||||
|
let paths = paths
|
||||||
|
.iter()
|
||||||
|
.map(|path| self.tree_item(path))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
delete(&paths)?;
|
||||||
|
let gone: Vec<Uuid> = self
|
||||||
|
.links
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, link)| paths.iter().any(|path| link.path.starts_with(path)))
|
||||||
|
.map(|(id, _)| *id)
|
||||||
|
.collect();
|
||||||
|
let closed = self.close_files(engine, &gone, false);
|
||||||
|
let flagged = self.reconcile(engine, Some(&paths));
|
||||||
|
Ok(match flagged {
|
||||||
|
Some(outcome) => closed.merge(outcome),
|
||||||
|
None => closed,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1151,6 +1338,77 @@ mod tests {
|
|||||||
assert_eq!(fx.workspace.folder.as_deref(), Some(folder.as_path()));
|
assert_eq!(fx.workspace.folder.as_deref(), Some(folder.as_path()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn folders_are_made_renamed_and_moved_and_the_open_files_follow() {
|
||||||
|
let mut fx = Fixture::new();
|
||||||
|
let a = fx.write("a.gpx", "a");
|
||||||
|
fx.open_folder();
|
||||||
|
let id = fx.ids()[0];
|
||||||
|
let root = fx.folder.clone();
|
||||||
|
let path_of = |fx: &Fixture| PathBuf::from(fx.status(id).path.unwrap());
|
||||||
|
|
||||||
|
let first = fx.workspace.create_folder(&root, "New folder").unwrap();
|
||||||
|
let second = fx.workspace.create_folder(&root, "New folder").unwrap();
|
||||||
|
assert_eq!(first, root.join("New folder"));
|
||||||
|
assert_eq!(second, root.join("New folder 2"));
|
||||||
|
|
||||||
|
fx.workspace.move_paths(&[a], &first).unwrap();
|
||||||
|
assert_eq!(path_of(&fx), first.join("a.gpx"));
|
||||||
|
assert!(first.join("a.gpx").exists() && !fx.status(id).dirty);
|
||||||
|
|
||||||
|
let trips = fx.workspace.rename_path(&first, "Trips").unwrap();
|
||||||
|
assert_eq!(path_of(&fx), root.join("Trips/a.gpx"));
|
||||||
|
// a folder does not go into itself, nor onto something else
|
||||||
|
assert!(
|
||||||
|
fx.workspace
|
||||||
|
.move_paths(std::slice::from_ref(&trips), &trips)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fx.workspace.rename_path(&trips, "New folder 2"),
|
||||||
|
Err(NAME_TAKEN.to_owned())
|
||||||
|
);
|
||||||
|
fx.workspace.move_paths(&[trips], &second).unwrap();
|
||||||
|
assert_eq!(path_of(&fx), second.join("Trips/a.gpx"));
|
||||||
|
|
||||||
|
// a file keeps its extension
|
||||||
|
let closed = fx.write("c.gpx", "c");
|
||||||
|
assert_eq!(
|
||||||
|
fx.workspace.rename_path(&closed, "d").unwrap(),
|
||||||
|
root.join("d.gpx")
|
||||||
|
);
|
||||||
|
// the open folder and what is not in it are left alone
|
||||||
|
assert!(fx.workspace.rename_path(&root, "x").is_err());
|
||||||
|
let elsewhere = canonical(&std::env::temp_dir());
|
||||||
|
assert!(fx.workspace.rename_path(&elsewhere, "x").is_err());
|
||||||
|
assert!(fx.workspace.create_folder(&elsewhere, "x").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleted_files_are_closed_unless_they_have_edits() {
|
||||||
|
let mut fx = Fixture::new();
|
||||||
|
let a = fx.write("a.gpx", "a");
|
||||||
|
let b = fx.write("b.gpx", "b");
|
||||||
|
fx.open_folder();
|
||||||
|
let (id_a, id_b) = (fx.id_named("a"), fx.id_named("b"));
|
||||||
|
api::select(&mut fx.engine, &[id_b], SelectMode::Replace);
|
||||||
|
assert!(!api::style(&mut fx.engine, Some("#ff0000"), None, None).has_error());
|
||||||
|
|
||||||
|
let outcome = fx
|
||||||
|
.workspace
|
||||||
|
.delete_paths(&mut fx.engine, &[a.clone(), b.clone()], |paths| {
|
||||||
|
paths.iter().for_each(|path| fs::remove_file(path).unwrap());
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(outcome.changed);
|
||||||
|
assert_eq!(fx.ids(), [id_b]);
|
||||||
|
assert!(!fx.ids().contains(&id_a));
|
||||||
|
let status = fx.status(id_b);
|
||||||
|
assert!(status.dirty && status.missing);
|
||||||
|
assert!(!a.exists() && !b.exists());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_tree_of_the_folder_shows_folders_first_and_only_gpx_files() {
|
fn the_tree_of_the_folder_shows_folders_first_and_only_gpx_files() {
|
||||||
let mut fx = Fixture::new();
|
let mut fx = Fixture::new();
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
Maximize,
|
Maximize,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
|
FolderX,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
||||||
@@ -215,7 +216,7 @@
|
|||||||
<Shortcut key="W" ctrl={true} shift={true} />
|
<Shortcut key="W" ctrl={true} shift={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item onclick={closeFolder} disabled={!$workspace?.folder}>
|
<Menubar.Item onclick={closeFolder} disabled={!$workspace?.folder}>
|
||||||
<FileX size="16" />
|
<FolderX size="16" />
|
||||||
{i18n._('menu.close_folder')}
|
{i18n._('menu.close_folder')}
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
import { CollapsibleTreeState } from '$lib/components/collapsible-tree/utils.svelte';
|
import { CollapsibleTreeState } from '$lib/components/collapsible-tree/utils.svelte';
|
||||||
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 { clickedInExplorer } from './state.svelte';
|
import ExplorerRow from './ExplorerRow.svelte';
|
||||||
|
import { openFiles } from './actions';
|
||||||
|
import { clickedInExplorer, explorerSelection, renaming } from './state.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 { selectedFileIds } from '$lib/engine/selection-helpers';
|
import { selectedFileIds } from '$lib/engine/selection-helpers';
|
||||||
@@ -27,12 +29,13 @@
|
|||||||
|
|
||||||
let container: HTMLElement | undefined = $state();
|
let container: HTMLElement | undefined = $state();
|
||||||
|
|
||||||
// the file that is selected, if it is in the folder
|
// the files that are selected, if they are in the folder
|
||||||
let selectedPath = $derived.by(() => {
|
let selectedPaths = $derived(
|
||||||
const id = selectedFileIds($selection)[0];
|
selectedFileIds($selection)
|
||||||
const path = id === undefined ? undefined : $workspace?.files[id]?.path;
|
.map((id) => $workspace?.files[id]?.path)
|
||||||
return subfolder(folder, path) === undefined ? undefined : path;
|
.filter((path): path is string => subfolder(folder, path) !== undefined)
|
||||||
});
|
);
|
||||||
|
let selectedPath = $derived(selectedPaths[0]);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// not what the click that was just made depends on, only the selection and the folder
|
// not what the click that was just made depends on, only the selection and the folder
|
||||||
@@ -41,11 +44,30 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clickedInExplorer.path = undefined;
|
clickedInExplorer.path = undefined;
|
||||||
|
// what is selected elsewhere (the file list, the map) is selected here
|
||||||
|
explorerSelection.paths = untrack(() => selectedPaths);
|
||||||
|
explorerSelection.anchor = selectedPath;
|
||||||
if (folder && selectedPath !== undefined) {
|
if (folder && selectedPath !== undefined) {
|
||||||
return reveal(folder, selectedPath);
|
return reveal(folder, selectedPath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 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.
|
||||||
@@ -83,16 +105,28 @@
|
|||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
<div class="px-2 py-1 text-sm font-semibold shrink-0">{i18n._('menu.explorer')}</div>
|
<div class="px-2 py-1 text-sm font-semibold shrink-0">{i18n._('menu.explorer')}</div>
|
||||||
<ScrollArea class="grow min-h-0">
|
<ScrollArea class="grow min-h-0">
|
||||||
<div bind:this={container} class="flex flex-col pl-1 pr-3 pb-1">
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
|
<div
|
||||||
|
bind:this={container}
|
||||||
|
data-explorer
|
||||||
|
role="tree"
|
||||||
|
tabindex="0"
|
||||||
|
class="flex flex-col pl-1 pr-3 pb-1 outline-none"
|
||||||
|
{onkeydown}
|
||||||
|
>
|
||||||
<!-- starts over when another folder is opened -->
|
<!-- starts over when another folder is opened -->
|
||||||
{#key folder}
|
{#key folder}
|
||||||
<!-- the folder itself -->
|
<!-- the folder itself -->
|
||||||
<CollapsibleTree side="left" treeState={trees.root} sticky>
|
<CollapsibleTree side="left" treeState={trees.root} sticky>
|
||||||
<CollapsibleTreeNode id="folder">
|
<CollapsibleTreeNode id="folder">
|
||||||
{#snippet trigger()}
|
{#snippet trigger()}
|
||||||
<span class="grow truncate text-left" title={folder}>
|
<ExplorerRow
|
||||||
{folderName(folder)}
|
path={folder}
|
||||||
</span>
|
name={folderName(folder)}
|
||||||
|
kind="root"
|
||||||
|
onOpenFolder={() =>
|
||||||
|
trees && (trees.root.get('root.folder').current = true)}
|
||||||
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet content(opened)}
|
{#snippet content(opened)}
|
||||||
<!-- what is in it is only read when it is open -->
|
<!-- what is in it is only read when it is open -->
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as ContextMenu from '$lib/components/ui/context-menu';
|
||||||
|
import {
|
||||||
|
ExternalLink,
|
||||||
|
FileX,
|
||||||
|
FolderOpen,
|
||||||
|
FolderX,
|
||||||
|
PencilLine,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
} from '@lucide/svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
|
import { engine } from '$lib/engine';
|
||||||
|
import { fileIdAt } from '$lib/engine/workspace';
|
||||||
|
import { closeFiles, closeFolder } from '$lib/actions/file-actions';
|
||||||
|
import { isMac } from '$lib/utils';
|
||||||
|
import { openFiles, showError } from './actions';
|
||||||
|
import {
|
||||||
|
clickedInExplorer,
|
||||||
|
creating,
|
||||||
|
dragging,
|
||||||
|
editableName,
|
||||||
|
explorerSelection,
|
||||||
|
isWithin,
|
||||||
|
renaming,
|
||||||
|
} from './state.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
path,
|
||||||
|
name,
|
||||||
|
kind,
|
||||||
|
onOpenFolder,
|
||||||
|
icon,
|
||||||
|
class: className = '',
|
||||||
|
style = '',
|
||||||
|
}: {
|
||||||
|
path: string;
|
||||||
|
/** What the row shows, the name of the file with its extension. */
|
||||||
|
name: string;
|
||||||
|
/** The open folder itself, a folder in it, or a GPX file. */
|
||||||
|
kind: 'root' | 'folder' | 'file';
|
||||||
|
/** Opens the folder of the row, to show what was made in it. */
|
||||||
|
onOpenFolder?: () => void;
|
||||||
|
icon?: Snippet;
|
||||||
|
class?: string;
|
||||||
|
style?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const { workspace } = engine;
|
||||||
|
|
||||||
|
let row: HTMLElement | undefined = $state();
|
||||||
|
|
||||||
|
// the open file of the row
|
||||||
|
let fileId = $derived(kind === 'file' ? fileIdAt($workspace, path) : undefined);
|
||||||
|
let selected = $derived(explorerSelection.paths.includes(path));
|
||||||
|
let editing = $derived(renaming.path === path);
|
||||||
|
let target = $derived(dragging.target === path);
|
||||||
|
|
||||||
|
/** 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. */
|
||||||
|
function targets(): string[] {
|
||||||
|
return selected ? explorerSelection.paths : [path];
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(paths: string[]) {
|
||||||
|
explorerSelection.paths = paths;
|
||||||
|
explorerSelection.anchor = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onclick(event: MouseEvent) {
|
||||||
|
if (editing || kind === 'root') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.metaKey || event.ctrlKey) {
|
||||||
|
// added to the selection, or taken out of it, without opening anything
|
||||||
|
select(
|
||||||
|
selected
|
||||||
|
? explorerSelection.paths.filter((other) => other !== path)
|
||||||
|
: [...explorerSelection.paths, path]
|
||||||
|
);
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
} else if (event.shiftKey && explorerSelection.anchor !== undefined) {
|
||||||
|
// everything shown between the row where the selection began and this one
|
||||||
|
const paths = rows()
|
||||||
|
.filter((r) => r.dataset.kind !== 'root')
|
||||||
|
.map((r) => r.dataset.explorerPath!);
|
||||||
|
const [from, to] = [paths.indexOf(explorerSelection.anchor), paths.indexOf(path)];
|
||||||
|
if (from >= 0 && to >= 0) {
|
||||||
|
explorerSelection.paths = paths.slice(Math.min(from, to), Math.max(from, to) + 1);
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
} else if (event.detail <= 1) {
|
||||||
|
select([path]);
|
||||||
|
if (kind === 'file') {
|
||||||
|
// it is in view, the explorer does not have to scroll to it
|
||||||
|
clickedInExplorer.path = path;
|
||||||
|
if (fileId !== undefined) {
|
||||||
|
engine.select([fileId]);
|
||||||
|
} else {
|
||||||
|
openFiles([path], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ondblclick() {
|
||||||
|
if (kind === 'file' && !editing) {
|
||||||
|
clickedInExplorer.path = path;
|
||||||
|
openFiles([path], false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the menu
|
||||||
|
|
||||||
|
/** The folder where what is made from the menu goes: the folder of the row. */
|
||||||
|
let folder = $derived(kind === 'file' ? undefined : path);
|
||||||
|
|
||||||
|
/** Asks for the name of a file or a folder to make in the folder of the row (see `creating`). */
|
||||||
|
function create(what: 'file' | 'folder') {
|
||||||
|
if (folder === undefined) return;
|
||||||
|
onOpenFolder?.();
|
||||||
|
creating.kind = what;
|
||||||
|
creating.parent = folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSelected() {
|
||||||
|
const files = targets().filter((other) => kindOf(other) === 'file');
|
||||||
|
if (files.length > 0) {
|
||||||
|
openFiles(files, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The open files among what the menu acts on. */
|
||||||
|
function openIds(): string[] {
|
||||||
|
return targets()
|
||||||
|
.map((other) => fileIdAt($workspace, other))
|
||||||
|
.filter((id): id is string => id !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
const paths = targets();
|
||||||
|
explorerSelection.paths = [];
|
||||||
|
if (!(await engine.deletePaths(paths))) {
|
||||||
|
showError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renaming, in the row
|
||||||
|
|
||||||
|
async function commit(value: string) {
|
||||||
|
if (renaming.path !== path) return;
|
||||||
|
renaming.path = undefined;
|
||||||
|
const name_ = value.trim();
|
||||||
|
if (name_ === '' || name_ === editableName(name, kind !== 'file')) return;
|
||||||
|
if (!(await engine.renamePath(path, name_))) {
|
||||||
|
showError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
function ondragstart(event: DragEvent) {
|
||||||
|
if (kind === 'root' || editing) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const paths = targets();
|
||||||
|
dragging.paths = paths;
|
||||||
|
event.dataTransfer?.setData('text/plain', paths.join('\n'));
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ondragend() {
|
||||||
|
dragging.paths = [];
|
||||||
|
dragging.target = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ondragover(event: DragEvent) {
|
||||||
|
if (kind === 'file' || dragging.paths.length === 0) return;
|
||||||
|
// not into itself
|
||||||
|
if (dragging.paths.some((dragged) => isWithin(path, dragged))) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
}
|
||||||
|
dragging.target = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ondragleave(event: DragEvent) {
|
||||||
|
if (dragging.target === path && !row?.contains(event.relatedTarget as Node | null)) {
|
||||||
|
dragging.target = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ondrop(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const paths = dragging.paths;
|
||||||
|
ondragend();
|
||||||
|
if (paths.length > 0 && !(await engine.movePaths(paths, path))) {
|
||||||
|
showError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ContextMenu.Root
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open && !selected && kind !== 'root') {
|
||||||
|
select([path]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContextMenu.Trigger class="grow min-w-0 pointer-events-auto">
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div
|
||||||
|
bind:this={row}
|
||||||
|
role="treeitem"
|
||||||
|
aria-selected={selected}
|
||||||
|
tabindex="-1"
|
||||||
|
data-explorer-path={path}
|
||||||
|
data-path={path}
|
||||||
|
data-kind={kind}
|
||||||
|
title={path}
|
||||||
|
draggable={kind !== 'root' && !editing}
|
||||||
|
class="flex flex-row items-center gap-1.5 min-w-0 rounded-md {selected
|
||||||
|
? 'bg-accent'
|
||||||
|
: ''} {target ? 'ring-1 ring-primary bg-accent/60' : ''} {className}"
|
||||||
|
{style}
|
||||||
|
{onclick}
|
||||||
|
{ondblclick}
|
||||||
|
{ondragstart}
|
||||||
|
{ondragend}
|
||||||
|
{ondragover}
|
||||||
|
ondragenter={ondragover}
|
||||||
|
{ondragleave}
|
||||||
|
{ondrop}
|
||||||
|
>
|
||||||
|
{@render icon?.()}
|
||||||
|
{#if editing}
|
||||||
|
<input
|
||||||
|
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')}
|
||||||
|
{@attach focus}
|
||||||
|
{onkeydown}
|
||||||
|
onkeyup={(event) => event.stopPropagation()}
|
||||||
|
onclick={(event) => event.stopPropagation()}
|
||||||
|
onblur={(event) => commit(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span class="grow truncate text-left">{name}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</ContextMenu.Trigger>
|
||||||
|
<!-- the name being edited keeps the focus, which the menu would give back to the row -->
|
||||||
|
<ContextMenu.Content
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (renaming.path !== undefined || creating.parent !== undefined) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if kind !== 'file'}
|
||||||
|
<ContextMenu.Item onclick={() => create('file')}>
|
||||||
|
<Plus size="16" />
|
||||||
|
{i18n._('menu.explorer_menu.new_file')}
|
||||||
|
</ContextMenu.Item>
|
||||||
|
<ContextMenu.Item onclick={() => create('folder')}>
|
||||||
|
<Plus size="16" />
|
||||||
|
{i18n._('menu.explorer_menu.new_folder')}
|
||||||
|
</ContextMenu.Item>
|
||||||
|
<ContextMenu.Separator />
|
||||||
|
{:else}
|
||||||
|
<ContextMenu.Item onclick={openSelected}>
|
||||||
|
<FolderOpen size="16" />
|
||||||
|
{i18n._('menu.explorer_menu.open')}
|
||||||
|
</ContextMenu.Item>
|
||||||
|
{#if openIds().length > 0}
|
||||||
|
<ContextMenu.Item onclick={() => closeFiles(openIds())}>
|
||||||
|
<FileX size="16" />
|
||||||
|
{i18n._('menu.close')}
|
||||||
|
</ContextMenu.Item>
|
||||||
|
{/if}
|
||||||
|
<ContextMenu.Separator />
|
||||||
|
{/if}
|
||||||
|
{#if kind !== 'root'}
|
||||||
|
<ContextMenu.Item
|
||||||
|
disabled={targets().length !== 1}
|
||||||
|
onclick={() => (renaming.path = path)}
|
||||||
|
>
|
||||||
|
<PencilLine size="16" />
|
||||||
|
{i18n._('menu.explorer_menu.rename')}
|
||||||
|
</ContextMenu.Item>
|
||||||
|
<ContextMenu.Item onclick={remove}>
|
||||||
|
<Trash2 size="16" />
|
||||||
|
{i18n._('menu.delete')}
|
||||||
|
</ContextMenu.Item>
|
||||||
|
<ContextMenu.Separator />
|
||||||
|
{/if}
|
||||||
|
<ContextMenu.Item onclick={() => engine.revealPath(path)}>
|
||||||
|
<ExternalLink size="16" />
|
||||||
|
{i18n._(isMac() ? 'menu.explorer_menu.reveal_finder' : 'menu.explorer_menu.reveal')}
|
||||||
|
</ContextMenu.Item>
|
||||||
|
{#if kind === 'root'}
|
||||||
|
<ContextMenu.Separator />
|
||||||
|
<ContextMenu.Item onclick={closeFolder}>
|
||||||
|
<FolderX size="16" />
|
||||||
|
{i18n._('menu.close_folder')}
|
||||||
|
</ContextMenu.Item>
|
||||||
|
{/if}
|
||||||
|
</ContextMenu.Content>
|
||||||
|
</ContextMenu.Root>
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
|
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { FileText, Folder } from '@lucide/svelte';
|
||||||
import { FileText } from '@lucide/svelte';
|
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { clickedInExplorer } from './state.svelte';
|
import { get } from 'svelte/store';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
import { stickyKey, type StickyLevel } from '$lib/components/collapsible-tree/sticky';
|
import { stickyKey, type StickyLevel } from '$lib/components/collapsible-tree/sticky';
|
||||||
import ExplorerTree from './ExplorerTree.svelte';
|
import ExplorerTree from './ExplorerTree.svelte';
|
||||||
import { openPath } from '$lib/actions/file-actions';
|
import ExplorerRow from './ExplorerRow.svelte';
|
||||||
import { engine, type FolderEntry } from '$lib/engine';
|
import { engine, type FolderEntry } from '$lib/engine';
|
||||||
import { selectedFileIds } from '$lib/engine/selection-helpers';
|
|
||||||
import { fileIdAt } from '$lib/engine/workspace';
|
import { fileIdAt } from '$lib/engine/workspace';
|
||||||
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
|
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||||
|
import { showError } from './actions';
|
||||||
|
import { creating, editableName, explorerSelection } from './state.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
path,
|
path,
|
||||||
@@ -18,11 +21,71 @@
|
|||||||
path?: string;
|
path?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const { workspace, selection, folderVersion } = engine;
|
const { workspace, folderVersion } = engine;
|
||||||
// the rows of the folders that stick above the files of this one
|
// the rows of the folders that stick above the files of this one
|
||||||
const level = getContext<StickyLevel>(stickyKey);
|
const level = getContext<StickyLevel>(stickyKey);
|
||||||
|
|
||||||
let entries: FolderEntry[] = $state([]);
|
let entries: FolderEntry[] = $state([]);
|
||||||
|
// the nodes of the folders, to open them when something is made in them
|
||||||
|
let nodes: Record<string, CollapsibleTreeNode | undefined> = $state({});
|
||||||
|
|
||||||
|
// the folder of the list, where what is being made goes
|
||||||
|
let folder = $derived(path ?? $workspace?.folder);
|
||||||
|
let pending = $derived(
|
||||||
|
creating.parent !== undefined && creating.parent === folder ? creating.kind : undefined
|
||||||
|
);
|
||||||
|
// the files come after the folders
|
||||||
|
let firstFile = $derived(entries.find((entry) => !entry.isDir));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes the file or folder that was asked for, called `value`: nothing is made without a name,
|
||||||
|
* nor with the name of something that is there (the user is told, and can change it). A new
|
||||||
|
* file is selected, for the routing tool.
|
||||||
|
*/
|
||||||
|
async function create(value: string, retry: boolean) {
|
||||||
|
const kind = pending;
|
||||||
|
const parent = folder;
|
||||||
|
if (kind === undefined || parent === undefined) return;
|
||||||
|
const name = value.trim();
|
||||||
|
const taken = entries.some(
|
||||||
|
(entry) =>
|
||||||
|
entry.isDir === (kind === 'folder') &&
|
||||||
|
editableName(entry.name, entry.isDir).toLowerCase() === name.toLowerCase()
|
||||||
|
);
|
||||||
|
if (name !== '' && taken) {
|
||||||
|
toast.error(i18n._('menu.metadata.name_taken'));
|
||||||
|
if (retry) return;
|
||||||
|
}
|
||||||
|
creating.parent = undefined;
|
||||||
|
if (name === '' || taken) return;
|
||||||
|
let created: string | undefined;
|
||||||
|
if (kind === 'file') {
|
||||||
|
const id = await engine.newFileIn(name, parent);
|
||||||
|
created = id === undefined ? undefined : get(workspace)?.files[id]?.path;
|
||||||
|
if (created !== undefined) {
|
||||||
|
currentTool.set(Tool.ROUTING);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
created = await engine.createFolder(parent, name);
|
||||||
|
}
|
||||||
|
if (created === undefined) {
|
||||||
|
showError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
explorerSelection.paths = [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
|
||||||
@@ -37,25 +100,44 @@
|
|||||||
current = false;
|
current = false;
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Opens the file, or selects it if it is open already. */
|
|
||||||
function open(entry: FolderEntry, id: string | undefined) {
|
|
||||||
// it is in view, the explorer does not have to scroll to it (which would move the rows
|
|
||||||
// under the pointer, and leave the one it was on highlighted)
|
|
||||||
clickedInExplorer.path = entry.path;
|
|
||||||
if (id !== undefined) {
|
|
||||||
engine.select([id]);
|
|
||||||
} else {
|
|
||||||
openPath(entry.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- the name of what is being made, which it is made with -->
|
||||||
|
{#snippet named(kind: 'file' | 'folder')}
|
||||||
|
<div class="flex flex-row items-center h-7 pl-1 pr-1 {kind === 'file' ? 'gap-1.5' : 'gap-1'}">
|
||||||
|
{#if kind === 'file'}
|
||||||
|
<FileText size="16" class="shrink-0" />
|
||||||
|
{:else}
|
||||||
|
<Folder size="16" class="shrink-0" />
|
||||||
|
{/if}
|
||||||
|
<input
|
||||||
|
class="h-6 grow min-w-0 px-1 text-sm bg-background border rounded outline-none"
|
||||||
|
placeholder={i18n._(
|
||||||
|
kind === 'file' ? 'menu.explorer_menu.new_file' : 'menu.explorer_menu.new_folder'
|
||||||
|
)}
|
||||||
|
{@attach (input) => input.focus()}
|
||||||
|
{onkeydown}
|
||||||
|
onblur={(event) => create(event.currentTarget.value, false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if pending === 'folder'}
|
||||||
|
{@render named('folder')}
|
||||||
|
{/if}
|
||||||
{#each entries as entry (entry.path)}
|
{#each entries as entry (entry.path)}
|
||||||
|
{#if pending === 'file' && entry === firstFile}
|
||||||
|
{@render named('file')}
|
||||||
|
{/if}
|
||||||
{#if entry.isDir}
|
{#if entry.isDir}
|
||||||
<CollapsibleTreeNode id={entry.name}>
|
<CollapsibleTreeNode id={entry.name} bind:this={nodes[entry.path]}>
|
||||||
{#snippet trigger()}
|
{#snippet trigger()}
|
||||||
<span class="grow truncate text-left">{entry.name}</span>
|
<ExplorerRow
|
||||||
|
path={entry.path}
|
||||||
|
name={entry.name}
|
||||||
|
kind="folder"
|
||||||
|
onOpenFolder={() => nodes[entry.path]?.openNode()}
|
||||||
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet content(opened)}
|
{#snippet content(opened)}
|
||||||
<!-- the content of a folder is only read when it is opened -->
|
<!-- the content of a folder is only read when it is opened -->
|
||||||
@@ -67,20 +149,23 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</CollapsibleTreeNode>
|
</CollapsibleTreeNode>
|
||||||
{:else}
|
{:else}
|
||||||
{@const id = fileIdAt($workspace, entry.path)}
|
<!-- the files that are not open are greyed -->
|
||||||
{@const selected = id !== undefined && selectedFileIds($selection).includes(id)}
|
<ExplorerRow
|
||||||
<Button
|
path={entry.path}
|
||||||
variant="ghost"
|
name={entry.name}
|
||||||
class="w-full h-7 px-1 justify-start gap-1.5 border-none font-normal {selected
|
kind="file"
|
||||||
? 'bg-accent'
|
class="w-full h-7 px-1 text-sm hover:bg-muted {fileIdAt($workspace, entry.path) ===
|
||||||
: ''} {id === undefined ? 'text-muted-foreground' : ''}"
|
undefined
|
||||||
title={entry.path}
|
? 'text-muted-foreground'
|
||||||
data-path={entry.path}
|
: ''}"
|
||||||
style="scroll-margin-top: {level.top}px"
|
style="scroll-margin-top: {level.top}px"
|
||||||
onclick={() => open(entry, id)}
|
|
||||||
>
|
>
|
||||||
<FileText size="16" class="shrink-0" />
|
{#snippet icon()}
|
||||||
<span class="grow truncate text-left">{entry.name}</span>
|
<FileText size="16" class="shrink-0" />
|
||||||
</Button>
|
{/snippet}
|
||||||
|
</ExplorerRow>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if pending === 'file' && firstFile === undefined}
|
||||||
|
{@render named('file')}
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,45 @@
|
|||||||
/** The file that the user just selected by clicking it in the explorer, until the selection changes. */
|
/** The file that the user just selected by clicking it in the explorer, until the selection changes. */
|
||||||
export const clickedInExplorer: { path: string | undefined } = $state({ path: undefined });
|
export const clickedInExplorer: { path: string | undefined } = $state({ path: undefined });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is selected in the explorer (files and folders, by path), which the menu and dragging act
|
||||||
|
* on, and where a range selected with shift begins.
|
||||||
|
*/
|
||||||
|
export const explorerSelection: { paths: string[]; anchor: string | undefined } = $state({
|
||||||
|
paths: [],
|
||||||
|
anchor: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The file or folder whose name is being edited in the explorer. */
|
||||||
|
export const renaming: { path: string | undefined } = $state({ path: undefined });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The file or folder that is being made in a folder (`parent`) of the explorer: it is only made once
|
||||||
|
* the user gave it a name.
|
||||||
|
*/
|
||||||
|
export const creating: { parent: string | undefined; kind: 'file' | 'folder' } = $state({
|
||||||
|
parent: undefined,
|
||||||
|
kind: 'file',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** What is dragged in the explorer, and the folder it would be dropped in. */
|
||||||
|
export const dragging: { paths: string[]; target: string | undefined } = $state({
|
||||||
|
paths: [],
|
||||||
|
target: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** `\` and `/` alike. */
|
||||||
|
function isSeparator(char: string | undefined) {
|
||||||
|
return char === '/' || char === '\\';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
@@ -100,6 +100,10 @@
|
|||||||
|
|
||||||
// the file is not the one on the disk (the desktop app)
|
// the file is not the one on the disk (the desktop app)
|
||||||
let unsaved = $derived(node.type === 'file' && $workspace?.files[node.fileId]?.dirty === true);
|
let unsaved = $derived(node.type === 'file' && $workspace?.files[node.fileId]?.dirty === true);
|
||||||
|
// the file is open as a preview, which the next one replaces (the desktop app)
|
||||||
|
let preview = $derived(
|
||||||
|
node.type === 'file' && $workspace?.files[node.fileId]?.preview === true
|
||||||
|
);
|
||||||
let hidden = $derived(isHidden(fileState, elementId(node)));
|
let hidden = $derived(isHidden(fileState, elementId(node)));
|
||||||
// cut elements are greyed until they are pasted
|
// cut elements are greyed until they are pasted
|
||||||
let isCut = $derived($clipboard?.cut === true && isInClipboard($clipboard, node));
|
let isCut = $derived($clipboard?.cut === true && isInClipboard($clipboard, node));
|
||||||
@@ -195,6 +199,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<!-- a tab of the horizontal list is as wide as its content: the name is not cut -->
|
<!-- a tab of the horizontal list is as wide as its content: the name is not cut -->
|
||||||
<span
|
<span
|
||||||
|
class:italic={preview}
|
||||||
class="grow select-none {orientation === 'vertical'
|
class="grow select-none {orientation === 'vertical'
|
||||||
? 'truncate last:mr-2'
|
? 'truncate last:mr-2'
|
||||||
: 'shrink-0'}"
|
: 'shrink-0'}"
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ 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 }),
|
openPaths: (paths, preview) => invoke<Outcome>('open_paths', { paths, preview }),
|
||||||
openFolder: (unsaved) => invoke<Outcome>('open_folder', { unsaved }),
|
openFolder: (unsaved) => invoke<Outcome>('open_folder', { unsaved }),
|
||||||
closeFolder: (unsaved) => invoke<Outcome>('close_folder', { unsaved }),
|
closeFolder: (unsaved) => invoke<Outcome>('close_folder', { unsaved }),
|
||||||
closeFiles: (fileIds, unsaved) => invoke<Outcome>('close_files', { fileIds, unsaved }),
|
closeFiles: (fileIds, unsaved) => invoke<Outcome>('close_files', { fileIds, unsaved }),
|
||||||
@@ -222,6 +222,12 @@ export async function createTauriBackend(): Promise<Backend> {
|
|||||||
invoke<boolean>('export_files', { fileIds, options }),
|
invoke<boolean>('export_files', { fileIds, options }),
|
||||||
listFolder: (path) => invoke<FolderEntry[]>('list_folder', { path }),
|
listFolder: (path) => invoke<FolderEntry[]>('list_folder', { path }),
|
||||||
openUrl: (url) => invoke('open_url', { url }),
|
openUrl: (url) => invoke('open_url', { url }),
|
||||||
|
newFile: (name, folder) => invoke<Outcome>('new_file', { name, folder }),
|
||||||
|
createFolder: (parent, name) => invoke<string>('create_folder', { parent, name }),
|
||||||
|
renamePath: (path, name) => invoke<Outcome>('rename_path', { path, name }),
|
||||||
|
movePaths: (paths, folder) => invoke('move_paths', { paths, folder }),
|
||||||
|
deletePaths: (paths) => invoke<Outcome>('delete_paths', { paths }),
|
||||||
|
revealPath: (path) => invoke('reveal_path', { path }),
|
||||||
listen: async (onEngineChanged, onWorkspaceChanged, onFilesOpened, onFolderChanged) => {
|
listen: async (onEngineChanged, onWorkspaceChanged, onFilesOpened, onFolderChanged) => {
|
||||||
await listen<Outcome>('files-opened', (event) => onFilesOpened(event.payload));
|
await listen<Outcome>('files-opened', (event) => onFilesOpened(event.payload));
|
||||||
await listen<Outcome>('engine-changed', (event) => onEngineChanged(event.payload));
|
await listen<Outcome>('engine-changed', (event) => onEngineChanged(event.payload));
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export type FileStatus = {
|
|||||||
replaced: boolean;
|
replaced: boolean;
|
||||||
/** The file on the disk is gone. */
|
/** The file on the disk is gone. */
|
||||||
missing: boolean;
|
missing: boolean;
|
||||||
|
/** The file is open as a preview (a single click in the tree), which the next one replaces. */
|
||||||
|
preview: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** An entry of the tree of the open folder: a folder, or a GPX file. */
|
/** An entry of the tree of the open folder: a folder, or a GPX file. */
|
||||||
@@ -56,8 +58,12 @@ 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 files of the file system by their path, like the ones of the tree of the folder. With
|
||||||
|
* `preview`, a single file is open as a preview: the next preview replaces it, until it is
|
||||||
|
* edited or opened for good. Otherwise they are open for good.
|
||||||
|
*/
|
||||||
|
openPaths(paths: string[], preview?: boolean): Promise<Outcome>;
|
||||||
/**
|
/**
|
||||||
* Opens the folder that the user picks, which is watched. Its files are not opened: they are
|
* Opens the folder that the user picks, which is watched. Its files are not opened: they are
|
||||||
* in its tree (`listFolder`). The folder that was open is closed with its files (see
|
* in its tree (`listFolder`). The folder that was open is closed with its files (see
|
||||||
@@ -91,6 +97,24 @@ export interface DesktopFiles {
|
|||||||
listFolder(path?: string): Promise<FolderEntry[]>;
|
listFolder(path?: string): Promise<FolderEntry[]>;
|
||||||
/** Opens a web page (or an email to write) in the app of the system for it. */
|
/** Opens a web page (or an email to write) in the app of the system for it. */
|
||||||
openUrl(url: string): Promise<void>;
|
openUrl(url: string): Promise<void>;
|
||||||
|
/** Makes a new file, written at once in `folder` (a folder of the open folder), and selects it. */
|
||||||
|
newFile(name: string, folder: string): Promise<Outcome>;
|
||||||
|
/** Makes a folder in a folder of the open folder (numbered if the name is taken): its path. */
|
||||||
|
createFolder(parent: string, name: string): Promise<string>;
|
||||||
|
/**
|
||||||
|
* Renames a file or a folder of the open folder. An open file is renamed like in its info (it
|
||||||
|
* is selected), which can be undone.
|
||||||
|
*/
|
||||||
|
renamePath(path: string, name: string): Promise<Outcome>;
|
||||||
|
/** Moves files and folders of the open folder into a folder of it. */
|
||||||
|
movePaths(paths: string[], folder: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Moves files and folders of the open folder to the trash. The open files among them are
|
||||||
|
* closed, unless they have edits.
|
||||||
|
*/
|
||||||
|
deletePaths(paths: string[]): Promise<Outcome>;
|
||||||
|
/** Shows a file or a folder in the file manager of the system. */
|
||||||
|
revealPath(path: string): Promise<void>;
|
||||||
/**
|
/**
|
||||||
* Tells when the engine changed without being asked, because the files on the disk did (the
|
* Tells when the engine changed without being asked, because the files on the disk did (the
|
||||||
* outcome to apply), when the status of the files changed, when files were opened with the
|
* outcome to apply), when the status of the files changed, when files were opened with the
|
||||||
|
|||||||
@@ -562,9 +562,87 @@ 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. */
|
/**
|
||||||
async openPaths(paths: string[]): Promise<string[]> {
|
* Opens files of the file system by their path, which get selected: as a preview with
|
||||||
return this.addedBy((b) => b.desktop!.openPaths(paths));
|
* `preview`, see `DesktopFiles.openPaths`. Resolves to the new ids.
|
||||||
|
*/
|
||||||
|
async openPaths(paths: string[], preview = false): Promise<string[]> {
|
||||||
|
const added = await 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,
|
||||||
|
// `lastError` tells why (a key of the dictionaries, or a message).
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a new file in a folder of the open folder, which is written there at once and selected.
|
||||||
|
* Resolves to its id.
|
||||||
|
*/
|
||||||
|
async newFileIn(name: string, folder: string): Promise<string | undefined> {
|
||||||
|
const [id] = await this.addedBy((b) => b.desktop!.newFile(name, folder));
|
||||||
|
this._folderVersion.update((version) => version + 1);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Makes a folder in a folder of the open folder: resolves to its path. */
|
||||||
|
async createFolder(parent: string, name: string): Promise<string | undefined> {
|
||||||
|
return (await this.treeOperation((desktop) => desktop.createFolder(parent, name))).value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renames a file or a folder of the open folder. Resolves to whether it was done. */
|
||||||
|
async renamePath(path: string, name: string): Promise<boolean> {
|
||||||
|
const changed = await this.run((b) => b.desktop!.renamePath(path, name));
|
||||||
|
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. */
|
||||||
|
async movePaths(paths: string[], folder: string): Promise<boolean> {
|
||||||
|
return (await this.treeOperation((desktop) => desktop.movePaths(paths, folder))).done;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moves files and folders of the open folder to the trash. */
|
||||||
|
async deletePaths(paths: string[]): Promise<boolean> {
|
||||||
|
const changed = await this.run((b) => b.desktop!.deletePaths(paths));
|
||||||
|
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. */
|
||||||
|
async revealPath(path: string) {
|
||||||
|
await this.ready;
|
||||||
|
try {
|
||||||
|
await this.backend!.desktop?.revealPath(path);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('engine:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,6 +15,15 @@
|
|||||||
"open": "Open...",
|
"open": "Open...",
|
||||||
"open_folder": "Open folder...",
|
"open_folder": "Open folder...",
|
||||||
"explorer": "Explorer",
|
"explorer": "Explorer",
|
||||||
|
"explorer_menu": {
|
||||||
|
"open": "Open",
|
||||||
|
"new_file": "New file...",
|
||||||
|
"new_folder": "New folder...",
|
||||||
|
"rename": "Rename...",
|
||||||
|
"reveal_finder": "Reveal in Finder",
|
||||||
|
"reveal": "Reveal in file explorer",
|
||||||
|
"delete_failed": "The files could not be moved to the trash."
|
||||||
|
},
|
||||||
"workspace": "Workspace",
|
"workspace": "Workspace",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"save_all": "Save all",
|
"save_all": "Save all",
|
||||||
|
|||||||
Reference in new issue
Block a user