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
+167 -28
View File
@@ -169,33 +169,11 @@ impl Relationship {
let Some(source) = self.source.as_ref().map(|source| source.id) else {
return false;
};
let directed = validation::can_connect(p, self.kind, source, target);
if self.kind != RelationKind::BoundaryExchange || self.oriented || directed {
return directed;
if self.kind == RelationKind::BoundaryExchange {
validation::can_connect_boundary(p, source, target, self.oriented)
} else {
validation::can_connect(p, self.kind, source, target)
}
if ![source, target].iter().all(|id| {
p.elements
.get(id)
.is_some_and(|element| element.kind() == Kind::Port)
}) {
return false;
}
// An unoriented connection may use the reverse stored endpoint order.
// Ask the exact core rules, including preserved Legacy restrictions;
// reversing can_connect alone would incorrectly relax those rules.
let candidate = Relation {
id: self.id,
alias: p.fresh_alias("connection", "relation", self.id),
name: "Connection".into(),
description: String::new(),
body: RelationBody::BoundaryExchange {
source,
target,
oriented: false,
items: vec![],
},
};
execute_batch(p, &[Command::CreateRelation(candidate)]).is_ok()
}
fn engineering_name(&self, p: &Project, source: Id, target: Id) -> String {
@@ -679,7 +657,15 @@ impl Workflow {
_ => ("Source", "Target"),
};
content = content
.push(text(d.kind.hint()).size(13).color(style::MUTED))
.push(
text(if d.kind == RelationKind::BoundaryExchange {
"Component port ↔ component port"
} else {
d.kind.hint()
})
.size(13)
.color(style::MUTED),
)
.push(
pick_list(kinds, Some(d.kind), |v| {
Message::Workflow(Event::RelationKind(v))
@@ -709,7 +695,15 @@ impl Workflow {
.width(Fill),
);
if d.source.is_some() && d.targets.is_empty() {
content = content.push(text("No compatible target yet. Create the needed model element, then connect it here.").size(12).color(style::MUTED));
content = content.push(
text(if d.kind == RelationKind::BoundaryExchange {
"No compatible component port on another participant. Select the other system or actor, add a port of the same kind (Standard or Flow), then connect it here. Flow ports also need compatible input/output orientations."
} else {
"No compatible target yet. Create the needed model element, then connect it here."
})
.size(12)
.color(style::MUTED),
);
}
if d.kind == RelationKind::CapabilityExtend {
content = content.push(text("Extension condition").size(12)).push(
@@ -1164,6 +1158,151 @@ fn choice(p: &Project, id: Id) -> Choice {
mod port_authoring_tests {
use super::*;
#[test]
fn boundary_picker_matches_exact_commands_across_contracts_and_ownership() {
let mut p = Project::empty();
let system = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let actor = p
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
.unwrap();
let function = p
.add_element(Kind::Function, Layer::Sa, "Internal work", None)
.unwrap();
let mut endpoints = vec![];
let mut standard = vec![];
for owner in [system, actor] {
for orientation in [
ComponentPortOrientation::Unset,
ComponentPortOrientation::In,
ComponentPortOrientation::Out,
ComponentPortOrientation::InOut,
] {
let id = Id::new_v4();
let port = make_port(
&p,
id,
owner,
"Flow",
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation,
},
)
.unwrap();
p.elements.insert(id, port);
endpoints.push(id);
}
let id = Id::new_v4();
let port = make_port(
&p,
id,
owner,
"Standard",
PortContract::Component {
kind: ComponentPortKind::Standard,
orientation: ComponentPortOrientation::Unset,
},
)
.unwrap();
p.elements.insert(id, port);
endpoints.push(id);
standard.push(id);
for direction in [PortDirection::In, PortDirection::Out, PortDirection::InOut] {
let id = Id::new_v4();
let mut port = make_port(&p, id, owner, "Legacy", PortContract::Legacy).unwrap();
if let Some(SharedBody::Port { legacy, .. }) = port.shared_mut() {
*legacy = Some(LegacyPortContract {
direction,
interface: None,
});
}
p.elements.insert(id, port);
endpoints.push(id);
}
}
for contract in [
PortContract::FunctionInput {
incoming_items: vec![],
},
PortContract::FunctionOutput {
outgoing_items: vec![],
},
] {
let id = Id::new_v4();
let port = make_port(&p, id, function, "Unrelated function port", contract).unwrap();
p.elements.insert(id, port);
endpoints.push(id);
}
let interface = p
.add_element(Kind::Interface, Layer::Sa, "Telemetry", None)
.unwrap();
p.relate(
RelationKind::InterfaceRequirement,
"Requires telemetry",
standard[0],
interface,
);
p.relate(
RelationKind::InterfaceProvision,
"Provides telemetry",
standard[1],
interface,
);
let missing_contract = Id::new_v4();
let port = make_port(
&p,
missing_contract,
actor,
"Missing provision",
PortContract::Component {
kind: ComponentPortKind::Standard,
orientation: ComponentPortOrientation::Unset,
},
)
.unwrap();
p.elements.insert(missing_contract, port);
endpoints.extend([missing_contract, system, function, Id::new_v4()]);
assert!(execute_batch(&p, &[]).is_ok());
for source in &endpoints {
let Workflow::Relation(mut draft) = Workflow::relationship_kind(
&p,
*source,
Layer::Sa,
RelationKind::BoundaryExchange,
false,
) else {
unreachable!()
};
// Include invalid sources as well as those shown by the UI.
draft.source = Some(choice(&p, *source));
for oriented in [false, true] {
draft.oriented = oriented;
for target in &endpoints {
let exact = Relation {
id: Id::new_v4(),
alias: "boundary_probe".into(),
name: "Boundary probe".into(),
description: String::new(),
body: RelationBody::BoundaryExchange {
source: *source,
target: *target,
oriented,
items: vec![],
},
};
let accepted = execute_batch(&p, &[Command::CreateRelation(exact)]).is_ok();
assert_eq!(
draft.accepts_target(&p, *target),
accepted,
"source {source}, target {target}, oriented {oriented}"
);
}
}
}
}
#[test]
fn generic_component_port_connection_keeps_its_endpoint_and_direction() {
for (kind, orientation) in [