1117 lines
50 KiB
Rust
1117 lines
50 KiB
Rust
use super::*;
|
|
use crate::{style, views::short};
|
|
use iced::{
|
|
Element, Fill,
|
|
widget::{
|
|
Space, button, checkbox, column, container, pick_list, row, scrollable, text, text_input,
|
|
},
|
|
};
|
|
|
|
fn event(event: Event) -> Message {
|
|
Message::Workflow(crate::workflow::Event::Semantic(event))
|
|
}
|
|
fn action<'a>(label: impl Into<String>, event_value: Event) -> iced::widget::Button<'a, Message> {
|
|
button(text(label.into()).size(12))
|
|
.padding([7, 9])
|
|
.style(style::nav)
|
|
.on_press(event(event_value))
|
|
}
|
|
pub(super) fn choice(project: &Project, id: Id) -> Choice {
|
|
let alias = project
|
|
.elements
|
|
.get(&id)
|
|
.map(|v| v.alias.as_str())
|
|
.or_else(|| project.relations.get(&id).map(|v| v.alias.as_str()))
|
|
.or_else(|| project.artifact_links.get(&id).map(|v| v.alias.as_str()));
|
|
Choice {
|
|
id,
|
|
label: alias.map_or_else(
|
|
|| format!("Deleted reference · {id}"),
|
|
|alias| format!("{} · @{alias}", project.named(id)),
|
|
),
|
|
}
|
|
}
|
|
fn words(value: &str) -> String {
|
|
let mut out = String::new();
|
|
for (index, c) in value.chars().enumerate() {
|
|
if c == '_' {
|
|
out.push(' ');
|
|
} else {
|
|
if index > 0 && c.is_uppercase() {
|
|
out.push(' ');
|
|
}
|
|
out.push(c);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
pub(super) fn group(facet: &FacetSelector) -> &'static str {
|
|
match facet {
|
|
FacetSelector::Header(_) => "Name and ownership",
|
|
FacetSelector::Data(_) | FacetSelector::Field(_) | FacetSelector::Literal(_) => {
|
|
"Data and defaults"
|
|
}
|
|
FacetSelector::Port(_) | FacetSelector::Interface(_) | FacetSelector::ExchangeItem(_) => {
|
|
"Contracts and interfaces"
|
|
}
|
|
FacetSelector::Scenario(_)
|
|
| FacetSelector::Lifeline(_)
|
|
| FacetSelector::Occurrence(_)
|
|
| FacetSelector::Event(_)
|
|
| FacetSelector::State(_)
|
|
| FacetSelector::Annotation(_)
|
|
| FacetSelector::Binding(_) => "Behavior and scenarios",
|
|
FacetSelector::Relation(_) | FacetSelector::Constraint(_) => {
|
|
"Relationships and constraints"
|
|
}
|
|
FacetSelector::Member { .. } | FacetSelector::Order(_) => "Membership and order",
|
|
FacetSelector::Child(_)
|
|
| FacetSelector::Incident { .. }
|
|
| FacetSelector::Artifact { .. } => "Attached records",
|
|
}
|
|
}
|
|
pub(super) fn title(project: &Project, facet: &FacetSelector) -> String {
|
|
let keys = |keys: &KeyPair<Id>, missing: &str| {
|
|
format!(
|
|
"{} ↔ {}",
|
|
keys.oa
|
|
.map(|id| choice(project, id).label)
|
|
.unwrap_or_else(|| missing.into()),
|
|
keys.sa
|
|
.map(|id| choice(project, id).label)
|
|
.unwrap_or_else(|| missing.into())
|
|
)
|
|
};
|
|
match facet {
|
|
FacetSelector::Member {
|
|
collection,
|
|
keys: pair,
|
|
} => format!(
|
|
"{} · {}",
|
|
words(&format!("{collection:?}")),
|
|
keys(pair, "No identity")
|
|
),
|
|
FacetSelector::Order(collection) => format!("{} order", words(&format!("{collection:?}"))),
|
|
FacetSelector::Literal(pair) => format!(
|
|
"Literal · {} ↔ {}",
|
|
pair.oa
|
|
.as_ref()
|
|
.map(|s| format!("{s:?}"))
|
|
.unwrap_or_else(|| "No identity".into()),
|
|
pair.sa
|
|
.as_ref()
|
|
.map(|s| format!("{s:?}"))
|
|
.unwrap_or_else(|| "No identity".into())
|
|
),
|
|
FacetSelector::Annotation(pair) => format!(
|
|
"Message value · {} ↔ {}",
|
|
pair.oa
|
|
.as_ref()
|
|
.map(|key| annotation_title(project, key))
|
|
.unwrap_or_else(|| "Absent".into()),
|
|
pair.sa
|
|
.as_ref()
|
|
.map(|key| annotation_title(project, key))
|
|
.unwrap_or_else(|| "Absent".into())
|
|
),
|
|
FacetSelector::Binding(pair) => format!("Lifeline binding · {}", keys(pair, "Absent")),
|
|
FacetSelector::Child(pair) => format!("Owned child · {}", keys(pair, "Absent")),
|
|
FacetSelector::Incident { keys: pair, end } => {
|
|
format!("Incident {end:?} · {}", keys(pair, "Absent"))
|
|
}
|
|
FacetSelector::Artifact { keys: pair, facet } => {
|
|
format!(
|
|
"Artifact {} · {}",
|
|
words(&format!("{facet:?}")),
|
|
keys(pair, "Absent")
|
|
)
|
|
}
|
|
_ => {
|
|
let json = serde_json::to_value(facet).expect("Typed selector");
|
|
let value = json
|
|
.as_object()
|
|
.expect("Family")
|
|
.values()
|
|
.next()
|
|
.expect("Leaf");
|
|
words(value.as_str().unwrap_or("Property"))
|
|
}
|
|
}
|
|
}
|
|
fn annotation_title(project: &Project, key: &AnnotationKey) -> String {
|
|
key.path
|
|
.iter()
|
|
.chain(std::iter::once(&key.field))
|
|
.map(|id| choice(project, *id).label)
|
|
.collect::<Vec<_>>()
|
|
.join(" / ")
|
|
}
|
|
fn drift(drift: Drift) -> &'static str {
|
|
match drift {
|
|
Drift::Unreviewed => "Unreviewed",
|
|
Drift::Unchanged => "Unchanged since review",
|
|
Drift::OaChanged => "Operational changed",
|
|
Drift::SaChanged => "System changed",
|
|
Drift::BothChanged => "Both changed",
|
|
}
|
|
}
|
|
pub(crate) fn summary(project: &Project, trace: &Trace) -> String {
|
|
let references = BTreeMap::from([(trace.oa, trace.sa)]);
|
|
match trace_semantic_drift(project, trace, &references) {
|
|
Ok(value) => format!(
|
|
"Semantics: {}{} · Linked: {}{}",
|
|
drift(value.own),
|
|
if value.own_unreviewed && value.own != Drift::Unreviewed {
|
|
" · incomplete review"
|
|
} else {
|
|
""
|
|
},
|
|
drift(value.linked),
|
|
if value.linked_unreviewed && value.linked != Drift::Unreviewed {
|
|
" · incomplete review"
|
|
} else {
|
|
""
|
|
}
|
|
),
|
|
Err(error) => format!("Semantic review unavailable: {error}"),
|
|
}
|
|
}
|
|
fn formatted(project: &Project, value: &Value, references: &BTreeSet<Id>) -> String {
|
|
match value {
|
|
Value::Null => "Not set".into(),
|
|
Value::Bool(value) => if *value { "Yes" } else { "No" }.into(),
|
|
Value::Number(value) => value.to_string(),
|
|
Value::String(value) => {
|
|
if let Ok(id) = value.parse::<Id>()
|
|
&& references.contains(&id)
|
|
{
|
|
choice(project, id).label
|
|
} else if value.is_empty() {
|
|
"Empty text".into()
|
|
} else {
|
|
format!("“{value}”")
|
|
}
|
|
}
|
|
Value::Array(values) if values.is_empty() => "Empty list (0 members)".into(),
|
|
Value::Array(values) => values
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, v)| format!("{}. {}", i + 1, formatted(project, v, references)))
|
|
.collect::<Vec<_>>()
|
|
.join("\n"),
|
|
Value::Object(values) => values
|
|
.iter()
|
|
.map(|(key, value)| {
|
|
format!("{}: {}", words(key), formatted(project, value, references))
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n"),
|
|
}
|
|
}
|
|
pub(super) fn observed(project: &Project, fact: Option<&FacetReview>, layer: Layer) -> String {
|
|
let Some(fact) = fact else {
|
|
return "Unreviewed — no previous observation".into();
|
|
};
|
|
let value = scalar::observation(fact, layer);
|
|
if value.as_str() == Some("NotApplicable") {
|
|
"Not applicable".into()
|
|
} else if matches!(fact, FacetReview::Annotation { .. }) {
|
|
if let Ok(row) = serde_json::from_value::<MessageValue>(value["Value"].clone()) {
|
|
format!(
|
|
"Field: {}\nExpression: {:?}",
|
|
annotation_title(project, &AnnotationKey::from(&row)),
|
|
row.expression
|
|
)
|
|
} else {
|
|
"Absent".into()
|
|
}
|
|
} else if keyed::supported(&fact.selector()) && !keyed_rows::supported(&fact.selector()) {
|
|
if value["Value"].as_bool() == Some(true) {
|
|
"Present".into()
|
|
} else {
|
|
"Absent".into()
|
|
}
|
|
} else {
|
|
formatted(project, &value["Value"], &scalar::references(fact))
|
|
}
|
|
}
|
|
|
|
impl State {
|
|
pub fn view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
|
let trace = self.trace.and_then(|id| project.traces.get(&id));
|
|
let mut traces: Vec<_> = project
|
|
.traces
|
|
.values()
|
|
.filter(|t| t.oa == self.anchor || t.sa == self.anchor)
|
|
.map(|t| Choice {
|
|
id: t.id,
|
|
label: format!(
|
|
"{} · {:?} · {} ↔ {} · @{}",
|
|
t.name,
|
|
t.kind,
|
|
project.named(t.oa),
|
|
project.named(t.sa),
|
|
t.alias
|
|
),
|
|
})
|
|
.collect();
|
|
traces.sort_by(|a, b| a.label.cmp(&b.label));
|
|
let selected_trace = traces.iter().find(|c| Some(c.id) == self.trace).cloned();
|
|
let direction = if self.direction == TransitionDirection::OaToSa {
|
|
"Use Operational changes in System"
|
|
} else {
|
|
"Use System changes in Operational"
|
|
};
|
|
let mut content = column![
|
|
text("Review semantics").size(22),
|
|
row![
|
|
pick_list(traces, selected_trace, |c| event(Event::Trace(c))).placeholder("Choose a trace / review history").text_size(12).padding(7).width(Fill),
|
|
action(direction, Event::Direction(if self.direction == TransitionDirection::OaToSa { TransitionDirection::SaToOa } else { TransitionDirection::OaToSa })),
|
|
].spacing(8),
|
|
text("Changing trace or direction starts a new review draft. Other pending edits remain separate.").size(10).color(style::MUTED),
|
|
row![action("Properties", Event::Tab(Tab::Facts)), action("Members / rows", Event::Tab(Tab::Members)), action(format!("History ({})", self.resets.len()), Event::Tab(Tab::History)), action(format!("Reference pairs ({})", self.references.len()), Event::Tab(Tab::References)), action(format!("Additional changes ({})", self.plan.as_ref().map_or(0, |p| p.impacts.len())), Event::Tab(Tab::Impacts)), Space::new().width(Fill), action("Refresh preview", Event::Preview)].spacing(6).wrap(),
|
|
].spacing(8);
|
|
let body = match self.tab {
|
|
Tab::Facts => self.facts_view(project),
|
|
Tab::References => self.references_view(project),
|
|
Tab::Members => self.members_view(project),
|
|
Tab::History => self.history_view(project),
|
|
Tab::Impacts => self.impacts_view(project),
|
|
};
|
|
content = content.push(body);
|
|
if let Some(error) = &self.error {
|
|
content = content.push(
|
|
text(error)
|
|
.size(12)
|
|
.color(iced::Color::from_rgb8(178, 54, 58)),
|
|
);
|
|
}
|
|
if let Some(plan) = &self.plan {
|
|
for error in &plan.conflicts {
|
|
content = content.push(
|
|
text(error)
|
|
.size(12)
|
|
.color(iced::Color::from_rgb8(178, 54, 58)),
|
|
);
|
|
}
|
|
}
|
|
let unreviewed = self
|
|
.facts
|
|
.iter()
|
|
.filter(|f| f.drift == Drift::Unreviewed)
|
|
.count();
|
|
content = content.push(text(format!("{} decisions · {} exact resets · {unreviewed} unreviewed facts · Attached-record and multi-source decisions remain pending.", self.decisions.len(), self.resets.len())).size(10).color(style::MUTED));
|
|
if let Some(trace) = trace {
|
|
content = content.push(text(summary(project, trace)).size(10).color(style::MUTED));
|
|
}
|
|
content
|
|
.push(
|
|
row![
|
|
text(
|
|
"Apply updates the selected history and model together; Undo restores both."
|
|
)
|
|
.size(10)
|
|
.width(Fill),
|
|
button(text("Cancel").size(12))
|
|
.padding(8)
|
|
.style(style::nav)
|
|
.on_press(Message::Workflow(crate::workflow::Event::Cancel)),
|
|
button(text("Apply reviewed changes").size(12))
|
|
.padding(8)
|
|
.on_press_maybe(
|
|
self.can_apply()
|
|
.then_some(Message::Workflow(crate::workflow::Event::Apply))
|
|
),
|
|
]
|
|
.spacing(8)
|
|
.align_y(iced::Center),
|
|
)
|
|
.into()
|
|
}
|
|
|
|
fn facts_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
|
let mut list = column![
|
|
checkbox(self.show_unchanged)
|
|
.label("Include unchanged facts")
|
|
.text_size(11)
|
|
.on_toggle(|v| event(Event::ShowUnchanged(v)))
|
|
]
|
|
.spacing(5);
|
|
let mut groups: BTreeMap<&str, Vec<&FacetDrift>> = BTreeMap::new();
|
|
for fact in &self.facts {
|
|
if self.show_unchanged
|
|
|| fact.drift != Drift::Unchanged
|
|
|| self.decisions.contains_key(&fact.selector)
|
|
|| self.selected.as_ref() == Some(&fact.selector)
|
|
{
|
|
groups.entry(group(&fact.selector)).or_default().push(fact);
|
|
}
|
|
}
|
|
for (group, facts) in groups {
|
|
list = list.push(text(group).size(12).color(style::TEAL));
|
|
for fact in facts {
|
|
let mark = match self.decisions.get(&fact.selector) {
|
|
Some(SemanticDecision::KeepTarget) => "Keep · ",
|
|
Some(SemanticDecision::TakeSource) => "Take · ",
|
|
Some(SemanticDecision::Candidate) => "Final value · ",
|
|
None => "",
|
|
};
|
|
list = list.push(
|
|
button(
|
|
column![
|
|
text(short(&title(project, &fact.selector), 52)).size(12),
|
|
text(format!("{mark}{}", drift(fact.drift))).size(10)
|
|
]
|
|
.spacing(3),
|
|
)
|
|
.padding(7)
|
|
.width(Fill)
|
|
.style(if self.selected.as_ref() == Some(&fact.selector) {
|
|
style::selected
|
|
} else {
|
|
style::nav
|
|
})
|
|
.on_press(event(Event::Select(fact.selector.clone()))),
|
|
);
|
|
}
|
|
}
|
|
row![
|
|
scrollable(list).height(305).width(260),
|
|
scrollable(self.detail(project)).height(305).width(Fill)
|
|
]
|
|
.spacing(12)
|
|
.into()
|
|
}
|
|
|
|
fn detail<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
|
let Some(fact) = self
|
|
.selected
|
|
.as_ref()
|
|
.and_then(|s| self.facts.iter().find(|f| &f.selector == s))
|
|
else {
|
|
return text("Choose a trace and property to compare.")
|
|
.size(13)
|
|
.into();
|
|
};
|
|
let trace = self.trace.and_then(|id| project.traces.get(&id));
|
|
let previous = trace
|
|
.and_then(|t| t.baseline.as_ref())
|
|
.and_then(|b| b.semantic.as_ref())
|
|
.and_then(|b| b.facets.iter().find(|f| f.selector() == fact.selector));
|
|
let is_group = scalar::grouped(&fact.selector);
|
|
let group_observations = |project: &Project, facts: Vec<&FacetReview>, layer| {
|
|
facts
|
|
.into_iter()
|
|
.filter(|f| constructors::contains(&fact.selector, &f.selector()))
|
|
.map(|f| {
|
|
format!(
|
|
"{}: {}",
|
|
title(project, &f.selector()),
|
|
observed(project, Some(f), layer)
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
};
|
|
let side = |layer, label: &str| {
|
|
let mut before = observed(project, previous, layer);
|
|
let mut current = observed(project, Some(&fact.current), layer);
|
|
if is_group {
|
|
before = group_observations(
|
|
project,
|
|
trace
|
|
.and_then(|t| t.baseline.as_ref())
|
|
.and_then(|b| b.semantic.as_ref())
|
|
.map(|b| b.facets.iter().collect())
|
|
.unwrap_or_default(),
|
|
layer,
|
|
);
|
|
if before.is_empty() {
|
|
before = "No previous group observations".into();
|
|
}
|
|
current = group_observations(
|
|
project,
|
|
self.facts.iter().map(|f| &f.current).collect(),
|
|
layer,
|
|
);
|
|
}
|
|
column![
|
|
text(format!(
|
|
"{label}{}",
|
|
if layer == self.direction.target_layer() {
|
|
" · destination"
|
|
} else {
|
|
" · source"
|
|
}
|
|
))
|
|
.size(12)
|
|
.color(style::TEAL),
|
|
text("Previously reviewed").size(10).color(style::MUTED),
|
|
text(before).size(12),
|
|
text("Current").size(10).color(style::MUTED),
|
|
text(current).size(12),
|
|
]
|
|
.spacing(5)
|
|
.width(Fill)
|
|
};
|
|
let mut detail = column![
|
|
text(title(project, &fact.selector)).size(17),
|
|
text(drift(fact.drift)).size(11),
|
|
row![side(Layer::Oa, "Operational"), side(Layer::Sa, "System")].spacing(14)
|
|
]
|
|
.spacing(10);
|
|
if previous.is_some() && history::resettable(&fact.selector) {
|
|
let facet = fact.selector.clone();
|
|
detail = detail.push(
|
|
checkbox(self.resets.contains(&facet))
|
|
.label("Reset this exact previous observation before review")
|
|
.text_size(11)
|
|
.on_toggle(move |v| event(Event::Reset(facet.clone(), v))),
|
|
);
|
|
}
|
|
if keyed::supported(&fact.selector) {
|
|
detail = detail.push(text("Review changes only this collection entry; referenced objects stay intact. Take preserves a row with no source identity. An explicit source identity that is absent can remove its mapped destination entry. Changing a row key requires separate removal and addition.").size(11))
|
|
.push(action("Choose / change exact row identities…", Event::EditPair(fact.selector.clone())));
|
|
if let Some(baseline) = trace
|
|
.and_then(|t| t.baseline.as_ref())
|
|
.and_then(|b| b.semantic.as_ref())
|
|
{
|
|
for prior in &baseline.facets {
|
|
let prior = prior.selector();
|
|
if prior != fact.selector && keyed::overlap(&prior, &fact.selector) {
|
|
detail = detail.push(
|
|
checkbox(self.resets.contains(&prior))
|
|
.label(format!(
|
|
"Reset overlapping history: {}",
|
|
title(project, &prior)
|
|
))
|
|
.text_size(11)
|
|
.on_toggle(move |v| event(Event::Reset(prior.clone(), v))),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if let FacetSelector::Order(which) = fact.selector {
|
|
detail = detail.push(text("Order changes positions only. Review member presence separately; the final order must include every surviving destination identity exactly once. Take preserves destination-only members when their surrounding order already agrees.").size(11));
|
|
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate) {
|
|
detail = detail.push(self.order_view(project, which));
|
|
}
|
|
}
|
|
if let Some(proposed) = &self.proposed
|
|
&& let Some(trace) = trace
|
|
&& let Ok(after) = capture_facet(proposed, trace.oa, trace.sa, &fact.selector)
|
|
{
|
|
detail = detail
|
|
.push(
|
|
text("Proposed destination value · validated preview")
|
|
.size(11)
|
|
.color(style::TEAL),
|
|
)
|
|
.push(
|
|
text(if is_group {
|
|
semantic_facets(proposed, &proposed.traces[&trace.id], &self.references)
|
|
.ok()
|
|
.map(|facts| {
|
|
group_observations(
|
|
proposed,
|
|
facts.iter().map(|f| &f.current).collect(),
|
|
self.direction.target_layer(),
|
|
)
|
|
})
|
|
.unwrap_or_else(|| "Group payload is unavailable.".into())
|
|
} else {
|
|
observed(proposed, Some(&after), self.direction.target_layer())
|
|
})
|
|
.size(12),
|
|
);
|
|
} else {
|
|
detail = detail.push(
|
|
text("No validated final preview. Resolve the messages below before applying.")
|
|
.size(11)
|
|
.color(style::MUTED),
|
|
);
|
|
}
|
|
if self.editable(project, &fact.selector) {
|
|
if is_group {
|
|
detail = detail.push(text(constructors::description(&fact.selector)).size(12));
|
|
detail = detail.push(text("Replacing a kind reviews its complete payload. Review every removed reference, row and prior observation in the exact scope below; changing kind retains your draft for switching back.").size(11));
|
|
if self.can_acknowledge_constructor(project, &fact.selector) {
|
|
detail = detail.push(text("These initial kinds are equal. Keep or Take acknowledges only the kind; an unchanged Candidate kind needs independent descendant decisions for payload edits.").size(11).color(style::MUTED));
|
|
}
|
|
}
|
|
let destination = if self.direction.target_layer() == Layer::Sa {
|
|
"System"
|
|
} else {
|
|
"Operational"
|
|
};
|
|
let source = if self.direction.source_layer() == Layer::Oa {
|
|
"Operational"
|
|
} else {
|
|
"System"
|
|
};
|
|
let mut buttons = row![
|
|
action(
|
|
format!("Keep {destination}"),
|
|
Event::Decide(fact.selector.clone(), Some(SemanticDecision::KeepTarget))
|
|
),
|
|
action(
|
|
format!("Take {source}"),
|
|
Event::Decide(fact.selector.clone(), Some(SemanticDecision::TakeSource))
|
|
)
|
|
]
|
|
.spacing(5);
|
|
buttons = buttons.push(action(
|
|
if is_group {
|
|
"Set final kind and payload…"
|
|
} else {
|
|
"Set final value…"
|
|
},
|
|
Event::Decide(fact.selector.clone(), Some(SemanticDecision::Candidate)),
|
|
));
|
|
detail = detail.push(buttons.wrap()).push(action(
|
|
"Leave unselected",
|
|
Event::Decide(fact.selector.clone(), None),
|
|
));
|
|
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
|
|
&& keyed::supported(&fact.selector)
|
|
{
|
|
let facet = fact.selector.clone();
|
|
detail = detail.push(
|
|
checkbox(self.presence.get(&facet).copied().unwrap_or(false))
|
|
.label(
|
|
"Include in destination collection (unchecked removes only this entry)",
|
|
)
|
|
.text_size(12)
|
|
.on_toggle(move |v| event(Event::Presence(facet.clone(), v))),
|
|
);
|
|
if keyed_rows::supported(&fact.selector)
|
|
&& let Some(draft) = self.row_drafts.get(&fact.selector)
|
|
{
|
|
let facet = fact.selector.clone();
|
|
detail = detail.push(text("The exact destination key stays fixed; its row value is independent of sibling rows.").size(11));
|
|
match &fact.selector {
|
|
FacetSelector::Annotation(_) => {
|
|
detail = detail.push(
|
|
iced::widget::text_editor(&draft.editor)
|
|
.placeholder("Expression")
|
|
.on_action(move |action| {
|
|
event(Event::RowText(facet.clone(), action))
|
|
})
|
|
.size(12)
|
|
.height(90)
|
|
.padding(7),
|
|
);
|
|
}
|
|
FacetSelector::Binding(_) => {
|
|
detail = detail.push(
|
|
pick_list(
|
|
self.binding_choices(
|
|
project,
|
|
self.direction.target_layer(),
|
|
false,
|
|
),
|
|
draft.local.clone(),
|
|
move |value| event(Event::RowLocal(facet.clone(), value)),
|
|
)
|
|
.placeholder("Local lifeline in destination scenario")
|
|
.text_size(12)
|
|
.padding(7)
|
|
.width(Fill),
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
|
|
&& let Some(draft) = self.candidate_draft(&fact.selector)
|
|
&& let Some(paths) = self
|
|
.target(project)
|
|
.and_then(|id| constructors::roots(project, id, &fact.selector))
|
|
{
|
|
detail = detail.push(text("Final destination value").size(12));
|
|
for path in paths {
|
|
let label = if path.ends_with("/default") {
|
|
"Default"
|
|
} else if path.ends_with("/legacy") {
|
|
"Generic association"
|
|
} else {
|
|
"Kind and payload"
|
|
};
|
|
detail = detail.push(
|
|
crate::forms::scoped(
|
|
draft,
|
|
project,
|
|
&path,
|
|
if is_group { label } else { "Final value" },
|
|
)
|
|
.map(form_event),
|
|
);
|
|
}
|
|
}
|
|
} else if self.can_acknowledge_constructor(project, &fact.selector) {
|
|
detail = detail.push(text("Both constructor kinds are equal and have no prior observation. Acknowledge only the kind; its properties remain independent.").size(12))
|
|
.push(action("Acknowledge equal kind only", Event::Decide(fact.selector.clone(), Some(SemanticDecision::KeepTarget))))
|
|
.push(action("Leave unselected", Event::Decide(fact.selector.clone(), None)));
|
|
} else {
|
|
detail = detail.push(text(if scalar::grouped(&fact.selector) { "This constructor is unavailable on one of the current counterparts. Review the outer constructor before its nested kind." } else { "This structured or inapplicable fact is read-only here. Its review history is retained." }).size(12).color(style::MUTED));
|
|
}
|
|
if self.staged.contains(&fact.selector) {
|
|
detail = detail.push(action(
|
|
"Remove staged address and its decision",
|
|
Event::DropStaged(fact.selector.clone()),
|
|
));
|
|
}
|
|
let overlaps: Vec<_> = self
|
|
.decisions
|
|
.keys()
|
|
.filter(|other| {
|
|
**other != fact.selector
|
|
&& (constructors::contains(&fact.selector, other)
|
|
|| constructors::contains(other, &fact.selector)
|
|
|| keyed::overlap(other, &fact.selector))
|
|
})
|
|
.collect();
|
|
if self.decisions.contains_key(&fact.selector) && !overlaps.is_empty() {
|
|
detail = detail.push(text("Related decisions are selected. Paired rows cannot review the same OA or SA identity twice. Replacing groups require one combined decision; first equal-kind acknowledgments may coexist with descendants. Resolve overlaps explicitly; all drafts are retained.").size(11));
|
|
for other in overlaps {
|
|
detail = detail.push(action(
|
|
format!("Leave {} unselected", title(project, other)),
|
|
Event::Decide(other.clone(), None),
|
|
));
|
|
}
|
|
detail = detail.push(action(
|
|
"Leave this decision unselected; use the related decisions",
|
|
Event::Decide(fact.selector.clone(), None),
|
|
));
|
|
}
|
|
if (is_group
|
|
|| keyed::supported(&fact.selector)
|
|
|| matches!(fact.selector, FacetSelector::Order(_))
|
|
|| constructors::selectors().iter().any(|group| {
|
|
self.decisions.contains_key(group) && constructors::contains(group, &fact.selector)
|
|
}))
|
|
&& let Some(review) = self.plan.as_ref().and_then(|p| p.reviews.first())
|
|
{
|
|
detail = detail.push(text("Exact review scope from preview").size(13));
|
|
for (label, selectors) in [
|
|
("Acknowledge", &review.acknowledged),
|
|
("Retire previous observation", &review.retired),
|
|
] {
|
|
detail = detail.push(
|
|
text(format!(
|
|
"{label}: {}",
|
|
if selectors.is_empty() {
|
|
"None".into()
|
|
} else {
|
|
selectors
|
|
.iter()
|
|
.map(|s| title(project, s))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|
|
))
|
|
.size(11),
|
|
);
|
|
}
|
|
}
|
|
if matches!(
|
|
fact.selector,
|
|
FacetSelector::Child(_)
|
|
| FacetSelector::Incident { .. }
|
|
| FacetSelector::Artifact { .. }
|
|
) {
|
|
detail = detail.push(text("Attached facts are separate from referenced definitions. Removing membership never implies deleting a semantic object.").size(11).color(style::MUTED));
|
|
}
|
|
detail.into()
|
|
}
|
|
|
|
fn members_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
|
let families = self.member_families(project);
|
|
let available = families.contains(&self.member_family);
|
|
let mut content = column![
|
|
text("Review an individual member or row").size(16),
|
|
text("Choose an exact address, then review its value and presence in Properties. This also stages a new row in an empty collection. No model object is created.").size(12),
|
|
pick_list(families, Some(self.member_family), |v| event(Event::MemberFamily(v))).text_size(12).padding(7),
|
|
].spacing(9);
|
|
if let keyed::Family::Member(collection) = self.member_family
|
|
&& let Some(which) = ordered::ordered(collection)
|
|
{
|
|
content = content.push(text("Membership and order are separate decisions. An incoming member with ambiguous placement requires an explicit final order; referenced objects are not created or reparented here.").size(11))
|
|
.push(action("Review final order…", Event::Select(FacetSelector::Order(which))));
|
|
}
|
|
if !available {
|
|
content = content.push(text("This collection is not available on the current counterparts. Choose an available collection above; existing staged addresses remain removable below.").size(12));
|
|
} else if self.member_family == keyed::Family::Annotation {
|
|
content = content.push(text("Pair the complete carried-field paths explicitly. Each path component establishes a reference pair. Repeated fields under different paths remain independent rows.").size(11))
|
|
.push(self.annotation_address_view(project, Layer::Oa))
|
|
.push(self.annotation_address_view(project, Layer::Sa));
|
|
} else if self.member_family == keyed::Family::Literal {
|
|
content = content.push(text("Literal identity is exact, including case and spaces. Both sides use the same string; a rename requires separate old/new rows.").size(11))
|
|
.push(text_input("Exact literal", &self.literal).on_input(|v| event(Event::Literal(v))).size(12).padding(7))
|
|
.push(row![checkbox(self.literal_oa).label("Operational identity").text_size(12).on_toggle(|v| event(Event::LiteralSide(Layer::Oa,v))), checkbox(self.literal_sa).label("System identity").text_size(12).on_toggle(|v| event(Event::LiteralSide(Layer::Sa,v)))].spacing(12));
|
|
} else {
|
|
content = content.push(text("Identities may exist outside the collection. Choosing both explicitly sets their OA → SA correspondence. No identity means an unpaired side, distinct from an absent member.").size(11))
|
|
.push(row![pick_list(self.member_choices(project, Layer::Oa), self.member_oa.clone(), |v| event(Event::MemberOa(Some(v)))).placeholder("Operational identity").text_size(12).padding(7).width(Fill), action("No OA identity", Event::MemberOa(None))].spacing(7))
|
|
.push(row![pick_list(self.member_choices(project, Layer::Sa), self.member_sa.clone(), |v| event(Event::MemberSa(Some(v)))).placeholder("System identity").text_size(12).padding(7).width(Fill), action("No SA identity", Event::MemberSa(None))].spacing(7));
|
|
}
|
|
content = content.push(button(text("Use these addresses and review row").size(12)).padding([7,9]).style(style::nav).on_press_maybe(available.then_some(event(Event::StageMember))))
|
|
.push(text("Changing a row address retains its earlier decisions and drafts. Reset conflicting historical observations and leave overlapping decisions unselected explicitly.").size(11).color(style::MUTED));
|
|
if !self.staged.is_empty() {
|
|
content = content.push(text("Staged addresses in this draft").size(13));
|
|
for facet in &self.staged {
|
|
content = content.push(
|
|
row![
|
|
text(title(project, facet)).size(11).width(Fill),
|
|
action(
|
|
"Remove address / decision",
|
|
Event::DropStaged(facet.clone())
|
|
)
|
|
]
|
|
.spacing(7),
|
|
);
|
|
}
|
|
}
|
|
scrollable(content).height(305).into()
|
|
}
|
|
|
|
fn order_view<'a>(
|
|
&'a self,
|
|
project: &'a Project,
|
|
which: OrderedCollection,
|
|
) -> Element<'a, Message> {
|
|
let mut content = column![text("Final destination order").size(13),
|
|
text("Up/Down changes only positions. Reconcile members explicitly after changing presence: retain surviving positions, remove excluded entries and append newly included entries.").size(11),
|
|
action("Reconcile with selected membership", Event::OrderReconcile(which))].spacing(6);
|
|
if let Some(ids) = self.order_drafts.get(&which) {
|
|
if ids.is_empty() {
|
|
content = content.push(text("Empty order").size(12));
|
|
}
|
|
for (index, id) in ids.iter().enumerate() {
|
|
let up = button(text("Up").size(11)).padding(5).on_press_maybe(
|
|
(index > 0).then_some(event(Event::OrderMove(which, index, -1))),
|
|
);
|
|
let down = button(text("Down").size(11)).padding(5).on_press_maybe(
|
|
(index + 1 < ids.len()).then_some(event(Event::OrderMove(which, index, 1))),
|
|
);
|
|
content = content.push(
|
|
row![
|
|
text(format!("{}. {}", index + 1, choice(project, *id).label))
|
|
.size(12)
|
|
.width(Fill),
|
|
up,
|
|
down
|
|
]
|
|
.spacing(5),
|
|
);
|
|
}
|
|
match self.ordered_members(project, which) {
|
|
Ok(members) => {
|
|
let missing: Vec<_> = members
|
|
.iter()
|
|
.filter(|id| !ids.contains(id))
|
|
.map(|id| choice(project, *id).label)
|
|
.collect();
|
|
let excluded: Vec<_> = ids
|
|
.iter()
|
|
.filter(|id| !members.contains(id))
|
|
.map(|id| choice(project, *id).label)
|
|
.collect();
|
|
if !missing.is_empty() {
|
|
content = content.push(
|
|
text(format!("Missing from this draft: {}", missing.join(", ")))
|
|
.size(11),
|
|
);
|
|
}
|
|
if !excluded.is_empty() {
|
|
content = content.push(
|
|
text(format!("No longer included: {}", excluded.join(", "))).size(11),
|
|
);
|
|
}
|
|
}
|
|
Err(error) => content = content.push(text(error).size(11)),
|
|
}
|
|
}
|
|
content.into()
|
|
}
|
|
|
|
fn annotation_address_view<'a>(
|
|
&'a self,
|
|
project: &'a Project,
|
|
layer: Layer,
|
|
) -> Element<'a, Message> {
|
|
let address = self.annotation_address(layer);
|
|
let side = if layer == Layer::Oa {
|
|
"Operational"
|
|
} else {
|
|
"System"
|
|
};
|
|
let mut content =
|
|
column![text(format!("{side} carried-field address")).size(13)].spacing(6);
|
|
if let Some(key) = &address.selected {
|
|
content = content.push(
|
|
text(annotation_title(
|
|
project,
|
|
&AnnotationKey {
|
|
path: key.path.clone(),
|
|
field: key.field,
|
|
},
|
|
))
|
|
.size(11),
|
|
);
|
|
} else {
|
|
content = content.push(text("No identity on this side").size(11));
|
|
}
|
|
content = content.push(
|
|
row![
|
|
action("Root fields", Event::AnnotationBrowse(layer, None, 0)),
|
|
action("No identity", Event::AnnotationAddress(layer, None)),
|
|
]
|
|
.spacing(6),
|
|
);
|
|
if let Some(scope) = &address.scope {
|
|
content = content
|
|
.push(
|
|
text(format!(
|
|
"Browsing: {}",
|
|
annotation_title(
|
|
project,
|
|
&AnnotationKey {
|
|
path: scope.path.clone(),
|
|
field: scope.field
|
|
}
|
|
)
|
|
))
|
|
.size(11),
|
|
)
|
|
.push(action(
|
|
"Up",
|
|
Event::AnnotationBrowse(layer, crate::data_values::parent(scope), 0),
|
|
));
|
|
}
|
|
match self.annotation_choices(project, layer) {
|
|
Err(error) => content = content.push(text(error).size(11).color(style::MUTED)),
|
|
Ok(choices) => {
|
|
const PAGE: usize = 24;
|
|
let pages = choices.len().div_ceil(PAGE).max(1);
|
|
let page = address.page.min(pages - 1);
|
|
let visible = choices
|
|
.into_iter()
|
|
.skip(page * PAGE)
|
|
.take(PAGE)
|
|
.collect::<Vec<_>>();
|
|
let selected = visible
|
|
.iter()
|
|
.find(|choice| Some(&choice.key) == address.selected.as_ref())
|
|
.cloned();
|
|
content = content.push(
|
|
pick_list(visible, selected, move |choice| {
|
|
event(Event::AnnotationAddress(layer, Some(choice.key)))
|
|
})
|
|
.placeholder("Choose a field")
|
|
.text_size(12)
|
|
.padding(7)
|
|
.width(Fill),
|
|
);
|
|
if let Some(selected) = &address.selected {
|
|
content = content.push(action(
|
|
"Browse fields within selected record",
|
|
Event::AnnotationBrowse(layer, Some(selected.clone()), 0),
|
|
));
|
|
}
|
|
let mut navigation =
|
|
row![text(format!("Page {} of {pages}", page + 1)).size(11)].spacing(7);
|
|
if page > 0 {
|
|
navigation = navigation.push(action(
|
|
"Previous",
|
|
Event::AnnotationBrowse(layer, address.scope.clone(), page - 1),
|
|
));
|
|
}
|
|
if page + 1 < pages {
|
|
navigation = navigation.push(action(
|
|
"Next",
|
|
Event::AnnotationBrowse(layer, address.scope.clone(), page + 1),
|
|
));
|
|
}
|
|
content = content.push(navigation);
|
|
}
|
|
}
|
|
content.into()
|
|
}
|
|
|
|
fn history_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
|
let mut content = column![text("Exact previous observations").size(16), text("Check only the observations to forget. Resets run before this review's decisions; reset alone changes history, not model values. Uncheck to restore the pending observation. Prose history remains separate.").size(12)].spacing(9);
|
|
let baseline = self
|
|
.trace
|
|
.and_then(|id| project.traces.get(&id))
|
|
.and_then(|t| t.baseline.as_ref())
|
|
.and_then(|b| b.semantic.as_ref());
|
|
if baseline.is_none_or(|b| b.facets.is_empty()) {
|
|
content =
|
|
content.push(text("No previous semantic observations on this trace.").size(12));
|
|
}
|
|
if let Some(baseline) = baseline {
|
|
for fact in &baseline.facets {
|
|
let facet = fact.selector();
|
|
let mut item = column![
|
|
text(title(project, &facet)).size(13),
|
|
text(format!(
|
|
"Operational: {}\nSystem: {}",
|
|
observed(project, Some(fact), Layer::Oa),
|
|
observed(project, Some(fact), Layer::Sa)
|
|
))
|
|
.size(11)
|
|
]
|
|
.spacing(4);
|
|
if history::resettable(&facet) {
|
|
item = item.push(
|
|
checkbox(self.resets.contains(&facet))
|
|
.label("Reset this exact observation")
|
|
.text_size(11)
|
|
.on_toggle(move |v| event(Event::Reset(facet.clone(), v))),
|
|
);
|
|
} else {
|
|
item = item.push(
|
|
text("Artifact history reset remains pending with artifact review.")
|
|
.size(11),
|
|
);
|
|
}
|
|
content = content.push(container(item).padding(7));
|
|
}
|
|
}
|
|
for pending in &self.resets {
|
|
if baseline.is_none_or(|b| !b.facets.iter().any(|f| f.selector() == *pending)) {
|
|
let facet = pending.clone();
|
|
content = content.push(
|
|
checkbox(true)
|
|
.label(format!(
|
|
"Pending reset no longer in current history: {}",
|
|
title(project, &facet)
|
|
))
|
|
.text_size(11)
|
|
.on_toggle(move |v| event(Event::Reset(facet.clone(), v))),
|
|
);
|
|
}
|
|
}
|
|
scrollable(content).height(305).into()
|
|
}
|
|
|
|
fn references_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
|
let mut content = column![
|
|
text("Reference correspondence · always Operational → System").size(15),
|
|
text("Choose explicit counterpart identities before taking reference values. Names do not establish correspondence. Changing an already reviewed keyed pair requires an exact reset in History. Existing decisions are retained; resolve their overlapping row identities explicitly.").size(12),
|
|
text_input("Filter reference choices by name or alias", &self.reference_search).on_input(|v| event(Event::ReferenceSearch(v))).padding(7).size(12),
|
|
row![
|
|
pick_list(self.all_references(project, Layer::Oa), self.reference_oa.clone(), |c| event(Event::ReferenceOa(c))).placeholder("Operational reference").padding(7).text_size(12).width(Fill),
|
|
pick_list(self.all_references(project, Layer::Sa), self.reference_sa.clone(), |c| event(Event::ReferenceSa(c))).placeholder("System reference").padding(7).text_size(12).width(Fill),
|
|
action("Use pair", Event::AddReference),
|
|
].spacing(7).width(Fill),
|
|
].spacing(9).width(Fill);
|
|
for (oa, sa) in &self.references {
|
|
content = content.push(
|
|
row![
|
|
text(format!(
|
|
"{}\n↔ {}",
|
|
choice(project, *oa).label,
|
|
choice(project, *sa).label
|
|
))
|
|
.size(12)
|
|
.width(Fill),
|
|
action("Remove pair", Event::RemoveReference(*oa))
|
|
]
|
|
.spacing(6)
|
|
.width(Fill),
|
|
);
|
|
}
|
|
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
|
|
content = content
|
|
.push(text("Referenced definitions · reviewed on their own traces").size(14));
|
|
match linked_semantic_impacts(project, trace, &self.references) {
|
|
Ok(impacts) => {
|
|
for impact in impacts {
|
|
content = content.push(
|
|
text(format!(
|
|
"{} ↔ {} · {}{} · {} trace(s)",
|
|
impact
|
|
.objects
|
|
.oa
|
|
.map(|id| choice(project, id).label)
|
|
.unwrap_or_else(|| "Absent".into()),
|
|
impact
|
|
.objects
|
|
.sa
|
|
.map(|id| choice(project, id).label)
|
|
.unwrap_or_else(|| "Absent".into()),
|
|
drift(impact.drift),
|
|
if impact.unreviewed {
|
|
" · incomplete review"
|
|
} else {
|
|
""
|
|
},
|
|
impact.traces.len()
|
|
))
|
|
.size(12),
|
|
);
|
|
}
|
|
}
|
|
Err(error) => content = content.push(text(error).size(12)),
|
|
}
|
|
}
|
|
scrollable(content).width(Fill).height(305).into()
|
|
}
|
|
|
|
fn impacts_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
|
let mut content = column![text("Additional prepared changes").size(16), text("Accept each exact change required by model preparation. Acceptance never overrides a selected Keep or Take. Editing a decision clears these approvals.").size(12)].spacing(10);
|
|
if let Some(plan) = &self.plan {
|
|
if plan.impacts.is_empty() {
|
|
content =
|
|
content.push(text("No additional prepared changes in this preview.").size(12));
|
|
}
|
|
for (index, impact) in plan.impacts.iter().enumerate() {
|
|
let before = impact
|
|
.before
|
|
.as_ref()
|
|
.map(|r| serde_json::to_value(r).expect("Typed impact"));
|
|
let after = impact
|
|
.after
|
|
.as_ref()
|
|
.map(|r| serde_json::to_value(r).expect("Typed impact"));
|
|
let ids = project.identities().into_iter().map(|(id, _)| id).collect();
|
|
content = content.push(
|
|
container(
|
|
column![
|
|
checkbox(self.accepted_impacts.contains(impact))
|
|
.label(format!("Accept · {}", project.named(impact.object)))
|
|
.text_size(12)
|
|
.on_toggle(move |accepted| event(Event::AcceptImpact(
|
|
index, accepted
|
|
))),
|
|
text("Before").size(11).color(style::MUTED),
|
|
text(
|
|
before
|
|
.as_ref()
|
|
.map(|v| formatted(project, v, &ids))
|
|
.unwrap_or_else(|| "Absent · record creation".into())
|
|
)
|
|
.size(12),
|
|
text("After").size(11).color(style::MUTED),
|
|
text(
|
|
after
|
|
.as_ref()
|
|
.map(|v| formatted(project, v, &ids))
|
|
.unwrap_or_else(|| "Absent · record deletion".into())
|
|
)
|
|
.size(12),
|
|
]
|
|
.spacing(6),
|
|
)
|
|
.padding(10)
|
|
.style(style::subtle),
|
|
);
|
|
}
|
|
}
|
|
scrollable(content).height(305).into()
|
|
}
|
|
}
|