diff --git a/src/app/annotation_data.rs b/src/app/annotation_data.rs index e549a4a11..24c268706 100644 --- a/src/app/annotation_data.rs +++ b/src/app/annotation_data.rs @@ -79,14 +79,18 @@ pub(crate) fn register_picked_source(path: &str, bytes: Arc>) -> bool { let mut cache = picked_sources() .lock() .unwrap_or_else(|error| error.into_inner()); - let replaced = cache - .get(path) - .is_some_and(|old| old.as_slice() != bytes.as_slice()); - if replaced { - take_last_collision().replace(file_name_of(path)); + let Some(old) = cache.get(path) else { + cache.insert(path.to_string(), bytes); + return true; + }; + if old.as_slice() == bytes.as_slice() { + return true; } - cache.insert(path.to_string(), bytes); - !replaced + // Refuse AND keep the old bytes: overwriting would silently retarget + // every reference reading this name. The name is remembered so the + // browse handlers can tell the user why their pick did nothing. + take_last_collision().replace(file_name_of(path)); + false } /// The name whose re-pick was refused because different bytes are already @@ -1846,6 +1850,13 @@ impl OpenCADStudio { &mut self, path: Option, ) -> Task { + #[cfg(target_arch = "wasm32")] + if let Some(name) = take_last_collision().take() { + self.data_extraction.error = crate::tf!( + "\"{name}\" is already loaded with different contents; the existing file was kept." + ) + .into_owned(); + } if let Some(path) = path { self.data_extraction.settings_path = path.to_string_lossy().into_owned(); if self.data_extraction.begin != ExtractionBegin::New { @@ -2074,6 +2085,23 @@ impl OpenCADStudio { #[cfg(test)] mod tests { + /// A refused same-name re-pick must KEEP the old bytes: overwriting + /// would silently retarget every reference reading that name. + #[test] + fn collision_refusal_keeps_the_registered_bytes() { + register_picked_source("a.csv", Arc::new(b"old".to_vec())); + let fresh = register_picked_source("a.csv", Arc::new(b"new".to_vec())); + assert!(!fresh, "differing bytes under a live name are refused"); + assert_eq!( + picked_source_bytes("a.csv").as_ref().map(|b| b.as_slice()), + Some(&b"old"[..]), + "the old registration survives the refusal" + ); + // Identical bytes are a no-op accept. + assert!(register_picked_source("a.csv", Arc::new(b"old".to_vec()))); + } + + use super::*; #[test] diff --git a/src/app/commands/fileops.rs b/src/app/commands/fileops.rs index c9a19b416..b9e6d75a9 100644 --- a/src/app/commands/fileops.rs +++ b/src/app/commands/fileops.rs @@ -82,8 +82,9 @@ impl OpenCADStudio { { // Web: every dirty saved tab is serialized and downloaded // in one pass — see `saveall_web` for the flow. - self.saveall_web(); + return Some(self.saveall_web()); } + #[cfg(not(target_arch = "wasm32"))] return Some(Task::none()); } "SAVEAS" => return Some(Task::done(Message::SaveAs)), @@ -243,7 +244,13 @@ impl OpenCADStudio { } } #[cfg(target_arch = "wasm32")] - self.archive_web(i, cmd); + { + // The drawing serialization runs on the serializer + // worker; the ZIP assembly + download continue in the + // completion message (`archive_web` below). + let task = self.archive_web(i, cmd); + return Some(Task::batch([task, self.finish_dispatch(cmd)])); + } } "EXIT" | "QUIT" => { @@ -564,12 +571,12 @@ fn parse_background_color(args: &[&str]) -> Option<[f32; 4]> { // silently dropped, the web counterpart of native's missing-file skip. #[cfg(target_arch = "wasm32")] impl OpenCADStudio { - pub(in crate::app) fn archive_web(&mut self, i: usize, cmd: &str) { + pub(in crate::app) fn archive_web(&mut self, i: usize, cmd: &str) -> Task { let Some(src) = self.tabs[i].current_path.clone() else { self.command_line.push_error( crate::t!("ARCHIVE: save the drawing first (it has no file path yet).").as_ref(), ); - return; + return Task::none(); }; let stem = src .file_stem() @@ -593,20 +600,12 @@ impl OpenCADStudio { self.stamp_header_sysvars(i); self.sync_solid_models_for_save(i); self.tabs[i].scene.sync_native_parametric_graph(); - // The drawing itself: the same serialization a web Save performs - // (native copies the last-saved file; the web build has none, so the - // current document is re-serialized). - let document = self.tabs[i].scene.document_for_save(); - let drawing = match crate::io::save_to_bytes(&document, &ext, version) { - Ok(bytes) => bytes, - Err(e) => { - self.command_line.push_error( - crate::tf!("{cmd}: cannot package the drawing ({e}).").as_ref(), - ); - return; - } - }; - let mut entries: Vec<(String, Vec)> = vec![(filename, drawing)]; + + // The dependency set (xrefs, raster images, PDF underlays, plot + // tables) is collected here, at command time — cheap in-memory + // registry reads — so the transmittal matches the drawing snapshot + // serialized below even if the tab changes while the writer runs. + let mut deps: Vec<(String, Vec)> = Vec::new(); let mut skipped: Vec = Vec::new(); // Xrefs — the block-record walk native copies; the bytes come from @@ -619,7 +618,7 @@ impl OpenCADStudio { .and_then(|key| crate::io::xref::registered_bytes(&key)) { Some(bytes) => push_entry( - &mut entries, + &mut deps, transmit_file_name(&br.xref_path), (*bytes).clone(), ), @@ -636,7 +635,7 @@ impl OpenCADStudio { } match crate::scene::model::image_model::registered_source_bytes(&img.file_path) { Some(bytes) => push_entry( - &mut entries, + &mut deps, transmit_file_name(&img.file_path), (*bytes).clone(), ), @@ -655,7 +654,7 @@ impl OpenCADStudio { } match crate::scene::model::pdf_raster::registered_source_bytes(&def.file_path) { Some(bytes) => push_entry( - &mut entries, + &mut deps, transmit_file_name(&def.file_path), (*bytes).clone(), ), @@ -690,12 +689,60 @@ impl OpenCADStudio { continue; } match crate::io::plot_style::session_table(&name) { - Some(bytes) => push_entry(&mut entries, name, (*bytes).clone()), + Some(bytes) => push_entry(&mut deps, name, (*bytes).clone()), None => push_skipped(&mut skipped, &name), } } + // The drawing itself: the same worker-routed serialization a web + // Save performs (native copies the last-saved file; the web build + // has none, so the current document is re-serialized). The ZIP + // assembly and download are cheap and stay on the main thread — + // they continue in the completion message. let zip_name = format!("{stem}_transmit.zip"); + let prepared = + crate::io::prepare_document_for_save(self.tabs[i].scene.document_for_save(), version); + let cmd = cmd.to_string(); + Task::perform( + async move { crate::io::serialize_prepared_document(&prepared, &ext).await }, + move |result| Message::WebArchiveSerialized { + cmd, + zip_name, + filename, + deps, + skipped, + result, + }, + ) + } + + /// Web ARCHIVE completion: the serializer worker (or the inline + /// fallback) produced the drawing bytes. Assemble the transmittal ZIP — + /// the drawing entry first, then the dependencies under the same + /// name-collision rule the synchronous pass used — download it and + /// report, including the skipped-dependency lines. + pub(in crate::app) fn on_web_archive_serialized( + &mut self, + cmd: String, + zip_name: String, + filename: String, + deps: Vec<(String, Vec)>, + skipped: Vec, + result: Result, String>, + ) -> Task { + let drawing = match result { + Ok(bytes) => bytes, + Err(e) => { + self.command_line.push_error( + crate::tf!("{cmd}: cannot package the drawing ({e}).").as_ref(), + ); + return Task::none(); + } + }; + let mut entries: Vec<(String, Vec)> = vec![(filename, drawing)]; + for (name, data) in deps { + push_entry(&mut entries, name, data); + } match build_zip(&entries) { Ok(zip) => { crate::sys::download_bytes(&zip_name, &zip); @@ -717,6 +764,7 @@ impl OpenCADStudio { .as_ref(), ); } + Task::none() } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 8bc0156ad..c2bd7123e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1607,6 +1607,25 @@ pub(super) struct PendingExternalChange { set_current_path: bool, } +/// One tab's slot in a web SAVEALL round (`saveall_web`): the tab identity, +/// download name and race-guard markers snapshot when serialization started, +/// plus the writer result — the future fills `result` in before the +/// completion message is built. The web counterpart of native's +/// `SaveOutcome`, minus the path/continuation bookkeeping SAVEALL never had. +#[derive(Debug, Clone)] +#[cfg(target_arch = "wasm32")] +pub struct WebSaveallEntry { + tab_id: u64, + path: PathBuf, + filename: String, + ext: String, + version: codec::DxfVersion, + geometry_epoch: u64, + edit_revision: u64, + camera_generation: u64, + result: Result, String>, +} + #[derive(Debug, Clone)] #[cfg(not(target_arch = "wasm32"))] pub struct SaveOutcome { @@ -2666,6 +2685,38 @@ pub enum Message { camera_generation: u64, result: Result, String>, }, + /// Web WBLOCK: the serializer worker (or the inline fallback) produced + /// the block document bytes; download them under `filename`, then run + /// the same command-line bookkeeping as `WblockWriteFinished`. + #[cfg(target_arch = "wasm32")] + WebWblockSerialized { + block_name: String, + path: std::path::PathBuf, + filename: String, + result: Result, String>, + }, + /// Web SAVEALL round completion: one entry per tab serialized on the + /// worker (or the inline fallback). Downloads fire per entry; dirty + /// flags clear only for tabs whose serialized snapshot is still current + /// (the same race guard a single web Save uses). + #[cfg(target_arch = "wasm32")] + WebSaveallSerialized { + skipped: usize, + results: Vec, + }, + /// Web ARCHIVE/ETRANSMIT completion: the serializer worker (or the + /// inline fallback) produced the drawing bytes; assemble the transmittal + /// ZIP from the dependencies collected at command time, download it and + /// report — including the skipped-dependency lines. + #[cfg(target_arch = "wasm32")] + WebArchiveSerialized { + cmd: String, + zip_name: String, + filename: String, + deps: Vec<(String, Vec)>, + skipped: Vec, + result: Result, String>, + }, /// Retry the failed save after the other application releases the file. #[cfg(not(target_arch = "wasm32"))] SaveFileInUseRetry, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 8d59055b9..9b8eb5f70 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -2299,54 +2299,93 @@ impl OpenCADStudio { let i = self.active_tab; let document = self.tabs[i].scene.document_for_save(); let handles: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect(); - let worker_name = block_name.clone(); - let worker_path = path.clone(); - background_task( - crate::t!("WBLOCK save"), - move || { - let document = if worker_name == "*" { - crate::modules::insert::wblock::extract_entities_to_doc(&document, &handles) - } else { - crate::modules::insert::wblock::extract_block_to_doc(&document, &worker_name) - } - .map_err(|e| e.to_string())?; - #[cfg(not(target_arch = "wasm32"))] - { + // Native: extraction and the file write both run on a background + // thread, unchanged. + #[cfg(not(target_arch = "wasm32"))] + { + let worker_name = block_name.clone(); + let worker_path = path.clone(); + background_task( + crate::t!("WBLOCK save"), + move || { + let document = if worker_name == "*" { + crate::modules::insert::wblock::extract_entities_to_doc(&document, &handles) + } else { + crate::modules::insert::wblock::extract_block_to_doc(&document, &worker_name) + } + .map_err(|e| e.to_string())?; crate::io::save(&document, &worker_path).map_err(|e| e.to_string()) + }, + move |result| Message::WblockWriteFinished(block_name, path, result), + ) + } + // Web: extraction is pure document manipulation (it reads the clone + // above and builds a fresh document) — run it inline on the main + // thread as the prepare step, together with the shared save + // preparation, then hand the DWG/DXF writer pass to the serializer + // worker the way the web Save flow does + // (`serialize_prepared_document` falls back inline when the worker + // cannot help). Completion (`WebWblockSerialized`) delivers the + // browser download plus the same `WblockWriteFinished` command-line + // bookkeeping. + #[cfg(target_arch = "wasm32")] + { + let extracted = if block_name == "*" { + crate::modules::insert::wblock::extract_entities_to_doc(&document, &handles) + } else { + crate::modules::insert::wblock::extract_block_to_doc(&document, &block_name) + } + .map_err(|e| e.to_string()); + let document = match extracted { + Ok(document) => document, + // Same reporting the background path used for a failed + // extraction — straight through the existing message. + Err(error) => { + return Task::done(Message::WblockWriteFinished( + block_name, + path, + Err(error), + )) } - // Web: no filesystem behind the name-only path — serialize to - // bytes (the same document version `save` would pick, format - // from the chosen file name) and hand the browser a download, - // the way the other web exports do. - #[cfg(target_arch = "wasm32")] - { - let ext = worker_path - .extension() - .map(|ext| ext.to_string_lossy().into_owned()) - .unwrap_or_else(|| "dwg".to_string()); - let bytes = crate::io::save_to_bytes(&document, &ext, document.version)?; - let name = worker_path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| "block.dwg".into()); - crate::sys::download_bytes(&name, &bytes); - Ok(()) - } - }, - move |result| Message::WblockWriteFinished(block_name, path, result), - ) + }; + let ext = path + .extension() + .map(|ext| ext.to_string_lossy().into_owned()) + .unwrap_or_else(|| "dwg".to_string()); + let filename = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "block.dwg".into()); + let version = document.version; + let prepared = crate::io::prepare_document_for_save(document, version); + Task::perform( + async move { crate::io::serialize_prepared_document(&prepared, &ext).await }, + move |result| Message::WebWblockSerialized { + block_name, + path, + filename, + result, + }, + ) + } } /// Web SAVEALL: every dirty tab that already has a name-only path is - /// serialized and downloaded in one pass — the same - /// `save_to_bytes` + `download_bytes` route a single Save takes. The - /// browser may ask once for permission to download several files, which - /// is the platform's price. Clean tabs are skipped (re-downloading - /// identical bytes is noise); tabs with no path still need SAVEAS. + /// serialized and downloaded in one pass — the worker-routed counterpart + /// of a single web Save. Each tab is prepared (state sync + clone + + /// style/dimension bake) once up front on the main thread, then the + /// DWG/DXF writer passes run serially on the serializer worker — the + /// same single-future structure the autosave round uses. The browser may + /// ask once for permission to download several files, which is the + /// platform's price. Clean tabs are skipped (re-downloading identical + /// bytes is noise); tabs with no path still need SAVEAS. Completion + /// (`on_web_saveall_serialized`) fires the downloads and clears each + /// tab's dirty flag — but only if the tab did not change while its + /// writer ran. #[cfg(target_arch = "wasm32")] - pub(in crate::app) fn saveall_web(&mut self) { - let mut saved = 0usize; + pub(in crate::app) fn saveall_web(&mut self) -> Task { let mut skipped = 0usize; + let mut pending: Vec<(crate::app::WebSaveallEntry, codec::CadDocument)> = Vec::new(); for t in 0..self.tabs.len() { if self.tabs[t].is_start { continue; @@ -2373,19 +2412,91 @@ impl OpenCADStudio { .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| path.to_string_lossy().into_owned()); - match crate::io::save_to_bytes(&self.tabs[t].scene.document, &ext, version) { - Ok(bytes) => { - crate::sys::download_bytes(&filename, &bytes); - self.tabs[t].scene.document.version = version; - self.tabs[t].dirty = false; - self.tabs[t].recovery_save_as_required = false; - saved += 1; + // Race guard (the same snapshot a single web Save takes): the + // markers are read after the state syncs, so completion only + // clears the dirty flag if the tab did not change while the + // writer ran off-thread. + let tab = &self.tabs[t]; + let entry = crate::app::WebSaveallEntry { + tab_id: tab.id, + path, + filename, + ext, + version, + geometry_epoch: tab.scene.geometry_epoch, + edit_revision: tab.edit_revision, + camera_generation: tab.scene.camera_generation, + // Placeholder — the serialization future below overwrites + // it before the completion message is built. + result: Ok(Vec::new()), + }; + pending.push(( + entry, + crate::io::prepare_document_for_save(tab.scene.document_for_save(), version), + )); + } + if pending.is_empty() { + self.report_saveall_web(0, skipped); + return Task::none(); + } + Task::perform( + async move { + let mut results = Vec::with_capacity(pending.len()); + for (mut entry, prepared) in pending { + entry.result = + crate::io::serialize_prepared_document(&prepared, &entry.ext).await; + results.push(entry); } - Err(error) => self - .command_line - .push_error(crate::tf!("SAVEALL: {} failed: {error}", path.display()).as_ref()), + results + }, + move |results| Message::WebSaveallSerialized { skipped, results }, + ) + } + + /// Web SAVEALL completion: the serializer worker (or inline fallback) + /// produced each tab's bytes. The download is the only artifact of a + /// tab's save — fire it even if the tab was closed while serialization + /// was in flight. Bookkeeping matches the old synchronous pass (version + /// stamped, recovery flag cleared, dirty cleared under the race guard, + /// one error line per failed tab), then the shared summary line. + #[cfg(target_arch = "wasm32")] + pub(super) fn on_web_saveall_serialized( + &mut self, + skipped: usize, + results: Vec, + ) -> Task { + let mut saved = 0usize; + for entry in results { + match entry.result { + Ok(bytes) => { + crate::sys::download_bytes(&entry.filename, &bytes); + saved += 1; + let Some(i) = self.tabs.iter().position(|tab| tab.id == entry.tab_id) else { + continue; + }; + self.tabs[i].scene.document.version = entry.version; + self.tabs[i].recovery_save_as_required = false; + let snapshot_is_current = self.tabs[i].scene.geometry_epoch + == entry.geometry_epoch + && self.tabs[i].edit_revision == entry.edit_revision + && self.tabs[i].scene.camera_generation == entry.camera_generation; + if snapshot_is_current { + self.tabs[i].dirty = false; + } + } + Err(error) => self.command_line.push_error( + crate::tf!("SAVEALL: {} failed: {error}", entry.path.display()).as_ref(), + ), } } + self.report_saveall_web(saved, skipped); + Task::none() + } + + /// The SAVEALL summary line — shared by the nothing-queued early return + /// and the worker completion. + #[cfg(target_arch = "wasm32")] + fn report_saveall_web(&mut self, saved: usize, skipped: usize) { self.command_line.push_output( crate::tf!( "SAVEALL: saved {saved} drawing(s){}.", @@ -2729,7 +2840,12 @@ impl OpenCADStudio { fn finish_web_save(&mut self, saved: bool) -> Task { if self.save_dialog_for_unsaved { if saved { - if let Some(crate::app::PendingClose::Tab(tab_id)) = self.pending_close.take() { + // Only a Tab pending is consumed here; a Quit pending must + // survive to continue the quit after this save. + if let Some(crate::app::PendingClose::Tab(tab_id)) = self + .pending_close + .take_if(|pending| matches!(pending, crate::app::PendingClose::Tab(_))) + { let continuation = self.update(Message::TabClose(tab_id)); let rest = self.continue_tab_close_queue(); return Task::batch([continuation, rest]); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 462192a31..a14f69bcc 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1676,6 +1676,27 @@ impl OpenCADStudio { Task::none() } + // Web WBLOCK completion: the download is the only artifact of + // the save — fire it even if the source tab was closed while + // serialization ran — then reuse the WblockWriteFinished + // bookkeeping for the command-line report. + #[cfg(target_arch = "wasm32")] + Message::WebWblockSerialized { + block_name, + path, + filename, + result, + } => { + if let Ok(bytes) = &result { + crate::sys::download_bytes(&filename, bytes); + } + self.update(Message::WblockWriteFinished( + block_name, + path, + result.map(|_| ()), + )) + } + Message::TableInsertStyle(value) => self.on_table_insert_style(value), Message::TableInsertField(field) => self.on_table_insert_field(field), Message::TableInsertApply => self.on_table_insert_apply(), @@ -10157,6 +10178,21 @@ impl OpenCADStudio { result, ), + #[cfg(target_arch = "wasm32")] + Message::WebSaveallSerialized { skipped, results } => { + self.on_web_saveall_serialized(skipped, results) + } + + #[cfg(target_arch = "wasm32")] + Message::WebArchiveSerialized { + cmd, + zip_name, + filename, + deps, + skipped, + result, + } => self.on_web_archive_serialized(cmd, zip_name, filename, deps, skipped, result), + #[cfg(not(target_arch = "wasm32"))] Message::SaveFileInUseRetry => self.on_save_file_in_use_retry(), diff --git a/src/io/xref.rs b/src/io/xref.rs index e0cc9aaac..ef3f28742 100644 --- a/src/io/xref.rs +++ b/src/io/xref.rs @@ -122,6 +122,11 @@ fn parsed_cache() -> &'static Mutex> { /// drawing's xref references will be matched against. Registering supersedes /// any eager parse cached for the same file, so a re-pick (or a drop that /// finally supplies a missing reference) reloads from the new bytes. +/// +/// Names are bare on the web, so two DISTINCT same-named drawings cannot be +/// told apart: the newest registration wins for resolution, ARCHIVE, and +/// XRELOAD alike — consistent with what the drawing itself displays, but a +/// fundamental browser limit (native distinguishes them by folder). #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // mirrors image_model::register_source pub fn register_source(name: &str, bytes: Arc>) { { @@ -3570,6 +3575,47 @@ mod web_registry_tests { ); } + /// Beyond SOURCE_CAP registrations the oldest leave first, and a + /// re-registration of a live name neither evicts nor duplicates it. + #[test] + fn source_registry_evicts_oldest_beyond_the_cap() { + for i in 0..(SOURCE_CAP + 2) { + register_source(&format!("f{i}.dwg"), Arc::new(vec![i as u8])); + } + let (map_len, order_len, has_newest, has_oldest) = { + // Scoped: the guard must drop before register_source locks again. + let cache = source_cache() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + ( + cache.0.len(), + cache.1.len(), + cache.0.contains_key(&format!("f{}.dwg", SOURCE_CAP + 1)), + cache.0.contains_key("f0.dwg"), + ) + }; + assert_eq!(map_len, SOURCE_CAP); + assert_eq!(order_len, SOURCE_CAP); + assert!(has_newest); + assert!(!has_oldest); + // Re-registering a live name refreshes its bytes without reordering. + register_source(&format!("f{}.dwg", SOURCE_CAP + 1), Arc::new(vec![255])); + let (order_len, refreshed) = { + let cache = source_cache() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + ( + cache.1.len(), + cache + .0 + .get(&format!("f{}.dwg", SOURCE_CAP + 1)) + .map(|b| b.to_vec()), + ) + }; + assert_eq!(order_len, SOURCE_CAP, "no duplicate order entry"); + assert_eq!(refreshed, Some(vec![255])); + } + #[test] fn register_source_supersedes_a_cached_parse_of_the_same_file() { let name = "Registry-Supersede-Plan.dwg";