Compose transition lifecycle and preserve authored attachments in both directions

This commit is contained in:
2026-09-23 00:49:26 -07:00
parent a9b9c81eab
commit ffa7b16003
7 changed files with 1409 additions and 60 deletions
+88 -58
View File
@@ -13,6 +13,7 @@ use iced::{
use oa_core::{transitions::*, *};
use std::collections::BTreeSet;
mod communication;
mod counterparts;
pub(crate) mod recovery;
#[cfg(test)]
mod transition_tests;
@@ -34,6 +35,7 @@ pub enum Event {
Scope(String),
Destination(Choice),
MappingTarget(Id, Choice),
MappingQuery(Id, String),
MappingKind(Id, Kind),
CommunicationPort(Id, bool, communication::PortMenuChoice),
OperationOmission(Id, bool),
@@ -274,6 +276,7 @@ pub struct Transition {
pub request: TransitionRequest,
pub plan: TransitionPlan,
communication_ports: std::collections::BTreeMap<Id, communication::PortDraft>,
counterpart_queries: std::collections::BTreeMap<Id, String>,
}
impl Transition {
fn presentation(&self, p: &Project) -> (Project, Vec<Id>) {
@@ -516,6 +519,7 @@ impl Workflow {
request,
plan,
communication_ports: Default::default(),
counterpart_queries: Default::default(),
})))
}
/// Return a complete accepted candidate only on explicit Apply. Intermediate
@@ -620,6 +624,12 @@ impl Workflow {
_ => {}
},
Self::Transition(d) => {
if let Event::MappingQuery(source, query) = event {
d.counterpart_queries.insert(source, query);
// Searching is presentation only: preserve reviewed decisions,
// preview identities and the exact recovery snapshot.
return Ok(None);
}
match &event {
Event::OperationOmission(source, enabled) => {
d.request
@@ -1149,48 +1159,17 @@ impl Workflow {
content=content.push(text("The SA operation remains unchanged. Correlated replies derive this Call's abstraction.").size(11));
}
let mapping = d.request.mappings.iter().find(|m| m.source == source);
let existing = p
.traces
.values()
.filter_map(|t| {
if d.request.direction == TransitionDirection::OaToSa && t.oa == source
{
Some(t.sa)
} else if d.request.direction == TransitionDirection::SaToOa
&& t.sa == source
{
Some(t.oa)
} else {
None
}
})
.collect::<Vec<_>>();
let target = mapping.map(|m| m.target).unwrap_or_else(|| {
if existing.len() == 1 {
Some(existing[0])
} else {
None
}
let target_choice = counterparts::target(p, source, &d.request);
let target = target_choice.flatten();
let query = d
.counterpart_queries
.get(&source)
.map_or("", String::as_str);
let targets = counterparts::choices(p, source, d.request.direction, query);
let no_matches = targets.len() == 1;
let selected = target_choice.map(|target| {
counterparts::selected(p, source, d.request.direction, target)
});
let mut targets = vec![Choice {
id: Id::nil(),
label: "Create new counterpart".into(),
}];
targets.extend(
p.elements
.keys()
.chain(p.relations.keys())
.filter(|id| {
p.layer_of(**id) == Some(d.request.direction.target_layer())
&& p.elements.contains_key(&source)
== p.elements.contains_key(id)
})
.map(|id| choice(p, *id)),
);
let selected = targets
.iter()
.find(|c| Some(c.id) == target.or(Some(Id::nil())))
.cloned();
let mut item = column![
text(format!(
"{} · {}",
@@ -1201,14 +1180,29 @@ impl Workflow {
.unwrap_or("Relationship")
))
.size(14),
text_input("Find counterpart by name or @alias", query)
.on_input(move |query| Message::Workflow(Event::MappingQuery(
source, query
)))
.size(12)
.padding(8),
pick_list(targets, selected, move |v| Message::Workflow(
Event::MappingTarget(source, v)
))
.placeholder("Choose a counterpart")
.text_size(12)
.padding(8)
.width(Fill)
]
.spacing(7);
if no_matches {
let message = if query.trim().is_empty() {
"No compatible existing counterparts. Create a new one."
} else {
"No matching existing counterparts. Clear the search or create a new one."
};
item = item.push(text(message).size(11));
}
if target.is_none()
&& let Some(e) = p.elements.get(&source)
{
@@ -1414,16 +1408,6 @@ impl Workflow {
.color(style::MUTED),
);
}
content = content.push(
text(format!(
"{} changes · {} required boundary choices · {} unresolved conflicts · {} modeling warnings",
d.plan.changes.len(),
d.plan.conflicts.iter().filter(|c| d.boundary_choice(p, c)).count(),
d.plan.conflicts.iter().filter(|c| !d.boundary_choice(p, c)).count(),
d.plan.warnings.len()
))
.size(12),
);
}
}
let enabled = match self {
@@ -1440,12 +1424,50 @@ impl Workflow {
.color(iced::Color::from_rgb8(178, 54, 58)),
);
}
column![
scrollable(content.padding(iced::Padding {
right: 14.,
..Default::default()
}))
.height(480),
let mut footer = column![].spacing(8);
if let Self::Transition(d) = self {
let boundary_choices = d
.plan
.conflicts
.iter()
.filter(|c| d.boundary_choice(p, c))
.count();
footer = footer.push(
text(format!(
"{} reviewed items · {} boundary choices · {} conflicts · {} warnings",
d.plan.changes.len(),
boundary_choices,
d.plan.conflicts.len() - boundary_choices,
d.plan.warnings.len()
))
.size(12),
);
let guidance = if d.destination_error(p).is_some() {
Some("Choose a compatible diagram in Show result in, above the mappings.".into())
} else if boundary_choices > 0 {
Some("Choose the counterpart kind in each mapping that needs a system boundary decision.".into())
} else if let Some(field) = d.plan.conflicts.iter().find_map(|c| c.field) {
let field = match field {
TransitionField::Name => "Name",
TransitionField::Description => "Description",
};
Some(format!(
"Review {field} below the mappings: choose Keep target, Take source, or merged text."
))
} else if !d.plan.conflicts.is_empty() {
Some("Review the conflict messages below the mappings and complete the required decisions.".into())
} else {
None
};
if let Some(guidance) = guidance {
footer = footer.push(
text(guidance)
.size(12)
.color(iced::Color::from_rgb8(178, 54, 58)),
);
}
}
footer = footer.push(
row![
Space::new().width(Fill),
button(text("Cancel").size(12))
@@ -1465,7 +1487,15 @@ impl Workflow {
.padding(10)
.on_press_maybe(enabled.then_some(Message::Workflow(Event::Apply)))
]
.spacing(10)
.spacing(10),
);
column![
scrollable(content.padding(iced::Padding {
right: 14.,
..Default::default()
}))
.height(480),
footer
]
.spacing(18)
.into()