832 lines
34 KiB
Rust
832 lines
34 KiB
Rust
//! Session orchestration. Row focus never changes the identity of authored drafts.
|
|
use super::*;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::{
|
|
ops::{Deref, DerefMut},
|
|
sync::Arc,
|
|
};
|
|
pub(super) const MAX_REVIEW_ROWS: usize = 256;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct RowAddress {
|
|
pub source: Id,
|
|
pub target: Id,
|
|
pub trace: Id,
|
|
}
|
|
impl RowAddress {
|
|
pub(super) fn from_trace(trace: &Trace, direction: TransitionDirection) -> Self {
|
|
let (source, target) = match direction {
|
|
TransitionDirection::OaToSa => (trace.oa, trace.sa),
|
|
TransitionDirection::SaToOa => (trace.sa, trace.oa),
|
|
};
|
|
Self {
|
|
source,
|
|
target,
|
|
trace: trace.id,
|
|
}
|
|
}
|
|
pub(super) fn direction(self, project: &Project) -> Result<TransitionDirection, String> {
|
|
let trace = project
|
|
.traces
|
|
.get(&self.trace)
|
|
.ok_or("Review trace no longer exists.")?;
|
|
if self.source == trace.oa && self.target == trace.sa {
|
|
Ok(TransitionDirection::OaToSa)
|
|
} else if self.source == trace.sa && self.target == trace.oa {
|
|
Ok(TransitionDirection::SaToOa)
|
|
} else {
|
|
Err("Review row and its exact trace correspondence disagree.".into())
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum Event {
|
|
AcceptImpact(Box<SemanticImpact>, bool),
|
|
ChooseTrace(Choice),
|
|
OpenTrace,
|
|
Focus(RowAddress),
|
|
Include(RowAddress, bool),
|
|
Direction(TransitionDirection),
|
|
Row {
|
|
address: Option<RowAddress>,
|
|
facet: Option<FacetSelector>,
|
|
event: RowEvent,
|
|
},
|
|
// Intermediate row-view message, always addressed by State::view before reaching App.
|
|
Editor(RowEvent),
|
|
}
|
|
#[cfg(test)]
|
|
impl From<RowEvent> for Event {
|
|
fn from(event: RowEvent) -> Self {
|
|
Self::Editor(event)
|
|
}
|
|
}
|
|
|
|
pub struct State {
|
|
pub(super) base: Arc<Project>,
|
|
pub(super) base_identity: Id,
|
|
pub(super) base_fingerprint: String,
|
|
pub direction: TransitionDirection,
|
|
pub references: BTreeMap<Id, Id>,
|
|
pub plan: Option<SemanticTransitionPlan>,
|
|
pub proposed: Option<Project>,
|
|
pub error: Option<String>,
|
|
pub(super) accepted_impacts: Vec<SemanticImpact>,
|
|
pub(super) active: ReviewRow,
|
|
pub(super) active_address: Option<RowAddress>,
|
|
pub(super) bank: BTreeMap<RowAddress, ReviewRow>,
|
|
pub(super) order: Vec<RowAddress>,
|
|
pub(super) included: BTreeSet<RowAddress>,
|
|
pub(super) add_trace: Option<Choice>,
|
|
pub(super) notice: Option<String>,
|
|
}
|
|
// The active editor remains accessible to existing local property tests/helpers.
|
|
// Session planning/error/reference fields deliberately take precedence over row context.
|
|
impl Deref for State {
|
|
type Target = ReviewRow;
|
|
fn deref(&self) -> &ReviewRow {
|
|
&self.active
|
|
}
|
|
}
|
|
impl DerefMut for State {
|
|
fn deref_mut(&mut self) -> &mut ReviewRow {
|
|
&mut self.active
|
|
}
|
|
}
|
|
|
|
impl State {
|
|
pub fn new(project: &Project, selected: Id) -> Result<Self, String> {
|
|
let active = ReviewRow::new(project, selected)?;
|
|
let direction = active.direction;
|
|
let active_address = active
|
|
.trace
|
|
.and_then(|id| project.traces.get(&id))
|
|
.map(|trace| RowAddress::from_trace(trace, direction));
|
|
let mut state = Self {
|
|
base: Arc::new(project.clone()),
|
|
base_identity: project.id,
|
|
base_fingerprint: project_fingerprint(project),
|
|
direction,
|
|
references: BTreeMap::new(),
|
|
plan: None,
|
|
proposed: None,
|
|
error: None,
|
|
accepted_impacts: vec![],
|
|
active,
|
|
active_address,
|
|
bank: BTreeMap::new(),
|
|
order: active_address.into_iter().collect(),
|
|
included: active_address.into_iter().collect(),
|
|
add_trace: None,
|
|
notice: None,
|
|
};
|
|
state.refresh(project);
|
|
Ok(state)
|
|
}
|
|
pub fn target(&self, _project: &Project) -> Option<Id> {
|
|
self.active_address.map(|address| address.target)
|
|
}
|
|
pub(super) fn row(&self, address: RowAddress) -> Option<&ReviewRow> {
|
|
if self.active_address == Some(address) {
|
|
Some(&self.active)
|
|
} else {
|
|
self.bank.get(&address)
|
|
}
|
|
}
|
|
fn row_mut(&mut self, address: Option<RowAddress>) -> Option<&mut ReviewRow> {
|
|
if self.active_address == address {
|
|
Some(&mut self.active)
|
|
} else {
|
|
address.and_then(|address| self.bank.get_mut(&address))
|
|
}
|
|
}
|
|
pub(super) fn included_addresses(&self) -> Vec<RowAddress> {
|
|
self.included
|
|
.iter()
|
|
.filter(|address| address.direction(&self.base).ok() == Some(self.direction))
|
|
.copied()
|
|
.collect()
|
|
}
|
|
pub(super) fn frozen(&self, project: &Project) -> Result<(), String> {
|
|
if project.id != self.base_identity || project_fingerprint(project) != self.base_fingerprint
|
|
{
|
|
Err("This review is stale: the accepted model changed after this review began. Drafts are retained; reopen a review against the new model before applying.".into())
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
pub fn address_event(&self, event: RowEvent) -> Event {
|
|
if let RowEvent::AcceptImpact(index, accepted) = &event
|
|
&& let Some(impact) = self.plan.as_ref().and_then(|plan| plan.impacts.get(*index))
|
|
{
|
|
return Event::AcceptImpact(Box::new(impact.clone()), *accepted);
|
|
}
|
|
Event::Row {
|
|
address: self.active_address,
|
|
facet: self.active.selected.clone(),
|
|
event,
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
pub(super) fn stage_row_references(&mut self, facet: &FacetSelector) -> Result<(), String> {
|
|
self.active.stage_row_references(facet)?;
|
|
self.references.extend(self.active.references.clone());
|
|
Ok(())
|
|
}
|
|
pub(super) fn canonical_references(&self) -> Result<BTreeMap<Id, Id>, String> {
|
|
let mut references = self.references.clone();
|
|
let mut sources = BTreeSet::new();
|
|
for address in self.included_addresses() {
|
|
if !sources.insert(address.source) {
|
|
return Err("Only one trace row per source can be included. Exclude its alternative history explicitly.".into());
|
|
}
|
|
let trace = &self.base.traces[&address.trace];
|
|
if references
|
|
.insert(trace.oa, trace.sa)
|
|
.is_some_and(|old| old != trace.sa)
|
|
{
|
|
return Err(format!(
|
|
"Transition mapping disagrees with frozen correspondence: {}. Reverse convergence of multiple System sources on one Operational target is not representable by the core's canonical reference map. Exclude a row or choose a distinct target; all drafts are retained.",
|
|
self.base.named(trace.oa)
|
|
));
|
|
}
|
|
}
|
|
Ok(references)
|
|
}
|
|
fn open(&mut self, trace_id: Id, replace_active: bool) -> Result<(), String> {
|
|
let trace = self
|
|
.base
|
|
.traces
|
|
.get(&trace_id)
|
|
.ok_or("Choose an existing trace.")?;
|
|
let address = RowAddress::from_trace(trace, self.direction);
|
|
if self.active_address == Some(address) {
|
|
return Ok(());
|
|
}
|
|
let old = self.active_address;
|
|
let transfer = replace_active && old.is_some_and(|old| self.included.contains(&old));
|
|
let existed = self.bank.contains_key(&address);
|
|
if !existed && self.order.len() >= MAX_REVIEW_ROWS {
|
|
return Err("Semantic review exceeds the supported 256 retained rows.".into());
|
|
}
|
|
let mut next = if let Some(row) = self.bank.remove(&address) {
|
|
row
|
|
} else {
|
|
let mut row = ReviewRow::new(&self.base, trace_id)?;
|
|
row.direction = self.direction;
|
|
row.load_trace(&self.base);
|
|
row
|
|
};
|
|
// References are a derived editor context; the session retains authored pairs.
|
|
next.references = self.references.clone();
|
|
let previous = std::mem::replace(&mut self.active, next);
|
|
if let Some(old) = old {
|
|
self.bank.insert(old, previous);
|
|
}
|
|
self.active_address = Some(address);
|
|
if !self.order.contains(&address) {
|
|
self.order.push(address);
|
|
}
|
|
if transfer && let Some(old) = old {
|
|
self.included.remove(&old);
|
|
}
|
|
let occupied = self
|
|
.included_addresses()
|
|
.iter()
|
|
.any(|other| other.source == address.source && *other != address);
|
|
if !occupied && (transfer || !existed) {
|
|
self.included.insert(address);
|
|
}
|
|
if occupied {
|
|
self.notice = Some("This source already has an included history. The alternative draft is open but excluded; exclude the other row before including this one.".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
fn change_direction(
|
|
&mut self,
|
|
direction: TransitionDirection,
|
|
trace: Option<Id>,
|
|
) -> Result<(), String> {
|
|
let previous = self.direction;
|
|
self.direction = direction;
|
|
if let Some(trace) = trace {
|
|
if let Err(error) = self.open(trace, false) {
|
|
self.direction = previous;
|
|
return Err(error);
|
|
}
|
|
} else {
|
|
self.active.direction = direction;
|
|
}
|
|
Ok(())
|
|
}
|
|
fn focus(&mut self, address: RowAddress) -> Result<(), String> {
|
|
if self.active_address == Some(address) {
|
|
return Ok(());
|
|
}
|
|
let next = self
|
|
.bank
|
|
.remove(&address)
|
|
.ok_or("That review draft is no longer available.")?;
|
|
let previous = std::mem::replace(&mut self.active, next);
|
|
if let Some(old) = self.active_address {
|
|
self.bank.insert(old, previous);
|
|
}
|
|
self.active_address = Some(address);
|
|
self.direction = address.direction(&self.base)?;
|
|
Ok(())
|
|
}
|
|
fn include(&mut self, address: RowAddress, include: bool) -> Result<(), String> {
|
|
if self.row(address).is_none() {
|
|
return Err("That review row is unavailable.".into());
|
|
}
|
|
if include {
|
|
let direction = address.direction(&self.base)?;
|
|
if self.included.iter().any(|other| {
|
|
other != &address
|
|
&& other.source == address.source
|
|
&& other.direction(&self.base).ok() == Some(direction)
|
|
}) {
|
|
return Err("Only one included trace per source is allowed. Exclude the alternative history first; both drafts are retained.".into());
|
|
}
|
|
self.included.insert(address);
|
|
} else {
|
|
self.included.remove(&address);
|
|
}
|
|
Ok(())
|
|
}
|
|
pub(crate) fn reference_form(&self, project: &Project) -> Option<&Draft> {
|
|
let facet = self.active.selected.as_ref()?;
|
|
if self.frozen(project).is_err()
|
|
|| self.active.tab != Tab::Facts
|
|
|| self.active.decisions.get(facet) != Some(&SemanticDecision::Candidate)
|
|
{
|
|
return None;
|
|
}
|
|
self.active.candidate_draft(facet)
|
|
}
|
|
pub(crate) fn reference_draft(
|
|
&self,
|
|
project: &Project,
|
|
address: Option<RowAddress>,
|
|
facet: &FacetSelector,
|
|
path: &str,
|
|
) -> Option<&Draft> {
|
|
if self.frozen(project).is_err()
|
|
|| self.active_address != address
|
|
|| self.active.tab != Tab::Facts
|
|
{
|
|
return None;
|
|
}
|
|
self.active.reference_draft(project, facet, path)
|
|
}
|
|
pub(crate) fn set_reference(
|
|
&mut self,
|
|
project: &Project,
|
|
address: Option<RowAddress>,
|
|
facet: &FacetSelector,
|
|
intent: crate::forms::reference_chooser::adapter::Set,
|
|
) -> Result<(), String> {
|
|
self.reference_draft(project, address, facet, &intent.context.path)
|
|
.ok_or("The originating review row or property is no longer active.")?
|
|
.reference_value(project, &intent)?;
|
|
self.update(
|
|
project,
|
|
Event::Row {
|
|
address,
|
|
facet: Some(facet.clone()),
|
|
event: RowEvent::ReferenceSet(intent),
|
|
},
|
|
);
|
|
Ok(())
|
|
}
|
|
pub fn update(&mut self, project: &Project, event: impl Into<Event>) {
|
|
let event = event.into();
|
|
#[cfg(test)]
|
|
let event = match event {
|
|
Event::Editor(event) => self.address_event(event),
|
|
other => other,
|
|
};
|
|
match &event {
|
|
Event::Row {
|
|
event: RowEvent::ReferenceOpen(_) | RowEvent::ReferenceEvent(_),
|
|
..
|
|
}
|
|
| Event::Editor(RowEvent::ReferenceOpen(_) | RowEvent::ReferenceEvent(_)) => return,
|
|
Event::Row {
|
|
address,
|
|
facet,
|
|
event: RowEvent::ReferenceSet(intent),
|
|
} => {
|
|
let valid = facet
|
|
.as_ref()
|
|
.and_then(|facet| {
|
|
self.reference_draft(project, *address, facet, &intent.context.path)
|
|
})
|
|
.is_some_and(|draft| draft.reference_value(project, intent).is_ok());
|
|
if !valid {
|
|
return;
|
|
}
|
|
}
|
|
Event::Editor(RowEvent::ReferenceSet(_)) => return,
|
|
_ => {}
|
|
}
|
|
if let Err(error) = self.frozen(project) {
|
|
// Withdrawing a pending reset is authored intent only. Retain it while
|
|
// keeping the original accepted base and disabling every stale plan.
|
|
if let Event::Row {
|
|
address,
|
|
event: RowEvent::Reset(facet, false),
|
|
..
|
|
} = &event
|
|
&& let Some(row) = self.row_mut(*address)
|
|
&& row.resets.remove(facet)
|
|
{
|
|
self.accepted_impacts.clear();
|
|
}
|
|
self.error = Some(error.clone());
|
|
self.active.error = Some(error);
|
|
self.plan = None;
|
|
self.proposed = None;
|
|
return;
|
|
}
|
|
let base = Arc::clone(&self.base);
|
|
let browser_only = matches!(
|
|
&event,
|
|
Event::Row {
|
|
event: RowEvent::ValueBrowse(_, _, _) | RowEvent::ReferenceBrowse(_, _, _),
|
|
..
|
|
}
|
|
);
|
|
self.notice = None;
|
|
let mut authored = true;
|
|
let result = match event {
|
|
Event::AcceptImpact(impact, accepted) => {
|
|
authored = false;
|
|
if !self
|
|
.plan
|
|
.as_ref()
|
|
.is_some_and(|plan| plan.impacts.contains(&impact))
|
|
{
|
|
Err("This exact prepared change is no longer in the preview. Review the current changes before accepting.".into())
|
|
} else {
|
|
self.accepted_impacts
|
|
.retain(|old| old.object != impact.object);
|
|
if accepted {
|
|
self.accepted_impacts.push(*impact);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
Event::ChooseTrace(choice) => {
|
|
self.add_trace = Some(choice);
|
|
authored = false;
|
|
Ok(())
|
|
}
|
|
Event::OpenTrace => self
|
|
.add_trace
|
|
.as_ref()
|
|
.map(|choice| choice.id)
|
|
.ok_or_else(|| "Choose a trace to add or reopen.".to_owned())
|
|
.and_then(|id| self.open(id, false)),
|
|
Event::Focus(address) => {
|
|
authored = false;
|
|
self.focus(address)
|
|
}
|
|
Event::Include(address, include) => self.include(address, include),
|
|
Event::Direction(direction) => self.change_direction(direction, self.active.trace),
|
|
Event::Editor(_) => {
|
|
Err("Unaddressed editor message was rejected; choose its review row again.".into())
|
|
}
|
|
Event::Row {
|
|
address,
|
|
facet,
|
|
event,
|
|
} => {
|
|
if self.row_mut(address).is_none() {
|
|
Err("The originating review draft is no longer available.".into())
|
|
} else {
|
|
match event {
|
|
#[cfg(test)]
|
|
RowEvent::Trace(choice) => {
|
|
if self.active_address != address {
|
|
Err("A delayed trace selection belongs to another row; reopen that row first.".into())
|
|
} else {
|
|
self.open(choice.id, true)
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
RowEvent::Direction(direction) => {
|
|
let trace = self.row_mut(address).and_then(|row| row.trace);
|
|
self.change_direction(direction, trace)
|
|
}
|
|
RowEvent::AddReference => {
|
|
let row = self.row_mut(address).expect("Checked row");
|
|
match (&row.reference_oa, &row.reference_sa) {
|
|
(Some(oa), Some(sa)) => {
|
|
let (oa, sa) = (oa.id, sa.id);
|
|
self.references.insert(oa, sa);
|
|
Ok(())
|
|
}
|
|
_ => {
|
|
Err("Choose both an Operational and a System reference.".into())
|
|
}
|
|
}
|
|
}
|
|
RowEvent::RestoreReviewedReferences => {
|
|
let pairs = history::reviewed_pairs(
|
|
self.row_mut(address).expect("Checked row"),
|
|
&base,
|
|
);
|
|
pairs.and_then(|pairs| {
|
|
let mut merged = self.canonical_references()?;
|
|
for (oa, sa) in &pairs {
|
|
if merged.insert(*oa, *sa).is_some_and(|old| old != *sa) {
|
|
return Err("A reviewed pair conflicts with the current explicit correspondence. No pairs were restored.".into());
|
|
}
|
|
}
|
|
if self.direction == TransitionDirection::SaToOa && merged.values().collect::<BTreeSet<_>>().len() != merged.len() {
|
|
return Err("Reviewed pairs would make reverse correspondence ambiguous. No pairs were restored.".into());
|
|
}
|
|
self.notice = Some(format!("Restored {} exact historical key pairs. Order observations alone do not establish correspondence.", pairs.len()));
|
|
self.references.extend(pairs);
|
|
Ok(())
|
|
})
|
|
}
|
|
RowEvent::RemoveReference(id) => {
|
|
self.references.remove(&id);
|
|
Ok(())
|
|
}
|
|
RowEvent::AcceptImpact(_, _) => {
|
|
authored = false;
|
|
Err("The prepared change could not be identified exactly; refresh its preview.".into())
|
|
}
|
|
event => {
|
|
authored = !matches!(
|
|
event,
|
|
RowEvent::Select(_)
|
|
| RowEvent::Tab(_)
|
|
| RowEvent::ShowUnchanged(_)
|
|
| RowEvent::ReferenceOa(_)
|
|
| RowEvent::ReferenceSa(_)
|
|
| RowEvent::ReferenceSearch(_)
|
|
| RowEvent::Attachment(
|
|
attachments::Event::Family(_)
|
|
| attachments::Event::Pick(_, _)
|
|
| attachments::Event::Search(_)
|
|
)
|
|
| RowEvent::Artifact(
|
|
artifacts::Event::Pick(_, _) | artifacts::Event::Search(_)
|
|
)
|
|
| RowEvent::MemberFamily(_)
|
|
| RowEvent::MemberOa(_)
|
|
| RowEvent::MemberSa(_)
|
|
| RowEvent::Literal(_)
|
|
| RowEvent::LiteralSide(_, _)
|
|
| RowEvent::EditPair(_)
|
|
| RowEvent::AnnotationAddress(_, _)
|
|
| RowEvent::AnnotationBrowse(_, _, _)
|
|
| RowEvent::ValueBrowse(_, _, _)
|
|
| RowEvent::ReferenceBrowse(_, _, _)
|
|
| RowEvent::Preview
|
|
);
|
|
let scoped_edit = matches!(
|
|
event,
|
|
RowEvent::ReferenceSet(_)
|
|
| RowEvent::Input(_, _)
|
|
| RowEvent::Text(_, _)
|
|
| RowEvent::Shape(_, _)
|
|
| RowEvent::Variant(_, _)
|
|
| RowEvent::Array(_, _, _)
|
|
| RowEvent::Append(_, _)
|
|
| RowEvent::ValuePath(_, _)
|
|
| RowEvent::ValueBrowse(_, _, _)
|
|
| RowEvent::ReferenceBrowse(_, _, _)
|
|
| RowEvent::OrderMove(_, _, _)
|
|
| RowEvent::OrderReconcile(_)
|
|
);
|
|
let stage = matches!(
|
|
event,
|
|
RowEvent::StageMember
|
|
| RowEvent::Attachment(attachments::Event::Stage)
|
|
| RowEvent::Artifact(artifacts::Event::Stage)
|
|
);
|
|
let row = self.row_mut(address).expect("Checked row");
|
|
let prior_references = stage.then(|| row.references.clone());
|
|
let selected = row.selected.clone();
|
|
let previous_error = row.error.take();
|
|
if scoped_edit {
|
|
row.selected = facet;
|
|
}
|
|
row.update(&base, event);
|
|
if scoped_edit {
|
|
row.selected = selected;
|
|
}
|
|
let result = row.error.clone().map_or(Ok(()), Err);
|
|
if browser_only && result.is_ok() {
|
|
row.error = previous_error;
|
|
}
|
|
if let Some(prior) = prior_references
|
|
&& result.is_ok()
|
|
{
|
|
let changed: Vec<_> = row
|
|
.references
|
|
.iter()
|
|
.filter(|(id, value)| prior.get(id) != Some(value))
|
|
.map(|(id, value)| (*id, *value))
|
|
.collect();
|
|
self.references.extend(changed);
|
|
}
|
|
result
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
if authored {
|
|
self.accepted_impacts.clear();
|
|
}
|
|
if !browser_only {
|
|
self.refresh(project);
|
|
}
|
|
if let Err(error) = result {
|
|
self.error = Some(error.clone());
|
|
self.active.error = Some(error);
|
|
}
|
|
}
|
|
pub(super) fn request(&self, _project: &Project) -> Result<SemanticTransitionRequest, String> {
|
|
compose::request(self)
|
|
}
|
|
pub fn refresh(&mut self, project: &Project) {
|
|
self.plan = None;
|
|
self.proposed = None;
|
|
self.error = None;
|
|
if let Err(error) = self.frozen(project) {
|
|
self.error = Some(error);
|
|
return;
|
|
}
|
|
let base = Arc::clone(&self.base);
|
|
let references = self.canonical_references();
|
|
let context = references.as_ref().unwrap_or(&self.references).clone();
|
|
for row in self
|
|
.bank
|
|
.values_mut()
|
|
.chain(std::iter::once(&mut self.active))
|
|
{
|
|
row.references = context.clone();
|
|
if let Some(trace) = row.trace.and_then(|id| base.traces.get(&id)) {
|
|
row.references.insert(trace.oa, trace.sa);
|
|
}
|
|
row.refresh(&base);
|
|
}
|
|
let request = references.and_then(|_| self.request(&base));
|
|
match request.and_then(|request| {
|
|
plan_semantic_transition(&base, request).map_err(|error| error.to_string())
|
|
}) {
|
|
Ok(plan) => {
|
|
if plan.conflicts.is_empty() {
|
|
match prepare_semantic_transition(&base, &plan) {
|
|
Ok(prepared) => self.proposed = Some(prepared.project),
|
|
Err(error) => self.error = Some(error.to_string()),
|
|
}
|
|
}
|
|
self.plan = Some(plan);
|
|
}
|
|
Err(error) => self.error = Some(error),
|
|
}
|
|
self.active.plan = self.plan.clone();
|
|
self.active.proposed = self.proposed.clone();
|
|
self.active.accepted_impacts = self.accepted_impacts.clone();
|
|
self.active.error = self.error.clone().or_else(|| {
|
|
self.active
|
|
.combined_value(&base, false)
|
|
.err()
|
|
.map(|error| format!("This row's retained draft: {error}"))
|
|
});
|
|
if let Some(value) = self
|
|
.proposed
|
|
.as_ref()
|
|
.and_then(|project| {
|
|
self.target(project)
|
|
.and_then(|id| Draft::from_project(project, id))
|
|
})
|
|
.map(|draft| draft.value)
|
|
{
|
|
if let Some(draft) = &mut self.active.draft {
|
|
draft.set_context(Some(value.clone()));
|
|
}
|
|
for draft in self.active.group_drafts.values_mut() {
|
|
draft.set_context(Some(value.clone()));
|
|
}
|
|
}
|
|
}
|
|
pub fn command(&mut self, project: &Project) -> Result<Command, String> {
|
|
self.frozen(project)?;
|
|
if !self.has_decisions() {
|
|
return Err("Choose at least one included property or exact history reset.".into());
|
|
}
|
|
if let Some(error) = &self.error {
|
|
return Err(error.clone());
|
|
}
|
|
let plan = self
|
|
.plan
|
|
.as_ref()
|
|
.ok_or("Refresh the combined review preview before applying.")?;
|
|
prepare_semantic_transition(project, plan).map_err(|error| error.to_string())?;
|
|
Ok(Command::Batch(plan.commands.clone()))
|
|
}
|
|
fn has_decisions(&self) -> bool {
|
|
self.included_addresses()
|
|
.into_iter()
|
|
.filter_map(|address| self.row(address))
|
|
.any(|row| !row.decisions.is_empty() || !row.resets.is_empty())
|
|
}
|
|
pub fn can_apply(&self) -> bool {
|
|
self.has_decisions()
|
|
&& self.error.is_none()
|
|
&& self.plan.as_ref().is_some_and(|plan| {
|
|
plan.conflicts.is_empty() && plan.prepared_fingerprint.is_some()
|
|
})
|
|
}
|
|
pub fn view<'a>(&'a self, _project: &'a Project) -> iced::Element<'a, Message> {
|
|
use iced::{
|
|
Fill,
|
|
widget::{button, checkbox, column, pick_list, row, text},
|
|
};
|
|
let message = |event| Message::Workflow(crate::workflow::Event::Semantic(event));
|
|
let mut available: Vec<_> = self
|
|
.base
|
|
.traces
|
|
.values()
|
|
.map(|trace| Choice {
|
|
id: trace.id,
|
|
label: format!(
|
|
"{} · {} ↔ {}",
|
|
trace.name,
|
|
self.base.named(trace.oa),
|
|
self.base.named(trace.sa)
|
|
),
|
|
})
|
|
.collect();
|
|
available.sort_by(|a, b| a.label.cmp(&b.label));
|
|
let choices: Vec<_> = self
|
|
.order
|
|
.iter()
|
|
.filter(|address| address.direction(&self.base).ok() == Some(self.direction))
|
|
.map(|address| Choice {
|
|
id: address.trace,
|
|
label: format!(
|
|
"{} · {} → {} · {}",
|
|
if self.included.contains(address) {
|
|
"Included"
|
|
} else {
|
|
"Excluded"
|
|
},
|
|
self.base.named(address.source),
|
|
self.base.named(address.target),
|
|
self.base.named(address.trace)
|
|
),
|
|
})
|
|
.collect();
|
|
let selected = choices
|
|
.iter()
|
|
.find(|choice| {
|
|
self.active_address
|
|
.is_some_and(|address| address.trace == choice.id)
|
|
})
|
|
.cloned();
|
|
let direction = self.direction;
|
|
let base = &self.base;
|
|
let (source_layer, target_layer) = if direction == TransitionDirection::OaToSa {
|
|
("Operational", "System")
|
|
} else {
|
|
("System", "Operational")
|
|
};
|
|
let mut header = column![
|
|
text("Review counterpart changes").size(22),
|
|
text("Compare existing linked OA and SA objects, then choose which values to copy. This is for updating counterparts after editing; creating a counterpart is a separate action.").size(12),
|
|
row![
|
|
text(format!("Copy selected changes: {source_layer} → {target_layer}")).size(13),
|
|
button(text("Reverse direction").size(12)).padding(7).style(crate::style::nav).on_press(message(Event::Direction(if direction == TransitionDirection::OaToSa { TransitionDirection::SaToOa } else { TransitionDirection::OaToSa }))),
|
|
].spacing(10),
|
|
].spacing(6);
|
|
if available.len() > 1 || self.active_address.is_none() {
|
|
header = header.push(
|
|
row![
|
|
pick_list(available, self.add_trace.clone(), move |choice| message(
|
|
Event::ChooseTrace(choice)
|
|
))
|
|
.placeholder("Choose a linked object to review…")
|
|
.width(Fill)
|
|
.text_size(12)
|
|
.padding(7),
|
|
button(text("Review object").size(12))
|
|
.padding(7)
|
|
.style(crate::style::nav)
|
|
.on_press(message(Event::OpenTrace)),
|
|
]
|
|
.spacing(7),
|
|
);
|
|
}
|
|
let active: iced::Element<'a, Message> = if choices.len() > 1 {
|
|
pick_list(choices, selected, move |choice| {
|
|
message(Event::Focus(RowAddress::from_trace(
|
|
&base.traces[&choice.id],
|
|
direction,
|
|
)))
|
|
})
|
|
.placeholder("Choose an object already in this review")
|
|
.width(Fill)
|
|
.text_size(12)
|
|
.padding(7)
|
|
.into()
|
|
} else if let Some(address) = self.active_address {
|
|
text(format!(
|
|
"{} → {}",
|
|
super::view::choice(base, address.source).label,
|
|
super::view::choice(base, address.target).label
|
|
))
|
|
.size(13)
|
|
.width(Fill)
|
|
.into()
|
|
} else {
|
|
text("Select a linked object above to compare its values.")
|
|
.size(13)
|
|
.width(Fill)
|
|
.into()
|
|
};
|
|
header = header.push(
|
|
row![
|
|
active,
|
|
checkbox(
|
|
self.active_address
|
|
.is_some_and(|address| self.included.contains(&address))
|
|
)
|
|
.label("Include this object")
|
|
.on_toggle_maybe(
|
|
self.active_address
|
|
.map(|address| move |included| message(Event::Include(address, included)))
|
|
),
|
|
]
|
|
.spacing(10),
|
|
);
|
|
if self.included_addresses().len() > 1 {
|
|
header = header.push(text(format!("{} objects included. Apply saves their selected decisions together; switching objects keeps your drafts.", self.included_addresses().len())).size(11).color(crate::style::MUTED));
|
|
}
|
|
if let Some(notice) = &self.notice {
|
|
header = header.push(text(notice).size(11));
|
|
}
|
|
let view = self
|
|
.active
|
|
.view(&self.base, header.into(), self.can_apply())
|
|
.map(move |message| match message {
|
|
Message::Workflow(crate::workflow::Event::Semantic(Event::Editor(event))) => {
|
|
Message::Workflow(crate::workflow::Event::Semantic(self.address_event(event)))
|
|
}
|
|
other => other,
|
|
});
|
|
crate::forms::reference_chooser::trigger::modal(view)
|
|
}
|
|
}
|