Make participant ports and hierarchical model navigation discoverable

Add focused containment browsing, contextual paged search, clear outline depth and direct component-port creation and connection actions. Share exact boundary feasibility checks to avoid whole-project validation for each picker candidate. Preserve source/property drafts and interrupted-save recovery state, document source-project behavior, and record Windows/Linux native verification and independent review fixes.
This commit is contained in:
2026-09-05 23:17:17 +03:00
parent 60131e610a
commit d364d3c058
16 changed files with 1829 additions and 324 deletions
+119 -166
View File
@@ -40,6 +40,22 @@ pub fn heading<'a>(title: &str, subtitle: &str) -> View<'a, Message> {
.into()
}
fn port_role(element: &oa_core::Element) -> String {
match port_contract(element) {
Some(PortContract::FunctionInput { .. }) => "Input".into(),
Some(PortContract::FunctionOutput { .. }) => "Output".into(),
Some(PortContract::Component {
kind: ComponentPortKind::Standard,
..
}) => "Standard".into(),
Some(PortContract::Component {
kind: ComponentPortKind::Flow,
orientation,
}) => format!("Flow · {orientation:?}"),
_ => "Legacy".into(),
}
}
pub fn view(app: &App) -> View<'_, Message> {
let p = app.project();
let header = container(
@@ -145,6 +161,16 @@ pub fn view(app: &App) -> View<'_, Message> {
&& p.elements
.get(&app.selected)
.is_some_and(|e| e.layer() == app.layer && e.kind().is_performer());
if crate::port_workflow::actions(p, app.selected)
.contains(&crate::port_workflow::Action::Component)
{
creation = creation.push(
button(text("+ Add port to selected…").size(12))
.padding(9)
.width(Fill)
.on_press(Message::PortAction(crate::port_workflow::Action::Component)),
);
}
creation = creation.push(
row![
button(text("+ At root").size(11))
@@ -180,76 +206,15 @@ pub fn view(app: &App) -> View<'_, Message> {
.size(12)
.padding(9),
);
let query = app.search.to_lowercase();
let mut elements = p
.elements
.values()
.filter(|e| {
e.layer() == app.layer
&& (query.is_empty()
|| e.name.to_lowercase().contains(&query)
|| e.alias
.to_lowercase()
.contains(query.trim_start_matches('@')))
})
.collect::<Vec<_>>();
elements.sort_by_key(|e| (tree_path(p, e.id), e.alias.clone()));
let mut explorer = column![].spacing(2);
if elements.is_empty() {
explorer = explorer.push(label(if query.is_empty() {
"No elements in this perspective. Create your first element below."
} else {
"No matching elements in this perspective."
}));
}
for e in elements {
if query.is_empty() && tree_hidden(app, e.id) {
continue;
}
let depth = ancestors(p, e.id).min(5);
let children = p.children(e.id).next().is_some();
explorer = explorer.push(row![
button(
text(if children {
if app.explorer_collapsed.contains(&e.id) {
"›"
} else {
"⌄"
}
} else {
"·"
})
.size(14)
.color(style::kind_color(e.kind()))
)
.style(style::nav)
.padding([6, 3])
.on_press_maybe(children.then_some(Message::CollapseExplorer(e.id))),
button(column![text(short(&e.name, 29)).size(11), label(e.kind().label())].spacing(2))
.padding(iced::Padding {
left: 3. + depth as f32 * 8.,
right: 6.,
top: 6.,
bottom: 6.
})
.width(Fill)
.on_press(Message::Select(e.id))
.style(if app.selected == e.id {
style::selected
} else {
style::nav
})
]);
}
nav = nav
.push(
scrollable(explorer.padding(iced::Padding {
right: 10.,
..Default::default()
}))
.id("model-explorer")
.height(Fill),
row![
action("Collapse all", Message::ExplorerCollapseAll),
action("Locate", Message::ExplorerLocate)
]
.spacing(3),
)
.push(crate::explorer_view::sidebar(app))
.push(creation)
.push(action("Project properties", Message::SelectProject).width(Fill));
let sidebar = container(if app.page == Page::Text {
@@ -258,12 +223,13 @@ pub fn view(app: &App) -> View<'_, Message> {
nav.into()
})
.padding(14)
.width(230)
.width(260)
.height(Fill)
.style(style::panel);
let main = match app.page {
Page::Diagram => diagram(app),
Page::Catalog | Page::Data => catalog(app),
Page::Catalog => crate::explorer_view::catalog(app),
Page::Data => data_catalog(app),
Page::Behavior => crate::behavior::view(app),
Page::Traces => traces(app),
Page::Text => source(app),
@@ -361,49 +327,6 @@ fn modal<'a>(base: View<'a, Message>, content: View<'a, Message>) -> View<'a, Me
]
.into()
}
fn ancestors(p: &Project, id: Id) -> usize {
let mut current = p.elements.get(&id).and_then(|e| e.parent);
let mut depth = 0;
while let Some(id) = current {
depth += 1;
if depth > 64 {
break;
}
current = p.elements.get(&id).and_then(|e| e.parent);
}
depth
}
fn tree_hidden(app: &App, id: Id) -> bool {
let mut current = app.project().elements.get(&id).and_then(|e| e.parent);
let mut seen = std::collections::BTreeSet::new();
while let Some(id) = current {
if !seen.insert(id) {
return true;
}
if app.explorer_collapsed.contains(&id) {
return true;
}
current = app.project().elements.get(&id).and_then(|e| e.parent);
}
false
}
fn tree_path(p: &Project, id: Id) -> String {
let mut chain = vec![];
let mut current = Some(id);
while let Some(id) = current {
if chain.len() > 64 {
break;
}
let Some(e) = p.elements.get(&id) else {
break;
};
chain.push(e.name.clone());
current = e.parent;
}
chain.reverse();
chain.join("/")
}
fn diagram(app: &App) -> View<'_, Message> {
let diagrams = app
.project()
@@ -566,6 +489,27 @@ fn diagram(app: &App) -> View<'_, Message> {
fn inspector(app: &App) -> View<'_, Message> {
let p = app.project();
if app.source_dirty() {
let mut notice = column![
text("Source drafts pending").size(17),
label("Properties become editable after source drafts are applied or reverted."),
action("Open source drafts", Message::Page(Page::Text))
]
.spacing(12);
if app.draft_dirty() {
notice = notice
.push(label(
"A property draft is also retained. Revert it to apply the source drafts.",
))
.push(action("Revert property draft", Message::RevertProperties));
}
return container(notice)
.padding(14)
.width(320)
.height(Fill)
.style(style::panel)
.into();
}
let tabs = row![
action(
"Properties",
@@ -709,17 +653,66 @@ fn inspector(app: &App) -> View<'_, Message> {
let kind = p.elements.get(&app.selected).map(Element::kind);
let port_actions = crate::port_workflow::actions(p, app.selected);
if !port_actions.is_empty() {
body = body.push(label("PORTS & EXCHANGES"));
for port_action in port_actions {
use crate::port_workflow::Action;
if matches!(port_action, Action::Flow | Action::Standard) {
continue;
}
let caption = match port_action {
Action::Component => "+ Add port…",
Action::Input => "+ Input port…",
Action::Output => "+ Output port…",
_ => port_action.label(),
};
body = body.push(action(caption, Message::PortAction(port_action)).width(Fill));
}
if matches!(kind, Some(Kind::System | Kind::SystemActor)) {
body = body.push(label(
"Ports belong to this participant and connect boundary exchanges.",
));
}
}
let ports: Vec<_> = p
.children(app.selected)
.filter(|e| e.kind() == Kind::Port)
.collect();
if !ports.is_empty() {
body = body.push(label(format!("OWNED PORTS · {}", ports.len())));
for port in ports {
body = body.push(
action(
&format!("{} · {}", port.name, port_role(port)),
Message::Select(port.id),
)
.width(Fill),
);
}
}
if p.elements
.get(&app.selected)
.is_some_and(|e| matches!(port_contract(e), Some(PortContract::Component { .. })))
{
body = body.push(
pick_list(
port_actions,
None::<crate::port_workflow::Action>,
Message::PortAction,
)
.placeholder("Port and exchange actions…")
.text_size(12)
.padding(9)
.width(Fill),
button(text("Connect boundary exchange…").size(12))
.padding(10)
.width(Fill)
.on_press(Message::AddRelationKind(
RelationKind::BoundaryExchange,
p.elements.get(&app.selected).is_some_and(|e| {
matches!(
port_contract(e),
Some(PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::In
})
)
}),
)),
);
body = body.push(label(
"Choose the other participant's port in the next step.",
));
}
let contextual: &[(&str, RelationKind, bool)] = match kind {
Some(Kind::OperationalMission | Kind::Mission) => &[
@@ -889,20 +882,11 @@ fn inspector(app: &App) -> View<'_, Message> {
.into()
}
fn catalog(app: &App) -> View<'_, Message> {
let data = app.page == Page::Data;
fn data_catalog(app: &App) -> View<'_, Message> {
let p = app.project();
let mut content = column![heading(
if data {
"Data & interfaces"
} else {
"Model explorer"
},
if data {
"Define information, material and energy at the system boundary."
} else {
"A shared model across every diagram, editor and automation."
}
"Data & interfaces",
"Define information, material and energy at the system boundary."
)]
.spacing(15);
for kind in Kind::ALL {
@@ -910,7 +894,7 @@ fn catalog(app: &App) -> View<'_, Message> {
kind,
Kind::ExchangeItem | Kind::DataType | Kind::DataField | Kind::Interface | Kind::Port
);
if data && !is_data {
if !is_data {
continue;
}
let elements = p
@@ -960,33 +944,6 @@ fn catalog(app: &App) -> View<'_, Message> {
);
}
}
if !data {
content = content.push(text("Relationships").size(18));
for r in p
.relations
.values()
.filter(|r| p.layer_of(r.id) == Some(app.layer))
{
content = content.push(
button(
column![
text(format!(
"{} → {}",
short(p.named(r.source()), 33),
short(p.named(r.target()), 33)
))
.size(12),
label(format!("{} · {}", r.kind(), r.name))
]
.spacing(5),
)
.padding(12)
.width(Fill)
.style(style::nav)
.on_press(Message::Select(r.id)),
);
}
}
scrollable(content.padding(24)).into()
}
@@ -1140,11 +1097,7 @@ fn source_files(app: &App) -> View<'_, Message> {
label("Includes define file membership. Closing a detached buffer never deletes its file."),
]
.spacing(9);
if app
.source_diagnostics
.iter()
.any(|issue| issue.diagnostic.code == "pending_save")
{
if app.pending_recovery.is_some() {
panel = panel.push(action("Recover interrupted save", Message::SourceRecover));
}
panel.into()