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
@@ -26,4 +26,5 @@ notify-debouncer-mini = "0.7.0"
|
||||
tauri-plugin-dialog = "2.8.2"
|
||||
tauri-plugin-single-instance = "2.5.3"
|
||||
tauri-plugin-opener = "2"
|
||||
trash = "5"
|
||||
|
||||
+102
-3
@@ -14,6 +14,7 @@ use tauri::{AppHandle, Runtime, State};
|
||||
use tauri_plugin_dialog::{DialogExt, FilePath};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::commands::edit;
|
||||
use crate::state::AppState;
|
||||
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 {
|
||||
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.
|
||||
/// 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)]
|
||||
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| {
|
||||
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.persist();
|
||||
core.announce(None);
|
||||
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 uuid::Uuid;
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::state::{AppState, Core};
|
||||
|
||||
/// Names and describes the selected files or tracks. A file on the disk is renamed with it (and back
|
||||
/// when it is undone): its name is one that a file can have, and not the one of another file.
|
||||
#[tauri::command(async)]
|
||||
pub fn metadata(state: State<AppState>, name: String, desc: String) -> api::Outcome {
|
||||
state.core(move |core| {
|
||||
let mut name = name;
|
||||
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);
|
||||
}
|
||||
state.core(move |core| rename_selection(core, name, desc))
|
||||
}
|
||||
|
||||
/// See `metadata`.
|
||||
pub fn rename_selection(core: &mut Core, name: String, desc: String) -> api::Outcome {
|
||||
let mut name = name;
|
||||
if let Selection::File { file_ids } = core.engine.selection() {
|
||||
for FileId(id) in file_ids.clone() {
|
||||
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)]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! What the app asks of the system, outside of the files.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tauri::{AppHandle, Runtime};
|
||||
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>)
|
||||
.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::export_files,
|
||||
disk::list_folder,
|
||||
disk::create_folder,
|
||||
disk::rename_path,
|
||||
disk::move_paths,
|
||||
disk::delete_paths,
|
||||
// the system
|
||||
system::open_url,
|
||||
system::reveal_path,
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,8 @@ impl Core {
|
||||
return;
|
||||
}
|
||||
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.persist();
|
||||
(self.emit)("files-opened", to_json(&outcome));
|
||||
@@ -101,7 +103,17 @@ impl Core {
|
||||
/// could not be renamed.
|
||||
fn editing<T>(&mut self, f: impl FnOnce(&mut Engine) -> T) -> (T, Vec<String>) {
|
||||
let names = self.workspace.names(&self.engine);
|
||||
let preview = self.workspace.preview;
|
||||
let preview_before = preview.and_then(|id| crate::workspace::signature(&self.engine, id));
|
||||
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);
|
||||
if renamed {
|
||||
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);
|
||||
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";
|
||||
/// A file cannot be renamed on the disk.
|
||||
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.
|
||||
#[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.
|
||||
#[serde(default)]
|
||||
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.
|
||||
@@ -121,6 +127,8 @@ pub struct FileStatus {
|
||||
pub replaced: bool,
|
||||
/// The file on the disk is gone.
|
||||
pub missing: bool,
|
||||
/// The file is open as a preview, which the next one replaces.
|
||||
pub preview: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -323,6 +331,7 @@ impl Workspace {
|
||||
dirty: self.is_dirty(engine, *id),
|
||||
replaced: link.is_some_and(|link| link.replaced),
|
||||
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();
|
||||
self.links.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
|
||||
@@ -410,6 +420,35 @@ impl Workspace {
|
||||
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
|
||||
/// engine does not have the file.
|
||||
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.dirs.remove(id);
|
||||
}
|
||||
self.keep(&closing);
|
||||
api::sync_files(engine, &[], &closing)
|
||||
}
|
||||
|
||||
@@ -910,6 +950,153 @@ impl Workspace {
|
||||
});
|
||||
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)]
|
||||
@@ -1151,6 +1338,77 @@ mod tests {
|
||||
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]
|
||||
fn the_tree_of_the_folder_shows_folders_first_and_only_gpx_files() {
|
||||
let mut fx = Fixture::new();
|
||||
|
||||
Reference in new issue
Block a user