diff --git a/crates/ui-egui/src/dialogs/save_for_web.rs b/crates/ui-egui/src/dialogs/save_for_web.rs index c15532437..73cf74d89 100644 --- a/crates/ui-egui/src/dialogs/save_for_web.rs +++ b/crates/ui-egui/src/dialogs/save_for_web.rs @@ -173,7 +173,7 @@ fn has_warnings(r: &Value) -> bool { r["warnings"].as_array().is_some_and(|w| !w.is_empty()) } -/// `file.saveForWeb.browser`: the HTML page and its images in a temporary folder, opened in the +/// `file.saveForWeb.browser`: the HTML page and its images in a new temporary folder, opened in the /// default browser (desktop). When the export has warnings, the status bar shows the first in /// place of "Opened …". pub(crate) fn browser_preview(app: &mut VectorcraftApp, p: &Value) -> Result { @@ -196,12 +196,39 @@ pub(crate) fn browser_preview(app: &mut VectorcraftApp, p: &Value) -> Result Result { - let dir = std::env::temp_dir().join("VectorCraft Save for Web"); - std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; - Ok(dir.to_string_lossy().replace('\\', "/")) + preview_folder(&std::env::temp_dir().join("VectorCraft Save for Web"), PREVIEW_KEEP) +} + +/// A new folder in `root`, named after the process id and a count of the process's previews. A +/// name that is already taken, by an earlier process with the same id, is skipped for the next +/// count. Entries of `root` last changed `keep` or longer ago are removed first. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn preview_folder(root: &std::path::Path, keep: std::time::Duration) -> Result { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + for e in std::fs::read_dir(root).into_iter().flatten().flatten() { + let old = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age >= keep); + if old { + // Best effort: one that can't be removed now is tried again by the next preview. + let _ = if e.file_type().is_ok_and(|t| t.is_dir()) { std::fs::remove_dir_all(e.path()) } else { std::fs::remove_file(e.path()) }; + } + } + std::fs::create_dir_all(root).map_err(|e| format!("{}: {e}", root.display()))?; + loop { + let dir = root.join(format!("{}-{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))); + match std::fs::create_dir(&dir) { + Ok(()) => return Ok(dir.to_string_lossy().replace('\\', "/")), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(format!("{}: {e}", dir.display())), + } + } } #[cfg(target_arch = "wasm32")] diff --git a/crates/ui-egui/src/dialogs/tests_save_for_web.rs b/crates/ui-egui/src/dialogs/tests_save_for_web.rs index c7615710e..d9c2d1160 100644 --- a/crates/ui-egui/src/dialogs/tests_save_for_web.rs +++ b/crates/ui-egui/src/dialogs/tests_save_for_web.rs @@ -141,6 +141,68 @@ fn preview_in_browser_opens_the_page() { assert!(log.last().unwrap().starts_with("open file://") && log.last().unwrap().ends_with("Untitled-1.html"), "{log:?}"); } +/// Each preview has its own folder: a second one, from another app or the same one, leaves the +/// first page and its image as they were. +#[test] +fn a_later_browser_preview_leaves_an_earlier_one_alone() { + let dir = vectorcraft_testkit::temp_dir("sfw-browser-two"); + let (mut first, _) = app(&dir, false); + let (mut second, _) = app(&dir, false); + let page = |r: serde_json::Value| r["path"].as_str().unwrap().to_string(); + let jpg = page(first.run("file.saveForWeb.browser", json!({"format": "jpg"})).unwrap()); + let gif = page(second.run("file.saveForWeb.browser", json!({"format": "gif"})).unwrap()); + let png = page(first.run("file.saveForWeb.browser", json!({"format": "png24"})).unwrap()); + assert!(jpg != gif && jpg != png && gif != png, "{jpg} {gif} {png}"); + let root = std::env::temp_dir().join("VectorCraft Save for Web").to_string_lossy().replace('\\', "/"); + for (page, image) in [(&jpg, "images/Untitled-1.jpg"), (&gif, "images/Untitled-1.gif"), (&png, "images/Untitled-1.png")] { + assert!(page.starts_with(&root) && page.ends_with("/Untitled-1.html"), "{page}"); + assert!(std::fs::read_to_string(page).unwrap().contains(image), "{page}"); + let folder = std::path::Path::new(page).parent().unwrap(); + assert!(folder.join(image).exists(), "{page}"); + } +} + +/// Making a preview's folder removes the folders and files in its root that are `keep` old or +/// older, and keeps younger ones. +#[test] +fn a_browser_preview_removes_old_previews() { + let root = vectorcraft_testkit::temp_dir("sfw-browser-old").join("previews"); + let _ = std::fs::remove_dir_all(&root); + let earlier = save_for_web::preview_folder(&root, std::time::Duration::from_secs(3600)).unwrap(); + std::fs::create_dir_all(format!("{earlier}/images")).unwrap(); + std::fs::write(format!("{earlier}/images/Untitled-1.gif"), b"GIF89a").unwrap(); + std::fs::write(root.join("Untitled-1.html"), b"").unwrap(); + // The folder and the page are younger than an hour, so both stay. + let kept = save_for_web::preview_folder(&root, std::time::Duration::from_secs(3600)).unwrap(); + assert!(std::path::Path::new(&earlier).exists() && root.join("Untitled-1.html").exists()); + // With a `keep` of zero every entry is old enough: all three go, and only the new folder is left. + let next = save_for_web::preview_folder(&root, std::time::Duration::ZERO).unwrap(); + assert!(!std::path::Path::new(&earlier).exists() && !std::path::Path::new(&kept).exists() && !root.join("Untitled-1.html").exists()); + assert!(std::path::Path::new(&next).is_dir()); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 1); +} + +/// A folder that an earlier process with the same id left, younger than `keep`, is not reused: +/// the preview makes a new, empty one. +#[test] +fn a_browser_preview_skips_a_folder_an_earlier_process_left() { + let root = vectorcraft_testkit::temp_dir("sfw-browser-reused").join("previews"); + let _ = std::fs::remove_dir_all(&root); + let keep = std::time::Duration::from_secs(3600); + let earlier = save_for_web::preview_folder(&root, keep).unwrap(); + let count: u64 = earlier.rsplit('-').next().unwrap().parse().unwrap(); + // The next 200 names, as an earlier process with the same id would have left them. + for n in count + 1..=count + 200 { + let left = root.join(format!("{}-{n}", std::process::id())); + std::fs::create_dir_all(left.join("images")).unwrap(); + std::fs::write(left.join("Untitled-1.html"), b"").unwrap(); + } + let next = save_for_web::preview_folder(&root, keep).unwrap(); + assert_ne!(next, earlier); + assert_eq!(std::fs::read_dir(&next).unwrap().count(), 0, "{next}"); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 202); +} + #[test] fn saving_says_in_the_status_bar_when_an_image_could_not_be_decoded() { const NOTE: &str = "with 1 note(s): an image couldn't be decoded and is missing from the picture"; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index dcf3badb9..208652885 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -435,6 +435,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. - Export for Screens: the dialog's Example line shows the path of the file the export writes first, sub-folder included, named as the export names it, for the first chosen artboard, asset, layer or object (`document.exportForScreens`). + - Save for Web: Preview in Browser writes each preview's page and images to a folder of its own inside `VectorCraft Save for Web` in the system's temporary folder. A later preview, from the same app or another one running, does not overwrite an earlier one. Each preview first removes the files and folders there that are an hour old or older (`file.saveForWeb.browser`). - Export for Screens, Save for Web (slice images, and files named after the document's title) and the Libraries panel name files with the characters Windows does not allow written as `-` and with a `-` after a Windows device name such as `CON`, on every platform (`document.exportForScreens`, `document.exportForWeb`, `library.create`). - Variables (data merge): text, visibility and image variables bound to objects (an object may carry several), dataset rows applied in one undo step (`variable.*`, `dataset.*`) from a Variables panel and Window › Variables, with dialogs to define, bind and edit, Make Text/Visibility/Image Dynamic, Capture/Update Data Set, CSV import/export (`dataset.import` / `dataset.export`), and a row in italics when an edit leaves the art not matching it; an image dataset value is a file path and apply keeps the object's bounds; bindings let go of deleted art and locked art is left alone; every command, like the rest, is drivable over MCP and the CLI. - Variables: Import Data Sets… (`dataset.import`) reads tab-separated files as well as comma-separated ones, and files without a `name` column, whose rows take the first free names Data Set 1, Data Set 2 and so on. An import error about a row's values or name gives the file line the row starts on.