fix(xref): carry stable row identity in row messages

XrefManagerSelect/RightClick/RowOp/PathPick/FindReplace carried positional display indices that rescans could retarget. New XrefRowId{key,saved_path} (the identity refresh uses) in all 5 variants, resolved via row_index at dispatch with graceful no-op; panel selection stays index-based. display.index remains for view-time styling/lookup only. Tests: xref_rowop_unknown_id_is_noop (RED first); existing dialog/simulator/click tests migrated to ids. Full suite: 2044 passed / 0 failed / 25 ignored.
This commit is contained in:
Karim Jerbi committed 2026-10-11 02:17:18 +01:00
1 parent 7bbc5b0319
commit 6fa11887c7
4 files changed
+185 -89

No files matched your search

+13 -10
View File
@@ -2671,11 +2671,11 @@ pub enum Message {
ToggleXrefManager,
/// Rescan the active drawing's references into the palette.
XrefManagerRefresh,
/// Toggle one palette row (entry index) in the multi-selection set.
XrefManagerSelect(usize),
/// Toggle one palette row (stable row id) in the multi-selection set.
XrefManagerSelect(crate::ui::window::xref_manager::XrefRowId),
/// Right-click on a palette row: single-select it when outside the
/// selection (the context menu then acts on the selection).
XrefRowRightClick(usize),
XrefRowRightClick(crate::ui::window::xref_manager::XrefRowId),
/// Flip the palette's list/tree presentation.
XrefManagerToggleTree,
/// Start a reference-table column divider drag (column index).
@@ -2712,16 +2712,19 @@ pub enum Message {
XrefManagerToggleExpand(u64),
/// Selection-scoped palette operation (detach/unload/reload/overlay/pathtype).
XrefManagerOp(crate::ui::window::xref_manager::XrefPaletteOp),
/// Row-scoped palette operation: selects row `index`, then applies the
/// operation to it.
XrefRowOp(usize, crate::ui::window::xref_manager::XrefPaletteOp),
/// Row-scoped palette operation: selects the row by stable id, then
/// applies the operation to it.
XrefRowOp(
crate::ui::window::xref_manager::XrefRowId,
crate::ui::window::xref_manager::XrefPaletteOp,
),
/// Prefill the command line for Find & Replace across references
/// (`XREF Path Find <old> <new>`); the CLI parses and runs it.
XrefFindReplacePrompt,
/// Pick a new path for a specific row index.
XrefRowPathPick(usize),
/// Prefill the command line for Find & Replace for a specific row index.
XrefRowFindReplacePrompt(usize),
/// Pick a new path for a specific row (stable id).
XrefRowPathPick(crate::ui::window::xref_manager::XrefRowId),
/// Prefill the command line for Find & Replace for a specific row id.
XrefRowFindReplacePrompt(crate::ui::window::xref_manager::XrefRowId),
XrefRowChangePathEnter,
XrefRowChangePathLeave,
LayerToggleVisible(String),
+46 -13
View File
@@ -1818,6 +1818,35 @@ mod tests {
.join("\n")
}
#[test]
fn xref_rowop_unknown_id_is_noop() {
// #49 RED: row messages carry (key, saved_path) identity and resolve
// at dispatch; a deleted row's id must no-op instead of hitting whatever
// row now sits at the old index. Fails to compile until the payload
// carries XrefRowId instead of usize.
use crate::app::Message;
use crate::ui::window::xref_manager::{XrefPaletteOp, XrefRowId};
let mut app = fresh();
let i = app.active_tab;
let start = app.command_line.history.len();
let ghost = XrefRowId {
key: u64::MAX,
saved_path: "gone.dwg".to_string(),
};
let records_before = app.tabs[i].scene.document.block_records.iter().count();
let _ = app.update(Message::XrefRowOp(ghost, XrefPaletteOp::Detach));
assert_eq!(
app.tabs[i].scene.document.block_records.iter().count(),
records_before,
"no-op must not disturb the document"
);
let out = palette_output(&app, start);
assert!(
!out.contains("detached"),
"unknown id must not detach anything, got: {out:?}"
);
}
#[test]
fn palette_rowop_nested_reports_per_entry() {
// Behavioral matrix: every row op on a NESTED row via the GUI
@@ -1825,7 +1854,7 @@ mod tests {
// wording), never silently skip.
use codec::tables::BlockRecord;
use crate::app::Message;
use crate::ui::window::xref_manager::XrefPaletteOp;
use crate::ui::window::xref_manager::{XrefPaletteOp, XrefRowId};
let dir = palette_tmpdir("nestedmatrix");
let mut host_doc = codec::CadDocument::new();
let mut inner = BlockRecord::new("INNER");
@@ -1843,8 +1872,9 @@ mod tests {
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());
let row = app.xref_manager.entries.iter().find(|e| e.name == "INNER").expect("nested INNER listed");
assert!(row.parent_key.is_some());
let row_id = XrefRowId::of(row.key, &row.saved_path);
for op in [
XrefPaletteOp::Detach,
XrefPaletteOp::Unload,
@@ -1853,7 +1883,7 @@ mod tests {
XrefPaletteOp::Overlay,
] {
let start = app.command_line.history.len();
let _ = app.update(Message::XrefRowOp(idx, op));
let _ = app.update(Message::XrefRowOp(row_id.clone(), op));
let out = palette_output(&app, start);
assert!(out.contains("INNER"), "op {op:?} on nested row gave: {out:?}");
assert!(
@@ -1870,7 +1900,7 @@ mod tests {
// drive the exact GUI message path (row right-click menu item).
use codec::tables::BlockRecord;
use crate::app::Message;
use crate::ui::window::xref_manager::XrefPaletteOp;
use crate::ui::window::xref_manager::{XrefPaletteOp, XrefRowId};
let mut app = fresh();
let i = app.active_tab;
for name in ["PLAN", "SITE"] {
@@ -1882,15 +1912,17 @@ mod tests {
}
app.tabs[i].current_path = Some(std::env::temp_dir().join("ocs_repro_host.dwg"));
app.refresh_xref_manager();
let idx = app.xref_manager.entries.iter().position(|e| e.name == "PLAN").expect("PLAN listed");
let plan = app.xref_manager.entries.iter().find(|e| e.name == "PLAN").expect("PLAN listed").clone();
let plan_id = XrefRowId::of(plan.key, &plan.saved_path);
let start = app.command_line.history.len();
let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Detach));
let _ = app.update(Message::XrefRowOp(plan_id, XrefPaletteOp::Detach));
let out = palette_output(&app, start);
assert!(out.contains("PLAN"), "detach output missing, got: {out:?}");
assert!(app.tabs[i].scene.document.block_records.get("PLAN").is_none(), "PLAN definition must be gone");
let idx = app.xref_manager.entries.iter().position(|e| e.name == "SITE").expect("SITE listed");
let site = app.xref_manager.entries.iter().find(|e| e.name == "SITE").expect("SITE listed").clone();
let site_id = XrefRowId::of(site.key, &site.saved_path);
let start = app.command_line.history.len();
let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Unload));
let _ = app.update(Message::XrefRowOp(site_id, XrefPaletteOp::Unload));
let out = palette_output(&app, start);
assert!(out.contains("SITE"), "unload output missing, got: {out:?}");
assert_eq!(
@@ -1906,7 +1938,7 @@ mod tests {
use codec::tables::BlockRecord;
use crate::app::Message;
use crate::io::xref_model::{Pathtype, RefType};
use crate::ui::window::xref_manager::XrefPaletteOp;
use crate::ui::window::xref_manager::{XrefPaletteOp, XrefRowId};
let mut app = fresh();
let i = app.active_tab;
let mut br = BlockRecord::new("PLAN");
@@ -1916,16 +1948,17 @@ mod tests {
app.tabs[i].scene.document.block_records.add(br).unwrap();
app.tabs[i].current_path = Some(std::env::temp_dir().join("ocs_rowop_host.dwg"));
app.refresh_xref_manager();
let idx = app.xref_manager.entries.iter().position(|e| e.name == "PLAN").expect("PLAN listed");
let plan = app.xref_manager.entries.iter().find(|e| e.name == "PLAN").expect("PLAN listed").clone();
let plan_id = XrefRowId::of(plan.key, &plan.saved_path);
let start = app.command_line.history.len();
let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Overlay));
let _ = app.update(Message::XrefRowOp(plan_id.clone(), XrefPaletteOp::Overlay));
let out = palette_output(&app, start);
assert!(out.contains("PLAN"), "got: {out:?}");
let br = app.tabs[i].scene.document.block_records.get("PLAN").unwrap();
assert!(br.flags.is_xref_overlay && !br.flags.is_xref);
assert_eq!(app.xref_manager.entries.iter().find(|e| e.name == "PLAN").unwrap().ref_type, RefType::Overlay);
let start = app.command_line.history.len();
let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Pathtype(Pathtype::None)));
let _ = app.update(Message::XrefRowOp(plan_id, XrefPaletteOp::Pathtype(Pathtype::None)));
let out = palette_output(&app, start);
assert!(out.contains("PLAN"), "got: {out:?}");
let br = app.tabs[i].scene.document.block_records.get("PLAN").unwrap();
+43 -36
View File
@@ -2613,7 +2613,7 @@ impl OpenCADStudio {
self.refresh_xref_manager();
Task::none()
}
Message::XrefManagerSelect(index) => {
Message::XrefManagerSelect(id) => {
use crate::ui::window::xref_manager::SelectExtend;
// Normal GUI list behavior off the globally tracked
// modifiers (same source as the layer list): plain click
@@ -2625,12 +2625,12 @@ impl OpenCADStudio {
} else {
SelectExtend::Single
};
self.xref_manager.click_select(index, extend);
self.xref_manager.click_select(&id, extend);
Task::none()
}
Message::XrefRowRightClick(index) => {
Message::XrefRowRightClick(id) => {
self.xref_manager.row_change_path_open = false;
self.xref_manager.right_click_select(index);
self.xref_manager.right_click_select(&id);
Task::none()
}
Message::XrefManagerToggleTree => {
@@ -2775,62 +2775,69 @@ impl OpenCADStudio {
self.xref_manager_op(op);
Task::none()
}
Message::XrefRowOp(index, op) => {
Message::XrefRowOp(id, op) => {
// Open is not a palette op — it navigates to the file.
if op == crate::ui::window::xref_manager::XrefPaletteOp::Open {
self.xref_manager.right_click_select(index);
self.xref_manager.right_click_select(&id);
self.xref_manager.row_change_path_open = false;
if let Some(entry) = self.xref_manager.entries.get(index) {
if let Some(found) = entry.found_at.clone() {
let is_dwg = found.to_ascii_lowercase().ends_with(".dwg")
|| found.to_ascii_lowercase().ends_with(".dxf");
self.command_line.push_output(crate::tf!("XOPEN: opening \"{}\".", found).as_ref());
if is_dwg {
return self.update(Message::OpenRecent(std::path::PathBuf::from(found)));
if let Some(index) = self.xref_manager.row_index(&id) {
if let Some(entry) = self.xref_manager.entries.get(index) {
if let Some(found) = entry.found_at.clone() {
let is_dwg = found.to_ascii_lowercase().ends_with(".dwg")
|| found.to_ascii_lowercase().ends_with(".dxf");
self.command_line.push_output(crate::tf!("XOPEN: opening \"{}\".", found).as_ref());
if is_dwg {
return self.update(Message::OpenRecent(std::path::PathBuf::from(found)));
} else {
#[cfg(not(target_arch = "wasm32"))]
let _ = open::that_detached(&found);
}
} else {
#[cfg(not(target_arch = "wasm32"))]
let _ = open::that_detached(&found);
self.command_line.push_error(
crate::t!("File not found — check the saved path.").as_ref(),
);
}
} else {
self.command_line.push_error(
crate::t!("File not found — check the saved path.").as_ref(),
);
}
}
return Task::none();
}
// Attach prompts the file dialog to pick a drawing to attach.
if matches!(op, crate::ui::window::xref_manager::XrefPaletteOp::Attach) {
self.xref_manager.right_click_select(index);
self.xref_manager.right_click_select(&id);
self.xref_manager.row_change_path_open = false;
self.command_line.push_output(crate::t!("XATTACH").as_ref());
return self.update(Message::XAttachPick);
}
// Row-scoped op: select the row first, then run the op.
self.xref_manager.right_click_select(index);
self.xref_manager.right_click_select(&id);
self.xref_manager.row_change_path_open = false;
self.xref_manager_op(op);
Task::none()
}
Message::XrefRowPathPick(index) => {
self.xref_manager.right_click_select(index);
Message::XrefRowPathPick(id) => {
self.xref_manager.right_click_select(&id);
self.xref_manager.row_change_path_open = false;
self.update(Message::XrefPathPick)
}
Message::XrefRowFindReplacePrompt(index) => {
self.xref_manager.right_click_select(index);
Message::XrefRowFindReplacePrompt(id) => {
self.xref_manager.right_click_select(&id);
self.xref_manager.row_change_path_open = false;
let prefill = if let Some(entry) = self.xref_manager.entries.get(index) {
let saved = &entry.saved_path;
if let Some(parent) = std::path::Path::new(saved).parent().and_then(|p| p.to_str()) {
if !parent.is_empty() {
format!("XREF Path Find \"{}\" ", parent)
} else {
"XREF Path Find ".to_string()
}
} else {
"XREF Path Find ".to_string()
}
let prefill = if let Some(index) = self.xref_manager.row_index(&id) {
self.xref_manager.entries.get(index).map_or_else(
|| "XREF Path Find ".to_string(),
|entry| {
let saved = &entry.saved_path;
if let Some(parent) = std::path::Path::new(saved).parent().and_then(|p| p.to_str()) {
if !parent.is_empty() {
format!("XREF Path Find \"{}\" ", parent)
} else {
"XREF Path Find ".to_string()
}
} else {
"XREF Path Find ".to_string()
}
},
)
} else {
"XREF Path Find ".to_string()
};