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.
This commit is contained in:
Karim Jerbi committed 2026-09-15 23:56:31 +01:00
1 parent 42219a4b8c
commit d0d946a568
16 files changed
+4856 -499

No files matched your search

+72
View File
@@ -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 <pattern> <new path>
.usage-xref-path-find = Usage: XREF Path Find <old> <new>
.usage-xref-pathtype = Usage: XREF Pathtype <Full|Relative|None> [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__ αντικείμενο(α) αποκόπηκαν στο πρόχειρο.
+72
View File
@@ -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 <pattern> <new path>
.usage-xref-path-find = Usage: XREF Path Find <old> <new>
.usage-xref-pathtype = Usage: XREF Pathtype <Full|Relative|None> [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.
+9 -5
View File
@@ -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).
File diff suppressed because it is too large. Load diff
+1
View File
@@ -713,6 +713,7 @@ inventory::submit!(crate::command::CommandRegistration {
"XDATA",
"XR",
"XREF",
"-XREF",
"XREFMAN",
"XRELOAD",
"ZOOM",
+13
View File
@@ -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,
+11
View File
@@ -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),
+608 -5
View File
@@ -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<acadrust::types::Handle> = 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<usize> = 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<std::path::PathBuf> = 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<acadrust::types::Handle> = 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 <old> <new>").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::<Vec<_>>()
.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();
}
}
+54 -1
View File
@@ -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()));
+24
View File
@@ -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;
+4 -4
View File
@@ -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 => {
+2286 -206
View File
File diff suppressed because it is too large. Load diff
+246 -93
View File
@@ -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<String, PathtypeError> {
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<String> = 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<std::time::SystemTime>,
pub saved_path: String,
pub found_at: Option<String>,
pub loaded: bool,
pub parent_key: Option<u64>,
}
@@ -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<str>]) -> 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<std::time::SystemTime>,
@@ -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<u64>` that `collect_entries` takes as `unloaded`.
/// Owns the `HashSet<UnloadKey>` 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<u64>);
pub struct UnloadSet(pub std::collections::HashSet<UnloadKey>);
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<u64> {
pub fn as_set(&self) -> &std::collections::HashSet<UnloadKey> {
&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<u64, std::time::SystemTime>);
@@ -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]
+73
View File
@@ -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 <pattern> <new path>" => Some(("insert", "usage-xref-path-pattern")),
"Usage: XREF Path Find <old> <new>" => Some(("insert", "usage-xref-path-find")),
"Usage: XREF Pathtype <Full|Relative|None> [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")),
+6 -2
View File
@@ -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);
}
+384 -138
View File
@@ -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<ReferenceEntry>,
/// Block-record / definition handles treated as unloaded (Task 8).
pub unloaded_keys: HashSet<u64>,
/// Multi-selected entry indices — consumed by batch path ops in Task 8.
/// Multi-selected entry indices — consumed by batch path ops.
pub selected: HashSet<usize>,
/// Anchor entry driving the details pane (last row clicked).
pub anchor: Option<usize>,
@@ -66,13 +63,47 @@ pub struct XrefManagerPanel {
pub nested: HashSet<usize>,
/// Document tab id that produced `entries` (stale check).
pub source_tab_id: Option<u64>,
/// `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<crate::io::xref_model::UnloadKey>,
prev: &HashMap<u64, SystemTime>,
) -> 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<usize> {
let mut out: Vec<usize> = 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<DisplayRow>,
) {
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<String> = 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<String> = 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<String>| 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<Element<'_, Message>> = 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<Handle> = 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<String>, 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<Message>) -> Element<'a, Message> {
fn toolbar_btn(label: String, msg: Option<Message>) -> 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<Message>) -> 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<std::borrow::Cow<'a, str>>) -> Element<'a, Message> {
fn detail_row<'a>(
label: impl Into<std::borrow::Cow<'a, str>>,
value: impl Into<std::borrow::Cow<'a, str>>,
) -> 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();