This commit is contained in:
vcoppe committed 2026-10-17 16:14:17 +02:00
1 parent ed90ba6a1f
commit b2495d1466
6 files changed
+186 -16

No files matched your search

+3 -6
View File
@@ -348,15 +348,12 @@ pub fn export_files<R: Runtime>(
options: ExportArguments,
) -> Result<bool, String> {
let options = ExportOptions::from(options);
let files: Vec<(String, Vec<u8>)> = state.read(move |engine| {
let files: Vec<(String, Vec<u8>)> = state.core(move |core| {
file_ids
.iter()
.filter_map(|id| {
let name = engine
.file_structure(&gpx_engine::FileId(*id))?
.name
.clone();
Some((name, api::export_file(engine, *id, options)?))
let name = core.workspace.display_name(&core.engine, *id)?;
Some((name, api::export_file(&core.engine, *id, options)?))
})
.collect()
});
+30 -1
View File
@@ -201,9 +201,38 @@ impl Core {
.then(|| self.workspace.folder.clone())
.flatten()
});
let outcome = f(&mut self.engine);
// what the files it comes from are called, by the name in them (the copies have the same one)
let sources: Vec<(String, String)> = selected
.iter()
.filter_map(|id| {
let structure = self.engine.file_structure(&gpx_engine::FileId(*id))?;
Some((
structure.name.clone(),
self.workspace.display_name(&self.engine, *id)?,
))
})
.collect();
let mut outcome = f(&mut self.engine);
if let Some(dir) = dir {
self.workspace.inherit(outcome.added(), &dir);
// what is made in a folder is written there at once, so that the tree shows it. A copy
// is named like the file it is a copy of
let made: Vec<(uuid::Uuid, Option<String>)> = outcome
.added()
.iter()
.filter_map(|id| uuid::Uuid::parse_str(id).ok())
.filter(|id| self.workspace.dirs.contains_key(id))
.filter_map(|id| {
let name = &self.engine.file_structure(&gpx_engine::FileId(id))?.name;
let source = sources.iter().find(|(source, _)| source == name);
Some((id, source.map(|(_, display)| display.clone())))
})
.collect();
if let Err(error) = self.workspace.save_new(&self.engine, &made)
&& !outcome.has_error()
{
outcome = outcome.merge(api::Outcome::failed(error));
}
}
self.keep_workspace();
self.persist();
+80 -4
View File
@@ -380,21 +380,23 @@ fn files_are_opened_closed_and_made_in_a_folder() {
assert_eq!(state["files"][&id]["dirty"], false);
assert!(state["files"][&id]["path"].is_string());
// a new file made in a folder is written there, and is not linked to a place yet
// a new file made in a folder is written there at once, so that the tree shows it
let created = app.call(
"new_file",
json!({ "name": "route", "lng": 4.4, "lat": 50.8, "folder": folder }),
);
let new_id = created["added"][0].as_str().unwrap().to_string();
let state = app.call("workspace_state", json!({}));
assert_eq!(state["files"][&new_id]["dirty"], true);
assert!(state["files"][&new_id]["folder"].is_string());
assert!(state["files"][&new_id].get("path").is_none());
assert_eq!(state["files"][&new_id]["dirty"], false);
assert!(state["files"][&new_id]["path"].is_string());
assert!(folder.join("route.gpx").exists());
// the tree needs an open folder
assert_eq!(app.call("list_folder", json!({})), json!([]));
// closing: what is saved goes, what has edits stays unless it is discarded
app.call("select", json!({ "fileIds": [new_id], "mode": "replace" }));
app.call("metadata", json!({ "name": "edited", "desc": "" }));
app.call("close_files", json!({ "fileIds": [id, new_id] }));
assert_eq!(app.call("file_order", json!({})), json!([new_id]));
app.call(
@@ -455,3 +457,77 @@ fn a_file_outside_the_open_folder_is_read_again_when_the_disk_changes() {
std::fs::remove_dir_all(folder).unwrap();
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn files_made_from_a_file_of_the_open_folder_are_written_next_to_it_at_once() {
let state = crate::state::AppState::temporary();
let folder = std::env::temp_dir().join(format!("gpx-tests-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(folder.join("sub")).unwrap();
std::fs::copy("../gpx-rs/data/simple.gpx", folder.join("sub/track.gpx")).unwrap();
let sub = folder.join("sub");
let (copied, new_file) = state.core(move |core| {
core.workspace.open_folder(&mut core.engine, &folder, false);
let opened = core
.workspace
.open_files(&mut core.engine, &[sub.join("track.gpx")], true);
core.keep_workspace();
assert_eq!(opened.added().len(), 1);
let copy = core.creating(None, false, gpx_engine::api::duplicate);
let fresh = core.creating(None, true, |e| {
gpx_engine::api::new_file(e, "fresh", None, None, None)
});
// a copy of the file goes next to it, a new file with nothing selected in the selection
// of the file that was just made
(copy, fresh)
});
let sub = std::env::temp_dir();
let _ = sub;
assert_eq!(copied.added().len(), 1);
assert_eq!(new_file.added().len(), 1);
let status = state.core(|core| core.workspace_state());
assert!(status.files.values().all(|file| !file.dirty));
let paths: Vec<String> = status
.files
.values()
.map(|file| file.path.clone().unwrap())
.collect();
// the copy is in the subfolder, named after the file
assert!(
paths.iter().any(|path| path.ends_with("sub/track (2).gpx")),
"{paths:?}"
);
// the new file went where the selected file is
assert!(
paths.iter().any(|path| path.ends_with("sub/fresh.gpx")),
"{paths:?}"
);
}
#[test]
fn the_parts_of_a_split_are_numbered_from_one() {
let state = crate::state::AppState::temporary();
let folder = std::env::temp_dir().join(format!("gpx-tests-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&folder).unwrap();
std::fs::copy("../gpx-rs/data/simple.gpx", folder.join("track.gpx")).unwrap();
let root = folder.clone();
let outcome = state.core(move |core| {
core.workspace.open_folder(&mut core.engine, &root, false);
core.workspace
.open_files(&mut core.engine, &[root.join("track.gpx")], true);
core.keep_workspace();
let revision = core.engine.routing().revision;
core.creating(None, false, |e| {
gpx_engine::api::split(e, revision, 2, gpx_engine::SplitType::Files)
})
});
assert_eq!(outcome.added().len(), 2, "{:?}", outcome.added());
let mut names: Vec<String> = std::fs::read_dir(&folder)
.unwrap()
.flatten()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect();
names.sort();
// the file stays, its two parts are numbered from 1
assert_eq!(names, ["track (1).gpx", "track (2).gpx", "track.gpx"]);
std::fs::remove_dir_all(folder).unwrap();
}
+62
View File
@@ -193,6 +193,16 @@ pub fn free_path(folder: &Path, name: &str, taken: impl Fn(&Path) -> bool) -> Pa
path
}
/// Where to put one of several files that are called the same in `folder`: `name (1)`, or else the
/// first number that is free.
pub fn numbered_path(folder: &Path, name: &str, taken: impl Fn(&Path) -> bool) -> PathBuf {
let name = file_name(name);
(1..)
.map(|number| folder.join(format!("{name} ({number}).gpx")))
.find(|path| !path.exists() && !taken(path))
.expect("there are enough numbers")
}
impl Workspace {
pub fn path() -> &'static str {
"workspace.json"
@@ -595,6 +605,58 @@ impl Workspace {
Ok(())
}
/// Writes the new files among `files` in their folder, each under the name that is given (the
/// name in the file when there is none). A name that is the one of something else is changed
/// (`name (2)`). Files that would be called the same, like the parts of a split, are numbered
/// from 1 (`name (1)`, `name (2)`).
pub fn save_new(
&mut self,
engine: &Engine,
files: &[(Uuid, Option<String>)],
) -> Result<(), String> {
let names: Vec<Option<(Uuid, PathBuf, String)>> = files
.iter()
.map(|(id, name)| {
let dir = self.dirs.get(id)?.clone();
let structure = engine.file_structure(&FileId(*id))?;
Some((
*id,
dir,
name.clone().unwrap_or_else(|| structure.name.clone()),
))
})
.collect();
for entry in &names {
let Some((id, dir, name)) = entry else {
continue;
};
let together = names
.iter()
.flatten()
.filter(|(_, other_dir, other)| {
other_dir == dir && file_name(other) == file_name(name)
})
.count()
> 1;
let path = if together {
numbered_path(dir, name, |p| self.is_taken(p))
} else {
free_path(dir, name, |p| self.is_taken(p))
};
self.write_file(engine, *id, &path)?;
}
Ok(())
}
/// The name of a file as the user knows it: the name of the file on the disk, without its
/// extension, or else the name in the file.
pub fn display_name(&self, engine: &Engine, id: Uuid) -> Option<String> {
match self.links.get(&id) {
Some(link) => Some(stem(&link.path)),
None => Some(engine.file_structure(&FileId(id))?.name.clone()),
}
}
/// Writes a file to a path of the user's choice, which is the place of the file from then on.
pub fn save_as(&mut self, engine: &Engine, id: Uuid, path: &Path) -> Result<(), String> {
if let Some(other) = self.id_of(&canonical(path))
@@ -61,7 +61,6 @@
{:else}
{@const id = fileIdAt($workspace, entry.path)}
{@const selected = id !== undefined && selectedFileIds($selection).includes(id)}
{@const dirty = id !== undefined && $workspace?.files[id]?.dirty === true}
<Button
variant="ghost"
class="w-full h-7 px-1 justify-start gap-1.5 border-none font-normal {selected
@@ -72,9 +71,6 @@
>
<FileText size="16" class="shrink-0" />
<span class="grow truncate text-left">{entry.name}</span>
{#if dirty}
<span class="size-1.5 shrink-0 rounded-full bg-foreground"></span>
{/if}
</Button>
{/if}
{/each}
@@ -50,7 +50,7 @@
label: string | undefined;
} = $props();
const { selection, clipboard, canPaste } = engine;
const { selection, clipboard, canPaste, workspace } = engine;
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let embedding = getContext<boolean>('embedding');
@@ -92,6 +92,8 @@
selectedElementIds($selection)[0]?.ids[0] === elementId(node)
);
// 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 hidden = $derived(isHidden(fileState, elementId(node)));
// cut elements are greyed until they are pasted
let isCut = $derived($clipboard?.cut === true && isInClipboard($clipboard, node));
@@ -187,6 +189,14 @@
>
{label}
</span>
{#if unsaved}
<span
class="size-1.5 shrink-0 rounded-full bg-foreground ml-1 {orientation ===
'vertical'
? 'mr-3'
: ''}"
></span>
{/if}
{#if hidden}
<EyeOff
size="10"