From d0d946a56837e60d24f22141ce93084eca1a7f54 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Sat, 12 Sep 2026 21:06:03 +0100 Subject: [PATCH] feat(xref): detach, unload, reload, bind, overlay, and path operations Detach, unload, reload preserving host layer overrides, bind, overlay, and saved-path editing, with per-tab session state and a read-only web list. --- locales/el-GR/opencadstudio.ftl | 72 + locales/en-US/opencadstudio.ftl | 72 + src/app/command_driver.rs | 14 +- src/app/commands/blocks.rs | 1038 ++++++++++++- src/app/commands/mod.rs | 1 + src/app/document.rs | 13 + src/app/mod.rs | 11 + src/app/update/dialog.rs | 613 +++++++- src/app/update/file.rs | 55 +- src/app/update/mod.rs | 24 + src/app/view/modal.rs | 8 +- src/io/xref.rs | 2492 ++++++++++++++++++++++++++++--- src/io/xref_model.rs | 339 +++-- src/locale_catalog.rs | 73 + src/modules/insert/xattach.rs | 8 +- src/ui/window/xref_manager.rs | 522 +++++-- 16 files changed, 4856 insertions(+), 499 deletions(-) diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 652a44b80..15d5eda19 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -1971,6 +1971,8 @@ file = .warning-corrupt-entities-dropped-parser-junk-b = Προειδοποίηση: απορρίφθηκαν __ocs_fmt_0__ κατεστραμμένες οντότητες (άχρηστα δεδομένα αναλυτή — εσφαλμένα κάθετα διανύσματα / πλήθη) .warning-corrupt-xref-entities-dropped = Προειδοποίηση: απορρίφθηκαν __ocs_fmt_0__ κατεστραμμένες οντότητες xref .xref-loaded = XREF Φορτώθηκε το "__ocs_fmt_0__" + .xref-recovered-with-warnings = XREF Recovered with warnings: "__ocs_fmt_0__" + .xref-recovery-failed = XREF Recovery failed: "__ocs_fmt_0__" (__ocs_fmt_1__) .cmd-cannot-create-folder-e = __ocs_fmt_0__: δεν είναι δυνατή η δημιουργία φακέλου (__ocs_fmt_1__). .cmd-packaged-copied-file-s-into = __ocs_fmt_0__: συσκευάστηκαν __ocs_fmt_1__ αρχείο(α) στο __ocs_fmt_2__ .error-external-change-checks-remain-active = __ocs_fmt_0__. Οι έλεγχοι εξωτερικών αλλαγών παραμένουν ενεργοί. @@ -3133,6 +3135,76 @@ insert = .xref-no-external-references-in-this-drawing = XREF Δεν υπάρχουν εξωτερικές αναφορές σε αυτό το σχέδιο. .xref-reloaded = XREF Επαναφορτώθηκε το "__ocs_fmt_0__" .xref-save-the-drawing-first-to-resolve-relativ = XREF Αποθηκεύστε πρώτα το σχέδιο για να επιλυθούν οι σχετικές διαδρομές XREF. + .xref-set-to-overlay = XREF: "__ocs_fmt_0__" set to Overlay. + .xref-bound = XREF: bound "__ocs_fmt_0__". + .xref-bound-with-unremapped-handles = XREF: bound "__ocs_fmt_0__" with __ocs_fmt_1__ unremapped style handles (see bind limitations). + .xref-cannot-bind-nested-reference = XREF: cannot bind nested reference '__ocs_fmt_0__'. Bind it in its host drawing. + .xref-cannot-bind = XREF: cannot bind "__ocs_fmt_0__": __ocs_fmt_1__. + .xref-pdf-bind-unavailable = XREF: PDF bind (vector import) is not available in this version. + .xref-cannot-detach-nested-reference = XREF: cannot detach nested reference '__ocs_fmt_0__'. Detach it in its host drawing. + .xref-cannot-make-path-relative = XREF: cannot make path relative across drives. + .xref-detached = XREF: detached "__ocs_fmt_0__". + .xref-no-loaded-reference-with-that-key = XREF: no loaded reference with that key. + .xref-no-references-match = XREF: no references match '__ocs_fmt_0__'. + .xref-set-to-attach = XREF: "__ocs_fmt_0__" set to Attach. + .xref-cannot-unload-nested-reference = XREF: cannot unload nested reference '__ocs_fmt_0__'. Unload it in its host drawing. + .xref-cannot-overlay-nested-reference = XREF: cannot overlay nested reference '__ocs_fmt_0__'. Overlay it in its host drawing. + .xref-cannot-reload-nested-reference = XREF: cannot reload nested reference '__ocs_fmt_0__'. Reload it in its host drawing. + .xref-cannot-attach-nested-reference = XREF: cannot attach nested reference '__ocs_fmt_0__'. Attach it in its host drawing. + .xref-cannot-set-path-type-nested-reference = XREF: cannot set path type for nested reference '__ocs_fmt_0__'. Set it in its host drawing. + .reload-applies-to-drawing-references-only = __ocs_fmt_0__: reload applies to drawing references only. + .xref-manager-title = Reference Manager + .xref-self-attach-blocked = XATTACH: cannot attach the host drawing into itself. + .xref-overlays-drawing-references-only = XREF: overlays apply to drawing references only. + .xref-path-set-for = XREF: Path set for "__ocs_fmt_0__" — Reload to apply. + .xref-unloaded = XREF: unloaded "__ocs_fmt_0__". + .xref-unknown-option = XREF: unknown option '__ocs_fmt_0__'. Options: ? Reload Unload Detach Path Pathtype Bind Overlay Attach + .xref-updated-references = XREF: updated __ocs_fmt_0__ reference(s). + .xref-reloaded-with-repairs = XREF Reloaded with repairs: "__ocs_fmt_0__" + .xref-reload-failed = XREF Reload failed: "__ocs_fmt_0__" (__ocs_fmt_1__) + .usage-xref-path-pattern = Usage: XREF Path + .usage-xref-path-find = Usage: XREF Path Find + .usage-xref-pathtype = Usage: XREF Pathtype [pattern] + .xref-missing-open-xrefman = __ocs_fmt_0__ reference(s) not found — open the reference manager with EXTERNALREFERENCES. + .xref-nested-not-rendered = (nested — not rendered) + .change-path = Change Path: + .date = Date + .details = Details + .xref-detach = Detach + .failed = Failed + .xref-file-attach-is-not-available-on-web = File attach is not available on web — the reference list below is read-only. + .xref-file-name = File name + .found-at = Found At + .xref-help = Help + .image = Image + .xref-nested-references-are-read-only = Nested references are read-only — edit them in their host drawing. + .not-found = Not found + .orphaned = Orphaned + .unreferenced = Unreferenced + .xref-overlay = Overlay + .path = Path: + .pdf = PDF + .xref-reference-manager-select-rows = Reference Manager — select rows, then Detach, Unload, Reload, Overlay, or a Change Path mode. + .xref-reference-manager-select-rows-bind = Reference Manager — select rows, then Detach, Unload, Reload, Bind, Overlay, or a Change Path mode. + .xref-bind = Bind + .xref-refresh = Refresh + .relative = Relative + .xref-reload = Reload + .xref-replace = Replace: + .saved-path = Saved Path + .xref-select-a-reference-first = Select a reference first. + .xref-select-a-reference-to-inspect-its = Select a reference to inspect its details + .xref-set-path = Set Path + .stale = Stale + .toggle-list-tree = Toggle list/tree + .tree = Tree + .xref-type-a-new-path-first = Type a new path first. + .xref-type-the-path-prefix-to-find-first = Type the path prefix to find first. + .xref-unload = Unload + .unloaded = Unloaded + .xref-new-path-for-selection = new path for selection + .xref-new-path-prefix = new path prefix + .xref-old-path-prefix = old path prefix .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ αντικείμενο(α) αντιγράφηκαν στο πρόχειρο (βάση __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = __ocs_fmt_0__ αντικείμενο(α) αντιγράφηκαν στο πρόχειρο. .object-s-cut-to-clipboard = __ocs_fmt_0__ αντικείμενο(α) αποκόπηκαν στο πρόχειρο. diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index a0d1f0b71..51f574526 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1972,6 +1972,8 @@ file = .warning-corrupt-entities-dropped-parser-junk-b = Warning: __ocs_fmt_0__ corrupt entities dropped (parser junk — bad normals / counts) .warning-corrupt-xref-entities-dropped = Warning: __ocs_fmt_0__ corrupt xref entities dropped .xref-loaded = XREF Loaded "__ocs_fmt_0__" + .xref-recovered-with-warnings = XREF Recovered with warnings: "__ocs_fmt_0__" + .xref-recovery-failed = XREF Recovery failed: "__ocs_fmt_0__" (__ocs_fmt_1__) .cmd-cannot-create-folder-e = __ocs_fmt_0__: cannot create folder (__ocs_fmt_1__). .cmd-packaged-copied-file-s-into = __ocs_fmt_0__: packaged __ocs_fmt_1__ file(s) into __ocs_fmt_2__ .error-external-change-checks-remain-active = __ocs_fmt_0__. External-change checks remain active. @@ -3134,6 +3136,76 @@ insert = .xref-no-external-references-in-this-drawing = XREF No external references in this drawing. .xref-reloaded = XREF Reloaded "__ocs_fmt_0__" .xref-save-the-drawing-first-to-resolve-relativ = XREF Save the drawing first to resolve relative XREF paths. + .xref-set-to-overlay = XREF: "__ocs_fmt_0__" set to Overlay. + .xref-set-to-attach = XREF: "__ocs_fmt_0__" set to Attach. + .xref-cannot-unload-nested-reference = XREF: cannot unload nested reference '__ocs_fmt_0__'. Unload it in its host drawing. + .xref-cannot-overlay-nested-reference = XREF: cannot overlay nested reference '__ocs_fmt_0__'. Overlay it in its host drawing. + .xref-cannot-reload-nested-reference = XREF: cannot reload nested reference '__ocs_fmt_0__'. Reload it in its host drawing. + .xref-cannot-attach-nested-reference = XREF: cannot attach nested reference '__ocs_fmt_0__'. Attach it in its host drawing. + .xref-cannot-set-path-type-nested-reference = XREF: cannot set path type for nested reference '__ocs_fmt_0__'. Set it in its host drawing. + .reload-applies-to-drawing-references-only = __ocs_fmt_0__: reload applies to drawing references only. + .xref-bound = XREF: bound "__ocs_fmt_0__". + .xref-bound-with-unremapped-handles = XREF: bound "__ocs_fmt_0__" with __ocs_fmt_1__ unremapped style handles (see bind limitations). + .xref-cannot-bind-nested-reference = XREF: cannot bind nested reference '__ocs_fmt_0__'. Bind it in its host drawing. + .xref-cannot-bind = XREF: cannot bind "__ocs_fmt_0__": __ocs_fmt_1__. + .xref-pdf-bind-unavailable = XREF: PDF bind (vector import) is not available in this version. + .xref-cannot-detach-nested-reference = XREF: cannot detach nested reference '__ocs_fmt_0__'. Detach it in its host drawing. + .xref-cannot-make-path-relative = XREF: cannot make path relative across drives. + .xref-detached = XREF: detached "__ocs_fmt_0__". + .xref-no-loaded-reference-with-that-key = XREF: no loaded reference with that key. + .xref-no-references-match = XREF: no references match '__ocs_fmt_0__'. + .xref-manager-title = Reference Manager + .xref-self-attach-blocked = XATTACH: cannot attach the host drawing into itself. + .xref-overlays-drawing-references-only = XREF: overlays apply to drawing references only. + .xref-path-set-for = XREF: Path set for "__ocs_fmt_0__" — Reload to apply. + .xref-unloaded = XREF: unloaded "__ocs_fmt_0__". + .xref-unknown-option = XREF: unknown option '__ocs_fmt_0__'. Options: ? Reload Unload Detach Path Pathtype Bind Overlay Attach + .xref-updated-references = XREF: updated __ocs_fmt_0__ reference(s). + .xref-reloaded-with-repairs = XREF Reloaded with repairs: "__ocs_fmt_0__" + .xref-reload-failed = XREF Reload failed: "__ocs_fmt_0__" (__ocs_fmt_1__) + .usage-xref-path-pattern = Usage: XREF Path + .usage-xref-path-find = Usage: XREF Path Find + .usage-xref-pathtype = Usage: XREF Pathtype [pattern] + .xref-missing-open-xrefman = __ocs_fmt_0__ reference(s) not found — open the reference manager with EXTERNALREFERENCES. + .xref-nested-not-rendered = (nested — not rendered) + .change-path = Change Path: + .date = Date + .details = Details + .xref-detach = Detach + .failed = Failed + .xref-file-attach-is-not-available-on-web = File attach is not available on web — the reference list below is read-only. + .xref-file-name = File name + .found-at = Found At + .xref-help = Help + .image = Image + .xref-nested-references-are-read-only = Nested references are read-only — edit them in their host drawing. + .not-found = Not found + .orphaned = Orphaned + .unreferenced = Unreferenced + .xref-overlay = Overlay + .path = Path: + .pdf = PDF + .xref-reference-manager-select-rows = Reference Manager — select rows, then Detach, Unload, Reload, Overlay, or a Change Path mode. + .xref-reference-manager-select-rows-bind = Reference Manager — select rows, then Detach, Unload, Reload, Bind, Overlay, or a Change Path mode. + .xref-bind = Bind + .xref-refresh = Refresh + .relative = Relative + .xref-reload = Reload + .xref-replace = Replace: + .saved-path = Saved Path + .xref-select-a-reference-first = Select a reference first. + .xref-select-a-reference-to-inspect-its = Select a reference to inspect its details + .xref-set-path = Set Path + .stale = Stale + .toggle-list-tree = Toggle list/tree + .tree = Tree + .xref-type-a-new-path-first = Type a new path first. + .xref-type-the-path-prefix-to-find-first = Type the path prefix to find first. + .xref-unload = Unload + .unloaded = Unloaded + .xref-new-path-for-selection = new path for selection + .xref-new-path-prefix = new path prefix + .xref-old-path-prefix = old path prefix .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ object(s) copied to clipboard (base __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = __ocs_fmt_0__ object(s) copied to clipboard. .object-s-cut-to-clipboard = __ocs_fmt_0__ object(s) cut to clipboard. diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 3e15e362e..1465d86dc 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -2301,7 +2301,7 @@ impl OpenCADStudio { self.sync_dyn_fields(); self.refresh_area_preview(i); } - CmdResult::CommitAndExit(entity) => { + CmdResult::CommitAndExit(mut entity) => { // For XATTACH: ensure the xref block definition exists before // committing the INSERT entity that references it. // Extract path early to avoid borrow conflicts. @@ -2332,9 +2332,7 @@ impl OpenCADStudio { &path, host_base.as_deref(), ) { - self.command_line.push_error( - "XATTACH: cannot attach the host drawing into itself.", - ); + self.command_line.push_error(crate::t!("XATTACH: cannot attach the host drawing into itself.").as_ref()); self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; @@ -2342,11 +2340,17 @@ impl OpenCADStudio { return Task::none(); } } - crate::modules::insert::xattach::prepare_xref_block( + let prepared_name = crate::modules::insert::xattach::prepare_xref_block( &mut self.tabs[i].scene, &path, host_base.as_deref(), ); + // `prepare_xref_block` may suffix a colliding stem. The + // command was created before that collision was known, so + // retarget its pending INSERT to the actual new block. + if let acadrust::EntityType::Insert(insert) = &mut entity { + insert.block_name = prepared_name; + } // Resolving the xref merged its layer / linetype tables // into the document — mirror them into the Layers panel // and ribbon dropdowns now, not on the next reopen (#407). diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 5b865745f..17c53cc8c 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -1,9 +1,5 @@ use super::*; -/// Placeholder for XREF reference operations landing in Tasks 8/9. -/// Each arm formats it with its own op name via `NOT_YET.replace("", …)`. -const NOT_YET: &str = "XREF: '' ships with reference operations (Task 8/9)."; - impl OpenCADStudio { /// Shared per-reference status reporting for XRELOAD and XREF Reload. /// Extracted verbatim from the XRELOAD arm — no behavior change. @@ -584,8 +580,11 @@ impl OpenCADStudio { return Some(Task::done(Message::ToggleXrefManager)); } - cmd if cmd.eq_ignore_ascii_case("XREF") || cmd.to_ascii_uppercase().starts_with("XREF ") => { // XREF sub-option dispatcher (Task 5). The verb is + cmd if cmd.eq_ignore_ascii_case("XREF") || cmd.to_ascii_uppercase().starts_with("XREF ") || cmd.eq_ignore_ascii_case("-XREF") || cmd.to_ascii_uppercase().starts_with("-XREF ") => { // XREF sub-option dispatcher (Task 5). The verb is // case-insensitive; arguments split on whitespace. + // `-XREF` is accepted as an alias for industry muscle memory + // (modern CAD opens the palette on `XREF`); bare `XREF` keeps + // this repo's legacy list output for compatibility. let rest = cmd .splitn(2, char::is_whitespace) .nth(1) @@ -593,21 +592,30 @@ impl OpenCADStudio { .trim(); let mut parts = rest.split_whitespace(); let op = parts.next().unwrap_or(""); + #[cfg(target_arch = "wasm32")] + if !op.is_empty() && op != "?" { + self.command_line.push_error(crate::t!( + "Reference changes are not available on web — the reference list is read-only." + ).as_ref()); + return Some(self.finish_dispatch(cmd)); + } if op.is_empty() || op == "?" { - // List every reference via collect_entries. The base dir - // is the drawing's parent (or "." unsaved); nothing is - // treated as unloaded here. Header lines stay - // byte-identical to the legacy listing; each entry line - // gains an " [Attach]" / " [Overlay]" type suffix. + // List every reference via collect_entries_with_prev. The + // base dir is the drawing's parent (or "." unsaved); the + // tab's session sets keep CLI and palette in agreement. + // Header lines stay byte-identical to the legacy listing; + // each entry line gains an " [Attach]" / " [Overlay]" + // type suffix. let base_dir: std::path::PathBuf = self.tabs[i] .current_path .as_ref() .and_then(|p| p.parent().map(|p| p.to_path_buf())) .unwrap_or_else(|| std::path::PathBuf::from(".")); - let entries = crate::io::xref::collect_entries( + let entries = crate::io::xref::collect_entries_with_prev( &self.tabs[i].scene.document, &base_dir, - &std::collections::HashSet::::new(), + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, ); if entries.is_empty() { self.command_line @@ -635,7 +643,7 @@ impl OpenCADStudio { let pattern = parts.next().unwrap_or("").trim(); // A concrete pattern that matches no entry resolves // nothing: pre-check the entry list (same base_dir + - // empty unloaded set as the list path) and return early + // session sets as the list path) and return early // without touching resolve_xrefs — no mutation, no // populate/refresh. Bare Reload and `*` keep the // resolve-everything path below. @@ -645,10 +653,11 @@ impl OpenCADStudio { .as_ref() .and_then(|p| p.parent().map(|p| p.to_path_buf())) .unwrap_or_else(|| std::path::PathBuf::from(".")); - let entries = crate::io::xref::collect_entries( + let entries = crate::io::xref::collect_entries_with_prev( &self.tabs[i].scene.document, &base_dir, - &std::collections::HashSet::::new(), + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, ); let any = entries .iter() @@ -663,10 +672,94 @@ impl OpenCADStudio { } if let Some(path) = &self.tabs[i].current_path.clone() { if let Some(base_dir) = path.parent() { - let (infos, _dropped) = crate::io::xref::resolve_xrefs( + // Reloaded keys leave the session-unloaded set + // (so the listing agrees) with fresh stat + // baselines on the next palette refresh. + let reload_keys: Vec = { + let entries = + crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); + // Drawing references only (mirrors the palette + // Reload): image/PDF rows keep their flags + // untouched and nested rows report per-entry + // instead of clearing state that resolve + // cannot rebuild. + let mut keys = Vec::new(); + for e in &entries { + let hit = pattern.is_empty() + || pattern == "*" + || crate::io::xref_model::wildcard_match( + &e.name, pattern, + ); + if !hit { + continue; + } + if e.parent_key.is_some() { + self.command_line.push_error(crate::tf!( + "XREF: cannot reload nested reference '{}'. Reload it in its host drawing.", + e.name + ).as_ref()); + } else if e.kind != crate::io::xref_model::RefKind::DwgXref { + self.command_line.push_error(crate::tf!( + "{}: reload applies to drawing references only.", + e.name + ).as_ref()); + } else { + keys.push(e.key); + } + } + keys + }; + // Nothing reloadable (all pattern hits were + // nested/image rows, already reported above): + // return before snapshot + resolve so no empty + // undo entry is pushed and no spurious + // no-match error follows the per-entry reports. + if reload_keys.is_empty() && !pattern.is_empty() && pattern != "*" { + return Some(self.finish_dispatch(cmd)); + } + self.push_undo_snapshot(i, "XREF-RELOAD"); + for key in &reload_keys { + self.tabs[i].xref_unloaded.remove(key); + self.tabs[i].xref_stat_cache.remove(key); + } + let handles: rustc_hash::FxHashSet = self.tabs[i] + .scene + .document + .block_records + .iter() + .filter(|br| reload_keys.contains(&br.handle.value())) + .map(|br| br.handle) + .collect(); + let (infos, _dropped) = crate::io::xref::resolve_xrefs_for_keys( &mut self.tabs[i].scene.document, base_dir, + &handles, ); + // Targeted reload updates only the selected rows. + { + let fresh = + crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); + for e in &fresh { + if !reload_keys.contains(&e.key) { + continue; + } + if e.status == crate::io::xref_model::RefStatus::Loaded { + if let Some(m) = e.modified { + self.tabs[i].xref_stat_cache.insert(e.key, m); + } + } + } + } let matched: Vec<&crate::io::xref::XrefInfo> = if pattern.is_empty() { infos.iter().collect() } else { @@ -687,39 +780,439 @@ impl OpenCADStudio { self.report_xref_status(info); } } - self.tabs[i].scene.populate_hatches_from_document(); - self.tabs[i].scene.populate_images_from_document(); - self.tabs[i].scene.populate_meshes_from_document(); - // The reload may have merged new xref layers / - // linetypes — mirror them into the Layers panel and - // ribbon dropdowns (#407). - self.refresh_layer_panel(); + self.post_ref_op(i); } } else { self.command_line .push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref()); } } else if op.eq_ignore_ascii_case("Unload") { - self.command_line - .push_output(&NOT_YET.replace("", "Unload")); + // Unload (empty/* = all): drop merged content + // per reference AND record the key session-unloaded, so + // listings (CLI + palette) agree until a Reload clears it. + let pattern = parts.next().unwrap_or("").trim(); + let base_dir: std::path::PathBuf = self.tabs[i] + .current_path + .as_ref() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let entries = crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + &base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); + let matched: Vec<_> = entries + .iter() + .filter(|e| { + pattern.is_empty() + || pattern == "*" + || crate::io::xref_model::wildcard_match(&e.name, pattern) + }) + .collect(); + if matched.is_empty() { + self.command_line.push_error(crate::tf!( + "XREF: no references match '{}'.", + pattern + ).as_ref()); + } else { + self.push_undo_snapshot(i, "XREF-UNLOAD"); + let mut done = 0usize; + for e in matched { + if e.parent_key.is_some() { + self.command_line.push_error(crate::tf!( + "XREF: cannot unload nested reference '{}'. Unload it in its host drawing.", + e.name + ).as_ref()); + continue; + } + match crate::io::xref::unload_reference( + &mut self.tabs[i].scene.document, + e.key, + ) { + Ok(name) => { + self.command_line.push_output(crate::tf!( + "XREF: unloaded \"{}\".", + name + ).as_ref()); + self.tabs[i].xref_unloaded.add(e.key); + done += 1; + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + } + if done > 0 { + self.post_ref_op(i); + } + } } else if op.eq_ignore_ascii_case("Detach") { - self.command_line - .push_output(&NOT_YET.replace("", "Detach")); + // Detach (empty/* = all). Nested entries are + // rejected per-entry; direct ones erase instances + + // definition + `name|*` dependents. + let pattern = parts.next().unwrap_or("").trim(); + let base_dir: std::path::PathBuf = self.tabs[i] + .current_path + .as_ref() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let entries = crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + &base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); + let matched: Vec<_> = entries + .iter() + .filter(|e| { + pattern.is_empty() + || pattern == "*" + || crate::io::xref_model::wildcard_match(&e.name, pattern) + }) + .collect(); + if matched.is_empty() { + self.command_line.push_error(crate::tf!( + "XREF: no references match '{}'.", + pattern + ).as_ref()); + } else { + self.push_undo_snapshot(i, "XREF-DETACH"); + let mut done = 0usize; + for e in matched { + if e.parent_key.is_some() { + self.command_line.push_error(crate::tf!( + "XREF: cannot detach nested reference '{}'. Detach it in its host drawing.", + e.name + ).as_ref()); + continue; + } + match crate::io::xref::detach_reference( + &mut self.tabs[i].scene.document, + e.key, + ) { + Ok(name) => { + self.command_line.push_output(crate::tf!( + "XREF: detached \"{}\".", + name + ).as_ref()); + self.tabs[i].xref_unloaded.remove(&e.key); + self.tabs[i].xref_stat_cache.remove(&e.key); + done += 1; + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + } + if done > 0 { + self.post_ref_op(i); + } + } } else if op.eq_ignore_ascii_case("Path") { - self.command_line - .push_output(&NOT_YET.replace("", "Path")); + // Path Find → prefix rewrite across all; + // Path → verbatim per-ref store. + let after_op = rest.get(op.len()..).unwrap_or("").trim(); + if after_op.split_whitespace().next().is_some_and(|w| w.eq_ignore_ascii_case("Find")) { + // Find & Replace: tokens are Find + // (`new` keeps spaces via remainder slice). + let toks: Vec<&str> = after_op.split_whitespace().collect(); + let (old, new) = if toks.len() >= 3 { + // Find the exact token index for old to avoid substring + // contamination (e.g. "in" matching inside "Find"). + if let Some(idx) = toks.iter().position(|t| t.eq_ignore_ascii_case(&toks[1])) { + let pos = after_op.find(toks[idx]).unwrap_or(0) + toks[idx].len(); + (toks[idx].to_string(), after_op[pos..].trim().to_string()) + } else { + (String::new(), String::new()) + } + } else { + (String::new(), String::new()) + }; + if old.is_empty() || new.is_empty() { + self.command_line.push_error(crate::t!("Usage: XREF Path Find ").as_ref()); + } else { + self.push_undo_snapshot(i, "XREF-PATH"); + let n = crate::io::xref::replace_path_prefix( + &mut self.tabs[i].scene.document, + &old, + &new, + ); + self.command_line.push_output(crate::tf!( + "XREF: updated {} reference(s).", + n + ).as_ref()); + if n > 0 { + self.post_ref_op(i); + } + } + } else { + let pattern = parts.next().unwrap_or("").trim().to_string(); + // New path keeps spaces: slice after pattern. + let new_raw: String = if pattern.is_empty() { + String::new() + } else { + after_op + .find(&pattern) + .map(|p| after_op[p + pattern.len()..].trim().to_string()) + .unwrap_or_default() + }; + if pattern.is_empty() || new_raw.is_empty() { + self.command_line.push_error(crate::t!("Usage: XREF Path ").as_ref()); + } else { + let base_dir: std::path::PathBuf = self.tabs[i] + .current_path + .as_ref() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let entries = crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + &base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); + let matched: Vec<_> = entries + .iter() + .filter(|e| crate::io::xref_model::wildcard_match(&e.name, &pattern)) + .collect(); + if matched.is_empty() { + self.command_line.push_error(crate::tf!( + "XREF: no references match '{}'.", + pattern + ).as_ref()); + } else { + self.push_undo_snapshot(i, "XREF-PATH"); + let mut done = 0usize; + for e in matched { + match crate::io::xref::set_ref_path( + &mut self.tabs[i].scene.document, + e.key, + &new_raw, + ) { + Ok(name) => { + self.command_line.push_output(crate::tf!( + "XREF: Path set for \"{}\" — Reload to apply.", + name + ).as_ref()); + done += 1; + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + } + if done > 0 { + self.post_ref_op(i); + } + } + } + } } else if op.eq_ignore_ascii_case("Pathtype") { - self.command_line - .push_output(&NOT_YET.replace("", "Pathtype")); + // Pathtype [pattern] (empty/* = all). + let kind_tok = parts.next().unwrap_or("").trim(); + let pattern = parts.next().unwrap_or("").trim().to_string(); + let pathtype = if kind_tok.eq_ignore_ascii_case("Full") { + Some(crate::io::xref_model::Pathtype::Full) + } else if kind_tok.eq_ignore_ascii_case("Relative") { + Some(crate::io::xref_model::Pathtype::Relative) + } else if kind_tok.eq_ignore_ascii_case("None") { + Some(crate::io::xref_model::Pathtype::None) + } else { + None + }; + let Some(pathtype) = pathtype else { + self.command_line.push_error(crate::t!("Usage: XREF Pathtype [pattern]").as_ref()); + return Some(self.finish_dispatch(cmd)); + }; + let Some(host) = self.tabs[i].current_path.clone() else { + self.command_line + .push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref()); + return Some(self.finish_dispatch(cmd)); + }; + let base_dir: std::path::PathBuf = host + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let entries = crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + &base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); + let matched: Vec<_> = entries + .iter() + .filter(|e| { + pattern.is_empty() + || pattern == "*" + || crate::io::xref_model::wildcard_match(&e.name, &pattern) + }) + .collect(); + if matched.is_empty() { + self.command_line.push_error(crate::tf!( + "XREF: no references match '{}'.", + if pattern.is_empty() { "*" } else { &pattern } + ).as_ref()); + } else { + self.push_undo_snapshot(i, "XREF-PATHTYPE"); + let mut done = 0usize; + for e in matched { + match crate::io::xref::apply_pathtype( + &mut self.tabs[i].scene.document, + e.key, + pathtype, + &host, + ) { + Ok(_) => { + self.command_line.push_output(crate::tf!( + "XREF: Path set for \"{}\" — Reload to apply.", + e.name + ).as_ref()); + done += 1; + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + } + if done > 0 { + self.post_ref_op(i); + } + } } else if op.eq_ignore_ascii_case("Bind") { - self.command_line - .push_output(&NOT_YET.replace("", "Bind")); + // Bind (empty/* = all): fold each direct + // reference into a local block. Nested entries are + // rejected per-entry; images are collected next to the + // host file; PDFs report the unavailable-import error. + let pattern = parts.next().unwrap_or("").trim(); + let base_dir: std::path::PathBuf = self.tabs[i] + .current_path + .as_ref() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let entries = crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + &base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); + let matched: Vec<_> = entries + .iter() + .filter(|e| { + pattern.is_empty() + || pattern == "*" + || crate::io::xref_model::wildcard_match(&e.name, pattern) + }) + .collect(); + if matched.is_empty() { + self.command_line.push_error(crate::tf!( + "XREF: no references match '{}'.", + pattern + ).as_ref()); + } else { + let Some(host) = self.tabs[i].current_path.clone() else { + self.command_line + .push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref()); + return Some(self.finish_dispatch(cmd)); + }; + let host_dir: std::path::PathBuf = host + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + self.push_undo_snapshot(i, "XREF-BIND"); + let mut done = 0usize; + for e in matched { + if e.parent_key.is_some() { + self.command_line.push_error(crate::tf!( + "XREF: cannot bind nested reference '{}'. Bind it in its host drawing.", + e.name + ).as_ref()); + continue; + } + match crate::io::xref::bind_reference( + &mut self.tabs[i].scene.document, + e.key, + &base_dir, + &host_dir, + ) { + Ok(outcome) => { + if outcome.unremapped == 0 { + self.command_line.push_output(crate::tf!( + "XREF: bound \"{}\".", + outcome.name + ).as_ref()); + } else { + self.command_line.push_output(crate::tf!( + "XREF: bound \"{}\" with {} unremapped style handles (see bind limitations).", + outcome.name, outcome.unremapped + ).as_ref()); + } + self.tabs[i].xref_unloaded.remove(&e.key); + self.tabs[i].xref_stat_cache.remove(&e.key); + done += 1; + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + } + if done > 0 { + self.post_ref_op(i); + } + } } else if op.eq_ignore_ascii_case("Overlay") { - self.command_line - .push_output(&NOT_YET.replace("", "Overlay")); + // Overlay : flip DWG refs to Overlay type. + let pattern = parts.next().unwrap_or("").trim(); + let base_dir: std::path::PathBuf = self.tabs[i] + .current_path + .as_ref() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let entries = crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + &base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); + let matched: Vec<_> = entries + .iter() + .filter(|e| { + pattern.is_empty() + || pattern == "*" + || crate::io::xref_model::wildcard_match(&e.name, pattern) + }) + .collect(); + if matched.is_empty() { + self.command_line.push_error(crate::tf!( + "XREF: no references match '{}'.", + pattern + ).as_ref()); + } else { + self.push_undo_snapshot(i, "XREF-OVERLAY"); + let mut done = 0usize; + for e in matched { + match crate::io::xref::set_ref_type( + &mut self.tabs[i].scene.document, + e.key, + crate::io::xref_model::RefType::Overlay, + ) { + Ok(name) => { + self.command_line.push_output(crate::tf!( + "XREF: \"{}\" set to Overlay.", + name + ).as_ref()); + done += 1; + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + } + if done > 0 { + self.post_ref_op(i); + } + } } else if op.eq_ignore_ascii_case("Attach") { - self.command_line - .push_output(&NOT_YET.replace("", "Attach")); + // Attach []: no-file-arg form launches the file + // picker (same flow as XATTACH); a file arg threads + // straight into placement as the pick result. + let after = rest.get(op.len()..).unwrap_or("").trim(); + if after.is_empty() { + return Some(Task::done(Message::XAttachPick)); + } + let cmd = + crate::modules::insert::xattach::XAttachCommand::with_path( + after.to_string(), + ); + self.command_line.push_info(&cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd)); } else { self.command_line.push_error(crate::tf!( "XREF: unknown option '{}'. Options: ? Reload Unload Detach Path Pathtype Bind Overlay Attach", @@ -729,23 +1222,53 @@ impl OpenCADStudio { } "XRELOAD" => { - // Reload all xrefs for the current drawing. + // Reload all xrefs for the current drawing. Every direct key + // leaves the session-unloaded set (reloaded content is live + // again) with fresh stat baselines on the next refresh. if let Some(path) = &self.tabs[i].current_path.clone() { if let Some(base_dir) = path.parent() { + let reload_keys: Vec = + crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ) + .iter() + .map(|e| e.key) + .collect(); + self.push_undo_snapshot(i, "XREF-RELOAD"); + for key in &reload_keys { + self.tabs[i].xref_unloaded.remove(key); + self.tabs[i].xref_stat_cache.remove(key); + } let (infos, _dropped) = crate::io::xref::resolve_xrefs( &mut self.tabs[i].scene.document, base_dir, ); + // `resolve_xrefs` re-merges ALL references — refresh stat + // baselines for every currently-Loaded entry so no row + // reports false Stale. + { + let fresh = + crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); + for e in &fresh { + if e.status == crate::io::xref_model::RefStatus::Loaded { + if let Some(m) = e.modified { + self.tabs[i].xref_stat_cache.insert(e.key, m); + } + } + } + } for info in &infos { self.report_xref_status(info); } - self.tabs[i].scene.populate_hatches_from_document(); - self.tabs[i].scene.populate_images_from_document(); - self.tabs[i].scene.populate_meshes_from_document(); - // The reload may have merged new xref layers / - // linetypes — mirror them into the Layers panel and - // ribbon dropdowns (#407). - self.refresh_layer_panel(); + self.post_ref_op(i); } } else { self.command_line @@ -913,6 +1436,15 @@ mod tests { assert!(app.block_palette.blocks.iter().any(|b| b.name == "Widget")); } + #[test] + fn xref_dash_alias_lists_like_xref() { + // `-XREF` is accepted as an alias for industry muscle memory; bare + // `XREF` keeps the legacy list output for compatibility. + let mut app = fresh_app(); + let out = run_capture(&mut app, "-XREF ?"); + assert!(out.contains("No external references") || out.contains("External references")); + } + #[test] fn xref_question_mark_lists_count() { let mut app = fresh_app(); @@ -929,6 +1461,422 @@ mod tests { assert_eq!(app.tabs[0].dirty, dirty_before); } + fn add_dwg_xref(app: &mut OpenCADStudio, name: &str, saved: &str) { + let i = app.active_tab; + let mut br = acadrust::tables::BlockRecord::new(name); + br.flags.is_xref = true; + br.xref_path = saved.to_string(); + br.handle = app.tabs[i].scene.document.allocate_handle(); + app.tabs[i].scene.document.block_records.add(br).unwrap(); + } + + #[test] + fn xref_path_stores_verbatim() { + let mut app = fresh_app(); + add_dwg_xref(&mut app, "PLAN", "old/plan.dwg"); + let out = run_capture(&mut app, "XREF Path PLAN new\\raw path.dwg"); + assert!(out.contains("Path set"), "got: {out:?}"); + let i = app.active_tab; + assert_eq!( + app.tabs[i].scene.document.block_records.get("PLAN").unwrap().xref_path, + "new\\raw path.dwg" + ); + } + + #[test] + fn xref_pathtype_across_drives_errors() { + let mut app = fresh_app(); + add_dwg_xref(&mut app, "PLAN", "D:/Lib/plan.dwg"); + let i = app.active_tab; + app.tabs[i].current_path = Some(std::path::PathBuf::from("C:/Drawings/host.dwg")); + let out = run_capture(&mut app, "XREF Pathtype Relative PLAN"); + assert!(out.contains("across drives"), "got: {out:?}"); + } + + #[test] + fn xref_overlay_on_image_errors() { + use acadrust::objects::{ImageDefinition, ObjectType}; + let mut app = fresh_app(); + let i = app.active_tab; + let h = app.tabs[i].scene.document.allocate_handle(); + let mut def = ImageDefinition::with_dimensions("img.png", 8, 8); + def.handle = h; + app.tabs[i].scene.document.objects.insert(h, ObjectType::ImageDefinition(def)); + // Reference it so collect_entries lists it. + let mut img = acadrust::entities::RasterImage::new( + "img.png", + acadrust::types::Vector3::ZERO, + 8.0, + 8.0, + ); + img.definition_handle = Some(h); + app.tabs[i].scene.document.add_entity(acadrust::EntityType::RasterImage(img)).unwrap(); + let out = run_capture(&mut app, "XREF Overlay img.png"); + assert!(out.contains("overlays apply to drawing references only"), "got: {out:?}"); + } + + #[test] + fn xref_reload_image_keeps_flag_no_spurious_match() { + // CLI mirror of the palette F7 guard: reloading an image row reports + // the drawing-only error, leaves its unloaded flag untouched, and + // does not follow with a spurious no-match error. + use acadrust::objects::{ImageDefinition, ObjectType}; + let mut app = fresh_app(); + let i = app.active_tab; + let h = app.tabs[i].scene.document.allocate_handle(); + let mut def = ImageDefinition::with_dimensions("img.png", 8, 8); + def.handle = h; + app.tabs[i].scene.document.objects.insert(h, ObjectType::ImageDefinition(def)); + let mut img = acadrust::entities::RasterImage::new( + "img.png", + acadrust::types::Vector3::ZERO, + 8.0, + 8.0, + ); + img.definition_handle = Some(h); + app.tabs[i].scene.document.add_entity(acadrust::EntityType::RasterImage(img)).unwrap(); + app.tabs[i].current_path = Some(std::path::PathBuf::from("C:/Drawings/host.dwg")); + app.tabs[i].xref_unloaded.add(h.value()); + let out = run_capture(&mut app, "XREF Reload img.png"); + assert!(out.contains("reload applies to drawing references only"), "got: {out:?}"); + assert!(!out.contains("no references match"), "spurious no-match, got: {out:?}"); + assert!( + app.tabs[i].xref_unloaded.is_unloaded(h.value()), + "image flag must stay untouched" + ); + } + + #[test] + fn xref_unload_reload_session_set_roundtrip() { + let mut app = fresh_app(); + add_dwg_xref(&mut app, "PLAN", "old/plan.dwg"); + let i = app.active_tab; + let key = app.tabs[i] + .scene + .document + .block_records + .get("PLAN") + .unwrap() + .handle + .value(); + let out = run_capture(&mut app, "XREF Unload PLAN"); + assert!(out.contains("unloaded"), "got: {out:?}"); + assert!( + app.tabs[i].xref_unloaded.is_unloaded(key), + "unload must record the session set" + ); + // Reload clears the session set even though the file is missing + // (status report says Not found; the key leaves the set first). + let dir = std::env::temp_dir(); + app.tabs[i].current_path = Some(dir.join("ocs_xref_host.dwg")); + let out = run_capture(&mut app, "XREF Reload PLAN"); + assert!(!app.tabs[i].xref_unloaded.is_unloaded(key), "got: {out:?}"); + } + + #[test] + fn xref_detach_nested_guard_errors() { + use acadrust::tables::BlockRecord; + // Host file on disk containing its own xref "INNER". + let dir = std::env::temp_dir().join(format!( + "ocs_xref_nested_{}_{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let mut host_doc = acadrust::CadDocument::new(); + let mut inner = BlockRecord::new("INNER"); + inner.flags.is_xref = true; + inner.xref_path = "inner.dwg".to_string(); + host_doc.block_records.add(inner).unwrap(); + let bytes = crate::io::save_to_bytes(&host_doc, "dwg", host_doc.version).unwrap(); + std::fs::write(dir.join("host.dwg"), &bytes).unwrap(); + // App drawing references the host file; give the app a path in the + // same dir so nested enumeration resolves. + let mut app = fresh_app(); + let host_path = dir.join("host.dwg").to_string_lossy().into_owned(); + add_dwg_xref(&mut app, "HOST", &host_path); + let i = app.active_tab; + app.tabs[i].current_path = Some(dir.join("app.dwg")); + let out = run_capture(&mut app, "XREF Detach INNER"); + assert!(out.contains("cannot detach nested"), "got: {out:?}"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn xref_bind_basic_rewrites_symbols() { + let dir = std::env::temp_dir().join(format!( + "ocs_xref_bind_cli_{}_{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let mut xref_doc = acadrust::CadDocument::new(); + xref_doc + .layers + .add(acadrust::tables::Layer::new("WALLS")) + .unwrap(); + let mut line = acadrust::entities::Line::new(); + line.common.layer = "WALLS".to_string(); + xref_doc + .add_entity(acadrust::EntityType::Line(line)) + .unwrap(); + let bytes = crate::io::save_to_bytes(&xref_doc, "dwg", xref_doc.version).unwrap(); + let xref_path = dir.join("plan.dwg"); + std::fs::write(&xref_path, &bytes).unwrap(); + + let mut app = fresh_app(); + add_dwg_xref(&mut app, "PLAN", &xref_path.to_string_lossy()); + let i = app.active_tab; + app.tabs[i].current_path = Some(dir.join("app.dwg")); + let out = run_capture(&mut app, "XREF Bind PLAN"); + assert!(out.contains("bound \"PLAN\""), "got: {out:?}"); + let doc = &app.tabs[i].scene.document; + assert!(doc.layers.get("PLAN$0$WALLS").is_some()); + let br = doc.block_records.get("PLAN").unwrap(); + assert!(!br.flags.is_xref && br.xref_path.is_empty()); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn xref_bind_reports_unremapped_handles() { + let dir = std::env::temp_dir().join(format!( + "ocs_xref_bind_limited_{}_{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let mut xref_doc = acadrust::CadDocument::new(); + let mut line = acadrust::entities::Line::new(); + line.common.plotstyle_flags = 0b11; + line.common.plotstyle_handle = Some(xref_doc.allocate_handle()); + xref_doc + .add_entity(acadrust::EntityType::Line(line)) + .unwrap(); + let bytes = crate::io::save_to_bytes(&xref_doc, "dwg", xref_doc.version).unwrap(); + let xref_path = dir.join("plan.dwg"); + std::fs::write(&xref_path, &bytes).unwrap(); + + let mut app = fresh_app(); + add_dwg_xref(&mut app, "PLAN", &xref_path.to_string_lossy()); + let i = app.active_tab; + app.tabs[i].current_path = Some(dir.join("app.dwg")); + let out = run_capture(&mut app, "XREF Bind PLAN"); + assert!(out.contains("with 1 unremapped style handles (see bind limitations)"), "got: {out:?}"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn xref_bind_pdf_errors() { + use acadrust::objects::{ObjectType, UnderlayDefinition}; + let mut app = fresh_app(); + let i = app.active_tab; + let h = app.tabs[i].scene.document.allocate_handle(); + let mut def = UnderlayDefinition::pdf("doc.pdf", "1"); + def.handle = h; + app.tabs[i] + .scene + .document + .objects + .insert(h, ObjectType::UnderlayDefinition(def)); + app.tabs[i].current_path = Some(std::env::temp_dir().join("ocs_xref_bind_host.dwg")); + let out = run_capture(&mut app, "XREF Bind doc.pdf"); + assert!(out.contains("PDF bind (vector import) is not available"), "got: {out:?}"); + } + + #[test] + fn xref_bind_nested_guard_errors() { + use acadrust::tables::BlockRecord; + let dir = std::env::temp_dir().join(format!( + "ocs_xref_bind_nested_{}_{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let mut host_doc = acadrust::CadDocument::new(); + let mut inner = BlockRecord::new("INNER"); + inner.flags.is_xref = true; + inner.xref_path = "inner.dwg".to_string(); + host_doc.block_records.add(inner).unwrap(); + let bytes = crate::io::save_to_bytes(&host_doc, "dwg", host_doc.version).unwrap(); + std::fs::write(dir.join("host.dwg"), &bytes).unwrap(); + let mut app = fresh_app(); + let host_path = dir.join("host.dwg").to_string_lossy().into_owned(); + add_dwg_xref(&mut app, "HOST", &host_path); + let i = app.active_tab; + app.tabs[i].current_path = Some(dir.join("app.dwg")); + let out = run_capture(&mut app, "XREF Bind INNER"); + assert!(out.contains("cannot bind nested"), "got: {out:?}"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn xattach_definition_undo_removes_definition() { + // F3: the CommitAndExit driver begins the undo transaction (structure + // image, delta-unsafe) BEFORE prepare_xref_block + the INSERT commit, + // so undo after XATTACH removes the definition, not just the entity. + // This mirrors that ordering: begin first, then prepare, then undo. + let mut app = fresh_app(); + let i = app.active_tab; + app.tabs[i].current_path = Some(std::path::PathBuf::from("C:/Drawings/host.dwg")); + let base = std::path::PathBuf::from("C:/Drawings"); + let pending = app.begin_undo(i, "ENTITY", 1, false).expect("begin undo"); + let name = crate::modules::insert::xattach::prepare_xref_block( + &mut app.tabs[i].scene, + "C:/refs/plan.dwg", + Some(&base), + ); + assert!( + app.tabs[i].scene.document.block_records.get(&name).is_some(), + "prepare must create the definition" + ); + app.commit_undo_delta(i, pending); + app.undo_active_tab(); + assert!( + app.tabs[i].scene.document.block_records.get(&name).is_none(), + "undo after XATTACH must remove the definition" + ); + } + + #[test] + fn xref_reload_pushes_undo() { + // F2: XREF Reload resolves (merges content) — it must push an undo + // snapshot first, mirroring the neighboring Unload/Detach arms. The + // snapshot sits in `pending` until the next history flush, so assert + // on the pending label, then that undo consumes it. + let mut app = fresh_app(); + add_dwg_xref(&mut app, "PLAN", "old/plan.dwg"); + let i = app.active_tab; + app.tabs[i].current_path = + Some(std::path::PathBuf::from("C:/Drawings/host.dwg")); + let out = run_capture(&mut app, "XREF Reload PLAN"); + assert!(!out.contains("no references match"), "got: {out:?}"); + assert_eq!( + app.tabs[i].history.pending.as_ref().map(|p| p.label.as_str()), + Some("XREF-RELOAD"), + "reload must push an undo snapshot, got: {out:?}" + ); + let redo_before = app.tabs[i].history.redo_stack.len(); + app.undo_active_tab(); + assert!(app.tabs[i].history.pending.is_none()); + let _ = redo_before; + } + + #[test] + fn xref_attach_with_file_starts_placement() { + // F10: `XREF Attach ` threads the file into the XATTACH + // placement command (same flow as the picker result). + let mut app = fresh_app(); + let out = run_capture(&mut app, "XREF Attach C:/refs/plan.dwg"); + assert!( + !out.contains("ships with reference operations"), + "dead-end text must be gone, got: {out:?}" + ); + let i = app.active_tab; + assert_eq!( + app.tabs[i].active_cmd.as_ref().map(|c| c.name()), + Some("XATTACH") + ); + } + + #[test] + fn xref_attach_bare_requests_picker_without_dead_end() { + // F10: bare `XREF Attach` launches the file picker flow (the + // returned task); nothing starts synchronously and the Task 8/9 + // dead-end text is gone. + let mut app = fresh_app(); + let out = run_capture(&mut app, "XREF Attach"); + assert!( + !out.contains("ships with reference operations"), + "dead-end text must be gone, got: {out:?}" + ); + let i = app.active_tab; + assert!(app.tabs[i].active_cmd.is_none()); + } + + #[test] + fn xref_selective_reload_touches_only_matched_baselines() { + // F5 (targeted reload): `resolve_xrefs_for_keys` re-merges ONLY the + // matched refs, so stat baselines refresh for the matched subset + // alone. Unmatched rows keep their prior baseline (and their content + // is untouched), so no false Stale can arise from this reload. + let dir = std::env::temp_dir().join(format!( + "ocs_xref_reload_skew_{}_{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let a_path = dir.join("plan_a.dwg"); + let b_path = dir.join("plan_b.dwg"); + std::fs::write(&a_path, b"fake-a").unwrap(); + std::fs::write(&b_path, b"fake-b").unwrap(); + let mut app = fresh_app(); + add_dwg_xref(&mut app, "PLAN-A", &a_path.to_string_lossy()); + add_dwg_xref(&mut app, "PLAN-B", &b_path.to_string_lossy()); + let i = app.active_tab; + app.tabs[i].current_path = Some(dir.join("app.dwg")); + let key_a = app.tabs[i].scene.document.block_records.get("PLAN-A").unwrap().handle.value(); + let key_b = app.tabs[i].scene.document.block_records.get("PLAN-B").unwrap().handle.value(); + let out = run_capture(&mut app, "XREF Reload PLAN-A"); + assert!(!out.contains("no references match"), "got: {out:?}"); + assert!( + app.tabs[i].xref_stat_cache.0.contains_key(&key_a), + "matched entry baseline refreshed, got: {out:?}" + ); + assert!( + !app.tabs[i].xref_stat_cache.0.contains_key(&key_b), + "unmatched entry baseline untouched, got: {out:?}" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn xref_bind_undo_restores_xref_state() { + let dir = std::env::temp_dir().join(format!( + "ocs_xref_bind_undo_{}_{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let mut xref_doc = acadrust::CadDocument::new(); + xref_doc + .layers + .add(acadrust::tables::Layer::new("WALLS")) + .unwrap(); + let mut line = acadrust::entities::Line::new(); + line.common.layer = "WALLS".to_string(); + xref_doc + .add_entity(acadrust::EntityType::Line(line)) + .unwrap(); + let bytes = crate::io::save_to_bytes(&xref_doc, "dwg", xref_doc.version).unwrap(); + let xref_path = dir.join("plan.dwg"); + std::fs::write(&xref_path, &bytes).unwrap(); + let saved = xref_path.to_string_lossy().into_owned(); + + let mut app = fresh_app(); + add_dwg_xref(&mut app, "PLAN", &saved); + let i = app.active_tab; + app.tabs[i].current_path = Some(dir.join("app.dwg")); + // Resolve first so `PLAN|*` names exist pre-bind. + let out = run_capture(&mut app, "XREF Reload PLAN"); + assert!(out.contains("Reloaded"), "got: {out:?}"); + assert!(app.tabs[i].scene.document.layers.get("PLAN|WALLS").is_some()); + let out = run_capture(&mut app, "XREF Bind PLAN"); + assert!(out.contains("bound \"PLAN\""), "got: {out:?}"); + assert!(app.tabs[i].scene.document.layers.get("PLAN$0$WALLS").is_some()); + + app.undo_active_tab(); + let doc = &app.tabs[i].scene.document; + let br = doc.block_records.get("PLAN").unwrap(); + assert!(br.flags.is_xref, "undo must restore the xref flag"); + assert_eq!(br.xref_path, saved, "undo must restore the xref path"); + assert!(doc.layers.get("PLAN|WALLS").is_some(), "undo must restore `|` names"); + assert!( + !doc.layers.names().any(|n| n.contains("PLAN$0")), + "undo must drop the bound names" + ); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn blockpalette_refreshes_when_switching_to_same_named_block_in_another_tab() { let mut app = fresh_app(); diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index f3ad6dfc8..90296dbe8 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -713,6 +713,7 @@ inventory::submit!(crate::command::CommandRegistration { "XDATA", "XR", "XREF", + "-XREF", "XREFMAN", "XRELOAD", "ZOOM", diff --git a/src/app/document.rs b/src/app/document.rs index 50d4cfc4b..fa19e5694 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -195,6 +195,16 @@ pub(super) struct DocumentTab { pub(super) active_mleader_style: String, /// Last camera_generation value written back to the document. pub(super) last_synced_camera_gen: u64, + /// Session set of unloaded reference keys (Task 8b). Owns the set that + /// `collect_entries` takes as `unloaded`, so CLI and palette agree. + pub(super) xref_unloaded: crate::io::xref_model::UnloadSet, + /// Load-time mtimes per reference key (Task 8b). Written on every + /// palette refresh; `Stale` is detectable from the second refresh on. + pub(super) xref_stat_cache: crate::io::xref_model::RefStatCache, + /// NotFound count from the last file-open xref resolution (Task 8b). + /// The palette renders a neutral "open XREFMAN" notice while non-zero; + /// never auto-opens a modal. Cleared by a clean palette refresh. + pub(super) xref_missing: usize, /// Sentinel "Welcome / Start" tab. Always at index 0 when present. /// Cannot be closed; the viewport area renders a welcome page instead /// of the model-space shader. The scene is still constructed so the @@ -615,6 +625,9 @@ impl DocumentTab { active_block_edit: None, active_mleader_style: "Standard".to_string(), last_synced_camera_gen: 0, + xref_unloaded: crate::io::xref_model::UnloadSet::default(), + xref_stat_cache: crate::io::xref_model::RefStatCache::default(), + xref_missing: 0, is_start: false, pan_mode: false, orbit_mode: false, diff --git a/src/app/mod.rs b/src/app/mod.rs index 6ee956ed7..7dbebbfbd 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2357,6 +2357,17 @@ pub enum Message { XrefManagerToggleTree, /// Expand/collapse one tree parent (block-record handle key). XrefManagerToggleExpand(u64), + /// Selection-scoped palette operation (detach/unload/reload/overlay/pathtype). + XrefManagerOp(crate::ui::window::xref_manager::XrefPaletteOp), + /// Details-pane "new path" draft text. + XrefManagerPathInput(String), + /// Apply the "new path" draft to the anchor entry. + XrefManagerPathApply, + /// Find & Replace row drafts. + XrefManagerFindInput(String), + XrefManagerReplaceInput(String), + /// Apply Find & Replace across all direct references. + XrefManagerFindReplaceApply, LayerToggleVisible(usize), LayerToggleLock(usize), LayerToggleFreeze(usize), diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 2f5aecbb7..3c3c99a47 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -759,7 +759,10 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } /// Rebuild the Reference Manager's entry list from the active drawing. - /// Display-only: `collect_entries` stats files but mutates nothing. + /// Uses the tab's session sets (unloaded + stat baseline) so CLI and + /// palette agree; writes fresh load-time mtimes back into the tab's stat + /// cache (`Stale` detectable from the second refresh on). Records the + /// tab's `edit_revision` so the palette auto-rescans on doc changes. pub(crate) fn refresh_xref_manager(&mut self) { let i = self.active_tab; let base_dir: std::path::PathBuf = self.tabs[i] @@ -768,21 +771,466 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven .and_then(|p| p.parent().map(|p| p.to_path_buf())) .unwrap_or_else(|| std::path::PathBuf::from(".")); let tab_id = self.tabs[i].id; - self.xref_manager - .refresh(&self.tabs[i].scene.document, &base_dir); + let revision = self.tabs[i].edit_revision; + let baselines = { + let tab = &self.tabs[i]; + self.xref_manager.refresh( + &tab.scene.document, + &base_dir, + tab.xref_unloaded.as_set(), + &tab.xref_stat_cache.0, + ) + }; + let tab = &mut self.tabs[i]; + for (key, mtime) in baselines { + tab.xref_stat_cache.insert(key, mtime); + } + // A clean rescan clears the open-time missing notice. + if !self + .xref_manager + .entries + .iter() + .any(|e| e.status == crate::io::xref_model::RefStatus::NotFound) + { + tab.xref_missing = 0; + } self.xref_manager.source_tab_id = Some(tab_id); + self.xref_manager.source_edit_revision = revision; } - /// Re-scan when the palette is open but showing another tab's drawing. + /// Re-scan when the palette is open but showing another tab's drawing or + /// the active drawing changed since the last scan (CLI/palette mutation, + /// XOPEN-return edit, undo/redo — all bump `edit_revision`). Cheap: + /// `collect_entries` stats files without re-parsing the host. pub(crate) fn refresh_xref_manager_if_stale(&mut self) { if self.active_modal != Some(crate::app::ModalKind::XrefManager) { return; } let i = self.active_tab; - if self.xref_manager.source_tab_id != Some(self.tabs[i].id) { + if self.xref_manager.source_tab_id != Some(self.tabs[i].id) + || self.xref_manager.source_edit_revision != self.tabs[i].edit_revision + { self.refresh_xref_manager(); } } + + /// Shared post-mutation sequence for reference ops (CLI + palette): + /// repopulate scene caches, mirror xref layers into the Layers panel, + /// and mark the tab dirty. Both call sites use it so the 5-line + /// sequence cannot drift. + pub(crate) fn post_ref_op(&mut self, i: usize) { + self.tabs[i].scene.populate_hatches_from_document(); + self.tabs[i].scene.populate_images_from_document(); + // Image/PDF unload is intentionally session-only (the file formats do + // not persist an unloaded bit), so keep their source entities but + // remove their decoded render models. A reload repopulates the cache + // after clearing the corresponding session key. + let hidden_images: Vec = self.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| match entity { + acadrust::EntityType::RasterImage(image) + if image.definition_handle.is_some_and(|key| self.tabs[i].xref_unloaded.is_unloaded(key.value())) => + { + Some(entity.common().handle) + } + acadrust::EntityType::Underlay(underlay) + if matches!(underlay.underlay_type, acadrust::entities::UnderlayType::Pdf) + && self.tabs[i].xref_unloaded.is_unloaded(underlay.definition_handle.value()) => + { + Some(entity.common().handle) + } + _ => None, + }) + .collect(); + for handle in hidden_images { + self.tabs[i].scene.images.remove(&handle); + } + self.tabs[i].scene.populate_meshes_from_document(); + self.refresh_layer_panel(); + self.tabs[i].dirty = true; + } + + /// Execute a toolbar [`XrefPaletteOp`] on the palette's actionable + /// selection. Same engine fns as the CLI arms, batched with per-item + /// report lines in the CLI wording; finishes with [`post_ref_op`] plus + /// a palette rescan (mirror of the CLI post-op sequence). + pub(crate) fn xref_manager_op( + &mut self, + op: crate::ui::window::xref_manager::XrefPaletteOp, + ) { + if cfg!(target_arch = "wasm32") { + self.command_line.push_error(crate::t!("Reference changes are not available on web — the reference list is read-only.").as_ref()); + return; + } + use crate::ui::window::xref_manager::XrefPaletteOp; + let i = self.active_tab; + // Full selection (nested rows included) with per-entry metadata, so + // nested rows report per-entry errors instead of being silently + // dropped by `actionable_selection` (mirrors the CLI arms). + let picked: Vec<(u64, String, crate::io::xref_model::RefKind, bool)> = { + let mut idx: Vec = self.xref_manager.selected.iter().copied().collect(); + idx.sort_unstable(); + idx.iter() + .filter_map(|idx| { + self.xref_manager.entries.get(*idx).map(|e| { + ( + e.key, + e.name.clone(), + e.kind, + e.parent_key.is_some(), + ) + }) + }) + .collect() + }; + if picked.is_empty() { + return; + } + let needs_host = matches!( + op, + XrefPaletteOp::Reload | XrefPaletteOp::Bind | XrefPaletteOp::Pathtype(_) + ); + let host: Option = self.tabs[i].current_path.clone(); + if needs_host && host.is_none() { + self.command_line + .push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref()); + return; + } + let label = match op { + XrefPaletteOp::Detach => "XREF-DETACH", + XrefPaletteOp::Unload => "XREF-UNLOAD", + XrefPaletteOp::Reload => "XREF-RELOAD", + XrefPaletteOp::Bind => "XREF-BIND", + XrefPaletteOp::Overlay => "XREF-OVERLAY", + XrefPaletteOp::Attach => "XREF-ATTACH", + XrefPaletteOp::Pathtype(_) => "XREF-PATHTYPE", + }; + self.push_undo_snapshot(i, label); + let mut done = 0usize; + match op { + XrefPaletteOp::Detach => { + for (key, _, _, is_nested) in &picked { + if *is_nested { + continue; + } + match crate::io::xref::detach_reference( + &mut self.tabs[i].scene.document, + *key, + ) { + Ok(name) => { + self.command_line.push_output(crate::tf!( + "XREF: detached \"{}\".", + name + ).as_ref()); + self.tabs[i].xref_unloaded.remove(key); + self.tabs[i].xref_stat_cache.remove(key); + done += 1; + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + } + } + XrefPaletteOp::Unload => { + for (key, name, _, is_nested) in &picked { + if *is_nested { + self.command_line.push_error(crate::tf!( + "XREF: cannot unload nested reference '{}'. Unload it in its host drawing.", + name + ).as_ref()); + continue; + } + match crate::io::xref::unload_reference( + &mut self.tabs[i].scene.document, + *key, + ) { + Ok(name) => { + self.command_line.push_output(crate::tf!( + "XREF: unloaded \"{}\".", + name + ).as_ref()); + self.tabs[i].xref_unloaded.add(*key); + done += 1; + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + } + } + XrefPaletteOp::Reload => { + let base_dir: std::path::PathBuf = host + .as_ref() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + // Drawing references only: image/PDF rows keep their flags + // untouched and report per-entry (their names never match the + // DWG-only `XrefInfo` list, so matching after the flag clear + // would spuriously report no-match). + let dwg_picked: Vec<(u64, String)> = picked + .iter() + .filter(|(_, _, kind, is_nested)| { + if *is_nested { + return false; + } + *kind == crate::io::xref_model::RefKind::DwgXref + }) + .map(|(key, name, _, _)| (*key, name.clone())) + .collect(); + for (_, name, kind, is_nested) in &picked { + if *is_nested { + self.command_line.push_error(crate::tf!( + "XREF: cannot reload nested reference '{}'. Reload it in its host drawing.", + name + ).as_ref()); + } else if *kind != crate::io::xref_model::RefKind::DwgXref { + self.command_line.push_error(crate::tf!( + "{}: reload applies to drawing references only.", + name + ).as_ref()); + } + } + for (key, _) in &dwg_picked { + self.tabs[i].xref_unloaded.remove(key); + self.tabs[i].xref_stat_cache.remove(key); + } + let handles: rustc_hash::FxHashSet = self.tabs[i] + .scene + .document + .block_records + .iter() + .filter(|br| dwg_picked.iter().any(|(key, _)| *key == br.handle.value())) + .map(|br| br.handle) + .collect(); + let (infos, _dropped) = crate::io::xref::resolve_xrefs_for_keys( + &mut self.tabs[i].scene.document, + &base_dir, + &handles, + ); + // Targeted reload updates baselines only for selected rows. + { + let fresh = crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + &base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); + for e in &fresh { + if !dwg_picked.iter().any(|(key, _)| *key == e.key) { + continue; + } + if e.status == crate::io::xref_model::RefStatus::Loaded { + if let Some(m) = e.modified { + self.tabs[i].xref_stat_cache.insert(e.key, m); + } + } + } + } + for (_, name) in &dwg_picked { + match infos + .iter() + .find(|n| n.name.eq_ignore_ascii_case(name)) + { + Some(info) => { + self.report_xref_status(info); + done += 1; + } + None => self.command_line.push_error(crate::tf!( + "XREF: no references match '{}'.", + name + ).as_ref()), + } + } + } + XrefPaletteOp::Overlay => { + for (key, name, _, is_nested) in &picked { + if *is_nested { + self.command_line.push_error(crate::tf!( + "XREF: cannot overlay nested reference '{}'. Overlay it in its host drawing.", + name + ).as_ref()); + continue; + } + match crate::io::xref::set_ref_type( + &mut self.tabs[i].scene.document, + *key, + crate::io::xref_model::RefType::Overlay, + ) { + Ok(name) => { + self.command_line.push_output(crate::tf!( + "XREF: \"{}\" set to Overlay.", + name + ).as_ref()); + done += 1; + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + } + } + XrefPaletteOp::Attach => { + for (key, name, _, is_nested) in &picked { + if *is_nested { + self.command_line.push_error(crate::tf!( + "XREF: cannot attach nested reference '{}'. Attach it in its host drawing.", + name + ).as_ref()); + continue; + } + match crate::io::xref::set_ref_type( + &mut self.tabs[i].scene.document, + *key, + crate::io::xref_model::RefType::Attach, + ) { + Ok(name) => { + self.command_line.push_output(crate::tf!( + "XREF: \"{}\" set to Attach.", + name + ).as_ref()); + done += 1; + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + } + } + XrefPaletteOp::Bind => { + let base_dir: std::path::PathBuf = host + .as_ref() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let host_dir: std::path::PathBuf = host + .as_ref() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + for (key, _, _, is_nested) in &picked { + if *is_nested { + continue; + } + match crate::io::xref::bind_reference( + &mut self.tabs[i].scene.document, + *key, + &base_dir, + &host_dir, + ) { + Ok(outcome) => { + if outcome.unremapped == 0 { + self.command_line.push_output(crate::tf!( + "XREF: bound \"{}\".", + outcome.name + ).as_ref()); + } else { + self.command_line.push_output(crate::tf!( + "XREF: bound \"{}\" with {} unremapped style handles (see bind limitations).", + outcome.name, outcome.unremapped + ).as_ref()); + } + self.tabs[i].xref_unloaded.remove(key); + self.tabs[i].xref_stat_cache.remove(key); + done += 1; + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + } + } + XrefPaletteOp::Pathtype(pathtype) => { + let host = host.unwrap_or_else(|| std::path::PathBuf::from(".")); + for (key, name, _, is_nested) in &picked { + if *is_nested { + self.command_line.push_error(crate::tf!( + "XREF: cannot set path type for nested reference '{}'. Set it in its host drawing.", + name + ).as_ref()); + continue; + } + match crate::io::xref::apply_pathtype( + &mut self.tabs[i].scene.document, + *key, + pathtype, + &host, + ) { + Ok(_) => { + self.command_line.push_output(crate::tf!( + "XREF: Path set for \"{}\" — Reload to apply.", + name + ).as_ref()); + done += 1; + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + } + } + } + if done > 0 { + self.post_ref_op(i); + } + self.refresh_xref_manager(); + } + + /// Apply the details-pane path draft to the anchor entry (verbatim + /// store, same engine fn as `XREF Path`). + pub(crate) fn xref_manager_path_apply(&mut self) { + if cfg!(target_arch = "wasm32") { + self.command_line.push_error(crate::t!("Reference changes are not available on web — the reference list is read-only.").as_ref()); + return; + } + let i = self.active_tab; + let anchor = self.xref_manager.anchor.and_then(|a| { + self.xref_manager + .entries + .get(a) + .filter(|_| !self.xref_manager.nested.contains(&a)) + .map(|e| (e.key, e.name.clone())) + }); + let Some((key, _)) = anchor else { + return; + }; + let new_raw = self.xref_manager.path_input.clone(); + if new_raw.is_empty() { + return; + } + self.push_undo_snapshot(i, "XREF-PATH"); + match crate::io::xref::set_ref_path(&mut self.tabs[i].scene.document, key, &new_raw) + { + Ok(name) => { + self.command_line.push_output(crate::tf!( + "XREF: Path set for \"{}\" — Reload to apply.", + name + ).as_ref()); + self.post_ref_op(i); + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + self.refresh_xref_manager(); + } + + /// Apply Find & Replace across all direct references (same engine fn + /// as `XREF Path Find`). + pub(crate) fn xref_manager_find_replace_apply(&mut self) { + if cfg!(target_arch = "wasm32") { + self.command_line.push_error(crate::t!("Reference changes are not available on web — the reference list is read-only.").as_ref()); + return; + } + let i = self.active_tab; + let old = self.xref_manager.find_input.clone(); + let new = self.xref_manager.replace_input.clone(); + if old.is_empty() || new.is_empty() { + self.command_line + .push_error(crate::t!("Usage: XREF Path Find ").as_ref()); + return; + } + self.push_undo_snapshot(i, "XREF-PATH"); + let n = crate::io::xref::replace_path_prefix( + &mut self.tabs[i].scene.document, + &old, + &new, + ); + self.command_line.push_output(crate::tf!( + "XREF: updated {} reference(s).", + n + ).as_ref()); + if n > 0 { + self.post_ref_op(i); + } + self.refresh_xref_manager(); + } } #[cfg(test)] @@ -1280,4 +1728,159 @@ mod tests { assert_eq!(app.block_name_from_file("Chair"), "Chair (2)"); assert_eq!(app.block_name_from_file("Table"), "Table"); } + + #[test] + fn save_as_rebases_live_doc_paths() { + // F4: on Save-As completion the live document's relative reference + // paths are rebased onto the new base dir (same helper the save + // snapshot used), so the session agrees with the file just written. + let mut app = fresh(); + let i = app.active_tab; + let dir = std::env::temp_dir().join(format!( + "ocs_saveas_rebase_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let old_dir = dir.join("old"); + let new_dir = dir.join("new"); + std::fs::create_dir_all(&old_dir).unwrap(); + std::fs::create_dir_all(&new_dir).unwrap(); + let mut br = acadrust::tables::BlockRecord::new("PLAN"); + br.flags.is_xref = true; + br.xref_path = "refs/plan.dwg".to_string(); + br.handle = app.tabs[i].scene.document.allocate_handle(); + app.tabs[i] + .scene + .document + .block_records + .add(br) + .unwrap(); + app.tabs[i].current_path = Some(old_dir.join("host.dwg")); + let tab_id = app.tabs[i].id; + let job_id = 4242u64; + app.active_save_jobs.insert(tab_id, job_id); + let outcome = crate::app::SaveOutcome { + job_id, + tab_id, + epoch: app.tabs[i].scene.geometry_epoch, + revision: app.tabs[i].edit_revision, + camera_generation: app.tabs[i].scene.camera_generation, + path: new_dir.join("host.dwg"), + version: app.tabs[i].scene.document.version, + previous_autosave: None, + set_current_path: true, + purpose: crate::app::SavePurpose::SaveAs, + continuation: crate::app::SaveContinuation::None, + refreshed_preview: None, + result: Ok(()), + }; + let _ = app.on_save_finished(outcome); + assert_eq!( + app.tabs[i].current_path.as_deref(), + Some(new_dir.join("host.dwg").as_path()) + ); + let br = app.tabs[i] + .scene + .document + .block_records + .get("PLAN") + .unwrap(); + assert_eq!(br.xref_path, "../old/refs/plan.dwg"); + std::fs::remove_dir_all(&dir).ok(); + } + + fn palette_tmpdir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "ocs_palette_{tag}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn palette_output(app: &OpenCADStudio, start: usize) -> String { + app.command_line.history[start..] + .iter() + .map(|e| e.text.as_str()) + .collect::>() + .join("\n") + } + + #[test] + fn palette_unload_nested_reports_per_entry() { + // F6: palette Unload on a nested row reports the per-entry nested + // error (CLI wording) instead of the confusing 'no loaded reference'. + use acadrust::tables::BlockRecord; + use crate::ui::window::xref_manager::XrefPaletteOp; + let dir = palette_tmpdir("nested"); + let mut host_doc = acadrust::CadDocument::new(); + let mut inner = BlockRecord::new("INNER"); + inner.flags.is_xref = true; + inner.xref_path = "inner.dwg".to_string(); + host_doc.block_records.add(inner).unwrap(); + let bytes = crate::io::save_to_bytes(&host_doc, "dwg", host_doc.version).unwrap(); + std::fs::write(dir.join("host.dwg"), &bytes).unwrap(); + let mut app = fresh(); + let i = app.active_tab; + let mut br = BlockRecord::new("HOST"); + br.flags.is_xref = true; + br.xref_path = dir.join("host.dwg").to_string_lossy().into_owned(); + br.handle = app.tabs[i].scene.document.allocate_handle(); + app.tabs[i].scene.document.block_records.add(br).unwrap(); + app.tabs[i].current_path = Some(dir.join("app.dwg")); + app.refresh_xref_manager(); + let idx = app.xref_manager.entries.iter().position(|e| e.name == "INNER").expect("nested INNER listed"); + assert!(app.xref_manager.entries[idx].parent_key.is_some()); + app.xref_manager.selected.insert(idx); + let start = app.command_line.history.len(); + app.xref_manager_op(XrefPaletteOp::Unload); + let out = palette_output(&app, start); + assert!(out.contains("cannot unload nested reference 'INNER'"), "got: {out:?}"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn palette_reload_skips_images_without_clearing_flags() { + // F7: palette Reload on an image/PDF row reports the drawing-only + // error and leaves its unloaded flag untouched (no spurious + // no-match after a flag clear). + use acadrust::objects::{ImageDefinition, ObjectType}; + use crate::io::xref_model::RefKind; + use crate::ui::window::xref_manager::XrefPaletteOp; + let dir = palette_tmpdir("imgreload"); + let mut app = fresh(); + let i = app.active_tab; + let h = app.tabs[i].scene.document.allocate_handle(); + let mut def = ImageDefinition::with_dimensions("img.png", 8, 8); + def.handle = h; + app.tabs[i].scene.document.objects.insert(h, ObjectType::ImageDefinition(def)); + let mut img = acadrust::entities::RasterImage::new( + "img.png", + acadrust::types::Vector3::ZERO, + 8.0, + 8.0, + ); + img.definition_handle = Some(h); + app.tabs[i].scene.document.add_entity(acadrust::EntityType::RasterImage(img)).unwrap(); + app.tabs[i].current_path = Some(dir.join("host.dwg")); + app.refresh_xref_manager(); + let idx = app.xref_manager.entries.iter().position(|e| e.kind == RefKind::Image).expect("image listed"); + let key = app.xref_manager.entries[idx].key; + app.xref_manager.selected.insert(idx); + app.tabs[i].xref_unloaded.add(key); + let start = app.command_line.history.len(); + app.xref_manager_op(XrefPaletteOp::Reload); + let out = palette_output(&app, start); + assert!(out.contains("reload applies to drawing references only"), "got: {out:?}"); + assert!(!out.contains("no references match"), "spurious no-match, got: {out:?}"); + assert!(app.tabs[i].xref_unloaded.is_unloaded(key), "image flag must stay untouched"); + std::fs::remove_dir_all(&dir).ok(); + } } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 8f7ca88f2..e31f8c07f 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1420,6 +1420,21 @@ impl OpenCADStudio { idx }; + // Missing-on-open: neutral command-line notice plus a flag + // the Reference Manager reads. Never an auto-open modal. + let missing = caches + .xrefs + .iter() + .filter(|item| item.status == crate::io::xref::XrefStatus::NotFound) + .count(); + self.tabs[i].xref_missing = missing; + if missing > 0 { + self.command_line.push_output(crate::tf!( + "{} reference(s) not found — open the reference manager with EXTERNALREFERENCES.", + missing + ).as_ref()); + } + let mut recovery_report = recovery_needed.then(|| { crate::io::recovery::RecoveryReport::recovered( self.tabs[i].id, @@ -2037,7 +2052,25 @@ impl OpenCADStudio { crate::ui::wrap_bar::dropdown_bounds(crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID) }); let clone_started = iced::time::Instant::now(); - let snapshot = self.tabs[i].scene.document.clone(); + let mut snapshot = self.tabs[i].scene.document.clone(); + // Save-As across folders: rebase relative reference paths onto the + // new base dir inside the snapshot only (live strings are untouched). + if purpose == crate::app::SavePurpose::SaveAs { + let old_base = self.tabs[i] + .current_path + .as_ref() + .and_then(|p| p.parent().map(|p| p.to_path_buf())); + let new_base = path.parent().map(|p| p.to_path_buf()); + if let (Some(old), Some(new)) = (old_base, new_base) { + if old != new { + crate::io::xref::rebase_relative_paths_for_save_as( + &mut snapshot, + &old, + &new, + ); + } + } + } let clone_ms = clone_started.elapsed().as_secs_f64() * 1000.0; if crate::perf::enabled() { crate::perf_record!( @@ -2321,10 +2354,30 @@ impl OpenCADStudio { } } if outcome.set_current_path { + let old_path = self.tabs[i].current_path.clone(); self.tabs[i].current_path = Some(outcome.path.clone()); self.tabs[i].scene.document.version = outcome.version; if outcome.purpose == crate::app::SavePurpose::SaveAs { self.tabs[i].recovery_save_as_required = false; + // Save-As across folders: the save snapshot was rebased + // in `queue_native_save`; rebase the live document too + // so the running session agrees with the file just + // written. Saves are not undoable, so no undo snapshot + // is pushed. + if let Some(old) = old_path.as_deref() { + let old_base = old.parent().map(|p| p.to_path_buf()); + let new_base = + outcome.path.parent().map(|p| p.to_path_buf()); + if let (Some(old), Some(new)) = (old_base, new_base) { + if old != new { + crate::io::xref::rebase_relative_paths_for_save_as( + &mut self.tabs[i].scene.document, + &old, + &new, + ); + } + } + } } } tasks.push(self.push_recent(outcome.path.clone())); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 9d5e17ded..103796246 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -2156,6 +2156,30 @@ impl OpenCADStudio { self.xref_manager.toggle_expand(key); Task::none() } + Message::XrefManagerOp(op) => { + self.xref_manager_op(op); + Task::none() + } + Message::XrefManagerPathInput(text) => { + self.xref_manager.path_input = text; + Task::none() + } + Message::XrefManagerPathApply => { + self.xref_manager_path_apply(); + Task::none() + } + Message::XrefManagerFindInput(text) => { + self.xref_manager.find_input = text; + Task::none() + } + Message::XrefManagerReplaceInput(text) => { + self.xref_manager.replace_input = text; + Task::none() + } + Message::XrefManagerFindReplaceApply => { + self.xref_manager_find_replace_apply(); + Task::none() + } Message::LayerStateManagerOpen => { let i = self.active_tab; diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 43a9e4fa5..445ce6ae5 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -20,8 +20,7 @@ impl OpenCADStudio { Some(K::UpdateNotice) => crate::tr!("modal", "update-available"), Some(K::DonationPrompt) => crate::tr!("donation", "title"), Some(K::Layers) => crate::tr!("modal", "layer-manager"), - // XREF-Task8: locale (hardcoded until Task 8 batches locale strings). - Some(K::XrefManager) => "Reference Manager".to_string(), + Some(K::XrefManager) => crate::t!("Reference Manager").into_owned(), Some(K::LayerStateManager) => crate::tr!("modal", "layer-state-manager"), Some(K::LayerTranslator) => crate::t!("Layer Translator").into_owned(), Some(K::DrawingUnits) => crate::t!("Drawing Units").into_owned(), @@ -397,8 +396,9 @@ impl OpenCADStudio { ) } super::super::ModalKind::XrefManager => { - sized_flow(ex, 960, 520, |flow| { - self.xref_manager.view_window(flow) + sized_flow(ex, 960, 560, |flow| { + let missing = self.tabs[self.active_tab].xref_missing; + self.xref_manager.view_window(flow, missing) }) } super::super::ModalKind::LayerTranslator => { diff --git a/src/io/xref.rs b/src/io/xref.rs index 71e3857af..28d1f54a4 100644 --- a/src/io/xref.rs +++ b/src/io/xref.rs @@ -54,7 +54,20 @@ pub struct XrefInfo { /// just like the host doc, so it gets the same corrupt-entity guard. Folding /// it in here avoids a second full-document `entities()` walk after resolve. pub fn resolve_xrefs(doc: &mut CadDocument, base_dir: &Path) -> (Vec, usize) { - resolve_xrefs_with_progress(doc, base_dir, None) + resolve_xrefs_with_filter(doc, base_dir, None, None) +} + +/// Reload only the direct xref block records identified by `keys`. +/// +/// Unlike [`resolve_xrefs`], this never touches unmatched references. This is +/// the operation behind `XREF Reload `: a targeted reload must not +/// silently reload (or alter the stale baseline of) every other reference. +pub fn resolve_xrefs_for_keys( + doc: &mut CadDocument, + base_dir: &Path, + keys: &HashSet, +) -> (Vec, usize) { + resolve_xrefs_with_filter(doc, base_dir, None, Some(keys)) } /// Resolve XREFs while reporting completed work units. @@ -66,12 +79,22 @@ pub fn resolve_xrefs_with_progress( doc: &mut CadDocument, base_dir: &Path, progress: Option>, +) -> (Vec, usize) { + resolve_xrefs_with_filter(doc, base_dir, progress, None) +} + +fn resolve_xrefs_with_filter( + doc: &mut CadDocument, + base_dir: &Path, + progress: Option>, + only: Option<&HashSet>, ) -> (Vec, usize) { // Auto-resolve every xref — frustum + LOD culling keep GPU cost bounded. let xref_entries: Vec<(String, String, Handle)> = doc .block_records .iter() .filter(|br| (br.flags.is_xref || br.flags.is_xref_overlay) && !br.xref_path.is_empty()) + .filter(|br| only.is_none_or(|keys| keys.contains(&br.handle))) .map(|br| (br.name.clone(), br.xref_path.clone(), br.handle)) .collect(); let xref_count = xref_entries.len(); @@ -224,6 +247,24 @@ pub fn resolve_xrefs_with_progress( if recovered || invalid > 0 { (XrefStatus::Failed, diagnostics, Some(outcome.stats)) } else { + // Reload is replacement, never append. Keep the prior merge + // intact until parsing succeeded, then discard its generated + // entities, sortents, and dependent symbols immediately before + // importing the replacement content. + // + // VISRETAIN=1 (`header.retain_xref_visibility`): host-side + // layer overrides survive the purge — snapshot them first and + // re-apply onto the freshly merged layers below. Without this + // every reload silently reset frozen/off/color tweaks the + // user made on `name|*` layers back to file defaults. + let visretain = doc.header.retain_xref_visibility; + let kept_layers = if visretain { + snapshot_dependent_layers(doc, &block_name) + } else { + HashMap::default() + }; + let _ = unload_reference(doc, br_handle.value()); + remove_pipe_symbols(doc, &block_name); ensure_block_entities(doc, &block_name); dropped += merge_xref_into_block( doc, @@ -231,6 +272,9 @@ pub fn resolve_xrefs_with_progress( br_handle, outcome.document, ); + if visretain { + restore_dependent_layers(doc, &block_name, kept_layers); + } (XrefStatus::Loaded, diagnostics, Some(outcome.stats)) } } @@ -312,6 +356,415 @@ fn ensure_block_entities(doc: &mut CadDocument, block_name: &str) { let _ = doc.add_entity(EntityType::BlockEnd(BlockEnd::new())); } +/// Shared symbol-remap core for XREF load-merge and BIND (SPIKE3 REFACTOR +/// verdict: extract, don't duplicate). +/// +/// Both paths import the source doc's symbol tables into the host and rewrite +/// every entity reference to the imported copies. They differ only in naming: +/// the load path prefixes `{xref}|{sym}` (collisions overwrite), BIND renames +/// `{parent}$N${sym}` (N bumped past collisions). +/// +/// `bind_limitations` — symbol references this helper can NOT remap (counted +/// as unremapped; BIND reports the count per bind, the load path ignores it): +/// - MLINE `style_name`/`style_handle`: no host MLineStyle-table import — +/// the handle is nulled, the name kept verbatim. +/// - Material / plotstyle / full-face-edge visual-style / color-book handles +/// (`EntityCommon` + ACIS `material_handle` bindings on `Solid3D`): the +/// target objects are never imported — handles nulled. +/// - Tolerance `dimension_style_handle`, Shape `style_handle`, MultiLeader +/// `style_handle`/`text_style_handle`, Table `table_style_handle` and an +/// unmapped `block_record_handle`: nulled. +/// - Kept verbatim (audited, benign): linetype handles (the name carries the +/// lookup), Leader `annotation_handle`, Hatch `boundary_handles`, +/// `attdef_handle`, reactors, xdata, extension dictionaries, graphic data, +/// Dimension anonymous-block names (`*D…` — geometry regenerates from the +/// style), Hatch pattern names (no pattern table exists), Shape style +/// names, MultiLeader line-type/arrowhead handles, Table cell internals +/// (rows/cells keep xref text-style names), Viewport visual-style handles, +/// SectionSymbol style handles, `Extended` entities. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +enum SymbolKind { + Layer, + Linetype, + TextStyle, + DimStyle, + Block, +} + +/// Per-kind collision sets for BIND naming, preloaded from the host doc +/// (uppercased — table lookup is case-insensitive). +#[derive(Debug, Default)] +struct BindTaken { + layers: HashSet, + linetypes: HashSet, + text_styles: HashSet, + dim_styles: HashSet, + blocks: HashSet, +} + +impl BindTaken { + fn capture(doc: &CadDocument) -> Self { + let upper_names = + |names: Vec| names.into_iter().map(|n| n.to_uppercase()).collect(); + Self { + layers: upper_names(doc.layers.names().map(|s| s.to_string()).collect()), + linetypes: upper_names(doc.line_types.names().map(|s| s.to_string()).collect()), + text_styles: upper_names( + doc.text_styles.names().map(|s| s.to_string()).collect(), + ), + dim_styles: upper_names(doc.dim_styles.names().map(|s| s.to_string()).collect()), + blocks: upper_names( + doc.block_records + .iter() + .map(|b| b.name.clone()) + .collect(), + ), + } + } + + fn set(&mut self, kind: SymbolKind) -> &mut HashSet { + match kind { + SymbolKind::Layer => &mut self.layers, + SymbolKind::Linetype => &mut self.linetypes, + SymbolKind::TextStyle => &mut self.text_styles, + SymbolKind::DimStyle => &mut self.dim_styles, + SymbolKind::Block => &mut self.blocks, + } + } + + /// Claim `{parent}$N${sym}`, bumping N past every taken name, and record + /// the winner so sequential imports compose transitive chains like + /// `PLAN$0$DETAIL$0$WALLS`. + fn claim(&mut self, kind: SymbolKind, parent: &str, sym: &str) -> String { + let mut n = 0u32; + loop { + let candidate = format!("{parent}${n}${sym}"); + if self.set(kind).insert(candidate.to_uppercase()) { + return candidate; + } + n += 1; + } + } +} + +/// How imported symbol names are formed. +enum SymbolNaming { + /// Load path: `{prefix}|{sym}` (collisions overwrite via add_or_replace). + MergePipe { prefix: String }, + /// BIND path: `{parent}$N${sym}` with per-kind collision dedup. + BindDollar { parent: String, taken: BindTaken }, +} + +impl SymbolNaming { + fn name_for(&mut self, kind: SymbolKind, sym: &str) -> String { + match self { + SymbolNaming::MergePipe { prefix } => format!("{prefix}|{sym}"), + SymbolNaming::BindDollar { parent, taken } => taken.claim(kind, parent, sym), + } + } +} + +/// Imported-name maps for one source document (old UPPER name → new name, +/// old handle → new handle). +#[derive(Default)] +struct XrefSymbolMaps { + layers: HashMap, + linetypes: HashMap, + text_styles: HashMap, + dim_styles: HashMap, + blocks: HashMap, + br_handles: HashMap, + image_defs: HashMap, + underlay_defs: HashMap, +} + +/// Import a source doc's symbol tables + IMAGE/UNDERLAY definitions into the +/// host doc under `naming`, returning the old→new maps for entity remap. +/// +/// Covers layers (incl. "0"), linetypes (minus sentinels), text styles, +/// dim styles, nested block records (layout `*` records and nested xrefs +/// skipped — BIND resolves those before merging), and every image/underlay +/// definition (fresh handle, detached owner, verbatim path — BIND retargets +/// image paths via COLLECT before merging). +fn import_xref_symbols( + doc: &mut CadDocument, + xref_doc: &CadDocument, + naming: &mut SymbolNaming, +) -> XrefSymbolMaps { + let mut maps = XrefSymbolMaps::default(); + + for layer in xref_doc.layers.iter() { + let old = layer.name.clone(); + let new = naming.name_for(SymbolKind::Layer, &old); + let mut cloned = layer.clone(); + cloned.name = new.clone(); + cloned.set_handle(doc.allocate_handle()); + doc.layers.add_or_replace(cloned); + maps.layers.insert(old.to_uppercase(), new); + } + + for lt in xref_doc.line_types.iter() { + let old = lt.name.clone(); + if is_sentinel_linetype(&old) { + continue; + } + let new = naming.name_for(SymbolKind::Linetype, &old); + let mut cloned = lt.clone(); + cloned.name = new.clone(); + cloned.set_handle(doc.allocate_handle()); + doc.line_types.add_or_replace(cloned); + maps.linetypes.insert(old.to_uppercase(), new); + } + + for ts in xref_doc.text_styles.iter() { + let old = ts.name.clone(); + let new = naming.name_for(SymbolKind::TextStyle, &old); + let mut cloned = ts.clone(); + cloned.name = new.clone(); + cloned.set_handle(doc.allocate_handle()); + cloned.xref_dependent = false; + doc.text_styles.add_or_replace(cloned); + maps.text_styles.insert(old.to_uppercase(), new); + } + + for ds in xref_doc.dim_styles.iter() { + let old = ds.name.clone(); + let new = naming.name_for(SymbolKind::DimStyle, &old); + let mut cloned = ds.clone(); + cloned.name = new.clone(); + cloned.set_handle(doc.allocate_handle()); + cloned.xref_reference = false; + cloned.xref_resolved = false; + cloned.xref_dependent = false; + cloned.xref_handle = Handle::NULL; + doc.dim_styles.add_or_replace(cloned); + maps.dim_styles.insert(old.to_uppercase(), new); + } + + // Nested block records: prefixed clones with cleared membership, detached + // layout pointer, fresh handles (same shape as the load path always made). + for br in xref_doc.block_records.iter() { + if br.name.starts_with('*') || br.flags.is_xref || br.flags.is_xref_overlay { + continue; + } + let old = br.name.clone(); + let new = naming.name_for(SymbolKind::Block, &old); + let mut cloned = br.clone(); + cloned.name = new.clone(); + cloned.entity_handles.clear(); + cloned.insert_handles.clear(); + cloned.layout = Handle::NULL; + let new_h = doc.allocate_handle(); + cloned.set_handle(new_h); + cloned.block_entity_handle = doc.allocate_handle(); + cloned.block_end_handle = doc.allocate_handle(); + maps.br_handles.insert(br.handle, new_h); + maps.blocks.insert(old.to_uppercase(), new); + doc.block_records.add_or_replace(cloned); + } + + // IMAGE / UNDERLAY definitions: the entities reference these by handle, + // so without an import every bound image loses its file link. Fresh + // handle, detached owner; the path stays verbatim here. + { + use acadrust::objects::ObjectType; + for (handle, obj) in xref_doc.objects.iter() { + match obj { + ObjectType::ImageDefinition(def) => { + let mut cloned = def.clone(); + let new_h = doc.allocate_handle(); + cloned.handle = new_h; + cloned.owner = Handle::NULL; + doc.objects + .insert(new_h, ObjectType::ImageDefinition(cloned)); + maps.image_defs.insert(*handle, new_h); + } + ObjectType::UnderlayDefinition(def) => { + let mut cloned = def.clone(); + let new_h = doc.allocate_handle(); + cloned.handle = new_h; + cloned.owner_handle = Handle::NULL; + cloned.reactors.clear(); + doc.objects + .insert(new_h, ObjectType::UnderlayDefinition(cloned)); + maps.underlay_defs.insert(*handle, new_h); + } + _ => {} + } + } + } + + maps +} + +/// Null one optional foreign handle, counting it as unremapped when set. +/// Returns 1 when a handle was cleared, 0 otherwise. +fn null_handle(handle: &mut Option) -> usize { + if handle.is_some() { + *handle = None; + 1 + } else { + 0 + } +} + +/// Rewrite one entity's symbol references to the imported copies in place. +/// Returns the count of unremappable foreign handles (see +/// `bind_limitations` above) — ignored on the load path, reported per BIND. +fn remap_xref_entity( + host: &CadDocument, + entity: &mut EntityType, + maps: &XrefSymbolMaps, +) -> usize { + let mut unremapped = 0usize; + let rename = |map: &HashMap, name: &mut String| { + if let Some(new) = map.get(&name.to_uppercase()) { + *name = new.clone(); + } + }; + + // ── Common layer / linetype + material-style handles ── + { + let c = entity.common_mut(); + rename(&maps.layers, &mut c.layer); + if !is_sentinel_linetype(&c.linetype) { + rename(&maps.linetypes, &mut c.linetype); + } + unremapped += null_handle(&mut c.material_handle); + unremapped += null_handle(&mut c.plotstyle_handle); + unremapped += null_handle(&mut c.full_visual_style_handle); + unremapped += null_handle(&mut c.face_visual_style_handle); + unremapped += null_handle(&mut c.edge_visual_style_handle); + unremapped += null_handle(&mut c.color_book_handle); + } + + // ── Text-style names (incl. embedded MText in attributes) ── + let remap_mtext_style = |mtext: &mut acadrust::entities::MText, maps: &XrefSymbolMaps| { + rename(&maps.text_styles, &mut mtext.style); + }; + match entity { + EntityType::Text(t) => rename(&maps.text_styles, &mut t.style), + EntityType::MText(m) => remap_mtext_style(m, maps), + EntityType::AttributeDefinition(a) => { + rename(&maps.text_styles, &mut a.text_style); + if let Some(m) = a.embedded_mtext.as_mut() { + remap_mtext_style(m, maps); + } + } + EntityType::AttributeEntity(a) => { + rename(&maps.text_styles, &mut a.text_style); + if let Some(m) = a.embedded_mtext.as_mut() { + remap_mtext_style(m, maps); + } + } + // ── Dim-style names (+ anonymous-block refs stay verbatim) ── + EntityType::Dimension(d) => rename(&maps.dim_styles, &mut d.base_mut().style_name), + EntityType::Leader(l) => rename(&maps.dim_styles, &mut l.dimension_style), + EntityType::Tolerance(t) => { + rename(&maps.dim_styles, &mut t.dimension_style_name); + unremapped += null_handle(&mut t.dimension_style_handle); + } + // ── Block refs by name ── + EntityType::Insert(ins) => rename(&maps.blocks, &mut ins.block_name), + // ── Table: style handles nulled, legacy block refs remapped ── + EntityType::Table(t) => { + unremapped += null_handle(&mut t.table_style_handle); + if let Some(br) = t.block_record_handle { + match maps.br_handles.get(&br) { + Some(&new_br) => t.block_record_handle = Some(new_br), + None => { + t.block_record_handle = None; + unremapped += 1; + } + } + } + rename(&maps.blocks, &mut t.block_name); + } + // ── MultiLeader / MLine / Shape style handles (names verbatim) ── + EntityType::MultiLeader(m) => { + unremapped += null_handle(&mut m.style_handle); + unremapped += null_handle(&mut m.text_style_handle); + } + EntityType::MLine(m) => { + unremapped += null_handle(&mut m.style_handle); + } + EntityType::Shape(s) => { + unremapped += null_handle(&mut s.style_handle); + } + EntityType::Solid3D(s) => { + for m in &mut s.acis_data.materials { + unremapped += null_handle(&mut m.material_handle); + } + } + // ── IMAGE / UNDERLAY definition links ── + EntityType::RasterImage(img) => { + if let Some(old) = img.definition_handle { + if let Some(&new) = maps.image_defs.get(&old) { + img.definition_handle = Some(new); + // Keep the entity path in sync with its definition (bind + // COLLECT retargets the definition before merging). + use acadrust::objects::ObjectType; + if let Some(ObjectType::ImageDefinition(def)) = host.objects.get(&new) { + img.file_path = def.file_name.clone(); + } + } + } + img.definition_reactor_handle = None; + } + EntityType::Underlay(ul) => { + if let Some(&new) = maps.underlay_defs.get(&ul.definition_handle) { + ul.definition_handle = new; + } + } + _ => {} + } + unremapped +} + +/// Re-point a source doc's draw-order tables at the merged blocks/handles, so +/// an explicit DRAWORDER inside the source survives the merge. +fn copy_xref_sortents( + doc: &mut CadDocument, + xref_doc: &CadDocument, + target_owner: Handle, + br_handle_map: &HashMap, + entity_handle_map: &HashMap, +) { + use acadrust::objects::{ObjectType, SortEntitiesTable}; + let xref_ms_handle = xref_doc.header.model_space_block_handle; + for obj in xref_doc.objects.values() { + let ObjectType::SortEntitiesTable(t) = obj else { + continue; + }; + if t.is_empty() { + continue; + } + let new_block = if t.block_owner_handle == xref_ms_handle { + target_owner + } else if let Some(&h) = br_handle_map.get(&t.block_owner_handle) { + h + } else { + continue; + }; + let mut nt = SortEntitiesTable::new(); + nt.handle = doc.allocate_handle(); + nt.block_owner_handle = new_block; + for e in t.entries() { + let Some(&ne) = entity_handle_map.get(&e.entity_handle) else { + continue; + }; + let ns = entity_handle_map + .get(&e.sort_handle) + .copied() + .unwrap_or(e.sort_handle); + nt.add_entry(ne, ns); + } + if !nt.is_empty() { + doc.objects.insert(nt.handle, ObjectType::SortEntitiesTable(nt)); + } + } +} + /// Merge an external-reference document into `doc`'s xref block. /// /// Copies the xref's model-space entities into the host xref block, AND @@ -338,108 +791,69 @@ fn merge_xref_into_block( br.units = src_insunits; } - // ── Layers ────────────────────────────────────────────────────────── - // Prefix every xref layer (including "0"). Entity layer references are - // remapped below so the resolver finds the merged copy. Host layers - // (incl. its own "0") are untouched — no collisions. - let mut layer_map: HashMap = HashMap::default(); - for layer in xref_doc.layers.iter() { - let old = layer.name.clone(); - let new = format!("{}|{}", prefix, old); - let mut cloned = layer.clone(); - xref_doc.normalize_imported_layer_defaults(&mut cloned); - cloned.name = new.clone(); - cloned.set_handle(doc.allocate_handle()); - doc.layers.add_or_replace(cloned); - layer_map.insert(old.to_uppercase(), new); - } + // ── Symbol tables + definitions (shared helper) ───────────────────── + // Same prefixed scheme as before ("{xref}|{sym}"); the helper additionally + // imports text styles, dim styles, and IMAGE/UNDERLAY definitions. + let mut naming = SymbolNaming::MergePipe { + prefix: prefix.to_string(), + }; + let maps = import_xref_symbols(doc, &xref_doc, &mut naming); - // ── Linetypes ─────────────────────────────────────────────────────── - // Skip the three sentinel names — "ByLayer" / "ByBlock" / "Continuous" - // are magic strings the resolver matches verbatim in both docs. - let mut linetype_map: HashMap = HashMap::default(); - for lt in xref_doc.line_types.iter() { - let old = lt.name.clone(); - if is_sentinel_linetype(&old) { - continue; - } - let new = format!("{}|{}", prefix, old); - let mut cloned = lt.clone(); - cloned.name = new.clone(); - cloned.set_handle(doc.allocate_handle()); - doc.line_types.add_or_replace(cloned); - linetype_map.insert(old.to_uppercase(), new); - } + // ── Entities (shared helper) ──────────────────────────────────────── + // The unremapped-handle count is ignored on the load path (BIND reports + // it per bind). + let (dropped, _, entity_handle_map) = + merge_source_entities(doc, &xref_doc, br_handle, &maps); - // ── Block records (nested blocks inside the xref) ─────────────────── - // First pass: create each prefixed BR with its host handle + empty - // entity_handles. Second pass (in the entity loop below) populates - // them by inserting entities with `owner_handle` set. - // - // Tracks (xref-doc BR handle → host BR handle) so entities owned by - // a nested xref block can be routed to the right host block_record. - let mut br_handle_map: HashMap = HashMap::default(); - let mut block_name_map: HashMap = HashMap::default(); + // ── Draw-order overrides (shared helper) ────────────────────────────── + copy_xref_sortents(doc, &xref_doc, br_handle, &maps.br_handles, &entity_handle_map); + dropped +} + +/// Source entities in display order: each block record's `entity_handles` +/// chain is the draw order inside that block, while the flat entity list is +/// stream order — merging in stream order scrambles the source's own stacking +/// (fills over outlines etc.), since the host ranks draw order by the +/// freshly-allocated handles. Entities missing from every chain keep their +/// stream order at the end. BLOCK/BlockEnd markers are excluded. +fn ordered_source_entities(xref_doc: &CadDocument) -> Vec<(Handle, EntityType)> { + let mut ordered: Vec = Vec::new(); + let mut seen: HashSet = HashSet::default(); for br in xref_doc.block_records.iter() { - // Skip layout block records (*Model_Space, *Paper_Space, *Paper_Space0…) - // and any further-nested xrefs (we don't recurse into xref-of-xref). - if br.name.starts_with('*') || br.flags.is_xref || br.flags.is_xref_overlay { - continue; - } - let old = br.name.clone(); - let new = format!("{}|{}", prefix, old); - let mut cloned = br.clone(); - cloned.name = new.clone(); - cloned.entity_handles.clear(); - cloned.insert_handles.clear(); - // Detach foreign layout pointer — it refers to a Layout in xref_doc - // we don't import. - cloned.layout = Handle::NULL; - let new_h = doc.allocate_handle(); - cloned.set_handle(new_h); - cloned.block_entity_handle = doc.allocate_handle(); - cloned.block_end_handle = doc.allocate_handle(); - br_handle_map.insert(br.handle, new_h); - block_name_map.insert(old.to_uppercase(), new); - doc.block_records.add_or_replace(cloned); - } - - // ── Entities ──────────────────────────────────────────────────────── - let xref_ms_handle = xref_doc.header.model_space_block_handle; - // Merge in each block's DISPLAY order: a block record's entity_handles - // chain is the draw order inside that block, while the flat entity list - // is stream order — merging in stream order scrambled the xref's own - // stacking (fills over outlines etc.), since the host ranks draw order - // by the freshly-allocated handles. Entities missing from every chain - // keep their stream order at the end. - let ordered: Vec = { - let mut ordered: Vec = Vec::new(); - let mut seen: HashSet = HashSet::default(); - for br in xref_doc.block_records.iter() { - for &h in &br.entity_handles { - if seen.insert(h) { - ordered.push(h); - } - } - } - for e in xref_doc.entities() { - let h = e.common().handle; + for &h in &br.entity_handles { if seen.insert(h) { ordered.push(h); } } - ordered - }; - let entities: Vec<(Handle, EntityType)> = ordered + } + for e in xref_doc.entities() { + let h = e.common().handle; + if seen.insert(h) { + ordered.push(h); + } + } + ordered .into_iter() .filter_map(|h| xref_doc.get_entity(h).map(|e| (h, e.clone()))) .filter(|(_, e)| !matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_))) - .collect(); + .collect() +} - // old xref-doc entity handle → merged host handle, for the sortents copy. +/// Merge one source doc's entities into `target`, routing model-space content +/// into `target_br` and nested-block content into its imported counterpart +/// (`maps.br_handles`). Returns corrupt-drop count, unremapped-handle count, +/// and the old→new entity handle map for the sortents copy. +fn merge_source_entities( + target: &mut CadDocument, + source: &CadDocument, + target_br: Handle, + maps: &XrefSymbolMaps, +) -> (usize, usize, HashMap) { + let source_ms_handle = source.header.model_space_block_handle; let mut entity_handle_map: HashMap = HashMap::default(); let mut dropped = 0usize; - for (old_h, mut entity) in entities { + let mut unremapped = 0usize; + for (old_h, mut entity) in ordered_source_entities(source) { // Drop parser-garbage entities (bad normals / vertex counts / inf // coords) before they enter the host doc — they trigger huge // allocations and unbounded recursion in the wire pipeline. @@ -447,35 +861,18 @@ fn merge_xref_into_block( dropped += 1; continue; } - // Remap layer / linetype names so the host's resolver hits the - // copies we just inserted. - { - let c = entity.common_mut(); - if let Some(new_layer) = layer_map.get(&c.layer.to_uppercase()) { - c.layer = new_layer.clone(); - } - if !is_sentinel_linetype(&c.linetype) { - if let Some(new_lt) = linetype_map.get(&c.linetype.to_uppercase()) { - c.linetype = new_lt.clone(); - } - } - } - - // INSERTs reference blocks by name, not handle — remap. - if let EntityType::Insert(ins) = &mut entity { - if let Some(new_name) = block_name_map.get(&ins.block_name.to_uppercase()) { - ins.block_name = new_name.clone(); - } - } - + // Remap every symbol reference so the host's resolver hits the copies + // just imported (load path ignores the unremapped count; BIND reports + // it per bind). + unremapped += remap_xref_entity(target, &mut entity, maps); // Route entity to the correct host block record. Entities owned by - // the xref's model_space land in the host xref block (br_handle); - // entities owned by one of the nested BRs land in its prefixed - // counterpart. Paper-space / unknown owners are skipped. + // the source model_space land in the target block; entities owned by + // one of the nested BRs land in its imported counterpart. + // Paper-space / unknown owners are skipped. let old_owner = entity.common().owner_handle; - let new_owner = if old_owner == xref_ms_handle { - br_handle - } else if let Some(&h) = br_handle_map.get(&old_owner) { + let new_owner = if old_owner == source_ms_handle { + target_br + } else if let Some(&h) = maps.br_handles.get(&old_owner) { h } else { continue; @@ -483,50 +880,11 @@ fn merge_xref_into_block( entity.common_mut().owner_handle = new_owner; // Clear the foreign handle so acadrust assigns a new one. set_handle(&mut entity, Handle::NULL); - if let Ok(new_h) = doc.add_entity(entity) { + if let Ok(new_h) = target.add_entity(entity) { entity_handle_map.insert(old_h, new_h); } } - - // ── Draw-order overrides ──────────────────────────────────────────── - // Re-point the xref's SortEntitiesTables at the merged blocks with the - // merged entity handles, so an explicit DRAWORDER inside the xref - // survives the merge. - { - use acadrust::objects::{ObjectType, SortEntitiesTable}; - for obj in xref_doc.objects.values() { - let ObjectType::SortEntitiesTable(t) = obj else { - continue; - }; - if t.is_empty() { - continue; - } - let new_block = if t.block_owner_handle == xref_ms_handle { - br_handle - } else if let Some(&h) = br_handle_map.get(&t.block_owner_handle) { - h - } else { - continue; - }; - let mut nt = SortEntitiesTable::new(); - nt.handle = doc.allocate_handle(); - nt.block_owner_handle = new_block; - for e in t.entries() { - let Some(&ne) = entity_handle_map.get(&e.entity_handle) else { - continue; - }; - let ns = entity_handle_map - .get(&e.sort_handle) - .copied() - .unwrap_or(e.sort_handle); - nt.add_entry(ne, ns); - } - if !nt.is_empty() { - doc.objects.insert(nt.handle, ObjectType::SortEntitiesTable(nt)); - } - } - } - dropped + (dropped, unremapped, entity_handle_map) } fn is_sentinel_linetype(name: &str) -> bool { @@ -540,6 +898,9 @@ fn set_handle(entity: &mut EntityType, h: Handle) { entity.common_mut().handle = h; } +/// Re-exported so CLI + tests share one derivation with the model. +pub use crate::io::xref_model::child_key; + /// Unified reference list for the XREF manager. /// /// Scans `doc` for DWG xref block-records, RasterImage-linked image @@ -548,28 +909,59 @@ fn set_handle(entity: &mut EntityType, h: Handle) { /// [`resolve_path`] against `base_dir`. Keys in `unloaded` come back as /// `Unloaded` (loaded=false) without touching the filesystem. /// -/// `Stale` (file changed since load) and `Orphaned` (definition with no -/// referencing entity) are Task 8 — this pass only emits -/// Loaded/Unloaded/NotFound. +/// `prev` holds cached load-time mtimes per key (see +/// [`RefStatCache`](crate::io::xref_model::RefStatCache)). Empty on first +/// call → no `Stale` on bootstrap. When both the live mtime and the cached +/// baseline exist and live > cached + 1s slack, a `Loaded` entry reports +/// `Stale`. Nested children whose host entry is `NotFound`/`Failed` report +/// `Orphaned` (overriding `Loaded`). +/// +/// Key namespacing: top-level keys are host handle values (unchanged). +/// Nested child keys are +/// [`child_key`](crate::io::xref_model::child_key)`(parent_key, name, +/// saved_path)` so foreign handles that collide with host handles never +/// alias a host row. Both `collect_entries` and the palette refresh path go +/// through this fn, and the palette keys rows off `(key, saved_path)`, so +/// nested rows keep resolving. /// /// Nested enumeration (SPIKE5): each LOADED DwgXref whose file resolved is /// opened read-only and its *direct* xref block-records are appended as child -/// entries (status Loaded, no geometry merge). Parsed docs live in a per-path +/// entries (no geometry merge). Parsed docs live in a per-path /// cache inside the call; already-seen normalized `saved_path`s are skipped, /// so a self- or cyclic reference enumerates once and never recurses. A -/// nested file that fails to parse is skipped silently — no diagnostics -/// plumbing exists yet (Task 8). Synchronous by design; async stat is Task 8. +/// nested file that fails to parse is skipped silently. Synchronous by +/// design; async stat is Task 8. pub fn collect_entries( doc: &acadrust::CadDocument, base_dir: &Path, - unloaded: &std::collections::HashSet, + unloaded: &std::collections::HashSet, +) -> Vec { + collect_entries_with_prev(doc, base_dir, unloaded, &std::collections::HashMap::new()) +} + +/// [`collect_entries`] with a load-time mtime baseline for `Stale` detection. +/// See [`collect_entries`] for the full contract. +pub fn collect_entries_with_prev( + doc: &acadrust::CadDocument, + base_dir: &Path, + unloaded: &std::collections::HashSet, + prev: &std::collections::HashMap, ) -> Vec { use acadrust::entities::UnderlayType; use acadrust::objects::ObjectType; - use crate::io::xref_model::{normalize_lexical, RefKind, RefStatus, RefType, ReferenceEntry}; + use crate::io::xref_model::{ + child_key, decide_status, normalize_lexical, RefKind, RefStatus, RefType, + ReferenceEntry, UnloadKey, + }; /// Resolve `raw` via `base_dir`; on success record `found_at`, stat /// size/mtime, and flip the entry to Loaded. Missing/empty stays NotFound. + /// + /// The filesystem stat is desktop-only by `cfg` (not a runtime check): the + /// wasm binary contains no `std::fs::metadata` call at all, so a web + /// session can never block on — or fail from — a host-filesystem probe. + /// Path-based web references resolve through the file-handle loader and + /// simply stay `NotFound` when unresolvable. fn stat_into(entry: &mut ReferenceEntry, raw: &str, base_dir: &Path) { if raw.is_empty() { return; @@ -579,6 +971,7 @@ pub fn collect_entries( }; entry.found_at = Some(found.to_string_lossy().into_owned()); entry.status = RefStatus::Loaded; + #[cfg(not(target_arch = "wasm32"))] if let Ok(meta) = std::fs::metadata(&found) { entry.size_bytes = Some(meta.len()); entry.modified = meta.modified().ok(); @@ -604,17 +997,24 @@ pub fn collect_entries( RefType::Attach }; entry.saved_path = br.xref_path.clone(); - if unloaded.contains(&key) { + if unloaded.contains(&UnloadKey::Direct(key)) { entry.status = RefStatus::Unloaded; - entry.loaded = false; } else { stat_into(&mut entry, &br.xref_path, base_dir); + entry.status = decide_status( + entry.status, + entry.modified, + prev.get(&key).copied(), + false, + ); } entries.push(entry); } - // ── Raster images: only definitions actually referenced by an entity ── - // (same file_name lookup direction as `resolve_raster_image_paths`). + // ── Raster images ──────────────────────────────────────────────────── + // Include unreferenced definitions too. They are actionable file + // references (and standard reference managers expose them) rather than + // silently disappearing from the palette. let mut referenced_images: HashSet = HashSet::default(); for e in doc.entities() { if let EntityType::RasterImage(img) = e { @@ -627,22 +1027,39 @@ pub fn collect_entries( let ObjectType::ImageDefinition(def) = obj else { continue; }; - if !referenced_images.contains(handle) { - continue; - } + let is_unreferenced = !referenced_images.contains(handle); let key = handle.value(); let mut entry = ReferenceEntry::new(key, file_name_only(&def.file_name), RefKind::Image); entry.saved_path = def.file_name.clone(); - if unloaded.contains(&key) { + if unloaded.contains(&UnloadKey::Direct(key)) { entry.status = RefStatus::Unloaded; - entry.loaded = false; } else { stat_into(&mut entry, &def.file_name, base_dir); + entry.status = decide_status( + entry.status, + entry.modified, + prev.get(&key).copied(), + false, + ); + if is_unreferenced { + entry.status = RefStatus::Unreferenced; + } } entries.push(entry); } // ── PDF underlays ── + // Same referenced/unreferenced split as raster images: an underlay + // definition no entity points at lists as Unreferenced (manageable, never + // merged) rather than vanishing from the palette. + let mut referenced_underlays: HashSet = HashSet::default(); + for e in doc.entities() { + if let EntityType::Underlay(u) = e { + if u.definition_handle.is_valid() { + referenced_underlays.insert(u.definition_handle); + } + } + } for (handle, obj) in doc.objects.iter() { let ObjectType::UnderlayDefinition(def) = obj else { continue; @@ -658,68 +1075,1047 @@ pub fn collect_entries( }; let mut entry = ReferenceEntry::new(key, name, RefKind::Pdf); entry.saved_path = def.file_path.clone(); - if unloaded.contains(&key) { + if unloaded.contains(&UnloadKey::Direct(key)) { entry.status = RefStatus::Unloaded; - entry.loaded = false; } else { stat_into(&mut entry, &def.file_path, base_dir); + entry.status = decide_status( + entry.status, + entry.modified, + prev.get(&key).copied(), + false, + ); + if !referenced_underlays.contains(handle) { + entry.status = RefStatus::Unreferenced; + } } entries.push(entry); } - // ── Nested enumeration: one level, read-only, no merge ── + // ── Nested enumeration: full transitive closure, read-only, no merge ── + // Paths stored in a nested drawing are relative to *that drawing*, not to + // the host. The work queue carries each parent's resolved file so every + // level gets the correct base directory. `seen` is keyed by resolved + // absolute identity and breaks A→B→A cycles as well as shared closures. let mut seen: HashSet = HashSet::default(); + let mut work: Vec<(u64, PathBuf)> = Vec::new(); for e in &entries { - seen.insert(normalize_lexical(&e.saved_path)); + if e.kind == RefKind::DwgXref && matches!(e.status, RefStatus::Loaded | RefStatus::Stale) { + if let Some(found) = &e.found_at { + let path = PathBuf::from(found); + seen.insert(normalize_lexical(&path.to_string_lossy())); + work.push((e.key, path)); + } + } } let mut nested_cache: HashMap = HashMap::default(); - let nested_hosts: Vec<(String, PathBuf)> = entries - .iter() - .filter(|e| e.kind == RefKind::DwgXref && e.status == RefStatus::Loaded) - .filter_map(|e| { - e.found_at - .as_ref() - .map(|found| (normalize_lexical(&e.saved_path), PathBuf::from(found))) - }) - .collect(); - for (cache_key, found_path) in nested_hosts { + while let Some((parent_key, parent_path)) = work.pop() { + let cache_key = normalize_lexical(&parent_path.to_string_lossy()); let nested_doc = if let Some(cached) = nested_cache.get(&cache_key) { - cached + cached.clone() } else { - match super::load_file(&found_path) { - Ok(nested) => nested_cache.entry(cache_key.clone()).or_insert(nested), + match super::load_file(&parent_path) { + Ok(nested) => { + nested_cache.insert(cache_key, nested.clone()); + nested + } Err(_) => continue, } }; + let parent_dir = parent_path.parent().unwrap_or(base_dir); for br in nested_doc.block_records.iter() { if !(br.flags.is_xref || br.flags.is_xref_overlay) { continue; } - // Cycle-guard: a nested path already seen (self-path or repeat) - // enumerates nothing — skip it. - if !seen.insert(normalize_lexical(&br.xref_path)) { + let key = child_key(parent_key, &br.name, &br.xref_path); + let mut child = ReferenceEntry::new(key, br.name.clone(), RefKind::DwgXref); + child.parent_key = Some(parent_key); + child.ref_type = if br.flags.is_xref_overlay { RefType::Overlay } else { RefType::Attach }; + child.saved_path = br.xref_path.clone(); + if unloaded.contains(&UnloadKey::Nested(key)) { + child.status = RefStatus::Unloaded; + } else { + stat_into(&mut child, &br.xref_path, parent_dir); + // Nested baselines live in the same stat cache under the + // child's high-bit-tagged key, which can never alias a host + // handle (see `child_key`) — so the lookup below is safe. + child.status = decide_status(child.status, child.modified, prev.get(&key).copied(), false); + } + if let Some(found) = &child.found_at { + let resolved_id = normalize_lexical(found); + if seen.insert(resolved_id) { + work.push((key, PathBuf::from(found))); + } + } + entries.push(child); + } + } + + // Stale propagation (plan §3.1): a Loaded parent with a Stale descendant + // shows Stale. Roots append before their children, so one reverse pass + // carries staleness transitively up any depth: by the time the iteration + // reaches a parent, the parent already reflects its own subtree. + // Unloaded/NotFound/Failed rows are never overridden. + { + let index: HashMap = + entries.iter().enumerate().map(|(i, e)| (e.key, i)).collect(); + for rev in (0..entries.len()).rev() { + if entries[rev].status != RefStatus::Stale { continue; } - let mut child = - ReferenceEntry::new(br.handle.value(), br.name.clone(), RefKind::DwgXref); - child.ref_type = if br.flags.is_xref_overlay { - RefType::Overlay - } else { - RefType::Attach - }; - child.saved_path = br.xref_path.clone(); - child.status = RefStatus::Loaded; - entries.push(child); + if let Some(pk) = entries[rev].parent_key { + if let Some(&pi) = index.get(&pk) { + if entries[pi].status == RefStatus::Loaded { + entries[pi].status = RefStatus::Stale; + } + } + } } } entries } +/// Direct (host-owned) reference target for a top-level key. +/// +/// Nested child keys are stable hashes, never handle values, so they resolve +/// to `None` here — callers treat that as "not directly actionable". +enum RefTarget { + DwgXref { handle: Handle, name: String }, + Image { handle: Handle, name: String }, + Pdf { handle: Handle, name: String }, +} + +fn find_target(doc: &CadDocument, key: u64) -> Option { + for br in doc.block_records.iter() { + if (br.flags.is_xref || br.flags.is_xref_overlay) && br.handle.value() == key { + return Some(RefTarget::DwgXref { + handle: br.handle, + name: br.name.clone(), + }); + } + } + for (handle, obj) in doc.objects.iter() { + use acadrust::objects::ObjectType; + match obj { + ObjectType::ImageDefinition(def) if handle.value() == key => { + return Some(RefTarget::Image { + handle: *handle, + name: def.file_name.clone(), + }); + } + ObjectType::UnderlayDefinition(def) if handle.value() == key => { + return Some(RefTarget::Pdf { + handle: *handle, + name: if def.name.trim().is_empty() { + def.file_path.clone() + } else { + def.name.clone() + }, + }); + } + _ => {} + } + } + None +} + +/// Drop merged content for one xref without removing its definition. +/// +/// Deletes every entity whose `owner_handle` is the xref BlockRecord handle +/// plus that block's draw-order tables; the BlockRecord (flags, path) stays. +/// Image/PDF keys need no doc mutation (session-unloaded covers them) and +/// return `Ok`. Unknown (nested-hash) keys error — nested rows are never +/// directly unloadable. +pub fn unload_reference(doc: &mut CadDocument, key: u64) -> Result { + let Some(target) = find_target(doc, key) else { + return Err(crate::t!("XREF: no loaded reference with that key.").to_string()); + }; + match target { + RefTarget::DwgXref { handle, name } => { + let owned: Vec = doc + .entities() + .filter(|e| e.common().owner_handle == handle) + .map(|e| e.common().handle) + .collect(); + let owned_set: HashSet = owned.iter().copied().collect(); + for h in owned { + doc.remove_entity(h); + } + // Prune the membership chain so the block reads back empty. + if let Some(br) = doc.block_records.iter_mut().find(|b| b.handle == handle) { + br.entity_handles.retain(|h| !owned_set.contains(h)); + } + // Drop draw-order overrides owned by the unloaded block. + { + use acadrust::objects::ObjectType; + let dead: Vec = doc + .objects + .iter() + .filter_map(|(h, o)| match o { + ObjectType::SortEntitiesTable(t) + if t.block_owner_handle == handle => + { + Some(*h) + } + _ => None, + }) + .collect(); + for h in dead { + doc.objects.remove(&h); + } + } + Ok(name) + } + RefTarget::Image { name, .. } | RefTarget::Pdf { name, .. } => Ok(name), + } +} + +/// Detach one direct reference: erase all its instances, delete the +/// BlockRecord/definition and this xref's `name|*` dependent symbols. +/// +/// - Nested keys (stable hashes, no direct definition) have no `find_target` +/// hit → `Err`. The CLI maps entries with `parent_key.is_some()` to the +/// "cannot detach nested" message with the entry name. +/// - INSERT erase walks the flat store, which holds block-owned entities too +/// (SPIKE2 item 6), so instances inside other block definitions are covered. +/// - Images/PDFs: remove the definition object plus every entity referencing +/// it by handle. +pub fn detach_reference(doc: &mut CadDocument, key: u64) -> Result { + let Some(target) = find_target(doc, key) else { + return Err(crate::t!("XREF: no loaded reference with that key.").to_string()); + }; + match target { + RefTarget::DwgXref { handle, name } => { + // (b) erase ALL INSERT instances (flat store includes block-owned). + let inserts: Vec = doc + .entities() + .filter_map(|e| match e { + EntityType::Insert(ins) + if ins.block_name.eq_ignore_ascii_case(&name) => + { + Some(e.common().handle) + } + _ => None, + }) + .collect(); + for h in inserts { + doc.remove_entity(h); + } + // Merged content owned by the xref block. + let owned: Vec = doc + .entities() + .filter(|e| e.common().owner_handle == handle) + .map(|e| e.common().handle) + .collect(); + for h in owned { + doc.remove_entity(h); + } + // (c) `name|*` dependents for THIS xref only (case-insensitive). + remove_pipe_symbols(doc, &name); + // Draw-order tables owned by the detached block. + { + use acadrust::objects::ObjectType; + let dead: Vec = doc + .objects + .iter() + .filter_map(|(h, o)| match o { + ObjectType::SortEntitiesTable(t) + if t.block_owner_handle == handle => + { + Some(*h) + } + _ => None, + }) + .collect(); + for h in dead { + doc.objects.remove(&h); + } + } + doc.block_records.remove(&name); + Ok(name) + } + RefTarget::Image { handle, name } => { + let refs: Vec = doc + .entities() + .filter_map(|e| match e { + EntityType::RasterImage(img) + if img.definition_handle == Some(handle) => + { + Some(e.common().handle) + } + _ => None, + }) + .collect(); + for h in refs { + doc.remove_entity(h); + } + doc.objects.remove(&handle); + Ok(name) + } + RefTarget::Pdf { handle, name } => { + let refs: Vec = doc + .entities() + .filter_map(|e| match e { + EntityType::Underlay(ul) if ul.definition_handle == handle => { + Some(e.common().handle) + } + _ => None, + }) + .collect(); + for h in refs { + doc.remove_entity(h); + } + doc.objects.remove(&handle); + Ok(name) + } + } +} + +// ── BIND (Task 9) ───────────────────────────────────────────────────────── + +/// Outcome of binding one reference. +#[derive(Debug)] +pub struct BindOutcome { + /// Bound block / definition name. + pub name: String, + /// Foreign style handles that could not be remapped (see + /// `bind_limitations` on the shared remap helper). + pub unremapped: usize, +} + +/// Bind one direct reference: fold the external file into the host drawing. +/// +/// - DWG xrefs become plain local blocks (same name, so existing INSERTs keep +/// resolving). Dependent symbols `name|sym` become `name$0$sym` (N bumped +/// past collisions); nested children bind first so chains compose +/// transitively (`PLAN$0$DETAIL$0$WALLS`); the whole bind-closure is +/// merged — nested geometry is never dropped. +/// - Images are COLLECTed: the file is copied next to the host drawing and +/// the definition retargeted to the relative filename. A copy failure +/// errors before the host doc is touched (no partial state). +/// - PDFs error explicitly — vector import is unavailable, and silently +/// dropping them would lose data. +/// - Nested keys have no host definition and never reach here (callers map +/// them to the "bind it in its host drawing" message, mirroring Detach). +pub fn bind_reference( + doc: &mut CadDocument, + key: u64, + base_dir: &Path, + host_dir: &Path, +) -> Result { + let Some(target) = find_target(doc, key) else { + return Err(crate::t!("XREF: no loaded reference with that key.").to_string()); + }; + match target { + RefTarget::DwgXref { handle, name } => bind_dwg(doc, handle, &name, base_dir, host_dir), + RefTarget::Image { handle, name } => { + collect_bind_images(doc, &name, base_dir, host_dir, Some(handle))?; + Ok(BindOutcome { name, unremapped: 0 }) + } + RefTarget::Pdf { .. } => Err(crate::t!( + "XREF: PDF bind (vector import) is not available in this version." + ) + .to_string()), + } +} + +fn bind_failed(name: &str, reason: impl std::fmt::Display) -> String { + crate::tf!("XREF: cannot bind \"{}\": {}.", name, reason.to_string()).to_string() +} + +fn bind_dwg( + doc: &mut CadDocument, + br_handle: Handle, + name: &str, + base_dir: &Path, + host_dir: &Path, +) -> Result { + let saved = doc + .block_records + .iter() + .find(|b| b.handle == br_handle) + .map(|b| b.xref_path.clone()) + .unwrap_or_default(); + let Some(found) = resolve_path(&saved, base_dir) else { + return Err(bind_failed(name, "file not found")); + }; + let source_dir = found + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")); + // Load the closure (this file + every nested xref, children first). All + // file copies happen below, before the host doc is touched — a copy or + // parse failure leaves no partial document state. + let mut source = load_bind_source(name, &found)?; + check_no_pdf_underlays(&source)?; + let mut stack = vec![crate::io::xref_model::normalize_lexical( + &found.to_string_lossy(), + )]; + let mut unremapped = bind_nested_closure(&mut source, &source_dir, host_dir, &mut stack)?; + collect_bind_images(&mut source, name, &source_dir, host_dir, None)?; + // Drop the previous merge + `name|*` symbols. INSERTs of the xref stay — + // they now reference the bound block. + unload_reference(doc, br_handle.value())?; + remove_pipe_symbols(doc, name); + let taken = BindTaken::capture(doc); + let (u, _) = merge_bound_doc(doc, name, br_handle, &source, taken); + unremapped += u; + // Convert the xref BlockRecord into a plain local block (same name, so + // existing INSERTs keep resolving). + if let Some(br) = doc.block_records.iter_mut().find(|b| b.handle == br_handle) { + br.flags.is_xref = false; + br.flags.is_xref_overlay = false; + br.xref_path.clear(); + } + Ok(BindOutcome { + name: name.to_string(), + unremapped, + }) +} + +fn load_bind_source(ref_name: &str, found: &Path) -> Result { + match super::load_file(found) { + Ok(doc) => Ok(doc), + Err(e) => Err(bind_failed(ref_name, e)), + } +} + +/// Reject PDF underlays anywhere in a bind closure: vector import is +/// unavailable, and silently dropping them would lose data. +fn check_no_pdf_underlays(source: &CadDocument) -> Result<(), String> { + use acadrust::entities::UnderlayType; + use acadrust::objects::ObjectType; + let has_pdf = source.objects.values().any(|o| { + matches!(o, ObjectType::UnderlayDefinition(d) if d.underlay_type == UnderlayType::Pdf) + }); + if has_pdf { + return Err(crate::t!( + "XREF: PDF bind (vector import) is not available in this version." + ) + .to_string()); + } + Ok(()) +} + +/// Recursively bind every nested xref inside `source`, children first, so the +/// parent merge composes transitive chains (`PLAN$0$DETAIL$0$WALLS`). +/// Unresolvable nested files error the whole bind; cyclic closures enumerate +/// once (mirroring `collect_entries`). Returns the nested unremapped count. +fn bind_nested_closure( + source: &mut CadDocument, + source_dir: &Path, + host_dir: &Path, + stack: &mut Vec, +) -> Result { + let children: Vec<(String, Handle, String)> = source + .block_records + .iter() + .filter(|br| (br.flags.is_xref || br.flags.is_xref_overlay) && !br.xref_path.is_empty()) + .map(|br| (br.name.clone(), br.handle, br.xref_path.clone())) + .collect(); + let mut taken = BindTaken::capture(source); + let mut unremapped = 0usize; + for (child_name, child_br, child_saved) in children { + let Some(child_found) = resolve_path(&child_saved, source_dir) else { + return Err(bind_failed(&child_name, "file not found")); + }; + let norm = + crate::io::xref_model::normalize_lexical(&child_found.to_string_lossy()); + if stack.contains(&norm) { + continue; + } + stack.push(norm); + let mut child_doc = load_bind_source(&child_name, &child_found)?; + let child_dir = child_found + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")); + unremapped += bind_nested_closure(&mut child_doc, &child_dir, host_dir, stack)?; + stack.pop(); + check_no_pdf_underlays(&child_doc)?; + collect_bind_images(&mut child_doc, &child_name, &child_dir, host_dir, None)?; + let (u, taken_back) = merge_bound_doc(source, &child_name, child_br, &child_doc, taken); + unremapped += u; + taken = taken_back; + if let Some(br) = source + .block_records + .iter_mut() + .find(|b| b.handle == child_br) + { + br.flags.is_xref = false; + br.flags.is_xref_overlay = false; + br.xref_path.clear(); + } + } + Ok(unremapped) +} + +/// Merge one fully child-bound source doc into `target` under BIND names +/// (`{parent}$N${sym}`), threading `taken` through for collision dedup. +/// Returns the merge's unremapped count plus the updated taken sets. +fn merge_bound_doc( + target: &mut CadDocument, + parent: &str, + target_br: Handle, + source: &CadDocument, + taken: BindTaken, +) -> (usize, BindTaken) { + ensure_block_entities(target, parent); + let mut naming = SymbolNaming::BindDollar { + parent: parent.to_string(), + taken, + }; + let maps = import_xref_symbols(target, source, &mut naming); + let (_, unremapped, entity_handle_map) = + merge_source_entities(target, source, target_br, &maps); + copy_xref_sortents( + target, + source, + target_br, + &maps.br_handles, + &entity_handle_map, + ); + let taken = match naming { + SymbolNaming::BindDollar { taken, .. } => taken, + SymbolNaming::MergePipe { .. } => unreachable!("bind merge always uses BindDollar"), + }; + (unremapped, taken) +} + +/// COLLECT every raster image in `source`: copy the file next to the host +/// drawing (first claimant keeps the filename, later same-name collisions get +/// `stem$N.ext`) and retarget definitions + entity paths to the relative +/// filename. Copies run before any host mutation — a failure errors with no +/// partial document state. `only` restricts the pass to one definition +/// (direct image bind); `None` collects the whole closure. +fn collect_bind_images( + source: &mut CadDocument, + ref_name: &str, + source_dir: &Path, + host_dir: &Path, + only: Option, +) -> Result<(), String> { + use acadrust::objects::ObjectType; + let defs: Vec<(Handle, String)> = source + .objects + .iter() + .filter_map(|(h, o)| match o { + ObjectType::ImageDefinition(d) + if only.is_none_or(|want| want == *h) => + { + Some((*h, d.file_name.clone())) + } + _ => None, + }) + .collect(); + if defs.is_empty() { + return Ok(()); + } + // Plan every copy first (dedupe repeat references to one source file). + let mut planned: HashMap = HashMap::default(); + let mut ops: Vec<(Handle, PathBuf, String)> = Vec::new(); + for (handle, saved) in &defs { + let Some(resolved) = resolve_path(saved, source_dir) else { + return Err(bind_failed(ref_name, format!("image not found: {saved}"))); + }; + let norm = crate::io::xref_model::normalize_lexical(&resolved.to_string_lossy()); + let dest_name = if let Some(prior) = planned.get(&norm) { + prior.clone() + } else { + let base = file_name_only(saved); + let mut candidate = base.clone(); + let mut n = 0u32; + while host_dir.join(&candidate).exists() + && !same_file(&resolved, &host_dir.join(&candidate)) + { + n += 1; + candidate = unique_file_name(&base, n); + } + planned.insert(norm, candidate.clone()); + candidate + }; + ops.push((*handle, resolved, dest_name)); + } + for (_, src, dest_name) in &ops { + let dest = host_dir.join(dest_name); + if same_file(src, &dest) { + continue; + } + if let Err(e) = std::fs::copy(src, &dest) { + return Err(bind_failed(ref_name, format!("cannot collect image: {e}"))); + } + } + // Retarget definitions + entity paths to the collected relative names. + for (handle, _, dest_name) in ops { + if let Some(ObjectType::ImageDefinition(def)) = source.objects.get_mut(&handle) { + def.file_name = dest_name.clone(); + } + let owned: Vec = source + .entities() + .filter_map(|e| match e { + EntityType::RasterImage(img) if img.definition_handle == Some(handle) => { + Some(e.common().handle) + } + _ => None, + }) + .collect(); + for eh in owned { + if let Some(EntityType::RasterImage(img)) = source.get_entity_mut(eh) { + img.file_path = dest_name.clone(); + } + } + } + Ok(()) +} + +fn file_name_only(raw: &str) -> String { + raw.rsplit(['/', '\\']).next().unwrap_or(raw).to_string() +} + +fn unique_file_name(base: &str, n: u32) -> String { + match base.rfind('.') { + Some(i) if i > 0 => format!("{}${n}.{}", &base[..i], &base[i + 1..]), + _ => format!("{base}${n}"), + } +} + +fn same_file(a: &Path, b: &Path) -> bool { + match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { + (Ok(x), Ok(y)) => x == y, + _ => a == b, + } +} + +/// Remove one xref's `name|*` dependent symbols (case-insensitive): layers, +/// linetypes, text styles, dim styles, and nested block records — plus every +/// entity and draw-order table owned by those dependent blocks. +/// +/// Shared by Detach (which then also erases the BlockRecord itself) and BIND +/// (which drops the previous merge before re-merging under `$N$` names). The +/// xref BlockRecord itself, its INSERTs, and non-prefixed symbols stay. +fn remove_pipe_symbols(doc: &mut CadDocument, name: &str) { + let prefix = format!("{}|", name.to_uppercase()); + let is_dep = |n: &str| n.to_uppercase().starts_with(&prefix); + // Dependent blocks first — their handles route the owned-entity cleanup. + let dep_brs: HashSet = doc + .block_records + .iter() + .filter(|b| is_dep(&b.name)) + .map(|b| b.handle) + .collect(); + let owned: Vec = doc + .entities() + .filter(|e| dep_brs.contains(&e.common().owner_handle)) + .map(|e| e.common().handle) + .collect(); + for h in owned { + doc.remove_entity(h); + } + { + use acadrust::objects::ObjectType; + let dead: Vec = doc + .objects + .iter() + .filter_map(|(h, o)| match o { + ObjectType::SortEntitiesTable(t) if dep_brs.contains(&t.block_owner_handle) => { + Some(*h) + } + _ => None, + }) + .collect(); + for h in dead { + doc.objects.remove(&h); + } + } + let layers: Vec = doc + .layers + .names() + .filter(|n| is_dep(n)) + .map(|s| s.to_string()) + .collect(); + for n in layers { + doc.layers.remove(&n); + } + let lts: Vec = doc + .line_types + .names() + .filter(|n| is_dep(n)) + .map(|s| s.to_string()) + .collect(); + for n in lts { + doc.line_types.remove(&n); + } + let styles: Vec = doc + .text_styles + .names() + .filter(|n| is_dep(n)) + .map(|s| s.to_string()) + .collect(); + for n in styles { + doc.text_styles.remove(&n); + } + let dims: Vec = doc + .dim_styles + .names() + .filter(|n| is_dep(n)) + .map(|s| s.to_string()) + .collect(); + for n in dims { + doc.dim_styles.remove(&n); + } + let brs: Vec = doc + .block_records + .iter() + .map(|b| b.name.clone()) + .filter(|n| is_dep(n)) + .collect(); + for n in brs { + doc.block_records.remove(&n); + } +} + +/// Snapshot one xref's dependent-layer overrides for VISRETAIN=1 reloads. +/// +/// Returns `name|*` layers keyed by uppercased name. The reload path purges +/// these entries and re-merges file defaults; without the snapshot the +/// user's frozen/off/color tweaks would be silently lost on every reload. +fn snapshot_dependent_layers( + doc: &CadDocument, + name: &str, +) -> HashMap { + let prefix = format!("{}|", name.to_uppercase()); + doc.layers + .names() + .filter(|n| n.to_uppercase().starts_with(&prefix)) + .filter_map(|n| { + doc.layers + .get(n) + .cloned() + .map(|l| (l.name.to_uppercase(), l)) + }) + .collect() +} + +/// Re-apply a [`snapshot_dependent_layers`] snapshot onto freshly merged +/// layers. Only layers present post-merge are touched (deleted-in-file layers +/// stay deleted); handles and file-structural links (material, plotstyle, +/// xref block-record pointer) stay on the fresh entries — everything a user +/// can tweak in a layer UI is restored. +fn restore_dependent_layers( + doc: &mut CadDocument, + name: &str, + kept: HashMap, +) { + if kept.is_empty() { + return; + } + let prefix = format!("{}|", name.to_uppercase()); + let targets: Vec = doc + .layers + .names() + .filter(|n| n.to_uppercase().starts_with(&prefix)) + .map(|s| s.to_string()) + .collect(); + for n in targets { + let (Some(cur), Some(old)) = ( + doc.layers.get_mut(&n), + kept.get(&n.to_uppercase()), + ) else { + continue; + }; + cur.flags = old.flags.clone(); + cur.color = old.color.clone(); + cur.color_name = old.color_name.clone(); + cur.book_name = old.book_name.clone(); + cur.line_type = old.line_type.clone(); + cur.line_weight = old.line_weight.clone(); + cur.plot_style = old.plot_style.clone(); + cur.is_plottable = old.is_plottable; + cur.transparency = old.transparency.clone(); + } +} + +/// Store `new_raw` verbatim on the definition (no synthesis). +pub fn set_ref_path( + doc: &mut CadDocument, + key: u64, + new_raw: &str, +) -> Result { + let Some(target) = find_target(doc, key) else { + return Err(crate::t!("XREF: no loaded reference with that key.").to_string()); + }; + match target { + RefTarget::DwgXref { name, .. } => { + if let Some(br) = doc.block_records.get_mut(&name) { + br.xref_path = new_raw.to_string(); + return Ok(name); + } + Err(crate::t!("XREF: no loaded reference with that key.").to_string()) + } + RefTarget::Image { handle, name } => { + use acadrust::objects::ObjectType; + match doc.objects.get_mut(&handle) { + Some(ObjectType::ImageDefinition(def)) => { + def.file_name = new_raw.to_string(); + Ok(name) + } + _ => Err(crate::t!("XREF: no loaded reference with that key.").to_string()), + } + } + RefTarget::Pdf { handle, name } => { + use acadrust::objects::ObjectType; + match doc.objects.get_mut(&handle) { + Some(ObjectType::UnderlayDefinition(def)) => { + def.file_path = new_raw.to_string(); + Ok(name) + } + _ => Err(crate::t!("XREF: no loaded reference with that key.").to_string()), + } + } + } +} + +/// Convert one reference's stored path to `pathtype` against `host_path`. +/// Verbatim store on success; neutral errors otherwise. +pub fn apply_pathtype( + doc: &mut CadDocument, + key: u64, + pathtype: crate::io::xref_model::Pathtype, + host_path: &Path, +) -> Result { + let saved = match find_target(doc, key) { + Some(RefTarget::DwgXref { name, .. }) => doc + .block_records + .get(&name) + .map(|b| b.xref_path.clone()) + .unwrap_or_default(), + Some(RefTarget::Image { handle, .. }) => { + use acadrust::objects::ObjectType; + match doc.objects.get(&handle) { + Some(ObjectType::ImageDefinition(def)) => def.file_name.clone(), + _ => String::new(), + } + } + Some(RefTarget::Pdf { handle, .. }) => { + use acadrust::objects::ObjectType; + match doc.objects.get(&handle) { + Some(ObjectType::UnderlayDefinition(def)) => def.file_path.clone(), + _ => String::new(), + } + } + None => return Err(crate::t!("XREF: no loaded reference with that key.").to_string()), + }; + // A stored relative path first has to be resolved against its current host + // folder. Passing it straight to `to_pathtype_result` made `Full` a + // no-op (`refs/a.dwg` stayed relative instead of becoming C:/…/refs/a). + let normalized = crate::io::xref_model::normalize_lexical(&saved); + let bytes = normalized.as_bytes(); + let looks_absolute = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' + || normalized.starts_with("//") + || normalized.starts_with('/'); + let stored_relative = !looks_absolute; + let effective = if stored_relative { + host_path + .parent() + .map(|dir| dir.join(&saved).to_string_lossy().into_owned()) + .unwrap_or(saved.clone()) + } else { + saved + }; + match crate::io::xref_model::to_pathtype_result(&effective, host_path, pathtype) { + Ok(next) => { + set_ref_path(doc, key, &next)?; + Ok(next) + } + Err(crate::io::xref_model::PathtypeError::AcrossDrives) => { + Err(crate::t!("XREF: cannot make path relative across drives.").to_string()) + } + Err(crate::io::xref_model::PathtypeError::UnsavedHost) => Err( + crate::t!("XREF Save the drawing first to resolve relative XREF paths.").to_string(), + ), + } +} + +/// Find & Replace: rewrite `old` → `new` as a component-wise prefix replace +/// wherever the normalized saved path has the normalized `old` path as its +/// leading run of `/`-components. Returns rewrites. +/// +/// Component-wise (never a raw byte splice): matching happens on normalized +/// forms and the result is rejoined from normalized components, so `..`-laden +/// or case-folded saved paths rewrite correctly and multibyte paths can never +/// panic on a mid-char cut. +pub fn replace_path_prefix(doc: &mut CadDocument, old: &str, new: &str) -> usize { + use crate::io::xref_model::normalize_lexical; + let old_n = normalize_lexical(old); + if old_n.is_empty() { + return 0; + } + let old_comps: Vec<&str> = old_n.split('/').collect(); + let new_n = normalize_lexical(new); + let splice = |saved: &mut String| -> bool { + let saved_n = normalize_lexical(saved); + let saved_comps: Vec<&str> = saved_n.split('/').collect(); + if saved_comps.len() < old_comps.len() { + return false; + } + if saved_comps[..old_comps.len()] != old_comps[..] { + return false; + } + let rest = &saved_comps[old_comps.len()..]; + let mut out = new_n.clone(); + if !rest.is_empty() { + if !out.is_empty() { + out.push('/'); + } + out.push_str(&rest.join("/")); + } + *saved = out; + true + }; + let mut n = 0usize; + for br in doc.block_records.iter_mut() { + if !(br.flags.is_xref || br.flags.is_xref_overlay) || br.xref_path.is_empty() { + continue; + } + if splice(&mut br.xref_path) { + n += 1; + } + } + { + use acadrust::objects::ObjectType; + for obj in doc.objects.values_mut() { + match obj { + ObjectType::ImageDefinition(def) if !def.file_name.is_empty() => { + if splice(&mut def.file_name) { + n += 1; + } + } + ObjectType::UnderlayDefinition(def) if !def.file_path.is_empty() => { + if splice(&mut def.file_path) { + n += 1; + } + } + _ => {} + } + } + } + n +} + +/// Flip Attach ↔ Overlay for one DWG xref. DWG only; images/PDFs error. +/// +/// The flip affects future nesting propagation (overlays do not re-export +/// through the host); current geometry is untouched. +pub fn set_ref_type( + doc: &mut CadDocument, + key: u64, + ref_type: crate::io::xref_model::RefType, +) -> Result { + let Some(target) = find_target(doc, key) else { + return Err(crate::t!("XREF: no loaded reference with that key.").to_string()); + }; + match target { + RefTarget::DwgXref { name, .. } => { + if let Some(br) = doc.block_records.get_mut(&name) { + match ref_type { + crate::io::xref_model::RefType::Attach => { + br.flags.is_xref = true; + br.flags.is_xref_overlay = false; + } + crate::io::xref_model::RefType::Overlay => { + br.flags.is_xref = false; + br.flags.is_xref_overlay = true; + } + } + return Ok(name); + } + Err(crate::t!("XREF: no loaded reference with that key.").to_string()) + } + RefTarget::Image { .. } | RefTarget::Pdf { .. } => { + Err(crate::t!("XREF: overlays apply to drawing references only.").to_string()) + } + } +} + +/// Save-As rebase helper, wired in `queue_native_save` (Task 8b). +/// +/// Call with the old/new parent dirs before serializing: every relative +/// saved path is made absolute via `old_base` then re-expressed relative to +/// `new_base` (absolute paths and cross-drive targets are left absolute). +/// Returns rewrite count. +/// +/// Both the save snapshot and, on Save-As completion (`on_save_finished`), +/// the live document are rebased so the running session agrees with the file +/// just written. +pub fn rebase_relative_paths_for_save_as( + doc: &mut CadDocument, + old_base: &Path, + new_base: &Path, +) -> usize { + use crate::io::xref_model::{normalize_lexical, to_pathtype_result, Pathtype}; + let is_relative = |s: &str| { + let n = normalize_lexical(s); + if n.is_empty() { + return false; + } + let b = n.as_bytes(); + if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' { + return false; + } + if n.starts_with("//") || n.starts_with('/') { + return false; + } + true + }; + // Fake host file inside the new base so `to_pathtype_result` has a parent. + let new_host = new_base.join("__host__.dwg"); + let mut n = 0usize; + let mut rebase = |saved: &mut String| { + if !is_relative(saved) { + return; + } + let abs = old_base.join(saved.clone()); + let abs_s = abs.to_string_lossy().into_owned(); + if let Ok(rel) = to_pathtype_result(&abs_s, &new_host, Pathtype::Relative) { + *saved = rel; + n += 1; + } + }; + for br in doc.block_records.iter_mut() { + if !(br.flags.is_xref || br.flags.is_xref_overlay) || br.xref_path.is_empty() { + continue; + } + rebase(&mut br.xref_path); + } + { + use acadrust::objects::ObjectType; + for obj in doc.objects.values_mut() { + match obj { + ObjectType::ImageDefinition(def) if !def.file_name.is_empty() => { + rebase(&mut def.file_name) + } + ObjectType::UnderlayDefinition(def) if !def.file_path.is_empty() => { + rebase(&mut def.file_path) + } + _ => {} + } + } + } + n +} + #[cfg(test)] mod tests { use super::collect_entries; - use crate::io::xref_model::{RefKind, RefStatus}; + use super::collect_entries_with_prev; + use super::HashSet; + use super::{ + apply_pathtype, child_key, detach_reference, replace_path_prefix, + resolve_xrefs_for_keys, restore_dependent_layers, set_ref_path, + set_ref_type, snapshot_dependent_layers, unload_reference, + }; + use crate::io::xref_model::{Pathtype, RefKind, RefStatus, RefType}; #[test] #[cfg(not(target_arch = "wasm32"))] @@ -752,4 +2148,688 @@ mod tests { assert_eq!(entries[0].saved_path, saved); assert_eq!(entries[0].kind, RefKind::DwgXref); } + + fn xref_doc_with(name: &str, saved: &str) -> (acadrust::CadDocument, u64) { + let mut doc = acadrust::CadDocument::new(); + let mut br = acadrust::tables::BlockRecord::new(name); + br.flags.is_xref = true; + br.xref_path = saved.to_string(); + br.handle = doc.allocate_handle(); + let key = br.handle.value(); + doc.block_records.add(br).expect("add xref"); + (doc, key) + } + + fn xref_tmpdir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "ocs_xref_{tag}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn write_nested_fixture(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) { + // mid.dwgxrefs inner.dwg; both must parse (enumeration reads the + // mid file's block-record table). Returns (mid, inner) abs paths. + std::fs::write(dir.join("inner.dwg"), b"inner-bytes").unwrap(); + let mut mid = acadrust::CadDocument::new(); + let mut br = acadrust::tables::BlockRecord::new("INNER"); + br.flags.is_xref = true; + br.xref_path = dir.join("inner.dwg").to_string_lossy().into_owned(); + mid.block_records.add(br).unwrap(); + let bytes = crate::io::save_to_bytes(&mid, "dwg", mid.version).unwrap(); + std::fs::write(dir.join("mid.dwg"), &bytes).unwrap(); + (dir.join("mid.dwg"), dir.join("inner.dwg")) + } + + #[test] + fn nested_stale_propagates_to_parent() { + // Plan §3.1: a Loaded parent with a Stale nested descendant shows + // Stale. The nested baseline predates the live mtime (epoch), so the + // child is Stale and the reverse pass carries it to the parent — + // without touching Unloaded/Failed rows. + use std::time::UNIX_EPOCH; + let dir = xref_tmpdir("staleprop"); + let (mid_path, inner_path) = write_nested_fixture(&dir); + let (doc, mid_key) = xref_doc_with( + "MID", + &mid_path.to_string_lossy(), + ); + let inner_key = child_key( + mid_key, + "INNER", + &inner_path.to_string_lossy(), + ); + let mut prev = std::collections::HashMap::new(); + prev.insert(inner_key, UNIX_EPOCH); + let entries = collect_entries_with_prev( + &doc, + &dir, + &std::collections::HashSet::new(), + &prev, + ); + let inner = entries.iter().find(|e| e.key == inner_key).expect("nested INNER listed"); + assert_eq!(inner.status, RefStatus::Stale); + let mid = entries.iter().find(|e| e.key == mid_key).expect("MID listed"); + assert_eq!(mid.status, RefStatus::Stale, "parent reflects stale child"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn xref_end_to_end_engine_lifecycle() { + // One true integration pass over a real DWG fixture (nested xref + + // referenced image + unreferenced PDF): attach-equivalent list → + // unload → targeted reload → detach. Catches integration skew that + // unit tests on single ops cannot. + use acadrust::objects::{ImageDefinition, ObjectType, UnderlayDefinition}; + let dir = xref_tmpdir("e2e"); + let (mid_path, _) = write_nested_fixture(&dir); + std::fs::write(dir.join("img.png"), b"fake-png").unwrap(); + let (mut doc, mid_key) = + xref_doc_with("MID", &mid_path.to_string_lossy()); + // Referenced image → Loaded entry. + let img_h = doc.allocate_handle(); + let mut img_def = ImageDefinition::with_dimensions("img.png", 8, 8); + img_def.handle = img_h; + doc.objects.insert(img_h, ObjectType::ImageDefinition(img_def)); + let mut img = acadrust::entities::RasterImage::new( + "img.png", + acadrust::types::Vector3::ZERO, + 8.0, + 8.0, + ); + img.definition_handle = Some(img_h); + doc.add_entity(acadrust::EntityType::RasterImage(img)).unwrap(); + // Unreferenced PDF definition → listed, never merged. + let pdf_h = doc.allocate_handle(); + let mut pdf_def = UnderlayDefinition::pdf("doc.pdf", "1"); + pdf_def.handle = pdf_h; + doc.objects.insert(pdf_h, ObjectType::UnderlayDefinition(pdf_def)); + + let empty = std::collections::HashSet::new(); + let no_prev = std::collections::HashMap::new(); + // 1. List: everything visible with the right statuses. + let listed = collect_entries_with_prev(&doc, &dir, &empty, &no_prev); + assert!(listed.iter().any(|e| e.name == "MID" && e.status == RefStatus::Loaded)); + assert!(listed.iter().any(|e| e.name == "INNER" && e.parent_key.is_some())); + assert!(listed.iter().any(|e| e.name == "img.png" && e.status == RefStatus::Loaded)); + assert!(listed.iter().any(|e| e.name == "doc.pdf" && e.status == RefStatus::Unreferenced)); + // 2. Unload: definition retained, content marked. + unload_reference(&mut doc, mid_key).expect("unload"); + let mut unloaded = std::collections::HashSet::new(); + unloaded.insert(crate::io::xref_model::UnloadKey::Direct(mid_key)); + let listed = collect_entries_with_prev(&doc, &dir, &unloaded, &no_prev); + assert!(listed.iter().any(|e| e.name == "MID" && e.status == RefStatus::Unloaded)); + assert!(doc.block_records.get("MID").is_some(), "definition retained"); + // 3. Targeted reload: back to Loaded. + let mut handles = HashSet::default(); + handles.insert(doc.block_records.get("MID").unwrap().handle); + let (infos, _) = resolve_xrefs_for_keys(&mut doc, &dir, &handles); + assert!(infos.iter().any(|i| i.name == "MID")); + let listed = collect_entries_with_prev(&doc, &dir, &empty, &no_prev); + assert!(listed.iter().any(|e| e.name == "MID" && e.status == RefStatus::Loaded)); + // 4. Detach: instances, definition, and nested rows all gone. + detach_reference(&mut doc, mid_key).expect("detach"); + let listed = collect_entries_with_prev(&doc, &dir, &empty, &no_prev); + assert!(!listed.iter().any(|e| e.name == "MID" || e.name == "INNER")); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn nested_child_key_never_aliases_host() { + let host_key = 42u64; + let nested = child_key(100, "DETAIL", "refs/detail.dwg"); + assert_ne!(nested, host_key); + } + + #[test] + fn set_ref_path_stores_verbatim() { + let (mut doc, key) = xref_doc_with("PLAN", "old/path.dwg"); + set_ref_path(&mut doc, key, "new\\raw path.dwg").expect("set path"); + let br = doc.block_records.get("PLAN").unwrap(); + assert_eq!(br.xref_path, "new\\raw path.dwg"); + } + + #[test] + fn apply_pathtype_across_drives_errors() { + let (mut doc, key) = xref_doc_with("PLAN", "D:/Lib/plan.dwg"); + let host = std::path::Path::new("C:/Drawings/host.dwg"); + let err = apply_pathtype(&mut doc, key, Pathtype::Relative, host).unwrap_err(); + assert_eq!(err, "XREF: cannot make path relative across drives."); + } + + #[test] + #[cfg(windows)] + fn apply_pathtype_full_resolves_existing_relative_path() { + let (mut doc, key) = xref_doc_with("PLAN", "refs/plan.dwg"); + let host = std::path::Path::new("C:/Drawings/host.dwg"); + apply_pathtype(&mut doc, key, Pathtype::Full, host).expect("full path"); + assert_eq!( + doc.block_records.get("PLAN").unwrap().xref_path, + "C:/Drawings/refs/plan.dwg" + ); + } + + #[test] + fn overlay_on_image_errors() { + use acadrust::objects::{ImageDefinition, ObjectType}; + let mut doc = acadrust::CadDocument::new(); + let h = doc.allocate_handle(); + let mut def = ImageDefinition::with_dimensions("img.png", 8, 8); + def.handle = h; + doc.objects.insert(h, ObjectType::ImageDefinition(def)); + let err = set_ref_type(&mut doc, h.value(), RefType::Overlay).unwrap_err(); + assert_eq!(err, "XREF: overlays apply to drawing references only."); + } + + #[test] + fn ref_type_attach_reachable_roundtrip() { + // F10: `set_ref_type(Attach)` (palette Type control, Attach + // direction) flips an overlay back to a full attach. + let (mut doc, key) = xref_doc_with("PLAN", "plan.dwg"); + set_ref_type(&mut doc, key, RefType::Overlay).expect("to overlay"); + assert!(doc.block_records.get("PLAN").unwrap().flags.is_xref_overlay); + let name = set_ref_type(&mut doc, key, RefType::Attach).expect("to attach"); + assert_eq!(name, "PLAN"); + let br = doc.block_records.get("PLAN").unwrap(); + assert!(br.flags.is_xref && !br.flags.is_xref_overlay); + } + + #[test] + fn unload_drops_merged_entities_keeps_definition() { + let (mut doc, key) = xref_doc_with("PLAN", "missing.dwg"); + let br_h = doc.block_records.get("PLAN").unwrap().handle; + let mut line = acadrust::entities::Line::new(); + line.common.handle = acadrust::types::Handle::NULL; + line.common.owner_handle = br_h; + doc.add_entity(acadrust::EntityType::Line(line)).unwrap(); + assert_eq!(doc.block_records.get("PLAN").unwrap().entity_handles.len(), 1); + unload_reference(&mut doc, key).expect("unload"); + assert!(doc.block_records.get("PLAN").is_some()); + let owned: Vec<_> = doc + .entities() + .filter(|e| e.common().owner_handle == br_h) + .collect(); + assert!(owned.is_empty()); + } + + #[test] + fn detach_erases_inserts_and_definition() { + let (mut doc, key) = xref_doc_with("PLAN", "missing.dwg"); + let ins = acadrust::entities::Insert::new("PLAN", acadrust::types::Vector3::ZERO); + doc.add_entity(acadrust::EntityType::Insert(ins)).unwrap(); + detach_reference(&mut doc, key).expect("detach"); + assert!(doc.block_records.get("PLAN").is_none()); + assert!(!doc.entities().any(|e| matches!( + e, + acadrust::EntityType::Insert(i) if i.block_name.eq_ignore_ascii_case("PLAN") + ))); + } + + #[test] + fn visretain_snapshot_restore_roundtrip() { + // VISRETAIN=1 reload path: host-side overrides on `name|*` layers + // must survive purge + re-merge. Exercises the mechanism directly: + // snapshot → mutate to defaults → restore → overrides back. + let (mut doc, _) = xref_doc_with("PLAN", "plan.dwg"); + let mut layer = acadrust::tables::Layer::new("PLAN|WALLS"); + layer.flags.off = true; + layer.flags.frozen = true; + layer.color = acadrust::types::Color::from_index(1); + doc.layers.add_or_replace(layer); + let kept = snapshot_dependent_layers(&doc, "PLAN"); + assert!(kept.contains_key("PLAN|WALLS")); + // Simulate the purge + file-default re-merge. + doc.layers.remove("PLAN|WALLS"); + doc.layers.add_or_replace(acadrust::tables::Layer::new("PLAN|WALLS")); + assert!(!doc.layers.get("PLAN|WALLS").unwrap().flags.off); + restore_dependent_layers(&mut doc, "PLAN", kept); + let back = doc.layers.get("PLAN|WALLS").unwrap(); + assert!(back.flags.off, "off override restored"); + assert!(back.flags.frozen, "frozen override restored"); + assert_eq!(back.color, acadrust::types::Color::from_index(1)); + } + + #[test] + fn visretain_restore_ignores_deleted_layers() { + // Layers gone from the new file version stay gone — restore only + // touches entries present post-merge, never resurrects. + let (mut doc, _) = xref_doc_with("PLAN", "plan.dwg"); + let mut layer = acadrust::tables::Layer::new("PLAN|GONE"); + layer.flags.off = true; + doc.layers.add_or_replace(layer); + let kept = snapshot_dependent_layers(&doc, "PLAN"); + doc.layers.remove("PLAN|GONE"); + restore_dependent_layers(&mut doc, "PLAN", kept); + assert!(doc.layers.get("PLAN|GONE").is_none()); + } + + #[test] + fn replace_prefix_rewrites_all() { + let (mut doc, _) = xref_doc_with("PLAN", "refs/old/plan.dwg"); + let n = replace_path_prefix(&mut doc, "refs/old", "refs/new"); + assert_eq!(n, 1); + assert_eq!( + doc.block_records.get("PLAN").unwrap().xref_path, + "refs/new/plan.dwg" + ); + } + + #[test] + fn replace_prefix_dotdot_saved_path() { + // `..`-laden saved path normalizes to the old prefix; the splice must + // follow normalized components, not raw bytes. + let (mut doc, _) = xref_doc_with("PLAN", "refs/a/../old/plan.dwg"); + let n = replace_path_prefix(&mut doc, "refs/old", "refs/new"); + assert_eq!(n, 1); + assert_eq!( + doc.block_records.get("PLAN").unwrap().xref_path, + "refs/new/plan.dwg" + ); + } + + #[test] + #[cfg(windows)] + fn replace_prefix_case_folded_match() { + let (mut doc, _) = xref_doc_with("PLAN", "REFS/OLD/plan.dwg"); + let n = replace_path_prefix(&mut doc, "refs/old", "refs/new"); + assert_eq!(n, 1); + assert_eq!( + doc.block_records.get("PLAN").unwrap().xref_path, + "refs/new/plan.dwg" + ); + } + + #[test] + fn replace_prefix_multibyte_no_panic() { + // Old code cut the RAW string by `old.len()` bytes after matching on + // normalized forms — a normalized match with a shorter raw prefix + // panics on a mid-char cut. Component-wise rejoin never slices raw. + // Here old `a/` normalizes to `a`, which prefixes normalized + // `aé/x.dwg`; raw cut at 2 lands inside `é` → old code panicked. + let (mut doc, _) = xref_doc_with("PLAN", "aé/x.dwg"); + let n = replace_path_prefix(&mut doc, "a/", "q/"); + assert_eq!(n, 0); + assert_eq!( + doc.block_records.get("PLAN").unwrap().xref_path, + "aé/x.dwg" + ); + let (mut doc2, _) = xref_doc_with("PLAN", "répértoire/old/plan.dwg"); + let n2 = replace_path_prefix(&mut doc2, "répértoire/old", "répértoire/new"); + assert_eq!(n2, 1); + assert_eq!( + doc2.block_records.get("PLAN").unwrap().xref_path, + "répértoire/new/plan.dwg" + ); + } + + #[test] + fn stale_only_after_cached_baseline() { + use std::collections::{HashMap, HashSet}; + use std::time::{Duration, UNIX_EPOCH}; + // No fs: drive decide_status directly via fabricated times is covered + // in xref_model; here assert the collect wrapper threads `prev` + // through without touching the filesystem (missing file → NotFound, + // never Stale even with a prev entry). + let (doc, key) = xref_doc_with("PLAN", "definitely-missing-xyz.dwg"); + let mut prev: HashMap = HashMap::new(); + prev.insert(key, UNIX_EPOCH + Duration::from_secs(1)); + let entries = super::collect_entries_with_prev( + &doc, + std::path::Path::new("."), + &HashSet::new(), + &prev, + ); + assert_eq!(entries[0].status, RefStatus::NotFound); + } + + #[allow(dead_code)] + type SystemTimeAlias = std::time::SystemTime; + + // ── BIND (Task 9) ──────────────────────────────────────────────────── + mod bind_tests { + use super::super::bind_reference; + use super::xref_doc_with; + + fn bind_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "ocs_xref_bind_{tag}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn write_xref_file( + dir: &std::path::Path, + name: &str, + build: impl FnOnce(&mut acadrust::CadDocument), + ) -> String { + let mut doc = acadrust::CadDocument::new(); + build(&mut doc); + let bytes = crate::io::save_to_bytes(&doc, "dwg", doc.version).expect("save xref"); + let path = dir.join(name); + std::fs::write(&path, &bytes).expect("write xref"); + path.to_string_lossy().into_owned() + } + + fn line_on(layer: &str) -> acadrust::EntityType { + let mut line = acadrust::entities::Line::new(); + line.common.layer = layer.to_string(); + acadrust::EntityType::Line(line) + } + + fn add_layer(doc: &mut acadrust::CadDocument, name: &str) { + doc.layers + .add(acadrust::tables::Layer::new(name)) + .expect("add layer"); + } + + #[test] + fn bind_rename_basic() { + let dir = bind_dir("basic"); + let xref_path = write_xref_file(&dir, "plan.dwg", |doc| { + add_layer(doc, "WALLS"); + doc.add_entity(line_on("WALLS")).unwrap(); + }); + let (mut host, key) = xref_doc_with("PLAN", &xref_path); + let outcome = bind_reference(&mut host, key, &dir, &dir).expect("bind"); + assert_eq!(outcome.name, "PLAN"); + assert_eq!(outcome.unremapped, 0); + let br = host.block_records.get("PLAN").unwrap(); + assert!(!br.flags.is_xref && !br.flags.is_xref_overlay); + assert!(br.xref_path.is_empty()); + assert!(host.layers.get("PLAN$0$WALLS").is_some()); + let owned: Vec<_> = host + .entities() + .filter(|e| e.common().owner_handle == br.handle) + .collect(); + assert_eq!(owned.len(), 1); + assert_eq!(owned[0].common().layer, "PLAN$0$WALLS"); + assert!(!host.layers.names().any(|n| n.contains('|'))); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn bind_collision_increments() { + let dir = bind_dir("collision"); + let xref_path = write_xref_file(&dir, "plan.dwg", |doc| { + add_layer(doc, "WALLS"); + doc.add_entity(line_on("WALLS")).unwrap(); + }); + let (mut host, key) = xref_doc_with("PLAN", &xref_path); + add_layer(&mut host, "PLAN$0$WALLS"); + let outcome = bind_reference(&mut host, key, &dir, &dir).expect("bind"); + assert_eq!(outcome.name, "PLAN"); + assert!(host.layers.get("PLAN$1$WALLS").is_some()); + let br = host.block_records.get("PLAN").unwrap(); + let owned: Vec<_> = host + .entities() + .filter(|e| e.common().owner_handle == br.handle) + .collect(); + assert_eq!(owned.len(), 1); + assert_eq!(owned[0].common().layer, "PLAN$1$WALLS"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn bind_transitive_chain() { + let dir = bind_dir("chain"); + let detail_path = write_xref_file(&dir, "detail.dwg", |doc| { + add_layer(doc, "WALLS"); + doc.add_entity(line_on("WALLS")).unwrap(); + }); + let plan_path = write_xref_file(&dir, "plan.dwg", |doc| { + add_layer(doc, "PLANLAYER"); + doc.add_entity(line_on("PLANLAYER")).unwrap(); + let mut br = acadrust::tables::BlockRecord::new("DETAIL"); + br.flags.is_xref = true; + br.xref_path = detail_path.clone(); + doc.block_records.add(br).expect("add nested xref"); + }); + let (mut host, key) = xref_doc_with("PLAN", &plan_path); + let outcome = bind_reference(&mut host, key, &dir, &dir).expect("bind"); + assert_eq!(outcome.name, "PLAN"); + assert!(host.layers.get("PLAN$0$DETAIL$0$WALLS").is_some()); + assert!(host.layers.get("PLAN$0$PLANLAYER").is_some()); + let child = host.block_records.get("PLAN$0$DETAIL").expect("bound child block"); + assert!(!child.flags.is_xref && !child.flags.is_xref_overlay); + assert!(!host + .block_records + .iter() + .any(|b| b.flags.is_xref || b.flags.is_xref_overlay)); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn bind_remaps_hatch_dim_block_refs() { + let dir = bind_dir("refs"); + let xref_path = write_xref_file(&dir, "plan.dwg", |doc| { + add_layer(doc, "WALLS"); + // Nested block with content + a model-space INSERT of it. + let mut door = acadrust::tables::BlockRecord::new("DOOR"); + door.handle = doc.allocate_handle(); + let door_h = door.handle; + doc.block_records.add(door).expect("add door"); + let mut inner = acadrust::entities::Line::new(); + inner.common.layer = "WALLS".to_string(); + inner.common.owner_handle = door_h; + doc.add_entity(acadrust::EntityType::Line(inner)).unwrap(); + let ins = acadrust::entities::Insert::new( + "DOOR", + acadrust::types::Vector3::ZERO, + ); + doc.add_entity(acadrust::EntityType::Insert(ins)).unwrap(); + // Hatch (pattern names have no table — preserved verbatim). + let mut hatch = acadrust::entities::Hatch::new(); + hatch.pattern.name = "ANSI31".to_string(); + doc.add_entity(acadrust::EntityType::Hatch(hatch)).unwrap(); + // Dim-style reference via a tolerance entity. + doc.dim_styles + .add(acadrust::tables::DimStyle::new("ARCH")) + .expect("add dimstyle"); + let mut tol = acadrust::entities::Tolerance::new(); + tol.dimension_style_name = "ARCH".to_string(); + doc.add_entity(acadrust::EntityType::Tolerance(tol)).unwrap(); + }); + let (mut host, key) = xref_doc_with("PLAN", &xref_path); + bind_reference(&mut host, key, &dir, &dir).expect("bind"); + let door = host.block_records.get("PLAN$0$DOOR").expect("bound block"); + assert!(!door.flags.is_xref); + let inserts: Vec<_> = host + .entities() + .filter_map(|e| match e { + acadrust::EntityType::Insert(i) => Some(i.block_name.clone()), + _ => None, + }) + .collect(); + assert!(inserts.iter().any(|n| n == "PLAN$0$DOOR"), "{inserts:?}"); + let door_lines: Vec<_> = host + .entities() + .filter(|e| e.common().owner_handle == door.handle) + .collect(); + assert_eq!(door_lines.len(), 1); + assert_eq!(door_lines[0].common().layer, "PLAN$0$WALLS"); + let br = host.block_records.get("PLAN").unwrap(); + let hatches: Vec<_> = host + .entities() + .filter(|e| { + e.common().owner_handle == br.handle + && matches!(e, acadrust::EntityType::Hatch(_)) + }) + .collect(); + assert_eq!(hatches.len(), 1); + assert!(host.dim_styles.get("PLAN$0$ARCH").is_some()); + let tols: Vec<_> = host + .entities() + .filter_map(|e| match e { + acadrust::EntityType::Tolerance(t) => Some(t.dimension_style_name.clone()), + _ => None, + }) + .collect(); + assert_eq!(tols, vec!["PLAN$0$ARCH".to_string()]); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn bind_remaps_text_styles_and_counts_unmapped() { + let dir = bind_dir("styles"); + let xref_path = write_xref_file(&dir, "plan.dwg", |doc| { + doc.text_styles + .add(acadrust::tables::TextStyle::new("NOTE")) + .expect("add text style"); + let mut text = acadrust::entities::Text::new(); + text.style = "NOTE".to_string(); + doc.add_entity(acadrust::EntityType::Text(text)).unwrap(); + // Two foreign handles that cannot be remapped (plotstyle_flags + // set as a real writer would — otherwise the round-trip + // drops the handle). + let mut line = acadrust::entities::Line::new(); + line.common.plotstyle_flags = 0b11; + line.common.plotstyle_handle = Some(doc.allocate_handle()); + doc.add_entity(acadrust::EntityType::Line(line)).unwrap(); + let mut mline = acadrust::entities::MLine::new(); + mline.style_name = "STD".to_string(); + mline.style_handle = Some(doc.allocate_handle()); + doc.add_entity(acadrust::EntityType::MLine(mline)).unwrap(); + }); + let (mut host, key) = xref_doc_with("PLAN", &xref_path); + let outcome = bind_reference(&mut host, key, &dir, &dir).expect("bind"); + assert_eq!(outcome.unremapped, 2); + assert!(host.text_styles.get("PLAN$0$NOTE").is_some()); + let styles: Vec<_> = host + .entities() + .filter_map(|e| match e { + acadrust::EntityType::Text(t) => Some(t.style.clone()), + _ => None, + }) + .collect(); + assert_eq!(styles, vec!["PLAN$0$NOTE".to_string()]); + let br = host.block_records.get("PLAN").unwrap(); + let lines: Vec<_> = host + .entities() + .filter(|e| { + e.common().owner_handle == br.handle + && matches!(e, acadrust::EntityType::Line(_)) + }) + .collect(); + assert_eq!(lines.len(), 1); + assert!(lines[0].common().plotstyle_handle.is_none()); + let mlines: Vec<_> = host + .entities() + .filter_map(|e| match e { + acadrust::EntityType::MLine(m) => Some(m.style_handle), + _ => None, + }) + .collect(); + assert_eq!(mlines, vec![None]); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn bind_image_collect_copies_file() { + use acadrust::objects::{ImageDefinition, ObjectType}; + let src_dir = bind_dir("imgsrc"); + let host_dir = bind_dir("imghost"); + std::fs::write(src_dir.join("pixel.png"), b"fakepng").unwrap(); + let xref_path = write_xref_file(&src_dir, "plan.dwg", |doc| { + let h = doc.allocate_handle(); + let mut def = ImageDefinition::with_dimensions("pixel.png", 8, 8); + def.handle = h; + doc.objects.insert(h, ObjectType::ImageDefinition(def)); + let mut img = acadrust::entities::RasterImage::new( + "pixel.png", + acadrust::types::Vector3::ZERO, + 8.0, + 8.0, + ); + img.definition_handle = Some(h); + doc.add_entity(acadrust::EntityType::RasterImage(img)).unwrap(); + }); + let (mut host, key) = xref_doc_with("PLAN", &xref_path); + bind_reference(&mut host, key, &src_dir, &host_dir).expect("bind"); + let copied = host_dir.join("pixel.png"); + assert!(copied.exists()); + assert_eq!(std::fs::read(&copied).unwrap(), b"fakepng"); + let defs: Vec<_> = host + .objects + .values() + .filter_map(|o| match o { + ObjectType::ImageDefinition(d) => Some(d.file_name.clone()), + _ => None, + }) + .collect(); + assert_eq!(defs, vec!["pixel.png".to_string()]); + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&host_dir).ok(); + } + + #[test] + fn bind_image_missing_errors_without_partial_state() { + use acadrust::objects::{ImageDefinition, ObjectType}; + let dir = bind_dir("imgmissing"); + let xref_path = write_xref_file(&dir, "plan.dwg", |doc| { + let h = doc.allocate_handle(); + let mut def = ImageDefinition::with_dimensions("ghost.png", 8, 8); + def.handle = h; + doc.objects.insert(h, ObjectType::ImageDefinition(def)); + let mut img = acadrust::entities::RasterImage::new( + "ghost.png", + acadrust::types::Vector3::ZERO, + 8.0, + 8.0, + ); + img.definition_handle = Some(h); + doc.add_entity(acadrust::EntityType::RasterImage(img)).unwrap(); + }); + let (mut host, key) = xref_doc_with("PLAN", &xref_path); + let err = bind_reference(&mut host, key, &dir, &dir).unwrap_err(); + assert!(err.contains("ghost.png"), "got: {err:?}"); + // No partial state: the definition is untouched. + let br = host.block_records.get("PLAN").unwrap(); + assert!(br.flags.is_xref); + assert_eq!(br.xref_path, xref_path); + assert!(!host.layers.names().any(|n| n.contains("PLAN$0"))); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn bind_pdf_errors() { + use acadrust::objects::{ObjectType, UnderlayDefinition}; + let mut host = acadrust::CadDocument::new(); + let h = host.allocate_handle(); + let mut def = UnderlayDefinition::pdf("doc.pdf", "1"); + def.handle = h; + host.objects.insert(h, ObjectType::UnderlayDefinition(def)); + let err = + bind_reference(&mut host, h.value(), std::path::Path::new("."), std::path::Path::new(".")) + .unwrap_err(); + assert_eq!( + err, + "XREF: PDF bind (vector import) is not available in this version." + ); + } + + #[test] + fn bind_unknown_key_errors() { + let (mut host, _) = xref_doc_with("PLAN", "missing.dwg"); + let err = bind_reference( + &mut host, + 0xDEAD_BEEF, + std::path::Path::new("."), + std::path::Path::new("."), + ) + .unwrap_err(); + assert_eq!(err, "XREF: no loaded reference with that key."); + } + } } diff --git a/src/io/xref_model.rs b/src/io/xref_model.rs index ee7526fbe..b810f3ddc 100644 --- a/src/io/xref_model.rs +++ b/src/io/xref_model.rs @@ -1,41 +1,61 @@ // XREF path model — lexical identity for external references (Task 2). -/// Lexical path normalization for reference identity. Never touches the -/// filesystem (canonicalize fails on missing files; NotFound is common). -/// Backslash→slash, `.`/`..` component cleanup, case-fold on Windows. -pub fn normalize_lexical(raw: &str) -> String { +/// Shared lexical walk for [`normalize_lexical`] (identity: case-folded on +/// Windows) and [`normalize_display`] (display: case-preserving). One +/// implementation so the two can never drift apart. +/// +/// `fold_case` lowercases the drive prefix and the joined remainder. +/// UNC shares (`//server/share/…`) additionally clamp `..` at the share root +/// (`floor == 2`): climbing above the share is deterministic garbage +/// otherwise, and a share root behaves like a filesystem root for this +/// purpose. Drive-absolute paths clamp at `/`; relative paths retain +/// surplus `..` (it still means "up"). +fn normalize_core(raw: &str, fold_case: bool) -> String { // 1. Separator unification. Pure string op — no I/O, so missing files // (the common NotFound case) normalize exactly like present ones. let slashed = raw.replace('\\', "/"); - // 2. Prefix split: drive (`C:`), UNC (`//`), or none. The prefix is - // lowercased so `C:\x` and `c:/x` hash to the same identity. + // 2. Prefix split: drive (`C:`), UNC (`//`), or none. let bytes = slashed.as_bytes(); - let (prefix, mut rest) = if bytes.len() >= 2 + let (prefix, mut rest, unc) = if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' { - (slashed[..2].to_ascii_lowercase(), slashed[2..].to_string()) - } else if slashed.starts_with("//") { - ("//".to_string(), slashed[2..].to_string()) + let drive = &slashed[..2]; + ( + if fold_case { + drive.to_ascii_lowercase() + } else { + drive.to_string() + }, + slashed[2..].to_string(), + false, + ) + } else if let Some(stripped) = slashed.strip_prefix("//") { + ("//".to_string(), stripped.to_string(), true) } else { - (String::new(), slashed) + (String::new(), slashed, false) }; - if prefix == "//" { + if unc { // Collapse `///share` → `//share` so the rejoin can't triple-slash. rest = rest.trim_start_matches('/').to_string(); } let absolute = rest.starts_with('/'); + // Components below this stack depth survive `..`: 2 for UNC + // (server + share), 0 otherwise (absolute roots clamp via `absolute`). + let floor = if unc { 2 } else { 0 }; + let climb = !(absolute || unc); // 3. Lexical component cleanup: drop empties/`.`, resolve `..` by - // popping. A relative path that climbs past its root keeps the surplus - // `..` (it still means "up"); an absolute path clamps at its root. + // popping subject to the floor above. let mut stack: Vec<&str> = Vec::new(); for comp in rest.split('/') { if comp.is_empty() || comp == "." { continue; } else if comp == ".." { - if stack.pop().is_none() && !absolute { + if stack.len() > floor { + stack.pop(); + } else if climb { stack.push(".."); } } else { @@ -46,7 +66,7 @@ pub fn normalize_lexical(raw: &str) -> String { // 4. Case fold: Windows filesystems are case-insensitive, so identity // must be too. Elsewhere case is significant and preserved. let mut joined = stack.join("/"); - if cfg!(windows) { + if fold_case && cfg!(windows) { joined = joined.to_lowercase(); } @@ -57,6 +77,18 @@ pub fn normalize_lexical(raw: &str) -> String { format!("{prefix}{joined}") } +/// Lexical path normalization for reference identity. Never touches the +/// filesystem (canonicalize fails on missing files; NotFound is common). +/// Backslash→slash, `.`/`..` component cleanup, case-fold on Windows. +pub fn normalize_lexical(raw: &str) -> String { + // `fold_case` is passed as true unconditionally: `normalize_core` still + // gates the actual fold on `cfg!(windows)`, so non-Windows builds keep + // case-sensitive identity (folding there would conflate distinct files). + // `root_key` lowercases its own input separately for root comparison, so + // identity regimes stay aligned across targets without touching this. + normalize_core(raw, true) +} + /// How an XREF path is stored relative to its host drawing. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Pathtype { @@ -72,40 +104,10 @@ pub enum PathtypeError { UnsavedHost, } +/// Case-preserving sibling of [`normalize_lexical`] for stored/display paths. +/// Same walk, no case folding. fn normalize_display(raw: &str) -> String { - let slashed = raw.replace('\\', "/"); - let bytes = slashed.as_bytes(); - let (prefix, mut rest) = if bytes.len() >= 2 - && bytes[0].is_ascii_alphabetic() - && bytes[1] == b':' - { - (slashed[..2].to_string(), slashed[2..].to_string()) - } else if slashed.starts_with("//") { - ("//".to_string(), slashed[2..].to_string()) - } else { - (String::new(), slashed) - }; - if prefix == "//" { - rest = rest.trim_start_matches('/').to_string(); - } - let absolute = rest.starts_with('/'); - let mut stack: Vec<&str> = Vec::new(); - for comp in rest.split('/') { - if comp.is_empty() || comp == "." { - continue; - } else if comp == ".." { - if stack.pop().is_none() && !absolute { - stack.push(".."); - } - } else { - stack.push(comp); - } - } - let mut joined = stack.join("/"); - if absolute { - joined = format!("/{joined}"); - } - format!("{prefix}{joined}") + normalize_core(raw, false) } fn root_key(normalized: &str) -> String { @@ -181,7 +183,19 @@ pub fn to_pathtype_result( pathtype: Pathtype, ) -> Result { match pathtype { - Pathtype::Full => Ok(normalize_display(path)), + // Absolute output: a stored relative path is resolved against the + // host folder first, so `Full` is never a silent no-op on one. + Pathtype::Full => { + if is_relative_path(&normalize_lexical(path)) && !normalize_lexical(path).is_empty() { + if let Some(dir) = host.parent() { + if !dir.as_os_str().is_empty() { + let joined = dir.join(normalize_display(path)).to_string_lossy().into_owned(); + return Ok(normalize_display(&joined)); + } + } + } + Ok(normalize_display(path)) + } Pathtype::None => Ok(file_name_only(path)), Pathtype::Relative => { let parent = host.parent(); @@ -215,20 +229,18 @@ pub fn to_pathtype_result( { k += 1; } + // Display components mirror normalized ones 1:1 (case folding + // cannot change component counts), so the display tail is reused + // to preserve the stored casing in the output. let target_disp = normalize_display(path); let target_comps_disp = comps_after_root(&target_disp); + debug_assert_eq!(target_comps_disp.len(), target_comps_norm.len()); let mut parts: Vec = Vec::new(); for _ in 0..host_comps_norm.len().saturating_sub(k) { parts.push("..".to_string()); } - if target_comps_disp.len() == target_comps_norm.len() { - for c in target_comps_disp.iter().skip(k) { - parts.push(c.to_string()); - } - } else { - for c in target_comps_norm.iter().skip(k) { - parts.push(c.to_string()); - } + for c in target_comps_disp.iter().skip(k) { + parts.push(c.to_string()); } if parts.is_empty() { return Ok(file_name_only(path)); @@ -299,7 +311,11 @@ pub enum RefStatus { NotFound, Failed, Stale, + /// A nested entry whose host failed (distinct from [`RefStatus::Unreferenced`). Orphaned, + /// A definition with no placed instances (e.g. an image definition no + /// entity references). Listed so it can be managed, never merged. + Unreferenced, } /// How a DWG xref attaches: full re-export (`Attach`) vs local-only (`Overlay`). @@ -314,10 +330,10 @@ pub enum RefType { /// `key` is the block-record handle (DWG xref) or definition-object handle /// (image / PDF) for top-level entries — stable across renames, so /// [`renamed`](Self::renamed) keeps it. Nested child entries (enumerated from -/// a host file, never merged) use [`child_key`] — a stable hash of -/// `(parent_key, name, saved_path)` — so a foreign handle that collides with -/// a host handle can never alias a host row. `saved_path` is the raw stored -/// string verbatim, never synthesized; `found_at` is where it actually +/// a host file, never merged) use [`child_key`] — an FNV-1a namespaced hash +/// of `(parent_key, name, saved_path)` — so a foreign handle that collides +/// with a host handle can never alias a host row. `saved_path` is the raw +/// stored string verbatim, never synthesized; `found_at` is where it actually /// resolved, if anywhere. /// /// `parent_key` is `None` for roots and `Some(host_key)` for nested children. @@ -334,7 +350,6 @@ pub struct ReferenceEntry { pub modified: Option, pub saved_path: String, pub found_at: Option, - pub loaded: bool, pub parent_key: Option, } @@ -350,7 +365,6 @@ impl ReferenceEntry { modified: None, saved_path: String::new(), found_at: None, - loaded: true, parent_key: None, } } @@ -365,40 +379,57 @@ impl ReferenceEntry { } } -/// Bind-style symbol name for a nested xref block: AutoCAD's `BIND` inserts -/// `$0$` separators, so `PLAN` → `DETAIL` → `WALLS` reads transitively. +/// Bind-style symbol name for a nested xref block: the industry-standard +/// CAD bind scheme inserts `$0$` separators, so `PLAN` → `DETAIL` → `WALLS` +/// reads transitively. pub fn bind_symbol(parent: &str, child: &str, sym: &str) -> String { format!("{parent}$0${child}$0${sym}") } /// [`bind_symbol`] for the two-level case, bumping the `$N$` counter past /// every collision in `taken` (`PLAN$0$WALLS` taken → `PLAN$1$WALLS`). +/// +/// Always terminates: `taken` holds N names, so one of the N+1 candidates +/// `0..=N` is necessarily free (pigeonhole) — no counter cap needed. pub fn bind_symbol_taken(parent: &str, sym: &str, taken: &[impl AsRef]) -> String { - let mut n = 0u32; - loop { + for n in 0..=taken.len() as u32 { let candidate = format!("{parent}${n}${sym}"); if !taken.iter().any(|t| t.as_ref() == candidate) { return candidate; } - n += 1; } + // Unreachable by the argument above; kept total instead of panicking. + format!("{parent}$4294967295${sym}") } /// Stable key for a nested child entry. /// /// Top-level entries keep the host handle value unchanged. Nested children /// come from foreign files whose handle values can collide with host handles, -/// so they are namespaced via `DefaultHasher(parent_key, name, saved_path)`. -/// Deterministic within a run; same derivation is used everywhere +/// so they are namespaced via FNV-1a over `(parent_key, name, saved_path)` +/// with the top bit forced set (`0x8000_0000_0000_0000`). The high bit is the +/// namespace tag: host handles (sequentially allocated, nowhere near 2^63) +/// never have it set, so a nested key can never alias a host key — in the +/// stat cache, the unload set's raw views, or anywhere else a bare `u64` +/// travels. FNV-1a is chosen over `DefaultHasher` deliberately: the algorithm +/// is fixed, so keys are stable across runs and toolchain versions (a +/// `DefaultHasher` key would become a persistence-format hazard the moment +/// unload state is ever persisted). Same derivation is used everywhere /// `collect_entries` output is consumed, so palette `(key, saved_path)` rows /// keep resolving. pub fn child_key(parent_key: u64, name: &str, saved_path: &str) -> u64 { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - let mut h = DefaultHasher::new(); - parent_key.hash(&mut h); - name.hash(&mut h); - saved_path.hash(&mut h); - h.finish() + // FNV-1a/64 (offset 14695981039346656037, prime 1099511628211). + let mut h: u64 = 14695981039346656037; + for b in parent_key + .to_le_bytes() + .iter() + .chain(name.as_bytes()) + .chain([0u8].iter()) + .chain(saved_path.as_bytes()) + { + h ^= *b as u64; + h = h.wrapping_mul(1099511628211); + } + h | 0x8000_0000_0000_0000 } /// Pure status decision for one entry (no filesystem). @@ -407,11 +438,15 @@ pub fn child_key(parent_key: u64, name: &str, saved_path: &str) -> u64 { /// never reaches here — it is decided before stat). /// - `live` is the current mtime, `cached` the load-time mtime from the /// previous refresh (`None` on first call → no `Stale` on bootstrap). +/// The comparison is absolute in both directions: a file restored from +/// backup (live < cached) changed content just as much as an edited one. /// - `parent_failed` is true for nested children whose host entry is /// `NotFound`/`Failed` → `Loaded` is overridden to `Orphaned`. /// /// The 1s slack absorbs filesystem timestamp granularity so a file that did -/// not actually change is not flagged `Stale`. +/// not actually change is not flagged `Stale`. Conversely, changes landing +/// between document open and the first refresh are invisible that session — +/// the baseline only exists from the first stat on (no file watcher in v1). pub fn decide_status( resolved: RefStatus, live: Option, @@ -423,10 +458,9 @@ pub fn decide_status( } if resolved == RefStatus::Loaded { if let (Some(l), Some(c)) = (live, cached) { - if let Ok(delta) = l.duration_since(c) { - if delta > std::time::Duration::from_secs(1) { - return RefStatus::Stale; - } + let delta = l.duration_since(c).ok().or_else(|| c.duration_since(l).ok()); + if delta.is_some_and(|d| d > std::time::Duration::from_secs(1)) { + return RefStatus::Stale; } } } @@ -435,27 +469,55 @@ pub fn decide_status( /// Session set of unloaded reference keys. /// -/// Owns the `HashSet` that `collect_entries` takes as `unloaded`. +/// Owns the `HashSet` that `collect_entries` takes as `unloaded`. /// Task 8b wires session state; the set itself lives here so both sides -/// share one owner. Keys are top-level handle values (nested children are -/// never unloadable on their own). +/// share one owner. +/// +/// Keys are domain-tagged: [`UnloadKey::Direct`] wraps a top-level handle +/// value (nested children are never unloadable on their own) while +/// [`UnloadKey::Nested`] wraps a nested [`child_key`] hash. The tag keeps a +/// nested hash from ever aliasing a host handle with the same numeric value. +/// NOTE (nested-key invariant): only [`add_key`](UnloadSet::add_key) may +/// insert `Nested` keys and only [`contains_key`](UnloadSet::contains_key) / +/// [`is_unloaded_key`](UnloadSet::is_unloaded_key) may query them — every op +/// site guards nested rows before touching session state, so the untagged +/// `add`/`contains`/`is_unloaded`/`remove` helpers below only ever see +/// direct keys. New code must use the `_key` variants for nested entries. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum UnloadKey { + Direct(u64), + Nested(u64), +} + #[derive(Debug, Clone, Default)] -pub struct UnloadSet(pub std::collections::HashSet); +pub struct UnloadSet(pub std::collections::HashSet); impl UnloadSet { pub fn add(&mut self, key: u64) { + self.0.insert(UnloadKey::Direct(key)); + } + pub fn add_key(&mut self, key: UnloadKey) { self.0.insert(key); } pub fn remove(&mut self, key: &u64) { + self.0.remove(&UnloadKey::Direct(*key)); + } + pub fn remove_key(&mut self, key: &UnloadKey) { self.0.remove(key); } pub fn contains(&self, key: &u64) -> bool { + self.0.contains(&UnloadKey::Direct(*key)) + } + pub fn contains_key(&self, key: &UnloadKey) -> bool { self.0.contains(key) } pub fn is_unloaded(&self, key: u64) -> bool { + self.0.contains(&UnloadKey::Direct(key)) + } + pub fn is_unloaded_key(&self, key: UnloadKey) -> bool { self.0.contains(&key) } - pub fn as_set(&self) -> &std::collections::HashSet { + pub fn as_set(&self) -> &std::collections::HashSet { &self.0 } } @@ -464,8 +526,14 @@ impl UnloadSet { /// /// `Stale` is detectable only thereafter: an empty cache means bootstrap, so /// `decide_status` never reports `Stale` without a cached baseline. Reload -/// clears the key (fresh baseline on next refresh). Task 8b owns session -/// wiring; the map type lives here. +/// clears the key (fresh baseline on next refresh). +/// +/// KEY NAMESPACE: keys are host-handle values for direct entries and +/// high-bit-tagged [`child_key`] hashes for nested children (see +/// [`child_key`]: the tag bit keeps the two spaces disjoint, so sharing one +/// `u64` map is sound — the same discipline as [`UnloadKey`], encoded in the +/// key itself because every call site here already threads bare `u64`s). +/// Never insert a foreign handle that is neither. #[derive(Debug, Clone, Default)] pub struct RefStatCache(pub std::collections::HashMap); @@ -483,7 +551,7 @@ impl RefStatCache { #[cfg(test)] mod tests { - use super::{normalize_lexical, to_pathtype, to_pathtype_result, wildcard_match, Pathtype, PathtypeError}; + use super::{normalize_display, normalize_lexical, to_pathtype, to_pathtype_result, wildcard_match, Pathtype, PathtypeError}; use super::{bind_symbol, bind_symbol_taken, RefKind, ReferenceEntry}; use super::{child_key, decide_status, RefStatCache, RefStatus, UnloadSet}; @@ -494,11 +562,72 @@ mod tests { let host_key = 42u64; let nested = child_key(100, "DETAIL", "refs/detail.dwg"); assert_ne!(nested, host_key); + // High-bit namespace tag: host handles never carry it. + assert_ne!(nested & 0x8000_0000_0000_0000, 0); // Stable: same inputs → same key; different parent → different key. assert_eq!(nested, child_key(100, "DETAIL", "refs/detail.dwg")); assert_ne!(nested, child_key(101, "DETAIL", "refs/detail.dwg")); } + #[test] + fn normalize_is_idempotent() { + // normalize(normalize(x)) == normalize(x) across drives, UNC, + // relative, dot-segments, and empty inputs. + let cases = [ + "C:\\Host\\PLAN.dwg", + "c:/host/./plan.dwg", + "//SERVER/Share/../Share/f.dwg", + "//s/sh/../../x.dwg", + "rel/../../x.dwg", + "c:/a/../../b.dwg", + "a//b\\\\c.dwg", + "../up/ref.dwg", + "", + ".", + ]; + for c in cases { + let once = normalize_lexical(c); + assert_eq!(normalize_lexical(&once), once, "idempotence for {c:?}"); + assert_eq!(normalize_display(&once), once, "display-stable for {c:?}"); + } + // UNC climbs clamp at the share root instead of producing garbage. + assert_eq!(normalize_lexical("//s/sh/../../x.dwg"), "//s/sh/x.dwg"); + } + + #[test] + fn relative_roundtrip_invariant() { + // The Relative branch's real contract: converting an absolute path to + // Relative and resolving it against the same host must recover the + // normalized absolute path. + let host = std::path::Path::new("C:/Drawings/host.dwg"); + for target in [ + "C:/Drawings/refs/plan.dwg", + "C:/Drawings/plan.dwg", + "C:/Lib/detail.dwg", + ] { + let rel = to_pathtype(target, host, Pathtype::Relative); + let back = std::path::Path::new("C:/Drawings").join(&rel); + assert_eq!( + normalize_lexical(&back.to_string_lossy()), + normalize_lexical(target), + "roundtrip for {target:?} via {rel:?}" + ); + } + } + + #[test] + fn decide_status_backwards_mtime_is_stale() { + // A file restored from backup (live < cached) changed content just + // as much as an edited one: absolute difference, both directions. + use std::time::{Duration, UNIX_EPOCH}; + let cached = UNIX_EPOCH + Duration::from_secs(1_000); + let older = cached - Duration::from_secs(30); + assert_eq!( + decide_status(RefStatus::Loaded, Some(older), Some(cached), false), + RefStatus::Stale + ); + } + #[test] fn decide_status_stale_needs_slack() { use std::time::{Duration, UNIX_EPOCH}; @@ -552,6 +681,23 @@ mod tests { assert!(!s.is_unloaded(7)); } + #[test] + fn unload_set_namespaces_direct_vs_nested() { + use super::UnloadKey; + // Crafted collision: host handle 42 vs nested child hash 42 must not + // cross-trigger — the domain tag keeps the two spaces apart. + let mut s = UnloadSet::default(); + s.add(42); + assert!(s.is_unloaded(42)); + assert!(!s.contains_key(&UnloadKey::Nested(42))); + assert!(!s.is_unloaded_key(UnloadKey::Nested(42))); + let mut t = UnloadSet::default(); + t.add_key(UnloadKey::Nested(42)); + assert!(!t.is_unloaded(42)); + assert!(!t.contains(&42)); + assert!(t.is_unloaded_key(UnloadKey::Nested(42))); + } + #[test] fn ref_stat_cache_roundtrip() { use std::time::{Duration, UNIX_EPOCH}; @@ -601,8 +747,14 @@ mod tests { use acadrust::tables::BlockRecord; use acadrust::CadDocument; - let dir = - std::path::PathBuf::from(r"C:\Users\apolius\AppData\Local\Temp\opencode\xref_probe"); + let dir = std::env::temp_dir().join(format!( + "ocs_xref_probe_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); std::fs::create_dir_all(&dir).expect("probe dir"); let mut doc = CadDocument::new(); @@ -671,6 +823,7 @@ mod tests { back.header.retain_xref_visibility, "retain_xref_visibility round-trips as true" ); + std::fs::remove_dir_all(&dir).ok(); } #[test] diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index fa1dd3a69..3a6bf7e4a 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -3151,6 +3151,79 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "XREF Reloaded \"{}\"" => Some(("insert", "xref-reloaded")), "XREF Save the drawing first to resolve relative XREF paths." => Some(("insert", "xref-save-the-drawing-first-to-resolve-relativ")), "XREF Unloaded (skipped): \"{}\"" => Some(("common", "xref-unloaded-skipped")), + "XREF: '{}' ships with reference operations (Task 8/9)." => Some(("insert", "xref-ships-with-reference-operations")), + "XREF: \"{}\" set to Overlay." => Some(("insert", "xref-set-to-overlay")), + "XREF: bound \"{}\"." => Some(("insert", "xref-bound")), + "XREF: bound \"{}\" with {} unremapped style handles (see bind limitations)." => Some(("insert", "xref-bound-with-unremapped-handles")), + "XREF: cannot bind nested reference '{}'. Bind it in its host drawing." => Some(("insert", "xref-cannot-bind-nested-reference")), + "Unreferenced" => Some(("insert", "unreferenced")), + "XREF: cannot unload nested reference '{}'. Unload it in its host drawing." => Some(("insert", "xref-cannot-unload-nested-reference")), + "XREF: cannot overlay nested reference '{}'. Overlay it in its host drawing." => Some(("insert", "xref-cannot-overlay-nested-reference")), + "XREF: cannot reload nested reference '{}'. Reload it in its host drawing." => Some(("insert", "xref-cannot-reload-nested-reference")), + "XREF: cannot attach nested reference '{}'. Attach it in its host drawing." => Some(("insert", "xref-cannot-attach-nested-reference")), + "XREF: cannot set path type for nested reference '{}'. Set it in its host drawing." => Some(("insert", "xref-cannot-set-path-type-nested-reference")), + "{}: reload applies to drawing references only." => Some(("insert", "reload-applies-to-drawing-references-only")), + "XREF: \"{}\" set to Attach." => Some(("insert", "xref-set-to-attach")), + "Reference Manager" => Some(("insert", "xref-manager-title")), + "XATTACH: cannot attach the host drawing into itself." => Some(("insert", "xref-self-attach-blocked")), + "XREF: cannot bind \"{}\": {}." => Some(("insert", "xref-cannot-bind")), + "XREF: cannot detach nested reference '{}'. Detach it in its host drawing." => Some(("insert", "xref-cannot-detach-nested-reference")), + "XREF: PDF bind (vector import) is not available in this version." => Some(("insert", "xref-pdf-bind-unavailable")), + "XREF: cannot make path relative across drives." => Some(("insert", "xref-cannot-make-path-relative")), + "XREF: detached \"{}\"." => Some(("insert", "xref-detached")), + "XREF: no loaded reference with that key." => Some(("insert", "xref-no-loaded-reference-with-that-key")), + "XREF: no references match '{}'." => Some(("insert", "xref-no-references-match")), + "XREF: overlays apply to drawing references only." => Some(("insert", "xref-overlays-drawing-references-only")), + "XREF: Path set for \"{}\" — Reload to apply." => Some(("insert", "xref-path-set-for")), + "XREF: unloaded \"{}\"." => Some(("insert", "xref-unloaded")), + "XREF: unknown option '{}'. Options: ? Reload Unload Detach Path Pathtype Bind Overlay Attach" => Some(("insert", "xref-unknown-option")), + "XREF: updated {} reference(s)." => Some(("insert", "xref-updated-references")), + "XREF Recovered with warnings: \"{}\"" => Some(("file", "xref-recovered-with-warnings")), + "XREF Recovery failed: \"{}\" ({})" => Some(("file", "xref-recovery-failed")), + "XREF Reload failed: \"{}\" ({})" => Some(("insert", "xref-reload-failed")), + "XREF Reloaded with repairs: \"{}\"" => Some(("insert", "xref-reloaded-with-repairs")), + "Usage: XREF Path " => Some(("insert", "usage-xref-path-pattern")), + "Usage: XREF Path Find " => Some(("insert", "usage-xref-path-find")), + "Usage: XREF Pathtype [pattern]" => Some(("insert", "usage-xref-pathtype")), + "{} reference(s) not found — open the reference manager with EXTERNALREFERENCES." => Some(("insert", "xref-missing-open-xrefman")), + " (nested — not rendered)" => Some(("insert", "xref-nested-not-rendered")), + "Change Path:" => Some(("insert", "change-path")), + "Date" => Some(("insert", "date")), + "Details" => Some(("insert", "details")), + "Detach" => Some(("insert", "xref-detach")), + "Failed" => Some(("insert", "failed")), + "File attach is not available on web — the reference list below is read-only." => Some(("insert", "xref-file-attach-is-not-available-on-web")), + "File name" => Some(("insert", "xref-file-name")), + "Found At" => Some(("insert", "found-at")), + "Help" => Some(("insert", "xref-help")), + "Image" => Some(("insert", "image")), + "Nested references are read-only — edit them in their host drawing." => Some(("insert", "xref-nested-references-are-read-only")), + "Not found" => Some(("insert", "not-found")), + "Orphaned" => Some(("insert", "orphaned")), + "Overlay" => Some(("insert", "xref-overlay")), + "Path:" => Some(("insert", "path")), + "PDF" => Some(("insert", "pdf")), + "Reference Manager — select rows, then Detach, Unload, Reload, Overlay, or a Change Path mode." => Some(("insert", "xref-reference-manager-select-rows")), + "Reference Manager — select rows, then Detach, Unload, Reload, Bind, Overlay, or a Change Path mode." => Some(("insert", "xref-reference-manager-select-rows-bind")), + "Bind" => Some(("insert", "xref-bind")), + "Refresh" => Some(("insert", "xref-refresh")), + "Relative" => Some(("insert", "relative")), + "Reload" => Some(("insert", "xref-reload")), + "Replace:" => Some(("insert", "xref-replace")), + "Saved Path" => Some(("insert", "saved-path")), + "Select a reference first." => Some(("insert", "xref-select-a-reference-first")), + "Select a reference to inspect its details" => Some(("insert", "xref-select-a-reference-to-inspect-its")), + "Set Path" => Some(("insert", "xref-set-path")), + "Stale" => Some(("insert", "stale")), + "Toggle list/tree" => Some(("insert", "toggle-list-tree")), + "Tree" => Some(("insert", "tree")), + "Type a new path first." => Some(("insert", "xref-type-a-new-path-first")), + "Type the path prefix to find first." => Some(("insert", "xref-type-the-path-prefix-to-find-first")), + "Unload" => Some(("insert", "xref-unload")), + "Unloaded" => Some(("insert", "unloaded")), + "new path for selection" => Some(("insert", "xref-new-path-for-selection")), + "new path prefix" => Some(("insert", "xref-new-path-prefix")), + "old path prefix" => Some(("insert", "xref-old-path-prefix")), "Y (mm)" => Some(("plot", "y-mm")), "Yards" => Some(("common", "yards")), "Yellow" => Some(("common", "yellow")), diff --git a/src/modules/insert/xattach.rs b/src/modules/insert/xattach.rs index 3fb937427..2f6844045 100644 --- a/src/modules/insert/xattach.rs +++ b/src/modules/insert/xattach.rs @@ -337,8 +337,12 @@ pub fn prepare_xref_block( .add_entity(EntityType::BlockEnd(BlockEnd::new())); // Resolve the XREF content immediately. - let path_buf = std::path::PathBuf::from(&store_path); - if let Some(base_dir) = path_buf.parent() { + // Every reference in this host resolves relative to the host drawing, not + // relative to the newly-attached file. Using `store_path.parent()` here + // made an attach unexpectedly redirect existing relative xrefs. + if let Some(base_dir) = host_base_dir { + let _ = crate::io::xref::resolve_xrefs(&mut scene.document, base_dir); + } else if let Some(base_dir) = std::path::Path::new(&store_path).parent() { let _ = crate::io::xref::resolve_xrefs(&mut scene.document, base_dir); } diff --git a/src/ui/window/xref_manager.rs b/src/ui/window/xref_manager.rs index a0351081e..19d81879f 100644 --- a/src/ui/window/xref_manager.rs +++ b/src/ui/window/xref_manager.rs @@ -1,18 +1,16 @@ -//! Reference Manager palette — display-only list of external references. +//! Reference Manager palette — list of external references with operations. //! -//! Task 7 build: a read-only table (Reference / Status / Size / Type / Date / -//! Saved Path) fed by [`collect_entries`], a List/Tree toggle over the nested -//! closure, a details pane for the selected entry, and a toolbar whose -//! mutating affordances are present but disabled. Mutations (attach result -//! handling, path edits, unload/reload, missing-on-open prompt) arrive in -//! Tasks 8–9 — this file performs no document mutation. +//! Task 8b build: the toolbar's mutating affordances are wired to the same +//! engine fns the CLI uses, operating on the multi-selection with per-item +//! report lines. Session state (unloaded set, stat cache) lives per-tab on +//! `DocumentTab`; this panel only caches display rows plus text inputs. use crate::app::Message; -use crate::io::xref::collect_entries; -use crate::io::xref_model::{normalize_lexical, RefKind, RefStatus, RefType, ReferenceEntry}; +use crate::io::xref::collect_entries_with_prev; +use crate::io::xref_model::{normalize_lexical, Pathtype, RefKind, RefStatus, RefType, ReferenceEntry}; use crate::ui::ROW_H; use acadrust::CadDocument; -use iced::widget::{button, column, container, mouse_area, row, scrollable, text, tooltip}; +use iced::widget::{button, column, container, mouse_area, row, scrollable, text, text_input, tooltip}; use iced::Padding; use iced::{Background, Border, Element, Fill, Length, Theme}; use std::collections::{HashMap, HashSet}; @@ -42,17 +40,16 @@ pub struct DisplayRow { pub is_nested: bool, } -/// Display-only state for the Reference Manager palette. +/// Palette state for the Reference Manager. /// -/// `unloaded_keys` is the session unloaded set — empty for now; Task 8 wires -/// it to unload/reload without restructuring this state. +/// Session state (unloaded set, stat cache) lives per-tab on `DocumentTab` +/// and is passed into [`refresh`](XrefManagerPanel::refresh); this panel +/// only caches display rows, expansion, selection, and text inputs. #[derive(Default)] pub struct XrefManagerPanel { - /// Cached [`collect_entries`] output for the active drawing. + /// Cached [`collect_entries_with_prev`] output for the active drawing. pub entries: Vec, - /// Block-record / definition handles treated as unloaded (Task 8). - pub unloaded_keys: HashSet, - /// Multi-selected entry indices — consumed by batch path ops in Task 8. + /// Multi-selected entry indices — consumed by batch path ops. pub selected: HashSet, /// Anchor entry driving the details pane (last row clicked). pub anchor: Option, @@ -66,13 +63,47 @@ pub struct XrefManagerPanel { pub nested: HashSet, /// Document tab id that produced `entries` (stale check). pub source_tab_id: Option, + /// `edit_revision` that produced `entries` — the palette auto-rescans + /// when the tab's revision moves (reuses the undo-snapshot counter, so + /// CLI and palette mutations both trip it). + pub source_edit_revision: u64, + /// Draft for the details-pane "new path" edit. + pub path_input: String, + /// Drafts for the Find & Replace row. + pub find_input: String, + pub replace_input: String, +} + +/// Selection-gated palette operation the toolbar offers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum XrefPaletteOp { + Detach, + Unload, + Reload, + Bind, + Overlay, + Attach, + Pathtype(Pathtype), } impl XrefManagerPanel { - /// Rebuild `entries` from `doc` via [`collect_entries`] and recompute the - /// parent → children linkage for tree mode. Selection/anchor survive by - /// `(key, saved_path)` identity; dead indices are dropped. - pub fn refresh(&mut self, doc: &CadDocument, base_dir: &Path) { + /// Rebuild `entries` from `doc` via [`collect_entries_with_prev`] and + /// recompute the parent → children linkage for tree mode. Selection/anchor + /// survive by `(key, saved_path)` identity; dead indices are dropped. + /// + /// `unloaded`/`prev` are the active tab's session sets, so CLI and + /// palette agree. Returns fresh `(key, mtime)` baselines for every + /// `Loaded` entry — the caller writes them into the tab's stat cache, + /// which makes `Stale` detectable from the second refresh on. + /// Wasm-safe by construction: the only I/O is `collect_entries`' path + /// stat plus `load_file` for nested enumeration, both already wasm-safe. + pub fn refresh( + &mut self, + doc: &CadDocument, + base_dir: &Path, + unloaded: &HashSet, + prev: &HashMap, + ) -> Vec<(u64, SystemTime)> { let old = std::mem::take(&mut self.entries); let sel_ids: HashSet<(u64, String)> = self .selected @@ -85,7 +116,7 @@ impl XrefManagerPanel { .and_then(|i| old.get(i)) .map(|e| (e.key, e.saved_path.clone())); - let entries = collect_entries(doc, base_dir, &self.unloaded_keys); + let entries = collect_entries_with_prev(doc, base_dir, unloaded, prev); // Direct references, keyed by (handle, saved path) exactly as // `collect_entries` emits them. The saved path disambiguates a nested // entry whose foreign handle happens to collide with a host handle. @@ -154,6 +185,11 @@ impl XrefManagerPanel { .position(|e| e.key == k && e.saved_path == p) }); self.expanded.retain(|k| live_keys.contains(k)); + self.entries + .iter() + .filter(|e| e.status == RefStatus::Loaded) + .filter_map(|e| e.modified.map(|m| (e.key, m))) + .collect() } /// Click toggles membership in the multi-selection set; selecting sets the @@ -182,6 +218,25 @@ impl XrefManagerPanel { } } + /// True when the selection includes at least one nested row. Nested rows + /// stay read-only — every mutating button disables with a reason then. + pub fn selection_has_nested(&self) -> bool { + self.selected.iter().any(|i| self.nested.contains(i)) + } + + /// Selected entry indices that are directly actionable (nested rows + /// excluded — they have no host definition to mutate). + pub fn actionable_selection(&self) -> Vec { + let mut out: Vec = self + .selected + .iter() + .copied() + .filter(|i| *i < self.entries.len() && !self.nested.contains(i)) + .collect(); + out.sort_unstable(); + out + } + /// Flat render order for the current mode. Tree mode emits roots /// (direct entries plus unclaimed nested ones) with expanded children /// inline, skipping repeats by normalized saved path so a cyclic closure @@ -199,10 +254,36 @@ impl XrefManagerPanel { }) .collect(); } + fn append_tree( + panel: &XrefManagerPanel, + index: usize, + depth: u32, + seen: &mut HashSet<(String, String)>, + rows: &mut Vec, + ) { + let Some(entry) = panel.entries.get(index) else { return; }; + if !claim_path(seen, &entry.saved_path, &entry.name) { + return; + } + rows.push(DisplayRow { + index, + depth, + is_nested: panel.nested.contains(&index), + }); + if !panel.expanded.contains(&entry.key) { + return; + } + if let Some(children) = panel.children.get(&entry.key) { + for &child in children { + append_tree(panel, child, depth + 1, seen, rows); + } + } + } + let mut rows = Vec::new(); - let mut seen: HashSet = HashSet::new(); - // Roots first, in collect order; children follow an expanded parent. - for (index, entry) in self.entries.iter().enumerate() { + let mut seen: HashSet<(String, String)> = HashSet::new(); + // Roots first, then recursively expanded descendants. + for (index, _) in self.entries.iter().enumerate() { let dominated = self .children .values() @@ -210,82 +291,219 @@ impl XrefManagerPanel { if self.nested.contains(&index) && dominated { continue; } - if !claim_path(&mut seen, &entry.saved_path) { - continue; - } - rows.push(DisplayRow { - index, - depth: 0, - is_nested: self.nested.contains(&index), - }); - if self.expanded.contains(&entry.key) { - if let Some(kids) = self.children.get(&entry.key) { - for &ci in kids { - let Some(child) = self.entries.get(ci) else { - continue; - }; - if !claim_path(&mut seen, &child.saved_path) { - continue; - } - rows.push(DisplayRow { - index: ci, - depth: 1, - is_nested: true, - }); - } - } - } + append_tree(self, index, 0, &mut seen, &mut rows); } rows } /// Render the palette as the full content of its modal dialog. - pub fn view_window(&self, sizing: crate::ui::modal::ModalSizing) -> Element<'_, Message> { + /// + /// `missing` is the active tab's open-time NotFound count — a neutral + /// notice renders while non-zero (never an auto-open modal). + pub fn view_window( + &self, + sizing: crate::ui::modal::ModalSizing, + missing: usize, + ) -> Element<'_, Message> { // ── Toolbar ─────────────────────────────────────────────────────── let can_attach = !IS_WASM; let attach = if can_attach { - toolbar_btn("Attach", Some(Message::XAttachPick)) // XREF-Task8: locale + toolbar_btn(crate::t!("Attach").into_owned(), Some(Message::XAttachPick)) } else { toolbar_tip( - "Attach", // XREF-Task8: locale - "File attach is not available on web — the reference list below is read-only.", // XREF-Task8: locale + crate::t!("Attach").into_owned(), + crate::t!("File attach is not available on web — the reference list below is read-only.").into_owned(), ) }; - let image_btn = toolbar_tip( - "Image…", // XREF-Task8: locale - "images/PDF attach — Task 8", // XREF-Task8: locale + let refresh = toolbar_btn( + crate::t!("Refresh").into_owned(), + Some(Message::XrefManagerRefresh), ); - let pdf_btn = toolbar_tip( - "PDF…", // XREF-Task8: locale - "images/PDF attach — Task 8", // XREF-Task8: locale - ); - let refresh = toolbar_btn("Refresh", Some(Message::XrefManagerRefresh)); // XREF-Task8: locale - let mode_label = if self.tree { "List" } else { "Tree" }; // XREF-Task8: locale + let mode_label = if self.tree { + crate::t!("List").into_owned() + } else { + crate::t!("Tree").into_owned() + }; let mode_btn = toolbar_btn(mode_label, Some(Message::XrefManagerToggleTree)); let mode_btn: Element<'_, Message> = tooltip( mode_btn, - text("Toggle list/tree").size(11), // XREF-Task8: locale + text(crate::t!("Toggle list/tree")).size(11), tooltip::Position::Bottom, ) .into(); - // Change Path submenu items exist but stay disabled until Task 8. + // Selection-gated ops: disabled with a reason when nothing actionable + // is selected (empty selection, or nested rows which stay read-only). + let gate: Option = if IS_WASM { + Some(crate::t!("Reference changes are not available on web — the reference list is read-only.").into_owned()) + } else if self.selected.is_empty() { + Some(crate::t!("Select a reference first.").into_owned()) + } else if self.selection_has_nested() { + Some( + crate::t!("Nested references are read-only — edit them in their host drawing.") + .into_owned(), + ) + } else { + None + }; + let op_btn = |label: String, op: XrefPaletteOp, gate: &Option| match gate { + Some(reason) => toolbar_tip(label, reason.clone()), + None => toolbar_btn(label, Some(Message::XrefManagerOp(op))), + }; + let detach = op_btn( + crate::t!("Detach").into_owned(), + XrefPaletteOp::Detach, + &gate, + ); + let unload = op_btn( + crate::t!("Unload").into_owned(), + XrefPaletteOp::Unload, + &gate, + ); + let reload = op_btn( + crate::t!("Reload").into_owned(), + XrefPaletteOp::Reload, + &gate, + ); + let bind = op_btn( + crate::t!("Bind").into_owned(), + XrefPaletteOp::Bind, + &gate, + ); + let overlay = op_btn( + crate::t!("Overlay").into_owned(), + XrefPaletteOp::Overlay, + &gate, + ); + // Type control, both directions: Attach ↔ Overlay (same engine fn the + // CLI Overlay arm uses; images/PDFs report the drawing-only error). + let attach_type = op_btn( + crate::t!("Attach").into_owned(), + XrefPaletteOp::Attach, + &gate, + ); let change_path = row![ - text("Change Path:").size(11), // XREF-Task8: locale - toolbar_tip("Full", "path operations — Task 8"), // XREF-Task8: locale - toolbar_tip("Relative", "path operations — Task 8"), // XREF-Task8: locale - toolbar_tip("File name", "path operations — Task 8"), // XREF-Task8: locale + text(crate::t!("Change Path:")).size(11), + op_btn( + crate::t!("Full").into_owned(), + XrefPaletteOp::Pathtype(Pathtype::Full), + &gate, + ), + op_btn( + crate::t!("Relative").into_owned(), + XrefPaletteOp::Pathtype(Pathtype::Relative), + &gate, + ), + op_btn( + crate::t!("File name").into_owned(), + XrefPaletteOp::Pathtype(Pathtype::None), + &gate, + ), ] .spacing(2) .align_y(iced::Center); - // No docs hook exists yet — tooltip only, no docs files (Task 7). let help = toolbar_tip( - "Help", // XREF-Task8: locale - "Reference Manager — path operations ship with a later task.", // XREF-Task8: locale + crate::t!("Help").into_owned(), + crate::t!("Reference Manager — select rows, then Detach, Unload, Reload, Bind, Overlay, or a Change Path mode.").into_owned(), ); let toolbar = container( - row![attach, image_btn, pdf_btn, refresh, mode_btn, change_path, help] - .spacing(4) - .align_y(iced::Center), + row![ + attach, + refresh, + mode_btn, + detach, + unload, + reload, + bind, + overlay, + attach_type, + change_path, + help + ] + .spacing(4) + .align_y(iced::Center), + ) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + ..Default::default() + }) + .width(sizing.width) + .padding([4, 8]); + + // ── Find & Replace / path-edit row ──────────────────────────────── + let find_apply = if IS_WASM { + toolbar_tip( + crate::t!("Apply").into_owned(), + crate::t!("Reference changes are not available on web — the reference list is read-only.").into_owned(), + ) + } else if self.find_input.is_empty() { + toolbar_tip( + crate::t!("Apply").into_owned(), + crate::t!("Type the path prefix to find first.").into_owned(), + ) + } else { + toolbar_btn( + crate::t!("Apply").into_owned(), + Some(Message::XrefManagerFindReplaceApply), + ) + }; + // Path edit applies to the anchor entry; nested anchors stay disabled. + let anchor_nested = self + .anchor + .is_some_and(|a| self.nested.contains(&a)); + let path_apply = match self.anchor.and_then(|i| self.entries.get(i)) { + Some(_) if IS_WASM => toolbar_tip( + crate::t!("Set Path").into_owned(), + crate::t!("Reference changes are not available on web — the reference list is read-only.").into_owned(), + ), + Some(_) if anchor_nested => toolbar_tip( + crate::t!("Set Path").into_owned(), + crate::t!("Nested references are read-only — edit them in their host drawing.") + .into_owned(), + ), + Some(_) if !self.path_input.is_empty() => toolbar_btn( + crate::t!("Set Path").into_owned(), + Some(Message::XrefManagerPathApply), + ), + Some(_) => toolbar_tip( + crate::t!("Set Path").into_owned(), + crate::t!("Type a new path first.").into_owned(), + ), + None => toolbar_tip( + crate::t!("Set Path").into_owned(), + crate::t!("Select a reference first.").into_owned(), + ), + }; + let edit_row = container( + row![ + text(crate::t!("Find:")).size(11), + text_input( + crate::t!("old path prefix").as_ref(), + &self.find_input + ) + .on_input(Message::XrefManagerFindInput) + .size(11) + .padding(4) + .width(Length::Fixed(150.0)), + text(crate::t!("Replace:")).size(11), + text_input( + crate::t!("new path prefix").as_ref(), + &self.replace_input + ) + .on_input(Message::XrefManagerReplaceInput) + .size(11) + .padding(4) + .width(Length::Fixed(150.0)), + find_apply, + text(crate::t!("Path:")).size(11), + text_input(crate::t!("new path for selection").as_ref(), &self.path_input) + .on_input(Message::XrefManagerPathInput) + .size(11) + .padding(4) + .width(Length::Fill), + path_apply, + ] + .spacing(4) + .align_y(iced::Center), ) .style(|theme: &Theme| container::Style { background: Some(Background::Color(theme.palette().background.weak.color)), @@ -297,12 +515,12 @@ impl XrefManagerPanel { // ── Column header ───────────────────────────────────────────────── let col_header = container( row![ - text("Reference").size(10).style(muted_style).width(Length::FillPortion(3)), // XREF-Task8: locale - text("Status").size(10).style(muted_style).width(Length::Fixed(88.0)), // XREF-Task8: locale - text("Size").size(10).style(muted_style).width(Length::Fixed(76.0)), // XREF-Task8: locale - text("Type").size(10).style(muted_style).width(Length::Fixed(76.0)), // XREF-Task8: locale - text("Date").size(10).style(muted_style).width(Length::Fixed(96.0)), // XREF-Task8: locale - text("Saved Path").size(10).style(muted_style).width(Length::FillPortion(4)), // XREF-Task8: locale + text(crate::t!("Reference")).size(10).style(muted_style).width(Length::FillPortion(3)), + text(crate::t!("Status")).size(10).style(muted_style).width(Length::Fixed(88.0)), + text(crate::t!("Size")).size(10).style(muted_style).width(Length::Fixed(76.0)), + text(crate::t!("Type")).size(10).style(muted_style).width(Length::Fixed(76.0)), + text(crate::t!("Date")).size(10).style(muted_style).width(Length::Fixed(96.0)), + text(crate::t!("Saved Path")).size(10).style(muted_style).width(Length::FillPortion(4)), ] .spacing(4) .width(sizing.width) @@ -343,7 +561,7 @@ impl XrefManagerPanel { } let body: Element<'_, Message> = if self.entries.is_empty() { container( - text("No external references in this drawing") // XREF-Task8: locale + text(crate::t!("XREF No external references in this drawing.")) .size(12) .color(iced::Color { r: 0.55, @@ -361,10 +579,28 @@ impl XrefManagerPanel { scrollable(rows_col).height(Length::Fixed(TABLE_H)).into() }; - // ── Details pane ────────────────────────────────────────────────── + // ── Missing-on-open notice + details pane ───────────────────────── + let notice: Option> = if missing > 0 { + Some( + container(text(crate::tf!( + "{} reference(s) not found — open the reference manager with XREFMAN.", + missing + ))) + .padding([6, 8]) + .width(Fill) + .into(), + ) + } else { + None + }; let details = details_pane(self.anchor.and_then(|i| self.entries.get(i))); - container(column![toolbar, col_header, body, details].spacing(0)) + let mut content = column![toolbar, edit_row, col_header, body]; + if let Some(notice) = notice { + content = content.push(notice); + } + content = content.push(details); + container(content.spacing(0)) .style(|theme: &Theme| container::Style { background: Some(Background::Color( theme.palette().background.base.color, @@ -383,9 +619,6 @@ impl XrefManagerPanel { fn direct_identities(doc: &CadDocument) -> HashSet<(u64, String)> { use acadrust::entities::UnderlayType; use acadrust::objects::ObjectType; - use acadrust::types::Handle; - use acadrust::EntityType; - use rustc_hash::FxHashSet; let mut ids: HashSet<(u64, String)> = HashSet::new(); for br in doc.block_records.iter() { @@ -393,20 +626,10 @@ fn direct_identities(doc: &CadDocument) -> HashSet<(u64, String)> { ids.insert((br.handle.value(), br.xref_path.clone())); } } - let mut referenced_images: FxHashSet = FxHashSet::default(); - for e in doc.entities() { - if let EntityType::RasterImage(img) = e { - if let Some(h) = img.definition_handle { - referenced_images.insert(h); - } - } - } for (handle, obj) in doc.objects.iter() { match obj { ObjectType::ImageDefinition(def) => { - if referenced_images.contains(handle) { - ids.insert((handle.value(), def.file_name.clone())); - } + ids.insert((handle.value(), def.file_name.clone())); } ObjectType::UnderlayDefinition(def) => { if def.underlay_type == UnderlayType::Pdf { @@ -419,36 +642,38 @@ fn direct_identities(doc: &CadDocument) -> HashSet<(u64, String)> { ids } -/// Claim a normalized saved path for display; empty paths always pass, repeats -/// are skipped (cycle guard). -fn claim_path(seen: &mut HashSet, saved_path: &str) -> bool { +/// Claim a `(normalized saved path, entry name)` pair for display; empty +/// paths always pass, exact repeats are skipped (cycle guard). Two entries +/// sharing one file under different names both render. +fn claim_path(seen: &mut HashSet<(String, String)>, saved_path: &str, name: &str) -> bool { if saved_path.is_empty() { return true; } - seen.insert(normalize_lexical(saved_path)) + seen.insert((normalize_lexical(saved_path), name.to_string())) } // ── Text helpers ────────────────────────────────────────────────────────── -fn status_text(status: RefStatus) -> &'static str { +fn status_text(status: RefStatus) -> std::borrow::Cow<'static, str> { match status { - RefStatus::Loaded => "Loaded", // XREF-Task8: locale - RefStatus::Unloaded => "Unloaded", // XREF-Task8: locale - RefStatus::NotFound => "Not found", // XREF-Task8: locale - RefStatus::Failed => "Failed", // XREF-Task8: locale - RefStatus::Stale => "Stale", // XREF-Task8: locale - RefStatus::Orphaned => "Orphaned", // XREF-Task8: locale + RefStatus::Loaded => crate::t!("Loaded"), + RefStatus::Unloaded => crate::t!("Unloaded"), + RefStatus::NotFound => crate::t!("Not found"), + RefStatus::Failed => crate::t!("Failed"), + RefStatus::Stale => crate::t!("Stale"), + RefStatus::Orphaned => crate::t!("Orphaned"), + RefStatus::Unreferenced => crate::t!("Unreferenced"), } } -fn type_text(entry: &ReferenceEntry) -> &'static str { +fn type_text(entry: &ReferenceEntry) -> std::borrow::Cow<'static, str> { match entry.kind { RefKind::DwgXref => match entry.ref_type { - RefType::Attach => "Attach", // XREF-Task8: locale - RefType::Overlay => "Overlay", // XREF-Task8: locale + RefType::Attach => crate::t!("Attach"), + RefType::Overlay => crate::t!("Overlay"), }, - RefKind::Image => "Image", // XREF-Task8: locale - RefKind::Pdf => "PDF", // XREF-Task8: locale + RefKind::Image => crate::t!("Image"), + RefKind::Pdf => crate::t!("PDF"), } } @@ -513,7 +738,7 @@ fn row_button_style(selected: bool, index: usize) -> impl Fn(&Theme, button::Sta } } -fn toolbar_btn<'a>(label: &'static str, msg: Option) -> Element<'a, Message> { +fn toolbar_btn(label: String, msg: Option) -> Element<'static, Message> { let mut b = button(text(label).size(11)) .style(|theme: &Theme, status| { let palette = theme.palette(); @@ -541,9 +766,9 @@ fn toolbar_btn<'a>(label: &'static str, msg: Option) -> Element<'a, Mes b.into() } -/// Disabled toolbar affordance with an explanatory tooltip (Task 8 work or -/// web-gated mutations). -fn toolbar_tip<'a>(label: &'static str, tip: &'static str) -> Element<'a, Message> { +/// Disabled toolbar affordance with an explanatory tooltip (gated +/// operations, nested-selection blocks, or web-gated mutations). +fn toolbar_tip(label: String, tip: String) -> Element<'static, Message> { let b = button(text(label).size(11).style(|theme: &Theme| { iced::widget::text::Style { color: Some( @@ -582,7 +807,7 @@ fn xref_row<'a>( ) -> Element<'a, Message> { let mut name = entry.name.clone(); if display.is_nested { - name.push_str(" (nested — not rendered)"); // XREF-Task8: locale + name.push_str(&crate::t!(" (nested — not rendered)")); } let mut name_cell = row![].spacing(2).align_y(iced::Center); if display.depth > 0 { @@ -652,7 +877,7 @@ fn xref_row<'a>( fn details_pane(entry: Option<&ReferenceEntry>) -> Element<'_, Message> { let inner: Element<'_, Message> = match entry { - None => text("Select a reference to inspect its details") // XREF-Task8: locale + None => text(crate::t!("Select a reference to inspect its details")) .size(11) .style(muted_style) .into(), @@ -666,13 +891,13 @@ fn details_pane(entry: Option<&ReferenceEntry>) -> Element<'_, Message> { let size = format_size(e.size_bytes); let date = format_date(e.modified); column![ - detail_row("Reference", e.name.as_str()), // XREF-Task8: locale - detail_row("Status", status_text(e.status)), // XREF-Task8: locale - detail_row("Size", size), // XREF-Task8: locale - detail_row("Type", type_text(e)), // XREF-Task8: locale - detail_row("Date", date), // XREF-Task8: locale - detail_row("Found At", found), // XREF-Task8: locale - detail_row("Saved Path", saved), // XREF-Task8: locale + detail_row(crate::t!("Reference"), e.name.as_str()), + detail_row(crate::t!("Status"), status_text(e.status)), + detail_row(crate::t!("Size"), size), + detail_row(crate::t!("Type"), type_text(e)), + detail_row(crate::t!("Date"), date), + detail_row(crate::t!("Found At"), found), + detail_row(crate::t!("Saved Path"), saved), ] .spacing(1) .into() @@ -680,7 +905,7 @@ fn details_pane(entry: Option<&ReferenceEntry>) -> Element<'_, Message> { }; container( column![ - text("Details").size(10).style(muted_style), // XREF-Task8: locale + text(crate::t!("Details")).size(10).style(muted_style), inner, ] .spacing(2), @@ -699,7 +924,11 @@ fn details_pane(entry: Option<&ReferenceEntry>) -> Element<'_, Message> { .into() } -fn detail_row<'a>(label: &'static str, value: impl Into>) -> Element<'a, Message> { +fn detail_row<'a>( + label: impl Into>, + value: impl Into>, +) -> Element<'a, Message> { + let label: std::borrow::Cow<'a, str> = label.into(); let value: std::borrow::Cow<'a, str> = value.into(); row![ text(label).size(11).style(muted_style).width(Length::Fixed(84.0)), @@ -765,20 +994,23 @@ mod tests { entry(3, "B2", "b.dwg"), ]; panel.tree = true; - // Repeats collapse even among roots: B2 shares B's path. - assert_eq!(panel.display_rows().len(), 2); - // Mark B2 nested under A: the repeat path now collapses away — - // A claims a.dwg, B2-as-child claims b.dwg, the B root is a repeat. + // Same file under two names renders twice; only exact + // (path, name) repeats collapse. + assert_eq!(panel.display_rows().len(), 3); + panel.entries.push(entry(4, "B", "b.dwg")); + assert_eq!(panel.display_rows().len(), 3); + // Mark B2 nested under A: A claims (a.dwg, A), B2-as-child claims + // (b.dwg, B2); the B root still renders as (b.dwg, B). panel.nested.insert(2); panel.children.insert(1, vec![2]); panel.expanded.insert(1); let rows = panel.display_rows(); - assert_eq!(rows.len(), 2); // A, B-as-child-of-A + assert_eq!(rows.len(), 3); // A, B2-as-child-of-A, B assert!(rows.iter().any(|r| r.index == 2 && r.is_nested)); - // A second parent claiming the same path shows nothing twice. - panel.entries.push(entry(4, "C", "c.dwg")); - panel.children.insert(4, vec![2]); - panel.expanded.insert(4); + // A second parent claiming the same child shows nothing twice. + panel.entries.push(entry(5, "C", "c.dwg")); + panel.children.insert(5, vec![2]); + panel.expanded.insert(5); let count = panel .display_rows() .iter() @@ -787,6 +1019,20 @@ mod tests { assert_eq!(count, 1); } + #[test] + fn actionable_selection_skips_nested() { + let mut panel = XrefManagerPanel::default(); + panel.entries = vec![entry(1, "A", "a.dwg"), entry(2, "B", "b.dwg")]; + panel.nested.insert(1); + panel.toggle_select(0); + panel.toggle_select(1); + assert!(panel.selection_has_nested()); + // Only the direct row is actionable; the nested row stays read-only. + assert_eq!(panel.actionable_selection(), vec![0]); + panel.toggle_select(1); + assert!(!panel.selection_has_nested()); + } + #[test] fn expand_collapses_by_default() { let mut panel = XrefManagerPanel::default();