mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-19 09:22:12 +00:00
progress
This commit is contained in:
1 parent
ed90ba6a1f
commit
b2495d1466
6 files changed
+186
-16
No files matched your search
@@ -348,15 +348,12 @@ pub fn export_files<R: Runtime>(
|
|||||||
options: ExportArguments,
|
options: ExportArguments,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let options = ExportOptions::from(options);
|
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
|
file_ids
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|id| {
|
.filter_map(|id| {
|
||||||
let name = engine
|
let name = core.workspace.display_name(&core.engine, *id)?;
|
||||||
.file_structure(&gpx_engine::FileId(*id))?
|
Some((name, api::export_file(&core.engine, *id, options)?))
|
||||||
.name
|
|
||||||
.clone();
|
|
||||||
Some((name, api::export_file(engine, *id, options)?))
|
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
});
|
});
|
||||||
|
|||||||
+30
-1
@@ -201,9 +201,38 @@ impl Core {
|
|||||||
.then(|| self.workspace.folder.clone())
|
.then(|| self.workspace.folder.clone())
|
||||||
.flatten()
|
.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 {
|
if let Some(dir) = dir {
|
||||||
self.workspace.inherit(outcome.added(), &dir);
|
self.workspace.inherit(outcome.added(), &dir);
|
||||||
|
// what is made in a folder is written there at once, so that the tree shows it. A copy
|
||||||
|
// 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.keep_workspace();
|
||||||
self.persist();
|
self.persist();
|
||||||
|
|||||||
+80
-4
@@ -380,21 +380,23 @@ fn files_are_opened_closed_and_made_in_a_folder() {
|
|||||||
assert_eq!(state["files"][&id]["dirty"], false);
|
assert_eq!(state["files"][&id]["dirty"], false);
|
||||||
assert!(state["files"][&id]["path"].is_string());
|
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(
|
let created = app.call(
|
||||||
"new_file",
|
"new_file",
|
||||||
json!({ "name": "route", "lng": 4.4, "lat": 50.8, "folder": folder }),
|
json!({ "name": "route", "lng": 4.4, "lat": 50.8, "folder": folder }),
|
||||||
);
|
);
|
||||||
let new_id = created["added"][0].as_str().unwrap().to_string();
|
let new_id = created["added"][0].as_str().unwrap().to_string();
|
||||||
let state = app.call("workspace_state", json!({}));
|
let state = app.call("workspace_state", json!({}));
|
||||||
assert_eq!(state["files"][&new_id]["dirty"], true);
|
assert_eq!(state["files"][&new_id]["dirty"], false);
|
||||||
assert!(state["files"][&new_id]["folder"].is_string());
|
assert!(state["files"][&new_id]["path"].is_string());
|
||||||
assert!(state["files"][&new_id].get("path").is_none());
|
assert!(folder.join("route.gpx").exists());
|
||||||
|
|
||||||
// the tree needs an open folder
|
// the tree needs an open folder
|
||||||
assert_eq!(app.call("list_folder", json!({})), json!([]));
|
assert_eq!(app.call("list_folder", json!({})), json!([]));
|
||||||
|
|
||||||
// closing: what is saved goes, what has edits stays unless it is discarded
|
// 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] }));
|
app.call("close_files", json!({ "fileIds": [id, new_id] }));
|
||||||
assert_eq!(app.call("file_order", json!({})), json!([new_id]));
|
assert_eq!(app.call("file_order", json!({})), json!([new_id]));
|
||||||
app.call(
|
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(folder).unwrap();
|
||||||
std::fs::remove_dir_all(directory).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();
|
||||||
|
}
|
||||||
@@ -193,6 +193,16 @@ pub fn free_path(folder: &Path, name: &str, taken: impl Fn(&Path) -> bool) -> Pa
|
|||||||
path
|
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 {
|
impl Workspace {
|
||||||
pub fn path() -> &'static str {
|
pub fn path() -> &'static str {
|
||||||
"workspace.json"
|
"workspace.json"
|
||||||
@@ -595,6 +605,58 @@ impl Workspace {
|
|||||||
Ok(())
|
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.
|
/// 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> {
|
pub fn save_as(&mut self, engine: &Engine, id: Uuid, path: &Path) -> Result<(), String> {
|
||||||
if let Some(other) = self.id_of(&canonical(path))
|
if let Some(other) = self.id_of(&canonical(path))
|
||||||
|
|||||||
@@ -61,7 +61,6 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{@const id = fileIdAt($workspace, entry.path)}
|
{@const id = fileIdAt($workspace, entry.path)}
|
||||||
{@const selected = id !== undefined && selectedFileIds($selection).includes(id)}
|
{@const selected = id !== undefined && selectedFileIds($selection).includes(id)}
|
||||||
{@const dirty = id !== undefined && $workspace?.files[id]?.dirty === true}
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="w-full h-7 px-1 justify-start gap-1.5 border-none font-normal {selected
|
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" />
|
<FileText size="16" class="shrink-0" />
|
||||||
<span class="grow truncate text-left">{entry.name}</span>
|
<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>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
label: string | undefined;
|
label: string | undefined;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const { selection, clipboard, canPaste } = engine;
|
const { selection, clipboard, canPaste, workspace } = engine;
|
||||||
|
|
||||||
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
||||||
let embedding = getContext<boolean>('embedding');
|
let embedding = getContext<boolean>('embedding');
|
||||||
@@ -92,6 +92,8 @@
|
|||||||
selectedElementIds($selection)[0]?.ids[0] === elementId(node)
|
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)));
|
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));
|
||||||
@@ -187,6 +189,14 @@
|
|||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
|
{#if unsaved}
|
||||||
|
<span
|
||||||
|
class="size-1.5 shrink-0 rounded-full bg-foreground ml-1 {orientation ===
|
||||||
|
'vertical'
|
||||||
|
? 'mr-3'
|
||||||
|
: ''}"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
{#if hidden}
|
{#if hidden}
|
||||||
<EyeOff
|
<EyeOff
|
||||||
size="10"
|
size="10"
|
||||||
|
|||||||
Reference in new issue
Block a user