Files
OneCAD-Draft/src/ui/window/xref_manager.rs
T
Karim Jerbi 42219a4b8c feat(xref): reference list with stale and orphaned status detection
Reference list rows showing stale and orphaned states with handle-based identity.
2026-09-15 23:56:31 +01:00

804 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Reference Manager palette — display-only list of external references.
//!
//! 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.
use crate::app::Message;
use crate::io::xref::collect_entries;
use crate::io::xref_model::{normalize_lexical, 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::Padding;
use iced::{Background, Border, Element, Fill, Length, Theme};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
/// `true` on web builds, where the filesystem and native file pickers do not
/// exist. Mutating affordances stay compiled but render disabled; the
/// read-only list renders everywhere.
const IS_WASM: bool = cfg!(target_arch = "wasm32");
/// Font size for table cells (mirrors `layers.rs`).
const FONT_SZ: f32 = ROW_H * 0.42; // ≈11 px at ROW_H=26
/// Fixed table height so the details pane below keeps stable space.
const TABLE_H: f32 = 240.0;
/// Indent per tree depth level.
const INDENT_W: f32 = 16.0;
/// One visible row in [`XrefManagerPanel::display_rows`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DisplayRow {
/// Index into [`XrefManagerPanel::entries`].
pub index: usize,
/// Tree depth (0 in list mode and for roots).
pub depth: u32,
/// True for entries enumerated from a nested file (never rendered).
pub is_nested: bool,
}
/// Display-only state for the Reference Manager palette.
///
/// `unloaded_keys` is the session unloaded set — empty for now; Task 8 wires
/// it to unload/reload without restructuring this state.
#[derive(Default)]
pub struct XrefManagerPanel {
/// Cached [`collect_entries`] 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.
pub selected: HashSet<usize>,
/// Anchor entry driving the details pane (last row clicked).
pub anchor: Option<usize>,
/// List (`false`) vs tree (`true`) presentation.
pub tree: bool,
/// Parent keys expanded in tree mode. Empty by default = collapsed.
pub expanded: HashSet<u64>,
/// Parent key → child entry indices, rebuilt on every refresh.
pub children: HashMap<u64, Vec<usize>>,
/// Entry indices enumerated from nested files (not the host drawing).
pub nested: HashSet<usize>,
/// Document tab id that produced `entries` (stale check).
pub source_tab_id: Option<u64>,
}
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) {
let old = std::mem::take(&mut self.entries);
let sel_ids: HashSet<(u64, String)> = self
.selected
.iter()
.filter_map(|&i| old.get(i))
.map(|e| (e.key, e.saved_path.clone()))
.collect();
let anchor_id: Option<(u64, String)> = self
.anchor
.and_then(|i| old.get(i))
.map(|e| (e.key, e.saved_path.clone()));
let entries = collect_entries(doc, base_dir, &self.unloaded_keys);
// 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.
let direct_ids = direct_identities(doc);
let mut nested = HashSet::new();
for (i, e) in entries.iter().enumerate() {
if !direct_ids.contains(&(e.key, e.saved_path.clone())) {
nested.insert(i);
}
}
// Parent linkage: each loaded drawing root's file is opened read-only
// and its direct reference paths are matched (by lexical identity)
// against nested entries. First parent wins; unmatchable nested
// entries stay unclaimed and render as roots so nothing vanishes.
let mut children: HashMap<u64, Vec<usize>> = HashMap::new();
let mut claimed: HashSet<usize> = HashSet::new();
let direct_order: Vec<usize> = entries
.iter()
.enumerate()
.filter(|(i, _)| !nested.contains(i))
.map(|(i, _)| i)
.collect();
for &pi in &direct_order {
let parent = &entries[pi];
if parent.kind != RefKind::DwgXref
|| parent.status != RefStatus::Loaded
|| parent.found_at.is_none()
{
continue;
}
let found = parent.found_at.as_deref().unwrap_or("");
let child_paths: HashSet<String> = match crate::io::load_file(Path::new(found)) {
Ok(nested_doc) => nested_doc
.block_records
.iter()
.filter(|br| br.flags.is_xref || br.flags.is_xref_overlay)
.map(|br| normalize_lexical(&br.xref_path))
.collect(),
Err(_) => continue,
};
for (ci, child) in entries.iter().enumerate() {
if !nested.contains(&ci) || claimed.contains(&ci) {
continue;
}
if child_paths.contains(&normalize_lexical(&child.saved_path)) {
children.entry(parent.key).or_default().push(ci);
claimed.insert(ci);
}
}
}
let live_keys: HashSet<u64> = entries.iter().map(|e| e.key).collect();
self.entries = entries;
self.nested = nested;
self.children = children;
self.selected = self
.entries
.iter()
.enumerate()
.filter(|(_, e)| sel_ids.contains(&(e.key, e.saved_path.clone())))
.map(|(i, _)| i)
.collect();
self.anchor = anchor_id.and_then(|(k, p)| {
self.entries
.iter()
.position(|e| e.key == k && e.saved_path == p)
});
self.expanded.retain(|k| live_keys.contains(k));
}
/// Click toggles membership in the multi-selection set; selecting sets the
/// details anchor, deselecting the anchor falls back to a survivor.
pub fn toggle_select(&mut self, index: usize) {
if index >= self.entries.len() {
return;
}
if !self.selected.remove(&index) {
self.selected.insert(index);
self.anchor = Some(index);
} else if self.anchor == Some(index) {
self.anchor = self.selected.iter().copied().max();
}
}
/// Flip list/tree presentation.
pub fn toggle_tree(&mut self) {
self.tree = !self.tree;
}
/// Expand/collapse one tree parent (no-op for unknown keys).
pub fn toggle_expand(&mut self, parent_key: u64) {
if !self.expanded.remove(&parent_key) && self.children.contains_key(&parent_key) {
self.expanded.insert(parent_key);
}
}
/// 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
/// can never recurse on screen.
pub fn display_rows(&self) -> Vec<DisplayRow> {
if !self.tree {
return self
.entries
.iter()
.enumerate()
.map(|(index, _)| DisplayRow {
index,
depth: 0,
is_nested: self.nested.contains(&index),
})
.collect();
}
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 dominated = self
.children
.values()
.any(|kids| kids.contains(&index));
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,
});
}
}
}
}
rows
}
/// Render the palette as the full content of its modal dialog.
pub fn view_window(&self, sizing: crate::ui::modal::ModalSizing) -> Element<'_, Message> {
// ── Toolbar ───────────────────────────────────────────────────────
let can_attach = !IS_WASM;
let attach = if can_attach {
toolbar_btn("Attach", Some(Message::XAttachPick)) // XREF-Task8: locale
} else {
toolbar_tip(
"Attach", // XREF-Task8: locale
"File attach is not available on web — the reference list below is read-only.", // XREF-Task8: locale
)
};
let image_btn = toolbar_tip(
"Image…", // XREF-Task8: locale
"images/PDF attach — Task 8", // XREF-Task8: locale
);
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_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
tooltip::Position::Bottom,
)
.into();
// Change Path submenu items exist but stay disabled until Task 8.
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
]
.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
);
let toolbar = container(
row![attach, image_btn, pdf_btn, refresh, mode_btn, 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]);
// ── 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
]
.spacing(4)
.width(sizing.width)
.align_y(iced::Center),
)
.style(|theme: &Theme| {
let palette = theme.palette();
container::Style {
background: Some(Background::Color(palette.background.weak.color)),
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 0.0.into(),
},
..Default::default()
}
})
.padding([4, 8])
.width(sizing.width);
// ── Reference rows ────────────────────────────────────────────────
let mut rows_col = column![].spacing(0);
for display in self.display_rows() {
let Some(entry) = self.entries.get(display.index) else {
continue;
};
let is_sel = self.selected.contains(&display.index);
let kids = self.children.get(&entry.key);
let has_children = kids.is_some_and(|k| !k.is_empty());
let is_expanded = has_children && self.expanded.contains(&entry.key);
rows_col = rows_col.push(xref_row(
display,
entry,
is_sel,
self.tree && has_children,
is_expanded,
));
}
let body: Element<'_, Message> = if self.entries.is_empty() {
container(
text("No external references in this drawing") // XREF-Task8: locale
.size(12)
.color(iced::Color {
r: 0.55,
g: 0.55,
b: 0.55,
a: 1.0,
}),
)
.center_x(Fill)
.center_y(Fill)
.width(Fill)
.height(Length::Fixed(TABLE_H))
.into()
} else {
scrollable(rows_col).height(Length::Fixed(TABLE_H)).into()
};
// ── Details pane ──────────────────────────────────────────────────
let details = details_pane(self.anchor.and_then(|i| self.entries.get(i)));
container(column![toolbar, col_header, body, details].spacing(0))
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.base.color,
)),
..Default::default()
})
.width(sizing.width)
.height(sizing.height)
.into()
}
}
/// `(key, saved_path)` identities for every reference the host drawing holds
/// directly — mirrors the three [`collect_entries`] scans so nested entries
/// (foreign handles) classify correctly even on handle collisions.
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() {
if br.flags.is_xref || br.flags.is_xref_overlay {
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()));
}
}
ObjectType::UnderlayDefinition(def) => {
if def.underlay_type == UnderlayType::Pdf {
ids.insert((handle.value(), def.file_path.clone()));
}
}
_ => {}
}
}
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 {
if saved_path.is_empty() {
return true;
}
seen.insert(normalize_lexical(saved_path))
}
// ── Text helpers ──────────────────────────────────────────────────────────
fn status_text(status: RefStatus) -> &'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
}
}
fn type_text(entry: &ReferenceEntry) -> &'static str {
match entry.kind {
RefKind::DwgXref => match entry.ref_type {
RefType::Attach => "Attach", // XREF-Task8: locale
RefType::Overlay => "Overlay", // XREF-Task8: locale
},
RefKind::Image => "Image", // XREF-Task8: locale
RefKind::Pdf => "PDF", // XREF-Task8: locale
}
}
fn format_size(size_bytes: Option<u64>) -> String {
match size_bytes {
None => "—".to_string(),
Some(b) if b < 1024 => format!("{b} B"),
Some(b) if b < 1024 * 1024 => format!("{:.1} KB", b as f64 / 1024.0),
Some(b) if b < 1024 * 1024 * 1024 => format!("{:.1} MB", b as f64 / 1_048_576.0),
Some(b) => format!("{:.1} GB", b as f64 / 1_073_741_824.0),
}
}
/// Calendar date (`YYYY-MM-DD`) for a file modification time. Hand-rolled
/// civil-from-days so no date crate joins the dependency tree.
fn format_date(modified: Option<SystemTime>) -> String {
let Some(t) = modified else {
return "—".to_string();
};
let secs = t
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let z = secs.div_euclid(86_400) + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy as i64 - (153 * mp as u64 + 2) as i64 / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as i64;
format!("{:04}-{:02}-{:02}", if m <= 2 { y + 1 } else { y }, m, d)
}
// ── Widget helpers (layers.rs conventions) ────────────────────────────────
fn muted_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().background.base.text.scale_alpha(0.68)),
}
}
fn row_button_style(selected: bool, index: usize) -> impl Fn(&Theme, button::Status) -> button::Style {
move |theme: &Theme, status: button::Status| {
let palette = theme.palette();
let highlighted = matches!(status, button::Status::Hovered);
let pair = if highlighted {
palette.background.strong
} else if selected {
palette.primary.weak
} else if index % 2 == 0 {
palette.background.base
} else {
palette.background.weak
};
button::Style {
background: highlighted.then_some(Background::Color(pair.color)),
text_color: pair.text,
..Default::default()
}
}
}
fn toolbar_btn<'a>(label: &'static str, msg: Option<Message>) -> Element<'a, Message> {
let mut b = button(text(label).size(11))
.style(|theme: &Theme, status| {
let palette = theme.palette();
let pair = match status {
button::Status::Hovered | button::Status::Pressed => {
palette.background.strong
}
_ => palette.background.weak,
};
button::Style {
background: Some(Background::Color(pair.color)),
border: Border {
radius: 3.0.into(),
color: palette.background.neutral.color,
width: 1.0,
},
text_color: pair.text,
..Default::default()
}
})
.padding([4, 10]);
if let Some(m) = msg {
b = b.on_press(m);
}
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> {
let b = button(text(label).size(11).style(|theme: &Theme| {
iced::widget::text::Style {
color: Some(
theme
.palette()
.background
.base
.text
.scale_alpha(0.42),
),
}
}))
.style(|theme: &Theme, _| {
let palette = theme.palette();
button::Style {
background: Some(Background::Color(palette.background.weak.color)),
border: Border {
radius: 3.0.into(),
color: palette.background.neutral.color,
width: 1.0,
},
text_color: palette.background.weak.text.scale_alpha(0.42),
..Default::default()
}
})
.padding([4, 10]);
tooltip(b, text(tip).size(11), tooltip::Position::Bottom).into()
}
fn xref_row<'a>(
display: DisplayRow,
entry: &'a ReferenceEntry,
is_selected: bool,
show_expand: bool,
is_expanded: bool,
) -> Element<'a, Message> {
let mut name = entry.name.clone();
if display.is_nested {
name.push_str(" (nested — not rendered)"); // XREF-Task8: locale
}
let mut name_cell = row![].spacing(2).align_y(iced::Center);
if display.depth > 0 {
name_cell = name_cell.push(
iced::widget::Space::new().width(Length::Fixed(INDENT_W * display.depth as f32)),
);
}
// Tree parents own the only interactive cell besides selection: the
// expand/collapse arrow. Nested rows never show one.
if show_expand {
let key = entry.key;
let arrow = button(crate::ui::icons::themed_arrow_toggle(is_expanded, 10.0))
.on_press(Message::XrefManagerToggleExpand(key))
.style(row_button_style(is_selected, display.index))
.padding(Padding {
top: 6.0,
bottom: 6.0,
left: 2.0,
right: 2.0,
});
name_cell = name_cell.push(arrow);
}
let name_text: Element<'_, Message> = text(name).size(FONT_SZ).into();
name_cell = name_cell.push(name_text);
let content = row![
name_cell.width(Length::FillPortion(3)),
text(status_text(entry.status)).size(FONT_SZ).width(Length::Fixed(88.0)),
text(format_size(entry.size_bytes)).size(FONT_SZ).width(Length::Fixed(76.0)),
text(type_text(entry)).size(FONT_SZ).width(Length::Fixed(76.0)),
text(format_date(entry.modified)).size(FONT_SZ).width(Length::Fixed(96.0)),
text(entry.saved_path.clone()).size(FONT_SZ).width(Length::FillPortion(4)),
]
.spacing(4)
.width(Fill)
.align_y(iced::Center);
let index = display.index;
mouse_area(
container(content)
.style(move |theme: &Theme| {
let palette = theme.palette();
let pair = if is_selected {
palette.primary.weak
} else if index % 2 == 0 {
palette.background.base
} else {
palette.background.weak
};
container::Style {
background: Some(Background::Color(pair.color)),
text_color: Some(pair.text),
..Default::default()
}
})
.padding(Padding {
top: 0.0,
bottom: 0.0,
left: 8.0,
right: 8.0,
})
.height(Length::Fixed(ROW_H))
.width(Fill),
)
.on_press(Message::XrefManagerSelect(index))
.into()
}
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
.size(11)
.style(muted_style)
.into(),
Some(e) => {
let found = e.found_at.as_deref().unwrap_or("—");
let saved = if e.saved_path.is_empty() {
"—"
} else {
e.saved_path.as_str()
};
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
]
.spacing(1)
.into()
}
};
container(
column![
text("Details").size(10).style(muted_style), // XREF-Task8: locale
inner,
]
.spacing(2),
)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.weak.color)),
border: Border {
color: theme.palette().background.neutral.color,
width: 1.0,
radius: 0.0.into(),
},
..Default::default()
})
.padding([6, 8])
.width(Fill)
.into()
}
fn detail_row<'a>(label: &'static str, value: impl Into<std::borrow::Cow<'a, str>>) -> Element<'a, Message> {
let value: std::borrow::Cow<'a, str> = value.into();
row![
text(label).size(11).style(muted_style).width(Length::Fixed(84.0)),
text(value).size(11),
]
.spacing(6)
.align_y(iced::Center)
.into()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::io::xref_model::RefKind;
#[test]
fn size_formats_without_deps() {
assert_eq!(format_size(None), "—");
assert_eq!(format_size(Some(0)), "0 B");
assert_eq!(format_size(Some(900)), "900 B");
assert_eq!(format_size(Some(2048)), "2.0 KB");
assert_eq!(format_size(Some(5 * 1_048_576)), "5.0 MB");
}
#[test]
fn date_formats_known_days() {
assert_eq!(format_date(None), "—");
let day = |days: u64| {
format_date(Some(UNIX_EPOCH + std::time::Duration::from_secs(days * 86_400)))
};
assert_eq!(day(0), "1970-01-01");
assert_eq!(day(10957), "2000-01-01");
assert_eq!(day(19723), "2024-01-01");
}
fn entry(key: u64, name: &str, saved: &str) -> ReferenceEntry {
let mut e = ReferenceEntry::new(key, name, RefKind::DwgXref);
e.saved_path = saved.to_string();
e
}
#[test]
fn select_toggles_anchor_and_set() {
let mut panel = XrefManagerPanel::default();
panel.entries = vec![entry(1, "A", "a.dwg"), entry(2, "B", "b.dwg")];
panel.toggle_select(0);
panel.toggle_select(1);
assert!(panel.selected.contains(&0) && panel.selected.contains(&1));
assert_eq!(panel.anchor, Some(1));
panel.toggle_select(1);
assert!(!panel.selected.contains(&1));
assert_eq!(panel.anchor, Some(0));
panel.toggle_select(9); // out of range: no-op
assert_eq!(panel.selected.len(), 1);
}
#[test]
fn tree_skips_repeat_paths() {
let mut panel = XrefManagerPanel::default();
panel.entries = vec![
entry(1, "A", "a.dwg"),
entry(2, "B", "b.dwg"),
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.
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!(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);
let count = panel
.display_rows()
.iter()
.filter(|r| r.index == 2)
.count();
assert_eq!(count, 1);
}
#[test]
fn expand_collapses_by_default() {
let mut panel = XrefManagerPanel::default();
panel.entries = vec![entry(1, "A", "a.dwg"), entry(2, "B", "b.dwg")];
panel.nested.insert(1);
panel.children.insert(1, vec![1]);
panel.tree = true;
assert!(!panel.display_rows().iter().any(|r| r.is_nested));
panel.toggle_expand(1);
assert!(panel.display_rows().iter().any(|r| r.is_nested));
panel.toggle_expand(1);
assert!(!panel.display_rows().iter().any(|r| r.is_nested));
}
}