forked from 1b-agent/opencadstudio-upstream
feat(xref): reference list with stale and orphaned status detection
Reference list rows showing stale and orphaned states with handle-based identity.
This commit is contained in:
1 parent
37e2b50e1c
commit
42219a4b8c
9 files changed
+442
-1141
No files matched your search
@@ -578,8 +578,13 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
cmd if cmd.eq_ignore_ascii_case("XREF") || cmd.to_ascii_uppercase().starts_with("XREF ") => {
|
||||
// XREF sub-option dispatcher (Task 5). The verb is
|
||||
// XREFMAN — open/focus the Reference Manager palette (display-only,
|
||||
// Task 7). The XREF command below is untouched.
|
||||
"XREFMAN" => {
|
||||
return Some(Task::done(Message::ToggleXrefManager));
|
||||
}
|
||||
|
||||
cmd if cmd.eq_ignore_ascii_case("XREF") || cmd.to_ascii_uppercase().starts_with("XREF ") => { // XREF sub-option dispatcher (Task 5). The verb is
|
||||
// case-insensitive; arguments split on whitespace.
|
||||
let rest = cmd
|
||||
.splitn(2, char::is_whitespace)
|
||||
|
||||
@@ -713,6 +713,7 @@ inventory::submit!(crate::command::CommandRegistration {
|
||||
"XDATA",
|
||||
"XR",
|
||||
"XREF",
|
||||
"XREFMAN",
|
||||
"XRELOAD",
|
||||
"ZOOM",
|
||||
"ZS",
|
||||
|
||||
@@ -708,6 +708,8 @@ pub(super) struct OpenCADStudio {
|
||||
pub(crate) dock_drag_target: Option<(crate::app::config::DockSide, usize)>,
|
||||
/// Docked Insert Block panel state (search, preview size, cached thumbnails).
|
||||
pub(crate) block_palette: crate::ui::window::block_palette::BlockPalette,
|
||||
/// Reference Manager palette state (display-only in Task 7).
|
||||
pub(crate) xref_manager: crate::ui::window::xref_manager::XrefManagerPanel,
|
||||
/// Whether the document file tabs are shown at the top (FILETAB).
|
||||
show_file_tabs: bool,
|
||||
/// Whether the layout/paper-space tabs are shown at the bottom (LAYOUTTAB).
|
||||
@@ -1728,6 +1730,7 @@ pub enum ModalKind {
|
||||
UpdateNotice,
|
||||
DonationPrompt,
|
||||
Layers,
|
||||
XrefManager,
|
||||
LayerStateManager,
|
||||
LayerTranslator,
|
||||
DrawingUnits,
|
||||
@@ -2344,6 +2347,16 @@ pub enum Message {
|
||||
/// active command as if typed (empty keyword = Enter). (#304)
|
||||
CommandOptionPick(String),
|
||||
ToggleLayers,
|
||||
/// Open/focus (or close) the Reference Manager palette.
|
||||
ToggleXrefManager,
|
||||
/// Rescan the active drawing's references into the palette.
|
||||
XrefManagerRefresh,
|
||||
/// Toggle one palette row (entry index) in the multi-selection set.
|
||||
XrefManagerSelect(usize),
|
||||
/// Flip the palette's list/tree presentation.
|
||||
XrefManagerToggleTree,
|
||||
/// Expand/collapse one tree parent (block-record handle key).
|
||||
XrefManagerToggleExpand(u64),
|
||||
LayerToggleVisible(usize),
|
||||
LayerToggleLock(usize),
|
||||
LayerToggleFreeze(usize),
|
||||
@@ -3710,6 +3723,7 @@ impl OpenCADStudio {
|
||||
show_properties: true,
|
||||
show_block_palette: false,
|
||||
block_palette: Default::default(),
|
||||
xref_manager: Default::default(),
|
||||
dock: Default::default(),
|
||||
dock_expanded: None,
|
||||
dock_dragging: None,
|
||||
|
||||
@@ -757,6 +757,32 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
|
||||
self.refresh_block_palette();
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the Reference Manager's entry list from the active drawing.
|
||||
/// Display-only: `collect_entries` stats files but mutates nothing.
|
||||
pub(crate) fn refresh_xref_manager(&mut self) {
|
||||
let i = self.active_tab;
|
||||
let base_dir: std::path::PathBuf = self.tabs[i]
|
||||
.current_path
|
||||
.as_ref()
|
||||
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
let tab_id = self.tabs[i].id;
|
||||
self.xref_manager
|
||||
.refresh(&self.tabs[i].scene.document, &base_dir);
|
||||
self.xref_manager.source_tab_id = Some(tab_id);
|
||||
}
|
||||
|
||||
/// Re-scan when the palette is open but showing another tab's drawing.
|
||||
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) {
|
||||
self.refresh_xref_manager();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -314,6 +314,9 @@ impl OpenCADStudio {
|
||||
// The block panel watches the drawing's block list and rebuilds its
|
||||
// thumbnails whenever the names change (BLOCK define, file open, …).
|
||||
self.refresh_block_palette_if_stale();
|
||||
// The Reference Manager watches the active drawing and re-scans when
|
||||
// the palette is open on another tab's entries.
|
||||
self.refresh_xref_manager_if_stale();
|
||||
// Let V4 plugins observe selection changes that happened while handling
|
||||
// this message (picking, window select, QSELECT, SELECTALL, grip edits,
|
||||
// and plugin request draining).
|
||||
@@ -2127,6 +2130,33 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::ToggleXrefManager => {
|
||||
if self.active_modal == Some(super::ModalKind::XrefManager) {
|
||||
self.active_modal = None;
|
||||
self.reset_modal_geometry();
|
||||
} else {
|
||||
self.refresh_xref_manager();
|
||||
self.active_modal = Some(super::ModalKind::XrefManager);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::XrefManagerRefresh => {
|
||||
self.refresh_xref_manager();
|
||||
Task::none()
|
||||
}
|
||||
Message::XrefManagerSelect(index) => {
|
||||
self.xref_manager.toggle_select(index);
|
||||
Task::none()
|
||||
}
|
||||
Message::XrefManagerToggleTree => {
|
||||
self.xref_manager.toggle_tree();
|
||||
Task::none()
|
||||
}
|
||||
Message::XrefManagerToggleExpand(key) => {
|
||||
self.xref_manager.toggle_expand(key);
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::LayerStateManagerOpen => {
|
||||
let i = self.active_tab;
|
||||
self.ribbon.close_dropdown();
|
||||
|
||||
@@ -20,6 +20,8 @@ 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::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(),
|
||||
@@ -394,6 +396,11 @@ impl OpenCADStudio {
|
||||
|flow| tab.layers.view_window(self.layer_name_col_w, flow),
|
||||
)
|
||||
}
|
||||
super::super::ModalKind::XrefManager => {
|
||||
sized_flow(ex, 960, 520, |flow| {
|
||||
self.xref_manager.view_window(flow)
|
||||
})
|
||||
}
|
||||
super::super::ModalKind::LayerTranslator => {
|
||||
use crate::modules::draw::layers::laytrans;
|
||||
let i = self.active_tab;
|
||||
|
||||
+186
-4
@@ -312,9 +312,17 @@ pub enum RefType {
|
||||
/// One external reference in the XREF manager's unified list.
|
||||
///
|
||||
/// `key` is the block-record handle (DWG xref) or definition-object handle
|
||||
/// (image / PDF) — stable across renames, so [`renamed`](Self::renamed) keeps
|
||||
/// it. `saved_path` is the raw stored string verbatim, never synthesized;
|
||||
/// `found_at` is where it actually resolved, if anywhere.
|
||||
/// (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
|
||||
/// resolved, if anywhere.
|
||||
///
|
||||
/// `parent_key` is `None` for roots and `Some(host_key)` for nested children.
|
||||
/// The palette's refresh path keys rows off `(key, saved_path)` — both sides
|
||||
/// go through `collect_entries`, so the same derivation applies everywhere.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ReferenceEntry {
|
||||
pub key: u64,
|
||||
@@ -327,6 +335,7 @@ pub struct ReferenceEntry {
|
||||
pub saved_path: String,
|
||||
pub found_at: Option<String>,
|
||||
pub loaded: bool,
|
||||
pub parent_key: Option<u64>,
|
||||
}
|
||||
|
||||
impl ReferenceEntry {
|
||||
@@ -342,6 +351,7 @@ impl ReferenceEntry {
|
||||
saved_path: String::new(),
|
||||
found_at: None,
|
||||
loaded: true,
|
||||
parent_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,7 +370,6 @@ impl ReferenceEntry {
|
||||
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`).
|
||||
pub fn bind_symbol_taken(parent: &str, sym: &str, taken: &[impl AsRef<str>]) -> String {
|
||||
@@ -374,10 +383,183 @@ pub fn bind_symbol_taken(parent: &str, sym: &str, taken: &[impl AsRef<str>]) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// `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()
|
||||
}
|
||||
|
||||
/// Pure status decision for one entry (no filesystem).
|
||||
///
|
||||
/// - `resolved` is the stat outcome (`Loaded`/`NotFound`/`Failed`; `Unloaded`
|
||||
/// 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).
|
||||
/// - `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`.
|
||||
pub fn decide_status(
|
||||
resolved: RefStatus,
|
||||
live: Option<std::time::SystemTime>,
|
||||
cached: Option<std::time::SystemTime>,
|
||||
parent_failed: bool,
|
||||
) -> RefStatus {
|
||||
if parent_failed && resolved == RefStatus::Loaded {
|
||||
return RefStatus::Orphaned;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
resolved
|
||||
}
|
||||
|
||||
/// Session set of unloaded reference keys.
|
||||
///
|
||||
/// Owns the `HashSet<u64>` 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).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct UnloadSet(pub std::collections::HashSet<u64>);
|
||||
|
||||
impl UnloadSet {
|
||||
pub fn add(&mut self, key: u64) {
|
||||
self.0.insert(key);
|
||||
}
|
||||
pub fn remove(&mut self, key: &u64) {
|
||||
self.0.remove(key);
|
||||
}
|
||||
pub fn contains(&self, key: &u64) -> bool {
|
||||
self.0.contains(key)
|
||||
}
|
||||
pub fn is_unloaded(&self, key: u64) -> bool {
|
||||
self.0.contains(&key)
|
||||
}
|
||||
pub fn as_set(&self) -> &std::collections::HashSet<u64> {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Load-time mtimes per reference key, populated on first refresh.
|
||||
///
|
||||
/// `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.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RefStatCache(pub std::collections::HashMap<u64, std::time::SystemTime>);
|
||||
|
||||
impl RefStatCache {
|
||||
pub fn insert(&mut self, key: u64, mtime: std::time::SystemTime) {
|
||||
self.0.insert(key, mtime);
|
||||
}
|
||||
pub fn get(&self, key: &u64) -> Option<std::time::SystemTime> {
|
||||
self.0.get(key).copied()
|
||||
}
|
||||
pub fn remove(&mut self, key: &u64) {
|
||||
self.0.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{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};
|
||||
|
||||
#[test]
|
||||
fn child_key_differs_from_colliding_host_key() {
|
||||
// Crafted collision: nested foreign handle 42 equals a host handle 42.
|
||||
// Namespaced derivation must not alias the host row.
|
||||
let host_key = 42u64;
|
||||
let nested = child_key(100, "DETAIL", "refs/detail.dwg");
|
||||
assert_ne!(nested, host_key);
|
||||
// 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 decide_status_stale_needs_slack() {
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
let cached = UNIX_EPOCH + Duration::from_secs(1_000);
|
||||
// +0.5s: within 1s slack → stays Loaded.
|
||||
let live = cached + Duration::from_millis(500);
|
||||
assert_eq!(
|
||||
decide_status(RefStatus::Loaded, Some(live), Some(cached), false),
|
||||
RefStatus::Loaded
|
||||
);
|
||||
// +2s: beyond slack → Stale.
|
||||
let live2 = cached + Duration::from_secs(2);
|
||||
assert_eq!(
|
||||
decide_status(RefStatus::Loaded, Some(live2), Some(cached), false),
|
||||
RefStatus::Stale
|
||||
);
|
||||
// No cached baseline (first refresh) → never Stale on bootstrap.
|
||||
assert_eq!(
|
||||
decide_status(RefStatus::Loaded, Some(live2), None, false),
|
||||
RefStatus::Loaded
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decide_status_orphaned_overrides_loaded_only() {
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
let t = Some(UNIX_EPOCH + Duration::from_secs(1_000));
|
||||
assert_eq!(
|
||||
decide_status(RefStatus::Loaded, t, t, true),
|
||||
RefStatus::Orphaned
|
||||
);
|
||||
// Non-Loaded nested states survive the parent override.
|
||||
assert_eq!(
|
||||
decide_status(RefStatus::NotFound, t, t, true),
|
||||
RefStatus::NotFound
|
||||
);
|
||||
assert_eq!(
|
||||
decide_status(RefStatus::Failed, t, t, true),
|
||||
RefStatus::Failed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unload_set_add_remove_contains() {
|
||||
let mut s = UnloadSet::default();
|
||||
assert!(!s.is_unloaded(7));
|
||||
s.add(7);
|
||||
assert!(s.is_unloaded(7));
|
||||
assert!(s.contains(&7));
|
||||
s.remove(&7);
|
||||
assert!(!s.is_unloaded(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ref_stat_cache_roundtrip() {
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
let mut c = RefStatCache::default();
|
||||
let t = UNIX_EPOCH + Duration::from_secs(5);
|
||||
c.insert(9, t);
|
||||
assert_eq!(c.get(&9), Some(t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lexical_normalize_missing_file_no_fs_touch() {
|
||||
|
||||
@@ -21,3 +21,4 @@ pub mod annotation_data;
|
||||
pub mod alias_editor;
|
||||
pub mod find_replace;
|
||||
pub mod named_parameters;
|
||||
pub mod xref_manager;
|
||||
+170
-1135
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user