forked from 1b-agent/opencadstudio-upstream
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:
1 parent
42219a4b8c
commit
d0d946a568
16 files changed
+4856
-499
No files matched your search
@@ -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__ αντικείμενο(α) αποκόπηκαν στο πρόχειρο.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
+993
-45
File diff suppressed because it is too large.
Load diff
@@ -713,6 +713,7 @@ inventory::submit!(crate::command::CommandRegistration {
|
||||
"XDATA",
|
||||
"XR",
|
||||
"XREF",
|
||||
"-XREF",
|
||||
"XREFMAN",
|
||||
"XRELOAD",
|
||||
"ZOOM",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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()));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+246
-93
@@ -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]
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user