Integrate typed port authoring and reviewable exchange workflows

This commit is contained in:
2026-09-05 20:00:21 +03:00
parent 2dd17aa693
commit fa096cefd3
21 changed files with 6449 additions and 2154 deletions
+199 -29
View File
@@ -5,6 +5,7 @@ use serde_json::{Value, json};
use std::{collections::BTreeSet, path::PathBuf, sync::Arc};
type FileSnapshot = (PathBuf, Project, Arc<[u8]>);
type LoadedSnapshot = (PathBuf, persistence::DecodeResult, Arc<[u8]>);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Page {
@@ -64,6 +65,7 @@ pub enum Message {
CreateKind(Kind),
Add(bool),
AddRelation,
PortAction(crate::port_workflow::Action),
AddRelationKind(RelationKind, bool),
InspectorTab(crate::navigation::InspectorTab),
RevealInDiagram(Id, Id),
@@ -107,7 +109,7 @@ pub enum Message {
SourceNavigation(crate::source_navigation::Event),
Save(bool),
Saved(Result<Option<FileSnapshot>, String>),
Loaded(Result<Option<FileSnapshot>, String>),
Loaded(Result<Option<LoadedSnapshot>, String>),
Request(Pending),
Discard,
Cancel,
@@ -157,6 +159,7 @@ pub struct App {
pub source_jump: Option<text_editor::Cursor>,
pub source_navigation: crate::source_navigation::State,
pub model_issues: Vec<validation::Issue>,
pub migration: Option<persistence::MigrationReport>,
source_past: Vec<String>,
source_future: Vec<String>,
source_version: u64,
@@ -176,10 +179,19 @@ impl App {
let source = language::format(&project);
let model_issues = validate(&project);
let selected = project.id;
let populated = |layer| {
project.elements.values().any(|e| e.layer() == layer)
|| project.diagrams.values().any(|d| d.layer == layer)
};
let layer = if !populated(Layer::Oa) && populated(Layer::Sa) {
Layer::Sa
} else {
Layer::Oa
};
let active_diagram = project
.diagrams
.values()
.filter(|d| d.layer == Layer::Oa)
.filter(|d| d.layer == layer)
.min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id))
.map(|d| d.id);
let draft = Draft::from_project(&project, selected);
@@ -190,13 +202,17 @@ impl App {
guide: crate::guide::State::default(),
behavior: Default::default(),
inspector_tab: Default::default(),
layer: Layer::Oa,
layer,
selected,
selected_node: None,
draft,
active_diagram,
search: String::new(),
create_kind: Kind::Activity,
create_kind: if layer == Layer::Sa {
Kind::Function
} else {
Kind::Activity
},
status: "Coastal rescue example · explore both engineering perspectives".into(),
busy: false,
pending: None,
@@ -217,6 +233,7 @@ impl App {
source_jump: None,
source_navigation: crate::source_navigation::State::default(),
model_issues,
migration: None,
source_past: vec![],
source_future: vec![],
source_version: 0,
@@ -316,9 +333,12 @@ impl App {
if !self.source_dirty() {
return true;
}
match language::parse(&self.source_editor.text()) {
Ok(project) => match self.session.execute(Command::Replace(project)) {
match language::parse_with_report(&self.source_editor.text()) {
Ok(decoded) => match self.session.execute(Command::Replace(decoded.project)) {
Ok(_) => {
if decoded.migration.is_some() {
self.migration = decoded.migration;
}
self.sync();
self.status = "Text applied as one model change".into();
true
@@ -423,22 +443,7 @@ impl App {
self.fit();
}
fn diagram_for(&self, layer: Layer, element: Id) -> Option<Id> {
self.project()
.diagrams
.values()
.filter(|d| d.layer == layer)
.min_by_key(|d| {
(
!self
.project()
.elements
.get(&element)
.is_none_or(|e| d.projection.includes(e.kind())),
!d.nodes.values().any(|n| n.element == element),
d.projection != ProjectionKind::Allocation,
)
})
.map(|d| d.id)
preferred_diagram(self.project(), self.active_diagram, layer, element)
}
fn select(&mut self, id: Id) {
if self.selected == id {
@@ -714,13 +719,14 @@ impl App {
}
if let Some(mut workflow) = self.workflow.take() {
let transition_focus = match &workflow {
crate::workflow::Workflow::Ports(state) => Some(state.focus()),
crate::workflow::Workflow::Transition(t) => {
t.plan.changes.first().map(|c| c.target)
}
_ => None,
};
match workflow.update(self.project(), event) {
Ok(Some(candidate)) => {
Ok(Some(mut candidate)) => {
let focus = transition_focus
.or_else(|| {
candidate
@@ -736,12 +742,21 @@ impl App {
.find(|id| !self.project().traces.contains_key(id))
.copied()
});
let destination = focus.and_then(|id| {
candidate.layer_of(id).and_then(|layer| {
preferred_diagram(&candidate, self.active_diagram, layer, id)
})
});
if let (Some(id), Some(diagram)) = (focus, destination) {
reveal_port_owner(&mut candidate, diagram, id);
}
if self.execute(Command::Replace(candidate)) {
if let Some(id) = focus {
self.selected = id;
if let Some(layer) = self.project().layer_of(id) {
self.layer = layer;
self.active_diagram = self.diagram_for(layer, id);
self.active_diagram =
destination.or_else(|| self.diagram_for(layer, id));
}
self.refresh_draft();
}
@@ -837,6 +852,16 @@ impl App {
self.add_relation();
}
}
Message::PortAction(action) => {
if self.apply() {
match crate::port_workflow::State::new(self.project(), self.selected, action) {
Ok(state) => {
self.workflow = Some(crate::workflow::Workflow::Ports(Box::new(state)))
}
Err(error) => self.status = error,
}
}
}
Message::AddRelationKind(kind, incoming) => {
if self.apply() {
self.workflow = Some(crate::workflow::Workflow::relationship_kind(
@@ -1363,8 +1388,13 @@ impl App {
Message::Loaded(result) => {
self.busy = false;
match result {
Ok(Some((path, p, bytes))) => {
self.reset(p, Some(path));
Ok(Some((path, decoded, bytes))) => {
self.reset(decoded.project, Some(path));
self.migration = decoded.migration;
if self.migration.is_some() {
self.status = "Imported earlier schema · review migration details in Model checks before saving".into();
self.page = Page::Validation;
}
self.file_snapshot = Some(bytes);
}
Ok(None) => self.status = "Open cancelled".into(),
@@ -1427,6 +1457,10 @@ impl App {
Task::none()
}
fn add_element(&mut self, child: bool) {
if self.create_kind == Kind::Port {
self.status = "Select an SA function or participant, then choose a typed port action in Properties.".into();
return;
}
let allocated = child
&& self.create_kind.is_work()
&& self
@@ -1848,10 +1882,146 @@ impl App {
}
}
fn read_snapshot(path: PathBuf) -> Result<FileSnapshot, String> {
fn preferred_diagram(p: &Project, active: Option<Id>, layer: Layer, element: Id) -> Option<Id> {
if let Some(owner) = p
.elements
.get(&element)
.filter(|e| e.kind() == Kind::Port)
.and_then(|e| e.parent)
{
return p
.diagrams
.values()
.filter(|d| d.layer == layer)
.min_by_key(|d| {
let view = project_view(p, d.id);
(
!d.projection.includes(p.elements[&owner].kind()),
!view.ports.iter().any(|port| port.element == element),
!d.nodes.values().any(|node| node.element == owner),
active != Some(d.id),
)
})
.map(|d| d.id);
}
p.diagrams
.values()
.filter(|d| d.layer == layer)
.min_by_key(|d| {
(
!p.elements
.get(&element)
.is_none_or(|e| d.projection.includes(e.kind())),
!d.nodes.values().any(|n| n.element == element),
d.projection != ProjectionKind::Allocation,
)
})
.map(|d| d.id)
}
fn reveal_port_owner(p: &mut Project, diagram: Id, element: Id) {
let Some(owner) = p
.elements
.get(&element)
.filter(|e| e.kind() == Kind::Port)
.and_then(|e| e.parent)
else {
return;
};
let Some(owner_node) = p.diagrams[&diagram]
.nodes
.values()
.find(|n| n.element == owner)
.map(|n| n.id)
else {
return;
};
let unfold: Vec<_> = p.diagrams[&diagram]
.nodes
.values()
.filter(|n| {
n.collapsed && projection::descendant_nodes(p, diagram, n.id).contains(&owner_node)
})
.map(|n| n.id)
.collect();
for id in unfold {
p.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&id)
.unwrap()
.collapsed = false;
}
}
fn read_snapshot(path: PathBuf) -> Result<LoadedSnapshot, String> {
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
let project = persistence::decode(&bytes).map_err(|e| e.to_string())?;
Ok((path, project, Arc::from(bytes)))
let decoded = persistence::decode_with_report(&bytes).map_err(|e| e.to_string())?;
Ok((path, decoded, Arc::from(bytes)))
}
#[cfg(test)]
mod port_view_tests {
use super::*;
#[test]
fn filtered_active_view_cannot_hide_a_created_port_when_eligible_owner_view_is_folded() {
let mut p = Project::empty();
let system = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let function = p
.add_element(Kind::Function, Layer::Sa, "Function", None)
.unwrap();
p.relate(RelationKind::Allocation, "Performs", system, function);
let architecture = p.ensure_diagram(Layer::Sa);
p.diagrams.get_mut(&architecture).unwrap().projection = ProjectionKind::Allocation;
let container = p.place(architecture, system, Position { x: 40., y: 40. });
p.place(architecture, function, Position { x: 70., y: 130. });
let capabilities = Id::new_v4();
p.diagrams.insert(
capabilities,
Diagram {
id: capabilities,
alias: "filtered_view".into(),
name: "Filtered view".into(),
description: String::new(),
layer: Layer::Sa,
projection: ProjectionKind::Capabilities,
nodes: Default::default(),
},
);
p.place(capabilities, system, Position { x: 40., y: 40. });
p.place(capabilities, function, Position { x: 70., y: 130. });
p.diagrams
.get_mut(&architecture)
.unwrap()
.nodes
.get_mut(&container)
.unwrap()
.collapsed = true;
let mut app = App::with_project(p.clone());
let _ = app.update(Message::Select(function));
app.active_diagram = Some(capabilities);
app.refresh_draft();
let _ = app.update(Message::PortAction(crate::port_workflow::Action::Output));
let _ = app.update(Message::Workflow(crate::workflow::Event::Apply));
assert!(app.workflow.is_none(), "{}", app.status);
assert_eq!(app.active_diagram, Some(architecture));
assert!(
project_view(app.project(), architecture)
.ports
.iter()
.any(|port| port.element == app.selected)
);
assert!(!app.project().diagrams[&architecture].nodes[&container].collapsed);
let _ = app.update(Message::Undo);
assert_eq!(
app.project(),
&p,
"Creation and the eligible owner reveal remain one transaction"
);
}
}
#[cfg(test)]