Files
ARCADIA-Studio/crates/oa-desktop/src/semantic_review/batch.rs
T

836 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)) => {
let event = match *event {
Event::Editor(event) => self.address_event(event),
other => other,
};
Message::Workflow(crate::workflow::Event::semantic(event))
}
other => other,
});
crate::forms::reference_chooser::trigger::modal(view)
}
}