//! Read-only semantic navigation derived from the core's reference visitors. use crate::{ app::Message, style, views::{action, label, short}, }; use iced::{ Element, Fill, widget::{column, container, text}, }; use oa_core::{ references::{element_references, relation_references}, *, }; use std::collections::BTreeSet; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum InspectorTab { #[default] Properties, Connections, } struct Group { title: &'static str, ids: BTreeSet, } fn groups(p: &Project, selected: Id) -> Vec { let parent = p.elements.get(&selected).and_then(|e| e.parent); let children = p.children(selected).map(|e| e.id).collect::>(); let mut direct = p .elements .get(&selected) .map(element_references) .or_else(|| p.relations.get(&selected).map(relation_references)) .unwrap_or_default() .into_iter() .collect::>(); direct.retain(|id| Some(*id) != parent && !children.contains(id)); let outgoing = p .relations .values() .filter(|r| r.source() == selected) .map(|r| r.id) .collect(); let incoming = p .relations .values() .filter(|r| r.target() == selected) .map(|r| r.id) .collect(); let users = p .elements .values() .filter(|e| { e.id != selected && Some(e.id) != parent && e.parent != Some(selected) && element_references(e).contains(&selected) }) .map(|e| e.id) .chain( p.relations .values() .filter(|r| { r.source() != selected && r.target() != selected && relation_references(r).contains(&selected) }) .map(|r| r.id), ) .collect(); let counterparts = p .traces .values() .filter_map(|t| { if t.oa == selected { Some(t.sa) } else if t.sa == selected { Some(t.oa) } else { None } }) .collect(); let artifacts = p .artifact_links .values() .filter(|a| a.owner == selected) .map(|a| a.id) .collect(); vec![ Group { title: "OWNED BY", ids: parent.into_iter().collect(), }, Group { title: "CONTAINS", ids: children, }, Group { title: "REFERENCES", ids: direct, }, Group { title: "OUTGOING RELATIONSHIPS", ids: outgoing, }, Group { title: "INCOMING RELATIONSHIPS", ids: incoming, }, Group { title: "USED BY BEHAVIOR & CONTRACTS", ids: users, }, Group { title: "COUNTERPARTS", ids: counterparts, }, Group { title: "ENGINEERING ARTIFACTS", ids: artifacts, }, ] } pub fn diagrams_containing(p: &Project, selected: Id) -> Vec { let endpoints = p.relations.get(&selected).map(|r| [r.source(), r.target()]); p.diagrams .values() .filter(|d| { let placed = |id| { let e = p.elements.get(&id); let owner = e .filter(|e| e.kind() == Kind::Port) .and_then(|e| e.parent) .unwrap_or(id); let kind = p.elements.get(&owner).map(|e| e.kind()); kind.is_none_or(|kind| d.projection.includes(kind)) && d.nodes.values().any(|n| n.element == owner || n.id == id) }; placed(selected) || endpoints.is_some_and(|ends| ends.into_iter().all(placed)) }) .map(|d| d.id) .collect() } pub fn view(p: &Project, selected: Id) -> Element<'_, Message> { let mut content = column![ text(short(p.named(selected), 50)).size(18), text("Follow ownership, usage and engineering relationships.") .size(12) .color(style::MUTED) ] .spacing(12); let mut count = 0; for group in groups(p, selected) { if group.ids.is_empty() { continue; } count += group.ids.len(); let mut entries = group.ids.into_iter().collect::>(); entries.sort_by_key(|id| p.named(*id).to_lowercase()); content = content.push(label(group.title)); for id in entries { let detail = p .elements .get(&id) .map(|e| format!("{} · {}", e.kind(), e.layer())) .or_else(|| { p.relations.get(&id).map(|r| { format!( "{} · {} → {}", r.kind(), p.named(r.source()), p.named(r.target()) ) }) }) .or_else(|| p.artifact_links.get(&id).map(|a| a.uri.clone())) .unwrap_or_default(); let mut entry = column![ action(&short(p.named(id), 42), Message::Select(id)).width(Fill), text(detail).size(11).color(style::MUTED) ] .spacing(3); if let Some(r) = p.relations.get(&id) { let other = if r.source() == selected { r.target() } else { r.source() }; entry = entry.push( action( &format!("Open {}", short(p.named(other), 32)), Message::Select(other), ) .width(Fill), ); } content = content.push(container(entry).padding(8).style(style::subtle)); } } let diagrams = diagrams_containing(p, selected); if !diagrams.is_empty() { content = content.push(label("SHOWN IN DIAGRAMS")); for id in diagrams { content = content.push( action( &short(p.named(id), 42), Message::RevealInDiagram(id, selected), ) .width(Fill), ); } } else if p.elements.contains_key(&selected) { content = content.push( text("This element has no diagram placement yet.") .size(12) .color(style::MUTED), ); } if count == 0 { content = content.push(text("No semantic connections yet. Use Actions or right-click an element to add relationships.").size(12).color(style::MUTED)); } content.into() } #[cfg(test)] mod tests { use super::*; #[test] fn semantic_browser_finds_behavior_users_without_confusing_containment() { let mut p = Project::empty(); let work = p .add_element(Kind::Activity, Layer::Oa, "Respond", None) .unwrap(); let process = p .add_element(Kind::Process, Layer::Oa, "Response route", None) .unwrap(); let occurrence = p .add_element(Kind::Occurrence, Layer::Oa, "Respond here", Some(process)) .unwrap(); *p.elements .get_mut(&occurrence) .unwrap() .shared_mut() .unwrap() = SharedBody::Occurrence { body: OccurrenceBody::Action { action: Some(work), lifeline: None, }, precondition: String::new(), postcondition: String::new(), }; let result = groups(&p, work); assert!( result .iter() .find(|g| g.title == "USED BY BEHAVIOR & CONTRACTS") .unwrap() .ids .contains(&occurrence) ); let result = groups(&p, occurrence); assert!( result .iter() .find(|g| g.title == "OWNED BY") .unwrap() .ids .contains(&process) ); assert!( result .iter() .find(|g| g.title == "REFERENCES") .unwrap() .ids .contains(&work) ); assert!( !result .iter() .find(|g| g.title == "REFERENCES") .unwrap() .ids .contains(&process) ); } }